├── .editorconfig ├── .gitattributes ├── .github ├── ISSUE_TEMPLATE │ └── bug_report.md ├── dependabot.yml └── workflows │ ├── spot-development.yml │ ├── spot-quality.yml │ └── spot-snapshots.yml ├── .gitignore ├── .vscode └── tasks.json ├── ARTISTS ├── AUTHORS ├── Cargo.lock ├── Cargo.toml ├── LICENSE ├── README.md ├── TRANSLATORS ├── build-aux └── flatpak-cargo-generator.py ├── cargo-sources.json ├── data ├── appstream │ ├── 1.png │ ├── 2.png │ └── 3.png ├── dev.alextren.Spot.Source.svg ├── dev.alextren.Spot.appdata.xml ├── dev.alextren.Spot.desktop ├── dev.alextren.Spot.gschema.xml ├── hicolor │ ├── scalable │ │ └── apps │ │ │ └── dev.alextren.Spot.svg │ └── symbolic │ │ └── apps │ │ └── dev.alextren.Spot-symbolic.svg └── meson.build ├── dev.alextren.Spot.development.json ├── dev.alextren.Spot.snapshots.json ├── doc ├── .latexmkrc ├── Dockerfile ├── Makefile ├── doc.pdf ├── doc.tex └── enter.sh ├── meson.build ├── meson_options.txt ├── po ├── LINGUAS ├── POTFILES ├── ar.po ├── bg.po ├── bn.po ├── ca.po ├── cs.po ├── de.po ├── el.po ├── en.po ├── es.po ├── et.po ├── eu.po ├── fi.po ├── fr.po ├── ia.po ├── id.po ├── it.po ├── ja.po ├── meson.build ├── nb.po ├── nl.po ├── pl.po ├── poeditor.yml ├── pt-br.po ├── pt.po ├── ru.po ├── sl.po ├── spot.pot ├── tr.po └── uk.po ├── rustfmt.toml ├── src ├── api │ ├── api_models.rs │ ├── cache.rs │ ├── cached_client.rs │ ├── client.rs │ └── mod.rs ├── app.css ├── app │ ├── batch_loader.rs │ ├── components │ │ ├── album │ │ │ ├── album.blp │ │ │ ├── album.css │ │ │ ├── album.rs │ │ │ └── mod.rs │ │ ├── artist │ │ │ ├── artist.blp │ │ │ └── mod.rs │ │ ├── artist_details │ │ │ ├── artist_details.blp │ │ │ ├── artist_details.css │ │ │ ├── artist_details.rs │ │ │ ├── artist_details_model.rs │ │ │ └── mod.rs │ │ ├── details │ │ │ ├── album_header.blp │ │ │ ├── album_header.css │ │ │ ├── album_header.rs │ │ │ ├── details.blp │ │ │ ├── details.rs │ │ │ ├── details_model.rs │ │ │ ├── mod.rs │ │ │ ├── release_details.blp │ │ │ └── release_details.rs │ │ ├── device_selector │ │ │ ├── component.rs │ │ │ ├── device_selector.blp │ │ │ ├── mod.rs │ │ │ └── widget.rs │ │ ├── headerbar │ │ │ ├── component.rs │ │ │ ├── headerbar.blp │ │ │ ├── mod.rs │ │ │ └── widget.rs │ │ ├── labels.rs │ │ ├── library │ │ │ ├── library.blp │ │ │ ├── library.rs │ │ │ ├── library_model.rs │ │ │ └── mod.rs │ │ ├── login │ │ │ ├── login.blp │ │ │ ├── login.rs │ │ │ ├── login_model.rs │ │ │ └── mod.rs │ │ ├── mod.rs │ │ ├── navigation │ │ │ ├── factory.rs │ │ │ ├── home.rs │ │ │ ├── mod.rs │ │ │ ├── navigation.rs │ │ │ └── navigation_model.rs │ │ ├── notification │ │ │ └── mod.rs │ │ ├── now_playing │ │ │ ├── mod.rs │ │ │ ├── now_playing.blp │ │ │ ├── now_playing.rs │ │ │ └── now_playing_model.rs │ │ ├── playback │ │ │ ├── component.rs │ │ │ ├── mod.rs │ │ │ ├── playback.css │ │ │ ├── playback_controls.blp │ │ │ ├── playback_controls.rs │ │ │ ├── playback_info.blp │ │ │ ├── playback_info.rs │ │ │ ├── playback_widget.blp │ │ │ └── playback_widget.rs │ │ ├── player_notifier.rs │ │ ├── playlist │ │ │ ├── mod.rs │ │ │ ├── playback-indicator │ │ │ │ ├── playback-0-symbolic.svg │ │ │ │ ├── playback-1-symbolic.svg │ │ │ │ ├── playback-10-symbolic.svg │ │ │ │ ├── playback-11-symbolic.svg │ │ │ │ ├── playback-12-symbolic.svg │ │ │ │ ├── playback-13-symbolic.svg │ │ │ │ ├── playback-14-symbolic.svg │ │ │ │ ├── playback-15-symbolic.svg │ │ │ │ ├── playback-16-symbolic.svg │ │ │ │ ├── playback-2-symbolic.svg │ │ │ │ ├── playback-3-symbolic.svg │ │ │ │ ├── playback-4-symbolic.svg │ │ │ │ ├── playback-5-symbolic.svg │ │ │ │ ├── playback-6-symbolic.svg │ │ │ │ ├── playback-7-symbolic.svg │ │ │ │ ├── playback-8-symbolic.svg │ │ │ │ ├── playback-9-symbolic.svg │ │ │ │ └── playback-paused-symbolic.svg │ │ │ ├── playlist.rs │ │ │ ├── song.blp │ │ │ ├── song.css │ │ │ ├── song.rs │ │ │ └── song_actions.rs │ │ ├── playlist_details │ │ │ ├── mod.rs │ │ │ ├── playlist_details.blp │ │ │ ├── playlist_details.rs │ │ │ ├── playlist_details_model.rs │ │ │ ├── playlist_header.blp │ │ │ ├── playlist_header.css │ │ │ ├── playlist_header.rs │ │ │ ├── playlist_headerbar.blp │ │ │ └── playlist_headerbar.rs │ │ ├── saved_playlists │ │ │ ├── mod.rs │ │ │ ├── saved_playlists.blp │ │ │ ├── saved_playlists.rs │ │ │ └── saved_playlists_model.rs │ │ ├── saved_tracks │ │ │ ├── mod.rs │ │ │ ├── saved_tracks.blp │ │ │ ├── saved_tracks.rs │ │ │ └── saved_tracks_model.rs │ │ ├── scrolling_header │ │ │ ├── mod.rs │ │ │ ├── scrolling_header.blp │ │ │ └── scrolling_header_widget.rs │ │ ├── search │ │ │ ├── mod.rs │ │ │ ├── search.blp │ │ │ ├── search.rs │ │ │ ├── search_button.rs │ │ │ └── search_model.rs │ │ ├── selection │ │ │ ├── component.rs │ │ │ ├── icons │ │ │ │ ├── music-queue-symbolic.svg │ │ │ │ └── playlist2-symbolic.svg │ │ │ ├── mod.rs │ │ │ ├── selection_toolbar.blp │ │ │ ├── selection_toolbar.css │ │ │ └── widget.rs │ │ ├── settings │ │ │ ├── mod.rs │ │ │ ├── settings.blp │ │ │ ├── settings.rs │ │ │ └── settings_model.rs │ │ ├── sidebar │ │ │ ├── create_playlist.blp │ │ │ ├── create_playlist.rs │ │ │ ├── icons │ │ │ │ └── library-music-symbolic.svg │ │ │ ├── mod.rs │ │ │ ├── sidebar.rs │ │ │ ├── sidebar_item.rs │ │ │ ├── sidebar_row.blp │ │ │ └── sidebar_row.rs │ │ ├── user_details │ │ │ ├── mod.rs │ │ │ ├── user_details.blp │ │ │ ├── user_details.css │ │ │ ├── user_details.rs │ │ │ └── user_details_model.rs │ │ ├── user_menu │ │ │ ├── mod.rs │ │ │ ├── user_menu.rs │ │ │ └── user_menu_model.rs │ │ ├── utils.rs │ │ └── window │ │ │ └── mod.rs │ ├── credentials.rs │ ├── dispatch.rs │ ├── list_store.rs │ ├── loader.rs │ ├── mod.rs │ ├── models │ │ ├── album_model.rs │ │ ├── artist_model.rs │ │ ├── main.rs │ │ ├── mod.rs │ │ └── songs │ │ │ ├── mod.rs │ │ │ ├── song_list_model.rs │ │ │ ├── song_model.rs │ │ │ └── support.rs │ ├── rng.rs │ └── state │ │ ├── app_model.rs │ │ ├── app_state.rs │ │ ├── browser_state.rs │ │ ├── login_state.rs │ │ ├── mod.rs │ │ ├── pagination.rs │ │ ├── playback_state.rs │ │ ├── screen_states.rs │ │ ├── selection_state.rs │ │ └── settings_state.rs ├── config.rs.in ├── connect │ ├── mod.rs │ └── player.rs ├── dbus │ ├── listener.rs │ ├── mod.rs │ ├── mpris.rs │ └── types.rs ├── main.rs ├── meson.build ├── player │ ├── login.html │ ├── mod.rs │ ├── oauth2.rs │ ├── player.rs │ └── token_store.rs ├── settings.rs ├── spot.gresource.xml └── window.blp └── subprojects └── blueprint-compiler.wrap /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 4 6 | end_of_line = lf 7 | trim_trailing_whitespace = true 8 | insert_final_newline = true 9 | charset = utf-8 10 | 11 | [*.{build,yml,ui,yaml,css,blp}] 12 | indent_size = 2 13 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | *.po diff=po 2 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **General information:** 27 | - Distribution: 28 | - Installation method [e. g. built from source, installed from Flathub...]: 29 | - Version [e.g. 0.1.0]: 30 | - Device used [e. g. desktop, phone...]: 31 | 32 | **Stack trace:** 33 | If applicable, run the application from a terminal and paste relevant log output. 34 | ``` 35 | flatpak run --env=RUST_BACKTRACE=full dev.alextren.Spot 36 | ``` 37 | 38 | **Additional context** 39 | Add any other context about the problem here. 40 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://help.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "cargo" # See documentation for possible values 9 | directory: "/" # Location of package manifests 10 | schedule: 11 | interval: "weekly" 12 | -------------------------------------------------------------------------------- /.github/workflows/spot-development.yml: -------------------------------------------------------------------------------- 1 | name: spot-development 2 | 3 | on: 4 | pull_request: 5 | branches: [development] 6 | workflow_dispatch: 7 | 8 | jobs: 9 | flatpak-builder: 10 | name: "Flatpak Builder" 11 | runs-on: ubuntu-latest 12 | container: 13 | image: bilelmoussaoui/flatpak-github-actions:gnome-nightly 14 | options: --privileged 15 | steps: 16 | - uses: actions/checkout@v4 17 | - uses: bilelmoussaoui/flatpak-github-actions/flatpak-builder@v6.3 18 | with: 19 | bundle: "spot.flatpak" 20 | manifest-path: "dev.alextren.Spot.development.json" 21 | cache-key: flatpak-builder-${{ github.sha }} 22 | run-tests: true 23 | -------------------------------------------------------------------------------- /.github/workflows/spot-quality.yml: -------------------------------------------------------------------------------- 1 | name: spot-quality 2 | 3 | on: 4 | push: 5 | pull_request: 6 | 7 | jobs: 8 | ci-check: 9 | runs-on: ubuntu-20.04 10 | steps: 11 | - uses: actions/checkout@v2 12 | - name: Install Rust toolchain 13 | uses: actions-rs/toolchain@v1 14 | with: 15 | toolchain: stable 16 | profile: minimal 17 | default: true 18 | components: rustfmt 19 | 20 | - name: Add empty config.rs 21 | run: | 22 | echo >> src/config.rs 23 | 24 | - name: Format 25 | uses: actions-rs/cargo@v1 26 | with: 27 | command: fmt 28 | args: --all -- --check 29 | 30 | shellcheck: 31 | runs-on: ubuntu-20.04 32 | steps: 33 | - uses: actions/checkout@v2 34 | - run: | 35 | sudo apt-get -y update && sudo apt-get -y install shellcheck 36 | find $GITHUB_WORKSPACE -type f -and \( -name "*.sh" \) | xargs shellcheck 37 | -------------------------------------------------------------------------------- /.github/workflows/spot-snapshots.yml: -------------------------------------------------------------------------------- 1 | name: spot-snapshots 2 | 3 | on: 4 | push: 5 | branches: [master] 6 | pull_request: 7 | branches: [master] 8 | workflow_dispatch: 9 | 10 | jobs: 11 | flatpak-builder: 12 | name: "Flatpak Builder" 13 | runs-on: ubuntu-latest 14 | container: 15 | image: bilelmoussaoui/flatpak-github-actions:gnome-42 16 | options: --privileged 17 | steps: 18 | - uses: actions/checkout@v2 19 | - uses: bilelmoussaoui/flatpak-github-actions/flatpak-builder@v4 20 | with: 21 | bundle: "spot.flatpak" 22 | manifest-path: "dev.alextren.Spot.snapshots.json" 23 | cache-key: flatpak-builder-${{ github.sha }} 24 | run-tests: true 25 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /target/ 2 | /doc/target 3 | /cargo/ 4 | /build/ 5 | /.flatpak-builder/ 6 | /src/config.rs 7 | /subprojects/blueprint-compiler 8 | -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | // See https://go.microsoft.com/fwlink/?LinkId=733558 3 | // for the documentation about the tasks.json format 4 | "version": "2.0.0", 5 | "options": { 6 | "env": { 7 | "RUST_BACKTRACE": "1", 8 | "RUST_LOG": "spot=debug", 9 | "LANG": "C", 10 | //"https_proxy": "localhost:8080" 11 | } 12 | }, 13 | "tasks": [ 14 | { 15 | "label": "meson", 16 | "type": "shell", 17 | "command": "meson setup target -Dbuildtype=debug -Doffline=false --prefix=\"$HOME/.local\"" 18 | }, 19 | { 20 | "label": "build", 21 | "type": "shell", 22 | "command": "ninja install -C target", 23 | "problemMatcher": [], 24 | "group": { 25 | "kind": "build", 26 | "isDefault": true 27 | }, 28 | "presentation": { 29 | "clear": true 30 | } 31 | }, 32 | { 33 | "label": "run", 34 | "type": "shell", 35 | "command": "$HOME/.local/bin/spot", 36 | "presentation": { 37 | "reveal": "always", 38 | "clear": true 39 | } 40 | }, 41 | { 42 | "label": "test", 43 | "type": "shell", 44 | "command": "meson test -C target --verbose", 45 | "group": { 46 | "kind": "test", 47 | "isDefault": true 48 | } 49 | } 50 | ] 51 | } -------------------------------------------------------------------------------- /ARTISTS: -------------------------------------------------------------------------------- 1 | Tobias Bernard 2 | Noëlle 3 | -------------------------------------------------------------------------------- /AUTHORS: -------------------------------------------------------------------------------- 1 | Alexandre Trendel 2 | Noëlle 3 | Armin Begert 4 | Douile 5 | Diego Augusto 6 | TotalDarkness-NRF 7 | Seioo Inoue 8 | xRMG412 9 | realJavabot 10 | Gabriele Musco 11 | Alistair Francis 12 | Daniel Peukert 13 | Nils Tonnätt 14 | Niklas Sauter 15 | Nicolas Fella 16 | Fridolin Weisser 17 | Jan Przebor 18 | Warren Hu 19 | bbb651 20 | Julius Rüberg 21 | janbrummer 22 | Alisson Lauffer 23 | Riley Smith 24 | Steven Leadbeater -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "spot" 3 | version = "0.5.0" 4 | edition = "2018" 5 | license = "MIT" 6 | 7 | [dependencies.gtk] 8 | version = "^0.9" 9 | package = "gtk4" 10 | features = ["gnome_47", "blueprint"] 11 | 12 | [dependencies.libadwaita] 13 | version = "^0.7" 14 | features = ["v1_6"] 15 | 16 | [dependencies.gdk] 17 | version = "^0.9" 18 | package = "gdk4" 19 | 20 | [dependencies.gio] 21 | version = "^0.20" 22 | features = [] 23 | 24 | [dependencies.glib] 25 | version = "^0.20" 26 | features = [] 27 | 28 | [dependencies.librespot] 29 | version = "0.6.0" 30 | features = ["alsa-backend", "pulseaudio-backend", "gstreamer-backend"] 31 | 32 | [dependencies.tokio] 33 | version = "1" 34 | features = ["rt", "macros", "sync"] 35 | 36 | [dependencies.futures] 37 | package = "futures" 38 | version = "0.3.18" 39 | 40 | [dependencies.serde] 41 | version = "^1.0.136" 42 | features = ["derive"] 43 | 44 | [dependencies.serde_json] 45 | version = "^1.0.96" 46 | 47 | [dependencies.isahc] 48 | version = "^1.7.2" 49 | features = ["json"] 50 | 51 | [dependencies.rand] 52 | version = "^0.8.5" 53 | features = ["small_rng"] 54 | 55 | [dependencies.gettext-rs] 56 | version = "=0.7.0" 57 | features = ["gettext-system"] 58 | 59 | [dependencies.secret-service] 60 | version = "3.0.1" 61 | features = ["rt-async-io-crypto-rust"] 62 | 63 | [dependencies] 64 | gdk-pixbuf = "^0.20" 65 | ref_filter_map = "1.0.1" 66 | regex = "1.8.3" 67 | async-std = "1.12.0" 68 | form_urlencoded = "1.0.1" 69 | zbus = "3.13" 70 | zvariant = "3.14" 71 | thiserror = "1.0.40" 72 | lazy_static = "1.4.0" 73 | log = "0.4.17" 74 | env_logger = "0.10.0" 75 | percent-encoding = "2.2.0" 76 | oauth2 = "4.4" 77 | url = "2.4.1" 78 | open = "5.3.0" 79 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Alexandre Trendel 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 | -------------------------------------------------------------------------------- /TRANSLATORS: -------------------------------------------------------------------------------- 1 | Heimen Stoffels 2 | Philipp Kiemle 3 | kleinHeiti 4 | Ondřej Sluka 5 | Ícar N. S. 6 | Jonasz Potoniec 7 | José Miguel Sarasola 8 | Hugo Meireles Gonçalves 9 | Paul Bragin 10 | Yusuf Çınar Özmen 11 | Kukuh Syafaat 12 | Lucas Araujo 13 | Igor Dyatlov 14 | Jiri Grönroos 15 | Guilherme 16 | Lars Martinsen 17 | Sergio 18 | SoftInterlingua 19 | Tine Jozelj 20 | Ana Pika Šubic 21 | Amor Ali 22 | Seioo Inoue 23 | Dmitry 24 | Julius Rüberg 25 | Francesco Babetto 26 | Walther Smith 27 | Delyan Tomov -------------------------------------------------------------------------------- /data/appstream/1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xou816/spot/e018b170c7dce3703e6d33051039e767a9b91823/data/appstream/1.png -------------------------------------------------------------------------------- /data/appstream/2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xou816/spot/e018b170c7dce3703e6d33051039e767a9b91823/data/appstream/2.png -------------------------------------------------------------------------------- /data/appstream/3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xou816/spot/e018b170c7dce3703e6d33051039e767a9b91823/data/appstream/3.png -------------------------------------------------------------------------------- /data/dev.alextren.Spot.desktop: -------------------------------------------------------------------------------- 1 | [Desktop Entry] 2 | Version=1.5 3 | Name=Spot 4 | Exec=spot %u 5 | GenericName=Music Player 6 | Icon=dev.alextren.Spot 7 | Terminal=false 8 | Type=Application 9 | Categories=GTK;GNOME;Music;AudioVideo; 10 | MimeType=x-scheme-handler/spotify; 11 | StartupNotify=true 12 | X-Purism-FormFactor=Workstation;Mobile; 13 | SingleMainWindow=true 14 | -------------------------------------------------------------------------------- /data/dev.alextren.Spot.gschema.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 'system' 21 | The theme preference 22 | 23 | 24 | 1080 25 | The width of the window 26 | 27 | 28 | 720 29 | The height of the window 30 | 31 | 32 | false 33 | A flag to enable maximized mode 34 | 35 | 36 | '160' 37 | Songs bitrate (96, 160, 320kbps) 38 | 39 | 40 | 'pulseaudio' 41 | Audio backend 42 | 43 | 44 | true 45 | A flag to enable gap-less playback 46 | 47 | 48 | 'default' 49 | Alsa device (if audio backend is 'alsa') 50 | 51 | 52 | 0 53 | Port to communicate with Spotify's server (access point). Setting to 0 (default) allows Spot to use servers running on any port. 54 | 55 | 56 | -------------------------------------------------------------------------------- /data/hicolor/symbolic/apps/dev.alextren.Spot-symbolic.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /data/meson.build: -------------------------------------------------------------------------------- 1 | install_data('dev.alextren.Spot.desktop', 2 | install_dir: get_option('datadir') / 'applications' 3 | ) 4 | 5 | install_subdir('hicolor', 6 | install_dir: get_option('datadir') / 'icons' 7 | ) 8 | 9 | install_data('dev.alextren.Spot.appdata.xml', 10 | install_dir: get_option('datadir') / 'appdata' 11 | ) 12 | 13 | install_data('dev.alextren.Spot.gschema.xml', 14 | install_dir: get_option('datadir') / 'glib-2.0/schemas' 15 | ) 16 | 17 | compile_schemas = find_program('glib-compile-schemas', required: true) 18 | if compile_schemas.found() 19 | test('Validate schema file', compile_schemas, 20 | args: ['--strict', '--dry-run', meson.current_source_dir()] 21 | ) 22 | endif 23 | 24 | appstream_util = find_program('appstream-util', required: false) 25 | if appstream_util.found() 26 | test( 27 | 'Validate appstream appdata', 28 | appstream_util, 29 | args: ['validate-relax', meson.current_source_dir() / 'dev.alextren.Spot.appdata.xml'] 30 | ) 31 | endif 32 | -------------------------------------------------------------------------------- /dev.alextren.Spot.development.json: -------------------------------------------------------------------------------- 1 | { 2 | "app-id": "dev.alextren.Spot", 3 | "runtime": "org.gnome.Platform", 4 | "runtime-version": "master", 5 | "sdk": "org.gnome.Sdk", 6 | "sdk-extensions": [ 7 | "org.freedesktop.Sdk.Extension.rust-stable" 8 | ], 9 | "command": "spot", 10 | "finish-args": [ 11 | "--share=network", 12 | "--share=ipc", 13 | "--socket=fallback-x11", 14 | "--socket=wayland", 15 | "--socket=pulseaudio", 16 | "--device=dri", 17 | "--talk-name=org.freedesktop.secrets", 18 | "--own-name=org.mpris.MediaPlayer2.Spot" 19 | ], 20 | "separate-locales": false, 21 | "build-options": { 22 | "append-path": "/usr/lib/sdk/rust-stable/bin", 23 | "env": { 24 | "RUST_BACKTRACE": "full", 25 | "RUST_LOG": "spot=debug" 26 | } 27 | }, 28 | "cleanup": [ 29 | "/include", 30 | "/lib/pkgconfig", 31 | "/man", 32 | "/share/doc", 33 | "/share/gtk-doc", 34 | "/share/man", 35 | "/share/pkgconfig", 36 | "*.la", 37 | "*.a" 38 | ], 39 | "modules": [ 40 | { 41 | "name": "blueprint-compiler", 42 | "buildsystem": "meson", 43 | "sources": [ 44 | { 45 | "type": "archive", 46 | "url": "https://gitlab.gnome.org/jwestman/blueprint-compiler/-/archive/v0.8.1/blueprint-compiler-v0.8.1.tar.gz", 47 | "sha256": "9207697cfac6e87a3c0ccf463be1a95c3bd06aa017c966a7e352ad5bc486cf3c" 48 | } 49 | ] 50 | }, 51 | { 52 | "name": "spot", 53 | "builddir": true, 54 | "buildsystem": "meson", 55 | "config-opts": [ 56 | "-Doffline=true", 57 | "-Dbuildtype=debug" 58 | ], 59 | "sources": [ 60 | { 61 | "type": "dir", 62 | "path": "." 63 | }, 64 | "cargo-sources.json" 65 | ] 66 | } 67 | ] 68 | } 69 | -------------------------------------------------------------------------------- /dev.alextren.Spot.snapshots.json: -------------------------------------------------------------------------------- 1 | { 2 | "app-id": "dev.alextren.Spot", 3 | "runtime": "org.gnome.Platform", 4 | "runtime-version": "47", 5 | "sdk": "org.gnome.Sdk", 6 | "sdk-extensions": [ 7 | "org.freedesktop.Sdk.Extension.rust-stable" 8 | ], 9 | "command": "spot", 10 | "finish-args": [ 11 | "--share=network", 12 | "--share=ipc", 13 | "--socket=fallback-x11", 14 | "--socket=wayland", 15 | "--socket=pulseaudio", 16 | "--device=dri", 17 | "--talk-name=org.freedesktop.secrets", 18 | "--own-name=org.mpris.MediaPlayer2.Spot" 19 | ], 20 | "separate-locales": false, 21 | "build-options": { 22 | "append-path": "/usr/lib/sdk/rust-stable/bin", 23 | "env": { 24 | "CARGO_HOME": "/run/build/spot/cargo", 25 | "RUST_BACKTRACE": "full", 26 | "RUST_LOG": "spot=debug" 27 | }, 28 | "strip": false, 29 | "no-debuginfo": true 30 | }, 31 | "cleanup": [ 32 | "/include", 33 | "/lib/pkgconfig", 34 | "/man", 35 | "/share/doc", 36 | "/share/gtk-doc", 37 | "/share/man", 38 | "/share/pkgconfig", 39 | "*.la", 40 | "*.a" 41 | ], 42 | "modules": [ 43 | { 44 | "name": "blueprint-compiler", 45 | "buildsystem": "meson", 46 | "sources": [ 47 | { 48 | "type": "archive", 49 | "url": "https://gitlab.gnome.org/jwestman/blueprint-compiler/-/archive/v0.8.1/blueprint-compiler-v0.8.1.tar.gz", 50 | "sha256": "9207697cfac6e87a3c0ccf463be1a95c3bd06aa017c966a7e352ad5bc486cf3c" 51 | } 52 | ] 53 | }, 54 | { 55 | "name": "spot", 56 | "builddir": true, 57 | "buildsystem": "meson", 58 | "config-opts": [ 59 | "-Doffline=true", 60 | "-Dbuildtype=debug" 61 | ], 62 | "sources": [ 63 | { 64 | "type": "dir", 65 | "path": "." 66 | }, 67 | "cargo-sources.json" 68 | ] 69 | } 70 | ] 71 | } 72 | -------------------------------------------------------------------------------- /doc/.latexmkrc: -------------------------------------------------------------------------------- 1 | $pdf_mode = 1; 2 | $pdf_previewer = ''; 3 | $pdflatex = 'lualatex -interaction=nonstopmode -synctex=1 -shell-escape %O %S'; 4 | $out_dir = 'target'; 5 | -------------------------------------------------------------------------------- /doc/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM atrendel/doxerlive:15-basic 2 | RUN apk add gettext py3-pygments 3 | ADD Makefile /var/doxerlive/ 4 | RUN make install 5 | -------------------------------------------------------------------------------- /doc/Makefile: -------------------------------------------------------------------------------- 1 | all: install build 2 | 3 | build: doc.tex 4 | latexmk -c doc.tex 5 | 6 | watch: doc.tex 7 | latexmk -pvc doc.tex 8 | 9 | DEPENDENCIES = luatex $\ 10 | fontspec $\ 11 | lm $\ 12 | xcolor $\ 13 | xcolor-solarized $\ 14 | fontawesome $\ 15 | xifthen $\ 16 | ifmtarg $\ 17 | pgf $\ 18 | pgf-blur $\ 19 | ec $\ 20 | etoolbox $\ 21 | xkeyval $\ 22 | minted kvoptions fancyvrb fvextra upquote float ifplatform pdftexcmds xstring lineno framed catchfile 23 | 24 | install: 25 | tlmgr update --self 26 | tlmgr update texlive-scripts 27 | tlmgr install $(DEPENDENCIES) 28 | 29 | clean: 30 | latexmk -C 31 | -------------------------------------------------------------------------------- /doc/doc.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xou816/spot/e018b170c7dce3703e6d33051039e767a9b91823/doc/doc.pdf -------------------------------------------------------------------------------- /doc/enter.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | docker build --network=host -t spot-doc . 3 | docker run --rm -it -e THEUID="$(id -u "$USER")" -v "$PWD":/var/doxerlive spot-doc ash 4 | -------------------------------------------------------------------------------- /meson.build: -------------------------------------------------------------------------------- 1 | project( 2 | 'spot', 3 | version: '0.5.0', 4 | meson_version: '>= 0.59.0', 5 | default_options: ['warning_level=2', 'buildtype=release'], 6 | ) 7 | 8 | 9 | subdir('data') 10 | subdir('po') 11 | subdir('src') 12 | 13 | flatpak_cargo_generator = find_program(meson.project_source_root() / 'build-aux/flatpak-cargo-generator.py') 14 | 15 | cargo_sources = custom_target( 16 | 'cargo-update-sources', 17 | build_by_default: false, 18 | output: 'cargo-sources.json', 19 | input: meson.project_source_root() / 'Cargo.lock', 20 | command: [ 21 | flatpak_cargo_generator, 22 | '@INPUT@', 23 | '-o', '@SOURCE_ROOT@/cargo-sources.json' 24 | ] 25 | ) 26 | -------------------------------------------------------------------------------- /meson_options.txt: -------------------------------------------------------------------------------- 1 | option('offline', type: 'boolean', value: true) 2 | option('features', type: 'string', value: '') 3 | -------------------------------------------------------------------------------- /po/LINGUAS: -------------------------------------------------------------------------------- 1 | en ru fr nl de ca cs pl es pt tr id pt-br fi eu ia nb sl ja ar it uk bn bg et 2 | -------------------------------------------------------------------------------- /po/POTFILES: -------------------------------------------------------------------------------- 1 | # grep gettext src/**/*.rs | cut -d: -f1 | uniq 2 | src/app/batch_loader.rs 3 | src/app/components/device_selector/widget.rs 4 | src/app/components/labels.rs 5 | src/app/components/login/login_model.rs 6 | src/app/components/mod.rs 7 | src/app/components/navigation/factory.rs 8 | src/app/components/notification/mod.rs 9 | src/app/components/playback/playback_controls.rs 10 | src/app/components/playback/playback_info.rs 11 | src/app/components/selection/component.rs 12 | src/app/components/sidebar/sidebar_item.rs 13 | src/app/components/sidebar/sidebar.rs 14 | src/app/components/user_menu/user_menu.rs 15 | src/app/state/login_state.rs 16 | src/connect/player.rs 17 | src/main.rs 18 | 19 | # find src -name "*.blp" -print 20 | src/window.blp 21 | src/app/components/saved_playlists/saved_playlists.blp 22 | src/app/components/artist_details/artist_details.blp 23 | src/app/components/saved_tracks/saved_tracks.blp 24 | src/app/components/search/search.blp 25 | src/app/components/settings/settings.blp 26 | src/app/components/artist/artist.blp 27 | src/app/components/user_details/user_details.blp 28 | src/app/components/selection/selection_toolbar.blp 29 | src/app/components/scrolling_header/scrolling_header.blp 30 | src/app/components/details/album_header.blp 31 | src/app/components/details/release_details.blp 32 | src/app/components/details/details.blp 33 | src/app/components/now_playing/now_playing.blp 34 | src/app/components/login/login.blp 35 | src/app/components/playlist_details/playlist_details.blp 36 | src/app/components/playlist_details/playlist_header.blp 37 | src/app/components/playlist_details/playlist_headerbar.blp 38 | src/app/components/headerbar/headerbar.blp 39 | src/app/components/device_selector/device_selector.blp 40 | src/app/components/sidebar/create_playlist.blp 41 | src/app/components/sidebar/sidebar_row.blp 42 | src/app/components/album/album.blp 43 | src/app/components/playlist/song.blp 44 | src/app/components/playback/playback_widget.blp 45 | src/app/components/playback/playback_info.blp 46 | src/app/components/playback/playback_controls.blp 47 | src/app/components/library/library.blp 48 | -------------------------------------------------------------------------------- /po/meson.build: -------------------------------------------------------------------------------- 1 | i18n = import('i18n') 2 | i18n.gettext('spot', args: ['--from-code=UTF-8', '--add-comments'], preset: 'glib') 3 | -------------------------------------------------------------------------------- /po/poeditor.yml: -------------------------------------------------------------------------------- 1 | api_token: 7ac85acabcb7842c23f5d4060485f25e # read only token 2 | projects: 3 | - format: po 4 | id: 469205 5 | terms_path: '{language_code}.po' 6 | -------------------------------------------------------------------------------- /rustfmt.toml: -------------------------------------------------------------------------------- 1 | #use_small_heuristics = "Max" 2 | newline_style = "Unix" 3 | use_field_init_shorthand = true 4 | -------------------------------------------------------------------------------- /src/api/mod.rs: -------------------------------------------------------------------------------- 1 | mod api_models; 2 | mod cached_client; 3 | mod client; 4 | 5 | pub mod cache; 6 | 7 | pub use cached_client::{CachedSpotifyClient, SpotifyApiClient, SpotifyResult}; 8 | pub use client::SpotifyApiError; 9 | 10 | pub async fn clear_user_cache() -> Option<()> { 11 | cache::CacheManager::for_dir("spot/net")? 12 | .clear_cache_pattern(&cached_client::USER_CACHE) 13 | .await 14 | .ok() 15 | } 16 | -------------------------------------------------------------------------------- /src/app.css: -------------------------------------------------------------------------------- 1 | @define-color accent_bg_color @green_5; 2 | @define-color accent_color @green_3; 3 | 4 | .container { 5 | transition: opacity 0.3s ease; 6 | opacity: 0; 7 | } 8 | 9 | .container--loaded { 10 | opacity: 1; 11 | } 12 | 13 | .playlist__title-entry--ro { 14 | background: none; 15 | } 16 | -------------------------------------------------------------------------------- /src/app/batch_loader.rs: -------------------------------------------------------------------------------- 1 | use gettextrs::gettext; 2 | use std::sync::Arc; 3 | 4 | use crate::api::{SpotifyApiClient, SpotifyApiError}; 5 | use crate::app::models::*; 6 | use crate::app::AppAction; 7 | 8 | // A wrapper around the Spotify API to load batches of songs from various sources (see below) 9 | #[derive(Clone)] 10 | pub struct BatchLoader { 11 | api: Arc, 12 | } 13 | 14 | // The sources mentionned above 15 | #[derive(Clone, Debug)] 16 | pub enum SongsSource { 17 | Playlist(String), 18 | Album(String), 19 | SavedTracks, 20 | } 21 | 22 | impl PartialEq for SongsSource { 23 | fn eq(&self, other: &Self) -> bool { 24 | match (self, other) { 25 | (Self::Playlist(l), Self::Playlist(r)) => l == r, 26 | (Self::Album(l), Self::Album(r)) => l == r, 27 | (Self::SavedTracks, Self::SavedTracks) => true, 28 | _ => false, 29 | } 30 | } 31 | } 32 | 33 | impl Eq for SongsSource {} 34 | 35 | impl SongsSource { 36 | pub fn has_spotify_uri(&self) -> bool { 37 | matches!(self, Self::Playlist(_) | Self::Album(_)) 38 | } 39 | 40 | pub fn spotify_uri(&self) -> Option { 41 | match self { 42 | Self::Playlist(id) => Some(format!("spotify:playlist:{}", id)), 43 | Self::Album(id) => Some(format!("spotify:album:{}", id)), 44 | _ => None, 45 | } 46 | } 47 | } 48 | 49 | // How to query for a batch: specify a source, and a batch to get (offset + number of elements to get) 50 | #[derive(Debug)] 51 | pub struct BatchQuery { 52 | pub source: SongsSource, 53 | pub batch: Batch, 54 | } 55 | 56 | impl BatchQuery { 57 | // Given a query, compute the next batch to get (if any) 58 | pub fn next(&self) -> Option { 59 | let Self { source, batch } = self; 60 | Some(Self { 61 | source: source.clone(), 62 | batch: batch.next()?, 63 | }) 64 | } 65 | } 66 | 67 | impl BatchLoader { 68 | pub fn new(api: Arc) -> Self { 69 | Self { api } 70 | } 71 | 72 | // Query a batch and create an action when it's been retrieved succesfully 73 | pub async fn query( 74 | &self, 75 | query: BatchQuery, 76 | create_action: ActionCreator, 77 | ) -> Option 78 | where 79 | ActionCreator: FnOnce(SongsSource, SongBatch) -> AppAction, 80 | { 81 | let api = Arc::clone(&self.api); 82 | 83 | let Batch { 84 | offset, batch_size, .. 85 | } = query.batch; 86 | let result = match &query.source { 87 | SongsSource::Playlist(id) => api.get_playlist_tracks(id, offset, batch_size).await, 88 | SongsSource::SavedTracks => api.get_saved_tracks(offset, batch_size).await, 89 | SongsSource::Album(id) => api.get_album_tracks(id, offset, batch_size).await, 90 | }; 91 | 92 | match result { 93 | Ok(batch) => Some(create_action(query.source, batch)), 94 | // No token? Why was the batch loader called? Ah, whatever 95 | Err(SpotifyApiError::NoToken) => None, 96 | Err(err) => { 97 | error!("Spotify API error: {}", err); 98 | Some(AppAction::ShowNotification(gettext( 99 | // translators: This notification is the default message for unhandled errors. Logs refer to console output. 100 | "An error occured. Check logs for details!", 101 | ))) 102 | } 103 | } 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /src/app/components/album/album.blp: -------------------------------------------------------------------------------- 1 | using Gtk 4.0; 2 | using Adw 1; 3 | 4 | template $AlbumWidget : Adw.Bin { 5 | Button cover_btn { 6 | hexpand: false; 7 | halign: center; 8 | valign: start; 9 | 10 | Box { 11 | halign: center; 12 | valign: start; 13 | margin-top: 6; 14 | margin-bottom: 6; 15 | orientation: vertical; 16 | spacing: 6; 17 | 18 | Image cover_image { 19 | icon-name: "media-playback-start-symbolic"; 20 | 21 | styles [ 22 | "card", 23 | ] 24 | } 25 | 26 | Label album_label { 27 | label: "Album"; 28 | justify: center; 29 | wrap: true; 30 | wrap-mode: word; 31 | ellipsize: end; 32 | max-width-chars: 1; 33 | margin-top: 6; 34 | 35 | styles [ 36 | "title-4", 37 | ] 38 | } 39 | 40 | Label artist_label { 41 | label: "Artist"; 42 | justify: center; 43 | wrap: true; 44 | wrap-mode: word; 45 | ellipsize: end; 46 | max-width-chars: 1; 47 | 48 | styles [ 49 | "body", 50 | ] 51 | } 52 | 53 | Label year_label { 54 | label: "Year"; 55 | justify: center; 56 | wrap: true; 57 | wrap-mode: word_char; 58 | max-width-chars: 1; 59 | sensitive: false; 60 | 61 | styles [ 62 | "body", 63 | ] 64 | } 65 | } 66 | 67 | styles [ 68 | "flat", 69 | ] 70 | } 71 | 72 | styles [ 73 | "container", 74 | "album", 75 | ] 76 | } 77 | -------------------------------------------------------------------------------- /src/app/components/album/album.css: -------------------------------------------------------------------------------- 1 | /* large style */ 2 | 3 | navigation-split-view .album .card { 4 | min-width: 200px; 5 | min-height: 200px; 6 | border-radius: 6px; 7 | } 8 | 9 | navigation-split-view .album { 10 | margin-top: 6px; 11 | margin-bottom: 6px; 12 | } 13 | 14 | navigation-split-view .album button { 15 | border-radius: 12px; 16 | } 17 | /* small style */ 18 | 19 | navigation-split-view.collapsed .album .card { 20 | min-width: 100px; 21 | min-height: 100px; 22 | border-radius: 6px; 23 | margin-top: 0px; 24 | margin-bottom: 0px; 25 | } 26 | 27 | navigation-split-view .album { 28 | margin-top: 0px; 29 | margin-bottom: 0px; 30 | } 31 | 32 | navigation-split-view.collapsed .album button { 33 | border-radius: 6px; 34 | } 35 | -------------------------------------------------------------------------------- /src/app/components/album/album.rs: -------------------------------------------------------------------------------- 1 | use crate::app::components::display_add_css_provider; 2 | use crate::app::dispatch::Worker; 3 | use crate::app::loader::ImageLoader; 4 | use crate::app::models::AlbumModel; 5 | 6 | use gtk::prelude::*; 7 | use gtk::subclass::prelude::*; 8 | use gtk::CompositeTemplate; 9 | use libadwaita::subclass::prelude::BinImpl; 10 | 11 | mod imp { 12 | 13 | use super::*; 14 | 15 | #[derive(Debug, Default, CompositeTemplate)] 16 | #[template(file = "src/app/components/album/album.blp")] 17 | pub struct AlbumWidget { 18 | #[template_child] 19 | pub album_label: TemplateChild, 20 | 21 | #[template_child] 22 | pub artist_label: TemplateChild, 23 | 24 | #[template_child] 25 | pub year_label: TemplateChild, 26 | 27 | #[template_child] 28 | pub cover_btn: TemplateChild, 29 | 30 | #[template_child] 31 | pub cover_image: TemplateChild, 32 | } 33 | 34 | #[glib::object_subclass] 35 | impl ObjectSubclass for AlbumWidget { 36 | const NAME: &'static str = "AlbumWidget"; 37 | type Type = super::AlbumWidget; 38 | type ParentType = libadwaita::Bin; 39 | 40 | fn class_init(klass: &mut Self::Class) { 41 | klass.bind_template(); 42 | } 43 | 44 | fn instance_init(obj: &glib::subclass::InitializingObject) { 45 | obj.init_template(); 46 | } 47 | } 48 | 49 | impl ObjectImpl for AlbumWidget {} 50 | impl WidgetImpl for AlbumWidget {} 51 | impl BinImpl for AlbumWidget {} 52 | } 53 | 54 | glib::wrapper! { 55 | pub struct AlbumWidget(ObjectSubclass) @extends gtk::Widget, libadwaita::Bin; 56 | } 57 | 58 | impl Default for AlbumWidget { 59 | fn default() -> Self { 60 | Self::new() 61 | } 62 | } 63 | 64 | impl AlbumWidget { 65 | pub fn new() -> Self { 66 | display_add_css_provider(resource!("/components/album.css")); 67 | glib::Object::new() 68 | } 69 | 70 | pub fn for_model(album_model: &AlbumModel, worker: Worker) -> Self { 71 | let _self = Self::new(); 72 | _self.bind(album_model, worker); 73 | _self 74 | } 75 | 76 | fn set_loaded(&self) { 77 | self.add_css_class("container--loaded"); 78 | } 79 | 80 | fn set_image(&self, pixbuf: &gdk_pixbuf::Pixbuf) { 81 | let texture = gdk::Texture::for_pixbuf(pixbuf); 82 | self.imp().cover_image.set_paintable(Some(&texture)); 83 | } 84 | 85 | fn bind(&self, album_model: &AlbumModel, worker: Worker) { 86 | let widget = self.imp(); 87 | widget.cover_image.set_overflow(gtk::Overflow::Hidden); 88 | 89 | if let Some(cover_art) = album_model.cover() { 90 | let _self = self.downgrade(); 91 | worker.send_local_task(async move { 92 | if let Some(_self) = _self.upgrade() { 93 | let loader = ImageLoader::new(); 94 | let result = loader.load_remote(&cover_art, "jpg", 200, 200).await; 95 | if let Some(image) = result.as_ref() { 96 | _self.set_image(image); 97 | _self.set_loaded(); 98 | } 99 | } 100 | }); 101 | } else { 102 | self.set_loaded(); 103 | } 104 | 105 | album_model 106 | .bind_property("album", &*widget.album_label, "label") 107 | .flags(glib::BindingFlags::DEFAULT | glib::BindingFlags::SYNC_CREATE) 108 | .build(); 109 | 110 | album_model 111 | .bind_property("artist", &*widget.artist_label, "label") 112 | .flags(glib::BindingFlags::DEFAULT | glib::BindingFlags::SYNC_CREATE) 113 | .build(); 114 | 115 | if album_model.year() > 0 { 116 | album_model 117 | .bind_property("year", &*widget.year_label, "label") 118 | .flags(glib::BindingFlags::DEFAULT | glib::BindingFlags::SYNC_CREATE) 119 | .build(); 120 | } else { 121 | widget.year_label.set_visible(false); 122 | } 123 | } 124 | 125 | pub fn connect_album_pressed(&self, f: F) { 126 | self.imp().cover_btn.connect_clicked(move |_| { 127 | f(); 128 | }); 129 | } 130 | } 131 | -------------------------------------------------------------------------------- /src/app/components/album/mod.rs: -------------------------------------------------------------------------------- 1 | #[allow(clippy::module_inception)] 2 | mod album; 3 | pub use album::AlbumWidget; 4 | -------------------------------------------------------------------------------- /src/app/components/artist/artist.blp: -------------------------------------------------------------------------------- 1 | using Gtk 4.0; 2 | using Adw 1; 3 | 4 | template $ArtistWidget : Box { 5 | orientation: vertical; 6 | 7 | Button avatar_btn { 8 | vexpand: true; 9 | width-request: 150; 10 | height-request: 150; 11 | receives-default: true; 12 | halign: center; 13 | valign: center; 14 | has-frame: false; 15 | 16 | Adw.Avatar avatar { 17 | halign: center; 18 | valign: center; 19 | show-initials: true; 20 | size: 150; 21 | } 22 | 23 | styles [ 24 | "circular", 25 | ] 26 | } 27 | 28 | Label artist { 29 | label: "Artist Name"; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/app/components/artist/mod.rs: -------------------------------------------------------------------------------- 1 | use gtk::prelude::*; 2 | use gtk::subclass::prelude::*; 3 | use gtk::CompositeTemplate; 4 | 5 | use crate::app::loader::ImageLoader; 6 | use crate::app::models::ArtistModel; 7 | use crate::app::Worker; 8 | 9 | mod imp { 10 | 11 | use super::*; 12 | 13 | #[derive(Debug, Default, CompositeTemplate)] 14 | #[template(resource = "/dev/alextren/Spot/components/artist.ui")] 15 | pub struct ArtistWidget { 16 | #[template_child] 17 | pub artist: TemplateChild, 18 | 19 | #[template_child] 20 | pub avatar_btn: TemplateChild, 21 | 22 | #[template_child] 23 | pub avatar: TemplateChild, 24 | } 25 | 26 | #[glib::object_subclass] 27 | impl ObjectSubclass for ArtistWidget { 28 | const NAME: &'static str = "ArtistWidget"; 29 | type Type = super::ArtistWidget; 30 | type ParentType = gtk::Box; 31 | 32 | fn class_init(klass: &mut Self::Class) { 33 | klass.bind_template(); 34 | } 35 | 36 | fn instance_init(obj: &glib::subclass::InitializingObject) { 37 | obj.init_template(); 38 | } 39 | } 40 | 41 | impl ObjectImpl for ArtistWidget {} 42 | impl WidgetImpl for ArtistWidget {} 43 | impl BoxImpl for ArtistWidget {} 44 | } 45 | 46 | glib::wrapper! { 47 | pub struct ArtistWidget(ObjectSubclass) @extends gtk::Widget, gtk::Box; 48 | } 49 | 50 | impl Default for ArtistWidget { 51 | fn default() -> Self { 52 | Self::new() 53 | } 54 | } 55 | 56 | impl ArtistWidget { 57 | pub fn new() -> Self { 58 | glib::Object::new() 59 | } 60 | 61 | pub fn for_model(model: &ArtistModel, worker: Worker) -> Self { 62 | let _self = Self::new(); 63 | _self.bind(model, worker); 64 | _self 65 | } 66 | 67 | pub fn connect_artist_pressed(&self, f: F) { 68 | self.imp().avatar_btn.connect_clicked(move |_| { 69 | f(); 70 | }); 71 | } 72 | 73 | fn bind(&self, model: &ArtistModel, worker: Worker) { 74 | let widget = self.imp(); 75 | 76 | if let Some(url) = model.image() { 77 | let avatar = widget.avatar.downgrade(); 78 | worker.send_local_task(async move { 79 | if let Some(avatar) = avatar.upgrade() { 80 | let loader = ImageLoader::new(); 81 | let pixbuf = loader.load_remote(&url, "jpg", 200, 200).await; 82 | let texture = pixbuf.as_ref().map(gdk::Texture::for_pixbuf); 83 | avatar.set_custom_image(texture.as_ref()); 84 | } 85 | }); 86 | } 87 | 88 | model 89 | .bind_property("artist", &*widget.artist, "label") 90 | .flags(glib::BindingFlags::DEFAULT | glib::BindingFlags::SYNC_CREATE) 91 | .build(); 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /src/app/components/artist_details/artist_details.blp: -------------------------------------------------------------------------------- 1 | using Gtk 4.0; 2 | 3 | template $ArtistDetailsWidget : Box { 4 | ScrolledWindow scrolled_window { 5 | hscrollbar-policy: never; 6 | hexpand: true; 7 | vexpand: true; 8 | Box { 9 | margin-start: 8; 10 | margin-end: 8; 11 | margin-top: 8; 12 | margin-bottom: 8; 13 | orientation: vertical; 14 | spacing: 16; 15 | 16 | Box { 17 | orientation: vertical; 18 | 19 | Label { 20 | halign: start; 21 | margin-start: 8; 22 | margin-end: 8; 23 | 24 | /* Translators: Title of the section that shows 5 of the top tracks for an artist, as defined by Spotify. */ 25 | 26 | label: _("Top tracks"); 27 | 28 | styles [ 29 | "title-4", 30 | ] 31 | } 32 | 33 | ListView top_tracks { 34 | } 35 | } 36 | 37 | Expander { 38 | margin-top: 8; 39 | margin-bottom: 8; 40 | expanded: true; 41 | 42 | FlowBox artist_releases { 43 | height-request: 100; 44 | hexpand: true; 45 | min-children-per-line: 1; 46 | selection-mode: none; 47 | activate-on-single-click: false; 48 | } 49 | 50 | [label] 51 | Label { 52 | /* Translators: Title of the sections that contains all releases from an artist (both singles and albums). */ 53 | 54 | label: _("Releases"); 55 | } 56 | } 57 | } 58 | } 59 | 60 | styles [ 61 | "artist", 62 | ] 63 | } 64 | -------------------------------------------------------------------------------- /src/app/components/artist_details/artist_details.css: -------------------------------------------------------------------------------- 1 | listview.artist__top-tracks { 2 | padding: 8px; 3 | border-radius: 8px; 4 | margin: 8px; 5 | } 6 | 7 | listview.artist__top-tracks row { 8 | border-radius: 4px; 9 | } 10 | 11 | .artist { 12 | transition: opacity .3s ease; 13 | opacity: 0; 14 | } 15 | 16 | .artist__loaded { 17 | opacity: 1; 18 | } 19 | -------------------------------------------------------------------------------- /src/app/components/artist_details/mod.rs: -------------------------------------------------------------------------------- 1 | #[allow(clippy::module_inception)] 2 | mod artist_details; 3 | pub use artist_details::*; 4 | 5 | mod artist_details_model; 6 | pub use artist_details_model::*; 7 | -------------------------------------------------------------------------------- /src/app/components/details/album_header.blp: -------------------------------------------------------------------------------- 1 | using Gtk 4.0; 2 | 3 | template $AlbumHeaderWidget : Box { 4 | valign: start; 5 | vexpand: false; 6 | margin-start: 6; 7 | margin-end: 6; 8 | margin-bottom: 6; 9 | 10 | Overlay album_overlay { 11 | overflow: hidden; 12 | halign: center; 13 | margin-top: 18; 14 | margin-bottom: 6; 15 | margin-start: 6; 16 | 17 | Image album_art { 18 | width-request: 160; 19 | height-request: 160; 20 | icon-name: "emblem-music-symbolic"; 21 | } 22 | 23 | [overlay] 24 | Button info_button { 25 | icon-name: "preferences-system-details-symbolic"; 26 | halign: end; 27 | valign: end; 28 | margin-start: 6; 29 | margin-end: 6; 30 | margin-top: 6; 31 | margin-bottom: 6; 32 | tooltip-text: "Album Info"; 33 | 34 | styles [ 35 | "circular", 36 | "osd", 37 | ] 38 | } 39 | 40 | styles [ 41 | "card", 42 | ] 43 | } 44 | 45 | Box album_info { 46 | hexpand: true; 47 | valign: center; 48 | orientation: vertical; 49 | spacing: 6; 50 | margin-start: 18; 51 | 52 | Label album_label { 53 | xalign: 0; 54 | halign: start; 55 | label: "Album"; 56 | wrap: true; 57 | ellipsize: end; 58 | max-width-chars: 50; 59 | lines: 4; 60 | 61 | styles [ 62 | "title-1", 63 | ] 64 | } 65 | 66 | LinkButton artist_button { 67 | receives-default: true; 68 | halign: start; 69 | valign: center; 70 | has-frame: false; 71 | 72 | Label artist_button_label { 73 | hexpand: true; 74 | vexpand: true; 75 | label: "Artist"; 76 | ellipsize: middle; 77 | } 78 | 79 | styles [ 80 | "title-4", 81 | ] 82 | } 83 | 84 | Label year_label { 85 | xalign: 0; 86 | halign: start; 87 | label: "Year"; 88 | ellipsize: end; 89 | max-width-chars: 50; 90 | lines: 1; 91 | sensitive: false; 92 | 93 | styles [ 94 | "body", 95 | ] 96 | } 97 | } 98 | 99 | Box button_box { 100 | orientation: horizontal; 101 | valign: center; 102 | 103 | margin-end: 6; 104 | spacing: 8; 105 | 106 | Button play_button { 107 | receives-default: true; 108 | halign: center; 109 | valign: center; 110 | tooltip-text: "Play"; 111 | icon-name: "media-playback-start-symbolic"; 112 | 113 | styles [ 114 | "circular", 115 | "play__button", 116 | ] 117 | } 118 | 119 | Button like_button { 120 | receives-default: true; 121 | halign: center; 122 | valign: center; 123 | tooltip-text: "Add to Library"; 124 | 125 | styles [ 126 | "circular", 127 | "like__button", 128 | ] 129 | } 130 | } 131 | 132 | 133 | styles [ 134 | "album__header", 135 | ] 136 | } 137 | -------------------------------------------------------------------------------- /src/app/components/details/album_header.css: -------------------------------------------------------------------------------- 1 | .album__header .title-4 label { 2 | color: @window_fg_color; 3 | font-weight: bold; 4 | text-decoration: none; 5 | } 6 | 7 | 8 | .album__header .title-4:hover { 9 | border-radius: 6px; 10 | background-image: image(alpha(currentColor, 0.08)); 11 | } 12 | 13 | clamp.details__clamp { 14 | background-color: @view_bg_color; 15 | box-shadow: inset 0px -1px 0px @borders; 16 | } 17 | 18 | headerbar.details__headerbar { 19 | transition: background-color .3s ease; 20 | } 21 | 22 | headerbar.flat.details__headerbar windowtitle { 23 | opacity: 0; 24 | } 25 | 26 | headerbar.details__headerbar windowtitle { 27 | transition: opacity .3s ease; 28 | opacity: 1; 29 | } 30 | 31 | .details__headerbar.flat { 32 | background-color: @view_bg_color; 33 | } 34 | -------------------------------------------------------------------------------- /src/app/components/details/details.blp: -------------------------------------------------------------------------------- 1 | using Gtk 4.0; 2 | using Adw 1; 3 | 4 | template $AlbumDetailsWidget : Adw.Bin { 5 | Box { 6 | orientation: vertical; 7 | vexpand: true; 8 | hexpand: true; 9 | 10 | $HeaderBarWidget headerbar {} 11 | 12 | $ScrollingHeaderWidget scrolling_header { 13 | [header] 14 | WindowHandle { 15 | Adw.Clamp { 16 | maximum-size: 900; 17 | styles [ 18 | "details__clamp", 19 | ] 20 | 21 | Adw.BreakpointBin { 22 | width-request: 1; 23 | height-request: 1; 24 | 25 | Adw.Breakpoint { 26 | condition("max-width:500sp") 27 | 28 | setters { 29 | header_widget.vertical-layout: true; 30 | } 31 | } 32 | 33 | $AlbumHeaderWidget header_widget {} 34 | } 35 | } 36 | } 37 | 38 | Adw.ClampScrollable { 39 | maximum-size: 900; 40 | 41 | ListView album_tracks { 42 | styles [ 43 | "album__tracks", 44 | ] 45 | } 46 | } 47 | 48 | styles [ 49 | "container", 50 | ] 51 | } 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/app/components/details/mod.rs: -------------------------------------------------------------------------------- 1 | mod album_header; 2 | #[allow(clippy::module_inception)] 3 | mod details; 4 | mod details_model; 5 | mod release_details; 6 | 7 | pub use details::Details; 8 | pub use details_model::DetailsModel; 9 | -------------------------------------------------------------------------------- /src/app/components/details/release_details.blp: -------------------------------------------------------------------------------- 1 | using Gtk 4.0; 2 | using Adw 1; 3 | 4 | template $ReleaseDetailsDialog : Adw.Dialog { 5 | 6 | Box { 7 | orientation: vertical; 8 | 9 | Adw.HeaderBar { 10 | show-end-title-buttons: true; 11 | 12 | [title] 13 | Adw.WindowTitle album_artist { 14 | } 15 | 16 | styles [ 17 | "flat", 18 | ] 19 | } 20 | 21 | ListBox { 22 | margin-start: 6; 23 | margin-end: 6; 24 | margin-top: 6; 25 | margin-bottom: 6; 26 | valign: start; 27 | selection-mode: none; 28 | show-separators: true; 29 | overflow: hidden; 30 | 31 | styles [ 32 | "card", 33 | ] 34 | 35 | Adw.ActionRow { 36 | /* Translators: This refers to a music label */ 37 | 38 | title: _("Label"); 39 | 40 | [suffix] 41 | Label label { 42 | label: "Label"; 43 | } 44 | } 45 | 46 | Adw.ActionRow { 47 | /* Translators: This refers to a release date */ 48 | 49 | title: _("Released"); 50 | 51 | [suffix] 52 | Label release { 53 | label: "Released"; 54 | } 55 | } 56 | 57 | Adw.ActionRow { 58 | /* Translators: This refers to a number of tracks */ 59 | 60 | title: _("Tracks"); 61 | 62 | [suffix] 63 | Label tracks { 64 | label: "Tracks"; 65 | } 66 | } 67 | 68 | Adw.ActionRow { 69 | title: _("Copyright"); 70 | 71 | [suffix] 72 | Label copyright { 73 | label: "Copyright"; 74 | ellipsize: middle; 75 | } 76 | } 77 | } 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /src/app/components/details/release_details.rs: -------------------------------------------------------------------------------- 1 | use gtk::subclass::prelude::*; 2 | use gtk::CompositeTemplate; 3 | use libadwaita::subclass::prelude::*; 4 | 5 | use crate::app::components::labels; 6 | 7 | mod imp { 8 | 9 | use super::*; 10 | 11 | #[derive(Debug, Default, CompositeTemplate)] 12 | #[template(resource = "/dev/alextren/Spot/components/release_details.ui")] 13 | pub struct ReleaseDetailsDialog { 14 | #[template_child] 15 | pub album_artist: TemplateChild, 16 | 17 | #[template_child] 18 | pub label: TemplateChild, 19 | 20 | #[template_child] 21 | pub release: TemplateChild, 22 | 23 | #[template_child] 24 | pub tracks: TemplateChild, 25 | 26 | #[template_child] 27 | pub copyright: TemplateChild, 28 | } 29 | 30 | #[glib::object_subclass] 31 | impl ObjectSubclass for ReleaseDetailsDialog { 32 | const NAME: &'static str = "ReleaseDetailsDialog"; 33 | type Type = super::ReleaseDetailsDialog; 34 | type ParentType = libadwaita::Dialog; 35 | 36 | fn class_init(klass: &mut Self::Class) { 37 | klass.bind_template(); 38 | } 39 | 40 | fn instance_init(obj: &glib::subclass::InitializingObject) { 41 | obj.init_template(); 42 | } 43 | } 44 | 45 | impl ObjectImpl for ReleaseDetailsDialog {} 46 | impl WidgetImpl for ReleaseDetailsDialog {} 47 | impl AdwDialogImpl for ReleaseDetailsDialog {} 48 | } 49 | 50 | glib::wrapper! { 51 | pub struct 52 | ReleaseDetailsDialog(ObjectSubclass) @extends gtk::Widget, libadwaita::Dialog; 53 | } 54 | 55 | impl ReleaseDetailsDialog { 56 | pub fn new() -> Self { 57 | glib::Object::new() 58 | } 59 | 60 | #[allow(clippy::too_many_arguments)] 61 | pub fn set_details( 62 | &self, 63 | album: &str, 64 | artist: &str, 65 | label: &str, 66 | release_date: &str, 67 | track_count: usize, 68 | copyright: &str, 69 | ) { 70 | let widget = self.imp(); 71 | 72 | widget 73 | .album_artist 74 | .set_title(&labels::album_by_artist_label(album, artist)); 75 | 76 | widget.label.set_text(label); 77 | widget.release.set_text(release_date); 78 | widget.tracks.set_text(&track_count.to_string()); 79 | widget.copyright.set_text(copyright); 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /src/app/components/device_selector/component.rs: -------------------------------------------------------------------------------- 1 | use std::ops::Deref; 2 | use std::rc::Rc; 3 | 4 | use gtk::prelude::Cast; 5 | 6 | use crate::app::components::{Component, EventListener}; 7 | use crate::app::models::ConnectDevice; 8 | use crate::app::state::{Device, LoginEvent, PlaybackAction, PlaybackEvent}; 9 | use crate::app::{ActionDispatcher, AppEvent, AppModel}; 10 | 11 | use super::widget::DeviceSelectorWidget; 12 | 13 | pub struct DeviceSelectorModel { 14 | app_model: Rc, 15 | dispatcher: Box, 16 | } 17 | 18 | impl DeviceSelectorModel { 19 | pub fn new(app_model: Rc, dispatcher: Box) -> Self { 20 | Self { 21 | app_model, 22 | dispatcher, 23 | } 24 | } 25 | 26 | pub fn refresh_available_devices(&self) { 27 | let api = self.app_model.get_spotify(); 28 | 29 | self.dispatcher 30 | .call_spotify_and_dispatch(move || async move { 31 | api.list_available_devices() 32 | .await 33 | .map(|devices| PlaybackAction::SetAvailableDevices(devices).into()) 34 | }); 35 | } 36 | 37 | pub fn get_available_devices(&self) -> impl Deref> + '_ { 38 | self.app_model.map_state(|s| s.playback.available_devices()) 39 | } 40 | 41 | pub fn get_current_device(&self) -> impl Deref + '_ { 42 | self.app_model.map_state(|s| s.playback.current_device()) 43 | } 44 | 45 | pub fn set_current_device(&self, id: Option) { 46 | let devices = self.get_available_devices(); 47 | let connect_device = id 48 | .and_then(|id| devices.iter().find(|&d| d.id == id)) 49 | .cloned(); 50 | let device = connect_device.map(Device::Connect).unwrap_or(Device::Local); 51 | self.dispatcher 52 | .dispatch(PlaybackAction::SwitchDevice(device).into()); 53 | } 54 | } 55 | 56 | pub struct DeviceSelector { 57 | widget: DeviceSelectorWidget, 58 | model: Rc, 59 | } 60 | 61 | impl DeviceSelector { 62 | pub fn new(widget: DeviceSelectorWidget, model: DeviceSelectorModel) -> Self { 63 | let model = Rc::new(model); 64 | 65 | widget.connect_refresh(clone!( 66 | #[weak] 67 | model, 68 | move || { 69 | model.refresh_available_devices(); 70 | } 71 | )); 72 | 73 | widget.connect_switch_device(clone!( 74 | #[weak] 75 | model, 76 | move |id| { 77 | model.set_current_device(id); 78 | } 79 | )); 80 | 81 | Self { widget, model } 82 | } 83 | } 84 | 85 | impl Component for DeviceSelector { 86 | fn get_root_widget(&self) -> >k::Widget { 87 | self.widget.upcast_ref() 88 | } 89 | } 90 | 91 | impl EventListener for DeviceSelector { 92 | fn on_event(&mut self, event: &AppEvent) { 93 | match event { 94 | AppEvent::LoginEvent(LoginEvent::LoginCompleted) => { 95 | self.model.refresh_available_devices(); 96 | } 97 | AppEvent::PlaybackEvent(PlaybackEvent::AvailableDevicesChanged) => { 98 | self.widget 99 | .update_devices_list(&self.model.get_available_devices()); 100 | } 101 | AppEvent::PlaybackEvent(PlaybackEvent::SwitchedDevice(_)) => { 102 | self.widget 103 | .set_current_device(&self.model.get_current_device()); 104 | } 105 | _ => (), 106 | } 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /src/app/components/device_selector/device_selector.blp: -------------------------------------------------------------------------------- 1 | using Gtk 4.0; 2 | using Adw 1; 3 | 4 | template $DeviceSelectorWidget : Button { 5 | Adw.ButtonContent button_content { 6 | halign: center; 7 | hexpand: false; 8 | icon-name: "audio-x-generic-symbolic"; 9 | label: _("This device"); 10 | } 11 | } 12 | 13 | menu menu { 14 | section { 15 | label: _("Playing on"); 16 | 17 | item { 18 | custom: "custom_content"; 19 | } 20 | } 21 | 22 | section { 23 | item { 24 | label: _("Refresh devices"); 25 | action: "devices.refresh"; 26 | } 27 | } 28 | } 29 | 30 | PopoverMenu popover { 31 | } 32 | 33 | Box custom_content { 34 | orientation: vertical; 35 | 36 | CheckButton this_device_button { 37 | label: _("This device"); 38 | sensitive: false; 39 | } 40 | 41 | Box devices { 42 | orientation: vertical; 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/app/components/device_selector/mod.rs: -------------------------------------------------------------------------------- 1 | use gtk::prelude::StaticType; 2 | 3 | mod component; 4 | pub use component::*; 5 | 6 | mod widget; 7 | pub use widget::*; 8 | 9 | pub fn expose_widgets() { 10 | widget::DeviceSelectorWidget::static_type(); 11 | } 12 | -------------------------------------------------------------------------------- /src/app/components/headerbar/headerbar.blp: -------------------------------------------------------------------------------- 1 | using Gtk 4.0; 2 | using Adw 1; 3 | 4 | template $HeaderBarWidget : Adw.Bin { 5 | [root] 6 | Overlay overlay { 7 | hexpand: true; 8 | 9 | Adw.HeaderBar main_header { 10 | show-end-title-buttons: true; 11 | show-back-button: false; 12 | 13 | Button go_back { 14 | receives-default: true; 15 | halign: start; 16 | valign: center; 17 | icon-name: "go-previous-symbolic"; 18 | has-frame: false; 19 | } 20 | 21 | [title] 22 | Adw.WindowTitle title { 23 | visible: true; 24 | title: "Spot"; 25 | } 26 | 27 | [end] 28 | Button start_selection { 29 | icon-name: "object-select-symbolic"; 30 | } 31 | } 32 | 33 | [overlay] 34 | Adw.HeaderBar selection_header { 35 | show-end-title-buttons: false; 36 | show-start-title-buttons: false; 37 | visible: false; 38 | 39 | styles [ 40 | "selection-mode", 41 | ] 42 | 43 | Button cancel { 44 | receives-default: true; 45 | halign: start; 46 | valign: center; 47 | 48 | /* Translators: Button label. Exits selection mode. */ 49 | 50 | label: _("Cancel"); 51 | } 52 | 53 | [title] 54 | Adw.WindowTitle selection_title { 55 | title: ""; 56 | } 57 | 58 | [end] 59 | Button select_all { 60 | valign: center; 61 | 62 | /* Translators: Button label. Selects all visible songs. */ 63 | 64 | label: _("Select all"); 65 | } 66 | } 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/app/components/headerbar/mod.rs: -------------------------------------------------------------------------------- 1 | mod widget; 2 | pub use widget::*; 3 | 4 | mod component; 5 | pub use component::*; 6 | 7 | use glib::prelude::*; 8 | 9 | pub fn expose_widgets() { 10 | widget::HeaderBarWidget::static_type(); 11 | } 12 | -------------------------------------------------------------------------------- /src/app/components/labels.rs: -------------------------------------------------------------------------------- 1 | use gettextrs::*; 2 | 3 | lazy_static! { 4 | // translators: This is part of a contextual menu attached to a single track; this entry allows viewing the album containing a specific track. 5 | pub static ref VIEW_ALBUM: String = gettext("View album"); 6 | 7 | // translators: This is part of a contextual menu attached to a single track; the intent is to copy the link (public URL) to a specific track. 8 | pub static ref COPY_LINK: String = gettext("Copy link"); 9 | 10 | // translators: This is part of a contextual menu attached to a single track; this entry adds a track at the end of the play queue. 11 | pub static ref ADD_TO_QUEUE: String = gettext("Add to queue"); 12 | 13 | // translators: This is part of a contextual menu attached to a single track; this entry removes a track from the play queue. 14 | pub static ref REMOVE_FROM_QUEUE: String = gettext("Remove from queue"); 15 | } 16 | 17 | pub fn add_to_playlist_label(playlist: &str) -> String { 18 | // this is just to fool xgettext, it doesn't like macros (or rust for that matter) :( 19 | if cfg!(debug_assertions) { 20 | // translators: This is part of a larger text that says "Add to ". This text should be as short as possible. 21 | gettext("Add to {}"); 22 | } 23 | gettext!("Add to {}", playlist) 24 | } 25 | 26 | pub fn n_songs_selected_label(n: usize) -> String { 27 | // this is just to fool xgettext, it doesn't like macros (or rust for that matter) :( 28 | if cfg!(debug_assertions) { 29 | // translators: This shows up when in selection mode. This text should be as short as possible. 30 | ngettext("{} song selected", "{} songs selected", n as u32); 31 | } 32 | ngettext!("{} song selected", "{} songs selected", n as u32, n) 33 | } 34 | 35 | pub fn more_from_label(artist: &str) -> String { 36 | // this is just to fool xgettext, it doesn't like macros (or rust for that matter) :( 37 | if cfg!(debug_assertions) { 38 | // translators: This is part of a contextual menu attached to a single track; the full text is "More from ". 39 | gettext("More from {}"); 40 | } 41 | gettext!("More from {}", glib::markup_escape_text(artist)) 42 | } 43 | 44 | pub fn album_by_artist_label(album: &str, artist: &str) -> String { 45 | // this is just to fool xgettext, it doesn't like macros (or rust for that matter) :( 46 | if cfg!(debug_assertions) { 47 | // translators: This is part of a larger label that reads " by " 48 | gettext("{} by {}"); 49 | } 50 | gettext!( 51 | "{} by {}", 52 | glib::markup_escape_text(album), 53 | glib::markup_escape_text(artist) 54 | ) 55 | } 56 | -------------------------------------------------------------------------------- /src/app/components/library/library.blp: -------------------------------------------------------------------------------- 1 | using Gtk 4.0; 2 | using Adw 1; 3 | 4 | template $LibraryWidget : Box { 5 | ScrolledWindow scrolled_window { 6 | hexpand: true; 7 | vexpand: true; 8 | vscrollbar-policy: automatic; 9 | min-content-width: 250; 10 | Overlay overlay { 11 | FlowBox flowbox { 12 | margin-start: 6; 13 | margin-end: 6; 14 | margin-top: 6; 15 | margin-bottom: 6; 16 | min-children-per-line: 1; 17 | selection-mode: none; 18 | activate-on-single-click: false; 19 | } 20 | 21 | [overlay] 22 | Adw.StatusPage status_page { 23 | /* Translators: A title that is shown when the user has not saved any albums. */ 24 | 25 | title: _("You have no saved albums."); 26 | 27 | /* Translators: A description of what happens when the user has saved albums. */ 28 | 29 | description: _("Your library will be shown here."); 30 | icon-name: "emblem-music-symbolic"; 31 | visible: true; 32 | } 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/app/components/library/library_model.rs: -------------------------------------------------------------------------------- 1 | use std::cell::Ref; 2 | use std::ops::Deref; 3 | use std::rc::Rc; 4 | 5 | use crate::app::models::*; 6 | use crate::app::state::HomeState; 7 | use crate::app::{ActionDispatcher, AppAction, AppModel, BrowserAction, ListStore}; 8 | 9 | pub struct LibraryModel { 10 | app_model: Rc, 11 | dispatcher: Box, 12 | } 13 | 14 | impl LibraryModel { 15 | pub fn new(app_model: Rc, dispatcher: Box) -> Self { 16 | Self { 17 | app_model, 18 | dispatcher, 19 | } 20 | } 21 | 22 | fn state(&self) -> Option> { 23 | self.app_model.map_state_opt(|s| s.browser.home_state()) 24 | } 25 | 26 | pub fn get_list_store(&self) -> Option> + '_> { 27 | Some(Ref::map(self.state()?, |s| &s.albums)) 28 | } 29 | 30 | pub fn refresh_saved_albums(&self) -> Option<()> { 31 | let api = self.app_model.get_spotify(); 32 | let batch_size = self.state()?.next_albums_page.batch_size; 33 | 34 | self.dispatcher 35 | .call_spotify_and_dispatch(move || async move { 36 | api.get_saved_albums(0, batch_size) 37 | .await 38 | .map(|albums| BrowserAction::SetLibraryContent(albums).into()) 39 | }); 40 | 41 | Some(()) 42 | } 43 | 44 | pub fn has_albums(&self) -> bool { 45 | self.get_list_store() 46 | .map(|list| list.len() > 0) 47 | .unwrap_or(false) 48 | } 49 | 50 | pub fn load_more_albums(&self) -> Option<()> { 51 | let api = self.app_model.get_spotify(); 52 | 53 | let next_page = &self.state()?.next_albums_page; 54 | let batch_size = next_page.batch_size; 55 | let offset = next_page.next_offset?; 56 | 57 | self.dispatcher 58 | .call_spotify_and_dispatch(move || async move { 59 | api.get_saved_albums(offset, batch_size) 60 | .await 61 | .map(|albums| BrowserAction::AppendLibraryContent(albums).into()) 62 | }); 63 | 64 | Some(()) 65 | } 66 | 67 | pub fn open_album(&self, album_id: String) { 68 | self.dispatcher.dispatch(AppAction::ViewAlbum(album_id)); 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/app/components/library/mod.rs: -------------------------------------------------------------------------------- 1 | #[allow(clippy::module_inception)] 2 | mod library; 3 | mod library_model; 4 | 5 | pub use library::*; 6 | pub use library_model::*; 7 | -------------------------------------------------------------------------------- /src/app/components/login/login.blp: -------------------------------------------------------------------------------- 1 | using Gtk 4.0; 2 | using Adw 1; 3 | 4 | 5 | template $LoginWindow : Adw.Window { 6 | default-width: 360; 7 | default-height: 100; 8 | 9 | Box { 10 | hexpand: true; 11 | margin-bottom: 24; 12 | orientation: vertical; 13 | 14 | Adw.HeaderBar { 15 | [title] 16 | Label {} 17 | 18 | styles ["flat"] 19 | } 20 | 21 | WindowHandle { 22 | 23 | Adw.Clamp { 24 | maximum-size: 360; 25 | tightening-threshold: 280; 26 | 27 | Box { 28 | hexpand: true; 29 | orientation: vertical; 30 | margin-start: 16; 31 | margin-end: 16; 32 | spacing: 24; 33 | 34 | Image { 35 | icon-name: "dev.alextren.Spot"; 36 | pixel-size: 128; 37 | margin-bottom: 20; 38 | } 39 | 40 | Box{ 41 | hexpand: true; 42 | orientation: vertical; 43 | spacing: 4; 44 | 45 | Label { 46 | label: _("Welcome to Spot"); 47 | halign: center; 48 | styles ["title-1"] 49 | } 50 | 51 | Label { 52 | /* Translators: Login window title, must mention Premium (a premium account is required). */ 53 | label: _("Log in with your Spotify Account. A Spotify Premium subscription is required to use the app."); 54 | wrap: true; 55 | wrap-mode: word; 56 | halign: center; 57 | justify: center; 58 | styles ["body"] 59 | } 60 | } 61 | 62 | Revealer auth_error_container { 63 | vexpand: true; 64 | transition-type: slide_up; 65 | 66 | Label { 67 | /* Translators: This error is shown when authentication fails. */ 68 | label: _("An error occured when trying to connect."); 69 | halign: center; 70 | justify: center; 71 | wrap: true; 72 | wrap-mode: word; 73 | styles ["error"] 74 | } 75 | } 76 | 77 | Button login_with_spotify_button { 78 | /* Translators: Log in button label */ 79 | label: _("Log in with Spotify..."); 80 | halign: center; 81 | styles ["pill", "suggested-action"] 82 | } 83 | } 84 | } 85 | } 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /src/app/components/login/login_model.rs: -------------------------------------------------------------------------------- 1 | use crate::app::state::{LoginAction, TryLoginAction}; 2 | use crate::app::ActionDispatcher; 3 | 4 | pub struct LoginModel { 5 | dispatcher: Box, 6 | } 7 | 8 | impl LoginModel { 9 | pub fn new(dispatcher: Box) -> Self { 10 | Self { dispatcher } 11 | } 12 | 13 | pub fn try_autologin(&self) { 14 | self.dispatcher 15 | .dispatch(LoginAction::TryLogin(TryLoginAction::Restore).into()); 16 | } 17 | 18 | pub fn login_with_spotify(&self) { 19 | self.dispatcher 20 | .dispatch(LoginAction::TryLogin(TryLoginAction::InitLogin).into()) 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/app/components/login/mod.rs: -------------------------------------------------------------------------------- 1 | #[allow(clippy::module_inception)] 2 | mod login; 3 | mod login_model; 4 | 5 | pub use login::*; 6 | pub use login_model::*; 7 | -------------------------------------------------------------------------------- /src/app/components/navigation/home.rs: -------------------------------------------------------------------------------- 1 | use gtk::prelude::*; 2 | 3 | use crate::app::components::sidebar::SidebarDestination; 4 | use crate::app::components::{Component, EventListener, ScreenFactory}; 5 | use crate::app::{AppEvent, BrowserEvent}; 6 | 7 | pub struct HomePane { 8 | stack: gtk::Stack, 9 | components: Vec>, 10 | } 11 | 12 | impl HomePane { 13 | pub fn new(listbox: gtk::ListBox, screen_factory: &ScreenFactory) -> Self { 14 | let library = screen_factory.make_library(); 15 | let saved_playlists = screen_factory.make_saved_playlists(); 16 | let saved_tracks = screen_factory.make_saved_tracks(); 17 | let now_playing = screen_factory.make_now_playing(); 18 | let sidebar = screen_factory.make_sidebar(listbox); 19 | 20 | let stack = gtk::Stack::new(); 21 | stack.set_transition_type(gtk::StackTransitionType::Crossfade); 22 | 23 | let dest = SidebarDestination::Library; 24 | stack.add_titled( 25 | library.get_root_widget(), 26 | Option::from(dest.id()), 27 | &dest.title(), 28 | ); 29 | 30 | let dest = SidebarDestination::SavedTracks; 31 | stack.add_titled( 32 | saved_tracks.get_root_widget(), 33 | Option::from(dest.id()), 34 | &dest.title(), 35 | ); 36 | 37 | let dest = SidebarDestination::SavedPlaylists; 38 | stack.add_titled( 39 | saved_playlists.get_root_widget(), 40 | Option::from(dest.id()), 41 | &dest.title(), 42 | ); 43 | 44 | let dest = SidebarDestination::NowPlaying; 45 | stack.add_titled( 46 | now_playing.get_root_widget(), 47 | Option::from(dest.id()), 48 | &dest.title(), 49 | ); 50 | 51 | Self { 52 | stack, 53 | components: vec![ 54 | Box::new(sidebar), 55 | Box::new(library), 56 | Box::new(saved_playlists), 57 | Box::new(saved_tracks), 58 | Box::new(now_playing), 59 | ], 60 | } 61 | } 62 | } 63 | 64 | impl Component for HomePane { 65 | fn get_root_widget(&self) -> >k::Widget { 66 | self.stack.upcast_ref() 67 | } 68 | 69 | fn get_children(&mut self) -> Option<&mut Vec>> { 70 | Some(&mut self.components) 71 | } 72 | } 73 | 74 | impl EventListener for HomePane { 75 | fn on_event(&mut self, event: &AppEvent) { 76 | match event { 77 | AppEvent::NowPlayingShown => { 78 | self.stack 79 | .set_visible_child_name(SidebarDestination::NowPlaying.id()); 80 | } 81 | AppEvent::BrowserEvent(BrowserEvent::HomeVisiblePageChanged(page)) => { 82 | self.stack.set_visible_child_name(page); 83 | } 84 | _ => {} 85 | } 86 | self.broadcast_event(event); 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /src/app/components/navigation/mod.rs: -------------------------------------------------------------------------------- 1 | #[allow(clippy::module_inception)] 2 | mod navigation; 3 | pub use navigation::*; 4 | 5 | mod navigation_model; 6 | pub use navigation_model::*; 7 | 8 | mod home; 9 | 10 | mod factory; 11 | pub use factory::*; 12 | -------------------------------------------------------------------------------- /src/app/components/navigation/navigation_model.rs: -------------------------------------------------------------------------------- 1 | use crate::app::state::ScreenName; 2 | use crate::app::{ActionDispatcher, AppModel, BrowserAction}; 3 | use std::ops::Deref; 4 | use std::rc::Rc; 5 | 6 | pub struct NavigationModel { 7 | app_model: Rc, 8 | dispatcher: Box, 9 | } 10 | 11 | impl NavigationModel { 12 | pub fn new(app_model: Rc, dispatcher: Box) -> Self { 13 | Self { 14 | app_model, 15 | dispatcher, 16 | } 17 | } 18 | 19 | pub fn visible_child_name(&self) -> impl Deref + '_ { 20 | self.app_model.map_state(|s| s.browser.current_screen()) 21 | } 22 | 23 | pub fn set_nav_hidden(&self, hidden: bool) { 24 | self.dispatcher 25 | .dispatch(BrowserAction::SetNavigationHidden(hidden).into()); 26 | } 27 | 28 | pub fn children_count(&self) -> usize { 29 | self.app_model.get_state().browser.count() 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/app/components/notification/mod.rs: -------------------------------------------------------------------------------- 1 | use crate::app::components::EventListener; 2 | use crate::app::AppEvent; 3 | use gdk::prelude::ToVariant; 4 | use gettextrs::*; 5 | 6 | pub struct Notification { 7 | toast_overlay: libadwaita::ToastOverlay, 8 | } 9 | 10 | impl Notification { 11 | pub fn new(toast_overlay: libadwaita::ToastOverlay) -> Self { 12 | Self { toast_overlay } 13 | } 14 | 15 | fn show(&self, content: &str) { 16 | let toast = libadwaita::Toast::builder() 17 | .title(content) 18 | .timeout(4) 19 | .build(); 20 | self.toast_overlay.add_toast(toast); 21 | } 22 | 23 | fn show_playlist_created(&self, id: &str) { 24 | // translators: This is a notification that pop ups when a new playlist is created. It includes the name of that playlist. 25 | let message = gettext("New playlist created."); 26 | // translators: This is a label in the notification shown after creating a new playlist. If it is clicked, the new playlist will be opened. 27 | let label = gettext("View"); 28 | let toast = libadwaita::Toast::builder() 29 | .title(message) 30 | .timeout(4) 31 | .action_name("app.open_playlist") 32 | .button_label(label) 33 | .action_target(&id.to_variant()) 34 | .build(); 35 | self.toast_overlay.add_toast(toast); 36 | } 37 | } 38 | 39 | impl EventListener for Notification { 40 | fn on_event(&mut self, event: &AppEvent) { 41 | if let AppEvent::NotificationShown(content) = event { 42 | self.show(content) 43 | } else if let AppEvent::PlaylistCreatedNotificationShown(id) = event { 44 | self.show_playlist_created(id) 45 | } 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/app/components/now_playing/mod.rs: -------------------------------------------------------------------------------- 1 | #[allow(clippy::module_inception)] 2 | mod now_playing; 3 | pub use now_playing::*; 4 | 5 | mod now_playing_model; 6 | pub use now_playing_model::*; 7 | -------------------------------------------------------------------------------- /src/app/components/now_playing/now_playing.blp: -------------------------------------------------------------------------------- 1 | using Gtk 4.0; 2 | using Adw 1; 3 | 4 | template $NowPlayingWidget : Box { 5 | orientation: vertical; 6 | vexpand: true; 7 | hexpand: true; 8 | 9 | $HeaderBarWidget headerbar { 10 | $DeviceSelectorWidget device_selector {} 11 | } 12 | 13 | ScrolledWindow scrolled_window { 14 | vexpand: true; 15 | 16 | Adw.ClampScrollable { 17 | maximum-size: 900; 18 | 19 | ListView song_list { 20 | } 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/app/components/now_playing/now_playing.rs: -------------------------------------------------------------------------------- 1 | use gtk::prelude::*; 2 | use gtk::subclass::prelude::*; 3 | use gtk::CompositeTemplate; 4 | use std::rc::Rc; 5 | 6 | use super::NowPlayingModel; 7 | use crate::app::components::{ 8 | Component, DeviceSelector, DeviceSelectorWidget, EventListener, HeaderBarComponent, 9 | HeaderBarWidget, Playlist, 10 | }; 11 | use crate::app::state::PlaybackEvent; 12 | use crate::app::{AppEvent, Worker}; 13 | 14 | mod imp { 15 | 16 | use super::*; 17 | 18 | #[derive(Debug, Default, CompositeTemplate)] 19 | #[template(resource = "/dev/alextren/Spot/components/now_playing.ui")] 20 | pub struct NowPlayingWidget { 21 | #[template_child] 22 | pub song_list: TemplateChild, 23 | 24 | #[template_child] 25 | pub headerbar: TemplateChild, 26 | 27 | #[template_child] 28 | pub device_selector: TemplateChild, 29 | 30 | #[template_child] 31 | pub scrolled_window: TemplateChild, 32 | } 33 | 34 | #[glib::object_subclass] 35 | impl ObjectSubclass for NowPlayingWidget { 36 | const NAME: &'static str = "NowPlayingWidget"; 37 | type Type = super::NowPlayingWidget; 38 | type ParentType = gtk::Box; 39 | 40 | fn class_init(klass: &mut Self::Class) { 41 | klass.bind_template(); 42 | } 43 | 44 | fn instance_init(obj: &glib::subclass::InitializingObject) { 45 | obj.init_template(); 46 | } 47 | } 48 | 49 | impl ObjectImpl for NowPlayingWidget {} 50 | impl WidgetImpl for NowPlayingWidget {} 51 | impl BoxImpl for NowPlayingWidget {} 52 | } 53 | 54 | glib::wrapper! { 55 | pub struct NowPlayingWidget(ObjectSubclass) @extends gtk::Widget, gtk::Box; 56 | } 57 | 58 | impl NowPlayingWidget { 59 | fn new() -> Self { 60 | glib::Object::new() 61 | } 62 | 63 | fn connect_bottom_edge(&self, f: F) 64 | where 65 | F: Fn() + 'static, 66 | { 67 | self.imp() 68 | .scrolled_window 69 | .connect_edge_reached(move |_, pos| { 70 | if let gtk::PositionType::Bottom = pos { 71 | f() 72 | } 73 | }); 74 | } 75 | 76 | fn song_list_widget(&self) -> >k::ListView { 77 | self.imp().song_list.as_ref() 78 | } 79 | 80 | fn headerbar_widget(&self) -> &HeaderBarWidget { 81 | self.imp().headerbar.as_ref() 82 | } 83 | 84 | fn device_selector_widget(&self) -> &DeviceSelectorWidget { 85 | self.imp().device_selector.as_ref() 86 | } 87 | } 88 | 89 | pub struct NowPlaying { 90 | widget: NowPlayingWidget, 91 | model: Rc, 92 | children: Vec>, 93 | } 94 | 95 | impl NowPlaying { 96 | pub fn new(model: Rc, worker: Worker) -> Self { 97 | let widget = NowPlayingWidget::new(); 98 | 99 | widget.connect_bottom_edge(clone!( 100 | #[weak] 101 | model, 102 | move || { 103 | model.load_more(); 104 | } 105 | )); 106 | 107 | let playlist = Box::new(Playlist::new( 108 | widget.song_list_widget().clone(), 109 | model.clone(), 110 | worker, 111 | )); 112 | 113 | let headerbar_widget = widget.headerbar_widget(); 114 | let headerbar = Box::new(HeaderBarComponent::new( 115 | headerbar_widget.clone(), 116 | model.to_headerbar_model(), 117 | )); 118 | 119 | let device_selector = Box::new(DeviceSelector::new( 120 | widget.device_selector_widget().clone(), 121 | model.device_selector_model(), 122 | )); 123 | 124 | Self { 125 | widget, 126 | model, 127 | children: vec![playlist, headerbar, device_selector], 128 | } 129 | } 130 | } 131 | 132 | impl Component for NowPlaying { 133 | fn get_root_widget(&self) -> >k::Widget { 134 | self.widget.upcast_ref() 135 | } 136 | 137 | fn get_children(&mut self) -> Option<&mut Vec>> { 138 | Some(&mut self.children) 139 | } 140 | } 141 | 142 | impl EventListener for NowPlaying { 143 | fn on_event(&mut self, event: &AppEvent) { 144 | if let AppEvent::PlaybackEvent(PlaybackEvent::TrackChanged(_)) = event { 145 | self.model.load_more(); 146 | } 147 | self.broadcast_event(event); 148 | } 149 | } 150 | -------------------------------------------------------------------------------- /src/app/components/playback/mod.rs: -------------------------------------------------------------------------------- 1 | mod component; 2 | mod playback_controls; 3 | mod playback_info; 4 | mod playback_widget; 5 | pub use component::*; 6 | 7 | use glib::prelude::*; 8 | 9 | pub fn expose_widgets() { 10 | playback_widget::PlaybackWidget::static_type(); 11 | } 12 | -------------------------------------------------------------------------------- /src/app/components/playback/playback.css: -------------------------------------------------------------------------------- 1 | .seek-bar { 2 | padding: 0; 3 | padding-bottom: 2px; 4 | min-height: 1px; 5 | } 6 | 7 | .seek-bar trough, .seek-bar highlight { 8 | border-radius: 0; 9 | border-left: none; 10 | border-right: none; 11 | min-height: 1px; 12 | transition: min-height 100ms ease; 13 | } 14 | 15 | .seek-bar--active trough, .seek-bar--active highlight { 16 | min-height: 5px; 17 | } 18 | 19 | .seek-bar--active:hover trough, .seek-bar--active:hover highlight { 20 | min-height: 10px; 21 | } 22 | 23 | .seek-bar highlight { 24 | border-left: none; 25 | border-right: none; 26 | } 27 | 28 | .playback-button { 29 | min-width: 40px; 30 | min-height: 40px; 31 | } 32 | -------------------------------------------------------------------------------- /src/app/components/playback/playback_controls.blp: -------------------------------------------------------------------------------- 1 | using Gtk 4.0; 2 | 3 | template $PlaybackControlsWidget : Box { 4 | halign: center; 5 | hexpand: true; 6 | spacing: 8; 7 | homogeneous: true; 8 | 9 | ToggleButton shuffle { 10 | receives-default: true; 11 | halign: center; 12 | valign: center; 13 | has-frame: false; 14 | icon-name: "media-playlist-shuffle-symbolic"; 15 | tooltip-text: _("Shuffle"); 16 | } 17 | 18 | Button prev { 19 | receives-default: true; 20 | halign: center; 21 | valign: center; 22 | has-frame: false; 23 | icon-name: "media-skip-backward-symbolic"; 24 | tooltip-text: _("Previous"); 25 | } 26 | 27 | Button play_pause { 28 | receives-default: true; 29 | halign: center; 30 | valign: center; 31 | icon-name: "media-playback-start-symbolic"; 32 | tooltip-text: "Play/Pause"; 33 | 34 | styles [ 35 | "circular", 36 | "playback-button", 37 | ] 38 | } 39 | 40 | Button next { 41 | receives-default: true; 42 | halign: center; 43 | valign: center; 44 | has-frame: false; 45 | icon-name: "media-skip-forward-symbolic"; 46 | tooltip-text: _("Next"); 47 | } 48 | 49 | Button repeat { 50 | receives-default: true; 51 | halign: center; 52 | valign: center; 53 | has-frame: false; 54 | icon-name: "media-playlist-consecutive-symbolic"; 55 | tooltip-text: _("Repeat"); 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/app/components/playback/playback_controls.rs: -------------------------------------------------------------------------------- 1 | use gettextrs::gettext; 2 | use gtk::prelude::*; 3 | use gtk::subclass::prelude::*; 4 | use gtk::{glib, CompositeTemplate}; 5 | 6 | use crate::app::models::RepeatMode; 7 | 8 | mod imp { 9 | 10 | use super::*; 11 | 12 | #[derive(Debug, Default, CompositeTemplate)] 13 | #[template(resource = "/dev/alextren/Spot/components/playback_controls.ui")] 14 | pub struct PlaybackControlsWidget { 15 | #[template_child] 16 | pub play_pause: TemplateChild, 17 | 18 | #[template_child] 19 | pub next: TemplateChild, 20 | 21 | #[template_child] 22 | pub prev: TemplateChild, 23 | 24 | #[template_child] 25 | pub shuffle: TemplateChild, 26 | 27 | #[template_child] 28 | pub repeat: TemplateChild, 29 | } 30 | 31 | #[glib::object_subclass] 32 | impl ObjectSubclass for PlaybackControlsWidget { 33 | const NAME: &'static str = "PlaybackControlsWidget"; 34 | type Type = super::PlaybackControlsWidget; 35 | type ParentType = gtk::Box; 36 | 37 | fn class_init(klass: &mut Self::Class) { 38 | klass.bind_template(); 39 | } 40 | 41 | fn instance_init(obj: &glib::subclass::InitializingObject) { 42 | obj.init_template(); 43 | } 44 | } 45 | 46 | impl ObjectImpl for PlaybackControlsWidget {} 47 | impl WidgetImpl for PlaybackControlsWidget {} 48 | impl BoxImpl for PlaybackControlsWidget {} 49 | } 50 | 51 | glib::wrapper! { 52 | pub struct PlaybackControlsWidget(ObjectSubclass) @extends gtk::Widget, gtk::Box; 53 | } 54 | 55 | impl PlaybackControlsWidget { 56 | pub fn set_playing(&self, is_playing: bool) { 57 | let playback_icon = if is_playing { 58 | "media-playback-pause-symbolic" 59 | } else { 60 | "media-playback-start-symbolic" 61 | }; 62 | 63 | let translated_tooltip = if is_playing { 64 | gettext("Pause") 65 | } else { 66 | gettext("Play") 67 | }; 68 | let tooltip_text = Some(translated_tooltip.as_str()); 69 | 70 | let playback_control = self.imp(); 71 | playback_control.play_pause.set_icon_name(playback_icon); 72 | playback_control.play_pause.set_tooltip_text(tooltip_text); 73 | } 74 | 75 | pub fn set_shuffled(&self, shuffled: bool) { 76 | self.imp().shuffle.set_active(shuffled); 77 | } 78 | 79 | pub fn set_repeat_mode(&self, mode: RepeatMode) { 80 | let repeat_mode_icon = match mode { 81 | RepeatMode::Song => "media-playlist-repeat-song-symbolic", 82 | RepeatMode::Playlist => "media-playlist-repeat-symbolic", 83 | RepeatMode::None => "media-playlist-consecutive-symbolic", 84 | }; 85 | 86 | self.imp().repeat.set_icon_name(repeat_mode_icon); 87 | } 88 | 89 | pub fn connect_play_pause(&self, f: F) 90 | where 91 | F: Fn() + 'static, 92 | { 93 | self.imp().play_pause.connect_clicked(move |_| f()); 94 | } 95 | 96 | pub fn connect_prev(&self, f: F) 97 | where 98 | F: Fn() + 'static, 99 | { 100 | self.imp().prev.connect_clicked(move |_| f()); 101 | } 102 | 103 | pub fn connect_next(&self, f: F) 104 | where 105 | F: Fn() + 'static, 106 | { 107 | self.imp().next.connect_clicked(move |_| f()); 108 | } 109 | 110 | pub fn connect_shuffle(&self, f: F) 111 | where 112 | F: Fn() + 'static, 113 | { 114 | self.imp().shuffle.connect_clicked(move |_| f()); 115 | } 116 | 117 | pub fn connect_repeat(&self, f: F) 118 | where 119 | F: Fn() + 'static, 120 | { 121 | self.imp().repeat.connect_clicked(move |_| f()); 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /src/app/components/playback/playback_info.blp: -------------------------------------------------------------------------------- 1 | using Gtk 4.0; 2 | 3 | template $PlaybackInfoWidget : Button { 4 | receives-default: true; 5 | halign: start; 6 | valign: center; 7 | has-frame: false; 8 | 9 | layout { 10 | column-span: "1"; 11 | column: "0"; 12 | row: "0"; 13 | } 14 | 15 | Box { 16 | halign: center; 17 | 18 | Image playing_image { 19 | width-request: 40; 20 | height-request: 40; 21 | icon-name: "emblem-music-symbolic"; 22 | } 23 | 24 | Label current_song_info { 25 | visible: false; 26 | halign: start; 27 | hexpand: true; 28 | margin-start: 12; 29 | margin-end: 12; 30 | 31 | /* Translators: Short text displayed instead of a song title when nothing plays */ 32 | 33 | label: _("No song playing"); 34 | use-markup: true; 35 | ellipsize: middle; 36 | lines: 1; 37 | } 38 | } 39 | 40 | styles [ 41 | "body", 42 | ] 43 | } 44 | -------------------------------------------------------------------------------- /src/app/components/playback/playback_info.rs: -------------------------------------------------------------------------------- 1 | use gettextrs::gettext; 2 | use gtk::prelude::*; 3 | use gtk::subclass::prelude::*; 4 | use gtk::{glib, CompositeTemplate}; 5 | 6 | mod imp { 7 | 8 | use super::*; 9 | 10 | #[derive(Debug, Default, CompositeTemplate)] 11 | #[template(resource = "/dev/alextren/Spot/components/playback_info.ui")] 12 | pub struct PlaybackInfoWidget { 13 | #[template_child] 14 | pub playing_image: TemplateChild, 15 | 16 | #[template_child] 17 | pub current_song_info: TemplateChild, 18 | } 19 | 20 | #[glib::object_subclass] 21 | impl ObjectSubclass for PlaybackInfoWidget { 22 | const NAME: &'static str = "PlaybackInfoWidget"; 23 | type Type = super::PlaybackInfoWidget; 24 | type ParentType = gtk::Button; 25 | 26 | fn class_init(klass: &mut Self::Class) { 27 | klass.bind_template(); 28 | } 29 | 30 | fn instance_init(obj: &glib::subclass::InitializingObject) { 31 | obj.init_template(); 32 | } 33 | } 34 | 35 | impl ObjectImpl for PlaybackInfoWidget {} 36 | impl WidgetImpl for PlaybackInfoWidget {} 37 | impl ButtonImpl for PlaybackInfoWidget {} 38 | } 39 | 40 | glib::wrapper! { 41 | pub struct PlaybackInfoWidget(ObjectSubclass) @extends gtk::Widget, gtk::Button; 42 | } 43 | 44 | impl PlaybackInfoWidget { 45 | pub fn set_title_and_artist(&self, title: &str, artist: &str) { 46 | let widget = self.imp(); 47 | let title = glib::markup_escape_text(title); 48 | let artist = glib::markup_escape_text(artist); 49 | let label = format!("{}\n{}", title.as_str(), artist.as_str()); 50 | widget.current_song_info.set_label(&label[..]); 51 | } 52 | 53 | pub fn reset_info(&self) { 54 | let widget = self.imp(); 55 | widget 56 | .current_song_info 57 | // translators: Short text displayed instead of a song title when nothing plays 58 | .set_label(&gettext("No song playing")); 59 | widget 60 | .playing_image 61 | .set_icon_name(Some("emblem-music-symbolic")); 62 | widget 63 | .playing_image 64 | .set_icon_name(Some("emblem-music-symbolic")); 65 | } 66 | 67 | pub fn set_info_visible(&self, visible: bool) { 68 | self.imp().current_song_info.set_visible(visible); 69 | } 70 | 71 | pub fn set_artwork(&self, pixbuf: &gdk_pixbuf::Pixbuf) { 72 | let texture = gdk::Texture::for_pixbuf(pixbuf); 73 | self.imp().playing_image.set_paintable(Some(&texture)); 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /src/app/components/playback/playback_widget.blp: -------------------------------------------------------------------------------- 1 | using Gtk 4.0; 2 | using Adw 1; 3 | 4 | template $PlaybackWidget : Box { 5 | orientation: vertical; 6 | 7 | Scale seek_bar { 8 | show-fill-level: true; 9 | restrict-to-fill-level: false; 10 | fill-level: 0; 11 | digits: 0; 12 | value-pos: left; 13 | 14 | styles [ 15 | "seek-bar", 16 | ] 17 | } 18 | 19 | Adw.BreakpointBin { 20 | width-request: 1; 21 | height-request: 1; 22 | 23 | Adw.Breakpoint { 24 | condition("max-width:700sp") 25 | 26 | setters { 27 | stack.visible-child-name: "mobile"; 28 | } 29 | } 30 | 31 | Stack stack { 32 | visible-child-name: "desktop"; 33 | hhomogeneous: false; 34 | 35 | StackPage { 36 | name: "desktop"; 37 | child: Grid { 38 | halign: fill; 39 | hexpand: true; 40 | column-homogeneous: true; 41 | 42 | $PlaybackInfoWidget now_playing { 43 | receives-default: "1"; 44 | halign: "start"; 45 | valign: "center"; 46 | has-frame: "0"; 47 | 48 | layout { 49 | column-span: "1"; 50 | column: "0"; 51 | row: "0"; 52 | } 53 | } 54 | 55 | $PlaybackControlsWidget controls { 56 | layout { 57 | column-span: "1"; 58 | column: "1"; 59 | row: "0"; 60 | } 61 | } 62 | 63 | Box { 64 | margin-top: 4; 65 | margin-bottom: 4; 66 | margin-start: 4; 67 | margin-end: 4; 68 | 69 | layout { 70 | column-span: "1"; 71 | column: "2"; 72 | row: "0"; 73 | } 74 | 75 | Label track_position { 76 | sensitive: false; 77 | label: "0∶00"; 78 | halign: end; 79 | hexpand: true; 80 | 81 | styles [ 82 | "numeric", 83 | ] 84 | } 85 | 86 | Label track_duration { 87 | sensitive: false; 88 | label: " / 0∶00"; 89 | halign: end; 90 | 91 | styles [ 92 | "numeric", 93 | ] 94 | } 95 | } 96 | }; 97 | } 98 | 99 | StackPage { 100 | name: "mobile"; 101 | child: Grid { 102 | halign: fill; 103 | hexpand: true; 104 | column-homogeneous: false; 105 | 106 | $PlaybackInfoWidget now_playing_mobile { 107 | receives-default: "1"; 108 | halign: "start"; 109 | valign: "center"; 110 | has-frame: "0"; 111 | 112 | layout { 113 | column-span: "1"; 114 | column: "0"; 115 | row: "0"; 116 | } 117 | } 118 | 119 | $PlaybackControlsWidget controls_mobile { 120 | layout { 121 | column-span: "1"; 122 | column: "1"; 123 | row: "0"; 124 | } 125 | } 126 | }; 127 | } 128 | } 129 | } 130 | } 131 | -------------------------------------------------------------------------------- /src/app/components/playlist/mod.rs: -------------------------------------------------------------------------------- 1 | #[allow(clippy::module_inception)] 2 | mod playlist; 3 | pub use playlist::*; 4 | 5 | mod song; 6 | pub use song::*; 7 | 8 | mod song_actions; 9 | -------------------------------------------------------------------------------- /src/app/components/playlist/playback-indicator/playback-0-symbolic.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /src/app/components/playlist/playback-indicator/playback-1-symbolic.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /src/app/components/playlist/playback-indicator/playback-10-symbolic.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /src/app/components/playlist/playback-indicator/playback-11-symbolic.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /src/app/components/playlist/playback-indicator/playback-12-symbolic.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /src/app/components/playlist/playback-indicator/playback-13-symbolic.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /src/app/components/playlist/playback-indicator/playback-14-symbolic.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /src/app/components/playlist/playback-indicator/playback-15-symbolic.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /src/app/components/playlist/playback-indicator/playback-16-symbolic.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /src/app/components/playlist/playback-indicator/playback-2-symbolic.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /src/app/components/playlist/playback-indicator/playback-3-symbolic.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /src/app/components/playlist/playback-indicator/playback-4-symbolic.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /src/app/components/playlist/playback-indicator/playback-5-symbolic.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /src/app/components/playlist/playback-indicator/playback-6-symbolic.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /src/app/components/playlist/playback-indicator/playback-7-symbolic.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /src/app/components/playlist/playback-indicator/playback-8-symbolic.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /src/app/components/playlist/playback-indicator/playback-9-symbolic.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /src/app/components/playlist/playback-indicator/playback-paused-symbolic.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /src/app/components/playlist/song.blp: -------------------------------------------------------------------------------- 1 | using Gtk 4.0; 2 | 3 | template $SongWidget : Grid { 4 | margin-start: 6; 5 | margin-end: 6; 6 | margin-top: 6; 7 | margin-bottom: 6; 8 | column-spacing: 6; 9 | row-spacing: 0; 10 | 11 | Overlay { 12 | layout { 13 | row-span: "2"; 14 | column: "0"; 15 | row: "0"; 16 | } 17 | 18 | Label song_index { 19 | label: "1"; 20 | sensitive: false; 21 | halign: center; 22 | 23 | styles [ 24 | "song__index", 25 | "numeric", 26 | ] 27 | } 28 | 29 | [overlay] 30 | Image song_cover { 31 | pixel-size: 30; 32 | overflow: hidden; 33 | halign: center; 34 | valign: center; 35 | 36 | styles [ 37 | "song__cover", 38 | ] 39 | } 40 | 41 | [overlay] 42 | Spinner song_icon { 43 | halign: center; 44 | valign: center; 45 | 46 | styles [ 47 | "song__icon", 48 | ] 49 | } 50 | 51 | [overlay] 52 | CheckButton song_checkbox { 53 | halign: center; 54 | valign: center; 55 | 56 | styles [ 57 | "song__checkbox", 58 | ] 59 | } 60 | } 61 | 62 | Label song_title { 63 | label: "Title"; 64 | ellipsize: middle; 65 | max-width-chars: 50; 66 | xalign: 0; 67 | yalign: 1; 68 | hexpand: true; 69 | 70 | layout { 71 | column-span: "2"; 72 | column: "1"; 73 | row: "0"; 74 | } 75 | 76 | styles [ 77 | "title", 78 | ] 79 | } 80 | 81 | Label song_artist { 82 | label: "Artist"; 83 | ellipsize: middle; 84 | max-width-chars: 35; 85 | xalign: 0; 86 | hexpand: true; 87 | 88 | layout { 89 | column-span: "1"; 90 | column: "1"; 91 | row: "1"; 92 | } 93 | 94 | styles [ 95 | "subtitle", 96 | ] 97 | } 98 | 99 | Label song_length { 100 | sensitive: false; 101 | label: "0∶00"; 102 | justify: right; 103 | max-width-chars: 7; 104 | xalign: 1; 105 | hexpand: false; 106 | 107 | layout { 108 | row-span: "2"; 109 | column: "3"; 110 | row: "0"; 111 | } 112 | 113 | styles [ 114 | "numeric", 115 | ] 116 | } 117 | 118 | MenuButton menu_btn { 119 | focus-on-click: false; 120 | receives-default: true; 121 | icon-name: "view-more-symbolic"; 122 | has-frame: false; 123 | hexpand: false; 124 | halign: end; 125 | valign: center; 126 | tooltip-text: "Menu"; 127 | 128 | layout { 129 | row-span: "2"; 130 | column: "4"; 131 | row: "0"; 132 | } 133 | 134 | styles [ 135 | "circular", 136 | "flat", 137 | ] 138 | } 139 | 140 | styles [ 141 | "song", 142 | ] 143 | } 144 | -------------------------------------------------------------------------------- /src/app/components/playlist/song.css: -------------------------------------------------------------------------------- 1 | .playlist .song__index { 2 | transition: opacity 150ms ease; 3 | margin: 6px 12px; 4 | padding: 0; 5 | opacity: 1; 6 | min-width: 1.5em; 7 | } 8 | 9 | .song__cover { 10 | border-radius: 6px; 11 | border: 1px solid @card_shade_color; 12 | } 13 | 14 | .album__tracks .song__cover { 15 | opacity: 0; 16 | } 17 | 18 | 19 | /* playback indicator */ 20 | 21 | .song--playing .song__icon { 22 | opacity: 1; 23 | animation: playing 1s linear infinite; 24 | color: @accent_bg_color; 25 | min-width: 16px; 26 | -gtk-icon-source: -gtk-icontheme("playback-0-symbolic"); 27 | } 28 | 29 | @keyframes playing { 30 | 0% { 31 | -gtk-icon-source: -gtk-icontheme("playback-0-symbolic"); 32 | } 33 | 34 | 6% { 35 | -gtk-icon-source: -gtk-icontheme("playback-1-symbolic"); 36 | } 37 | 38 | 12% { 39 | -gtk-icon-source: -gtk-icontheme("playback-2-symbolic"); 40 | } 41 | 42 | 18% { 43 | -gtk-icon-source: -gtk-icontheme("playback-3-symbolic"); 44 | } 45 | 46 | 24% { 47 | -gtk-icon-source: -gtk-icontheme("playback-4-symbolic"); 48 | } 49 | 50 | 30% { 51 | -gtk-icon-source: -gtk-icontheme("playback-5-symbolic"); 52 | } 53 | 54 | 36% { 55 | -gtk-icon-source: -gtk-icontheme("playback-6-symbolic"); 56 | } 57 | 58 | 42% { 59 | -gtk-icon-source: -gtk-icontheme("playback-7-symbolic"); 60 | } 61 | 62 | 49% { 63 | -gtk-icon-source: -gtk-icontheme("playback-8-symbolic"); 64 | } 65 | 66 | 54% { 67 | -gtk-icon-source: -gtk-icontheme("playback-9-symbolic"); 68 | } 69 | 70 | 60% { 71 | -gtk-icon-source: -gtk-icontheme("playback-10-symbolic"); 72 | } 73 | 74 | 66% { 75 | -gtk-icon-source: -gtk-icontheme("playback-11-symbolic"); 76 | } 77 | 78 | 72% { 79 | -gtk-icon-source: -gtk-icontheme("playback-12-symbolic"); 80 | } 81 | 82 | 79% { 83 | -gtk-icon-source: -gtk-icontheme("playback-13-symbolic"); 84 | } 85 | 86 | 85% { 87 | -gtk-icon-source: -gtk-icontheme("playback-14-symbolic"); 88 | } 89 | 90 | 90% { 91 | -gtk-icon-source: -gtk-icontheme("playback-15-symbolic"); 92 | } 93 | 94 | 96% { 95 | -gtk-icon-source: -gtk-icontheme("playback-16-symbolic"); 96 | } 97 | 98 | 100% { 99 | -gtk-icon-source: -gtk-icontheme("playback-0-symbolic"); 100 | } 101 | } 102 | 103 | .playlist--paused .song--playing .song__icon { 104 | animation: none; 105 | -gtk-icon-source: -gtk-icontheme("playback-paused-symbolic"); 106 | } 107 | 108 | .song__icon, 109 | .song__checkbox, 110 | .song--playing .song__index, 111 | .song--playing .song__cover, 112 | .playlist--selectable .song__index, 113 | .playlist--selectable .song__cover, 114 | .playlist--selectable .song__icon { 115 | transition: opacity 150ms ease; 116 | opacity: 0; 117 | } 118 | 119 | 120 | .playlist--selectable .song__checkbox, 121 | .playlist--selectable .song__checkbox check { 122 | opacity: 1; 123 | filter: none; 124 | } 125 | 126 | 127 | row:hover .song__menu--enabled, .song__menu--enabled:checked { 128 | opacity: 1; 129 | } 130 | 131 | 132 | /* Song Labels */ 133 | .song--playing label.title { 134 | font-weight: bold; 135 | } 136 | 137 | /* "Context Menu" */ 138 | .song__menu { 139 | opacity: 0; 140 | } 141 | 142 | .song__menu--enabled { 143 | opacity: 0.2; 144 | } 145 | 146 | 147 | /* Song boxed list styling */ 148 | 149 | .playlist { 150 | background: transparent; 151 | } 152 | 153 | .playlist row { 154 | background: @card_bg_color; 155 | margin-left: 12px; 156 | margin-right: 12px; 157 | box-shadow: 1px 0px 3px rgba(0, 0, 0, 0.07), -1px 0px 3px rgba(0, 0, 0, 0.07); 158 | transition: background-color 150ms ease; 159 | } 160 | 161 | .playlist row:hover { 162 | background-image: image(alpha(currentColor, 0.03)); 163 | } 164 | 165 | .playlist row:active { 166 | background-image: image(alpha(currentColor, 0.08)); 167 | } 168 | 169 | 170 | .playlist row:first-child { 171 | margin-top: 12px; 172 | border-radius: 12px 12px 0 0; 173 | } 174 | 175 | .playlist row:last-child { 176 | margin-bottom: 12px; 177 | border-bottom-color: rgba(0, 0, 0, 0); 178 | border-radius: 0 0 12px 12px; 179 | box-shadow: 0px 1px 3px rgba(0, 0, 0, 0.21); 180 | } 181 | 182 | .playlist row:only-child { 183 | margin-top: 12px; 184 | margin-bottom: 12px; 185 | border-radius: 12px 12px 12px 12px; 186 | } -------------------------------------------------------------------------------- /src/app/components/playlist/song_actions.rs: -------------------------------------------------------------------------------- 1 | use gdk::prelude::*; 2 | use gio::SimpleAction; 3 | 4 | use crate::app::models::SongDescription; 5 | use crate::app::state::{AppAction, PlaybackAction}; 6 | use crate::app::ActionDispatcher; 7 | 8 | impl SongDescription { 9 | pub fn make_queue_action( 10 | &self, 11 | dispatcher: Box, 12 | name: Option<&str>, 13 | ) -> SimpleAction { 14 | let queue = SimpleAction::new(name.unwrap_or("queue"), None); 15 | let song = self.clone(); 16 | queue.connect_activate(move |_, _| { 17 | dispatcher.dispatch(PlaybackAction::Queue(vec![song.clone()]).into()); 18 | }); 19 | queue 20 | } 21 | 22 | pub fn make_dequeue_action( 23 | &self, 24 | dispatcher: Box, 25 | name: Option<&str>, 26 | ) -> SimpleAction { 27 | let dequeue = SimpleAction::new(name.unwrap_or("dequeue"), None); 28 | let track_id = self.id.clone(); 29 | dequeue.connect_activate(move |_, _| { 30 | dispatcher.dispatch(PlaybackAction::Dequeue(track_id.clone()).into()); 31 | }); 32 | dequeue 33 | } 34 | 35 | pub fn make_link_action(&self, name: Option<&str>) -> SimpleAction { 36 | let track_id = self.id.clone(); 37 | let copy_link = SimpleAction::new(name.unwrap_or("copy_link"), None); 38 | copy_link.connect_activate(move |_, _| { 39 | let link = format!("https://open.spotify.com/track/{track_id}"); 40 | let clipboard = gdk::Display::default().unwrap().clipboard(); 41 | clipboard 42 | .set_content(Some(&gdk::ContentProvider::for_value(&link.to_value()))) 43 | .expect("Failed to set clipboard content"); 44 | }); 45 | copy_link 46 | } 47 | 48 | pub fn make_album_action( 49 | &self, 50 | dispatcher: Box, 51 | name: Option<&str>, 52 | ) -> SimpleAction { 53 | let album_id = self.album.id.clone(); 54 | let view_album = SimpleAction::new(name.unwrap_or("view_album"), None); 55 | view_album.connect_activate(move |_, _| { 56 | dispatcher.dispatch(AppAction::ViewAlbum(album_id.clone())); 57 | }); 58 | view_album 59 | } 60 | 61 | pub fn make_artist_actions( 62 | &self, 63 | dispatcher: Box, 64 | prefix: Option<&str>, 65 | ) -> Vec { 66 | self.artists 67 | .iter() 68 | .map(|artist| { 69 | let id = artist.id.clone(); 70 | let view_artist = SimpleAction::new( 71 | &format!("{}_{}", prefix.unwrap_or("view_artist"), &id), 72 | None, 73 | ); 74 | let dispatcher = dispatcher.box_clone(); 75 | view_artist.connect_activate(move |_, _| { 76 | dispatcher.dispatch(AppAction::ViewArtist(id.clone())); 77 | }); 78 | view_artist 79 | }) 80 | .collect() 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /src/app/components/playlist_details/mod.rs: -------------------------------------------------------------------------------- 1 | #[allow(clippy::module_inception)] 2 | mod playlist_details; 3 | mod playlist_details_model; 4 | mod playlist_header; 5 | mod playlist_headerbar; 6 | 7 | pub use playlist_details::*; 8 | pub use playlist_details_model::*; 9 | 10 | use gtk::prelude::StaticType; 11 | 12 | pub fn expose_widgets() { 13 | playlist_headerbar::PlaylistHeaderBarWidget::static_type(); 14 | } 15 | -------------------------------------------------------------------------------- /src/app/components/playlist_details/playlist_details.blp: -------------------------------------------------------------------------------- 1 | using Gtk 4.0; 2 | using Adw 1; 3 | 4 | template $PlaylistDetailsWidget : Adw.Bin { 5 | Box { 6 | orientation: vertical; 7 | vexpand: true; 8 | hexpand: true; 9 | 10 | $PlaylistHeaderBarWidget headerbar { 11 | } 12 | 13 | $ScrollingHeaderWidget scrolling_header { 14 | [header] 15 | WindowHandle { 16 | Adw.Clamp { 17 | maximum-size: 900; 18 | styles [ 19 | "playlist_details__clamp", 20 | ] 21 | 22 | Adw.BreakpointBin { 23 | width-request: 1; 24 | height-request: 1; 25 | 26 | Adw.Breakpoint { 27 | condition("max-width:500sp") 28 | 29 | setters { 30 | header_widget.vertical-layout: true; 31 | } 32 | } 33 | 34 | $PlaylistHeaderWidget header_widget {} 35 | } 36 | } 37 | } 38 | 39 | Adw.ClampScrollable { 40 | maximum-size: 900; 41 | 42 | ListView tracks { 43 | } 44 | } 45 | 46 | styles [ 47 | "container", 48 | ] 49 | } 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/app/components/playlist_details/playlist_header.blp: -------------------------------------------------------------------------------- 1 | using Gtk 4.0; 2 | 3 | template $PlaylistHeaderWidget : Box { 4 | valign: start; 5 | vexpand: false; 6 | margin-start: 6; 7 | margin-end: 6; 8 | margin-bottom: 6; 9 | 10 | Box playlist_image_box { 11 | overflow: hidden; 12 | halign: center; 13 | margin-top: 18; 14 | margin-start: 6; 15 | margin-bottom: 6; 16 | 17 | Image playlist_art { 18 | width-request: 160; 19 | height-request: 160; 20 | icon-name: "emblem-music-symbolic"; 21 | } 22 | 23 | styles [ 24 | "card", 25 | ] 26 | } 27 | 28 | Box playlist_info { 29 | hexpand: true; 30 | valign: center; 31 | orientation: vertical; 32 | spacing: 6; 33 | margin-start: 18; 34 | 35 | Entry playlist_label_entry { 36 | hexpand: false; 37 | halign: start; 38 | editable: false; 39 | can-focus: false; 40 | placeholder-text: "Playlist Title"; 41 | 42 | styles [ 43 | "title-1", 44 | "playlist__title-entry", 45 | "playlist__title-entry--ro", 46 | ] 47 | } 48 | 49 | LinkButton author_button { 50 | receives-default: true; 51 | halign: start; 52 | valign: center; 53 | has-frame: false; 54 | 55 | Label author_button_label { 56 | hexpand: true; 57 | vexpand: true; 58 | label: "Artist"; 59 | ellipsize: middle; 60 | } 61 | 62 | styles [ 63 | "title-4", 64 | ] 65 | } 66 | } 67 | Button play_button { 68 | margin-end: 6; 69 | receives-default: true; 70 | halign: center; 71 | valign: center; 72 | tooltip-text: "Play"; 73 | icon-name: "media-playback-start-symbolic"; 74 | 75 | styles [ 76 | "circular", 77 | "play__button", 78 | ] 79 | } 80 | 81 | styles [ 82 | "playlist__header", 83 | ] 84 | } 85 | -------------------------------------------------------------------------------- /src/app/components/playlist_details/playlist_header.css: -------------------------------------------------------------------------------- 1 | .playlist__header .title-4 label { 2 | color: @window_fg_color; 3 | font-weight: bold; 4 | text-decoration: none; 5 | } 6 | 7 | .playlist__header .title-4:hover { 8 | border-radius: 6px; 9 | background-image: image(alpha(currentColor, 0.08)); 10 | } 11 | 12 | clamp.playlist_details__clamp { 13 | background-color: @view_bg_color; 14 | box-shadow: inset 0px -1px 0px @borders; 15 | } 16 | 17 | headerbar.playlist_details__headerbar { 18 | transition: background-color .3s ease; 19 | } 20 | 21 | headerbar.flat.playlist_details__headerbar windowtitle { 22 | opacity: 0; 23 | } 24 | 25 | headerbar.playlist_details__headerbar windowtitle { 26 | transition: opacity .3s ease; 27 | opacity: 1; 28 | } 29 | 30 | .playlist_details__headerbar.flat { 31 | background-color: @view_bg_color; 32 | } 33 | -------------------------------------------------------------------------------- /src/app/components/playlist_details/playlist_headerbar.blp: -------------------------------------------------------------------------------- 1 | using Gtk 4.0; 2 | using Adw 1; 3 | 4 | template $PlaylistHeaderBarWidget : Adw.Bin { 5 | [root] 6 | Overlay overlay { 7 | hexpand: true; 8 | 9 | Adw.HeaderBar main_header { 10 | show-end-title-buttons: true; 11 | show-back-button: false; 12 | 13 | Button go_back { 14 | receives-default: true; 15 | halign: start; 16 | valign: center; 17 | icon-name: "go-previous-symbolic"; 18 | has-frame: false; 19 | } 20 | 21 | [title] 22 | Adw.WindowTitle title { 23 | visible: false; 24 | title: "Spot"; 25 | } 26 | 27 | [end] 28 | Button edit { 29 | icon-name: "document-edit-symbolic"; 30 | } 31 | 32 | styles [ 33 | "playlist_details__headerbar", 34 | ] 35 | } 36 | 37 | [overlay] 38 | Adw.HeaderBar edition_header { 39 | show-end-title-buttons: false; 40 | show-start-title-buttons: false; 41 | visible: false; 42 | 43 | styles [ 44 | "selection-mode", 45 | ] 46 | 47 | Button cancel { 48 | receives-default: true; 49 | halign: start; 50 | valign: center; 51 | 52 | /* Translators: Exit playlist edition */ 53 | 54 | label: _("Cancel"); 55 | } 56 | 57 | [title] 58 | Separator { 59 | styles [ 60 | "spacer", 61 | ] 62 | } 63 | 64 | [end] 65 | Button ok { 66 | valign: center; 67 | 68 | /* Translators: Finish playlist edition */ 69 | 70 | label: _("Done"); 71 | 72 | styles [ 73 | "suggested-action", 74 | ] 75 | } 76 | } 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /src/app/components/saved_playlists/mod.rs: -------------------------------------------------------------------------------- 1 | #[allow(clippy::module_inception)] 2 | mod saved_playlists; 3 | mod saved_playlists_model; 4 | 5 | pub use saved_playlists::*; 6 | pub use saved_playlists_model::*; 7 | -------------------------------------------------------------------------------- /src/app/components/saved_playlists/saved_playlists.blp: -------------------------------------------------------------------------------- 1 | using Gtk 4.0; 2 | using Adw 1; 3 | 4 | template $SavedPlaylistsWidget : Box { 5 | ScrolledWindow scrolled_window { 6 | hexpand: true; 7 | vexpand: true; 8 | vscrollbar-policy: always; 9 | min-content-width: 250; 10 | 11 | Overlay overlay { 12 | FlowBox flowbox { 13 | margin-start: 8; 14 | margin-end: 8; 15 | margin-top: 8; 16 | margin-bottom: 8; 17 | min-children-per-line: 1; 18 | selection-mode: none; 19 | activate-on-single-click: false; 20 | } 21 | 22 | [overlay] 23 | Adw.StatusPage status_page { 24 | /* Translators: A title that is shown when the user has not saved any playlists. */ 25 | 26 | title: _("You have no saved playlists."); 27 | 28 | /* Translators: A description of what happens when the user has saved playlists. */ 29 | 30 | description: _("Your playlists will be shown here."); 31 | icon-name: "emblem-music-symbolic"; 32 | visible: true; 33 | } 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/app/components/saved_playlists/saved_playlists_model.rs: -------------------------------------------------------------------------------- 1 | use std::cell::Ref; 2 | use std::ops::Deref; 3 | use std::rc::Rc; 4 | 5 | use crate::app::models::*; 6 | use crate::app::state::HomeState; 7 | use crate::app::{ActionDispatcher, AppAction, AppModel, BrowserAction, ListStore}; 8 | 9 | pub struct SavedPlaylistsModel { 10 | app_model: Rc, 11 | dispatcher: Box, 12 | } 13 | 14 | impl SavedPlaylistsModel { 15 | pub fn new(app_model: Rc, dispatcher: Box) -> Self { 16 | Self { 17 | app_model, 18 | dispatcher, 19 | } 20 | } 21 | 22 | fn state(&self) -> Option> { 23 | self.app_model.map_state_opt(|s| s.browser.home_state()) 24 | } 25 | 26 | pub fn get_list_store(&self) -> Option> + '_> { 27 | Some(Ref::map(self.state()?, |s| &s.playlists)) 28 | } 29 | 30 | pub fn refresh_saved_playlists(&self) -> Option<()> { 31 | let api = self.app_model.get_spotify(); 32 | let batch_size = self.state()?.next_playlists_page.batch_size; 33 | 34 | self.dispatcher 35 | .call_spotify_and_dispatch(move || async move { 36 | api.get_saved_playlists(0, batch_size) 37 | .await 38 | .map(|playlists| BrowserAction::SetPlaylistsContent(playlists).into()) 39 | }); 40 | 41 | Some(()) 42 | } 43 | 44 | pub fn has_playlists(&self) -> bool { 45 | self.get_list_store() 46 | .map(|list| list.len() > 0) 47 | .unwrap_or(false) 48 | } 49 | 50 | pub fn load_more_playlists(&self) -> Option<()> { 51 | let api = self.app_model.get_spotify(); 52 | 53 | let next_page = &self.state()?.next_playlists_page; 54 | let batch_size = next_page.batch_size; 55 | let offset = next_page.next_offset?; 56 | 57 | self.dispatcher 58 | .call_spotify_and_dispatch(move || async move { 59 | api.get_saved_playlists(offset, batch_size) 60 | .await 61 | .map(|playlists| BrowserAction::AppendPlaylistsContent(playlists).into()) 62 | }); 63 | 64 | Some(()) 65 | } 66 | 67 | pub fn open_playlist(&self, id: String) { 68 | self.dispatcher.dispatch(AppAction::ViewPlaylist(id)); 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/app/components/saved_tracks/mod.rs: -------------------------------------------------------------------------------- 1 | #[allow(clippy::module_inception)] 2 | mod saved_tracks; 3 | pub use saved_tracks::*; 4 | 5 | mod saved_tracks_model; 6 | pub use saved_tracks_model::*; 7 | -------------------------------------------------------------------------------- /src/app/components/saved_tracks/saved_tracks.blp: -------------------------------------------------------------------------------- 1 | using Gtk 4.0; 2 | using Adw 1; 3 | 4 | template $SavedTracksWidget : Adw.Bin { 5 | ScrolledWindow scrolled_window { 6 | vexpand: true; 7 | 8 | Adw.ClampScrollable { 9 | maximum-size: 900; 10 | 11 | ListView song_list { 12 | } 13 | } 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/app/components/saved_tracks/saved_tracks.rs: -------------------------------------------------------------------------------- 1 | use gtk::prelude::*; 2 | use gtk::subclass::prelude::*; 3 | use gtk::CompositeTemplate; 4 | use std::rc::Rc; 5 | 6 | use super::SavedTracksModel; 7 | use crate::app::components::{Component, EventListener, Playlist}; 8 | use crate::app::state::LoginEvent; 9 | use crate::app::{AppEvent, Worker}; 10 | use libadwaita::subclass::prelude::BinImpl; 11 | 12 | mod imp { 13 | 14 | use super::*; 15 | 16 | #[derive(Debug, Default, CompositeTemplate)] 17 | #[template(resource = "/dev/alextren/Spot/components/saved_tracks.ui")] 18 | pub struct SavedTracksWidget { 19 | #[template_child] 20 | pub song_list: TemplateChild, 21 | 22 | #[template_child] 23 | pub scrolled_window: TemplateChild, 24 | } 25 | 26 | #[glib::object_subclass] 27 | impl ObjectSubclass for SavedTracksWidget { 28 | const NAME: &'static str = "SavedTracksWidget"; 29 | type Type = super::SavedTracksWidget; 30 | type ParentType = libadwaita::Bin; 31 | 32 | fn class_init(klass: &mut Self::Class) { 33 | klass.bind_template(); 34 | } 35 | 36 | fn instance_init(obj: &glib::subclass::InitializingObject) { 37 | obj.init_template(); 38 | } 39 | } 40 | 41 | impl ObjectImpl for SavedTracksWidget {} 42 | impl WidgetImpl for SavedTracksWidget {} 43 | impl BinImpl for SavedTracksWidget {} 44 | } 45 | 46 | glib::wrapper! { 47 | pub struct SavedTracksWidget(ObjectSubclass) @extends gtk::Widget, libadwaita::Bin; 48 | } 49 | 50 | impl SavedTracksWidget { 51 | fn new() -> Self { 52 | glib::Object::new() 53 | } 54 | 55 | fn connect_bottom_edge(&self, f: F) 56 | where 57 | F: Fn() + 'static, 58 | { 59 | self.imp() 60 | .scrolled_window 61 | .connect_edge_reached(move |_, pos| { 62 | if let gtk::PositionType::Bottom = pos { 63 | f() 64 | } 65 | }); 66 | } 67 | 68 | fn song_list_widget(&self) -> >k::ListView { 69 | self.imp().song_list.as_ref() 70 | } 71 | } 72 | 73 | pub struct SavedTracks { 74 | widget: SavedTracksWidget, 75 | model: Rc, 76 | children: Vec>, 77 | } 78 | 79 | impl SavedTracks { 80 | pub fn new(model: Rc, worker: Worker) -> Self { 81 | let widget = SavedTracksWidget::new(); 82 | 83 | widget.connect_bottom_edge(clone!( 84 | #[weak] 85 | model, 86 | move || { 87 | model.load_more(); 88 | } 89 | )); 90 | 91 | let playlist = Playlist::new(widget.song_list_widget().clone(), model.clone(), worker); 92 | 93 | Self { 94 | widget, 95 | model, 96 | children: vec![Box::new(playlist)], 97 | } 98 | } 99 | } 100 | 101 | impl Component for SavedTracks { 102 | fn get_root_widget(&self) -> >k::Widget { 103 | self.widget.upcast_ref() 104 | } 105 | 106 | fn get_children(&mut self) -> Option<&mut Vec>> { 107 | Some(&mut self.children) 108 | } 109 | } 110 | 111 | impl EventListener for SavedTracks { 112 | fn on_event(&mut self, event: &AppEvent) { 113 | match event { 114 | AppEvent::Started | AppEvent::LoginEvent(LoginEvent::LoginCompleted) => { 115 | self.model.load_initial(); 116 | } 117 | _ => {} 118 | } 119 | self.broadcast_event(event); 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /src/app/components/scrolling_header/mod.rs: -------------------------------------------------------------------------------- 1 | mod scrolling_header_widget; 2 | use gtk::prelude::StaticType; 3 | pub use scrolling_header_widget::*; 4 | 5 | pub fn expose_widgets() { 6 | scrolling_header_widget::ScrollingHeaderWidget::static_type(); 7 | } 8 | -------------------------------------------------------------------------------- /src/app/components/scrolling_header/scrolling_header.blp: -------------------------------------------------------------------------------- 1 | using Gtk 4.0; 2 | 3 | template $ScrollingHeaderWidget : Box { 4 | orientation: vertical; 5 | vexpand: true; 6 | hexpand: true; 7 | 8 | [internal] 9 | Revealer revealer { 10 | transition-type: slide_up; 11 | } 12 | 13 | [internal] 14 | ScrolledWindow scrolled_window { 15 | hscrollbar-policy: never; 16 | propagate-natural-width: true; 17 | hexpand: true; 18 | vexpand: true; 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/app/components/scrolling_header/scrolling_header_widget.rs: -------------------------------------------------------------------------------- 1 | use gtk::prelude::*; 2 | use gtk::subclass::prelude::*; 3 | use gtk::CompositeTemplate; 4 | 5 | mod imp { 6 | 7 | use super::*; 8 | 9 | #[derive(Debug, Default, CompositeTemplate)] 10 | #[template(resource = "/dev/alextren/Spot/components/scrolling_header.ui")] 11 | pub struct ScrollingHeaderWidget { 12 | #[template_child] 13 | pub scrolled_window: TemplateChild, 14 | 15 | #[template_child] 16 | pub revealer: TemplateChild, 17 | } 18 | 19 | #[glib::object_subclass] 20 | impl ObjectSubclass for ScrollingHeaderWidget { 21 | const NAME: &'static str = "ScrollingHeaderWidget"; 22 | type Type = super::ScrollingHeaderWidget; 23 | type ParentType = gtk::Box; 24 | type Interfaces = (gtk::Buildable,); 25 | 26 | fn class_init(klass: &mut Self::Class) { 27 | klass.bind_template(); 28 | } 29 | 30 | fn instance_init(obj: &glib::subclass::InitializingObject) { 31 | obj.init_template(); 32 | } 33 | } 34 | 35 | impl ObjectImpl for ScrollingHeaderWidget {} 36 | 37 | impl BuildableImpl for ScrollingHeaderWidget { 38 | fn add_child(&self, builder: >k::Builder, child: &glib::Object, type_: Option<&str>) { 39 | let child_widget = child.downcast_ref::(); 40 | match type_ { 41 | Some("internal") => self.parent_add_child(builder, child, type_), 42 | Some("header") => self.revealer.set_child(child_widget), 43 | _ => self.scrolled_window.set_child(child_widget), 44 | } 45 | } 46 | } 47 | 48 | impl WidgetImpl for ScrollingHeaderWidget {} 49 | impl BoxImpl for ScrollingHeaderWidget {} 50 | } 51 | 52 | glib::wrapper! { 53 | pub struct ScrollingHeaderWidget(ObjectSubclass) @extends gtk::Widget, gtk::Box; 54 | } 55 | 56 | impl ScrollingHeaderWidget { 57 | fn set_header_visible(&self, visible: bool) -> bool { 58 | let widget = self.imp(); 59 | let is_up_to_date = widget.revealer.reveals_child() == visible; 60 | if !is_up_to_date { 61 | widget.revealer.set_reveal_child(visible); 62 | } 63 | is_up_to_date 64 | } 65 | 66 | fn is_scrolled_to_top(&self) -> bool { 67 | self.imp().scrolled_window.vadjustment().value() <= f64::EPSILON 68 | || self.imp().revealer.reveals_child() 69 | } 70 | 71 | pub fn connect_header_visibility(&self, f: F) 72 | where 73 | F: Fn(bool) + Clone + 'static, 74 | { 75 | self.set_header_visible(true); 76 | f(true); 77 | 78 | let scroll_controller = 79 | gtk::EventControllerScroll::new(gtk::EventControllerScrollFlags::VERTICAL); 80 | scroll_controller.connect_scroll(clone!( 81 | #[strong] 82 | f, 83 | #[weak(rename_to = _self)] 84 | self, 85 | #[upgrade_or] 86 | glib::Propagation::Proceed, 87 | move |_, _, dy| { 88 | let visible = dy < 0f64 && _self.is_scrolled_to_top(); 89 | f(visible); 90 | if _self.set_header_visible(visible) { 91 | glib::Propagation::Proceed 92 | } else { 93 | glib::Propagation::Stop 94 | } 95 | } 96 | )); 97 | 98 | let swipe_controller = gtk::GestureSwipe::new(); 99 | swipe_controller.set_touch_only(true); 100 | swipe_controller.set_propagation_phase(gtk::PropagationPhase::Capture); 101 | swipe_controller.connect_swipe(clone!( 102 | #[weak(rename_to = _self)] 103 | self, 104 | move |_, _, dy| { 105 | let visible = dy >= 0f64 && _self.is_scrolled_to_top(); 106 | f(visible); 107 | _self.set_header_visible(visible); 108 | } 109 | )); 110 | 111 | self.imp().scrolled_window.add_controller(scroll_controller); 112 | self.add_controller(swipe_controller); 113 | } 114 | 115 | pub fn connect_bottom_edge(&self, f: F) 116 | where 117 | F: Fn() + 'static, 118 | { 119 | self.imp() 120 | .scrolled_window 121 | .connect_edge_reached(move |_, pos| { 122 | if let gtk::PositionType::Bottom = pos { 123 | f() 124 | } 125 | }); 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /src/app/components/search/mod.rs: -------------------------------------------------------------------------------- 1 | #[allow(clippy::module_inception)] 2 | mod search; 3 | pub use search::*; 4 | 5 | mod search_model; 6 | pub use search_model::*; 7 | 8 | mod search_button; 9 | pub use search_button::*; 10 | -------------------------------------------------------------------------------- /src/app/components/search/search.blp: -------------------------------------------------------------------------------- 1 | using Gtk 4.0; 2 | using Adw 1; 3 | 4 | template $SearchResultsWidget : Box { 5 | orientation: vertical; 6 | can-focus: true; 7 | 8 | Adw.HeaderBar main_header { 9 | show-end-title-buttons: true; 10 | show-back-button: false; 11 | 12 | Button go_back { 13 | halign: start; 14 | valign: center; 15 | icon-name: "go-previous-symbolic"; 16 | has-frame: false; 17 | } 18 | 19 | [title] 20 | SearchEntry search_entry { 21 | receives-default: true; 22 | can-focus: true; 23 | } 24 | } 25 | 26 | Overlay overlay { 27 | hexpand: true; 28 | vexpand: true; 29 | 30 | ScrolledWindow search_results { 31 | visible: false; 32 | hexpand: true; 33 | vexpand: true; 34 | hscrollbar-policy: never; 35 | Box { 36 | vexpand: false; 37 | margin-start: 8; 38 | margin-end: 8; 39 | margin-top: 8; 40 | margin-bottom: 8; 41 | orientation: vertical; 42 | spacing: 8; 43 | 44 | Expander { 45 | margin-start: 4; 46 | margin-end: 4; 47 | expanded: true; 48 | vexpand: false; 49 | valign: start; 50 | 51 | ScrolledWindow { 52 | vscrollbar-policy: never; 53 | propagate-natural-height: false; 54 | FlowBox albums_results { 55 | halign: start; 56 | hexpand: true; 57 | vexpand: false; 58 | valign: start; 59 | orientation: vertical; 60 | max-children-per-line: 1; 61 | selection-mode: none; 62 | activate-on-single-click: false; 63 | } 64 | } 65 | 66 | [label] 67 | Label { 68 | /* Translators: This is the title of a section of the search results */ 69 | 70 | label: _("Albums"); 71 | } 72 | } 73 | 74 | Expander { 75 | margin-start: 4; 76 | margin-end: 4; 77 | margin-bottom: 4; 78 | expanded: true; 79 | vexpand: false; 80 | valign: start; 81 | 82 | ScrolledWindow { 83 | vscrollbar-policy: never; 84 | propagate-natural-height: false; 85 | FlowBox artist_results { 86 | halign: start; 87 | hexpand: true; 88 | vexpand: false; 89 | valign: start; 90 | orientation: vertical; 91 | max-children-per-line: 1; 92 | selection-mode: none; 93 | activate-on-single-click: false; 94 | } 95 | } 96 | 97 | [label] 98 | Label { 99 | /* Translators: This is the title of a section of the search results */ 100 | 101 | label: _("Artists"); 102 | } 103 | } 104 | } 105 | } 106 | 107 | [overlay] 108 | Adw.StatusPage status_page { 109 | /* Translators: Title for the empty search page (initial state). */ 110 | 111 | title: _("Search Spotify."); 112 | 113 | /* Translators: Subtitle for the empty search page (initial state). */ 114 | 115 | description: _("Type to search."); 116 | icon-name: "system-search-symbolic"; 117 | visible: true; 118 | } 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /src/app/components/search/search_button.rs: -------------------------------------------------------------------------------- 1 | use gtk::prelude::*; 2 | 3 | use crate::app::components::EventListener; 4 | use crate::app::{ActionDispatcher, AppAction}; 5 | 6 | pub struct SearchBarModel(pub Box); 7 | 8 | impl SearchBarModel { 9 | pub fn navigate_to_search(&self) { 10 | self.0.dispatch(AppAction::ViewSearch()); 11 | } 12 | } 13 | 14 | pub struct SearchButton; 15 | 16 | impl SearchButton { 17 | pub fn new(model: SearchBarModel, search_button: gtk::Button) -> Self { 18 | search_button.connect_clicked(move |_| { 19 | model.navigate_to_search(); 20 | }); 21 | 22 | Self 23 | } 24 | } 25 | 26 | impl EventListener for SearchButton {} 27 | -------------------------------------------------------------------------------- /src/app/components/search/search_model.rs: -------------------------------------------------------------------------------- 1 | use std::ops::Deref; 2 | use std::rc::Rc; 3 | 4 | use crate::app::dispatch::ActionDispatcher; 5 | use crate::app::models::*; 6 | use crate::app::state::{AppAction, AppModel, BrowserAction}; 7 | 8 | pub struct SearchResultsModel { 9 | app_model: Rc, 10 | dispatcher: Box, 11 | } 12 | 13 | impl SearchResultsModel { 14 | pub fn new(app_model: Rc, dispatcher: Box) -> Self { 15 | Self { 16 | app_model, 17 | dispatcher, 18 | } 19 | } 20 | 21 | pub fn go_back(&self) { 22 | self.dispatcher 23 | .dispatch(BrowserAction::NavigationPop.into()); 24 | } 25 | 26 | pub fn search(&self, query: String) { 27 | self.dispatcher 28 | .dispatch(BrowserAction::Search(query).into()); 29 | } 30 | 31 | fn get_query(&self) -> Option + '_> { 32 | self.app_model 33 | .map_state_opt(|s| Some(&s.browser.search_state()?.query).filter(|s| !s.is_empty())) 34 | } 35 | 36 | pub fn fetch_results(&self) { 37 | let api = self.app_model.get_spotify(); 38 | if let Some(query) = self.get_query() { 39 | let query = query.to_owned(); 40 | self.dispatcher 41 | .call_spotify_and_dispatch(move || async move { 42 | api.search(&query, 0, 5) 43 | .await 44 | .map(|results| BrowserAction::SetSearchResults(Box::new(results)).into()) 45 | }); 46 | } 47 | } 48 | 49 | pub fn get_album_results(&self) -> Option> + '_> { 50 | self.app_model 51 | .map_state_opt(|s| Some(&s.browser.search_state()?.album_results)) 52 | } 53 | 54 | pub fn get_artist_results(&self) -> Option> + '_> { 55 | self.app_model 56 | .map_state_opt(|s| Some(&s.browser.search_state()?.artist_results)) 57 | } 58 | 59 | pub fn open_album(&self, id: String) { 60 | self.dispatcher.dispatch(AppAction::ViewAlbum(id)); 61 | } 62 | 63 | pub fn open_artist(&self, id: String) { 64 | self.dispatcher.dispatch(AppAction::ViewArtist(id)); 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/app/components/selection/icons/music-queue-symbolic.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /src/app/components/selection/icons/playlist2-symbolic.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /src/app/components/selection/mod.rs: -------------------------------------------------------------------------------- 1 | mod widget; 2 | 3 | mod component; 4 | pub use component::*; 5 | 6 | use glib::prelude::*; 7 | 8 | pub fn expose_widgets() { 9 | widget::SelectionToolbarWidget::static_type(); 10 | } 11 | -------------------------------------------------------------------------------- /src/app/components/selection/selection_toolbar.blp: -------------------------------------------------------------------------------- 1 | using Gtk 4.0; 2 | using Adw 1; 3 | 4 | template $SelectionToolbarWidget : Box { 5 | hexpand: true; 6 | visible: false; 7 | 8 | Adw.BreakpointBin { 9 | width-request: 1; 10 | height-request: 1; 11 | 12 | Adw.Breakpoint { 13 | condition("max-width:500sp") 14 | 15 | setters { 16 | btn1.label: ""; 17 | btn2.label: ""; 18 | btn3.label: ""; 19 | } 20 | } 21 | 22 | ActionBar action_bar { 23 | hexpand: true; 24 | revealed: true; 25 | styles [ 26 | "selection_toolbar", 27 | ] 28 | 29 | Box { 30 | valign: center; 31 | 32 | styles [ 33 | "linked", 34 | ] 35 | 36 | Button move_up { 37 | icon-name: "go-up-symbolic"; 38 | } 39 | 40 | Button move_down { 41 | icon-name: "go-down-symbolic"; 42 | } 43 | } 44 | 45 | [end] 46 | Button queue { 47 | valign: center; 48 | has-frame: false; 49 | 50 | Adw.ButtonContent btn1 { 51 | icon-name: "music-queue-symbolic"; 52 | label: _("Add to queue"); 53 | } 54 | } 55 | 56 | [end] 57 | MenuButton add { 58 | valign: center; 59 | has-frame: false; 60 | label: _("Add to playlist..."); 61 | direction: up; 62 | } 63 | 64 | [end] 65 | Button remove { 66 | valign: center; 67 | has-frame: false; 68 | 69 | Adw.ButtonContent btn2 { 70 | icon-name: "user-trash-symbolic"; 71 | label: _("Remove"); 72 | } 73 | } 74 | 75 | [end] 76 | Button save { 77 | valign: center; 78 | has-frame: false; 79 | 80 | Adw.ButtonContent btn3 { 81 | icon-name: "star-new-symbolic"; 82 | label: _("Save to library"); 83 | } 84 | } 85 | } 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /src/app/components/selection/selection_toolbar.css: -------------------------------------------------------------------------------- 1 | .selection_toolbar { 2 | background: @theme_bg_color; 3 | } 4 | -------------------------------------------------------------------------------- /src/app/components/settings/mod.rs: -------------------------------------------------------------------------------- 1 | #[allow(clippy::module_inception)] 2 | mod settings; 3 | mod settings_model; 4 | 5 | pub use settings::*; 6 | pub use settings_model::*; 7 | -------------------------------------------------------------------------------- /src/app/components/settings/settings.blp: -------------------------------------------------------------------------------- 1 | using Gtk 4.0; 2 | using Adw 1; 3 | 4 | template $SettingsWindow : Adw.PreferencesDialog { 5 | search-enabled: false; 6 | 7 | Adw.PreferencesPage { 8 | Adw.PreferencesGroup { 9 | /* Translators: Header for a group of preference items regarding audio */ 10 | 11 | title: _("Audio"); 12 | 13 | Adw.ComboRow audio_backend { 14 | /* Translators: Title for an item in preferences */ 15 | 16 | title: _("Audio Backend"); 17 | model: StringList { 18 | strings [ 19 | "PulseAudio", 20 | "ALSA", 21 | "Pipewire (GStreamer)" 22 | ] 23 | }; 24 | } 25 | 26 | Adw.ActionRow alsa_device_row { 27 | /* Translators: Title for an item in preferences */ 28 | 29 | title: _("ALSA Device"); 30 | 31 | /* Translators: Description for the item (ALSA Device) in preferences */ 32 | 33 | subtitle: _("Applied only if audio backend is ALSA"); 34 | 35 | Entry alsa_device { 36 | valign: center; 37 | } 38 | } 39 | 40 | Adw.ComboRow player_bitrate { 41 | /* Translators: Title for an item in preferences */ 42 | 43 | title: _("Audio Quality"); 44 | model: StringList { 45 | strings [ 46 | _("Normal"), 47 | _("High"), 48 | _("Very high"), 49 | ] 50 | }; 51 | } 52 | 53 | Adw.ActionRow gapless_playback { 54 | /* Translators: Title for an item in preferences */ 55 | 56 | title: _("Gapless playback"); 57 | name: "gapless_playback_row"; 58 | activatable-widget: gapless_playback_switch; 59 | visible: true; 60 | 61 | Switch gapless_playback_switch { 62 | margin-top: 12; 63 | margin-bottom: 12; 64 | } 65 | } 66 | } 67 | 68 | Adw.PreferencesGroup { 69 | /* Translators: Header for a group of preference items regarding the application's appearance */ 70 | 71 | title: _("Appearance"); 72 | 73 | Adw.ComboRow theme { 74 | /* Translators: Title for an item in preferences */ 75 | 76 | title: _("Theme"); 77 | model: StringList { 78 | strings [ 79 | _("Light"), 80 | _("Dark"), 81 | _("System") 82 | ] 83 | }; 84 | } 85 | } 86 | 87 | Adw.PreferencesGroup { 88 | /* Translators: Header for a group of preference items regarding network */ 89 | 90 | title: _("Network"); 91 | 92 | Adw.ActionRow { 93 | /* Translators: Title for an item in preferences */ 94 | 95 | title: _("Access Point Port"); 96 | 97 | /* Translators: Longer description for an item (Access Point Port) in preferences */ 98 | 99 | subtitle: _("Port used for connections to Spotify\'s Access Point. Set to 0 if any port is fine."); 100 | 101 | Entry ap_port { 102 | valign: center; 103 | } 104 | } 105 | } 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /src/app/components/settings/settings_model.rs: -------------------------------------------------------------------------------- 1 | use crate::app::state::{PlaybackAction, SettingsAction}; 2 | use crate::app::{ActionDispatcher, AppModel}; 3 | use crate::settings::SpotSettings; 4 | use std::rc::Rc; 5 | 6 | pub struct SettingsModel { 7 | app_model: Rc, 8 | dispatcher: Box, 9 | } 10 | 11 | impl SettingsModel { 12 | pub fn new(app_model: Rc, dispatcher: Box) -> Self { 13 | Self { 14 | app_model, 15 | dispatcher, 16 | } 17 | } 18 | 19 | pub fn stop_player(&self) { 20 | self.dispatcher.dispatch(PlaybackAction::Stop.into()); 21 | } 22 | 23 | pub fn set_settings(&self) { 24 | self.dispatcher 25 | .dispatch(SettingsAction::ChangeSettings.into()); 26 | } 27 | 28 | pub fn settings(&self) -> SpotSettings { 29 | let state = self.app_model.get_state(); 30 | state.settings.settings.clone() 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/app/components/sidebar/create_playlist.blp: -------------------------------------------------------------------------------- 1 | using Gtk 4.0; 2 | 3 | template $CreatePlaylistPopover : Popover { 4 | position: right; 5 | 6 | Box box { 7 | Label label { 8 | /* Translators: label for the entry containing the name of a new playlist */ 9 | 10 | label: _("Name"); 11 | } 12 | 13 | Entry entry { 14 | focusable: true; 15 | margin-start: 12; 16 | margin-end: 12; 17 | } 18 | 19 | Revealer error_revealer { 20 | Label error_label { 21 | max-width-chars: 0; 22 | wrap: true; 23 | xalign: 0; 24 | } 25 | } 26 | 27 | Button button { 28 | /* Translators: Button that creates a new playlist */ 29 | 30 | label: _("Create"); 31 | focusable: true; 32 | halign: end; 33 | use-underline: true; 34 | 35 | styles [ 36 | "suggested-action", 37 | ] 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/app/components/sidebar/create_playlist.rs: -------------------------------------------------------------------------------- 1 | use gtk::prelude::*; 2 | use gtk::subclass::prelude::*; 3 | use gtk::CompositeTemplate; 4 | 5 | mod imp { 6 | use super::*; 7 | 8 | #[derive(Debug, Default, CompositeTemplate)] 9 | #[template(resource = "/dev/alextren/Spot/components/create_playlist.ui")] 10 | pub struct CreatePlaylistPopover { 11 | #[template_child] 12 | pub label: TemplateChild, 13 | 14 | #[template_child] 15 | pub entry: TemplateChild, 16 | 17 | #[template_child] 18 | pub button: TemplateChild, 19 | } 20 | 21 | #[glib::object_subclass] 22 | impl ObjectSubclass for CreatePlaylistPopover { 23 | const NAME: &'static str = "CreatePlaylistPopover"; 24 | type Type = super::CreatePlaylistPopover; 25 | type ParentType = gtk::Popover; 26 | 27 | fn class_init(klass: &mut Self::Class) { 28 | Self::bind_template(klass); 29 | } 30 | 31 | fn instance_init(obj: &glib::subclass::InitializingObject) { 32 | obj.init_template(); 33 | } 34 | } 35 | 36 | impl ObjectImpl for CreatePlaylistPopover {} 37 | impl WidgetImpl for CreatePlaylistPopover {} 38 | impl PopoverImpl for CreatePlaylistPopover {} 39 | } 40 | 41 | glib::wrapper! { 42 | pub struct CreatePlaylistPopover(ObjectSubclass) @extends gtk::Widget, gtk::Popover; 43 | } 44 | 45 | impl Default for CreatePlaylistPopover { 46 | fn default() -> Self { 47 | Self::new() 48 | } 49 | } 50 | 51 | impl CreatePlaylistPopover { 52 | pub fn new() -> Self { 53 | glib::Object::new() 54 | } 55 | 56 | pub fn connect_create(&self, create_fun: F) { 57 | let entry = self.imp().entry.get(); 58 | let closure = clone!( 59 | #[weak(rename_to = popover)] 60 | self, 61 | #[weak] 62 | entry, 63 | #[strong] 64 | create_fun, 65 | move || { 66 | create_fun(entry.text().to_string()); 67 | popover.popdown(); 68 | entry.buffer().delete_text(0, None); 69 | } 70 | ); 71 | let closure_clone = closure.clone(); 72 | entry.connect_activate(move |_| closure()); 73 | self.imp().button.connect_clicked(move |_| closure_clone()); 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /src/app/components/sidebar/icons/library-music-symbolic.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /src/app/components/sidebar/mod.rs: -------------------------------------------------------------------------------- 1 | #[allow(clippy::module_inception)] 2 | mod sidebar; 3 | pub use sidebar::*; 4 | 5 | mod sidebar_item; 6 | pub use sidebar_item::*; 7 | 8 | mod create_playlist; 9 | mod sidebar_row; 10 | -------------------------------------------------------------------------------- /src/app/components/sidebar/sidebar_row.blp: -------------------------------------------------------------------------------- 1 | using Gtk 4.0; 2 | 3 | template $SidebarRow : ListBoxRow { 4 | Box { 5 | visible: true; 6 | spacing: 12; 7 | 8 | Image icon { 9 | } 10 | 11 | Label title { 12 | width-chars: 20; 13 | ellipsize: end; 14 | xalign: 0; 15 | } 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/app/components/sidebar/sidebar_row.rs: -------------------------------------------------------------------------------- 1 | use gtk::prelude::*; 2 | use gtk::subclass::prelude::*; 3 | use gtk::CompositeTemplate; 4 | 5 | use super::SidebarItem; 6 | 7 | impl SidebarRow { 8 | pub fn new(item: SidebarItem) -> Self { 9 | glib::Object::builder().property("item", item).build() 10 | } 11 | } 12 | 13 | mod imp { 14 | use super::*; 15 | use glib::Properties; 16 | use std::cell::RefCell; 17 | 18 | #[derive(Debug, CompositeTemplate, Properties)] 19 | #[template(resource = "/dev/alextren/Spot/sidebar/sidebar_row.ui")] 20 | #[properties(wrapper_type = super::SidebarRow)] 21 | pub struct SidebarRow { 22 | #[template_child] 23 | pub icon: TemplateChild, 24 | 25 | #[template_child] 26 | pub title: TemplateChild, 27 | 28 | #[property(get, set = Self::set_item)] 29 | pub item: RefCell, 30 | } 31 | 32 | impl SidebarRow { 33 | fn set_item(&self, item: SidebarItem) { 34 | self.title.set_text(item.title().as_str()); 35 | self.icon.set_icon_name(item.icon()); 36 | self.obj().set_tooltip_text(Some(item.title().as_str())); 37 | self.item.replace(item); 38 | } 39 | } 40 | 41 | #[glib::object_subclass] 42 | impl ObjectSubclass for SidebarRow { 43 | const NAME: &'static str = "SidebarRow"; 44 | type Type = super::SidebarRow; 45 | type ParentType = gtk::ListBoxRow; 46 | 47 | fn class_init(klass: &mut Self::Class) { 48 | klass.bind_template(); 49 | } 50 | 51 | fn instance_init(obj: &glib::subclass::InitializingObject) { 52 | obj.init_template(); 53 | } 54 | 55 | fn new() -> Self { 56 | Self { 57 | icon: Default::default(), 58 | title: Default::default(), 59 | item: RefCell::new(glib::Object::new()), 60 | } 61 | } 62 | } 63 | 64 | #[glib::derived_properties] 65 | impl ObjectImpl for SidebarRow {} 66 | impl WidgetImpl for SidebarRow {} 67 | impl ListBoxRowImpl for SidebarRow {} 68 | } 69 | 70 | glib::wrapper! { 71 | pub struct SidebarRow(ObjectSubclass) @extends gtk::Widget, gtk::ListBoxRow; 72 | } 73 | -------------------------------------------------------------------------------- /src/app/components/user_details/mod.rs: -------------------------------------------------------------------------------- 1 | #[allow(clippy::module_inception)] 2 | mod user_details; 3 | pub use user_details::*; 4 | 5 | mod user_details_model; 6 | pub use user_details_model::*; 7 | -------------------------------------------------------------------------------- /src/app/components/user_details/user_details.blp: -------------------------------------------------------------------------------- 1 | using Gtk 4.0; 2 | 3 | template $UserDetailsWidget : Box { 4 | ScrolledWindow scrolled_window { 5 | hscrollbar-policy: never; 6 | hexpand: true; 7 | vexpand: true; 8 | Box { 9 | margin-start: 8; 10 | margin-end: 8; 11 | margin-top: 8; 12 | margin-bottom: 8; 13 | orientation: vertical; 14 | spacing: 10; 15 | 16 | Label user_name { 17 | halign: start; 18 | margin-start: 8; 19 | margin-end: 8; 20 | label: "User"; 21 | wrap: true; 22 | xalign: 0; 23 | 24 | styles [ 25 | "user_details--name", 26 | "large-title", 27 | ] 28 | } 29 | 30 | FlowBox user_playlists { 31 | height-request: 100; 32 | valign: start; 33 | hexpand: true; 34 | min-children-per-line: 1; 35 | selection-mode: none; 36 | activate-on-single-click: false; 37 | } 38 | } 39 | } 40 | 41 | styles [ 42 | "user", 43 | ] 44 | } 45 | -------------------------------------------------------------------------------- /src/app/components/user_details/user_details.css: -------------------------------------------------------------------------------- 1 | .user { 2 | transition: opacity .3s ease; 3 | opacity: 0; 4 | } 5 | 6 | .user__loaded { 7 | opacity: 1; 8 | } 9 | -------------------------------------------------------------------------------- /src/app/components/user_details/user_details_model.rs: -------------------------------------------------------------------------------- 1 | use std::ops::Deref; 2 | use std::rc::Rc; 3 | 4 | use crate::app::models::*; 5 | use crate::app::state::BrowserAction; 6 | use crate::app::{ActionDispatcher, AppAction, AppModel, ListStore}; 7 | 8 | pub struct UserDetailsModel { 9 | pub id: String, 10 | app_model: Rc, 11 | dispatcher: Box, 12 | } 13 | 14 | impl UserDetailsModel { 15 | pub fn new(id: String, app_model: Rc, dispatcher: Box) -> Self { 16 | Self { 17 | id, 18 | app_model, 19 | dispatcher, 20 | } 21 | } 22 | pub fn get_user_name(&self) -> Option + '_> { 23 | self.app_model 24 | .map_state_opt(|s| s.browser.user_state(&self.id)?.user.as_ref()) 25 | } 26 | 27 | pub fn get_list_store(&self) -> Option> + '_> { 28 | self.app_model 29 | .map_state_opt(|s| Some(&s.browser.user_state(&self.id)?.playlists)) 30 | } 31 | 32 | pub fn load_user_details(&self, id: String) { 33 | let api = self.app_model.get_spotify(); 34 | self.dispatcher 35 | .call_spotify_and_dispatch(move || async move { 36 | api.get_user(&id) 37 | .await 38 | .map(|user| BrowserAction::SetUserDetails(Box::new(user)).into()) 39 | }); 40 | } 41 | 42 | pub fn open_playlist(&self, id: String) { 43 | self.dispatcher.dispatch(AppAction::ViewPlaylist(id)); 44 | } 45 | 46 | pub fn load_more(&self) -> Option<()> { 47 | let api = self.app_model.get_spotify(); 48 | let state = self.app_model.get_state(); 49 | let next_page = &state.browser.user_state(&self.id)?.next_page; 50 | 51 | let id = next_page.data.clone(); 52 | let batch_size = next_page.batch_size; 53 | let offset = next_page.next_offset?; 54 | self.dispatcher 55 | .call_spotify_and_dispatch(move || async move { 56 | api.get_user_playlists(&id, offset, batch_size) 57 | .await 58 | .map(|playlists| BrowserAction::AppendUserPlaylists(id, playlists).into()) 59 | }); 60 | 61 | Some(()) 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/app/components/user_menu/mod.rs: -------------------------------------------------------------------------------- 1 | #[allow(clippy::module_inception)] 2 | mod user_menu; 3 | pub use user_menu::*; 4 | 5 | mod user_menu_model; 6 | pub use user_menu_model::*; 7 | -------------------------------------------------------------------------------- /src/app/components/user_menu/user_menu.rs: -------------------------------------------------------------------------------- 1 | use gettextrs::*; 2 | use gio::{prelude::ActionMapExt, SimpleAction, SimpleActionGroup}; 3 | use gtk::prelude::*; 4 | use libadwaita::prelude::AdwDialogExt; 5 | use std::rc::Rc; 6 | 7 | use super::UserMenuModel; 8 | use crate::app::components::{EventListener, Settings}; 9 | use crate::app::{state::LoginEvent, AppEvent}; 10 | 11 | pub struct UserMenu { 12 | user_button: gtk::MenuButton, 13 | model: Rc, 14 | } 15 | 16 | impl UserMenu { 17 | pub fn new( 18 | user_button: gtk::MenuButton, 19 | settings: Settings, 20 | about: libadwaita::AboutDialog, 21 | parent: gtk::Window, 22 | model: UserMenuModel, 23 | ) -> Self { 24 | let model = Rc::new(model); 25 | 26 | let action_group = SimpleActionGroup::new(); 27 | 28 | action_group.add_action(&{ 29 | let logout = SimpleAction::new("logout", None); 30 | logout.connect_activate(clone!( 31 | #[weak] 32 | model, 33 | move |_, _| { 34 | model.logout(); 35 | } 36 | )); 37 | logout 38 | }); 39 | 40 | action_group.add_action(&{ 41 | let settings_action = SimpleAction::new("settings", None); 42 | settings_action.connect_activate(move |_, _| { 43 | settings.show_self(); 44 | }); 45 | settings_action 46 | }); 47 | 48 | action_group.add_action(&{ 49 | let about_action = SimpleAction::new("about", None); 50 | about_action.connect_activate(clone!( 51 | #[weak] 52 | about, 53 | #[weak] 54 | parent, 55 | move |_, _| { 56 | about.present(Some(&parent)); 57 | } 58 | )); 59 | about_action 60 | }); 61 | 62 | user_button.insert_action_group("menu", Some(&action_group)); 63 | 64 | Self { user_button, model } 65 | } 66 | 67 | fn update_menu(&self) { 68 | let menu = gio::Menu::new(); 69 | // translators: This is a menu entry. 70 | menu.append(Some(&gettext("Preferences")), Some("menu.settings")); 71 | // translators: This is a menu entry. 72 | menu.append(Some(&gettext("About")), Some("menu.about")); 73 | // translators: This is a menu entry. 74 | menu.append(Some(&gettext("Quit")), Some("app.quit")); 75 | 76 | if let Some(username) = self.model.username() { 77 | let user_menu = gio::Menu::new(); 78 | // translators: This is a menu entry. 79 | user_menu.append(Some(&gettext("Log out")), Some("menu.logout")); 80 | menu.insert_section(0, Some(&username), &user_menu); 81 | } 82 | 83 | self.user_button.set_menu_model(Some(&menu)); 84 | } 85 | } 86 | 87 | impl EventListener for UserMenu { 88 | fn on_event(&mut self, event: &AppEvent) { 89 | match event { 90 | AppEvent::LoginEvent(LoginEvent::LoginCompleted) | AppEvent::Started => { 91 | self.update_menu(); 92 | self.model.fetch_user_playlists(); 93 | } 94 | _ => {} 95 | } 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /src/app/components/user_menu/user_menu_model.rs: -------------------------------------------------------------------------------- 1 | use crate::api::clear_user_cache; 2 | use crate::app::credentials::Credentials; 3 | use crate::app::state::{LoginAction, PlaybackAction}; 4 | use crate::app::{ActionDispatcher, AppModel}; 5 | use std::ops::Deref; 6 | use std::rc::Rc; 7 | 8 | pub struct UserMenuModel { 9 | app_model: Rc, 10 | dispatcher: Box, 11 | } 12 | 13 | impl UserMenuModel { 14 | pub fn new(app_model: Rc, dispatcher: Box) -> Self { 15 | Self { 16 | app_model, 17 | dispatcher, 18 | } 19 | } 20 | 21 | pub fn username(&self) -> Option + '_> { 22 | self.app_model 23 | .map_state_opt(|s| s.logged_user.user.as_ref()) 24 | } 25 | 26 | pub fn logout(&self) { 27 | self.dispatcher.dispatch(PlaybackAction::Stop.into()); 28 | self.dispatcher.dispatch_async(Box::pin(async { 29 | let _ = Credentials::logout().await; 30 | let _ = clear_user_cache().await; 31 | Some(LoginAction::Logout.into()) 32 | })); 33 | } 34 | 35 | pub fn fetch_user_playlists(&self) { 36 | let api = self.app_model.get_spotify(); 37 | if let Some(current_user) = self.username() { 38 | let current_user = current_user.clone(); 39 | self.dispatcher 40 | .call_spotify_and_dispatch(move || async move { 41 | api.get_saved_playlists(0, 30).await.map(|playlists| { 42 | let summaries = playlists 43 | .into_iter() 44 | .filter(|p| p.owner.id == current_user) 45 | .map(|p| p.into()) 46 | .collect(); 47 | LoginAction::SetUserPlaylists(summaries).into() 48 | }) 49 | }); 50 | } 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/app/components/window/mod.rs: -------------------------------------------------------------------------------- 1 | use gtk::prelude::*; 2 | use std::cell::RefCell; 3 | use std::rc::Rc; 4 | 5 | use crate::app::components::EventListener; 6 | use crate::app::{AppEvent, AppModel}; 7 | use crate::settings::WindowGeometry; 8 | 9 | thread_local! { 10 | static WINDOW_GEOMETRY: RefCell = const { RefCell::new(WindowGeometry { 11 | width: 0, height: 0, is_maximized: false 12 | }) }; 13 | } 14 | 15 | pub struct MainWindow { 16 | initial_window_geometry: WindowGeometry, 17 | window: libadwaita::ApplicationWindow, 18 | } 19 | 20 | impl MainWindow { 21 | pub fn new( 22 | initial_window_geometry: WindowGeometry, 23 | app_model: Rc, 24 | window: libadwaita::ApplicationWindow, 25 | ) -> Self { 26 | window.connect_close_request(clone!( 27 | #[weak] 28 | app_model, 29 | #[upgrade_or] 30 | glib::Propagation::Proceed, 31 | move |window| { 32 | let state = app_model.get_state(); 33 | if state.playback.is_playing() { 34 | window.set_visible(false); 35 | glib::Propagation::Stop 36 | } else { 37 | glib::Propagation::Proceed 38 | } 39 | } 40 | )); 41 | 42 | window.connect_default_height_notify(Self::save_window_geometry); 43 | window.connect_default_width_notify(Self::save_window_geometry); 44 | window.connect_maximized_notify(Self::save_window_geometry); 45 | 46 | window.connect_unrealize(|_| { 47 | debug!("saving geometry"); 48 | WINDOW_GEOMETRY.with(|g| g.borrow().save()); 49 | }); 50 | 51 | Self { 52 | initial_window_geometry, 53 | window, 54 | } 55 | } 56 | 57 | fn start(&self) { 58 | self.window.set_default_size( 59 | self.initial_window_geometry.width, 60 | self.initial_window_geometry.height, 61 | ); 62 | if self.initial_window_geometry.is_maximized { 63 | self.window.maximize(); 64 | } 65 | self.window.present(); 66 | } 67 | 68 | fn raise(&self) { 69 | self.window.present(); 70 | } 71 | 72 | fn save_window_geometry(window: &W) { 73 | let (width, height) = window.default_size(); 74 | let is_maximized = window.is_maximized(); 75 | WINDOW_GEOMETRY.with(|g| { 76 | let mut g = g.borrow_mut(); 77 | g.is_maximized = is_maximized; 78 | if !is_maximized { 79 | g.width = width; 80 | g.height = height; 81 | } 82 | }); 83 | } 84 | } 85 | 86 | impl EventListener for MainWindow { 87 | fn on_event(&mut self, event: &AppEvent) { 88 | match event { 89 | AppEvent::Started => self.start(), 90 | AppEvent::Raised => self.raise(), 91 | _ => {} 92 | } 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /src/app/credentials.rs: -------------------------------------------------------------------------------- 1 | use secret_service::{EncryptionType, Error, SecretService}; 2 | use serde::{Deserialize, Serialize}; 3 | use std::{collections::HashMap, time::SystemTime}; 4 | 5 | static SPOT_ATTR: &str = "spot_credentials"; 6 | 7 | // I'm not sure this is the right way to make credentials identifiable, but hey, it works 8 | fn make_attributes() -> HashMap<&'static str, &'static str> { 9 | let mut attributes = HashMap::new(); 10 | attributes.insert(SPOT_ATTR, "yes"); 11 | attributes 12 | } 13 | 14 | // A (statically accessed) wrapper around the DBUS Secret Service 15 | #[derive(Deserialize, Serialize, Clone, Debug)] 16 | pub struct Credentials { 17 | pub access_token: String, 18 | pub refresh_token: String, 19 | pub token_expiry_time: Option, 20 | } 21 | 22 | impl Credentials { 23 | pub fn token_expired(&self) -> bool { 24 | match self.token_expiry_time { 25 | Some(v) => SystemTime::now() > v, 26 | None => true, 27 | } 28 | } 29 | 30 | pub async fn retrieve() -> Result { 31 | let service = SecretService::connect(EncryptionType::Dh).await?; 32 | let collection = service.get_default_collection().await?; 33 | if collection.is_locked().await? { 34 | collection.unlock().await?; 35 | } 36 | let items = collection.search_items(make_attributes()).await?; 37 | let item = items.first().ok_or(Error::NoResult)?.get_secret().await?; 38 | serde_json::from_slice(&item).map_err(|_| Error::Unavailable) 39 | } 40 | 41 | // Try to clear the credentials 42 | pub async fn logout() -> Result<(), Error> { 43 | let service = SecretService::connect(EncryptionType::Dh).await?; 44 | let collection = service.get_default_collection().await?; 45 | if !collection.is_locked().await? { 46 | let result = collection.search_items(make_attributes()).await?; 47 | let item = result.first().ok_or(Error::NoResult)?; 48 | item.delete().await 49 | } else { 50 | warn!("Keyring is locked -- not clearing credentials"); 51 | Ok(()) 52 | } 53 | } 54 | 55 | pub async fn save(&self) -> Result<(), Error> { 56 | let service = SecretService::connect(EncryptionType::Dh).await?; 57 | let collection = service.get_default_collection().await?; 58 | if collection.is_locked().await? { 59 | collection.unlock().await?; 60 | } 61 | // We simply write our stuct as JSON and send it 62 | info!("Saving credentials"); 63 | let encoded = serde_json::to_vec(&self).unwrap(); 64 | collection 65 | .create_item( 66 | "Spotify Credentials", 67 | make_attributes(), 68 | &encoded, 69 | true, 70 | "text/plain", 71 | ) 72 | .await?; 73 | info!("Saved credentials"); 74 | Ok(()) 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /src/app/dispatch.rs: -------------------------------------------------------------------------------- 1 | use futures::channel::mpsc::{unbounded, UnboundedReceiver, UnboundedSender}; 2 | use futures::future::BoxFuture; 3 | use futures::future::Future; 4 | use futures::stream::StreamExt; 5 | use std::pin::Pin; 6 | 7 | use super::AppAction; 8 | 9 | // A wrapper around an MPSC sender to send AppActions synchronously or asynchronously 10 | // It is a trait because I guess I wanted to be able to stub it, but see how that went... 11 | pub trait ActionDispatcher { 12 | fn dispatch(&self, action: AppAction); 13 | fn dispatch_many(&self, actions: Vec); 14 | fn dispatch_async(&self, action: BoxFuture<'static, Option>); 15 | fn dispatch_many_async(&self, actions: BoxFuture<'static, Vec>); 16 | // Can't have impl Clone easily so there you go 17 | fn box_clone(&self) -> Box; 18 | } 19 | 20 | #[derive(Clone)] 21 | pub struct ActionDispatcherImpl { 22 | sender: UnboundedSender, 23 | worker: Worker, 24 | } 25 | 26 | impl ActionDispatcherImpl { 27 | pub fn new(sender: UnboundedSender, worker: Worker) -> Self { 28 | Self { sender, worker } 29 | } 30 | } 31 | 32 | impl ActionDispatcher for ActionDispatcherImpl { 33 | fn dispatch(&self, action: AppAction) { 34 | self.sender.unbounded_send(action).unwrap(); 35 | } 36 | 37 | fn dispatch_many(&self, actions: Vec) { 38 | for action in actions.into_iter() { 39 | self.sender.unbounded_send(action).unwrap(); 40 | } 41 | } 42 | 43 | fn dispatch_async(&self, action: BoxFuture<'static, Option>) { 44 | let clone = self.sender.clone(); 45 | self.worker.send_task(async move { 46 | if let Some(action) = action.await { 47 | clone.unbounded_send(action).unwrap(); 48 | } 49 | }); 50 | } 51 | 52 | fn dispatch_many_async(&self, actions: BoxFuture<'static, Vec>) { 53 | let clone = self.sender.clone(); 54 | self.worker.send_task(async move { 55 | for action in actions.await.into_iter() { 56 | clone.unbounded_send(action).unwrap(); 57 | } 58 | }); 59 | } 60 | 61 | fn box_clone(&self) -> Box { 62 | Box::new(self.clone()) 63 | } 64 | } 65 | 66 | // Funky name for a mere wrapper around an MPSC send/recv pair 67 | pub struct DispatchLoop { 68 | receiver: UnboundedReceiver, 69 | sender: UnboundedSender, 70 | } 71 | 72 | impl DispatchLoop { 73 | pub fn new() -> Self { 74 | let (sender, receiver) = unbounded::(); 75 | Self { receiver, sender } 76 | } 77 | 78 | pub fn make_dispatcher(&self) -> UnboundedSender { 79 | self.sender.clone() 80 | } 81 | 82 | pub async fn attach(self, mut handler: impl FnMut(AppAction)) { 83 | self.receiver 84 | .for_each(|action| { 85 | handler(action); 86 | async {} 87 | }) 88 | .await; 89 | } 90 | } 91 | 92 | pub type FutureTask = Pin + Send>>; 93 | pub type FutureLocalTask = Pin>>; 94 | 95 | // The Worker (see below) is a glorified way to send an async task to the GLib(rs) future executor 96 | pub fn spawn_task_handler(context: &glib::MainContext) -> Worker { 97 | let (future_local_sender, future_local_receiver) = unbounded::(); 98 | context.spawn_local_with_priority( 99 | glib::Priority::DEFAULT_IDLE, 100 | future_local_receiver.for_each(|t| t), 101 | ); 102 | 103 | let (future_sender, future_receiver) = unbounded::(); 104 | context.spawn_with_priority( 105 | glib::Priority::DEFAULT_IDLE, 106 | future_receiver.for_each(|t| t), 107 | ); 108 | 109 | Worker(future_local_sender, future_sender) 110 | } 111 | 112 | // Again, fancy name for an MPSC sender 113 | // Actually two of them, in case you need to send local futures (no Send needed) 114 | #[derive(Clone)] 115 | pub struct Worker( 116 | UnboundedSender, 117 | UnboundedSender, 118 | ); 119 | 120 | impl Worker { 121 | pub fn send_local_task + 'static>(&self, task: T) -> Option<()> { 122 | self.0.unbounded_send(Box::pin(task)).ok() 123 | } 124 | 125 | pub fn send_task + Send + 'static>(&self, task: T) -> Option<()> { 126 | self.1.unbounded_send(Box::pin(task)).ok() 127 | } 128 | } 129 | -------------------------------------------------------------------------------- /src/app/list_store.rs: -------------------------------------------------------------------------------- 1 | use gio::prelude::*; 2 | use glib::clone::{Downgrade, Upgrade}; 3 | use std::iter::Iterator; 4 | use std::marker::PhantomData; 5 | 6 | // A wrapper around a GIO ListStore 7 | // DEPRECATED 8 | pub struct ListStore { 9 | store: gio::ListStore, 10 | _marker: PhantomData, 11 | } 12 | 13 | pub struct WeakListStore { 14 | store: ::Weak, 15 | _marker: PhantomData, 16 | } 17 | 18 | impl ListStore 19 | where 20 | GType: IsA, 21 | { 22 | pub fn new() -> Self { 23 | Self { 24 | store: gio::ListStore::new::(), 25 | _marker: PhantomData, 26 | } 27 | } 28 | 29 | pub fn inner(&self) -> &gio::ListStore { 30 | &self.store 31 | } 32 | 33 | pub fn prepend(&mut self, elements: impl Iterator) { 34 | let upcast_vec: Vec = elements.map(|e| e.upcast::()).collect(); 35 | self.store.splice(0, 0, &upcast_vec[..]); 36 | } 37 | 38 | pub fn extend(&mut self, elements: impl Iterator) { 39 | let upcast_vec: Vec = elements.map(|e| e.upcast::()).collect(); 40 | self.store.splice(self.store.n_items(), 0, &upcast_vec[..]); 41 | } 42 | 43 | pub fn replace_all(&mut self, elements: impl Iterator) { 44 | let upcast_vec: Vec = elements.map(|e| e.upcast::()).collect(); 45 | self.store.splice(0, self.store.n_items(), &upcast_vec[..]); 46 | } 47 | 48 | pub fn insert(&mut self, position: u32, element: GType) { 49 | self.store.insert(position, &element); 50 | } 51 | 52 | pub fn remove(&mut self, position: u32) { 53 | self.store.remove(position); 54 | } 55 | 56 | pub fn get(&self, index: u32) -> GType { 57 | self.store.item(index).unwrap().downcast::().unwrap() 58 | } 59 | 60 | pub fn iter(&self) -> impl Iterator + '_ { 61 | let store = &self.store; 62 | let count = store.n_items(); 63 | (0..count).map(move |i| self.get(i)) 64 | } 65 | 66 | pub fn len(&self) -> usize { 67 | self.store.n_items() as usize 68 | } 69 | 70 | // Quick and dirty comparison between the list store and a slice of object that can be compared 71 | // with the contents of the store using some function F. 72 | // Not so great but eh 73 | pub fn eq(&self, other: &[O], comparison: F) -> bool 74 | where 75 | F: Fn(>ype, &O) -> bool, 76 | { 77 | self.len() == other.len() 78 | && self 79 | .iter() 80 | .zip(other.iter()) 81 | .all(|(left, right)| comparison(&left, right)) 82 | } 83 | } 84 | 85 | impl Clone for ListStore { 86 | fn clone(&self) -> Self { 87 | Self { 88 | store: self.store.clone(), 89 | _marker: PhantomData, 90 | } 91 | } 92 | } 93 | 94 | impl Downgrade for ListStore { 95 | type Weak = WeakListStore; 96 | 97 | fn downgrade(&self) -> Self::Weak { 98 | Self::Weak { 99 | store: Downgrade::downgrade(&self.store), 100 | _marker: PhantomData, 101 | } 102 | } 103 | } 104 | 105 | impl Upgrade for WeakListStore { 106 | type Strong = ListStore; 107 | 108 | fn upgrade(&self) -> Option { 109 | Some(Self::Strong { 110 | store: self.store.upgrade()?, 111 | _marker: PhantomData, 112 | }) 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /src/app/loader.rs: -------------------------------------------------------------------------------- 1 | use crate::api::cache::*; 2 | use gdk_pixbuf::{prelude::PixbufLoaderExt, Pixbuf, PixbufLoader}; 3 | use isahc::config::Configurable; 4 | use isahc::{AsyncBody, AsyncReadResponseExt, HttpClient, Response}; 5 | use std::collections::hash_map::DefaultHasher; 6 | use std::hash::Hasher; 7 | use std::io::{Error, ErrorKind, Write}; 8 | 9 | // A wrapper to be able to implement the Write trait on a PixbufLoader 10 | struct LocalPixbufLoader<'a>(&'a PixbufLoader); 11 | 12 | impl Write for LocalPixbufLoader<'_> { 13 | fn write(&mut self, buf: &[u8]) -> Result { 14 | self.0 15 | .write(buf) 16 | .map_err(|e| Error::new(ErrorKind::Other, format!("glib error: {e}")))?; 17 | Ok(buf.len()) 18 | } 19 | 20 | fn flush(&mut self) -> Result<(), Error> { 21 | self.0 22 | .close() 23 | .map_err(|e| Error::new(ErrorKind::Other, format!("glib error: {e}")))?; 24 | Ok(()) 25 | } 26 | } 27 | 28 | // A helper to load remote images, with simple cache management 29 | pub struct ImageLoader { 30 | cache: CacheManager, 31 | } 32 | 33 | impl ImageLoader { 34 | pub fn new() -> Self { 35 | Self { 36 | cache: CacheManager::for_dir("spot/img").unwrap(), 37 | } 38 | } 39 | 40 | // Downloaded images are simply named [hash of url].[file extension] 41 | fn resource_for(url: &str, ext: &str) -> String { 42 | let mut hasher = DefaultHasher::new(); 43 | hasher.write(url.as_bytes()); 44 | let hashed = hasher.finish().to_string(); 45 | hashed + "." + ext 46 | } 47 | 48 | async fn get_image(url: &str) -> Option> { 49 | let mut builder = HttpClient::builder(); 50 | if cfg!(debug_assertions) { 51 | builder = builder.ssl_options(isahc::config::SslOption::DANGER_ACCEPT_INVALID_CERTS); 52 | } 53 | let client = builder.build().unwrap(); 54 | client.get_async(url).await.ok() 55 | } 56 | 57 | pub async fn load_remote( 58 | &self, 59 | url: &str, 60 | ext: &str, 61 | width: i32, 62 | height: i32, 63 | ) -> Option { 64 | let resource = Self::resource_for(url, ext); 65 | let pixbuf_loader = PixbufLoader::new(); 66 | pixbuf_loader.set_size(width, height); 67 | let mut loader = LocalPixbufLoader(&pixbuf_loader); 68 | 69 | // Try to read from cache first, ignoring possible expiry 70 | match self 71 | .cache 72 | .read_cache_file(&resource[..], CachePolicy::IgnoreExpiry) 73 | .await 74 | { 75 | // Write content of cache file to the pixbuf loader if the cache contained something 76 | Ok(CacheFile::Fresh(buffer)) => { 77 | loader.write_all(&buffer[..]).ok()?; 78 | } 79 | // Otherwise, get image over HTTP 80 | _ => { 81 | if let Some(mut resp) = Self::get_image(url).await { 82 | let mut buffer = vec![]; 83 | // Copy the image to a buffer... 84 | resp.copy_to(&mut buffer).await.ok()?; 85 | // ... copy the buffer to the loader... 86 | loader.write_all(&buffer[..]).ok()?; 87 | // ... but also save that buffer to cache 88 | self.cache 89 | .write_cache_file(&resource[..], &buffer[..], CacheExpiry::Never) 90 | .await 91 | .ok()?; 92 | } 93 | } 94 | }; 95 | 96 | pixbuf_loader.close().ok()?; 97 | pixbuf_loader.pixbuf() 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /src/app/models/album_model.rs: -------------------------------------------------------------------------------- 1 | #![allow(clippy::all)] 2 | 3 | use gio::prelude::*; 4 | use glib::subclass::prelude::*; 5 | use glib::Properties; 6 | 7 | // UI model! 8 | // Despite the name, it can represent a playlist as well 9 | glib::wrapper! { 10 | pub struct AlbumModel(ObjectSubclass); 11 | } 12 | 13 | impl AlbumModel { 14 | pub fn new( 15 | artist: &String, 16 | album: &String, 17 | year: Option, 18 | cover: Option<&String>, 19 | uri: &String, 20 | ) -> AlbumModel { 21 | let year = &year.unwrap_or(0); 22 | glib::Object::builder() 23 | .property("artist", artist) 24 | .property("album", album) 25 | .property("year", year) 26 | .property("cover", &cover) 27 | .property("uri", uri) 28 | .build() 29 | } 30 | } 31 | 32 | mod imp { 33 | 34 | use super::*; 35 | 36 | use std::cell::{Cell, RefCell}; 37 | 38 | #[derive(Default, Properties)] 39 | #[properties(wrapper_type = super::AlbumModel)] 40 | pub struct AlbumModel { 41 | #[property(get, set)] 42 | album: RefCell, 43 | #[property(get, set)] 44 | artist: RefCell, 45 | #[property(get, set)] 46 | year: Cell, 47 | #[property(get, set)] 48 | cover: RefCell>, 49 | #[property(get, set)] 50 | uri: RefCell, 51 | } 52 | 53 | #[glib::object_subclass] 54 | impl ObjectSubclass for AlbumModel { 55 | const NAME: &'static str = "AlbumModel"; 56 | type Type = super::AlbumModel; 57 | type ParentType = glib::Object; 58 | } 59 | 60 | #[glib::derived_properties] 61 | impl ObjectImpl for AlbumModel {} 62 | } 63 | -------------------------------------------------------------------------------- /src/app/models/artist_model.rs: -------------------------------------------------------------------------------- 1 | #![allow(clippy::all)] 2 | 3 | use gio::prelude::*; 4 | use glib::subclass::prelude::*; 5 | use glib::Properties; 6 | 7 | // UI model! 8 | glib::wrapper! { 9 | pub struct ArtistModel(ObjectSubclass); 10 | } 11 | 12 | impl ArtistModel { 13 | pub fn new(artist: &str, image: &Option, id: &str) -> ArtistModel { 14 | glib::Object::builder() 15 | .property("artist", &artist) 16 | .property("image", image) 17 | .property("id", &id) 18 | .build() 19 | } 20 | } 21 | 22 | mod imp { 23 | 24 | use super::*; 25 | use std::cell::RefCell; 26 | 27 | #[derive(Default, Properties)] 28 | #[properties(wrapper_type = super::ArtistModel)] 29 | pub struct ArtistModel { 30 | #[property(get, set)] 31 | artist: RefCell, 32 | #[property(get, set)] 33 | image: RefCell>, 34 | #[property(get, set)] 35 | id: RefCell, 36 | } 37 | 38 | #[glib::object_subclass] 39 | impl ObjectSubclass for ArtistModel { 40 | const NAME: &'static str = "ArtistModel"; 41 | type Type = super::ArtistModel; 42 | type ParentType = glib::Object; 43 | } 44 | 45 | #[glib::derived_properties] 46 | impl ObjectImpl for ArtistModel {} 47 | } 48 | -------------------------------------------------------------------------------- /src/app/models/mod.rs: -------------------------------------------------------------------------------- 1 | // Domain models 2 | mod main; 3 | pub use main::*; 4 | 5 | // UI models (GObject) 6 | mod songs; 7 | pub use songs::*; 8 | 9 | mod album_model; 10 | pub use album_model::*; 11 | 12 | mod artist_model; 13 | pub use artist_model::*; 14 | 15 | impl From<&AlbumDescription> for AlbumModel { 16 | fn from(album: &AlbumDescription) -> Self { 17 | AlbumModel::new( 18 | &album.artists_name(), 19 | &album.title, 20 | album.year(), 21 | album.art.as_ref(), 22 | &album.id, 23 | ) 24 | } 25 | } 26 | 27 | impl From for AlbumModel { 28 | fn from(album: AlbumDescription) -> Self { 29 | Self::from(&album) 30 | } 31 | } 32 | 33 | impl From<&PlaylistDescription> for AlbumModel { 34 | fn from(playlist: &PlaylistDescription) -> Self { 35 | AlbumModel::new( 36 | &playlist.owner.display_name, 37 | &playlist.title, 38 | // Playlists do not have their released date since they are expected to be updated anytime. 39 | None, 40 | playlist.art.as_ref(), 41 | &playlist.id, 42 | ) 43 | } 44 | } 45 | 46 | impl From for PlaylistSummary { 47 | fn from(PlaylistDescription { id, title, .. }: PlaylistDescription) -> Self { 48 | Self { id, title } 49 | } 50 | } 51 | 52 | impl From for AlbumModel { 53 | fn from(playlist: PlaylistDescription) -> Self { 54 | Self::from(&playlist) 55 | } 56 | } 57 | 58 | impl From for SongModel { 59 | fn from(song: SongDescription) -> Self { 60 | SongModel::new(song) 61 | } 62 | } 63 | 64 | impl From<&SongDescription> for SongModel { 65 | fn from(song: &SongDescription) -> Self { 66 | SongModel::new(song.clone()) 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/app/models/songs/mod.rs: -------------------------------------------------------------------------------- 1 | // The underlying data structure for a list of songs 2 | mod support; 3 | 4 | // A GObject wrapper around that list 5 | mod song_list_model; 6 | pub use song_list_model::*; 7 | 8 | mod song_model; 9 | pub use song_model::*; 10 | -------------------------------------------------------------------------------- /src/app/state/app_model.rs: -------------------------------------------------------------------------------- 1 | use crate::api::SpotifyApiClient; 2 | use crate::app::{state::*, BatchLoader}; 3 | use ref_filter_map::*; 4 | use std::cell::{Ref, RefCell}; 5 | use std::sync::Arc; 6 | 7 | pub struct AppServices { 8 | pub spotify_api: Arc, 9 | pub batch_loader: BatchLoader, 10 | } 11 | 12 | // Two purposes: give access to some services to users of the AppModel (shared) 13 | // and give a read only view of the state 14 | pub struct AppModel { 15 | state: RefCell, 16 | services: AppServices, 17 | } 18 | 19 | impl AppModel { 20 | pub fn new(state: AppState, spotify_api: Arc) -> Self { 21 | let services = AppServices { 22 | batch_loader: BatchLoader::new(Arc::clone(&spotify_api)), 23 | spotify_api, 24 | }; 25 | let state = RefCell::new(state); 26 | Self { state, services } 27 | } 28 | 29 | pub fn get_spotify(&self) -> Arc { 30 | Arc::clone(&self.services.spotify_api) 31 | } 32 | 33 | pub fn get_batch_loader(&self) -> BatchLoader { 34 | self.services.batch_loader.clone() 35 | } 36 | 37 | // Read only access to the state! 38 | pub fn get_state(&self) -> Ref<'_, AppState> { 39 | self.state.borrow() 40 | } 41 | 42 | // Convenience... 43 | pub fn map_state &T>(&self, map: F) -> Ref<'_, T> { 44 | Ref::map(self.state.borrow(), map) 45 | } 46 | 47 | // Convenience... 48 | pub fn map_state_opt Option<&T>>( 49 | &self, 50 | map: F, 51 | ) -> Option> { 52 | ref_filter_map(self.state.borrow(), map) 53 | } 54 | 55 | pub fn update_state(&self, action: AppAction) -> Vec { 56 | // And this is the only mutable borrow of our state! 57 | let mut state = self.state.borrow_mut(); 58 | state.update_state(action) 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/app/state/mod.rs: -------------------------------------------------------------------------------- 1 | mod app_model; 2 | mod app_state; 3 | mod browser_state; 4 | mod login_state; 5 | mod pagination; 6 | mod playback_state; 7 | mod screen_states; 8 | mod selection_state; 9 | mod settings_state; 10 | 11 | pub use app_model::AppModel; 12 | pub use app_state::*; 13 | pub use browser_state::*; 14 | pub use login_state::*; 15 | pub use playback_state::*; 16 | pub use screen_states::*; 17 | pub use selection_state::*; 18 | pub use settings_state::*; 19 | 20 | pub trait UpdatableState { 21 | type Action: Clone; 22 | type Event; 23 | 24 | fn update_with(&mut self, action: std::borrow::Cow) -> Vec; 25 | } 26 | -------------------------------------------------------------------------------- /src/app/state/pagination.rs: -------------------------------------------------------------------------------- 1 | // A structure for batched queries that I introduced before proper batch management 2 | // Still used to load album lists for instance 3 | // Doesn't know how many elements exist in total ahead of time 4 | #[derive(Clone, Debug)] 5 | pub struct Pagination 6 | where 7 | T: Clone, 8 | { 9 | pub data: T, 10 | // The next offset (of things to load) is set to None whenever we get less than we asked for 11 | // as it probably means we've reached the end of some list 12 | pub next_offset: Option, 13 | pub batch_size: usize, 14 | } 15 | 16 | impl Pagination 17 | where 18 | T: Clone, 19 | { 20 | pub fn new(data: T, batch_size: usize) -> Self { 21 | Self { 22 | data, 23 | next_offset: Some(0), 24 | batch_size, 25 | } 26 | } 27 | 28 | pub fn reset_count(&mut self, new_length: usize) { 29 | self.next_offset = if new_length >= self.batch_size { 30 | Some(self.batch_size) 31 | } else { 32 | None 33 | } 34 | } 35 | 36 | pub fn set_loaded_count(&mut self, loaded_count: usize) { 37 | if let Some(offset) = self.next_offset.take() { 38 | self.next_offset = if loaded_count >= self.batch_size { 39 | Some(offset + self.batch_size) 40 | } else { 41 | None 42 | } 43 | } 44 | } 45 | 46 | // If we remove elements from paginated data without refetching from the source, 47 | // we have to adjust the next offset to load 48 | pub fn decrement(&mut self) { 49 | if let Some(offset) = self.next_offset.take() { 50 | self.next_offset = Some(offset - 1); 51 | } 52 | } 53 | 54 | // Same idea as decrement 55 | pub fn increment(&mut self) { 56 | if let Some(offset) = self.next_offset.take() { 57 | self.next_offset = Some(offset + 1); 58 | } 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/app/state/settings_state.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | app::state::{AppAction, AppEvent, UpdatableState}, 3 | settings::SpotSettings, 4 | }; 5 | 6 | #[derive(Clone, Debug)] 7 | pub enum SettingsAction { 8 | ChangeSettings, 9 | } 10 | 11 | impl From for AppAction { 12 | fn from(settings_action: SettingsAction) -> Self { 13 | Self::SettingsAction(settings_action) 14 | } 15 | } 16 | 17 | #[derive(Clone, Debug)] 18 | pub enum SettingsEvent { 19 | PlayerSettingsChanged, 20 | } 21 | 22 | impl From for AppEvent { 23 | fn from(settings_event: SettingsEvent) -> Self { 24 | Self::SettingsEvent(settings_event) 25 | } 26 | } 27 | 28 | #[derive(Default)] 29 | pub struct SettingsState { 30 | // Probably shouldn't be stored, the source of truth is GSettings anyway 31 | pub settings: SpotSettings, 32 | } 33 | 34 | impl UpdatableState for SettingsState { 35 | type Action = SettingsAction; 36 | type Event = AppEvent; 37 | 38 | fn update_with(&mut self, action: std::borrow::Cow) -> Vec { 39 | match action.into_owned() { 40 | SettingsAction::ChangeSettings => { 41 | let old_settings = &self.settings; 42 | let new_settings = SpotSettings::new_from_gsettings().unwrap_or_default(); 43 | let player_settings_changed = 44 | new_settings.player_settings != old_settings.player_settings; 45 | self.settings = new_settings; 46 | if player_settings_changed { 47 | vec![SettingsEvent::PlayerSettingsChanged.into()] 48 | } else { 49 | vec![] 50 | } 51 | } 52 | } 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/config.rs.in: -------------------------------------------------------------------------------- 1 | // Configured by meson 2 | pub static PKGDATADIR: &str = @PKGDATADIR@; 3 | pub static VERSION: &str = "@VERSION@"; 4 | pub static LOCALEDIR: &str = @LOCALEDIR@; 5 | pub static APPID: &str = @APPID@; 6 | -------------------------------------------------------------------------------- /src/connect/mod.rs: -------------------------------------------------------------------------------- 1 | use std::sync::Arc; 2 | use std::time::Duration; 3 | 4 | use futures::channel::mpsc::{unbounded, UnboundedReceiver, UnboundedSender}; 5 | use futures::StreamExt; 6 | use tokio::{task, time}; 7 | 8 | use crate::api::SpotifyApiClient; 9 | use crate::app::AppAction; 10 | 11 | mod player; 12 | pub use player::ConnectCommand; 13 | 14 | #[tokio::main] 15 | async fn connect_server( 16 | api: Arc, 17 | action_sender: UnboundedSender, 18 | receiver: UnboundedReceiver, 19 | ) { 20 | let player = Arc::new(player::ConnectPlayer::new(api, action_sender)); 21 | 22 | let player_clone = Arc::clone(&player); 23 | task::spawn(async move { 24 | let mut interval = time::interval(Duration::from_secs(5)); 25 | loop { 26 | interval.tick().await; 27 | if player_clone.has_device() { 28 | player_clone.sync_state().await; 29 | } 30 | } 31 | }); 32 | 33 | receiver 34 | .for_each(|command| async { player.handle_command(command).await.unwrap() }) 35 | .await; 36 | } 37 | 38 | pub fn start_connect_server( 39 | api: Arc, 40 | action_sender: UnboundedSender, 41 | ) -> UnboundedSender { 42 | let (sender, receiver) = unbounded(); 43 | 44 | std::thread::spawn(move || connect_server(api, action_sender, receiver)); 45 | 46 | sender 47 | } 48 | -------------------------------------------------------------------------------- /src/dbus/mod.rs: -------------------------------------------------------------------------------- 1 | use futures::channel::mpsc::{unbounded, UnboundedReceiver, UnboundedSender}; 2 | use futures::StreamExt; 3 | use std::rc::Rc; 4 | use std::thread; 5 | use zbus::Connection; 6 | 7 | use crate::app::{AppAction, AppModel}; 8 | 9 | mod mpris; 10 | pub use mpris::*; 11 | 12 | mod types; 13 | 14 | mod listener; 15 | use listener::*; 16 | 17 | #[tokio::main] 18 | async fn dbus_server( 19 | mpris: SpotMpris, 20 | player: SpotMprisPlayer, 21 | receiver: UnboundedReceiver, 22 | ) -> zbus::Result<()> { 23 | let connection = Connection::session().await?; 24 | connection 25 | .object_server() 26 | .at("/org/mpris/MediaPlayer2", mpris) 27 | .await?; 28 | connection 29 | .object_server() 30 | .at("/org/mpris/MediaPlayer2", player) 31 | .await?; 32 | connection 33 | .request_name("org.mpris.MediaPlayer2.Spot") 34 | .await?; 35 | 36 | receiver 37 | .for_each(|update| async { 38 | if let Ok(player_ref) = connection 39 | .object_server() 40 | .interface::<_, SpotMprisPlayer>("/org/mpris/MediaPlayer2") 41 | .await 42 | { 43 | let mut player = player_ref.get_mut().await; 44 | let ctxt = player_ref.signal_context(); 45 | let res: zbus::Result<()> = match update { 46 | MprisStateUpdate::SetVolume(volume) => { 47 | player.state_mut().set_volume(volume); 48 | player.volume_changed(ctxt).await 49 | } 50 | MprisStateUpdate::SetCurrentTrack { 51 | has_prev, 52 | has_next, 53 | current, 54 | } => { 55 | player.state_mut().set_has_prev(has_prev); 56 | player.state_mut().set_has_next(has_next); 57 | player.state_mut().set_current_track(current); 58 | player.notify_current_track_changed(ctxt).await 59 | } 60 | MprisStateUpdate::SetPositionMs(position) => { 61 | player.state_mut().set_position(position); 62 | Ok(()) 63 | } 64 | MprisStateUpdate::SetLoopStatus { 65 | has_prev, 66 | has_next, 67 | loop_status, 68 | } => { 69 | player.state_mut().set_has_prev(has_prev); 70 | player.state_mut().set_has_next(has_next); 71 | player.state_mut().set_loop_status(loop_status); 72 | player.notify_loop_status(ctxt).await 73 | } 74 | MprisStateUpdate::SetShuffled(shuffled) => { 75 | player.state_mut().set_shuffled(shuffled); 76 | player.shuffle_changed(ctxt).await 77 | } 78 | MprisStateUpdate::SetPlaying(status) => { 79 | player.state_mut().set_playing(status); 80 | player.playback_status_changed(ctxt).await 81 | } 82 | }; 83 | res.expect("Signal emission failed"); 84 | } 85 | }) 86 | .await; 87 | 88 | Ok(()) 89 | } 90 | 91 | pub fn start_dbus_server( 92 | app_model: Rc, 93 | sender: UnboundedSender, 94 | ) -> AppPlaybackStateListener { 95 | let mpris = SpotMpris::new(sender.clone()); 96 | let player = SpotMprisPlayer::new(sender); 97 | 98 | let (sender, receiver) = unbounded(); 99 | 100 | thread::spawn(move || dbus_server(mpris, player, receiver)); 101 | 102 | AppPlaybackStateListener::new(app_model, sender) 103 | } 104 | -------------------------------------------------------------------------------- /src/player/login.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Spot 5 | 28 | 29 | 30 |
31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 |

