A video's channel name under the thumbnail is now a link — click it in
either list or tile view and the feed narrows to that channel. It
clears the search box on the way, so arriving at a channel shows the
channel rather than whatever you had been searching for.
On a channel page, an icon button to the right of Local queues every
video listed there. The backend already runs two downloads at a time
and parks the rest, so the whole channel goes into the queue at once
and comes down in order. The button is only there when there is
something left to fetch, and a video that fails reports it on its own
row rather than raising a dialog per failure.
Cancelling a queued download now actually cancels it. Before, a video
waiting for a slot ignored the cancel and started anyway once its turn
came — barely reachable with one-at-a-time downloading, unmissable
when a whole channel is queued. A download re-reads its own state
after claiming a slot and stands down if it is no longer wanted.
Failing channels are visible instead of buried in a toast count. Each
refresh records its outcome per channel, the sidebar marks the failures
and counts them in its heading, and selecting one explains why above its
videos. Yours turn out to be three channels returning HTTP 404 — removed
or renamed on YouTube.
Video lengths now fill for what is on screen. Filling the newest across
all subscriptions meant a channel's videos stayed blank forever, since
the global newest always won the queue.
yt-dlp can be updated from Settings, which also says whether it is
current. It breaks whenever YouTube changes something, so it lands in
app data and takes precedence over the bundled copy; ffmpeg is stable
and ships with each release, so it is shown but not updated.
Also: 'Downloaded only' is now 'Local'; Hide Shorts moved to Settings;
the seven-step Takeout guide moved behind a button, since it dominated
the panel; the player names the height it is actually streaming, which
changes as an adaptive stream switches rendition; the player's delete is
an icon; and the window minimum drops to 1080 now that the control row
has one fewer button, while still never wrapping.
Two faults behind the 429 in the report.
The subtitle languages were a wildcard: 'en.*' also matches every
machine-translated variant YouTube offers — en-en-US and dozens more —
so each download fired a burst of subtitle requests and earned an HTTP
429. Languages are now named exactly, as '<lang>,<lang>-orig'. Turning
subtitles off now really does request none; it previously still asked
for English.
Worse, yt-dlp exits non-zero if anything at all failed, and a caption it
could not fetch was enough to mark a fully downloaded video as failed
and leave the file orphaned. yt-dlp now runs with --ignore-errors, and
success is judged by whether the media file actually landed rather than
by the exit code.
429 also gets its own message pointing at the sign-in setting, which
raises the limit.
Answers the bot challenge from inside the app. Settings gains a browser
picker listing only the browsers actually installed; choosing one passes
--cookies-from-browser to every yt-dlp call, so YouTube sees an
authenticated session. Verified against a live block: refused without
cookies, resolved with them.
Arc is Chromium underneath but is not one of yt-dlp's known names, so it
is addressed by its profile directory instead.
yt-dlp's errors are translated into something actionable. The stock bot
message points at command-line flags a user cannot type; it now names
the setting that fixes it, and Safari's protected cookie store gets its
own message about Full Disk Access rather than a bare 'Operation not
permitted'.
A Check connection button reports whether YouTube is reachable, testing
against the newest video in the feed — the hardcoded id it used at first
had been taken down, so it reported a dead video rather than the
connection.
Subtitles rendered pinned to the left edge and clipped, in half-grey
karaoke text: YouTube's auto-captions carry align:start position:0% on
every cue plus inline <00:00:12.480><c>word</c> timing tags. Both are
now stripped after download, and existing downloads are tidied the first
time they are listed.
Thumbnails show video length. The Atom feed carries no duration, so it
is read from the watch page and cached — but only a trickle. The first
version fetched 24 pages every 12 seconds at six concurrent, roughly two
requests a second sustained, and YouTube answered by challenging the
whole IP: 'Sign in to confirm you are not a bot', which broke streaming
and downloads too. It is now four pages every five minutes, one at a
time, and stops for the session the moment a batch is refused.
A subtitle chosen in the player becomes the stored preference, so the
next video matches without going back to Settings.
The back and download buttons are square icon buttons; the back glyph
was off-centre because px-2 beat the p-0 meant to clear it.
Subtitles: a language preference in Settings drives what is shown and
what is downloaded. yt-dlp saves WebVTT sidecars next to each download,
including YouTube's auto-generated track, and the player attaches them
as <track> elements so they work offline. Sidecars are removed with
their video, and by Delete all.
WebVTT rather than muxed subtitle streams because WebKit reads a <track>
reliably and largely ignores subtitle tracks inside an MP4.
Downloads in progress now appear under the downloaded-only filter, so a
download you just started does not vanish from the list you are watching
it in. That view also gains a Delete all, behind a confirmation naming
what goes.
The feed refreshes on launch and whenever the player closes, so the
Refresh button is only for staleness.
Player: Delete moved to the footer and shortened, Open on YouTube is now
an external-link icon.
Removes the Edit and Help menus. Cut/Copy/Paste move to the app menu,
without which their shortcuts would stop working in the search field.
The webview context menu is suppressed outside text fields — its Reload
and Back items act on a page the app does not present as one.
Settings now warns that 4K AV1 plays back with artefacts: the files
decode cleanly in ffmpeg, so it is the built-in decoder, not the
download.
YouTube marks an AI-dubbed audio track DEFAULT=YES on some videos and
AVFoundation obeys the manifest, so a synthetic voice played over the
original. The app now always serves its own rewritten master playlist —
previously only when a quality cap was set — and marks the track whose
language matches the video's own as the default, falling back to
whichever track does not describe itself as dubbed. Every track is still
listed, so switching remains possible.
Adds an audio and subtitle picker to the transport bar, reading the
video element's audioTracks and textTracks. Subtitles start off for the
same reason the dub does: nothing translated is forced on.
Also collapses the player's own title-bar strip in window fullscreen,
which was leaving a stray bar above the player header.
Replaces the official yt-dlp_macos sidecar. That build is PyInstaller
one-file and unpacks 37MB on every invocation, costing ~8s per call on
this machine — ruled out signing, xattrs and thinning the universal
binary as causes, and it is 8% CPU over 8s, so it is the unpack itself.
Every stream resolve paid it.
The 3MB zipapp on a trimmed portable CPython starts in ~0.45s, a 17x
improvement; a real stream resolve goes from ~10s to ~2.4s. macOS ships
only Python 3.9 and yt-dlp needs 3.10+, hence bundling an interpreter.
yt-dlp is now resolved as an argv prefix rather than a path, so a system
install (a plain script, instant) still takes precedence.
Bundle grows 153MB -> 219MB, which buys back the responsiveness.
The bundled yt-dlp is a PyInstaller one-file binary that unpacks ~37MB
on every invocation, costing about eight seconds a call on this machine
regardless of signing, xattrs or thinning the universal binary. Homebrew's
copy is a Python script and starts instantly, so bundling traded startup
speed for self-containment.
Softens it where possible: tool versions are read once at startup in the
background rather than on every Settings open, and resolved stream URLs
are cached for three hours (well inside YouTube's signed-URL lifetime) so
replaying a video costs nothing. A first play still pays the startup.
The app is now self-contained: yt-dlp, ffmpeg and ffprobe ship inside
the bundle and are resolved beside the executable, so a fresh Mac needs
nothing installed. A system copy still wins when present, which is how
to run a newer yt-dlp than the bundled one. yt-dlp is pointed at the
bundled ffmpeg explicitly, since a bundled app has no reason to have one
on PATH.
The binaries stay out of git (~140MB); scripts/fetch-binaries.sh pulls
them. Homebrew's ffmpeg cannot be used — it links a dozen dylibs and
does not relocate — so the static arm64 build is used instead.
Version checks moved to the async Command. yt-dlp is a PyInstaller
bundle that unpacks ~37MB on first run, and the blocking call was
stalling a runtime worker for roughly twenty seconds.
Bundle size goes from 19MB to 153MB.
Every control in the app now shares one height (CONTROL_H), with width
still growing to fit its label. The player transport is larger, Back is
an icon, and the footer buttons match the prev/next size.
Player chrome — transport and edge arrows — fades after 2.6s of
inactivity and never hides while paused.
The title-bar strip collapses in macOS window fullscreen, where the
traffic lights are gone and it was just a blank white bar.
Streaming quality is now selectable alongside download quality. A cap is
applied by rewriting YouTube's HLS master playlist down to the best
variant at or below the chosen height, keeping its audio group. That
playlist is served from a small loopback HTTP server: Safari's native
HLS is backed by AVFoundation, which cannot read blob: or custom-scheme
URLs, so a Blob URL silently fails to play.
Settings closes with an icon button and its selects match the shared
control height.
Enable WKWebView's elementFullscreenEnabled at startup. It is off by
default in a Tauri window, which is why the native player had no
full-screen button and why right-click 'Enter Full Screen' did nothing.
With it on, WebKit's own control appears on the video and gives the real
system fullscreen player, so the app-level workaround is gone.
Restore window dragging: data-tauri-drag-region needs
core:window:allow-start-dragging, which was missing, leaving the
title-bar strip inert.
Download quality is now a resolution picker (best/2160/1440/1080/720/
480) instead of a two-way toggle; every selector still pins AAC audio
because Opus in MP4 is silent in WebKit.
Tile titles truncate to a single line so rows stay uniform, and the
player shows a spinner while the video buffers, not just while the
stream URL resolves.
Window chrome: titleBarStyle Overlay (Transparent left the native bar in
place, white in dark mode, with a dead strip beneath it). The app now
paints that strip itself, so it matches the page background.
Player: prev/next through the feed, a full-screen button, a spinner
while a stream resolves, Open on YouTube on downloaded videos too, and
the description collapsed behind a disclosure.
Downloads default to Best, which reaches real 4K — above 1080p YouTube
serves VP9/AV1, verified to play natively in WKWebView here. Audio stays
pinned to AAC because Opus in MP4 would be silent. A Compatible setting
keeps the old 1080p H.264 behaviour. Files are now named
'<ISO date> - <title> [<id>].mp4'.
Watch progress is recorded and drawn under thumbnails like YouTube's,
and reopening a video resumes where it left off.
Tiles clamp every text line to a fixed height so they share a baseline,
and the search field no longer clips its placeholder.
Fixes a Picture-in-Picture leak: WebKit kept a detached video playing
after the player closed, so a second video could play over the first
with no way to stop it. Every exit path now tears the element down.
Clicking an undownloaded video now plays it in the app instead of
handing off to the browser. YouTube's iframe embed cannot be used —
it rejects a tauri:// origin with Error 153 — so yt-dlp resolves
YouTube's HLS master playlist instead, whose H.264+AAC variants
AVFoundation streams natively in WKWebView, adaptive up to 1080p.
Adds an optional Tiles view alongside the list, remembered between
launches.
The title bar is now transparent with the title hidden, and the app
paints that strip itself in the page background so it matches instead
of showing macOS chrome beside the traffic lights.
Adds a seven-step in-app guide to exporting subscriptions from Google
Takeout, with the real URLs opened in the system browser.
Restyles the app onto the supplied design system: slate/sky palette,
9-15px type ladder, outline-first controls, tiered radii, borders for
separation and shadows only for elevation. Light and dark are both
designed, with a System/Light/Dark control and a pre-paint script so
the window does not flash light on a dark machine.
Importing now replaces the subscription list rather than merging, per
request. Because that can delete downloaded files, a confirmation
dialog names exactly what will go first.
ffmpeg only accepts -version (single dash); it exits 8 on --version and
writes to stderr, so the status panel reported it missing even when
installed. Version flags are now per-tool and both streams are read.
The player used max-h-full inside a grid, which a 1080x1920 Short
overflowed. Switched to absolute fill with object-contain so portrait
and landscape both letterbox correctly.
Also sized the window to 1180x780 centered, which fits a laptop display.
Adds an end-to-end integration test that runs the real pipeline against
live YouTube Atom feeds, verifying the merged feed is newest-first
across channels.
Refresh fetches 8 channel feeds concurrently and mirrors thumbnails to
disk so the offline feed still renders. Downloads are capped at 2 and
shell out to yt-dlp with an absolute binary path, since GUI apps do not
inherit a login shell PATH.
37 unit tests covering the three parsers and the database, including
that a feed refresh preserves download state and that a truncated
feed response fails rather than storing partial results.