// ==UserScript== // @name FLAC Player // @namespace https://tingting.app // @version 1.0.0 // @description 在 Safari 中直接播放网页上的 FLAC 音频链接,并提供下载功能 // @author Tingting // @match *://*/* // @grant none // @run-at document-end // ==/UserScript== (function () { 'use strict'; // ── 配置 ──────────────────────────────────────────────────────────────────── const ACCENT = '#22C55E'; const CARD_BG = '#2c2c2e'; const TEXT = '#f2f2f7'; const SUBTEXT = '#8e8e93'; // ── 全局状态 ───────────────────────────────────────────────────────────────── let audioEl = null; let modalEl = null; let playBtnEl = null; let progressEl = null; // ── 样式注入 ───────────────────────────────────────────────────────────────── function injectStyles() { const style = document.createElement('style'); style.id = 'fp-styles'; style.textContent = ` #fp-overlay { position: fixed; inset: 0; z-index: 2147483647; background: rgba(0,0,0,0.6); backdrop-filter: blur(10px); display: flex; align-items: flex-end; justify-content: center; padding-bottom: 48px; animation: fp-fadein 0.2s ease; } @keyframes fp-fadein { from { opacity: 0 } to { opacity: 1 } } @keyframes fp-slidein { from { transform: translateY(24px); opacity: 0 } to { transform: translateY(0); opacity: 1 } } #fp-card { background: ${CARD_BG}; border-radius: 20px; padding: 20px 24px 22px; width: 380px; max-width: calc(100vw - 32px); box-shadow: 0 24px 64px rgba(0,0,0,0.55), 0 0 0 0.5px rgba(255,255,255,0.08); animation: fp-slidein 0.28s cubic-bezier(0.34,1.56,0.64,1); font-family: -apple-system, sans-serif; } #fp-header { display: flex; align-items: center; gap: 12px; margin-bottom: 18px; } #fp-icon { width: 46px; height: 46px; border-radius: 12px; flex-shrink: 0; background: linear-gradient(135deg, #1a3a28 0%, #0d2016 100%); display: flex; align-items: center; justify-content: center; font-size: 22px; } #fp-title-wrap { flex: 1; min-width: 0; } #fp-title { color: ${TEXT}; font-size: 14px; font-weight: 600; line-height: 1.3; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } #fp-subtitle { color: ${SUBTEXT}; font-size: 12px; margin-top: 3px; } #fp-close { width: 28px; height: 28px; border-radius: 50%; flex-shrink: 0; background: rgba(255,255,255,0.08); border: none; cursor: pointer; color: ${SUBTEXT}; font-size: 14px; display: flex; align-items: center; justify-content: center; transition: background 0.15s, color 0.15s; } #fp-close:hover { background: rgba(255,255,255,0.18); color: ${TEXT}; } #fp-progress-wrap { padding: 10px 0 4px; cursor: pointer; } #fp-progress-track { height: 3px; background: rgba(255,255,255,0.1); border-radius: 2px; transition: height 0.15s, background 0.15s; overflow: hidden; } #fp-progress-wrap:hover #fp-progress-track { height: 5px; } #fp-progress-fill { height: 100%; width: 0%; background: ${ACCENT}; border-radius: 2px; transition: width 0.1s linear; } #fp-time-row { display: flex; justify-content: space-between; color: ${SUBTEXT}; font-size: 11px; margin: 6px 0 18px; font-variant-numeric: tabular-nums; } #fp-controls { display: flex; align-items: center; gap: 14px; } #fp-play-btn { width: 46px; height: 46px; border-radius: 50%; flex-shrink: 0; background: ${ACCENT}; border: none; cursor: pointer; color: #000; font-size: 17px; display: flex; align-items: center; justify-content: center; transition: transform 0.12s, opacity 0.12s; } #fp-play-btn:hover { transform: scale(1.07); } #fp-play-btn:active { transform: scale(0.93); } #fp-volume-wrap { flex: 1; display: flex; align-items: center; gap: 8px; } #fp-vol-icon { color: ${SUBTEXT}; font-size: 14px; user-select: none; } #fp-volume { flex: 1; -webkit-appearance: none; height: 3px; border-radius: 2px; background: rgba(255,255,255,0.12); cursor: pointer; outline: none; } #fp-volume::-webkit-slider-thumb { -webkit-appearance: none; width: 13px; height: 13px; border-radius: 50%; background: ${TEXT}; cursor: pointer; box-shadow: 0 1px 4px rgba(0,0,0,0.4); } #fp-download-btn { width: 38px; height: 38px; border-radius: 50%; flex-shrink: 0; background: rgba(255,255,255,0.08); border: none; cursor: pointer; color: ${SUBTEXT}; font-size: 18px; font-weight: 300; display: flex; align-items: center; justify-content: center; transition: background 0.15s, color 0.15s; } #fp-download-btn:hover { background: rgba(255,255,255,0.16); color: ${TEXT}; } #fp-download-btn.fp-loading { animation: fp-spin 0.8s linear infinite; } @keyframes fp-spin { to { transform: rotate(360deg); } } /* FLAC 链接标记 */ a[data-fp-decorated]::after { content: ' ▶'; color: ${ACCENT}; font-size: 0.75em; opacity: 0.75; vertical-align: middle; } `; document.head.appendChild(style); } // ── 工具函数 ───────────────────────────────────────────────────────────────── function formatTime(s) { if (!s || isNaN(s)) return '--:--'; const m = Math.floor(s / 60); return `${m}:${Math.floor(s % 60).toString().padStart(2, '0')}`; } function getFilename(url) { try { const p = new URL(url).pathname; return decodeURIComponent(p.split('/').pop() || 'audio.flac'); } catch { return 'audio.flac'; } } // ── 下载处理 ───────────────────────────────────────────────────────────────── async function downloadFile(url, filename, btn) { btn.classList.add('fp-loading'); btn.textContent = '↻'; try { const res = await fetch(url); if (!res.ok) throw new Error(`HTTP ${res.status}`); const blob = await res.blob(); const blobUrl = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = blobUrl; a.download = filename; document.body.appendChild(a); a.click(); document.body.removeChild(a); setTimeout(() => URL.revokeObjectURL(blobUrl), 10000); } catch { // CORS 受限时回退:创建带 download 属性的链接 const a = document.createElement('a'); a.href = url; a.download = filename; a.target = '_blank'; document.body.appendChild(a); a.click(); document.body.removeChild(a); } finally { btn.classList.remove('fp-loading'); btn.textContent = '↓'; } } // ── 播放器 Modal ───────────────────────────────────────────────────────────── function openPlayer(url, linkTitle) { destroyPlayer(); const filename = getFilename(url); const displayTitle = (linkTitle && linkTitle.length < 80) ? linkTitle : filename; const overlay = document.createElement('div'); overlay.id = 'fp-overlay'; overlay.innerHTML = `
🎵
${displayTitle}
FLAC · 无损音质
0:00 --:--
🔊
`; document.body.appendChild(overlay); modalEl = overlay; // 元素引用 progressEl = overlay.querySelector('#fp-progress-fill'); playBtnEl = overlay.querySelector('#fp-play-btn'); const currentEl = overlay.querySelector('#fp-current'); const durationEl = overlay.querySelector('#fp-duration'); const progressWrap = overlay.querySelector('#fp-progress-wrap'); const volumeEl = overlay.querySelector('#fp-volume'); const downloadBtn = overlay.querySelector('#fp-download-btn'); // Audio 元素 audioEl = new Audio(url); audioEl.addEventListener('loadedmetadata', () => { durationEl.textContent = formatTime(audioEl.duration); }); audioEl.addEventListener('timeupdate', () => { currentEl.textContent = formatTime(audioEl.currentTime); if (audioEl.duration) { progressEl.style.width = `${(audioEl.currentTime / audioEl.duration) * 100}%`; } }); audioEl.addEventListener('ended', () => { playBtnEl.textContent = '▶'; }); audioEl.addEventListener('error', () => { durationEl.textContent = '加载失败'; playBtnEl.textContent = '✕'; playBtnEl.disabled = true; }); // 控制事件 playBtnEl.addEventListener('click', togglePlay); progressWrap.addEventListener('click', (e) => { if (!audioEl.duration) return; const rect = progressWrap.getBoundingClientRect(); audioEl.currentTime = ((e.clientX - rect.left) / rect.width) * audioEl.duration; }); volumeEl.addEventListener('input', () => { audioEl.volume = parseFloat(volumeEl.value); }); downloadBtn.addEventListener('click', () => downloadFile(url, filename, downloadBtn)); overlay.querySelector('#fp-close').addEventListener('click', destroyPlayer); overlay.addEventListener('click', (e) => { if (e.target === overlay) destroyPlayer(); }); document.addEventListener('keydown', handleKeydown); // 自动播放 audioEl.play() .then(() => { playBtnEl.textContent = '⏸'; }) .catch(() => { /* 自动播放被阻止,等用户点击 */ }); } function togglePlay() { if (!audioEl) return; if (audioEl.paused) { audioEl.play(); playBtnEl.textContent = '⏸'; } else { audioEl.pause(); playBtnEl.textContent = '▶'; } } function destroyPlayer() { if (audioEl) { audioEl.pause(); audioEl.src = ''; audioEl = null; } if (modalEl) { modalEl.remove(); modalEl = null; } document.removeEventListener('keydown', handleKeydown); } function handleKeydown(e) { if (!modalEl) return; switch (e.key) { case 'Escape': destroyPlayer(); break; case ' ': if (e.target === document.body || e.target === document.documentElement) { e.preventDefault(); togglePlay(); } break; case 'ArrowRight': if (audioEl) audioEl.currentTime = Math.min(audioEl.currentTime + 10, audioEl.duration || 0); break; case 'ArrowLeft': if (audioEl) audioEl.currentTime = Math.max(audioEl.currentTime - 10, 0); break; } } // ── 链接检测 ───────────────────────────────────────────────────────────────── function isFlacLink(href) { if (!href) return false; try { const path = new URL(href, location.href).pathname; return /\.flac$/i.test(path); } catch { return /\.flac(\?|#|$)/i.test(href); } } function decorateLink(link) { if (link.dataset.fpDecorated) return; link.dataset.fpDecorated = 'true'; link.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); openPlayer(link.href, link.textContent.trim() || null); }); } function scanLinks(root = document) { root.querySelectorAll('a[href]').forEach(link => { if (isFlacLink(link.getAttribute('href'))) decorateLink(link); }); } function observeDOM() { const observer = new MutationObserver((mutations) => { for (const m of mutations) { for (const node of m.addedNodes) { if (node.nodeType !== 1) continue; if (node.tagName === 'A') { if (isFlacLink(node.getAttribute('href'))) decorateLink(node); } else if (node.querySelectorAll) { scanLinks(node); } } } }); observer.observe(document.body, { childList: true, subtree: true }); } // ── 启动 ───────────────────────────────────────────────────────────────────── injectStyles(); scanLinks(); observeDOM(); })();