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' },
|
||||
};
|
||||
|
||||
// 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 模块
|
||||
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) {
|
||||
const chunks: string[] = [];
|
||||
const chunks: Uint8Array[] = [];
|
||||
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) {
|
||||
// 简化:不做真正的哈希,返回一个稳定的伪随机值
|
||||
const s = algo + ':' + chunks.join('');
|
||||
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;
|
||||
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; })();
|
||||
return bufferToString(simpleHash(algo, combined), encoding);
|
||||
},
|
||||
};
|
||||
},
|
||||
createHmac: function(algo: string, key: any) {
|
||||
const chunks: string[] = [];
|
||||
const chunks: Uint8Array[] = [];
|
||||
const keyBuf = toBuffer(key);
|
||||
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) {
|
||||
const s = 'hmac:' + algo + ':' + key + ':' + chunks.join('');
|
||||
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 * 17).toString(16).padStart(16, '0');
|
||||
if (encoding === 'hex') return hex;
|
||||
if (encoding === 'base64') return btoa(hex);
|
||||
return hex;
|
||||
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; })();
|
||||
const blockSize = algo === 'sha256' || algo === 'sha512' ? 128 : 64;
|
||||
const k = keyBuf.length > blockSize ? simpleHash(algo, keyBuf) : keyBuf;
|
||||
const iPadded = new Uint8Array(blockSize);
|
||||
const oPadded = new Uint8Array(blockSize);
|
||||
for (let i = 0; i < k.length; i++) { iPadded[i] = k[i] ^ 0x36; oPadded[i] = k[i] ^ 0x5c; }
|
||||
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]);
|
||||
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; }
|
||||
@@ -341,6 +659,46 @@ const cryptoModule: any = {
|
||||
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 注册表
|
||||
@@ -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); },
|
||||
},
|
||||
buffer: bufferModule,
|
||||
// 'Buffer' is already an alias for bufferModule, but some plugins require('buffer').Buffer
|
||||
Buffer: bufferModule,
|
||||
string_decoder: {
|
||||
StringDecoder: function() {
|
||||
|
||||
@@ -20,9 +20,21 @@ const BUILTIN_PLUGINS: BuiltinPluginDef[] = [
|
||||
];
|
||||
|
||||
// 支持的插件源 URL 前缀 - 按优先级排序
|
||||
const PLUGIN_SOURCE_PREFIXES: string[] = [
|
||||
'/plugins/', // 本地构建产物 /plugins/ 目录(通过 install.sh 下载)
|
||||
];
|
||||
function getPluginSourcePrefixes(): string[] {
|
||||
// 优先从 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 {
|
||||
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 代码字符串加载插件。
|
||||
@@ -293,7 +333,8 @@ export class PluginManager {
|
||||
|
||||
async loadBuiltin(def: BuiltinPluginDef): Promise<boolean> {
|
||||
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')) {
|
||||
// 拉成功后用配置里的 name/desc 更新元信息(插件内的 info 可能为英文)
|
||||
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 = {
|
||||
process: processObj,
|
||||
Buffer: BufferCtor,
|
||||
@@ -516,11 +563,11 @@ export class PluginManager {
|
||||
exports: moduleObj.exports,
|
||||
__dirname: '/',
|
||||
__filename: '/plugin.js',
|
||||
// Node 全局(alias 到浏览器真实全局,以保持 window.location.href 正常工作)
|
||||
global: globalThis,
|
||||
globalThis: globalThis,
|
||||
self: globalThis,
|
||||
root: globalThis,
|
||||
env: pluginEnv,
|
||||
console,
|
||||
setTimeout: setTimeout as any,
|
||||
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 版 songUtils.ts 的 transformSearchSong
|
||||
* 将 PC/Android 原版插件搜索结果转换为 Web 版 Song 格式
|
||||
*
|
||||
* PC 原版搜索结果字段:
|
||||
* PC 原版 v2 字段:
|
||||
* - songmid: 歌曲 ID
|
||||
* - name: 歌曲名
|
||||
* - singer: 歌手名
|
||||
@@ -22,21 +21,41 @@ export function formatDuration(ms: number): string {
|
||||
* - albumId: 专辑 ID
|
||||
* - albumName: 专辑名
|
||||
* - types: 音质映射
|
||||
*
|
||||
* v3 插件字段:
|
||||
* - id: 歌曲 ID
|
||||
* - name: 歌曲名
|
||||
* - artists: 歌手名
|
||||
* - pic: 封面图
|
||||
* - interval: 时长(毫秒字符串)
|
||||
* - source: 来源标识
|
||||
* - albumName: 专辑名
|
||||
* - albumId: 专辑 ID
|
||||
* - qualities: 音质映射
|
||||
*/
|
||||
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 {
|
||||
id: String(raw.songmid || raw.id || ''),
|
||||
name: raw.name || '未知歌曲',
|
||||
artist: raw.singer || raw.artist || '未知歌手',
|
||||
picUrl: raw.img || raw.m_img || raw.s_img || raw.picUrl || '',
|
||||
id,
|
||||
name,
|
||||
artist,
|
||||
picUrl,
|
||||
url: '',
|
||||
duration: formatDuration(raw.interval ? Number(raw.interval) : 0),
|
||||
duration: formatDuration(interval),
|
||||
source: raw.source || '',
|
||||
albumId: raw.albumId ? String(raw.albumId) : null,
|
||||
albumName: raw.albumName || null,
|
||||
albumId,
|
||||
albumName,
|
||||
type: 'Remote',
|
||||
quality: 'auto',
|
||||
types: raw.types || undefined,
|
||||
types,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,18 +1,174 @@
|
||||
<template>
|
||||
<div class="view-container local-view">
|
||||
<h1 class="view-title">Local Files</h1>
|
||||
<div class="empty-state">
|
||||
<h1 class="view-title">本地音乐</h1>
|
||||
|
||||
<!-- 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">
|
||||
<Icon icon="lucide:music" width="48" height="48" />
|
||||
</div>
|
||||
<p>No local files scanned yet.</p>
|
||||
<button class="action-btn">Scan Folder</button>
|
||||
<p>{{ scanned ? '未找到音频文件' : '暂无本地音乐' }}</p>
|
||||
<p class="hint">选择包含音乐文件的文件夹或直接选择文件</p>
|
||||
</div>
|
||||
|
||||
<!-- Hidden file input -->
|
||||
<input
|
||||
ref="fileInput"
|
||||
type="file"
|
||||
multiple
|
||||
accept="audio/*"
|
||||
style="display:none"
|
||||
@change="onFileSelected"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from '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>
|
||||
|
||||
<style scoped>
|
||||
@@ -22,6 +178,148 @@ import { Icon } from '@iconify/vue';
|
||||
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 {
|
||||
height: 400px;
|
||||
display: flex;
|
||||
@@ -38,17 +336,9 @@ import { Icon } from '@iconify/vue';
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
margin-top: 24px;
|
||||
background-color: var(--color-accent);
|
||||
color: var(--color-bg-primary);
|
||||
padding: 12px 24px;
|
||||
border-radius: var(--radius-full);
|
||||
font-weight: 600;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.action-btn:hover {
|
||||
opacity: 0.9;
|
||||
.hint {
|
||||
font-size: 0.85rem;
|
||||
margin-top: 8px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user