Skip to content

Commit c7d49a4

Browse files
committed
Loop + playback speed settings
1 parent ff1c8ea commit c7d49a4

File tree

14 files changed

+431
-202
lines changed

14 files changed

+431
-202
lines changed

src/components/ControlBar.tsx

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
1-
import { useState } from "react";
2-
import type { ChangeEvent } from "react";
1+
import { useState, type ChangeEvent } from "react";
32
import { useLingui } from "@lingui/react/macro";
43
import {
54
HiPlay,
@@ -12,13 +11,15 @@ import {
1211
HiInformationCircle,
1312
HiArrowPath,
1413
} from "react-icons/hi2";
14+
1515
import {
1616
useVideoActions,
1717
useVideoUrl,
1818
useVideoState,
1919
useUIControls,
2020
} from "../hooks";
2121
import { formatTime } from "../utils/format";
22+
import { SettingsPopover } from "./Settings/SettingsPopover";
2223

2324
interface ControlBarProps {
2425
onOpenFile: () => void;
@@ -66,7 +67,7 @@ export default function ControlBar(props: ControlBarProps) {
6667

6768
return (
6869
<div
69-
className={`absolute right-0 bottom-0 left-0 bg-gradient-to-t from-black/80 via-black/60 to-transparent px-4 py-0 text-blue-100 transition-all duration-300 ${
70+
className={`absolute right-0 bottom-0 left-0 bg-linear-to-t from-black/80 via-black/60 to-transparent px-4 py-0 text-blue-100 transition-all duration-300 ${
7071
uiControls.showControls
7172
? "translate-y-0 opacity-100"
7273
: "translate-y-full opacity-0"
@@ -78,7 +79,7 @@ export default function ControlBar(props: ControlBarProps) {
7879
>
7980
{/* Progress Bar */}
8081
<div className="flex items-center space-x-3">
81-
<span className="min-w-[40px] font-mono text-sm">
82+
<span className="min-w-10 font-mono text-sm">
8283
{formatTime(videoState.currentTime)}
8384
</span>
8485
<input
@@ -109,7 +110,7 @@ export default function ControlBar(props: ControlBarProps) {
109110
disabled={!videoUrl}
110111
aria-label="Seek"
111112
/>
112-
<span className="min-w-[40px] font-mono text-sm">
113+
<span className="min-w-10 font-mono text-sm">
113114
{formatTime(videoState.duration)}
114115
</span>
115116
</div>
@@ -208,6 +209,7 @@ export default function ControlBar(props: ControlBarProps) {
208209
<HiArrowsPointingOut className="h-5 w-5" />
209210
)}
210211
</button>
212+
<SettingsPopover />
211213
<button
212214
onClick={props.onOpenFile}
213215
className="button-styled h-12 w-12"
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
import { useState, useEffect, useRef } from "react";
2+
import { useLingui } from "@lingui/react/macro";
3+
import { Popover, PopoverButton, PopoverPanel } from "@headlessui/react";
4+
import { HiCog6Tooth } from "react-icons/hi2";
5+
6+
import { SettingsSpeedTab } from "./SettingsSpeedTab";
7+
import { SettingsRootTab } from "./SettingsRootTab";
8+
import { useVideoActions, useVideoState } from "../../hooks";
9+
import { useSetAtom } from "jotai";
10+
import { settingsPopoverOpenAtom } from "../../store/video";
11+
12+
export function SettingsPopover() {
13+
const { t } = useLingui();
14+
const [settingsTab, setSettingsTab] = useState<"root" | "speed">("root");
15+
const popoverOpenRef = useRef(false);
16+
const setSettingsPopoverOpen = useSetAtom(settingsPopoverOpenAtom);
17+
18+
const videoActions = useVideoActions();
19+
const videoState = useVideoState();
20+
21+
// Update the global popover state when ref changes
22+
useEffect(() => {
23+
setSettingsPopoverOpen(popoverOpenRef.current);
24+
});
25+
26+
return (
27+
<Popover>
28+
{({ open }) => {
29+
// Update ref when popover state changes
30+
popoverOpenRef.current = open;
31+
32+
return (
33+
<>
34+
<PopoverButton
35+
onClick={() => {
36+
setSettingsTab("root");
37+
}}
38+
className="inline-flex items-center gap-2 rounded-md px-3 py-1.5 text-sm/6 font-semibold text-white shadow-inner shadow-white/10 focus:not-data-focus:outline-none data-focus:outline data-focus:outline-white data-hover:bg-gray-900/95 data-open:bg-gray-900/95"
39+
title={t`Settings`}
40+
>
41+
<HiCog6Tooth
42+
className={`h-5 w-5 transition-transform duration-300 ${
43+
open ? "rotate-90" : "rotate-0"
44+
}`}
45+
/>
46+
</PopoverButton>
47+
48+
<PopoverPanel
49+
transition
50+
anchor="bottom end"
51+
// Prevent clicks within the panel from propagating to the underlying
52+
// video container, which could interpret them as play/pause toggles.
53+
onClick={(event) => {
54+
event.stopPropagation();
55+
}}
56+
onMouseDown={(event) => {
57+
event.stopPropagation();
58+
}}
59+
className="w-64 origin-top-right rounded-xl border border-white/5 bg-gray-900/95 p-1 text-sm/6 text-white transition duration-100 ease-out [--anchor-gap:--spacing(1)] focus:outline-none data-closed:scale-95 data-closed:opacity-0"
60+
>
61+
{settingsTab === "root" ? (
62+
<SettingsRootTab
63+
toggleLoop={videoActions.toggleLoop}
64+
loop={videoState.loop}
65+
onSpeedTab={() => {
66+
setSettingsTab("speed");
67+
}}
68+
/>
69+
) : null}
70+
71+
{settingsTab === "speed" ? (
72+
<SettingsSpeedTab
73+
onBack={() => {
74+
setSettingsTab("root");
75+
}}
76+
/>
77+
) : null}
78+
</PopoverPanel>
79+
</>
80+
);
81+
}}
82+
</Popover>
83+
);
84+
}
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import { useLingui } from "@lingui/react/macro";
2+
import { HiArrowPath, HiChevronRight, HiPencil } from "react-icons/hi2";
3+
4+
interface SettingsRootTabProps {
5+
readonly onSpeedTab: () => void;
6+
readonly toggleLoop: () => void;
7+
readonly loop: boolean;
8+
}
9+
10+
export function SettingsRootTab(props: SettingsRootTabProps) {
11+
const { t } = useLingui();
12+
13+
return (
14+
<div className="p-1">
15+
<button
16+
type="button"
17+
onMouseDown={(e) => {
18+
// Keep popover open while toggling
19+
e.preventDefault();
20+
}}
21+
onClick={props.toggleLoop}
22+
className="group flex w-full items-center gap-2 rounded-lg px-3 py-2 hover:bg-white/10 focus:outline-none"
23+
>
24+
<HiArrowPath className="size-4 fill-white/30" />
25+
{t`Loop`}
26+
<span className="ml-auto text-white/60">
27+
{props.loop ? t`On` : t`Off`}
28+
</span>
29+
</button>
30+
<button
31+
type="button"
32+
onMouseDown={(e) => {
33+
// Prevent immediate close when clicking to navigate within the panel
34+
e.preventDefault();
35+
}}
36+
onClick={props.onSpeedTab}
37+
className="group flex w-full items-center gap-2 rounded-lg px-3 py-2 hover:bg-white/10 focus:outline-none"
38+
>
39+
<HiPencil className="size-4 fill-white/30" />
40+
{t`Playback speed`}
41+
<span className="ml-auto text-white/60">
42+
<HiChevronRight />
43+
</span>
44+
</button>
45+
</div>
46+
);
47+
}
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import { useLingui } from "@lingui/react/macro";
2+
import { useVideoActions, useVideoState } from "../../hooks";
3+
4+
interface SettingsSpeedTabProps {
5+
readonly onBack: () => void;
6+
}
7+
8+
export function SettingsSpeedTab(props: SettingsSpeedTabProps) {
9+
const { t } = useLingui();
10+
const { setPlaybackRate } = useVideoActions();
11+
const { playbackRate } = useVideoState();
12+
13+
const rates = [0.25, 0.5, 0.75, 1, 1.25, 1.5, 1.75, 2, 3, 4];
14+
15+
return (
16+
<div className="flex flex-col gap-1 p-1">
17+
<div className="flex items-center gap-2 px-2 py-1 text-white/70">
18+
<button
19+
type="button"
20+
className="rounded-lg px-2 py-1 text-white/70 hover:bg-white/10"
21+
onClick={props.onBack}
22+
aria-label={t`Back`}
23+
>
24+
{t`Settings`}
25+
</button>
26+
<div className="ml-auto text-xs">{t`Speed`}</div>
27+
</div>
28+
{rates.map((r) => (
29+
<div key={r}>
30+
<button
31+
type="button"
32+
aria-checked={playbackRate === r}
33+
onMouseDown={(e) => {
34+
// Keep popover open while selecting a rate
35+
e.preventDefault();
36+
}}
37+
onClick={() => {
38+
setPlaybackRate(r);
39+
// Return to root view after selection
40+
props.onBack();
41+
}}
42+
className={`flex w-full items-center justify-between rounded-lg px-3 py-2 text-left hover:bg-white/10 focus:outline-none ${
43+
playbackRate === r ? "bg-white/10" : ""
44+
}`}
45+
>
46+
<span>{r.toFixed(2)}×</span>
47+
{playbackRate === r ? (
48+
<span className="text-xs text-white/70">{t`Selected`}</span>
49+
) : null}
50+
</button>
51+
</div>
52+
))}
53+
</div>
54+
);
55+
}

src/components/VideoPlayer.tsx

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { useEffect, useMemo, useRef, useState } from "react";
2+
import { useAtomValue, useSetAtom } from "jotai";
23
import { HiFilm } from "react-icons/hi2";
34

45
import { useRegisterSW } from "virtual:pwa-register/react";
@@ -14,12 +15,12 @@ import {
1415
import { useMediaInfoMetadata } from "../hooks";
1516
import type { MediaInfoMetadata } from "../utils/mediaInfo";
1617
import { isVideoFile } from "../utils";
17-
import { useSetAtom } from "jotai";
1818
import {
1919
updateDurationAtom,
2020
updateCurrentTimeAtom,
2121
updatePlayStateAtom,
2222
updateVolumeStateAtom,
23+
settingsPopoverOpenAtom,
2324
} from "../store/video";
2425
import { useLingui } from "@lingui/react/macro";
2526

@@ -30,6 +31,7 @@ export default function VideoPlayerApp() {
3031
width: 0,
3132
height: 0,
3233
});
34+
const settingsPopoverOpen = useAtomValue(settingsPopoverOpenAtom);
3335

3436
// Get video context data
3537
const videoActions = useVideoActions();
@@ -113,7 +115,9 @@ export default function VideoPlayerApp() {
113115
};
114116

115117
const openFileDialog = () => {
116-
fileInputRef.current?.click();
118+
if (!settingsPopoverOpen) {
119+
fileInputRef.current?.click();
120+
}
117121
};
118122

119123
// Build merged metadata for overlay

src/hooks/useVideoContext.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
volumeAtom,
1111
isMutedAtom,
1212
isSeekingAtom,
13+
playbackRateAtom,
1314
showControlsAtom,
1415
isFullscreenAtom,
1516
isDragOverAtom,
@@ -24,13 +25,17 @@ import {
2425
setVolumeAtom,
2526
setMuteAtom,
2627
toggleMuteAtom,
28+
setPlaybackRateAtom,
29+
setLoopAtom,
30+
toggleLoopAtom,
2731

2832
// Effects
2933
videoUrlCleanupEffect,
3034
videoElementSyncEffect,
3135
mediaInfoInitEffect,
3236
mediaInfoExtractEffect,
3337
mediaInfoMetadataAtom,
38+
loopAtom,
3439
} from "../store/video";
3540

3641
// Hook for video actions (play, pause, seek, etc.)
@@ -41,6 +46,9 @@ export function useVideoActions() {
4146
const setVolume = useSetAtom(setVolumeAtom);
4247
const toggleMute = useSetAtom(toggleMuteAtom);
4348
const setVideoElement = useSetAtom(videoElementAtom);
49+
const setPlaybackRate = useSetAtom(setPlaybackRateAtom);
50+
const setLoop = useSetAtom(setLoopAtom);
51+
const toggleLoop = useSetAtom(toggleLoopAtom);
4452

4553
// Trigger effects
4654
useAtom(videoUrlCleanupEffect);
@@ -62,6 +70,9 @@ export function useVideoActions() {
6270
setVolume,
6371
setMute: useSetAtom(setMuteAtom),
6472
toggleMute,
73+
setPlaybackRate,
74+
setLoop,
75+
toggleLoop,
6576
registerVideoElement,
6677
};
6778
}
@@ -94,6 +105,8 @@ export function useVideoState() {
94105
effectiveVolume: useAtomValue(effectiveVolumeAtom),
95106
isMuted: useAtomValue(isMutedAtom),
96107
isSeeking: useAtomValue(isSeekingAtom),
108+
playbackRate: useAtomValue(playbackRateAtom),
109+
loop: useAtomValue(loopAtom),
97110
metadata: useAtomValue(videoMetadataAtom),
98111
hasMetadata: useAtomValue(hasVideoMetadataAtom),
99112
mediaInfo: useAtomValue(mediaInfoMetadataAtom),

0 commit comments

Comments
 (0)