From 4d9b64973de31b8d08542cd6af386a1ba9a97574 Mon Sep 17 00:00:00 2001 From: benya Date: Sun, 8 Mar 2026 18:51:12 +0300 Subject: [PATCH] voice: add global playback speed control for audio and voice --- docs/core-checklist-status.md | 2 +- web/src/components/MessageList.tsx | 18 +++++++++++++++++ web/src/store/audioPlayerStore.ts | 31 ++++++++++++++++++++++++++++++ 3 files changed, 50 insertions(+), 1 deletion(-) diff --git a/docs/core-checklist-status.md b/docs/core-checklist-status.md index f919181..9998c6b 100644 --- a/docs/core-checklist-status.md +++ b/docs/core-checklist-status.md @@ -23,7 +23,7 @@ Legend: 14. Delivery Status - `DONE` (sent/delivered/read + reconnect reconciliation after backend restarts) 15. Typing Realtime - `PARTIAL` (typing start/stop done; voice/video typing signals limited) 16. Media & Attachments - `DONE` (upload/preview/download/gallery) -17. Voice Messages - `PARTIAL` (record/send/play/seek/speed; UX still being polished) +17. Voice Messages - `PARTIAL` (record/send/play/seek + global speed 1x/1.5x/2x; UX still being polished) 18. Circle Video Messages - `PARTIAL` (send/play present, recording UX basic) 19. Stickers - `PARTIAL` (web sticker picker with preset pack + favorites) 20. GIF - `PARTIAL` (web GIF picker with Tenor search + preset fallback + favorites) diff --git a/web/src/components/MessageList.tsx b/web/src/components/MessageList.tsx index 5aeeeb2..df3d160 100644 --- a/web/src/components/MessageList.tsx +++ b/web/src/components/MessageList.tsx @@ -1340,6 +1340,8 @@ function AudioInlinePlayer({ src, title }: { src: string; title: string }) { const playTrack = useAudioPlayerStore((s) => s.playTrack); const togglePlayGlobal = useAudioPlayerStore((s) => s.togglePlay); const seekToGlobal = useAudioPlayerStore((s) => s.seekTo); + const playbackRate = useAudioPlayerStore((s) => s.playbackRate); + const cyclePlaybackRate = useAudioPlayerStore((s) => s.cyclePlaybackRate); const isActiveTrack = track?.src === src; const isPlaying = isActiveTrack && isPlayingGlobal; @@ -1391,6 +1393,13 @@ function AudioInlinePlayer({ src, title }: { src: string; title: string }) { + ); @@ -1412,6 +1421,8 @@ function VoiceInlinePlayer({ const playTrack = useAudioPlayerStore((s) => s.playTrack); const togglePlayGlobal = useAudioPlayerStore((s) => s.togglePlay); const seekToGlobal = useAudioPlayerStore((s) => s.seekTo); + const playbackRate = useAudioPlayerStore((s) => s.playbackRate); + const cyclePlaybackRate = useAudioPlayerStore((s) => s.cyclePlaybackRate); const isActiveTrack = track?.src === src; const isPlaying = isActiveTrack && isPlayingGlobal; @@ -1461,6 +1472,13 @@ function VoiceInlinePlayer({ {formatAudioTime(duration > 0 ? duration : position)} + ); } diff --git a/web/src/store/audioPlayerStore.ts b/web/src/store/audioPlayerStore.ts index 3eb2d74..79575e0 100644 --- a/web/src/store/audioPlayerStore.ts +++ b/web/src/store/audioPlayerStore.ts @@ -10,6 +10,7 @@ interface AudioPlayerState { audioEl: HTMLAudioElement | null; isPlaying: boolean; volume: number; + playbackRate: number; duration: number; position: number; playTrack: (track: ActiveTrack) => Promise; @@ -17,11 +18,25 @@ interface AudioPlayerState { seekTo: (seconds: number) => void; seekBy: (secondsDelta: number) => void; setVolume: (volume: number) => void; + setPlaybackRate: (rate: number) => void; + cyclePlaybackRate: () => void; stop: () => void; } let globalAudioEl: HTMLAudioElement | null = null; let isBound = false; +const PLAYBACK_RATE_KEY = "bm_audio_playback_rate"; + +function getInitialPlaybackRate(): number { + if (typeof window === "undefined") { + return 1; + } + const raw = Number(window.localStorage.getItem(PLAYBACK_RATE_KEY) || "1"); + if (raw === 1 || raw === 1.5 || raw === 2) { + return raw; + } + return 1; +} function ensureAudio(): HTMLAudioElement { if (globalAudioEl) { @@ -37,6 +52,7 @@ export const useAudioPlayerStore = create((set, get) => ({ audioEl: ensureAudio(), isPlaying: false, volume: 1, + playbackRate: getInitialPlaybackRate(), duration: 0, position: 0, playTrack: async (track) => { @@ -63,6 +79,7 @@ export const useAudioPlayerStore = create((set, get) => ({ set({ track }); } audio.volume = get().volume; + audio.playbackRate = get().playbackRate; try { await audio.play(); set({ isPlaying: true, audioEl: audio }); @@ -111,6 +128,20 @@ export const useAudioPlayerStore = create((set, get) => ({ audio.volume = normalized; set({ volume: normalized }); }, + setPlaybackRate: (rate) => { + const normalized = rate === 2 ? 2 : rate === 1.5 ? 1.5 : 1; + const audio = ensureAudio(); + audio.playbackRate = normalized; + if (typeof window !== "undefined") { + window.localStorage.setItem(PLAYBACK_RATE_KEY, String(normalized)); + } + set({ playbackRate: normalized }); + }, + cyclePlaybackRate: () => { + const current = get().playbackRate; + const next = current === 1 ? 1.5 : current === 1.5 ? 2 : 1; + get().setPlaybackRate(next); + }, stop: () => { const audio = ensureAudio(); if (audio) {