mirror of
https://github.com/zadam/trilium.git
synced 2026-03-13 20:03:36 +01:00
Audio player improvements (#9008)
This commit is contained in:
commit
5e8de14721
@ -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"
|
||||
},
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
@ -8,6 +8,7 @@
|
||||
color: var(--muted-text-color);
|
||||
height: 100%;
|
||||
text-align: center;
|
||||
white-space: pre-line;
|
||||
|
||||
.tn-icon {
|
||||
font-size: 4em;
|
||||
|
||||
@ -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">
|
||||
|
||||
112
apps/client/src/widgets/type_widgets/file/Audio.tsx
Normal file
112
apps/client/src/widgets/type_widgets/file/Audio.tsx
Normal 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 ]);
|
||||
}
|
||||
98
apps/client/src/widgets/type_widgets/file/MediaPlayer.css
Normal file
98
apps/client/src/widgets/type_widgets/file/MediaPlayer.css
Normal 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;
|
||||
}
|
||||
}
|
||||
220
apps/client/src/widgets/type_widgets/file/MediaPlayer.tsx
Normal file
220
apps/client/src/widgets/type_widgets/file/MediaPlayer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user