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:
351
src/bridge.zig
Normal file
351
src/bridge.zig
Normal 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();
|
||||
}
|
||||
Reference in New Issue
Block a user