feat: add modal YouTube browsing and configurable streaming

This commit is contained in:
2026-10-09 21:03:34 +02:00
parent 7e5bcc9087
commit 79a14dce1e
4 changed files with 1569 additions and 2 deletions
Generated
+960
View File
@@ -2,6 +2,966 @@
# It is not intended for manual editing.
version = 4
[[package]]
name = "allocator-api2"
version = "0.2.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
[[package]]
name = "anstream"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d"
dependencies = [
"anstyle",
"anstyle-parse",
"anstyle-query",
"anstyle-wincon",
"colorchoice",
"is_terminal_polyfill",
"utf8parse",
]
[[package]]
name = "anstyle"
version = "1.0.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
[[package]]
name = "anstyle-parse"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e"
dependencies = [
"utf8parse",
]
[[package]]
name = "anstyle-query"
version = "1.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
dependencies = [
"windows-sys",
]
[[package]]
name = "anstyle-wincon"
version = "3.0.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
dependencies = [
"anstyle",
"once_cell_polyfill",
"windows-sys",
]
[[package]]
name = "approx"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cab112f0a86d568ea0e627cc1d6be74a1e9cd55214684db5561995f6dad897c6"
dependencies = [
"num-traits",
]
[[package]]
name = "autocfg"
version = "1.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
[[package]]
name = "bitflags"
version = "2.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"
[[package]]
name = "by_address"
version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "64fa3c856b712db6612c019f14756e64e4bcea13337a6b33b696333a9eaa2d06"
[[package]]
name = "castaway"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a"
dependencies = [
"rustversion",
]
[[package]]
name = "cfg-if"
version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600"
[[package]]
name = "clap"
version = "4.6.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aa8876b300ab35ba921adea3dfd70157a46249b33f95c9084ae5709785478946"
dependencies = [
"clap_builder",
"clap_derive",
]
[[package]]
name = "clap_builder"
version = "4.6.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec0797fb7aeb1406c84efac526901f7ec3ead2124f946b494e72879d4b54704d"
dependencies = [
"anstream",
"anstyle",
"clap_lex",
"strsim",
]
[[package]]
name = "clap_derive"
version = "4.6.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f9c751b79415d4e559e3d1fcf128e09e720eb673a06d26cf6f392d37d75b66e0"
dependencies = [
"heck",
"proc-macro2",
"quote",
"syn 3.0.6",
]
[[package]]
name = "clap_lex"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1c133bc6a41be0d194c306b5506d15e6feeea7b1d6604bd3f8310dfb2ca96486"
[[package]]
name = "colorchoice"
version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
[[package]]
name = "compact_str"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9dfdd1c2274d9aa354115b09dc9a901d6c5576818cdf70d14cae2bdb47df00ab"
dependencies = [
"castaway",
"cfg-if",
"itoa",
"rustversion",
"ryu",
"static_assertions",
]
[[package]]
name = "convert_case"
version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9"
dependencies = [
"unicode-segmentation",
]
[[package]]
name = "crossterm"
version = "0.29.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b"
dependencies = [
"bitflags",
"crossterm_winapi",
"derive_more",
"document-features",
"mio",
"parking_lot",
"rustix",
"signal-hook",
"signal-hook-mio",
"winapi",
]
[[package]]
name = "crossterm_winapi"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b"
dependencies = [
"winapi",
]
[[package]]
name = "darling"
version = "0.24.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed17f5901b6630b993ca003def43f2f8ef4014fc13b047b57aad617ff32bc2ec"
dependencies = [
"darling_core",
"darling_macro",
]
[[package]]
name = "darling_core"
version = "0.24.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6837e2cf7485aaae18f86181d2f0e9a7ed297a025e220aeabf63fdebd3a2ddff"
dependencies = [
"ident_case",
"proc-macro2",
"quote",
"strsim",
"syn 3.0.6",
]
[[package]]
name = "darling_macro"
version = "0.24.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2ac7135c3ef02b2f7833bbeb1be5ba7f966dcde8a87c6b87f65a778d71a02785"
dependencies = [
"darling_core",
"quote",
"syn 3.0.6",
]
[[package]]
name = "deranged"
version = "0.5.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
[[package]]
name = "derive_more"
version = "2.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134"
dependencies = [
"derive_more-impl",
]
[[package]]
name = "derive_more-impl"
version = "2.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb"
dependencies = [
"convert_case",
"proc-macro2",
"quote",
"rustc_version",
"syn 2.0.119",
]
[[package]]
name = "document-features"
version = "0.2.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61"
dependencies = [
"litrs",
]
[[package]]
name = "either"
version = "1.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0e9c71c2167ca323c882b99918929403426e2373ea17242ff5653e0d5e1058be"
[[package]]
name = "equivalent"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
[[package]]
name = "errno"
version = "0.3.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys",
]
[[package]]
name = "foldhash"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
[[package]]
name = "hashbrown"
version = "0.16.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
dependencies = [
"allocator-api2",
"equivalent",
"foldhash",
]
[[package]]
name = "hashbrown"
version = "0.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
dependencies = [
"allocator-api2",
"equivalent",
"foldhash",
]
[[package]]
name = "heck"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "ident_case"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39"
[[package]]
name = "indoc"
version = "2.0.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a37b2691796cffeb8a8cd305ac66e65841559f147f4e63231d0eafa4db5384d1"
dependencies = [
"rustversion",
]
[[package]]
name = "instability"
version = "0.3.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4c3b5acc1e2fd9375041a388da33d1eb8aed5f7a8c0dd3543e3ea2805adfbe20"
dependencies = [
"darling",
"indoc",
"proc-macro2",
"quote",
"syn 3.0.6",
]
[[package]]
name = "is_terminal_polyfill"
version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
[[package]]
name = "itertools"
version = "0.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285"
dependencies = [
"either",
]
[[package]]
name = "itoa"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "kasuari"
version = "0.4.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bde5057d6143cc94e861d90f591b9303d6716c6b9602309150bd068853c10899"
dependencies = [
"hashbrown 0.16.1",
"portable-atomic",
"thiserror",
]
[[package]]
name = "libc"
version = "0.2.190"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78"
[[package]]
name = "libm"
version = "0.2.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"
[[package]]
name = "line-clipping"
version = "0.3.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e752191d037c44ad111a8caa762921926658402f01cc1253f7bef2020ece4f5e"
dependencies = [
"bitflags",
]
[[package]]
name = "linux-raw-sys"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
[[package]]
name = "litrs"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092"
[[package]]
name = "lock_api"
version = "0.4.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965"
dependencies = [
"scopeguard",
]
[[package]]
name = "log"
version = "0.4.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
[[package]]
name = "lru"
version = "0.18.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ef9ac18847474e638e3702b76c65d4eb93428471a74778ef0f1be711717f89b5"
dependencies = [
"hashbrown 0.17.1",
]
[[package]]
name = "memchr"
version = "2.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
[[package]]
name = "mio"
version = "1.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee"
dependencies = [
"libc",
"log",
"wasi",
"windows-sys",
]
[[package]]
name = "num-conv"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441"
[[package]]
name = "num-traits"
version = "0.2.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
dependencies = [
"autocfg",
]
[[package]]
name = "num_threads"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9"
dependencies = [
"libc",
]
[[package]]
name = "once_cell_polyfill"
version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
[[package]]
name = "palette"
version = "0.7.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ddeed8580d347d2abf3dcf06a5f0b3dc020258338526b277847cd4248a70fc64"
dependencies = [
"approx",
"libm",
"palette_derive",
"palette_math",
]
[[package]]
name = "palette_derive"
version = "0.7.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "88537020289b719d81be994ccf1bbf4990f477e2f69ee52fe3e45f43a02e56be"
dependencies = [
"by_address",
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "palette_math"
version = "0.7.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e6eb142958d64335fb0e345c5b9ead2ecd6fc438c307e9d7d3c4fd428dbaf12"
dependencies = [
"libm",
]
[[package]]
name = "parking_lot"
version = "0.12.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a"
dependencies = [
"lock_api",
"parking_lot_core",
]
[[package]]
name = "parking_lot_core"
version = "0.9.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
dependencies = [
"cfg-if",
"libc",
"redox_syscall",
"smallvec",
"windows-link",
]
[[package]]
name = "portable-atomic"
version = "1.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85"
[[package]]
name = "powerfmt"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4a6394b9e965e73d0a289ee54f589087e2c676aedf60885baf52c76b771e4958"
[[package]]
name = "proc-macro2"
version = "1.0.107"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
dependencies = [
"proc-macro2",
]
[[package]]
name = "ratatui"
version = "0.30.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3274ba0a2c5e1bcad2a2005d20f4dc59dad26b2eb0940fb094500dba4099d57d"
dependencies = [
"instability",
"ratatui-core",
"ratatui-crossterm",
"ratatui-widgets",
"serde",
]
[[package]]
name = "ratatui-core"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cbb175c433c8e28a809d1f5773a2ae96e68c0ce40db865cbab1020bf33ae479c"
dependencies = [
"bitflags",
"compact_str",
"hashbrown 0.17.1",
"itertools",
"kasuari",
"lru",
"palette",
"serde",
"strum",
"thiserror",
"unicode-segmentation",
"unicode-truncate",
"unicode-width",
]
[[package]]
name = "ratatui-crossterm"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "567584a3b0e6a8203c23de40b4861497266725eb5363dbfd18a1edd603cca9f0"
dependencies = [
"cfg-if",
"crossterm",
"instability",
"ratatui-core",
]
[[package]]
name = "ratatui-widgets"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "66e3d19bcc9130ca376277d93b60767ff121ace3be06f5f95f81dd68956407d1"
dependencies = [
"bitflags",
"hashbrown 0.17.1",
"indoc",
"instability",
"itertools",
"line-clipping",
"ratatui-core",
"serde",
"strum",
"time",
"unicode-segmentation",
"unicode-width",
]
[[package]]
name = "redox_syscall"
version = "0.5.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
dependencies = [
"bitflags",
]
[[package]]
name = "rustc_version"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92"
dependencies = [
"semver",
]
[[package]]
name = "rustix"
version = "1.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d"
dependencies = [
"bitflags",
"errno",
"libc",
"linux-raw-sys",
"windows-sys",
]
[[package]]
name = "rustversion"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
[[package]]
name = "ryu"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
[[package]]
name = "scopeguard"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]]
name = "semver"
version = "1.0.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
[[package]]
name = "serde"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
dependencies = [
"serde_core",
"serde_derive",
]
[[package]]
name = "serde_core"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.6",
]
[[package]]
name = "serde_json"
version = "1.0.151"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"
dependencies = [
"itoa",
"memchr",
"serde",
"serde_core",
"zmij",
]
[[package]]
name = "signal-hook"
version = "0.3.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2"
dependencies = [
"libc",
"signal-hook-registry",
]
[[package]]
name = "signal-hook-mio"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc"
dependencies = [
"libc",
"mio",
"signal-hook",
]
[[package]]
name = "signal-hook-registry"
version = "1.4.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b"
dependencies = [
"errno",
"libc",
]
[[package]]
name = "smallvec"
version = "1.16.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f9395f0f0eee849a9b707b2f06bb92a6a422090e2123bb2ef8e87a0e61892a8e"
[[package]]
name = "static_assertions"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
[[package]]
name = "strsim"
version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]]
name = "strum"
version = "0.28.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd"
dependencies = [
"strum_macros",
]
[[package]]
name = "strum_macros"
version = "0.28.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664"
dependencies = [
"heck",
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "syn"
version = "2.0.119"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "syn"
version = "3.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "thiserror"
version = "2.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e"
dependencies = [
"thiserror-impl",
]
[[package]]
name = "thiserror-impl"
version = "2.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.6",
]
[[package]]
name = "time"
version = "0.3.55"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134"
dependencies = [
"deranged",
"libc",
"num-conv",
"num_threads",
"powerfmt",
"serde_core",
"time-core",
]
[[package]]
name = "time-core"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109"
[[package]]
name = "tubular"
version = "0.1.0"
dependencies = [
"clap",
"libc",
"ratatui",
"serde",
"serde_json",
]
[[package]]
name = "unicode-ident"
version = "1.0.26"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954"
[[package]]
name = "unicode-segmentation"
version = "1.13.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8"
[[package]]
name = "unicode-truncate"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "16b380a1238663e5f8a691f9039c73e1cdae598a30e9855f541d29b08b53e9a5"
dependencies = [
"itertools",
"unicode-segmentation",
"unicode-width",
]
[[package]]
name = "unicode-width"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
[[package]]
name = "utf8parse"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
[[package]]
name = "wasi"
version = "0.11.1+wasi-snapshot-preview1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
[[package]]
name = "winapi"
version = "0.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
dependencies = [
"winapi-i686-pc-windows-gnu",
"winapi-x86_64-pc-windows-gnu",
]
[[package]]
name = "winapi-i686-pc-windows-gnu"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
[[package]]
name = "winapi-x86_64-pc-windows-gnu"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
[[package]]
name = "windows-link"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "windows-sys"
version = "0.61.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
dependencies = [
"windows-link",
]
[[package]]
name = "zmij"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
+8
View File
@@ -2,5 +2,13 @@
name = "tubular"
version = "0.1.0"
edition = "2024"
description = "A terminal YouTube browser that streams videos to your chosen player"
[dependencies]
clap = { version = "4.6.7", features = ["derive"] }
ratatui = { version = "0.30.2", default-features = false, features = ["crossterm"] }
serde = { version = "1.0.229", features = ["derive"] }
serde_json = "1.0.151"
[target."cfg(unix)".dependencies]
libc = "0.2.190"
+333 -2
View File
@@ -1,3 +1,334 @@
fn main() {
println!("Hello, world!");
//! A Ratatui YouTube browser backed by yt-dlp and FFmpeg, with no API key.
//!
//! Run `cargo run -- --player mpv --player-arg=--volume=50 "rust programming"`.
//! Without a CLI query, start in insert mode; otherwise search and start in normal mode.
//! Insert: Enter submits a changed query and returns to normal; Esc returns without searching.
//! Normal: j/k selects, Enter streams, and i returns to insert mode to edit the query.
//! The player must read media from standard input when passed `-`.
//! Audio and video are streamed through a pipe, never saved to a video file.
//! See `--help` for controls; replacing playback or quitting cancels its processes.
mod youtube;
use std::{ffi::OsString, io, time::Duration};
use clap::Parser;
use ratatui::{
DefaultTerminal, Frame,
crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers},
layout::{Constraint, Layout},
style::{Color, Style, Stylize},
widgets::{Block, Paragraph, Row, Table, TableState, Wrap},
};
use youtube::{Job, Playback, Video};
#[derive(Parser)]
#[command(
version,
about = "Search YouTube and stream videos to your chosen player",
after_help = "Requires yt-dlp, FFmpeg, and a player on PATH.\nThe player receives audio/video on standard input, with '-' as its last argument.\nWithout a CLI query, start in INSERT; a CLI query is searched automatically in NORMAL.\nINSERT: type to edit; Enter submits if changed and returns to NORMAL; Esc returns without submitting.\nNORMAL: j/k or arrows select; Enter streams; i edits the query; q quits.\nCtrl-U clears in INSERT; Ctrl-C quits in either mode.\nSelecting another video replaces playback. Quitting stops playback.\nExample: tubular --player mpv --player-arg=--volume=50 \"rust programming\""
)]
struct Options {
/// Player executable (must support '-' for media on standard input).
#[arg(long, default_value = "mpv")]
player: OsString,
/// Argument passed to the player before '-'; repeat for multiple arguments.
#[arg(long, allow_hyphen_values = true)]
player_arg: Vec<OsString>,
/// Optional initial YouTube search.
query: Option<String>,
}
#[derive(Debug, PartialEq, Eq)]
enum Mode {
Insert,
Normal,
}
struct App {
options: Options,
query: String,
mode: Mode,
submitted_query: String,
videos: Vec<Video>,
selection: TableState,
job: Option<Job>,
playback: Option<Playback>,
status: String,
}
impl App {
fn new(options: Options) -> Self {
let query = options.query.clone().unwrap_or_default();
let mode = if query.trim().is_empty() {
Mode::Insert
} else {
Mode::Normal
};
Self {
options,
query,
mode,
submitted_query: String::new(),
videos: Vec::new(),
selection: TableState::default(),
job: None,
playback: None,
status: "Type a query, then press Enter.".into(),
}
}
fn search(&mut self) {
let query = self.query.trim();
if query.is_empty() {
self.status = "Enter a search first.".into();
return;
}
if query == self.submitted_query {
return;
}
match Job::search(query) {
Ok(job) => {
self.job = Some(job);
self.submitted_query = query.to_owned();
self.videos.clear();
self.selection = TableState::default();
self.status = format!("Searching for {query}...");
}
Err(error) => self.status = error.to_string(),
}
}
fn play(&mut self) {
if self.job.is_some() {
return;
}
let Some(video) = self
.selection
.selected()
.and_then(|index| self.videos.get(index))
else {
return;
};
match Playback::start(&video.id, &self.options.player, &self.options.player_arg) {
Ok(playback) => {
self.status = format!(
"Streaming {} in {}...",
video.title,
self.options.player.to_string_lossy()
);
self.playback = Some(playback);
}
Err(error) => self.status = error.to_string(),
}
}
fn update(&mut self) {
if let Some(result) = self.job.as_mut().and_then(Job::poll) {
self.job = None;
self.status = match result.and_then(|output| youtube::search_results(&output)) {
Ok(videos) => {
self.videos = videos;
self.selection = TableState::default();
self.selection
.select((!self.videos.is_empty()).then_some(0));
format!("{} results. Select a video to stream.", self.videos.len())
}
Err(error) => error,
};
}
if let Some(result) = self.playback.as_mut().and_then(Playback::poll) {
self.playback = None;
self.status = match result {
Ok(()) => "Playback finished.".into(),
Err(error) => error,
};
}
}
fn key(&mut self, key: KeyEvent) -> bool {
if key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL) {
return true;
}
if self.mode == Mode::Insert {
match key.code {
KeyCode::Esc => self.mode = Mode::Normal,
KeyCode::Enter => {
self.search();
self.mode = Mode::Normal;
}
KeyCode::Backspace => {
self.query.pop();
}
KeyCode::Char('u') if key.modifiers.contains(KeyModifiers::CONTROL) => {
self.query.clear();
}
KeyCode::Char(c)
if !key
.modifiers
.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) =>
{
self.query.push(c);
}
_ => {}
}
} else {
match key.code {
KeyCode::Char('q') => return true,
KeyCode::Char('i') => self.mode = Mode::Insert,
KeyCode::Enter => self.play(),
KeyCode::Down | KeyCode::Char('j') => self.selection.select_next(),
KeyCode::Up | KeyCode::Char('k') => self.selection.select_previous(),
KeyCode::Home | KeyCode::Char('g') => self.selection.select_first(),
KeyCode::End | KeyCode::Char('G') => self.selection.select_last(),
_ => {}
}
}
if let Some(index) = self
.selection
.selected()
.filter(|_| !self.videos.is_empty())
{
self.selection
.select(Some(index.min(self.videos.len() - 1)));
}
false
}
fn draw(&mut self, frame: &mut Frame) {
let [search, results, status, help] = Layout::vertical([
Constraint::Length(3),
Constraint::Min(3),
Constraint::Length(3),
Constraint::Length(1),
])
.areas(frame.area());
let search_style = if self.mode == Mode::Insert {
Style::new().fg(Color::Cyan)
} else {
Style::new()
};
// End-only editing; scroll long queries without counting UTF-8 bytes as columns.
let query = ratatui::text::Line::raw(self.query.as_str());
let scroll = query
.width()
.saturating_sub(search.width.saturating_sub(3) as usize);
frame.render_widget(
Paragraph::new(query)
.scroll((0, scroll.min(u16::MAX as usize) as u16))
.block(
Block::bordered()
.title(" tubular / YouTube search ")
.border_style(search_style),
),
search,
);
let table = Table::new(
self.videos.iter().map(|video| {
Row::new([
video.title.as_str(),
video.channel.as_deref().unwrap_or("Unknown"),
])
}),
[Constraint::Percentage(75), Constraint::Percentage(25)],
)
.header(Row::new(["Video", "Channel"]).bold())
.block(Block::bordered().title(" Results "))
.row_highlight_style(Style::new().fg(Color::Black).bg(Color::Cyan))
.highlight_symbol("> ");
frame.render_stateful_widget(table, results, &mut self.selection);
frame.render_widget(
Paragraph::new(self.status.as_str())
.wrap(Wrap { trim: true })
.block(Block::bordered().title(" Status ")),
status,
);
let help_text = match self.mode {
Mode::Insert => {
"INSERT · Enter search → normal · Esc normal · Ctrl-U clear · Ctrl-C quit"
}
Mode::Normal => "NORMAL · j/k or ↑/↓ select · Enter stream · i insert · q quit",
};
frame.render_widget(Paragraph::new(help_text).dim(), help);
}
fn run(mut self, terminal: &mut DefaultTerminal) -> io::Result<()> {
if !self.query.trim().is_empty() {
self.search();
}
loop {
self.update();
terminal.draw(|frame| self.draw(frame))?;
if event::poll(Duration::from_millis(100))?
&& let Event::Key(key) = event::read()?
&& key.kind != KeyEventKind::Release
&& self.key(key)
{
return Ok(());
}
}
}
}
fn main() -> io::Result<()> {
let app = App::new(Options::parse());
ratatui::run(|terminal| app.run(terminal))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn modal_keys_edit_or_navigate_without_submitting_on_escape() {
let mut app = App::new(Options::parse_from(["tubular"]));
assert_eq!(app.mode, Mode::Insert);
app.videos = vec![
Video {
id: "jNQXAC9IVRw".into(),
title: "Me at the zoo".into(),
channel: None,
},
Video {
id: "rQ_J9WH6CGk".into(),
title: "Rust course".into(),
channel: None,
},
];
app.selection.select(Some(0));
for c in ['j', 'k', 'q'] {
assert!(!app.key(KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE)));
}
app.update();
assert_eq!(app.query, "jkq");
assert_eq!(app.selection.selected(), Some(0));
assert!(app.job.is_none());
app.key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
assert_eq!(app.mode, Mode::Normal);
assert!(app.job.is_none());
app.key(KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE));
assert_eq!(app.selection.selected(), Some(1));
app.key(KeyEvent::new(KeyCode::Char('k'), KeyModifiers::NONE));
assert_eq!(app.selection.selected(), Some(0));
app.key(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE));
assert_eq!(app.mode, Mode::Insert);
app.key(KeyEvent::new(KeyCode::Char('u'), KeyModifiers::CONTROL));
app.key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert_eq!(app.mode, Mode::Normal);
assert!(app.query.is_empty());
assert!(app.job.is_none());
assert_eq!(
App::new(Options::parse_from(["tubular", "rust"])).mode,
Mode::Normal
);
assert_eq!(
App::new(Options::parse_from(["tubular", " "])).mode,
Mode::Insert
);
}
}
+268
View File
@@ -0,0 +1,268 @@
use std::{
ffi::{OsStr, OsString},
io::{self, Read},
process::{Child, Command, Stdio},
sync::mpsc::{self, Receiver, TryRecvError},
thread,
};
use serde::Deserialize;
#[derive(Deserialize)]
pub struct Video {
pub id: String,
pub title: String,
pub channel: Option<String>,
}
#[derive(Deserialize)]
struct SearchResults {
entries: Vec<Option<Video>>,
}
pub fn search_results(output: &str) -> Result<Vec<Video>, String> {
let results: SearchResults = serde_json::from_str(output)
.map_err(|error| format!("Invalid search response: {error}"))?;
let mut videos = Vec::new();
for mut video in results.entries.into_iter().flatten() {
if video.id.len() != 11
|| !video
.id
.bytes()
.all(|c| c.is_ascii_alphanumeric() || c == b'-' || c == b'_')
{
return Err("Search returned an invalid YouTube video ID".into());
}
video.title.retain(|c| !c.is_control());
if let Some(channel) = &mut video.channel {
channel.retain(|c| !c.is_control());
}
videos.push(video);
}
Ok(videos)
}
// Own the extractor so replacing a request or quitting also cancels its process.
pub struct Job {
child: Child,
output: Receiver<io::Result<(String, String)>>,
}
impl Job {
pub fn search(query: &str) -> io::Result<Self> {
let mut child = extractor()
.args(["--flat-playlist", "--dump-single-json"])
.arg(format!("ytsearch20:{query}"))
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.map_err(|error| {
io::Error::new(error.kind(), format!("Cannot start yt-dlp: {error}"))
})?;
let mut stdout = child.stdout.take().expect("stdout is piped");
let mut stderr = child.stderr.take().expect("stderr is piped");
let (sender, output) = mpsc::channel();
// Drain both pipes while the process runs; a full pipe must not stall the TUI.
thread::spawn(move || {
let stdout_reader = thread::spawn(move || {
let mut text = String::new();
stdout.read_to_string(&mut text).map(|_| text)
});
let mut errors = String::new();
let stderr_result = stderr.read_to_string(&mut errors);
let result = stdout_reader
.join()
.unwrap_or_else(|_| Err(io::Error::other("Output reader panicked")))
.and_then(|text| stderr_result.map(|_| (text, errors)));
let _ = sender.send(result);
});
Ok(Self { child, output })
}
pub fn poll(&mut self) -> Option<Result<String, String>> {
let status = match self.child.try_wait() {
Ok(Some(status)) => status,
Ok(None) => return None,
Err(error) => return Some(Err(format!("Cannot wait for yt-dlp: {error}"))),
};
match self.output.try_recv() {
Ok(Ok((output, _))) if status.success() => Some(Ok(output)),
Ok(Ok((_, errors))) => Some(Err(if errors.trim().is_empty() {
format!("yt-dlp exited with {status}")
} else {
errors.trim().to_owned()
})),
Ok(Err(error)) => Some(Err(format!("Cannot read yt-dlp output: {error}"))),
Err(TryRecvError::Empty) => None,
Err(TryRecvError::Disconnected) => {
Some(Err("yt-dlp output reader disconnected".into()))
}
}
}
}
impl Drop for Job {
fn drop(&mut self) {
stop(&mut self.child);
}
}
fn isolated_command(program: &OsStr) -> Command {
let mut command = Command::new(program);
#[cfg(unix)]
{
use std::os::unix::process::CommandExt;
command.process_group(0);
}
command
}
fn extractor() -> Command {
let mut command = isolated_command(OsStr::new("yt-dlp"));
command.args(["--ignore-config", "--no-warnings", "--socket-timeout", "15"]);
command
}
fn stop(child: &mut Child) {
if matches!(child.try_wait(), Ok(None)) {
// SAFETY: isolated_command gives this still-running child its own process group.
// Cancel the whole group, including FFmpeg, without signaling the TUI.
#[cfg(unix)]
unsafe {
libc::kill(-(child.id() as i32), libc::SIGKILL);
}
let _ = child.kill();
}
let _ = child.wait();
}
fn read_errors(mut pipe: impl Read + Send + 'static) -> Receiver<io::Result<String>> {
let (sender, receiver) = mpsc::channel();
thread::spawn(move || {
let mut text = String::new();
let _ = sender.send(pipe.read_to_string(&mut text).map(|_| text));
});
receiver
}
fn failure(errors: &Receiver<io::Result<String>>, fallback: String) -> Option<Result<(), String>> {
match errors.try_recv() {
Ok(Ok(text)) => Some(Err(if text.trim().is_empty() {
fallback
} else {
text.trim().to_owned()
})),
Ok(Err(error)) => Some(Err(format!("{fallback}: {error}"))),
Err(TryRecvError::Empty) => None,
Err(TryRecvError::Disconnected) => Some(Err(fallback)),
}
}
pub struct Playback {
extractor: Child,
player: Child,
extractor_errors: Receiver<io::Result<String>>,
player_errors: Receiver<io::Result<String>>,
}
impl Playback {
pub fn start(id: &str, player: &OsStr, args: &[OsString]) -> io::Result<Self> {
let mut extractor = extractor()
.args([
"--quiet",
"--no-progress",
"--no-playlist",
"--no-cache-dir",
"--no-part",
"--format",
"b/bv+ba",
"--downloader",
"ffmpeg",
"--downloader-args",
"ffmpeg:-loglevel error",
"--output",
"-",
])
.arg(format!("https://www.youtube.com/watch?v={id}"))
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.map_err(|error| {
io::Error::new(error.kind(), format!("Cannot start yt-dlp: {error}"))
})?;
let stream = extractor.stdout.take().expect("stdout is piped");
let player = match isolated_command(player)
.args(args)
.arg("-")
.stdin(Stdio::from(stream))
.stdout(Stdio::null())
.stderr(Stdio::piped())
.spawn()
{
Ok(player) => player,
Err(error) => {
stop(&mut extractor);
return Err(io::Error::new(
error.kind(),
format!("Cannot start player: {error}"),
));
}
};
let mut player = player;
let extractor_errors = read_errors(extractor.stderr.take().expect("stderr is piped"));
let player_errors = read_errors(player.stderr.take().expect("stderr is piped"));
Ok(Self {
extractor,
player,
extractor_errors,
player_errors,
})
}
pub fn poll(&mut self) -> Option<Result<(), String>> {
match self.extractor.try_wait() {
Ok(Some(status)) if !status.success() => {
return failure(
&self.extractor_errors,
format!("yt-dlp exited with {status}"),
);
}
Err(error) => return Some(Err(format!("Cannot wait for yt-dlp: {error}"))),
_ => {}
}
match self.player.try_wait() {
Ok(Some(status)) if status.success() => Some(Ok(())),
Ok(Some(status)) => {
failure(&self.player_errors, format!("Player exited with {status}"))
}
Ok(None) => None,
Err(error) => Some(Err(format!("Cannot wait for player: {error}"))),
}
}
}
impl Drop for Playback {
fn drop(&mut self) {
stop(&mut self.extractor);
stop(&mut self.player);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn search_handles_unavailable_entries_and_rejects_invalid_ids() {
let videos = search_results(
r#"{"entries":[null,{"id":"rQ_J9WH6CGk","title":"Rust\u001b","channel":null}]}"#,
)
.unwrap();
assert_eq!(videos[0].title, "Rust");
assert_eq!(videos[0].channel, None);
assert!(search_results(r#"{"entries":[{"id":"--bad","title":"Bad"}]}"#).is_err());
assert!(search_results("not JSON").is_err());
}
}