feat: uniform control height, chrome auto-hide, streaming quality
Every control in the app now shares one height (CONTROL_H), with width still growing to fit its label. The player transport is larger, Back is an icon, and the footer buttons match the prev/next size. Player chrome — transport and edge arrows — fades after 2.6s of inactivity and never hides while paused. The title-bar strip collapses in macOS window fullscreen, where the traffic lights are gone and it was just a blank white bar. Streaming quality is now selectable alongside download quality. A cap is applied by rewriting YouTube's HLS master playlist down to the best variant at or below the chosen height, keeping its audio group. That playlist is served from a small loopback HTTP server: Safari's native HLS is backed by AVFoundation, which cannot read blob: or custom-scheme URLs, so a Blob URL silently fails to play. Settings closes with an icon button and its selects match the shared control height.
This commit is contained in:
@@ -9,6 +9,7 @@
|
|||||||
"core:default",
|
"core:default",
|
||||||
"opener:default",
|
"opener:default",
|
||||||
"dialog:default",
|
"dialog:default",
|
||||||
"core:window:allow-start-dragging"
|
"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-start-dragging"]}}
|
{"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","core:window:allow-is-fullscreen"]}}
|
||||||
+142
-4
@@ -7,6 +7,7 @@ use crate::models::{
|
|||||||
Channel, ChannelWithCount, DownloadState, FeedFilter, FeedItem, ImportPreview, Prereqs,
|
Channel, ChannelWithCount, DownloadState, FeedFilter, FeedItem, ImportPreview, Prereqs,
|
||||||
};
|
};
|
||||||
use crate::net;
|
use crate::net;
|
||||||
|
use crate::playlist_server::PlaylistServer;
|
||||||
use crate::takeout;
|
use crate::takeout;
|
||||||
use crate::thumbs;
|
use crate::thumbs;
|
||||||
|
|
||||||
@@ -35,6 +36,7 @@ pub struct AppState {
|
|||||||
pub app_data: PathBuf,
|
pub app_data: PathBuf,
|
||||||
pub children: Arc<Mutex<HashMap<String, tokio::process::Child>>>,
|
pub children: Arc<Mutex<HashMap<String, tokio::process::Child>>>,
|
||||||
pub download_slots: Arc<Semaphore>,
|
pub download_slots: Arc<Semaphore>,
|
||||||
|
pub playlists: PlaylistServer,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Serialize)]
|
#[derive(Clone, Serialize)]
|
||||||
@@ -174,18 +176,49 @@ pub async fn import_takeout_csv(
|
|||||||
/// playlist, which lists H.264 + AAC variants up to 1080p with separate audio
|
/// playlist, which lists H.264 + AAC variants up to 1080p with separate audio
|
||||||
/// tracks — exactly the shape AVFoundation plays natively in WKWebView, with
|
/// tracks — exactly the shape AVFoundation plays natively in WKWebView, with
|
||||||
/// adaptive bitrate for free.
|
/// adaptive bitrate for free.
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct Stream {
|
||||||
|
/// Direct URL to hand the player, when no filtering was needed.
|
||||||
|
pub url: Option<String>,
|
||||||
|
/// A rewritten HLS master playlist, when a quality cap was applied. The
|
||||||
|
/// frontend turns this into a Blob URL — every URL inside is absolute, so
|
||||||
|
/// the playlist works from anywhere.
|
||||||
|
pub playlist: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn resolve_stream(video_id: String) -> Result<String, String> {
|
pub async fn resolve_stream(
|
||||||
|
video_id: String,
|
||||||
|
max_height: Option<u32>,
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
) -> Result<Stream, String> {
|
||||||
let url = format!("https://www.youtube.com/watch?v={video_id}");
|
let url = format!("https://www.youtube.com/watch?v={video_id}");
|
||||||
|
|
||||||
// The HLS master playlist. Every m3u8 format shares the same manifest_url,
|
// The HLS master playlist. Every m3u8 format shares the same manifest_url,
|
||||||
// so any one of them yields the master.
|
// so any one of them yields the master.
|
||||||
if let Some(u) = yt_dlp_print(
|
if let Some(master) = yt_dlp_print(
|
||||||
&["-f", "bv*[protocol^=m3u8]", "--print", "%(manifest_url)s", &url],
|
&["-f", "bv*[protocol^=m3u8]", "--print", "%(manifest_url)s", &url],
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
return Ok(u);
|
let Some(cap) = max_height else {
|
||||||
|
return Ok(Stream { url: Some(master), playlist: None });
|
||||||
|
};
|
||||||
|
// Fetch and cut it down. If anything about that fails, the adaptive
|
||||||
|
// master still plays — a quality preference is not worth an error.
|
||||||
|
return match state.http.get(&master).send().await {
|
||||||
|
Ok(resp) => match resp.text().await {
|
||||||
|
Ok(body) => Ok(match filter_master_playlist(&body, cap) {
|
||||||
|
Some(filtered) => Stream {
|
||||||
|
url: Some(state.playlists.publish(filtered).await),
|
||||||
|
playlist: None,
|
||||||
|
},
|
||||||
|
None => Stream { url: Some(master), playlist: None },
|
||||||
|
}),
|
||||||
|
Err(_) => Ok(Stream { url: Some(master), playlist: None }),
|
||||||
|
},
|
||||||
|
Err(_) => Ok(Stream { url: Some(master), playlist: None }),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Rare fallback: an old-style progressive muxed MP4.
|
// Rare fallback: an old-style progressive muxed MP4.
|
||||||
@@ -198,12 +231,63 @@ pub async fn resolve_stream(video_id: String) -> Result<String, String> {
|
|||||||
])
|
])
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
return Ok(u);
|
return Ok(Stream { url: Some(u), playlist: None });
|
||||||
}
|
}
|
||||||
|
|
||||||
Err("Could not find a playable stream for this video.".into())
|
Err("Could not find a playable stream for this video.".into())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Keeps only the highest video variant at or below `max_height`, along with
|
||||||
|
/// every `EXT-X-MEDIA` line (the audio and subtitle groups it references).
|
||||||
|
///
|
||||||
|
/// Returns `None` if nothing matched, so the caller can fall back to adaptive
|
||||||
|
/// rather than hand the player an empty playlist.
|
||||||
|
pub fn filter_master_playlist(body: &str, max_height: u32) -> Option<String> {
|
||||||
|
let lines: Vec<&str> = body.lines().collect();
|
||||||
|
let mut media = Vec::new();
|
||||||
|
// (height, stream-inf line, url line)
|
||||||
|
let mut variants: Vec<(u32, &str, &str)> = Vec::new();
|
||||||
|
|
||||||
|
for (i, line) in lines.iter().enumerate() {
|
||||||
|
if line.starts_with("#EXT-X-MEDIA:") {
|
||||||
|
media.push(*line);
|
||||||
|
} else if line.starts_with("#EXT-X-STREAM-INF:") {
|
||||||
|
let Some(url) = lines.get(i + 1) else { continue };
|
||||||
|
if url.starts_with('#') || url.trim().is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if let Some(h) = resolution_height(line) {
|
||||||
|
if h <= max_height {
|
||||||
|
variants.push((h, *line, *url));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let best = variants.iter().max_by_key(|(h, _, _)| *h)?;
|
||||||
|
|
||||||
|
let mut out = String::from("#EXTM3U
|
||||||
|
#EXT-X-INDEPENDENT-SEGMENTS
|
||||||
|
");
|
||||||
|
for m in media {
|
||||||
|
out.push_str(m);
|
||||||
|
out.push('\n');
|
||||||
|
}
|
||||||
|
out.push_str(best.1);
|
||||||
|
out.push('\n');
|
||||||
|
out.push_str(best.2);
|
||||||
|
out.push('\n');
|
||||||
|
Some(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pulls the vertical size out of a `RESOLUTION=1920x1080` attribute.
|
||||||
|
fn resolution_height(stream_inf: &str) -> Option<u32> {
|
||||||
|
let at = stream_inf.find("RESOLUTION=")? + "RESOLUTION=".len();
|
||||||
|
let rest = &stream_inf[at..];
|
||||||
|
let value = rest.split(&[',', ' '][..]).next()?;
|
||||||
|
value.split(&['x', 'X'][..]).nth(1)?.parse().ok()
|
||||||
|
}
|
||||||
|
|
||||||
/// Runs yt-dlp and returns its first non-empty stdout line, or None.
|
/// Runs yt-dlp and returns its first non-empty stdout line, or None.
|
||||||
async fn yt_dlp_print(args: &[&str]) -> Option<String> {
|
async fn yt_dlp_print(args: &[&str]) -> Option<String> {
|
||||||
let mut cmd = tokio::process::Command::new(bin("yt-dlp"));
|
let mut cmd = tokio::process::Command::new(bin("yt-dlp"));
|
||||||
@@ -642,5 +726,59 @@ pub fn build_state(app: &AppHandle) -> Result<AppState, String> {
|
|||||||
app_data,
|
app_data,
|
||||||
children: Arc::new(Mutex::new(HashMap::new())),
|
children: Arc::new(Mutex::new(HashMap::new())),
|
||||||
download_slots: Arc::new(Semaphore::new(DOWNLOAD_CONCURRENCY)),
|
download_slots: Arc::new(Semaphore::new(DOWNLOAD_CONCURRENCY)),
|
||||||
|
playlists: PlaylistServer::start()?,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::filter_master_playlist;
|
||||||
|
|
||||||
|
const MASTER: &str = concat!(
|
||||||
|
"#EXTM3U\n",
|
||||||
|
"#EXT-X-INDEPENDENT-SEGMENTS\n",
|
||||||
|
"#EXT-X-MEDIA:URI=\"https://a/audio.m3u8\",TYPE=AUDIO,GROUP-ID=\"234\",DEFAULT=YES\n",
|
||||||
|
"#EXT-X-STREAM-INF:BANDWIDTH=756324,CODECS=\"avc1,mp4a\",RESOLUTION=640x360,AUDIO=\"234\"\n",
|
||||||
|
"https://a/360.m3u8\n",
|
||||||
|
"#EXT-X-STREAM-INF:BANDWIDTH=3878958,CODECS=\"avc1,mp4a\",RESOLUTION=1280x720,AUDIO=\"234\"\n",
|
||||||
|
"https://a/720.m3u8\n",
|
||||||
|
"#EXT-X-STREAM-INF:BANDWIDTH=6039686,CODECS=\"avc1,mp4a\",RESOLUTION=1920x1080,AUDIO=\"234\"\n",
|
||||||
|
"https://a/1080.m3u8\n",
|
||||||
|
);
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn keeps_the_best_variant_at_or_below_the_cap() {
|
||||||
|
let out = filter_master_playlist(MASTER, 720).unwrap();
|
||||||
|
assert!(out.contains("https://a/720.m3u8"));
|
||||||
|
assert!(!out.contains("https://a/1080.m3u8"));
|
||||||
|
assert!(!out.contains("https://a/360.m3u8"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn always_carries_the_audio_group_across() {
|
||||||
|
// A video-only variant would play silently, so EXT-X-MEDIA must survive.
|
||||||
|
let out = filter_master_playlist(MASTER, 360).unwrap();
|
||||||
|
assert!(out.contains("TYPE=AUDIO"));
|
||||||
|
assert!(out.contains("https://a/audio.m3u8"));
|
||||||
|
assert!(out.starts_with("#EXTM3U"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_exact_match_is_included_not_excluded() {
|
||||||
|
let out = filter_master_playlist(MASTER, 1080).unwrap();
|
||||||
|
assert!(out.contains("https://a/1080.m3u8"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn nothing_below_the_cap_yields_none_so_the_caller_can_fall_back() {
|
||||||
|
assert!(filter_master_playlist(MASTER, 144).is_none());
|
||||||
|
assert!(filter_master_playlist("#EXTM3U\n", 1080).is_none());
|
||||||
|
assert!(filter_master_playlist("", 1080).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_stream_inf_with_no_following_url_is_skipped() {
|
||||||
|
let truncated = "#EXTM3U\n#EXT-X-STREAM-INF:RESOLUTION=1280x720\n";
|
||||||
|
assert!(filter_master_playlist(truncated, 1080).is_none());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ pub mod downloader;
|
|||||||
pub mod feed;
|
pub mod feed;
|
||||||
pub mod models;
|
pub mod models;
|
||||||
pub mod net;
|
pub mod net;
|
||||||
|
pub mod playlist_server;
|
||||||
pub mod takeout;
|
pub mod takeout;
|
||||||
pub mod thumbs;
|
pub mod thumbs;
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,125 @@
|
|||||||
|
//! A minimal loopback HTTP server for rewritten HLS playlists.
|
||||||
|
//!
|
||||||
|
//! When a streaming quality cap is set we hand the player a filtered master
|
||||||
|
//! playlist rather than YouTube's. That playlist has to be reachable over
|
||||||
|
//! `http://`: Safari's native HLS is backed by AVFoundation, which cannot read
|
||||||
|
//! `blob:` or custom-scheme URLs — only a real HTTP one. Hence ~80 lines of
|
||||||
|
//! server rather than a one-line Blob URL.
|
||||||
|
//!
|
||||||
|
//! It binds to 127.0.0.1 on an ephemeral port and only ever serves playlists it
|
||||||
|
//! was explicitly handed, addressed by an unguessable token.
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||||
|
use tokio::net::TcpListener;
|
||||||
|
use tokio::sync::Mutex;
|
||||||
|
|
||||||
|
/// Playlists are small; keeping the last few is plenty, and it stops a long
|
||||||
|
/// session from growing the map without bound.
|
||||||
|
const MAX_ENTRIES: usize = 8;
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct PlaylistServer {
|
||||||
|
port: u16,
|
||||||
|
entries: Arc<Mutex<Vec<(String, String)>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PlaylistServer {
|
||||||
|
pub fn start() -> Result<Self, String> {
|
||||||
|
let listener = std::net::TcpListener::bind("127.0.0.1:0")
|
||||||
|
.map_err(|e| format!("Cannot bind playlist server: {e}"))?;
|
||||||
|
listener
|
||||||
|
.set_nonblocking(true)
|
||||||
|
.map_err(|e| format!("Cannot configure playlist server: {e}"))?;
|
||||||
|
let port = listener
|
||||||
|
.local_addr()
|
||||||
|
.map_err(|e| format!("Cannot read playlist server port: {e}"))?
|
||||||
|
.port();
|
||||||
|
|
||||||
|
let server = PlaylistServer {
|
||||||
|
port,
|
||||||
|
entries: Arc::new(Mutex::new(Vec::new())),
|
||||||
|
};
|
||||||
|
|
||||||
|
let entries = server.entries.clone();
|
||||||
|
tauri::async_runtime::spawn(async move {
|
||||||
|
let Ok(listener) = TcpListener::from_std(listener) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
loop {
|
||||||
|
let Ok((stream, _)) = listener.accept().await else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let entries = entries.clone();
|
||||||
|
tauri::async_runtime::spawn(async move {
|
||||||
|
let _ = serve(stream, entries).await;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(server)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Publishes a playlist and returns the URL to hand the player.
|
||||||
|
pub async fn publish(&self, body: String) -> String {
|
||||||
|
let token = token();
|
||||||
|
let mut entries = self.entries.lock().await;
|
||||||
|
entries.push((token.clone(), body));
|
||||||
|
if entries.len() > MAX_ENTRIES {
|
||||||
|
entries.remove(0);
|
||||||
|
}
|
||||||
|
format!("http://127.0.0.1:{}/{token}.m3u8", self.port)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Not cryptographic, just unguessable enough that another local process
|
||||||
|
/// cannot stumble onto a playlist by iterating short paths.
|
||||||
|
fn token() -> String {
|
||||||
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
let nanos = SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.map(|d| d.as_nanos())
|
||||||
|
.unwrap_or(0);
|
||||||
|
let addr = Box::into_raw(Box::new(0u8)) as usize;
|
||||||
|
// Reclaim the allocation used purely as an address source.
|
||||||
|
unsafe { drop(Box::from_raw(addr as *mut u8)) };
|
||||||
|
format!("{nanos:x}{addr:x}")
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn serve(
|
||||||
|
mut stream: tokio::net::TcpStream,
|
||||||
|
entries: Arc<Mutex<Vec<(String, String)>>>,
|
||||||
|
) -> std::io::Result<()> {
|
||||||
|
let mut buf = vec![0u8; 2048];
|
||||||
|
let n = stream.read(&mut buf).await?;
|
||||||
|
let request = String::from_utf8_lossy(&buf[..n]);
|
||||||
|
|
||||||
|
// "GET /<token>.m3u8 HTTP/1.1"
|
||||||
|
let path = request
|
||||||
|
.lines()
|
||||||
|
.next()
|
||||||
|
.and_then(|l| l.split_whitespace().nth(1))
|
||||||
|
.unwrap_or("");
|
||||||
|
let wanted = path.trim_start_matches('/').trim_end_matches(".m3u8");
|
||||||
|
|
||||||
|
let body = {
|
||||||
|
let entries = entries.lock().await;
|
||||||
|
entries
|
||||||
|
.iter()
|
||||||
|
.find(|(t, _)| t == wanted)
|
||||||
|
.map(|(_, b)| b.clone())
|
||||||
|
};
|
||||||
|
|
||||||
|
let response = match body {
|
||||||
|
Some(body) => format!(
|
||||||
|
"HTTP/1.1 200 OK\r\nContent-Type: application/vnd.apple.mpegurl\r\n\
|
||||||
|
Content-Length: {}\r\nCache-Control: no-store\r\nConnection: close\r\n\r\n{}",
|
||||||
|
body.len(),
|
||||||
|
body
|
||||||
|
),
|
||||||
|
None => "HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\nConnection: close\r\n\r\n".into(),
|
||||||
|
};
|
||||||
|
|
||||||
|
stream.write_all(response.as_bytes()).await?;
|
||||||
|
stream.flush().await
|
||||||
|
}
|
||||||
+28
-7
@@ -13,7 +13,11 @@ 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 { QUALITIES, type FeedFilter, type FeedItem, type Quality, type RefreshProgress } from "./types";
|
import { useWindowFullscreen } from "./hooks/useWindowFullscreen";
|
||||||
|
import {
|
||||||
|
QUALITIES, STREAM_QUALITIES,
|
||||||
|
type FeedFilter, type FeedItem, type Quality, type RefreshProgress,
|
||||||
|
} from "./types";
|
||||||
|
|
||||||
const TOAST_MS = 2400;
|
const TOAST_MS = 2400;
|
||||||
/** How often to pull new videos while online, so the feed stays live. */
|
/** How often to pull new videos while online, so the feed stays live. */
|
||||||
@@ -44,6 +48,14 @@ export default function App() {
|
|||||||
const [showSettings, setShowSettings] = useState(false);
|
const [showSettings, setShowSettings] = useState(false);
|
||||||
// Index into the current feed, so the player can step through it.
|
// Index into the current feed, so the player can step through it.
|
||||||
const [playingIndex, setPlayingIndex] = useState<number | null>(null);
|
const [playingIndex, setPlayingIndex] = useState<number | null>(null);
|
||||||
|
const [streamQuality, setStreamQuality] = useState<Quality>(() => {
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem("flighttube.streamQuality");
|
||||||
|
return STREAM_QUALITIES.some((q) => q.value === stored) ? (stored as Quality) : "best";
|
||||||
|
} catch {
|
||||||
|
return "best";
|
||||||
|
}
|
||||||
|
});
|
||||||
const [quality, setQuality] = useState<Quality>(() => {
|
const [quality, setQuality] = useState<Quality>(() => {
|
||||||
try {
|
try {
|
||||||
const stored = localStorage.getItem("flighttube.quality");
|
const stored = localStorage.getItem("flighttube.quality");
|
||||||
@@ -58,6 +70,7 @@ export default function App() {
|
|||||||
const [failure, setFailure] = useState<string | null>(null);
|
const [failure, setFailure] = useState<string | null>(null);
|
||||||
|
|
||||||
const { mode, setMode } = useAppearance();
|
const { mode, setMode } = useAppearance();
|
||||||
|
const windowFullscreen = useWindowFullscreen();
|
||||||
const { online, reachable, forcedOffline, setForcedOffline, probe } = useConnectivity();
|
const { online, reachable, forcedOffline, setForcedOffline, probe } = useConnectivity();
|
||||||
|
|
||||||
// A toast reports success and fades; a modal reports a failure or asks a
|
// A toast reports success and fades; a modal reports a failure or asks a
|
||||||
@@ -74,13 +87,14 @@ export default function App() {
|
|||||||
try {
|
try {
|
||||||
localStorage.setItem("flighttube.view", view);
|
localStorage.setItem("flighttube.view", view);
|
||||||
localStorage.setItem("flighttube.quality", quality);
|
localStorage.setItem("flighttube.quality", quality);
|
||||||
|
localStorage.setItem("flighttube.streamQuality", streamQuality);
|
||||||
localStorage.setItem("flighttube.downloadedOnly", downloadedOnly ? "1" : "0");
|
localStorage.setItem("flighttube.downloadedOnly", downloadedOnly ? "1" : "0");
|
||||||
localStorage.setItem("flighttube.hideShorts", hideShorts ? "1" : "0");
|
localStorage.setItem("flighttube.hideShorts", hideShorts ? "1" : "0");
|
||||||
localStorage.setItem("flighttube.sidebarHidden", sidebarHidden ? "1" : "0");
|
localStorage.setItem("flighttube.sidebarHidden", sidebarHidden ? "1" : "0");
|
||||||
} catch {
|
} catch {
|
||||||
/* storage blocked */
|
/* storage blocked */
|
||||||
}
|
}
|
||||||
}, [view, quality, downloadedOnly, hideShorts, sidebarHidden]);
|
}, [view, quality, streamQuality, downloadedOnly, hideShorts, sidebarHidden]);
|
||||||
|
|
||||||
// Offline, the only videos that can be played are the ones already on disk,
|
// Offline, the only videos that can be played are the ones already on disk,
|
||||||
// so the feed collapses to those regardless of the toggle.
|
// so the feed collapses to those regardless of the toggle.
|
||||||
@@ -203,11 +217,15 @@ export default function App() {
|
|||||||
return (
|
return (
|
||||||
<div className="flex h-screen flex-col">
|
<div className="flex h-screen flex-col">
|
||||||
{/* The webview paints the title bar itself. It sits directly above the
|
{/* The webview paints the title bar itself. It sits directly above the
|
||||||
sidebar and top bar, so it takes the panel colour, not the page's. */}
|
sidebar and top bar, so it takes the panel colour, not the page's.
|
||||||
<div
|
In macOS window fullscreen the traffic lights are gone, so the strip
|
||||||
data-tauri-drag-region
|
would just be a blank bar — it collapses instead. */}
|
||||||
className="h-9 shrink-0 bg-white dark:bg-slate-900"
|
{!windowFullscreen && (
|
||||||
/>
|
<div
|
||||||
|
data-tauri-drag-region
|
||||||
|
className="h-9 shrink-0 bg-white dark:bg-slate-900"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="relative flex min-h-0 flex-1 flex-col lg:flex-row">
|
<div className="relative flex min-h-0 flex-1 flex-col lg:flex-row">
|
||||||
{/* With the sidebar hidden, a thin strip along the left edge brings it
|
{/* With the sidebar hidden, a thin strip along the left edge brings it
|
||||||
@@ -322,6 +340,7 @@ export default function App() {
|
|||||||
path={playing.path}
|
path={playing.path}
|
||||||
index={playingIndex}
|
index={playingIndex}
|
||||||
total={items.length}
|
total={items.length}
|
||||||
|
maxHeight={streamQuality === "best" ? null : Number(streamQuality)}
|
||||||
onPrev={
|
onPrev={
|
||||||
stepFrom(playingIndex, -1) != null
|
stepFrom(playingIndex, -1) != null
|
||||||
? () => setPlayingIndex(stepFrom(playingIndex, -1))
|
? () => setPlayingIndex(stepFrom(playingIndex, -1))
|
||||||
@@ -361,6 +380,8 @@ export default function App() {
|
|||||||
onAppearance={setMode}
|
onAppearance={setMode}
|
||||||
quality={quality}
|
quality={quality}
|
||||||
onQuality={setQuality}
|
onQuality={setQuality}
|
||||||
|
streamQuality={streamQuality}
|
||||||
|
onStreamQuality={setStreamQuality}
|
||||||
onError={setFailure}
|
onError={setFailure}
|
||||||
onImported={(n) => {
|
onImported={(n) => {
|
||||||
reload();
|
reload();
|
||||||
|
|||||||
+14
-3
@@ -12,6 +12,7 @@ import type {
|
|||||||
Quality,
|
Quality,
|
||||||
RefreshProgress,
|
RefreshProgress,
|
||||||
RefreshSummary,
|
RefreshSummary,
|
||||||
|
Stream,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
|
|
||||||
export const checkPrereqs = () => invoke<Prereqs>("check_prereqs");
|
export const checkPrereqs = () => invoke<Prereqs>("check_prereqs");
|
||||||
@@ -37,9 +38,19 @@ export const deleteDownload = (videoId: string) =>
|
|||||||
|
|
||||||
export const getConnectivity = () => invoke<boolean>("get_connectivity");
|
export const getConnectivity = () => invoke<boolean>("get_connectivity");
|
||||||
|
|
||||||
/** A directly playable URL (HLS master playlist) for an undownloaded video. */
|
/**
|
||||||
export const resolveStream = (videoId: string) =>
|
* A directly playable URL for an undownloaded video. When a quality cap is set
|
||||||
invoke<string>("resolve_stream", { videoId });
|
* the backend serves a rewritten playlist from its own loopback server, so this
|
||||||
|
* is always a plain URL either way.
|
||||||
|
*/
|
||||||
|
export async function resolveStream(
|
||||||
|
videoId: string,
|
||||||
|
maxHeight: number | null,
|
||||||
|
): Promise<string> {
|
||||||
|
const s = await invoke<Stream>("resolve_stream", { videoId, maxHeight });
|
||||||
|
if (!s.url) throw new Error("No playable stream was returned.");
|
||||||
|
return s.url;
|
||||||
|
}
|
||||||
|
|
||||||
export const setLibraryPath = (path: string) =>
|
export const setLibraryPath = (path: string) =>
|
||||||
invoke<string>("set_library_path", { path });
|
invoke<string>("set_library_path", { path });
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { LiveDownload } from "../hooks/useDownloads";
|
import type { LiveDownload } from "../hooks/useDownloads";
|
||||||
import type { FeedItem } from "../types";
|
import type { FeedItem } from "../types";
|
||||||
import { humanEta } from "./format";
|
import { humanEta } from "./format";
|
||||||
|
import { CONTROL_H } from "./ui";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
item: FeedItem;
|
item: FeedItem;
|
||||||
@@ -11,7 +12,9 @@ interface Props {
|
|||||||
onDelete: () => void;
|
onDelete: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const CHIP = "rounded-lg px-2.5 py-1.5 text-[11px] font-medium shrink-0 cursor-pointer";
|
const CHIP =
|
||||||
|
`inline-flex ${CONTROL_H} shrink-0 cursor-pointer items-center justify-center rounded-lg ` +
|
||||||
|
"px-2.5 text-[12px] font-medium";
|
||||||
|
|
||||||
export default function DownloadButton({
|
export default function DownloadButton({
|
||||||
item, live, online, onDownload, onCancel, onDelete,
|
item, live, online, onDownload, onCancel, onDelete,
|
||||||
@@ -38,22 +41,18 @@ export default function DownloadButton({
|
|||||||
const known = state === "running" && pct != null;
|
const known = state === "running" && pct != null;
|
||||||
return (
|
return (
|
||||||
<button onClick={onCancel} title={humanEta(live?.eta ?? null) || "Cancel download"}
|
<button onClick={onCancel} title={humanEta(live?.eta ?? null) || "Cancel download"}
|
||||||
className={`${CHIP} group w-[104px] border border-slate-300 text-slate-500
|
className={`${CHIP} group relative w-[104px] overflow-hidden border border-slate-300
|
||||||
hover:border-red-500 hover:text-red-600
|
text-slate-500 hover:border-red-500 hover:text-red-600
|
||||||
dark:border-slate-700 dark:text-slate-400 dark:hover:border-red-500 dark:hover:text-red-400`}>
|
dark:border-slate-700 dark:text-slate-400 dark:hover:border-red-500
|
||||||
<span className="hidden group-hover:block">Cancel</span>
|
dark:hover:text-red-400`}>
|
||||||
<span className="block group-hover:hidden">
|
{/* Progress fills the chip itself, so the control stays one line tall. */}
|
||||||
<span className="mb-1 block font-mono tabular-nums">
|
<span
|
||||||
{known ? `${pct!.toFixed(0)}%` : state === "queued" ? "Queued" : "Starting"}
|
className="absolute inset-y-0 left-0 bg-sky-500/15 transition-[width] duration-100"
|
||||||
</span>
|
style={{ width: known ? `${pct}%` : "35%" }}
|
||||||
<span className="block h-1 overflow-hidden rounded-full bg-slate-200 dark:bg-slate-700">
|
/>
|
||||||
<span
|
<span className="relative hidden group-hover:inline">Cancel</span>
|
||||||
className={`block h-full rounded-full bg-sky-500 transition-[width] duration-100 ${
|
<span className="relative inline font-mono tabular-nums group-hover:hidden">
|
||||||
known ? "" : "animate-pulse"
|
{known ? `${pct!.toFixed(0)}%` : state === "queued" ? "Queued" : "Starting"}
|
||||||
}`}
|
|
||||||
style={{ width: known ? `${pct}%` : "35%" }}
|
|
||||||
/>
|
|
||||||
</span>
|
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
|
|||||||
+43
-11
@@ -16,6 +16,8 @@ interface Props {
|
|||||||
/** Present only while streaming, so the video can be saved from here. */
|
/** Present only while streaming, so the video can be saved from here. */
|
||||||
onDownload?: () => void;
|
onDownload?: () => void;
|
||||||
downloading?: boolean;
|
downloading?: boolean;
|
||||||
|
/** Max height for streaming, or null to let the player adapt. */
|
||||||
|
maxHeight: number | null;
|
||||||
/** Position in the current feed, for the "3 of 180" readout. */
|
/** Position in the current feed, for the "3 of 180" readout. */
|
||||||
index: number;
|
index: number;
|
||||||
total: number;
|
total: number;
|
||||||
@@ -92,12 +94,16 @@ const RESUME_EDGE_S = 5;
|
|||||||
* cannot be used: it rejects a `tauri://` origin with "Error 153".
|
* cannot be used: it rejects a `tauri://` origin with "Error 153".
|
||||||
*/
|
*/
|
||||||
export default function Player({
|
export default function Player({
|
||||||
item, path, onClose, onDelete, onPrev, onNext, onDownload, downloading, index, total,
|
item, path, onClose, onDelete, onPrev, onNext, onDownload, downloading,
|
||||||
|
maxHeight, index, total,
|
||||||
}: 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);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [buffering, setBuffering] = useState(true);
|
const [buffering, setBuffering] = useState(true);
|
||||||
|
// Controls and edge arrows fade away while you are just watching.
|
||||||
|
const [chromeVisible, setChromeVisible] = useState(true);
|
||||||
|
const hideTimer = useRef<number | undefined>(undefined);
|
||||||
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);
|
||||||
@@ -110,13 +116,14 @@ export default function Player({
|
|||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
setSrc(null);
|
setSrc(null);
|
||||||
setError(null);
|
setError(null);
|
||||||
resolveStream(item.id)
|
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)));
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [item.id, path]);
|
}, [item.id, path, maxHeight]);
|
||||||
|
|
||||||
|
|
||||||
const persist = useCallback(() => {
|
const persist = useCallback(() => {
|
||||||
const v = videoRef.current;
|
const v = videoRef.current;
|
||||||
@@ -176,8 +183,22 @@ export default function Player({
|
|||||||
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 " +
|
||||||
"bg-slate-950/55 text-2xl leading-none text-white backdrop-blur cursor-pointer " +
|
"bg-slate-950/55 text-2xl leading-none text-white backdrop-blur cursor-pointer " +
|
||||||
"opacity-0 transition-opacity group-hover/stage:opacity-100 focus-visible:opacity-100 " +
|
"transition-opacity duration-200 hover:bg-slate-950/80 disabled:hidden " +
|
||||||
"hover:bg-slate-950/80 disabled:hidden";
|
(chromeVisible ? "opacity-100" : "pointer-events-none opacity-0");
|
||||||
|
|
||||||
|
const showChrome = useCallback(() => {
|
||||||
|
setChromeVisible(true);
|
||||||
|
window.clearTimeout(hideTimer.current);
|
||||||
|
hideTimer.current = window.setTimeout(() => {
|
||||||
|
// Never hide while paused — there would be no way back to play.
|
||||||
|
if (!videoRef.current?.paused) setChromeVisible(false);
|
||||||
|
}, 2600);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
showChrome();
|
||||||
|
return () => window.clearTimeout(hideTimer.current);
|
||||||
|
}, [showChrome, src]);
|
||||||
|
|
||||||
const navBtn =
|
const navBtn =
|
||||||
"rounded-lg border border-slate-300 px-2 py-1.5 text-[11px] font-medium cursor-pointer " +
|
"rounded-lg border border-slate-300 px-2 py-1.5 text-[11px] font-medium cursor-pointer " +
|
||||||
@@ -192,8 +213,11 @@ 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} className={`${BTN} cursor-pointer py-1.5`}>
|
<button onClick={leave} title="Back to the feed (Esc)" aria-label="Back"
|
||||||
← Back
|
className={`${navBtn} w-[30px] p-0`}>
|
||||||
|
<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>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button onClick={onPrev} disabled={!onPrev} title="Previous video" className={navBtn}>
|
<button onClick={onPrev} disabled={!onPrev} title="Previous video" className={navBtn}>
|
||||||
@@ -229,7 +253,9 @@ export default function Player({
|
|||||||
<div
|
<div
|
||||||
ref={stageRef}
|
ref={stageRef}
|
||||||
onContextMenu={(e) => e.preventDefault()}
|
onContextMenu={(e) => e.preventDefault()}
|
||||||
className="group/stage relative min-h-0 flex-1 bg-slate-950"
|
onMouseMove={showChrome}
|
||||||
|
onMouseLeave={() => !videoRef.current?.paused && setChromeVisible(false)}
|
||||||
|
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
|
||||||
@@ -301,7 +327,13 @@ export default function Player({
|
|||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
{src && !error && (
|
{src && !error && (
|
||||||
<PlayerControls videoRef={videoRef} stageRef={stageRef} />
|
<div
|
||||||
|
className={`transition-opacity duration-200 ${
|
||||||
|
chromeVisible ? "opacity-100" : "pointer-events-none opacity-0"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<PlayerControls videoRef={videoRef} stageRef={stageRef} onActivity={showChrome} />
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -323,14 +355,14 @@ export default function Player({
|
|||||||
<button
|
<button
|
||||||
onClick={onDownload}
|
onClick={onDownload}
|
||||||
disabled={downloading}
|
disabled={downloading}
|
||||||
className={`${BTN} cursor-pointer whitespace-nowrap py-1.5`}
|
className={`${navBtn} whitespace-nowrap`}
|
||||||
>
|
>
|
||||||
{downloading ? "Downloading…" : "Download"}
|
{downloading ? "Downloading…" : "Download"}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={() => openExternal(`https://www.youtube.com/watch?v=${item.id}`)}
|
onClick={() => openExternal(`https://www.youtube.com/watch?v=${item.id}`)}
|
||||||
className={`${BTN} cursor-pointer whitespace-nowrap py-1.5`}
|
className={`${navBtn} whitespace-nowrap`}
|
||||||
>
|
>
|
||||||
Open on YouTube
|
Open on YouTube
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ function clock(seconds: number): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const btn =
|
const btn =
|
||||||
"grid size-8 shrink-0 place-items-center rounded-md text-white/90 cursor-pointer " +
|
"grid size-10 shrink-0 place-items-center rounded-lg text-white/90 cursor-pointer " +
|
||||||
"hover:bg-white/15 hover:text-white disabled:opacity-30 disabled:cursor-not-allowed";
|
"hover:bg-white/15 hover:text-white disabled:opacity-30 disabled:cursor-not-allowed";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -113,17 +113,17 @@ export default function PlayerControls({ videoRef, stageRef, onActivity }: Props
|
|||||||
<div
|
<div
|
||||||
// Clicks here must not reach the video's own play/pause handler.
|
// Clicks here must not reach the video's own play/pause handler.
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
className="absolute inset-x-0 bottom-0 z-20 flex items-center gap-2 bg-gradient-to-t
|
className="absolute inset-x-0 bottom-0 z-20 flex items-center gap-2.5 bg-gradient-to-t
|
||||||
from-slate-950/85 via-slate-950/60 to-transparent px-4 pb-3 pt-8"
|
from-slate-950/90 via-slate-950/65 to-transparent px-5 pb-4 pt-10"
|
||||||
>
|
>
|
||||||
<button onClick={togglePlay} className={btn} title={playing ? "Pause (space)" : "Play (space)"}>
|
<button onClick={togglePlay} className={btn} title={playing ? "Pause (space)" : "Play (space)"}>
|
||||||
{playing ? (
|
{playing ? (
|
||||||
<svg viewBox="0 0 24 24" className="size-4" fill="currentColor">
|
<svg viewBox="0 0 24 24" className="size-5" fill="currentColor">
|
||||||
<rect x="6" y="5" width="4" height="14" rx="1" />
|
<rect x="6" y="5" width="4" height="14" rx="1" />
|
||||||
<rect x="14" y="5" width="4" height="14" rx="1" />
|
<rect x="14" y="5" width="4" height="14" rx="1" />
|
||||||
</svg>
|
</svg>
|
||||||
) : (
|
) : (
|
||||||
<svg viewBox="0 0 24 24" className="size-4" fill="currentColor">
|
<svg viewBox="0 0 24 24" className="size-5" fill="currentColor">
|
||||||
<path d="M8 5.5v13l11-6.5z" />
|
<path d="M8 5.5v13l11-6.5z" />
|
||||||
</svg>
|
</svg>
|
||||||
)}
|
)}
|
||||||
@@ -134,7 +134,7 @@ export default function PlayerControls({ videoRef, stageRef, onActivity }: Props
|
|||||||
className={btn}
|
className={btn}
|
||||||
title={`Back ${SKIP_S}s`}
|
title={`Back ${SKIP_S}s`}
|
||||||
>
|
>
|
||||||
<svg viewBox="0 0 24 24" className="size-4" fill="none" stroke="currentColor" strokeWidth="2">
|
<svg viewBox="0 0 24 24" className="size-5" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M11 17l-5-5 5-5M18 17l-5-5 5-5" />
|
<path strokeLinecap="round" strokeLinejoin="round" d="M11 17l-5-5 5-5M18 17l-5-5 5-5" />
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
@@ -143,12 +143,12 @@ export default function PlayerControls({ videoRef, stageRef, onActivity }: Props
|
|||||||
className={btn}
|
className={btn}
|
||||||
title={`Forward ${SKIP_S}s`}
|
title={`Forward ${SKIP_S}s`}
|
||||||
>
|
>
|
||||||
<svg viewBox="0 0 24 24" className="size-4" fill="none" stroke="currentColor" strokeWidth="2">
|
<svg viewBox="0 0 24 24" className="size-5" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M13 7l5 5-5 5M6 7l5 5-5 5" />
|
<path strokeLinecap="round" strokeLinejoin="round" d="M13 7l5 5-5 5M6 7l5 5-5 5" />
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<span className="shrink-0 font-mono text-[11px] tabular-nums text-white/80">{clock(time)}</span>
|
<span className="shrink-0 font-mono text-[12px] tabular-nums text-white/85">{clock(time)}</span>
|
||||||
|
|
||||||
<input
|
<input
|
||||||
type="range"
|
type="range"
|
||||||
@@ -162,20 +162,20 @@ export default function PlayerControls({ videoRef, stageRef, onActivity }: Props
|
|||||||
onActivity?.();
|
onActivity?.();
|
||||||
}}
|
}}
|
||||||
aria-label="Seek"
|
aria-label="Seek"
|
||||||
className="h-1 min-w-0 flex-1 cursor-pointer appearance-none rounded-full bg-white/25 accent-sky-500"
|
className="h-1.5 min-w-0 flex-1 cursor-pointer appearance-none rounded-full bg-white/25 accent-sky-500"
|
||||||
style={{
|
style={{
|
||||||
background: `linear-gradient(to right, var(--color-sky-500) ${pct}%, rgba(255,255,255,0.25) ${pct}%)`,
|
background: `linear-gradient(to right, var(--color-sky-500) ${pct}%, rgba(255,255,255,0.25) ${pct}%)`,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<span className="shrink-0 font-mono text-[11px] tabular-nums text-white/80">{clock(duration)}</span>
|
<span className="shrink-0 font-mono text-[12px] tabular-nums text-white/85">{clock(duration)}</span>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={act((v) => (v.muted = !v.muted))}
|
onClick={act((v) => (v.muted = !v.muted))}
|
||||||
className={btn}
|
className={btn}
|
||||||
title={muted || volume === 0 ? "Unmute" : "Mute"}
|
title={muted || volume === 0 ? "Unmute" : "Mute"}
|
||||||
>
|
>
|
||||||
<svg viewBox="0 0 24 24" className="size-4" fill="currentColor">
|
<svg viewBox="0 0 24 24" className="size-5" fill="currentColor">
|
||||||
<path d="M4 9v6h4l5 4V5L8 9H4z" />
|
<path d="M4 9v6h4l5 4V5L8 9H4z" />
|
||||||
{muted || volume === 0 ? (
|
{muted || volume === 0 ? (
|
||||||
<path d="M16 9l5 6M21 9l-5 6" stroke="currentColor" strokeWidth="2" fill="none" strokeLinecap="round" />
|
<path d="M16 9l5 6M21 9l-5 6" stroke="currentColor" strokeWidth="2" fill="none" strokeLinecap="round" />
|
||||||
@@ -204,7 +204,7 @@ export default function PlayerControls({ videoRef, stageRef, onActivity }: Props
|
|||||||
onActivity?.();
|
onActivity?.();
|
||||||
}}
|
}}
|
||||||
aria-label="Volume"
|
aria-label="Volume"
|
||||||
className="h-1 w-20 shrink-0 cursor-pointer appearance-none rounded-full accent-sky-500"
|
className="h-1.5 w-24 shrink-0 cursor-pointer appearance-none rounded-full accent-sky-500"
|
||||||
style={{
|
style={{
|
||||||
background: `linear-gradient(to right, rgba(255,255,255,0.85) ${
|
background: `linear-gradient(to right, rgba(255,255,255,0.85) ${
|
||||||
(muted ? 0 : volume) * 100
|
(muted ? 0 : volume) * 100
|
||||||
@@ -213,14 +213,14 @@ export default function PlayerControls({ videoRef, stageRef, onActivity }: Props
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<button onClick={togglePip} className={btn} title={pip ? "Leave Picture in Picture" : "Picture in Picture"}>
|
<button onClick={togglePip} className={btn} title={pip ? "Leave Picture in Picture" : "Picture in Picture"}>
|
||||||
<svg viewBox="0 0 24 24" className="size-4" fill="none" stroke="currentColor" strokeWidth="2">
|
<svg viewBox="0 0 24 24" className="size-5" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
<rect x="3" y="5" width="18" height="14" rx="2" />
|
<rect x="3" y="5" width="18" height="14" rx="2" />
|
||||||
<rect x="12" y="11" width="7" height="6" rx="1" fill="currentColor" stroke="none" />
|
<rect x="12" y="11" width="7" height="6" rx="1" fill="currentColor" stroke="none" />
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button onClick={toggleFull} className={btn} title={full ? "Leave full screen (f)" : "Full screen (f)"}>
|
<button onClick={toggleFull} className={btn} title={full ? "Leave full screen (f)" : "Full screen (f)"}>
|
||||||
<svg viewBox="0 0 24 24" className="size-4" fill="none" stroke="currentColor" strokeWidth="2">
|
<svg viewBox="0 0 24 24" className="size-5" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
{full ? (
|
{full ? (
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 4v5H4M15 4v5h5M9 20v-5H4M15 20v-5h5" />
|
<path strokeLinecap="round" strokeLinejoin="round" d="M9 4v5H4M15 4v5h5M9 20v-5H4M15 20v-5h5" />
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -3,10 +3,12 @@ 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 { QUALITIES, type ImportPreview, type Prereqs, type Quality } from "../types";
|
import {
|
||||||
|
QUALITIES, STREAM_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_PRIMARY, CONTROL_H, Dialog, HELP, ICON_BTN, LABEL, SectionHeading, Segmented, SUBPANEL,
|
||||||
} from "./ui";
|
} from "./ui";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -16,9 +18,15 @@ interface Props {
|
|||||||
onAppearance: (a: Appearance) => void;
|
onAppearance: (a: Appearance) => void;
|
||||||
quality: Quality;
|
quality: Quality;
|
||||||
onQuality: (q: Quality) => void;
|
onQuality: (q: Quality) => void;
|
||||||
|
streamQuality: Quality;
|
||||||
|
onStreamQuality: (q: Quality) => void;
|
||||||
onError: (message: string) => void;
|
onError: (message: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const SELECT =
|
||||||
|
`w-full ${CONTROL_H} cursor-pointer rounded-lg border border-slate-300 bg-white px-2 ` +
|
||||||
|
"text-[12px] outline-none dark:border-slate-700 dark:bg-slate-800";
|
||||||
|
|
||||||
function StatusRow({ label, value }: { label: string; value: string | null }) {
|
function StatusRow({ label, value }: { label: string; value: string | null }) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-start justify-between gap-4 border-b border-slate-200 py-2 last:border-b-0 dark:border-slate-800">
|
<div className="flex items-start justify-between gap-4 border-b border-slate-200 py-2 last:border-b-0 dark:border-slate-800">
|
||||||
@@ -35,7 +43,8 @@ function StatusRow({ label, value }: { label: string; value: string | null }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function Settings({
|
export default function Settings({
|
||||||
onClose, onImported, appearance, onAppearance, quality, onQuality, onError,
|
onClose, onImported, appearance, onAppearance, quality, onQuality,
|
||||||
|
streamQuality, onStreamQuality, onError,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const [prereqs, setPrereqs] = useState<Prereqs | null>(null);
|
const [prereqs, setPrereqs] = useState<Prereqs | null>(null);
|
||||||
const [pending, setPending] = useState<{ path: string; preview: ImportPreview } | null>(null);
|
const [pending, setPending] = useState<{ path: string; preview: ImportPreview } | null>(null);
|
||||||
@@ -96,7 +105,17 @@ export default function Settings({
|
|||||||
>
|
>
|
||||||
<header className="flex items-center justify-between gap-2 border-b border-slate-200 px-5 py-4 dark:border-slate-800">
|
<header className="flex items-center justify-between gap-2 border-b border-slate-200 px-5 py-4 dark:border-slate-800">
|
||||||
<h2 className="text-[15px] font-semibold tracking-tight">Settings</h2>
|
<h2 className="text-[15px] font-semibold tracking-tight">Settings</h2>
|
||||||
<button onClick={onClose} className={`${BTN_CHROME} cursor-pointer`}>Close</button>
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
title="Close settings"
|
||||||
|
aria-label="Close settings"
|
||||||
|
className={`${ICON_BTN} text-slate-500 hover:bg-slate-100 hover:text-slate-900
|
||||||
|
dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-white`}
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 24 24" className="size-4" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<path strokeLinecap="round" d="M6 6l12 12M18 6L6 18" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div className="min-h-0 flex-1 overflow-y-auto">
|
<div className="min-h-0 flex-1 overflow-y-auto">
|
||||||
@@ -135,9 +154,7 @@ export default function Settings({
|
|||||||
<select
|
<select
|
||||||
value={quality}
|
value={quality}
|
||||||
onChange={(e) => onQuality(e.target.value as Quality)}
|
onChange={(e) => onQuality(e.target.value as Quality)}
|
||||||
className="w-full rounded-lg border border-slate-300 bg-white px-2 py-1.5
|
className={SELECT}
|
||||||
text-[13px] outline-none cursor-pointer
|
|
||||||
dark:border-slate-700 dark:bg-slate-800"
|
|
||||||
>
|
>
|
||||||
{QUALITIES.map((q) => (
|
{QUALITIES.map((q) => (
|
||||||
<option key={q.value} value={q.value}>
|
<option key={q.value} value={q.value}>
|
||||||
@@ -146,6 +163,25 @@ export default function Settings({
|
|||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
|
<label className="mt-2 grid grid-cols-[92px_1fr] items-center gap-2">
|
||||||
|
<span className={LABEL}>Streaming</span>
|
||||||
|
<select
|
||||||
|
value={streamQuality}
|
||||||
|
onChange={(e) => onStreamQuality(e.target.value as Quality)}
|
||||||
|
className={SELECT}
|
||||||
|
>
|
||||||
|
{STREAM_QUALITIES.map((q) => (
|
||||||
|
<option key={q.value} value={q.value}>
|
||||||
|
{q.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<p className={`mt-2 ${HELP}`}>
|
||||||
|
Streaming quality applies when you play something you have not downloaded.
|
||||||
|
Best lets the player adapt to your connection; a fixed height pins it.
|
||||||
|
</p>
|
||||||
</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">
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { ChannelWithCount } from "../types";
|
import type { ChannelWithCount } from "../types";
|
||||||
import { BTN_CHROME, HEADING } from "./ui";
|
import { HEADING, ICON_BTN } from "./ui";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
channels: ChannelWithCount[];
|
channels: ChannelWithCount[];
|
||||||
@@ -49,7 +49,7 @@ export default function Sidebar({
|
|||||||
onClick={onOpenSettings}
|
onClick={onOpenSettings}
|
||||||
title="Settings"
|
title="Settings"
|
||||||
aria-label="Settings"
|
aria-label="Settings"
|
||||||
className={`${BTN_CHROME} grid size-7 cursor-pointer place-items-center`}
|
className={`${ICON_BTN} text-slate-500 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-white`}
|
||||||
>
|
>
|
||||||
<svg viewBox="0 0 24 24" className="size-4" fill="none" stroke="currentColor" strokeWidth="1.8">
|
<svg viewBox="0 0 24 24" className="size-4" fill="none" stroke="currentColor" strokeWidth="1.8">
|
||||||
<circle cx="12" cy="12" r="3.2" />
|
<circle cx="12" cy="12" r="3.2" />
|
||||||
@@ -60,7 +60,7 @@ export default function Sidebar({
|
|||||||
onClick={onHide}
|
onClick={onHide}
|
||||||
title="Hide subscriptions"
|
title="Hide subscriptions"
|
||||||
aria-label="Hide subscriptions"
|
aria-label="Hide subscriptions"
|
||||||
className={`${BTN_CHROME} grid size-7 cursor-pointer place-items-center`}
|
className={`${ICON_BTN} text-slate-500 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-white`}
|
||||||
>
|
>
|
||||||
<svg viewBox="0 0 24 24" className="size-4" fill="none" stroke="currentColor" strokeWidth="1.8">
|
<svg viewBox="0 0 24 24" className="size-4" fill="none" stroke="currentColor" strokeWidth="1.8">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 6l-6 6 6 6" />
|
<path strokeLinecap="round" strokeLinejoin="round" d="M15 6l-6 6 6 6" />
|
||||||
|
|||||||
+12
-15
@@ -1,4 +1,4 @@
|
|||||||
import { INPUT, Segmented } from "./ui";
|
import { CONTROL_H, ICON_BTN, INPUT, Segmented } from "./ui";
|
||||||
|
|
||||||
export type ViewMode = "list" | "grid";
|
export type ViewMode = "list" | "grid";
|
||||||
|
|
||||||
@@ -39,7 +39,7 @@ function Toggle({
|
|||||||
title={title}
|
title={title}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
className={
|
className={
|
||||||
"cursor-pointer whitespace-nowrap rounded-lg border px-2.5 py-1.5 text-[11px] " +
|
`inline-flex ${CONTROL_H} cursor-pointer items-center whitespace-nowrap rounded-lg border px-2.5 text-[12px] ` +
|
||||||
"font-medium transition-colors disabled:cursor-not-allowed disabled:opacity-40 " +
|
"font-medium transition-colors disabled:cursor-not-allowed disabled:opacity-40 " +
|
||||||
(active
|
(active
|
||||||
? "border-sky-500 bg-sky-500 text-white hover:bg-sky-400"
|
? "border-sky-500 bg-sky-500 text-white hover:bg-sky-400"
|
||||||
@@ -73,10 +73,9 @@ export default function TopBar({
|
|||||||
onClick={onShowSidebar}
|
onClick={onShowSidebar}
|
||||||
title="Show subscriptions"
|
title="Show subscriptions"
|
||||||
aria-label="Show subscriptions"
|
aria-label="Show subscriptions"
|
||||||
className="grid size-[30px] shrink-0 cursor-pointer place-items-center rounded-lg
|
className={`${ICON_BTN} border border-slate-300 text-slate-500 hover:border-sky-500
|
||||||
border border-slate-300 text-slate-500 hover:border-sky-500
|
hover:text-sky-600 dark:border-slate-700 dark:text-slate-400
|
||||||
hover:text-sky-600 dark:border-slate-700 dark:text-slate-400
|
dark:hover:border-sky-500 dark:hover:text-sky-400`}
|
||||||
dark:hover:border-sky-500 dark:hover:text-sky-400"
|
|
||||||
>
|
>
|
||||||
<svg viewBox="0 0 24 24" className="size-4" fill="none" stroke="currentColor" strokeWidth="1.8">
|
<svg viewBox="0 0 24 24" className="size-4" fill="none" stroke="currentColor" strokeWidth="1.8">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M4 6h16M4 12h16M4 18h16" />
|
<path strokeLinecap="round" strokeLinejoin="round" d="M4 6h16M4 12h16M4 18h16" />
|
||||||
@@ -87,7 +86,7 @@ export default function TopBar({
|
|||||||
value={search}
|
value={search}
|
||||||
onChange={(e) => onSearch(e.target.value)}
|
onChange={(e) => onSearch(e.target.value)}
|
||||||
placeholder="Search videos and channels"
|
placeholder="Search videos and channels"
|
||||||
className={`${INPUT} min-w-72 max-w-sm flex-1 py-1.5`}
|
className={`${INPUT} min-w-72 max-w-sm flex-1`}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<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">
|
||||||
@@ -125,11 +124,11 @@ export default function TopBar({
|
|||||||
? "Offline mode is forced on — click to go back online"
|
? "Offline mode is forced on — click to go back online"
|
||||||
: "Simulate being offline"
|
: "Simulate being offline"
|
||||||
}
|
}
|
||||||
className="flex cursor-pointer items-center gap-1.5 whitespace-nowrap rounded-lg border
|
className={`inline-flex ${CONTROL_H} cursor-pointer items-center gap-1.5 whitespace-nowrap
|
||||||
border-slate-300 px-2.5 py-1.5 text-[11px] font-medium text-slate-500
|
rounded-lg border border-slate-300 px-2.5 text-[12px] font-medium text-slate-500
|
||||||
hover:border-sky-500 hover:text-sky-600
|
hover:border-sky-500 hover:text-sky-600
|
||||||
dark:border-slate-700 dark:text-slate-400 dark:hover:border-sky-500
|
dark:border-slate-700 dark:text-slate-400 dark:hover:border-sky-500
|
||||||
dark:hover:text-sky-400"
|
dark:hover:text-sky-400`}
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
className={`size-1.5 rounded-full ${online ? "bg-sky-500" : "bg-amber-500"}`}
|
className={`size-1.5 rounded-full ${online ? "bg-sky-500" : "bg-amber-500"}`}
|
||||||
@@ -146,9 +145,7 @@ export default function TopBar({
|
|||||||
: "Refreshing needs a connection"
|
: "Refreshing needs a connection"
|
||||||
}
|
}
|
||||||
aria-label="Refresh"
|
aria-label="Refresh"
|
||||||
className="grid size-[30px] shrink-0 cursor-pointer place-items-center rounded-lg
|
className={`${ICON_BTN} bg-sky-500 text-white hover:bg-sky-400`}>
|
||||||
bg-sky-500 text-white hover:bg-sky-400 disabled:cursor-not-allowed
|
|
||||||
disabled:opacity-40">
|
|
||||||
<svg viewBox="0 0 24 24" className={`size-4 ${refreshing ? "animate-spin" : ""}`}
|
<svg viewBox="0 0 24 24" className={`size-4 ${refreshing ? "animate-spin" : ""}`}
|
||||||
fill="none" stroke="currentColor" strokeWidth="2">
|
fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round"
|
<path strokeLinecap="round" strokeLinejoin="round"
|
||||||
|
|||||||
+22
-9
@@ -4,6 +4,17 @@
|
|||||||
*/
|
*/
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every control in the app is this tall. Width still grows with the label —
|
||||||
|
* only the height is fixed, so a row of mixed buttons lines up.
|
||||||
|
*/
|
||||||
|
export const CONTROL_H = "h-[30px]";
|
||||||
|
|
||||||
|
/** Square version, for icon-only buttons. */
|
||||||
|
export const ICON_BTN =
|
||||||
|
`grid ${CONTROL_H} w-[30px] shrink-0 place-items-center rounded-lg cursor-pointer ` +
|
||||||
|
"disabled:cursor-not-allowed disabled:opacity-40";
|
||||||
|
|
||||||
export const HEADING =
|
export const HEADING =
|
||||||
"text-[11px] font-bold uppercase tracking-widest text-slate-500 dark:text-slate-400";
|
"text-[11px] font-bold uppercase tracking-widest text-slate-500 dark:text-slate-400";
|
||||||
export const LABEL = "text-[12px] text-slate-500 dark:text-slate-400";
|
export const LABEL = "text-[12px] text-slate-500 dark:text-slate-400";
|
||||||
@@ -14,27 +25,29 @@ export const PANEL =
|
|||||||
export const SECTION =
|
export const SECTION =
|
||||||
"border-b border-slate-200 px-4 py-4 dark:border-slate-800";
|
"border-b border-slate-200 px-4 py-4 dark:border-slate-800";
|
||||||
export const INPUT =
|
export const INPUT =
|
||||||
"w-full rounded-lg border border-slate-300 bg-white px-3 py-2 text-[13px] outline-none " +
|
`w-full ${CONTROL_H} rounded-lg border border-slate-300 bg-white px-3 text-[12px] outline-none ` +
|
||||||
"placeholder:text-slate-400 dark:border-slate-700 dark:bg-slate-800 dark:placeholder:text-slate-500";
|
"placeholder:text-slate-400 dark:border-slate-700 dark:bg-slate-800 dark:placeholder:text-slate-500";
|
||||||
export const SUBPANEL = "rounded-lg bg-slate-50 p-3 dark:bg-slate-800/50";
|
export const SUBPANEL = "rounded-lg bg-slate-50 p-3 dark:bg-slate-800/50";
|
||||||
|
|
||||||
const BTN_BASE = "rounded-lg text-[13px] disabled:cursor-not-allowed";
|
const BTN_BASE =
|
||||||
|
`inline-flex ${CONTROL_H} items-center justify-center rounded-lg text-[12px] ` +
|
||||||
|
"disabled:cursor-not-allowed";
|
||||||
|
|
||||||
export const BTN =
|
export const BTN =
|
||||||
`${BTN_BASE} border border-slate-300 px-3 py-2 font-medium ` +
|
`${BTN_BASE} border border-slate-300 px-2.5 font-medium ` +
|
||||||
"hover:border-sky-500 hover:text-sky-600 disabled:opacity-40 " +
|
"hover:border-sky-500 hover:text-sky-600 disabled:opacity-40 " +
|
||||||
"dark:border-slate-700 dark:hover:border-sky-500 dark:hover:text-sky-400";
|
"dark:border-slate-700 dark:hover:border-sky-500 dark:hover:text-sky-400";
|
||||||
|
|
||||||
export const BTN_PRIMARY =
|
export const BTN_PRIMARY =
|
||||||
`${BTN_BASE} bg-sky-500 px-3 py-2 font-semibold text-white ` +
|
`${BTN_BASE} bg-sky-500 px-3 font-semibold text-white ` +
|
||||||
"hover:bg-sky-400 disabled:opacity-40";
|
"hover:bg-sky-400 disabled:opacity-40";
|
||||||
|
|
||||||
export const BTN_DANGER =
|
export const BTN_DANGER =
|
||||||
`${BTN_BASE} bg-red-600 px-3 py-1.5 font-semibold text-white hover:bg-red-500`;
|
`${BTN_BASE} bg-red-600 px-3 font-semibold text-white hover:bg-red-500`;
|
||||||
|
|
||||||
/** Header actions: quieter than a secondary button, still a real target. */
|
/** Header actions: quieter than a secondary button, still a real target. */
|
||||||
export const BTN_CHROME =
|
export const BTN_CHROME =
|
||||||
"rounded-md px-2 py-1 text-[11px] font-medium text-slate-500 " +
|
`inline-flex ${CONTROL_H} items-center rounded-lg px-2 text-[12px] font-medium text-slate-500 ` +
|
||||||
"hover:bg-slate-100 hover:text-slate-900 disabled:opacity-40 disabled:hover:bg-transparent " +
|
"hover:bg-slate-100 hover:text-slate-900 disabled:opacity-40 disabled:hover:bg-transparent " +
|
||||||
"dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-white";
|
"dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-white";
|
||||||
|
|
||||||
@@ -85,7 +98,7 @@ export function Segmented<T extends string>({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
role="group"
|
role="group"
|
||||||
className="flex rounded-lg border border-slate-300 p-0.5 dark:border-slate-700"
|
className={`flex ${CONTROL_H} items-center rounded-lg border border-slate-300 p-0.5 dark:border-slate-700`}
|
||||||
>
|
>
|
||||||
{options.map((o) => {
|
{options.map((o) => {
|
||||||
const active = o.value === value;
|
const active = o.value === value;
|
||||||
@@ -94,7 +107,7 @@ export function Segmented<T extends string>({
|
|||||||
key={o.value}
|
key={o.value}
|
||||||
onClick={() => onChange(o.value)}
|
onClick={() => onChange(o.value)}
|
||||||
className={
|
className={
|
||||||
"rounded-md px-2 py-1 text-[11px] font-medium transition-colors cursor-pointer " +
|
"h-full rounded-md px-2.5 text-[12px] font-medium transition-colors cursor-pointer " +
|
||||||
(active
|
(active
|
||||||
? "bg-slate-900! text-white! dark:bg-white! dark:text-slate-900!"
|
? "bg-slate-900! text-white! dark:bg-white! dark:text-slate-900!"
|
||||||
: "text-slate-500 hover:bg-slate-100 hover:text-slate-900 " +
|
: "text-slate-500 hover:bg-slate-100 hover:text-slate-900 " +
|
||||||
@@ -172,7 +185,7 @@ export function Dialog({
|
|||||||
{onConfirm && (
|
{onConfirm && (
|
||||||
<button
|
<button
|
||||||
onClick={onConfirm}
|
onClick={onConfirm}
|
||||||
className={`${destructive ? BTN_DANGER + " py-2" : BTN_PRIMARY} cursor-pointer`}
|
className={`${destructive ? BTN_DANGER : BTN_PRIMARY} cursor-pointer`}
|
||||||
>
|
>
|
||||||
{confirmLabel ?? "Continue"}
|
{confirmLabel ?? "Continue"}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True while the macOS *window* is fullscreen (the green button), as distinct
|
||||||
|
* from the video being fullscreen. In that state the traffic lights are gone,
|
||||||
|
* so the title-bar strip the app paints is pure dead space and must collapse.
|
||||||
|
*/
|
||||||
|
export function useWindowFullscreen(): boolean {
|
||||||
|
const [full, setFull] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const win = getCurrentWindow();
|
||||||
|
let unlisten: (() => void) | undefined;
|
||||||
|
const check = () => {
|
||||||
|
win.isFullscreen().then(setFull).catch(() => {});
|
||||||
|
};
|
||||||
|
check();
|
||||||
|
win.onResized(check).then((u) => (unlisten = u));
|
||||||
|
return () => unlisten?.();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return full;
|
||||||
|
}
|
||||||
@@ -89,6 +89,11 @@ export interface ImportPreview {
|
|||||||
/** "best" or a maximum height in pixels. */
|
/** "best" or a maximum height in pixels. */
|
||||||
export type Quality = "best" | "2160" | "1440" | "1080" | "720" | "480";
|
export type Quality = "best" | "2160" | "1440" | "1080" | "720" | "480";
|
||||||
|
|
||||||
|
export interface Stream {
|
||||||
|
url: string | null;
|
||||||
|
playlist: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
export const QUALITIES: Array<{ value: Quality; label: string }> = [
|
export const QUALITIES: Array<{ value: Quality; label: string }> = [
|
||||||
{ value: "best", label: "Best available (up to 4K)" },
|
{ value: "best", label: "Best available (up to 4K)" },
|
||||||
{ value: "2160", label: "2160p — 4K" },
|
{ value: "2160", label: "2160p — 4K" },
|
||||||
@@ -97,3 +102,11 @@ export const QUALITIES: Array<{ value: Quality; label: string }> = [
|
|||||||
{ value: "720", label: "720p" },
|
{ value: "720", label: "720p" },
|
||||||
{ value: "480", label: "480p" },
|
{ value: "480", label: "480p" },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/** Streaming tops out at 1080p — YouTube's HLS carries nothing higher. */
|
||||||
|
export const STREAM_QUALITIES: Array<{ value: Quality; label: string }> = [
|
||||||
|
{ value: "best", label: "Best available (adaptive)" },
|
||||||
|
{ value: "1080", label: "1080p" },
|
||||||
|
{ value: "720", label: "720p" },
|
||||||
|
{ value: "480", label: "480p" },
|
||||||
|
];
|
||||||
|
|||||||
Reference in New Issue
Block a user