Commit Graph
46 Commits
Author SHA1 Message Date
vincent 90f1eddb31 fix: the subtitle choice holds from one video to the next
Reproduced first: subtitles switched off on one video came back on at
the next one, and the same the other way.

The preference was applied once per set of tracks and then left alone.
That is not enough, because WebKit switches a newly added text track on
by itself, following the system's caption settings, and it does so
after the track has been added — after the one application had already
run. Nothing was watching, so its choice stood.

The decision is now held rather than applied: which track should be
showing is decided once per set of tracks, from the preference or from
a choice made in the menu, and re-asserted whenever a track's mode
changes behind us. Choosing from the menu records the decision first,
so holding enforces that choice instead of undoing it.

A cap on corrections, so that if something ever insists the two do not
sit there flipping a track at each other forever.

Verified in the running app both ways: off on 3/50 stayed off at 4/50,
English on at 4/50 stayed on at 5/50, and off survived a restart.
2026-09-03 23:19:35 +02:00
vincent c31bafa07a fix: the keyboard shortcuts the player has always advertised
The transport buttons have said "Play (space)" and "Full screen (f)"
since they were built, with nothing listening for either key. Pressing
f did nothing, which is exactly what a broken fullscreen button looks
like.

Both now work, verified in the running app: f enters and leaves
fullscreen, space plays and pauses. Space is prevented from also
pressing whichever button has focus, and neither fires while typing in
a field or alongside a modifier.

Escape now asks to leave fullscreen before the player considers
closing, so one keypress cannot drop you all the way back to the feed.

The button itself was never broken: it was checked on a downloaded
video, on a stream, and inside a window already in macOS fullscreen,
entering and leaving each time.
2026-09-03 20:52:31 +02:00
vincent dac159050c feat: auto mode, keeping the newest N videos on disk
A toggle beside Refresh. On, it keeps the newest videos of the feed
downloaded and deletes the rest, so the library follows the feed
instead of growing without bound. How many is the Download all number
in Settings, so there is one place that says how much disk this app
uses.

It runs after every check of the feed, and once at launch — otherwise
it would look asleep for the first ten minutes.

Turning it on asks first, and the question is concrete: it counts what
would be fetched and what would be deleted right now, before anything
happens. Turning it off does not ask, because stopping is harmless.

Two things it will not do. With Download all set to no limit it refuses
rather than fetching five hundred videos, and says which values work.
And a video saved by hand from the menu bar is left alone: its channel
is not a subscription, so it is not part of what auto mode manages, and
sweeping away something saved a minute ago would be a nasty surprise.
2026-09-03 20:23:38 +02:00
vincent b9e8a9db84 feat: play the next video automatically when one ends
A toggle under Playback in Settings, off by default and remembered.

Verified offline with forced offline mode on: a downloaded video ran to
its end and the next one started by itself, 6/51 to 7/51, with no
network.

It follows the same list the Next button does, which already steps to
the next *playable* video — offline that is the next download, so it
plays through what is on the Mac one after another. Online it will
stream the next video, and the Settings text says so: a toggle that
quietly does nothing outside one hidden condition is how the subtitle
preference went wrong three times.

The last video in the list simply stops; there is nowhere to go and
Next is already absent there.
2026-09-01 18:57:07 +02:00
vincent ad29f57e65 build: stop producing a disk image
"targets": "all" built a 90MB DMG on every single build, for nobody:
this is installed to /Applications by copying the .app. Building only
the app also shortens each build.

A full rebuild from an empty target directory takes 78 seconds, so the
cache is worth clearing when it grows rather than hoarding.
2026-08-29 22:07:59 +02:00
vincent 832a8f0f26 fix: the menu bar could never read a browser's address
Tested end to end from the menu bar: a video playing in Arc was saved,
its channel subscribed to, and the subscription removed again.

Three things were wrong.

One script named all seven browsers. AppleScript resolves an
application's terminology when it compiles, so naming a browser that is
not installed is a compile error — which no `try` can catch, and which
kills the whole script before a line of it runs. On a Mac without Brave
the lookup failed outright and Arc, first in the list and working, was
never asked. Each browser now gets its own script.

The tray icon was dropped as soon as it was built. TrayIcon is
reference-counted and "the icon is removed when the last instance is
dropped", so it was created and destroyed in the same breath. It is
held for the life of the app.

