├── .github ├── FUNDING.yml └── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md ├── .rustfmt.toml ├── psst-gui ├── assets │ ├── logo.icns │ ├── logo_32.png │ ├── logo_64.png │ ├── logo.afdesign │ ├── logo_128.png │ ├── logo_256.png │ ├── logo_512.png │ └── screenshot.png ├── src │ ├── webapi │ │ ├── mod.rs │ │ └── cache.rs │ ├── data │ │ ├── id.rs │ │ ├── user.rs │ │ ├── slider_scroll_scale.rs │ │ ├── find.rs │ │ ├── search.rs │ │ ├── artist.rs │ │ ├── playlist.rs │ │ ├── promise.rs │ │ ├── track.rs │ │ ├── show.rs │ │ ├── nav.rs │ │ ├── album.rs │ │ ├── utils.rs │ │ ├── recommend.rs │ │ └── ctx.rs │ ├── error.rs │ ├── widget │ │ ├── empty.rs │ │ ├── overlay.rs │ │ ├── theme.rs │ │ ├── fill_between.rs │ │ ├── link.rs │ │ ├── remote_image.rs │ │ ├── dispatcher.rs │ │ ├── checkbox.rs │ │ └── mod.rs │ ├── controller │ │ ├── on_update.rs │ │ ├── mod.rs │ │ ├── ex_cursor.rs │ │ ├── alert_cleanup.rs │ │ ├── on_command.rs │ │ ├── on_debounce.rs │ │ ├── after_delay.rs │ │ ├── session.rs │ │ ├── ex_scroll.rs │ │ ├── ex_click.rs │ │ ├── input.rs │ │ ├── sort.rs │ │ └── on_command_async.rs │ ├── ui │ │ ├── user.rs │ │ ├── menu.rs │ │ ├── lyrics.rs │ │ ├── episode.rs │ │ └── recommend.rs │ ├── main.rs │ └── cmd.rs ├── build.rs ├── build-icons.sh └── Cargo.toml ├── .gitignore ├── psst-core ├── src │ ├── audio │ │ ├── mod.rs │ │ ├── output │ │ │ └── mod.rs │ │ ├── decrypt.rs │ │ ├── normalize.rs │ │ ├── probe.rs │ │ ├── resample.rs │ │ └── source.rs │ ├── system_info.rs │ ├── session │ │ ├── token.rs │ │ ├── audio_key.rs │ │ └── access_token.rs │ ├── lib.rs │ ├── connection │ │ ├── diffie_hellman.rs │ │ └── shannon_codec.rs │ ├── actor.rs │ ├── lastfm.rs │ ├── error.rs │ ├── metadata.rs │ ├── player │ │ └── queue.rs │ ├── util.rs │ ├── cache.rs │ └── cdn.rs ├── build.rs └── Cargo.toml ├── .pkg ├── psst.desktop ├── DEBIAN │ └── control ├── APPIMAGE │ └── pkg2appimage-ingredients.yml └── copyright ├── psst-cli ├── Cargo.toml └── src │ └── main.rs ├── Cargo.toml ├── Cross.toml ├── .homebrew └── generate_formula.sh └── LICENSE.md /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: jpochyla 2 | -------------------------------------------------------------------------------- /.rustfmt.toml: -------------------------------------------------------------------------------- 1 | imports_granularity = "Crate" 2 | wrap_comments = true 3 | -------------------------------------------------------------------------------- /psst-gui/assets/logo.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jpochyla/psst/HEAD/psst-gui/assets/logo.icns -------------------------------------------------------------------------------- /psst-gui/assets/logo_32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jpochyla/psst/HEAD/psst-gui/assets/logo_32.png -------------------------------------------------------------------------------- /psst-gui/assets/logo_64.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jpochyla/psst/HEAD/psst-gui/assets/logo_64.png -------------------------------------------------------------------------------- /psst-gui/assets/logo.afdesign: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jpochyla/psst/HEAD/psst-gui/assets/logo.afdesign -------------------------------------------------------------------------------- /psst-gui/assets/logo_128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jpochyla/psst/HEAD/psst-gui/assets/logo_128.png -------------------------------------------------------------------------------- /psst-gui/assets/logo_256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jpochyla/psst/HEAD/psst-gui/assets/logo_256.png -------------------------------------------------------------------------------- /psst-gui/assets/logo_512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jpochyla/psst/HEAD/psst-gui/assets/logo_512.png -------------------------------------------------------------------------------- /psst-gui/src/webapi/mod.rs: -------------------------------------------------------------------------------- 1 | mod cache; 2 | mod client; 3 | mod local; 4 | 5 | pub use client::WebApi; 6 | -------------------------------------------------------------------------------- /psst-gui/assets/screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jpochyla/psst/HEAD/psst-gui/assets/screenshot.png -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | config.json 2 | target 3 | cache 4 | .cargo 5 | .idea 6 | .DS_Store 7 | .env 8 | *.iml 9 | rust-toolchain 10 | *.ico -------------------------------------------------------------------------------- /psst-core/src/audio/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod decode; 2 | pub mod decrypt; 3 | pub mod normalize; 4 | pub mod output; 5 | pub mod probe; 6 | pub mod resample; 7 | pub mod source; 8 | -------------------------------------------------------------------------------- /psst-gui/src/data/id.rs: -------------------------------------------------------------------------------- 1 | #[allow(dead_code)] 2 | pub trait Id { 3 | type Id: PartialEq; 4 | 5 | fn id(&self) -> Self::Id; 6 | 7 | fn has_id(&self, id: &Self::Id) -> bool { 8 | id == &self.id() 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /.pkg/psst.desktop: -------------------------------------------------------------------------------- 1 | [Desktop Entry] 2 | Type=Application 3 | Name=Psst 4 | Comment=Fast and native Spotify client 5 | GenericName=Music Player 6 | Icon=psst 7 | TryExec=psst-gui 8 | Exec=psst-gui %U 9 | Terminal=false 10 | MimeType=x-scheme-handler/psst; 11 | Categories=Audio;Music;Player;AudioVideo; 12 | StartupWMClass=psst-gui 13 | -------------------------------------------------------------------------------- /.pkg/DEBIAN/control: -------------------------------------------------------------------------------- 1 | Package: psst-gui 2 | Version: $VERSION 3 | Architecture: $ARCHITECTURE 4 | Maintainer: Jan Pochyla 5 | Section: sound 6 | Priority: optional 7 | Homepage: https://github.com/jpochyla/psst 8 | Package-Type: deb 9 | Depends: libssl3 | libssl1.1, libgtk-3-0, libcairo2 10 | Description: Fast and native Spotify client 11 | -------------------------------------------------------------------------------- /psst-cli/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "psst-cli" 3 | version = "0.1.0" 4 | authors = ["Jan Pochyla "] 5 | edition = "2021" 6 | 7 | [features] 8 | default = ["cpal"] 9 | cpal = ["psst-core/cpal"] 10 | cubeb = ["psst-core/cubeb"] 11 | 12 | [dependencies] 13 | psst-core = { path = "../psst-core" } 14 | 15 | env_logger = "0.11.5" 16 | log = "0.4.22" 17 | -------------------------------------------------------------------------------- /.pkg/APPIMAGE/pkg2appimage-ingredients.yml: -------------------------------------------------------------------------------- 1 | ingredients: 2 | dist: focal 3 | sources: 4 | - deb http://us.archive.ubuntu.com/ubuntu/ focal main universe 5 | debs: 6 | - ../*.deb 7 | script: 8 | - mkdir -p /home/runner/work/psst/psst/.AppDir/ 9 | - cp /home/runner/work/psst/psst/psst-gui/assets/logo_256.png /home/runner/work/psst/psst/.AppDir/psst.png 10 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [workspace] 2 | resolver = "2" 3 | members = ["psst-core", "psst-cli", "psst-gui"] 4 | 5 | [profile.dev] 6 | opt-level = 1 7 | debug = true 8 | lto = false 9 | 10 | [profile.release] 11 | opt-level = 3 12 | strip = true 13 | lto = true 14 | codegen-units = 1 15 | 16 | [profile.dev.package.symphonia] 17 | opt-level = 2 18 | 19 | [profile.dev.package.libsamplerate] 20 | opt-level = 2 21 | -------------------------------------------------------------------------------- /psst-gui/src/error.rs: -------------------------------------------------------------------------------- 1 | use std::{error, fmt}; 2 | 3 | use druid::Data; 4 | 5 | #[derive(Clone, Debug, Data)] 6 | pub enum Error { 7 | WebApiError(String), 8 | } 9 | 10 | impl error::Error for Error {} 11 | 12 | impl fmt::Display for Error { 13 | fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { 14 | match self { 15 | Self::WebApiError(err) => f.write_str(err), 16 | } 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /psst-gui/src/data/user.rs: -------------------------------------------------------------------------------- 1 | use std::sync::Arc; 2 | 3 | use druid::{Data, Lens}; 4 | use serde::Deserialize; 5 | 6 | #[derive(Clone, Data, Lens, Deserialize)] 7 | pub struct UserProfile { 8 | pub display_name: Arc, 9 | pub email: Arc, 10 | pub id: Arc, 11 | } 12 | 13 | #[derive(Clone, Data, Lens, Deserialize, Debug)] 14 | pub struct PublicUser { 15 | pub display_name: Arc, 16 | pub id: Arc, 17 | } 18 | -------------------------------------------------------------------------------- /Cross.toml: -------------------------------------------------------------------------------- 1 | [build] 2 | pre-build = [""" 3 | dpkg --add-architecture $CROSS_DEB_ARCH && \ 4 | apt-get update && \ 5 | apt-get --assume-yes install \ 6 | libgtk-3-dev:$CROSS_DEB_ARCH \ 7 | libssl-dev:$CROSS_DEB_ARCH \ 8 | libasound2-dev:$CROSS_DEB_ARCH 9 | """] 10 | 11 | [target.x86_64-unknown-linux-gnu] 12 | image = "ghcr.io/cross-rs/x86_64-unknown-linux-gnu:edge" 13 | 14 | [target.aarch64-unknown-linux-gnu] 15 | image = "ghcr.io/cross-rs/aarch64-unknown-linux-gnu:edge" 16 | -------------------------------------------------------------------------------- /psst-core/src/system_info.rs: -------------------------------------------------------------------------------- 1 | /// Operating System as given by the Rust standard library 2 | pub const OS: &str = std::env::consts::OS; 3 | 4 | /// Device ID used for authentication procedures. 5 | /// librespot opts for UUIDv4s instead 6 | pub const DEVICE_ID: &str = "Psst"; 7 | 8 | /// Client ID for desktop keymaster client 9 | pub const CLIENT_ID: &str = "65b708073fc0480ea92a077233ca87bd"; 10 | 11 | /// The semantic version of the Spotify desktop client 12 | pub const SPOTIFY_SEMANTIC_VERSION: &str = "1.2.52.442"; -------------------------------------------------------------------------------- /psst-core/src/session/token.rs: -------------------------------------------------------------------------------- 1 | // Ported from librespot 2 | 3 | use std::time::{Duration, Instant}; 4 | 5 | const EXPIRY_THRESHOLD: Duration = Duration::from_secs(10); 6 | 7 | #[derive(Clone, Debug)] 8 | pub struct Token { 9 | pub access_token: String, 10 | pub expires_in: Duration, 11 | pub token_type: String, 12 | pub scopes: Vec, 13 | pub timestamp: Instant, 14 | } 15 | 16 | impl Token { 17 | pub fn is_expired(&self) -> bool { 18 | self.timestamp + (self.expires_in.saturating_sub(EXPIRY_THRESHOLD)) < Instant::now() 19 | } 20 | } -------------------------------------------------------------------------------- /psst-core/src/lib.rs: -------------------------------------------------------------------------------- 1 | #![allow(clippy::new_without_default)] 2 | 3 | use git_version::git_version; 4 | 5 | pub const GIT_VERSION: &str = git_version!(); 6 | pub const BUILD_TIME: &str = include!(concat!(env!("OUT_DIR"), "/build-time.txt")); 7 | pub const REMOTE_URL: &str = include!(concat!(env!("OUT_DIR"), "/remote-url.txt")); 8 | 9 | pub mod actor; 10 | pub mod audio; 11 | pub mod cache; 12 | pub mod cdn; 13 | pub mod connection; 14 | pub mod error; 15 | pub mod item_id; 16 | pub mod lastfm; 17 | pub mod metadata; 18 | pub mod oauth; 19 | pub mod player; 20 | pub mod session; 21 | pub mod system_info; 22 | pub mod util; 23 | -------------------------------------------------------------------------------- /psst-gui/src/widget/empty.rs: -------------------------------------------------------------------------------- 1 | use druid::widget::prelude::*; 2 | 3 | pub struct Empty; 4 | 5 | impl Widget for Empty { 6 | fn event(&mut self, _ctx: &mut EventCtx, _event: &Event, _data: &mut T, _env: &Env) {} 7 | fn lifecycle(&mut self, _ctx: &mut LifeCycleCtx, _event: &LifeCycle, _data: &T, _env: &Env) {} 8 | fn update(&mut self, _ctx: &mut UpdateCtx, _old_data: &T, _data: &T, _env: &Env) {} 9 | fn layout(&mut self, _ctx: &mut LayoutCtx, bc: &BoxConstraints, _data: &T, _env: &Env) -> Size { 10 | bc.min() 11 | } 12 | fn paint(&mut self, _ctx: &mut PaintCtx, _data: &T, _env: &Env) {} 13 | } 14 | -------------------------------------------------------------------------------- /.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 | **Describe the bug** 10 | A clear and concise description of what the bug is. 11 | 12 | **To Reproduce** 13 | Steps to reproduce the behavior. 14 | 15 | **Expected behavior** 16 | A clear and concise description of what you expected to happen. 17 | 18 | **Screenshots** 19 | If applicable, add screenshots to help explain your problem. 20 | 21 | **Environment** 22 | 23 | - OS: 24 | - Version: 25 | 26 | **Additional context** 27 | Add any other context about the problem here. 28 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: "" 5 | labels: "" 6 | assignees: "" 7 | --- 8 | 9 | **Is your feature request related to a problem? Please describe.** 10 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 11 | 12 | **Describe the solution you'd like** 13 | A clear and concise description of what you want to happen. 14 | 15 | **Describe alternatives you've considered** 16 | A clear and concise description of any alternative solutions or features you've considered. 17 | 18 | **Additional context** 19 | Add any other context or screenshots about the feature request here. 20 | -------------------------------------------------------------------------------- /psst-gui/src/controller/on_update.rs: -------------------------------------------------------------------------------- 1 | use druid::{widget::Controller, Data, Env, UpdateCtx, Widget}; 2 | 3 | pub struct OnUpdate { 4 | handler: F, 5 | } 6 | 7 | impl OnUpdate { 8 | pub fn new(handler: F) -> Self 9 | where 10 | F: Fn(&mut UpdateCtx, &T, &T, &Env), 11 | { 12 | Self { handler } 13 | } 14 | } 15 | 16 | impl Controller for OnUpdate 17 | where 18 | T: Data, 19 | F: Fn(&mut UpdateCtx, &T, &T, &Env), 20 | W: Widget, 21 | { 22 | fn update(&mut self, child: &mut W, ctx: &mut UpdateCtx, old_data: &T, data: &T, env: &Env) { 23 | (self.handler)(ctx, old_data, data, env); 24 | child.update(ctx, old_data, data, env); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /psst-gui/src/controller/mod.rs: -------------------------------------------------------------------------------- 1 | mod after_delay; 2 | mod alert_cleanup; 3 | mod ex_click; 4 | mod ex_cursor; 5 | mod ex_scroll; 6 | mod input; 7 | mod nav; 8 | mod on_command; 9 | mod on_command_async; 10 | mod on_debounce; 11 | mod on_update; 12 | mod playback; 13 | mod session; 14 | mod sort; 15 | 16 | pub use after_delay::AfterDelay; 17 | pub use alert_cleanup::AlertCleanupController; 18 | pub use ex_click::ExClick; 19 | pub use ex_cursor::ExCursor; 20 | pub use ex_scroll::ExScroll; 21 | pub use input::InputController; 22 | pub use nav::NavController; 23 | pub use on_command::OnCommand; 24 | pub use on_command_async::OnCommandAsync; 25 | pub use on_debounce::OnDebounce; 26 | pub use on_update::OnUpdate; 27 | pub use playback::PlaybackController; 28 | pub use session::SessionController; 29 | pub use sort::SortController; 30 | -------------------------------------------------------------------------------- /psst-gui/src/controller/ex_cursor.rs: -------------------------------------------------------------------------------- 1 | use std::marker::PhantomData; 2 | 3 | use druid::{widget::Controller, Data, Env, Event, EventCtx, Widget}; 4 | use druid_shell::Cursor; 5 | 6 | pub struct ExCursor { 7 | cursor: Cursor, 8 | phantom: PhantomData, 9 | } 10 | 11 | impl ExCursor { 12 | pub fn new(cursor: Cursor) -> Self { 13 | Self { 14 | cursor, 15 | phantom: PhantomData, 16 | } 17 | } 18 | } 19 | 20 | impl> Controller for ExCursor { 21 | fn event(&mut self, child: &mut W, ctx: &mut EventCtx, event: &Event, data: &mut T, env: &Env) { 22 | if let Event::MouseMove(_) = event { 23 | ctx.set_cursor(&self.cursor); 24 | } 25 | 26 | child.event(ctx, event, data, env); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /psst-core/src/audio/output/mod.rs: -------------------------------------------------------------------------------- 1 | use crate::audio::source::AudioSource; 2 | 3 | #[cfg(feature = "cpal")] 4 | pub mod cpal; 5 | #[cfg(feature = "cubeb")] 6 | pub mod cubeb; 7 | 8 | #[cfg(feature = "cubeb")] 9 | pub type DefaultAudioOutput = cubeb::CubebOutput; 10 | #[cfg(feature = "cpal")] 11 | pub type DefaultAudioOutput = cpal::CpalOutput; 12 | 13 | pub type DefaultAudioSink = ::Sink; 14 | 15 | pub trait AudioOutput { 16 | type Sink: AudioSink; 17 | 18 | fn sink(&self) -> Self::Sink; 19 | } 20 | 21 | pub trait AudioSink { 22 | fn channel_count(&self) -> usize; 23 | fn sample_rate(&self) -> u32; 24 | fn set_volume(&self, volume: f32); 25 | fn play(&self, source: impl AudioSource); 26 | fn pause(&self); 27 | fn resume(&self); 28 | fn stop(&self); 29 | fn close(&self); 30 | } 31 | -------------------------------------------------------------------------------- /.homebrew/generate_formula.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -eo pipefail 4 | 5 | REPO_OWNER="jpochyla" 6 | REPO_NAME="psst" 7 | 8 | cat <= :big_sur" 19 | 20 | app "Psst.app" 21 | 22 | zap trash: [ 23 | "~/Library/Application Support/Psst", 24 | "~/Library/Caches/com.jpochyla.psst", 25 | "~/Library/Caches/Psst", 26 | "~/Library/HTTPStorages/com.jpochyla.psst", 27 | "~/Library/Preferences/com.jpochyla.psst.plist", 28 | "~/Library/Saved Application State/com.jpochyla.psst.savedState", 29 | ] 30 | end 31 | EOF 32 | -------------------------------------------------------------------------------- /psst-gui/src/data/slider_scroll_scale.rs: -------------------------------------------------------------------------------- 1 | use std::f64; 2 | 3 | use druid::Lens; 4 | 5 | use { 6 | druid::Data, 7 | serde::{Deserialize, Serialize}, 8 | }; 9 | 10 | #[derive(Clone, Debug, Data, Lens, PartialEq, Serialize, Deserialize)] 11 | pub struct SliderScrollScale { 12 | // Volume percentage per 'bump' of the wheel(s) 13 | pub scale: f64, 14 | // If you have an MX Master, or another mouse with a free wheel, setting this to the 15 | // number of scroll events that get fired per 'bump' of the wheel will make it 16 | // change the volume at the same rate as the thumb wheel 17 | pub y: f64, 18 | // In case anyone wants it 19 | pub x: f64, 20 | } 21 | 22 | impl Default for SliderScrollScale { 23 | fn default() -> Self { 24 | Self { 25 | scale: 3.0, 26 | y: 1.0, 27 | x: 1.0, 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /psst-gui/src/controller/alert_cleanup.rs: -------------------------------------------------------------------------------- 1 | use crate::data::AppState; 2 | use druid::{widget::Controller, Env, Event, EventCtx, Widget}; 3 | use std::time::Duration; 4 | 5 | pub struct AlertCleanupController; 6 | 7 | const CLEANUP_INTERVAL: Duration = Duration::from_secs(1); 8 | 9 | impl> Controller for AlertCleanupController { 10 | fn event( 11 | &mut self, 12 | child: &mut W, 13 | ctx: &mut EventCtx, 14 | event: &Event, 15 | data: &mut AppState, 16 | env: &Env, 17 | ) { 18 | match event { 19 | Event::WindowConnected => { 20 | ctx.request_timer(CLEANUP_INTERVAL); 21 | } 22 | Event::Timer(_) => { 23 | data.cleanup_alerts(); 24 | ctx.request_timer(CLEANUP_INTERVAL); 25 | } 26 | _ => {} 27 | } 28 | child.event(ctx, event, data, env) 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /psst-gui/src/controller/on_command.rs: -------------------------------------------------------------------------------- 1 | use druid::{widget::Controller, Data, Env, Event, EventCtx, Selector, Widget}; 2 | 3 | pub struct OnCommand { 4 | selector: Selector, 5 | handler: F, 6 | } 7 | 8 | impl OnCommand { 9 | pub fn new(selector: Selector, handler: F) -> Self 10 | where 11 | F: Fn(&mut EventCtx, &U, &mut T), 12 | { 13 | Self { selector, handler } 14 | } 15 | } 16 | 17 | impl Controller for OnCommand 18 | where 19 | T: Data, 20 | U: 'static, 21 | F: Fn(&mut EventCtx, &U, &mut T), 22 | W: Widget, 23 | { 24 | fn event(&mut self, child: &mut W, ctx: &mut EventCtx, event: &Event, data: &mut T, env: &Env) { 25 | match event { 26 | Event::Command(cmd) if cmd.is(self.selector) => { 27 | (self.handler)(ctx, cmd.get_unchecked(self.selector), data); 28 | } 29 | _ => {} 30 | } 31 | child.event(ctx, event, data, env); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Jan Pochyla 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /.pkg/copyright: -------------------------------------------------------------------------------- 1 | Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/ 2 | Upstream-Name: Psst 3 | Source: https://github.com/jpochyla/psst 4 | 5 | Files: * 6 | Copyright: (c) 2020 Jan Pochyla 7 | License: MIT 8 | 9 | License: MIT 10 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 11 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 12 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 13 | -------------------------------------------------------------------------------- /psst-gui/build.rs: -------------------------------------------------------------------------------- 1 | fn main() { 2 | #[cfg(windows)] 3 | add_windows_icon(); 4 | } 5 | 6 | #[cfg(windows)] 7 | fn add_windows_icon() { 8 | use image::{ 9 | codecs::ico::{IcoEncoder, IcoFrame}, 10 | ColorType, 11 | }; 12 | 13 | let ico_path = "assets/logo.ico"; 14 | if std::fs::metadata(ico_path).is_err() { 15 | let ico_frames = load_images(); 16 | save_ico(&ico_frames, ico_path); 17 | } 18 | 19 | let mut res = winres::WindowsResource::new(); 20 | res.set_icon(ico_path); 21 | res.compile().expect("Could not attach exe icon"); 22 | 23 | fn load_images() -> Vec> { 24 | let sizes = [32, 64, 128, 256]; 25 | sizes 26 | .iter() 27 | .map(|s| { 28 | IcoFrame::as_png( 29 | image::open(format!("assets/logo_{s}.png")) 30 | .unwrap() 31 | .as_bytes(), 32 | *s, 33 | *s, 34 | ColorType::Rgba8.into(), 35 | ) 36 | .unwrap() 37 | }) 38 | .collect() 39 | } 40 | 41 | fn save_ico(images: &[IcoFrame<'_>], ico_path: &str) { 42 | let file = std::fs::File::create(ico_path).unwrap(); 43 | let encoder = IcoEncoder::new(file); 44 | encoder.encode_images(images).unwrap(); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /psst-gui/src/controller/on_debounce.rs: -------------------------------------------------------------------------------- 1 | use std::time::Duration; 2 | 3 | use druid::{widget::Controller, Data, Env, Event, EventCtx, TimerToken, UpdateCtx, Widget}; 4 | 5 | pub struct OnDebounce { 6 | duration: Duration, 7 | timer: TimerToken, 8 | handler: Box, 9 | } 10 | 11 | impl OnDebounce { 12 | pub fn trailing( 13 | duration: Duration, 14 | handler: impl Fn(&mut EventCtx, &mut T, &Env) + 'static, 15 | ) -> Self { 16 | Self { 17 | duration, 18 | timer: TimerToken::INVALID, 19 | handler: Box::new(handler), 20 | } 21 | } 22 | } 23 | 24 | impl Controller for OnDebounce 25 | where 26 | T: Data, 27 | W: Widget, 28 | { 29 | fn event(&mut self, child: &mut W, ctx: &mut EventCtx, event: &Event, data: &mut T, env: &Env) { 30 | match event { 31 | Event::Timer(token) if token == &self.timer => { 32 | (self.handler)(ctx, data, env); 33 | self.timer = TimerToken::INVALID; 34 | ctx.set_handled(); 35 | } 36 | _ => child.event(ctx, event, data, env), 37 | } 38 | } 39 | 40 | fn update(&mut self, child: &mut W, ctx: &mut UpdateCtx, old_data: &T, data: &T, env: &Env) { 41 | if !old_data.same(data) { 42 | self.timer = ctx.request_timer(self.duration); 43 | } 44 | child.update(ctx, old_data, data, env) 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /psst-core/src/audio/decrypt.rs: -------------------------------------------------------------------------------- 1 | use std::{convert::TryInto, io}; 2 | 3 | use aes::{ 4 | cipher::{generic_array::GenericArray, KeyIvInit, StreamCipher, StreamCipherSeek}, 5 | Aes128, 6 | }; 7 | use ctr::Ctr128BE; 8 | 9 | const AUDIO_AESIV: [u8; 16] = [ 10 | 0x72, 0xe0, 0x67, 0xfb, 0xdd, 0xcb, 0xcf, 0x77, 0xeb, 0xe8, 0xbc, 0x64, 0x3f, 0x63, 0x0d, 0x93, 11 | ]; 12 | 13 | #[derive(Debug, Hash, PartialEq, Eq, Copy, Clone)] 14 | pub struct AudioKey(pub [u8; 16]); 15 | 16 | impl AudioKey { 17 | pub fn from_raw(data: &[u8]) -> Option { 18 | Some(AudioKey(data.try_into().ok()?)) 19 | } 20 | } 21 | 22 | pub struct AudioDecrypt { 23 | cipher: Ctr128BE, 24 | reader: T, 25 | } 26 | 27 | impl AudioDecrypt { 28 | pub fn new(key: AudioKey, reader: T) -> AudioDecrypt { 29 | let cipher = Ctr128BE::::new( 30 | GenericArray::from_slice(&key.0), 31 | GenericArray::from_slice(&AUDIO_AESIV), 32 | ); 33 | AudioDecrypt { cipher, reader } 34 | } 35 | } 36 | 37 | impl io::Read for AudioDecrypt { 38 | fn read(&mut self, output: &mut [u8]) -> io::Result { 39 | let len = self.reader.read(output)?; 40 | 41 | self.cipher.apply_keystream(&mut output[..len]); 42 | 43 | Ok(len) 44 | } 45 | } 46 | 47 | impl io::Seek for AudioDecrypt { 48 | fn seek(&mut self, pos: io::SeekFrom) -> io::Result { 49 | let newpos = self.reader.seek(pos)?; 50 | 51 | self.cipher.seek(newpos); 52 | 53 | Ok(newpos) 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /psst-core/build.rs: -------------------------------------------------------------------------------- 1 | use gix_config::File; 2 | use std::{env, fs, io::Write}; 3 | use time::OffsetDateTime; 4 | 5 | fn main() { 6 | let outdir = env::var("OUT_DIR").unwrap(); 7 | let outfile = format!("{outdir}/build-time.txt"); 8 | 9 | let mut fh = fs::File::create(outfile).unwrap(); 10 | let now = OffsetDateTime::now_local().unwrap_or_else(|_| OffsetDateTime::now_utc()); 11 | write!(fh, r#""{now}""#).ok(); 12 | 13 | let git_config = File::from_git_dir("../.git/".into()).expect("Git Config not found!"); 14 | // Get Git's 'Origin' URL 15 | let mut remote_url = git_config 16 | .raw_value("remote.origin.url") 17 | .expect("Couldn't extract origin url!") 18 | .to_string(); 19 | 20 | // Check whether origin is accessed via ssh 21 | if remote_url.contains('@') { 22 | // If yes, strip the `git@` prefix and split the domain and path 23 | let mut split = remote_url 24 | .strip_prefix("git@") 25 | .unwrap_or(&remote_url) 26 | .split(':'); 27 | let domain = split 28 | .next() 29 | .expect("Couldn't extract domain from ssh-style origin"); 30 | let path = split 31 | .next() 32 | .expect("Couldn't expect path from ssh-style origin"); 33 | 34 | // And construct the http-style url 35 | remote_url = format!("https://{domain}/{path}"); 36 | } 37 | let trimmed_url = remote_url.trim_end_matches(".git"); 38 | remote_url.clone_from(&String::from(trimmed_url)); 39 | 40 | let outfile = format!("{outdir}/remote-url.txt"); 41 | let mut file = fs::File::create(outfile).unwrap(); 42 | write!(file, r#""{remote_url}""#).ok(); 43 | } 44 | -------------------------------------------------------------------------------- /psst-core/src/connection/diffie_hellman.rs: -------------------------------------------------------------------------------- 1 | use num_bigint::{BigUint, ToBigUint}; 2 | use rand::Rng; 3 | 4 | pub struct DHLocalKeys { 5 | private_key: BigUint, 6 | public_key: BigUint, 7 | } 8 | 9 | impl DHLocalKeys { 10 | pub fn random() -> DHLocalKeys { 11 | let private_key = rand::rng().random::().to_biguint().unwrap(); 12 | let public_key = dh_generator().modpow(&private_key, &dh_prime()); 13 | DHLocalKeys { 14 | private_key, 15 | public_key, 16 | } 17 | } 18 | 19 | pub fn public_key(&self) -> Vec { 20 | self.public_key.to_bytes_be() 21 | } 22 | 23 | pub fn shared_secret(&self, remote_key: &[u8]) -> Vec { 24 | let remote_key = BigUint::from_bytes_be(remote_key); 25 | let shared_key = remote_key.modpow(&self.private_key, &dh_prime()); 26 | shared_key.to_bytes_be() 27 | } 28 | } 29 | 30 | fn dh_generator() -> BigUint { 31 | BigUint::from(0x2_u64) 32 | } 33 | 34 | fn dh_prime() -> BigUint { 35 | BigUint::from_bytes_be(&[ 36 | 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xc9, 0x0f, 0xda, 0xa2, 0x21, 0x68, 0xc2, 37 | 0x34, 0xc4, 0xc6, 0x62, 0x8b, 0x80, 0xdc, 0x1c, 0xd1, 0x29, 0x02, 0x4e, 0x08, 0x8a, 0x67, 38 | 0xcc, 0x74, 0x02, 0x0b, 0xbe, 0xa6, 0x3b, 0x13, 0x9b, 0x22, 0x51, 0x4a, 0x08, 0x79, 0x8e, 39 | 0x34, 0x04, 0xdd, 0xef, 0x95, 0x19, 0xb3, 0xcd, 0x3a, 0x43, 0x1b, 0x30, 0x2b, 0x0a, 0x6d, 40 | 0xf2, 0x5f, 0x14, 0x37, 0x4f, 0xe1, 0x35, 0x6d, 0x6d, 0x51, 0xc2, 0x45, 0xe4, 0x85, 0xb5, 41 | 0x76, 0x62, 0x5e, 0x7e, 0xc6, 0xf4, 0x4c, 0x42, 0xe9, 0xa6, 0x3a, 0x36, 0x20, 0xff, 0xff, 42 | 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 43 | ]) 44 | } 45 | -------------------------------------------------------------------------------- /psst-core/src/audio/normalize.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | io, 3 | io::{Read, Seek, SeekFrom}, 4 | }; 5 | 6 | use byteorder::{ReadBytesExt, LE}; 7 | 8 | #[derive(Debug, Clone, Copy, Eq, PartialEq)] 9 | pub enum NormalizationLevel { 10 | None, 11 | Track, 12 | Album, 13 | } 14 | 15 | #[derive(Clone, Copy)] 16 | pub struct NormalizationData { 17 | track_gain_db: f32, 18 | track_peak: f32, 19 | album_gain_db: f32, 20 | album_peak: f32, 21 | } 22 | 23 | impl NormalizationData { 24 | pub fn parse(mut file: impl Read + Seek) -> io::Result { 25 | const NORMALIZATION_OFFSET: u64 = 144; 26 | 27 | file.seek(SeekFrom::Start(NORMALIZATION_OFFSET))?; 28 | 29 | let track_gain_db = file.read_f32::()?; 30 | let track_peak = file.read_f32::()?; 31 | let album_gain_db = file.read_f32::()?; 32 | let album_peak = file.read_f32::()?; 33 | 34 | Ok(Self { 35 | track_gain_db, 36 | track_peak, 37 | album_gain_db, 38 | album_peak, 39 | }) 40 | } 41 | 42 | pub fn factor_for_level(&self, level: NormalizationLevel, pregain: f32) -> f32 { 43 | match level { 44 | NormalizationLevel::None => 1.0, 45 | NormalizationLevel::Track => Self::factor(pregain, self.track_gain_db, self.track_peak), 46 | NormalizationLevel::Album => Self::factor(pregain, self.album_gain_db, self.album_peak), 47 | } 48 | } 49 | 50 | fn factor(pregain: f32, gain: f32, peak: f32) -> f32 { 51 | let mut nf = f32::powf(10.0, (pregain + gain) / 20.0); 52 | if nf * peak > 1.0 { 53 | nf = 1.0 / peak; 54 | } 55 | nf 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /psst-gui/src/controller/after_delay.rs: -------------------------------------------------------------------------------- 1 | use std::time::Duration; 2 | 3 | use druid::{ 4 | widget::Controller, Data, Env, Event, EventCtx, LifeCycle, LifeCycleCtx, TimerToken, Widget, 5 | }; 6 | 7 | type DelayFunc = Box; 8 | 9 | pub struct AfterDelay { 10 | duration: Duration, 11 | timer: TimerToken, 12 | func: Option>, 13 | } 14 | 15 | impl AfterDelay { 16 | pub fn new( 17 | duration: Duration, 18 | func: impl FnOnce(&mut EventCtx, &mut T, &Env) + 'static, 19 | ) -> Self { 20 | Self { 21 | duration, 22 | timer: TimerToken::INVALID, 23 | func: Some(Box::new(func)), 24 | } 25 | } 26 | } 27 | 28 | impl Controller for AfterDelay 29 | where 30 | T: Data, 31 | W: Widget, 32 | { 33 | fn event(&mut self, child: &mut W, ctx: &mut EventCtx, event: &Event, data: &mut T, env: &Env) { 34 | match event { 35 | Event::Timer(token) if token == &self.timer => { 36 | if let Some(func) = self.func.take() { 37 | func(ctx, data, env); 38 | } 39 | self.timer = TimerToken::INVALID; 40 | } 41 | _ => child.event(ctx, event, data, env), 42 | } 43 | } 44 | 45 | fn lifecycle( 46 | &mut self, 47 | child: &mut W, 48 | ctx: &mut LifeCycleCtx, 49 | event: &LifeCycle, 50 | data: &T, 51 | env: &Env, 52 | ) { 53 | if let LifeCycle::WidgetAdded = event { 54 | self.timer = ctx.request_timer(self.duration); 55 | } 56 | child.lifecycle(ctx, event, data, env) 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /psst-gui/src/controller/session.rs: -------------------------------------------------------------------------------- 1 | use druid::widget::{prelude::*, Controller}; 2 | 3 | use crate::{ 4 | cmd, 5 | data::AppState, 6 | ui::{home, playlist, user}, 7 | }; 8 | 9 | pub struct SessionController; 10 | 11 | impl SessionController { 12 | fn connect(&self, ctx: &mut EventCtx, data: &mut AppState) { 13 | // Update the session configuration, any active session will get shut down. 14 | data.session.update_config(data.config.session()); 15 | 16 | // Reload the global, usually visible data. 17 | ctx.submit_command(playlist::LOAD_LIST); 18 | ctx.submit_command(home::LOAD_MADE_FOR_YOU); 19 | ctx.submit_command(user::LOAD_PROFILE); 20 | } 21 | } 22 | 23 | impl Controller for SessionController 24 | where 25 | W: Widget, 26 | { 27 | fn event( 28 | &mut self, 29 | child: &mut W, 30 | ctx: &mut EventCtx, 31 | event: &Event, 32 | data: &mut AppState, 33 | env: &Env, 34 | ) { 35 | match event { 36 | Event::Command(cmd) if cmd.is(cmd::SESSION_CONNECT) => { 37 | if data.config.has_credentials() { 38 | self.connect(ctx, data); 39 | } 40 | ctx.set_handled(); 41 | } 42 | _ => { 43 | child.event(ctx, event, data, env); 44 | } 45 | } 46 | } 47 | 48 | fn lifecycle( 49 | &mut self, 50 | child: &mut W, 51 | ctx: &mut LifeCycleCtx, 52 | event: &LifeCycle, 53 | data: &AppState, 54 | env: &Env, 55 | ) { 56 | if let LifeCycle::WidgetAdded = event { 57 | ctx.submit_command(cmd::SESSION_CONNECT); 58 | } 59 | child.lifecycle(ctx, event, data, env) 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /psst-core/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "psst-core" 3 | version = "0.1.0" 4 | authors = ["Jan Pochyla "] 5 | edition = "2021" 6 | 7 | 8 | [build-dependencies] 9 | gix-config = "0.45.1" 10 | time = { version = "0.3.36", features = ["local-offset"] } 11 | 12 | [dependencies] 13 | 14 | # Common 15 | byteorder = { version = "1.5.0" } 16 | crossbeam-channel = { version = "0.5.13" } 17 | git-version = { version = "0.3.9" } 18 | log = { version = "0.4.22" } 19 | num-bigint = { version = "0.4.6", features = ["rand"] } 20 | num-traits = { version = "0.2.19" } 21 | oauth2 = { version = "4.4.2" } 22 | parking_lot = { version = "0.12.3" } 23 | librespot-protocol = "0.7.1" 24 | protobuf = "3" 25 | sysinfo = "0.35.0" 26 | data-encoding = "2.9" 27 | rand = { version = "0.9.1" } 28 | rangemap = { version = "1.5.1" } 29 | serde = { version = "1.0.210", features = ["derive"] } 30 | serde_json = { version = "1.0.132" } 31 | socks = { version = "0.3.4" } 32 | tempfile = { version = "3.13.0" } 33 | rustfm-scrobble = "1.1.1" 34 | ureq = { version = "3.0.11", features = ["json"] } 35 | url = { version = "2.5.2" } 36 | 37 | # Cryptography 38 | aes = { version = "0.8.4" } 39 | ctr = { version = "0.9.2" } 40 | hmac = { version = "0.12.1" } 41 | sha-1 = { version = "0.10.1" } 42 | shannon = { version = "0.2.0" } 43 | 44 | # Audio 45 | audio_thread_priority = "0.33.0" 46 | cpal = { version = "0.15.3", optional = true } 47 | cubeb = { git = "https://github.com/mozilla/cubeb-rs", optional = true } 48 | libsamplerate = { version = "0.1.0" } 49 | rb = { version = "0.4.1" } 50 | symphonia = { version = "0.5.4", default-features = false, features = [ 51 | "ogg", 52 | "vorbis", 53 | "mp3", 54 | ] } 55 | 56 | [target.'cfg(target_os = "windows")'.dependencies] 57 | windows = { version = "0.61.1", features = ["Win32_System_Com"], default-features = false } 58 | -------------------------------------------------------------------------------- /psst-gui/build-icons.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -euo pipefail 3 | 4 | # Check for required tools 5 | command -v rsvg-convert >/dev/null 2>&1 || { 6 | echo >&2 "rsvg-convert is required but not installed. Aborting." 7 | exit 1 8 | } 9 | command -v iconutil >/dev/null 2>&1 || { 10 | echo >&2 "iconutil is required but not installed. Aborting." 11 | exit 1 12 | } 13 | command -v pngquant >/dev/null 2>&1 || { 14 | echo >&2 "pngquant is required but not installed. Aborting." 15 | exit 1 16 | } 17 | command -v optipng >/dev/null 2>&1 || { 18 | echo >&2 "optipng is required but not installed. Aborting." 19 | exit 1 20 | } 21 | 22 | # Temp folder 23 | ICON_DIR="icons" 24 | mkdir -p "$ICON_DIR" 25 | 26 | # Generate PNG icons from SVG 27 | SIZES=(16 32 64 128 256 512) 28 | for size in "${SIZES[@]}"; do 29 | rsvg-convert -w $size -h $size assets/logo.svg -o "$ICON_DIR/logo_${size}.png" 30 | 31 | # Apply lossy compression with pngquant 32 | pngquant --force --quality=60-80 "$ICON_DIR/logo_${size}.png" --output "$ICON_DIR/logo_${size}.png" 33 | 34 | # Further optimize with optipng 35 | optipng -quiet -o5 "$ICON_DIR/logo_${size}.png" 36 | 37 | # For smaller sizes, reduce color depth 38 | if [ $size -le 32 ]; then 39 | magick "$ICON_DIR/logo_${size}.png" -colors 256 PNG8:"$ICON_DIR/logo_${size}.png" 40 | fi 41 | done 42 | 43 | # Generate ICNS for macOS 44 | ICONSET_DIR="$ICON_DIR/psst.iconset" 45 | mkdir -p "$ICONSET_DIR" 46 | for size in "${SIZES[@]}"; do 47 | cp "$ICON_DIR/logo_${size}.png" "$ICONSET_DIR/icon_${size}x${size}.png" 48 | if [ $size -ne 16 ] && [ $size -ne 32 ]; then 49 | cp "$ICON_DIR/logo_${size}.png" "$ICONSET_DIR/icon_$((size / 2))x$((size / 2))@2x.png" 50 | fi 51 | done 52 | 53 | # Create ICNS file 54 | iconutil -c icns "$ICONSET_DIR" -o assets/logo.icns 55 | 56 | # Cleanup 57 | rm -r "$ICON_DIR" 58 | 59 | echo "Icon generation complete. ICNS file size: $(du -h assets/logo.icns | cut -f1)" 60 | -------------------------------------------------------------------------------- /psst-gui/src/data/find.rs: -------------------------------------------------------------------------------- 1 | use druid::{Data, Lens}; 2 | use regex::{Regex, RegexBuilder}; 3 | 4 | #[derive(Clone, Default, Debug, Data, Lens)] 5 | pub struct Finder { 6 | pub focused_result: usize, 7 | pub results: usize, 8 | pub show: bool, 9 | pub query: String, 10 | } 11 | 12 | impl Finder { 13 | pub fn new() -> Self { 14 | Self::default() 15 | } 16 | 17 | pub fn reset(&mut self) { 18 | self.query = String::new(); 19 | self.results = 0; 20 | self.focused_result = 0; 21 | } 22 | 23 | pub fn reset_matches(&mut self) { 24 | self.results = 0; 25 | } 26 | 27 | pub fn report_match(&mut self) -> usize { 28 | self.results += 1; 29 | self.results 30 | } 31 | 32 | pub fn focus_previous(&mut self) { 33 | self.focused_result = if self.focused_result > 0 { 34 | self.focused_result - 1 35 | } else { 36 | self.results.saturating_sub(1) 37 | }; 38 | } 39 | 40 | pub fn focus_next(&mut self) { 41 | self.focused_result = if self.focused_result < self.results - 1 { 42 | self.focused_result + 1 43 | } else { 44 | 0 45 | } 46 | } 47 | } 48 | 49 | #[derive(Clone)] 50 | pub struct FindQuery { 51 | regex: Regex, 52 | } 53 | 54 | impl FindQuery { 55 | pub fn new(query: &str) -> Self { 56 | Self { 57 | regex: Self::build_regex(query), 58 | } 59 | } 60 | 61 | fn build_regex(query: &str) -> Regex { 62 | RegexBuilder::new(®ex::escape(query)) 63 | .case_insensitive(true) 64 | .build() 65 | .unwrap() 66 | } 67 | 68 | pub fn is_empty(&self) -> bool { 69 | self.regex.as_str().is_empty() 70 | } 71 | 72 | pub fn matches_str(&self, s: &str) -> bool { 73 | self.regex.is_match(s) 74 | } 75 | } 76 | 77 | pub trait MatchFindQuery { 78 | fn matches_query(&self, query: &FindQuery) -> bool; 79 | } 80 | -------------------------------------------------------------------------------- /psst-gui/src/controller/ex_scroll.rs: -------------------------------------------------------------------------------- 1 | use crate::data::SliderScrollScale; 2 | use druid::{widget::Controller, Data, Env, Event, EventCtx, LifeCycle, LifeCycleCtx, Widget}; 3 | 4 | pub struct ExScroll { 5 | scale_picker: Box &SliderScrollScale>, 6 | action: Box, 7 | } 8 | 9 | impl ExScroll { 10 | pub fn new( 11 | scale_picker: impl Fn(&mut T) -> &SliderScrollScale + 'static, 12 | action: impl Fn(&mut EventCtx, &mut T, &Env, f64) + 'static, 13 | ) -> Self { 14 | ExScroll { 15 | scale_picker: Box::new(scale_picker), 16 | action: Box::new(action), 17 | } 18 | } 19 | } 20 | 21 | impl> Controller for ExScroll { 22 | fn event(&mut self, child: &mut W, ctx: &mut EventCtx, event: &Event, data: &mut T, env: &Env) { 23 | if let Event::Wheel(mouse_event) = event { 24 | ctx.set_active(true); 25 | 26 | let delta = mouse_event.wheel_delta; 27 | let scale_config = (self.scale_picker)(data); 28 | let scale = scale_config.scale / 100.; 29 | 30 | let (directional_scale, delta) = if delta.x == 0. { 31 | (scale_config.y, -delta.y) 32 | } else { 33 | (scale_config.x, delta.x) 34 | }; 35 | let scaled_delta = delta.signum() * scale * 1. / directional_scale; 36 | (self.action)(ctx, data, env, scaled_delta); 37 | 38 | ctx.set_active(false); 39 | ctx.request_paint() 40 | } 41 | 42 | child.event(ctx, event, data, env); 43 | } 44 | 45 | fn lifecycle( 46 | &mut self, 47 | child: &mut W, 48 | ctx: &mut LifeCycleCtx, 49 | event: &LifeCycle, 50 | data: &T, 51 | env: &Env, 52 | ) { 53 | if let LifeCycle::HotChanged(_) | LifeCycle::FocusChanged(_) = event { 54 | ctx.request_paint(); 55 | } 56 | 57 | child.lifecycle(ctx, event, data, env); 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /psst-gui/src/widget/overlay.rs: -------------------------------------------------------------------------------- 1 | use druid::{widget::prelude::*, Data, Point, Vec2, WidgetPod}; 2 | 3 | pub enum OverlayPosition { 4 | Bottom, 5 | } 6 | 7 | pub struct Overlay { 8 | inner: W, 9 | overlay: WidgetPod, 10 | position: OverlayPosition, 11 | } 12 | 13 | impl Overlay 14 | where 15 | O: Widget, 16 | { 17 | pub fn bottom(inner: W, overlay: O) -> Self { 18 | Self { 19 | inner, 20 | overlay: WidgetPod::new(overlay), 21 | position: OverlayPosition::Bottom, 22 | } 23 | } 24 | } 25 | 26 | impl Widget for Overlay 27 | where 28 | T: Data, 29 | W: Widget, 30 | O: Widget, 31 | { 32 | fn event(&mut self, ctx: &mut EventCtx, event: &Event, data: &mut T, env: &Env) { 33 | self.inner.event(ctx, event, data, env); 34 | self.overlay.event(ctx, event, data, env); 35 | } 36 | 37 | fn lifecycle(&mut self, ctx: &mut LifeCycleCtx, event: &LifeCycle, data: &T, env: &Env) { 38 | self.inner.lifecycle(ctx, event, data, env); 39 | self.overlay.lifecycle(ctx, event, data, env); 40 | } 41 | 42 | fn update(&mut self, ctx: &mut UpdateCtx, old_data: &T, data: &T, env: &Env) { 43 | self.inner.update(ctx, old_data, data, env); 44 | self.overlay.update(ctx, data, env); 45 | } 46 | 47 | fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints, data: &T, env: &Env) -> Size { 48 | let inner_size = self.inner.layout(ctx, bc, data, env); 49 | let over_size = self.overlay.layout(ctx, bc, data, env); 50 | let pos = match self.position { 51 | OverlayPosition::Bottom => { 52 | Point::ORIGIN + Vec2::new(0.0, inner_size.height - over_size.height) 53 | } 54 | }; 55 | self.overlay.set_origin(ctx, pos); 56 | inner_size 57 | } 58 | 59 | fn paint(&mut self, ctx: &mut PaintCtx, data: &T, env: &Env) { 60 | self.inner.paint(ctx, data, env); 61 | self.overlay.paint(ctx, data, env); 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /psst-gui/src/controller/ex_click.rs: -------------------------------------------------------------------------------- 1 | use druid::{ 2 | widget::Controller, Data, Env, Event, EventCtx, LifeCycle, LifeCycleCtx, MouseButton, 3 | MouseEvent, Widget, 4 | }; 5 | 6 | pub struct ExClick { 7 | button: Option, 8 | action: Box, 9 | } 10 | 11 | impl ExClick { 12 | pub fn new( 13 | button: Option, 14 | action: impl Fn(&mut EventCtx, &MouseEvent, &mut T, &Env) + 'static, 15 | ) -> Self { 16 | ExClick { 17 | button, 18 | action: Box::new(action), 19 | } 20 | } 21 | } 22 | 23 | impl> Controller for ExClick { 24 | fn event(&mut self, child: &mut W, ctx: &mut EventCtx, event: &Event, data: &mut T, env: &Env) { 25 | match event { 26 | Event::MouseDown(mouse_event) => { 27 | if mouse_event.button == self.button.unwrap_or(mouse_event.button) { 28 | ctx.set_active(true); 29 | ctx.request_paint(); 30 | } 31 | } 32 | Event::MouseUp(mouse_event) => { 33 | if mouse_event.button == self.button.unwrap_or(mouse_event.button) 34 | && ctx.is_active() 35 | { 36 | ctx.set_active(false); 37 | if ctx.is_hot() { 38 | (self.action)(ctx, mouse_event, data, env); 39 | } 40 | ctx.request_paint(); 41 | } 42 | } 43 | _ => {} 44 | } 45 | 46 | child.event(ctx, event, data, env); 47 | } 48 | 49 | fn lifecycle( 50 | &mut self, 51 | child: &mut W, 52 | ctx: &mut LifeCycleCtx, 53 | event: &LifeCycle, 54 | data: &T, 55 | env: &Env, 56 | ) { 57 | if let LifeCycle::HotChanged(_) | LifeCycle::FocusChanged(_) = event { 58 | ctx.request_paint(); 59 | } 60 | 61 | child.lifecycle(ctx, event, data, env); 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /psst-gui/src/widget/theme.rs: -------------------------------------------------------------------------------- 1 | use crate::{data::AppState, ui::theme}; 2 | use druid::widget::prelude::*; 3 | 4 | pub struct ThemeScope { 5 | inner: W, 6 | cached_env: Option, 7 | } 8 | 9 | impl ThemeScope { 10 | pub fn new(inner: W) -> Self { 11 | Self { 12 | inner, 13 | cached_env: None, 14 | } 15 | } 16 | 17 | fn set_env(&mut self, data: &AppState, outer_env: &Env) { 18 | let mut themed_env = outer_env.clone(); 19 | theme::setup(&mut themed_env, data); 20 | self.cached_env.replace(themed_env); 21 | } 22 | } 23 | 24 | impl> Widget for ThemeScope { 25 | fn event(&mut self, ctx: &mut EventCtx, event: &Event, data: &mut AppState, env: &Env) { 26 | self.inner 27 | .event(ctx, event, data, self.cached_env.as_ref().unwrap_or(env)) 28 | } 29 | 30 | fn lifecycle(&mut self, ctx: &mut LifeCycleCtx, event: &LifeCycle, data: &AppState, env: &Env) { 31 | if let LifeCycle::WidgetAdded = &event { 32 | self.set_env(data, env); 33 | } 34 | self.inner 35 | .lifecycle(ctx, event, data, self.cached_env.as_ref().unwrap_or(env)) 36 | } 37 | 38 | fn update(&mut self, ctx: &mut UpdateCtx, old_data: &AppState, data: &AppState, env: &Env) { 39 | if !data.config.theme.same(&old_data.config.theme) { 40 | self.set_env(data, env); 41 | ctx.request_layout(); 42 | ctx.request_paint(); 43 | } 44 | self.inner 45 | .update(ctx, old_data, data, self.cached_env.as_ref().unwrap_or(env)); 46 | } 47 | 48 | fn layout( 49 | &mut self, 50 | ctx: &mut LayoutCtx, 51 | bc: &BoxConstraints, 52 | data: &AppState, 53 | env: &Env, 54 | ) -> Size { 55 | self.inner 56 | .layout(ctx, bc, data, self.cached_env.as_ref().unwrap_or(env)) 57 | } 58 | 59 | fn paint(&mut self, ctx: &mut PaintCtx, data: &AppState, env: &Env) { 60 | self.inner 61 | .paint(ctx, data, self.cached_env.as_ref().unwrap_or(env)); 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /psst-gui/src/data/search.rs: -------------------------------------------------------------------------------- 1 | use std::sync::Arc; 2 | 3 | use druid::{im::Vector, Data, Lens}; 4 | 5 | use crate::data::{Album, Artist, Playlist, Promise, Show, Track}; 6 | 7 | #[derive(Clone, Data, Lens)] 8 | pub struct Search { 9 | pub input: String, 10 | pub topic: Option, 11 | pub results: Promise, Option)>, 12 | } 13 | 14 | #[derive(Copy, Clone, Data, Eq, PartialEq)] 15 | pub enum SearchTopic { 16 | Artist, 17 | Album, 18 | Track, 19 | Playlist, 20 | Show, 21 | } 22 | 23 | impl SearchTopic { 24 | pub fn as_str(&self) -> &'static str { 25 | match self { 26 | SearchTopic::Artist => "artist", 27 | SearchTopic::Album => "album", 28 | SearchTopic::Track => "track", 29 | SearchTopic::Playlist => "playlist", 30 | SearchTopic::Show => "show", 31 | } 32 | } 33 | 34 | pub fn display_name(&self) -> &'static str { 35 | match self { 36 | SearchTopic::Artist => "Artists", 37 | SearchTopic::Album => "Albums", 38 | SearchTopic::Track => "Tracks", 39 | SearchTopic::Playlist => "Playlists", 40 | SearchTopic::Show => "Podcasts", 41 | } 42 | } 43 | 44 | pub fn all() -> &'static [Self] { 45 | &[ 46 | Self::Artist, 47 | Self::Album, 48 | Self::Track, 49 | Self::Playlist, 50 | Self::Show, 51 | ] 52 | } 53 | } 54 | 55 | #[derive(Clone, Data, Lens)] 56 | pub struct SearchResults { 57 | pub query: Arc, 58 | pub topic: Option, 59 | pub artists: Vector, 60 | pub albums: Vector>, 61 | pub tracks: Vector>, 62 | pub playlists: Vector, 63 | pub shows: Vector>, 64 | } 65 | 66 | impl SearchResults { 67 | pub fn is_empty(&self) -> bool { 68 | self.artists.is_empty() 69 | && self.albums.is_empty() 70 | && self.tracks.is_empty() 71 | && self.playlists.is_empty() 72 | && self.shows.is_empty() 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /psst-gui/src/ui/user.rs: -------------------------------------------------------------------------------- 1 | use druid::{ 2 | commands, 3 | widget::{Either, Flex, Label}, 4 | Data, LensExt, Selector, Widget, WidgetExt, 5 | }; 6 | 7 | use crate::{ 8 | data::{AppState, Library, UserProfile}, 9 | webapi::WebApi, 10 | widget::{icons, icons::SvgIcon, Async, Empty, MyWidgetExt}, 11 | }; 12 | 13 | use super::theme; 14 | 15 | pub const LOAD_PROFILE: Selector = Selector::new("app.user.load-profile"); 16 | 17 | pub fn user_widget() -> impl Widget { 18 | let is_connected = Either::new( 19 | // TODO: Avoid the locking here. 20 | |state: &AppState, _| state.session.is_connected(), 21 | Label::new("Connected") 22 | .with_text_color(theme::PLACEHOLDER_COLOR) 23 | .with_text_size(theme::TEXT_SIZE_SMALL), 24 | Label::new("Disconnected") 25 | .with_text_color(theme::PLACEHOLDER_COLOR) 26 | .with_text_size(theme::TEXT_SIZE_SMALL), 27 | ); 28 | 29 | let user_profile = Async::new( 30 | || Empty, 31 | || { 32 | Label::raw() 33 | .with_text_size(theme::TEXT_SIZE_SMALL) 34 | .lens(UserProfile::display_name) 35 | }, 36 | || Empty, 37 | ) 38 | .lens(AppState::library.then(Library::user_profile.in_arc())) 39 | .on_command_async( 40 | LOAD_PROFILE, 41 | |_| WebApi::global().get_user_profile(), 42 | |_, data, d| data.with_library_mut(|l| l.user_profile.defer(d)), 43 | |_, data, r| data.with_library_mut(|l| l.user_profile.update(r)), 44 | ); 45 | 46 | Flex::row() 47 | .with_child( 48 | Flex::column() 49 | .with_child(is_connected) 50 | .with_default_spacer() 51 | .with_child(user_profile) 52 | .padding(theme::grid(1.0)), 53 | ) 54 | .with_child(preferences_widget(&icons::PREFERENCES)) 55 | } 56 | 57 | fn preferences_widget(svg: &SvgIcon) -> impl Widget { 58 | svg.scale((theme::grid(3.0), theme::grid(3.0))) 59 | .padding(theme::grid(1.0)) 60 | .link() 61 | .rounded(theme::BUTTON_BORDER_RADIUS) 62 | .on_left_click(|ctx, _, _, _| ctx.submit_command(commands::SHOW_PREFERENCES)) 63 | } 64 | -------------------------------------------------------------------------------- /psst-gui/src/data/artist.rs: -------------------------------------------------------------------------------- 1 | use std::sync::Arc; 2 | 3 | use druid::{im::Vector, Data, Lens}; 4 | use serde::{Deserialize, Serialize}; 5 | 6 | use crate::data::{Album, Cached, Image, Promise, Track}; 7 | 8 | #[derive(Clone, Data, Lens)] 9 | pub struct ArtistDetail { 10 | pub artist: Promise, 11 | pub albums: Promise, 12 | pub top_tracks: Promise, 13 | pub related_artists: Promise>, ArtistLink>, 14 | pub artist_info: Promise, 15 | } 16 | 17 | #[derive(Clone, Data, Lens, Deserialize)] 18 | pub struct Artist { 19 | pub id: Arc, 20 | pub name: Arc, 21 | pub images: Vector, 22 | } 23 | 24 | impl Artist { 25 | pub fn image(&self, width: f64, height: f64) -> Option<&Image> { 26 | Image::at_least_of_size(&self.images, width, height) 27 | } 28 | 29 | pub fn link(&self) -> ArtistLink { 30 | ArtistLink { 31 | id: self.id.clone(), 32 | name: self.name.clone(), 33 | } 34 | } 35 | } 36 | 37 | #[derive(Clone, Data, Lens)] 38 | pub struct ArtistAlbums { 39 | pub albums: Vector>, 40 | pub singles: Vector>, 41 | pub compilations: Vector>, 42 | pub appears_on: Vector>, 43 | } 44 | #[derive(Clone, Data, Lens)] 45 | pub struct ArtistInfo { 46 | pub main_image: Arc, 47 | pub stats: ArtistStats, 48 | pub bio: String, 49 | pub artist_links: Vector, 50 | } 51 | 52 | #[derive(Clone, Data, Lens)] 53 | pub struct ArtistStats { 54 | pub followers: i64, 55 | pub monthly_listeners: i64, 56 | pub world_rank: i64, 57 | } 58 | 59 | #[derive(Clone, Data, Lens)] 60 | pub struct ArtistTracks { 61 | pub id: Arc, 62 | pub name: Arc, 63 | pub tracks: Vector>, 64 | } 65 | 66 | impl ArtistTracks { 67 | pub fn link(&self) -> ArtistLink { 68 | ArtistLink { 69 | id: self.id.clone(), 70 | name: self.name.clone(), 71 | } 72 | } 73 | } 74 | 75 | #[derive(Clone, Debug, Data, Lens, Eq, PartialEq, Hash, Deserialize, Serialize)] 76 | pub struct ArtistLink { 77 | pub id: Arc, 78 | pub name: Arc, 79 | } 80 | 81 | impl ArtistLink { 82 | pub fn url(&self) -> String { 83 | format!("https://open.spotify.com/artist/{id}", id = self.id) 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /psst-gui/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "psst-gui" 3 | version = "0.1.0" 4 | authors = ["Jan Pochyla "] 5 | edition = "2021" 6 | build = "build.rs" 7 | description = "Fast and native Spotify client" 8 | repository = "https://github.com/jpochyla/psst" 9 | 10 | [features] 11 | default = ["cpal"] 12 | cpal = ["psst-core/cpal"] 13 | cubeb = ["psst-core/cubeb"] 14 | 15 | [dependencies] 16 | psst-core = { path = "../psst-core" } 17 | 18 | # Common 19 | crossbeam-channel = { version = "0.5.15" } 20 | directories = "6.0.0" 21 | env_logger = { version = "0.11.8" } 22 | itertools = "0.14.0" 23 | log = { version = "0.4.27" } 24 | lru = "0.14.0" 25 | parking_lot = { version = "0.12.3" } 26 | platform-dirs = { version = "0.3.0" } 27 | rand = { version = "0.9.1" } 28 | regex = { version = "1.11.1" } 29 | serde = { version = "1.0.219", features = ["derive", "rc"] } 30 | serde_json = { version = "1.0.140" } 31 | threadpool = { version = "1.8.1" } 32 | time = { version = "0.3.41", features = ["macros", "formatting"] } 33 | time-humanize = { version = "0.1.3" } 34 | ureq = { version = "3.0.11", features = ["json", "socks-proxy"] } 35 | url = { version = "2.5.4" } 36 | infer = "0.19.0" 37 | urlencoding = { version = "2.1.3" } 38 | 39 | # GUI 40 | druid = { git = "https://github.com/jpochyla/druid", branch = "psst", features = [ 41 | "im", 42 | "image", 43 | "jpeg", 44 | "png", 45 | "webp", 46 | "serde", 47 | ] } 48 | druid-enums = { git = "https://github.com/jpochyla/druid-enums" } 49 | druid-shell = { git = "https://github.com/jpochyla/druid", branch = "psst", features = [ 50 | "raw-win-handle", 51 | ] } 52 | open = { version = "5.3.2" } 53 | raw-window-handle = "0.5.2" # Must stay compatible with Druid 54 | souvlaki = { version = "0.8.2", default-features = false, features = ["use_zbus"] } 55 | sanitize_html = "0.9.0" 56 | rustfm-scrobble = "1.1.1" 57 | [target.'cfg(windows)'.build-dependencies] 58 | winres = { version = "0.1.12" } 59 | image = { version = "0.25.6" } 60 | 61 | [package.metadata.bundle] 62 | name = "Psst" 63 | identifier = "com.jpochyla.psst" 64 | icon = ["assets/logo.icns"] 65 | version = "0.1.0" 66 | osx_minimum_system_version = "11.0" 67 | resources = [] 68 | copyright = "Copyright (c) Jan Pochyla 2024. All rights reserved." 69 | category = "Music" 70 | short_description = "Fast and native Spotify client" 71 | long_description = """ 72 | Small and efficient graphical music player for the Spotify network. 73 | """ 74 | -------------------------------------------------------------------------------- /psst-gui/src/controller/input.rs: -------------------------------------------------------------------------------- 1 | use druid::{ 2 | commands, 3 | widget::{prelude::*, Controller, TextBox}, 4 | HotKey, KbKey, SysMods, 5 | }; 6 | 7 | use crate::cmd; 8 | 9 | type SubmitHandler = Box; 10 | 11 | pub struct InputController { 12 | on_submit: Option, 13 | } 14 | 15 | impl InputController { 16 | pub fn new() -> Self { 17 | Self { on_submit: None } 18 | } 19 | 20 | pub fn on_submit( 21 | mut self, 22 | on_submit: impl Fn(&mut EventCtx, &mut String, &Env) + 'static, 23 | ) -> Self { 24 | self.on_submit = Some(Box::new(on_submit)); 25 | self 26 | } 27 | } 28 | 29 | impl Controller> for InputController { 30 | fn event( 31 | &mut self, 32 | child: &mut TextBox, 33 | ctx: &mut EventCtx, 34 | event: &Event, 35 | data: &mut String, 36 | env: &Env, 37 | ) { 38 | match event { 39 | Event::Command(cmd) if cmd.is(cmd::SET_FOCUS) => { 40 | ctx.request_focus(); 41 | ctx.request_paint(); 42 | ctx.set_handled(); 43 | } 44 | Event::KeyDown(k_e) if HotKey::new(None, KbKey::Enter).matches(k_e) => { 45 | ctx.resign_focus(); 46 | ctx.request_paint(); 47 | ctx.set_handled(); 48 | if let Some(on_submit) = &self.on_submit { 49 | on_submit(ctx, data, env); 50 | } 51 | } 52 | Event::KeyDown(k_e) if k_e.key == KbKey::Escape => { 53 | ctx.resign_focus(); 54 | ctx.set_handled(); 55 | } 56 | Event::KeyDown(k_e) if HotKey::new(SysMods::Cmd, "c").matches(k_e) => { 57 | ctx.submit_command(commands::COPY); 58 | ctx.set_handled(); 59 | } 60 | Event::KeyDown(k_e) if HotKey::new(SysMods::Cmd, "x").matches(k_e) => { 61 | ctx.submit_command(commands::CUT); 62 | ctx.set_handled(); 63 | } 64 | Event::KeyDown(k_e) if HotKey::new(SysMods::Cmd, "v").matches(k_e) => { 65 | ctx.submit_command(commands::PASTE); 66 | ctx.set_handled(); 67 | } 68 | _ => { 69 | child.event(ctx, event, data, env); 70 | } 71 | } 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /psst-gui/src/main.rs: -------------------------------------------------------------------------------- 1 | #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] 2 | #![allow(clippy::new_without_default, clippy::type_complexity)] 3 | 4 | mod cmd; 5 | mod controller; 6 | mod data; 7 | mod delegate; 8 | mod error; 9 | mod ui; 10 | mod webapi; 11 | mod widget; 12 | 13 | use druid::AppLauncher; 14 | use env_logger::{Builder, Env}; 15 | use webapi::WebApi; 16 | 17 | use psst_core::cache::Cache; 18 | 19 | use crate::{ 20 | data::{AppState, Config}, 21 | delegate::Delegate, 22 | }; 23 | 24 | const ENV_LOG: &str = "PSST_LOG"; 25 | const ENV_LOG_STYLE: &str = "PSST_LOG_STYLE"; 26 | 27 | fn main() { 28 | // Setup logging from the env variables, with defaults. 29 | Builder::from_env( 30 | Env::new() 31 | .filter_or(ENV_LOG, "info") 32 | .write_style(ENV_LOG_STYLE), 33 | ) 34 | .init(); 35 | 36 | // Load configuration 37 | let config = Config::load().unwrap_or_default(); 38 | 39 | let paginated_limit = config.paginated_limit; 40 | let mut state = AppState::default_with_config(config.clone()); 41 | 42 | if let Some(cache_dir) = Config::cache_dir() { 43 | match Cache::new(cache_dir) { 44 | Ok(cache) => { 45 | state.preferences.cache = Some(cache); 46 | } 47 | Err(err) => { 48 | log::error!("Failed to create cache: {err}"); 49 | } 50 | } 51 | } 52 | 53 | WebApi::new( 54 | state.session.clone(), 55 | Config::proxy().as_deref(), 56 | Config::cache_dir(), 57 | paginated_limit, 58 | ) 59 | .install_as_global(); 60 | 61 | let delegate; 62 | let launcher; 63 | if state.config.has_credentials() { 64 | // Credentials are configured, open the main window. 65 | let window = ui::main_window(&state.config); 66 | delegate = Delegate::with_main(window.id); 67 | launcher = AppLauncher::with_window(window).configure_env(ui::theme::setup); 68 | 69 | // Load user's local tracks for the WebApi. 70 | WebApi::global().load_local_tracks(state.config.username().unwrap()); 71 | } else { 72 | // No configured credentials, open the account setup. 73 | let window = ui::account_setup_window(); 74 | delegate = Delegate::with_preferences(window.id); 75 | launcher = AppLauncher::with_window(window).configure_env(ui::theme::setup); 76 | }; 77 | 78 | launcher 79 | .delegate(delegate) 80 | .launch(state) 81 | .expect("Application launch"); 82 | } 83 | -------------------------------------------------------------------------------- /psst-core/src/session/audio_key.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | collections::HashMap, 3 | io::{Cursor, Read}, 4 | }; 5 | 6 | use byteorder::{ReadBytesExt, BE}; 7 | use crossbeam_channel::Sender; 8 | 9 | use crate::{ 10 | audio::decrypt::AudioKey, 11 | connection::shannon_codec::ShannonMsg, 12 | error::Error, 13 | item_id::{FileId, ItemId}, 14 | util::Sequence, 15 | }; 16 | 17 | pub struct AudioKeyDispatcher { 18 | sequence: Sequence, 19 | pending: HashMap>>, 20 | } 21 | 22 | impl AudioKeyDispatcher { 23 | pub fn new() -> Self { 24 | Self { 25 | sequence: Sequence::new(0), 26 | pending: HashMap::new(), 27 | } 28 | } 29 | 30 | pub fn enqueue_request( 31 | &mut self, 32 | track: ItemId, 33 | file: FileId, 34 | callback: Sender>, 35 | ) -> ShannonMsg { 36 | let seq = self.sequence.advance(); 37 | self.pending.insert(seq, callback); 38 | Self::make_key_request(seq, track, file) 39 | } 40 | 41 | fn make_key_request(seq: u32, track: ItemId, file: FileId) -> ShannonMsg { 42 | let mut buf = Vec::new(); 43 | buf.extend(file.0); 44 | buf.extend(track.to_raw()); 45 | buf.extend(seq.to_be_bytes()); 46 | buf.extend(0_u16.to_be_bytes()); 47 | ShannonMsg::new(ShannonMsg::REQUEST_KEY, buf) 48 | } 49 | 50 | pub fn handle_aes_key(&mut self, msg: ShannonMsg) { 51 | let mut payload = Cursor::new(msg.payload); 52 | let seq = payload.read_u32::().unwrap(); 53 | 54 | if let Some(tx) = self.pending.remove(&seq) { 55 | let mut key = [0_u8; 16]; 56 | payload.read_exact(&mut key).unwrap(); 57 | 58 | if tx.send(Ok(AudioKey(key))).is_err() { 59 | log::warn!("missing receiver for audio key, seq: {seq}"); 60 | } 61 | } else { 62 | log::warn!("received unexpected audio key msg, seq: {seq}"); 63 | } 64 | } 65 | 66 | pub fn handle_aes_key_error(&mut self, msg: ShannonMsg) { 67 | let mut payload = Cursor::new(msg.payload); 68 | let seq = payload.read_u32::().unwrap(); 69 | 70 | if let Some(tx) = self.pending.remove(&seq) { 71 | log::error!("audio key error"); 72 | if tx.send(Err(Error::UnexpectedResponse)).is_err() { 73 | log::warn!("missing receiver for audio key error, seq: {seq}"); 74 | } 75 | } else { 76 | log::warn!("received unknown audio key, seq: {seq}"); 77 | } 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /psst-core/src/session/access_token.rs: -------------------------------------------------------------------------------- 1 | use std::time::{Duration, Instant}; 2 | 3 | use parking_lot::Mutex; 4 | use serde::Deserialize; 5 | 6 | use crate::error::Error; 7 | 8 | use super::SessionService; 9 | 10 | // Client ID of the official Web Spotify front-end. 11 | pub const CLIENT_ID: &str = "65b708073fc0480ea92a077233ca87bd"; 12 | 13 | // All scopes we could possibly require. 14 | pub const ACCESS_SCOPES: &str = "streaming,user-read-email,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"; 15 | 16 | // Consider token expired even before the official expiration time. Spotify 17 | // seems to be reporting excessive token TTLs so let's cut it down by 30 18 | // minutes. 19 | const EXPIRATION_TIME_THRESHOLD: Duration = Duration::from_secs(60 * 30); 20 | 21 | #[derive(Clone)] 22 | pub struct AccessToken { 23 | pub token: String, 24 | pub expires: Instant, 25 | } 26 | 27 | impl AccessToken { 28 | fn expired() -> Self { 29 | Self { 30 | token: String::new(), 31 | expires: Instant::now(), 32 | } 33 | } 34 | 35 | pub fn request(session: &SessionService) -> Result { 36 | #[derive(Deserialize)] 37 | struct MercuryAccessToken { 38 | #[serde(rename = "expiresIn")] 39 | expires_in: u64, 40 | #[serde(rename = "accessToken")] 41 | access_token: String, 42 | } 43 | 44 | let token: MercuryAccessToken = session.connected()?.get_mercury_json(format!( 45 | "hm://keymaster/token/authenticated?client_id={CLIENT_ID}&scope={ACCESS_SCOPES}", 46 | ))?; 47 | 48 | Ok(Self { 49 | token: token.access_token, 50 | expires: Instant::now() + Duration::from_secs(token.expires_in), 51 | }) 52 | } 53 | 54 | fn is_expired(&self) -> bool { 55 | self.expires.saturating_duration_since(Instant::now()) < EXPIRATION_TIME_THRESHOLD 56 | } 57 | } 58 | 59 | pub struct TokenProvider { 60 | token: Mutex, 61 | } 62 | 63 | impl TokenProvider { 64 | pub fn new() -> Self { 65 | Self { 66 | token: Mutex::new(AccessToken::expired()), 67 | } 68 | } 69 | 70 | pub fn get(&self, session: &SessionService) -> Result { 71 | let mut token = self.token.lock(); 72 | if token.is_expired() { 73 | log::info!("access token expired, requesting"); 74 | *token = AccessToken::request(session)?; 75 | } 76 | Ok(token.clone()) 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /psst-core/src/audio/probe.rs: -------------------------------------------------------------------------------- 1 | use std::fs::File; 2 | use std::path::PathBuf; 3 | use std::time::Duration; 4 | 5 | use symphonia::core::codecs::CodecType; 6 | use symphonia::core::formats::FormatOptions; 7 | use symphonia::core::io::{MediaSourceStream, MediaSourceStreamOptions}; 8 | use symphonia::core::meta::MetadataOptions; 9 | use symphonia::core::probe::{Hint, Probe}; 10 | use symphonia::default::formats::{MpaReader, OggReader}; 11 | 12 | use crate::error::Error; 13 | 14 | pub struct TrackProbe { 15 | pub codec: CodecType, 16 | pub duration: Option, 17 | } 18 | 19 | macro_rules! probe_err { 20 | ($message:tt) => { 21 | // This is necessary to work around the fact that the two impls for From<&str> are: 22 | // Box 23 | // Box 24 | // And the trait bound on our error is: 25 | // Box 26 | // Normally we could just do `$message.into()`, but no impl exists for exactly 27 | // `Error + Send`, so we have to be explicit about which we want to use. 28 | Error::AudioProbeError(Box::::from($message)) 29 | }; 30 | } 31 | 32 | impl TrackProbe { 33 | pub fn new(path: &PathBuf) -> Result { 34 | // Register all supported file formats for detection. 35 | let mut probe = Probe::default(); 36 | probe.register_all::(); 37 | probe.register_all::(); 38 | 39 | let mut hint = Hint::new(); 40 | if let Some(ext) = path.extension().and_then(|e| e.to_str()) { 41 | hint.with_extension(ext); 42 | } 43 | 44 | let file = File::open(path)?; 45 | let mss_opts = MediaSourceStreamOptions::default(); 46 | let mss = MediaSourceStream::new(Box::new(file), mss_opts); 47 | 48 | let fmt_opts = FormatOptions::default(); 49 | let meta_opts = MetadataOptions::default(); 50 | let probe_result = probe 51 | .format(&hint, mss, &fmt_opts, &meta_opts) 52 | .map_err(|_| probe_err!("failed to probe file"))?; 53 | let track = probe_result 54 | .format 55 | .default_track() 56 | .ok_or_else(|| probe_err!("file contained no tracks"))?; 57 | let params = &track.codec_params; 58 | 59 | let duration = 60 | if let (Some(time_base), Some(n_frames)) = (params.time_base, params.n_frames) { 61 | let time = time_base.calc_time(n_frames); 62 | let secs = time.seconds; 63 | let ms = (time.frac * 1_000.0).round() as u64; 64 | Some(Duration::from_millis(secs * 1_000 + ms)) 65 | } else { 66 | None 67 | }; 68 | 69 | Ok(Self { 70 | codec: params.codec, 71 | duration, 72 | }) 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /psst-gui/src/widget/fill_between.rs: -------------------------------------------------------------------------------- 1 | use druid::{ 2 | BoxConstraints, Data, Env, Event, EventCtx, LayoutCtx, LifeCycle, LifeCycleCtx, PaintCtx, 3 | Point, Size, UpdateCtx, Widget, WidgetPod, 4 | }; 5 | 6 | /// A widget that positions two children, allowing the right child to fill the remaining space. 7 | /// The left child is measured first, and the right child is given the rest of the available width. 8 | pub struct FillBetween { 9 | left: WidgetPod>>, 10 | right: WidgetPod>>, 11 | } 12 | 13 | impl FillBetween { 14 | pub fn new(left: impl Widget + 'static, right: impl Widget + 'static) -> Self { 15 | Self { 16 | left: WidgetPod::new(Box::new(left)), 17 | right: WidgetPod::new(Box::new(right)), 18 | } 19 | } 20 | } 21 | 22 | impl Widget for FillBetween { 23 | fn event(&mut self, ctx: &mut EventCtx, event: &Event, data: &mut T, env: &Env) { 24 | self.right.event(ctx, event, data, env); 25 | self.left.event(ctx, event, data, env); 26 | } 27 | 28 | fn lifecycle(&mut self, ctx: &mut LifeCycleCtx, event: &LifeCycle, data: &T, env: &Env) { 29 | self.right.lifecycle(ctx, event, data, env); 30 | self.left.lifecycle(ctx, event, data, env); 31 | } 32 | 33 | fn update(&mut self, ctx: &mut UpdateCtx, _old_data: &T, data: &T, env: &Env) { 34 | self.left.update(ctx, data, env); 35 | self.right.update(ctx, data, env); 36 | } 37 | 38 | fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints, data: &T, env: &Env) -> Size { 39 | let max_width = bc.max().width; 40 | 41 | // Measure the left child with our max width constraint. 42 | let left_bc = BoxConstraints::new( 43 | Size::new(0.0, bc.min().height), 44 | Size::new(max_width, bc.max().height), 45 | ); 46 | let left_size = self.left.layout(ctx, &left_bc, data, env); 47 | 48 | // Layout the right child in the remaining space. 49 | let right_width = (max_width - left_size.width).max(0.0); 50 | let right_bc = BoxConstraints::tight(Size::new(right_width, left_size.height)); 51 | let right_size = self.right.layout(ctx, &right_bc, data, env); 52 | 53 | // Vertically center children. 54 | let total_height = left_size.height.max(right_size.height); 55 | let left_y = (total_height - left_size.height) / 2.0; 56 | let right_y = (total_height - right_size.height) / 2.0; 57 | 58 | self.left.set_origin(ctx, Point::new(0.0, left_y)); 59 | self.right 60 | .set_origin(ctx, Point::new(left_size.width, right_y)); 61 | 62 | Size::new(max_width, total_height) 63 | } 64 | 65 | fn paint(&mut self, ctx: &mut PaintCtx, data: &T, env: &Env) { 66 | self.left.paint(ctx, data, env); 67 | self.right.paint(ctx, data, env); 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /psst-gui/src/webapi/cache.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | collections::hash_map::DefaultHasher, 3 | fs::{self, File}, 4 | hash::{Hash, Hasher}, 5 | num::NonZeroUsize, 6 | path::PathBuf, 7 | sync::Arc, 8 | }; 9 | 10 | use druid::image; 11 | use druid::ImageBuf; 12 | use lru::LruCache; 13 | use parking_lot::Mutex; 14 | use psst_core::cache::mkdir_if_not_exists; 15 | 16 | pub struct WebApiCache { 17 | base: Option, 18 | images: Mutex, ImageBuf>>, 19 | } 20 | 21 | impl WebApiCache { 22 | pub fn new(base: Option) -> Self { 23 | const IMAGE_CACHE_SIZE: usize = 256; 24 | Self { 25 | base, 26 | images: Mutex::new(LruCache::new(NonZeroUsize::new(IMAGE_CACHE_SIZE).unwrap())), 27 | } 28 | } 29 | 30 | pub fn get_image(&self, uri: &Arc) -> Option { 31 | self.images.lock().get(uri).cloned() 32 | } 33 | 34 | pub fn set_image(&self, uri: Arc, image: ImageBuf) { 35 | self.images.lock().put(uri, image); 36 | } 37 | 38 | pub fn get_image_from_disk(&self, uri: &Arc) -> Option { 39 | let hash = Self::hash_uri(uri); 40 | self.key("images", &format!("{hash:016x}")) 41 | .and_then(|path| std::fs::read(path).ok()) 42 | .and_then(|bytes| image::load_from_memory(&bytes).ok()) 43 | .map(ImageBuf::from_dynamic_image) 44 | } 45 | 46 | pub fn save_image_to_disk(&self, uri: &Arc, data: &[u8]) { 47 | let hash = Self::hash_uri(uri); 48 | if let Some(path) = self.key("images", &format!("{hash:016x}")) { 49 | if let Some(parent) = path.parent() { 50 | let _ = std::fs::create_dir_all(parent); 51 | } 52 | let _ = std::fs::write(path, data); 53 | } 54 | } 55 | 56 | fn hash_uri(uri: &str) -> u64 { 57 | let mut hasher = DefaultHasher::new(); 58 | uri.hash(&mut hasher); 59 | hasher.finish() 60 | } 61 | 62 | pub fn get(&self, bucket: &str, key: &str) -> Option { 63 | self.key(bucket, key).and_then(|path| File::open(path).ok()) 64 | } 65 | 66 | pub fn set(&self, bucket: &str, key: &str, value: &[u8]) { 67 | if let Some(path) = self.bucket(bucket) { 68 | if let Err(err) = mkdir_if_not_exists(&path) { 69 | log::error!("failed to create WebAPI cache bucket: {err:?}"); 70 | } 71 | } 72 | if let Some(path) = self.key(bucket, key) { 73 | if let Err(err) = fs::write(path, value) { 74 | log::error!("failed to save to WebAPI cache: {err:?}"); 75 | } 76 | } 77 | } 78 | 79 | fn bucket(&self, bucket: &str) -> Option { 80 | self.base.as_ref().map(|path| path.join(bucket)) 81 | } 82 | 83 | fn key(&self, bucket: &str, key: &str) -> Option { 84 | self.bucket(bucket).map(|path| path.join(key)) 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /psst-gui/src/controller/sort.rs: -------------------------------------------------------------------------------- 1 | use druid::widget::{prelude::*, Controller}; 2 | use druid::{Event, EventCtx, Widget}; 3 | 4 | use crate::cmd; 5 | use crate::data::config::SortCriteria; 6 | use crate::data::{config::SortOrder, AppState}; 7 | 8 | pub struct SortController; 9 | 10 | impl Controller for SortController 11 | where 12 | W: Widget, 13 | { 14 | fn event( 15 | &mut self, 16 | child: &mut W, 17 | ctx: &mut EventCtx, 18 | event: &Event, 19 | data: &mut AppState, 20 | env: &Env, 21 | ) { 22 | match event { 23 | Event::Command(cmd) if cmd.is(cmd::TOGGLE_SORT_ORDER) => { 24 | if data.config.sort_order == SortOrder::Ascending { 25 | data.config.sort_order = SortOrder::Descending; 26 | } else { 27 | data.config.sort_order = SortOrder::Ascending; 28 | } 29 | 30 | ctx.submit_command(cmd::NAVIGATE_REFRESH); 31 | ctx.set_handled(); 32 | } 33 | Event::Command(cmd) if cmd.is(cmd::SORT_BY_TITLE) => { 34 | if data.config.sort_criteria != SortCriteria::Title { 35 | data.config.sort_criteria = SortCriteria::Title; 36 | ctx.submit_command(cmd::NAVIGATE_REFRESH); 37 | ctx.set_handled(); 38 | } 39 | } 40 | Event::Command(cmd) if cmd.is(cmd::SORT_BY_ALBUM) => { 41 | if data.config.sort_criteria != SortCriteria::Album { 42 | data.config.sort_criteria = SortCriteria::Album; 43 | ctx.submit_command(cmd::NAVIGATE_REFRESH); 44 | ctx.set_handled(); 45 | } 46 | } 47 | Event::Command(cmd) if cmd.is(cmd::SORT_BY_DATE_ADDED) => { 48 | if data.config.sort_criteria != SortCriteria::DateAdded { 49 | data.config.sort_criteria = SortCriteria::DateAdded; 50 | ctx.submit_command(cmd::NAVIGATE_REFRESH); 51 | ctx.set_handled(); 52 | } 53 | } 54 | Event::Command(cmd) if cmd.is(cmd::SORT_BY_ARTIST) => { 55 | if data.config.sort_criteria != SortCriteria::Artist { 56 | data.config.sort_criteria = SortCriteria::Artist; 57 | ctx.submit_command(cmd::NAVIGATE_REFRESH); 58 | ctx.set_handled(); 59 | } 60 | } 61 | Event::Command(cmd) if cmd.is(cmd::SORT_BY_DURATION) => { 62 | if data.config.sort_criteria != SortCriteria::Duration { 63 | data.config.sort_criteria = SortCriteria::Duration; 64 | ctx.submit_command(cmd::NAVIGATE_REFRESH); 65 | ctx.set_handled(); 66 | } 67 | } 68 | _ => { 69 | child.event(ctx, event, data, env); 70 | } 71 | } 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /psst-gui/src/data/playlist.rs: -------------------------------------------------------------------------------- 1 | use std::sync::Arc; 2 | 3 | use druid::{im::Vector, Data, Lens}; 4 | use serde::{Deserialize, Deserializer, Serialize}; 5 | 6 | use crate::data::utils::sanitize_html_string; 7 | use crate::data::{user::PublicUser, Image, Promise, Track, TrackId}; 8 | 9 | #[derive(Clone, Debug, Data, Lens)] 10 | pub struct PlaylistDetail { 11 | pub playlist: Promise, 12 | pub tracks: Promise, 13 | } 14 | 15 | #[derive(Clone, Debug, Data, Lens, Deserialize)] 16 | pub struct PlaylistAddTrack { 17 | pub link: PlaylistLink, 18 | pub track_id: TrackId, 19 | } 20 | 21 | #[derive(Clone, Debug, Data, Lens, Deserialize)] 22 | pub struct PlaylistRemoveTrack { 23 | pub link: PlaylistLink, 24 | pub track_pos: usize, 25 | } 26 | 27 | #[derive(Clone, Debug, Data, Lens, Deserialize)] 28 | pub struct Playlist { 29 | pub id: Arc, 30 | pub name: Arc, 31 | #[serde(skip_serializing_if = "Option::is_none")] 32 | pub images: Option>, 33 | #[serde(deserialize_with = "deserialize_description")] 34 | pub description: Arc, 35 | #[serde(rename = "tracks")] 36 | #[serde(deserialize_with = "deserialize_track_count")] 37 | pub track_count: Option, 38 | pub owner: PublicUser, 39 | pub collaborative: bool, 40 | #[serde(rename = "public")] 41 | pub public: Option, 42 | } 43 | 44 | impl Playlist { 45 | pub fn link(&self) -> PlaylistLink { 46 | PlaylistLink { 47 | id: self.id.clone(), 48 | name: self.name.clone(), 49 | } 50 | } 51 | 52 | pub fn image(&self, width: f64, height: f64) -> Option<&Image> { 53 | self.images 54 | .as_ref() 55 | .and_then(|images| Image::at_least_of_size(images, width, height)) 56 | } 57 | 58 | pub fn url(&self) -> String { 59 | format!("https://open.spotify.com/playlist/{id}", id = self.id) 60 | } 61 | } 62 | 63 | #[derive(Clone, Debug, Data, Lens)] 64 | pub struct PlaylistTracks { 65 | pub id: Arc, 66 | pub name: Arc, 67 | pub tracks: Vector>, 68 | } 69 | 70 | impl PlaylistTracks { 71 | pub fn link(&self) -> PlaylistLink { 72 | PlaylistLink { 73 | id: self.id.clone(), 74 | name: self.name.clone(), 75 | } 76 | } 77 | } 78 | 79 | #[derive(Clone, Debug, Data, Lens, Eq, PartialEq, Hash, Deserialize, Serialize)] 80 | pub struct PlaylistLink { 81 | pub id: Arc, 82 | pub name: Arc, 83 | } 84 | 85 | fn deserialize_track_count<'de, D>(deserializer: D) -> Result, D::Error> 86 | where 87 | D: Deserializer<'de>, 88 | { 89 | #[derive(Deserialize)] 90 | struct PlaylistTracksRef { 91 | total: Option, 92 | } 93 | 94 | Ok(PlaylistTracksRef::deserialize(deserializer)?.total) 95 | } 96 | 97 | fn deserialize_description<'de, D>(deserializer: D) -> Result, D::Error> 98 | where 99 | D: Deserializer<'de>, 100 | { 101 | let description: String = String::deserialize(deserializer)?; 102 | Ok(sanitize_html_string(&description)) 103 | } 104 | -------------------------------------------------------------------------------- /psst-gui/src/data/promise.rs: -------------------------------------------------------------------------------- 1 | use druid::Data; 2 | 3 | use crate::error::Error; 4 | 5 | #[derive(Clone, Debug, Data, Default)] 6 | pub enum Promise { 7 | #[default] 8 | Empty, 9 | Deferred { def: D }, 10 | Resolved { def: D, val: T }, 11 | Rejected { def: D, err: E }, 12 | } 13 | 14 | #[derive(Eq, PartialEq, Debug)] 15 | pub enum PromiseState { 16 | Empty, 17 | Deferred, 18 | Resolved, 19 | Rejected, 20 | } 21 | 22 | impl Promise { 23 | pub fn state(&self) -> PromiseState { 24 | match self { 25 | Self::Empty => PromiseState::Empty, 26 | Self::Deferred { .. } => PromiseState::Deferred, 27 | Self::Resolved { .. } => PromiseState::Resolved, 28 | Self::Rejected { .. } => PromiseState::Rejected, 29 | } 30 | } 31 | 32 | pub fn is_resolved(&self) -> bool { 33 | self.state() == PromiseState::Resolved 34 | } 35 | 36 | pub fn is_deferred(&self, d: &D) -> bool 37 | where 38 | D: PartialEq, 39 | { 40 | matches!(self, Self::Deferred { def } if def == d) 41 | } 42 | 43 | pub fn contains(&self, d: &D) -> bool 44 | where 45 | D: PartialEq, 46 | { 47 | matches!(self, Self::Resolved { def, .. } if def == d) 48 | } 49 | 50 | pub fn deferred(&self) -> Option<&D> { 51 | match self { 52 | Promise::Deferred { def } 53 | | Promise::Resolved { def, .. } 54 | | Promise::Rejected { def, .. } => Some(def), 55 | Promise::Empty => None, 56 | } 57 | } 58 | 59 | pub fn resolved(&self) -> Option<&T> { 60 | if let Promise::Resolved { val, .. } = self { 61 | Some(val) 62 | } else { 63 | None 64 | } 65 | } 66 | 67 | pub fn resolved_mut(&mut self) -> Option<&mut T> { 68 | if let Promise::Resolved { val, .. } = self { 69 | Some(val) 70 | } else { 71 | None 72 | } 73 | } 74 | 75 | pub fn clear(&mut self) { 76 | *self = Self::Empty; 77 | } 78 | 79 | pub fn defer(&mut self, def: D) { 80 | *self = Self::Deferred { def }; 81 | } 82 | 83 | pub fn resolve(&mut self, def: D, val: T) { 84 | *self = Self::Resolved { def, val }; 85 | } 86 | 87 | pub fn reject(&mut self, def: D, err: E) { 88 | *self = Self::Rejected { def, err }; 89 | } 90 | 91 | pub fn resolve_or_reject(&mut self, def: D, res: Result) { 92 | match res { 93 | Ok(val) => self.resolve(def, val), 94 | Err(err) => self.reject(def, err), 95 | } 96 | } 97 | 98 | pub fn update(&mut self, (def, res): (D, Result)) 99 | where 100 | D: PartialEq, 101 | { 102 | if self.is_deferred(&def) { 103 | self.resolve_or_reject(def, res); 104 | } else { 105 | // Ignore. 106 | } 107 | } 108 | } 109 | 110 | impl Promise { 111 | pub fn defer_default(&mut self) { 112 | *self = Self::Deferred { def: D::default() }; 113 | } 114 | } 115 | 116 | 117 | -------------------------------------------------------------------------------- /psst-core/src/audio/resample.rs: -------------------------------------------------------------------------------- 1 | use crate::error::Error; 2 | 3 | #[derive(Copy, Clone)] 4 | pub enum ResamplingQuality { 5 | SincBestQuality = libsamplerate::SRC_SINC_BEST_QUALITY as isize, 6 | SincMediumQuality = libsamplerate::SRC_SINC_MEDIUM_QUALITY as isize, 7 | SincFastest = libsamplerate::SRC_SINC_FASTEST as isize, 8 | ZeroOrderHold = libsamplerate::SRC_ZERO_ORDER_HOLD as isize, 9 | Linear = libsamplerate::SRC_LINEAR as isize, 10 | } 11 | 12 | #[derive(Copy, Clone)] 13 | pub struct ResamplingSpec { 14 | pub input_rate: u32, 15 | pub output_rate: u32, 16 | pub channels: usize, 17 | } 18 | 19 | impl ResamplingSpec { 20 | pub fn output_size(&self, input_size: usize) -> usize { 21 | (self.output_rate as f64 / self.input_rate as f64 * input_size as f64) as usize 22 | } 23 | 24 | pub fn input_size(&self, output_size: usize) -> usize { 25 | (self.input_rate as f64 / self.output_rate as f64 * output_size as f64) as usize 26 | } 27 | 28 | pub fn ratio(&self) -> f64 { 29 | self.output_rate as f64 / self.input_rate as f64 30 | } 31 | } 32 | 33 | pub struct AudioResampler { 34 | pub spec: ResamplingSpec, 35 | state: *mut libsamplerate::SRC_STATE, 36 | } 37 | 38 | impl AudioResampler { 39 | pub fn new(quality: ResamplingQuality, spec: ResamplingSpec) -> Result { 40 | let mut error_int = 0i32; 41 | let state = unsafe { 42 | libsamplerate::src_new( 43 | quality as i32, 44 | spec.channels as i32, 45 | &mut error_int as *mut i32, 46 | ) 47 | }; 48 | if error_int != 0 { 49 | Err(Error::ResamplingError(error_int)) 50 | } else { 51 | Ok(Self { state, spec }) 52 | } 53 | } 54 | 55 | pub fn process(&mut self, input: &[f32], output: &mut [f32]) -> Result<(usize, usize), Error> { 56 | if self.spec.input_rate == self.spec.output_rate { 57 | // Bypass conversion completely in case the sample rates are equal. 58 | let output = &mut output[..input.len()]; 59 | output.copy_from_slice(input); 60 | return Ok((input.len(), output.len())); 61 | } 62 | let mut src = libsamplerate::SRC_DATA { 63 | data_in: input.as_ptr(), 64 | data_out: output.as_mut_ptr(), 65 | input_frames: (input.len() / self.spec.channels) as _, 66 | output_frames: (output.len() / self.spec.channels) as _, 67 | src_ratio: self.spec.ratio(), 68 | end_of_input: 0, // TODO: Use this. 69 | input_frames_used: 0, 70 | output_frames_gen: 0, 71 | }; 72 | let error_int = unsafe { libsamplerate::src_process(self.state, &mut src as *mut _) }; 73 | if error_int != 0 { 74 | Err(Error::ResamplingError(error_int)) 75 | } else { 76 | let processed_len = src.input_frames_used as usize * self.spec.channels; 77 | let output_len = src.output_frames_gen as usize * self.spec.channels; 78 | Ok((processed_len, output_len)) 79 | } 80 | } 81 | } 82 | 83 | impl Drop for AudioResampler { 84 | fn drop(&mut self) { 85 | unsafe { libsamplerate::src_delete(self.state) }; 86 | } 87 | } 88 | 89 | unsafe impl Send for AudioResampler {} 90 | -------------------------------------------------------------------------------- /psst-core/src/actor.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | fmt::Display, 3 | thread::{self, JoinHandle}, 4 | time::Duration, 5 | }; 6 | 7 | use crossbeam_channel::{ 8 | bounded, unbounded, Receiver, RecvTimeoutError, SendError, Sender, TrySendError, 9 | }; 10 | 11 | pub enum Act { 12 | Continue, 13 | WaitOr { 14 | timeout: Duration, 15 | timeout_msg: T::Message, 16 | }, 17 | Shutdown, 18 | } 19 | 20 | pub trait Actor: Sized { 21 | type Message: Send + 'static; 22 | type Error: Display; 23 | 24 | fn handle(&mut self, msg: Self::Message) -> Result, Self::Error>; 25 | 26 | fn process(mut self, recv: Receiver) { 27 | let mut act = Act::Continue; 28 | loop { 29 | let msg = match act { 30 | Act::Continue => match recv.recv() { 31 | Ok(msg) => msg, 32 | Err(_) => { 33 | break; 34 | } 35 | }, 36 | Act::WaitOr { 37 | timeout, 38 | timeout_msg, 39 | } => match recv.recv_timeout(timeout) { 40 | Ok(msg) => msg, 41 | Err(RecvTimeoutError::Timeout) => timeout_msg, 42 | Err(RecvTimeoutError::Disconnected) => { 43 | break; 44 | } 45 | }, 46 | Act::Shutdown => { 47 | break; 48 | } 49 | }; 50 | act = match self.handle(msg) { 51 | Ok(act) => act, 52 | Err(err) => { 53 | log::error!("error: {err}"); 54 | break; 55 | } 56 | }; 57 | } 58 | } 59 | 60 | fn spawn(cap: Capacity, name: &str, factory: F) -> ActorHandle 61 | where 62 | F: FnOnce(Sender) -> Self + Send + 'static, 63 | { 64 | let (send, recv) = cap.to_channel(); 65 | ActorHandle { 66 | sender: send.clone(), 67 | thread: thread::Builder::new() 68 | .name(name.to_string()) 69 | .spawn(move || { 70 | factory(send).process(recv); 71 | }) 72 | .unwrap(), 73 | } 74 | } 75 | 76 | fn spawn_with_default_cap(name: &str, factory: F) -> ActorHandle 77 | where 78 | F: FnOnce(Sender) -> Self + Send + 'static, 79 | { 80 | Self::spawn(Capacity::Bounded(128), name, factory) 81 | } 82 | } 83 | 84 | pub struct ActorHandle { 85 | thread: JoinHandle<()>, 86 | sender: Sender, 87 | } 88 | 89 | impl ActorHandle { 90 | pub fn sender(&self) -> Sender { 91 | self.sender.clone() 92 | } 93 | 94 | pub fn join(self) { 95 | let _ = self.thread.join(); 96 | } 97 | 98 | pub fn send(&self, msg: M) -> Result<(), SendError> { 99 | self.sender.send(msg) 100 | } 101 | 102 | pub fn try_send(&self, msg: M) -> Result<(), TrySendError> { 103 | self.sender.try_send(msg) 104 | } 105 | } 106 | 107 | pub enum Capacity { 108 | Sync, 109 | Bounded(usize), 110 | Unbounded, 111 | } 112 | 113 | impl Capacity { 114 | pub fn to_channel(&self) -> (Sender, Receiver) { 115 | match self { 116 | Capacity::Sync => bounded(0), 117 | Capacity::Bounded(cap) => bounded(*cap), 118 | Capacity::Unbounded => unbounded(), 119 | } 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /psst-gui/src/ui/menu.rs: -------------------------------------------------------------------------------- 1 | use druid::{commands, platform_menus, Env, LocalizedString, Menu, MenuItem, SysMods, WindowId}; 2 | 3 | use crate::{ 4 | cmd, 5 | data::{AppState, Nav}, 6 | }; 7 | 8 | pub fn main_menu(_window: Option, _data: &AppState, _env: &Env) -> Menu { 9 | if cfg!(target_os = "macos") { 10 | Menu::empty().entry(mac_app_menu()) 11 | } else { 12 | Menu::empty() 13 | } 14 | .entry(edit_menu()) 15 | .entry(view_menu()) 16 | } 17 | 18 | fn mac_app_menu() -> Menu { 19 | // macOS-only commands are deprecated on other systems. 20 | #[cfg_attr(not(target_os = "macos"), allow(deprecated))] 21 | Menu::new(LocalizedString::new("macos-menu-application-menu")) 22 | .entry(platform_menus::mac::application::preferences()) 23 | .separator() 24 | .entry( 25 | // TODO: 26 | // This is just overriding `platform_menus::mac::application::quit()` 27 | // because l10n is a bit stupid now. 28 | MenuItem::new(LocalizedString::new("macos-menu-quit").with_placeholder("Quit Psst")) 29 | .command(cmd::QUIT_APP_WITH_SAVE) 30 | .hotkey(SysMods::Cmd, "q"), 31 | ) 32 | .entry( 33 | MenuItem::new(LocalizedString::new("macos-menu-hide").with_placeholder("Hide Psst")) 34 | .command(commands::HIDE_APPLICATION) 35 | .hotkey(SysMods::Cmd, "h"), 36 | ) 37 | .entry( 38 | MenuItem::new( 39 | LocalizedString::new("macos-menu-hide-others").with_placeholder("Hide Others"), 40 | ) 41 | .command(commands::HIDE_OTHERS) 42 | .hotkey(SysMods::AltCmd, "h"), 43 | ) 44 | } 45 | 46 | fn edit_menu() -> Menu { 47 | Menu::new(LocalizedString::new("common-menu-edit-menu").with_placeholder("Edit")) 48 | .entry(platform_menus::common::cut()) 49 | .entry(platform_menus::common::copy()) 50 | .entry(platform_menus::common::paste()) 51 | } 52 | 53 | fn view_menu() -> Menu { 54 | Menu::new(LocalizedString::new("menu-view-menu").with_placeholder("View")) 55 | .entry( 56 | MenuItem::new(LocalizedString::new("menu-item-home").with_placeholder("Home")) 57 | .command(cmd::NAVIGATE.with(Nav::Home)) 58 | .hotkey(SysMods::Cmd, "1"), 59 | ) 60 | .entry( 61 | MenuItem::new( 62 | LocalizedString::new("menu-item-saved-tracks").with_placeholder("Saved Tracks"), 63 | ) 64 | .command(cmd::NAVIGATE.with(Nav::SavedTracks)) 65 | .hotkey(SysMods::Cmd, "2"), 66 | ) 67 | .entry( 68 | MenuItem::new( 69 | LocalizedString::new("menu-item-saved-albums").with_placeholder("Saved Albums"), 70 | ) 71 | .command(cmd::NAVIGATE.with(Nav::SavedAlbums)) 72 | .hotkey(SysMods::Cmd, "3"), 73 | ) 74 | .entry( 75 | MenuItem::new( 76 | LocalizedString::new("menu-item-saved-shows").with_placeholder("Saved Shows"), 77 | ) 78 | .command(cmd::NAVIGATE.with(Nav::Shows)) 79 | .hotkey(SysMods::Cmd, "4"), 80 | ) 81 | .entry( 82 | MenuItem::new(LocalizedString::new("menu-item-search").with_placeholder("Search...")) 83 | .command(cmd::SET_FOCUS.to(cmd::WIDGET_SEARCH_INPUT)) 84 | .hotkey(SysMods::Cmd, "l"), 85 | ) 86 | .entry( 87 | MenuItem::new(LocalizedString::new("menu-item-find").with_placeholder("Find...")) 88 | .command(cmd::TOGGLE_FINDER) 89 | .hotkey(SysMods::Cmd, "f"), 90 | ) 91 | } 92 | -------------------------------------------------------------------------------- /psst-core/src/lastfm.rs: -------------------------------------------------------------------------------- 1 | use crate::error::Error; 2 | use crate::oauth::listen_for_callback_parameter; 3 | use rustfm_scrobble::{responses::SessionResponse, Scrobble, Scrobbler, ScrobblerError}; 4 | use std::{net::SocketAddr, time::Duration}; 5 | use url::Url; 6 | 7 | pub struct LastFmClient; 8 | 9 | impl LastFmClient { 10 | /// Report a track as "now playing" to Last.fm using an existing Scrobbler instance. 11 | pub fn now_playing_song( 12 | scrobbler: &Scrobbler, // Requires an authenticated Scrobbler 13 | artist: &str, 14 | title: &str, 15 | album: Option<&str>, 16 | ) -> Result<(), Error> { 17 | let song = Scrobble::new(artist, title, album.unwrap_or("")); 18 | scrobbler 19 | .now_playing(&song) 20 | .map(|_| ()) 21 | .map_err(Error::from) 22 | } 23 | 24 | /// Scrobble a finished track to Last.fm using an existing Scrobbler instance. 25 | pub fn scrobble_song( 26 | scrobbler: &Scrobbler, // Requires an authenticated Scrobbler 27 | artist: &str, 28 | title: &str, 29 | album: Option<&str>, 30 | ) -> Result<(), Error> { 31 | let song = Scrobble::new(artist, title, album.unwrap_or("")); 32 | scrobbler.scrobble(&song).map(|_| ()).map_err(Error::from) 33 | } 34 | 35 | /// Creates an authenticated Last.fm Scrobbler instance with provided credentials. 36 | /// Note: This assumes the session_key is valid. Validity is checked on first API call. 37 | pub fn create_scrobbler( 38 | api_key: Option<&str>, 39 | api_secret: Option<&str>, 40 | session_key: Option<&str>, 41 | ) -> Result { 42 | let (Some(api_key), Some(api_secret), Some(session_key)) = 43 | (api_key, api_secret, session_key) 44 | else { 45 | log::warn!("missing Last.fm API key, secret, or session key for scrobbler creation."); 46 | return Err(Error::ConfigError( 47 | "Missing Last.fm API key, secret, or session key.".to_string(), 48 | )); 49 | }; 50 | 51 | let mut scrobbler = Scrobbler::new(api_key, api_secret); 52 | // Associate the session key with the scrobbler instance. 53 | scrobbler.authenticate_with_session_key(session_key); 54 | log::info!("scrobbler instance created with session key (validity checked on first use)."); 55 | Ok(scrobbler) 56 | } 57 | } 58 | 59 | impl From for Error { 60 | fn from(value: ScrobblerError) -> Self { 61 | Self::ScrobblerError(Box::new(value)) 62 | } 63 | } 64 | 65 | /// Generate a Last.fm authentication URL 66 | pub fn generate_lastfm_auth_url( 67 | api_key: &str, 68 | callback_url: &str, 69 | ) -> Result { 70 | let base = "http://www.last.fm/api/auth/"; 71 | let url = Url::parse_with_params(base, &[("api_key", api_key), ("cb", callback_url)])?; 72 | Ok(url.to_string()) 73 | } 74 | 75 | /// Exchange a token for a Last.fm session key 76 | pub fn exchange_token_for_session( 77 | api_key: &str, 78 | api_secret: &str, 79 | token: &str, 80 | ) -> Result { 81 | let mut scrobbler = Scrobbler::new(api_key, api_secret); 82 | scrobbler 83 | .authenticate_with_token(token) // Uses auth.getSession API call internally 84 | .map(|response: SessionResponse| response.key) // Extract the session key string 85 | .map_err(Error::from) // Map ScrobblerError to crate::error::Error 86 | } 87 | 88 | /// Listen for a Last.fm token from the callback 89 | pub fn get_lastfm_token_listener( 90 | socket_address: SocketAddr, 91 | timeout: Duration, 92 | ) -> Result { 93 | // Use the shared listener function, specifying "token" as the parameter 94 | listen_for_callback_parameter(socket_address, timeout, "token") 95 | } 96 | -------------------------------------------------------------------------------- /psst-gui/src/ui/lyrics.rs: -------------------------------------------------------------------------------- 1 | use druid::widget::{Container, CrossAxisAlignment, Flex, Label, LineBreaking, List, Scroll}; 2 | use druid::{Insets, LensExt, Selector, Widget, WidgetExt}; 3 | 4 | use crate::cmd; 5 | use crate::data::{AppState, Ctx, NowPlaying, Playable, TrackLines}; 6 | use crate::widget::MyWidgetExt; 7 | use crate::{webapi::WebApi, widget::Async}; 8 | 9 | use super::theme; 10 | use super::utils; 11 | 12 | pub const SHOW_LYRICS: Selector = Selector::new("app.home.show_lyrics"); 13 | 14 | pub fn lyrics_widget() -> impl Widget { 15 | Scroll::new( 16 | Container::new( 17 | Flex::column() 18 | .cross_axis_alignment(CrossAxisAlignment::Center) 19 | .with_default_spacer() 20 | .with_child(track_info_widget()) 21 | .with_spacer(theme::grid(2.0)) 22 | .with_child(track_lyrics_widget()), 23 | ) 24 | .fix_width(400.0) 25 | .center(), 26 | ) 27 | .vertical() 28 | } 29 | 30 | fn track_info_widget() -> impl Widget { 31 | Flex::column() 32 | .cross_axis_alignment(CrossAxisAlignment::Center) 33 | .with_child( 34 | Label::dynamic(|data: &AppState, _| { 35 | data.playback.now_playing.as_ref().map_or_else( 36 | || "No track playing".to_string(), 37 | |np| match &np.item { 38 | Playable::Track(track) => track.name.clone().to_string(), 39 | _ => "Unknown track".to_string(), 40 | }, 41 | ) 42 | }) 43 | .with_font(theme::UI_FONT_MEDIUM) 44 | .with_text_size(theme::TEXT_SIZE_LARGE), 45 | ) 46 | .with_spacer(theme::grid(0.5)) 47 | .with_child( 48 | Label::dynamic(|data: &AppState, _| { 49 | data.playback.now_playing.as_ref().map_or_else( 50 | || "".to_string(), 51 | |np| match &np.item { 52 | Playable::Track(track) => { 53 | format!("{} - {}", track.artist_name(), track.album_name()) 54 | } 55 | _ => "".to_string(), 56 | }, 57 | ) 58 | }) 59 | .with_text_size(theme::TEXT_SIZE_SMALL) 60 | .with_text_color(theme::PLACEHOLDER_COLOR), 61 | ) 62 | } 63 | 64 | fn track_lyrics_widget() -> impl Widget { 65 | Async::new( 66 | utils::spinner_widget, 67 | || { 68 | List::new(|| { 69 | Label::raw() 70 | .with_line_break_mode(LineBreaking::WordWrap) 71 | .lens(Ctx::data().then(TrackLines::words)) 72 | .expand_width() 73 | .center() 74 | .padding(Insets::uniform_xy(theme::grid(1.0), theme::grid(0.5))) 75 | .link() 76 | .rounded(theme::BUTTON_BORDER_RADIUS) 77 | .on_left_click(|ctx, _, c, _| { 78 | if c.data.start_time_ms.parse::().unwrap() != 0 { 79 | ctx.submit_command( 80 | cmd::SKIP_TO_POSITION 81 | .with(c.data.start_time_ms.parse::().unwrap()), 82 | ) 83 | } 84 | }) 85 | }) 86 | }, 87 | || Label::new("No lyrics found for this track").center(), 88 | ) 89 | .lens(Ctx::make(AppState::common_ctx, AppState::lyrics).then(Ctx::in_promise())) 90 | .on_command_async( 91 | SHOW_LYRICS, 92 | |t| WebApi::global().get_lyrics(t.item.id().to_base62()), 93 | |_, data, _| data.lyrics.defer(()), 94 | |_, data, r| data.lyrics.update(((), r.1)), 95 | ) 96 | } 97 | -------------------------------------------------------------------------------- /psst-cli/src/main.rs: -------------------------------------------------------------------------------- 1 | use psst_core::{ 2 | audio::{ 3 | normalize::NormalizationLevel, 4 | output::{AudioOutput, AudioSink, DefaultAudioOutput}, 5 | }, 6 | cache::{Cache, CacheHandle}, 7 | cdn::{Cdn, CdnHandle}, 8 | connection::Credentials, 9 | error::Error, 10 | item_id::{ItemId, ItemIdType}, 11 | player::{item::PlaybackItem, PlaybackConfig, Player, PlayerCommand, PlayerEvent}, 12 | session::{SessionConfig, SessionService}, 13 | }; 14 | use std::{env, io, io::BufRead, path::PathBuf, thread}; 15 | 16 | fn main() { 17 | env_logger::init(); 18 | 19 | let args: Vec = env::args().collect(); 20 | let track_id = args 21 | .get(1) 22 | .expect("Expected in the first parameter"); 23 | let login_creds = Credentials::from_username_and_password( 24 | env::var("SPOTIFY_USERNAME").unwrap(), 25 | env::var("SPOTIFY_PASSWORD").unwrap(), 26 | ); 27 | let session = SessionService::with_config(SessionConfig { 28 | login_creds, 29 | proxy_url: None, 30 | }); 31 | 32 | start(track_id, session).unwrap(); 33 | } 34 | 35 | fn start(track_id: &str, session: SessionService) -> Result<(), Error> { 36 | let cdn = Cdn::new(session.clone(), None)?; 37 | let cache = Cache::new(PathBuf::from("cache"))?; 38 | let item_id = ItemId::from_base62(track_id, ItemIdType::Track).unwrap(); 39 | play_item( 40 | session, 41 | cdn, 42 | cache, 43 | PlaybackItem { 44 | item_id, 45 | norm_level: NormalizationLevel::Track, 46 | }, 47 | ) 48 | } 49 | 50 | fn play_item( 51 | session: SessionService, 52 | cdn: CdnHandle, 53 | cache: CacheHandle, 54 | item: PlaybackItem, 55 | ) -> Result<(), Error> { 56 | let output = DefaultAudioOutput::open()?; 57 | let config = PlaybackConfig::default(); 58 | 59 | let mut player = Player::new(session, cdn, cache, config, &output); 60 | 61 | let _ui_thread = thread::spawn({ 62 | let player_sender = player.sender(); 63 | 64 | player_sender 65 | .send(PlayerEvent::Command(PlayerCommand::LoadQueue { 66 | items: vec![item, item, item], 67 | position: 0, 68 | })) 69 | .unwrap(); 70 | 71 | move || { 72 | for line in io::stdin().lock().lines() { 73 | match line.as_ref().map(|s| s.as_str()) { 74 | Ok("p") => { 75 | player_sender 76 | .send(PlayerEvent::Command(PlayerCommand::Pause)) 77 | .unwrap(); 78 | } 79 | Ok("r") => { 80 | player_sender 81 | .send(PlayerEvent::Command(PlayerCommand::Resume)) 82 | .unwrap(); 83 | } 84 | Ok("s") => { 85 | player_sender 86 | .send(PlayerEvent::Command(PlayerCommand::Stop)) 87 | .unwrap(); 88 | } 89 | Ok("<") => { 90 | player_sender 91 | .send(PlayerEvent::Command(PlayerCommand::Previous)) 92 | .unwrap(); 93 | } 94 | Ok(">") => { 95 | player_sender 96 | .send(PlayerEvent::Command(PlayerCommand::Next)) 97 | .unwrap(); 98 | } 99 | _ => log::warn!("unknown command"), 100 | } 101 | } 102 | } 103 | }); 104 | 105 | for event in player.receiver() { 106 | player.handle(event); 107 | } 108 | output.sink().close(); 109 | 110 | Ok(()) 111 | } 112 | -------------------------------------------------------------------------------- /psst-gui/src/controller/on_command_async.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | sync::Arc, 3 | thread::{self, JoinHandle}, 4 | }; 5 | 6 | use druid::{ 7 | BoxConstraints, Data, Env, Event, EventCtx, LayoutCtx, LifeCycle, LifeCycleCtx, PaintCtx, 8 | Selector, SingleUse, Size, Target, UpdateCtx, Widget, WidgetPod, 9 | }; 10 | 11 | type AsyncCmdPre = Box; 12 | type AsyncCmdReq = Arc V + Sync + Send + 'static>; 13 | type AsyncCmdRes = Box; 14 | 15 | pub struct OnCommandAsync { 16 | child: WidgetPod, 17 | selector: Selector, 18 | preflight_fn: AsyncCmdPre, 19 | request_fn: AsyncCmdReq, 20 | response_fn: AsyncCmdRes, 21 | thread: Option>, 22 | } 23 | 24 | impl OnCommandAsync 25 | where 26 | W: Widget, 27 | { 28 | const RESPONSE: Selector> = Selector::new("on_cmd_async.response"); 29 | 30 | pub fn new( 31 | child: W, 32 | selector: Selector, 33 | preflight_fn: AsyncCmdPre, 34 | request_fn: AsyncCmdReq, 35 | response_fn: AsyncCmdRes, 36 | ) -> Self { 37 | Self { 38 | child: WidgetPod::new(child), 39 | selector, 40 | preflight_fn, 41 | request_fn, 42 | response_fn, 43 | thread: None, 44 | } 45 | } 46 | } 47 | 48 | impl Widget for OnCommandAsync 49 | where 50 | W: Widget, 51 | T: Data, 52 | U: Send + Clone + 'static, 53 | V: Send + 'static, 54 | { 55 | fn event(&mut self, ctx: &mut EventCtx, event: &Event, data: &mut T, env: &Env) { 56 | match event { 57 | Event::Command(cmd) if cmd.is(self.selector) => { 58 | let req = cmd.get_unchecked(self.selector); 59 | 60 | (self.preflight_fn)(ctx, data, req.to_owned()); 61 | 62 | let old_thread = self.thread.replace(thread::spawn({ 63 | let req_fn = self.request_fn.clone(); 64 | let req = req.to_owned(); 65 | let sink = ctx.get_external_handle(); 66 | let self_id = ctx.widget_id(); 67 | 68 | move || { 69 | let res = req_fn(req.clone()); 70 | sink.submit_command( 71 | Self::RESPONSE, 72 | SingleUse::new((req, res)), 73 | Target::Widget(self_id), 74 | ) 75 | .unwrap(); 76 | } 77 | })); 78 | if old_thread.is_some() { 79 | log::warn!("async action pending"); 80 | } 81 | } 82 | Event::Command(cmd) if cmd.is(Self::RESPONSE) => { 83 | let res = cmd.get_unchecked(Self::RESPONSE).take().unwrap(); 84 | (self.response_fn)(ctx, data, res); 85 | self.thread.take(); 86 | ctx.set_handled(); 87 | } 88 | _ => { 89 | self.child.event(ctx, event, data, env); 90 | } 91 | } 92 | } 93 | 94 | fn lifecycle(&mut self, ctx: &mut LifeCycleCtx, event: &LifeCycle, data: &T, env: &Env) { 95 | self.child.lifecycle(ctx, event, data, env); 96 | } 97 | 98 | fn update(&mut self, ctx: &mut UpdateCtx, _old_data: &T, data: &T, env: &Env) { 99 | self.child.update(ctx, data, env); 100 | } 101 | 102 | fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints, data: &T, env: &Env) -> Size { 103 | self.child.layout(ctx, bc, data, env) 104 | } 105 | 106 | fn paint(&mut self, ctx: &mut PaintCtx, data: &T, env: &Env) { 107 | self.child.paint(ctx, data, env); 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /psst-gui/src/widget/link.rs: -------------------------------------------------------------------------------- 1 | use druid::{widget::prelude::*, Color, Data, KeyOrValue, Point, RoundedRectRadii, WidgetPod}; 2 | 3 | use crate::ui::theme; 4 | 5 | pub struct Link { 6 | inner: WidgetPod>>, 7 | border_color: KeyOrValue, 8 | border_width: KeyOrValue, 9 | corner_radius: KeyOrValue, 10 | is_active: Option bool>>, 11 | } 12 | 13 | impl Link { 14 | pub fn new(inner: impl Widget + 'static) -> Self { 15 | Self { 16 | inner: WidgetPod::new(inner).boxed(), 17 | border_color: theme::LINK_HOT_COLOR.into(), 18 | border_width: 0.0.into(), 19 | corner_radius: RoundedRectRadii::from(0.0).into(), 20 | is_active: None, 21 | } 22 | } 23 | 24 | pub fn border( 25 | mut self, 26 | color: impl Into>, 27 | width: impl Into>, 28 | ) -> Self { 29 | self.border_color = color.into(); 30 | self.border_width = width.into(); 31 | self 32 | } 33 | 34 | pub fn rounded(mut self, radius: impl Into>) -> Self { 35 | self.corner_radius = radius.into(); 36 | self 37 | } 38 | 39 | pub fn circle(self) -> Self { 40 | self.rounded(RoundedRectRadii::from(f64::INFINITY)) 41 | } 42 | 43 | pub fn active(mut self, predicate: impl Fn(&T, &Env) -> bool + 'static) -> Self { 44 | self.is_active = Some(Box::new(predicate)); 45 | self 46 | } 47 | } 48 | 49 | impl Widget for Link { 50 | fn event(&mut self, ctx: &mut EventCtx, event: &Event, data: &mut T, env: &Env) { 51 | self.inner.event(ctx, event, data, env); 52 | } 53 | 54 | fn lifecycle(&mut self, ctx: &mut LifeCycleCtx, event: &LifeCycle, data: &T, env: &Env) { 55 | if let LifeCycle::HotChanged(_) = event { 56 | ctx.request_paint(); 57 | } 58 | self.inner.lifecycle(ctx, event, data, env) 59 | } 60 | 61 | fn update(&mut self, ctx: &mut UpdateCtx, _old_data: &T, data: &T, env: &Env) { 62 | self.inner.update(ctx, data, env); 63 | } 64 | 65 | fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints, data: &T, env: &Env) -> Size { 66 | let size = self.inner.layout(ctx, bc, data, env); 67 | self.inner.set_origin(ctx, Point::ORIGIN); 68 | size 69 | } 70 | 71 | fn paint(&mut self, ctx: &mut PaintCtx, data: &T, env: &Env) { 72 | let background = if ctx.is_hot() { 73 | env.get(theme::LINK_HOT_COLOR) 74 | } else { 75 | let is_active = self 76 | .is_active 77 | .as_ref() 78 | .is_some_and(|predicate| predicate(data, env)); 79 | if is_active { 80 | env.get(theme::LINK_ACTIVE_COLOR) 81 | } else { 82 | env.get(theme::LINK_COLD_COLOR) 83 | } 84 | }; 85 | let border_color = self.border_color.resolve(env); 86 | let border_width = self.border_width.resolve(env); 87 | let visible_background = background.as_rgba_u32() & 0x00000FF > 0; 88 | let visible_border = border_color.as_rgba_u32() & 0x000000FF > 0 && border_width > 0.0; 89 | if visible_background || visible_border { 90 | let corner_radius = self.corner_radius.resolve(env); 91 | let rounded_rect = ctx 92 | .size() 93 | .to_rect() 94 | .inset(-border_width / 2.0) 95 | .to_rounded_rect(corner_radius); 96 | if visible_border { 97 | ctx.stroke(rounded_rect, &border_color, border_width); 98 | } 99 | if visible_background { 100 | ctx.fill(rounded_rect, &background); 101 | } 102 | } 103 | self.inner.paint(ctx, data, env); 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /psst-gui/src/cmd.rs: -------------------------------------------------------------------------------- 1 | use crate::data::Track; 2 | use druid::{Selector, WidgetId}; 3 | use psst_core::{item_id::ItemId, player::item::PlaybackItem}; 4 | use std::sync::Arc; 5 | use std::time::Duration; 6 | 7 | use crate::{ 8 | data::{Nav, PlaybackPayload, QueueBehavior, QueueEntry}, 9 | ui::find::Find, 10 | }; 11 | 12 | // Widget IDs 13 | pub const WIDGET_SEARCH_INPUT: WidgetId = WidgetId::reserved(1); 14 | 15 | // Common 16 | pub const SHOW_MAIN: Selector = Selector::new("app.show-main"); 17 | pub const SHOW_ACCOUNT_SETUP: Selector = Selector::new("app.show-initial"); 18 | pub const CLOSE_ALL_WINDOWS: Selector = Selector::new("app.close-all-windows"); 19 | pub const QUIT_APP_WITH_SAVE: Selector = Selector::new("app.quit-with-save"); 20 | pub const SET_FOCUS: Selector = Selector::new("app.set-focus"); 21 | pub const COPY: Selector = Selector::new("app.copy-to-clipboard"); 22 | pub const GO_TO_URL: Selector = Selector::new("app.go-to-url"); 23 | 24 | // Find 25 | pub const TOGGLE_FINDER: Selector = Selector::new("app.show-finder"); 26 | pub const FIND_IN_PLAYLIST: Selector = Selector::new("find-in-playlist"); 27 | pub const FIND_IN_SAVED_TRACKS: Selector = Selector::new("find-in-saved-tracks"); 28 | 29 | // Session 30 | pub const SESSION_CONNECT: Selector = Selector::new("app.session-connect"); 31 | pub const LOG_OUT: Selector = Selector::new("app.log-out"); 32 | 33 | // Navigation 34 | pub const NAVIGATE: Selector