diff --git a/.gitignore b/.gitignore index 20b18fe..a94cdcb 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,12 @@ build.zig build.zig.zon src/runner.zig +# tauri / rust build artifacts +src-tauri/target/ +src-tauri/gen/ +# generated by `npm run tauri icon ./appicon.png` +src-tauri/icons/ + # node node_modules/ diff --git a/README.md b/README.md index 2746a55..f23d5bc 100644 --- a/README.md +++ b/README.md @@ -24,21 +24,32 @@ or Node runtime. ## Architecture +The **same frontend** drives two native shells: + +- **Linux / macOS** → zero-native (Zig + system WebView) +- **Windows** → Tauri (Rust + WebView2) — see [Run on Windows](#run-on-windows-tauri) + ``` -frontend/ Static web UI (no framework, no build step) +frontend/ Static web UI (no framework, no build step) — shared index.html styles.css - app.js Player engine + UI; calls window.zero.invoke(...) -src/ + app.js Player engine + UI; auto-detects Tauri vs zero-native +src/ zero-native (Linux/macOS) shell main.zig App definition + bridge handler registration bridge.zig Native handlers: spawn yt-dlp, slim its JSON, local store +src-tauri/ Tauri (Windows) shell + src/main.rs Same handlers as Rust commands + tauri.conf.json Window, CSP, bundle (icons, yt-dlp resource) + Cargo.toml scripts/ setup-ytdlp.js Downloads the standalone yt-dlp binary into ./bin + make-icon.js Generates appicon.png for `tauri icon` bin/ yt-dlp lands here (gitignored) -app.zon App manifest (window, engine, permissions, frontend dir) +app.zon zero-native manifest ``` -The web UI talks to the Zig side over the zero-native bridge: +The web UI talks to the native side over whichever bridge is present +(`window.__TAURI__.core.invoke` on Windows, `window.zero.invoke` elsewhere): | `window.zero.invoke(...)` | Native handler | Returns | |---|---|---| @@ -58,8 +69,44 @@ and return only the compact fields the UI needs. - No system `yt-dlp` needed — the setup script bundles it. (On Linux/macOS the bundled build is self-contained; Python is not required.) +## Run on Windows (Tauri) + +zero-native does **not** target Windows yet, so the Windows build uses a Tauri +shell (Rust + the WebView2 runtime that ships with Windows 10/11). The result is +a small standalone `.exe`/installer — no bundled Chromium or Node. + +**Build natively on the Windows machine** (cross-compiling a WebView2 app from +WSL/Linux is unreliable, so do this on Windows): + +```powershell +# Prerequisites (one time): +# • Rust https://rustup.rs (MSVC toolchain) +# • Microsoft C++ Build Tools (Desktop development with C++) +# • WebView2 runtime — preinstalled on Win11; on Win10 grab the Evergreen runtime +# • Node.js (to run the helper scripts + Tauri CLI) + +npm install # installs the Tauri CLI (@tauri-apps/cli) +npm run setup # downloads yt-dlp.exe into .\bin +npm run make-icon # writes appicon.png +npm run tauri icon .\appicon.png # expands it into src-tauri\icons\* + +npm run tauri:dev # hot dev window +npm run tauri:build # produces the installer (see below) +``` + +The installer lands in +`src-tauri\target\release\bundle\` (`nsis\*-setup.exe` and `msi\*.msi`). +`yt-dlp.exe` is bundled as an app resource, so the installed app is self-contained. + +> Already in WSL and just want it running fast? Your WSL is WSLg-enabled, so you +> can instead build the **Linux** (zero-native) target and its window appears on +> your Windows desktop — see [Setup & run](#setup--run). That needs WSL running +> each time; the Tauri build above is a true standalone Windows app. + ## Setup & run +> Linux / macOS (zero-native). For Windows see [Run on Windows](#run-on-windows-tauri). + ```bash # 1. Download the yt-dlp binary into ./bin npm run setup # or: node scripts/setup-ytdlp.js diff --git a/appicon.png b/appicon.png new file mode 100644 index 0000000..498405a Binary files /dev/null and b/appicon.png differ diff --git a/frontend/app.js b/frontend/app.js index d8d632c..f196d56 100644 --- a/frontend/app.js +++ b/frontend/app.js @@ -9,19 +9,25 @@ * ========================================================================== */ // ---------- Native bridge adapter ---------- -async function invoke(command, payload = {}) { - if (window.zero && typeof window.zero.invoke === 'function') { - return await window.zero.invoke(command, payload); - } - throw new Error('Native bridge unavailable — run this inside zero-native (zig build run).'); +// Works against two shells from the same frontend: +// • Tauri (Windows / WebView2): window.__TAURI__.core.invoke, snake_case commands +// • zero-native (Linux / macOS): window.zero.invoke, dotted commands +const TAURI = window.__TAURI__ && window.__TAURI__.core ? window.__TAURI__.core : null; +const ZERO = window.zero && typeof window.zero.invoke === 'function' ? window.zero : null; + +// call(zeroName, tauriName, payload) — routes to whichever shell is present. +async function call(zeroName, tauriName, payload = {}) { + if (TAURI) return await TAURI.invoke(tauriName, payload); + if (ZERO) return await ZERO.invoke(zeroName, payload); + throw new Error('No native bridge available — run inside the YT Player app.'); } const API = { - search: (query) => invoke('yt.search', { query }), - getStreams: (videoId) => invoke('yt.streams', { videoId }), - loadData: () => invoke('store.load', {}), + search: (query) => call('yt.search', 'yt_search', { query }), + getStreams: (videoId) => call('yt.streams', 'yt_streams', { videoId }), + loadData: () => call('store.load', 'store_load', {}), // data is sent pre-stringified so the native side can write it verbatim. - saveData: (data) => invoke('store.save', { data: JSON.stringify(data) }), + saveData: (data) => call('store.save', 'store_save', { data: JSON.stringify(data) }), }; // ---------- State ---------- diff --git a/frontend/index.html b/frontend/index.html index 866043c..d06ada4 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -5,7 +5,7 @@ YT Player diff --git a/package.json b/package.json index 8275cfb..0bc3b18 100644 --- a/package.json +++ b/package.json @@ -1,11 +1,18 @@ { "name": "ytplayer", "version": "1.0.0", - "description": "Ad-free YouTube player with local playlists. No login, no ads, direct streams via yt-dlp. Built on zero-native (Zig + system WebView).", + "description": "Ad-free YouTube player with local playlists. No login, no ads, direct streams via yt-dlp. zero-native (Linux/macOS) and Tauri (Windows) shells.", "scripts": { "setup": "node scripts/setup-ytdlp.js", - "update-ytdlp": "node scripts/setup-ytdlp.js --force" + "update-ytdlp": "node scripts/setup-ytdlp.js --force", + "make-icon": "node scripts/make-icon.js", + "tauri": "tauri", + "tauri:dev": "tauri dev", + "tauri:build": "tauri build" }, "author": "", - "license": "MIT" + "license": "MIT", + "devDependencies": { + "@tauri-apps/cli": "^2" + } } diff --git a/scripts/make-icon.js b/scripts/make-icon.js new file mode 100644 index 0000000..d7b923f --- /dev/null +++ b/scripts/make-icon.js @@ -0,0 +1,92 @@ +#!/usr/bin/env node +/** + * Generates appicon.png (512x512) — a vermilion tile with a white play glyph. + * No image libraries: encodes a PNG by hand with Node's zlib. + * + * After running this, expand it into all platform sizes with the Tauri CLI: + * npm run tauri icon ./appicon.png + */ +const fs = require('fs'); +const path = require('path'); +const zlib = require('zlib'); + +const SIZE = 512; + +// CRC32 (PNG chunk checksums) +const CRC_TABLE = (() => { + const t = new Uint32Array(256); + for (let n = 0; n < 256; n++) { + let c = n; + for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; + t[n] = c >>> 0; + } + return t; +})(); +function crc32(buf) { + let c = 0xffffffff; + for (let i = 0; i < buf.length; i++) c = CRC_TABLE[(c ^ buf[i]) & 0xff] ^ (c >>> 8); + return (c ^ 0xffffffff) >>> 0; +} +function chunk(type, data) { + const len = Buffer.alloc(4); + len.writeUInt32BE(data.length, 0); + const typeBuf = Buffer.from(type, 'ascii'); + const crc = Buffer.alloc(4); + crc.writeUInt32BE(crc32(Buffer.concat([typeBuf, data])), 0); + return Buffer.concat([len, typeBuf, data, crc]); +} + +// --- paint pixels (RGBA) --- +const px = Buffer.alloc(SIZE * SIZE * 4); +function set(x, y, r, g, b, a = 255) { + const i = (y * SIZE + x) * 4; + px[i] = r; px[i + 1] = g; px[i + 2] = b; px[i + 3] = a; +} + +// Background: subtle vertical vermilion gradient (#ff6a52 -> #d4321d) +for (let y = 0; y < SIZE; y++) { + const t = y / SIZE; + const r = Math.round(0xff + (0xd4 - 0xff) * t); + const g = Math.round(0x6a + (0x32 - 0x6a) * t); + const b = Math.round(0x52 + (0x1d - 0x52) * t); + for (let x = 0; x < SIZE; x++) set(x, y, r, g, b); +} + +// White play triangle, centered, slightly right-nudged for optical balance. +const ax = 196, ay = 150, bx = 196, by = 362, cx = 384, cy = 256; +function edge(px1, py1, px2, py2, x, y) { + return (x - px1) * (py2 - py1) - (y - py1) * (px2 - px1); +} +for (let y = 120; y < 392; y++) { + for (let x = 170; x < 400; x++) { + const w0 = edge(bx, by, cx, cy, x, y); + const w1 = edge(cx, cy, ax, ay, x, y); + const w2 = edge(ax, ay, bx, by, x, y); + if ((w0 <= 0 && w1 <= 0 && w2 <= 0) || (w0 >= 0 && w1 >= 0 && w2 >= 0)) { + set(x, y, 255, 255, 255); + } + } +} + +// --- encode --- +const raw = Buffer.alloc((SIZE * 4 + 1) * SIZE); +for (let y = 0; y < SIZE; y++) { + raw[y * (SIZE * 4 + 1)] = 0; // filter: none + px.copy(raw, y * (SIZE * 4 + 1) + 1, y * SIZE * 4, (y + 1) * SIZE * 4); +} +const ihdr = Buffer.alloc(13); +ihdr.writeUInt32BE(SIZE, 0); +ihdr.writeUInt32BE(SIZE, 4); +ihdr[8] = 8; // bit depth +ihdr[9] = 6; // color type RGBA +const png = Buffer.concat([ + Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), + chunk('IHDR', ihdr), + chunk('IDAT', zlib.deflateSync(raw, { level: 9 })), + chunk('IEND', Buffer.alloc(0)), +]); + +const out = path.join(__dirname, '..', 'appicon.png'); +fs.writeFileSync(out, png); +console.log(`Wrote ${out} (${SIZE}x${SIZE}).`); +console.log('Now run: npm run tauri icon ./appicon.png'); diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml new file mode 100644 index 0000000..ebecd46 --- /dev/null +++ b/src-tauri/Cargo.toml @@ -0,0 +1,22 @@ +[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 = [] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" + +[profile.release] +opt-level = "s" +lto = true +strip = true +panic = "abort" +codegen-units = 1 diff --git a/src-tauri/build.rs b/src-tauri/build.rs new file mode 100644 index 0000000..d860e1e --- /dev/null +++ b/src-tauri/build.rs @@ -0,0 +1,3 @@ +fn main() { + tauri_build::build() +} diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json new file mode 100644 index 0000000..25544c6 --- /dev/null +++ b/src-tauri/capabilities/default.json @@ -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"] +} diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs new file mode 100644 index 0000000..f4fef48 --- /dev/null +++ b/src-tauri/src/main.rs @@ -0,0 +1,280 @@ +// 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_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. + +#![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; + +/// Locate yt-dlp: prefer the bundled resource copy, then a dev ./bin, then 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; + } + PathBuf::from(name) +} + +fn run_ytdlp(app: &tauri::AppHandle, args: &[&str]) -> Result { + 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) +} + +#[tauri::command] +async fn yt_search(app: tauri::AppHandle, query: String) -> Value { + let q = query.trim(); + if q.is_empty() { + return json!({ "ok": false, "error": "empty query" }); + } + 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, + }; + let id = str_or(&j, "id", ""); + if id.is_empty() { + continue; + } + let channel = { + let c = str_or(&j, "channel", ""); + if c.is_empty() { + str_or(&j, "uploader", "") + } else { + c + } + }; + results.push(json!({ + "id": id, + "title": str_or(&j, "title", "(untitled)"), + "channel": channel, + "duration": j.get("duration").and_then(|x| x.as_f64()).unwrap_or(0.0), + "thumbnail": format!("https://i.ytimg.com/vi/{}/mqdefault.jpg", id), + })); + } + + json!({ "ok": true, "results": results }) +} + +#[tauri::command] +async fn yt_streams(app: tauri::AppHandle, video_id: String) -> Value { + if video_id.is_empty() { + return json!({ "ok": false, "error": "missing videoId" }); + } + 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 = 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: adaptive video-only first, then progressive; dedupe by height. + let mut qualities = Vec::new(); + let mut seen: HashSet = HashSet::new(); + for want_progressive in [false, true] { + 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)) + }); + + let channel = { + let c = str_or(&info, "channel", ""); + if c.is_empty() { + str_or(&info, "uploader", "") + } else { + c + } + }; + + json!({ + "ok": true, + "data": { + "meta": { + "id": video_id, + "title": str_or(&info, "title", "(untitled)"), + "channel": channel, + "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 { + 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")) +} + +#[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::(&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_streams, + store_load, + store_save + ]) + .run(tauri::generate_context!()) + .expect("error while running tauri application"); +} diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json new file mode 100644 index 0000000..f5a019f --- /dev/null +++ b/src-tauri/tauri.conf.json @@ -0,0 +1,39 @@ +{ + "$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:; 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" + } + }, + "bundle": { + "active": true, + "targets": ["nsis", "msi"], + "icon": [ + "icons/32x32.png", + "icons/128x128.png", + "icons/128x128@2x.png", + "icons/icon.icns", + "icons/icon.ico" + ], + "resources": { + "../bin/yt-dlp.exe": "bin/yt-dlp.exe" + } + } +}