Subscribing to a channel already present as a bare row — the parent of
a video saved from the menu bar — was refused as a duplicate, and the
upsert would not have promoted it anyway. Saving a video from a channel
therefore made it impossible to subscribe to. The check asks whether it
is a subscription, not whether the row exists, and subscribing promotes.

Feedback no longer depends on one channel that can fail silently: a
notification from the app, the AppleScript one behind it, and the
menu's own first line, which reports the last result and cannot be
suppressed. A log beside the database records each step, which is how
all three of these were found rather than guessed at.
2026-08-29 20:27:52 +02:00
vincent 1790453a47 feat: manage subscriptions in the app, and act from the menu bar
Takeout is still the way a subscription list arrives. These are the
things it cannot do.

Hovering a channel in the sidebar reveals a delete, sitting where the
count is — the least useful thing on that row at the moment you reach
for it. It asks first, and says what goes: the channel, its videos, and
how many downloaded files will be deleted. It also says plainly that
YouTube is not touched; only this app forgets the channel.

A + beside the cog adds one channel from any link — its page, its
@handle, or one of its videos. The channel id and name are read off the
page, which is the same pair the CSV carries, and the feed is fetched
straight away so it is not sitting there empty.

A menu bar item acts on whatever a browser is showing without leaving
it: save that video, or subscribe to its channel, while it plays on.
The address comes from whichever running browser has a YouTube page —
asked with AppleScript, guarded so no browser is launched to answer,
and reported honestly when macOS has not granted the permission yet.
Answers come back as notifications rather than by raising the window.

Saving a video is not subscribing to its channel, so a channel can now
exist as a parent row without being a subscription: out of the sidebar,
skipped by refreshes, and left alone when a CSV import replaces the
subscription list.

Also removes a stale duplicate of the file header that had been sitting
inside run()'s setup block. Items are legal inside a block, so it
compiled and nobody noticed.
2026-08-29 19:54:41 +02:00
vincent 87d96be178 feat: the channel name in the player is a tag that goes there
It read as a caption but behaves like a link, so it now looks like one:
a pill beside the position counter, outlined until hovered. Clicking it
closes the player and narrows the feed to that channel, clearing the
search box on the way — the same thing the channel name under a
thumbnail already does.
2026-08-29 17:57:07 +02:00
vincent ca93cc65ca feat: subtitle size, face and placement, set from the player
One fixed size cannot suit everyone — the default was too big, then
too small. The subtitle menu in the transport bar now carries an
Appearance section: Size (S/M/L/XL), Font (Sans/Serif/Mono) and Place
(Bottom/Raised/Top). Choices apply at once and are remembered across
videos and launches, like every other preference.

Size and face are written into a style element rather than the
stylesheet: ::cue takes no custom properties, so the values have to be
literal.

Placement is not a CSS property at all — WebVTT carries position on the
cue — so it is rewritten into the cues themselves and the track re-cut.
Every source is text now, including the sidecars beside older
downloads, so all three can be placed the same way.

The default lands at 85%, between WebKit's own size and the 58% that
replaced it.

Re-cutting a track exposed a bug in the guard that applies the
subtitle preference: it keyed on how many text tracks there were, and
re-cutting one leaves that unchanged, so the fresh track would have
stayed disabled and the subtitles would have vanished on any change of
Place. It keys on the tracks themselves.
2026-08-29 17:45:19 +02:00
vincent 8dade59bb4 fix: subtitles centred and quieter
The captions were pinned to one side and set for a television across a
room. Verified fixed in the running app: centred at the bottom of the
picture, smaller, on a translucent ground instead of solid black.

The position was not in the file — the embedded track extracts to
WebVTT with no cue settings at all. It was the element's in-band
rendering: WebKit hands a container's own subtitle stream to the media
pipeline, which places it wherever the container's text box points and
which no CSS can reach.

So the player reads the stream out of the file with ffmpeg — about
40ms, no re-encoding — and attaches it as an ordinary track. The
download stays one self-contained file; the cues become ours to place
and style. The file's own track is filtered out of the menu and left
disabled, or the same captions would be offered twice and the in-band
copy would render underneath.

