Repository: Rigellute/spotify-tui Branch: master Commit: c4dcf6b9fd83 Files: 60 Total size: 464.3 KB Directory structure: gitextract_9oul2w9i/ ├── .all-contributorsrc ├── .github/ │ ├── FUNDING.yml │ ├── dependabot.yml │ └── workflows/ │ ├── cd.yml │ └── ci.yml ├── .gitignore ├── CHANGELOG.md ├── Cargo.toml ├── LICENSE ├── README.md ├── how_to_release.md ├── rustfmt.toml ├── snap/ │ ├── gui/ │ │ └── spt.desktop │ └── snapcraft.yaml └── src/ ├── app.rs ├── banner.rs ├── cli/ │ ├── clap.rs │ ├── cli_app.rs │ ├── handle.rs │ ├── mod.rs │ └── util.rs ├── config.rs ├── event/ │ ├── events.rs │ ├── key.rs │ └── mod.rs ├── handlers/ │ ├── album_list.rs │ ├── album_tracks.rs │ ├── analysis.rs │ ├── artist.rs │ ├── artist_albums.rs │ ├── artists.rs │ ├── basic_view.rs │ ├── common_key_events.rs │ ├── dialog.rs │ ├── empty.rs │ ├── episode_table.rs │ ├── error_screen.rs │ ├── help_menu.rs │ ├── home.rs │ ├── input.rs │ ├── library.rs │ ├── made_for_you.rs │ ├── mod.rs │ ├── playbar.rs │ ├── playlist.rs │ ├── podcasts.rs │ ├── recently_played.rs │ ├── search_results.rs │ ├── select_device.rs │ └── track_table.rs ├── main.rs ├── network.rs ├── redirect_uri.html ├── redirect_uri.rs ├── ui/ │ ├── audio_analysis.rs │ ├── help.rs │ ├── mod.rs │ └── util.rs ├── user_config.rs └── util.rs ================================================ FILE CONTENTS ================================================ ================================================ FILE: .all-contributorsrc ================================================ { "files": [ "README.md" ], "imageSize": 100, "commit": false, "contributors": [ { "login": "Rigellute", "name": "Alexander Keliris", "avatar_url": "https://avatars2.githubusercontent.com/u/12150276?v=4", "profile": "https://keliris.dev/", "contributions": [ "code", "doc", "design", "blog", "ideas", "infra", "maintenance", "platform", "review" ] }, { "login": "mikepombal", "name": "Mickael Marques", "avatar_url": "https://avatars3.githubusercontent.com/u/6864231?v=4", "profile": "https://github.com/mikepombal", "contributions": [ "financial" ] }, { "login": "HakierGrzonzo", "name": "Grzegorz Koperwas", "avatar_url": "https://avatars0.githubusercontent.com/u/36668331?v=4", "profile": "https://github.com/HakierGrzonzo", "contributions": [ "doc" ] }, { "login": "amgassert", "name": "Austin Gassert", "avatar_url": "https://avatars2.githubusercontent.com/u/22896005?v=4", "profile": "https://github.com/amgassert", "contributions": [ "code" ] }, { "login": "calenrobinette", "name": "Calen Robinette", "avatar_url": "https://avatars2.githubusercontent.com/u/30757528?v=4", "profile": "https://robinette.dev", "contributions": [ "code" ] }, { "login": "MCOfficer", "name": "M*C*O", "avatar_url": "https://avatars0.githubusercontent.com/u/22377202?v=4", "profile": "https://mcofficer.me", "contributions": [ "infra" ] }, { "login": "eminence", "name": "Andrew Chin", "avatar_url": "https://avatars0.githubusercontent.com/u/402454?v=4", "profile": "https://github.com/eminence", "contributions": [ "code" ] }, { "login": "Monkeyanator", "name": "Sam Naser", "avatar_url": "https://avatars0.githubusercontent.com/u/4377348?v=4", "profile": "https://www.samnaser.com/", "contributions": [ "code" ] }, { "login": "radogost", "name": "Micha", "avatar_url": "https://avatars0.githubusercontent.com/u/15713820?v=4", "profile": "https://github.com/radogost", "contributions": [ "code" ] }, { "login": "neriglissar", "name": "neriglissar", "avatar_url": "https://avatars2.githubusercontent.com/u/53038761?v=4", "profile": "https://github.com/neriglissar", "contributions": [ "code" ] }, { "login": "TimonPost", "name": "Timon", "avatar_url": "https://avatars3.githubusercontent.com/u/19969910?v=4", "profile": "https://github.com/TimonPost", "contributions": [ "code" ] }, { "login": "echoSayonara", "name": "echoSayonara", "avatar_url": "https://avatars2.githubusercontent.com/u/54503126?v=4", "profile": "https://github.com/echoSayonara", "contributions": [ "code" ] }, { "login": "D-Nice", "name": "D-Nice", "avatar_url": "https://avatars1.githubusercontent.com/u/2888248?v=4", "profile": "https://github.com/D-Nice", "contributions": [ "doc", "infra" ] }, { "login": "gpawlik", "name": "Grzegorz Pawlik", "avatar_url": "https://avatars3.githubusercontent.com/u/6296883?v=4", "profile": "http://gpawlik.com", "contributions": [ "code" ] }, { "login": "LennyPenny", "name": "Lennart Bernhardt", "avatar_url": "https://avatars1.githubusercontent.com/u/4027243?v=4", "profile": "http://lenny.ninja", "contributions": [ "code" ] }, { "login": "BlackYoup", "name": "Arnaud Lefebvre", "avatar_url": "https://avatars3.githubusercontent.com/u/6098160?v=4", "profile": "https://github.com/BlackYoup", "contributions": [ "code" ] }, { "login": "tem1029", "name": "tem1029", "avatar_url": "https://avatars3.githubusercontent.com/u/57712713?v=4", "profile": "https://github.com/tem1029", "contributions": [ "code" ] }, { "login": "Peterkmoss", "name": "Peter K. Moss", "avatar_url": "https://avatars2.githubusercontent.com/u/12544579?v=4", "profile": "http://peter.moss.dk", "contributions": [ "code" ] }, { "login": "RadicalZephyr", "name": "Geoff Shannon", "avatar_url": "https://avatars1.githubusercontent.com/u/113102?v=4", "profile": "http://www.zephyrizing.net/", "contributions": [ "code" ] }, { "login": "zacklukem", "name": "Zachary Mayhew", "avatar_url": "https://avatars0.githubusercontent.com/u/8787486?v=4", "profile": "http://zacklukem.info", "contributions": [ "code" ] }, { "login": "jfaltis", "name": "jfaltis", "avatar_url": "https://avatars2.githubusercontent.com/u/45465572?v=4", "profile": "http://jfaltis.de", "contributions": [ "code" ] }, { "login": "Bios-Marcel", "name": "Marcel Schramm", "avatar_url": "https://avatars3.githubusercontent.com/u/19377618?v=4", "profile": "https://marcelschr.me", "contributions": [ "doc" ] }, { "login": "fangyi-zhou", "name": "Fangyi Zhou", "avatar_url": "https://avatars3.githubusercontent.com/u/7815439?v=4", "profile": "https://github.com/fangyi-zhou", "contributions": [ "code" ] }, { "login": "synth-ruiner", "name": "Max", "avatar_url": "https://avatars1.githubusercontent.com/u/8642013?v=4", "profile": "https://github.com/synth-ruiner", "contributions": [ "code" ] }, { "login": "svenvNL", "name": "Sven van der Vlist", "avatar_url": "https://avatars1.githubusercontent.com/u/13982006?v=4", "profile": "https://github.com/svenvNL", "contributions": [ "code" ] }, { "login": "jacobchrismarsh", "name": "jacobchrismarsh", "avatar_url": "https://avatars2.githubusercontent.com/u/15932179?v=4", "profile": "https://github.com/jacobchrismarsh", "contributions": [ "code" ] }, { "login": "TheWalkingLeek", "name": "Nils Rauch", "avatar_url": "https://avatars2.githubusercontent.com/u/36076343?v=4", "profile": "https://github.com/TheWalkingLeek", "contributions": [ "code" ] }, { "login": "sputnick1124", "name": "Nick Stockton", "avatar_url": "https://avatars1.githubusercontent.com/u/8843309?v=4", "profile": "https://github.com/sputnick1124", "contributions": [ "code", "bug", "maintenance", "question", "doc" ] }, { "login": "stuarth", "name": "Stuart Hinson", "avatar_url": "https://avatars3.githubusercontent.com/u/7055?v=4", "profile": "http://stuarth.github.io", "contributions": [ "code" ] }, { "login": "samcal", "name": "Sam Calvert", "avatar_url": "https://avatars3.githubusercontent.com/u/2117940?v=4", "profile": "https://github.com/samcal", "contributions": [ "code", "doc" ] }, { "login": "jwijenbergh", "name": "Jeroen Wijenbergh", "avatar_url": "https://avatars0.githubusercontent.com/u/46386452?v=4", "profile": "https://github.com/jwijenbergh", "contributions": [ "doc" ] }, { "login": "KimberleyCook", "name": "Kimberley Cook", "avatar_url": "https://avatars3.githubusercontent.com/u/2683270?v=4", "profile": "https://twitter.com/KimberleyCook91", "contributions": [ "doc" ] }, { "login": "baxtea", "name": "Audrey Baxter", "avatar_url": "https://avatars0.githubusercontent.com/u/22502477?v=4", "profile": "https://github.com/baxtea", "contributions": [ "code" ] }, { "login": "nkoehring", "name": "Norman", "avatar_url": "https://avatars2.githubusercontent.com/u/246402?v=4", "profile": "https://koehr.in", "contributions": [ "doc" ] }, { "login": "blackwolf12333", "name": "Peter Maatman", "avatar_url": "https://avatars0.githubusercontent.com/u/1572975?v=4", "profile": "https://github.com/blackwolf12333", "contributions": [ "code" ] }, { "login": "AlexandreSi", "name": "AlexandreS", "avatar_url": "https://avatars1.githubusercontent.com/u/32449369?v=4", "profile": "https://github.com/AlexandreSi", "contributions": [ "code" ] }, { "login": "fiinnnn", "name": "Finn Vos", "avatar_url": "https://avatars2.githubusercontent.com/u/5011796?v=4", "profile": "https://github.com/fiinnnn", "contributions": [ "code" ] }, { "login": "hurricanehrndz", "name": "Carlos Hernandez", "avatar_url": "https://avatars0.githubusercontent.com/u/5804237?v=4", "profile": "https://github.com/hurricanehrndz", "contributions": [ "platform" ] }, { "login": "pedrohva", "name": "Pedro Alves", "avatar_url": "https://avatars3.githubusercontent.com/u/33297928?v=4", "profile": "https://github.com/pedrohva", "contributions": [ "code" ] }, { "login": "jtagcat", "name": "jtagcat", "avatar_url": "https://avatars1.githubusercontent.com/u/38327267?v=4", "profile": "https://gitlab.com/jtagcat/", "contributions": [ "doc" ] }, { "login": "BKitor", "name": "Benjamin Kitor", "avatar_url": "https://avatars0.githubusercontent.com/u/16880850?v=4", "profile": "https://github.com/BKitor", "contributions": [ "code" ] }, { "login": "littleli", "name": "Aleš Najmann", "avatar_url": "https://avatars0.githubusercontent.com/u/544082?v=4", "profile": "https://ales.rocks", "contributions": [ "doc", "platform" ] }, { "login": "jeremystucki", "name": "Jeremy Stucki", "avatar_url": "https://avatars3.githubusercontent.com/u/7629727?v=4", "profile": "https://github.com/jeremystucki", "contributions": [ "code" ] }, { "login": "pt2121", "name": "(´⌣`ʃƪ)", "avatar_url": "https://avatars0.githubusercontent.com/u/616399?v=4", "profile": "http://pt2121.github.io", "contributions": [ "code" ] }, { "login": "tim77", "name": "Artem Polishchuk", "avatar_url": "https://avatars0.githubusercontent.com/u/5614476?v=4", "profile": "https://github.com/tim77", "contributions": [ "platform" ] }, { "login": "slumber", "name": "Chris Sosnin", "avatar_url": "https://avatars2.githubusercontent.com/u/48099298?v=4", "profile": "https://github.com/slumber", "contributions": [ "code" ] }, { "login": "bwbuhse", "name": "Ben Buhse", "avatar_url": "https://avatars1.githubusercontent.com/u/21225303?v=4", "profile": "http://www.benbuhse.com", "contributions": [ "doc" ] }, { "login": "ilnaes", "name": "Sean Li", "avatar_url": "https://avatars1.githubusercontent.com/u/20805499?v=4", "profile": "https://github.com/ilnaes", "contributions": [ "code" ] }, { "login": "TimotheeGerber", "name": "TimotheeGerber", "avatar_url": "https://avatars3.githubusercontent.com/u/37541513?v=4", "profile": "https://github.com/TimotheeGerber", "contributions": [ "code", "doc" ] }, { "login": "fratajczak", "name": "Ferdinand Ratajczak", "avatar_url": "https://avatars2.githubusercontent.com/u/33835579?v=4", "profile": "https://github.com/fratajczak", "contributions": [ "code" ] }, { "login": "sheelc", "name": "Sheel Choksi", "avatar_url": "https://avatars0.githubusercontent.com/u/1355710?v=4", "profile": "https://github.com/sheelc", "contributions": [ "code" ] }, { "login": "mhellwig", "name": "Michael Hellwig", "avatar_url": "https://avatars1.githubusercontent.com/u/414112?v=4", "profile": "http://fnanp.in-ulm.de/microblog/", "contributions": [ "doc" ] }, { "login": "oliver-daniel", "name": "Oliver Daniel", "avatar_url": "https://avatars2.githubusercontent.com/u/17235417?v=4", "profile": "https://github.com/oliver-daniel", "contributions": [ "code" ] }, { "login": "Drewsapple", "name": "Drew Fisher", "avatar_url": "https://avatars2.githubusercontent.com/u/4532572?v=4", "profile": "https://github.com/Drewsapple", "contributions": [ "code" ] }, { "login": "ncoder-1", "name": "ncoder-1", "avatar_url": "https://avatars0.githubusercontent.com/u/7622286?v=4", "profile": "https://github.com/ncoder-1", "contributions": [ "doc" ] }, { "login": "macguirerintoul", "name": "Macguire Rintoul", "avatar_url": "https://avatars3.githubusercontent.com/u/18323154?v=4", "profile": "http://macguire.me", "contributions": [ "doc" ] }, { "login": "RicardoHE97", "name": "Ricardo Holguin", "avatar_url": "https://avatars3.githubusercontent.com/u/28399979?v=4", "profile": "http://ricardohe97.github.io", "contributions": [ "code" ] }, { "login": "ksk001100", "name": "Keisuke Toyota", "avatar_url": "https://avatars3.githubusercontent.com/u/13160198?v=4", "profile": "https://ksk.netlify.com", "contributions": [ "code" ] }, { "login": "jackson15j", "name": "Craig Astill", "avatar_url": "https://avatars1.githubusercontent.com/u/3226988?v=4", "profile": "https://jackson15j.github.io", "contributions": [ "code" ] }, { "login": "onielfa", "name": "Onielfa", "avatar_url": "https://avatars0.githubusercontent.com/u/4358172?v=4", "profile": "https://github.com/onielfa", "contributions": [ "code" ] }, { "login": "usrme", "name": "usrme", "avatar_url": "https://avatars3.githubusercontent.com/u/5902545?v=4", "profile": "https://usrme.xyz", "contributions": [ "doc" ] }, { "login": "murlakatamenka", "name": "Sergey A.", "avatar_url": "https://avatars2.githubusercontent.com/u/7361274?v=4", "profile": "https://github.com/murlakatamenka", "contributions": [ "code" ] }, { "login": "elcih17", "name": "Hideyuki Okada", "avatar_url": "https://avatars3.githubusercontent.com/u/17084445?v=4", "profile": "https://github.com/elcih17", "contributions": [ "code" ] }, { "login": "kepae", "name": "kepae", "avatar_url": "https://avatars2.githubusercontent.com/u/4238598?v=4", "profile": "https://github.com/kepae", "contributions": [ "code", "doc" ] }, { "login": "ericonr", "name": "Érico Nogueira Rolim", "avatar_url": "https://avatars0.githubusercontent.com/u/34201958?v=4", "profile": "https://github.com/ericonr", "contributions": [ "code" ] }, { "login": "BeneCollyridam", "name": "Alexander Meinhardt Scheurer", "avatar_url": "https://avatars2.githubusercontent.com/u/15802915?v=4", "profile": "https://github.com/BeneCollyridam", "contributions": [ "code" ] }, { "login": "Toaster192", "name": "Ondřej Kinšt", "avatar_url": "https://avatars0.githubusercontent.com/u/14369229?v=4", "profile": "https://github.com/Toaster192", "contributions": [ "code" ] }, { "login": "Kryan90", "name": "Kryan90", "avatar_url": "https://avatars3.githubusercontent.com/u/18740821?v=4", "profile": "https://github.com/Kryan90", "contributions": [ "doc" ] }, { "login": "n-ivanov", "name": "n-ivanov", "avatar_url": "https://avatars3.githubusercontent.com/u/11470871?v=4", "profile": "https://github.com/n-ivanov", "contributions": [ "code" ] }, { "login": "bi1yeu", "name": "bi1yeu", "avatar_url": "https://avatars3.githubusercontent.com/u/1185129?v=4", "profile": "http://matthewbilyeu.com/resume/", "contributions": [ "code", "doc" ] }, { "login": "Utagai", "name": "May", "avatar_url": "https://avatars2.githubusercontent.com/u/10730394?v=4", "profile": "https://github.com/Utagai", "contributions": [ "code" ] }, { "login": "mucinoab", "name": "Bruno A. Muciño", "avatar_url": "https://avatars1.githubusercontent.com/u/28630268?v=4", "profile": "https://mucinoab.github.io/", "contributions": [ "code" ] }, { "login": "OrangeFran", "name": "Finn Hediger", "avatar_url": "https://avatars2.githubusercontent.com/u/55061632?v=4", "profile": "https://github.com/OrangeFran", "contributions": [ "code" ] }, { "login": "dp304", "name": "dp304", "avatar_url": "https://avatars1.githubusercontent.com/u/34493835?v=4", "profile": "https://github.com/dp304", "contributions": [ "code" ] }, { "login": "marcomicera", "name": "Marco Micera", "avatar_url": "https://avatars0.githubusercontent.com/u/13918587?v=4", "profile": "http://marcomicera.github.io", "contributions": [ "doc" ] }, { "login": "MarcoIeni", "name": "Marco Ieni", "avatar_url": "https://avatars3.githubusercontent.com/u/11428655?v=4", "profile": "http://marcoieni.com", "contributions": [ "infra" ] }, { "login": "ArturKovacs", "name": "Artúr Kovács", "avatar_url": "https://avatars3.githubusercontent.com/u/8320264?v=4", "profile": "https://github.com/ArturKovacs", "contributions": [ "code" ] }, { "login": "aokellermann", "name": "Antony Kellermann", "avatar_url": "https://avatars.githubusercontent.com/u/26678747?v=4", "profile": "https://github.com/aokellermann", "contributions": [ "code" ] }, { "login": "rasmuspeders1", "name": "Rasmus Pedersen", "avatar_url": "https://avatars.githubusercontent.com/u/1898960?v=4", "profile": "https://github.com/rasmuspeders1", "contributions": [ "code" ] }, { "login": "noir-Z", "name": "noir-Z", "avatar_url": "https://avatars.githubusercontent.com/u/45096516?v=4", "profile": "https://github.com/noir-Z", "contributions": [ "doc" ] }, { "login": "davidbailey00", "name": "David Bailey", "avatar_url": "https://avatars.githubusercontent.com/u/4248177?v=4", "profile": "https://davidbailey.codes/", "contributions": [ "doc" ] }, { "login": "sheepwall", "name": "sheepwall", "avatar_url": "https://avatars.githubusercontent.com/u/22132993?v=4", "profile": "https://github.com/sheepwall", "contributions": [ "code" ] }, { "login": "Hwatwasthat", "name": "Hwatwasthat", "avatar_url": "https://avatars.githubusercontent.com/u/29790143?v=4", "profile": "https://github.com/Hwatwasthat", "contributions": [ "code" ] }, { "login": "Jesse-Bakker", "name": "Jesse", "avatar_url": "https://avatars.githubusercontent.com/u/22473248?v=4", "profile": "https://github.com/Jesse-Bakker", "contributions": [ "code" ] }, { "login": "hantatsang", "name": "Sang", "avatar_url": "https://avatars.githubusercontent.com/u/11912225?v=4", "profile": "https://github.com/hantatsang", "contributions": [ "doc" ] }, { "login": "yktakaha4", "name": "Yuuki Takahashi", "avatar_url": "https://avatars.githubusercontent.com/u/20282867?v=4", "profile": "https://yktakaha4.github.io/", "contributions": [ "doc" ] }, { "login": "alejandro-angulo", "name": "Alejandro Angulo", "avatar_url": "https://avatars.githubusercontent.com/u/5242883?v=4", "profile": "https://alejandr0angul0.dev/", "contributions": [ "code" ] }, { "login": "masguit42", "name": "Anton Kostin", "avatar_url": "https://avatars.githubusercontent.com/u/11005780?v=4", "profile": "http://t.me/lego1as", "contributions": [ "doc" ] }, { "login": "JSextonn", "name": "Justin Sexton", "avatar_url": "https://avatars.githubusercontent.com/u/20236003?v=4", "profile": "https://justinsexton.net", "contributions": [ "code" ] }, { "login": "lejiati", "name": "Jiati Le", "avatar_url": "https://avatars.githubusercontent.com/u/6442124?v=4", "profile": "https://github.com/lejiati", "contributions": [ "doc" ] }, { "login": "cobbinma", "name": "Matthew Cobbing", "avatar_url": "https://avatars.githubusercontent.com/u/578718?v=4", "profile": "https://github.com/cobbinma", "contributions": [ "code" ] }, { "login": "Milo123459", "name": "Milo", "avatar_url": "https://avatars.githubusercontent.com/u/50248166?v=4", "profile": "https://milo123459.vercel.app", "contributions": [ "infra" ] }, { "login": "diegov", "name": "Diego Veralli", "avatar_url": "https://avatars.githubusercontent.com/u/297206?v=4", "profile": "https://www.diegoveralli.com", "contributions": [ "code" ] }, { "login": "majabojarska", "name": "Maja Bojarska", "avatar_url": "https://avatars.githubusercontent.com/u/33836570?v=4", "profile": "https://github.com/majabojarska", "contributions": [ "code" ] } ], "contributorsPerLine": 7, "projectName": "spotify-tui", "projectOwner": "Rigellute", "repoType": "github", "repoHost": "https://github.com", "skipCi": true } ================================================ FILE: .github/FUNDING.yml ================================================ # These are supported funding model platforms github: Rigellute patreon: rigellute ================================================ FILE: .github/dependabot.yml ================================================ version: 2 updates: - package-ecosystem: "cargo" directory: "/" schedule: interval: "monthly" ================================================ FILE: .github/workflows/cd.yml ================================================ name: Continuous Deployment on: push: tags: - "v*.*.*" jobs: publish: name: Publishing for ${{ matrix.os }} runs-on: ${{ matrix.os }} strategy: matrix: os: [macos-latest, ubuntu-latest, windows-latest] rust: [stable] include: - os: macos-latest artifact_prefix: macos target: x86_64-apple-darwin binary_postfix: "" - os: ubuntu-latest artifact_prefix: linux target: x86_64-unknown-linux-gnu binary_postfix: "" - os: windows-latest artifact_prefix: windows target: x86_64-pc-windows-msvc binary_postfix: ".exe" steps: - name: Installing Rust toolchain uses: actions-rs/toolchain@v1 with: toolchain: ${{ matrix.rust }} override: true - name: Installing needed macOS dependencies if: matrix.os == 'macos-latest' run: brew install openssl@1.1 - name: Installing needed Ubuntu dependencies if: matrix.os == 'ubuntu-latest' run: | sudo apt-get update sudo apt-get install -y -qq pkg-config libssl-dev libxcb1-dev libxcb-render0-dev libxcb-shape0-dev libxcb-xfixes0-dev - name: Checking out sources uses: actions/checkout@v1 - name: Running cargo build uses: actions-rs/cargo@v1 with: command: build toolchain: ${{ matrix.rust }} args: --release --target ${{ matrix.target }} - name: Packaging final binary shell: bash run: | cd target/${{ matrix.target }}/release BINARY_NAME=spt${{ matrix.binary_postfix }} strip $BINARY_NAME RELEASE_NAME=spotify-tui-${{ matrix.artifact_prefix }} tar czvf $RELEASE_NAME.tar.gz $BINARY_NAME if [[ ${{ runner.os }} == 'Windows' ]]; then certutil -hashfile $RELEASE_NAME.tar.gz sha256 | grep -E [A-Fa-f0-9]{64} > $RELEASE_NAME.sha256 else shasum -a 256 $RELEASE_NAME.tar.gz > $RELEASE_NAME.sha256 fi - name: Releasing assets uses: softprops/action-gh-release@v1 with: files: | target/${{ matrix.target }}/release/spotify-tui-${{ matrix.artifact_prefix }}.tar.gz target/${{ matrix.target }}/release/spotify-tui-${{ matrix.artifact_prefix }}.sha256 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} publish-cargo: name: Publishing to Cargo runs-on: ubuntu-latest steps: - uses: actions/checkout@master - uses: actions-rs/toolchain@v1 with: toolchain: stable override: true - run: | sudo apt-get update sudo apt-get install -y -qq pkg-config libssl-dev libxcb1-dev libxcb-render0-dev libxcb-shape0-dev libxcb-xfixes0-dev - uses: actions-rs/cargo@v1 with: command: publish args: --token ${{ secrets.CARGO_API_KEY }} --allow-dirty ================================================ FILE: .github/workflows/ci.yml ================================================ on: pull_request: push: branches: master name: Continuous Integration jobs: # Workaround for making Github Actions skip based on commit message `[skip ci]` # Source https://gist.github.com/ybiquitous/c80f15c18319c63cae8447a3be341267 prepare: runs-on: ubuntu-latest if: | !contains(format('{0} {1} {2}', github.event.head_commit.message, github.event.pull_request.title, github.event.pull_request.body), '[skip ci]') steps: - run: | cat <<'MESSAGE' github.event_name: ${{ toJson(github.event_name) }} github.event: ${{ toJson(github.event) }} MESSAGE check: name: Check runs-on: ubuntu-latest needs: prepare steps: - uses: actions/checkout@master - uses: actions-rs/toolchain@v1 with: toolchain: stable profile: minimal override: true - uses: actions-rs/cargo@v1 with: command: check test: name: Test Suite runs-on: ubuntu-latest needs: prepare steps: - uses: actions/checkout@master - uses: actions-rs/toolchain@v1 with: toolchain: stable profile: minimal override: true # These dependencies are required for `clipboard` - run: sudo apt-get install -y -qq libxcb1-dev libxcb-render0-dev libxcb-shape0-dev libxcb-xfixes0-dev - uses: Swatinem/rust-cache@v1 - uses: actions-rs/cargo@v1 with: command: test fmt: name: Rustfmt runs-on: ubuntu-latest needs: prepare steps: - uses: actions/checkout@master - uses: actions-rs/toolchain@v1 with: toolchain: stable profile: minimal override: true components: rustfmt - uses: Swatinem/rust-cache@v1 - uses: actions-rs/cargo@v1 with: command: fmt args: --all -- --check clippy: name: Clippy runs-on: ubuntu-latest needs: prepare steps: - uses: actions/checkout@master - uses: actions-rs/toolchain@v1 with: toolchain: stable profile: minimal override: true components: clippy - uses: Swatinem/rust-cache@v1 - uses: actions-rs/cargo@v1 with: command: clippy args: -- -D warnings ================================================ FILE: .gitignore ================================================ /target **/*.rs.bk .DS_store .env .spotify_token_cache.json .cached_device_id.txt spotify-tui.sketch spt*.txt *.snap .ci/lp-creds snapcraft-login secrets.tar *.swp tags .idea ================================================ FILE: CHANGELOG.md ================================================ # Changelog ## [Unreleased] - Fix confirmation dialog handling on playlist delete [#910](https://github.com/Rigellute/spotify-tui/pull/910) ### Added - Show `album_type` in Search panes [#868](https://github.com/Rigellute/spotify-tui/pull/868) - Add option to set window title to "spt - Spotify TUI" on startup [#844](https://github.com/Rigellute/spotify-tui/pull/844) ## [0.25.0] - 2021-08-24 ### Fixed - Fixed rate limiting issue [#852](https://github.com/Rigellute/spotify-tui/pull/852) - Fix double navigation to same route [#826](https://github.com/Rigellute/spotify-tui/pull/826) ## [0.24.0] - 2021-04-26 ### Fixed - Handle invalid Client ID/Secret [#668](https://github.com/Rigellute/spotify-tui/pull/668) - Fix default liked, shuffle, etc. icons to be more recognizable symbols [#702](https://github.com/Rigellute/spotify-tui/pull/702) - Replace black and white default colors with reset [#742](https://github.com/Rigellute/spotify-tui/pull/742) ### Added - Add ability to seek from the CLI [#692](https://github.com/Rigellute/spotify-tui/pull/692) - Replace `clipboard` with `arboard` [#691](https://github.com/Rigellute/spotify-tui/pull/691) - Implement some episode table functions [#698](https://github.com/Rigellute/spotify-tui/pull/698) - Change `--like` that toggled the liked-state to explicit `--like` and `--dislike` flags [#717](https://github.com/Rigellute/spotify-tui/pull/717) - Add to config: `enforce_wide_search_bar` to make search bar bigger [#738](https://github.com/Rigellute/spotify-tui/pull/738) - Add Daily Drive to Made For You lists search [#743](https://github.com/Rigellute/spotify-tui/pull/743) ## [0.23.0] - 2021-01-06 ### Fixed - Fix app crash when pressing Enter before a screen has loaded [#599](https://github.com/Rigellute/spotify-tui/pull/599) - Make layout more responsive to large/small screens [#502](https://github.com/Rigellute/spotify-tui/pull/502) - Fix use of incorrect playlist index when playing from an associated track table [#632](https://github.com/Rigellute/spotify-tui/pull/632) - Fix flickering help menu in small screens [#638](https://github.com/Rigellute/spotify-tui/pull/638) - Optimize seek [#640](https://github.com/Rigellute/spotify-tui/pull/640) - Fix centering of basic_view [#664](https://github.com/Rigellute/spotify-tui/pull/664) ### Added - Implement next/previous page behavior for the Artists table [#604](https://github.com/Rigellute/spotify-tui/pull/604) - Show saved albums when getting an artist [#612](https://github.com/Rigellute/spotify-tui/pull/612) - Transfer playback when changing device [#408](https://github.com/Rigellute/spotify-tui/pull/408) - Search using Spotify share URLs and URIs like the desktop client [#623](https://github.com/Rigellute/spotify-tui/pull/623) - Make the liked icon configurable [#659](https://github.com/Rigellute/spotify-tui/pull/659) - Add CLI for controlling Spotify [#645](https://github.com/Rigellute/spotify-tui/pull/645) - Implement Podcasts Library page [#650](https://github.com/Rigellute/spotify-tui/pull/650) ## [0.22.0] - 2020-10-05 ### Fixed - Show ♥ next to album name in saved list [#540](https://github.com/Rigellute/spotify-tui/pull/540) - Fix to be able to follow an artist in search result view [#565](https://github.com/Rigellute/spotify-tui/pull/565) - Don't add analysis view to stack if already in it [#580](https://github.com/Rigellute/spotify-tui/pull/580) ### Added - Add additional line of help to show that 'w' can be used to save/like an album [#548](https://github.com/Rigellute/spotify-tui/pull/548) - Add handling Home and End buttons in user input [#550](https://github.com/Rigellute/spotify-tui/pull/550) - Add `playbar_progress_text` to user config and upgrade tui lib [#564](https://github.com/Rigellute/spotify-tui/pull/564) - Add basic playbar support for podcasts [#563](https://github.com/Rigellute/spotify-tui/pull/563) - Add 'enable_text_emphasis' behavior config option [#573](https://github.com/Rigellute/spotify-tui/pull/573) - Add next/prev page, jump to start/end to user config [#566](https://github.com/Rigellute/spotify-tui/pull/566) - Add possibility to queue a song [#567](https://github.com/Rigellute/spotify-tui/pull/567) - Add user-configurable header styling [#583](https://github.com/Rigellute/spotify-tui/pull/583) - Show active keybindings in Help [#585](https://github.com/Rigellute/spotify-tui/pull/585) - Full Podcast support [#581](https://github.com/Rigellute/spotify-tui/pull/581) ## [0.21.0] - 2020-07-24 ### Fixed - Fix typo in help menu [#485](https://github.com/Rigellute/spotify-tui/pull/485) ### Added - Add save album on album view [#506](https://github.com/Rigellute/spotify-tui/pull/506) - Add feature to like a song from basic view [#507](https://github.com/Rigellute/spotify-tui/pull/507) - Enable Unix and Linux shortcut keys in the input [#511](https://github.com/Rigellute/spotify-tui/pull/511) - Add album artist field to full album view [#519](https://github.com/Rigellute/spotify-tui/pull/519) - Handle track saving in non-album contexts (eg. playlist/Made for you). [#525](https://github.com/Rigellute/spotify-tui/pull/525) ## [0.20.0] - 2020-05-28 ### Fixed - Move pagination instructions to top of help menu [#442](https://github.com/Rigellute/spotify-tui/pull/442) ### Added - Add user configuration toggle for the loading indicator [#447](https://github.com/Rigellute/spotify-tui/pull/447) - Add support for saving an album and following an artist in artist view [#445](https://github.com/Rigellute/spotify-tui/pull/445) - Use the `▶` glyph to indicate the currently playing song [#472](https://github.com/Rigellute/spotify-tui/pull/472) - Jump to play context (if available) - default binding is `o` [#474](https://github.com/Rigellute/spotify-tui/pull/474) ## [0.19.0] - 2020-05-04 ### Fixed - Fix re-authentication [#415](https://github.com/Rigellute/spotify-tui/pull/415) - Fix audio analysis feature [#435](https://github.com/Rigellute/spotify-tui/pull/435) ### Added - Add more readline shortcuts to the search input [#425](https://github.com/Rigellute/spotify-tui/pull/425) ## [0.18.0] - 2020-04-21 ### Fixed - Fix crash when opening playlist [#398](https://github.com/Rigellute/spotify-tui/pull/398) - Fix crash when there are no artists avaliable [#388](https://github.com/Rigellute/spotify-tui/pull/388) - Correctly handle playlist unfollowing [#399](https://github.com/Rigellute/spotify-tui/pull/399) ### Added - Allow specifying alternative config file path [#391](https://github.com/Rigellute/spotify-tui/pull/391) - List artists names in the album view [#393](https://github.com/Rigellute/spotify-tui/pull/393) - Add confirmation modal for delete playlist action [#402](https://github.com/Rigellute/spotify-tui/pull/402) ## [0.17.1] - 2020-03-30 ### Fixed - Artist name in songs block [#365](https://github.com/Rigellute/spotify-tui/pull/365) (fixes regression) - Add basic view key binding to help menu ## [0.17.0] - 2020-03-20 ### Added - Show if search results are liked/followed [#342](https://github.com/Rigellute/spotify-tui/pull/342) - Show currently playing track in song search menu and play through the searched tracks [#343](https://github.com/Rigellute/spotify-tui/pull/343) - Add a "basic view" that only shows the playbar. Press `B` to get there [#344](https://github.com/Rigellute/spotify-tui/pull/344) - Show currently playing top track [#347](https://github.com/Rigellute/spotify-tui/pull/347) - Press shift-s (`S`) to pick a random song on track-lists [#339](https://github.com/Rigellute/spotify-tui/pull/339) ### Fixed - Prevent search when there is no input [#351](https://github.com/Rigellute/spotify-tui/pull/351) ## [0.16.0] - 2020-03-12 ### Fixed - Fix empty UI when pressing escape in the device and analysis views [#315](https://github.com/Rigellute/spotify-tui/pull/315) - Fix slow and frozen UI by implementing an asynchronous runtime for network events [#322](https://github.com/Rigellute/spotify-tui/pull/322). This fixes issues #24, #92, #207 and #218. Read more [here](https://keliris.dev/improving-spotify-tui/). ## [0.15.0] - 2020-02-24 - Add experimental audio visualizer (press `v` to navigate to it). The feature uses the audio analysis data from Spotify and animates the pitch information. - Display Artist layout when searching an artist url [#298](https://github.com/Rigellute/spotify-tui/pull/298) - Add pagination to the help menu [#302](https://github.com/Rigellute/spotify-tui/pull/302) ## [0.14.0] - 2020-02-11 ### Added - Add high-middle-low navigation (`H`, `M`, `L` respectively) for jumping around lists [#234](https://github.com/Rigellute/spotify-tui/pull/234). - Play every known song with `e` [#228](https://github.com/Rigellute/spotify-tui/pull/228) - Search album by url: paste a spotify album link into the search input to go to that album [#281](https://github.com/Rigellute/spotify-tui/pull/281) - Implement 'Made For You' section of Library [#278](https://github.com/Rigellute/spotify-tui/pull/278) - Add user theme configuration [#284](https://github.com/Rigellute/spotify-tui/pull/284) - Allow user to define the volume increment [#288](https://github.com/Rigellute/spotify-tui/pull/288) ### Fixed - Fix crash on small terminals [#231](https://github.com/Rigellute/spotify-tui/pull/231) ## [0.13.0] - 2020-01-26 ### Fixed - Don't error if failed to open clipboard [#217](https://github.com/Rigellute/spotify-tui/pull/217) - Fix scrolling beyond the end of pagination. [#216](https://github.com/Rigellute/spotify-tui/pull/216) - Add copy album url functionality [#226](https://github.com/Rigellute/spotify-tui/pull/226) ### Added - Allow user to configure the port for the Spotify auth Redirect URI [#204](https://github.com/Rigellute/spotify-tui/pull/204) - Add play recommendations for song/artist on pressing 'r' [#200](https://github.com/Rigellute/spotify-tui/pull/200) - Added continuous deployment for Windows [#222](https://github.com/Rigellute/spotify-tui/pull/222) ### Changed - Change behavior of previous button (`p`) to mimic behavior in official Spotify client. When the track is more than three seconds in, pressing previous will restart the track. When less than three seconds it will jump to previous. [#219](https://github.com/Rigellute/spotify-tui/pull/219) ## [0.12.0] - 2020-01-23 ### Added - Add Windows support [#99](https://github.com/Rigellute/spotify-tui/pull/99) - Add support for Related artists and top tacks [#191](https://github.com/Rigellute/spotify-tui/pull/191) ## [0.11.0] - 2019-12-23 ### Added - Add support for adding an album and following a playlist. NOTE: that this will require the user to grant more permissions [#172](https://github.com/Rigellute/spotify-tui/pull/172) - Add shortcuts to jump to the start or the end of a playlist [#167](https://github.com/Rigellute/spotify-tui/pull/167) - Make seeking amount configurable [#168](https://github.com/Rigellute/spotify-tui/pull/168) ### Fixed - Fix playlist index after search [#177](https://github.com/Rigellute/spotify-tui/pull/177) - Fix cursor offset in search input [#183](https://github.com/Rigellute/spotify-tui/pull/183) ### Changed - Remove focus on input when jumping back [#184](https://github.com/Rigellute/spotify-tui/pull/184) - Pad strings in status bar to prevent reformatting [#188](https://github.com/Rigellute/spotify-tui/pull/188) ## [0.10.0] - 2019-11-30 ### Added - Added pagination to user playlists [#150](https://github.com/Rigellute/spotify-tui/pull/150) - Add ability to delete a saved album (hover over the album you wish to delete and press `D`) [#152](https://github.com/Rigellute/spotify-tui/pull/152) - Add support for following/unfollowing artists [#155](https://github.com/Rigellute/spotify-tui/pull/155) - Add hotkey to copy url of currently playing track (default binding is `c`)[#156](https://github.com/Rigellute/spotify-tui/pull/156) ### Fixed - Refine Spotify result limits, which should fit your current terminal size. Most notably this will increase the number of results from a search [#154](https://github.com/Rigellute/spotify-tui/pull/154) - Navigation from "Liked Songs" [#151](https://github.com/Rigellute/spotify-tui/pull/151) - App hang upon trying to authenticate with Spotify on FreeBSD [#148](https://github.com/Rigellute/spotify-tui/pull/148) - Showing "Release Date" in saved albums table [#162](https://github.com/Rigellute/spotify-tui/pull/162) - Showing "Length" in library -> recently played table [#164](https://github.com/Rigellute/spotify-tui/pull/164) - Typo: "AlbumTracks" -> "Albums" [#165](https://github.com/Rigellute/spotify-tui/pull/165) - Janky volume control [#166](https://github.com/Rigellute/spotify-tui/pull/166) - Volume bug that would prevent volumes of 0 and 100 [#170](https://github.com/Rigellute/spotify-tui/pull/170) - Playing a wrong track in playlist [#173](https://github.com/Rigellute/spotify-tui/pull/173) ## [0.9.0] - 2019-11-13 ### Added - Add custom keybindings feature. Check the README for an example configuration [#112](https://github.com/Rigellute/spotify-tui/pull/112) ### Fixed - Fix panic when seeking beyond track boundaries [#124](https://github.com/Rigellute/spotify-tui/pull/124) - Add scrolling to library album list. Can now use `ctrl+d/u` to scroll between result pages [#128](https://github.com/Rigellute/spotify-tui/pull/128) - Fix showing wrong album in library album view - [#130](https://github.com/Rigellute/spotify-tui/pull/130) - Fix scrolling in table views [#135](https://github.com/Rigellute/spotify-tui/pull/135) - Use space more efficiently in small terminals [#143](https://github.com/Rigellute/spotify-tui/pull/143) ## [0.8.0] - 2019-10-29 ### Added - Improve onboarding: auto fill the redirect url [#98](https://github.com/Rigellute/spotify-tui/pull/98) - Indicate if a track is "liked" in Recently Played, Album tracks and song list views using "♥" - [#103](https://github.com/Rigellute/spotify-tui/pull/103) - Add ability to toggle the saved state of a track: pressing `s` on an already saved track will unsave it. [#104](https://github.com/Rigellute/spotify-tui/pull/104) - Add collaborative playlists scope. You'll need to reauthenticate due to this change. [#115](https://github.com/Rigellute/spotify-tui/pull/115) - Add Ctrl-f and Ctrl-b emacs style keybindings for left and right motion. [#114](https://github.com/Rigellute/spotify-tui/pull/114) ### Fixed - Fix app crash when pressing `enter`, `q` and then `down`. [#109](https://github.com/Rigellute/spotify-tui/pull/109) - Fix trying to save a track in the album view [#119](https://github.com/Rigellute/spotify-tui/pull/119) - Fix UI saved indicator when toggling saved track [#119](https://github.com/Rigellute/spotify-tui/pull/119) ## [0.7.0] - 2019-10-20 - Implement library "Artists" view - [#67](https://github.com/Rigellute/spotify-tui/pull/67) thanks [@svenvNL](https://github.com/svenvNL). NOTE that this adds an additional scope (`user-follow-read`), so you'll be prompted to grant this new permissions when you upgrade. - Fix searching with non-english characters - [#30](https://github.com/Rigellute/spotify-tui/pull/30). Thanks to [@fangyi-zhou](https://github.com/fangyi-zhou) - Remove hardcoded country (was always set to UK). We now fetch the user to get their country. [#68](https://github.com/Rigellute/spotify-tui/pull/68). Thanks to [@svenvNL](https://github.com/svenvNL) - Save currently playing track - the playbar is now selectable/hoverable [#80](https://github.com/Rigellute/spotify-tui/pull/80) - Lay foundation for showing if a track is saved. You can now see if the currently playing track is saved (indicated by ♥) ## [0.6.0] - 2019-10-14 ### Added - Start a web server on localhost to display a simple webpage for the Redirect URI. Should hopefully improve the onboarding experience. - Add ability to skip to tracks using `n` for next and `p` for previous - thanks to [@samcal](https://github.com/samcal) - Implement seek functionality - you can now use `<` to seek backwards 5 seconds and `>` to go forwards 5 seconds - The event `A` will jump to the album list of the first artist in the track's artists list - closing [#45](https://github.com/Rigellute/spotify-tui/issues/45) - Add volume controls - use `-` to decrease and `+` to increase volume in 10% increments. Closes [#57](https://github.com/Rigellute/spotify-tui/issues/57) ### Fixed - Keep format of highlighted track when it is playing - [#44](https://github.com/Rigellute/spotify-tui/pull/44) thanks to [@jfaltis](https://github.com/jfaltis) - Search input bug: Fix "out-of-bounds" crash when pressing left too many times [#63](https://github.com/Rigellute/spotify-tui/issues/63) - Search input bug: Fix issue that backspace always deleted the end of input, not where the cursor was [#33](https://github.com/Rigellute/spotify-tui/issues/33) ## [0.5.0] - 2019-10-11 ### Added - Add `Ctrl-r` to cycle repeat mode ([@baxtea](https://github.com/baxtea)) - Refresh token when token expires ([@fangyi-zhou](https://github.com/fangyi-zhou)) - Upgrade `rspotify` to fix [#39](https://github.com/Rigellute/spotify-tui/issues/39) ([@epwalsh](https://github.com/epwalsh)) ### Changed - Fix duplicate albums showing in artist discographies ([@baxtea](https://github.com/baxtea)) - Slightly better error message with some debug tips when tracks fail to play ## [0.4.0] - 2019-10-05 ### Added - Can now install `spotify-tui` using `brew reinstall Rigellute/tap/spotify-tui` and `cargo install spotify-tui` 🎉 - Credentials (auth token, chosen device, and CLIENT_ID & CLIENT_SECRET) are now all stored in the same place (`${HOME}/.config/spotify-tui/client.yml`), which closes [this issue](https://github.com/Rigellute/spotify-tui/issues/4) ## [0.3.0] - 2019-10-04 ### Added - Improved onboarding experience - On first startup instructions will (hopefully) guide the user on how to get setup ## [0.2.0] - 2019-09-17 ### Added - General navigation improvements - Improved search input: it should now behave how one would expect - Add `Ctrl-d/u` for scrolling up and down through result pages (currently only implemented for "Liked Songs") - Minor theme improvements - Make tables responsive - Implement resume playback feature - Add saved albums table - Show which track is currently playing within a table or list - Add `a` event to jump to currently playing track's album - Add `s` event to save a track from within the "Recently Played" view (eventually this should be everywhere) - Add `Ctrl-s` to toggle shuffle - Add the following journey: search -> select artist and see their albums -> select album -> go to album and play tracks # What is this? All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ================================================ FILE: Cargo.toml ================================================ [package] name = "spotify-tui" description = "A terminal user interface for Spotify" homepage = "https://github.com/Rigellute/spotify-tui" documentation = "https://github.com/Rigellute/spotify-tui" repository = "https://github.com/Rigellute/spotify-tui" keywords = ["spotify", "tui", "cli", "terminal"] categories = ["command-line-utilities"] version = "0.25.0" authors = ["Alexander Keliris "] edition = "2018" license = "MIT OR Apache-2.0" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] rspotify = "0.10.0" tui = { version = "0.16.0", features = ["crossterm"], default-features = false } serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" serde_yaml = "0.8" dirs = "3.0.2" clap = "2.33.3" unicode-width = "0.1.8" backtrace = "0.3.57" arboard = "1.2.0" crossterm = "0.20" tokio = { version = "0.2", features = ["full"] } rand = "0.8.4" anyhow = "1.0.43" [[bin]] bench = false path = "src/main.rs" name = "spt" ================================================ FILE: LICENSE ================================================ MIT License Copyright (c) 2021 Alexander Keliris Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: README.md ================================================ # Spotify TUI ![Continuous Integration](https://github.com/Rigellute/spotify-tui/workflows/Continuous%20Integration/badge.svg?branch=master&event=push) ![](https://img.shields.io/badge/license-MIT-blueviolet.svg) ![](https://tokei.rs/b1/github/Rigellute/spotify-tui?category=code) [![Crates.io](https://img.shields.io/crates/v/spotify-tui.svg)](https://crates.io/crates/spotify-tui) ![](https://img.shields.io/github/v/release/Rigellute/spotify-tui?color=%23c694ff) [![All Contributors](https://img.shields.io/badge/all_contributors-94-orange.svg?style=flat-square)](#contributors-) [![Follow Alexander Keliris (Rigellute)](https://img.shields.io/twitter/follow/AlexKeliris?label=Follow%20Alexander%20Keliris%20%28Rigellute%29&style=social)](https://twitter.com/intent/follow?screen_name=AlexKeliris) A Spotify client for the terminal written in Rust. ![Demo](https://user-images.githubusercontent.com/12150276/75177190-91d4ab00-572d-11ea-80bd-c5e28c7b17ad.gif) The terminal in the demo above is using the [Rigel theme](https://rigel.netlify.com/). - [Spotify TUI](#spotify-tui) - [Installation](#installation) - [Homebrew](#homebrew) - [Snap](#snap) - [AUR](#aur) - [Nix](#nix) - [Void Linux](#void-linux) - [Fedora/CentOS](#fedora-centos) - [Cargo](#cargo) - [Note on Linux](#note-on-linux) - [Windows](#windows-10) - [Scoop installer](#scoop-installer) - [Manual](#manual) - [Connecting to Spotify’s API](#connecting-to-spotifys-api) - [Usage](#usage) - [Configuration](#configuration) - [Limitations](#limitations) - [Using with spotifyd](#using-with-spotifyd) - [Libraries used](#libraries-used) - [Development](#development) - [Windows Subsystem for Linux](#windows-subsystem-for-linux) - [Contributors](#contributors) - [Roadmap](#roadmap) - [High-level requirements yet to be implemented](#high-level-requirements-yet-to-be-implemented) ## Installation The binary executable is `spt`. ### Homebrew For both macOS and Linux ```bash brew install spotify-tui ``` To update, run ```bash brew upgrade spotify-tui ``` ### Snap For a system with Snap installed, run ```bash snap install spt ``` The stable version will be installed for you automatically. If you want to install the nightly build, run ```bash snap install spt --edge ``` ### AUR For those on Arch Linux you can find the package on AUR [here](https://aur.archlinux.org/packages/spotify-tui/). If however you're using an AUR helper you can install directly from that, for example (in the case of [yay](https://github.com/Jguer/yay)), run ```bash yay -S spotify-tui ``` ### Nix Available as the package `spotify-tui`. To install run: ```bash nix-env -iA nixpkgs.spotify-tui ``` Where `nixpkgs` is the channel name in your configuration. For a more up-to-date installation, use the unstable channel. It is also possible to add the package to `environment.systemPackages` (for NixOS), or `home.packages` when using [home-manager](https://github.com/rycee/home-manager). ### Void Linux Available on the official repositories. To install, run ```bash sudo xbps-install -Su spotify-tui ``` ### Fedora/CentOS Available on the [Copr](https://copr.fedorainfracloud.org/coprs/atim/spotify-tui/) repositories. To install, run ```bash sudo dnf copr enable atim/spotify-tui -y && sudo dnf install spotify-tui ``` ### Cargo Use this option if your architecture is not supported by the pre-built binaries found on the [releases page](https://github.com/Rigellute/spotify-tui/releases). First, install [Rust](https://www.rust-lang.org/tools/install) (using the recommended `rustup` installation method) and then ```bash cargo install spotify-tui ``` This method will build the binary from source. To update, run the same command again. #### Note on Linux For compilation on Linux the development packages for `libssl` are required. For basic installation instructions, see [install OpenSSL](https://docs.rs/openssl/0.10.25/openssl/#automatic). In order to locate dependencies, the compilation also requires `pkg-config` to be installed. If you are using the Windows Subsystem for Linux, you'll need to [install additional dependencies](#windows-subsystem-for-linux). ### Windows 10 #### Scoop installer First, make sure scoop installer is on your windows box, for instruction please visit [scoop.sh](https://scoop.sh) Then open powershell and run following two commands: ```bash scoop bucket add scoop-bucket https://github.com/Rigellute/scoop-bucket scoop install spotify-tui ``` After that program is available as: `spt` or `spt.exe` ### Manual 1. Download the latest [binary](https://github.com/Rigellute/spotify-tui/releases) for your OS. 1. `cd` to the file you just downloaded and unzip 1. `cd` to `spotify-tui` and run with `./spt` ## Connecting to Spotify’s API `spotify-tui` needs to connect to Spotify’s API in order to find music by name, play tracks etc. Instructions on how to set this up will be shown when you first run the app. But here they are again: 1. Go to the [Spotify dashboard](https://developer.spotify.com/dashboard/applications) 1. Click `Create an app` - You now can see your `Client ID` and `Client Secret` 1. Now click `Edit Settings` 1. Add `http://localhost:8888/callback` to the Redirect URIs 1. Scroll down and click `Save` 1. You are now ready to authenticate with Spotify! 1. Go back to the terminal 1. Run `spt` 1. Enter your `Client ID` 1. Enter your `Client Secret` 1. Press enter to confirm the default port (8888) or enter a custom port 1. You will be redirected to an official Spotify webpage to ask you for permissions. 1. After accepting the permissions, you'll be redirected to localhost. If all goes well, the redirect URL will be parsed automatically and now you're done. If the local webserver fails for some reason you'll be redirected to a blank webpage that might say something like "Connection Refused" since no server is running. Regardless, copy the URL and paste into the prompt in the terminal. And now you are ready to use the `spotify-tui` 🎉 You can edit the config at anytime at `${HOME}/.config/spotify-tui/client.yml`. (for snap `${HOME}/snap/spt/current/.config/spotify-tui/client.yml`) ## Usage The binary is named `spt`. Running `spt` with no arguments will bring up the UI. Press `?` to bring up a help menu that shows currently implemented key events and their actions. There is also a CLI that is able to do most of the stuff the UI does. Use `spt --help` to learn more. Here are some example to get you excited. ``` spt --completions zsh # Prints shell completions for zsh to stdout (bash, power-shell and more are supported) spt play --name "Your Playlist" --playlist --random # Plays a random song from "Your Playlist" spt play --name "A cool song" --track # Plays 'A cool song' spt playback --like --shuffle # Likes the current song and toggles shuffle mode spt playback --toggle # Plays/pauses the current playback spt list --liked --limit 50 # See your liked songs (50 is the max limit) # Looks for 'An even cooler song' and gives you the '{name} from {album}' of up to 30 matches spt search "An even cooler song" --tracks --format "%t from %b" --limit 30 ``` # Configuration A configuration file is located at `${HOME}/.config/spotify-tui/config.yml`, for snap `${HOME}/snap/spt/current/.config/spotify-tui/config.yml` (not to be confused with client.yml which handles spotify authentication) The following is a sample config.yml file: ```yaml # Sample config file # The theme colours can be an rgb string of the form "255, 255, 255" or a string that references the colours from your terminal theme: Reset, Black, Red, Green, Yellow, Blue, Magenta, Cyan, Gray, DarkGray, LightRed, LightGreen, LightYellow, LightBlue, LightMagenta, LightCyan, White. theme: active: Cyan # current playing song in list banner: LightCyan # the "spotify-tui" banner on launch error_border: Red # error dialog border error_text: LightRed # error message text (e.g. "Spotify API reported error 404") hint: Yellow # hint text in errors hovered: Magenta # hovered pane border inactive: Gray # borders of inactive panes playbar_background: Black # background of progress bar playbar_progress: LightCyan # filled-in part of the progress bar playbar_progress_text: Cyan # song length and time played/left indicator in the progress bar playbar_text: White # artist name in player pane selected: LightCyan # a) selected pane border, b) hovered item in list, & c) track title in player text: "255, 255, 255" # text in panes header: White # header text in panes (e.g. 'Title', 'Artist', etc.) behavior: seek_milliseconds: 5000 volume_increment: 10 # The lower the number the higher the "frames per second". You can decrease this number so that the audio visualisation is smoother but this can be expensive! tick_rate_milliseconds: 250 # Enable text emphasis (typically italic/bold text styling). Disabling this might be important if the terminal config is otherwise restricted and rendering text escapes interferes with the UI. enable_text_emphasis: true # Controls whether to show a loading indicator in the top right of the UI whenever communicating with Spotify API show_loading_indicator: true # Disables the responsive layout that makes the search bar smaller on bigger # screens and enforces a wide search bar enforce_wide_search_bar: false # Determines the text icon to display next to "liked" Spotify items, such as # liked songs and albums, or followed artists. Can be any length string. # These icons require a patched nerd font. liked_icon: ♥ shuffle_icon: 🔀 repeat_track_icon: 🔂 repeat_context_icon: 🔁 playing_icon: ▶ paused_icon: ⏸ # Sets the window title to "spt - Spotify TUI" via ANSI escape code. set_window_title: true keybindings: # Key stroke can be used if it only uses two keys: # ctrl-q works, # ctrl-alt-q doesn't. back: "ctrl-q" jump_to_album: "a" # Shift modifiers use a capital letter (also applies with other modifier keys # like ctrl-A) jump_to_artist_album: "A" manage_devices: "d" decrease_volume: "-" increase_volume: "+" toggle_playback: " " seek_backwards: "<" seek_forwards: ">" next_track: "n" previous_track: "p" copy_song_url: "c" copy_album_url: "C" help: "?" shuffle: "ctrl-s" repeat: "r" search: "/" audio_analysis: "v" jump_to_context: "o" basic_view: "B" add_item_to_queue: "z" ``` ## Limitations This app uses the [Web API](https://developer.spotify.com/documentation/web-api/) from Spotify, which doesn't handle streaming itself. So you'll need either an official Spotify client open or a lighter weight alternative such as [spotifyd](https://github.com/Spotifyd/spotifyd). If you want to play tracks, Spotify requires that you have a Premium account. ## Using with [spotifyd](https://github.com/Spotifyd/spotifyd) Follow the spotifyd documentation to get set up. After that there is not much to it. 1. Start running the spotifyd daemon. 1. Start up `spt` 1. Press `d` to go to the device selection menu and the spotifyd "device" should be there - if not check [these docs](https://github.com/Spotifyd/spotifyd#logging) ## Libraries used - [tui-rs](https://github.com/fdehau/tui-rs) - [rspotify](https://github.com/ramsayleung/rspotify) ## Development 1. [Install OpenSSL](https://docs.rs/openssl/0.10.25/openssl/#automatic) 1. [Install Rust](https://www.rust-lang.org/tools/install) 1. [Install `xorg-dev`](https://github.com/aweinstock314/rust-clipboard#prerequisites) (required for clipboard support) 1. Clone or fork this repo and `cd` to it 1. And then `cargo run` ### Windows Subsystem for Linux You might get a linking error. If so, you'll probably need to install additional dependencies required by the clipboard package ```bash sudo apt-get install -y -qq pkg-config libssl-dev libxcb1-dev libxcb-render0-dev libxcb-shape0-dev libxcb-xfixes0-dev ``` ## Contributors Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/docs/en/emoji-key)):

Alexander Keliris

💻 📖 🎨 📝 🤔 🚇 🚧 📦 👀

Mickael Marques

💵

Grzegorz Koperwas

📖

Austin Gassert

💻

Calen Robinette

💻

M*C*O

🚇

Andrew Chin

💻

Sam Naser

💻

Micha

💻

neriglissar

💻

Timon

💻

echoSayonara

💻

D-Nice

📖 🚇

Grzegorz Pawlik

💻

Lennart Bernhardt

💻

Arnaud Lefebvre

💻

tem1029

💻

Peter K. Moss

💻

Geoff Shannon

💻

Zachary Mayhew

💻

jfaltis

💻

Marcel Schramm

📖

Fangyi Zhou

💻

Max

💻

Sven van der Vlist

💻

jacobchrismarsh

💻

Nils Rauch

💻

Nick Stockton

💻 🐛 🚧 💬 📖

Stuart Hinson

💻

Sam Calvert

💻 📖

Jeroen Wijenbergh

📖

Kimberley Cook

📖

Audrey Baxter

💻

Norman

📖

Peter Maatman

💻

AlexandreS

💻

Finn Vos

💻

Carlos Hernandez

📦

Pedro Alves

💻

jtagcat

📖

Benjamin Kitor

💻

Aleš Najmann

📖 📦

Jeremy Stucki

💻

(´⌣`ʃƪ)

💻

Artem Polishchuk

📦

Chris Sosnin

💻

Ben Buhse

📖

Sean Li

💻

TimotheeGerber

💻 📖

Ferdinand Ratajczak

💻

Sheel Choksi

💻

Michael Hellwig

📖

Oliver Daniel

💻

Drew Fisher

💻

ncoder-1

📖

Macguire Rintoul

📖

Ricardo Holguin

💻

Keisuke Toyota

💻

Craig Astill

💻

Onielfa

💻

usrme

📖

Sergey A.

💻

Hideyuki Okada

💻

kepae

💻 📖

Érico Nogueira Rolim

💻

Alexander Meinhardt Scheurer

💻

Ondřej Kinšt

💻

Kryan90

📖

n-ivanov

💻

bi1yeu

💻 📖

May

💻

Bruno A. Muciño

💻

Finn Hediger

💻

dp304

💻

Marco Micera

📖

Marco Ieni

🚇

Artúr Kovács

💻

Antony Kellermann

💻

Rasmus Pedersen

💻

noir-Z

📖

David Bailey

📖

sheepwall

💻

Hwatwasthat

💻

Jesse

💻

Sang

📖

Yuuki Takahashi

📖

Alejandro Angulo

💻

Anton Kostin

📖

Justin Sexton

💻

Jiati Le

📖

Matthew Cobbing

💻

Milo

🚇

Diego Veralli

💻

Maja Bojarska

💻
This project follows the [all-contributors](https://github.com/all-contributors/all-contributors) specification. Contributions of any kind welcome! ## Roadmap The goal is to eventually implement almost every Spotify feature. ### High-level requirements yet to be implemented - Add songs to a playlist - Be able to scroll through result pages in every view This table shows all that is possible with the Spotify API, what is implemented already, and whether that is essential. | API method | Implemented yet? | Explanation | Essential? | | ------------------------------------------------- | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------- | | track | No | returns a single track given the track's ID, URI or URL | No | | tracks | No | returns a list of tracks given a list of track IDs, URIs, or URLs | No | | artist | No | returns a single artist given the artist's ID, URI or URL | Yes | | artists | No | returns a list of artists given the artist IDs, URIs, or URLs | No | | artist_albums | Yes | Get Spotify catalog information about an artist's albums | Yes | | artist_top_tracks | Yes | Get Spotify catalog information about an artist's top 10 tracks by country. | Yes | | artist_related_artists | Yes | Get Spotify catalog information about artists similar to an identified artist. Similarity is based on analysis of the Spotify community's listening history. | Yes | | album | Yes | returns a single album given the album's ID, URIs or URL | Yes | | albums | No | returns a list of albums given the album IDs, URIs, or URLs | No | | search_album | Yes | Search album based on query | Yes | | search_artist | Yes | Search artist based on query | Yes | | search_track | Yes | Search track based on query | Yes | | search_playlist | Yes | Search playlist based on query | Yes | | album_track | Yes | Get Spotify catalog information about an album's tracks | Yes | | user | No | Gets basic profile information about a Spotify User | No | | playlist | Yes | Get full details about Spotify playlist | Yes | | current_user_playlists | Yes | Get current user playlists without required getting his profile | Yes | | user_playlists | No | Gets playlists of a user | No | | user_playlist | No | Gets playlist of a user | No | | user_playlist_tracks | Yes | Get full details of the tracks of a playlist owned by a user | Yes | | user_playlist_create | No | Creates a playlist for a user | Yes | | user_playlist_change_detail | No | Changes a playlist's name and/or public/private state | Yes | | user_playlist_unfollow | Yes | Unfollows (deletes) a playlist for a user | Yes | | user_playlist_add_track | No | Adds tracks to a playlist | Yes | | user_playlist_replace_track | No | Replace all tracks in a playlist | No | | user_playlist_recorder_tracks | No | Reorder tracks in a playlist | No | | user_playlist_remove_all_occurrences_of_track | No | Removes all occurrences of the given tracks from the given playlist | No | | user_playlist_remove_specific_occurrenes_of_track | No | Removes all occurrences of the given tracks from the given playlist | No | | user_playlist_follow_playlist | Yes | Add the current authenticated user as a follower of a playlist. | Yes | | user_playlist_check_follow | No | Check to see if the given users are following the given playlist | Yes | | me | No | Get detailed profile information about the current user. | Yes | | current_user | No | Alias for `me` | Yes | | current_user_playing_track | Yes | Get information about the current users currently playing track. | Yes | | current_user_saved_albums | Yes | Gets a list of the albums saved in the current authorized user's "Your Music" library | Yes | | current_user_saved_tracks | Yes | Gets the user's saved tracks or "Liked Songs" | Yes | | current_user_followed_artists | Yes | Gets a list of the artists followed by the current authorized user | Yes | | current_user_saved_tracks_delete | Yes | Remove one or more tracks from the current user's "Your Music" library. | Yes | | current_user_saved_tracks_contain | No | Check if one or more tracks is already saved in the current Spotify user’s “Your Music” library. | Yes | | current_user_saved_tracks_add | Yes | Save one or more tracks to the current user's "Your Music" library. | Yes | | current_user_top_artists | No | Get the current user's top artists | Yes | | current_user_top_tracks | No | Get the current user's top tracks | Yes | | current_user_recently_played | Yes | Get the current user's recently played tracks | Yes | | current_user_saved_albums_add | Yes | Add one or more albums to the current user's "Your Music" library. | Yes | | current_user_saved_albums_delete | Yes | Remove one or more albums from the current user's "Your Music" library. | Yes | | user_follow_artists | Yes | Follow one or more artists | Yes | | user_unfollow_artists | Yes | Unfollow one or more artists | Yes | | user_follow_users | No | Follow one or more users | No | | user_unfollow_users | No | Unfollow one or more users | No | | featured_playlists | No | Get a list of Spotify featured playlists | Yes | | new_releases | No | Get a list of new album releases featured in Spotify | Yes | | categories | No | Get a list of categories used to tag items in Spotify | Yes | | recommendations | Yes | Get Recommendations Based on Seeds | Yes | | audio_features | No | Get audio features for a track | No | | audios_features | No | Get Audio Features for Several Tracks | No | | audio_analysis | Yes | Get Audio Analysis for a Track | Yes | | device | Yes | Get a User’s Available Devices | Yes | | current_playback | Yes | Get Information About The User’s Current Playback | Yes | | current_playing | No | Get the User’s Currently Playing Track | No | | transfer_playback | Yes | Transfer a User’s Playback | Yes | | start_playback | Yes | Start/Resume a User’s Playback | Yes | | pause_playback | Yes | Pause a User’s Playback | Yes | | next_track | Yes | Skip User’s Playback To Next Track | Yes | | previous_track | Yes | Skip User’s Playback To Previous Track | Yes | | seek_track | Yes | Seek To Position In Currently Playing Track | Yes | | repeat | Yes | Set Repeat Mode On User’s Playback | Yes | | volume | Yes | Set Volume For User’s Playback | Yes | | shuffle | Yes | Toggle Shuffle For User’s Playback | Yes | ================================================ FILE: how_to_release.md ================================================ # To create a release The releases are automated via GitHub actions, using [this configuration file](https://github.com/Rigellute/spotify-tui/blob/master/.github/workflows/cd.yml). The release is triggered by pushing a tag. 1. Bump the version in `Cargo.toml` and run the app to update the `lock` file 1. Update the "Unreleased" header for the new version in the `CHANGELOG`. Use `### Added/Fixed/Changed` headers as appropriate 1. Commit the changes and push them. 1. Create a new tag e.g. `git tag -a v0.7.0` and add the CHANGELOG to the commit body 1. Push the tag `git push --tags` 1. Wait for the build to finish on the [Actions page](https://github.com/Rigellute/spotify-tui/actions) 1. This should publish to cargo as well ### Update `brew` 1. `cd` to the [`tap` repo](https://github.com/Rigellute/homebrew-tap) 1. Run script to update the Formula `sh scripts/spotify-tui.sh $VERSION` ### Update `scoop` (Windows 10) 1. `cd` to [the `scoop` repo](https://github.com/Rigellute/scoop-bucket) 1. Run the script to update the manifest `sh scripts/spotify-tui.sh $VERSION` ================================================ FILE: rustfmt.toml ================================================ tab_spaces=2 edition = "2018" ================================================ FILE: snap/gui/spt.desktop ================================================ [Desktop Entry] Name=Spotify TUI Exec=spt Comment=Spotify for the terminal written in Rust Icon=${SNAP}/meta/gui/spt.png Type=Application Terminal=true StartupNotify=false Categories=Music; ================================================ FILE: snap/snapcraft.yaml ================================================ name: spt-temp base: core18 adopt-info: spotify-tui summary: Spotify TUI description: | Spotify for the terminal written in Rust grade: stable confinement: strict parts: spotify-tui: plugin: rust source: . build-packages: - libssl-dev - pkg-config - libxcb1-dev - libxcb-render0-dev - libxcb-shape0-dev - libxcb-xfixes0-dev stage-packages: - libxau6 - libxcb-render0 - libxcb-shape0 - libxcb-xfixes0 - libxcb1 - libxdmcp6 override-pull: | snapcraftctl pull last_committed_tag="$(git describe --tags --abbrev=0)" echo $last_committed_tag VERSION="$(git describe --first-parent --tags --always)" echo "Setting version to $VERSION" snapcraftctl set-version "${VERSION}" apps: spt-temp: environment: LD_LIBRARY_PATH: $SNAP/usr/lib/$SNAPCRAFT_ARCH_TRIPLET/pulseaudio:$LD_LIBRARY_PATH command: spt plugs: - desktop - network - network-bind - pulseaudio ================================================ FILE: src/app.rs ================================================ use super::user_config::UserConfig; use crate::network::IoEvent; use anyhow::anyhow; use rspotify::{ model::{ album::{FullAlbum, SavedAlbum, SimplifiedAlbum}, artist::FullArtist, audio::AudioAnalysis, context::CurrentlyPlaybackContext, device::DevicePayload, page::{CursorBasedPage, Page}, playing::PlayHistory, playlist::{PlaylistTrack, SimplifiedPlaylist}, show::{FullShow, Show, SimplifiedEpisode, SimplifiedShow}, track::{FullTrack, SavedTrack, SimplifiedTrack}, user::PrivateUser, PlayingItem, }, senum::Country, }; use std::str::FromStr; use std::sync::mpsc::Sender; use std::{ cmp::{max, min}, collections::HashSet, time::{Instant, SystemTime}, }; use tui::layout::Rect; use arboard::Clipboard; pub const LIBRARY_OPTIONS: [&str; 6] = [ "Made For You", "Recently Played", "Liked Songs", "Albums", "Artists", "Podcasts", ]; const DEFAULT_ROUTE: Route = Route { id: RouteId::Home, active_block: ActiveBlock::Empty, hovered_block: ActiveBlock::Library, }; #[derive(Clone)] pub struct ScrollableResultPages { index: usize, pub pages: Vec, } impl ScrollableResultPages { pub fn new() -> ScrollableResultPages { ScrollableResultPages { index: 0, pages: vec![], } } pub fn get_results(&self, at_index: Option) -> Option<&T> { self.pages.get(at_index.unwrap_or(self.index)) } pub fn get_mut_results(&mut self, at_index: Option) -> Option<&mut T> { self.pages.get_mut(at_index.unwrap_or(self.index)) } pub fn add_pages(&mut self, new_pages: T) { self.pages.push(new_pages); // Whenever a new page is added, set the active index to the end of the vector self.index = self.pages.len() - 1; } } #[derive(Default)] pub struct SpotifyResultAndSelectedIndex { pub index: usize, pub result: T, } #[derive(Clone)] pub struct Library { pub selected_index: usize, pub saved_tracks: ScrollableResultPages>, pub made_for_you_playlists: ScrollableResultPages>, pub saved_albums: ScrollableResultPages>, pub saved_shows: ScrollableResultPages>, pub saved_artists: ScrollableResultPages>, pub show_episodes: ScrollableResultPages>, } #[derive(PartialEq, Debug)] pub enum SearchResultBlock { AlbumSearch, SongSearch, ArtistSearch, PlaylistSearch, ShowSearch, Empty, } #[derive(PartialEq, Debug, Clone)] pub enum ArtistBlock { TopTracks, Albums, RelatedArtists, Empty, } #[derive(Clone, Copy, PartialEq, Debug)] pub enum DialogContext { PlaylistWindow, PlaylistSearch, } #[derive(Clone, Copy, PartialEq, Debug)] pub enum ActiveBlock { Analysis, PlayBar, AlbumTracks, AlbumList, ArtistBlock, Empty, Error, HelpMenu, Home, Input, Library, MyPlaylists, Podcasts, EpisodeTable, RecentlyPlayed, SearchResultBlock, SelectDevice, TrackTable, MadeForYou, Artists, BasicView, Dialog(DialogContext), } #[derive(Clone, PartialEq, Debug)] pub enum RouteId { Analysis, AlbumTracks, AlbumList, Artist, BasicView, Error, Home, RecentlyPlayed, Search, SelectedDevice, TrackTable, MadeForYou, Artists, Podcasts, PodcastEpisodes, Recommendations, Dialog, } #[derive(Debug)] pub struct Route { pub id: RouteId, pub active_block: ActiveBlock, pub hovered_block: ActiveBlock, } // Is it possible to compose enums? #[derive(PartialEq, Debug)] pub enum TrackTableContext { MyPlaylists, AlbumSearch, PlaylistSearch, SavedTracks, RecommendedTracks, MadeForYou, } // Is it possible to compose enums? #[derive(Clone, PartialEq, Debug, Copy)] pub enum AlbumTableContext { Simplified, Full, } #[derive(Clone, PartialEq, Debug, Copy)] pub enum EpisodeTableContext { Simplified, Full, } #[derive(Clone, PartialEq, Debug)] pub enum RecommendationsContext { Artist, Song, } pub struct SearchResult { pub albums: Option>, pub artists: Option>, pub playlists: Option>, pub tracks: Option>, pub shows: Option>, pub selected_album_index: Option, pub selected_artists_index: Option, pub selected_playlists_index: Option, pub selected_tracks_index: Option, pub selected_shows_index: Option, pub hovered_block: SearchResultBlock, pub selected_block: SearchResultBlock, } #[derive(Default)] pub struct TrackTable { pub tracks: Vec, pub selected_index: usize, pub context: Option, } #[derive(Clone)] pub struct SelectedShow { pub show: SimplifiedShow, } #[derive(Clone)] pub struct SelectedFullShow { pub show: FullShow, } #[derive(Clone)] pub struct SelectedAlbum { pub album: SimplifiedAlbum, pub tracks: Page, pub selected_index: usize, } #[derive(Clone)] pub struct SelectedFullAlbum { pub album: FullAlbum, pub selected_index: usize, } #[derive(Clone)] pub struct Artist { pub artist_name: String, pub albums: Page, pub related_artists: Vec, pub top_tracks: Vec, pub selected_album_index: usize, pub selected_related_artist_index: usize, pub selected_top_track_index: usize, pub artist_hovered_block: ArtistBlock, pub artist_selected_block: ArtistBlock, } pub struct App { pub instant_since_last_current_playback_poll: Instant, navigation_stack: Vec, pub audio_analysis: Option, pub home_scroll: u16, pub user_config: UserConfig, pub artists: Vec, pub artist: Option, pub album_table_context: AlbumTableContext, pub saved_album_tracks_index: usize, pub api_error: String, pub current_playback_context: Option, pub devices: Option, // Inputs: // input is the string for input; // input_idx is the index of the cursor in terms of character; // input_cursor_position is the sum of the width of characters preceding the cursor. // Reason for this complication is due to non-ASCII characters, they may // take more than 1 bytes to store and more than 1 character width to display. pub input: Vec, pub input_idx: usize, pub input_cursor_position: u16, pub liked_song_ids_set: HashSet, pub followed_artist_ids_set: HashSet, pub saved_album_ids_set: HashSet, pub saved_show_ids_set: HashSet, pub large_search_limit: u32, pub library: Library, pub playlist_offset: u32, pub made_for_you_offset: u32, pub playlist_tracks: Option>, pub made_for_you_tracks: Option>, pub playlists: Option>, pub recently_played: SpotifyResultAndSelectedIndex>>, pub recommended_tracks: Vec, pub recommendations_seed: String, pub recommendations_context: Option, pub search_results: SearchResult, pub selected_album_simplified: Option, pub selected_album_full: Option, pub selected_device_index: Option, pub selected_playlist_index: Option, pub active_playlist_index: Option, pub size: Rect, pub small_search_limit: u32, pub song_progress_ms: u128, pub seek_ms: Option, pub track_table: TrackTable, pub episode_table_context: EpisodeTableContext, pub selected_show_simplified: Option, pub selected_show_full: Option, pub user: Option, pub album_list_index: usize, pub made_for_you_index: usize, pub artists_list_index: usize, pub clipboard: Option, pub shows_list_index: usize, pub episode_list_index: usize, pub help_docs_size: u32, pub help_menu_page: u32, pub help_menu_max_lines: u32, pub help_menu_offset: u32, pub is_loading: bool, io_tx: Option>, pub is_fetching_current_playback: bool, pub spotify_token_expiry: SystemTime, pub dialog: Option, pub confirm: bool, } impl Default for App { fn default() -> Self { App { audio_analysis: None, album_table_context: AlbumTableContext::Full, album_list_index: 0, made_for_you_index: 0, artists_list_index: 0, shows_list_index: 0, episode_list_index: 0, artists: vec![], artist: None, user_config: UserConfig::new(), saved_album_tracks_index: 0, recently_played: Default::default(), size: Rect::default(), selected_album_simplified: None, selected_album_full: None, home_scroll: 0, library: Library { saved_tracks: ScrollableResultPages::new(), made_for_you_playlists: ScrollableResultPages::new(), saved_albums: ScrollableResultPages::new(), saved_shows: ScrollableResultPages::new(), saved_artists: ScrollableResultPages::new(), show_episodes: ScrollableResultPages::new(), selected_index: 0, }, liked_song_ids_set: HashSet::new(), followed_artist_ids_set: HashSet::new(), saved_album_ids_set: HashSet::new(), saved_show_ids_set: HashSet::new(), navigation_stack: vec![DEFAULT_ROUTE], large_search_limit: 20, small_search_limit: 4, api_error: String::new(), current_playback_context: None, devices: None, input: vec![], input_idx: 0, input_cursor_position: 0, playlist_offset: 0, made_for_you_offset: 0, playlist_tracks: None, made_for_you_tracks: None, playlists: None, recommended_tracks: vec![], recommendations_context: None, recommendations_seed: "".to_string(), search_results: SearchResult { hovered_block: SearchResultBlock::SongSearch, selected_block: SearchResultBlock::Empty, albums: None, artists: None, playlists: None, shows: None, selected_album_index: None, selected_artists_index: None, selected_playlists_index: None, selected_tracks_index: None, selected_shows_index: None, tracks: None, }, song_progress_ms: 0, seek_ms: None, selected_device_index: None, selected_playlist_index: None, active_playlist_index: None, track_table: Default::default(), episode_table_context: EpisodeTableContext::Full, selected_show_simplified: None, selected_show_full: None, user: None, instant_since_last_current_playback_poll: Instant::now(), clipboard: Clipboard::new().ok(), help_docs_size: 0, help_menu_page: 0, help_menu_max_lines: 0, help_menu_offset: 0, is_loading: false, io_tx: None, is_fetching_current_playback: false, spotify_token_expiry: SystemTime::now(), dialog: None, confirm: false, } } } impl App { pub fn new( io_tx: Sender, user_config: UserConfig, spotify_token_expiry: SystemTime, ) -> App { App { io_tx: Some(io_tx), user_config, spotify_token_expiry, ..App::default() } } // Send a network event to the network thread pub fn dispatch(&mut self, action: IoEvent) { // `is_loading` will be set to false again after the async action has finished in network.rs self.is_loading = true; if let Some(io_tx) = &self.io_tx { if let Err(e) = io_tx.send(action) { self.is_loading = false; println!("Error from dispatch {}", e); // TODO: handle error }; } } fn apply_seek(&mut self, seek_ms: u32) { if let Some(CurrentlyPlaybackContext { item: Some(item), .. }) = &self.current_playback_context { let duration_ms = match item { PlayingItem::Track(track) => track.duration_ms, PlayingItem::Episode(episode) => episode.duration_ms, }; let event = if seek_ms < duration_ms { IoEvent::Seek(seek_ms) } else { IoEvent::NextTrack }; self.dispatch(event); } } fn poll_current_playback(&mut self) { // Poll every 5 seconds let poll_interval_ms = 5_000; let elapsed = self .instant_since_last_current_playback_poll .elapsed() .as_millis(); if !self.is_fetching_current_playback && elapsed >= poll_interval_ms { self.is_fetching_current_playback = true; // Trigger the seek if the user has set a new position match self.seek_ms { Some(seek_ms) => self.apply_seek(seek_ms as u32), None => self.dispatch(IoEvent::GetCurrentPlayback), } } } pub fn update_on_tick(&mut self) { self.poll_current_playback(); if let Some(CurrentlyPlaybackContext { item: Some(item), progress_ms: Some(progress_ms), is_playing, .. }) = &self.current_playback_context { // Update progress even when the song is not playing, // because seeking is possible while paused let elapsed = if *is_playing { self .instant_since_last_current_playback_poll .elapsed() .as_millis() } else { 0u128 } + u128::from(*progress_ms); let duration_ms = match item { PlayingItem::Track(track) => track.duration_ms, PlayingItem::Episode(episode) => episode.duration_ms, }; if elapsed < u128::from(duration_ms) { self.song_progress_ms = elapsed; } else { self.song_progress_ms = duration_ms.into(); } } } pub fn seek_forwards(&mut self) { if let Some(CurrentlyPlaybackContext { item: Some(item), .. }) = &self.current_playback_context { let duration_ms = match item { PlayingItem::Track(track) => track.duration_ms, PlayingItem::Episode(episode) => episode.duration_ms, }; let old_progress = match self.seek_ms { Some(seek_ms) => seek_ms, None => self.song_progress_ms, }; let new_progress = min( old_progress as u32 + self.user_config.behavior.seek_milliseconds, duration_ms, ); self.seek_ms = Some(new_progress as u128); } } pub fn seek_backwards(&mut self) { let old_progress = match self.seek_ms { Some(seek_ms) => seek_ms, None => self.song_progress_ms, }; let new_progress = if old_progress as u32 > self.user_config.behavior.seek_milliseconds { old_progress as u32 - self.user_config.behavior.seek_milliseconds } else { 0u32 }; self.seek_ms = Some(new_progress as u128); } pub fn get_recommendations_for_seed( &mut self, seed_artists: Option>, seed_tracks: Option>, first_track: Option, ) { let user_country = self.get_user_country(); self.dispatch(IoEvent::GetRecommendationsForSeed( seed_artists, seed_tracks, Box::new(first_track), user_country, )); } pub fn get_recommendations_for_track_id(&mut self, id: String) { let user_country = self.get_user_country(); self.dispatch(IoEvent::GetRecommendationsForTrackId(id, user_country)); } pub fn increase_volume(&mut self) { if let Some(context) = self.current_playback_context.clone() { let current_volume = context.device.volume_percent as u8; let next_volume = min( current_volume + self.user_config.behavior.volume_increment, 100, ); if next_volume != current_volume { self.dispatch(IoEvent::ChangeVolume(next_volume)); } } } pub fn decrease_volume(&mut self) { if let Some(context) = self.current_playback_context.clone() { let current_volume = context.device.volume_percent as i8; let next_volume = max( current_volume - self.user_config.behavior.volume_increment as i8, 0, ); if next_volume != current_volume { self.dispatch(IoEvent::ChangeVolume(next_volume as u8)); } } } pub fn handle_error(&mut self, e: anyhow::Error) { self.push_navigation_stack(RouteId::Error, ActiveBlock::Error); self.api_error = e.to_string(); } pub fn toggle_playback(&mut self) { if let Some(CurrentlyPlaybackContext { is_playing: true, .. }) = &self.current_playback_context { self.dispatch(IoEvent::PausePlayback); } else { // When no offset or uris are passed, spotify will resume current playback self.dispatch(IoEvent::StartPlayback(None, None, None)); } } pub fn previous_track(&mut self) { if self.song_progress_ms >= 3_000 { self.dispatch(IoEvent::Seek(0)); } else { self.dispatch(IoEvent::PreviousTrack); } } // The navigation_stack actually only controls the large block to the right of `library` and // `playlists` pub fn push_navigation_stack(&mut self, next_route_id: RouteId, next_active_block: ActiveBlock) { if !self .navigation_stack .last() .map(|last_route| last_route.id == next_route_id) .unwrap_or(false) { self.navigation_stack.push(Route { id: next_route_id, active_block: next_active_block, hovered_block: next_active_block, }); } } pub fn pop_navigation_stack(&mut self) -> Option { if self.navigation_stack.len() == 1 { None } else { self.navigation_stack.pop() } } pub fn get_current_route(&self) -> &Route { // if for some reason there is no route return the default self.navigation_stack.last().unwrap_or(&DEFAULT_ROUTE) } fn get_current_route_mut(&mut self) -> &mut Route { self.navigation_stack.last_mut().unwrap() } pub fn set_current_route_state( &mut self, active_block: Option, hovered_block: Option, ) { let mut current_route = self.get_current_route_mut(); if let Some(active_block) = active_block { current_route.active_block = active_block; } if let Some(hovered_block) = hovered_block { current_route.hovered_block = hovered_block; } } pub fn copy_song_url(&mut self) { let clipboard = match &mut self.clipboard { Some(ctx) => ctx, None => return, }; if let Some(CurrentlyPlaybackContext { item: Some(item), .. }) = &self.current_playback_context { match item { PlayingItem::Track(track) => { if let Err(e) = clipboard.set_text(format!( "https://open.spotify.com/track/{}", track.id.to_owned().unwrap_or_default() )) { self.handle_error(anyhow!("failed to set clipboard content: {}", e)); } } PlayingItem::Episode(episode) => { if let Err(e) = clipboard.set_text(format!( "https://open.spotify.com/episode/{}", episode.id.to_owned() )) { self.handle_error(anyhow!("failed to set clipboard content: {}", e)); } } } } } pub fn copy_album_url(&mut self) { let clipboard = match &mut self.clipboard { Some(ctx) => ctx, None => return, }; if let Some(CurrentlyPlaybackContext { item: Some(item), .. }) = &self.current_playback_context { match item { PlayingItem::Track(track) => { if let Err(e) = clipboard.set_text(format!( "https://open.spotify.com/album/{}", track.album.id.to_owned().unwrap_or_default() )) { self.handle_error(anyhow!("failed to set clipboard content: {}", e)); } } PlayingItem::Episode(episode) => { if let Err(e) = clipboard.set_text(format!( "https://open.spotify.com/show/{}", episode.show.id.to_owned() )) { self.handle_error(anyhow!("failed to set clipboard content: {}", e)); } } } } } pub fn set_saved_tracks_to_table(&mut self, saved_track_page: &Page) { self.dispatch(IoEvent::SetTracksToTable( saved_track_page .items .clone() .into_iter() .map(|item| item.track) .collect::>(), )); } pub fn set_saved_artists_to_table(&mut self, saved_artists_page: &CursorBasedPage) { self.dispatch(IoEvent::SetArtistsToTable( saved_artists_page .items .clone() .into_iter() .collect::>(), )) } pub fn get_current_user_saved_artists_next(&mut self) { match self .library .saved_artists .get_results(Some(self.library.saved_artists.index + 1)) .cloned() { Some(saved_artists) => { self.set_saved_artists_to_table(&saved_artists); self.library.saved_artists.index += 1 } None => { if let Some(saved_artists) = &self.library.saved_artists.clone().get_results(None) { if let Some(last_artist) = saved_artists.items.last() { self.dispatch(IoEvent::GetFollowedArtists(Some(last_artist.id.clone()))); } } } } } pub fn get_current_user_saved_artists_previous(&mut self) { if self.library.saved_artists.index > 0 { self.library.saved_artists.index -= 1; } if let Some(saved_artists) = &self.library.saved_artists.get_results(None).cloned() { self.set_saved_artists_to_table(saved_artists); } } pub fn get_current_user_saved_tracks_next(&mut self) { // Before fetching the next tracks, check if we have already fetched them match self .library .saved_tracks .get_results(Some(self.library.saved_tracks.index + 1)) .cloned() { Some(saved_tracks) => { self.set_saved_tracks_to_table(&saved_tracks); self.library.saved_tracks.index += 1 } None => { if let Some(saved_tracks) = &self.library.saved_tracks.get_results(None) { let offset = Some(saved_tracks.offset + saved_tracks.limit); self.dispatch(IoEvent::GetCurrentSavedTracks(offset)); } } } } pub fn get_current_user_saved_tracks_previous(&mut self) { if self.library.saved_tracks.index > 0 { self.library.saved_tracks.index -= 1; } if let Some(saved_tracks) = &self.library.saved_tracks.get_results(None).cloned() { self.set_saved_tracks_to_table(saved_tracks); } } pub fn shuffle(&mut self) { if let Some(context) = &self.current_playback_context.clone() { self.dispatch(IoEvent::Shuffle(context.shuffle_state)); }; } pub fn get_current_user_saved_albums_next(&mut self) { match self .library .saved_albums .get_results(Some(self.library.saved_albums.index + 1)) .cloned() { Some(_) => self.library.saved_albums.index += 1, None => { if let Some(saved_albums) = &self.library.saved_albums.get_results(None) { let offset = Some(saved_albums.offset + saved_albums.limit); self.dispatch(IoEvent::GetCurrentUserSavedAlbums(offset)); } } } } pub fn get_current_user_saved_albums_previous(&mut self) { if self.library.saved_albums.index > 0 { self.library.saved_albums.index -= 1; } } pub fn current_user_saved_album_delete(&mut self, block: ActiveBlock) { match block { ActiveBlock::SearchResultBlock => { if let Some(albums) = &self.search_results.albums { if let Some(selected_index) = self.search_results.selected_album_index { let selected_album = &albums.items[selected_index]; if let Some(album_id) = selected_album.id.clone() { self.dispatch(IoEvent::CurrentUserSavedAlbumDelete(album_id)); } } } } ActiveBlock::AlbumList => { if let Some(albums) = self.library.saved_albums.get_results(None) { if let Some(selected_album) = albums.items.get(self.album_list_index) { let album_id = selected_album.album.id.clone(); self.dispatch(IoEvent::CurrentUserSavedAlbumDelete(album_id)); } } } ActiveBlock::ArtistBlock => { if let Some(artist) = &self.artist { if let Some(selected_album) = artist.albums.items.get(artist.selected_album_index) { if let Some(album_id) = selected_album.id.clone() { self.dispatch(IoEvent::CurrentUserSavedAlbumDelete(album_id)); } } } } _ => (), } } pub fn current_user_saved_album_add(&mut self, block: ActiveBlock) { match block { ActiveBlock::SearchResultBlock => { if let Some(albums) = &self.search_results.albums { if let Some(selected_index) = self.search_results.selected_album_index { let selected_album = &albums.items[selected_index]; if let Some(album_id) = selected_album.id.clone() { self.dispatch(IoEvent::CurrentUserSavedAlbumAdd(album_id)); } } } } ActiveBlock::ArtistBlock => { if let Some(artist) = &self.artist { if let Some(selected_album) = artist.albums.items.get(artist.selected_album_index) { if let Some(album_id) = selected_album.id.clone() { self.dispatch(IoEvent::CurrentUserSavedAlbumAdd(album_id)); } } } } _ => (), } } pub fn get_current_user_saved_shows_next(&mut self) { match self .library .saved_shows .get_results(Some(self.library.saved_shows.index + 1)) .cloned() { Some(_) => self.library.saved_shows.index += 1, None => { if let Some(saved_shows) = &self.library.saved_shows.get_results(None) { let offset = Some(saved_shows.offset + saved_shows.limit); self.dispatch(IoEvent::GetCurrentUserSavedShows(offset)); } } } } pub fn get_current_user_saved_shows_previous(&mut self) { if self.library.saved_shows.index > 0 { self.library.saved_shows.index -= 1; } } pub fn get_episode_table_next(&mut self, show_id: String) { match self .library .show_episodes .get_results(Some(self.library.show_episodes.index + 1)) .cloned() { Some(_) => self.library.show_episodes.index += 1, None => { if let Some(show_episodes) = &self.library.show_episodes.get_results(None) { let offset = Some(show_episodes.offset + show_episodes.limit); self.dispatch(IoEvent::GetCurrentShowEpisodes(show_id, offset)); } } } } pub fn get_episode_table_previous(&mut self) { if self.library.show_episodes.index > 0 { self.library.show_episodes.index -= 1; } } pub fn user_unfollow_artists(&mut self, block: ActiveBlock) { match block { ActiveBlock::SearchResultBlock => { if let Some(artists) = &self.search_results.artists { if let Some(selected_index) = self.search_results.selected_artists_index { let selected_artist: &FullArtist = &artists.items[selected_index]; let artist_id = selected_artist.id.clone(); self.dispatch(IoEvent::UserUnfollowArtists(vec![artist_id])); } } } ActiveBlock::AlbumList => { if let Some(artists) = self.library.saved_artists.get_results(None) { if let Some(selected_artist) = artists.items.get(self.artists_list_index) { let artist_id = selected_artist.id.clone(); self.dispatch(IoEvent::UserUnfollowArtists(vec![artist_id])); } } } ActiveBlock::ArtistBlock => { if let Some(artist) = &self.artist { let selected_artis = &artist.related_artists[artist.selected_related_artist_index]; let artist_id = selected_artis.id.clone(); self.dispatch(IoEvent::UserUnfollowArtists(vec![artist_id])); } } _ => (), }; } pub fn user_follow_artists(&mut self, block: ActiveBlock) { match block { ActiveBlock::SearchResultBlock => { if let Some(artists) = &self.search_results.artists { if let Some(selected_index) = self.search_results.selected_artists_index { let selected_artist: &FullArtist = &artists.items[selected_index]; let artist_id = selected_artist.id.clone(); self.dispatch(IoEvent::UserFollowArtists(vec![artist_id])); } } } ActiveBlock::ArtistBlock => { if let Some(artist) = &self.artist { let selected_artis = &artist.related_artists[artist.selected_related_artist_index]; let artist_id = selected_artis.id.clone(); self.dispatch(IoEvent::UserFollowArtists(vec![artist_id])); } } _ => (), } } pub fn user_follow_playlist(&mut self) { if let SearchResult { playlists: Some(ref playlists), selected_playlists_index: Some(selected_index), .. } = self.search_results { let selected_playlist: &SimplifiedPlaylist = &playlists.items[selected_index]; let selected_id = selected_playlist.id.clone(); let selected_public = selected_playlist.public; let selected_owner_id = selected_playlist.owner.id.clone(); self.dispatch(IoEvent::UserFollowPlaylist( selected_owner_id, selected_id, selected_public, )); } } pub fn user_unfollow_playlist(&mut self) { if let (Some(playlists), Some(selected_index), Some(user)) = (&self.playlists, self.selected_playlist_index, &self.user) { let selected_playlist = &playlists.items[selected_index]; let selected_id = selected_playlist.id.clone(); let user_id = user.id.clone(); self.dispatch(IoEvent::UserUnfollowPlaylist(user_id, selected_id)) } } pub fn user_unfollow_playlist_search_result(&mut self) { if let (Some(playlists), Some(selected_index), Some(user)) = ( &self.search_results.playlists, self.search_results.selected_playlists_index, &self.user, ) { let selected_playlist = &playlists.items[selected_index]; let selected_id = selected_playlist.id.clone(); let user_id = user.id.clone(); self.dispatch(IoEvent::UserUnfollowPlaylist(user_id, selected_id)) } } pub fn user_follow_show(&mut self, block: ActiveBlock) { match block { ActiveBlock::SearchResultBlock => { if let Some(shows) = &self.search_results.shows { if let Some(selected_index) = self.search_results.selected_shows_index { if let Some(show_id) = shows.items.get(selected_index).map(|item| item.id.clone()) { self.dispatch(IoEvent::CurrentUserSavedShowAdd(show_id)); } } } } ActiveBlock::EpisodeTable => match self.episode_table_context { EpisodeTableContext::Full => { if let Some(selected_episode) = self.selected_show_full.clone() { let show_id = selected_episode.show.id; self.dispatch(IoEvent::CurrentUserSavedShowAdd(show_id)); } } EpisodeTableContext::Simplified => { if let Some(selected_episode) = self.selected_show_simplified.clone() { let show_id = selected_episode.show.id; self.dispatch(IoEvent::CurrentUserSavedShowAdd(show_id)); } } }, _ => (), } } pub fn user_unfollow_show(&mut self, block: ActiveBlock) { match block { ActiveBlock::Podcasts => { if let Some(shows) = self.library.saved_shows.get_results(None) { if let Some(selected_show) = shows.items.get(self.shows_list_index) { let show_id = selected_show.show.id.clone(); self.dispatch(IoEvent::CurrentUserSavedShowDelete(show_id)); } } } ActiveBlock::SearchResultBlock => { if let Some(shows) = &self.search_results.shows { if let Some(selected_index) = self.search_results.selected_shows_index { let show_id = shows.items[selected_index].id.to_owned(); self.dispatch(IoEvent::CurrentUserSavedShowDelete(show_id)); } } } ActiveBlock::EpisodeTable => match self.episode_table_context { EpisodeTableContext::Full => { if let Some(selected_episode) = self.selected_show_full.clone() { let show_id = selected_episode.show.id; self.dispatch(IoEvent::CurrentUserSavedShowDelete(show_id)); } } EpisodeTableContext::Simplified => { if let Some(selected_episode) = self.selected_show_simplified.clone() { let show_id = selected_episode.show.id; self.dispatch(IoEvent::CurrentUserSavedShowDelete(show_id)); } } }, _ => (), } } pub fn get_made_for_you(&mut self) { // TODO: replace searches when relevant endpoint is added const DISCOVER_WEEKLY: &str = "Discover Weekly"; const RELEASE_RADAR: &str = "Release Radar"; const ON_REPEAT: &str = "On Repeat"; const REPEAT_REWIND: &str = "Repeat Rewind"; const DAILY_DRIVE: &str = "Daily Drive"; if self.library.made_for_you_playlists.pages.is_empty() { // We shouldn't be fetching all the results immediately - only load the data when the // user selects the playlist self.made_for_you_search_and_add(DISCOVER_WEEKLY); self.made_for_you_search_and_add(RELEASE_RADAR); self.made_for_you_search_and_add(ON_REPEAT); self.made_for_you_search_and_add(REPEAT_REWIND); self.made_for_you_search_and_add(DAILY_DRIVE); } } fn made_for_you_search_and_add(&mut self, search_string: &str) { let user_country = self.get_user_country(); self.dispatch(IoEvent::MadeForYouSearchAndAdd( search_string.to_string(), user_country, )); } pub fn get_audio_analysis(&mut self) { if let Some(CurrentlyPlaybackContext { item: Some(item), .. }) = &self.current_playback_context { match item { PlayingItem::Track(track) => { if self.get_current_route().id != RouteId::Analysis { let uri = track.uri.clone(); self.dispatch(IoEvent::GetAudioAnalysis(uri)); self.push_navigation_stack(RouteId::Analysis, ActiveBlock::Analysis); } } PlayingItem::Episode(_episode) => { // No audio analysis available for podcast uris, so just default to the empty analysis // view to avoid a 400 error code self.push_navigation_stack(RouteId::Analysis, ActiveBlock::Analysis); } } } } pub fn repeat(&mut self) { if let Some(context) = &self.current_playback_context.clone() { self.dispatch(IoEvent::Repeat(context.repeat_state)); } } pub fn get_artist(&mut self, artist_id: String, input_artist_name: String) { let user_country = self.get_user_country(); self.dispatch(IoEvent::GetArtist( artist_id, input_artist_name, user_country, )); } pub fn get_user_country(&self) -> Option { self .user .to_owned() .and_then(|user| Country::from_str(&user.country.unwrap_or_else(|| "".to_string())).ok()) } pub fn calculate_help_menu_offset(&mut self) { let old_offset = self.help_menu_offset; if self.help_menu_max_lines < self.help_docs_size { self.help_menu_offset = self.help_menu_page * self.help_menu_max_lines; } if self.help_menu_offset > self.help_docs_size { self.help_menu_offset = old_offset; self.help_menu_page -= 1; } } } ================================================ FILE: src/banner.rs ================================================ pub const BANNER: &str = " _________ ____ / /_(_) __/_ __ / /___ __(_) / ___/ __ \\/ __ \\/ __/ / /_/ / / /_____/ __/ / / / / (__ ) /_/ / /_/ / /_/ / __/ /_/ /_____/ /_/ /_/ / / /____/ .___/\\____/\\__/_/_/ \\__, / \\__/\\__,_/_/ /_/ /____/ "; ================================================ FILE: src/cli/clap.rs ================================================ use clap::{App, Arg, ArgGroup, SubCommand}; fn device_arg() -> Arg<'static, 'static> { Arg::with_name("device") .short("d") .long("device") .takes_value(true) .value_name("DEVICE") .help("Specifies the spotify device to use") } fn format_arg() -> Arg<'static, 'static> { Arg::with_name("format") .short("f") .long("format") .takes_value(true) .value_name("FORMAT") .help("Specifies the output format") .long_help( "There are multiple format specifiers you can use: %a: artist, %b: album, %p: playlist, \ %t: track, %h: show, %f: flags (shuffle, repeat, like), %s: playback status, %v: volume, %d: current device. \ Example: spt pb -s -f 'playing on %d at %v%'", ) } pub fn playback_subcommand() -> App<'static, 'static> { SubCommand::with_name("playback") .version(env!("CARGO_PKG_VERSION")) .author(env!("CARGO_PKG_AUTHORS")) .about("Interacts with the playback of a device") .long_about( "Use `playback` to interact with the playback of the current or any other device. \ You can specify another device with `--device`. If no options were provided, spt \ will default to just displaying the current playback. Actually, after every action \ spt will display the updated playback. The output format is configurable with the \ `--format` flag. Some options can be used together, other options have to be alone. Here's a list: * `--next` and `--previous` cannot be used with other options * `--status`, `--toggle`, `--transfer`, `--volume`, `--like`, `--repeat` and `--shuffle` \ can be used together * `--share-track` and `--share-album` cannot be used with other options", ) .visible_alias("pb") .arg(device_arg()) .arg( format_arg() .default_value("%f %s %t - %a") .default_value_ifs(&[ ("seek", None, "%f %s %t - %a %r"), ("volume", None, "%v% %f %s %t - %a"), ("transfer", None, "%f %s %t - %a on %d"), ]), ) .arg( Arg::with_name("toggle") .short("t") .long("toggle") .help("Pauses/resumes the playback of a device"), ) .arg( Arg::with_name("status") .short("s") .long("status") .help("Prints out the current status of a device (default)"), ) .arg( Arg::with_name("share-track") .long("share-track") .help("Returns the url to the current track"), ) .arg( Arg::with_name("share-album") .long("share-album") .help("Returns the url to the album of the current track"), ) .arg( Arg::with_name("transfer") .long("transfer") .takes_value(true) .value_name("DEVICE") .help("Transfers the playback to new DEVICE"), ) .arg( Arg::with_name("like") .long("like") .help("Likes the current song if possible"), ) .arg( Arg::with_name("dislike") .long("dislike") .help("Dislikes the current song if possible"), ) .arg( Arg::with_name("shuffle") .long("shuffle") .help("Toggles shuffle mode"), ) .arg( Arg::with_name("repeat") .long("repeat") .help("Switches between repeat modes"), ) .arg( Arg::with_name("next") .short("n") .long("next") .multiple(true) .help("Jumps to the next song") .long_help( "This jumps to the next song if specied once. If you want to jump, let's say 3 songs \ forward, you can use `--next` 3 times: `spt pb -nnn`.", ), ) .arg( Arg::with_name("previous") .short("p") .long("previous") .multiple(true) .help("Jumps to the previous song") .long_help( "This jumps to the beginning of the current song if specied once. You probably want to \ jump to the previous song though, so you can use the previous flag twice: `spt pb -pp`. To jump \ two songs back, you can use `spt pb -ppp` and so on.", ), ) .arg( Arg::with_name("seek") .long("seek") .takes_value(true) .value_name("±SECONDS") .allow_hyphen_values(true) .help("Jumps SECONDS forwards (+) or backwards (-)") .long_help( "For example: `spt pb --seek +10` jumps ten second forwards, `spt pb --seek -10` ten \ seconds backwards and `spt pb --seek 10` to the tenth second of the track.", ), ) .arg( Arg::with_name("volume") .short("v") .long("volume") .takes_value(true) .value_name("VOLUME") .help("Sets the volume of a device to VOLUME (1 - 100)"), ) .group( ArgGroup::with_name("jumps") .args(&["next", "previous"]) .multiple(false) .conflicts_with_all(&["single", "flags", "actions"]), ) .group( ArgGroup::with_name("likes") .args(&["like", "dislike"]) .multiple(false), ) .group( ArgGroup::with_name("flags") .args(&["like", "dislike", "shuffle", "repeat"]) .multiple(true) .conflicts_with_all(&["single", "jumps"]), ) .group( ArgGroup::with_name("actions") .args(&["toggle", "status", "transfer", "volume"]) .multiple(true) .conflicts_with_all(&["single", "jumps"]), ) .group( ArgGroup::with_name("single") .args(&["share-track", "share-album"]) .multiple(false) .conflicts_with_all(&["actions", "flags", "jumps"]), ) } pub fn play_subcommand() -> App<'static, 'static> { SubCommand::with_name("play") .version(env!("CARGO_PKG_VERSION")) .author(env!("CARGO_PKG_AUTHORS")) .about("Plays a uri or another spotify item by name") .long_about( "If you specify a uri, the type can be inferred. If you want to play something by \ name, you have to specify the type: `--track`, `--album`, `--artist`, `--playlist` \ or `--show`. The first item which was found will be played without confirmation. \ To add a track to the queue, use `--queue`. To play a random song from a playlist, \ use `--random`. Again, with `--format` you can specify how the output will look. \ The same function as found in `playback` will be called.", ) .visible_alias("p") .arg(device_arg()) .arg(format_arg().default_value("%f %s %t - %a")) .arg( Arg::with_name("uri") .short("u") .long("uri") .takes_value(true) .value_name("URI") .help("Plays the URI"), ) .arg( Arg::with_name("name") .short("n") .long("name") .takes_value(true) .value_name("NAME") .requires("contexts") .help("Plays the first match with NAME from the specified category"), ) .arg( Arg::with_name("queue") .short("q") .long("queue") // Only works with tracks .conflicts_with_all(&["album", "artist", "playlist", "show"]) .help("Adds track to queue instead of playing it directly"), ) .arg( Arg::with_name("random") .short("r") .long("random") // Only works with playlists .conflicts_with_all(&["track", "album", "artist", "show"]) .help("Plays a random track (only works with playlists)"), ) .arg( Arg::with_name("album") .short("b") .long("album") .help("Looks for an album"), ) .arg( Arg::with_name("artist") .short("a") .long("artist") .help("Looks for an artist"), ) .arg( Arg::with_name("track") .short("t") .long("track") .help("Looks for a track"), ) .arg( Arg::with_name("show") .short("w") .long("show") .help("Looks for a show"), ) .arg( Arg::with_name("playlist") .short("p") .long("playlist") .help("Looks for a playlist"), ) .group( ArgGroup::with_name("contexts") .args(&["track", "artist", "playlist", "album", "show"]) .multiple(false), ) .group( ArgGroup::with_name("actions") .args(&["uri", "name"]) .multiple(false) .required(true), ) } pub fn list_subcommand() -> App<'static, 'static> { SubCommand::with_name("list") .version(env!("CARGO_PKG_VERSION")) .author(env!("CARGO_PKG_AUTHORS")) .about("Lists devices, liked songs and playlists") .long_about( "This will list devices, liked songs or playlists. With the `--limit` flag you are \ able to specify the amount of results (between 1 and 50). Here, the `--format` is \ even more awesome, get your output exactly the way you want. The format option will \ be applied to every item found.", ) .visible_alias("l") .arg(format_arg().default_value_ifs(&[ ("devices", None, "%v% %d"), ("liked", None, "%t - %a (%u)"), ("playlists", None, "%p (%u)"), ])) .arg( Arg::with_name("devices") .short("d") .long("devices") .help("Lists devices"), ) .arg( Arg::with_name("playlists") .short("p") .long("playlists") .help("Lists playlists"), ) .arg( Arg::with_name("liked") .long("liked") .help("Lists liked songs"), ) .arg( Arg::with_name("limit") .long("limit") .takes_value(true) .help("Specifies the maximum number of results (1 - 50)"), ) .group( ArgGroup::with_name("listable") .args(&["devices", "playlists", "liked"]) .required(true) .multiple(false), ) } pub fn search_subcommand() -> App<'static, 'static> { SubCommand::with_name("search") .version(env!("CARGO_PKG_VERSION")) .author(env!("CARGO_PKG_AUTHORS")) .about("Searches for tracks, albums and more") .long_about( "This will search for something on spotify and displays you the items. The output \ format can be changed with the `--format` flag and the limit can be changed with \ the `--limit` flag (between 1 and 50). The type can't be inferred, so you have to \ specify it.", ) .visible_alias("s") .arg(format_arg().default_value_ifs(&[ ("tracks", None, "%t - %a (%u)"), ("playlists", None, "%p (%u)"), ("artists", None, "%a (%u)"), ("albums", None, "%b - %a (%u)"), ("shows", None, "%h - %a (%u)"), ])) .arg( Arg::with_name("search") .required(true) .takes_value(true) .value_name("SEARCH") .help("Specifies the search query"), ) .arg( Arg::with_name("albums") .short("b") .long("albums") .help("Looks for albums"), ) .arg( Arg::with_name("artists") .short("a") .long("artists") .help("Looks for artists"), ) .arg( Arg::with_name("playlists") .short("p") .long("playlists") .help("Looks for playlists"), ) .arg( Arg::with_name("tracks") .short("t") .long("tracks") .help("Looks for tracks"), ) .arg( Arg::with_name("shows") .short("w") .long("shows") .help("Looks for shows"), ) .arg( Arg::with_name("limit") .long("limit") .takes_value(true) .help("Specifies the maximum number of results (1 - 50)"), ) .group( ArgGroup::with_name("searchable") .args(&["playlists", "tracks", "albums", "artists", "shows"]) .required(true) .multiple(false), ) } ================================================ FILE: src/cli/cli_app.rs ================================================ use crate::network::{IoEvent, Network}; use crate::user_config::UserConfig; use super::util::{Flag, Format, FormatType, JumpDirection, Type}; use anyhow::{anyhow, Result}; use rand::{thread_rng, Rng}; use rspotify::model::{context::CurrentlyPlaybackContext, PlayingItem}; pub struct CliApp<'a> { pub net: Network<'a>, pub config: UserConfig, } // Non-concurrent functions // I feel that async in a cli is not working // I just .await all processes and directly interact // by calling network.handle_network_event impl<'a> CliApp<'a> { pub fn new(net: Network<'a>, config: UserConfig) -> Self { Self { net, config } } async fn is_a_saved_track(&mut self, id: &str) -> bool { // Update the liked_song_ids_set self .net .handle_network_event(IoEvent::CurrentUserSavedTracksContains( vec![id.to_string()], )) .await; self.net.app.lock().await.liked_song_ids_set.contains(id) } pub fn format_output(&self, mut format: String, values: Vec) -> String { for val in values { format = format.replace(val.get_placeholder(), &val.inner(self.config.clone())); } // Replace unsupported flags with 'None' for p in &["%a", "%b", "%t", "%p", "%h", "%u", "%d", "%v", "%f", "%s"] { format = format.replace(p, "None"); } format.trim().to_string() } // spt playback -t pub async fn toggle_playback(&mut self) { let context = self.net.app.lock().await.current_playback_context.clone(); if let Some(c) = context { if c.is_playing { self.net.handle_network_event(IoEvent::PausePlayback).await; return; } } self .net .handle_network_event(IoEvent::StartPlayback(None, None, None)) .await; } // spt pb --share-track (share the current playing song) // Basically copy-pasted the 'copy_song_url' function pub async fn share_track_or_episode(&mut self) -> Result { let app = self.net.app.lock().await; if let Some(CurrentlyPlaybackContext { item: Some(item), .. }) = &app.current_playback_context { match item { PlayingItem::Track(track) => Ok(format!( "https://open.spotify.com/track/{}", track.id.to_owned().unwrap_or_default() )), PlayingItem::Episode(episode) => Ok(format!( "https://open.spotify.com/episode/{}", episode.id.to_owned() )), } } else { Err(anyhow!( "failed to generate a shareable url for the current song" )) } } // spt pb --share-album (share the current album) // Basically copy-pasted the 'copy_album_url' function pub async fn share_album_or_show(&mut self) -> Result { let app = self.net.app.lock().await; if let Some(CurrentlyPlaybackContext { item: Some(item), .. }) = &app.current_playback_context { match item { PlayingItem::Track(track) => Ok(format!( "https://open.spotify.com/album/{}", track.album.id.to_owned().unwrap_or_default() )), PlayingItem::Episode(episode) => Ok(format!( "https://open.spotify.com/show/{}", episode.show.id.to_owned() )), } } else { Err(anyhow!( "failed to generate a shareable url for the current song" )) } } // spt ... -d ... (specify device to control) pub async fn set_device(&mut self, name: String) -> Result<()> { // Change the device if specified by user let mut app = self.net.app.lock().await; let mut device_index = 0; if let Some(dp) = &app.devices { for (i, d) in dp.devices.iter().enumerate() { if d.name == name { device_index = i; // Save the id of the device self .net .client_config .set_device_id(d.id.clone()) .map_err(|_e| anyhow!("failed to use device with name '{}'", d.name))?; } } } else { // Error out if no device is available return Err(anyhow!("no device available")); } app.selected_device_index = Some(device_index); Ok(()) } // spt query ... --limit LIMIT (set max search limit) pub async fn update_query_limits(&mut self, max: String) -> Result<()> { let num = max .parse::() .map_err(|_e| anyhow!("limit must be between 1 and 50"))?; // 50 seems to be the maximum limit if num > 50 || num == 0 { return Err(anyhow!("limit must be between 1 and 50")); }; self .net .handle_network_event(IoEvent::UpdateSearchLimits(num, num)) .await; Ok(()) } pub async fn volume(&mut self, vol: String) -> Result<()> { let num = vol .parse::() .map_err(|_e| anyhow!("volume must be between 0 and 100"))?; // Check if it's in range if num > 100 { return Err(anyhow!("volume must be between 0 and 100")); }; self .net .handle_network_event(IoEvent::ChangeVolume(num as u8)) .await; Ok(()) } // spt playback --next / --previous pub async fn jump(&mut self, d: &JumpDirection) { match d { JumpDirection::Next => self.net.handle_network_event(IoEvent::NextTrack).await, JumpDirection::Previous => self.net.handle_network_event(IoEvent::PreviousTrack).await, } } // spt query -l ... pub async fn list(&mut self, item: Type, format: &str) -> String { match item { Type::Device => { if let Some(devices) = &self.net.app.lock().await.devices { devices .devices .iter() .map(|d| { self.format_output( format.to_string(), vec![ Format::Device(d.name.clone()), Format::Volume(d.volume_percent), ], ) }) .collect::>() .join("\n") } else { "No devices available".to_string() } } Type::Playlist => { self.net.handle_network_event(IoEvent::GetPlaylists).await; if let Some(playlists) = &self.net.app.lock().await.playlists { playlists .items .iter() .map(|p| { self.format_output( format.to_string(), Format::from_type(FormatType::Playlist(Box::new(p.clone()))), ) }) .collect::>() .join("\n") } else { "No playlists found".to_string() } } Type::Liked => { self .net .handle_network_event(IoEvent::GetCurrentSavedTracks(None)) .await; let liked_songs = self .net .app .lock() .await .track_table .tracks .iter() .map(|t| { self.format_output( format.to_string(), Format::from_type(FormatType::Track(Box::new(t.clone()))), ) }) .collect::>(); // Check if there are any liked songs if liked_songs.is_empty() { "No liked songs found".to_string() } else { liked_songs.join("\n") } } // Enforced by clap _ => unreachable!(), } } // spt playback --transfer DEVICE pub async fn transfer_playback(&mut self, device: &str) -> Result<()> { // Get the device id by name let mut id = String::new(); if let Some(devices) = &self.net.app.lock().await.devices { for d in &devices.devices { if d.name == device { id.push_str(d.id.as_str()); break; } } }; if id.is_empty() { Err(anyhow!("no device with name '{}'", device)) } else { self .net .handle_network_event(IoEvent::TransferPlaybackToDevice(id.to_string())) .await; Ok(()) } } pub async fn seek(&mut self, seconds_str: String) -> Result<()> { let seconds = match seconds_str.parse::() { Ok(s) => s.abs() as u32, Err(_) => return Err(anyhow!("failed to convert seconds to i32")), }; let (current_pos, duration) = { self .net .handle_network_event(IoEvent::GetCurrentPlayback) .await; let app = self.net.app.lock().await; if let Some(CurrentlyPlaybackContext { progress_ms: Some(ms), item: Some(item), .. }) = &app.current_playback_context { let duration = match item { PlayingItem::Track(track) => track.duration_ms, PlayingItem::Episode(episode) => episode.duration_ms, }; (*ms as u32, duration) } else { return Err(anyhow!("no context available")); } }; // Convert secs to ms let ms = seconds * 1000; // Calculate new positon let position_to_seek = if seconds_str.starts_with('+') { current_pos + ms } else if seconds_str.starts_with('-') { // Jump to the beginning if the position_to_seek would be // negative, must be checked before the calculation to avoid // an 'underflow' if ms > current_pos { 0u32 } else { current_pos - ms } } else { // Absolute value of the track seconds * 1000 }; // Check if position_to_seek is greater than duration (next track) if position_to_seek > duration { self.jump(&JumpDirection::Next).await; } else { // This seeks to a position in the current song self .net .handle_network_event(IoEvent::Seek(position_to_seek)) .await; } Ok(()) } // spt playback --like / --dislike / --shuffle / --repeat pub async fn mark(&mut self, flag: Flag) -> Result<()> { let c = { let app = self.net.app.lock().await; app .current_playback_context .clone() .ok_or_else(|| anyhow!("no context available"))? }; match flag { Flag::Like(s) => { // Get the id of the current song let id = match c.item { Some(i) => match i { PlayingItem::Track(t) => t.id.ok_or_else(|| anyhow!("item has no id")), PlayingItem::Episode(_) => Err(anyhow!("saving episodes not yet implemented")), }, None => Err(anyhow!("no item playing")), }?; // Want to like but is already liked -> do nothing // Want to like and is not liked yet -> like if s && !self.is_a_saved_track(&id).await { self .net .handle_network_event(IoEvent::ToggleSaveTrack(id)) .await; // Want to dislike but is already disliked -> do nothing // Want to dislike and is liked currently -> remove like } else if !s && self.is_a_saved_track(&id).await { self .net .handle_network_event(IoEvent::ToggleSaveTrack(id)) .await; } } Flag::Shuffle => { self .net .handle_network_event(IoEvent::Shuffle(c.shuffle_state)) .await } Flag::Repeat => { self .net .handle_network_event(IoEvent::Repeat(c.repeat_state)) .await; } } Ok(()) } // spt playback -s pub async fn get_status(&mut self, format: String) -> Result { // Update info on current playback self .net .handle_network_event(IoEvent::GetCurrentPlayback) .await; self .net .handle_network_event(IoEvent::GetCurrentSavedTracks(None)) .await; let context = self .net .app .lock() .await .current_playback_context .clone() .ok_or_else(|| anyhow!("no context available"))?; let playing_item = context.item.ok_or_else(|| anyhow!("no track playing"))?; let mut hs = match playing_item { PlayingItem::Track(track) => { let id = track.id.clone().unwrap_or_default(); let mut hs = Format::from_type(FormatType::Track(Box::new(track.clone()))); if let Some(ms) = context.progress_ms { hs.push(Format::Position((ms, track.duration_ms))) } hs.push(Format::Flags(( context.repeat_state, context.shuffle_state, self.is_a_saved_track(&id).await, ))); hs } PlayingItem::Episode(episode) => { let mut hs = Format::from_type(FormatType::Episode(Box::new(episode.clone()))); if let Some(ms) = context.progress_ms { hs.push(Format::Position((ms, episode.duration_ms))) } hs.push(Format::Flags(( context.repeat_state, context.shuffle_state, false, ))); hs } }; hs.push(Format::Device(context.device.name)); hs.push(Format::Volume(context.device.volume_percent)); hs.push(Format::Playing(context.is_playing)); Ok(self.format_output(format, hs)) } // spt play -u URI pub async fn play_uri(&mut self, uri: String, queue: bool, random: bool) { let offset = if random { // Only works with playlists for now if uri.contains("spotify:playlist:") { let id = uri.split(':').last().unwrap(); match self.net.spotify.playlist(id, None, None).await { Ok(p) => { let num = p.tracks.total; Some(thread_rng().gen_range(0..num) as usize) } Err(e) => { self .net .app .lock() .await .handle_error(anyhow!(e.to_string())); return; } } } else { None } } else { None }; if uri.contains("spotify:track:") { if queue { self .net .handle_network_event(IoEvent::AddItemToQueue(uri)) .await; } else { self .net .handle_network_event(IoEvent::StartPlayback( None, Some(vec![uri.clone()]), Some(0), )) .await; } } else { self .net .handle_network_event(IoEvent::StartPlayback(Some(uri.clone()), None, offset)) .await; } } // spt play -n NAME ... pub async fn play(&mut self, name: String, item: Type, queue: bool, random: bool) -> Result<()> { self .net .handle_network_event(IoEvent::GetSearchResults(name.clone(), None)) .await; // Get the uri of the first found // item + the offset or return an error message let uri = { let results = &self.net.app.lock().await.search_results; match item { Type::Track => { if let Some(r) = &results.tracks { r.items[0].uri.clone() } else { return Err(anyhow!("no tracks with name '{}'", name)); } } Type::Album => { if let Some(r) = &results.albums { let album = &r.items[0]; if let Some(uri) = &album.uri { uri.clone() } else { return Err(anyhow!("album {} has no uri", album.name)); } } else { return Err(anyhow!("no albums with name '{}'", name)); } } Type::Artist => { if let Some(r) = &results.artists { r.items[0].uri.clone() } else { return Err(anyhow!("no artists with name '{}'", name)); } } Type::Show => { if let Some(r) = &results.shows { r.items[0].uri.clone() } else { return Err(anyhow!("no shows with name '{}'", name)); } } Type::Playlist => { if let Some(r) = &results.playlists { let p = &r.items[0]; // For a random song, create a random offset p.uri.clone() } else { return Err(anyhow!("no playlists with name '{}'", name)); } } _ => unreachable!(), } }; // Play or queue the uri self.play_uri(uri, queue, random).await; Ok(()) } // spt query -s SEARCH ... pub async fn query(&mut self, search: String, format: String, item: Type) -> String { self .net .handle_network_event(IoEvent::GetSearchResults(search.clone(), None)) .await; let app = self.net.app.lock().await; match item { Type::Playlist => { if let Some(results) = &app.search_results.playlists { results .items .iter() .map(|r| { self.format_output( format.clone(), Format::from_type(FormatType::Playlist(Box::new(r.clone()))), ) }) .collect::>() .join("\n") } else { format!("no playlists with name '{}'", search) } } Type::Track => { if let Some(results) = &app.search_results.tracks { results .items .iter() .map(|r| { self.format_output( format.clone(), Format::from_type(FormatType::Track(Box::new(r.clone()))), ) }) .collect::>() .join("\n") } else { format!("no tracks with name '{}'", search) } } Type::Artist => { if let Some(results) = &app.search_results.artists { results .items .iter() .map(|r| { self.format_output( format.clone(), Format::from_type(FormatType::Artist(Box::new(r.clone()))), ) }) .collect::>() .join("\n") } else { format!("no artists with name '{}'", search) } } Type::Show => { if let Some(results) = &app.search_results.shows { results .items .iter() .map(|r| { self.format_output( format.clone(), Format::from_type(FormatType::Show(Box::new(r.clone()))), ) }) .collect::>() .join("\n") } else { format!("no shows with name '{}'", search) } } Type::Album => { if let Some(results) = &app.search_results.albums { results .items .iter() .map(|r| { self.format_output( format.clone(), Format::from_type(FormatType::Album(Box::new(r.clone()))), ) }) .collect::>() .join("\n") } else { format!("no albums with name '{}'", search) } } // Enforced by clap _ => unreachable!(), } } } ================================================ FILE: src/cli/handle.rs ================================================ use crate::network::{IoEvent, Network}; use crate::user_config::UserConfig; use super::{ util::{Flag, JumpDirection, Type}, CliApp, }; use anyhow::{anyhow, Result}; use clap::ArgMatches; // Handle the different subcommands pub async fn handle_matches( matches: &ArgMatches<'_>, cmd: String, net: Network<'_>, config: UserConfig, ) -> Result { let mut cli = CliApp::new(net, config); cli.net.handle_network_event(IoEvent::GetDevices).await; cli .net .handle_network_event(IoEvent::GetCurrentPlayback) .await; let devices_list = match &cli.net.app.lock().await.devices { Some(p) => p .devices .iter() .map(|d| d.id.clone()) .collect::>(), None => Vec::new(), }; // If the device_id is not specified, select the first available device let device_id = cli.net.client_config.device_id.clone(); if device_id.is_none() || !devices_list.contains(&device_id.unwrap()) { // Select the first device available if let Some(d) = devices_list.get(0) { cli.net.client_config.set_device_id(d.clone())?; } } if let Some(d) = matches.value_of("device") { cli.set_device(d.to_string()).await?; } // Evalute the subcommand let output = match cmd.as_str() { "playback" => { let format = matches.value_of("format").unwrap(); // Commands that are 'single' if matches.is_present("share-track") { return cli.share_track_or_episode().await; } else if matches.is_present("share-album") { return cli.share_album_or_show().await; } // Run the action, and print out the status // No 'else if's because multiple different commands are possible if matches.is_present("toggle") { cli.toggle_playback().await; } if let Some(d) = matches.value_of("transfer") { cli.transfer_playback(d).await?; } // Multiple flags are possible if matches.is_present("flags") { let flags = Flag::from_matches(matches); for f in flags { cli.mark(f).await?; } } if matches.is_present("jumps") { let (direction, amount) = JumpDirection::from_matches(matches); for _ in 0..amount { cli.jump(&direction).await; } } if let Some(vol) = matches.value_of("volume") { cli.volume(vol.to_string()).await?; } if let Some(secs) = matches.value_of("seek") { cli.seek(secs.to_string()).await?; } // Print out the status if no errors were found cli.get_status(format.to_string()).await } "play" => { let queue = matches.is_present("queue"); let random = matches.is_present("random"); let format = matches.value_of("format").unwrap(); if let Some(uri) = matches.value_of("uri") { cli.play_uri(uri.to_string(), queue, random).await; } else if let Some(name) = matches.value_of("name") { let category = Type::play_from_matches(matches); cli.play(name.to_string(), category, queue, random).await?; } cli.get_status(format.to_string()).await } "list" => { let format = matches.value_of("format").unwrap().to_string(); // Update the limits for the list and search functions // I think the small and big search limits are very confusing // so I just set them both to max, is this okay? if let Some(max) = matches.value_of("limit") { cli.update_query_limits(max.to_string()).await?; } let category = Type::list_from_matches(matches); Ok(cli.list(category, &format).await) } "search" => { let format = matches.value_of("format").unwrap().to_string(); // Update the limits for the list and search functions // I think the small and big search limits are very confusing // so I just set them both to max, is this okay? if let Some(max) = matches.value_of("limit") { cli.update_query_limits(max.to_string()).await?; } let category = Type::search_from_matches(matches); Ok( cli .query( matches.value_of("search").unwrap().to_string(), format, category, ) .await, ) } // Clap enforces that one of the things above is specified _ => unreachable!(), }; // Check if there was an error let api_error = cli.net.app.lock().await.api_error.clone(); if api_error.is_empty() { output } else { Err(anyhow!("{}", api_error)) } } ================================================ FILE: src/cli/mod.rs ================================================ mod clap; mod cli_app; mod handle; mod util; pub use self::clap::{list_subcommand, play_subcommand, playback_subcommand, search_subcommand}; use cli_app::CliApp; pub use handle::handle_matches; ================================================ FILE: src/cli/util.rs ================================================ use clap::ArgMatches; use rspotify::{ model::{ album::SimplifiedAlbum, artist::FullArtist, artist::SimplifiedArtist, playlist::SimplifiedPlaylist, show::FullEpisode, show::SimplifiedShow, track::FullTrack, }, senum::RepeatState, }; use crate::user_config::UserConfig; // Possible types to list or search #[derive(Debug)] pub enum Type { Playlist, Track, Artist, Album, Show, Device, Liked, } impl Type { pub fn play_from_matches(m: &ArgMatches<'_>) -> Self { if m.is_present("playlist") { Self::Playlist } else if m.is_present("track") { Self::Track } else if m.is_present("artist") { Self::Artist } else if m.is_present("album") { Self::Album } else if m.is_present("show") { Self::Show } // Enforced by clap else { unreachable!() } } pub fn search_from_matches(m: &ArgMatches<'_>) -> Self { if m.is_present("playlists") { Self::Playlist } else if m.is_present("tracks") { Self::Track } else if m.is_present("artists") { Self::Artist } else if m.is_present("albums") { Self::Album } else if m.is_present("shows") { Self::Show } // Enforced by clap else { unreachable!() } } pub fn list_from_matches(m: &ArgMatches<'_>) -> Self { if m.is_present("playlists") { Self::Playlist } else if m.is_present("devices") { Self::Device } else if m.is_present("liked") { Self::Liked } // Enforced by clap else { unreachable!() } } } // // Possible flags to set // pub enum Flag { // Does not get toggled // * User chooses like -> Flag::Like(true) // * User chooses dislike -> Flag::Like(false) Like(bool), Shuffle, Repeat, } impl Flag { pub fn from_matches(m: &ArgMatches<'_>) -> Vec { // Multiple flags are possible let mut flags = Vec::new(); // Only one of these two if m.is_present("like") { flags.push(Self::Like(true)); } else if m.is_present("dislike") { flags.push(Self::Like(false)); } if m.is_present("shuffle") { flags.push(Self::Shuffle); } if m.is_present("repeat") { flags.push(Self::Repeat); } flags } } // Possible directions to jump to pub enum JumpDirection { Next, Previous, } impl JumpDirection { pub fn from_matches(m: &ArgMatches<'_>) -> (Self, u64) { if m.is_present("next") { (Self::Next, m.occurrences_of("next")) } else if m.is_present("previous") { (Self::Previous, m.occurrences_of("previous")) // Enforced by clap } else { unreachable!() } } } // For fomatting (-f / --format flag) // Types to create a Format enum from // Boxing was proposed by cargo clippy // to reduce the size of this enum pub enum FormatType { Album(Box), Artist(Box), Playlist(Box), Track(Box), Episode(Box), Show(Box), } // Types that can be formatted #[derive(Clone)] pub enum Format { Album(String), Artist(String), Playlist(String), Track(String), Show(String), Uri(String), Device(String), Volume(u32), // Current position, duration Position((u32, u32)), // This is a bit long, should it be splitted up? Flags((RepeatState, bool, bool)), Playing(bool), } pub fn join_artists(a: Vec) -> String { a.iter() .map(|l| l.name.clone()) .collect::>() .join(", ") } impl Format { // Extract important information from types pub fn from_type(t: FormatType) -> Vec { match t { FormatType::Album(a) => { let joined_artists = join_artists(a.artists.clone()); let mut vec = vec![Self::Album(a.name), Self::Artist(joined_artists)]; if let Some(uri) = a.uri { vec.push(Self::Uri(uri)); } vec } FormatType::Artist(a) => vec![Self::Artist(a.name), Self::Uri(a.uri)], FormatType::Playlist(p) => vec![Self::Playlist(p.name), Self::Uri(p.uri)], FormatType::Track(t) => { let joined_artists = join_artists(t.artists.clone()); vec![ Self::Album(t.album.name), Self::Artist(joined_artists), Self::Track(t.name), Self::Uri(t.uri), ] } FormatType::Show(r) => vec![ Self::Artist(r.publisher), Self::Show(r.name), Self::Uri(r.uri), ], FormatType::Episode(e) => vec![ Self::Show(e.show.name), Self::Artist(e.show.publisher), Self::Track(e.name), Self::Uri(e.uri), ], } } // Is there a better way? pub fn inner(&self, conf: UserConfig) -> String { match self { Self::Album(s) => s.clone(), Self::Artist(s) => s.clone(), Self::Playlist(s) => s.clone(), Self::Track(s) => s.clone(), Self::Show(s) => s.clone(), Self::Uri(s) => s.clone(), Self::Device(s) => s.clone(), // Because this match statements // needs to return a &String, I have to do it this way Self::Volume(s) => s.to_string(), Self::Position((curr, duration)) => { crate::ui::util::display_track_progress(*curr as u128, *duration) } Self::Flags((r, s, l)) => { let like = if *l { conf.behavior.liked_icon } else { String::new() }; let shuffle = if *s { conf.behavior.shuffle_icon } else { String::new() }; let repeat = match r { RepeatState::Off => String::new(), RepeatState::Track => conf.behavior.repeat_track_icon, RepeatState::Context => conf.behavior.repeat_context_icon, }; // Add them together (only those that aren't empty) [shuffle, repeat, like] .iter() .filter(|a| !a.is_empty()) // Convert &String to String to join them .map(|s| s.to_string()) .collect::>() .join(" ") } Self::Playing(s) => { if *s { conf.behavior.playing_icon } else { conf.behavior.paused_icon } } } } pub fn get_placeholder(&self) -> &str { match self { Self::Album(_) => "%b", Self::Artist(_) => "%a", Self::Playlist(_) => "%p", Self::Track(_) => "%t", Self::Show(_) => "%h", Self::Uri(_) => "%u", Self::Device(_) => "%d", Self::Volume(_) => "%v", Self::Position(_) => "%r", Self::Flags(_) => "%f", Self::Playing(_) => "%s", } } } ================================================ FILE: src/config.rs ================================================ use super::banner::BANNER; use anyhow::{anyhow, Error, Result}; use serde::{Deserialize, Serialize}; use std::{ fs, io::{stdin, Write}, path::{Path, PathBuf}, }; const DEFAULT_PORT: u16 = 8888; const FILE_NAME: &str = "client.yml"; const CONFIG_DIR: &str = ".config"; const APP_CONFIG_DIR: &str = "spotify-tui"; const TOKEN_CACHE_FILE: &str = ".spotify_token_cache.json"; #[derive(Default, Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct ClientConfig { pub client_id: String, pub client_secret: String, pub device_id: Option, // FIXME: port should be defined in `user_config` not in here pub port: Option, } pub struct ConfigPaths { pub config_file_path: PathBuf, pub token_cache_path: PathBuf, } impl ClientConfig { pub fn new() -> ClientConfig { ClientConfig { client_id: "".to_string(), client_secret: "".to_string(), device_id: None, port: None, } } pub fn get_redirect_uri(&self) -> String { format!("http://localhost:{}/callback", self.get_port()) } pub fn get_port(&self) -> u16 { self.port.unwrap_or(DEFAULT_PORT) } pub fn get_or_build_paths(&self) -> Result { match dirs::home_dir() { Some(home) => { let path = Path::new(&home); let home_config_dir = path.join(CONFIG_DIR); let app_config_dir = home_config_dir.join(APP_CONFIG_DIR); if !home_config_dir.exists() { fs::create_dir(&home_config_dir)?; } if !app_config_dir.exists() { fs::create_dir(&app_config_dir)?; } let config_file_path = &app_config_dir.join(FILE_NAME); let token_cache_path = &app_config_dir.join(TOKEN_CACHE_FILE); let paths = ConfigPaths { config_file_path: config_file_path.to_path_buf(), token_cache_path: token_cache_path.to_path_buf(), }; Ok(paths) } None => Err(anyhow!("No $HOME directory found for client config")), } } pub fn set_device_id(&mut self, device_id: String) -> Result<()> { let paths = self.get_or_build_paths()?; let config_string = fs::read_to_string(&paths.config_file_path)?; let mut config_yml: ClientConfig = serde_yaml::from_str(&config_string)?; self.device_id = Some(device_id.clone()); config_yml.device_id = Some(device_id); let new_config = serde_yaml::to_string(&config_yml)?; let mut config_file = fs::File::create(&paths.config_file_path)?; write!(config_file, "{}", new_config)?; Ok(()) } pub fn load_config(&mut self) -> Result<()> { let paths = self.get_or_build_paths()?; if paths.config_file_path.exists() { let config_string = fs::read_to_string(&paths.config_file_path)?; let config_yml: ClientConfig = serde_yaml::from_str(&config_string)?; self.client_id = config_yml.client_id; self.client_secret = config_yml.client_secret; self.device_id = config_yml.device_id; self.port = config_yml.port; Ok(()) } else { println!("{}", BANNER); println!( "Config will be saved to {}", paths.config_file_path.display() ); println!("\nHow to get setup:\n"); let instructions = [ "Go to the Spotify dashboard - https://developer.spotify.com/dashboard/applications", "Click `Create a Client ID` and create an app", "Now click `Edit Settings`", &format!( "Add `http://localhost:{}/callback` to the Redirect URIs", DEFAULT_PORT ), "You are now ready to authenticate with Spotify!", ]; let mut number = 1; for item in instructions.iter() { println!(" {}. {}", number, item); number += 1; } let client_id = ClientConfig::get_client_key_from_input("Client ID")?; let client_secret = ClientConfig::get_client_key_from_input("Client Secret")?; let mut port = String::new(); println!("\nEnter port of redirect uri (default {}): ", DEFAULT_PORT); stdin().read_line(&mut port)?; let port = port.trim().parse::().unwrap_or(DEFAULT_PORT); let config_yml = ClientConfig { client_id, client_secret, device_id: None, port: Some(port), }; let content_yml = serde_yaml::to_string(&config_yml)?; let mut new_config = fs::File::create(&paths.config_file_path)?; write!(new_config, "{}", content_yml)?; self.client_id = config_yml.client_id; self.client_secret = config_yml.client_secret; self.device_id = config_yml.device_id; self.port = config_yml.port; Ok(()) } } fn get_client_key_from_input(type_label: &'static str) -> Result { let mut client_key = String::new(); const MAX_RETRIES: u8 = 5; let mut num_retries = 0; loop { println!("\nEnter your {}: ", type_label); stdin().read_line(&mut client_key)?; client_key = client_key.trim().to_string(); match ClientConfig::validate_client_key(&client_key) { Ok(_) => return Ok(client_key), Err(error_string) => { println!("{}", error_string); client_key.clear(); num_retries += 1; if num_retries == MAX_RETRIES { return Err(Error::from(std::io::Error::new( std::io::ErrorKind::Other, format!("Maximum retries ({}) exceeded.", MAX_RETRIES), ))); } } }; } } fn validate_client_key(key: &str) -> Result<()> { const EXPECTED_LEN: usize = 32; if key.len() != EXPECTED_LEN { Err(Error::from(std::io::Error::new( std::io::ErrorKind::InvalidInput, format!("invalid length: {} (must be {})", key.len(), EXPECTED_LEN,), ))) } else if !key.chars().all(|c| c.is_digit(16)) { Err(Error::from(std::io::Error::new( std::io::ErrorKind::InvalidInput, "invalid character found (must be hex digits)", ))) } else { Ok(()) } } } ================================================ FILE: src/event/events.rs ================================================ use crate::event::Key; use crossterm::event; use std::{sync::mpsc, thread, time::Duration}; #[derive(Debug, Clone, Copy)] /// Configuration for event handling. pub struct EventConfig { /// The key that is used to exit the application. pub exit_key: Key, /// The tick rate at which the application will sent an tick event. pub tick_rate: Duration, } impl Default for EventConfig { fn default() -> EventConfig { EventConfig { exit_key: Key::Ctrl('c'), tick_rate: Duration::from_millis(250), } } } /// An occurred event. pub enum Event { /// An input event occurred. Input(I), /// An tick event occurred. Tick, } /// A small event handler that wrap crossterm input and tick event. Each event /// type is handled in its own thread and returned to a common `Receiver` pub struct Events { rx: mpsc::Receiver>, // Need to be kept around to prevent disposing the sender side. _tx: mpsc::Sender>, } impl Events { /// Constructs an new instance of `Events` with the default config. pub fn new(tick_rate: u64) -> Events { Events::with_config(EventConfig { tick_rate: Duration::from_millis(tick_rate), ..Default::default() }) } /// Constructs an new instance of `Events` from given config. pub fn with_config(config: EventConfig) -> Events { let (tx, rx) = mpsc::channel(); let event_tx = tx.clone(); thread::spawn(move || { loop { // poll for tick rate duration, if no event, sent tick event. if event::poll(config.tick_rate).unwrap() { if let event::Event::Key(key) = event::read().unwrap() { let key = Key::from(key); event_tx.send(Event::Input(key)).unwrap(); } } event_tx.send(Event::Tick).unwrap(); } }); Events { rx, _tx: tx } } /// Attempts to read an event. /// This function will block the current thread. pub fn next(&self) -> Result, mpsc::RecvError> { self.rx.recv() } } ================================================ FILE: src/event/key.rs ================================================ use crossterm::event; use std::fmt; /// Represents an key. #[derive(PartialEq, Eq, Clone, Copy, Hash, Debug)] pub enum Key { /// Both Enter (or Return) and numpad Enter Enter, /// Tabulation key Tab, /// Backspace key Backspace, /// Escape key Esc, /// Left arrow Left, /// Right arrow Right, /// Up arrow Up, /// Down arrow Down, /// Insert key Ins, /// Delete key Delete, /// Home key Home, /// End key End, /// Page Up key PageUp, /// Page Down key PageDown, /// F0 key F0, /// F1 key F1, /// F2 key F2, /// F3 key F3, /// F4 key F4, /// F5 key F5, /// F6 key F6, /// F7 key F7, /// F8 key F8, /// F9 key F9, /// F10 key F10, /// F11 key F11, /// F12 key F12, Char(char), Ctrl(char), Alt(char), Unknown, } impl Key { /// Returns the function key corresponding to the given number /// /// 1 -> F1, etc... /// /// # Panics /// /// If `n == 0 || n > 12` pub fn from_f(n: u8) -> Key { match n { 0 => Key::F0, 1 => Key::F1, 2 => Key::F2, 3 => Key::F3, 4 => Key::F4, 5 => Key::F5, 6 => Key::F6, 7 => Key::F7, 8 => Key::F8, 9 => Key::F9, 10 => Key::F10, 11 => Key::F11, 12 => Key::F12, _ => panic!("unknown function key: F{}", n), } } } impl fmt::Display for Key { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { match *self { Key::Alt(' ') => write!(f, ""), Key::Ctrl(' ') => write!(f, ""), Key::Char(' ') => write!(f, ""), Key::Alt(c) => write!(f, "", c), Key::Ctrl(c) => write!(f, "", c), Key::Char(c) => write!(f, "{}", c), Key::Left | Key::Right | Key::Up | Key::Down => write!(f, "<{:?} Arrow Key>", self), Key::Enter | Key::Tab | Key::Backspace | Key::Esc | Key::Ins | Key::Delete | Key::Home | Key::End | Key::PageUp | Key::PageDown => write!(f, "<{:?}>", self), _ => write!(f, "{:?}", self), } } } impl From for Key { fn from(key_event: event::KeyEvent) -> Self { match key_event { event::KeyEvent { code: event::KeyCode::Esc, .. } => Key::Esc, event::KeyEvent { code: event::KeyCode::Backspace, .. } => Key::Backspace, event::KeyEvent { code: event::KeyCode::Left, .. } => Key::Left, event::KeyEvent { code: event::KeyCode::Right, .. } => Key::Right, event::KeyEvent { code: event::KeyCode::Up, .. } => Key::Up, event::KeyEvent { code: event::KeyCode::Down, .. } => Key::Down, event::KeyEvent { code: event::KeyCode::Home, .. } => Key::Home, event::KeyEvent { code: event::KeyCode::End, .. } => Key::End, event::KeyEvent { code: event::KeyCode::PageUp, .. } => Key::PageUp, event::KeyEvent { code: event::KeyCode::PageDown, .. } => Key::PageDown, event::KeyEvent { code: event::KeyCode::Delete, .. } => Key::Delete, event::KeyEvent { code: event::KeyCode::Insert, .. } => Key::Ins, event::KeyEvent { code: event::KeyCode::F(n), .. } => Key::from_f(n), event::KeyEvent { code: event::KeyCode::Enter, .. } => Key::Enter, event::KeyEvent { code: event::KeyCode::Tab, .. } => Key::Tab, // First check for char + modifier event::KeyEvent { code: event::KeyCode::Char(c), modifiers: event::KeyModifiers::ALT, } => Key::Alt(c), event::KeyEvent { code: event::KeyCode::Char(c), modifiers: event::KeyModifiers::CONTROL, } => Key::Ctrl(c), event::KeyEvent { code: event::KeyCode::Char(c), .. } => Key::Char(c), _ => Key::Unknown, } } } ================================================ FILE: src/event/mod.rs ================================================ mod events; mod key; pub use self::{ events::{Event, Events}, key::Key, }; ================================================ FILE: src/handlers/album_list.rs ================================================ use super::common_key_events; use crate::{ app::{ActiveBlock, AlbumTableContext, App, RouteId, SelectedFullAlbum}, event::Key, }; pub fn handler(key: Key, app: &mut App) { match key { k if common_key_events::left_event(k) => common_key_events::handle_left_event(app), k if common_key_events::down_event(k) => { if let Some(albums) = &mut app.library.saved_albums.get_results(None) { let next_index = common_key_events::on_down_press_handler(&albums.items, Some(app.album_list_index)); app.album_list_index = next_index; } } k if common_key_events::up_event(k) => { if let Some(albums) = &mut app.library.saved_albums.get_results(None) { let next_index = common_key_events::on_up_press_handler(&albums.items, Some(app.album_list_index)); app.album_list_index = next_index; } } k if common_key_events::high_event(k) => { if let Some(_albums) = app.library.saved_albums.get_results(None) { let next_index = common_key_events::on_high_press_handler(); app.album_list_index = next_index; } } k if common_key_events::middle_event(k) => { if let Some(albums) = app.library.saved_albums.get_results(None) { let next_index = common_key_events::on_middle_press_handler(&albums.items); app.album_list_index = next_index; } } k if common_key_events::low_event(k) => { if let Some(albums) = app.library.saved_albums.get_results(None) { let next_index = common_key_events::on_low_press_handler(&albums.items); app.album_list_index = next_index; } } Key::Enter => { if let Some(albums) = app.library.saved_albums.get_results(None) { if let Some(selected_album) = albums.items.get(app.album_list_index) { app.selected_album_full = Some(SelectedFullAlbum { album: selected_album.album.clone(), selected_index: 0, }); app.album_table_context = AlbumTableContext::Full; app.push_navigation_stack(RouteId::AlbumTracks, ActiveBlock::AlbumTracks); }; } } k if k == app.user_config.keys.next_page => app.get_current_user_saved_albums_next(), k if k == app.user_config.keys.previous_page => app.get_current_user_saved_albums_previous(), Key::Char('D') => app.current_user_saved_album_delete(ActiveBlock::AlbumList), _ => {} }; } #[cfg(test)] mod tests { use super::*; #[test] fn on_left_press() { let mut app = App::default(); app.set_current_route_state( Some(ActiveBlock::AlbumTracks), Some(ActiveBlock::AlbumTracks), ); handler(Key::Left, &mut app); let current_route = app.get_current_route(); assert_eq!(current_route.active_block, ActiveBlock::Empty); assert_eq!(current_route.hovered_block, ActiveBlock::Library); } #[test] fn on_esc() { let mut app = App::default(); handler(Key::Esc, &mut app); let current_route = app.get_current_route(); assert_eq!(current_route.active_block, ActiveBlock::Empty); } } ================================================ FILE: src/handlers/album_tracks.rs ================================================ use super::common_key_events; use crate::{ app::{AlbumTableContext, App, RecommendationsContext}, event::Key, network::IoEvent, }; pub fn handler(key: Key, app: &mut App) { match key { k if common_key_events::left_event(k) => common_key_events::handle_left_event(app), k if common_key_events::down_event(k) => match app.album_table_context { AlbumTableContext::Full => { if let Some(selected_album) = &app.selected_album_full { let next_index = common_key_events::on_down_press_handler( &selected_album.album.tracks.items, Some(app.saved_album_tracks_index), ); app.saved_album_tracks_index = next_index; }; } AlbumTableContext::Simplified => { if let Some(selected_album_simplified) = &mut app.selected_album_simplified { let next_index = common_key_events::on_down_press_handler( &selected_album_simplified.tracks.items, Some(selected_album_simplified.selected_index), ); selected_album_simplified.selected_index = next_index; } } }, k if common_key_events::up_event(k) => match app.album_table_context { AlbumTableContext::Full => { if let Some(selected_album) = &app.selected_album_full { let next_index = common_key_events::on_up_press_handler( &selected_album.album.tracks.items, Some(app.saved_album_tracks_index), ); app.saved_album_tracks_index = next_index; }; } AlbumTableContext::Simplified => { if let Some(selected_album_simplified) = &mut app.selected_album_simplified { let next_index = common_key_events::on_up_press_handler( &selected_album_simplified.tracks.items, Some(selected_album_simplified.selected_index), ); selected_album_simplified.selected_index = next_index; } } }, k if common_key_events::high_event(k) => handle_high_event(app), k if common_key_events::middle_event(k) => handle_middle_event(app), k if common_key_events::low_event(k) => handle_low_event(app), Key::Char('s') => handle_save_event(app), Key::Char('w') => handle_save_album_event(app), Key::Enter => match app.album_table_context { AlbumTableContext::Full => { if let Some(selected_album) = app.selected_album_full.clone() { app.dispatch(IoEvent::StartPlayback( Some(selected_album.album.uri), None, Some(app.saved_album_tracks_index), )); }; } AlbumTableContext::Simplified => { if let Some(selected_album_simplified) = &app.selected_album_simplified.clone() { app.dispatch(IoEvent::StartPlayback( selected_album_simplified.album.uri.clone(), None, Some(selected_album_simplified.selected_index), )); }; } }, //recommended playlist based on selected track Key::Char('r') => { handle_recommended_tracks(app); } _ if key == app.user_config.keys.add_item_to_queue => match app.album_table_context { AlbumTableContext::Full => { if let Some(selected_album) = app.selected_album_full.clone() { if let Some(track) = selected_album .album .tracks .items .get(app.saved_album_tracks_index) { app.dispatch(IoEvent::AddItemToQueue(track.uri.clone())); } }; } AlbumTableContext::Simplified => { if let Some(selected_album_simplified) = &app.selected_album_simplified.clone() { if let Some(track) = selected_album_simplified .tracks .items .get(selected_album_simplified.selected_index) { app.dispatch(IoEvent::AddItemToQueue(track.uri.clone())); } }; } }, _ => {} }; } fn handle_high_event(app: &mut App) { match app.album_table_context { AlbumTableContext::Full => { let next_index = common_key_events::on_high_press_handler(); app.saved_album_tracks_index = next_index; } AlbumTableContext::Simplified => { if let Some(selected_album_simplified) = &mut app.selected_album_simplified { let next_index = common_key_events::on_high_press_handler(); selected_album_simplified.selected_index = next_index; } } } } fn handle_middle_event(app: &mut App) { match app.album_table_context { AlbumTableContext::Full => { if let Some(selected_album) = &app.selected_album_full { let next_index = common_key_events::on_middle_press_handler(&selected_album.album.tracks.items); app.saved_album_tracks_index = next_index; }; } AlbumTableContext::Simplified => { if let Some(selected_album_simplified) = &mut app.selected_album_simplified { let next_index = common_key_events::on_middle_press_handler(&selected_album_simplified.tracks.items); selected_album_simplified.selected_index = next_index; } } } } fn handle_low_event(app: &mut App) { match app.album_table_context { AlbumTableContext::Full => { if let Some(selected_album) = &app.selected_album_full { let next_index = common_key_events::on_low_press_handler(&selected_album.album.tracks.items); app.saved_album_tracks_index = next_index; }; } AlbumTableContext::Simplified => { if let Some(selected_album_simplified) = &mut app.selected_album_simplified { let next_index = common_key_events::on_low_press_handler(&selected_album_simplified.tracks.items); selected_album_simplified.selected_index = next_index; } } } } fn handle_recommended_tracks(app: &mut App) { match app.album_table_context { AlbumTableContext::Full => { if let Some(albums) = &app.library.clone().saved_albums.get_results(None) { if let Some(selected_album) = albums.items.get(app.album_list_index) { if let Some(track) = &selected_album .album .tracks .items .get(app.saved_album_tracks_index) { if let Some(id) = &track.id { app.recommendations_context = Some(RecommendationsContext::Song); app.recommendations_seed = track.name.clone(); app.get_recommendations_for_track_id(id.to_string()); } } } } } AlbumTableContext::Simplified => { if let Some(selected_album_simplified) = &app.selected_album_simplified.clone() { if let Some(track) = &selected_album_simplified .tracks .items .get(selected_album_simplified.selected_index) { if let Some(id) = &track.id { app.recommendations_context = Some(RecommendationsContext::Song); app.recommendations_seed = track.name.clone(); app.get_recommendations_for_track_id(id.to_string()); } } }; } } } fn handle_save_event(app: &mut App) { match app.album_table_context { AlbumTableContext::Full => { if let Some(selected_album) = app.selected_album_full.clone() { if let Some(selected_track) = selected_album .album .tracks .items .get(app.saved_album_tracks_index) { if let Some(track_id) = &selected_track.id { app.dispatch(IoEvent::ToggleSaveTrack(track_id.to_string())); }; }; }; } AlbumTableContext::Simplified => { if let Some(selected_album_simplified) = app.selected_album_simplified.clone() { if let Some(selected_track) = selected_album_simplified .tracks .items .get(selected_album_simplified.selected_index) { if let Some(track_id) = &selected_track.id { app.dispatch(IoEvent::ToggleSaveTrack(track_id.to_string())); }; }; }; } } } fn handle_save_album_event(app: &mut App) { match app.album_table_context { AlbumTableContext::Full => { if let Some(selected_album) = app.selected_album_full.clone() { let album_id = &selected_album.album.id; app.dispatch(IoEvent::CurrentUserSavedAlbumAdd(album_id.to_string())); }; } AlbumTableContext::Simplified => { if let Some(selected_album_simplified) = app.selected_album_simplified.clone() { if let Some(album_id) = selected_album_simplified.album.id { app.dispatch(IoEvent::CurrentUserSavedAlbumAdd(album_id)); }; }; } } } #[cfg(test)] mod tests { use super::*; use crate::app::ActiveBlock; #[test] fn on_left_press() { let mut app = App::default(); app.set_current_route_state( Some(ActiveBlock::AlbumTracks), Some(ActiveBlock::AlbumTracks), ); handler(Key::Left, &mut app); let current_route = app.get_current_route(); assert_eq!(current_route.active_block, ActiveBlock::Empty); assert_eq!(current_route.hovered_block, ActiveBlock::Library); } #[test] fn on_esc() { let mut app = App::default(); handler(Key::Esc, &mut app); let current_route = app.get_current_route(); assert_eq!(current_route.active_block, ActiveBlock::Empty); } } ================================================ FILE: src/handlers/analysis.rs ================================================ use crate::{app::App, event::Key}; pub fn handler(_key: Key, _app: &mut App) {} ================================================ FILE: src/handlers/artist.rs ================================================ use super::common_key_events; use crate::app::{ActiveBlock, App, ArtistBlock, RecommendationsContext, TrackTableContext}; use crate::event::Key; use crate::network::IoEvent; fn handle_down_press_on_selected_block(app: &mut App) { if let Some(artist) = &mut app.artist { match artist.artist_selected_block { ArtistBlock::TopTracks => { let next_index = common_key_events::on_down_press_handler( &artist.top_tracks, Some(artist.selected_top_track_index), ); artist.selected_top_track_index = next_index; } ArtistBlock::Albums => { let next_index = common_key_events::on_down_press_handler( &artist.albums.items, Some(artist.selected_album_index), ); artist.selected_album_index = next_index; } ArtistBlock::RelatedArtists => { let next_index = common_key_events::on_down_press_handler( &artist.related_artists, Some(artist.selected_related_artist_index), ); artist.selected_related_artist_index = next_index; } ArtistBlock::Empty => {} } } } fn handle_down_press_on_hovered_block(app: &mut App) { if let Some(artist) = &mut app.artist { match artist.artist_hovered_block { ArtistBlock::TopTracks => { artist.artist_hovered_block = ArtistBlock::Albums; } ArtistBlock::Albums => { artist.artist_hovered_block = ArtistBlock::RelatedArtists; } ArtistBlock::RelatedArtists => { artist.artist_hovered_block = ArtistBlock::TopTracks; } ArtistBlock::Empty => {} } } } fn handle_up_press_on_selected_block(app: &mut App) { if let Some(artist) = &mut app.artist { match artist.artist_selected_block { ArtistBlock::TopTracks => { let next_index = common_key_events::on_up_press_handler( &artist.top_tracks, Some(artist.selected_top_track_index), ); artist.selected_top_track_index = next_index; } ArtistBlock::Albums => { let next_index = common_key_events::on_up_press_handler( &artist.albums.items, Some(artist.selected_album_index), ); artist.selected_album_index = next_index; } ArtistBlock::RelatedArtists => { let next_index = common_key_events::on_up_press_handler( &artist.related_artists, Some(artist.selected_related_artist_index), ); artist.selected_related_artist_index = next_index; } ArtistBlock::Empty => {} } } } fn handle_up_press_on_hovered_block(app: &mut App) { if let Some(artist) = &mut app.artist { match artist.artist_hovered_block { ArtistBlock::TopTracks => { artist.artist_hovered_block = ArtistBlock::RelatedArtists; } ArtistBlock::Albums => { artist.artist_hovered_block = ArtistBlock::TopTracks; } ArtistBlock::RelatedArtists => { artist.artist_hovered_block = ArtistBlock::Albums; } ArtistBlock::Empty => {} } } } fn handle_high_press_on_selected_block(app: &mut App) { if let Some(artist) = &mut app.artist { match artist.artist_selected_block { ArtistBlock::TopTracks => { let next_index = common_key_events::on_high_press_handler(); artist.selected_top_track_index = next_index; } ArtistBlock::Albums => { let next_index = common_key_events::on_high_press_handler(); artist.selected_album_index = next_index; } ArtistBlock::RelatedArtists => { let next_index = common_key_events::on_high_press_handler(); artist.selected_related_artist_index = next_index; } ArtistBlock::Empty => {} } } } fn handle_middle_press_on_selected_block(app: &mut App) { if let Some(artist) = &mut app.artist { match artist.artist_selected_block { ArtistBlock::TopTracks => { let next_index = common_key_events::on_middle_press_handler(&artist.top_tracks); artist.selected_top_track_index = next_index; } ArtistBlock::Albums => { let next_index = common_key_events::on_middle_press_handler(&artist.albums.items); artist.selected_album_index = next_index; } ArtistBlock::RelatedArtists => { let next_index = common_key_events::on_middle_press_handler(&artist.related_artists); artist.selected_related_artist_index = next_index; } ArtistBlock::Empty => {} } } } fn handle_low_press_on_selected_block(app: &mut App) { if let Some(artist) = &mut app.artist { match artist.artist_selected_block { ArtistBlock::TopTracks => { let next_index = common_key_events::on_low_press_handler(&artist.top_tracks); artist.selected_top_track_index = next_index; } ArtistBlock::Albums => { let next_index = common_key_events::on_low_press_handler(&artist.albums.items); artist.selected_album_index = next_index; } ArtistBlock::RelatedArtists => { let next_index = common_key_events::on_low_press_handler(&artist.related_artists); artist.selected_related_artist_index = next_index; } ArtistBlock::Empty => {} } } } fn handle_recommend_event_on_selected_block(app: &mut App) { //recommendations. if let Some(artist) = &mut app.artist.clone() { match artist.artist_selected_block { ArtistBlock::TopTracks => { let selected_index = artist.selected_top_track_index; if let Some(track) = artist.top_tracks.get(selected_index) { let track_id_list: Option> = track.id.as_ref().map(|id| vec![id.to_string()]); app.recommendations_context = Some(RecommendationsContext::Song); app.recommendations_seed = track.name.clone(); app.get_recommendations_for_seed(None, track_id_list, Some(track.clone())); } } ArtistBlock::RelatedArtists => { let selected_index = artist.selected_related_artist_index; let artist_id = &artist.related_artists[selected_index].id; let artist_name = &artist.related_artists[selected_index].name; let artist_id_list: Option> = Some(vec![artist_id.clone()]); app.recommendations_context = Some(RecommendationsContext::Artist); app.recommendations_seed = artist_name.clone(); app.get_recommendations_for_seed(artist_id_list, None, None); } _ => {} } } } fn handle_enter_event_on_selected_block(app: &mut App) { if let Some(artist) = &mut app.artist.clone() { match artist.artist_selected_block { ArtistBlock::TopTracks => { let selected_index = artist.selected_top_track_index; let top_tracks = artist .top_tracks .iter() .map(|track| track.uri.to_owned()) .collect(); app.dispatch(IoEvent::StartPlayback( None, Some(top_tracks), Some(selected_index), )); } ArtistBlock::Albums => { if let Some(selected_album) = artist .albums .items .get(artist.selected_album_index) .cloned() { app.track_table.context = Some(TrackTableContext::AlbumSearch); app.dispatch(IoEvent::GetAlbumTracks(Box::new(selected_album))); } } ArtistBlock::RelatedArtists => { let selected_index = artist.selected_related_artist_index; let artist_id = artist.related_artists[selected_index].id.clone(); let artist_name = artist.related_artists[selected_index].name.clone(); app.get_artist(artist_id, artist_name); } ArtistBlock::Empty => {} } } } fn handle_enter_event_on_hovered_block(app: &mut App) { if let Some(artist) = &mut app.artist { match artist.artist_hovered_block { ArtistBlock::TopTracks => artist.artist_selected_block = ArtistBlock::TopTracks, ArtistBlock::Albums => artist.artist_selected_block = ArtistBlock::Albums, ArtistBlock::RelatedArtists => artist.artist_selected_block = ArtistBlock::RelatedArtists, ArtistBlock::Empty => {} } } } pub fn handler(key: Key, app: &mut App) { if let Some(artist) = &mut app.artist { match key { Key::Esc => { artist.artist_selected_block = ArtistBlock::Empty; } k if common_key_events::down_event(k) => { if artist.artist_selected_block != ArtistBlock::Empty { handle_down_press_on_selected_block(app); } else { handle_down_press_on_hovered_block(app); } } k if common_key_events::up_event(k) => { if artist.artist_selected_block != ArtistBlock::Empty { handle_up_press_on_selected_block(app); } else { handle_up_press_on_hovered_block(app); } } k if common_key_events::left_event(k) => { artist.artist_selected_block = ArtistBlock::Empty; match artist.artist_hovered_block { ArtistBlock::TopTracks => common_key_events::handle_left_event(app), ArtistBlock::Albums => { artist.artist_hovered_block = ArtistBlock::TopTracks; } ArtistBlock::RelatedArtists => { artist.artist_hovered_block = ArtistBlock::Albums; } ArtistBlock::Empty => {} } } k if common_key_events::right_event(k) => { artist.artist_selected_block = ArtistBlock::Empty; handle_down_press_on_hovered_block(app); } k if common_key_events::high_event(k) => { if artist.artist_selected_block != ArtistBlock::Empty { handle_high_press_on_selected_block(app); } } k if common_key_events::middle_event(k) => { if artist.artist_selected_block != ArtistBlock::Empty { handle_middle_press_on_selected_block(app); } } k if common_key_events::low_event(k) => { if artist.artist_selected_block != ArtistBlock::Empty { handle_low_press_on_selected_block(app); } } Key::Enter => { if artist.artist_selected_block != ArtistBlock::Empty { handle_enter_event_on_selected_block(app); } else { handle_enter_event_on_hovered_block(app); } } Key::Char('r') => { if artist.artist_selected_block != ArtistBlock::Empty { handle_recommend_event_on_selected_block(app); } } Key::Char('w') => match artist.artist_selected_block { ArtistBlock::Albums => app.current_user_saved_album_add(ActiveBlock::ArtistBlock), ArtistBlock::RelatedArtists => app.user_follow_artists(ActiveBlock::ArtistBlock), _ => (), }, Key::Char('D') => match artist.artist_selected_block { ArtistBlock::Albums => app.current_user_saved_album_delete(ActiveBlock::ArtistBlock), ArtistBlock::RelatedArtists => app.user_unfollow_artists(ActiveBlock::ArtistBlock), _ => (), }, _ if key == app.user_config.keys.add_item_to_queue => { if let ArtistBlock::TopTracks = artist.artist_selected_block { if let Some(track) = artist.top_tracks.get(artist.selected_top_track_index) { let uri = track.uri.clone(); app.dispatch(IoEvent::AddItemToQueue(uri)); }; } } _ => {} }; } } #[cfg(test)] mod tests { use super::*; use crate::app::ActiveBlock; #[test] fn on_esc() { let mut app = App::default(); handler(Key::Esc, &mut app); let current_route = app.get_current_route(); assert_eq!(current_route.active_block, ActiveBlock::Empty); } } ================================================ FILE: src/handlers/artist_albums.rs ================================================ use super::common_key_events; use crate::{ app::{App, TrackTableContext}, event::Key, }; pub fn handler(key: Key, app: &mut App) { match key { k if common_key_events::left_event(k) => common_key_events::handle_left_event(app), k if common_key_events::down_event(k) => { if let Some(artist_albums) = &mut app.artist_albums { let next_index = common_key_events::on_down_press_handler( &artist_albums.albums.items, Some(artist_albums.selected_index), ); artist_albums.selected_index = next_index; } } k if common_key_events::up_event(k) => { if let Some(artist_albums) = &mut app.artist_albums { let next_index = common_key_events::on_up_press_handler( &artist_albums.albums.items, Some(artist_albums.selected_index), ); artist_albums.selected_index = next_index; } } Key::Enter => { if let Some(artist_albums) = &mut app.artist_albums { if let Some(selected_album) = artist_albums .albums .items .get(artist_albums.selected_index) .cloned() { app.track_table.context = Some(TrackTableContext::AlbumSearch); app.get_album_tracks(selected_album); } }; } _ => {} }; } #[cfg(test)] mod tests { use super::*; use crate::app::ActiveBlock; #[test] fn on_left_press() { let mut app = App::new(); app.set_current_route_state( Some(ActiveBlock::AlbumTracks), Some(ActiveBlock::AlbumTracks), ); handler(Key::Left, &mut app); let current_route = app.get_current_route(); assert_eq!(current_route.active_block, ActiveBlock::Empty); assert_eq!(current_route.hovered_block, ActiveBlock::Library); } #[test] fn on_esc() { let mut app = App::new(); handler(Key::Esc, &mut app); let current_route = app.get_current_route(); assert_eq!(current_route.active_block, ActiveBlock::Empty); } } ================================================ FILE: src/handlers/artists.rs ================================================ use super::common_key_events; use crate::{ app::{ActiveBlock, App, RecommendationsContext, RouteId}, event::Key, network::IoEvent, }; pub fn handler(key: Key, app: &mut App) { match key { k if common_key_events::left_event(k) => common_key_events::handle_left_event(app), k if common_key_events::down_event(k) => { if let Some(artists) = &mut app.library.saved_artists.get_results(None) { let next_index = common_key_events::on_down_press_handler(&artists.items, Some(app.artists_list_index)); app.artists_list_index = next_index; } } k if common_key_events::up_event(k) => { if let Some(artists) = &mut app.library.saved_artists.get_results(None) { let next_index = common_key_events::on_up_press_handler(&artists.items, Some(app.artists_list_index)); app.artists_list_index = next_index; } } k if common_key_events::high_event(k) => { if let Some(_artists) = &mut app.library.saved_artists.get_results(None) { let next_index = common_key_events::on_high_press_handler(); app.artists_list_index = next_index; } } k if common_key_events::middle_event(k) => { if let Some(artists) = &mut app.library.saved_artists.get_results(None) { let next_index = common_key_events::on_middle_press_handler(&artists.items); app.artists_list_index = next_index; } } k if common_key_events::low_event(k) => { if let Some(artists) = &mut app.library.saved_artists.get_results(None) { let next_index = common_key_events::on_low_press_handler(&artists.items); app.artists_list_index = next_index; } } Key::Enter => { let artists = app.artists.to_owned(); if !artists.is_empty() { let artist = &artists[app.artists_list_index]; app.get_artist(artist.id.clone(), artist.name.clone()); app.push_navigation_stack(RouteId::Artist, ActiveBlock::ArtistBlock); } } Key::Char('D') => app.user_unfollow_artists(ActiveBlock::AlbumList), Key::Char('e') => { let artists = app.artists.to_owned(); let artist = artists.get(app.artists_list_index); if let Some(artist) = artist { app.dispatch(IoEvent::StartPlayback( Some(artist.uri.to_owned()), None, None, )); } } Key::Char('r') => { let artists = app.artists.to_owned(); let artist = artists.get(app.artists_list_index); if let Some(artist) = artist { let artist_name = artist.name.clone(); let artist_id_list: Option> = Some(vec![artist.id.clone()]); app.recommendations_context = Some(RecommendationsContext::Artist); app.recommendations_seed = artist_name; app.get_recommendations_for_seed(artist_id_list, None, None); } } k if k == app.user_config.keys.next_page => app.get_current_user_saved_artists_next(), k if k == app.user_config.keys.previous_page => app.get_current_user_saved_artists_previous(), _ => {} } } ================================================ FILE: src/handlers/basic_view.rs ================================================ use crate::{app::App, event::Key, network::IoEvent}; use rspotify::model::{context::CurrentlyPlaybackContext, PlayingItem}; pub fn handler(key: Key, app: &mut App) { if let Key::Char('s') = key { if let Some(CurrentlyPlaybackContext { item: Some(item), .. }) = app.current_playback_context.to_owned() { match item { PlayingItem::Track(track) => { if let Some(track_id) = track.id { app.dispatch(IoEvent::ToggleSaveTrack(track_id)); } } PlayingItem::Episode(episode) => { app.dispatch(IoEvent::ToggleSaveTrack(episode.id)); } }; }; } } ================================================ FILE: src/handlers/common_key_events.rs ================================================ use super::super::app::{ActiveBlock, App, RouteId}; use crate::event::Key; pub fn down_event(key: Key) -> bool { matches!(key, Key::Down | Key::Char('j') | Key::Ctrl('n')) } pub fn up_event(key: Key) -> bool { matches!(key, Key::Up | Key::Char('k') | Key::Ctrl('p')) } pub fn left_event(key: Key) -> bool { matches!(key, Key::Left | Key::Char('h') | Key::Ctrl('b')) } pub fn right_event(key: Key) -> bool { matches!(key, Key::Right | Key::Char('l') | Key::Ctrl('f')) } pub fn high_event(key: Key) -> bool { matches!(key, Key::Char('H')) } pub fn middle_event(key: Key) -> bool { matches!(key, Key::Char('M')) } pub fn low_event(key: Key) -> bool { matches!(key, Key::Char('L')) } pub fn on_down_press_handler(selection_data: &[T], selection_index: Option) -> usize { match selection_index { Some(selection_index) => { if !selection_data.is_empty() { let next_index = selection_index + 1; if next_index > selection_data.len() - 1 { return 0; } else { return next_index; } } 0 } None => 0, } } pub fn on_up_press_handler(selection_data: &[T], selection_index: Option) -> usize { match selection_index { Some(selection_index) => { if !selection_data.is_empty() { if selection_index > 0 { return selection_index - 1; } else { return selection_data.len() - 1; } } 0 } None => 0, } } pub fn on_high_press_handler() -> usize { 0 } pub fn on_middle_press_handler(selection_data: &[T]) -> usize { let mut index = selection_data.len() / 2; if selection_data.len() % 2 == 0 { index -= 1; } index } pub fn on_low_press_handler(selection_data: &[T]) -> usize { selection_data.len() - 1 } pub fn handle_right_event(app: &mut App) { match app.get_current_route().hovered_block { ActiveBlock::MyPlaylists | ActiveBlock::Library => match app.get_current_route().id { RouteId::AlbumTracks => { app.set_current_route_state( Some(ActiveBlock::AlbumTracks), Some(ActiveBlock::AlbumTracks), ); } RouteId::TrackTable => { app.set_current_route_state(Some(ActiveBlock::TrackTable), Some(ActiveBlock::TrackTable)); } RouteId::Podcasts => { app.set_current_route_state(Some(ActiveBlock::Podcasts), Some(ActiveBlock::Podcasts)); } RouteId::Recommendations => { app.set_current_route_state(Some(ActiveBlock::TrackTable), Some(ActiveBlock::TrackTable)); } RouteId::AlbumList => { app.set_current_route_state(Some(ActiveBlock::AlbumList), Some(ActiveBlock::AlbumList)); } RouteId::PodcastEpisodes => { app.set_current_route_state( Some(ActiveBlock::EpisodeTable), Some(ActiveBlock::EpisodeTable), ); } RouteId::MadeForYou => { app.set_current_route_state(Some(ActiveBlock::MadeForYou), Some(ActiveBlock::MadeForYou)); } RouteId::Artists => { app.set_current_route_state(Some(ActiveBlock::Artists), Some(ActiveBlock::Artists)); } RouteId::RecentlyPlayed => { app.set_current_route_state( Some(ActiveBlock::RecentlyPlayed), Some(ActiveBlock::RecentlyPlayed), ); } RouteId::Search => { app.set_current_route_state( Some(ActiveBlock::SearchResultBlock), Some(ActiveBlock::SearchResultBlock), ); } RouteId::Artist => app.set_current_route_state( Some(ActiveBlock::ArtistBlock), Some(ActiveBlock::ArtistBlock), ), RouteId::Home => { app.set_current_route_state(Some(ActiveBlock::Home), Some(ActiveBlock::Home)); } RouteId::SelectedDevice => {} RouteId::Error => {} RouteId::Analysis => {} RouteId::BasicView => {} RouteId::Dialog => {} }, _ => {} }; } pub fn handle_left_event(app: &mut App) { // TODO: This should send you back to either library or playlist based on last selection app.set_current_route_state(Some(ActiveBlock::Empty), Some(ActiveBlock::Library)); } #[cfg(test)] mod tests { use super::*; #[test] fn test_on_down_press_handler() { let data = vec!["Choice 1", "Choice 2", "Choice 3"]; let index = 0; let next_index = on_down_press_handler(&data, Some(index)); assert_eq!(next_index, 1); // Selection wrap if on last item let index = data.len() - 1; let next_index = on_down_press_handler(&data, Some(index)); assert_eq!(next_index, 0); } #[test] fn test_on_up_press_handler() { let data = vec!["Choice 1", "Choice 2", "Choice 3"]; let index = data.len() - 1; let next_index = on_up_press_handler(&data, Some(index)); assert_eq!(next_index, index - 1); // Selection wrap if on first item let index = 0; let next_index = on_up_press_handler(&data, Some(index)); assert_eq!(next_index, data.len() - 1); } } ================================================ FILE: src/handlers/dialog.rs ================================================ use super::super::app::{ActiveBlock, App, DialogContext}; use crate::event::Key; pub fn handler(key: Key, app: &mut App) { match key { Key::Enter => { if let Some(route) = app.pop_navigation_stack() { if app.confirm { if let ActiveBlock::Dialog(d) = route.active_block { match d { DialogContext::PlaylistWindow => handle_playlist_dialog(app), DialogContext::PlaylistSearch => handle_playlist_search_dialog(app), } } } } } Key::Char('q') => { app.pop_navigation_stack(); } Key::Right => app.confirm = !app.confirm, Key::Left => app.confirm = !app.confirm, _ => {} } } fn handle_playlist_dialog(app: &mut App) { app.user_unfollow_playlist() } fn handle_playlist_search_dialog(app: &mut App) { app.user_unfollow_playlist_search_result() } ================================================ FILE: src/handlers/empty.rs ================================================ use super::common_key_events; use crate::{ app::{ActiveBlock, App}, event::Key, }; // When no block is actively selected, just handle regular event pub fn handler(key: Key, app: &mut App) { match key { Key::Enter => { let current_hovered = app.get_current_route().hovered_block; app.set_current_route_state(Some(current_hovered), None); } k if common_key_events::down_event(k) => match app.get_current_route().hovered_block { ActiveBlock::Library => { app.set_current_route_state(None, Some(ActiveBlock::MyPlaylists)); } ActiveBlock::ArtistBlock | ActiveBlock::AlbumList | ActiveBlock::AlbumTracks | ActiveBlock::Artists | ActiveBlock::Podcasts | ActiveBlock::EpisodeTable | ActiveBlock::Home | ActiveBlock::MadeForYou | ActiveBlock::MyPlaylists | ActiveBlock::RecentlyPlayed | ActiveBlock::TrackTable => { app.set_current_route_state(None, Some(ActiveBlock::PlayBar)); } _ => {} }, k if common_key_events::up_event(k) => match app.get_current_route().hovered_block { ActiveBlock::MyPlaylists => { app.set_current_route_state(None, Some(ActiveBlock::Library)); } ActiveBlock::PlayBar => { app.set_current_route_state(None, Some(ActiveBlock::MyPlaylists)); } _ => {} }, k if common_key_events::left_event(k) => match app.get_current_route().hovered_block { ActiveBlock::ArtistBlock | ActiveBlock::AlbumList | ActiveBlock::AlbumTracks | ActiveBlock::Artists | ActiveBlock::Podcasts | ActiveBlock::EpisodeTable | ActiveBlock::Home | ActiveBlock::MadeForYou | ActiveBlock::RecentlyPlayed | ActiveBlock::TrackTable => { app.set_current_route_state(None, Some(ActiveBlock::Library)); } _ => {} }, k if common_key_events::right_event(k) => common_key_events::handle_right_event(app), _ => (), }; } #[cfg(test)] mod tests { use super::*; use crate::app::RouteId; #[test] fn on_enter() { let mut app = App::default(); app.set_current_route_state(Some(ActiveBlock::Empty), Some(ActiveBlock::Library)); handler(Key::Enter, &mut app); let current_route = app.get_current_route(); assert_eq!(current_route.active_block, ActiveBlock::Library); assert_eq!(current_route.hovered_block, ActiveBlock::Library); } #[test] fn on_down_press() { let mut app = App::default(); app.set_current_route_state(Some(ActiveBlock::Empty), Some(ActiveBlock::Library)); handler(Key::Down, &mut app); let current_route = app.get_current_route(); assert_eq!(current_route.active_block, ActiveBlock::Empty); assert_eq!(current_route.hovered_block, ActiveBlock::MyPlaylists); // TODO: test the other cases when they are implemented } #[test] fn on_up_press() { let mut app = App::default(); app.set_current_route_state(Some(ActiveBlock::Empty), Some(ActiveBlock::MyPlaylists)); handler(Key::Up, &mut app); let current_route = app.get_current_route(); assert_eq!(current_route.active_block, ActiveBlock::Empty); assert_eq!(current_route.hovered_block, ActiveBlock::Library); } #[test] fn on_left_press() { let mut app = App::default(); app.set_current_route_state(Some(ActiveBlock::Empty), Some(ActiveBlock::AlbumTracks)); handler(Key::Left, &mut app); let current_route = app.get_current_route(); assert_eq!(current_route.active_block, ActiveBlock::Empty); assert_eq!(current_route.hovered_block, ActiveBlock::Library); app.set_current_route_state(None, Some(ActiveBlock::Home)); handler(Key::Left, &mut app); let current_route = app.get_current_route(); assert_eq!(current_route.hovered_block, ActiveBlock::Library); app.set_current_route_state(None, Some(ActiveBlock::TrackTable)); handler(Key::Left, &mut app); let current_route = app.get_current_route(); assert_eq!(current_route.hovered_block, ActiveBlock::Library); } #[test] fn on_right_press() { let mut app = App::default(); app.set_current_route_state(Some(ActiveBlock::Empty), Some(ActiveBlock::Library)); app.push_navigation_stack(RouteId::AlbumTracks, ActiveBlock::AlbumTracks); handler(Key::Right, &mut app); let current_route = app.get_current_route(); assert_eq!(current_route.active_block, ActiveBlock::AlbumTracks); assert_eq!(current_route.hovered_block, ActiveBlock::AlbumTracks); app.push_navigation_stack(RouteId::Search, ActiveBlock::Empty); app.set_current_route_state(None, Some(ActiveBlock::MyPlaylists)); handler(Key::Right, &mut app); let current_route = app.get_current_route(); assert_eq!(current_route.active_block, ActiveBlock::SearchResultBlock); assert_eq!(current_route.hovered_block, ActiveBlock::SearchResultBlock); app.set_current_route_state(None, Some(ActiveBlock::Library)); app.push_navigation_stack(RouteId::TrackTable, ActiveBlock::TrackTable); handler(Key::Right, &mut app); let current_route = app.get_current_route(); assert_eq!(current_route.active_block, ActiveBlock::TrackTable); assert_eq!(current_route.hovered_block, ActiveBlock::TrackTable); app.set_current_route_state(None, Some(ActiveBlock::Library)); app.push_navigation_stack(RouteId::TrackTable, ActiveBlock::TrackTable); handler(Key::Right, &mut app); let current_route = app.get_current_route(); assert_eq!(current_route.active_block, ActiveBlock::TrackTable); assert_eq!(current_route.hovered_block, ActiveBlock::TrackTable); app.push_navigation_stack(RouteId::Home, ActiveBlock::Home); app.set_current_route_state(Some(ActiveBlock::Empty), Some(ActiveBlock::Library)); handler(Key::Right, &mut app); let current_route = app.get_current_route(); assert_eq!(current_route.active_block, ActiveBlock::Home); assert_eq!(current_route.hovered_block, ActiveBlock::Home); } } ================================================ FILE: src/handlers/episode_table.rs ================================================ use super::{ super::app::{App, EpisodeTableContext}, common_key_events, }; use crate::app::ActiveBlock; use crate::event::Key; use crate::network::IoEvent; pub fn handler(key: Key, app: &mut App) { match key { k if common_key_events::left_event(k) => common_key_events::handle_left_event(app), k if common_key_events::down_event(k) => { if let Some(episodes) = &mut app.library.show_episodes.get_results(None) { let next_index = common_key_events::on_down_press_handler(&episodes.items, Some(app.episode_list_index)); app.episode_list_index = next_index; } } k if common_key_events::up_event(k) => { if let Some(episodes) = &mut app.library.show_episodes.get_results(None) { let next_index = common_key_events::on_up_press_handler(&episodes.items, Some(app.episode_list_index)); app.episode_list_index = next_index; } } k if common_key_events::high_event(k) => { if let Some(_episodes) = app.library.show_episodes.get_results(None) { let next_index = common_key_events::on_high_press_handler(); app.episode_list_index = next_index; } } k if common_key_events::middle_event(k) => { if let Some(episodes) = app.library.show_episodes.get_results(None) { let next_index = common_key_events::on_middle_press_handler(&episodes.items); app.episode_list_index = next_index; } } k if common_key_events::low_event(k) => { if let Some(episodes) = app.library.show_episodes.get_results(None) { let next_index = common_key_events::on_low_press_handler(&episodes.items); app.episode_list_index = next_index; } } Key::Enter => { on_enter(app); } // Scroll down k if k == app.user_config.keys.next_page => handle_next_event(app), // Scroll up k if k == app.user_config.keys.previous_page => handle_prev_event(app), Key::Char('S') => toggle_sort_by_date(app), Key::Char('s') => handle_follow_event(app), Key::Char('D') => handle_unfollow_event(app), Key::Ctrl('e') => jump_to_end(app), Key::Ctrl('a') => jump_to_start(app), _ => {} } } fn jump_to_end(app: &mut App) { if let Some(episodes) = app.library.show_episodes.get_results(None) { let last_idx = episodes.items.len() - 1; app.episode_list_index = last_idx; } } fn on_enter(app: &mut App) { if let Some(episodes) = app.library.show_episodes.get_results(None) { let episode_uris = episodes .items .iter() .map(|episode| episode.uri.to_owned()) .collect::>(); app.dispatch(IoEvent::StartPlayback( None, Some(episode_uris), Some(app.episode_list_index), )); } } fn handle_prev_event(app: &mut App) { app.get_episode_table_previous(); } fn handle_next_event(app: &mut App) { match app.episode_table_context { EpisodeTableContext::Full => { if let Some(selected_episode) = app.selected_show_full.clone() { let show_id = selected_episode.show.id; app.get_episode_table_next(show_id) } } EpisodeTableContext::Simplified => { if let Some(selected_episode) = app.selected_show_simplified.clone() { let show_id = selected_episode.show.id; app.get_episode_table_next(show_id) } } } } fn handle_follow_event(app: &mut App) { app.user_follow_show(ActiveBlock::EpisodeTable); } fn handle_unfollow_event(app: &mut App) { app.user_unfollow_show(ActiveBlock::EpisodeTable); } fn jump_to_start(app: &mut App) { app.episode_list_index = 0; } fn toggle_sort_by_date(app: &mut App) { //TODO: reverse whole list and not just currently visible episodes let selected_id = match app.library.show_episodes.get_results(None) { Some(episodes) => episodes .items .get(app.episode_list_index) .map(|e| e.id.clone()), None => None, }; if let Some(episodes) = app.library.show_episodes.get_mut_results(None) { episodes.items.reverse(); } if let Some(id) = selected_id { if let Some(episodes) = app.library.show_episodes.get_results(None) { app.episode_list_index = episodes.items.iter().position(|e| e.id == id).unwrap_or(0); } } else { app.episode_list_index = 0; } } ================================================ FILE: src/handlers/error_screen.rs ================================================ use crate::{app::App, event::Key}; pub fn handler(_key: Key, _app: &mut App) {} ================================================ FILE: src/handlers/help_menu.rs ================================================ use super::common_key_events; use crate::{app::App, event::Key}; #[derive(PartialEq)] enum Direction { Up, Down, } pub fn handler(key: Key, app: &mut App) { match key { k if common_key_events::down_event(k) => { move_page(Direction::Down, app); } k if common_key_events::up_event(k) => { move_page(Direction::Up, app); } Key::Ctrl('d') => { move_page(Direction::Down, app); } Key::Ctrl('u') => { move_page(Direction::Up, app); } _ => {} }; } fn move_page(direction: Direction, app: &mut App) { if direction == Direction::Up { if app.help_menu_page > 0 { app.help_menu_page -= 1; } } else if direction == Direction::Down { app.help_menu_page += 1; } app.calculate_help_menu_offset(); } ================================================ FILE: src/handlers/home.rs ================================================ use super::{super::app::App, common_key_events}; use crate::event::Key; const LARGE_SCROLL: u16 = 10; const SMALL_SCROLL: u16 = 1; pub fn handler(key: Key, app: &mut App) { match key { k if common_key_events::left_event(k) => common_key_events::handle_left_event(app), k if common_key_events::down_event(k) => { app.home_scroll += SMALL_SCROLL; } k if common_key_events::up_event(k) => { if app.home_scroll > 0 { app.home_scroll -= SMALL_SCROLL; } } k if k == app.user_config.keys.next_page => { app.home_scroll += LARGE_SCROLL; } k if k == app.user_config.keys.previous_page => { if app.home_scroll > LARGE_SCROLL { app.home_scroll -= LARGE_SCROLL; } else { app.home_scroll = 0; } } _ => {} } } #[cfg(test)] mod tests { use super::*; #[test] fn on_small_down_press() { let mut app = App::default(); handler(Key::Down, &mut app); assert_eq!(app.home_scroll, SMALL_SCROLL); handler(Key::Down, &mut app); assert_eq!(app.home_scroll, SMALL_SCROLL * 2); } #[test] fn on_small_up_press() { let mut app = App::default(); handler(Key::Up, &mut app); assert_eq!(app.home_scroll, 0); app.home_scroll = 1; handler(Key::Up, &mut app); assert_eq!(app.home_scroll, 0); // Check that smashing the up button doesn't go to negative scroll (which would cause a crash) handler(Key::Up, &mut app); handler(Key::Up, &mut app); handler(Key::Up, &mut app); assert_eq!(app.home_scroll, 0); } #[test] fn on_large_down_press() { let mut app = App::default(); handler(Key::Ctrl('d'), &mut app); assert_eq!(app.home_scroll, LARGE_SCROLL); handler(Key::Ctrl('d'), &mut app); assert_eq!(app.home_scroll, LARGE_SCROLL * 2); } #[test] fn on_large_up_press() { let mut app = App::default(); let scroll = 37; app.home_scroll = scroll; handler(Key::Ctrl('u'), &mut app); assert_eq!(app.home_scroll, scroll - LARGE_SCROLL); handler(Key::Ctrl('u'), &mut app); assert_eq!(app.home_scroll, scroll - LARGE_SCROLL * 2); // Check that smashing the up button doesn't go to negative scroll (which would cause a crash) handler(Key::Ctrl('u'), &mut app); handler(Key::Ctrl('u'), &mut app); handler(Key::Ctrl('u'), &mut app); assert_eq!(app.home_scroll, 0); } } ================================================ FILE: src/handlers/input.rs ================================================ extern crate unicode_width; use super::super::app::{ActiveBlock, App, RouteId}; use crate::event::Key; use crate::network::IoEvent; use std::convert::TryInto; use unicode_width::{UnicodeWidthChar, UnicodeWidthStr}; // Handle event when the search input block is active pub fn handler(key: Key, app: &mut App) { match key { Key::Ctrl('k') => { app.input.drain(app.input_idx..app.input.len()); } Key::Ctrl('u') => { app.input.drain(..app.input_idx); app.input_idx = 0; app.input_cursor_position = 0; } Key::Ctrl('l') => { app.input = vec![]; app.input_idx = 0; app.input_cursor_position = 0; } Key::Ctrl('w') => { if app.input_cursor_position == 0 { return; } let word_end = match app.input[..app.input_idx].iter().rposition(|&x| x != ' ') { Some(index) => index + 1, None => 0, }; let word_start = match app.input[..word_end].iter().rposition(|&x| x == ' ') { Some(index) => index + 1, None => 0, }; let deleted: String = app.input[word_start..app.input_idx].iter().collect(); let deleted_len: u16 = UnicodeWidthStr::width(deleted.as_str()).try_into().unwrap(); app.input.drain(word_start..app.input_idx); app.input_idx = word_start; app.input_cursor_position -= deleted_len; } Key::End | Key::Ctrl('e') => { app.input_idx = app.input.len(); let input_string: String = app.input.iter().collect(); app.input_cursor_position = UnicodeWidthStr::width(input_string.as_str()) .try_into() .unwrap(); } Key::Home | Key::Ctrl('a') => { app.input_idx = 0; app.input_cursor_position = 0; } Key::Left | Key::Ctrl('b') => { if !app.input.is_empty() && app.input_idx > 0 { let last_c = app.input[app.input_idx - 1]; app.input_idx -= 1; app.input_cursor_position -= compute_character_width(last_c); } } Key::Right | Key::Ctrl('f') => { if app.input_idx < app.input.len() { let next_c = app.input[app.input_idx]; app.input_idx += 1; app.input_cursor_position += compute_character_width(next_c); } } Key::Esc => { app.set_current_route_state(Some(ActiveBlock::Empty), Some(ActiveBlock::Library)); } Key::Enter => { let input_str: String = app.input.iter().collect(); process_input(app, input_str); } Key::Char(c) => { app.input.insert(app.input_idx, c); app.input_idx += 1; app.input_cursor_position += compute_character_width(c); } Key::Backspace | Key::Ctrl('h') => { if !app.input.is_empty() && app.input_idx > 0 { let last_c = app.input.remove(app.input_idx - 1); app.input_idx -= 1; app.input_cursor_position -= compute_character_width(last_c); } } Key::Delete | Key::Ctrl('d') => { if !app.input.is_empty() && app.input_idx < app.input.len() { app.input.remove(app.input_idx); } } _ => {} } } fn process_input(app: &mut App, input: String) { // Don't do anything if there is no input if input.is_empty() { return; } // On searching for a track, clear the playlist selection app.selected_playlist_index = Some(0); if attempt_process_uri(app, &input, "https://open.spotify.com/", "/") || attempt_process_uri(app, &input, "spotify:", ":") { return; } // Default fallback behavior: treat the input as a raw search phrase. app.dispatch(IoEvent::GetSearchResults(input, app.get_user_country())); app.push_navigation_stack(RouteId::Search, ActiveBlock::SearchResultBlock); } fn spotify_resource_id(base: &str, uri: &str, sep: &str, resource_type: &str) -> (String, bool) { let uri_prefix = format!("{}{}{}", base, resource_type, sep); let id_string_with_query_params = uri.trim_start_matches(&uri_prefix); let query_idx = id_string_with_query_params .find('?') .unwrap_or_else(|| id_string_with_query_params.len()); let id_string = id_string_with_query_params[0..query_idx].to_string(); // If the lengths aren't equal, we must have found a match. let matched = id_string_with_query_params.len() != uri.len() && id_string.len() != uri.len(); (id_string, matched) } // Returns true if the input was successfully processed as a Spotify URI. fn attempt_process_uri(app: &mut App, input: &str, base: &str, sep: &str) -> bool { let (album_id, matched) = spotify_resource_id(base, input, sep, "album"); if matched { app.dispatch(IoEvent::GetAlbum(album_id)); return true; } let (artist_id, matched) = spotify_resource_id(base, input, sep, "artist"); if matched { app.get_artist(artist_id, "".to_string()); app.push_navigation_stack(RouteId::Artist, ActiveBlock::ArtistBlock); return true; } let (track_id, matched) = spotify_resource_id(base, input, sep, "track"); if matched { app.dispatch(IoEvent::GetAlbumForTrack(track_id)); return true; } let (playlist_id, matched) = spotify_resource_id(base, input, sep, "playlist"); if matched { app.dispatch(IoEvent::GetPlaylistTracks(playlist_id, 0)); return true; } let (show_id, matched) = spotify_resource_id(base, input, sep, "show"); if matched { app.dispatch(IoEvent::GetShow(show_id)); return true; } false } fn compute_character_width(character: char) -> u16 { UnicodeWidthChar::width(character) .unwrap() .try_into() .unwrap() } #[cfg(test)] mod tests { use super::*; fn str_to_vec_char(s: &str) -> Vec { String::from(s).chars().collect() } #[test] fn test_compute_character_width_with_multiple_characters() { assert_eq!(1, compute_character_width('a')); assert_eq!(1, compute_character_width('ß')); assert_eq!(1, compute_character_width('ç')); } #[test] fn test_input_handler_clear_input_on_ctrl_l() { let mut app = App::default(); app.input = str_to_vec_char("My text"); handler(Key::Ctrl('l'), &mut app); assert_eq!(app.input, str_to_vec_char("")); } #[test] fn test_input_handler_ctrl_u() { let mut app = App::default(); app.input = str_to_vec_char("My text"); handler(Key::Ctrl('u'), &mut app); assert_eq!(app.input, str_to_vec_char("My text")); app.input_cursor_position = 3; app.input_idx = 3; handler(Key::Ctrl('u'), &mut app); assert_eq!(app.input, str_to_vec_char("text")); } #[test] fn test_input_handler_ctrl_k() { let mut app = App::default(); app.input = str_to_vec_char("My text"); handler(Key::Ctrl('k'), &mut app); assert_eq!(app.input, str_to_vec_char("")); app.input = str_to_vec_char("My text"); app.input_cursor_position = 2; app.input_idx = 2; handler(Key::Ctrl('k'), &mut app); assert_eq!(app.input, str_to_vec_char("My")); handler(Key::Ctrl('k'), &mut app); assert_eq!(app.input, str_to_vec_char("My")); } #[test] fn test_input_handler_ctrl_w() { let mut app = App::default(); app.input = str_to_vec_char("My text"); handler(Key::Ctrl('w'), &mut app); assert_eq!(app.input, str_to_vec_char("My text")); app.input_cursor_position = 3; app.input_idx = 3; handler(Key::Ctrl('w'), &mut app); assert_eq!(app.input, str_to_vec_char("text")); assert_eq!(app.input_cursor_position, 0); assert_eq!(app.input_idx, 0); app.input = str_to_vec_char(" "); app.input_cursor_position = 3; app.input_idx = 3; handler(Key::Ctrl('w'), &mut app); assert_eq!(app.input, str_to_vec_char(" ")); assert_eq!(app.input_cursor_position, 0); assert_eq!(app.input_idx, 0); app.input_cursor_position = 1; app.input_idx = 1; handler(Key::Ctrl('w'), &mut app); assert_eq!(app.input, str_to_vec_char("")); assert_eq!(app.input_cursor_position, 0); assert_eq!(app.input_idx, 0); app.input = str_to_vec_char("Hello there "); app.input_cursor_position = 13; app.input_idx = 13; handler(Key::Ctrl('w'), &mut app); assert_eq!(app.input, str_to_vec_char("Hello ")); assert_eq!(app.input_cursor_position, 6); assert_eq!(app.input_idx, 6); } #[test] fn test_input_handler_esc_back_to_playlist() { let mut app = App::default(); app.set_current_route_state(Some(ActiveBlock::MyPlaylists), None); handler(Key::Esc, &mut app); let current_route = app.get_current_route(); assert_eq!(current_route.active_block, ActiveBlock::Empty); } #[test] fn test_input_handler_on_enter_text() { let mut app = App::default(); app.input = str_to_vec_char("My tex"); app.input_cursor_position = app.input.len().try_into().unwrap(); app.input_idx = app.input.len(); handler(Key::Char('t'), &mut app); assert_eq!(app.input, str_to_vec_char("My text")); } #[test] fn test_input_handler_backspace() { let mut app = App::default(); app.input = str_to_vec_char("My text"); app.input_cursor_position = app.input.len().try_into().unwrap(); app.input_idx = app.input.len(); handler(Key::Backspace, &mut app); assert_eq!(app.input, str_to_vec_char("My tex")); // Test that backspace deletes from the cursor position app.input_idx = 2; app.input_cursor_position = 2; handler(Key::Backspace, &mut app); assert_eq!(app.input, str_to_vec_char("M tex")); app.input_idx = 1; app.input_cursor_position = 1; handler(Key::Ctrl('h'), &mut app); assert_eq!(app.input, str_to_vec_char(" tex")); } #[test] fn test_input_handler_delete() { let mut app = App::default(); app.input = str_to_vec_char("My text"); app.input_idx = 3; app.input_cursor_position = 3; handler(Key::Delete, &mut app); assert_eq!(app.input, str_to_vec_char("My ext")); app.input = str_to_vec_char("ラスト"); app.input_idx = 1; app.input_cursor_position = 1; handler(Key::Delete, &mut app); assert_eq!(app.input, str_to_vec_char("ラト")); app.input = str_to_vec_char("Rust"); app.input_idx = 2; app.input_cursor_position = 2; handler(Key::Ctrl('d'), &mut app); assert_eq!(app.input, str_to_vec_char("Rut")); } #[test] fn test_input_handler_left_event() { let mut app = App::default(); app.input = str_to_vec_char("My text"); let input_len = app.input.len().try_into().unwrap(); app.input_idx = app.input.len(); app.input_cursor_position = input_len; handler(Key::Left, &mut app); assert_eq!(app.input_cursor_position, input_len - 1); handler(Key::Left, &mut app); assert_eq!(app.input_cursor_position, input_len - 2); handler(Key::Left, &mut app); assert_eq!(app.input_cursor_position, input_len - 3); handler(Key::Ctrl('b'), &mut app); assert_eq!(app.input_cursor_position, input_len - 4); handler(Key::Ctrl('b'), &mut app); assert_eq!(app.input_cursor_position, input_len - 5); // Pretend to smash the left event to test the we have no out-of-bounds crash for _ in 0..20 { handler(Key::Left, &mut app); } assert_eq!(app.input_cursor_position, 0); } #[test] fn test_input_handler_on_enter_text_non_english_char() { let mut app = App::default(); app.input = str_to_vec_char("ыа"); app.input_cursor_position = app.input.len().try_into().unwrap(); app.input_idx = app.input.len(); handler(Key::Char('ы'), &mut app); assert_eq!(app.input, str_to_vec_char("ыаы")); } #[test] fn test_input_handler_on_enter_text_wide_char() { let mut app = App::default(); app.input = str_to_vec_char("你"); app.input_cursor_position = 2; // 你 is 2 char wide app.input_idx = 1; // 1 char handler(Key::Char('好'), &mut app); assert_eq!(app.input, str_to_vec_char("你好")); assert_eq!(app.input_idx, 2); assert_eq!(app.input_cursor_position, 4); } mod test_uri_parsing { use super::*; const URI_BASE: &str = "spotify:"; const URL_BASE: &str = "https://open.spotify.com/"; fn check_uri_parse(expected_id: &str, parsed: (String, bool)) { assert_eq!(parsed.1, true); assert_eq!(parsed.0, expected_id); } fn run_test_for_id_and_resource_type(id: &str, resource_type: &str) { check_uri_parse( id, spotify_resource_id( URI_BASE, &format!("spotify:{}:{}", resource_type, id), ":", resource_type, ), ); check_uri_parse( id, spotify_resource_id( URL_BASE, &format!("https://open.spotify.com/{}/{}", resource_type, id), "/", resource_type, ), ) } #[test] fn artist() { let expected_artist_id = "2ye2Wgw4gimLv2eAKyk1NB"; run_test_for_id_and_resource_type(expected_artist_id, "artist"); } #[test] fn album() { let expected_album_id = "5gzLOflH95LkKYE6XSXE9k"; run_test_for_id_and_resource_type(expected_album_id, "album"); } #[test] fn playlist() { let expected_playlist_id = "1cJ6lPBYj2fscs0kqBHsVV"; run_test_for_id_and_resource_type(expected_playlist_id, "playlist"); } #[test] fn show() { let expected_show_id = "3aNsrV6lkzmcU1w8u8kA7N"; run_test_for_id_and_resource_type(expected_show_id, "show"); } #[test] fn track() { let expected_track_id = "10igKaIKsSB6ZnWxPxPvKO"; run_test_for_id_and_resource_type(expected_track_id, "track"); } #[test] fn invalid_format_doesnt_match() { let swapped = "show:spotify:3aNsrV6lkzmcU1w8u8kA7N"; let totally_wrong = "hehe-haha-3aNsrV6lkzmcU1w8u8kA7N"; let random = "random string"; let (_, matched) = spotify_resource_id(URI_BASE, swapped, ":", "track"); assert_eq!(matched, false); let (_, matched) = spotify_resource_id(URI_BASE, totally_wrong, ":", "track"); assert_eq!(matched, false); let (_, matched) = spotify_resource_id(URL_BASE, totally_wrong, "/", "track"); assert_eq!(matched, false); let (_, matched) = spotify_resource_id(URL_BASE, random, "/", "track"); assert_eq!(matched, false); } #[test] fn parse_with_query_parameters() { // If this test ever fails due to some change to the parsing logic, it is likely a sign we // should just integrate the url crate instead of trying to do things ourselves. let playlist_url_with_query = "https://open.spotify.com/playlist/1cJ6lPBYj2fscs0kqBHsVV?si=OdwuJsbsSeuUAOadehng3A"; let playlist_url = "https://open.spotify.com/playlist/1cJ6lPBYj2fscs0kqBHsVV"; let expected_id = "1cJ6lPBYj2fscs0kqBHsVV"; let (actual_id, matched) = spotify_resource_id(URL_BASE, playlist_url, "/", "playlist"); assert_eq!(matched, true); assert_eq!(actual_id, expected_id); let (actual_id, matched) = spotify_resource_id(URL_BASE, playlist_url_with_query, "/", "playlist"); assert_eq!(matched, true); assert_eq!(actual_id, expected_id); } #[test] fn mismatched_resource_types_do_not_match() { let playlist_url = "https://open.spotify.com/playlist/1cJ6lPBYj2fscs0kqBHsVV?si=OdwuJsbsSeuUAOadehng3A"; let (_, matched) = spotify_resource_id(URL_BASE, playlist_url, "/", "album"); assert_eq!(matched, false); } } } ================================================ FILE: src/handlers/library.rs ================================================ use super::{ super::app::{ActiveBlock, App, RouteId, LIBRARY_OPTIONS}, common_key_events, }; use crate::event::Key; use crate::network::IoEvent; pub fn handler(key: Key, app: &mut App) { match key { k if common_key_events::right_event(k) => common_key_events::handle_right_event(app), k if common_key_events::down_event(k) => { let next_index = common_key_events::on_down_press_handler( &LIBRARY_OPTIONS, Some(app.library.selected_index), ); app.library.selected_index = next_index; } k if common_key_events::up_event(k) => { let next_index = common_key_events::on_up_press_handler(&LIBRARY_OPTIONS, Some(app.library.selected_index)); app.library.selected_index = next_index; } k if common_key_events::high_event(k) => { let next_index = common_key_events::on_high_press_handler(); app.library.selected_index = next_index; } k if common_key_events::middle_event(k) => { let next_index = common_key_events::on_middle_press_handler(&LIBRARY_OPTIONS); app.library.selected_index = next_index; } k if common_key_events::low_event(k) => { let next_index = common_key_events::on_low_press_handler(&LIBRARY_OPTIONS); app.library.selected_index = next_index } // `library` should probably be an array of structs with enums rather than just using indexes // like this Key::Enter => match app.library.selected_index { // Made For You, 0 => { app.get_made_for_you(); app.push_navigation_stack(RouteId::MadeForYou, ActiveBlock::MadeForYou); } // Recently Played, 1 => { app.dispatch(IoEvent::GetRecentlyPlayed); app.push_navigation_stack(RouteId::RecentlyPlayed, ActiveBlock::RecentlyPlayed); } // Liked Songs, 2 => { app.dispatch(IoEvent::GetCurrentSavedTracks(None)); app.push_navigation_stack(RouteId::TrackTable, ActiveBlock::TrackTable); } // Albums, 3 => { app.dispatch(IoEvent::GetCurrentUserSavedAlbums(None)); app.push_navigation_stack(RouteId::AlbumList, ActiveBlock::AlbumList); } // Artists, 4 => { app.dispatch(IoEvent::GetFollowedArtists(None)); app.push_navigation_stack(RouteId::Artists, ActiveBlock::Artists); } // Podcasts, 5 => { app.dispatch(IoEvent::GetCurrentUserSavedShows(None)); app.push_navigation_stack(RouteId::Podcasts, ActiveBlock::Podcasts); } // This is required because Rust can't tell if this pattern in exhaustive _ => {} }, _ => (), }; } ================================================ FILE: src/handlers/made_for_you.rs ================================================ use super::{ super::app::{App, TrackTableContext}, common_key_events, }; use crate::event::Key; use crate::network::IoEvent; pub fn handler(key: Key, app: &mut App) { match key { k if common_key_events::left_event(k) => common_key_events::handle_left_event(app), k if common_key_events::up_event(k) => { if let Some(playlists) = &mut app.library.made_for_you_playlists.get_results(None) { let next_index = common_key_events::on_up_press_handler(&playlists.items, Some(app.made_for_you_index)); app.made_for_you_index = next_index; } } k if common_key_events::down_event(k) => { if let Some(playlists) = &mut app.library.made_for_you_playlists.get_results(None) { let next_index = common_key_events::on_down_press_handler(&playlists.items, Some(app.made_for_you_index)); app.made_for_you_index = next_index; } } k if common_key_events::high_event(k) => { if let Some(_playlists) = &mut app.library.made_for_you_playlists.get_results(None) { let next_index = common_key_events::on_high_press_handler(); app.made_for_you_index = next_index; } } k if common_key_events::middle_event(k) => { if let Some(playlists) = &mut app.library.made_for_you_playlists.get_results(None) { let next_index = common_key_events::on_middle_press_handler(&playlists.items); app.made_for_you_index = next_index; } } k if common_key_events::low_event(k) => { if let Some(playlists) = &mut app.library.made_for_you_playlists.get_results(None) { let next_index = common_key_events::on_low_press_handler(&playlists.items); app.made_for_you_index = next_index; } } Key::Enter => { if let (Some(playlists), selected_playlist_index) = ( &app.library.made_for_you_playlists.get_results(Some(0)), &app.made_for_you_index, ) { app.track_table.context = Some(TrackTableContext::MadeForYou); app.playlist_offset = 0; if let Some(selected_playlist) = playlists.items.get(selected_playlist_index.to_owned()) { app.made_for_you_offset = 0; let playlist_id = selected_playlist.id.to_owned(); app.dispatch(IoEvent::GetMadeForYouPlaylistTracks( playlist_id, app.made_for_you_offset, )); } }; } _ => {} } } ================================================ FILE: src/handlers/mod.rs ================================================ mod album_list; mod album_tracks; mod analysis; mod artist; mod artists; mod basic_view; mod common_key_events; mod dialog; mod empty; mod episode_table; mod error_screen; mod help_menu; mod home; mod input; mod library; mod made_for_you; mod playbar; mod playlist; mod podcasts; mod recently_played; mod search_results; mod select_device; mod track_table; use super::app::{ActiveBlock, App, ArtistBlock, RouteId, SearchResultBlock}; use crate::event::Key; use crate::network::IoEvent; use rspotify::model::{context::CurrentlyPlaybackContext, PlayingItem}; pub use input::handler as input_handler; pub fn handle_app(key: Key, app: &mut App) { // First handle any global event and then move to block event match key { Key::Esc => { handle_escape(app); } _ if key == app.user_config.keys.jump_to_album => { handle_jump_to_album(app); } _ if key == app.user_config.keys.jump_to_artist_album => { handle_jump_to_artist_album(app); } _ if key == app.user_config.keys.jump_to_context => { handle_jump_to_context(app); } _ if key == app.user_config.keys.manage_devices => { app.dispatch(IoEvent::GetDevices); } _ if key == app.user_config.keys.decrease_volume => { app.decrease_volume(); } _ if key == app.user_config.keys.increase_volume => { app.increase_volume(); } // Press space to toggle playback _ if key == app.user_config.keys.toggle_playback => { app.toggle_playback(); } _ if key == app.user_config.keys.seek_backwards => { app.seek_backwards(); } _ if key == app.user_config.keys.seek_forwards => { app.seek_forwards(); } _ if key == app.user_config.keys.next_track => { app.dispatch(IoEvent::NextTrack); } _ if key == app.user_config.keys.previous_track => { app.previous_track(); } _ if key == app.user_config.keys.help => { app.set_current_route_state(Some(ActiveBlock::HelpMenu), None); } _ if key == app.user_config.keys.shuffle => { app.shuffle(); } _ if key == app.user_config.keys.repeat => { app.repeat(); } _ if key == app.user_config.keys.search => { app.set_current_route_state(Some(ActiveBlock::Input), Some(ActiveBlock::Input)); } _ if key == app.user_config.keys.copy_song_url => { app.copy_song_url(); } _ if key == app.user_config.keys.copy_album_url => { app.copy_album_url(); } _ if key == app.user_config.keys.audio_analysis => { app.get_audio_analysis(); } _ if key == app.user_config.keys.basic_view => { app.push_navigation_stack(RouteId::BasicView, ActiveBlock::BasicView); } _ => handle_block_events(key, app), } } // Handle event for the current active block fn handle_block_events(key: Key, app: &mut App) { let current_route = app.get_current_route(); match current_route.active_block { ActiveBlock::Analysis => { analysis::handler(key, app); } ActiveBlock::ArtistBlock => { artist::handler(key, app); } ActiveBlock::Input => { input::handler(key, app); } ActiveBlock::MyPlaylists => { playlist::handler(key, app); } ActiveBlock::TrackTable => { track_table::handler(key, app); } ActiveBlock::EpisodeTable => { episode_table::handler(key, app); } ActiveBlock::HelpMenu => { help_menu::handler(key, app); } ActiveBlock::Error => { error_screen::handler(key, app); } ActiveBlock::SelectDevice => { select_device::handler(key, app); } ActiveBlock::SearchResultBlock => { search_results::handler(key, app); } ActiveBlock::Home => { home::handler(key, app); } ActiveBlock::AlbumList => { album_list::handler(key, app); } ActiveBlock::AlbumTracks => { album_tracks::handler(key, app); } ActiveBlock::Library => { library::handler(key, app); } ActiveBlock::Empty => { empty::handler(key, app); } ActiveBlock::RecentlyPlayed => { recently_played::handler(key, app); } ActiveBlock::Artists => { artists::handler(key, app); } ActiveBlock::MadeForYou => { made_for_you::handler(key, app); } ActiveBlock::Podcasts => { podcasts::handler(key, app); } ActiveBlock::PlayBar => { playbar::handler(key, app); } ActiveBlock::BasicView => { basic_view::handler(key, app); } ActiveBlock::Dialog(_) => { dialog::handler(key, app); } } } fn handle_escape(app: &mut App) { match app.get_current_route().active_block { ActiveBlock::SearchResultBlock => { app.search_results.selected_block = SearchResultBlock::Empty; } ActiveBlock::ArtistBlock => { if let Some(artist) = &mut app.artist { artist.artist_selected_block = ArtistBlock::Empty; } } ActiveBlock::Error => { app.pop_navigation_stack(); } ActiveBlock::Dialog(_) => { app.pop_navigation_stack(); } // These are global views that have no active/inactive distinction so do nothing ActiveBlock::SelectDevice | ActiveBlock::Analysis => {} _ => { app.set_current_route_state(Some(ActiveBlock::Empty), None); } } } fn handle_jump_to_context(app: &mut App) { if let Some(current_playback_context) = &app.current_playback_context { if let Some(play_context) = current_playback_context.context.clone() { match play_context._type { rspotify::senum::Type::Album => handle_jump_to_album(app), rspotify::senum::Type::Artist => handle_jump_to_artist_album(app), rspotify::senum::Type::Playlist => { app.dispatch(IoEvent::GetPlaylistTracks(play_context.uri, 0)) } _ => {} } } } } fn handle_jump_to_album(app: &mut App) { if let Some(CurrentlyPlaybackContext { item: Some(item), .. }) = app.current_playback_context.to_owned() { match item { PlayingItem::Track(track) => { app.dispatch(IoEvent::GetAlbumTracks(Box::new(track.album))); } PlayingItem::Episode(episode) => { app.dispatch(IoEvent::GetShowEpisodes(Box::new(episode.show))); } }; } } // NOTE: this only finds the first artist of the song and jumps to their albums fn handle_jump_to_artist_album(app: &mut App) { if let Some(CurrentlyPlaybackContext { item: Some(item), .. }) = app.current_playback_context.to_owned() { match item { PlayingItem::Track(track) => { if let Some(artist) = track.artists.first() { if let Some(artist_id) = artist.id.clone() { app.get_artist(artist_id, artist.name.clone()); app.push_navigation_stack(RouteId::Artist, ActiveBlock::ArtistBlock); } } } PlayingItem::Episode(_episode) => { // Do nothing for episode (yet!) } } }; } ================================================ FILE: src/handlers/playbar.rs ================================================ use super::{ super::app::{ActiveBlock, App}, common_key_events, }; use crate::event::Key; use crate::network::IoEvent; use rspotify::model::{context::CurrentlyPlaybackContext, PlayingItem}; pub fn handler(key: Key, app: &mut App) { match key { k if common_key_events::up_event(k) => { app.set_current_route_state(Some(ActiveBlock::Empty), Some(ActiveBlock::MyPlaylists)); } Key::Char('s') => { if let Some(CurrentlyPlaybackContext { item: Some(item), .. }) = app.current_playback_context.to_owned() { match item { PlayingItem::Track(track) => { if let Some(track_id) = track.id { app.dispatch(IoEvent::ToggleSaveTrack(track_id)); } } PlayingItem::Episode(episode) => { app.dispatch(IoEvent::ToggleSaveTrack(episode.id)); } }; }; } _ => {} }; } #[cfg(test)] mod tests { use super::*; #[test] fn on_left_press() { let mut app = App::default(); app.set_current_route_state(Some(ActiveBlock::PlayBar), Some(ActiveBlock::PlayBar)); handler(Key::Up, &mut app); let current_route = app.get_current_route(); assert_eq!(current_route.active_block, ActiveBlock::Empty); assert_eq!(current_route.hovered_block, ActiveBlock::MyPlaylists); } } ================================================ FILE: src/handlers/playlist.rs ================================================ use super::{ super::app::{App, DialogContext, TrackTableContext}, common_key_events, }; use crate::app::{ActiveBlock, RouteId}; use crate::event::Key; use crate::network::IoEvent; pub fn handler(key: Key, app: &mut App) { match key { k if common_key_events::right_event(k) => common_key_events::handle_right_event(app), k if common_key_events::down_event(k) => { match &app.playlists { Some(p) => { if let Some(selected_playlist_index) = app.selected_playlist_index { let next_index = common_key_events::on_down_press_handler(&p.items, Some(selected_playlist_index)); app.selected_playlist_index = Some(next_index); } } None => {} }; } k if common_key_events::up_event(k) => { match &app.playlists { Some(p) => { let next_index = common_key_events::on_up_press_handler(&p.items, app.selected_playlist_index); app.selected_playlist_index = Some(next_index); } None => {} }; } k if common_key_events::high_event(k) => { match &app.playlists { Some(_p) => { let next_index = common_key_events::on_high_press_handler(); app.selected_playlist_index = Some(next_index); } None => {} }; } k if common_key_events::middle_event(k) => { match &app.playlists { Some(p) => { let next_index = common_key_events::on_middle_press_handler(&p.items); app.selected_playlist_index = Some(next_index); } None => {} }; } k if common_key_events::low_event(k) => { match &app.playlists { Some(p) => { let next_index = common_key_events::on_low_press_handler(&p.items); app.selected_playlist_index = Some(next_index); } None => {} }; } Key::Enter => { if let (Some(playlists), Some(selected_playlist_index)) = (&app.playlists, &app.selected_playlist_index) { app.active_playlist_index = Some(selected_playlist_index.to_owned()); app.track_table.context = Some(TrackTableContext::MyPlaylists); app.playlist_offset = 0; if let Some(selected_playlist) = playlists.items.get(selected_playlist_index.to_owned()) { let playlist_id = selected_playlist.id.to_owned(); app.dispatch(IoEvent::GetPlaylistTracks(playlist_id, app.playlist_offset)); } }; } Key::Char('D') => { if let (Some(playlists), Some(selected_index)) = (&app.playlists, app.selected_playlist_index) { let selected_playlist = &playlists.items[selected_index].name; app.dialog = Some(selected_playlist.clone()); app.confirm = false; app.push_navigation_stack( RouteId::Dialog, ActiveBlock::Dialog(DialogContext::PlaylistWindow), ); } } _ => {} } } #[cfg(test)] mod tests { #[test] fn test() {} } ================================================ FILE: src/handlers/podcasts.rs ================================================ use super::common_key_events; use crate::{ app::{ActiveBlock, App}, event::Key, network::IoEvent, }; pub fn handler(key: Key, app: &mut App) { match key { k if common_key_events::left_event(k) => common_key_events::handle_left_event(app), k if common_key_events::down_event(k) => { if let Some(shows) = &mut app.library.saved_shows.get_results(None) { let next_index = common_key_events::on_down_press_handler(&shows.items, Some(app.shows_list_index)); app.shows_list_index = next_index; } } k if common_key_events::up_event(k) => { if let Some(shows) = &mut app.library.saved_shows.get_results(None) { let next_index = common_key_events::on_up_press_handler(&shows.items, Some(app.shows_list_index)); app.shows_list_index = next_index; } } k if common_key_events::high_event(k) => { if let Some(_shows) = app.library.saved_shows.get_results(None) { let next_index = common_key_events::on_high_press_handler(); app.shows_list_index = next_index; } } k if common_key_events::middle_event(k) => { if let Some(shows) = app.library.saved_shows.get_results(None) { let next_index = common_key_events::on_middle_press_handler(&shows.items); app.shows_list_index = next_index; } } k if common_key_events::low_event(k) => { if let Some(shows) = app.library.saved_shows.get_results(None) { let next_index = common_key_events::on_low_press_handler(&shows.items); app.shows_list_index = next_index; } } Key::Enter => { if let Some(shows) = app.library.saved_shows.get_results(None) { if let Some(selected_show) = shows.items.get(app.shows_list_index).cloned() { app.dispatch(IoEvent::GetShowEpisodes(Box::new(selected_show.show))); }; } } k if k == app.user_config.keys.next_page => app.get_current_user_saved_shows_next(), k if k == app.user_config.keys.previous_page => app.get_current_user_saved_shows_previous(), Key::Char('D') => app.user_unfollow_show(ActiveBlock::Podcasts), _ => {} } } ================================================ FILE: src/handlers/recently_played.rs ================================================ use super::{super::app::App, common_key_events}; use crate::{app::RecommendationsContext, event::Key, network::IoEvent}; pub fn handler(key: Key, app: &mut App) { match key { k if common_key_events::left_event(k) => common_key_events::handle_left_event(app), k if common_key_events::down_event(k) => { if let Some(recently_played_result) = &app.recently_played.result { let next_index = common_key_events::on_down_press_handler( &recently_played_result.items, Some(app.recently_played.index), ); app.recently_played.index = next_index; } } k if common_key_events::up_event(k) => { if let Some(recently_played_result) = &app.recently_played.result { let next_index = common_key_events::on_up_press_handler( &recently_played_result.items, Some(app.recently_played.index), ); app.recently_played.index = next_index; } } k if common_key_events::high_event(k) => { if let Some(_recently_played_result) = &app.recently_played.result { let next_index = common_key_events::on_high_press_handler(); app.recently_played.index = next_index; } } k if common_key_events::middle_event(k) => { if let Some(recently_played_result) = &app.recently_played.result { let next_index = common_key_events::on_middle_press_handler(&recently_played_result.items); app.recently_played.index = next_index; } } k if common_key_events::low_event(k) => { if let Some(recently_played_result) = &app.recently_played.result { let next_index = common_key_events::on_low_press_handler(&recently_played_result.items); app.recently_played.index = next_index; } } Key::Char('s') => { if let Some(recently_played_result) = &app.recently_played.result.clone() { if let Some(selected_track) = recently_played_result.items.get(app.recently_played.index) { if let Some(track_id) = &selected_track.track.id { app.dispatch(IoEvent::ToggleSaveTrack(track_id.to_string())); }; }; }; } Key::Enter => { if let Some(recently_played_result) = &app.recently_played.result.clone() { let track_uris: Vec = recently_played_result .items .iter() .map(|item| item.track.uri.to_owned()) .collect(); app.dispatch(IoEvent::StartPlayback( None, Some(track_uris), Some(app.recently_played.index), )); }; } Key::Char('r') => { if let Some(recently_played_result) = &app.recently_played.result.clone() { let selected_track_history_item = recently_played_result.items.get(app.recently_played.index); if let Some(item) = selected_track_history_item { if let Some(id) = &item.track.id { app.recommendations_context = Some(RecommendationsContext::Song); app.recommendations_seed = item.track.name.clone(); app.get_recommendations_for_track_id(id.to_string()); } } } } _ if key == app.user_config.keys.add_item_to_queue => { if let Some(recently_played_result) = &app.recently_played.result.clone() { if let Some(history) = recently_played_result.items.get(app.recently_played.index) { app.dispatch(IoEvent::AddItemToQueue(history.track.uri.clone())) } }; } _ => {} }; } #[cfg(test)] mod tests { use super::{super::super::app::ActiveBlock, *}; #[test] fn on_left_press() { let mut app = App::default(); app.set_current_route_state( Some(ActiveBlock::AlbumTracks), Some(ActiveBlock::AlbumTracks), ); handler(Key::Left, &mut app); let current_route = app.get_current_route(); assert_eq!(current_route.active_block, ActiveBlock::Empty); assert_eq!(current_route.hovered_block, ActiveBlock::Library); } #[test] fn on_esc() { let mut app = App::default(); handler(Key::Esc, &mut app); let current_route = app.get_current_route(); assert_eq!(current_route.active_block, ActiveBlock::Empty); } } ================================================ FILE: src/handlers/search_results.rs ================================================ use super::{ super::app::{ ActiveBlock, App, DialogContext, RecommendationsContext, RouteId, SearchResultBlock, TrackTableContext, }, common_key_events, }; use crate::event::Key; use crate::network::IoEvent; fn handle_down_press_on_selected_block(app: &mut App) { // Start selecting within the selected block match app.search_results.selected_block { SearchResultBlock::AlbumSearch => { if let Some(result) = &app.search_results.albums { let next_index = common_key_events::on_down_press_handler( &result.items, app.search_results.selected_album_index, ); app.search_results.selected_album_index = Some(next_index); } } SearchResultBlock::SongSearch => { if let Some(result) = &app.search_results.tracks { let next_index = common_key_events::on_down_press_handler( &result.items, app.search_results.selected_tracks_index, ); app.search_results.selected_tracks_index = Some(next_index); } } SearchResultBlock::ArtistSearch => { if let Some(result) = &app.search_results.artists { let next_index = common_key_events::on_down_press_handler( &result.items, app.search_results.selected_artists_index, ); app.search_results.selected_artists_index = Some(next_index); } } SearchResultBlock::PlaylistSearch => { if let Some(result) = &app.search_results.playlists { let next_index = common_key_events::on_down_press_handler( &result.items, app.search_results.selected_playlists_index, ); app.search_results.selected_playlists_index = Some(next_index); } } SearchResultBlock::ShowSearch => { if let Some(result) = &app.search_results.shows { let next_index = common_key_events::on_down_press_handler( &result.items, app.search_results.selected_shows_index, ); app.search_results.selected_shows_index = Some(next_index); } } SearchResultBlock::Empty => {} } } fn handle_down_press_on_hovered_block(app: &mut App) { match app.search_results.hovered_block { SearchResultBlock::AlbumSearch => { app.search_results.hovered_block = SearchResultBlock::ShowSearch; } SearchResultBlock::SongSearch => { app.search_results.hovered_block = SearchResultBlock::AlbumSearch; } SearchResultBlock::ArtistSearch => { app.search_results.hovered_block = SearchResultBlock::PlaylistSearch; } SearchResultBlock::PlaylistSearch => { app.search_results.hovered_block = SearchResultBlock::ShowSearch; } SearchResultBlock::ShowSearch => { app.search_results.hovered_block = SearchResultBlock::SongSearch; } SearchResultBlock::Empty => {} } } fn handle_up_press_on_selected_block(app: &mut App) { // Start selecting within the selected block match app.search_results.selected_block { SearchResultBlock::AlbumSearch => { if let Some(result) = &app.search_results.albums { let next_index = common_key_events::on_up_press_handler( &result.items, app.search_results.selected_album_index, ); app.search_results.selected_album_index = Some(next_index); } } SearchResultBlock::SongSearch => { if let Some(result) = &app.search_results.tracks { let next_index = common_key_events::on_up_press_handler( &result.items, app.search_results.selected_tracks_index, ); app.search_results.selected_tracks_index = Some(next_index); } } SearchResultBlock::ArtistSearch => { if let Some(result) = &app.search_results.artists { let next_index = common_key_events::on_up_press_handler( &result.items, app.search_results.selected_artists_index, ); app.search_results.selected_artists_index = Some(next_index); } } SearchResultBlock::PlaylistSearch => { if let Some(result) = &app.search_results.playlists { let next_index = common_key_events::on_up_press_handler( &result.items, app.search_results.selected_playlists_index, ); app.search_results.selected_playlists_index = Some(next_index); } } SearchResultBlock::ShowSearch => { if let Some(result) = &app.search_results.shows { let next_index = common_key_events::on_up_press_handler( &result.items, app.search_results.selected_shows_index, ); app.search_results.selected_shows_index = Some(next_index); } } SearchResultBlock::Empty => {} } } fn handle_up_press_on_hovered_block(app: &mut App) { match app.search_results.hovered_block { SearchResultBlock::AlbumSearch => { app.search_results.hovered_block = SearchResultBlock::SongSearch; } SearchResultBlock::SongSearch => { app.search_results.hovered_block = SearchResultBlock::ShowSearch; } SearchResultBlock::ArtistSearch => { app.search_results.hovered_block = SearchResultBlock::ShowSearch; } SearchResultBlock::PlaylistSearch => { app.search_results.hovered_block = SearchResultBlock::ArtistSearch; } SearchResultBlock::ShowSearch => { app.search_results.hovered_block = SearchResultBlock::AlbumSearch; } SearchResultBlock::Empty => {} } } fn handle_high_press_on_selected_block(app: &mut App) { match app.search_results.selected_block { SearchResultBlock::AlbumSearch => { if let Some(_result) = &app.search_results.albums { let next_index = common_key_events::on_high_press_handler(); app.search_results.selected_album_index = Some(next_index); } } SearchResultBlock::SongSearch => { if let Some(_result) = &app.search_results.tracks { let next_index = common_key_events::on_high_press_handler(); app.search_results.selected_tracks_index = Some(next_index); } } SearchResultBlock::ArtistSearch => { if let Some(_result) = &app.search_results.artists { let next_index = common_key_events::on_high_press_handler(); app.search_results.selected_artists_index = Some(next_index); } } SearchResultBlock::PlaylistSearch => { if let Some(_result) = &app.search_results.playlists { let next_index = common_key_events::on_high_press_handler(); app.search_results.selected_playlists_index = Some(next_index); } } SearchResultBlock::ShowSearch => { if let Some(_result) = &app.search_results.shows { let next_index = common_key_events::on_high_press_handler(); app.search_results.selected_shows_index = Some(next_index); } } SearchResultBlock::Empty => {} } } fn handle_middle_press_on_selected_block(app: &mut App) { match app.search_results.selected_block { SearchResultBlock::AlbumSearch => { if let Some(result) = &app.search_results.albums { let next_index = common_key_events::on_middle_press_handler(&result.items); app.search_results.selected_album_index = Some(next_index); } } SearchResultBlock::SongSearch => { if let Some(result) = &app.search_results.tracks { let next_index = common_key_events::on_middle_press_handler(&result.items); app.search_results.selected_tracks_index = Some(next_index); } } SearchResultBlock::ArtistSearch => { if let Some(result) = &app.search_results.artists { let next_index = common_key_events::on_middle_press_handler(&result.items); app.search_results.selected_artists_index = Some(next_index); } } SearchResultBlock::PlaylistSearch => { if let Some(result) = &app.search_results.playlists { let next_index = common_key_events::on_middle_press_handler(&result.items); app.search_results.selected_playlists_index = Some(next_index); } } SearchResultBlock::ShowSearch => { if let Some(result) = &app.search_results.shows { let next_index = common_key_events::on_middle_press_handler(&result.items); app.search_results.selected_shows_index = Some(next_index); } } SearchResultBlock::Empty => {} } } fn handle_low_press_on_selected_block(app: &mut App) { match app.search_results.selected_block { SearchResultBlock::AlbumSearch => { if let Some(result) = &app.search_results.albums { let next_index = common_key_events::on_low_press_handler(&result.items); app.search_results.selected_album_index = Some(next_index); } } SearchResultBlock::SongSearch => { if let Some(result) = &app.search_results.tracks { let next_index = common_key_events::on_low_press_handler(&result.items); app.search_results.selected_tracks_index = Some(next_index); } } SearchResultBlock::ArtistSearch => { if let Some(result) = &app.search_results.artists { let next_index = common_key_events::on_low_press_handler(&result.items); app.search_results.selected_artists_index = Some(next_index); } } SearchResultBlock::PlaylistSearch => { if let Some(result) = &app.search_results.playlists { let next_index = common_key_events::on_low_press_handler(&result.items); app.search_results.selected_playlists_index = Some(next_index); } } SearchResultBlock::ShowSearch => { if let Some(result) = &app.search_results.shows { let next_index = common_key_events::on_low_press_handler(&result.items); app.search_results.selected_shows_index = Some(next_index); } } SearchResultBlock::Empty => {} } } fn handle_add_item_to_queue(app: &mut App) { match &app.search_results.selected_block { SearchResultBlock::SongSearch => { if let (Some(index), Some(tracks)) = ( app.search_results.selected_tracks_index, &app.search_results.tracks, ) { if let Some(track) = tracks.items.get(index) { let uri = track.uri.clone(); app.dispatch(IoEvent::AddItemToQueue(uri)); } } } SearchResultBlock::ArtistSearch => {} SearchResultBlock::PlaylistSearch => {} SearchResultBlock::AlbumSearch => {} SearchResultBlock::ShowSearch => {} SearchResultBlock::Empty => {} }; } fn handle_enter_event_on_selected_block(app: &mut App) { match &app.search_results.selected_block { SearchResultBlock::AlbumSearch => { if let (Some(index), Some(albums_result)) = ( &app.search_results.selected_album_index, &app.search_results.albums, ) { if let Some(album) = albums_result.items.get(index.to_owned()).cloned() { app.track_table.context = Some(TrackTableContext::AlbumSearch); app.dispatch(IoEvent::GetAlbumTracks(Box::new(album))); }; } } SearchResultBlock::SongSearch => { let index = app.search_results.selected_tracks_index; let tracks = app.search_results.tracks.clone(); let track_uris = tracks.map(|tracks| { tracks .items .into_iter() .map(|track| track.uri) .collect::>() }); app.dispatch(IoEvent::StartPlayback(None, track_uris, index)); } SearchResultBlock::ArtistSearch => { if let Some(index) = &app.search_results.selected_artists_index { if let Some(result) = app.search_results.artists.clone() { if let Some(artist) = result.items.get(index.to_owned()) { app.get_artist(artist.id.clone(), artist.name.clone()); app.push_navigation_stack(RouteId::Artist, ActiveBlock::ArtistBlock); }; }; }; } SearchResultBlock::PlaylistSearch => { if let (Some(index), Some(playlists_result)) = ( app.search_results.selected_playlists_index, &app.search_results.playlists, ) { if let Some(playlist) = playlists_result.items.get(index) { // Go to playlist tracks table app.track_table.context = Some(TrackTableContext::PlaylistSearch); let playlist_id = playlist.id.to_owned(); app.dispatch(IoEvent::GetPlaylistTracks(playlist_id, app.playlist_offset)); }; } } SearchResultBlock::ShowSearch => { if let (Some(index), Some(shows_result)) = ( app.search_results.selected_shows_index, &app.search_results.shows, ) { if let Some(show) = shows_result.items.get(index).cloned() { // Go to show tracks table app.dispatch(IoEvent::GetShowEpisodes(Box::new(show))); }; } } SearchResultBlock::Empty => {} }; } fn handle_enter_event_on_hovered_block(app: &mut App) { match app.search_results.hovered_block { SearchResultBlock::AlbumSearch => { let next_index = app.search_results.selected_album_index.unwrap_or(0); app.search_results.selected_album_index = Some(next_index); app.search_results.selected_block = SearchResultBlock::AlbumSearch; } SearchResultBlock::SongSearch => { let next_index = app.search_results.selected_tracks_index.unwrap_or(0); app.search_results.selected_tracks_index = Some(next_index); app.search_results.selected_block = SearchResultBlock::SongSearch; } SearchResultBlock::ArtistSearch => { let next_index = app.search_results.selected_artists_index.unwrap_or(0); app.search_results.selected_artists_index = Some(next_index); app.search_results.selected_block = SearchResultBlock::ArtistSearch; } SearchResultBlock::PlaylistSearch => { let next_index = app.search_results.selected_playlists_index.unwrap_or(0); app.search_results.selected_playlists_index = Some(next_index); app.search_results.selected_block = SearchResultBlock::PlaylistSearch; } SearchResultBlock::ShowSearch => { let next_index = app.search_results.selected_shows_index.unwrap_or(0); app.search_results.selected_shows_index = Some(next_index); app.search_results.selected_block = SearchResultBlock::ShowSearch; } SearchResultBlock::Empty => {} }; } fn handle_recommended_tracks(app: &mut App) { match app.search_results.selected_block { SearchResultBlock::AlbumSearch => {} SearchResultBlock::SongSearch => { if let Some(index) = &app.search_results.selected_tracks_index { if let Some(result) = app.search_results.tracks.clone() { if let Some(track) = result.items.get(index.to_owned()) { let track_id_list: Option> = track.id.as_ref().map(|id| vec![id.to_string()]); app.recommendations_context = Some(RecommendationsContext::Song); app.recommendations_seed = track.name.clone(); app.get_recommendations_for_seed(None, track_id_list, Some(track.clone())); }; }; }; } SearchResultBlock::ArtistSearch => { if let Some(index) = &app.search_results.selected_artists_index { if let Some(result) = app.search_results.artists.clone() { if let Some(artist) = result.items.get(index.to_owned()) { let artist_id_list: Option> = Some(vec![artist.id.clone()]); app.recommendations_context = Some(RecommendationsContext::Artist); app.recommendations_seed = artist.name.clone(); app.get_recommendations_for_seed(artist_id_list, None, None); }; }; }; } SearchResultBlock::PlaylistSearch => {} SearchResultBlock::ShowSearch => {} SearchResultBlock::Empty => {} } } pub fn handler(key: Key, app: &mut App) { match key { Key::Esc => { app.search_results.selected_block = SearchResultBlock::Empty; } k if common_key_events::down_event(k) => { if app.search_results.selected_block != SearchResultBlock::Empty { handle_down_press_on_selected_block(app); } else { handle_down_press_on_hovered_block(app); } } k if common_key_events::up_event(k) => { if app.search_results.selected_block != SearchResultBlock::Empty { handle_up_press_on_selected_block(app); } else { handle_up_press_on_hovered_block(app); } } k if common_key_events::left_event(k) => { app.search_results.selected_block = SearchResultBlock::Empty; match app.search_results.hovered_block { SearchResultBlock::AlbumSearch => { common_key_events::handle_left_event(app); } SearchResultBlock::SongSearch => { common_key_events::handle_left_event(app); } SearchResultBlock::ArtistSearch => { app.search_results.hovered_block = SearchResultBlock::SongSearch; } SearchResultBlock::PlaylistSearch => { app.search_results.hovered_block = SearchResultBlock::AlbumSearch; } SearchResultBlock::ShowSearch => { common_key_events::handle_left_event(app); } SearchResultBlock::Empty => {} } } k if common_key_events::right_event(k) => { app.search_results.selected_block = SearchResultBlock::Empty; match app.search_results.hovered_block { SearchResultBlock::AlbumSearch => { app.search_results.hovered_block = SearchResultBlock::PlaylistSearch; } SearchResultBlock::SongSearch => { app.search_results.hovered_block = SearchResultBlock::ArtistSearch; } SearchResultBlock::ArtistSearch => { app.search_results.hovered_block = SearchResultBlock::SongSearch; } SearchResultBlock::PlaylistSearch => { app.search_results.hovered_block = SearchResultBlock::AlbumSearch; } SearchResultBlock::ShowSearch => {} SearchResultBlock::Empty => {} } } k if common_key_events::high_event(k) => { if app.search_results.selected_block != SearchResultBlock::Empty { handle_high_press_on_selected_block(app); } } k if common_key_events::middle_event(k) => { if app.search_results.selected_block != SearchResultBlock::Empty { handle_middle_press_on_selected_block(app); } } k if common_key_events::low_event(k) => { if app.search_results.selected_block != SearchResultBlock::Empty { handle_low_press_on_selected_block(app) } } // Handle pressing enter when block is selected to start playing track Key::Enter => match app.search_results.selected_block { SearchResultBlock::Empty => handle_enter_event_on_hovered_block(app), SearchResultBlock::PlaylistSearch => { app.playlist_offset = 0; handle_enter_event_on_selected_block(app); } _ => handle_enter_event_on_selected_block(app), }, Key::Char('w') => match app.search_results.selected_block { SearchResultBlock::AlbumSearch => { app.current_user_saved_album_add(ActiveBlock::SearchResultBlock) } SearchResultBlock::SongSearch => {} SearchResultBlock::ArtistSearch => app.user_follow_artists(ActiveBlock::SearchResultBlock), SearchResultBlock::PlaylistSearch => { app.user_follow_playlist(); } SearchResultBlock::ShowSearch => app.user_follow_show(ActiveBlock::SearchResultBlock), SearchResultBlock::Empty => {} }, Key::Char('D') => match app.search_results.selected_block { SearchResultBlock::AlbumSearch => { app.current_user_saved_album_delete(ActiveBlock::SearchResultBlock) } SearchResultBlock::SongSearch => {} SearchResultBlock::ArtistSearch => app.user_unfollow_artists(ActiveBlock::SearchResultBlock), SearchResultBlock::PlaylistSearch => { if let (Some(playlists), Some(selected_index)) = ( &app.search_results.playlists, app.search_results.selected_playlists_index, ) { let selected_playlist = &playlists.items[selected_index].name; app.dialog = Some(selected_playlist.clone()); app.confirm = false; app.push_navigation_stack( RouteId::Dialog, ActiveBlock::Dialog(DialogContext::PlaylistSearch), ); } } SearchResultBlock::ShowSearch => app.user_unfollow_show(ActiveBlock::SearchResultBlock), SearchResultBlock::Empty => {} }, Key::Char('r') => handle_recommended_tracks(app), _ if key == app.user_config.keys.add_item_to_queue => handle_add_item_to_queue(app), // Add `s` to "see more" on each option _ => {} } } ================================================ FILE: src/handlers/select_device.rs ================================================ use super::{ super::app::{ActiveBlock, App}, common_key_events, }; use crate::event::Key; use crate::network::IoEvent; pub fn handler(key: Key, app: &mut App) { match key { Key::Esc => { app.set_current_route_state(Some(ActiveBlock::Library), None); } k if common_key_events::down_event(k) => { match &app.devices { Some(p) => { if let Some(selected_device_index) = app.selected_device_index { let next_index = common_key_events::on_down_press_handler(&p.devices, Some(selected_device_index)); app.selected_device_index = Some(next_index); } } None => {} }; } k if common_key_events::up_event(k) => { match &app.devices { Some(p) => { if let Some(selected_device_index) = app.selected_device_index { let next_index = common_key_events::on_up_press_handler(&p.devices, Some(selected_device_index)); app.selected_device_index = Some(next_index); } } None => {} }; } k if common_key_events::high_event(k) => { match &app.devices { Some(_p) => { if let Some(_selected_device_index) = app.selected_device_index { let next_index = common_key_events::on_high_press_handler(); app.selected_device_index = Some(next_index); } } None => {} }; } k if common_key_events::middle_event(k) => { match &app.devices { Some(p) => { if let Some(_selected_device_index) = app.selected_device_index { let next_index = common_key_events::on_middle_press_handler(&p.devices); app.selected_device_index = Some(next_index); } } None => {} }; } k if common_key_events::low_event(k) => { match &app.devices { Some(p) => { if let Some(_selected_device_index) = app.selected_device_index { let next_index = common_key_events::on_low_press_handler(&p.devices); app.selected_device_index = Some(next_index); } } None => {} }; } Key::Enter => { if let (Some(devices), Some(index)) = (app.devices.clone(), app.selected_device_index) { if let Some(device) = &devices.devices.get(index) { app.dispatch(IoEvent::TransferPlaybackToDevice(device.id.clone())); } }; } _ => {} } } ================================================ FILE: src/handlers/track_table.rs ================================================ use super::{ super::app::{App, RecommendationsContext, TrackTable, TrackTableContext}, common_key_events, }; use crate::event::Key; use crate::network::IoEvent; use rand::{thread_rng, Rng}; use serde_json::from_value; pub fn handler(key: Key, app: &mut App) { match key { k if common_key_events::left_event(k) => common_key_events::handle_left_event(app), k if common_key_events::down_event(k) => { let next_index = common_key_events::on_down_press_handler( &app.track_table.tracks, Some(app.track_table.selected_index), ); app.track_table.selected_index = next_index; } k if common_key_events::up_event(k) => { let next_index = common_key_events::on_up_press_handler( &app.track_table.tracks, Some(app.track_table.selected_index), ); app.track_table.selected_index = next_index; } k if common_key_events::high_event(k) => { let next_index = common_key_events::on_high_press_handler(); app.track_table.selected_index = next_index; } k if common_key_events::middle_event(k) => { let next_index = common_key_events::on_middle_press_handler(&app.track_table.tracks); app.track_table.selected_index = next_index; } k if common_key_events::low_event(k) => { let next_index = common_key_events::on_low_press_handler(&app.track_table.tracks); app.track_table.selected_index = next_index; } Key::Enter => { on_enter(app); } // Scroll down k if k == app.user_config.keys.next_page => { match &app.track_table.context { Some(context) => match context { TrackTableContext::MyPlaylists => { if let (Some(playlists), Some(selected_playlist_index)) = (&app.playlists, &app.selected_playlist_index) { if let Some(selected_playlist) = playlists.items.get(selected_playlist_index.to_owned()) { if let Some(playlist_tracks) = &app.playlist_tracks { if app.playlist_offset + app.large_search_limit < playlist_tracks.total { app.playlist_offset += app.large_search_limit; let playlist_id = selected_playlist.id.to_owned(); app.dispatch(IoEvent::GetPlaylistTracks(playlist_id, app.playlist_offset)); } } } }; } TrackTableContext::RecommendedTracks => {} TrackTableContext::SavedTracks => { app.get_current_user_saved_tracks_next(); } TrackTableContext::AlbumSearch => {} TrackTableContext::PlaylistSearch => {} TrackTableContext::MadeForYou => { let (playlists, selected_playlist_index) = (&app.library.made_for_you_playlists, &app.made_for_you_index); if let Some(selected_playlist) = playlists .get_results(Some(0)) .unwrap() .items .get(selected_playlist_index.to_owned()) { if let Some(playlist_tracks) = &app.made_for_you_tracks { if app.made_for_you_offset + app.large_search_limit < playlist_tracks.total { app.made_for_you_offset += app.large_search_limit; let playlist_id = selected_playlist.id.to_owned(); app.dispatch(IoEvent::GetMadeForYouPlaylistTracks( playlist_id, app.made_for_you_offset, )); } } } } }, None => {} }; } // Scroll up k if k == app.user_config.keys.previous_page => { match &app.track_table.context { Some(context) => match context { TrackTableContext::MyPlaylists => { if let (Some(playlists), Some(selected_playlist_index)) = (&app.playlists, &app.selected_playlist_index) { if app.playlist_offset >= app.large_search_limit { app.playlist_offset -= app.large_search_limit; }; if let Some(selected_playlist) = playlists.items.get(selected_playlist_index.to_owned()) { let playlist_id = selected_playlist.id.to_owned(); app.dispatch(IoEvent::GetPlaylistTracks(playlist_id, app.playlist_offset)); } }; } TrackTableContext::RecommendedTracks => {} TrackTableContext::SavedTracks => { app.get_current_user_saved_tracks_previous(); } TrackTableContext::AlbumSearch => {} TrackTableContext::PlaylistSearch => {} TrackTableContext::MadeForYou => { let (playlists, selected_playlist_index) = ( &app .library .made_for_you_playlists .get_results(Some(0)) .unwrap(), app.made_for_you_index, ); if app.made_for_you_offset >= app.large_search_limit { app.made_for_you_offset -= app.large_search_limit; } if let Some(selected_playlist) = playlists.items.get(selected_playlist_index) { let playlist_id = selected_playlist.id.to_owned(); app.dispatch(IoEvent::GetMadeForYouPlaylistTracks( playlist_id, app.made_for_you_offset, )); } } }, None => {} }; } Key::Char('s') => handle_save_track_event(app), Key::Char('S') => play_random_song(app), k if k == app.user_config.keys.jump_to_end => jump_to_end(app), k if k == app.user_config.keys.jump_to_start => jump_to_start(app), //recommended song radio Key::Char('r') => { handle_recommended_tracks(app); } _ if key == app.user_config.keys.add_item_to_queue => on_queue(app), _ => {} } } fn play_random_song(app: &mut App) { if let Some(context) = &app.track_table.context { match context { TrackTableContext::MyPlaylists => { let (context_uri, track_json) = match (&app.selected_playlist_index, &app.playlists) { (Some(selected_playlist_index), Some(playlists)) => { if let Some(selected_playlist) = playlists.items.get(selected_playlist_index.to_owned()) { ( Some(selected_playlist.uri.to_owned()), selected_playlist.tracks.get("total"), ) } else { (None, None) } } _ => (None, None), }; if let Some(val) = track_json { let num_tracks: usize = from_value(val.clone()).unwrap(); app.dispatch(IoEvent::StartPlayback( context_uri, None, Some(thread_rng().gen_range(0..num_tracks)), )); } } TrackTableContext::RecommendedTracks => {} TrackTableContext::SavedTracks => { if let Some(saved_tracks) = &app.library.saved_tracks.get_results(None) { let track_uris: Vec = saved_tracks .items .iter() .map(|item| item.track.uri.to_owned()) .collect(); let rand_idx = thread_rng().gen_range(0..track_uris.len()); app.dispatch(IoEvent::StartPlayback( None, Some(track_uris), Some(rand_idx), )) } } TrackTableContext::AlbumSearch => {} TrackTableContext::PlaylistSearch => { let (context_uri, playlist_track_json) = match ( &app.search_results.selected_playlists_index, &app.search_results.playlists, ) { (Some(selected_playlist_index), Some(playlist_result)) => { if let Some(selected_playlist) = playlist_result .items .get(selected_playlist_index.to_owned()) { ( Some(selected_playlist.uri.to_owned()), selected_playlist.tracks.get("total"), ) } else { (None, None) } } _ => (None, None), }; if let Some(val) = playlist_track_json { let num_tracks: usize = from_value(val.clone()).unwrap(); app.dispatch(IoEvent::StartPlayback( context_uri, None, Some(thread_rng().gen_range(0..num_tracks)), )) } } TrackTableContext::MadeForYou => { if let Some(playlist) = &app .library .made_for_you_playlists .get_results(Some(0)) .and_then(|playlist| playlist.items.get(app.made_for_you_index)) { if let Some(num_tracks) = &playlist .tracks .get("total") .and_then(|total| -> Option { from_value(total.clone()).ok() }) { let uri = Some(playlist.uri.clone()); app.dispatch(IoEvent::StartPlayback( uri, None, Some(thread_rng().gen_range(0..*num_tracks)), )) }; }; } } }; } fn handle_save_track_event(app: &mut App) { let (selected_index, tracks) = (&app.track_table.selected_index, &app.track_table.tracks); if let Some(track) = tracks.get(*selected_index) { if let Some(id) = &track.id { let id = id.to_string(); app.dispatch(IoEvent::ToggleSaveTrack(id)); }; }; } fn handle_recommended_tracks(app: &mut App) { let (selected_index, tracks) = (&app.track_table.selected_index, &app.track_table.tracks); if let Some(track) = tracks.get(*selected_index) { let first_track = track.clone(); let track_id_list = track.id.as_ref().map(|id| vec![id.to_string()]); app.recommendations_context = Some(RecommendationsContext::Song); app.recommendations_seed = first_track.name.clone(); app.get_recommendations_for_seed(None, track_id_list, Some(first_track)); }; } fn jump_to_end(app: &mut App) { match &app.track_table.context { Some(context) => match context { TrackTableContext::MyPlaylists => { if let (Some(playlists), Some(selected_playlist_index)) = (&app.playlists, &app.selected_playlist_index) { if let Some(selected_playlist) = playlists.items.get(selected_playlist_index.to_owned()) { let total_tracks = selected_playlist .tracks .get("total") .and_then(|total| total.as_u64()) .expect("playlist.tracks object should have a total field") as u32; if app.large_search_limit < total_tracks { app.playlist_offset = total_tracks - (total_tracks % app.large_search_limit); let playlist_id = selected_playlist.id.to_owned(); app.dispatch(IoEvent::GetPlaylistTracks(playlist_id, app.playlist_offset)); } } } } TrackTableContext::RecommendedTracks => {} TrackTableContext::SavedTracks => {} TrackTableContext::AlbumSearch => {} TrackTableContext::PlaylistSearch => {} TrackTableContext::MadeForYou => {} }, None => {} } } fn on_enter(app: &mut App) { let TrackTable { context, selected_index, tracks, } = &app.track_table; match &context { Some(context) => match context { TrackTableContext::MyPlaylists => { if let Some(_track) = tracks.get(*selected_index) { let context_uri = match (&app.active_playlist_index, &app.playlists) { (Some(active_playlist_index), Some(playlists)) => playlists .items .get(active_playlist_index.to_owned()) .map(|selected_playlist| selected_playlist.uri.to_owned()), _ => None, }; app.dispatch(IoEvent::StartPlayback( context_uri, None, Some(app.track_table.selected_index + app.playlist_offset as usize), )); }; } TrackTableContext::RecommendedTracks => { app.dispatch(IoEvent::StartPlayback( None, Some( app .recommended_tracks .iter() .map(|x| x.uri.clone()) .collect::>(), ), Some(app.track_table.selected_index), )); } TrackTableContext::SavedTracks => { if let Some(saved_tracks) = &app.library.saved_tracks.get_results(None) { let track_uris: Vec = saved_tracks .items .iter() .map(|item| item.track.uri.to_owned()) .collect(); app.dispatch(IoEvent::StartPlayback( None, Some(track_uris), Some(app.track_table.selected_index), )); }; } TrackTableContext::AlbumSearch => {} TrackTableContext::PlaylistSearch => { let TrackTable { selected_index, tracks, .. } = &app.track_table; if let Some(_track) = tracks.get(*selected_index) { let context_uri = match ( &app.search_results.selected_playlists_index, &app.search_results.playlists, ) { (Some(selected_playlist_index), Some(playlist_result)) => playlist_result .items .get(selected_playlist_index.to_owned()) .map(|selected_playlist| selected_playlist.uri.to_owned()), _ => None, }; app.dispatch(IoEvent::StartPlayback( context_uri, None, Some(app.track_table.selected_index), )); }; } TrackTableContext::MadeForYou => { if let Some(_track) = tracks.get(*selected_index) { let context_uri = Some( app .library .made_for_you_playlists .get_results(Some(0)) .unwrap() .items .get(app.made_for_you_index) .unwrap() .uri .to_owned(), ); app.dispatch(IoEvent::StartPlayback( context_uri, None, Some(app.track_table.selected_index + app.made_for_you_offset as usize), )); } } }, None => {} }; } fn on_queue(app: &mut App) { let TrackTable { context, selected_index, tracks, } = &app.track_table; match &context { Some(context) => match context { TrackTableContext::MyPlaylists => { if let Some(track) = tracks.get(*selected_index) { let uri = track.uri.clone(); app.dispatch(IoEvent::AddItemToQueue(uri)); }; } TrackTableContext::RecommendedTracks => { if let Some(full_track) = app.recommended_tracks.get(app.track_table.selected_index) { let uri = full_track.uri.clone(); app.dispatch(IoEvent::AddItemToQueue(uri)); } } TrackTableContext::SavedTracks => { if let Some(page) = app.library.saved_tracks.get_results(None) { if let Some(saved_track) = page.items.get(app.track_table.selected_index) { let uri = saved_track.track.uri.clone(); app.dispatch(IoEvent::AddItemToQueue(uri)); } } } TrackTableContext::AlbumSearch => {} TrackTableContext::PlaylistSearch => { let TrackTable { selected_index, tracks, .. } = &app.track_table; if let Some(track) = tracks.get(*selected_index) { let uri = track.uri.clone(); app.dispatch(IoEvent::AddItemToQueue(uri)); }; } TrackTableContext::MadeForYou => { if let Some(track) = tracks.get(*selected_index) { let uri = track.uri.clone(); app.dispatch(IoEvent::AddItemToQueue(uri)); } } }, None => {} }; } fn jump_to_start(app: &mut App) { match &app.track_table.context { Some(context) => match context { TrackTableContext::MyPlaylists => { if let (Some(playlists), Some(selected_playlist_index)) = (&app.playlists, &app.selected_playlist_index) { if let Some(selected_playlist) = playlists.items.get(selected_playlist_index.to_owned()) { app.playlist_offset = 0; let playlist_id = selected_playlist.id.to_owned(); app.dispatch(IoEvent::GetPlaylistTracks(playlist_id, app.playlist_offset)); } } } TrackTableContext::RecommendedTracks => {} TrackTableContext::SavedTracks => {} TrackTableContext::AlbumSearch => {} TrackTableContext::PlaylistSearch => {} TrackTableContext::MadeForYou => {} }, None => {} } } ================================================ FILE: src/main.rs ================================================ mod app; mod banner; mod cli; mod config; mod event; mod handlers; mod network; mod redirect_uri; mod ui; mod user_config; use crate::app::RouteId; use crate::event::Key; use anyhow::{anyhow, Result}; use app::{ActiveBlock, App}; use backtrace::Backtrace; use banner::BANNER; use clap::{App as ClapApp, Arg, Shell}; use config::ClientConfig; use crossterm::{ cursor::MoveTo, event::{DisableMouseCapture, EnableMouseCapture}, execute, style::Print, terminal::{ disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen, SetTitle, }, ExecutableCommand, }; use network::{get_spotify, IoEvent, Network}; use redirect_uri::redirect_uri_web_server; use rspotify::{ oauth2::{SpotifyOAuth, TokenInfo}, util::{process_token, request_token}, }; use std::{ cmp::{max, min}, io::{self, stdout}, panic::{self, PanicInfo}, path::PathBuf, sync::Arc, time::SystemTime, }; use tokio::sync::Mutex; use tui::{ backend::{Backend, CrosstermBackend}, Terminal, }; use user_config::{UserConfig, UserConfigPaths}; const SCOPES: [&str; 14] = [ "playlist-read-collaborative", "playlist-read-private", "playlist-modify-private", "playlist-modify-public", "user-follow-read", "user-follow-modify", "user-library-modify", "user-library-read", "user-modify-playback-state", "user-read-currently-playing", "user-read-playback-state", "user-read-playback-position", "user-read-private", "user-read-recently-played", ]; /// get token automatically with local webserver pub async fn get_token_auto(spotify_oauth: &mut SpotifyOAuth, port: u16) -> Option { match spotify_oauth.get_cached_token().await { Some(token_info) => Some(token_info), None => match redirect_uri_web_server(spotify_oauth, port) { Ok(mut url) => process_token(spotify_oauth, &mut url).await, Err(()) => { println!("Starting webserver failed. Continuing with manual authentication"); request_token(spotify_oauth); println!("Enter the URL you were redirected to: "); let mut input = String::new(); match io::stdin().read_line(&mut input) { Ok(_) => process_token(spotify_oauth, &mut input).await, Err(_) => None, } } }, } } fn close_application() -> Result<()> { disable_raw_mode()?; let mut stdout = io::stdout(); execute!(stdout, LeaveAlternateScreen, DisableMouseCapture)?; Ok(()) } fn panic_hook(info: &PanicInfo<'_>) { if cfg!(debug_assertions) { let location = info.location().unwrap(); let msg = match info.payload().downcast_ref::<&'static str>() { Some(s) => *s, None => match info.payload().downcast_ref::() { Some(s) => &s[..], None => "Box", }, }; let stacktrace: String = format!("{:?}", Backtrace::new()).replace('\n', "\n\r"); disable_raw_mode().unwrap(); execute!( io::stdout(), LeaveAlternateScreen, Print(format!( "thread '' panicked at '{}', {}\n\r{}", msg, location, stacktrace )), DisableMouseCapture ) .unwrap(); } } #[tokio::main] async fn main() -> Result<()> { panic::set_hook(Box::new(|info| { panic_hook(info); })); let mut clap_app = ClapApp::new(env!("CARGO_PKG_NAME")) .version(env!("CARGO_PKG_VERSION")) .author(env!("CARGO_PKG_AUTHORS")) .about(env!("CARGO_PKG_DESCRIPTION")) .usage("Press `?` while running the app to see keybindings") .before_help(BANNER) .after_help( "Your spotify Client ID and Client Secret are stored in $HOME/.config/spotify-tui/client.yml", ) .arg( Arg::with_name("tick-rate") .short("t") .long("tick-rate") .help("Set the tick rate (milliseconds): the lower the number the higher the FPS.") .long_help( "Specify the tick rate in milliseconds: the lower the number the \ higher the FPS. It can be nicer to have a lower value when you want to use the audio analysis view \ of the app. Beware that this comes at a CPU cost!", ) .takes_value(true), ) .arg( Arg::with_name("config") .short("c") .long("config") .help("Specify configuration file path.") .takes_value(true), ) .arg( Arg::with_name("completions") .long("completions") .help("Generates completions for your preferred shell") .takes_value(true) .possible_values(&["bash", "zsh", "fish", "power-shell", "elvish"]) .value_name("SHELL"), ) // Control spotify from the command line .subcommand(cli::playback_subcommand()) .subcommand(cli::play_subcommand()) .subcommand(cli::list_subcommand()) .subcommand(cli::search_subcommand()); let matches = clap_app.clone().get_matches(); // Shell completions don't need any spotify work if let Some(s) = matches.value_of("completions") { let shell = match s { "fish" => Shell::Fish, "bash" => Shell::Bash, "zsh" => Shell::Zsh, "power-shell" => Shell::PowerShell, "elvish" => Shell::Elvish, _ => return Err(anyhow!("no completions avaible for '{}'", s)), }; clap_app.gen_completions_to("spt", shell, &mut io::stdout()); return Ok(()); } let mut user_config = UserConfig::new(); if let Some(config_file_path) = matches.value_of("config") { let config_file_path = PathBuf::from(config_file_path); let path = UserConfigPaths { config_file_path }; user_config.path_to_config.replace(path); } user_config.load_config()?; if let Some(tick_rate) = matches .value_of("tick-rate") .and_then(|tick_rate| tick_rate.parse().ok()) { if tick_rate >= 1000 { panic!("Tick rate must be below 1000"); } else { user_config.behavior.tick_rate_milliseconds = tick_rate; } } let mut client_config = ClientConfig::new(); client_config.load_config()?; let config_paths = client_config.get_or_build_paths()?; // Start authorization with spotify let mut oauth = SpotifyOAuth::default() .client_id(&client_config.client_id) .client_secret(&client_config.client_secret) .redirect_uri(&client_config.get_redirect_uri()) .cache_path(config_paths.token_cache_path) .scope(&SCOPES.join(" ")) .build(); let config_port = client_config.get_port(); match get_token_auto(&mut oauth, config_port).await { Some(token_info) => { let (sync_io_tx, sync_io_rx) = std::sync::mpsc::channel::(); let (spotify, token_expiry) = get_spotify(token_info); // Initialise app state let app = Arc::new(Mutex::new(App::new( sync_io_tx, user_config.clone(), token_expiry, ))); // Work with the cli (not really async) if let Some(cmd) = matches.subcommand_name() { // Save, because we checked if the subcommand is present at runtime let m = matches.subcommand_matches(cmd).unwrap(); let network = Network::new(oauth, spotify, client_config, &app); println!( "{}", cli::handle_matches(m, cmd.to_string(), network, user_config).await? ); // Launch the UI (async) } else { let cloned_app = Arc::clone(&app); std::thread::spawn(move || { let mut network = Network::new(oauth, spotify, client_config, &app); start_tokio(sync_io_rx, &mut network); }); // The UI must run in the "main" thread start_ui(user_config, &cloned_app).await?; } } None => println!("\nSpotify auth failed"), } Ok(()) } #[tokio::main] async fn start_tokio<'a>(io_rx: std::sync::mpsc::Receiver, network: &mut Network) { while let Ok(io_event) = io_rx.recv() { network.handle_network_event(io_event).await; } } async fn start_ui(user_config: UserConfig, app: &Arc>) -> Result<()> { // Terminal initialization let mut stdout = stdout(); execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?; enable_raw_mode()?; let mut backend = CrosstermBackend::new(stdout); if user_config.behavior.set_window_title { backend.execute(SetTitle("spt - Spotify TUI"))?; } let mut terminal = Terminal::new(backend)?; terminal.hide_cursor()?; let events = event::Events::new(user_config.behavior.tick_rate_milliseconds); // play music on, if not send them to the device selection view let mut is_first_render = true; loop { let mut app = app.lock().await; // Get the size of the screen on each loop to account for resize event if let Ok(size) = terminal.backend().size() { // Reset the help menu is the terminal was resized if is_first_render || app.size != size { app.help_menu_max_lines = 0; app.help_menu_offset = 0; app.help_menu_page = 0; app.size = size; // Based on the size of the terminal, adjust the search limit. let potential_limit = max((app.size.height as i32) - 13, 0) as u32; let max_limit = min(potential_limit, 50); let large_search_limit = min((f32::from(size.height) / 1.4) as u32, max_limit); let small_search_limit = min((f32::from(size.height) / 2.85) as u32, max_limit / 2); app.dispatch(IoEvent::UpdateSearchLimits( large_search_limit, small_search_limit, )); // Based on the size of the terminal, adjust how many lines are // displayed in the help menu if app.size.height > 8 { app.help_menu_max_lines = (app.size.height as u32) - 8; } else { app.help_menu_max_lines = 0; } } }; let current_route = app.get_current_route(); terminal.draw(|mut f| match current_route.active_block { ActiveBlock::HelpMenu => { ui::draw_help_menu(&mut f, &app); } ActiveBlock::Error => { ui::draw_error_screen(&mut f, &app); } ActiveBlock::SelectDevice => { ui::draw_device_list(&mut f, &app); } ActiveBlock::Analysis => { ui::audio_analysis::draw(&mut f, &app); } ActiveBlock::BasicView => { ui::draw_basic_view(&mut f, &app); } _ => { ui::draw_main_layout(&mut f, &app); } })?; if current_route.active_block == ActiveBlock::Input { terminal.show_cursor()?; } else { terminal.hide_cursor()?; } let cursor_offset = if app.size.height > ui::util::SMALL_TERMINAL_HEIGHT { 2 } else { 1 }; // Put the cursor back inside the input box terminal.backend_mut().execute(MoveTo( cursor_offset + app.input_cursor_position, cursor_offset, ))?; // Handle authentication refresh if SystemTime::now() > app.spotify_token_expiry { app.dispatch(IoEvent::RefreshAuthentication); } match events.next()? { event::Event::Input(key) => { if key == Key::Ctrl('c') { break; } let current_active_block = app.get_current_route().active_block; // To avoid swallowing the global key presses `q` and `-` make a special // case for the input handler if current_active_block == ActiveBlock::Input { handlers::input_handler(key, &mut app); } else if key == app.user_config.keys.back { if app.get_current_route().active_block != ActiveBlock::Input { // Go back through navigation stack when not in search input mode and exit the app if there are no more places to back to let pop_result = match app.pop_navigation_stack() { Some(ref x) if x.id == RouteId::Search => app.pop_navigation_stack(), Some(x) => Some(x), None => None, }; if pop_result.is_none() { break; // Exit application } } } else { handlers::handle_app(key, &mut app); } } event::Event::Tick => { app.update_on_tick(); } } // Delay spotify request until first render, will have the effect of improving // startup speed if is_first_render { app.dispatch(IoEvent::GetPlaylists); app.dispatch(IoEvent::GetUser); app.dispatch(IoEvent::GetCurrentPlayback); app.help_docs_size = ui::help::get_help_docs(&app.user_config.keys).len() as u32; is_first_render = false; } } terminal.show_cursor()?; close_application()?; Ok(()) } ================================================ FILE: src/network.rs ================================================ use crate::app::{ ActiveBlock, AlbumTableContext, App, Artist, ArtistBlock, EpisodeTableContext, RouteId, ScrollableResultPages, SelectedAlbum, SelectedFullAlbum, SelectedFullShow, SelectedShow, TrackTableContext, }; use crate::config::ClientConfig; use anyhow::anyhow; use rspotify::{ client::Spotify, model::{ album::SimplifiedAlbum, artist::FullArtist, offset::for_position, page::Page, playlist::{PlaylistTrack, SimplifiedPlaylist}, recommend::Recommendations, search::SearchResult, show::SimplifiedShow, track::FullTrack, PlayingItem, }, oauth2::{SpotifyClientCredentials, SpotifyOAuth, TokenInfo}, senum::{AdditionalType, Country, RepeatState, SearchType}, util::get_token, }; use serde_json::{map::Map, Value}; use std::{ sync::Arc, time::{Duration, Instant, SystemTime}, }; use tokio::sync::Mutex; use tokio::try_join; #[derive(Debug)] pub enum IoEvent { GetCurrentPlayback, RefreshAuthentication, GetPlaylists, GetDevices, GetSearchResults(String, Option), SetTracksToTable(Vec), GetMadeForYouPlaylistTracks(String, u32), GetPlaylistTracks(String, u32), GetCurrentSavedTracks(Option), StartPlayback(Option, Option>, Option), UpdateSearchLimits(u32, u32), Seek(u32), NextTrack, PreviousTrack, Shuffle(bool), Repeat(RepeatState), PausePlayback, ChangeVolume(u8), GetArtist(String, String, Option), GetAlbumTracks(Box), GetRecommendationsForSeed( Option>, Option>, Box>, Option, ), GetCurrentUserSavedAlbums(Option), CurrentUserSavedAlbumsContains(Vec), CurrentUserSavedAlbumDelete(String), CurrentUserSavedAlbumAdd(String), UserUnfollowArtists(Vec), UserFollowArtists(Vec), UserFollowPlaylist(String, String, Option), UserUnfollowPlaylist(String, String), MadeForYouSearchAndAdd(String, Option), GetAudioAnalysis(String), GetUser, ToggleSaveTrack(String), GetRecommendationsForTrackId(String, Option), GetRecentlyPlayed, GetFollowedArtists(Option), SetArtistsToTable(Vec), UserArtistFollowCheck(Vec), GetAlbum(String), TransferPlaybackToDevice(String), GetAlbumForTrack(String), CurrentUserSavedTracksContains(Vec), GetCurrentUserSavedShows(Option), CurrentUserSavedShowsContains(Vec), CurrentUserSavedShowDelete(String), CurrentUserSavedShowAdd(String), GetShowEpisodes(Box), GetShow(String), GetCurrentShowEpisodes(String, Option), AddItemToQueue(String), } pub fn get_spotify(token_info: TokenInfo) -> (Spotify, SystemTime) { let token_expiry = { if let Some(expires_at) = token_info.expires_at { SystemTime::UNIX_EPOCH + Duration::from_secs(expires_at as u64) // Set 10 seconds early - Duration::from_secs(10) } else { SystemTime::now() } }; let client_credential = SpotifyClientCredentials::default() .token_info(token_info) .build(); let spotify = Spotify::default() .client_credentials_manager(client_credential) .build(); (spotify, token_expiry) } #[derive(Clone)] pub struct Network<'a> { oauth: SpotifyOAuth, pub spotify: Spotify, large_search_limit: u32, small_search_limit: u32, pub client_config: ClientConfig, pub app: &'a Arc>, } impl<'a> Network<'a> { pub fn new( oauth: SpotifyOAuth, spotify: Spotify, client_config: ClientConfig, app: &'a Arc>, ) -> Self { Network { oauth, spotify, large_search_limit: 20, small_search_limit: 4, client_config, app, } } #[allow(clippy::cognitive_complexity)] pub async fn handle_network_event(&mut self, io_event: IoEvent) { match io_event { IoEvent::RefreshAuthentication => { self.refresh_authentication().await; } IoEvent::GetPlaylists => { self.get_current_user_playlists().await; } IoEvent::GetUser => { self.get_user().await; } IoEvent::GetDevices => { self.get_devices().await; } IoEvent::GetCurrentPlayback => { self.get_current_playback().await; } IoEvent::SetTracksToTable(full_tracks) => { self.set_tracks_to_table(full_tracks).await; } IoEvent::GetSearchResults(search_term, country) => { self.get_search_results(search_term, country).await; } IoEvent::GetMadeForYouPlaylistTracks(playlist_id, made_for_you_offset) => { self .get_made_for_you_playlist_tracks(playlist_id, made_for_you_offset) .await; } IoEvent::GetPlaylistTracks(playlist_id, playlist_offset) => { self.get_playlist_tracks(playlist_id, playlist_offset).await; } IoEvent::GetCurrentSavedTracks(offset) => { self.get_current_user_saved_tracks(offset).await; } IoEvent::StartPlayback(context_uri, uris, offset) => { self.start_playback(context_uri, uris, offset).await; } IoEvent::UpdateSearchLimits(large_search_limit, small_search_limit) => { self.large_search_limit = large_search_limit; self.small_search_limit = small_search_limit; } IoEvent::Seek(position_ms) => { self.seek(position_ms).await; } IoEvent::NextTrack => { self.next_track().await; } IoEvent::PreviousTrack => { self.previous_track().await; } IoEvent::Repeat(repeat_state) => { self.repeat(repeat_state).await; } IoEvent::PausePlayback => { self.pause_playback().await; } IoEvent::ChangeVolume(volume) => { self.change_volume(volume).await; } IoEvent::GetArtist(artist_id, input_artist_name, country) => { self.get_artist(artist_id, input_artist_name, country).await; } IoEvent::GetAlbumTracks(album) => { self.get_album_tracks(album).await; } IoEvent::GetRecommendationsForSeed(seed_artists, seed_tracks, first_track, country) => { self .get_recommendations_for_seed(seed_artists, seed_tracks, first_track, country) .await; } IoEvent::GetCurrentUserSavedAlbums(offset) => { self.get_current_user_saved_albums(offset).await; } IoEvent::CurrentUserSavedAlbumsContains(album_ids) => { self.current_user_saved_albums_contains(album_ids).await; } IoEvent::CurrentUserSavedAlbumDelete(album_id) => { self.current_user_saved_album_delete(album_id).await; } IoEvent::CurrentUserSavedAlbumAdd(album_id) => { self.current_user_saved_album_add(album_id).await; } IoEvent::UserUnfollowArtists(artist_ids) => { self.user_unfollow_artists(artist_ids).await; } IoEvent::UserFollowArtists(artist_ids) => { self.user_follow_artists(artist_ids).await; } IoEvent::UserFollowPlaylist(playlist_owner_id, playlist_id, is_public) => { self .user_follow_playlist(playlist_owner_id, playlist_id, is_public) .await; } IoEvent::UserUnfollowPlaylist(user_id, playlist_id) => { self.user_unfollow_playlist(user_id, playlist_id).await; } IoEvent::MadeForYouSearchAndAdd(search_term, country) => { self.made_for_you_search_and_add(search_term, country).await; } IoEvent::GetAudioAnalysis(uri) => { self.get_audio_analysis(uri).await; } IoEvent::ToggleSaveTrack(track_id) => { self.toggle_save_track(track_id).await; } IoEvent::GetRecommendationsForTrackId(track_id, country) => { self .get_recommendations_for_track_id(track_id, country) .await; } IoEvent::GetRecentlyPlayed => { self.get_recently_played().await; } IoEvent::GetFollowedArtists(after) => { self.get_followed_artists(after).await; } IoEvent::SetArtistsToTable(full_artists) => { self.set_artists_to_table(full_artists).await; } IoEvent::UserArtistFollowCheck(artist_ids) => { self.user_artist_check_follow(artist_ids).await; } IoEvent::GetAlbum(album_id) => { self.get_album(album_id).await; } IoEvent::TransferPlaybackToDevice(device_id) => { self.transfert_playback_to_device(device_id).await; } IoEvent::GetAlbumForTrack(track_id) => { self.get_album_for_track(track_id).await; } IoEvent::Shuffle(shuffle_state) => { self.shuffle(shuffle_state).await; } IoEvent::CurrentUserSavedTracksContains(track_ids) => { self.current_user_saved_tracks_contains(track_ids).await; } IoEvent::GetCurrentUserSavedShows(offset) => { self.get_current_user_saved_shows(offset).await; } IoEvent::CurrentUserSavedShowsContains(show_ids) => { self.current_user_saved_shows_contains(show_ids).await; } IoEvent::CurrentUserSavedShowDelete(show_id) => { self.current_user_saved_shows_delete(show_id).await; } IoEvent::CurrentUserSavedShowAdd(show_id) => { self.current_user_saved_shows_add(show_id).await; } IoEvent::GetShowEpisodes(show) => { self.get_show_episodes(show).await; } IoEvent::GetShow(show_id) => { self.get_show(show_id).await; } IoEvent::GetCurrentShowEpisodes(show_id, offset) => { self.get_current_show_episodes(show_id, offset).await; } IoEvent::AddItemToQueue(item) => { self.add_item_to_queue(item).await; } }; let mut app = self.app.lock().await; app.is_loading = false; } async fn handle_error(&mut self, e: anyhow::Error) { let mut app = self.app.lock().await; app.handle_error(e); } async fn get_user(&mut self) { match self.spotify.current_user().await { Ok(user) => { let mut app = self.app.lock().await; app.user = Some(user); } Err(e) => { self.handle_error(anyhow!(e)).await; } } } async fn get_devices(&mut self) { if let Ok(result) = self.spotify.device().await { let mut app = self.app.lock().await; app.push_navigation_stack(RouteId::SelectedDevice, ActiveBlock::SelectDevice); if !result.devices.is_empty() { app.devices = Some(result); // Select the first device in the list app.selected_device_index = Some(0); } } } async fn get_current_playback(&mut self) { let context = self .spotify .current_playback( None, Some(vec![AdditionalType::Episode, AdditionalType::Track]), ) .await; match context { Ok(Some(c)) => { let mut app = self.app.lock().await; app.current_playback_context = Some(c.clone()); app.instant_since_last_current_playback_poll = Instant::now(); if let Some(item) = c.item { match item { PlayingItem::Track(track) => { if let Some(track_id) = track.id { app.dispatch(IoEvent::CurrentUserSavedTracksContains(vec![track_id])); }; } PlayingItem::Episode(_episode) => { /*should map this to following the podcast show*/ } } }; } Ok(None) => { let mut app = self.app.lock().await; app.instant_since_last_current_playback_poll = Instant::now(); } Err(e) => { self.handle_error(anyhow!(e)).await; } } let mut app = self.app.lock().await; app.seek_ms.take(); app.is_fetching_current_playback = false; } async fn current_user_saved_tracks_contains(&mut self, ids: Vec) { match self.spotify.current_user_saved_tracks_contains(&ids).await { Ok(is_saved_vec) => { let mut app = self.app.lock().await; for (i, id) in ids.iter().enumerate() { if let Some(is_liked) = is_saved_vec.get(i) { if *is_liked { app.liked_song_ids_set.insert(id.to_string()); } else { // The song is not liked, so check if it should be removed if app.liked_song_ids_set.contains(id) { app.liked_song_ids_set.remove(id); } } }; } } Err(e) => { self.handle_error(anyhow!(e)).await; } } } async fn get_playlist_tracks(&mut self, playlist_id: String, playlist_offset: u32) { if let Ok(playlist_tracks) = self .spotify .user_playlist_tracks( "spotify", &playlist_id, None, Some(self.large_search_limit), Some(playlist_offset), None, ) .await { self.set_playlist_tracks_to_table(&playlist_tracks).await; let mut app = self.app.lock().await; app.playlist_tracks = Some(playlist_tracks); app.push_navigation_stack(RouteId::TrackTable, ActiveBlock::TrackTable); }; } async fn set_playlist_tracks_to_table(&mut self, playlist_track_page: &Page) { self .set_tracks_to_table( playlist_track_page .items .clone() .into_iter() .filter_map(|item| item.track) .collect::>(), ) .await; } async fn set_tracks_to_table(&mut self, tracks: Vec) { let mut app = self.app.lock().await; app.track_table.tracks = tracks.clone(); // Send this event round (don't block here) app.dispatch(IoEvent::CurrentUserSavedTracksContains( tracks .into_iter() .filter_map(|item| item.id) .collect::>(), )); } async fn set_artists_to_table(&mut self, artists: Vec) { let mut app = self.app.lock().await; app.artists = artists; } async fn get_made_for_you_playlist_tracks( &mut self, playlist_id: String, made_for_you_offset: u32, ) { if let Ok(made_for_you_tracks) = self .spotify .user_playlist_tracks( "spotify", &playlist_id, None, Some(self.large_search_limit), Some(made_for_you_offset), None, ) .await { self .set_playlist_tracks_to_table(&made_for_you_tracks) .await; let mut app = self.app.lock().await; app.made_for_you_tracks = Some(made_for_you_tracks); if app.get_current_route().id != RouteId::TrackTable { app.push_navigation_stack(RouteId::TrackTable, ActiveBlock::TrackTable); } } } async fn get_current_user_saved_shows(&mut self, offset: Option) { match self .spotify .get_saved_show(self.large_search_limit, offset) .await { Ok(saved_shows) => { // not to show a blank page if !saved_shows.items.is_empty() { let mut app = self.app.lock().await; app.library.saved_shows.add_pages(saved_shows); } } Err(e) => { self.handle_error(anyhow!(e)).await; } } } async fn current_user_saved_shows_contains(&mut self, show_ids: Vec) { if let Ok(are_followed) = self .spotify .check_users_saved_shows(show_ids.to_owned()) .await { let mut app = self.app.lock().await; show_ids.iter().enumerate().for_each(|(i, id)| { if are_followed[i] { app.saved_show_ids_set.insert(id.to_owned()); } else { app.saved_show_ids_set.remove(id); } }) } } async fn get_show_episodes(&mut self, show: Box) { match self .spotify .get_shows_episodes(show.id.clone(), self.large_search_limit, 0, None) .await { Ok(episodes) => { if !episodes.items.is_empty() { let mut app = self.app.lock().await; app.library.show_episodes = ScrollableResultPages::new(); app.library.show_episodes.add_pages(episodes); app.selected_show_simplified = Some(SelectedShow { show: *show }); app.episode_table_context = EpisodeTableContext::Simplified; app.push_navigation_stack(RouteId::PodcastEpisodes, ActiveBlock::EpisodeTable); } } Err(e) => { self.handle_error(anyhow!(e)).await; } } } async fn get_show(&mut self, show_id: String) { match self.spotify.get_a_show(show_id, None).await { Ok(show) => { let selected_show = SelectedFullShow { show }; let mut app = self.app.lock().await; app.selected_show_full = Some(selected_show); app.episode_table_context = EpisodeTableContext::Full; app.push_navigation_stack(RouteId::PodcastEpisodes, ActiveBlock::EpisodeTable); } Err(e) => { self.handle_error(anyhow!(e)).await; } } } async fn get_current_show_episodes(&mut self, show_id: String, offset: Option) { match self .spotify .get_shows_episodes(show_id, self.large_search_limit, offset, None) .await { Ok(episodes) => { if !episodes.items.is_empty() { let mut app = self.app.lock().await; app.library.show_episodes.add_pages(episodes); } } Err(e) => { self.handle_error(anyhow!(e)).await; } } } async fn get_search_results(&mut self, search_term: String, country: Option) { let search_track = self.spotify.search( &search_term, SearchType::Track, self.small_search_limit, 0, country, None, ); let search_artist = self.spotify.search( &search_term, SearchType::Artist, self.small_search_limit, 0, country, None, ); let search_album = self.spotify.search( &search_term, SearchType::Album, self.small_search_limit, 0, country, None, ); let search_playlist = self.spotify.search( &search_term, SearchType::Playlist, self.small_search_limit, 0, country, None, ); let search_show = self.spotify.search( &search_term, SearchType::Show, self.small_search_limit, 0, country, None, ); // Run the futures concurrently match try_join!( search_track, search_artist, search_album, search_playlist, search_show ) { Ok(( SearchResult::Tracks(track_results), SearchResult::Artists(artist_results), SearchResult::Albums(album_results), SearchResult::Playlists(playlist_results), SearchResult::Shows(show_results), )) => { let mut app = self.app.lock().await; let artist_ids = album_results .items .iter() .filter_map(|item| item.id.to_owned()) .collect(); // Check if these artists are followed app.dispatch(IoEvent::UserArtistFollowCheck(artist_ids)); let album_ids = album_results .items .iter() .filter_map(|album| album.id.to_owned()) .collect(); // Check if these albums are saved app.dispatch(IoEvent::CurrentUserSavedAlbumsContains(album_ids)); let show_ids = show_results .items .iter() .map(|show| show.id.to_owned()) .collect(); // check if these shows are saved app.dispatch(IoEvent::CurrentUserSavedShowsContains(show_ids)); app.search_results.tracks = Some(track_results); app.search_results.artists = Some(artist_results); app.search_results.albums = Some(album_results); app.search_results.playlists = Some(playlist_results); app.search_results.shows = Some(show_results); } Err(e) => { self.handle_error(anyhow!(e)).await; } _ => {} }; } async fn get_current_user_saved_tracks(&mut self, offset: Option) { match self .spotify .current_user_saved_tracks(self.large_search_limit, offset) .await { Ok(saved_tracks) => { let mut app = self.app.lock().await; app.track_table.tracks = saved_tracks .items .clone() .into_iter() .map(|item| item.track) .collect::>(); saved_tracks.items.iter().for_each(|item| { if let Some(track_id) = &item.track.id { app.liked_song_ids_set.insert(track_id.to_string()); } }); app.library.saved_tracks.add_pages(saved_tracks); app.track_table.context = Some(TrackTableContext::SavedTracks); } Err(e) => { self.handle_error(anyhow!(e)).await; } } } async fn start_playback( &mut self, context_uri: Option, uris: Option>, offset: Option, ) { let (uris, context_uri) = if context_uri.is_some() { (None, context_uri) } else if uris.is_some() { (uris, None) } else { (None, None) }; let offset = offset.and_then(|o| for_position(o as u32)); let result = match &self.client_config.device_id { Some(device_id) => { match self .spotify .start_playback( Some(device_id.to_string()), context_uri.clone(), uris.clone(), offset.clone(), None, ) .await { Ok(()) => Ok(()), Err(e) => Err(anyhow!(e)), } } None => Err(anyhow!("No device_id selected")), }; match result { Ok(()) => { let mut app = self.app.lock().await; app.song_progress_ms = 0; app.dispatch(IoEvent::GetCurrentPlayback); } Err(e) => { self.handle_error(e).await; } } } async fn seek(&mut self, position_ms: u32) { if let Some(device_id) = &self.client_config.device_id { match self .spotify .seek_track(position_ms, Some(device_id.to_string())) .await { Ok(()) => { // Wait between seek and status query. // Without it, the Spotify API may return the old progress. tokio::time::delay_for(Duration::from_millis(1000)).await; self.get_current_playback().await; } Err(e) => { self.handle_error(anyhow!(e)).await; } }; } } async fn next_track(&mut self) { match self .spotify .next_track(self.client_config.device_id.clone()) .await { Ok(()) => { self.get_current_playback().await; } Err(e) => { self.handle_error(anyhow!(e)).await; } }; } async fn previous_track(&mut self) { match self .spotify .previous_track(self.client_config.device_id.clone()) .await { Ok(()) => { self.get_current_playback().await; } Err(e) => { self.handle_error(anyhow!(e)).await; } }; } async fn shuffle(&mut self, shuffle_state: bool) { match self .spotify .shuffle(!shuffle_state, self.client_config.device_id.clone()) .await { Ok(()) => { // Update the UI eagerly (otherwise the UI will wait until the next 5 second interval // due to polling playback context) let mut app = self.app.lock().await; if let Some(current_playback_context) = &mut app.current_playback_context { current_playback_context.shuffle_state = !shuffle_state; }; } Err(e) => { self.handle_error(anyhow!(e)).await; } }; } async fn repeat(&mut self, repeat_state: RepeatState) { let next_repeat_state = match repeat_state { RepeatState::Off => RepeatState::Context, RepeatState::Context => RepeatState::Track, RepeatState::Track => RepeatState::Off, }; match self .spotify .repeat(next_repeat_state, self.client_config.device_id.clone()) .await { Ok(()) => { let mut app = self.app.lock().await; if let Some(current_playback_context) = &mut app.current_playback_context { current_playback_context.repeat_state = next_repeat_state; }; } Err(e) => { self.handle_error(anyhow!(e)).await; } }; } async fn pause_playback(&mut self) { match self .spotify .pause_playback(self.client_config.device_id.clone()) .await { Ok(()) => { self.get_current_playback().await; } Err(e) => { self.handle_error(anyhow!(e)).await; } }; } async fn change_volume(&mut self, volume_percent: u8) { match self .spotify .volume(volume_percent, self.client_config.device_id.clone()) .await { Ok(()) => { let mut app = self.app.lock().await; if let Some(current_playback_context) = &mut app.current_playback_context { current_playback_context.device.volume_percent = volume_percent.into(); }; } Err(e) => { self.handle_error(anyhow!(e)).await; } }; } async fn get_artist( &mut self, artist_id: String, input_artist_name: String, country: Option, ) { let albums = self.spotify.artist_albums( &artist_id, None, country, Some(self.large_search_limit), Some(0), ); let artist_name = if input_artist_name.is_empty() { self .spotify .artist(&artist_id) .await .map(|full_artist| full_artist.name) .unwrap_or_default() } else { input_artist_name }; let top_tracks = self.spotify.artist_top_tracks(&artist_id, country); let related_artist = self.spotify.artist_related_artists(&artist_id); if let Ok((albums, top_tracks, related_artist)) = try_join!(albums, top_tracks, related_artist) { let mut app = self.app.lock().await; app.dispatch(IoEvent::CurrentUserSavedAlbumsContains( albums .items .iter() .filter_map(|item| item.id.to_owned()) .collect(), )); app.artist = Some(Artist { artist_name, albums, related_artists: related_artist.artists, top_tracks: top_tracks.tracks, selected_album_index: 0, selected_related_artist_index: 0, selected_top_track_index: 0, artist_hovered_block: ArtistBlock::TopTracks, artist_selected_block: ArtistBlock::Empty, }); } } async fn get_album_tracks(&mut self, album: Box) { if let Some(album_id) = &album.id { match self .spotify .album_track(&album_id.clone(), self.large_search_limit, 0) .await { Ok(tracks) => { let track_ids = tracks .items .iter() .filter_map(|item| item.id.clone()) .collect::>(); let mut app = self.app.lock().await; app.selected_album_simplified = Some(SelectedAlbum { album: *album, tracks, selected_index: 0, }); app.album_table_context = AlbumTableContext::Simplified; app.push_navigation_stack(RouteId::AlbumTracks, ActiveBlock::AlbumTracks); app.dispatch(IoEvent::CurrentUserSavedTracksContains(track_ids)); } Err(e) => { self.handle_error(anyhow!(e)).await; } } } } async fn get_recommendations_for_seed( &mut self, seed_artists: Option>, seed_tracks: Option>, first_track: Box>, country: Option, ) { let empty_payload: Map = Map::new(); match self .spotify .recommendations( seed_artists, // artists None, // genres seed_tracks, // tracks self.large_search_limit, // adjust playlist to screen size country, // country &empty_payload, // payload ) .await { Ok(result) => { if let Some(mut recommended_tracks) = self.extract_recommended_tracks(&result).await { //custom first track if let Some(track) = *first_track { recommended_tracks.insert(0, track); } let track_ids = recommended_tracks .iter() .map(|x| x.uri.clone()) .collect::>(); self.set_tracks_to_table(recommended_tracks.clone()).await; let mut app = self.app.lock().await; app.recommended_tracks = recommended_tracks; app.track_table.context = Some(TrackTableContext::RecommendedTracks); if app.get_current_route().id != RouteId::Recommendations { app.push_navigation_stack(RouteId::Recommendations, ActiveBlock::TrackTable); }; app.dispatch(IoEvent::StartPlayback(None, Some(track_ids), Some(0))); } } Err(e) => { self.handle_error(anyhow!(e)).await; } } } async fn extract_recommended_tracks( &mut self, recommendations: &Recommendations, ) -> Option> { let tracks = recommendations .clone() .tracks .into_iter() .map(|item| item.uri) .collect::>(); if let Ok(result) = self .spotify .tracks(tracks.iter().map(|x| &x[..]).collect::>(), None) .await { return Some(result.tracks); } None } async fn get_recommendations_for_track_id(&mut self, id: String, country: Option) { if let Ok(track) = self.spotify.track(&id).await { let track_id_list = track.id.as_ref().map(|id| vec![id.to_string()]); self .get_recommendations_for_seed(None, track_id_list, Box::new(Some(track)), country) .await; } } async fn toggle_save_track(&mut self, track_id: String) { match self .spotify .current_user_saved_tracks_contains(&[track_id.clone()]) .await { Ok(saved) => { if saved.first() == Some(&true) { match self .spotify .current_user_saved_tracks_delete(&[track_id.clone()]) .await { Ok(()) => { let mut app = self.app.lock().await; app.liked_song_ids_set.remove(&track_id); } Err(e) => { self.handle_error(anyhow!(e)).await; } } } else { match self .spotify .current_user_saved_tracks_add(&[track_id.clone()]) .await { Ok(()) => { // TODO: This should ideally use the same logic as `self.current_user_saved_tracks_contains` let mut app = self.app.lock().await; app.liked_song_ids_set.insert(track_id); } Err(e) => { self.handle_error(anyhow!(e)).await; } } } } Err(e) => { self.handle_error(anyhow!(e)).await; } }; } async fn get_followed_artists(&mut self, after: Option) { match self .spotify .current_user_followed_artists(self.large_search_limit, after) .await { Ok(saved_artists) => { let mut app = self.app.lock().await; app.artists = saved_artists.artists.items.to_owned(); app.library.saved_artists.add_pages(saved_artists.artists); } Err(e) => { self.handle_error(anyhow!(e)).await; } }; } async fn user_artist_check_follow(&mut self, artist_ids: Vec) { if let Ok(are_followed) = self.spotify.user_artist_check_follow(&artist_ids).await { let mut app = self.app.lock().await; artist_ids.iter().enumerate().for_each(|(i, id)| { if are_followed[i] { app.followed_artist_ids_set.insert(id.to_owned()); } else { app.followed_artist_ids_set.remove(id); } }); } } async fn get_current_user_saved_albums(&mut self, offset: Option) { match self .spotify .current_user_saved_albums(self.large_search_limit, offset) .await { Ok(saved_albums) => { // not to show a blank page if !saved_albums.items.is_empty() { let mut app = self.app.lock().await; app.library.saved_albums.add_pages(saved_albums); } } Err(e) => { self.handle_error(anyhow!(e)).await; } }; } async fn current_user_saved_albums_contains(&mut self, album_ids: Vec) { if let Ok(are_followed) = self .spotify .current_user_saved_albums_contains(&album_ids) .await { let mut app = self.app.lock().await; album_ids.iter().enumerate().for_each(|(i, id)| { if are_followed[i] { app.saved_album_ids_set.insert(id.to_owned()); } else { app.saved_album_ids_set.remove(id); } }); } } pub async fn current_user_saved_album_delete(&mut self, album_id: String) { match self .spotify .current_user_saved_albums_delete(&[album_id.to_owned()]) .await { Ok(_) => { self.get_current_user_saved_albums(None).await; let mut app = self.app.lock().await; app.saved_album_ids_set.remove(&album_id.to_owned()); } Err(e) => { self.handle_error(anyhow!(e)).await; } }; } async fn current_user_saved_album_add(&mut self, album_id: String) { match self .spotify .current_user_saved_albums_add(&[album_id.to_owned()]) .await { Ok(_) => { let mut app = self.app.lock().await; app.saved_album_ids_set.insert(album_id.to_owned()); } Err(e) => self.handle_error(anyhow!(e)).await, } } async fn current_user_saved_shows_delete(&mut self, show_id: String) { match self .spotify .remove_users_saved_shows(vec![show_id.to_owned()], None) .await { Ok(_) => { self.get_current_user_saved_shows(None).await; let mut app = self.app.lock().await; app.saved_show_ids_set.remove(&show_id.to_owned()); } Err(e) => { self.handle_error(anyhow!(e)).await; } } } async fn current_user_saved_shows_add(&mut self, show_id: String) { match self.spotify.save_shows(vec![show_id.to_owned()]).await { Ok(_) => { self.get_current_user_saved_shows(None).await; let mut app = self.app.lock().await; app.saved_show_ids_set.insert(show_id.to_owned()); } Err(e) => { self.handle_error(anyhow!(e)).await; } } } async fn user_unfollow_artists(&mut self, artist_ids: Vec) { match self.spotify.user_unfollow_artists(&artist_ids).await { Ok(_) => { self.get_followed_artists(None).await; let mut app = self.app.lock().await; artist_ids.iter().for_each(|id| { app.followed_artist_ids_set.remove(&id.to_owned()); }); } Err(e) => { self.handle_error(anyhow!(e)).await; } } } async fn user_follow_artists(&mut self, artist_ids: Vec) { match self.spotify.user_follow_artists(&artist_ids).await { Ok(_) => { self.get_followed_artists(None).await; let mut app = self.app.lock().await; artist_ids.iter().for_each(|id| { app.followed_artist_ids_set.insert(id.to_owned()); }); } Err(e) => { self.handle_error(anyhow!(e)).await; } } } async fn user_follow_playlist( &mut self, playlist_owner_id: String, playlist_id: String, is_public: Option, ) { match self .spotify .user_playlist_follow_playlist(&playlist_owner_id, &playlist_id, is_public) .await { Ok(_) => { self.get_current_user_playlists().await; } Err(e) => { self.handle_error(anyhow!(e)).await; } } } async fn user_unfollow_playlist(&mut self, user_id: String, playlist_id: String) { match self .spotify .user_playlist_unfollow(&user_id, &playlist_id) .await { Ok(_) => { self.get_current_user_playlists().await; } Err(e) => { self.handle_error(anyhow!(e)).await; } } } async fn made_for_you_search_and_add(&mut self, search_string: String, country: Option) { const SPOTIFY_ID: &str = "spotify"; match self .spotify .search( &search_string, SearchType::Playlist, self.large_search_limit, 0, country, None, ) .await { Ok(SearchResult::Playlists(mut search_playlists)) => { let mut filtered_playlists = search_playlists .items .iter() .filter(|playlist| playlist.owner.id == SPOTIFY_ID && playlist.name == search_string) .map(|playlist| playlist.to_owned()) .collect::>(); let mut app = self.app.lock().await; if !app.library.made_for_you_playlists.pages.is_empty() { app .library .made_for_you_playlists .get_mut_results(None) .unwrap() .items .append(&mut filtered_playlists); } else { search_playlists.items = filtered_playlists; app .library .made_for_you_playlists .add_pages(search_playlists); } } Err(e) => { self.handle_error(anyhow!(e)).await; } _ => {} } } async fn get_audio_analysis(&mut self, uri: String) { match self.spotify.audio_analysis(&uri).await { Ok(result) => { let mut app = self.app.lock().await; app.audio_analysis = Some(result); } Err(e) => { self.handle_error(anyhow!(e)).await; } } } async fn get_current_user_playlists(&mut self) { let playlists = self .spotify .current_user_playlists(self.large_search_limit, None) .await; match playlists { Ok(p) => { let mut app = self.app.lock().await; app.playlists = Some(p); // Select the first playlist app.selected_playlist_index = Some(0); } Err(e) => { self.handle_error(anyhow!(e)).await; } }; } async fn get_recently_played(&mut self) { match self .spotify .current_user_recently_played(self.large_search_limit) .await { Ok(result) => { let track_ids = result .items .iter() .filter_map(|item| item.track.id.clone()) .collect::>(); self.current_user_saved_tracks_contains(track_ids).await; let mut app = self.app.lock().await; app.recently_played.result = Some(result.clone()); } Err(e) => { self.handle_error(anyhow!(e)).await; } } } async fn get_album(&mut self, album_id: String) { match self.spotify.album(&album_id).await { Ok(album) => { let selected_album = SelectedFullAlbum { album, selected_index: 0, }; let mut app = self.app.lock().await; app.selected_album_full = Some(selected_album); app.album_table_context = AlbumTableContext::Full; app.push_navigation_stack(RouteId::AlbumTracks, ActiveBlock::AlbumTracks); } Err(e) => { self.handle_error(anyhow!(e)).await; } } } async fn get_album_for_track(&mut self, track_id: String) { match self.spotify.track(&track_id).await { Ok(track) => { // It is unclear when the id can ever be None, but perhaps a track can be album-less. If // so, there isn't much to do here anyways, since we're looking for the parent album. let album_id = match track.album.id { Some(id) => id, None => return, }; if let Ok(album) = self.spotify.album(&album_id).await { // The way we map to the UI is zero-indexed, but Spotify is 1-indexed. let zero_indexed_track_number = track.track_number - 1; let selected_album = SelectedFullAlbum { album, // Overflow should be essentially impossible here, so we prefer the cleaner 'as'. selected_index: zero_indexed_track_number as usize, }; let mut app = self.app.lock().await; app.selected_album_full = Some(selected_album.clone()); app.saved_album_tracks_index = selected_album.selected_index; app.album_table_context = AlbumTableContext::Full; app.push_navigation_stack(RouteId::AlbumTracks, ActiveBlock::AlbumTracks); } } Err(e) => { self.handle_error(anyhow!(e)).await; } } } async fn transfert_playback_to_device(&mut self, device_id: String) { match self.spotify.transfer_playback(&device_id, true).await { Ok(()) => { self.get_current_playback().await; } Err(e) => { self.handle_error(anyhow!(e)).await; return; } }; match self.client_config.set_device_id(device_id) { Ok(()) => { let mut app = self.app.lock().await; app.pop_navigation_stack(); } Err(e) => { self.handle_error(e).await; } }; } async fn refresh_authentication(&mut self) { if let Some(new_token_info) = get_token(&mut self.oauth).await { let (new_spotify, new_token_expiry) = get_spotify(new_token_info); self.spotify = new_spotify; let mut app = self.app.lock().await; app.spotify_token_expiry = new_token_expiry; } else { println!("\nFailed to refresh authentication token"); // TODO panic! } } async fn add_item_to_queue(&mut self, item: String) { match self .spotify .add_item_to_queue(item, self.client_config.device_id.clone()) .await { Ok(()) => (), Err(e) => { self.handle_error(anyhow!(e)).await; } } } } ================================================ FILE: src/redirect_uri.html ================================================ spotify-tui

spotify-tui

Client authorized. You can return to your terminal and close this window.

================================================ FILE: src/redirect_uri.rs ================================================ use rspotify::{oauth2::SpotifyOAuth, util::request_token}; use std::{ io::prelude::*, net::{TcpListener, TcpStream}, }; pub fn redirect_uri_web_server(spotify_oauth: &mut SpotifyOAuth, port: u16) -> Result { let listener = TcpListener::bind(format!("127.0.0.1:{}", port)); match listener { Ok(listener) => { request_token(spotify_oauth); for stream in listener.incoming() { match stream { Ok(stream) => { if let Some(url) = handle_connection(stream) { return Ok(url); } } Err(e) => { println!("Error: {}", e); } }; } } Err(e) => { println!("Error: {}", e); } } Err(()) } fn handle_connection(mut stream: TcpStream) -> Option { // The request will be quite large (> 512) so just assign plenty just in case let mut buffer = [0; 1000]; let _ = stream.read(&mut buffer).unwrap(); // convert buffer into string and 'parse' the URL match String::from_utf8(buffer.to_vec()) { Ok(request) => { let split: Vec<&str> = request.split_whitespace().collect(); if split.len() > 1 { respond_with_success(stream); return Some(split[1].to_string()); } respond_with_error("Malformed request".to_string(), stream); } Err(e) => { respond_with_error(format!("Invalid UTF-8 sequence: {}", e), stream); } }; None } fn respond_with_success(mut stream: TcpStream) { let contents = include_str!("redirect_uri.html"); let response = format!("HTTP/1.1 200 OK\r\n\r\n{}", contents); stream.write_all(response.as_bytes()).unwrap(); stream.flush().unwrap(); } fn respond_with_error(error_message: String, mut stream: TcpStream) { println!("Error: {}", error_message); let response = format!( "HTTP/1.1 400 Bad Request\r\n\r\n400 - Bad Request - {}", error_message ); stream.write_all(response.as_bytes()).unwrap(); stream.flush().unwrap(); } ================================================ FILE: src/ui/audio_analysis.rs ================================================ use super::util; use crate::app::App; use tui::{ backend::Backend, layout::{Constraint, Direction, Layout}, style::Style, text::{Span, Spans}, widgets::{BarChart, Block, Borders, Paragraph}, Frame, }; const PITCHES: [&str; 12] = [ "C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B", ]; pub fn draw(f: &mut Frame, app: &App) where B: Backend, { let margin = util::get_main_layout_margin(app); let chunks = Layout::default() .direction(Direction::Vertical) .constraints([Constraint::Min(5), Constraint::Length(95)].as_ref()) .margin(margin) .split(f.size()); let analysis_block = Block::default() .title(Span::styled( "Analysis", Style::default().fg(app.user_config.theme.inactive), )) .borders(Borders::ALL) .border_style(Style::default().fg(app.user_config.theme.inactive)); let white = Style::default().fg(app.user_config.theme.text); let gray = Style::default().fg(app.user_config.theme.inactive); let width = (chunks[1].width) as f32 / (1 + PITCHES.len()) as f32; let tick_rate = app.user_config.behavior.tick_rate_milliseconds; let bar_chart_title = &format!("Pitches | Tick Rate {} {}FPS", tick_rate, 1000 / tick_rate); let bar_chart_block = Block::default() .borders(Borders::ALL) .style(white) .title(Span::styled(bar_chart_title, gray)) .border_style(gray); let empty_analysis_block = || { Paragraph::new("No analysis available") .block(analysis_block.clone()) .style(Style::default().fg(app.user_config.theme.text)) }; let empty_pitches_block = || { Paragraph::new("No pitch information available") .block(bar_chart_block.clone()) .style(Style::default().fg(app.user_config.theme.text)) }; if let Some(analysis) = &app.audio_analysis { let progress_seconds = (app.song_progress_ms as f32) / 1000.0; let beat = analysis .beats .iter() .find(|beat| beat.start >= progress_seconds); let beat_offset = beat .map(|beat| beat.start - progress_seconds) .unwrap_or(0.0); let segment = analysis .segments .iter() .find(|segment| segment.start >= progress_seconds); let section = analysis .sections .iter() .find(|section| section.start >= progress_seconds); if let (Some(segment), Some(section)) = (segment, section) { let texts = vec![ Spans::from(format!( "Tempo: {} (confidence {:.0}%)", section.tempo, section.tempo_confidence * 100.0 )), Spans::from(format!( "Key: {} (confidence {:.0}%)", PITCHES.get(section.key as usize).unwrap_or(&PITCHES[0]), section.key_confidence * 100.0 )), Spans::from(format!( "Time Signature: {}/4 (confidence {:.0}%)", section.time_signature, section.time_signature_confidence * 100.0 )), ]; let p = Paragraph::new(texts) .block(analysis_block) .style(Style::default().fg(app.user_config.theme.text)); f.render_widget(p, chunks[0]); let data: Vec<(&str, u64)> = segment .clone() .pitches .iter() .enumerate() .map(|(index, pitch)| { let display_pitch = *PITCHES.get(index).unwrap_or(&PITCHES[0]); let bar_value = ((pitch * 1000.0) as u64) // Add a beat offset to make the bar animate between beats .checked_add((beat_offset * 3000.0) as u64) .unwrap_or(0); (display_pitch, bar_value) }) .collect(); let analysis_bar = BarChart::default() .block(bar_chart_block) .data(&data) .bar_width(width as u16) .bar_style(Style::default().fg(app.user_config.theme.analysis_bar)) .value_style( Style::default() .fg(app.user_config.theme.analysis_bar_text) .bg(app.user_config.theme.analysis_bar), ); f.render_widget(analysis_bar, chunks[1]); } else { f.render_widget(empty_analysis_block(), chunks[0]); f.render_widget(empty_pitches_block(), chunks[1]); }; } else { f.render_widget(empty_analysis_block(), chunks[0]); f.render_widget(empty_pitches_block(), chunks[1]); } } ================================================ FILE: src/ui/help.rs ================================================ use crate::user_config::KeyBindings; pub fn get_help_docs(key_bindings: &KeyBindings) -> Vec> { vec![ vec![ String::from("Scroll down to next result page"), key_bindings.next_page.to_string(), String::from("Pagination"), ], vec![ String::from("Scroll up to previous result page"), key_bindings.previous_page.to_string(), String::from("Pagination"), ], vec![ String::from("Jump to start of playlist"), key_bindings.jump_to_start.to_string(), String::from("Pagination"), ], vec![ String::from("Jump to end of playlist"), key_bindings.jump_to_end.to_string(), String::from("Pagination"), ], vec![ String::from("Jump to currently playing album"), key_bindings.jump_to_album.to_string(), String::from("General"), ], vec![ String::from("Jump to currently playing artist's album list"), key_bindings.jump_to_artist_album.to_string(), String::from("General"), ], vec![ String::from("Jump to current play context"), key_bindings.jump_to_context.to_string(), String::from("General"), ], vec![ String::from("Increase volume by 10%"), key_bindings.increase_volume.to_string(), String::from("General"), ], vec![ String::from("Decrease volume by 10%"), key_bindings.decrease_volume.to_string(), String::from("General"), ], vec![ String::from("Skip to next track"), key_bindings.next_track.to_string(), String::from("General"), ], vec![ String::from("Skip to previous track"), key_bindings.previous_track.to_string(), String::from("General"), ], vec![ String::from("Seek backwards 5 seconds"), key_bindings.seek_backwards.to_string(), String::from("General"), ], vec![ String::from("Seek forwards 5 seconds"), key_bindings.seek_forwards.to_string(), String::from("General"), ], vec![ String::from("Toggle shuffle"), key_bindings.shuffle.to_string(), String::from("General"), ], vec![ String::from("Copy url to currently playing song/episode"), key_bindings.copy_song_url.to_string(), String::from("General"), ], vec![ String::from("Copy url to currently playing album/show"), key_bindings.copy_album_url.to_string(), String::from("General"), ], vec![ String::from("Cycle repeat mode"), key_bindings.repeat.to_string(), String::from("General"), ], vec![ String::from("Move selection left"), String::from("h | | "), String::from("General"), ], vec![ String::from("Move selection down"), String::from("j | | "), String::from("General"), ], vec![ String::from("Move selection up"), String::from("k | | "), String::from("General"), ], vec![ String::from("Move selection right"), String::from("l | | "), String::from("General"), ], vec![ String::from("Move selection to top of list"), String::from("H"), String::from("General"), ], vec![ String::from("Move selection to middle of list"), String::from("M"), String::from("General"), ], vec![ String::from("Move selection to bottom of list"), String::from("L"), String::from("General"), ], vec![ String::from("Enter input for search"), key_bindings.search.to_string(), String::from("General"), ], vec![ String::from("Pause/Resume playback"), key_bindings.toggle_playback.to_string(), String::from("General"), ], vec![ String::from("Enter active mode"), String::from(""), String::from("General"), ], vec![ String::from("Go to audio analysis screen"), key_bindings.audio_analysis.to_string(), String::from("General"), ], vec![ String::from("Go to playbar only screen (basic view)"), key_bindings.basic_view.to_string(), String::from("General"), ], vec![ String::from("Go back or exit when nowhere left to back to"), key_bindings.back.to_string(), String::from("General"), ], vec![ String::from("Select device to play music on"), key_bindings.manage_devices.to_string(), String::from("General"), ], vec![ String::from("Enter hover mode"), String::from(""), String::from("Selected block"), ], vec![ String::from("Save track in list or table"), String::from("s"), String::from("Selected block"), ], vec![ String::from("Start playback or enter album/artist/playlist"), key_bindings.submit.to_string(), String::from("Selected block"), ], vec![ String::from("Play recommendations for song/artist"), String::from("r"), String::from("Selected block"), ], vec![ String::from("Play all tracks for artist"), String::from("e"), String::from("Library -> Artists"), ], vec![ String::from("Search with input text"), String::from(""), String::from("Search input"), ], vec![ String::from("Move cursor one space left"), String::from(""), String::from("Search input"), ], vec![ String::from("Move cursor one space right"), String::from(""), String::from("Search input"), ], vec![ String::from("Delete entire input"), String::from(""), String::from("Search input"), ], vec![ String::from("Delete text from cursor to start of input"), String::from(""), String::from("Search input"), ], vec![ String::from("Delete text from cursor to end of input"), String::from(""), String::from("Search input"), ], vec![ String::from("Delete previous word"), String::from(""), String::from("Search input"), ], vec![ String::from("Jump to start of input"), String::from(""), String::from("Search input"), ], vec![ String::from("Jump to end of input"), String::from(""), String::from("Search input"), ], vec![ String::from("Escape from the input back to hovered block"), String::from(""), String::from("Search input"), ], vec![ String::from("Delete saved album"), String::from("D"), String::from("Library -> Albums"), ], vec![ String::from("Delete saved playlist"), String::from("D"), String::from("Playlist"), ], vec![ String::from("Follow an artist/playlist"), String::from("w"), String::from("Search result"), ], vec![ String::from("Save (like) album to library"), String::from("w"), String::from("Search result"), ], vec![ String::from("Play random song in playlist"), String::from("S"), String::from("Selected Playlist"), ], vec![ String::from("Toggle sort order of podcast episodes"), String::from("S"), String::from("Selected Show"), ], vec![ String::from("Add track to queue"), key_bindings.add_item_to_queue.to_string(), String::from("Hovered over track"), ], ] } ================================================ FILE: src/ui/mod.rs ================================================ pub mod audio_analysis; pub mod help; pub mod util; use super::{ app::{ ActiveBlock, AlbumTableContext, App, ArtistBlock, EpisodeTableContext, RecommendationsContext, RouteId, SearchResultBlock, LIBRARY_OPTIONS, }, banner::BANNER, }; use help::get_help_docs; use rspotify::model::show::ResumePoint; use rspotify::model::PlayingItem; use rspotify::senum::RepeatState; use tui::{ backend::Backend, layout::{Alignment, Constraint, Direction, Layout, Rect}, style::{Modifier, Style}, text::{Span, Spans, Text}, widgets::{Block, Borders, Clear, Gauge, List, ListItem, ListState, Paragraph, Row, Table, Wrap}, Frame, }; use util::{ create_artist_string, display_track_progress, get_artist_highlight_state, get_color, get_percentage_width, get_search_results_highlight_state, get_track_progress_percentage, millis_to_minutes, BASIC_VIEW_HEIGHT, SMALL_TERMINAL_WIDTH, }; pub enum TableId { Album, AlbumList, Artist, Podcast, Song, RecentlyPlayed, MadeForYou, PodcastEpisodes, } #[derive(PartialEq)] pub enum ColumnId { None, Title, Liked, } impl Default for ColumnId { fn default() -> Self { ColumnId::None } } pub struct TableHeader<'a> { id: TableId, items: Vec>, } impl TableHeader<'_> { pub fn get_index(&self, id: ColumnId) -> Option { self.items.iter().position(|item| item.id == id) } } #[derive(Default)] pub struct TableHeaderItem<'a> { id: ColumnId, text: &'a str, width: u16, } pub struct TableItem { id: String, format: Vec, } pub fn draw_help_menu(f: &mut Frame, app: &App) where B: Backend, { let chunks = Layout::default() .direction(Direction::Vertical) .constraints([Constraint::Percentage(100)].as_ref()) .margin(2) .split(f.size()); // Create a one-column table to avoid flickering due to non-determinism when // resolving constraints on widths of table columns. let format_row = |r: Vec| -> Vec { vec![format!("{:50}{:40}{:20}", r[0], r[1], r[2])] }; let help_menu_style = Style::default().fg(app.user_config.theme.text); let header = ["Description", "Event", "Context"]; let header = format_row(header.iter().map(|s| s.to_string()).collect()); let help_docs = get_help_docs(&app.user_config.keys); let help_docs = help_docs .into_iter() .map(format_row) .collect::>>(); let help_docs = &help_docs[app.help_menu_offset as usize..]; let rows = help_docs .iter() .map(|item| Row::new(item.clone()).style(help_menu_style)); let help_menu = Table::new(rows) .header(Row::new(header)) .block( Block::default() .borders(Borders::ALL) .style(help_menu_style) .title(Span::styled( "Help (press to go back)", help_menu_style, )) .border_style(help_menu_style), ) .style(help_menu_style) .widths(&[Constraint::Percentage(100)]); f.render_widget(help_menu, chunks[0]); } pub fn draw_input_and_help_box(f: &mut Frame, app: &App, layout_chunk: Rect) where B: Backend, { // Check for the width and change the contraints accordingly let chunks = Layout::default() .direction(Direction::Horizontal) .constraints( if app.size.width >= SMALL_TERMINAL_WIDTH && !app.user_config.behavior.enforce_wide_search_bar { [Constraint::Percentage(65), Constraint::Percentage(35)].as_ref() } else { [Constraint::Percentage(90), Constraint::Percentage(10)].as_ref() }, ) .split(layout_chunk); let current_route = app.get_current_route(); let highlight_state = ( current_route.active_block == ActiveBlock::Input, current_route.hovered_block == ActiveBlock::Input, ); let input_string: String = app.input.iter().collect(); let lines = Text::from((&input_string).as_str()); let input = Paragraph::new(lines).block( Block::default() .borders(Borders::ALL) .title(Span::styled( "Search", get_color(highlight_state, app.user_config.theme), )) .border_style(get_color(highlight_state, app.user_config.theme)), ); f.render_widget(input, chunks[0]); let show_loading = app.is_loading && app.user_config.behavior.show_loading_indicator; let help_block_text = if show_loading { (app.user_config.theme.hint, "Loading...") } else { (app.user_config.theme.inactive, "Type ?") }; let block = Block::default() .title(Span::styled("Help", Style::default().fg(help_block_text.0))) .borders(Borders::ALL) .border_style(Style::default().fg(help_block_text.0)); let lines = Text::from(help_block_text.1); let help = Paragraph::new(lines) .block(block) .style(Style::default().fg(help_block_text.0)); f.render_widget(help, chunks[1]); } pub fn draw_main_layout(f: &mut Frame, app: &App) where B: Backend, { let margin = util::get_main_layout_margin(app); // Responsive layout: new one kicks in at width 150 or higher if app.size.width >= SMALL_TERMINAL_WIDTH && !app.user_config.behavior.enforce_wide_search_bar { let parent_layout = Layout::default() .direction(Direction::Vertical) .constraints([Constraint::Min(1), Constraint::Length(6)].as_ref()) .margin(margin) .split(f.size()); // Nested main block with potential routes draw_routes(f, app, parent_layout[0]); // Currently playing draw_playbar(f, app, parent_layout[1]); } else { let parent_layout = Layout::default() .direction(Direction::Vertical) .constraints( [ Constraint::Length(3), Constraint::Min(1), Constraint::Length(6), ] .as_ref(), ) .margin(margin) .split(f.size()); // Search input and help draw_input_and_help_box(f, app, parent_layout[0]); // Nested main block with potential routes draw_routes(f, app, parent_layout[1]); // Currently playing draw_playbar(f, app, parent_layout[2]); } // Possibly draw confirm dialog draw_dialog(f, app); } pub fn draw_routes(f: &mut Frame, app: &App, layout_chunk: Rect) where B: Backend, { let chunks = Layout::default() .direction(Direction::Horizontal) .constraints([Constraint::Percentage(20), Constraint::Percentage(80)].as_ref()) .split(layout_chunk); draw_user_block(f, app, chunks[0]); let current_route = app.get_current_route(); match current_route.id { RouteId::Search => { draw_search_results(f, app, chunks[1]); } RouteId::TrackTable => { draw_song_table(f, app, chunks[1]); } RouteId::AlbumTracks => { draw_album_table(f, app, chunks[1]); } RouteId::RecentlyPlayed => { draw_recently_played_table(f, app, chunks[1]); } RouteId::Artist => { draw_artist_albums(f, app, chunks[1]); } RouteId::AlbumList => { draw_album_list(f, app, chunks[1]); } RouteId::PodcastEpisodes => { draw_show_episodes(f, app, chunks[1]); } RouteId::Home => { draw_home(f, app, chunks[1]); } RouteId::MadeForYou => { draw_made_for_you(f, app, chunks[1]); } RouteId::Artists => { draw_artist_table(f, app, chunks[1]); } RouteId::Podcasts => { draw_podcast_table(f, app, chunks[1]); } RouteId::Recommendations => { draw_recommendations_table(f, app, chunks[1]); } RouteId::Error => {} // This is handled as a "full screen" route in main.rs RouteId::SelectedDevice => {} // This is handled as a "full screen" route in main.rs RouteId::Analysis => {} // This is handled as a "full screen" route in main.rs RouteId::BasicView => {} // This is handled as a "full screen" route in main.rs RouteId::Dialog => {} // This is handled in the draw_dialog function in mod.rs }; } pub fn draw_library_block(f: &mut Frame, app: &App, layout_chunk: Rect) where B: Backend, { let current_route = app.get_current_route(); let highlight_state = ( current_route.active_block == ActiveBlock::Library, current_route.hovered_block == ActiveBlock::Library, ); draw_selectable_list( f, app, layout_chunk, "Library", &LIBRARY_OPTIONS, highlight_state, Some(app.library.selected_index), ); } pub fn draw_playlist_block(f: &mut Frame, app: &App, layout_chunk: Rect) where B: Backend, { let playlist_items = match &app.playlists { Some(p) => p.items.iter().map(|item| item.name.to_owned()).collect(), None => vec![], }; let current_route = app.get_current_route(); let highlight_state = ( current_route.active_block == ActiveBlock::MyPlaylists, current_route.hovered_block == ActiveBlock::MyPlaylists, ); draw_selectable_list( f, app, layout_chunk, "Playlists", &playlist_items, highlight_state, app.selected_playlist_index, ); } pub fn draw_user_block(f: &mut Frame, app: &App, layout_chunk: Rect) where B: Backend, { // Check for width to make a responsive layout if app.size.width >= SMALL_TERMINAL_WIDTH && !app.user_config.behavior.enforce_wide_search_bar { let chunks = Layout::default() .direction(Direction::Vertical) .constraints( [ Constraint::Length(3), Constraint::Percentage(30), Constraint::Percentage(70), ] .as_ref(), ) .split(layout_chunk); // Search input and help draw_input_and_help_box(f, app, chunks[0]); draw_library_block(f, app, chunks[1]); draw_playlist_block(f, app, chunks[2]); } else { let chunks = Layout::default() .direction(Direction::Vertical) .constraints([Constraint::Percentage(30), Constraint::Percentage(70)].as_ref()) .split(layout_chunk); // Search input and help draw_library_block(f, app, chunks[0]); draw_playlist_block(f, app, chunks[1]); } } pub fn draw_search_results(f: &mut Frame, app: &App, layout_chunk: Rect) where B: Backend, { let chunks = Layout::default() .direction(Direction::Vertical) .constraints( [ Constraint::Percentage(35), Constraint::Percentage(35), Constraint::Percentage(25), ] .as_ref(), ) .split(layout_chunk); { let song_artist_block = Layout::default() .direction(Direction::Horizontal) .constraints([Constraint::Percentage(50), Constraint::Percentage(50)].as_ref()) .split(chunks[0]); let currently_playing_id = app .current_playback_context .clone() .and_then(|context| { context.item.and_then(|item| match item { PlayingItem::Track(track) => track.id, PlayingItem::Episode(episode) => Some(episode.id), }) }) .unwrap_or_else(|| "".to_string()); let songs = match &app.search_results.tracks { Some(tracks) => tracks .items .iter() .map(|item| { let mut song_name = "".to_string(); let id = item.clone().id.unwrap_or_else(|| "".to_string()); if currently_playing_id == id { song_name += "▶ " } if app.liked_song_ids_set.contains(&id) { song_name += &app.user_config.padded_liked_icon(); } song_name += &item.name; song_name += &format!(" - {}", &create_artist_string(&item.artists)); song_name }) .collect(), None => vec![], }; draw_selectable_list( f, app, song_artist_block[0], "Songs", &songs, get_search_results_highlight_state(app, SearchResultBlock::SongSearch), app.search_results.selected_tracks_index, ); let artists = match &app.search_results.artists { Some(artists) => artists .items .iter() .map(|item| { let mut artist = String::new(); if app.followed_artist_ids_set.contains(&item.id.to_owned()) { artist.push_str(&app.user_config.padded_liked_icon()); } artist.push_str(&item.name.to_owned()); artist }) .collect(), None => vec![], }; draw_selectable_list( f, app, song_artist_block[1], "Artists", &artists, get_search_results_highlight_state(app, SearchResultBlock::ArtistSearch), app.search_results.selected_artists_index, ); } { let albums_playlist_block = Layout::default() .direction(Direction::Horizontal) .constraints([Constraint::Percentage(50), Constraint::Percentage(50)].as_ref()) .split(chunks[1]); let albums = match &app.search_results.albums { Some(albums) => albums .items .iter() .map(|item| { let mut album_artist = String::new(); if let Some(album_id) = &item.id { if app.saved_album_ids_set.contains(&album_id.to_owned()) { album_artist.push_str(&app.user_config.padded_liked_icon()); } } album_artist.push_str(&format!( "{} - {} ({})", item.name.to_owned(), create_artist_string(&item.artists), item.album_type.as_deref().unwrap_or("unknown") )); album_artist }) .collect(), None => vec![], }; draw_selectable_list( f, app, albums_playlist_block[0], "Albums", &albums, get_search_results_highlight_state(app, SearchResultBlock::AlbumSearch), app.search_results.selected_album_index, ); let playlists = match &app.search_results.playlists { Some(playlists) => playlists .items .iter() .map(|item| item.name.to_owned()) .collect(), None => vec![], }; draw_selectable_list( f, app, albums_playlist_block[1], "Playlists", &playlists, get_search_results_highlight_state(app, SearchResultBlock::PlaylistSearch), app.search_results.selected_playlists_index, ); } { let podcasts_block = Layout::default() .direction(Direction::Horizontal) .constraints([Constraint::Percentage(100)].as_ref()) .split(chunks[2]); let podcasts = match &app.search_results.shows { Some(podcasts) => podcasts .items .iter() .map(|item| { let mut show_name = String::new(); if app.saved_show_ids_set.contains(&item.id.to_owned()) { show_name.push_str(&app.user_config.padded_liked_icon()); } show_name.push_str(&format!("{:} - {}", item.name, item.publisher)); show_name }) .collect(), None => vec![], }; draw_selectable_list( f, app, podcasts_block[0], "Podcasts", &podcasts, get_search_results_highlight_state(app, SearchResultBlock::ShowSearch), app.search_results.selected_shows_index, ); } } struct AlbumUi { selected_index: usize, items: Vec, title: String, } pub fn draw_artist_table(f: &mut Frame, app: &App, layout_chunk: Rect) where B: Backend, { let header = TableHeader { id: TableId::Artist, items: vec![TableHeaderItem { text: "Artist", width: get_percentage_width(layout_chunk.width, 1.0), ..Default::default() }], }; let current_route = app.get_current_route(); let highlight_state = ( current_route.active_block == ActiveBlock::Artists, current_route.hovered_block == ActiveBlock::Artists, ); let items = app .artists .iter() .map(|item| TableItem { id: item.id.clone(), format: vec![item.name.to_owned()], }) .collect::>(); draw_table( f, app, layout_chunk, ("Artists", &header), &items, app.artists_list_index, highlight_state, ) } pub fn draw_podcast_table(f: &mut Frame, app: &App, layout_chunk: Rect) where B: Backend, { let header = TableHeader { id: TableId::Podcast, items: vec![ TableHeaderItem { text: "Name", width: get_percentage_width(layout_chunk.width, 2.0 / 5.0), ..Default::default() }, TableHeaderItem { text: "Publisher(s)", width: get_percentage_width(layout_chunk.width, 2.0 / 5.0), ..Default::default() }, ], }; let current_route = app.get_current_route(); let highlight_state = ( current_route.active_block == ActiveBlock::Podcasts, current_route.hovered_block == ActiveBlock::Podcasts, ); if let Some(saved_shows) = app.library.saved_shows.get_results(None) { let items = saved_shows .items .iter() .map(|show_page| TableItem { id: show_page.show.id.to_owned(), format: vec![ show_page.show.name.to_owned(), show_page.show.publisher.to_owned(), ], }) .collect::>(); draw_table( f, app, layout_chunk, ("Podcasts", &header), &items, app.shows_list_index, highlight_state, ) }; } pub fn draw_album_table(f: &mut Frame, app: &App, layout_chunk: Rect) where B: Backend, { let header = TableHeader { id: TableId::Album, items: vec![ TableHeaderItem { id: ColumnId::Liked, text: "", width: 2, }, TableHeaderItem { text: "#", width: 3, ..Default::default() }, TableHeaderItem { id: ColumnId::Title, text: "Title", width: get_percentage_width(layout_chunk.width, 2.0 / 5.0) - 5, }, TableHeaderItem { text: "Artist", width: get_percentage_width(layout_chunk.width, 2.0 / 5.0), ..Default::default() }, TableHeaderItem { text: "Length", width: get_percentage_width(layout_chunk.width, 1.0 / 5.0), ..Default::default() }, ], }; let current_route = app.get_current_route(); let highlight_state = ( current_route.active_block == ActiveBlock::AlbumTracks, current_route.hovered_block == ActiveBlock::AlbumTracks, ); let album_ui = match &app.album_table_context { AlbumTableContext::Simplified => { app .selected_album_simplified .as_ref() .map(|selected_album_simplified| AlbumUi { items: selected_album_simplified .tracks .items .iter() .map(|item| TableItem { id: item.id.clone().unwrap_or_else(|| "".to_string()), format: vec![ "".to_string(), item.track_number.to_string(), item.name.to_owned(), create_artist_string(&item.artists), millis_to_minutes(u128::from(item.duration_ms)), ], }) .collect::>(), title: format!( "{} by {}", selected_album_simplified.album.name, create_artist_string(&selected_album_simplified.album.artists) ), selected_index: selected_album_simplified.selected_index, }) } AlbumTableContext::Full => match app.selected_album_full.clone() { Some(selected_album) => Some(AlbumUi { items: selected_album .album .tracks .items .iter() .map(|item| TableItem { id: item.id.clone().unwrap_or_else(|| "".to_string()), format: vec![ "".to_string(), item.track_number.to_string(), item.name.to_owned(), create_artist_string(&item.artists), millis_to_minutes(u128::from(item.duration_ms)), ], }) .collect::>(), title: format!( "{} by {}", selected_album.album.name, create_artist_string(&selected_album.album.artists) ), selected_index: app.saved_album_tracks_index, }), None => None, }, }; if let Some(album_ui) = album_ui { draw_table( f, app, layout_chunk, (&album_ui.title, &header), &album_ui.items, album_ui.selected_index, highlight_state, ); }; } pub fn draw_recommendations_table(f: &mut Frame, app: &App, layout_chunk: Rect) where B: Backend, { let header = TableHeader { id: TableId::Song, items: vec![ TableHeaderItem { id: ColumnId::Liked, text: "", width: 2, }, TableHeaderItem { id: ColumnId::Title, text: "Title", width: get_percentage_width(layout_chunk.width, 0.3), }, TableHeaderItem { text: "Artist", width: get_percentage_width(layout_chunk.width, 0.3), ..Default::default() }, TableHeaderItem { text: "Album", width: get_percentage_width(layout_chunk.width, 0.3), ..Default::default() }, TableHeaderItem { text: "Length", width: get_percentage_width(layout_chunk.width, 0.1), ..Default::default() }, ], }; let current_route = app.get_current_route(); let highlight_state = ( current_route.active_block == ActiveBlock::TrackTable, current_route.hovered_block == ActiveBlock::TrackTable, ); let items = app .track_table .tracks .iter() .map(|item| TableItem { id: item.id.clone().unwrap_or_else(|| "".to_string()), format: vec![ "".to_string(), item.name.to_owned(), create_artist_string(&item.artists), item.album.name.to_owned(), millis_to_minutes(u128::from(item.duration_ms)), ], }) .collect::>(); // match RecommendedContext let recommendations_ui = match &app.recommendations_context { Some(RecommendationsContext::Song) => format!( "Recommendations based on Song \'{}\'", &app.recommendations_seed ), Some(RecommendationsContext::Artist) => format!( "Recommendations based on Artist \'{}\'", &app.recommendations_seed ), None => "Recommendations".to_string(), }; draw_table( f, app, layout_chunk, (&recommendations_ui[..], &header), &items, app.track_table.selected_index, highlight_state, ) } pub fn draw_song_table(f: &mut Frame, app: &App, layout_chunk: Rect) where B: Backend, { let header = TableHeader { id: TableId::Song, items: vec![ TableHeaderItem { id: ColumnId::Liked, text: "", width: 2, }, TableHeaderItem { id: ColumnId::Title, text: "Title", width: get_percentage_width(layout_chunk.width, 0.3), }, TableHeaderItem { text: "Artist", width: get_percentage_width(layout_chunk.width, 0.3), ..Default::default() }, TableHeaderItem { text: "Album", width: get_percentage_width(layout_chunk.width, 0.3), ..Default::default() }, TableHeaderItem { text: "Length", width: get_percentage_width(layout_chunk.width, 0.1), ..Default::default() }, ], }; let current_route = app.get_current_route(); let highlight_state = ( current_route.active_block == ActiveBlock::TrackTable, current_route.hovered_block == ActiveBlock::TrackTable, ); let items = app .track_table .tracks .iter() .map(|item| TableItem { id: item.id.clone().unwrap_or_else(|| "".to_string()), format: vec![ "".to_string(), item.name.to_owned(), create_artist_string(&item.artists), item.album.name.to_owned(), millis_to_minutes(u128::from(item.duration_ms)), ], }) .collect::>(); draw_table( f, app, layout_chunk, ("Songs", &header), &items, app.track_table.selected_index, highlight_state, ) } pub fn draw_basic_view(f: &mut Frame, app: &App) where B: Backend, { // If space is negative, do nothing because the widget would not fit if let Some(s) = app.size.height.checked_sub(BASIC_VIEW_HEIGHT) { let space = s / 2; let chunks = Layout::default() .direction(Direction::Vertical) .constraints( [ Constraint::Length(space), Constraint::Length(BASIC_VIEW_HEIGHT), Constraint::Length(space), ] .as_ref(), ) .split(f.size()); draw_playbar(f, app, chunks[1]); } } pub fn draw_playbar(f: &mut Frame, app: &App, layout_chunk: Rect) where B: Backend, { let chunks = Layout::default() .direction(Direction::Vertical) .constraints( [ Constraint::Percentage(50), Constraint::Percentage(25), Constraint::Percentage(25), ] .as_ref(), ) .margin(1) .split(layout_chunk); // If no track is playing, render paragraph showing which device is selected, if no selected // give hint to choose a device if let Some(current_playback_context) = &app.current_playback_context { if let Some(track_item) = ¤t_playback_context.item { let play_title = if current_playback_context.is_playing { "Playing" } else { "Paused" }; let shuffle_text = if current_playback_context.shuffle_state { "On" } else { "Off" }; let repeat_text = match current_playback_context.repeat_state { RepeatState::Off => "Off", RepeatState::Track => "Track", RepeatState::Context => "All", }; let title = format!( "{:-7} ({} | Shuffle: {:-3} | Repeat: {:-5} | Volume: {:-2}%)", play_title, current_playback_context.device.name, shuffle_text, repeat_text, current_playback_context.device.volume_percent ); let current_route = app.get_current_route(); let highlight_state = ( current_route.active_block == ActiveBlock::PlayBar, current_route.hovered_block == ActiveBlock::PlayBar, ); let title_block = Block::default() .borders(Borders::ALL) .title(Span::styled( &title, get_color(highlight_state, app.user_config.theme), )) .border_style(get_color(highlight_state, app.user_config.theme)); f.render_widget(title_block, layout_chunk); let (item_id, name, duration_ms) = match track_item { PlayingItem::Track(track) => ( track.id.to_owned().unwrap_or_else(|| "".to_string()), track.name.to_owned(), track.duration_ms, ), PlayingItem::Episode(episode) => ( episode.id.to_owned(), episode.name.to_owned(), episode.duration_ms, ), }; let track_name = if app.liked_song_ids_set.contains(&item_id) { format!("{}{}", &app.user_config.padded_liked_icon(), name) } else { name }; let play_bar_text = match track_item { PlayingItem::Track(track) => create_artist_string(&track.artists), PlayingItem::Episode(episode) => format!("{} - {}", episode.name, episode.show.name), }; let lines = Text::from(Span::styled( play_bar_text, Style::default().fg(app.user_config.theme.playbar_text), )); let artist = Paragraph::new(lines) .style(Style::default().fg(app.user_config.theme.playbar_text)) .block( Block::default().title(Span::styled( &track_name, Style::default() .fg(app.user_config.theme.selected) .add_modifier(Modifier::BOLD), )), ); f.render_widget(artist, chunks[0]); let progress_ms = match app.seek_ms { Some(seek_ms) => seek_ms, None => app.song_progress_ms, }; let perc = get_track_progress_percentage(progress_ms, duration_ms); let song_progress_label = display_track_progress(progress_ms, duration_ms); let modifier = if app.user_config.behavior.enable_text_emphasis { Modifier::ITALIC | Modifier::BOLD } else { Modifier::empty() }; let song_progress = Gauge::default() .gauge_style( Style::default() .fg(app.user_config.theme.playbar_progress) .bg(app.user_config.theme.playbar_background) .add_modifier(modifier), ) .percent(perc) .label(Span::styled( &song_progress_label, Style::default().fg(app.user_config.theme.playbar_progress_text), )); f.render_widget(song_progress, chunks[2]); } } } pub fn draw_error_screen(f: &mut Frame, app: &App) where B: Backend, { let chunks = Layout::default() .direction(Direction::Vertical) .constraints([Constraint::Percentage(100)].as_ref()) .margin(5) .split(f.size()); let playing_text = vec![ Spans::from(vec![ Span::raw("Api response: "), Span::styled( &app.api_error, Style::default().fg(app.user_config.theme.error_text), ), ]), Spans::from(Span::styled( "If you are trying to play a track, please check that", Style::default().fg(app.user_config.theme.text), )), Spans::from(Span::styled( " 1. You have a Spotify Premium Account", Style::default().fg(app.user_config.theme.text), )), Spans::from(Span::styled( " 2. Your playback device is active and selected - press `d` to go to device selection menu", Style::default().fg(app.user_config.theme.text), )), Spans::from(Span::styled( " 3. If you're using spotifyd as a playback device, your device name must not contain spaces", Style::default().fg(app.user_config.theme.text), )), Spans::from(Span::styled("Hint: a playback device must be either an official spotify client or a light weight alternative such as spotifyd", Style::default().fg(app.user_config.theme.hint) ), ), Spans::from( Span::styled( "\nPress to return", Style::default().fg(app.user_config.theme.inactive), ), ) ]; let playing_paragraph = Paragraph::new(playing_text) .wrap(Wrap { trim: true }) .style(Style::default().fg(app.user_config.theme.text)) .block( Block::default() .borders(Borders::ALL) .title(Span::styled( "Error", Style::default().fg(app.user_config.theme.error_border), )) .border_style(Style::default().fg(app.user_config.theme.error_border)), ); f.render_widget(playing_paragraph, chunks[0]); } fn draw_home(f: &mut Frame, app: &App, layout_chunk: Rect) where B: Backend, { let chunks = Layout::default() .direction(Direction::Vertical) .constraints([Constraint::Length(7), Constraint::Length(93)].as_ref()) .margin(2) .split(layout_chunk); let current_route = app.get_current_route(); let highlight_state = ( current_route.active_block == ActiveBlock::Home, current_route.hovered_block == ActiveBlock::Home, ); let welcome = Block::default() .title(Span::styled( "Welcome!", get_color(highlight_state, app.user_config.theme), )) .borders(Borders::ALL) .border_style(get_color(highlight_state, app.user_config.theme)); f.render_widget(welcome, layout_chunk); let changelog = include_str!("../../CHANGELOG.md").to_string(); // If debug mode show the "Unreleased" header. Otherwise it is a release so there should be no // unreleased features let clean_changelog = if cfg!(debug_assertions) { changelog } else { changelog.replace("\n## [Unreleased]\n", "") }; // Banner text with correct styling let mut top_text = Text::from(BANNER); top_text.patch_style(Style::default().fg(app.user_config.theme.banner)); let bottom_text_raw = format!( "{}{}", "\nPlease report any bugs or missing features to https://github.com/Rigellute/spotify-tui\n\n", clean_changelog ); let bottom_text = Text::from(bottom_text_raw.as_str()); // Contains the banner let top_text = Paragraph::new(top_text) .style(Style::default().fg(app.user_config.theme.text)) .block(Block::default()); f.render_widget(top_text, chunks[0]); // CHANGELOG let bottom_text = Paragraph::new(bottom_text) .style(Style::default().fg(app.user_config.theme.text)) .block(Block::default()) .wrap(Wrap { trim: false }) .scroll((app.home_scroll, 0)); f.render_widget(bottom_text, chunks[1]); } fn draw_artist_albums(f: &mut Frame, app: &App, layout_chunk: Rect) where B: Backend, { let chunks = Layout::default() .direction(Direction::Horizontal) .constraints( [ Constraint::Percentage(33), Constraint::Percentage(33), Constraint::Percentage(33), ] .as_ref(), ) .split(layout_chunk); if let Some(artist) = &app.artist { let top_tracks = artist .top_tracks .iter() .map(|top_track| { let mut name = String::new(); if let Some(context) = &app.current_playback_context { let track_id = match &context.item { Some(PlayingItem::Track(track)) => track.id.to_owned(), Some(PlayingItem::Episode(episode)) => Some(episode.id.to_owned()), _ => None, }; if track_id == top_track.id { name.push_str("▶ "); } }; name.push_str(&top_track.name); name }) .collect::>(); draw_selectable_list( f, app, chunks[0], &format!("{} - Top Tracks", &artist.artist_name), &top_tracks, get_artist_highlight_state(app, ArtistBlock::TopTracks), Some(artist.selected_top_track_index), ); let albums = &artist .albums .items .iter() .map(|item| { let mut album_artist = String::new(); if let Some(album_id) = &item.id { if app.saved_album_ids_set.contains(&album_id.to_owned()) { album_artist.push_str(&app.user_config.padded_liked_icon()); } } album_artist.push_str(&format!( "{} - {} ({})", item.name.to_owned(), create_artist_string(&item.artists), item.album_type.as_deref().unwrap_or("unknown") )); album_artist }) .collect::>(); draw_selectable_list( f, app, chunks[1], "Albums", albums, get_artist_highlight_state(app, ArtistBlock::Albums), Some(artist.selected_album_index), ); let related_artists = artist .related_artists .iter() .map(|item| { let mut artist = String::new(); if app.followed_artist_ids_set.contains(&item.id.to_owned()) { artist.push_str(&app.user_config.padded_liked_icon()); } artist.push_str(&item.name.to_owned()); artist }) .collect::>(); draw_selectable_list( f, app, chunks[2], "Related artists", &related_artists, get_artist_highlight_state(app, ArtistBlock::RelatedArtists), Some(artist.selected_related_artist_index), ); }; } pub fn draw_device_list(f: &mut Frame, app: &App) where B: Backend, { let chunks = Layout::default() .direction(Direction::Vertical) .constraints([Constraint::Percentage(20), Constraint::Percentage(80)].as_ref()) .margin(5) .split(f.size()); let device_instructions: Vec = vec![ "To play tracks, please select a device. ", "Use `j/k` or up/down arrow keys to move up and down and to select. ", "Your choice here will be cached so you can jump straight back in when you next open `spotify-tui`. ", "You can change the playback device at any time by pressing `d`.", ].into_iter().map(|instruction| Spans::from(Span::raw(instruction))).collect(); let instructions = Paragraph::new(device_instructions) .style(Style::default().fg(app.user_config.theme.text)) .wrap(Wrap { trim: true }) .block( Block::default().borders(Borders::NONE).title(Span::styled( "Welcome to spotify-tui!", Style::default() .fg(app.user_config.theme.active) .add_modifier(Modifier::BOLD), )), ); f.render_widget(instructions, chunks[0]); let no_device_message = Span::raw("No devices found: Make sure a device is active"); let items = match &app.devices { Some(items) => { if items.devices.is_empty() { vec![ListItem::new(no_device_message)] } else { items .devices .iter() .map(|device| ListItem::new(Span::raw(&device.name))) .collect() } } None => vec![ListItem::new(no_device_message)], }; let mut state = ListState::default(); state.select(app.selected_device_index); let list = List::new(items) .block( Block::default() .title(Span::styled( "Devices", Style::default().fg(app.user_config.theme.active), )) .borders(Borders::ALL) .border_style(Style::default().fg(app.user_config.theme.inactive)), ) .style(Style::default().fg(app.user_config.theme.text)) .highlight_style( Style::default() .fg(app.user_config.theme.active) .add_modifier(Modifier::BOLD), ); f.render_stateful_widget(list, chunks[1], &mut state); } pub fn draw_album_list(f: &mut Frame, app: &App, layout_chunk: Rect) where B: Backend, { let header = TableHeader { id: TableId::AlbumList, items: vec![ TableHeaderItem { text: "Name", width: get_percentage_width(layout_chunk.width, 2.0 / 5.0), ..Default::default() }, TableHeaderItem { text: "Artists", width: get_percentage_width(layout_chunk.width, 2.0 / 5.0), ..Default::default() }, TableHeaderItem { text: "Release Date", width: get_percentage_width(layout_chunk.width, 1.0 / 5.0), ..Default::default() }, ], }; let current_route = app.get_current_route(); let highlight_state = ( current_route.active_block == ActiveBlock::AlbumList, current_route.hovered_block == ActiveBlock::AlbumList, ); let selected_song_index = app.album_list_index; if let Some(saved_albums) = app.library.saved_albums.get_results(None) { let items = saved_albums .items .iter() .map(|album_page| TableItem { id: album_page.album.id.to_owned(), format: vec![ format!( "{}{}", app.user_config.padded_liked_icon(), &album_page.album.name ), create_artist_string(&album_page.album.artists), album_page.album.release_date.to_owned(), ], }) .collect::>(); draw_table( f, app, layout_chunk, ("Saved Albums", &header), &items, selected_song_index, highlight_state, ) }; } pub fn draw_show_episodes(f: &mut Frame, app: &App, layout_chunk: Rect) where B: Backend, { let header = TableHeader { id: TableId::PodcastEpisodes, items: vec![ TableHeaderItem { // Column to mark an episode as fully played text: "", width: 2, ..Default::default() }, TableHeaderItem { text: "Date", width: get_percentage_width(layout_chunk.width, 0.5 / 5.0) - 2, ..Default::default() }, TableHeaderItem { text: "Name", width: get_percentage_width(layout_chunk.width, 3.5 / 5.0), id: ColumnId::Title, }, TableHeaderItem { text: "Duration", width: get_percentage_width(layout_chunk.width, 1.0 / 5.0), ..Default::default() }, ], }; let current_route = app.get_current_route(); let highlight_state = ( current_route.active_block == ActiveBlock::EpisodeTable, current_route.hovered_block == ActiveBlock::EpisodeTable, ); if let Some(episodes) = app.library.show_episodes.get_results(None) { let items = episodes .items .iter() .map(|episode| { let (played_str, time_str) = match episode.resume_point { Some(ResumePoint { fully_played, resume_position_ms, }) => ( if fully_played { " ✔".to_owned() } else { "".to_owned() }, format!( "{} / {}", millis_to_minutes(u128::from(resume_position_ms)), millis_to_minutes(u128::from(episode.duration_ms)) ), ), None => ( "".to_owned(), millis_to_minutes(u128::from(episode.duration_ms)), ), }; TableItem { id: episode.id.to_owned(), format: vec![ played_str, episode.release_date.to_owned(), episode.name.to_owned(), time_str, ], } }) .collect::>(); let title = match &app.episode_table_context { EpisodeTableContext::Simplified => match &app.selected_show_simplified { Some(selected_show) => { format!( "{} by {}", selected_show.show.name.to_owned(), selected_show.show.publisher ) } None => "Episodes".to_owned(), }, EpisodeTableContext::Full => match &app.selected_show_full { Some(selected_show) => { format!( "{} by {}", selected_show.show.name.to_owned(), selected_show.show.publisher ) } None => "Episodes".to_owned(), }, }; draw_table( f, app, layout_chunk, (&title, &header), &items, app.episode_list_index, highlight_state, ); }; } pub fn draw_made_for_you(f: &mut Frame, app: &App, layout_chunk: Rect) where B: Backend, { let header = TableHeader { id: TableId::MadeForYou, items: vec![TableHeaderItem { text: "Name", width: get_percentage_width(layout_chunk.width, 2.0 / 5.0), ..Default::default() }], }; if let Some(playlists) = &app.library.made_for_you_playlists.get_results(None) { let items = playlists .items .iter() .map(|playlist| TableItem { id: playlist.id.to_owned(), format: vec![playlist.name.to_owned()], }) .collect::>(); let current_route = app.get_current_route(); let highlight_state = ( current_route.active_block == ActiveBlock::MadeForYou, current_route.hovered_block == ActiveBlock::MadeForYou, ); draw_table( f, app, layout_chunk, ("Made For You", &header), &items, app.made_for_you_index, highlight_state, ); } } pub fn draw_recently_played_table(f: &mut Frame, app: &App, layout_chunk: Rect) where B: Backend, { let header = TableHeader { id: TableId::RecentlyPlayed, items: vec![ TableHeaderItem { id: ColumnId::Liked, text: "", width: 2, }, TableHeaderItem { id: ColumnId::Title, text: "Title", // We need to subtract the fixed value of the previous column width: get_percentage_width(layout_chunk.width, 2.0 / 5.0) - 2, }, TableHeaderItem { text: "Artist", width: get_percentage_width(layout_chunk.width, 2.0 / 5.0), ..Default::default() }, TableHeaderItem { text: "Length", width: get_percentage_width(layout_chunk.width, 1.0 / 5.0), ..Default::default() }, ], }; if let Some(recently_played) = &app.recently_played.result { let current_route = app.get_current_route(); let highlight_state = ( current_route.active_block == ActiveBlock::RecentlyPlayed, current_route.hovered_block == ActiveBlock::RecentlyPlayed, ); let selected_song_index = app.recently_played.index; let items = recently_played .items .iter() .map(|item| TableItem { id: item.track.id.clone().unwrap_or_else(|| "".to_string()), format: vec![ "".to_string(), item.track.name.to_owned(), create_artist_string(&item.track.artists), millis_to_minutes(u128::from(item.track.duration_ms)), ], }) .collect::>(); draw_table( f, app, layout_chunk, ("Recently Played Tracks", &header), &items, selected_song_index, highlight_state, ) }; } fn draw_selectable_list( f: &mut Frame, app: &App, layout_chunk: Rect, title: &str, items: &[S], highlight_state: (bool, bool), selected_index: Option, ) where B: Backend, S: std::convert::AsRef, { let mut state = ListState::default(); state.select(selected_index); let lst_items: Vec = items .iter() .map(|i| ListItem::new(Span::raw(i.as_ref()))) .collect(); //TODO let list = List::new(lst_items) .block( Block::default() .title(Span::styled( title, get_color(highlight_state, app.user_config.theme), )) .borders(Borders::ALL) .border_style(get_color(highlight_state, app.user_config.theme)), ) .style(Style::default().fg(app.user_config.theme.text)) .highlight_style( get_color(highlight_state, app.user_config.theme).add_modifier(Modifier::BOLD), ); f.render_stateful_widget(list, layout_chunk, &mut state); } fn draw_dialog(f: &mut Frame, app: &App) where B: Backend, { if let ActiveBlock::Dialog(_) = app.get_current_route().active_block { if let Some(playlist) = app.dialog.as_ref() { let bounds = f.size(); // maybe do this better let width = std::cmp::min(bounds.width - 2, 45); let height = 8; let left = (bounds.width - width) / 2; let top = bounds.height / 4; let rect = Rect::new(left, top, width, height); f.render_widget(Clear, rect); let block = Block::default() .borders(Borders::ALL) .border_style(Style::default().fg(app.user_config.theme.inactive)); f.render_widget(block, rect); let vchunks = Layout::default() .direction(Direction::Vertical) .margin(2) .constraints([Constraint::Min(3), Constraint::Length(3)].as_ref()) .split(rect); // suggestion: possibly put this as part of // app.dialog, but would have to introduce lifetime let text = vec![ Spans::from(Span::raw("Are you sure you want to delete the playlist: ")), Spans::from(Span::styled( playlist.as_str(), Style::default().add_modifier(Modifier::BOLD), )), Spans::from(Span::raw("?")), ]; let text = Paragraph::new(text) .wrap(Wrap { trim: true }) .alignment(Alignment::Center); f.render_widget(text, vchunks[0]); let hchunks = Layout::default() .direction(Direction::Horizontal) .horizontal_margin(3) .constraints([Constraint::Ratio(1, 2), Constraint::Ratio(1, 2)].as_ref()) .split(vchunks[1]); let ok_text = Span::raw("Ok"); let ok = Paragraph::new(ok_text) .style(Style::default().fg(if app.confirm { app.user_config.theme.hovered } else { app.user_config.theme.inactive })) .alignment(Alignment::Center); f.render_widget(ok, hchunks[0]); let cancel_text = Span::raw("Cancel"); let cancel = Paragraph::new(cancel_text) .style(Style::default().fg(if app.confirm { app.user_config.theme.inactive } else { app.user_config.theme.hovered })) .alignment(Alignment::Center); f.render_widget(cancel, hchunks[1]); } } } fn draw_table( f: &mut Frame, app: &App, layout_chunk: Rect, table_layout: (&str, &TableHeader), // (title, header colums) items: &[TableItem], // The nested vector must have the same length as the `header_columns` selected_index: usize, highlight_state: (bool, bool), ) where B: Backend, { let selected_style = get_color(highlight_state, app.user_config.theme).add_modifier(Modifier::BOLD); let track_playing_index = app.current_playback_context.to_owned().and_then(|ctx| { ctx.item.and_then(|item| match item { PlayingItem::Track(track) => items .iter() .position(|item| track.id.to_owned().map(|id| id == item.id).unwrap_or(false)), PlayingItem::Episode(episode) => items.iter().position(|item| episode.id == item.id), }) }); let (title, header) = table_layout; // Make sure that the selected item is visible on the page. Need to add some rows of padding // to chunk height for header and header space to get a true table height let padding = 5; let offset = layout_chunk .height .checked_sub(padding) .and_then(|height| selected_index.checked_sub(height as usize)) .unwrap_or(0); let rows = items.iter().skip(offset).enumerate().map(|(i, item)| { let mut formatted_row = item.format.clone(); let mut style = Style::default().fg(app.user_config.theme.text); // default styling // if table displays songs match header.id { TableId::Song | TableId::RecentlyPlayed | TableId::Album => { // First check if the song should be highlighted because it is currently playing if let Some(title_idx) = header.get_index(ColumnId::Title) { if let Some(track_playing_offset_index) = track_playing_index.and_then(|idx| idx.checked_sub(offset)) { if i == track_playing_offset_index { formatted_row[title_idx] = format!("▶ {}", &formatted_row[title_idx]); style = Style::default() .fg(app.user_config.theme.active) .add_modifier(Modifier::BOLD); } } } // Show this the liked icon if the song is liked if let Some(liked_idx) = header.get_index(ColumnId::Liked) { if app.liked_song_ids_set.contains(item.id.as_str()) { formatted_row[liked_idx] = app.user_config.padded_liked_icon(); } } } TableId::PodcastEpisodes => { if let Some(name_idx) = header.get_index(ColumnId::Title) { if let Some(track_playing_offset_index) = track_playing_index.and_then(|idx| idx.checked_sub(offset)) { if i == track_playing_offset_index { formatted_row[name_idx] = format!("▶ {}", &formatted_row[name_idx]); style = Style::default() .fg(app.user_config.theme.active) .add_modifier(Modifier::BOLD); } } } } _ => {} } // Next check if the item is under selection. if Some(i) == selected_index.checked_sub(offset) { style = selected_style; } // Return row styled data Row::new(formatted_row).style(style) }); let widths = header .items .iter() .map(|h| Constraint::Length(h.width)) .collect::>(); let table = Table::new(rows) .header( Row::new(header.items.iter().map(|h| h.text)) .style(Style::default().fg(app.user_config.theme.header)), ) .block( Block::default() .borders(Borders::ALL) .style(Style::default().fg(app.user_config.theme.text)) .title(Span::styled( title, get_color(highlight_state, app.user_config.theme), )) .border_style(get_color(highlight_state, app.user_config.theme)), ) .style(Style::default().fg(app.user_config.theme.text)) .widths(&widths); f.render_widget(table, layout_chunk); } ================================================ FILE: src/ui/util.rs ================================================ use super::super::app::{ActiveBlock, App, ArtistBlock, SearchResultBlock}; use crate::user_config::Theme; use rspotify::model::artist::SimplifiedArtist; use tui::style::Style; pub const BASIC_VIEW_HEIGHT: u16 = 6; pub const SMALL_TERMINAL_WIDTH: u16 = 150; pub const SMALL_TERMINAL_HEIGHT: u16 = 45; pub fn get_search_results_highlight_state( app: &App, block_to_match: SearchResultBlock, ) -> (bool, bool) { let current_route = app.get_current_route(); ( app.search_results.selected_block == block_to_match, current_route.hovered_block == ActiveBlock::SearchResultBlock && app.search_results.hovered_block == block_to_match, ) } pub fn get_artist_highlight_state(app: &App, block_to_match: ArtistBlock) -> (bool, bool) { let current_route = app.get_current_route(); if let Some(artist) = &app.artist { let is_hovered = artist.artist_selected_block == block_to_match; let is_selected = current_route.hovered_block == ActiveBlock::ArtistBlock && artist.artist_hovered_block == block_to_match; (is_hovered, is_selected) } else { (false, false) } } pub fn get_color((is_active, is_hovered): (bool, bool), theme: Theme) -> Style { match (is_active, is_hovered) { (true, _) => Style::default().fg(theme.selected), (false, true) => Style::default().fg(theme.hovered), _ => Style::default().fg(theme.inactive), } } pub fn create_artist_string(artists: &[SimplifiedArtist]) -> String { artists .iter() .map(|artist| artist.name.to_string()) .collect::>() .join(", ") } pub fn millis_to_minutes(millis: u128) -> String { let minutes = millis / 60000; let seconds = (millis % 60000) / 1000; let seconds_display = if seconds < 10 { format!("0{}", seconds) } else { format!("{}", seconds) }; if seconds == 60 { format!("{}:00", minutes + 1) } else { format!("{}:{}", minutes, seconds_display) } } pub fn display_track_progress(progress: u128, track_duration: u32) -> String { let duration = millis_to_minutes(u128::from(track_duration)); let progress_display = millis_to_minutes(progress); let remaining = millis_to_minutes(u128::from(track_duration).saturating_sub(progress)); format!("{}/{} (-{})", progress_display, duration, remaining,) } // `percentage` param needs to be between 0 and 1 pub fn get_percentage_width(width: u16, percentage: f32) -> u16 { let padding = 3; let width = width - padding; (f32::from(width) * percentage) as u16 } // Ensure track progress percentage is between 0 and 100 inclusive pub fn get_track_progress_percentage(song_progress_ms: u128, track_duration_ms: u32) -> u16 { let min_perc = 0_f64; let track_progress = std::cmp::min(song_progress_ms, track_duration_ms.into()); let track_perc = (track_progress as f64 / f64::from(track_duration_ms)) * 100_f64; min_perc.max(track_perc) as u16 } // Make better use of space on small terminals pub fn get_main_layout_margin(app: &App) -> u16 { if app.size.height > SMALL_TERMINAL_HEIGHT { 1 } else { 0 } } #[cfg(test)] mod tests { use super::*; #[test] fn millis_to_minutes_test() { assert_eq!(millis_to_minutes(0), "0:00"); assert_eq!(millis_to_minutes(1000), "0:01"); assert_eq!(millis_to_minutes(1500), "0:01"); assert_eq!(millis_to_minutes(1900), "0:01"); assert_eq!(millis_to_minutes(60 * 1000), "1:00"); assert_eq!(millis_to_minutes(60 * 1500), "1:30"); } #[test] fn display_track_progress_test() { assert_eq!( display_track_progress(0, 2 * 60 * 1000), "0:00/2:00 (-2:00)" ); assert_eq!( display_track_progress(60 * 1000, 2 * 60 * 1000), "1:00/2:00 (-1:00)" ); } #[test] fn get_track_progress_percentage_test() { let track_length = 60 * 1000; assert_eq!(get_track_progress_percentage(0, track_length), 0); assert_eq!( get_track_progress_percentage((60 * 1000) / 2, track_length), 50 ); // If progress is somehow higher than total duration, 100 should be max assert_eq!( get_track_progress_percentage(60 * 1000 * 2, track_length), 100 ); } } ================================================ FILE: src/user_config.rs ================================================ use crate::event::Key; use anyhow::{anyhow, Result}; use serde::{Deserialize, Serialize}; use std::{ fs, path::{Path, PathBuf}, }; use tui::style::Color; const FILE_NAME: &str = "config.yml"; const CONFIG_DIR: &str = ".config"; const APP_CONFIG_DIR: &str = "spotify-tui"; #[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] pub struct UserTheme { pub active: Option, pub banner: Option, pub error_border: Option, pub error_text: Option, pub hint: Option, pub hovered: Option, pub inactive: Option, pub playbar_background: Option, pub playbar_progress: Option, pub playbar_progress_text: Option, pub playbar_text: Option, pub selected: Option, pub text: Option, pub header: Option, } #[derive(Copy, Clone, Debug)] pub struct Theme { pub analysis_bar: Color, pub analysis_bar_text: Color, pub active: Color, pub banner: Color, pub error_border: Color, pub error_text: Color, pub hint: Color, pub hovered: Color, pub inactive: Color, pub playbar_background: Color, pub playbar_progress: Color, pub playbar_progress_text: Color, pub playbar_text: Color, pub selected: Color, pub text: Color, pub header: Color, } impl Default for Theme { fn default() -> Self { Theme { analysis_bar: Color::LightCyan, analysis_bar_text: Color::Reset, active: Color::Cyan, banner: Color::LightCyan, error_border: Color::Red, error_text: Color::LightRed, hint: Color::Yellow, hovered: Color::Magenta, inactive: Color::Gray, playbar_background: Color::Black, playbar_progress: Color::LightCyan, playbar_progress_text: Color::LightCyan, playbar_text: Color::Reset, selected: Color::LightCyan, text: Color::Reset, header: Color::Reset, } } } fn parse_key(key: String) -> Result { fn get_single_char(string: &str) -> char { match string.chars().next() { Some(c) => c, None => panic!(), } } match key.len() { 1 => Ok(Key::Char(get_single_char(key.as_str()))), _ => { let sections: Vec<&str> = key.split('-').collect(); if sections.len() > 2 { return Err(anyhow!( "Shortcut can only have 2 keys, \"{}\" has {}", key, sections.len() )); } match sections[0].to_lowercase().as_str() { "ctrl" => Ok(Key::Ctrl(get_single_char(sections[1]))), "alt" => Ok(Key::Alt(get_single_char(sections[1]))), "left" => Ok(Key::Left), "right" => Ok(Key::Right), "up" => Ok(Key::Up), "down" => Ok(Key::Down), "backspace" | "delete" => Ok(Key::Backspace), "del" => Ok(Key::Delete), "esc" | "escape" => Ok(Key::Esc), "pageup" => Ok(Key::PageUp), "pagedown" => Ok(Key::PageDown), "space" => Ok(Key::Char(' ')), _ => Err(anyhow!("The key \"{}\" is unknown.", sections[0])), } } } } fn check_reserved_keys(key: Key) -> Result<()> { let reserved = [ Key::Char('h'), Key::Char('j'), Key::Char('k'), Key::Char('l'), Key::Char('H'), Key::Char('M'), Key::Char('L'), Key::Up, Key::Down, Key::Left, Key::Right, Key::Backspace, Key::Enter, ]; for item in reserved.iter() { if key == *item { // TODO: Add pretty print for key return Err(anyhow!( "The key {:?} is reserved and cannot be remapped", key )); } } Ok(()) } #[derive(Clone)] pub struct UserConfigPaths { pub config_file_path: PathBuf, } #[derive(Default, Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct KeyBindingsString { back: Option, next_page: Option, previous_page: Option, jump_to_start: Option, jump_to_end: Option, jump_to_album: Option, jump_to_artist_album: Option, jump_to_context: Option, manage_devices: Option, decrease_volume: Option, increase_volume: Option, toggle_playback: Option, seek_backwards: Option, seek_forwards: Option, next_track: Option, previous_track: Option, help: Option, shuffle: Option, repeat: Option, search: Option, submit: Option, copy_song_url: Option, copy_album_url: Option, audio_analysis: Option, basic_view: Option, add_item_to_queue: Option, } #[derive(Clone)] pub struct KeyBindings { pub back: Key, pub next_page: Key, pub previous_page: Key, pub jump_to_start: Key, pub jump_to_end: Key, pub jump_to_album: Key, pub jump_to_artist_album: Key, pub jump_to_context: Key, pub manage_devices: Key, pub decrease_volume: Key, pub increase_volume: Key, pub toggle_playback: Key, pub seek_backwards: Key, pub seek_forwards: Key, pub next_track: Key, pub previous_track: Key, pub help: Key, pub shuffle: Key, pub repeat: Key, pub search: Key, pub submit: Key, pub copy_song_url: Key, pub copy_album_url: Key, pub audio_analysis: Key, pub basic_view: Key, pub add_item_to_queue: Key, } #[derive(Default, Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct BehaviorConfigString { pub seek_milliseconds: Option, pub volume_increment: Option, pub tick_rate_milliseconds: Option, pub enable_text_emphasis: Option, pub show_loading_indicator: Option, pub enforce_wide_search_bar: Option, pub liked_icon: Option, pub shuffle_icon: Option, pub repeat_track_icon: Option, pub repeat_context_icon: Option, pub playing_icon: Option, pub paused_icon: Option, pub set_window_title: Option, } #[derive(Clone)] pub struct BehaviorConfig { pub seek_milliseconds: u32, pub volume_increment: u8, pub tick_rate_milliseconds: u64, pub enable_text_emphasis: bool, pub show_loading_indicator: bool, pub enforce_wide_search_bar: bool, pub liked_icon: String, pub shuffle_icon: String, pub repeat_track_icon: String, pub repeat_context_icon: String, pub playing_icon: String, pub paused_icon: String, pub set_window_title: bool, } #[derive(Default, Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct UserConfigString { keybindings: Option, behavior: Option, theme: Option, } #[derive(Clone)] pub struct UserConfig { pub keys: KeyBindings, pub theme: Theme, pub behavior: BehaviorConfig, pub path_to_config: Option, } impl UserConfig { pub fn new() -> UserConfig { UserConfig { theme: Default::default(), keys: KeyBindings { back: Key::Char('q'), next_page: Key::Ctrl('d'), previous_page: Key::Ctrl('u'), jump_to_start: Key::Ctrl('a'), jump_to_end: Key::Ctrl('e'), jump_to_album: Key::Char('a'), jump_to_artist_album: Key::Char('A'), jump_to_context: Key::Char('o'), manage_devices: Key::Char('d'), decrease_volume: Key::Char('-'), increase_volume: Key::Char('+'), toggle_playback: Key::Char(' '), seek_backwards: Key::Char('<'), seek_forwards: Key::Char('>'), next_track: Key::Char('n'), previous_track: Key::Char('p'), help: Key::Char('?'), shuffle: Key::Ctrl('s'), repeat: Key::Ctrl('r'), search: Key::Char('/'), submit: Key::Enter, copy_song_url: Key::Char('c'), copy_album_url: Key::Char('C'), audio_analysis: Key::Char('v'), basic_view: Key::Char('B'), add_item_to_queue: Key::Char('z'), }, behavior: BehaviorConfig { seek_milliseconds: 5 * 1000, volume_increment: 10, tick_rate_milliseconds: 250, enable_text_emphasis: true, show_loading_indicator: true, enforce_wide_search_bar: false, liked_icon: "♥".to_string(), shuffle_icon: "🔀".to_string(), repeat_track_icon: "🔂".to_string(), repeat_context_icon: "🔁".to_string(), playing_icon: "▶".to_string(), paused_icon: "⏸".to_string(), set_window_title: true, }, path_to_config: None, } } pub fn get_or_build_paths(&mut self) -> Result<()> { match dirs::home_dir() { Some(home) => { let path = Path::new(&home); let home_config_dir = path.join(CONFIG_DIR); let app_config_dir = home_config_dir.join(APP_CONFIG_DIR); if !home_config_dir.exists() { fs::create_dir(&home_config_dir)?; } if !app_config_dir.exists() { fs::create_dir(&app_config_dir)?; } let config_file_path = &app_config_dir.join(FILE_NAME); let paths = UserConfigPaths { config_file_path: config_file_path.to_path_buf(), }; self.path_to_config = Some(paths); Ok(()) } None => Err(anyhow!("No $HOME directory found for client config")), } } pub fn load_keybindings(&mut self, keybindings: KeyBindingsString) -> Result<()> { macro_rules! to_keys { ($name: ident) => { if let Some(key_string) = keybindings.$name { self.keys.$name = parse_key(key_string)?; check_reserved_keys(self.keys.$name)?; } }; } to_keys!(back); to_keys!(next_page); to_keys!(previous_page); to_keys!(jump_to_start); to_keys!(jump_to_end); to_keys!(jump_to_album); to_keys!(jump_to_artist_album); to_keys!(jump_to_context); to_keys!(manage_devices); to_keys!(decrease_volume); to_keys!(increase_volume); to_keys!(toggle_playback); to_keys!(seek_backwards); to_keys!(seek_forwards); to_keys!(next_track); to_keys!(previous_track); to_keys!(help); to_keys!(shuffle); to_keys!(repeat); to_keys!(search); to_keys!(submit); to_keys!(copy_song_url); to_keys!(copy_album_url); to_keys!(audio_analysis); to_keys!(basic_view); to_keys!(add_item_to_queue); Ok(()) } pub fn load_theme(&mut self, theme: UserTheme) -> Result<()> { macro_rules! to_theme_item { ($name: ident) => { if let Some(theme_item) = theme.$name { self.theme.$name = parse_theme_item(&theme_item)?; } }; } to_theme_item!(active); to_theme_item!(banner); to_theme_item!(error_border); to_theme_item!(error_text); to_theme_item!(hint); to_theme_item!(hovered); to_theme_item!(inactive); to_theme_item!(playbar_background); to_theme_item!(playbar_progress); to_theme_item!(playbar_progress_text); to_theme_item!(playbar_text); to_theme_item!(selected); to_theme_item!(text); to_theme_item!(header); Ok(()) } pub fn load_behaviorconfig(&mut self, behavior_config: BehaviorConfigString) -> Result<()> { if let Some(behavior_string) = behavior_config.seek_milliseconds { self.behavior.seek_milliseconds = behavior_string; } if let Some(behavior_string) = behavior_config.volume_increment { if behavior_string > 100 { return Err(anyhow!( "Volume increment must be between 0 and 100, is {}", behavior_string, )); } self.behavior.volume_increment = behavior_string; } if let Some(tick_rate) = behavior_config.tick_rate_milliseconds { if tick_rate >= 1000 { return Err(anyhow!("Tick rate must be below 1000")); } else { self.behavior.tick_rate_milliseconds = tick_rate; } } if let Some(text_emphasis) = behavior_config.enable_text_emphasis { self.behavior.enable_text_emphasis = text_emphasis; } if let Some(loading_indicator) = behavior_config.show_loading_indicator { self.behavior.show_loading_indicator = loading_indicator; } if let Some(wide_search_bar) = behavior_config.enforce_wide_search_bar { self.behavior.enforce_wide_search_bar = wide_search_bar; } if let Some(liked_icon) = behavior_config.liked_icon { self.behavior.liked_icon = liked_icon; } if let Some(paused_icon) = behavior_config.paused_icon { self.behavior.paused_icon = paused_icon; } if let Some(playing_icon) = behavior_config.playing_icon { self.behavior.playing_icon = playing_icon; } if let Some(shuffle_icon) = behavior_config.shuffle_icon { self.behavior.shuffle_icon = shuffle_icon; } if let Some(repeat_track_icon) = behavior_config.repeat_track_icon { self.behavior.repeat_track_icon = repeat_track_icon; } if let Some(repeat_context_icon) = behavior_config.repeat_context_icon { self.behavior.repeat_context_icon = repeat_context_icon; } if let Some(set_window_title) = behavior_config.set_window_title { self.behavior.set_window_title = set_window_title; } Ok(()) } pub fn load_config(&mut self) -> Result<()> { let paths = match &self.path_to_config { Some(path) => path, None => { self.get_or_build_paths()?; self.path_to_config.as_ref().unwrap() } }; if paths.config_file_path.exists() { let config_string = fs::read_to_string(&paths.config_file_path)?; // serde fails if file is empty if config_string.trim().is_empty() { return Ok(()); } let config_yml: UserConfigString = serde_yaml::from_str(&config_string)?; if let Some(keybindings) = config_yml.keybindings.clone() { self.load_keybindings(keybindings)?; } if let Some(behavior) = config_yml.behavior { self.load_behaviorconfig(behavior)?; } if let Some(theme) = config_yml.theme { self.load_theme(theme)?; } Ok(()) } else { Ok(()) } } pub fn padded_liked_icon(&self) -> String { format!("{} ", &self.behavior.liked_icon) } } fn parse_theme_item(theme_item: &str) -> Result { let color = match theme_item { "Reset" => Color::Reset, "Black" => Color::Black, "Red" => Color::Red, "Green" => Color::Green, "Yellow" => Color::Yellow, "Blue" => Color::Blue, "Magenta" => Color::Magenta, "Cyan" => Color::Cyan, "Gray" => Color::Gray, "DarkGray" => Color::DarkGray, "LightRed" => Color::LightRed, "LightGreen" => Color::LightGreen, "LightYellow" => Color::LightYellow, "LightBlue" => Color::LightBlue, "LightMagenta" => Color::LightMagenta, "LightCyan" => Color::LightCyan, "White" => Color::White, _ => { let colors = theme_item.split(',').collect::>(); if let (Some(r), Some(g), Some(b)) = (colors.get(0), colors.get(1), colors.get(2)) { Color::Rgb( r.trim().parse::()?, g.trim().parse::()?, b.trim().parse::()?, ) } else { println!("Unexpected color {}", theme_item); Color::Black } } }; Ok(color) } #[cfg(test)] mod tests { #[test] fn test_parse_key() { use super::parse_key; use crate::event::Key; assert_eq!(parse_key(String::from("j")).unwrap(), Key::Char('j')); assert_eq!(parse_key(String::from("J")).unwrap(), Key::Char('J')); assert_eq!(parse_key(String::from("ctrl-j")).unwrap(), Key::Ctrl('j')); assert_eq!(parse_key(String::from("ctrl-J")).unwrap(), Key::Ctrl('J')); assert_eq!(parse_key(String::from("-")).unwrap(), Key::Char('-')); assert_eq!(parse_key(String::from("esc")).unwrap(), Key::Esc); assert_eq!(parse_key(String::from("del")).unwrap(), Key::Delete); } #[test] fn parse_theme_item_test() { use super::parse_theme_item; use tui::style::Color; assert_eq!(parse_theme_item("Reset").unwrap(), Color::Reset); assert_eq!(parse_theme_item("Black").unwrap(), Color::Black); assert_eq!(parse_theme_item("Red").unwrap(), Color::Red); assert_eq!(parse_theme_item("Green").unwrap(), Color::Green); assert_eq!(parse_theme_item("Yellow").unwrap(), Color::Yellow); assert_eq!(parse_theme_item("Blue").unwrap(), Color::Blue); assert_eq!(parse_theme_item("Magenta").unwrap(), Color::Magenta); assert_eq!(parse_theme_item("Cyan").unwrap(), Color::Cyan); assert_eq!(parse_theme_item("Gray").unwrap(), Color::Gray); assert_eq!(parse_theme_item("DarkGray").unwrap(), Color::DarkGray); assert_eq!(parse_theme_item("LightRed").unwrap(), Color::LightRed); assert_eq!(parse_theme_item("LightGreen").unwrap(), Color::LightGreen); assert_eq!(parse_theme_item("LightYellow").unwrap(), Color::LightYellow); assert_eq!(parse_theme_item("LightBlue").unwrap(), Color::LightBlue); assert_eq!( parse_theme_item("LightMagenta").unwrap(), Color::LightMagenta ); assert_eq!(parse_theme_item("LightCyan").unwrap(), Color::LightCyan); assert_eq!(parse_theme_item("White").unwrap(), Color::White); assert_eq!( parse_theme_item("23, 43, 45").unwrap(), Color::Rgb(23, 43, 45) ); } #[test] fn test_reserved_key() { use super::check_reserved_keys; use crate::event::Key; assert!( check_reserved_keys(Key::Enter).is_err(), "Enter key should be reserved" ); } } ================================================ FILE: src/util.rs ================================================ use std::{io::stdin, sync::mpsc, thread, time::Duration}; use termion::{event::Key, input::TermRead}; pub enum Event { Input(I), Tick, } /// A small event handler that wrap termion input and tick events. Each event /// type is handled in its own thread and returned to a common `Receiver` pub struct Events { rx: mpsc::Receiver>, } #[derive(Debug, Clone, Copy)] pub struct Config { pub exit_key: Key, pub tick_rate: Duration, } impl Default for Config { fn default() -> Config { Config { exit_key: Key::Ctrl('c'), tick_rate: Duration::from_millis(250), } } } impl Events { pub fn new() -> Events { Events::with_config(Config::default()) } pub fn with_config(config: Config) -> Events { let (tx, rx) = mpsc::channel(); let _input_handle = { let tx = tx.clone(); thread::spawn(move || { let stdin_result = stdin(); for evt in stdin_result.keys() { if let Ok(key) = evt { if tx.send(Event::Input(key)).is_err() { return; } if key == config.exit_key { return; } } } }) }; let _tick_handle = { let tx = tx; thread::spawn(move || { let tx = tx.clone(); loop { tx.send(Event::Tick).unwrap(); thread::sleep(config.tick_rate); } }) }; Events { rx } } pub fn next(&self) -> Result, mpsc::RecvError> { self.rx.recv() } }