Compare commits

...
10 Commits
Author SHA1 Message Date
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
18 changed files with 1244 additions and 170 deletions
+9
View File
@@ -32,3 +32,12 @@ dist-ssr
src-tauri/binaries/
src-tauri/resources/python/
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 = [
"cfg-if",
"cpufeatures 0.3.1",
"rand_core",
"rand_core 0.10.1",
]
[[package]]
@@ -1156,6 +1156,7 @@ dependencies = [
"tauri",
"tauri-build",
"tauri-plugin-dialog",
"tauri-plugin-notification",
"tauri-plugin-opener",
"tokio",
]
@@ -1459,7 +1460,7 @@ dependencies = [
"js-sys",
"libc",
"r-efi 6.0.0",
"rand_core",
"rand_core 0.10.1",
"wasm-bindgen",
]
@@ -2327,6 +2328,20 @@ version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
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]]
name = "markup5ever"
version = "0.38.0"
@@ -2451,6 +2466,20 @@ version = "1.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
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]]
name = "num-conv"
version = "0.2.2"
@@ -2965,6 +2994,15 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
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]]
name = "precomputed-hash"
version = "0.1.1"
@@ -3087,7 +3125,7 @@ dependencies = [
"bytes",
"getrandom 0.4.3",
"lru-slab",
"rand",
"rand 0.10.2",
"rand_pcg",
"ring",
"rustc-hash",
@@ -3111,7 +3149,7 @@ dependencies = [
"once_cell",
"socket2",
"tracing",
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -3135,6 +3173,16 @@ version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
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]]
name = "rand"
version = "0.10.2"
@@ -3143,7 +3191,26 @@ checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80"
dependencies = [
"chacha20",
"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]]
@@ -3158,7 +3225,7 @@ version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a"
dependencies = [
"rand_core",
"rand_core 0.10.1",
]
[[package]]
@@ -3424,7 +3491,7 @@ dependencies = [
"security-framework",
"security-framework-sys",
"webpki-root-certs",
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -4228,6 +4295,25 @@ dependencies = [
"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]]
name = "tauri-plugin-opener"
version = "2.5.4"
@@ -4350,6 +4436,17 @@ dependencies = [
"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]]
name = "tempfile"
version = "3.27.0"
@@ -5772,6 +5869,26 @@ dependencies = [
"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]]
name = "zerofrom"
version = "0.1.8"
+1
View File
@@ -29,6 +29,7 @@ chrono = "0.4.45"
tokio = { version = "1.53.1", features = ["full"] }
futures = "0.3.34"
tauri-plugin-dialog = "2.7.2"
tauri-plugin-notification = "2"
reqwest = { version = "0.13.4", default-features = false, features = ["rustls", "http2", "charset", "stream", "gzip"] }
[target.'cfg(target_os = "macos")'.dependencies]
+2 -1
View File
@@ -10,6 +10,7 @@
"opener:default",
"dialog:default",
"core:window:allow-start-dragging",
"core:window:allow-is-fullscreen"
"core:window:allow-is-fullscreen",
"notification:default"
]
}
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","local":true,"windows":["main"],"permissions":["core:default","opener:default","dialog:default","core:window:allow-start-dragging","core:window:allow-is-fullscreen","notification:default"]}}
+198
View File
@@ -2426,6 +2426,204 @@
"const": "dialog:deny-save",
"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`",
"type": "string",
+198
View File
@@ -2426,6 +2426,204 @@
"const": "dialog:deny-save",
"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`",
"type": "string",
+1 -1
View File
@@ -1696,7 +1696,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());
};
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."));
}
+41 -10
View File
@@ -288,8 +288,15 @@ impl Db {
let mut stmt = tx
.prepare(
"INSERT INTO channels (id, title, url, added_at) VALUES (?1, ?2, ?3, ?4)
ON CONFLICT(id) DO UPDATE SET title=excluded.title, url=excluded.url",
"INSERT INTO channels (id, title, url, added_at, subscribed)
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())?;
let ts = now();
@@ -349,10 +356,15 @@ impl Db {
tx.commit().map_err(|e| e.to_string())
}
/// True when a channel is already subscribed.
pub fn has_channel(&self, id: &str) -> Result<bool, String> {
/// True when a channel is already a subscription. A bare row saved for a
/// 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
.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)
.or_else(|e| match e {
rusqlite::Error::QueryReturnedNoRows => Ok(false),
@@ -366,8 +378,15 @@ impl Db {
{
let mut stmt = tx
.prepare(
"INSERT INTO channels (id, title, url, added_at) VALUES (?1, ?2, ?3, ?4)
ON CONFLICT(id) DO UPDATE SET title=excluded.title, url=excluded.url",
"INSERT INTO channels (id, title, url, added_at, subscribed)
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())?;
let ts = now();
@@ -1167,7 +1186,7 @@ mod tests {
db.ensure_channel(&one_off).unwrap();
// It exists as a parent for the video, but it is not in the sidebar and
// 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.channel_ids().unwrap().contains(&"UCX".to_string()));
@@ -1178,7 +1197,19 @@ mod tests {
url: "https://youtube.com/channel/UC1".into(),
}];
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]
@@ -1191,7 +1222,7 @@ mod tests {
assert_eq!(paths, vec!["/tmp/a.mp4".to_string()]);
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"));
}
+1
View File
@@ -84,6 +84,7 @@ pub fn run() {
.menu(build_menu)
.plugin(tauri_plugin_opener::init())
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_notification::init())
.on_menu_event(tray::on_menu_event)
.setup(|app| {
let state = commands::build_state(&app.handle().clone())?;
+166 -80
View File
@@ -10,13 +10,52 @@
use crate::commands::{self, AppState};
use crate::resolve;
use std::io::Write;
use tauri::menu::{Menu, MenuEvent, MenuItem, PredefinedMenuItem};
use tauri::tray::TrayIconBuilder;
use tauri::{AppHandle, Emitter, Manager};
use tauri_plugin_notification::NotificationExt;
pub const SAVE_VIDEO: &str = "tray.save_video";
pub const ADD_CHANNEL: &str = "tray.add_channel";
pub const SHOW: &str = "tray.show";
pub const STATUS: &str = "tray.status";
/// 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 menu's own first line, kept so its text can be rewritten. A TrayIcon
/// does not hand its menu back, so the item is held here.
pub struct StatusItem(pub MenuItem<tauri::Wry>);
/// 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>);
/// Leaves the answer on the menu itself, which no permission can suppress.
fn set_status(app: &AppHandle, text: &str) {
if let Some(item) = app.try_state::<StatusItem>() {
let _ = item.0.set_text(text);
}
}
/// Browsers worth asking, in the order they are asked. All but Safari answer
/// the same Chromium-flavoured AppleScript.
@@ -29,71 +68,94 @@ const CHROMIUM: [&str; 6] = [
"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.
///
/// Only browsers that are already running are asked, so nothing is launched to
/// answer the question, and every lookup is wrapped in `try` — a browser with
/// no window open must not turn into an error.
fn browser_url_script() -> String {
let mut s = String::from(
r#"set found to ""
set apps to {}
try
tell application "System Events" to set apps to name of every process
end try
"#,
);
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
"#
));
/// Each browser gets its own script, and that is the point rather than a
/// tidiness choice. 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. One script
/// covering six browsers therefore failed outright on a Mac missing any one of
/// them, and the first, working browser was never asked.
///
/// `running of application` needs no dictionary, so it answers even for a
/// browser that is not installed, and launches nothing.
async fn browser_youtube_url(app: &AppHandle) -> Result<String, String> {
let mut refused = false;
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 stdout = String::from_utf8_lossy(&out.stdout).trim().to_string();
let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string();
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") {
refused = true;
}
if resolve::is_youtube_url(&stdout) {
return Ok(stdout);
}
}
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
if refused {
return Err("FlightTube needs permission to read your browser's address. \
Allow it under System Settings Privacy & Security Automation."
.into());
}
Err("No YouTube page open in a browser.".into())
}
async fn browser_youtube_url() -> Result<String, String> {
let out = tokio::process::Command::new("/usr/bin/osascript")
.arg("-e")
.arg(browser_url_script())
.output()
.await
.map_err(|e| format!("Could not ask the browser: {e}"))?;
/// The answer, delivered without the window coming forward.
///
/// Three ways, because the first two can fail silently: a notification from the
/// app itself, the same through AppleScript if the plugin is unavailable, and
/// the menu's own first line, which always survives.
async fn notify(app: &AppHandle, text: &str) {
log(app, text);
set_status(app, text);
let stderr = String::from_utf8_lossy(&out.stderr);
// -1743 is macOS refusing the Apple event because the permission has not
// been granted. Saying so beats "nothing found".
if stderr.contains("-1743") || stderr.contains("Not authorized") {
return Err(
"FlightTube needs permission to read your browser's address. \
Allow it under System Settings Privacy & Security Automation."
.into(),
);
if app
.notification()
.builder()
.title("FlightTube")
.body(text)
.show()
.is_ok()
{
return;
}
let url = String::from_utf8_lossy(&out.stdout).trim().to_string();
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.
async fn notify(text: &str) {
let script = format!(
"display notification {} with title \"FlightTube\"",
applescript_string(text)
@@ -112,46 +174,50 @@ fn applescript_string(s: &str) -> String {
}
async fn handle_save_video(app: AppHandle) {
let url = match browser_youtube_url().await {
let app = &app;
log(app, "menu: save video");
let url = match browser_youtube_url(app).await {
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() {
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 (video_id, title) = match commands::save_video(&state, &url).await {
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 _ = 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.
let handle = app.clone();
tauri::async_runtime::spawn(async move {
let state = handle.state::<AppState>();
match commands::download_video(video_id, quality, sub_lang, handle.clone(), state).await {
Ok(()) => notify(&format!("Saved {title}")).await,
Err(e) => notify(&format!("{title} failed: {e}")).await,
Ok(()) => notify(&handle, &format!("Saved {title}")).await,
Err(e) => notify(&handle, &format!("{title} failed: {e}")).await,
}
});
}
async fn handle_add_channel(app: AppHandle) {
let url = match browser_youtube_url().await {
let app = &app;
log(app, "menu: add channel");
let url = match browser_youtube_url(app).await {
Ok(u) => u,
Err(e) => return notify(&e).await,
Err(e) => return notify(app, &e).await,
};
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;
notify(app, &format!("Subscribed to {title}")).await;
}
Err(e) => notify(&e).await,
Err(e) => notify(app, &e).await,
}
}
@@ -178,9 +244,13 @@ pub fn on_menu_event(app: &AppHandle, event: MenuEvent) {
}
pub fn build(app: &AppHandle) -> tauri::Result<()> {
// Disabled: it reports, it does not act.
let status = MenuItem::with_id(app, STATUS, "FlightTube", false, None::<&str>)?;
let menu = Menu::with_items(
app,
&[
&status,
&PredefinedMenuItem::separator(app)?,
&MenuItem::with_id(app, SAVE_VIDEO, "Download the video I'm watching", true, None::<&str>)?,
&MenuItem::with_id(app, ADD_CHANNEL, "Add the channel I'm watching", true, None::<&str>)?,
&PredefinedMenuItem::separator(app)?,
@@ -192,7 +262,7 @@ pub fn build(app: &AppHandle) -> tauri::Result<()> {
let icon = tauri::image::Image::from_bytes(include_bytes!("../icons/tray.png"))?;
TrayIconBuilder::with_id("flighttube")
let tray = TrayIconBuilder::with_id("flighttube")
.icon(icon)
// A template image takes the menu bar's own colour, light or dark.
.icon_as_template(true)
@@ -201,6 +271,13 @@ pub fn build(app: &AppHandle) -> tauri::Result<()> {
// The menu is the whole point; a left click should open it too.
.show_menu_on_left_click(true)
.build(app)?;
// TrayIcon is reference-counted and "the icon is removed when the last
// instance is dropped" — letting the handle fall out of scope here created
// the item and destroyed it in the same breath, which is exactly as
// invisible as never creating it.
app.manage(Tray(tray));
app.manage(StatusItem(status));
Ok(())
}
@@ -209,17 +286,26 @@ mod tests {
use super::*;
#[test]
fn the_script_asks_every_browser_and_launches_none() {
let s = browser_url_script();
for b in CHROMIUM {
assert!(s.contains(&format!("apps contains \"{b}\"")), "missing {b}");
fn each_browser_gets_its_own_script() {
let arc = url_script("Arc", false);
// Naming a browser that is not installed is a compile error, so a
// 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"));
// Guarded by the running-process list, so asking cannot start a browser.
assert_eq!(s.matches("apps contains").count(), CHROMIUM.len() + 1);
// Every lookup is inside a try, so a browser with no windows is not an
// error: one per browser, one for Safari, one for the process list.
assert_eq!(s.matches("end try").count(), CHROMIUM.len() + 2);
assert!(!arc.contains("Safari"));
// Behind a running check, so asking cannot start a browser, and inside
// a try, so a browser with no window is not an error.
assert!(arc.contains(r#"running of application "Arc""#));
assert!(arc.contains("end try"));
// 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]
+1 -1
View File
@@ -36,7 +36,7 @@
},
"bundle": {
"active": true,
"targets": "all",
"targets": ["app"],
"icon": [
"icons/32x32.png",
"icons/128x128.png",
+162 -4
View File
@@ -2,7 +2,8 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { listen } from "@tauri-apps/api/event";
import {
cancelAllDownloads, cancelDownload, deleteAllDownloads, deleteChannel,
deleteDownload, downloadVideo, interruptedDownloads, previewDeleteChannel,
deleteDownload, downloadVideo, interruptedDownloads, listFeed, openExternal,
previewDeleteChannel,
setDownloadDefaults, type RemovalPreview,
fetchDurations, onRefreshProgress, refreshFeeds, setCookieSource,
} from "./api";
@@ -49,6 +50,9 @@ export default function App() {
const [search, setSearch] = useState("");
const [downloadedOnly, setDownloadedOnly] = useState(() => remembered("downloadedOnly"));
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);
const [sidebarHidden, setSidebarHidden] = useState(() => remembered("sidebarHidden"));
const [sidebarPeek, setSidebarPeek] = useState(false);
const [view, setView] = useState<ViewMode>(() => {
@@ -161,12 +165,14 @@ export default function App() {
localStorage.setItem("flighttube.browser", browser);
localStorage.setItem("flighttube.downloadedOnly", downloadedOnly ? "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");
} catch {
/* storage blocked */
}
}, [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
// depend on the on/off preference: that says what is shown, and a file
@@ -217,6 +223,62 @@ export default function App() {
// machine-translated variant YouTube offers, and asking for all of them
// 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 () => {
setRefreshing(true);
try {
@@ -228,13 +290,14 @@ export default function App() {
? `Checked ${s.channels} channels · ${failed} failed`
: `Checked ${s.channels} channel${s.channels === 1 ? "" : "s"}`,
);
if (autoModeRef.current) void reconcileAuto();
} catch (e) {
setFailure(String(e));
} finally {
setRefreshing(false);
setRefreshProgress(null);
}
}, [reload, say]);
}, [reload, say, reconcileAuto]);
// Downloaded plays from disk; anything else streams. Only being offline with
// no local copy leaves nothing to play.
@@ -301,6 +364,39 @@ export default function App() {
};
}, [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) => {
previewDeleteChannel(id)
.then((p) => setRemoving({ ...p, id }))
@@ -316,10 +412,25 @@ export default function App() {
if (channelId === id) setChannelId(null);
reload();
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)));
}, [removing, channelId, reload, say]);
// 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
// started on one channel keeps running while you look at another.
const activeDownloads = useMemo(() => {
@@ -543,6 +654,9 @@ export default function App() {
downloadAllTotal={pendingDownloads.length}
onStopAll={activeDownloads > 0 ? stopAll : undefined}
stopAllCount={activeDownloads}
autoMode={autoMode}
onAutoMode={askAutoMode}
autoModeCount={bulkLimit}
sidebarHidden={sidebarHidden}
onShowSidebar={() => { setSidebarHidden(false); setSidebarPeek(false); }}
titleBarInset={titleBarInset}
@@ -624,6 +738,7 @@ export default function App() {
onSubLang={setSubLang}
subStyle={subStyle}
onSubStyle={setSubStyle}
autoplayNext={autoplayNext}
onOpenChannel={() => {
setChannelId(playing.item.channel_id);
setSearch("");
@@ -683,6 +798,8 @@ export default function App() {
onSubLang={setSubLang}
hideShorts={hideShorts}
onHideShorts={setHideShorts}
autoplayNext={autoplayNext}
onAutoplayNext={setAutoplayNext}
browser={browser}
onBrowser={setBrowser}
onError={setFailure}
@@ -709,6 +826,42 @@ export default function App() {
</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 && (
<Dialog
title={`Remove ${removing.title}?`}
@@ -726,7 +879,12 @@ export default function App() {
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>
</Dialog>
)}
+80 -26
View File
@@ -6,7 +6,7 @@ import {
import { DEFAULT_SUB_LANG, SUB_FONTS, SUB_PLACES, type FeedItem, type SubStyle } from "../types";
import { compactViews, relativeTime, subtitleLabel } from "./format";
import PlayerControls from "./PlayerControls";
import { Badge, BTN, Spinner } from "./ui";
import { Badge, BTN, Dialog, Spinner } from "./ui";
interface Props {
item: FeedItem;
@@ -27,6 +27,8 @@ interface Props {
onSubStyle: (s: SubStyle) => void;
/** Leaves the player for this video's channel. */
onOpenChannel: () => void;
/** Roll straight on to the next video when this one ends. */
autoplayNext: boolean;
/** Persists a subtitle choice made from the transport bar. */
onSubLang: (l: string) => void;
/** Position in the current feed, for the "3 of 180" readout. */
@@ -106,6 +108,40 @@ const RESUME_EDGE_S = 5;
* so watching still happens here rather than in a browser. The iframe embed
* 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.
*
@@ -122,7 +158,7 @@ function placeCues(vtt: string, line: number | null): string {
export default function Player({
item, path, onClose, onDelete, onPrev, onNext, onDownload, downloading,
maxHeight, subLang, onSubLang, subStyle, onSubStyle, onOpenChannel,
maxHeight, subLang, onSubLang, subStyle, onSubStyle, onOpenChannel, autoplayNext,
index, total, titleBarInset,
}: Props) {
const streaming = path === null;
@@ -152,6 +188,7 @@ export default function Player({
const [rawTracks, setRawTracks] = useState<Array<[string, string]>>([]);
const [tracks, setTracks] = useState<Array<[string, string]>>([]);
const [fetchingSubs, setFetchingSubs] = useState(false);
const [showDescription, setShowDescription] = useState(false);
// 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,
// and refetching then would tear down the tracks — and the stream with them —
@@ -297,12 +334,14 @@ export default function Player({
// Escape backs out, as it does everywhere else in the app.
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
// In fullscreen the browser already handles Escape; closing the player
// as well would drop you all the way back to the feed.
// In fullscreen, Escape is the transport bar's to handle: it leaves
// 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();
// Arrow keys only when the video does not own them for seeking.
if (e.key === "ArrowLeft" && e.shiftKey) onPrev?.();
if (e.key === "ArrowRight" && e.shiftKey) onNext?.();
// Moving between videos. Shift with the arrows because the bare ones
// seek, and shift with N and P because that is what YouTube uses.
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);
return () => window.removeEventListener("keydown", onKey);
@@ -457,6 +496,12 @@ export default function Player({
onTimeUpdate={onTimeUpdate}
onLoadedMetadata={onLoadedMetadata}
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)}
onWaiting={() => setBuffering(true)}
onStalled={() => setBuffering(true)}
@@ -511,13 +556,29 @@ export default function Player({
dark:border-slate-800 dark:bg-slate-900"
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<h2 className="truncate text-[15px] font-semibold tracking-tight">{item.title}</h2>
<div className="mt-0.5 text-[11px] text-slate-400 dark:text-slate-500">
{/* Title and figures on one line. The title opens the description
rather than announcing itself as a link: it keeps its colour and
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)]
.filter(Boolean)
.join(" · ")}
</div>
</span>
</div>
<div className="flex shrink-0 items-center gap-2">
{onDownload && (
@@ -565,22 +626,15 @@ export default function Player({
</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>
{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>
);
}
+204 -37
View File
@@ -71,6 +71,15 @@ function StyleRow<T extends string | number>({
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 {
if (!Number.isFinite(seconds) || seconds < 0) return "0:00";
const h = Math.floor(seconds / 3600);
@@ -100,14 +109,18 @@ export default function PlayerControls({
const [duration, setDuration] = useState(0);
const [volume, setVolume] = useState(1);
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 [full, setFull] = useState(false);
const [menu, setMenu] = useState(false);
const [subs, setSubs] = useState<TextTrack[]>([]);
const [, bump] = useState(0);
const menuRef = useRef<HTMLDivElement>(null);
// Which video, and how many tracks, the preference was last applied to.
const applied = useRef("");
// The track that should be showing, and the set of tracks that choice was
// made for.
const desired = useRef<TextTrack | null>(null);
const decided = useRef("");
// Tracks arrive with the manifest, after metadata rather than on mount.
useEffect(() => {
@@ -116,37 +129,58 @@ export default function PlayerControls({
const read = () => setSubs(listSubs(v));
read();
// Subtitles follow the language chosen in Settings and nothing else.
// WebKit will otherwise switch on whatever matches the system language,
// which is the same unwanted auto-selection as a dubbed audio track.
// 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.
applied.current = "";
const applyPreference = () => {
const tracks = Array.from(v.textTracks);
// Which track should be showing, or null for none. Decided once per set of
// tracks — from the preference, or from a choice made in the menu — and
// then held.
//
// Holding it is the point. Deciding once and walking away was not enough:
// WebKit switches a newly added track on by itself, following the system's
// caption settings, and it does so after the track is added. Off would come
// 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
// subtitles sit re-cuts the same one track, and a count would not notice
// — leaving the fresh track disabled and the subtitles gone.
// subtitles sit re-cuts the same one track, and a count would not notice.
const key = `${v.currentSrc}|${Array.from(v.querySelectorAll("track"))
.map((el) => el.src)
.join("|")}`;
if (key === applied.current) return;
applied.current = key;
const wanted =
subLang === "off"
? undefined
: listSubs(v).find((t) =>
(t.language || "").toLowerCase().startsWith(subLang.toLowerCase()),
);
// Everything else off, in-band tracks included — otherwise the file's own
// copy renders underneath ours.
for (const t of tracks) t.mode = t === wanted ? "showing" : "disabled";
read();
if (key !== decided.current) {
decided.current = key;
corrections = 0;
desired.current =
subLang === "off"
? null
: (listSubs(v).find((t) =>
(t.language || "").toLowerCase().startsWith(subLang.toLowerCase()),
) ?? null);
}
enforce();
};
applyPreference();
v.addEventListener("loadedmetadata", applyPreference);
// HLS subtitle renditions arrive after metadata, so re-apply as they land.
v.textTracks.addEventListener?.("addtrack", applyPreference);
decide();
v.addEventListener("loadedmetadata", decide);
// 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.textTracks.addEventListener?.("addtrack", read);
@@ -154,8 +188,9 @@ export default function PlayerControls({
const id = setInterval(read, 1000);
const stop = setTimeout(() => clearInterval(id), 8000);
return () => {
v.removeEventListener("loadedmetadata", applyPreference);
v.textTracks.removeEventListener?.("addtrack", applyPreference);
v.removeEventListener("loadedmetadata", decide);
v.textTracks.removeEventListener?.("addtrack", decide);
v.textTracks.removeEventListener?.("change", enforce);
v.removeEventListener("loadedmetadata", read);
v.textTracks.removeEventListener?.("addtrack", read);
clearInterval(id);
@@ -176,6 +211,8 @@ export default function PlayerControls({
const chooseSub = (track: TextTrack | null) => {
const v = videoRef.current;
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)) {
t.mode = t === track ? "showing" : "disabled";
}
@@ -186,6 +223,23 @@ export default function PlayerControls({
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 —
// playback can change from the keyboard, the system, or the video ending.
useEffect(() => {
@@ -197,18 +251,19 @@ export default function PlayerControls({
setDuration(Number.isFinite(v.duration) ? v.duration : 0);
setVolume(v.volume);
setMuted(v.muted);
setRate(v.playbackRate);
};
const onPip = () => setPip(!!document.pictureInPictureElement);
const onFull = () => setFull(!!document.fullscreenElement);
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("enterpictureinpicture", onPip);
v.addEventListener("leavepictureinpicture", onPip);
document.addEventListener("fullscreenchange", onFull);
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("enterpictureinpicture", onPip);
@@ -253,6 +308,104 @@ export default function PlayerControls({
}
}, [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;
return (
@@ -278,7 +431,7 @@ export default function PlayerControls({
<button
onClick={act((v) => (v.currentTime = Math.max(0, v.currentTime - SKIP_S)))}
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">
<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
onClick={act((v) => (v.currentTime = Math.min(v.duration || 0, v.currentTime + SKIP_S)))}
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">
<path strokeLinecap="round" strokeLinejoin="round" d="M13 7l5 5-5 5M6 7l5 5-5 5" />
</svg>
</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>
<input
@@ -319,7 +486,7 @@ export default function PlayerControls({
<button
onClick={act((v) => (v.muted = !v.muted))}
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">
<path d="M4 9v6h4l5 4V5L8 9H4z" />
@@ -363,7 +530,7 @@ export default function PlayerControls({
<button
onClick={() => { setMenu((m) => !m); onActivity?.(); }}
className={btn}
title="Subtitles"
title="Subtitles (c)"
>
<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" />
@@ -456,7 +623,7 @@ export default function PlayerControls({
</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">
<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" />
+21
View File
@@ -30,6 +30,8 @@ interface Props {
onSubLang: (l: string) => void;
hideShorts: boolean;
onHideShorts: (v: boolean) => void;
autoplayNext: boolean;
onAutoplayNext: (v: boolean) => void;
browser: string;
onBrowser: (b: string) => void;
onError: (message: string) => void;
@@ -58,6 +60,7 @@ export default function Settings({
onClose, onImported, appearance, onAppearance, quality, onQuality,
bulkLimit, onBulkLimit,
streamQuality, onStreamQuality, subLang, onSubLang, hideShorts, onHideShorts,
autoplayNext, onAutoplayNext,
browser, onBrowser, onError,
}: Props) {
const [prereqs, setPrereqs] = useState<Prereqs | null>(null);
@@ -301,6 +304,24 @@ export default function Settings({
</p>
</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">
<SectionHeading>Sign in to YouTube</SectionHeading>
<p className={`mt-1.5 ${HELP}`}>
+33 -1
View File
@@ -26,6 +26,10 @@ interface Props {
/** Present only while something is downloading or waiting to. */
onStopAll?: () => void;
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. */
downloadAllCount?: number;
downloadAllTotal?: number;
@@ -67,7 +71,8 @@ export default function TopBar({
online, reachable, forcedOffline, onToggleForcedOffline,
onRefresh, refreshing, refreshProgress, resultCount, view, onView,
sidebarHidden, onShowSidebar, onDeleteAll, onDownloadAll, downloadAllCount = 0,
downloadAllTotal = 0, onStopAll, stopAllCount = 0, titleBarInset,
downloadAllTotal = 0, onStopAll, stopAllCount = 0,
autoMode, onAutoMode, autoModeCount, titleBarInset,
}: Props) {
const pct = refreshProgress && refreshProgress.total > 0
? (refreshProgress.done / refreshProgress.total) * 100
@@ -225,6 +230,33 @@ export default function TopBar({
{online ? "Online" : forcedOffline ? "Offline (forced)" : "Offline"}
</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}
title={
online