feat(player): clip mode - mark a range of what is playing and cut it into a download
Both players get a scissors in their control row (Plex: the control row; YouTube: the actions and the docked bar), `c`, and `[` / `]` to mark at the playhead. The clip panel lives in the player, so the watched video is the preview: an overview of the whole item and a zoomed strip around the selection whose ends are sliders (arrows step a tenth, Shift a second), Start / End fields with nudges and "to the playhead", the length, a looping Play selection, a name, Cut. Escape closes the panel before the player and the focus goes back to the scissors. A cut is a toast with a Downloads link plus the usual Ready notification. - ui/RangeTrack + RulerTicks: the shared, domain-free time track (regions, keyboard-steppable handles with a 1.5rem hit area, a playhead, a background slot, over a window of the item). The Downloads editor's cuts moved onto it, so they are keyboard-steppable now. - lib/clipRange holds the marking rules (1 s to 10 min, the server's), lib/timecode the editing timecode the editor's private tc/parseTc became. - keymap: a symbol typed through AltGr answers its bare-symbol row - on the HU layout `[` is AltGr+F, which arrives as Ctrl+Alt; letter and Mod rows still never match Ctrl+Alt. - The YouTube stage sits in a wrapper with the panel, so the maximised stage's blur escape matches at any depth. - media-scope: fields on the always-dark player chrome read white in a light theme too.
This commit is contained in:
@@ -28,6 +28,7 @@ import {
|
||||
Minimize2,
|
||||
PanelRightClose,
|
||||
PanelRightOpen,
|
||||
Scissors,
|
||||
Settings,
|
||||
SkipBack,
|
||||
SkipForward,
|
||||
@@ -50,6 +51,8 @@ import {
|
||||
} from "../lib/layerStack";
|
||||
import { trapTab, useDialogChrome } from "./ui/dialogFocus";
|
||||
import DownloadButton from "./DownloadButton";
|
||||
import ClipPanel from "./ClipPanel";
|
||||
import { DEFAULT_CLIP_S, markIn, markOut, openAt, type ClipRange } from "../lib/clipRange";
|
||||
import { api, type Video, type VideoStatus } from "../lib/api";
|
||||
import type { YTPlayer } from "../lib/youtube";
|
||||
import {
|
||||
@@ -338,6 +341,56 @@ export default function PlayerModal({
|
||||
const [currentVideoId, setCurrentVideoId] = useState(active.id);
|
||||
const currentIdRef = useRef(active.id);
|
||||
const navigated = currentVideoId !== active.id;
|
||||
// Clip mode: the range being marked of the video that is playing, or null. Its panel sits under
|
||||
// the video, which is its preview; another video starts without one.
|
||||
const [clip, setClip] = useState<ClipRange | null>(null);
|
||||
const [clipFor, setClipFor] = useState(currentVideoId);
|
||||
if (clipFor !== currentVideoId) {
|
||||
setClipFor(currentVideoId);
|
||||
setClip(null);
|
||||
}
|
||||
const clipOpen = clip != null;
|
||||
// The player's clock, read while the panel is open: the iframe reports no time of its own.
|
||||
const [clipClock, setClipClock] = useState({ now: 0, duration: 0 });
|
||||
useEffect(() => {
|
||||
if (!clipOpen) return;
|
||||
const read = () => {
|
||||
const p = playerRef.current;
|
||||
if (!p || typeof p.getCurrentTime !== "function") return;
|
||||
const now = p.getCurrentTime() || 0;
|
||||
const duration = p.getDuration() || 0;
|
||||
// Paused, the clock stands still: no new state, no re-render of the whole modal.
|
||||
setClipClock((c) => (c.now === now && c.duration === duration ? c : { now, duration }));
|
||||
};
|
||||
read();
|
||||
const iv = window.setInterval(read, 200);
|
||||
return () => window.clearInterval(iv);
|
||||
}, [clipOpen]);
|
||||
// The scissors that opened the panel gets the focus back when it closes.
|
||||
const clipButtonRef = useRef<HTMLButtonElement>(null);
|
||||
const refocusClip = useRef(false);
|
||||
const closeClip = () => {
|
||||
refocusClip.current = true;
|
||||
setClip(null);
|
||||
};
|
||||
useEffect(() => {
|
||||
if (clipOpen || !refocusClip.current) return;
|
||||
refocusClip.current = false;
|
||||
clipButtonRef.current?.focus();
|
||||
}, [clipOpen]);
|
||||
/** Mark where the player is now (`mark` gets the range so far, or null when the panel is shut). The
|
||||
* panel lives under the video, so a big view steps back to the card first. */
|
||||
const markClip = (mark: (r: ClipRange | null, t: number, d: number) => ClipRange) => {
|
||||
const p = playerRef.current;
|
||||
if (!p || typeof p.getCurrentTime !== "function") return;
|
||||
const t = p.getCurrentTime() || 0;
|
||||
const d = p.getDuration() || 0;
|
||||
if (d <= 0) return;
|
||||
if (maximized || isFullscreen) exitBigView();
|
||||
setClipClock({ now: t, duration: d });
|
||||
setClip((r) => mark(r, t, d));
|
||||
};
|
||||
const openClip = () => markClip((_r, t, d) => openAt(t, d));
|
||||
// Title/author of a navigated-to video, read from the player (free, no API call).
|
||||
const [liveData, setLiveData] = useState<{ title?: string; author?: string } | null>(null);
|
||||
// What is playing, by name: the panel's title, and the dialog's name (`titleId`) in either layout.
|
||||
@@ -601,6 +654,13 @@ export default function PlayerModal({
|
||||
return;
|
||||
}
|
||||
dispatch(e, "ytPlayer", {
|
||||
clip: () => {
|
||||
if (clip) closeClip();
|
||||
else openClip();
|
||||
},
|
||||
markIn: () => markClip((r, t, d) => markIn(r ?? openAt(t, d), t, d)),
|
||||
markOut: () =>
|
||||
markClip((r, t, d) => markOut(r ?? openAt(Math.max(0, t - DEFAULT_CLIP_S), d), t, d)),
|
||||
close: (ev) => {
|
||||
// Defer to any layer opened above the player FIRST (a Modal like the download dialog, or an
|
||||
// anchored popover): it takes the Escape and we do nothing — so we don't also step out of
|
||||
@@ -609,6 +669,12 @@ export default function PlayerModal({
|
||||
// stack synchronously, so `anyLayerAbove` alone would miss it and close the player too.
|
||||
if (escapeHandled(ev)) return;
|
||||
if (layerIdRef.current != null && anyLayerAbove(layerIdRef.current)) return;
|
||||
// The clip panel is the player's own layer: it closes before the player does.
|
||||
if (clip) {
|
||||
ev.preventDefault();
|
||||
closeClip();
|
||||
return;
|
||||
}
|
||||
// In fullscreen, let the browser's Esc exit fullscreen only — don't also close the modal.
|
||||
if (fullscreenElement()) return;
|
||||
// Same for our own maximise: Esc steps back out of it before it closes the player.
|
||||
@@ -746,32 +812,35 @@ export default function PlayerModal({
|
||||
}`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div
|
||||
ref={stageRef}
|
||||
// The wheel over the video is its volume, even inside a stacked card that scrolls.
|
||||
data-wheel-volume=""
|
||||
className={`player-stage relative aspect-video w-full bg-black overflow-hidden ${
|
||||
wide ? "flex-1 min-w-0 self-start rounded-l-2xl" : "rounded-t-2xl"
|
||||
} ${maximized ? "player-stage--max" : ""}`}
|
||||
>
|
||||
{/* Hide the iframe entirely on error so YouTube's own error screen can't bleed
|
||||
{/* The video and, in clip mode, the clip panel under it. Always this wrapper, so opening the
|
||||
panel never moves the stage to another parent (that would re-create the YouTube iframe). */}
|
||||
<div className={wide ? "flex-1 min-w-0 self-start" : undefined}>
|
||||
<div
|
||||
ref={stageRef}
|
||||
// The wheel over the video is its volume, even inside a stacked card that scrolls.
|
||||
data-wheel-volume=""
|
||||
className={`player-stage relative aspect-video w-full bg-black overflow-hidden ${
|
||||
wide ? (clipOpen ? "rounded-tl-2xl" : "rounded-l-2xl") : "rounded-t-2xl"
|
||||
} ${maximized ? "player-stage--max" : ""}`}
|
||||
>
|
||||
{/* Hide the iframe entirely on error so YouTube's own error screen can't bleed
|
||||
through our overlay. */}
|
||||
{/* In the big views the video area is inset by the control bar's height (`bottom-14`) so
|
||||
{/* In the big views the video area is inset by the control bar's height (`bottom-14`) so
|
||||
the bar below never overlaps it. The inset lives on THIS wrapper, not on `mountRef`:
|
||||
YT.Player REPLACES the mount element with its iframe (width/height 100% of the parent),
|
||||
which would drop any class on mountRef itself — the iframe then sizes to this wrapper. */}
|
||||
<div
|
||||
className={
|
||||
maximized || isFullscreen
|
||||
? "absolute inset-x-0 top-0 bottom-14"
|
||||
: "relative w-full h-full"
|
||||
}
|
||||
>
|
||||
<div
|
||||
ref={mountRef}
|
||||
className={`w-full h-full ${playerError != null ? "invisible" : ""}`}
|
||||
/>
|
||||
{/* Interaction layer over the CENTRE of the video: catches click (play/pause) + wheel
|
||||
className={
|
||||
maximized || isFullscreen
|
||||
? "absolute inset-x-0 top-0 bottom-14"
|
||||
: "relative w-full h-full"
|
||||
}
|
||||
>
|
||||
<div
|
||||
ref={mountRef}
|
||||
className={`w-full h-full ${playerError != null ? "invisible" : ""}`}
|
||||
/>
|
||||
{/* Interaction layer over the CENTRE of the video: catches click (play/pause) + wheel
|
||||
volume (the wheel event bubbles from here to the modal's listener — over the bare
|
||||
cross-origin iframe it would be swallowed) and stops the iframe stealing keyboard focus.
|
||||
It leaves the top AND bottom edges uncovered so YouTube's edge controls stay reachable,
|
||||
@@ -782,25 +851,25 @@ export default function PlayerModal({
|
||||
gap) — it only yields to `nativeControls`, i.e. once the user opens YouTube's own
|
||||
settings menu (focus moves into the iframe), so that menu is navigable end to end.
|
||||
Hidden on error so the "Open on YouTube" CTA is clickable. */}
|
||||
{playerError == null &&
|
||||
YT_CLICK_LAYER.map((box, i) => (
|
||||
<div
|
||||
key={i}
|
||||
data-testid="yt-click-layer"
|
||||
onClick={() => {
|
||||
focusModal();
|
||||
togglePlay();
|
||||
}}
|
||||
title={t("player.shortcutsHint")}
|
||||
aria-hidden="true"
|
||||
style={box}
|
||||
className={`absolute z-base cursor-pointer ${
|
||||
nativeControls ? "pointer-events-none" : ""
|
||||
}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{/* Our own control bar for the big views (maximise / our fullscreen), where the card's
|
||||
{playerError == null &&
|
||||
YT_CLICK_LAYER.map((box, i) => (
|
||||
<div
|
||||
key={i}
|
||||
data-testid="yt-click-layer"
|
||||
onClick={() => {
|
||||
focusModal();
|
||||
togglePlay();
|
||||
}}
|
||||
title={t("player.shortcutsHint")}
|
||||
aria-hidden="true"
|
||||
style={box}
|
||||
className={`absolute z-base cursor-pointer ${
|
||||
nativeControls ? "pointer-events-none" : ""
|
||||
}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{/* Our own control bar for the big views (maximise / our fullscreen), where the card's
|
||||
controls are off-screen. It's IN the stage (so it survives true fullscreen, where a
|
||||
<body> portal would not) and DOCKED in its own strip: the video area above is inset by
|
||||
this bar's height (see the iframe wrapper's `bottom-14`), so the bar NEVER overlaps YouTube's seek
|
||||
@@ -809,124 +878,154 @@ export default function PlayerModal({
|
||||
seek bar over the same pixels. stopPropagation so a click here doesn't toggle playback;
|
||||
the exit button also re-arms our shortcuts. Both of its dialogs work in true fullscreen:
|
||||
add-to-playlist's popover and download's Modal portal INTO the fullscreen element. */}
|
||||
{isFullscreen && <Toaster overlay order={overlayOrder} />}
|
||||
{(maximized || isFullscreen) && (
|
||||
<div
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="absolute inset-x-0 bottom-0 z-menu flex h-14 items-center justify-start gap-x-3 overflow-x-auto border-t border-white/10 bg-media-chrome/95 px-3 text-sm text-white"
|
||||
>
|
||||
{hasQueue && (
|
||||
<>
|
||||
<PlayerTransportControls
|
||||
variant="docked"
|
||||
index={index}
|
||||
queueLength={queue!.length}
|
||||
autoMode={autoMode}
|
||||
loopMode={loopMode}
|
||||
onPrev={goPrev}
|
||||
onNext={goNext}
|
||||
onCycleAuto={cycleAuto}
|
||||
onCycleLoop={cycleLoop}
|
||||
/>
|
||||
<span className="h-4 w-px bg-white/20" />
|
||||
</>
|
||||
)}
|
||||
{/* Both work in fullscreen now: add-to-playlist's popover and download's Modal both
|
||||
{isFullscreen && <Toaster overlay order={overlayOrder} />}
|
||||
{(maximized || isFullscreen) && (
|
||||
<div
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="absolute inset-x-0 bottom-0 z-menu flex h-14 items-center justify-start gap-x-3 overflow-x-auto border-t border-white/10 bg-media-chrome/95 px-3 text-sm text-white"
|
||||
>
|
||||
{hasQueue && (
|
||||
<>
|
||||
<PlayerTransportControls
|
||||
variant="docked"
|
||||
index={index}
|
||||
queueLength={queue!.length}
|
||||
autoMode={autoMode}
|
||||
loopMode={loopMode}
|
||||
onPrev={goPrev}
|
||||
onNext={goNext}
|
||||
onCycleAuto={cycleAuto}
|
||||
onCycleLoop={cycleLoop}
|
||||
/>
|
||||
<span className="h-4 w-px bg-white/20" />
|
||||
</>
|
||||
)}
|
||||
{/* Both work in fullscreen now: add-to-playlist's popover and download's Modal both
|
||||
portal into the fullscreen element rather than <body> (a body-portaled node is
|
||||
painted underneath the element holding the lock, i.e. invisible). */}
|
||||
{!navigated && <AddToPlaylist videoId={active.id} variant="overlay" />}
|
||||
{!navigated && (
|
||||
<DownloadButton videoId={active.id} title={active.title} variant="overlay" />
|
||||
)}
|
||||
{!navigated && (
|
||||
{!navigated && <AddToPlaylist videoId={active.id} variant="overlay" />}
|
||||
{!navigated && (
|
||||
<DownloadButton videoId={active.id} title={active.title} variant="overlay" />
|
||||
)}
|
||||
{/* The clip panel lives under the video in the card: this steps out of the big view. */}
|
||||
{playerError == null && (
|
||||
<IconButton variant="overlay" onClick={openClip} label={t("clip.open")}>
|
||||
<Scissors />
|
||||
</IconButton>
|
||||
)}
|
||||
{!navigated && (
|
||||
<Button
|
||||
variant="overlay"
|
||||
size="sm"
|
||||
pressed={watched}
|
||||
onClick={() => setWatched(!watched)}
|
||||
label={watched ? t("player.watchedUnmark") : t("player.markWatched")}
|
||||
icon={watched ? <CheckCheck /> : <Check />}
|
||||
>
|
||||
<span className="hidden md:inline">
|
||||
{watched ? t("player.watched") : t("player.markWatched")}
|
||||
</span>
|
||||
</Button>
|
||||
)}
|
||||
<span className="h-4 w-px bg-white/20" />
|
||||
{/* Absent where the browser cannot fullscreen an element (Brave on an iPad): there
|
||||
the window-filling view is the biggest, and YouTube's own button opens the
|
||||
system player. */}
|
||||
{canFullscreenElements() && (
|
||||
<IconButton
|
||||
variant="overlay"
|
||||
onClick={toggleTrueFullscreen}
|
||||
label={isFullscreen ? t("player.exitFullscreen") : t("player.fullscreen")}
|
||||
>
|
||||
{isFullscreen ? <Minimize /> : <Maximize />}
|
||||
</IconButton>
|
||||
)}
|
||||
<Button
|
||||
variant="overlay"
|
||||
size="sm"
|
||||
pressed={watched}
|
||||
onClick={() => setWatched(!watched)}
|
||||
label={watched ? t("player.watchedUnmark") : t("player.markWatched")}
|
||||
icon={watched ? <CheckCheck /> : <Check />}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
exitBigView();
|
||||
setNativeControls(false);
|
||||
focusModal();
|
||||
}}
|
||||
label={t("player.exitMaximize")}
|
||||
icon={<Minimize2 />}
|
||||
>
|
||||
<span className="hidden md:inline">
|
||||
{watched ? t("player.watched") : t("player.markWatched")}
|
||||
</span>
|
||||
<span className="hidden sm:inline">{t("player.exitMaximize")}</span>
|
||||
</Button>
|
||||
)}
|
||||
<span className="h-4 w-px bg-white/20" />
|
||||
{/* Absent where the browser cannot fullscreen an element (Brave on an iPad): there
|
||||
the window-filling view is the biggest, and YouTube's own button opens the
|
||||
system player. */}
|
||||
{canFullscreenElements() && (
|
||||
<IconButton
|
||||
variant="overlay"
|
||||
onClick={toggleTrueFullscreen}
|
||||
label={isFullscreen ? t("player.exitFullscreen") : t("player.fullscreen")}
|
||||
>
|
||||
{isFullscreen ? <Minimize /> : <Maximize />}
|
||||
</IconButton>
|
||||
)}
|
||||
<Button
|
||||
variant="overlay"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
exitBigView();
|
||||
setNativeControls(false);
|
||||
focusModal();
|
||||
}}
|
||||
label={t("player.exitMaximize")}
|
||||
icon={<Minimize2 />}
|
||||
>
|
||||
<span className="hidden sm:inline">{t("player.exitMaximize")}</span>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{/* Discreet badge while YouTube's own controls have taken over (overlay yielded). */}
|
||||
{playerError == null && nativeControls && (
|
||||
<div className="pointer-events-none absolute top-3 left-3 z-menu flex items-center gap-1.5 rounded-full bg-black/60 px-2.5 py-1 text-2xs text-white/90 shadow-lg backdrop-blur-xs">
|
||||
<Settings className="h-3.5 w-3.5" />
|
||||
{t("player.nativeControls")}
|
||||
</div>
|
||||
)}
|
||||
{/* Volume level flash (auto-fades). pointer-events-none so it never blocks the video. */}
|
||||
{volumeUi != null && (
|
||||
<div className="pointer-events-none absolute top-3 left-1/2 -translate-x-1/2 z-menu flex items-center gap-2 rounded-full bg-black/70 px-3 py-1.5 text-xs text-white shadow-lg">
|
||||
{volumeUi === 0 ? <VolumeX className="h-4 w-4" /> : <Volume2 className="h-4 w-4" />}
|
||||
<div className="h-1.5 w-28 overflow-hidden rounded-full bg-white/25">
|
||||
<div
|
||||
className="h-full rounded-full bg-white transition-all"
|
||||
style={{ width: `${volumeUi}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="w-7 text-right tabular-nums">{volumeUi}</span>
|
||||
</div>
|
||||
)}
|
||||
{playerError != null && (
|
||||
<div className="absolute inset-0 grid place-items-center bg-bg p-6 text-center">
|
||||
<div className="max-w-sm">
|
||||
<AlertTriangle className="w-8 h-8 text-warn mx-auto mb-3" />
|
||||
<h3 className="text-base font-semibold">{t("player.unavailableTitle")}</h3>
|
||||
<p className="text-sm text-muted mt-1.5 mb-4">
|
||||
{playerError === YT_API_LOAD_FAILED
|
||||
? t("player.apiLoadFailedBody")
|
||||
: playerError === 101 || playerError === 150
|
||||
? t("player.embedDisabledBody")
|
||||
: t("player.unavailableBody")}
|
||||
</p>
|
||||
<LinkButton
|
||||
href={`https://www.youtube.com/watch?v=${currentVideoId}`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
variant="primary"
|
||||
icon={<ExternalLink />}
|
||||
>
|
||||
{t("player.openOnYoutube")}
|
||||
</LinkButton>
|
||||
)}
|
||||
{/* Discreet badge while YouTube's own controls have taken over (overlay yielded). */}
|
||||
{playerError == null && nativeControls && (
|
||||
<div className="pointer-events-none absolute top-3 left-3 z-menu flex items-center gap-1.5 rounded-full bg-black/60 px-2.5 py-1 text-2xs text-white/90 shadow-lg backdrop-blur-xs">
|
||||
<Settings className="h-3.5 w-3.5" />
|
||||
{t("player.nativeControls")}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* Volume level flash (auto-fades). pointer-events-none so it never blocks the video. */}
|
||||
{volumeUi != null && (
|
||||
<div className="pointer-events-none absolute top-3 left-1/2 -translate-x-1/2 z-menu flex items-center gap-2 rounded-full bg-black/70 px-3 py-1.5 text-xs text-white shadow-lg">
|
||||
{volumeUi === 0 ? (
|
||||
<VolumeX className="h-4 w-4" />
|
||||
) : (
|
||||
<Volume2 className="h-4 w-4" />
|
||||
)}
|
||||
<div className="h-1.5 w-28 overflow-hidden rounded-full bg-white/25">
|
||||
<div
|
||||
className="h-full rounded-full bg-white transition-all"
|
||||
style={{ width: `${volumeUi}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="w-7 text-right tabular-nums">{volumeUi}</span>
|
||||
</div>
|
||||
)}
|
||||
{playerError != null && (
|
||||
<div className="absolute inset-0 grid place-items-center bg-bg p-6 text-center">
|
||||
<div className="max-w-sm">
|
||||
<AlertTriangle className="w-8 h-8 text-warn mx-auto mb-3" />
|
||||
<h3 className="text-base font-semibold">{t("player.unavailableTitle")}</h3>
|
||||
<p className="text-sm text-muted mt-1.5 mb-4">
|
||||
{playerError === YT_API_LOAD_FAILED
|
||||
? t("player.apiLoadFailedBody")
|
||||
: playerError === 101 || playerError === 150
|
||||
? t("player.embedDisabledBody")
|
||||
: t("player.unavailableBody")}
|
||||
</p>
|
||||
<LinkButton
|
||||
href={`https://www.youtube.com/watch?v=${currentVideoId}`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
variant="primary"
|
||||
icon={<ExternalLink />}
|
||||
>
|
||||
{t("player.openOnYoutube")}
|
||||
</LinkButton>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{clip && !maximized && !isFullscreen && (
|
||||
<ClipPanel
|
||||
src={{ source: "youtube", ref: currentVideoId }}
|
||||
clip={clip}
|
||||
onChange={setClip}
|
||||
duration={clipClock.duration}
|
||||
current={clipClock.now}
|
||||
onSeek={(time, final) => {
|
||||
// A seek past what is buffered fetches from there: only once the drag settles.
|
||||
playerRef.current?.seekTo?.(time, final);
|
||||
setClipClock((c) => ({ ...c, now: time }));
|
||||
}}
|
||||
onPlayFrom={(time) => {
|
||||
seekTo(time);
|
||||
setClipClock((c) => ({ ...c, now: time }));
|
||||
}}
|
||||
onClose={closeClip}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Wide: the side panel. Its content scrolls inside a box the video's height, so a long
|
||||
description never makes the card taller than the video. */}
|
||||
<div
|
||||
@@ -1201,6 +1300,19 @@ export default function PlayerModal({
|
||||
variant="outline"
|
||||
size="md"
|
||||
/>
|
||||
{playerError == null && (
|
||||
<IconButton
|
||||
ref={clipButtonRef}
|
||||
variant="outline"
|
||||
size="md"
|
||||
pressed={clipOpen}
|
||||
onClick={() => (clip ? closeClip() : openClip())}
|
||||
label={t("clip.open")}
|
||||
title={`${t("clip.open")} · C`}
|
||||
>
|
||||
<Scissors />
|
||||
</IconButton>
|
||||
)}
|
||||
{onRemoveFromList && (
|
||||
<IconButton
|
||||
variant="outline-danger"
|
||||
|
||||
Reference in New Issue
Block a user