Compare commits

...
13 Commits
Author SHA1 Message Date
Vincent Rozenberg 22e31afc1d feat: remove every subscription from Settings, and reopen the last video
Remove all sits beside Import in Settings, since emptying the list is
the other half of replacing it. It asks first and counts what goes:
210 channels, 3339 videos, 50 downloaded files, in those words. Nothing
changes on YouTube, and the confirmation says so — importing or reading
the list again brings it all back.

It takes the same path an import does when nothing survives, so a video
saved on its own from the menu bar is left alone: its channel was never
a subscription, and this is about subscriptions.

The app also reopens whatever was playing when it last closed, if that
video is still in the feed and still playable — offline, one that was
streaming is not. The note is made while the player is open and dropped
when it is closed, so a restart only reopens something you were in the
middle of, never something you had deliberately finished with. Verified
both ways: quitting mid-video came back to it, closing the player first
came back to the feed.

The panel's Remove all downloads is gone; it was a misreading of the
request.
2026-09-04 02:03:46 +02:00
Vincent Rozenberg 35f14ab044 feat: fullscreen carries across videos, and stays out of the way
In fullscreen, Next now keeps you there and shows nothing but the
picture: one video after another, no arrows, no header, no counter.

Three things had to change for that. The player was keyed on the video,
so every Next rebuilt the stage — and the stage is what is fullscreen.
It now survives, resetting its own per-video state instead of being
thrown away to get it.

The cleanup that ran on every change of source called exitFullscreen
outright. It exists for the Picture-in-Picture leak, which does need
handling per video; leaving fullscreen and dropping the source belong
to leaving the player, and are now only done there.

And resolving a stream set the source to nothing first, which unmounted
the element mid-fullscreen. The outgoing video is paused in place
instead, and the source goes straight from one address to the next.

The edge arrows are gone in fullscreen. The transport bar stays — it is
playback, not navigation — and fades on its own as before.

Also: Remove all downloads in the menu bar panel, which asks in the
window rather than in a panel that closes when you look away; and the
JavaScript round-trip logging is gone now that the scrape is settled.

Verified in the running app: three videos in a row without leaving
fullscreen, chrome faded to nothing, and Remove all reaching the
window's own "Delete every download?" with 50 downloads left untouched.
2026-09-04 01:56:08 +02:00
Vincent Rozenberg 41d638b232 feat: read the subscription list off YouTube, and an app-shaped menu bar panel
Takeout is a snapshot you have to go and fetch. This reads the live list
from youtube.com/feed/channels in a browser already signed in.

It runs JavaScript in the page rather than fetching it here, because
the session cannot be borrowed: Chromium encrypts its cookie store with
a per-app Keychain key, and Arc's cookies read with Chrome's key came
back undecryptable — 1346 of 2196, the session cookies among them. The
list is also lazily loaded, so it has to be scrolled to the end, which
only the page can do. Measured against a hand-scrolled count: 208 both
ways.

The browser is brought to the front first. A background tab has its DOM
discarded, so it reports its address while having nothing on it, which
reads exactly like an empty subscription list.

The page gives handles, not channel ids. Most belong to channels already
known here and are matched by name, costing nothing; only the rest are
looked up, four at a time. That mattered more than it sounds: with the
name selector wrong every name came back empty, nothing matched, all 208
were looked up blind, and the result claimed 58 channels should be
deleted. With names read correctly it is 4 lookups and 0 deletions.
Names arriving doubled — "3D OCD 3D OCD", which reads fine and matches
nothing — are collapsed.

osascript prints a string result in source form, quoted with the
newlines escaped, so a list of rows arrived as one line that looked
like no rows at all. It is unquoted before parsing.

The menu bar item is now a window, not an NSMenu: the app's own type,
spacing and colours, hanging from the icon and closing on blur. Adding a
channel is gone from it — the sidebar already does that.

Replacing the subscription list is confirmed in the main window, never
in a panel that closes when you look away, and the confirmation counts
what would go before anything happens.

Verified end to end: 208 read, 4 looked up, 0 removed, 48 downloads
untouched.
2026-09-04 01:34:29 +02:00
vincent 5cc3612b5f chore: ignore python artefacts and local-only settings 2026-09-04 00:29:41 +02:00
vincent c2206c8811 feat: removing a channel opens it on YouTube to unsubscribe
Removing a subscription here only ever taught this app to forget it,
which leaves the job half done. It now opens the channel on YouTube
straight afterwards.

YouTube has no address that unsubscribes on its own — sub_confirmation
only goes the other way — so this opens the channel page, where
Subscribed is one click from Unsubscribe. The confirmation says so
before anything happens, and says that nothing changes on YouTube until
that click.
2026-09-03 23:53:30 +02:00
vincent 14dab91334 feat: the player's title opens the description
The title is the link now, and looks like nothing: it keeps its colour
and carries no underline, since dressing it up would compete with the
video for attention. The pointer and the tooltip say the rest.

Views and age move onto that line, in the same small quiet grey, and
the "› Description" disclosure underneath is gone — one line instead of
two, and the description gets a proper modal with room to read it.

Addresses in a description are plain text as YouTube stores them; they
are found and made clickable, and open in the real browser. Trailing
punctuation is trimmed from the target: a full stop ends the sentence,
not the address.

Verified in the running app on a description with three consecutive
affiliate links, all three of which came out as links — the test for
one uses startsWith rather than a global regex, whose lastIndex carries
between calls and would have left every other address as plain text.
2026-09-03 23:47:27 +02:00
vincent ddad42593c feat: YouTube's keyboard shortcuts in the player
This is a video player, so the keys fingers already know should work.

  space / k   play, pause
  j / l       ten seconds back, forward
  ← / →       five seconds
  ↑ / ↓       volume
  0–9         jump to that tenth of the video
  Home / End  the ends
  m           mute
  f           fullscreen
  i           Picture in Picture, YouTube's miniplayer
  c           subtitles on or off
  , / .       one frame back or forward, while paused
  shift , / . slower, faster
  shift N / P next, previous video
  Escape      leave fullscreen, then close the player

Speed gets a chip in the transport bar when it is not 1×, since nothing
else there would say so, and clicking it goes back to normal. The other
buttons now name their key in the tooltip.

Arrow keys are taken from a focused slider, which would otherwise seek
by a tenth of a second rather than five. Typing in a field is left
alone, as are the system's own modifier combinations. A frame is taken
as a thirtieth of a second, since a video element will not say what its
frame rate is.

