From 365d0f0aac22ffd248d18a8939d63085cb6b01b6 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Wed, 11 Mar 2026 19:17:14 +0200 Subject: [PATCH] feat(audio): introduce playback speed --- .../src/widgets/type_widgets/file/Audio.tsx | 6 ++- .../widgets/type_widgets/file/MediaPlayer.css | 19 +++++++ .../widgets/type_widgets/file/MediaPlayer.tsx | 50 ++++++++++++++++++ .../src/widgets/type_widgets/file/Video.css | 19 ------- .../src/widgets/type_widgets/file/Video.tsx | 52 +------------------ 5 files changed, 75 insertions(+), 71 deletions(-) diff --git a/apps/client/src/widgets/type_widgets/file/Audio.tsx b/apps/client/src/widgets/type_widgets/file/Audio.tsx index 32b67ac1b3..3d48479977 100644 --- a/apps/client/src/widgets/type_widgets/file/Audio.tsx +++ b/apps/client/src/widgets/type_widgets/file/Audio.tsx @@ -3,7 +3,7 @@ import { useRef, useState } from "preact/hooks"; import FNote from "../../../entities/fnote"; import { t } from "../../../services/i18n"; import { getUrlForDownload } from "../../../services/open"; -import { LoopButton, PlayPauseButton, SeekBar, SkipButton, VolumeControl } from "./MediaPlayer"; +import { LoopButton, PlaybackSpeed, PlayPauseButton, SeekBar, SkipButton, VolumeControl } from "./MediaPlayer"; export default function AudioPreview({ note }: { note: FNote }) { const [playing, setPlaying] = useState(false); @@ -22,7 +22,9 @@ export default function AudioPreview({ note }: { note: FNote }) {
-
+
+ +
diff --git a/apps/client/src/widgets/type_widgets/file/MediaPlayer.css b/apps/client/src/widgets/type_widgets/file/MediaPlayer.css index f387b41ebd..1f1bdad0f0 100644 --- a/apps/client/src/widgets/type_widgets/file/MediaPlayer.css +++ b/apps/client/src/widgets/type_widgets/file/MediaPlayer.css @@ -66,4 +66,23 @@ 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; + } + } } diff --git a/apps/client/src/widgets/type_widgets/file/MediaPlayer.tsx b/apps/client/src/widgets/type_widgets/file/MediaPlayer.tsx index 6985773214..66aeffae80 100644 --- a/apps/client/src/widgets/type_widgets/file/MediaPlayer.tsx +++ b/apps/client/src/widgets/type_widgets/file/MediaPlayer.tsx @@ -5,6 +5,8 @@ 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 }) { const [currentTime, setCurrentTime] = useState(0); @@ -176,3 +178,51 @@ export function LoopButton({ mediaRef }: { mediaRef: RefObject ); } + +const PLAYBACK_SPEEDS = [0.5, 1, 1.25, 1.5, 2]; + +export function PlaybackSpeed({ mediaRef }: { mediaRef: RefObject }) { + 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); + }, []); + + const selectSpeed = (rate: number) => { + const media = mediaRef.current; + if (!media) return; + media.playbackRate = rate; + setSpeed(rate); + }; + + return ( + + + {speed}x + } + title={t("video.playback-speed")} + > + {PLAYBACK_SPEEDS.map((rate) => ( +
  • + +
  • + ))} +
    + ); +} diff --git a/apps/client/src/widgets/type_widgets/file/Video.css b/apps/client/src/widgets/type_widgets/file/Video.css index 9224c02752..af6d9cca53 100644 --- a/apps/client/src/widgets/type_widgets/file/Video.css +++ b/apps/client/src/widgets/type_widgets/file/Video.css @@ -25,23 +25,4 @@ opacity: 1; transition: opacity 300ms ease; } - - .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; - } - } } diff --git a/apps/client/src/widgets/type_widgets/file/Video.tsx b/apps/client/src/widgets/type_widgets/file/Video.tsx index 41654140fc..efd01e11ac 100644 --- a/apps/client/src/widgets/type_widgets/file/Video.tsx +++ b/apps/client/src/widgets/type_widgets/file/Video.tsx @@ -10,7 +10,7 @@ import ActionButton from "../../react/ActionButton"; import Dropdown from "../../react/Dropdown"; import Icon from "../../react/Icon"; import NoItems from "../../react/NoItems"; -import { LoopButton, PlayPauseButton, SeekBar, SkipButton, VolumeControl } from "./MediaPlayer"; +import { LoopButton, PlaybackSpeed, PlayPauseButton, SeekBar, SkipButton, VolumeControl } from "./MediaPlayer"; const AUTO_HIDE_DELAY = 3000; @@ -117,7 +117,7 @@ export default function VideoPreview({ note }: { note: FNote }) {
    - +
    @@ -169,54 +169,6 @@ function useAutoHideControls(videoRef: RefObject, playing: boo return { visible, onMouseMove, flash: onMouseMove }; } -const PLAYBACK_SPEEDS = [0.5, 1, 1.25, 1.5, 2]; - -function PlaybackSpeed({ videoRef }: { videoRef: RefObject }) { - 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 ( - - - {speed}x - } - title={t("video.playback-speed")} - > - {PLAYBACK_SPEEDS.map((rate) => ( -
  • - -
  • - ))} -
    - ); -} - function RotateButton({ videoRef }: { videoRef: RefObject }) { const [rotation, setRotation] = useState(0);