取消了输入时的频繁同步:移除了对 syncToken 变化的自动监听,只在收藏列表变化时触发。

支持了删除同步:通过快照对比,本地删除操作可以正确同步到云端,而不仅仅是简单的追加或覆盖。
防止了意外覆盖:同步前先拉取远程数据进行合并,避免了直接用本地旧数据覆盖云端新数据的情况。
This commit is contained in:
史悦
2026-01-06 15:55:32 +08:00
parent 1af86ed6a6
commit 9f1d52c09d

View File

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