fix(audio,sessions): unify audio playback state and improve session discovery
Some checks failed
CI / test (push) Failing after 18s

- move voice/audio players to single global audio engine with shared volume

- stop/reset previous track when switching to another media

- keep playback alive across chat switches via global audio element

- list refresh sessions by redis scan fallback when user session set is missing
This commit is contained in:
2026-03-08 11:48:13 +03:00
parent 27d3340a37
commit 897defc39d
3 changed files with 122 additions and 99 deletions

View File

@@ -105,6 +105,26 @@ async def list_refresh_sessions_for_user(*, user_id: int) -> list[RefreshSession
try: try:
redis = get_redis_client() redis = get_redis_client()
session_ids = await redis.smembers(f"auth:user_refresh:{user_id}") session_ids = await redis.smembers(f"auth:user_refresh:{user_id}")
if not session_ids:
cursor = 0
discovered: list[str] = []
while True:
cursor, keys = await redis.scan(cursor=cursor, match="auth:refresh:*", count=200)
for raw_key in keys:
key = raw_key.decode("utf-8") if isinstance(raw_key, bytes) else str(raw_key)
if not key.startswith("auth:refresh:"):
continue
jti = key.removeprefix("auth:refresh:")
if not jti:
continue
owner = await redis.get(key)
if owner and str(owner).isdigit() and int(owner) == user_id:
discovered.append(jti)
if cursor == 0:
break
if discovered:
await redis.sadd(f"auth:user_refresh:{user_id}", *discovered)
session_ids = {item.encode("utf-8") for item in discovered}
sessions: list[RefreshSession] = [] sessions: list[RefreshSession] = []
stale: list[str] = [] stale: list[str] = []
for raw_jti in session_ids: for raw_jti in session_ids:

View File

@@ -956,88 +956,43 @@ async function downloadFileFromUrl(url: string): Promise<void> {
} }
function AudioInlinePlayer({ src, title }: { src: string; title: string }) { function AudioInlinePlayer({ src, title }: { src: string; title: string }) {
const audioRef = useRef<HTMLAudioElement | null>(null); const track = useAudioPlayerStore((s) => s.track);
const activate = useAudioPlayerStore((s) => s.activate); const isPlayingGlobal = useAudioPlayerStore((s) => s.isPlaying);
const detach = useAudioPlayerStore((s) => s.detach); const durationGlobal = useAudioPlayerStore((s) => s.duration);
const setPlayingState = useAudioPlayerStore((s) => s.setPlaying); const positionGlobal = useAudioPlayerStore((s) => s.position);
const setVolumeState = useAudioPlayerStore((s) => s.setVolume); const volumeGlobal = useAudioPlayerStore((s) => s.volume);
const [isPlaying, setIsPlaying] = useState(false); const playTrack = useAudioPlayerStore((s) => s.playTrack);
const [duration, setDuration] = useState(0); const togglePlayGlobal = useAudioPlayerStore((s) => s.togglePlay);
const [position, setPosition] = useState(0); const seekToGlobal = useAudioPlayerStore((s) => s.seekTo);
const [volume, setVolume] = useState(1); const setVolumeGlobal = useAudioPlayerStore((s) => s.setVolume);
useEffect(() => { const isActiveTrack = track?.src === src;
const audio = audioRef.current; const isPlaying = isActiveTrack && isPlayingGlobal;
if (!audio) return; const duration = isActiveTrack ? durationGlobal : 0;
const position = isActiveTrack ? positionGlobal : 0;
const onLoaded = () => { const volume = volumeGlobal;
setDuration(Number.isFinite(audio.duration) ? audio.duration : 0);
};
const onTime = () => {
setPosition(audio.currentTime || 0);
};
const onEnded = () => {
setIsPlaying(false);
setPlayingState(audio, false);
};
const onPlay = () => {
activate(audio, { src, title });
setPlayingState(audio, true);
};
const onPause = () => {
setPlayingState(audio, false);
};
audio.addEventListener("loadedmetadata", onLoaded);
audio.addEventListener("timeupdate", onTime);
audio.addEventListener("ended", onEnded);
audio.addEventListener("play", onPlay);
audio.addEventListener("pause", onPause);
return () => {
audio.removeEventListener("loadedmetadata", onLoaded);
audio.removeEventListener("timeupdate", onTime);
audio.removeEventListener("ended", onEnded);
audio.removeEventListener("play", onPlay);
audio.removeEventListener("pause", onPause);
detach(audio);
};
}, [activate, detach, setPlayingState, src, title]);
async function togglePlay() { async function togglePlay() {
const audio = audioRef.current; if (isActiveTrack) {
if (!audio) return; await togglePlayGlobal();
if (isPlaying) {
audio.pause();
setIsPlaying(false);
return; return;
} }
try { await playTrack({ src, title });
activate(audio, { src, title });
await audio.play();
setIsPlaying(true);
} catch {
setIsPlaying(false);
}
} }
function onSeek(nextValue: number) { function onSeek(nextValue: number) {
const audio = audioRef.current; if (!isActiveTrack) {
if (!audio) return; return;
audio.currentTime = nextValue; }
setPosition(nextValue); seekToGlobal(nextValue);
} }
function onVolume(nextValue: number) { function onVolume(nextValue: number) {
const audio = audioRef.current; setVolumeGlobal(nextValue);
if (!audio) return;
audio.volume = nextValue;
setVolume(nextValue);
setVolumeState(audio, nextValue);
} }
return ( return (
<div className="rounded-lg border border-sky-500/40 bg-sky-600/20 px-2 py-1.5"> <div className="rounded-lg border border-sky-500/40 bg-sky-600/20 px-2 py-1.5">
<audio ref={audioRef} preload="metadata" src={src} />
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<button <button
className="inline-flex h-7 w-7 items-center justify-center rounded-full bg-slate-900/70 text-xs text-white hover:bg-slate-900" className="inline-flex h-7 w-7 items-center justify-center rounded-full bg-slate-900/70 text-xs text-white hover:bg-slate-900"

View File

@@ -10,48 +10,71 @@ interface AudioPlayerState {
audioEl: HTMLAudioElement | null; audioEl: HTMLAudioElement | null;
isPlaying: boolean; isPlaying: boolean;
volume: number; volume: number;
activate: (audioEl: HTMLAudioElement, track: ActiveTrack) => void; duration: number;
detach: (audioEl: HTMLAudioElement) => void; position: number;
setPlaying: (audioEl: HTMLAudioElement, isPlaying: boolean) => void; playTrack: (track: ActiveTrack) => Promise<void>;
setVolume: (audioEl: HTMLAudioElement, volume: number) => void;
togglePlay: () => Promise<void>; togglePlay: () => Promise<void>;
seekTo: (seconds: number) => void;
seekBy: (secondsDelta: number) => void;
setVolume: (volume: number) => void;
stop: () => void; stop: () => void;
} }
let globalAudioEl: HTMLAudioElement | null = null;
let isBound = false;
function ensureAudio(): HTMLAudioElement {
if (globalAudioEl) {
return globalAudioEl;
}
globalAudioEl = new Audio();
globalAudioEl.preload = "metadata";
return globalAudioEl;
}
export const useAudioPlayerStore = create<AudioPlayerState>((set, get) => ({ export const useAudioPlayerStore = create<AudioPlayerState>((set, get) => ({
track: null, track: null,
audioEl: null, audioEl: ensureAudio(),
isPlaying: false, isPlaying: false,
volume: 1, volume: 1,
activate: (audioEl, track) => { duration: 0,
set({ position: 0,
audioEl, playTrack: async (track) => {
track, const audio = ensureAudio();
isPlaying: !audioEl.paused, if (!isBound) {
volume: audioEl.volume ?? 1, audio.addEventListener("timeupdate", () => {
set({ position: audio.currentTime || 0 });
}); });
}, audio.addEventListener("loadedmetadata", () => {
detach: (audioEl) => { set({ duration: Number.isFinite(audio.duration) ? audio.duration : 0 });
const current = get().audioEl; });
if (current !== audioEl) { audio.addEventListener("play", () => set({ isPlaying: true }));
return; audio.addEventListener("pause", () => set({ isPlaying: false }));
audio.addEventListener("ended", () => set({ isPlaying: false, position: 0 }));
isBound = true;
} }
set({ audioEl: null, track: null, isPlaying: false }); const current = get().track;
}, if (current?.src !== track.src) {
setPlaying: (audioEl, isPlaying) => { audio.pause();
if (get().audioEl !== audioEl) { audio.currentTime = 0;
return; audio.src = track.src;
set({ track, position: 0, duration: 0 });
} else {
set({ track });
} }
set({ isPlaying }); audio.volume = get().volume;
}, try {
setVolume: (audioEl, volume) => { await audio.play();
if (get().audioEl !== audioEl) { set({ isPlaying: true, audioEl: audio });
return; } catch {
set({ isPlaying: false, audioEl: audio });
} }
set({ volume });
}, },
togglePlay: async () => { togglePlay: async () => {
const audio = get().audioEl; const audio = ensureAudio();
if (!audio.src) {
return;
}
if (!audio) { if (!audio) {
return; return;
} }
@@ -63,12 +86,37 @@ export const useAudioPlayerStore = create<AudioPlayerState>((set, get) => ({
audio.pause(); audio.pause();
set({ isPlaying: false }); set({ isPlaying: false });
}, },
seekTo: (seconds) => {
const audio = ensureAudio();
if (!audio.src) {
return;
}
const target = Math.max(0, Math.min(Number.isFinite(audio.duration) ? audio.duration : seconds, seconds));
audio.currentTime = target;
set({ position: target });
},
seekBy: (secondsDelta) => {
const audio = ensureAudio();
if (!audio.src) {
return;
}
const duration = Number.isFinite(audio.duration) ? audio.duration : Number.MAX_SAFE_INTEGER;
const target = Math.max(0, Math.min(duration, (audio.currentTime || 0) + secondsDelta));
audio.currentTime = target;
set({ position: target });
},
setVolume: (volume) => {
const audio = ensureAudio();
const normalized = Math.max(0, Math.min(1, volume));
audio.volume = normalized;
set({ volume: normalized });
},
stop: () => { stop: () => {
const audio = get().audioEl; const audio = ensureAudio();
if (audio) { if (audio) {
audio.pause(); audio.pause();
audio.currentTime = 0;
} }
set({ audioEl: null, track: null, isPlaying: false }); set({ audioEl: audio, track: null, isPlaying: false, position: 0, duration: 0 });
}, },
})); }));