feat: fullscreen carries across videos, and stays out of the way
In fullscreen, Next now keeps you there and shows nothing but the picture: one video after another, no arrows, no header, no counter. Three things had to change for that. The player was keyed on the video, so every Next rebuilt the stage — and the stage is what is fullscreen. It now survives, resetting its own per-video state instead of being thrown away to get it. The cleanup that ran on every change of source called exitFullscreen outright. It exists for the Picture-in-Picture leak, which does need handling per video; leaving fullscreen and dropping the source belong to leaving the player, and are now only done there. And resolving a stream set the source to nothing first, which unmounted the element mid-fullscreen. The outgoing video is paused in place instead, and the source goes straight from one address to the next. The edge arrows are gone in fullscreen. The transport bar stays — it is playback, not navigation — and fades on its own as before. Also: Remove all downloads in the menu bar panel, which asks in the window rather than in a panel that closes when you look away; and the JavaScript round-trip logging is gone now that the scrape is settled. Verified in the running app: three videos in a row without leaving fullscreen, chrome faded to nothing, and Remove all reaching the window's own "Delete every download?" with 50 downloads left untouched.
This commit is contained in:
+3
-12
@@ -129,15 +129,6 @@ async fn browser_youtube_url(app: &AppHandle) -> Result<String, String> {
|
|||||||
/// Arc allows this out of the box. Chrome and its relatives ship with it off,
|
/// Arc allows this out of the box. Chrome and its relatives ship with it off,
|
||||||
/// and say so in the error, which is passed straight back rather than being
|
/// and say so in the error, which is passed straight back rather than being
|
||||||
/// flattened into "something went wrong".
|
/// flattened into "something went wrong".
|
||||||
pub async fn run_js_logged(app: &AppHandle, browser: &str, js: &str) -> Result<String, String> {
|
|
||||||
let r = run_js(browser, js).await;
|
|
||||||
match &r {
|
|
||||||
Ok(out) => log(app, &format!("js ok, {} chars back", out.len())),
|
|
||||||
Err(e) => log(app, &format!("js failed: {e}")),
|
|
||||||
}
|
|
||||||
r
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn run_js(browser: &str, js: &str) -> Result<String, String> {
|
pub async fn run_js(browser: &str, js: &str) -> Result<String, String> {
|
||||||
let safari = browser == "Safari";
|
let safari = browser == "Safari";
|
||||||
let script = if safari {
|
let script = if safari {
|
||||||
@@ -211,7 +202,7 @@ pub async fn scrape_subscriptions(app: &AppHandle) -> Result<subscriptions::Scra
|
|||||||
let mut last = 0usize;
|
let mut last = 0usize;
|
||||||
let mut settled = 0;
|
let mut settled = 0;
|
||||||
for round in 0..40 {
|
for round in 0..40 {
|
||||||
let n = run_js_logged(app, &browser, subscriptions::SCROLL_JS)
|
let n = run_js(&browser, subscriptions::SCROLL_JS)
|
||||||
.await?
|
.await?
|
||||||
.parse::<usize>()
|
.parse::<usize>()
|
||||||
.unwrap_or(0);
|
.unwrap_or(0);
|
||||||
@@ -232,7 +223,7 @@ pub async fn scrape_subscriptions(app: &AppHandle) -> Result<subscriptions::Scra
|
|||||||
tokio::time::sleep(std::time::Duration::from_millis(900)).await;
|
tokio::time::sleep(std::time::Duration::from_millis(900)).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
let raw = run_js_logged(app, &browser, subscriptions::EXTRACT_JS).await?;
|
let raw = run_js(&browser, subscriptions::EXTRACT_JS).await?;
|
||||||
let rows = subscriptions::parse_rows(&raw);
|
let rows = subscriptions::parse_rows(&raw);
|
||||||
log(app, &format!("scraped {} channels from {browser}", rows.len()));
|
log(app, &format!("scraped {} channels from {browser}", rows.len()));
|
||||||
if rows.is_empty() {
|
if rows.is_empty() {
|
||||||
@@ -366,7 +357,7 @@ pub fn quit_app(app: AppHandle) {
|
|||||||
|
|
||||||
/// The panel's own size. Fixed, because it is a menu: it does not resize.
|
/// The panel's own size. Fixed, because it is a menu: it does not resize.
|
||||||
const PANEL_W: f64 = 304.0;
|
const PANEL_W: f64 = 304.0;
|
||||||
const PANEL_H: f64 = 252.0;
|
const PANEL_H: f64 = 296.0;
|
||||||
|
|
||||||
/// Opens the panel under the menu bar icon.
|
/// Opens the panel under the menu bar icon.
|
||||||
///
|
///
|
||||||
|
|||||||
+13
-1
@@ -359,6 +359,15 @@ export default function App() {
|
|||||||
});
|
});
|
||||||
}, [quality, embedLang]);
|
}, [quality, embedLang]);
|
||||||
|
|
||||||
|
// The menu bar can ask to clear the downloads; the answering is done here,
|
||||||
|
// by the same confirmation the toolbar uses.
|
||||||
|
useEffect(() => {
|
||||||
|
const un = listen("downloads:wipe-request", () => setConfirmWipe(true));
|
||||||
|
return () => {
|
||||||
|
void un.then((f) => f());
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
// The menu bar reads the subscription list, but replacing what is here is
|
// The menu bar reads the subscription list, but replacing what is here is
|
||||||
// not a thing to agree to in a panel that closes when you look away.
|
// not a thing to agree to in a panel that closes when you look away.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -742,9 +751,12 @@ export default function App() {
|
|||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* The player is deliberately not keyed on the video. Remounting would
|
||||||
|
build a new stage element, and the stage is what is fullscreen — so
|
||||||
|
every Next dropped out of fullscreen. It resets its own per-video
|
||||||
|
state instead. */}
|
||||||
{playing && playingIndex != null && (
|
{playing && playingIndex != null && (
|
||||||
<Player
|
<Player
|
||||||
key={playing.item.id}
|
|
||||||
item={playing.item}
|
item={playing.item}
|
||||||
path={playing.path}
|
path={playing.path}
|
||||||
index={playingIndex}
|
index={playingIndex}
|
||||||
|
|||||||
+194
-42
@@ -1,9 +1,20 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import {
|
import {
|
||||||
embeddedSubtitles, fetchSubtitles, fileUrl, listSubtitles, openExternal, resolveStream,
|
embeddedSubtitles,
|
||||||
|
fetchSubtitles,
|
||||||
|
fileUrl,
|
||||||
|
listSubtitles,
|
||||||
|
openExternal,
|
||||||
|
resolveStream,
|
||||||
savePlayback,
|
savePlayback,
|
||||||
} from "../api";
|
} from "../api";
|
||||||
import { DEFAULT_SUB_LANG, SUB_FONTS, SUB_PLACES, type FeedItem, type SubStyle } from "../types";
|
import {
|
||||||
|
DEFAULT_SUB_LANG,
|
||||||
|
SUB_FONTS,
|
||||||
|
SUB_PLACES,
|
||||||
|
type FeedItem,
|
||||||
|
type SubStyle,
|
||||||
|
} from "../types";
|
||||||
import { compactViews, relativeTime, subtitleLabel } from "./format";
|
import { compactViews, relativeTime, subtitleLabel } from "./format";
|
||||||
import PlayerControls from "./PlayerControls";
|
import PlayerControls from "./PlayerControls";
|
||||||
import { Badge, BTN, Dialog, Spinner } from "./ui";
|
import { Badge, BTN, Dialog, Spinner } from "./ui";
|
||||||
@@ -39,20 +50,22 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Releases a <video> completely.
|
* Ends any Picture-in-Picture session on a <video>.
|
||||||
*
|
*
|
||||||
* Detaching the element is not enough: WebKit keeps a Picture-in-Picture
|
* Detaching the element is not enough: WebKit keeps the session (and its
|
||||||
* session (and its audio) running after the element leaves the DOM, so closing
|
* audio) running after the element leaves the DOM, so stepping to the next
|
||||||
* the player or stepping to the next video would leave the previous one playing
|
* video would leave the previous one playing with no way to stop it.
|
||||||
* with no way to stop it. Every exit path goes through here.
|
|
||||||
*/
|
*/
|
||||||
function teardown(v: HTMLVideoElement | null) {
|
function releasePiP(v: HTMLVideoElement | null) {
|
||||||
if (!v) return;
|
if (!v) return;
|
||||||
// Safari's PiP is the non-standard presentation-mode API; the spec one is
|
// Safari's PiP is the non-standard presentation-mode API; the spec one is
|
||||||
// tried too, since either may be the live implementation.
|
// tried too, since either may be the live implementation.
|
||||||
const webkit = v as WebkitVideo;
|
const webkit = v as WebkitVideo;
|
||||||
try {
|
try {
|
||||||
if (webkit.webkitPresentationMode && webkit.webkitPresentationMode !== "inline") {
|
if (
|
||||||
|
webkit.webkitPresentationMode &&
|
||||||
|
webkit.webkitPresentationMode !== "inline"
|
||||||
|
) {
|
||||||
webkit.webkitSetPresentationMode?.("inline");
|
webkit.webkitSetPresentationMode?.("inline");
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
@@ -68,6 +81,18 @@ function teardown(v: HTMLVideoElement | null) {
|
|||||||
} catch {
|
} catch {
|
||||||
/* not supported here */
|
/* not supported here */
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Releases the element for good, on the way out of the player.
|
||||||
|
*
|
||||||
|
* Separate from the above because it also leaves fullscreen, and this used to
|
||||||
|
* run on every change of video: stepping to the next one dropped you out of
|
||||||
|
* fullscreen every time, which is the opposite of watching one after another.
|
||||||
|
*/
|
||||||
|
function teardown(v: HTMLVideoElement | null) {
|
||||||
|
if (!v) return;
|
||||||
|
releasePiP(v);
|
||||||
try {
|
try {
|
||||||
if (document.fullscreenElement) void document.exitFullscreen();
|
if (document.fullscreenElement) void document.exitFullscreen();
|
||||||
} catch {
|
} catch {
|
||||||
@@ -157,9 +182,24 @@ function placeCues(vtt: string, line: number | null): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function Player({
|
export default function Player({
|
||||||
item, path, onClose, onDelete, onPrev, onNext, onDownload, downloading,
|
item,
|
||||||
maxHeight, subLang, onSubLang, subStyle, onSubStyle, onOpenChannel, autoplayNext,
|
path,
|
||||||
index, total, titleBarInset,
|
onClose,
|
||||||
|
onDelete,
|
||||||
|
onPrev,
|
||||||
|
onNext,
|
||||||
|
onDownload,
|
||||||
|
downloading,
|
||||||
|
maxHeight,
|
||||||
|
subLang,
|
||||||
|
onSubLang,
|
||||||
|
subStyle,
|
||||||
|
onSubStyle,
|
||||||
|
onOpenChannel,
|
||||||
|
autoplayNext,
|
||||||
|
index,
|
||||||
|
total,
|
||||||
|
titleBarInset,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const streaming = path === null;
|
const streaming = path === null;
|
||||||
const [src, setSrc] = useState<string | null>(path ? fileUrl(path) : null);
|
const [src, setSrc] = useState<string | null>(path ? fileUrl(path) : null);
|
||||||
@@ -243,7 +283,8 @@ export default function Player({
|
|||||||
// Placement is a WebVTT cue setting, not something CSS can reach, so it is
|
// Placement is a WebVTT cue setting, not something CSS can reach, so it is
|
||||||
// written into the cues themselves. Re-cut whenever the choice changes.
|
// written into the cues themselves. Re-cut whenever the choice changes.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const line = SUB_PLACES.find((p) => p.value === subStyle.place)?.line ?? null;
|
const line =
|
||||||
|
SUB_PLACES.find((p) => p.value === subStyle.place)?.line ?? null;
|
||||||
const urls: string[] = [];
|
const urls: string[] = [];
|
||||||
setTracks(
|
setTracks(
|
||||||
rawTracks.map(([lang, text]) => {
|
rawTracks.map(([lang, text]) => {
|
||||||
@@ -259,6 +300,24 @@ export default function Player({
|
|||||||
};
|
};
|
||||||
}, [rawTracks, subStyle.place]);
|
}, [rawTracks, subStyle.place]);
|
||||||
|
|
||||||
|
// What a remount used to clear. Kept in one place so a new video starts as
|
||||||
|
// clean as it would have, without throwing the stage away to get there.
|
||||||
|
useEffect(() => {
|
||||||
|
setError(null);
|
||||||
|
setBuffering(true);
|
||||||
|
setShowDescription(false);
|
||||||
|
}, [item.id]);
|
||||||
|
|
||||||
|
// Fullscreen belongs to the stage, which now outlives the video. Whether it
|
||||||
|
// is on decides whether anything to click is shown at all.
|
||||||
|
const [fullscreen, setFullscreen] = useState(false);
|
||||||
|
useEffect(() => {
|
||||||
|
const sync = () => setFullscreen(!!document.fullscreenElement);
|
||||||
|
sync();
|
||||||
|
document.addEventListener("fullscreenchange", sync);
|
||||||
|
return () => document.removeEventListener("fullscreenchange", sync);
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Controls and edge arrows fade away while you are just watching.
|
// Controls and edge arrows fade away while you are just watching.
|
||||||
const [chromeVisible, setChromeVisible] = useState(true);
|
const [chromeVisible, setChromeVisible] = useState(true);
|
||||||
const hideTimer = useRef<number | undefined>(undefined);
|
const hideTimer = useRef<number | undefined>(undefined);
|
||||||
@@ -279,8 +338,11 @@ export default function Player({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
setSrc(null);
|
|
||||||
setError(null);
|
setError(null);
|
||||||
|
// Quiet the outgoing video, but leave it in place: clearing the source
|
||||||
|
// would unmount the element mid-fullscreen.
|
||||||
|
videoRef.current?.pause();
|
||||||
|
setBuffering(true);
|
||||||
resolveStream(item.id, maxHeight)
|
resolveStream(item.id, maxHeight)
|
||||||
.then((u) => !cancelled && setSrc(u))
|
.then((u) => !cancelled && setSrc(u))
|
||||||
.catch((e) => !cancelled && setError(String(e)));
|
.catch((e) => !cancelled && setError(String(e)));
|
||||||
@@ -289,7 +351,6 @@ export default function Player({
|
|||||||
};
|
};
|
||||||
}, [item.id, path, maxHeight]);
|
}, [item.id, path, maxHeight]);
|
||||||
|
|
||||||
|
|
||||||
const persist = useCallback(() => {
|
const persist = useCallback(() => {
|
||||||
const v = videoRef.current;
|
const v = videoRef.current;
|
||||||
if (!v || !Number.isFinite(v.duration) || v.duration <= 0) return;
|
if (!v || !Number.isFinite(v.duration) || v.duration <= 0) return;
|
||||||
@@ -305,9 +366,15 @@ export default function Player({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const v = videoRef.current;
|
const v = videoRef.current;
|
||||||
return () => teardown(v);
|
return () => releasePiP(v);
|
||||||
}, [src]);
|
}, [src]);
|
||||||
|
|
||||||
|
// Leaving the player is the only place the element is released outright.
|
||||||
|
useEffect(() => {
|
||||||
|
const v = videoRef.current;
|
||||||
|
return () => teardown(v);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const onTimeUpdate = () => {
|
const onTimeUpdate = () => {
|
||||||
// Frames are flowing, so whatever the media events claimed, we are not
|
// Frames are flowing, so whatever the media events claimed, we are not
|
||||||
// buffering. A resume-seek can fire `waiting` after `playing` and leave the
|
// buffering. A resume-seek can fire `waiting` after `playing` and leave the
|
||||||
@@ -326,7 +393,8 @@ export default function Player({
|
|||||||
const v = videoRef.current;
|
const v = videoRef.current;
|
||||||
const at = item.position ?? 0;
|
const at = item.position ?? 0;
|
||||||
if (!v || !Number.isFinite(v.duration)) return;
|
if (!v || !Number.isFinite(v.duration)) return;
|
||||||
if (at > RESUME_EDGE_S && at < v.duration - RESUME_EDGE_S) v.currentTime = at;
|
if (at > RESUME_EDGE_S && at < v.duration - RESUME_EDGE_S)
|
||||||
|
v.currentTime = at;
|
||||||
};
|
};
|
||||||
|
|
||||||
const leave = useCallback(() => onClose(), [onClose]);
|
const leave = useCallback(() => onClose(), [onClose]);
|
||||||
@@ -340,8 +408,16 @@ export default function Player({
|
|||||||
if (e.key === "Escape" && !document.fullscreenElement) leave();
|
if (e.key === "Escape" && !document.fullscreenElement) leave();
|
||||||
// Moving between videos. Shift with the arrows because the bare ones
|
// Moving between videos. Shift with the arrows because the bare ones
|
||||||
// seek, and shift with N and P because that is what YouTube uses.
|
// seek, and shift with N and P because that is what YouTube uses.
|
||||||
if (e.shiftKey && (e.key === "ArrowLeft" || e.key === "P" || e.key === "p")) onPrev?.();
|
if (
|
||||||
if (e.shiftKey && (e.key === "ArrowRight" || e.key === "N" || e.key === "n")) onNext?.();
|
e.shiftKey &&
|
||||||
|
(e.key === "ArrowLeft" || e.key === "P" || e.key === "p")
|
||||||
|
)
|
||||||
|
onPrev?.();
|
||||||
|
if (
|
||||||
|
e.shiftKey &&
|
||||||
|
(e.key === "ArrowRight" || e.key === "N" || e.key === "n")
|
||||||
|
)
|
||||||
|
onNext?.();
|
||||||
};
|
};
|
||||||
window.addEventListener("keydown", onKey);
|
window.addEventListener("keydown", onKey);
|
||||||
return () => window.removeEventListener("keydown", onKey);
|
return () => window.removeEventListener("keydown", onKey);
|
||||||
@@ -386,17 +462,41 @@ export default function Player({
|
|||||||
className="flex items-center gap-2 border-b border-slate-200 bg-white px-4 py-3
|
className="flex items-center gap-2 border-b border-slate-200 bg-white px-4 py-3
|
||||||
dark:border-slate-800 dark:bg-slate-900"
|
dark:border-slate-800 dark:bg-slate-900"
|
||||||
>
|
>
|
||||||
<button onClick={leave} title="Back to the feed (Esc)" aria-label="Back"
|
<button
|
||||||
className={navIcon}>
|
onClick={leave}
|
||||||
<svg viewBox="0 0 24 24" className="size-4" fill="none" stroke="currentColor" strokeWidth="2">
|
title="Back to the feed (Esc)"
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 6l-6 6 6 6" />
|
aria-label="Back"
|
||||||
|
className={navIcon}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
className="size-4"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M15 6l-6 6 6 6"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button onClick={onPrev} disabled={!onPrev} title="Previous video" className={navBtn}>
|
<button
|
||||||
|
onClick={onPrev}
|
||||||
|
disabled={!onPrev}
|
||||||
|
title="Previous video"
|
||||||
|
className={navBtn}
|
||||||
|
>
|
||||||
‹ Prev
|
‹ Prev
|
||||||
</button>
|
</button>
|
||||||
<button onClick={onNext} disabled={!onNext} title="Next video" className={navBtn}>
|
<button
|
||||||
|
onClick={onNext}
|
||||||
|
disabled={!onNext}
|
||||||
|
title="Next video"
|
||||||
|
className={navBtn}
|
||||||
|
>
|
||||||
Next ›
|
Next ›
|
||||||
</button>
|
</button>
|
||||||
<span className="font-mono text-[11px] tabular-nums text-slate-400 dark:text-slate-500">
|
<span className="font-mono text-[11px] tabular-nums text-slate-400 dark:text-slate-500">
|
||||||
@@ -421,7 +521,10 @@ export default function Player({
|
|||||||
error ? (
|
error ? (
|
||||||
<Badge tone="danger">Unavailable</Badge>
|
<Badge tone="danger">Unavailable</Badge>
|
||||||
) : src && !buffering ? (
|
) : src && !buffering ? (
|
||||||
<Badge tone="accent" title={`Streaming at ${height || "an unknown"}p`}>
|
<Badge
|
||||||
|
tone="accent"
|
||||||
|
title={`Streaming at ${height || "an unknown"}p`}
|
||||||
|
>
|
||||||
Streaming{height ? ` · ${height}p` : ""}
|
Streaming{height ? ` · ${height}p` : ""}
|
||||||
</Badge>
|
</Badge>
|
||||||
) : (
|
) : (
|
||||||
@@ -447,12 +550,19 @@ export default function Player({
|
|||||||
ref={stageRef}
|
ref={stageRef}
|
||||||
onContextMenu={(e) => e.preventDefault()}
|
onContextMenu={(e) => e.preventDefault()}
|
||||||
onMouseMove={showChrome}
|
onMouseMove={showChrome}
|
||||||
onMouseLeave={() => !videoRef.current?.paused && setChromeVisible(false)}
|
onMouseLeave={() =>
|
||||||
|
!videoRef.current?.paused && setChromeVisible(false)
|
||||||
|
}
|
||||||
className={`relative min-h-0 flex-1 bg-slate-950 ${chromeVisible ? "" : "cursor-none"}`}
|
className={`relative min-h-0 flex-1 bg-slate-950 ${chromeVisible ? "" : "cursor-none"}`}
|
||||||
>
|
>
|
||||||
{/* Edge arrows, the way a player wants them: big targets on the left and
|
{/* Edge arrows, the way a player wants them: big targets on the left and
|
||||||
right of the picture. They fade in on hover so they never sit on top
|
right of the picture. They fade in on hover so they never sit on top
|
||||||
of the video while you are watching it. */}
|
of the video while you are watching it.
|
||||||
|
|
||||||
|
Gone entirely in fullscreen. Nothing to navigate with there: it
|
||||||
|
plays one video after another and shows only the picture. */}
|
||||||
|
{!fullscreen && (
|
||||||
|
<>
|
||||||
<button
|
<button
|
||||||
onClick={onPrev}
|
onClick={onPrev}
|
||||||
disabled={!onPrev}
|
disabled={!onPrev}
|
||||||
@@ -471,6 +581,8 @@ export default function Player({
|
|||||||
>
|
>
|
||||||
›
|
›
|
||||||
</button>
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Resolving the stream and buffering it are the same wait as far as
|
{/* Resolving the stream and buffering it are the same wait as far as
|
||||||
you are concerned, so they get the same spinner in the same place. */}
|
you are concerned, so they get the same spinner in the same place. */}
|
||||||
@@ -483,7 +595,6 @@ export default function Player({
|
|||||||
{src ? (
|
{src ? (
|
||||||
<video
|
<video
|
||||||
ref={videoRef}
|
ref={videoRef}
|
||||||
key={src}
|
|
||||||
src={src}
|
src={src}
|
||||||
autoPlay
|
autoPlay
|
||||||
onContextMenu={(e) => e.preventDefault()}
|
onContextMenu={(e) => e.preventDefault()}
|
||||||
@@ -513,7 +624,13 @@ export default function Player({
|
|||||||
className="absolute inset-0 size-full object-contain"
|
className="absolute inset-0 size-full object-contain"
|
||||||
>
|
>
|
||||||
{tracks.map(([lang, url]) => (
|
{tracks.map(([lang, url]) => (
|
||||||
<track key={url} kind="subtitles" srcLang={lang} label={subtitleLabel(lang)} src={url} />
|
<track
|
||||||
|
key={url}
|
||||||
|
kind="subtitles"
|
||||||
|
srcLang={lang}
|
||||||
|
label={subtitleLabel(lang)}
|
||||||
|
src={url}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</video>
|
</video>
|
||||||
) : (
|
) : (
|
||||||
@@ -522,7 +639,9 @@ export default function Player({
|
|||||||
<div className="max-w-sm">
|
<div className="max-w-sm">
|
||||||
<p className="text-[13px] text-red-400">{error}</p>
|
<p className="text-[13px] text-red-400">{error}</p>
|
||||||
<button
|
<button
|
||||||
onClick={() => openExternal(`https://www.youtube.com/watch?v=${item.id}`)}
|
onClick={() =>
|
||||||
|
openExternal(`https://www.youtube.com/watch?v=${item.id}`)
|
||||||
|
}
|
||||||
className={`${BTN} mt-3 cursor-pointer py-1.5`}
|
className={`${BTN} mt-3 cursor-pointer py-1.5`}
|
||||||
>
|
>
|
||||||
Open on YouTube instead
|
Open on YouTube instead
|
||||||
@@ -592,8 +711,18 @@ export default function Player({
|
|||||||
{downloading ? (
|
{downloading ? (
|
||||||
<Spinner className="size-4" />
|
<Spinner className="size-4" />
|
||||||
) : (
|
) : (
|
||||||
<svg viewBox="0 0 24 24" className="size-4" fill="none" stroke="currentColor" strokeWidth="2">
|
<svg
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4v12m0 0l-4-4m4 4l4-4M4 20h16" />
|
viewBox="0 0 24 24"
|
||||||
|
className="size-4"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M12 4v12m0 0l-4-4m4 4l4-4M4 20h16"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
@@ -606,30 +735,53 @@ export default function Player({
|
|||||||
className={`${navIcon} hover:border-red-500! hover:text-red-600!
|
className={`${navIcon} hover:border-red-500! hover:text-red-600!
|
||||||
dark:hover:border-red-500! dark:hover:text-red-400!`}
|
dark:hover:border-red-500! dark:hover:text-red-400!`}
|
||||||
>
|
>
|
||||||
<svg viewBox="0 0 24 24" className="size-4" fill="none" stroke="currentColor" strokeWidth="2">
|
<svg
|
||||||
<path strokeLinecap="round" strokeLinejoin="round"
|
viewBox="0 0 24 24"
|
||||||
d="M4 7h16M9 7V5h6v2M6 7l1 13h10l1-13M10 11v6M14 11v6" />
|
className="size-4"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M4 7h16M9 7V5h6v2M6 7l1 13h10l1-13M10 11v6M14 11v6"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={() => openExternal(`https://www.youtube.com/watch?v=${item.id}`)}
|
onClick={() =>
|
||||||
|
openExternal(`https://www.youtube.com/watch?v=${item.id}`)
|
||||||
|
}
|
||||||
title="Open on YouTube"
|
title="Open on YouTube"
|
||||||
aria-label="Open on YouTube"
|
aria-label="Open on YouTube"
|
||||||
className={navIcon}
|
className={navIcon}
|
||||||
>
|
>
|
||||||
<svg viewBox="0 0 24 24" className="size-4" fill="none" stroke="currentColor" strokeWidth="2">
|
<svg
|
||||||
<path strokeLinecap="round" strokeLinejoin="round"
|
viewBox="0 0 24 24"
|
||||||
d="M14 4h6v6M20 4l-9 9M18 14v5a1 1 0 01-1 1H5a1 1 0 01-1-1V7a1 1 0 011-1h5" />
|
className="size-4"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M14 4h6v6M20 4l-9 9M18 14v5a1 1 0 01-1 1H5a1 1 0 01-1-1V7a1 1 0 011-1h5"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
{showDescription && (
|
{showDescription && (
|
||||||
<Dialog title={item.title} onCancel={() => setShowDescription(false)} wide>
|
<Dialog
|
||||||
|
title={item.title}
|
||||||
|
onCancel={() => setShowDescription(false)}
|
||||||
|
wide
|
||||||
|
>
|
||||||
<p className="whitespace-pre-wrap text-[12.5px] leading-relaxed">
|
<p className="whitespace-pre-wrap text-[12.5px] leading-relaxed">
|
||||||
<Linked text={item.description} />
|
<Linked text={item.description} />
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -122,6 +122,29 @@ export default function TrayPanel() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<Row
|
||||||
|
label="Remove all downloads"
|
||||||
|
hint="Asks in the window first"
|
||||||
|
busy={busy === "wipe"}
|
||||||
|
onClick={() =>
|
||||||
|
run(
|
||||||
|
"wipe",
|
||||||
|
async () => {
|
||||||
|
await emitTo("main", "downloads:wipe-request", null);
|
||||||
|
await showMainWindow();
|
||||||
|
},
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
icon={
|
||||||
|
<svg viewBox="0 0 24 24" className={ICON} {...stroke} strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<path d="M4 7h16M10 11v6M14 11v6" />
|
||||||
|
<path d="M6 7l1 12.5A1.5 1.5 0 008.5 21h7a1.5 1.5 0 001.5-1.5L18 7" />
|
||||||
|
<path d="M9 7V5a1 1 0 011-1h4a1 1 0 011 1v2" />
|
||||||
|
</svg>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
<div className="my-1 h-px bg-slate-200 dark:bg-slate-800" />
|
<div className="my-1 h-px bg-slate-200 dark:bg-slate-800" />
|
||||||
|
|
||||||
<Row
|
<Row
|
||||||
|
|||||||
Reference in New Issue
Block a user