Add ad-free YouTube player built on zero-native

Desktop YouTube player with no login and no ads. Extracts direct
video/audio streams with yt-dlp and plays them in a native window via
zero-native (Zig + system WebView) for a tiny footprint vs Electron.

- Dual-stream engine: muted video synced with separate audio track for
  high quality without ffmpeg muxing; progressive fallback
- On-device playlists, watch history, audio-only mode
- Full custom controls: seek, volume, speed, quality, fullscreen, queue
- Zig bridge handlers spawn yt-dlp and return slimmed JSON to the web UI
- Cinematic dark UI: Bricolage/Hanken/JetBrains Mono, vermilion accent
This commit is contained in:
Jonathan Sykes
2026-06-13 23:33:16 +08:00
commit a171cae417
10 changed files with 2146 additions and 0 deletions

351
src/bridge.zig Normal file
View File

@@ -0,0 +1,351 @@
//! 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;
}
// ---------------------------------------------------------------------------
// 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;
const id = jsonStr(obj.get("id"));
if (id.len == 0) continue;
if (!first) try w.writeByte(',');
first = false;
try w.writeAll("{\"id\":");
try writeJsonString(w, id);
try w.writeAll(",\"title\":");
try writeJsonString(w, jsonStr(obj.get("title")));
try w.writeAll(",\"channel\":");
const ch = if (jsonStr(obj.get("channel")).len > 0) jsonStr(obj.get("channel")) else jsonStr(obj.get("uploader"));
try writeJsonString(w, ch);
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('}');
}
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 = if (jsonStr(info.get("channel")).len > 0) jsonStr(info.get("channel")) else jsonStr(info.get("uploader"));
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.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();
}