Audio player improvements (#9008)

This commit is contained in:
Elian Doran 2026-03-11 21:38:58 +02:00 committed by GitHub
commit 5e8de14721
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 502 additions and 377 deletions

View File

@ -1036,7 +1036,7 @@
"file_preview_not_available": "File preview is not available for this file format.",
"too_big": "The preview only shows the first {{maxNumChars}} characters of the file for performance reasons. Download the file and open it externally to be able to see the entire content."
},
"video": {
"media": {
"play": "Play (Space)",
"pause": "Pause (Space)",
"back-10s": "Back 10s (Left arrow key)",
@ -1051,7 +1051,7 @@
"exit-picture-in-picture": "Exit picture-in-picture",
"fullscreen": "Fullscreen (F)",
"exit-fullscreen": "Exit fullscreen",
"unsupported-format": "Video preview is not available for this file format.",
"unsupported-format": "Media preview is not available for this file format:\n{{mime}}",
"zoom-to-fit": "Zoom to fill",
"zoom-reset": "Reset zoom to fill"
},

View File

@ -83,7 +83,7 @@ export default class NoteWrapperWidget extends FlexContainer<BasicWidget> {
return true;
}
if (note.type === "file" && (note.mime === "application/pdf" || note.mime.startsWith("video/"))) {
if (note.type === "file" && (note.mime === "application/pdf" || note.mime.startsWith("video/") || note.mime.startsWith("audio/"))) {
return true;
}
@ -108,7 +108,7 @@ export default class NoteWrapperWidget extends FlexContainer<BasicWidget> {
return true;
}
if (note.type === "file" && MIME_TYPES_WITH_BACKGROUND_EFFECTS.includes(note.mime)) {
if (note.type === "file" && (MIME_TYPES_WITH_BACKGROUND_EFFECTS.includes(note.mime) || note.mime.startsWith("audio/"))) {
return true;
}

View File

@ -8,6 +8,7 @@
color: var(--muted-text-color);
height: 100%;
text-align: center;
white-space: pre-line;
.tn-icon {
font-size: 4em;

View File

@ -1,10 +1,9 @@
import "./File.css";
import FNote from "../../entities/fnote";
import { t } from "../../services/i18n";
import { getUrlForDownload } from "../../services/open";
import Alert from "../react/Alert";
import { useNoteBlob } from "../react/hooks";
import AudioPreview from "./file/Audio";
import PdfPreview from "./file/Pdf";
import VideoPreview from "./file/Video";
import { TypeWidgetProps } from "./type_widget";
@ -43,16 +42,6 @@ function TextPreview({ content }: { content: string }) {
);
}
function AudioPreview({ note }: { note: FNote }) {
return (
<audio
class="audio-preview"
src={getUrlForDownload(`api/notes/${note.noteId}/open-partial`)}
controls
/>
);
}
function NoPreview() {
return (
<Alert className="file-preview-not-available" type="info">

View File

@ -0,0 +1,112 @@
import { MutableRef, useCallback, useEffect, useRef, useState } from "preact/hooks";
import FNote from "../../../entities/fnote";
import { t } from "../../../services/i18n";
import { getUrlForDownload } from "../../../services/open";
import Icon from "../../react/Icon";
import NoItems from "../../react/NoItems";
import { LoopButton, PlaybackSpeed, PlayPauseButton, SeekBar, SkipButton, VolumeControl } from "./MediaPlayer";
export default function AudioPreview({ note }: { note: FNote }) {
const wrapperRef = useRef<HTMLDivElement>(null);
const audioRef = useRef<HTMLAudioElement>(null);
const [playing, setPlaying] = useState(false);
const [error, setError] = useState(false);
const togglePlayback = useCallback(() => {
const audio = audioRef.current;
if (!audio) return;
if (audio.paused) {
audio.play();
} else {
audio.pause();
}
}, []);
const onKeyDown = useKeyboardShortcuts(audioRef, togglePlayback);
useEffect(() => setError(false), [note.noteId]);
const onError = useCallback(() => setError(true), []);
if (error) {
return <NoItems icon="bx bx-volume-mute" text={t("media.unsupported-format", { mime: note.mime.replace("/", "-") })} />;
}
return (
<div ref={wrapperRef} className="audio-preview-wrapper" onKeyDown={onKeyDown} tabIndex={0}>
<audio
class="audio-preview"
src={getUrlForDownload(`api/notes/${note.noteId}/open-partial`)}
ref={audioRef}
onPlay={() => setPlaying(true)}
onPause={() => setPlaying(false)}
onError={onError}
/>
<div className="audio-preview-icon-wrapper">
<Icon icon="bx bx-music" className="audio-preview-icon" />
</div>
<div className="media-preview-controls">
<SeekBar mediaRef={audioRef} />
<div class="media-buttons-row">
<div className="left">
<PlaybackSpeed mediaRef={audioRef} />
</div>
<div className="center">
<div className="spacer" />
<SkipButton mediaRef={audioRef} seconds={-10} icon="bx bx-rewind" text={t("media.back-10s")} />
<PlayPauseButton playing={playing} togglePlayback={togglePlayback} />
<SkipButton mediaRef={audioRef} seconds={30} icon="bx bx-fast-forward" text={t("media.forward-30s")} />
<LoopButton mediaRef={audioRef} />
</div>
<div className="right">
<VolumeControl mediaRef={audioRef} />
</div>
</div>
</div>
</div>
);
}
function useKeyboardShortcuts(audioRef: MutableRef<HTMLAudioElement | null>, togglePlayback: () => void) {
return useCallback((e: KeyboardEvent) => {
const audio = audioRef.current;
if (!audio) return;
switch (e.key) {
case " ":
e.preventDefault();
togglePlayback();
break;
case "ArrowLeft":
e.preventDefault();
audio.currentTime = Math.max(0, audio.currentTime - (e.ctrlKey ? 60 : 10));
break;
case "ArrowRight":
e.preventDefault();
audio.currentTime = Math.min(audio.duration, audio.currentTime + (e.ctrlKey ? 60 : 10));
break;
case "m":
case "M":
e.preventDefault();
audio.muted = !audio.muted;
break;
case "ArrowUp":
e.preventDefault();
audio.volume = Math.min(1, audio.volume + 0.05);
break;
case "ArrowDown":
e.preventDefault();
audio.volume = Math.max(0, audio.volume - 0.05);
break;
case "Home":
e.preventDefault();
audio.currentTime = 0;
break;
case "End":
e.preventDefault();
audio.currentTime = audio.duration;
break;
}
}, [ audioRef, togglePlayback ]);
}

View File

@ -0,0 +1,98 @@
.media-preview-controls {
padding: 1.25em;
display: flex;
flex-direction: column;
gap: 0.5em;
.media-buttons-row {
display: flex;
> * {
flex: 1;
align-items: center;
gap: 0.5em;
display: flex;
}
.spacer {
width: var(--icon-button-size, 32px);
height: var(--icon-button-size, 32px);
}
.center {
justify-content: center;
}
.right {
display: flex;
justify-content: flex-end;
}
.play-button {
--icon-button-size: 48px;
}
}
.media-seekbar-row {
display: flex;
align-items: center;
gap: 0.5em;
.media-time {
font-size: 0.85em;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.media-trackbar {
flex: 1;
cursor: pointer;
}
}
.media-volume-row {
display: flex;
align-items: center;
gap: 0.25em;
.media-volume-slider {
width: 80px;
cursor: pointer;
}
}
.speed-dropdown {
position: relative;
.tn-icon {
transform: translateY(-10%);
}
.media-speed-label {
position: absolute;
bottom: 0;
left: 0;
right: 0;
transform: translateY(15%);
text-align: center;
font-size: 0.6rem;
font-variant-numeric: tabular-nums;
}
}
}
.audio-preview-wrapper {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
.audio-preview-icon-wrapper {
flex-grow: 1;
display: flex;
align-items: center;
justify-content: center;
font-size: 8em;
opacity: 0.6;
}
}

View File

@ -0,0 +1,220 @@
import "./MediaPlayer.css";
import { RefObject } from "preact";
import { useEffect, useState } from "preact/hooks";
import { t } from "../../../services/i18n";
import ActionButton from "../../react/ActionButton";
import Dropdown from "../../react/Dropdown";
import Icon from "../../react/Icon";
export function SeekBar({ mediaRef }: { mediaRef: RefObject<HTMLVideoElement | HTMLAudioElement> }) {
const [currentTime, setCurrentTime] = useState(0);
const [duration, setDuration] = useState(0);
useEffect(() => {
const media = mediaRef.current;
if (!media) return;
const onTimeUpdate = () => setCurrentTime(media.currentTime);
const onDurationChange = () => setDuration(media.duration);
media.addEventListener("timeupdate", onTimeUpdate);
media.addEventListener("durationchange", onDurationChange);
return () => {
media.removeEventListener("timeupdate", onTimeUpdate);
media.removeEventListener("durationchange", onDurationChange);
};
}, [ mediaRef ]);
const onSeek = (e: Event) => {
const media = mediaRef.current;
if (!media) return;
media.currentTime = parseFloat((e.target as HTMLInputElement).value);
};
return (
<div class="media-seekbar-row">
<span class="media-time">{formatTime(currentTime)}</span>
<input
type="range"
class="media-trackbar"
min={0}
max={duration || 0}
step={0.1}
value={currentTime}
onInput={onSeek}
/>
<span class="media-time">-{formatTime(Math.max(0, duration - currentTime))}</span>
</div>
);
}
function formatTime(seconds: number): string {
const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
return `${mins}:${secs.toString().padStart(2, "0")}`;
}
export function PlayPauseButton({ playing, togglePlayback }: {
playing: boolean,
togglePlayback: () => void
}) {
return (
<ActionButton
className="play-button"
icon={playing ? "bx bx-pause" : "bx bx-play"}
text={playing ? t("media.pause") : t("media.play")}
onClick={togglePlayback}
/>
);
}
export function VolumeControl({ mediaRef }: { mediaRef: RefObject<HTMLVideoElement | HTMLAudioElement> }) {
const [volume, setVolume] = useState(() => mediaRef.current?.volume ?? 1);
const [muted, setMuted] = useState(() => mediaRef.current?.muted ?? false);
// Sync state when the media element changes volume externally.
useEffect(() => {
const media = mediaRef.current;
if (!media) return;
setVolume(media.volume);
setMuted(media.muted);
const onVolumeChange = () => {
setVolume(media.volume);
setMuted(media.muted);
};
media.addEventListener("volumechange", onVolumeChange);
return () => media.removeEventListener("volumechange", onVolumeChange);
}, [ mediaRef ]);
const onVolumeChange = (e: Event) => {
const media = mediaRef.current;
if (!media) return;
const val = parseFloat((e.target as HTMLInputElement).value);
media.volume = val;
setVolume(val);
if (val > 0 && media.muted) {
media.muted = false;
setMuted(false);
}
};
const toggleMute = () => {
const media = mediaRef.current;
if (!media) return;
media.muted = !media.muted;
setMuted(media.muted);
};
return (
<div class="media-volume-row">
<ActionButton
icon={muted || volume === 0 ? "bx bx-volume-mute" : volume < 0.5 ? "bx bx-volume-low" : "bx bx-volume-full"}
text={muted ? t("media.unmute") : t("media.mute")}
onClick={toggleMute}
/>
<input
type="range"
class="media-volume-slider"
min={0}
max={1}
step={0.05}
value={muted ? 0 : volume}
onInput={onVolumeChange}
/>
</div>
);
}
export function SkipButton({ mediaRef, seconds, icon, text }: { mediaRef: RefObject<HTMLVideoElement | HTMLAudioElement>, seconds: number, icon: string, text: string }) {
const skip = () => {
const media = mediaRef.current;
if (!media) return;
media.currentTime = Math.max(0, Math.min(media.duration, media.currentTime + seconds));
};
return (
<ActionButton icon={icon} text={text} onClick={skip} />
);
}
export function LoopButton({ mediaRef }: { mediaRef: RefObject<HTMLVideoElement | HTMLAudioElement> }) {
const [loop, setLoop] = useState(() => mediaRef.current?.loop ?? false);
useEffect(() => {
const media = mediaRef.current;
if (!media) return;
setLoop(media.loop);
const observer = new MutationObserver(() => setLoop(media.loop));
observer.observe(media, { attributes: true, attributeFilter: ["loop"] });
return () => observer.disconnect();
}, [ mediaRef ]);
const toggle = () => {
const media = mediaRef.current;
if (!media) return;
media.loop = !media.loop;
setLoop(media.loop);
};
return (
<ActionButton
className={loop ? "active" : ""}
icon="bx bx-repeat"
text={loop ? t("media.disable-loop") : t("media.loop")}
onClick={toggle}
/>
);
}
const PLAYBACK_SPEEDS = [0.5, 1, 1.25, 1.5, 2];
export function PlaybackSpeed({ mediaRef }: { mediaRef: RefObject<HTMLVideoElement | HTMLAudioElement> }) {
const [speed, setSpeed] = useState(() => mediaRef.current?.playbackRate ?? 1);
useEffect(() => {
const media = mediaRef.current;
if (!media) return;
setSpeed(media.playbackRate);
const onRateChange = () => setSpeed(media.playbackRate);
media.addEventListener("ratechange", onRateChange);
return () => media.removeEventListener("ratechange", onRateChange);
}, [ mediaRef ]);
const selectSpeed = (rate: number) => {
const media = mediaRef.current;
if (!media) return;
media.playbackRate = rate;
setSpeed(rate);
};
return (
<Dropdown
iconAction
hideToggleArrow
buttonClassName="speed-dropdown"
text={<>
<Icon icon="bx bx-tachometer" />
<span class="media-speed-label">{speed}x</span>
</>}
title={t("media.playback-speed")}
>
{PLAYBACK_SPEEDS.map((rate) => (
<li key={rate}>
<button
class={`dropdown-item ${rate === speed ? "active" : ""}`}
onClick={() => selectSpeed(rate)}
>
{rate}x
</button>
</li>
))}
</Dropdown>
);
}

View File

@ -2,7 +2,7 @@
width: 100%;
height: 100%;
position: relative;
background-color: black;
background-color: black;
.video-preview {
background-color: black;
@ -13,102 +13,23 @@
&.controls-hidden {
cursor: pointer;
.video-preview-controls {
.media-preview-controls {
opacity: 0;
pointer-events: none;
}
}
.video-preview-controls {
position: absolute;
bottom: 0;
left: 0;
right: 0;
padding: 1.25em;
display: flex;
flex-direction: column;
gap: 0.5em;
background: rgba(0, 0, 0, 0.5);
color: white;
.media-preview-controls {
--icon-button-hover-color: white;
--icon-button-hover-background: rgba(255, 255, 255, 0.2);
opacity: 1;
transition: opacity 300ms ease;
.video-buttons-row {
display: flex;
> * {
flex: 1;
align-items: center;
gap: 0.5em;
display: flex;
}
.spacer {
width: var(--icon-button-size, 32px);
height: var(--icon-button-size, 32px);
}
.center {
justify-content: center;
}
.right {
display: flex;
justify-content: flex-end;
}
.play-button {
--icon-button-size: 48px;
}
}
}
.video-seekbar-row {
display: flex;
align-items: center;
gap: 0.5em;
}
.video-trackbar {
flex: 1;
cursor: pointer;
}
.video-time {
font-size: 0.85em;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.video-volume-row {
display: flex;
align-items: center;
gap: 0.25em;
}
.video-volume-slider {
width: 80px;
cursor: pointer;
}
.speed-dropdown {
position: relative;
.tn-icon {
transform: translateY(-10%);
}
.video-speed-label {
position: absolute;
bottom: 0;
left: 0;
right: 0;
transform: translateY(15%);
text-align: center;
font-size: 0.6rem;
font-variant-numeric: tabular-nums;
}
position: absolute;
bottom: 0;
left: 0;
right: 0;
background: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(6px);
color: white;
}
}

View File

@ -1,21 +1,14 @@
import "./Video.css";
import { RefObject } from "preact";
import { useCallback, useEffect, useRef, useState } from "preact/hooks";
import { MutableRef, useCallback, useEffect, useRef, useState } from "preact/hooks";
import FNote from "../../../entities/fnote";
import { t } from "../../../services/i18n";
import { getUrlForDownload } from "../../../services/open";
import ActionButton from "../../react/ActionButton";
import Dropdown from "../../react/Dropdown";
import Icon from "../../react/Icon";
import NoItems from "../../react/NoItems";
function formatTime(seconds: number): string {
const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
return `${mins}:${secs.toString().padStart(2, "0")}`;
}
import { LoopButton, PlaybackSpeed, PlayPauseButton, SeekBar, SkipButton, VolumeControl } from "./MediaPlayer";
const AUTO_HIDE_DELAY = 3000;
@ -40,11 +33,56 @@ export default function VideoPreview({ note }: { note: FNote }) {
}, []);
const onVideoClick = useCallback((e: MouseEvent) => {
if ((e.target as HTMLElement).closest(".video-preview-controls")) return;
if ((e.target as HTMLElement).closest(".media-preview-controls")) return;
togglePlayback();
}, [togglePlayback]);
const onKeyDown = useCallback((e: KeyboardEvent) => {
const onKeyDown = useKeyboardShortcuts(videoRef, wrapperRef, togglePlayback, flashControls);
if (error) {
return <NoItems icon="bx bx-video-off" text={t("media.unsupported-format", { mime: note.mime.replace("/", "-") })} />;
}
return (
<div ref={wrapperRef} className={`video-preview-wrapper ${controlsVisible ? "" : "controls-hidden"}`} tabIndex={0} onClick={onVideoClick} onKeyDown={onKeyDown} onMouseMove={onMouseMove}>
<video
ref={videoRef}
class="video-preview"
src={getUrlForDownload(`api/notes/${note.noteId}/open-partial`)}
datatype={note?.mime}
onPlay={() => setPlaying(true)}
onPause={() => setPlaying(false)}
onError={onError}
/>
<div className="media-preview-controls">
<SeekBar mediaRef={videoRef} />
<div class="media-buttons-row">
<div className="left">
<PlaybackSpeed mediaRef={videoRef} />
<RotateButton videoRef={videoRef} />
</div>
<div className="center">
<div className="spacer" />
<SkipButton mediaRef={videoRef} seconds={-10} icon="bx bx-rewind" text={t("media.back-10s")} />
<PlayPauseButton playing={playing} togglePlayback={togglePlayback} />
<SkipButton mediaRef={videoRef} seconds={30} icon="bx bx-fast-forward" text={t("media.forward-30s")} />
<LoopButton mediaRef={videoRef} />
</div>
<div className="right">
<VolumeControl mediaRef={videoRef} />
<ZoomToFitButton videoRef={videoRef} />
<PictureInPictureButton videoRef={videoRef} />
<FullscreenButton targetRef={wrapperRef} />
</div>
</div>
</div>
</div>
);
}
function useKeyboardShortcuts(videoRef: MutableRef<HTMLVideoElement | null>, wrapperRef: MutableRef<HTMLDivElement | null>, togglePlayback: () => void, flashControls: () => void) {
return useCallback((e: KeyboardEvent) => {
const video = videoRef.current;
if (!video) return;
@ -100,48 +138,7 @@ export default function VideoPreview({ note }: { note: FNote }) {
flashControls();
break;
}
}, [togglePlayback, flashControls]);
if (error) {
return <NoItems icon="bx bx-video-off" text={t("video.unsupported-format")} />;
}
return (
<div ref={wrapperRef} className={`video-preview-wrapper ${controlsVisible ? "" : "controls-hidden"}`} tabIndex={0} onClick={onVideoClick} onKeyDown={onKeyDown} onMouseMove={onMouseMove}>
<video
ref={videoRef}
class="video-preview"
src={getUrlForDownload(`api/notes/${note.noteId}/open-partial`)}
datatype={note?.mime}
onPlay={() => setPlaying(true)}
onPause={() => setPlaying(false)}
onError={onError}
/>
<div className="video-preview-controls">
<SeekBar videoRef={videoRef} />
<div class="video-buttons-row">
<div className="left">
<PlaybackSpeed videoRef={videoRef} />
<RotateButton videoRef={videoRef} />
</div>
<div className="center">
<div className="spacer" />
<SkipButton videoRef={videoRef} seconds={-10} icon="bx bx-rewind" text={t("video.back-10s")} />
<PlayPauseButton videoRef={videoRef} playing={playing} />
<SkipButton videoRef={videoRef} seconds={30} icon="bx bx-fast-forward" text={t("video.forward-30s")} />
<LoopButton videoRef={videoRef} />
</div>
<div className="right">
<VolumeControl videoRef={videoRef} />
<ZoomToFitButton videoRef={videoRef} />
<PictureInPictureButton videoRef={videoRef} />
<FullscreenButton targetRef={wrapperRef} />
</div>
</div>
</div>
</div>
);
}, [ wrapperRef, videoRef, togglePlayback, flashControls ]);
}
function useAutoHideControls(videoRef: RefObject<HTMLVideoElement>, playing: boolean) {
@ -153,7 +150,7 @@ function useAutoHideControls(videoRef: RefObject<HTMLVideoElement>, playing: boo
if (videoRef.current && !videoRef.current.paused) {
hideTimerRef.current = setTimeout(() => setVisible(false), AUTO_HIDE_DELAY);
}
}, []);
}, [ videoRef]);
const onMouseMove = useCallback(() => {
setVisible(true);
@ -174,219 +171,6 @@ function useAutoHideControls(videoRef: RefObject<HTMLVideoElement>, playing: boo
return { visible, onMouseMove, flash: onMouseMove };
}
function PlayPauseButton({ videoRef, playing }: { videoRef: RefObject<HTMLVideoElement>, playing: boolean }) {
const togglePlayback = () => {
const video = videoRef.current;
if (!video) return;
if (video.paused) {
video.play();
} else {
video.pause();
}
};
return (
<ActionButton
className="play-button"
icon={playing ? "bx bx-pause" : "bx bx-play"}
text={playing ? t("video.pause") : t("video.play")}
onClick={togglePlayback}
/>
);
}
function SkipButton({ videoRef, seconds, icon, text }: { videoRef: RefObject<HTMLVideoElement>, seconds: number, icon: string, text: string }) {
const skip = () => {
const video = videoRef.current;
if (!video) return;
video.currentTime = Math.max(0, Math.min(video.duration, video.currentTime + seconds));
};
return (
<ActionButton icon={icon} text={text} onClick={skip} />
);
}
function SeekBar({ videoRef }: { videoRef: RefObject<HTMLVideoElement> }) {
const [currentTime, setCurrentTime] = useState(0);
const [duration, setDuration] = useState(0);
useEffect(() => {
const video = videoRef.current;
if (!video) return;
const onTimeUpdate = () => setCurrentTime(video.currentTime);
const onDurationChange = () => setDuration(video.duration);
video.addEventListener("timeupdate", onTimeUpdate);
video.addEventListener("durationchange", onDurationChange);
return () => {
video.removeEventListener("timeupdate", onTimeUpdate);
video.removeEventListener("durationchange", onDurationChange);
};
}, []);
const onSeek = (e: Event) => {
const video = videoRef.current;
if (!video) return;
video.currentTime = parseFloat((e.target as HTMLInputElement).value);
};
return (
<div class="video-seekbar-row">
<span class="video-time">{formatTime(currentTime)}</span>
<input
type="range"
class="video-trackbar"
min={0}
max={duration || 0}
step={0.1}
value={currentTime}
onInput={onSeek}
/>
<span class="video-time">-{formatTime(Math.max(0, duration - currentTime))}</span>
</div>
);
}
function VolumeControl({ videoRef }: { videoRef: RefObject<HTMLVideoElement> }) {
const [volume, setVolume] = useState(() => videoRef.current?.volume ?? 1);
const [muted, setMuted] = useState(() => videoRef.current?.muted ?? false);
// Sync state when the video element changes volume externally.
useEffect(() => {
const video = videoRef.current;
if (!video) return;
setVolume(video.volume);
setMuted(video.muted);
const onVolumeChange = () => {
setVolume(video.volume);
setMuted(video.muted);
};
video.addEventListener("volumechange", onVolumeChange);
return () => video.removeEventListener("volumechange", onVolumeChange);
}, []);
const onVolumeChange = (e: Event) => {
const video = videoRef.current;
if (!video) return;
const val = parseFloat((e.target as HTMLInputElement).value);
video.volume = val;
setVolume(val);
if (val > 0 && video.muted) {
video.muted = false;
setMuted(false);
}
};
const toggleMute = () => {
const video = videoRef.current;
if (!video) return;
video.muted = !video.muted;
setMuted(video.muted);
};
return (
<div class="video-volume-row">
<ActionButton
icon={muted || volume === 0 ? "bx bx-volume-mute" : volume < 0.5 ? "bx bx-volume-low" : "bx bx-volume-full"}
text={muted ? t("video.unmute") : t("video.mute")}
onClick={toggleMute}
/>
<input
type="range"
class="video-volume-slider"
min={0}
max={1}
step={0.05}
value={muted ? 0 : volume}
onInput={onVolumeChange}
/>
</div>
);
}
const PLAYBACK_SPEEDS = [0.5, 1, 1.25, 1.5, 2];
function PlaybackSpeed({ videoRef }: { videoRef: RefObject<HTMLVideoElement> }) {
const [speed, setSpeed] = useState(() => videoRef.current?.playbackRate ?? 1);
useEffect(() => {
const video = videoRef.current;
if (!video) return;
setSpeed(video.playbackRate);
const onRateChange = () => setSpeed(video.playbackRate);
video.addEventListener("ratechange", onRateChange);
return () => video.removeEventListener("ratechange", onRateChange);
}, []);
const selectSpeed = (rate: number) => {
const video = videoRef.current;
if (!video) return;
video.playbackRate = rate;
setSpeed(rate);
};
return (
<Dropdown
iconAction
hideToggleArrow
buttonClassName="speed-dropdown"
text={<>
<Icon icon="bx bx-tachometer" />
<span class="video-speed-label">{speed}x</span>
</>}
title={t("video.playback-speed")}
>
{PLAYBACK_SPEEDS.map((rate) => (
<li key={rate}>
<button
class={`dropdown-item ${rate === speed ? "active" : ""}`}
onClick={() => selectSpeed(rate)}
>
{rate}x
</button>
</li>
))}
</Dropdown>
);
}
function LoopButton({ videoRef }: { videoRef: RefObject<HTMLVideoElement> }) {
const [loop, setLoop] = useState(() => videoRef.current?.loop ?? false);
useEffect(() => {
const video = videoRef.current;
if (!video) return;
setLoop(video.loop);
const observer = new MutationObserver(() => setLoop(video.loop));
observer.observe(video, { attributes: true, attributeFilter: ["loop"] });
return () => observer.disconnect();
}, []);
const toggle = () => {
const video = videoRef.current;
if (!video) return;
video.loop = !video.loop;
setLoop(video.loop);
};
return (
<ActionButton
className={loop ? "active" : ""}
icon="bx bx-repeat"
text={loop ? t("video.disable-loop") : t("video.loop")}
onClick={toggle}
/>
);
}
function RotateButton({ videoRef }: { videoRef: RefObject<HTMLVideoElement> }) {
const [rotation, setRotation] = useState(0);
@ -414,7 +198,7 @@ function RotateButton({ videoRef }: { videoRef: RefObject<HTMLVideoElement> }) {
return (
<ActionButton
icon="bx bx-rotate-right"
text={t("video.rotate")}
text={t("media.rotate")}
onClick={rotate}
/>
);
@ -435,7 +219,7 @@ function ZoomToFitButton({ videoRef }: { videoRef: RefObject<HTMLVideoElement> }
<ActionButton
className={fitted ? "active" : ""}
icon={fitted ? "bx bx-collapse" : "bx bx-expand"}
text={fitted ? t("video.zoom-reset") : t("video.zoom-to-fit")}
text={fitted ? t("media.zoom-reset") : t("media.zoom-to-fit")}
onClick={toggle}
/>
);
@ -460,7 +244,7 @@ function PictureInPictureButton({ videoRef }: { videoRef: RefObject<HTMLVideoEle
video.removeEventListener("enterpictureinpicture", onEnter);
video.removeEventListener("leavepictureinpicture", onLeave);
};
}, [supported]);
}, [ videoRef, supported ]);
if (!supported) return null;
@ -478,7 +262,7 @@ function PictureInPictureButton({ videoRef }: { videoRef: RefObject<HTMLVideoEle
return (
<ActionButton
icon={active ? "bx bx-exit" : "bx bx-window-open"}
text={active ? t("video.exit-picture-in-picture") : t("video.picture-in-picture")}
text={active ? t("media.exit-picture-in-picture") : t("media.picture-in-picture")}
onClick={toggle}
/>
);
@ -507,7 +291,7 @@ function FullscreenButton({ targetRef }: { targetRef: RefObject<HTMLElement> })
return (
<ActionButton
icon={isFullscreen ? "bx bx-exit-fullscreen" : "bx bx-fullscreen"}
text={isFullscreen ? t("video.exit-fullscreen") : t("video.fullscreen")}
text={isFullscreen ? t("media.exit-fullscreen") : t("media.fullscreen")}
onClick={toggleFullscreen}
/>
);