Compare commits
13
Commits
1790453a47
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
22e31afc1d | ||
|
|
35f14ab044 | ||
|
|
41d638b232 | ||
|
|
5cc3612b5f | ||
|
|
c2206c8811 | ||
|
|
14dab91334 | ||
|
|
ddad42593c | ||
|
|
90f1eddb31 | ||
|
|
c31bafa07a | ||
|
|
dac159050c | ||
|
|
b9e8a9db84 | ||
|
|
ad29f57e65 | ||
|
|
832a8f0f26 |
@@ -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
|
||||||
|
|||||||
Generated
+124
-7
@@ -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"
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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 @@
|
|||||||
{"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"]}}
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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,
|
||||||
|
|||||||
@@ -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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
+373
-116
@@ -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 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Runs JavaScript in a browser's active tab.
|
||||||
|
///
|
||||||
|
/// 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);
|
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||||
// -1743 is macOS refusing the Apple event because the permission has not
|
if stderr.contains("Executing JavaScript through AppleScript is turned off") {
|
||||||
// been granted. Saying so beats "nothing found".
|
return Err(format!(
|
||||||
if stderr.contains("-1743") || stderr.contains("Not authorized") {
|
"{browser} will not run JavaScript for another app. Turn it on in {browser} under \
|
||||||
return Err(
|
View › Developer › Allow JavaScript from Apple Events, then try again. Arc allows \
|
||||||
"FlightTube needs permission to read your browser's address. \
|
it already."
|
||||||
Allow it under System Settings › Privacy & Security › Automation."
|
));
|
||||||
.into(),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
let url = String::from_utf8_lossy(&out.stdout).trim().to_string();
|
if !out.status.success() {
|
||||||
if url.is_empty() {
|
return Err(format!("{browser} refused: {}", stderr.trim()));
|
||||||
return Err("No YouTube page open in a browser.".into());
|
|
||||||
}
|
}
|
||||||
Ok(url)
|
Ok(applescript_unquote(&String::from_utf8_lossy(&out.stdout)))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A notification, so the answer arrives without the window coming forward.
|
/// AppleScript string literal.
|
||||||
async fn notify(text: &str) {
|
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]
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
@@ -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}`}>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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>,
|
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user