取消了输入时的频繁同步:移除了对 syncToken 变化的自动监听,只在收藏列表变化时触发。
支持了删除同步:通过快照对比,本地删除操作可以正确同步到云端,而不仅仅是简单的追加或覆盖。 防止了意外覆盖:同步前先拉取远程数据进行合并,避免了直接用本地旧数据覆盖云端新数据的情况。
This commit is contained in:
176
index.html
176
index.html
@@ -179,6 +179,59 @@
|
|||||||
return result;
|
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 ---
|
// --- API Services ---
|
||||||
const api = {
|
const api = {
|
||||||
search: async (keyword, source = 'netease', page = 1) => {
|
search: async (keyword, source = 'netease', page = 1) => {
|
||||||
@@ -811,6 +864,9 @@
|
|||||||
const [mode, setMode] = useState('loop'); // loop, one, shuffle
|
const [mode, setMode] = useState('loop'); // loop, one, shuffle
|
||||||
const [volume, setVolume] = useState(1);
|
const [volume, setVolume] = useState(1);
|
||||||
const [favorites, setFavorites] = useState(() => JSON.parse(localStorage.getItem('th_favorites')) || []);
|
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 [quality, setQuality] = useState(() => localStorage.getItem('th_quality') || '320k');
|
||||||
const [syncToken, setSyncToken] = useState(() => localStorage.getItem('th_sync_token') || '');
|
const [syncToken, setSyncToken] = useState(() => localStorage.getItem('th_sync_token') || '');
|
||||||
|
|
||||||
@@ -821,6 +877,12 @@
|
|||||||
|
|
||||||
const audioRef = useRef(new Audio());
|
const audioRef = useRef(new Audio());
|
||||||
|
|
||||||
|
const currentSongRef = useRef(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
currentSongRef.current = currentSong;
|
||||||
|
}, [currentSong]);
|
||||||
|
|
||||||
// Media Session Refs
|
// Media Session Refs
|
||||||
const playNextRef = useRef(null);
|
const playNextRef = useRef(null);
|
||||||
const playPrevRef = useRef(null);
|
const playPrevRef = useRef(null);
|
||||||
@@ -831,44 +893,95 @@
|
|||||||
|
|
||||||
useEffect(() => { localStorage.setItem('th_playlist', JSON.stringify(playlist)); }, [playlist]);
|
useEffect(() => { localStorage.setItem('th_playlist', JSON.stringify(playlist)); }, [playlist]);
|
||||||
useEffect(() => { localStorage.setItem('th_current', JSON.stringify(currentSong)); }, [currentSong]);
|
useEffect(() => { localStorage.setItem('th_current', JSON.stringify(currentSong)); }, [currentSong]);
|
||||||
useEffect(() => {
|
useEffect(() => { localStorage.setItem('th_favorites', JSON.stringify(favorites)); }, [favorites]);
|
||||||
localStorage.setItem('th_favorites', JSON.stringify(favorites));
|
useEffect(() => { localStorage.setItem('th_favorites_synced', JSON.stringify(lastSyncedFavorites)); }, [lastSyncedFavorites]);
|
||||||
// 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_quality', quality); }, [quality]);
|
useEffect(() => { localStorage.setItem('th_quality', quality); }, [quality]);
|
||||||
useEffect(() => { localStorage.setItem('th_sync_token', syncToken); }, [syncToken]);
|
useEffect(() => { localStorage.setItem('th_sync_token', syncToken); }, [syncToken]);
|
||||||
|
|
||||||
// Initial Sync
|
// Auto Sync Logic
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (syncToken) {
|
// Only auto-sync if we have a token and there are actual changes compared to last sync
|
||||||
handleSync();
|
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;
|
if (!syncToken) return;
|
||||||
|
|
||||||
// Sync Favorites
|
// Pull Remote
|
||||||
const cloudFavorites = await syncService.get('favorites', syncToken);
|
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 (cloudFavorites && Array.isArray(cloudFavorites)) {
|
||||||
if (favorites.length > 0) {
|
// Merge Strategy: Union (Local + Remote)
|
||||||
syncService.set('favorites', favorites, syncToken);
|
// 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 = () => {
|
const updateTime = () => {
|
||||||
setCurrentTime(audio.currentTime);
|
setCurrentTime(audio.currentTime);
|
||||||
};
|
};
|
||||||
const updateDuration = () => setDuration(audio.duration);
|
const updateDuration = () => setDuration(resolveDurationSeconds(audio, currentSongRef.current));
|
||||||
const onEnded = () => playNext(true);
|
const onEnded = () => playNext(true);
|
||||||
|
|
||||||
audio.addEventListener('timeupdate', updateTime);
|
audio.addEventListener('timeupdate', updateTime);
|
||||||
@@ -1036,9 +1149,10 @@
|
|||||||
|
|
||||||
const updateMediaSessionPosition = () => {
|
const updateMediaSessionPosition = () => {
|
||||||
if (!('mediaSession' in navigator)) return;
|
if (!('mediaSession' in navigator)) return;
|
||||||
|
if (typeof navigator.mediaSession.setPositionState !== 'function') return;
|
||||||
const audio = audioRef.current;
|
const audio = audioRef.current;
|
||||||
if (!audio) return;
|
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 position = Number.isFinite(audio.currentTime) ? audio.currentTime : 0;
|
||||||
const playbackRate = Number.isFinite(audio.playbackRate) ? audio.playbackRate : 1;
|
const playbackRate = Number.isFinite(audio.playbackRate) ? audio.playbackRate : 1;
|
||||||
try {
|
try {
|
||||||
@@ -1207,7 +1321,7 @@
|
|||||||
onClearCache={handleClearCache}
|
onClearCache={handleClearCache}
|
||||||
syncToken={syncToken}
|
syncToken={syncToken}
|
||||||
setSyncToken={setSyncToken}
|
setSyncToken={setSyncToken}
|
||||||
onSyncNow={handleSync}
|
onSyncNow={manualSyncFavorites}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Top Navigation / Search Bar */}
|
{/* Top Navigation / Search Bar */}
|
||||||
|
|||||||
Reference in New Issue
Block a user