diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 53763dd5..eba1a395 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -6,8 +6,13 @@ on: pull_request: branches: [main] +permissions: + contents: read + env: CARGO_TERM_COLOR: always + VERSION_DATE: ${{ format('{0}.{1}.{2}', github.run_number, github.run_id, github.run_attempt) }} + VERSION_TIMESTAMP: ${{ github.event.repository.updated_at }} jobs: code-style: @@ -45,6 +50,17 @@ jobs: - name: Checkout Repository uses: actions/checkout@v4 + - name: Set Version Info + id: version + shell: bash + env: + FULL_SHA: ${{ github.sha }} + run: | + echo "BUILD_DATE=$(date +'%Y%m%d')" >> $GITHUB_ENV + echo "VERSION=0.1.0,$(date +'%Y%m%d.%H%M%S')" >> $GITHUB_ENV + SHORT_SHA=${FULL_SHA::7} + echo "RELEASE_VERSION=$(date +'%Y.%m.%d')-$SHORT_SHA" >> $GITHUB_ENV + - name: Setup Rust Cache uses: Swatinem/rust-cache@v2 with: @@ -119,6 +135,12 @@ jobs: "target/release/bundle/osx/Psst.app" working-directory: psst-gui + - name: Generate DMG Checksum + if: runner.os == 'macOS' + run: | + shasum -a 256 "Psst.dmg" > "Psst.dmg.sha256" + working-directory: psst-gui + - name: Upload macOS DMG uses: actions/upload-artifact@v4 if: runner.os == 'macOS' @@ -126,16 +148,27 @@ jobs: name: Psst.dmg path: ./psst-gui/Psst.dmg + - name: Upload macOS DMG Checksum + uses: actions/upload-artifact@v4 + if: runner.os == 'macOS' + with: + name: Psst.dmg.sha256 + path: ./psst-gui/Psst.dmg.sha256 + - name: Make Linux Binary Executable if: runner.os == 'Linux' run: chmod +x target/${{ matrix.target }}/release/psst-gui + - name: Rename Linux Binary + if: runner.os == 'Linux' + run: mv target/${{ matrix.target }}/release/psst-gui target/${{ matrix.target }}/release/psst + - name: Upload Linux Binary uses: actions/upload-artifact@v4 if: runner.os == 'Linux' with: - name: psst-gui-${{ matrix.target }} - path: target/${{ matrix.target }}/release/psst-gui + name: psst-${{ matrix.target }} + path: target/${{ matrix.target }}/release/psst - name: Upload Windows Executable uses: actions/upload-artifact@v4 @@ -163,16 +196,16 @@ jobs: - name: Download Linux Binaries uses: actions/download-artifact@v4 with: - name: psst-gui-${{ matrix.target }} - path: ${{runner.workspace}}/binaries + name: psst-${{ matrix.target }} + path: binaries - name: Move Binary run: | - mkdir -p ${{runner.workspace}}/pkg/usr/bin/ - mv ${{runner.workspace}}/binaries/psst-gui ${{runner.workspace}}/pkg/usr/bin/ + mkdir -p pkg/usr/bin/ + mv binaries/psst pkg/usr/bin/ - name: Move Desktop Entry - run: mkdir -p ${{runner.workspace}}/pkg/usr/share/applications/; mv .pkg/psst.desktop $_ + run: mkdir -p pkg/usr/share/applications/; mv .pkg/psst.desktop pkg/usr/share/applications/ - name: Add Icons run: | @@ -180,30 +213,30 @@ jobs: for LOGO in $LOGOS do LOGO_SIZE=$(echo "${LOGO}" | grep -oE '[[:digit:]]{2,}') - mkdir -p "${{runner.workspace}}/pkg/usr/share/icons/hicolor/${LOGO_SIZE}x${LOGO_SIZE}/" - cp "./psst-gui/assets/${LOGO}" "$_/psst.png" + mkdir -p "pkg/usr/share/icons/hicolor/${LOGO_SIZE}x${LOGO_SIZE}/" + cp "./psst-gui/assets/${LOGO}" "pkg/usr/share/icons/hicolor/${LOGO_SIZE}x${LOGO_SIZE}/psst.png" done - mkdir -p "${{runner.workspace}}/pkg/usr/share/icons/hicolor/scalable/apps/" - cp "./psst-gui/assets/logo.svg" "$_/psst.svg" + mkdir -p "pkg/usr/share/icons/hicolor/scalable/apps/" + cp "./psst-gui/assets/logo.svg" "pkg/usr/share/icons/hicolor/scalable/apps/psst.svg" - name: Set Permissions - run: chmod 755 ${{runner.workspace}}/pkg/usr/bin/psst-gui + run: chmod 755 pkg/usr/bin/psst - name: Move License - run: mkdir -p ${{runner.workspace}}/pkg/usr/share/doc/psst-gui/; mv .pkg/copyright $_ + run: mkdir -p pkg/usr/share/doc/psst-gui/; mv .pkg/copyright pkg/usr/share/doc/psst-gui/ - name: Write Package Config run: | - mkdir -p ${{runner.workspace}}/pkg/DEBIAN + mkdir -p pkg/DEBIAN export ARCHITECTURE=${{ matrix.arch }} SANITIZED_BRANCH="$(echo ${GITHUB_HEAD_REF:+.$GITHUB_HEAD_REF}|tr '_/' '-')" export VERSION=0.1.0"$SANITIZED_BRANCH"+r"$(git rev-list --count HEAD)"-0 - envsubst < .pkg/DEBIAN/control > ${{runner.workspace}}/pkg/DEBIAN/control + envsubst < .pkg/DEBIAN/control > pkg/DEBIAN/control - name: Build Package run: | - cat ${{runner.workspace}}/pkg/DEBIAN/control - dpkg-deb -b ${{runner.workspace}}/pkg/ psst_$(git rev-list --count HEAD)_${{ matrix.arch }}.deb + cat pkg/DEBIAN/control + dpkg-deb -b pkg/ psst_$(git rev-list --count HEAD)_${{ matrix.arch }}.deb - name: Upload Debian Package uses: actions/upload-artifact@v4 @@ -223,29 +256,30 @@ jobs: uses: actions/download-artifact@v4 with: name: psst-deb - path: ${{runner.workspace}} + # Downloads to the root of the workspace by default if path is omitted or '.', + # so removing explicit path to ${{github.workspace}} - name: Install Dependencies run: sudo apt-get update && sudo apt-get install -y libfuse2 - name: Create Workspace - run: mkdir -p ${{runner.workspace}}/appimage + run: mkdir -p appimage - name: Download the Latest pkg2appimage run: | latest_release_appimage_url=$(wget -q https://api.github.com/repos/AppImageCommunity/pkg2appimage/releases/latest -O - | jq -r '.assets[0].browser_download_url') - wget --directory-prefix=${{runner.workspace}}/appimage -c $latest_release_appimage_url + wget --directory-prefix=appimage -c $latest_release_appimage_url - name: Create Path to pkg2appimage run: | - pkg2appimage_executable=$(ls ${{runner.workspace}}/appimage) - app_path=${{runner.workspace}}/appimage/${pkg2appimage_executable} + pkg2appimage_executable=$(ls appimage) + app_path=appimage/${pkg2appimage_executable} chmod +x ${app_path} echo "app_path=${app_path}" >> $GITHUB_ENV - name: Create Path to pkg2appimage's Recipe File run: | - recipe_path=${{runner.workspace}}/psst/.pkg/APPIMAGE/pkg2appimage-ingredients.yml + recipe_path=psst/.pkg/APPIMAGE/pkg2appimage-ingredients.yml echo "recipe_path=${recipe_path}" >> $GITHUB_ENV - name: Run pkg2appimage @@ -256,4 +290,82 @@ jobs: uses: actions/upload-artifact@v4 with: name: psst-appimage - path: ${{runner.workspace}}/out/*.AppImage + path: out/*.AppImage + + release: + needs: [build, deb] + if: github.ref == 'refs/heads/main' + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Set Version Info + env: + FULL_SHA: ${{ github.sha }} + run: | + echo "BUILD_DATE=$(date +'%Y%m%d')" >> $GITHUB_ENV + echo "VERSION=0.1.0,$(date +'%Y%m%d.%H%M%S')" >> $GITHUB_ENV + SHORT_SHA=${FULL_SHA::7} + echo "RELEASE_VERSION=$(date +'%Y.%m.%d')-$SHORT_SHA" >> $GITHUB_ENV + echo "RELEASE_FILES=artifacts/Psst.dmg/Psst.dmg + artifacts/Psst.dmg.sha256/Psst.dmg.sha256 + artifacts/Psst.exe/psst-gui.exe + artifacts/psst-x86_64-unknown-linux-gnu/psst + artifacts/psst-aarch64-unknown-linux-gnu/psst + artifacts/checksums.txt + artifacts/psst-deb-amd64/*.deb + artifacts/psst-deb-arm64/*.deb" >> $GITHUB_ENV + + - name: Download all artifacts + uses: actions/download-artifact@v4 + with: + path: artifacts + + - name: Generate SHA256 checksums + run: | + cd artifacts + find . -type f -name "*.dmg" -o -name "*.exe" -o -name "psst-*" | while read file; do + if [[ "$file" != *.sha256 ]]; then + sha256sum "$file" >> checksums.txt + fi + done + + - name: Create Release + uses: softprops/action-gh-release@v2 + with: + name: Psst ${{ env.RELEASE_VERSION }} + tag_name: v${{ env.RELEASE_VERSION }} + body: | + ## Psst ${{ env.RELEASE_VERSION }} + Built: $(date) + Version: ${{ env.VERSION }} + + **SHA256 Checksums:** + ``` + $(cat artifacts/checksums.txt) + ``` + + **Homebrew:** + `brew install --cask psst` + files: ${{ env.RELEASE_FILES }} + generate_release_notes: false + + - name: Create or Update Latest Build Release + uses: softprops/action-gh-release@v2 + with: + name: Psst Latest Build (main branch) + tag_name: latest # Fixed tag for the latest build + body: | + ## Psst Latest Build (main) + Commit: ${{ github.sha }} + Built: $(date) + Workflow: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + + Auto-updated from the main branch. For stable releases, see other tags. + + **SHA256 Checksums:** + ``` + $(cat artifacts/checksums.txt) + ``` + files: ${{ env.RELEASE_FILES }} + generate_release_notes: false diff --git a/.homebrew/generate_formula.sh b/.homebrew/generate_formula.sh new file mode 100755 index 00000000..8f0048c1 --- /dev/null +++ b/.homebrew/generate_formula.sh @@ -0,0 +1,56 @@ +#!/bin/bash + +set -eo pipefail + +REPO_OWNER="jpochyla" +REPO_NAME="psst" + +LATEST_VERSION_TAG_NO_V=$(git ls-remote --tags "https://github.com/${REPO_OWNER}/${REPO_NAME}.git" | + grep -Eo 'refs/tags/v[0-9]{4}\.[0-9]{2}\.[0-9]{2}-[a-f0-9]{7}$' | + sed 's|refs/tags/v||' | sort -V | tail -n1) +: "${LATEST_VERSION_TAG_NO_V:?Error: No versioned tag found.}" + +VERSION="$LATEST_VERSION_TAG_NO_V" +TAG_WITH_V="v${VERSION}" + +RELEASE_INFO_JSON=$(curl -sL "https://api.github.com/repos/${REPO_OWNER}/${REPO_NAME}/releases/tags/${TAG_WITH_V}") + +DMG_URL=$(echo "$RELEASE_INFO_JSON" | jq -r '.assets[] | select(.name=="Psst.dmg") | .browser_download_url') +: "${DMG_URL:?Error: Could not find Psst.dmg asset URL for tag ${TAG_WITH_V}.}" + +CHECKSUMS_URL=$(echo "$RELEASE_INFO_JSON" | jq -r '.assets[] | select(.name=="checksums.txt") | .browser_download_url') +: "${CHECKSUMS_URL:?Error: Could not find checksums.txt asset URL for tag ${TAG_WITH_V}.}" + +SHA256=$(curl -sL "$CHECKSUMS_URL" | grep '\./Psst.dmg/Psst.dmg$' | awk '{print $1}') +: "${SHA256:?Error: Could not find SHA256 for Psst.dmg in checksums.txt.}" + +cat <= :big_sur" + + zap trash: [ + "~/Library/Application Support/com.jpochyla.psst", + "~/Library/Caches/com.jpochyla.psst", + "~/Library/HTTPStorages/com.jpochyla.psst", + "~/Library/Preferences/com.jpochyla.psst.plist", + "~/Library/Saved Application State/com.jpochyla.psst.savedState", + ] +end +EOF diff --git a/.pkg/DEBIAN/control b/.pkg/DEBIAN/control index cc618e14..1d42a75d 100644 --- a/.pkg/DEBIAN/control +++ b/.pkg/DEBIAN/control @@ -7,4 +7,4 @@ Priority: optional Homepage: https://github.com/jpochyla/psst Package-Type: deb Depends: libssl3 | libssl1.1, libgtk-3-0, libcairo2 -Description: Fast and multi-platform Spotify client with native GUI +Description: Fast and native Spotify client diff --git a/.pkg/psst.desktop b/.pkg/psst.desktop index ca0c8f9f..51f1103d 100755 --- a/.pkg/psst.desktop +++ b/.pkg/psst.desktop @@ -1,7 +1,7 @@ [Desktop Entry] Type=Application Name=Psst -Comment=Fast and multi-platform Spotify client with native GUI +Comment=Fast and native Spotify client GenericName=Music Player Icon=psst TryExec=psst-gui diff --git a/README.md b/README.md index 926188d1..bc0f34d7 100644 --- a/README.md +++ b/README.md @@ -13,17 +13,17 @@ Contributions are welcome! ## Download -GitHub Actions automatically creates builds when new commits are pushed to the `main` branch. -You can download the prebuilt binaries for x86_64 Windows, Linux (Ubuntu), and macOS. - -| Platform | -| ----------------------------------------------------------------------------------------------------------------- | -| [Linux (x86_64)](https://nightly.link/jpochyla/psst/workflows/build/main/psst-gui-x86_64-unknown-linux-gnu.zip) | -| [Linux (aarch64)](https://nightly.link/jpochyla/psst/workflows/build/main/psst-gui-aarch64-unknown-linux-gnu.zip) | -| [Debian Package (amd64)](https://nightly.link/jpochyla/psst/workflows/build/main/psst-deb-amd64.zip) | -| [Debian Package (arm64)](https://nightly.link/jpochyla/psst/workflows/build/main/psst-deb-arm64.zip) | -| [MacOS](https://nightly.link/jpochyla/psst/workflows/build/main/Psst.dmg.zip) | -| [Windows](https://nightly.link/jpochyla/psst/workflows/build/main/Psst.exe.zip) | +GitHub Actions automatically builds and releases new versions when changes are pushed to the `main` branch. +You can download the latest release for Windows, Linux, and macOS from the [GitHub Releases page](https://github.com/jpochyla/psst/releases/latest). + +| Platform | Download Link | +| ---------------------- | -------------------------------------------------------------------------------------------------------- | +| Linux (x86_64) | [Download](https://github.com/jpochyla/psst/releases/latest/download/psst-gui-x86_64-unknown-linux-gnu) | +| Linux (aarch64) | [Download](https://github.com/jpochyla/psst/releases/latest/download/psst-gui-aarch64-unknown-linux-gnu) | +| Debian Package (amd64) | [Download](https://github.com/jpochyla/psst/releases/latest/download/psst_*_amd64.deb) | +| Debian Package (arm64) | [Download](https://github.com/jpochyla/psst/releases/latest/download/psst_*_arm64.deb) | +| macOS | [Download](https://github.com/jpochyla/psst/releases/latest/download/Psst-*.dmg) | +| Windows | [Download](https://github.com/jpochyla/psst/releases/latest/download/psst-gui.exe) | Unofficial builds of Psst are also available through the [AUR](https://aur.archlinux.org/packages/psst-git) and [Homebrew](https://formulae.brew.sh/cask/psst). diff --git a/psst-cli/src/main.rs b/psst-cli/src/main.rs index c829dc19..b2344d54 100644 --- a/psst-cli/src/main.rs +++ b/psst-cli/src/main.rs @@ -35,7 +35,7 @@ fn main() { fn start(track_id: &str, session: SessionService) -> Result<(), Error> { let cdn = Cdn::new(session.clone(), None)?; let cache = Cache::new(PathBuf::from("cache"))?; - let item_id = ItemId::from_base62(track_id, ItemIdType::Track).unwrap(); + let item_id = ItemId::from_base62(track_id, ItemIdType::Track, false).unwrap(); play_item( session, cdn, diff --git a/psst-core/src/item_id.rs b/psst-core/src/item_id.rs index 73f76c25..4f814d5f 100644 --- a/psst-core/src/item_id.rs +++ b/psst-core/src/item_id.rs @@ -59,51 +59,56 @@ pub enum ItemIdType { pub struct ItemId { pub id: u128, pub id_type: ItemIdType, + pub from_added_queue: bool, } const BASE62_DIGITS: &[u8] = b"0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"; const BASE16_DIGITS: &[u8] = b"0123456789abcdef"; impl ItemId { - pub const INVALID: Self = Self::new(0u128, ItemIdType::Unknown); + pub const INVALID: Self = Self::new(0u128, ItemIdType::Unknown, false); - pub const fn new(id: u128, id_type: ItemIdType) -> Self { - Self { id, id_type } + pub const fn new(id: u128, id_type: ItemIdType, from_added_queue: bool) -> Self { + Self { + id, + id_type, + from_added_queue, + } } - pub fn from_base16(id: &str, id_type: ItemIdType) -> Option { + pub fn from_base16(id: &str, id_type: ItemIdType, from_added_queue: bool) -> Option { let mut n = 0_u128; for c in id.as_bytes() { let d = BASE16_DIGITS.iter().position(|e| e == c)? as u128; n *= 16; n += d; } - Some(Self::new(n, id_type)) + Some(Self::new(n, id_type, from_added_queue)) } - pub fn from_base62(id: &str, id_type: ItemIdType) -> Option { + pub fn from_base62(id: &str, id_type: ItemIdType, from_added_queue: bool) -> Option { let mut n = 0_u128; for c in id.as_bytes() { let d = BASE62_DIGITS.iter().position(|e| e == c)? as u128; n *= 62; n += d; } - Some(Self::new(n, id_type)) + Some(Self::new(n, id_type, from_added_queue)) } - pub fn from_raw(data: &[u8], id_type: ItemIdType) -> Option { + pub fn from_raw(data: &[u8], id_type: ItemIdType, from_added_queue: bool) -> Option { let n = u128::from_be_bytes(data.try_into().ok()?); - Some(Self::new(n, id_type)) + Some(Self::new(n, id_type, from_added_queue)) } - pub fn from_uri(uri: &str) -> Option { + pub fn from_uri(uri: &str, from_added_queue: bool) -> Option { let gid = uri.split(':').next_back()?; if uri.contains(":episode:") { - Self::from_base62(gid, ItemIdType::Podcast) + Self::from_base62(gid, ItemIdType::Podcast, from_added_queue) } else if uri.contains(":track:") { - Self::from_base62(gid, ItemIdType::Track) + Self::from_base62(gid, ItemIdType::Track, from_added_queue) } else { - Self::from_base62(gid, ItemIdType::Unknown) + Self::from_base62(gid, ItemIdType::Unknown, from_added_queue) } } @@ -137,10 +142,11 @@ impl ItemId { self.id.to_be_bytes() } - pub fn from_local(path: PathBuf) -> Self { + pub fn from_local(path: PathBuf, from_added_queue: bool) -> Self { Self::new( LocalItemRegistry::get_or_insert(path), ItemIdType::LocalFile, + from_added_queue, ) } diff --git a/psst-core/src/metadata.rs b/psst-core/src/metadata.rs index b044af81..7c02f077 100644 --- a/psst-core/src/metadata.rs +++ b/psst-core/src/metadata.rs @@ -31,8 +31,8 @@ impl Fetch for Episode { pub trait ToMediaPath { fn is_restricted_in_region(&self, country: &str) -> bool; - fn find_allowed_alternative(&self, country: &str) -> Option; - fn to_media_path(&self, preferred_bitrate: usize) -> Option; + fn find_allowed_alternative(&self, country: &str, from_added_queue: bool) -> Option; + fn to_media_path(&self, preferred_bitrate: usize, from_added_queue: bool) -> Option; } impl ToMediaPath for Track { @@ -42,18 +42,18 @@ impl ToMediaPath for Track { .any(|rest| is_restricted_in_region(rest, country)) } - fn find_allowed_alternative(&self, country: &str) -> Option { + fn find_allowed_alternative(&self, country: &str, from_added_queue: bool) -> Option { let alt_track = self .alternative .iter() .find(|alt_track| !alt_track.is_restricted_in_region(country))?; - ItemId::from_raw(alt_track.gid.as_ref()?, ItemIdType::Track) + ItemId::from_raw(alt_track.gid.as_ref()?, ItemIdType::Track, from_added_queue) } - fn to_media_path(&self, preferred_bitrate: usize) -> Option { + fn to_media_path(&self, preferred_bitrate: usize, from_added_queue: bool) -> Option { let file = select_preferred_file(&self.file, preferred_bitrate)?; Some(MediaPath { - item_id: ItemId::from_raw(self.gid.as_ref()?, ItemIdType::Track)?, + item_id: ItemId::from_raw(self.gid.as_ref()?, ItemIdType::Track, from_added_queue)?, file_id: FileId::from_raw(file.file_id.as_ref()?)?, file_format: AudioFormat::from_protocol(file.format?), duration: Duration::from_millis(self.duration? as u64), @@ -68,14 +68,14 @@ impl ToMediaPath for Episode { .any(|rest| is_restricted_in_region(rest, country)) } - fn find_allowed_alternative(&self, _country: &str) -> Option { + fn find_allowed_alternative(&self, _country: &str, _from_added_queue: bool) -> Option { None } - fn to_media_path(&self, preferred_bitrate: usize) -> Option { + fn to_media_path(&self, preferred_bitrate: usize, from_added_queue: bool) -> Option { let file = select_preferred_file(&self.file, preferred_bitrate)?; Some(MediaPath { - item_id: ItemId::from_raw(self.gid.as_ref()?, ItemIdType::Podcast)?, + item_id: ItemId::from_raw(self.gid.as_ref()?, ItemIdType::Podcast, from_added_queue)?, file_id: FileId::from_raw(file.file_id.as_ref()?)?, file_format: AudioFormat::from_protocol(file.format?), duration: Duration::from_millis(self.duration? as u64), diff --git a/psst-core/src/player/item.rs b/psst-core/src/player/item.rs index 9b934b32..e56a78ee 100644 --- a/psst-core/src/player/item.rs +++ b/psst-core/src/player/item.rs @@ -90,11 +90,11 @@ fn load_media_path_from_track_or_alternative( // The track is regionally restricted and is unavailable. Let's try to find an // alternative track. let alt_id = track - .find_allowed_alternative(&user_country) + .find_allowed_alternative(&user_country, item_id.from_added_queue) .ok_or(Error::MediaFileNotFound)?; let alt_track = load_track(alt_id, session, cache)?; let alt_path = alt_track - .to_media_path(config.bitrate) + .to_media_path(config.bitrate, item_id.from_added_queue) .ok_or(Error::MediaFileNotFound)?; // We've found an alternative track with a fitting audio file. Let's cheat a // little and pretend we've obtained it from the requested track. @@ -108,7 +108,7 @@ fn load_media_path_from_track_or_alternative( // Either we do not have a country code loaded or the track is available, return // it. track - .to_media_path(config.bitrate) + .to_media_path(config.bitrate, item_id.from_added_queue) .ok_or(Error::MediaFileNotFound)? } }; @@ -129,7 +129,7 @@ fn load_media_path_from_episode( return Err(Error::MediaFileNotFound); } _ => episode - .to_media_path(config.bitrate) + .to_media_path(config.bitrate, item_id.from_added_queue) .ok_or(Error::MediaFileNotFound)?, }; Ok(path) diff --git a/psst-core/src/player/mod.rs b/psst-core/src/player/mod.rs index 3de35edd..7b2f0ec2 100644 --- a/psst-core/src/player/mod.rs +++ b/psst-core/src/player/mod.rs @@ -7,6 +7,7 @@ mod worker; use std::{mem, thread, thread::JoinHandle, time::Duration}; use crossbeam_channel::{unbounded, Receiver, Sender}; +use log::info; use crate::{ audio::output::{AudioOutput, AudioSink, DefaultAudioOutput, DefaultAudioSink}, @@ -119,7 +120,10 @@ impl Player { PlayerCommand::Seek { position } => self.seek(position), PlayerCommand::Configure { config } => self.configure(config), PlayerCommand::SetQueueBehavior { behavior } => self.queue.set_behaviour(behavior), + PlayerCommand::SkipToPlaceInQueue { item } => self.queue.skip_to_place_in_queue(item), + PlayerCommand::ClearQueue => self.queue.clear_user_items(), PlayerCommand::AddToQueue { item } => self.queue.add(item), + PlayerCommand::RemoveFromQueue { item } => self.queue.remove(item), PlayerCommand::SetVolume { volume } => self.set_volume(volume), } } @@ -289,14 +293,18 @@ impl Player { } fn play_loaded(&mut self, loaded_item: LoadedPlaybackItem) { + info!("{:?}", loaded_item.file.path()); log::info!("starting playback"); let path = loaded_item.file.path(); let position = Duration::default(); self.playback_mgr.play(loaded_item); + info!("111"); self.state = PlayerState::Playing { path, position }; + info!("22"); self.sender .send(PlayerEvent::Playing { path, position }) .unwrap(); + info!("3333"); } fn pause(&mut self) { @@ -423,9 +431,16 @@ pub enum PlayerCommand { SetQueueBehavior { behavior: QueueBehavior, }, + SkipToPlaceInQueue { + item: usize, + }, AddToQueue { item: PlaybackItem, }, + RemoveFromQueue { + item: usize, + }, + ClearQueue, /// Change playback volume to a value in 0.0..=1.0 range. SetVolume { volume: f64, diff --git a/psst-core/src/player/queue.rs b/psst-core/src/player/queue.rs index acbea36e..cd4c61d8 100644 --- a/psst-core/src/player/queue.rs +++ b/psst-core/src/player/queue.rs @@ -18,51 +18,106 @@ impl Default for QueueBehavior { pub struct Queue { items: Vec, + added_items_in_main_queue: Vec<(usize, usize)>, user_items: Vec, position: usize, user_items_position: usize, positions: Vec, behavior: QueueBehavior, + playing_from_user_items: bool, } impl Queue { pub fn new() -> Self { Self { items: Vec::new(), + added_items_in_main_queue: Vec::new(), user_items: Vec::new(), position: 0, user_items_position: 0, positions: Vec::new(), behavior: QueueBehavior::default(), + playing_from_user_items: false, } } - + pub fn clear(&mut self) { self.items.clear(); self.positions.clear(); self.position = 0; } + pub fn clear_user_items(&mut self) { + self.user_items.clear(); + self.user_items_position = 0; + } + pub fn fill(&mut self, items: Vec, position: usize) { self.positions.clear(); + self.added_items_in_main_queue.clear(); self.items = items; self.position = position; self.compute_positions(); } + + pub fn skip_to_place_in_queue(&mut self, index: usize) { + if self.playing_from_user_items { + self.user_items = self.user_items.split_off(index + 1); + } + else { + self.user_items = self.user_items.split_off(index); + } + self.user_items_position = 0; + } pub fn add(&mut self, item: PlaybackItem) { self.user_items.push(item); } + pub fn get_playing_from_user_items_bool(&mut self) -> bool{ + self.playing_from_user_items + } + + pub fn remove(&mut self, index: usize) { + if self.playing_from_user_items { + self.user_items.remove(index+1); + } + else { + self.user_items.remove(index); + } + if self.user_items_position < index && self.user_items_position > 0 { + self.user_items_position -= 1; + } + } + fn handle_added_queue(&mut self) { + if !self.added_items_in_main_queue.is_empty() { + let item_index = self.added_items_in_main_queue[0].0; + let position_index = self.added_items_in_main_queue[0].1; + self.items.remove(item_index - 1); + self.positions.remove(position_index); + + self.added_items_in_main_queue.remove(0); + if self.position > 0 { + self.position -= 1; + } + } + if self.user_items.len() > self.user_items_position { self.items.insert( self.positions.len(), self.user_items[self.user_items_position], ); + self.positions .insert(self.position + 1, self.positions.len()); self.user_items_position += 1; + + self.added_items_in_main_queue.push((self.positions.len(), self.position + 1)); + self.playing_from_user_items = true; + } + else { + self.playing_from_user_items = false; } } diff --git a/psst-gui/Cargo.toml b/psst-gui/Cargo.toml index c82169f5..8c48c88f 100644 --- a/psst-gui/Cargo.toml +++ b/psst-gui/Cargo.toml @@ -4,7 +4,7 @@ version = "0.1.0" authors = ["Jan Pochyla "] edition = "2021" build = "build.rs" -description = "Fast Spotify client with native GUI" +description = "Fast and native Spotify client" repository = "https://github.com/jpochyla/psst" [features] @@ -66,7 +66,7 @@ version = "0.1.0" resources = [] copyright = "Copyright (c) Jan Pochyla 2024. All rights reserved." category = "Music" -short_description = "Fast Spotify client with native GUI" +short_description = "Fast and native Spotify client" long_description = """ -Small and efficient graphical music player for Spotify network. +Small and efficient graphical music player for the Spotify network. """ diff --git a/psst-gui/src/cmd.rs b/psst-gui/src/cmd.rs index 9e897e79..a3f1fdc0 100644 --- a/psst-gui/src/cmd.rs +++ b/psst-gui/src/cmd.rs @@ -58,6 +58,11 @@ pub const PLAY_QUEUE_BEHAVIOR: Selector = Selector::new("app.play pub const PLAY_SEEK: Selector = Selector::new("app.play-seek"); pub const SKIP_TO_POSITION: Selector = Selector::new("app.skip-to-position"); +// Queue control +pub const REMOVE_FROM_QUEUE: Selector = Selector::new("app.remove-from-queue"); +pub const CLEAR_QUEUE: Selector = Selector::new("app.clear-queue"); +pub const SKIP_TO_PLACE_IN_QUEUE: Selector = Selector::new("app.skip-to-place-in-queue"); + // Sorting control pub const SORT_BY_DATE_ADDED: Selector = Selector::new("app.sort-by-date-added"); pub const SORT_BY_TITLE: Selector = Selector::new("app.sort-by-title"); diff --git a/psst-gui/src/controller/playback.rs b/psst-gui/src/controller/playback.rs index 4631221a..f5d75059 100644 --- a/psst-gui/src/controller/playback.rs +++ b/psst-gui/src/controller/playback.rs @@ -1,4 +1,5 @@ use std::{ + cmp::Ordering, thread::{self, JoinHandle}, time::Duration, }; @@ -9,10 +10,12 @@ use druid::{ widget::{prelude::*, Controller}, Code, ExtEventSink, InternalLifeCycle, KbKey, WindowHandle, }; +use log::info; use psst_core::{ audio::{normalize::NormalizationLevel, output::DefaultAudioOutput}, cache::Cache, cdn::Cdn, + item_id::ItemId, lastfm::LastFmClient, player::{item::PlaybackItem, PlaybackConfig, Player, PlayerCommand, PlayerEvent}, session::SessionService, @@ -41,20 +44,15 @@ pub struct PlaybackController { scrobbler: Option, startup: bool, } -fn init_scrobbler_instance( - data: &AppState -) -> Option { +fn init_scrobbler_instance(data: &AppState) -> Option { if data.config.lastfm_enable { if let (Some(api_key), Some(api_secret), Some(session_key)) = ( data.config.lastfm_api_key.as_deref(), data.config.lastfm_api_secret.as_deref(), data.config.lastfm_session_key.as_deref(), ) { - match LastFmClient::create_scrobbler( - Some(api_key), - Some(api_secret), - Some(session_key), - ) { + match LastFmClient::create_scrobbler(Some(api_key), Some(api_secret), Some(session_key)) + { Ok(scr) => { log::info!("Last.fm Scrobbler instance created/updated."); return Some(scr); @@ -64,9 +62,7 @@ fn init_scrobbler_instance( } } } else { - log::info!( - "Last.fm credentials incomplete or removed, clearing Scrobbler instance." - ); + log::info!("Last.fm credentials incomplete or removed, clearing Scrobbler instance."); } } else { log::info!("Last.fm scrobbling is disabled, clearing Scrobbler instance."); @@ -74,7 +70,6 @@ fn init_scrobbler_instance( None } - impl PlaybackController { pub fn new() -> Self { Self { @@ -186,7 +181,12 @@ impl PlaybackController { }; let mut media_controls = MediaControls::new(PlatformConfig { - dbus_name: "psst", + dbus_name: format!( + "com.jpochyla.psst.{:04x}", + ((time::OffsetDateTime::now_utc().unix_timestamp_nanos() / 1_000) as u16) + ^ (rand::random::()) + ) + .as_str(), display_name: "Psst", hwnd, })?; @@ -324,7 +324,11 @@ impl PlaybackController { fn play(&mut self, items: &Vector, position: usize) { let playback_items = items.iter().map(|queued| PlaybackItem { - item_id: queued.item.id(), + item_id: ItemId { + id: queued.item.id().id, + id_type: queued.item.id().id_type, + from_added_queue: false, + }, norm_level: match queued.origin { PlaybackOrigin::Album(_) => NormalizationLevel::Album, _ => NormalizationLevel::Track, @@ -398,6 +402,19 @@ impl PlaybackController { item: *item, })); } + fn skip_to_place_in_queue(&mut self, item: &usize) { + self.send(PlayerEvent::Command(PlayerCommand::SkipToPlaceInQueue { + item: *item, + })); + } + fn remove_from_queue(&mut self, item: &usize) { + self.send(PlayerEvent::Command(PlayerCommand::RemoveFromQueue { + item: *item, + })); + } + fn clear_queue(&mut self) { + self.send(PlayerEvent::Command(PlayerCommand::ClearQueue)); + } fn set_queue_behavior(&mut self, behavior: QueueBehavior) { self.send(PlayerEvent::Command(PlayerCommand::SetQueueBehavior { @@ -448,6 +465,15 @@ where Event::Command(cmd) if cmd.is(cmd::PLAYBACK_PLAYING) => { let (item, progress) = cmd.get_unchecked(cmd::PLAYBACK_PLAYING); + // TODO: this falsely removes the song if you click on a song from the playlist that is also in the queue, not sure how to solve this? + if !data.added_queue.displayed_queue.is_empty() + && data.playback.now_playing.as_mut().is_some_and(|np| { + np.origin.to_string() == PlaybackOrigin::Queue.to_string() + && np.item.id() == data.added_queue.displayed_queue[0].item.id() + }) + { + data.added_queue.displayed_queue.remove(0); + } // Song has changed, so we reset the has_scrobbled value self.has_scrobbled = false; self.report_now_playing(&data.playback); @@ -531,6 +557,7 @@ where self.add_to_queue(item); data.add_queued_entry(entry.clone()); + data.info_alert("Track added to queue."); ctx.set_handled(); } Event::Command(cmd) if cmd.is(cmd::PLAY_QUEUE_BEHAVIOR) => { @@ -549,6 +576,65 @@ where } ctx.set_handled(); } + Event::Command(cmd) if cmd.is(cmd::SKIP_TO_PLACE_IN_QUEUE) => { + let track_pos = *cmd.get_unchecked(cmd::SKIP_TO_PLACE_IN_QUEUE); + match track_pos.cmp(&0) { + Ordering::Greater => { + if data.playback.queue.is_empty() + || (data.playback.queue.len() <= 1 + && data.playback.queue[0].origin.to_string() + == PlaybackOrigin::Queue.to_string()) + { + data.playback.queue.clear(); + data.playback + .queue + .push_back(data.added_queue.displayed_queue[track_pos].clone()); + data.added_queue.displayed_queue = + data.added_queue.displayed_queue.split_off(track_pos); + self.skip_to_place_in_queue(&(track_pos + 1)); + self.play(&data.playback.queue, track_pos); + } else if data.playback.now_playing.is_some() { + data.added_queue.displayed_queue = + data.added_queue.displayed_queue.split_off(track_pos); + self.skip_to_place_in_queue(&track_pos); + self.next(); + } + } + Ordering::Equal => { + if data.playback.queue.is_empty() + || (data.playback.queue.len() <= 1 + && data.playback.queue[0].origin.to_string() + == PlaybackOrigin::Queue.to_string()) + { + data.playback.queue.clear(); + data.playback + .queue + .push_back(data.added_queue.displayed_queue[track_pos].clone()); + self.remove_from_queue(&track_pos); + data.added_queue.displayed_queue.remove(track_pos); + self.play(&data.playback.queue, track_pos); + } else if data.playback.now_playing.is_some() { + self.next(); + } + } + _ => {} + } + + ctx.set_handled(); + } + Event::Command(cmd) if cmd.is(cmd::REMOVE_FROM_QUEUE) => { + let item = cmd.get_unchecked(cmd::REMOVE_FROM_QUEUE); + data.added_queue.displayed_queue.remove(*item); + self.remove_from_queue(item); + data.info_alert("Track removed from queue."); + + ctx.set_handled(); + } + Event::Command(cmd) if cmd.is(cmd::CLEAR_QUEUE) => { + data.added_queue.displayed_queue.clear(); + self.clear_queue(); + data.info_alert("Tracks cleared from queue."); + } Event::Command(cmd) if cmd.is(cmd::SKIP_TO_POSITION) => { let location = cmd.get_unchecked(cmd::SKIP_TO_POSITION); self.seek(Duration::from_millis(*location)); @@ -622,8 +708,7 @@ where if self.startup { self.startup = false; self.scrobbler = init_scrobbler_instance(data); - - } + } child.lifecycle(ctx, event, data, env); } diff --git a/psst-gui/src/data/config.rs b/psst-gui/src/data/config.rs index 99010ce0..6b62eeab 100644 --- a/psst-gui/src/data/config.rs +++ b/psst-gui/src/data/config.rs @@ -114,6 +114,7 @@ pub struct Config { pub last_route: Option