├── .gitignore ├── data ├── screenshots │ ├── amberol-full.png │ ├── amberol-compact.png │ ├── amberol-playlist.png │ └── amberol-recolor.png ├── io.bassi.Amberol.service.in ├── icons │ ├── meson.build │ └── hicolor │ │ ├── symbolic │ │ └── apps │ │ │ ├── io.bassi.Amberol-symbolic.svg │ │ │ └── io.bassi.Amberol.Devel-symbolic.svg │ │ └── scalable │ │ └── apps │ │ ├── io.bassi.Amberol.svg │ │ └── io.bassi.Amberol.Devel.svg ├── io.bassi.Amberol.gschema.xml ├── io.bassi.Amberol.desktop.in.in └── meson.build ├── src ├── gtk │ ├── style-hc.css │ ├── volume-control.ui │ ├── song-details.ui │ ├── song-cover.ui │ ├── style.css │ ├── help-overlay.ui │ ├── queue-row.ui │ ├── playlist-view.ui │ └── playback-control.ui ├── assets │ └── icons │ │ ├── app-remove-symbolic.svg │ │ ├── media-playback-pause-symbolic.svg │ │ ├── view-queue-symbolic.svg │ │ ├── media-playback-start-symbolic.svg │ │ ├── view-queue-rtl-symbolic.svg │ │ ├── go-previous-symbolic.svg │ │ ├── media-playlist-consecutive-symbolic.svg │ │ ├── media-skip-backward-symbolic.svg │ │ ├── media-skip-forward-symbolic.svg │ │ ├── folder-music-symbolic.svg │ │ ├── edit-select-all-symbolic.svg │ │ ├── edit-clear-all-symbolic.svg │ │ ├── media-playlist-repeat-symbolic.svg │ │ ├── selection-mode-symbolic.svg │ │ ├── media-playlist-repeat-song-symbolic.svg │ │ ├── media-playlist-shuffle-symbolic.svg │ │ └── audio-only-symbolic.svg ├── config.rs.in ├── audio │ ├── controller.rs │ ├── inhibit_controller.rs │ ├── mod.rs │ ├── shuffle.rs │ ├── state.rs │ ├── gst_backend.rs │ ├── cover_cache.rs │ ├── mpris_controller.rs │ └── waveform_generator.rs ├── meson.build ├── song_details.rs ├── song_cover.rs ├── amberol.gresource.xml ├── search.rs ├── main.rs ├── sort.rs ├── playback_control.rs ├── playlist_view.rs ├── drag_overlay.rs ├── cover_picture.rs ├── volume_control.rs ├── i18n.rs └── queue_row.rs ├── meson_options.txt ├── .typos.toml ├── po ├── LINGUAS ├── meson.build └── POTFILES.in ├── .rustfmt.toml ├── .editorconfig ├── .gitlab-ci.yml ├── amberol.doap ├── Cargo.toml ├── RELEASING.md ├── io.bassi.Amberol.json ├── .reuse └── dep5 ├── meson.build ├── README.md ├── LICENSES └── CC0-1.0.txt └── CONTRIBUTING.md /.gitignore: -------------------------------------------------------------------------------- 1 | *.json~ 2 | 3 | src/config.rs 4 | 5 | target 6 | -------------------------------------------------------------------------------- /data/screenshots/amberol-full.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adamjatim/amberol/HEAD/data/screenshots/amberol-full.png -------------------------------------------------------------------------------- /data/io.bassi.Amberol.service.in: -------------------------------------------------------------------------------- 1 | [D-BUS Service] 2 | Name=@application_id@ 3 | Exec=@bindir@/amberol --gapplication-service 4 | -------------------------------------------------------------------------------- /data/screenshots/amberol-compact.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adamjatim/amberol/HEAD/data/screenshots/amberol-compact.png -------------------------------------------------------------------------------- /data/screenshots/amberol-playlist.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adamjatim/amberol/HEAD/data/screenshots/amberol-playlist.png -------------------------------------------------------------------------------- /data/screenshots/amberol-recolor.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adamjatim/amberol/HEAD/data/screenshots/amberol-recolor.png -------------------------------------------------------------------------------- /src/gtk/style-hc.css: -------------------------------------------------------------------------------- 1 | .main-window button:focus:focus-visible, 2 | .main-window row:focus:focus-visible, 3 | .main-window scale:focus:focus-visible > trough { 4 | outline-color: alpha(currentColor, .5); 5 | } 6 | -------------------------------------------------------------------------------- /src/assets/icons/app-remove-symbolic.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /meson_options.txt: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2022 Emmanuele Bassi 2 | # SPDX-License-Identifier: GPL-3.0-or-later 3 | 4 | option ( 5 | 'profile', 6 | type: 'combo', 7 | choices: [ 8 | 'default', 9 | 'development' 10 | ], 11 | value: 'default', 12 | ) 13 | -------------------------------------------------------------------------------- /.typos.toml: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2022 Emmanuele Bassi 2 | # SPDX-License-Identifier: CC0-1.0 3 | [files] 4 | extend-exclude = ["LICENSES/*", "po/*"] 5 | 6 | [default.extend-identifiers] 7 | # MPRIS has a bad API, and mpris-server goes along with it 8 | Seeked = "Seeked" 9 | seeked = "seeked" 10 | -------------------------------------------------------------------------------- /po/LINGUAS: -------------------------------------------------------------------------------- 1 | be 2 | bg 3 | ca 4 | cs 5 | da 6 | de 7 | el 8 | en_GB 9 | es 10 | eu 11 | fa 12 | fi 13 | fr 14 | fur 15 | gl 16 | he 17 | hi 18 | hr 19 | hu 20 | ia 21 | id 22 | is 23 | it 24 | ka 25 | ko 26 | lt 27 | my 28 | nb 29 | ne 30 | nl 31 | oc 32 | pl 33 | pt 34 | pt_BR 35 | ro 36 | ru 37 | sl 38 | sr 39 | sv 40 | tr 41 | uk 42 | zh_CN 43 | -------------------------------------------------------------------------------- /.rustfmt.toml: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2022 Emmanuele Bassi 2 | # SPDX-License-Identifier: CC0-1.0 3 | condense_wildcard_suffixes = true 4 | format_code_in_doc_comments = true 5 | group_imports = "StdExternalCrate" 6 | imports_granularity = "Crate" 7 | newline_style = "Unix" 8 | normalize_comments = true 9 | normalize_doc_attributes = true 10 | wrap_comments = true 11 | -------------------------------------------------------------------------------- /po/meson.build: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2022 Emmanuele Bassi 2 | # SPDX-License-Identifier: GPL-3.0-or-later 3 | 4 | i18n.gettext( 5 | 'amberol', 6 | args: [ 7 | '--keyword=i18n', 8 | '--keyword=i18n_f', 9 | '--keyword=i18n_k', 10 | '--keyword=ni18n:1,2', 11 | '--keyword=ni18n_f:1,2', 12 | '--keyword=ni18n_k:1,2', 13 | ], 14 | preset: 'glib', 15 | ) 16 | -------------------------------------------------------------------------------- /src/config.rs.in: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2022 Emmanuele Bassi 2 | // SPDX-License-Identifier: GPL-3.0-or-later 3 | 4 | pub static VERSION: &str = @VERSION@; 5 | pub static GETTEXT_PACKAGE: &str = @GETTEXT_PACKAGE@; 6 | pub static LOCALEDIR: &str = @LOCALEDIR@; 7 | pub static PKGDATADIR: &str = @PKGDATADIR@; 8 | pub static APPLICATION_ID: &str = @APPLICATION_ID@; 9 | pub static PROFILE: &str = @PROFILE@; 10 | -------------------------------------------------------------------------------- /po/POTFILES.in: -------------------------------------------------------------------------------- 1 | data/io.bassi.Amberol.desktop.in.in 2 | data/io.bassi.Amberol.gschema.xml 3 | data/io.bassi.Amberol.metainfo.xml.in.in 4 | src/audio/inhibit_controller.rs 5 | src/audio/song.rs 6 | src/gtk/help-overlay.ui 7 | src/gtk/playback-control.ui 8 | src/gtk/playlist-view.ui 9 | src/gtk/queue-row.ui 10 | src/gtk/window.ui 11 | src/application.rs 12 | src/cover_picture.rs 13 | src/playback_control.rs 14 | src/window.rs 15 | -------------------------------------------------------------------------------- /src/audio/controller.rs: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2022 Emmanuele Bassi 2 | // SPDX-License-Identifier: GPL-3.0-or-later 3 | 4 | use crate::audio::{PlaybackState, RepeatMode, Song}; 5 | 6 | pub trait Controller { 7 | fn set_playback_state(&self, state: &PlaybackState); 8 | 9 | fn set_song(&self, song: &Song); 10 | fn set_position(&self, position: u64); 11 | fn set_repeat_mode(&self, repeat: RepeatMode); 12 | } 13 | -------------------------------------------------------------------------------- /data/icons/meson.build: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2022 Emmanuele Bassi 2 | # SPDX-License-Identifier: GPL-3.0-or-later 3 | 4 | scalable_dir = 'hicolor/scalable/apps' 5 | install_data( 6 | scalable_dir / ('@0@.svg').format(application_id), 7 | install_dir: get_option('datadir') / 'icons' / scalable_dir, 8 | ) 9 | 10 | symbolic_dir = 'hicolor/symbolic/apps' 11 | install_data( 12 | symbolic_dir / ('@0@-symbolic.svg').format(application_id), 13 | install_dir: get_option('datadir') / 'icons' / symbolic_dir, 14 | ) 15 | -------------------------------------------------------------------------------- /src/assets/icons/media-playback-pause-symbolic.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2022 Emmanuele Bassi 2 | # SPDX-License-Identifier: CC0-1.0 3 | 4 | root = true 5 | 6 | [*] 7 | charset = utf-8 8 | end_of_line = lf 9 | trim_trailing_whitespace = true 10 | insert_final_newline = true 11 | indent_style = space 12 | indent_size = 4 13 | 14 | [*.css] 15 | indent_size = 2 16 | 17 | [*.ui] 18 | indent_size = 2 19 | 20 | [*.{xml,xml.in,xml.in.in}] 21 | indent_size = 2 22 | 23 | [meson.build] 24 | indent_size = 2 25 | 26 | [*.md] 27 | max_line_length = 80 28 | trim_trailing_whitespace = false 29 | 30 | [*.json] 31 | indent_size = 4 32 | -------------------------------------------------------------------------------- /src/assets/icons/view-queue-symbolic.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /src/assets/icons/media-playback-start-symbolic.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /src/assets/icons/view-queue-rtl-symbolic.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /src/assets/icons/go-previous-symbolic.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /src/assets/icons/media-playlist-consecutive-symbolic.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /src/assets/icons/media-skip-backward-symbolic.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /src/assets/icons/media-skip-forward-symbolic.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /data/icons/hicolor/symbolic/apps/io.bassi.Amberol-symbolic.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /data/icons/hicolor/symbolic/apps/io.bassi.Amberol.Devel-symbolic.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /src/assets/icons/folder-music-symbolic.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /data/io.bassi.Amberol.gschema.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 600 11 | 12 | 13 | 300 14 | 15 | 16 | true 17 | 18 | 19 | 'off' 20 | 21 | 22 | true 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /data/io.bassi.Amberol.desktop.in.in: -------------------------------------------------------------------------------- 1 | [Desktop Entry] 2 | Name=Amberol 3 | GenericName=Music Player 4 | TryExec=amberol 5 | Exec=amberol %U 6 | Icon=@APPLICATION_ID@ 7 | Terminal=false 8 | Type=Application 9 | Categories=GNOME;GTK;Music;Audio;AudioVideo; 10 | # Translators: Search terms to find this application. Do NOT translate or localize the semicolons! The list MUST also end with a semicolon! 11 | Keywords=music;player;media;audio;playlist; 12 | StartupNotify=true 13 | X-SingleMainWindow=true 14 | X-Purism-FormFactor=Workstation;Mobile; 15 | DBusActivatable=true 16 | MimeType=audio/mpeg;audio/wav;audio/x-aac;audio/x-aiff;audio/x-ape;audio/x-flac;audio/x-m4a;audio/x-m4b;audio/x-mp1;audio/x-mp2;audio/x-mp3;audio/x-mpg;audio/x-mpeg;audio/x-mpegurl;audio/x-opus+ogg;audio/x-pn-aiff;audio/x-pn-au;audio/x-pn-wav;audio/x-speex;audio/x-vorbis;audio/x-vorbis+ogg;audio/x-wavpack;inode/directory; 17 | -------------------------------------------------------------------------------- /.gitlab-ci.yml: -------------------------------------------------------------------------------- 1 | include: 2 | - project: 'gnome/citemplates' 3 | file: 'flatpak/flatpak-ci-initiative-sdk-extensions.yml' 4 | # ref: '' 5 | 6 | stages: 7 | - check 8 | - test 9 | - deploy 10 | 11 | flatpak: 12 | extends: ".flatpak" 13 | variables: 14 | APP_ID: "io.bassi.Amberol.Devel" 15 | BUNDLE: "io.bassi.Amberol.Devel.flatpak" 16 | FLATPAK_MODULE: "amberol" 17 | MANIFEST_PATH: "io.bassi.Amberol.json" 18 | RUNTIME_REPO: "https://nightly.gnome.org/gnome-nightly.flatpakrepo" 19 | 20 | rust-fmt: 21 | image: "registry.gitlab.com/alatiera/rustfmt-oci-image/rustfmt:stable" 22 | stage: "check" 23 | needs: [] 24 | script: 25 | - echo -e "" > src/config.rs 26 | - rustc -Vv && cargo -Vv 27 | - cargo --version 28 | - cargo fmt --all -- --check 29 | 30 | reuse: 31 | image: fedora:latest 32 | stage: "check" 33 | needs: [] 34 | before_script: 35 | - export PATH="$HOME/.local/bin:$PATH" 36 | - dnf install -y python3-pip 37 | script: 38 | - pip install --user reuse 39 | - reuse lint 40 | -------------------------------------------------------------------------------- /src/gtk/volume-control.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 29 | 30 | -------------------------------------------------------------------------------- /src/assets/icons/edit-select-all-symbolic.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /amberol.doap: -------------------------------------------------------------------------------- 1 | 6 | 7 | Amberol 8 | Plays music, and nothing else 9 | 10 | 11 | 12 | Rust 13 | GTK 4 14 | Libadwaita 15 | 16 | 17 | 18 | Emmanuele Bassi 19 | 20 | 21 | 22 | 23 | ebassi 24 | 25 | 26 | 27 | 28 | 29 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2022 Emmanuele Bassi 2 | # SPDX-License-Identifier: GPL-3.0-or-later 3 | 4 | [package] 5 | name = "amberol" 6 | authors = ["Emmanuele Bassi "] 7 | version = "0.1.0" 8 | edition = "2018" 9 | 10 | [dependencies] 11 | color-thief = "0.2.1" 12 | gdk-pixbuf = { version = "0.20", features = ["v2_42"] } 13 | gettext-rs = { version = "0.7", features = ["gettext-system"] } 14 | gtk = { version = "0.9", package = "gtk4", features = ["v4_14"] } 15 | lofty = "0.21.0" 16 | log = "0.4" 17 | mpris-server = "0.8" 18 | once_cell = "1.10" 19 | pretty_env_logger = "0.5" 20 | rand = "0.8.5" 21 | regex = "1.3.4" 22 | serde_json = "1.0" 23 | sha2 = "0.10.2" 24 | fuzzy-matcher = "0.3.7" 25 | async-channel = "2.2.0" 26 | futures = "0.3" 27 | 28 | [target.'cfg(any(target_os = "linux", target_os = "freebsd"))'.dependencies] 29 | ashpd = {version = "0.9.1", features = ["gtk4"]} 30 | 31 | [dependencies.adw] 32 | package = "libadwaita" 33 | version = "0.7" 34 | features = ["v1_5"] 35 | 36 | [dependencies.gst] 37 | package = "gstreamer" 38 | version = "0.23" 39 | 40 | [dependencies.gst-audio] 41 | package = "gstreamer-audio" 42 | version = "0.23" 43 | 44 | [dependencies.gst-player] 45 | package = "gstreamer-player" 46 | version = "0.23" 47 | -------------------------------------------------------------------------------- /src/assets/icons/edit-clear-all-symbolic.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /src/assets/icons/media-playlist-repeat-symbolic.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /RELEASING.md: -------------------------------------------------------------------------------- 1 | Releasing Amberol 2 | ================= 3 | 4 | QA Plan 5 | ------- 6 | 7 | 1. Initial state 8 | - Open folder 9 | - Open file 10 | - Drag and drop 11 | 2. Main view 12 | - Play/pause 13 | - Previous/next 14 | - Scrubbing 15 | 3. Playlist 16 | - Shuffle 17 | - Repeat: all, one, continuous 18 | - Select 19 | - Remove song 20 | - Remove all 21 | - Toggle playlist 22 | 23 | Release 24 | ------- 25 | 26 | Checklist for a release. 27 | 28 | - [ ] Update the [change log](./CHANGES.md) 29 | - [ ] **Added**: New features, settings, UI, translations 30 | - [ ] **Changed**: UI updates, improvements, translation updates 31 | - [ ] **Fixed**: bug fixes, with reference 32 | - [ ] **Removed**: Removed features, settings, UI; **IMPORTANT**: anything 33 | inside this list requires a version bump 34 | - [ ] Update the [appdata](./data/io.bassi.Amberol.appdata.xml.in.in) 35 | - [ ] New `` element 36 | - [ ] *Optional*: new screenshots 37 | - [ ] `git commit -m 'Release Amberol $VERSION'` 38 | - [ ] `meson dist` 39 | - [ ] `git tag -s $VERSION` (use the change log entry) 40 | - [ ] Bump up the project version in [`meson.build`](./meson.build) 41 | - [ ] `git push origin HEAD && git push origin $VERSION` 42 | - [ ] Create a new release on [GitLab](https://gitlab.gnome.org/World/amberol/-/releases) 43 | - [ ] Copy the `CHANGES.md` entry 44 | - [ ] Attach the release tarball and the SHA256 checksum files 45 | 46 | Flathub 47 | ------- 48 | 49 | - [ ] Update the `io.bassi.Amberol.json` manifest 50 | - [ ] Change the archive URL 51 | - [ ] Change the SHA256 checksum 52 | - [ ] `git push origin HEAD` 53 | -------------------------------------------------------------------------------- /io.bassi.Amberol.json: -------------------------------------------------------------------------------- 1 | { 2 | "app-id" : "io.bassi.Amberol.Devel", 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" : "amberol", 10 | "finish-args" : [ 11 | "--share=ipc", 12 | "--socket=fallback-x11", 13 | "--device=dri", 14 | "--socket=wayland", 15 | "--socket=pulseaudio", 16 | "--filesystem=xdg-music:ro" 17 | ], 18 | "build-options" : { 19 | "append-path" : "/usr/lib/sdk/rust-stable/bin", 20 | "build-args" : [ 21 | "--share=network" 22 | ], 23 | "env" : { 24 | "G_MESSAGES_DEBUG" : "none", 25 | "RUST_BACKTRACE" : "1", 26 | "RUST_LOG" : "amberol=debug,glib=debug" 27 | } 28 | }, 29 | "cleanup" : [ 30 | "/include", 31 | "/lib/pkgconfig", 32 | "/man", 33 | "/share/doc", 34 | "/share/gtk-doc", 35 | "/share/man", 36 | "/share/pkgconfig", 37 | "*.la", 38 | "*.a" 39 | ], 40 | "modules" : [ 41 | { 42 | "name" : "amberol", 43 | "builddir" : true, 44 | "buildsystem" : "meson", 45 | "config-opts": [ 46 | "-Dprofile=development" 47 | ], 48 | "sources" : [ 49 | { 50 | "type" : "git", 51 | "url" : "https://gitlab.gnome.org/ebassi/amberol.git" 52 | } 53 | ] 54 | } 55 | ] 56 | } 57 | -------------------------------------------------------------------------------- /src/assets/icons/selection-mode-symbolic.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /src/assets/icons/media-playlist-repeat-song-symbolic.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /src/assets/icons/media-playlist-shuffle-symbolic.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /src/audio/inhibit_controller.rs: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2022 Emmanuele Bassi 2 | // SPDX-License-Identifier: GPL-3.0-or-later 3 | 4 | use std::cell::Cell; 5 | 6 | use gtk::{gio, prelude::*}; 7 | use log::debug; 8 | 9 | use crate::{ 10 | audio::{Controller, PlaybackState, RepeatMode, Song}, 11 | i18n::i18n, 12 | }; 13 | 14 | #[derive(Debug, Default)] 15 | pub struct InhibitController { 16 | cookie: Cell, 17 | } 18 | 19 | impl InhibitController { 20 | pub fn new() -> Self { 21 | Self::default() 22 | } 23 | } 24 | 25 | impl Controller for InhibitController { 26 | fn set_playback_state(&self, playback_state: &PlaybackState) { 27 | let app = gio::Application::default() 28 | .expect("Failed to retrieve application singleton") 29 | .downcast::() 30 | .unwrap(); 31 | let win = app 32 | .active_window() 33 | .map(|win| win.downcast::().unwrap()); 34 | 35 | if playback_state == &PlaybackState::Playing { 36 | if self.cookie.get() == 0 { 37 | let cookie = app.inhibit( 38 | win.as_ref(), 39 | gtk::ApplicationInhibitFlags::SUSPEND, 40 | Some(&i18n("Playback in progress")), 41 | ); 42 | self.cookie.set(cookie); 43 | 44 | debug!("Suspend inhibited"); 45 | } 46 | } else { 47 | let cookie = self.cookie.take(); 48 | if cookie != 0 { 49 | app.uninhibit(cookie); 50 | 51 | debug!("Suspend uninhibited"); 52 | } 53 | } 54 | } 55 | 56 | fn set_song(&self, _song: &Song) {} 57 | fn set_position(&self, _position: u64) {} 58 | fn set_repeat_mode(&self, _mode: RepeatMode) {} 59 | } 60 | -------------------------------------------------------------------------------- /src/gtk/song-details.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 47 | 48 | -------------------------------------------------------------------------------- /src/gtk/song-cover.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 46 | 47 | -------------------------------------------------------------------------------- /src/meson.build: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2022 Emmanuele Bassi 2 | # SPDX-License-Identifier: GPL-3.0-or-later 3 | 4 | pkgdatadir = get_option('prefix') / get_option('datadir') / meson.project_name() 5 | 6 | gnome.compile_resources('amberol', 7 | 'amberol.gresource.xml', 8 | gresource_bundle: true, 9 | install: true, 10 | install_dir: pkgdatadir, 11 | ) 12 | 13 | conf = configuration_data() 14 | conf.set_quoted('VERSION', '@0@@1@'.format(meson.project_version(), version_suffix)) 15 | conf.set_quoted('GETTEXT_PACKAGE', 'amberol') 16 | conf.set_quoted('LOCALEDIR', get_option('prefix') / get_option('localedir')) 17 | conf.set_quoted('PKGDATADIR', pkgdatadir) 18 | conf.set_quoted('APPLICATION_ID', application_id) 19 | conf.set_quoted('PROFILE', get_option('profile')) 20 | 21 | config_rs = configure_file( 22 | input: 'config.rs.in', 23 | output: 'config.rs', 24 | configuration: conf 25 | ) 26 | 27 | # Copy the config.rs output to the source directory. 28 | run_command( 29 | 'cp', 30 | meson.project_build_root() / 'src' / 'config.rs', 31 | meson.project_source_root() / 'src' / 'config.rs', 32 | check: true, 33 | ) 34 | 35 | cargo_options = [ '--manifest-path', meson.project_source_root() / 'Cargo.toml' ] 36 | cargo_options += [ '--target-dir', meson.project_build_root() / 'src' ] 37 | 38 | if get_option('profile') == 'default' 39 | cargo_options += [ '--release' ] 40 | rust_target = 'release' 41 | message('Building in release mode') 42 | else 43 | rust_target = 'debug' 44 | message('Building in debug mode') 45 | endif 46 | 47 | cargo_env = [ 'CARGO_HOME=' + meson.project_build_root() / 'cargo-home' ] 48 | 49 | cargo_release = custom_target( 50 | 'cargo-build', 51 | build_by_default: true, 52 | build_always_stale: true, 53 | output: meson.project_name(), 54 | console: true, 55 | install: true, 56 | install_dir: get_option('bindir'), 57 | command: [ 58 | 'env', 59 | cargo_env, 60 | cargo, 'build', 61 | cargo_options, 62 | '&&', 63 | 'cp', 'src' / rust_target / meson.project_name(), '@OUTPUT@', 64 | ], 65 | ) 66 | -------------------------------------------------------------------------------- /src/song_details.rs: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2022 Emmanuele Bassi 2 | // SPDX-License-Identifier: GPL-3.0-or-later 3 | 4 | use adw::subclass::prelude::*; 5 | use gtk::{glib, prelude::*, CompositeTemplate}; 6 | 7 | mod imp { 8 | use super::*; 9 | 10 | #[derive(Debug, Default, CompositeTemplate)] 11 | #[template(resource = "/io/bassi/Amberol/song-details.ui")] 12 | pub struct SongDetails { 13 | // Template widgets 14 | #[template_child] 15 | pub song_title_label: TemplateChild, 16 | #[template_child] 17 | pub song_artist_label: TemplateChild, 18 | #[template_child] 19 | pub song_album_label: TemplateChild, 20 | } 21 | 22 | #[glib::object_subclass] 23 | impl ObjectSubclass for SongDetails { 24 | const NAME: &'static str = "AmberolSongDetails"; 25 | type Type = super::SongDetails; 26 | type ParentType = gtk::Widget; 27 | 28 | fn class_init(klass: &mut Self::Class) { 29 | Self::bind_template(klass); 30 | 31 | klass.set_layout_manager_type::(); 32 | klass.set_css_name("songdetails"); 33 | klass.set_accessible_role(gtk::AccessibleRole::Group); 34 | } 35 | 36 | fn instance_init(obj: &glib::subclass::InitializingObject) { 37 | obj.init_template(); 38 | } 39 | } 40 | 41 | impl ObjectImpl for SongDetails { 42 | fn dispose(&self) { 43 | while let Some(child) = self.obj().first_child() { 44 | child.unparent(); 45 | } 46 | } 47 | } 48 | 49 | impl WidgetImpl for SongDetails {} 50 | } 51 | 52 | glib::wrapper! { 53 | pub struct SongDetails(ObjectSubclass) 54 | @extends gtk::Widget; 55 | } 56 | 57 | impl Default for SongDetails { 58 | fn default() -> Self { 59 | glib::Object::new() 60 | } 61 | } 62 | 63 | impl SongDetails { 64 | pub fn new() -> Self { 65 | Self::default() 66 | } 67 | 68 | pub fn artist_label(&self) -> gtk::Label { 69 | self.imp().song_artist_label.get() 70 | } 71 | 72 | pub fn title_label(&self) -> gtk::Label { 73 | self.imp().song_title_label.get() 74 | } 75 | 76 | pub fn album_label(&self) -> gtk::Label { 77 | self.imp().song_album_label.get() 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /.reuse/dep5: -------------------------------------------------------------------------------- 1 | Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/ 2 | Upstream-Name: amberol 3 | Upstream-Contact: Emmanuele Bassi 4 | Source: https://gitlab.gnome.org/ebassi/amberol 5 | 6 | Files: .gitignore 7 | Copyright: 2022 Emmanuele Bassi 8 | License: CC0-1.0 9 | 10 | Files: .gitlab-ci.yml 11 | Copyright: 2022 Emmanuele Bassi 12 | License: CC0-1.0 13 | 14 | Files: amberol.doap 15 | Copyright: 2022 Sophie Herold 16 | License: CC0-1.0 17 | 18 | Files: Cargo.lock 19 | Copyright: 2022 Emmanuele Bassi 20 | License: GPL-3.0-or-later 21 | 22 | Files: README.md CONTRIBUTING.md CHANGES.md RELEASING.md 23 | Copyright: 2022 Emmanuele Bassi 24 | License: CC0-1.0 25 | 26 | Files: io.bassi.Amberol.json 27 | Copyright: 2022 Emmanuele Bassi 28 | License: CC0-1.0 29 | 30 | Files: io.bassi.Amberol.Devel.json 31 | Copyright: 2022 Emmanuele Bassi 32 | License: CC0-1.0 33 | 34 | Files: data/io.bassi.Amberol.metainfo.xml.in.in 35 | Copyright: 2022 Emmanuele Bassi 36 | License: CC0-1.0 37 | 38 | Files: data/io.bassi.Amberol.desktop.in.in 39 | Copyright: 2022 Emmanuele Bassi 40 | License: CC0-1.0 41 | 42 | Files: data/io.bassi.Amberol.gschema.xml 43 | Copyright: 2022 Emmanuele Bassi 44 | License: CC0-1.0 45 | 46 | Files: data/io.bassi.Amberol.service.in 47 | Copyright: 2022 Emmanuele Bassi 48 | License: CC0-1.0 49 | 50 | Files: data/screenshots/*.png 51 | Copyright: 2022 Emmanuele Bassi 52 | License: CC0-1.0 53 | 54 | Files: data/icons/hicolor/scalable/apps/io.bassi.Amberol.* 55 | Copyright: 2022 Jakub Steiner 56 | License: CC-BY-SA-3.0 57 | 58 | Files: data/icons/hicolor/symbolic/apps/io.bassi.Amberol.* 59 | Copyright: 2022 Jakub Steiner 60 | License: CC-BY-SA-3.0 61 | 62 | Files: *.svg 63 | Copyright: 2022 Emmanuele Bassi 64 | License: CC-BY-SA-3.0 65 | 66 | Files: po/POTFILES.in po/LINGUAS 67 | Copyright: 2022 Emmanuele Bassi 68 | License: CC0-1.0 69 | 70 | Files: po/*.po 71 | Copyright: 2022 Translation authors 72 | License: GPL-3.0-or-later 73 | 74 | Files: src/amberol.gresource.xml 75 | Copyright: 2022 Emmanuele Bassi 76 | License: GPL-3.0-or-later 77 | 78 | Files: src/gtk/*.ui 79 | Copyright: 2022 Emmanuele Bassi 80 | License: GPL-3.0-or-later 81 | 82 | Files: src/gtk/style.css src/gtk/style-hc.css 83 | Copyright: 2022 Emmanuele Bassi 84 | License: GPL-3.0-or-later 85 | 86 | Files: src/gtk/style-dark.css 87 | Copyright: 2024 Alice Mikhaylenko 88 | License: GPL-3.0-or-later 89 | -------------------------------------------------------------------------------- /src/song_cover.rs: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2023 Emmanuele Bassi 2 | // SPDX-License-Identifier: GPL-3.0-or-later 3 | 4 | use adw::subclass::prelude::*; 5 | use gtk::{glib, prelude::*, CompositeTemplate}; 6 | 7 | use crate::cover_picture::CoverPicture; 8 | 9 | mod imp { 10 | use super::*; 11 | 12 | #[derive(Debug, Default, CompositeTemplate)] 13 | #[template(resource = "/io/bassi/Amberol/song-cover.ui")] 14 | pub struct SongCover { 15 | // Template widgets 16 | #[template_child] 17 | pub cover_stack: TemplateChild, 18 | #[template_child] 19 | pub album_image: TemplateChild, 20 | } 21 | 22 | #[glib::object_subclass] 23 | impl ObjectSubclass for SongCover { 24 | const NAME: &'static str = "AmberolSongCover"; 25 | type Type = super::SongCover; 26 | type ParentType = gtk::Widget; 27 | 28 | fn class_init(klass: &mut Self::Class) { 29 | Self::bind_template(klass); 30 | 31 | klass.set_layout_manager_type::(); 32 | klass.set_css_name("songcover"); 33 | klass.set_accessible_role(gtk::AccessibleRole::Group); 34 | } 35 | 36 | fn instance_init(obj: &glib::subclass::InitializingObject) { 37 | CoverPicture::static_type(); 38 | obj.init_template(); 39 | } 40 | } 41 | 42 | impl ObjectImpl for SongCover { 43 | fn dispose(&self) { 44 | while let Some(child) = self.obj().first_child() { 45 | child.unparent(); 46 | } 47 | } 48 | } 49 | 50 | impl WidgetImpl for SongCover {} 51 | } 52 | 53 | glib::wrapper! { 54 | pub struct SongCover(ObjectSubclass) 55 | @extends gtk::Widget; 56 | } 57 | 58 | impl Default for SongCover { 59 | fn default() -> Self { 60 | glib::Object::new::() 61 | } 62 | } 63 | 64 | impl SongCover { 65 | pub fn new() -> Self { 66 | Self::default() 67 | } 68 | 69 | pub fn album_image(&self) -> CoverPicture { 70 | self.imp().album_image.get() 71 | } 72 | 73 | pub fn show_cover_image(&self, has_image: bool) { 74 | let cover_stack = self.imp().cover_stack.get(); 75 | if has_image { 76 | cover_stack.set_visible_child_name("cover-image"); 77 | } else { 78 | cover_stack.set_visible_child_name("no-image"); 79 | } 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /meson.build: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2022 Emmanuele Bassi 2 | # SPDX-License-Identifier: GPL-3.0-or-later 3 | 4 | project('amberol', 'rust', 5 | version: '2024.2', 6 | license: ['GPL-3.0'], 7 | meson_version: '>= 0.59.0', 8 | default_options: [ 'warning_level=2', ], 9 | ) 10 | 11 | dependency('gtk4', version: '>= 4.13.4') 12 | dependency('libadwaita-1', version: '>= 1.5') 13 | dependency('gstreamer-1.0', version: '>= 1.20') 14 | dependency('gstreamer-audio-1.0', version: '>= 1.20') 15 | dependency('gstreamer-player-1.0', version: '>= 1.20') 16 | dependency('gstreamer-plugins-base-1.0', version: '>= 1.20') 17 | dependency('gstreamer-plugins-bad-1.0', version: '>= 1.20') 18 | dependency('gstreamer-bad-audio-1.0', version: '>= 1.20') 19 | 20 | i18n = import('i18n') 21 | gnome = import('gnome') 22 | fs = import('fs') 23 | 24 | cargo = find_program('cargo', required: true) 25 | 26 | cargo_sources = files( 27 | 'Cargo.toml', 28 | 'Cargo.lock', 29 | ) 30 | 31 | if get_option('profile') == 'development' 32 | profile = '.Devel' 33 | if fs.is_dir('.git') 34 | vcs_tag = run_command('git', 'rev-parse', '--short', 'HEAD', check: true).stdout().strip() 35 | if vcs_tag == '' 36 | version_suffix = '-devel' 37 | else 38 | version_suffix = '-@0@'.format(vcs_tag) 39 | endif 40 | else 41 | version_suffix = '-devel' 42 | endif 43 | else 44 | profile = '' 45 | version_suffix = '' 46 | endif 47 | 48 | application_id = 'io.bassi.Amberol@0@'.format(profile) 49 | 50 | subdir('data') 51 | subdir('src') 52 | subdir('po') 53 | 54 | reuse = find_program('reuse', required: false) 55 | if reuse.found() 56 | test('license', 57 | reuse, 58 | args:['lint'], 59 | workdir: meson.project_source_root(), 60 | suite: ['lint'], 61 | ) 62 | endif 63 | 64 | meson.add_dist_script( 65 | 'build-aux/dist-vendor.sh', 66 | meson.project_source_root(), 67 | meson.project_build_root() / 'meson-dist' / '@0@-@1@'.format(meson.project_name(), meson.project_version()), 68 | ) 69 | 70 | gnome.post_install( 71 | glib_compile_schemas: true, 72 | gtk_update_icon_cache: true, 73 | update_desktop_database: true, 74 | ) 75 | 76 | summary({ 77 | 'prefix': get_option('prefix'), 78 | 'libdir': get_option('libdir'), 79 | 'datadir': get_option('datadir'), 80 | 'bindir': get_option('bindir'), 81 | }, 82 | section: 'Directories', 83 | ) 84 | 85 | summary({ 86 | 'Profile': get_option('profile'), 87 | }, 88 | section: 'Build options', 89 | ) 90 | -------------------------------------------------------------------------------- /data/meson.build: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2022 Emmanuele Bassi 2 | # SPDX-License-Identifier: GPL-3.0-or-later 3 | 4 | desktop_data = configuration_data() 5 | desktop_data.set('APPLICATION_ID', application_id) 6 | desktop_file = i18n.merge_file( 7 | input: configure_file( 8 | input: 'io.bassi.Amberol.desktop.in.in', 9 | output: 'io.bassi.Amberol.desktop.in', 10 | configuration: desktop_data, 11 | ), 12 | output: '@0@.desktop'.format(application_id), 13 | type: 'desktop', 14 | po_dir: '../po', 15 | install: true, 16 | install_dir: get_option('datadir') / 'applications', 17 | ) 18 | 19 | desktop_utils = find_program('desktop-file-validate', required: false) 20 | if desktop_utils.found() 21 | test('Validate desktop file', 22 | desktop_utils, 23 | args: [desktop_file], 24 | suite: ['lint'], 25 | ) 26 | endif 27 | 28 | appstream_data = configuration_data() 29 | appstream_data.set('APPLICATION_ID', application_id) 30 | appstream_file = i18n.merge_file( 31 | input: configure_file( 32 | input: 'io.bassi.Amberol.metainfo.xml.in.in', 33 | output: 'io.bassi.Amberol.metainfo.xml.in', 34 | configuration: appstream_data, 35 | ), 36 | output: '@0@.metainfo.xml'.format(application_id), 37 | po_dir: '../po', 38 | install: true, 39 | install_dir: get_option('datadir') / 'metainfo', 40 | ) 41 | 42 | appstreamcli = find_program('appstreamcli', required: false) 43 | if appstreamcli.found() 44 | test('Validate appstream file', 45 | appstreamcli, 46 | args: ['validate', '--no-net', '--explain', appstream_file], 47 | suite: ['lint'], 48 | ) 49 | endif 50 | 51 | install_data('io.bassi.Amberol.gschema.xml', 52 | install_dir: get_option('datadir') / 'glib-2.0/schemas', 53 | ) 54 | 55 | # Compile schemas locally, so we can run uninstalled under a devenv 56 | gnome.compile_schemas() 57 | 58 | compile_schemas = find_program('glib-compile-schemas', required: false) 59 | if compile_schemas.found() 60 | test('Validate schema file', 61 | compile_schemas, 62 | args: ['--strict', '--dry-run', meson.current_source_dir()], 63 | suite: ['lint'], 64 | ) 65 | endif 66 | 67 | service_conf = configuration_data() 68 | service_conf.set('application_id', application_id) 69 | service_conf.set('bindir', get_option('prefix') / get_option('bindir')) 70 | configure_file( 71 | input: 'io.bassi.Amberol.service.in', 72 | output: '@0@.service'.format(application_id), 73 | configuration: service_conf, 74 | install: true, 75 | install_dir: get_option('datadir') / 'dbus-1/services', 76 | ) 77 | 78 | subdir('icons') 79 | -------------------------------------------------------------------------------- /src/amberol.gresource.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | assets/icons/app-remove-symbolic.svg 5 | assets/icons/audio-only-symbolic.svg 6 | assets/icons/edit-clear-all-symbolic.svg 7 | assets/icons/edit-select-all-symbolic.svg 8 | assets/icons/folder-music-symbolic.svg 9 | assets/icons/go-previous-symbolic.svg 10 | assets/icons/media-playback-pause-symbolic.svg 11 | assets/icons/media-playback-start-symbolic.svg 12 | assets/icons/media-playlist-consecutive-symbolic.svg 13 | assets/icons/media-playlist-repeat-song-symbolic.svg 14 | assets/icons/media-playlist-repeat-symbolic.svg 15 | assets/icons/media-playlist-shuffle-symbolic.svg 16 | assets/icons/media-skip-backward-symbolic.svg 17 | assets/icons/media-skip-forward-symbolic.svg 18 | assets/icons/selection-mode-symbolic.svg 19 | assets/icons/view-queue-rtl-symbolic.svg 20 | assets/icons/view-queue-symbolic.svg 21 | 22 | 23 | gtk/help-overlay.ui 24 | gtk/playback-control.ui 25 | gtk/playlist-view.ui 26 | gtk/queue-row.ui 27 | gtk/song-cover.ui 28 | gtk/song-details.ui 29 | gtk/style-hc.css 30 | gtk/style.css 31 | gtk/volume-control.ui 32 | gtk/window.ui 33 | 34 | 35 | -------------------------------------------------------------------------------- /src/audio/mod.rs: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2022 Emmanuele Bassi 2 | // SPDX-License-Identifier: GPL-3.0-or-later 3 | 4 | // Audio playback is slightly more complicated than simple UI handling code. 5 | // 6 | // We want to bind the state of the UI to the state of the player through 7 | // well established patterns of property bindings and signal emissions, but 8 | // the player can be updated from different threads and by different contexts 9 | // which has an effect on the types that are in play. Of course, we can work 10 | // around it by telling GStreamer that we want to receive signals in the 11 | // default main context, but it's always largely iffy to do so as it requires 12 | // working against the type system through the use of wrappers like Rc and 13 | // Fragile. 14 | 15 | // To avoid this mess, the best practice is to use message passing. 16 | // 17 | // In this particular case, the design of the audio playback interface is 18 | // split into the following components: 19 | // 20 | // AudioPlayer: the main object managing the audio playback 21 | // ├── PlayerState: the state tracker GObject used by the UI 22 | // ├── Queue: the playlist tracker GListModel 23 | // ├── GstBackend: a GstPlayer wrapper 24 | // ╰── controllers: external bits of code that interact with the state 25 | // ╰── MprisController: an MPRIS wrapper 26 | // 27 | // The AudioPlayer object creates a glib::Sender/Receiver channel pair, and 28 | // passes the sender to the controllers; whenever the controllers update their 29 | // state, they will use the glib::Sender to notify the AudioPlayer, which will 30 | // update the PlayerState. 31 | // 32 | // The GstBackend uses a similar sender/receiver pair to communicate with the 33 | // AudioPlayer whenever the GStreamer state changes. 34 | // 35 | // The UI side connects to the PlayerState object for state tracking; all 36 | // changes to the state object happen in the main context by design. 37 | // 38 | // Playback actions are proxied to the AudioPlayer object from the controllers. 39 | 40 | mod controller; 41 | pub use controller::Controller; 42 | 43 | mod cover_cache; 44 | pub use cover_cache::CoverCache; 45 | 46 | mod inhibit_controller; 47 | mod mpris_controller; 48 | pub use inhibit_controller::InhibitController; 49 | pub use mpris_controller::MprisController; 50 | 51 | mod gst_backend; 52 | pub use gst_backend::GstBackend; 53 | 54 | mod player; 55 | mod queue; 56 | mod shuffle; 57 | mod song; 58 | mod state; 59 | mod waveform_generator; 60 | 61 | pub use player::{ 62 | AudioPlayer, PlaybackAction, PlaybackState, RepeatMode, ReplayGainMode, SeekDirection, 63 | }; 64 | pub use queue::Queue; 65 | pub use shuffle::ShuffleListModel; 66 | pub use song::Song; 67 | pub use state::PlayerState; 68 | pub use waveform_generator::WaveformGenerator; 69 | -------------------------------------------------------------------------------- /src/assets/icons/audio-only-symbolic.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /src/search.rs: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2022 John Toohey 2 | // SPDX-License-Identifier: GPL-3.0-or-later 3 | 4 | use gtk::{glib, prelude::*, subclass::prelude::*}; 5 | 6 | mod imp { 7 | 8 | use std::cell::RefCell; 9 | 10 | use fuzzy_matcher::{skim::SkimMatcherV2, FuzzyMatcher}; 11 | use gtk::{ 12 | glib::{self, ParamSpec, ParamSpecString, Value}, 13 | prelude::*, 14 | subclass::prelude::*, 15 | }; 16 | use once_cell::sync::Lazy; 17 | 18 | use crate::audio::Song; 19 | 20 | #[derive(Default)] 21 | pub struct FuzzyFilter { 22 | pub search: RefCell>, 23 | } 24 | 25 | #[glib::object_subclass] 26 | impl ObjectSubclass for FuzzyFilter { 27 | const NAME: &'static str = "AmberolFuzzyFilter"; 28 | type Type = super::FuzzyFilter; 29 | type ParentType = gtk::Filter; 30 | } 31 | 32 | impl ObjectImpl for FuzzyFilter { 33 | fn properties() -> &'static [ParamSpec] { 34 | static PROPERTIES: Lazy> = 35 | Lazy::new(|| vec![ParamSpecString::builder("search").build()]); 36 | PROPERTIES.as_ref() 37 | } 38 | 39 | fn set_property(&self, _id: usize, value: &Value, pspec: &ParamSpec) { 40 | match pspec.name() { 41 | "search" => { 42 | let p = value 43 | .get::>() 44 | .expect("Value must be a string"); 45 | self.obj().set_search(p); 46 | } 47 | _ => unimplemented!(), 48 | } 49 | } 50 | } 51 | 52 | impl FilterImpl for FuzzyFilter { 53 | fn strictness(&self) -> gtk::FilterMatch { 54 | gtk::FilterMatch::Some 55 | } 56 | 57 | fn match_(&self, song: &glib::Object) -> bool { 58 | let song = song.downcast_ref::().unwrap(); 59 | 60 | if let Some(search) = self.search.borrow().as_ref() { 61 | let key = song.search_key(); 62 | let matcher = SkimMatcherV2::default(); 63 | matcher.fuzzy_match(&key, search).is_some() || search.is_empty() 64 | } else { 65 | true 66 | } 67 | } 68 | } 69 | } 70 | 71 | glib::wrapper! { 72 | pub struct FuzzyFilter(ObjectSubclass) 73 | @extends gtk::Filter; 74 | 75 | } 76 | 77 | impl Default for FuzzyFilter { 78 | fn default() -> Self { 79 | Self::new() 80 | } 81 | } 82 | 83 | impl FuzzyFilter { 84 | pub fn new() -> Self { 85 | glib::Object::new() 86 | } 87 | 88 | pub fn search(&self) -> Option { 89 | self.imp().search.borrow().as_ref().map(ToString::to_string) 90 | } 91 | 92 | pub fn set_search(&self, search: Option) { 93 | if *self.imp().search.borrow() != search { 94 | *self.imp().search.borrow_mut() = search.map(|x| x.to_lowercase()); 95 | self.changed(gtk::FilterChange::Different); 96 | } 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2022 Emmanuele Bassi 2 | // SPDX-License-Identifier: GPL-3.0-or-later 3 | 4 | mod application; 5 | mod audio; 6 | mod config; 7 | mod cover_picture; 8 | mod drag_overlay; 9 | mod i18n; 10 | mod playback_control; 11 | mod playlist_view; 12 | mod queue_row; 13 | mod search; 14 | mod song_cover; 15 | mod song_details; 16 | mod sort; 17 | mod utils; 18 | mod volume_control; 19 | mod waveform_view; 20 | mod window; 21 | 22 | use std::env; 23 | 24 | use config::{APPLICATION_ID, GETTEXT_PACKAGE, LOCALEDIR, PKGDATADIR, PROFILE}; 25 | use gettextrs::{bind_textdomain_codeset, bindtextdomain, setlocale, textdomain, LocaleCategory}; 26 | use gtk::{gio, glib, prelude::*}; 27 | use log::{debug, error, LevelFilter}; 28 | 29 | use self::application::Application; 30 | 31 | fn main() -> glib::ExitCode { 32 | let mut builder = pretty_env_logger::formatted_builder(); 33 | if APPLICATION_ID.ends_with("Devel") { 34 | builder.filter(Some("amberol"), LevelFilter::Debug); 35 | } else { 36 | builder.filter(Some("amberol"), LevelFilter::Info); 37 | } 38 | builder.init(); 39 | 40 | // Set up gettext translations 41 | debug!("Setting up locale data"); 42 | setlocale(LocaleCategory::LcAll, ""); 43 | 44 | bindtextdomain(GETTEXT_PACKAGE, LOCALEDIR).expect("Unable to bind the text domain"); 45 | bind_textdomain_codeset(GETTEXT_PACKAGE, "UTF-8") 46 | .expect("Unable to set the text domain encoding"); 47 | textdomain(GETTEXT_PACKAGE).expect("Unable to switch to the text domain"); 48 | 49 | debug!("Setting up pulseaudio environment"); 50 | let app_id = APPLICATION_ID.trim_end_matches(".Devel"); 51 | env::set_var("PULSE_PROP_application.icon_name", app_id); 52 | env::set_var("PULSE_PROP_application.metadata().name", "Amberol"); 53 | env::set_var("PULSE_PROP_media.role", "music"); 54 | 55 | debug!("Loading resources"); 56 | let resources = match env::var("MESON_DEVENV") { 57 | Err(_) => gio::Resource::load(PKGDATADIR.to_owned() + "/amberol.gresource") 58 | .expect("Unable to find amberol.gresource"), 59 | Ok(_) => match env::current_exe() { 60 | Ok(path) => { 61 | let mut resource_path = path; 62 | resource_path.pop(); 63 | resource_path.push("amberol.gresource"); 64 | gio::Resource::load(&resource_path) 65 | .expect("Unable to find amberol.gresource in devenv") 66 | } 67 | Err(err) => { 68 | error!("Unable to find the current path: {}", err); 69 | return glib::ExitCode::FAILURE; 70 | } 71 | }, 72 | }; 73 | gio::resources_register(&resources); 74 | 75 | debug!("Setting up application (profile: {})", &PROFILE); 76 | glib::set_application_name("Amberol"); 77 | glib::set_program_name(Some("amberol")); 78 | 79 | gst::init().expect("Failed to initialize gstreamer"); 80 | 81 | let ctx = glib::MainContext::default(); 82 | let _guard = ctx.acquire().unwrap(); 83 | 84 | Application::new().run() 85 | } 86 | -------------------------------------------------------------------------------- /data/icons/hicolor/scalable/apps/io.bassi.Amberol.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Amberol 2 | ======= 3 | 4 | ![Application icon](./data/icons/hicolor/scalable/apps/io.bassi.Amberol.svg) 5 | 6 | A small and simple sound and music player that is well integrated with GNOME. 7 | 8 | Amberol aspires to be as small, unintrusive, and simple as possible. It does 9 | not manage your music collection; it does not let you manage playlists, smart 10 | or otherwise; it does not let you edit the metadata for your songs; it does 11 | not show you lyrics for your songs, or the Wikipedia page for your bands. 12 | 13 | Amberol plays music, and nothing else. 14 | 15 | ![Full UI](./data/screenshots/amberol-full.png) 16 | ![Compact UI](./data/screenshots/amberol-compact.png) 17 | 18 | Flatpak builds 19 | -------------- 20 | 21 | The recommended way of installing Amberol is through Flatpak. If you don't have 22 | Flatpak installed, you can get it from [the Flatpak website](https://flatpak.org/setup). 23 | 24 | You can install stable builds of Amberol from [Flathub](https://flathub.org) 25 | by using this command: 26 | 27 | flatpak remote-add --if-not-exists flathub https://flathub.org/repo/flathub.flatpakrepo 28 | flatpak install flathub io.bassi.Amberol 29 | 30 | 31 | 32 | Getting in touch 33 | ---------------- 34 | 35 | If you have questions about Amberol, you can join the [`#amberol:gnome.org`](https://matrix.to/#/#amberol:gnome.org) 36 | channel on Matrix, or use the [GNOME Discourse instance](https://discourse.gnome.org/c/applications/7). 37 | 38 | Contributing 39 | ------------ 40 | 41 | Please, see the [contribution guide](./CONTRIBUTING.md) if you wish to report 42 | and issue, fix a bug, or implement a new feature. 43 | 44 | How to obtain debugging information 45 | ----------------------------------- 46 | 47 | Run Amberol from your terminal using: 48 | 49 | RUST_BACKTRACE=1 RUST_LOG=amberol=debug flatpak run io.bassi.Amberol 50 | 51 | to obtain a full debug log. 52 | 53 | Translations 54 | ------------ 55 | 56 | Amberol is translated on the [GNOME translation platform](https://l10n.gnome.org/module/amberol). 57 | 58 | You should contact the coordinator of [the localization team for your language](https://l10n.gnome.org/teams/) 59 | if you have questions. 60 | 61 | For more information, please see the [GNOME Translation Project wiki](https://wiki.gnome.org/TranslationProject). 62 | 63 | Code of conduct 64 | --------------- 65 | 66 | Amberol follows the GNOME project [Code of Conduct](./code-of-conduct.md). All 67 | communications in project spaces, such as the issue tracker or 68 | [Discourse](https://discourse.gnome.org) are expected to follow it. 69 | 70 | Why is it called "Amberol"? 71 | --------------------------- 72 | 73 | The name comes from the the [Blue Amberol 74 | Records](https://en.wikipedia.org/wiki/Blue_Amberol_Records), a type of cylinder 75 | records made of (blue) nitrocellulose, capable of playback durations of around 76 | four minutes, just about the length of the average song since 1990. 77 | 78 | Copyright and licensing 79 | ----------------------- 80 | 81 | Copyright 2022 Emmanuele Bassi 82 | 83 | Amberol is released under the terms of the GNU General Public License, either 84 | version 3.0 or, at your option, any later version. 85 | -------------------------------------------------------------------------------- /src/sort.rs: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2022 John Toohey 2 | // SPDX-License-Identifier: GPL-3.0-or-later 3 | 4 | use gtk::{glib, prelude::*, subclass::prelude::*}; 5 | 6 | mod imp { 7 | 8 | use std::cell::RefCell; 9 | 10 | use fuzzy_matcher::{skim::SkimMatcherV2, FuzzyMatcher}; 11 | use gtk::{ 12 | glib::{self, ParamSpec, ParamSpecString, Value}, 13 | prelude::*, 14 | subclass::prelude::*, 15 | }; 16 | use once_cell::sync::Lazy; 17 | 18 | use crate::{audio::Song, utils::cmp_two_files}; 19 | 20 | #[derive(Default)] 21 | pub struct FuzzySorter { 22 | pub search: RefCell>, 23 | } 24 | 25 | #[glib::object_subclass] 26 | impl ObjectSubclass for FuzzySorter { 27 | const NAME: &'static str = "AmberolFuzzySorter"; 28 | type Type = super::FuzzySorter; 29 | type ParentType = gtk::Sorter; 30 | } 31 | 32 | impl ObjectImpl for FuzzySorter { 33 | fn properties() -> &'static [ParamSpec] { 34 | static PROPERTIES: Lazy> = 35 | Lazy::new(|| vec![ParamSpecString::builder("search").build()]); 36 | PROPERTIES.as_ref() 37 | } 38 | 39 | fn set_property(&self, _id: usize, value: &Value, pspec: &ParamSpec) { 40 | match pspec.name() { 41 | "search" => { 42 | let p = value 43 | .get::>() 44 | .expect("Value must be a string"); 45 | self.obj().set_search(p); 46 | } 47 | _ => unimplemented!(), 48 | } 49 | } 50 | } 51 | 52 | impl SorterImpl for FuzzySorter { 53 | fn compare(&self, item1: &glib::Object, item2: &glib::Object) -> gtk::Ordering { 54 | let item1 = item1.downcast_ref::().unwrap(); 55 | let item2 = item2.downcast_ref::().unwrap(); 56 | 57 | if let Some(search) = self.search.borrow().as_ref() { 58 | let matcher = SkimMatcherV2::default(); 59 | let item1_key = item1.search_key(); 60 | let item2_key = item2.search_key(); 61 | let item1_score = matcher.fuzzy_match(&item1_key, search); 62 | let item2_score = matcher.fuzzy_match(&item2_key, search); 63 | item1_score.cmp(&item2_score).reverse().into() 64 | } else { 65 | cmp_two_files(None, &item1.file(), &item2.file()).into() 66 | } 67 | } 68 | 69 | fn order(&self) -> gtk::SorterOrder { 70 | gtk::SorterOrder::Partial 71 | } 72 | } 73 | } 74 | 75 | glib::wrapper! { 76 | pub struct FuzzySorter(ObjectSubclass) 77 | @extends gtk::Sorter; 78 | 79 | } 80 | 81 | impl Default for FuzzySorter { 82 | fn default() -> Self { 83 | Self::new() 84 | } 85 | } 86 | 87 | impl FuzzySorter { 88 | pub fn new() -> Self { 89 | glib::Object::new() 90 | } 91 | 92 | pub fn search(&self) -> Option { 93 | self.imp().search.borrow().as_ref().map(ToString::to_string) 94 | } 95 | 96 | pub fn set_search(&self, search: Option) { 97 | if *self.imp().search.borrow() != search { 98 | *self.imp().search.borrow_mut() = search; 99 | self.changed(gtk::SorterChange::Different); 100 | } 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /src/gtk/style.css: -------------------------------------------------------------------------------- 1 | @define-color dimmed_color alpha(currentColor, .55); 2 | 3 | button.large { 4 | min-width: 48px; 5 | min-height: 48px; 6 | } 7 | 8 | .main-box { 9 | padding: 6px; 10 | margin-left: 12px; 11 | margin-right: 12px; 12 | } 13 | 14 | songcover { 15 | padding-bottom: 24px; 16 | } 17 | 18 | songcover picture.cover, 19 | songcover image.card { 20 | box-shadow: 0px 1px 6px rgba(0,0,0,0.3), 21 | 0px 2px 12px rgba(0,0,0,0.15), 22 | 0px 6px 32px rgba(0,0,0,0.1); 23 | border-radius: 12px; 24 | margin: 6px; 25 | } 26 | 27 | .main-window > overlay-split-view > .sidebar-pane { 28 | background: transparent; 29 | } 30 | 31 | .main-window songcover picture.cover { 32 | background: linear-gradient(to bottom, @background_color_0, @background_color_1); 33 | } 34 | 35 | songdetails { 36 | padding-top: 12px; 37 | padding-bottom: 24px; 38 | } 39 | 40 | songdetails label.song-title { 41 | /* match title-1 */ 42 | font-size: 15pt; 43 | font-weight: 800; 44 | padding-left: 12px; 45 | padding-right: 12px; 46 | } 47 | 48 | songdetails label.song-title, 49 | songdetails label.song-artist, 50 | songdetails label.song-album { 51 | min-height: 1.5em; 52 | } 53 | 54 | waveformview { 55 | padding-bottom: 6px; 56 | } 57 | 58 | playbackcontrol { 59 | padding-bottom: 12px; 60 | } 61 | 62 | queuerow .currently-playing { 63 | padding-left: 6px; 64 | font-weight: 700; 65 | } 66 | 67 | queuerow box.song-details { 68 | padding: 6px 0; 69 | border-spacing: 12px; 70 | } 71 | 72 | queuerow label.song-title { 73 | font-weight: 700; 74 | font-size: 85%; 75 | } 76 | 77 | queuerow label.song-artist { 78 | font-size: 85%; 79 | } 80 | 81 | queuerow picture.cover, 82 | queuerow image.card { 83 | box-shadow: none; 84 | border-radius: 4px; 85 | margin: 0px; 86 | } 87 | 88 | queuerow checkbutton.selection-mode { 89 | padding-right: 12px; 90 | padding-left: 8px; 91 | } 92 | 93 | .queue-length { 94 | padding: 9px 12px; 95 | } 96 | 97 | dragoverlay { 98 | box-shadow: none; 99 | } 100 | 101 | .drag-overlay-status-page { 102 | background-color: alpha(@accent_bg_color, 0.5); 103 | color: @accent_fg_color; 104 | padding: 32px; 105 | } 106 | 107 | .blurred { 108 | filter: blur(6px); 109 | } 110 | 111 | .main-window { 112 | background: linear-gradient(127deg, alpha(@background_color_0, .55), alpha(@background_color_0, 0) 70.71%), 113 | linear-gradient(217deg, alpha(@background_color_1, .55), alpha(@background_color_1, 0) 70.71%), 114 | linear-gradient(336deg, alpha(@background_color_2, .55), alpha(@background_color_2, 0) 70.71%); 115 | transition-property: background; 116 | transition-duration: 250ms; 117 | transition-timing-function: ease; 118 | } 119 | 120 | .darken { 121 | background-color: rgba(0, 0, 0, 0.08); 122 | } 123 | 124 | .main-window > overlay-split-view > widget.background > playlistview { 125 | background: linear-gradient(127deg, alpha(@background_color_0, .55), alpha(@background_color_0, 0) 70.71%), 126 | linear-gradient(217deg, alpha(@background_color_1, .55), alpha(@background_color_1, 0) 70.71%), 127 | linear-gradient(336deg, alpha(@background_color_2, .55), alpha(@background_color_2, 0) 70.71%), 128 | @window_bg_color; 129 | transition-property: background; 130 | transition-duration: 250ms; 131 | transition-timing-function: ease; 132 | } 133 | 134 | .main-window button:focus:focus-visible, 135 | .main-window row:focus:focus-visible, 136 | .main-window scale:focus:focus-visible > trough, 137 | .main-window searchbar entry.search { 138 | outline-color: alpha(currentColor, .25); 139 | } 140 | 141 | .main-window scale highlight, 142 | .main-window progressbar progress { 143 | background: currentColor; 144 | color: inherit; 145 | } 146 | 147 | volume > scale { color: @dimmed_color; } 148 | 149 | volume:hover > scale { color: inherit; } 150 | 151 | volume > scale trough highlight { min-height: 12px; min-width: 12px; } 152 | 153 | volume > scale trough slider { opacity: 0; } 154 | -------------------------------------------------------------------------------- /src/playback_control.rs: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2022 Emmanuele Bassi 2 | // SPDX-License-Identifier: GPL-3.0-or-later 3 | 4 | use adw::subclass::prelude::*; 5 | use gtk::{gio, glib, prelude::*, CompositeTemplate}; 6 | 7 | use crate::{audio::RepeatMode, i18n::i18n, volume_control::VolumeControl}; 8 | 9 | mod imp { 10 | use super::*; 11 | 12 | #[derive(Debug, Default, CompositeTemplate)] 13 | #[template(resource = "/io/bassi/Amberol/playback-control.ui")] 14 | pub struct PlaybackControl { 15 | // Template widgets 16 | #[template_child] 17 | pub start_box: TemplateChild, 18 | #[template_child] 19 | pub center_box: TemplateChild, 20 | #[template_child] 21 | pub end_box: TemplateChild, 22 | 23 | #[template_child] 24 | pub previous_button: TemplateChild, 25 | #[template_child] 26 | pub play_button: TemplateChild, 27 | #[template_child] 28 | pub next_button: TemplateChild, 29 | 30 | #[template_child] 31 | pub volume_control: TemplateChild, 32 | 33 | #[template_child] 34 | pub playlist_button: TemplateChild, 35 | #[template_child] 36 | pub shuffle_button: TemplateChild, 37 | #[template_child] 38 | pub repeat_button: TemplateChild, 39 | #[template_child] 40 | pub menu_button: TemplateChild, 41 | } 42 | 43 | #[glib::object_subclass] 44 | impl ObjectSubclass for PlaybackControl { 45 | const NAME: &'static str = "AmberolPlaybackControl"; 46 | type Type = super::PlaybackControl; 47 | type ParentType = gtk::Widget; 48 | 49 | fn class_init(klass: &mut Self::Class) { 50 | Self::bind_template(klass); 51 | 52 | klass.set_layout_manager_type::(); 53 | klass.set_css_name("playbackcontrol"); 54 | klass.set_accessible_role(gtk::AccessibleRole::Group); 55 | } 56 | 57 | fn instance_init(obj: &glib::subclass::InitializingObject) { 58 | VolumeControl::static_type(); 59 | obj.init_template(); 60 | } 61 | } 62 | 63 | impl ObjectImpl for PlaybackControl { 64 | fn dispose(&self) { 65 | while let Some(child) = self.obj().first_child() { 66 | child.unparent(); 67 | } 68 | } 69 | 70 | fn constructed(&self) { 71 | self.parent_constructed(); 72 | 73 | self.menu_button.set_primary(true); 74 | } 75 | } 76 | 77 | impl WidgetImpl for PlaybackControl {} 78 | } 79 | 80 | glib::wrapper! { 81 | pub struct PlaybackControl(ObjectSubclass) 82 | @extends gtk::Widget, 83 | @implements gio::ActionGroup, gio::ActionMap; 84 | } 85 | 86 | impl Default for PlaybackControl { 87 | fn default() -> Self { 88 | glib::Object::new() 89 | } 90 | } 91 | 92 | impl PlaybackControl { 93 | pub fn new() -> Self { 94 | Self::default() 95 | } 96 | 97 | pub fn play_button(&self) -> gtk::Button { 98 | self.imp().play_button.get() 99 | } 100 | 101 | pub fn repeat_button(&self) -> gtk::Button { 102 | self.imp().repeat_button.get() 103 | } 104 | 105 | pub fn volume_control(&self) -> VolumeControl { 106 | self.imp().volume_control.get() 107 | } 108 | 109 | pub fn set_repeat_mode(&self, repeat_mode: RepeatMode) { 110 | let repeat_button = self.imp().repeat_button.get(); 111 | match repeat_mode { 112 | RepeatMode::Consecutive => { 113 | repeat_button.set_icon_name("media-playlist-consecutive-symbolic"); 114 | repeat_button.set_tooltip_text(Some(&i18n("Enable Repeat"))); 115 | } 116 | RepeatMode::RepeatAll => { 117 | repeat_button.set_icon_name("media-playlist-repeat-symbolic"); 118 | repeat_button.set_tooltip_text(Some(&i18n("Repeat All Songs"))); 119 | } 120 | RepeatMode::RepeatOne => { 121 | repeat_button.set_icon_name("media-playlist-repeat-song-symbolic"); 122 | repeat_button.set_tooltip_text(Some(&i18n("Repeat the Current Song"))); 123 | } 124 | } 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /src/playlist_view.rs: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2022 Emmanuele Bassi 2 | // SPDX-License-Identifier: GPL-3.0-or-later 3 | 4 | use adw::subclass::prelude::*; 5 | use gtk::{gio, glib, prelude::*, CompositeTemplate}; 6 | 7 | mod imp { 8 | use super::*; 9 | 10 | #[derive(Debug, Default, CompositeTemplate)] 11 | #[template(resource = "/io/bassi/Amberol/playlist-view.ui")] 12 | pub struct PlaylistView { 13 | #[template_child] 14 | pub back_button: TemplateChild, 15 | #[template_child] 16 | pub queue_view: TemplateChild, 17 | #[template_child] 18 | pub queue_length_label: TemplateChild, 19 | #[template_child] 20 | pub queue_actionbar: TemplateChild, 21 | #[template_child] 22 | pub queue_select_all_button: TemplateChild, 23 | #[template_child] 24 | pub queue_remove_button: TemplateChild, 25 | #[template_child] 26 | pub queue_selected_label: TemplateChild, 27 | #[template_child] 28 | pub playlist_progress: TemplateChild, 29 | #[template_child] 30 | pub playlist_searchbar: TemplateChild, 31 | #[template_child] 32 | pub playlist_searchentry: TemplateChild, 33 | } 34 | 35 | #[glib::object_subclass] 36 | impl ObjectSubclass for PlaylistView { 37 | const NAME: &'static str = "AmberolPlaylistView"; 38 | type Type = super::PlaylistView; 39 | type ParentType = gtk::Widget; 40 | 41 | fn class_init(klass: &mut Self::Class) { 42 | Self::bind_template(klass); 43 | 44 | klass.set_layout_manager_type::(); 45 | klass.set_css_name("playlistview"); 46 | klass.set_accessible_role(gtk::AccessibleRole::Group); 47 | } 48 | 49 | fn instance_init(obj: &glib::subclass::InitializingObject) { 50 | obj.init_template(); 51 | } 52 | } 53 | 54 | impl ObjectImpl for PlaylistView { 55 | fn dispose(&self) { 56 | while let Some(child) = self.obj().first_child() { 57 | child.unparent(); 58 | } 59 | } 60 | 61 | fn constructed(&self) { 62 | self.parent_constructed(); 63 | 64 | self.obj().setup_searchbar(); 65 | } 66 | } 67 | 68 | impl WidgetImpl for PlaylistView {} 69 | } 70 | 71 | glib::wrapper! { 72 | pub struct PlaylistView(ObjectSubclass) 73 | @extends gtk::Widget, 74 | @implements gio::ActionGroup, gio::ActionMap; 75 | } 76 | 77 | impl Default for PlaylistView { 78 | fn default() -> Self { 79 | glib::Object::new() 80 | } 81 | } 82 | 83 | impl PlaylistView { 84 | pub fn new() -> Self { 85 | Self::default() 86 | } 87 | 88 | fn setup_searchbar(&self) { 89 | let entry = self.imp().playlist_searchentry.get(); 90 | self.imp().playlist_searchbar.connect_entry(&entry); 91 | } 92 | 93 | pub fn back_button(&self) -> gtk::Button { 94 | self.imp().back_button.get() 95 | } 96 | 97 | pub fn queue_actionbar(&self) -> gtk::ActionBar { 98 | self.imp().queue_actionbar.get() 99 | } 100 | 101 | pub fn queue_remove_button(&self) -> gtk::Button { 102 | self.imp().queue_remove_button.get() 103 | } 104 | 105 | pub fn queue_select_all_button(&self) -> gtk::Button { 106 | self.imp().queue_select_all_button.get() 107 | } 108 | 109 | pub fn queue_selected_label(&self) -> gtk::Label { 110 | self.imp().queue_selected_label.get() 111 | } 112 | 113 | pub fn queue_view(&self) -> gtk::ListView { 114 | self.imp().queue_view.get() 115 | } 116 | 117 | pub fn queue_length_label(&self) -> gtk::Label { 118 | self.imp().queue_length_label.get() 119 | } 120 | 121 | pub fn playlist_searchbar(&self) -> gtk::SearchBar { 122 | self.imp().playlist_searchbar.get() 123 | } 124 | 125 | pub fn playlist_searchentry(&self) -> gtk::SearchEntry { 126 | self.imp().playlist_searchentry.get() 127 | } 128 | 129 | pub fn begin_loading(&self) { 130 | self.imp().playlist_progress.set_fraction(0.0); 131 | self.imp().playlist_progress.set_visible(true); 132 | } 133 | 134 | pub fn end_loading(&self) { 135 | self.imp().playlist_progress.set_visible(false); 136 | } 137 | 138 | pub fn update_loading(&self, cur: u32, max: u32) { 139 | let step = cur as f64 / max as f64; 140 | self.imp().playlist_progress.set_fraction(step); 141 | } 142 | 143 | pub fn set_search(&self, search: bool) { 144 | self.imp().playlist_searchbar.set_search_mode(search); 145 | } 146 | } 147 | -------------------------------------------------------------------------------- /src/drag_overlay.rs: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2022 Maximiliano Sandoval R 2 | // SPDX-License-Identifier: GPL-3.0-or-later 3 | 4 | use gtk::{glib, prelude::*, subclass::prelude::*}; 5 | 6 | mod imp { 7 | use std::cell::RefCell; 8 | 9 | use adw::subclass::prelude::*; 10 | use once_cell::sync::Lazy; 11 | 12 | use super::*; 13 | 14 | #[derive(Debug, Default)] 15 | pub struct DragOverlay { 16 | pub overlay: gtk::Overlay, 17 | pub revealer: gtk::Revealer, 18 | pub status: adw::StatusPage, 19 | pub drop_target: RefCell>, 20 | pub handler_id: RefCell>, 21 | } 22 | 23 | #[glib::object_subclass] 24 | impl ObjectSubclass for DragOverlay { 25 | const NAME: &'static str = "DragOverlay"; 26 | type Type = super::DragOverlay; 27 | type ParentType = adw::Bin; 28 | 29 | fn class_init(klass: &mut Self::Class) { 30 | klass.set_css_name("dragoverlay"); 31 | } 32 | } 33 | 34 | impl ObjectImpl for DragOverlay { 35 | fn properties() -> &'static [glib::ParamSpec] { 36 | static PROPERTIES: Lazy> = Lazy::new(|| { 37 | vec![ 38 | glib::ParamSpecString::builder("title").build(), 39 | glib::ParamSpecObject::builder::("child").build(), 40 | glib::ParamSpecObject::builder::("drop-target") 41 | .explicit_notify() 42 | .build(), 43 | ] 44 | }); 45 | PROPERTIES.as_ref() 46 | } 47 | 48 | fn property(&self, _id: usize, pspec: &glib::ParamSpec) -> glib::Value { 49 | match pspec.name() { 50 | "title" => self.status.title().to_value(), 51 | "child" => self.overlay.child().to_value(), 52 | "drop-target" => self.drop_target.borrow().to_value(), 53 | _ => unimplemented!(), 54 | } 55 | } 56 | 57 | fn set_property(&self, _id: usize, value: &glib::Value, pspec: &glib::ParamSpec) { 58 | match pspec.name() { 59 | "title" => self.status.set_title(value.get().unwrap()), 60 | "child" => self 61 | .overlay 62 | .set_child(value.get::().ok().as_ref()), 63 | "drop-target" => self 64 | .obj() 65 | .set_drop_target(&value.get::().unwrap()), 66 | _ => unimplemented!(), 67 | }; 68 | } 69 | 70 | fn constructed(&self) { 71 | self.overlay 72 | .set_parent(self.obj().upcast_ref::()); 73 | self.overlay.add_overlay(&self.revealer); 74 | 75 | self.revealer.set_can_target(false); 76 | self.revealer 77 | .set_transition_type(gtk::RevealerTransitionType::Crossfade); 78 | self.revealer.set_reveal_child(false); 79 | 80 | self.status.set_icon_name(Some("folder-music-symbolic")); 81 | self.status.add_css_class("drag-overlay-status-page"); 82 | 83 | self.revealer.set_child(Some(&self.status)); 84 | } 85 | 86 | fn dispose(&self) { 87 | self.overlay.unparent(); 88 | } 89 | } 90 | impl WidgetImpl for DragOverlay {} 91 | impl BinImpl for DragOverlay {} 92 | } 93 | 94 | glib::wrapper! { 95 | pub struct DragOverlay(ObjectSubclass) 96 | @extends gtk::Widget, adw::Bin; 97 | } 98 | 99 | impl Default for DragOverlay { 100 | fn default() -> Self { 101 | glib::Object::new() 102 | } 103 | } 104 | 105 | impl DragOverlay { 106 | pub fn new() -> Self { 107 | Self::default() 108 | } 109 | 110 | pub fn set_drop_target(&self, drop_target: >k::DropTarget) { 111 | let priv_ = self.imp(); 112 | 113 | if let Some(target) = priv_.drop_target.borrow_mut().take() { 114 | self.remove_controller(&target); 115 | 116 | if let Some(handler_id) = priv_.handler_id.borrow_mut().take() { 117 | target.disconnect(handler_id); 118 | } 119 | } 120 | 121 | let handler_id = drop_target.connect_current_drop_notify( 122 | glib::clone!(@weak priv_.revealer as revealer, @weak priv_.overlay as overlay => move |target| { 123 | let reveal = target.current_drop().is_some(); 124 | revealer.set_reveal_child(reveal); 125 | if reveal { 126 | overlay.child().unwrap().add_css_class("blurred"); 127 | } else { 128 | overlay.child().unwrap().remove_css_class("blurred"); 129 | } 130 | }), 131 | ); 132 | priv_.handler_id.replace(Some(handler_id)); 133 | 134 | self.add_controller(drop_target.clone()); 135 | priv_.drop_target.replace(Some(drop_target.clone())); 136 | self.notify("drop-target"); 137 | } 138 | } 139 | -------------------------------------------------------------------------------- /src/gtk/help-overlay.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | True 5 | 6 | 7 | shortcuts 8 | 10 9 | 10 | 11 | General 12 | 13 | 14 | Toggle primary menu 15 | F10 16 | 17 | 18 | 19 | 20 | Copy song details to the clipboard 21 | win.copy 22 | 23 | 24 | 25 | 26 | Show shortcuts 27 | win.show-help-overlay 28 | 29 | 30 | 31 | 32 | Quit 33 | app.quit 34 | 35 | 36 | 37 | 38 | 39 | 40 | Playlist 41 | 42 | 43 | Add a song to the playlist 44 | queue.add-song 45 | 46 | 47 | 48 | 49 | Add a folder to the playlist 50 | queue.add-folder 51 | 52 | 53 | 54 | 55 | Clear the playlist 56 | queue.clear 57 | 58 | 59 | 60 | 61 | Toggle the playlist pane 62 | queue.toggle 63 | 64 | 65 | 66 | 67 | Toggle shuffling songs 68 | queue.shuffle 69 | 70 | 71 | 72 | 73 | 74 | 75 | Playback 76 | 77 | 78 | Toggle play/pause 79 | win.play 80 | 81 | 82 | 83 | 84 | Previous song 85 | win.previous 86 | 87 | 88 | 89 | 90 | Next song 91 | win.next 92 | 93 | 94 | 95 | 96 | Seek backwards in the current song 97 | win.seek-backwards 98 | 99 | 100 | 101 | 102 | Seek forward in the current song 103 | win.seek-forward 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | -------------------------------------------------------------------------------- /src/audio/shuffle.rs: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2022 Emmanuele Bassi 2 | // SPDX-License-Identifier: GPL-3.0-or-later 3 | 4 | use std::cell::RefCell; 5 | 6 | use glib::clone; 7 | use gtk::{gio, glib, prelude::*, subclass::prelude::*}; 8 | use rand::prelude::*; 9 | 10 | mod imp { 11 | use glib::{ParamSpec, ParamSpecObject, Value}; 12 | use once_cell::sync::Lazy; 13 | 14 | use super::*; 15 | 16 | #[derive(Debug, Default)] 17 | pub struct ShuffleListModel { 18 | pub model: RefCell>, 19 | pub shuffle: RefCell>>, 20 | } 21 | 22 | #[glib::object_subclass] 23 | impl ObjectSubclass for ShuffleListModel { 24 | const NAME: &'static str = "ShuffleListModel"; 25 | type Type = super::ShuffleListModel; 26 | type Interfaces = (gio::ListModel,); 27 | } 28 | 29 | impl ObjectImpl for ShuffleListModel { 30 | fn properties() -> &'static [ParamSpec] { 31 | static PROPERTIES: Lazy> = Lazy::new(|| { 32 | vec![ParamSpecObject::builder::("model") 33 | .explicit_notify() 34 | .build()] 35 | }); 36 | 37 | PROPERTIES.as_ref() 38 | } 39 | 40 | fn property(&self, _id: usize, pspec: &ParamSpec) -> Value { 41 | match pspec.name() { 42 | "model" => self.model.borrow().to_value(), 43 | _ => unimplemented!(), 44 | } 45 | } 46 | 47 | fn set_property(&self, _id: usize, value: &Value, pspec: &ParamSpec) { 48 | match pspec.name() { 49 | "model" => self 50 | .obj() 51 | .set_model(value.get::().ok().as_ref()), 52 | _ => unimplemented!(), 53 | }; 54 | } 55 | } 56 | 57 | impl ListModelImpl for ShuffleListModel { 58 | fn item_type(&self) -> glib::Type { 59 | if let Some(ref model) = *self.model.borrow() { 60 | return model.item_type(); 61 | } 62 | 63 | glib::Object::static_type() 64 | } 65 | 66 | fn n_items(&self) -> u32 { 67 | if let Some(ref model) = *self.model.borrow() { 68 | return model.n_items(); 69 | } 70 | 71 | 0 72 | } 73 | 74 | fn item(&self, position: u32) -> Option { 75 | if let Some(ref model) = *self.model.borrow() { 76 | if let Some(ref shuffle) = *self.shuffle.borrow() { 77 | if let Some(shuffled_pos) = shuffle.get(position as usize) { 78 | return model.item(*shuffled_pos); 79 | } 80 | } 81 | return model.item(position); 82 | } 83 | 84 | None 85 | } 86 | } 87 | } 88 | 89 | glib::wrapper! { 90 | pub struct ShuffleListModel(ObjectSubclass) 91 | @implements gio::ListModel; 92 | } 93 | 94 | impl Default for ShuffleListModel { 95 | fn default() -> Self { 96 | Self::new(gio::ListModel::NONE) 97 | } 98 | } 99 | 100 | impl ShuffleListModel { 101 | pub fn new(model: Option<&impl IsA>) -> Self { 102 | glib::Object::builder::() 103 | .property("model", model.map(|m| m.as_ref())) 104 | .build() 105 | } 106 | 107 | pub fn model(&self) -> Option { 108 | self.imp().model.borrow().as_ref().cloned() 109 | } 110 | 111 | pub fn set_model(&self, model: Option<&gio::ListModel>) { 112 | if let Some(model) = model { 113 | self.imp().model.replace(Some(model.clone())); 114 | model.connect_items_changed( 115 | clone!(@strong self as this => move |_, position, removed, added| { 116 | if let Some(ref shuffle) = *this.imp().shuffle.borrow() { 117 | if let Some(shuffled_pos) = shuffle.get(position as usize) { 118 | this.items_changed(*shuffled_pos, removed, added); 119 | return; 120 | } 121 | } 122 | 123 | this.items_changed(position, removed, added); 124 | }), 125 | ); 126 | } else { 127 | self.imp().model.replace(None); 128 | } 129 | 130 | self.notify("model"); 131 | } 132 | 133 | pub fn shuffled(&self) -> bool { 134 | self.imp().shuffle.borrow().is_some() 135 | } 136 | 137 | pub fn reshuffle(&self, anchor: u32) { 138 | if let Some(ref model) = *self.imp().model.borrow() { 139 | let n_songs = model.n_items(); 140 | let mut rng = thread_rng(); 141 | 142 | let positions: Vec = if anchor == 0 { 143 | let mut before: Vec = vec![0]; 144 | let mut after: Vec = (1..n_songs).collect(); 145 | after.shuffle(&mut rng); 146 | 147 | before.extend(after); 148 | before 149 | } else if anchor == n_songs - 1 { 150 | let mut before: Vec = (0..n_songs - 1).collect(); 151 | let after: Vec = vec![n_songs - 1]; 152 | before.shuffle(&mut rng); 153 | 154 | before.extend(after); 155 | before 156 | } else { 157 | let mut before: Vec = (0..anchor).collect(); 158 | let mut after: Vec = (anchor + 1..n_songs).collect(); 159 | after.shuffle(&mut rng); 160 | 161 | before.push(anchor); 162 | before.extend(after); 163 | before 164 | }; 165 | 166 | self.imp().shuffle.replace(Some(positions)); 167 | self.items_changed(0, model.n_items(), model.n_items()); 168 | } else { 169 | self.imp().shuffle.replace(None); 170 | } 171 | } 172 | 173 | pub fn unshuffle(&self) { 174 | if let Some(ref model) = *self.imp().model.borrow() { 175 | self.imp().shuffle.replace(None); 176 | self.items_changed(0, model.n_items(), model.n_items()); 177 | } 178 | } 179 | } 180 | -------------------------------------------------------------------------------- /src/cover_picture.rs: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2022 Emmanuele Bassi 2 | // SPDX-License-Identifier: GPL-3.0-or-later 3 | 4 | use std::cell::{Cell, RefCell}; 5 | 6 | use glib::clone; 7 | use gtk::{gdk, gio, glib, graphene, gsk, prelude::*, subclass::prelude::*}; 8 | 9 | #[derive(Clone, Copy, Debug, glib::Enum, PartialEq, Default)] 10 | #[enum_type(name = "AmberolCoverSize")] 11 | pub enum CoverSize { 12 | #[default] 13 | Large = 0, 14 | Small = 1, 15 | } 16 | 17 | impl AsRef for CoverSize { 18 | fn as_ref(&self) -> &str { 19 | match self { 20 | CoverSize::Large => "large", 21 | CoverSize::Small => "small", 22 | } 23 | } 24 | } 25 | 26 | mod imp { 27 | use glib::{ParamSpec, ParamSpecEnum, ParamSpecObject, Value}; 28 | use once_cell::sync::Lazy; 29 | 30 | use super::*; 31 | 32 | const LARGE_SIZE: i32 = 192; 33 | const SMALL_SIZE: i32 = 48; 34 | 35 | #[derive(Debug, Default)] 36 | pub struct CoverPicture { 37 | pub cover: RefCell>, 38 | pub cover_size: Cell, 39 | } 40 | 41 | #[glib::object_subclass] 42 | impl ObjectSubclass for CoverPicture { 43 | const NAME: &'static str = "AmberolCoverPicture"; 44 | type Type = super::CoverPicture; 45 | type ParentType = gtk::Widget; 46 | 47 | fn class_init(klass: &mut Self::Class) { 48 | klass.set_css_name("picture"); 49 | klass.set_accessible_role(gtk::AccessibleRole::Img); 50 | } 51 | } 52 | 53 | impl ObjectImpl for CoverPicture { 54 | fn constructed(&self) { 55 | self.parent_constructed(); 56 | 57 | self.obj().add_css_class("cover"); 58 | self.obj().set_overflow(gtk::Overflow::Hidden); 59 | 60 | self.obj().connect_notify_local( 61 | Some("scale-factor"), 62 | clone!(@weak self as obj => move |picture, _| { 63 | picture.queue_draw(); 64 | }), 65 | ); 66 | } 67 | 68 | fn properties() -> &'static [ParamSpec] { 69 | static PROPERTIES: Lazy> = Lazy::new(|| { 70 | vec![ 71 | ParamSpecObject::builder::("cover").build(), 72 | ParamSpecEnum::builder::("cover-size").build(), 73 | ] 74 | }); 75 | PROPERTIES.as_ref() 76 | } 77 | 78 | fn property(&self, _id: usize, pspec: &ParamSpec) -> Value { 79 | match pspec.name() { 80 | "cover" => self.cover.borrow().to_value(), 81 | "cover-size" => self.cover_size.get().to_value(), 82 | _ => unimplemented!(), 83 | } 84 | } 85 | 86 | fn set_property(&self, _id: usize, value: &Value, pspec: &ParamSpec) { 87 | match pspec.name() { 88 | "cover" => self 89 | .obj() 90 | .set_cover(value.get::().ok().as_ref()), 91 | "cover-size" => self 92 | .obj() 93 | .set_cover_size(value.get::().expect("Required CoverSize")), 94 | _ => unimplemented!(), 95 | }; 96 | } 97 | } 98 | 99 | impl WidgetImpl for CoverPicture { 100 | fn request_mode(&self) -> gtk::SizeRequestMode { 101 | gtk::SizeRequestMode::ConstantSize 102 | } 103 | 104 | fn measure(&self, _orientation: gtk::Orientation, _for_size: i32) -> (i32, i32, i32, i32) { 105 | match self.cover_size.get() { 106 | CoverSize::Large => (LARGE_SIZE, LARGE_SIZE, -1, -1), 107 | CoverSize::Small => (SMALL_SIZE, SMALL_SIZE, -1, -1), 108 | } 109 | } 110 | 111 | fn snapshot(&self, snapshot: >k::Snapshot) { 112 | if let Some(ref cover) = *self.cover.borrow() { 113 | let widget = self.obj(); 114 | let scale_factor = widget.scale_factor() as f64; 115 | let width = widget.width() as f64 * scale_factor; 116 | let height = widget.height() as f64 * scale_factor; 117 | let ratio = cover.intrinsic_aspect_ratio(); 118 | let w; 119 | let h; 120 | if ratio > 1.0 { 121 | w = width; 122 | h = width / ratio; 123 | } else { 124 | w = height * ratio; 125 | h = height; 126 | } 127 | 128 | let x = (width - w.ceil()) / 2.0; 129 | let y = (height - h).floor() / 2.0; 130 | 131 | snapshot.save(); 132 | snapshot.scale(1.0 / scale_factor as f32, 1.0 / scale_factor as f32); 133 | snapshot.translate(&graphene::Point::new(x as f32, y as f32)); 134 | snapshot.append_scaled_texture( 135 | cover, 136 | gsk::ScalingFilter::Trilinear, 137 | &graphene::Rect::new(0.0, 0.0, w as f32, h as f32), 138 | ); 139 | snapshot.restore(); 140 | } 141 | } 142 | } 143 | } 144 | 145 | glib::wrapper! { 146 | pub struct CoverPicture(ObjectSubclass) 147 | @extends gtk::Widget, 148 | @implements gio::ActionGroup, gio::ActionMap; 149 | } 150 | 151 | impl Default for CoverPicture { 152 | fn default() -> Self { 153 | glib::Object::new() 154 | } 155 | } 156 | 157 | impl CoverPicture { 158 | pub fn new() -> Self { 159 | Self::default() 160 | } 161 | 162 | pub fn cover(&self) -> Option { 163 | (*self.imp().cover.borrow()).as_ref().cloned() 164 | } 165 | 166 | pub fn set_cover(&self, cover: Option<&gdk::Texture>) { 167 | if let Some(cover) = cover { 168 | self.imp().cover.replace(Some(cover.clone())); 169 | } else { 170 | self.imp().cover.replace(None); 171 | } 172 | 173 | self.queue_draw(); 174 | self.notify("cover"); 175 | } 176 | 177 | pub fn set_cover_size(&self, cover_size: CoverSize) { 178 | self.imp().cover_size.replace(cover_size); 179 | self.queue_resize(); 180 | self.notify("cover-size"); 181 | } 182 | } 183 | -------------------------------------------------------------------------------- /data/icons/hicolor/scalable/apps/io.bassi.Amberol.Devel.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | -------------------------------------------------------------------------------- /src/audio/state.rs: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2022 Emmanuele Bassi 2 | // SPDX-License-Identifier: GPL-3.0-or-later 3 | 4 | use std::cell::{Cell, RefCell}; 5 | 6 | use gtk::{gdk, glib, prelude::*, subclass::prelude::*}; 7 | 8 | use crate::audio::{PlaybackState, Song}; 9 | 10 | mod imp { 11 | use glib::{ 12 | ParamSpec, ParamSpecBoolean, ParamSpecDouble, ParamSpecObject, ParamSpecString, 13 | ParamSpecUInt64, 14 | }; 15 | use once_cell::sync::Lazy; 16 | 17 | use super::*; 18 | 19 | #[derive(Debug)] 20 | pub struct PlayerState { 21 | pub playback_state: Cell, 22 | pub position: Cell, 23 | pub current_song: RefCell>, 24 | pub volume: Cell, 25 | } 26 | 27 | #[glib::object_subclass] 28 | impl ObjectSubclass for PlayerState { 29 | const NAME: &'static str = "AmberolPlayerState"; 30 | type Type = super::PlayerState; 31 | 32 | fn new() -> Self { 33 | Self { 34 | playback_state: Cell::new(PlaybackState::Stopped), 35 | position: Cell::new(0), 36 | current_song: RefCell::new(None), 37 | volume: Cell::new(1.0), 38 | } 39 | } 40 | } 41 | 42 | impl ObjectImpl for PlayerState { 43 | fn properties() -> &'static [ParamSpec] { 44 | static PROPERTIES: Lazy> = Lazy::new(|| { 45 | vec![ 46 | ParamSpecBoolean::builder("playing").read_only().build(), 47 | ParamSpecUInt64::builder("position").read_only().build(), 48 | ParamSpecObject::builder::("song").read_only().build(), 49 | ParamSpecString::builder("title").read_only().build(), 50 | ParamSpecString::builder("artist").read_only().build(), 51 | ParamSpecString::builder("album").read_only().build(), 52 | ParamSpecUInt64::builder("duration").read_only().build(), 53 | ParamSpecObject::builder::("cover") 54 | .read_only() 55 | .build(), 56 | ParamSpecDouble::builder("volume") 57 | .minimum(0.0) 58 | .maximum(1.0) 59 | .default_value(1.0) 60 | .read_only() 61 | .build(), 62 | ] 63 | }); 64 | PROPERTIES.as_ref() 65 | } 66 | 67 | fn property(&self, _id: usize, pspec: &ParamSpec) -> glib::Value { 68 | let obj = self.obj(); 69 | match pspec.name() { 70 | "playing" => obj.playing().to_value(), 71 | "position" => obj.position().to_value(), 72 | "song" => self.current_song.borrow().to_value(), 73 | "volume" => obj.volume().to_value(), 74 | 75 | // These are proxies for Song properties 76 | "title" => obj.title().to_value(), 77 | "artist" => obj.artist().to_value(), 78 | "album" => obj.album().to_value(), 79 | "duration" => obj.duration().to_value(), 80 | "cover" => obj.cover().to_value(), 81 | _ => unimplemented!(), 82 | } 83 | } 84 | } 85 | } 86 | 87 | // PlayerState is a GObject that we can use to bind to 88 | // widgets and other objects; it contains the current 89 | // state of the audio player: song metadata, playback 90 | // position and duration, etc. 91 | glib::wrapper! { 92 | pub struct PlayerState(ObjectSubclass); 93 | } 94 | 95 | impl PlayerState { 96 | pub fn title(&self) -> Option { 97 | if let Some(song) = &*self.imp().current_song.borrow() { 98 | return Some(song.title()); 99 | } 100 | 101 | None 102 | } 103 | 104 | pub fn artist(&self) -> Option { 105 | if let Some(song) = &*self.imp().current_song.borrow() { 106 | return Some(song.artist()); 107 | } 108 | 109 | None 110 | } 111 | 112 | pub fn album(&self) -> Option { 113 | if let Some(song) = &*self.imp().current_song.borrow() { 114 | return Some(song.album()); 115 | } 116 | 117 | None 118 | } 119 | 120 | pub fn duration(&self) -> u64 { 121 | if let Some(song) = &*self.imp().current_song.borrow() { 122 | return song.duration(); 123 | } 124 | 125 | 0 126 | } 127 | 128 | pub fn cover(&self) -> Option { 129 | if let Some(song) = &*self.imp().current_song.borrow() { 130 | return song.cover_texture(); 131 | } 132 | 133 | None 134 | } 135 | 136 | pub fn playing(&self) -> bool { 137 | let playback_state = self.imp().playback_state.get(); 138 | matches!(playback_state, PlaybackState::Playing) 139 | } 140 | 141 | pub fn set_playback_state(&self, playback_state: &PlaybackState) -> bool { 142 | let old_state = self.imp().playback_state.replace(*playback_state); 143 | if old_state != *playback_state { 144 | self.notify("playing"); 145 | return true; 146 | } 147 | 148 | false 149 | } 150 | 151 | pub fn current_song(&self) -> Option { 152 | (*self.imp().current_song.borrow()).as_ref().cloned() 153 | } 154 | 155 | pub fn set_current_song(&self, song: Option) { 156 | self.imp().current_song.replace(song); 157 | self.imp().position.replace(0); 158 | self.notify("song"); 159 | self.notify("title"); 160 | self.notify("artist"); 161 | self.notify("album"); 162 | self.notify("duration"); 163 | self.notify("cover"); 164 | self.notify("position"); 165 | } 166 | 167 | pub fn position(&self) -> u64 { 168 | self.imp().position.get() 169 | } 170 | 171 | pub fn set_position(&self, position: u64) { 172 | self.imp().position.replace(position); 173 | self.notify("position"); 174 | } 175 | 176 | pub fn volume(&self) -> f64 { 177 | self.imp().volume.get() 178 | } 179 | 180 | pub fn set_volume(&self, volume: f64) { 181 | let old_volume = self.imp().volume.replace(volume); 182 | // We only care about two digits of precision, to avoid 183 | // notification cycles when we update the volume with a 184 | // similar value coming from the volume control 185 | let old_rounded = format!("{:.2}", old_volume); 186 | let new_rounded = format!("{:.2}", volume); 187 | if old_rounded != new_rounded { 188 | self.notify("volume"); 189 | } 190 | } 191 | } 192 | 193 | impl Default for PlayerState { 194 | fn default() -> Self { 195 | glib::Object::new() 196 | } 197 | } 198 | -------------------------------------------------------------------------------- /src/volume_control.rs: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2022 Emmanuele Bassi 2 | // SPDX-License-Identifier: GPL-3.0-or-later 3 | 4 | use std::cell::Cell; 5 | 6 | use adw::subclass::prelude::*; 7 | use glib::clone; 8 | use gtk::{gio, glib, prelude::*, CompositeTemplate}; 9 | use log::debug; 10 | 11 | mod imp { 12 | use glib::{subclass::Signal, ParamSpec, ParamSpecBoolean, ParamSpecDouble, Value}; 13 | use once_cell::sync::Lazy; 14 | 15 | use super::*; 16 | 17 | #[derive(Debug, Default, CompositeTemplate)] 18 | #[template(resource = "/io/bassi/Amberol/volume-control.ui")] 19 | pub struct VolumeControl { 20 | // Template widgets 21 | #[template_child] 22 | pub volume_low_button: TemplateChild, 23 | #[template_child] 24 | pub volume_scale: TemplateChild, 25 | #[template_child] 26 | pub volume_high_image: TemplateChild, 27 | 28 | pub toggle_mute: Cell, 29 | pub prev_volume: Cell, 30 | } 31 | 32 | #[glib::object_subclass] 33 | impl ObjectSubclass for VolumeControl { 34 | const NAME: &'static str = "AmberolVolumeControl"; 35 | type Type = super::VolumeControl; 36 | type ParentType = gtk::Widget; 37 | 38 | fn class_init(klass: &mut Self::Class) { 39 | Self::bind_template(klass); 40 | 41 | klass.set_layout_manager_type::(); 42 | klass.set_css_name("volume"); 43 | klass.set_accessible_role(gtk::AccessibleRole::Group); 44 | 45 | klass.install_property_action("volume.toggle-mute", "toggle-mute"); 46 | } 47 | 48 | fn instance_init(obj: &glib::subclass::InitializingObject) { 49 | obj.init_template(); 50 | } 51 | } 52 | 53 | impl ObjectImpl for VolumeControl { 54 | fn constructed(&self) { 55 | self.parent_constructed(); 56 | 57 | self.obj().setup_adjustment(); 58 | self.obj().setup_controller(); 59 | } 60 | 61 | fn dispose(&self) { 62 | while let Some(child) = self.obj().first_child() { 63 | child.unparent(); 64 | } 65 | } 66 | 67 | fn properties() -> &'static [ParamSpec] { 68 | static PROPERTIES: Lazy> = Lazy::new(|| { 69 | vec![ 70 | ParamSpecDouble::builder("volume") 71 | .minimum(0.0) 72 | .maximum(1.0) 73 | .default_value(1.0) 74 | .build(), 75 | ParamSpecBoolean::builder("toggle-mute").build(), 76 | ] 77 | }); 78 | 79 | PROPERTIES.as_ref() 80 | } 81 | 82 | fn set_property(&self, _id: usize, value: &Value, pspec: &ParamSpec) { 83 | match pspec.name() { 84 | "volume" => { 85 | let v = value.get::().expect("Failed to get f64 value"); 86 | self.volume_scale.set_value(v); 87 | } 88 | "toggle-mute" => { 89 | let v = value.get::().expect("Failed to get a boolean value"); 90 | self.obj().toggle_mute(v); 91 | } 92 | _ => unimplemented!(), 93 | } 94 | } 95 | 96 | fn property(&self, _id: usize, pspec: &ParamSpec) -> Value { 97 | match pspec.name() { 98 | "volume" => self.volume_scale.value().to_value(), 99 | "toggle-mute" => self.toggle_mute.get().to_value(), 100 | _ => unimplemented!(), 101 | } 102 | } 103 | 104 | fn signals() -> &'static [Signal] { 105 | static SIGNALS: Lazy> = Lazy::new(|| { 106 | vec![Signal::builder("volume-changed") 107 | .param_types([f64::static_type()]) 108 | .build()] 109 | }); 110 | 111 | SIGNALS.as_ref() 112 | } 113 | } 114 | 115 | impl WidgetImpl for VolumeControl {} 116 | } 117 | 118 | glib::wrapper! { 119 | pub struct VolumeControl(ObjectSubclass) 120 | @extends gtk::Widget, 121 | @implements gio::ActionGroup, gio::ActionMap; 122 | } 123 | 124 | impl Default for VolumeControl { 125 | fn default() -> Self { 126 | glib::Object::new() 127 | } 128 | } 129 | 130 | impl VolumeControl { 131 | pub fn new() -> Self { 132 | Self::default() 133 | } 134 | 135 | fn setup_adjustment(&self) { 136 | let adj = gtk::Adjustment::builder() 137 | .lower(0.0) 138 | .upper(1.0) 139 | .step_increment(0.05) 140 | .value(1.0) 141 | .build(); 142 | self.imp().volume_scale.set_adjustment(&adj); 143 | adj.connect_notify_local( 144 | Some("value"), 145 | clone!(@strong self as this => move |adj, _| { 146 | let value = adj.value(); 147 | if value == adj.lower() { 148 | this.imp().volume_low_button.set_icon_name("audio-volume-muted-symbolic"); 149 | } else { 150 | this.imp().volume_low_button.set_icon_name("audio-volume-low-symbolic"); 151 | } 152 | this.notify("volume"); 153 | this.emit_by_name::<()>("volume-changed", &[&value]); 154 | }), 155 | ); 156 | } 157 | 158 | fn setup_controller(&self) { 159 | let controller = gtk::EventControllerScroll::builder() 160 | .name("volume-scroll") 161 | .flags(gtk::EventControllerScrollFlags::VERTICAL) 162 | .build(); 163 | controller.connect_scroll(clone!(@strong self as this => move |_, _, dy| { 164 | debug!("Scroll delta: {}", dy); 165 | let adj = this.imp().volume_scale.adjustment(); 166 | let delta = dy * adj.step_increment(); 167 | let d = (adj.value() - delta).clamp(adj.lower(), adj.upper()); 168 | adj.set_value(d); 169 | glib::Propagation::Stop 170 | })); 171 | self.imp().volume_scale.add_controller(controller); 172 | } 173 | 174 | fn toggle_mute(&self, muted: bool) { 175 | if muted != self.imp().toggle_mute.replace(muted) { 176 | if muted { 177 | let prev_value = self.imp().volume_scale.value(); 178 | self.imp().prev_volume.replace(prev_value); 179 | self.imp().volume_scale.set_value(0.0); 180 | } else { 181 | let prev_value = self.imp().prev_volume.get(); 182 | self.imp().volume_scale.set_value(prev_value); 183 | } 184 | self.notify("toggle-mute"); 185 | } 186 | } 187 | 188 | pub fn volume(&self) -> f64 { 189 | self.imp().volume_scale.value() 190 | } 191 | } 192 | -------------------------------------------------------------------------------- /src/gtk/queue-row.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 147 | 148 | -------------------------------------------------------------------------------- /src/gtk/playlist-view.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 158 | 159 | -------------------------------------------------------------------------------- /LICENSES/CC0-1.0.txt: -------------------------------------------------------------------------------- 1 | Creative Commons Legal Code 2 | 3 | CC0 1.0 Universal CREATIVE COMMONS CORPORATION IS NOT A LAW FIRM AND DOES 4 | NOT PROVIDE LEGAL SERVICES. DISTRIBUTION OF THIS DOCUMENT DOES NOT CREATE 5 | AN ATTORNEY-CLIENT RELATIONSHIP. CREATIVE COMMONS PROVIDES THIS INFORMATION 6 | ON AN "AS-IS" BASIS. CREATIVE COMMONS MAKES NO WARRANTIES REGARDING THE USE 7 | OF THIS DOCUMENT OR THE INFORMATION OR WORKS PROVIDED HEREUNDER, AND DISCLAIMS 8 | LIABILITY FOR DAMAGES RESULTING FROM THE USE OF THIS DOCUMENT OR THE INFORMATION 9 | OR WORKS PROVIDED HEREUNDER. 10 | 11 | Statement of Purpose 12 | 13 | The laws of most jurisdictions throughout the world automatically confer exclusive 14 | Copyright and Related Rights (defined below) upon the creator and subsequent 15 | owner(s) (each and all, an "owner") of an original work of authorship and/or 16 | a database (each, a "Work"). 17 | 18 | Certain owners wish to permanently relinquish those rights to a Work for the 19 | purpose of contributing to a commons of creative, cultural and scientific 20 | works ("Commons") that the public can reliably and without fear of later claims 21 | of infringement build upon, modify, incorporate in other works, reuse and 22 | redistribute as freely as possible in any form whatsoever and for any purposes, 23 | including without limitation commercial purposes. These owners may contribute 24 | to the Commons to promote the ideal of a free culture and the further production 25 | of creative, cultural and scientific works, or to gain reputation or greater 26 | distribution for their Work in part through the use and efforts of others. 27 | 28 | For these and/or other purposes and motivations, and without any expectation 29 | of additional consideration or compensation, the person associating CC0 with 30 | a Work (the "Affirmer"), to the extent that he or she is an owner of Copyright 31 | and Related Rights in the Work, voluntarily elects to apply CC0 to the Work 32 | and publicly distribute the Work under its terms, with knowledge of his or 33 | her Copyright and Related Rights in the Work and the meaning and intended 34 | legal effect of CC0 on those rights. 35 | 36 | 1. Copyright and Related Rights. A Work made available under CC0 may be protected 37 | by copyright and related or neighboring rights ("Copyright and Related Rights"). 38 | Copyright and Related Rights include, but are not limited to, the following: 39 | 40 | i. the right to reproduce, adapt, distribute, perform, display, communicate, 41 | and translate a Work; 42 | 43 | ii. moral rights retained by the original author(s) and/or performer(s); 44 | 45 | iii. publicity and privacy rights pertaining to a person's image or likeness 46 | depicted in a Work; 47 | 48 | iv. rights protecting against unfair competition in regards to a Work, subject 49 | to the limitations in paragraph 4(a), below; 50 | 51 | v. rights protecting the extraction, dissemination, use and reuse of data 52 | in a Work; 53 | 54 | vi. database rights (such as those arising under Directive 96/9/EC of the 55 | European Parliament and of the Council of 11 March 1996 on the legal protection 56 | of databases, and under any national implementation thereof, including any 57 | amended or successor version of such directive); and 58 | 59 | vii. other similar, equivalent or corresponding rights throughout the world 60 | based on applicable law or treaty, and any national implementations thereof. 61 | 62 | 2. Waiver. To the greatest extent permitted by, but not in contravention of, 63 | applicable law, Affirmer hereby overtly, fully, permanently, irrevocably and 64 | unconditionally waives, abandons, and surrenders all of Affirmer's Copyright 65 | and Related Rights and associated claims and causes of action, whether now 66 | known or unknown (including existing as well as future claims and causes of 67 | action), in the Work (i) in all territories worldwide, (ii) for the maximum 68 | duration provided by applicable law or treaty (including future time extensions), 69 | (iii) in any current or future medium and for any number of copies, and (iv) 70 | for any purpose whatsoever, including without limitation commercial, advertising 71 | or promotional purposes (the "Waiver"). Affirmer makes the Waiver for the 72 | benefit of each member of the public at large and to the detriment of Affirmer's 73 | heirs and successors, fully intending that such Waiver shall not be subject 74 | to revocation, rescission, cancellation, termination, or any other legal or 75 | equitable action to disrupt the quiet enjoyment of the Work by the public 76 | as contemplated by Affirmer's express Statement of Purpose. 77 | 78 | 3. Public License Fallback. Should any part of the Waiver for any reason be 79 | judged legally invalid or ineffective under applicable law, then the Waiver 80 | shall be preserved to the maximum extent permitted taking into account Affirmer's 81 | express Statement of Purpose. In addition, to the extent the Waiver is so 82 | judged Affirmer hereby grants to each affected person a royalty-free, non 83 | transferable, non sublicensable, non exclusive, irrevocable and unconditional 84 | license to exercise Affirmer's Copyright and Related Rights in the Work (i) 85 | in all territories worldwide, (ii) for the maximum duration provided by applicable 86 | law or treaty (including future time extensions), (iii) in any current or 87 | future medium and for any number of copies, and (iv) for any purpose whatsoever, 88 | including without limitation commercial, advertising or promotional purposes 89 | (the "License"). The License shall be deemed effective as of the date CC0 90 | was applied by Affirmer to the Work. Should any part of the License for any 91 | reason be judged legally invalid or ineffective under applicable law, such 92 | partial invalidity or ineffectiveness shall not invalidate the remainder of 93 | the License, and in such case Affirmer hereby affirms that he or she will 94 | not (i) exercise any of his or her remaining Copyright and Related Rights 95 | in the Work or (ii) assert any associated claims and causes of action with 96 | respect to the Work, in either case contrary to Affirmer's express Statement 97 | of Purpose. 98 | 99 | 4. Limitations and Disclaimers. 100 | 101 | a. No trademark or patent rights held by Affirmer are waived, abandoned, surrendered, 102 | licensed or otherwise affected by this document. 103 | 104 | b. Affirmer offers the Work as-is and makes no representations or warranties 105 | of any kind concerning the Work, express, implied, statutory or otherwise, 106 | including without limitation warranties of title, merchantability, fitness 107 | for a particular purpose, non infringement, or the absence of latent or other 108 | defects, accuracy, or the present or absence of errors, whether or not discoverable, 109 | all to the greatest extent permissible under applicable law. 110 | 111 | c. Affirmer disclaims responsibility for clearing rights of other persons 112 | that may apply to the Work or any use thereof, including without limitation 113 | any person's Copyright and Related Rights in the Work. Further, Affirmer disclaims 114 | responsibility for obtaining any necessary consents, permissions or other 115 | rights required for any use of the Work. 116 | 117 | d. Affirmer understands and acknowledges that Creative Commons is not a party 118 | to this document and has no duty or obligation with respect to this CC0 or 119 | use of the Work. 120 | -------------------------------------------------------------------------------- /src/i18n.rs: -------------------------------------------------------------------------------- 1 | // i18n.rs 2 | // 3 | // Copyright 2020 Christopher Davis 4 | // 5 | // This program is free software: you can redistribute it and/or modify 6 | // it under the terms of the GNU General Public License as published by 7 | // the Free Software Foundation, either version 3 of the License, or 8 | // (at your option) any later version. 9 | // 10 | // This program is distributed in the hope that it will be useful, 11 | // but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | // GNU General Public License for more details. 14 | // 15 | // You should have received a copy of the GNU General Public License 16 | // along with this program. If not, see . 17 | // 18 | // SPDX-License-Identifier: GPL-3.0-or-later 19 | 20 | use gettextrs::{gettext, ngettext, npgettext, pgettext}; 21 | use regex::{Captures, Regex}; 22 | 23 | #[allow(dead_code)] 24 | fn freplace(input: String, args: &[&str]) -> String { 25 | let mut parts = input.split("{}"); 26 | let mut output = parts.next().unwrap_or_default().to_string(); 27 | for (p, a) in parts.zip(args.iter()) { 28 | output += &(a.to_string() + p); 29 | } 30 | output 31 | } 32 | 33 | #[allow(dead_code)] 34 | fn kreplace(input: String, kwargs: &[(&str, &str)]) -> String { 35 | let mut s = input; 36 | for (k, v) in kwargs { 37 | if let Ok(re) = Regex::new(&format!("\\{{{}\\}}", k)) { 38 | s = re 39 | .replace_all(&s, |_: &Captures<'_>| v.to_string()) 40 | .to_string(); 41 | } 42 | } 43 | 44 | s 45 | } 46 | 47 | // Simple translations functions 48 | 49 | #[allow(dead_code)] 50 | pub fn i18n(format: &str) -> String { 51 | gettext(format) 52 | } 53 | 54 | #[allow(dead_code)] 55 | pub fn i18n_f(format: &str, args: &[&str]) -> String { 56 | let s = gettext(format); 57 | freplace(s, args) 58 | } 59 | 60 | #[allow(dead_code)] 61 | pub fn i18n_k(format: &str, kwargs: &[(&str, &str)]) -> String { 62 | let s = gettext(format); 63 | kreplace(s, kwargs) 64 | } 65 | 66 | // Singular and plural translations functions 67 | 68 | #[allow(dead_code)] 69 | pub fn ni18n(single: &str, multiple: &str, number: u32) -> String { 70 | ngettext(single, multiple, number) 71 | } 72 | 73 | #[allow(dead_code)] 74 | pub fn ni18n_f(single: &str, multiple: &str, number: u32, args: &[&str]) -> String { 75 | let s = ngettext(single, multiple, number); 76 | freplace(s, args) 77 | } 78 | 79 | #[allow(dead_code)] 80 | pub fn ni18n_k(single: &str, multiple: &str, number: u32, kwargs: &[(&str, &str)]) -> String { 81 | let s = ngettext(single, multiple, number); 82 | kreplace(s, kwargs) 83 | } 84 | 85 | // Translations with context functions 86 | 87 | #[allow(dead_code)] 88 | pub fn pi18n(ctx: &str, format: &str) -> String { 89 | pgettext(ctx, format) 90 | } 91 | 92 | #[allow(dead_code)] 93 | pub fn pi18n_f(ctx: &str, format: &str, args: &[&str]) -> String { 94 | let s = pgettext(ctx, format); 95 | freplace(s, args) 96 | } 97 | 98 | #[allow(dead_code)] 99 | pub fn pi18n_k(ctx: &str, format: &str, kwargs: &[(&str, &str)]) -> String { 100 | let s = pgettext(ctx, format); 101 | kreplace(s, kwargs) 102 | } 103 | 104 | // Singular and plural with context 105 | 106 | #[allow(dead_code)] 107 | pub fn pni18n(ctx: &str, single: &str, multiple: &str, number: u32) -> String { 108 | npgettext(ctx, single, multiple, number) 109 | } 110 | 111 | #[allow(dead_code)] 112 | pub fn pni18n_f(ctx: &str, single: &str, multiple: &str, number: u32, args: &[&str]) -> String { 113 | let s = npgettext(ctx, single, multiple, number); 114 | freplace(s, args) 115 | } 116 | 117 | #[allow(dead_code)] 118 | pub fn pni18n_k( 119 | ctx: &str, 120 | single: &str, 121 | multiple: &str, 122 | number: u32, 123 | kwargs: &[(&str, &str)], 124 | ) -> String { 125 | let s = npgettext(ctx, single, multiple, number); 126 | kreplace(s, kwargs) 127 | } 128 | 129 | #[cfg(test)] 130 | mod tests { 131 | use super::*; 132 | #[test] 133 | fn test_i18n() { 134 | let out = i18n("translate1"); 135 | assert_eq!(out, "translate1"); 136 | 137 | let out = ni18n("translate1", "translate multi", 1); 138 | assert_eq!(out, "translate1"); 139 | let out = ni18n("translate1", "translate multi", 2); 140 | assert_eq!(out, "translate multi"); 141 | } 142 | 143 | #[test] 144 | fn test_i18n_f() { 145 | let out = i18n_f("{} param", &["one"]); 146 | assert_eq!(out, "one param"); 147 | 148 | let out = i18n_f("middle {} param", &["one"]); 149 | assert_eq!(out, "middle one param"); 150 | 151 | let out = i18n_f("end {}", &["one"]); 152 | assert_eq!(out, "end one"); 153 | 154 | let out = i18n_f("multiple {} and {}", &["one", "two"]); 155 | assert_eq!(out, "multiple one and two"); 156 | 157 | let out = ni18n_f("singular {} and {}", "plural {} and {}", 2, &["one", "two"]); 158 | assert_eq!(out, "plural one and two"); 159 | let out = ni18n_f("singular {} and {}", "plural {} and {}", 1, &["one", "two"]); 160 | assert_eq!(out, "singular one and two"); 161 | } 162 | 163 | #[test] 164 | fn test_i18n_k() { 165 | let out = i18n_k("{one} param", &[("one", "one")]); 166 | assert_eq!(out, "one param"); 167 | 168 | let out = i18n_k("middle {one} param", &[("one", "one")]); 169 | assert_eq!(out, "middle one param"); 170 | 171 | let out = i18n_k("end {one}", &[("one", "one")]); 172 | assert_eq!(out, "end one"); 173 | 174 | let out = i18n_k("multiple {one} and {two}", &[("one", "1"), ("two", "two")]); 175 | assert_eq!(out, "multiple 1 and two"); 176 | 177 | let out = i18n_k("multiple {two} and {one}", &[("one", "1"), ("two", "two")]); 178 | assert_eq!(out, "multiple two and 1"); 179 | 180 | let out = i18n_k("multiple {one} and {one}", &[("one", "1"), ("two", "two")]); 181 | assert_eq!(out, "multiple 1 and 1"); 182 | 183 | let out = ni18n_k( 184 | "singular {one} and {two}", 185 | "plural {one} and {two}", 186 | 1, 187 | &[("one", "1"), ("two", "two")], 188 | ); 189 | assert_eq!(out, "singular 1 and two"); 190 | let out = ni18n_k( 191 | "singular {one} and {two}", 192 | "plural {one} and {two}", 193 | 2, 194 | &[("one", "1"), ("two", "two")], 195 | ); 196 | assert_eq!(out, "plural 1 and two"); 197 | } 198 | 199 | #[test] 200 | fn test_pi18n() { 201 | let out = pi18n("This is the context", "translate1"); 202 | assert_eq!(out, "translate1"); 203 | 204 | let out = pni18n("context", "translate1", "translate multi", 1); 205 | assert_eq!(out, "translate1"); 206 | let out = pni18n("The context string", "translate1", "translate multi", 2); 207 | assert_eq!(out, "translate multi"); 208 | 209 | let out = pi18n_f("Context for translation", "{} param", &["one"]); 210 | assert_eq!(out, "one param"); 211 | 212 | let out = pi18n_k("context", "{one} param", &[("one", "one")]); 213 | assert_eq!(out, "one param"); 214 | } 215 | } 216 | -------------------------------------------------------------------------------- /src/audio/gst_backend.rs: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2022 Emmanuele Bassi 2 | // SPDX-License-Identifier: GPL-3.0-or-later 3 | 4 | use async_channel::Sender; 5 | use glib::clone; 6 | use gst::prelude::*; 7 | use gtk::glib; 8 | use log::{debug, error, warn}; 9 | 10 | use crate::audio::{PlaybackAction, ReplayGainMode, SeekDirection}; 11 | 12 | #[derive(Debug)] 13 | pub struct GstBackend { 14 | sender: Sender, 15 | gst_player: gst_player::Player, 16 | replaygain: Option, 17 | } 18 | 19 | #[derive(Debug)] 20 | pub struct GstReplayGain { 21 | rg_filter_bin: gst::Element, 22 | rg_volume: gst::Element, 23 | } 24 | 25 | impl GstReplayGain { 26 | pub fn new() -> Result> { 27 | let rg_volume = gst::ElementFactory::make_with_name("rgvolume", Some("rg volume"))?; 28 | let rg_limiter = gst::ElementFactory::make_with_name("rglimiter", Some("rg limiter"))?; 29 | 30 | let filter_bin = gst::Bin::builder().name("filter bin").build(); 31 | filter_bin.add(&rg_volume)?; 32 | filter_bin.add(&rg_limiter)?; 33 | rg_volume.link(&rg_limiter)?; 34 | 35 | let pad_src = rg_limiter.static_pad("src").unwrap(); 36 | pad_src.set_active(true).unwrap(); 37 | let ghost_src = gst::GhostPad::with_target(&pad_src)?; 38 | filter_bin.add_pad(&ghost_src)?; 39 | 40 | let pad_sink = rg_volume.static_pad("sink").unwrap(); 41 | pad_sink.set_active(true).unwrap(); 42 | let ghost_sink = gst::GhostPad::with_target(&pad_sink)?; 43 | filter_bin.add_pad(&ghost_sink)?; 44 | 45 | Ok(Self { 46 | rg_filter_bin: filter_bin.upcast(), 47 | rg_volume, 48 | }) 49 | } 50 | 51 | pub fn set_mode(&self, playbin: gst::Element, replaygain: ReplayGainMode) { 52 | let identity = gst::ElementFactory::make_with_name("identity", None).unwrap(); 53 | 54 | let (filter, album_mode) = match replaygain { 55 | ReplayGainMode::Album => (self.rg_filter_bin.as_ref(), true), 56 | ReplayGainMode::Track => (self.rg_filter_bin.as_ref(), false), 57 | ReplayGainMode::Off => (&identity, true), 58 | }; 59 | 60 | self.rg_volume.set_property("album-mode", album_mode); 61 | playbin.set_property("audio-filter", filter); 62 | } 63 | } 64 | 65 | impl GstBackend { 66 | pub fn new(sender: Sender) -> Self { 67 | let dispatcher = gst_player::PlayerGMainContextSignalDispatcher::new(None); 68 | let gst_player = gst_player::Player::new( 69 | None::, 70 | Some(dispatcher.upcast::()), 71 | ); 72 | gst_player.set_video_track_enabled(false); 73 | 74 | let mut config = gst_player.config(); 75 | config.set_position_update_interval(250); 76 | gst_player.set_config(config).unwrap(); 77 | 78 | let res = Self { 79 | sender, 80 | gst_player, 81 | replaygain: GstReplayGain::new().ok(), 82 | }; 83 | 84 | res.setup_signals(); 85 | 86 | res 87 | } 88 | 89 | fn setup_signals(&self) { 90 | self.gst_player.connect_warning(move |_, warn| { 91 | warn!("GStreamer warning: {}", warn); 92 | }); 93 | 94 | self.gst_player 95 | .connect_end_of_stream(clone!(@strong self.sender as sender => move |_| { 96 | if let Err(e) = sender.send_blocking(PlaybackAction::PlayNext) { 97 | error!("Failed to send PlayNext: {e}"); 98 | } 99 | })); 100 | 101 | self.gst_player.connect_position_updated( 102 | clone!(@strong self.sender as sender => move |_, clock| { 103 | if let Some(clock) = clock { 104 | let pos = clock.seconds(); 105 | if let Err(e) = sender.send_blocking(PlaybackAction::UpdatePosition(pos)) { 106 | error!("Failed to send UpdatePosition({pos}): {e}"); 107 | } 108 | } 109 | }), 110 | ); 111 | 112 | self.gst_player.connect_volume_changed( 113 | clone!(@strong self.sender as sender => move |player| { 114 | let volume = gst_audio::StreamVolume::convert_volume( 115 | gst_audio::StreamVolumeFormat::Linear, 116 | gst_audio::StreamVolumeFormat::Cubic, 117 | player.volume(), 118 | ); 119 | if let Err(e) = sender.send_blocking(PlaybackAction::VolumeChanged(volume)) { 120 | error!("Failed to send VolumeChanged({volume}): {e}"); 121 | } 122 | }), 123 | ); 124 | } 125 | 126 | pub fn set_song_uri(&self, uri: Option<&str>) { 127 | // FIXME: https://gitlab.freedesktop.org/gstreamer/gstreamer/-/issues/1124 128 | if uri.is_some() { 129 | self.gst_player.set_uri(uri); 130 | } 131 | } 132 | 133 | pub fn seek(&self, position: u64, duration: u64, offset: u64, direction: SeekDirection) { 134 | let offset = gst::ClockTime::from_seconds(offset); 135 | let position = gst::ClockTime::from_seconds(position); 136 | let duration = gst::ClockTime::from_seconds(duration); 137 | 138 | let destination = match direction { 139 | SeekDirection::Backwards if position >= offset => position.checked_sub(offset), 140 | SeekDirection::Backwards if position < offset => Some(gst::ClockTime::from_seconds(0)), 141 | SeekDirection::Forward if !duration.is_zero() && position + offset <= duration => { 142 | position.checked_add(offset) 143 | } 144 | SeekDirection::Forward if !duration.is_zero() && position + offset > duration => { 145 | Some(duration) 146 | } 147 | _ => None, 148 | }; 149 | 150 | if let Some(destination) = destination { 151 | self.gst_player.seek(destination); 152 | } 153 | } 154 | 155 | pub fn seek_position(&self, position: u64) { 156 | self.gst_player.seek(gst::ClockTime::from_seconds(position)); 157 | } 158 | 159 | pub fn seek_start(&self) { 160 | self.gst_player.seek(gst::ClockTime::from_seconds(0)); 161 | } 162 | 163 | pub fn play(&self) { 164 | self.gst_player.play(); 165 | } 166 | 167 | pub fn pause(&self) { 168 | self.gst_player.pause(); 169 | } 170 | 171 | pub fn stop(&self) { 172 | self.gst_player.stop(); 173 | } 174 | 175 | pub fn set_volume(&self, volume: f64) { 176 | let linear_volume = gst_audio::StreamVolume::convert_volume( 177 | gst_audio::StreamVolumeFormat::Cubic, 178 | gst_audio::StreamVolumeFormat::Linear, 179 | volume, 180 | ); 181 | debug!("Setting volume to: {}", &linear_volume); 182 | self.gst_player.set_volume(linear_volume); 183 | } 184 | 185 | pub fn set_replaygain(&self, replaygain: ReplayGainMode) { 186 | if let Some(ref r) = self.replaygain { 187 | r.set_mode(self.gst_player.pipeline(), replaygain); 188 | } 189 | } 190 | 191 | pub fn replaygain_available(&self) -> bool { 192 | self.replaygain.is_some() 193 | } 194 | } 195 | -------------------------------------------------------------------------------- /src/audio/cover_cache.rs: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2022 Emmanuele Bassi 2 | // SPDX-License-Identifier: GPL-3.0-or-later 3 | 4 | use std::{ 5 | collections::HashMap, 6 | path::{Path, PathBuf}, 7 | sync::Mutex, 8 | }; 9 | 10 | use gtk::{gdk, gio, glib, prelude::*}; 11 | use log::debug; 12 | use once_cell::sync::OnceCell; 13 | use sha2::{Digest, Sha256}; 14 | 15 | use crate::utils; 16 | 17 | #[derive(Clone, Debug)] 18 | pub struct CoverArt { 19 | texture: gdk::Texture, 20 | palette: Vec, 21 | cache: Option, 22 | } 23 | 24 | impl CoverArt { 25 | pub fn texture(&self) -> &gdk::Texture { 26 | self.texture.as_ref() 27 | } 28 | 29 | pub fn palette(&self) -> &Vec { 30 | self.palette.as_ref() 31 | } 32 | 33 | pub fn cache(&self) -> Option<&PathBuf> { 34 | self.cache.as_ref() 35 | } 36 | } 37 | 38 | #[derive(Debug)] 39 | pub struct CoverCache { 40 | entries: HashMap, 41 | } 42 | 43 | impl CoverCache { 44 | pub fn global() -> &'static Mutex { 45 | static CACHE: OnceCell> = OnceCell::new(); 46 | 47 | CACHE.get_or_init(|| { 48 | let c = CoverCache::new(); 49 | Mutex::new(c) 50 | }) 51 | } 52 | 53 | fn new() -> Self { 54 | CoverCache { 55 | entries: HashMap::new(), 56 | } 57 | } 58 | 59 | fn add_entry(&mut self, uuid: &str, cover: CoverArt) -> &CoverArt { 60 | self.entries.entry(uuid.to_string()).or_insert(cover) 61 | } 62 | 63 | fn lookup(&self, uuid: &String) -> Option<&CoverArt> { 64 | self.entries.get(uuid) 65 | } 66 | 67 | fn load_cover_art(&self, tag: &lofty::Tag, path: Option<&Path>) -> Option { 68 | if let Some(picture) = tag.get_picture_type(lofty::PictureType::CoverFront) { 69 | debug!("Found CoverFront"); 70 | return Some(glib::Bytes::from(picture.data())); 71 | } else { 72 | // If we don't have a CoverFront picture, we fall back to Other 73 | // and BandLogo types 74 | for picture in tag.pictures() { 75 | let cover_art = match picture.pic_type() { 76 | lofty::PictureType::Other => Some(glib::Bytes::from(picture.data())), 77 | lofty::PictureType::BandLogo => Some(glib::Bytes::from(picture.data())), 78 | _ => None, 79 | }; 80 | 81 | if cover_art.is_some() { 82 | debug!("Found fallback"); 83 | return cover_art; 84 | } 85 | } 86 | } 87 | 88 | // We always favour the cover art in the song metadata because it's going 89 | // to be in a hot cache; looking for a separate file will blow a bunch of 90 | // caches out of the water, which will slow down loading the song into the 91 | // playlist model 92 | if let Some(p) = path { 93 | let ext_covers = vec!["Cover.jpg", "Cover.png", "cover.jpg", "cover.png"]; 94 | 95 | for name in ext_covers { 96 | let mut cover_file = PathBuf::from(p); 97 | cover_file.push(name); 98 | debug!("Looking for external cover file: {:?}", &cover_file); 99 | 100 | let f = gio::File::for_path(&cover_file); 101 | if let Ok((res, _)) = f.load_bytes(None::<&gio::Cancellable>) { 102 | debug!("Loading cover from external cover file"); 103 | return Some(res); 104 | } 105 | } 106 | } 107 | 108 | debug!("No cover art"); 109 | 110 | None 111 | } 112 | 113 | pub fn cover_art(&mut self, path: &Path, tag: &lofty::Tag) -> Option<(CoverArt, String)> { 114 | let mut album_artist = None; 115 | let mut track_artist = None; 116 | let mut album = None; 117 | 118 | fn get_text_value(value: &lofty::ItemValue) -> Option { 119 | match value { 120 | lofty::ItemValue::Text(s) => Some(s.to_string()), 121 | _ => None, 122 | } 123 | } 124 | 125 | for item in tag.items() { 126 | match item.key() { 127 | lofty::ItemKey::AlbumTitle => album = get_text_value(item.value()), 128 | lofty::ItemKey::AlbumArtist => album_artist = get_text_value(item.value()), 129 | lofty::ItemKey::TrackArtist => track_artist = get_text_value(item.value()), 130 | _ => (), 131 | }; 132 | } 133 | 134 | // We use the album and artist to ensure we share the 135 | // same cover data for every track in the album; if we 136 | // don't have an album, we use the file name 137 | let mut hasher = Sha256::new(); 138 | if let Some(album) = album { 139 | hasher.update(&album); 140 | 141 | if let Some(artist) = album_artist { 142 | hasher.update(&artist); 143 | } else if let Some(artist) = track_artist { 144 | hasher.update(&artist); 145 | } 146 | 147 | if let Some(parent) = path.parent() { 148 | hasher.update(parent.to_str().unwrap()); 149 | } 150 | } else { 151 | hasher.update(path.to_str().unwrap()); 152 | } 153 | 154 | let uuid = format!("{:x}", hasher.finalize()); 155 | 156 | match self.lookup(&uuid) { 157 | Some(c) => { 158 | debug!("Found cover for UUID '{}'", &uuid); 159 | Some((c.clone(), uuid)) 160 | } 161 | None => { 162 | debug!("Loading cover art for UUID: {}", &uuid); 163 | 164 | let cover_art = self.load_cover_art(tag, path.parent()); 165 | 166 | // The pixel buffer for the cover art 167 | let cover_pixbuf = if let Some(ref cover_art) = cover_art { 168 | utils::load_cover_texture(cover_art) 169 | } else { 170 | None 171 | }; 172 | 173 | // Cache the pixel buffer, so that the MPRIS controller can 174 | // reference it later 175 | let cache_path = if let Some(ref pixbuf) = cover_pixbuf { 176 | utils::cache_cover_art(&uuid, pixbuf) 177 | } else { 178 | None 179 | }; 180 | 181 | // The texture we draw on screen 182 | let texture = cover_pixbuf.as_ref().map(gdk::Texture::for_pixbuf); 183 | 184 | // The color palette we use for styling the UI 185 | let palette = if let Some(ref pixbuf) = cover_pixbuf { 186 | utils::load_palette(pixbuf) 187 | } else { 188 | None 189 | }; 190 | 191 | // We want both texture and palette 192 | if texture.is_some() && palette.is_some() { 193 | let res = CoverArt { 194 | texture: texture.unwrap(), 195 | palette: palette.unwrap(), 196 | cache: cache_path, 197 | }; 198 | 199 | self.add_entry(&uuid, res.clone()); 200 | 201 | Some((res, uuid)) 202 | } else { 203 | None 204 | } 205 | } 206 | } 207 | } 208 | 209 | pub fn clear(&mut self) { 210 | self.entries.clear(); 211 | } 212 | } 213 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contribution guidelines 2 | 3 | Thank you for considering contributing to the Amberol project! 4 | 5 | Following these guidelines helps to communicate that you respect the time of 6 | the developers managing and developing this free software project. In return, 7 | they should reciprocate that respect in addressing your issue, assessing 8 | changes, and helping you finalize your pull requests. 9 | 10 | There are many ways to contribute, from improving the documentation, 11 | submitting bug reports and feature requests, localizing the user interface, or 12 | writing code which can be incorporated into Amberol itself. 13 | 14 | The issue tracker is meant to be used for actionable issues only. Please, 15 | don't use the issue tracker for support questions. Feel free to use the 16 | [GNOME Discourse forum](https://discourse.gnome.org) to ask your questions. 17 | 18 | ## How to report bugs 19 | 20 | Issues should only be reported [on the project page](https://gitlab.gnome.org/World/Amberol/issues/). 21 | 22 | ### Bug reports 23 | 24 | If you're reporting a bug make sure to list: 25 | 26 | 0. which version of Amberol are you using? 27 | 0. which operating system are you using? 28 | 0. how did you install Amberol? 29 | 0. the necessary steps to reproduce the issue 30 | 0. the expected outcome 31 | 0. a description of the behavior; screenshots are also welcome 32 | 33 | If the issue includes a crash, you should also include: 34 | 35 | 0. the eventual warnings printed on the terminal 36 | 0. a backtrace, obtained with tools such as GDB or LLDB 37 | 38 | It is fine to include screenshots of screen recordings to demonstrate 39 | an issue that is best to understand visually, but please don't just 40 | attach screen recordings without further details into issues. It is 41 | essential that the problem is described in enough detail to reproduce 42 | it without watching a video. 43 | 44 | For small issues, such as: 45 | 46 | - spelling/grammar fixes in the documentation 47 | - typo correction 48 | - comment clean ups 49 | - changes to metadata files (CI, `.gitignore`) 50 | - build system changes 51 | - source tree clean ups and reorganizations 52 | 53 | You should directly open a merge request instead of filing a new issue. 54 | 55 | ### Security issues 56 | 57 | If you have a security issue, please mark it as confidential in the issue 58 | tracker, to ensure that only the maintainers can see it. 59 | 60 | ### Features and enhancements 61 | 62 | Feature discussion can be open ended and require high bandwidth channels; if 63 | you are proposing a new feature on the issue tracker, make sure to make 64 | an actionable proposal, and list: 65 | 66 | 0. what you're trying to achieve 67 | 0. prior art, in other applications 68 | 0. design and theming changes 69 | 70 | When in doubt, you should open an issue to discuss your changes and ask 71 | questions before opening your code editor and hacking away; this way you'll get 72 | feedback from the project maintainers, if they have any, and you will avoid 73 | spending unnecessary effort. 74 | 75 | ## Your first contribution 76 | 77 | ### Prerequisites 78 | 79 | If you want to contribute to the Amberol project, you will need to have the 80 | development tools appropriate for your operating system, including: 81 | 82 | - Python 3.x 83 | - Meson 84 | - Ninja 85 | - the Rust compiler 86 | - Cargo 87 | 88 | ### Dependencies 89 | 90 | You will also need the various dependencies needed to build Amberol from 91 | source. You will find the compile time dependencies in the 92 | [`Cargo.toml`](./Cargo.toml) file, while the run time dependencies are listed 93 | in the [`meson.build`](./meson.build) file. 94 | 95 | You are strongly encouraged to use GNOME Builder to build and run Amberol, 96 | as it knows how to download and build all the dependencies necessary. 97 | 98 | ### Getting started 99 | 100 | You should start by forking the Amberol repository from the GitLab web UI; 101 | then you can select *Clone Repository* from GNOME Builder and use your 102 | fork's URL as the repository URL. 103 | 104 | GNOME Builder will find all the dependencies and download them for you. 105 | 106 | ---- 107 | 108 | If you want to use another development environment, you will need to clone 109 | the repository manually; make sure to have an account on GNOME's GitLab 110 | instance, and that you have an SSH key associated to that account: 111 | 112 | ```sh 113 | $ git clone git@ssh.gitlab.gnome.org:yourusername/amberol.git 114 | $ cd amberol 115 | ``` 116 | 117 | To compile the Git version of Amberol on your system, you will need to 118 | configure your build using Meson: 119 | 120 | ```sh 121 | $ meson setup _builddir . 122 | $ meson compile -C _builddir 123 | ``` 124 | 125 | Meson will search for all the required dependencies during the setup 126 | step, and will run Cargo in the compile step. 127 | 128 | You can run Amberol uninstalled by using the Meson devenv command: 129 | 130 | ```sh 131 | $ meson devenv -C _builddir 132 | $ ./src/amberol 133 | $ exit 134 | ``` 135 | 136 | ---- 137 | 138 | You can now switch to a new branch to work on Amberol: 139 | 140 | ```sh 141 | $ git switch -C your-branch 142 | ``` 143 | 144 | Once you've finished working on the bug fix or feature, push the branch 145 | to your Git repository and open a new merge request, to let the Amberol 146 | maintainers review your contribution. 147 | 148 | Remember that the Amberol is maintained by volunteers, so it might take a 149 | little while to get reviews or feedback. Don't be discouraged, and feel 150 | free to join the `#amberol:gnome.org` channel on Matrix for any issue you 151 | may find. 152 | 153 | ### Coding style 154 | 155 | Amberol uses the standard Rust coding style. You can use: 156 | 157 | cargo +nightly fmt --all 158 | 159 | To ensure that your contribution is following the expected format. 160 | 161 | Amberol has an additional set of checks available in the 162 | [`checks.sh`](./build-aux/checks.sh) tool. 163 | 164 | ### Commit messages 165 | 166 | The expected format for git commit messages is as follows: 167 | 168 | ```plain 169 | Short explanation of the commit 170 | 171 | Longer explanation explaining exactly what's changed, whether any 172 | external or private interfaces changed, what bugs were fixed (with bug 173 | tracker reference if applicable) and so forth. Be concise but not too 174 | brief. 175 | 176 | Closes #1234 177 | ``` 178 | 179 | - Always add a brief description of the commit to the _first_ line of 180 | the commit and terminate by two newlines (it will work without the 181 | second newline, but that is not nice for the interfaces). 182 | 183 | - First line (the brief description) must only be one sentence and 184 | should start with a capital letter unless it starts with a lowercase 185 | symbol or identifier. Don't use a trailing period either. Don't exceed 186 | 72 characters. 187 | 188 | - The main description (the body) is normal prose and should use normal 189 | punctuation and capital letters where appropriate. Consider the commit 190 | message as an email sent to the developers (or yourself, six months 191 | down the line) detailing **why** you changed something. There's no need 192 | to specify the **how**: the changes can be inlined. 193 | 194 | - When committing code on behalf of others use the `--author` option, e.g. 195 | `git commit -a --author "Joe Coder "` and `--signoff`. 196 | 197 | - If your commit is addressing an issue, use the 198 | [GitLab syntax](https://docs.gitlab.com/ce/user/project/issues/automatic_issue_closing.html) 199 | to automatically close the issue when merging the commit with the upstream 200 | repository: 201 | 202 | ```plain 203 | Closes #1234 204 | Fixes #1234 205 | Closes: https://gitlab.gnome.org/World/amberol/-/issues/123 206 | ``` 207 | 208 | - If you have a merge request with multiple commits and none of them 209 | completely fixes an issue, you should add a reference to the issue in 210 | the commit message, e.g. `Bug: #1234`, and use the automatic issue 211 | closing syntax in the description of the merge request. 212 | -------------------------------------------------------------------------------- /src/queue_row.rs: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2022 Emmanuele Bassi 2 | // SPDX-License-Identifier: GPL-3.0-or-later 3 | 4 | use std::cell::{Cell, RefCell}; 5 | 6 | use adw::subclass::prelude::*; 7 | use glib::clone; 8 | use gtk::{gdk, gio, glib, prelude::*, CompositeTemplate}; 9 | 10 | use crate::{audio::Song, cover_picture::CoverPicture}; 11 | 12 | mod imp { 13 | use glib::{ParamSpec, ParamSpecBoolean, ParamSpecObject, ParamSpecString, Value}; 14 | use once_cell::sync::Lazy; 15 | 16 | use super::*; 17 | 18 | #[derive(Debug, Default, CompositeTemplate)] 19 | #[template(resource = "/io/bassi/Amberol/queue-row.ui")] 20 | pub struct QueueRow { 21 | // Template widgets 22 | #[template_child] 23 | pub row_stack: TemplateChild, 24 | #[template_child] 25 | pub song_cover_stack: TemplateChild, 26 | #[template_child] 27 | pub song_cover_image: TemplateChild, 28 | #[template_child] 29 | pub song_title_label: TemplateChild, 30 | #[template_child] 31 | pub song_artist_label: TemplateChild, 32 | #[template_child] 33 | pub song_playing_image: TemplateChild, 34 | #[template_child] 35 | pub selection_title_label: TemplateChild, 36 | #[template_child] 37 | pub selection_artist_label: TemplateChild, 38 | #[template_child] 39 | pub selected_button: TemplateChild, 40 | #[template_child] 41 | pub selection_playing_image: TemplateChild, 42 | 43 | pub song: RefCell>, 44 | pub playing: Cell, 45 | pub selection_mode: Cell, 46 | } 47 | 48 | #[glib::object_subclass] 49 | impl ObjectSubclass for QueueRow { 50 | const NAME: &'static str = "AmberolQueueRow"; 51 | type Type = super::QueueRow; 52 | type ParentType = gtk::Widget; 53 | 54 | fn class_init(klass: &mut Self::Class) { 55 | Self::bind_template(klass); 56 | 57 | klass.set_layout_manager_type::(); 58 | klass.set_css_name("queuerow"); 59 | klass.set_accessible_role(gtk::AccessibleRole::Group); 60 | } 61 | 62 | fn instance_init(obj: &glib::subclass::InitializingObject) { 63 | obj.init_template(); 64 | } 65 | } 66 | 67 | impl ObjectImpl for QueueRow { 68 | fn dispose(&self) { 69 | while let Some(child) = self.obj().first_child() { 70 | child.unparent(); 71 | } 72 | } 73 | 74 | fn constructed(&self) { 75 | self.parent_constructed(); 76 | 77 | self.obj().init_widgets(); 78 | } 79 | 80 | fn properties() -> &'static [ParamSpec] { 81 | static PROPERTIES: Lazy> = Lazy::new(|| { 82 | vec![ 83 | ParamSpecObject::builder::("song").build(), 84 | ParamSpecString::builder("song-artist").build(), 85 | ParamSpecString::builder("song-title").build(), 86 | ParamSpecObject::builder::("song-cover").build(), 87 | ParamSpecBoolean::builder("playing").build(), 88 | ParamSpecBoolean::builder("selection-mode").build(), 89 | ParamSpecBoolean::builder("selected").build(), 90 | ] 91 | }); 92 | PROPERTIES.as_ref() 93 | } 94 | 95 | fn set_property(&self, _id: usize, value: &Value, pspec: &ParamSpec) { 96 | match pspec.name() { 97 | "song" => { 98 | let song = value.get::>().unwrap(); 99 | self.song.replace(song); 100 | } 101 | "song-artist" => { 102 | let p = value.get::<&str>().expect("The value needs to be a string"); 103 | self.obj().set_song_artist(p); 104 | } 105 | "song-title" => { 106 | let p = value.get::<&str>().expect("The value needs to be a string"); 107 | self.obj().set_song_title(p); 108 | } 109 | "song-cover" => { 110 | let p = value.get::().ok(); 111 | self.obj().set_song_cover(p); 112 | } 113 | "playing" => { 114 | let p = value 115 | .get::() 116 | .expect("The value needs to be a boolean"); 117 | self.obj().set_playing(p); 118 | } 119 | "selection-mode" => { 120 | let p = value 121 | .get::() 122 | .expect("The value needs to be a boolean"); 123 | self.obj().set_selection_mode(p); 124 | } 125 | "selected" => { 126 | let p = value 127 | .get::() 128 | .expect("The value needs to be a boolean"); 129 | self.selected_button.set_active(p); 130 | } 131 | _ => unimplemented!(), 132 | } 133 | } 134 | 135 | fn property(&self, _id: usize, pspec: &ParamSpec) -> Value { 136 | match pspec.name() { 137 | "song" => self.song.borrow().to_value(), 138 | "song-artist" => self.song_artist_label.text().to_value(), 139 | "song-title" => self.song_title_label.text().to_value(), 140 | "song-cover" => self.song_cover_image.cover().to_value(), 141 | "playing" => self.playing.get().to_value(), 142 | "selection-mode" => self.selection_mode.get().to_value(), 143 | "selected" => self.selected_button.is_active().to_value(), 144 | _ => unimplemented!(), 145 | } 146 | } 147 | } 148 | 149 | impl WidgetImpl for QueueRow {} 150 | } 151 | 152 | glib::wrapper! { 153 | pub struct QueueRow(ObjectSubclass) 154 | @extends gtk::Widget, 155 | @implements gio::ActionGroup, gio::ActionMap; 156 | } 157 | 158 | impl Default for QueueRow { 159 | fn default() -> Self { 160 | glib::Object::new() 161 | } 162 | } 163 | 164 | impl QueueRow { 165 | pub fn new() -> Self { 166 | Self::default() 167 | } 168 | 169 | fn init_widgets(&self) { 170 | self.imp().selected_button.connect_active_notify( 171 | clone!(@strong self as this => move |button| { 172 | if let Some(ref song) = *this.imp().song.borrow() { 173 | song.set_selected(button.is_active()); 174 | } 175 | this.notify("selected"); 176 | }), 177 | ); 178 | } 179 | 180 | fn set_playing(&self, playing: bool) { 181 | if playing != self.imp().playing.replace(playing) { 182 | self.update_mode(); 183 | self.notify("playing"); 184 | } 185 | } 186 | 187 | fn set_selection_mode(&self, selection_mode: bool) { 188 | if selection_mode != self.imp().selection_mode.replace(selection_mode) { 189 | self.update_mode(); 190 | self.notify("selection-mode"); 191 | } 192 | } 193 | 194 | fn update_mode(&self) { 195 | let imp = self.imp(); 196 | if imp.selection_mode.get() { 197 | imp.row_stack.set_visible_child_name("selection-mode"); 198 | let opacity = if imp.playing.get() { 1.0 } else { 0.0 }; 199 | imp.selection_playing_image.set_opacity(opacity); 200 | } else { 201 | imp.row_stack.set_visible_child_name("song-details"); 202 | let opacity = if imp.playing.get() { 1.0 } else { 0.0 }; 203 | imp.song_playing_image.set_opacity(opacity); 204 | } 205 | } 206 | 207 | fn set_song_title(&self, title: &str) { 208 | let imp = self.imp(); 209 | imp.song_title_label.set_text(Some(title)); 210 | imp.selection_title_label.set_text(Some(title)); 211 | } 212 | 213 | fn set_song_artist(&self, artist: &str) { 214 | let imp = self.imp(); 215 | imp.song_artist_label.set_text(Some(artist)); 216 | imp.selection_artist_label.set_text(Some(artist)); 217 | } 218 | 219 | fn set_song_cover(&self, cover: Option) { 220 | let imp = self.imp(); 221 | if let Some(texture) = cover { 222 | imp.song_cover_image.set_cover(Some(&texture)); 223 | imp.song_cover_stack.set_visible_child_name("cover"); 224 | } else { 225 | imp.song_cover_image.set_cover(None); 226 | imp.song_cover_stack.set_visible_child_name("no-cover"); 227 | } 228 | } 229 | 230 | pub fn song(&self) -> Option { 231 | self.imp().song.borrow().clone() 232 | } 233 | } 234 | -------------------------------------------------------------------------------- /src/gtk/playback-control.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 147 | 148 | 149 |
150 | 151 | Copy 152 | win.copy 153 | 154 |
155 |
156 | 157 | Add _Song 158 | queue.add-song 159 | 160 | 161 | Add _Folder 162 | queue.add-folder 163 | 164 | 165 | Clear 166 | queue.clear 167 | 168 |
169 |
170 | 171 | _Match Cover Art 172 | win.enable-recoloring 173 | 174 | 175 | _Background Playback 176 | app.background-play 177 | 178 |
179 |
180 | 181 | _ReplayGain 182 | 183 | _Album 184 | win.replaygain 185 | album 186 | 187 | 188 | _Song 189 | win.replaygain 190 | track 191 | 192 | 193 | _Disabled 194 | win.replaygain 195 | off 196 | 197 | 198 |
199 |
200 | 201 | _Keyboard Shortcuts 202 | win.show-help-overlay 203 | 204 | 205 | _About Amberol 206 | app.about 207 | 208 | 209 | _Quit 210 | app.quit 211 | 212 |
213 |
214 |
215 | -------------------------------------------------------------------------------- /src/audio/mpris_controller.rs: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2022 Emmanuele Bassi 2 | // SPDX-License-Identifier: GPL-3.0-or-later 3 | 4 | use std::{ 5 | cell::{OnceCell, RefCell}, 6 | rc::Rc, 7 | }; 8 | 9 | use async_channel::Sender; 10 | use glib::clone; 11 | use gtk::{gio, glib, prelude::*}; 12 | use log::error; 13 | use mpris_server::{LoopStatus, Metadata, PlaybackStatus, Player, Time}; 14 | 15 | use crate::{ 16 | audio::{Controller, PlaybackAction, PlaybackState, RepeatMode, Song}, 17 | config::APPLICATION_ID, 18 | }; 19 | 20 | #[derive(Debug)] 21 | pub struct MprisController { 22 | mpris: Rc>, 23 | song: RefCell>, 24 | } 25 | 26 | impl MprisController { 27 | pub fn new(sender: Sender) -> Self { 28 | let builder = Player::builder(APPLICATION_ID) 29 | .identity("Amberol") 30 | .desktop_entry(APPLICATION_ID) 31 | .can_raise(true) 32 | .can_play(false) 33 | .can_pause(true) 34 | .can_seek(true) 35 | .can_go_next(true) 36 | .can_go_previous(true) 37 | .can_set_fullscreen(false); 38 | 39 | let mpris = Rc::new(OnceCell::new()); 40 | 41 | glib::spawn_future_local(clone!( 42 | #[weak] 43 | mpris, 44 | #[strong] 45 | sender, 46 | async move { 47 | match builder.build().await { 48 | Err(err) => error!("Failed to create MPRIS server: {:?}", err), 49 | Ok(player) => { 50 | setup_signals(sender, &player); 51 | let mpris_task = player.run(); 52 | let _ = mpris.set(player); 53 | mpris_task.await; 54 | } 55 | } 56 | } 57 | )); 58 | 59 | Self { 60 | mpris, 61 | song: RefCell::new(None), 62 | } 63 | } 64 | 65 | fn update_metadata(&self) { 66 | let mut metadata = Metadata::new(); 67 | 68 | if let Some(song) = self.song.take() { 69 | metadata.set_artist(Some(vec![song.artist()])); 70 | metadata.set_title(Some(song.title())); 71 | metadata.set_album(Some(song.album())); 72 | 73 | let length = Time::from_secs(song.duration() as i64); 74 | metadata.set_length(Some(length)); 75 | 76 | // MPRIS should really support passing a bytes buffer for 77 | // the cover art, instead of requiring this ridiculous 78 | // charade 79 | if let Some(cache) = song.cover_cache() { 80 | let file = gio::File::for_path(cache); 81 | match file.query_info( 82 | "standard::type", 83 | gio::FileQueryInfoFlags::NONE, 84 | gio::Cancellable::NONE, 85 | ) { 86 | Ok(info) if info.file_type() == gio::FileType::Regular => { 87 | metadata.set_art_url(Some(file.uri())); 88 | } 89 | _ => metadata.set_art_url(None::), 90 | } 91 | } 92 | 93 | self.song.replace(Some(song)); 94 | } 95 | 96 | glib::spawn_future_local(clone!( 97 | #[weak(rename_to = mpris)] 98 | self.mpris, 99 | async move { 100 | if let Some(mpris) = mpris.get() { 101 | if let Err(err) = mpris.set_metadata(metadata).await { 102 | error!("Unable to set MPRIS metadata: {err:?}"); 103 | } 104 | } 105 | } 106 | )); 107 | } 108 | } 109 | 110 | impl Controller for MprisController { 111 | fn set_playback_state(&self, state: &PlaybackState) { 112 | let status = match state { 113 | PlaybackState::Playing => PlaybackStatus::Playing, 114 | PlaybackState::Paused => PlaybackStatus::Paused, 115 | _ => PlaybackStatus::Stopped, 116 | }; 117 | 118 | glib::spawn_future_local(clone!( 119 | #[weak(rename_to = mpris)] 120 | self.mpris, 121 | async move { 122 | if let Some(mpris) = mpris.get() { 123 | if let Err(err) = mpris.set_can_play(true).await { 124 | error!("Unable to set MPRIS play capability: {err:?}"); 125 | } 126 | if let Err(err) = mpris.set_playback_status(status).await { 127 | error!("Unable to set MPRIS playback status: {err:?}"); 128 | } 129 | } 130 | } 131 | )); 132 | } 133 | 134 | fn set_song(&self, song: &Song) { 135 | self.song.replace(Some(song.clone())); 136 | self.update_metadata(); 137 | } 138 | 139 | fn set_position(&self, position: u64, notify: bool) { 140 | let pos = Time::from_secs(position as i64); 141 | if let Some(mpris) = self.mpris.get() { 142 | mpris.set_position(pos); 143 | } 144 | if notify { 145 | glib::spawn_future_local(clone!( 146 | #[weak(rename_to = mpris)] 147 | self.mpris, 148 | async move { 149 | if let Some(mpris) = mpris.get() { 150 | if let Err(err) = mpris.seeked(pos).await { 151 | error!("Unable to emit MPRIS Seeked: {err:?}"); 152 | } 153 | } 154 | } 155 | )); 156 | } 157 | } 158 | 159 | fn set_repeat_mode(&self, repeat: RepeatMode) { 160 | let status = match repeat { 161 | RepeatMode::Consecutive => LoopStatus::None, 162 | RepeatMode::RepeatOne => LoopStatus::Track, 163 | RepeatMode::RepeatAll => LoopStatus::Playlist, 164 | }; 165 | 166 | glib::spawn_future_local(clone!( 167 | #[weak(rename_to = mpris)] 168 | self.mpris, 169 | async move { 170 | if let Some(mpris) = mpris.get() { 171 | if let Err(err) = mpris.set_loop_status(status).await { 172 | error!("Unable to set MPRIS loop status: {err:?}"); 173 | } 174 | } 175 | } 176 | )); 177 | } 178 | } 179 | 180 | fn setup_signals(sender: Sender, mpris: &Player) { 181 | mpris.connect_play_pause(clone!( 182 | #[strong] 183 | sender, 184 | move |player| { 185 | match player.playback_status() { 186 | PlaybackStatus::Paused => { 187 | if let Err(e) = sender.send_blocking(PlaybackAction::Play) { 188 | error!("Unable to send Play: {e}"); 189 | } 190 | } 191 | PlaybackStatus::Stopped => { 192 | if let Err(e) = sender.send_blocking(PlaybackAction::Stop) { 193 | error!("Unable to send Stop: {e}"); 194 | } 195 | } 196 | _ => { 197 | if let Err(e) = sender.send_blocking(PlaybackAction::Pause) { 198 | error!("Unable to send Pause: {e}"); 199 | } 200 | } 201 | }; 202 | } 203 | )); 204 | 205 | mpris.connect_play(clone!( 206 | #[strong] 207 | sender, 208 | move |_| { 209 | if let Err(e) = sender.send_blocking(PlaybackAction::Play) { 210 | error!("Unable to send Play: {e}"); 211 | } 212 | } 213 | )); 214 | 215 | mpris.connect_stop(clone!( 216 | #[strong] 217 | sender, 218 | move |_| { 219 | if let Err(e) = sender.send_blocking(PlaybackAction::Stop) { 220 | error!("Unable to send Stop: {e}"); 221 | } 222 | } 223 | )); 224 | 225 | mpris.connect_pause(clone!( 226 | #[strong] 227 | sender, 228 | move |_| { 229 | if let Err(e) = sender.send_blocking(PlaybackAction::Pause) { 230 | error!("Unable to send Pause: {e}"); 231 | } 232 | } 233 | )); 234 | 235 | mpris.connect_previous(clone!( 236 | #[strong] 237 | sender, 238 | move |_| { 239 | if let Err(e) = sender.send_blocking(PlaybackAction::SkipPrevious) { 240 | error!("Unable to send SkipPrevious: {e}"); 241 | } 242 | } 243 | )); 244 | 245 | mpris.connect_next(clone!( 246 | #[strong] 247 | sender, 248 | move |_| { 249 | if let Err(e) = sender.send_blocking(PlaybackAction::SkipNext) { 250 | error!("Unable to send SkipNext: {e}"); 251 | } 252 | } 253 | )); 254 | 255 | mpris.connect_raise(clone!( 256 | #[strong] 257 | sender, 258 | move |_| { 259 | if let Err(e) = sender.send_blocking(PlaybackAction::Raise) { 260 | error!("Unable to send Raise: {e}"); 261 | } 262 | } 263 | )); 264 | 265 | mpris.connect_set_loop_status(clone!( 266 | #[strong] 267 | sender, 268 | move |_, status| { 269 | let mode = match status { 270 | LoopStatus::None => RepeatMode::Consecutive, 271 | LoopStatus::Track => RepeatMode::RepeatOne, 272 | LoopStatus::Playlist => RepeatMode::RepeatAll, 273 | }; 274 | 275 | if let Err(e) = sender.send_blocking(PlaybackAction::Repeat(mode)) { 276 | error!("Unable to send Repeat({mode}): {e}"); 277 | } 278 | } 279 | )); 280 | 281 | mpris.connect_seek(clone!( 282 | #[strong] 283 | sender, 284 | move |_, offset| { 285 | let offset = offset.as_secs(); 286 | if let Err(e) = sender.send_blocking(PlaybackAction::Seek(offset)) { 287 | error!("Unable to send Seek({offset}): {e}"); 288 | } 289 | } 290 | )); 291 | } 292 | -------------------------------------------------------------------------------- /src/audio/waveform_generator.rs: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2022 Emmanuele Bassi 2 | // SPDX-License-Identifier: GPL-3.0-or-later 3 | 4 | use std::cell::RefCell; 5 | 6 | use glib::clone; 7 | use gst::prelude::*; 8 | use gtk::{gio, glib, prelude::*, subclass::prelude::*}; 9 | use log::{debug, warn}; 10 | 11 | use crate::audio::{Controller, PlaybackState, RepeatMode, Song}; 12 | 13 | mod imp { 14 | use glib::{ParamSpec, ParamSpecBoolean, Value}; 15 | use once_cell::sync::Lazy; 16 | 17 | use super::*; 18 | 19 | #[derive(Debug, Default)] 20 | pub struct WaveformGenerator { 21 | pub song: RefCell>, 22 | pub peaks: RefCell>>, 23 | pub pipeline: RefCell>, 24 | } 25 | 26 | #[glib::object_subclass] 27 | impl ObjectSubclass for WaveformGenerator { 28 | const NAME: &'static str = "WaveformGenerator"; 29 | type Type = super::WaveformGenerator; 30 | } 31 | 32 | impl ObjectImpl for WaveformGenerator { 33 | fn dispose(&self) { 34 | if let Some((pipeline, _bus_watch)) = self.pipeline.take() { 35 | pipeline.send_event(gst::event::Eos::new()); 36 | match pipeline.set_state(gst::State::Null) { 37 | Ok(_) => {} 38 | Err(err) => warn!("Unable to set existing pipeline to Null state: {}", err), 39 | } 40 | } 41 | } 42 | 43 | fn properties() -> &'static [ParamSpec] { 44 | static PROPERTIES: Lazy> = 45 | Lazy::new(|| vec![ParamSpecBoolean::builder("has-peaks").read_only().build()]); 46 | 47 | PROPERTIES.as_ref() 48 | } 49 | 50 | fn property(&self, _id: usize, pspec: &ParamSpec) -> Value { 51 | match pspec.name() { 52 | "has-peaks" => self.obj().peaks().is_some().to_value(), 53 | _ => unimplemented!(), 54 | } 55 | } 56 | } 57 | } 58 | 59 | glib::wrapper! { 60 | pub struct WaveformGenerator(ObjectSubclass); 61 | } 62 | 63 | impl Default for WaveformGenerator { 64 | fn default() -> Self { 65 | glib::Object::new() 66 | } 67 | } 68 | 69 | impl Controller for WaveformGenerator { 70 | fn set_playback_state(&self, _playback_state: &PlaybackState) {} 71 | 72 | fn set_song(&self, song: &Song) { 73 | self.imp().song.replace(Some(song.clone())); 74 | self.load_peaks(); 75 | } 76 | 77 | fn set_position(&self, _position: u64) {} 78 | fn set_repeat_mode(&self, _mode: RepeatMode) {} 79 | } 80 | 81 | impl WaveformGenerator { 82 | pub fn new() -> Self { 83 | WaveformGenerator::default() 84 | } 85 | 86 | pub fn peaks(&self) -> Option> { 87 | (*self.imp().peaks.borrow()).as_ref().cloned() 88 | } 89 | 90 | fn save_peaks(&self) { 91 | if let Some(peaks) = self.peaks() { 92 | let song = match self.imp().song.borrow().as_ref() { 93 | Some(s) => s.clone(), 94 | None => { 95 | self.notify("has-peaks"); 96 | return; 97 | } 98 | }; 99 | 100 | if let Some(uuid) = song.uuid() { 101 | let mut cache = glib::user_cache_dir(); 102 | cache.push("amberol"); 103 | cache.push("waveforms"); 104 | glib::mkdir_with_parents(&cache, 0o755); 105 | 106 | cache.push(format!("{}.json", uuid)); 107 | 108 | let j = serde_json::to_string(&peaks).unwrap(); 109 | let file = gio::File::for_path(&cache); 110 | file.replace_contents_async( 111 | j, 112 | None, 113 | false, 114 | gio::FileCreateFlags::NONE, 115 | gio::Cancellable::NONE, 116 | move |_| { 117 | debug!("Waveform cached at: {:?}", &cache); 118 | }, 119 | ); 120 | } 121 | } 122 | 123 | self.notify("has-peaks"); 124 | } 125 | 126 | fn load_peaks(&self) { 127 | let song = match self.imp().song.borrow().as_ref() { 128 | Some(s) => s.clone(), 129 | None => return, 130 | }; 131 | 132 | if let Some(uuid) = song.uuid() { 133 | let mut cache = glib::user_cache_dir(); 134 | cache.push("amberol"); 135 | cache.push("waveforms"); 136 | cache.push(format!("{}.json", uuid)); 137 | 138 | let file = gio::File::for_path(&cache); 139 | file.load_contents_async( 140 | gio::Cancellable::NONE, 141 | clone!(@strong self as this => move |res| { 142 | match res { 143 | Ok((bytes, _tag)) => { 144 | let p: Vec<(f64, f64)> = serde_json::from_slice(&bytes[..]).unwrap(); 145 | this.imp().peaks.replace(Some(p)); 146 | this.notify("has-peaks"); 147 | } 148 | Err(err) => { 149 | debug!("Could not read waveform cache file: {}", err); 150 | this.generate_peaks(); 151 | } 152 | } 153 | }), 154 | ); 155 | } 156 | } 157 | 158 | fn generate_peaks(&self) { 159 | if let Some((pipeline, _bus_watch)) = self.imp().pipeline.take() { 160 | // Stop any running pipeline, and ensure that we have nothing to 161 | // report 162 | self.imp().peaks.replace(None); 163 | pipeline.send_event(gst::event::Eos::new()); 164 | match pipeline.set_state(gst::State::Null) { 165 | Ok(_) => {} 166 | Err(err) => warn!("Unable to set existing pipeline to Null state: {}", err), 167 | } 168 | } 169 | 170 | let song = match self.imp().song.borrow().as_ref() { 171 | Some(s) => s.clone(), 172 | None => { 173 | self.imp().peaks.replace(None); 174 | self.notify("has-peaks"); 175 | return; 176 | } 177 | }; 178 | 179 | // Reset the peaks vector 180 | let peaks: Vec<(f64, f64)> = Vec::new(); 181 | self.imp().peaks.replace(Some(peaks)); 182 | 183 | let pipeline_str = "uridecodebin name=uridecodebin ! audioconvert ! audio/x-raw,channels=2 ! level name=level interval=250000000 ! fakesink name=faked"; 184 | let pipeline = match gst::parse::launch(pipeline_str) { 185 | Ok(pipeline) => pipeline, 186 | Err(err) => { 187 | warn!("Unable to generate the waveform: {}", err); 188 | self.imp().peaks.replace(None); 189 | self.notify("has-peaks"); 190 | return; 191 | } 192 | }; 193 | 194 | let uridecodebin = pipeline 195 | .downcast_ref::() 196 | .unwrap() 197 | .by_name("uridecodebin") 198 | .unwrap(); 199 | uridecodebin.set_property("uri", song.uri()); 200 | 201 | let fakesink = pipeline 202 | .downcast_ref::() 203 | .unwrap() 204 | .by_name("faked") 205 | .unwrap(); 206 | fakesink.set_property("qos", false); 207 | fakesink.set_property("sync", false); 208 | 209 | let bus = pipeline 210 | .bus() 211 | .expect("Pipeline without bus. Shouldn't happen!"); 212 | 213 | debug!("Adding bus watch"); 214 | let bus_watch = bus.add_watch_local(clone!(@weak self as this, @weak pipeline => @default-return glib::ControlFlow::Break, move |_, msg| { 215 | use gst::MessageView; 216 | 217 | match msg.view() { 218 | MessageView::Eos(..) => { 219 | debug!("End of waveform stream"); 220 | pipeline.set_state(gst::State::Null).expect("Unable to set 'null' state"); 221 | // We're done 222 | this.imp().pipeline.replace(None); 223 | this.save_peaks(); 224 | return glib::ControlFlow::Break; 225 | } 226 | MessageView::Error(err) => { 227 | warn!("Pipeline error: {:?}", err); 228 | pipeline.set_state(gst::State::Null).expect("Unable to set 'null' state"); 229 | // We're done 230 | this.imp().pipeline.replace(None); 231 | this.save_peaks(); 232 | return glib::ControlFlow::Break; 233 | } 234 | MessageView::Element(element) => { 235 | if let Some(s) = element.structure() { 236 | if s.has_name("level") { 237 | let peaks_array = s.get::<&glib::ValueArray>("peak").unwrap(); 238 | let v1 = peaks_array[0].get::().unwrap(); 239 | let v2 = peaks_array[1].get::().unwrap(); 240 | // Normalize peaks between 0 and 1 241 | let peak1 = f64::powf(10.0, v1 / 20.0); 242 | let peak2 = f64::powf(10.0, v2 / 20.0); 243 | if let Some(ref mut peaks) = *this.imp().peaks.borrow_mut() { 244 | peaks.push((peak1, peak2)); 245 | } 246 | } 247 | } 248 | } 249 | _ => (), 250 | }; 251 | 252 | glib::ControlFlow::Continue 253 | })) 254 | .expect("failed to add bus watch"); 255 | 256 | match pipeline.set_state(gst::State::Playing) { 257 | Ok(_) => { 258 | self.imp().pipeline.replace(Some((pipeline, bus_watch))); 259 | } 260 | Err(err) => { 261 | warn!("Unable to generate the waveform: {}", err); 262 | pipeline 263 | .set_state(gst::State::Null) 264 | .expect("Pipeline reset failed"); 265 | self.imp().peaks.replace(None); 266 | self.notify("has-peaks"); 267 | } 268 | }; 269 | } 270 | } 271 | --------------------------------------------------------------------------------