Cue styling: 58% of WebKit's default size, on slate at 55% rather than
opaque black.
2026-08-29 17:35:09 +02:00
vincent 2121f3e475 feat: embedded subtitles, resumed downloads, subtitle-only track menu
Verified in the running app and against the files on disk.

Subtitles are muxed into the download as a soft SubRip track rather
than written beside it. Without --write-subs, yt-dlp fetches them,
embeds them and removes the WebVTT, so a download is one file — checked
with ffprobe: video, audio, one mov_text track, no sidecar. WebKit
exposes it, so the player lists and shows it with no fetch at all,
which also means it works offline.

They are converted to SubRip on the way in. YouTube's WebVTT pins every
cue to the left edge and fills it with karaoke timing tags; the first
embed carried both and rendered clamped to the side of the picture.
SubRip carries neither. Only the plain language is embedded — "en" and
"en-orig" are usually the same captions, and nothing can collapse
duplicates once they are muxed in.

Downloads left unfinished when the app closes are picked up on the next
launch, at the quality and language they were asked for, which the
downloads table now records. Verified by leaving a row "running" with
no process behind it and restarting: it downloaded and completed.

The player's track menu is subtitles only — the audio renditions are
gone, the original-audio default still being fixed in the manifest —
and tracks read "English" rather than "en".

Turning subtitles off in one video now sticks for the next. The guard
that applies the preference keyed on the number of text tracks alone,
which does not change from one video to the next, so a choice of Off
was never re-applied; it keys on the video as well.

Embedding no longer depends on the on/off preference, the same trap
that kept fetching switched off: a file downloaded without a subtitle
track can never gain one offline.

Delete all is a trash icon.
2026-08-29 17:12:21 +02:00
vincent 62110b28fc fix: subtitles no longer depend on the Settings preference
Verified in the running app: captions render on a downloaded video and
on a stream, and the player's menu lists them under SUBTITLES beside
AUDIO.

Three things were wrong.

The preference decided whether subtitles were fetched at all, so "Off"
— the old default, which nobody had to choose — silently emptied the
subtitle menu as well. Fetching now happens regardless; the preference
only says which language switches itself on.

Choosing a track from the menu moved the preference from "off" to that
language, which re-ran the fetch, tore down the tracks and stalled the
stream mid-play. The fetch keys on the language it would ask for, not
on the preference, and that value does not change when "off" becomes
the language it already stood for.

The preference was re-applied on every poll of the track list, so a
choice made in the menu was undone a second later. It now applies once
per set of tracks, which also makes "Off" mean off.

Anyone still carrying the old "off" default is moved to English once.
An Off chosen deliberately after this is left alone.
2026-08-29 16:46:34 +02:00
vincent 967bcdde93 feat: stop every download at once
A stop button appears beside Download all whenever anything is
downloading or waiting to, and disappears when nothing is. It kills the
running processes, marks everything the database still calls queued or
running as cancelled, and clears the part files. Downloads already
finished are untouched.

That also covers two cases a per-video Cancel cannot reach: a download
parked on a slot, which stands down when its turn comes, and a row left
"running" by a crash that no process backs any more.

Cancelling no longer reports itself as a failure. download_video
returned Err("Download was cancelled.") when it found its child gone,
which the caller turned into an error dialog — pressing Stop all would
have raised one per download. Stopping something is a normal outcome:
the state is already recorded and the event already sent, so it returns
cleanly.
2026-08-29 16:31:41 +02:00
vincent 5b2be50852 feat: subtitles while streaming, and fetched when a download lacks them
YouTube's HLS manifest carries a dozen audio renditions and no
subtitles whatsoever — checked against a live master playlist: 16
EXT-X-MEDIA entries, all TYPE=AUDIO, zero SUBTITLES. So the track menu
could only ever list audio while streaming, which is what it did.

Subtitles are now fetched separately with yt-dlp, tidied through the
existing VTT cleanup, and cached per video and language. They reach the
player as blob URLs, which share the document's origin — a file:// or
127.0.0.1 track would be cross-origin to the page and need CORS the
media pipeline cannot supply. The same path fills in a download saved
before subtitles were switched on, without fetching the video again.

YouTube serves identical auto-generated captions under both "en" and
"en-orig", so byte-identical texts collapse to one entry rather than
offering the same track twice, and tracks are labelled "English"
rather than "en".

