From 9f1d52c09d4b531db8f99d7565120d9974c3e263 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8F=B2=E6=82=A6?= Date: Tue, 6 Jan 2026 15:55:32 +0800 Subject: [PATCH] =?UTF-8?q?=E5=8F=96=E6=B6=88=E4=BA=86=E8=BE=93=E5=85=A5?= =?UTF-8?q?=E6=97=B6=E7=9A=84=E9=A2=91=E7=B9=81=E5=90=8C=E6=AD=A5=EF=BC=9A?= =?UTF-8?q?=E7=A7=BB=E9=99=A4=E4=BA=86=E5=AF=B9=20syncToken=20=E5=8F=98?= =?UTF-8?q?=E5=8C=96=E7=9A=84=E8=87=AA=E5=8A=A8=E7=9B=91=E5=90=AC=EF=BC=8C?= =?UTF-8?q?=E5=8F=AA=E5=9C=A8=E6=94=B6=E8=97=8F=E5=88=97=E8=A1=A8=E5=8F=98?= =?UTF-8?q?=E5=8C=96=E6=97=B6=E8=A7=A6=E5=8F=91=E3=80=82=20=E6=94=AF?= =?UTF-8?q?=E6=8C=81=E4=BA=86=E5=88=A0=E9=99=A4=E5=90=8C=E6=AD=A5=EF=BC=9A?= =?UTF-8?q?=E9=80=9A=E8=BF=87=E5=BF=AB=E7=85=A7=E5=AF=B9=E6=AF=94=EF=BC=8C?= =?UTF-8?q?=E6=9C=AC=E5=9C=B0=E5=88=A0=E9=99=A4=E6=93=8D=E4=BD=9C=E5=8F=AF?= =?UTF-8?q?=E4=BB=A5=E6=AD=A3=E7=A1=AE=E5=90=8C=E6=AD=A5=E5=88=B0=E4=BA=91?= =?UTF-8?q?=E7=AB=AF=EF=BC=8C=E8=80=8C=E4=B8=8D=E4=BB=85=E4=BB=85=E6=98=AF?= =?UTF-8?q?=E7=AE=80=E5=8D=95=E7=9A=84=E8=BF=BD=E5=8A=A0=E6=88=96=E8=A6=86?= =?UTF-8?q?=E7=9B=96=E3=80=82=20=E9=98=B2=E6=AD=A2=E4=BA=86=E6=84=8F?= =?UTF-8?q?=E5=A4=96=E8=A6=86=E7=9B=96=EF=BC=9A=E5=90=8C=E6=AD=A5=E5=89=8D?= =?UTF-8?q?=E5=85=88=E6=8B=89=E5=8F=96=E8=BF=9C=E7=A8=8B=E6=95=B0=E6=8D=AE?= =?UTF-8?q?=E8=BF=9B=E8=A1=8C=E5=90=88=E5=B9=B6=EF=BC=8C=E9=81=BF=E5=85=8D?= =?UTF-8?q?=E4=BA=86=E7=9B=B4=E6=8E=A5=E7=94=A8=E6=9C=AC=E5=9C=B0=E6=97=A7?= =?UTF-8?q?=E6=95=B0=E6=8D=AE=E8=A6=86=E7=9B=96=E4=BA=91=E7=AB=AF=E6=96=B0?= =?UTF-8?q?=E6=95=B0=E6=8D=AE=E7=9A=84=E6=83=85=E5=86=B5=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- index.html | 176 +++++++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 145 insertions(+), 31 deletions(-) diff --git a/index.html b/index.html index b2b2b44..21c01f8 100644 --- a/index.html +++ b/index.html @@ -179,6 +179,59 @@ return result; }; + const normalizeDurationSeconds = (value) => { + if (value === null || value === undefined) return 0; + if (typeof value === 'number') { + if (!Number.isFinite(value) || value <= 0) return 0; + return value >= 1000 ? value / 1000 : value; + } + if (typeof value === 'string') { + const trimmed = value.trim(); + if (!trimmed) return 0; + const numeric = Number(trimmed); + if (!Number.isNaN(numeric)) { + return normalizeDurationSeconds(numeric); + } + const parts = trimmed.split(':').map(part => Number(part)); + if (parts.some(part => Number.isNaN(part))) return 0; + if (parts.length === 2) return parts[0] * 60 + parts[1]; + if (parts.length === 3) return parts[0] * 3600 + parts[1] * 60 + parts[2]; + } + return 0; + }; + + const getSongDurationSeconds = (song) => { + if (!song) return 0; + const candidates = [ + song.duration, + song.dt, + song.time, + song.interval, + song.length, + song.playTime + ]; + for (const candidate of candidates) { + const seconds = normalizeDurationSeconds(candidate); + if (seconds > 0) return seconds; + } + return 0; + }; + + const resolveDurationSeconds = (audio, song) => { + if (audio) { + const audioDuration = Number.isFinite(audio.duration) && audio.duration > 0 ? audio.duration : 0; + if (audioDuration > 0) return audioDuration; + if (audio.seekable && audio.seekable.length) { + try { + const end = audio.seekable.end(audio.seekable.length - 1); + if (Number.isFinite(end) && end > 0) return end; + } catch (e) {} + } + } + return getSongDurationSeconds(song); + }; + + // --- API Services --- const api = { search: async (keyword, source = 'netease', page = 1) => { @@ -811,6 +864,9 @@ const [mode, setMode] = useState('loop'); // loop, one, shuffle const [volume, setVolume] = useState(1); const [favorites, setFavorites] = useState(() => JSON.parse(localStorage.getItem('th_favorites')) || []); + // Add lastSyncedFavorites to track the state of favorites at the last successful sync + // This allows us to determine what the user actually changed (added or removed) + const [lastSyncedFavorites, setLastSyncedFavorites] = useState(() => JSON.parse(localStorage.getItem('th_favorites_synced')) || []); const [quality, setQuality] = useState(() => localStorage.getItem('th_quality') || '320k'); const [syncToken, setSyncToken] = useState(() => localStorage.getItem('th_sync_token') || ''); @@ -821,6 +877,12 @@ const audioRef = useRef(new Audio()); + const currentSongRef = useRef(null); + + useEffect(() => { + currentSongRef.current = currentSong; + }, [currentSong]); + // Media Session Refs const playNextRef = useRef(null); const playPrevRef = useRef(null); @@ -831,44 +893,95 @@ useEffect(() => { localStorage.setItem('th_playlist', JSON.stringify(playlist)); }, [playlist]); useEffect(() => { localStorage.setItem('th_current', JSON.stringify(currentSong)); }, [currentSong]); - useEffect(() => { - localStorage.setItem('th_favorites', JSON.stringify(favorites)); - // Auto sync to cloud if token exists - if (syncToken && favorites.length > 0) { - // Debounce could be added here, but for simplicity we just sync - syncService.set('favorites', favorites, syncToken); - } - }, [favorites, syncToken]); + useEffect(() => { localStorage.setItem('th_favorites', JSON.stringify(favorites)); }, [favorites]); + useEffect(() => { localStorage.setItem('th_favorites_synced', JSON.stringify(lastSyncedFavorites)); }, [lastSyncedFavorites]); useEffect(() => { localStorage.setItem('th_quality', quality); }, [quality]); useEffect(() => { localStorage.setItem('th_sync_token', syncToken); }, [syncToken]); - // Initial Sync + // Auto Sync Logic useEffect(() => { - if (syncToken) { - handleSync(); + // Only auto-sync if we have a token and there are actual changes compared to last sync + if (syncToken && JSON.stringify(favorites) !== JSON.stringify(lastSyncedFavorites)) { + // Use a small timeout to avoid rapid-fire syncs if user clicks quickly + const timer = setTimeout(() => { + autoSyncFavorites(); + }, 1000); + return () => clearTimeout(timer); } - }, []); // Run once on mount if token exists + }, [favorites, syncToken, lastSyncedFavorites]); - const handleSync = async () => { + // 1. Auto Sync: Incremental update based on diff + const autoSyncFavorites = async () => { + if (!syncToken) return; + try { + // Get latest remote state + const remoteFavorites = await syncService.get('favorites', syncToken) || []; + + // Calculate Diff: What did the user do locally since last sync? + // Added: In Local but not in LastSynced + const added = favorites.filter(f => !lastSyncedFavorites.find(ls => ls.id === f.id)); + // Removed: In LastSynced but not in Local + const removedIds = lastSyncedFavorites.filter(ls => !favorites.find(f => f.id === ls.id)).map(s => s.id); + + // Apply Diff to Remote + let newRemote = [...remoteFavorites]; + + // Remove deleted songs + if (removedIds.length > 0) { + newRemote = newRemote.filter(r => !removedIds.includes(r.id)); + } + + // Add new songs (avoid duplicates) + added.forEach(song => { + if (!newRemote.find(r => r.id === song.id)) { + newRemote.push(song); + } + }); + + // Push to server + await syncService.set('favorites', newRemote, syncToken); + + // Update states + // We update LastSynced to match the new state + setLastSyncedFavorites(newRemote); + // We also update Favorites to match NewRemote (in case Remote had other changes we just pulled) + setFavorites(newRemote); + + } catch (e) { + console.error("Auto sync failed", e); + } + }; + + // 2. Manual Sync: Merge (Union) + // Used when user clicks "Sync" button or initial load + const manualSyncFavorites = async () => { if (!syncToken) return; - // Sync Favorites + // Pull Remote const cloudFavorites = await syncService.get('favorites', syncToken); - if (cloudFavorites && Array.isArray(cloudFavorites)) { - setFavorites(prev => { - const combined = [...prev]; - cloudFavorites.forEach(cloudSong => { - if (!combined.find(s => s.id === cloudSong.id)) { - combined.push(cloudSong); - } - }); - return combined; - }); - } - // Also push current favorites to cloud to ensure sync - if (favorites.length > 0) { - syncService.set('favorites', favorites, syncToken); + if (cloudFavorites && Array.isArray(cloudFavorites)) { + // Merge Strategy: Union (Local + Remote) + // Since this is a manual sync (often initial), we assume user wants to keep everything + const merged = [...favorites]; + cloudFavorites.forEach(cloudSong => { + if (!merged.find(s => s.id === cloudSong.id)) { + merged.push(cloudSong); + } + }); + + // Update Local + setFavorites(merged); + setLastSyncedFavorites(merged); + + // Push Merged back to server + await syncService.set('favorites', merged, syncToken); + } else { + // If remote is empty/null, push local + if (favorites.length > 0) { + await syncService.set('favorites', favorites, syncToken); + setLastSyncedFavorites(favorites); + } } }; @@ -888,7 +1001,7 @@ const updateTime = () => { setCurrentTime(audio.currentTime); }; - const updateDuration = () => setDuration(audio.duration); + const updateDuration = () => setDuration(resolveDurationSeconds(audio, currentSongRef.current)); const onEnded = () => playNext(true); audio.addEventListener('timeupdate', updateTime); @@ -1036,9 +1149,10 @@ const updateMediaSessionPosition = () => { if (!('mediaSession' in navigator)) return; + if (typeof navigator.mediaSession.setPositionState !== 'function') return; const audio = audioRef.current; if (!audio) return; - const duration = Number.isFinite(audio.duration) && audio.duration > 0 ? audio.duration : 0; + const duration = resolveDurationSeconds(audio, currentSongRef.current); const position = Number.isFinite(audio.currentTime) ? audio.currentTime : 0; const playbackRate = Number.isFinite(audio.playbackRate) ? audio.playbackRate : 1; try { @@ -1207,7 +1321,7 @@ onClearCache={handleClearCache} syncToken={syncToken} setSyncToken={setSyncToken} - onSyncNow={handleSync} + onSyncNow={manualSyncFavorites} /> {/* Top Navigation / Search Bar */}