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) {