Add Windows support via a Tauri (WebView2) shell
zero-native has no Windows target yet, so add a parallel Tauri shell for Windows that reuses the same frontend: - src-tauri/: Rust commands (yt_search, yt_streams, store_load, store_save) mirroring the Zig bridge; spawns bundled yt-dlp.exe with CREATE_NO_WINDOW; data stored in app_data_dir - frontend bridge now auto-detects window.__TAURI__ vs window.zero and routes accordingly; CSP updated for Tauri IPC - tauri.conf.json bundles yt-dlp.exe as a resource and builds nsis/msi - scripts/make-icon.js generates appicon.png for 'tauri icon' - README: native Windows build steps + WSLg fallback note
This commit is contained in:
22
src-tauri/Cargo.toml
Normal file
22
src-tauri/Cargo.toml
Normal file
@@ -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
|
||||
3
src-tauri/build.rs
Normal file
3
src-tauri/build.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
tauri_build::build()
|
||||
}
|
||||
7
src-tauri/capabilities/default.json
Normal file
7
src-tauri/capabilities/default.json
Normal 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"]
|
||||
}
|
||||
280
src-tauri/src/main.rs
Normal file
280
src-tauri/src/main.rs
Normal file
@@ -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<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)
|
||||
}
|
||||
|
||||
#[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<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: adaptive video-only first, then progressive; dedupe by height.
|
||||
let mut qualities = Vec::new();
|
||||
let mut seen: HashSet<i64> = 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<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"))
|
||||
}
|
||||
|
||||
#[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_streams,
|
||||
store_load,
|
||||
store_save
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
}
|
||||
39
src-tauri/tauri.conf.json
Normal file
39
src-tauri/tauri.conf.json
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user