给HIFI发烧友分享一个强大的FLAC歌词嵌入工具

给HIFI发烧友分享一个强大的FLAC歌词嵌入工具
给HIFI发烧友分享一个强大的FLAC歌词嵌入工具
<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>FLAC 歌词嵌入 · LRCLib</title>
    <style>
        :root {
            --bg: #08080f;
            --surface: #111118;
            --surface2: #181820;
            --border: #222230;
            --text: #e0e0e8;
            --text2: #8888a0;
            --accent: #a78bfa;
            --accent2: #7c5cfc;
            --green: #34d399;
            --gold: #fbbf24;
            --radius: 16px;
            --radius-sm: 10px;
            --radius-xs: 8px;
        }
        * {
            margin: 0;
            padding: 0;
            box-sizing: border-box;
        }
        body {
            background: var(--bg);
            font-family: 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', system-ui, sans-serif;
            min-height: 100vh;
            display: flex;
            justify-content: center;
            padding: 28px 16px;
            color: var(--text);
        }
        .container {
            width: 100%;
            max-width: 960px;
            display: flex;
            flex-direction: column;
            gap: 18px;
        }
        .header {
            text-align: center;
            padding: 8px 0 4px;
        }
        .header h1 {
            font-size: 1.7rem;
            font-weight: 700;
            letter-spacing: -0.4px;
            background: linear-gradient(135deg, #a78bfa, #34d399);
            -webkit-background-clip: text;
            -webkit-text-fill-color: transparent;
            background-clip: text;
        }
        .header .sub {
            font-size: 0.82rem;
            color: var(--text2);
            margin-top: 2px;
        }
        .card {
            background: var(--surface);
            border: 1px solid var(--border);
            border-radius: var(--radius);
            padding: 22px;
        }
        .card-label {
            font-size: 0.72rem;
            text-transform: uppercase;
            letter-spacing: 1.5px;
            color: var(--text2);
            margin-bottom: 12px;
            font-weight: 600;
        }
        .drop-wrapper {
            position: relative;
        }
        .drop-wrapper input[type="file"] {
            position: absolute;
            inset: 0;
            width: 100%;
            height: 100%;
            opacity: 0;
            cursor: pointer;
            z-index: 2;
        }
        .drop-zone {
            background: var(--surface2);
            border: 2px dashed #2a2a3e;
            border-radius: var(--radius);
            padding: 32px 20px;
            text-align: center;
            transition: all 0.2s;
            pointer-events: none;
        }
        .drop-wrapper.drag-over .drop-zone {
            border-color: var(--accent);
            background: #1a1a28;
            box-shadow: 0 0 0 6px rgba(124, 92, 252, 0.15);
        }
        .drop-wrapper:hover .drop-zone {
            border-color: var(--accent);
            background: #1a1a28;
            box-shadow: 0 0 0 6px rgba(124, 92, 252, 0.06);
        }
        .drop-zone .dz-icon {
            font-size: 2.4rem;
            margin-bottom: 8px;
            opacity: 0.8;
        }
        .drop-zone .dz-title {
            font-weight: 600;
            font-size: 0.95rem;
        }
        .drop-zone .dz-hint {
            font-size: 0.78rem;
            color: var(--text2);
            margin-top: 4px;
        }
        .file-chips {
            display: flex;
            flex-wrap: wrap;
            gap: 6px;
            margin-top: 12px;
        }
        .chip {
            display: inline-flex;
            align-items: center;
            gap: 6px;
            background: var(--surface2);
            border: 1px solid var(--border);
            padding: 6px 12px;
            border-radius: 20px;
            font-size: 0.8rem;
            font-family: 'SF Mono', 'Consolas', monospace;
            color: #c0c0d0;
        }
        .chip .chip-tag {
            font-size: 0.64rem;
            background: #1e1e30;
            color: var(--accent);
            padding: 2px 7px;
            border-radius: 10px;
            font-weight: 500;
        }
        .chip .chip-del {
            cursor: pointer;
            color: #666;
            font-weight: 700;
            font-size: 1rem;
            line-height: 1;
            margin-left: 2px;
            transition: color 0.15s;
        }
        .chip .chip-del:hover {
            color: #f87171;
        }
        .row {
            display: flex;
            gap: 10px;
            align-items: center;
            flex-wrap: wrap;
        }
        .row.mt {
            margin-top: 12px;
        }
        .input {
            flex: 1;
            min-width: 180px;
            padding: 11px 16px;
            border-radius: var(--radius-xs);
            border: 1px solid var(--border);
            background: var(--surface2);
            color: var(--text);
            font-size: 0.9rem;
            outline: none;
            font-family: inherit;
            transition: border-color 0.2s;
        }
        .input:focus {
            border-color: var(--accent);
        }
        .input::placeholder {
            color: #555;
        }
        .btn {
            padding: 10px 20px;
            border-radius: 24px;
            border: 1px solid var(--border);
            background: var(--surface2);
            color: var(--text);
            cursor: pointer;
            font-size: 0.85rem;
            font-weight: 600;
            transition: all 0.2s;
            white-space: nowrap;
            display: inline-flex;
            align-items: center;
            gap: 5px;
            font-family: inherit;
            letter-spacing: 0.2px;
        }
        .btn:hover {
            background: #222238;
            border-color: #444;
        }
        .btn-primary {
            background: var(--accent2);
            border-color: var(--accent2);
            color: #fff;
            box-shadow: 0 4px 18px rgba(124, 92, 252, 0.25);
        }
        .btn-primary:hover {
            background: #8f6fff;
            border-color: #8f6fff;
            box-shadow: 0 6px 24px rgba(124, 92, 252, 0.35);
        }
        .btn-sm {
            padding: 6px 14px;
            font-size: 0.76rem;
            border-radius: 18px;
        }
        .btn-xs {
            padding: 4px 10px;
            font-size: 0.7rem;
            border-radius: 14px;
        }
        .btn:disabled {
            opacity: 0.35;
            cursor: not-allowed;
            pointer-events: none;
        }
        .hint {
            font-size: 0.72rem;
            color: var(--text2);
            margin-top: 6px;
            font-style: italic;
        }
        .results-box {
            display: none;
            max-height: 280px;
            overflow-y: auto;
            margin-top: 10px;
            border: 1px solid var(--border);
            border-radius: var(--radius-sm);
            background: var(--surface2);
        }
        .results-box.open {
            display: block;
        }
        .result-row {
            display: flex;
            align-items: center;
            justify-content: space-between;
            padding: 10px 14px;
            cursor: pointer;
            transition: background 0.12s;
            gap: 10px;
            flex-wrap: wrap;
        }
        .result-row:hover {
            background: #1e1e2c;
        }
        .result-row+.result-row {
            border-top: 1px solid rgba(255, 255, 255, 0.03);
        }
        .result-row.selected {
            background: #1a1830;
            border-left: 3px solid var(--accent);
        }
        .result-info {
            flex: 1;
            min-width: 0;
        }
        .result-info .rtrack {
            font-weight: 600;
            font-size: 0.88rem;
        }
        .result-info .rartist {
            font-size: 0.76rem;
            color: var(--text2);
        }
        .result-meta {
            font-size: 0.7rem;
            color: #555;
            white-space: nowrap;
        }
        .empty {
            text-align: center;
            color: var(--text2);
            padding: 28px;
            font-size: 0.85rem;
        }
        .spinner-wrap {
            text-align: center;
            padding: 28px;
            color: var(--text2);
            display: flex;
            align-items: center;
            justify-content: center;
            gap: 8px;
        }
        .spinner {
            width: 16px;
            height: 16px;
            border: 2px solid var(--border);
            border-top-color: var(--accent);
            border-radius: 50%;
            animation: spin 0.7s linear infinite;
        }
        @keyframes spin {
            to {
                transform: rotate(360deg);
            }
        }
        .lyrics-panel {
            display: none;
            margin-top: 12px;
            border: 1px solid var(--border);
            border-radius: var(--radius-sm);
            overflow: hidden;
        }
        .lyrics-panel.open {
            display: block;
        }
        .lyrics-top {
            display: flex;
            justify-content: space-between;
            align-items: center;
            flex-wrap: wrap;
            gap: 8px;
            padding: 12px 16px;
            background: #14141e;
            border-bottom: 1px solid var(--border);
        }
        .lyrics-top .ltitle {
            font-weight: 700;
            font-size: 1rem;
        }
        .lyrics-top .lartist {
            color: var(--accent);
            font-size: 0.82rem;
        }
        .lyrics-top .lalbum {
            color: var(--text2);
            font-size: 0.76rem;
        }
        .tabs {
            display: flex;
            gap: 3px;
            flex-wrap: wrap;
        }
        .tab {
            padding: 5px 12px;
            border-radius: 16px;
            border: 1px solid var(--border);
            background: transparent;
            color: var(--text2);
            cursor: pointer;
            font-size: 0.72rem;
            font-weight: 500;
            transition: 0.2s;
            font-family: inherit;
        }
        .tab.on {
            background: var(--accent2);
            border-color: var(--accent2);
            color: #fff;
        }
        .lyrics-content {
            padding: 16px;
            max-height: 340px;
            overflow-y: auto;
            background: #0c0c16;
            font-family: 'SF Mono', 'Fira Code', 'Consolas', 'PingFang SC', monospace;
            font-size: 0.8rem;
            line-height: 1.75;
            white-space: pre-wrap;
            color: #c0c0d0;
        }
        .lyrics-content .hl-tag {
            color: var(--accent);
            font-weight: 600;
        }
        .lyrics-content .hl-idx {
            color: #666;
        }
        .lyrics-content .hl-time {
            color: var(--green);
        }
        .lyrics-content .hl-section {
            color: var(--gold);
        }
        .lyrics-content .hl-style {
            color: #60a5fa;
        }
        .check-row {
            display: flex;
            gap: 14px;
            align-items: center;
            flex-wrap: wrap;
            padding: 10px 0;
            font-size: 0.76rem;
            color: var(--text2);
        }
        .check-row label {
            display: flex;
            align-items: center;
            gap: 5px;
            cursor: pointer;
            user-select: none;
        }
        .check-row input[type="checkbox"] {
            accent-color: var(--accent2);
            width: 15px;
            height: 15px;
            cursor: pointer;
        }
        .action-row {
            display: flex;
            gap: 8px;
            align-items: center;
            flex-wrap: wrap;
            padding-top: 12px;
            border-top: 1px solid var(--border);
        }
        .tag-chips {
            display: flex;
            gap: 4px;
            flex-wrap: wrap;
        }
        .tag-chip {
            padding: 4px 10px;
            border-radius: 14px;
            border: 1px solid var(--border);
            background: transparent;
            color: var(--text2);
            cursor: pointer;
            font-size: 0.68rem;
            font-weight: 500;
            transition: 0.2s;
            user-select: none;
            font-family: inherit;
        }
        .tag-chip.on {
            background: var(--accent2);
            border-color: var(--accent2);
            color: #fff;
        }
        .toast {
            position: fixed;
            top: 16px;
            left: 50%;
            transform: translateX(-50%);
            padding: 10px 22px;
            border-radius: 24px;
            font-weight: 600;
            font-size: 0.82rem;
            z-index: 999;
            opacity: 0;
            pointer-events: none;
            transition: opacity 0.3s;
            letter-spacing: 0.2px;
        }
        .toast.show {
            opacity: 1;
        }
        .toast.ok {
            background: #065f46;
            color: #d1fae5;
            box-shadow: 0 8px 28px rgba(5, 150, 105, 0.3);
        }
        .toast.err {
            background: #7f1d1d;
            color: #fecaca;
            box-shadow: 0 8px 28px rgba(220, 38, 38, 0.3);
        }
        .footer-note {
            text-align: center;
            font-size: 0.7rem;
            color: #555;
        }
        ::-webkit-scrollbar {
            width: 4px;
        }
        ::-webkit-scrollbar-track {
            background: transparent;
        }
        ::-webkit-scrollbar-thumb {
            background: #333;
            border-radius: 2px;
        }
        @media (max-width: 640px) {
            .row {
                flex-direction: column;
                align-items: stretch;
            }
            .btn {
                justify-content: center;
            }
            .action-row {
                flex-direction: column;
                align-items: stretch;
            }
        }
    </style>
</head>
<body>
    <div class="container">
        <div class="header">
            <h1>🎵 FLAC 歌词嵌入工具</h1>
            <div class="sub">LRCLib 歌词搜索 · Vorbis Comment 标签写入 · 元数据覆盖</div>
        </div>

        <div class="card">
            <div class="card-label">📂 选择 FLAC 文件</div>
            <div class="drop-wrapper" id="dropWrapper">
                <input type="file" id="fileInput" accept=".flac,audio/flac" multiple>
                <div class="drop-zone">
                    <div class="dz-icon">🎶</div>
                    <div class="dz-title">点击选择或拖拽 FLAC 文件</div>
                    <div class="dz-hint">支持批量 · 自动读取 TITLE / ARTIST / ALBUM 标签</div>
                </div>
            </div>
            <div class="file-chips" id="fileChips"></div>
            <div class="row mt">
                <button class="btn btn-sm" id="clearFilesBtn" disabled>🗑 清空</button>
                <span style="font-size:0.76rem;color:var(--text2);" id="fileStatus">等待添加 FLAC…</span>
            </div>
        </div>

        <div class="card">
            <div class="card-label">🔍 搜索歌词</div>
            <div class="row">
                <input class="input" id="searchInput" placeholder="歌曲名 或「歌曲名 - 歌手名」" autocomplete="off">
                <button class="btn btn-primary" id="searchBtn">搜索</button>
                <button class="btn btn-sm" id="directBtn">🎯 精确获取</button>
            </div>
            <div class="hint" id="autoFillHint"></div>
            <div class="results-box" id="resultsBox">
                <div id="resultsList"></div>
            </div>
        </div>

        <div class="card" id="lyricsCard" style="display:none;">
            <div class="lyrics-panel open" id="lyricsPreview">
                <div class="lyrics-top">
                    <div>
                        <span class="ltitle" id="songName">—</span>
                        <span style="margin:0 5px;color:#555;">·</span>
                        <span class="lartist" id="songArtist">—</span>
                        <span style="margin:0 5px;color:#555;">·</span>
                        <span class="lalbum" id="songAlbum">—</span>
                    </div>
                    <div class="tabs">
                        <button class="tab on" data-fmt="lrc">LRC</button>
                        <button class="tab" data-fmt="srt">SRT</button>
                        <button class="tab" data-fmt="ass">ASS</button>
                        <button class="tab" data-fmt="vtt">VTT</button>
                    </div>
                </div>
                <div class="lyrics-content" id="lyricsContent"></div>
            </div>
            <div class="check-row">
                <span>🔧 写入时覆盖:</span>
                <label><input type="checkbox" id="ovTitle" checked> TITLE</label>
                <label><input type="checkbox" id="ovArtist" checked> ARTIST</label>
                <label><input type="checkbox" id="ovAlbum" checked> ALBUM</label>
                <span style="color:#555;font-size:0.68rem;">用 API 返回信息覆盖 FLAC 标签</span>
            </div>
            <div class="action-row">
                <span style="font-size:0.72rem;color:var(--text2);font-weight:600;">歌词标签</span>
                <div class="tag-chips" id="tagChips">
                    <span class="tag-chip on" data-tag="LYRICS">LYRICS</span>
                    <span class="tag-chip" data-tag="UNSYNCEDLYRICS">UNSYNCEDLYRICS</span>
                    <span class="tag-chip" data-tag="LYRICS_LRC">LYRICS_LRC</span>
                    <span class="tag-chip" data-tag="LYRICS_SRT">LYRICS_SRT</span>
                    <span class="tag-chip" data-tag="LYRICS_ASS">LYRICS_ASS</span>
                    <span class="tag-chip" data-tag="LYRICS_VTT">LYRICS_VTT</span>
                </div>
                <span style="flex:1;"></span>
                <button class="btn btn-primary" id="embedBtn" disabled>💾 写入并下载</button>
            </div>
        </div>

        <div class="footer-note">浏览器安全限制:通过下载生成新文件,原文件不被修改</div>
    </div>

    <div class="toast" id="toast"></div>

    <script>
        (function() {
            var DW = document.getElementById('dropWrapper');
            var FI = document.getElementById('fileInput');
            var FC = document.getElementById('fileChips');
            var CFB = document.getElementById('clearFilesBtn');
            var FST = document.getElementById('fileStatus');
            var SI = document.getElementById('searchInput');
            var SB = document.getElementById('searchBtn');
            var DB = document.getElementById('directBtn');
            var AFH = document.getElementById('autoFillHint');
            var RB = document.getElementById('resultsBox');
            var RL = document.getElementById('resultsList');
            var LC = document.getElementById('lyricsCard');
            var LCT = document.getElementById('lyricsContent');
            var SN = document.getElementById('songName');
            var SA = document.getElementById('songArtist');
            var SAL = document.getElementById('songAlbum');
            var EB = document.getElementById('embedBtn');
            var OVT = document.getElementById('ovTitle');
            var OVA = document.getElementById('ovArtist');
            var OVAL = document.getElementById('ovAlbum');
            var TO = document.getElementById('toast');
            var TABS = document.querySelectorAll('.tab');
            var TCHIPS = document.querySelectorAll('.tag-chip');

            var files = [];
            var song = null;
            var fmt = 'lrc';
            var tag = 'LYRICS';
            var results = [];
            var selectedIdx = -1;
            var tt;

            function toast(m, e) {
                clearTimeout(tt);
                TO.textContent = m;
                TO.className = 'toast show ' + (e ? 'err' : 'ok');
                tt = setTimeout(function() { TO.className = 'toast'; }, 2200);
            }

            function esc(s) {
                var d = document.createElement('div');
                d.appendChild(document.createTextNode(s));
                return d.innerHTML;
            }

            function p2(n) { return String(n).padStart(2, '0'); }

            function p3(n) { return String(n).padStart(3, '0'); }

            function readMeta(file, cb) {
                var r = new FileReader();
                r.onload = function(e) {
                    try {
                        var b = e.target.result;
                        var v = new DataView(b);
                        if (b.byteLength < 4) return cb({ t: null, a: null, c: null, al: null });
                        if (String.fromCharCode(v.getUint8(0), v.getUint8(1), v.getUint8(2), v.getUint8(3)) !==
                            'fLaC') return cb({ t: null, a: null, c: null, al: null });
                        var o = 4,
                            lb = false,
                            t = null,
                            a = null,
                            c = null,
                            al = null;
                        while (o < b.byteLength && !lb) {
                            if (o + 4 > b.byteLength) break;
                            var h = v.getUint8(o);
                            lb = (h & 0x80) !== 0;
                            var bt = h & 0x7F;
                            var bs = (v.getUint8(o + 1) << 16) | (v.getUint8(o + 2) << 8) | v.getUint8(o + 3);
                            o += 4;
                            if (bt === 4 && o + bs <= b.byteLength) {
                                var bl = new Uint8Array(b, o, bs);
                                var dec = new TextDecoder('utf-8');
                                if (bl.length >= 4) {
                                    var vl = bl[0] | (bl[1] << 8) | (bl[2] << 16) | (bl[3] << 24);
                                    var p = 4 + vl;
                                    if (p + 4 <= bl.length) {
                                        var nc = bl[p] | (bl[p + 1] << 8) | (bl[p + 2] << 16) | (bl[p + 3] << 24);
                                        p += 4;
                                        for (var i = 0; i < nc && p + 4 <= bl.length; i++) {
                                            var cl = bl[p] | (bl[p + 1] << 8) | (bl[p + 2] << 16) | (bl[p + 3] <<
                                                24);
                                            p += 4;
                                            if (p + cl > bl.length) break;
                                            var cs = dec.decode(bl.slice(p, p + cl));
                                            p += cl;
                                            var ei = cs.indexOf('=');
                                            if (ei > 0) {
                                                var k = cs.substring(0, ei).toUpperCase().trim();
                                                var val = cs.substring(ei + 1).trim();
                                                if (k === 'TITLE' && !t) t = val;
                                                if (k === 'ARTIST' && !a) a = val;
                                                if (k === 'COMPOSER' && !c) c = val;
                                                if (k === 'ALBUM' && !al) al = val;
                                            }
                                        }
                                    }
                                }
                                break;
                            }
                            o += bs;
                        }
                        cb({ t: t, a: a, c: c, al: al });
                    } catch (x) { cb({ t: null, a: null, c: null, al: null }); }
                };
                r.onerror = function() { cb({ t: null, a: null, c: null, al: null }); };
                r.readAsArrayBuffer(file);
            }

            function autoFill() {
                if (files.length === 0) { AFH.textContent = ''; return; }
                var t = files[0].t || '';
                var a = files[0].a || files[0].c || '';
                if (t) {
                    var ft = t;
                    if (a) ft += ' - ' + a;
                    SI.value = ft;
                    AFH.textContent = '📋 已从 FLAC 标签自动填充:' + ft;
                } else {
                    AFH.textContent = '⚠️ 未找到 TITLE 标签,请手动输入';
                }
            }

            function updateFiles() {
                FC.innerHTML = '';
                if (files.length === 0) {
                    CFB.disabled = true;
                    FST.textContent = '等待添加 FLAC…';
                    EB.disabled = true;
                    AFH.textContent = '';
                } else {
                    CFB.disabled = false;
                    FST.textContent = files.length + ' 个 FLAC 文件';
                    if (song) EB.disabled = false;
                    for (var i = 0; i < files.length; i++) {
                        var f = files[i];
                        var el = document.createElement('span');
                        el.className = 'chip';
                        var h = '🎵 ' + esc(f.file.name);
                        if (f.t) h += ' <span class="chip-tag">' + esc(f.t) + '</span>';
                        h += ' <span class="chip-del" data-idx="' + i + '">×</span>';
                        el.innerHTML = h;
                        FC.appendChild(el);
                    }
                    var dels = FC.querySelectorAll('.chip-del');
                    for (var d = 0; d < dels.length; d++) {
                        dels[d].onclick = function(e) {
                            e.stopPropagation();
                            var idx = parseInt(this.getAttribute('data-idx'), 10);
                            files.splice(idx, 1);
                            updateFiles();
                            autoFill();
                        };
                    }
                }
            }

            function addFiles(fileList) {
                var only = [];
                for (var i = 0; i < fileList.length; i++) {
                    if (!fileList[i].name.toLowerCase().endsWith('.flac')) continue;
                    var dup = false;
                    for (var j = 0; j < files.length; j++) {
                        if (files[j].file.name === fileList[i].name &&
                            files[j].file.size === fileList[i].size &&
                            files[j].file.lastModified === fileList[i].lastModified) {
                            dup = true;
                            break;
                        }
                    }
                    if (!dup) only.push(fileList[i]);
                }
                if (only.length === 0) {
                    if (fileList.length > 0) toast('请选择 .flac 文件', true);
                    return;
                }
                FST.textContent = '🔍 读取元数据…';
                var done = 0;
                var total = only.length;

                function handleOne(f, meta) {
                    files.push({ file: f, t: meta.t, a: meta.a, c: meta.c, al: meta.al });
                    done++;
                    if (done >= total) {
                        updateFiles();
                        autoFill();
                        toast('✅ 已添加 ' + total + ' 个 FLAC 文件');
                        if (files.length > 0 && SI.value.trim()) doSearch(SI.value);
                    }
                }

                for (var k = 0; k < only.length; k++) {
                    (function(fileRef) {
                        readMeta(fileRef, function(meta) {
                            handleOne(fileRef, meta);
                        });
                    })(only[k]);
                }
            }

            function handleFileSelect(fileList) {
                if (fileList && fileList.length) {
                    addFiles(fileList);
                }
            }

            FI.addEventListener('change', function(e) {
                handleFileSelect(e.target.files);
                FI.value = '';
            });

            DW.addEventListener('dragover', function(e) {
                e.preventDefault();
                e.stopPropagation();
                DW.classList.add('drag-over');
            });

            DW.addEventListener('dragleave', function(e) {
                e.preventDefault();
                e.stopPropagation();
                DW.classList.remove('drag-over');
            });

            DW.addEventListener('drop', function(e) {
                e.preventDefault();
                e.stopPropagation();
                DW.classList.remove('drag-over');
                if (e.dataTransfer.files && e.dataTransfer.files.length) {
                    handleFileSelect(e.dataTransfer.files);
                }
            });

            CFB.addEventListener('click', function() {
                files = [];
                updateFiles();
                autoFill();
                toast('🧹 已清空');
            });

            function apiSearch(q) {
                return fetch('https://lrclib.net/api/search?q=' + encodeURIComponent(q)).then(function(r) {
                    if (!r.ok) throw new Error('搜索失败');
                    return r.json();
                });
            }

            function apiGet(t, a) {
                return fetch('https://lrclib.net/api/get?track_name=' + encodeURIComponent(t) + '&artist_name=' +
                    encodeURIComponent(a)).then(function(r) {
                    if (!r.ok) throw new Error('获取失败');
                    return r.text();
                }).then(function(t) { if (!t) throw new Error('未找到'); return JSON.parse(t); });
            }

            function parseLrc(sl) {
                var items = [];
                if (!sl) return items;
                var re = /\[(\d{2}):(\d{2})\.(\d{2,3})\]\s*(.*)/g,
                    m;
                while ((m = re.exec(sl)) !== null) {
                    var ms = m[3].length === 2 ? parseInt(m[3], 10) * 10 : parseInt(m[3], 10);
                    items.push({ tm: parseInt(m[1]) * 60000 + parseInt(m[2]) * 1000 + ms, tx: (m[4] || '').trim() });
                }
                return items;
            }

            function msLrc(ms) { var mn = Math.floor(ms / 60000); return '[' + p2(mn) + ':' + p2(Math.floor((ms % 60000) /
                    1000)) + '.' + p2(Math.floor((ms % 1000) / 10)) + ']'; }

            function msSrt(ms) { var h = Math.floor(ms / 3600000); return p2(h) + ':' + p2(Math.floor((ms % 3600000) /
                    60000)) + ':' + p2(Math.floor((ms % 60000) / 1000)) + ',' + p3(ms % 1000); }

            function msAss(ms) { var h = Math.floor(ms / 3600000); return h + ':' + p2(Math.floor((ms % 3600000) / 60000)) +
                    ':' + p2(Math.floor((ms % 60000) / 1000)) + '.' + p2(Math.floor((ms % 1000) / 10)); }

            function msVtt(ms) { var h = Math.floor(ms / 3600000); return p2(h) + ':' + p2(Math.floor((ms % 3600000) /
                    60000)) + ':' + p2(Math.floor((ms % 60000) / 1000)) + '.' + p3(ms % 1000); }

            function genLRC(s) {
                var it = parseLrc(s.syncedLyrics);
                var l = [];
                l.push('[ti:' + (s.trackName || '') + ']');
                l.push('[ar:' + (s.artistName || '') + ']');
                if (s.albumName) l.push('[al:' + s.albumName + ']');
                if (s.duration) { var m = Math.floor(s.duration / 60);
                    l.push('[length:' + p2(m) + ':' + p2(Math.floor(s.duration % 60)) + ']'); }
                l.push('');
                if (it.length) { for (var i = 0; i < it.length; i++) l.push(msLrc(it[i].tm) + (it[i].tx || '♪')); } else if (
                    s.plainLyrics) { var pl = s.plainLyrics.split('\n'); for (var j = 0; j < pl.length; j++) l.push(pl[j]
                        .trim()); }
                return l.join('\n');
            }

            function genSRT(s) {
                var it = parseLrc(s.syncedLyrics);
                if (!it.length) return s.plainLyrics ? '1\n00:00:00,000 --> 00:03:00,000\n' + s.plainLyrics.trim() + '\n' :
                    '';
                var l = [];
                for (var i = 0; i < it.length; i++) { var nx = it[i + 1],
                        em = nx ? nx.tm : it[i].tm + 3000;
                    l.push(String(i + 1));
                    l.push(msSrt(it[i].tm) + ' --> ' + msSrt(em));
                    l.push(it[i].tx || '♪');
                    l.push(''); }
                return l.join('\n').trim();
            }

            function genASS(s) {
                var it = parseLrc(s.syncedLyrics);
                var l = [];
                l.push('[Script Info]');
                l.push('Title: ' + (s.trackName || 'Unknown'));
                l.push('Original Script: ' + (s.artistName || 'Unknown'));
                l.push('ScriptType: v4.00+');
                l.push('Collisions: Normal');
                l.push('PlayDepth: 0');
                l.push('');
                l.push('[V4+ Styles]');
                l.push(
                    'Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding');
                l.push(
                    'Style: Default,Microsoft YaHei,36,&H00FFFFFF,&H000000FF,&H00000000,&H80000000,-1,0,0,0,100,100,0,0,1,2,1,2,30,30,30,1');
                l.push('');
                l.push('[Events]');
                l.push('Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text');
                if (it.length) {
                    for (var i = 0; i < it.length; i++) { var nx = it[i + 1],
                            em = nx ? nx.tm : it[i].tm + 3000;
                        l.push('Dialogue: 0,' + msAss(it[i].tm) + ',' + msAss(em) + ',Default,,0,0,0,,' + (it[i].tx ||
                            '♪').replace(/\n/g, '\\N')); }
                } else if (s.plainLyrics) {
                    var pl = s.plainLyrics.split('\n').filter(function(x) { return x.trim(); });
                    var dur = (s.duration || 180) * 1000;
                    var each = Math.floor(dur / Math.max(pl.length, 1));
                    for (var j = 0; j < pl.length; j++) l.push('Dialogue: 0,' + msAss(j * each) + ',' + msAss(j * each +
                        each) + ',Default,,0,0,0,,' + pl[j].trim().replace(/\n/g, '\\N'));
                }
                return l.join('\n');
            }

            function genVTT(s) {
                var it = parseLrc(s.syncedLyrics);
                var l = [];
                l.push('WEBVTT');
                l.push('');
                if (it.length) {
                    for (var i = 0; i < it.length; i++) { var nx = it[i + 1],
                            em = nx ? nx.tm : it[i].tm + 3000;
                        l.push(msVtt(it[i].tm) + ' --> ' + msVtt(em));
                        l.push(it[i].tx || '♪');
                        l.push(''); }
                } else if (s.plainLyrics) {
                    var pl = s.plainLyrics.split('\n').filter(function(x) { return x.trim(); });
                    var dur = (s.duration || 180) * 1000;
                    var each = Math.floor(dur / Math.max(pl.length, 1));
                    for (var j = 0; j < pl.length; j++) { l.push(msVtt(j * each) + ' --> ' + msVtt(j * each + each));
                        l.push(pl[j].trim());
                        l.push(''); }
                }
                return l.join('\n').trim();
            }

            function getFmt(f, s) {
                if (!s) return '';
                if (f === 'lrc') return genLRC(s);
                if (f === 'srt') return genSRT(s);
                if (f === 'ass') return genASS(s);
                if (f === 'vtt') return genVTT(s);
                return '';
            }

            function hlHtml(f, s) {
                var raw = getFmt(f, s);
                if (!raw) return '<span style="color:#555;">暂无内容</span>';
                var e = esc(raw);
                if (f === 'lrc') { e = e.replace(/^(\[ti:.*\]|\[ar:.*\]|\[al:.*\]|\[length:.*\])$/gm,
                        '<span class="hl-tag">$1</span>');
                    e = e.replace(/^(\[\d{2}:\d{2}\.\d{2}\])/gm, '<span class="hl-tag">$1</span>'); } else if (f ===
                    'srt') { e = e.replace(/^(\d+)$/gm, '<span class="hl-idx">$1</span>');
                    e = e.replace(/^(\d{2}:\d{2}:\d{2},\d{3} --> \d{2}:\d{2}:\d{2},\d{3})$/gm,
                        '<span class="hl-time">$1</span>'); } else if (f === 'ass') { e = e.replace(/^(\[.*\])$/gm,
                        '<span class="hl-section">$1</span>');
                    e = e.replace(/^(Style:.*)$/gm, '<span class="hl-style">$1</span>');
                    e = e.replace(/^(Format:.*)$/gm, '<span class="hl-style">$1</span>'); } else if (f === 'vtt') { e = e
                        .replace(/^(WEBVTT)$/gm, '<span class="hl-section">$1</span>');
                    e = e.replace(/^(\d{2}:\d{2}:\d{2}\.\d{3} --> \d{2}:\d{2}:\d{2}\.\d{3})$/gm,
                        '<span class="hl-time">$1</span>'); }
                return e;
            }

            function renderSong(s) {
                song = s;
                SN.textContent = s.trackName || '未知';
                SA.textContent = s.artistName || '未知';
                SAL.textContent = s.albumName ? '💿 ' + s.albumName : '';
                LC.style.display = 'block';
                if (files.length > 0) EB.disabled = false;
                updateLyrics();
                LC.scrollIntoView({ behavior: 'smooth', block: 'center' });
            }

            function updateLyrics() { LCT.innerHTML = hlHtml(fmt, song); }

            for (var t = 0; t < TABS.length; t++) {
                TABS[t].addEventListener('click', function() {
                    for (var i = 0; i < TABS.length; i++) TABS[i].classList.remove('on');
                    this.classList.add('on');
                    fmt = this.getAttribute('data-fmt');
                    updateLyrics();
                });
            }

            for (var c = 0; c < TCHIPS.length; c++) {
                TCHIPS[c].addEventListener('click', function() {
                    for (var i = 0; i < TCHIPS.length; i++) TCHIPS[i].classList.remove('on');
                    this.classList.add('on');
                    tag = this.getAttribute('data-tag');
                });
            }

            function doSearch(q) {
                if (!q || !q.trim()) { toast('请输入关键词', true); return; }
                results = [];
                selectedIdx = -1;
                RL.innerHTML = '<div class="spinner-wrap"><span class="spinner"></span>搜索中…</div>';
                RB.classList.add('open');
                apiSearch(q.trim()).then(function(rs) {
                    results = rs;
                    if (!rs.length) { RL.innerHTML = '<div class="empty">😕 未找到匹配的歌曲</div>'; return; }
                    var h = '';
                    for (var i = 0; i < rs.length; i++) {
                        var r = rs[i];
                        h += '<div class="result-row" data-idx="' + i + '">';
                        h += '<div class="result-info"><div class="rtrack">' + esc(r.trackName || r.name || '未知') +
                            '</div><div class="rartist">' + esc(r.artistName || '未知') + '</div></div>';
                        h += '<div class="result-meta">' + fdur(r.duration) + '</div>';
                        h += '<button class="btn btn-xs pick-btn" data-idx="' + i + '">选择</button>';
                        h += '</div>';
                    }
                    RL.innerHTML = h;
                    bindResults();
                }).catch(function(err) { RL.innerHTML = '<div class="empty">❌ ' + esc(err.message) + '</div>'; });
            }

            function bindResults() {
                var rows = RL.querySelectorAll('.result-row');
                for (var j = 0; j < rows.length; j++) {
                    (function(idx) {
                        rows[j].addEventListener('click', function(e) {
                            if (e.target.closest('.pick-btn')) return;
                            loadResult(idx);
                        });
                    })(j);
                }
                var btns = RL.querySelectorAll('.pick-btn');
                for (var k = 0; k < btns.length; k++) {
                    (function(idx) {
                        btns[k].addEventListener('click', function(e) {
                            e.stopPropagation();
                            loadResult(idx);
                        });
                    })(k);
                }
            }

            function loadResult(idx) {
                var s = results[idx];
                if (!s) return;
                selectedIdx = idx;
                var tn = s.trackName || s.name;
                var an = s.artistName || '';
                var rows = RL.querySelectorAll('.result-row');
                for (var i = 0; i < rows.length; i++) {
                    rows[i].classList.remove('selected');
                    rows[i].style.opacity = '0.4';
                }
                var tgt = RL.querySelector('[data-idx="' + idx + '"]');
                if (tgt) { tgt.classList.add('selected');
                    tgt.style.opacity = '1'; }
                apiGet(tn, an).then(function(d) {
                    renderSong(d);
                    for (var j = 0; j < rows.length; j++) rows[j].style.opacity = '1';
                }).catch(function(err) {
                    toast('❌ ' + err.message, true);
                    for (var j = 0; j < rows.length; j++) rows[j].style.opacity = '1';
                    selectedIdx = -1;
                });
            }

            function fdur(s) { if (!s && s !== 0) return ''; var m = Math.floor(s / 60); return m + ':' + p2(Math.floor(s %
                    60)); }

            SB.addEventListener('click', function() { doSearch(SI.value); });
            SI.addEventListener('keydown', function(e) { if (e.key === 'Enter') doSearch(SI.value); });
            DB.addEventListener('click', function() {
                var q = SI.value.trim();
                if (!q) { toast('请输入「歌曲名 - 歌手名」', true); return; }
                var seps = [' - ', '-', ' – ', '–', ' | ', '|', ':', ':'];
                var tk = '',
                    ar = '';
                for (var i = 0; i < seps.length; i++) { if (q.indexOf(seps[i]) !== -1) { var pts = q.split(seps[i]);
                        tk = pts[0].trim();
                        ar = pts.slice(1).join(seps[i]).trim(); break; } }
                if (!tk) { tk = prompt('歌曲名:', q); if (!tk) return;
                    ar = prompt('歌手名(可选):', '') || ''; }
                results = [];
                selectedIdx = -1;
                RL.innerHTML = '<div class="spinner-wrap"><span class="spinner"></span>获取中…</div>';
                RB.classList.add('open');
                apiGet(tk, ar).then(function(d) {
                    results = [d];
                    selectedIdx = 0;
                    RL.innerHTML =
                        '<div class="result-row selected" data-idx="0" style="opacity:1;"><div class="result-info"><div class="rtrack">' +
                        esc(d.trackName || d.name || '未知') + '</div><div class="rartist">' + esc(d.artistName ||
                            '未知') + '</div></div><div class="result-meta">' + fdur(d.duration) + '</div></div>';
                    renderSong(d);
                }).catch(function(err) { RL.innerHTML = '<div class="empty">❌ ' + esc(err.message) + '</div>';
                    toast('❌ ' + err.message, true); });
            });

            function readBlocks(ab) {
                var v = new DataView(ab);
                var b = ab;
                if (b.byteLength < 4) throw new Error('太小');
                if (String.fromCharCode(v.getUint8(0), v.getUint8(1), v.getUint8(2), v.getUint8(3)) !== 'fLaC') throw new Error(
                    '非FLAC');
                var bl = [];
                var o = 4,
                    lb = false;
                while (o < b.byteLength && !lb) {
                    if (o + 4 > b.byteLength) break;
                    var h = v.getUint8(o);
                    lb = (h & 0x80) !== 0;
                    var bt = h & 0x7F;
                    var bs = (v.getUint8(o + 1) << 16) | (v.getUint8(o + 2) << 8) | v.getUint8(o + 3);
                    var st = o;
                    o += 4;
                    bl.push({ type: bt, last: lb, ho: st, dto: o, size: bs });
                    o += bs;
                }
                return { blocks: bl, buf: b };
            }

            function parseVC(bi, buf) {
                var bl = new Uint8Array(buf, bi.dto, bi.size);
                var dec = new TextDecoder('utf-8');
                if (bl.length < 4) return { cmts: [] };
                var vl = bl[0] | (bl[1] << 8) | (bl[2] << 16) | (bl[3] << 24);
                var p = 4 + vl;
                if (p + 4 > bl.length) return { cmts: [] };
                var nc = bl[p] | (bl[p + 1] << 8) | (bl[p + 2] << 16) | (bl[p + 3] << 24);
                p += 4;
                var cmts = [];
                for (var i = 0; i < nc; i++) {
                    if (p + 4 > bl.length) break;
                    var cl = bl[p] | (bl[p + 1] << 8) | (bl[p + 2] << 16) | (bl[p + 3] << 24);
                    p += 4;
                    if (p + cl > bl.length) break;
                    cmts.push(dec.decode(bl.slice(p, p + cl)));
                    p += cl;
                }
                return { cmts: cmts };
            }

            function buildVC(cmts, isLast) {
                var enc = new TextEncoder();
                var vb = new Uint8Array(4);
                var cb = new Uint8Array(4);
                cb[0] = cmts.length & 0xFF;
                cb[1] = (cmts.length >> 8) & 0xFF;
                cb[2] = (cmts.length >> 16) & 0xFF;
                cb[3] = (cmts.length >> 24) & 0xFF;
                var arrs = [];
                for (var i = 0; i < cmts.length; i++) {
                    var ed = enc.encode(cmts[i]);
                    var lb = new Uint8Array(4);
                    lb[0] = ed.length & 0xFF;
                    lb[1] = (ed.length >> 8) & 0xFF;
                    lb[2] = (ed.length >> 16) & 0xFF;
                    lb[3] = (ed.length >> 24) & 0xFF;
                    arrs.push(lb);
                    arrs.push(ed);
                }
                var tp = 8;
                for (var j = 0; j < arrs.length; j++) tp += arrs[j].length;
                var bh = new Uint8Array(4);
                bh[0] = 4;
                if (isLast) bh[0] |= 0x80;
                bh[1] = (tp >> 16) & 0xFF;
                bh[2] = (tp >> 8) & 0xFF;
                bh[3] = tp & 0xFF;
                var res = new Uint8Array(4 + tp);
                res.set(bh, 0);
                var off = 4;
                res.set(vb, off);
                off += 4;
                res.set(cb, off);
                off += 4;
                for (var k = 0; k < arrs.length; k++) { res.set(arrs[k], off);
                    off += arrs[k].length; }
                return res;
            }

            function embed(file, lc, tn, sm, ov, cb) {
                var r = new FileReader();
                r.onload = function(e) {
                    try {
                        var info = readBlocks(e.target.result);
                        var bl = info.blocks;
                        var buf = info.buf;
                        var vi = -1;
                        for (var i = 0; i < bl.length; i++) { if (bl[i].type === 4) { vi = i; break; } }
                        var nc = [];
                        var tu = tn.toUpperCase();
                        var pk = [tu];
                        if (ov.title) pk.push('TITLE');
                        if (ov.artist) pk.push('ARTIST');
                        if (ov.album) pk.push('ALBUM');
                        if (vi >= 0) {
                            var parsed = parseVC(bl[vi], buf);
                            for (var j = 0; j < parsed.cmts.length; j++) {
                                var eq = parsed.cmts[j].indexOf('=');
                                if (eq > 0) { var key = parsed.cmts[j].substring(0, eq).toUpperCase().trim(); if (pk.indexOf(
                                        key) === -1) nc.push(parsed.cmts[j]); } else nc.push(parsed.cmts[j]);
                            }
                        }
                        nc.push(tu + '=' + lc);
                        if (ov.title && sm.trackName) nc.push('TITLE=' + sm.trackName);
                        if (ov.artist && sm.artistName) nc.push('ARTIST=' + sm.artistName);
                        if (ov.album && sm.albumName) nc.push('ALBUM=' + sm.albumName);
                        var isLast = (vi >= 0) ? bl[vi].last : false;
                        var nb = buildVC(nc, isLast);
                        var parts = [];
                        if (vi >= 0) {
                            parts.push(new Uint8Array(buf, 0, bl[vi].ho));
                            parts.push(nb);
                            parts.push(new Uint8Array(buf, bl[vi].dto + bl[vi].size));
                        } else {
                            var si = -1;
                            for (var k = 0; k < bl.length; k++) { if (bl[k].type === 0) { si = k; break; } }
                            if (si >= 0) {
                                var sho = bl[si].ho;
                                var sa = bl[si].dto + bl[si].size;
                                parts.push(new Uint8Array(buf, 0, sho));
                                var sh = new Uint8Array(buf, sho, 4);
                                sh[0] = sh[0] & 0x7F;
                                parts.push(sh);
                                parts.push(new Uint8Array(buf, bl[si].dto, bl[si].size));
                                parts.push(nb);
                                parts.push(new Uint8Array(buf, sa));
                            } else {
                                parts.push(new Uint8Array(buf, 0, 4));
                                parts.push(nb);
                                parts.push(new Uint8Array(buf, 4));
                            }
                        }
                        var tl = 0;
                        for (var p = 0; p < parts.length; p++) tl += parts[p].length;
                        var result = new Uint8Array(tl);
                        var off = 0;
                        for (var q = 0; q < parts.length; q++) { result.set(parts[q], off);
                            off += parts[q].length; }
                        cb(null, new Blob([result], { type: 'audio/flac' }));
                    } catch (x) { cb(x); }
                };
                r.onerror = function() { cb(new Error('读取失败')); };
                r.readAsArrayBuffer(file);
            }

            EB.addEventListener('click', function() {
                if (!files.length) { toast('请先添加 FLAC 文件', true); return; }
                if (!song) { toast('请先选择歌词', true); return; }
                var lc = getFmt(fmt, song);
                if (!lc.trim()) { toast('当前格式无内容', true); return; }
                var ov = { title: OVT.checked, artist: OVA.checked, album: OVAL.checked };
                EB.disabled = true;
                CFB.disabled = true;
                var extra = [];
                if (ov.title) extra.push('TITLE');
                if (ov.artist) extra.push('ARTIST');
                if (ov.album) extra.push('ALBUM');
                var em = extra.length ? ' + 覆盖 ' + extra.join('/') : '';
                toast('⏳ 正在写入 ' + files.length + ' 个文件' + em + '…');
                var ok = 0,
                    fl = 0;

                function next(idx) {
                    if (idx >= files.length) { EB.disabled = false;
                        CFB.disabled = false; if (fl === 0) toast('🎉 成功!已下载 ' + ok + ' 个 FLAC 文件' + em);
                        else toast('⚠️ ' + ok + ' 成功, ' + fl + ' 失败', true); return; }
                    embed(files[idx].file, lc, tag, song, ov, function(err, blob) {
                        if (err) { fl++;
                            console.error(err); } else {
                            var url = URL.createObjectURL(blob);
                            var a = document.createElement('a');
                            a.href = url;
                            a.download = files[idx].file.name;
                            document.body.appendChild(a);
                            a.click();
                            document.body.removeChild(a);
                            URL.revokeObjectURL(url);
                            ok++;
                        }
                        setTimeout(function() { next(idx + 1); }, 200);
                    });
                }
                next(0);
            });

            updateFiles();
            SI.focus();
        })();
    </script>
</body>
</html>

歌词搜索、标签写入和元数据覆盖一次性搞定

2 个帖子 - 2 位参与者

阅读完整话题

来源: LinuxDo 最新话题查看原文