fix: native video fullscreen, window dragging, quality picker, spinner
Enable WKWebView's elementFullscreenEnabled at startup. It is off by default in a Tauri window, which is why the native player had no full-screen button and why right-click 'Enter Full Screen' did nothing. With it on, WebKit's own control appears on the video and gives the real system fullscreen player, so the app-level workaround is gone. Restore window dragging: data-tauri-drag-region needs core:window:allow-start-dragging, which was missing, leaving the title-bar strip inert. Download quality is now a resolution picker (best/2160/1440/1080/720/ 480) instead of a two-way toggle; every selector still pins AAC audio because Opus in MP4 is silent in WebKit. Tile titles truncate to a single line so rows stay uniform, and the player shows a spinner while the video buffers, not just while the stream URL resolves.
This commit is contained in:
Generated
+24
@@ -1141,6 +1141,7 @@ dependencies = [
|
|||||||
"chrono",
|
"chrono",
|
||||||
"csv",
|
"csv",
|
||||||
"futures",
|
"futures",
|
||||||
|
"objc2-web-kit",
|
||||||
"quick-xml 0.42.0",
|
"quick-xml 0.42.0",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
"rusqlite",
|
"rusqlite",
|
||||||
@@ -2597,6 +2598,16 @@ dependencies = [
|
|||||||
"objc2-core-foundation",
|
"objc2-core-foundation",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "objc2-javascript-core"
|
||||||
|
version = "0.3.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2a1e6550c4caed348956ce3370c9ffeca70bb1dbed4fa96112e7c6170e074586"
|
||||||
|
dependencies = [
|
||||||
|
"objc2",
|
||||||
|
"objc2-core-foundation",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "objc2-quartz-core"
|
name = "objc2-quartz-core"
|
||||||
version = "0.3.2"
|
version = "0.3.2"
|
||||||
@@ -2609,6 +2620,17 @@ dependencies = [
|
|||||||
"objc2-foundation",
|
"objc2-foundation",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "objc2-security"
|
||||||
|
version = "0.3.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "709fe137109bd1e8b5a99390f77a7d8b2961dafc1a1c5db8f2e60329ad6d895a"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags 2.13.1",
|
||||||
|
"objc2",
|
||||||
|
"objc2-core-foundation",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "objc2-ui-kit"
|
name = "objc2-ui-kit"
|
||||||
version = "0.3.2"
|
version = "0.3.2"
|
||||||
@@ -2652,6 +2674,8 @@ dependencies = [
|
|||||||
"objc2-app-kit",
|
"objc2-app-kit",
|
||||||
"objc2-core-foundation",
|
"objc2-core-foundation",
|
||||||
"objc2-foundation",
|
"objc2-foundation",
|
||||||
|
"objc2-javascript-core",
|
||||||
|
"objc2-security",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|||||||
@@ -31,3 +31,6 @@ futures = "0.3.34"
|
|||||||
tauri-plugin-dialog = "2.7.2"
|
tauri-plugin-dialog = "2.7.2"
|
||||||
reqwest = { version = "0.13.4", default-features = false, features = ["rustls", "http2", "charset", "stream", "gzip"] }
|
reqwest = { version = "0.13.4", default-features = false, features = ["rustls", "http2", "charset", "stream", "gzip"] }
|
||||||
|
|
||||||
|
[target.'cfg(target_os = "macos")'.dependencies]
|
||||||
|
objc2-web-kit = { version = "0.3.2", features = ["WKWebView", "WKWebViewConfiguration", "WKPreferences"] }
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,6 @@
|
|||||||
"core:default",
|
"core:default",
|
||||||
"opener:default",
|
"opener:default",
|
||||||
"dialog:default",
|
"dialog:default",
|
||||||
"core:window:allow-set-fullscreen",
|
"core:window:allow-start-dragging"
|
||||||
"core:window:allow-is-fullscreen"
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
{"default":{"identifier":"default","description":"Capability for the main window","local":true,"windows":["main"],"permissions":["core:default","opener:default","dialog:default","core:window:allow-set-fullscreen","core:window:allow-is-fullscreen"]}}
|
{"default":{"identifier":"default","description":"Capability for the main window","local":true,"windows":["main"],"permissions":["core:default","opener:default","dialog:default","core:window:allow-start-dragging"]}}
|
||||||
+36
-22
@@ -1,20 +1,23 @@
|
|||||||
//! Driving `yt-dlp` and interpreting its progress output.
|
//! Driving `yt-dlp` and interpreting its progress output.
|
||||||
|
|
||||||
/// Highest resolution available, which on YouTube means VP9 or AV1 above 1080p.
|
/// Highest resolution available, which on YouTube means VP9 or AV1 above 1080p.
|
||||||
/// Audio is still pinned to AAC (`m4a`): YouTube pairs those codecs with Opus,
|
/// Audio is pinned to AAC (`m4a`) in every selector: YouTube pairs those codecs
|
||||||
/// which WebKit will not decode inside an MP4 container, so taking Opus would
|
/// with Opus, which WebKit will not decode inside an MP4 container, so taking
|
||||||
/// yield a silent file.
|
/// Opus would yield a silent file.
|
||||||
pub const FORMAT_BEST: &str = "bv*+ba[ext=m4a]/bv*+ba/b";
|
pub const FORMAT_BEST: &str = "bv*+ba[ext=m4a]/bv*+ba/b";
|
||||||
|
|
||||||
/// H.264 video plus AAC audio. Caps at 1080p — YouTube serves H.264 no higher —
|
/// Builds a format selector for a chosen quality.
|
||||||
/// but is guaranteed to decode in WKWebView on any Mac.
|
///
|
||||||
pub const FORMAT_COMPATIBLE: &str =
|
/// `quality` is either "best" or a maximum height in pixels ("2160", "1080", …).
|
||||||
"bv*[vcodec^=avc1]+ba[acodec^=mp4a]/bv*[vcodec^=avc1]+ba/b[ext=mp4]/bv*+ba/b";
|
/// Anything unrecognised falls back to best, so a stale stored preference can
|
||||||
|
/// never leave downloads broken.
|
||||||
pub fn format_selector(quality: &str) -> &'static str {
|
pub fn format_selector(quality: &str) -> String {
|
||||||
match quality {
|
match quality.parse::<u32>() {
|
||||||
"compatible" => FORMAT_COMPATIBLE,
|
Ok(height) if (144..=4320).contains(&height) => format!(
|
||||||
_ => FORMAT_BEST,
|
"bv*[height<={h}]+ba[ext=m4a]/bv*[height<={h}]+ba/b[height<={h}]/b",
|
||||||
|
h = height
|
||||||
|
),
|
||||||
|
_ => FORMAT_BEST.to_string(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -79,7 +82,7 @@ pub fn parse_progress_line(line: &str) -> Option<Progress> {
|
|||||||
pub fn build_args(video_id: &str, out_template: &str, quality: &str) -> Vec<String> {
|
pub fn build_args(video_id: &str, out_template: &str, quality: &str) -> Vec<String> {
|
||||||
vec![
|
vec![
|
||||||
"-f".into(),
|
"-f".into(),
|
||||||
format_selector(quality).into(),
|
format_selector(quality),
|
||||||
"--merge-output-format".into(),
|
"--merge-output-format".into(),
|
||||||
"mp4".into(),
|
"mp4".into(),
|
||||||
"--no-playlist".into(),
|
"--no-playlist".into(),
|
||||||
@@ -173,26 +176,37 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn compatible_quality_pins_h264_and_aac() {
|
fn best_quality_takes_the_highest_available() {
|
||||||
let args = build_args("abc123", "/tmp/out.%(ext)s", "compatible");
|
let args = build_args("abc123", "/tmp/out.%(ext)s", "best");
|
||||||
assert!(args.contains(&FORMAT_COMPATIBLE.to_string()));
|
assert!(args.contains(&FORMAT_BEST.to_string()));
|
||||||
assert!(args.contains(&"mp4".to_string()));
|
assert!(args.contains(&"mp4".to_string()));
|
||||||
assert!(args.contains(&"https://www.youtube.com/watch?v=abc123".to_string()));
|
assert!(args.contains(&"https://www.youtube.com/watch?v=abc123".to_string()));
|
||||||
assert!(args.contains(&"--no-playlist".to_string()));
|
assert!(args.contains(&"--no-playlist".to_string()));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn best_quality_still_pins_aac_audio() {
|
fn every_selector_pins_aac_audio() {
|
||||||
let args = build_args("abc123", "/tmp/out.%(ext)s", "best");
|
|
||||||
assert!(args.contains(&FORMAT_BEST.to_string()));
|
|
||||||
// Opus in MP4 would be silent in WebKit, so the audio half stays m4a.
|
// Opus in MP4 would be silent in WebKit, so the audio half stays m4a.
|
||||||
assert!(FORMAT_BEST.contains("ba[ext=m4a]"));
|
for q in ["best", "2160", "1080", "480"] {
|
||||||
|
assert!(format_selector(q).contains("ba[ext=m4a]"), "quality {q}");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn unknown_quality_falls_back_to_best() {
|
fn a_numeric_quality_caps_the_height() {
|
||||||
|
let sel = format_selector("1080");
|
||||||
|
assert!(sel.contains("height<=1080"));
|
||||||
|
assert!(!sel.contains("height<=2160"));
|
||||||
|
assert!(build_args("x", "o", "1080").contains(&sel));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn nonsense_or_out_of_range_quality_falls_back_to_best() {
|
||||||
assert_eq!(format_selector("nonsense"), FORMAT_BEST);
|
assert_eq!(format_selector("nonsense"), FORMAT_BEST);
|
||||||
assert_eq!(format_selector("compatible"), FORMAT_COMPATIBLE);
|
assert_eq!(format_selector(""), FORMAT_BEST);
|
||||||
|
assert_eq!(format_selector("0"), FORMAT_BEST);
|
||||||
|
assert_eq!(format_selector("99999"), FORMAT_BEST);
|
||||||
|
assert_eq!(format_selector("best"), FORMAT_BEST);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -9,6 +9,28 @@ pub mod thumbs;
|
|||||||
|
|
||||||
use tauri::Manager;
|
use tauri::Manager;
|
||||||
|
|
||||||
|
/// Turns on WKWebView's element fullscreen.
|
||||||
|
///
|
||||||
|
/// It is off by default in a Tauri window, which is why the native player has
|
||||||
|
/// no full-screen button and why `requestFullscreen()` — and the right-click
|
||||||
|
/// "Enter Full Screen" item — silently do nothing. Flipping this one preference
|
||||||
|
/// puts the button back in the video's own transport bar, where it belongs.
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
fn enable_element_fullscreen(window: &tauri::WebviewWindow) {
|
||||||
|
use objc2_web_kit::WKWebView;
|
||||||
|
let _ = window.with_webview(|platform| unsafe {
|
||||||
|
let ptr = platform.inner() as *const WKWebView;
|
||||||
|
if ptr.is_null() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let webview: &WKWebView = &*ptr;
|
||||||
|
webview
|
||||||
|
.configuration()
|
||||||
|
.preferences()
|
||||||
|
.setElementFullscreenEnabled(true);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||||
pub fn run() {
|
pub fn run() {
|
||||||
tauri::Builder::default()
|
tauri::Builder::default()
|
||||||
@@ -17,6 +39,12 @@ pub fn run() {
|
|||||||
.setup(|app| {
|
.setup(|app| {
|
||||||
let state = commands::build_state(&app.handle().clone())?;
|
let state = commands::build_state(&app.handle().clone())?;
|
||||||
app.manage(state);
|
app.manage(state);
|
||||||
|
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
if let Some(window) = app.get_webview_window("main") {
|
||||||
|
enable_element_fullscreen(&window);
|
||||||
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
})
|
})
|
||||||
.invoke_handler(tauri::generate_handler![
|
.invoke_handler(tauri::generate_handler![
|
||||||
|
|||||||
+3
-4
@@ -13,7 +13,7 @@ import { useAppearance } from "./hooks/useAppearance";
|
|||||||
import { useConnectivity } from "./hooks/useConnectivity";
|
import { useConnectivity } from "./hooks/useConnectivity";
|
||||||
import { useDownloads } from "./hooks/useDownloads";
|
import { useDownloads } from "./hooks/useDownloads";
|
||||||
import { useFeed } from "./hooks/useFeed";
|
import { useFeed } from "./hooks/useFeed";
|
||||||
import type { FeedFilter, FeedItem, Quality, RefreshProgress } from "./types";
|
import { QUALITIES, type FeedFilter, type FeedItem, type Quality, type RefreshProgress } from "./types";
|
||||||
|
|
||||||
const TOAST_MS = 2400;
|
const TOAST_MS = 2400;
|
||||||
|
|
||||||
@@ -34,9 +34,8 @@ export default function App() {
|
|||||||
const [playingIndex, setPlayingIndex] = useState<number | null>(null);
|
const [playingIndex, setPlayingIndex] = useState<number | null>(null);
|
||||||
const [quality, setQuality] = useState<Quality>(() => {
|
const [quality, setQuality] = useState<Quality>(() => {
|
||||||
try {
|
try {
|
||||||
return localStorage.getItem("flighttube.quality") === "compatible"
|
const stored = localStorage.getItem("flighttube.quality");
|
||||||
? "compatible"
|
return QUALITIES.some((q) => q.value === stored) ? (stored as Quality) : "best";
|
||||||
: "best";
|
|
||||||
} catch {
|
} catch {
|
||||||
return "best";
|
return "best";
|
||||||
}
|
}
|
||||||
|
|||||||
+17
-61
@@ -1,4 +1,3 @@
|
|||||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { fileUrl, openExternal, resolveStream, savePlayback } from "../api";
|
import { fileUrl, openExternal, resolveStream, savePlayback } from "../api";
|
||||||
import type { FeedItem } from "../types";
|
import type { FeedItem } from "../types";
|
||||||
@@ -94,6 +93,7 @@ export default function Player({
|
|||||||
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);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [buffering, setBuffering] = useState(false);
|
||||||
const videoRef = useRef<HTMLVideoElement>(null);
|
const videoRef = useRef<HTMLVideoElement>(null);
|
||||||
const stageRef = useRef<HTMLDivElement>(null);
|
const stageRef = useRef<HTMLDivElement>(null);
|
||||||
const lastSave = useRef(0);
|
const lastSave = useRef(0);
|
||||||
@@ -147,52 +147,19 @@ export default function Player({
|
|||||||
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;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Real video fullscreen, the only way available here.
|
const leave = useCallback(() => onClose(), [onClose]);
|
||||||
//
|
|
||||||
// WKWebView inside a Tauri window has element fullscreen disabled outright,
|
|
||||||
// so `video.requestFullscreen()` and WebKit's `webkitEnterFullscreen()` are
|
|
||||||
// both inert, and Tauri exposes no switch to turn it on. Fullscreening the
|
|
||||||
// window alone is not the same thing — the player's own header and footer
|
|
||||||
// stay on screen around the video. So: fullscreen the window AND hide every
|
|
||||||
// piece of chrome, leaving the picture alone on the display. Same result,
|
|
||||||
// and it cannot silently fail.
|
|
||||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
|
||||||
|
|
||||||
const setFullscreen = useCallback(async (on: boolean) => {
|
|
||||||
try {
|
|
||||||
await getCurrentWindow().setFullscreen(on);
|
|
||||||
} catch {
|
|
||||||
/* still worth hiding the chrome */
|
|
||||||
}
|
|
||||||
setIsFullscreen(on);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const toggleFullscreen = useCallback(
|
|
||||||
() => void setFullscreen(!isFullscreen),
|
|
||||||
[isFullscreen, setFullscreen],
|
|
||||||
);
|
|
||||||
|
|
||||||
// Leaving the player must not strand the window in fullscreen.
|
|
||||||
const leave = useCallback(async () => {
|
|
||||||
if (isFullscreen) await setFullscreen(false);
|
|
||||||
onClose();
|
|
||||||
}, [isFullscreen, onClose, setFullscreen]);
|
|
||||||
|
|
||||||
// Escape backs out, as it does everywhere else in the app.
|
// Escape backs out, as it does everywhere else in the app.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onKey = (e: KeyboardEvent) => {
|
const onKey = (e: KeyboardEvent) => {
|
||||||
if (e.key === "Escape") {
|
if (e.key === "Escape") leave();
|
||||||
if (isFullscreen) void setFullscreen(false);
|
|
||||||
else void leave();
|
|
||||||
}
|
|
||||||
if (e.key === "f" && !e.metaKey && !e.ctrlKey) void toggleFullscreen();
|
|
||||||
// Arrow keys only when the video does not own them for seeking.
|
// Arrow keys only when the video does not own them for seeking.
|
||||||
if (e.key === "ArrowLeft" && e.shiftKey) onPrev?.();
|
if (e.key === "ArrowLeft" && e.shiftKey) onPrev?.();
|
||||||
if (e.key === "ArrowRight" && e.shiftKey) onNext?.();
|
if (e.key === "ArrowRight" && e.shiftKey) onNext?.();
|
||||||
};
|
};
|
||||||
window.addEventListener("keydown", onKey);
|
window.addEventListener("keydown", onKey);
|
||||||
return () => window.removeEventListener("keydown", onKey);
|
return () => window.removeEventListener("keydown", onKey);
|
||||||
}, [leave, toggleFullscreen, onPrev, onNext, isFullscreen, setFullscreen]);
|
}, [leave, onPrev, onNext]);
|
||||||
|
|
||||||
const edgeBtn =
|
const edgeBtn =
|
||||||
"absolute top-1/2 z-10 -translate-y-1/2 grid size-11 place-items-center rounded-full " +
|
"absolute top-1/2 z-10 -translate-y-1/2 grid size-11 place-items-center rounded-full " +
|
||||||
@@ -208,9 +175,8 @@ export default function Player({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-50 flex flex-col bg-slate-100 dark:bg-slate-950">
|
<div className="fixed inset-0 z-50 flex flex-col bg-slate-100 dark:bg-slate-950">
|
||||||
{!isFullscreen && <div data-tauri-drag-region className="h-9 shrink-0" />}
|
<div data-tauri-drag-region className="h-9 shrink-0" />
|
||||||
<header
|
<header
|
||||||
hidden={isFullscreen}
|
|
||||||
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"
|
||||||
>
|
>
|
||||||
@@ -232,18 +198,9 @@ export default function Player({
|
|||||||
{item.channel_title}
|
{item.channel_title}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={toggleFullscreen}
|
|
||||||
disabled={!src}
|
|
||||||
title={isFullscreen ? "Leave full screen (f)" : "Full screen (f)"}
|
|
||||||
className={navBtn}
|
|
||||||
>
|
|
||||||
{isFullscreen ? "⤡ Exit full screen" : "⤢ Full screen"}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{streaming ? (
|
{streaming ? (
|
||||||
<span className="text-[11px] text-slate-400 dark:text-slate-500">
|
<span className="text-[11px] text-slate-400 dark:text-slate-500">
|
||||||
{src ? "Streaming" : error ? "Unavailable" : "Loading"}
|
{error ? "Unavailable" : src && !buffering ? "Streaming" : "Loading…"}
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<button
|
<button
|
||||||
@@ -280,17 +237,12 @@ export default function Player({
|
|||||||
›
|
›
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{isFullscreen && (
|
{/* Two different waits look the same to you: resolving the stream, and
|
||||||
<button
|
the player buffering it. Both get the spinner. */}
|
||||||
onClick={() => void setFullscreen(false)}
|
{src && buffering && (
|
||||||
title="Leave full screen (Esc)"
|
<div className="pointer-events-none absolute inset-0 z-10 grid place-items-center">
|
||||||
className="absolute right-3 top-3 z-10 rounded-full bg-slate-950/55 px-3 py-1.5
|
<Spinner className="size-8 text-white/80" />
|
||||||
text-[11px] font-medium text-white opacity-0 backdrop-blur
|
</div>
|
||||||
transition-opacity group-hover/stage:opacity-100 hover:bg-slate-950/80
|
|
||||||
cursor-pointer"
|
|
||||||
>
|
|
||||||
⤡ Exit full screen
|
|
||||||
</button>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{src ? (
|
{src ? (
|
||||||
@@ -303,6 +255,11 @@ export default function Player({
|
|||||||
onTimeUpdate={onTimeUpdate}
|
onTimeUpdate={onTimeUpdate}
|
||||||
onLoadedMetadata={onLoadedMetadata}
|
onLoadedMetadata={onLoadedMetadata}
|
||||||
onPause={persist}
|
onPause={persist}
|
||||||
|
onLoadStart={() => setBuffering(true)}
|
||||||
|
onWaiting={() => setBuffering(true)}
|
||||||
|
onStalled={() => setBuffering(true)}
|
||||||
|
onCanPlay={() => setBuffering(false)}
|
||||||
|
onPlaying={() => setBuffering(false)}
|
||||||
className="absolute inset-0 size-full object-contain"
|
className="absolute inset-0 size-full object-contain"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
@@ -328,7 +285,6 @@ export default function Player({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<footer
|
<footer
|
||||||
hidden={isFullscreen}
|
|
||||||
className="max-h-52 shrink-0 overflow-y-auto border-t border-slate-200 bg-white px-4 py-3
|
className="max-h-52 shrink-0 overflow-y-auto border-t 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"
|
||||||
>
|
>
|
||||||
|
|||||||
+18
-14
@@ -3,7 +3,7 @@ import {
|
|||||||
checkPrereqs, importTakeoutCsv, pickLibraryFolder, pickTakeoutFile, previewTakeoutImport,
|
checkPrereqs, importTakeoutCsv, pickLibraryFolder, pickTakeoutFile, previewTakeoutImport,
|
||||||
} from "../api";
|
} from "../api";
|
||||||
import { APPEARANCE_MODES, type Appearance } from "../hooks/useAppearance";
|
import { APPEARANCE_MODES, type Appearance } from "../hooks/useAppearance";
|
||||||
import type { ImportPreview, Prereqs, Quality } from "../types";
|
import { QUALITIES, type ImportPreview, type Prereqs, type Quality } from "../types";
|
||||||
import TakeoutGuide from "./TakeoutGuide";
|
import TakeoutGuide from "./TakeoutGuide";
|
||||||
import {
|
import {
|
||||||
BTN_CHROME, BTN_PRIMARY, Dialog, HELP, LABEL, SectionHeading, Segmented, SUBPANEL,
|
BTN_CHROME, BTN_PRIMARY, Dialog, HELP, LABEL, SectionHeading, Segmented, SUBPANEL,
|
||||||
@@ -126,22 +126,26 @@ export default function Settings({
|
|||||||
<section className="border-b border-slate-200 px-5 py-4 dark:border-slate-800">
|
<section className="border-b border-slate-200 px-5 py-4 dark:border-slate-800">
|
||||||
<SectionHeading>Download quality</SectionHeading>
|
<SectionHeading>Download quality</SectionHeading>
|
||||||
<p className={`mt-1.5 ${HELP}`}>
|
<p className={`mt-1.5 ${HELP}`}>
|
||||||
<b>Best</b> takes the highest resolution available, up to 4K — above 1080p
|
Above 1080p YouTube only serves VP9 and AV1. Those play here, but the
|
||||||
that means VP9 or AV1, which your Mac decodes but older ones may not, and
|
files are several times larger and older Macs may struggle. Pick 1080p
|
||||||
the files are several times larger. <b>Compatible</b> caps at 1080p H.264,
|
for H.264, which plays anywhere. Audio is AAC at every setting.
|
||||||
which plays anywhere. Audio is AAC either way.
|
|
||||||
</p>
|
</p>
|
||||||
<div className="mt-2 flex items-center justify-between gap-3">
|
<label className="mt-2 grid grid-cols-[92px_1fr] items-center gap-2">
|
||||||
<span className={LABEL}>Quality</span>
|
<span className={LABEL}>Quality</span>
|
||||||
<Segmented
|
<select
|
||||||
value={quality}
|
value={quality}
|
||||||
onChange={onQuality}
|
onChange={(e) => onQuality(e.target.value as Quality)}
|
||||||
options={[
|
className="w-full rounded-lg border border-slate-300 bg-white px-2 py-1.5
|
||||||
{ value: "best", label: "Best (4K)" },
|
text-[13px] outline-none cursor-pointer
|
||||||
{ value: "compatible", label: "Compatible" },
|
dark:border-slate-700 dark:bg-slate-800"
|
||||||
]}
|
>
|
||||||
/>
|
{QUALITIES.map((q) => (
|
||||||
</div>
|
<option key={q.value} value={q.value}>
|
||||||
|
{q.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="border-b border-slate-200 px-5 py-4 dark:border-slate-800">
|
<section className="border-b border-slate-200 px-5 py-4 dark:border-slate-800">
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ export default function VideoTile({
|
|||||||
<div className="mt-2 flex min-w-0 flex-col">
|
<div className="mt-2 flex min-w-0 flex-col">
|
||||||
<button onClick={onOpen} className="cursor-pointer text-left">
|
<button onClick={onOpen} className="cursor-pointer text-left">
|
||||||
<h3
|
<h3
|
||||||
className="line-clamp-2 h-[2.25rem] text-[13px] font-medium leading-snug"
|
className="h-[1.25rem] truncate text-[13px] font-medium leading-5"
|
||||||
title={item.title}
|
title={item.title}
|
||||||
>
|
>
|
||||||
{item.title}
|
{item.title}
|
||||||
|
|||||||
+11
-1
@@ -86,4 +86,14 @@ export interface ImportPreview {
|
|||||||
removed_downloads: number;
|
removed_downloads: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Quality = "best" | "compatible";
|
/** "best" or a maximum height in pixels. */
|
||||||
|
export type Quality = "best" | "2160" | "1440" | "1080" | "720" | "480";
|
||||||
|
|
||||||
|
export const QUALITIES: Array<{ value: Quality; label: string }> = [
|
||||||
|
{ value: "best", label: "Best available (up to 4K)" },
|
||||||
|
{ value: "2160", label: "2160p — 4K" },
|
||||||
|
{ value: "1440", label: "1440p — 2K" },
|
||||||
|
{ value: "1080", label: "1080p" },
|
||||||
|
{ value: "720", label: "720p" },
|
||||||
|
{ value: "480", label: "480p" },
|
||||||
|
];
|
||||||
|
|||||||
Reference in New Issue
Block a user