The subtitle preference now defaults to English. "None" is a poor
default for a setting whose whole purpose is captions: it silently
means no subtitles are downloaded, fetched, or offered anywhere, and
the Settings text now says so.
2026-08-29 16:25:26 +02:00
vincent 66cb80a044 fix: a refresh no longer drags you off a channel; cap Download all
The auto-refresh could throw you back to All subscriptions and then
refuse to let you back in. useFeed rebuilt reload() whenever the filter
changed, so a refresh started before you picked a channel still held
the old closure: half a minute later it re-fetched the unfiltered list
and overwrote yours. The sidebar still showed the channel as selected,
so clicking it changed nothing and there was no way back except via
another channel.

reload() now reads the filter at call time instead of capturing it, and
carries a sequence number so a slow reply cannot overwrite a newer one.
It is stable across filter changes, which also stops the auto-refresh
interval being torn down and restarted every time you click a channel.

Download all now works from All subscriptions too, capped by a new
Settings limit (default 25, up to no limit). It takes the newest first
and says what it did — "Queued the newest 25 of 500" — so a cap is
never silent. Press it again for the next batch.
2026-08-29 16:13:44 +02:00
vincent caea8bad8f feat: jump to a channel by name, and queue the whole channel
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.
2026-08-29 16:01:16 +02:00
vincent dc1efccf87 feat: icon view toggle, failing filter, quality badges
List and Tiles are now icons rather than words, centred in their
buttons — Segmented takes a ReactNode label and an optional title.

The red "N failing" count in the sidebar is a button: click it to see
only the channels that failed to refresh, click again for all of them.
It clears itself once a refresh fixes everything, so the filter can
never strand you on an empty list.

The player states its quality as a badge instead of loose grey text,
and a downloaded video now says so too — "Downloaded · 1440p" — read
from the file itself. Quality is the short side, so a portrait Short
reads 1080p rather than 1920p, and it resets between videos so one
never inherits the previous one's number.
2026-08-29 15:47:32 +02:00
vincent 48acaa359f feat: channel failures, updater, and a tidier Settings
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.
2026-08-29 15:17:15 +02:00
vincent 923fd3273f fix: a failed subtitle no longer destroys the download
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.
2026-08-29 14:29:14 +02:00
vincent 22c7a3a5ec feat: sign in to YouTube with browser cookies
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.
2026-08-29 14:21:35 +02:00
vincent 7aae080e46 feat: durations, clean subtitles, icon buttons
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.
2026-08-29 14:07:17 +02:00
vincent e080715f3b fix: search field never narrower than its own placeholder
The field could shrink to 160px and clip 'Search videos and channels'.
Its floor is now 17rem, wide enough for the full placeholder, with a
roomier max so it uses spare width.

The window minimum rises to 1260 to keep the control row on one line at
that floor, and the default size moves to 1320x820 — it was below the
new minimum, so every launch was being silently clamped.
2026-08-29 13:39:47 +02:00
vincent cc3a0b9c22 fix: top bar never wraps, and warn about 4K AV1 playback
The control row wrapped to a second line as the window narrowed. It is
now flex-nowrap with only the search field giving up width, and the
window minimum is raised to 1140x620 so the fixed controls always fit
beside the sidebar on one line.

Also lands the 4K AV1 warning in Settings. The earlier attempt at this
silently changed nothing — the replacement text did not match the file,
so the copy shipped unchanged while being reported as done.
2026-08-29 13:34:23 +02:00
vincent 557467baad feat: subtitles, delete-all, auto refresh, and menu cleanup
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.
2026-08-29 13:25:17 +02:00
vincent 73f12cfac1 fix: default to original audio, and pick tracks from the transport bar
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.
2026-08-29 12:54:05 +02:00
vincent e556534459 fix: unbroken divider between sidebar and feed
The title bar was one band across the top of the window, so the
sidebar's right border only began below it and the pane read as
detached from the top of the window.

