diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml deleted file mode 100644 index 9fd45e0..0000000 --- a/.github/workflows/rust.yml +++ /dev/null @@ -1,22 +0,0 @@ -name: Rust - -on: - push: - branches: [ "main" ] - pull_request: - branches: [ "main" ] - -env: - CARGO_TERM_COLOR: always - -jobs: - build: - - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v4 - - name: Build - run: cargo build --verbose - - name: Run tests - run: cargo test --verbose diff --git a/CHANGELOG.md b/CHANGELOG.md deleted file mode 100644 index eedaa5f..0000000 --- a/CHANGELOG.md +++ /dev/null @@ -1,40 +0,0 @@ -# Changelog - -Changes to drift. Loosely follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - -## Unreleased - -### Added - -- Discord rich presence. Your current song shows up in your Discord status as "Listening to ...", with cover art, album, a progress bar and a "Listen Along" button. Paused songs show `(paused)` and drop the timer. Off by default: make an application at https://discord.com/developers/applications (its name is what shows after "Listening to"), paste the Application ID into `client_id` under the new `[discord]` section of `config.toml`. If discord isnt running drift keeps retrying quietly in the background. -- `:save!` / `:w!` to overwrite an existing playlist. Plain `:save` now refuses if the name is taken and tells you to use `:save!` (replace) or `:append` (add to the end). -- MPRIS on linux. media keys, `playerctl` and waybar can now see and control drift (play/pause/next/prev/seek/stop), with title, artist, album and cover art. Shows up as `org.mpris.MediaPlayer2.drift`. If theres no dbus session it just doesnt start, nothing breaks. -- `:album ` to search albums, `:playlist ` to search youtube music playlists. Results show up in a picker, enter loads the tracks. (`:playlists` is still your own playlists tab.) Big playlists stop at ~300 tracks. -- `gb` / `:album` / `G` then `b` opens the album of the selected song. It finds it by album + artist name so it can guess wrong sometimes, and the song needs album info to begin with. -- `gs` / `:similar` / `:radio` loads ~50 songs similar to the selected one and starts playing them, like youtube music radio. -- Synced lyrics from LRCLIB. `L` toggles the side panel, `F` for fullscreen. Current line gets a progress fill, lines around it fade. -- Plain lyrics fallback when theres no synced version, scroll with `j`/`k`. - -### Changed - -- Playback now downloads the audio with yt-dlp first and plays the local file instead of letting mpv stream it (`--ytdl=no`). Files live in the cache dir under `drift/audio` (last 40 are kept). The next queued song is fetched in the background while the current one plays, so track changes are quicker. Songs that are already cached start instantly. -- Playback errors show the actual reason from yt-dlp/mpv (e.g. "Sign in to confirm you're not a bot") instead of a generic "update yt-dlp" hint. Status messages can be wider too (70 -> 120 chars). -- Artists are split using the credits youtube gives us (linked artist names) instead of guessing from the text, so names like "Tyler, The Creator" stay in one piece. yt-dlp's `artists` list is used too. The text-based fallback (old saves, plain yt-dlp) knows a list of common band names with `,` / `&` in them, and matches separators case-insensitively. Affects `ga`, `gb`, `gs` and the lyrics lookup. -- Selected rows use an accent colored bold text + `▎` bar instead of a filled background. -- Tab bar went back to plain text: the active tab is accent colored, bold and underlined, inactive ones are gray with dimmed counts. (replaces the pills.) -- Tab bar only reserves space for the spinner while busy, fits narrow terminals better. -- Album tracks now get the artist/album/length filled in when youtube leaves them out. -- Tracks from a playlist get their album too, so `gb` works on them. -- Search/radio requests share one http connection instead of making a new one every time. -- New dependencies: `discord-rich-presence` (everywhere) and, on linux, `souvlaki` (zbus backend, pure rust, no system libs). `Cargo.lock` needs a regen, just run `cargo build` once. `--locked` builds, the flake and the AUR package need the new lock. - -### Fixed - -- Running several drifts at once no longer clobbers stuff. Only the first one restores and saves the session (a `session.lock` file, unix only), and saving playlists merges with whatever the other instance wrote instead of overwriting it, so playlists made in another window dont disappear. -- Collection picker (`:album`, `:playlist`) no longer pops up in the middle of typing and steals your keys, it waits until you're back in normal mode. -- "Play next" (song menu) didnt shift the "already played" marks, so shuffle could replay or skip the wrong songs after it. fixed. -- Starting a song from a playlist/results while a queue is playing no longer drags the old queue along: whats queued counts as done and only songs you add afterwards play after it. -- Playing from a playlist no longer replaces the queue. Playlists play as their own list, queue only has what you add, queued songs go first and then the playlist continues. -- Shuffle and repeat-all work for playlists and results now. repeat-all used to loop the queue instead of the list you were actualy playing. -- `P` / `:playall` doesnt wipe the queue anymore. -- Lyrics sorting no longer has an `unwrap()` that could panic on a NaN time drift diff --git a/Cargo.lock b/Cargo.lock index 1262e1a..2eaed14 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8,15 +8,6 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" -[[package]] -name = "aho-corasick" -version = "1.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" -dependencies = [ - "memchr", -] - [[package]] name = "allocator-api2" version = "0.2.21" @@ -29,239 +20,18 @@ version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" -[[package]] -name = "async-broadcast" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c48ccdbf6ca6b121e0f586cbc0e73ae440e56c67c30fa0873b4e110d9c26d2b" -dependencies = [ - "event-listener 2.5.3", - "futures-core", -] - -[[package]] -name = "async-channel" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" -dependencies = [ - "concurrent-queue", - "event-listener-strategy", - "futures-core", - "pin-project-lite", -] - -[[package]] -name = "async-executor" -version = "1.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" -dependencies = [ - "async-task", - "concurrent-queue", - "fastrand 2.5.0", - "futures-lite 2.6.1", - "pin-project-lite", - "slab", -] - -[[package]] -name = "async-fs" -version = "1.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "279cf904654eeebfa37ac9bb1598880884924aab82e290aa65c9e77a0e142e06" -dependencies = [ - "async-lock 2.8.0", - "autocfg", - "blocking", - "futures-lite 1.13.0", -] - -[[package]] -name = "async-io" -version = "1.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fc5b45d93ef0529756f812ca52e44c221b35341892d3dcc34132ac02f3dd2af" -dependencies = [ - "async-lock 2.8.0", - "autocfg", - "cfg-if", - "concurrent-queue", - "futures-lite 1.13.0", - "log", - "parking", - "polling 2.8.0", - "rustix 0.37.28", - "slab", - "socket2", - "waker-fn", -] - -[[package]] -name = "async-io" -version = "2.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" -dependencies = [ - "autocfg", - "cfg-if", - "concurrent-queue", - "futures-io", - "futures-lite 2.6.1", - "parking", - "polling 3.11.0", - "rustix 1.1.6", - "slab", - "windows-sys 0.61.2", -] - -[[package]] -name = "async-lock" -version = "2.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "287272293e9d8c41773cec55e365490fe034813a2f172f502d6ddcf75b2f582b" -dependencies = [ - "event-listener 2.5.3", -] - -[[package]] -name = "async-lock" -version = "3.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" -dependencies = [ - "event-listener 5.4.2", - "event-listener-strategy", - "pin-project-lite", -] - -[[package]] -name = "async-process" -version = "1.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea6438ba0a08d81529c69b36700fa2f95837bfe3e776ab39cde9c14d9149da88" -dependencies = [ - "async-io 1.13.0", - "async-lock 2.8.0", - "async-signal", - "blocking", - "cfg-if", - "event-listener 3.1.0", - "futures-lite 1.13.0", - "rustix 0.38.44", - "windows-sys 0.48.0", -] - -[[package]] -name = "async-recursion" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f8abc12baad266b1c8cec146854c195b5864b4221d4b2ca7296a7ae82d9e451" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.9", -] - -[[package]] -name = "async-signal" -version = "0.2.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485" -dependencies = [ - "async-io 2.6.0", - "async-lock 3.4.2", - "atomic-waker", - "cfg-if", - "futures-core", - "futures-io", - "rustix 1.1.6", - "signal-hook-registry", - "slab", - "windows-sys 0.61.2", -] - -[[package]] -name = "async-task" -version = "4.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" - -[[package]] -name = "async-trait" -version = "0.1.92" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.9", -] - -[[package]] -name = "atomic-waker" -version = "1.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" - -[[package]] -name = "autocfg" -version = "1.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" - [[package]] name = "base64" version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" -[[package]] -name = "bitflags" -version = "1.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" - [[package]] name = "bitflags" version = "2.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" -[[package]] -name = "block" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d8c1fef690941d3e7788d328517591fecc684c084084702d6ff1641e993699a" - -[[package]] -name = "block-buffer" -version = "0.10.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" -dependencies = [ - "generic-array", -] - -[[package]] -name = "blocking" -version = "1.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a70e4329df6cb94385eed412ec92375c3cdd8a6e502493d1229b6414e4036dfa" -dependencies = [ - "async-channel", - "async-task", - "futures-io", - "futures-lite 2.6.1", - "piper", -] - -[[package]] -name = "byteorder" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" - [[package]] name = "cassowary" version = "0.3.0" @@ -279,9 +49,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.10.0" +version = "1.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "86e055fa6e3e40ad6187ba25b6855eeb635d5271f8e0f0e1e9a5826b8d75bea0" +checksum = "f74872d07caf508b30a21f6836e7d7016a2eaf7d9ff4f48deaa58cd8a0407630" dependencies = [ "find-msvc-tools", "shlex", @@ -293,36 +63,6 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" -[[package]] -name = "cocoa" -version = "0.24.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f425db7937052c684daec3bd6375c8abe2d146dca4b8b143d6db777c39138f3a" -dependencies = [ - "bitflags 1.3.2", - "block", - "cocoa-foundation", - "core-foundation", - "core-graphics", - "foreign-types", - "libc", - "objc", -] - -[[package]] -name = "cocoa-foundation" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8c6234cbb2e4c785b456c0644748b1ac416dd045799740356f8363dfe00c93f7" -dependencies = [ - "bitflags 1.3.2", - "block", - "core-foundation", - "core-graphics-types", - "libc", - "objc", -] - [[package]] name = "compact_str" version = "0.8.2" @@ -337,90 +77,26 @@ dependencies = [ "static_assertions", ] -[[package]] -name = "concurrent-queue" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" -dependencies = [ - "crossbeam-utils", -] - -[[package]] -name = "core-foundation" -version = "0.9.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" -dependencies = [ - "core-foundation-sys", - "libc", -] - -[[package]] -name = "core-foundation-sys" -version = "0.8.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" - -[[package]] -name = "core-graphics" -version = "0.22.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2581bbab3b8ffc6fcbd550bf46c355135d16e9ff2a6ea032ad6b9bf1d7efe4fb" -dependencies = [ - "bitflags 1.3.2", - "core-foundation", - "core-graphics-types", - "foreign-types", - "libc", -] - -[[package]] -name = "core-graphics-types" -version = "0.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "45390e6114f68f718cc7a830514a96f903cccd70d02a8f6d9f643ac4ba45afaf" -dependencies = [ - "bitflags 1.3.2", - "core-foundation", - "libc", -] - -[[package]] -name = "cpufeatures" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" -dependencies = [ - "libc", -] - [[package]] name = "crc32fast" -version = "1.5.3" +version = "1.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ba106e844045ddc02c20768ac4b0b7cb45f2cc2ecbd833ac9f5dacb183c2055" +checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78" dependencies = [ "cfg-if", ] -[[package]] -name = "crossbeam-utils" -version = "0.8.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" - [[package]] name = "crossterm" version = "0.28.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" dependencies = [ - "bitflags 2.13.2", + "bitflags", "crossterm_winapi", "mio", "parking_lot", - "rustix 0.38.44", + "rustix", "signal-hook", "signal-hook-mio", "winapi", @@ -435,16 +111,6 @@ dependencies = [ "winapi", ] -[[package]] -name = "crypto-common" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" -dependencies = [ - "generic-array", - "typenum", -] - [[package]] name = "darling" version = "0.24.1" @@ -465,7 +131,7 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn 3.0.9", + "syn 3.0.6", ] [[package]] @@ -476,28 +142,7 @@ checksum = "2ac7135c3ef02b2f7833bbeb1be5ba7f966dcde8a87c6b87f65a778d71a02785" dependencies = [ "darling_core", "quote", - "syn 3.0.9", -] - -[[package]] -name = "derivative" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fcc3dd5e9e9c0b295d6e1e4d811fb6f157d5ffd784b8d202fc62eac8035a770b" -dependencies = [ - "proc-macro2", - "quote", - "syn 1.0.109", -] - -[[package]] -name = "digest" -version = "0.10.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" -dependencies = [ - "block-buffer", - "crypto-common", + "syn 3.0.6", ] [[package]] @@ -521,27 +166,6 @@ dependencies = [ "windows-sys 0.48.0", ] -[[package]] -name = "discord-rich-presence" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90c55d69cab17c19677ce3a5f8face993a9e6eaf847fecac3547f3a3ff4a2494" -dependencies = [ - "log", - "serde", - "serde_derive", - "serde_json", - "serde_repr", - "thiserror 2.0.21", - "uuid", -] - -[[package]] -name = "dispatch" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bd0c93bb4b0c6d9b77f4435b0ae98c24d17f1c45b2ff844c6151a07256ca923b" - [[package]] name = "displaydoc" version = "0.2.7" @@ -550,7 +174,7 @@ checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" dependencies = [ "proc-macro2", "quote", - "syn 3.0.9", + "syn 3.0.6", ] [[package]] @@ -559,12 +183,10 @@ version = "0.1.0" dependencies = [ "anyhow", "dirs", - "discord-rich-presence", "libc", "ratatui", "serde", "serde_json", - "souvlaki", "toml", "ureq", ] @@ -575,32 +197,11 @@ version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0e9c71c2167ca323c882b99918929403426e2373ea17242ff5653e0d5e1058be" -[[package]] -name = "enumflags2" -version = "0.7.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" -dependencies = [ - "enumflags2_derive", - "serde", -] - -[[package]] -name = "enumflags2_derive" -version = "0.7.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - [[package]] name = "equivalent" -version = "1.0.3" +version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" [[package]] name = "errno" @@ -612,63 +213,11 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "event-listener" -version = "2.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0206175f82b8d6bf6652ff7d71a1e27fd2e4efde587fd368662814d6ec1d9ce0" - -[[package]] -name = "event-listener" -version = "3.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d93877bcde0eb80ca09131a08d23f0a5c18a620b01db137dba666d18cd9b30c2" -dependencies = [ - "concurrent-queue", - "parking", - "pin-project-lite", -] - -[[package]] -name = "event-listener" -version = "5.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" -dependencies = [ - "parking", - "pin-project-lite", -] - -[[package]] -name = "event-listener-strategy" -version = "0.5.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" -dependencies = [ - "event-listener 5.4.2", - "pin-project-lite", -] - -[[package]] -name = "fastrand" -version = "1.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e51093e27b0797c359783294ca4f0a911c270184cb10f85783b118614a1501be" -dependencies = [ - "instant", -] - -[[package]] -name = "fastrand" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" - [[package]] name = "find-msvc-tools" -version = "0.1.15" +version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1c66915cc5391097de7a2b0b972e8505aa33c0df401fd3a6811dbdb32881ba48" +checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" [[package]] name = "flate2" @@ -687,21 +236,6 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" -[[package]] -name = "foreign-types" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" -dependencies = [ - "foreign-types-shared", -] - -[[package]] -name = "foreign-types-shared" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" - [[package]] name = "form_urlencoded" version = "1.2.2" @@ -711,83 +245,6 @@ dependencies = [ "percent-encoding", ] -[[package]] -name = "futures-core" -version = "0.3.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" - -[[package]] -name = "futures-io" -version = "0.3.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" - -[[package]] -name = "futures-lite" -version = "1.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49a9d51ce47660b1e808d3c990b4709f2f415d928835a17dfd16991515c46bce" -dependencies = [ - "fastrand 1.9.0", - "futures-core", - "futures-io", - "memchr", - "parking", - "pin-project-lite", - "waker-fn", -] - -[[package]] -name = "futures-lite" -version = "2.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" -dependencies = [ - "fastrand 2.5.0", - "futures-core", - "futures-io", - "parking", - "pin-project-lite", -] - -[[package]] -name = "futures-sink" -version = "0.3.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" - -[[package]] -name = "futures-task" -version = "0.3.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" - -[[package]] -name = "futures-util" -version = "0.3.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" -dependencies = [ - "futures-core", - "futures-io", - "futures-sink", - "futures-task", - "memchr", - "pin-project-lite", - "slab", -] - -[[package]] -name = "generic-array" -version = "0.14.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" -dependencies = [ - "typenum", - "version_check", -] - [[package]] name = "getrandom" version = "0.2.17" @@ -799,17 +256,6 @@ dependencies = [ "wasi", ] -[[package]] -name = "getrandom" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" -dependencies = [ - "cfg-if", - "libc", - "r-efi", -] - [[package]] name = "hashbrown" version = "0.15.5" @@ -833,29 +279,11 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" -[[package]] -name = "hermit-abi" -version = "0.3.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d231dfb89cfffdbc30e7fc41579ed6066ad03abda9e567ccafae602b97ec5024" - -[[package]] -name = "hermit-abi" -version = "0.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e17592d60ebacc7d5e169f4663c5f84f9161cc90328abcfe8456f41e4dfcb284" - -[[package]] -name = "hex" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" - [[package]] name = "icu_collections" -version = "2.4.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fe090a42a0d3140fed8c63263ed1fecc02422cb2f4bfe55cd8de2de1b8e105dd" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" dependencies = [ "displaydoc", "potential_utf", @@ -867,9 +295,9 @@ dependencies = [ [[package]] name = "icu_locale_core" -version = "2.4.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25d29b57c17c6528d0dc4c2141cfa2d08200465acb537a3909c8b9b8a4a984c0" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" dependencies = [ "displaydoc", "litemap", @@ -880,9 +308,9 @@ dependencies = [ [[package]] name = "icu_normalizer" -version = "2.4.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "404a3b8c486f205c87fe7f23ab6894d84a4ad76d6e4671ae1981ba7def87c599" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" dependencies = [ "icu_collections", "icu_normalizer_data", @@ -894,15 +322,15 @@ dependencies = [ [[package]] name = "icu_normalizer_data" -version = "2.4.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20f0088bfb7441f7a37fa0c9e20cb1e9cba1cac6b9ce8cd2a6dc44294f8e8cbc" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" [[package]] name = "icu_properties" -version = "2.4.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ea83d991c56158f7cf898bf86436c1dfe7c07ee82fe88951f2695c9f37a379f" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" dependencies = [ "displaydoc", "icu_collections", @@ -915,15 +343,15 @@ dependencies = [ [[package]] name = "icu_properties_data" -version = "2.4.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b98f29af82d02b163fe50161388350e743bb3107cc8e8c2f7314cb505faddfcf" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" [[package]] name = "icu_provider" -version = "2.4.0" +version = "2.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "413099315636c588ee8f011190b329b971a09a8fae9d157e01dcae8379468514" +checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" dependencies = [ "displaydoc", "icu_locale_core", @@ -973,9 +401,9 @@ dependencies = [ [[package]] name = "indoc" -version = "2.0.8" +version = "2.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a37b2691796cffeb8a8cd305ac66e65841559f147f4e63231d0eafa4db5384d1" +checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" dependencies = [ "rustversion", ] @@ -990,27 +418,7 @@ dependencies = [ "indoc", "proc-macro2", "quote", - "syn 3.0.9", -] - -[[package]] -name = "instant" -version = "0.1.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e0242819d153cba4b4b05a5a8f2a7e9bbf97b6055b2a002b395c96b5ff3c0222" -dependencies = [ - "cfg-if", -] - -[[package]] -name = "io-lifetimes" -version = "1.0.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eae7b9aee968036d54dce06cebaefd919e4472e753296daccd6d344e3e2df0c2" -dependencies = [ - "hermit-abi 0.3.9", - "libc", - "windows-sys 0.48.0", + "syn 3.0.6", ] [[package]] @@ -1030,9 +438,9 @@ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] name = "libc" -version = "0.2.191" +version = "0.2.190" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a" +checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78" [[package]] name = "libredox" @@ -1043,24 +451,12 @@ dependencies = [ "libc", ] -[[package]] -name = "linux-raw-sys" -version = "0.3.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef53942eb7bf7ff43a617b3e2c1c4a5ecf5944a7c1bc12d7ee39bbb15e5c1519" - [[package]] name = "linux-raw-sys" version = "0.4.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" -[[package]] -name = "linux-raw-sys" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" - [[package]] name = "litemap" version = "0.8.3" @@ -1091,39 +487,12 @@ dependencies = [ "hashbrown 0.15.5", ] -[[package]] -name = "malloc_buf" -version = "0.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62bb907fe88d54d8d9ce32a3cceab4218ed2f6b7d35617cafe9adf84e43919cb" -dependencies = [ - "libc", -] - [[package]] name = "memchr" version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" -[[package]] -name = "memoffset" -version = "0.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5de893c32cde5f383baa4c04c5d6dbdd735cfd4a794b0debdb2bb1b421da5ff4" -dependencies = [ - "autocfg", -] - -[[package]] -name = "memoffset" -version = "0.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" -dependencies = [ - "autocfg", -] - [[package]] name = "miniz_oxide" version = "0.9.1" @@ -1146,27 +515,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "nix" -version = "0.26.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "598beaf3cc6fdd9a5dfb1630c2800c7acd31df7aaf0f565796fba2b53ca1af1b" -dependencies = [ - "bitflags 1.3.2", - "cfg-if", - "libc", - "memoffset 0.7.1", -] - -[[package]] -name = "objc" -version = "0.2.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "915b1b472bc21c53464d6c8461c9d3af805ba1ef837e1cac254428f4a77177b1" -dependencies = [ - "malloc_buf", -] - [[package]] name = "once_cell" version = "1.21.4" @@ -1179,22 +527,6 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" -[[package]] -name = "ordered-stream" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" -dependencies = [ - "futures-core", - "pin-project-lite", -] - -[[package]] -name = "parking" -version = "2.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" - [[package]] name = "parking_lot" version = "0.12.5" @@ -1230,59 +562,6 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" -[[package]] -name = "pin-project-lite" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" - -[[package]] -name = "piper" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" -dependencies = [ - "atomic-waker", - "fastrand 2.5.0", - "futures-io", -] - -[[package]] -name = "polling" -version = "2.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b2d323e8ca7996b3e23126511a523f7e62924d93ecd5ae73b333815b0eb3dce" -dependencies = [ - "autocfg", - "bitflags 1.3.2", - "cfg-if", - "concurrent-queue", - "libc", - "log", - "pin-project-lite", - "windows-sys 0.48.0", -] - -[[package]] -name = "polling" -version = "3.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" -dependencies = [ - "cfg-if", - "concurrent-queue", - "hermit-abi 0.5.3", - "pin-project-lite", - "rustix 1.1.6", - "windows-sys 0.61.2", -] - -[[package]] -name = "pollster" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22686f4785f02a4fcc856d3b3bb19bf6c8160d103f7a99cc258bddd0251dc7f2" - [[package]] name = "potential_utf" version = "0.1.6" @@ -1292,25 +571,6 @@ dependencies = [ "zerovec", ] -[[package]] -name = "ppv-lite86" -version = "0.2.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" -dependencies = [ - "zerocopy", -] - -[[package]] -name = "proc-macro-crate" -version = "1.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f4c021e1093a56626774e81216a4ce732a735e5bad4868a03f3ed65ca0c3919" -dependencies = [ - "once_cell", - "toml_edit 0.19.15", -] - [[package]] name = "proc-macro2" version = "1.0.107" @@ -1329,49 +589,13 @@ dependencies = [ "proc-macro2", ] -[[package]] -name = "r-efi" -version = "6.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" - -[[package]] -name = "rand" -version = "0.8.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" -dependencies = [ - "libc", - "rand_chacha", - "rand_core", -] - -[[package]] -name = "rand_chacha" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" -dependencies = [ - "ppv-lite86", - "rand_core", -] - -[[package]] -name = "rand_core" -version = "0.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" -dependencies = [ - "getrandom 0.2.17", -] - [[package]] name = "ratatui" version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eabd94c2f37801c20583fc49dd5cd6b0ba68c716787c2dd6ed18571e1e63117b" dependencies = [ - "bitflags 2.13.2", + "bitflags", "cassowary", "compact_str", "crossterm", @@ -1392,7 +616,7 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags 2.13.2", + "bitflags", ] [[package]] @@ -1401,40 +625,11 @@ version = "0.4.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" dependencies = [ - "getrandom 0.2.17", + "getrandom", "libredox", - "thiserror 1.0.69", + "thiserror", ] -[[package]] -name = "regex" -version = "1.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" -dependencies = [ - "aho-corasick", - "memchr", - "regex-automata", - "regex-syntax", -] - -[[package]] -name = "regex-automata" -version = "0.4.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" -dependencies = [ - "aho-corasick", - "memchr", - "regex-syntax", -] - -[[package]] -name = "regex-syntax" -version = "0.8.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" - [[package]] name = "ring" version = "0.17.14" @@ -1443,57 +638,30 @@ checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" dependencies = [ "cc", "cfg-if", - "getrandom 0.2.17", + "getrandom", "libc", "untrusted", "windows-sys 0.52.0", ] -[[package]] -name = "rustix" -version = "0.37.28" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "519165d378b97752ca44bbe15047d5d3409e875f39327546b42ac81d7e18c1b6" -dependencies = [ - "bitflags 1.3.2", - "errno", - "io-lifetimes", - "libc", - "linux-raw-sys 0.3.8", - "windows-sys 0.48.0", -] - [[package]] name = "rustix" version = "0.38.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" dependencies = [ - "bitflags 2.13.2", + "bitflags", "errno", "libc", - "linux-raw-sys 0.4.15", + "linux-raw-sys", "windows-sys 0.59.0", ] -[[package]] -name = "rustix" -version = "1.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e5e76742e5294153d4dde1207dbec5bdd81eef6fabdc0d2118babadabfd858aa" -dependencies = [ - "bitflags 2.13.2", - "errno", - "libc", - "linux-raw-sys 0.12.1", - "windows-sys 0.61.2", -] - [[package]] name = "rustls" -version = "0.23.46" +version = "0.23.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48e13bd8c0e9365c43cfa5c9e8f9ad49d3c8444926c9aac819e0e4dc503c8fdf" +checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" dependencies = [ "log", "once_cell", @@ -1515,9 +683,9 @@ dependencies = [ [[package]] name = "rustls-webpki" -version = "0.103.16" +version = "0.103.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4443b1b6d39d7c663139942cc4558262562d2c84123bb6282ef177009ca8b281" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" dependencies = [ "ring", "rustls-pki-types", @@ -1569,14 +737,14 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn 3.0.9", + "syn 3.0.6", ] [[package]] name = "serde_json" -version = "1.0.154" +version = "1.0.151" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" dependencies = [ "itoa", "memchr", @@ -1585,17 +753,6 @@ dependencies = [ "zmij", ] -[[package]] -name = "serde_repr" -version = "0.1.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.9", -] - [[package]] name = "serde_spanned" version = "0.6.9" @@ -1605,17 +762,6 @@ dependencies = [ "serde", ] -[[package]] -name = "sha1" -version = "0.10.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" -dependencies = [ - "cfg-if", - "cpufeatures", - "digest", -] - [[package]] name = "shlex" version = "2.0.1" @@ -1659,46 +805,11 @@ version = "0.3.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" -[[package]] -name = "slab" -version = "0.4.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" - [[package]] name = "smallvec" -version = "1.16.4" +version = "1.16.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0d5d4b1981b8bbbb7aed204cae5726d683d5d6ac7e74ec54122ea28f315e7c8" - -[[package]] -name = "socket2" -version = "0.4.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f7916fc008ca5542385b89a3d3ce689953c143e9304a9bf8beec1de48994c0d" -dependencies = [ - "libc", - "winapi", -] - -[[package]] -name = "souvlaki" -version = "0.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5855c8f31521af07d896b852eaa9eca974ddd3211fc2ae292e58dda8eb129bc8" -dependencies = [ - "base64", - "block", - "cocoa", - "core-graphics", - "dispatch", - "objc", - "pollster", - "thiserror 1.0.69", - "windows", - "zbus", - "zvariant", -] +checksum = "f9395f0f0eee849a9b707b2f06bb92a6a422090e2123bb2ef8e87a0e61892a8e" [[package]] name = "stable_deref_trait" @@ -1746,17 +857,6 @@ version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" -[[package]] -name = "syn" -version = "1.0.109" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - [[package]] name = "syn" version = "2.0.119" @@ -1770,9 +870,9 @@ dependencies = [ [[package]] name = "syn" -version = "3.0.9" +version = "3.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" +checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee" dependencies = [ "proc-macro2", "quote", @@ -1787,20 +887,7 @@ checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" dependencies = [ "proc-macro2", "quote", - "syn 3.0.9", -] - -[[package]] -name = "tempfile" -version = "3.27.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" -dependencies = [ - "fastrand 2.5.0", - "getrandom 0.4.3", - "once_cell", - "rustix 1.1.6", - "windows-sys 0.61.2", + "syn 3.0.6", ] [[package]] @@ -1809,16 +896,7 @@ version = "1.0.69" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" dependencies = [ - "thiserror-impl 1.0.69", -] - -[[package]] -name = "thiserror" -version = "2.0.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" -dependencies = [ - "thiserror-impl 2.0.21", + "thiserror-impl", ] [[package]] @@ -1832,22 +910,11 @@ dependencies = [ "syn 2.0.119", ] -[[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.9", -] - [[package]] name = "tinystr" -version = "0.8.5" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "abc840c238154d58f9e86b2792261edfde565a17696e1cda1a449de27979801a" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" dependencies = [ "displaydoc", "zerovec", @@ -1862,7 +929,7 @@ dependencies = [ "serde", "serde_spanned", "toml_datetime", - "toml_edit 0.22.27", + "toml_edit", ] [[package]] @@ -1874,17 +941,6 @@ dependencies = [ "serde", ] -[[package]] -name = "toml_edit" -version = "0.19.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421" -dependencies = [ - "indexmap", - "toml_datetime", - "winnow 0.5.40", -] - [[package]] name = "toml_edit" version = "0.22.27" @@ -1896,7 +952,7 @@ dependencies = [ "serde_spanned", "toml_datetime", "toml_write", - "winnow 0.7.15", + "winnow", ] [[package]] @@ -1905,59 +961,11 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" -[[package]] -name = "tracing" -version = "0.1.44" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" -dependencies = [ - "pin-project-lite", - "tracing-attributes", - "tracing-core", -] - -[[package]] -name = "tracing-attributes" -version = "0.1.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "tracing-core" -version = "0.1.36" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" -dependencies = [ - "once_cell", -] - -[[package]] -name = "typenum" -version = "1.20.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" - -[[package]] -name = "uds_windows" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" -dependencies = [ - "memoffset 0.9.1", - "tempfile", - "windows-sys 0.61.2", -] - [[package]] name = "unicode-ident" -version = "1.0.27" +version = "1.0.26" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" +checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" [[package]] name = "unicode-segmentation" @@ -2030,27 +1038,6 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" -[[package]] -name = "uuid" -version = "0.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc5cf98d8186244414c848017f0e2676b3fcb46807f6668a97dfe67359a3c4b7" -dependencies = [ - "getrandom 0.2.17", -] - -[[package]] -name = "version_check" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" - -[[package]] -name = "waker-fn" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "317211a0dc0ceedd78fb2ca9a44aed3d7b9b26f81870d485c07122b4350673b7" - [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" @@ -2063,14 +1050,14 @@ version = "0.26.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" dependencies = [ - "webpki-roots 1.0.11", + "webpki-roots 1.0.9", ] [[package]] name = "webpki-roots" -version = "1.0.11" +version = "1.0.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f5f280bbb2b287bc8e7de7e3852945a0ff50d1bbcb5ed42b6c7a568c546df171" +checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" dependencies = [ "rustls-pki-types", ] @@ -2097,15 +1084,6 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" -[[package]] -name = "windows" -version = "0.44.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e745dab35a0c4c77aa3ce42d595e13d2003d6902d6b08c9ef5fc326d08da12b" -dependencies = [ - "windows-targets 0.42.2", -] - [[package]] name = "windows-link" version = "0.2.1" @@ -2148,21 +1126,6 @@ dependencies = [ "windows-link", ] -[[package]] -name = "windows-targets" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" -dependencies = [ - "windows_aarch64_gnullvm 0.42.2", - "windows_aarch64_msvc 0.42.2", - "windows_i686_gnu 0.42.2", - "windows_i686_msvc 0.42.2", - "windows_x86_64_gnu 0.42.2", - "windows_x86_64_gnullvm 0.42.2", - "windows_x86_64_msvc 0.42.2", -] - [[package]] name = "windows-targets" version = "0.48.5" @@ -2194,12 +1157,6 @@ dependencies = [ "windows_x86_64_msvc 0.52.6", ] -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" - [[package]] name = "windows_aarch64_gnullvm" version = "0.48.5" @@ -2212,12 +1169,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" -[[package]] -name = "windows_aarch64_msvc" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" - [[package]] name = "windows_aarch64_msvc" version = "0.48.5" @@ -2230,12 +1181,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" -[[package]] -name = "windows_i686_gnu" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" - [[package]] name = "windows_i686_gnu" version = "0.48.5" @@ -2254,12 +1199,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" -[[package]] -name = "windows_i686_msvc" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" - [[package]] name = "windows_i686_msvc" version = "0.48.5" @@ -2272,12 +1211,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" -[[package]] -name = "windows_x86_64_gnu" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" - [[package]] name = "windows_x86_64_gnu" version = "0.48.5" @@ -2290,12 +1223,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" - [[package]] name = "windows_x86_64_gnullvm" version = "0.48.5" @@ -2308,12 +1235,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" -[[package]] -name = "windows_x86_64_msvc" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" - [[package]] name = "windows_x86_64_msvc" version = "0.48.5" @@ -2326,15 +1247,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" -[[package]] -name = "winnow" -version = "0.5.40" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f593a95398737aeed53e489c785df13f3618e41dbcd6718c6addbf1395aa6876" -dependencies = [ - "memchr", -] - [[package]] name = "winnow" version = "0.7.15" @@ -2350,21 +1262,11 @@ version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" -[[package]] -name = "xdg-home" -version = "1.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec1cdab258fb55c0da61328dc52c8764709b249011b2cad0454c72f0bf10a1f6" -dependencies = [ - "libc", - "windows-sys 0.59.0", -] - [[package]] name = "yoke" -version = "0.8.4" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "417ef3fcf6c300b7dbd25c85531a746931c02ad536f3cf4786ed1e88223340b3" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" dependencies = [ "stable_deref_trait", "yoke-derive", @@ -2379,96 +1281,10 @@ checksum = "ec8ebde2db3681e8c9980cc27822030e68752690ddfa9473e739aeb4dbde6d71" dependencies = [ "proc-macro2", "quote", - "syn 3.0.9", + "syn 3.0.6", "synstructure", ] -[[package]] -name = "zbus" -version = "3.15.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "675d170b632a6ad49804c8cf2105d7c31eddd3312555cffd4b740e08e97c25e6" -dependencies = [ - "async-broadcast", - "async-executor", - "async-fs", - "async-io 1.13.0", - "async-lock 2.8.0", - "async-process", - "async-recursion", - "async-task", - "async-trait", - "blocking", - "byteorder", - "derivative", - "enumflags2", - "event-listener 2.5.3", - "futures-core", - "futures-sink", - "futures-util", - "hex", - "nix", - "once_cell", - "ordered-stream", - "rand", - "serde", - "serde_repr", - "sha1", - "static_assertions", - "tracing", - "uds_windows", - "winapi", - "xdg-home", - "zbus_macros", - "zbus_names", - "zvariant", -] - -[[package]] -name = "zbus_macros" -version = "3.15.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7131497b0f887e8061b430c530240063d33bf9455fa34438f388a245da69e0a5" -dependencies = [ - "proc-macro-crate", - "proc-macro2", - "quote", - "regex", - "syn 1.0.109", - "zvariant_utils", -] - -[[package]] -name = "zbus_names" -version = "2.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "437d738d3750bed6ca9b8d423ccc7a8eb284f6b1d6d4e225a0e4e6258d864c8d" -dependencies = [ - "serde", - "static_assertions", - "zvariant", -] - -[[package]] -name = "zerocopy" -version = "0.8.63" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e5fe1f8f1b06191a00962174c61aa5005e0bb391a6d80d07e24d115c01a92ed8" -dependencies = [ - "zerocopy-derive", -] - -[[package]] -name = "zerocopy-derive" -version = "0.8.63" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "863ad3ac83293fb4d740aedbfdc9240dd8d1a50c1099acd76ce80ce7c7230c7f" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - [[package]] name = "zerofrom" version = "0.1.8" @@ -2486,7 +1302,7 @@ checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" dependencies = [ "proc-macro2", "quote", - "syn 3.0.9", + "syn 3.0.6", "synstructure", ] @@ -2509,9 +1325,9 @@ dependencies = [ [[package]] name = "zerovec" -version = "0.11.9" +version = "0.11.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f58a2799cfe9a1670c7ea8edbd52ef8edb93cb37a7f83bf31ba9e35d9dae6a40" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" dependencies = [ "yoke", "zerofrom", @@ -2520,13 +1336,13 @@ dependencies = [ [[package]] name = "zerovec-derive" -version = "0.11.7" +version = "0.11.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3743ddbf47738d929cd1ae2e199d40d0162979dfe31aef8ae646d2bf00bf97a" +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" dependencies = [ "proc-macro2", "quote", - "syn 3.0.9", + "syn 3.0.6", ] [[package]] @@ -2540,41 +1356,3 @@ name = "zmij" version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" - -[[package]] -name = "zvariant" -version = "3.15.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4eef2be88ba09b358d3b58aca6e41cd853631d44787f319a1383ca83424fb2db" -dependencies = [ - "byteorder", - "enumflags2", - "libc", - "serde", - "static_assertions", - "zvariant_derive", -] - -[[package]] -name = "zvariant_derive" -version = "3.15.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37c24dc0bed72f5f90d1f8bb5b07228cbf63b3c6e9f82d82559d4bae666e7ed9" -dependencies = [ - "proc-macro-crate", - "proc-macro2", - "quote", - "syn 1.0.109", - "zvariant_utils", -] - -[[package]] -name = "zvariant_utils" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7234f0d811589db492d16893e3f21e8e2fd282e6d01b0cddee310322062cc200" -dependencies = [ - "proc-macro2", - "quote", - "syn 1.0.109", -] diff --git a/Cargo.toml b/Cargo.toml index 22d50f9..582b230 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,7 +3,7 @@ name = "drift" version = "0.1.0" edition = "2021" description = "A Vim-style terminal music player for YouTube Music (no login required)" -license = "GPL-3.0-or-later" +license = "MIT" [workspace] @@ -16,10 +16,6 @@ toml = "0.8" ureq = { version = "2", features = ["json"] } dirs = "5" libc = "0.2" -discord-rich-presence = "1.1.0" - -[target.'cfg(target_os = "linux")'.dependencies] -souvlaki = { version = "0.8", default-features = false, features = ["use_zbus"] } [profile.release] lto = true diff --git a/README.md b/README.md index f5e0613..b8f7085 100644 --- a/README.md +++ b/README.md @@ -1,322 +1,81 @@
-# Drift - -### A YouTube Music player for your terminal, with Vim keybinds. - -Search, queue and play music with no account, no login and no browser. +

