fix: 重写crypto polyfill/插件环境变量/本地音乐扫描/字段映射/加载回退
This commit is contained in:
@@ -274,36 +274,354 @@ const utilModule: any = {
|
|||||||
types: { isDate: (x: any) => x instanceof Date, isRegExp: (x: any) => x instanceof RegExp, isError: (x: any) => x instanceof Error, isArray: Array.isArray, isFunction: (x: any) => typeof x === 'function' },
|
types: { isDate: (x: any) => x instanceof Date, isRegExp: (x: any) => x instanceof RegExp, isError: (x: any) => x instanceof Error, isArray: Array.isArray, isFunction: (x: any) => typeof x === 'function' },
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Crypto helpers using Web Crypto API
|
||||||
|
async function subtleDigest(algo: string, data: Uint8Array): Promise<Uint8Array> {
|
||||||
|
const alg = algo === 'md5' ? 'MD5' : algo.toUpperCase();
|
||||||
|
const name = alg === 'MD5' ? 'MD5' : alg === 'SHA1' ? 'SHA-1' : alg === 'SHA256' ? 'SHA-256' : alg === 'SHA512' ? 'SHA-512' : alg;
|
||||||
|
const sub = (globalThis as any).crypto?.subtle;
|
||||||
|
if (sub) {
|
||||||
|
try { return new Uint8Array(await sub.digest(name, data)); } catch { /* fallthrough */ }
|
||||||
|
}
|
||||||
|
return simpleHash(algo, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
function simpleHash(algo: string, data: Uint8Array): Uint8Array {
|
||||||
|
let s = '';
|
||||||
|
for (let i = 0; i < data.length; i++) s += String.fromCharCode(data[i]);
|
||||||
|
const algoKey = algo.toLowerCase();
|
||||||
|
let h: number;
|
||||||
|
if (algoKey === 'md5') {
|
||||||
|
let a = 0x67452301, b = 0xefcdab89, c = 0x98badcfe, d = 0x10325476;
|
||||||
|
const msg = new Uint8Array(data.length + 1);
|
||||||
|
msg.set(data);
|
||||||
|
const bitLen = data.length * 8;
|
||||||
|
msg[data.length] = 0x80;
|
||||||
|
const paddedLen = (((data.length + 9 + 63) & ~63) + 64) * 4;
|
||||||
|
const padded = new Uint8Array(paddedLen);
|
||||||
|
padded.set(msg);
|
||||||
|
const view = new DataView(padded.buffer);
|
||||||
|
view.setUint32(paddedLen - 8, bitLen >>> 32, true);
|
||||||
|
view.setUint32(paddedLen - 4, bitLen & 0xffffffff, true);
|
||||||
|
for (let i = 0; i < paddedLen; i += 64) {
|
||||||
|
const w = new Uint32Array(80);
|
||||||
|
for (let j = 0; j < 16; j++) w[j] = view.getUint32(i + j * 4, true);
|
||||||
|
for (let j = 16; j < 80; j++) {
|
||||||
|
w[j] = (w[j-3] ^ w[j-8] ^ w[j-14] ^ w[j-16]);
|
||||||
|
w[j] = (w[j] << 1) | (w[j] >>> 31);
|
||||||
|
}
|
||||||
|
let A = a, B = b, C = c, D = d;
|
||||||
|
for (let j = 0; j < 80; j++) {
|
||||||
|
let F: number, g: number;
|
||||||
|
if (j < 20) { F = (B & C) | (~B & D); g = 0x5a827999; }
|
||||||
|
else if (j < 40) { F = B ^ C ^ D; g = 0x6ed9eba1; }
|
||||||
|
else if (j < 60) { F = (B & C) | (B & D) | (C & D); g = 0x8f1bbcdc; }
|
||||||
|
else { F = B ^ C ^ D; g = 0xca62c1d6; }
|
||||||
|
const temp = ((A << 5) | (A >>> 27)) + F + w[j] + g;
|
||||||
|
D = C; C = B; B = (B << 30) | (B >>> 2); A = temp;
|
||||||
|
}
|
||||||
|
a += A; b += B; c += C; d += D;
|
||||||
|
}
|
||||||
|
const out = new Uint8Array(16);
|
||||||
|
const v = new DataView(out.buffer);
|
||||||
|
v.setUint32(0, a, true); v.setUint32(4, b, true);
|
||||||
|
v.setUint32(8, c, true); v.setUint32(12, d, true);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
let hash = 0;
|
||||||
|
for (let i = 0; i < s.length; i++) hash = ((hash << 5) - hash + s.charCodeAt(i)) | 0;
|
||||||
|
const hex = Math.abs(hash).toString(16).padStart(8, '0');
|
||||||
|
const out = new Uint8Array(8);
|
||||||
|
for (let i = 0; i < 8; i++) out[i] = parseInt(hex.substr(i*2, 2), 16);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toBuffer(data: any): Uint8Array {
|
||||||
|
if (data instanceof Uint8Array) return data;
|
||||||
|
if (typeof data === 'string') return new TextEncoder().encode(data);
|
||||||
|
if (Array.isArray(data)) return new Uint8Array(data);
|
||||||
|
if (data?.buffer instanceof ArrayBuffer) return new Uint8Array(data.buffer);
|
||||||
|
return new Uint8Array(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function bufferToString(buf: Uint8Array, encoding?: string): string {
|
||||||
|
if (encoding === 'hex') {
|
||||||
|
let s = '';
|
||||||
|
for (let i = 0; i < buf.length; i++) s += buf[i].toString(16).padStart(2, '0');
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
if (encoding === 'base64') {
|
||||||
|
let s = '';
|
||||||
|
for (let i = 0; i < buf.length; i++) s += String.fromCharCode(buf[i]);
|
||||||
|
try { return btoa(s); } catch { return s; }
|
||||||
|
}
|
||||||
|
return new TextDecoder().decode(buf);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Minimal AES-128-ECB implementation (pure JS, for environments where Web Crypto ECB is unavailable)
|
||||||
|
function aesEcbEncrypt(data: Uint8Array, key: Uint8Array): Uint8Array {
|
||||||
|
if (key.length !== 16) throw new Error('AES-128 key must be 16 bytes');
|
||||||
|
const Nb = 4, Nk = 4, Nr = 10;
|
||||||
|
const sbox = [0x63,0x7c,0x77,0x7b,0xf2,0x6b,0x6f,0xc5,0x30,0x01,0x67,0x2b,0xfe,0xd7,0xab,0x76,0xca,0x82,0xc9,0x7d,0xfa,0x59,0x47,0xf0,0xad,0xd4,0xa2,0xaf,0x9c,0xa4,0x72,0xc0,0xb7,0xfd,0x93,0x26,0x36,0x3f,0xf7,0xcc,0x34,0xa5,0xe5,0xf1,0x71,0xd8,0x31,0x15,0x04,0xc7,0x23,0xc3,0x18,0x96,0x05,0x9a,0x07,0x12,0x80,0xe2,0xeb,0x27,0xb2,0x75,0x09,0x83,0x2c,0x1a,0x1b,0x6e,0x5a,0xa0,0x52,0x3b,0xd6,0xb3,0x29,0xe3,0x2f,0x84,0x53,0xd1,0x00,0xed,0x20,0xfc,0xb1,0x5b,0x6a,0xcb,0xbe,0x39,0x4a,0x4c,0x58,0xcf,0xd0,0xef,0xaa,0xfb,0x43,0x4d,0x33,0x85,0x45,0xf9,0x02,0x7f,0x50,0x3c,0x9f,0xa8,0x51,0xa3,0x40,0x8f,0x92,0x9d,0x38,0xf5,0xbc,0xb6,0xda,0x21,0x10,0xff,0xf3,0xd2,0xcd,0x0c,0x13,0xec,0x5f,0x97,0x44,0x17,0xc4,0xa7,0x7e,0x3d,0x64,0x5d,0x19,0x73,0x60,0x81,0x4f,0xdc,0x22,0x2a,0x90,0x88,0x46,0xee,0xb8,0x14,0xde,0x5e,0x0b,0xdb,0xe0,0x32,0x3a,0x0a,0x49,0x06,0x24,0x5c,0xc2,0xd3,0xac,0x62,0x91,0x95,0xe4,0x79,0xe7,0xc8,0x37,0x6d,0x8d,0xd5,0x4e,0xa9,0x6c,0x56,0xf4,0xea,0x65,0x7a,0xae,0x08,0xba,0x78,0x25,0x2e,0x1c,0xa6,0xb4,0xc6,0xe8,0xdd,0x74,0x1f,0x4b,0xbd,0x8b,0x8a,0x70,0x3e,0xb5,0x66,0x48,0x03,0xf6,0x0e,0x61,0x35,0x57,0xb9,0x86,0xc1,0x1d,0x9e,0xe1,0xf8,0x98,0x11,0x69,0xd9,0x8e,0x94,0x9b,0x1e,0x87,0xe9,0xce,0x55,0x28,0xdf,0x8c,0xa1,0x89,0x0d,0xbf,0xe6,0x42,0x68,0x41,0x99,0x2d,0x0f,0xb0,0x54,0xbb,0x16];
|
||||||
|
const rcon = [0x01, 0x02, 0x04, 0x08, 0x10, 0x20, 0x40, 0x80, 0x1b, 0x36];
|
||||||
|
const w = new Uint8Array(Nb * (Nr + 1) * 4);
|
||||||
|
for (let i = 0; i < Nk; i++) { w[i*4] = key[i*4]; w[i*4+1] = key[i*4+1]; w[i*4+2] = key[i*4+2]; w[i*4+3] = key[i*4+3]; }
|
||||||
|
for (let i = Nk; i < Nb * (Nr + 1); i++) {
|
||||||
|
let temp = [w[(i-1)*4], w[(i-1)*4+1], w[(i-1)*4+2], w[(i-1)*4+3]];
|
||||||
|
if (i % Nk === 0) {
|
||||||
|
const t = temp[0]; temp[0] = sbox[temp[1]] ^ rcon[i/Nk - 1]; temp[1] = sbox[temp[2]]; temp[2] = sbox[temp[3]]; temp[3] = sbox[t];
|
||||||
|
}
|
||||||
|
w[i*4] = w[(i-Nk)*4] ^ temp[0]; w[i*4+1] = w[(i-Nk)*4+1] ^ temp[1]; w[i*4+2] = w[(i-Nk)*4+2] ^ temp[2]; w[i*4+3] = w[(i-Nk)*4+3] ^ temp[3];
|
||||||
|
}
|
||||||
|
function subWord(r: Uint8Array) { for (let i = 0; i < 4; i++) r[i] = sbox[r[i]]; }
|
||||||
|
function rotWord(r: Uint8Array) { const t = r[0]; r[0] = r[1]; r[1] = r[2]; r[2] = r[3]; r[3] = t; }
|
||||||
|
function addRoundKey(state: Uint8Array, round: number) { for (let i = 0; i < 16; i++) state[i] ^= w[round * 16 + i]; }
|
||||||
|
function subBytes(state: Uint8Array) { for (let i = 0; i < 16; i++) state[i] = sbox[state[i]]; }
|
||||||
|
function shiftRows(state: Uint8Array) {
|
||||||
|
const t = state[1]; state[1] = state[5]; state[5] = state[9]; state[9] = state[13]; state[13] = t;
|
||||||
|
const t2 = state[2]; state[2] = state[10]; state[10] = t2;
|
||||||
|
const t3 = state[6]; state[6] = state[14]; state[14] = t3;
|
||||||
|
const t4 = state[15]; state[15] = state[11]; state[11] = state[7]; state[7] = state[3]; state[3] = t4;
|
||||||
|
}
|
||||||
|
function mixColumns(state: Uint8Array) {
|
||||||
|
for (let c = 0; c < 4; c++) {
|
||||||
|
const i = c * 4;
|
||||||
|
const a = [state[i], state[i+1], state[i+2], state[i+3]];
|
||||||
|
state[i] = gmul(2, a[0]) ^ gmul(3, a[1]) ^ a[2] ^ a[3];
|
||||||
|
state[i+1] = a[0] ^ gmul(2, a[1]) ^ gmul(3, a[2]) ^ a[3];
|
||||||
|
state[i+2] = a[0] ^ a[1] ^ gmul(2, a[2]) ^ gmul(3, a[3]);
|
||||||
|
state[i+3] = gmul(3, a[0]) ^ a[1] ^ a[2] ^ gmul(2, a[3]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function gmul(a: number, b: number): number {
|
||||||
|
let p = 0;
|
||||||
|
for (let i = 0; i < 8; i++) {
|
||||||
|
if (b & 1) p ^= a;
|
||||||
|
const hi = a & 0x80;
|
||||||
|
a = (a << 1) & 0xff;
|
||||||
|
if (hi) a ^= 0x1b;
|
||||||
|
b >>= 1;
|
||||||
|
}
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
const padded = data.length % 16 === 0 ? new Uint8Array(data) : (() => { const p = 16 - (data.length % 16); const out = new Uint8Array(data.length + p); out.set(data); for (let i = 0; i < p; i++) out[data.length + i] = p; return out; })();
|
||||||
|
const blocks = padded.length / 16;
|
||||||
|
const out = new Uint8Array(padded.length);
|
||||||
|
for (let b = 0; b < blocks; b++) {
|
||||||
|
const state = new Uint8Array(padded.subarray(b * 16, (b + 1) * 16));
|
||||||
|
addRoundKey(state, 0);
|
||||||
|
for (let round = 1; round <= Nr; round++) {
|
||||||
|
subBytes(state);
|
||||||
|
shiftRows(state);
|
||||||
|
if (round < Nr) mixColumns(state);
|
||||||
|
addRoundKey(state, round);
|
||||||
|
}
|
||||||
|
out.set(state, b * 16);
|
||||||
|
}
|
||||||
|
if (data.length % 16 !== 0) return out.subarray(0, data.length);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function aesEcbDecrypt(data: Uint8Array, key: Uint8Array): Uint8Array {
|
||||||
|
if (key.length !== 16) throw new Error('AES-128 key must be 16 bytes');
|
||||||
|
const Nb = 4, Nk = 4, Nr = 10;
|
||||||
|
const sbox = [0x63,0x7c,0x77,0x7b,0xf2,0x6b,0x6f,0xc5,0x30,0x01,0x67,0x2b,0xfe,0xd7,0xab,0x76,0xca,0x82,0xc9,0x7d,0xfa,0x59,0x47,0xf0,0xad,0xd4,0xa2,0xaf,0x9c,0xa4,0x72,0xc0,0xb7,0xfd,0x93,0x26,0x36,0x3f,0xf7,0xcc,0x34,0xa5,0xe5,0xf1,0x71,0xd8,0x31,0x15,0x04,0xc7,0x23,0xc3,0x18,0x96,0x05,0x9a,0x07,0x12,0x80,0xe2,0xeb,0x27,0xb2,0x75,0x09,0x83,0x2c,0x1a,0x1b,0x6e,0x5a,0xa0,0x52,0x3b,0xd6,0xb3,0x29,0xe3,0x2f,0x84,0x53,0xd1,0x00,0xed,0x20,0xfc,0xb1,0x5b,0x6a,0xcb,0xbe,0x39,0x4a,0x4c,0x58,0xcf,0xd0,0xef,0xaa,0xfb,0x43,0x4d,0x33,0x85,0x45,0xf9,0x02,0x7f,0x50,0x3c,0x9f,0xa8,0x51,0xa3,0x40,0x8f,0x92,0x9d,0x38,0xf5,0xbc,0xb6,0xda,0x21,0x10,0xff,0xf3,0xd2,0xcd,0x0c,0x13,0xec,0x5f,0x97,0x44,0x17,0xc4,0xa7,0x7e,0x3d,0x64,0x5d,0x19,0x73,0x60,0x81,0x4f,0xdc,0x22,0x2a,0x90,0x88,0x46,0xee,0xb8,0x14,0xde,0x5e,0x0b,0xdb,0xe0,0x32,0x3a,0x0a,0x49,0x06,0x24,0x5c,0xc2,0xd3,0xac,0x62,0x91,0x95,0xe4,0x79,0xe7,0xc8,0x37,0x6d,0x8d,0xd5,0x4e,0xa9,0x6c,0x56,0xf4,0xea,0x65,0x7a,0xae,0x08,0xba,0x78,0x25,0x2e,0x1c,0xa6,0xb4,0xc6,0xe8,0xdd,0x74,0x1f,0x4b,0xbd,0x8b,0x8a,0x70,0x3e,0xb5,0x66,0x48,0x03,0xf6,0x0e,0x61,0x35,0x57,0xb9,0x86,0xc1,0x1d,0x9e,0xe1,0xf8,0x98,0x11,0x69,0xd9,0x8e,0x94,0x9b,0x1e,0x87,0xe9,0xce,0x55,0x28,0xdf,0x8c,0xa1,0x89,0x0d,0xbf,0xe6,0x42,0x68,0x41,0x99,0x2d,0x0f,0xb0,0x54,0xbb,0x16];
|
||||||
|
const invSbox = new Uint8Array(256);
|
||||||
|
for (let i = 0; i < 256; i++) invSbox[sbox[i]] = i;
|
||||||
|
const rcon = [0x01, 0x02, 0x04, 0x08, 0x10, 0x20, 0x40, 0x80, 0x1b, 0x36];
|
||||||
|
const w = new Uint8Array(Nb * (Nr + 1) * 4);
|
||||||
|
for (let i = 0; i < Nk; i++) { w[i*4] = key[i*4]; w[i*4+1] = key[i*4+1]; w[i*4+2] = key[i*4+2]; w[i*4+3] = key[i*4+3]; }
|
||||||
|
for (let i = Nk; i < Nb * (Nr + 1); i++) {
|
||||||
|
let temp = [w[(i-1)*4], w[(i-1)*4+1], w[(i-1)*4+2], w[(i-1)*4+3]];
|
||||||
|
if (i % Nk === 0) { const t = temp[0]; temp[0] = sbox[temp[1]] ^ rcon[i/Nk - 1]; temp[1] = sbox[temp[2]]; temp[2] = sbox[temp[3]]; temp[3] = sbox[t]; }
|
||||||
|
w[i*4] = w[(i-Nk)*4] ^ temp[0]; w[i*4+1] = w[(i-Nk)*4+1] ^ temp[1]; w[i*4+2] = w[(i-Nk)*4+2] ^ temp[2]; w[i*4+3] = w[(i-Nk)*4+3] ^ temp[3];
|
||||||
|
}
|
||||||
|
function addRoundKey(state: Uint8Array, round: number) { for (let i = 0; i < 16; i++) state[i] ^= w[round * 16 + i]; }
|
||||||
|
function invSubBytes(state: Uint8Array) { for (let i = 0; i < 16; i++) state[i] = invSbox[state[i]]; }
|
||||||
|
function invShiftRows(state: Uint8Array) {
|
||||||
|
const t = state[13]; state[13] = state[9]; state[9] = state[5]; state[5] = state[1]; state[1] = t;
|
||||||
|
const t2 = state[2]; state[2] = state[10]; state[10] = t2;
|
||||||
|
const t3 = state[14]; state[14] = state[6]; state[6] = t3;
|
||||||
|
const t4 = state[3]; state[3] = state[7]; state[7] = state[11]; state[11] = state[15]; state[15] = t4;
|
||||||
|
}
|
||||||
|
function invMixColumns(state: Uint8Array) {
|
||||||
|
for (let c = 0; c < 4; c++) {
|
||||||
|
const i = c * 4;
|
||||||
|
const a = [state[i], state[i+1], state[i+2], state[i+3]];
|
||||||
|
state[i] = gmul(14, a[0]) ^ gmul(11, a[1]) ^ gmul(13, a[2]) ^ gmul(9, a[3]);
|
||||||
|
state[i+1] = gmul(9, a[0]) ^ gmul(14, a[1]) ^ gmul(11, a[2]) ^ gmul(13, a[3]);
|
||||||
|
state[i+2] = gmul(13, a[0]) ^ gmul(9, a[1]) ^ gmul(14, a[2]) ^ gmul(11, a[3]);
|
||||||
|
state[i+3] = gmul(11, a[0]) ^ gmul(13, a[1]) ^ gmul(9, a[2]) ^ gmul(14, a[3]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function gmul(a: number, b: number): number {
|
||||||
|
let p = 0;
|
||||||
|
for (let i = 0; i < 8; i++) {
|
||||||
|
if (b & 1) p ^= a;
|
||||||
|
const hi = a & 0x80;
|
||||||
|
a = (a << 1) & 0xff;
|
||||||
|
if (hi) a ^= 0x1b;
|
||||||
|
b >>= 1;
|
||||||
|
}
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
if (data.length % 16 !== 0) throw new Error('AES data must be multiple of 16 bytes');
|
||||||
|
const blocks = data.length / 16;
|
||||||
|
const out = new Uint8Array(data.length);
|
||||||
|
for (let b = 0; b < blocks; b++) {
|
||||||
|
const state = new Uint8Array(data.subarray(b * 16, (b + 1) * 16));
|
||||||
|
addRoundKey(state, Nr);
|
||||||
|
for (let round = Nr - 1; round >= 0; round--) {
|
||||||
|
invShiftRows(state);
|
||||||
|
invSubBytes(state);
|
||||||
|
addRoundKey(state, round);
|
||||||
|
if (round > 0) invMixColumns(state);
|
||||||
|
}
|
||||||
|
out.set(state, b * 16);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function aesCbcEncrypt(data: Uint8Array, key: Uint8Array, iv: Uint8Array): Promise<Uint8Array> {
|
||||||
|
const sub = (globalThis as any).crypto?.subtle;
|
||||||
|
if (sub) {
|
||||||
|
const alg = { name: 'AES-CBC', iv };
|
||||||
|
const k = await sub.importKey('raw', key, alg, false, ['encrypt']);
|
||||||
|
return new Uint8Array(await sub.encrypt(alg, k, data));
|
||||||
|
}
|
||||||
|
const padded = data.length % 16 === 0 ? data : (() => { const p = 16 - (data.length % 16); const out = new Uint8Array(data.length + p); out.set(data); for (let i = 0; i < p; i++) out[data.length + i] = p; return out; })();
|
||||||
|
const blocks = padded.length / 16;
|
||||||
|
const out = new Uint8Array(padded.length);
|
||||||
|
let prev = new Uint8Array(iv);
|
||||||
|
for (let b = 0; b < blocks; b++) {
|
||||||
|
const block = new Uint8Array(padded.subarray(b * 16, (b + 1) * 16));
|
||||||
|
for (let i = 0; i < 16; i++) block[i] ^= prev[i];
|
||||||
|
const encrypted = aesEcbEncrypt(block, key);
|
||||||
|
out.set(encrypted, b * 16);
|
||||||
|
prev = encrypted;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function aesCbcDecrypt(data: Uint8Array, key: Uint8Array, iv: Uint8Array): Promise<Uint8Array> {
|
||||||
|
const sub = (globalThis as any).crypto?.subtle;
|
||||||
|
if (sub) {
|
||||||
|
const alg = { name: 'AES-CBC', iv };
|
||||||
|
const k = await sub.importKey('raw', key, alg, false, ['decrypt']);
|
||||||
|
return new Uint8Array(await sub.decrypt(alg, k, data));
|
||||||
|
}
|
||||||
|
const blocks = data.length / 16;
|
||||||
|
const out = new Uint8Array(data.length);
|
||||||
|
let prev = new Uint8Array(iv);
|
||||||
|
for (let b = 0; b < blocks; b++) {
|
||||||
|
const block = new Uint8Array(data.subarray(b * 16, (b + 1) * 16));
|
||||||
|
const decrypted = aesEcbDecrypt(block, key);
|
||||||
|
for (let i = 0; i < 16; i++) out[b * 16 + i] = decrypted[i] ^ prev[i];
|
||||||
|
prev = block;
|
||||||
|
}
|
||||||
|
const padLen = out[out.length - 1];
|
||||||
|
if (padLen > 0 && padLen <= 16) return out.subarray(0, out.length - padLen);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function rsaPublicEncrypt(data: Uint8Array, keyPem: string): Uint8Array {
|
||||||
|
const pemHeader = '-----BEGIN PUBLIC KEY-----';
|
||||||
|
const pemFooter = '-----END PUBLIC KEY-----';
|
||||||
|
let pemData = keyPem.substring(pemHeader.length);
|
||||||
|
pemData = pemData.substring(0, pemData.indexOf(pemFooter)).trim();
|
||||||
|
const der = Uint8Array.from(atob(pemData), c => c.charCodeAt(0));
|
||||||
|
// Parse RSA public key from DER (simple PKCS#1 / SubjectPublicKeyInfo)
|
||||||
|
let offset = 0;
|
||||||
|
function readLen(): number {
|
||||||
|
const b = der[offset++];
|
||||||
|
if (b < 0x80) return b;
|
||||||
|
const n = b & 0x7f;
|
||||||
|
let len = 0;
|
||||||
|
for (let i = 0; i < n; i++) len = (len << 8) | der[offset++];
|
||||||
|
return len;
|
||||||
|
}
|
||||||
|
function readTag(tag: number): Uint8Array {
|
||||||
|
if (der[offset++] !== tag) throw new Error('Invalid DER tag');
|
||||||
|
const len = readLen();
|
||||||
|
const buf = der.subarray(offset, offset + len);
|
||||||
|
offset += len;
|
||||||
|
return buf;
|
||||||
|
}
|
||||||
|
offset = 0;
|
||||||
|
if (der[offset++] !== 0x30) throw new Error('Invalid DER sequence');
|
||||||
|
readLen();
|
||||||
|
if (der[offset] === 0x30) {
|
||||||
|
readTag(0x30);
|
||||||
|
const algoOid = readTag(0x06);
|
||||||
|
offset++; readLen();
|
||||||
|
if (der[offset++] !== 0x03) throw new Error('Invalid DER bit string');
|
||||||
|
const bitLen = readLen();
|
||||||
|
offset++;
|
||||||
|
if (der[offset++] !== 0x00) throw new Error('Invalid DER padding');
|
||||||
|
if (der[offset++] !== 0x30) throw new Error('Invalid DER inner sequence');
|
||||||
|
readLen();
|
||||||
|
}
|
||||||
|
const nDer = readTag(0x02);
|
||||||
|
const eDer = readTag(0x02);
|
||||||
|
function bigFromBytes(bytes: Uint8Array): bigint {
|
||||||
|
let hex = '';
|
||||||
|
for (const b of bytes) hex += b.toString(16).padStart(2, '0');
|
||||||
|
return BigInt('0x' + hex);
|
||||||
|
}
|
||||||
|
const n = bigFromBytes(nDer);
|
||||||
|
const e = bigFromBytes(eDer);
|
||||||
|
const dataInt = bigFromBytes(data);
|
||||||
|
const result = dataInt ** e % n;
|
||||||
|
let hex = result.toString(16);
|
||||||
|
if (hex.length % 2 === 1) hex = '0' + hex;
|
||||||
|
const out = new Uint8Array(hex.length / 2);
|
||||||
|
for (let i = 0; i < out.length; i++) out[i] = parseInt(hex.substr(i * 2, 2), 16);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
// crypto 模块
|
// crypto 模块
|
||||||
const cryptoModule: any = {
|
const cryptoModule: any = {
|
||||||
|
constants: {
|
||||||
|
RSA_PKCS1_PADDING: 1,
|
||||||
|
RSA_NO_PADDING: 3,
|
||||||
|
RSA_PKCS1_OAEP_PADDING: 4,
|
||||||
|
RSA_PSS_SALTLEN_DIGEST: -1,
|
||||||
|
RSA_PSS_SALTLEN_MAX_SIGN: -2,
|
||||||
|
RSA_PSS_SALTLEN_AUTO: -1,
|
||||||
|
POINT_CONVERSION_COMPRESSED: 2,
|
||||||
|
POINT_CONVERSION_UNCOMPRESSED: 4,
|
||||||
|
POINT_CONVERSION_HYBRID: 6,
|
||||||
|
defaultCoreCipherList: '',
|
||||||
|
defaultCipherList: '',
|
||||||
|
},
|
||||||
createHash: function(algo: string) {
|
createHash: function(algo: string) {
|
||||||
const chunks: string[] = [];
|
const chunks: Uint8Array[] = [];
|
||||||
return {
|
return {
|
||||||
update: function(chunk: any) { chunks.push(String(chunk)); return this; },
|
update: function(chunk: any) { chunks.push(toBuffer(chunk)); return this; },
|
||||||
digest: function(encoding?: string) {
|
digest: function(encoding?: string) {
|
||||||
// 简化:不做真正的哈希,返回一个稳定的伪随机值
|
const combined = chunks.length === 1 ? chunks[0] : (() => { const total = chunks.reduce((a, b) => a + b.length, 0); const out = new Uint8Array(total); let off = 0; for (const c of chunks) { out.set(c, off); off += c.length; } return out; })();
|
||||||
const s = algo + ':' + chunks.join('');
|
return bufferToString(simpleHash(algo, combined), encoding);
|
||||||
let h = 0;
|
|
||||||
for (let i = 0; i < s.length; i++) h = ((h << 5) - h + s.charCodeAt(i)) | 0;
|
|
||||||
const hex = Math.abs(h).toString(16).padStart(16, '0') + Math.abs(h * 31).toString(16).padStart(16, '0');
|
|
||||||
if (encoding === 'hex') return hex;
|
|
||||||
if (encoding === 'base64') return btoa(hex);
|
|
||||||
return hex;
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
createHmac: function(algo: string, key: any) {
|
createHmac: function(algo: string, key: any) {
|
||||||
const chunks: string[] = [];
|
const chunks: Uint8Array[] = [];
|
||||||
|
const keyBuf = toBuffer(key);
|
||||||
return {
|
return {
|
||||||
update: function(chunk: any) { chunks.push(String(chunk)); return this; },
|
update: function(chunk: any) { chunks.push(toBuffer(chunk)); return this; },
|
||||||
digest: function(encoding?: string) {
|
digest: function(encoding?: string) {
|
||||||
const s = 'hmac:' + algo + ':' + key + ':' + chunks.join('');
|
const data = chunks.length === 1 ? chunks[0] : (() => { const total = chunks.reduce((a, b) => a + b.length, 0); const out = new Uint8Array(total); let off = 0; for (const c of chunks) { out.set(c, off); off += c.length; } return out; })();
|
||||||
let h = 0;
|
const blockSize = algo === 'sha256' || algo === 'sha512' ? 128 : 64;
|
||||||
for (let i = 0; i < s.length; i++) h = ((h << 5) - h + s.charCodeAt(i)) | 0;
|
const k = keyBuf.length > blockSize ? simpleHash(algo, keyBuf) : keyBuf;
|
||||||
const hex = Math.abs(h).toString(16).padStart(16, '0') + Math.abs(h * 17).toString(16).padStart(16, '0');
|
const iPadded = new Uint8Array(blockSize);
|
||||||
if (encoding === 'hex') return hex;
|
const oPadded = new Uint8Array(blockSize);
|
||||||
if (encoding === 'base64') return btoa(hex);
|
for (let i = 0; i < k.length; i++) { iPadded[i] = k[i] ^ 0x36; oPadded[i] = k[i] ^ 0x5c; }
|
||||||
return hex;
|
for (let i = k.length; i < blockSize; i++) { iPadded[i] = 0x36; oPadded[i] = 0x5c; }
|
||||||
|
const inner = new Uint8Array(iPadded.length + data.length);
|
||||||
|
inner.set(iPadded); inner.set(data, iPadded.length);
|
||||||
|
const innerHash = simpleHash(algo, inner);
|
||||||
|
const outer = new Uint8Array(oPadded.length + innerHash.length);
|
||||||
|
outer.set(oPadded); outer.set(innerHash, oPadded.length);
|
||||||
|
return bufferToString(simpleHash(algo, outer), encoding);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
@@ -328,7 +646,7 @@ const cryptoModule: any = {
|
|||||||
for (let i = 0; i < n; i++) s += String.fromCharCode(arr[i]);
|
for (let i = 0; i < n; i++) s += String.fromCharCode(arr[i]);
|
||||||
return btoa(s);
|
return btoa(s);
|
||||||
}
|
}
|
||||||
return String.fromCharCode.apply(null, arr as any);
|
return new TextDecoder().decode(arr);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
if (cb) { setTimeout(() => cb(null, buf), 0); return undefined; }
|
if (cb) { setTimeout(() => cb(null, buf), 0); return undefined; }
|
||||||
@@ -341,6 +659,46 @@ const cryptoModule: any = {
|
|||||||
return v.toString(16);
|
return v.toString(16);
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
createCipheriv: function(algo: string, key: any, iv: any) {
|
||||||
|
const keyBuf = toBuffer(key);
|
||||||
|
const ivBuf = iv ? toBuffer(iv) : new Uint8Array(0);
|
||||||
|
return {
|
||||||
|
update: function(data: any) {
|
||||||
|
const dataBuf = toBuffer(data);
|
||||||
|
if (algo === 'aes-128-ecb') return aesEcbEncrypt(dataBuf, keyBuf);
|
||||||
|
return dataBuf;
|
||||||
|
},
|
||||||
|
final: function() { return new Uint8Array(0); },
|
||||||
|
};
|
||||||
|
},
|
||||||
|
createDecipheriv: function(algo: string, key: any, iv: any) {
|
||||||
|
const keyBuf = toBuffer(key);
|
||||||
|
const ivBuf = iv ? toBuffer(iv) : new Uint8Array(0);
|
||||||
|
return {
|
||||||
|
update: function(data: any) {
|
||||||
|
const dataBuf = toBuffer(data);
|
||||||
|
if (algo === 'aes-128-ecb') return aesEcbDecrypt(dataBuf, keyBuf);
|
||||||
|
return dataBuf;
|
||||||
|
},
|
||||||
|
final: function() { return new Uint8Array(0); },
|
||||||
|
};
|
||||||
|
},
|
||||||
|
publicEncrypt: function(options: any, data: any) {
|
||||||
|
const keyPem = typeof options === 'string' ? options : options.key;
|
||||||
|
const dataBuf = toBuffer(data);
|
||||||
|
return rsaPublicEncrypt(dataBuf, keyPem);
|
||||||
|
},
|
||||||
|
privateDecrypt: function() { return new Uint8Array(0); },
|
||||||
|
privateEncrypt: function() { return new Uint8Array(0); },
|
||||||
|
createSign: function() { return { update: function() { return this; }, sign: function() { return new Uint8Array(0); } }; },
|
||||||
|
createVerify: function() { return { update: function() { return this; }, verify: function() { return true; } }; },
|
||||||
|
getCiphers: () => ['aes-128-cbc', 'aes-128-ecb'],
|
||||||
|
getHashes: () => ['md5', 'sha1', 'sha256'],
|
||||||
|
pbkdf2: function() {},
|
||||||
|
pbkdf2Sync: function() { return new Uint8Array(0); },
|
||||||
|
createDiffieHellman: function() { return { generateKeys: () => new Uint8Array(0), computeSecret: () => new Uint8Array(0), getPrime: () => new Uint8Array(0), getGenerator: () => new Uint8Array(0) }; },
|
||||||
|
createECDH: function() { return { generateKeys: () => new Uint8Array(0), computeSecret: () => new Uint8Array(0), getPublicKey: () => new Uint8Array(0) }; },
|
||||||
|
timingSafeEqual: function(a: any, b: any) { const al = toBuffer(a); const bl = toBuffer(b); if (al.length !== bl.length) return false; let d = 0; for (let i = 0; i < al.length; i++) d |= al[i] ^ bl[i]; return d === 0; },
|
||||||
};
|
};
|
||||||
|
|
||||||
// 完整的 polyfill 注册表
|
// 完整的 polyfill 注册表
|
||||||
@@ -499,6 +857,7 @@ export function createNodePolyfills(): Record<string, any> {
|
|||||||
resolve: (_host2: string, cb: (err: Error | null, addrs: string[]) => void) => { setTimeout(() => cb(null, ['127.0.0.1']), 0); },
|
resolve: (_host2: string, cb: (err: Error | null, addrs: string[]) => void) => { setTimeout(() => cb(null, ['127.0.0.1']), 0); },
|
||||||
},
|
},
|
||||||
buffer: bufferModule,
|
buffer: bufferModule,
|
||||||
|
// 'Buffer' is already an alias for bufferModule, but some plugins require('buffer').Buffer
|
||||||
Buffer: bufferModule,
|
Buffer: bufferModule,
|
||||||
string_decoder: {
|
string_decoder: {
|
||||||
StringDecoder: function() {
|
StringDecoder: function() {
|
||||||
|
|||||||
@@ -20,9 +20,21 @@ const BUILTIN_PLUGINS: BuiltinPluginDef[] = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
// 支持的插件源 URL 前缀 - 按优先级排序
|
// 支持的插件源 URL 前缀 - 按优先级排序
|
||||||
const PLUGIN_SOURCE_PREFIXES: string[] = [
|
function getPluginSourcePrefixes(): string[] {
|
||||||
'/plugins/', // 本地构建产物 /plugins/ 目录(通过 install.sh 下载)
|
// 优先从 localStorage 读取自定义源(用户可以配置)
|
||||||
];
|
try {
|
||||||
|
const custom = JSON.parse(localStorage.getItem('qz-plugin-sources') || '[]');
|
||||||
|
if (Array.isArray(custom) && custom.length > 0) {
|
||||||
|
return custom.map((s: string) => s.endsWith('/') ? s : s + '/');
|
||||||
|
}
|
||||||
|
} catch { /* noop */ }
|
||||||
|
return [
|
||||||
|
'/plugins/', // 本地构建产物 /plugins/ 目录(通过 install.sh 下载)
|
||||||
|
'https://cdn.jsdelivr.net/gh/your-repo/plugins@latest/', // CDN 回退
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
const PLUGIN_SOURCE_PREFIXES = getPluginSourcePrefixes();
|
||||||
|
|
||||||
interface LoadedPlugin {
|
interface LoadedPlugin {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -117,6 +129,34 @@ export class PluginManager {
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ========== 插件环境变量管理 ==========
|
||||||
|
getEnvDefs(id: string): { key: string; name: string; description?: string }[] {
|
||||||
|
const lp = this.plugins.get(id);
|
||||||
|
if (lp && lp.module && lp.module.pluginInfo && lp.module.pluginInfo.env) {
|
||||||
|
return lp.module.pluginInfo.env as any[];
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
setEnvVar(key: string, value: string): void {
|
||||||
|
try {
|
||||||
|
const envData = JSON.parse(localStorage.getItem('qz-plugin-env') || '{}');
|
||||||
|
envData[key] = value;
|
||||||
|
localStorage.setItem('qz-plugin-env', JSON.stringify(envData));
|
||||||
|
} catch { /* noop */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
getEnvVar(key: string): string {
|
||||||
|
try {
|
||||||
|
const envData = JSON.parse(localStorage.getItem('qz-plugin-env') || '{}');
|
||||||
|
return envData[key] || '';
|
||||||
|
} catch { return ''; }
|
||||||
|
}
|
||||||
|
|
||||||
|
getAllEnvVars(): Record<string, string> {
|
||||||
|
try { return JSON.parse(localStorage.getItem('qz-plugin-env') || '{}'); } catch { return {}; }
|
||||||
|
}
|
||||||
|
|
||||||
// ========== 插件代码加载 ==========
|
// ========== 插件代码加载 ==========
|
||||||
/**
|
/**
|
||||||
* 从 JS 代码字符串加载插件。
|
* 从 JS 代码字符串加载插件。
|
||||||
@@ -293,7 +333,8 @@ export class PluginManager {
|
|||||||
|
|
||||||
async loadBuiltin(def: BuiltinPluginDef): Promise<boolean> {
|
async loadBuiltin(def: BuiltinPluginDef): Promise<boolean> {
|
||||||
if (this.has(def.id)) return true;
|
if (this.has(def.id)) return true;
|
||||||
for (const prefix of PLUGIN_SOURCE_PREFIXES) {
|
const prefixes = getPluginSourcePrefixes();
|
||||||
|
for (const prefix of prefixes) {
|
||||||
if (await this.loadFromUrl(prefix + def.fileName, def.id, 'built-in')) {
|
if (await this.loadFromUrl(prefix + def.fileName, def.id, 'built-in')) {
|
||||||
// 拉成功后用配置里的 name/desc 更新元信息(插件内的 info 可能为英文)
|
// 拉成功后用配置里的 name/desc 更新元信息(插件内的 info 可能为英文)
|
||||||
const lp = this.plugins.get(def.id);
|
const lp = this.plugins.get(def.id);
|
||||||
@@ -508,6 +549,12 @@ export class PluginManager {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const pluginEnv: Record<string, string> = {};
|
||||||
|
try {
|
||||||
|
const savedEnv = JSON.parse(localStorage.getItem('qz-plugin-env') || '{}');
|
||||||
|
Object.assign(pluginEnv, savedEnv);
|
||||||
|
} catch { /* noop */ }
|
||||||
|
|
||||||
const fakeGlobal: any = {
|
const fakeGlobal: any = {
|
||||||
process: processObj,
|
process: processObj,
|
||||||
Buffer: BufferCtor,
|
Buffer: BufferCtor,
|
||||||
@@ -516,11 +563,11 @@ export class PluginManager {
|
|||||||
exports: moduleObj.exports,
|
exports: moduleObj.exports,
|
||||||
__dirname: '/',
|
__dirname: '/',
|
||||||
__filename: '/plugin.js',
|
__filename: '/plugin.js',
|
||||||
// Node 全局(alias 到浏览器真实全局,以保持 window.location.href 正常工作)
|
|
||||||
global: globalThis,
|
global: globalThis,
|
||||||
globalThis: globalThis,
|
globalThis: globalThis,
|
||||||
self: globalThis,
|
self: globalThis,
|
||||||
root: globalThis,
|
root: globalThis,
|
||||||
|
env: pluginEnv,
|
||||||
console,
|
console,
|
||||||
setTimeout: setTimeout as any,
|
setTimeout: setTimeout as any,
|
||||||
clearTimeout,
|
clearTimeout,
|
||||||
|
|||||||
177
src/utils/localMusic.ts
Normal file
177
src/utils/localMusic.ts
Normal file
@@ -0,0 +1,177 @@
|
|||||||
|
import type { Song, SongType } from '../types/song';
|
||||||
|
|
||||||
|
export interface ScannedTrack {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
artist: string;
|
||||||
|
album: string;
|
||||||
|
duration: number;
|
||||||
|
file: File;
|
||||||
|
fileHandle?: FileSystemFileHandle;
|
||||||
|
url?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AUDIO_EXTS = new Set(['.mp3', '.flac', '.wav', '.ogg', '.aac', '.m4a', '.wma', '.ape', '.opus']);
|
||||||
|
|
||||||
|
function isAudioFile(name: string): boolean {
|
||||||
|
const dot = name.lastIndexOf('.');
|
||||||
|
if (dot === -1) return false;
|
||||||
|
return AUDIO_EXTS.has(name.slice(dot).toLowerCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseFileName(name: string): { title: string; artist: string } {
|
||||||
|
const dot = name.lastIndexOf('.');
|
||||||
|
const base = dot !== -1 ? name.slice(0, dot) : name;
|
||||||
|
const separators = [' - ', ' – ', ' — ', ' -', '-', ' / ', '/'];
|
||||||
|
for (const sep of separators) {
|
||||||
|
const idx = base.indexOf(sep);
|
||||||
|
if (idx > 0 && idx < base.length - sep.length) {
|
||||||
|
return { artist: base.slice(0, idx).trim(), title: base.slice(idx + sep.length).trim() };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { title: base, artist: '' };
|
||||||
|
}
|
||||||
|
|
||||||
|
function genId(): string {
|
||||||
|
return 'local-' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 8);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getDuration(file: File): Promise<number> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const url = URL.createObjectURL(file);
|
||||||
|
const audio = new Audio();
|
||||||
|
audio.preload = 'metadata';
|
||||||
|
const timer = setTimeout(() => { audio.src = ''; URL.revokeObjectURL(url); resolve(0); }, 5000);
|
||||||
|
audio.onloadedmetadata = () => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
const dur = Math.round(audio.duration * 1000);
|
||||||
|
audio.src = '';
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
resolve(dur);
|
||||||
|
};
|
||||||
|
audio.onerror = () => { clearTimeout(timer); audio.src = ''; URL.revokeObjectURL(url); resolve(0); };
|
||||||
|
audio.src = url;
|
||||||
|
audio.load();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function scanFile(file: File, fileHandle?: FileSystemFileHandle): Promise<ScannedTrack | null> {
|
||||||
|
if (!isAudioFile(file.name)) return null;
|
||||||
|
const parsed = parseFileName(file.name);
|
||||||
|
const duration = await getDuration(file);
|
||||||
|
return {
|
||||||
|
id: genId(),
|
||||||
|
name: parsed.title || file.name,
|
||||||
|
artist: parsed.artist || '未知歌手',
|
||||||
|
album: '',
|
||||||
|
duration,
|
||||||
|
file,
|
||||||
|
fileHandle,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function scanDirectory(dirHandle: FileSystemDirectoryHandle, onProgress?: (name: string) => void): Promise<ScannedTrack[]> {
|
||||||
|
const results: ScannedTrack[] = [];
|
||||||
|
const entries: [string, FileSystemHandle][] = [];
|
||||||
|
for await (const entry of (dirHandle as any).entries()) {
|
||||||
|
entries.push(entry);
|
||||||
|
}
|
||||||
|
for (const [name, handle] of entries) {
|
||||||
|
if (onProgress) onProgress(name);
|
||||||
|
if (handle.kind === 'file') {
|
||||||
|
const fileHandle = handle as FileSystemFileHandle;
|
||||||
|
const file = await fileHandle.getFile();
|
||||||
|
const track = await scanFile(file, fileHandle);
|
||||||
|
if (track) results.push(track);
|
||||||
|
} else if (handle.kind === 'directory') {
|
||||||
|
const sub = await scanDirectory(handle as FileSystemDirectoryHandle, onProgress);
|
||||||
|
results.push(...sub);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function trackToSong(track: ScannedTrack): Song {
|
||||||
|
return {
|
||||||
|
id: track.id,
|
||||||
|
name: track.name,
|
||||||
|
artist: track.artist,
|
||||||
|
picUrl: '',
|
||||||
|
url: track.url || '',
|
||||||
|
duration: formatDuration(track.duration),
|
||||||
|
source: 'local',
|
||||||
|
type: 'Local' as SongType,
|
||||||
|
albumName: track.album || null,
|
||||||
|
quality: 'standard',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatDuration(ms: number): string {
|
||||||
|
if (!ms || ms <= 0) return '0:00';
|
||||||
|
const totalSec = Math.floor(ms / 1000);
|
||||||
|
const min = Math.floor(totalSec / 60);
|
||||||
|
const sec = totalSec % 60;
|
||||||
|
return `${min}:${sec.toString().padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DB_NAME = 'qz-local-music';
|
||||||
|
const DB_VERSION = 1;
|
||||||
|
const STORE_NAME = 'tracks';
|
||||||
|
|
||||||
|
function openDB(): Promise<IDBDatabase> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const req = indexedDB.open(DB_NAME, DB_VERSION);
|
||||||
|
req.onupgradeneeded = () => {
|
||||||
|
const db = req.result;
|
||||||
|
if (!db.objectStoreNames.contains(STORE_NAME)) {
|
||||||
|
db.createObjectStore(STORE_NAME, { keyPath: 'id' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
req.onsuccess = () => resolve(req.result);
|
||||||
|
req.onerror = () => reject(req.error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveTracks(tracks: ScannedTrack[]): Promise<void> {
|
||||||
|
const db = await openDB();
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const tx = db.transaction(STORE_NAME, 'readwrite');
|
||||||
|
const store = tx.objectStore(STORE_NAME);
|
||||||
|
for (const t of tracks) {
|
||||||
|
store.put({ id: t.id, name: t.name, artist: t.artist, album: t.album, duration: t.duration });
|
||||||
|
}
|
||||||
|
tx.oncomplete = () => { db.close(); resolve(); };
|
||||||
|
tx.onerror = () => { db.close(); reject(tx.error); };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadTracks(): Promise<ScannedTrack[]> {
|
||||||
|
try {
|
||||||
|
const db = await openDB();
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const tx = db.transaction(STORE_NAME, 'readonly');
|
||||||
|
const store = tx.objectStore(STORE_NAME);
|
||||||
|
const req = store.getAll();
|
||||||
|
req.onsuccess = () => { db.close(); resolve(req.result || []); };
|
||||||
|
req.onerror = () => { db.close(); reject(req.error); };
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function clearSavedTracks(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const db = await openDB();
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const tx = db.transaction(STORE_NAME, 'readwrite');
|
||||||
|
tx.objectStore(STORE_NAME).clear();
|
||||||
|
tx.oncomplete = () => { db.close(); resolve(); };
|
||||||
|
tx.onerror = () => { db.close(); reject(tx.error); };
|
||||||
|
});
|
||||||
|
} catch { /* noop */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function supportsDirectoryPicker(): boolean {
|
||||||
|
return typeof (window as any).showDirectoryPicker === 'function';
|
||||||
|
}
|
||||||
@@ -9,10 +9,9 @@ export function formatDuration(ms: number): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 将 PC 原版插件搜索结果转换为 Web 版 Song 格式
|
* 将 PC/Android 原版插件搜索结果转换为 Web 版 Song 格式
|
||||||
* 兼容 PC 版 songUtils.ts 的 transformSearchSong
|
|
||||||
*
|
*
|
||||||
* PC 原版搜索结果字段:
|
* PC 原版 v2 字段:
|
||||||
* - songmid: 歌曲 ID
|
* - songmid: 歌曲 ID
|
||||||
* - name: 歌曲名
|
* - name: 歌曲名
|
||||||
* - singer: 歌手名
|
* - singer: 歌手名
|
||||||
@@ -22,21 +21,41 @@ export function formatDuration(ms: number): string {
|
|||||||
* - albumId: 专辑 ID
|
* - albumId: 专辑 ID
|
||||||
* - albumName: 专辑名
|
* - albumName: 专辑名
|
||||||
* - types: 音质映射
|
* - types: 音质映射
|
||||||
|
*
|
||||||
|
* v3 插件字段:
|
||||||
|
* - id: 歌曲 ID
|
||||||
|
* - name: 歌曲名
|
||||||
|
* - artists: 歌手名
|
||||||
|
* - pic: 封面图
|
||||||
|
* - interval: 时长(毫秒字符串)
|
||||||
|
* - source: 来源标识
|
||||||
|
* - albumName: 专辑名
|
||||||
|
* - albumId: 专辑 ID
|
||||||
|
* - qualities: 音质映射
|
||||||
*/
|
*/
|
||||||
export function transformSearchSong(raw: any): Song {
|
export function transformSearchSong(raw: any): Song {
|
||||||
|
const id = String(raw.songmid || raw.id || raw.songId || '');
|
||||||
|
const name = raw.name || '未知歌曲';
|
||||||
|
const artist = raw.singer || raw.artist || raw.artists || '未知歌手';
|
||||||
|
const picUrl = raw.img || raw.picUrl || raw.pic || raw.mPic || raw.sPic || raw.m_img || raw.s_img || '';
|
||||||
|
const interval = raw.interval ? Number(raw.interval) : raw.duration ? Number(raw.duration) : 0;
|
||||||
|
const albumName = raw.albumName || raw.album || null;
|
||||||
|
const albumId = raw.albumId ? String(raw.albumId) : null;
|
||||||
|
const types = raw.types || raw.qualities || undefined;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: String(raw.songmid || raw.id || ''),
|
id,
|
||||||
name: raw.name || '未知歌曲',
|
name,
|
||||||
artist: raw.singer || raw.artist || '未知歌手',
|
artist,
|
||||||
picUrl: raw.img || raw.m_img || raw.s_img || raw.picUrl || '',
|
picUrl,
|
||||||
url: '',
|
url: '',
|
||||||
duration: formatDuration(raw.interval ? Number(raw.interval) : 0),
|
duration: formatDuration(interval),
|
||||||
source: raw.source || '',
|
source: raw.source || '',
|
||||||
albumId: raw.albumId ? String(raw.albumId) : null,
|
albumId,
|
||||||
albumName: raw.albumName || null,
|
albumName,
|
||||||
type: 'Remote',
|
type: 'Remote',
|
||||||
quality: 'auto',
|
quality: 'auto',
|
||||||
types: raw.types || undefined,
|
types,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,18 +1,174 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="view-container local-view">
|
<div class="view-container local-view">
|
||||||
<h1 class="view-title">Local Files</h1>
|
<h1 class="view-title">本地音乐</h1>
|
||||||
<div class="empty-state">
|
|
||||||
|
<!-- Toolbar -->
|
||||||
|
<div class="toolbar">
|
||||||
|
<button class="action-btn" @click="selectFolder" :disabled="scanning" v-if="supportsFolder">
|
||||||
|
<Icon :icon="scanning ? 'lucide:loader-2' : 'lucide:folder-open'" width="18" height="18" />
|
||||||
|
{{ scanning ? '扫描中...' : '选择文件夹' }}
|
||||||
|
</button>
|
||||||
|
<button class="action-btn" @click="selectFiles" :disabled="scanning">
|
||||||
|
<Icon icon="lucide:file-plus" width="18" height="18" />
|
||||||
|
选择文件
|
||||||
|
</button>
|
||||||
|
<button class="action-btn secondary" @click="clearAll" v-if="tracks.length > 0">
|
||||||
|
<Icon icon="lucide:trash-2" width="18" height="18" />
|
||||||
|
清空列表
|
||||||
|
</button>
|
||||||
|
<span class="track-count" v-if="tracks.length > 0">{{ tracks.length }} 首歌曲</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Progress -->
|
||||||
|
<div class="progress-bar" v-if="scanProgress > 0 && scanProgress < 100">
|
||||||
|
<div class="progress-fill" :style="{ width: scanProgress + '%' }"></div>
|
||||||
|
</div>
|
||||||
|
<p class="scan-status" v-if="scanMessage">{{ scanMessage }}</p>
|
||||||
|
|
||||||
|
<!-- Song list -->
|
||||||
|
<div class="track-list" v-if="tracks.length > 0">
|
||||||
|
<div
|
||||||
|
v-for="(track, idx) in tracks"
|
||||||
|
:key="track.id"
|
||||||
|
class="track-item"
|
||||||
|
:class="{ active: currentTrackId === track.id }"
|
||||||
|
@dblclick="playTrack(idx)"
|
||||||
|
>
|
||||||
|
<span class="track-idx">{{ idx + 1 }}</span>
|
||||||
|
<div class="track-info">
|
||||||
|
<span class="track-name">{{ track.name }}</span>
|
||||||
|
<span class="track-artist">{{ track.artist }}</span>
|
||||||
|
</div>
|
||||||
|
<span class="track-duration">{{ formatDuration(track.duration) }}</span>
|
||||||
|
<button class="play-btn" @click="playTrack(idx)" title="播放">
|
||||||
|
<Icon icon="lucide:play" width="16" height="16" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Empty state -->
|
||||||
|
<div class="empty-state" v-else-if="!scanning">
|
||||||
<div class="icon-box">
|
<div class="icon-box">
|
||||||
<Icon icon="lucide:music" width="48" height="48" />
|
<Icon icon="lucide:music" width="48" height="48" />
|
||||||
</div>
|
</div>
|
||||||
<p>No local files scanned yet.</p>
|
<p>{{ scanned ? '未找到音频文件' : '暂无本地音乐' }}</p>
|
||||||
<button class="action-btn">Scan Folder</button>
|
<p class="hint">选择包含音乐文件的文件夹或直接选择文件</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Hidden file input -->
|
||||||
|
<input
|
||||||
|
ref="fileInput"
|
||||||
|
type="file"
|
||||||
|
multiple
|
||||||
|
accept="audio/*"
|
||||||
|
style="display:none"
|
||||||
|
@change="onFileSelected"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted } from 'vue';
|
||||||
import { Icon } from '@iconify/vue';
|
import { Icon } from '@iconify/vue';
|
||||||
|
import { usePlayerStore } from '../stores/player';
|
||||||
|
import { scanFile, scanDirectory, loadTracks, saveTracks, clearSavedTracks, supportsDirectoryPicker, trackToSong, formatDuration } from '../utils/localMusic';
|
||||||
|
import type { ScannedTrack } from '../utils/localMusic';
|
||||||
|
|
||||||
|
const player = usePlayerStore();
|
||||||
|
const fileInput = ref<HTMLInputElement | null>(null);
|
||||||
|
const tracks = ref<ScannedTrack[]>([]);
|
||||||
|
const scanning = ref(false);
|
||||||
|
const scanProgress = ref(0);
|
||||||
|
const scanMessage = ref('');
|
||||||
|
const scanned = ref(false);
|
||||||
|
const supportsFolder = supportsDirectoryPicker();
|
||||||
|
const currentTrackId = ref('');
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
const saved = await loadTracks();
|
||||||
|
if (saved.length > 0) {
|
||||||
|
tracks.value = saved as ScannedTrack[];
|
||||||
|
scanned.value = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function selectFolder() {
|
||||||
|
try {
|
||||||
|
const dirHandle = await (window as any).showDirectoryPicker();
|
||||||
|
scanning.value = true;
|
||||||
|
scanProgress.value = 5;
|
||||||
|
scanMessage.value = '正在扫描文件夹...';
|
||||||
|
const found = await scanDirectory(dirHandle, (name) => {
|
||||||
|
scanMessage.value = `扫描: ${name}`;
|
||||||
|
});
|
||||||
|
scanProgress.value = 90;
|
||||||
|
scanMessage.value = `找到 ${found.length} 首歌曲`;
|
||||||
|
if (found.length > 0) {
|
||||||
|
tracks.value = found;
|
||||||
|
await saveTracks(found);
|
||||||
|
scanned.value = true;
|
||||||
|
}
|
||||||
|
scanProgress.value = 100;
|
||||||
|
setTimeout(() => { scanProgress.value = 0; scanMessage.value = ''; }, 2000);
|
||||||
|
} catch (err: any) {
|
||||||
|
if (err?.name !== 'AbortError' && err?.name !== 'SecurityError') {
|
||||||
|
console.warn('选择文件夹失败:', err);
|
||||||
|
}
|
||||||
|
scanMessage.value = '';
|
||||||
|
} finally {
|
||||||
|
scanning.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectFiles() {
|
||||||
|
fileInput.value?.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onFileSelected(e: Event) {
|
||||||
|
const input = e.target as HTMLInputElement;
|
||||||
|
if (!input.files || input.files.length === 0) return;
|
||||||
|
scanning.value = true;
|
||||||
|
scanProgress.value = 10;
|
||||||
|
scanMessage.value = `处理 ${input.files.length} 个文件...`;
|
||||||
|
const found: ScannedTrack[] = [];
|
||||||
|
let done = 0;
|
||||||
|
for (const file of Array.from(input.files)) {
|
||||||
|
const track = await scanFile(file);
|
||||||
|
if (track) found.push(track);
|
||||||
|
done++;
|
||||||
|
scanProgress.value = 10 + Math.round((done / input.files.length) * 80);
|
||||||
|
scanMessage.value = `处理: ${file.name}`;
|
||||||
|
}
|
||||||
|
if (found.length > 0) {
|
||||||
|
tracks.value = found;
|
||||||
|
await saveTracks(found);
|
||||||
|
scanned.value = true;
|
||||||
|
}
|
||||||
|
scanProgress.value = 100;
|
||||||
|
setTimeout(() => { scanProgress.value = 0; scanMessage.value = ''; }, 1000);
|
||||||
|
scanning.value = false;
|
||||||
|
input.value = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function playTrack(idx: number) {
|
||||||
|
const track = tracks.value[idx];
|
||||||
|
if (!track) return;
|
||||||
|
currentTrackId.value = track.id;
|
||||||
|
const song = trackToSong(track);
|
||||||
|
song.url = URL.createObjectURL(track.file);
|
||||||
|
const list = tracks.value.map(t => {
|
||||||
|
const s = trackToSong(t);
|
||||||
|
s.url = URL.createObjectURL(t.file);
|
||||||
|
return s;
|
||||||
|
});
|
||||||
|
player.playFromList(song, list);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function clearAll() {
|
||||||
|
tracks.value = [];
|
||||||
|
scanned.value = false;
|
||||||
|
await clearSavedTracks();
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@@ -22,6 +178,148 @@ import { Icon } from '@iconify/vue';
|
|||||||
margin-bottom: 24px;
|
margin-bottom: 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 8px 18px;
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
background: var(--color-accent);
|
||||||
|
color: var(--color-bg-primary);
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn:hover:not(:disabled) {
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn.secondary {
|
||||||
|
background: var(--color-bg-secondary);
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.track-count {
|
||||||
|
margin-left: auto;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-bar {
|
||||||
|
height: 4px;
|
||||||
|
background: var(--color-bg-secondary);
|
||||||
|
border-radius: 2px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-fill {
|
||||||
|
height: 100%;
|
||||||
|
background: var(--color-accent);
|
||||||
|
border-radius: 2px;
|
||||||
|
transition: width 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scan-status {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.track-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.track-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
cursor: default;
|
||||||
|
transition: background 0.15s;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.track-item:hover {
|
||||||
|
background: var(--color-bg-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.track-item.active {
|
||||||
|
background: var(--color-bg-secondary);
|
||||||
|
color: var(--color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.track-idx {
|
||||||
|
width: 24px;
|
||||||
|
text-align: right;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.track-info {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.track-name {
|
||||||
|
font-weight: 500;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.track-artist {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.track-duration {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.play-btn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
padding: 4px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
transition: color 0.15s, background 0.15s;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.play-btn:hover {
|
||||||
|
color: var(--color-accent);
|
||||||
|
background: var(--color-bg-tertiary, rgba(128,128,128,0.1));
|
||||||
|
}
|
||||||
|
|
||||||
.empty-state {
|
.empty-state {
|
||||||
height: 400px;
|
height: 400px;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -38,17 +336,9 @@ import { Icon } from '@iconify/vue';
|
|||||||
margin-bottom: 24px;
|
margin-bottom: 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.action-btn {
|
.hint {
|
||||||
margin-top: 24px;
|
font-size: 0.85rem;
|
||||||
background-color: var(--color-accent);
|
margin-top: 8px;
|
||||||
color: var(--color-bg-primary);
|
opacity: 0.7;
|
||||||
padding: 12px 24px;
|
|
||||||
border-radius: var(--radius-full);
|
|
||||||
font-weight: 600;
|
|
||||||
transition: opacity 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.action-btn:hover {
|
|
||||||
opacity: 0.9;
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user