Verified in the running app: 5 jumped to exactly half, j seeked, m
muted, shift-. reached 1.5×, c turned subtitles off and on again, and
shift+N moved to the next video.
2026-09-03 23:29:25 +02:00
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
22 changed files with 2376 additions and 276 deletions
+9
View File
@@ -32,3 +32,12 @@ dist-ssr
src-tauri/binaries/ src-tauri/binaries/
src-tauri/resources/python/ src-tauri/resources/python/
src-tauri/resources/yt-dlp.pyz src-tauri/resources/yt-dlp.pyz
# Python, for the helper scripts
venv/
.venv/
__pycache__/
*.py[cod]
# Local-only, not shared
.claude/settings.local.json
+124 -7
View File
@@ -510,7 +510,7 @@ checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"cpufeatures 0.3.1", "cpufeatures 0.3.1",
"rand_core", "rand_core 0.10.1",
] ]
[[package]] [[package]]
@@ -1156,6 +1156,7 @@ dependencies = [
"tauri", "tauri",
"tauri-build", "tauri-build",
"tauri-plugin-dialog", "tauri-plugin-dialog",
"tauri-plugin-notification",
"tauri-plugin-opener", "tauri-plugin-opener",
"tokio", "tokio",
] ]
@@ -1459,7 +1460,7 @@ dependencies = [
"js-sys", "js-sys",
"libc", "libc",
"r-efi 6.0.0", "r-efi 6.0.0",
"rand_core", "rand_core 0.10.1",
"wasm-bindgen", "wasm-bindgen",
] ]
@@ -2327,6 +2328,20 @@ version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
[[package]]
name = "mac-notification-sys"
version = "0.6.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fd604973958ddcc11b561193c0fb96ba146506ef2f231ef2e7c35fd2cbc9beca"
dependencies = [
"cc",
"log",
"objc2",
"objc2-foundation",
"time",
"uuid",
]
[[package]] [[package]]
name = "markup5ever" name = "markup5ever"
version = "0.38.0" version = "0.38.0"
@@ -2451,6 +2466,20 @@ version = "1.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
[[package]]
name = "notify-rust"
version = "4.18.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c5b4c1b4f2aa9f25f63a7a49d3dd0ed567b3670da15330a66b29434be899b891"
dependencies = [
"futures-lite",
"log",
"mac-notification-sys",
"serde",
"tauri-winrt-notification",
"zbus",
]
[[package]] [[package]]
name = "num-conv" name = "num-conv"
version = "0.2.2" version = "0.2.2"
@@ -2965,6 +2994,15 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
[[package]]
name = "ppv-lite86"
version = "0.2.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
dependencies = [
"zerocopy",
]
[[package]] [[package]]
name = "precomputed-hash" name = "precomputed-hash"
version = "0.1.1" version = "0.1.1"
@@ -3087,7 +3125,7 @@ dependencies = [
"bytes", "bytes",
"getrandom 0.4.3", "getrandom 0.4.3",
"lru-slab", "lru-slab",
"rand", "rand 0.10.2",
"rand_pcg", "rand_pcg",
"ring", "ring",
"rustc-hash", "rustc-hash",
@@ -3111,7 +3149,7 @@ dependencies = [
"once_cell", "once_cell",
"socket2", "socket2",
"tracing", "tracing",
"windows-sys 0.59.0", "windows-sys 0.61.2",
] ]
[[package]] [[package]]
@@ -3135,6 +3173,16 @@ version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
[[package]]
name = "rand"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41"
dependencies = [
"rand_chacha",
"rand_core 0.9.5",
]
[[package]] [[package]]
name = "rand" name = "rand"
version = "0.10.2" version = "0.10.2"
@@ -3143,7 +3191,26 @@ checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80"
dependencies = [ dependencies = [
"chacha20", "chacha20",
"getrandom 0.4.3", "getrandom 0.4.3",
"rand_core", "rand_core 0.10.1",
]
[[package]]
name = "rand_chacha"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
dependencies = [
"ppv-lite86",
"rand_core 0.9.5",
]
[[package]]
name = "rand_core"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
dependencies = [
"getrandom 0.3.4",
] ]
[[package]] [[package]]
@@ -3158,7 +3225,7 @@ version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a"
dependencies = [ dependencies = [
"rand_core", "rand_core 0.10.1",
] ]
[[package]] [[package]]
@@ -3424,7 +3491,7 @@ dependencies = [
"security-framework", "security-framework",
"security-framework-sys", "security-framework-sys",
"webpki-root-certs", "webpki-root-certs",
"windows-sys 0.59.0", "windows-sys 0.61.2",
] ]
[[package]] [[package]]
@@ -4228,6 +4295,25 @@ dependencies = [
"url", "url",
] ]
[[package]]
name = "tauri-plugin-notification"
version = "2.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "01fc2c5ff41105bd1f7242d8201fdf3efd70749b82fa013a17f2126357d194cc"
dependencies = [
"log",
"notify-rust",
"rand 0.9.5",
"serde",
"serde_json",
"serde_repr",
"tauri",
"tauri-plugin",
"thiserror 2.0.20",
"time",
"url",
]
[[package]] [[package]]
name = "tauri-plugin-opener" name = "tauri-plugin-opener"
version = "2.5.4" version = "2.5.4"
@@ -4350,6 +4436,17 @@ dependencies = [
"toml 1.1.4+spec-1.1.0", "toml 1.1.4+spec-1.1.0",
] ]
[[package]]
name = "tauri-winrt-notification"
version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ed071c670382e85fc2f48ae706492d8c338f4f89bf72520d32f8abfe880aade"
dependencies = [
"thiserror 2.0.20",
"windows",
"windows-version",
]
[[package]] [[package]]
name = "tempfile" name = "tempfile"
version = "3.27.0" version = "3.27.0"
@@ -5772,6 +5869,26 @@ dependencies = [
"serde", "serde",
] ]
[[package]]
name = "zerocopy"
version = "0.8.56"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "556764e583adb45a9f8d413c2a147fa7e8d821e48e12b14fd560b607998b75eb"
dependencies = [
"zerocopy-derive",
]
[[package]]
name = "zerocopy-derive"
version = "0.8.56"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2ab42fc20575779bd240faa45f94a74256f755c0fa9e89f0ede20d91d0cdfc1"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]] [[package]]
name = "zerofrom" name = "zerofrom"
version = "0.1.8" version = "0.1.8"
+2 -1
View File
@@ -18,7 +18,7 @@ crate-type = ["staticlib", "cdylib", "rlib"]
tauri-build = { version = "2", features = [] } tauri-build = { version = "2", features = [] }
[dependencies] [dependencies]
tauri = { version = "2", features = ["protocol-asset", "tray-icon", "image-png"] } tauri = { version = "2", features = ["protocol-asset", "tray-icon", "image-png", "macos-private-api"] }
tauri-plugin-opener = "2" tauri-plugin-opener = "2"
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
@@ -29,6 +29,7 @@ chrono = "0.4.45"
tokio = { version = "1.53.1", features = ["full"] } tokio = { version = "1.53.1", features = ["full"] }
futures = "0.3.34" futures = "0.3.34"
tauri-plugin-dialog = "2.7.2" tauri-plugin-dialog = "2.7.2"
tauri-plugin-notification = "2"
reqwest = { version = "0.13.4", default-features = false, features = ["rustls", "http2", "charset", "stream", "gzip"] } reqwest = { version = "0.13.4", default-features = false, features = ["rustls", "http2", "charset", "stream", "gzip"] }
[target.'cfg(target_os = "macos")'.dependencies] [target.'cfg(target_os = "macos")'.dependencies]
+8 -3
View File
@@ -1,15 +1,20 @@
{ {
"$schema": "../gen/schemas/desktop-schema.json", "$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default", "identifier": "default",
"description": "Capability for the main window", "description": "Capability for the main window and the menu bar panel",
"windows": [ "windows": [
"main" "main",
"tray"
], ],
"permissions": [ "permissions": [
"core:default", "core:default",
"opener:default", "opener:default",
"dialog:default", "dialog:default",
"core:window:allow-start-dragging", "core:window:allow-start-dragging",
"core:window:allow-is-fullscreen" "core:window:allow-is-fullscreen",
"notification:default",
"core:event:allow-emit-to",
"core:event:allow-emit",
"core:event:allow-listen"
] ]
} }
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -1 +1 @@
{"default":{"identifier":"default","description":"Capability for the main window","local":true,"windows":["main"],"permissions":["core:default","opener:default","dialog:default","core:window:allow-start-dragging","core:window:allow-is-fullscreen"]}} {"default":{"identifier":"default","description":"Capability for the main window and the menu bar panel","local":true,"windows":["main","tray"],"permissions":["core:default","opener:default","dialog:default","core:window:allow-start-dragging","core:window:allow-is-fullscreen","notification:default","core:event:allow-emit-to","core:event:allow-emit","core:event:allow-listen"]}}
+198
View File
@@ -2426,6 +2426,204 @@
"const": "dialog:deny-save", "const": "dialog:deny-save",
"markdownDescription": "Denies the save command without any pre-configured scope." "markdownDescription": "Denies the save command without any pre-configured scope."
}, },
{
"description": "This permission set configures which\nnotification features are by default exposed.\n\n#### Granted Permissions\n\nIt allows all notification related features.\n\n\n#### This default permission set includes:\n\n- `allow-is-permission-granted`\n- `allow-request-permission`\n- `allow-notify`\n- `allow-register-action-types`\n- `allow-register-listener`\n- `allow-cancel`\n- `allow-get-pending`\n- `allow-remove-active`\n- `allow-get-active`\n- `allow-check-permissions`\n- `allow-show`\n- `allow-batch`\n- `allow-list-channels`\n- `allow-delete-channel`\n- `allow-create-channel`\n- `allow-permission-state`",
"type": "string",
"const": "notification:default",
"markdownDescription": "This permission set configures which\nnotification features are by default exposed.\n\n#### Granted Permissions\n\nIt allows all notification related features.\n\n\n#### This default permission set includes:\n\n- `allow-is-permission-granted`\n- `allow-request-permission`\n- `allow-notify`\n- `allow-register-action-types`\n- `allow-register-listener`\n- `allow-cancel`\n- `allow-get-pending`\n- `allow-remove-active`\n- `allow-get-active`\n- `allow-check-permissions`\n- `allow-show`\n- `allow-batch`\n- `allow-list-channels`\n- `allow-delete-channel`\n- `allow-create-channel`\n- `allow-permission-state`"
},
{
"description": "Enables the batch command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-batch",
"markdownDescription": "Enables the batch command without any pre-configured scope."
},
{
"description": "Enables the cancel command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-cancel",
"markdownDescription": "Enables the cancel command without any pre-configured scope."
},
{
"description": "Enables the check_permissions command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-check-permissions",
"markdownDescription": "Enables the check_permissions command without any pre-configured scope."
},
{
"description": "Enables the create_channel command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-create-channel",
"markdownDescription": "Enables the create_channel command without any pre-configured scope."
},
{
"description": "Enables the delete_channel command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-delete-channel",
"markdownDescription": "Enables the delete_channel command without any pre-configured scope."
},
{
"description": "Enables the get_active command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-get-active",
"markdownDescription": "Enables the get_active command without any pre-configured scope."
},
{
"description": "Enables the get_pending command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-get-pending",
"markdownDescription": "Enables the get_pending command without any pre-configured scope."
},
{
"description": "Enables the is_permission_granted command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-is-permission-granted",
"markdownDescription": "Enables the is_permission_granted command without any pre-configured scope."
},
{
"description": "Enables the list_channels command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-list-channels",
"markdownDescription": "Enables the list_channels command without any pre-configured scope."
},
{
"description": "Enables the notify command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-notify",
"markdownDescription": "Enables the notify command without any pre-configured scope."
},
{
"description": "Enables the permission_state command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-permission-state",
"markdownDescription": "Enables the permission_state command without any pre-configured scope."
},
{
"description": "Enables the register_action_types command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-register-action-types",
"markdownDescription": "Enables the register_action_types command without any pre-configured scope."
},
{
"description": "Enables the register_listener command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-register-listener",
"markdownDescription": "Enables the register_listener command without any pre-configured scope."
},
{
"description": "Enables the remove_active command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-remove-active",
"markdownDescription": "Enables the remove_active command without any pre-configured scope."
},
{
"description": "Enables the request_permission command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-request-permission",
"markdownDescription": "Enables the request_permission command without any pre-configured scope."
},
{
"description": "Enables the show command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-show",
"markdownDescription": "Enables the show command without any pre-configured scope."
},
{
"description": "Denies the batch command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-batch",
"markdownDescription": "Denies the batch command without any pre-configured scope."
},
{
"description": "Denies the cancel command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-cancel",
"markdownDescription": "Denies the cancel command without any pre-configured scope."
},
{
"description": "Denies the check_permissions command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-check-permissions",
"markdownDescription": "Denies the check_permissions command without any pre-configured scope."
},
{
"description": "Denies the create_channel command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-create-channel",
"markdownDescription": "Denies the create_channel command without any pre-configured scope."
},
{
"description": "Denies the delete_channel command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-delete-channel",
"markdownDescription": "Denies the delete_channel command without any pre-configured scope."
},
{
"description": "Denies the get_active command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-get-active",
"markdownDescription": "Denies the get_active command without any pre-configured scope."
},
{
"description": "Denies the get_pending command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-get-pending",
"markdownDescription": "Denies the get_pending command without any pre-configured scope."
},
{
"description": "Denies the is_permission_granted command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-is-permission-granted",
"markdownDescription": "Denies the is_permission_granted command without any pre-configured scope."
},
{
"description": "Denies the list_channels command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-list-channels",
"markdownDescription": "Denies the list_channels command without any pre-configured scope."
},
{
"description": "Denies the notify command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-notify",
"markdownDescription": "Denies the notify command without any pre-configured scope."
},
{
"description": "Denies the permission_state command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-permission-state",
"markdownDescription": "Denies the permission_state command without any pre-configured scope."
},
{
"description": "Denies the register_action_types command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-register-action-types",
"markdownDescription": "Denies the register_action_types command without any pre-configured scope."
},
{
"description": "Denies the register_listener command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-register-listener",
"markdownDescription": "Denies the register_listener command without any pre-configured scope."
},
{
"description": "Denies the remove_active command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-remove-active",
"markdownDescription": "Denies the remove_active command without any pre-configured scope."
},
{
"description": "Denies the request_permission command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-request-permission",
"markdownDescription": "Denies the request_permission command without any pre-configured scope."
},
{
"description": "Denies the show command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-show",
"markdownDescription": "Denies the show command without any pre-configured scope."
},
{ {
"description": "This permission set allows opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application\nas well as reveal file in directories using default file explorer\n#### This default permission set includes:\n\n- `allow-open-url`\n- `allow-reveal-item-in-dir`\n- `allow-default-urls`", "description": "This permission set allows opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application\nas well as reveal file in directories using default file explorer\n#### This default permission set includes:\n\n- `allow-open-url`\n- `allow-reveal-item-in-dir`\n- `allow-default-urls`",
"type": "string", "type": "string",
+198
View File
@@ -2426,6 +2426,204 @@
"const": "dialog:deny-save", "const": "dialog:deny-save",
"markdownDescription": "Denies the save command without any pre-configured scope." "markdownDescription": "Denies the save command without any pre-configured scope."
}, },
{
"description": "This permission set configures which\nnotification features are by default exposed.\n\n#### Granted Permissions\n\nIt allows all notification related features.\n\n\n#### This default permission set includes:\n\n- `allow-is-permission-granted`\n- `allow-request-permission`\n- `allow-notify`\n- `allow-register-action-types`\n- `allow-register-listener`\n- `allow-cancel`\n- `allow-get-pending`\n- `allow-remove-active`\n- `allow-get-active`\n- `allow-check-permissions`\n- `allow-show`\n- `allow-batch`\n- `allow-list-channels`\n- `allow-delete-channel`\n- `allow-create-channel`\n- `allow-permission-state`",
"type": "string",
"const": "notification:default",
"markdownDescription": "This permission set configures which\nnotification features are by default exposed.\n\n#### Granted Permissions\n\nIt allows all notification related features.\n\n\n#### This default permission set includes:\n\n- `allow-is-permission-granted`\n- `allow-request-permission`\n- `allow-notify`\n- `allow-register-action-types`\n- `allow-register-listener`\n- `allow-cancel`\n- `allow-get-pending`\n- `allow-remove-active`\n- `allow-get-active`\n- `allow-check-permissions`\n- `allow-show`\n- `allow-batch`\n- `allow-list-channels`\n- `allow-delete-channel`\n- `allow-create-channel`\n- `allow-permission-state`"
},
{
"description": "Enables the batch command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-batch",
"markdownDescription": "Enables the batch command without any pre-configured scope."
},
{
"description": "Enables the cancel command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-cancel",
"markdownDescription": "Enables the cancel command without any pre-configured scope."
},
{
"description": "Enables the check_permissions command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-check-permissions",
"markdownDescription": "Enables the check_permissions command without any pre-configured scope."
},
{
"description": "Enables the create_channel command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-create-channel",
"markdownDescription": "Enables the create_channel command without any pre-configured scope."
},
{
"description": "Enables the delete_channel command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-delete-channel",
"markdownDescription": "Enables the delete_channel command without any pre-configured scope."
},
{
"description": "Enables the get_active command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-get-active",
"markdownDescription": "Enables the get_active command without any pre-configured scope."
},
{
"description": "Enables the get_pending command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-get-pending",
"markdownDescription": "Enables the get_pending command without any pre-configured scope."
},
{
"description": "Enables the is_permission_granted command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-is-permission-granted",
"markdownDescription": "Enables the is_permission_granted command without any pre-configured scope."
},
{
"description": "Enables the list_channels command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-list-channels",
"markdownDescription": "Enables the list_channels command without any pre-configured scope."
},
{
"description": "Enables the notify command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-notify",
"markdownDescription": "Enables the notify command without any pre-configured scope."
},
{
"description": "Enables the permission_state command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-permission-state",
"markdownDescription": "Enables the permission_state command without any pre-configured scope."
},
{
"description": "Enables the register_action_types command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-register-action-types",
"markdownDescription": "Enables the register_action_types command without any pre-configured scope."
},
{
"description": "Enables the register_listener command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-register-listener",
"markdownDescription": "Enables the register_listener command without any pre-configured scope."
},
{
"description": "Enables the remove_active command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-remove-active",
"markdownDescription": "Enables the remove_active command without any pre-configured scope."
},
{
"description": "Enables the request_permission command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-request-permission",
"markdownDescription": "Enables the request_permission command without any pre-configured scope."
},
{
"description": "Enables the show command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-show",
"markdownDescription": "Enables the show command without any pre-configured scope."
},
{
"description": "Denies the batch command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-batch",
"markdownDescription": "Denies the batch command without any pre-configured scope."
},
{
"description": "Denies the cancel command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-cancel",
"markdownDescription": "Denies the cancel command without any pre-configured scope."
},
{
"description": "Denies the check_permissions command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-check-permissions",
"markdownDescription": "Denies the check_permissions command without any pre-configured scope."
},
{
"description": "Denies the create_channel command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-create-channel",
"markdownDescription": "Denies the create_channel command without any pre-configured scope."
},
{
"description": "Denies the delete_channel command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-delete-channel",
"markdownDescription": "Denies the delete_channel command without any pre-configured scope."
},
{
"description": "Denies the get_active command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-get-active",
"markdownDescription": "Denies the get_active command without any pre-configured scope."
},
{
"description": "Denies the get_pending command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-get-pending",
"markdownDescription": "Denies the get_pending command without any pre-configured scope."
},
{
"description": "Denies the is_permission_granted command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-is-permission-granted",
"markdownDescription": "Denies the is_permission_granted command without any pre-configured scope."
},
{
"description": "Denies the list_channels command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-list-channels",
"markdownDescription": "Denies the list_channels command without any pre-configured scope."
},
{
"description": "Denies the notify command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-notify",
"markdownDescription": "Denies the notify command without any pre-configured scope."
},
{
"description": "Denies the permission_state command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-permission-state",
"markdownDescription": "Denies the permission_state command without any pre-configured scope."
},
{
"description": "Denies the register_action_types command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-register-action-types",
"markdownDescription": "Denies the register_action_types command without any pre-configured scope."
},
{
"description": "Denies the register_listener command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-register-listener",
"markdownDescription": "Denies the register_listener command without any pre-configured scope."
},
{
"description": "Denies the remove_active command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-remove-active",
"markdownDescription": "Denies the remove_active command without any pre-configured scope."
},
{
"description": "Denies the request_permission command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-request-permission",
"markdownDescription": "Denies the request_permission command without any pre-configured scope."
},
{
"description": "Denies the show command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-show",
"markdownDescription": "Denies the show command without any pre-configured scope."
},
{ {
"description": "This permission set allows opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application\nas well as reveal file in directories using default file explorer\n#### This default permission set includes:\n\n- `allow-open-url`\n- `allow-reveal-item-in-dir`\n- `allow-default-urls`", "description": "This permission set allows opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application\nas well as reveal file in directories using default file explorer\n#### This default permission set includes:\n\n- `allow-open-url`\n- `allow-reveal-item-in-dir`\n- `allow-default-urls`",
"type": "string", "type": "string",
+149 -1
View File
@@ -10,6 +10,7 @@ use crate::models::{
use crate::net; use crate::net;
use crate::playlist_server::PlaylistServer; use crate::playlist_server::PlaylistServer;
use crate::resolve; use crate::resolve;
use crate::subscriptions;
use crate::takeout; use crate::takeout;
use crate::thumbs; use crate::thumbs;
@@ -1613,6 +1614,153 @@ pub async fn set_download_defaults(
Ok(()) Ok(())
} }
/// Turns a scraped list into channels, and says what it could not resolve.
#[derive(Serialize)]
pub struct ScrapeResult {
pub channels: Vec<Channel>,
/// Names whose channel could not be identified, so the user knows the list
/// handed on is short rather than wondering later.
pub unresolved: Vec<String>,
pub looked_up: usize,
}
/// Reads the subscription list out of the browser and resolves it to channels.
///
/// The page gives handles, not channel ids. Most of them belong to channels
/// already known here, and those are matched by name — no request at all.
/// Only genuinely new ones are looked up, a few at a time, because a burst of
/// two hundred requests is how you earn an HTTP 429.
#[tauri::command]
pub async fn scrape_subscriptions(
app: AppHandle,
state: State<'_, AppState>,
) -> Result<ScrapeResult, String> {
let rows = crate::tray::scrape_subscriptions(&app).await?;
let known: HashMap<String, ChannelWithCount> = state
.db
.lock()
.await
.list_channels()?
.into_iter()
.map(|c| (subscriptions::name_key(&c.title), c))
.collect();
let mut channels: Vec<Channel> = Vec::new();
let mut to_look_up: Vec<(String, String)> = Vec::new();
for (href, name) in rows {
// A link that already carries the id costs nothing.
if let Some(id) = subscriptions::id_from_href(&href) {
channels.push(Channel { url: resolve::channel_url(&id), id, title: name });
continue;
}
match known.get(&subscriptions::name_key(&name)) {
Some(c) if !name.is_empty() => channels.push(Channel {
id: c.id.clone(),
title: name,
url: c.url.clone(),
}),
_ => to_look_up.push((href, name)),
}
}
let looked_up = to_look_up.len();
let mut unresolved = Vec::new();
// Four at a time: enough to finish a normal import quickly, gentle enough
// that YouTube does not start refusing.
let found = futures::stream::iter(to_look_up.into_iter().map(|(href, name)| {
let http = state.http.clone();
async move {
let url = subscriptions::handle_url(&href);
let page = match http
.get(&url)
.header("Accept-Language", "en-US,en;q=0.9")
.header("Cookie", "CONSENT=YES+1")
.send()
.await
{
Ok(r) => r.text().await.ok(),
Err(_) => None,
};
let resolved = page
.as_deref()
.and_then(resolve::parse_channel)
.map(|(id, parsed)| Channel {
url: resolve::channel_url(&id),
id,
// The page's own name beats a scraped one only when the
// scrape had none.
title: if name.is_empty() { parsed } else { name.clone() },
});
(name, href, resolved)
}
}))
.buffer_unordered(4)
.collect::<Vec<_>>()
.await;
for (name, href, resolved) in found {
match resolved {
Some(c) => channels.push(c),
None => unresolved.push(if name.is_empty() { href } else { name }),
}
}
if channels.is_empty() {
return Err("None of the channels on that page could be identified.".into());
}
Ok(ScrapeResult { channels, unresolved, looked_up })
}
/// What replacing the subscription list with a scraped one would change.
#[tauri::command]
pub async fn preview_scraped_import(
channels: Vec<Channel>,
state: State<'_, AppState>,
) -> Result<ImportPreview, String> {
state.db.lock().await.preview_replace(&channels)
}
/// Replaces the subscription list with a scraped one, exactly as a Takeout
/// import does, files and all.
#[tauri::command]
pub async fn import_scraped(
channels: Vec<Channel>,
state: State<'_, AppState>,
) -> Result<usize, String> {
let dropped = state.db.lock().await.paths_dropped_by_replace(&channels)?;
for p in &dropped {
let _ = tokio::fs::remove_file(p).await;
}
state.db.lock().await.replace_channels(&channels)
}
/// What clearing the subscription list would take with it.
#[tauri::command]
pub async fn preview_remove_all_subscriptions(
state: State<'_, AppState>,
) -> Result<ImportPreview, String> {
state.db.lock().await.preview_replace(&[])
}
/// Empties the subscription list, and the videos and files hanging off it.
///
/// The same path an import takes when nothing survives it, so a video saved on
/// its own from the menu bar is left alone here too: its channel was never a
/// subscription, and this is about subscriptions.
#[tauri::command]
pub async fn remove_all_subscriptions(state: State<'_, AppState>) -> Result<usize, String> {
let dropped = state.db.lock().await.paths_dropped_by_replace(&[])?;
for path in &dropped {
let _ = tokio::fs::remove_file(path).await;
}
let before = state.db.lock().await.list_channels()?.len();
state.db.lock().await.replace_channels(&[])?;
Ok(before)
}
/// What deleting one subscription would take with it. /// What deleting one subscription would take with it.
#[derive(Serialize)] #[derive(Serialize)]
pub struct RemovalPreview { pub struct RemovalPreview {
@@ -1696,7 +1844,7 @@ pub async fn add_channel(
return Err("No channel found at that link. A channel page or one of its videos works best.".into()); return Err("No channel found at that link. A channel page or one of its videos works best.".into());
}; };
if state.db.lock().await.has_channel(&id)? { if state.db.lock().await.is_subscribed(&id)? {
return Err(format!("{title} is already in your subscriptions.")); return Err(format!("{title} is already in your subscriptions."));
} }
+41 -10
View File
@@ -288,8 +288,15 @@ impl Db {
let mut stmt = tx let mut stmt = tx
.prepare( .prepare(
"INSERT INTO channels (id, title, url, added_at) VALUES (?1, ?2, ?3, ?4) "INSERT INTO channels (id, title, url, added_at, subscribed)
ON CONFLICT(id) DO UPDATE SET title=excluded.title, url=excluded.url", VALUES (?1, ?2, ?3, ?4, 1)
ON CONFLICT(id) DO UPDATE SET
title = excluded.title,
url = excluded.url,
-- A channel saved from the menu bar exists as a bare
-- row; subscribing to it now is a promotion, not a
-- duplicate.
subscribed = 1",
) )
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
let ts = now(); let ts = now();
@@ -349,10 +356,15 @@ impl Db {
tx.commit().map_err(|e| e.to_string()) tx.commit().map_err(|e| e.to_string())
} }
/// True when a channel is already subscribed. /// True when a channel is already a subscription. A bare row saved for a
pub fn has_channel(&self, id: &str) -> Result<bool, String> { /// one-off video does not count: subscribing to it is a real change.
pub fn is_subscribed(&self, id: &str) -> Result<bool, String> {
self.conn self.conn
.query_row("SELECT 1 FROM channels WHERE id = ?1", params![id], |_| Ok(())) .query_row(
"SELECT 1 FROM channels WHERE id = ?1 AND subscribed = 1",
params![id],
|_| Ok(()),
)
.map(|_| true) .map(|_| true)
.or_else(|e| match e { .or_else(|e| match e {
rusqlite::Error::QueryReturnedNoRows => Ok(false), rusqlite::Error::QueryReturnedNoRows => Ok(false),
@@ -366,8 +378,15 @@ impl Db {
{ {
let mut stmt = tx let mut stmt = tx
.prepare( .prepare(
"INSERT INTO channels (id, title, url, added_at) VALUES (?1, ?2, ?3, ?4) "INSERT INTO channels (id, title, url, added_at, subscribed)
ON CONFLICT(id) DO UPDATE SET title=excluded.title, url=excluded.url", VALUES (?1, ?2, ?3, ?4, 1)
ON CONFLICT(id) DO UPDATE SET
title = excluded.title,
url = excluded.url,
-- A channel saved from the menu bar exists as a bare
-- row; subscribing to it now is a promotion, not a
-- duplicate.
subscribed = 1",
) )
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
let ts = now(); let ts = now();
@@ -1167,7 +1186,7 @@ mod tests {
db.ensure_channel(&one_off).unwrap(); db.ensure_channel(&one_off).unwrap();
// It exists as a parent for the video, but it is not in the sidebar and // It exists as a parent for the video, but it is not in the sidebar and
// refreshing does not go looking for it. // refreshing does not go looking for it.
assert!(db.has_channel("UCX").unwrap()); assert!(!db.is_subscribed("UCX").unwrap());
assert!(!db.list_channels().unwrap().iter().any(|c| c.id == "UCX")); assert!(!db.list_channels().unwrap().iter().any(|c| c.id == "UCX"));
assert!(!db.channel_ids().unwrap().contains(&"UCX".to_string())); assert!(!db.channel_ids().unwrap().contains(&"UCX".to_string()));
@@ -1178,7 +1197,19 @@ mod tests {
url: "https://youtube.com/channel/UC1".into(), url: "https://youtube.com/channel/UC1".into(),
}]; }];
db.replace_channels(&incoming).unwrap(); db.replace_channels(&incoming).unwrap();
assert!(db.has_channel("UCX").unwrap()); assert_eq!(
db.conn
.query_row("SELECT COUNT(*) FROM channels WHERE id='UCX'", [], |r| r
.get::<_, i64>(0))
.unwrap(),
1
);
// Subscribing to it later promotes the row rather than being refused
// as a duplicate — saving a video is not subscribing.
db.upsert_channels(&[one_off.clone()]).unwrap();
assert!(db.is_subscribed("UCX").unwrap());
assert!(db.list_channels().unwrap().iter().any(|c| c.id == "UCX"));
} }
#[test] #[test]
@@ -1191,7 +1222,7 @@ mod tests {
assert_eq!(paths, vec!["/tmp/a.mp4".to_string()]); assert_eq!(paths, vec!["/tmp/a.mp4".to_string()]);
db.delete_channel("UC1").unwrap(); db.delete_channel("UC1").unwrap();
assert!(!db.has_channel("UC1").unwrap()); assert!(!db.is_subscribed("UC1").unwrap());
assert!(db.list_feed(&FeedFilter::default()).unwrap().iter().all(|f| f.id != "a")); assert!(db.list_feed(&FeedFilter::default()).unwrap().iter().all(|f| f.id != "a"));
} }
+11 -1
View File
@@ -6,6 +6,7 @@ pub mod models;
pub mod net; pub mod net;
pub mod playlist_server; pub mod playlist_server;
pub mod resolve; pub mod resolve;
pub mod subscriptions;
pub mod takeout; pub mod takeout;
pub mod thumbs; pub mod thumbs;
pub mod tray; pub mod tray;
@@ -84,7 +85,7 @@ pub fn run() {
.menu(build_menu) .menu(build_menu)
.plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_opener::init())
.plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_dialog::init())
.on_menu_event(tray::on_menu_event) .plugin(tauri_plugin_notification::init())
.setup(|app| { .setup(|app| {
let state = commands::build_state(&app.handle().clone())?; let state = commands::build_state(&app.handle().clone())?;
app.manage(state); app.manage(state);
@@ -136,6 +137,15 @@ pub fn run() {
commands::set_library_path, commands::set_library_path,
commands::open_external, commands::open_external,
commands::add_channel, commands::add_channel,
commands::scrape_subscriptions,
tray::tray_save_video,
tray::show_main_window,
tray::hide_panel,
tray::quit_app,
commands::preview_scraped_import,
commands::import_scraped,
commands::preview_remove_all_subscriptions,
commands::remove_all_subscriptions,
commands::delete_channel, commands::delete_channel,
commands::preview_delete_channel, commands::preview_delete_channel,
commands::set_download_defaults, commands::set_download_defaults,
+207
View File
@@ -0,0 +1,207 @@
//! Reading the subscription list out of a signed-in browser.
//!
//! The Takeout CSV is a snapshot you have to go and fetch. This reads the live
//! list from <https://www.youtube.com/feed/channels> in a browser that is
//! already signed in, which is both current and no trouble to repeat.
//!
//! It runs JavaScript in the page rather than fetching it here, for two
//! reasons. The page is signed-in-only, and the session cookie cannot be
//! borrowed: Chromium encrypts its cookie store with a per-app Keychain key, so
//! Arc's cookies read with Chrome's key come back undecryptable — measured at
//! 1346 of 2196, the session cookies among them. And the list is lazily loaded,
//! so it has to be scrolled to the end, which only the page itself can do.
/// The channels a page carries, as (handle, name).
pub type Scraped = Vec<(String, String)>;
/// Collects what is currently rendered. Handles rather than channel ids: the
/// page carries no ids at all — no `ytInitialData`, and every link is a
/// `/@handle`.
///
/// The name comes from `#text`. `#title` does not exist on this page and
/// `#channel-title` wraps a second copy of the name, which is worth knowing
/// because a wrong name is not a visible failure — it just means nothing
/// matches what is already stored, and every channel gets looked up over the
/// network instead.
pub const EXTRACT_JS: &str = r#"(function(){
var els = document.querySelectorAll('ytd-channel-renderer');
var rows = [];
for (var i = 0; i < els.length; i++) {
var a = els[i].querySelector('a[href^="/@"], a[href^="/channel/"]');
if (!a) continue;
var t = els[i].querySelector('#text, #channel-title');
var name = t ? t.textContent.replace(/\s+/g, ' ').trim() : '';
rows.push(a.getAttribute('href') + '\t' + name);
}
return rows.join('\n');
})()"#;
/// One scroll to the end, so the next batch loads.
pub const SCROLL_JS: &str = "window.scrollTo(0, document.documentElement.scrollHeight); \
document.querySelectorAll('ytd-channel-renderer').length";
/// True for the page this can read.
pub fn is_subscriptions_page(url: &str) -> bool {
let u = url.to_ascii_lowercase();
u.contains("youtube.com/feed/channels")
}
/// Parses what the page handed back.
///
/// A name is allowed to be empty — the handle is what identifies the channel,
/// and a nameless row is still a subscription.
pub fn parse_rows(raw: &str) -> Scraped {
let mut out: Scraped = Vec::new();
for line in raw.lines() {
let line = line.trim();
if line.is_empty() {
continue;
}
let (href, name) = match line.split_once('\t') {
Some((h, n)) => (h.trim(), n.trim()),
None => (line, ""),
};
if !href.starts_with("/@") && !href.starts_with("/channel/") {
continue;
}
// The same channel can be rendered twice while the list re-flows.
if out.iter().any(|(h, _)| h == href) {
continue;
}
out.push((href.to_string(), collapse_doubled(name)));
}
out
}
/// Collapses a name that arrived twice over.
///
/// YouTube's markup nests the channel name inside an element that also holds
/// it, so a slightly wrong selector yields "3D OCD 3D OCD". That reads fine to
/// a person and matches nothing at all.
pub fn collapse_doubled(name: &str) -> String {
let n = name.split_whitespace().collect::<Vec<_>>().join(" ");
if n.is_empty() {
return n;
}
if n.len() % 2 == 1 {
let mid = n.len() / 2;
if n.is_char_boundary(mid) && n.is_char_boundary(mid + 1) && &n[mid..mid + 1] == " " {
let (a, b) = (&n[..mid], &n[mid + 1..]);
if a == b {
return a.to_string();
}
}
}
n
}
/// The full address for a scraped handle.
pub fn handle_url(href: &str) -> String {
format!("https://www.youtube.com{href}")
}
/// The channel id, when the page gave one outright rather than a handle.
pub fn id_from_href(href: &str) -> Option<String> {
let id = href.strip_prefix("/channel/")?;
let id = id.split('/').next().unwrap_or(id);
(id.starts_with("UC") && id.len() == 24).then(|| id.to_string())
}
/// Names as YouTube renders them and as a CSV stored them differ in case,
/// spacing and stray whitespace; this is what they are compared on.
pub fn name_key(name: &str) -> String {
name.split_whitespace()
.collect::<Vec<_>>()
.join(" ")
.to_lowercase()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn only_the_subscriptions_page_qualifies() {
assert!(is_subscriptions_page("https://www.youtube.com/feed/channels"));
assert!(is_subscriptions_page("https://youtube.com/feed/channels?flow=grid"));
assert!(!is_subscriptions_page("https://www.youtube.com/feed/subscriptions"));
assert!(!is_subscriptions_page("https://www.youtube.com/watch?v=abc"));
}
#[test]
fn rows_come_back_as_handle_and_name() {
let raw = "/@3DOCD\t3D OCD\n/@mkbhd\tMarques Brownlee\n";
assert_eq!(
parse_rows(raw),
vec![
("/@3DOCD".to_string(), "3D OCD".to_string()),
("/@mkbhd".to_string(), "Marques Brownlee".to_string()),
]
);
}
#[test]
fn junk_rows_are_dropped_and_repeats_collapse() {
// A re-flowing list can render the same channel twice, and anything
// that is not a channel link is not a subscription.
let raw = "/@a\tA\n\n/watch?v=x\tNot a channel\n/@a\tA\n/@b\t\n";
assert_eq!(
parse_rows(raw),
vec![
("/@a".to_string(), "A".to_string()),
// A nameless row is still a subscription; the handle identifies it.
("/@b".to_string(), String::new()),
]
);
}
#[test]
fn a_channel_link_gives_its_id_without_a_lookup() {
assert_eq!(
id_from_href("/channel/UCBJycsmduvYEL83R_U4JriQ").as_deref(),
Some("UCBJycsmduvYEL83R_U4JriQ")
);
assert_eq!(id_from_href("/@mkbhd"), None);
assert_eq!(id_from_href("/channel/UCshort"), None);
}
#[test]
fn a_name_that_arrived_twice_is_collapsed() {
assert_eq!(collapse_doubled("3D OCD 3D OCD"), "3D OCD");
assert_eq!(collapse_doubled(" Linus Tech Tips "), "Linus Tech Tips");
// Not everything that repeats is doubled.
assert_eq!(collapse_doubled("Spanian 2"), "Spanian 2");
assert_eq!(collapse_doubled("Corridor Crew"), "Corridor Crew");
assert_eq!(collapse_doubled(""), "");
}
#[test]
fn doubled_names_survive_parsing() {
assert_eq!(
parse_rows("/@3DOCD\t3D OCD 3D OCD\n"),
vec![("/@3DOCD".to_string(), "3D OCD".to_string())]
);
}
#[test]
fn handles_become_addresses() {
assert_eq!(handle_url("/@mkbhd"), "https://www.youtube.com/@mkbhd");
}
#[test]
fn names_match_across_spacing_and_case() {
// What the page renders vs what a Takeout CSV stored.
assert_eq!(name_key(" Linus Tech Tips "), name_key("linus tech tips"));
assert_ne!(name_key("Corridor Crew"), name_key("Corridor Digital"));
}
#[test]
fn the_extraction_script_asks_for_what_the_page_has() {
// Handles and names, since the page carries no channel ids.
assert!(EXTRACT_JS.contains("ytd-channel-renderer"));
assert!(EXTRACT_JS.contains(r#"a[href^="/@"]"#));
// #text holds the name once; #title does not exist on this page.
assert!(EXTRACT_JS.contains("'#text, #channel-title'"));
assert!(SCROLL_JS.contains("scrollTo"));
}
}
+375 -118
View File
@@ -10,13 +10,39 @@
use crate::commands::{self, AppState}; use crate::commands::{self, AppState};
use crate::resolve; use crate::resolve;
use tauri::menu::{Menu, MenuEvent, MenuItem, PredefinedMenuItem}; use crate::subscriptions;
use tauri::tray::TrayIconBuilder; use std::io::Write;
use tauri::{AppHandle, Emitter, Manager}; use tauri::tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent};
use tauri::{AppHandle, Emitter, Manager, WebviewUrl, WebviewWindowBuilder};
use tauri_plugin_notification::NotificationExt;
pub const SAVE_VIDEO: &str = "tray.save_video"; /// The panel window's label.
pub const ADD_CHANNEL: &str = "tray.add_channel"; pub const PANEL: &str = "tray";
pub const SHOW: &str = "tray.show";
/// A running account of what the menu bar did, next to the database.
///
/// The menu bar acts with no window open and its answers arrive as
/// notifications, which macOS can quietly withhold. When someone reports that
/// nothing happened, this is the difference between guessing and knowing.
fn log(app: &AppHandle, line: &str) {
let Ok(dir) = app.path().app_data_dir() else { return };
let _ = std::fs::create_dir_all(&dir);
if let Ok(mut f) = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(dir.join("tray.log"))
{
let secs = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
let _ = writeln!(f, "{secs} {line}");
}
}
/// The tray icon itself, held for as long as the app runs. Dropping it takes
/// the icon out of the menu bar.
pub struct Tray(#[allow(dead_code)] pub tauri::tray::TrayIcon<tauri::Wry>);
/// Browsers worth asking, in the order they are asked. All but Safari answer /// Browsers worth asking, in the order they are asked. All but Safari answer
/// the same Chromium-flavoured AppleScript. /// the same Chromium-flavoured AppleScript.
@@ -29,71 +55,203 @@ const CHROMIUM: [&str; 6] = [
"Chromium", "Chromium",
]; ];
/// One browser's script. Chromium-derived browsers share a dictionary; Safari
/// speaks of documents rather than tabs.
fn url_script(browser: &str, safari_style: bool) -> String {
let getter = if safari_style {
"URL of front document"
} else {
"URL of active tab of front window"
};
format!(
r#"try
if running of application "{browser}" then
tell application "{browser}" to return {getter}
end if
end try
return ""
"#
)
}
/// The YouTube address showing in whichever browser has one. /// The YouTube address showing in whichever browser has one.
/// ///
/// Only browsers that are already running are asked, so nothing is launched to /// Each browser gets its own script, and that is the point rather than a
/// answer the question, and every lookup is wrapped in `try` — a browser with /// tidiness choice. AppleScript resolves an application's terminology when it
/// no window open must not turn into an error. /// compiles, so naming a browser that is not installed is a *compile* error
fn browser_url_script() -> String { /// which no `try` can catch, and which kills the whole script. One script
let mut s = String::from( /// covering six browsers therefore failed outright on a Mac missing any one of
r#"set found to "" /// them, and the first, working browser was never asked.
set apps to {} ///
try /// `running of application` needs no dictionary, so it answers even for a
tell application "System Events" to set apps to name of every process /// browser that is not installed, and launches nothing.
end try async fn browser_youtube_url(app: &AppHandle) -> Result<String, String> {
"#, let mut refused = false;
);
for b in CHROMIUM {
s.push_str(&format!(
r#"if found is "" and apps contains "{b}" then
try
tell application "{b}" to set u to URL of active tab of front window
if u contains "youtube.com" or u contains "youtu.be" then set found to u
end try
end if
"#
));
}
s.push_str(
r#"if found is "" and apps contains "Safari" then
try
tell application "Safari" to set u to URL of front document
if u contains "youtube.com" or u contains "youtu.be" then set found to u
end try
end if
return found
"#,
);
s
}
async fn browser_youtube_url() -> Result<String, String> { for (browser, safari_style) in CHROMIUM
let out = tokio::process::Command::new("/usr/bin/osascript") .iter()
.map(|b| (*b, false))
.chain(std::iter::once(("Safari", true)))
{
let Ok(out) = tokio::process::Command::new("/usr/bin/osascript")
.arg("-e") .arg("-e")
.arg(browser_url_script()) .arg(url_script(browser, safari_style))
.output() .output()
.await .await
.map_err(|e| format!("Could not ask the browser: {e}"))?; else {
continue;
};
let stderr = String::from_utf8_lossy(&out.stderr); let stdout = String::from_utf8_lossy(&out.stdout).trim().to_string();
// -1743 is macOS refusing the Apple event because the permission has not let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string();
// been granted. Saying so beats "nothing found". if !stdout.is_empty() || !stderr.is_empty() {
log(app, &format!("{browser}: out={stdout:?} err={stderr:?}"));
}
// -1743 is macOS refusing the Apple event for want of permission.
if stderr.contains("-1743") || stderr.contains("Not authorized") { if stderr.contains("-1743") || stderr.contains("Not authorized") {
return Err( refused = true;
"FlightTube needs permission to read your browser's address. \ }
if resolve::is_youtube_url(&stdout) {
return Ok(stdout);
}
}
if refused {
return Err("FlightTube needs permission to read your browser's address. \
Allow it under System Settings Privacy & Security Automation." Allow it under System Settings Privacy & Security Automation."
.into(), .into());
);
} }
let url = String::from_utf8_lossy(&out.stdout).trim().to_string(); Err("No YouTube page open in a browser.".into())
if url.is_empty() {
return Err("No YouTube page open in a browser.".into());
}
Ok(url)
} }
/// A notification, so the answer arrives without the window coming forward. /// Runs JavaScript in a browser's active tab.
async fn notify(text: &str) { ///
/// Arc allows this out of the box. Chrome and its relatives ship with it off,
/// and say so in the error, which is passed straight back rather than being
/// flattened into "something went wrong".
pub async fn run_js(browser: &str, js: &str) -> Result<String, String> {
let safari = browser == "Safari";
let script = if safari {
format!("tell application \"Safari\" to return (do JavaScript {} in front document)", quote(js))
} else {
format!(
"tell application {} to return (execute front window's active tab javascript {})",
quote(browser),
quote(js)
)
};
let out = tokio::process::Command::new("/usr/bin/osascript")
.arg("-e")
.arg(script)
.output()
.await
.map_err(|e| format!("Could not reach {browser}: {e}"))?;
let stderr = String::from_utf8_lossy(&out.stderr);
if stderr.contains("Executing JavaScript through AppleScript is turned off") {
return Err(format!(
"{browser} will not run JavaScript for another app. Turn it on in {browser} under \
View Developer Allow JavaScript from Apple Events, then try again. Arc allows \
it already."
));
}
if !out.status.success() {
return Err(format!("{browser} refused: {}", stderr.trim()));
}
Ok(applescript_unquote(&String::from_utf8_lossy(&out.stdout)))
}
/// AppleScript string literal.
fn quote(s: &str) -> String {
applescript_string(s)
}
/// The browser showing the subscriptions page, if one is.
async fn browser_on_subscriptions(app: &AppHandle) -> Result<String, String> {
for (browser, safari_style) in CHROMIUM
.iter()
.map(|b| (*b, false))
.chain(std::iter::once(("Safari", true)))
{
let Ok(out) = tokio::process::Command::new("/usr/bin/osascript")
.arg("-e")
.arg(url_script(browser, safari_style))
.output()
.await
else {
continue;
};
let url = String::from_utf8_lossy(&out.stdout).trim().to_string();
if subscriptions::is_subscriptions_page(&url) {
log(app, &format!("subscriptions page open in {browser}"));
return Ok(browser.to_string());
}
}
Err("Open https://www.youtube.com/feed/channels in your browser first, \
then try again."
.into())
}
/// Scrolls the subscriptions page to its end and reads the list off it.
///
/// The list loads a batch at a time, so this scrolls until the count stops
/// growing rather than trusting one read — a first look sees about half.
pub async fn scrape_subscriptions(app: &AppHandle) -> Result<subscriptions::Scraped, String> {
let browser = browser_on_subscriptions(app).await?;
let mut last = 0usize;
let mut settled = 0;
for round in 0..40 {
let n = run_js(&browser, subscriptions::SCROLL_JS)
.await?
.parse::<usize>()
.unwrap_or(0);
// Nothing there yet only means the tab is still waking up.
if n == 0 && round < 8 {
tokio::time::sleep(std::time::Duration::from_millis(700)).await;
continue;
}
if n == last {
settled += 1;
if settled >= 3 {
break;
}
} else {
settled = 0;
}
last = n;
tokio::time::sleep(std::time::Duration::from_millis(900)).await;
}
let raw = run_js(&browser, subscriptions::EXTRACT_JS).await?;
let rows = subscriptions::parse_rows(&raw);
log(app, &format!("scraped {} channels from {browser}", rows.len()));
if rows.is_empty() {
return Err("That page has no channels on it. Is it the subscriptions page, \
and are you signed in?"
.into());
}
Ok(rows)
}
/// The answer, delivered without the window coming forward.
///
/// Two ways, because either can fail silently: a notification from the app
/// itself, and the same through AppleScript if the plugin is unavailable.
/// Both are also written to the log beside the database.
async fn notify(app: &AppHandle, text: &str) {
log(app, text);
if app
.notification()
.builder()
.title("FlightTube")
.body(text)
.show()
.is_ok()
{
return;
}
let script = format!( let script = format!(
"display notification {} with title \"FlightTube\"", "display notification {} with title \"FlightTube\"",
applescript_string(text) applescript_string(text)
@@ -105,57 +263,78 @@ async fn notify(text: &str) {
.await; .await;
} }
/// Undoes how osascript prints a string result.
///
/// It prints in source form — wrapped in quotes with the newlines and tabs
/// escaped — so a list of rows arrives as one line that looks nothing like
/// rows. Numbers and bare words come back untouched and pass straight through.
fn applescript_unquote(out: &str) -> String {
let out = out.trim();
let Some(inner) = out.strip_prefix('"').and_then(|s| s.strip_suffix('"')) else {
return out.to_string();
};
let mut s = String::with_capacity(inner.len());
let mut chars = inner.chars();
while let Some(c) = chars.next() {
if c != '\\' {
s.push(c);
continue;
}
match chars.next() {
Some('n') => s.push('\n'),
Some('t') => s.push('\t'),
Some('r') => s.push('\r'),
Some(other) => s.push(other),
None => break,
}
}
s
}
/// Quotes a string for AppleScript. Backslashes first, or the escaping of the /// Quotes a string for AppleScript. Backslashes first, or the escaping of the
/// quotes gets undone. /// quotes gets undone.
fn applescript_string(s: &str) -> String { fn applescript_string(s: &str) -> String {
format!("\"{}\"", s.replace('\\', "\\\\").replace('"', "\\\"")) format!("\"{}\"", s.replace('\\', "\\\\").replace('"', "\\\""))
} }
async fn handle_save_video(app: AppHandle) { #[tauri::command]
let url = match browser_youtube_url().await { pub async fn tray_save_video(app: AppHandle) {
let app = &app;
log(app, "menu: save video");
let url = match browser_youtube_url(app).await {
Ok(u) => u, Ok(u) => u,
Err(e) => return notify(&e).await, Err(e) => return notify(app, &e).await,
}; };
if resolve::video_id_from_url(&url).is_none() { if resolve::video_id_from_url(&url).is_none() {
return notify("That page is not a video.").await; return notify(app, "That page is not a video.").await;
} }
let state = app.state::<AppState>(); let state = app.state::<AppState>();
let (video_id, title) = match commands::save_video(&state, &url).await { let (video_id, title) = match commands::save_video(&state, &url).await {
Ok(v) => v, Ok(v) => v,
Err(e) => return notify(&e).await, Err(e) => return notify(app, &e).await,
}; };
let (quality, sub_lang) = state.download_defaults.lock().await.clone(); let (quality, sub_lang) = state.download_defaults.lock().await.clone();
let _ = app.emit("feed:changed", ()); let _ = app.emit("feed:changed", ());
notify(&format!("Downloading {title}")).await; notify(app, &format!("Downloading {title}")).await;
// The download outlives this handler; its progress shows in the window. // The download outlives this handler; its progress shows in the window.
let handle = app.clone(); let handle = app.clone();
tauri::async_runtime::spawn(async move { tauri::async_runtime::spawn(async move {
let state = handle.state::<AppState>(); let state = handle.state::<AppState>();
match commands::download_video(video_id, quality, sub_lang, handle.clone(), state).await { match commands::download_video(video_id, quality, sub_lang, handle.clone(), state).await {
Ok(()) => notify(&format!("Saved {title}")).await, Ok(()) => notify(&handle, &format!("Saved {title}")).await,
Err(e) => notify(&format!("{title} failed: {e}")).await, Err(e) => notify(&handle, &format!("{title} failed: {e}")).await,
} }
}); });
} }
async fn handle_add_channel(app: AppHandle) { /// Brings the window forward, and closes the panel behind it.
let url = match browser_youtube_url().await { #[tauri::command]
Ok(u) => u, pub fn show_main_window(app: AppHandle) {
Err(e) => return notify(&e).await, if let Some(p) = app.get_webview_window(PANEL) {
}; let _ = p.hide();
let state = app.state::<AppState>();
match commands::add_channel(url, app.clone(), state).await {
Ok(title) => {
let _ = app.emit("feed:changed", ());
notify(&format!("Subscribed to {title}")).await;
} }
Err(e) => notify(&e).await,
}
}
fn show_window(app: &AppHandle) {
if let Some(w) = app.get_webview_window("main") { if let Some(w) = app.get_webview_window("main") {
let _ = w.show(); let _ = w.show();
let _ = w.unminimize(); let _ = w.unminimize();
@@ -163,44 +342,98 @@ fn show_window(app: &AppHandle) {
} }
} }
pub fn on_menu_event(app: &AppHandle, event: MenuEvent) { /// Closes the panel without doing anything else.
let app = app.clone(); #[tauri::command]
match event.id().as_ref() { pub fn hide_panel(app: AppHandle) {
SAVE_VIDEO => { if let Some(p) = app.get_webview_window(PANEL) {
tauri::async_runtime::spawn(handle_save_video(app)); let _ = p.hide();
}
ADD_CHANNEL => {
tauri::async_runtime::spawn(handle_add_channel(app));
}
SHOW => show_window(&app),
_ => {}
} }
} }
#[tauri::command]
pub fn quit_app(app: AppHandle) {
app.exit(0);
}
/// The panel's own size. Fixed, because it is a menu: it does not resize.
const PANEL_W: f64 = 304.0;
const PANEL_H: f64 = 252.0;
/// Opens the panel under the menu bar icon.
///
/// A native menu would look like every other menu bar item; this is the app's
/// own window, styled like the rest of it, positioned to hang from the icon.
fn show_panel(app: &AppHandle, icon: tauri::Rect) {
let Some(win) = app.get_webview_window(PANEL) else { return };
if win.is_visible().unwrap_or(false) {
let _ = win.hide();
return;
}
// The icon's rect is in physical pixels; the window is placed in the same
// space, centred under the icon and just below the menu bar.
if let (tauri::Position::Physical(pos), tauri::Size::Physical(size)) =
(icon.position, icon.size)
{
let scale = win.scale_factor().unwrap_or(1.0);
let w = PANEL_W * scale;
let x = pos.x as f64 + size.width as f64 / 2.0 - w / 2.0;
let y = pos.y as f64 + size.height as f64 + 6.0 * scale;
let _ = win.set_position(tauri::PhysicalPosition::new(x.max(8.0), y));
}
let _ = win.show();
let _ = win.set_focus();
}
pub fn build(app: &AppHandle) -> tauri::Result<()> { pub fn build(app: &AppHandle) -> tauri::Result<()> {
let menu = Menu::with_items( // Hidden until the icon is clicked. Undecorated and transparent so the
app, // page can draw its own rounded, shadowed card.
&[ let panel = WebviewWindowBuilder::new(app, PANEL, WebviewUrl::App("index.html#tray".into()))
&MenuItem::with_id(app, SAVE_VIDEO, "Download the video I'm watching", true, None::<&str>)?, .title("FlightTube")
&MenuItem::with_id(app, ADD_CHANNEL, "Add the channel I'm watching", true, None::<&str>)?, .inner_size(PANEL_W, PANEL_H)
&PredefinedMenuItem::separator(app)?, .decorations(false)
&MenuItem::with_id(app, SHOW, "Open FlightTube", true, None::<&str>)?, .transparent(true)
&PredefinedMenuItem::separator(app)?, .resizable(false)
&PredefinedMenuItem::quit(app, Some("Quit FlightTube"))?, .always_on_top(true)
], .skip_taskbar(true)
)?; .visible(false)
.focused(false)
.build()?;
// Clicking away closes it, as a menu does.
{
let handle = panel.clone();
panel.on_window_event(move |e| {
if let tauri::WindowEvent::Focused(false) = e {
let _ = handle.hide();
}
});
}
let icon = tauri::image::Image::from_bytes(include_bytes!("../icons/tray.png"))?; let icon = tauri::image::Image::from_bytes(include_bytes!("../icons/tray.png"))?;
TrayIconBuilder::with_id("flighttube") let tray = TrayIconBuilder::with_id("flighttube")
.icon(icon) .icon(icon)
// A template image takes the menu bar's own colour, light or dark. // A template image takes the menu bar's own colour, light or dark.
.icon_as_template(true) .icon_as_template(true)
.tooltip("FlightTube") .tooltip("FlightTube")
.menu(&menu) // No menu at all: the click opens the panel instead.
// The menu is the whole point; a left click should open it too. .show_menu_on_left_click(false)
.show_menu_on_left_click(true) .on_tray_icon_event(|tray, event| {
if let TrayIconEvent::Click { button: MouseButton::Left, button_state, rect, .. } = event
{
if button_state == MouseButtonState::Up {
show_panel(tray.app_handle(), rect);
}
}
})
.build(app)?; .build(app)?;
// TrayIcon is reference-counted and "the icon is removed when the last
// instance is dropped", so letting the handle fall out of scope here would
// create the item and destroy it in the same breath.
app.manage(Tray(tray));
Ok(()) Ok(())
} }
@@ -209,17 +442,41 @@ mod tests {
use super::*; use super::*;
#[test] #[test]
fn the_script_asks_every_browser_and_launches_none() { fn each_browser_gets_its_own_script() {
let s = browser_url_script(); let arc = url_script("Arc", false);
for b in CHROMIUM { // Naming a browser that is not installed is a compile error, so a
assert!(s.contains(&format!("apps contains \"{b}\"")), "missing {b}"); // script must never mention more than the one it is asking.
for other in CHROMIUM.iter().filter(|b| **b != "Arc") {
assert!(!arc.contains(*other), "Arc's script mentions {other}");
} }
assert!(s.contains("Safari")); assert!(!arc.contains("Safari"));
// Guarded by the running-process list, so asking cannot start a browser. // Behind a running check, so asking cannot start a browser, and inside
assert_eq!(s.matches("apps contains").count(), CHROMIUM.len() + 1); // a try, so a browser with no window is not an error.
// Every lookup is inside a try, so a browser with no windows is not an assert!(arc.contains(r#"running of application "Arc""#));
// error: one per browser, one for Safari, one for the process list. assert!(arc.contains("end try"));
assert_eq!(s.matches("end try").count(), CHROMIUM.len() + 2); // Nothing needs System Events, which is a separate permission.
assert!(!arc.contains("System Events"));
}
#[test]
fn safari_is_asked_in_its_own_dialect() {
assert!(url_script("Safari", true).contains("URL of front document"));
assert!(url_script("Arc", false).contains("URL of active tab of front window"));
}
#[test]
fn a_printed_string_result_is_unwrapped_into_real_rows() {
// What osascript actually prints for a multi-line string result: one
// quoted line with the newlines and tabs escaped.
let printed = r#""/@a\tA\n/@b\tB""#;
assert_eq!(applescript_unquote(printed), "/@a\tA\n/@b\tB");
assert_eq!(applescript_unquote(printed).lines().count(), 2);
}
#[test]
fn a_number_result_passes_through_untouched() {
assert_eq!(applescript_unquote("197\n"), "197");
assert_eq!(applescript_unquote(""), "");
} }
#[test] #[test]
+5 -2
View File
@@ -32,11 +32,14 @@
"$APPLOCALDATA/**" "$APPLOCALDATA/**"
] ]
} }
} },
"macOSPrivateApi": true
}, },
"bundle": { "bundle": {
"active": true, "active": true,
"targets": "all", "targets": [
"app"
],
"icon": [ "icon": [
"icons/32x32.png", "icons/32x32.png",
"icons/128x128.png", "icons/128x128.png",
+270 -9
View File
@@ -2,8 +2,9 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { listen } from "@tauri-apps/api/event"; import { listen } from "@tauri-apps/api/event";
import { import {
cancelAllDownloads, cancelDownload, deleteAllDownloads, deleteChannel, cancelAllDownloads, cancelDownload, deleteAllDownloads, deleteChannel,
deleteDownload, downloadVideo, interruptedDownloads, previewDeleteChannel, deleteDownload, downloadVideo, importScraped, interruptedDownloads, listFeed,
setDownloadDefaults, type RemovalPreview, openExternal, previewDeleteChannel, previewScrapedImport,
setDownloadDefaults, type RemovalPreview, type ScrapeResult,
fetchDurations, onRefreshProgress, refreshFeeds, setCookieSource, fetchDurations, onRefreshProgress, refreshFeeds, setCookieSource,
} from "./api"; } from "./api";
import AddChannel from "./components/AddChannel"; import AddChannel from "./components/AddChannel";
@@ -22,7 +23,8 @@ import { useWindowFullscreen } from "./hooks/useWindowFullscreen";
import { import {
BULK_LIMITS, DEFAULT_BULK_LIMIT, DEFAULT_SUB_LANG, DEFAULT_SUB_STYLE, BULK_LIMITS, DEFAULT_BULK_LIMIT, DEFAULT_SUB_LANG, DEFAULT_SUB_STYLE,
QUALITIES, STREAM_QUALITIES, SUB_LANGS, QUALITIES, STREAM_QUALITIES, SUB_LANGS,
type FeedFilter, type FeedItem, type Quality, type RefreshProgress, type SubStyle, type FeedFilter, type FeedItem, type ImportPreview, type Quality,
type RefreshProgress, type SubStyle,
} from "./types"; } from "./types";
const TOAST_MS = 2400; const TOAST_MS = 2400;
@@ -49,6 +51,11 @@ export default function App() {
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const [downloadedOnly, setDownloadedOnly] = useState(() => remembered("downloadedOnly")); const [downloadedOnly, setDownloadedOnly] = useState(() => remembered("downloadedOnly"));
const [hideShorts, setHideShorts] = useState(() => remembered("hideShorts")); const [hideShorts, setHideShorts] = useState(() => remembered("hideShorts"));
const [autoplayNext, setAutoplayNext] = useState(() => remembered("autoplayNext"));
const [autoMode, setAutoMode] = useState(() => remembered("autoMode"));
const [confirmAuto, setConfirmAuto] = useState<{ fetch: number; remove: number } | null>(null);
// A subscription list read out of the browser, waiting to be confirmed.
const [scraped, setScraped] = useState<(ScrapeResult & { preview: ImportPreview }) | null>(null);
const [sidebarHidden, setSidebarHidden] = useState(() => remembered("sidebarHidden")); const [sidebarHidden, setSidebarHidden] = useState(() => remembered("sidebarHidden"));
const [sidebarPeek, setSidebarPeek] = useState(false); const [sidebarPeek, setSidebarPeek] = useState(false);
const [view, setView] = useState<ViewMode>(() => { const [view, setView] = useState<ViewMode>(() => {
@@ -161,12 +168,14 @@ export default function App() {
localStorage.setItem("flighttube.browser", browser); localStorage.setItem("flighttube.browser", browser);
localStorage.setItem("flighttube.downloadedOnly", downloadedOnly ? "1" : "0"); localStorage.setItem("flighttube.downloadedOnly", downloadedOnly ? "1" : "0");
localStorage.setItem("flighttube.hideShorts", hideShorts ? "1" : "0"); localStorage.setItem("flighttube.hideShorts", hideShorts ? "1" : "0");
localStorage.setItem("flighttube.autoplayNext", autoplayNext ? "1" : "0");
localStorage.setItem("flighttube.autoMode", autoMode ? "1" : "0");
localStorage.setItem("flighttube.sidebarHidden", sidebarHidden ? "1" : "0"); localStorage.setItem("flighttube.sidebarHidden", sidebarHidden ? "1" : "0");
} catch { } catch {
/* storage blocked */ /* storage blocked */
} }
}, [view, quality, bulkLimit, streamQuality, subLang, subStyle, browser, downloadedOnly, }, [view, quality, bulkLimit, streamQuality, subLang, subStyle, browser, downloadedOnly,
hideShorts, sidebarHidden]); hideShorts, autoplayNext, autoMode, sidebarHidden]);
// The language a download embeds. Like the player's fetch, this does not // The language a download embeds. Like the player's fetch, this does not
// depend on the on/off preference: that says what is shown, and a file // depend on the on/off preference: that says what is shown, and a file
@@ -217,6 +226,62 @@ export default function App() {
// machine-translated variant YouTube offers, and asking for all of them // machine-translated variant YouTube offers, and asking for all of them
// earns an HTTP 429. Off means no subtitle requests at all. // earns an HTTP 429. Off means no subtitle requests at all.
/**
* Auto mode: keep the newest `bulkLimit` videos of the feed on disk, and
* nothing else.
*
* Runs after every check of the feed, so the library follows the feed rather
* than accumulating. Deliberate one-off saves from the menu bar are left
* alone — their channel is not a subscription, so they are not part of what
* this is managing, and deleting something saved by hand a minute ago would
* be a nasty surprise.
*/
const reconciling = useRef(false);
const autoModeRef = useRef(autoMode);
autoModeRef.current = autoMode;
const reconcileAuto = useCallback(async () => {
if (reconciling.current || bulkLimit <= 0) return;
reconciling.current = true;
try {
const shared = { channel_id: null, search: null, hide_shorts: hideShorts };
const [wanted, held] = await Promise.all([
listFeed({ ...shared, downloaded_only: false, limit: bulkLimit }),
// Everything on disk, Shorts included: one downloaded before Hide
// Shorts was switched on still takes up room.
listFeed({ ...shared, hide_shorts: false, downloaded_only: true, limit: 1000 }),
]);
const keep = new Set(wanted.map((v) => v.id));
const subscribed = new Set(channels.map((c) => c.id));
const stale = held.filter(
(v) => !keep.has(v.id) && v.state === "done" && subscribed.has(v.channel_id),
);
for (const v of stale) {
await deleteDownload(v.id).catch(() => {});
clearLive(v.id);
}
const missing = wanted.filter(
(v) => v.state !== "done" && v.state !== "queued" && v.state !== "running",
);
for (const v of missing) {
downloadVideo(v.id, quality, embedLang).catch(() => {});
}
if (stale.length > 0 || missing.length > 0) {
reload();
say(
`Auto: ${missing.length} to fetch` +
(stale.length > 0 ? `, ${stale.length} removed` : ""),
);
}
} finally {
reconciling.current = false;
}
}, [bulkLimit, hideShorts, channels, quality, embedLang, reload, say, clearLive]);
const doRefresh = useCallback(async () => { const doRefresh = useCallback(async () => {
setRefreshing(true); setRefreshing(true);
try { try {
@@ -228,13 +293,14 @@ export default function App() {
? `Checked ${s.channels} channels · ${failed} failed` ? `Checked ${s.channels} channels · ${failed} failed`
: `Checked ${s.channels} channel${s.channels === 1 ? "" : "s"}`, : `Checked ${s.channels} channel${s.channels === 1 ? "" : "s"}`,
); );
if (autoModeRef.current) void reconcileAuto();
} catch (e) { } catch (e) {
setFailure(String(e)); setFailure(String(e));
} finally { } finally {
setRefreshing(false); setRefreshing(false);
setRefreshProgress(null); setRefreshProgress(null);
} }
}, [reload, say]); }, [reload, say, reconcileAuto]);
// Downloaded plays from disk; anything else streams. Only being offline with // Downloaded plays from disk; anything else streams. Only being offline with
// no local copy leaves nothing to play. // no local copy leaves nothing to play.
@@ -252,8 +318,19 @@ export default function App() {
const openIndex = useCallback( const openIndex = useCallback(
(i: number) => { (i: number) => {
if (playableAt(i)) setPlayingIndex(i); const at = playableAt(i);
else setFailure("That video isn't downloaded, and you're offline."); if (!at) {
setFailure("That video isn't downloaded, and you're offline.");
return;
}
setPlayingIndex(i);
// Noted while it is open and forgotten when it is closed, so a restart
// only reopens something you were actually in the middle of.
try {
localStorage.setItem("flighttube.playing", at.item.id);
} catch {
/* storage blocked */
}
}, },
[playableAt], [playableAt],
); );
@@ -293,6 +370,19 @@ export default function App() {
}); });
}, [quality, embedLang]); }, [quality, embedLang]);
// The menu bar reads the subscription list, but replacing what is here is
// not a thing to agree to in a panel that closes when you look away.
useEffect(() => {
const un = listen<ScrapeResult>("subs:scraped", (e) => {
previewScrapedImport(e.payload.channels)
.then((preview) => setScraped({ ...e.payload, preview }))
.catch((err) => setFailure(String(err)));
});
return () => {
void un.then((f) => f());
};
}, []);
// Anything saved from the menu bar arrives behind the app's back. // Anything saved from the menu bar arrives behind the app's back.
useEffect(() => { useEffect(() => {
const un = listen("feed:changed", () => reload()); const un = listen("feed:changed", () => reload());
@@ -301,6 +391,39 @@ export default function App() {
}; };
}, [reload]); }, [reload]);
/** What engaging auto mode would do right now, so the question is concrete. */
const askAutoMode = useCallback(async () => {
if (autoMode) {
setAutoMode(false);
say("Auto mode off");
return;
}
if (bulkLimit <= 0) {
setFailure(
"Auto mode needs a number to keep. Set Download all in Settings to 5, 10, 25, " +
"50 or 100 — with no limit there is nothing to trim to.",
);
return;
}
try {
const shared = { channel_id: null, search: null, hide_shorts: hideShorts };
const [wanted, held] = await Promise.all([
listFeed({ ...shared, downloaded_only: false, limit: bulkLimit }),
listFeed({ ...shared, hide_shorts: false, downloaded_only: true, limit: 1000 }),
]);
const keep = new Set(wanted.map((v) => v.id));
const subscribed = new Set(channels.map((c) => c.id));
setConfirmAuto({
fetch: wanted.filter((v) => v.state !== "done").length,
remove: held.filter(
(v) => !keep.has(v.id) && v.state === "done" && subscribed.has(v.channel_id),
).length,
});
} catch (e) {
setFailure(String(e));
}
}, [autoMode, bulkLimit, hideShorts, channels, say]);
const askRemoveChannel = useCallback((id: string) => { const askRemoveChannel = useCallback((id: string) => {
previewDeleteChannel(id) previewDeleteChannel(id)
.then((p) => setRemoving({ ...p, id })) .then((p) => setRemoving({ ...p, id }))
@@ -316,10 +439,45 @@ export default function App() {
if (channelId === id) setChannelId(null); if (channelId === id) setChannelId(null);
reload(); reload();
say(`Removed ${title}`); say(`Removed ${title}`);
// And then the other half of the job. YouTube has no address that
// unsubscribes on its own, so this opens the channel, where Subscribed
// is one click from Unsubscribe.
openExternal(`https://www.youtube.com/channel/${id}`);
}) })
.catch((e) => setFailure(String(e))); .catch((e) => setFailure(String(e)));
}, [removing, channelId, reload, say]); }, [removing, channelId, reload, say]);
// Reopen whatever was playing when the app last closed, once the feed has
// loaded and only if that video is still in it and still playable — offline,
// a video that was streaming is not.
const resumedOnce = useRef(false);
useEffect(() => {
if (resumedOnce.current || items.length === 0 || playingIndex != null) return;
resumedOnce.current = true;
let id: string | null = null;
try {
id = localStorage.getItem("flighttube.playing");
} catch {
/* storage blocked */
}
if (!id) return;
const at = items.findIndex((i) => i.id === id);
if (at >= 0 && playableAt(at)) setPlayingIndex(at);
// playableAt changes with every render; the guard above runs this once.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [items]);
// On launch, bring the library in line before the first ten-minute check —
// otherwise auto mode looks asleep for the first ten minutes. Waits for the
// channel list, since knowing which channels are subscriptions is what keeps
// hand-saved videos from being swept.
const reconciledOnce = useRef(false);
useEffect(() => {
if (!autoMode || reconciledOnce.current || channels.length === 0) return;
reconciledOnce.current = true;
void reconcileAuto();
}, [autoMode, channels.length, reconcileAuto]);
// Anything in flight, whether or not it is currently listed — a download // Anything in flight, whether or not it is currently listed — a download
// started on one channel keeps running while you look at another. // started on one channel keeps running while you look at another.
const activeDownloads = useMemo(() => { const activeDownloads = useMemo(() => {
@@ -543,6 +701,9 @@ export default function App() {
downloadAllTotal={pendingDownloads.length} downloadAllTotal={pendingDownloads.length}
onStopAll={activeDownloads > 0 ? stopAll : undefined} onStopAll={activeDownloads > 0 ? stopAll : undefined}
stopAllCount={activeDownloads} stopAllCount={activeDownloads}
autoMode={autoMode}
onAutoMode={askAutoMode}
autoModeCount={bulkLimit}
sidebarHidden={sidebarHidden} sidebarHidden={sidebarHidden}
onShowSidebar={() => { setSidebarHidden(false); setSidebarPeek(false); }} onShowSidebar={() => { setSidebarHidden(false); setSidebarPeek(false); }}
titleBarInset={titleBarInset} titleBarInset={titleBarInset}
@@ -612,9 +773,12 @@ export default function App() {
</main> </main>
</div> </div>
{/* The player is deliberately not keyed on the video. Remounting would
build a new stage element, and the stage is what is fullscreen — so
every Next dropped out of fullscreen. It resets its own per-video
state instead. */}
{playing && playingIndex != null && ( {playing && playingIndex != null && (
<Player <Player
key={playing.item.id}
item={playing.item} item={playing.item}
path={playing.path} path={playing.path}
index={playingIndex} index={playingIndex}
@@ -624,6 +788,7 @@ export default function App() {
onSubLang={setSubLang} onSubLang={setSubLang}
subStyle={subStyle} subStyle={subStyle}
onSubStyle={setSubStyle} onSubStyle={setSubStyle}
autoplayNext={autoplayNext}
onOpenChannel={() => { onOpenChannel={() => {
setChannelId(playing.item.channel_id); setChannelId(playing.item.channel_id);
setSearch(""); setSearch("");
@@ -653,6 +818,11 @@ export default function App() {
)} )}
onClose={() => { onClose={() => {
setPlayingIndex(null); setPlayingIndex(null);
try {
localStorage.removeItem("flighttube.playing");
} catch {
/* storage blocked */
}
// Coming back from a video is the natural moment to pick up // Coming back from a video is the natural moment to pick up
// whatever has been posted since. // whatever has been posted since.
if (online && !refreshing) void doRefresh(); if (online && !refreshing) void doRefresh();
@@ -683,6 +853,8 @@ export default function App() {
onSubLang={setSubLang} onSubLang={setSubLang}
hideShorts={hideShorts} hideShorts={hideShorts}
onHideShorts={setHideShorts} onHideShorts={setHideShorts}
autoplayNext={autoplayNext}
onAutoplayNext={setAutoplayNext}
browser={browser} browser={browser}
onBrowser={setBrowser} onBrowser={setBrowser}
onError={setFailure} onError={setFailure}
@@ -690,6 +862,11 @@ export default function App() {
reload(); reload();
say(`Imported ${n} subscription${n === 1 ? "" : "s"}`); say(`Imported ${n} subscription${n === 1 ? "" : "s"}`);
}} }}
onRemovedAll={(n) => {
setChannelId(null);
reload();
say(`Removed ${n} subscription${n === 1 ? "" : "s"}`);
}}
/> />
)} )}
@@ -709,6 +886,85 @@ export default function App() {
</Dialog> </Dialog>
)} )}
{scraped && (
<Dialog
title="Replace your subscriptions?"
onCancel={() => setScraped(null)}
onConfirm={() => {
const list = scraped.channels;
setScraped(null);
importScraped(list)
.then((n) => {
reload();
say(`Imported ${n} subscriptions`);
void doRefresh();
})
.catch((e) => setFailure(String(e)));
}}
confirmLabel="Replace"
destructive={scraped.preview.removed_channels > 0}
wide
>
<p>
Read <b>{scraped.channels.length}</b> channels off YouTube
{scraped.looked_up > 0 && <> ({scraped.looked_up} looked up by hand)</>}.
</p>
<p className="mt-2">
This replaces the list here. <b>{scraped.preview.removed_channels}</b> channel
{scraped.preview.removed_channels === 1 ? "" : "s"} would go, taking{" "}
<b>{scraped.preview.removed_videos}</b> videos and{" "}
<b>{scraped.preview.removed_downloads}</b> downloaded file
{scraped.preview.removed_downloads === 1 ? "" : "s"} with them.
</p>
{scraped.unresolved.length > 0 && (
<p className="mt-2">
<b>{scraped.unresolved.length}</b> could not be identified and are left out:{" "}
<span className="text-slate-500 dark:text-slate-400">
{scraped.unresolved.slice(0, 8).join(", ")}
{scraped.unresolved.length > 8 && ` and ${scraped.unresolved.length - 8} more`}
</span>
. Add those by hand if you want them.
</p>
)}
</Dialog>
)}
{confirmAuto && (
<Dialog
title="Turn on auto mode?"
onCancel={() => setConfirmAuto(null)}
onConfirm={() => {
setConfirmAuto(null);
setAutoMode(true);
say("Auto mode on");
void reconcileAuto();
}}
confirmLabel="Turn on"
destructive={confirmAuto.remove > 0}
>
<p>
FlightTube will keep the newest <b>{bulkLimit}</b> videos of your feed on this
Mac, and check again after every refresh.
</p>
<p className="mt-2">
Right now that means downloading <b>{confirmAuto.fetch}</b> video
{confirmAuto.fetch === 1 ? "" : "s"}
{confirmAuto.remove > 0 ? (
<>
{" "}and <b>deleting {confirmAuto.remove}</b> already downloaded that fall
outside the newest {bulkLimit}
</>
) : null}
. From then on, anything that drops out of the newest {bulkLimit} is deleted to
make room.
</p>
<p className="mt-2">
Videos you saved by hand from the menu bar are left alone those are not part
of a subscription, so auto mode does not manage them.
</p>
</Dialog>
)}
{removing && ( {removing && (
<Dialog <Dialog
title={`Remove ${removing.title}?`} title={`Remove ${removing.title}?`}
@@ -726,7 +982,12 @@ export default function App() {
from disk from disk
</> </>
)} )}
. Your YouTube subscription is not touched; only this app forgets the channel. .
</p>
<p className="mt-2">
The channel then opens on YouTube so you can unsubscribe there too
YouTube has no link that does it on its own, so it is one click on{" "}
<b>Subscribed</b>. Nothing changes there until you do.
</p> </p>
</Dialog> </Dialog>
)} )}
+27
View File
@@ -58,6 +58,33 @@ export const checkYtDlpUpdate = () => invoke<UpdateStatus>("check_yt_dlp_update"
export const updateYtDlp = () => invoke<string>("update_yt_dlp"); export const updateYtDlp = () => invoke<string>("update_yt_dlp");
/** WebVTT sidecars beside a downloaded video, as [language, path] pairs. */ /** WebVTT sidecars beside a downloaded video, as [language, path] pairs. */
export interface ScrapeResult {
channels: Array<{ id: string; title: string; url: string }>;
unresolved: string[];
looked_up: number;
}
/** Reads the subscription list out of a signed-in browser page. */
export const scrapeSubscriptions = () => invoke<ScrapeResult>("scrape_subscriptions");
export const previewScrapedImport = (channels: ScrapeResult["channels"]) =>
invoke<ImportPreview>("preview_scraped_import", { channels });
export const importScraped = (channels: ScrapeResult["channels"]) =>
invoke<number>("import_scraped", { channels });
/** What emptying the subscription list would take with it. */
export const previewRemoveAllSubscriptions = () =>
invoke<ImportPreview>("preview_remove_all_subscriptions");
export const removeAllSubscriptions = () => invoke<number>("remove_all_subscriptions");
/** The menu bar panel's own actions. */
export const traySaveVideo = () => invoke<void>("tray_save_video");
export const showMainWindow = () => invoke<void>("show_main_window");
export const hidePanel = () => invoke<void>("hide_panel");
export const quitApp = () => invoke<void>("quit_app");
/** Adds one channel from any YouTube link. Returns its title. */ /** Adds one channel from any YouTube link. Returns its title. */
export const addChannel = (url: string) => invoke<string>("add_channel", { url }); export const addChannel = (url: string) => invoke<string>("add_channel", { url });
+270 -64
View File
@@ -1,12 +1,23 @@
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { import {
embeddedSubtitles, fetchSubtitles, fileUrl, listSubtitles, openExternal, resolveStream, embeddedSubtitles,
fetchSubtitles,
fileUrl,
listSubtitles,
openExternal,
resolveStream,
savePlayback, savePlayback,
} from "../api"; } from "../api";
import { DEFAULT_SUB_LANG, SUB_FONTS, SUB_PLACES, type FeedItem, type SubStyle } from "../types"; import {
DEFAULT_SUB_LANG,
SUB_FONTS,
SUB_PLACES,
type FeedItem,
type SubStyle,
} from "../types";
import { compactViews, relativeTime, subtitleLabel } from "./format"; import { compactViews, relativeTime, subtitleLabel } from "./format";
import PlayerControls from "./PlayerControls"; import PlayerControls from "./PlayerControls";
import { Badge, BTN, Spinner } from "./ui"; import { Badge, BTN, Dialog, Spinner } from "./ui";
interface Props { interface Props {
item: FeedItem; item: FeedItem;
@@ -27,6 +38,8 @@ interface Props {
onSubStyle: (s: SubStyle) => void; onSubStyle: (s: SubStyle) => void;
/** Leaves the player for this video's channel. */ /** Leaves the player for this video's channel. */
onOpenChannel: () => void; onOpenChannel: () => void;
/** Roll straight on to the next video when this one ends. */
autoplayNext: boolean;
/** Persists a subtitle choice made from the transport bar. */ /** Persists a subtitle choice made from the transport bar. */
onSubLang: (l: string) => void; onSubLang: (l: string) => void;
/** Position in the current feed, for the "3 of 180" readout. */ /** Position in the current feed, for the "3 of 180" readout. */
@@ -37,20 +50,22 @@ interface Props {
} }
/** /**
* Releases a <video> completely. * Ends any Picture-in-Picture session on a <video>.
* *
* Detaching the element is not enough: WebKit keeps a Picture-in-Picture * Detaching the element is not enough: WebKit keeps the session (and its
* session (and its audio) running after the element leaves the DOM, so closing * audio) running after the element leaves the DOM, so stepping to the next
* the player or stepping to the next video would leave the previous one playing * video would leave the previous one playing with no way to stop it.
* with no way to stop it. Every exit path goes through here.
*/ */
function teardown(v: HTMLVideoElement | null) { function releasePiP(v: HTMLVideoElement | null) {
if (!v) return; if (!v) return;
// Safari's PiP is the non-standard presentation-mode API; the spec one is // Safari's PiP is the non-standard presentation-mode API; the spec one is
// tried too, since either may be the live implementation. // tried too, since either may be the live implementation.
const webkit = v as WebkitVideo; const webkit = v as WebkitVideo;
try { try {
if (webkit.webkitPresentationMode && webkit.webkitPresentationMode !== "inline") { if (
webkit.webkitPresentationMode &&
webkit.webkitPresentationMode !== "inline"
) {
webkit.webkitSetPresentationMode?.("inline"); webkit.webkitSetPresentationMode?.("inline");
} }
} catch { } catch {
@@ -66,6 +81,18 @@ function teardown(v: HTMLVideoElement | null) {
} catch { } catch {
/* not supported here */ /* not supported here */
} }
}
/**
* Releases the element for good, on the way out of the player.
*
* Separate from the above because it also leaves fullscreen, and this used to
* run on every change of video: stepping to the next one dropped you out of
* fullscreen every time, which is the opposite of watching one after another.
*/
function teardown(v: HTMLVideoElement | null) {
if (!v) return;
releasePiP(v);
try { try {
if (document.fullscreenElement) void document.exitFullscreen(); if (document.fullscreenElement) void document.exitFullscreen();
} catch { } catch {
@@ -106,6 +133,40 @@ const RESUME_EDGE_S = 5;
* so watching still happens here rather than in a browser. The iframe embed * so watching still happens here rather than in a browser. The iframe embed
* cannot be used: it rejects a `tauri://` origin with "Error 153". * cannot be used: it rejects a `tauri://` origin with "Error 153".
*/ */
/** Web addresses in a YouTube description, which are plain text as it stores them. */
const URL_IN_TEXT = /(https?:\/\/[^\s<>"']+)/g;
/**
* A description with its addresses made clickable.
*
* They open in the real browser: a YouTube link is the one thing in here this
* app has no way to show, and the rest belong to whoever wrote them.
*/
function Linked({ text }: { text: string }) {
return (
<>
{text.split(URL_IN_TEXT).map((part, i) =>
// Not URL_IN_TEXT.test: a global regex carries lastIndex between calls,
// so every other address would come out as plain text.
part.startsWith("http") ? (
// Trailing punctuation is sentence, not address.
<button
key={i}
onClick={() => openExternal(part.replace(/[.,;:!?)\]]+$/, ""))}
title={part}
className="cursor-pointer break-all text-left text-sky-600 underline
underline-offset-2 hover:text-sky-500 dark:text-sky-400"
>
{part}
</button>
) : (
<span key={i}>{part}</span>
),
)}
</>
);
}
/** /**
* Rewrites every cue's settings to one placement. * Rewrites every cue's settings to one placement.
* *
@@ -121,9 +182,24 @@ function placeCues(vtt: string, line: number | null): string {
} }
export default function Player({ export default function Player({
item, path, onClose, onDelete, onPrev, onNext, onDownload, downloading, item,
maxHeight, subLang, onSubLang, subStyle, onSubStyle, onOpenChannel, path,
index, total, titleBarInset, onClose,
onDelete,
onPrev,
onNext,
onDownload,
downloading,
maxHeight,
subLang,
onSubLang,
subStyle,
onSubStyle,
onOpenChannel,
autoplayNext,
index,
total,
titleBarInset,
}: Props) { }: Props) {
const streaming = path === null; const streaming = path === null;
const [src, setSrc] = useState<string | null>(path ? fileUrl(path) : null); const [src, setSrc] = useState<string | null>(path ? fileUrl(path) : null);
@@ -152,6 +228,7 @@ export default function Player({
const [rawTracks, setRawTracks] = useState<Array<[string, string]>>([]); const [rawTracks, setRawTracks] = useState<Array<[string, string]>>([]);
const [tracks, setTracks] = useState<Array<[string, string]>>([]); const [tracks, setTracks] = useState<Array<[string, string]>>([]);
const [fetchingSubs, setFetchingSubs] = useState(false); const [fetchingSubs, setFetchingSubs] = useState(false);
const [showDescription, setShowDescription] = useState(false);
// The language to fetch, which is NOT the preference: turning subtitles on // The language to fetch, which is NOT the preference: turning subtitles on
// from the player's menu moves the preference from "off" to that language, // from the player's menu moves the preference from "off" to that language,
// and refetching then would tear down the tracks — and the stream with them — // and refetching then would tear down the tracks — and the stream with them —
@@ -206,7 +283,8 @@ export default function Player({
// Placement is a WebVTT cue setting, not something CSS can reach, so it is // Placement is a WebVTT cue setting, not something CSS can reach, so it is
// written into the cues themselves. Re-cut whenever the choice changes. // written into the cues themselves. Re-cut whenever the choice changes.
useEffect(() => { useEffect(() => {
const line = SUB_PLACES.find((p) => p.value === subStyle.place)?.line ?? null; const line =
SUB_PLACES.find((p) => p.value === subStyle.place)?.line ?? null;
const urls: string[] = []; const urls: string[] = [];
setTracks( setTracks(
rawTracks.map(([lang, text]) => { rawTracks.map(([lang, text]) => {
@@ -222,6 +300,24 @@ export default function Player({
}; };
}, [rawTracks, subStyle.place]); }, [rawTracks, subStyle.place]);
// What a remount used to clear. Kept in one place so a new video starts as
// clean as it would have, without throwing the stage away to get there.
useEffect(() => {
setError(null);
setBuffering(true);
setShowDescription(false);
}, [item.id]);
// Fullscreen belongs to the stage, which now outlives the video. Whether it
// is on decides whether anything to click is shown at all.
const [fullscreen, setFullscreen] = useState(false);
useEffect(() => {
const sync = () => setFullscreen(!!document.fullscreenElement);
sync();
document.addEventListener("fullscreenchange", sync);
return () => document.removeEventListener("fullscreenchange", sync);
}, []);
// Controls and edge arrows fade away while you are just watching. // Controls and edge arrows fade away while you are just watching.
const [chromeVisible, setChromeVisible] = useState(true); const [chromeVisible, setChromeVisible] = useState(true);
const hideTimer = useRef<number | undefined>(undefined); const hideTimer = useRef<number | undefined>(undefined);
@@ -242,8 +338,11 @@ export default function Player({
return; return;
} }
let cancelled = false; let cancelled = false;
setSrc(null);
setError(null); setError(null);
// Quiet the outgoing video, but leave it in place: clearing the source
// would unmount the element mid-fullscreen.
videoRef.current?.pause();
setBuffering(true);
resolveStream(item.id, maxHeight) resolveStream(item.id, maxHeight)
.then((u) => !cancelled && setSrc(u)) .then((u) => !cancelled && setSrc(u))
.catch((e) => !cancelled && setError(String(e))); .catch((e) => !cancelled && setError(String(e)));
@@ -252,7 +351,6 @@ export default function Player({
}; };
}, [item.id, path, maxHeight]); }, [item.id, path, maxHeight]);
const persist = useCallback(() => { const persist = useCallback(() => {
const v = videoRef.current; const v = videoRef.current;
if (!v || !Number.isFinite(v.duration) || v.duration <= 0) return; if (!v || !Number.isFinite(v.duration) || v.duration <= 0) return;
@@ -268,9 +366,15 @@ export default function Player({
useEffect(() => { useEffect(() => {
const v = videoRef.current; const v = videoRef.current;
return () => teardown(v); return () => releasePiP(v);
}, [src]); }, [src]);
// Leaving the player is the only place the element is released outright.
useEffect(() => {
const v = videoRef.current;
return () => teardown(v);
}, []);
const onTimeUpdate = () => { const onTimeUpdate = () => {
// Frames are flowing, so whatever the media events claimed, we are not // Frames are flowing, so whatever the media events claimed, we are not
// buffering. A resume-seek can fire `waiting` after `playing` and leave the // buffering. A resume-seek can fire `waiting` after `playing` and leave the
@@ -289,7 +393,8 @@ export default function Player({
const v = videoRef.current; const v = videoRef.current;
const at = item.position ?? 0; const at = item.position ?? 0;
if (!v || !Number.isFinite(v.duration)) return; if (!v || !Number.isFinite(v.duration)) return;
if (at > RESUME_EDGE_S && at < v.duration - RESUME_EDGE_S) v.currentTime = at; if (at > RESUME_EDGE_S && at < v.duration - RESUME_EDGE_S)
v.currentTime = at;
}; };
const leave = useCallback(() => onClose(), [onClose]); const leave = useCallback(() => onClose(), [onClose]);
@@ -297,12 +402,22 @@ export default function Player({
// Escape backs out, as it does everywhere else in the app. // Escape backs out, as it does everywhere else in the app.
useEffect(() => { useEffect(() => {
const onKey = (e: KeyboardEvent) => { const onKey = (e: KeyboardEvent) => {
// In fullscreen the browser already handles Escape; closing the player // In fullscreen, Escape is the transport bar's to handle: it leaves
// as well would drop you all the way back to the feed. // fullscreen. Closing the player as well would drop you all the way back
// to the feed in one keypress.
if (e.key === "Escape" && !document.fullscreenElement) leave(); if (e.key === "Escape" && !document.fullscreenElement) leave();
// Arrow keys only when the video does not own them for seeking. // Moving between videos. Shift with the arrows because the bare ones
if (e.key === "ArrowLeft" && e.shiftKey) onPrev?.(); // seek, and shift with N and P because that is what YouTube uses.
if (e.key === "ArrowRight" && e.shiftKey) onNext?.(); if (
e.shiftKey &&
(e.key === "ArrowLeft" || e.key === "P" || e.key === "p")
)
onPrev?.();
if (
e.shiftKey &&
(e.key === "ArrowRight" || e.key === "N" || e.key === "n")
)
onNext?.();
}; };
window.addEventListener("keydown", onKey); window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey);
@@ -347,17 +462,41 @@ export default function Player({
className="flex items-center gap-2 border-b border-slate-200 bg-white px-4 py-3 className="flex items-center gap-2 border-b border-slate-200 bg-white px-4 py-3
dark:border-slate-800 dark:bg-slate-900" dark:border-slate-800 dark:bg-slate-900"
> >
<button onClick={leave} title="Back to the feed (Esc)" aria-label="Back" <button
className={navIcon}> onClick={leave}
<svg viewBox="0 0 24 24" className="size-4" fill="none" stroke="currentColor" strokeWidth="2"> title="Back to the feed (Esc)"
<path strokeLinecap="round" strokeLinejoin="round" d="M15 6l-6 6 6 6" /> aria-label="Back"
className={navIcon}
>
<svg
viewBox="0 0 24 24"
className="size-4"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M15 6l-6 6 6 6"
/>
</svg> </svg>
</button> </button>
<button onClick={onPrev} disabled={!onPrev} title="Previous video" className={navBtn}> <button
onClick={onPrev}
disabled={!onPrev}
title="Previous video"
className={navBtn}
>
Prev Prev
</button> </button>
<button onClick={onNext} disabled={!onNext} title="Next video" className={navBtn}> <button
onClick={onNext}
disabled={!onNext}
title="Next video"
className={navBtn}
>
Next Next
</button> </button>
<span className="font-mono text-[11px] tabular-nums text-slate-400 dark:text-slate-500"> <span className="font-mono text-[11px] tabular-nums text-slate-400 dark:text-slate-500">
@@ -382,7 +521,10 @@ export default function Player({
error ? ( error ? (
<Badge tone="danger">Unavailable</Badge> <Badge tone="danger">Unavailable</Badge>
) : src && !buffering ? ( ) : src && !buffering ? (
<Badge tone="accent" title={`Streaming at ${height || "an unknown"}p`}> <Badge
tone="accent"
title={`Streaming at ${height || "an unknown"}p`}
>
Streaming{height ? ` · ${height}p` : ""} Streaming{height ? ` · ${height}p` : ""}
</Badge> </Badge>
) : ( ) : (
@@ -408,12 +550,19 @@ export default function Player({
ref={stageRef} ref={stageRef}
onContextMenu={(e) => e.preventDefault()} onContextMenu={(e) => e.preventDefault()}
onMouseMove={showChrome} onMouseMove={showChrome}
onMouseLeave={() => !videoRef.current?.paused && setChromeVisible(false)} onMouseLeave={() =>
!videoRef.current?.paused && setChromeVisible(false)
}
className={`relative min-h-0 flex-1 bg-slate-950 ${chromeVisible ? "" : "cursor-none"}`} className={`relative min-h-0 flex-1 bg-slate-950 ${chromeVisible ? "" : "cursor-none"}`}
> >
{/* Edge arrows, the way a player wants them: big targets on the left and {/* Edge arrows, the way a player wants them: big targets on the left and
right of the picture. They fade in on hover so they never sit on top right of the picture. They fade in on hover so they never sit on top
of the video while you are watching it. */} of the video while you are watching it.
Gone entirely in fullscreen. Nothing to navigate with there: it
plays one video after another and shows only the picture. */}
{!fullscreen && (
<>
<button <button
onClick={onPrev} onClick={onPrev}
disabled={!onPrev} disabled={!onPrev}
@@ -432,6 +581,8 @@ export default function Player({
> >
</button> </button>
</>
)}
{/* Resolving the stream and buffering it are the same wait as far as {/* Resolving the stream and buffering it are the same wait as far as
you are concerned, so they get the same spinner in the same place. */} you are concerned, so they get the same spinner in the same place. */}
@@ -444,7 +595,6 @@ export default function Player({
{src ? ( {src ? (
<video <video
ref={videoRef} ref={videoRef}
key={src}
src={src} src={src}
autoPlay autoPlay
onContextMenu={(e) => e.preventDefault()} onContextMenu={(e) => e.preventDefault()}
@@ -457,6 +607,12 @@ export default function Player({
onTimeUpdate={onTimeUpdate} onTimeUpdate={onTimeUpdate}
onLoadedMetadata={onLoadedMetadata} onLoadedMetadata={onLoadedMetadata}
onPause={persist} onPause={persist}
onEnded={() => {
persist();
// Only where there is somewhere to go: the last video in the
// list simply stops.
if (autoplayNext) onNext?.();
}}
onLoadStart={() => setBuffering(true)} onLoadStart={() => setBuffering(true)}
onWaiting={() => setBuffering(true)} onWaiting={() => setBuffering(true)}
onStalled={() => setBuffering(true)} onStalled={() => setBuffering(true)}
@@ -468,7 +624,13 @@ export default function Player({
className="absolute inset-0 size-full object-contain" className="absolute inset-0 size-full object-contain"
> >
{tracks.map(([lang, url]) => ( {tracks.map(([lang, url]) => (
<track key={url} kind="subtitles" srcLang={lang} label={subtitleLabel(lang)} src={url} /> <track
key={url}
kind="subtitles"
srcLang={lang}
label={subtitleLabel(lang)}
src={url}
/>
))} ))}
</video> </video>
) : ( ) : (
@@ -477,7 +639,9 @@ export default function Player({
<div className="max-w-sm"> <div className="max-w-sm">
<p className="text-[13px] text-red-400">{error}</p> <p className="text-[13px] text-red-400">{error}</p>
<button <button
onClick={() => openExternal(`https://www.youtube.com/watch?v=${item.id}`)} onClick={() =>
openExternal(`https://www.youtube.com/watch?v=${item.id}`)
}
className={`${BTN} mt-3 cursor-pointer py-1.5`} className={`${BTN} mt-3 cursor-pointer py-1.5`}
> >
Open on YouTube instead Open on YouTube instead
@@ -511,13 +675,29 @@ export default function Player({
dark:border-slate-800 dark:bg-slate-900" dark:border-slate-800 dark:bg-slate-900"
> >
<div className="flex items-start justify-between gap-3"> <div className="flex items-start justify-between gap-3">
<div className="min-w-0"> {/* Title and figures on one line. The title opens the description
<h2 className="truncate text-[15px] font-semibold tracking-tight">{item.title}</h2> rather than announcing itself as a link: it keeps its colour and
<div className="mt-0.5 text-[11px] text-slate-400 dark:text-slate-500"> stays unadorned, and the pointer says the rest. */}
<div className="flex min-w-0 items-baseline gap-2">
{item.description ? (
<button
onClick={() => setShowDescription(true)}
title="Show the description"
className="min-w-0 cursor-pointer truncate text-left text-[15px] font-semibold
tracking-tight"
>
{item.title}
</button>
) : (
<h2 className="min-w-0 truncate text-[15px] font-semibold tracking-tight">
{item.title}
</h2>
)}
<span className="shrink-0 text-[11px] text-slate-400 dark:text-slate-500">
{[compactViews(item.views), relativeTime(item.published)] {[compactViews(item.views), relativeTime(item.published)]
.filter(Boolean) .filter(Boolean)
.join(" · ")} .join(" · ")}
</div> </span>
</div> </div>
<div className="flex shrink-0 items-center gap-2"> <div className="flex shrink-0 items-center gap-2">
{onDownload && ( {onDownload && (
@@ -531,8 +711,18 @@ export default function Player({
{downloading ? ( {downloading ? (
<Spinner className="size-4" /> <Spinner className="size-4" />
) : ( ) : (
<svg viewBox="0 0 24 24" className="size-4" fill="none" stroke="currentColor" strokeWidth="2"> <svg
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4v12m0 0l-4-4m4 4l4-4M4 20h16" /> viewBox="0 0 24 24"
className="size-4"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M12 4v12m0 0l-4-4m4 4l4-4M4 20h16"
/>
</svg> </svg>
)} )}
</button> </button>
@@ -545,42 +735,58 @@ export default function Player({
className={`${navIcon} hover:border-red-500! hover:text-red-600! className={`${navIcon} hover:border-red-500! hover:text-red-600!
dark:hover:border-red-500! dark:hover:text-red-400!`} dark:hover:border-red-500! dark:hover:text-red-400!`}
> >
<svg viewBox="0 0 24 24" className="size-4" fill="none" stroke="currentColor" strokeWidth="2"> <svg
<path strokeLinecap="round" strokeLinejoin="round" viewBox="0 0 24 24"
d="M4 7h16M9 7V5h6v2M6 7l1 13h10l1-13M10 11v6M14 11v6" /> className="size-4"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M4 7h16M9 7V5h6v2M6 7l1 13h10l1-13M10 11v6M14 11v6"
/>
</svg> </svg>
</button> </button>
)} )}
<button <button
onClick={() => openExternal(`https://www.youtube.com/watch?v=${item.id}`)} onClick={() =>
openExternal(`https://www.youtube.com/watch?v=${item.id}`)
}
title="Open on YouTube" title="Open on YouTube"
aria-label="Open on YouTube" aria-label="Open on YouTube"
className={navIcon} className={navIcon}
> >
<svg viewBox="0 0 24 24" className="size-4" fill="none" stroke="currentColor" strokeWidth="2"> <svg
<path strokeLinecap="round" strokeLinejoin="round" viewBox="0 0 24 24"
d="M14 4h6v6M20 4l-9 9M18 14v5a1 1 0 01-1 1H5a1 1 0 01-1-1V7a1 1 0 011-1h5" /> className="size-4"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M14 4h6v6M20 4l-9 9M18 14v5a1 1 0 01-1 1H5a1 1 0 01-1-1V7a1 1 0 011-1h5"
/>
</svg> </svg>
</button> </button>
</div> </div>
</div> </div>
{/* Collapsed by default — the description is rarely what you came for. */}
{item.description && (
<details className="group mt-2">
<summary
className="cursor-pointer list-none text-[11px] font-medium text-slate-500
hover:text-sky-600 dark:text-slate-400 dark:hover:text-sky-400"
>
<span className="inline-block transition-transform group-open:rotate-90"></span>{" "}
Description
</summary>
<p className="mt-2 whitespace-pre-wrap text-[12.5px] leading-relaxed text-slate-600 dark:text-slate-300">
{item.description}
</p>
</details>
)}
</footer> </footer>
{showDescription && (
<Dialog
title={item.title}
onCancel={() => setShowDescription(false)}
wide
>
<p className="whitespace-pre-wrap text-[12.5px] leading-relaxed">
<Linked text={item.description} />
</p>
</Dialog>
)}
</div> </div>
); );
} }
+202 -35
View File
@@ -71,6 +71,15 @@ function StyleRow<T extends string | number>({
const SKIP_S = 10; const SKIP_S = 10;
/** The speeds shift-comma and shift-full-stop step through, as on YouTube. */
const SPEEDS = [0.25, 0.5, 0.75, 1, 1.25, 1.5, 1.75, 2];
function nearestSpeed(rate: number): number {
return SPEEDS.reduce((best, s) =>
Math.abs(s - rate) < Math.abs(best - rate) ? s : best,
);
}
function clock(seconds: number): string { function clock(seconds: number): string {
if (!Number.isFinite(seconds) || seconds < 0) return "0:00"; if (!Number.isFinite(seconds) || seconds < 0) return "0:00";
const h = Math.floor(seconds / 3600); const h = Math.floor(seconds / 3600);
@@ -100,14 +109,18 @@ export default function PlayerControls({
const [duration, setDuration] = useState(0); const [duration, setDuration] = useState(0);
const [volume, setVolume] = useState(1); const [volume, setVolume] = useState(1);
const [muted, setMuted] = useState(false); const [muted, setMuted] = useState(false);
// Shown only when it is not 1×, since nothing else in the bar would say so.
const [rate, setRate] = useState(1);
const [pip, setPip] = useState(false); const [pip, setPip] = useState(false);
const [full, setFull] = useState(false); const [full, setFull] = useState(false);
const [menu, setMenu] = useState(false); const [menu, setMenu] = useState(false);
const [subs, setSubs] = useState<TextTrack[]>([]); const [subs, setSubs] = useState<TextTrack[]>([]);
const [, bump] = useState(0); const [, bump] = useState(0);
const menuRef = useRef<HTMLDivElement>(null); const menuRef = useRef<HTMLDivElement>(null);
// Which video, and how many tracks, the preference was last applied to. // The track that should be showing, and the set of tracks that choice was
const applied = useRef(""); // made for.
const desired = useRef<TextTrack | null>(null);
const decided = useRef("");
// Tracks arrive with the manifest, after metadata rather than on mount. // Tracks arrive with the manifest, after metadata rather than on mount.
useEffect(() => { useEffect(() => {
@@ -116,37 +129,58 @@ export default function PlayerControls({
const read = () => setSubs(listSubs(v)); const read = () => setSubs(listSubs(v));
read(); read();
// Subtitles follow the language chosen in Settings and nothing else. // Which track should be showing, or null for none. Decided once per set of
// WebKit will otherwise switch on whatever matches the system language, // tracks — from the preference, or from a choice made in the menu — and
// which is the same unwanted auto-selection as a dubbed audio track. // then held.
// Applied once per set of tracks. Re-applying on every poll would undo a //
// choice made in the menu a second after it was made. // Holding it is the point. Deciding once and walking away was not enough:
applied.current = ""; // WebKit switches a newly added track on by itself, following the system's
const applyPreference = () => { // caption settings, and it does so after the track is added. Off would come
const tracks = Array.from(v.textTracks); // back on at the next video, and there was nothing watching to undo it.
decided.current = "";
let corrections = 0;
const enforce = () => {
// A cap, so that if something insists on its own choice the two do not
// sit there flipping it at each other forever.
if (corrections > 24) return;
let changed = false;
for (const t of Array.from(v.textTracks)) {
const want = t === desired.current ? "showing" : "disabled";
if (t.mode !== want) {
t.mode = want;
changed = true;
}
}
if (changed) corrections++;
read();
};
const decide = () => {
// Keyed on the tracks themselves, not their number. Changing where // Keyed on the tracks themselves, not their number. Changing where
// subtitles sit re-cuts the same one track, and a count would not notice // subtitles sit re-cuts the same one track, and a count would not notice.
// — leaving the fresh track disabled and the subtitles gone.
const key = `${v.currentSrc}|${Array.from(v.querySelectorAll("track")) const key = `${v.currentSrc}|${Array.from(v.querySelectorAll("track"))
.map((el) => el.src) .map((el) => el.src)
.join("|")}`; .join("|")}`;
if (key === applied.current) return; if (key !== decided.current) {
applied.current = key; decided.current = key;
const wanted = corrections = 0;
desired.current =
subLang === "off" subLang === "off"
? undefined ? null
: listSubs(v).find((t) => : (listSubs(v).find((t) =>
(t.language || "").toLowerCase().startsWith(subLang.toLowerCase()), (t.language || "").toLowerCase().startsWith(subLang.toLowerCase()),
); ) ?? null);
// Everything else off, in-band tracks included — otherwise the file's own }
// copy renders underneath ours. enforce();
for (const t of tracks) t.mode = t === wanted ? "showing" : "disabled";
read();
}; };
applyPreference();
v.addEventListener("loadedmetadata", applyPreference); decide();
// HLS subtitle renditions arrive after metadata, so re-apply as they land. v.addEventListener("loadedmetadata", decide);
v.textTracks.addEventListener?.("addtrack", applyPreference); // HLS subtitle renditions arrive after metadata, so decide as they land.
v.textTracks.addEventListener?.("addtrack", decide);
// And hold that decision against anything that changes a mode behind us.
v.textTracks.addEventListener?.("change", enforce);
v.addEventListener("loadedmetadata", read); v.addEventListener("loadedmetadata", read);
v.textTracks.addEventListener?.("addtrack", read); v.textTracks.addEventListener?.("addtrack", read);
@@ -154,8 +188,9 @@ export default function PlayerControls({
const id = setInterval(read, 1000); const id = setInterval(read, 1000);
const stop = setTimeout(() => clearInterval(id), 8000); const stop = setTimeout(() => clearInterval(id), 8000);
return () => { return () => {
v.removeEventListener("loadedmetadata", applyPreference); v.removeEventListener("loadedmetadata", decide);
v.textTracks.removeEventListener?.("addtrack", applyPreference); v.textTracks.removeEventListener?.("addtrack", decide);
v.textTracks.removeEventListener?.("change", enforce);
v.removeEventListener("loadedmetadata", read); v.removeEventListener("loadedmetadata", read);
v.textTracks.removeEventListener?.("addtrack", read); v.textTracks.removeEventListener?.("addtrack", read);
clearInterval(id); clearInterval(id);
@@ -176,6 +211,8 @@ export default function PlayerControls({
const chooseSub = (track: TextTrack | null) => { const chooseSub = (track: TextTrack | null) => {
const v = videoRef.current; const v = videoRef.current;
if (!v) return; if (!v) return;
// Recorded first, so the holding above enforces this rather than undoing it.
desired.current = track;
for (const t of Array.from(v.textTracks)) { for (const t of Array.from(v.textTracks)) {
t.mode = t === track ? "showing" : "disabled"; t.mode = t === track ? "showing" : "disabled";
} }
@@ -186,6 +223,23 @@ export default function PlayerControls({
onActivity?.(); onActivity?.();
}; };
/** What `c` does: off if anything is showing, otherwise the best match. */
const toggleSubs = useCallback(() => {
const v = videoRef.current;
if (!v) return;
const available = listSubs(v);
if (available.length === 0) return;
const showing = available.find((t) => t.mode === "showing");
chooseSub(
showing
? null
: (available.find((t) =>
(t.language || "").toLowerCase().startsWith(subLang.toLowerCase()),
) ?? available[0]),
);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [videoRef, subLang]);
// Mirror the element's state rather than assuming ours is authoritative — // Mirror the element's state rather than assuming ours is authoritative —
// playback can change from the keyboard, the system, or the video ending. // playback can change from the keyboard, the system, or the video ending.
useEffect(() => { useEffect(() => {
@@ -197,18 +251,19 @@ export default function PlayerControls({
setDuration(Number.isFinite(v.duration) ? v.duration : 0); setDuration(Number.isFinite(v.duration) ? v.duration : 0);
setVolume(v.volume); setVolume(v.volume);
setMuted(v.muted); setMuted(v.muted);
setRate(v.playbackRate);
}; };
const onPip = () => setPip(!!document.pictureInPictureElement); const onPip = () => setPip(!!document.pictureInPictureElement);
const onFull = () => setFull(!!document.fullscreenElement); const onFull = () => setFull(!!document.fullscreenElement);
sync(); sync();
for (const e of ["play", "pause", "timeupdate", "durationchange", "volumechange", "loadedmetadata", "ended"]) { for (const e of ["play", "pause", "timeupdate", "durationchange", "volumechange", "loadedmetadata", "ended", "ratechange"]) {
v.addEventListener(e, sync); v.addEventListener(e, sync);
} }
v.addEventListener("enterpictureinpicture", onPip); v.addEventListener("enterpictureinpicture", onPip);
v.addEventListener("leavepictureinpicture", onPip); v.addEventListener("leavepictureinpicture", onPip);
document.addEventListener("fullscreenchange", onFull); document.addEventListener("fullscreenchange", onFull);
return () => { return () => {
for (const e of ["play", "pause", "timeupdate", "durationchange", "volumechange", "loadedmetadata", "ended"]) { for (const e of ["play", "pause", "timeupdate", "durationchange", "volumechange", "loadedmetadata", "ended", "ratechange"]) {
v.removeEventListener(e, sync); v.removeEventListener(e, sync);
} }
v.removeEventListener("enterpictureinpicture", onPip); v.removeEventListener("enterpictureinpicture", onPip);
@@ -253,6 +308,104 @@ export default function PlayerControls({
} }
}, [stageRef, onActivity]); }, [stageRef, onActivity]);
/**
* YouTube's keyboard shortcuts, because this is a video player and those are
* the ones fingers already know.
*
* space/k play, j/l jump ten seconds, arrows five, up and down are volume,
* m mutes, f is fullscreen, i is Picture in Picture (YouTube's miniplayer),
* c toggles subtitles, digits jump to that tenth of the video, Home and End
* go to the ends, and shift with comma or full stop changes speed.
*
* Escape leaves fullscreen: WebKit does not do it for an element made
* fullscreen this way, so without this there was no way out but the button.
*/
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
const v = videoRef.current;
if (!v) return;
// Typing is typing. A range input is not: leaving arrows to the slider
// would make them seek by a tenth of a second instead of five.
const t = e.target as HTMLElement | null;
const typing =
t?.isContentEditable ||
t?.tagName === "TEXTAREA" ||
(t?.tagName === "INPUT" &&
!["range", "checkbox", "button", "submit"].includes((t as HTMLInputElement).type));
if (typing) return;
// Leave the system's own combinations alone. Shift is ours: it carries
// the speed controls.
if (e.metaKey || e.ctrlKey || e.altKey) return;
const seek = (to: number) => {
v.currentTime = Math.min(Math.max(to, 0), v.duration || 0);
};
const setVol = (to: number) => {
v.volume = Math.min(Math.max(to, 0), 1);
v.muted = v.volume === 0;
};
const speed = (dir: 1 | -1) => {
const i = SPEEDS.indexOf(nearestSpeed(v.playbackRate));
v.playbackRate = SPEEDS[Math.min(Math.max(i + dir, 0), SPEEDS.length - 1)];
setRate(v.playbackRate);
};
// A digit jumps to that tenth of the way through, as on YouTube.
if (/^[0-9]$/.test(e.key) && !e.shiftKey) {
seek(((v.duration || 0) * Number(e.key)) / 10);
} else if (e.key === " " || e.code === "Space" || e.key === "k" || e.key === "K") {
// Or space would also press whichever button has focus.
e.preventDefault();
if (v.paused) void v.play().catch(() => {});
else v.pause();
} else if (e.key === "j" || e.key === "J") {
seek(v.currentTime - 10);
} else if (e.key === "l" || e.key === "L") {
seek(v.currentTime + 10);
} else if (e.key === "ArrowLeft" && !e.shiftKey) {
e.preventDefault();
seek(v.currentTime - 5);
} else if (e.key === "ArrowRight" && !e.shiftKey) {
e.preventDefault();
seek(v.currentTime + 5);
} else if (e.key === "ArrowUp") {
e.preventDefault();
setVol(v.volume + 0.05);
} else if (e.key === "ArrowDown") {
e.preventDefault();
setVol(v.volume - 0.05);
} else if (e.key === "Home") {
seek(0);
} else if (e.key === "End") {
seek(v.duration || 0);
} else if (e.key === "m" || e.key === "M") {
v.muted = !v.muted;
} else if (e.key === "f" || e.key === "F") {
void toggleFull();
} else if (e.key === "i" || e.key === "I") {
void togglePip();
} else if (e.key === "c" || e.key === "C") {
toggleSubs();
} else if (e.key === "<" || (e.key === "," && e.shiftKey)) {
speed(-1);
} else if (e.key === ">" || (e.key === "." && e.shiftKey)) {
speed(1);
} else if ((e.key === "," || e.key === ".") && v.paused) {
// Frame stepping while paused. A video element will not say what its
// frame rate is, so a frame is taken as a thirtieth of a second.
seek(v.currentTime + (e.key === "." ? 1 / 30 : -1 / 30));
} else if (e.key === "Escape" && document.fullscreenElement) {
void document.exitFullscreen();
} else {
return;
}
onActivity?.();
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [videoRef, toggleFull, togglePip, toggleSubs, onActivity]);
const pct = duration > 0 ? (time / duration) * 100 : 0; const pct = duration > 0 ? (time / duration) * 100 : 0;
return ( return (
@@ -278,7 +431,7 @@ export default function PlayerControls({
<button <button
onClick={act((v) => (v.currentTime = Math.max(0, v.currentTime - SKIP_S)))} onClick={act((v) => (v.currentTime = Math.max(0, v.currentTime - SKIP_S)))}
className={btn} className={btn}
title={`Back ${SKIP_S}s`} title={`Back ${SKIP_S}s (j)`}
> >
<svg viewBox="0 0 24 24" className="size-5" fill="none" stroke="currentColor" strokeWidth="2"> <svg viewBox="0 0 24 24" className="size-5" fill="none" stroke="currentColor" strokeWidth="2">
<path strokeLinecap="round" strokeLinejoin="round" d="M11 17l-5-5 5-5M18 17l-5-5 5-5" /> <path strokeLinecap="round" strokeLinejoin="round" d="M11 17l-5-5 5-5M18 17l-5-5 5-5" />
@@ -287,13 +440,27 @@ export default function PlayerControls({
<button <button
onClick={act((v) => (v.currentTime = Math.min(v.duration || 0, v.currentTime + SKIP_S)))} onClick={act((v) => (v.currentTime = Math.min(v.duration || 0, v.currentTime + SKIP_S)))}
className={btn} className={btn}
title={`Forward ${SKIP_S}s`} title={`Forward ${SKIP_S}s (l)`}
> >
<svg viewBox="0 0 24 24" className="size-5" fill="none" stroke="currentColor" strokeWidth="2"> <svg viewBox="0 0 24 24" className="size-5" fill="none" stroke="currentColor" strokeWidth="2">
<path strokeLinecap="round" strokeLinejoin="round" d="M13 7l5 5-5 5M6 7l5 5-5 5" /> <path strokeLinecap="round" strokeLinejoin="round" d="M13 7l5 5-5 5M6 7l5 5-5 5" />
</svg> </svg>
</button> </button>
{rate !== 1 && (
<button
onClick={act((v) => {
v.playbackRate = 1;
setRate(1);
})}
title="Back to normal speed"
className="shrink-0 cursor-pointer rounded bg-white/15 px-1.5 py-0.5 text-[11px]
font-medium text-white/90 hover:bg-white/25"
>
{rate}×
</button>
)}
<span className="shrink-0 font-mono text-[12px] tabular-nums text-white/85">{clock(time)}</span> <span className="shrink-0 font-mono text-[12px] tabular-nums text-white/85">{clock(time)}</span>
<input <input
@@ -319,7 +486,7 @@ export default function PlayerControls({
<button <button
onClick={act((v) => (v.muted = !v.muted))} onClick={act((v) => (v.muted = !v.muted))}
className={btn} className={btn}
title={muted || volume === 0 ? "Unmute" : "Mute"} title={muted || volume === 0 ? "Unmute (m)" : "Mute (m)"}
> >
<svg viewBox="0 0 24 24" className="size-5" fill="currentColor"> <svg viewBox="0 0 24 24" className="size-5" fill="currentColor">
<path d="M4 9v6h4l5 4V5L8 9H4z" /> <path d="M4 9v6h4l5 4V5L8 9H4z" />
@@ -363,7 +530,7 @@ export default function PlayerControls({
<button <button
onClick={() => { setMenu((m) => !m); onActivity?.(); }} onClick={() => { setMenu((m) => !m); onActivity?.(); }}
className={btn} className={btn}
title="Subtitles" title="Subtitles (c)"
> >
<svg viewBox="0 0 24 24" className="size-5" fill="none" stroke="currentColor" strokeWidth="2"> <svg viewBox="0 0 24 24" className="size-5" fill="none" stroke="currentColor" strokeWidth="2">
<rect x="3" y="5" width="18" height="14" rx="2" /> <rect x="3" y="5" width="18" height="14" rx="2" />
@@ -456,7 +623,7 @@ export default function PlayerControls({
</div> </div>
)} )}
<button onClick={togglePip} className={btn} title={pip ? "Leave Picture in Picture" : "Picture in Picture"}> <button onClick={togglePip} className={btn} title={pip ? "Leave Picture in Picture (i)" : "Picture in Picture (i)"}>
<svg viewBox="0 0 24 24" className="size-5" fill="none" stroke="currentColor" strokeWidth="2"> <svg viewBox="0 0 24 24" className="size-5" fill="none" stroke="currentColor" strokeWidth="2">
<rect x="3" y="5" width="18" height="14" rx="2" /> <rect x="3" y="5" width="18" height="14" rx="2" />
<rect x="12" y="11" width="7" height="6" rx="1" fill="currentColor" stroke="none" /> <rect x="12" y="11" width="7" height="6" rx="1" fill="currentColor" stroke="none" />
+65 -2
View File
@@ -1,7 +1,8 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { import {
checkPrereqs, checkYtDlpUpdate, importTakeoutCsv, listBrowsers, pickLibraryFolder, checkPrereqs, checkYtDlpUpdate, importTakeoutCsv, listBrowsers, pickLibraryFolder,
pickTakeoutFile, previewTakeoutImport, setCookieSource, testYoutube, updateYtDlp, pickTakeoutFile, previewRemoveAllSubscriptions, previewTakeoutImport,
removeAllSubscriptions, setCookieSource, testYoutube, updateYtDlp,
} from "../api"; } from "../api";
import { APPEARANCE_MODES, type Appearance } from "../hooks/useAppearance"; import { APPEARANCE_MODES, type Appearance } from "../hooks/useAppearance";
import { import {
@@ -18,6 +19,7 @@ import {
interface Props { interface Props {
onClose: () => void; onClose: () => void;
onImported: (count: number) => void; onImported: (count: number) => void;
onRemovedAll: (count: number) => void;
appearance: Appearance; appearance: Appearance;
onAppearance: (a: Appearance) => void; onAppearance: (a: Appearance) => void;
quality: Quality; quality: Quality;
@@ -30,6 +32,8 @@ interface Props {
onSubLang: (l: string) => void; onSubLang: (l: string) => void;
hideShorts: boolean; hideShorts: boolean;
onHideShorts: (v: boolean) => void; onHideShorts: (v: boolean) => void;
autoplayNext: boolean;
onAutoplayNext: (v: boolean) => void;
browser: string; browser: string;
onBrowser: (b: string) => void; onBrowser: (b: string) => void;
onError: (message: string) => void; onError: (message: string) => void;
@@ -55,13 +59,16 @@ function StatusRow({ label, value }: { label: string; value: string | null }) {
} }
export default function Settings({ export default function Settings({
onClose, onImported, appearance, onAppearance, quality, onQuality, onClose, onImported, onRemovedAll, appearance, onAppearance, quality, onQuality,
bulkLimit, onBulkLimit, bulkLimit, onBulkLimit,
streamQuality, onStreamQuality, subLang, onSubLang, hideShorts, onHideShorts, streamQuality, onStreamQuality, subLang, onSubLang, hideShorts, onHideShorts,
autoplayNext, onAutoplayNext,
browser, onBrowser, onError, browser, onBrowser, onError,
}: Props) { }: Props) {
const [prereqs, setPrereqs] = useState<Prereqs | null>(null); const [prereqs, setPrereqs] = useState<Prereqs | null>(null);
const [pending, setPending] = useState<{ path: string; preview: ImportPreview } | null>(null); const [pending, setPending] = useState<{ path: string; preview: ImportPreview } | null>(null);
// Emptying the list is confirmed against what it would actually remove.
const [wipe, setWipe] = useState<ImportPreview | null>(null);
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [browsers, setBrowsers] = useState<Array<[string, string]>>([]); const [browsers, setBrowsers] = useState<Array<[string, string]>>([]);
const [check, setCheck] = useState<{ ok: boolean; message: string } | null>(null); const [check, setCheck] = useState<{ ok: boolean; message: string } | null>(null);
@@ -196,6 +203,17 @@ export default function Settings({
<button onClick={() => setGuide(true)} className={`${BTN} cursor-pointer`}> <button onClick={() => setGuide(true)} className={`${BTN} cursor-pointer`}>
How do I get the file? How do I get the file?
</button> </button>
<button
onClick={() =>
previewRemoveAllSubscriptions()
.then(setWipe)
.catch((e) => onError(String(e)))
}
className={`${BTN} cursor-pointer hover:border-red-500! hover:text-red-600!
dark:hover:border-red-500! dark:hover:text-red-400!`}
>
Remove all
</button>
</div> </div>
</section> </section>
@@ -301,6 +319,24 @@ export default function Settings({
</p> </p>
</section> </section>
<section className="border-b border-slate-200 px-5 py-4 dark:border-slate-800">
<SectionHeading>Playback</SectionHeading>
<label className="mt-2 flex cursor-pointer items-center justify-between gap-3">
<span className={LABEL}>Play next automatically</span>
<input
type="checkbox"
checked={autoplayNext}
onChange={(e) => onAutoplayNext(e.target.checked)}
className="size-4 cursor-pointer accent-sky-500"
/>
</label>
<p className={`mt-2 ${HELP}`}>
When a video ends, the next one in the list starts on its own. Meant for
offline viewing, where it plays through your downloads one after another;
online it will stream the next video, and it stops at the end of the list.
</p>
</section>
<section className="border-b border-slate-200 px-5 py-4 dark:border-slate-800"> <section className="border-b border-slate-200 px-5 py-4 dark:border-slate-800">
<SectionHeading>Sign in to YouTube</SectionHeading> <SectionHeading>Sign in to YouTube</SectionHeading>
<p className={`mt-1.5 ${HELP}`}> <p className={`mt-1.5 ${HELP}`}>
@@ -418,6 +454,33 @@ export default function Settings({
</div> </div>
</div> </div>
{wipe && (
<Dialog
title="Remove every subscription?"
onCancel={() => setWipe(null)}
onConfirm={() => {
setWipe(null);
setBusy(true);
removeAllSubscriptions()
.then(onRemovedAll)
.catch((e) => onError(String(e)))
.finally(() => setBusy(false));
}}
confirmLabel="Remove all"
destructive
>
<p>
All <b>{wipe.removed_channels}</b> channels leave FlightTube, taking{" "}
<b>{wipe.removed_videos}</b> videos and <b>{wipe.removed_downloads}</b> downloaded
file{wipe.removed_downloads === 1 ? "" : "s"} with them.
</p>
<p className="mt-2">
Nothing changes on YouTube you stay subscribed there, and importing or reading
the list again brings everything back.
</p>
</Dialog>
)}
{guide && ( {guide && (
<Dialog title="Getting your subscriptions" onCancel={() => setGuide(false)} wide> <Dialog title="Getting your subscriptions" onCancel={() => setGuide(false)} wide>
<p className={`mb-3 ${HELP}`}> <p className={`mb-3 ${HELP}`}>
+33 -1
View File
@@ -26,6 +26,10 @@ interface Props {
/** Present only while something is downloading or waiting to. */ /** Present only while something is downloading or waiting to. */
onStopAll?: () => void; onStopAll?: () => void;
stopAllCount?: number; stopAllCount?: number;
autoMode: boolean;
onAutoMode: () => void;
/** How many videos auto mode keeps, for the tooltip. */
autoModeCount: number;
/** How many this press would queue, and how many are listed in all. */ /** How many this press would queue, and how many are listed in all. */
downloadAllCount?: number; downloadAllCount?: number;
downloadAllTotal?: number; downloadAllTotal?: number;
@@ -67,7 +71,8 @@ export default function TopBar({
online, reachable, forcedOffline, onToggleForcedOffline, online, reachable, forcedOffline, onToggleForcedOffline,
onRefresh, refreshing, refreshProgress, resultCount, view, onView, onRefresh, refreshing, refreshProgress, resultCount, view, onView,
sidebarHidden, onShowSidebar, onDeleteAll, onDownloadAll, downloadAllCount = 0, sidebarHidden, onShowSidebar, onDeleteAll, onDownloadAll, downloadAllCount = 0,
downloadAllTotal = 0, onStopAll, stopAllCount = 0, titleBarInset, downloadAllTotal = 0, onStopAll, stopAllCount = 0,
autoMode, onAutoMode, autoModeCount, titleBarInset,
}: Props) { }: Props) {
const pct = refreshProgress && refreshProgress.total > 0 const pct = refreshProgress && refreshProgress.total > 0
? (refreshProgress.done / refreshProgress.total) * 100 ? (refreshProgress.done / refreshProgress.total) * 100
@@ -225,6 +230,33 @@ export default function TopBar({
{online ? "Online" : forcedOffline ? "Offline (forced)" : "Offline"} {online ? "Online" : forcedOffline ? "Offline (forced)" : "Offline"}
</button> </button>
<button
onClick={onAutoMode}
title={
autoMode
? `Auto mode is on — keeping the newest ${autoModeCount} videos on this Mac. Click to stop.`
: "Auto mode: keep the newest videos downloaded automatically"
}
aria-label="Auto mode"
aria-pressed={autoMode}
className={
`${ICON_BTN} border ` +
(autoMode
? "border-sky-500 bg-sky-500 text-white hover:bg-sky-400"
: "border-slate-300 text-slate-500 hover:border-sky-500 hover:text-sky-600 " +
"dark:border-slate-700 dark:text-slate-400 dark:hover:border-sky-500 " +
"dark:hover:text-sky-400")
}
>
{/* A download inside a cycle: it fetches, and it keeps doing it. */}
<svg viewBox="0 0 24 24" className="size-4" fill="none" stroke="currentColor"
strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
<path d="M20.5 12a8.5 8.5 0 01-14.6 5.9M3.5 12a8.5 8.5 0 0114.6-5.9" />
<path d="M18.1 2.5v3.6h-3.6M5.9 21.5v-3.6h3.6" />
<path d="M12 8.5v5m0 0l-2-2m2 2l2-2" />
</svg>
</button>
<button onClick={onRefresh} disabled={refreshing || !online} <button onClick={onRefresh} disabled={refreshing || !online}
title={ title={
online online
+157
View File
@@ -0,0 +1,157 @@
import { useEffect, useState } from "react";
import { emitTo } from "@tauri-apps/api/event";
import {
hidePanel,
quitApp,
scrapeSubscriptions,
showMainWindow,
traySaveVideo,
} from "../api";
import { Spinner } from "./ui";
/**
* The menu bar panel.
*
* A window rather than a native menu, so it is the app's own type, spacing and
* colours instead of the system's. It hangs from the icon and closes when it
* loses focus, which is what a menu does; everything else about it is ours.
*/
interface RowProps {
label: string;
hint?: string;
onClick: () => void;
busy?: boolean;
icon: React.ReactNode;
}
function Row({ label, hint, onClick, busy, icon }: RowProps) {
return (
<button
onClick={onClick}
disabled={busy}
className="flex w-full cursor-pointer items-center gap-2.5 rounded-lg px-2 py-1.5 text-left
transition-colors hover:bg-slate-100 disabled:cursor-wait
dark:hover:bg-slate-800"
>
<span className="grid size-6 shrink-0 place-items-center text-slate-400 dark:text-slate-500">
{busy ? <Spinner className="size-3.5" /> : icon}
</span>
<span className="min-w-0">
<span className="block truncate text-[12.5px] font-medium text-slate-700 dark:text-slate-200">
{label}
</span>
{hint && (
<span className="block truncate text-[11px] text-slate-400 dark:text-slate-500">
{hint}
</span>
)}
</span>
</button>
);
}
const ICON = "size-4";
const stroke = { fill: "none", stroke: "currentColor", strokeWidth: 1.8 } as const;
export default function TrayPanel() {
const [busy, setBusy] = useState<string | null>(null);
const [note, setNote] = useState<string | null>(null);
// The panel is its own window, so it carries no page background of the app's.
useEffect(() => {
document.documentElement.style.background = "transparent";
document.body.style.background = "transparent";
}, []);
const run = (id: string, fn: () => Promise<unknown>, closeAfter = true) => {
setBusy(id);
setNote(null);
fn()
.then(() => {
if (closeAfter) void hidePanel();
})
.catch((e) => setNote(String(e)))
.finally(() => setBusy(null));
};
const importSubscriptions = () =>
run(
"subs",
async () => {
const result = await scrapeSubscriptions();
// The window owns the confirmation: replacing the list is not a thing
// to agree to in a panel that closes when you look away.
await emitTo("main", "subs:scraped", result);
await showMainWindow();
},
false,
);
return (
<div
className="flex h-screen w-screen flex-col rounded-xl border border-slate-300 bg-white/95
p-1.5 shadow-2xl backdrop-blur-xl dark:border-slate-700 dark:bg-slate-900/95"
>
<div className="px-2 pb-1 pt-1.5 text-[10px] font-bold uppercase tracking-widest text-slate-400">
FlightTube
</div>
<Row
label="Download this video"
hint="From the browser it is playing in"
busy={busy === "video"}
onClick={() => run("video", traySaveVideo)}
icon={
<svg viewBox="0 0 24 24" className={ICON} {...stroke} strokeLinecap="round" strokeLinejoin="round">
<path d="M12 4v10.5M7.5 10l4.5 4.5 4.5-4.5M4 18.5v1A2.5 2.5 0 006.5 22h11a2.5 2.5 0 002.5-2.5v-1" />
</svg>
}
/>
<Row
label="Import subscriptions"
hint="Read them off your YouTube page"
busy={busy === "subs"}
onClick={importSubscriptions}
icon={
<svg viewBox="0 0 24 24" className={ICON} {...stroke} strokeLinecap="round" strokeLinejoin="round">
<path d="M4 7h9M4 12h9M4 17h5" />
<path d="M17 9v8m0 0l-2.5-2.5M17 17l2.5-2.5" />
</svg>
}
/>
<div className="my-1 h-px bg-slate-200 dark:bg-slate-800" />
<Row
label="Open FlightTube"
onClick={() => run("open", async () => showMainWindow())}
icon={
<svg viewBox="0 0 24 24" className={ICON} {...stroke} strokeLinecap="round" strokeLinejoin="round">
<rect x="3.5" y="5" width="17" height="14" rx="2" />
<path d="M9 5v14" />
</svg>
}
/>
<Row
label="Quit"
onClick={() => void quitApp()}
icon={
<svg viewBox="0 0 24 24" className={ICON} {...stroke} strokeLinecap="round" strokeLinejoin="round">
<path d="M15 5h3a2 2 0 012 2v10a2 2 0 01-2 2h-3M10 12H3m0 0l3.5-3.5M3 12l3.5 3.5" />
</svg>
}
/>
{note && (
<p
className="mt-1 max-h-16 overflow-y-auto rounded-lg bg-red-500/10 px-2 py-1.5 text-[11px]
leading-snug text-red-700 dark:text-red-300"
>
{note}
</p>
)}
</div>
);
}
+6 -3
View File
@@ -1,6 +1,7 @@
import React from "react"; import React from "react";
import ReactDOM from "react-dom/client"; import ReactDOM from "react-dom/client";
import App from "./App"; import App from "./App";
import TrayPanel from "./components/TrayPanel";
import "./index.css"; import "./index.css";
// The webview's own context menu offers Reload, Back and Inspect — page // The webview's own context menu offers Reload, Back and Inspect — page
@@ -14,8 +15,10 @@ document.addEventListener("contextmenu", (e) => {
if (!editable) e.preventDefault(); if (!editable) e.preventDefault();
}); });
// The menu bar panel is a second window on the same bundle, told apart by
// the hash it is opened with.
const panel = window.location.hash === "#tray";
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
<React.StrictMode> <React.StrictMode>{panel ? <TrayPanel /> : <App />}</React.StrictMode>,
<App />
</React.StrictMode>,
); );