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 */}