mirror of
https://github.com/timeshiftsauce/CeruMusic.git
synced 2025-11-25 11:29:42 +08:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
34fb0f7c2f | ||
|
|
191ba1e199 | ||
|
|
324e81c0dc |
@@ -33,7 +33,8 @@ export default defineConfig({
|
||||
]
|
||||
},
|
||||
{ text: '软件设计文档', link: '/guide/design' },
|
||||
{ text: '更新日志', link: '/guide/updateLog' }
|
||||
{ text: '更新日志', link: '/guide/updateLog' },
|
||||
{ text: '更新计划', link: '/guide/update'}
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
BIN
docs/guide/assets/image-20250921130607735.png
Normal file
BIN
docs/guide/assets/image-20250921130607735.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 606 KiB |
12
docs/guide/update.md
Normal file
12
docs/guide/update.md
Normal file
@@ -0,0 +1,12 @@
|
||||
# 我的-更新计划-欢迎issue
|
||||
|
||||
- [ ] 搜索框可以弄对应的音乐源的排行榜 搜索联想
|
||||
- [ ] 导航上面这几个按钮可以稍微优化一下
|
||||
- [ ] 大熊好暖: 09-21 12:52:25 在搜索歌曲出来后 大熊好暖: 09-21 12:52:34 一般都是双击播放 大熊好暖: 09-21 12:54:16 这个是双击加入播放列表 可以优化 或者可以把这个双击做一个选项,让人选择
|
||||
- [ ] 播放页 样式模板选择 歌词背景律动很有沉浸感,但是黑胶唱片的专辑样式不是很喜欢,希望增加一个选项如图二一样只有圆形专辑封面
|
||||
- [x] 点击搜索框的 源图标实现快速切换
|
||||
- [ ] ai功能完善
|
||||
- [ ] 支持歌词隐藏
|
||||
- [x] 兼容多平台歌单导入
|
||||
- [ ] 软件能不能记住上次打开的窗口大小,每次都要手动拉
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "ceru-music",
|
||||
"version": "1.3.2",
|
||||
"version": "1.3.4",
|
||||
"description": "一款简洁优雅的音乐播放器",
|
||||
"main": "./out/main/index.js",
|
||||
"author": "sqj,wldss,star",
|
||||
@@ -8,7 +8,7 @@
|
||||
"homepage": "https://ceru.docs.shiqianjiang.cn",
|
||||
"scripts": {
|
||||
"format": "prettier --write .",
|
||||
"lint": "eslint --cache . --fix",
|
||||
"lint": "eslint --cache . --fix && yarn typecheck",
|
||||
"typecheck:node": "tsc --noEmit -p tsconfig.node.json --composite false",
|
||||
"typecheck:web": "vue-tsc --noEmit -p tsconfig.web.json --composite false",
|
||||
"typecheck": "yarn run typecheck:node && yarn run typecheck:web",
|
||||
|
||||
@@ -85,6 +85,10 @@ function main(source: string) {
|
||||
},
|
||||
|
||||
async getPlaylistDetail({ id, page }: GetSongListDetailsArg) {
|
||||
// 酷狗音乐特殊处理:直接调用getUserListDetail
|
||||
if (source === 'kg' && /https?:\/\//.test(id)) {
|
||||
return (await Api.songList.getUserListDetail(id, page)) as PlaylistDetailResult
|
||||
}
|
||||
return (await Api.songList.getListDetail(id, page)) as PlaylistDetailResult
|
||||
},
|
||||
|
||||
|
||||
@@ -24,5 +24,4 @@ const kg = {
|
||||
return `https://www.kugou.com/song/#hash=${songInfo.hash}&album_id=${songInfo.albumId}`
|
||||
}
|
||||
}
|
||||
|
||||
export default kg
|
||||
|
||||
BIN
src/main/utils/musicSdk/tx/__pycache__/des.cpython-313.pyc
Normal file
BIN
src/main/utils/musicSdk/tx/__pycache__/des.cpython-313.pyc
Normal file
Binary file not shown.
@@ -21,5 +21,4 @@ const tx = {
|
||||
return `https://y.qq.com/n/yqq/song/${songInfo.songmid}.html`
|
||||
}
|
||||
}
|
||||
|
||||
export default tx
|
||||
|
||||
@@ -1,10 +1,29 @@
|
||||
import qrcDecrypt from './qrc-decrypt'
|
||||
import { httpFetch } from '../../request'
|
||||
import getMusicInfo from './musicInfo'
|
||||
|
||||
const songIdMap = new Map()
|
||||
const promises = new Map()
|
||||
const decode = qrcDecrypt()
|
||||
|
||||
export default {
|
||||
rxps: {
|
||||
info: /^{"/,
|
||||
lineTime: /^\[(\d+),\d+\]/,
|
||||
lineTime2: /^\[([\d:.]+)\]/,
|
||||
wordTime: /\(\d+,\d+,\d+\)/,
|
||||
wordTimeAll: /(\(\d+,\d+,\d+\))/g,
|
||||
timeLabelFixRxp: /(?:\.0+|0+)$/,
|
||||
},
|
||||
msFormat(timeMs) {
|
||||
if (Number.isNaN(timeMs)) return ''
|
||||
let ms = timeMs % 1000
|
||||
timeMs /= 1000
|
||||
let m = parseInt(timeMs / 60).toString().padStart(2, '0')
|
||||
timeMs %= 60
|
||||
let s = parseInt(timeMs).toString().padStart(2, '0')
|
||||
return `[${m}:${s}.${String(ms).padStart(3, '0')}]`
|
||||
},
|
||||
successCode: 0,
|
||||
async getSongId({ songId, songmid }) {
|
||||
if (songId) return songId
|
||||
@@ -17,6 +36,185 @@ export default {
|
||||
promises.delete(songmid)
|
||||
return info.songId
|
||||
},
|
||||
removeTag(str) {
|
||||
return str.replace(/^[\S\s]*?LyricContent="/, '').replace(/"\/>[\S\s]*?$/, '')
|
||||
},
|
||||
parseCeru(lrc) {
|
||||
lrc = lrc.trim()
|
||||
lrc = lrc.replace(/\r/g, '')
|
||||
if (!lrc) return { lyric: '', lxlyric: '' }
|
||||
const lines = lrc.split('\n')
|
||||
|
||||
const lxlrcLines = []
|
||||
const lrcLines = []
|
||||
|
||||
for (let line of lines) {
|
||||
line = line.trim()
|
||||
let result = this.rxps.lineTime.exec(line)
|
||||
if (!result) {
|
||||
if (line.startsWith('[offset')) {
|
||||
lxlrcLines.push(line)
|
||||
lrcLines.push(line)
|
||||
continue
|
||||
}
|
||||
if (this.rxps.lineTime2.test(line)) {
|
||||
// lxlrcLines.push(line)
|
||||
lrcLines.push(line)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
const startMsTime = parseInt(result[1])
|
||||
const startTimeStr = this.msFormat(startMsTime)
|
||||
if (!startTimeStr) continue
|
||||
|
||||
let words = line.replace(this.rxps.lineTime, '')
|
||||
|
||||
lrcLines.push(`${startTimeStr}${words.replace(this.rxps.wordTimeAll, '')}`)
|
||||
|
||||
let times = words.match(this.rxps.wordTimeAll)
|
||||
if (!times) continue
|
||||
|
||||
let currentStart = startMsTime
|
||||
const processedTimes = []
|
||||
|
||||
times.forEach(time => {
|
||||
const result = /\((\d+),(\d+),(\d+)\)/.exec(time)
|
||||
const duration = parseInt(result[2])
|
||||
processedTimes.push(`(${currentStart},${duration},0)`)
|
||||
currentStart += duration
|
||||
})
|
||||
|
||||
const wordArr = words.split(this.rxps.wordTime)
|
||||
const newWords = processedTimes.map((time, index) => `${time}${wordArr[index]}`).join('')
|
||||
lxlrcLines.push(`${startTimeStr}${newWords}`)
|
||||
}
|
||||
return {
|
||||
lyric: lrcLines.join('\n'),
|
||||
lxlyric: lxlrcLines.join('\n'),
|
||||
}
|
||||
},
|
||||
getIntv(interval) {
|
||||
if (!interval) return 0
|
||||
if (!interval.includes('.')) interval += '.0'
|
||||
let arr = interval.split(/:|\./)
|
||||
while (arr.length < 3) arr.unshift('0')
|
||||
const [m, s, ms] = arr
|
||||
return parseInt(m) * 3600000 + parseInt(s) * 1000 + parseInt(ms)
|
||||
},
|
||||
fixRlrcTimeTag(rlrc, lrc) {
|
||||
// console.log(lrc)
|
||||
// console.log(rlrc)
|
||||
const rlrcLines = rlrc.split('\n')
|
||||
let lrcLines = lrc.split('\n')
|
||||
// let temp = []
|
||||
let newLrc = []
|
||||
rlrcLines.forEach((line) => {
|
||||
const result = this.rxps.lineTime2.exec(line)
|
||||
if (!result) return
|
||||
const words = line.replace(this.rxps.lineTime2, '')
|
||||
if (!words.trim()) return
|
||||
const t1 = this.getIntv(result[1])
|
||||
|
||||
while (lrcLines.length) {
|
||||
const lrcLine = lrcLines.shift()
|
||||
const lrcLineResult = this.rxps.lineTime2.exec(lrcLine)
|
||||
if (!lrcLineResult) continue
|
||||
const t2 = this.getIntv(lrcLineResult[1])
|
||||
if (Math.abs(t1 - t2) < 100) {
|
||||
newLrc.push(line.replace(this.rxps.lineTime2, lrcLineResult[0]))
|
||||
break
|
||||
}
|
||||
// temp.push(line)
|
||||
}
|
||||
// lrcLines = [...temp, ...lrcLines]
|
||||
// temp = []
|
||||
})
|
||||
return newLrc.join('\n')
|
||||
},
|
||||
fixTlrcTimeTag(tlrc, lrc) {
|
||||
// console.log(lrc)
|
||||
// console.log(tlrc)
|
||||
const tlrcLines = tlrc.split('\n')
|
||||
let lrcLines = lrc.split('\n')
|
||||
// let temp = []
|
||||
let newLrc = []
|
||||
tlrcLines.forEach((line) => {
|
||||
const result = this.rxps.lineTime2.exec(line)
|
||||
if (!result) return
|
||||
const words = line.replace(this.rxps.lineTime2, '')
|
||||
if (!words.trim()) return
|
||||
let time = result[1]
|
||||
if (time.includes('.')) {
|
||||
time += ''.padStart(3 - time.split('.')[1].length, '0')
|
||||
}
|
||||
const t1 = this.getIntv(time)
|
||||
|
||||
while (lrcLines.length) {
|
||||
const lrcLine = lrcLines.shift()
|
||||
const lrcLineResult = this.rxps.lineTime2.exec(lrcLine)
|
||||
if (!lrcLineResult) continue
|
||||
const t2 = this.getIntv(lrcLineResult[1])
|
||||
if (Math.abs(t1 - t2) < 100) {
|
||||
newLrc.push(line.replace(this.rxps.lineTime2, lrcLineResult[0]))
|
||||
break
|
||||
}
|
||||
// temp.push(line)
|
||||
}
|
||||
// lrcLines = [...temp, ...lrcLines]
|
||||
// temp = []
|
||||
})
|
||||
return newLrc.join('\n')
|
||||
},
|
||||
parse(lrc, tlrc, rlrc) {
|
||||
const info = {
|
||||
lyric: '',
|
||||
tlyric: '',
|
||||
rlyric: '',
|
||||
crlyric: '',
|
||||
|
||||
}
|
||||
if (lrc) {
|
||||
let { lyric } = this.parseCeru(this.removeTag(lrc))
|
||||
console.log(lyric, lrc)
|
||||
info.lyric = lyric
|
||||
info.crlyric = lrc
|
||||
}
|
||||
if (rlrc) info.rlyric = this.fixRlrcTimeTag(this.parseRlyric(this.removeTag(rlrc)), info.lyric)
|
||||
if (tlrc) info.tlyric = this.fixTlrcTimeTag(tlrc, info.lyric)
|
||||
|
||||
return info
|
||||
},
|
||||
parseRlyric(lrc) {
|
||||
lrc = lrc.trim()
|
||||
lrc = lrc.replace(/\r/g, '')
|
||||
if (!lrc) return { lyric: '', lxlyric: '' }
|
||||
const lines = lrc.split('\n')
|
||||
|
||||
const lrcLines = []
|
||||
|
||||
for (let line of lines) {
|
||||
line = line.trim()
|
||||
let result = this.rxps.lineTime.exec(line)
|
||||
if (!result) continue
|
||||
|
||||
const startMsTime = parseInt(result[1])
|
||||
const startTimeStr = this.msFormat(startMsTime)
|
||||
if (!startTimeStr) continue
|
||||
|
||||
let words = line.replace(this.rxps.lineTime, '')
|
||||
|
||||
lrcLines.push(`${startTimeStr}${words.replace(this.rxps.wordTimeAll, '')}`)
|
||||
}
|
||||
return lrcLines.join('\n')
|
||||
},
|
||||
parseLyric(lrc, tlrc, rlrc) {
|
||||
return this.parse(
|
||||
decode(lrc),
|
||||
decode(tlrc),
|
||||
decode(rlrc)
|
||||
)
|
||||
},
|
||||
getLyric(mInfo, retryNum = 0) {
|
||||
if (retryNum > 3) return Promise.reject(new Error('Get lyric failed'))
|
||||
|
||||
|
||||
522
src/main/utils/musicSdk/tx/qrc-decrypt.js
Normal file
522
src/main/utils/musicSdk/tx/qrc-decrypt.js
Normal file
@@ -0,0 +1,522 @@
|
||||
|
||||
import zlib from 'zlib'
|
||||
|
||||
export default () => {
|
||||
const ENCRYPT = 1
|
||||
const DECRYPT = 0
|
||||
|
||||
const sbox = [
|
||||
// sbox1
|
||||
[
|
||||
14, 4, 13, 1, 2, 15, 11, 8, 3, 10, 6, 12, 5, 9, 0, 7, 0, 15, 7, 4, 14, 2, 13, 1, 10, 6, 12,
|
||||
11, 9, 5, 3, 8, 4, 1, 14, 8, 13, 6, 2, 11, 15, 12, 9, 7, 3, 10, 5, 0, 15, 12, 8, 2, 4, 9, 1,
|
||||
7, 5, 11, 3, 14, 10, 0, 6, 13
|
||||
],
|
||||
// sbox2
|
||||
[
|
||||
15, 1, 8, 14, 6, 11, 3, 4, 9, 7, 2, 13, 12, 0, 5, 10, 3, 13, 4, 7, 15, 2, 8, 15, 12, 0, 1, 10,
|
||||
6, 9, 11, 5, 0, 14, 7, 11, 10, 4, 13, 1, 5, 8, 12, 6, 9, 3, 2, 15, 13, 8, 10, 1, 3, 15, 4, 2,
|
||||
11, 6, 7, 12, 0, 5, 14, 9
|
||||
],
|
||||
// sbox3
|
||||
[
|
||||
10, 0, 9, 14, 6, 3, 15, 5, 1, 13, 12, 7, 11, 4, 2, 8, 13, 7, 0, 9, 3, 4, 6, 10, 2, 8, 5, 14,
|
||||
12, 11, 15, 1, 13, 6, 4, 9, 8, 15, 3, 0, 11, 1, 2, 12, 5, 10, 14, 7, 1, 10, 13, 0, 6, 9, 8, 7,
|
||||
4, 15, 14, 3, 11, 5, 2, 12
|
||||
],
|
||||
// sbox4
|
||||
[
|
||||
7, 13, 14, 3, 0, 6, 9, 10, 1, 2, 8, 5, 11, 12, 4, 15, 13, 8, 11, 5, 6, 15, 0, 3, 4, 7, 2, 12,
|
||||
1, 10, 14, 9, 10, 6, 9, 0, 12, 11, 7, 13, 15, 1, 3, 14, 5, 2, 8, 4, 3, 15, 0, 6, 10, 10, 13,
|
||||
8, 9, 4, 5, 11, 12, 7, 2, 14
|
||||
],
|
||||
// sbox5
|
||||
[
|
||||
2, 12, 4, 1, 7, 10, 11, 6, 8, 5, 3, 15, 13, 0, 14, 9, 14, 11, 2, 12, 4, 7, 13, 1, 5, 0, 15,
|
||||
10, 3, 9, 8, 6, 4, 2, 1, 11, 10, 13, 7, 8, 15, 9, 12, 5, 6, 3, 0, 14, 11, 8, 12, 7, 1, 14, 2,
|
||||
13, 6, 15, 0, 9, 10, 4, 5, 3
|
||||
],
|
||||
// sbox6
|
||||
[
|
||||
12, 1, 10, 15, 9, 2, 6, 8, 0, 13, 3, 4, 14, 7, 5, 11, 10, 15, 4, 2, 7, 12, 9, 5, 6, 1, 13, 14,
|
||||
0, 11, 3, 8, 9, 14, 15, 5, 2, 8, 12, 3, 7, 0, 4, 10, 1, 13, 11, 6, 4, 3, 2, 12, 9, 5, 15, 10,
|
||||
11, 14, 1, 7, 6, 0, 8, 13
|
||||
],
|
||||
// sbox7
|
||||
[
|
||||
4, 11, 2, 14, 15, 0, 8, 13, 3, 12, 9, 7, 5, 10, 6, 1, 13, 0, 11, 7, 4, 9, 1, 10, 14, 3, 5, 12,
|
||||
2, 15, 8, 6, 1, 4, 11, 13, 12, 3, 7, 14, 10, 15, 6, 8, 0, 5, 9, 2, 6, 11, 13, 8, 1, 4, 10, 7,
|
||||
9, 5, 0, 15, 14, 2, 3, 12
|
||||
],
|
||||
// sbox8
|
||||
[
|
||||
13, 2, 8, 4, 6, 15, 11, 1, 10, 9, 3, 14, 5, 0, 12, 7, 1, 15, 13, 8, 10, 3, 7, 4, 12, 5, 6, 11,
|
||||
0, 14, 9, 2, 7, 11, 4, 1, 9, 12, 14, 2, 0, 6, 10, 13, 15, 3, 5, 8, 2, 1, 14, 7, 4, 10, 8, 13,
|
||||
15, 12, 9, 0, 3, 5, 6, 11
|
||||
]
|
||||
]
|
||||
|
||||
/**
|
||||
* 从 Buffer 中提取指定位置的位,并左移指定偏移量
|
||||
* @param {Buffer} a - Buffer
|
||||
* @param {number} b - 要提取的位索引
|
||||
* @param {number} c - 位提取后的偏移量
|
||||
* @returns {number} 提取后的位
|
||||
*/
|
||||
function bitnum(a, b, c) {
|
||||
const byteIndex = Math.floor(b / 32) * 4 + 3 - Math.floor((b % 32) / 8)
|
||||
const bitInByte = 7 - (b % 8)
|
||||
const bit = (a[byteIndex] >> bitInByte) & 1
|
||||
return bit << c
|
||||
}
|
||||
|
||||
/**
|
||||
* 从整数中提取指定位置的位,并左移指定偏移量
|
||||
* @param {number} a - 整数
|
||||
* @param {number} b - 要提取的位索引
|
||||
* @param {number} c - 位提取后的偏移量
|
||||
* @returns {number} 提取后的位
|
||||
*/
|
||||
function bitnum_intr(a, b, c) {
|
||||
return (((a >>> (31 - b)) & 1) << c) | 0
|
||||
}
|
||||
|
||||
/**
|
||||
* 从整数中提取指定位置的位,并右移指定偏移量
|
||||
* @param {number} a - 整数
|
||||
* @param {number} b - 要提取的位索引
|
||||
* @param {number} c - 位提取后的偏移量
|
||||
* @returns {number} 提取后的位
|
||||
*/
|
||||
function bitnum_intl(a, b, c) {
|
||||
return (((a << b) & 0x80000000) >>> c) | 0
|
||||
}
|
||||
|
||||
/**
|
||||
* 对输入整数进行位运算,重新组合位
|
||||
* @param {number} a - 整数
|
||||
* @returns {number} 重新组合后的位
|
||||
*/
|
||||
function sbox_bit(a) {
|
||||
return (a & 32) | ((a & 31) >> 1) | ((a & 1) << 4) | 0
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始置换
|
||||
* @param {Buffer} input_data - 输入 Buffer
|
||||
* @returns {[number, number]} 初始置换后的两个32位整数
|
||||
*/
|
||||
function initial_permutation(input_data) {
|
||||
const s0 =
|
||||
bitnum(input_data, 57, 31) |
|
||||
bitnum(input_data, 49, 30) |
|
||||
bitnum(input_data, 41, 29) |
|
||||
bitnum(input_data, 33, 28) |
|
||||
bitnum(input_data, 25, 27) |
|
||||
bitnum(input_data, 17, 26) |
|
||||
bitnum(input_data, 9, 25) |
|
||||
bitnum(input_data, 1, 24) |
|
||||
bitnum(input_data, 59, 23) |
|
||||
bitnum(input_data, 51, 22) |
|
||||
bitnum(input_data, 43, 21) |
|
||||
bitnum(input_data, 35, 20) |
|
||||
bitnum(input_data, 27, 19) |
|
||||
bitnum(input_data, 19, 18) |
|
||||
bitnum(input_data, 11, 17) |
|
||||
bitnum(input_data, 3, 16) |
|
||||
bitnum(input_data, 61, 15) |
|
||||
bitnum(input_data, 53, 14) |
|
||||
bitnum(input_data, 45, 13) |
|
||||
bitnum(input_data, 37, 12) |
|
||||
bitnum(input_data, 29, 11) |
|
||||
bitnum(input_data, 21, 10) |
|
||||
bitnum(input_data, 13, 9) |
|
||||
bitnum(input_data, 5, 8) |
|
||||
bitnum(input_data, 63, 7) |
|
||||
bitnum(input_data, 55, 6) |
|
||||
bitnum(input_data, 47, 5) |
|
||||
bitnum(input_data, 39, 4) |
|
||||
bitnum(input_data, 31, 3) |
|
||||
bitnum(input_data, 23, 2) |
|
||||
bitnum(input_data, 15, 1) |
|
||||
bitnum(input_data, 7, 0) |
|
||||
0
|
||||
|
||||
const s1 =
|
||||
bitnum(input_data, 56, 31) |
|
||||
bitnum(input_data, 48, 30) |
|
||||
bitnum(input_data, 40, 29) |
|
||||
bitnum(input_data, 32, 28) |
|
||||
bitnum(input_data, 24, 27) |
|
||||
bitnum(input_data, 16, 26) |
|
||||
bitnum(input_data, 8, 25) |
|
||||
bitnum(input_data, 0, 24) |
|
||||
bitnum(input_data, 58, 23) |
|
||||
bitnum(input_data, 50, 22) |
|
||||
bitnum(input_data, 42, 21) |
|
||||
bitnum(input_data, 34, 20) |
|
||||
bitnum(input_data, 26, 19) |
|
||||
bitnum(input_data, 18, 18) |
|
||||
bitnum(input_data, 10, 17) |
|
||||
bitnum(input_data, 2, 16) |
|
||||
bitnum(input_data, 60, 15) |
|
||||
bitnum(input_data, 52, 14) |
|
||||
bitnum(input_data, 44, 13) |
|
||||
bitnum(input_data, 36, 12) |
|
||||
bitnum(input_data, 28, 11) |
|
||||
bitnum(input_data, 20, 10) |
|
||||
bitnum(input_data, 12, 9) |
|
||||
bitnum(input_data, 4, 8) |
|
||||
bitnum(input_data, 62, 7) |
|
||||
bitnum(input_data, 54, 6) |
|
||||
bitnum(input_data, 46, 5) |
|
||||
bitnum(input_data, 38, 4) |
|
||||
bitnum(input_data, 30, 3) |
|
||||
bitnum(input_data, 22, 2) |
|
||||
bitnum(input_data, 14, 1) |
|
||||
bitnum(input_data, 6, 0) |
|
||||
0
|
||||
|
||||
return [s0, s1]
|
||||
}
|
||||
|
||||
/**
|
||||
* 逆初始置换
|
||||
* @param {number} s0 - 32位整数
|
||||
* @param {number} s1 - 32位整数
|
||||
* @returns {Buffer} 逆初始置换后的 Buffer
|
||||
*/
|
||||
function inverse_permutation(s0, s1) {
|
||||
const data = Buffer.alloc(8)
|
||||
data[3] =
|
||||
bitnum_intr(s1, 7, 7) |
|
||||
bitnum_intr(s0, 7, 6) |
|
||||
bitnum_intr(s1, 15, 5) |
|
||||
bitnum_intr(s0, 15, 4) |
|
||||
bitnum_intr(s1, 23, 3) |
|
||||
bitnum_intr(s0, 23, 2) |
|
||||
bitnum_intr(s1, 31, 1) |
|
||||
bitnum_intr(s0, 31, 0) |
|
||||
0
|
||||
data[2] =
|
||||
bitnum_intr(s1, 6, 7) |
|
||||
bitnum_intr(s0, 6, 6) |
|
||||
bitnum_intr(s1, 14, 5) |
|
||||
bitnum_intr(s0, 14, 4) |
|
||||
bitnum_intr(s1, 22, 3) |
|
||||
bitnum_intr(s0, 22, 2) |
|
||||
bitnum_intr(s1, 30, 1) |
|
||||
bitnum_intr(s0, 30, 0) |
|
||||
0
|
||||
data[1] =
|
||||
bitnum_intr(s1, 5, 7) |
|
||||
bitnum_intr(s0, 5, 6) |
|
||||
bitnum_intr(s1, 13, 5) |
|
||||
bitnum_intr(s0, 13, 4) |
|
||||
bitnum_intr(s1, 21, 3) |
|
||||
bitnum_intr(s0, 21, 2) |
|
||||
bitnum_intr(s1, 29, 1) |
|
||||
bitnum_intr(s0, 29, 0) |
|
||||
0
|
||||
data[0] =
|
||||
bitnum_intr(s1, 4, 7) |
|
||||
bitnum_intr(s0, 4, 6) |
|
||||
bitnum_intr(s1, 12, 5) |
|
||||
bitnum_intr(s0, 12, 4) |
|
||||
bitnum_intr(s1, 20, 3) |
|
||||
bitnum_intr(s0, 20, 2) |
|
||||
bitnum_intr(s1, 28, 1) |
|
||||
bitnum_intr(s0, 28, 0) |
|
||||
0
|
||||
data[7] =
|
||||
bitnum_intr(s1, 3, 7) |
|
||||
bitnum_intr(s0, 3, 6) |
|
||||
bitnum_intr(s1, 11, 5) |
|
||||
bitnum_intr(s0, 11, 4) |
|
||||
bitnum_intr(s1, 19, 3) |
|
||||
bitnum_intr(s0, 19, 2) |
|
||||
bitnum_intr(s1, 27, 1) |
|
||||
bitnum_intr(s0, 27, 0) |
|
||||
0
|
||||
data[6] =
|
||||
bitnum_intr(s1, 2, 7) |
|
||||
bitnum_intr(s0, 2, 6) |
|
||||
bitnum_intr(s1, 10, 5) |
|
||||
bitnum_intr(s0, 10, 4) |
|
||||
bitnum_intr(s1, 18, 3) |
|
||||
bitnum_intr(s0, 18, 2) |
|
||||
bitnum_intr(s1, 26, 1) |
|
||||
bitnum_intr(s0, 26, 0) |
|
||||
0
|
||||
data[5] =
|
||||
bitnum_intr(s1, 1, 7) |
|
||||
bitnum_intr(s0, 1, 6) |
|
||||
bitnum_intr(s1, 9, 5) |
|
||||
bitnum_intr(s0, 9, 4) |
|
||||
bitnum_intr(s1, 17, 3) |
|
||||
bitnum_intr(s0, 17, 2) |
|
||||
bitnum_intr(s1, 25, 1) |
|
||||
bitnum_intr(s0, 25, 0) |
|
||||
0
|
||||
data[4] =
|
||||
bitnum_intr(s1, 0, 7) |
|
||||
bitnum_intr(s0, 0, 6) |
|
||||
bitnum_intr(s1, 8, 5) |
|
||||
bitnum_intr(s0, 8, 4) |
|
||||
bitnum_intr(s1, 16, 3) |
|
||||
bitnum_intr(s0, 16, 2) |
|
||||
bitnum_intr(s1, 24, 1) |
|
||||
bitnum_intr(s0, 24, 0) |
|
||||
0
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Triple-DES F函数
|
||||
* @param {number} state - 输入
|
||||
* @param {number[]} key - 密钥
|
||||
* @returns {number} 输出
|
||||
*/
|
||||
function f(state, key) {
|
||||
state = state | 0
|
||||
const t1 =
|
||||
bitnum_intl(state, 31, 0) |
|
||||
(((state & 0xf0000000) >>> 1) | 0) |
|
||||
bitnum_intl(state, 4, 5) |
|
||||
bitnum_intl(state, 3, 6) |
|
||||
(((state & 0x0f000000) >>> 3) | 0) |
|
||||
bitnum_intl(state, 8, 11) |
|
||||
bitnum_intl(state, 7, 12) |
|
||||
(((state & 0x00f00000) >>> 5) | 0) |
|
||||
bitnum_intl(state, 12, 17) |
|
||||
bitnum_intl(state, 11, 18) |
|
||||
(((state & 0x000f0000) >>> 7) | 0) |
|
||||
bitnum_intl(state, 16, 23) |
|
||||
0
|
||||
|
||||
const t2 =
|
||||
bitnum_intl(state, 15, 0) |
|
||||
(((state & 0x0000f000) << 15) | 0) |
|
||||
bitnum_intl(state, 20, 5) |
|
||||
bitnum_intl(state, 19, 6) |
|
||||
(((state & 0x00000f00) << 13) | 0) |
|
||||
bitnum_intl(state, 24, 11) |
|
||||
bitnum_intl(state, 23, 12) |
|
||||
(((state & 0x000000f0) << 11) | 0) |
|
||||
bitnum_intl(state, 28, 17) |
|
||||
bitnum_intl(state, 27, 18) |
|
||||
(((state & 0x0000000f) << 9) | 0) |
|
||||
bitnum_intl(state, 0, 23) |
|
||||
0
|
||||
|
||||
const _lrgstate = [
|
||||
(t1 >>> 24) & 0xff,
|
||||
(t1 >>> 16) & 0xff,
|
||||
(t1 >>> 8) & 0xff,
|
||||
(t2 >>> 24) & 0xff,
|
||||
(t2 >>> 16) & 0xff,
|
||||
(t2 >>> 8) & 0xff
|
||||
]
|
||||
|
||||
const lrgstate = _lrgstate.map((val, i) => val ^ key[i])
|
||||
|
||||
const newState =
|
||||
(sbox[0][sbox_bit(lrgstate[0] >>> 2)] << 28) |
|
||||
(sbox[1][sbox_bit(((lrgstate[0] & 0x03) << 4) | (lrgstate[1] >>> 4))] << 24) |
|
||||
(sbox[2][sbox_bit(((lrgstate[1] & 0x0f) << 2) | (lrgstate[2] >>> 6))] << 20) |
|
||||
(sbox[3][sbox_bit(lrgstate[2] & 0x3f)] << 16) |
|
||||
(sbox[4][sbox_bit(lrgstate[3] >>> 2)] << 12) |
|
||||
(sbox[5][sbox_bit(((lrgstate[3] & 0x03) << 4) | (lrgstate[4] >>> 4))] << 8) |
|
||||
(sbox[6][sbox_bit(((lrgstate[4] & 0x0f) << 2) | (lrgstate[5] >>> 6))] << 4) |
|
||||
sbox[7][sbox_bit(lrgstate[5] & 0x3f)] |
|
||||
0
|
||||
|
||||
return (
|
||||
bitnum_intl(newState, 15, 0) |
|
||||
bitnum_intl(newState, 6, 1) |
|
||||
bitnum_intl(newState, 19, 2) |
|
||||
bitnum_intl(newState, 20, 3) |
|
||||
bitnum_intl(newState, 28, 4) |
|
||||
bitnum_intl(newState, 11, 5) |
|
||||
bitnum_intl(newState, 27, 6) |
|
||||
bitnum_intl(newState, 16, 7) |
|
||||
bitnum_intl(newState, 0, 8) |
|
||||
bitnum_intl(newState, 14, 9) |
|
||||
bitnum_intl(newState, 22, 10) |
|
||||
bitnum_intl(newState, 25, 11) |
|
||||
bitnum_intl(newState, 4, 12) |
|
||||
bitnum_intl(newState, 17, 13) |
|
||||
bitnum_intl(newState, 30, 14) |
|
||||
bitnum_intl(newState, 9, 15) |
|
||||
bitnum_intl(newState, 1, 16) |
|
||||
bitnum_intl(newState, 7, 17) |
|
||||
bitnum_intl(newState, 23, 18) |
|
||||
bitnum_intl(newState, 13, 19) |
|
||||
bitnum_intl(newState, 31, 20) |
|
||||
bitnum_intl(newState, 26, 21) |
|
||||
bitnum_intl(newState, 2, 22) |
|
||||
bitnum_intl(newState, 8, 23) |
|
||||
bitnum_intl(newState, 18, 24) |
|
||||
bitnum_intl(newState, 12, 25) |
|
||||
bitnum_intl(newState, 29, 26) |
|
||||
bitnum_intl(newState, 5, 27) |
|
||||
bitnum_intl(newState, 21, 28) |
|
||||
bitnum_intl(newState, 10, 29) |
|
||||
bitnum_intl(newState, 3, 30) |
|
||||
bitnum_intl(newState, 24, 31) |
|
||||
0
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* TripleDES 加密/解密算法 (单块)
|
||||
* @param {Buffer} input_data - 输入 Buffer
|
||||
* @param {number[][]} key - 密钥
|
||||
* @returns {Buffer} 加/解密后的 Buffer
|
||||
*/
|
||||
function crypt(input_data, key) {
|
||||
let [s0, s1] = initial_permutation(input_data)
|
||||
|
||||
for (let idx = 0; idx < 15; idx++) {
|
||||
const previous_s1 = s1
|
||||
s1 = (f(s1, key[idx]) ^ s0) | 0
|
||||
s0 = previous_s1
|
||||
}
|
||||
s0 = (f(s1, key[15]) ^ s0) | 0
|
||||
|
||||
return inverse_permutation(s0, s1)
|
||||
}
|
||||
|
||||
/**
|
||||
* TripleDES 密钥扩展算法
|
||||
* @param {Buffer} key - 密钥
|
||||
* @param {number} mode - 模式 (ENCRYPT/DECRYPT)
|
||||
* @returns {number[][]} 密钥扩展
|
||||
*/
|
||||
function key_schedule(key, mode) {
|
||||
const schedule = Array.from({ length: 16 }, () => Array(6).fill(0))
|
||||
const key_rnd_shift = [1, 1, 2, 2, 2, 2, 2, 2, 1, 2, 2, 2, 2, 2, 2, 1]
|
||||
const key_perm_c = [
|
||||
56, 48, 40, 32, 24, 16, 8, 0, 57, 49, 41, 33, 25, 17, 9, 1, 58, 50, 42, 34, 26, 18, 10, 2, 59,
|
||||
51, 43, 35
|
||||
]
|
||||
const key_perm_d = [
|
||||
62, 54, 46, 38, 30, 22, 14, 6, 61, 53, 45, 37, 29, 21, 13, 5, 60, 52, 44, 36, 28, 20, 12, 4,
|
||||
27, 19, 11, 3
|
||||
]
|
||||
const key_compression = [
|
||||
13, 16, 10, 23, 0, 4, 2, 27, 14, 5, 20, 9, 22, 18, 11, 3, 25, 7, 15, 6, 26, 19, 12, 1, 40, 51,
|
||||
30, 36, 46, 54, 29, 39, 50, 44, 32, 47, 43, 48, 38, 55, 33, 52, 45, 41, 49, 35, 28, 31
|
||||
]
|
||||
|
||||
let c = 0,
|
||||
d = 0
|
||||
for (let i = 0; i < 28; i++) {
|
||||
c |= bitnum(key, key_perm_c[i], 31 - i)
|
||||
d |= bitnum(key, key_perm_d[i], 31 - i)
|
||||
}
|
||||
c = c | 0
|
||||
d = d | 0
|
||||
|
||||
for (let i = 0; i < 16; i++) {
|
||||
const shift = key_rnd_shift[i]
|
||||
c = (((c << shift) | (c >>> (28 - shift))) & 0xfffffff0) | 0
|
||||
d = (((d << shift) | (d >>> (28 - shift))) & 0xfffffff0) | 0
|
||||
|
||||
const togen = mode === DECRYPT ? 15 - i : i
|
||||
|
||||
schedule[togen] = Array(6).fill(0)
|
||||
|
||||
for (let j = 0; j < 24; j++) {
|
||||
schedule[togen][Math.floor(j / 8)] |= bitnum_intr(c, key_compression[j], 7 - (j % 8))
|
||||
}
|
||||
|
||||
for (let j = 24; j < 48; j++) {
|
||||
schedule[togen][Math.floor(j / 8)] |= bitnum_intr(d, key_compression[j] - 27, 7 - (j % 8))
|
||||
}
|
||||
}
|
||||
return schedule
|
||||
}
|
||||
|
||||
/**
|
||||
* TripleDES 密钥设置
|
||||
* @param {Buffer} key - 密钥
|
||||
* @param {number} mode - 模式
|
||||
* @returns {number[][][]} 密钥设置
|
||||
*/
|
||||
function tripledes_key_setup(key, mode) {
|
||||
if (mode === ENCRYPT) {
|
||||
return [
|
||||
key_schedule(key.slice(0, 8), ENCRYPT),
|
||||
key_schedule(key.slice(8, 16), DECRYPT),
|
||||
key_schedule(key.slice(16, 24), ENCRYPT)
|
||||
]
|
||||
}
|
||||
return [
|
||||
key_schedule(key.slice(16, 24), DECRYPT),
|
||||
key_schedule(key.slice(8, 16), ENCRYPT),
|
||||
key_schedule(key.slice(0, 8), DECRYPT)
|
||||
]
|
||||
}
|
||||
|
||||
/**
|
||||
* TripleDES 加密/解密算法 (完整)
|
||||
* @param {Buffer} data - 输入 Buffer
|
||||
* @param {number[][][]} key - 密钥
|
||||
* @returns {Buffer} 加/解密后的 Buffer
|
||||
*/
|
||||
function tripledes_crypt(data, key) {
|
||||
let result = data
|
||||
for (let i = 0; i < 3; i++) {
|
||||
result = crypt(result, key[i])
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* QRC解密主函数
|
||||
* @param {string | Buffer} encrypted_qrc - 加密的QRC内容 (十六进制字符串或Buffer)
|
||||
* @returns {string} 解密后的UTF-8字符串
|
||||
*/
|
||||
function qrc_decrypt(encrypted_qrc) {
|
||||
if (!encrypted_qrc) {
|
||||
return ''
|
||||
}
|
||||
|
||||
let input_buffer
|
||||
if (typeof encrypted_qrc === 'string') {
|
||||
input_buffer = Buffer.from(encrypted_qrc, 'hex')
|
||||
} else if (Buffer.isBuffer(encrypted_qrc)) {
|
||||
input_buffer = encrypted_qrc
|
||||
} else {
|
||||
throw new Error('无效的加密数据类型')
|
||||
}
|
||||
|
||||
try {
|
||||
const decrypted_chunks = []
|
||||
const key = Buffer.from('!@#)(*$%123ZXC!@!@#)(NHL')
|
||||
const schedule = tripledes_key_setup(key, DECRYPT)
|
||||
|
||||
for (let i = 0; i < input_buffer.length; i += 8) {
|
||||
const chunk = input_buffer.slice(i, i + 8)
|
||||
if (chunk.length < 8) {
|
||||
// 如果最后一块不足8字节,DES无法处理,但QRC格式应该是8的倍数
|
||||
// 这里可以根据实际情况决定如何处理,例如抛出错误或填充
|
||||
// 根据原始代码行为,这里假设输入总是8字节的倍数
|
||||
console.warn('警告: 数据末尾存在不足8字节的块,可能导致解密不完整。')
|
||||
continue
|
||||
}
|
||||
decrypted_chunks.push(tripledes_crypt(chunk, schedule))
|
||||
}
|
||||
|
||||
const data = Buffer.concat(decrypted_chunks)
|
||||
const decompressed = zlib.unzipSync(data)
|
||||
return decompressed.toString('utf-8')
|
||||
} catch (e) {
|
||||
throw new Error(`解密失败: ${e.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
// 导出主函数
|
||||
return qrc_decrypt
|
||||
}
|
||||
23
src/renderer/components.d.ts
vendored
23
src/renderer/components.d.ts
vendored
@@ -25,8 +25,31 @@ declare module 'vue' {
|
||||
RouterView: typeof import('vue-router')['RouterView']
|
||||
ShaderBackground: typeof import('./src/components/Play/ShaderBackground.vue')['default']
|
||||
SongVirtualList: typeof import('./src/components/Music/SongVirtualList.vue')['default']
|
||||
TAlert: typeof import('tdesign-vue-next')['Alert']
|
||||
TAside: typeof import('tdesign-vue-next')['Aside']
|
||||
TBadge: typeof import('tdesign-vue-next')['Badge']
|
||||
TButton: typeof import('tdesign-vue-next')['Button']
|
||||
TCard: typeof import('tdesign-vue-next')['Card']
|
||||
TContent: typeof import('tdesign-vue-next')['Content']
|
||||
TDialog: typeof import('tdesign-vue-next')['Dialog']
|
||||
TDivider: typeof import('tdesign-vue-next')['Divider']
|
||||
TDropdown: typeof import('tdesign-vue-next')['Dropdown']
|
||||
TForm: typeof import('tdesign-vue-next')['Form']
|
||||
TFormItem: typeof import('tdesign-vue-next')['FormItem']
|
||||
ThemeSelector: typeof import('./src/components/ThemeSelector.vue')['default']
|
||||
TIcon: typeof import('tdesign-vue-next')['Icon']
|
||||
TImage: typeof import('tdesign-vue-next')['Image']
|
||||
TInput: typeof import('tdesign-vue-next')['Input']
|
||||
TitleBarControls: typeof import('./src/components/TitleBarControls.vue')['default']
|
||||
TLayout: typeof import('tdesign-vue-next')['Layout']
|
||||
TLoading: typeof import('tdesign-vue-next')['Loading']
|
||||
TRadioButton: typeof import('tdesign-vue-next')['RadioButton']
|
||||
TRadioGroup: typeof import('tdesign-vue-next')['RadioGroup']
|
||||
TSlider: typeof import('tdesign-vue-next')['Slider']
|
||||
TSwitch: typeof import('tdesign-vue-next')['Switch']
|
||||
TTag: typeof import('tdesign-vue-next')['Tag']
|
||||
TTextarea: typeof import('tdesign-vue-next')['Textarea']
|
||||
TTooltip: typeof import('tdesign-vue-next')['Tooltip']
|
||||
UpdateExample: typeof import('./src/components/UpdateExample.vue')['default']
|
||||
UpdateProgress: typeof import('./src/components/UpdateProgress.vue')['default']
|
||||
UpdateSettings: typeof import('./src/components/Settings/UpdateSettings.vue')['default']
|
||||
|
||||
@@ -16,7 +16,12 @@ import { shouldUseBlackText } from '@renderer/utils/color/contrastColor'
|
||||
import { ControlAudioStore } from '@renderer/store/ControlAudio'
|
||||
import { Fullscreen1Icon, FullscreenExit1Icon, ChevronDownIcon } from 'tdesign-icons-vue-next'
|
||||
// 直接从包路径导入,避免 WebAssembly 导入问题
|
||||
import { parseYrc, parseLrc, parseTTML } from '@applemusic-like-lyrics/lyric/pkg/amll_lyric.js'
|
||||
import {
|
||||
parseYrc,
|
||||
parseLrc,
|
||||
parseTTML,
|
||||
parseQrc
|
||||
} from '@applemusic-like-lyrics/lyric/pkg/amll_lyric.js'
|
||||
import _ from 'lodash'
|
||||
import { storeToRefs } from 'pinia'
|
||||
|
||||
@@ -151,7 +156,11 @@ watch(
|
||||
if (lyricData.crlyric) {
|
||||
// 使用逐字歌词
|
||||
lyricText = lyricData.crlyric
|
||||
parsedLyrics = parseYrc(lyricText)
|
||||
if (source === 'tx') {
|
||||
parsedLyrics = parseQrc(lyricText)
|
||||
} else {
|
||||
parsedLyrics = parseYrc(lyricText)
|
||||
}
|
||||
console.log(`使用${source}逐字歌词`, parsedLyrics)
|
||||
} else if (lyricData.lyric) {
|
||||
lyricText = lyricData.lyric
|
||||
|
||||
@@ -124,10 +124,20 @@ const currentOperatingSong = ref<any>(null)
|
||||
|
||||
// 统一的鼠标/触摸事件处理
|
||||
const handleMouseDown = (event: MouseEvent, index: number, song: any) => {
|
||||
// 检查是否点击了删除按钮或其子元素
|
||||
const target = event.target as HTMLElement
|
||||
if (target.closest('.song-remove')) {
|
||||
return // 如果点击的是删除按钮,直接返回,不处理拖拽逻辑
|
||||
}
|
||||
handlePointerStart(event, index, song, false)
|
||||
}
|
||||
|
||||
const handleTouchStart = (event: TouchEvent, index: number, song: any) => {
|
||||
// 检查是否点击了删除按钮或其子元素
|
||||
const target = event.target as HTMLElement
|
||||
if (target.closest('.song-remove')) {
|
||||
return // 如果点击的是删除按钮,直接返回,不处理拖拽逻辑
|
||||
}
|
||||
handlePointerStart(event, index, song, true)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import TitleBarControls from '@renderer/components/TitleBarControls.vue'
|
||||
import { SearchIcon } from 'tdesign-icons-vue-next'
|
||||
import { onMounted, ref, watchEffect } from 'vue'
|
||||
import { onMounted, ref, watchEffect, computed } from 'vue'
|
||||
import { LocalUserDetailStore } from '@renderer/store/LocalUserDetail'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { searchValue } from '@renderer/store/search'
|
||||
@@ -45,6 +45,68 @@ const menuList: MenuItem[] = [
|
||||
]
|
||||
const menuActive = ref(0)
|
||||
const router = useRouter()
|
||||
const source_list_show = ref(false)
|
||||
|
||||
// 检查是否有插件数据
|
||||
const hasPluginData = computed(() => {
|
||||
const LocalUserDetail = LocalUserDetailStore()
|
||||
return !!(
|
||||
LocalUserDetail.userInfo.pluginId &&
|
||||
LocalUserDetail.userInfo.supportedSources &&
|
||||
Object.keys(LocalUserDetail.userInfo.supportedSources).length > 0
|
||||
)
|
||||
})
|
||||
|
||||
// 音源名称映射
|
||||
const sourceNames = {
|
||||
wy: '网易云音乐',
|
||||
kg: '酷狗音乐',
|
||||
mg: '咪咕音乐',
|
||||
tx: 'QQ音乐',
|
||||
kw: '酷我音乐'
|
||||
}
|
||||
|
||||
// 动态音源列表数据,基于supportedSources
|
||||
const sourceList = computed(() => {
|
||||
const LocalUserDetail = LocalUserDetailStore()
|
||||
const supportedSources = LocalUserDetail.userInfo.supportedSources
|
||||
|
||||
if (!supportedSources) return []
|
||||
|
||||
return Object.keys(supportedSources).map((key) => ({
|
||||
key,
|
||||
name: sourceNames[key] || key,
|
||||
icon: sourceicon[key] || key
|
||||
}))
|
||||
})
|
||||
|
||||
// 切换音源选择器显示状态
|
||||
const toggleSourceList = () => {
|
||||
source_list_show.value = !source_list_show.value
|
||||
}
|
||||
|
||||
// 选择音源
|
||||
const selectSource = (sourceKey: string) => {
|
||||
if (!hasPluginData.value) return
|
||||
|
||||
const LocalUserDetail = LocalUserDetailStore()
|
||||
LocalUserDetail.userInfo.selectSources = sourceKey
|
||||
|
||||
// 自动选择该音源的最高音质
|
||||
const sourceDetail = LocalUserDetail.userInfo.supportedSources?.[sourceKey]
|
||||
if (sourceDetail && sourceDetail.qualitys && sourceDetail.qualitys.length > 0) {
|
||||
LocalUserDetail.userInfo.selectQuality = sourceDetail.qualitys[sourceDetail.qualitys.length - 1]
|
||||
}
|
||||
|
||||
// 更新音源图标
|
||||
source.value = sourceicon[sourceKey]
|
||||
source_list_show.value = false
|
||||
}
|
||||
|
||||
// 点击遮罩关闭音源选择器
|
||||
const handleMaskClick = () => {
|
||||
source_list_show.value = false
|
||||
}
|
||||
|
||||
const handleClick = (index: number): void => {
|
||||
menuActive.value = index
|
||||
@@ -132,9 +194,34 @@ const handleKeyDown = () => {
|
||||
|
||||
<div class="search-container">
|
||||
<div class="search-input">
|
||||
<svg class="icon" aria-hidden="true">
|
||||
<use :xlink:href="`#icon-${source}`"></use>
|
||||
</svg>
|
||||
<div class="source-selector" @click="toggleSourceList">
|
||||
<svg class="icon" aria-hidden="true">
|
||||
<use :xlink:href="`#icon-${source}`"></use>
|
||||
</svg>
|
||||
</div>
|
||||
<!-- 透明遮罩 -->
|
||||
<transition name="mask">
|
||||
<div v-if="source_list_show" class="source-mask" @click="handleMaskClick"></div>
|
||||
</transition>
|
||||
<!-- 音源选择列表 -->
|
||||
<transition name="source">
|
||||
<div v-if="source_list_show" class="source-list">
|
||||
<div class="items">
|
||||
<div
|
||||
v-for="item in sourceList"
|
||||
:key="item.key"
|
||||
class="source-item"
|
||||
:class="{ active: source === item.icon }"
|
||||
@click="selectSource(item.key)"
|
||||
>
|
||||
<svg class="source-icon" aria-hidden="true">
|
||||
<use :xlink:href="`#icon-${item.icon}`"></use>
|
||||
</svg>
|
||||
<span class="source-name">{{ item.name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
<t-input
|
||||
v-model="keyword"
|
||||
placeholder="搜索音乐、歌手"
|
||||
@@ -173,6 +260,34 @@ const handleKeyDown = () => {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
// 音源选择器过渡动画
|
||||
.source-enter-active,
|
||||
.source-leave-active {
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.source-enter-from {
|
||||
opacity: 0;
|
||||
transform: translateY(-0.5rem);
|
||||
}
|
||||
|
||||
.source-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(-0.5rem);
|
||||
}
|
||||
|
||||
// 遮罩过渡动画
|
||||
.mask-enter-active,
|
||||
.mask-leave-active {
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.mask-enter-from,
|
||||
.mask-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.home-container {
|
||||
height: calc(100vh - var(--play-bottom-height));
|
||||
overflow-y: hidden;
|
||||
@@ -320,16 +435,130 @@ const handleKeyDown = () => {
|
||||
margin-right: 0.5rem;
|
||||
border-radius: 1.25rem !important;
|
||||
background-color: #fff;
|
||||
overflow: hidden;
|
||||
overflow: visible;
|
||||
position: relative;
|
||||
|
||||
&:has(input:focus) {
|
||||
width: max(18.75rem, 400px);
|
||||
}
|
||||
|
||||
.source-selector {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
box-sizing: border-box;
|
||||
padding: 0.25rem;
|
||||
aspect-ratio: 1 / 1;
|
||||
border-radius: 999px;
|
||||
overflow: hidden;
|
||||
transition: background-color 0.2s;
|
||||
|
||||
&:hover {
|
||||
background-color: #f3f4f6;
|
||||
}
|
||||
|
||||
.source-arrow {
|
||||
margin-left: 0.25rem;
|
||||
font-size: 0.75rem;
|
||||
color: #6b7280;
|
||||
transition: transform 0.2s;
|
||||
|
||||
&.rotated {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.source-mask {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 9999999;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.source-list {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
z-index: 10000000;
|
||||
background: white;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 0.5rem;
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
|
||||
min-width: 10rem;
|
||||
overflow-y: hidden;
|
||||
margin-top: 0.25rem;
|
||||
padding: 0.5em;
|
||||
|
||||
.items {
|
||||
max-height: 12rem;
|
||||
overflow-y: auto;
|
||||
|
||||
// 隐藏滚动条
|
||||
&::-webkit-scrollbar {
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
// Firefox 隐藏滚动条
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.source-item {
|
||||
border-radius: 5px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0.5rem 0.75rem;
|
||||
margin-bottom: 5px;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
|
||||
&:last-child {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background-color: #f3f4f6;
|
||||
}
|
||||
|
||||
&.active {
|
||||
background-color: var(--td-brand-color-1);
|
||||
color: var(--td-brand-color);
|
||||
}
|
||||
|
||||
.source-icon {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
|
||||
.source-name {
|
||||
font-size: 0.875rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.t-input) {
|
||||
border-radius: 0rem !important;
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
&.t-input--suffix {
|
||||
padding-right: 0 !important;
|
||||
}
|
||||
}
|
||||
|
||||
.settings-btn {
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { ref, onMounted, watch, WatchHandle, onUnmounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { LocalUserDetailStore } from '@renderer/store/LocalUserDetail'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { extractDominantColor } from '../../utils/color/colorExtractor'
|
||||
|
||||
// 路由实例
|
||||
@@ -18,25 +19,16 @@ const textColors = ref<string[]>([])
|
||||
const hotSongs: any = ref([])
|
||||
|
||||
let watchSource: WatchHandle | null = null
|
||||
|
||||
// 获取热门歌单数据
|
||||
const fetchHotSonglist = async () => {
|
||||
const LocalUserDetail = LocalUserDetailStore()
|
||||
watchSource = watch(
|
||||
LocalUserDetail.userSource,
|
||||
() => {
|
||||
if (LocalUserDetail.userSource.source) {
|
||||
fetchHotSonglist()
|
||||
}
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
try {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
|
||||
// 调用真实 API 获取热门歌单
|
||||
const result = await window.api.music.requestSdk('getHotSonglist', {
|
||||
source: LocalUserDetail.userSource.source
|
||||
source: userSource.value.source
|
||||
})
|
||||
if (result && result.list) {
|
||||
recommendPlaylists.value = result.list.map((item: any) => ({
|
||||
@@ -112,13 +104,27 @@ const playSong = (song: any): void => {
|
||||
console.log('播放歌曲:', song.title)
|
||||
}
|
||||
|
||||
// 获取 store 实例和响应式引用
|
||||
const LocalUserDetail = LocalUserDetailStore()
|
||||
const { userSource } = storeToRefs(LocalUserDetail)
|
||||
|
||||
// 组件挂载时获取数据
|
||||
onMounted(() => {
|
||||
fetchHotSonglist()
|
||||
// 设置音源变化监听器
|
||||
watchSource = watch(
|
||||
userSource,
|
||||
(newSource) => {
|
||||
if (newSource.source) {
|
||||
fetchHotSonglist()
|
||||
}
|
||||
},
|
||||
{ deep: true, immediate: true }
|
||||
)
|
||||
})
|
||||
onUnmounted(() => {
|
||||
if (watchSource) {
|
||||
watchSource()
|
||||
watchSource = null
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -434,12 +434,14 @@ const importFromPlaylist = async () => {
|
||||
// 网络歌单导入对话框状态
|
||||
const showNetworkImportDialog = ref(false)
|
||||
const networkPlaylistUrl = ref('')
|
||||
const importPlatformType = ref('wy') // 默认选择网易云音乐
|
||||
|
||||
// 从网络歌单导入
|
||||
const importFromNetwork = () => {
|
||||
showImportDialog.value = false
|
||||
showNetworkImportDialog.value = true
|
||||
networkPlaylistUrl.value = ''
|
||||
importPlatformType.value = 'wy' // 重置为默认平台
|
||||
}
|
||||
|
||||
// 确认网络歌单导入
|
||||
@@ -457,6 +459,43 @@ const confirmNetworkImport = async () => {
|
||||
const cancelNetworkImport = () => {
|
||||
showNetworkImportDialog.value = false
|
||||
networkPlaylistUrl.value = ''
|
||||
importPlatformType.value = 'wy'
|
||||
}
|
||||
|
||||
// 为歌单歌曲获取封面图片
|
||||
const setPicForPlaylist = async (songs: any[], source: string) => {
|
||||
// 筛选出需要获取封面的歌曲
|
||||
const songsNeedPic = songs.filter((song) => !song.img)
|
||||
|
||||
if (songsNeedPic.length === 0) return
|
||||
|
||||
// 批量请求封面
|
||||
const picPromises = songsNeedPic.map(async (song, index) => {
|
||||
try {
|
||||
const url = await window.api.music.requestSdk('getPic', {
|
||||
source,
|
||||
songInfo: toRaw(song)
|
||||
})
|
||||
return {
|
||||
song,
|
||||
url: typeof url !== 'object' ? url : ''
|
||||
}
|
||||
} catch (e) {
|
||||
console.log('获取封面失败 index' + index, e)
|
||||
return {
|
||||
song,
|
||||
url: ''
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// 等待所有请求完成
|
||||
const results = await Promise.all(picPromises)
|
||||
|
||||
// 更新歌曲封面
|
||||
results.forEach((result) => {
|
||||
result.song.img = result.url
|
||||
})
|
||||
}
|
||||
|
||||
// 处理网络歌单导入
|
||||
@@ -464,31 +503,171 @@ const handleNetworkPlaylistImport = async (input: string) => {
|
||||
try {
|
||||
const load1 = MessagePlugin.loading('正在解析歌单链接...')
|
||||
|
||||
// 使用正则表达式匹配网易云音乐歌单ID
|
||||
const playlistIdRegex = /(?:music\.163\.com\/.*[?&]id=|playlist\?id=|playlist\/|id=)(\d+)/i
|
||||
const match = input.match(playlistIdRegex)
|
||||
let playlistId: string = ''
|
||||
let platformName: string = ''
|
||||
|
||||
let playlistId: string
|
||||
if (importPlatformType.value === 'wy') {
|
||||
// 网易云音乐歌单ID解析
|
||||
const playlistIdRegex = /(?:music\.163\.com\/.*[?&]id=|playlist\?id=|playlist\/|id=)(\d+)/i
|
||||
const match = input.match(playlistIdRegex)
|
||||
|
||||
if (match && match[1]) {
|
||||
// 从链接中提取到歌单ID
|
||||
playlistId = match[1]
|
||||
} else {
|
||||
// 检查是否直接输入的是纯数字ID
|
||||
const numericMatch = input.match(/^\d+$/)
|
||||
if (numericMatch) {
|
||||
playlistId = input
|
||||
if (match && match[1]) {
|
||||
playlistId = match[1]
|
||||
} else {
|
||||
MessagePlugin.error('无法识别的歌单链接或ID格式,请输入网易云音乐歌单链接或歌单ID')
|
||||
return
|
||||
const numericMatch = input.match(/^\d+$/)
|
||||
if (numericMatch) {
|
||||
playlistId = input
|
||||
} else {
|
||||
MessagePlugin.error('无法识别的网易云音乐歌单链接或ID格式')
|
||||
load1.then((res) => res.close())
|
||||
return
|
||||
}
|
||||
}
|
||||
platformName = '网易云音乐'
|
||||
} else if (importPlatformType.value === 'tx') {
|
||||
// QQ音乐歌单ID解析 - 支持多种链接格式
|
||||
const qqPlaylistRegexes = [
|
||||
// 标准歌单链接
|
||||
/(?:y\.qq\.com\/n\/ryqq\/playlist\/|music\.qq\.com\/.*[?&]id=|playlist[?&]id=)(\d+)/i,
|
||||
// 分享链接格式
|
||||
/(?:i\.y\.qq\.com\/n2\/m\/share\/details\/taoge\.html.*[?&]id=)(\d+)/i,
|
||||
// 其他可能的分享格式
|
||||
/(?:c\.y\.qq\.com\/base\/fcgi-bin\/u\?.*__=)(\d+)/i,
|
||||
// 手机版链接
|
||||
/(?:i\.y\.qq\.com\/v8\/playsquare\/playlist\.html.*[?&]id=)(\d+)/i,
|
||||
// 通用ID提取 - 匹配 id= 或 &id= 参数
|
||||
/[?&]id=(\d+)/i
|
||||
]
|
||||
|
||||
let match: RegExpMatchArray | null = null
|
||||
for (const regex of qqPlaylistRegexes) {
|
||||
match = input.match(regex)
|
||||
if (match && match[1]) {
|
||||
playlistId = match[1]
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (!match || !match[1]) {
|
||||
// 检查是否直接输入的是纯数字ID
|
||||
const numericMatch = input.match(/^\d+$/)
|
||||
if (numericMatch) {
|
||||
playlistId = input
|
||||
} else {
|
||||
MessagePlugin.error('无法识别的QQ音乐歌单链接或ID格式,请检查链接是否正确')
|
||||
load1.then((res) => res.close())
|
||||
return
|
||||
}
|
||||
}
|
||||
platformName = 'QQ音乐'
|
||||
} else if (importPlatformType.value === 'kw') {
|
||||
// 酷我音乐歌单ID解析
|
||||
const kwPlaylistRegexes = [
|
||||
// 标准歌单链接
|
||||
/(?:kuwo\.cn\/playlist_detail\/|kuwo\.cn\/.*[?&]pid=)(\d+)/i,
|
||||
// 手机版链接
|
||||
/(?:m\.kuwo\.cn\/h5app\/playlist\/|kuwo\.cn\/.*[?&]id=)(\d+)/i,
|
||||
// 通用ID提取
|
||||
/[?&](?:pid|id)=(\d+)/i
|
||||
]
|
||||
|
||||
let match: RegExpMatchArray | null = null
|
||||
for (const regex of kwPlaylistRegexes) {
|
||||
match = input.match(regex)
|
||||
if (match && match[1]) {
|
||||
playlistId = match[1]
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (!match || !match[1]) {
|
||||
const numericMatch = input.match(/^\d+$/)
|
||||
if (numericMatch) {
|
||||
playlistId = input
|
||||
} else {
|
||||
MessagePlugin.error('无法识别的酷我音乐歌单链接或ID格式,请检查链接是否正确')
|
||||
load1.then((res) => res.close())
|
||||
return
|
||||
}
|
||||
}
|
||||
platformName = '酷我音乐'
|
||||
} else if (importPlatformType.value === 'kg') {
|
||||
// 酷狗音乐链接处理 - 传递完整链接给getUserListDetail
|
||||
const kgPlaylistRegexes = [
|
||||
// 标准歌单链接
|
||||
/kugou\.com\/yy\/special\/single\/\d+/i,
|
||||
// 手机版歌单链接 (新格式)
|
||||
/m\.kugou\.com\/songlist\/gcid_[a-zA-Z0-9]+/i,
|
||||
// 手机版链接 (旧格式)
|
||||
/m\.kugou\.com\/.*[?&]id=\d+/i,
|
||||
// 参数链接
|
||||
/kugou\.com\/.*[?&](?:specialid|id)=\d+/i,
|
||||
// 通用酷狗链接
|
||||
/kugou\.com\/.*playlist/i
|
||||
]
|
||||
|
||||
let isValidLink = false
|
||||
for (const regex of kgPlaylistRegexes) {
|
||||
if (regex.test(input)) {
|
||||
isValidLink = true
|
||||
playlistId = input // 传递完整链接
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (!isValidLink) {
|
||||
// 检查是否为纯数字ID
|
||||
const numericMatch = input.match(/^\d+$/)
|
||||
if (numericMatch) {
|
||||
playlistId = input
|
||||
} else {
|
||||
MessagePlugin.error('无法识别的酷狗音乐歌单链接或ID格式,请检查链接是否正确')
|
||||
load1.then((res) => res.close())
|
||||
return
|
||||
}
|
||||
}
|
||||
platformName = '酷狗音乐'
|
||||
} else if (importPlatformType.value === 'mg') {
|
||||
// 咪咕音乐歌单ID解析
|
||||
const mgPlaylistRegexes = [
|
||||
// 标准歌单链接
|
||||
/(?:music\.migu\.cn\/.*[?&]id=)(\d+)/i,
|
||||
// 手机版链接
|
||||
/(?:m\.music\.migu\.cn\/.*[?&]id=)(\d+)/i,
|
||||
// 通用ID提取
|
||||
/[?&]id=(\d+)/i
|
||||
]
|
||||
|
||||
let match: RegExpMatchArray | null = null
|
||||
for (const regex of mgPlaylistRegexes) {
|
||||
match = input.match(regex)
|
||||
if (match && match[1]) {
|
||||
playlistId = match[1]
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (!match || !match[1]) {
|
||||
const numericMatch = input.match(/^\d+$/)
|
||||
if (numericMatch) {
|
||||
playlistId = input
|
||||
} else {
|
||||
MessagePlugin.error('无法识别的咪咕音乐歌单链接或ID格式,请检查链接是否正确')
|
||||
load1.then((res) => res.close())
|
||||
return
|
||||
}
|
||||
}
|
||||
platformName = '咪咕音乐'
|
||||
} else {
|
||||
MessagePlugin.error('不支持的平台类型')
|
||||
load1.then((res) => res.close())
|
||||
return
|
||||
}
|
||||
|
||||
// 验证歌单ID是否有效
|
||||
if (!playlistId || playlistId.length < 6) {
|
||||
MessagePlugin.error('歌单ID格式不正确')
|
||||
load1.then((res) => res.close())
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
@@ -500,58 +679,104 @@ const handleNetworkPlaylistImport = async (input: string) => {
|
||||
let detailResult: any
|
||||
try {
|
||||
detailResult = (await window.api.music.requestSdk('getPlaylistDetail', {
|
||||
source: 'wy',
|
||||
source: importPlatformType.value,
|
||||
id: playlistId,
|
||||
page: 1
|
||||
})) as any
|
||||
} catch {
|
||||
MessagePlugin.error('获取歌单详情失败:歌曲信息可能有误')
|
||||
MessagePlugin.error(`获取${platformName}歌单详情失败:歌曲信息可能有误`)
|
||||
load2.then((res) => res.close())
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (detailResult.error) {
|
||||
MessagePlugin.error('获取歌单详情失败:' + detailResult.error)
|
||||
MessagePlugin.error(`获取${platformName}歌单详情失败:` + detailResult.error)
|
||||
load2.then((res) => res.close())
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const playlistInfo = detailResult.info
|
||||
const songs = detailResult.list || []
|
||||
|
||||
if (songs.length === 0) {
|
||||
MessagePlugin.warning('该歌单没有歌曲')
|
||||
load2.then((res) => res.close())
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const createResult = await songListAPI.create(
|
||||
`${playlistInfo.name} (导入)`,
|
||||
`从网易云音乐导入 - 原歌单:${playlistInfo.name}`,
|
||||
'wy'
|
||||
)
|
||||
const newPlaylistId = createResult.data!.id
|
||||
await songListAPI.updateCover(newPlaylistId, detailResult.info.img)
|
||||
if (!createResult.success) {
|
||||
MessagePlugin.error('创建本地歌单失败:' + createResult.error)
|
||||
return
|
||||
}
|
||||
|
||||
const addResult = await songListAPI.addSongs(newPlaylistId, songs)
|
||||
|
||||
// 处理导入结果
|
||||
let successCount = 0
|
||||
let failCount = 0
|
||||
|
||||
if (addResult.success) {
|
||||
successCount = songs.length
|
||||
failCount = 0
|
||||
// 为酷狗音乐获取封面图片
|
||||
if (importPlatformType.value === 'kg') {
|
||||
load2.then((res) => res.close())
|
||||
const load3 = MessagePlugin.loading('正在获取歌曲封面...')
|
||||
if (songs.length > 100) MessagePlugin.info('歌曲较多,封面获取可能较慢')
|
||||
|
||||
try {
|
||||
await setPicForPlaylist(songs, importPlatformType.value)
|
||||
} catch (error) {
|
||||
console.warn('获取封面失败,但继续导入:', error)
|
||||
}
|
||||
|
||||
load3.then((res) => res.close())
|
||||
const load4 = MessagePlugin.loading('正在创建本地歌单...')
|
||||
|
||||
const createResult = await songListAPI.create(
|
||||
`${playlistInfo.name} (导入)`,
|
||||
`从${platformName}导入 - 原歌单:${playlistInfo.name}`,
|
||||
importPlatformType.value
|
||||
)
|
||||
|
||||
const newPlaylistId = createResult.data!.id
|
||||
await songListAPI.updateCover(newPlaylistId, detailResult.info.img)
|
||||
|
||||
if (!createResult.success) {
|
||||
MessagePlugin.error('创建本地歌单失败:' + createResult.error)
|
||||
load4.then((res) => res.close())
|
||||
return
|
||||
}
|
||||
|
||||
const addResult = await songListAPI.addSongs(newPlaylistId, songs)
|
||||
load4.then((res) => res.close())
|
||||
|
||||
if (addResult.success) {
|
||||
successCount = songs.length
|
||||
failCount = 0
|
||||
} else {
|
||||
successCount = 0
|
||||
failCount = songs.length
|
||||
console.error('批量添加歌曲失败:', addResult.error)
|
||||
}
|
||||
} else {
|
||||
successCount = 0
|
||||
failCount = songs.length
|
||||
console.error('批量添加歌曲失败:', addResult.error)
|
||||
const createResult = await songListAPI.create(
|
||||
`${playlistInfo.name} (导入)`,
|
||||
`从${platformName}导入 - 原歌单:${playlistInfo.name}`,
|
||||
importPlatformType.value
|
||||
)
|
||||
|
||||
const newPlaylistId = createResult.data!.id
|
||||
await songListAPI.updateCover(newPlaylistId, detailResult.info.img)
|
||||
|
||||
if (!createResult.success) {
|
||||
MessagePlugin.error('创建本地歌单失败:' + createResult.error)
|
||||
load2.then((res) => res.close())
|
||||
return
|
||||
}
|
||||
|
||||
const addResult = await songListAPI.addSongs(newPlaylistId, songs)
|
||||
load2.then((res) => res.close())
|
||||
|
||||
if (addResult.success) {
|
||||
successCount = songs.length
|
||||
failCount = 0
|
||||
} else {
|
||||
successCount = 0
|
||||
failCount = songs.length
|
||||
console.error('批量添加歌曲失败:', addResult.error)
|
||||
}
|
||||
}
|
||||
load2.then((res) => res.close())
|
||||
|
||||
// 刷新歌单列表
|
||||
await loadPlaylists()
|
||||
@@ -559,7 +784,7 @@ const handleNetworkPlaylistImport = async (input: string) => {
|
||||
// 显示导入结果
|
||||
if (successCount > 0) {
|
||||
MessagePlugin.success(
|
||||
`导入完成!成功导入 ${successCount} 首歌曲` +
|
||||
`从${platformName}导入完成!成功导入 ${successCount} 首歌曲` +
|
||||
(failCount > 0 ? `,${failCount} 首歌曲导入失败` : '')
|
||||
)
|
||||
} else {
|
||||
@@ -917,7 +1142,7 @@ onMounted(() => {
|
||||
</div>
|
||||
<div class="option-content">
|
||||
<h4>从网络歌单</h4>
|
||||
<p>导入网易云、QQ音乐等平台歌单</p>
|
||||
<p>导入网易云音乐、QQ音乐等平台歌单</p>
|
||||
<span class="coming-soon">实验性功能</span>
|
||||
</div>
|
||||
<div class="option-arrow">
|
||||
@@ -930,37 +1155,122 @@ onMounted(() => {
|
||||
<t-dialog
|
||||
v-model:visible="showNetworkImportDialog"
|
||||
placement="center"
|
||||
header="导入网易云音乐歌单"
|
||||
header="导入网络歌单"
|
||||
:confirm-btn="{ content: '开始导入', theme: 'primary' }"
|
||||
:cancel-btn="{ content: '取消', variant: 'outline' }"
|
||||
width="500px"
|
||||
width="600px"
|
||||
:style="{ maxHeight: '80vh' }"
|
||||
@confirm="confirmNetworkImport"
|
||||
@cancel="cancelNetworkImport"
|
||||
>
|
||||
<div class="network-import-content">
|
||||
<p class="import-description">
|
||||
请输入网易云音乐歌单链接或歌单ID,系统将自动识别格式并导入歌单中的所有歌曲到本地歌单。
|
||||
</p>
|
||||
<!-- 平台选择 -->
|
||||
<div class="platform-selector">
|
||||
<label class="form-label">选择导入平台</label>
|
||||
<t-radio-group v-model="importPlatformType" variant="primary-filled">
|
||||
<t-radio-button value="wy"> 网易云音乐 </t-radio-button>
|
||||
<t-radio-button value="tx"> QQ音乐 </t-radio-button>
|
||||
<t-radio-button value="kw"> 酷我音乐 </t-radio-button>
|
||||
<t-radio-button value="kg"> 酷狗音乐 </t-radio-button>
|
||||
<t-radio-button value="mg"> 咪咕音乐 </t-radio-button>
|
||||
</t-radio-group>
|
||||
</div>
|
||||
|
||||
<t-input
|
||||
v-model="networkPlaylistUrl"
|
||||
placeholder="支持链接或ID:https://music.163.com/playlist?id=123456789 或 123456789"
|
||||
clearable
|
||||
autofocus
|
||||
class="url-input"
|
||||
@enter="confirmNetworkImport"
|
||||
/>
|
||||
<!-- 内容区域 - 添加过渡动画 -->
|
||||
<div class="import-content-wrapper">
|
||||
<transition name="fade-slide" mode="out-in">
|
||||
<div :key="importPlatformType" class="import-content">
|
||||
<div style="margin-bottom: 1em">
|
||||
请输入{{
|
||||
importPlatformType === 'wy'
|
||||
? '网易云音乐'
|
||||
: importPlatformType === 'tx'
|
||||
? 'QQ音乐'
|
||||
: importPlatformType === 'kw'
|
||||
? '酷我音乐'
|
||||
: importPlatformType === 'kg'
|
||||
? '酷狗音乐'
|
||||
: importPlatformType === 'mg'
|
||||
? '咪咕音乐'
|
||||
: '音乐平台'
|
||||
}}歌单链接或歌单ID,系统将自动识别格式并导入歌单中的所有歌曲到本地歌单。
|
||||
</div>
|
||||
<t-input
|
||||
v-model="networkPlaylistUrl"
|
||||
:placeholder="
|
||||
importPlatformType === 'wy'
|
||||
? '支持链接或ID:https://music.163.com/playlist?id=123456789 或 123456789'
|
||||
: importPlatformType === 'tx'
|
||||
? '支持链接或ID:https://y.qq.com/n/ryqq/playlist/123456789 或 123456789'
|
||||
: importPlatformType === 'kw'
|
||||
? '支持链接或ID:http://www.kuwo.cn/playlist_detail/123456789 或 123456789'
|
||||
: importPlatformType === 'kg'
|
||||
? '支持链接或ID:https://www.kugou.com/yy/special/single/123456789 或 123456789'
|
||||
: importPlatformType === 'mg'
|
||||
? '支持链接或ID:https://music.migu.cn/v3/music/playlist/123456789 或 123456789'
|
||||
: '请输入歌单链接或ID'
|
||||
"
|
||||
clearable
|
||||
autofocus
|
||||
class="url-input"
|
||||
@enter="confirmNetworkImport"
|
||||
/>
|
||||
|
||||
<div class="import-tips">
|
||||
<p class="tip-title">支持的输入格式:</p>
|
||||
<ul class="tip-list">
|
||||
<li>完整链接:https://music.163.com/playlist?id=123456789</li>
|
||||
<li>手机链接:https://music.163.com/m/playlist?id=123456789</li>
|
||||
<li>分享链接:https://y.music.163.com/m/playlist/123456789</li>
|
||||
<li>纯数字ID:123456789</li>
|
||||
<li>其他包含ID的网易云链接格式</li>
|
||||
</ul>
|
||||
<p class="tip-note">智能识别:系统会自动从输入中提取歌单ID</p>
|
||||
<div class="import-tips">
|
||||
<p class="tip-title">
|
||||
{{
|
||||
importPlatformType === 'wy'
|
||||
? '网易云音乐'
|
||||
: importPlatformType === 'tx'
|
||||
? 'QQ音乐'
|
||||
: importPlatformType === 'kw'
|
||||
? '酷我音乐'
|
||||
: importPlatformType === 'kg'
|
||||
? '酷狗音乐'
|
||||
: importPlatformType === 'mg'
|
||||
? '咪咕音乐'
|
||||
: '音乐平台'
|
||||
}}支持的输入格式:
|
||||
</p>
|
||||
<ul v-if="importPlatformType === 'wy'" class="tip-list">
|
||||
<li>完整链接:https://music.163.com/playlist?id=123456789</li>
|
||||
<li>手机链接:https://music.163.com/m/playlist?id=123456789</li>
|
||||
<li>分享链接:https://y.music.163.com/m/playlist/123456789</li>
|
||||
<li>纯数字ID:123456789</li>
|
||||
<li>其他包含ID的网易云链接格式</li>
|
||||
</ul>
|
||||
<ul v-else-if="importPlatformType === 'tx'" class="tip-list">
|
||||
<li>完整链接:https://y.qq.com/n/ryqq/playlist/123456789</li>
|
||||
<li>手机链接:https://i.y.qq.com/v8/playsquare/playlist.html?id=123456789</li>
|
||||
<li>分享链接:https://i.y.qq.com/n2/m/share/details/taoge.html?id=123456789</li>
|
||||
<li>其他分享:https://c.y.qq.com/base/fcgi-bin/u?__=123456789</li>
|
||||
<li>纯数字ID:123456789</li>
|
||||
</ul>
|
||||
<ul v-else-if="importPlatformType === 'kw'" class="tip-list">
|
||||
<li>完整链接:http://www.kuwo.cn/playlist_detail/123456789</li>
|
||||
<li>手机链接:http://m.kuwo.cn/h5app/playlist/123456789</li>
|
||||
<li>参数链接:http://www.kuwo.cn/playlist?pid=123456789</li>
|
||||
<li>纯数字ID:123456789</li>
|
||||
<li>其他包含ID的酷我音乐链接格式</li>
|
||||
</ul>
|
||||
<ul v-else-if="importPlatformType === 'kg'" class="tip-list">
|
||||
<li>完整链接:https://www.kugou.com/yy/special/single/123456789</li>
|
||||
<li>手机版链接:https://m.kugou.com/songlist/gcid_3z9vj0yqz4bz00b</li>
|
||||
<li>旧版手机链接:https://m.kugou.com/playlist?id=123456789</li>
|
||||
<li>参数链接:https://www.kugou.com/playlist?specialid=123456789</li>
|
||||
<li>纯数字ID:123456789</li>
|
||||
</ul>
|
||||
<ul v-else-if="importPlatformType === 'mg'" class="tip-list">
|
||||
<li>完整链接:https://music.migu.cn/v3/music/playlist/123456789</li>
|
||||
<li>手机链接:https://m.music.migu.cn/playlist?id=123456789</li>
|
||||
<li>参数链接:https://music.migu.cn/playlist?id=123456789</li>
|
||||
<li>纯数字ID:123456789</li>
|
||||
<li>其他包含ID的咪咕音乐链接格式</li>
|
||||
</ul>
|
||||
<p class="tip-note">智能识别:系统会自动从输入中提取歌单ID</p>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
</div>
|
||||
</t-dialog>
|
||||
@@ -1055,48 +1365,190 @@ onMounted(() => {
|
||||
|
||||
// 网络歌单导入对话框样式
|
||||
.network-import-content {
|
||||
.import-description {
|
||||
margin-bottom: 1rem;
|
||||
color: #666;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
max-height: 60vh;
|
||||
overflow-y: auto;
|
||||
scrollbar-width: none;
|
||||
padding: 0 10px;
|
||||
// 自定义滚动条样式
|
||||
&::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.url-input {
|
||||
margin-bottom: 1.5rem;
|
||||
&::-webkit-scrollbar-track {
|
||||
background: #f1f1f1;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.import-tips {
|
||||
background: #f8f9fa;
|
||||
border-radius: 6px;
|
||||
padding: 1rem;
|
||||
border-left: 3px solid #507daf;
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: #c1c1c1;
|
||||
border-radius: 3px;
|
||||
|
||||
.tip-title {
|
||||
margin: 0 0 0.5rem 0;
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
font-size: 14px;
|
||||
&:hover {
|
||||
background: #a8a8a8;
|
||||
}
|
||||
}
|
||||
|
||||
.platform-selector {
|
||||
margin-bottom: 2rem;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background: #fff;
|
||||
z-index: 10;
|
||||
padding: 0.5rem 0;
|
||||
margin: -0.5rem 0 1.5rem 0;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
|
||||
.form-label {
|
||||
display: block;
|
||||
margin-bottom: 1rem;
|
||||
font-weight: 600;
|
||||
color: #374151;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.tip-list {
|
||||
margin: 0 0 0.5rem 0;
|
||||
padding-left: 1.2rem;
|
||||
:deep(.t-radio-group) {
|
||||
width: 100%;
|
||||
|
||||
li {
|
||||
color: #666;
|
||||
font-size: 13px;
|
||||
margin-bottom: 0.25rem;
|
||||
font-family: 'Consolas', 'Monaco', monospace;
|
||||
.t-radio-button {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
.t-radio-button__label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
font-weight: 500;
|
||||
text-align: center;
|
||||
.iconfont {
|
||||
font-size: 16px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
}
|
||||
|
||||
&.t-is-checked .t-radio-button__label .iconfont {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.tip-note {
|
||||
margin: 0;
|
||||
color: #999;
|
||||
font-size: 12px;
|
||||
font-style: italic;
|
||||
.import-content-wrapper {
|
||||
position: relative;
|
||||
min-height: 200px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.import-content {
|
||||
.import-description {
|
||||
margin-bottom: 1.25rem;
|
||||
color: #64748b;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
padding: 1rem;
|
||||
background: linear-gradient(135deg, #f8fafc, #f1f5f9);
|
||||
border-radius: 8px;
|
||||
border-left: 4px solid var(--td-brand-color-4);
|
||||
}
|
||||
|
||||
.url-input {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.import-tips {
|
||||
background: linear-gradient(135deg, #f8fafc, #f1f5f9);
|
||||
border-radius: 12px;
|
||||
padding: 1.25rem;
|
||||
border: 1px solid #e2e8f0;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 4px;
|
||||
height: 100%;
|
||||
background: linear-gradient(to bottom, var(--td-brand-color-4), var(--td-brand-color-6));
|
||||
}
|
||||
|
||||
.tip-title {
|
||||
margin: 0 0 0.75rem 0;
|
||||
font-weight: 600;
|
||||
color: #334155;
|
||||
font-size: 15px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
|
||||
&::before {
|
||||
content: '💡';
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.tip-list {
|
||||
margin: 0 0 0.75rem 0;
|
||||
padding-left: 1.5rem;
|
||||
|
||||
li {
|
||||
color: #64748b;
|
||||
font-size: 13px;
|
||||
margin-bottom: 0.5rem;
|
||||
font-family: 'SF Mono', 'Monaco', 'Consolas', monospace;
|
||||
padding: 0.25rem 0.5rem;
|
||||
background: rgba(255, 255, 255, 0.6);
|
||||
border-radius: 4px;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
transform: translateX(4px);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.tip-note {
|
||||
margin: 0;
|
||||
color: #94a3b8;
|
||||
font-size: 12px;
|
||||
font-style: italic;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem;
|
||||
background: rgba(255, 255, 255, 0.5);
|
||||
border-radius: 6px;
|
||||
|
||||
&::before {
|
||||
content: '✨';
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 过渡动画
|
||||
.fade-slide-enter-active,
|
||||
.fade-slide-leave-active {
|
||||
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.fade-slide-enter-from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px) scale(0.95);
|
||||
}
|
||||
|
||||
.fade-slide-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(-20px) scale(0.95);
|
||||
}
|
||||
|
||||
.fade-slide-enter-to,
|
||||
.fade-slide-leave-from {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -735,6 +735,44 @@ const openLink = (url: string) => {
|
||||
禁止修改后用于侵犯第三方权益的场景。
|
||||
</p>
|
||||
</div>
|
||||
<div class="notice-item">
|
||||
<h4>🚫 使用限制</h4>
|
||||
<p>
|
||||
本项目仅允许用于非商业、纯技术学习目的,禁止用于任何商业运营、盈利活动,
|
||||
禁止修改后用于侵犯第三方权益的场景。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<h3 style="margin-top: 2rem">关于我们(菜单)</h3>
|
||||
<div class="legal-notice">
|
||||
<div class="notice-item">
|
||||
<h4>😊 时迁酱</h4>
|
||||
<p>
|
||||
你好呀好呀~我是 (时迁酱)
|
||||
<br />
|
||||
一枚普普通通的高中生,因为好奇+喜欢,悄悄自学了一点编程✨!
|
||||
<br />
|
||||
<br />
|
||||
没想到今天你能用上我做的软件——「澜音」,它其实是我学 Electron
|
||||
时孵出来的小demo!
|
||||
<br />
|
||||
看到它真的能运行、还有人愿意用,我真的超级开心+骄傲的!💖
|
||||
<br />
|
||||
<br />
|
||||
当然啦,平时还是要乖乖写作业上课哒~但我还是会继续挤出时间,让澜音慢慢长大,越走越远哒!💪
|
||||
<br />
|
||||
<br />
|
||||
如果你也喜欢它,或者想给我加点零食鼓励🧋,欢迎打赏赞助哟~谢谢可爱的你!!
|
||||
<img
|
||||
src="https://oss.shiqianjiang.cn/storage/default/20250907/image-2025082711173bb1bba3608ef15d0e1fb485f80f29c728186.png"
|
||||
alt="赞赏"
|
||||
style="width: 100%; padding: 20px 30%"
|
||||
/>
|
||||
什么你也想学习编程?我教你吖!QQ:2115295703
|
||||
</p>
|
||||
<br />
|
||||
<h4>...待补充</h4>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user