- Dual mode (separate video + audio, used by adaptive streams and the iPhone EQ copies): going to the background now makes the audio the master, so progress, Now Playing and auto-advance keep working while iOS has the video suspended (the paused video used to be the only source of "ended", so playback stopped after the current song). The picture is re-synced on return. - A track that starts while the app is in the background (auto-advance, the lock-screen Next button) starts as audio — iOS will not start a <video> there — and gets its picture back at the same position when the app returns. - The handoff also runs on pagehide and when iOS pauses the master video while the page is hidden, for when visibilitychange arrives too late. - Classic: playlist cards place their three actions as a 2x2 block (note + add on top, remove below) instead of the note button wrapping to the bottom-left. - Save to this device: hide the full progress bar once the file is ready; it read as a squashed, broken button above "Save to Photos / Files".
YT Player
A lightweight, ad-free YouTube player with on-device playlists. No login, no
tracking, no ads — it extracts direct video/audio streams with yt-dlp and plays
them in a native window.
Built on zero-native (Zig + the system WebView) instead of Electron, so the binary is tiny — no bundled Chromium or Node runtime.
Features
- 🔍 Search YouTube without an account
- 🚫 No ads — plays the raw stream, never the YouTube player
- 📺 High quality via a dual-stream engine: a muted video track synced with a
separate audio track (1080p+) with no
ffmpegmuxing required; progressive formats are used as a fallback - 🎵 Audio-only mode — great for music, saves bandwidth
- 📂 On-device playlists — create, rename, delete, add/remove videos. Stored locally as JSON; nothing leaves your machine
- 💾 Offline cache / preload — a Save button and an Add to playlist button on the now-playing video download a self-contained copy into a permanent file cache, so it plays instantly and works offline. Videos added to a playlist are auto-preloaded and kept until you remove them.
- ⚙ Settings page — playback defaults (quality, volume, audio-only) plus cache management: see storage used, toggle auto-preload, and delete cached videos individually or all at once
- 🕘 Watch history
- ⏯ Full controls: seek, volume, playback speed, quality switching, fullscreen,
next/prev, and keyboard shortcuts (
space,←/→,f,m)
Architecture
The same frontend drives two native shells:
- Linux / macOS → zero-native (Zig + system WebView)
- Windows → Tauri (Rust + WebView2) — see Run on Windows
frontend/ Static web UI (no framework, no build step) — shared
index.html
styles.css
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 zero-native manifest
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 |
|---|---|---|
yt.search { query } |
ytSearch |
{ ok, results:[…] } |
yt.streams { videoId } |
ytStreams |
{ ok, data:{ meta, audioUrl, qualities[] } } |
store.load {} |
storeLoad |
playlists / history / settings |
store.save { data } |
storeSave |
{ ok } |
cache.download { videoId } |
cache_download |
downloads a single-file copy into the offline cache |
cache.status { videoId } |
cache_status |
{ ok, cached, path?, size? } |
cache.list {} |
cache_list |
{ ok, items:[{id,size,path}], total } |
cache.delete { videoId } |
cache_delete |
removes one cached file |
cache.clear {} |
cache_clear |
removes all cached files |
The offline cache is implemented in the Tauri (Windows) shell. Files live in
<app_cache_dir>/videos/<videoId>.<ext>and persist until deleted from the Settings page. The frontend falls back to live streaming if a cached file is missing, and the cache calls degrade gracefully on shells that don't implement them.
Because the bridge is size-limited, the Zig handlers parse yt-dlp's large JSON
and return only the compact fields the UI needs.
Prerequisites
- Zig (0.14.x recommended) — https://ziglang.org/download/
- zero-native CLI:
npm install -g zero-native - Node.js (only to run the yt-dlp downloader script)
- No system
yt-dlpneeded — 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):
# 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.
One-shot release
scripts\release.ps1 does the whole flow — build, copy installers into
.\releases, then commit and push through WSL git:
pwsh -File scripts\release.ps1
To only commit + push from WSL (e.g. after building separately):
bash scripts/push.sh "your commit message"
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. 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.
# 1. Download the yt-dlp binary into ./bin
npm run setup # or: node scripts/setup-ytdlp.js
# 2. Generate the zero-native build files for your installed version
# (build.zig, build.zig.zon, src/runner.zig). Run this in a scratch dir and
# copy the generated build.zig / build.zig.zon next to this project, OR run
# init here and keep your files:
zero-native init ytplayer --frontend none
# 3. Merge: keep THIS repo's frontend/, src/main.zig, src/bridge.zig and app.zon.
# (src/main.zig shows exactly how the handlers are registered — fold that into
# the generated App if the scaffold differs.)
# 4. Build & launch the native window
zig build run
To refresh yt-dlp later (YouTube changes often): npm run update-ytdlp.
Notes & caveats
- zero-native is pre-1.0. The bridge handler signature used here follows its
documented contract (
fn(context, invocation, output) anyerror![]const u8). If your installed version exposes theInvocationtype orBridgeDispatcherfields slightly differently, only the wiring insrc/main.zigand thepayloadFieldhelper need adjusting — the handler logic is self-contained. - Stream URLs from
yt-dlpare IP-locked and expire after a few hours; the app re-fetches them each time you play a video, so this is transparent. - This is for personal use. Respect YouTube's Terms of Service and the rights of content creators.
License
MIT