├── .github ├── FUNDING.yml ├── dependabot.yml ├── ISSUE_TEMPLATE │ ├── feature_request.md │ └── bug_report.md └── workflows │ ├── ci.yml │ └── cd.yml ├── screenshots └── screenshot.png ├── .gitmodules ├── .gitignore ├── src ├── ui │ ├── mod.rs │ ├── modal.rs │ ├── show.rs │ ├── playlists.rs │ ├── library.rs │ ├── album.rs │ ├── help.rs │ ├── playlist.rs │ ├── search.rs │ ├── artist.rs │ ├── pagination.rs │ ├── tabview.rs │ ├── queue.rs │ ├── statusbar.rs │ ├── contextmenu.rs │ ├── cover.rs │ └── layout.rs ├── utils.rs ├── sharing.rs ├── events.rs ├── token.rs ├── theme.rs ├── traits.rs ├── episode.rs ├── spotify_url.rs ├── serialization.rs ├── playable.rs ├── show.rs ├── artist.rs ├── authentication.rs ├── config.rs ├── album.rs ├── spotify_worker.rs ├── track.rs ├── playlist.rs ├── main.rs └── command.rs ├── PKGBUILD ├── LICENSE ├── README.md ├── Cargo.toml └── rspotify.patch /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: hrkfdn 2 | -------------------------------------------------------------------------------- /screenshots/screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mtshrmn/ncspot/master/screenshots/screenshot.png -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "rspotify"] 2 | path = rspotify 3 | url = https://github.com/ramsayleung/rspotify.git 4 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: cargo 4 | directory: "/" 5 | schedule: 6 | interval: daily 7 | time: "04:00" 8 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Generated by Cargo 2 | # will have compiled files and executables 3 | /target/ 4 | /.idea/ 5 | 6 | # These are backup files generated by rustfmt 7 | **/*.rs.bk 8 | 9 | *.log 10 | 11 | tags 12 | 13 | /.vscode/ -------------------------------------------------------------------------------- /src/ui/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod album; 2 | pub mod artist; 3 | pub mod contextmenu; 4 | pub mod help; 5 | pub mod layout; 6 | pub mod library; 7 | pub mod listview; 8 | pub mod modal; 9 | pub mod pagination; 10 | pub mod playlist; 11 | pub mod playlists; 12 | pub mod queue; 13 | pub mod search; 14 | pub mod search_results; 15 | pub mod show; 16 | pub mod statusbar; 17 | pub mod tabview; 18 | 19 | #[cfg(feature = "cover")] 20 | pub mod cover; 21 | -------------------------------------------------------------------------------- /src/utils.rs: -------------------------------------------------------------------------------- 1 | /// Returns a human readable String of a Duration 2 | /// 3 | /// Example: `3h 12m 53s` 4 | pub fn format_duration(d: &std::time::Duration) -> String { 5 | let mut s = String::new(); 6 | let mut append_unit = |value, unit| { 7 | if value > 0 { 8 | s.push_str(&format!("{}{}", value, unit)); 9 | } 10 | }; 11 | 12 | let seconds = d.as_secs() % 60; 13 | let minutes = (d.as_secs() / 60) % 60; 14 | let hours = (d.as_secs() / 60) / 60; 15 | 16 | append_unit(hours, "h "); 17 | append_unit(minutes, "m "); 18 | append_unit(seconds, "s "); 19 | 20 | s.trim_end().to_string() 21 | } 22 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: enhancement 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /src/ui/modal.rs: -------------------------------------------------------------------------------- 1 | use cursive::event::{Event, EventResult}; 2 | use cursive::view::{View, ViewWrapper}; 3 | 4 | pub struct Modal { 5 | block_events: bool, 6 | inner: T, 7 | } 8 | 9 | impl Modal { 10 | pub fn new(inner: T) -> Self { 11 | Modal { 12 | block_events: true, 13 | inner, 14 | } 15 | } 16 | pub fn new_ext(inner: T) -> Self { 17 | Modal { 18 | block_events: false, 19 | inner, 20 | } 21 | } 22 | } 23 | 24 | impl ViewWrapper for Modal { 25 | wrap_impl!(self.inner: T); 26 | fn wrap_on_event(&mut self, ch: Event) -> EventResult { 27 | match self.inner.on_event(ch) { 28 | EventResult::Consumed(cb) => EventResult::Consumed(cb), 29 | _ => match self.block_events { 30 | true => EventResult::Consumed(None), 31 | false => EventResult::Ignored, 32 | }, 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /PKGBUILD: -------------------------------------------------------------------------------- 1 | pkgname=ncspot 2 | pkgver=0.8.1 3 | pkgrel=1 4 | pkgdesc="Custom fork of ncspot; based around personal preference." 5 | arch=('x86_64' 'aarch64' 'armv7h') 6 | url="https://github.com/mtshrmn/ncspot" 7 | license=('BSD') 8 | depends=('ncurses' 'openssl' 'libpulse' 'libxcb' 'ncurses' 'dbus' 'libnotify') 9 | makedepends=('rust' 'cargo' 'git' 'alsa-lib' 'python' 'pkgconf') 10 | provides=("${pkgname}") 11 | conflicts=("${pkgname}") 12 | source=('ncspot'::'git+https://github.com/mtshrmn/ncspot.git') 13 | md5sums=('SKIP') 14 | 15 | prepare() { 16 | cd "${srcdir}/${pkgname}" 17 | git submodule update --init --recursive 18 | cd rspotify 19 | git apply ../rspotify.patch 20 | cargo fetch 21 | } 22 | 23 | build() { 24 | cd "${srcdir}/${pkgname}" 25 | cargo build --release --locked 26 | } 27 | 28 | check() { 29 | cd "${srcdir}/${pkgname}" 30 | cargo test --release --locked 31 | } 32 | 33 | package() { 34 | cd "${srcdir}/${pkgname}" 35 | install -Dm 755 "target/release/${pkgname}" "${pkgdir}/usr/bin/${pkgname}" 36 | install -Dm 755 "${srcdir}/${pkgname}/LICENSE" "${pkgdir}/usr/share/licenses/${pkgname}/LICENSE" 37 | } 38 | 39 | 40 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | branches: [ master ] 8 | 9 | jobs: 10 | check: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v2 14 | with: 15 | submodules: true 16 | - name: Update APT package lists 17 | run: sudo apt update 18 | - name: Install dependencies 19 | run: sudo apt install libpulse-dev libdbus-1-dev libncursesw5-dev libxcb-shape0-dev libxcb-xfixes0-dev libnotify-dev 20 | - name: Patch rspotify library 21 | run: cd rspotify && git apply ../rspotify.patch 22 | - uses: actions/cache@v2 23 | with: 24 | path: | 25 | ~/.cargo/registry 26 | ~/.cargo/git 27 | target 28 | key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} 29 | - name: cargo check 30 | run: cargo check --verbose 31 | 32 | fmt: 33 | runs-on: ubuntu-latest 34 | steps: 35 | - uses: actions/checkout@v2 36 | with: 37 | submodules: true 38 | - name: cargo fmt 39 | run: cargo fmt --all -- --check 40 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **System (please complete the following information):** 27 | - OS: [e.g. Linux, macOS] 28 | - Terminal: [e.g. GNOME Terminal, Alacritty] 29 | - Version: [e.g. 0.4.0, `master` branch] 30 | - Installed from: [e.g. AUR, brew, cargo] 31 | 32 | **Backtrace/Debug log** 33 | Instructions on how to capture debug logs: https://github.com/hrkfdn/ncspot#usage 34 | 35 | To debug crashes a backtrace is very helpful. Make sure you run a debug build of ncspot, e.g. by running the command mentioned in the link above. 36 | 37 | **Additional context** 38 | Add any other context about the problem here. 39 | -------------------------------------------------------------------------------- /src/sharing.rs: -------------------------------------------------------------------------------- 1 | #![cfg(feature = "share_clipboard")] 2 | 3 | #[cfg(feature = "share_selection")] 4 | use clipboard::x11_clipboard::{Primary, X11ClipboardContext}; 5 | #[cfg(not(feature = "share_selection"))] 6 | use clipboard::ClipboardContext; 7 | use clipboard::ClipboardProvider; 8 | 9 | #[cfg(not(feature = "share_selection"))] 10 | pub fn read_share() -> Option { 11 | ClipboardProvider::new() 12 | .and_then(|mut ctx: ClipboardContext| ctx.get_contents()) 13 | .ok() 14 | } 15 | 16 | #[cfg(feature = "share_selection")] 17 | pub fn read_share() -> Option { 18 | ClipboardProvider::new() 19 | .and_then(|mut ctx: X11ClipboardContext| ctx.get_contents()) 20 | .ok() 21 | } 22 | 23 | #[cfg(not(feature = "share_selection"))] 24 | pub fn write_share(url: String) -> Option<()> { 25 | ClipboardProvider::new() 26 | .and_then(|mut ctx: ClipboardContext| ctx.set_contents(url)) 27 | .ok() 28 | } 29 | 30 | #[cfg(feature = "share_selection")] 31 | pub fn write_share(url: String) -> Option<()> { 32 | ClipboardProvider::new() 33 | .and_then(|mut ctx: X11ClipboardContext| ctx.set_contents(url)) 34 | .ok() 35 | } 36 | -------------------------------------------------------------------------------- /src/events.rs: -------------------------------------------------------------------------------- 1 | use crossbeam_channel::{unbounded, Receiver, Sender, TryIter}; 2 | use cursive::{CbSink, Cursive}; 3 | 4 | use crate::queue::QueueEvent; 5 | use crate::spotify::PlayerEvent; 6 | 7 | pub enum Event { 8 | Player(PlayerEvent), 9 | Queue(QueueEvent), 10 | SessionDied, 11 | } 12 | 13 | pub type EventSender = Sender; 14 | 15 | #[derive(Clone)] 16 | pub struct EventManager { 17 | tx: EventSender, 18 | rx: Receiver, 19 | cursive_sink: CbSink, 20 | } 21 | 22 | impl EventManager { 23 | pub fn new(cursive_sink: CbSink) -> EventManager { 24 | let (tx, rx) = unbounded(); 25 | 26 | EventManager { 27 | tx, 28 | rx, 29 | cursive_sink, 30 | } 31 | } 32 | 33 | pub fn msg_iter(&self) -> TryIter { 34 | self.rx.try_iter() 35 | } 36 | 37 | pub fn send(&self, event: Event) { 38 | self.tx.send(event).expect("could not send event"); 39 | self.trigger(); 40 | } 41 | 42 | pub fn trigger(&self) { 43 | // send a no-op to trigger event loop processing 44 | self.cursive_sink 45 | .send(Box::new(Cursive::noop)) 46 | .expect("could not send no-op event to cursive"); 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/ui/show.rs: -------------------------------------------------------------------------------- 1 | use std::sync::Arc; 2 | 3 | use cursive::view::ViewWrapper; 4 | use cursive::Cursive; 5 | 6 | use crate::command::Command; 7 | use crate::commands::CommandResult; 8 | use crate::episode::Episode; 9 | use crate::library::Library; 10 | use crate::queue::Queue; 11 | use crate::show::Show; 12 | use crate::traits::ViewExt; 13 | use crate::ui::listview::ListView; 14 | 15 | pub struct ShowView { 16 | list: ListView, 17 | show: Show, 18 | } 19 | 20 | impl ShowView { 21 | pub fn new(queue: Arc, library: Arc, show: &Show) -> Self { 22 | let spotify = queue.get_spotify(); 23 | let show = show.clone(); 24 | 25 | let list = { 26 | let results = spotify.show_episodes(&show.id); 27 | let view = ListView::new(results.items.clone(), queue, library); 28 | results.apply_pagination(view.get_pagination()); 29 | 30 | view 31 | }; 32 | 33 | Self { list, show } 34 | } 35 | } 36 | 37 | impl ViewWrapper for ShowView { 38 | wrap_impl!(self.list: ListView); 39 | } 40 | 41 | impl ViewExt for ShowView { 42 | fn title(&self) -> String { 43 | self.show.name.clone() 44 | } 45 | 46 | fn on_command(&mut self, s: &mut Cursive, cmd: &Command) -> Result { 47 | self.list.on_command(s, cmd) 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 2-Clause License 2 | 3 | Copyright (c) 2019, Henrik Friedrichsen 4 | All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without 7 | modification, are permitted provided that the following conditions are met: 8 | 9 | * Redistributions of source code must retain the above copyright notice, this 10 | list of conditions and the following disclaimer. 11 | 12 | * Redistributions in binary form must reproduce the above copyright notice, 13 | this list of conditions and the following disclaimer in the documentation 14 | and/or other materials provided with the distribution. 15 | 16 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 17 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 18 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 19 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 20 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 21 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 22 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 23 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 24 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 25 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 26 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ncspot 2 | 3 | Custom fork of [ncspot](https://github.com/hrkfdn/ncspot) based around personal preferences. 4 | 5 | ![Screenshot](screenshots/screenshot.png) 6 | ## Features 7 | Read the upstream readme for the entire documentation 8 | 9 | * Better notifications behavior 10 | * Simmilar tracks have album name 11 | * Made for you tab 12 | * Autoplay simmilar tracks at end of queue (use `autoplay = true` in config) 13 | * Highlight fadeout after inactivity period (use `highlight_fadeout = x` in config where `x` is time in seconds) 14 | --- 15 | 16 | ##### custom commands: 17 | `:queueall` - queue all songs proceeding selection (and included). 18 | 19 | ### Installation guide 20 | ##### Arch linux 21 | `PKGBUILD` provided in repository 22 | 23 | ##### Compiling from source 24 | ```sh 25 | $ git clone https://github.com/mtshrmn/ncspot.git --recursive 26 | $ cd rspotify && git apply ../rspotify.patch && cd 27 | $ cargo build --release # use whatever features you like 28 | ``` 29 | 30 | ### Setting up cookies for "for you" tab 31 | 1. Go to open.spotify.com 32 | 2. Open developer options and find your cookies 33 | 3. Copy the 4 cookies in the following format: `sp_dc=; sp_t=; sp_landing=; sp_key=` into `$HOME/.config/ncspot/cookie.txt` (in the same directory where `config.toml` is) 34 | 4. Once a year or something you'll have to update your cookies as they have an expiration date. 35 | 36 | -------------------------------------------------------------------------------- /src/token.rs: -------------------------------------------------------------------------------- 1 | use crate::config; 2 | use reqwest::blocking::Client; 3 | use reqwest::header::{HeaderMap, HeaderValue, COOKIE}; 4 | 5 | #[derive(Clone, Debug, Serialize, Deserialize)] 6 | pub struct TokenInfo { 7 | #[serde(rename = "clientId")] 8 | pub client_id: String, 9 | #[serde(rename = "accessToken")] 10 | pub access_token: String, 11 | #[serde(rename = "accessTokenExpirationTimestampMs")] 12 | pub expires_at: u64, 13 | #[serde(rename = "isAnonymous")] 14 | pub is_anonymous: bool, 15 | } 16 | 17 | pub fn fetch_illegal_access_token() -> Option { 18 | let mut headers = HeaderMap::new(); 19 | let path = config::config_path("cookie.txt"); 20 | let mut contents = std::fs::read_to_string(path) 21 | .map_err(|e| format!("unable to read: {}", e)) 22 | .unwrap(); 23 | contents.pop(); // remove newline from the end 24 | headers.insert(COOKIE, HeaderValue::from_str(contents.as_str()).unwrap()); 25 | 26 | let client = Client::builder() 27 | .cookie_store(true) 28 | .default_headers(headers) 29 | .user_agent("Chrome") 30 | .build() 31 | .unwrap(); 32 | 33 | let response = client 34 | .get("https://open.spotify.com/get_access_token") 35 | .send() 36 | .expect("send request failed"); 37 | if response.status().is_success() { 38 | let token_info: TokenInfo = response.json().unwrap(); 39 | //debug!("access_token is: {}", token_info.access_token); 40 | Some(token_info) 41 | } else { 42 | //error!("failed retrieving token, error: {:?}", response); 43 | None 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/ui/playlists.rs: -------------------------------------------------------------------------------- 1 | use std::sync::Arc; 2 | 3 | use cursive::view::{Margins, ViewWrapper}; 4 | use cursive::views::Dialog; 5 | use cursive::Cursive; 6 | 7 | use crate::command::Command; 8 | use crate::commands::CommandResult; 9 | use crate::library::Library; 10 | use crate::playlist::{Playlist, PlaylistType}; 11 | use crate::queue::Queue; 12 | use crate::traits::ViewExt; 13 | use crate::ui::listview::ListView; 14 | use crate::ui::modal::Modal; 15 | 16 | pub struct PlaylistsView { 17 | list: ListView, 18 | library: Arc, 19 | } 20 | 21 | impl PlaylistsView { 22 | pub fn new(p_type: PlaylistType, queue: Arc, library: Arc) -> Self { 23 | let list = match p_type { 24 | PlaylistType::Library => library.playlists.clone(), 25 | PlaylistType::ForYou => library.foru.clone(), 26 | }; 27 | 28 | Self { 29 | list: ListView::new(list, queue, library.clone()), 30 | library, 31 | } 32 | } 33 | 34 | pub fn delete_dialog(&mut self) -> Option> { 35 | let playlists = self.library.playlists(); 36 | let current = playlists.get(self.list.get_selected_index()); 37 | 38 | if let Some(playlist) = current { 39 | let library = self.library.clone(); 40 | let id = playlist.id.clone(); 41 | let dialog = Dialog::text("Are you sure you want to delete this playlist?") 42 | .padding(Margins::lrtb(1, 1, 1, 0)) 43 | .title("Delete playlist") 44 | .dismiss_button("No") 45 | .button("Yes", move |s: &mut Cursive| { 46 | library.delete_playlist(&id); 47 | s.pop_layer(); 48 | }); 49 | Some(Modal::new(dialog)) 50 | } else { 51 | None 52 | } 53 | } 54 | } 55 | 56 | impl ViewWrapper for PlaylistsView { 57 | wrap_impl!(self.list: ListView); 58 | } 59 | 60 | impl ViewExt for PlaylistsView { 61 | fn on_arrive(&self) { 62 | self.list.on_arrive(); 63 | } 64 | 65 | fn on_command(&mut self, s: &mut Cursive, cmd: &Command) -> Result { 66 | if let Command::Delete = cmd { 67 | if let Some(dialog) = self.delete_dialog() { 68 | s.add_layer(dialog); 69 | } 70 | return Ok(CommandResult::Consumed(None)); 71 | } 72 | 73 | self.list.on_command(s, cmd) 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /src/ui/library.rs: -------------------------------------------------------------------------------- 1 | use std::sync::Arc; 2 | 3 | use cursive::view::ViewWrapper; 4 | use cursive::Cursive; 5 | 6 | use crate::command::Command; 7 | use crate::commands::CommandResult; 8 | use crate::library::Library; 9 | use crate::playlist::PlaylistType; 10 | use crate::queue::Queue; 11 | use crate::traits::ViewExt; 12 | use crate::ui::listview::ListView; 13 | use crate::ui::playlists::PlaylistsView; 14 | use crate::ui::tabview::TabView; 15 | 16 | pub struct LibraryView { 17 | tabs: TabView, 18 | display_name: Option, 19 | } 20 | 21 | impl LibraryView { 22 | pub fn new(queue: Arc, library: Arc) -> Self { 23 | let tabs = TabView::new() 24 | .tab( 25 | "tracks", 26 | "Tracks", 27 | ListView::new(library.tracks.clone(), queue.clone(), library.clone()), 28 | ) 29 | .tab( 30 | "albums", 31 | "Albums", 32 | ListView::new(library.albums.clone(), queue.clone(), library.clone()), 33 | ) 34 | .tab( 35 | "artists", 36 | "Artists", 37 | ListView::new(library.artists.clone(), queue.clone(), library.clone()), 38 | ) 39 | .tab( 40 | "playlists", 41 | "Playlists", 42 | PlaylistsView::new(PlaylistType::Library, queue.clone(), library.clone()), 43 | ) 44 | .tab( 45 | "podcasts", 46 | "Podcasts", 47 | ListView::new(library.shows.clone(), queue.clone(), library.clone()), 48 | ) 49 | .tab( 50 | "foru", 51 | "Made for You", 52 | PlaylistsView::new(PlaylistType::ForYou, queue, library.clone()), 53 | ); 54 | 55 | Self { 56 | tabs, 57 | display_name: library.display_name.clone(), 58 | } 59 | } 60 | } 61 | 62 | impl ViewWrapper for LibraryView { 63 | wrap_impl!(self.tabs: TabView); 64 | } 65 | 66 | impl ViewExt for LibraryView { 67 | fn title(&self) -> String { 68 | if let Some(name) = &self.display_name { 69 | format!("Library of {}", name) 70 | } else { 71 | "Library".to_string() 72 | } 73 | } 74 | 75 | fn on_arrive(&self) { 76 | self.tabs.on_arrive(); 77 | } 78 | 79 | fn on_command(&mut self, s: &mut Cursive, cmd: &Command) -> Result { 80 | self.tabs.on_command(s, cmd) 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /src/ui/album.rs: -------------------------------------------------------------------------------- 1 | use std::sync::{Arc, RwLock}; 2 | 3 | use cursive::view::ViewWrapper; 4 | use cursive::Cursive; 5 | 6 | use crate::album::Album; 7 | use crate::artist::Artist; 8 | use crate::command::Command; 9 | use crate::commands::CommandResult; 10 | use crate::library::Library; 11 | use crate::queue::Queue; 12 | use crate::traits::ViewExt; 13 | use crate::ui::listview::ListView; 14 | use crate::ui::tabview::TabView; 15 | 16 | pub struct AlbumView { 17 | album: Album, 18 | tabs: TabView, 19 | } 20 | 21 | impl AlbumView { 22 | pub fn new(queue: Arc, library: Arc, album: &Album) -> Self { 23 | let mut album = album.clone(); 24 | 25 | album.load_all_tracks(queue.get_spotify()); 26 | 27 | let tracks = if let Some(t) = album.tracks.as_ref() { 28 | t.clone() 29 | } else { 30 | Vec::new() 31 | }; 32 | 33 | let artists = album 34 | .artist_ids 35 | .iter() 36 | .zip(album.artists.iter()) 37 | .map(|(id, name)| Artist::new(id.clone(), name.clone())) 38 | .collect(); 39 | 40 | let tabs = TabView::new() 41 | .tab( 42 | "tracks", 43 | "Tracks", 44 | ListView::new( 45 | Arc::new(RwLock::new(tracks)), 46 | queue.clone(), 47 | library.clone(), 48 | ), 49 | ) 50 | .tab( 51 | "artists", 52 | "Artists", 53 | ListView::new(Arc::new(RwLock::new(artists)), queue, library), 54 | ); 55 | 56 | Self { album, tabs } 57 | } 58 | } 59 | 60 | impl ViewWrapper for AlbumView { 61 | wrap_impl!(self.tabs: TabView); 62 | } 63 | 64 | impl ViewExt for AlbumView { 65 | fn title(&self) -> String { 66 | format!("{} ({})", self.album.title, self.album.year) 67 | } 68 | 69 | fn title_sub(&self) -> String { 70 | if let Some(tracks) = &self.album.tracks { 71 | let duration_secs: u64 = tracks.iter().map(|t| t.duration as u64 / 1000).sum(); 72 | let duration = std::time::Duration::from_secs(duration_secs); 73 | let duration_str = crate::utils::format_duration(&duration); 74 | format!("{} tracks, {}", tracks.len(), duration_str) 75 | } else { 76 | "".to_string() 77 | } 78 | } 79 | 80 | fn on_command(&mut self, s: &mut Cursive, cmd: &Command) -> Result { 81 | self.tabs.on_command(s, cmd) 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /.github/workflows/cd.yml: -------------------------------------------------------------------------------- 1 | name: CD 2 | 3 | on: 4 | push: 5 | tags: 6 | - "v*.*.*" 7 | 8 | jobs: 9 | publish: 10 | name: Publishing ${{ matrix.build_target }} 11 | runs-on: ${{ matrix.os }} 12 | strategy: 13 | matrix: 14 | build_target: [linux, macos] 15 | include: 16 | - build_target: linux 17 | os: ubuntu-latest 18 | artifact_prefix: linux 19 | target: x86_64-unknown-linux-gnu 20 | features: '' 21 | - build_target: macos 22 | os: macos-latest 23 | artifact_prefix: macos 24 | target: x86_64-apple-darwin 25 | features: '--no-default-features --features portaudio_backend,cursive/pancurses-backend' 26 | steps: 27 | - name: Install Rust toolchain 28 | uses: actions-rs/toolchain@v1 29 | with: 30 | toolchain: stable 31 | override: true 32 | target: ${{ matrix.target }} 33 | profile: minimal 34 | - name: Install macOS dependencies 35 | if: matrix.os == 'macos-latest' 36 | run: brew install portaudio pkg-config libnotify 37 | - name: Install Linux dependencies 38 | if: matrix.os == 'ubuntu-latest' 39 | run: | 40 | sudo apt update 41 | sudo apt install libpulse-dev libdbus-1-dev libncursesw5-dev libxcb-shape0-dev libxcb-xfixes0-dev libnotify-dev 42 | - uses: actions/checkout@v2 43 | name: Checkout src 44 | - uses: actions/cache@v2 45 | with: 46 | path: | 47 | ~/.cargo/registry 48 | ~/.cargo/git 49 | key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} 50 | - name: Patch rspotify 51 | run: cd rspotify && git apply ../rspotify.patch 52 | - name: Running cargo build 53 | uses: actions-rs/cargo@v1 54 | with: 55 | command: build 56 | args: --locked --release --target ${{ matrix.target }} ${{ matrix.features }} 57 | - name: Extract git tag 58 | shell: bash 59 | run: echo "##[set-output name=tag;]$(echo ${GITHUB_REF#refs/tags/})" 60 | id: extract_tag 61 | - name: Packaging assets 62 | shell: bash 63 | run: | 64 | cd target/${{ matrix.target }}/release 65 | strip ncspot 66 | tar czvf ncspot-${{ steps.extract_tag.outputs.tag }}-${{ matrix.artifact_prefix }}.tar.gz ncspot 67 | shasum -a 256 ncspot-${{ steps.extract_tag.outputs.tag }}-${{ matrix.artifact_prefix }}.tar.gz > ncspot-${{ steps.extract_tag.outputs.tag }}-${{ matrix.artifact_prefix }}.sha256 68 | - name: Releasing assets 69 | uses: softprops/action-gh-release@v1 70 | with: 71 | files: target/${{ matrix.target }}/release/ncspot-* 72 | env: 73 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 74 | -------------------------------------------------------------------------------- /src/theme.rs: -------------------------------------------------------------------------------- 1 | use cursive::theme::BaseColor::*; 2 | use cursive::theme::Color::*; 3 | use cursive::theme::PaletteColor::*; 4 | use cursive::theme::*; 5 | use log::warn; 6 | 7 | use crate::config::ConfigTheme; 8 | 9 | macro_rules! load_color { 10 | ( $theme: expr, $member: ident, $default: expr ) => { 11 | $theme 12 | .as_ref() 13 | .and_then(|t| t.$member.clone()) 14 | .and_then(|c| Color::parse(c.as_ref())) 15 | .unwrap_or_else(|| { 16 | warn!( 17 | "Failed to parse color in \"{}\", falling back to default", 18 | stringify!($member) 19 | ); 20 | $default 21 | }) 22 | }; 23 | } 24 | 25 | pub fn load(theme_cfg: &Option) -> Theme { 26 | let mut palette = Palette::default(); 27 | let borders = BorderStyle::Simple; 28 | 29 | palette[Background] = load_color!(theme_cfg, background, TerminalDefault); 30 | palette[View] = load_color!(theme_cfg, background, TerminalDefault); 31 | palette[Primary] = load_color!(theme_cfg, primary, TerminalDefault); 32 | palette[Secondary] = load_color!(theme_cfg, secondary, Dark(Blue)); 33 | palette[TitlePrimary] = load_color!(theme_cfg, title, Dark(Red)); 34 | palette[Tertiary] = load_color!(theme_cfg, highlight, TerminalDefault); 35 | palette[Highlight] = load_color!(theme_cfg, highlight_bg, Dark(Red)); 36 | palette.set_color("playing", load_color!(theme_cfg, playing, Dark(Blue))); 37 | palette.set_color( 38 | "playing_selected", 39 | load_color!(theme_cfg, playing_selected, Light(Blue)), 40 | ); 41 | palette.set_color( 42 | "playing_bg", 43 | load_color!(theme_cfg, playing_bg, TerminalDefault), 44 | ); 45 | palette.set_color("error", load_color!(theme_cfg, error, TerminalDefault)); 46 | palette.set_color("error_bg", load_color!(theme_cfg, error_bg, Dark(Red))); 47 | palette.set_color( 48 | "statusbar_progress", 49 | load_color!(theme_cfg, statusbar_progress, Dark(Blue)), 50 | ); 51 | palette.set_color( 52 | "statusbar_progress_bg", 53 | load_color!(theme_cfg, statusbar_progress_bg, Light(Black)), 54 | ); 55 | palette.set_color("statusbar", load_color!(theme_cfg, statusbar, Dark(Yellow))); 56 | palette.set_color( 57 | "statusbar_bg", 58 | load_color!(theme_cfg, statusbar_bg, TerminalDefault), 59 | ); 60 | palette.set_color("cmdline", load_color!(theme_cfg, cmdline, TerminalDefault)); 61 | palette.set_color( 62 | "cmdline_bg", 63 | load_color!(theme_cfg, cmdline_bg, TerminalDefault), 64 | ); 65 | palette.set_color( 66 | "search_match", 67 | load_color!(theme_cfg, search_match, Light(Red)), 68 | ); 69 | 70 | Theme { 71 | shadow: false, 72 | palette, 73 | borders, 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /src/traits.rs: -------------------------------------------------------------------------------- 1 | use std::sync::Arc; 2 | 3 | use cursive::view::{View, ViewWrapper}; 4 | use cursive::views::NamedView; 5 | use cursive::Cursive; 6 | 7 | use crate::album::Album; 8 | use crate::artist::Artist; 9 | use crate::command::Command; 10 | use crate::commands::CommandResult; 11 | use crate::library::Library; 12 | use crate::queue::Queue; 13 | use crate::track::Track; 14 | 15 | pub trait ListItem: Sync + Send + 'static { 16 | fn is_playing(&self, queue: Arc) -> bool; 17 | fn display_left(&self) -> String; 18 | fn display_center(&self, _library: Arc) -> String { 19 | "".to_string() 20 | } 21 | fn display_right(&self, library: Arc) -> String; 22 | fn play(&mut self, queue: Arc); 23 | fn queue(&mut self, queue: Arc); 24 | fn play_next(&mut self, queue: Arc); 25 | fn toggle_saved(&mut self, library: Arc); 26 | fn save(&mut self, library: Arc); 27 | fn unsave(&mut self, library: Arc); 28 | fn open(&self, queue: Arc, library: Arc) -> Option>; 29 | fn open_recommendations( 30 | &self, 31 | _queue: Arc, 32 | _library: Arc, 33 | ) -> Option> { 34 | None 35 | } 36 | fn share_url(&self) -> Option; 37 | 38 | fn album(&self, _queue: Arc) -> Option { 39 | None 40 | } 41 | 42 | fn artists(&self) -> Option> { 43 | None 44 | } 45 | 46 | fn track(&self) -> Option { 47 | None 48 | } 49 | 50 | fn as_listitem(&self) -> Box; 51 | } 52 | 53 | pub trait ViewExt: View { 54 | fn title(&self) -> String { 55 | "".into() 56 | } 57 | 58 | fn title_sub(&self) -> String { 59 | "".into() 60 | } 61 | 62 | fn on_leave(&self) {} 63 | 64 | fn on_arrive(&self) {} 65 | 66 | fn on_command(&mut self, _s: &mut Cursive, _cmd: &Command) -> Result { 67 | Ok(CommandResult::Ignored) 68 | } 69 | } 70 | 71 | impl ViewExt for NamedView { 72 | fn title(&self) -> String { 73 | self.with_view(|v| v.title()).unwrap_or_default() 74 | } 75 | 76 | fn title_sub(&self) -> String { 77 | self.with_view(|v| v.title_sub()).unwrap_or_default() 78 | } 79 | 80 | fn on_leave(&self) { 81 | self.with_view(|v| v.on_leave()); 82 | } 83 | 84 | fn on_arrive(&self) { 85 | self.with_view(|v| v.on_arrive()); 86 | } 87 | 88 | fn on_command(&mut self, s: &mut Cursive, cmd: &Command) -> Result { 89 | self.with_view_mut(move |v| v.on_command(s, cmd)).unwrap() 90 | } 91 | } 92 | 93 | pub trait IntoBoxedViewExt { 94 | fn into_boxed_view_ext(self) -> Box; 95 | } 96 | 97 | impl IntoBoxedViewExt for V { 98 | fn into_boxed_view_ext(self) -> Box { 99 | Box::new(self) 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "ncspot" 3 | description = "ncurses Spotify client written in Rust using librespot, inspired by ncmpc and the likes." 4 | exclude = ["screenshots/**"] 5 | version = "0.8.1" 6 | authors = ["Henrik Friedrichsen "] 7 | repository = "https://github.com/hrkfdn/ncspot" 8 | keywords = ["spotify", "ncurses", "librespot", "terminal"] 9 | license = "BSD-2-Clause" 10 | readme = "README.md" 11 | edition = "2018" 12 | 13 | [badges] 14 | maintenance = { status = "actively-developed" } 15 | 16 | [dependencies] 17 | clap = "2.33.0" 18 | chrono = "0.4" 19 | reqwest = { version = "0.11", features = ["blocking", "json", "cookies"] } 20 | crossbeam-channel = "0.5" 21 | platform-dirs = "0.3.0" 22 | failure = "0.1" 23 | fern = "0.6" 24 | futures = { version = "0.3", features = ["compat"] } 25 | futures_01 = { version = "0.1", package = "futures" } 26 | lazy_static = "1.3.0" 27 | librespot-core = { version = "0.2.0", features = ["apresolve"] } 28 | librespot-playback = "0.2.0" 29 | librespot-protocol = "0.2.0" 30 | log = "0.4.13" 31 | libnotify = { version = "1.0.3", optional = true } 32 | rayon = "1.5" 33 | rspotify = { version = "0.10.0", features = ["blocking"] } 34 | serde = "1.0" 35 | serde_json = "1.0" 36 | tokio = { version = "1", features = ["rt-multi-thread", "sync", "time"] } 37 | tokio-stream = "0.1.7" 38 | toml = "0.5" 39 | unicode-width = "0.1.8" 40 | dbus = { version = "0.9.3", optional = true } 41 | dbus-tree = { version = "0.9.0", optional = true } 42 | rand = "0.8" 43 | webbrowser = "0.5" 44 | clipboard = { version = "0.5", optional = true } 45 | url = "2.2" 46 | strum = "0.21.0" 47 | strum_macros = "0.21.1" 48 | tempdir = { version = "0.3.7", optional = true } 49 | gdk-pixbuf = { version = "0.3.0", optional = true } 50 | regex = "1" 51 | ioctl-rs = { version = "0.2", optional = true } 52 | serde_cbor = "0.11.1" 53 | signal-hook = "0.3.9" 54 | pancurses = { version = "0.16.1", features = ["win32"] } 55 | 56 | [patch.crates-io] 57 | rspotify = { path = "./rspotify" } 58 | 59 | [dependencies.cursive] 60 | version = "0.16.3" 61 | default-features = false 62 | 63 | [features] 64 | share_clipboard = ["clipboard"] 65 | share_selection = ["clipboard"] # Use the primary selection for sharing - linux only 66 | alsa_backend = ["librespot-playback/alsa-backend"] 67 | pulseaudio_backend = ["librespot-playback/pulseaudio-backend"] 68 | rodio_backend = ["librespot-playback/rodio-backend"] 69 | portaudio_backend = ["librespot-playback/portaudio-backend"] 70 | termion_backend = ["cursive/termion-backend"] 71 | mpris = ["dbus", "dbus-tree"] 72 | notify = ["libnotify", "tempdir", "gdk-pixbuf"] 73 | cover = ["ioctl-rs"] 74 | default = ["share_clipboard", "pulseaudio_backend", "mpris", "notify", "cover", "cursive/pancurses-backend"] 75 | 76 | [package.metadata.deb] 77 | depends = "$auto, pulseaudio" 78 | section = "sound" 79 | priority = "optional" 80 | extended-description = """\ 81 | ncurses Spotify client written in Rust using librespot. \ 82 | It is heavily inspired by ncurses MPD clients, such as ncmpc.""" 83 | license-file = ["LICENSE"] 84 | assets = [ 85 | ["target/release/ncspot", "usr/bin/", "755"], 86 | ["README.md", "usr/share/doc/ncspot/README.md", "644"], 87 | ] 88 | -------------------------------------------------------------------------------- /src/ui/help.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashMap; 2 | 3 | use cursive::theme::Effect; 4 | use cursive::utils::markup::StyledString; 5 | use cursive::view::ViewWrapper; 6 | use cursive::views::{ScrollView, TextView}; 7 | use cursive::Cursive; 8 | 9 | use crate::command::{Command, MoveAmount, MoveMode}; 10 | use crate::commands::CommandResult; 11 | use crate::config::config_path; 12 | use crate::traits::ViewExt; 13 | use cursive::view::scroll::Scroller; 14 | 15 | pub struct HelpView { 16 | view: ScrollView, 17 | } 18 | 19 | impl HelpView { 20 | pub fn new(bindings: HashMap) -> HelpView { 21 | let mut text = StyledString::styled("Keybindings\n\n", Effect::Bold); 22 | 23 | let note = format!( 24 | "Custom bindings can be set in {} within the [keybindings] section.\n\n", 25 | config_path("config.toml").to_str().unwrap_or_default() 26 | ); 27 | text.append(StyledString::styled(note, Effect::Italic)); 28 | 29 | let mut keys: Vec<&String> = bindings.keys().collect(); 30 | keys.sort(); 31 | 32 | for key in keys { 33 | let command = &bindings[key]; 34 | let binding = format!("{} -> {}\n", key, command); 35 | text.append(binding); 36 | } 37 | 38 | HelpView { 39 | view: ScrollView::new(TextView::new(text)), 40 | } 41 | } 42 | } 43 | 44 | impl ViewWrapper for HelpView { 45 | wrap_impl!(self.view: ScrollView); 46 | } 47 | 48 | impl ViewExt for HelpView { 49 | fn title(&self) -> String { 50 | "Help".to_string() 51 | } 52 | 53 | fn on_command(&mut self, _s: &mut Cursive, cmd: &Command) -> Result { 54 | match cmd { 55 | Command::Help => Ok(CommandResult::Consumed(None)), 56 | Command::Move(mode, amount) => { 57 | let scroller = self.view.get_scroller_mut(); 58 | let viewport = scroller.content_viewport(); 59 | match mode { 60 | MoveMode::Up => { 61 | match amount { 62 | MoveAmount::Extreme => { 63 | self.view.scroll_to_top(); 64 | } 65 | MoveAmount::Integer(amount) => scroller 66 | .scroll_to_y(viewport.top().saturating_sub(*amount as usize)), 67 | }; 68 | Ok(CommandResult::Consumed(None)) 69 | } 70 | MoveMode::Down => { 71 | match amount { 72 | MoveAmount::Extreme => { 73 | self.view.scroll_to_bottom(); 74 | } 75 | MoveAmount::Integer(amount) => scroller 76 | .scroll_to_y(viewport.bottom().saturating_add(*amount as usize)), 77 | }; 78 | Ok(CommandResult::Consumed(None)) 79 | } 80 | _ => Ok(CommandResult::Consumed(None)), 81 | } 82 | } 83 | _ => Ok(CommandResult::Ignored), 84 | } 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /src/episode.rs: -------------------------------------------------------------------------------- 1 | use crate::library::Library; 2 | use crate::playable::Playable; 3 | use crate::queue::Queue; 4 | use crate::traits::{ListItem, ViewExt}; 5 | use rspotify::model::show::{FullEpisode, SimplifiedEpisode}; 6 | use std::fmt; 7 | use std::sync::Arc; 8 | 9 | #[derive(Clone, Debug, Deserialize, Serialize)] 10 | pub struct Episode { 11 | pub id: String, 12 | pub uri: String, 13 | pub duration: u32, 14 | pub name: String, 15 | pub description: String, 16 | pub release_date: String, 17 | pub cover_url: Option, 18 | } 19 | 20 | impl Episode { 21 | pub fn duration_str(&self) -> String { 22 | let minutes = self.duration / 60_000; 23 | let seconds = (self.duration / 1000) % 60; 24 | format!("{:02}:{:02}", minutes, seconds) 25 | } 26 | } 27 | 28 | impl From<&SimplifiedEpisode> for Episode { 29 | fn from(episode: &SimplifiedEpisode) -> Self { 30 | Self { 31 | id: episode.id.clone(), 32 | uri: episode.uri.clone(), 33 | duration: episode.duration_ms, 34 | name: episode.name.clone(), 35 | description: episode.description.clone(), 36 | release_date: episode.release_date.clone(), 37 | cover_url: episode.images.get(0).map(|img| img.url.clone()), 38 | } 39 | } 40 | } 41 | 42 | impl From<&FullEpisode> for Episode { 43 | fn from(episode: &FullEpisode) -> Self { 44 | Self { 45 | id: episode.id.clone(), 46 | uri: episode.uri.clone(), 47 | duration: episode.duration_ms, 48 | name: episode.name.clone(), 49 | description: episode.description.clone(), 50 | release_date: episode.release_date.clone(), 51 | cover_url: episode.images.get(0).map(|img| img.url.clone()), 52 | } 53 | } 54 | } 55 | 56 | impl fmt::Display for Episode { 57 | fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { 58 | write!(f, "{}", self.name) 59 | } 60 | } 61 | 62 | impl ListItem for Episode { 63 | fn is_playing(&self, queue: Arc) -> bool { 64 | let current = queue.get_current(); 65 | current 66 | .map(|t| t.id() == Some(self.id.clone())) 67 | .unwrap_or(false) 68 | } 69 | 70 | fn display_left(&self) -> String { 71 | self.name.clone() 72 | } 73 | 74 | fn display_right(&self, _library: Arc) -> String { 75 | format!("{} [{}]", self.duration_str(), self.release_date) 76 | } 77 | 78 | fn play(&mut self, queue: Arc) { 79 | let index = queue.append_next(vec![Playable::Episode(self.clone())]); 80 | queue.play(index, true, false); 81 | } 82 | 83 | fn play_next(&mut self, queue: Arc) { 84 | queue.insert_after_current(Playable::Episode(self.clone())); 85 | } 86 | 87 | fn queue(&mut self, queue: Arc) { 88 | queue.append(Playable::Episode(self.clone())); 89 | } 90 | 91 | fn toggle_saved(&mut self, _library: Arc) {} 92 | 93 | fn save(&mut self, _library: Arc) {} 94 | 95 | fn unsave(&mut self, _library: Arc) {} 96 | 97 | fn open(&self, _queue: Arc, _library: Arc) -> Option> { 98 | None 99 | } 100 | 101 | fn share_url(&self) -> Option { 102 | Some(format!("https://open.spotify.com/episode/{}", self.id)) 103 | } 104 | 105 | fn as_listitem(&self) -> Box { 106 | Box::new(self.clone()) 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /src/spotify_url.rs: -------------------------------------------------------------------------------- 1 | use crate::spotify::UriType; 2 | 3 | use url::{Host, Url}; 4 | 5 | pub struct SpotifyUrl { 6 | pub id: String, 7 | pub uri_type: UriType, 8 | } 9 | 10 | impl SpotifyUrl { 11 | fn new(id: &str, uri_type: UriType) -> SpotifyUrl { 12 | SpotifyUrl { 13 | id: id.to_string(), 14 | uri_type, 15 | } 16 | } 17 | 18 | /// Get media id and type from open.spotify.com url 19 | /// 20 | /// ``` 21 | /// let result = spotify_url::SpotifyURL::from_url("https://open.spotify.com/track/4uLU6hMCjMI75M1A2tKUQC").unwrap(); 22 | /// assert_eq!(result.id, "4uLU6hMCjMI75M1A2tKUQC"); 23 | /// assert_eq!(result.uri_type, URIType::Track); 24 | /// ``` 25 | pub fn from_url(s: &str) -> Option { 26 | let url = Url::parse(s).ok()?; 27 | if url.host() != Some(Host::Domain("open.spotify.com")) { 28 | return None; 29 | } 30 | 31 | let mut path_segments = url.path_segments()?; 32 | 33 | let entity = path_segments.next()?; 34 | 35 | let uri_type = match entity.to_lowercase().as_str() { 36 | "album" => Some(UriType::Album), 37 | "artist" => Some(UriType::Artist), 38 | "episode" => Some(UriType::Episode), 39 | "playlist" => Some(UriType::Playlist), 40 | "show" => Some(UriType::Show), 41 | "track" => Some(UriType::Track), 42 | "user" => { 43 | let _user_id = path_segments.next()?; 44 | let entity = path_segments.next()?; 45 | 46 | if entity != "playlist" { 47 | return None; 48 | } 49 | 50 | Some(UriType::Playlist) 51 | } 52 | _ => None, 53 | }?; 54 | 55 | let id = path_segments.next()?; 56 | 57 | Some(SpotifyUrl::new(id, uri_type)) 58 | } 59 | } 60 | 61 | #[cfg(test)] 62 | mod tests { 63 | use std::collections::HashMap; 64 | 65 | use super::SpotifyUrl; 66 | use crate::spotify::UriType; 67 | 68 | #[test] 69 | fn test_urls() { 70 | let mut test_cases = HashMap::new(); 71 | test_cases.insert( 72 | "https://open.spotify.com/playlist/1XFxe8bkTryTODn0lk4CNa?si=FfSpZ6KPQdieClZbwHakOQ", 73 | SpotifyUrl::new("1XFxe8bkTryTODn0lk4CNa", UriType::Playlist), 74 | ); 75 | test_cases.insert( 76 | "https://open.spotify.com/track/6fRJg3R90w0juYoCJXxj2d", 77 | SpotifyUrl::new("6fRJg3R90w0juYoCJXxj2d", UriType::Track), 78 | ); 79 | test_cases.insert( 80 | "https://open.spotify.com/user/~villainy~/playlist/0OgoSs65CLDPn6AF6tsZVg", 81 | SpotifyUrl::new("0OgoSs65CLDPn6AF6tsZVg", UriType::Playlist), 82 | ); 83 | test_cases.insert( 84 | "https://open.spotify.com/show/4MZfJbM2MXzZdPbv6gi5lJ", 85 | SpotifyUrl::new("4MZfJbM2MXzZdPbv6gi5lJ", UriType::Show), 86 | ); 87 | test_cases.insert( 88 | "https://open.spotify.com/episode/3QE6rfmjRaeqXSqeWcIWF6", 89 | SpotifyUrl::new("3QE6rfmjRaeqXSqeWcIWF6", UriType::Episode), 90 | ); 91 | test_cases.insert( 92 | "https://open.spotify.com/artist/6LEeAFiJF8OuPx747e1wxR", 93 | SpotifyUrl::new("6LEeAFiJF8OuPx747e1wxR", UriType::Artist), 94 | ); 95 | 96 | for case in test_cases { 97 | let result = SpotifyUrl::from_url(case.0).unwrap(); 98 | assert_eq!(result.id, case.1.id); 99 | assert_eq!(result.uri_type, case.1.uri_type); 100 | } 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /src/ui/playlist.rs: -------------------------------------------------------------------------------- 1 | use std::sync::{Arc, RwLock}; 2 | 3 | use cursive::view::ViewWrapper; 4 | use cursive::Cursive; 5 | 6 | use crate::command::Command; 7 | use crate::commands::CommandResult; 8 | use crate::library::Library; 9 | use crate::playlist::Playlist; 10 | use crate::queue::Queue; 11 | use crate::spotify::Spotify; 12 | use crate::track::Track; 13 | use crate::traits::ViewExt; 14 | use crate::ui::listview::ListView; 15 | 16 | pub struct PlaylistView { 17 | playlist: Playlist, 18 | list: ListView, 19 | spotify: Spotify, 20 | library: Arc, 21 | queue: Arc, 22 | } 23 | 24 | impl PlaylistView { 25 | pub fn new(queue: Arc, library: Arc, playlist: &Playlist) -> Self { 26 | let mut playlist = playlist.clone(); 27 | playlist.load_tracks(queue.get_spotify()); 28 | 29 | if let Some(order) = library.cfg.state().playlist_orders.get(&playlist.id) { 30 | playlist.sort(&order.key, &order.direction); 31 | } 32 | 33 | let tracks = if let Some(t) = playlist.tracks.as_ref() { 34 | t.clone() 35 | } else { 36 | Vec::new() 37 | }; 38 | 39 | let spotify = queue.get_spotify(); 40 | let list = ListView::new( 41 | Arc::new(RwLock::new(tracks)), 42 | queue.clone(), 43 | library.clone(), 44 | ); 45 | 46 | Self { 47 | playlist, 48 | list, 49 | spotify, 50 | library, 51 | queue, 52 | } 53 | } 54 | } 55 | 56 | impl ViewWrapper for PlaylistView { 57 | wrap_impl!(self.list: ListView); 58 | } 59 | 60 | impl ViewExt for PlaylistView { 61 | fn title(&self) -> String { 62 | self.playlist.name.clone() 63 | } 64 | 65 | fn title_sub(&self) -> String { 66 | if let Some(tracks) = self.playlist.tracks.as_ref() { 67 | let duration_secs = tracks.iter().map(|p| p.duration as u64 / 1000).sum(); 68 | let duration = std::time::Duration::from_secs(duration_secs); 69 | format!( 70 | "{} tracks, {}", 71 | tracks.len(), 72 | crate::utils::format_duration(&duration) 73 | ) 74 | } else { 75 | "".to_string() 76 | } 77 | } 78 | 79 | fn on_command(&mut self, s: &mut Cursive, cmd: &Command) -> Result { 80 | if let Command::Delete = cmd { 81 | let pos = self.list.get_selected_index(); 82 | if self 83 | .playlist 84 | .delete_track(pos, self.spotify.clone(), self.library.clone()) 85 | { 86 | self.list.remove(pos); 87 | } 88 | return Ok(CommandResult::Consumed(None)); 89 | } 90 | 91 | if let Command::Sort(key, direction) = cmd { 92 | self.library.cfg.with_state_mut(|mut state| { 93 | let order = crate::config::SortingOrder { 94 | key: key.clone(), 95 | direction: direction.clone(), 96 | }; 97 | state 98 | .playlist_orders 99 | .insert(self.playlist.id.clone(), order); 100 | }); 101 | 102 | self.playlist.sort(key, direction); 103 | let tracks = self.playlist.tracks.as_ref().unwrap_or(&Vec::new()).clone(); 104 | self.list = ListView::new( 105 | Arc::new(RwLock::new(tracks)), 106 | self.queue.clone(), 107 | self.library.clone(), 108 | ); 109 | return Ok(CommandResult::Consumed(None)); 110 | } 111 | 112 | self.list.on_command(s, cmd) 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /src/serialization.rs: -------------------------------------------------------------------------------- 1 | use std::{fs, path::Path}; 2 | 3 | pub trait Serializer { 4 | /// Configuration and credential file helper 5 | /// Creates a default configuration if none exist, otherwise will optionally overwrite 6 | /// the file if it fails to parse 7 | fn load_or_generate_default< 8 | P: AsRef, 9 | T: serde::Serialize + serde::de::DeserializeOwned, 10 | F: Fn() -> Result, 11 | >( 12 | &self, 13 | path: P, 14 | default: F, 15 | default_on_parse_failure: bool, 16 | ) -> Result { 17 | let path = path.as_ref(); 18 | // Nothing exists so just write the default and return it 19 | if !path.exists() { 20 | let value = default()?; 21 | return self.write(&path, value); 22 | } 23 | 24 | let result = self.load(&path); 25 | if default_on_parse_failure && result.is_err() { 26 | let value = default()?; 27 | return self.write(&path, value); 28 | } 29 | result.map_err(|e| format!("Unable to parse {}: {}", path.to_string_lossy(), e)) 30 | } 31 | 32 | fn load, T: serde::Serialize + serde::de::DeserializeOwned>( 33 | &self, 34 | path: P, 35 | ) -> Result; 36 | fn write, T: serde::Serialize>(&self, path: P, value: T) -> Result; 37 | } 38 | 39 | pub struct TomlSerializer {} 40 | impl Serializer for TomlSerializer { 41 | fn load, T: serde::Serialize + serde::de::DeserializeOwned>( 42 | &self, 43 | path: P, 44 | ) -> Result { 45 | let contents = std::fs::read_to_string(&path) 46 | .map_err(|e| format!("Unable to read {}: {}", path.as_ref().to_string_lossy(), e))?; 47 | toml::from_str(&contents).map_err(|e| { 48 | format!( 49 | "Unable to parse toml {}: {}", 50 | path.as_ref().to_string_lossy(), 51 | e 52 | ) 53 | }) 54 | } 55 | 56 | fn write, T: serde::Serialize>(&self, path: P, value: T) -> Result { 57 | let content = toml::to_string_pretty(&value) 58 | .map_err(|e| format!("Failed serializing value: {}", e))?; 59 | fs::write(path.as_ref(), content) 60 | .map(|_| value) 61 | .map_err(|e| { 62 | format!( 63 | "Failed writing content to {}: {}", 64 | path.as_ref().display(), 65 | e 66 | ) 67 | }) 68 | } 69 | } 70 | 71 | pub struct CborSerializer {} 72 | impl Serializer for CborSerializer { 73 | fn load, T: serde::Serialize + serde::de::DeserializeOwned>( 74 | &self, 75 | path: P, 76 | ) -> Result { 77 | let contents = std::fs::read(&path) 78 | .map_err(|e| format!("Unable to read {}: {}", path.as_ref().to_string_lossy(), e))?; 79 | serde_cbor::from_slice(&contents).map_err(|e| { 80 | format!( 81 | "Unable to parse CBOR {}: {}", 82 | path.as_ref().to_string_lossy(), 83 | e 84 | ) 85 | }) 86 | } 87 | 88 | fn write, T: serde::Serialize>(&self, path: P, value: T) -> Result { 89 | let file = std::fs::File::create(&path).map_err(|e| { 90 | format!( 91 | "Failed creating file {}: {}", 92 | path.as_ref().to_string_lossy(), 93 | e 94 | ) 95 | })?; 96 | serde_cbor::to_writer(file, &value) 97 | .map(|_| value) 98 | .map_err(|e| { 99 | format!( 100 | "Failed writing content to {}: {}", 101 | path.as_ref().display(), 102 | e 103 | ) 104 | }) 105 | } 106 | } 107 | 108 | pub static TOML: TomlSerializer = TomlSerializer {}; 109 | pub static CBOR: CborSerializer = CborSerializer {}; 110 | -------------------------------------------------------------------------------- /src/ui/search.rs: -------------------------------------------------------------------------------- 1 | #![allow(unused_imports)] 2 | 3 | use cursive::direction::Orientation; 4 | use cursive::event::{AnyCb, Event, EventResult, Key}; 5 | use cursive::traits::{Boxable, Finder, Identifiable, View}; 6 | use cursive::view::{IntoBoxedView, Selector, ViewNotFound, ViewWrapper}; 7 | use cursive::views::{EditView, NamedView, ViewRef}; 8 | use cursive::{Cursive, Printer, Vec2}; 9 | use std::cell::RefCell; 10 | use std::sync::{Arc, Mutex, RwLock}; 11 | 12 | use crate::album::Album; 13 | use crate::artist::Artist; 14 | use crate::command::{Command, MoveMode}; 15 | use crate::commands::CommandResult; 16 | use crate::episode::Episode; 17 | use crate::events::EventManager; 18 | use crate::library::Library; 19 | use crate::playlist::Playlist; 20 | use crate::queue::Queue; 21 | use crate::show::Show; 22 | use crate::spotify::{Spotify, UriType}; 23 | use crate::track::Track; 24 | use crate::traits::{ListItem, ViewExt}; 25 | use crate::ui::layout::Layout; 26 | use crate::ui::listview::ListView; 27 | use crate::ui::pagination::Pagination; 28 | use crate::ui::search_results::SearchResultsView; 29 | use crate::ui::tabview::TabView; 30 | use rspotify::model::search::SearchResult; 31 | use rspotify::senum::SearchType; 32 | 33 | pub struct SearchView { 34 | edit: NamedView, 35 | edit_focused: bool, 36 | } 37 | 38 | pub const EDIT_ID: &str = "search_edit"; 39 | 40 | impl SearchView { 41 | pub fn new(events: EventManager, queue: Arc, library: Arc) -> SearchView { 42 | let searchfield = EditView::new() 43 | .on_submit(move |s, input| { 44 | if !input.is_empty() { 45 | let results = SearchResultsView::new( 46 | input.to_string(), 47 | events.clone(), 48 | queue.clone(), 49 | library.clone(), 50 | ); 51 | s.call_on_name("main", move |v: &mut Layout| v.push_view(Box::new(results))); 52 | } 53 | }) 54 | .with_name(EDIT_ID); 55 | 56 | SearchView { 57 | edit: searchfield, 58 | edit_focused: true, 59 | } 60 | } 61 | 62 | pub fn clear(&mut self) { 63 | self.edit 64 | .call_on(&Selector::Name(EDIT_ID), |v: &mut EditView| { 65 | v.set_content(""); 66 | }); 67 | } 68 | } 69 | 70 | impl View for SearchView { 71 | fn draw(&self, printer: &Printer<'_, '_>) { 72 | let printer = &printer 73 | .offset((0, 0)) 74 | .cropped((printer.size.x, 1)) 75 | .focused(self.edit_focused); 76 | self.edit.draw(printer); 77 | } 78 | 79 | fn layout(&mut self, size: Vec2) { 80 | self.edit.layout(Vec2::new(size.x, 1)); 81 | } 82 | 83 | fn on_event(&mut self, event: Event) -> EventResult { 84 | if event == Event::Key(Key::Tab) { 85 | self.edit_focused = !self.edit_focused; 86 | return EventResult::Consumed(None); 87 | } else if self.edit_focused && event == Event::Key(Key::Esc) { 88 | self.clear(); 89 | } 90 | 91 | if self.edit_focused { 92 | self.edit.on_event(event) 93 | } else { 94 | EventResult::Ignored 95 | } 96 | } 97 | 98 | fn call_on_any<'a>(&mut self, selector: &Selector<'_>, callback: AnyCb<'a>) { 99 | self.edit.call_on_any(selector, &mut |v| callback(v)); 100 | } 101 | 102 | fn focus_view(&mut self, selector: &Selector<'_>) -> Result<(), ViewNotFound> { 103 | if let Selector::Name(s) = selector { 104 | self.edit_focused = s == &"search_edit"; 105 | Ok(()) 106 | } else { 107 | Err(ViewNotFound) 108 | } 109 | } 110 | } 111 | 112 | impl ViewExt for SearchView { 113 | fn title(&self) -> String { 114 | "Search".to_string() 115 | } 116 | 117 | fn on_command(&mut self, _s: &mut Cursive, cmd: &Command) -> Result { 118 | if let Command::Focus(_) = cmd { 119 | self.edit_focused = true; 120 | self.clear(); 121 | return Ok(CommandResult::Consumed(None)); 122 | } 123 | 124 | Ok(CommandResult::Ignored) 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /src/playable.rs: -------------------------------------------------------------------------------- 1 | use crate::album::Album; 2 | use crate::artist::Artist; 3 | use crate::episode::Episode; 4 | use crate::library::Library; 5 | use crate::queue::Queue; 6 | use crate::track::Track; 7 | use crate::traits::{ListItem, ViewExt}; 8 | use std::fmt; 9 | use std::sync::Arc; 10 | 11 | #[derive(Clone, Debug, Deserialize, Serialize)] 12 | #[serde(tag = "type")] 13 | pub enum Playable { 14 | Track(Track), 15 | Episode(Episode), 16 | } 17 | 18 | impl Playable { 19 | pub fn id(&self) -> Option { 20 | match self { 21 | Playable::Track(track) => track.id.clone(), 22 | Playable::Episode(episode) => Some(episode.id.clone()), 23 | } 24 | } 25 | 26 | pub fn uri(&self) -> String { 27 | match self { 28 | Playable::Track(track) => track.uri.clone(), 29 | Playable::Episode(episode) => episode.uri.clone(), 30 | } 31 | } 32 | 33 | pub fn cover_url(&self) -> Option { 34 | match self { 35 | Playable::Track(track) => track.cover_url.clone(), 36 | Playable::Episode(episode) => episode.cover_url.clone(), 37 | } 38 | } 39 | 40 | pub fn duration(&self) -> u32 { 41 | match self { 42 | Playable::Track(track) => track.duration, 43 | Playable::Episode(episode) => episode.duration, 44 | } 45 | } 46 | 47 | pub fn duration_str(&self) -> String { 48 | let duration = self.duration(); 49 | let minutes = duration / 60_000; 50 | let seconds = (duration / 1000) % 60; 51 | format!("{:02}:{:02}", minutes, seconds) 52 | } 53 | 54 | pub fn as_listitem(&self) -> Box { 55 | match self { 56 | Playable::Track(track) => track.as_listitem(), 57 | Playable::Episode(episode) => episode.as_listitem(), 58 | } 59 | } 60 | 61 | #[cfg(feature = "notify")] 62 | pub fn title(&self) -> String { 63 | match self { 64 | Playable::Track(track) => track.title.clone(), 65 | Playable::Episode(episode) => episode.name.clone(), 66 | } 67 | } 68 | } 69 | 70 | impl fmt::Display for Playable { 71 | fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { 72 | match self { 73 | Playable::Track(track) => track.fmt(f), 74 | Playable::Episode(episode) => episode.fmt(f), 75 | } 76 | } 77 | } 78 | 79 | impl ListItem for Playable { 80 | fn is_playing(&self, queue: Arc) -> bool { 81 | self.as_listitem().is_playing(queue) 82 | } 83 | 84 | fn display_left(&self) -> String { 85 | self.as_listitem().display_left() 86 | } 87 | 88 | fn display_center(&self, library: Arc) -> String { 89 | self.as_listitem().display_center(library) 90 | } 91 | 92 | fn display_right(&self, library: Arc) -> String { 93 | self.as_listitem().display_right(library) 94 | } 95 | 96 | fn play(&mut self, queue: Arc) { 97 | self.as_listitem().play(queue) 98 | } 99 | 100 | fn play_next(&mut self, queue: Arc) { 101 | self.as_listitem().play_next(queue) 102 | } 103 | 104 | fn queue(&mut self, queue: Arc) { 105 | self.as_listitem().queue(queue) 106 | } 107 | 108 | fn toggle_saved(&mut self, library: Arc) { 109 | self.as_listitem().toggle_saved(library) 110 | } 111 | 112 | fn save(&mut self, library: Arc) { 113 | self.as_listitem().save(library) 114 | } 115 | 116 | fn unsave(&mut self, library: Arc) { 117 | self.as_listitem().unsave(library) 118 | } 119 | 120 | fn open(&self, queue: Arc, library: Arc) -> Option> { 121 | self.as_listitem().open(queue, library) 122 | } 123 | 124 | fn share_url(&self) -> Option { 125 | self.as_listitem().share_url() 126 | } 127 | 128 | fn album(&self, queue: Arc) -> Option { 129 | self.as_listitem().album(queue) 130 | } 131 | 132 | fn artists(&self) -> Option> { 133 | self.as_listitem().artists() 134 | } 135 | 136 | fn track(&self) -> Option { 137 | self.as_listitem().track() 138 | } 139 | 140 | fn as_listitem(&self) -> Box { 141 | self.as_listitem() 142 | } 143 | } 144 | -------------------------------------------------------------------------------- /src/ui/artist.rs: -------------------------------------------------------------------------------- 1 | use std::sync::{Arc, RwLock}; 2 | use std::thread; 3 | 4 | use cursive::view::ViewWrapper; 5 | use cursive::Cursive; 6 | 7 | use crate::album::Album; 8 | use crate::artist::Artist; 9 | use crate::command::Command; 10 | use crate::commands::CommandResult; 11 | use crate::library::Library; 12 | use crate::queue::Queue; 13 | use crate::track::Track; 14 | use crate::traits::ViewExt; 15 | use crate::ui::listview::ListView; 16 | use crate::ui::tabview::TabView; 17 | use rspotify::senum::AlbumType; 18 | 19 | pub struct ArtistView { 20 | artist: Artist, 21 | tabs: TabView, 22 | } 23 | 24 | impl ArtistView { 25 | pub fn new(queue: Arc, library: Arc, artist: &Artist) -> Self { 26 | let spotify = queue.get_spotify(); 27 | 28 | let albums_view = 29 | Self::albums_view(&artist, AlbumType::Album, queue.clone(), library.clone()); 30 | let singles_view = 31 | Self::albums_view(&artist, AlbumType::Single, queue.clone(), library.clone()); 32 | 33 | let top_tracks: Arc>> = Arc::new(RwLock::new(Vec::new())); 34 | { 35 | let top_tracks = top_tracks.clone(); 36 | let spotify = spotify.clone(); 37 | let id = artist.id.clone(); 38 | let library = library.clone(); 39 | thread::spawn(move || { 40 | if let Some(id) = id { 41 | if let Some(tracks) = spotify.artist_top_tracks(&id) { 42 | top_tracks.write().unwrap().extend(tracks); 43 | library.trigger_redraw(); 44 | } 45 | } 46 | }); 47 | } 48 | 49 | let related: Arc>> = Arc::new(RwLock::new(Vec::new())); 50 | { 51 | let related = related.clone(); 52 | let id = artist.id.clone(); 53 | let library = library.clone(); 54 | thread::spawn(move || { 55 | if let Some(id) = id { 56 | if let Some(artists) = spotify.artist_related_artists(id) { 57 | related.write().unwrap().extend(artists); 58 | library.trigger_redraw(); 59 | } 60 | } 61 | }); 62 | } 63 | 64 | let mut tabs = TabView::new(); 65 | 66 | if let Some(tracks) = artist.tracks.as_ref() { 67 | let tracks = tracks.clone(); 68 | 69 | tabs.add_tab( 70 | "tracks", 71 | "Saved Tracks", 72 | ListView::new( 73 | Arc::new(RwLock::new(tracks)), 74 | queue.clone(), 75 | library.clone(), 76 | ), 77 | ); 78 | } 79 | 80 | tabs.add_tab( 81 | "top_tracks", 82 | "Top 10", 83 | ListView::new(top_tracks, queue.clone(), library.clone()), 84 | ); 85 | 86 | tabs.add_tab("albums", "Albums", albums_view); 87 | tabs.add_tab("singles", "Singles", singles_view); 88 | 89 | tabs.add_tab( 90 | "related", 91 | "Related Artists", 92 | ListView::new(related, queue, library), 93 | ); 94 | 95 | Self { 96 | artist: artist.clone(), 97 | tabs, 98 | } 99 | } 100 | 101 | fn albums_view( 102 | artist: &Artist, 103 | album_type: AlbumType, 104 | queue: Arc, 105 | library: Arc, 106 | ) -> ListView { 107 | if let Some(artist_id) = &artist.id { 108 | let spotify = queue.get_spotify(); 109 | let albums_page = spotify.artist_albums(artist_id, Some(album_type)); 110 | let view = ListView::new(albums_page.items.clone(), queue, library); 111 | albums_page.apply_pagination(view.get_pagination()); 112 | 113 | view 114 | } else { 115 | ListView::new(Arc::new(RwLock::new(Vec::new())), queue, library) 116 | } 117 | } 118 | } 119 | 120 | impl ViewWrapper for ArtistView { 121 | wrap_impl!(self.tabs: TabView); 122 | } 123 | 124 | impl ViewExt for ArtistView { 125 | fn title(&self) -> String { 126 | self.artist.name.clone() 127 | } 128 | 129 | fn on_command(&mut self, s: &mut Cursive, cmd: &Command) -> Result { 130 | self.tabs.on_command(s, cmd) 131 | } 132 | } 133 | -------------------------------------------------------------------------------- /src/ui/pagination.rs: -------------------------------------------------------------------------------- 1 | use crate::library::Library; 2 | use crate::traits::ListItem; 3 | use log::debug; 4 | use std::sync::{Arc, RwLock}; 5 | 6 | pub struct ApiPage { 7 | pub offset: u32, 8 | pub total: u32, 9 | pub items: Vec, 10 | } 11 | pub type FetchPageFn = dyn Fn(u32) -> Option> + Send + Sync; 12 | pub struct ApiResult { 13 | offset: Arc>, 14 | limit: u32, 15 | pub total: u32, 16 | pub items: Arc>>, 17 | fetch_page: Arc>, 18 | } 19 | 20 | impl ApiResult { 21 | pub fn new(limit: u32, fetch_page: Arc>) -> ApiResult { 22 | let items = Arc::new(RwLock::new(Vec::new())); 23 | if let Some(first_page) = fetch_page(0) { 24 | items.write().unwrap().extend(first_page.items); 25 | ApiResult { 26 | offset: Arc::new(RwLock::new(first_page.offset)), 27 | limit, 28 | total: first_page.total, 29 | items, 30 | fetch_page: fetch_page.clone(), 31 | } 32 | } else { 33 | ApiResult { 34 | offset: Arc::new(RwLock::new(0)), 35 | limit, 36 | total: 0, 37 | items, 38 | fetch_page: fetch_page.clone(), 39 | } 40 | } 41 | } 42 | 43 | fn offset(&self) -> u32 { 44 | *self.offset.read().unwrap() 45 | } 46 | 47 | pub fn at_end(&self) -> bool { 48 | (self.offset() + self.limit as u32) >= self.total 49 | } 50 | 51 | pub fn apply_pagination(self, pagination: &Pagination) { 52 | let total = self.total as usize; 53 | pagination.set( 54 | total, 55 | Box::new(move |_| { 56 | self.next(); 57 | }), 58 | ) 59 | } 60 | 61 | pub fn next(&self) -> Option> { 62 | let offset = self.offset() + self.limit as u32; 63 | debug!("fetching next page at offset {}", offset); 64 | if !self.at_end() { 65 | if let Some(next_page) = (self.fetch_page)(offset) { 66 | *self.offset.write().unwrap() = next_page.offset; 67 | self.items.write().unwrap().extend(next_page.items.clone()); 68 | Some(next_page.items) 69 | } else { 70 | None 71 | } 72 | } else { 73 | debug!("paginator is at end"); 74 | None 75 | } 76 | } 77 | } 78 | 79 | pub type Paginator = Box>>) + Send + Sync>; 80 | 81 | pub struct Pagination { 82 | max_content: Arc>>, 83 | callback: Arc>>>, 84 | busy: Arc>, 85 | } 86 | 87 | impl Default for Pagination { 88 | fn default() -> Self { 89 | Pagination { 90 | max_content: Arc::new(RwLock::new(None)), 91 | callback: Arc::new(RwLock::new(None)), 92 | busy: Arc::new(RwLock::new(false)), 93 | } 94 | } 95 | } 96 | 97 | // TODO: figure out why deriving Clone doesn't work 98 | impl Clone for Pagination { 99 | fn clone(&self) -> Self { 100 | Pagination { 101 | max_content: self.max_content.clone(), 102 | callback: self.callback.clone(), 103 | busy: self.busy.clone(), 104 | } 105 | } 106 | } 107 | 108 | impl Pagination { 109 | pub fn clear(&mut self) { 110 | *self.max_content.write().unwrap() = None; 111 | *self.callback.write().unwrap() = None; 112 | } 113 | pub fn set(&self, max_content: usize, callback: Paginator) { 114 | *self.max_content.write().unwrap() = Some(max_content); 115 | *self.callback.write().unwrap() = Some(callback); 116 | } 117 | 118 | pub fn max_content(&self) -> Option { 119 | *self.max_content.read().unwrap() 120 | } 121 | 122 | fn is_busy(&self) -> bool { 123 | *self.busy.read().unwrap() 124 | } 125 | 126 | pub fn call(&self, content: &Arc>>, library: Arc) { 127 | let pagination = self.clone(); 128 | let content = content.clone(); 129 | if !self.is_busy() { 130 | *self.busy.write().unwrap() = true; 131 | std::thread::spawn(move || { 132 | let cb = pagination.callback.read().unwrap(); 133 | if let Some(ref cb) = *cb { 134 | debug!("calling paginator!"); 135 | cb(content); 136 | *pagination.busy.write().unwrap() = false; 137 | library.trigger_redraw(); 138 | } 139 | }); 140 | } 141 | } 142 | } 143 | -------------------------------------------------------------------------------- /src/show.rs: -------------------------------------------------------------------------------- 1 | use crate::episode::Episode; 2 | use crate::library::Library; 3 | use crate::playable::Playable; 4 | use crate::queue::Queue; 5 | use crate::spotify::Spotify; 6 | use crate::traits::{IntoBoxedViewExt, ListItem, ViewExt}; 7 | use crate::ui::show::ShowView; 8 | use rspotify::model::show::{FullShow, SimplifiedShow}; 9 | use std::fmt; 10 | use std::sync::Arc; 11 | 12 | #[derive(Clone, Deserialize, Serialize)] 13 | pub struct Show { 14 | pub id: String, 15 | pub uri: String, 16 | pub name: String, 17 | pub publisher: String, 18 | pub description: String, 19 | pub cover_url: Option, 20 | pub episodes: Option>, 21 | } 22 | 23 | impl Show { 24 | pub fn load_all_episodes(&mut self, spotify: Spotify) { 25 | if self.episodes.is_some() { 26 | return; 27 | } 28 | 29 | let episodes_result = spotify.show_episodes(&self.id); 30 | while !episodes_result.at_end() { 31 | episodes_result.next(); 32 | } 33 | 34 | let episodes = episodes_result.items.read().unwrap().clone(); 35 | self.episodes = Some(episodes); 36 | } 37 | } 38 | 39 | impl From<&SimplifiedShow> for Show { 40 | fn from(show: &SimplifiedShow) -> Self { 41 | Self { 42 | id: show.id.clone(), 43 | uri: show.uri.clone(), 44 | name: show.name.clone(), 45 | publisher: show.publisher.clone(), 46 | description: show.description.clone(), 47 | cover_url: show.images.get(0).map(|i| i.url.clone()), 48 | episodes: None, 49 | } 50 | } 51 | } 52 | 53 | impl From<&FullShow> for Show { 54 | fn from(show: &FullShow) -> Self { 55 | Self { 56 | id: show.id.clone(), 57 | uri: show.uri.clone(), 58 | name: show.name.clone(), 59 | publisher: show.publisher.clone(), 60 | description: show.description.clone(), 61 | cover_url: show.images.get(0).map(|i| i.url.clone()), 62 | episodes: None, 63 | } 64 | } 65 | } 66 | 67 | impl fmt::Display for Show { 68 | fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { 69 | write!(f, "{} - {}", self.publisher, self.name) 70 | } 71 | } 72 | 73 | impl ListItem for Show { 74 | fn is_playing(&self, _queue: Arc) -> bool { 75 | false 76 | } 77 | 78 | fn display_left(&self) -> String { 79 | format!("{}", self) 80 | } 81 | 82 | fn display_right(&self, library: Arc) -> String { 83 | let saved = if library.is_saved_show(self) { 84 | if library.cfg.values().use_nerdfont.unwrap_or(false) { 85 | "\u{f62b} " 86 | } else { 87 | "✓ " 88 | } 89 | } else { 90 | "" 91 | }; 92 | saved.to_owned() 93 | } 94 | 95 | fn play(&mut self, queue: Arc) { 96 | self.load_all_episodes(queue.get_spotify()); 97 | 98 | let playables = self 99 | .episodes 100 | .as_ref() 101 | .unwrap_or(&Vec::new()) 102 | .iter() 103 | .map(|ep| Playable::Episode(ep.clone())) 104 | .collect(); 105 | 106 | let index = queue.append_next(playables); 107 | queue.play(index, true, true); 108 | } 109 | 110 | fn play_next(&mut self, queue: Arc) { 111 | self.load_all_episodes(queue.get_spotify()); 112 | 113 | if let Some(episodes) = self.episodes.as_ref() { 114 | for ep in episodes.iter().rev() { 115 | queue.insert_after_current(Playable::Episode(ep.clone())); 116 | } 117 | } 118 | } 119 | 120 | fn queue(&mut self, queue: Arc) { 121 | self.load_all_episodes(queue.get_spotify()); 122 | 123 | for ep in self.episodes.as_ref().unwrap_or(&Vec::new()) { 124 | queue.append(Playable::Episode(ep.clone())); 125 | } 126 | } 127 | 128 | fn toggle_saved(&mut self, library: Arc) { 129 | if library.is_saved_show(self) { 130 | self.unsave(library); 131 | } else { 132 | self.save(library); 133 | } 134 | } 135 | 136 | fn save(&mut self, library: Arc) { 137 | library.save_show(self); 138 | } 139 | 140 | fn unsave(&mut self, library: Arc) { 141 | library.unsave_show(self); 142 | } 143 | 144 | fn open(&self, queue: Arc, library: Arc) -> Option> { 145 | Some(ShowView::new(queue, library, self).into_boxed_view_ext()) 146 | } 147 | 148 | fn share_url(&self) -> Option { 149 | Some(format!("https://open.spotify.com/show/{}", self.id)) 150 | } 151 | 152 | fn as_listitem(&self) -> Box { 153 | Box::new(self.clone()) 154 | } 155 | } 156 | -------------------------------------------------------------------------------- /rspotify.patch: -------------------------------------------------------------------------------- 1 | diff --git a/src/blocking/client.rs b/src/blocking/client.rs 2 | index c126f9d..bdc32c0 100644 3 | --- a/src/blocking/client.rs 4 | +++ b/src/blocking/client.rs 5 | @@ -33,6 +33,7 @@ use crate::model::search::SearchResult; 6 | use crate::model::show::{ 7 | FullEpisode, FullShow, SeveralEpisodes, SeversalSimplifiedShows, Show, SimplifiedEpisode, 8 | }; 9 | +use crate::model::madeforyou::MadeForXHub; 10 | use crate::model::track::{FullTrack, FullTracks, SavedTrack, SimplifiedTrack}; 11 | use crate::model::user::{PrivateUser, PublicUser}; 12 | use crate::senum::{ 13 | @@ -1448,6 +1449,45 @@ impl Spotify { 14 | let result = self.get(&url, &mut params)?; 15 | self.convert_result::(&result) 16 | } 17 | + 18 | + /// Get made for you hub 19 | + /// This isn't a documented endpoint so everything here is guesswork. 20 | + /// TODO: add proper documentation. 21 | + pub fn made_for_x>, L: Into>>( 22 | + &self, 23 | + timestamp: Option>, 24 | + content_limit: C, 25 | + limit: L, 26 | + country: Option, 27 | + locale: Option, 28 | + ) -> Result { 29 | + let mut params = HashMap::new(); 30 | + params.insert("content_limit".to_owned(), content_limit.into().unwrap_or(20).to_string()); 31 | + params.insert("limit".to_owned(), limit.into().unwrap_or(20).to_string()); 32 | + 33 | + if let Some(_locale) = locale { 34 | + params.insert("locale".to_owned(), _locale); 35 | + } 36 | + 37 | + if let Some(_country) = country { 38 | + params.insert("country".to_owned(), _country.as_str().to_owned()); 39 | + } 40 | + 41 | + if let Some(_timestamp) = timestamp { 42 | + params.insert("timestamp".to_owned(), _timestamp.to_rfc3339()); 43 | + 44 | + } 45 | + 46 | + // stuff that i think should be hardcoded. 47 | + params.insert("platform".to_owned(), "web".to_owned()); 48 | + params.insert("market".to_owned(), "from_token".to_owned()); 49 | + params.insert("types".to_owned(), "album,playlist,artist,show,station".to_owned()); 50 | + params.insert("image_style".to_owned(), "gradient_overlay".to_owned()); 51 | + 52 | + let result = self.get("views/made-for-x-hub", &mut params)?; 53 | + self.convert_result::(&result) 54 | + } 55 | + 56 | ///[get audio features](https://developer.spotify.com/web-api/get-audio-features/) 57 | ///Get audio features for a track 58 | ///- track - track URI, URL or ID 59 | diff --git a/src/model/madeforyou.rs b/src/model/madeforyou.rs 60 | new file mode 100644 61 | index 0000000..24c231c 62 | --- /dev/null 63 | +++ b/src/model/madeforyou.rs 64 | @@ -0,0 +1,25 @@ 65 | +// This is not documented and is all guessed from looking at the developer tools of my browser. 66 | + 67 | +use std::collections::HashMap; 68 | +use super::playlist::SimplifiedPlaylist; 69 | +use super::page::Page; 70 | +use crate::senum::Type; 71 | + 72 | +#[derive(Clone, Debug, Serialize, Deserialize)] 73 | +pub struct PlaylistGroup { 74 | + pub content: Page, 75 | + pub custom_fields: HashMap, 76 | + pub external_urls: Option>, 77 | + pub href: Option, 78 | + pub id: Option, 79 | + pub images: Vec, 80 | + pub name: String, 81 | + pub rendering: String, 82 | + pub tag_line: Option, 83 | + #[serde(rename = "type")] 84 | + pub _type: Type, 85 | +} 86 | + 87 | +pub type MadeForXHub = PlaylistGroup>; 88 | + 89 | + 90 | diff --git a/src/model/mod.rs b/src/model/mod.rs 91 | index e54cb4d..021ddde 100644 92 | --- a/src/model/mod.rs 93 | +++ b/src/model/mod.rs 94 | @@ -16,6 +16,7 @@ pub mod search; 95 | pub mod show; 96 | pub mod track; 97 | pub mod user; 98 | +pub mod madeforyou; 99 | 100 | #[derive(Clone, Debug, Serialize, Deserialize)] 101 | #[serde(untagged)] 102 | diff --git a/src/senum.rs b/src/senum.rs 103 | index c94c31c..705386e 100644 104 | --- a/src/senum.rs 105 | +++ b/src/senum.rs 106 | @@ -87,6 +87,7 @@ pub enum Type { 107 | User, 108 | Show, 109 | Episode, 110 | + View, 111 | } 112 | impl Type { 113 | pub fn as_str(&self) -> &str { 114 | @@ -98,6 +99,7 @@ impl Type { 115 | Type::User => "user", 116 | Type::Show => "show", 117 | Type::Episode => "episode", 118 | + Type::View => "view", 119 | } 120 | } 121 | } 122 | @@ -112,6 +114,7 @@ impl FromStr for Type { 123 | "user" => Ok(Type::User), 124 | "show" => Ok(Type::Show), 125 | "episode" => Ok(Type::Episode), 126 | + "view" => Ok(Type::View), 127 | _ => Err(Error::new(ErrorKind::NoEnum(s.to_owned()))), 128 | } 129 | } 130 | -------------------------------------------------------------------------------- /src/ui/tabview.rs: -------------------------------------------------------------------------------- 1 | use std::cmp::{max, min}; 2 | use std::collections::HashMap; 3 | 4 | use cursive::align::HAlign; 5 | use cursive::event::{Event, EventResult}; 6 | use cursive::theme::{ColorStyle, ColorType, PaletteColor}; 7 | use cursive::traits::View; 8 | use cursive::{Cursive, Printer, Vec2}; 9 | use unicode_width::UnicodeWidthStr; 10 | 11 | use crate::command::{Command, MoveAmount, MoveMode}; 12 | use crate::commands::CommandResult; 13 | use crate::traits::{IntoBoxedViewExt, ViewExt}; 14 | 15 | pub struct Tab { 16 | title: String, 17 | view: Box, 18 | } 19 | 20 | pub struct TabView { 21 | tabs: Vec, 22 | ids: HashMap, 23 | selected: usize, 24 | } 25 | 26 | impl TabView { 27 | pub fn new() -> Self { 28 | Self { 29 | tabs: Vec::new(), 30 | ids: HashMap::new(), 31 | selected: 0, 32 | } 33 | } 34 | 35 | pub fn add_tab, V: IntoBoxedViewExt>(&mut self, id: S, title: S, view: V) { 36 | let tab = Tab { 37 | title: title.into(), 38 | view: view.into_boxed_view_ext(), 39 | }; 40 | self.tabs.push(tab); 41 | self.ids.insert(id.into(), self.tabs.len() - 1); 42 | } 43 | 44 | pub fn tab, V: IntoBoxedViewExt>(mut self, id: S, title: S, view: V) -> Self { 45 | self.add_tab(id, title, view); 46 | self 47 | } 48 | 49 | pub fn move_focus_to(&mut self, target: usize) { 50 | let len = self.tabs.len().saturating_sub(1); 51 | self.selected = min(target, len); 52 | 53 | if let Some(tab) = self.tabs.get(self.selected) { 54 | tab.view.on_arrive(); 55 | } 56 | } 57 | 58 | pub fn move_focus(&mut self, delta: i32) { 59 | let new = self.selected as i32 + delta; 60 | self.move_focus_to(max(new, 0) as usize); 61 | } 62 | } 63 | 64 | impl View for TabView { 65 | fn draw(&self, printer: &Printer<'_, '_>) { 66 | if self.tabs.is_empty() { 67 | return; 68 | } 69 | 70 | let tabwidth = printer.size.x / self.tabs.len(); 71 | for (i, tab) in self.tabs.iter().enumerate() { 72 | let style = if self.selected == i { 73 | ColorStyle::new( 74 | ColorType::Palette(PaletteColor::Tertiary), 75 | ColorType::Palette(PaletteColor::Highlight), 76 | ) 77 | } else { 78 | ColorStyle::primary() 79 | }; 80 | 81 | let mut width = tabwidth; 82 | if i == self.tabs.len() - 1 { 83 | width += printer.size.x % self.tabs.len(); 84 | } 85 | 86 | let offset = HAlign::Center.get_offset(tab.title.width(), width); 87 | 88 | printer.with_color(style, |printer| { 89 | printer.print_hline((i * tabwidth, 0), width, " "); 90 | printer.print((i * tabwidth + offset, 0), &tab.title); 91 | }); 92 | } 93 | 94 | if let Some(tab) = self.tabs.get(self.selected) { 95 | let printer = printer 96 | .offset((0, 1)) 97 | .cropped((printer.size.x, printer.size.y - 1)); 98 | 99 | tab.view.draw(&printer); 100 | } 101 | } 102 | 103 | fn layout(&mut self, size: Vec2) { 104 | if let Some(tab) = self.tabs.get_mut(self.selected) { 105 | tab.view.layout(Vec2::new(size.x, size.y - 1)); 106 | } 107 | } 108 | 109 | fn on_event(&mut self, event: Event) -> EventResult { 110 | if let Some(tab) = self.tabs.get_mut(self.selected) { 111 | tab.view.on_event(event) 112 | } else { 113 | EventResult::Ignored 114 | } 115 | } 116 | } 117 | 118 | impl ViewExt for TabView { 119 | fn on_arrive(&self) { 120 | if let Some(tab) = self.tabs.get(self.selected) { 121 | tab.view.on_arrive(); 122 | } 123 | } 124 | 125 | fn on_command(&mut self, s: &mut Cursive, cmd: &Command) -> Result { 126 | if let Command::Move(mode, amount) = cmd { 127 | let last_idx = self.tabs.len() - 1; 128 | 129 | match mode { 130 | MoveMode::Left if self.selected > 0 => { 131 | match amount { 132 | MoveAmount::Extreme => self.move_focus_to(0), 133 | MoveAmount::Integer(amount) => self.move_focus(-(*amount)), 134 | } 135 | return Ok(CommandResult::Consumed(None)); 136 | } 137 | MoveMode::Right if self.selected < last_idx => { 138 | match amount { 139 | MoveAmount::Extreme => self.move_focus_to(last_idx), 140 | MoveAmount::Integer(amount) => self.move_focus(*amount), 141 | } 142 | return Ok(CommandResult::Consumed(None)); 143 | } 144 | _ => {} 145 | } 146 | } 147 | 148 | if let Some(tab) = self.tabs.get_mut(self.selected) { 149 | tab.view.on_command(s, cmd) 150 | } else { 151 | Ok(CommandResult::Ignored) 152 | } 153 | } 154 | } 155 | -------------------------------------------------------------------------------- /src/artist.rs: -------------------------------------------------------------------------------- 1 | use std::fmt; 2 | use std::sync::Arc; 3 | 4 | use rspotify::model::artist::{FullArtist, SimplifiedArtist}; 5 | 6 | use crate::library::Library; 7 | use crate::playable::Playable; 8 | use crate::queue::Queue; 9 | use crate::spotify::Spotify; 10 | use crate::track::Track; 11 | use crate::traits::{IntoBoxedViewExt, ListItem, ViewExt}; 12 | use crate::ui::artist::ArtistView; 13 | 14 | #[derive(Clone, Deserialize, Serialize)] 15 | pub struct Artist { 16 | pub id: Option, 17 | pub name: String, 18 | pub url: Option, 19 | pub tracks: Option>, 20 | pub is_followed: bool, 21 | } 22 | 23 | impl Artist { 24 | pub fn new(id: String, name: String) -> Self { 25 | Self { 26 | id: Some(id), 27 | name, 28 | url: None, 29 | tracks: None, 30 | is_followed: false, 31 | } 32 | } 33 | 34 | fn load_top_tracks(&mut self, spotify: Spotify) { 35 | if let Some(artist_id) = &self.id { 36 | if self.tracks.is_none() { 37 | self.tracks = spotify.artist_top_tracks(artist_id); 38 | } 39 | } 40 | } 41 | } 42 | 43 | impl From<&SimplifiedArtist> for Artist { 44 | fn from(sa: &SimplifiedArtist) -> Self { 45 | Self { 46 | id: sa.id.clone(), 47 | name: sa.name.clone(), 48 | url: sa.uri.clone(), 49 | tracks: None, 50 | is_followed: false, 51 | } 52 | } 53 | } 54 | 55 | impl From<&FullArtist> for Artist { 56 | fn from(fa: &FullArtist) -> Self { 57 | Self { 58 | id: Some(fa.id.clone()), 59 | name: fa.name.clone(), 60 | url: Some(fa.uri.clone()), 61 | tracks: None, 62 | is_followed: false, 63 | } 64 | } 65 | } 66 | 67 | impl fmt::Display for Artist { 68 | fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { 69 | write!(f, "{}", self.name) 70 | } 71 | } 72 | 73 | impl fmt::Debug for Artist { 74 | fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { 75 | write!(f, "{} ({:?})", self.name, self.id) 76 | } 77 | } 78 | 79 | impl ListItem for Artist { 80 | fn is_playing(&self, queue: Arc) -> bool { 81 | if let Some(tracks) = &self.tracks { 82 | let playing: Vec = queue 83 | .queue 84 | .read() 85 | .unwrap() 86 | .iter() 87 | .filter_map(|t| t.id()) 88 | .collect(); 89 | let ids: Vec = tracks.iter().filter_map(|t| t.id.clone()).collect(); 90 | !ids.is_empty() && playing == ids 91 | } else { 92 | false 93 | } 94 | } 95 | 96 | fn as_listitem(&self) -> Box { 97 | Box::new(self.clone()) 98 | } 99 | 100 | fn display_left(&self) -> String { 101 | format!("{}", self) 102 | } 103 | 104 | fn display_right(&self, library: Arc) -> String { 105 | let followed = if library.is_followed_artist(self) { 106 | if library.cfg.values().use_nerdfont.unwrap_or(false) { 107 | "\u{f62b} " 108 | } else { 109 | "✓ " 110 | } 111 | } else { 112 | "" 113 | }; 114 | 115 | let tracks = if let Some(tracks) = self.tracks.as_ref() { 116 | format!("{:>3} saved tracks", tracks.len()) 117 | } else { 118 | "".into() 119 | }; 120 | 121 | format!("{}{}", followed, tracks) 122 | } 123 | 124 | fn play(&mut self, queue: Arc) { 125 | self.load_top_tracks(queue.get_spotify()); 126 | 127 | if let Some(tracks) = self.tracks.as_ref() { 128 | let tracks: Vec = tracks 129 | .iter() 130 | .map(|track| Playable::Track(track.clone())) 131 | .collect(); 132 | let index = queue.append_next(tracks); 133 | queue.play(index, true, true); 134 | } 135 | } 136 | 137 | fn play_next(&mut self, queue: Arc) { 138 | self.load_top_tracks(queue.get_spotify()); 139 | 140 | if let Some(tracks) = self.tracks.as_ref() { 141 | for t in tracks.iter().rev() { 142 | queue.insert_after_current(Playable::Track(t.clone())); 143 | } 144 | } 145 | } 146 | 147 | fn queue(&mut self, queue: Arc) { 148 | self.load_top_tracks(queue.get_spotify()); 149 | 150 | if let Some(tracks) = &self.tracks { 151 | for t in tracks { 152 | queue.append(Playable::Track(t.clone())); 153 | } 154 | } 155 | } 156 | 157 | fn save(&mut self, library: Arc) { 158 | library.follow_artist(self); 159 | } 160 | 161 | fn unsave(&mut self, library: Arc) { 162 | library.unfollow_artist(self); 163 | } 164 | 165 | fn toggle_saved(&mut self, library: Arc) { 166 | if library.is_followed_artist(self) { 167 | library.unfollow_artist(self); 168 | } else { 169 | library.follow_artist(self); 170 | } 171 | } 172 | 173 | fn open(&self, queue: Arc, library: Arc) -> Option> { 174 | Some(ArtistView::new(queue, library, self).into_boxed_view_ext()) 175 | } 176 | 177 | fn share_url(&self) -> Option { 178 | self.id 179 | .clone() 180 | .map(|id| format!("https://open.spotify.com/artist/{}", id)) 181 | } 182 | } 183 | -------------------------------------------------------------------------------- /src/authentication.rs: -------------------------------------------------------------------------------- 1 | use cursive::traits::Boxable; 2 | use cursive::view::Identifiable; 3 | use cursive::views::*; 4 | use cursive::{CbSink, Cursive, CursiveExt}; 5 | 6 | use librespot_core::authentication::Credentials as RespotCredentials; 7 | use librespot_protocol::authentication::AuthenticationType; 8 | 9 | pub fn create_credentials() -> Result { 10 | let mut login_cursive = Cursive::default(); 11 | let info_buf = TextContent::new("Please login to Spotify\n"); 12 | let info_view = Dialog::around(TextView::new_with_content(info_buf)) 13 | .button("Login", move |s| { 14 | let login_view = Dialog::new() 15 | .title("Spotify login") 16 | .content( 17 | ListView::new() 18 | .child( 19 | "Username", 20 | EditView::new().with_name("spotify_user").fixed_width(18), 21 | ) 22 | .child( 23 | "Password", 24 | EditView::new() 25 | .secret() 26 | .with_name("spotify_password") 27 | .fixed_width(18), 28 | ), 29 | ) 30 | .button("Login", |s| { 31 | let username = s 32 | .call_on_name("spotify_user", |view: &mut EditView| view.get_content()) 33 | .unwrap() 34 | .to_string(); 35 | let auth_data = s 36 | .call_on_name("spotify_password", |view: &mut EditView| view.get_content()) 37 | .unwrap() 38 | .to_string() 39 | .as_bytes() 40 | .to_vec(); 41 | s.set_user_data::>(Ok(RespotCredentials { 42 | username, 43 | auth_type: AuthenticationType::AUTHENTICATION_USER_PASS, 44 | auth_data, 45 | })); 46 | s.quit(); 47 | }) 48 | .button("Quit", Cursive::quit); 49 | s.pop_layer(); 50 | s.add_layer(login_view); 51 | }) 52 | .button("Login with Facebook", |s| { 53 | let urls: std::collections::HashMap = 54 | reqwest::blocking::get("https://login2.spotify.com/v1/config") 55 | .expect("didn't connect") 56 | .json() 57 | .expect("didn't parse"); 58 | // not a dialog to let people copy & paste the URL 59 | let url_notice = TextView::new(format!("Browse to {}", &urls["login_url"])); 60 | 61 | let controls = Button::new("Quit", Cursive::quit); 62 | 63 | let login_view = LinearLayout::new(cursive::direction::Orientation::Vertical) 64 | .child(url_notice) 65 | .child(controls); 66 | let url = &urls["login_url"]; 67 | webbrowser::open(url).ok(); 68 | auth_poller(&urls["credentials_url"], &s.cb_sink()); 69 | s.pop_layer(); 70 | s.add_layer(login_view) 71 | }) 72 | .button("Quit", Cursive::quit); 73 | 74 | login_cursive.add_layer(info_view); 75 | login_cursive.run(); 76 | 77 | login_cursive 78 | .user_data() 79 | .cloned() 80 | .unwrap_or_else(|| Err("Didn't obtain any credentials".to_string())) 81 | } 82 | 83 | // TODO: better with futures? 84 | fn auth_poller(url: &str, app_sink: &CbSink) { 85 | let app_sink = app_sink.clone(); 86 | let url = url.to_string(); 87 | std::thread::spawn(move || { 88 | let timeout = std::time::Duration::from_secs(5 * 60); 89 | let start_time = std::time::SystemTime::now(); 90 | while std::time::SystemTime::now() 91 | .duration_since(start_time) 92 | .unwrap_or(timeout) 93 | < timeout 94 | { 95 | if let Ok(response) = reqwest::blocking::get(&url) { 96 | if response.status() != reqwest::StatusCode::ACCEPTED { 97 | let result = match response.status() { 98 | reqwest::StatusCode::OK => { 99 | let creds = response 100 | .json::() 101 | .expect("Unable to parse") 102 | .credentials; 103 | Ok(creds) 104 | } 105 | 106 | _ => Err(format!( 107 | "Facebook auth failed with code {}: {}", 108 | response.status(), 109 | response.text().unwrap() 110 | )), 111 | }; 112 | app_sink 113 | .send(Box::new(|s: &mut Cursive| { 114 | s.set_user_data(result); 115 | s.quit(); 116 | })) 117 | .unwrap(); 118 | return; 119 | } 120 | } 121 | std::thread::sleep(std::time::Duration::from_millis(1000)); 122 | } 123 | 124 | app_sink 125 | .send(Box::new(|s: &mut Cursive| { 126 | s.set_user_data::>(Err( 127 | "Timed out authenticating".to_string(), 128 | )); 129 | s.quit(); 130 | })) 131 | .unwrap(); 132 | }); 133 | } 134 | 135 | #[derive(Serialize, Deserialize, Debug)] 136 | pub struct AuthResponse { 137 | pub credentials: RespotCredentials, 138 | pub error: Option, 139 | } 140 | -------------------------------------------------------------------------------- /src/ui/queue.rs: -------------------------------------------------------------------------------- 1 | use cursive::traits::{Boxable, Identifiable}; 2 | use cursive::view::{Margins, ViewWrapper}; 3 | use cursive::views::{Dialog, EditView, ScrollView, SelectView}; 4 | use cursive::Cursive; 5 | 6 | use std::cmp::min; 7 | use std::sync::Arc; 8 | 9 | use crate::command::{Command, MoveMode, ShiftMode}; 10 | use crate::commands::CommandResult; 11 | use crate::library::Library; 12 | use crate::playable::Playable; 13 | use crate::queue::Queue; 14 | use crate::traits::ViewExt; 15 | use crate::ui::listview::ListView; 16 | use crate::ui::modal::Modal; 17 | 18 | pub struct QueueView { 19 | list: ListView, 20 | library: Arc, 21 | queue: Arc, 22 | } 23 | 24 | impl QueueView { 25 | pub fn new(queue: Arc, library: Arc) -> QueueView { 26 | let list = ListView::new(queue.queue.clone(), queue.clone(), library.clone()); 27 | 28 | QueueView { 29 | list, 30 | library, 31 | queue, 32 | } 33 | } 34 | 35 | fn save_dialog_cb( 36 | s: &mut Cursive, 37 | queue: Arc, 38 | library: Arc, 39 | id: Option, 40 | ) { 41 | let tracks = queue.queue.read().unwrap().clone(); 42 | match id { 43 | Some(id) => { 44 | library.overwrite_playlist(&id, &tracks); 45 | s.pop_layer(); 46 | } 47 | None => { 48 | s.pop_layer(); 49 | let edit = EditView::new() 50 | .on_submit(move |s: &mut Cursive, name| { 51 | library.save_playlist(name, &tracks); 52 | s.pop_layer(); 53 | }) 54 | .with_name("name") 55 | .fixed_width(20); 56 | let dialog = Dialog::new() 57 | .title("Enter name") 58 | .dismiss_button("Cancel") 59 | .padding(Margins::lrtb(1, 1, 1, 0)) 60 | .content(edit); 61 | s.add_layer(Modal::new(dialog)); 62 | } 63 | } 64 | } 65 | 66 | fn save_dialog(queue: Arc, library: Arc) -> Modal { 67 | let mut list_select: SelectView> = SelectView::new().autojump(); 68 | list_select.add_item("[Create new]", None); 69 | 70 | for list in library.playlists().iter() { 71 | list_select.add_item(list.name.clone(), Some(list.id.clone())); 72 | } 73 | 74 | list_select.set_on_submit(move |s, selected| { 75 | Self::save_dialog_cb(s, queue.clone(), library.clone(), selected.clone()) 76 | }); 77 | 78 | let dialog = Dialog::new() 79 | .title("Create new or overwrite existing playlist?") 80 | .dismiss_button("Cancel") 81 | .padding(Margins::lrtb(1, 1, 1, 0)) 82 | .content(ScrollView::new(list_select)); 83 | Modal::new(dialog) 84 | } 85 | } 86 | 87 | impl ViewWrapper for QueueView { 88 | wrap_impl!(self.list: ListView); 89 | } 90 | 91 | impl ViewExt for QueueView { 92 | fn title(&self) -> String { 93 | "Queue".to_string() 94 | } 95 | 96 | fn title_sub(&self) -> String { 97 | let track_count = self.queue.len(); 98 | let duration_secs: u64 = self 99 | .queue 100 | .queue 101 | .read() 102 | .unwrap() 103 | .iter() 104 | .map(|p| p.duration() as u64 / 1000) 105 | .sum(); 106 | 107 | if duration_secs > 0 { 108 | let duration = std::time::Duration::from_secs(duration_secs); 109 | format!( 110 | "{} tracks, {}", 111 | track_count, 112 | crate::utils::format_duration(&duration) 113 | ) 114 | } else { 115 | "".to_string() 116 | } 117 | } 118 | 119 | fn on_arrive(&self) { 120 | self.list.on_arrive(); 121 | } 122 | 123 | fn on_command(&mut self, s: &mut Cursive, cmd: &Command) -> Result { 124 | match cmd { 125 | Command::Play => { 126 | self.queue.play(self.list.get_selected_index(), true, false); 127 | return Ok(CommandResult::Consumed(None)); 128 | } 129 | Command::PlayNext => { 130 | return Ok(CommandResult::Ignored); 131 | } 132 | Command::Queue => { 133 | return Ok(CommandResult::Ignored); 134 | } 135 | Command::Delete => { 136 | let selected = self.list.get_selected_index(); 137 | let len = self.queue.len(); 138 | 139 | self.queue.remove(selected); 140 | if selected == len.saturating_sub(1) { 141 | self.list.move_focus(-1); 142 | } 143 | return Ok(CommandResult::Consumed(None)); 144 | } 145 | Command::Shift(mode, amount) => { 146 | let amount = match amount { 147 | Some(amount) => *amount, 148 | _ => 1, 149 | }; 150 | 151 | let selected = self.list.get_selected_index(); 152 | let len = self.queue.len(); 153 | 154 | match mode { 155 | ShiftMode::Up if selected > 0 => { 156 | self.queue 157 | .shift(selected, (selected as i32).saturating_sub(amount) as usize); 158 | self.list.move_focus(-(amount as i32)); 159 | return Ok(CommandResult::Consumed(None)); 160 | } 161 | ShiftMode::Down if selected < len.saturating_sub(1) => { 162 | self.queue 163 | .shift(selected, min(selected + amount as usize, len - 1)); 164 | self.list.move_focus(amount as i32); 165 | return Ok(CommandResult::Consumed(None)); 166 | } 167 | _ => {} 168 | } 169 | } 170 | Command::SaveQueue => { 171 | let dialog = Self::save_dialog(self.queue.clone(), self.library.clone()); 172 | s.add_layer(dialog); 173 | return Ok(CommandResult::Consumed(None)); 174 | } 175 | Command::Move(MoveMode::Playing, _) => { 176 | if let Some(playing) = self.queue.get_current_index() { 177 | self.list.move_focus_to(playing); 178 | } 179 | } 180 | _ => {} 181 | } 182 | 183 | self.with_view_mut(move |v| v.on_command(s, cmd)).unwrap() 184 | } 185 | } 186 | -------------------------------------------------------------------------------- /src/config.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashMap; 2 | use std::path::PathBuf; 3 | use std::sync::{RwLock, RwLockReadGuard, RwLockWriteGuard}; 4 | use std::{fs, process}; 5 | 6 | use cursive::theme::Theme; 7 | use log::{debug, error}; 8 | use platform_dirs::AppDirs; 9 | 10 | use crate::command::{SortDirection, SortKey}; 11 | use crate::playable::Playable; 12 | use crate::queue; 13 | use crate::serialization::{Serializer, CBOR, TOML}; 14 | 15 | pub const CLIENT_ID: &str = "d420a117a32841c2b3474932e49fb54b"; 16 | 17 | #[derive(Clone, Serialize, Deserialize, Debug, Default)] 18 | pub struct ConfigValues { 19 | pub command_key: Option, 20 | pub default_keybindings: Option, 21 | pub keybindings: Option>, 22 | pub theme: Option, 23 | pub use_nerdfont: Option, 24 | pub flip_status_indicators: Option, 25 | pub audio_cache: Option, 26 | pub audio_cache_size: Option, 27 | pub backend: Option, 28 | pub backend_device: Option, 29 | pub volnorm: Option, 30 | pub volnorm_pregain: Option, 31 | pub notify: Option, 32 | pub bitrate: Option, 33 | pub album_column: Option, 34 | pub autoplay: Option, 35 | pub gapless: Option, 36 | pub shuffle: Option, 37 | pub repeat: Option, 38 | pub cover_max_scale: Option, 39 | pub highlight_fadeout: Option, 40 | } 41 | 42 | #[derive(Serialize, Deserialize, Debug, Default, Clone)] 43 | pub struct ConfigTheme { 44 | pub background: Option, 45 | pub primary: Option, 46 | pub secondary: Option, 47 | pub title: Option, 48 | pub playing: Option, 49 | pub playing_selected: Option, 50 | pub playing_bg: Option, 51 | pub highlight: Option, 52 | pub highlight_bg: Option, 53 | pub error: Option, 54 | pub error_bg: Option, 55 | pub statusbar_progress: Option, 56 | pub statusbar_progress_bg: Option, 57 | pub statusbar: Option, 58 | pub statusbar_bg: Option, 59 | pub cmdline: Option, 60 | pub cmdline_bg: Option, 61 | pub search_match: Option, 62 | } 63 | 64 | #[derive(Serialize, Deserialize, Debug, Clone)] 65 | pub struct SortingOrder { 66 | pub key: SortKey, 67 | pub direction: SortDirection, 68 | } 69 | 70 | #[derive(Serialize, Default, Deserialize, Debug, Clone)] 71 | pub struct QueueState { 72 | pub current_track: Option, 73 | pub random_order: Option>, 74 | pub track_progress: std::time::Duration, 75 | pub queue: Vec, 76 | } 77 | 78 | #[derive(Serialize, Deserialize, Debug, Clone)] 79 | pub struct UserState { 80 | pub volume: u16, 81 | pub shuffle: bool, 82 | pub repeat: queue::RepeatSetting, 83 | pub queuestate: QueueState, 84 | pub playlist_orders: HashMap, 85 | } 86 | 87 | impl Default for UserState { 88 | fn default() -> Self { 89 | UserState { 90 | volume: u16::max_value(), 91 | shuffle: false, 92 | repeat: queue::RepeatSetting::None, 93 | queuestate: QueueState::default(), 94 | playlist_orders: HashMap::new(), 95 | } 96 | } 97 | } 98 | 99 | lazy_static! { 100 | pub static ref BASE_PATH: RwLock> = RwLock::new(None); 101 | } 102 | 103 | pub struct Config { 104 | filename: String, 105 | values: RwLock, 106 | state: RwLock, 107 | } 108 | 109 | impl Config { 110 | pub fn new(filename: &str) -> Self { 111 | let values = load(filename).unwrap_or_else(|e| { 112 | eprintln!("could not load config: {}", e); 113 | process::exit(1); 114 | }); 115 | 116 | let mut userstate = { 117 | let path = config_path("userstate.cbor"); 118 | CBOR.load_or_generate_default(path, || Ok(UserState::default()), true) 119 | .expect("could not load user state") 120 | }; 121 | 122 | if let Some(shuffle) = values.shuffle { 123 | userstate.shuffle = shuffle; 124 | } 125 | 126 | if let Some(repeat) = values.repeat { 127 | userstate.repeat = repeat; 128 | } 129 | 130 | Self { 131 | filename: filename.to_string(), 132 | values: RwLock::new(values), 133 | state: RwLock::new(userstate), 134 | } 135 | } 136 | 137 | pub fn values(&self) -> RwLockReadGuard { 138 | self.values.read().expect("can't readlock config values") 139 | } 140 | 141 | pub fn state(&self) -> RwLockReadGuard { 142 | self.state.read().expect("can't readlock user state") 143 | } 144 | 145 | pub fn with_state_mut(&self, cb: F) 146 | where 147 | F: Fn(RwLockWriteGuard), 148 | { 149 | let state_guard = self.state.write().expect("can't writelock user state"); 150 | cb(state_guard); 151 | } 152 | 153 | pub fn save_state(&self) { 154 | let path = config_path("userstate.cbor"); 155 | debug!("saving user state to {}", path.display()); 156 | if let Err(e) = CBOR.write(path, self.state().clone()) { 157 | error!("Could not save user state: {}", e); 158 | } 159 | } 160 | 161 | pub fn build_theme(&self) -> Theme { 162 | let theme = &self.values().theme; 163 | crate::theme::load(theme) 164 | } 165 | 166 | pub fn reload(&self) { 167 | let cfg = load(&self.filename).expect("could not reload config"); 168 | *self.values.write().expect("can't writelock config values") = cfg 169 | } 170 | } 171 | 172 | fn load(filename: &str) -> Result { 173 | let path = config_path(filename); 174 | TOML.load_or_generate_default(path, || Ok(ConfigValues::default()), false) 175 | } 176 | 177 | fn proj_dirs() -> AppDirs { 178 | match *BASE_PATH.read().expect("can't readlock BASE_PATH") { 179 | Some(ref basepath) => AppDirs { 180 | cache_dir: basepath.join(".cache"), 181 | config_dir: basepath.join(".config"), 182 | data_dir: basepath.join(".local/share"), 183 | state_dir: basepath.join(".local/state"), 184 | }, 185 | None => AppDirs::new(Some("ncspot"), true).expect("can't determine project paths"), 186 | } 187 | } 188 | 189 | pub fn config_path(file: &str) -> PathBuf { 190 | let proj_dirs = proj_dirs(); 191 | let cfg_dir = &proj_dirs.config_dir; 192 | if cfg_dir.exists() && !cfg_dir.is_dir() { 193 | fs::remove_file(cfg_dir).expect("unable to remove old config file"); 194 | } 195 | if !cfg_dir.exists() { 196 | fs::create_dir_all(cfg_dir).expect("can't create config folder"); 197 | } 198 | let mut cfg = cfg_dir.to_path_buf(); 199 | cfg.push(file); 200 | cfg 201 | } 202 | 203 | pub fn cache_path(file: &str) -> PathBuf { 204 | let proj_dirs = proj_dirs(); 205 | let cache_dir = &proj_dirs.cache_dir; 206 | if !cache_dir.exists() { 207 | fs::create_dir_all(cache_dir).expect("can't create cache folder"); 208 | } 209 | let mut pb = cache_dir.to_path_buf(); 210 | pb.push(file); 211 | pb 212 | } 213 | -------------------------------------------------------------------------------- /src/ui/statusbar.rs: -------------------------------------------------------------------------------- 1 | use std::sync::Arc; 2 | 3 | use cursive::align::HAlign; 4 | use cursive::event::{Event, EventResult, MouseButton, MouseEvent}; 5 | use cursive::theme::{ColorStyle, ColorType, PaletteColor}; 6 | use cursive::traits::View; 7 | use cursive::vec::Vec2; 8 | use cursive::Printer; 9 | use unicode_width::UnicodeWidthStr; 10 | 11 | use crate::library::Library; 12 | use crate::queue::{Queue, RepeatSetting}; 13 | use crate::spotify::{PlayerEvent, Spotify}; 14 | 15 | pub struct StatusBar { 16 | queue: Arc, 17 | spotify: Spotify, 18 | library: Arc, 19 | last_size: Vec2, 20 | } 21 | 22 | impl StatusBar { 23 | pub fn new(queue: Arc, library: Arc) -> StatusBar { 24 | let spotify = queue.get_spotify(); 25 | 26 | StatusBar { 27 | queue, 28 | spotify, 29 | library, 30 | last_size: Vec2::new(0, 0), 31 | } 32 | } 33 | 34 | fn use_nerdfont(&self) -> bool { 35 | self.library.cfg.values().use_nerdfont.unwrap_or(false) 36 | } 37 | 38 | fn playback_indicator(&self) -> &str { 39 | let status = self.spotify.get_current_status(); 40 | let nerdfont = self.use_nerdfont(); 41 | let flipped = self 42 | .library 43 | .cfg 44 | .values() 45 | .flip_status_indicators 46 | .unwrap_or(false); 47 | 48 | const NF_PLAY: &str = "\u{f909} "; 49 | const NF_PAUSE: &str = "\u{f8e3} "; 50 | const NF_STOP: &str = "\u{f9da} "; 51 | let indicators = match (nerdfont, flipped) { 52 | (false, false) => ("▶ ", "▮▮", "◼ "), 53 | (false, true) => ("▮▮", "▶ ", "▶ "), 54 | (true, false) => (NF_PLAY, NF_PAUSE, NF_STOP), 55 | (true, true) => (NF_PAUSE, NF_PLAY, NF_PLAY), 56 | }; 57 | 58 | match status { 59 | PlayerEvent::Playing(_) => indicators.0, 60 | PlayerEvent::Paused(_) => indicators.1, 61 | PlayerEvent::Stopped | PlayerEvent::FinishedTrack => indicators.2, 62 | } 63 | } 64 | } 65 | 66 | impl View for StatusBar { 67 | fn draw(&self, printer: &Printer<'_, '_>) { 68 | if printer.size.x == 0 { 69 | return; 70 | } 71 | 72 | let style_bar = ColorStyle::new( 73 | ColorType::Color(*printer.theme.palette.custom("statusbar_progress").unwrap()), 74 | ColorType::Palette(PaletteColor::Background), 75 | ); 76 | let style_bar_bg = ColorStyle::new( 77 | ColorType::Color( 78 | *printer 79 | .theme 80 | .palette 81 | .custom("statusbar_progress_bg") 82 | .unwrap(), 83 | ), 84 | ColorType::Palette(PaletteColor::Background), 85 | ); 86 | let style = ColorStyle::new( 87 | ColorType::Color(*printer.theme.palette.custom("statusbar").unwrap()), 88 | ColorType::Color(*printer.theme.palette.custom("statusbar_bg").unwrap()), 89 | ); 90 | 91 | printer.print( 92 | (0, 0), 93 | &vec![' '; printer.size.x].into_iter().collect::(), 94 | ); 95 | printer.with_color(style, |printer| { 96 | printer.print( 97 | (0, 1), 98 | &vec![' '; printer.size.x].into_iter().collect::(), 99 | ); 100 | }); 101 | 102 | printer.with_color(style, |printer| { 103 | printer.print((1, 1), self.playback_indicator()); 104 | }); 105 | 106 | let updating = if !*self.library.is_done.read().unwrap() { 107 | if self.use_nerdfont() { 108 | "\u{f9e5} " 109 | } else { 110 | "[U] " 111 | } 112 | } else { 113 | "" 114 | }; 115 | 116 | let repeat = if self.use_nerdfont() { 117 | match self.queue.get_repeat() { 118 | RepeatSetting::None => "", 119 | RepeatSetting::RepeatPlaylist => "\u{f955} ", 120 | RepeatSetting::RepeatTrack => "\u{f957} ", 121 | } 122 | } else { 123 | match self.queue.get_repeat() { 124 | RepeatSetting::None => "", 125 | RepeatSetting::RepeatPlaylist => "[R] ", 126 | RepeatSetting::RepeatTrack => "[R1] ", 127 | } 128 | }; 129 | 130 | let shuffle = if self.queue.get_shuffle() { 131 | if self.use_nerdfont() { 132 | "\u{f99c} " 133 | } else { 134 | "[Z] " 135 | } 136 | } else { 137 | "" 138 | }; 139 | 140 | let volume = format!( 141 | " [{}%]", 142 | (self.spotify.volume() as f64 / 65535_f64 * 100.0).round() as u16 143 | ); 144 | 145 | printer.with_color(style_bar_bg, |printer| { 146 | printer.print((0, 0), &"┉".repeat(printer.size.x)); 147 | }); 148 | 149 | let elapsed = self.spotify.get_current_progress(); 150 | let elapsed_ms = elapsed.as_millis() as u32; 151 | 152 | let formatted_elapsed = format!( 153 | "{:02}:{:02}", 154 | elapsed.as_secs() / 60, 155 | elapsed.as_secs() % 60 156 | ); 157 | 158 | let playback_duration_status = match self.queue.get_current() { 159 | Some(ref t) => format!("{} / {}", formatted_elapsed, t.duration_str()), 160 | None => "".to_string(), 161 | }; 162 | 163 | let right = updating.to_string() 164 | + repeat 165 | + shuffle 166 | // + saved 167 | + &playback_duration_status 168 | + &volume; 169 | let offset = HAlign::Right.get_offset(right.width(), printer.size.x); 170 | 171 | printer.with_color(style, |printer| { 172 | if let Some(ref t) = self.queue.get_current() { 173 | printer.print((4, 1), &t.to_string()); 174 | } 175 | printer.print((offset, 1), &right); 176 | }); 177 | 178 | if let Some(t) = self.queue.get_current() { 179 | printer.with_color(style_bar, |printer| { 180 | let duration_width = 181 | (((printer.size.x as u32) * elapsed_ms) / t.duration()) as usize; 182 | printer.print((0, 0), &"━".repeat(duration_width + 1)); 183 | }); 184 | } 185 | } 186 | 187 | fn layout(&mut self, size: Vec2) { 188 | self.last_size = size; 189 | } 190 | 191 | fn required_size(&mut self, constraint: Vec2) -> Vec2 { 192 | Vec2::new(constraint.x, 2) 193 | } 194 | 195 | fn on_event(&mut self, event: Event) -> EventResult { 196 | if let Event::Mouse { 197 | offset, 198 | position, 199 | event, 200 | } = event 201 | { 202 | let position = position - offset; 203 | 204 | if position.y == 0 { 205 | if event == MouseEvent::WheelUp { 206 | self.spotify.seek_relative(-500); 207 | } 208 | 209 | if event == MouseEvent::WheelDown { 210 | self.spotify.seek_relative(500); 211 | } 212 | 213 | if event == MouseEvent::Press(MouseButton::Left) 214 | || event == MouseEvent::Hold(MouseButton::Left) 215 | { 216 | if let Some(playable) = self.queue.get_current() { 217 | let f: f32 = position.x as f32 / self.last_size.x as f32; 218 | let new = playable.duration() as f32 * f; 219 | self.spotify.seek(new as u32); 220 | } 221 | } 222 | } else if event == MouseEvent::Press(MouseButton::Left) { 223 | self.queue.toggleplayback(); 224 | } 225 | 226 | EventResult::Consumed(None) 227 | } else { 228 | EventResult::Ignored 229 | } 230 | } 231 | } 232 | -------------------------------------------------------------------------------- /src/album.rs: -------------------------------------------------------------------------------- 1 | use std::fmt; 2 | use std::sync::Arc; 3 | 4 | use chrono::{DateTime, Utc}; 5 | use log::debug; 6 | use rspotify::model::album::{FullAlbum, SavedAlbum, SimplifiedAlbum}; 7 | 8 | use crate::artist::Artist; 9 | use crate::library::Library; 10 | use crate::playable::Playable; 11 | use crate::queue::Queue; 12 | use crate::spotify::Spotify; 13 | use crate::track::Track; 14 | use crate::traits::{IntoBoxedViewExt, ListItem, ViewExt}; 15 | use crate::ui::album::AlbumView; 16 | 17 | #[derive(Clone, Deserialize, Serialize)] 18 | pub struct Album { 19 | pub id: Option, 20 | pub title: String, 21 | pub artists: Vec, 22 | pub artist_ids: Vec, 23 | pub year: String, 24 | pub cover_url: Option, 25 | pub url: Option, 26 | pub tracks: Option>, 27 | pub added_at: Option>, 28 | } 29 | 30 | impl Album { 31 | pub fn load_all_tracks(&mut self, spotify: Spotify) { 32 | if self.tracks.is_some() { 33 | return; 34 | } 35 | 36 | if let Some(ref album_id) = self.id { 37 | let mut collected_tracks = Vec::new(); 38 | if let Some(full_album) = spotify.full_album(album_id) { 39 | let mut tracks_result = Some(full_album.tracks.clone()); 40 | while let Some(ref tracks) = tracks_result { 41 | for t in &tracks.items { 42 | collected_tracks.push(Track::from_simplified_track(t, &full_album)); 43 | } 44 | 45 | debug!("got {} tracks", tracks.items.len()); 46 | 47 | // load next batch if necessary 48 | tracks_result = match tracks.next { 49 | Some(_) => { 50 | debug!("requesting tracks again.."); 51 | spotify.album_tracks( 52 | album_id, 53 | 50, 54 | tracks.offset + tracks.items.len() as u32, 55 | ) 56 | } 57 | None => None, 58 | } 59 | } 60 | } 61 | 62 | self.tracks = Some(collected_tracks) 63 | } 64 | } 65 | } 66 | 67 | impl From<&SimplifiedAlbum> for Album { 68 | fn from(sa: &SimplifiedAlbum) -> Self { 69 | Self { 70 | id: sa.id.clone(), 71 | title: sa.name.clone(), 72 | artists: sa.artists.iter().map(|sa| sa.name.clone()).collect(), 73 | artist_ids: sa.artists.iter().filter_map(|a| a.id.clone()).collect(), 74 | year: sa 75 | .release_date 76 | .clone() 77 | .unwrap_or_default() 78 | .split('-') 79 | .next() 80 | .unwrap() 81 | .into(), 82 | cover_url: sa.images.get(0).map(|i| i.url.clone()), 83 | url: sa.uri.clone(), 84 | tracks: None, 85 | added_at: None, 86 | } 87 | } 88 | } 89 | 90 | impl From<&FullAlbum> for Album { 91 | fn from(fa: &FullAlbum) -> Self { 92 | let tracks = Some( 93 | fa.tracks 94 | .items 95 | .iter() 96 | .map(|st| Track::from_simplified_track(&st, &fa)) 97 | .collect(), 98 | ); 99 | 100 | Self { 101 | id: Some(fa.id.clone()), 102 | title: fa.name.clone(), 103 | artists: fa.artists.iter().map(|sa| sa.name.clone()).collect(), 104 | artist_ids: fa.artists.iter().filter_map(|a| a.id.clone()).collect(), 105 | year: fa.release_date.split('-').next().unwrap().into(), 106 | cover_url: fa.images.get(0).map(|i| i.url.clone()), 107 | url: Some(fa.uri.clone()), 108 | tracks, 109 | added_at: None, 110 | } 111 | } 112 | } 113 | 114 | impl From<&SavedAlbum> for Album { 115 | fn from(sa: &SavedAlbum) -> Self { 116 | let mut album: Self = (&sa.album).into(); 117 | album.added_at = Some(sa.added_at); 118 | album 119 | } 120 | } 121 | 122 | impl fmt::Display for Album { 123 | fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { 124 | write!(f, "{} - {}", self.artists.join(", "), self.title) 125 | } 126 | } 127 | 128 | impl fmt::Debug for Album { 129 | fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { 130 | write!( 131 | f, 132 | "({} - {} ({:?}))", 133 | self.artists.join(", "), 134 | self.title, 135 | self.id 136 | ) 137 | } 138 | } 139 | 140 | impl ListItem for Album { 141 | fn is_playing(&self, queue: Arc) -> bool { 142 | if let Some(tracks) = self.tracks.as_ref() { 143 | let playing: Vec = queue 144 | .queue 145 | .read() 146 | .unwrap() 147 | .iter() 148 | .filter_map(|t| t.id()) 149 | .collect(); 150 | 151 | let ids: Vec = tracks.iter().filter_map(|t| t.id.clone()).collect(); 152 | !ids.is_empty() && playing == ids 153 | } else { 154 | false 155 | } 156 | } 157 | 158 | fn as_listitem(&self) -> Box { 159 | Box::new(self.clone()) 160 | } 161 | 162 | fn display_left(&self) -> String { 163 | format!("{}", self) 164 | } 165 | 166 | fn display_right(&self, library: Arc) -> String { 167 | let saved = if library.is_saved_album(self) { 168 | if library.cfg.values().use_nerdfont.unwrap_or(false) { 169 | "\u{f62b} " 170 | } else { 171 | "✓ " 172 | } 173 | } else { 174 | "" 175 | }; 176 | format!("{}{}", saved, self.year) 177 | } 178 | 179 | fn play(&mut self, queue: Arc) { 180 | self.load_all_tracks(queue.get_spotify()); 181 | 182 | if let Some(tracks) = self.tracks.as_ref() { 183 | let tracks: Vec = tracks 184 | .iter() 185 | .map(|track| Playable::Track(track.clone())) 186 | .collect(); 187 | let index = queue.append_next(tracks); 188 | queue.play(index, true, true); 189 | } 190 | } 191 | 192 | fn play_next(&mut self, queue: Arc) { 193 | self.load_all_tracks(queue.get_spotify()); 194 | 195 | if let Some(tracks) = self.tracks.as_ref() { 196 | for t in tracks.iter().rev() { 197 | queue.insert_after_current(Playable::Track(t.clone())); 198 | } 199 | } 200 | } 201 | 202 | fn queue(&mut self, queue: Arc) { 203 | self.load_all_tracks(queue.get_spotify()); 204 | 205 | if let Some(tracks) = self.tracks.as_ref() { 206 | for t in tracks { 207 | queue.append(Playable::Track(t.clone())); 208 | } 209 | } 210 | } 211 | 212 | fn save(&mut self, library: Arc) { 213 | library.save_album(self); 214 | } 215 | 216 | fn unsave(&mut self, library: Arc) { 217 | library.unsave_album(self); 218 | } 219 | 220 | fn toggle_saved(&mut self, library: Arc) { 221 | if library.is_saved_album(self) { 222 | library.unsave_album(self); 223 | } else { 224 | library.save_album(self); 225 | } 226 | } 227 | 228 | fn open(&self, queue: Arc, library: Arc) -> Option> { 229 | Some(AlbumView::new(queue, library, self).into_boxed_view_ext()) 230 | } 231 | 232 | fn share_url(&self) -> Option { 233 | self.id 234 | .clone() 235 | .map(|id| format!("https://open.spotify.com/album/{}", id)) 236 | } 237 | 238 | fn artists(&self) -> Option> { 239 | Some( 240 | self.artist_ids 241 | .iter() 242 | .zip(self.artists.iter()) 243 | .map(|(id, name)| Artist::new(id.clone(), name.clone())) 244 | .collect(), 245 | ) 246 | } 247 | } 248 | -------------------------------------------------------------------------------- /src/spotify_worker.rs: -------------------------------------------------------------------------------- 1 | use crate::config; 2 | use crate::events::{Event, EventManager}; 3 | use crate::playable::Playable; 4 | use crate::queue::QueueEvent; 5 | use crate::spotify::PlayerEvent; 6 | use crate::token::fetch_illegal_access_token; 7 | use futures::channel::oneshot; 8 | use futures::{Future, FutureExt}; 9 | use librespot_core::keymaster::Token; 10 | use librespot_core::session::Session; 11 | use librespot_core::spotify_id::{SpotifyAudioType, SpotifyId}; 12 | use librespot_playback::mixer::Mixer; 13 | use librespot_playback::player::{Player, PlayerEvent as LibrespotPlayerEvent}; 14 | use log::{debug, error, info, warn}; 15 | use std::time::Duration; 16 | use std::{pin::Pin, time::SystemTime}; 17 | use tokio::sync::mpsc; 18 | use tokio::time; 19 | use tokio_stream::wrappers::UnboundedReceiverStream; 20 | use tokio_stream::StreamExt; 21 | 22 | #[derive(Debug)] 23 | pub(crate) enum WorkerCommand { 24 | Load(Playable, bool, u32), 25 | Play, 26 | Pause, 27 | Stop, 28 | Seek(u32), 29 | SetVolume(u16), 30 | RequestToken(oneshot::Sender), 31 | Preload(Playable), 32 | Shutdown, 33 | } 34 | 35 | pub struct Worker { 36 | events: EventManager, 37 | player_events: UnboundedReceiverStream, 38 | commands: UnboundedReceiverStream, 39 | session: Session, 40 | player: Player, 41 | token_task: Pin + Send>>, 42 | active: bool, 43 | mixer: Box, 44 | highlight_fadeout: u32, 45 | } 46 | 47 | impl Worker { 48 | pub(crate) fn new( 49 | events: EventManager, 50 | player_events: mpsc::UnboundedReceiver, 51 | commands: mpsc::UnboundedReceiver, 52 | session: Session, 53 | player: Player, 54 | mixer: Box, 55 | highlight_fadeout: u32, 56 | ) -> Worker { 57 | Worker { 58 | events, 59 | player_events: UnboundedReceiverStream::new(player_events), 60 | commands: UnboundedReceiverStream::new(commands), 61 | player, 62 | session, 63 | token_task: Box::pin(futures::future::pending()), 64 | active: false, 65 | mixer, 66 | highlight_fadeout, 67 | } 68 | } 69 | } 70 | 71 | impl Worker { 72 | fn get_token( 73 | &self, 74 | sender: oneshot::Sender, 75 | ) -> Pin + Send>> { 76 | let client_id = config::CLIENT_ID; 77 | let scopes = "user-read-private,playlist-read-private,playlist-read-collaborative,playlist-modify-public,playlist-modify-private,user-follow-modify,user-follow-read,user-library-read,user-library-modify,user-top-read,user-read-recently-played"; 78 | let url = format!( 79 | "hm://keymaster/token/authenticated?client_id={}&scope={}", 80 | client_id, scopes 81 | ); 82 | Box::pin( 83 | self.session 84 | .mercury() 85 | .get(url) 86 | .map(move |response| { 87 | let payload = response 88 | .as_ref() 89 | .unwrap() 90 | .payload 91 | .first() 92 | .expect("Empty payload"); 93 | let data = String::from_utf8(payload.clone()).unwrap(); 94 | let token: Token = serde_json::from_str(&data).unwrap(); 95 | info!("new token received: {:?}", token); 96 | token 97 | }) 98 | .map(move |token| { 99 | tokio::task::block_in_place(move || { 100 | let mut new_token = token; 101 | if let Some(t) = fetch_illegal_access_token() { 102 | new_token.access_token = t.access_token; 103 | } 104 | new_token 105 | }) 106 | }) 107 | .map(|token| sender.send(token).unwrap()), 108 | ) 109 | } 110 | 111 | pub async fn run_loop(&mut self) { 112 | let mut fast_ui_refresh = time::interval(Duration::from_millis(500)); 113 | let mut slow_ui_refresh = 114 | time::interval(Duration::from_secs(self.highlight_fadeout.into())); 115 | 116 | loop { 117 | if self.session.is_invalid() { 118 | info!("Librespot session invalidated, terminating worker"); 119 | self.events.send(Event::Player(PlayerEvent::Stopped)); 120 | break; 121 | } 122 | 123 | tokio::select! { 124 | cmd = self.commands.next() => match cmd { 125 | 126 | Some(WorkerCommand::Load(playable, start_playing, position_ms)) => { 127 | match SpotifyId::from_uri(&playable.uri()) { 128 | Ok(id) => { 129 | info!("player loading track: {:?}", id); 130 | if id.audio_type == SpotifyAudioType::NonPlayable { 131 | warn!("track is not playable"); 132 | self.events.send(Event::Player(PlayerEvent::FinishedTrack)); 133 | } else { 134 | self.player.load(id, start_playing, position_ms); 135 | } 136 | } 137 | Err(e) => { 138 | error!("error parsing uri: {:?}", e); 139 | self.events.send(Event::Player(PlayerEvent::FinishedTrack)); 140 | } 141 | } 142 | } 143 | Some(WorkerCommand::Play) => { 144 | self.player.play(); 145 | } 146 | Some(WorkerCommand::Pause) => { 147 | self.player.pause(); 148 | } 149 | Some(WorkerCommand::Stop) => { 150 | self.player.stop(); 151 | } 152 | Some(WorkerCommand::Seek(pos)) => { 153 | self.player.seek(pos); 154 | } 155 | Some(WorkerCommand::SetVolume(volume)) => { 156 | self.mixer.set_volume(volume); 157 | } 158 | Some(WorkerCommand::RequestToken(sender)) => { 159 | self.token_task = self.get_token(sender); 160 | } 161 | Some(WorkerCommand::Preload(playable)) => { 162 | if let Ok(id) = SpotifyId::from_uri(&playable.uri()) { 163 | debug!("Preloading {:?}", id); 164 | self.player.preload(id); 165 | } 166 | } 167 | Some(WorkerCommand::Shutdown) => { 168 | self.player.stop(); 169 | self.session.shutdown(); 170 | } 171 | None => info!("empty stream") 172 | }, 173 | event = self.player_events.next() => match event { 174 | Some(LibrespotPlayerEvent::Playing { 175 | play_request_id: _, 176 | track_id: _, 177 | position_ms, 178 | duration_ms: _, 179 | }) => { 180 | let position = Duration::from_millis(position_ms as u64); 181 | let playback_start = SystemTime::now() - position; 182 | self.events 183 | .send(Event::Player(PlayerEvent::Playing(playback_start))); 184 | self.active = true; 185 | } 186 | Some(LibrespotPlayerEvent::Paused { 187 | play_request_id: _, 188 | track_id: _, 189 | position_ms, 190 | duration_ms: _, 191 | }) => { 192 | let position = Duration::from_millis(position_ms as u64); 193 | self.events 194 | .send(Event::Player(PlayerEvent::Paused(position))); 195 | self.active = false; 196 | } 197 | Some(LibrespotPlayerEvent::Stopped { .. }) => { 198 | self.events.send(Event::Player(PlayerEvent::Stopped)); 199 | self.active = false; 200 | } 201 | Some(LibrespotPlayerEvent::EndOfTrack { .. }) => { 202 | self.events.send(Event::Player(PlayerEvent::FinishedTrack)); 203 | } 204 | Some(LibrespotPlayerEvent::TimeToPreloadNextTrack { .. }) => { 205 | self.events 206 | .send(Event::Queue(QueueEvent::PreloadTrackRequest)); 207 | } 208 | None => { 209 | warn!("Librespot player event channel died, terminating worker"); 210 | break 211 | }, 212 | _ => {} 213 | }, 214 | _ = fast_ui_refresh.tick() => { 215 | if self.active { 216 | self.events.trigger(); 217 | } 218 | }, 219 | _ = slow_ui_refresh.tick() =>{ 220 | self.events.trigger(); 221 | }, 222 | _ = self.token_task.as_mut() => { 223 | info!("token updated!"); 224 | self.token_task = Box::pin(futures::future::pending()); 225 | } 226 | } 227 | } 228 | } 229 | } 230 | -------------------------------------------------------------------------------- /src/track.rs: -------------------------------------------------------------------------------- 1 | use std::fmt; 2 | use std::sync::{Arc, RwLock}; 3 | 4 | use chrono::{DateTime, Utc}; 5 | use rayon::prelude::*; 6 | use rspotify::model::album::FullAlbum; 7 | use rspotify::model::track::{FullTrack, SavedTrack, SimplifiedTrack}; 8 | 9 | use crate::album::Album; 10 | use crate::artist::Artist; 11 | use crate::library::Library; 12 | use crate::playable::Playable; 13 | use crate::queue::Queue; 14 | use crate::traits::{IntoBoxedViewExt, ListItem, ViewExt}; 15 | use crate::ui::listview::ListView; 16 | 17 | #[derive(Clone, Deserialize, Serialize)] 18 | pub struct Track { 19 | pub id: Option, 20 | pub uri: String, 21 | pub title: String, 22 | pub track_number: u32, 23 | pub disc_number: i32, 24 | pub duration: u32, 25 | pub artists: Vec, 26 | pub artist_ids: Vec, 27 | pub album: Option, 28 | pub album_id: Option, 29 | pub album_artists: Vec, 30 | pub cover_url: Option, 31 | pub url: String, 32 | pub added_at: Option>, 33 | pub list_index: usize, 34 | } 35 | 36 | impl Track { 37 | pub fn from_simplified_track(track: &SimplifiedTrack, album: &FullAlbum) -> Track { 38 | let artists = track 39 | .artists 40 | .iter() 41 | .map(|artist| artist.name.clone()) 42 | .collect::>(); 43 | let artist_ids = track 44 | .artists 45 | .iter() 46 | .filter_map(|a| a.id.clone()) 47 | .collect::>(); 48 | let album_artists = album 49 | .artists 50 | .iter() 51 | .map(|artist| artist.name.clone()) 52 | .collect::>(); 53 | 54 | Self { 55 | id: track.id.clone(), 56 | uri: track.uri.clone(), 57 | title: track.name.clone(), 58 | track_number: track.track_number, 59 | disc_number: track.disc_number, 60 | duration: track.duration_ms, 61 | artists, 62 | artist_ids, 63 | album: Some(album.name.clone()), 64 | album_id: Some(album.id.clone()), 65 | album_artists, 66 | cover_url: album.images.get(0).map(|img| img.url.clone()), 67 | url: track.uri.clone(), 68 | added_at: None, 69 | list_index: 0, 70 | } 71 | } 72 | 73 | pub fn duration_str(&self) -> String { 74 | let minutes = self.duration / 60_000; 75 | let seconds = (self.duration / 1000) % 60; 76 | format!("{:02}:{:02}", minutes, seconds) 77 | } 78 | } 79 | 80 | impl From<&SimplifiedTrack> for Track { 81 | fn from(track: &SimplifiedTrack) -> Self { 82 | let artists = track 83 | .artists 84 | .iter() 85 | .map(|ref artist| artist.name.clone()) 86 | .collect::>(); 87 | let artist_ids = track 88 | .artists 89 | .iter() 90 | .filter_map(|a| a.id.clone()) 91 | .collect::>(); 92 | 93 | Self { 94 | id: track.id.clone(), 95 | uri: track.uri.clone(), 96 | title: track.name.clone(), 97 | track_number: track.track_number, 98 | disc_number: track.disc_number, 99 | duration: track.duration_ms, 100 | artists, 101 | artist_ids, 102 | album: None, 103 | album_id: None, 104 | album_artists: Vec::new(), 105 | cover_url: None, 106 | url: track.uri.clone(), 107 | added_at: None, 108 | list_index: 0, 109 | } 110 | } 111 | } 112 | 113 | impl From<&FullTrack> for Track { 114 | fn from(track: &FullTrack) -> Self { 115 | let artists = track 116 | .artists 117 | .iter() 118 | .map(|ref artist| artist.name.clone()) 119 | .collect::>(); 120 | let artist_ids = track 121 | .artists 122 | .iter() 123 | .filter_map(|a| a.id.clone()) 124 | .collect::>(); 125 | let album_artists = track 126 | .album 127 | .artists 128 | .iter() 129 | .map(|ref artist| artist.name.clone()) 130 | .collect::>(); 131 | 132 | Self { 133 | id: track.id.clone(), 134 | uri: track.uri.clone(), 135 | title: track.name.clone(), 136 | track_number: track.track_number, 137 | disc_number: track.disc_number, 138 | duration: track.duration_ms, 139 | artists, 140 | artist_ids, 141 | album: Some(track.album.name.clone()), 142 | album_id: track.album.id.clone(), 143 | album_artists, 144 | cover_url: track.album.images.get(0).map(|img| img.url.clone()), 145 | url: track.uri.clone(), 146 | added_at: None, 147 | list_index: 0, 148 | } 149 | } 150 | } 151 | 152 | impl From<&SavedTrack> for Track { 153 | fn from(st: &SavedTrack) -> Self { 154 | let mut track: Self = (&st.track).into(); 155 | track.added_at = Some(st.added_at); 156 | track 157 | } 158 | } 159 | 160 | impl fmt::Display for Track { 161 | fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { 162 | write!(f, "{} - {}", self.artists.join(", "), self.title) 163 | } 164 | } 165 | 166 | impl fmt::Debug for Track { 167 | fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { 168 | write!( 169 | f, 170 | "({} - {} ({:?}))", 171 | self.artists.join(", "), 172 | self.title, 173 | self.id 174 | ) 175 | } 176 | } 177 | 178 | impl ListItem for Track { 179 | fn is_playing(&self, queue: Arc) -> bool { 180 | let current = queue.get_current(); 181 | current.map(|t| t.id() == self.id).unwrap_or(false) 182 | } 183 | 184 | fn as_listitem(&self) -> Box { 185 | Box::new(self.clone()) 186 | } 187 | 188 | fn display_left(&self) -> String { 189 | format!("{}", self) 190 | } 191 | 192 | fn display_center(&self, library: Arc) -> String { 193 | if library.cfg.values().album_column.unwrap_or(true) { 194 | self.album.clone().unwrap_or_default() 195 | } else { 196 | "".to_string() 197 | } 198 | } 199 | 200 | fn display_right(&self, library: Arc) -> String { 201 | let saved = if library.is_saved_track(&Playable::Track(self.clone())) { 202 | if library.cfg.values().use_nerdfont.unwrap_or(false) { 203 | "\u{f62b} " 204 | } else { 205 | "✓ " 206 | } 207 | } else { 208 | "" 209 | }; 210 | format!("{}{}", saved, self.duration_str()) 211 | } 212 | 213 | fn play(&mut self, queue: Arc) { 214 | let index = queue.append_next(vec![Playable::Track(self.clone())]); 215 | queue.play(index, true, false); 216 | } 217 | 218 | fn play_next(&mut self, queue: Arc) { 219 | queue.insert_after_current(Playable::Track(self.clone())); 220 | } 221 | 222 | fn queue(&mut self, queue: Arc) { 223 | queue.append(Playable::Track(self.clone())); 224 | } 225 | 226 | fn save(&mut self, library: Arc) { 227 | library.save_tracks(vec![self], true); 228 | } 229 | 230 | fn unsave(&mut self, library: Arc) { 231 | library.unsave_tracks(vec![self], true); 232 | } 233 | 234 | fn toggle_saved(&mut self, library: Arc) { 235 | if library.is_saved_track(&Playable::Track(self.clone())) { 236 | library.unsave_tracks(vec![self], true); 237 | } else { 238 | library.save_tracks(vec![self], true); 239 | } 240 | } 241 | 242 | fn open(&self, _queue: Arc, _library: Arc) -> Option> { 243 | None 244 | } 245 | 246 | fn open_recommendations( 247 | &self, 248 | queue: Arc, 249 | library: Arc, 250 | ) -> Option> { 251 | let spotify = queue.get_spotify(); 252 | 253 | let recommendations: Option> = if let Some(id) = &self.id { 254 | spotify 255 | .recommendations(None, None, Some(vec![id.clone()])) 256 | .map(|r| r.tracks) 257 | .map(|tracks| { 258 | tracks 259 | .par_iter() 260 | .filter_map(|track| match track.id.as_ref() { 261 | Some(id) => spotify.track(id), 262 | None => None, 263 | }) 264 | .collect() 265 | }) 266 | .map(|tracks: Vec| tracks.iter().map(Track::from).collect()) 267 | } else { 268 | None 269 | }; 270 | 271 | recommendations.map(|tracks| { 272 | ListView::new( 273 | Arc::new(RwLock::new(tracks)), 274 | queue.clone(), 275 | library.clone(), 276 | ) 277 | .set_title(format!( 278 | "Similar to \"{} - {}\"", 279 | self.artists.join(", "), 280 | self.title 281 | )) 282 | .into_boxed_view_ext() 283 | }) 284 | } 285 | 286 | fn share_url(&self) -> Option { 287 | self.id 288 | .clone() 289 | .map(|id| format!("https://open.spotify.com/track/{}", id)) 290 | } 291 | 292 | fn album(&self, queue: Arc) -> Option { 293 | let spotify = queue.get_spotify(); 294 | 295 | match self.album_id { 296 | Some(ref album_id) => spotify.album(&album_id).map(|ref fa| fa.into()), 297 | None => None, 298 | } 299 | } 300 | 301 | fn artists(&self) -> Option> { 302 | Some( 303 | self.artist_ids 304 | .iter() 305 | .zip(self.artists.iter()) 306 | .map(|(id, name)| Artist::new(id.clone(), name.clone())) 307 | .collect(), 308 | ) 309 | } 310 | 311 | fn track(&self) -> Option { 312 | Some(self.clone()) 313 | } 314 | } 315 | -------------------------------------------------------------------------------- /src/playlist.rs: -------------------------------------------------------------------------------- 1 | use std::sync::Arc; 2 | use std::{cmp::Ordering, iter::Iterator}; 3 | 4 | use log::debug; 5 | use rspotify::model::playlist::{FullPlaylist, SimplifiedPlaylist}; 6 | 7 | use crate::playable::Playable; 8 | use crate::queue::Queue; 9 | use crate::spotify::Spotify; 10 | use crate::track::Track; 11 | use crate::traits::{IntoBoxedViewExt, ListItem, ViewExt}; 12 | use crate::ui::playlist::PlaylistView; 13 | use crate::{command::SortDirection, command::SortKey, library::Library}; 14 | 15 | pub enum PlaylistType { 16 | Library, 17 | ForYou, 18 | } 19 | 20 | #[derive(Clone, Debug, Deserialize, Serialize)] 21 | pub struct Playlist { 22 | pub id: String, 23 | pub name: String, 24 | pub owner_id: String, 25 | pub snapshot_id: String, 26 | pub num_tracks: usize, 27 | pub tracks: Option>, 28 | pub collaborative: bool, 29 | } 30 | 31 | impl Playlist { 32 | pub fn load_tracks(&mut self, spotify: Spotify) { 33 | if self.tracks.is_some() { 34 | return; 35 | } 36 | 37 | self.tracks = Some(self.get_all_tracks(spotify)); 38 | } 39 | 40 | fn get_all_tracks(&self, spotify: Spotify) -> Vec { 41 | let tracks_result = spotify.user_playlist_tracks(&self.id); 42 | while !tracks_result.at_end() { 43 | tracks_result.next(); 44 | } 45 | 46 | let tracks = tracks_result.items.read().unwrap(); 47 | tracks.clone() 48 | } 49 | 50 | pub fn has_track(&self, track_id: &str) -> bool { 51 | self.tracks.as_ref().map_or(false, |tracks| { 52 | tracks 53 | .iter() 54 | .any(|track| track.id == Some(track_id.to_string())) 55 | }) 56 | } 57 | 58 | pub fn delete_track(&mut self, index: usize, spotify: Spotify, library: Arc) -> bool { 59 | let track = self.tracks.as_ref().unwrap()[index].clone(); 60 | debug!("deleting track: {} {:?}", index, track); 61 | match spotify.delete_tracks(&self.id, &self.snapshot_id, &[(&track, track.list_index)]) { 62 | false => false, 63 | true => { 64 | if let Some(tracks) = &mut self.tracks { 65 | tracks.remove(index); 66 | library.playlist_update(&self); 67 | } 68 | 69 | true 70 | } 71 | } 72 | } 73 | 74 | pub fn append_tracks(&mut self, new_tracks: &[Track], spotify: Spotify, library: Arc) { 75 | let track_ids: Vec = new_tracks 76 | .to_vec() 77 | .iter() 78 | .filter_map(|t| t.id.clone()) 79 | .collect(); 80 | 81 | let mut has_modified = false; 82 | 83 | if spotify.append_tracks(&self.id, &track_ids, None) { 84 | if let Some(tracks) = &mut self.tracks { 85 | tracks.append(&mut new_tracks.to_vec()); 86 | has_modified = true; 87 | } 88 | } 89 | 90 | if has_modified { 91 | library.playlist_update(self); 92 | } 93 | } 94 | 95 | pub fn sort(&mut self, key: &SortKey, direction: &SortDirection) { 96 | fn compare_artists(a: Vec, b: Vec) -> Ordering { 97 | let sanitize_artists_name = |x: Vec| -> Vec { 98 | x.iter() 99 | .map(|x| { 100 | x.to_lowercase() 101 | .split(' ') 102 | .skip_while(|x| x == &"the") 103 | .collect() 104 | }) 105 | .collect() 106 | }; 107 | 108 | let a = sanitize_artists_name(a); 109 | let b = sanitize_artists_name(b); 110 | 111 | a.cmp(&b) 112 | } 113 | 114 | if let Some(c) = self.tracks.as_mut() { 115 | c.sort_by(|a, b| match (a.track(), b.track()) { 116 | (Some(a), Some(b)) => match (key, direction) { 117 | (SortKey::Title, SortDirection::Ascending) => { 118 | a.title.to_lowercase().cmp(&b.title.to_lowercase()) 119 | } 120 | (SortKey::Title, SortDirection::Descending) => { 121 | b.title.to_lowercase().cmp(&a.title.to_lowercase()) 122 | } 123 | (SortKey::Duration, SortDirection::Ascending) => a.duration.cmp(&b.duration), 124 | (SortKey::Duration, SortDirection::Descending) => b.duration.cmp(&a.duration), 125 | (SortKey::Album, SortDirection::Ascending) => a 126 | .album 127 | .map(|x| x.to_lowercase()) 128 | .cmp(&b.album.map(|x| x.to_lowercase())), 129 | (SortKey::Album, SortDirection::Descending) => b 130 | .album 131 | .map(|x| x.to_lowercase()) 132 | .cmp(&a.album.map(|x| x.to_lowercase())), 133 | (SortKey::Added, SortDirection::Ascending) => a.added_at.cmp(&b.added_at), 134 | (SortKey::Added, SortDirection::Descending) => b.added_at.cmp(&a.added_at), 135 | (SortKey::Artist, SortDirection::Ascending) => { 136 | compare_artists(a.artists, b.artists) 137 | } 138 | (SortKey::Artist, SortDirection::Descending) => { 139 | compare_artists(b.artists, a.artists) 140 | } 141 | }, 142 | _ => std::cmp::Ordering::Equal, 143 | }) 144 | } 145 | } 146 | } 147 | 148 | impl From<&SimplifiedPlaylist> for Playlist { 149 | fn from(list: &SimplifiedPlaylist) -> Self { 150 | let num_tracks = if let Some(number) = list.tracks.get("total") { 151 | number.as_u64().unwrap() as usize 152 | } else { 153 | 0 154 | }; 155 | 156 | Playlist { 157 | id: list.id.clone(), 158 | name: list.name.clone(), 159 | owner_id: list.owner.id.clone(), 160 | snapshot_id: list.snapshot_id.clone(), 161 | num_tracks, 162 | tracks: None, 163 | collaborative: list.collaborative, 164 | } 165 | } 166 | } 167 | 168 | impl From<&FullPlaylist> for Playlist { 169 | fn from(list: &FullPlaylist) -> Self { 170 | Playlist { 171 | id: list.id.clone(), 172 | name: list.name.clone(), 173 | owner_id: list.owner.id.clone(), 174 | snapshot_id: list.snapshot_id.clone(), 175 | num_tracks: list.tracks.total as usize, 176 | tracks: None, 177 | collaborative: list.collaborative, 178 | } 179 | } 180 | } 181 | 182 | impl ListItem for Playlist { 183 | fn is_playing(&self, queue: Arc) -> bool { 184 | if let Some(tracks) = self.tracks.as_ref() { 185 | let playing: Vec = queue 186 | .queue 187 | .read() 188 | .unwrap() 189 | .iter() 190 | .filter_map(|t| t.id()) 191 | .collect(); 192 | let ids: Vec = tracks.iter().filter_map(|t| t.id.clone()).collect(); 193 | !ids.is_empty() && playing == ids 194 | } else { 195 | false 196 | } 197 | } 198 | 199 | fn as_listitem(&self) -> Box { 200 | Box::new(self.clone()) 201 | } 202 | 203 | fn display_left(&self) -> String { 204 | self.name.clone() 205 | } 206 | 207 | fn display_right(&self, library: Arc) -> String { 208 | let saved = if library.is_saved_playlist(self) { 209 | if library.cfg.values().use_nerdfont.unwrap_or(false) { 210 | "\u{f62b} " 211 | } else { 212 | "✓ " 213 | } 214 | } else { 215 | "" 216 | }; 217 | 218 | let num_tracks = self 219 | .tracks 220 | .as_ref() 221 | .map(|t| t.len()) 222 | .unwrap_or(self.num_tracks); 223 | 224 | format!("{}{:>4} tracks", saved, num_tracks) 225 | } 226 | 227 | fn play(&mut self, queue: Arc) { 228 | self.load_tracks(queue.get_spotify()); 229 | 230 | if let Some(tracks) = &self.tracks { 231 | let tracks: Vec = tracks 232 | .iter() 233 | .map(|track| Playable::Track(track.clone())) 234 | .collect(); 235 | let index = queue.append_next(tracks); 236 | queue.play(index, true, true); 237 | } 238 | } 239 | 240 | fn play_next(&mut self, queue: Arc) { 241 | self.load_tracks(queue.get_spotify()); 242 | 243 | if let Some(tracks) = self.tracks.as_ref() { 244 | for track in tracks.iter().rev() { 245 | queue.insert_after_current(Playable::Track(track.clone())); 246 | } 247 | } 248 | } 249 | 250 | fn queue(&mut self, queue: Arc) { 251 | self.load_tracks(queue.get_spotify()); 252 | 253 | if let Some(tracks) = self.tracks.as_ref() { 254 | for track in tracks.iter() { 255 | queue.append(Playable::Track(track.clone())); 256 | } 257 | } 258 | } 259 | 260 | fn save(&mut self, library: Arc) { 261 | library.follow_playlist(self); 262 | } 263 | 264 | fn unsave(&mut self, library: Arc) { 265 | library.delete_playlist(&self.id); 266 | } 267 | 268 | fn toggle_saved(&mut self, library: Arc) { 269 | // Don't allow users to unsave their own playlists with one keypress 270 | if !library.is_followed_playlist(self) { 271 | return; 272 | } 273 | 274 | if library.is_saved_playlist(self) { 275 | library.delete_playlist(&self.id); 276 | } else { 277 | library.follow_playlist(self); 278 | } 279 | } 280 | 281 | fn open(&self, queue: Arc, library: Arc) -> Option> { 282 | Some(PlaylistView::new(queue, library, self).into_boxed_view_ext()) 283 | } 284 | 285 | fn share_url(&self) -> Option { 286 | Some(format!( 287 | "https://open.spotify.com/user/{}/playlist/{}", 288 | self.owner_id, self.id 289 | )) 290 | } 291 | } 292 | -------------------------------------------------------------------------------- /src/ui/contextmenu.rs: -------------------------------------------------------------------------------- 1 | use std::sync::Arc; 2 | 3 | use cursive::view::{Margins, ViewWrapper}; 4 | use cursive::views::{Dialog, NamedView, ScrollView, SelectView}; 5 | use cursive::Cursive; 6 | 7 | use crate::library::Library; 8 | use crate::playable::Playable; 9 | use crate::queue::Queue; 10 | #[cfg(feature = "share_clipboard")] 11 | use crate::sharing::write_share; 12 | use crate::track::Track; 13 | use crate::traits::{ListItem, ViewExt}; 14 | use crate::ui::layout::Layout; 15 | use crate::ui::modal::Modal; 16 | use crate::{artist::Artist, commands::CommandResult}; 17 | use crate::{ 18 | command::{Command, MoveAmount, MoveMode}, 19 | playlist::Playlist, 20 | spotify::Spotify, 21 | }; 22 | use cursive::traits::{Finder, Nameable}; 23 | 24 | pub struct ContextMenu { 25 | dialog: Modal, 26 | } 27 | 28 | pub struct AddToPlaylistMenu { 29 | dialog: Modal, 30 | } 31 | 32 | pub struct SelectArtistMenu { 33 | dialog: Modal, 34 | } 35 | 36 | enum ContextMenuAction { 37 | ShowItem(Box), 38 | SelectArtist(Vec), 39 | ShareUrl(String), 40 | AddToPlaylist(Box), 41 | ShowRecommendations(Box), 42 | ToggleTrackSavedStatus(Box), 43 | } 44 | 45 | impl ContextMenu { 46 | pub fn add_track_dialog( 47 | library: Arc, 48 | spotify: Spotify, 49 | track: Track, 50 | ) -> NamedView { 51 | let mut list_select: SelectView = SelectView::new(); 52 | let current_user_id = library.user_id.as_ref().unwrap(); 53 | 54 | for list in library.playlists().iter() { 55 | if current_user_id == &list.owner_id || list.collaborative { 56 | list_select.add_item(list.name.clone(), list.clone()); 57 | } 58 | } 59 | 60 | list_select.set_on_submit(move |s, selected| { 61 | let track = track.clone(); 62 | let mut playlist = selected.clone(); 63 | let spotify = spotify.clone(); 64 | let library = library.clone(); 65 | 66 | if playlist.has_track(track.id.as_ref().unwrap_or(&String::new())) { 67 | let mut already_added_dialog = Self::track_already_added(); 68 | 69 | already_added_dialog.add_button("Add anyway", move |c| { 70 | let mut playlist = playlist.clone(); 71 | let spotify = spotify.clone(); 72 | let library = library.clone(); 73 | 74 | playlist.append_tracks(&[track.clone()], spotify, library); 75 | c.pop_layer(); 76 | 77 | // Close add_track_dialog too 78 | c.pop_layer(); 79 | }); 80 | 81 | let modal = Modal::new(already_added_dialog); 82 | s.add_layer(modal); 83 | } else { 84 | playlist.append_tracks(&[track], spotify, library); 85 | s.pop_layer(); 86 | } 87 | }); 88 | 89 | let dialog = Dialog::new() 90 | .title("Add track to playlist") 91 | .dismiss_button("Cancel") 92 | .padding(Margins::lrtb(1, 1, 1, 0)) 93 | .content(ScrollView::new(list_select.with_name("addplaylist_select"))); 94 | 95 | AddToPlaylistMenu { 96 | dialog: Modal::new_ext(dialog), 97 | } 98 | .with_name("addtrackmenu") 99 | } 100 | 101 | pub fn select_artist_dialog( 102 | library: Arc, 103 | queue: Arc, 104 | artists: Vec, 105 | ) -> NamedView { 106 | let mut list_select = SelectView::::new(); 107 | 108 | for artist in artists { 109 | list_select.add_item(artist.name.clone(), artist); 110 | } 111 | 112 | list_select.set_on_submit(move |s, selected| { 113 | if let Some(view) = selected.open(queue.clone(), library.clone()) { 114 | s.call_on_name("main", move |v: &mut Layout| v.push_view(view)); 115 | s.pop_layer(); 116 | } 117 | }); 118 | 119 | let dialog = Dialog::new() 120 | .title("Select artist") 121 | .dismiss_button("Cancel") 122 | .padding(Margins::lrtb(1, 1, 1, 0)) 123 | .content(ScrollView::new(list_select.with_name("artist_select"))); 124 | 125 | SelectArtistMenu { 126 | dialog: Modal::new_ext(dialog), 127 | } 128 | .with_name("selectartist") 129 | } 130 | 131 | fn track_already_added() -> Dialog { 132 | Dialog::text("This track is already in your playlist") 133 | .title("Track already exists") 134 | .padding(Margins::lrtb(1, 1, 1, 0)) 135 | .dismiss_button("Cancel") 136 | } 137 | 138 | pub fn new(item: &dyn ListItem, queue: Arc, library: Arc) -> NamedView { 139 | let mut content: SelectView = SelectView::new(); 140 | if let Some(a) = item.artists() { 141 | let action = match a.len() { 142 | 0 => None, 143 | 1 => Some(ContextMenuAction::ShowItem(Box::new(a[0].clone()))), 144 | _ => Some(ContextMenuAction::SelectArtist(a)), 145 | }; 146 | 147 | if let Some(a) = action { 148 | content.add_item("Show artist", a) 149 | } 150 | } 151 | if let Some(a) = item.album(queue.clone()) { 152 | content.add_item("Show album", ContextMenuAction::ShowItem(Box::new(a))); 153 | } 154 | if let Some(url) = item.share_url() { 155 | #[cfg(feature = "share_clipboard")] 156 | content.add_item("Share", ContextMenuAction::ShareUrl(url)); 157 | } 158 | if let Some(t) = item.track() { 159 | content.add_item( 160 | "Add to playlist", 161 | ContextMenuAction::AddToPlaylist(Box::new(t.clone())), 162 | ); 163 | content.add_item( 164 | "Similar tracks", 165 | ContextMenuAction::ShowRecommendations(Box::new(t.clone())), 166 | ); 167 | content.add_item( 168 | match library.is_saved_track(&Playable::Track(t.clone())) { 169 | true => "Unsave track", 170 | false => "Save track", 171 | }, 172 | ContextMenuAction::ToggleTrackSavedStatus(Box::new(t)), 173 | ) 174 | } 175 | 176 | // open detail view of artist/album 177 | content.set_on_submit(move |s: &mut Cursive, action: &ContextMenuAction| { 178 | s.pop_layer(); 179 | let queue = queue.clone(); 180 | let library = library.clone(); 181 | 182 | match action { 183 | ContextMenuAction::ShowItem(item) => { 184 | if let Some(view) = item.open(queue, library) { 185 | s.call_on_name("main", move |v: &mut Layout| v.push_view(view)); 186 | } 187 | } 188 | ContextMenuAction::ShareUrl(url) => { 189 | #[cfg(feature = "share_clipboard")] 190 | write_share(url.to_string()); 191 | } 192 | ContextMenuAction::AddToPlaylist(track) => { 193 | let dialog = 194 | Self::add_track_dialog(library, queue.get_spotify(), *track.clone()); 195 | s.add_layer(dialog); 196 | } 197 | ContextMenuAction::ShowRecommendations(item) => { 198 | if let Some(view) = item.open_recommendations(queue, library) { 199 | s.call_on_name("main", move |v: &mut Layout| v.push_view(view)); 200 | } 201 | } 202 | ContextMenuAction::ToggleTrackSavedStatus(track) => { 203 | let mut track: Track = *track.clone(); 204 | track.toggle_saved(library); 205 | } 206 | ContextMenuAction::SelectArtist(artists) => { 207 | let dialog = Self::select_artist_dialog(library, queue, artists.clone()); 208 | s.add_layer(dialog); 209 | } 210 | } 211 | }); 212 | 213 | let dialog = Dialog::new() 214 | .title(item.display_left()) 215 | .dismiss_button("Cancel") 216 | .padding(Margins::lrtb(1, 1, 1, 0)) 217 | .content(content.with_name("contextmenu_select")); 218 | Self { 219 | dialog: Modal::new_ext(dialog), 220 | } 221 | .with_name("contextmenu") 222 | } 223 | } 224 | 225 | impl ViewExt for AddToPlaylistMenu { 226 | fn on_command(&mut self, s: &mut Cursive, cmd: &Command) -> Result { 227 | log::info!("playlist command: {:?}", cmd); 228 | handle_move_command::(&mut self.dialog, s, cmd, "addplaylist_select") 229 | } 230 | } 231 | 232 | impl ViewExt for ContextMenu { 233 | fn on_command(&mut self, s: &mut Cursive, cmd: &Command) -> Result { 234 | handle_move_command::(&mut self.dialog, s, cmd, "contextmenu_select") 235 | } 236 | } 237 | 238 | impl ViewExt for SelectArtistMenu { 239 | fn on_command(&mut self, s: &mut Cursive, cmd: &Command) -> Result { 240 | log::info!("artist move command: {:?}", cmd); 241 | handle_move_command::(&mut self.dialog, s, cmd, "artist_select") 242 | } 243 | } 244 | 245 | fn handle_move_command( 246 | sel: &mut Modal, 247 | s: &mut Cursive, 248 | cmd: &Command, 249 | name: &str, 250 | ) -> Result { 251 | match cmd { 252 | Command::Back => { 253 | s.pop_layer(); 254 | Ok(CommandResult::Consumed(None)) 255 | } 256 | Command::Move(mode, amount) => sel 257 | .call_on_name(name, |select: &mut SelectView| { 258 | let items = select.len(); 259 | match mode { 260 | MoveMode::Up => { 261 | match amount { 262 | MoveAmount::Extreme => select.set_selection(0), 263 | MoveAmount::Integer(amount) => select.select_up(*amount as usize), 264 | }; 265 | Ok(CommandResult::Consumed(None)) 266 | } 267 | MoveMode::Down => { 268 | match amount { 269 | MoveAmount::Extreme => select.set_selection(items), 270 | MoveAmount::Integer(amount) => select.select_down(*amount as usize), 271 | }; 272 | Ok(CommandResult::Consumed(None)) 273 | } 274 | _ => Ok(CommandResult::Consumed(None)), 275 | } 276 | }) 277 | .unwrap_or(Ok(CommandResult::Consumed(None))), 278 | _ => Ok(CommandResult::Consumed(None)), 279 | } 280 | } 281 | 282 | impl ViewWrapper for AddToPlaylistMenu { 283 | wrap_impl!(self.dialog: Modal); 284 | } 285 | 286 | impl ViewWrapper for ContextMenu { 287 | wrap_impl!(self.dialog: Modal); 288 | } 289 | 290 | impl ViewWrapper for SelectArtistMenu { 291 | wrap_impl!(self.dialog: Modal); 292 | } 293 | -------------------------------------------------------------------------------- /src/ui/cover.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashSet; 2 | use std::fs::File; 3 | use std::io::Write; 4 | use std::path::PathBuf; 5 | use std::process::{Child, Stdio}; 6 | 7 | use std::sync::{Arc, RwLock}; 8 | 9 | use cursive::theme::{ColorStyle, ColorType, PaletteColor}; 10 | use cursive::{Cursive, Printer, Vec2, View}; 11 | use ioctl_rs::{ioctl, TIOCGWINSZ}; 12 | use log::{debug, error}; 13 | 14 | use crate::command::{Command, GotoMode}; 15 | use crate::commands::CommandResult; 16 | use crate::library::Library; 17 | use crate::queue::Queue; 18 | use crate::traits::{IntoBoxedViewExt, ListItem, ViewExt}; 19 | use crate::ui::album::AlbumView; 20 | use crate::ui::artist::ArtistView; 21 | use crate::Config; 22 | 23 | pub struct CoverView { 24 | queue: Arc, 25 | library: Arc, 26 | loading: Arc>>, 27 | last_size: RwLock, 28 | drawn_url: RwLock>, 29 | ueberzug: RwLock>, 30 | font_size: Vec2, 31 | } 32 | 33 | impl CoverView { 34 | pub fn new(queue: Arc, library: Arc, config: &Config) -> Self { 35 | // Determine size of window both in pixels and chars 36 | let (rows, cols, mut xpixels, mut ypixels) = unsafe { 37 | let query: (u16, u16, u16, u16) = (0, 0, 0, 0); 38 | ioctl(1, TIOCGWINSZ, &query); 39 | query 40 | }; 41 | 42 | debug!( 43 | "Determined window dimensions: {}x{}, {}x{}", 44 | xpixels, ypixels, cols, rows 45 | ); 46 | 47 | // Determine font size, considering max scale to prevent tiny covers on HiDPI screens 48 | let scale = config.values().cover_max_scale.unwrap_or(1.0); 49 | xpixels = ((xpixels as f32) / scale) as u16; 50 | ypixels = ((ypixels as f32) / scale) as u16; 51 | 52 | let font_size = Vec2::new((xpixels / cols) as usize, (ypixels / rows) as usize); 53 | 54 | debug!("Determined font size: {}x{}", font_size.x, font_size.y); 55 | 56 | Self { 57 | queue, 58 | library, 59 | ueberzug: RwLock::new(None), 60 | loading: Arc::new(RwLock::new(HashSet::new())), 61 | last_size: RwLock::new(Vec2::new(0, 0)), 62 | drawn_url: RwLock::new(None), 63 | font_size, 64 | } 65 | } 66 | 67 | fn draw_cover(&self, url: String, mut draw_offset: Vec2, draw_size: Vec2) { 68 | if draw_size.x <= 1 || draw_size.y <= 1 { 69 | return; 70 | } 71 | 72 | let needs_redraw = { 73 | let last_size = self.last_size.read().unwrap(); 74 | let drawn_url = self.drawn_url.read().unwrap(); 75 | *last_size != draw_size || drawn_url.as_ref() != Some(&url) 76 | }; 77 | 78 | if !needs_redraw { 79 | return; 80 | } 81 | 82 | let path = match self.cache_path(url.clone()) { 83 | Some(p) => p, 84 | None => return, 85 | }; 86 | 87 | let mut img_size = Vec2::new(640, 640); 88 | 89 | let draw_size_pxls = draw_size * self.font_size; 90 | let ratio = f32::min( 91 | f32::min( 92 | draw_size_pxls.x as f32 / img_size.x as f32, 93 | draw_size_pxls.y as f32 / img_size.y as f32, 94 | ), 95 | 1.0, 96 | ); 97 | 98 | img_size = Vec2::new( 99 | (ratio * img_size.x as f32) as usize, 100 | (ratio * img_size.y as f32) as usize, 101 | ); 102 | 103 | // Ueberzug takes an area given in chars and fits the image to 104 | // that area (from the top left). Since we want to center the 105 | // image at least horizontally, we need to fiddle around a bit. 106 | let mut size = img_size / self.font_size; 107 | 108 | // Make sure there is equal space in chars on either side 109 | if size.x % 2 != draw_size.x % 2 { 110 | size.x -= 1; 111 | } 112 | 113 | // Make sure x is the bottleneck so full width is used 114 | size.y = std::cmp::min(draw_size.y, size.y + 1); 115 | 116 | // Round up since the bottom might have empty space within 117 | // the designated box 118 | draw_offset.x += (draw_size.x - size.x) / 2; 119 | draw_offset.y += (draw_size.y - size.y) - (draw_size.y - size.y) / 2; 120 | 121 | let cmd = format!("{{\"action\":\"add\",\"scaler\":\"fit_contain\",\"identifier\":\"cover\",\"x\":{},\"y\":{},\"width\":{},\"height\":{},\"path\":\"{}\"}}\n", 122 | draw_offset.x, draw_offset.y, 123 | size.x, size.y, 124 | path.to_str().unwrap() 125 | ); 126 | 127 | if let Err(e) = self.run_ueberzug_cmd(&cmd) { 128 | error!("Failed to run Ueberzug: {}", e); 129 | return; 130 | } 131 | 132 | let mut last_size = self.last_size.write().unwrap(); 133 | *last_size = draw_size; 134 | 135 | let mut drawn_url = self.drawn_url.write().unwrap(); 136 | *drawn_url = Some(url); 137 | } 138 | 139 | fn clear_cover(&self) { 140 | let mut drawn_url = self.drawn_url.write().unwrap(); 141 | *drawn_url = None; 142 | 143 | let cmd = "{\"action\": \"remove\", \"identifier\": \"cover\"}\n"; 144 | if let Err(e) = self.run_ueberzug_cmd(cmd) { 145 | error!("Failed to run Ueberzug: {}", e); 146 | } 147 | } 148 | 149 | fn run_ueberzug_cmd(&self, cmd: &str) -> Result<(), std::io::Error> { 150 | let mut ueberzug = self.ueberzug.write().unwrap(); 151 | 152 | if ueberzug.is_none() { 153 | *ueberzug = Some( 154 | std::process::Command::new("ueberzug") 155 | .args(&["layer", "--silent"]) 156 | .stdin(Stdio::piped()) 157 | .stdout(Stdio::piped()) 158 | .spawn()?, 159 | ); 160 | } 161 | 162 | let stdin = (*ueberzug).as_mut().unwrap().stdin.as_mut().unwrap(); 163 | stdin.write_all(cmd.as_bytes())?; 164 | 165 | Ok(()) 166 | } 167 | 168 | fn cache_path(&self, url: String) -> Option { 169 | let path = cache_path_for_url(url.clone()); 170 | 171 | let mut loading = self.loading.write().unwrap(); 172 | if loading.contains(&url) { 173 | return None; 174 | } 175 | 176 | if path.exists() { 177 | return Some(path); 178 | } 179 | 180 | loading.insert(url.clone()); 181 | 182 | let loading_thread = self.loading.clone(); 183 | std::thread::spawn(move || { 184 | if let Err(e) = download(url.clone(), path.clone()) { 185 | error!("Failed to download cover: {}", e); 186 | } 187 | let mut loading = loading_thread.write().unwrap(); 188 | loading.remove(&url.clone()); 189 | }); 190 | 191 | None 192 | } 193 | } 194 | 195 | impl View for CoverView { 196 | fn draw(&self, printer: &Printer<'_, '_>) { 197 | // Completely blank out screen 198 | let style = ColorStyle::new( 199 | ColorType::Palette(PaletteColor::Background), 200 | ColorType::Palette(PaletteColor::Background), 201 | ); 202 | printer.with_color(style, |printer| { 203 | for i in 0..printer.size.y { 204 | printer.print_hline((0, i), printer.size.x, " "); 205 | } 206 | }); 207 | 208 | let cover_url = self.queue.get_current().map(|t| t.cover_url()).flatten(); 209 | 210 | if let Some(url) = cover_url { 211 | self.draw_cover(url, printer.offset, printer.size); 212 | } else { 213 | self.clear_cover(); 214 | } 215 | } 216 | 217 | fn required_size(&mut self, constraint: Vec2) -> Vec2 { 218 | Vec2::new(constraint.x, 2) 219 | } 220 | } 221 | 222 | impl ViewExt for CoverView { 223 | fn title(&self) -> String { 224 | "Cover".to_string() 225 | } 226 | 227 | fn on_leave(&self) { 228 | self.clear_cover(); 229 | } 230 | 231 | fn on_command(&mut self, _s: &mut Cursive, cmd: &Command) -> Result { 232 | match cmd { 233 | Command::Save => { 234 | if let Some(mut track) = self.queue.get_current() { 235 | track.save(self.library.clone()); 236 | } 237 | } 238 | Command::Delete => { 239 | if let Some(mut track) = self.queue.get_current() { 240 | track.unsave(self.library.clone()); 241 | } 242 | } 243 | Command::Share(_mode) => { 244 | let url = self 245 | .queue 246 | .get_current() 247 | .and_then(|t| t.as_listitem().share_url()); 248 | 249 | if let Some(url) = url { 250 | #[cfg(feature = "share_clipboard")] 251 | crate::sharing::write_share(url); 252 | } 253 | 254 | return Ok(CommandResult::Consumed(None)); 255 | } 256 | Command::Goto(mode) => { 257 | if let Some(track) = self.queue.get_current() { 258 | let queue = self.queue.clone(); 259 | let library = self.library.clone(); 260 | 261 | match mode { 262 | GotoMode::Album => { 263 | if let Some(album) = track.album(queue.clone()) { 264 | let view = 265 | AlbumView::new(queue, library, &album).into_boxed_view_ext(); 266 | return Ok(CommandResult::View(view)); 267 | } 268 | } 269 | GotoMode::Artist => { 270 | if let Some(artists) = track.artists() { 271 | return match artists.len() { 272 | 0 => Ok(CommandResult::Consumed(None)), 273 | // Always choose the first artist even with more because 274 | // the cover image really doesn't play nice with the menu 275 | _ => { 276 | let view = ArtistView::new(queue, library, &artists[0]) 277 | .into_boxed_view_ext(); 278 | Ok(CommandResult::View(view)) 279 | } 280 | }; 281 | } 282 | } 283 | } 284 | } 285 | } 286 | _ => {} 287 | }; 288 | 289 | Ok(CommandResult::Ignored) 290 | } 291 | } 292 | 293 | pub fn cache_path_for_url(url: String) -> PathBuf { 294 | let mut path = crate::config::cache_path("covers"); 295 | path.push(url.split('/').last().unwrap()); 296 | path 297 | } 298 | 299 | pub fn download(url: String, path: PathBuf) -> Result<(), std::io::Error> { 300 | let mut resp = reqwest::blocking::get(&url) 301 | .map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e))?; 302 | 303 | std::fs::create_dir_all(path.parent().unwrap())?; 304 | let mut file = File::create(path)?; 305 | 306 | std::io::copy(&mut resp, &mut file)?; 307 | Ok(()) 308 | } 309 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | #[macro_use] 2 | extern crate cursive; 3 | #[macro_use] 4 | extern crate lazy_static; 5 | #[macro_use] 6 | extern crate serde; 7 | 8 | use std::fs; 9 | use std::path::PathBuf; 10 | use std::str::FromStr; 11 | use std::sync::Arc; 12 | 13 | use clap::{App, Arg}; 14 | use cursive::event::EventTrigger; 15 | use cursive::traits::Identifiable; 16 | use librespot_core::authentication::Credentials; 17 | use librespot_core::cache::Cache; 18 | use librespot_playback::audio_backend; 19 | use log::{info, trace}; 20 | use signal_hook::{consts::SIGHUP, iterator::Signals}; 21 | 22 | mod album; 23 | mod artist; 24 | mod authentication; 25 | mod command; 26 | mod commands; 27 | mod config; 28 | mod episode; 29 | mod events; 30 | mod library; 31 | mod playable; 32 | mod playlist; 33 | mod queue; 34 | mod serialization; 35 | mod sharing; 36 | mod show; 37 | mod spotify; 38 | mod spotify_url; 39 | mod spotify_worker; 40 | mod theme; 41 | mod token; 42 | mod track; 43 | mod traits; 44 | mod ui; 45 | mod utils; 46 | 47 | #[cfg(feature = "mpris")] 48 | mod mpris; 49 | 50 | use crate::command::{Command, JumpMode}; 51 | use crate::commands::CommandManager; 52 | use crate::config::Config; 53 | use crate::events::{Event, EventManager}; 54 | use crate::library::Library; 55 | use crate::spotify::PlayerEvent; 56 | use crate::ui::contextmenu::ContextMenu; 57 | 58 | fn setup_logging(filename: &str) -> Result<(), fern::InitError> { 59 | fern::Dispatch::new() 60 | // Perform allocation-free log formatting 61 | .format(|out, message, record| { 62 | out.finish(format_args!( 63 | "{} [{}] [{}] {}", 64 | chrono::Local::now().format("[%Y-%m-%d][%H:%M:%S]"), 65 | record.target(), 66 | record.level(), 67 | message 68 | )) 69 | }) 70 | // Add blanket level filter - 71 | .level(log::LevelFilter::Trace) 72 | // - and per-module overrides 73 | .level_for("librespot", log::LevelFilter::Debug) 74 | // Output to stdout, files, and other Dispatch configurations 75 | .chain(fern::log_file(filename)?) 76 | // Apply globally 77 | .apply()?; 78 | Ok(()) 79 | } 80 | 81 | fn credentials_prompt(error_message: Option) -> Result { 82 | if let Some(message) = error_message { 83 | let mut siv = cursive::default(); 84 | let dialog = cursive::views::Dialog::around(cursive::views::TextView::new(format!( 85 | "Connection error:\n{}", 86 | message 87 | ))) 88 | .button("Ok", |s| s.quit()); 89 | siv.add_layer(dialog); 90 | siv.run(); 91 | } 92 | 93 | authentication::create_credentials() 94 | } 95 | 96 | type UserData = Arc; 97 | struct UserDataInner { 98 | pub cmd: CommandManager, 99 | } 100 | 101 | #[tokio::main] 102 | async fn main() -> Result<(), String> { 103 | let backends = { 104 | let backends: Vec<&str> = audio_backend::BACKENDS.iter().map(|b| b.0).collect(); 105 | format!("Audio backends: {}", backends.join(", ")) 106 | }; 107 | let matches = App::new("ncspot") 108 | .version(env!("CARGO_PKG_VERSION")) 109 | .author("Henrik Friedrichsen and contributors") 110 | .about("cross-platform ncurses Spotify client") 111 | .after_help(&*backends) 112 | .arg( 113 | Arg::with_name("debug") 114 | .short("d") 115 | .long("debug") 116 | .value_name("FILE") 117 | .help("Enable debug logging to the specified file") 118 | .takes_value(true), 119 | ) 120 | .arg( 121 | Arg::with_name("basepath") 122 | .short("b") 123 | .long("basepath") 124 | .value_name("PATH") 125 | .help("custom basepath to config/cache files") 126 | .takes_value(true), 127 | ) 128 | .arg( 129 | Arg::with_name("config") 130 | .short("c") 131 | .long("config") 132 | .value_name("FILE") 133 | .help("Filename of config file in basepath") 134 | .takes_value(true) 135 | .default_value("config.toml"), 136 | ) 137 | .get_matches(); 138 | 139 | if let Some(filename) = matches.value_of("debug") { 140 | setup_logging(filename).expect("can't setup logging"); 141 | } 142 | 143 | if let Some(basepath) = matches.value_of("basepath") { 144 | let path = PathBuf::from_str(basepath).expect("invalid path"); 145 | if !path.exists() { 146 | fs::create_dir_all(&path).expect("could not create basepath directory"); 147 | } 148 | *config::BASE_PATH.write().unwrap() = Some(path); 149 | } 150 | 151 | // Things here may cause the process to abort; we must do them before creating curses windows 152 | // otherwise the error message will not be seen by a user 153 | let cfg: Arc = Arc::new(Config::new( 154 | matches.value_of("config").unwrap_or("config.toml"), 155 | )); 156 | 157 | let cache = Cache::new( 158 | Some(config::cache_path("librespot")), 159 | Some(config::cache_path("librespot").join("files")), 160 | None, 161 | ) 162 | .expect("Could not create librespot cache"); 163 | let mut credentials = { 164 | let cached_credentials = cache.credentials(); 165 | match cached_credentials { 166 | Some(c) => { 167 | info!("Using cached credentials"); 168 | c 169 | } 170 | None => credentials_prompt(None)?, 171 | } 172 | }; 173 | 174 | while let Err(error) = spotify::Spotify::test_credentials(credentials.clone()) { 175 | let error_msg = format!("{}", error); 176 | credentials = credentials_prompt(Some(error_msg))?; 177 | } 178 | 179 | let mut cursive = cursive::default().into_runner(); 180 | let theme = cfg.build_theme(); 181 | cursive.set_theme(theme.clone()); 182 | 183 | let event_manager = EventManager::new(cursive.cb_sink().clone()); 184 | 185 | println!("Connecting to Spotify.."); 186 | let spotify = spotify::Spotify::new(event_manager.clone(), credentials, cfg.clone()); 187 | 188 | let queue = Arc::new(queue::Queue::new(spotify.clone(), cfg.clone())); 189 | 190 | #[cfg(feature = "mpris")] 191 | let mpris_manager = Arc::new(mpris::MprisManager::new( 192 | event_manager.clone(), 193 | spotify.clone(), 194 | queue.clone(), 195 | )); 196 | 197 | let library = Arc::new(Library::new(&event_manager, spotify.clone(), cfg.clone())); 198 | 199 | let mut cmd_manager = CommandManager::new( 200 | spotify.clone(), 201 | queue.clone(), 202 | library.clone(), 203 | cfg.clone(), 204 | event_manager.clone(), 205 | ); 206 | 207 | cmd_manager.register_all(); 208 | cmd_manager.register_keybindings(&mut cursive); 209 | 210 | let user_data: UserData = Arc::new(UserDataInner { cmd: cmd_manager }); 211 | cursive.set_user_data(user_data.clone()); 212 | 213 | let search = ui::search::SearchView::new(event_manager.clone(), queue.clone(), library.clone()); 214 | 215 | let libraryview = ui::library::LibraryView::new(queue.clone(), library.clone()); 216 | 217 | let queueview = ui::queue::QueueView::new(queue.clone(), library.clone()); 218 | 219 | #[cfg(feature = "cover")] 220 | let coverview = ui::cover::CoverView::new(queue.clone(), library.clone(), &cfg); 221 | 222 | let status = ui::statusbar::StatusBar::new(queue.clone(), library); 223 | 224 | let mut layout = ui::layout::Layout::new(status, &event_manager, theme) 225 | .screen("search", search.with_name("search")) 226 | .screen("library", libraryview.with_name("library")) 227 | .screen("queue", queueview); 228 | 229 | #[cfg(feature = "cover")] 230 | layout.add_screen("cover", coverview.with_name("cover")); 231 | 232 | // initial screen is library 233 | layout.set_screen("library"); 234 | 235 | let cmd_key = |cfg: Arc| cfg.values().command_key.unwrap_or(':'); 236 | 237 | { 238 | let c = cfg.clone(); 239 | cursive.set_on_post_event( 240 | EventTrigger::from_fn(move |event| { 241 | event == &cursive::event::Event::Char(cmd_key(c.clone())) 242 | }), 243 | move |s| { 244 | if s.find_name::("contextmenu").is_none() { 245 | s.call_on_name("main", |v: &mut ui::layout::Layout| { 246 | v.enable_cmdline(cmd_key(cfg.clone())); 247 | }); 248 | } 249 | }, 250 | ); 251 | } 252 | 253 | cursive.add_global_callback('/', move |s| { 254 | if s.find_name::("contextmenu").is_none() { 255 | s.call_on_name("main", |v: &mut ui::layout::Layout| { 256 | v.enable_jump(); 257 | }); 258 | } 259 | }); 260 | 261 | cursive.add_global_callback(cursive::event::Key::Esc, move |s| { 262 | if s.find_name::("contextmenu").is_none() { 263 | s.call_on_name("main", |v: &mut ui::layout::Layout| { 264 | v.clear_cmdline(); 265 | }); 266 | } 267 | }); 268 | 269 | layout.cmdline.set_on_edit(move |s, cmd, _| { 270 | s.call_on_name("main", |v: &mut ui::layout::Layout| { 271 | if cmd.is_empty() { 272 | v.clear_cmdline(); 273 | } 274 | }); 275 | }); 276 | 277 | { 278 | let ev = event_manager.clone(); 279 | layout.cmdline.set_on_submit(move |s, cmd| { 280 | { 281 | let mut main = s.find_name::("main").unwrap(); 282 | main.clear_cmdline(); 283 | } 284 | let cmd_without_prefix = &cmd[1..]; 285 | if cmd.strip_prefix('/').is_some() { 286 | let command = Command::Jump(JumpMode::Query(cmd_without_prefix.to_string())); 287 | if let Some(data) = s.user_data::().cloned() { 288 | data.cmd.handle(s, command); 289 | } 290 | } else { 291 | let parsed = command::parse(cmd_without_prefix); 292 | if let Some(parsed) = parsed { 293 | if let Some(data) = s.user_data::().cloned() { 294 | data.cmd.handle(s, parsed) 295 | } 296 | } else { 297 | let mut main = s.find_name::("main").unwrap(); 298 | let err_msg = format!("Unknown command: \"{}\"", cmd_without_prefix); 299 | main.set_result(Err(err_msg)); 300 | } 301 | } 302 | ev.trigger(); 303 | }); 304 | } 305 | 306 | cursive.add_fullscreen_layer(layout.with_name("main")); 307 | 308 | let mut signals = Signals::new(&[SIGHUP]).unwrap(); 309 | 310 | // cursive event loop 311 | while cursive.is_running() { 312 | for signal in signals.pending() { 313 | match signal as i32 { 314 | SIGHUP => { 315 | user_data.cmd.handle(&mut cursive, Command::Quit); 316 | } 317 | _ => {} 318 | } 319 | } 320 | cursive.step(); 321 | for event in event_manager.msg_iter() { 322 | match event { 323 | Event::Player(state) => { 324 | trace!("event received: {:?}", state); 325 | spotify.update_status(state.clone()); 326 | 327 | #[cfg(feature = "mpris")] 328 | mpris_manager.update(); 329 | 330 | if state == PlayerEvent::FinishedTrack { 331 | queue.next(false); 332 | } 333 | } 334 | Event::Queue(event) => { 335 | queue.handle_event(event); 336 | } 337 | Event::SessionDied => spotify.start_worker(None), 338 | } 339 | } 340 | } 341 | 342 | Ok(()) 343 | } 344 | -------------------------------------------------------------------------------- /src/ui/layout.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashMap; 2 | use std::time::{Duration, SystemTime}; 3 | 4 | use cursive::align::HAlign; 5 | use cursive::direction::Direction; 6 | use cursive::event::{AnyCb, Event, EventResult}; 7 | use cursive::theme::{ColorStyle, ColorType, Theme}; 8 | use cursive::traits::View; 9 | use cursive::vec::Vec2; 10 | use cursive::view::{IntoBoxedView, Selector}; 11 | use cursive::views::EditView; 12 | use cursive::{Cursive, Printer}; 13 | use log::debug; 14 | use unicode_width::UnicodeWidthStr; 15 | 16 | use crate::command::Command; 17 | use crate::commands::CommandResult; 18 | use crate::events; 19 | use crate::traits::{IntoBoxedViewExt, ViewExt}; 20 | 21 | pub struct Layout { 22 | screens: HashMap>, 23 | stack: HashMap>>, 24 | statusbar: Box, 25 | focus: Option, 26 | pub cmdline: EditView, 27 | cmdline_focus: bool, 28 | result: Result, String>, 29 | result_time: Option, 30 | screenchange: bool, 31 | last_size: Vec2, 32 | ev: events::EventManager, 33 | theme: Theme, 34 | } 35 | 36 | impl Layout { 37 | pub fn new(status: T, ev: &events::EventManager, theme: Theme) -> Layout { 38 | let style = ColorStyle::new( 39 | ColorType::Color(*theme.palette.custom("cmdline_bg").unwrap()), 40 | ColorType::Color(*theme.palette.custom("cmdline").unwrap()), 41 | ); 42 | 43 | Layout { 44 | screens: HashMap::new(), 45 | stack: HashMap::new(), 46 | statusbar: status.into_boxed_view(), 47 | focus: None, 48 | cmdline: EditView::new().filler(" ").style(style), 49 | cmdline_focus: false, 50 | result: Ok(None), 51 | result_time: None, 52 | screenchange: true, 53 | last_size: Vec2::new(0, 0), 54 | ev: ev.clone(), 55 | theme, 56 | } 57 | } 58 | 59 | pub fn enable_cmdline(&mut self, prefix: char) { 60 | if !self.cmdline_focus { 61 | self.cmdline.set_content(prefix); 62 | self.cmdline_focus = true; 63 | } 64 | } 65 | 66 | pub fn enable_jump(&mut self) { 67 | if !self.cmdline_focus { 68 | self.cmdline.set_content("/"); 69 | self.cmdline_focus = true; 70 | } 71 | } 72 | 73 | pub fn add_screen, T: IntoBoxedViewExt>(&mut self, id: S, view: T) { 74 | if let Some(view) = self.get_top_view() { 75 | view.on_leave(); 76 | } 77 | 78 | let s = id.into(); 79 | self.screens.insert(s.clone(), view.into_boxed_view_ext()); 80 | self.stack.insert(s.clone(), Vec::new()); 81 | self.focus = Some(s); 82 | } 83 | 84 | pub fn screen, T: IntoBoxedViewExt>(mut self, id: S, view: T) -> Self { 85 | (&mut self).add_screen(id, view); 86 | self 87 | } 88 | 89 | pub fn set_screen>(&mut self, id: S) { 90 | if let Some(view) = self.get_top_view() { 91 | view.on_leave(); 92 | } 93 | 94 | let s = id.into(); 95 | self.focus = Some(s); 96 | self.cmdline_focus = false; 97 | self.screenchange = true; 98 | 99 | // trigger a redraw 100 | self.ev.trigger(); 101 | } 102 | 103 | pub fn set_result(&mut self, result: Result, String>) { 104 | self.result = result; 105 | self.result_time = Some(SystemTime::now()); 106 | } 107 | 108 | pub fn clear_cmdline(&mut self) { 109 | self.cmdline.set_content(""); 110 | self.cmdline_focus = false; 111 | self.result = Ok(None); 112 | self.result_time = None; 113 | } 114 | 115 | fn get_result(&self) -> Result, String> { 116 | if let Some(t) = self.result_time { 117 | if t.elapsed().unwrap() > Duration::from_secs(5) { 118 | return Ok(None); 119 | } 120 | } 121 | self.result.clone() 122 | } 123 | 124 | pub fn push_view(&mut self, view: Box) { 125 | if let Some(view) = self.get_top_view() { 126 | view.on_leave(); 127 | } 128 | 129 | if let Some(stack) = self.get_focussed_stack_mut() { 130 | stack.push(view) 131 | } 132 | } 133 | 134 | pub fn pop_view(&mut self) { 135 | if let Some(view) = self.get_top_view() { 136 | view.on_leave(); 137 | } 138 | 139 | self.get_focussed_stack_mut().map(|stack| stack.pop()); 140 | } 141 | 142 | fn get_current_screen(&self) -> Option<&Box> { 143 | self.focus 144 | .as_ref() 145 | .and_then(|focus| self.screens.get(focus)) 146 | } 147 | 148 | fn get_focussed_stack_mut(&mut self) -> Option<&mut Vec>> { 149 | let focus = self.focus.clone(); 150 | if let Some(focus) = &focus { 151 | self.stack.get_mut(focus) 152 | } else { 153 | None 154 | } 155 | } 156 | 157 | fn get_focussed_stack(&self) -> Option<&Vec>> { 158 | self.focus.as_ref().and_then(|focus| self.stack.get(focus)) 159 | } 160 | 161 | fn get_top_view(&self) -> Option<&Box> { 162 | let focussed_stack = self.get_focussed_stack(); 163 | if focussed_stack.map(|s| s.len()).unwrap_or_default() > 0 { 164 | focussed_stack.unwrap().last() 165 | } else if let Some(id) = &self.focus { 166 | self.screens.get(id) 167 | } else { 168 | None 169 | } 170 | } 171 | 172 | fn get_current_view_mut(&mut self) -> Option<&mut Box> { 173 | if let Some(focus) = &self.focus { 174 | let last_view = self 175 | .stack 176 | .get_mut(focus) 177 | .filter(|stack| !stack.is_empty()) 178 | .and_then(|stack| stack.last_mut()); 179 | if last_view.is_some() { 180 | last_view 181 | } else { 182 | self.screens.get_mut(focus) 183 | } 184 | } else { 185 | None 186 | } 187 | } 188 | } 189 | 190 | impl View for Layout { 191 | fn draw(&self, printer: &Printer<'_, '_>) { 192 | let result = self.get_result(); 193 | 194 | let cmdline_visible = self.cmdline.get_content().len() > 0; 195 | let mut cmdline_height = if cmdline_visible { 1 } else { 0 }; 196 | if result.as_ref().map(Option::is_some).unwrap_or(true) { 197 | cmdline_height += 1; 198 | } 199 | 200 | let screen_title = self 201 | .get_current_screen() 202 | .map(|screen| screen.title()) 203 | .unwrap_or_default(); 204 | 205 | if let Some(view) = self.get_top_view() { 206 | // back button + title 207 | if !self 208 | .get_focussed_stack() 209 | .map(|s| s.is_empty()) 210 | .unwrap_or(false) 211 | { 212 | printer.with_color(ColorStyle::title_secondary(), |printer| { 213 | printer.print((1, 0), &format!("< {}", screen_title)); 214 | }); 215 | } 216 | 217 | // view title 218 | printer.with_color(ColorStyle::title_primary(), |printer| { 219 | let offset = HAlign::Center.get_offset(view.title().width(), printer.size.x); 220 | printer.print((offset, 0), &view.title()); 221 | }); 222 | 223 | printer.with_color(ColorStyle::secondary(), |printer| { 224 | let offset = HAlign::Right.get_offset(view.title_sub().width(), printer.size.x); 225 | printer.print((offset, 0), &view.title_sub()); 226 | }); 227 | 228 | // screen content 229 | let printer = &printer 230 | .offset((0, 1)) 231 | .cropped((printer.size.x, printer.size.y - 3 - cmdline_height)) 232 | .focused(true); 233 | view.draw(printer); 234 | } 235 | 236 | self.statusbar 237 | .draw(&printer.offset((0, printer.size.y - 2 - cmdline_height))); 238 | 239 | if let Ok(Some(r)) = result { 240 | printer.print_hline((0, printer.size.y - cmdline_height), printer.size.x, " "); 241 | printer.print((0, printer.size.y - cmdline_height), &r); 242 | } else if let Err(e) = result { 243 | let style = ColorStyle::new( 244 | ColorType::Color(*self.theme.palette.custom("error").unwrap()), 245 | ColorType::Color(*self.theme.palette.custom("error_bg").unwrap()), 246 | ); 247 | 248 | printer.with_color(style, |printer| { 249 | printer.print_hline((0, printer.size.y - cmdline_height), printer.size.x, " "); 250 | printer.print( 251 | (0, printer.size.y - cmdline_height), 252 | &format!("ERROR: {}", e), 253 | ); 254 | }); 255 | } 256 | 257 | if cmdline_visible { 258 | let printer = &printer.offset((0, printer.size.y - 1)); 259 | self.cmdline.draw(&printer); 260 | } 261 | } 262 | 263 | fn layout(&mut self, size: Vec2) { 264 | self.last_size = size; 265 | 266 | self.statusbar.layout(Vec2::new(size.x, 2)); 267 | 268 | self.cmdline.layout(Vec2::new(size.x, 1)); 269 | 270 | if let Some(view) = self.get_current_view_mut() { 271 | view.layout(Vec2::new(size.x, size.y - 3)); 272 | } 273 | 274 | // the focus view has changed, let the views know so they can redraw 275 | // their items 276 | if self.screenchange { 277 | debug!("layout: new screen selected: {:?}", self.focus); 278 | self.screenchange = false; 279 | } 280 | } 281 | 282 | fn required_size(&mut self, constraint: Vec2) -> Vec2 { 283 | Vec2::new(constraint.x, constraint.y) 284 | } 285 | 286 | fn on_event(&mut self, event: Event) -> EventResult { 287 | if let Event::Mouse { position, .. } = event { 288 | let result = self.get_result(); 289 | 290 | let cmdline_visible = self.cmdline.get_content().len() > 0; 291 | let mut cmdline_height = if cmdline_visible { 1 } else { 0 }; 292 | if result.as_ref().map(Option::is_some).unwrap_or(true) { 293 | cmdline_height += 1; 294 | } 295 | 296 | if position.y < self.last_size.y.saturating_sub(2 + cmdline_height) { 297 | if let Some(view) = self.get_current_view_mut() { 298 | view.on_event(event); 299 | } 300 | } else if position.y < self.last_size.y - cmdline_height { 301 | self.statusbar.on_event( 302 | event.relativized(Vec2::new(0, self.last_size.y - 2 - cmdline_height)), 303 | ); 304 | } 305 | 306 | return EventResult::Consumed(None); 307 | } 308 | 309 | if self.cmdline_focus { 310 | return self.cmdline.on_event(event); 311 | } 312 | 313 | if let Some(view) = self.get_current_view_mut() { 314 | view.on_event(event) 315 | } else { 316 | EventResult::Ignored 317 | } 318 | } 319 | 320 | fn call_on_any<'a>(&mut self, s: &Selector, c: AnyCb<'a>) { 321 | if let Some(view) = self.get_current_view_mut() { 322 | view.call_on_any(s, c); 323 | } 324 | } 325 | 326 | fn take_focus(&mut self, source: Direction) -> bool { 327 | if self.cmdline_focus { 328 | return self.cmdline.take_focus(source); 329 | } 330 | 331 | if let Some(view) = self.get_current_view_mut() { 332 | view.take_focus(source) 333 | } else { 334 | false 335 | } 336 | } 337 | } 338 | 339 | impl ViewExt for Layout { 340 | fn on_command(&mut self, s: &mut Cursive, cmd: &Command) -> Result { 341 | match cmd { 342 | Command::Focus(view) => { 343 | // Clear search results and return to search bar 344 | // If trying to focus search screen while already on it 345 | let search_view_name = "search"; 346 | if view == search_view_name && self.focus == Some(search_view_name.into()) { 347 | if let Some(stack) = self.stack.get_mut(search_view_name) { 348 | stack.clear(); 349 | } 350 | } 351 | 352 | if self.screens.keys().any(|k| k == view) { 353 | self.set_screen(view.clone()); 354 | let screen = self.screens.get_mut(view).unwrap(); 355 | screen.on_command(s, cmd)?; 356 | } 357 | 358 | Ok(CommandResult::Consumed(None)) 359 | } 360 | Command::Back => { 361 | self.pop_view(); 362 | Ok(CommandResult::Consumed(None)) 363 | } 364 | _ => { 365 | if let Some(view) = self.get_current_view_mut() { 366 | view.on_command(s, cmd) 367 | } else { 368 | Ok(CommandResult::Ignored) 369 | } 370 | } 371 | } 372 | } 373 | } 374 | -------------------------------------------------------------------------------- /src/command.rs: -------------------------------------------------------------------------------- 1 | use crate::queue::RepeatSetting; 2 | use std::collections::HashMap; 3 | use std::fmt; 4 | 5 | use strum_macros::Display; 6 | 7 | #[derive(Clone, Serialize, Deserialize, Debug)] 8 | pub enum SeekInterval { 9 | Forward, 10 | Backwards, 11 | Custom(usize), 12 | } 13 | 14 | #[derive(Display, Clone, Serialize, Deserialize, Debug)] 15 | #[strum(serialize_all = "lowercase")] 16 | pub enum TargetMode { 17 | Current, 18 | Selected, 19 | } 20 | 21 | #[derive(Display, Clone, Serialize, Deserialize, Debug)] 22 | #[strum(serialize_all = "lowercase")] 23 | pub enum MoveMode { 24 | Up, 25 | Down, 26 | Left, 27 | Right, 28 | Playing, 29 | } 30 | 31 | #[derive(Display, Clone, Serialize, Deserialize, Debug)] 32 | #[strum(serialize_all = "lowercase")] 33 | pub enum MoveAmount { 34 | Integer(i32), 35 | Extreme, 36 | } 37 | 38 | impl Default for MoveAmount { 39 | fn default() -> Self { 40 | MoveAmount::Integer(1) 41 | } 42 | } 43 | 44 | #[derive(Display, Clone, Serialize, Deserialize, Debug)] 45 | #[strum(serialize_all = "lowercase")] 46 | pub enum SortKey { 47 | Title, 48 | Duration, 49 | Artist, 50 | Album, 51 | Added, 52 | } 53 | 54 | #[derive(Display, Clone, Serialize, Deserialize, Debug)] 55 | #[strum(serialize_all = "lowercase")] 56 | pub enum SortDirection { 57 | Ascending, 58 | Descending, 59 | } 60 | 61 | #[derive(Display, Clone, Serialize, Deserialize, Debug)] 62 | #[strum(serialize_all = "lowercase")] 63 | pub enum JumpMode { 64 | Previous, 65 | Next, 66 | Query(String), 67 | } 68 | 69 | #[derive(Display, Clone, Serialize, Deserialize, Debug)] 70 | #[strum(serialize_all = "lowercase")] 71 | pub enum ShiftMode { 72 | Up, 73 | Down, 74 | } 75 | 76 | #[derive(Display, Clone, Serialize, Deserialize, Debug)] 77 | #[strum(serialize_all = "lowercase")] 78 | pub enum GotoMode { 79 | Album, 80 | Artist, 81 | } 82 | 83 | #[derive(Clone, Serialize, Deserialize, Debug)] 84 | pub enum SeekDirection { 85 | Relative(i32), 86 | Absolute(u32), 87 | } 88 | 89 | impl fmt::Display for SeekDirection { 90 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 91 | let repr = match self { 92 | SeekDirection::Absolute(pos) => format!("{}", pos), 93 | SeekDirection::Relative(delta) => { 94 | format!("{}{}", if delta > &0 { "+" } else { "" }, delta) 95 | } 96 | }; 97 | write!(f, "{}", repr) 98 | } 99 | } 100 | 101 | #[derive(Clone, Serialize, Deserialize, Debug)] 102 | pub enum Command { 103 | Quit, 104 | TogglePlay, 105 | Stop, 106 | Previous, 107 | Next, 108 | Clear, 109 | Queue, 110 | QueueAll, 111 | PlayNext, 112 | Play, 113 | UpdateLibrary, 114 | Save, 115 | SaveQueue, 116 | Delete, 117 | Focus(String), 118 | Seek(SeekDirection), 119 | VolumeUp(u16), 120 | VolumeDown(u16), 121 | Repeat(Option), 122 | Shuffle(Option), 123 | Share(TargetMode), 124 | Back, 125 | Open(TargetMode), 126 | Goto(GotoMode), 127 | Move(MoveMode, MoveAmount), 128 | Shift(ShiftMode, Option), 129 | Search(String), 130 | Jump(JumpMode), 131 | Help, 132 | ReloadConfig, 133 | Noop, 134 | Insert(Option), 135 | NewPlaylist(String), 136 | Sort(SortKey, SortDirection), 137 | Logout, 138 | } 139 | 140 | impl fmt::Display for Command { 141 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 142 | let repr = match self { 143 | Command::Noop => "noop".to_string(), 144 | Command::Quit => "quit".to_string(), 145 | Command::TogglePlay => "playpause".to_string(), 146 | Command::Stop => "stop".to_string(), 147 | Command::Previous => "previous".to_string(), 148 | Command::Next => "next".to_string(), 149 | Command::Clear => "clear".to_string(), 150 | Command::Queue => "queue".to_string(), 151 | Command::QueueAll => "queueall".to_string(), 152 | Command::PlayNext => "playnext".to_string(), 153 | Command::Play => "play".to_string(), 154 | Command::UpdateLibrary => "update".to_string(), 155 | Command::Save => "save".to_string(), 156 | Command::SaveQueue => "save queue".to_string(), 157 | Command::Delete => "delete".to_string(), 158 | Command::Focus(tab) => format!("focus {}", tab), 159 | Command::Seek(direction) => format!("seek {}", direction), 160 | Command::VolumeUp(amount) => format!("volup {}", amount), 161 | Command::VolumeDown(amount) => format!("voldown {}", amount), 162 | Command::Repeat(mode) => { 163 | let param = match mode { 164 | Some(mode) => format!("{}", mode), 165 | None => "".to_string(), 166 | }; 167 | format!("repeat {}", param) 168 | } 169 | Command::Shuffle(on) => { 170 | let param = on.map(|x| if x { "on" } else { "off" }); 171 | format!("shuffle {}", param.unwrap_or("")) 172 | } 173 | Command::Share(mode) => format!("share {}", mode), 174 | Command::Back => "back".to_string(), 175 | Command::Open(mode) => format!("open {}", mode), 176 | Command::Goto(mode) => format!("goto {}", mode), 177 | Command::Move(mode, MoveAmount::Extreme) => format!( 178 | "move {}", 179 | match mode { 180 | MoveMode::Up => "top", 181 | MoveMode::Down => "bottom", 182 | MoveMode::Left => "leftmost", 183 | MoveMode::Right => "rightmost", 184 | _ => "", 185 | } 186 | ), 187 | Command::Move(MoveMode::Playing, _) => "move playing".to_string(), 188 | Command::Move(mode, MoveAmount::Integer(amount)) => format!("move {} {}", mode, amount), 189 | Command::Shift(mode, amount) => format!("shift {} {}", mode, amount.unwrap_or(1)), 190 | Command::Search(term) => format!("search {}", term), 191 | Command::Jump(term) => format!("jump {}", term), 192 | Command::Help => "help".to_string(), 193 | Command::ReloadConfig => "reload".to_string(), 194 | Command::Insert(_) => "insert".to_string(), 195 | Command::NewPlaylist(name) => format!("new playlist {}", name), 196 | Command::Sort(key, direction) => format!("sort {} {}", key, direction), 197 | Command::Logout => "logout".to_string(), 198 | }; 199 | write!(f, "{}", repr) 200 | } 201 | } 202 | 203 | fn register_aliases(map: &mut HashMap<&str, &str>, cmd: &'static str, names: Vec<&'static str>) { 204 | for a in names { 205 | map.insert(a, cmd); 206 | } 207 | } 208 | 209 | lazy_static! { 210 | static ref ALIASES: HashMap<&'static str, &'static str> = { 211 | let mut m = HashMap::new(); 212 | 213 | register_aliases(&mut m, "quit", vec!["q", "x"]); 214 | register_aliases( 215 | &mut m, 216 | "playpause", 217 | vec!["pause", "toggleplay", "toggleplayback"], 218 | ); 219 | register_aliases(&mut m, "repeat", vec!["loop"]); 220 | 221 | m.insert("1", "foo"); 222 | m.insert("2", "bar"); 223 | m.insert("3", "baz"); 224 | m 225 | }; 226 | } 227 | 228 | fn handle_aliases(input: &str) -> &str { 229 | if let Some(cmd) = ALIASES.get(input) { 230 | handle_aliases(cmd) 231 | } else { 232 | input 233 | } 234 | } 235 | 236 | pub fn parse(input: &str) -> Option { 237 | let components: Vec<_> = input.trim().split(' ').collect(); 238 | 239 | let command = handle_aliases(&components[0]); 240 | let args = components[1..].to_vec(); 241 | 242 | match command { 243 | "quit" => Some(Command::Quit), 244 | "playpause" => Some(Command::TogglePlay), 245 | "stop" => Some(Command::Stop), 246 | "previous" => Some(Command::Previous), 247 | "next" => Some(Command::Next), 248 | "clear" => Some(Command::Clear), 249 | "playnext" => Some(Command::PlayNext), 250 | "queue" => Some(Command::Queue), 251 | "queueall" => Some(Command::QueueAll), 252 | "play" => Some(Command::Play), 253 | "update" => Some(Command::UpdateLibrary), 254 | "delete" => Some(Command::Delete), 255 | "back" => Some(Command::Back), 256 | "open" => args 257 | .get(0) 258 | .and_then(|target| match *target { 259 | "selected" => Some(TargetMode::Selected), 260 | "current" => Some(TargetMode::Current), 261 | _ => None, 262 | }) 263 | .map(Command::Open), 264 | "jump" => Some(Command::Jump(JumpMode::Query(args.join(" ")))), 265 | "search" => Some(Command::Search(args.join(" "))), 266 | "shift" => { 267 | let amount = args.get(1).and_then(|amount| amount.parse().ok()); 268 | 269 | args.get(0) 270 | .and_then(|direction| match *direction { 271 | "up" => Some(ShiftMode::Up), 272 | "down" => Some(ShiftMode::Down), 273 | _ => None, 274 | }) 275 | .map(|mode| Command::Shift(mode, amount)) 276 | } 277 | "move" => { 278 | let cmd: Option = { 279 | args.get(0).and_then(|extreme| match *extreme { 280 | "top" => Some(Command::Move(MoveMode::Up, MoveAmount::Extreme)), 281 | "bottom" => Some(Command::Move(MoveMode::Down, MoveAmount::Extreme)), 282 | "leftmost" => Some(Command::Move(MoveMode::Left, MoveAmount::Extreme)), 283 | "rightmost" => Some(Command::Move(MoveMode::Right, MoveAmount::Extreme)), 284 | "playing" => Some(Command::Move(MoveMode::Playing, MoveAmount::default())), 285 | _ => None, 286 | }) 287 | }; 288 | 289 | cmd.or({ 290 | let amount = args 291 | .get(1) 292 | .and_then(|amount| amount.parse().ok()) 293 | .map(MoveAmount::Integer) 294 | .unwrap_or_default(); 295 | 296 | args.get(0) 297 | .and_then(|direction| match *direction { 298 | "up" => Some(MoveMode::Up), 299 | "down" => Some(MoveMode::Down), 300 | "left" => Some(MoveMode::Left), 301 | "right" => Some(MoveMode::Right), 302 | _ => None, 303 | }) 304 | .map(|mode| Command::Move(mode, amount)) 305 | }) 306 | } 307 | "goto" => args 308 | .get(0) 309 | .and_then(|mode| match *mode { 310 | "album" => Some(GotoMode::Album), 311 | "artist" => Some(GotoMode::Artist), 312 | _ => None, 313 | }) 314 | .map(Command::Goto), 315 | "share" => args 316 | .get(0) 317 | .and_then(|target| match *target { 318 | "selected" => Some(TargetMode::Selected), 319 | "current" => Some(TargetMode::Current), 320 | _ => None, 321 | }) 322 | .map(Command::Share), 323 | "shuffle" => { 324 | let shuffle = args.get(0).and_then(|mode| match *mode { 325 | "on" => Some(true), 326 | "off" => Some(false), 327 | _ => None, 328 | }); 329 | 330 | Some(Command::Shuffle(shuffle)) 331 | } 332 | "repeat" => { 333 | let mode = args.get(0).and_then(|mode| match *mode { 334 | "list" | "playlist" | "queue" => Some(RepeatSetting::RepeatPlaylist), 335 | "track" | "once" => Some(RepeatSetting::RepeatTrack), 336 | "none" | "off" => Some(RepeatSetting::None), 337 | _ => None, 338 | }); 339 | 340 | Some(Command::Repeat(mode)) 341 | } 342 | "seek" => args.get(0).and_then(|arg| match arg.chars().next() { 343 | Some(x) if x == '-' || x == '+' => arg 344 | .chars() 345 | .skip(1) 346 | .collect::() 347 | .parse::() 348 | .ok() 349 | .map(|amount| { 350 | Command::Seek(SeekDirection::Relative( 351 | amount 352 | * match x { 353 | '-' => -1, 354 | _ => 1, 355 | }, 356 | )) 357 | }), 358 | _ => arg 359 | .chars() 360 | .collect::() 361 | .parse() 362 | .ok() 363 | .map(|amount| Command::Seek(SeekDirection::Absolute(amount))), 364 | }), 365 | "focus" => args 366 | .get(0) 367 | .map(|target| Command::Focus((*target).to_string())), 368 | "save" => args 369 | .get(0) 370 | .map(|target| match *target { 371 | "queue" => Command::SaveQueue, 372 | _ => Command::Save, 373 | }) 374 | .or(Some(Command::Save)), 375 | "volup" => Some(Command::VolumeUp( 376 | args.get(0).and_then(|v| v.parse::().ok()).unwrap_or(1), 377 | )), 378 | "voldown" => Some(Command::VolumeDown( 379 | args.get(0).and_then(|v| v.parse::().ok()).unwrap_or(1), 380 | )), 381 | "help" => Some(Command::Help), 382 | "reload" => Some(Command::ReloadConfig), 383 | "insert" => { 384 | if args.is_empty() { 385 | Some(Command::Insert(None)) 386 | } else { 387 | args.get(0) 388 | .map(|url| Command::Insert(Some((*url).to_string()))) 389 | } 390 | } 391 | "newplaylist" => { 392 | if !args.is_empty() { 393 | Some(Command::NewPlaylist(args.join(" "))) 394 | } else { 395 | None 396 | } 397 | } 398 | "sort" => { 399 | if !args.is_empty() { 400 | let sort_key = args.get(0).and_then(|key| match *key { 401 | "title" => Some(SortKey::Title), 402 | "duration" => Some(SortKey::Duration), 403 | "album" => Some(SortKey::Album), 404 | "added" => Some(SortKey::Added), 405 | "artist" => Some(SortKey::Artist), 406 | _ => None, 407 | })?; 408 | 409 | let sort_direction = args 410 | .get(1) 411 | .map(|direction| match *direction { 412 | "a" => SortDirection::Ascending, 413 | "asc" => SortDirection::Ascending, 414 | "ascending" => SortDirection::Ascending, 415 | "d" => SortDirection::Descending, 416 | "desc" => SortDirection::Descending, 417 | "descending" => SortDirection::Descending, 418 | _ => SortDirection::Ascending, 419 | }) 420 | .unwrap_or(SortDirection::Ascending); 421 | 422 | Some(Command::Sort(sort_key, sort_direction)) 423 | } else { 424 | None 425 | } 426 | } 427 | "logout" => Some(Command::Logout), 428 | "noop" => Some(Command::Noop), 429 | _ => None, 430 | } 431 | } 432 | --------------------------------------------------------------------------------