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:
6
.gitignore
vendored
6
.gitignore
vendored
@@ -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/
|
||||
|
||||
|
||||
57
README.md
57
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
|
||||
|
||||
BIN
appicon.png
Normal file
BIN
appicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.2 KiB |
@@ -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 ----------
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta
|
||||
http-equiv="Content-Security-Policy"
|
||||
content="default-src 'self'; img-src 'self' https: data:; media-src 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:;"
|
||||
content="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;"
|
||||
/>
|
||||
<title>YT Player</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
|
||||
13
package.json
13
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"
|
||||
}
|
||||
}
|
||||
|
||||
92
scripts/make-icon.js
Normal file
92
scripts/make-icon.js
Normal file
@@ -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');
|
||||
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