Compare commits

...

3 Commits

17 changed files with 1623 additions and 121 deletions

View File

@@ -33,7 +33,8 @@ export default defineConfig({
]
},
{ text: '软件设计文档', link: '/guide/design' },
{ text: '更新日志', link: '/guide/updateLog' }
{ text: '更新日志', link: '/guide/updateLog' },
{ text: '更新计划', link: '/guide/update'}
]
},
{

Binary file not shown.

After

Width:  |  Height:  |  Size: 606 KiB

12
docs/guide/update.md Normal file
View File

@@ -0,0 +1,12 @@
# 我的-更新计划-欢迎issue
- [ ] 搜索框可以弄对应的音乐源的排行榜 搜索联想
- [ ] 导航上面这几个按钮可以稍微优化一下
- [ ] 大熊好暖: 09-21 12:52:25 在搜索歌曲出来后 大熊好暖: 09-21 12:52:34 一般都是双击播放 大熊好暖: 09-21 12:54:16 这个是双击加入播放列表 可以优化 或者可以把这个双击做一个选项,让人选择
- [ ] 播放页 样式模板选择 歌词背景律动很有沉浸感,但是黑胶唱片的专辑样式不是很喜欢,希望增加一个选项如图二一样只有圆形专辑封面
- [x] 点击搜索框的 源图标实现快速切换
- [ ] ai功能完善
- [ ] 支持歌词隐藏
- [x] 兼容多平台歌单导入
- [ ] 软件能不能记住上次打开的窗口大小,每次都要手动拉

View File

@@ -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",

View File

@@ -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
},

View File

@@ -24,5 +24,4 @@ const kg = {
return `https://www.kugou.com/song/#hash=${songInfo.hash}&album_id=${songInfo.albumId}`
}
}
export default kg

View File

@@ -21,5 +21,4 @@ const tx = {
return `https://y.qq.com/n/yqq/song/${songInfo.songmid}.html`
}
}
export default tx

View File

@@ -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'))

View 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
}

View File

@@ -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']

View File

@@ -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

View File

@@ -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)
}

View File

@@ -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 {

View File

@@ -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>

View File

@@ -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="支持链接或IDhttps://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'
? '支持链接或IDhttps://music.163.com/playlist?id=123456789 或 123456789'
: importPlatformType === 'tx'
? '支持链接或IDhttps://y.qq.com/n/ryqq/playlist/123456789 或 123456789'
: importPlatformType === 'kw'
? '支持链接或IDhttp://www.kuwo.cn/playlist_detail/123456789 或 123456789'
: importPlatformType === 'kg'
? '支持链接或IDhttps://www.kugou.com/yy/special/single/123456789 或 123456789'
: importPlatformType === 'mg'
? '支持链接或IDhttps://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>纯数字ID123456789</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>纯数字ID123456789</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>纯数字ID123456789</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>纯数字ID123456789</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>纯数字ID123456789</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>纯数字ID123456789</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);
}
}

View File

@@ -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>