feat: 添加网易云音乐同步到Navidrome的功能

新增NetEase-sync模块,实现将网易云音乐歌单同步到Navidrome的功能
修复iOS设备自动播放问题,优化播放器体验
This commit is contained in:
史悦
2026-01-12 17:59:31 +08:00
parent 4ea05279bd
commit 89a28e1bc5
7 changed files with 1196 additions and 22 deletions

View File

@@ -148,6 +148,14 @@
{ id: 'qq', name: 'QQ音乐' },
{ id: 'kugou', name: '酷狗' }
];
const IS_IOS = (() => {
if (typeof navigator === 'undefined') return false;
const ua = navigator.userAgent || '';
const platform = navigator.platform || '';
const iOSUA = /iPad|iPhone|iPod/.test(ua);
const iPadOS = platform === 'MacIntel' && navigator.maxTouchPoints > 1;
return iOSUA || iPadOS;
})();
// --- Utility Functions ---
const formatTime = (seconds) => {
@@ -945,6 +953,7 @@
const audioRef = useRef(null);
const autoAdvanceLockRef = useRef(false);
const autoNextPendingRef = useRef(false);
const currentSongRef = useRef(null);
useEffect(() => {
@@ -955,6 +964,33 @@
autoAdvanceLockRef.current = false;
}, [currentSong]);
const playAudioWithFallback = (audio, options = {}) => {
if (!audio) return;
const { deferOnIOS = false } = options;
const doPlay = () => {
const playPromise = audio.play();
if (playPromise && typeof playPromise.catch === 'function') {
playPromise.catch(e => {
console.warn("Auto-play prevented:", e);
if (e && e.name === 'AbortError') return;
setIsPlaying(false);
});
}
};
if (deferOnIOS && IS_IOS) {
const onCanPlay = () => {
audio.removeEventListener('canplay', onCanPlay);
doPlay();
};
audio.addEventListener('canplay', onCanPlay);
try { audio.load(); } catch (e) {}
return;
}
doPlay();
};
// Media Session Refs
const playNextRef = useRef(null);
const playPrevRef = useRef(null);
@@ -1185,30 +1221,39 @@
const triggerAutoNext = () => {
if (autoAdvanceLockRef.current) return;
autoAdvanceLockRef.current = true;
playNext(true, { immediate: true });
playNext(true, { immediate: !IS_IOS, deferOnIOS: IS_IOS });
};
const isNearEnd = () => {
const durationSeconds = resolveDurationSeconds(audio, currentSongRef.current);
if (!Number.isFinite(durationSeconds) || durationSeconds <= 0) return false;
const threshold = IS_IOS ? 0.15 : 0.35;
return audio.currentTime >= durationSeconds - threshold;
};
const updateTime = () => {
setCurrentTime(audio.currentTime);
if (autoAdvanceLockRef.current) return;
const durationSeconds = resolveDurationSeconds(audio, currentSongRef.current);
if (Number.isFinite(durationSeconds) && durationSeconds > 0) {
if (audio.currentTime >= durationSeconds - 0.35) {
triggerAutoNext();
}
}
if (!IS_IOS && isNearEnd()) triggerAutoNext();
};
const updateDuration = () => setDuration(resolveDurationSeconds(audio, currentSongRef.current));
const onEnded = () => triggerAutoNext();
const onPause = () => {
if (!IS_IOS) return;
if (autoAdvanceLockRef.current) return;
if (isNearEnd()) triggerAutoNext();
};
audio.addEventListener('timeupdate', updateTime);
audio.addEventListener('loadedmetadata', updateDuration);
audio.addEventListener('ended', onEnded);
audio.addEventListener('pause', onPause);
return () => {
audio.removeEventListener('timeupdate', updateTime);
audio.removeEventListener('loadedmetadata', updateDuration);
audio.removeEventListener('ended', onEnded);
audio.removeEventListener('pause', onPause);
};
}, [playlist, currentSong, mode, volume, quality]);
@@ -1222,20 +1267,21 @@
// Only update src if it's different to avoid reloading same song on re-render (unless quality changed)
// Note: audioRef.current.src returns full absolute URL
const currentSrc = audio.src;
const wasPlaying = isPlaying;
const deferOnIOS = autoNextPendingRef.current;
autoNextPendingRef.current = false;
// Simple check if src changed significantly (avoiding minor encoding diffs if possible, but exact match is safer)
if (currentSrc !== url) {
const wasPlaying = isPlaying;
audio.src = url;
if (wasPlaying) {
audio.play()
.then(() => setIsPlaying(true))
.catch(e => {
console.warn("Auto-play prevented:", e);
setIsPlaying(false);
});
playAudioWithFallback(audio, { deferOnIOS });
}
} else if (deferOnIOS && wasPlaying) {
playAudioWithFallback(audio, { deferOnIOS: true });
}
} else {
autoNextPendingRef.current = false;
}
}, [currentSong, quality]); // Re-run when quality changes
@@ -1243,7 +1289,7 @@
if (currentSong) {
const audio = audioRef.current;
if (!audio) return;
if (isPlaying) audio.play().catch(() => setIsPlaying(false));
if (isPlaying) playAudioWithFallback(audio);
else audio.pause();
}
}, [isPlaying]);
@@ -1325,11 +1371,15 @@
const audio = audioRef.current;
if (!audio) return;
audio.currentTime = 0;
audio.play().catch(() => setIsPlaying(false));
playAudioWithFallback(audio);
autoAdvanceLockRef.current = false;
return;
}
if (auto) {
autoNextPendingRef.current = true;
}
let nextIdx;
const currIdx = playlist.findIndex(s => s.id === currentSong?.id);
@@ -1350,12 +1400,7 @@
if (audio.src !== url) {
audio.src = url;
}
audio.play()
.then(() => setIsPlaying(true))
.catch(e => {
console.warn("Auto-play prevented:", e);
setIsPlaying(false);
});
playAudioWithFallback(audio, { deferOnIOS: options.deferOnIOS });
}
};