Drift

[![Stars](https://img.shields.io/github/stars/socatlolmeow/drift?style=flat-square&logo=github)](https://github.com/socatlolmeow/drift/stargazers) -[![Build](https://img.shields.io/github/actions/workflow/status/socatlolmeow/drift/rust.yml?branch=main&style=flat-square&label=build)](https://github.com/socatlolmeow/drift/actions/workflows/rust.yml) -[![License](https://img.shields.io/github/license/socatlolmeow/drift?style=flat-square)](LICENSE) -![Rust](https://img.shields.io/badge/built%20with-Rust-orange?style=flat-square&logo=rust) +![License](https://img.shields.io/github/license/socatlolmeow/drift?style=flat-square) -Drift showcase +### A music streaming client using Vim keybinds built in Rust. + +Stream your favourite music from YouTube Music — all in one client, in your terminal.
--- -## Table of contents - -- [Features](#features) -- [Installation](#installation) - - [Arch Linux (AUR)](#arch-linux-aur) - - [Install script](#install-script) - - [From source](#from-source) - - [Nix](#nix) - - [Installing mpv and yt-dlp manually](#installing-mpv-and-yt-dlp-manually) -- [Usage](#usage) -- [Keybinds](#keybinds) -- [Commands](#commands) -- [Lyrics](#lyrics) -- [Configuration](#configuration) - - [Streaming or downloading](#streaming-or-downloading) - - [Discord rich presence](#discord-rich-presence) - - [Custom keys](#custom-keys) - - [Environment variables](#environment-variables) -- [Troubleshooting](#troubleshooting) -- [Contributing](#contributing) -- [License](#license) +
+ Drift showcase +
--- ## Features -- Vim-style keybinds: `j`/`k`, `gg`/`ge`, counts like `5j`, and `:` for the command line -- Synced lyrics in a side panel or fullscreen, with the current line filled in as it's sung -- Queue and playlists. Build a queue, save and reorder playlists, or import any YouTube / YouTube Music playlist by URL -- Discovery: `ga` for an artist's songs, `gs` for similar songs, `gb` for the album, and `:album` / `:playlist` to search for albums and playlists -- A song menu (`G`) to play a song, queue it, add it to a playlist, jump to the artist or see its info -- A command palette with completion and history -- Your queue and position are restored the next time you start Drift -- Media keys and `playerctl` on Linux (MPRIS), plus optional Discord rich presence -- One TOML file for volume, seek step, shuffle/repeat defaults, accent color and custom keybinds +- **YouTube Music streaming** — search and stream tracks directly from YouTube Music via `yt-dlp` and `mpv` +- **Vim-style keybinds** — navigate with `j`/`k`, enter commands with `:`, quit with `q` +- **Queue management** — add, remove, reorder, and clear tracks in your queue +- **Playlists** — save, load, append, and delete named playlists; import from YouTube/YouTube Music URLs +- **Shuffle & repeat** — toggle shuffle and cycle through off / repeat-all / repeat-one modes +- **Volume & seek control** — adjust volume and seek with configurable step sizes +- **Session restore** — automatically restores your queue and playback position on next launch +- **Artist search** — drill into an artist's discography from the results view +- **Command palette** — full `:command` interface (`:search`, `:play`, `:volume`, `:seek`, `:save`, `:load`, `:import`, and more) +- **Autoplay** — optionally keep playing through results when the queue ends +- **TOML config** — persistent config at `~/.config/drift/config.toml` (your platform's config directory); drift never rewrites it, so comments are preserved + +--- ## Installation -Drift needs [mpv](https://mpv.io/) for playback and [yt-dlp](https://github.com/yt-dlp/yt-dlp) to get the audio streams. Run `drift --check` at any time to see if both are installed. +### Dependencies -### Arch Linux (AUR) +- [mpv](https://mpv.io/) — audio playback +- [yt-dlp](https://github.com/yt-dlp/yt-dlp) — YouTube stream extraction +- [Rust](https://rustup.rs/) (build only) -```bash -yay -S driftmusic-git +Install them with your package manager, e.g. on Arch: +``` +sudo pacman -S mpv yt-dlp ``` -Any AUR helper works. You can also build it by hand: - -```bash -git clone https://aur.archlinux.org/driftmusic-git.git -cd driftmusic-git -makepkg -si -``` - -`mpv` and `yt-dlp` are pulled in automatically. - -### Install script - -Works on Arch, Debian/Ubuntu, Fedora, openSUSE, Void, Alpine and Gentoo. It installs the dependencies and builds Drift with cargo. +### Build & install ```bash git clone https://github.com/socatlolmeow/drift.git cd drift -./install.sh +cargo build --release ``` -### From source +Then copy the binary to somewhere on your `$PATH`: +```bash +sudo cp target/release/drift /usr/local/bin/ +``` -Install the dependencies and [Rust](https://rustup.rs/), then: - +Or install directly with cargo: ```bash -git clone https://github.com/socatlolmeow/drift.git -cd drift cargo install --path . ``` -This puts `drift` in `~/.cargo/bin/`, which is on your `$PATH` if you installed Rust with rustup. +This installs `drift` to `~/.cargo/bin/`, which is on your `$PATH` if you installed Rust via [rustup](https://rustup.rs/). -### Nix +--- -The repo has a flake that puts `mpv` and `yt-dlp` on Drift's `PATH` for you: +## Star History -```bash -nix run github:socatlolmeow/drift -``` +[![Star History Chart](https://api.star-history.com/svg?repos=socatlolmeow/drift&type=Date)](https://star-history.com/#socatlolmeow/drift) -### Installing mpv and yt-dlp manually - -| Platform | Command | -| --- | --- | -| Debian / Ubuntu | `sudo apt install mpv` and `pipx install yt-dlp` | -| macOS | `brew install mpv yt-dlp` | -| Arch | `sudo pacman -S mpv yt-dlp` | -| Windows | `winget install mpv.net yt-dlp.yt-dlp` or `scoop install mpv yt-dlp` | - -## Usage - -```bash -drift # start the player -drift "daft punk" # start with a search -drift --check # check that mpv and yt-dlp are installed -drift --help -drift --version -``` - -Press `/` to search, `Enter` to play, `a` to add a song to the queue and `?` for the full list of keybinds. Inside the player, `:help` lists the commands. - -## Keybinds - -These are the defaults. You can change them, see [Custom keys](#custom-keys). - -| Key | Action | -| --- | --- | -| `j` `k` / `↓` `↑` | move (counts work: `5j`) | -| `gg` `ge` | jump to top / bottom (`5gg` goes to row 5) | -| `Tab` `Shift-Tab` | cycle Results / Queue / Playlists | -| `gr` `gq` `gp` | jump to Results / Queue / Playlists | -| `Enter` | play the selected song | -| `Space` | pause / resume | -| `n` `N` | next / previous track | -| `h` `l` / `←` `→` | seek back / forward (counts: `6l`) | -| `+` `-` | volume up / down | -| `s` `r` | toggle shuffle / cycle repeat | -| `a` `A` `P` | queue the song / queue all results / play all results | -| `dd` `x` | remove from queue or playlist | -| `J` `K` | move a queue or playlist item down / up | -| `G` | song menu | -| `ga` | go to the artist | -| `gs` | see similar songs | -| `gb` | go to the album | -| `gB` | save the album as a playlist | -| `L` | toggle the lyrics panel | -| `F` | fullscreen lyrics | -| `/` `:` | search / command line | -| `?` | help | -| `q` | quit | - -## Commands - -Press `:` to open the command line. Most commands have short aliases (`:vol`, `:sa`, `:ar` and so on). - -| Command | Action | -| --- | --- | -| `:search "query"` | search YouTube Music | -| `:play [N]` | play or resume, or play row `N` | -| `:pause` `:resume` `:toggle` `:stop` | control playback | -| `:next` `:prev` | next / previous track | -| `:seek 90` `1:30` `+10` | absolute or relative seek | -| `:vol 50` `+5` | set or change the volume | -| `:shuffle` `:repeat [off\|all\|one]` | playback modes | -| `:add [N]` `:addall` `:playall` | queue results | -| `:remove [N]` `:clear` | remove from the queue / clear it | -| `:save name` `:load name` `:append name` | work with playlists | -| `:save! name` | save and overwrite an existing playlist | -| `:savealbum [name]` `:saveresults [name]` | save an album or the results directly as a playlist | -| `:addto name` `:delpl name` | add a song to / delete a playlist | -| `:import URL` | import a YouTube / YouTube Music playlist | -| `:album [query]` | open the selected song's album, or search albums | -| `:playlist query` | search YouTube Music playlists | -| `:similar` | songs similar to the selected one (also `:radio`) | -| `:artist` | songs by the selected song's artist | -| `:results` `:queue` `:playlists` | switch view | -| `:lyrics` | toggle the lyrics panel | -| `:quit` | quit | - -`:save` refuses to overwrite an existing playlist. Use `:save!` to replace it or `:append` to add to the end of it. - -## Lyrics - -Press `L` for a side panel (needs a terminal at least 70 columns wide) or `F` for fullscreen. The current line is highlighted and fills in as it's sung. Songs without synced lyrics show plain text that you can scroll with `j`/`k`. - -Lyrics come from [LRCLIB](https://lrclib.net) and are only fetched while the panel or fullscreen view is open. - -## Configuration - -Settings live in `config.toml` inside Drift's config directory (`~/.config/drift/` on Linux). Drift only reads the file and never rewrites it, so your comments are safe. Restart Drift after editing. - -```toml -[playback] -volume = 80 -shuffle = false -repeat = "off" # off, all or one -autoplay = false -restore_session = true -seek_step = 5.0 -volume_step = 5.0 - -[artist] -popular_first = false - -[ui] -accent = "cyan" # a color name or a hex value like "#FF8800" - -[discord] -client_id = "" # set this to enable Discord rich presence - -[audio] -download = false # false: stream songs; true: download to the cache first -download_max_minutes = 15 # with download = true, longer songs are still streamed (0 = no limit) -cookies_from_browser = "" # e.g. "firefox" or "chrome"; passed to yt-dlp when YouTube blocks playback - -[keys] -quit = "Q" -move_down = ["j", "down", "ctrl+n"] -move_up = ["k", "up", "ctrl+p"] -go_top = ["gg", "home"] -shuffle = [] # an empty list unbinds the action -``` - -If you already have a `config.toml` from an older version, add the `[audio]` section by hand, since Drift never rewrites the file. - -### Streaming or downloading - -By default Drift streams songs, so playback starts right away, even for hour-long tracks. With `download = true` songs are cached on disk first, and the next song in the queue is fetched in the background while the current one plays. - -### Discord rich presence - -This is off by default. - -1. Create an application at https://discord.com/developers/applications. Its name is what shows up after "Listening to". -2. Copy the Application ID into `client_id` under `[discord]` in `config.toml`. -3. Restart Drift. - -Your status then shows the current song with cover art, album, a progress bar and a "Listen Along" button. If Discord isn't running, Drift keeps retrying quietly in the background. - -### Custom keys - -To change keys, add a `[keys]` section to `config.toml`. Each line sets an action to one key or a list of keys and replaces that action's default keys. Everything you leave out keeps its default. - -```toml -[keys] -quit = "Q" -move_down = ["j", "down", "ctrl+n"] -move_up = ["k", "up", "ctrl+p"] -go_top = ["gg", "home"] -shuffle = [] # an empty list unbinds the action -``` - -A key is a character (`"x"`, `"G"`, `"/"`), a name (`space`, `enter`, `esc`, `tab`, `backtab`, `backspace`, `delete`, `insert`, `home`, `end`, `pageup`, `pagedown`, `up`, `down`, `left`, `right`, `menu`, `f1` to `f12`), `ctrl+` plus a key, or a sequence like `"gg"` or `"ctrl+x g"`. Counts (`5j`) keep working on any binding. If you bind a key that is a default of another action, the other action loses it. A mistake in `[keys]` shows a message at startup and falls back to the default keys. - -| Action | Default keys | -| --- | --- | -| `move_down` `move_up` | `j` `down` / `k` `up` | -| `half_page_down` `half_page_up` | `ctrl+d` / `ctrl+u` | -| `page_down` `page_up` | `ctrl+f` `pagedown` / `ctrl+b` `pageup` | -| `go_top` `go_bottom` | `gg` `home` / `ge` `end` | -| `next_view` `prev_view` | `tab` / `backtab` | -| `view_results` `view_queue` `view_playlists` | `gr` / `gq` / `gp` | -| `activate` | `enter` | -| `pause` | `space` | -| `next` `prev` | `n` `>` / `N` `<` | -| `seek_back` `seek_forward` | `h` `left` / `l` `right` | -| `volume_up` `volume_down` | `+` `=` / `-` `_` | -| `add` `add_all` `play_all` | `a` / `A` / `P` | -| `delete` | `x` `delete` `dd` | -| `move_item_down` `move_item_up` | `J` / `K` | -| `shuffle` `repeat` | `s` / `r` | -| `menu` | `G` `menu` | -| `go_artist` `go_similar` `go_album` `save_album` | `ga` / `gs` / `gb` / `gB` | -| `lyrics_panel` `lyrics` | `L` / `F` | -| `search` `command` | `/` / `:` | -| `help` | `?` | -| `quit` | `q` | -| `back` | `backspace` `esc` | - -In the Playlists tab `seek_forward` opens the selected playlist, and `seek_back` or `back` leaves it. Only the main view is remappable. Popups, the command line and fullscreen lyrics keep their own keys, and the in-app help (`?`) still lists the defaults. - -### Environment variables - -| Variable | What it does | -| --- | --- | -| `DRIFT_CONFIG_DIR` | where Drift keeps `config.toml` and your playlists | -| `DRIFT_STATE_DIR` | where Drift keeps the saved session | -| `DRIFT_MPV_ARGS` | extra arguments passed to mpv | - -## Troubleshooting - -**Drift says mpv or yt-dlp is missing.** Run `drift --check` to see which one, and install it with the table in [Installing mpv and yt-dlp manually](#installing-mpv-and-yt-dlp-manually). - -**Playback fails with "Sign in to confirm you're not a bot".** YouTube wants a login. Set `cookies_from_browser` under `[audio]` to a browser you're logged into YouTube with (for example `"firefox"`), then restart Drift. Updating yt-dlp also helps, since YouTube changes things often. - -**The lyrics panel doesn't show up.** It needs a terminal at least 70 columns wide. Make the window wider or use `F` for fullscreen lyrics. - -**`drift: command not found`.** Make sure `~/.cargo/bin` is in your `$PATH`. - -**`gb` opens the wrong album.** Drift looks albums up by album and artist name, so it can guess wrong, and the song needs album info to begin with. `:album ` lets you search for it yourself. - -You can run several Drift windows at once. Only the first one restores and saves the session, and playlists saved from different windows are merged instead of overwritten. - -## Contributing - -Bug reports, ideas and pull requests are welcome. Read [CONTRIBUTING.md](CONTRIBUTING.md) before you start. To build and run the tests: - -```bash -cargo build -cargo test -``` - -Recent changes are listed in [CHANGELOG.md](CHANGELOG.md). +--- ## License -[Drift](https://github.com/socatlolmeow/drift) is free software, released under the [GNU General Public License version 3 or later](https://www.gnu.org/licenses/gpl-3.0.html). +[Drift](https://github.com/socatlolmeow/drift) is FOSS, released under the [GNU General Public License version 3 or later](https://www.gnu.org/licenses/gpl-3.0.html). diff --git a/examples/showcase.gif b/examples/showcase.gif index adab71d..607770c 100644 Binary files a/examples/showcase.gif and b/examples/showcase.gif differ diff --git a/flake.lock b/flake.lock deleted file mode 100644 index bc0b988..0000000 --- a/flake.lock +++ /dev/null @@ -1,27 +0,0 @@ -{ - "nodes": { - "nixpkgs": { - "locked": { - "lastModified": 1792488032, - "narHash": "sha256-RkAXQ3+a5rHMiP97Er7AawR1kvEXX9tK1mhiWedOPTc=", - "owner": "NixOS", - "repo": "nixpkgs", - "rev": "e513db7b0a65def6f6e17bb55195dfe510871944", - "type": "github" - }, - "original": { - "owner": "NixOS", - "ref": "nixpkgs-unstable", - "repo": "nixpkgs", - "type": "github" - } - }, - "root": { - "inputs": { - "nixpkgs": "nixpkgs" - } - } - }, - "root": "root", - "version": 7 -} diff --git a/flake.nix b/flake.nix deleted file mode 100644 index 505bb0e..0000000 --- a/flake.nix +++ /dev/null @@ -1,26 +0,0 @@ -{ - inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable"; - - outputs = { self, nixpkgs }: - let - systems = [ "x86_64-linux" "aarch64-linux" "x86_64-darwin" "aarch64-darwin" ]; - forAll = nixpkgs.lib.genAttrs systems; - in { - packages = forAll (system: - let pkgs = nixpkgs.legacyPackages.${system}; - in { - default = pkgs.rustPlatform.buildRustPackage { - pname = "drift"; - version = "1.3"; - src = ./.; - cargoLock.lockFile = ./Cargo.lock; - nativeBuildInputs = [ pkgs.makeWrapper ]; - postInstall = '' - wrapProgram $out/bin/drift \ - --prefix PATH : ${pkgs.lib.makeBinPath [ pkgs.mpv pkgs.yt-dlp ]} - ''; - meta.mainProgram = "drift"; - }; - }); - }; -} diff --git a/install.sh b/install.sh deleted file mode 100644 index 9c038b2..0000000 --- a/install.sh +++ /dev/null @@ -1,76 +0,0 @@ -#!/bin/sh -set -eu - -install_packages() { - if [ "$(id -u)" -eq 0 ]; then - "$@" - else - sudo "$@" - fi -} - -echo "-> Detecting package manager..." - -if command -v pacman >/dev/null 2>&1; then - # Arch Linux, Manjaro, EndeavourOS, CachyOS - install_packages pacman -S --needed --noconfirm \ - mpv yt-dlp base-devel rust - -elif command -v apt-get >/dev/null 2>&1; then - # Debian, Ubuntu, Linux Mint, Pop!_OS, elementary OS - install_packages apt-get update - install_packages apt-get install -y \ - mpv yt-dlp build-essential cargo - -elif command -v dnf >/dev/null 2>&1; then - # Fedora, RHEL (ew), Rocky Linux, AlmaLinux - install_packages dnf install -y \ - mpv yt-dlp gcc gcc-c++ make cargo - -elif command -v zypper >/dev/null 2>&1; then - # openSUSE Leap, Tumbleweed, SUSE Linux Enterprise - install_packages zypper --non-interactive install \ - mpv yt-dlp gcc gcc-c++ make cargo - -elif command -v xbps-install >/dev/null 2>&1; then - # Void Linux - install_packages xbps-install -Sy \ - mpv yt-dlp base-devel cargo - -elif command -v apk >/dev/null 2>&1; then - # Alpine Linux or Chimera - install_packages apk add \ - mpv yt-dlp build-base cargo - -elif command -v emerge >/dev/null 2>&1; then - # Gentoo :3 - install_packages emerge \ - media-video/mpv net-misc/yt-dlp \ - sys-devel/gcc dev-lang/rust - -elif command -v zypper >/dev/null 2>&1; then - install_packages zypper install \ - mpv yt-dlp gcc cargo - -else - echo "Error: unsupported package manager." >&2 - echo "Install mpv, yt-dlp, a C compiler, and Cargo manually." >&2 - exit 1 -fi - -echo "-> Checking required commands..." - -for cmd in cargo mpv yt-dlp; do - if ! command -v "$cmd" >/dev/null 2>&1; then - echo "Error: $cmd was not found after installation." >&2 - exit 1 - fi -done - -echo "-> Installing drift..." - -cargo install --git https://github.com/socatlolmeow/drift - -echo "-> Drift installation complete!" -echo "Run 'drift' to start the application." -echo "If the command is not found, pls check that ~/.cargo/bin is in your PATH" diff --git a/src/app.rs b/src/app.rs index d8fdade..4c91c4a 100644 --- a/src/app.rs +++ b/src/app.rs @@ -7,56 +7,22 @@ use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; use ratatui::widgets::TableState; use crate::command::{self, Amount, Command, COMMAND_NAMES}; -use crate::config::{self, Audio, Config}; -use crate::lyrics::{self, Lyrics}; -use crate::model::{split_artists, Collection, RepeatMode, Track}; -use crate::discord::Discord; -use crate::keys::{Action, Key, Keymap, Lookup}; -use crate::mpris::{Cmd, Mpris}; -use crate::player::{self, Player, PlayerEvent}; +use crate::config::{self, Config}; +use crate::model::{split_artists, RepeatMode, Track}; +use crate::player::{Player, PlayerEvent}; use crate::session::Session; use crate::store::Store; -use crate::ytmusic::{self, Kind, Page}; +use crate::ytmusic; pub enum AppEvent { Player(u64, PlayerEvent), Results { id: u64, title: String, - result: Result, - }, - More { - generation: u64, - result: Result, - }, - Lyrics { - id: String, - result: Result, String>, - }, - Collections { - id: u64, - title: String, - result: Result, String>, - }, - SavePlaylist { - name: String, result: Result, String>, }, - Media(Cmd), - Audio { - id: String, - result: Result, - }, - Stream { - id: String, - result: Result, - }, } -const FIRST_PAGE: usize = 20; -const NEXT_PAGE: usize = 10; -const LOAD_AHEAD: usize = 3; - #[derive(Clone, Copy, PartialEq, Eq, Debug)] pub enum View { Results, @@ -82,12 +48,8 @@ pub enum Mode { Normal, Command, ArtistPick, - CollectionPick, ConfirmDelete, Help, - Menu, - Info, - Lyrics, } #[derive(Clone, Copy, PartialEq, Eq)] @@ -98,71 +60,12 @@ pub enum PlayState { Paused, } -pub enum LyricsState { - Idle, - Loading, - Ready(Lyrics), - Missing, - Failed(String), -} - #[derive(Clone, Copy, PartialEq, Eq)] pub enum Level { Info, Error, } -/// Hotkey value meaning "this menu entry has no hotkey". -pub const NO_KEY: char = '\0'; - -#[derive(Clone, PartialEq, Eq)] -pub enum MenuAction { - Play, - PlayNext, - Queue, - Artist, - Similar, - Album, - SaveTrackAlbum, - SaveAlbum, - SaveResults, - PlaylistMenu, - PickPlaylist(String), - NewPlaylist, - Remove, - Info, - QueueAll, - PlayAll, - ClearQueue, - SaveQueue, - OpenPlaylist, - AppendPlaylist, - DeletePlaylist, -} - -#[derive(Clone)] -pub struct MenuItem { - pub key: char, - pub label: String, - pub detail: String, - pub action: MenuAction, -} - -/// Ctrl held without Alt. Windows reports AltGr (needed for `@ \\ { [ ~` and -/// friends on many layouts) as Ctrl+Alt, which must still count as typing. -fn is_ctrl(key: &KeyEvent) -> bool { - key.modifiers.contains(KeyModifiers::CONTROL) && !key.modifiers.contains(KeyModifiers::ALT) -} - -fn menu_item(key: char, label: &str, action: MenuAction) -> MenuItem { - MenuItem { - key, - label: label.to_string(), - detail: String::new(), - action, - } -} - pub struct Status { pub text: String, pub level: Level, @@ -182,30 +85,14 @@ pub struct App { pub pl_state: TableState, pub cmd_buf: String, - pub cmd_search: bool, - pub menu_items: Vec, - pub menu_sel: usize, - pub menu_title: String, - pub menu_sub: bool, - pub menu_track: Option, - pub cmd_sel: usize, - cmd_cycle: Option, pub artist_opts: Vec, - pub info_track: Option, pub artist_sel: usize, - pub picks: Vec, - pub pick_sel: usize, - pub pick_title: String, pub pending_delete: Option, pub help_scroll: usize, - pub lyrics: LyricsState, - pub lyrics_panel: bool, - pub lyrics_scroll: usize, - pub lyrics_id: Option, history: Vec, hist_pos: Option, pub count: Option, - pub pending: Vec, + pub pending: Option, pub play_state: PlayState, pub position: f64, @@ -213,31 +100,14 @@ pub struct App { pub volume: u8, pub shuffle: bool, pub repeat: RepeatMode, - pub accent: ratatui::style::Color, played: HashSet, - radio_played: HashSet, - resume_radio: bool, fail_streak: u8, - want: Option, - fetching: Option, - resolving: Option, - audio: Audio, result_cursor: Option, - buffered: Vec, - next_page: Option, - loading_more: bool, - results_gen: u64, radio: Vec, on_radio: bool, - similar_load: Option, - mpris: Option, - discord: Option, - owns_session: bool, - keymap: Keymap, seek_step: f64, volume_step: f64, autoplay: bool, - popular_first: bool, resume: Option<(String, f64)>, saved: String, save_failed: bool, @@ -266,14 +136,6 @@ impl App { .map(|d| d.as_nanos() as u64) .unwrap_or(0x9E37_79B9_7F4A_7C15) | 1; - let mpris = { - let t = tx.clone(); - Mpris::new(move |c| { - let _ = t.send(AppEvent::Media(c)); - }) - }; - let discord = Discord::new(&cfg.discord.client_id); - let owns_session = Session::claim(); let mut app = App { view: View::Results, mode: Mode::Normal, @@ -286,61 +148,28 @@ impl App { open: None, pl_state: TableState::default(), cmd_buf: String::new(), - cmd_search: false, - menu_items: Vec::new(), - menu_sel: 0, - menu_title: String::new(), - menu_sub: false, - menu_track: None, - cmd_sel: 0, - cmd_cycle: None, artist_opts: Vec::new(), - info_track: None, artist_sel: 0, - picks: Vec::new(), - pick_sel: 0, - pick_title: String::new(), pending_delete: None, help_scroll: 0, - lyrics: LyricsState::Idle, - lyrics_panel: false, - lyrics_scroll: 0, - lyrics_id: None, history: Vec::new(), hist_pos: None, count: None, - pending: Vec::new(), - keymap: cfg.keymap.clone(), + pending: None, play_state: PlayState::Stopped, position: 0.0, duration: 0.0, volume: p.volume, shuffle: p.shuffle, repeat: p.repeat, - accent: cfg.ui.accent, played: HashSet::new(), - radio_played: HashSet::new(), - resume_radio: false, fail_streak: 0, - want: None, - fetching: None, - resolving: None, - audio: cfg.audio.clone(), result_cursor: None, - buffered: Vec::new(), - next_page: None, - loading_more: false, - results_gen: 0, radio: Vec::new(), on_radio: false, - similar_load: None, - mpris, - discord, - owns_session, seek_step: p.seek_step, volume_step: p.volume_step, autoplay: p.autoplay, - popular_first: cfg.artist.popular_first, resume: None, saved: String::new(), save_failed: false, @@ -354,7 +183,7 @@ impl App { tx, rng: seed, }; - if p.restore_session && owns_session { + if p.restore_session { let s = Session::load(); app.resume = s .current @@ -368,7 +197,7 @@ impl App { } match warning { Some(w) => app.error(w), - None => app.info("Hello! Press / to search, and ? for help"), + None => app.info("Welcome! Press / to search, ? for help."), } app } @@ -378,14 +207,9 @@ impl App { eprintln!("drift: could not save session: {e:#}"); } self.player = None; - self.mpris = None; - self.discord = None; } fn save_session(&mut self, force: bool) -> Result<()> { - if !self.owns_session { - return Ok(()); - } let session = Session { queue: self.queue.clone(), current: self.current, @@ -424,14 +248,6 @@ impl App { pub fn on_tick(&mut self) { self.tick = self.tick.wrapping_add(1); - self.load_more(); - self.sync_lyrics(); - self.sync_mpris(); - self.sync_discord(); - /* open the picker once nothing else is being typed, so keys aren't hijacked */ - if self.mode == Mode::Normal && !self.picks.is_empty() { - self.mode = Mode::CollectionPick; - } if self.tick.is_multiple_of(50) { match self.save_session(false) { Ok(()) => self.save_failed = false, @@ -534,337 +350,121 @@ impl App { Mode::Help => self.key_help(key), Mode::Command => self.key_command(key), Mode::ArtistPick => self.key_artist_pick(key), - Mode::CollectionPick => self.key_collection_pick(key), Mode::ConfirmDelete => self.key_confirm_delete(key), - Mode::Menu => self.key_menu(key), - Mode::Info => self.key_info(key), - Mode::Lyrics => self.key_lyrics(key), Mode::Normal => self.key_normal(key), } } fn key_normal(&mut self, key: KeyEvent) { - let key = Key::from(key); + let ctrl = key.modifiers.contains(KeyModifiers::CONTROL); - if self.pending.is_empty() { - if let Some(d) = key.digit().filter(|&d| d != 0 || self.count.is_some()) { + if let Some(p) = self.pending.take() { + let n = self.count.take(); + if let KeyCode::Char(c) = key.code { + match (p, c) { + ('g', 'g') => self.set_selected(n.map_or(0, |n| n.saturating_sub(1))), + ('g', 'r') => self.set_view(View::Results), + ('g', 'q') => self.set_view(View::Queue), + ('g', 'p') => self.set_view(View::Playlists), + ('d', 'd') => self.delete_selected(), + ('g', 'a') => self.go_to_artist(), + _ => {} + } + } + return; + } + + if let KeyCode::Char(c @ '0'..='9') = key.code { + if !ctrl && !(c == '0' && self.count.is_none()) { + let d = c as usize - '0' as usize; self.count = Some((self.count.unwrap_or(0) * 10 + d).min(9999)); return; } } - - self.pending.push(key); - match self.keymap.lookup(&self.pending) { - Lookup::Pending => {} - Lookup::Run(action) => { - self.pending.clear(); - let n = self.count.take(); - self.run_action(action, n); - } - Lookup::Nothing => { - self.pending.clear(); - self.count = None; - } - } - } - - fn run_action(&mut self, action: Action, n: Option) { + let n = self.count.take(); let times = n.unwrap_or(1); if self.view == View::Playlists { - match action { - Action::SeekForward if !self.in_playlist() => { + match key.code { + KeyCode::Right | KeyCode::Char('l') if !self.in_playlist() && !ctrl => { return self.open_selected_playlist(); } - Action::SeekBack | Action::Back if self.in_playlist() => { + KeyCode::Left | KeyCode::Char('h') | KeyCode::Backspace | KeyCode::Esc + if self.in_playlist() && !ctrl => + { return self.close_playlist(); } _ => {} } } - match action { - Action::HalfPageDown => self.move_sel(10), - Action::HalfPageUp => self.move_sel(-10), - Action::PageDown => self.move_sel(20), - Action::PageUp => self.move_sel(-20), - Action::MoveDown => self.move_sel(times as isize), - Action::MoveUp => self.move_sel(-(times as isize)), - Action::GoTop => self.set_selected(n.map_or(0, |n| n.saturating_sub(1))), - Action::GoBottom => { - self.set_selected(n.map_or(usize::MAX, |n| n.saturating_sub(1))) + match key.code { + KeyCode::Char('d') if ctrl => self.move_sel(10), + KeyCode::Char('u') if ctrl => self.move_sel(-10), + KeyCode::Char('f') if ctrl => self.move_sel(20), + KeyCode::Char('b') if ctrl => self.move_sel(-20), + KeyCode::PageDown => self.move_sel(20), + KeyCode::PageUp => self.move_sel(-20), + KeyCode::Home => self.set_selected(0), + KeyCode::End => self.set_selected(usize::MAX), + + KeyCode::Char('j') | KeyCode::Down => self.move_sel(times as isize), + KeyCode::Char('k') | KeyCode::Up => self.move_sel(-(times as isize)), + KeyCode::Char('g') => { + self.pending = Some('g'); + self.count = n; } - Action::Menu => self.open_menu(), - Action::Delete => self.delete_selected(), + KeyCode::Char('G') => self.set_selected(n.map_or(usize::MAX, |n| n.saturating_sub(1))), + KeyCode::Char('d') => { + if matches!(self.view, View::Queue | View::Playlists) { + self.pending = Some('d'); + } + } + KeyCode::Char('x') | KeyCode::Delete => self.delete_selected(), - Action::NextView => self.set_view(View::from_idx(self.view.idx() + 1)), - Action::PrevView => self.set_view(View::from_idx(self.view.idx() + 2)), - Action::ViewResults => self.set_view(View::Results), - Action::ViewQueue => self.set_view(View::Queue), - Action::ViewPlaylists => self.set_view(View::Playlists), + KeyCode::Tab => self.set_view(View::from_idx(self.view.idx() + 1)), + KeyCode::BackTab => self.set_view(View::from_idx(self.view.idx() + 2)), - Action::Activate => self.activate(), - Action::Pause => self.toggle_pause(), - Action::Next => self.next(), - Action::Prev => self.prev(), - Action::SeekBack => self.seek(Amount::Delta(-self.seek_step * times as f64)), - Action::SeekForward => self.seek(Amount::Delta(self.seek_step * times as f64)), - Action::VolumeUp => self.set_volume(Amount::Delta(self.volume_step * times as f64)), - Action::VolumeDown => { + KeyCode::Enter => self.activate(), + KeyCode::Char(' ') => self.toggle_pause(), + KeyCode::Char('n') | KeyCode::Char('>') => self.next(), + KeyCode::Char('N') | KeyCode::Char('<') => self.prev(), + KeyCode::Char('h') | KeyCode::Left => { + self.seek(Amount::Delta(-self.seek_step * times as f64)) + } + KeyCode::Char('l') | KeyCode::Right => { + self.seek(Amount::Delta(self.seek_step * times as f64)) + } + KeyCode::Char('+') | KeyCode::Char('=') => { + self.set_volume(Amount::Delta(self.volume_step * times as f64)) + } + KeyCode::Char('-') | KeyCode::Char('_') => { self.set_volume(Amount::Delta(-self.volume_step * times as f64)) } - Action::Add => self.add_selected(), - Action::AddAll => self.add_all_results(), - Action::PlayAll => self.play_all_results(), - Action::MoveItemDown => self.move_item(1), - Action::MoveItemUp => self.move_item(-1), - Action::Shuffle => self.toggle_shuffle(), - Action::Repeat => self.cycle_repeat(), + KeyCode::Char('a') => self.add_selected(), + KeyCode::Char('A') => self.add_all_results(), + KeyCode::Char('P') => self.play_all_results(), + KeyCode::Char('J') => self.move_item(1), + KeyCode::Char('K') => self.move_item(-1), + KeyCode::Char('s') => self.toggle_shuffle(), + KeyCode::Char('r') => self.cycle_repeat(), - Action::Search => { - self.cmd_buf.clear(); - self.cmd_search = true; + KeyCode::Char('/') => { + self.cmd_buf = "search ".into(); self.enter_command_mode(); } - Action::Command => { + KeyCode::Char(':') => { self.cmd_buf.clear(); - self.cmd_search = false; self.enter_command_mode(); } - Action::LyricsPanel => self.lyrics_panel = !self.lyrics_panel, - Action::Lyrics => self.open_lyrics(), - Action::GoArtist => self.go_to_artist(), - Action::GoSimilar => self.go_similar(), - Action::GoAlbum => self.go_album(), - Action::SaveAlbum => self.save_album(None), - Action::Help => self.open_help(), - Action::Quit => self.should_quit = true, - Action::Back => self.status = None, - } - } - - /// The song under the cursor (no fallback to the playing song). - fn selected_track(&self) -> Option { - let sel = self.selected(); - match self.view { - View::Results => self.results.get(sel).cloned(), - View::Queue => self.queue.get(sel).cloned(), - View::Playlists if self.in_playlist() => self - .open - .as_ref() - .and_then(|n| self.store.playlists.get(n)) - .and_then(|t| t.get(sel)) - .cloned(), - View::Playlists => None, - } - } - - /// `G`: open the context menu for whatever is highlighted. - fn open_menu(&mut self) { - let mut items: Vec = Vec::new(); - if self.view == View::Playlists && !self.in_playlist() { - let Some(name) = self.playlist_name(self.selected()) else { - return self.error("No playlist selected"); - }; - let n = self.store.playlists.get(&name).map_or(0, Vec::len); - items.push(menu_item('o', "Open", MenuAction::OpenPlaylist)); - items.push(menu_item('p', "Play", MenuAction::Play)); - items.push(menu_item('a', "Append to queue", MenuAction::AppendPlaylist)); - items.push(menu_item('x', "Delete playlist…", MenuAction::DeletePlaylist)); - self.menu_title = format!("{name} ({n})"); - self.menu_track = None; - } else { - let Some(track) = self.selected_track() else { - return self.error("No song selected"); - }; - let in_queue = self.view == View::Queue; - items.push(menu_item('p', "Play now", MenuAction::Play)); - if !in_queue { - items.push(menu_item('n', "Play next", MenuAction::PlayNext)); - items.push(menu_item('a', "Add to queue", MenuAction::Queue)); - } - items.push(menu_item('g', "Show artist", MenuAction::Artist)); - items.push(menu_item('s', "Similar songs", MenuAction::Similar)); - if track.album.is_some() { - items.push(menu_item('b', "Show album", MenuAction::Album)); - items.push(menu_item('B', "Save album to playlist", MenuAction::SaveTrackAlbum)); - } - items.push(menu_item('l', "Add to playlist…", MenuAction::PlaylistMenu)); - if in_queue { - items.push(menu_item('x', "Remove from queue", MenuAction::Remove)); - } else if self.in_playlist() { - items.push(menu_item('x', "Remove from playlist", MenuAction::Remove)); - } - items.push(menu_item('i', "Song info", MenuAction::Info)); - if self.view == View::Results { - items.push(menu_item('Q', "Queue all results", MenuAction::QueueAll)); - items.push(menu_item('P', "Play all results", MenuAction::PlayAll)); - if self.results_title.starts_with("Album: ") { - items.push(menu_item('S', "Save album as playlist…", MenuAction::SaveAlbum)); - } else { - items.push(menu_item('S', "Save results as playlist…", MenuAction::SaveResults)); - } - } - if in_queue { - items.push(menu_item('c', "Clear queue", MenuAction::ClearQueue)); - items.push(menu_item('S', "Save queue as playlist…", MenuAction::SaveQueue)); - } - self.menu_title = track.title.clone(); - self.menu_track = Some(track); - } - self.menu_items = items; - self.menu_sel = 0; - self.menu_sub = false; - self.mode = Mode::Menu; - } - - fn open_playlist_menu(&mut self) { - let track = self.menu_track.clone(); - let mut items: Vec = self - .store - .playlists - .iter() - .enumerate() - .map(|(i, (name, list))| { - let has = track - .as_ref() - .is_some_and(|t| list.iter().any(|x| x.id == t.id)); - MenuItem { - key: if i < 9 { - char::from_digit(i as u32 + 1, 10).unwrap_or(NO_KEY) - } else { - NO_KEY - }, - label: name.clone(), - detail: if has { - "✓ already in it".to_string() - } else { - format!("{} songs", list.len()) - }, - action: MenuAction::PickPlaylist(name.clone()), - } - }) - .collect(); - items.push(menu_item('+', "New playlist…", MenuAction::NewPlaylist)); - self.menu_items = items; - self.menu_title = "Add to playlist".into(); - self.menu_sel = 0; - self.menu_sub = true; - } - - fn close_menu(&mut self) { - self.mode = Mode::Normal; - self.menu_items.clear(); - self.menu_sel = 0; - self.menu_sub = false; - self.menu_track = None; - } - - fn key_menu(&mut self, key: KeyEvent) { - let ctrl = is_ctrl(&key); - let len = self.menu_items.len(); - if len == 0 { - return self.close_menu(); - } - match key.code { - KeyCode::Esc | KeyCode::Char('q') | KeyCode::Char('G') | KeyCode::Menu => { - if self.menu_sub { - self.open_menu(); - } else { - self.close_menu(); - } - } - KeyCode::Char('j') | KeyCode::Down => self.menu_sel = (self.menu_sel + 1) % len, - KeyCode::Char('k') | KeyCode::Up => self.menu_sel = (self.menu_sel + len - 1) % len, - KeyCode::Char('n') if ctrl => self.menu_sel = (self.menu_sel + 1) % len, - KeyCode::Char('p') if ctrl => self.menu_sel = (self.menu_sel + len - 1) % len, - KeyCode::Home => self.menu_sel = 0, - KeyCode::End => self.menu_sel = len - 1, - KeyCode::Enter => self.run_menu(self.menu_sel), - KeyCode::Char(c) if !ctrl && c != NO_KEY => { - if let Some(i) = self.menu_items.iter().position(|it| it.key == c) { - self.menu_sel = i; - self.run_menu(i); - } - } + KeyCode::Char('?') => self.open_help(), + KeyCode::Char('q') => self.should_quit = true, + KeyCode::Esc => self.status = None, _ => {} } } - fn run_menu(&mut self, i: usize) { - let Some(item) = self.menu_items.get(i).cloned() else { - return; - }; - if item.action == MenuAction::PlaylistMenu { - return self.open_playlist_menu(); - } - let track = self.menu_track.clone(); - self.close_menu(); - match item.action { - MenuAction::Play => self.activate(), - MenuAction::PlayNext => { - if let Some(t) = track { - self.play_next(t); - } - } - MenuAction::Queue | MenuAction::AppendPlaylist => self.add_selected(), - MenuAction::Artist => self.go_to_artist(), - MenuAction::Similar => self.go_similar(), - MenuAction::Album => self.go_album(), - MenuAction::SaveTrackAlbum => { - if let Some(t) = track { - let album_title = t.album.clone().unwrap_or_else(|| "Album".to_string()); - self.save_album_background(t, album_title); - } - } - MenuAction::SaveAlbum => { - let default_name = self.results_title.strip_prefix("Album: ").unwrap_or("").trim(); - if !default_name.is_empty() { - self.prefill_command(&format!("savealbum \"{default_name}\"")); - } else { - self.prefill_command("savealbum "); - } - } - MenuAction::SaveResults => self.prefill_command("saveresults "), - MenuAction::PlaylistMenu => {} - MenuAction::PickPlaylist(name) => self.add_to_playlist(&name), - MenuAction::NewPlaylist => self.prefill_command("addto "), - MenuAction::Remove | MenuAction::DeletePlaylist => self.delete_selected(), - MenuAction::Info => { - if let Some(t) = track { - self.info_track = Some(t); - self.mode = Mode::Info; - } - } - MenuAction::QueueAll => self.add_all_results(), - MenuAction::PlayAll => self.play_all_results(), - MenuAction::ClearQueue => self.clear_queue(), - MenuAction::SaveQueue => self.prefill_command("save "), - MenuAction::OpenPlaylist => self.open_selected_playlist(), - } - } - - /// Open the `:` popup with some text already typed (e.g. `save `). - fn prefill_command(&mut self, text: &str) { - self.cmd_buf = text.to_string(); - self.cmd_search = false; - self.enter_command_mode(); - } - - fn play_next(&mut self, t: Track) { - let label = t.label(); - let len = self.queue.len(); - let at = self.current.map_or(0, |c| (c + 1).min(len)); - self.queue.insert(at, t); - self.played = self - .played - .iter() - .map(|&i| if i >= at { i + 1 } else { i }) - .collect(); - self.info(format!("Playing next: {label}")); - } - fn key_confirm_delete(&mut self, key: KeyEvent) { self.mode = Mode::Normal; let name = self.pending_delete.take(); @@ -878,65 +478,8 @@ impl App { self.mode = Mode::Help; } - fn key_info(&mut self, _key: KeyEvent) { - self.mode = Mode::Normal; - self.info_track = None; - } - - fn open_lyrics(&mut self) { - self.mode = Mode::Lyrics; - } - - fn key_lyrics(&mut self, key: KeyEvent) { - let scroll = self.lyrics_scroll; - match key.code { - KeyCode::Esc | KeyCode::Char('q' | 'F' | 'L') => self.mode = Mode::Normal, - KeyCode::Char('j') | KeyCode::Down => self.lyrics_scroll = scroll.saturating_add(1), - KeyCode::Char('k') | KeyCode::Up => self.lyrics_scroll = scroll.saturating_sub(1), - KeyCode::Char(' ') => self.toggle_pause(), - KeyCode::Char('n') | KeyCode::Char('>') => self.next(), - KeyCode::Char('N') | KeyCode::Char('<') => self.prev(), - KeyCode::Char('h') | KeyCode::Left => self.seek(Amount::Delta(-self.seek_step)), - KeyCode::Char('l') | KeyCode::Right => self.seek(Amount::Delta(self.seek_step)), - KeyCode::Char('+') | KeyCode::Char('=') => { - self.set_volume(Amount::Delta(self.volume_step)) - } - KeyCode::Char('-') | KeyCode::Char('_') => { - self.set_volume(Amount::Delta(-self.volume_step)) - } - _ => {} - } - } - - fn sync_lyrics(&mut self) { - if !self.lyrics_panel && self.mode != Mode::Lyrics { - return; - } - let Some(track) = self - .now_track() - .filter(|_| self.play_state != PlayState::Stopped) - .cloned() - else { - return; - }; - if self.lyrics_id.as_deref() == Some(track.id.as_str()) { - return; - } - self.lyrics_id = Some(track.id.clone()); - self.lyrics = LyricsState::Loading; - self.lyrics_scroll = 0; - let tx = self.tx.clone(); - std::thread::spawn(move || { - let result = lyrics::fetch(&track); - let _ = tx.send(AppEvent::Lyrics { - id: track.id, - result, - }); - }); - } - fn key_help(&mut self, key: KeyEvent) { - let ctrl = is_ctrl(&key); + let ctrl = key.modifiers.contains(KeyModifiers::CONTROL); let s = self.help_scroll; match key.code { KeyCode::Esc | KeyCode::Char('q') | KeyCode::Char('?') | KeyCode::Enter => { @@ -957,45 +500,10 @@ impl App { fn enter_command_mode(&mut self) { self.mode = Mode::Command; self.hist_pos = None; - self.cmd_cycle = None; - self.cmd_sel = 0; - } - - /// Commands matching what is typed so far (only while typing the command name). - pub fn command_matches(&self) -> Vec<&'static str> { - if self.cmd_search || self.cmd_buf.contains(' ') { - return Vec::new(); - } - let prefix = self.cmd_cycle.as_deref().unwrap_or(self.cmd_buf.as_str()); - COMMAND_NAMES - .iter() - .copied() - .filter(|c| c.starts_with(prefix)) - .collect() - } - - /// True while Tab / Shift-Tab is stepping through the suggestion list. - pub fn cycling(&self) -> bool { - self.cmd_cycle.is_some() - } - - /// Most recent searches, newest first (shown in the `/` popup). - pub fn recent_searches(&self, n: usize) -> Vec<&str> { - self.history - .iter() - .rev() - .filter_map(|h| h.strip_prefix("search ")) - .take(n) - .collect() } fn key_command(&mut self, key: KeyEvent) { - let ctrl = is_ctrl(&key); - let cycling = matches!(key.code, KeyCode::Tab | KeyCode::BackTab) - || (ctrl && matches!(key.code, KeyCode::Char('n') | KeyCode::Char('p'))); - if !cycling { - self.cmd_cycle = None; - } + let ctrl = key.modifiers.contains(KeyModifiers::CONTROL); match key.code { KeyCode::Esc => { self.mode = Mode::Normal; @@ -1005,18 +513,12 @@ impl App { let line = std::mem::take(&mut self.cmd_buf); self.mode = Mode::Normal; let trimmed = line.trim(); - if trimmed.is_empty() { - return; + if !trimmed.is_empty() { + if self.history.last().map(String::as_str) != Some(trimmed) { + self.history.push(trimmed.to_string()); + } + self.run_command(trimmed); } - let full = if self.cmd_search { - format!("search {trimmed}") - } else { - trimmed.to_string() - }; - if self.history.last() != Some(&full) { - self.history.push(full.clone()); - } - self.run_command(&full); } KeyCode::Backspace => { if self.cmd_buf.is_empty() { @@ -1033,30 +535,17 @@ impl App { } KeyCode::Up => self.history_step(-1), KeyCode::Down => self.history_step(1), - KeyCode::Tab => self.complete(1), - KeyCode::BackTab => self.complete(-1), - KeyCode::Char('n') if ctrl => self.complete(1), - KeyCode::Char('p') if ctrl => self.complete(-1), + KeyCode::Tab => self.complete(), KeyCode::Char(c) if !ctrl => self.cmd_buf.push(c), _ => {} } } fn history_step(&mut self, dir: isize) { - let search = self.cmd_search; - let entries: Vec = self - .history - .iter() - .filter_map(|h| match h.strip_prefix("search ") { - Some(q) if search => Some(q.to_string()), - _ if search => None, - _ => Some(h.clone()), - }) - .collect(); - if entries.is_empty() { + if self.history.is_empty() { return; } - let len = entries.len() as isize; + let len = self.history.len() as isize; let pos = match (self.hist_pos, dir) { (None, -1) => len - 1, (None, _) => return, @@ -1068,36 +557,25 @@ impl App { } else { let pos = pos.max(0) as usize; self.hist_pos = Some(pos); - self.cmd_buf = entries[pos].clone(); + self.cmd_buf = self.history[pos].clone(); } } - /// Tab / Shift-Tab: cycle through the commands matching the typed prefix. - fn complete(&mut self, dir: isize) { - if self.cmd_search || self.cmd_buf.contains(' ') { + fn complete(&mut self) { + if self.cmd_buf.contains(' ') { return; } - if self.cmd_cycle.is_none() { - self.cmd_cycle = Some(self.cmd_buf.clone()); - self.cmd_sel = if dir < 0 { usize::MAX } else { 0 }; - } else { - let n = self.command_matches().len().max(1) as isize; - self.cmd_sel = (self.cmd_sel as isize + dir).rem_euclid(n) as usize; - } - let matches = self.command_matches(); - if matches.is_empty() { - self.cmd_cycle = None; - return; - } - if self.cmd_sel >= matches.len() { - self.cmd_sel = matches.len() - 1; - } - let pick = matches[self.cmd_sel]; - if matches.len() == 1 { - self.cmd_buf = format!("{pick} "); - self.cmd_cycle = None; - } else { - self.cmd_buf = pick.to_string(); + let matches: Vec<&&str> = COMMAND_NAMES + .iter() + .filter(|c| c.starts_with(self.cmd_buf.as_str())) + .collect(); + match matches.len() { + 0 => {} + 1 => self.cmd_buf = format!("{} ", matches[0]), + _ => { + let list = matches.iter().map(|s| **s).collect::>().join(" "); + self.info(list); + } } } @@ -1153,10 +631,7 @@ impl App { self.repeat = m.unwrap_or_else(|| self.repeat.cycle()); self.info(format!("Repeat: {}", self.repeat.label())); } - Command::Save(name) => self.save_playlist(&name, false), - Command::SaveForce(name) => self.save_playlist(&name, true), - Command::SaveAlbum(name) => self.save_album(name), - Command::SaveResults(name) => self.save_results_as_playlist(name), + Command::Save(name) => self.save_playlist(&name), Command::Load(name) => match self.store.resolve(&name) { Some(n) => self.load_playlist(&n, true), None => self.error(format!("no playlist named '{name}'")), @@ -1172,11 +647,6 @@ impl App { Command::AddTo(name) => self.add_to_playlist(&name), Command::Import(url) => self.start_import(url), Command::Artist => self.go_to_artist(), - Command::Similar => self.go_similar(), - Command::Album(Some(q)) => self.start_collection_search(q, Kind::Album), - Command::Album(None) => self.go_album(), - Command::PlaylistSearch(q) => self.start_collection_search(q, Kind::Playlist), - Command::Lyrics => self.lyrics_panel = !self.lyrics_panel, Command::Help => self.open_help(), } } @@ -1200,7 +670,7 @@ impl App { let Some(track) = self.artist_target() else { return self.error("No song selected"); }; - let names = track.artist_names(); + let names = split_artists(&track.artist); if names.is_empty() || track.artist == "Unknown artist" { return self.error("No artist known for this song"); } @@ -1255,233 +725,26 @@ impl App { fn search_artist(&mut self, name: String) { let busy = format!("Loading songs by \u{201c}{name}\u{201d}"); let title = format!("Artist: {name}"); - let popular_first = self.popular_first; self.spawn_loader(title, busy, move || { let wanted = name.to_lowercase(); - let mut tracks = if popular_first { - ytmusic::artist_top_songs(&name).unwrap_or_default() - } else { - Vec::new() - }; - let found = match ytmusic::search(&name) { - Ok(found) => found, - Err(e) if tracks.is_empty() => return Err(e), - Err(_) => Vec::new(), - }; - let known: HashSet = tracks.iter().map(|t| t.id.clone()).collect(); - tracks.extend(found.into_iter().filter(|t| { - !known.contains(&t.id) - && (t.artist.to_lowercase() == wanted - || t.artist_names() + let tracks = ytmusic::search(&name)?; + Ok(tracks + .into_iter() + .filter(|t| { + t.artist.to_lowercase() == wanted + || split_artists(&t.artist) .iter() - .any(|a| a.to_lowercase() == wanted)) - })); - Ok(tracks.into()) + .any(|a| a.to_lowercase() == wanted) + }) + .collect()) }); } - fn go_album(&mut self) { - let Some(track) = self.artist_target() else { - return self.error("No song selected"); - }; - let Some(album) = track.album.clone() else { - return self.error("No album known for this song"); - }; - let artist = track - .artist_names() - .into_iter() - .next() - .unwrap_or_default(); - let busy = format!("Loading album \u{201c}{album}\u{201d}"); - let title = format!("Album: {album}"); - self.spawn_loader(title, busy, move || { - let found = ytmusic::search_collections(&format!("{album} {artist}"), Kind::Album)?; - let want = album.to_lowercase(); - let pick = found - .iter() - .find(|c| c.title.to_lowercase() == want) - .or(found.first()) - .ok_or_else(|| "album not found".to_string())?; - ytmusic::browse_tracks(&pick.browse_id, &pick.title, &pick.artist).map(Page::from) - }); - } - - fn save_album(&mut self, name: Option) { - if self.view == View::Results - && self.results_title.starts_with("Album: ") - && !self.results.is_empty() - { - return self.save_results_as_playlist(name); - } - - if let Some(track) = self.artist_target() { - if let Some(album) = track.album.clone() { - let target_name = name.unwrap_or(album); - return self.save_album_background(track, target_name); - } - } - - if self.view == View::Results && !self.results.is_empty() { - return self.save_results_as_playlist(name); - } - - self.error("No album to save — open an album or select a song with an album"); - } - - fn save_album_background(&mut self, track: Track, target_name: String) { - let Some(album) = track.album else { - return self.error("No album known for this song"); - }; - let artist = split_artists(&track.artist) - .into_iter() - .next() - .unwrap_or_default(); - self.busy = Some(format!("Saving album \u{201c}{album}\u{201d}")); - let tx = self.tx.clone(); - std::thread::spawn(move || { - let result = (|| -> Result, String> { - let found = ytmusic::search_collections(&format!("{album} {artist}"), Kind::Album)?; - let want = album.to_lowercase(); - let pick = found - .iter() - .find(|c| c.title.to_lowercase() == want) - .or(found.first()) - .ok_or_else(|| "album not found".to_string())?; - ytmusic::browse_tracks(&pick.browse_id, &pick.title, &pick.artist) - })(); - let _ = tx.send(AppEvent::SavePlaylist { - name: target_name, - result, - }); - }); - } - - fn start_collection_search(&mut self, query: String, kind: Kind) { - let (what, title) = match kind { - Kind::Album => ("albums", format!("Albums: {query}")), - Kind::Playlist => ("playlists", format!("Playlists: {query}")), - }; - self.search_id += 1; - let id = self.search_id; - self.busy = Some(format!("Searching {what} for \u{201c}{query}\u{201d}")); - let tx = self.tx.clone(); - std::thread::spawn(move || { - let result = ytmusic::search_collections(&query, kind); - let _ = tx.send(AppEvent::Collections { id, title, result }); - }); - } - - fn key_collection_pick(&mut self, key: KeyEvent) { - let len = self.picks.len(); - if len == 0 { - self.mode = Mode::Normal; - return; - } - match key.code { - KeyCode::Esc | KeyCode::Char('q') => self.close_collection_pick(), - KeyCode::Char('s') | KeyCode::Char('S') => self.save_picked_collection(), - KeyCode::Char('j') | KeyCode::Down => self.pick_sel = (self.pick_sel + 1).min(len - 1), - KeyCode::Char('k') | KeyCode::Up => self.pick_sel = self.pick_sel.saturating_sub(1), - KeyCode::Char('g') | KeyCode::Home => self.pick_sel = 0, - KeyCode::Char('G') | KeyCode::End => self.pick_sel = len - 1, - KeyCode::Char(c @ '1'..='9') => { - let i = c as usize - '1' as usize; - if i < len { - self.pick_sel = i; - self.confirm_collection(); - } - } - KeyCode::Enter => self.confirm_collection(), - _ => {} - } - } - - fn close_collection_pick(&mut self) { - self.mode = Mode::Normal; - self.picks.clear(); - self.pick_sel = 0; - } - - fn confirm_collection(&mut self) { - let c = self.picks.get(self.pick_sel).cloned(); - self.close_collection_pick(); - let Some(c) = c else { return }; - let kind = if c.browse_id.starts_with("VL") { "Playlist" } else { "Album" }; - let busy = format!("Loading \u{201c}{}\u{201d}", c.title); - let title = format!("{kind}: {}", c.title); - self.spawn_loader(title, busy, move || { - ytmusic::browse_tracks(&c.browse_id, &c.title, &c.artist).map(Page::from) - }); - } - - fn save_picked_collection(&mut self) { - let c = self.picks.get(self.pick_sel).cloned(); - self.close_collection_pick(); - let Some(c) = c else { return }; - let kind = if c.browse_id.starts_with("VL") { "Playlist" } else { "Album" }; - let target_name = c.title.clone(); - self.busy = Some(format!("Saving {kind} \u{201c}{target_name}\u{201d}")); - let tx = self.tx.clone(); - std::thread::spawn(move || { - let result = ytmusic::browse_tracks(&c.browse_id, &c.title, &c.artist); - let _ = tx.send(AppEvent::SavePlaylist { - name: target_name, - result, - }); - }); - } - - fn on_media(&mut self, cmd: Cmd) { - match cmd { - Cmd::Play => match self.play_state { - PlayState::Stopped => self.play_default(), - _ => self.set_pause(false), - }, - Cmd::Pause => { - if self.play_state != PlayState::Stopped { - self.set_pause(true); - } - } - Cmd::Toggle => self.toggle_pause(), - Cmd::Stop => self.stop_playback(), - Cmd::Next => self.next(), - Cmd::Prev => self.prev(), - Cmd::SeekBy(d) => self.seek(Amount::Delta(d)), - Cmd::SeekTo(p) => self.seek(Amount::Abs(p)), - Cmd::Quit => self.should_quit = true, - } - } - - fn sync_mpris(&mut self) { - if let Some(mut m) = self.mpris.take() { - m.update(self.now_track(), self.play_state, self.position); - self.mpris = Some(m); - } - } - - fn sync_discord(&mut self) { - if let Some(mut d) = self.discord.take() { - d.update(self.now_track(), self.play_state, self.position); - self.discord = Some(d); - } - } - - fn go_similar(&mut self) { - let Some(track) = self.artist_target() else { - return self.error("No song selected"); - }; - let busy = format!("Finding songs like \u{201c}{}\u{201d}", track.title); - let title = format!("Similar to {}", track.title); - let id = track.id; - self.spawn_loader(title, busy, move || ytmusic::radio(&id).map(Page::from)); - self.similar_load = Some(self.search_id); - } - fn start_search(&mut self, query: String) { self.spawn_loader( format!("Search: {query}"), format!("Searching \u{201c}{query}\u{201d}"), - move || ytmusic::search_page(&query), + move || ytmusic::search(&query), ); } @@ -1489,13 +752,13 @@ impl App { self.spawn_loader( "Imported playlist".into(), "Importing playlist".into(), - move || ytmusic::import(&url).map(Page::from), + move || ytmusic::import(&url), ); } fn spawn_loader(&mut self, title: String, busy: String, work: F) where - F: FnOnce() -> Result + Send + 'static, + F: FnOnce() -> Result, String> + Send + 'static, { self.search_id += 1; let id = self.search_id; @@ -1514,110 +777,19 @@ impl App { return; } self.busy = None; - let autoplay = self.similar_load.take() == Some(id); match result { - Ok(page) if page.tracks.is_empty() => self.info("No results found."), - Ok(page) => { - let mut tracks = page.tracks; - self.buffered = match page.next { - Some(_) if tracks.len() > FIRST_PAGE => tracks.split_off(FIRST_PAGE), - _ => Vec::new(), - }; - self.next_page = page.next; - self.loading_more = false; - self.results_gen += 1; + Ok(tracks) if tracks.is_empty() => self.info("No results found."), + Ok(tracks) => { let n = tracks.len(); self.results = tracks; self.results_title = title; self.states[View::Results.idx()].select(Some(0)); self.set_view(View::Results); self.info(format!("{n} results — Enter plays, a queues, ? for help")); - if autoplay { - self.play_result(0); - } } Err(e) => self.error(e), } } - AppEvent::More { generation, result } => { - if generation != self.results_gen { - return; - } - self.loading_more = false; - match result { - Ok(page) => { - let known: HashSet<&str> = self - .results - .iter() - .chain(&self.buffered) - .map(|t| t.id.as_str()) - .collect(); - let fresh: Vec = page - .tracks - .into_iter() - .filter(|t| !known.contains(t.id.as_str())) - .collect(); - self.buffered.extend(fresh); - self.next_page = page.next; - self.reveal_buffered(); - } - Err(e) => { - self.next_page = None; - self.error(format!("Could not load more results: {e}")); - } - } - } - AppEvent::Collections { id, title, result } => { - if id != self.search_id { - return; - } - self.busy = None; - match result { - Ok(list) if list.is_empty() => self.info("Nothing found."), - Ok(list) => { - self.picks = list; - self.pick_sel = 0; - self.pick_title = title; - } - Err(e) => self.error(e), - } - } - AppEvent::Media(cmd) => self.on_media(cmd), - AppEvent::SavePlaylist { name, result } => { - self.busy = None; - match result { - Ok(tracks) if tracks.is_empty() => { - self.error(format!("Album \u{201c}{name}\u{201d} contains no tracks")); - } - Ok(tracks) => { - let n = tracks.len(); - let key = self.store.resolve_exact(&name).unwrap_or(name); - self.store.playlists.insert(key.clone(), tracks); - if self.persist() { - self.info(format!( - "Saved {n} tracks to playlist \u{201c}{key}\u{201d}" - )); - let sel = self.selected(); - self.set_selected(sel); - } - } - Err(e) => { - self.error(format!("Could not save album \u{201c}{name}\u{201d}: {e}")) - } - } - } - AppEvent::Lyrics { id, result } => { - if self.lyrics_id.as_deref() != Some(id.as_str()) { - return; - } - self.lyrics = match result { - Ok(Some(l)) => LyricsState::Ready(l), - Ok(None) => LyricsState::Missing, - Err(e) => LyricsState::Failed(e), - }; - } - AppEvent::Audio { id, result } => self.on_audio(id, result), - AppEvent::Stream { id, result } => self.on_stream(id, result), AppEvent::Player(generation, pe) => { if generation == self.player_gen { self.on_player(pe); @@ -1626,36 +798,6 @@ impl App { } } - fn reveal_buffered(&mut self) { - let n = NEXT_PAGE.min(self.buffered.len()); - self.results.extend(self.buffered.drain(..n)); - } - - fn load_more(&mut self) { - if self.view != View::Results - || self.results.is_empty() - || self.selected() + LOAD_AHEAD < self.results.len() - { - return; - } - if !self.buffered.is_empty() { - return self.reveal_buffered(); - } - let Some(token) = self.next_page.clone() else { - return; - }; - if self.loading_more { - return; - } - self.loading_more = true; - let generation = self.results_gen; - let tx = self.tx.clone(); - std::thread::spawn(move || { - let result = ytmusic::search_more(&token); - let _ = tx.send(AppEvent::More { generation, result }); - }); - } - fn ensure_player(&mut self) -> Result<&mut Player> { if let Some(p) = self.player.as_mut() { if !p.is_alive() { @@ -1699,7 +841,6 @@ impl App { PlayerEvent::FileLoaded => { self.play_state = PlayState::Playing; self.fail_streak = 0; - self.prefetch_next(); if let Some((id, pos)) = self.resume.take() { let ok = self.now_track().is_some_and(|t| { t.id == id && t.duration.is_none_or(|d| pos < f64::from(d) - 1.0) @@ -1721,7 +862,7 @@ impl App { if self.fail_streak >= 3 { self.stop_playback(); self.error(format!( - "Playback failed 3 times in a row: {msg}" + "Playback failed 3 times in a row ({msg}). Update yt-dlp: yt-dlp -U" )); } else { self.error(format!( @@ -1740,100 +881,6 @@ impl App { } } - fn fetch_audio(&mut self, id: &str) { - if self.fetching.as_deref() == Some(id) { - return; - } - self.fetching = Some(id.to_string()); - let tx = self.tx.clone(); - let id = id.to_string(); - let audio = self.audio.clone(); - std::thread::spawn(move || { - let result = player::fetch_audio(&id, &audio); - let _ = tx.send(AppEvent::Audio { id, result }); - }); - } - - fn load_track(&mut self, id: &str, duration: Option) { - self.want = Some(id.to_string()); - match player::cached_audio(id) { - Some(path) => self.on_audio(id.to_string(), Ok(path)), - None if self.audio.should_download(duration) => self.fetch_audio(id), - None => self.resolve_stream(id), - } - } - - /* Start playing from the network right away; no full download first. */ - fn resolve_stream(&mut self, id: &str) { - if self.resolving.as_deref() == Some(id) { - return; - } - self.resolving = Some(id.to_string()); - let tx = self.tx.clone(); - let id = id.to_string(); - let audio = self.audio.clone(); - std::thread::spawn(move || { - let result = player::resolve_stream(&id, &audio); - let _ = tx.send(AppEvent::Stream { id, result }); - }); - } - - fn on_stream(&mut self, id: String, result: Result) { - if self.resolving.as_deref() == Some(id.as_str()) { - self.resolving = None; - } - if self.want.as_deref() != Some(id.as_str()) { - return; - } - self.want = None; - match result { - Ok(url) => { - if let Err(e) = self.with_player(|p| p.load(&url)) { - self.play_state = PlayState::Stopped; - self.error(format!("Playback failed: {e:#}")); - } - } - Err(msg) => self.on_player(PlayerEvent::LoadError(msg)), - } - } - - fn on_audio(&mut self, id: String, result: Result) { - if self.fetching.as_deref() == Some(id.as_str()) { - self.fetching = None; - } - if self.want.as_deref() != Some(id.as_str()) { - return; - } - self.want = None; - match result { - Ok(path) => { - let path = path.to_string_lossy().into_owned(); - if let Err(e) = self.with_player(|p| p.load(&path)) { - self.play_state = PlayState::Stopped; - self.error(format!("Playback failed: {e:#}")); - } - } - Err(msg) => self.on_player(PlayerEvent::LoadError(msg)), - } - } - - fn prefetch_next(&mut self) { - if self.shuffle || self.on_radio || self.fetching.is_some() { - return; - } - let Some(next) = self.current.and_then(|c| self.queue.get(c + 1)) else { - return; - }; - /* streamed tracks (the default, or too long to download) aren't prefetched */ - if !self.audio.should_download(next.duration) { - return; - } - let id = next.id.clone(); - if player::cached_audio(&id).is_none() { - self.fetch_audio(&id); - } - } - fn play_index(&mut self, i: usize) { self.start_track(i, false); } @@ -1854,8 +901,14 @@ impl App { _ => 0.0, }; self.duration = track.duration.map_or(0.0, f64::from); - self.info(format!("♪ {}", track.label())); - self.load_track(&track.id, track.duration); + let url = track.url(); + match self.with_player(|p| p.load(&url)) { + Ok(()) => self.info(format!("♪ {}", track.label())), + Err(e) => { + self.play_state = PlayState::Stopped; + self.error(format!("Playback failed: {e:#}")); + } + } } fn play_default(&mut self) { @@ -1885,7 +938,6 @@ impl App { } else { self.current.unwrap_or(0) }; - self.resume_radio = false; self.start_track(i.min(self.queue.len() - 1), true); } @@ -1903,7 +955,6 @@ impl App { } View::Queue => { if i < self.queue.len() { - self.resume_radio = false; self.play_index(i); } } @@ -1913,7 +964,7 @@ impl App { self.play_playlist_from(&name, i); } } else if let Some(name) = self.playlist_name(i) { - self.play_playlist_from(&name, 0); + self.load_playlist(&name, true); } } } @@ -1924,7 +975,6 @@ impl App { return; } self.radio = self.results.clone(); - self.radio_played.clear(); self.play_radio(i); } @@ -1932,22 +982,20 @@ impl App { let Some(track) = self.radio.get(i).cloned() else { return; }; - if !self.on_radio && self.current.is_some() { - /* leaving the queue for a list: what is queued now counts as done, - only songs added from here on play after this one */ - self.current = self.queue.len().checked_sub(1); - self.played = (0..self.queue.len()).collect(); - } self.on_radio = true; - self.resume_radio = true; self.result_cursor = Some(i); - self.radio_played.insert(i); self.resume = None; self.play_state = PlayState::Loading; self.position = 0.0; self.duration = track.duration.map_or(0.0, f64::from); - self.info(format!("♪ {}", track.label())); - self.load_track(&track.id, track.duration); + let url = track.url(); + match self.with_player(|p| p.load(&url)) { + Ok(()) => self.info(format!("♪ {}", track.label())), + Err(e) => { + self.play_state = PlayState::Stopped; + self.error(format!("Playback failed: {e:#}")); + } + } } fn set_pause(&mut self, pause: bool) { @@ -1978,7 +1026,6 @@ impl App { } fn stop_playback(&mut self) { - self.want = None; if let Some(p) = self.player.as_mut() { let _ = p.stop(); } @@ -2039,12 +1086,11 @@ impl App { } let len = self.queue.len(); - let wrap = self.repeat == RepeatMode::All && !(self.on_radio || self.resume_radio); let next = if len == 0 { None } else if self.shuffle { let mut pool: Vec = (0..len).filter(|i| !self.played.contains(i)).collect(); - if pool.is_empty() && wrap { + if pool.is_empty() && self.repeat == RepeatMode::All { self.played.clear(); pool = (0..len).filter(|i| Some(*i) != self.current).collect(); if pool.is_empty() { @@ -2060,7 +1106,7 @@ impl App { } else { match self.current { Some(c) if c + 1 < len => Some(c + 1), - Some(_) if wrap => Some(0), + Some(_) if self.repeat == RepeatMode::All => Some(0), Some(_) => None, None => Some(0), } @@ -2072,45 +1118,21 @@ impl App { } fn advance_radio(&mut self, auto: bool) { - let cursor = self + let next = self .result_cursor - .filter(|_| self.on_radio || self.resume_radio || self.autoplay); - match cursor.and_then(|c| self.next_in_radio(c)) { + .filter(|_| self.on_radio || self.autoplay) + .map(|c| c + 1) + .filter(|&i| i < self.radio.len()); + match next { Some(i) => self.play_radio(i), None if auto => { self.stop_playback(); - self.info("Finished playing"); + self.info("Queue finished"); } - None => self.info("End of the list"), + None => self.info("End of queue"), } } - fn next_in_radio(&mut self, cursor: usize) -> Option { - let len = self.radio.len(); - if !self.shuffle { - return match cursor + 1 { - n if n < len => Some(n), - _ if self.repeat == RepeatMode::All && len > 0 => Some(0), - _ => None, - }; - } - let mut pool: Vec = (0..len) - .filter(|i| !self.radio_played.contains(i)) - .collect(); - if pool.is_empty() && self.repeat == RepeatMode::All { - self.radio_played.clear(); - pool = (0..len).filter(|&i| i != cursor).collect(); - if pool.is_empty() { - pool = vec![cursor]; - } - } - if pool.is_empty() { - return None; - } - let k = self.rand_below(pool.len()); - Some(pool[k]) - } - fn seek(&mut self, amount: Amount) { if !matches!(self.play_state, PlayState::Playing | PlayState::Paused) { return self.error("Nothing is playing"); @@ -2152,10 +1174,6 @@ impl App { if let Some(c) = self.current { self.played.insert(c); } - self.radio_played.clear(); - if let Some(c) = self.result_cursor { - self.radio_played.insert(c); - } self.info(format!( "Shuffle: {}", if self.shuffle { "on" } else { "off" } @@ -2218,7 +1236,11 @@ impl App { } else { 0 }; - self.play_result(start.min(self.results.len() - 1)); + self.queue = self.results.clone(); + self.played.clear(); + self.current = None; + self.result_cursor = None; + self.play_index(start.min(self.queue.len() - 1)); } fn clear_queue(&mut self) { @@ -2229,7 +1251,7 @@ impl App { self.stop_playback(); } self.states[View::Queue.idx()].select(None); - self.info("Cleared queue"); + self.info("Queue cleared"); } fn remove_from_queue(&mut self, i: usize) { @@ -2311,60 +1333,18 @@ impl App { } } - fn save_playlist(&mut self, name: &str, force: bool) { + fn save_playlist(&mut self, name: &str) { if self.queue.is_empty() { return self.error("Queue is empty — nothing to save"); } - let key = self - .store - .resolve_exact(name) - .unwrap_or_else(|| name.to_string()); - let exists = self.store.playlists.get(&key).is_some_and(|l| !l.is_empty()); - if exists && !force { - return self.error(format!( - "Playlist \u{201c}{key}\u{201d} already exists. :save! {key} replaces it, :append {key} adds the queue to the end" - )); - } let n = self.queue.len(); - self.store.playlists.insert(key.clone(), self.queue.clone()); - if self.persist() { - self.info(format!( - "Saved {n} tracks to playlist \u{201c}{key}\u{201d}" - )); - } - } - - fn save_results_as_playlist(&mut self, name: Option) { - if self.results.is_empty() { - return self.error("No results to save — search or open an album first"); - } - let target_name = match name { - Some(n) if !n.trim().is_empty() => n.trim().to_string(), - _ => { - if let Some(rest) = self.results_title.strip_prefix("Album: ") { - rest.trim().to_string() - } else if let Some(rest) = self.results_title.strip_prefix("Playlist: ") { - rest.trim().to_string() - } else if let Some(album) = self.results.iter().find_map(|t| t.album.as_deref()) { - album.to_string() - } else { - return self.error( - "No album name found; usage: :savealbum or :saveresults ", - ); - } - } - }; - let n = self.results.len(); - let key = self.store.resolve_exact(&target_name).unwrap_or(target_name); self.store .playlists - .insert(key.clone(), self.results.clone()); + .insert(name.to_string(), self.queue.clone()); if self.persist() { self.info(format!( - "Saved {n} tracks to playlist \u{201c}{key}\u{201d}" + "Saved {n} tracks to playlist \u{201c}{name}\u{201d}" )); - let sel = self.selected(); - self.set_selected(sel); } } @@ -2411,7 +1391,6 @@ impl App { self.played.clear(); self.current = None; self.result_cursor = None; - self.resume_radio = false; self.states[View::Queue.idx()].select(Some(0)); self.play_index(0); self.info(format!( @@ -2446,9 +1425,12 @@ impl App { return self.error(format!("Playlist \u{201c}{name}\u{201d} is empty")); } let i = i.min(tracks.len() - 1); - self.radio = tracks; - self.radio_played.clear(); - self.play_radio(i); + self.queue = tracks; + self.played.clear(); + self.current = None; + self.result_cursor = None; + self.states[View::Queue.idx()].select(Some(i)); + self.play_index(i); } fn remove_playlist_track(&mut self, i: usize) { @@ -2520,7 +1502,6 @@ impl App { #[cfg(test)] mod tests { use super::*; - use crate::keys::Keys; use std::sync::mpsc; fn track(id: &str, title: &str) -> Track { @@ -2528,7 +1509,6 @@ mod tests { id: id.into(), title: title.into(), artist: "Artist".into(), - artists: Vec::new(), album: None, duration: Some(100), } @@ -2555,128 +1535,12 @@ mod tests { assert!(app.artist_opts.is_empty()); } - fn check_paging(app: &mut App) { - let tracks: Vec = (0..25) - .map(|i| track(&format!("{i:011}"), &format!("Song {i}"))) - .collect(); - let page = Page { - tracks, - next: Some("token".into()), - }; - app.search_id = 7; - app.on_event(AppEvent::Results { - id: 7, - title: "Search: x".into(), - result: Ok(page), - }); - assert_eq!((app.results.len(), app.buffered.len()), (20, 5)); - - app.on_tick(); - assert_eq!(app.results.len(), 20); - app.states[View::Results.idx()].select(Some(18)); - app.load_more(); - assert_eq!((app.results.len(), app.buffered.len()), (25, 0)); - - app.next_page = None; - app.load_more(); - assert_eq!(app.results.len(), 25); - } - - fn now_title(app: &App) -> Option<&str> { - app.now_track().map(|t| t.title.as_str()) - } - - fn check_playlist_playback(app: &mut App) { - app.queue.clear(); - app.current = None; - app.played.clear(); - app.store.playlists.insert( - "run".into(), - vec![ - track("rrrrrrrrrr1", "R1"), - track("rrrrrrrrrr2", "R2"), - track("rrrrrrrrrr3", "R3"), - ], - ); - - app.set_view(View::Playlists); - app.open = Some("run".into()); - app.pl_state.select(Some(0)); - press(app, KeyCode::Enter); - assert!(app.queue.is_empty()); - assert_eq!(now_title(app), Some("R1")); - - app.enqueue(track("qqqqqqqqqq1", "Q1")); - app.advance(true); - assert_eq!(now_title(app), Some("Q1")); - app.advance(true); - assert_eq!(now_title(app), Some("R2")); - app.advance(true); - assert_eq!(now_title(app), Some("R3")); - assert_eq!(app.queue.len(), 1); - - app.repeat = RepeatMode::All; - app.advance(true); - assert_eq!(now_title(app), Some("R1")); - app.repeat = RepeatMode::Off; - - app.open = None; - let i = app.store.playlists.keys().position(|k| k == "run").unwrap(); - app.activate_at(View::Playlists, i); - assert_eq!(now_title(app), Some("R1")); - assert_eq!(app.queue.len(), 1); - - app.shuffle = true; - app.advance(true); - let first = app.result_cursor.unwrap(); - app.advance(true); - let second = app.result_cursor.unwrap(); - assert!(first != 0 && second != 0 && first != second); - app.advance(true); - assert_eq!(app.result_cursor, Some(second)); - app.shuffle = false; - } - - fn check_custom_keys(app: &mut App) { - let custom = [ - ("move_down", Keys::Many(vec!["e".into()])), - ("go_top", Keys::One("zz".into())), - ("quit", Keys::One("Q".into())), - ]; - let custom = custom.into_iter().map(|(k, v)| (k.into(), v)).collect(); - app.keymap = Keymap::build(&custom).unwrap(); - - app.results = (0..5).map(|i| track(&format!("{i:011}"), "T")).collect(); - app.view = View::Results; - app.states[View::Results.idx()].select(Some(0)); - - press(app, KeyCode::Char('j')); - assert_eq!(app.selected(), 0); - press(app, KeyCode::Char('e')); - assert_eq!(app.selected(), 1); - press(app, KeyCode::Char('2')); - press(app, KeyCode::Char('e')); - assert_eq!(app.selected(), 3); - press(app, KeyCode::Char('z')); - assert!(app.count.is_none() && !app.pending.is_empty()); - press(app, KeyCode::Char('z')); - assert_eq!(app.selected(), 0); - assert!(app.pending.is_empty()); - - press(app, KeyCode::Char('q')); - assert!(!app.should_quit); - press(app, KeyCode::Char('Q')); - assert!(app.should_quit); - app.should_quit = false; - app.keymap = Keymap::default(); - } - #[test] fn playlist_drill_in() { let dir = std::env::temp_dir().join(format!("drift-test-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); - std::env::set_var("DRIFT_CONFIG_DIR", &dir); - std::env::set_var("DRIFT_STATE_DIR", &dir); + std::env::set_var("XDG_CONFIG_HOME", &dir); + std::env::set_var("XDG_STATE_HOME", &dir); let (tx, _rx) = mpsc::channel(); let mut app = App::new(tx); @@ -2760,54 +1624,10 @@ mod tests { assert_eq!(app.store.playlists.len(), 1); check_artist_popup(&mut app); - check_paging(&mut app); - check_custom_keys(&mut app); app.run_command("remove"); assert_eq!(app.queue.len(), 1); - check_playlist_playback(&mut app); - - // Test savealbum and saveresults - let mut t1 = track("song1111111", "Dreams"); - t1.album = Some("Rumours".into()); - let mut t2 = track("song2222222", "Go Your Own Way"); - t2.album = Some("Rumours".into()); - app.results = vec![t1.clone(), t2.clone()]; - app.results_title = "Album: Rumours".into(); - app.view = View::Results; - - // :savealbum with no args uses album name from results_title - app.run_command("savealbum"); - assert_eq!(app.store.playlists.get("Rumours").map(Vec::len), Some(2)); - - // :savealbum with custom name - app.run_command(r#"savealbum "Fleetwood Mac - Rumours""#); - assert_eq!( - app.store - .playlists - .get("Fleetwood Mac - Rumours") - .map(Vec::len), - Some(2) - ); - - // :saveresults with custom name - app.run_command(r#"saveresults "Classic Hits""#); - assert_eq!( - app.store.playlists.get("Classic Hits").map(Vec::len), - Some(2) - ); - - // Test AppEvent::SavePlaylist background event - app.on_event(AppEvent::SavePlaylist { - name: "Background Album".into(), - result: Ok(vec![track("bg111111111", "BG Track")]), - }); - assert_eq!( - app.store.playlists.get("Background Album").map(Vec::len), - Some(1) - ); - let _ = std::fs::remove_dir_all(&dir); } } diff --git a/src/command.rs b/src/command.rs index 3f6ada2..c2881be 100644 --- a/src/command.rs +++ b/src/command.rs @@ -30,19 +30,12 @@ pub enum Command { Shuffle, Repeat(Option), Save(String), - SaveForce(String), - SaveAlbum(Option), - SaveResults(Option), Load(String), Append(String), DeletePlaylist(String), AddTo(String), Import(String), Artist, - Similar, - Album(Option), - PlaylistSearch(String), - Lyrics, Help, } @@ -69,18 +62,12 @@ pub const COMMAND_NAMES: &[&str] = &[ "shuffle", "repeat", "save", - "savealbum", - "saveresults", "load", "append", "delpl", "addto", "import", "artist", - "similar", - "album", - "playlist", - "lyrics", "help", ]; @@ -177,9 +164,6 @@ pub fn parse(line: &str) -> Result, String> { other => return Err(format!(":repeat expects off|all|one, got '{other}'")), }, "save" | "w" => Save(name_arg(arg, "save")?), - "save!" | "w!" => SaveForce(name_arg(arg, "save!")?), - "savealbum" | "sa" => SaveAlbum((!arg.is_empty()).then(|| arg.to_string())), - "saveresults" | "sr" => SaveResults((!arg.is_empty()).then(|| arg.to_string())), "load" | "e" => Load(name_arg(arg, "load")?), "append" => Append(name_arg(arg, "append")?), "delpl" | "deletepl" | "delete" => DeletePlaylist(name_arg(arg, "delpl")?), @@ -191,15 +175,6 @@ pub fn parse(line: &str) -> Result, String> { Import(arg.to_string()) } "artist" | "ar" => Artist, - "similar" | "radio" | "sim" => Similar, - "album" | "albums" | "al" => Album((!arg.is_empty()).then(|| arg.to_string())), - "playlist" => { - if arg.is_empty() { - return Err("usage: :playlist \"query\"".into()); - } - PlaylistSearch(arg.to_string()) - } - "lyrics" | "ly" => Lyrics, "help" | "h" => Help, other => return Err(format!("unknown command: :{other} (try :help)")), }; @@ -263,20 +238,6 @@ mod tests { fn artist_command() { assert_eq!(parse("artist"), Ok(Some(Command::Artist))); assert_eq!(parse(":ar"), Ok(Some(Command::Artist))); - assert_eq!(parse("lyrics"), Ok(Some(Command::Lyrics))); - assert_eq!(parse("similar"), Ok(Some(Command::Similar))); - assert_eq!(parse(":radio"), Ok(Some(Command::Similar))); - assert_eq!(parse("album"), Ok(Some(Command::Album(None)))); - assert_eq!( - parse("album daft punk"), - Ok(Some(Command::Album(Some("daft punk".into())))) - ); - assert_eq!( - parse("playlist lofi"), - Ok(Some(Command::PlaylistSearch("lofi".into()))) - ); - assert!(parse("playlist").is_err()); - assert_eq!(parse("playlists"), Ok(Some(Command::ShowPlaylists))); } #[test] @@ -286,22 +247,5 @@ mod tests { Ok(Some(Command::Save("my mix".into()))) ); assert!(parse("save").is_err()); - assert_eq!( - parse("save! my mix"), - Ok(Some(Command::SaveForce("my mix".into()))) - ); - assert_eq!(parse("savealbum"), Ok(Some(Command::SaveAlbum(None)))); - assert_eq!( - parse(r#"savealbum "Abbey Road""#), - Ok(Some(Command::SaveAlbum(Some("Abbey Road".into())))) - ); - assert_eq!( - parse("saveresults"), - Ok(Some(Command::SaveResults(None))) - ); - assert_eq!( - parse(r#"saveresults "My Hits""#), - Ok(Some(Command::SaveResults(Some("My Hits".into())))) - ); } } diff --git a/src/config.rs b/src/config.rs index cb92b38..cd1577c 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,35 +1,13 @@ -use std::collections::HashMap; use std::fs; use std::path::PathBuf; -use std::str::FromStr; use anyhow::Result; -use ratatui::style::Color; -use serde::de::{self, Visitor}; -use serde::{Deserialize, Deserializer}; +use serde::Deserialize; -use crate::keys::{Keymap, Keys}; use crate::model::{RepeatMode, Track}; use crate::session::Session; use crate::store::{write_atomic, Store}; -fn deserialize_color<'de, D>(deserializer: D) -> Result -where - D: Deserializer<'de>, -{ - struct ColorVisitor; - impl<'de> Visitor<'de> for ColorVisitor { - type Value = Color; - fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { - write!(f, "a color name (e.g. \"cyan\") or hex value (e.g. \"#FF8800\")") - } - fn visit_str(self, v: &str) -> Result { - Color::from_str(v).map_err(de::Error::custom) - } - } - deserializer.deserialize_str(ColorVisitor) -} - #[derive(Debug, Clone, Copy, Deserialize)] #[serde(default)] pub struct Playback { @@ -70,85 +48,10 @@ impl Playback { } } -#[derive(Debug, Clone, Copy, Default, Deserialize)] -#[serde(default)] -pub struct Artist { - pub popular_first: bool, -} - -#[derive(Debug, Clone, Default, Deserialize)] -#[serde(default)] -pub struct Discord { - pub client_id: String, -} - -#[derive(Debug, Clone, Deserialize)] -#[serde(default)] -pub struct Audio { - pub download: bool, - pub download_max_minutes: u32, - pub cookies_from_browser: String, -} - -impl Default for Audio { - fn default() -> Self { - Audio { - download: false, - download_max_minutes: 15, - cookies_from_browser: String::new(), - } - } -} - -impl Audio { - /// Browser name for yt-dlp's --cookies-from-browser, if one is set. - pub fn cookies(&self) -> Option<&str> { - let c = self.cookies_from_browser.trim(); - (!c.is_empty()).then_some(c) - } - - /// Whether a track should be downloaded to the cache before playing, - /// as opposed to streamed. Unknown lengths are streamed when a limit is set. - pub fn should_download(&self, duration: Option) -> bool { - if !self.download { - return false; - } - match (self.download_max_minutes, duration) { - (0, _) => true, - (max, Some(d)) => d <= max.saturating_mul(60), - (_, None) => false, - } - } -} - -fn default_accent() -> Color { - Color::Cyan -} - -#[derive(Debug, Clone, Copy, Deserialize)] -#[serde(default)] -pub struct Ui { - #[serde(deserialize_with = "deserialize_color")] - pub accent: Color, -} - -impl Default for Ui { - fn default() -> Self { - Ui { accent: default_accent() } - } -} - #[derive(Debug, Clone, Default, Deserialize)] #[serde(default)] pub struct Config { pub playback: Playback, - pub artist: Artist, - pub ui: Ui, - pub discord: Discord, - pub audio: Audio, - pub keys: HashMap, - #[serde(skip)] - pub keymap: Keymap, } impl Config { @@ -156,27 +59,7 @@ impl Config { Ok(Store::dir()?.join("config.toml")) } - pub fn render(p: &Playback, a: &Artist, u: &Ui, d: &Discord, au: &Audio) -> String { - let accent = match u.accent { - Color::Reset => "reset", - Color::Black => "black", - Color::Red => "red", - Color::Green => "green", - Color::Yellow => "yellow", - Color::Blue => "blue", - Color::Magenta => "magenta", - Color::Cyan => "cyan", - Color::Gray => "gray", - Color::DarkGray => "dark-gray", - Color::LightRed => "light-red", - Color::LightGreen => "light-green", - Color::LightYellow => "light-yellow", - Color::LightBlue => "light-blue", - Color::LightMagenta => "light-magenta", - Color::LightCyan => "light-cyan", - Color::White => "white", - _ => "cyan", - }; + pub fn render(p: &Playback) -> String { format!( "\ # Drift settings. Drift only reads this file and never rewrites it, so your @@ -190,48 +73,6 @@ autoplay = {autoplay} restore_session = {restore} seek_step = {seek:?} volume_step = {vstep:?} - -[artist] -# :artist and ga list the artist's most popular songs first, then more of their songs below -popular_first = {popular} - -[ui] -# Accent color used throughout the UI. -# Named colors: black, red, green, yellow, blue, magenta, cyan, gray, dark-gray, -# light-red, light-green, light-yellow, light-blue, light-magenta, -# light-cyan, white -# Or use a hex value: \"#FF8800\" -accent = \"{accent}\" - -[discord] -# Show the current song in your Discord status. Create an application at -# https://discord.com/developers/applications (its name is shown after -# \"Listening to\") and paste its Application ID here. Empty means off. -client_id = \"{client_id}\" - -[audio] -# false: stream songs straight from YouTube (starts playing right away). -# true: download songs to the cache first, then play (and prefetch the next one). -download = {download} -# With download = true, songs longer than this many minutes are still streamed -# instead of downloaded. 0 means download everything, however long. -download_max_minutes = {dl_max} -# Use your browser's cookies with yt-dlp when YouTube refuses to play something -# (\"Sign in to confirm you're not a bot\", age-restricted songs, ...). -# Examples: \"firefox\", \"chrome\", \"chromium\", \"brave\", \"edge\", -# \"firefox:my-profile\". Empty means off. Close the browser if reading fails. -cookies_from_browser = \"{cookies}\" - -[keys] -# Remap keys here. Each line sets an action to one key or a list of keys and -# replaces that action's default keys. Everything you leave out keeps its vim -# default. See the README for the list of actions. -# Write keys as a character (\"x\", \"G\"), a name (space, enter, esc, tab, backtab, -# backspace, delete, home, end, pageup, pagedown, up, down, left, right, f1-f12), -# ctrl+key (\"ctrl+d\") or a sequence (\"gg\", \"ctrl+x g\"). An empty list unbinds. -# quit = \"Q\" -# move_down = [\"j\", \"down\"] -# go_top = [\"gg\", \"home\"] ", volume = p.volume, shuffle = p.shuffle, @@ -240,12 +81,6 @@ cookies_from_browser = \"{cookies}\" restore = p.restore_session, seek = p.seek_step, vstep = p.volume_step, - popular = a.popular_first, - accent = accent, - client_id = d.client_id, - download = au.download, - dl_max = au.download_max_minutes, - cookies = au.cookies_from_browser.replace(['\\', '"'], ""), ) } @@ -260,7 +95,7 @@ cookies_from_browser = \"{cookies}\" let text = match fs::read_to_string(&path) { Ok(t) => t, Err(e) if e.kind() == std::io::ErrorKind::NotFound => { - let _ = Self::write(&Self::render(&Playback::default(), &Artist::default(), &Ui::default(), &Discord::default(), &Audio::default())); + let _ = Self::write(&Self::render(&Playback::default())); return (Config::default(), None); } Err(e) => { @@ -273,14 +108,7 @@ cookies_from_browser = \"{cookies}\" match toml::from_str::(&text) { Ok(mut c) => { c.playback.sanitize(); - let warning = match Keymap::build(&c.keys) { - Ok(keymap) => { - c.keymap = keymap; - None - } - Err(e) => Some(format!("config.toml [keys]: {e}; using default keys")), - }; - (c, warning) + (c, None) } Err(e) => { let bak = path.with_extension("toml.bak"); @@ -346,7 +174,7 @@ pub fn migrate_legacy() { let _ = Session::write(&s); } } - let _ = Config::write(&Config::render(&p, &Artist::default(), &Ui::default(), &Discord::default(), &Audio::default())); + let _ = Config::write(&Config::render(&p)); } #[cfg(test)] @@ -355,22 +183,13 @@ mod tests { #[test] fn default_file_parses_back() { - let text = Config::render(&Playback::default(), &Artist::default(), &Ui::default(), &Discord::default(), &Audio::default()); + let text = Config::render(&Playback::default()); let c: Config = toml::from_str(&text).unwrap(); let d = Playback::default(); assert_eq!(c.playback.volume, d.volume); assert_eq!(c.playback.repeat, d.repeat); assert_eq!(c.playback.seek_step, 5.0); assert!(c.playback.restore_session); - assert!(!c.artist.popular_first); - } - - #[test] - fn artist_section() { - let c: Config = toml::from_str("[artist]\npopular_first = true").unwrap(); - assert!(c.artist.popular_first); - let c: Config = toml::from_str("[playback]\nvolume = 10").unwrap(); - assert!(!c.artist.popular_first); } #[test] @@ -398,76 +217,4 @@ mod tests { p.sanitize(); assert_eq!((p.volume, p.seek_step, p.volume_step), (100, 5.0, 100.0)); } - - #[test] - fn accent_color_parsing() { - // Default is cyan - let c: Config = toml::from_str("").unwrap(); - assert_eq!(c.ui.accent, Color::Cyan); - - // Named colors - let c: Config = toml::from_str("[ui]\naccent = \"red\"").unwrap(); - assert_eq!(c.ui.accent, Color::Red); - let c: Config = toml::from_str("[ui]\naccent = \"green\"").unwrap(); - assert_eq!(c.ui.accent, Color::Green); - let c: Config = toml::from_str("[ui]\naccent = \"magenta\"").unwrap(); - assert_eq!(c.ui.accent, Color::Magenta); - - // Hex value - let c: Config = toml::from_str("[ui]\naccent = \"#FF8800\"").unwrap(); - assert_eq!(c.ui.accent, Color::Rgb(255, 136, 0)); - - // Invalid color is an error - assert!(toml::from_str::("[ui]\naccent = \"notacolor\"").is_err()); - - // Roundtrip: render then parse - let u = Ui { accent: Color::Blue }; - let text = Config::render(&Playback::default(), &Artist::default(), &u, &Discord::default(), &Audio::default()); - let c: Config = toml::from_str(&text).unwrap(); - assert_eq!(c.ui.accent, Color::Blue); - } - - #[test] - fn keys_section() { - let c: Config = toml::from_str("").unwrap(); - assert!(c.keys.is_empty()); - let c: Config = - toml::from_str("[keys]\nquit = \"Q\"\nmove_down = [\"e\", \"down\"]").unwrap(); - assert!(Keymap::build(&c.keys).is_ok()); - assert_eq!(c.keys.len(), 2); - } - - #[test] - fn audio_section() { - let c: Config = toml::from_str("").unwrap(); - assert!(!c.audio.download); - assert_eq!(c.audio.cookies(), None); - let c: Config = toml::from_str( - "[audio]\ndownload = true\ndownload_max_minutes = 10\ncookies_from_browser = \" firefox \"", - ) - .unwrap(); - assert_eq!(c.audio.cookies(), Some("firefox")); - assert!(c.audio.should_download(Some(600))); - assert!(!c.audio.should_download(Some(601))); - assert!(!c.audio.should_download(None)); - let mut a = c.audio.clone(); - a.download_max_minutes = 0; - assert!(a.should_download(Some(10_000))); - a.download = false; - assert!(!a.should_download(Some(1))); - } - - #[test] - fn audio_roundtrip() { - let a = Audio { - download: true, - download_max_minutes: 30, - cookies_from_browser: "chrome".into(), - }; - let text = Config::render(&Playback::default(), &Artist::default(), &Ui::default(), &Discord::default(), &a); - let c: Config = toml::from_str(&text).unwrap(); - assert!(c.audio.download); - assert_eq!(c.audio.download_max_minutes, 30); - assert_eq!(c.audio.cookies(), Some("chrome")); - } } diff --git a/src/discord.rs b/src/discord.rs deleted file mode 100644 index 1a0af81..0000000 --- a/src/discord.rs +++ /dev/null @@ -1,174 +0,0 @@ -use std::sync::mpsc::{self, RecvTimeoutError, Sender}; -use std::thread; -use std::time::{Duration, SystemTime, UNIX_EPOCH}; - -use discord_rich_presence::activity::{ - Activity, ActivityType, Assets, Button, StatusDisplayType, Timestamps, -}; -use discord_rich_presence::{DiscordIpc, DiscordIpcClient}; - -use crate::app::PlayState; -use crate::model::Track; - -const RETRY: Duration = Duration::from_secs(5); -const DRIFT_TOLERANCE_MS: i64 = 2500; - -enum Msg { - Show(Presence), - Clear, -} - -struct Presence { - title: String, - artist: String, - album: Option, - cover: String, - url: String, - paused: bool, - start: i64, - end: Option, -} - -pub struct Discord { - tx: Sender, - track: Option, - state: PlayState, - start: i64, -} - -impl Discord { - pub fn new(client_id: &str) -> Option { - let id = client_id.trim(); - if id.is_empty() { - return None; - } - let id = id.to_string(); - let (tx, rx) = mpsc::channel(); - thread::Builder::new() - .name("discord".into()) - .spawn(move || run(&id, rx)) - .ok()?; - Some(Discord { - tx, - track: None, - state: PlayState::Stopped, - start: 0, - }) - } - - pub fn update(&mut self, track: Option<&Track>, state: PlayState, position: f64) { - let Some(track) = track.filter(|_| state != PlayState::Stopped) else { - if self.state != PlayState::Stopped { - self.state = PlayState::Stopped; - self.track = None; - let _ = self.tx.send(Msg::Clear); - } - return; - }; - - let position = if position.is_finite() { position.max(0.0) } else { 0.0 }; - let start = now_ms() - (position * 1000.0) as i64; - let paused = state == PlayState::Paused; - let same_track = self.track.as_deref() == Some(track.id.as_str()); - let was_paused = self.state == PlayState::Paused; - let on_time = paused || (start - self.start).abs() < DRIFT_TOLERANCE_MS; - if same_track && paused == was_paused && self.state != PlayState::Stopped && on_time { - self.state = state; - return; - } - - self.track = Some(track.id.clone()); - self.state = state; - self.start = start; - let end = track - .duration - .map(|d| start + i64::from(d) * 1000) - .filter(|_| !paused); - let _ = self.tx.send(Msg::Show(Presence { - title: track.title.clone(), - artist: track.artist.clone(), - album: track.album.clone(), - cover: format!("https://i.ytimg.com/vi/{}/hqdefault.jpg", track.id), - url: track.url(), - paused, - start, - end, - })); - } -} - -fn now_ms() -> i64 { - SystemTime::now() - .duration_since(UNIX_EPOCH) - .map(|d| d.as_millis() as i64) - .unwrap_or(0) -} - -fn run(client_id: &str, rx: mpsc::Receiver) { - let mut client: Option = None; - let mut pending: Option = None; - loop { - match rx.recv_timeout(RETRY) { - Ok(msg) => pending = Some(msg), - Err(RecvTimeoutError::Timeout) => {} - Err(RecvTimeoutError::Disconnected) => break, - } - while let Ok(msg) = rx.try_recv() { - pending = Some(msg); - } - let Some(msg) = pending.take() else { continue }; - - if client.is_none() { - if matches!(msg, Msg::Clear) { - continue; - } - let mut c = DiscordIpcClient::new(client_id); - if c.connect().is_ok() { - client = Some(c); - } - } - let sent = client.as_mut().is_some_and(|c| send(c, &msg)); - if !sent { - client = None; - if matches!(msg, Msg::Show(_)) { - pending = Some(msg); - } - } - } - if let Some(mut c) = client { - let _ = c.clear_activity(); - let _ = c.close(); - } -} - -fn send(client: &mut DiscordIpcClient, msg: &Msg) -> bool { - match msg { - Msg::Clear => client.clear_activity().is_ok(), - Msg::Show(p) => { - let mut assets = Assets::new().large_image(&p.cover); - if let Some(album) = &p.album { - assets = assets.large_text(album); - } - let mut timestamps = Timestamps::new().start(p.start); - if let Some(end) = p.end { - timestamps = timestamps.end(end); - } - let artist = if p.paused { - format!("{} (paused)", p.artist) - } else { - p.artist.clone() - }; - let mut activity = Activity::new() - .activity_type(ActivityType::Listening) - .status_display_type(StatusDisplayType::Details) - .details(&p.title) - .state(&artist) - .assets(assets) - .buttons(vec![Button::new("Listen Along", &p.url)]); - if !p.paused { - activity = activity.timestamps(timestamps); - } - client.set_activity(activity).is_ok() - } - } -} diff --git a/src/keys.rs b/src/keys.rs deleted file mode 100644 index 50a997a..0000000 --- a/src/keys.rs +++ /dev/null @@ -1,353 +0,0 @@ -use std::collections::HashMap; -use std::fmt; - -use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; -use serde::Deserialize; - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum Action { - MoveDown, - MoveUp, - HalfPageDown, - HalfPageUp, - PageDown, - PageUp, - GoTop, - GoBottom, - Menu, - Delete, - NextView, - PrevView, - ViewResults, - ViewQueue, - ViewPlaylists, - Activate, - Pause, - Next, - Prev, - SeekBack, - SeekForward, - VolumeUp, - VolumeDown, - Add, - AddAll, - PlayAll, - MoveItemDown, - MoveItemUp, - Shuffle, - Repeat, - Search, - Command, - LyricsPanel, - Lyrics, - GoArtist, - GoSimilar, - GoAlbum, - SaveAlbum, - Help, - Quit, - Back, -} - -const DEFAULTS: &[(&str, Action, &[&str])] = &[ - ("move_down", Action::MoveDown, &["j", "down"]), - ("move_up", Action::MoveUp, &["k", "up"]), - ("half_page_down", Action::HalfPageDown, &["ctrl+d"]), - ("half_page_up", Action::HalfPageUp, &["ctrl+u"]), - ("page_down", Action::PageDown, &["ctrl+f", "pagedown"]), - ("page_up", Action::PageUp, &["ctrl+b", "pageup"]), - ("go_top", Action::GoTop, &["gg", "home"]), - ("go_bottom", Action::GoBottom, &["ge", "end"]), - ("menu", Action::Menu, &["G", "menu"]), - ("delete", Action::Delete, &["x", "delete", "dd"]), - ("next_view", Action::NextView, &["tab"]), - ("prev_view", Action::PrevView, &["backtab"]), - ("view_results", Action::ViewResults, &["gr"]), - ("view_queue", Action::ViewQueue, &["gq"]), - ("view_playlists", Action::ViewPlaylists, &["gp"]), - ("activate", Action::Activate, &["enter"]), - ("pause", Action::Pause, &["space"]), - ("next", Action::Next, &["n", ">"]), - ("prev", Action::Prev, &["N", "<"]), - ("seek_back", Action::SeekBack, &["h", "left"]), - ("seek_forward", Action::SeekForward, &["l", "right"]), - ("volume_up", Action::VolumeUp, &["+", "="]), - ("volume_down", Action::VolumeDown, &["-", "_"]), - ("add", Action::Add, &["a"]), - ("add_all", Action::AddAll, &["A"]), - ("play_all", Action::PlayAll, &["P"]), - ("move_item_down", Action::MoveItemDown, &["J"]), - ("move_item_up", Action::MoveItemUp, &["K"]), - ("shuffle", Action::Shuffle, &["s"]), - ("repeat", Action::Repeat, &["r"]), - ("search", Action::Search, &["/"]), - ("command", Action::Command, &[":"]), - ("lyrics_panel", Action::LyricsPanel, &["L"]), - ("lyrics", Action::Lyrics, &["F"]), - ("go_artist", Action::GoArtist, &["ga"]), - ("go_similar", Action::GoSimilar, &["gs"]), - ("go_album", Action::GoAlbum, &["gb"]), - ("save_album", Action::SaveAlbum, &["gB"]), - ("help", Action::Help, &["?"]), - ("quit", Action::Quit, &["q"]), - ("back", Action::Back, &["backspace", "esc"]), -]; - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct Key { - code: KeyCode, - ctrl: bool, -} - -impl Key { - pub fn digit(&self) -> Option { - match self.code { - KeyCode::Char(c @ '0'..='9') if !self.ctrl => Some(c as usize - '0' as usize), - _ => None, - } - } -} - -impl From for Key { - fn from(e: KeyEvent) -> Key { - Key { - code: e.code, - ctrl: e.modifiers.contains(KeyModifiers::CONTROL) - && !e.modifiers.contains(KeyModifiers::ALT), - } - } -} - -impl fmt::Display for Key { - fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { - if self.ctrl { - f.write_str("^")?; - } - match self.code { - KeyCode::Char(c) => write!(f, "{c}"), - other => write!(f, "{other}"), - } - } -} - -#[derive(Debug, Clone, Deserialize)] -#[serde(untagged)] -pub enum Keys { - One(String), - Many(Vec), -} - -impl Keys { - fn as_slice(&self) -> &[String] { - match self { - Keys::One(s) => std::slice::from_ref(s), - Keys::Many(v) => v, - } - } -} - -pub enum Lookup { - Run(Action), - Pending, - Nothing, -} - -#[derive(Debug, Clone)] -pub struct Keymap(Vec<(Vec, Action)>); - -impl Default for Keymap { - fn default() -> Keymap { - Keymap::build(&HashMap::new()).expect("default keys are valid") - } -} - -impl Keymap { - pub fn build(custom: &HashMap) -> Result { - if let Some(name) = custom - .keys() - .find(|n| !DEFAULTS.iter().any(|d| d.0 == n.as_str())) - { - return Err(format!("unknown action \"{name}\"")); - } - - let mut map: Vec<(Vec, Action)> = Vec::new(); - for (name, action, _) in DEFAULTS { - let Some(keys) = custom.get(*name) else { - continue; - }; - for text in keys.as_slice() { - let seq = parse_sequence(text).map_err(|e| format!("{name}: {e}"))?; - if let Some((_, other)) = map.iter().find(|(s, _)| *s == seq) { - if other != action { - return Err(format!("\"{text}\" is bound to two actions")); - } - continue; - } - map.push((seq, *action)); - } - } - - for (name, action, keys) in DEFAULTS { - if custom.contains_key(*name) { - continue; - } - for text in *keys { - let seq = parse_sequence(text).expect("default keys are valid"); - if !map.iter().any(|(s, _)| *s == seq) { - map.push((seq, *action)); - } - } - } - Ok(Keymap(map)) - } - - pub fn lookup(&self, seq: &[Key]) -> Lookup { - if let Some((_, action)) = self.0.iter().find(|(s, _)| s == seq) { - Lookup::Run(*action) - } else if self.0.iter().any(|(s, _)| s.starts_with(seq)) { - Lookup::Pending - } else { - Lookup::Nothing - } - } -} - -fn parse_sequence(text: &str) -> Result, String> { - let mut keys = Vec::new(); - for token in text.split_whitespace() { - match parse_key(token) { - Some(key) => keys.push(key), - None if !token.contains('+') => keys.extend(token.chars().map(|c| Key { - code: KeyCode::Char(c), - ctrl: false, - })), - None => return Err(format!("unknown key \"{token}\"")), - } - } - if keys.is_empty() { - return Err("empty key".into()); - } - Ok(keys) -} - -fn parse_key(token: &str) -> Option { - let (ctrl, name) = match token.get(..5) { - Some(p) if p.eq_ignore_ascii_case("ctrl+") => (true, &token[5..]), - _ => (false, token), - }; - let mut chars = name.chars(); - let code = match (chars.next()?, chars.next()) { - (c, None) if ctrl => KeyCode::Char(c.to_ascii_lowercase()), - (c, None) => KeyCode::Char(c), - _ => match name.to_ascii_lowercase().as_str() { - "space" => KeyCode::Char(' '), - "enter" | "return" => KeyCode::Enter, - "esc" | "escape" => KeyCode::Esc, - "tab" => KeyCode::Tab, - "backtab" => KeyCode::BackTab, - "backspace" => KeyCode::Backspace, - "delete" | "del" => KeyCode::Delete, - "insert" => KeyCode::Insert, - "home" => KeyCode::Home, - "end" => KeyCode::End, - "pageup" => KeyCode::PageUp, - "pagedown" => KeyCode::PageDown, - "up" => KeyCode::Up, - "down" => KeyCode::Down, - "left" => KeyCode::Left, - "right" => KeyCode::Right, - "menu" => KeyCode::Menu, - f => match f.strip_prefix('f')?.parse::() { - Ok(n @ 1..=12) => KeyCode::F(n), - _ => return None, - }, - }, - }; - Some(Key { code, ctrl }) -} - -#[cfg(test)] -mod tests { - use super::*; - - fn custom(entries: &[(&str, &[&str])]) -> HashMap { - entries - .iter() - .map(|(name, keys)| { - let keys = keys.iter().map(|k| k.to_string()).collect(); - (name.to_string(), Keys::Many(keys)) - }) - .collect() - } - - fn seq(text: &str) -> Vec { - parse_sequence(text).unwrap() - } - - fn run(map: &Keymap, text: &str) -> Option { - match map.lookup(&seq(text)) { - Lookup::Run(a) => Some(a), - _ => None, - } - } - - #[test] - fn defaults_are_vim() { - let map = Keymap::default(); - assert_eq!(run(&map, "j"), Some(Action::MoveDown)); - assert_eq!(run(&map, "down"), Some(Action::MoveDown)); - assert_eq!(run(&map, "gg"), Some(Action::GoTop)); - assert_eq!(run(&map, "dd"), Some(Action::Delete)); - assert_eq!(run(&map, "ctrl+d"), Some(Action::HalfPageDown)); - assert_eq!(run(&map, "G"), Some(Action::Menu)); - assert_eq!(run(&map, "space"), Some(Action::Pause)); - assert!(matches!(map.lookup(&seq("g")), Lookup::Pending)); - assert!(matches!(map.lookup(&seq("gz")), Lookup::Nothing)); - } - - #[test] - fn parsing() { - assert_eq!(seq("ctrl+D"), seq("ctrl+d")); - assert_eq!(seq("gg"), seq("g g")); - assert_eq!(seq("+").len(), 1); - assert_eq!(seq("ctrl+x g").len(), 2); - assert_eq!(seq("f5")[0].code, KeyCode::F(5)); - assert!(parse_sequence("").is_err()); - assert!(parse_sequence("ctrl+nope").is_err()); - assert!(parse_key("f13").is_none()); - } - - #[test] - fn custom_replaces_default_keys() { - let map = Keymap::build(&custom(&[("quit", &["Q"]), ("move_down", &["e"])])).unwrap(); - assert_eq!(run(&map, "Q"), Some(Action::Quit)); - assert_eq!(run(&map, "q"), None); - assert_eq!(run(&map, "e"), Some(Action::MoveDown)); - assert_eq!(run(&map, "j"), None); - assert_eq!(run(&map, "k"), Some(Action::MoveUp)); - } - - #[test] - fn custom_wins_over_other_defaults() { - let map = Keymap::build(&custom(&[("quit", &["x"])])).unwrap(); - assert_eq!(run(&map, "x"), Some(Action::Quit)); - assert_eq!(run(&map, "dd"), Some(Action::Delete)); - } - - #[test] - fn single_string_and_disable() { - let toml = "quit = \"Q\"\nshuffle = []"; - let keys: HashMap = toml::from_str(toml).unwrap(); - let map = Keymap::build(&keys).unwrap(); - assert_eq!(run(&map, "Q"), Some(Action::Quit)); - assert_eq!(run(&map, "s"), None); - } - - #[test] - fn bad_config_is_rejected() { - assert!(Keymap::build(&custom(&[("fly", &["f"])])).is_err()); - assert!(Keymap::build(&custom(&[("quit", &["ctrl+nope"])])).is_err()); - assert!(Keymap::build(&custom(&[("quit", &["z"]), ("add", &["z"])])).is_err()); - assert!(Keymap::build(&custom(&[("quit", &["z", "z"])])).is_ok()); - } -} diff --git a/src/lyrics.rs b/src/lyrics.rs deleted file mode 100644 index 373c8fa..0000000 --- a/src/lyrics.rs +++ /dev/null @@ -1,201 +0,0 @@ -use std::time::Duration; - -use serde_json::Value; - -use crate::model::{parse_clock, Track}; - -const SEARCH: &str = "https://lrclib.net/api/search"; -const UA: &str = concat!("drift/", env!("CARGO_PKG_VERSION")); -const LEAD: f64 = 0.25; -const MAX_DRIFT: f64 = 8.0; - -#[derive(Debug, Clone, PartialEq)] -pub struct Line { - pub at: f64, - pub text: String, -} - -#[derive(Debug, Clone, PartialEq)] -pub struct Lyrics { - pub lines: Vec, - pub synced: bool, -} - -impl Lyrics { - pub fn active(&self, position: f64) -> Option { - if !self.synced { - return None; - } - let n = self.lines.partition_point(|l| l.at <= position + LEAD); - n.checked_sub(1) - } - - pub fn progress(&self, i: usize, position: f64) -> f64 { - let Some(line) = self.lines.get(i) else { - return 0.0; - }; - let sing = line.text.chars().count() as f64 * 0.1 + 1.0; - let span = match self.lines.get(i + 1) { - Some(next) => (next.at - line.at).min(sing), - None => sing, - }; - ((position - line.at) / span.max(0.1)).clamp(0.0, 1.0) - } -} - -pub fn parse_lrc(text: &str) -> Vec { - let mut out = Vec::new(); - for raw in text.lines() { - let mut rest = raw.trim(); - let mut stamps = Vec::new(); - while let Some(tail) = rest.strip_prefix('[') { - let Some((tag, after)) = tail.split_once(']') else { - break; - }; - let Some(t) = parse_clock(tag) else { - break; - }; - stamps.push(t); - rest = after; - } - let text = rest.trim(); - for at in stamps { - out.push(Line { - at, - text: text.to_string(), - }); - } - } - out.sort_by(|a, b| a.at.total_cmp(&b.at)); - out -} - -fn from_entry(entry: &Value) -> Option { - let text = |key| entry[key].as_str().filter(|s| !s.trim().is_empty()); - if let Some(s) = text("syncedLyrics") { - let lines = parse_lrc(s); - if !lines.is_empty() { - return Some(Lyrics { lines, synced: true }); - } - } - let plain = text("plainLyrics")?; - let lines = plain - .lines() - .map(|l| Line { - at: 0.0, - text: l.trim().to_string(), - }) - .collect(); - Some(Lyrics { - lines, - synced: false, - }) -} - -fn pick(entries: &[Value], duration: Option) -> Option { - let drift = |e: &Value| match (duration, e["duration"].as_f64()) { - (Some(want), Some(got)) => (got - f64::from(want)).abs(), - _ => 0.0, - }; - let mut found: Vec<(bool, f64, Lyrics)> = entries - .iter() - .filter(|e| drift(e) <= MAX_DRIFT) - .filter_map(|e| from_entry(e).map(|l| (!l.synced, drift(e), l))) - .collect(); - found.sort_by(|a, b| a.0.cmp(&b.0).then(a.1.total_cmp(&b.1))); - found.into_iter().next().map(|(_, _, l)| l) -} - -fn query(params: &[(&str, &str)]) -> Result, String> { - let agent = ureq::AgentBuilder::new() - .timeout(Duration::from_secs(10)) - .user_agent(UA) - .build(); - let mut req = agent.get(SEARCH); - for (k, v) in params { - req = req.query(k, v); - } - req.call() - .map_err(|e| e.to_string())? - .into_json::>() - .map_err(|e| e.to_string()) -} - -pub fn fetch(track: &Track) -> Result, String> { - let artist = track - .artist_names() - .into_iter() - .next() - .unwrap_or_default(); - let exact = query(&[("track_name", &track.title), ("artist_name", &artist)])?; - if let Some(l) = pick(&exact, track.duration) { - return Ok(Some(l)); - } - let loose = query(&[("q", &format!("{} {}", track.title, artist))])?; - Ok(pick(&loose, track.duration)) -} - -#[cfg(test)] -mod tests { - use super::*; - use serde_json::json; - - #[test] - fn parses_timestamps_and_skips_metadata() { - let lines = parse_lrc("[ar:Someone]\n[00:12.50]first\n[01:02.00][00:30.25]shared\n[00:45.00]"); - let got: Vec<(f64, &str)> = lines.iter().map(|l| (l.at, l.text.as_str())).collect(); - assert_eq!( - got, - [ - (12.5, "first"), - (30.25, "shared"), - (45.0, ""), - (62.0, "shared") - ] - ); - } - - #[test] - fn finds_active_line() { - let l = Lyrics { - lines: parse_lrc("[00:10.00]a\n[00:20.00]b"), - synced: true, - }; - assert_eq!(l.active(5.0), None); - assert_eq!(l.active(10.0), Some(0)); - assert_eq!(l.active(19.9), Some(1)); - assert_eq!(l.active(99.0), Some(1)); - } - - #[test] - fn progress_stays_in_range() { - let l = Lyrics { - lines: parse_lrc("[00:10.00]abcde\n[00:40.00]b"), - synced: true, - }; - assert_eq!(l.progress(0, 9.0), 0.0); - assert!(l.progress(0, 10.75) > 0.4 && l.progress(0, 10.75) < 0.6); - assert_eq!(l.progress(0, 30.0), 1.0); - } - - #[test] - fn prefers_synced_and_close_duration() { - let entries = [ - json!({"duration": 200.0, "plainLyrics": "plain"}), - json!({"duration": 230.0, "syncedLyrics": "[00:01.00]far"}), - json!({"duration": 203.0, "syncedLyrics": "[00:01.00]near"}), - json!({"duration": 201.0, "syncedLyrics": "[00:01.00]nearest"}), - ]; - let l = pick(&entries, Some(200)).unwrap(); - assert!(l.synced); - assert_eq!(l.lines[0].text, "nearest"); - } - - #[test] - fn falls_back_to_plain_lyrics() { - let entries = [json!({"duration": 200.0, "syncedLyrics": null, "plainLyrics": "one\ntwo"})]; - let l = pick(&entries, Some(200)).unwrap(); - assert!(!l.synced); - assert_eq!(l.lines.len(), 2); - } -} diff --git a/src/main.rs b/src/main.rs index b43714a..d685433 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,15 +1,10 @@ mod app; mod command; mod config; -mod discord; -mod keys; -mod lyrics; mod model; -mod mpris; mod player; mod session; mod store; -mod sys; mod ui; mod ytmusic; @@ -35,9 +30,12 @@ USAGE: Inside the player press ? for key bindings and :help for commands."; fn tool_version(name: &str) -> Option { - let mut cmd = Command::new(name); - cmd.arg("--version").stdin(Stdio::null()).stderr(Stdio::null()); - let out = sys::no_window(&mut cmd).output().ok()?; + let out = Command::new(name) + .arg("--version") + .stdin(Stdio::null()) + .stderr(Stdio::null()) + .output() + .ok()?; out.status.success().then(|| { String::from_utf8_lossy(&out.stdout) .lines() @@ -53,11 +51,11 @@ fn missing_dependencies(verbose: bool) -> Vec { for (tool, hint) in [ ( "mpv", - "sudo apt install mpv | brew install mpv | sudo pacman -S mpv | winget install mpv.net / scoop install mpv", + "sudo apt install mpv | brew install mpv | sudo pacman -S mpv", ), ( "yt-dlp", - "pipx install yt-dlp | brew install yt-dlp | sudo pacman -S yt-dlp | winget install yt-dlp.yt-dlp / scoop install yt-dlp", + "pipx install yt-dlp | brew install yt-dlp | sudo pacman -S yt-dlp", ), ] { match tool_version(tool) { @@ -131,12 +129,6 @@ fn run(initial_query: Option) -> Result<()> { } let mut terminal = ratatui::try_init()?; - let hook = std::panic::take_hook(); - std::panic::set_hook(Box::new(move |info| { - if std::thread::current().name() == Some("main") { - hook(info); - } - })); let result = event_loop(&mut terminal, &mut app, &rx); ratatui::restore(); app.shutdown(); diff --git a/src/model.rs b/src/model.rs index aef6d38..685c42c 100644 --- a/src/model.rs +++ b/src/model.rs @@ -5,9 +5,6 @@ pub struct Track { pub id: String, pub title: String, pub artist: String, - /* individual credited artists when the source gave them to us */ - #[serde(default)] - pub artists: Vec, #[serde(default)] pub album: Option, #[serde(default)] @@ -19,98 +16,26 @@ impl Track { format!("https://music.youtube.com/watch?v={}", self.id) } - /* Individual artists: the structured list if we have one, else a guess - * made by splitting the display string (old saves, plain yt-dlp). */ - pub fn artist_names(&self) -> Vec { - if self.artists.is_empty() { - split_artists(&self.artist) - } else { - self.artists.clone() - } - } - pub fn label(&self) -> String { format!("{} — {}", self.title, self.artist) } } -#[derive(Debug, Clone, PartialEq)] -pub struct Collection { - pub browse_id: String, - pub title: String, - pub subtitle: String, - pub artist: String, -} - -/* names that contain a separator and must stay in one piece */ -const WHOLE_NAMES: &[&str] = &[ - "Tyler, The Creator", - "Earth, Wind & Fire", - "Emerson, Lake & Palmer", - "Crosby, Stills, Nash & Young", - "Crosby, Stills & Nash", - "Simon & Garfunkel", - "Hall & Oates", - "Brooks & Dunn", - "Mumford & Sons", - "Belle & Sebastian", - "Sly & The Family Stone", - "Ike & Tina Turner", - "Bob Seger & The Silver Bullet Band", - "Hootie & the Blowfish", - "Florence & The Machine", - "Years & Years", - "Kool & The Gang", - "Gladys Knight & The Pips", - "Diana Ross & The Supremes", - "Smokey Robinson & The Miracles", - "Martha Reeves & The Vandellas", - "Huey Lewis & The News", - "Echo & the Bunnymen", - "Prince & The Revolution", - "Bruce Springsteen & The E Street Band", - "Tom Petty & The Heartbreakers", -]; - -/* credit separators, matched case-insensitively */ -const SEPARATORS: &[&str] = &[" & ", ", ", " feat. ", " feat ", " ft. ", " × "]; - -fn starts_with_ci(s: &str, prefix: &str) -> bool { - s.get(..prefix.len()) - .is_some_and(|head| head.eq_ignore_ascii_case(prefix)) -} - -fn push_name(out: &mut Vec, raw: &str) { - let name = raw.trim(); - if !name.is_empty() && !out.iter().any(|o| o == name) { - out.push(name.to_string()); - } -} - -/* Best-effort split of a flattened credit string. Only a fallback: prefer - * Track::artists, which comes straight from the source's own boundaries. */ pub fn split_artists(credit: &str) -> Vec { + const MARK: char = '\u{1}'; + let mut s = credit.to_string(); + for sep in [ + " & ", ", ", " feat. ", " Feat. ", " feat ", " ft. ", " Ft. ", " × ", + ] { + s = s.replace(sep, &MARK.to_string()); + } let mut out: Vec = Vec::new(); - let mut start = 0; - let mut i = 0; - while i < credit.len() { - let rest = &credit[i..]; - let at_piece_start = credit[start..i].trim().is_empty(); - if at_piece_start { - if let Some(name) = WHOLE_NAMES.iter().find(|n| starts_with_ci(rest, n)) { - i += name.len(); - continue; - } + for part in s.split(MARK) { + let name = part.trim(); + if !name.is_empty() && !out.iter().any(|o| o == name) { + out.push(name.to_string()); } - if let Some(sep) = SEPARATORS.iter().find(|sep| starts_with_ci(rest, sep)) { - push_name(&mut out, &credit[start..i]); - i += sep.len(); - start = i; - continue; - } - i += rest.chars().next().map_or(1, char::len_utf8); } - push_name(&mut out, &credit[start..]); out } @@ -200,45 +125,9 @@ mod tests { assert_eq!(split_artists("A, B & C"), ["A", "B", "C"]); assert_eq!(split_artists("A feat. B"), ["A", "B"]); assert_eq!(split_artists("A, A"), ["A"]); - assert_eq!(split_artists("Tyler, The Creator"), ["Tyler, The Creator"]); - assert_eq!( - split_artists("Tyler, The Creator & Kali Uchis"), - ["Tyler, The Creator", "Kali Uchis"] - ); - assert_eq!( - split_artists("Earth, Wind & Fire, Someone"), - ["Earth, Wind & Fire", "Someone"] - ); assert!(split_artists(" ").is_empty()); } - #[test] - fn artist_splitting_edge_cases() { - assert_eq!(split_artists("hall & oates"), ["hall & oates"]); - assert_eq!(split_artists(" Tyler, The Creator "), ["Tyler, The Creator"]); - assert_eq!(split_artists("A Feat. B"), ["A", "B"]); - assert_eq!( - split_artists("Drake, Hall & Oates"), - ["Drake", "Hall & Oates"] - ); - assert_eq!(split_artists("Beyoncé × Jay-Z"), ["Beyoncé", "Jay-Z"]); - } - - #[test] - fn artist_names_prefers_structured_list() { - let mut t = Track { - id: "abcdefghijk".into(), - title: "T".into(), - artist: "Blood, Sweat & Tears".into(), - artists: vec!["Blood, Sweat & Tears".into()], - album: None, - duration: None, - }; - assert_eq!(t.artist_names(), ["Blood, Sweat & Tears"]); - t.artists.clear(); - assert_eq!(t.artist_names(), ["Blood", "Sweat", "Tears"]); - } - #[test] fn ids() { assert!(is_valid_id("dQw4w9WgXcQ")); diff --git a/src/mpris.rs b/src/mpris.rs deleted file mode 100644 index 31c47cf..0000000 --- a/src/mpris.rs +++ /dev/null @@ -1,162 +0,0 @@ -/* MPRIS (media keys, playerctl, waybar...) on Linux; a no-op elsewhere. */ - -#[allow(dead_code)] -#[derive(Debug, Clone, Copy, PartialEq)] -pub enum Cmd { - Play, - Pause, - Toggle, - Stop, - Next, - Prev, - SeekBy(f64), - SeekTo(f64), - Quit, -} - -#[cfg(target_os = "linux")] -mod imp { - use std::time::Duration; - - use souvlaki::{ - MediaControlEvent, MediaControls, MediaMetadata, MediaPlayback, MediaPosition, - PlatformConfig, SeekDirection, - }; - - use super::Cmd; - use crate::app::PlayState; - use crate::model::Track; - - pub struct Mpris { - controls: MediaControls, - track: Option, - state: PlayState, - secs: u64, - } - - impl Mpris { - /* None when there is no session bus. */ - pub fn new(send: impl Fn(Cmd) + Send + 'static) -> Option { - let name = format!("drift.instance{}", std::process::id()); - let config = PlatformConfig { - dbus_name: &name, - display_name: "drift", - hwnd: None, - }; - let mut controls = MediaControls::new(config).ok()?; - controls - .attach(move |ev| { - if let Some(c) = convert(ev) { - send(c); - } - }) - .ok()?; - Some(Mpris { - controls, - track: None, - state: PlayState::Stopped, - secs: 0, - }) - } - - pub fn update(&mut self, track: Option<&Track>, state: PlayState, position: f64) { - let id = track.map(|t| t.id.as_str()); - if id != self.track.as_deref() { - self.track = id.map(str::to_string); - let cover = track.map(|t| format!("https://i.ytimg.com/vi/{}/hqdefault.jpg", t.id)); - let meta = match track { - Some(t) => MediaMetadata { - title: Some(t.title.as_str()), - album: t.album.as_deref(), - artist: Some(t.artist.as_str()), - cover_url: cover.as_deref(), - duration: t.duration.map(|d| Duration::from_secs(u64::from(d))), - }, - None => MediaMetadata::default(), - }; - let _ = self.controls.set_metadata(meta); - self.secs = u64::MAX; - } - let pos = if position.is_finite() { position.max(0.0) } else { 0.0 }; - let secs = pos as u64; - if state == self.state && secs == self.secs { - return; - } - self.state = state; - self.secs = secs; - let progress = Some(MediaPosition(Duration::from_secs_f64(pos))); - let _ = self.controls.set_playback(match state { - PlayState::Playing | PlayState::Loading => MediaPlayback::Playing { progress }, - PlayState::Paused => MediaPlayback::Paused { progress }, - PlayState::Stopped => MediaPlayback::Stopped, - }); - } - } - - fn dir(d: SeekDirection, secs: f64) -> f64 { - if d == SeekDirection::Backward { - -secs - } else { - secs - } - } - - pub(super) fn convert(ev: MediaControlEvent) -> Option { - Some(match ev { - MediaControlEvent::Play => Cmd::Play, - MediaControlEvent::Pause => Cmd::Pause, - MediaControlEvent::Toggle => Cmd::Toggle, - MediaControlEvent::Next => Cmd::Next, - MediaControlEvent::Previous => Cmd::Prev, - MediaControlEvent::Stop => Cmd::Stop, - MediaControlEvent::Seek(d) => Cmd::SeekBy(dir(d, 5.0)), - MediaControlEvent::SeekBy(d, by) => Cmd::SeekBy(dir(d, by.as_secs_f64())), - MediaControlEvent::SetPosition(MediaPosition(p)) => Cmd::SeekTo(p.as_secs_f64()), - MediaControlEvent::Quit => Cmd::Quit, - _ => return None, - }) - } - - #[cfg(test)] - mod tests { - use super::*; - - #[test] - fn maps_events() { - assert_eq!(convert(MediaControlEvent::Previous), Some(Cmd::Prev)); - assert_eq!( - convert(MediaControlEvent::SeekBy( - SeekDirection::Backward, - Duration::from_secs(10) - )), - Some(Cmd::SeekBy(-10.0)) - ); - assert_eq!( - convert(MediaControlEvent::SetPosition(MediaPosition( - Duration::from_secs(90) - ))), - Some(Cmd::SeekTo(90.0)) - ); - assert_eq!(convert(MediaControlEvent::Raise), None); - } - } -} - -#[cfg(not(target_os = "linux"))] -mod imp { - use super::Cmd; - use crate::app::PlayState; - use crate::model::Track; - - pub struct Mpris; - - impl Mpris { - pub fn new(_send: impl Fn(Cmd) + Send + 'static) -> Option { - None - } - - pub fn update(&mut self, _track: Option<&Track>, _state: PlayState, _position: f64) {} - } -} - -pub use imp::Mpris; diff --git a/src/player.rs b/src/player.rs index a4ea4b2..37806af 100644 --- a/src/player.rs +++ b/src/player.rs @@ -1,116 +1,12 @@ use std::io::{BufRead, BufReader, Write}; +use std::os::unix::net::UnixStream; use std::path::PathBuf; use std::process::{Child, Command, Stdio}; use std::time::{Duration, Instant}; -use anyhow::{anyhow, bail, Result}; +use anyhow::{anyhow, bail, Context, Result}; use serde_json::{json, Value}; -use crate::config::Audio; - -/// Platform-specific transport for mpv's JSON IPC. -/// Unix: a Unix domain socket. Windows: a named pipe (`\\.\pipe\...`). -#[cfg(unix)] -mod ipc { - use std::io; - use std::os::unix::net::UnixStream; - use std::path::{Path, PathBuf}; - - pub type Reader = UnixStream; - pub type Writer = UnixStream; - - pub fn endpoint() -> PathBuf { - let dir = dirs::runtime_dir().unwrap_or_else(std::env::temp_dir); - let path = dir.join(format!("drift-{}.sock", std::process::id())); - let _ = std::fs::remove_file(&path); - path - } - - pub fn connect(path: &Path) -> io::Result<(Reader, Writer)> { - let stream = UnixStream::connect(path)?; - let reader = stream.try_clone()?; - Ok((reader, stream)) - } - - pub fn cleanup(path: &Path) { - let _ = std::fs::remove_file(path); - } -} - -#[cfg(windows)] -mod ipc { - use std::ffi::c_void; - use std::fs::{File, OpenOptions}; - use std::io::{self, Read}; - use std::os::windows::io::AsRawHandle; - use std::path::{Path, PathBuf}; - use std::time::Duration; - - pub type Writer = File; - - extern "system" { - fn PeekNamedPipe( - pipe: *mut c_void, - buffer: *mut c_void, - buffer_size: u32, - bytes_read: *mut u32, - total_bytes_avail: *mut u32, - bytes_left_this_message: *mut u32, - ) -> i32; - } - - /// Reader half of the pipe. - /// - /// A blocking `ReadFile` on a synchronous pipe handle also blocks any - /// `WriteFile` on a clone of that handle, which would deadlock the player. - /// So we poll with `PeekNamedPipe` and only read when data is waiting. - pub struct Reader(File); - - impl Read for Reader { - fn read(&mut self, buf: &mut [u8]) -> io::Result { - if buf.is_empty() { - return Ok(0); - } - loop { - let mut avail: u32 = 0; - let ok = unsafe { - PeekNamedPipe( - self.0.as_raw_handle() as *mut c_void, - std::ptr::null_mut(), - 0, - std::ptr::null_mut(), - &mut avail, - std::ptr::null_mut(), - ) - }; - if ok == 0 { - // mpv exited / pipe closed: report EOF. - return Ok(0); - } - if avail > 0 { - let n = (avail as usize).min(buf.len()); - return self.0.read(&mut buf[..n]); - } - std::thread::sleep(Duration::from_millis(10)); - } - } - } - - pub fn endpoint() -> PathBuf { - PathBuf::from(format!(r"\\.\pipe\drift-{}", std::process::id())) - } - - pub fn connect(path: &Path) -> io::Result<(Reader, Writer)> { - let file = OpenOptions::new().read(true).write(true).open(path)?; - let reader = file.try_clone()?; - Ok((Reader(reader), file)) - } - - pub fn cleanup(_path: &Path) { - // Named pipes disappear when mpv exits. - } -} - #[derive(Debug)] pub enum PlayerEvent { Position(Option), @@ -125,13 +21,15 @@ pub enum PlayerEvent { pub struct Player { child: Child, - writer: ipc::Writer, + writer: UnixStream, sock_path: PathBuf, } impl Player { pub fn spawn(volume: u8, on_event: impl Fn(PlayerEvent) + Send + 'static) -> Result { - let sock_path = ipc::endpoint(); + let sock_dir = dirs::runtime_dir().unwrap_or_else(std::env::temp_dir); + let sock_path = sock_dir.join(format!("drift-{}.sock", std::process::id())); + let _ = std::fs::remove_file(&sock_path); let mut cmd = Command::new("mpv"); cmd.args([ @@ -142,7 +40,8 @@ impl Player { "--really-quiet", "--audio-display=no", "--force-window=no", - "--ytdl=no", + "--ytdl=yes", + "--ytdl-format=bestaudio/best", "--cache=yes", "--demuxer-max-bytes=32MiB", ]) @@ -157,9 +56,7 @@ impl Player { cmd.stdin(Stdio::null()) .stdout(Stdio::null()) .stderr(Stdio::null()); - crate::sys::no_window(&mut cmd); - // Linux only: ask the kernel to SIGTERM mpv if drift dies unexpectedly. #[cfg(target_os = "linux")] unsafe { use std::os::unix::process::CommandExt; @@ -177,12 +74,9 @@ impl Player { } })?; - // Windows: tie mpv's lifetime to ours. - crate::sys::kill_on_exit(&child); - let start = Instant::now(); - let (reader, writer) = loop { - match ipc::connect(&sock_path) { + let stream = loop { + match UnixStream::connect(&sock_path) { Ok(s) => break s, Err(e) => { if let Ok(Some(status)) = child.try_wait() { @@ -198,9 +92,10 @@ impl Player { } }; + let reader = stream.try_clone().context("cloning mpv socket")?; let mut player = Player { child, - writer, + writer: stream, sock_path, }; @@ -212,44 +107,16 @@ impl Player { ] { player.send(json!(["observe_property", id, prop]))?; } - player.send(json!(["request_log_messages", "error"]))?; std::thread::spawn(move || { - let mut reason: Option = None; for line in BufReader::new(reader).lines() { let Ok(line) = line else { break }; let Ok(v) = serde_json::from_str::(&line) else { continue; }; - match v.get("event").and_then(Value::as_str) { - Some("log-message") => { - let prefix = v.get("prefix").and_then(Value::as_str).unwrap_or(""); - let text = v.get("text").and_then(Value::as_str).unwrap_or("").trim(); - match text.strip_prefix("ERROR: ") { - Some(t) if prefix == "ytdl_hook" => reason = Some(ytdl_reason(t)), - _ if reason.is_none() && !text.is_empty() && !text.starts_with("Failed to open") => { - reason = Some(text.lines().next().unwrap_or(text).to_string()); - } - _ => {} - } - continue; - } - Some("start-file") => reason = None, - _ => {} + if let Some(ev) = parse_event(&v) { + on_event(ev); } - let Some(mut ev) = parse_event(&v) else { - continue; - }; - match &mut ev { - PlayerEvent::LoadError(msg) => { - if let Some(r) = reason.take() { - *msg = r; - } - } - PlayerEvent::FileLoaded => reason = None, - _ => {} - } - on_event(ev); } on_event(PlayerEvent::Died); }); @@ -270,9 +137,9 @@ impl Player { matches!(self.child.try_wait(), Ok(None)) } - pub fn load(&mut self, path: &str) -> Result<()> { + pub fn load(&mut self, url: &str) -> Result<()> { self.send(json!(["set_property", "pause", false]))?; - self.send(json!(["loadfile", path, "replace"])) + self.send(json!(["loadfile", url, "replace"])) } pub fn set_pause(&mut self, pause: bool) -> Result<()> { @@ -307,124 +174,10 @@ impl Drop for Player { } let _ = self.child.kill(); let _ = self.child.wait(); - ipc::cleanup(&self.sock_path); + let _ = std::fs::remove_file(&self.sock_path); } } -const KEEP_FILES: usize = 40; - -/// Base yt-dlp command with the user's cookie setting applied. -fn ytdlp(audio: &Audio) -> Command { - let mut cmd = Command::new("yt-dlp"); - if let Some(browser) = audio.cookies() { - cmd.arg("--cookies-from-browser").arg(browser); - } - cmd -} - -fn audio_dir() -> PathBuf { - dirs::cache_dir() - .unwrap_or_else(std::env::temp_dir) - .join("drift") - .join("audio") -} - -pub fn cached_audio(id: &str) -> Option { - std::fs::read_dir(audio_dir()) - .ok()? - .flatten() - .map(|e| e.path()) - .find(|p| p.file_stem().and_then(|s| s.to_str()) == Some(id)) -} - -pub fn fetch_audio(id: &str, audio: &Audio) -> Result { - if let Some(path) = cached_audio(id) { - return Ok(path); - } - let dir = audio_dir(); - std::fs::create_dir_all(&dir).map_err(|e| format!("can't create {}: {e}", dir.display()))?; - let mut cmd = ytdlp(audio); - cmd.args(["-f", "bestaudio/best", "--no-playlist", "--no-warnings", "--no-progress", "--no-mtime"]) - .arg("-P") - .arg(&dir) - .args(["-o", "%(id)s.%(ext)s"]) - .arg(format!("https://music.youtube.com/watch?v={id}")) - .stdin(Stdio::null()) - .stdout(Stdio::null()); - crate::sys::no_window(&mut cmd); - let out = cmd - .output() - .map_err(|e| format!("could not run yt-dlp ({e}); is it installed and on PATH?"))?; - if let Some(path) = cached_audio(id).filter(|_| out.status.success()) { - prune(&dir); - return Ok(path); - } - let stderr = String::from_utf8_lossy(&out.stderr); - Err(stderr - .lines() - .rev() - .find_map(|l| l.strip_prefix("ERROR: ")) - .map(ytdl_reason) - .unwrap_or_else(|| "yt-dlp downloaded nothing".into())) -} - -/// Ask yt-dlp for the direct media URL so mpv can start playing right away -/// instead of waiting for the whole file to download. -pub fn resolve_stream(id: &str, audio: &Audio) -> Result { - let mut cmd = ytdlp(audio); - cmd.args(["-f", "bestaudio/best", "--no-playlist", "--no-warnings", "-g"]) - .arg(format!("https://music.youtube.com/watch?v={id}")) - .stdin(Stdio::null()); - crate::sys::no_window(&mut cmd); - let out = cmd - .output() - .map_err(|e| format!("could not run yt-dlp ({e}); is it installed and on PATH?"))?; - let stdout = String::from_utf8_lossy(&out.stdout); - if let Some(url) = stdout - .lines() - .map(str::trim) - .find(|l| l.starts_with("http")) - .filter(|_| out.status.success()) - { - return Ok(url.to_string()); - } - let stderr = String::from_utf8_lossy(&out.stderr); - Err(stderr - .lines() - .rev() - .find_map(|l| l.strip_prefix("ERROR: ")) - .map(ytdl_reason) - .unwrap_or_else(|| "yt-dlp returned no stream URL".into())) -} - -fn prune(dir: &std::path::Path) { - let Ok(entries) = std::fs::read_dir(dir) else { - return; - }; - let mut files: Vec<_> = entries - .flatten() - .filter_map(|e| Some((e.metadata().ok()?.modified().ok()?, e.path()))) - .collect(); - if files.len() > KEEP_FILES { - files.sort(); - for (_, path) in &files[..files.len() - KEEP_FILES] { - let _ = std::fs::remove_file(path); - } - } -} - -pub fn ytdl_reason(text: &str) -> String { - let text = text.split("; please report").next().unwrap_or(text).trim(); - let text = match text.strip_prefix('[').and_then(|t| t.split_once("] ")) { - Some((_, rest)) => match rest.split_once(": ") { - Some((id, msg)) if id.len() == 11 && !id.contains(' ') => msg, - _ => rest, - }, - None => text, - }; - text.trim().to_string() -} - fn parse_event(v: &Value) -> Option { match v.get("event")?.as_str()? { "property-change" => { @@ -502,19 +255,6 @@ mod tests { assert!(eof, "end-of-file not seen"); } - #[test] - fn ytdl_reason_is_trimmed() { - assert_eq!( - ytdl_reason("[youtube] dQw4w9WgXcQ: Sign in to confirm you're not a bot. Use --cookies. \n"), - "Sign in to confirm you're not a bot. Use --cookies." - ); - assert_eq!( - ytdl_reason("[youtube] dQw4w9WgXcQ: Failed to extract any player response; please report this issue on https://x"), - "Failed to extract any player response" - ); - assert_eq!(ytdl_reason("Unsupported URL: foo"), "Unsupported URL: foo"); - } - #[test] fn event_parsing() { let v: Value = serde_json::from_str( diff --git a/src/session.rs b/src/session.rs index 599e620..7af170a 100644 --- a/src/session.rs +++ b/src/session.rs @@ -17,42 +17,12 @@ pub struct Session { impl Session { fn path() -> Result { - let dir = crate::store::state_base() + let dir = dirs::state_dir() + .or_else(dirs::data_local_dir) .ok_or_else(|| anyhow!("cannot determine state directory"))?; Ok(dir.join("drift").join("session.json")) } - /* The first drift to start owns the saved session. Later ones neither - restore nor overwrite it. The lock goes away when the process exits. */ - #[cfg(unix)] - pub fn claim() -> bool { - use std::os::unix::io::AsRawFd; - let Ok(path) = Self::path().map(|p| p.with_file_name("session.lock")) else { - return true; - }; - if let Some(dir) = path.parent() { - let _ = fs::create_dir_all(dir); - } - let Ok(file) = fs::OpenOptions::new() - .create(true) - .truncate(false) - .write(true) - .open(&path) - else { - return true; - }; - let ok = unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) } == 0; - if ok { - std::mem::forget(file); - } - ok - } - - #[cfg(not(unix))] - pub fn claim() -> bool { - true - } - pub fn exists() -> bool { Self::path().is_ok_and(|p| p.exists()) } @@ -106,7 +76,6 @@ mod tests { id: id.into(), title: "T".into(), artist: "A".into(), - artists: Vec::new(), album: None, duration: Some(200), } diff --git a/src/store.rs b/src/store.rs index 6216d3d..6a8cec1 100644 --- a/src/store.rs +++ b/src/store.rs @@ -10,32 +10,6 @@ use crate::model::Track; #[derive(Debug, Default, Serialize, Deserialize)] pub struct Store { pub playlists: BTreeMap>, - /* what the file looked like when we last read or wrote it */ - #[serde(skip)] - base: BTreeMap>, -} - -/* keep whatever another drift wrote, but apply the playlists this one changed */ -fn merge( - base: &BTreeMap>, - mine: &BTreeMap>, - disk: BTreeMap>, -) -> BTreeMap> { - let mut out = disk; - for name in base.keys().chain(mine.keys()) { - if base.get(name) == mine.get(name) { - continue; - } - match mine.get(name) { - Some(list) => { - out.insert(name.clone(), list.clone()); - } - None => { - out.remove(name); - } - } - } - out } pub fn write_atomic(path: &Path, text: &str) -> Result<()> { @@ -44,44 +18,13 @@ pub fn write_atomic(path: &Path, text: &str) -> Result<()> { } let tmp = path.with_extension("tmp"); fs::write(&tmp, text).with_context(|| format!("writing {}", tmp.display()))?; - // On Windows the rename can fail briefly while an antivirus scanner or - // indexer holds the destination open, so retry a few times. - let mut last = None; - for attempt in 0..6 { - match fs::rename(&tmp, path) { - Ok(()) => return Ok(()), - Err(e) => { - last = Some(e); - if attempt < 5 { - std::thread::sleep(std::time::Duration::from_millis(40)); - } - } - } - } - let _ = fs::remove_file(&tmp); - Err(last.expect("at least one attempt")) - .with_context(|| format!("replacing {}", path.display())) -} - -/// Base directory for settings and playlists (`DRIFT_CONFIG_DIR` overrides it). -pub fn config_base() -> Option { - std::env::var_os("DRIFT_CONFIG_DIR") - .map(PathBuf::from) - .or_else(dirs::config_dir) -} - -/// Base directory for the saved session (`DRIFT_STATE_DIR` overrides it). -/// `dirs::state_dir()` is `None` on Windows and macOS, so fall back to local data. -pub fn state_base() -> Option { - std::env::var_os("DRIFT_STATE_DIR") - .map(PathBuf::from) - .or_else(dirs::state_dir) - .or_else(dirs::data_local_dir) + fs::rename(&tmp, path).with_context(|| format!("replacing {}", path.display()))?; + Ok(()) } impl Store { pub fn dir() -> Result { - let dir = config_base().ok_or_else(|| anyhow!("cannot determine config directory"))?; + let dir = dirs::config_dir().ok_or_else(|| anyhow!("cannot determine config directory"))?; let new_dir = dir.join("drift"); let old_dir = dir.join("ytm-tui"); if !new_dir.exists() && old_dir.exists() { @@ -110,10 +53,7 @@ impl Store { } }; match serde_json::from_str::(&text) { - Ok(mut s) => { - s.base = s.playlists.clone(); - (s, None) - } + Ok(s) => (s, None), Err(e) => { let bak = path.with_extension("json.bak"); let _ = fs::rename(&path, &bak); @@ -128,18 +68,8 @@ impl Store { } } - fn read_disk() -> Option>> { - let text = fs::read_to_string(Self::path().ok()?).ok()?; - serde_json::from_str::(&text).ok().map(|s| s.playlists) - } - - pub fn save(&mut self) -> Result<()> { - if let Some(disk) = Self::read_disk() { - self.playlists = merge(&self.base, &self.playlists, disk); - } - write_atomic(&Self::path()?, &serde_json::to_string_pretty(&*self)?)?; - self.base = self.playlists.clone(); - Ok(()) + pub fn save(&self) -> Result<()> { + write_atomic(&Self::path()?, &serde_json::to_string_pretty(self)?) } pub fn resolve_exact(&self, name: &str) -> Option { @@ -173,33 +103,6 @@ impl Store { mod tests { use super::*; - #[test] - fn merge_keeps_other_instances_changes() { - let t = |id: &str| Track { - id: id.into(), - title: "T".into(), - artist: "A".into(), - artists: Vec::new(), - album: None, - duration: None, - }; - let base: BTreeMap> = BTreeMap::from([ - ("a".to_string(), vec![t("aaaaaaaaaaa")]), - ("b".to_string(), vec![]), - ]); - let mut mine = base.clone(); - mine.insert("c".into(), vec![t("ccccccccccc")]); - mine.remove("b"); - let mut disk = base.clone(); - disk.insert("d".into(), vec![t("ddddddddddd")]); - disk.get_mut("a").unwrap().push(t("eeeeeeeeeee")); - - let out = merge(&base, &mine, disk); - let names: Vec<&str> = out.keys().map(String::as_str).collect(); - assert_eq!(names, ["a", "c", "d"]); - assert_eq!(out["a"].len(), 2); - } - #[test] fn exact_ignores_prefixes() { let mut s = Store::default(); diff --git a/src/sys.rs b/src/sys.rs deleted file mode 100644 index b20c526..0000000 --- a/src/sys.rs +++ /dev/null @@ -1,91 +0,0 @@ -//! Small platform shims so the rest of the app stays free of `cfg` noise. - -use std::process::Command; - -/// Keep child processes from flashing a console window on Windows. -pub fn no_window(cmd: &mut Command) -> &mut Command { - #[cfg(windows)] - { - use std::os::windows::process::CommandExt; - const CREATE_NO_WINDOW: u32 = 0x0800_0000; - cmd.creation_flags(CREATE_NO_WINDOW); - } - cmd -} - -/// Windows: make sure `child` is killed when drift exits, even if drift -/// crashes (the equivalent of Linux's PR_SET_PDEATHSIG). -#[cfg(windows)] -pub fn kill_on_exit(child: &std::process::Child) { - use std::ffi::c_void; - use std::os::windows::io::AsRawHandle; - use std::sync::OnceLock; - - #[repr(C)] - struct BasicLimits { - per_process_user_time_limit: i64, - per_job_user_time_limit: i64, - limit_flags: u32, - minimum_working_set_size: usize, - maximum_working_set_size: usize, - active_process_limit: u32, - affinity: usize, - priority_class: u32, - scheduling_class: u32, - } - #[repr(C)] - struct IoCounters([u64; 6]); - #[repr(C)] - struct ExtendedLimits { - basic: BasicLimits, - io: IoCounters, - process_memory_limit: usize, - job_memory_limit: usize, - peak_process_memory_used: usize, - peak_job_memory_used: usize, - } - - extern "system" { - fn CreateJobObjectW(attrs: *mut c_void, name: *const u16) -> *mut c_void; - fn SetInformationJobObject( - job: *mut c_void, - class: i32, - info: *mut c_void, - len: u32, - ) -> i32; - fn AssignProcessToJobObject(job: *mut c_void, process: *mut c_void) -> i32; - } - - const JOB_OBJECT_EXTENDED_LIMIT_INFORMATION: i32 = 9; - const KILL_ON_JOB_CLOSE: u32 = 0x2000; - - // The job handle is intentionally kept open for the life of the process; - // when drift exits (or dies) Windows closes it and kills everything in it. - static JOB: OnceLock = OnceLock::new(); - let job = *JOB.get_or_init(|| unsafe { - let job = CreateJobObjectW(std::ptr::null_mut(), std::ptr::null()); - if job.is_null() { - return 0; - } - let mut info: ExtendedLimits = std::mem::zeroed(); - info.basic.limit_flags = KILL_ON_JOB_CLOSE; - let ok = SetInformationJobObject( - job, - JOB_OBJECT_EXTENDED_LIMIT_INFORMATION, - &mut info as *mut _ as *mut c_void, - std::mem::size_of::() as u32, - ); - if ok == 0 { - return 0; - } - job as usize - }); - if job != 0 { - unsafe { - AssignProcessToJobObject(job as *mut c_void, child.as_raw_handle() as *mut c_void); - } - } -} - -#[cfg(not(windows))] -pub fn kill_on_exit(_child: &std::process::Child) {} diff --git a/src/ui.rs b/src/ui.rs index 1f27bdf..3311e06 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -1,28 +1,20 @@ -use ratatui::layout::{Alignment, Constraint, Layout, Margin, Rect}; +use ratatui::layout::{Alignment, Constraint, Layout, Rect}; use ratatui::style::{Color, Modifier, Style, Stylize}; use ratatui::text::{Line, Span}; -use ratatui::widgets::{Block, BorderType, Cell, Clear, Paragraph, Row, Table, Wrap}; +use ratatui::widgets::{Block, Cell, Clear, Paragraph, Row, Table, Tabs}; use ratatui::Frame; -use crate::app::{App, Level, LyricsState, MenuAction, Mode, PlayState, View, NO_KEY}; -use crate::lyrics::Lyrics; +use crate::app::{App, Level, Mode, PlayState, View}; use crate::model::{fmt_time, Track}; +const ACCENT: Color = Color::Cyan; const SPINNER: [&str; 10] = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; -/* Cursor row: accent-colored bold text plus a bar, no filled background. */ -const CURSOR: &str = "▎"; - -fn selected_style(accent: Color) -> Style { - Style::new().fg(accent).add_modifier(Modifier::BOLD) -} - -/* Swap the leading space of a menu line for the cursor bar. */ -fn cursor_text(text: &str) -> String { - match text.strip_prefix(' ') { - Some(rest) => format!("{CURSOR}{rest}"), - None => text.to_string(), - } +fn selected_style() -> Style { + Style::new() + .fg(Color::Black) + .bg(ACCENT) + .add_modifier(Modifier::BOLD) } fn header_style() -> Style { @@ -35,109 +27,56 @@ fn dim() -> Style { pub fn draw(f: &mut Frame, app: &mut App) { let area = f.area(); - let [top, body, playing] = Layout::vertical([ + let [top, body, playing, bottom] = Layout::vertical([ Constraint::Length(1), Constraint::Min(3), Constraint::Length(4), + Constraint::Length(1), ]) .areas(area); - if app.mode == Mode::Lyrics { - draw_lyrics_header(f, app, top); - draw_lyrics(f, app, body, true); - } else { - draw_tabs(f, app, top); - let list = if app.lyrics_panel && body.width >= 70 { - let [list, side] = - Layout::horizontal([Constraint::Min(40), Constraint::Percentage(40)]).areas(body); - draw_lyrics(f, app, side, false); - list - } else { - body - }; - match app.view { - View::Results => draw_tracks(f, app, list, View::Results), - View::Queue => draw_tracks(f, app, list, View::Queue), - View::Playlists => draw_playlists(f, app, list), - } + draw_tabs(f, app, top); + match app.view { + View::Results => draw_tracks(f, app, body, View::Results), + View::Queue => draw_tracks(f, app, body, View::Queue), + View::Playlists => draw_playlists(f, app, body), } draw_now_playing(f, app, playing); - if app.mode == Mode::Command { - draw_palette(f, app, area); - } - if app.mode == Mode::ConfirmDelete { - draw_confirm(f, app, area); - } - if app.mode == Mode::Menu { - draw_menu(f, app, area); - } + draw_cmdline(f, app, bottom); if app.mode == Mode::Help { draw_help(f, app, area); } if app.mode == Mode::ArtistPick { draw_artist_picker(f, app, area); } - if app.mode == Mode::CollectionPick { - draw_collection_picker(f, app, area); - } - if app.mode == Mode::Info { - draw_info(f, app, area); - } - draw_toast(f, app, body); -} - -fn hints(items: &[(&str, &str)], width: u16) -> Option> { - let mut spans: Vec> = vec![Span::raw(" ")]; - let mut len = 2usize; - for (i, (key, label)) in items.iter().enumerate() { - if i > 0 { - spans.push(Span::styled(" ", dim())); - len += 2; - } - spans.push(Span::styled(key.to_string(), Style::new().fg(Color::Yellow))); - spans.push(Span::styled(format!(" {label}"), dim())); - len += key.chars().count() + 1 + label.chars().count(); - } - spans.push(Span::raw(" ")); - (len + 4 <= width as usize).then(|| Line::from(spans).right_aligned()) } fn draw_tabs(f: &mut Frame, app: &App, area: Rect) { let [name, tabs, right] = Layout::horizontal([ - Constraint::Length(10), + Constraint::Length(9), Constraint::Min(10), - Constraint::Length(app.busy.as_ref().map_or(0, |b| b.chars().count() as u16 + 5)), + Constraint::Length(34), ]) .areas(area); f.render_widget( - Paragraph::new(Line::from(vec![ - Span::styled(" ♪ ", Style::new().fg(app.accent)), - Span::styled("Drift", Style::new().fg(app.accent).bold()), - ])), + Paragraph::new(" ♪ Drift").style(Style::new().fg(ACCENT).bold()), name, ); let titles = [ - ("Results", app.results.len()), - ("Queue", app.queue.len()), - ("Playlists", app.store.playlists.len()), + format!(" Results {} ", app.results.len()), + format!(" Queue {} ", app.queue.len()), + format!(" Playlists {} ", app.store.playlists.len()), ]; - let active = Style::new() - .fg(app.accent) - .add_modifier(Modifier::BOLD | Modifier::UNDERLINED); - let mut spans: Vec = Vec::new(); - for (i, (title, count)) in titles.into_iter().enumerate() { - spans.push(Span::raw(" ")); - if i == app.view.idx() { - spans.push(Span::styled(format!("{title} {count}"), active)); - } else { - spans.push(Span::styled(title, Style::new().fg(Color::Gray))); - spans.push(Span::styled(format!(" {count}"), dim())); - } - spans.push(Span::raw(" ")); - } - f.render_widget(Paragraph::new(Line::from(spans)), tabs); + f.render_widget( + Tabs::new(titles) + .select(app.view.idx()) + .style(Style::new().fg(Color::Gray)) + .highlight_style(Style::new().fg(Color::Black).bg(ACCENT).bold()) + .divider(" "), + tabs, + ); if let Some(busy) = &app.busy { let spin = SPINNER[app.tick % SPINNER.len()]; @@ -201,7 +140,7 @@ fn track_rows<'a>( .collect() } -fn track_table<'a>(rows: Vec>, show_album: bool, block: Block<'a>, accent: Color) -> Table<'a> { +fn track_table<'a>(rows: Vec>, show_album: bool, block: Block<'a>) -> Table<'a> { let mut header = vec!["", " #", "Title", "Artist"]; let mut widths = vec![ Constraint::Length(2), @@ -220,8 +159,7 @@ fn track_table<'a>(rows: Vec>, show_album: bool, block: Block<'a>, accen .header(Row::new(header).style(header_style())) .block(block) .column_spacing(1) - .row_highlight_style(selected_style(accent)) - .highlight_symbol(CURSOR) + .row_highlight_style(selected_style()) } fn empty_hint(f: &mut Frame, block: Block, area: Rect, lines: &[&str]) { @@ -243,19 +181,9 @@ fn draw_tracks(f: &mut Frame, app: &mut App, area: Rect, view: View) { View::Queue => (&app.queue, " Queue ".to_string()), _ => (&app.results, format!(" {} ", app.results_title)), }; - let mut block = Block::bordered() + let block = Block::bordered() .title(format!("{title}({}) ", tracks.len())) - .border_style(Style::new().fg(app.accent)); - if !tracks.is_empty() { - let keys: &[(&str, &str)] = if view == View::Queue { - &[("⏎", "play"), ("x", "remove"), ("J/K", "move"), ("G", "menu"), ("?", "help")] - } else { - &[("⏎", "play"), ("a", "queue"), ("G", "menu"), ("/", "search"), ("?", "help")] - }; - if let Some(h) = hints(keys, area.width) { - block = block.title_bottom(h); - } - } + .border_style(Style::new().fg(ACCENT)); if tracks.is_empty() { let hint: &[&str] = if view == View::Queue { @@ -274,7 +202,7 @@ fn draw_tracks(f: &mut Frame, app: &mut App, area: Rect, view: View) { return empty_hint(f, block, area, hint); } let rows = track_rows(tracks, playing, show_album); - let table = track_table(rows, show_album, block, app.accent); + let table = track_table(rows, show_album, block); let idx = view.idx(); f.render_stateful_widget(table, area, &mut app.states[idx]); } @@ -286,15 +214,9 @@ fn draw_open_playlist(f: &mut Frame, app: &mut App, area: Rect, name: &str) { let Some(tracks) = app.store.playlists.get(name) else { return; }; - let mut block = Block::bordered() + let block = Block::bordered() .title(format!(" {name} ({}) ", tracks.len())) - .border_style(Style::new().fg(app.accent)); - if !tracks.is_empty() { - let keys = [("⏎", "play"), ("a", "queue"), ("x", "remove"), ("G", "menu"), ("←", "back")]; - if let Some(h) = hints(&keys, area.width) { - block = block.title_bottom(h); - } - } + .border_style(Style::new().fg(ACCENT)); if tracks.is_empty() { return empty_hint( f, @@ -309,7 +231,7 @@ fn draw_open_playlist(f: &mut Frame, app: &mut App, area: Rect, name: &str) { } let show_album = area.width >= 76; let rows = track_rows(tracks, playing, show_album); - let table = track_table(rows, show_album, block, app.accent); + let table = track_table(rows, show_album, block); f.render_stateful_widget(table, area, &mut app.pl_state); } @@ -322,14 +244,9 @@ fn draw_playlists(f: &mut Frame, app: &mut App, area: Rect) { let [left, right] = Layout::horizontal([Constraint::Percentage(35), Constraint::Percentage(65)]).areas(area); - let mut block = Block::bordered() + let block = Block::bordered() .title(format!(" Playlists ({}) ", app.store.playlists.len())) - .border_style(Style::new().fg(app.accent)); - if !app.store.playlists.is_empty() { - if let Some(h) = hints(&[("⏎", "load"), ("→", "open"), ("G", "menu")], left.width) { - block = block.title_bottom(h); - } - } + .border_style(Style::new().fg(ACCENT)); if app.store.playlists.is_empty() { empty_hint( @@ -371,8 +288,7 @@ fn draw_playlists(f: &mut Frame, app: &mut App, area: Rect) { ) .header(Row::new(vec!["", "Name", "Songs"]).style(header_style())) .block(block) - .row_highlight_style(selected_style(app.accent)) - .highlight_symbol(CURSOR); + .row_highlight_style(selected_style()); f.render_stateful_widget(table, left, &mut app.states[View::Playlists.idx()]); let sel = app.selected(); @@ -384,133 +300,60 @@ fn draw_playlists(f: &mut Frame, app: &mut App, area: Rect) { Some(tracks) if !tracks.is_empty() => { let show_album = right.width >= 70; let rows = track_rows(tracks, None, show_album); - f.render_widget(track_table(rows, show_album, preview_block, app.accent), right); + f.render_widget(track_table(rows, show_album, preview_block), right); } _ => f.render_widget(preview_block, right), } } -const BARS: [&str; 8] = ["▁", "▂", "▃", "▄", "▅", "▆", "▇", "█"]; - -fn gradient(_t: f64, accent: Color) -> Color { - accent -} - -fn equalizer(app: &App, n: usize) -> Vec> { - let t = app.tick as f64; - (0..n) - .map(|i| { - let x = i as f64; - let level = match app.play_state { - PlayState::Playing => { - let v = ((t * 0.45 + x * 1.3).sin() + (t * 0.23 + x * 2.1).sin()) / 4.0 + 0.5; - (v.clamp(0.0, 1.0) * 7.0).round() as usize - } - _ => 0, - }; - let color = if app.play_state == PlayState::Playing { - gradient(x / n as f64, app.accent) - } else { - Color::DarkGray - }; - Span::styled(BARS[level], Style::new().fg(color)) - }) - .collect() -} - fn draw_now_playing(f: &mut Frame, app: &App, area: Rect) { - let active = app.play_state != PlayState::Stopped; - let mut block = Block::bordered() - .border_type(BorderType::Rounded) - .border_style(Style::new().fg(if active { app.accent } else { Color::DarkGray })) - .title(Line::from(vec![ - Span::styled(" ♪ ", Style::new().fg(app.accent).bold()), - Span::styled("Now Playing ", Style::new().fg(app.accent).bold()), - ])); - - let mut pending = String::new(); - if let Some(c) = app.count { - pending.push_str(&c.to_string()); - } - for key in &app.pending { - pending.push_str(&key.to_string()); - } - if !pending.is_empty() { - block = block.title_bottom( - Line::styled(format!(" {pending} "), Style::new().fg(Color::Yellow).bold()) - .right_aligned(), - ); - } - + let block = Block::bordered() + .title(" Now Playing ") + .border_style(Style::new().fg(ACCENT)); let inner = block.inner(area); f.render_widget(block, area); let [l1, l2] = Layout::vertical([Constraint::Length(1), Constraint::Length(1)]).areas(inner); - let on = Style::new().fg(app.accent).bold(); - let vol_cells = 5usize; - let vol_fill = ((app.volume as usize * vol_cells) + 50) / 100; - let mut flags = vec![ - Span::styled("⇄", if app.shuffle { on } else { dim() }), - Span::raw(" "), - Span::styled( - match app.repeat { - crate::model::RepeatMode::Off => "↻".to_string(), - m => format!("↻ {}", m.label()), - }, - if app.repeat != crate::model::RepeatMode::Off { - on - } else { - dim() - }, - ), - Span::raw(" "), - Span::styled("vol ", dim()), - ]; - for i in 0..vol_cells { - flags.push(if i < vol_fill.min(vol_cells) { - Span::styled("━", Style::new().fg(gradient(i as f64 / vol_cells as f64, app.accent))) - } else { - Span::styled("─", dim()) - }); + let mut flags = vec![format!("vol {}%", app.volume)]; + if app.shuffle { + flags.push("shuffle".into()); } - flags.push(Span::styled(format!(" {:>3}%", app.volume), Style::new().fg(Color::Gray))); - flags.push(Span::raw(" ")); - let flags_w: usize = flags.iter().map(|s| s.content.chars().count()).sum(); + if app.repeat != crate::model::RepeatMode::Off { + flags.push(format!("repeat:{}", app.repeat.label())); + } + let flags = flags.join(" · "); + let flags_w = flags.chars().count() as u16 + 1; let [title_area, flags_area] = - Layout::horizontal([Constraint::Min(1), Constraint::Length(flags_w as u16)]).areas(l1); - f.render_widget(Paragraph::new(Line::from(flags)), flags_area); + Layout::horizontal([Constraint::Min(1), Constraint::Length(flags_w)]).areas(l1); + f.render_widget( + Paragraph::new(flags) + .style(dim()) + .alignment(Alignment::Right), + flags_area, + ); - let mut spans: Vec = vec![Span::raw(" ")]; - match app.play_state { - PlayState::Loading => spans.push(Span::styled( - format!("{} ", SPINNER[app.tick % SPINNER.len()]), - Style::new().fg(Color::Yellow), - )), - PlayState::Playing => { - spans.extend(equalizer(app, 6)); - spans.push(Span::raw(" ")); - } - PlayState::Paused => { - spans.push(Span::styled("⏸ ", Style::new().fg(Color::Yellow))); - spans.push(Span::raw(" ")); - } - _ => { - } - } - spans.push(Span::raw(" ")); - match (app.now_track(), active) { - (Some(t), true) => { - spans.push(Span::styled(t.title.clone(), Style::new().fg(Color::White).bold())); - spans.push(Span::styled(" · ", dim())); - spans.push(Span::styled(t.artist.clone(), Style::new().fg(app.accent))); - } - (Some(t), false) => spans.push(Span::styled( - format!("Last played: {} — {}", t.title, t.artist), - dim(), - )), - _ => spans.push(Span::styled("Nothing playing · press / to search", dim())), - } - f.render_widget(Paragraph::new(Line::from(spans)), title_area); + let icon = match app.play_state { + PlayState::Playing => "▶".to_string(), + PlayState::Paused => "⏸".to_string(), + PlayState::Loading => SPINNER[app.tick % SPINNER.len()].to_string(), + PlayState::Stopped => "■".to_string(), + }; + let line = match (app.now_track(), app.play_state) { + (Some(t), s) if s != PlayState::Stopped => Line::from(vec![ + Span::styled(format!("{icon} "), Style::new().fg(Color::Green)), + Span::styled(t.title.clone(), Style::new().bold()), + Span::styled(format!(" {}", t.artist), Style::new().fg(Color::Gray)), + ]), + (Some(t), _) => Line::from(vec![ + Span::styled(format!("{icon} "), dim()), + Span::styled(format!("Last played: {} — {}", t.title, t.artist), dim()), + ]), + _ => Line::from(vec![ + Span::styled(format!("{icon} "), dim()), + Span::styled("Nothing playing", dim()), + ]), + }; + f.render_widget(Paragraph::new(line), title_area); let left = format!(" {} ", fmt_time(app.position)); let right = format!( @@ -527,543 +370,65 @@ fn draw_now_playing(f: &mut Frame, app: &App, area: Rect) { } else { 0.0 }; - let head = ((ratio * bar_w as f64) as usize).min(bar_w.saturating_sub(1)); - let mut line = vec![Span::styled(left, Style::new().fg(Color::Gray))]; - for i in 0..bar_w { - line.push(if !active || app.duration <= 0.0 { - Span::styled("─", dim()) - } else if i < head { - Span::styled("━", Style::new().fg(gradient(i as f64 / bar_w.max(1) as f64, app.accent))) - } else if i == head { - Span::styled("●", Style::new().fg(Color::White).bold()) - } else { - Span::styled("─", dim()) - }); - } - line.push(Span::styled(right, Style::new().fg(Color::Gray))); - f.render_widget(Paragraph::new(Line::from(line)), l2); + let filled = (ratio * bar_w as f64).round() as usize; + let line = Line::from(vec![ + Span::raw(left), + Span::styled("━".repeat(filled), Style::new().fg(ACCENT)), + Span::styled("─".repeat(bar_w.saturating_sub(filled)), dim()), + Span::raw(right), + ]); + f.render_widget(Paragraph::new(line), l2); } -fn draw_lyrics_header(f: &mut Frame, app: &App, area: Rect) { - let unsynced = matches!(&app.lyrics, LyricsState::Ready(l) if !l.synced); - let mut title = vec![ - Span::styled(" Drift", Style::new().fg(app.accent).bold()), - Span::styled(" · Lyrics", dim()), - ]; - if unsynced { - title.push(Span::styled(" · not synced", dim())); - } - f.render_widget(Paragraph::new(Line::from(title)), area); - - let mut keys = vec![("space", "pause"), ("h l", "seek"), ("n N", "skip")]; - if unsynced { - keys.insert(0, ("j k", "scroll")); - } - keys.push(("Esc", "close")); - if let Some(h) = hints(&keys, area.width.saturating_sub(30)) { - f.render_widget(Paragraph::new(h), area); - } -} - -fn lyric_rows( - lyrics: &Lyrics, - position: f64, - width: usize, - roomy: bool, - accent: Color, -) -> (Vec>, Option) { - let active = lyrics.active(position); - let mut rows: Vec> = Vec::new(); - let mut focus = None; - for (i, line) in lyrics.lines.iter().enumerate() { - let parts = wrap_words(&line.text, width); - let is_active = active == Some(i); - if is_active { - focus = Some(rows.len() + parts.len().max(1) / 2); - } - if parts.is_empty() { - rows.push(if is_active { - Line::styled("♪", Style::new().fg(accent).bold()) - } else { - Line::raw("") - }); - continue; - } - if is_active { - let total: usize = parts.iter().map(|p| p.chars().count()).sum(); - let mut fill = (lyrics.progress(i, position) * total as f64).round() as usize; - for part in parts { - let done = fill.min(part.chars().count()); - fill -= done; - let sung: String = part.chars().take(done).collect(); - let rest: String = part.chars().skip(done).collect(); - rows.push(Line::from(vec![ - Span::styled(sung, Style::new().fg(accent).bold()), - Span::styled(rest, Style::new().fg(Color::White).bold()), - ])); - } - } else { - let ahead = active.map_or(i as isize + 1, |a| i as isize - a as isize); - let color = match (lyrics.synced, ahead) { - (false, _) | (_, 1..=2) => Color::Gray, - _ => Color::DarkGray, - }; - rows.extend(parts.into_iter().map(|p| Line::styled(p, Style::new().fg(color)))); - } - if roomy { - rows.push(Line::raw("")); - } - } - (rows, focus) -} - -fn draw_lyrics(f: &mut Frame, app: &mut App, area: Rect, full: bool) { - let inner = if full { - let w = area.width.saturating_sub(4).min(76); - Rect { - x: area.x + (area.width - w) / 2, - width: w, - ..area - } - } else { - let block = Block::bordered() - .border_type(BorderType::Rounded) - .border_style(dim()) - .title(Span::styled(" ♪ Lyrics ", Style::new().fg(app.accent).bold())); - let inner = block.inner(area); - f.render_widget(block, area); - inner.inner(Margin::new(1, 0)) - }; - if inner.width < 4 || inner.height == 0 { - return; - } - - let now = app.now_track().map(|t| t.id.clone()); - let playing = now.is_some() && app.play_state != PlayState::Stopped; - let fresh = playing && app.lyrics_id == now; - let notice: Option> = match &app.lyrics { - _ if !playing => Some(vec![Line::styled("Nothing playing", dim())]), - LyricsState::Ready(_) if fresh => None, - LyricsState::Missing if fresh => Some(vec![ - Line::styled("No lyrics found", Style::new().fg(Color::Gray)), - Line::styled("not every song has them", dim()), - ]), - LyricsState::Failed(e) if fresh => Some(vec![ - Line::styled("Couldn't load lyrics", Style::new().fg(Color::Red)), - Line::styled(e.clone(), dim()), - ]), - _ => Some(vec![Line::styled( - format!("{} Searching lyrics…", SPINNER[app.tick % SPINNER.len()]), - Style::new().fg(Color::Yellow), - )]), - }; - if let Some(lines) = notice { - let h = (lines.len() as u16 + 1).min(inner.height); - let rect = Rect { - y: inner.y + (inner.height - h) / 2, - height: h, - ..inner - }; +fn draw_cmdline(f: &mut Frame, app: &App, area: Rect) { + if app.mode == Mode::ConfirmDelete { + let name = app.pending_delete.as_deref().unwrap_or_default(); f.render_widget( - Paragraph::new(lines) - .alignment(Alignment::Center) - .wrap(Wrap { trim: true }), - rect, + Paragraph::new(format!(" Delete playlist \u{201c}{name}\u{201d}? (y/N)")) + .style(Style::new().fg(Color::Yellow).bold()), + area, ); return; } - let LyricsState::Ready(lyrics) = &app.lyrics else { - return; - }; - let (rows, focus) = lyric_rows(lyrics, app.position, inner.width as usize, full, app.accent); - let height = inner.height as usize; - let max_start = rows.len().saturating_sub(height); - let start = if lyrics.synced { - focus.map_or(0, |r| r.saturating_sub(height / 2)) - } else { - app.lyrics_scroll - } - .min(max_start); - app.lyrics_scroll = start; - let visible: Vec = rows.into_iter().skip(start).take(height).collect(); - let align = if full { Alignment::Center } else { Alignment::Left }; - f.render_widget(Paragraph::new(visible).alignment(align), inner); -} - -fn draw_toast(f: &mut Frame, app: &App, body: Rect) { - let Some(s) = app.visible_status() else { - return; - }; - if body.width < 16 || body.height < 5 { - return; - } - let (icon, color) = match s.level { - Level::Error => ("✖", Color::Red), - Level::Info => ("●", Color::Green), - }; - let max_text = (body.width as usize).saturating_sub(8).min(120); - let mut text: String = s.text.lines().next().unwrap_or("").to_string(); - if text.chars().count() > max_text { - text = text.chars().take(max_text.saturating_sub(1)).collect::() + "…"; - } - let w = (text.chars().count() as u16 + 6).min(body.width); - let rect = Rect::new(body.right().saturating_sub(w + 1).max(body.x), body.bottom().saturating_sub(4), w, 3); - f.render_widget(Clear, rect); - f.render_widget( - Paragraph::new(Line::from(vec![ - Span::styled(format!(" {icon} "), Style::new().fg(color).bold()), - Span::styled(text, Style::new().fg(Color::White)), - ])) - .block( - Block::bordered() - .border_type(BorderType::Rounded) - .border_style(Style::new().fg(color)), - ), - rect, - ); -} - -fn draw_info(f: &mut Frame, app: &App, area: Rect) { - let Some(t) = &app.info_track else { - return; - }; - let rows: Vec<(&str, String)> = vec![ - ("Title", t.title.clone()), - ("Artist", t.artist.clone()), - ("Album", t.album.clone().unwrap_or_else(|| "—".into())), - ("Length", t.duration.map_or("—".into(), |d| fmt_time(d as f64))), - ("ID", t.id.clone()), - ]; - let label_w = 8usize; - let widest = rows.iter().map(|(_, v)| v.chars().count()).max().unwrap_or(0); - let w = ((label_w + widest + 6).max(40) as u16).min(area.width.min(80)); - let value_w = (w as usize).saturating_sub(label_w + 4).max(1); - let mut lines: Vec = Vec::new(); - for (k, v) in &rows { - let mut parts = wrap_words(v, value_w).into_iter(); - let first = parts.next().unwrap_or_default(); - lines.push(Line::from(vec![ - Span::styled(format!(" {k: Rect { - let w = want_w.min(area.width); - let x = area.x + (area.width - w) / 2; - let y = if top_third { - area.y + area.height / 5 - } else { - area.y + area.height.saturating_sub(want_h) / 2 - }; - let h = want_h.min(area.bottom().saturating_sub(y)); - Rect::new(x, y, w, h) -} - -const CMD_INFO: &[(&str, &str)] = &[ - ("search", "search YouTube Music"), - ("play", "play / resume · play N plays row N"), - ("pause", "pause playback"), - ("resume", "resume playback"), - ("toggle", "toggle pause"), - ("stop", "stop playback"), - ("next", "skip to the next track"), - ("prev", "go back to the previous track"), - ("quit", "exit drift"), - ("add", "queue the selected result · add N"), - ("addall", "queue all results"), - ("playall", "play all results"), - ("remove", "remove from the queue · remove N"), - ("clear", "empty the queue"), - ("results", "show the Results tab"), - ("queue", "show the Queue tab"), - ("playlists", "show the Playlists tab"), - ("volume", "set volume · 50 | +5 | -5"), - ("seek", "seek · 90 | 1:30 | +10 | -10"), - ("shuffle", "toggle shuffle"), - ("repeat", "repeat · off | all | one"), - ("save", "save the queue as a playlist"), - ("savealbum", "save album as a playlist · savealbum [name]"), - ("saveresults", "save results as a playlist · saveresults [name]"), - ("load", "load a playlist into the queue"), - ("append", "append a playlist to the queue"), - ("delpl", "delete a playlist"), - ("addto", "add the selected track to a playlist"), - ("import", "import a YouTube / YT Music playlist URL"), - ("artist", "songs by the selected artist"), - ("similar", "radio: songs similar to the selected one"), - ("album", "open the selected song's album, or :album to search"), - ("playlist", "search YouTube Music playlists"), - ("lyrics", "show or hide the lyrics panel"), - ("help", "show key bindings"), -]; - -fn cmd_info(name: &str) -> &'static str { - CMD_INFO - .iter() - .find(|(n, _)| *n == name) - .map_or("", |(_, d)| *d) -} - -fn draw_palette(f: &mut Frame, app: &App, area: Rect) { - const MAX_ROWS: usize = 8; - let search = app.cmd_search; - let matches = app.command_matches(); - let recent = if search && app.cmd_buf.is_empty() { - app.recent_searches(5) - } else { - Vec::new() - }; - let first_word = app.cmd_buf.split_whitespace().next().unwrap_or(""); - let typing_args = !search && app.cmd_buf.contains(' '); - - let list_rows = if search { - recent.len().max(1) - } else if typing_args { - 1 - } else { - matches.len().clamp(1, MAX_ROWS) - }; - let want_h = 2 + 1 + 1 + list_rows as u16; - let popup = popup_rect(area, 64, want_h, true); - if popup.width < 12 || popup.height < 3 { + if app.mode == Mode::Command { + let text = format!(":{}", app.cmd_buf); + let col = text + .chars() + .count() + .min(area.width.saturating_sub(1) as usize) as u16; + f.render_widget(Paragraph::new(text), area); + f.set_cursor_position((area.x + col, area.y)); return; } - let (title, prompt) = if search { - (" Search YouTube Music ", " / ") - } else { - (" Command ", " : ") - }; - let footer = if search { - " ⏎ search · ↑↓ history · Esc close " - } else { - " ⏎ run · Tab complete · Esc close " - }; - let block = Block::bordered() - .border_type(BorderType::Rounded) - .border_style(Style::new().fg(if search { app.accent } else { app.accent })) - .title(Span::styled( - title, - Style::new() - .fg(if search { app.accent } else { app.accent }) - .bold(), - )) - .title_bottom(Line::styled(footer, dim()).right_aligned()); - let inner = block.inner(popup); - f.render_widget(Clear, popup); - f.render_widget(block, popup); - - let [input, sep, list] = Layout::vertical([ - Constraint::Length(1), - Constraint::Length(1), - Constraint::Min(0), - ]) - .areas(inner); - - let prompt_w = prompt.chars().count(); - let avail = (input.width as usize).saturating_sub(prompt_w + 1); - let chars: Vec = app.cmd_buf.chars().collect(); - let shown: String = chars[chars.len().saturating_sub(avail)..].iter().collect(); - let shown_w = shown.chars().count(); - f.render_widget( - Paragraph::new(Line::from(vec![ - Span::styled( - prompt, - Style::new().fg(Color::Black).bg(if search { app.accent } else { app.accent }).bold(), - ), - Span::raw(" "), - Span::styled(shown, Style::new().fg(Color::White)), - ])), - input, - ); - if input.width as usize > prompt_w + 1 { - f.set_cursor_position((input.x + (prompt_w + 1 + shown_w) as u16, input.y)); - } - f.render_widget( - Paragraph::new("─".repeat(sep.width as usize)).style(dim()), - sep, - ); - - let mut lines: Vec = Vec::new(); - if search { - if recent.is_empty() { - lines.push(Line::styled(" type what you want to hear, then press Enter", dim())); - } else { - for r in recent { - lines.push(Line::from(vec![ - Span::styled(" ↺ ", dim()), - Span::styled(r.to_string(), Style::new().fg(Color::Gray)), - ])); - } - } - } else if typing_args { - let d = cmd_info(first_word); - lines.push(if d.is_empty() { - Line::styled(" ⏎ run", dim()) - } else { - Line::from(vec![ - Span::styled(format!(" {first_word} "), Style::new().fg(app.accent).bold()), - Span::styled(d, dim()), - ]) - }); - } else if matches.is_empty() { - lines.push(Line::styled(" no matching command", Style::new().fg(Color::Red))); - } else { - let sel = app.cycling().then_some(app.cmd_sel); - let start = sel.map_or(0, |s| (s + 1).saturating_sub(MAX_ROWS)); - let width = list.width as usize; - for (i, name) in matches.iter().enumerate().skip(start).take(MAX_ROWS) { - let text = format!(" {name:<10}{}", cmd_info(name)); - let text: String = text.chars().take(width).collect(); - lines.push(if Some(i) == sel { - let pad = width.saturating_sub(text.chars().count()); - Line::styled(format!("{}{}", cursor_text(&text), " ".repeat(pad)), selected_style(app.accent)) - } else { - Line::from(vec![ - Span::styled( - text.chars().take(11).collect::(), - Style::new().fg(app.accent), - ), - Span::styled(text.chars().skip(11).collect::(), dim()), - ]) - }); - } - } - f.render_widget(Paragraph::new(lines), list); -} - -fn draw_menu(f: &mut Frame, app: &App, area: Rect) { - let items = &app.menu_items; - if items.is_empty() { - return; - } - let label_w = items.iter().map(|i| i.label.chars().count()).max().unwrap_or(0); - let detail_w = items.iter().map(|i| i.detail.chars().count()).max().unwrap_or(0); - let body_w = 5 + label_w + if detail_w > 0 { detail_w + 3 } else { 0 } + 2; - let title_w = app.menu_title.chars().count() + 6; - let w = body_w.max(title_w).clamp(34, 60) as u16; - let popup = popup_rect(area, w, items.len() as u16 + 2, false); - if popup.width < 12 || popup.height < 3 { - return; - } - - let max_title = (popup.width as usize).saturating_sub(8); - let mut title: String = app.menu_title.chars().take(max_title).collect(); - if app.menu_title.chars().count() > max_title { - title.push('…'); - } - let title = if app.menu_sub { - format!(" {title} ") - } else { - format!(" ♪ {title} ") - }; - let footer = if app.menu_sub { - " ⏎ add · Esc back " - } else { - " ⏎ select · Esc close " - }; - let block = Block::bordered() - .border_type(BorderType::Rounded) - .border_style(Style::new().fg(app.accent)) - .title(Span::styled(title, Style::new().fg(app.accent).bold())) - .title_bottom(Line::styled(footer, dim()).right_aligned()); - let inner = block.inner(popup); - f.render_widget(Clear, popup); - f.render_widget(block, popup); - - let width = inner.width as usize; - let accent = app.accent; - let lines: Vec = items - .iter() - .enumerate() - .map(|(i, it)| { - let key = if it.key == NO_KEY { ' ' } else { it.key }; - let label = format!("{: { + let style = match s.level { + Level::Error => Style::new().fg(Color::Red).bold(), + Level::Info => Style::new().fg(Color::Green), }; - let mut spans = vec![ - Span::raw(" "), - Span::styled(key.to_string(), Style::new().fg(Color::Yellow).bold()), - Span::raw(" "), - Span::styled(label, label_style), - ]; - if !it.detail.is_empty() { - spans.push(Span::styled(format!(" {}", it.detail), dim())); - } - Line::from(spans) - }) - .collect(); - f.render_widget(Paragraph::new(lines), inner); -} + Line::styled(format!(" {}", s.text), style) + } + None => Line::default(), + }; + f.render_widget(Paragraph::new(line), area); -fn draw_confirm(f: &mut Frame, app: &App, area: Rect) { - let name = app.pending_delete.as_deref().unwrap_or_default(); - let msg = format!(" Delete playlist \u{201c}{name}\u{201d}? "); - let w = (msg.chars().count() as u16 + 4).max(34); - let popup = popup_rect(area, w, 5, false); - if popup.width < 12 || popup.height < 3 { - return; + let mut pending = String::new(); + if let Some(c) = app.count { + pending.push_str(&c.to_string()); + } + if let Some(p) = app.pending { + pending.push(p); + } + if !pending.is_empty() { + f.render_widget( + Paragraph::new(format!("{pending} ")) + .alignment(Alignment::Right) + .style(Style::new().fg(Color::Yellow)), + area, + ); } - let lines = vec![ - Line::styled(msg, Style::new().fg(Color::White).bold()), - Line::from(vec![ - Span::styled(" y ", Style::new().fg(Color::Black).bg(Color::Red).bold()), - Span::styled(" delete ", dim()), - Span::styled(" any other key ", Style::new().fg(Color::Gray)), - Span::styled(" cancel", dim()), - ]), - ]; - f.render_widget(Clear, popup); - f.render_widget( - Paragraph::new(lines).alignment(Alignment::Center).block( - Block::bordered() - .border_type(BorderType::Rounded) - .title(" Confirm ") - .border_style(Style::new().fg(Color::Yellow)), - ), - popup, - ); } const HELP: &[(&str, &str)] = &[ @@ -1072,7 +437,7 @@ const HELP: &[(&str, &str)] = &[ ("q Esc ?", "close"), ("NAVIGATION", "#"), ("j k / ↓ ↑", "move (counts work: 5j)"), - ("gg ge Home End", "top / bottom (5gg or 5ge → row 5)"), + ("gg G", "top / bottom (5G → row 5)"), ("Ctrl-d Ctrl-u", "half page down / up"), ("Tab S-Tab", "cycle Results / Queue / Playlists"), ("gr gq gp", "jump to Results / Queue / Playlists"), @@ -1086,26 +451,10 @@ const HELP: &[(&str, &str)] = &[ ("h l / ← →", "seek -5s / +5s (counts: 6l)"), ("+ -", "volume up / down"), ("s r", "toggle shuffle / cycle repeat"), - ("L F", "lyrics side panel / fullscreen lyrics (Esc closes)"), ( "ga or :artist", "songs by the artist of the selected song; a popup asks which one when there are several", ), - ( - "gb or :album", - "open the album of the selected song; :album searches albums", - ), - (":playlist ", "search YouTube Music playlists (pick one, then ⏎)"), - ( - "gs or :similar", - "radio: load songs similar to the selected one and start playing them", - ), - ("SONG MENU ( G )", "#"), - ("G", "menu for the highlighted song or playlist"), - ("j k ⏎", "move / choose (the yellow letter is a shortcut)"), - ("p n a g s b l", "play · next · queue · artist · similar · album · playlist"), - ("x i", "remove · song info"), - ("Esc q G", "close (Esc goes back from a sub-menu)"), ("QUEUE", "#"), ("a A P", "add selected / add all / play all results"), ( @@ -1132,15 +481,7 @@ const HELP: &[(&str, &str)] = &[ (":clear", "empty the queue"), ( ":save n :load n", - "save the queue as a playlist / load one (:save! n replaces an existing one)", - ), - ( - ":savealbum [n]", - "save album or current album results as a playlist", - ), - ( - ":saveresults [n]", - "save current results as a playlist", + "save the queue as a playlist / load one", ), ( ":append n :delpl n", @@ -1149,9 +490,6 @@ const HELP: &[(&str, &str)] = &[ (":addto n", "add the selected track to playlist n"), (":import URL", "load a YouTube / YT Music playlist"), (":artist", "same as ga"), - (":similar", "same as gs (also :radio)"), - (":album [q]", "same as gb, or search albums"), - (":lyrics", "same as L"), (":quit (or q, Ctrl-c)", ""), ]; @@ -1177,10 +515,10 @@ fn wrap_words(text: &str, width: usize) -> Vec { out } -fn help_lines(inner_w: usize, accent: Color) -> Vec> { +fn help_lines(inner_w: usize) -> Vec> { let key_w = HELP_KEY_W.min(inner_w / 2).max(1); let desc_w = inner_w.saturating_sub(key_w); - let key_style = Style::new().fg(accent); + let key_style = Style::new().fg(ACCENT); let mut lines: Vec> = Vec::new(); for (k, d) in HELP { @@ -1236,7 +574,7 @@ fn draw_artist_picker(f: &mut Frame, app: &App, area: Rect) { .map(|(i, name)| { let text = format!(" {} {name}", i + 1); if i == app.artist_sel { - Line::styled(cursor_text(&text), selected_style(app.accent)) + Line::styled(text, selected_style()) } else { Line::raw(text) } @@ -1254,61 +592,9 @@ fn draw_artist_picker(f: &mut Frame, app: &App, area: Rect) { ); } -fn draw_collection_picker(f: &mut Frame, app: &App, area: Rect) { - let n = app.picks.len(); - let w = area.width.min(78); - let h = (n as u16).saturating_add(3).min(area.height); - let visible = h.saturating_sub(3) as usize; - let start = if app.pick_sel < visible { - 0 - } else { - app.pick_sel + 1 - visible - }; - let popup = Rect { - x: area.x + (area.width - w) / 2, - y: area.y + (area.height - h) / 2, - width: w, - height: h, - }; - let inner = w.saturating_sub(2) as usize; - let mut lines: Vec = app - .picks - .iter() - .enumerate() - .skip(start) - .take(visible) - .map(|(i, c)| { - let mut text = format!(" {:>2} {}", i + 1, c.title); - if !c.subtitle.is_empty() { - text.push_str(&format!(" · {}", c.subtitle)); - } - let text: String = text.chars().take(inner).collect(); - if i == app.pick_sel { - let pad = inner.saturating_sub(text.chars().count()); - Line::styled(format!("{}{}", cursor_text(&text), " ".repeat(pad)), selected_style(app.accent)) - } else { - Line::raw(text) - } - }) - .collect(); - lines.push(Line::styled( - " Enter select · s save playlist · j k move · Esc cancel", - dim(), - )); - f.render_widget(Clear, popup); - f.render_widget( - Paragraph::new(lines).block( - Block::bordered() - .title(format!(" {} ", app.pick_title)) - .border_style(Style::new().fg(Color::Yellow)), - ), - popup, - ); -} - fn draw_help(f: &mut Frame, app: &mut App, area: Rect) { let w = area.width.min(80); - let lines = help_lines(w.saturating_sub(2) as usize, app.accent); + let lines = help_lines(w.saturating_sub(2) as usize); let h = (lines.len() as u16).saturating_add(2).min(area.height); let popup = Rect { x: area.x + (area.width - w) / 2, @@ -1362,8 +648,8 @@ mod tests { fn open_playlist_renders() { let dir = std::env::temp_dir().join(format!("drift-ui-test-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); - std::env::set_var("DRIFT_CONFIG_DIR", &dir); - std::env::set_var("DRIFT_STATE_DIR", &dir); + std::env::set_var("XDG_CONFIG_HOME", &dir); + std::env::set_var("XDG_STATE_HOME", &dir); let (tx, _rx) = mpsc::channel(); let mut app = App::new(tx); @@ -1371,7 +657,6 @@ mod tests { id: id.into(), title: title.into(), artist: "Someone".into(), - artists: Vec::new(), album: None, duration: Some(90), }; @@ -1406,33 +691,6 @@ mod tests { let mut tiny = Terminal::new(TestBackend::new(20, 6)).unwrap(); tiny.draw(|f| draw(f, &mut app)).unwrap(); - key(&mut app, KeyCode::Char(':')); - term.draw(|f| draw(f, &mut app)).unwrap(); - assert!(text(&term).contains("Command")); - key(&mut app, KeyCode::Tab); - term.draw(|f| draw(f, &mut app)).unwrap(); - tiny.draw(|f| draw(f, &mut app)).unwrap(); - key(&mut app, KeyCode::Esc); - key(&mut app, KeyCode::Char('/')); - term.draw(|f| draw(f, &mut app)).unwrap(); - assert!(text(&term).contains("Search YouTube Music")); - tiny.draw(|f| draw(f, &mut app)).unwrap(); - key(&mut app, KeyCode::Esc); - - key(&mut app, KeyCode::Char('G')); - term.draw(|f| draw(f, &mut app)).unwrap(); - assert!(text(&term).contains("Play now")); - tiny.draw(|f| draw(f, &mut app)).unwrap(); - key(&mut app, KeyCode::Char('l')); - term.draw(|f| draw(f, &mut app)).unwrap(); - assert!(text(&term).contains("New playlist")); - key(&mut app, KeyCode::Esc); - term.draw(|f| draw(f, &mut app)).unwrap(); - assert!(text(&term).contains("Play now")); - key(&mut app, KeyCode::Esc); - term.draw(|f| draw(f, &mut app)).unwrap(); - assert!(!text(&term).contains("Play now")); - key(&mut app, KeyCode::Char('?')); term.draw(|f| draw(f, &mut app)).unwrap(); let s = text(&term); diff --git a/src/ytmusic.rs b/src/ytmusic.rs index f356c95..c6ee46f 100644 --- a/src/ytmusic.rs +++ b/src/ytmusic.rs @@ -1,52 +1,21 @@ use std::process::{Command, Stdio}; -use std::sync::OnceLock; use std::time::{Duration, SystemTime, UNIX_EPOCH}; use anyhow::{anyhow, bail, Context, Result}; use serde_json::{json, Value}; -use crate::model::{is_valid_id, parse_clock, Collection, Track}; +use crate::model::{is_valid_id, parse_clock, Track}; const ENDPOINT: &str = "https://music.youtube.com/youtubei/v1/search?prettyPrint=false"; -const BROWSE_ENDPOINT: &str = "https://music.youtube.com/youtubei/v1/browse?prettyPrint=false"; -const NEXT_ENDPOINT: &str = "https://music.youtube.com/youtubei/v1/next?prettyPrint=false"; -const ALBUMS_FILTER: &str = "EgWKAQIYAWoMEA4QChADEAQQCRAF"; -const PLAYLISTS_FILTER: &str = "Eg-KAQwIABAAGAAgACgBMABqChAEEAMQCRAFEAo%3D"; -const RADIO_TARGET: usize = 50; -const RADIO_PAGES: usize = 4; const SONGS_FILTER: &str = "EgWKAQIIAWoMEA4QChADEAQQCRAF"; -const ARTISTS_FILTER: &str = "EgWKAQIgAWoMEA4QChADEAQQCRAF"; const UA: &str = "Mozilla/5.0 (X11; Linux x86_64; rv:128.0) Gecko/20100101 Firefox/128.0"; const FALLBACK_COUNT: usize = 25; -#[derive(Clone, Copy, PartialEq, Eq, Debug)] -pub enum Kind { - Album, - Playlist, -} - -pub struct Page { - pub tracks: Vec, - pub next: Option, -} - -impl From> for Page { - fn from(tracks: Vec) -> Self { - Page { tracks, next: None } - } -} - pub fn search(query: &str) -> Result, String> { - search_page(query).map(|p| p.tracks) -} - -pub fn search_page(query: &str) -> Result { match search_innertube(query) { - Ok(page) if !page.tracks.is_empty() => Ok(page), - Ok(_) => ytdlp_search(query) - .map(Page::from) - .map_err(|e| format!("{e:#}")), - Err(primary) => ytdlp_search(query).map(Page::from).map_err(|fallback| { + Ok(tracks) if !tracks.is_empty() => Ok(tracks), + Ok(_) => ytdlp_search(query).map_err(|e| format!("{e:#}")), + Err(primary) => ytdlp_search(query).map_err(|fallback| { format!( "YouTube Music search failed ({primary:#}); yt-dlp fallback failed ({fallback:#})" ) @@ -54,98 +23,6 @@ pub fn search_page(query: &str) -> Result { } } -pub fn search_more(token: &str) -> Result { - let url = format!("{ENDPOINT}&ctoken={token}&continuation={token}"); - post(&url, json!({})) - .map(|v| page_from(&v)) - .map_err(|e| format!("{e:#}")) -} - -pub fn search_collections(query: &str, kind: Kind) -> Result, String> { - let params = match kind { - Kind::Album => ALBUMS_FILTER, - Kind::Playlist => PLAYLISTS_FILTER, - }; - post(ENDPOINT, json!({ "query": query, "params": params })) - .map(|v| parse_collections(&v, kind)) - .map_err(|e| format!("{e:#}")) -} - -/* browse_id: MPREb_... for an album, VL... for a playlist */ -pub fn browse_tracks(browse_id: &str, album: &str, artist: &str) -> Result, String> { - let playlist = browse_id.starts_with("VL"); - let v = post(BROWSE_ENDPOINT, json!({ "browseId": browse_id })).map_err(|e| format!("{e:#}"))?; - let mut tracks = parse_browse(&v, playlist); - let mut next = if playlist { find_continuation(&v) } else { None }; - let mut pages = 0; - while let Some(tok) = next.take() { - if pages >= 5 || tracks.len() >= 300 { - break; - } - pages += 1; - let url = format!("{BROWSE_ENDPOINT}&ctoken={tok}&continuation={tok}"); - let Ok(v) = post(&url, json!({})) else { break }; - let more = parse_browse(&v, true); - if more.is_empty() { - break; - } - let known: std::collections::HashSet = - tracks.iter().map(|t| t.id.clone()).collect(); - tracks.extend(more.into_iter().filter(|t| !known.contains(&t.id))); - next = find_continuation(&v); - } - if tracks.is_empty() { - return Err("nothing playable found".into()); - } - if !playlist { - for t in &mut tracks { - if !album.is_empty() && t.album.is_none() { - t.album = Some(album.to_string()); - } - if !artist.is_empty() && t.artist == "Unknown artist" { - t.artist = artist.to_string(); - } - } - } - Ok(tracks) -} - -pub fn radio(seed: &str) -> Result, String> { - fetch_radio(seed).map_err(|e| format!("{e:#}")) -} - -fn fetch_radio(seed: &str) -> Result> { - let body = json!({ - "videoId": seed, - "playlistId": format!("RDAMVM{seed}"), - "params": "wAEB", - "isAudioOnly": true, - "enablePersistentPlaylistPanel": true, - "tunerSettingValue": "AUTOMIX_SETTING_NORMAL", - }); - let mut url = NEXT_ENDPOINT.to_string(); - let mut tracks: Vec = Vec::new(); - for _ in 0..RADIO_PAGES { - let v = post(&url, body.clone())?; - let before = tracks.len(); - for t in parse_radio_response(&v) { - if !tracks.iter().any(|x| x.id == t.id) { - tracks.push(t); - } - } - match find_continuation(&v) { - Some(tok) if tracks.len() < RADIO_TARGET && tracks.len() > before => { - url = format!("{NEXT_ENDPOINT}&ctoken={tok}&continuation={tok}"); - } - _ => break, - } - } - if tracks.is_empty() { - bail!("no similar songs found"); - } - Ok(tracks) -} - pub fn import(url: &str) -> Result, String> { if !(url.starts_with("http://") || url.starts_with("https://")) { return Err("import expects a full http(s) URL".into()); @@ -176,100 +53,32 @@ fn civil_from_days(z: i64) -> (i64, i64, i64) { (if m <= 2 { y + 1 } else { y }, m, d) } -fn search_innertube(query: &str) -> Result { - let v = post(ENDPOINT, json!({ "query": query, "params": SONGS_FILTER }))?; - Ok(page_from(&v)) -} - -fn page_from(v: &Value) -> Page { - Page { - tracks: parse_search_response(v), - next: find_continuation(v), - } -} - -fn find_continuation(v: &Value) -> Option { - match v { - Value::Object(map) => { - let token = map - .get("nextContinuationData") - .or_else(|| map.get("nextRadioContinuationData")) - .and_then(|c| c.get("continuation")) - .or_else(|| { - map.get("continuationCommand") - .and_then(|c| c.get("token")) - }) - .and_then(Value::as_str); - match token { - Some(t) => Some(t.to_string()), - None => map.values().find_map(find_continuation), - } - } - Value::Array(arr) => arr.iter().find_map(find_continuation), - _ => None, - } -} - -/* one shared agent so paging and radio reuse the same connection */ -fn agent() -> &'static ureq::Agent { - static AGENT: OnceLock = OnceLock::new(); - AGENT.get_or_init(|| { - ureq::AgentBuilder::new() - .timeout(Duration::from_secs(12)) - .user_agent(UA) - .build() - }) -} - -fn post(url: &str, mut body: Value) -> Result { - body["context"] = json!({ "client": { - "clientName": "WEB_REMIX", - "clientVersion": client_version(), - "hl": "en", - "gl": "US", - }}); - let resp = agent() - .post(url) +fn search_innertube(query: &str) -> Result> { + let agent = ureq::AgentBuilder::new() + .timeout(Duration::from_secs(12)) + .user_agent(UA) + .build(); + let body = json!({ + "context": { "client": { + "clientName": "WEB_REMIX", + "clientVersion": client_version(), + "hl": "en", + "gl": "US", + }}, + "query": query, + "params": SONGS_FILTER, + }); + let resp = agent + .post(ENDPOINT) .set("Origin", "https://music.youtube.com") .set("Referer", "https://music.youtube.com/") .set("Cookie", "SOCS=CAI") .send_json(body) .map_err(|e| anyhow!("request failed: {e}"))?; - resp.into_json::() - .context("invalid JSON from YouTube Music") -} - -pub fn artist_top_songs(name: &str) -> Result, String> { - let fetch = || -> Result> { - let found = post(ENDPOINT, json!({ "query": name, "params": ARTISTS_FILTER }))?; - let Some(id) = pick_artist_id(&found, name) else { - return Ok(Vec::new()); - }; - let page = post(BROWSE_ENDPOINT, json!({ "browseId": id }))?; - Ok(parse_search_response(&page)) - }; - fetch().map_err(|e| format!("{e:#}")) -} - -fn pick_artist_id(v: &Value, name: &str) -> Option { - let mut items = Vec::new(); - collect_items(v, &mut items); - let artists: Vec<(String, &str)> = items - .into_iter() - .filter_map(|item| { - let id = item - .pointer("/navigationEndpoint/browseEndpoint/browseId")? - .as_str()?; - let title = column_segments(item, 0).into_iter().next()?; - id.starts_with("UC").then_some((title, id)) - }) - .collect(); - let wanted = name.to_lowercase(); - artists - .iter() - .find(|(title, _)| title.to_lowercase() == wanted) - .or(artists.first()) - .map(|(_, id)| id.to_string()) + let v: Value = resp + .into_json() + .context("invalid JSON from YouTube Music")?; + Ok(parse_search_response(&v)) } fn collect_items<'a>(v: &'a Value, out: &mut Vec<&'a Value>) { @@ -301,45 +110,12 @@ fn find_str<'a>(v: &'a Value, key: &str) -> Option<&'a str> { } } -fn column_runs(item: &Value, col: usize) -> Option<&Vec> { - item.get("flexColumns") +fn column_segments(item: &Value, col: usize) -> Vec { + let runs = item + .get("flexColumns") .and_then(|c| c.get(col)) .and_then(|c| c.pointer("/musicResponsiveListItemFlexColumnRenderer/text/runs")) - .and_then(Value::as_array) -} - -fn column_segments(item: &Value, col: usize) -> Vec { - run_segments(column_runs(item, col)) -} - -/* Artists are the runs that link to an artist/channel page ("UC..." browse - * ids); the "&" and "," between them are plain runs. Using the source's own - * boundaries means names that contain a separator stay in one piece. Names - * must also appear in `credit`, so a stray linked run can't leak in. Empty - * when the response carries no links (the caller falls back to guessing). */ -fn artist_names_from_runs(runs: Option<&Vec>, credit: &str) -> Vec { - let mut names: Vec = Vec::new(); - for run in runs.into_iter().flatten() { - let id = run - .pointer("/navigationEndpoint/browseEndpoint/browseId") - .and_then(Value::as_str); - let text = run.get("text").and_then(Value::as_str); - let (Some(id), Some(text)) = (id, text) else { - continue; - }; - let text = text.trim(); - if id.starts_with("UC") - && !text.is_empty() - && credit.contains(text) - && !names.iter().any(|n| n == text) - { - names.push(text.to_string()); - } - } - names -} - -fn run_segments(runs: Option<&Vec>) -> Vec { + .and_then(Value::as_array); let mut segments = Vec::new(); let mut cur = String::new(); for run in runs.into_iter().flatten() { @@ -364,13 +140,6 @@ fn looks_like_duration(s: &str) -> bool { s.contains(':') && s.chars().all(|c| c.is_ascii_digit() || c == ':') } -fn fixed_duration(item: &Value) -> Option { - let s = item - .pointer("/fixedColumns/0/musicResponsiveListItemFixedColumnRenderer/text/runs/0/text")? - .as_str()?; - parse_clock(s).map(|s| s as u32) -} - fn parse_item(item: &Value) -> Option { let id = find_str(item, "videoId")?; if !is_valid_id(id) { @@ -393,12 +162,10 @@ fn parse_item(item: &Value) -> Option { } _ => None, }; - let duration = duration.or_else(|| fixed_duration(item)); let artist = meta .first() .cloned() .unwrap_or_else(|| "Unknown artist".into()); - let artists = artist_names_from_runs(column_runs(item, 1), &artist); let album = meta .get(1) .filter(|a| !a.contains(" views") && !a.contains(" plays")) @@ -408,69 +175,11 @@ fn parse_item(item: &Value) -> Option { id: id.to_string(), title, artist, - artists, album, duration, }) } -fn parse_browse(v: &Value, playlist: bool) -> Vec { - let mut items = Vec::new(); - collect_items(v, &mut items); - let mut seen = std::collections::HashSet::new(); - items - .into_iter() - .filter_map(|item| { - let mut t = parse_item(item)?; - if playlist && t.album.is_none() { - t.album = column_segments(item, 2).into_iter().next(); - } - Some(t) - }) - .filter(|t| seen.insert(t.id.clone())) - .collect() -} - -pub fn parse_collections(v: &Value, kind: Kind) -> Vec { - let mut items = Vec::new(); - collect_items(v, &mut items); - let mut seen = std::collections::HashSet::new(); - items - .into_iter() - .filter_map(|item| { - let id = item - .pointer("/navigationEndpoint/browseEndpoint/browseId")? - .as_str()?; - let ok = match kind { - Kind::Album => id.starts_with("MPRE"), - Kind::Playlist => id.starts_with("VL"), - }; - if !ok { - return None; - } - let title = column_segments(item, 0).into_iter().next()?; - let mut meta = column_segments(item, 1); - if matches!( - meta.first().map(String::as_str), - Some("Album" | "EP" | "Single" | "Playlist") - ) { - meta.remove(0); - } - let artist = match kind { - Kind::Album => meta.first().cloned().unwrap_or_default(), - Kind::Playlist => String::new(), - }; - Some(Collection { - browse_id: id.to_string(), - title, - subtitle: meta.join(" · "), - artist, - }) - }) - .filter(|c| seen.insert(c.browse_id.clone())) - .collect() -} - pub fn parse_search_response(v: &Value) -> Vec { let mut items = Vec::new(); collect_items(v, &mut items); @@ -482,86 +191,13 @@ pub fn parse_search_response(v: &Value) -> Vec { .collect() } -fn collect_panel<'a>(v: &'a Value, out: &mut Vec<&'a Value>) { - match v { - Value::Object(map) => { - if let Some(w) = map.get("playlistPanelVideoWrapperRenderer") { - out.extend(w.pointer("/primaryRenderer/playlistPanelVideoRenderer")); - } else if let Some(item) = map.get("playlistPanelVideoRenderer") { - out.push(item); - } else { - map.values().for_each(|c| collect_panel(c, out)); - } - } - Value::Array(arr) => arr.iter().for_each(|c| collect_panel(c, out)), - _ => {} - } -} - -fn parse_panel_item(item: &Value) -> Option { - let id = item.get("videoId")?.as_str()?; - if !is_valid_id(id) { - return None; - } - let title: String = item - .pointer("/title/runs")? - .as_array()? - .iter() - .filter_map(|r| r.get("text")?.as_str()) - .collect(); - let title = title.trim().to_string(); - if title.is_empty() { - return None; - } - let byline = item.pointer("/longBylineText/runs").and_then(Value::as_array); - let meta = run_segments(byline); - let artist = meta - .first() - .cloned() - .unwrap_or_else(|| "Unknown artist".into()); - let artists = artist_names_from_runs(byline, &artist); - let album = meta - .get(1) - .filter(|a| { - !a.contains(" views") - && !a.contains(" plays") - && !(a.len() == 4 && a.bytes().all(|b| b.is_ascii_digit())) - }) - .cloned(); - let duration = item - .pointer("/lengthText/runs/0/text") - .or_else(|| item.pointer("/lengthText/simpleText")) - .and_then(Value::as_str) - .and_then(parse_clock) - .map(|s| s as u32); - Some(Track { - id: id.to_string(), - title, - artist, - artists, - album, - duration, - }) -} - -pub fn parse_radio_response(v: &Value) -> Vec { - let mut items = Vec::new(); - collect_panel(v, &mut items); - let mut seen = std::collections::HashSet::new(); - items - .into_iter() - .filter_map(parse_panel_item) - .filter(|t| seen.insert(t.id.clone())) - .collect() -} - fn ytdlp_search(query: &str) -> Result> { ytdlp_entries(&format!("ytsearch{FALLBACK_COUNT}:{query}")) } fn ytdlp_entries(target: &str) -> Result> { - let mut cmd = Command::new("yt-dlp"); - cmd.args([ + let out = Command::new("yt-dlp") + .args([ "--flat-playlist", "--dump-json", "--no-warnings", @@ -570,8 +206,7 @@ fn ytdlp_entries(target: &str) -> Result> { "15", ]) .arg(target) - .stdin(Stdio::null()); - let out = crate::sys::no_window(&mut cmd) + .stdin(Stdio::null()) .output() .map_err(|e| anyhow!("could not run yt-dlp ({e}); is it installed and on PATH?"))?; @@ -604,24 +239,11 @@ pub fn parse_ytdlp_lines(text: &str) -> Vec { .unwrap_or("Unknown artist") .trim_end_matches(" - Topic") .to_string(); - /* newer yt-dlp lists each credited artist separately */ - let artists: Vec = v - .get("artists") - .and_then(Value::as_array) - .map(|a| { - a.iter() - .filter_map(Value::as_str) - .map(|n| n.trim().to_string()) - .filter(|n| !n.is_empty()) - .collect() - }) - .unwrap_or_default(); let duration = v.get("duration").and_then(Value::as_f64).map(|d| d as u32); Some(Track { id: id.to_string(), title, artist, - artists, album: None, duration, }) @@ -680,36 +302,6 @@ mod tests { assert_eq!(t.duration, Some(241)); } - #[test] - fn keeps_linked_artist_runs_whole() { - let link = |text: &str, id: &str| { - json!({"text": text, "navigationEndpoint": {"browseEndpoint": {"browseId": id}}}) - }; - let resp = json!([{"musicResponsiveListItemRenderer": { - "overlay": {"watchEndpoint": {"videoId": "abcdefghijk"}}, - "flexColumns": [ - {"musicResponsiveListItemFlexColumnRenderer": run("Song")}, - {"musicResponsiveListItemFlexColumnRenderer": {"text": {"runs": [ - link("Tyler, The Creator", "UCaaaaaaaa"), {"text": " & "}, - link("Kali Uchis", "UCbbbbbbbb"), {"text": " • "}, - link("Album", "MPREb_cccc"), {"text": " • "}, {"text": "3:21"} - ]}}} - ] - }}]); - let t = &parse_search_response(&resp)[0]; - assert_eq!(t.artist, "Tyler, The Creator & Kali Uchis"); - assert_eq!(t.artists, ["Tyler, The Creator", "Kali Uchis"]); - assert_eq!(t.album.as_deref(), Some("Album")); - } - - #[test] - fn reads_ytdlp_artists_list() { - let line = r#"{"id":"abcdefghijk","title":"T","artist":"A, B","artists":["A","B"],"duration":10}"#; - let t = parse_ytdlp_lines(line); - assert_eq!(t[0].artists, ["A", "B"]); - assert_eq!(t[0].artist, "A, B"); - } - #[test] fn skips_items_without_video_id() { let resp = json!([{"musicResponsiveListItemRenderer": { @@ -731,106 +323,6 @@ not json assert_eq!(t[0].duration, Some(213)); } - #[test] - fn picks_artist_by_name() { - let artist = |name: &str, id: &str| { - json!({"musicResponsiveListItemRenderer": { - "navigationEndpoint": {"browseEndpoint": {"browseId": id}}, - "flexColumns": [{"musicResponsiveListItemFlexColumnRenderer": run(name)}] - }}) - }; - let resp = json!({"contents": [ - artist("Daft Punk Tribute", "UCaaa"), - artist("Daft Punk", "UCbbb"), - ]}); - assert_eq!(pick_artist_id(&resp, "daft punk").as_deref(), Some("UCbbb")); - assert_eq!(pick_artist_id(&resp, "Other").as_deref(), Some("UCaaa")); - assert_eq!(pick_artist_id(&json!([]), "x"), None); - } - - #[test] - fn finds_continuation_tokens() { - let old = json!({"musicShelfRenderer": {"contents": [], "continuations": [ - {"nextContinuationData": {"continuation": "abc%3D", "clickTrackingParams": "x"}} - ]}}); - assert_eq!(find_continuation(&old).as_deref(), Some("abc%3D")); - let new = json!([{"continuationItemRenderer": {"continuationEndpoint": { - "continuationCommand": {"token": "def", "request": "CONTINUATION_REQUEST_TYPE_SEARCH"} - }}}]); - assert_eq!(find_continuation(&new).as_deref(), Some("def")); - assert_eq!(find_continuation(&json!({"a": [1, 2]})), None); - } - - #[test] - fn parses_radio_panel() { - let item = |id: &str, title: &str, by: Value, len: &str| { - json!({"playlistPanelVideoRenderer": { - "videoId": id, - "title": {"runs": [{"text": title}]}, - "longBylineText": {"runs": by}, - "lengthText": {"runs": [{"text": len}]}, - }}) - }; - let by = json!([{"text": "A"}, {"text": " & "}, {"text": "B"}, - {"text": " • "}, {"text": "Album"}, {"text": " • "}, {"text": "2019"}]); - let resp = json!({"contents": [ - item("abcdefghijk", "One", by, "3:21"), - {"playlistPanelVideoWrapperRenderer": { - "primaryRenderer": item("bcdefghijkl", "Two", json!([{"text": "C"}]), "1:05"), - "counterpart": [item("zzzzzzzzzzz", "Video", json!([{"text": "C"}]), "1:05")], - }}, - item("abcdefghijk", "dup", json!([{"text": "A"}]), "3:21"), - ]}); - let t = parse_radio_response(&resp); - assert_eq!(t.len(), 2); - assert_eq!(t[0].artist, "A & B"); - assert_eq!(t[0].album.as_deref(), Some("Album")); - assert_eq!(t[0].duration, Some(201)); - assert_eq!(t[1].id, "bcdefghijkl"); - assert_eq!(t[1].album, None); - } - - #[test] - fn parses_album_and_playlist_results() { - let row = |id: &str, title: &str, runs: Value| { - json!({"musicResponsiveListItemRenderer": { - "navigationEndpoint": {"browseEndpoint": {"browseId": id}}, - "flexColumns": [ - {"musicResponsiveListItemFlexColumnRenderer": run(title)}, - {"musicResponsiveListItemFlexColumnRenderer": {"text": {"runs": runs}}}, - ] - }}) - }; - let resp = json!([ - row("MPREb_abc", "Discovery", json!([{"text": "Album"}, {"text": " • "}, - {"text": "Daft Punk"}, {"text": " • "}, {"text": "2001"}])), - row("VLPL123", "Mix", json!([{"text": "Playlist"}, {"text": " • "}, {"text": "Someone"}])), - row("UCartist", "Artist", json!([{"text": "Artist"}])), - ]); - let a = parse_collections(&resp, Kind::Album); - assert_eq!(a.len(), 1); - assert_eq!(a[0].title, "Discovery"); - assert_eq!(a[0].artist, "Daft Punk"); - assert_eq!(a[0].subtitle, "Daft Punk · 2001"); - let p = parse_collections(&resp, Kind::Playlist); - assert_eq!(p.len(), 1); - assert_eq!(p[0].browse_id, "VLPL123"); - assert_eq!(p[0].artist, ""); - } - - #[test] - fn parses_browse_tracks() { - let resp = json!({"contents": [{"musicResponsiveListItemRenderer": { - "playlistItemData": {"videoId": "abcdefghijk"}, - "flexColumns": [{"musicResponsiveListItemFlexColumnRenderer": run("One More Time")}], - "fixedColumns": [{"musicResponsiveListItemFixedColumnRenderer": run("5:20")}], - }}]}); - let t = parse_browse(&resp, false); - assert_eq!(t.len(), 1); - assert_eq!(t[0].duration, Some(320)); - assert_eq!(t[0].artist, "Unknown artist"); - } - #[test] fn date_math() { assert_eq!(civil_from_days(0), (1970, 1, 1));