取消了输入时的频繁同步:移除了对 syncToken 变化的自动监听,只在收藏列表变化时触发。
支持了删除同步:通过快照对比,本地删除操作可以正确同步到云端,而不仅仅是简单的追加或覆盖。 防止了意外覆盖:同步前先拉取远程数据进行合并,避免了直接用本地旧数据覆盖云端新数据的情况。
This commit is contained in:
176
index.html
176
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 */}
|
||||
|
||||
Reference in New Issue
Block a user