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"
+ }
+ }
+}