Skip to content

Commit 8c08a04

Browse files
committed
feat: Add a "play from start" button and improve media playback reliability by handling pending plays and unmount cleanup.
1 parent 8684ea2 commit 8c08a04

3 files changed

Lines changed: 102 additions & 17 deletions

File tree

src/components/controls/CombinedControls.tsx

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import {
2121
ListMusic,
2222
X,
2323
Mic,
24+
RotateCcw,
2425
} from "lucide-react";
2526
import { useShadowingStore } from "../../stores/shadowingStore";
2627
import { useShadowingRecorder } from "../../hooks/useShadowingRecorder";
@@ -378,6 +379,18 @@ export const CombinedControls = () => {
378379
</div>
379380

380381
<div className="flex items-center space-x-2 sm:space-x-4">
382+
<button
383+
onClick={() => {
384+
setCurrentTime(0);
385+
if (!isPlaying) setIsPlaying(true);
386+
}}
387+
className="p-2.5 rounded-full hover:bg-gray-200 dark:hover:bg-gray-700 text-gray-600 dark:text-gray-300 transition-colors"
388+
aria-label={t("player.playFromStart", { defaultValue: "Play from start" })}
389+
title={t("player.playFromStart", { defaultValue: "Play from start" })}
390+
>
391+
<RotateCcw size={16} className="sm:w-[18px] sm:h-[18px]" />
392+
</button>
393+
381394
<button
382395
onClick={seekBackward}
383396
className="p-2.5 rounded-full hover:bg-gray-200 dark:hover:bg-gray-700 text-gray-600 dark:text-gray-300 transition-colors"
@@ -405,12 +418,12 @@ export const CombinedControls = () => {
405418
<button
406419
onClick={handleShadowingToggle}
407420
className={`p-2.5 rounded-full transition-all duration-150 ${isRecording
408-
? "bg-red-600 text-white animate-pulse"
409-
: isShadowingMode
410-
? "bg-orange-600 text-white hover:bg-orange-700"
411-
: canRecord
412-
? "bg-gray-200 dark:bg-gray-700 text-gray-600 dark:text-gray-300 hover:bg-gray-300 dark:hover:bg-gray-600"
413-
: "bg-gray-200 dark:bg-gray-700 text-gray-400 dark:text-gray-500 cursor-not-allowed opacity-50"
421+
? "bg-red-600 text-white animate-pulse"
422+
: isShadowingMode
423+
? "bg-orange-600 text-white hover:bg-orange-700"
424+
: canRecord
425+
? "bg-gray-200 dark:bg-gray-700 text-gray-600 dark:text-gray-300 hover:bg-gray-300 dark:hover:bg-gray-600"
426+
: "bg-gray-200 dark:bg-gray-700 text-gray-400 dark:text-gray-500 cursor-not-allowed opacity-50"
414427
}`}
415428
aria-label={isShadowingMode ? t("shadowing.disable") : t("shadowing.enable")}
416429
title={!canRecord ? "Audio recording is not supported on this device/browser" : (isShadowingMode ? t("shadowing.disable") : t("shadowing.enable"))}

src/components/controls/PlayerControls.tsx

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
Minus,
1212
Plus,
1313
ListMusic,
14+
RotateCcw,
1415
} from "lucide-react";
1516
import { Slider } from "@/components/ui/slider";
1617
import { Button } from "@/components/ui/button";
@@ -149,6 +150,19 @@ export const PlayerControls = () => {
149150

150151
{/* Play/pause and skip controls */}
151152
<div className="flex items-center justify-center space-x-2">
153+
<Button
154+
variant="ghost"
155+
size="icon"
156+
onClick={() => {
157+
setCurrentTime(0);
158+
if (!isPlaying) togglePlay();
159+
}}
160+
aria-label={t("player.playFromStart", { defaultValue: "Play from start" })}
161+
title={t("player.playFromStart", { defaultValue: "Play from start" })}
162+
>
163+
<RotateCcw size={16} />
164+
</Button>
165+
152166
<Button
153167
variant="ghost"
154168
size="icon"

src/components/player/MediaPlayer.tsx

Lines changed: 69 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useRef, useEffect, useState } from "react";
1+
import { useRef, useEffect, useState, useCallback } from "react";
22
import { usePlayerStore } from "../../stores/playerStore";
33
import { toast } from "react-hot-toast";
44
import { Play, Pause } from "lucide-react";
@@ -13,8 +13,9 @@ export const MediaPlayer = ({ hiddenMode = false }: MediaPlayerProps) => {
1313
const videoRef = useRef<HTMLVideoElement>(null);
1414
const [localPlayState, setLocalPlayState] = useState(false);
1515

16-
1716
const isDelayingRef = useRef(false);
17+
// Track pending play intent so we can start playback once the element is ready
18+
const pendingPlayRef = useRef(false);
1819

1920
const {
2021
currentFile,
@@ -38,6 +39,69 @@ export const MediaPlayer = ({ hiddenMode = false }: MediaPlayerProps) => {
3839
setLocalPlayState(isPlaying);
3940
}, [isPlaying]);
4041

42+
// Helper to safely play a media element
43+
const safePlay = useCallback(
44+
(mediaElement: HTMLMediaElement) => {
45+
// readyState >= 2 (HAVE_CURRENT_DATA) means enough data to play
46+
if (mediaElement.readyState >= 2) {
47+
mediaElement.play().catch((err) => {
48+
console.error("Error playing media:", err);
49+
toast.error(
50+
"Error playing media. The file may be corrupted or not supported."
51+
);
52+
setIsPlaying(false);
53+
});
54+
} else {
55+
// Not ready yet – mark as pending and wait for canplay
56+
pendingPlayRef.current = true;
57+
}
58+
},
59+
[setIsPlaying]
60+
);
61+
62+
// Reset pending play when the media source changes
63+
useEffect(() => {
64+
pendingPlayRef.current = false;
65+
}, [currentFile?.url]);
66+
67+
// Listen for canplay to know when the element is ready
68+
useEffect(() => {
69+
const mediaElement = currentFile?.type.includes("video")
70+
? videoRef.current
71+
: audioRef.current;
72+
if (!mediaElement) return;
73+
74+
const handleCanPlay = () => {
75+
// If a play was requested while we were loading, start now
76+
if (pendingPlayRef.current) {
77+
pendingPlayRef.current = false;
78+
mediaElement.play().catch((err) => {
79+
console.error("Error playing media after canplay:", err);
80+
setIsPlaying(false);
81+
});
82+
}
83+
};
84+
85+
mediaElement.addEventListener("canplay", handleCanPlay);
86+
// If it's already ready (cached / fast load), handle pending play immediately
87+
if (mediaElement.readyState >= 2) {
88+
handleCanPlay();
89+
}
90+
return () => {
91+
mediaElement.removeEventListener("canplay", handleCanPlay);
92+
};
93+
}, [currentFile, setIsPlaying]);
94+
95+
// Pause playback when the component unmounts so the store stays in sync
96+
useEffect(() => {
97+
return () => {
98+
const { isPlaying: stillPlaying } = usePlayerStore.getState();
99+
if (stillPlaying) {
100+
usePlayerStore.getState().setIsPlaying(false);
101+
}
102+
};
103+
}, []);
104+
41105
// Handle play/pause
42106
useEffect(() => {
43107
const mediaElement = currentFile?.type.includes("video")
@@ -47,18 +111,12 @@ export const MediaPlayer = ({ hiddenMode = false }: MediaPlayerProps) => {
47111

48112
if (isPlaying) {
49113
if (isDelayingRef.current) return; // Don't interfere if delaying
50-
console.log("Attempting to play media:", currentFile?.url);
51-
mediaElement.play().catch((err) => {
52-
console.error("Error playing media:", err);
53-
toast.error(
54-
"Error playing media. The file may be corrupted or not supported."
55-
);
56-
setIsPlaying(false);
57-
});
114+
safePlay(mediaElement);
58115
} else {
116+
pendingPlayRef.current = false;
59117
mediaElement.pause();
60118
}
61-
}, [isPlaying, currentFile, setIsPlaying]);
119+
}, [isPlaying, currentFile, setIsPlaying, safePlay]);
62120

63121
// Handle volume changes
64122
useEffect(() => {

0 commit comments

Comments
 (0)