voice: add global playback speed control for audio and voice
All checks were successful
CI / test (push) Successful in 25s
All checks were successful
CI / test (push) Successful in 25s
This commit is contained in:
@@ -23,7 +23,7 @@ Legend:
|
|||||||
14. Delivery Status - `DONE` (sent/delivered/read + reconnect reconciliation after backend restarts)
|
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)
|
15. Typing Realtime - `PARTIAL` (typing start/stop done; voice/video typing signals limited)
|
||||||
16. Media & Attachments - `DONE` (upload/preview/download/gallery)
|
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)
|
18. Circle Video Messages - `PARTIAL` (send/play present, recording UX basic)
|
||||||
19. Stickers - `PARTIAL` (web sticker picker with preset pack + favorites)
|
19. Stickers - `PARTIAL` (web sticker picker with preset pack + favorites)
|
||||||
20. GIF - `PARTIAL` (web GIF picker with Tenor search + preset fallback + favorites)
|
20. GIF - `PARTIAL` (web GIF picker with Tenor search + preset fallback + favorites)
|
||||||
|
|||||||
@@ -1340,6 +1340,8 @@ function AudioInlinePlayer({ src, title }: { src: string; title: string }) {
|
|||||||
const playTrack = useAudioPlayerStore((s) => s.playTrack);
|
const playTrack = useAudioPlayerStore((s) => s.playTrack);
|
||||||
const togglePlayGlobal = useAudioPlayerStore((s) => s.togglePlay);
|
const togglePlayGlobal = useAudioPlayerStore((s) => s.togglePlay);
|
||||||
const seekToGlobal = useAudioPlayerStore((s) => s.seekTo);
|
const seekToGlobal = useAudioPlayerStore((s) => s.seekTo);
|
||||||
|
const playbackRate = useAudioPlayerStore((s) => s.playbackRate);
|
||||||
|
const cyclePlaybackRate = useAudioPlayerStore((s) => s.cyclePlaybackRate);
|
||||||
|
|
||||||
const isActiveTrack = track?.src === src;
|
const isActiveTrack = track?.src === src;
|
||||||
const isPlaying = isActiveTrack && isPlayingGlobal;
|
const isPlaying = isActiveTrack && isPlayingGlobal;
|
||||||
@@ -1391,6 +1393,13 @@ function AudioInlinePlayer({ src, title }: { src: string; title: string }) {
|
|||||||
<span className="inline-flex h-6 min-w-6 items-center justify-center rounded-full bg-slate-800 px-1 text-[10px] text-slate-300">
|
<span className="inline-flex h-6 min-w-6 items-center justify-center rounded-full bg-slate-800 px-1 text-[10px] text-slate-300">
|
||||||
♪
|
♪
|
||||||
</span>
|
</span>
|
||||||
|
<button
|
||||||
|
className="inline-flex h-6 min-w-8 items-center justify-center rounded-full bg-slate-800 px-1 text-[10px] text-slate-300 hover:bg-slate-700"
|
||||||
|
onClick={() => cyclePlaybackRate()}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{playbackRate}x
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -1412,6 +1421,8 @@ function VoiceInlinePlayer({
|
|||||||
const playTrack = useAudioPlayerStore((s) => s.playTrack);
|
const playTrack = useAudioPlayerStore((s) => s.playTrack);
|
||||||
const togglePlayGlobal = useAudioPlayerStore((s) => s.togglePlay);
|
const togglePlayGlobal = useAudioPlayerStore((s) => s.togglePlay);
|
||||||
const seekToGlobal = useAudioPlayerStore((s) => s.seekTo);
|
const seekToGlobal = useAudioPlayerStore((s) => s.seekTo);
|
||||||
|
const playbackRate = useAudioPlayerStore((s) => s.playbackRate);
|
||||||
|
const cyclePlaybackRate = useAudioPlayerStore((s) => s.cyclePlaybackRate);
|
||||||
|
|
||||||
const isActiveTrack = track?.src === src;
|
const isActiveTrack = track?.src === src;
|
||||||
const isPlaying = isActiveTrack && isPlayingGlobal;
|
const isPlaying = isActiveTrack && isPlayingGlobal;
|
||||||
@@ -1461,6 +1472,13 @@ function VoiceInlinePlayer({
|
|||||||
<span className="w-10 text-right text-[11px] tabular-nums text-slate-200">
|
<span className="w-10 text-right text-[11px] tabular-nums text-slate-200">
|
||||||
{formatAudioTime(duration > 0 ? duration : position)}
|
{formatAudioTime(duration > 0 ? duration : position)}
|
||||||
</span>
|
</span>
|
||||||
|
<button
|
||||||
|
className="inline-flex h-6 min-w-8 items-center justify-center rounded-full bg-slate-800 px-1 text-[10px] text-slate-300 hover:bg-slate-700"
|
||||||
|
onClick={() => cyclePlaybackRate()}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{playbackRate}x
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ interface AudioPlayerState {
|
|||||||
audioEl: HTMLAudioElement | null;
|
audioEl: HTMLAudioElement | null;
|
||||||
isPlaying: boolean;
|
isPlaying: boolean;
|
||||||
volume: number;
|
volume: number;
|
||||||
|
playbackRate: number;
|
||||||
duration: number;
|
duration: number;
|
||||||
position: number;
|
position: number;
|
||||||
playTrack: (track: ActiveTrack) => Promise<void>;
|
playTrack: (track: ActiveTrack) => Promise<void>;
|
||||||
@@ -17,11 +18,25 @@ interface AudioPlayerState {
|
|||||||
seekTo: (seconds: number) => void;
|
seekTo: (seconds: number) => void;
|
||||||
seekBy: (secondsDelta: number) => void;
|
seekBy: (secondsDelta: number) => void;
|
||||||
setVolume: (volume: number) => void;
|
setVolume: (volume: number) => void;
|
||||||
|
setPlaybackRate: (rate: number) => void;
|
||||||
|
cyclePlaybackRate: () => void;
|
||||||
stop: () => void;
|
stop: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
let globalAudioEl: HTMLAudioElement | null = null;
|
let globalAudioEl: HTMLAudioElement | null = null;
|
||||||
let isBound = false;
|
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 {
|
function ensureAudio(): HTMLAudioElement {
|
||||||
if (globalAudioEl) {
|
if (globalAudioEl) {
|
||||||
@@ -37,6 +52,7 @@ export const useAudioPlayerStore = create<AudioPlayerState>((set, get) => ({
|
|||||||
audioEl: ensureAudio(),
|
audioEl: ensureAudio(),
|
||||||
isPlaying: false,
|
isPlaying: false,
|
||||||
volume: 1,
|
volume: 1,
|
||||||
|
playbackRate: getInitialPlaybackRate(),
|
||||||
duration: 0,
|
duration: 0,
|
||||||
position: 0,
|
position: 0,
|
||||||
playTrack: async (track) => {
|
playTrack: async (track) => {
|
||||||
@@ -63,6 +79,7 @@ export const useAudioPlayerStore = create<AudioPlayerState>((set, get) => ({
|
|||||||
set({ track });
|
set({ track });
|
||||||
}
|
}
|
||||||
audio.volume = get().volume;
|
audio.volume = get().volume;
|
||||||
|
audio.playbackRate = get().playbackRate;
|
||||||
try {
|
try {
|
||||||
await audio.play();
|
await audio.play();
|
||||||
set({ isPlaying: true, audioEl: audio });
|
set({ isPlaying: true, audioEl: audio });
|
||||||
@@ -111,6 +128,20 @@ export const useAudioPlayerStore = create<AudioPlayerState>((set, get) => ({
|
|||||||
audio.volume = normalized;
|
audio.volume = normalized;
|
||||||
set({ 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: () => {
|
stop: () => {
|
||||||
const audio = ensureAudio();
|
const audio = ensureAudio();
|
||||||
if (audio) {
|
if (audio) {
|
||||||
|
|||||||
Reference in New Issue
Block a user