feat: convert to PWA — OPFS storage, service worker, Bun/Hono server
- Move native shells (Zig/src, Tauri/src-tauri, app.zon, releases) to legacy/
- Add Bun + Hono server with yt-dlp proxy endpoints (search, channel, streams,
download), libsql (concurrent SQLite fork) for fingerprint-keyed playlist/
history sync, and static file serving for the frontend
- Add Dockerfile + docker-compose.yml (single container, volume-mounted DB)
- Add frontend/sw.js: app-shell cache-first, /api/* network-only,
thumbnails stale-while-revalidate, SW_UPDATE_AVAILABLE broadcast,
SKIP_WAITING message handler for seamless auto-update
- Add frontend/manifest.webmanifest: standalone PWA, vermilion theme,
search/history shortcuts
- Add frontend/icons/icon-{192,512}.png: generated PWA icons
- Add frontend/fingerprint.js: canvas+UA djb2 fingerprint, localStorage-cached,
exposes window.getFingerprint() for server-side playlist keying
- Add frontend/opfs.js: full OPFS video store (writeFromResponse streams
directly without full-file buffering), exposes window.OPFS
- Add scripts/make-pwa-icons.js: regenerate icons without external deps
- Patch frontend/app.js: WEB mode detection, webFetch + opfs* bridge wrappers,
API object routes to WEB helpers when no native bridge present,
Player.loadVideo handles OPFS blob URLs + revokes them on next load,
SW registration + update banner in boot()
- Patch frontend/index.html: manifest link, theme-color, Apple PWA meta,
CSP blob:/worker-src, fingerprint.js + opfs.js script tags
- Patch frontend/styles.css: .toast-update + .toast-reload-btn for update banner
- Native Tauri/Zig builds unchanged — all new code is additive via WEB flag
This commit is contained in:
25
legacy/app.zon
Executable file
25
legacy/app.zon
Executable file
@@ -0,0 +1,25 @@
|
||||
.{
|
||||
.id = "com.ytplayer.app",
|
||||
.name = "ytplayer",
|
||||
.display_name = "YT Player",
|
||||
.version = "1.0.0",
|
||||
// System WebView (WKWebView / WebKitGTK) keeps the binary tiny vs. Electron.
|
||||
.web_engine = "system",
|
||||
.permissions = .{"window"},
|
||||
.capabilities = .{ "webview", "js_bridge" },
|
||||
.security = .{
|
||||
.navigation = .{
|
||||
// Local packaged UI + dev server. Media/images load from https at runtime.
|
||||
.allowed_origins = .{ "zero://app", "http://127.0.0.1:5173" },
|
||||
},
|
||||
},
|
||||
.windows = .{
|
||||
.{ .label = "main", .title = "YT Player", .width = 1280, .height = 820 },
|
||||
},
|
||||
// Static web UI lives in ./frontend (no build step — plain HTML/CSS/JS).
|
||||
.frontend = .{
|
||||
.source = "packaged",
|
||||
.dir = "frontend",
|
||||
.entry = "index.html",
|
||||
},
|
||||
}
|
||||
BIN
legacy/releases/YT Player_1.0.0_x64-setup.exe
Executable file
BIN
legacy/releases/YT Player_1.0.0_x64-setup.exe
Executable file
Binary file not shown.
BIN
legacy/releases/YT Player_1.0.0_x64_en-US.msi
Executable file
BIN
legacy/releases/YT Player_1.0.0_x64_en-US.msi
Executable file
Binary file not shown.
BIN
legacy/releases/YTPlayer_1.0.0_x64-setup.exe
Executable file
BIN
legacy/releases/YTPlayer_1.0.0_x64-setup.exe
Executable file
Binary file not shown.
BIN
legacy/releases/YTPlayer_1.0.0_x64_en-US.msi
Executable file
BIN
legacy/releases/YTPlayer_1.0.0_x64_en-US.msi
Executable file
Binary file not shown.
BIN
legacy/releases/ytplayer.exe
Executable file
BIN
legacy/releases/ytplayer.exe
Executable file
Binary file not shown.
4397
legacy/src-tauri/Cargo.lock
generated
Executable file
4397
legacy/src-tauri/Cargo.lock
generated
Executable file
File diff suppressed because it is too large
Load Diff
26
legacy/src-tauri/Cargo.toml
Executable file
26
legacy/src-tauri/Cargo.toml
Executable file
@@ -0,0 +1,26 @@
|
||||
[package]
|
||||
name = "ytplayer"
|
||||
version = "1.0.0"
|
||||
description = "Ad-free YouTube player with local playlists"
|
||||
authors = ["Jonathan Sykes"]
|
||||
edition = "2021"
|
||||
rust-version = "1.77"
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2", features = [] }
|
||||
|
||||
[dependencies]
|
||||
tauri = { version = "2", features = ["protocol-asset"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
|
||||
[features]
|
||||
default = ["embed-ytdlp"]
|
||||
embed-ytdlp = []
|
||||
|
||||
[profile.release]
|
||||
opt-level = "s"
|
||||
lto = true
|
||||
strip = true
|
||||
panic = "abort"
|
||||
codegen-units = 1
|
||||
3
legacy/src-tauri/build.rs
Executable file
3
legacy/src-tauri/build.rs
Executable file
@@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
tauri_build::build()
|
||||
}
|
||||
7
legacy/src-tauri/capabilities/default.json
Executable file
7
legacy/src-tauri/capabilities/default.json
Executable file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "default",
|
||||
"description": "Core capabilities for the main window. App-defined commands (yt_search, yt_streams, store_load, store_save) are not gated by the ACL.",
|
||||
"windows": ["main"],
|
||||
"permissions": ["core:default"]
|
||||
}
|
||||
614
legacy/src-tauri/src/main.rs
Executable file
614
legacy/src-tauri/src/main.rs
Executable file
@@ -0,0 +1,614 @@
|
||||
// YT Player — Tauri (Windows / WebView2) backend.
|
||||
//
|
||||
// Mirrors the zero-native Zig bridge, but as Tauri commands. The same frontend
|
||||
// in ../frontend calls these via window.__TAURI__.core.invoke(...). Returns the
|
||||
// exact JSON shapes the UI expects:
|
||||
// yt_search { query } -> { ok, results:[…] }
|
||||
// yt_channel { channel } -> { ok, channel, channelUrl, results:[…] }
|
||||
// yt_streams { videoId } -> { ok, data:{ meta, audioUrl, qualities[] } }
|
||||
// store_load {} -> playlists / history / settings
|
||||
// store_save { data } -> { ok }
|
||||
//
|
||||
// Unlike the size-limited zero-native bridge, Tauri's IPC has no fixed buffer,
|
||||
// but we still return only the slim fields the UI needs.
|
||||
//
|
||||
// Threading: the yt-dlp calls and the offline downloads are blocking and can
|
||||
// take seconds. They are dispatched onto a background thread pool with
|
||||
// `tauri::async_runtime::spawn_blocking` so the WebView/main thread stays
|
||||
// responsive and multiple downloads can run concurrently.
|
||||
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
use serde_json::{json, Value};
|
||||
use std::collections::HashSet;
|
||||
use std::path::PathBuf;
|
||||
use std::process::Command;
|
||||
use tauri::Manager;
|
||||
|
||||
const SEARCH_LIMIT: u32 = 25;
|
||||
const CHANNEL_LIMIT: u32 = 60;
|
||||
|
||||
// yt-dlp.exe is baked straight into this binary at compile time, so the standalone
|
||||
// `ytplayer.exe` is fully self-contained — no sibling file and nothing on PATH
|
||||
// required. On first run we materialize it into the app cache dir and run from there.
|
||||
// (Windows only; other platforms fall back to a resource/dev/PATH copy.)
|
||||
// Disable with --no-default-features for faster debug builds.
|
||||
#[cfg(all(windows, feature = "embed-ytdlp"))]
|
||||
static YTDLP_EMBEDDED: &[u8] = include_bytes!("../../bin/yt-dlp.exe");
|
||||
|
||||
/// Write the embedded yt-dlp to the cache dir if it isn't already there (size-checked),
|
||||
/// and return its path.
|
||||
#[cfg(all(windows, feature = "embed-ytdlp"))]
|
||||
fn embedded_ytdlp(app: &tauri::AppHandle) -> Option<PathBuf> {
|
||||
let dir = app.path().app_cache_dir().ok()?;
|
||||
std::fs::create_dir_all(&dir).ok()?;
|
||||
let dest = dir.join("yt-dlp.exe");
|
||||
let fresh = std::fs::metadata(&dest)
|
||||
.map(|m| m.len() == YTDLP_EMBEDDED.len() as u64)
|
||||
.unwrap_or(false);
|
||||
if !fresh {
|
||||
std::fs::write(&dest, YTDLP_EMBEDDED).ok()?;
|
||||
}
|
||||
Some(dest)
|
||||
}
|
||||
|
||||
/// Locate yt-dlp: prefer a bundled resource copy, then a dev ./bin, then the
|
||||
/// embedded self-contained copy (Windows), then finally PATH.
|
||||
fn ytdlp_path(app: &tauri::AppHandle) -> PathBuf {
|
||||
let name = if cfg!(windows) { "yt-dlp.exe" } else { "yt-dlp" };
|
||||
if let Ok(res) = app.path().resource_dir() {
|
||||
let p = res.join("bin").join(name);
|
||||
if p.exists() {
|
||||
return p;
|
||||
}
|
||||
}
|
||||
let dev = PathBuf::from("bin").join(name);
|
||||
if dev.exists() {
|
||||
return dev;
|
||||
}
|
||||
#[cfg(all(windows, feature = "embed-ytdlp"))]
|
||||
{
|
||||
if let Some(p) = embedded_ytdlp(app) {
|
||||
return p;
|
||||
}
|
||||
}
|
||||
PathBuf::from(name)
|
||||
}
|
||||
|
||||
fn run_ytdlp(app: &tauri::AppHandle, args: &[&str]) -> Result<String, String> {
|
||||
let exe = ytdlp_path(app);
|
||||
let mut cmd = Command::new(&exe);
|
||||
cmd.args(args);
|
||||
|
||||
// Don't flash a console window when spawning yt-dlp on Windows.
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use std::os::windows::process::CommandExt;
|
||||
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
|
||||
cmd.creation_flags(CREATE_NO_WINDOW);
|
||||
}
|
||||
|
||||
let output = cmd
|
||||
.output()
|
||||
.map_err(|e| format!("failed to launch yt-dlp ({}): {e}", exe.display()))?;
|
||||
|
||||
if !output.status.success() {
|
||||
let err = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(if err.trim().is_empty() {
|
||||
"yt-dlp failed".to_string()
|
||||
} else {
|
||||
err.trim().to_string()
|
||||
});
|
||||
}
|
||||
Ok(String::from_utf8_lossy(&output.stdout).into_owned())
|
||||
}
|
||||
|
||||
fn str_or<'a>(v: &'a Value, key: &str, fallback: &'a str) -> &'a str {
|
||||
v.get(key).and_then(|x| x.as_str()).unwrap_or(fallback)
|
||||
}
|
||||
|
||||
/// Pick the channel display name from a yt-dlp record (channel, then uploader).
|
||||
fn pick_channel(v: &Value) -> &str {
|
||||
let c = str_or(v, "channel", "");
|
||||
if c.is_empty() {
|
||||
str_or(v, "uploader", "")
|
||||
} else {
|
||||
c
|
||||
}
|
||||
}
|
||||
|
||||
/// Pick a usable channel URL (channel_url, then uploader_url).
|
||||
fn pick_channel_url(v: &Value) -> &str {
|
||||
let u = str_or(v, "channel_url", "");
|
||||
if u.is_empty() {
|
||||
str_or(v, "uploader_url", "")
|
||||
} else {
|
||||
u
|
||||
}
|
||||
}
|
||||
|
||||
/// Pick a channel id (channel_id, then uploader_id).
|
||||
fn pick_channel_id(v: &Value) -> &str {
|
||||
let id = str_or(v, "channel_id", "");
|
||||
if id.is_empty() {
|
||||
str_or(v, "uploader_id", "")
|
||||
} else {
|
||||
id
|
||||
}
|
||||
}
|
||||
|
||||
/// Turn one yt-dlp flat-playlist record into the slim card the UI expects.
|
||||
fn slim_entry(j: &Value) -> Option<Value> {
|
||||
let id = str_or(j, "id", "");
|
||||
if id.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(json!({
|
||||
"id": id,
|
||||
"title": str_or(j, "title", "(untitled)"),
|
||||
"channel": pick_channel(j),
|
||||
"channelId": pick_channel_id(j),
|
||||
"channelUrl": pick_channel_url(j),
|
||||
"duration": j.get("duration").and_then(|x| x.as_f64()).unwrap_or(0.0),
|
||||
"thumbnail": format!("https://i.ytimg.com/vi/{}/mqdefault.jpg", id),
|
||||
}))
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Search
|
||||
// ============================================================================
|
||||
#[tauri::command]
|
||||
async fn yt_search(app: tauri::AppHandle, query: String) -> Value {
|
||||
let q = query.trim().to_string();
|
||||
if q.is_empty() {
|
||||
return json!({ "ok": false, "error": "empty query" });
|
||||
}
|
||||
tauri::async_runtime::spawn_blocking(move || yt_search_blocking(&app, &q))
|
||||
.await
|
||||
.unwrap_or_else(|e| json!({ "ok": false, "error": e.to_string() }))
|
||||
}
|
||||
|
||||
fn yt_search_blocking(app: &tauri::AppHandle, q: &str) -> Value {
|
||||
let search = format!("ytsearch{}:{}", SEARCH_LIMIT, q);
|
||||
let out = match run_ytdlp(
|
||||
app,
|
||||
&[
|
||||
&search,
|
||||
"--dump-json",
|
||||
"--flat-playlist",
|
||||
"--no-warnings",
|
||||
"--ignore-errors",
|
||||
],
|
||||
) {
|
||||
Ok(o) => o,
|
||||
Err(e) => return json!({ "ok": false, "error": e }),
|
||||
};
|
||||
|
||||
let mut results = Vec::new();
|
||||
for line in out.lines() {
|
||||
let line = line.trim();
|
||||
if line.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let j: Value = match serde_json::from_str(line) {
|
||||
Ok(v) => v,
|
||||
Err(_) => continue,
|
||||
};
|
||||
if let Some(card) = slim_entry(&j) {
|
||||
results.push(card);
|
||||
}
|
||||
}
|
||||
|
||||
json!({ "ok": true, "results": results })
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Channel — list a channel's recent uploads
|
||||
// ============================================================================
|
||||
#[tauri::command]
|
||||
async fn yt_channel(app: tauri::AppHandle, channel: String) -> Value {
|
||||
let c = channel.trim().to_string();
|
||||
if c.is_empty() {
|
||||
return json!({ "ok": false, "error": "missing channel" });
|
||||
}
|
||||
tauri::async_runtime::spawn_blocking(move || yt_channel_blocking(&app, &c))
|
||||
.await
|
||||
.unwrap_or_else(|e| json!({ "ok": false, "error": e.to_string() }))
|
||||
}
|
||||
|
||||
fn yt_channel_blocking(app: &tauri::AppHandle, channel: &str) -> Value {
|
||||
// Accept either a full channel/uploader URL or a bare channel id. Always
|
||||
// resolve to the "/videos" tab so we list uploads, not the channel home.
|
||||
let base = if channel.starts_with("http") {
|
||||
channel.trim_end_matches('/').to_string()
|
||||
} else if channel.starts_with('@') {
|
||||
format!("https://www.youtube.com/{}", channel)
|
||||
} else if channel.starts_with("UC") {
|
||||
format!("https://www.youtube.com/channel/{}", channel)
|
||||
} else {
|
||||
format!("https://www.youtube.com/@{}", channel)
|
||||
};
|
||||
let url = if base.ends_with("/videos") {
|
||||
base
|
||||
} else {
|
||||
format!("{}/videos", base)
|
||||
};
|
||||
|
||||
let end = CHANNEL_LIMIT.to_string();
|
||||
let out = match run_ytdlp(
|
||||
app,
|
||||
&[
|
||||
&url,
|
||||
"--dump-json",
|
||||
"--flat-playlist",
|
||||
"--no-warnings",
|
||||
"--ignore-errors",
|
||||
"--playlist-end",
|
||||
&end,
|
||||
],
|
||||
) {
|
||||
Ok(o) => o,
|
||||
Err(e) => return json!({ "ok": false, "error": e }),
|
||||
};
|
||||
|
||||
let mut results = Vec::new();
|
||||
let mut name = String::new();
|
||||
let mut chan_url = String::new();
|
||||
for line in out.lines() {
|
||||
let line = line.trim();
|
||||
if line.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let j: Value = match serde_json::from_str(line) {
|
||||
Ok(v) => v,
|
||||
Err(_) => continue,
|
||||
};
|
||||
if name.is_empty() {
|
||||
let n = pick_channel(&j);
|
||||
if !n.is_empty() {
|
||||
name = n.to_string();
|
||||
}
|
||||
}
|
||||
if chan_url.is_empty() {
|
||||
let u = pick_channel_url(&j);
|
||||
if !u.is_empty() {
|
||||
chan_url = u.to_string();
|
||||
}
|
||||
}
|
||||
if let Some(card) = slim_entry(&j) {
|
||||
results.push(card);
|
||||
}
|
||||
}
|
||||
|
||||
json!({
|
||||
"ok": true,
|
||||
"channel": name,
|
||||
"channelUrl": chan_url,
|
||||
"results": results,
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Streams
|
||||
// ============================================================================
|
||||
#[tauri::command]
|
||||
async fn yt_streams(app: tauri::AppHandle, video_id: String) -> Value {
|
||||
let id = video_id.trim().to_string();
|
||||
if id.is_empty() {
|
||||
return json!({ "ok": false, "error": "missing videoId" });
|
||||
}
|
||||
tauri::async_runtime::spawn_blocking(move || yt_streams_blocking(&app, &id))
|
||||
.await
|
||||
.unwrap_or_else(|e| json!({ "ok": false, "error": e.to_string() }))
|
||||
}
|
||||
|
||||
fn yt_streams_blocking(app: &tauri::AppHandle, video_id: &str) -> Value {
|
||||
let url = format!("https://www.youtube.com/watch?v={}", video_id);
|
||||
let out = match run_ytdlp(app, &["-J", "--no-warnings", &url]) {
|
||||
Ok(o) => o,
|
||||
Err(e) => return json!({ "ok": false, "error": e }),
|
||||
};
|
||||
let info: Value = match serde_json::from_str(&out) {
|
||||
Ok(v) => v,
|
||||
Err(e) => return json!({ "ok": false, "error": format!("parse error: {e}") }),
|
||||
};
|
||||
|
||||
let empty: Vec<Value> = Vec::new();
|
||||
let formats = info.get("formats").and_then(|f| f.as_array()).unwrap_or(&empty);
|
||||
|
||||
// Best audio-only stream (prefer m4a/mp4a, then by bitrate).
|
||||
let mut best_audio_url = String::new();
|
||||
let mut best_audio_score = -1.0_f64;
|
||||
for f in formats {
|
||||
let vcodec = str_or(f, "vcodec", "none");
|
||||
let acodec = str_or(f, "acodec", "none");
|
||||
let furl = str_or(f, "url", "");
|
||||
if furl.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let has_video = vcodec != "none" && !vcodec.is_empty();
|
||||
let has_audio = acodec != "none" && !acodec.is_empty();
|
||||
if !has_video && has_audio {
|
||||
let mut score = f.get("abr").and_then(|x| x.as_f64()).unwrap_or(0.0);
|
||||
if acodec.contains("mp4a") {
|
||||
score += 1000.0;
|
||||
}
|
||||
if score > best_audio_score {
|
||||
best_audio_score = score;
|
||||
best_audio_url = furl.to_string();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Quality list: progressive (single-file, has audio) first so it survives the
|
||||
// per-height dedupe — it's the most reliable fallback when the dual adaptive
|
||||
// path fails. Adaptive video-only fills in the heights progressive doesn't cover.
|
||||
let mut qualities = Vec::new();
|
||||
let mut seen: HashSet<i64> = HashSet::new();
|
||||
for want_progressive in [true, false] {
|
||||
for f in formats {
|
||||
let vcodec = str_or(f, "vcodec", "none");
|
||||
let acodec = str_or(f, "acodec", "none");
|
||||
let furl = str_or(f, "url", "");
|
||||
if furl.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let has_video = vcodec != "none" && !vcodec.is_empty();
|
||||
let has_audio = acodec != "none" && !acodec.is_empty();
|
||||
if !has_video {
|
||||
continue;
|
||||
}
|
||||
let is_progressive = has_audio;
|
||||
if is_progressive != want_progressive {
|
||||
continue;
|
||||
}
|
||||
let height = f.get("height").and_then(|x| x.as_i64()).unwrap_or(0);
|
||||
if height <= 0 || seen.contains(&height) {
|
||||
continue;
|
||||
}
|
||||
seen.insert(height);
|
||||
qualities.push(json!({
|
||||
"label": format!("{}p", height),
|
||||
"height": height,
|
||||
"hasAudio": is_progressive,
|
||||
"url": furl,
|
||||
"ext": str_or(f, "ext", ""),
|
||||
}));
|
||||
}
|
||||
}
|
||||
qualities.sort_by(|a, b| {
|
||||
b["height"]
|
||||
.as_i64()
|
||||
.unwrap_or(0)
|
||||
.cmp(&a["height"].as_i64().unwrap_or(0))
|
||||
});
|
||||
|
||||
json!({
|
||||
"ok": true,
|
||||
"data": {
|
||||
"meta": {
|
||||
"id": video_id,
|
||||
"title": str_or(&info, "title", "(untitled)"),
|
||||
"channel": pick_channel(&info),
|
||||
"channelId": pick_channel_id(&info),
|
||||
"channelUrl": pick_channel_url(&info),
|
||||
"duration": info.get("duration").and_then(|x| x.as_f64()).unwrap_or(0.0),
|
||||
"thumbnail": format!("https://i.ytimg.com/vi/{}/hqdefault.jpg", video_id),
|
||||
},
|
||||
"audioUrl": if best_audio_url.is_empty() { Value::Null } else { Value::String(best_audio_url) },
|
||||
"qualities": qualities,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn data_file(app: &tauri::AppHandle) -> Result<PathBuf, String> {
|
||||
let dir = app.path().app_data_dir().map_err(|e| e.to_string())?;
|
||||
std::fs::create_dir_all(&dir).ok();
|
||||
Ok(dir.join("ytplayer-data.json"))
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Offline cache — download a single self-contained file per video so playlist
|
||||
// items play instantly and work offline. Files live in <app_cache_dir>/videos
|
||||
// and are named "<videoId>.<ext>". They persist until explicitly deleted.
|
||||
// ============================================================================
|
||||
fn cache_dir(app: &tauri::AppHandle) -> Result<PathBuf, String> {
|
||||
let dir = app
|
||||
.path()
|
||||
.app_cache_dir()
|
||||
.map_err(|e| e.to_string())?
|
||||
.join("videos");
|
||||
std::fs::create_dir_all(&dir).ok();
|
||||
Ok(dir)
|
||||
}
|
||||
|
||||
/// Find an already-cached file for a video id (any extension), if present.
|
||||
fn cached_file(dir: &PathBuf, video_id: &str) -> Option<PathBuf> {
|
||||
let entries = std::fs::read_dir(dir).ok()?;
|
||||
for e in entries.flatten() {
|
||||
let p = e.path();
|
||||
if p.file_stem().and_then(|s| s.to_str()) == Some(video_id) {
|
||||
// Ignore partial yt-dlp downloads.
|
||||
let ext = p.extension().and_then(|s| s.to_str()).unwrap_or("");
|
||||
if ext != "part" && ext != "ytdl" {
|
||||
return Some(p);
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn file_size(p: &PathBuf) -> u64 {
|
||||
std::fs::metadata(p).map(|m| m.len()).unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Download a video into the cache as a single progressive mp4 (audio+video in
|
||||
/// one file, so it needs no ffmpeg muxing and plays offline). Idempotent.
|
||||
///
|
||||
/// Async + spawn_blocking so several saves can run in parallel without blocking
|
||||
/// the UI thread.
|
||||
#[tauri::command]
|
||||
async fn cache_download(app: tauri::AppHandle, video_id: String) -> Value {
|
||||
let id = video_id.trim().to_string();
|
||||
if id.is_empty() {
|
||||
return json!({ "ok": false, "error": "missing videoId" });
|
||||
}
|
||||
tauri::async_runtime::spawn_blocking(move || cache_download_blocking(&app, &id))
|
||||
.await
|
||||
.unwrap_or_else(|e| json!({ "ok": false, "error": e.to_string() }))
|
||||
}
|
||||
|
||||
fn cache_download_blocking(app: &tauri::AppHandle, video_id: &str) -> Value {
|
||||
let dir = match cache_dir(app) {
|
||||
Ok(d) => d,
|
||||
Err(e) => return json!({ "ok": false, "error": e }),
|
||||
};
|
||||
if let Some(p) = cached_file(&dir, video_id) {
|
||||
return json!({ "ok": true, "cached": true, "path": p.to_string_lossy(), "size": file_size(&p) });
|
||||
}
|
||||
let url = format!("https://www.youtube.com/watch?v={}", video_id);
|
||||
let out_tmpl = dir.join(format!("{}.%(ext)s", video_id));
|
||||
let out_tmpl = out_tmpl.to_string_lossy().to_string();
|
||||
// Prefer a progressive mp4 (single file with audio); fall back to best single
|
||||
// file. Avoids ffmpeg by not requesting separate streams that need merging.
|
||||
let res = run_ytdlp(
|
||||
app,
|
||||
&[
|
||||
"--no-playlist",
|
||||
"--no-warnings",
|
||||
"-f",
|
||||
"best[ext=mp4][acodec!=none][vcodec!=none]/best[acodec!=none][vcodec!=none]/best",
|
||||
"-o",
|
||||
&out_tmpl,
|
||||
&url,
|
||||
],
|
||||
);
|
||||
if let Err(e) = res {
|
||||
return json!({ "ok": false, "error": e });
|
||||
}
|
||||
match cached_file(&dir, video_id) {
|
||||
Some(p) => json!({ "ok": true, "cached": true, "path": p.to_string_lossy(), "size": file_size(&p) }),
|
||||
None => json!({ "ok": false, "error": "download finished but no file was produced" }),
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn cache_status(app: tauri::AppHandle, video_id: String) -> Value {
|
||||
let dir = match cache_dir(&app) {
|
||||
Ok(d) => d,
|
||||
Err(e) => return json!({ "ok": false, "error": e }),
|
||||
};
|
||||
match cached_file(&dir, &video_id) {
|
||||
Some(p) => json!({ "ok": true, "cached": true, "path": p.to_string_lossy(), "size": file_size(&p) }),
|
||||
None => json!({ "ok": true, "cached": false }),
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn cache_list(app: tauri::AppHandle) -> Value {
|
||||
let dir = match cache_dir(&app) {
|
||||
Ok(d) => d,
|
||||
Err(e) => return json!({ "ok": false, "error": e }),
|
||||
};
|
||||
let mut items = Vec::new();
|
||||
let mut total: u64 = 0;
|
||||
if let Ok(entries) = std::fs::read_dir(&dir) {
|
||||
for e in entries.flatten() {
|
||||
let p = e.path();
|
||||
if !p.is_file() {
|
||||
continue;
|
||||
}
|
||||
let ext = p.extension().and_then(|s| s.to_str()).unwrap_or("");
|
||||
if ext == "part" || ext == "ytdl" {
|
||||
continue;
|
||||
}
|
||||
if let Some(id) = p.file_stem().and_then(|s| s.to_str()) {
|
||||
let size = file_size(&p);
|
||||
total += size;
|
||||
items.push(json!({ "id": id, "size": size, "path": p.to_string_lossy() }));
|
||||
}
|
||||
}
|
||||
}
|
||||
json!({ "ok": true, "items": items, "total": total })
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn cache_delete(app: tauri::AppHandle, video_id: String) -> Value {
|
||||
let dir = match cache_dir(&app) {
|
||||
Ok(d) => d,
|
||||
Err(e) => return json!({ "ok": false, "error": e }),
|
||||
};
|
||||
if let Some(p) = cached_file(&dir, &video_id) {
|
||||
match std::fs::remove_file(&p) {
|
||||
Ok(_) => json!({ "ok": true }),
|
||||
Err(e) => json!({ "ok": false, "error": e.to_string() }),
|
||||
}
|
||||
} else {
|
||||
json!({ "ok": true })
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn cache_clear(app: tauri::AppHandle) -> Value {
|
||||
let dir = match cache_dir(&app) {
|
||||
Ok(d) => d,
|
||||
Err(e) => return json!({ "ok": false, "error": e }),
|
||||
};
|
||||
let mut removed = 0u32;
|
||||
if let Ok(entries) = std::fs::read_dir(&dir) {
|
||||
for e in entries.flatten() {
|
||||
let p = e.path();
|
||||
if p.is_file() && std::fs::remove_file(&p).is_ok() {
|
||||
removed += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
json!({ "ok": true, "removed": removed })
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn store_load(app: tauri::AppHandle) -> Value {
|
||||
let default = json!({
|
||||
"playlists": [],
|
||||
"history": [],
|
||||
"settings": { "quality": "auto", "volume": 1, "audioOnly": false }
|
||||
});
|
||||
let path = match data_file(&app) {
|
||||
Ok(p) => p,
|
||||
Err(_) => return default,
|
||||
};
|
||||
match std::fs::read_to_string(&path) {
|
||||
Ok(s) => serde_json::from_str::<Value>(&s).unwrap_or(default),
|
||||
Err(_) => default,
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn store_save(app: tauri::AppHandle, data: String) -> Value {
|
||||
let path = match data_file(&app) {
|
||||
Ok(p) => p,
|
||||
Err(e) => return json!({ "ok": false, "error": e }),
|
||||
};
|
||||
match std::fs::write(&path, data) {
|
||||
Ok(_) => json!({ "ok": true }),
|
||||
Err(e) => json!({ "ok": false, "error": e.to_string() }),
|
||||
}
|
||||
}
|
||||
|
||||
fn main() {
|
||||
tauri::Builder::default()
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
yt_search,
|
||||
yt_channel,
|
||||
yt_streams,
|
||||
store_load,
|
||||
store_save,
|
||||
cache_download,
|
||||
cache_status,
|
||||
cache_list,
|
||||
cache_delete,
|
||||
cache_clear
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
}
|
||||
40
legacy/src-tauri/tauri.conf.json
Executable file
40
legacy/src-tauri/tauri.conf.json
Executable file
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "YT Player",
|
||||
"version": "1.0.0",
|
||||
"identifier": "com.ytplayer.app",
|
||||
"build": {
|
||||
"frontendDist": "../frontend"
|
||||
},
|
||||
"app": {
|
||||
"withGlobalTauri": true,
|
||||
"windows": [
|
||||
{
|
||||
"title": "YT Player",
|
||||
"width": 1280,
|
||||
"height": 820,
|
||||
"minWidth": 900,
|
||||
"minHeight": 600,
|
||||
"resizable": true
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
"csp": "default-src 'self'; img-src 'self' https: data: asset: http://asset.localhost; media-src 'self' https: blob: asset: http://asset.localhost; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src https://fonts.gstatic.com data:; script-src 'self'; connect-src 'self' https: ipc: http://ipc.localhost",
|
||||
"assetProtocol": {
|
||||
"enable": true,
|
||||
"scope": ["**"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": ["nsis", "msi"],
|
||||
"icon": [
|
||||
"icons/32x32.png",
|
||||
"icons/128x128.png",
|
||||
"icons/128x128@2x.png",
|
||||
"icons/icon.icns",
|
||||
"icons/icon.ico"
|
||||
]
|
||||
}
|
||||
}
|
||||
464
legacy/src/bridge.zig
Executable file
464
legacy/src/bridge.zig
Executable file
@@ -0,0 +1,464 @@
|
||||
//! Native bridge handlers for YT Player.
|
||||
//!
|
||||
//! These run on the Zig side and are invoked from the web UI via
|
||||
//! `window.zero.invoke(command, payload)`. They:
|
||||
//! * yt.search — run yt-dlp search and return a slim result list
|
||||
//! * yt.streams — run yt-dlp on one video and return playable stream URLs
|
||||
//! * store.load — read the local playlists/history/settings JSON
|
||||
//! * store.save — write it back
|
||||
//!
|
||||
//! Handler signature follows the zero-native bridge contract:
|
||||
//! fn(context: *anyopaque, invocation: bridge.Invocation, output: []u8) anyerror![]const u8
|
||||
//! The returned slice MUST point inside `output`. The bridge is size-limited,
|
||||
//! so handlers parse yt-dlp's large JSON and emit only the compact fields the
|
||||
//! UI needs.
|
||||
//!
|
||||
//! Targets Zig 0.14 std. If your installed zero-native exposes the Invocation
|
||||
//! type under a different path, only the `payloadOf` helper and the handler
|
||||
//! parameter type need adjusting — the logic below is self-contained.
|
||||
|
||||
const std = @import("std");
|
||||
const zero_native = @import("zero_native");
|
||||
const Invocation = zero_native.bridge.Invocation;
|
||||
|
||||
const MAX_OUTPUT = 4 * 1024 * 1024; // yt-dlp -J can be large; give it room.
|
||||
const SEARCH_LIMIT = 25;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Small JSON string escaper writing into any writer.
|
||||
// ---------------------------------------------------------------------------
|
||||
fn writeJsonString(w: anytype, s: []const u8) !void {
|
||||
try w.writeByte('"');
|
||||
for (s) |c| {
|
||||
switch (c) {
|
||||
'"' => try w.writeAll("\\\""),
|
||||
'\\' => try w.writeAll("\\\\"),
|
||||
'\n' => try w.writeAll("\\n"),
|
||||
'\r' => try w.writeAll("\\r"),
|
||||
'\t' => try w.writeAll("\\t"),
|
||||
0x00...0x08, 0x0b, 0x0c, 0x0e...0x1f => try w.print("\\u{x:0>4}", .{c}),
|
||||
else => try w.writeByte(c),
|
||||
}
|
||||
}
|
||||
try w.writeByte('"');
|
||||
}
|
||||
|
||||
fn jsonStr(v: ?std.json.Value) []const u8 {
|
||||
if (v) |val| {
|
||||
return switch (val) {
|
||||
.string => |s| s,
|
||||
else => "",
|
||||
};
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
fn jsonNum(v: ?std.json.Value) f64 {
|
||||
if (v) |val| {
|
||||
return switch (val) {
|
||||
.integer => |i| @floatFromInt(i),
|
||||
.float => |f| f,
|
||||
.number_string => |s| std.fmt.parseFloat(f64, s) catch 0,
|
||||
else => 0,
|
||||
};
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Channel display name, URL and id, falling back to the uploader_* variants.
|
||||
fn pickChannel(obj: std.json.ObjectMap) []const u8 {
|
||||
const c = jsonStr(obj.get("channel"));
|
||||
return if (c.len > 0) c else jsonStr(obj.get("uploader"));
|
||||
}
|
||||
fn pickChannelUrl(obj: std.json.ObjectMap) []const u8 {
|
||||
const u = jsonStr(obj.get("channel_url"));
|
||||
return if (u.len > 0) u else jsonStr(obj.get("uploader_url"));
|
||||
}
|
||||
fn pickChannelId(obj: std.json.ObjectMap) []const u8 {
|
||||
const id = jsonStr(obj.get("channel_id"));
|
||||
return if (id.len > 0) id else jsonStr(obj.get("uploader_id"));
|
||||
}
|
||||
|
||||
// Write one slim video card object into `w` from a flat-playlist record.
|
||||
fn writeCard(w: anytype, a: std.mem.Allocator, obj: std.json.ObjectMap) !bool {
|
||||
const id = jsonStr(obj.get("id"));
|
||||
if (id.len == 0) return false;
|
||||
try w.writeAll("{\"id\":");
|
||||
try writeJsonString(w, id);
|
||||
try w.writeAll(",\"title\":");
|
||||
try writeJsonString(w, jsonStr(obj.get("title")));
|
||||
try w.writeAll(",\"channel\":");
|
||||
try writeJsonString(w, pickChannel(obj));
|
||||
try w.writeAll(",\"channelId\":");
|
||||
try writeJsonString(w, pickChannelId(obj));
|
||||
try w.writeAll(",\"channelUrl\":");
|
||||
try writeJsonString(w, pickChannelUrl(obj));
|
||||
try w.print(",\"duration\":{d}", .{jsonNum(obj.get("duration"))});
|
||||
try w.writeAll(",\"thumbnail\":");
|
||||
const thumb = try std.fmt.allocPrint(a, "https://i.ytimg.com/vi/{s}/mqdefault.jpg", .{id});
|
||||
try writeJsonString(w, thumb);
|
||||
try w.writeByte('}');
|
||||
return true;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Locate the yt-dlp binary: prefer the bundled ./bin copy, fall back to PATH.
|
||||
// ---------------------------------------------------------------------------
|
||||
fn ytDlpPath(allocator: std.mem.Allocator) []const u8 {
|
||||
const candidates = [_][]const u8{ "bin/yt-dlp", "./bin/yt-dlp", "yt-dlp" };
|
||||
for (candidates) |c| {
|
||||
if (std.mem.eql(u8, c, "yt-dlp")) return c; // PATH fallback
|
||||
std.fs.cwd().access(c, .{}) catch continue;
|
||||
return allocator.dupe(u8, c) catch c;
|
||||
}
|
||||
return "yt-dlp";
|
||||
}
|
||||
|
||||
fn runYtDlp(allocator: std.mem.Allocator, argv: []const []const u8) ![]const u8 {
|
||||
const result = try std.process.Child.run(.{
|
||||
.allocator = allocator,
|
||||
.argv = argv,
|
||||
.max_output_bytes = MAX_OUTPUT,
|
||||
});
|
||||
if (result.term != .Exited or result.term.Exited != 0) {
|
||||
// Surface yt-dlp's stderr to the caller.
|
||||
if (result.stderr.len > 0) return error.YtDlpFailed;
|
||||
return error.YtDlpFailed;
|
||||
}
|
||||
return result.stdout;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Payload helpers — extract a string field from the invocation payload JSON.
|
||||
// ---------------------------------------------------------------------------
|
||||
fn payloadField(allocator: std.mem.Allocator, payload: []const u8, key: []const u8) !?[]const u8 {
|
||||
if (payload.len == 0) return null;
|
||||
var parsed = std.json.parseFromSlice(std.json.Value, allocator, payload, .{}) catch return null;
|
||||
defer parsed.deinit();
|
||||
if (parsed.value != .object) return null;
|
||||
const v = parsed.value.object.get(key) orelse return null;
|
||||
if (v != .string) return null;
|
||||
return try allocator.dupe(u8, v.string);
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Handler: yt.search
|
||||
// ===========================================================================
|
||||
pub fn ytSearch(context: *anyopaque, invocation: Invocation, output: []u8) anyerror![]const u8 {
|
||||
_ = context;
|
||||
var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
|
||||
defer arena.deinit();
|
||||
const a = arena.allocator();
|
||||
|
||||
const query = (try payloadField(a, invocation.request.payload, "query")) orelse "";
|
||||
if (query.len == 0) return errorJson(output, "empty query");
|
||||
|
||||
const exe = ytDlpPath(a);
|
||||
const search_arg = try std.fmt.allocPrint(a, "ytsearch{d}:{s}", .{ SEARCH_LIMIT, query });
|
||||
const argv = [_][]const u8{
|
||||
exe, search_arg, "--dump-json", "--flat-playlist", "--no-warnings", "--ignore-errors",
|
||||
};
|
||||
|
||||
const out = runYtDlp(a, &argv) catch |e| {
|
||||
return errorJson(output, @errorName(e));
|
||||
};
|
||||
|
||||
var fbs = std.io.fixedBufferStream(output);
|
||||
const w = fbs.writer();
|
||||
try w.writeAll("{\"ok\":true,\"results\":[");
|
||||
|
||||
var first = true;
|
||||
var it = std.mem.splitScalar(u8, out, '\n');
|
||||
while (it.next()) |line| {
|
||||
const trimmed = std.mem.trim(u8, line, " \r\t");
|
||||
if (trimmed.len == 0) continue;
|
||||
var parsed = std.json.parseFromSlice(std.json.Value, a, trimmed, .{}) catch continue;
|
||||
defer parsed.deinit();
|
||||
if (parsed.value != .object) continue;
|
||||
const obj = parsed.value.object;
|
||||
|
||||
if (jsonStr(obj.get("id")).len == 0) continue;
|
||||
|
||||
if (!first) try w.writeByte(',');
|
||||
const wrote = try writeCard(w, a, obj);
|
||||
if (wrote) first = false;
|
||||
}
|
||||
|
||||
try w.writeAll("]}");
|
||||
return fbs.getWritten();
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Handler: yt.channel — list a channel's recent uploads
|
||||
// ===========================================================================
|
||||
pub fn ytChannel(context: *anyopaque, invocation: Invocation, output: []u8) anyerror![]const u8 {
|
||||
_ = context;
|
||||
var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
|
||||
defer arena.deinit();
|
||||
const a = arena.allocator();
|
||||
|
||||
const channel = (try payloadField(a, invocation.request.payload, "channel")) orelse "";
|
||||
if (channel.len == 0) return errorJson(output, "missing channel");
|
||||
|
||||
// Resolve to a "/videos" tab URL whether we were handed a URL, @handle,
|
||||
// UC… id, or a bare name.
|
||||
var base: []const u8 = undefined;
|
||||
if (std.mem.startsWith(u8, channel, "http")) {
|
||||
base = std.mem.trimRight(u8, channel, "/");
|
||||
} else if (std.mem.startsWith(u8, channel, "@")) {
|
||||
base = try std.fmt.allocPrint(a, "https://www.youtube.com/{s}", .{channel});
|
||||
} else if (std.mem.startsWith(u8, channel, "UC")) {
|
||||
base = try std.fmt.allocPrint(a, "https://www.youtube.com/channel/{s}", .{channel});
|
||||
} else {
|
||||
base = try std.fmt.allocPrint(a, "https://www.youtube.com/@{s}", .{channel});
|
||||
}
|
||||
const url = if (std.mem.endsWith(u8, base, "/videos"))
|
||||
base
|
||||
else
|
||||
try std.fmt.allocPrint(a, "{s}/videos", .{base});
|
||||
|
||||
const exe = ytDlpPath(a);
|
||||
const argv = [_][]const u8{
|
||||
exe, url, "--dump-json", "--flat-playlist", "--no-warnings", "--ignore-errors", "--playlist-end", "60",
|
||||
};
|
||||
|
||||
const out = runYtDlp(a, &argv) catch |e| {
|
||||
return errorJson(output, @errorName(e));
|
||||
};
|
||||
|
||||
var fbs = std.io.fixedBufferStream(output);
|
||||
const w = fbs.writer();
|
||||
|
||||
// Two passes: first scan for the channel name/url, then stream the cards.
|
||||
var name: []const u8 = "";
|
||||
var chan_url: []const u8 = "";
|
||||
{
|
||||
var it = std.mem.splitScalar(u8, out, '\n');
|
||||
while (it.next()) |line| {
|
||||
const trimmed = std.mem.trim(u8, line, " \r\t");
|
||||
if (trimmed.len == 0) continue;
|
||||
var parsed = std.json.parseFromSlice(std.json.Value, a, trimmed, .{}) catch continue;
|
||||
defer parsed.deinit();
|
||||
if (parsed.value != .object) continue;
|
||||
const obj = parsed.value.object;
|
||||
if (name.len == 0) name = a.dupe(u8, pickChannel(obj)) catch "";
|
||||
if (chan_url.len == 0) chan_url = a.dupe(u8, pickChannelUrl(obj)) catch "";
|
||||
if (name.len > 0 and chan_url.len > 0) break;
|
||||
}
|
||||
}
|
||||
|
||||
try w.writeAll("{\"ok\":true,\"channel\":");
|
||||
try writeJsonString(w, name);
|
||||
try w.writeAll(",\"channelUrl\":");
|
||||
try writeJsonString(w, chan_url);
|
||||
try w.writeAll(",\"results\":[");
|
||||
|
||||
var first = true;
|
||||
var it = std.mem.splitScalar(u8, out, '\n');
|
||||
while (it.next()) |line| {
|
||||
const trimmed = std.mem.trim(u8, line, " \r\t");
|
||||
if (trimmed.len == 0) continue;
|
||||
var parsed = std.json.parseFromSlice(std.json.Value, a, trimmed, .{}) catch continue;
|
||||
defer parsed.deinit();
|
||||
if (parsed.value != .object) continue;
|
||||
const obj = parsed.value.object;
|
||||
if (jsonStr(obj.get("id")).len == 0) continue;
|
||||
if (!first) try w.writeByte(',');
|
||||
const wrote = try writeCard(w, a, obj);
|
||||
if (wrote) first = false;
|
||||
}
|
||||
|
||||
try w.writeAll("]}");
|
||||
return fbs.getWritten();
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Handler: yt.streams
|
||||
// ===========================================================================
|
||||
pub fn ytStreams(context: *anyopaque, invocation: Invocation, output: []u8) anyerror![]const u8 {
|
||||
_ = context;
|
||||
var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
|
||||
defer arena.deinit();
|
||||
const a = arena.allocator();
|
||||
|
||||
const video_id = (try payloadField(a, invocation.request.payload, "videoId")) orelse "";
|
||||
if (video_id.len == 0) return errorJson(output, "missing videoId");
|
||||
|
||||
const exe = ytDlpPath(a);
|
||||
const url = try std.fmt.allocPrint(a, "https://www.youtube.com/watch?v={s}", .{video_id});
|
||||
const argv = [_][]const u8{ exe, "-J", "--no-warnings", url };
|
||||
|
||||
const out = runYtDlp(a, &argv) catch |e| {
|
||||
return errorJson(output, @errorName(e));
|
||||
};
|
||||
|
||||
var parsed = std.json.parseFromSlice(std.json.Value, a, out, .{}) catch {
|
||||
return errorJson(output, "parse error");
|
||||
};
|
||||
defer parsed.deinit();
|
||||
if (parsed.value != .object) return errorJson(output, "bad info json");
|
||||
const info = parsed.value.object;
|
||||
|
||||
const title = jsonStr(info.get("title"));
|
||||
const channel = pickChannel(info);
|
||||
const channel_id = pickChannelId(info);
|
||||
const channel_url = pickChannelUrl(info);
|
||||
const duration = jsonNum(info.get("duration"));
|
||||
|
||||
var fbs = std.io.fixedBufferStream(output);
|
||||
const w = fbs.writer();
|
||||
|
||||
try w.writeAll("{\"ok\":true,\"data\":{\"meta\":{\"id\":");
|
||||
try writeJsonString(w, video_id);
|
||||
try w.writeAll(",\"title\":");
|
||||
try writeJsonString(w, title);
|
||||
try w.writeAll(",\"channel\":");
|
||||
try writeJsonString(w, channel);
|
||||
try w.writeAll(",\"channelId\":");
|
||||
try writeJsonString(w, channel_id);
|
||||
try w.writeAll(",\"channelUrl\":");
|
||||
try writeJsonString(w, channel_url);
|
||||
try w.print(",\"duration\":{d}", .{duration});
|
||||
try w.writeAll(",\"thumbnail\":");
|
||||
const thumb = try std.fmt.allocPrint(a, "https://i.ytimg.com/vi/{s}/hqdefault.jpg", .{video_id});
|
||||
try writeJsonString(w, thumb);
|
||||
try w.writeByte('}');
|
||||
|
||||
// Walk formats. Track best audio, and emit video/progressive qualities.
|
||||
var best_audio_url: []const u8 = "";
|
||||
var best_audio_abr: f64 = -1;
|
||||
|
||||
const formats = info.get("formats");
|
||||
if (formats) |fv| {
|
||||
if (fv == .array) {
|
||||
// First pass: best audio-only stream (prefer m4a/mp4a).
|
||||
for (fv.array.items) |item| {
|
||||
if (item != .object) continue;
|
||||
const f = item.object;
|
||||
const vcodec = jsonStr(f.get("vcodec"));
|
||||
const acodec = jsonStr(f.get("acodec"));
|
||||
const furl = jsonStr(f.get("url"));
|
||||
if (furl.len == 0) continue;
|
||||
const has_video = vcodec.len > 0 and !std.mem.eql(u8, vcodec, "none");
|
||||
const has_audio = acodec.len > 0 and !std.mem.eql(u8, acodec, "none");
|
||||
if (!has_video and has_audio) {
|
||||
var score = jsonNum(f.get("abr"));
|
||||
if (std.mem.indexOf(u8, acodec, "mp4a") != null) score += 1000;
|
||||
if (score > best_audio_abr) {
|
||||
best_audio_abr = score;
|
||||
best_audio_url = furl;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try w.writeAll(",\"audioUrl\":");
|
||||
if (best_audio_url.len > 0) try writeJsonString(w, best_audio_url) else try w.writeAll("null");
|
||||
|
||||
try w.writeAll(",\"qualities\":[");
|
||||
var firstq = true;
|
||||
// Track which heights we've already emitted to dedupe.
|
||||
var seen = std.AutoHashMap(i64, void).init(a);
|
||||
|
||||
if (formats) |fv| {
|
||||
if (fv == .array) {
|
||||
// Adaptive video-only, then progressive — both keyed by height.
|
||||
const passes = [_]bool{ false, true }; // false=video-only, true=progressive
|
||||
for (passes) |want_progressive| {
|
||||
for (fv.array.items) |item| {
|
||||
if (item != .object) continue;
|
||||
const f = item.object;
|
||||
const vcodec = jsonStr(f.get("vcodec"));
|
||||
const acodec = jsonStr(f.get("acodec"));
|
||||
const furl = jsonStr(f.get("url"));
|
||||
if (furl.len == 0) continue;
|
||||
const has_video = vcodec.len > 0 and !std.mem.eql(u8, vcodec, "none");
|
||||
const has_audio = acodec.len > 0 and !std.mem.eql(u8, acodec, "none");
|
||||
if (!has_video) continue;
|
||||
const is_progressive = has_audio;
|
||||
if (is_progressive != want_progressive) continue;
|
||||
const height: i64 = @intFromFloat(jsonNum(f.get("height")));
|
||||
if (height <= 0) continue;
|
||||
if (seen.contains(height)) continue;
|
||||
seen.put(height, {}) catch {};
|
||||
|
||||
if (!firstq) try w.writeByte(',');
|
||||
firstq = false;
|
||||
try w.print("{{\"label\":\"{d}p\",\"height\":{d},\"hasAudio\":{s},\"url\":", .{
|
||||
height, height, if (is_progressive) "true" else "false",
|
||||
});
|
||||
try writeJsonString(w, furl);
|
||||
try w.writeAll(",\"ext\":");
|
||||
try writeJsonString(w, jsonStr(f.get("ext")));
|
||||
try w.writeByte('}');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try w.writeAll("]}}");
|
||||
return fbs.getWritten();
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Handlers: store.load / store.save (local JSON in the app data dir)
|
||||
// ===========================================================================
|
||||
fn dataFilePath(a: std.mem.Allocator) ![]const u8 {
|
||||
const dir = std.fs.getAppDataDir(a, "ytplayer") catch ".";
|
||||
std.fs.cwd().makePath(dir) catch {};
|
||||
return std.fs.path.join(a, &.{ dir, "ytplayer-data.json" });
|
||||
}
|
||||
|
||||
pub fn storeLoad(context: *anyopaque, invocation: Invocation, output: []u8) anyerror![]const u8 {
|
||||
_ = context;
|
||||
_ = invocation;
|
||||
var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
|
||||
defer arena.deinit();
|
||||
const a = arena.allocator();
|
||||
|
||||
const path = try dataFilePath(a);
|
||||
const file = std.fs.cwd().openFile(path, .{}) catch {
|
||||
const def = "{\"playlists\":[],\"history\":[],\"settings\":{\"quality\":\"auto\",\"volume\":1,\"audioOnly\":false}}";
|
||||
if (def.len > output.len) return error.NoSpaceLeft;
|
||||
@memcpy(output[0..def.len], def);
|
||||
return output[0..def.len];
|
||||
};
|
||||
defer file.close();
|
||||
const n = try file.readAll(output);
|
||||
return output[0..n];
|
||||
}
|
||||
|
||||
pub fn storeSave(context: *anyopaque, invocation: Invocation, output: []u8) anyerror![]const u8 {
|
||||
_ = context;
|
||||
var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
|
||||
defer arena.deinit();
|
||||
const a = arena.allocator();
|
||||
|
||||
const blob = (try payloadField(a, invocation.request.payload, "data")) orelse {
|
||||
// payload.data may itself be an object; re-serialize the whole payload's "data".
|
||||
return errorJson(output, "missing data");
|
||||
};
|
||||
|
||||
const path = try dataFilePath(a);
|
||||
const tmp = try std.fmt.allocPrint(a, "{s}.tmp", .{path});
|
||||
{
|
||||
const file = try std.fs.cwd().createFile(tmp, .{ .truncate = true });
|
||||
defer file.close();
|
||||
try file.writeAll(blob);
|
||||
}
|
||||
try std.fs.cwd().rename(tmp, path);
|
||||
|
||||
const ok = "{\"ok\":true}";
|
||||
@memcpy(output[0..ok.len], ok);
|
||||
return output[0..ok.len];
|
||||
}
|
||||
|
||||
fn errorJson(output: []u8, msg: []const u8) []const u8 {
|
||||
var fbs = std.io.fixedBufferStream(output);
|
||||
const w = fbs.writer();
|
||||
w.writeAll("{\"ok\":false,\"error\":") catch return "{\"ok\":false}";
|
||||
writeJsonString(w, msg) catch return "{\"ok\":false}";
|
||||
w.writeByte('}') catch return "{\"ok\":false}";
|
||||
return fbs.getWritten();
|
||||
}
|
||||
58
legacy/src/main.zig
Executable file
58
legacy/src/main.zig
Executable file
@@ -0,0 +1,58 @@
|
||||
//! YT Player — zero-native app entry.
|
||||
//!
|
||||
//! This file shows how the App is wired to the runtime and how the bridge
|
||||
//! handlers in `bridge.zig` are registered. When you scaffold with
|
||||
//! `zero-native init`, a `src/main.zig` + `src/runner.zig` + `build.zig` are
|
||||
//! generated for your installed version. Merge the `bridge()` registration and
|
||||
//! the `handlers`/`policies` below into that generated App — the handler
|
||||
//! implementations themselves live in `bridge.zig` and need no changes.
|
||||
|
||||
const std = @import("std");
|
||||
const zero_native = @import("zero_native");
|
||||
const handlers_impl = @import("bridge.zig");
|
||||
|
||||
const Handler = zero_native.bridge.Handler;
|
||||
|
||||
// Commands the UI is allowed to call, matched against window.zero.invoke names.
|
||||
const policies = [_]zero_native.bridge.CommandPolicy{
|
||||
.{ .command = "yt.search" },
|
||||
.{ .command = "yt.channel" },
|
||||
.{ .command = "yt.streams" },
|
||||
.{ .command = "store.load" },
|
||||
.{ .command = "store.save" },
|
||||
};
|
||||
|
||||
pub const App = struct {
|
||||
handlers: [5]Handler = undefined,
|
||||
|
||||
pub fn app(self: *App) zero_native.App {
|
||||
return .{
|
||||
.context = self,
|
||||
.name = "YT Player",
|
||||
// Serve the packaged static UI from the zero://app origin.
|
||||
.source = zero_native.WebViewSource.packaged("frontend", "index.html"),
|
||||
.bridge = bridge(self),
|
||||
};
|
||||
}
|
||||
|
||||
fn bridge(self: *App) zero_native.BridgeDispatcher {
|
||||
self.handlers = .{
|
||||
.{ .name = "yt.search", .context = self, .invoke_fn = handlers_impl.ytSearch },
|
||||
.{ .name = "yt.channel", .context = self, .invoke_fn = handlers_impl.ytChannel },
|
||||
.{ .name = "yt.streams", .context = self, .invoke_fn = handlers_impl.ytStreams },
|
||||
.{ .name = "store.load", .context = self, .invoke_fn = handlers_impl.storeLoad },
|
||||
.{ .name = "store.save", .context = self, .invoke_fn = handlers_impl.storeSave },
|
||||
};
|
||||
return .{
|
||||
.policy = .{ .enabled = true, .commands = &policies },
|
||||
.registry = .{ .handlers = &self.handlers },
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
pub fn main() !void {
|
||||
var instance: App = .{};
|
||||
var runtime = try zero_native.Runtime.init(std.heap.page_allocator);
|
||||
defer runtime.deinit();
|
||||
try runtime.run(instance.app());
|
||||
}
|
||||
Reference in New Issue
Block a user