Each pane now reserves its own strip for the traffic lights instead.
The border runs from the very top, and because both insets are the same
height the sidebar header and the top bar share a baseline, so their
bottom borders form one continuous line across the window. Both strips
stay draggable, and the inset collapses in window fullscreen where
there are no traffic lights to clear.
2026-08-29 12:39:53 +02:00
vincent 9e9f71b31b perf: run yt-dlp from a zipapp on a portable Python
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.
2026-08-29 12:07:09 +02:00
vincent 50ad908f1e perf: cache prereq versions and resolved stream URLs
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.
2026-08-29 11:52:33 +02:00
vincent 3296b5436a feat: bundle yt-dlp and ffmpeg as sidecars
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.
2026-08-29 11:41:14 +02:00
vincent 9bb7b71225 feat: uniform control height, chrome auto-hide, streaming quality
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.
2026-08-29 11:28:05 +02:00
vincent 211823f265 feat: custom transport bar, live refresh, collapsible sidebar
Replace WebKit's native video controls with our own. WebKit puts
fullscreen, Picture-in-Picture and volume as overlay buttons in the
video's top corners with no way to move them; owning the bar is the only
way to get every control into one strip along the bottom. Right-click on
the player is suppressed — its menu acted on a video the app does not
control.

One spinner now covers both waits: resolving the stream URL and
buffering it. It no longer sticks after a resume-seek, which fires
'waiting' after 'playing'.

Also: Open on YouTube and a new Download are real buttons in the player;
refresh is a compact icon; Settings is a cog; the subscription sidebar
collapses and reappears on a left-edge hover; the feed auto-refreshes
every 10 minutes while online, skipped while the player is open; the
title-bar strip takes the panel colour, since it sits above panels
rather than the page; and downloaded-only, hide-Shorts and the sidebar
state are remembered between launches.
2026-08-29 11:03:29 +02:00
vincent 61db10b2c8 fix: native video fullscreen, window dragging, quality picker, spinner
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.
2026-08-29 10:38:35 +02:00
vincent 359873cff1 fix: real video fullscreen, and edge navigation arrows
The full-screen button did nothing: WKWebView inside a Tauri window has
element fullscreen disabled outright, so both requestFullscreen() and
WebKit's webkitEnterFullscreen() are inert, and Tauri exposes no switch
for it. Fullscreening the window alone is not the same thing either —
the player's header and footer stay on screen around the picture. The
button now fullscreens the window and hides every piece of chrome, so
the video alone fills the display, with Esc and a hover control to exit.

Prev/next also get large arrows on the left and right edges of the
picture, fading in on hover, alongside the header buttons.
2026-08-29 10:19:09 +02:00
vincent 5b09acd28d feat: 4K downloads, watch progress, player controls, new icon
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.
2026-08-29 04:03:45 +02:00
vincent d0bad64d7c fix: clear stale download overlay after delete
Deleting a download removed the database row but left its entry in the
live-progress map, so the row kept showing Saved and an Offline badge
until the app restarted.
2026-08-29 03:27:20 +02:00
vincent d976d111bf docs: note in-app streaming and tile view 2026-08-29 03:24:40 +02:00
vincent f47bc3dab1 feat: tile view, in-app streaming, and self-painted title bar
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.
2026-08-29 03:24:24 +02:00
vincent ccb25fad33 docs: update README for restyle, Takeout guide, and replacing import 2026-08-29 03:01:32 +02:00
vincent cac6727072 feat: Takeout walkthrough, design-system restyle, replacing import
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.
2026-08-29 03:00:54 +02:00
vincent 11331671c5 fix: ffmpeg version detection and player letterboxing
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.
2026-08-29 02:44:39 +02:00
vincent e75d896933 feat: feed UI, in-app player, offline mode, settings
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.
2026-08-29 02:34:14 +02:00
vincent a30423e4b5 feat: Tauri commands, concurrent refresh, download manager
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.
2026-08-29 02:28:26 +02:00
vincent b724e9d56f feat: Takeout CSV, Atom feed, yt-dlp progress, and SQLite layers
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.
2026-08-29 02:26:17 +02:00
vincent 6fdad60190 feat: scaffold Tauri + React + Tailwind v4 2026-08-29 02:21:25 +02:00
vincent 4c877fd7f9 Add FlightTube implementation plan 2026-08-29 02:19:23 +02:00
vincent 69dab761f1 Add FlightTube design spec
Offline-first YouTube subscription feed reader: Takeout CSV import,
public Atom feeds for metadata, yt-dlp downloads constrained to
H.264/AAC for in-app WKWebView playback.
2026-08-29 02:16:47 +02:00