feat: tile view, in-app streaming, and self-painted title bar
Clicking an undownloaded video now plays it in the app instead of handing off to the browser. YouTube's iframe embed cannot be used — it rejects a tauri:// origin with Error 153 — so yt-dlp resolves YouTube's HLS master playlist instead, whose H.264+AAC variants AVFoundation streams natively in WKWebView, adaptive up to 1080p. Adds an optional Tiles view alongside the list, remembered between launches. The title bar is now transparent with the title hidden, and the app paints that strip itself in the page background so it matches instead of showing macOS chrome beside the traffic lights.
This commit is contained in:
@@ -165,6 +165,61 @@ pub async fn import_takeout_csv(
|
||||
db.replace_channels(&channels)
|
||||
}
|
||||
|
||||
/// Resolves a directly playable URL for a video we have NOT downloaded, so it
|
||||
/// can stream inside the app's own player.
|
||||
///
|
||||
/// YouTube's iframe embed is not an option here: it rejects a Tauri window with
|
||||
/// "Error 153" because the page origin is `tauri://localhost` rather than an
|
||||
/// http(s) origin it will accept. Instead we ask yt-dlp for YouTube's HLS master
|
||||
/// playlist, which lists H.264 + AAC variants up to 1080p with separate audio
|
||||
/// tracks — exactly the shape AVFoundation plays natively in WKWebView, with
|
||||
/// adaptive bitrate for free.
|
||||
#[tauri::command]
|
||||
pub async fn resolve_stream(video_id: String) -> Result<String, String> {
|
||||
let url = format!("https://www.youtube.com/watch?v={video_id}");
|
||||
|
||||
// The HLS master playlist. Every m3u8 format shares the same manifest_url,
|
||||
// so any one of them yields the master.
|
||||
if let Some(u) = yt_dlp_print(
|
||||
&["-f", "bv*[protocol^=m3u8]", "--print", "%(manifest_url)s", &url],
|
||||
)
|
||||
.await
|
||||
{
|
||||
return Ok(u);
|
||||
}
|
||||
|
||||
// Rare fallback: an old-style progressive muxed MP4.
|
||||
if let Some(u) = yt_dlp_print(&[
|
||||
"-f",
|
||||
"b[ext=mp4][acodec!=none][vcodec!=none]",
|
||||
"--print",
|
||||
"%(url)s",
|
||||
&url,
|
||||
])
|
||||
.await
|
||||
{
|
||||
return Ok(u);
|
||||
}
|
||||
|
||||
Err("Could not find a playable stream for this video.".into())
|
||||
}
|
||||
|
||||
/// Runs yt-dlp and returns its first non-empty stdout line, or None.
|
||||
async fn yt_dlp_print(args: &[&str]) -> Option<String> {
|
||||
let mut cmd = tokio::process::Command::new(bin("yt-dlp"));
|
||||
cmd.args(["--no-warnings", "--no-playlist", "--simulate"]);
|
||||
cmd.args(args);
|
||||
let out = cmd.output().await.ok()?;
|
||||
if !out.status.success() {
|
||||
return None;
|
||||
}
|
||||
String::from_utf8_lossy(&out.stdout)
|
||||
.lines()
|
||||
.map(str::trim)
|
||||
.find(|l| l.starts_with("http"))
|
||||
.map(str::to_string)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn list_channels(state: State<'_, AppState>) -> Result<Vec<ChannelWithCount>, String> {
|
||||
state.db.lock().await.list_channels()
|
||||
|
||||
@@ -25,6 +25,7 @@ pub fn run() {
|
||||
commands::preview_takeout_import,
|
||||
commands::list_channels,
|
||||
commands::list_feed,
|
||||
commands::resolve_stream,
|
||||
commands::refresh_feeds,
|
||||
commands::download_video,
|
||||
commands::cancel_download,
|
||||
|
||||
@@ -17,7 +17,9 @@
|
||||
"height": 780,
|
||||
"minWidth": 900,
|
||||
"minHeight": 560,
|
||||
"center": true
|
||||
"center": true,
|
||||
"titleBarStyle": "Transparent",
|
||||
"hiddenTitle": true
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
|
||||
+61
-17
@@ -1,14 +1,14 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
cancelDownload, deleteDownload, downloadVideo, onRefreshProgress,
|
||||
openExternal, refreshFeeds,
|
||||
cancelDownload, deleteDownload, downloadVideo, onRefreshProgress, refreshFeeds,
|
||||
} from "./api";
|
||||
import Player from "./components/Player";
|
||||
import Settings from "./components/Settings";
|
||||
import Sidebar from "./components/Sidebar";
|
||||
import TopBar from "./components/TopBar";
|
||||
import TopBar, { type ViewMode } from "./components/TopBar";
|
||||
import { Dialog, Toast } from "./components/ui";
|
||||
import VideoRow from "./components/VideoRow";
|
||||
import VideoTile from "./components/VideoTile";
|
||||
import { useAppearance } from "./hooks/useAppearance";
|
||||
import { useConnectivity } from "./hooks/useConnectivity";
|
||||
import { useDownloads } from "./hooks/useDownloads";
|
||||
@@ -22,8 +22,15 @@ export default function App() {
|
||||
const [search, setSearch] = useState("");
|
||||
const [downloadedOnly, setDownloadedOnly] = useState(false);
|
||||
const [hideShorts, setHideShorts] = useState(false);
|
||||
const [view, setView] = useState<ViewMode>(() => {
|
||||
try {
|
||||
return localStorage.getItem("flighttube.view") === "grid" ? "grid" : "list";
|
||||
} catch {
|
||||
return "list";
|
||||
}
|
||||
});
|
||||
const [showSettings, setShowSettings] = useState(false);
|
||||
const [playing, setPlaying] = useState<{ item: FeedItem; path: string } | null>(null);
|
||||
const [playing, setPlaying] = useState<{ item: FeedItem; path: string | null } | null>(null);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [refreshProgress, setRefreshProgress] = useState<RefreshProgress | null>(null);
|
||||
const [toast, setToast] = useState<string | null>(null);
|
||||
@@ -42,6 +49,14 @@ export default function App() {
|
||||
}, []);
|
||||
useEffect(() => () => window.clearTimeout(toastTimer.current), []);
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
localStorage.setItem("flighttube.view", view);
|
||||
} catch {
|
||||
/* storage blocked */
|
||||
}
|
||||
}, [view]);
|
||||
|
||||
// Offline, the only videos that can be played are the ones already on disk,
|
||||
// so the feed collapses to those regardless of the toggle.
|
||||
const effectiveDownloadedOnly = downloadedOnly || !online;
|
||||
@@ -102,13 +117,15 @@ export default function App() {
|
||||
}, [reload, say]);
|
||||
|
||||
const openItem = useCallback(
|
||||
async (item: FeedItem) => {
|
||||
(item: FeedItem) => {
|
||||
const path = live[item.id]?.path ?? item.path;
|
||||
const done = (live[item.id]?.state ?? item.state) === "done";
|
||||
// Downloaded plays from disk; anything else streams YouTube's embed in
|
||||
// the app. Only being offline with no local copy leaves nothing to play.
|
||||
if (done && path) {
|
||||
setPlaying({ item, path });
|
||||
} else if (online) {
|
||||
await openExternal(`https://www.youtube.com/watch?v=${item.id}`);
|
||||
setPlaying({ item, path: null });
|
||||
} else {
|
||||
setFailure("That video isn't downloaded, and you're offline.");
|
||||
}
|
||||
@@ -127,7 +144,16 @@ export default function App() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-screen flex-col lg:flex-row">
|
||||
<div className="flex h-screen flex-col">
|
||||
{/* The title bar is transparent, so the webview paints this strip itself
|
||||
in the page background — the macOS traffic lights sit on top of it.
|
||||
Without the drag region the strip would be dead space. */}
|
||||
<div
|
||||
data-tauri-drag-region
|
||||
className="h-9 shrink-0 bg-slate-100 dark:bg-slate-950"
|
||||
/>
|
||||
|
||||
<div className="flex min-h-0 flex-1 flex-col lg:flex-row">
|
||||
<Sidebar
|
||||
channels={channels}
|
||||
activeChannel={channelId}
|
||||
@@ -146,6 +172,7 @@ export default function App() {
|
||||
onToggleForcedOffline={() => { setForcedOffline(!forcedOffline); probe(); }}
|
||||
onRefresh={doRefresh} refreshing={refreshing} refreshProgress={refreshProgress}
|
||||
resultCount={items.length}
|
||||
view={view} onView={setView}
|
||||
/>
|
||||
|
||||
{!online && (
|
||||
@@ -166,22 +193,39 @@ export default function App() {
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<ul className="mx-auto max-w-4xl space-y-1.5">
|
||||
{items.map((item) => (
|
||||
<VideoRow key={item.id} item={item} live={live[item.id]} online={online}
|
||||
onOpen={() => openItem(item)}
|
||||
onDownload={() => downloadVideo(item.id).catch((e) => setFailure(String(e)))}
|
||||
onCancel={() => cancelDownload(item.id).catch((e) => setFailure(String(e)))}
|
||||
onDelete={() =>
|
||||
<ul
|
||||
className={
|
||||
view === "grid"
|
||||
? "mx-auto grid max-w-7xl grid-cols-[repeat(auto-fill,minmax(240px,1fr))] gap-x-4 gap-y-6"
|
||||
: "mx-auto max-w-4xl space-y-1.5"
|
||||
}
|
||||
>
|
||||
{items.map((item) => {
|
||||
const shared = {
|
||||
item,
|
||||
live: live[item.id],
|
||||
online,
|
||||
onOpen: () => openItem(item),
|
||||
onDownload: () =>
|
||||
downloadVideo(item.id).catch((e) => setFailure(String(e))),
|
||||
onCancel: () =>
|
||||
cancelDownload(item.id).catch((e) => setFailure(String(e))),
|
||||
onDelete: () =>
|
||||
deleteDownload(item.id)
|
||||
.then(() => { reload(); say("Download deleted"); })
|
||||
.catch((e) => setFailure(String(e)))
|
||||
} />
|
||||
))}
|
||||
.catch((e) => setFailure(String(e))),
|
||||
};
|
||||
return view === "grid" ? (
|
||||
<VideoTile key={item.id} {...shared} />
|
||||
) : (
|
||||
<VideoRow key={item.id} {...shared} />
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
{playing && (
|
||||
<Player item={playing.item} path={playing.path}
|
||||
|
||||
@@ -33,6 +33,10 @@ export const deleteDownload = (videoId: string) =>
|
||||
|
||||
export const getConnectivity = () => invoke<boolean>("get_connectivity");
|
||||
|
||||
/** A directly playable URL (HLS master playlist) for an undownloaded video. */
|
||||
export const resolveStream = (videoId: string) =>
|
||||
invoke<string>("resolve_stream", { videoId });
|
||||
|
||||
export const setLibraryPath = (path: string) =>
|
||||
invoke<string>("set_library_path", { path });
|
||||
|
||||
|
||||
+77
-13
@@ -1,22 +1,52 @@
|
||||
import { fileUrl } from "../api";
|
||||
import { useEffect, useState } from "react";
|
||||
import { fileUrl, openExternal, resolveStream } from "../api";
|
||||
import type { FeedItem } from "../types";
|
||||
import { compactViews, relativeTime } from "./format";
|
||||
import { BTN, BTN_CHROME } from "./ui";
|
||||
import { BTN, BTN_CHROME, BTN_QUIET } from "./ui";
|
||||
|
||||
interface Props {
|
||||
item: FeedItem;
|
||||
path: string;
|
||||
/** Local file when downloaded; null means resolve and stream it instead. */
|
||||
path: string | null;
|
||||
onClose: () => void;
|
||||
onDelete: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Plays the local file through Tauri's asset protocol. Every download is
|
||||
* Two sources, one player element.
|
||||
*
|
||||
* Downloaded: the local file through Tauri's asset protocol. Every download is
|
||||
* H.264/AAC in MP4 precisely so WKWebView can decode it natively.
|
||||
*
|
||||
* Not downloaded: YouTube's HLS master playlist, resolved by yt-dlp. Its
|
||||
* variants are H.264 + AAC up to 1080p, which AVFoundation streams natively —
|
||||
* so watching still happens here rather than in a browser. The iframe embed
|
||||
* cannot be used: it rejects a `tauri://` origin with "Error 153".
|
||||
*/
|
||||
export default function Player({ item, path, onClose, onDelete }: Props) {
|
||||
const streaming = path === null;
|
||||
const [src, setSrc] = useState<string | null>(path ? fileUrl(path) : null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (path) {
|
||||
setSrc(fileUrl(path));
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
setSrc(null);
|
||||
setError(null);
|
||||
resolveStream(item.id)
|
||||
.then((u) => !cancelled && setSrc(u))
|
||||
.catch((e) => !cancelled && setError(String(e)));
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [item.id, path]);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex flex-col bg-slate-100 dark:bg-slate-950">
|
||||
<div data-tauri-drag-region className="h-9 shrink-0" />
|
||||
<header
|
||||
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"
|
||||
@@ -27,26 +57,60 @@ export default function Player({ item, path, onClose, onDelete }: Props) {
|
||||
<span className="min-w-0 flex-1 truncate text-[12px] text-slate-500 dark:text-slate-400">
|
||||
{item.channel_title}
|
||||
</span>
|
||||
<button
|
||||
onClick={onDelete}
|
||||
className={`${BTN_CHROME} cursor-pointer hover:bg-red-500/10! hover:text-red-600! dark:hover:text-red-400!`}
|
||||
>
|
||||
Delete download
|
||||
</button>
|
||||
{streaming ? (
|
||||
<span className="text-[11px] text-slate-400 dark:text-slate-500">
|
||||
{src ? "Streaming" : error ? "Unavailable" : "Resolving…"}
|
||||
</span>
|
||||
) : (
|
||||
<button
|
||||
onClick={onDelete}
|
||||
className={`${BTN_CHROME} cursor-pointer hover:bg-red-500/10! hover:text-red-600! dark:hover:text-red-400!`}
|
||||
>
|
||||
Delete download
|
||||
</button>
|
||||
)}
|
||||
</header>
|
||||
|
||||
{/* Absolute fill + object-contain, so portrait Shorts and landscape
|
||||
videos are both letterboxed to the pane instead of overflowing it. */}
|
||||
<div className="relative min-h-0 flex-1 bg-slate-950">
|
||||
<video key={path} src={fileUrl(path)} controls autoPlay
|
||||
className="absolute inset-0 size-full object-contain" />
|
||||
{src ? (
|
||||
<video key={src} src={src} controls autoPlay
|
||||
className="absolute inset-0 size-full object-contain" />
|
||||
) : (
|
||||
<div className="absolute inset-0 grid place-items-center px-6 text-center">
|
||||
{error ? (
|
||||
<div className="max-w-sm">
|
||||
<p className="text-[13px] text-red-400">{error}</p>
|
||||
<button
|
||||
onClick={() => openExternal(`https://www.youtube.com/watch?v=${item.id}`)}
|
||||
className={`${BTN_QUIET} mt-2 cursor-pointer`}
|
||||
>
|
||||
Open on YouTube instead
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-[12px] text-slate-400">Finding a stream…</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<footer
|
||||
className="max-h-52 overflow-y-auto border-t border-slate-200 bg-white px-4 py-4
|
||||
dark:border-slate-800 dark:bg-slate-900"
|
||||
>
|
||||
<h2 className="text-[15px] font-semibold leading-snug tracking-tight">{item.title}</h2>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<h2 className="text-[15px] font-semibold leading-snug tracking-tight">{item.title}</h2>
|
||||
{streaming && (
|
||||
<button
|
||||
onClick={() => openExternal(`https://www.youtube.com/watch?v=${item.id}`)}
|
||||
className={`${BTN_QUIET} shrink-0 cursor-pointer whitespace-nowrap`}
|
||||
>
|
||||
Open on YouTube
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-1 text-[11px] text-slate-400 dark:text-slate-500">
|
||||
{[compactViews(item.views), relativeTime(item.published)].filter(Boolean).join(" · ")}
|
||||
</div>
|
||||
|
||||
@@ -23,7 +23,7 @@ export default function Sidebar({
|
||||
return (
|
||||
<aside
|
||||
className="flex max-h-[45vh] w-full shrink-0 flex-col overflow-hidden border-b
|
||||
border-slate-300 bg-white lg:h-screen lg:max-h-none lg:w-[280px]
|
||||
border-slate-300 bg-white lg:h-full lg:max-h-none lg:w-[280px]
|
||||
lg:border-b-0 lg:border-r dark:border-slate-800 dark:bg-slate-900"
|
||||
>
|
||||
<header
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { BTN_PRIMARY, INPUT } from "./ui";
|
||||
import { BTN_PRIMARY, INPUT, Segmented } from "./ui";
|
||||
|
||||
export type ViewMode = "list" | "grid";
|
||||
|
||||
interface Props {
|
||||
search: string;
|
||||
@@ -15,6 +17,8 @@ interface Props {
|
||||
refreshing: boolean;
|
||||
refreshProgress: { done: number; total: number } | null;
|
||||
resultCount: number;
|
||||
view: ViewMode;
|
||||
onView: (v: ViewMode) => void;
|
||||
}
|
||||
|
||||
/** Neutral outline until active; active is the one filled state. */
|
||||
@@ -49,7 +53,7 @@ function Toggle({
|
||||
export default function TopBar({
|
||||
search, onSearch, downloadedOnly, onDownloadedOnly, hideShorts, onHideShorts,
|
||||
online, reachable, forcedOffline, onToggleForcedOffline,
|
||||
onRefresh, refreshing, refreshProgress, resultCount,
|
||||
onRefresh, refreshing, refreshProgress, resultCount, view, onView,
|
||||
}: Props) {
|
||||
const pct = refreshProgress && refreshProgress.total > 0
|
||||
? (refreshProgress.done / refreshProgress.total) * 100
|
||||
@@ -85,6 +89,15 @@ export default function TopBar({
|
||||
Hide Shorts
|
||||
</Toggle>
|
||||
|
||||
<Segmented
|
||||
value={view}
|
||||
onChange={onView}
|
||||
options={[
|
||||
{ value: "list", label: "List" },
|
||||
{ value: "grid", label: "Tiles" },
|
||||
]}
|
||||
/>
|
||||
|
||||
<button
|
||||
onClick={onToggleForcedOffline}
|
||||
title={
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import { thumbSrc } from "../api";
|
||||
import type { LiveDownload } from "../hooks/useDownloads";
|
||||
import type { FeedItem } from "../types";
|
||||
import DownloadButton from "./DownloadButton";
|
||||
import { compactViews, relativeTime } from "./format";
|
||||
|
||||
interface Props {
|
||||
item: FeedItem;
|
||||
live?: LiveDownload;
|
||||
online: boolean;
|
||||
onOpen: () => void;
|
||||
onDownload: () => void;
|
||||
onCancel: () => void;
|
||||
onDelete: () => void;
|
||||
}
|
||||
|
||||
/** Grid cell: thumbnail on top, metadata beneath — the familiar YouTube shape. */
|
||||
export default function VideoTile({
|
||||
item, live, online, onOpen, onDownload, onCancel, onDelete,
|
||||
}: Props) {
|
||||
const downloaded = (live?.state ?? item.state) === "done";
|
||||
const src = thumbSrc(item, online);
|
||||
|
||||
return (
|
||||
<li className="group flex flex-col">
|
||||
<button
|
||||
onClick={onOpen}
|
||||
className="relative aspect-video w-full cursor-pointer overflow-hidden rounded-lg
|
||||
border border-slate-200 bg-slate-200 dark:border-slate-800 dark:bg-slate-800"
|
||||
>
|
||||
{src ? (
|
||||
<img src={src} alt="" loading="lazy" className="size-full object-cover" />
|
||||
) : (
|
||||
<span className="grid size-full place-items-center px-2 text-center text-[10px] text-slate-400">
|
||||
No thumbnail
|
||||
</span>
|
||||
)}
|
||||
{item.is_short && (
|
||||
<span
|
||||
className="absolute left-1.5 top-1.5 rounded bg-slate-950/75 px-1 py-0.5 text-[9px]
|
||||
font-bold uppercase tracking-widest leading-none text-white"
|
||||
>
|
||||
Short
|
||||
</span>
|
||||
)}
|
||||
{downloaded && (
|
||||
<span
|
||||
className="absolute bottom-1.5 right-1.5 rounded bg-sky-500 px-1 py-0.5 text-[9px]
|
||||
font-bold uppercase tracking-widest leading-none text-white"
|
||||
>
|
||||
Offline
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<div className="mt-2 flex min-w-0 flex-1 flex-col">
|
||||
<button onClick={onOpen} className="cursor-pointer text-left">
|
||||
<h3 className="line-clamp-2 text-[13px] font-medium leading-snug">{item.title}</h3>
|
||||
</button>
|
||||
<div className="mt-1 truncate text-[12px] text-slate-500 dark:text-slate-400">
|
||||
{item.channel_title}
|
||||
</div>
|
||||
<div className="mt-0.5 text-[11px] text-slate-400 dark:text-slate-500">
|
||||
{[compactViews(item.views), relativeTime(item.published)].filter(Boolean).join(" · ")}
|
||||
</div>
|
||||
|
||||
{(live?.error ?? item.error) && (live?.state ?? item.state) === "failed" && (
|
||||
<div className="mt-1 line-clamp-2 text-[11px] text-red-600 dark:text-red-400">
|
||||
{live?.error ?? item.error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-2 flex">
|
||||
<DownloadButton item={item} live={live} online={online}
|
||||
onDownload={onDownload} onCancel={onCancel} onDelete={onDelete} />
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user