// ==UserScript== // @name FLAC Player // @namespace https://tingting.app // @version 1.0.1 // @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; } @media (min-width: 768px) { #fp-overlay { align-items: center; padding-bottom: 0; } #fp-card { animation: fp-scalein 0.25s cubic-bezier(0.34,1.56,0.64,1); } } @keyframes fp-fadein { from { opacity: 0 } to { opacity: 1 } } @keyframes fp-slidein { from { transform: translateY(24px); opacity: 0 } to { transform: translateY(0); opacity: 1 } } @keyframes fp-scalein { from { transform: scale(0.92); opacity: 0 } to { transform: scale(1); 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 = `