Spot connected

44 |

45 | Logged in successfully! You may close this window. 46 |

47 |
48 | 49 | -------------------------------------------------------------------------------- /src/player/mod.rs: -------------------------------------------------------------------------------- 1 | use futures::channel::mpsc::{unbounded, UnboundedReceiver, UnboundedSender}; 2 | use librespot::core::spotify_id::SpotifyId; 3 | use std::cell::RefCell; 4 | use std::rc::Rc; 5 | use std::sync::Arc; 6 | use tokio::task; 7 | use url::Url; 8 | 9 | use crate::app::state::{LoginAction, PlaybackAction}; 10 | use crate::app::AppAction; 11 | #[allow(clippy::module_inception)] 12 | mod player; 13 | pub use player::*; 14 | 15 | mod oauth2; 16 | 17 | mod token_store; 18 | pub use token_store::*; 19 | 20 | #[derive(Debug, Clone)] 21 | pub enum Command { 22 | Restore, 23 | InitLogin, 24 | CompleteLogin, 25 | RefreshToken, 26 | Logout, 27 | PlayerLoad { track: SpotifyId, resume: bool }, 28 | PlayerResume, 29 | PlayerPause, 30 | PlayerStop, 31 | PlayerSeek(u32), 32 | PlayerSetVolume(f64), 33 | PlayerPreload(SpotifyId), 34 | ReloadSettings, 35 | } 36 | 37 | struct AppPlayerDelegate { 38 | sender: RefCell>, 39 | } 40 | 41 | impl AppPlayerDelegate { 42 | fn new(sender: UnboundedSender) -> Self { 43 | let sender = RefCell::new(sender); 44 | Self { sender } 45 | } 46 | } 47 | 48 | impl SpotifyPlayerDelegate for AppPlayerDelegate { 49 | fn end_of_track_reached(&self) { 50 | self.sender 51 | .borrow_mut() 52 | .unbounded_send(PlaybackAction::Next.into()) 53 | .unwrap(); 54 | } 55 | 56 | fn token_login_successful(&self, username: String) { 57 | self.sender 58 | .borrow_mut() 59 | .unbounded_send(LoginAction::SetLoginSuccess(username).into()) 60 | .unwrap(); 61 | } 62 | 63 | fn refresh_successful(&self) { 64 | self.sender 65 | .borrow_mut() 66 | .unbounded_send(LoginAction::TokenRefreshed.into()) 67 | .unwrap(); 68 | } 69 | 70 | fn report_error(&self, error: SpotifyError) { 71 | self.sender 72 | .borrow_mut() 73 | .unbounded_send(match error { 74 | SpotifyError::LoginFailed => LoginAction::SetLoginFailure.into(), 75 | SpotifyError::LoggedOut => LoginAction::Logout.into(), 76 | _ => AppAction::ShowNotification(format!("{error}")), 77 | }) 78 | .unwrap(); 79 | } 80 | 81 | fn notify_playback_state(&self, position: u32) { 82 | self.sender 83 | .borrow_mut() 84 | .unbounded_send(PlaybackAction::SyncSeek(position).into()) 85 | .unwrap(); 86 | } 87 | 88 | fn preload_next_track(&self) { 89 | self.sender 90 | .borrow_mut() 91 | .unbounded_send(PlaybackAction::Preload.into()) 92 | .unwrap(); 93 | } 94 | 95 | fn login_challenge_started(&self, url: Url) { 96 | self.sender 97 | .borrow_mut() 98 | .unbounded_send(LoginAction::OpenLoginUrl(url).into()) 99 | .unwrap(); 100 | } 101 | } 102 | 103 | #[tokio::main] 104 | async fn player_main( 105 | player_settings: SpotifyPlayerSettings, 106 | appaction_sender: UnboundedSender, 107 | token_store: Arc, 108 | sender: UnboundedSender, 109 | receiver: UnboundedReceiver, 110 | ) { 111 | task::LocalSet::new() 112 | .run_until(async move { 113 | task::spawn_local(async move { 114 | let delegate = Rc::new(AppPlayerDelegate::new(appaction_sender.clone())); 115 | let player = SpotifyPlayer::new(player_settings, delegate, token_store, sender); 116 | player.start(receiver).await.unwrap(); 117 | }) 118 | .await 119 | .unwrap(); 120 | }) 121 | .await; 122 | } 123 | 124 | pub fn start_player_service( 125 | player_settings: SpotifyPlayerSettings, 126 | appaction_sender: UnboundedSender, 127 | token_store: Arc, 128 | ) -> UnboundedSender { 129 | let (sender, receiver) = unbounded::(); 130 | let sender_clone = sender.clone(); 131 | std::thread::spawn(move || { 132 | player_main( 133 | player_settings, 134 | appaction_sender, 135 | token_store, 136 | sender_clone, 137 | receiver, 138 | ) 139 | }); 140 | sender 141 | } 142 | -------------------------------------------------------------------------------- /src/player/token_store.rs: -------------------------------------------------------------------------------- 1 | use tokio::sync::RwLock; 2 | 3 | use crate::app::credentials::Credentials; 4 | 5 | pub struct TokenStore { 6 | storage: RwLock>, 7 | } 8 | 9 | impl TokenStore { 10 | pub fn new() -> Self { 11 | Self { 12 | storage: RwLock::new(None), 13 | } 14 | } 15 | 16 | pub fn get_cached_blocking(&self) -> Option { 17 | self.storage.blocking_read().clone() 18 | } 19 | 20 | pub async fn get_cached(&self) -> Option { 21 | self.storage.read().await.clone() 22 | } 23 | 24 | pub async fn get(&self) -> Option { 25 | let local = self.storage.read().await.clone(); 26 | if local.is_some() { 27 | return local; 28 | } 29 | 30 | match Credentials::retrieve().await { 31 | Ok(token) => { 32 | self.storage.write().await.replace(token.clone()); 33 | Some(token) 34 | } 35 | Err(e) => { 36 | error!("Couldnt get token from secrets service: {e}"); 37 | None 38 | } 39 | } 40 | } 41 | 42 | pub async fn set(&self, creds: Credentials) { 43 | debug!("Saving token to store..."); 44 | if let Err(e) = creds.save().await { 45 | warn!("Couldnt save token to secrets service: {e}"); 46 | } 47 | self.storage.write().await.replace(creds); 48 | } 49 | 50 | pub async fn clear(&self) { 51 | if let Err(e) = Credentials::logout().await { 52 | warn!("Couldnt save token to secrets service: {e}"); 53 | } 54 | self.storage.write().await.take(); 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/settings.rs: -------------------------------------------------------------------------------- 1 | use crate::player::{AudioBackend, SpotifyPlayerSettings}; 2 | use gio::prelude::SettingsExt; 3 | use libadwaita::ColorScheme; 4 | use librespot::playback::config::Bitrate; 5 | 6 | const SETTINGS: &str = "dev.alextren.Spot"; 7 | 8 | #[derive(Clone, Debug, Default)] 9 | pub struct WindowGeometry { 10 | pub width: i32, 11 | pub height: i32, 12 | pub is_maximized: bool, 13 | } 14 | 15 | impl WindowGeometry { 16 | pub fn new_from_gsettings() -> Self { 17 | let settings = gio::Settings::new(SETTINGS); 18 | Self { 19 | width: settings.int("window-width"), 20 | height: settings.int("window-height"), 21 | is_maximized: settings.boolean("window-is-maximized"), 22 | } 23 | } 24 | 25 | pub fn save(&self) -> Option<()> { 26 | let settings = gio::Settings::new(SETTINGS); 27 | settings.delay(); 28 | settings.set_int("window-width", self.width).ok()?; 29 | settings.set_int("window-height", self.height).ok()?; 30 | settings 31 | .set_boolean("window-is-maximized", self.is_maximized) 32 | .ok()?; 33 | settings.apply(); 34 | Some(()) 35 | } 36 | } 37 | 38 | // Player (librespot) settings 39 | impl SpotifyPlayerSettings { 40 | pub fn new_from_gsettings() -> Option { 41 | let settings = gio::Settings::new(SETTINGS); 42 | let bitrate = match settings.enum_("player-bitrate") { 43 | 0 => Some(Bitrate::Bitrate96), 44 | 1 => Some(Bitrate::Bitrate160), 45 | 2 => Some(Bitrate::Bitrate320), 46 | _ => None, 47 | }?; 48 | let backend = match settings.enum_("audio-backend") { 49 | 0 => Some(AudioBackend::PulseAudio), 50 | 1 => Some(AudioBackend::Alsa( 51 | settings.string("alsa-device").as_str().to_string(), 52 | )), 53 | 2 => Some(AudioBackend::GStreamer( 54 | "audioconvert dithering=none ! audioresample ! pipewiresink".to_string(), // This should be configurable eventually 55 | )), 56 | _ => None, 57 | }?; 58 | let gapless = settings.boolean("gapless-playback"); 59 | 60 | let ap_port_val = settings.uint("ap-port"); 61 | if ap_port_val > 65535 { 62 | panic!("Invalid access point port"); 63 | } 64 | 65 | // Access points usually use port 80, 443 or 4070. Since gsettings 66 | // does not allow optional values, we use 0 to indicate that any 67 | // port is OK and we should pass None to librespot's ap-port. 68 | let ap_port = match ap_port_val { 69 | 0 => None, 70 | x => Some(x as u16), 71 | }; 72 | 73 | Some(Self { 74 | bitrate, 75 | backend, 76 | gapless, 77 | ap_port, 78 | }) 79 | } 80 | } 81 | 82 | #[derive(Debug, Clone)] 83 | pub struct SpotSettings { 84 | pub theme_preference: ColorScheme, 85 | pub player_settings: SpotifyPlayerSettings, 86 | pub window: WindowGeometry, 87 | } 88 | 89 | // Application settings 90 | impl SpotSettings { 91 | pub fn new_from_gsettings() -> Option { 92 | let settings = gio::Settings::new(SETTINGS); 93 | let theme_preference = match settings.enum_("theme-preference") { 94 | 0 => Some(ColorScheme::ForceLight), 95 | 1 => Some(ColorScheme::ForceDark), 96 | 2 => Some(ColorScheme::Default), 97 | _ => None, 98 | }?; 99 | Some(Self { 100 | theme_preference, 101 | player_settings: SpotifyPlayerSettings::new_from_gsettings()?, 102 | window: WindowGeometry::new_from_gsettings(), 103 | }) 104 | } 105 | } 106 | 107 | impl Default for SpotSettings { 108 | fn default() -> Self { 109 | Self { 110 | theme_preference: ColorScheme::PreferDark, 111 | player_settings: Default::default(), 112 | window: Default::default(), 113 | } 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /src/window.blp: -------------------------------------------------------------------------------- 1 | using Gtk 4.0; 2 | using Adw 1; 3 | 4 | Adw.ApplicationWindow window { 5 | default-width: 1080; 6 | default-height: 720; 7 | 8 | Adw.Breakpoint { 9 | condition("max-width:550sp") 10 | 11 | setters { 12 | split_view.collapsed: true; 13 | } 14 | } 15 | 16 | Box { 17 | orientation: vertical; 18 | 19 | ShortcutController { 20 | scope: local; 21 | 22 | Shortcut { 23 | trigger: "space"; 24 | action: "action(app.toggle_playback)"; 25 | } 26 | 27 | Shortcut { 28 | trigger: "Q"; 29 | action: "action(app.quit)"; 30 | } 31 | 32 | Shortcut { 33 | trigger: "P"; 34 | action: "action(app.player_prev)"; 35 | } 36 | 37 | Shortcut { 38 | trigger: "N"; 39 | action: "action(app.player_next)"; 40 | } 41 | 42 | Shortcut { 43 | trigger: "Left"; 44 | action: "action(app.nav_pop)"; 45 | } 46 | 47 | Shortcut { 48 | trigger: "F"; 49 | action: "action(app.search)"; 50 | } 51 | } 52 | 53 | Adw.NavigationSplitView split_view { 54 | vexpand: true; 55 | 56 | sidebar: Adw.NavigationPage { 57 | title: "Sidebar"; 58 | 59 | child: Box { 60 | orientation: vertical; 61 | 62 | Adw.HeaderBar { 63 | styles ["flat"] 64 | 65 | Button search_button { 66 | icon-name: "system-search-symbolic"; 67 | } 68 | 69 | [title] 70 | Adw.WindowTitle { 71 | title: "Spot"; 72 | } 73 | 74 | [end] 75 | MenuButton user { 76 | icon-name: "open-menu-symbolic"; 77 | } 78 | } 79 | 80 | ScrolledWindow { 81 | hscrollbar-policy: never; 82 | ListBox home_listbox { 83 | width-request: 200; 84 | vexpand: true; 85 | 86 | styles [ 87 | "navigation-sidebar", 88 | ] 89 | } 90 | } 91 | }; 92 | }; 93 | 94 | content: Adw.NavigationPage { 95 | tag: "main"; 96 | title: "Home"; 97 | 98 | child: Box { 99 | orientation: vertical; 100 | 101 | Adw.ToastOverlay main { 102 | hexpand: true; 103 | vexpand: true; 104 | 105 | Stack navigation_stack { 106 | transition-type: slide_left_right; 107 | } 108 | } 109 | 110 | Overlay { 111 | hexpand: true; 112 | 113 | $PlaybackWidget playback { 114 | hexpand: "1"; 115 | } 116 | 117 | [overlay] 118 | $SelectionToolbarWidget selection_toolbar { 119 | hexpand: "1"; 120 | } 121 | } 122 | }; 123 | }; 124 | } 125 | } 126 | } 127 | 128 | Adw.AboutDialog about { 129 | application-name: "Spot"; 130 | website: "https://github.com/xou816/spot"; 131 | application-icon: "dev.alextren.Spot"; 132 | license-type: mit_x11; 133 | } 134 | -------------------------------------------------------------------------------- /subprojects/blueprint-compiler.wrap: -------------------------------------------------------------------------------- 1 | [wrap-git] 2 | directory = blueprint-compiler 3 | url = https://gitlab.gnome.org/jwestman/blueprint-compiler.git 4 | revision = v0.8.1 5 | depth = 1 6 | 7 | [provide] 8 | program_names = blueprint-compiler 9 | --------------------------------------------------------------------------------