diff --git a/.github/workflows/linux.yml b/.github/workflows/linux.yml index 8737192..62121c0 100644 --- a/.github/workflows/linux.yml +++ b/.github/workflows/linux.yml @@ -9,6 +9,8 @@ on: - src/Reprise.Core/** - src/Reprise.Desktop/** - src/Reprise.Platform.Linux/** + - BrowserExtension/Chromium/icons/icon32.png + - BrowserExtension/Chromium/icons/icon128.png - tests/unit/** - tests/contracts/** - tests/integration/linux/** @@ -26,6 +28,8 @@ on: - src/Reprise.Core/** - src/Reprise.Desktop/** - src/Reprise.Platform.Linux/** + - BrowserExtension/Chromium/icons/icon32.png + - BrowserExtension/Chromium/icons/icon128.png - tests/unit/** - tests/contracts/** - tests/integration/linux/** @@ -174,6 +178,33 @@ jobs: with: dotnet-version: 10.0.x + - name: Restore Linux application + if: steps.discover.outputs.ready == 'true' + shell: bash + run: | + dotnet restore \ + src/Reprise.Platform.Linux/Reprise.Platform.Linux.csproj \ + --runtime '${{ matrix.runtime }}' + + - name: Publish Linux application + if: steps.discover.outputs.ready == 'true' + shell: bash + run: | + publish_directory="$GITHUB_WORKSPACE/Artifacts/${{ matrix.target }}/app" + dotnet publish \ + src/Reprise.Platform.Linux/Reprise.Platform.Linux.csproj \ + --configuration Release \ + --runtime '${{ matrix.runtime }}' \ + --self-contained true \ + --no-restore \ + --output "$publish_directory" + + - name: Smoke test Linux executable + if: steps.discover.outputs.ready == 'true' + shell: bash + run: | + 'Artifacts/${{ matrix.target }}/app/reprise' --health-check + - name: Run applicable tests if: steps.discover.outputs.ready == 'true' shell: bash @@ -206,6 +237,81 @@ jobs: --logger "trx;LogFileName=${project_name}.trx" done + - name: Package Linux application + if: steps.discover.outputs.ready == 'true' + shell: bash + run: | + artifact_directory="Artifacts/${{ matrix.target }}" + cp packaging/linux/dev.junx.Reprise.desktop "$artifact_directory/" + cp packaging/linux/README.md "$artifact_directory/" + cp packaging/linux/install-gnome-extension.sh "$artifact_directory/" + cp -r packaging/linux/gnome-extension "$artifact_directory/" + tar \ + -C Artifacts \ + -czf "Artifacts/reprise-${{ matrix.runtime }}.tar.gz" \ + '${{ matrix.target }}' + + - name: Build Debian package + if: steps.discover.outputs.ready == 'true' + shell: bash + run: | + case '${{ matrix.architecture }}' in + x64) debian_architecture=amd64 ;; + arm64) debian_architecture=arm64 ;; + *) echo 'unsupported architecture' >&2; exit 1 ;; + esac + + version="$(grep -oPm1 '(?<=)[^<]+' Directory.Build.props)" + suffix="$(grep -oPm1 '(?<=)[^<]+' Directory.Build.props || true)" + if [[ -n "$suffix" ]]; then + version="$version~$suffix" + fi + + packaging/linux/build-deb.sh \ + "Artifacts/${{ matrix.target }}/app" \ + "$debian_architecture" \ + "$version" \ + Artifacts + + ls Artifacts/*.deb + + - name: Build AppImage + if: steps.discover.outputs.ready == 'true' + shell: bash + run: | + sudo apt-get update -qq + sudo apt-get install -y -qq squashfs-tools + + case '${{ matrix.architecture }}' in + x64) appimage_architecture=x86_64 ;; + arm64) appimage_architecture=aarch64 ;; + *) echo 'unsupported architecture' >&2; exit 1 ;; + esac + + version="$(grep -oPm1 '(?<=)[^<]+' Directory.Build.props)" + suffix="$(grep -oPm1 '(?<=)[^<]+' Directory.Build.props || true)" + if [[ -n "$suffix" ]]; then + version="$version-$suffix" + fi + + packaging/linux/build-appimage.sh \ + "Artifacts/${{ matrix.target }}/app" \ + "$appimage_architecture" \ + "$version" \ + Artifacts + + - name: Upload Linux application + if: steps.discover.outputs.ready == 'true' + uses: actions/upload-artifact@v7 + with: + name: reprise-${{ matrix.runtime }} + path: | + Artifacts/reprise-${{ matrix.runtime }}.tar.gz + Artifacts/*.deb + Artifacts/*.AppImage + if-no-files-found: error + retention-days: 7 + - name: Upload Linux test results if: ${{ always() && steps.discover.outputs.ready == 'true' }} uses: actions/upload-artifact@v7 @@ -214,3 +320,98 @@ jobs: path: TestResults/${{ matrix.target }} if-no-files-found: ignore retention-days: 7 + + publish-repository: + name: Publish the apt repository + needs: platform-tests + if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/develop') + runs-on: ubuntu-24.04 + timeout-minutes: 15 + permissions: + contents: write + pages: write + id-token: write + concurrency: + group: apt-repository + cancel-in-progress: false + environment: + name: github-pages + url: ${{ steps.deploy.outputs.page_url }} + steps: + - name: Check out repository + uses: actions/checkout@v7 + + - name: Download built packages + uses: actions/download-artifact@v6 + with: + pattern: reprise-linux-* + path: Downloads + + - name: Install packaging tools + shell: bash + run: | + sudo apt-get update -qq + sudo apt-get install -y -qq dpkg-dev apt-utils + + - name: Configure Pages + id: pages + uses: actions/configure-pages@v5 + + - name: Fetch the published site + shell: bash + run: | + # The site holds both channels, and each branch rewrites only its + # own, so the run has to start from what is already published + # rather than from an empty directory. + if ! git clone --branch apt-repo --depth 1 \ + "https://x-access-token:${{ github.token }}@github.com/${{ github.repository }}.git" Site; then + mkdir Site + git -C Site init -q -b apt-repo + git -C Site remote add origin \ + "https://x-access-token:${{ github.token }}@github.com/${{ github.repository }}.git" + fi + + - name: Update this channel + shell: bash + run: | + if [[ '${{ github.ref }}' == 'refs/heads/main' ]]; then + suite=stable + else + suite=develop + fi + echo "Publishing to the $suite channel." + + packaging/linux/build-apt-repo.sh \ + Downloads \ + Site \ + "${{ steps.pages.outputs.base_url }}" \ + "$suite" + + # The AppImages ride along, one directory per channel. + mkdir -p "Site/downloads/$suite" + rm -f "Site/downloads/$suite"/*.AppImage + find Downloads -name '*.AppImage' -exec cp {} "Site/downloads/$suite/" \; + find Site -maxdepth 3 -type d | sort + + - name: Commit the site + shell: bash + run: | + cd Site + git config user.name 'github-actions[bot]' + git config user.email 'github-actions[bot]@users.noreply.github.com' + git add -A + if git diff --cached --quiet; then + echo 'Nothing changed.' + else + git commit -q -m "chore: publish ${{ github.ref_name }} build ${{ github.run_number }}" + git push -q origin HEAD:apt-repo + fi + + - name: Upload the site + uses: actions/upload-pages-artifact@v4 + with: + path: Site + + - name: Deploy to Pages + id: deploy + uses: actions/deploy-pages@v4 diff --git a/.gitignore b/.gitignore index 3104fcb..259efce 100644 --- a/.gitignore +++ b/.gitignore @@ -22,6 +22,12 @@ xcuserdata/ .build/ .swiftpm/ +# .NET +**/bin/ +**/obj/ +TestResults/ +Artifacts/ + # Local editor and tool state .idea/ .vscode/ @@ -29,6 +35,7 @@ xcuserdata/ *.swp *.swo *~ +CLAUDE.md # Local configuration and secrets .env diff --git a/Directory.Build.props b/Directory.Build.props new file mode 100644 index 0000000..34b8c2b --- /dev/null +++ b/Directory.Build.props @@ -0,0 +1,12 @@ + + + net10.0 + enable + enable + latest + true + 2.0.0 + alpha.1 + true + + diff --git a/Directory.Packages.props b/Directory.Packages.props new file mode 100644 index 0000000..19d240e --- /dev/null +++ b/Directory.Packages.props @@ -0,0 +1,14 @@ + + + true + + + + + + + + + + + diff --git a/README.md b/README.md index d1bac5b..da227e4 100644 --- a/README.md +++ b/README.md @@ -76,6 +76,38 @@ open src/Reprise.Platform.MacOS/Reprise.xcodeproj Build and run the `Reprise` scheme in Xcode 16 or later. +### Linux alpha + +The Linux implementation is under active development on `develop`. It uses +Avalonia for the desktop and reads local MPRIS media sessions straight from the +D-Bus session bus, so it needs no extra packages. The panel is the same one +macOS shows: album art, a scrolling title, transport controls, a scrubber, +volume, and the White, Dark, Liquid, and System themes. + +Add the apt repository once, and later builds arrive with your usual +updates: + +```bash +echo "deb [trusted=yes] https://aodjo.github.io/Reprise stable main" | sudo tee /etc/apt/sources.list.d/reprise.list +sudo apt update && sudo apt install reprise +``` + +There is also an AppImage on that page for running Reprise without +installing anything, and the source builds directly: + +```bash +dotnet run --project src/Reprise.Platform.Linux/Reprise.Platform.Linux.csproj +``` + +One click on the tray icon opens the panel, which hides again when it loses +focus, like the macOS popover. On panels that support the Ubuntu tray label +extension, the track title or the current lyric line appears beside the icon, +as it does in the macOS menu bar. On GNOME a small shell extension, installed +and switched on by the package, draws that entry itself and scrolls the title +smoothly. The interface is set in Pretendard, bundled +with the application. Linux CI also publishes self-contained `linux-x64` and +`linux-arm64` archives. + ## Usage Click the Reprise icon in your menu bar to open the player panel. diff --git a/Reprise.slnx b/Reprise.slnx new file mode 100644 index 0000000..2dfbd84 --- /dev/null +++ b/Reprise.slnx @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/docs/CROSS_PLATFORM.md b/docs/CROSS_PLATFORM.md index f370863..d28b2c4 100644 --- a/docs/CROSS_PLATFORM.md +++ b/docs/CROSS_PLATFORM.md @@ -66,8 +66,8 @@ targets for browser-specific manifests and generated artifacts. ## Branch and version policy -- `main` remains the single integration branch. -- `migration/cross-platform` contains the initial migration scaffold. +- `main` is the release branch and `develop` is the integration branch. +- The initial migration scaffold has been merged into `develop`. - New work uses short-lived `feature/*` and `fix/*` branches. - Creating the scaffold does not change the application version. - The first public release of the unified cross-platform application will be diff --git a/global.json b/global.json new file mode 100644 index 0000000..7c8e277 --- /dev/null +++ b/global.json @@ -0,0 +1,7 @@ +{ + "sdk": { + "version": "10.0.301", + "rollForward": "latestFeature", + "allowPrerelease": false + } +} diff --git a/packaging/linux/.gitkeep b/packaging/linux/.gitkeep deleted file mode 100644 index 8b13789..0000000 --- a/packaging/linux/.gitkeep +++ /dev/null @@ -1 +0,0 @@ - diff --git a/packaging/linux/README.md b/packaging/linux/README.md new file mode 100644 index 0000000..3e6a350 --- /dev/null +++ b/packaging/linux/README.md @@ -0,0 +1,94 @@ +# Linux packaging + +GitHub Actions publishes a Debian package, an AppImage, and a self-contained +archive for `linux-x64` and `linux-arm64`. All of them carry the whole +application, including its .NET runtime and the Pretendard typeface. + +## Apt repository + +Pushes publish the packages as an apt repository on GitHub Pages, so a +machine that adds it once keeps up with every later build. There are two +channels, and a machine follows only the one it subscribed to: `stable` +comes from `main`, `develop` from `develop`. + +```bash +echo "deb [trusted=yes] https://aodjo.github.io/Reprise stable main" | sudo tee /etc/apt/sources.list.d/reprise.list +sudo apt update && sudo apt install reprise +``` + +Swap `stable` for `develop` to follow development builds instead. The site +itself lives on the `apt-repo` branch, which each run updates in place, +because a channel has to survive the other channel being republished. + +`build-apt-repo.sh` assembles that tree and can be run locally against a +directory of `.deb` files to try it out. + +## Debian package + +The easiest way in. It puts the application in `/opt/reprise`, a launcher on +the path, a desktop entry, and the GNOME extension where the shell looks for +it. + +```bash +sudo apt install ./reprise_2.0.0~alpha.1+*_amd64.deb +``` + +Each build carries a stamp after the version, so installing a newer file over +an older one upgrades it rather than being skipped as already installed. +Removing it again is by package name, not by file: + +```bash +sudo apt remove reprise +``` + +The GNOME extension is switched on as part of the install, and Reprise checks +again whenever it starts, so there is nothing else to run. A shell that was +already running loads it after a log out and back in, or after Alt+F2 and `r` +on Xorg. + +Removing it takes the extension with it: + +```bash +sudo apt remove reprise +``` + +## AppImage + +One file, nothing installed. Mark it executable once and run it, from the +file manager or the terminal: + +```bash +chmod +x Reprise-2.0.0-alpha.1-x86_64.AppImage +./Reprise-2.0.0-alpha.1-x86_64.AppImage +``` + +Updating means replacing the file. The GNOME extension travels inside the +image, and Reprise copies it into the user's extension directory and enables +it on first run, so the top bar entry works here too. + +## Archive + +Each archive contains the application and the desktop entry in this +directory. The application talks to MPRIS players over the D-Bus session bus +that the desktop session already provides, so it has no other runtime +dependency. + +## GNOME top bar + +The tray protocol carries a label as a plain string, so the desktop draws it +and a long title can only step a character at a time. The optional GNOME +extension in `gnome-extension/` draws Reprise's entry itself, which lets the +title scroll smoothly as it does in the macOS menu bar, and hides nothing +else: without the extension Reprise still appears in the tray. + +```bash +./install-gnome-extension.sh +``` + +Then reload the shell: on Xorg press Alt+F2, type `r`, Enter; on Wayland log +out and back in. + +To try a CI archive, extract it and run `app/reprise`. The `.desktop` file can +be copied to `~/.local/share/applications`, and the application directory can +be placed under `~/.local/lib/reprise` with a launcher symlink in +`~/.local/bin/reprise`. diff --git a/packaging/linux/build-appimage.sh b/packaging/linux/build-appimage.sh new file mode 100755 index 0000000..027557d --- /dev/null +++ b/packaging/linux/build-appimage.sh @@ -0,0 +1,73 @@ +#!/usr/bin/env bash +# Builds an AppImage: one file the user marks executable and runs. +# +# Nothing is installed and no package manager is involved, which suits +# trying a build out. Reprise still finds its shell extension, because it +# looks beside its own executable and copies it into the user's extension +# directory on first run. +# +# Needs curl and mksquashfs, both of which the Debian and Ubuntu images +# used for packaging already have or can install. +# +# Usage: build-appimage.sh +# published-app-dir Output of "dotnet publish --self-contained" +# arch AppImage architecture: x86_64 or aarch64 +# version Version shown in the file name +# output-dir Where the .AppImage is written +set -euo pipefail + +app_dir="${1:?published app directory}" +arch="${2:?appimage architecture}" +version="${3:?version}" +output_dir="${4:?output directory}" +here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +root="$(cd "$here/../.." && pwd)" + +staging="$(mktemp -d)" +trap 'rm -rf "$staging"' EXIT +appdir="$staging/Reprise.AppDir" + +install -d "$appdir/usr/bin" "$appdir/usr/share/applications" \ + "$appdir/usr/share/icons/hicolor/128x128/apps" + +cp -r "$app_dir" "$appdir/usr/bin/app" +chmod +x "$appdir/usr/bin/app/reprise" +cp -r "$here/gnome-extension" "$appdir/usr/bin/gnome-extension" + +cat > "$appdir/AppRun" <<'LAUNCHER' +#!/bin/sh +# Runs Reprise from wherever the AppImage was mounted. +here="$(dirname "$(readlink -f "$0")")" +exec "$here/usr/bin/app/reprise" "$@" +LAUNCHER +chmod +x "$appdir/AppRun" + +cat > "$appdir/dev.junx.Reprise.desktop" <<'ENTRY' +[Desktop Entry] +Type=Application +Name=Reprise +Comment=Control MPRIS-compatible music players from one panel +Exec=AppRun +Icon=dev.junx.Reprise +Terminal=false +Categories=AudioVideo;Audio;Player; +StartupNotify=true +ENTRY +cp "$appdir/dev.junx.Reprise.desktop" "$appdir/usr/share/applications/" +cp "$root/BrowserExtension/Chromium/icons/icon128.png" "$appdir/dev.junx.Reprise.png" +cp "$appdir/dev.junx.Reprise.png" "$appdir/usr/share/icons/hicolor/128x128/apps/" + +# An AppImage is its runtime followed by a squashfs of the AppDir, so it is +# assembled here rather than with appimagetool: the tool is itself an +# AppImage for the target architecture, which a cross build cannot run. +runtime="$staging/runtime" +curl -fsSL -o "$runtime" \ + "https://github.com/AppImage/type2-runtime/releases/download/continuous/runtime-$arch" + +image="$staging/image.squashfs" +mksquashfs "$appdir" "$image" -root-owned -noappend -no-progress -quiet -comp zstd + +mkdir -p "$output_dir" +output="$output_dir/Reprise-$version-$arch.AppImage" +cat "$runtime" "$image" > "$output" +chmod +x "$output" diff --git a/packaging/linux/build-apt-repo.sh b/packaging/linux/build-apt-repo.sh new file mode 100755 index 0000000..e2ffdb2 --- /dev/null +++ b/packaging/linux/build-apt-repo.sh @@ -0,0 +1,103 @@ +#!/usr/bin/env bash +# Adds a channel of packages to an apt repository. +# +# Adding this repository once is the Linux counterpart of tapping a +# Homebrew formula: from then on "apt upgrade" carries every new build, so +# nobody has to download a file and remember which one they installed. +# +# There are two channels, and a machine follows whichever it subscribed to: +# "stable" comes from the main branch, "develop" from the development one. +# Each run rewrites only its own channel, so publishing a development build +# cannot disturb what stable users are offered. +# +# The repository is unsigned, and the sources line says so with +# "[trusted=yes]". Signing would mean a key that has to live somewhere and +# be rotated; for a personal alpha served over HTTPS from GitHub Pages, the +# transport is already doing the work a signature would. +# +# Usage: build-apt-repo.sh [suite] +# package-dir Directory holding the .deb files, searched recursively +# output-dir Repository tree to write into, kept if it already exists +# site-url Public address of the repository, for the instructions page +# suite Channel to update: stable or develop. Defaults to stable. +set -euo pipefail + +package_dir="${1:?directory of .deb files}" +output_dir="${2:?output directory}" +site_url="${3:?public site url}" +suite="${4:-stable}" + +architectures="amd64 arm64" +component=main + +case "$suite" in + stable | develop) ;; + *) echo "Unknown channel: $suite" >&2; exit 1 ;; +esac + +# Only this channel is rebuilt; anything else already published stays. +rm -rf "$output_dir/dists/$suite" "$output_dir/pool/$suite" +install -d "$output_dir/pool/$suite/$component/r/reprise" +find "$package_dir" -name '*.deb' -exec cp {} "$output_dir/pool/$suite/$component/r/reprise/" \; + +if ! ls "$output_dir/pool/$suite/$component/r/reprise/"*.deb >/dev/null 2>&1; then + echo "No .deb files found under $package_dir" >&2 + exit 1 +fi + +cd "$output_dir" +for architecture in $architectures; do + install -d "dists/$suite/$component/binary-$architecture" + dpkg-scanpackages --arch "$architecture" "pool/$suite" 2>/dev/null \ + > "dists/$suite/$component/binary-$architecture/Packages" + gzip -9nc "dists/$suite/$component/binary-$architecture/Packages" \ + > "dists/$suite/$component/binary-$architecture/Packages.gz" +done + +apt-ftparchive \ + -o "APT::FTPArchive::Release::Origin=Reprise" \ + -o "APT::FTPArchive::Release::Label=Reprise" \ + -o "APT::FTPArchive::Release::Suite=$suite" \ + -o "APT::FTPArchive::Release::Codename=$suite" \ + -o "APT::FTPArchive::Release::Architectures=$architectures" \ + -o "APT::FTPArchive::Release::Components=$component" \ + release "dists/$suite" > "dists/$suite/Release" + +# Pages will not serve a directory whose name starts with an underscore, and +# Jekyll would otherwise rewrite what it thinks are pages. +touch .nojekyll + +cat > index.html < + +Reprise for Linux + +

Reprise for Linux

+

A menu bar music controller for MPRIS players. Add one channel, and every + later build in it arrives with your usual updates.

+

Stable

+

Built from the main branch.

+
echo "deb [trusted=yes] $site_url stable main" | sudo tee /etc/apt/sources.list.d/reprise.list
+sudo apt update
+sudo apt install reprise
+

Development

+

Built from the development branch, updated far more often.

+
echo "deb [trusted=yes] $site_url develop main" | sudo tee /etc/apt/sources.list.d/reprise-develop.list
+sudo apt update
+sudo apt install reprise
+

Update

+
sudo apt update && sudo apt upgrade
+

Remove

+
sudo apt remove reprise
+sudo rm /etc/apt/sources.list.d/reprise*.list
+

Packages are unsigned, which is what [trusted=yes] + allows; they are served over HTTPS from GitHub Pages. An AppImage sits + beside this page for running Reprise without installing anything.

+PAGE diff --git a/packaging/linux/build-deb.sh b/packaging/linux/build-deb.sh new file mode 100755 index 0000000..3493f10 --- /dev/null +++ b/packaging/linux/build-deb.sh @@ -0,0 +1,55 @@ +#!/usr/bin/env bash +# Builds a Debian package around an already published Reprise application. +# +# The application is self-contained, so the package carries its own .NET +# runtime and depends only on the system libraries Avalonia links against. +# Run it inside a Debian or Ubuntu environment, which is where dpkg-deb +# lives; the CI workflow and the local Docker build both do. +# +# Usage: build-deb.sh +# published-app-dir Output of "dotnet publish --self-contained" +# arch Debian architecture: amd64 or arm64 +# version Package version, for example 2.0.0~alpha.1. A build +# stamp is appended so that two packages built from the +# same source still compare as newer and older: without +# it apt sees the same version and skips the install, +# leaving the previous build running. +# output-dir Where the .deb is written +set -euo pipefail + +app_dir="${1:?published app directory}" +arch="${2:?debian architecture}" +version="${3:?package version}" +output_dir="${4:?output directory}" +here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +root="$(cd "$here/../.." && pwd)" + +version="$version+$(date -u +%Y%m%d%H%M%S)" + +staging="$(mktemp -d)" +trap 'rm -rf "$staging"' EXIT + +install -d "$staging/DEBIAN" \ + "$staging/opt/reprise" \ + "$staging/usr/bin" \ + "$staging/usr/share/applications" \ + "$staging/usr/share/gnome-shell/extensions/reprise@junx.dev" \ + "$staging/usr/share/icons/hicolor/128x128/apps" \ + "$staging/usr/share/doc/reprise" + +cp -r "$app_dir" "$staging/opt/reprise/app" +chmod +x "$staging/opt/reprise/app/reprise" +ln -s /opt/reprise/app/reprise "$staging/usr/bin/reprise" + +sed -e "s/@VERSION@/$version/" -e "s/@ARCH@/$arch/" "$here/deb/control.in" > "$staging/DEBIAN/control" +install -m 755 "$here/deb/postinst" "$staging/DEBIAN/postinst" +install -m 755 "$here/deb/prerm" "$staging/DEBIAN/prerm" +install -m 644 "$here/deb/reprise.desktop" "$staging/usr/share/applications/dev.junx.Reprise.desktop" +install -m 644 "$here/gnome-extension/metadata.json" "$here/gnome-extension/extension.js" \ + "$staging/usr/share/gnome-shell/extensions/reprise@junx.dev/" +install -m 644 "$root/BrowserExtension/Chromium/icons/icon128.png" \ + "$staging/usr/share/icons/hicolor/128x128/apps/dev.junx.Reprise.png" +install -m 644 "$here/README.md" "$staging/usr/share/doc/reprise/README.md" + +mkdir -p "$output_dir" +dpkg-deb --root-owner-group --build "$staging" "$output_dir/reprise_${version}_${arch}.deb" diff --git a/packaging/linux/deb/control.in b/packaging/linux/deb/control.in new file mode 100644 index 0000000..9ecbc0d --- /dev/null +++ b/packaging/linux/deb/control.in @@ -0,0 +1,16 @@ +Package: reprise +Version: @VERSION@ +Architecture: @ARCH@ +Maintainer: aodjo +Section: sound +Priority: optional +Homepage: https://github.com/aodjo/Reprise +Depends: libc6, libgcc-s1, libstdc++6, zlib1g, libx11-6, libice6, libsm6, libfontconfig1, libgl1, ca-certificates +Description: Menu bar music controller for MPRIS players + Reprise follows whichever MPRIS player is playing and puts it one click + away: album art, the track title and artist, a scrubber, volume, and + synced lyrics, in a panel that opens from the system tray. + . + On GNOME a small shell extension, installed with this package, draws the + entry in the top bar itself so the title scrolls smoothly. Enable it with + "gnome-extensions enable reprise@junx.dev". diff --git a/packaging/linux/deb/postinst b/packaging/linux/deb/postinst new file mode 100755 index 0000000..7b2bc8a --- /dev/null +++ b/packaging/linux/deb/postinst @@ -0,0 +1,55 @@ +#!/bin/sh +# Refreshes the desktop caches and switches the shell extension on. +# +# Enabling a GNOME extension is per-user state, so this reaches into the +# graphical sessions that are running and asks as that user. Nobody may be +# logged in during an install, so Reprise also checks at startup; between +# the two the user never has to run a command themselves. +set -e + +# Turns the extension on for one logged-in user. +# +# $1 - the user's login name. +# $2 - the user's numeric id, used to find their session bus. +enable_for_user() { + _user="$1" + _uid="$2" + [ -S "/run/user/$_uid/bus" ] || return 0 + runuser -u "$_user" -- env \ + "DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/$_uid/bus" \ + gnome-extensions enable reprise@junx.dev >/dev/null 2>&1 || true +} + +if [ "$1" = "configure" ]; then + if command -v update-desktop-database >/dev/null 2>&1; then + update-desktop-database -q /usr/share/applications || true + fi + if command -v gtk-update-icon-cache >/dev/null 2>&1; then + gtk-update-icon-cache -q -t -f /usr/share/icons/hicolor || true + fi + + if command -v loginctl >/dev/null 2>&1 && command -v runuser >/dev/null 2>&1; then + loginctl list-sessions --no-legend 2>/dev/null | awk '{print $1}' | while read -r session; do + [ -n "$session" ] || continue + type=$(loginctl show-session "$session" -p Type --value 2>/dev/null || echo "") + case "$type" in + x11 | wayland) ;; + *) continue ;; + esac + user=$(loginctl show-session "$session" -p Name --value 2>/dev/null || echo "") + uid=$(loginctl show-session "$session" -p User --value 2>/dev/null || echo "") + [ -n "$user" ] && [ -n "$uid" ] && enable_for_user "$user" "$uid" + done + fi + + cat <<'MESSAGE' + +Reprise is installed. Start it from your applications menu, or run "reprise". + +The GNOME top bar entry is switched on for you; if the shell was already +running, log out and back in, or press Alt+F2 and type r on Xorg. + +MESSAGE +fi + +exit 0 diff --git a/packaging/linux/deb/prerm b/packaging/linux/deb/prerm new file mode 100755 index 0000000..188d0e6 --- /dev/null +++ b/packaging/linux/deb/prerm @@ -0,0 +1,9 @@ +#!/bin/sh +# Turns the shell extension off before its files go away. +set -e + +if [ "$1" = "remove" ] && command -v gnome-extensions >/dev/null 2>&1; then + gnome-extensions disable reprise@junx.dev 2>/dev/null || true +fi + +exit 0 diff --git a/packaging/linux/deb/reprise.desktop b/packaging/linux/deb/reprise.desktop new file mode 100644 index 0000000..b379eb4 --- /dev/null +++ b/packaging/linux/deb/reprise.desktop @@ -0,0 +1,11 @@ +[Desktop Entry] +Type=Application +Name=Reprise +Comment=Control MPRIS-compatible music players from one panel +Exec=/usr/bin/reprise +Icon=dev.junx.Reprise +Terminal=false +Categories=AudioVideo;Audio;Player; +Keywords=Music;Spotify;MPRIS;Player; +StartupNotify=true +StartupWMClass=reprise diff --git a/packaging/linux/dev.junx.Reprise.desktop b/packaging/linux/dev.junx.Reprise.desktop new file mode 100644 index 0000000..a65273a --- /dev/null +++ b/packaging/linux/dev.junx.Reprise.desktop @@ -0,0 +1,10 @@ +[Desktop Entry] +Type=Application +Name=Reprise +Comment=Control MPRIS-compatible music players from one window +Exec=reprise +Icon=multimedia-player +Terminal=false +Categories=AudioVideo;Audio;Player; +Keywords=Music;Spotify;MPRIS;Player; +StartupNotify=true diff --git a/packaging/linux/gnome-extension/extension.js b/packaging/linux/gnome-extension/extension.js new file mode 100644 index 0000000..d44949f --- /dev/null +++ b/packaging/linux/gnome-extension/extension.js @@ -0,0 +1,421 @@ +/** + * Reprise in the GNOME top bar. + * + * The tray protocol every desktop shares carries a label as a plain string, + * so a title there cannot move: the best it could do is swap in a different + * slice of the text, which reads as the letters changing rather than the + * words sliding past. GNOME lets an extension draw its own panel button, so + * this one does, and the title slides pixel by pixel the way the macOS menu + * bar does. Clicking the button asks Reprise to show its panel. + * + * While this extension runs it holds the bus name Reprise watches for, and + * Reprise withdraws its own tray entry, so the two never appear at once. + */ + +import Clutter from 'gi://Clutter'; +import GObject from 'gi://GObject'; +import GLib from 'gi://GLib'; +import Pango from 'gi://Pango'; +import Gio from 'gi://Gio'; +import St from 'gi://St'; + +import * as Main from 'resource:///org/gnome/shell/ui/main.js'; +import * as PanelMenu from 'resource:///org/gnome/shell/ui/panelMenu.js'; +import { Extension } from 'resource:///org/gnome/shell/extensions/extension.js'; + +/** Bus name Reprise publishes its top-bar content on. */ +const SERVICE = 'dev.junx.Reprise'; + +/** Object path carrying that content. */ +const OBJECT_PATH = '/dev/junx/Reprise'; + +/** Interface carrying that content. */ +const INTERFACE = 'dev.junx.Reprise.MenuBar'; + +/** Gap drawn between the end of a scrolling title and its repeat, in pixels. */ +const SCROLL_GAP = 28; + +/** How long a title rests before it starts scrolling, in milliseconds. */ +const INITIAL_PAUSE = 1400; + +/** Edge length of the album cover in the panel, in pixels. */ +const ICON_SIZE = 18; + +/** Bus name held while this extension is drawing Reprise's entry. */ +const SHELL_NAME = 'dev.junx.Reprise.Shell'; + +/** + * How far the text has slid at a moment of the scroll. + * + * The text and a repeat of it sit one distance apart, so sliding the pair + * left by exactly that distance puts the repeat where the original began: + * wrapping there is invisible, which is what makes the motion endless + * rather than a jump back to the start. The rest is constant speed, with a + * rest at each end that is spent parked on the repeat. + * + * @param {number} elapsed - Milliseconds since the text appeared. + * @param {number} distance - Width of the text plus the gap, in pixels. + * @param {number} pointsPerSecond - Scrolling speed. + * @param {number} [pause=INITIAL_PAUSE] - Rest before moving, in milliseconds. + * @returns {number} Pixels to slide left, from 0 to distance. + * + * @example + * scrollOffset(0, 100, 30); // 0, still resting + * scrollOffset(2400, 100, 30); // 30, one second of travel + */ +export function scrollOffset(elapsed, distance, pointsPerSecond, pause = INITIAL_PAUSE) { + if (distance <= 0 || pointsPerSecond <= 0) { + return 0; + } + + const moving = elapsed - pause; + if (moving <= 0) { + return 0; + } + + const travel = (distance / pointsPerSecond) * 1000; + const cycle = travel + pause; + const phase = moving % cycle; + return Math.min((phase / 1000) * pointsPerSecond, distance); +} + +/** + * A panel button showing the album cover and a scrolling title. + * + * The title lives in a fixed-width viewport that clips it, and a repeat of + * the same text sits one gap further along, so sliding the pair left by the + * distance between them and snapping back reads as an endless scroll. + */ +const RepriseButton = GObject.registerClass( +class RepriseButton extends PanelMenu.Button { + /** + * Builds the button with an empty label. + * + * @param {function(): void} onClick - Runs when the button is pressed. + */ + _init(onClick) { + super._init(0.0, 'Reprise', true); + this._onClick = onClick; + this._offset = 0; + this._textWidth = 0; + this._viewportWidth = 0; + this._scrolls = true; + this._pointsPerSecond = 30; + this._timeline = null; + this._startedAt = 0; + + this._icon = new St.Icon({ icon_size: ICON_SIZE, style_class: 'system-status-icon' }); + this._first = this._createLabel(); + this._second = this._createLabel(); + // The track keeps its natural width and sits at the start of the + // viewport: a child aligned to FILL would be squeezed into the + // viewport instead of overflowing it, leaving nothing to scroll. + this._track = new St.BoxLayout({ + x_align: Clutter.ActorAlign.START, + y_align: Clutter.ActorAlign.CENTER, + x_expand: false, + }); + this._track.add_child(this._first); + this._track.add_child(this._second); + + this._viewport = new St.Widget({ + layout_manager: new Clutter.BinLayout(), + clip_to_allocation: true, + y_align: Clutter.ActorAlign.CENTER, + x_expand: false, + }); + this._viewport.add_child(this._track); + + const box = new St.BoxLayout({ style_class: 'panel-status-menu-box' }); + box.add_child(this._icon); + box.add_child(this._viewport); + this.add_child(box); + this.connect('destroy', () => this._stopScrolling()); + } + + /** + * Creates one copy of the title label. + * + * Ellipsis is turned off explicitly: a label that is narrower than its + * text would otherwise end in three dots, and here the text is meant to + * run past the edge and be clipped. + * + * @returns {St.Label} A label that never shortens its text. + */ + _createLabel() { + const label = new St.Label({ + y_align: Clutter.ActorAlign.CENTER, + x_expand: false, + }); + label.clutter_text.ellipsize = Pango.EllipsizeMode.NONE; + label.clutter_text.single_line_mode = true; + return label; + } + + /** + * Passes a click on and keeps the shell from opening an empty menu. + * + * @param {Clutter.Event} event - The press. + * @returns {number} Whether the event was handled. + */ + vfunc_event(event) { + const type = event.type(); + if (type === Clutter.EventType.BUTTON_PRESS || type === Clutter.EventType.TOUCH_BEGIN) { + this._onClick(); + return Clutter.EVENT_STOP; + } + return Clutter.EVENT_PROPAGATE; + } + + /** + * Applies new content from Reprise. + * + * @param {object} state - Text, icon, and scrolling settings. + * @param {string} state.text - The full line to show. + * @param {Uint8Array} state.iconPng - Album cover, or an empty array. + * @param {boolean} state.scrolls - Whether long text should scroll. + * @param {number} state.pointsPerSecond - Scrolling speed. + * @param {number} state.maxWidthChars - Width to hold the text to, in + * characters of the text itself, which is what makes the setting mean + * the same for a Hangul line as for a Latin one. + * @returns {void} Nothing; the button redraws in place. + */ + update(state) { + this._scrolls = state.scrolls; + this._pointsPerSecond = Math.max(state.pointsPerSecond, 1); + this._setIcon(state.iconPng); + + const changed = this._first.text !== state.text; + if (changed) { + this._first.text = state.text; + this._second.text = state.text; + } + + this._viewport.visible = state.text.length > 0; + if (!this._viewport.visible) { + this._stopScrolling(); + return; + } + + this._textWidth = this._first.get_preferred_width(-1)[1]; + const characters = Math.max([...state.text].length, 1); + const perCharacter = this._textWidth / characters; + this._viewportWidth = Math.max( + Math.min(this._textWidth, Math.max(state.maxWidthChars, 1) * perCharacter), + 1); + this._viewport.set_width(this._viewportWidth); + this._viewport.set_height(-1); + this._second.visible = this._scrolls && this._textWidth > this._viewportWidth; + this._second.set_style(`margin-left: ${SCROLL_GAP}px;`); + + if (!this._second.visible) { + this._stopScrolling(); + this._track.set_translation(0, 0, 0); + return; + } + + if (changed || !this._timeline) { + this._startScrolling(); + } + } + + /** + * Sets the panel icon from PNG bytes, falling back to a note. + * + * @param {Uint8Array} png - Encoded cover, or an empty array. + * @returns {void} Nothing; an unreadable image falls back to a note icon. + */ + _setIcon(png) { + if (!png || png.length === 0) { + this._icon.gicon = null; + this._icon.icon_name = 'audio-x-generic-symbolic'; + return; + } + + try { + this._icon.gicon = Gio.BytesIcon.new(new GLib.Bytes(png)); + } catch (error) { + this._icon.gicon = null; + this._icon.icon_name = 'audio-x-generic-symbolic'; + } + } + + /** + * Starts the scroll, driven by a per-frame timeline. + * + * A timeline rather than an eased animation because the motion is + * constant speed with a pause at each end, which an easing curve cannot + * express, and because the frame callback keeps the two copies of the + * text exactly one gap apart at any width. + * + * @returns {void} Nothing; any previous scroll is stopped first. + */ + _startScrolling() { + this._stopScrolling(); + this._startedAt = GLib.get_monotonic_time() / 1000; + this._timeline = new Clutter.Timeline({ + actor: this, + duration: 1000, + repeat_count: -1, + }); + this._timeline.connect('new-frame', () => this._step()); + this._timeline.start(); + } + + /** + * Places the text for this frame. + * + * @returns {void} Nothing; the track actor is moved in place. + */ + _step() { + const elapsed = GLib.get_monotonic_time() / 1000 - this._startedAt; + const offset = scrollOffset( + elapsed, + this._textWidth + SCROLL_GAP, + this._pointsPerSecond); + this._track.set_translation(-offset, 0, 0); + } + + /** + * Stops the scroll and releases its timeline. + * + * @returns {void} Nothing; safe to call when nothing is scrolling. + */ + _stopScrolling() { + if (this._timeline) { + this._timeline.stop(); + this._timeline.run_dispose(); + this._timeline = null; + } + } +}); + +export default class RepriseExtension extends Extension { + /** + * Adds the panel button and starts following Reprise. + * + * @returns {void} Nothing; the button appears as soon as the shell draws. + */ + enable() { + this._button = new RepriseButton(() => this._activate()); + Main.panel.addToStatusArea('reprise', this._button, 0, 'right'); + + this._nameId = Gio.bus_own_name( + Gio.BusType.SESSION, + SHELL_NAME, + Gio.BusNameOwnerFlags.REPLACE, + null, + null, + null); + + this._proxy = null; + this._watchId = Gio.bus_watch_name( + Gio.BusType.SESSION, + SERVICE, + Gio.BusNameWatcherFlags.NONE, + () => this._connect(), + () => this._disconnect()); + } + + /** + * Removes the panel button and stops following Reprise. + * + * @returns {void} Nothing; every watch and proxy is released. + */ + disable() { + if (this._nameId) { + Gio.bus_unown_name(this._nameId); + this._nameId = null; + } + + if (this._watchId) { + Gio.bus_unwatch_name(this._watchId); + this._watchId = null; + } + + this._disconnect(); + this._button?.destroy(); + this._button = null; + } + + /** + * Opens a proxy to Reprise and reads its current content. + * + * @returns {void} Nothing; the proxy arrives through a callback. + */ + _connect() { + Gio.DBusProxy.new_for_bus( + Gio.BusType.SESSION, + Gio.DBusProxyFlags.NONE, + null, + SERVICE, + OBJECT_PATH, + INTERFACE, + null, + (_source, result) => { + try { + this._proxy = Gio.DBusProxy.new_for_bus_finish(result); + } catch (error) { + logError(error, 'Reprise: cannot reach the player'); + return; + } + + this._changedId = this._proxy.connect('g-properties-changed', () => this._refresh()); + this._refresh(); + }); + } + + /** + * Drops the proxy and empties the button. + * + * @returns {void} Nothing; the button is left showing no track. + */ + _disconnect() { + if (this._proxy && this._changedId) { + this._proxy.disconnect(this._changedId); + } + + this._changedId = null; + this._proxy = null; + this._button?.update({ + text: '', + iconPng: null, + scrolls: false, + pointsPerSecond: 30, + maxWidthChars: 20, + }); + } + + /** + * Reads the current content and hands it to the button. + * + * @returns {void} Nothing; cached properties are read, so it never blocks. + */ + _refresh() { + if (!this._proxy || !this._button) { + return; + } + + const read = (name, fallback) => { + const value = this._proxy.get_cached_property(name); + return value === null ? fallback : value.deepUnpack(); + }; + + this._button.update({ + text: read('Text', ''), + iconPng: read('IconPng', null), + scrolls: read('ScrollsText', true), + pointsPerSecond: read('PointsPerSecond', 30), + maxWidthChars: read('MaxWidthChars', 20), + }); + } + + /** + * Asks Reprise to show its panel. + * + * @returns {void} Nothing; the call is sent without waiting for a reply. + */ + _activate() { + this._proxy?.call('Activate', null, Gio.DBusCallFlags.NONE, -1, null, null); + } +} diff --git a/packaging/linux/gnome-extension/metadata.json b/packaging/linux/gnome-extension/metadata.json new file mode 100644 index 0000000..6291795 --- /dev/null +++ b/packaging/linux/gnome-extension/metadata.json @@ -0,0 +1,8 @@ +{ + "uuid": "reprise@junx.dev", + "name": "Reprise", + "description": "Shows the track Reprise is following in the top bar, with the title scrolling smoothly, and opens the Reprise panel when clicked.", + "shell-version": ["45", "46", "47", "48", "49"], + "url": "https://github.com/aodjo/Reprise", + "version": 1 +} diff --git a/packaging/linux/install-gnome-extension.sh b/packaging/linux/install-gnome-extension.sh new file mode 100755 index 0000000..02343ef --- /dev/null +++ b/packaging/linux/install-gnome-extension.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +# Installs and switches on the Reprise GNOME Shell extension. +# +# Only needed for the archive build; the Debian package does this itself, +# and so does Reprise when it starts. The extension draws Reprise's entry in +# the top bar, which lets the track title scroll smoothly rather than +# stepping a character at a time as a plain tray label must. +set -euo pipefail + +uuid="reprise@junx.dev" +source_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/gnome-extension" +target_dir="${XDG_DATA_HOME:-$HOME/.local/share}/gnome-shell/extensions/$uuid" + +if [[ ! -d "$source_dir" ]]; then + echo "Cannot find the extension next to this script." >&2 + exit 1 +fi + +mkdir -p "$target_dir" +cp "$source_dir"/metadata.json "$source_dir"/extension.js "$target_dir/" +echo "Installed to $target_dir" + +if command -v gnome-extensions >/dev/null 2>&1 && gnome-extensions enable "$uuid" 2>/dev/null; then + echo "Enabled." + exit 0 +fi + +echo "The shell has not seen the extension yet." +echo "Log out and back in, or press Alt+F2 and type r on Xorg, then start Reprise." diff --git a/packaging/linux/install.sh b/packaging/linux/install.sh new file mode 100755 index 0000000..63265fa --- /dev/null +++ b/packaging/linux/install.sh @@ -0,0 +1,31 @@ +#!/usr/bin/env bash +# Installs the Reprise package that matches this machine. +# +# The release carries one package per architecture, and apt refuses the +# wrong one with a wall of unsatisfiable dependencies rather than a plain +# "wrong architecture", so pick the right file here instead of leaving it +# to the reader. +set -euo pipefail + +here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +if ! command -v dpkg >/dev/null 2>&1; then + echo "This installer is for Debian and Ubuntu. Use the .tar.gz archive instead." >&2 + exit 1 +fi + +architecture="$(dpkg --print-architecture)" +package="$(ls "$here"/reprise_*_"$architecture".deb 2>/dev/null | sort | tail -1 || true)" + +if [[ -z "$package" ]]; then + echo "No package here for $architecture. Available:" >&2 + ls "$here"/reprise_*.deb 2>/dev/null >&2 || echo " (none)" >&2 + exit 1 +fi + +echo "Installing $(basename "$package") for $architecture." +if [[ "$(id -u)" -eq 0 ]]; then + apt-get install -y "$package" +else + sudo apt-get install -y "$package" +fi diff --git a/src/README.md b/src/README.md index 14d6d5b..69401e1 100644 --- a/src/README.md +++ b/src/README.md @@ -2,6 +2,6 @@ This directory contains the shared cross-platform application and its operating-system implementations. The existing native Xcode application lives -under `Reprise.Platform.MacOS` while the shared core and desktop projects are -being introduced. See [the migration document](../docs/CROSS_PLATFORM.md) for -ownership boundaries. +under `Reprise.Platform.MacOS`. The shared .NET core, Avalonia desktop UI, and +the first MPRIS-based Linux host now live alongside it. See +[the migration document](../docs/CROSS_PLATFORM.md) for ownership boundaries. diff --git a/src/Reprise.Core/.gitkeep b/src/Reprise.Core/.gitkeep deleted file mode 100644 index 8b13789..0000000 --- a/src/Reprise.Core/.gitkeep +++ /dev/null @@ -1 +0,0 @@ - diff --git a/src/Reprise.Core/ActiveSessionSelector.cs b/src/Reprise.Core/ActiveSessionSelector.cs new file mode 100644 index 0000000..7f4491b --- /dev/null +++ b/src/Reprise.Core/ActiveSessionSelector.cs @@ -0,0 +1,89 @@ +namespace Reprise.Core; + +/// +/// Picks the one session Reprise should display when several players are open. +/// +/// +/// A desktop routinely has more than one MPRIS player on the bus at once - a +/// music app, a browser tab, a video call - and only one of them belongs in +/// the panel. This type holds that choice in a single place so every platform +/// backend resolves it the same way. +/// +public static class ActiveSessionSelector +{ + /// + /// Chooses the session that best represents what the user is listening to. + /// + /// + /// Candidates are ranked on four keys, applied in order: playback status + /// first, so anything actually playing outranks anything idle; then the + /// caller's preference list; then how recently the session was observed; + /// and finally the player id, so the result stays stable across polls + /// instead of flickering between two otherwise identical players. + /// + /// + /// Sessions reported by the platform backend. May be empty; must not be + /// null. + /// + /// + /// Player ids in descending priority, consulted only to break a tie + /// between sessions sharing the same playback status. Ids not in the list + /// rank last, and a repeated id keeps its first, highest position. + /// Defaults to null, meaning no preference. + /// + /// + /// The winning session, or null when the sequence is empty. + /// + /// + /// Thrown when is null. + /// + /// + /// + /// // A paused Spotify beats a paused Firefox. + /// var active = ActiveSessionSelector.Select(sessions, ["spotify", "firefox"]); + /// + /// + public static MediaSessionSnapshot? Select( + IEnumerable sessions, + IReadOnlyList? preferredPlayerIds = null) + { + ArgumentNullException.ThrowIfNull(sessions); + + var priorities = new Dictionary( + StringComparer.OrdinalIgnoreCase); + if (preferredPlayerIds is not null) + { + for (var index = 0; index < preferredPlayerIds.Count; index++) + { + priorities.TryAdd(preferredPlayerIds[index], index); + } + } + + return sessions + .OrderByDescending(session => StatusRank(session.Status)) + .ThenBy(session => priorities.GetValueOrDefault( + session.PlayerId, + int.MaxValue)) + .ThenByDescending(session => session.ObservedAt) + .ThenBy(session => session.PlayerId, StringComparer.OrdinalIgnoreCase) + .FirstOrDefault(); + } + + /// + /// Scores a playback status by how strong a claim it has on the panel. + /// + /// + /// Kept separate from the enum's own numeric values so the display order + /// can be tuned without silently changing the meaning of a serialised + /// . + /// + /// Status to score. + /// A rank from 0 to 3, where a higher number wins. + private static int StatusRank(PlaybackStatus status) => status switch + { + PlaybackStatus.Playing => 3, + PlaybackStatus.Paused => 2, + PlaybackStatus.Stopped => 1, + _ => 0, + }; +} diff --git a/src/Reprise.Core/AssemblyInfo.cs b/src/Reprise.Core/AssemblyInfo.cs new file mode 100644 index 0000000..e1cd68a --- /dev/null +++ b/src/Reprise.Core/AssemblyInfo.cs @@ -0,0 +1,6 @@ +using System.Runtime.CompilerServices; + +// The lyrics service keeps its parsers and candidate scoring internal, since +// they expose the shape of third-party responses rather than anything the +// desktop layer needs. Those are exactly the pieces worth pinning with tests. +[assembly: InternalsVisibleTo("Reprise.Core.Tests")] diff --git a/src/Reprise.Core/IMediaSessionService.cs b/src/Reprise.Core/IMediaSessionService.cs new file mode 100644 index 0000000..6710e28 --- /dev/null +++ b/src/Reprise.Core/IMediaSessionService.cs @@ -0,0 +1,161 @@ +namespace Reprise.Core; + +/// +/// Platform-specific gateway to the media players running on this machine. +/// +/// +/// One implementation exists per platform - MPRIS over D-Bus on Linux, and +/// the native player APIs elsewhere - and each hides its transport entirely +/// behind these few calls. The desktop layer depends only on this interface, +/// which is also what lets the UI be exercised against a stub in tests. +/// +public interface IMediaSessionService +{ + /// + /// Reads the current state of every media player the platform exposes. + /// + /// + /// Each call is a fresh sample rather than a cached or pushed value, so + /// callers poll at whatever rate suits them. An absent player is not an + /// error condition: a machine with nothing playing yields an empty list. + /// + /// + /// Cancels the in-flight query. Defaults to default. + /// + /// + /// Sessions found at the moment of the call, in an order the + /// implementation defines. Empty when no player is available. + /// + /// + /// Thrown when is cancelled. + /// + /// + /// + /// var sessions = await service.GetSessionsAsync(); + /// var active = ActiveSessionSelector.Select(sessions); + /// + /// + Task> GetSessionsAsync( + CancellationToken cancellationToken = default); + + /// + /// Sends a transport command to one specific player. + /// + /// + /// The command targets a player by id rather than acting on whichever one + /// happens to be active, so a control press cannot land on a different + /// player than the one the user was looking at. Completion means the + /// player accepted the command, not that its state has already changed; + /// callers that need the new state should re-read the sessions afterwards. + /// + /// + /// of the target player. + /// + /// Transport control to invoke. + /// + /// Cancels the in-flight command. Defaults to default. + /// + /// A task that completes once the player accepted the command. + /// + /// Thrown when is null, empty, or whitespace. + /// + /// + /// Thrown when the player rejects the command or is no longer reachable. + /// + /// + /// Thrown when is cancelled. + /// + /// + /// + /// await service.SendCommandAsync(active.PlayerId, PlaybackCommand.Next); + /// + /// + Task SendCommandAsync( + string playerId, + PlaybackCommand command, + CancellationToken cancellationToken = default); + + /// + /// Moves playback of one player to an absolute position in its current + /// track. + /// + /// + /// Positions are absolute rather than relative so the panel can send the + /// value the user released the scrubber at, without depending on a + /// possibly stale idea of where playback currently is. Completion means + /// the player accepted the request; the new position becomes visible on + /// a later read. + /// + /// + /// of the target player. + /// + /// + /// Offset from the start of the track. Values past the end are clamped + /// by the player. + /// + /// + /// Cancels the in-flight request. Defaults to default. + /// + /// A task that completes once the player accepted the seek. + /// + /// Thrown when is null, empty, or whitespace. + /// + /// + /// Thrown when is negative. + /// + /// + /// Thrown when the player cannot seek, rejects the request, or is no + /// longer reachable. + /// + /// + /// Thrown when is cancelled. + /// + /// + /// + /// await service.SeekAsync(active.PlayerId, TimeSpan.FromSeconds(90)); + /// + /// + Task SeekAsync( + string playerId, + TimeSpan position, + CancellationToken cancellationToken = default); + + /// + /// Sets the output volume of one player. + /// + /// + /// The scale is normalised so the desktop layer never has to know whether + /// a platform counts in percent, decibels, or fractions. Implementations + /// clamp out-of-range values rather than reject them, since a slider can + /// overshoot by rounding. + /// + /// + /// of the target player. + /// + /// Level from 0, silent, to 1, full volume. + /// + /// Cancels the in-flight request. Defaults to default. + /// + /// A task that completes once the player accepted the level. + /// + /// Thrown when is null, empty, or whitespace. + /// + /// + /// Thrown when is not a number. + /// + /// + /// Thrown when the player rejects the request or is no longer reachable. + /// + /// + /// Thrown when is cancelled. + /// + /// + /// + /// await service.SetVolumeAsync(active.PlayerId, 0.5); + /// + /// + Task SetVolumeAsync( + string playerId, + double volume, + CancellationToken cancellationToken = default); +} diff --git a/src/Reprise.Core/LrcParser.cs b/src/Reprise.Core/LrcParser.cs new file mode 100644 index 0000000..5c4c93c --- /dev/null +++ b/src/Reprise.Core/LrcParser.cs @@ -0,0 +1,98 @@ +using System.Globalization; +using System.Text.RegularExpressions; + +namespace Reprise.Core; + +/// +/// Reads LRC-format lyrics into timed lines. +/// +/// +/// LRC is loosely specified: a line may carry several timestamps, metadata +/// tags sit beside lyric lines, and an [offset:] tag shifts every +/// time. This parser accepts all of that and produces one line per +/// timestamp, sorted, with each line ending where the next begins. +/// +public static partial class LrcParser +{ + /// + /// Parses LRC text. + /// + /// Lyrics in LRC format. + /// + /// Track length, used as the end of the last line. Zero leaves the last + /// line open-ended. + /// + /// Timed lines in ascending order; empty when nothing parsed. + /// + /// + /// var lines = LrcParser.Parse("[00:12.00]Hello\n[00:15.50]World", TimeSpan.FromSeconds(20)); + /// // 12.0s "Hello" until 15.5s, 15.5s "World" until 20s + /// + /// + public static IReadOnlyList Parse(string lrc, TimeSpan duration) + { + ArgumentNullException.ThrowIfNull(lrc); + + var offset = TimeSpan.Zero; + var timed = new List<(TimeSpan Start, string Text)>(); + foreach (var rawLine in lrc.Split('\n')) + { + var line = rawLine.Trim(); + if (line.StartsWith("[offset:", StringComparison.OrdinalIgnoreCase)) + { + var closing = line.IndexOf(']', StringComparison.Ordinal); + if (closing > 8 + && double.TryParse(line[8..closing], NumberStyles.Float, CultureInfo.InvariantCulture, out var milliseconds)) + { + offset = TimeSpan.FromMilliseconds(milliseconds); + } + + continue; + } + + var matches = Timestamp().Matches(line); + if (matches.Count == 0) + { + continue; + } + + var text = Timestamp().Replace(line, string.Empty).Trim(); + if (text.Length == 0) + { + continue; + } + + foreach (Match match in matches) + { + var minutes = double.Parse(match.Groups[1].Value, CultureInfo.InvariantCulture); + var seconds = double.Parse(match.Groups[2].Value, CultureInfo.InvariantCulture); + var start = TimeSpan.FromSeconds(minutes * 60 + seconds) + offset; + timed.Add((start < TimeSpan.Zero ? TimeSpan.Zero : start, text)); + } + } + + timed.Sort((left, right) => + { + var byTime = left.Start.CompareTo(right.Start); + return byTime != 0 ? byTime : string.CompareOrdinal(left.Text, right.Text); + }); + + var lines = new List(timed.Count); + for (var index = 0; index < timed.Count; index++) + { + TimeSpan? end = index + 1 < timed.Count + ? timed[index + 1].Start + : duration > timed[index].Start ? duration : null; + lines.Add(new LyricLine(timed[index].Start, end, timed[index].Text)); + } + + return lines; + } + + /// + /// Matches one [mm:ss.xx] timestamp. + /// + /// The compiled pattern. + [GeneratedRegex(@"\[(\d{1,3}):(\d{2}(?:\.\d{1,3})?)\]")] + private static partial Regex Timestamp(); +} diff --git a/src/Reprise.Core/LyricsService.cs b/src/Reprise.Core/LyricsService.cs new file mode 100644 index 0000000..fa5714b --- /dev/null +++ b/src/Reprise.Core/LyricsService.cs @@ -0,0 +1,543 @@ +using System.Globalization; +using System.Net.Http.Headers; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Xml; +using System.Xml.Linq; + +namespace Reprise.Core; + +/// +/// Looks up time-synced lyrics for a track. +/// +public interface ILyricsService +{ + /// + /// Fetches synced lyrics for a track, if any service has them. + /// + /// Track to look up. + /// Cancels the lookup. + /// The lyrics, or null when none are available. + /// + /// Thrown when is cancelled. + /// + Task FetchSyncedLyricsAsync( + LyricsTrackQuery query, + CancellationToken cancellationToken = default); +} + +/// +/// Fetches time-synced lyrics from VIBE and LRCLIB. +/// +/// +/// A port of the macOS LyricsService. VIBE is tried first because it +/// carries line end times, which LRC cannot express, and covers Korean +/// releases well; LRCLIB is the broader fallback. Every network or parse +/// failure resolves to "no lyrics" rather than an error, since lyrics are +/// an extra and the panel must not report a missing verse as a fault. +/// +public sealed class LyricsService : ILyricsService +{ + private static readonly Uri VibeBaseUri = new("https://apis.naver.com/vibeWeb/musicapiweb/"); + private static readonly Uri LrclibBaseUri = new("https://lrclib.net/api/"); + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNameCaseInsensitive = true, + }; + + private readonly HttpClient _http; + + /// + /// Creates a service that talks to the real endpoints. + /// + public LyricsService() + : this(new HttpClientHandler()) + { + } + + /// + /// Creates a service over a supplied HTTP stack, for tests. + /// + /// Handler every request goes through. + /// + /// + /// var service = new LyricsService(new FakeHandler()); + /// + /// + public LyricsService(HttpMessageHandler handler) + { + ArgumentNullException.ThrowIfNull(handler); + _http = new HttpClient(handler) + { + Timeout = TimeSpan.FromSeconds(10), + }; + _http.DefaultRequestHeaders.UserAgent.Add(new ProductInfoHeaderValue("Reprise", "2.0")); + _http.DefaultRequestHeaders.UserAgent.Add(new ProductInfoHeaderValue("(https://junx.dev)")); + } + + /// + /// Fetches synced lyrics, preferring VIBE and falling back to LRCLIB. + /// + /// Track to look up. + /// Cancels the lookup. + /// The lyrics, or null when neither service has them. + /// + /// Thrown when is cancelled. + /// + /// + /// + /// var lyrics = await service.FetchSyncedLyricsAsync(LyricsTrackQuery.From(session)!); + /// + /// + public async Task FetchSyncedLyricsAsync( + LyricsTrackQuery query, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(query); + + if (await FetchVibeAsync(query, cancellationToken) is { } vibe) + { + return vibe; + } + + cancellationToken.ThrowIfCancellationRequested(); + return await FetchLrclibAsync(query, cancellationToken); + } + + /// + /// Searches VIBE for the track and reads its synced lyrics. + /// + /// Track to look up. + /// Cancels the lookup. + /// The lyrics, or null on any miss or failure. + private async Task FetchVibeAsync( + LyricsTrackQuery query, + CancellationToken cancellationToken) + { + try + { + var searchXml = await GetStringAsync( + BuildUri(VibeBaseUri, "v3/search/track", new[] + { + ("query", $"{query.Title} {query.Artist}".Trim()), + ("display", "10"), + ("sort", "RELEVANCE"), + }), + cancellationToken); + var candidate = BestVibeCandidate(query, ParseVibeSearch(searchXml)); + if (candidate is null) + { + return null; + } + + var lyricXml = await GetStringAsync( + BuildUri(VibeBaseUri, $"vibe/v4/lyric/{Uri.EscapeDataString(candidate.TrackId)}", []), + cancellationToken); + return ParseVibeLyrics(lyricXml); + } + catch (Exception exception) when (IsRecoverable(exception, cancellationToken)) + { + return null; + } + } + + /// + /// Reads lyrics from LRCLIB, by exact match first and then by search. + /// + /// Track to look up. + /// Cancels the lookup. + /// The lyrics, or null on any miss or failure. + private async Task FetchLrclibAsync( + LyricsTrackQuery query, + CancellationToken cancellationToken) + { + try + { + var json = await GetStringAsync( + BuildUri(LrclibBaseUri, "get", LrclibQuery(query)), + cancellationToken); + var result = JsonSerializer.Deserialize(json, JsonOptions); + if (result is not null && FromLrclib(result, query.Duration) is { } lyrics) + { + return lyrics; + } + } + catch (Exception exception) when (IsRecoverable(exception, cancellationToken)) + { + } + + cancellationToken.ThrowIfCancellationRequested(); + + try + { + var json = await GetStringAsync( + BuildUri(LrclibBaseUri, "search", new[] + { + ("track_name", query.Title), + ("artist_name", query.Artist), + }), + cancellationToken); + var results = JsonSerializer.Deserialize>(json, JsonOptions) ?? []; + return BestLrclibResult(query, results) is { } best + ? FromLrclib(best, query.Duration) + : null; + } + catch (Exception exception) when (IsRecoverable(exception, cancellationToken)) + { + return null; + } + } + + /// + /// Performs one GET and returns the body. + /// + /// Address to fetch. + /// Cancels the request. + /// The response body. + /// Thrown on a non-success status. + private async Task GetStringAsync(Uri uri, CancellationToken cancellationToken) + { + using var response = await _http.GetAsync(uri, cancellationToken); + response.EnsureSuccessStatusCode(); + return await response.Content.ReadAsStringAsync(cancellationToken); + } + + /// + /// Decides whether a failure means "no lyrics" rather than a bug. + /// + /// Failure to classify. + /// Token the caller passed in. + /// True for network, timeout, and parse failures. + private static bool IsRecoverable(Exception exception, CancellationToken cancellationToken) => + !cancellationToken.IsCancellationRequested + && exception is HttpRequestException + or TaskCanceledException + or XmlException + or JsonException + or FormatException + or InvalidOperationException; + + /// + /// Builds an address with percent-encoded query parameters. + /// + /// Service root, ending in a slash. + /// Path below the root. + /// Query parameters in order. + /// The complete address. + internal static Uri BuildUri(Uri baseUri, string path, IEnumerable<(string Name, string Value)> parameters) + { + var query = string.Join( + "&", + parameters.Select(p => $"{Uri.EscapeDataString(p.Name)}={Uri.EscapeDataString(p.Value)}")); + return new Uri(baseUri, query.Length == 0 ? path : $"{path}?{query}"); + } + + /// + /// Parameters for LRCLIB's exact-match endpoint. + /// + /// Track to look up. + /// Title and artist, plus album and duration when known. + internal static IReadOnlyList<(string Name, string Value)> LrclibQuery(LyricsTrackQuery query) + { + var items = new List<(string, string)> + { + ("track_name", query.Title), + ("artist_name", query.Artist), + }; + if (query.Album.Length > 0) + { + items.Add(("album_name", query.Album)); + } + + if (query.Duration > TimeSpan.Zero) + { + items.Add(("duration", Math.Round(query.Duration.TotalSeconds).ToString(CultureInfo.InvariantCulture))); + } + + return items; + } + + /// + /// A track from a VIBE search reply. + /// + /// VIBE's identifier, used to fetch lyrics. + /// Track title. + /// Album name, possibly empty. + /// Credited artists. + /// Track length, or zero when unparsable. + /// Whether VIBE holds timed lyrics for it. + /// Whether VIBE flags it as adult-only. + internal sealed record VibeCandidate( + string TrackId, + string Title, + string Album, + IReadOnlyList Artists, + TimeSpan Duration, + bool HasSyncedLyrics, + bool IsAdult); + + /// + /// Reads the tracks out of a VIBE search reply. + /// + /// Reply body. + /// Every track that carries an id and a title. + /// Thrown when the body is not XML. + internal static IReadOnlyList ParseVibeSearch(string xml) + { + var document = XDocument.Parse(xml); + var candidates = new List(); + foreach (var track in document.Descendants("tracks")) + { + var trackId = Text(track.Element("trackId")); + var title = Text(track.Element("trackTitle")); + if (trackId is null || title is null) + { + continue; + } + + candidates.Add(new VibeCandidate( + trackId, + title, + Text(track.Element("album")?.Element("albumTitle")) ?? string.Empty, + track.Element("artists")?.Descendants("artistName") + .Select(Text).OfType().ToList() ?? [], + ParseClock(Text(track.Element("playTime")) ?? string.Empty), + Text(track.Element("hasSyncLyric")) == "true", + Text(track.Element("isAdult")) == "true")); + } + + return candidates; + } + + /// + /// Picks the VIBE track most likely to be the one playing. + /// + /// + /// Only tracks with synced lyrics qualify, and the title must match + /// loosely. Artist, album, and duration then vote: at least one must + /// agree, and the more that agree the higher the score. + /// + /// Track being played. + /// Search results. + /// The best candidate, or null when none is plausible. + internal static VibeCandidate? BestVibeCandidate( + LyricsTrackQuery query, + IReadOnlyList candidates) + { + VibeCandidate? best = null; + var bestScore = int.MinValue; + foreach (var candidate in candidates) + { + if (!candidate.HasSyncedLyrics || candidate.IsAdult) + { + continue; + } + + var title = LyricsTrackQuery.Normalize(candidate.Title); + if (title.Length == 0 || query.NormalizedTitle.Length == 0 || !Overlaps(title, query.NormalizedTitle)) + { + continue; + } + + var artistMatches = query.NormalizedArtist.Length == 0 + || candidate.Artists.Any(artist => Overlaps(LyricsTrackQuery.Normalize(artist), query.NormalizedArtist)); + var albumMatches = query.NormalizedAlbum.Length > 0 + && LyricsTrackQuery.Normalize(candidate.Album) == query.NormalizedAlbum; + var durationDifference = query.Duration > TimeSpan.Zero && candidate.Duration > TimeSpan.Zero + ? (query.Duration - candidate.Duration).Duration().TotalSeconds + : double.PositiveInfinity; + var durationMatches = durationDifference <= 6; + if (!artistMatches && !albumMatches && !durationMatches) + { + continue; + } + + var score = title == query.NormalizedTitle ? 100 : 70; + score += artistMatches ? 50 : 0; + score += albumMatches ? 20 : 0; + score += durationMatches ? 25 : durationDifference <= 12 ? 8 : 0; + if (score > bestScore) + { + bestScore = score; + best = candidate; + } + } + + return best; + } + + /// + /// Reads timed lines out of a VIBE lyric reply. + /// + /// Reply body. + /// The lyrics, or null when the track has no synced lyrics. + /// Thrown when the body is not XML. + internal static SyncedLyrics? ParseVibeLyrics(string xml) + { + var document = XDocument.Parse(xml); + if (Text(document.Descendants("hasSyncLyric").FirstOrDefault()) != "true" + || document.Descendants("syncLyric").FirstOrDefault() is not { } sync) + { + return null; + } + + var starts = sync.Element("startTimeIndex")?.Elements("startTimeIndex") + .Select(ParseSeconds).OfType().ToList() ?? []; + var ends = sync.Element("endTimeIndex")?.Elements("endTimeIndex") + .Select(ParseSeconds).OfType().ToList() ?? []; + var contents = sync.Element("contents")?.Elements("contents").ToList() ?? []; + var content = contents.FirstOrDefault(c => Text(c.Element("languageType")) == "default") + ?? contents.FirstOrDefault(); + var texts = content?.Element("text")?.Elements("text") + .Select(e => e.Value.Trim()).ToList() ?? []; + if (starts.Count == 0 || starts.Count != texts.Count) + { + return null; + } + + var lines = new List(starts.Count); + for (var index = 0; index < starts.Count; index++) + { + if (texts[index].Length == 0) + { + continue; + } + + double? end = index < ends.Count + ? ends[index] + : index + 1 < starts.Count ? starts[index + 1] : null; + lines.Add(new LyricLine( + TimeSpan.FromSeconds(starts[index]), + end is { } seconds ? TimeSpan.FromSeconds(seconds) : null, + texts[index])); + } + + return lines.Count == 0 ? null : new SyncedLyrics(LyricsSource.Vibe, lines); + } + + /// + /// One track from LRCLIB. + /// + /// Track title. + /// Artist credit. + /// Album name, possibly absent. + /// Track length in seconds. + /// LRC text, or null when only plain lyrics exist. + internal sealed record LrclibResult( + [property: JsonPropertyName("trackName")] string TrackName, + [property: JsonPropertyName("artistName")] string ArtistName, + [property: JsonPropertyName("albumName")] string? AlbumName, + [property: JsonPropertyName("duration")] double Duration, + [property: JsonPropertyName("syncedLyrics")] string? SyncedLyrics); + + /// + /// Picks the LRCLIB search result closest in length to the track. + /// + /// Track being played. + /// Search results. + /// The best result, or null when none has synced lyrics and matches. + internal static LrclibResult? BestLrclibResult( + LyricsTrackQuery query, + IReadOnlyList results) + { + LrclibResult? best = null; + var bestDifference = double.PositiveInfinity; + foreach (var result in results) + { + if (string.IsNullOrEmpty(result.SyncedLyrics)) + { + continue; + } + + var title = LyricsTrackQuery.Normalize(result.TrackName); + var artist = LyricsTrackQuery.Normalize(result.ArtistName); + var titleMatches = Overlaps(title, query.NormalizedTitle); + var artistMatches = query.NormalizedArtist.Length == 0 || Overlaps(artist, query.NormalizedArtist); + if (!titleMatches || !artistMatches) + { + continue; + } + + var difference = Math.Abs(result.Duration - query.Duration.TotalSeconds); + if (difference < bestDifference) + { + bestDifference = difference; + best = result; + } + } + + return best; + } + + /// + /// Turns an LRCLIB result into lyrics. + /// + /// Result to convert. + /// + /// Length of the playing track, used to close the last line; the + /// result's own length is used when unknown. + /// + /// The lyrics, or null when the result carries no timed lines. + internal static SyncedLyrics? FromLrclib(LrclibResult result, TimeSpan duration) + { + if (string.IsNullOrEmpty(result.SyncedLyrics)) + { + return null; + } + + var lines = LrcParser.Parse( + result.SyncedLyrics, + duration > TimeSpan.Zero ? duration : TimeSpan.FromSeconds(result.Duration)); + return lines.Count == 0 ? null : new SyncedLyrics(LyricsSource.Lrclib, lines); + } + + /// + /// Whether two normalised names are the same or one contains the other. + /// + /// One name. + /// The other name. + /// True when they plausibly refer to the same thing. + private static bool Overlaps(string left, string right) => + left.Length > 0 + && right.Length > 0 + && (left == right || left.Contains(right, StringComparison.Ordinal) || right.Contains(left, StringComparison.Ordinal)); + + /// + /// Trimmed element text, or null when the element is absent or blank. + /// + /// Element to read. + /// The text, or null. + private static string? Text(XElement? element) + { + var value = element?.Value.Trim(); + return string.IsNullOrEmpty(value) ? null : value; + } + + /// + /// Parses an element holding a number of seconds. + /// + /// Element to read. + /// The value, or null when not numeric. + private static double? ParseSeconds(XElement element) => + double.TryParse(element.Value.Trim(), NumberStyles.Float, CultureInfo.InvariantCulture, out var value) + ? value + : null; + + /// + /// Parses a m:ss clock into a duration. + /// + /// Clock text. + /// The duration, or zero when malformed. + private static TimeSpan ParseClock(string value) + { + var parts = value.Split(':'); + if (parts.Length != 2 + || !double.TryParse(parts[0], NumberStyles.Float, CultureInfo.InvariantCulture, out var minutes) + || !double.TryParse(parts[1], NumberStyles.Float, CultureInfo.InvariantCulture, out var seconds)) + { + return TimeSpan.Zero; + } + + return TimeSpan.FromSeconds(minutes * 60 + seconds); + } +} diff --git a/src/Reprise.Core/MediaSessionSnapshot.cs b/src/Reprise.Core/MediaSessionSnapshot.cs new file mode 100644 index 0000000..d4d8d96 --- /dev/null +++ b/src/Reprise.Core/MediaSessionSnapshot.cs @@ -0,0 +1,88 @@ +namespace Reprise.Core; + +/// +/// Immutable view of a single media player's state at one moment in time. +/// +/// +/// Platform backends translate whatever their native API exposes into this +/// shape, so the desktop layer never has to know whether a track arrived over +/// MPRIS, AppleScript, or a browser extension. Values a backend cannot supply +/// are left null instead of being defaulted, which keeps "no position was +/// reported" distinguishable from "playing from the very start". Text fields +/// use an empty string for the same reason they are not nullable: they are +/// bound straight to the UI, where absent and blank render identically. +/// +/// +/// Backend-scoped identifier used to address the player when sending +/// commands, for example spotify. +/// +/// +/// Display name for the player, for example Spotify. +/// +/// Whether the player is playing, paused, or stopped. +/// Track title, or an empty string when unavailable. +/// +/// Artist credit, already joined when there are several. +/// +/// Album name, or an empty string when unavailable. +/// Total track length, or null when not reported. +/// Elapsed playback time, or null when not reported. +/// Player volume from 0 to 1, or null when not reported. +/// +/// Absolute URI of the cover art, or null when absent. +/// +/// +/// When the backend sampled this state. Used to prefer the freshest player +/// when several are ranked equally. +/// +/// +/// +/// var sessions = await service.GetSessionsAsync(); +/// Console.WriteLine($"{sessions[0].Title} - {sessions[0].Artist}"); +/// +/// +public sealed record MediaSessionSnapshot( + string PlayerId, + string PlayerName, + PlaybackStatus Status, + string Title, + string Artist, + string Album, + TimeSpan? Duration, + TimeSpan? Position, + double? Volume, + Uri? ArtworkUri, + DateTimeOffset ObservedAt) +{ + /// + /// Fraction of the track that has already played, as a progress-bar value. + /// + /// + /// Collapses the nullable position and duration pair into a single number + /// the UI can bind without null checks. A track with no reported duration + /// cannot show meaningful progress, so it reads as 0 rather than throwing + /// or dividing by zero. The result is clamped because some players briefly + /// report a position past the end of a track while advancing to the next. + /// + /// + /// Progress from 0 to 1 inclusive, or 0 when either the position or the + /// duration is unavailable. + /// + /// + /// + /// progressBar.Value = snapshot.Progress; // 0.42 at 42% through the track + /// + /// + public double Progress + { + get + { + if (Duration is not { Ticks: > 0 } duration || Position is null) + { + return 0; + } + + return Math.Clamp(Position.Value.TotalSeconds / duration.TotalSeconds, 0, 1); + } + } +} diff --git a/src/Reprise.Core/PlaybackCommand.cs b/src/Reprise.Core/PlaybackCommand.cs new file mode 100644 index 0000000..5c434ad --- /dev/null +++ b/src/Reprise.Core/PlaybackCommand.cs @@ -0,0 +1,26 @@ +namespace Reprise.Core; + +/// +/// Playback control Reprise can send to a media player. +/// +/// +/// The set is deliberately narrow: these are the only transport controls +/// every supported backend is able to carry out, so a command never has to +/// be feature-detected before it is dispatched. +/// +public enum PlaybackCommand +{ + /// Skip back to the previous track. + Previous, + + /// Toggle between playing and paused. + PlayPause, + + /// Skip forward to the next track. + Next, + + /// + /// Pause without toggling, so a player that is already paused stays so. + /// + Pause, +} diff --git a/src/Reprise.Core/PlaybackPosition.cs b/src/Reprise.Core/PlaybackPosition.cs new file mode 100644 index 0000000..a336aab --- /dev/null +++ b/src/Reprise.Core/PlaybackPosition.cs @@ -0,0 +1,138 @@ +namespace Reprise.Core; + +/// +/// Arithmetic for showing and confirming a playback position. +/// +/// +/// Every platform reports position as a sample taken at some moment, while +/// the panel redraws far more often than it polls. These helpers turn a +/// sample into what the position should be now, and decide whether a later +/// sample confirms a seek the user asked for. They are pure so both +/// platforms and the view-model tests share one definition. +/// +public static class PlaybackPosition +{ + /// + /// How far a reported position may sit from a seek target and still count + /// as that seek having landed. + /// + /// + /// Players quantise positions and report them a poll late, so an exact + /// match would leave a completed seek waiting forever. + /// + public static readonly TimeSpan SeekConfirmationTolerance = TimeSpan.FromSeconds(1.5); + + /// + /// Confines a position to the playable range of a track. + /// + /// Position to clamp. + /// + /// Track length, or null when the player does not report one. + /// + /// + /// The position limited to 0.., or + /// zero when the duration is missing or not positive, since a position + /// means nothing without a length to measure it against. + /// + /// + /// + /// PlaybackPosition.Clamp(TimeSpan.FromSeconds(400), TimeSpan.FromSeconds(180)); + /// // 00:03:00 + /// + /// + public static TimeSpan Clamp(TimeSpan position, TimeSpan? duration) + { + if (duration is not { Ticks: > 0 } length) + { + return TimeSpan.Zero; + } + + if (position < TimeSpan.Zero) + { + return TimeSpan.Zero; + } + + return position > length ? length : position; + } + + /// + /// Projects an observed position forward to the present. + /// + /// + /// Only a playing session advances; a paused or stopped one is reported + /// as observed. Time is never allowed to run backwards, so a clock + /// adjustment between poll and redraw cannot make the scrubber jump left. + /// + /// Position the player reported. + /// Playback state at the time of the report. + /// When the report was taken. + /// Moment to project to. + /// Track length used to clamp the result. + /// The estimated current position, clamped to the track. + /// + /// + /// var shown = PlaybackPosition.Estimate( + /// session.Position ?? TimeSpan.Zero, + /// session.Status, + /// session.ObservedAt, + /// DateTimeOffset.UtcNow, + /// session.Duration); + /// + /// + public static TimeSpan Estimate( + TimeSpan observedPosition, + PlaybackStatus status, + DateTimeOffset observedAt, + DateTimeOffset now, + TimeSpan? duration) + { + var elapsed = status == PlaybackStatus.Playing && now > observedAt + ? now - observedAt + : TimeSpan.Zero; + + return Clamp(observedPosition + elapsed, duration); + } + + /// + /// Projects a session's reported position forward to the present. + /// + /// Session whose position to estimate. + /// Moment to project to. + /// + /// The estimated current position, or zero when the session reports no + /// position. + /// + /// + /// + /// var shown = PlaybackPosition.Estimate(session, DateTimeOffset.UtcNow); + /// + /// + public static TimeSpan Estimate(MediaSessionSnapshot session, DateTimeOffset now) + { + ArgumentNullException.ThrowIfNull(session); + + return Estimate( + session.Position ?? TimeSpan.Zero, + session.Status, + session.ObservedAt, + now, + session.Duration); + } + + /// + /// Decides whether a reported position shows that a seek has taken effect. + /// + /// Position the player now reports. + /// Position the seek asked for. + /// + /// True when the two are within . + /// + /// + /// + /// PlaybackPosition.ConfirmsSeek(TimeSpan.FromSeconds(91), TimeSpan.FromSeconds(90)); + /// // true + /// + /// + public static bool ConfirmsSeek(TimeSpan actual, TimeSpan target) => + (actual - target).Duration() <= SeekConfirmationTolerance; +} diff --git a/src/Reprise.Core/PlaybackStatus.cs b/src/Reprise.Core/PlaybackStatus.cs new file mode 100644 index 0000000..f4801ca --- /dev/null +++ b/src/Reprise.Core/PlaybackStatus.cs @@ -0,0 +1,26 @@ +namespace Reprise.Core; + +/// +/// Transport state a media player reports for its current track. +/// +/// +/// The names mirror the values of the MPRIS PlaybackStatus property so +/// a backend can map them across without inventing a vocabulary of its own. +/// Values are declared from least to most active, which is the order +/// prefers when several players are on +/// screen at once. +/// +public enum PlaybackStatus +{ + /// The backend reported a state Reprise does not recognise. + Unknown, + + /// Idle, with no meaningful playback position. + Stopped, + + /// A track is loaded and its position is frozen. + Paused, + + /// A track is advancing right now. + Playing, +} diff --git a/src/Reprise.Core/PlayerVolume.cs b/src/Reprise.Core/PlayerVolume.cs new file mode 100644 index 0000000..699b6a4 --- /dev/null +++ b/src/Reprise.Core/PlayerVolume.cs @@ -0,0 +1,107 @@ +namespace Reprise.Core; + +/// +/// Rules for the volume control shared by every panel. +/// +/// +/// The panel works in whole percent because that is what users read off a +/// slider, while takes a fraction; the +/// conversions and the mute behaviour live here so both directions agree. +/// +public static class PlayerVolume +{ + /// + /// Lowest level the slider can express. + /// + public const int Minimum = 0; + + /// + /// Highest level the slider can express. + /// + public const int Maximum = 100; + + /// + /// Level restored by unmuting when nothing better is known. + /// + /// + /// Half volume is loud enough to hear that unmuting worked and quiet + /// enough not to startle, which matters when the last audible level was + /// never observed - a player that was already muted when Reprise started. + /// + public const int DefaultAudibleLevel = 50; + + /// + /// Confines a level to the slider's range. + /// + /// Level in percent. + /// The level limited to ... + /// + /// + /// PlayerVolume.Clamp(140); // 100 + /// + /// + public static int Clamp(int value) => Math.Clamp(value, Minimum, Maximum); + + /// + /// Converts a service-scale fraction into a slider percent. + /// + /// Level from 0 to 1, as the service reports it. + /// The level in whole percent, clamped to the slider range. + /// + /// + /// PlayerVolume.FromFraction(0.64); // 64 + /// + /// + public static int FromFraction(double fraction) + { + if (double.IsNaN(fraction)) + { + return Minimum; + } + + return Clamp((int)Math.Round(fraction * Maximum, MidpointRounding.AwayFromZero)); + } + + /// + /// Converts a slider percent into the fraction the service expects. + /// + /// Level in percent. + /// The level from 0 to 1. + /// + /// + /// PlayerVolume.ToFraction(64); // 0.64 + /// + /// + public static double ToFraction(int percent) => Clamp(percent) / (double)Maximum; + + /// + /// Chooses the level a mute toggle should move to. + /// + /// + /// Anything audible mutes. From silence, the last audible level is + /// restored so a toggle round-trips, falling back to + /// when that level is unknown or was + /// itself zero - restoring silence would make the button appear broken. + /// + /// Level in percent right now. + /// + /// Most recent non-zero level seen, or null when none was observed. + /// + /// The level to apply. + /// + /// + /// PlayerVolume.MuteToggleTarget(current: 0, lastAudible: 64); // 64 + /// PlayerVolume.MuteToggleTarget(current: 64, lastAudible: 64); // 0 + /// + /// + public static int MuteToggleTarget(int current, int? lastAudible) + { + if (Clamp(current) != Minimum) + { + return Minimum; + } + + var restored = lastAudible is { } level ? Clamp(level) : DefaultAudibleLevel; + return restored > Minimum ? restored : DefaultAudibleLevel; + } +} diff --git a/src/Reprise.Core/Reprise.Core.csproj b/src/Reprise.Core/Reprise.Core.csproj new file mode 100644 index 0000000..b4e533b --- /dev/null +++ b/src/Reprise.Core/Reprise.Core.csproj @@ -0,0 +1,5 @@ + + + Shared playback models and selection behavior for Reprise. + + diff --git a/src/Reprise.Core/SyncedLyrics.cs b/src/Reprise.Core/SyncedLyrics.cs new file mode 100644 index 0000000..78414c5 --- /dev/null +++ b/src/Reprise.Core/SyncedLyrics.cs @@ -0,0 +1,259 @@ +using System.Globalization; +using System.Text; + +namespace Reprise.Core; + +/// +/// Where a set of lyrics came from. +/// +public enum LyricsSource +{ + /// + /// NAVER VIBE, which carries line end times and Korean catalogue depth. + /// + Vibe, + + /// + /// LRCLIB, which supplies LRC-format lyrics for a broad catalogue. + /// + Lrclib, +} + +/// +/// One timed line of lyrics. +/// +/// When the line becomes current. +/// +/// When the line stops being current, or null when it lasts until the next +/// line or the end of the track. +/// +/// The words. +public sealed record LyricLine(TimeSpan StartTime, TimeSpan? EndTime, string Text); + +/// +/// Time-synced lyrics for one track. +/// +/// +/// Lines are kept in start-time order so the current line can be found by +/// binary search, which matters because the panel asks several times a +/// second. +/// +/// Service the lyrics came from. +/// Lines in ascending start time. +public sealed record SyncedLyrics(LyricsSource Source, IReadOnlyList Lines) +{ + /// + /// Index of the last line that has started by a position. + /// + /// + /// Ignores end times, so a gap between lines keeps the previous line + /// focused; that is what the scrolling lyrics view wants, since it has + /// to keep something centred during an instrumental break. + /// + /// Playback position. + /// The index, or null before the first line. + /// + /// + /// lyrics.FocusedLineIndex(TimeSpan.FromSeconds(42)); // 7 + /// + /// + public int? FocusedLineIndex(TimeSpan position) + { + if (Lines.Count == 0) + { + return null; + } + + var lower = 0; + var upper = Lines.Count; + while (lower < upper) + { + var middle = (lower + upper) / 2; + if (Lines[middle].StartTime <= position) + { + lower = middle + 1; + } + else + { + upper = middle; + } + } + + return lower - 1 >= 0 ? lower - 1 : null; + } + + /// + /// Index of the line that is being sung at a position. + /// + /// + /// Unlike , a line whose end time has + /// passed no longer counts, so the menu bar can go quiet between verses. + /// + /// Playback position. + /// The index, or null when no line is current. + public int? LineIndex(TimeSpan position) + { + if (FocusedLineIndex(position) is not { } index) + { + return null; + } + + return Lines[index].EndTime is { } end && position >= end ? null : index; + } + + /// + /// The line being sung at a position. + /// + /// Playback position. + /// The line, or null when no line is current. + public LyricLine? Line(TimeSpan position) => + LineIndex(position) is { } index ? Lines[index] : null; +} + +/// +/// Identifies a track for lyrics lookup, comparing loosely. +/// +/// +/// Two queries are equal when their title, album, and artist match after +/// normalisation - case, diacritics, width, and punctuation are ignored - +/// because players report the same song with cosmetic differences and each +/// difference would otherwise be a fresh network lookup. Duration is +/// carried for the services to score with but does not take part in +/// equality. +/// +public sealed class LyricsTrackQuery : IEquatable +{ + /// + /// Creates a query from its parts. + /// + /// Track title. + /// Album name, possibly empty. + /// Artist credit, possibly empty. + /// Track length, or zero when unknown. + public LyricsTrackQuery(string title, string album, string artist, TimeSpan duration) + { + Title = title; + Album = album; + Artist = artist; + Duration = duration; + NormalizedTitle = Normalize(title); + NormalizedAlbum = Normalize(album); + NormalizedArtist = Normalize(artist); + } + + /// + /// Track title as the player reported it. + /// + public string Title { get; } + + /// + /// Album name as the player reported it. + /// + public string Album { get; } + + /// + /// Artist credit as the player reported it. + /// + public string Artist { get; } + + /// + /// Track length, or zero when unknown. + /// + public TimeSpan Duration { get; } + + /// + /// Title reduced to lower-case alphanumerics. + /// + public string NormalizedTitle { get; } + + /// + /// Album reduced to lower-case alphanumerics. + /// + public string NormalizedAlbum { get; } + + /// + /// Artist reduced to lower-case alphanumerics. + /// + public string NormalizedArtist { get; } + + /// + /// Builds the query for a session's current track. + /// + /// Session to describe. + /// A query, or null when the session has no title to look up. + /// + /// + /// var query = LyricsTrackQuery.From(session); + /// + /// + public static LyricsTrackQuery? From(MediaSessionSnapshot session) + { + ArgumentNullException.ThrowIfNull(session); + if (string.IsNullOrWhiteSpace(session.Title)) + { + return null; + } + + return new LyricsTrackQuery( + session.Title, + session.Album, + session.Artist, + session.Duration ?? TimeSpan.Zero); + } + + /// + /// Reduces text to the characters that identify a song. + /// + /// + /// Compatibility decomposition folds full-width forms and ligatures, + /// dropping combining marks removes diacritics, and keeping only letters + /// and digits removes the punctuation that varies between catalogues. + /// + /// Text to normalise. + /// Lower-case letters and digits only. + /// + /// + /// LyricsTrackQuery.Normalize("Café D'Amour!"); // "cafedamour" + /// + /// + public static string Normalize(string value) + { + if (string.IsNullOrEmpty(value)) + { + return string.Empty; + } + + var builder = new StringBuilder(value.Length); + foreach (var rune in value.Normalize(NormalizationForm.FormKD).EnumerateRunes()) + { + var category = Rune.GetUnicodeCategory(rune); + if (category is UnicodeCategory.NonSpacingMark + or UnicodeCategory.SpacingCombiningMark + or UnicodeCategory.EnclosingMark) + { + continue; + } + + if (Rune.IsLetterOrDigit(rune)) + { + builder.Append(Rune.ToLowerInvariant(rune).ToString()); + } + } + + return builder.ToString(); + } + + /// + public bool Equals(LyricsTrackQuery? other) => + other is not null + && NormalizedTitle == other.NormalizedTitle + && NormalizedAlbum == other.NormalizedAlbum + && NormalizedArtist == other.NormalizedArtist; + + /// + public override bool Equals(object? obj) => Equals(obj as LyricsTrackQuery); + + /// + public override int GetHashCode() => + HashCode.Combine(NormalizedTitle, NormalizedAlbum, NormalizedArtist); +} diff --git a/src/Reprise.Desktop/.gitkeep b/src/Reprise.Desktop/.gitkeep deleted file mode 100644 index 8b13789..0000000 --- a/src/Reprise.Desktop/.gitkeep +++ /dev/null @@ -1 +0,0 @@ - diff --git a/src/Reprise.Desktop/ArtworkLoader.cs b/src/Reprise.Desktop/ArtworkLoader.cs new file mode 100644 index 0000000..692fdb0 --- /dev/null +++ b/src/Reprise.Desktop/ArtworkLoader.cs @@ -0,0 +1,175 @@ +using System.Net.Http.Headers; + +namespace Reprise.Desktop; + +/// +/// Fetches the bytes behind an artwork URI. +/// +/// +/// An interface so the view model can be tested without touching the file +/// system or the network. +/// +public interface IArtworkLoader +{ + /// + /// Loads the encoded image a player points at. + /// + /// Absolute artwork location. + /// Cancels the load. + /// + /// The encoded image bytes, or null when the artwork cannot be fetched. + /// + /// + /// Thrown when is cancelled. + /// + Task LoadAsync(Uri uri, CancellationToken cancellationToken); +} + +/// +/// Loads artwork from the schemes MPRIS players actually publish. +/// +/// +/// Players hand out file: paths into their own caches, http(s): +/// links to a CDN, and occasionally inline data: URIs. Anything else +/// is treated as no artwork. Failures resolve to null rather than throw +/// because a missing cover is cosmetic and the poll that triggered the load +/// must not surface it as an error. +/// +public sealed class ArtworkLoader : IArtworkLoader +{ + /// + /// Largest cover accepted, to keep a hostile or broken URL from filling + /// memory. + /// + private const long MaximumBytes = 16 * 1024 * 1024; + + private static readonly HttpClient Http = CreateClient(); + + /// + /// Loads the encoded image a player points at. + /// + /// Absolute artwork location. + /// Cancels the load. + /// + /// The encoded image bytes, or null when the scheme is unsupported, the + /// source is missing, or the transfer fails. + /// + /// + /// Thrown when is cancelled. + /// + /// + /// + /// var bytes = await loader.LoadAsync(session.ArtworkUri, token); + /// + /// + public async Task LoadAsync(Uri uri, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(uri); + + try + { + return uri.Scheme switch + { + "file" => await ReadFileAsync(uri, cancellationToken), + "http" or "https" => await ReadHttpAsync(uri, cancellationToken), + "data" => DecodeDataUri(uri), + _ => null, + }; + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (Exception exception) when (exception is IOException + or UnauthorizedAccessException + or HttpRequestException + or FormatException + or TaskCanceledException + or NotSupportedException) + { + return null; + } + } + + /// + /// Builds the shared HTTP client with a bounded timeout. + /// + /// A client that identifies itself and gives up after ten seconds. + private static HttpClient CreateClient() + { + var client = new HttpClient + { + Timeout = TimeSpan.FromSeconds(10), + }; + client.DefaultRequestHeaders.UserAgent.Add( + new ProductInfoHeaderValue("Reprise", "2.0")); + return client; + } + + /// + /// Reads a local file, refusing anything past . + /// + /// A file: URI. + /// Cancels the read. + /// The file contents, or null when absent or too large. + private static async Task ReadFileAsync(Uri uri, CancellationToken cancellationToken) + { + var path = uri.LocalPath; + var info = new FileInfo(path); + if (!info.Exists || info.Length > MaximumBytes) + { + return null; + } + + return await File.ReadAllBytesAsync(path, cancellationToken); + } + + /// + /// Downloads a remote image, refusing anything past . + /// + /// An http: or https: URI. + /// Cancels the download. + /// The response body, or null on a non-success status or oversize body. + private static async Task ReadHttpAsync(Uri uri, CancellationToken cancellationToken) + { + using var response = await Http.GetAsync( + uri, + HttpCompletionOption.ResponseHeadersRead, + cancellationToken); + if (!response.IsSuccessStatusCode + || response.Content.Headers.ContentLength > MaximumBytes) + { + return null; + } + + var bytes = await response.Content.ReadAsByteArrayAsync(cancellationToken); + return bytes.Length > MaximumBytes ? null : bytes; + } + + /// + /// Decodes an inline data: URI. + /// + /// + /// Only the base64 form is accepted; percent-encoded image data is not + /// something any known player emits. + /// + /// A data: URI. + /// The decoded payload, or null when it is not base64. + private static byte[]? DecodeDataUri(Uri uri) + { + var body = uri.OriginalString; + var separator = body.IndexOf(',', StringComparison.Ordinal); + if (separator < 0) + { + return null; + } + + var header = body[..separator]; + if (!header.EndsWith(";base64", StringComparison.OrdinalIgnoreCase)) + { + return null; + } + + return Convert.FromBase64String(body[(separator + 1)..]); + } +} diff --git a/src/Reprise.Desktop/AssemblyInfo.cs b/src/Reprise.Desktop/AssemblyInfo.cs new file mode 100644 index 0000000..ae63b48 --- /dev/null +++ b/src/Reprise.Desktop/AssemblyInfo.cs @@ -0,0 +1,6 @@ +using System.Runtime.CompilerServices; + +// The panel's animation curves are internal: they are shape, not API, and +// nothing outside the drawing code calls them. They are also the pieces that +// have to match the macOS app frame for frame, so they are worth pinning. +[assembly: InternalsVisibleTo("Reprise.Desktop.Tests")] diff --git a/src/Reprise.Desktop/Assets/Fonts/LICENSE.txt b/src/Reprise.Desktop/Assets/Fonts/LICENSE.txt new file mode 100644 index 0000000..c0592ca --- /dev/null +++ b/src/Reprise.Desktop/Assets/Fonts/LICENSE.txt @@ -0,0 +1,94 @@ +Copyright (c) 2021, Kil Hyung-jin (https://github.com/orioncactus/pretendard), +with Reserved Font Name Pretendard. + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. \ No newline at end of file diff --git a/src/Reprise.Desktop/Assets/Fonts/Pretendard-Bold.otf b/src/Reprise.Desktop/Assets/Fonts/Pretendard-Bold.otf new file mode 100644 index 0000000..8e5e30a Binary files /dev/null and b/src/Reprise.Desktop/Assets/Fonts/Pretendard-Bold.otf differ diff --git a/src/Reprise.Desktop/Assets/Fonts/Pretendard-Medium.otf b/src/Reprise.Desktop/Assets/Fonts/Pretendard-Medium.otf new file mode 100644 index 0000000..0575069 Binary files /dev/null and b/src/Reprise.Desktop/Assets/Fonts/Pretendard-Medium.otf differ diff --git a/src/Reprise.Desktop/Assets/Fonts/Pretendard-Regular.otf b/src/Reprise.Desktop/Assets/Fonts/Pretendard-Regular.otf new file mode 100644 index 0000000..08bf4cf Binary files /dev/null and b/src/Reprise.Desktop/Assets/Fonts/Pretendard-Regular.otf differ diff --git a/src/Reprise.Desktop/Assets/Fonts/Pretendard-SemiBold.otf b/src/Reprise.Desktop/Assets/Fonts/Pretendard-SemiBold.otf new file mode 100644 index 0000000..e7e36ab Binary files /dev/null and b/src/Reprise.Desktop/Assets/Fonts/Pretendard-SemiBold.otf differ diff --git a/src/Reprise.Desktop/AutostartEntry.cs b/src/Reprise.Desktop/AutostartEntry.cs new file mode 100644 index 0000000..b845a67 --- /dev/null +++ b/src/Reprise.Desktop/AutostartEntry.cs @@ -0,0 +1,148 @@ +namespace Reprise.Desktop; + +/// +/// Launch-at-login through the XDG autostart directory. +/// +/// +/// The Linux counterpart of the macOS login item: a desktop entry under +/// $XDG_CONFIG_HOME/autostart that every freedesktop session runs at +/// login. The entry points at the running executable, so a copy of the +/// self-contained archive placed anywhere in the home directory works +/// without a system install. Failures are reported rather than thrown, as +/// the settings toggle needs a message, not a crash. +/// +public sealed class AutostartEntry +{ + private const string FileName = "dev.junx.Reprise.desktop"; + + private readonly string _path; + private readonly string? _executable; + + /// + /// Creates the entry for the current user and executable. + /// + public AutostartEntry() + : this(DefaultPath, Environment.ProcessPath) + { + } + + /// + /// Creates an entry at an explicit path, for tests. + /// + /// Desktop file to manage. + /// Program the entry should launch. + public AutostartEntry(string path, string? executable) + { + _path = path; + _executable = executable; + } + + /// + /// Location of the autostart entry for this user. + /// + public static string DefaultPath + { + get + { + var configHome = Environment.GetEnvironmentVariable("XDG_CONFIG_HOME"); + if (string.IsNullOrWhiteSpace(configHome)) + { + configHome = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), + ".config"); + } + + return Path.Combine(configHome, "autostart", FileName); + } + } + + /// + /// Whether the desktop can run Reprise at login at all. + /// + /// + /// False when the executable path is unknown, which happens only in + /// unusual hosting situations such as tests. + /// + public bool IsAvailable => !string.IsNullOrEmpty(_executable); + + /// + /// Whether an entry exists and is not marked hidden. + /// + public bool IsEnabled + { + get + { + try + { + if (!File.Exists(_path)) + { + return false; + } + + return !File.ReadLines(_path).Any(line => + line.Trim().Equals("Hidden=true", StringComparison.OrdinalIgnoreCase)); + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException) + { + return false; + } + } + } + + /// + /// Creates or removes the entry. + /// + /// Whether Reprise should start at login. + /// Null on success, or a message explaining the failure. + /// + /// + /// var error = new AutostartEntry().SetEnabled(true); + /// + /// + public string? SetEnabled(bool enabled) + { + if (!IsAvailable) + { + return "실행 파일 위치를 알 수 없어 자동 실행을 설정할 수 없습니다."; + } + + try + { + if (!enabled) + { + if (File.Exists(_path)) + { + File.Delete(_path); + } + + return null; + } + + Directory.CreateDirectory(Path.GetDirectoryName(_path)!); + File.WriteAllText(_path, DesktopFile(_executable!)); + return null; + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException) + { + return $"자동 실행 항목을 저장하지 못했습니다: {exception.Message}"; + } + } + + /// + /// Builds the desktop entry text. + /// + /// Program to launch. + /// A complete .desktop file. + private static string DesktopFile(string executable) => + $""" + [Desktop Entry] + Type=Application + Name=Reprise + Comment=Control MPRIS-compatible music players from one panel + Exec="{executable}" + Icon=multimedia-player + Terminal=false + X-GNOME-Autostart-enabled=true + + """; +} diff --git a/src/Reprise.Desktop/AvaloniaTrayStatusItem.cs b/src/Reprise.Desktop/AvaloniaTrayStatusItem.cs new file mode 100644 index 0000000..baae0d3 --- /dev/null +++ b/src/Reprise.Desktop/AvaloniaTrayStatusItem.cs @@ -0,0 +1,80 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Platform; + +namespace Reprise.Desktop; + +/// +/// Tray entry built on Avalonia's own . +/// +/// +/// The fallback for platforms without a dedicated implementation. It shows +/// an icon and a tooltip, but no label: Avalonia's tray has no notion of +/// text beside the icon, which is why Linux brings its own D-Bus +/// implementation instead. Like that one it offers no menu, so a click +/// opens the panel directly. +/// +public sealed class AvaloniaTrayStatusItem : IStatusItem +{ + private readonly TrayIcon _trayIcon; + private readonly Application _application; + private bool _disposed; + + /// + /// Creates the tray icon. + /// + /// Application the icon belongs to. + public AvaloniaTrayStatusItem(Application application) + { + _application = application; + + using var iconStream = AssetLoader.Open(new Uri("avares://Reprise.Desktop/Assets/reprise.png")); + _trayIcon = new TrayIcon + { + Icon = new WindowIcon(iconStream), + IsVisible = false, + ToolTipText = "Reprise", + }; + _trayIcon.Clicked += (_, _) => Activated?.Invoke(this, EventArgs.Empty); + } + + /// + public event EventHandler? Activated; + + /// + /// Shows the tray icon. + /// + /// Unused. + /// Always true; Avalonia reports no registration outcome. + public Task StartAsync(CancellationToken cancellationToken = default) + { + TrayIcon.SetIcons(_application, new TrayIcons { _trayIcon }); + _trayIcon.IsVisible = true; + return Task.FromResult(true); + } + + /// + /// Applies the tooltip; the label and icon are not supported here. + /// + /// Content to show. + public void Update(StatusItemState state) + { + ArgumentNullException.ThrowIfNull(state); + _trayIcon.ToolTipText = state.ToolTip; + } + + /// + /// Removes the tray icon. + /// + public void Dispose() + { + if (_disposed) + { + return; + } + + _disposed = true; + _trayIcon.IsVisible = false; + _trayIcon.Dispose(); + } +} diff --git a/src/Reprise.Desktop/CompactSlider.cs b/src/Reprise.Desktop/CompactSlider.cs new file mode 100644 index 0000000..2351d54 --- /dev/null +++ b/src/Reprise.Desktop/CompactSlider.cs @@ -0,0 +1,323 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Input; +using Avalonia.Media; +using Avalonia.Media.Immutable; + +namespace Reprise.Desktop; + +/// +/// The thin slider the panel uses for scrubbing and volume. +/// +/// +/// Drawn by hand rather than restyled from the Fluent slider because the +/// macOS control it mirrors is a 3px track with a small round knob, and +/// matching that through a control theme would take more markup than the +/// control itself. It also reports the start and end of a drag, which the +/// panel needs to hold a seek until the pointer is released. +/// +public sealed class CompactSlider : Control +{ + /// + /// Thickness of the track. + /// + private const double TrackHeight = 3; + + /// + /// Radius of the knob. + /// + private const double KnobRadius = 5.5; + + /// + /// Lowest value the slider can express. + /// + public static readonly StyledProperty MinimumProperty = + AvaloniaProperty.Register(nameof(Minimum), 0); + + /// + /// Highest value the slider can express. + /// + public static readonly StyledProperty MaximumProperty = + AvaloniaProperty.Register(nameof(Maximum), 1); + + /// + /// Current value. + /// + public static readonly StyledProperty ValueProperty = + AvaloniaProperty.Register(nameof(Value), 0); + + /// + /// Brush for the unfilled part of the track. + /// + public static readonly StyledProperty TrackBrushProperty = + AvaloniaProperty.Register(nameof(TrackBrush), Brushes.Gray); + + /// + /// Brush for the filled part of the track, up to the knob. + /// + public static readonly StyledProperty FillBrushProperty = + AvaloniaProperty.Register(nameof(FillBrush), Brushes.DodgerBlue); + + /// + /// Brush for the knob. + /// + public static readonly StyledProperty KnobBrushProperty = + AvaloniaProperty.Register(nameof(KnobBrush), Brushes.White); + + private static readonly IBrush KnobShadowBrush = + new ImmutableSolidColorBrush(Color.FromArgb(0x40, 0, 0, 0)); + + private bool _isDragging; + + /// + /// Registers the property watchers that trigger a redraw. + /// + static CompactSlider() + { + AffectsRender( + MinimumProperty, + MaximumProperty, + ValueProperty, + TrackBrushProperty, + FillBrushProperty, + KnobBrushProperty, + IsEnabledProperty); + } + + /// + /// Creates a slider sized for the panel. + /// + public CompactSlider() + { + Height = 16; + Cursor = new Cursor(StandardCursorType.Arrow); + } + + /// + /// Raised when the user presses on the slider, before any value change. + /// + public event EventHandler? DragStarted; + + /// + /// Raised while the user drags or clicks, with the value they chose. + /// + public event EventHandler? UserValueChanged; + + /// + /// Raised when the user releases the slider. + /// + public event EventHandler? DragCompleted; + + /// + /// Lowest value the slider can express. + /// + public double Minimum + { + get => GetValue(MinimumProperty); + set => SetValue(MinimumProperty, value); + } + + /// + /// Highest value the slider can express. + /// + public double Maximum + { + get => GetValue(MaximumProperty); + set => SetValue(MaximumProperty, value); + } + + /// + /// Current value. + /// + public double Value + { + get => GetValue(ValueProperty); + set => SetValue(ValueProperty, value); + } + + /// + /// Brush for the unfilled part of the track. + /// + public IBrush? TrackBrush + { + get => GetValue(TrackBrushProperty); + set => SetValue(TrackBrushProperty, value); + } + + /// + /// Brush for the filled part of the track. + /// + public IBrush? FillBrush + { + get => GetValue(FillBrushProperty); + set => SetValue(FillBrushProperty, value); + } + + /// + /// Brush for the knob. + /// + public IBrush? KnobBrush + { + get => GetValue(KnobBrushProperty); + set => SetValue(KnobBrushProperty, value); + } + + /// + /// Whether the user is holding the slider. + /// + public bool IsDragging => _isDragging; + + /// + /// Draws the track, its filled portion, and the knob. + /// + /// + /// The whole control is dimmed when disabled, which is how the macOS + /// slider reads while a player cannot seek. A transparent fill under the + /// track makes the full strip respond to the pointer, not just the three + /// painted pixels of the track. + /// + /// Surface to draw on. + public override void Render(DrawingContext context) + { + var bounds = Bounds; + if (bounds.Width <= 0 || bounds.Height <= 0) + { + return; + } + + context.DrawRectangle(Brushes.Transparent, null, new Rect(bounds.Size)); + var opacity = IsEnabled ? 1 : 0.45; + using var _ = context.PushOpacity(opacity); + + var centerY = bounds.Height / 2; + var left = KnobRadius; + var right = Math.Max(left, bounds.Width - KnobRadius); + var knobX = left + (right - left) * Fraction(Value); + var trackRect = new Rect(left, centerY - TrackHeight / 2, right - left, TrackHeight); + var fillRect = new Rect(left, centerY - TrackHeight / 2, Math.Max(0, knobX - left), TrackHeight); + + if (TrackBrush is { } track) + { + context.DrawRectangle(track, null, new RoundedRect(trackRect, TrackHeight / 2)); + } + + if (FillBrush is { } fill && fillRect.Width > 0) + { + context.DrawRectangle(fill, null, new RoundedRect(fillRect, TrackHeight / 2)); + } + + var shadowCenter = new Point(knobX, centerY + 0.5); + context.DrawEllipse(KnobShadowBrush, null, shadowCenter, KnobRadius + 0.5, KnobRadius + 0.5); + if (KnobBrush is { } knob) + { + context.DrawEllipse(knob, null, new Point(knobX, centerY), KnobRadius, KnobRadius); + } + } + + /// + /// Begins a drag and moves the knob to the pointer. + /// + /// Pointer event. + protected override void OnPointerPressed(PointerPressedEventArgs e) + { + base.OnPointerPressed(e); + if (!IsEnabled || !e.GetCurrentPoint(this).Properties.IsLeftButtonPressed) + { + return; + } + + e.Pointer.Capture(this); + _isDragging = true; + DragStarted?.Invoke(this, EventArgs.Empty); + ApplyPointer(e.GetPosition(this)); + e.Handled = true; + } + + /// + /// Follows the pointer while a drag is in progress. + /// + /// Pointer event. + protected override void OnPointerMoved(PointerEventArgs e) + { + base.OnPointerMoved(e); + if (!_isDragging) + { + return; + } + + ApplyPointer(e.GetPosition(this)); + e.Handled = true; + } + + /// + /// Ends a drag. + /// + /// Pointer event. + protected override void OnPointerReleased(PointerReleasedEventArgs e) + { + base.OnPointerReleased(e); + if (!_isDragging) + { + return; + } + + ApplyPointer(e.GetPosition(this)); + e.Pointer.Capture(null); + FinishDrag(); + e.Handled = true; + } + + /// + /// Ends a drag whose pointer was taken away, so a seek is never left hanging. + /// + /// Capture event. + protected override void OnPointerCaptureLost(PointerCaptureLostEventArgs e) + { + base.OnPointerCaptureLost(e); + FinishDrag(); + } + + /// + /// Converts a value into its fraction of the track. + /// + /// Value to place. + /// A fraction from 0 to 1. + private double Fraction(double value) + { + var range = Maximum - Minimum; + if (range <= 0 || double.IsNaN(value)) + { + return 0; + } + + return Math.Clamp((value - Minimum) / range, 0, 1); + } + + /// + /// Sets the value from a pointer position and reports it. + /// + /// Pointer position relative to this control. + private void ApplyPointer(Point position) + { + var left = KnobRadius; + var right = Math.Max(left + 1, Bounds.Width - KnobRadius); + var fraction = Math.Clamp((position.X - left) / (right - left), 0, 1); + var value = Minimum + (Maximum - Minimum) * fraction; + Value = value; + UserValueChanged?.Invoke(this, value); + } + + /// + /// Clears the drag state and notifies listeners once. + /// + private void FinishDrag() + { + if (!_isDragging) + { + return; + } + + _isDragging = false; + DragCompleted?.Invoke(this, EventArgs.Empty); + } +} diff --git a/src/Reprise.Desktop/DesktopPreferences.cs b/src/Reprise.Desktop/DesktopPreferences.cs new file mode 100644 index 0000000..34f721a --- /dev/null +++ b/src/Reprise.Desktop/DesktopPreferences.cs @@ -0,0 +1,217 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Reprise.Desktop; + +/// +/// The user's panel settings, as one immutable value. +/// +/// +/// A record so a change is expressed as with and applied through +/// , which is the only place that +/// persists and broadcasts it. Defaults match the values the macOS app +/// registers on first launch. +/// +/// Visual treatment of the panel. +/// Value shown in the left time label. +/// Value shown in the right time label. +/// +/// Whether a long title scrolls on its own, or only while hovered. +/// +/// Title scrolling speed. +/// Text shown beside the tray icon. +/// +/// Whether the tray label shows the current lyric line instead of the title +/// when synced lyrics exist. +/// +/// What the tray icon shows for a track. +/// +/// Longest label the tray shows before it starts scrolling, in characters. +/// +/// +/// Whether the tray label keeps its full width while showing lyrics, so +/// neighbouring items do not shift as lines change length. +/// +/// +/// Whether a scrolling tray label restarts from the beginning when the panel +/// is opened. +/// +/// +/// Whether a player that starts playing pauses any other player that was +/// already playing. +/// +/// +/// Whether the player the user most recently started takes precedence over +/// the display priority. +/// +/// Kind of the player most recently started. +/// +/// Comma-separated player kinds, most preferred first; see +/// . +/// +public sealed record DesktopPreferences( + PanelTheme PanelTheme = PanelTheme.Liquid, + PanelLeadingTimeStyle LeadingTimeStyle = PanelLeadingTimeStyle.Elapsed, + PanelTrailingTimeStyle TrailingTimeStyle = PanelTrailingTimeStyle.Remaining, + bool AutomaticallyScrollsTitles = true, + double MarqueePointsPerSecond = 30, + MenuBarTitleFormat MenuBarTitleFormat = MenuBarTitleFormat.TitleOnly, + bool MenuBarShowsLyrics = false, + MenuBarArtworkStyle MenuBarArtworkStyle = MenuBarArtworkStyle.AlbumArtwork, + int MenuBarLabelLength = 20, + bool MenuBarReservesLabelWidth = true, + bool ResetsMenuTitleWhenPanelOpens = true, + bool AutomaticallyPausesOtherPlayer = false, + bool RemembersLastPlayedPlayer = false, + string LastPlayedPlayer = "", + string PlayerDisplayPriority = "spotify,youTubeMusic,generic"); + +/// +/// Loads, holds, and saves . +/// +/// +/// The file lives under the XDG configuration directory so it survives +/// reinstalling the application and follows the user's home. Reading is +/// forgiving - a missing or damaged file yields defaults rather than a +/// crash at startup - while writing is best-effort, since losing a setting +/// is preferable to losing the panel. +/// +public sealed class PreferencesStore +{ + private static readonly JsonSerializerOptions SerializerOptions = new() + { + WriteIndented = true, + Converters = { new JsonStringEnumConverter(JsonNamingPolicy.CamelCase) }, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + }; + + private readonly string _path; + + /// + /// Creates a store bound to one file, reading it immediately. + /// + /// File the preferences are kept in. + /// + /// + /// var store = new PreferencesStore(PreferencesStore.DefaultPath); + /// + /// + public PreferencesStore(string path) + { + _path = path; + Current = Read(path); + } + + /// + /// Raised after changes, on the calling thread. + /// + public event EventHandler? Changed; + + /// + /// The preferences in effect right now. + /// + public DesktopPreferences Current { get; private set; } + + /// + /// Location of the preferences file for this user. + /// + /// + /// Honours XDG_CONFIG_HOME and falls back to ~/.config, as + /// the base directory specification requires. + /// + public static string DefaultPath + { + get + { + var configHome = Environment.GetEnvironmentVariable("XDG_CONFIG_HOME"); + if (string.IsNullOrWhiteSpace(configHome)) + { + configHome = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), + ".config"); + } + + return Path.Combine(configHome, "reprise", "preferences.json"); + } + } + + /// + /// Applies a change, saves it, and notifies listeners. + /// + /// + /// An update that produces an equal value is dropped without saving or + /// notifying, so menus can re-apply the current choice freely. + /// + /// Produces the new value from the current one. + /// + /// + /// store.Update(current => current with { PanelTheme = PanelTheme.Dark }); + /// + /// + public void Update(Func change) + { + ArgumentNullException.ThrowIfNull(change); + + var next = change(Current); + if (next == Current) + { + return; + } + + Current = next; + Write(_path, next); + Changed?.Invoke(this, EventArgs.Empty); + } + + /// + /// Reads a preferences file, tolerating its absence or corruption. + /// + /// File to read. + /// The stored preferences, or defaults when unreadable. + private static DesktopPreferences Read(string path) + { + try + { + if (!File.Exists(path)) + { + return new DesktopPreferences(); + } + + using var stream = File.OpenRead(path); + return JsonSerializer.Deserialize(stream, SerializerOptions) + ?? new DesktopPreferences(); + } + catch (Exception exception) when (exception is IOException or JsonException or UnauthorizedAccessException) + { + return new DesktopPreferences(); + } + } + + /// + /// Writes a preferences file, creating its directory as needed. + /// + /// + /// Failures are swallowed on purpose: the setting still applies for + /// this session, and a read-only home directory should not make the + /// menu misbehave. + /// + /// File to write. + /// Value to store. + private static void Write(string path, DesktopPreferences preferences) + { + try + { + var directory = Path.GetDirectoryName(path); + if (!string.IsNullOrEmpty(directory)) + { + Directory.CreateDirectory(directory); + } + + using var stream = File.Create(path); + JsonSerializer.Serialize(stream, preferences, SerializerOptions); + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException) + { + } + } +} diff --git a/src/Reprise.Desktop/IStatusItem.cs b/src/Reprise.Desktop/IStatusItem.cs new file mode 100644 index 0000000..b9a11e5 --- /dev/null +++ b/src/Reprise.Desktop/IStatusItem.cs @@ -0,0 +1,68 @@ +namespace Reprise.Desktop; + +/// +/// A raster icon for the tray, in the layout tray protocols expect. +/// +/// Width in pixels. +/// Height in pixels. +/// +/// Straight-alpha pixels, four bytes each in A, R, G, B order, rows top to +/// bottom. +/// +public sealed record StatusItemIcon(int Width, int Height, byte[] Argb); + +/// +/// Everything the tray shows for Reprise at one moment. +/// +/// +/// Text beside the icon, or empty for icon only. Its width is its own: a +/// label that should hold a fixed width is padded to that width by the +/// caller, since a tray label carries no separate measurement. +/// +/// Text shown on hover. +/// +/// The icon at one or more sizes, for the host to choose from. Empty means +/// keep the application icon. +/// +public sealed record StatusItemState( + string Label, + string ToolTip, + IReadOnlyList Icons); + +/// +/// The platform's tray or status-bar entry for Reprise. +/// +/// +/// Implemented per platform because each desktop exposes its status area +/// differently - Linux through D-Bus, and others through their own APIs. +/// Events are raised on whatever thread the platform delivers them on; +/// callers marshal to the UI thread themselves. +/// +public interface IStatusItem : IDisposable +{ + /// + /// Raised when the user clicks the item to bring up the panel. + /// + /// + /// The only interaction the item offers. It deliberately publishes no + /// menu: a click should reach the panel directly, as it does on macOS, + /// and everything a menu would carry lives in the panel's footer. + /// + event EventHandler? Activated; + + /// + /// Registers the item with the desktop. + /// + /// Cancels the registration. + /// + /// True when a status area accepted the item; false when the desktop + /// offers none, in which case the panel is the only way to reach Reprise. + /// + Task StartAsync(CancellationToken cancellationToken = default); + + /// + /// Pushes new content to the item. + /// + /// Label, tooltip, and icon to show. + void Update(StatusItemState state); +} diff --git a/src/Reprise.Desktop/MenuBarPublisher.cs b/src/Reprise.Desktop/MenuBarPublisher.cs new file mode 100644 index 0000000..6debd71 --- /dev/null +++ b/src/Reprise.Desktop/MenuBarPublisher.cs @@ -0,0 +1,61 @@ +namespace Reprise.Desktop; + +/// +/// Everything a shell extension needs to draw Reprise in the top bar. +/// +/// +/// Richer than on purpose. A tray host is +/// handed a finished label because that is all the protocol carries, while +/// an extension draws the item itself and wants the raw material: the whole +/// text, the cover as an image, and the user's scrolling settings, so it +/// can animate smoothly the way the macOS menu bar does. +/// +/// The full line to show, never shortened. +/// Text for the hover tooltip. +/// Album cover as PNG bytes, or empty for none. +/// Whether a player is being followed. +/// Whether that player is playing right now. +/// Whether the user wants long text to scroll. +/// Scrolling speed the user chose. +/// +/// Width the text should be held to, in characters; the extension converts +/// it to pixels with its own font. +/// +public sealed record MenuBarState( + string Text, + string ToolTip, + byte[] IconPng, + bool IsRunning, + bool IsPlaying, + bool ScrollsText, + double PointsPerSecond, + int MaxWidthChars); + +/// +/// Publishes to whatever draws the top bar. +/// +/// +/// Implemented per platform, since the transport is platform-specific: +/// D-Bus on Linux, and nothing at all where the application draws its own +/// menu bar item. +/// +public interface IMenuBarPublisher : IDisposable +{ + /// + /// Raised when the shell asks Reprise to show its panel. + /// + event EventHandler? Activated; + + /// + /// Starts accepting connections from the shell. + /// + /// Cancels the start-up. + /// A task that completes once the service is reachable. + Task StartAsync(CancellationToken cancellationToken = default); + + /// + /// Publishes new content. + /// + /// What the top bar should show. + void Publish(MenuBarState state); +} diff --git a/src/Reprise.Desktop/MenuBarText.cs b/src/Reprise.Desktop/MenuBarText.cs new file mode 100644 index 0000000..caf20bd --- /dev/null +++ b/src/Reprise.Desktop/MenuBarText.cs @@ -0,0 +1,133 @@ +using System.Globalization; +using Reprise.Core; + +namespace Reprise.Desktop; + +/// +/// Composes the text the tray label shows beside the icon. +/// +/// +/// The rules are the macOS status item's: nothing when the format is +/// hidden, the current lyric line when lyrics are shown and available, and +/// otherwise the title in the chosen format. +/// +/// The line is never shortened. A tray label is a string the desktop +/// draws, so it cannot move; the nearest imitation would be to publish a +/// different slice of it each tick, which reads as the letters changing +/// rather than the words sliding past. Scrolling is left to the surfaces +/// Reprise draws itself. is the one adjustment, and +/// it only ever adds width. +/// +/// +public static class MenuBarText +{ + /// + /// Text for the tray label given the current state. + /// + /// Session on display, or null. + /// Lyric line being sung, or null. + /// Label settings. + /// The label text, possibly empty. + /// + /// + /// MenuBarText.Compose(session, null, preferences); // "Bridge Song" + /// + /// + public static string Compose( + MediaSessionSnapshot? session, + string? lyricLine, + DesktopPreferences preferences) + { + ArgumentNullException.ThrowIfNull(preferences); + + if (preferences.MenuBarTitleFormat == MenuBarTitleFormat.Hidden || session is null) + { + return string.Empty; + } + + if (preferences.MenuBarShowsLyrics && !string.IsNullOrWhiteSpace(lyricLine)) + { + return lyricLine.Trim(); + } + + return FormatTitle(preferences.MenuBarTitleFormat, session.Title, session.Artist); + } + + /// + /// Joins a title and artist the way a format asks. + /// + /// Arrangement to use. + /// Track title. + /// Artist credit. + /// The joined text; empty parts are dropped along with their dash. + /// + /// + /// MenuBarText.FormatTitle(MenuBarTitleFormat.ArtistTitle, "Song", "Band"); // "Band - Song" + /// + /// + public static string FormatTitle(MenuBarTitleFormat format, string title, string artist) + { + title = title.Trim(); + artist = artist.Trim(); + return format switch + { + MenuBarTitleFormat.TitleOnly => title.Length > 0 ? title : artist, + MenuBarTitleFormat.TitleArtist => Join(title, artist), + MenuBarTitleFormat.ArtistTitle => Join(artist, title), + _ => string.Empty, + }; + } + + /// + /// Pads a label out to a fixed width so the tray entry stops resizing. + /// + /// + /// A tray label is a string, not a measurement, so the only way to hold + /// a width is to make every label the same length. Trailing spaces do + /// that, and unlike a separate width hint they cannot overstate the + /// width: the label is exactly as wide as it reads. + /// + /// Label text. + /// Width to hold, in text elements. + /// + /// The text padded with spaces to , or + /// unchanged when it is already that long. + /// + /// + /// + /// MenuBarText.Reserve("ab", 5); // "ab " + /// + /// + public static string Reserve(string text, int length) + { + ArgumentNullException.ThrowIfNull(text); + var elements = TextElements(text).Length; + return elements >= length ? text : text + new string(' ', length - elements); + } + + /// + /// Splits text into user-perceived characters. + /// + /// Text to split. + /// One string per text element. + private static string[] TextElements(string text) + { + var elements = new List(text.Length); + var enumerator = StringInfo.GetTextElementEnumerator(text); + while (enumerator.MoveNext()) + { + elements.Add(enumerator.GetTextElement()); + } + + return [.. elements]; + } + + /// + /// Joins two parts with a dash, omitting empty parts. + /// + /// Leading part. + /// Trailing part. + /// The joined text. + private static string Join(string first, string second) => + string.Join(" - ", new[] { first, second }.Where(part => part.Length > 0)); +} diff --git a/src/Reprise.Desktop/NowPlayingViewModel.cs b/src/Reprise.Desktop/NowPlayingViewModel.cs new file mode 100644 index 0000000..7eae6b8 --- /dev/null +++ b/src/Reprise.Desktop/NowPlayingViewModel.cs @@ -0,0 +1,1095 @@ +using System.ComponentModel; +using System.Runtime.CompilerServices; +using Reprise.Core; + +namespace Reprise.Desktop; + +/// +/// Which player mark the panel shows beside the track. +/// +public enum PanelPlayerLogo +{ + /// + /// Any MPRIS player without a dedicated mark; drawn as a music note. + /// + Generic, + + /// + /// The Spotify desktop client. + /// + Spotify, + + /// + /// YouTube Music, whether through a browser tab or a wrapper app. + /// + YouTubeMusic, +} + +/// +/// Progress of the lyrics lookup for the current track. +/// +public enum LyricsLoadState +{ + /// + /// No track, so nothing to look up. + /// + Idle, + + /// + /// A lookup is in flight. + /// + Loading, + + /// + /// Synced lyrics were found and are in . + /// + Available, + + /// + /// No service had synced lyrics for the track. + /// + Unavailable, +} + +/// +/// Presentation state for the player panel. +/// +/// +/// Sits between the platform's and the +/// window, turning a polled list of sessions into the single session, the +/// artwork, the scrubber position, and the volume level the panel binds to. +/// It also owns the two interactions that outlive a single poll - a seek in +/// progress and a volume change in flight - so the window never has to +/// reconcile a stale sample against what the user just did. +/// +/// Not thread-safe by design: it is driven from the UI thread, and the only +/// concurrency it guards against is its own asynchronous completions. +/// +/// +public sealed class NowPlayingViewModel : INotifyPropertyChanged, IDisposable +{ + /// + /// Polls a pending seek may go unconfirmed before it is abandoned. + /// + /// + /// A player that ignores the seek would otherwise pin the scrubber at + /// the requested position forever. + /// + private const int MaximumUnconfirmedSeekPolls = 3; + + private readonly IMediaSessionService _mediaSessionService; + private readonly IArtworkLoader _artworkLoader; + private readonly ILyricsService? _lyricsService; + private readonly TimeProvider _timeProvider; + private readonly Func _preferences; + private IReadOnlyList _sessions = []; + private HashSet _previouslyPlaying = []; + private readonly Dictionary _lyricsCache = []; + private LyricsTrackQuery? _lyricsQuery; + private CancellationTokenSource? _lyricsLoad; + private SyncedLyrics? _lyrics; + private LyricsLoadState _lyricsState; + private readonly SemaphoreSlim _refreshGate = new(1, 1); + private readonly CancellationTokenSource _lifetime = new(); + private readonly Dictionary _lastAudibleVolumes = new(StringComparer.Ordinal); + private MediaSessionSnapshot? _activeSession; + private byte[]? _artworkData; + private Uri? _artworkUri; + private CancellationTokenSource? _artworkLoad; + private string? _pollError; + private string? _commandError; + private int _volume = PlayerVolume.Maximum; + private bool _isVolumeEditing; + private bool _volumeSendInFlight; + private int? _queuedVolume; + private TimeSpan _seekPosition; + private bool _isSeeking; + private TimeSpan? _pendingSeek; + private int _unconfirmedSeekPolls; + private string? _trackIdentity; + private bool _disposed; + + /// + /// Creates a view model over the given media session backend. + /// + /// + /// Platform backend polled for sessions and used to dispatch commands. + /// + /// + /// Fetches cover art. Defaults to . + /// + /// + /// Clock used to project playback position. Defaults to the system clock. + /// + /// + /// Looks up synced lyrics. Null disables lyrics entirely. + /// + /// + /// Supplies the current settings for player priority and auto-pause. + /// Defaults to the built-in defaults. + /// + /// + /// + /// var viewModel = new NowPlayingViewModel( + /// new MprisMediaSessionService(), + /// lyricsService: new LyricsService(), + /// preferences: () => store.Current); + /// + /// + public NowPlayingViewModel( + IMediaSessionService mediaSessionService, + IArtworkLoader? artworkLoader = null, + TimeProvider? timeProvider = null, + ILyricsService? lyricsService = null, + Func? preferences = null) + { + _mediaSessionService = mediaSessionService; + _artworkLoader = artworkLoader ?? new ArtworkLoader(); + _timeProvider = timeProvider ?? TimeProvider.System; + _lyricsService = lyricsService; + _preferences = preferences ?? DefaultPreferences; + } + + /// + /// Raised on the calling thread when the remembered player changes. + /// + /// + /// The view model has no store to write to, so whoever owns the + /// preferences persists the kind carried by the event. + /// + public event EventHandler? LastPlayedPlayerChanged; + + /// + /// Raised on the calling thread whenever a bound property changes. + /// + public event PropertyChangedEventHandler? PropertyChanged; + + /// + /// Every session from the latest poll, in the backend's order. + /// + public IReadOnlyList Sessions => _sessions; + + /// + /// The session currently on display, or null when nothing is playing. + /// + public MediaSessionSnapshot? ActiveSession + { + get => _activeSession; + private set + { + if (Equals(_activeSession, value)) + { + return; + } + + _activeSession = value; + OnPropertyChanged(); + } + } + + /// + /// Encoded cover art for , or null while it + /// loads or when the player publishes none. + /// + public byte[]? ArtworkData + { + get => _artworkData; + private set + { + if (ReferenceEquals(_artworkData, value)) + { + return; + } + + _artworkData = value; + OnPropertyChanged(); + } + } + + /// + /// Synced lyrics for the current track, or null when none are loaded. + /// + public SyncedLyrics? Lyrics => _lyrics; + + /// + /// Where the lyrics lookup for the current track stands. + /// + public LyricsLoadState LyricsState => _lyricsState; + + /// + /// The lyric line to show at a moment. + /// + /// + /// Uses the focused line rather than the strictly current one, so the + /// last sung line stays up through an instrumental gap instead of the + /// label flickering empty. + /// + /// Moment to evaluate at. + /// The line, or null before the first line or without lyrics. + /// + /// + /// var line = viewModel.CurrentLyricLine(DateTimeOffset.UtcNow); + /// + /// + public LyricLine? CurrentLyricLine(DateTimeOffset now) + { + if (_lyrics is not { } lyrics) + { + return null; + } + + return lyrics.FocusedLineIndex(DisplayedPosition(now)) is { } index + ? lyrics.Lines[index] + : null; + } + + /// + /// The lyric line to show right now. + /// + /// The line, or null. + public LyricLine? CurrentLyricLine() => CurrentLyricLine(_timeProvider.GetUtcNow()); + + /// + /// Message from the most recent failure, or null while things are fine. + /// + /// + /// A failed command outranks a failed poll, since it is the more recent + /// thing the user did. + /// + public string? ErrorText => _commandError ?? _pollError; + + /// + /// Whether there is a player to control at all. + /// + public bool IsRunning => _activeSession is not null; + + /// + /// Whether the active session is currently playing. + /// + public bool IsPlaying => _activeSession?.Status == PlaybackStatus.Playing; + + /// + /// Whether the active player reports a volume the panel can adjust. + /// + public bool HasVolume => _activeSession?.Volume is not null; + + /// + /// Volume in percent, as the slider should show it. + /// + /// + /// Follows the player's reported level except while the user is editing + /// or a change is still in flight, when it holds the user's value so the + /// slider does not snap back to a stale sample mid-drag. + /// + public int Volume + { + get => _volume; + private set + { + if (_volume == value) + { + return; + } + + _volume = value; + OnPropertyChanged(); + } + } + + /// + /// Whether the user is dragging the volume slider right now. + /// + /// + /// Set by the window around a drag so polls do not overwrite the slider. + /// + public bool IsVolumeEditing + { + get => _isVolumeEditing; + set => _isVolumeEditing = value; + } + + /// + /// Whether the user is dragging the scrubber right now. + /// + public bool IsSeeking => _isSeeking; + + /// + /// Length of the current track, or zero when the player reports none. + /// + public TimeSpan Duration => _activeSession?.Duration is { Ticks: > 0 } duration + ? duration + : TimeSpan.Zero; + + /// + /// Which player mark to draw for the active session. + /// + public PanelPlayerLogo PlayerLogo => LogoFor(_activeSession?.PlayerId); + + /// + /// Position the scrubber should show at a given moment. + /// + /// + /// Resolved in priority order: the point the user is dragging to, then a + /// seek that was sent but not yet reflected by the player, then the last + /// sample projected forward by the time since it was taken. + /// + /// Moment to project the last sample to. + /// A position clamped to the current track. + /// + /// + /// var shown = viewModel.DisplayedPosition(DateTimeOffset.UtcNow); + /// + /// + public TimeSpan DisplayedPosition(DateTimeOffset now) + { + var session = _activeSession; + if (session is null) + { + return TimeSpan.Zero; + } + + if (_isSeeking) + { + return PlaybackPosition.Clamp(_seekPosition, session.Duration); + } + + if (_pendingSeek is { } pending) + { + return PlaybackPosition.Clamp(pending, session.Duration); + } + + return PlaybackPosition.Estimate(session, now); + } + + /// + /// Position the scrubber should show right now. + /// + /// A position clamped to the current track. + public TimeSpan DisplayedPosition() => DisplayedPosition(_timeProvider.GetUtcNow()); + + /// + /// Polls the backend once and republishes the resulting state. + /// + /// + /// Called every second by the window's timer. A refresh already in flight + /// causes this call to return immediately rather than queue: with a fixed + /// tick, queueing a backend slower than the interval would build a + /// backlog of polls whose results are stale by the time they arrive. + /// + /// Failures are surfaced through instead of + /// thrown, because the caller is a timer tick with nowhere to report to, + /// and a transient bus error should not tear down the UI. Cancellation + /// during disposal is swallowed for the same reason - it is expected + /// shutdown, not a fault. + /// + /// + /// + /// A task that completes once the poll has finished and properties have + /// been updated. Never faults. + /// + /// + /// + /// timer.Tick += async (_, _) => await viewModel.RefreshAsync(); + /// + /// + public async Task RefreshAsync() + { + if (_disposed) + { + return; + } + + if (!await _refreshGate.WaitAsync(0, _lifetime.Token)) + { + return; + } + + try + { + var polled = await _mediaSessionService.GetSessionsAsync(_lifetime.Token); + var (started, paused) = await ReconcilePlaybackAsync(polled); + var sessions = paused.Count == 0 + ? polled + : polled.Select(session => paused.Contains(session.PlayerId) + ? session with { Status = PlaybackStatus.Paused } + : session).ToList(); + _sessions = sessions; + Apply(SelectActive(sessions, started)); + SetPollError(null); + } + catch (OperationCanceledException) when (_lifetime.IsCancellationRequested) + { + } + catch (Exception exception) + { + Apply(null); + SetPollError(exception.Message); + } + finally + { + _refreshGate.Release(); + } + } + + /// + /// Sends a playback command to the session currently on display. + /// + /// + /// Targets the snapshot the user is looking at rather than re-reading the + /// active session, so a poll landing between the click and the dispatch + /// cannot redirect the command to a different player. Refreshes + /// immediately afterwards so the transport buttons reflect the new state + /// without waiting out the remainder of the timer interval. + /// + /// Transport control to invoke. + /// + /// A task that completes once the command has been sent and the state + /// re-read. Does nothing when no session is active. Never faults; + /// failures land in . + /// + /// + /// + /// nextButton.Click += async (_, _) => + /// await viewModel.SendAsync(PlaybackCommand.Next); + /// + /// + public async Task SendAsync(PlaybackCommand command) + { + if (_disposed || _activeSession is not { } session) + { + return; + } + + await Guarded(async () => + { + await _mediaSessionService.SendCommandAsync( + session.PlayerId, + command, + _lifetime.Token); + await RefreshAsync(); + }); + } + + /// + /// Starts a scrub at the given position. + /// + /// + /// From here until , the scrubber shows the + /// dragged value rather than the player's, and any earlier pending seek + /// is forgotten because the user has overridden it. + /// + /// Where the drag started. + public void BeginSeek(TimeSpan position) + { + _pendingSeek = null; + _unconfirmedSeekPolls = 0; + _seekPosition = position; + _isSeeking = true; + OnPropertyChanged(nameof(IsSeeking)); + } + + /// + /// Moves an in-progress scrub. + /// + /// Where the pointer is now. + public void UpdateSeek(TimeSpan position) + { + if (!_isSeeking) + { + return; + } + + _seekPosition = position; + } + + /// + /// Finishes a scrub by sending the final position to the player. + /// + /// + /// The target stays on display as a pending seek until a poll confirms + /// it, so the scrubber does not jump back to the old position for the + /// second it takes the player to catch up. A seek the player rejects is + /// dropped at once, since nothing will ever confirm it. + /// + /// + /// A task that completes once the seek has been sent and the state + /// re-read. Never faults; failures land in . + /// + public async Task EndSeekAsync() + { + if (!_isSeeking) + { + return; + } + + _isSeeking = false; + OnPropertyChanged(nameof(IsSeeking)); + await SeekAsync(_seekPosition); + } + + /// + /// Moves playback of the active session to a position. + /// + /// Target offset from the start of the track. + /// + /// A task that completes once the seek has been sent and the state + /// re-read. Does nothing when no session is active. Never faults; + /// failures land in . + /// + /// + /// + /// await viewModel.SeekAsync(TimeSpan.FromSeconds(90)); + /// + /// + public async Task SeekAsync(TimeSpan position) + { + if (_disposed || _activeSession is not { } session) + { + return; + } + + var target = PlaybackPosition.Clamp(position, session.Duration); + _pendingSeek = target; + _unconfirmedSeekPolls = 0; + + var accepted = await Guarded(() => + _mediaSessionService.SeekAsync(session.PlayerId, target, _lifetime.Token)); + if (!accepted && _pendingSeek == target) + { + _pendingSeek = null; + } + + await RefreshAsync(); + } + + /// + /// Sets the active session's volume. + /// + /// + /// Slider drags arrive faster than a bus round trip, so sends are + /// coalesced: while one is in flight the newest level waits, and only + /// the latest waiting value is sent afterwards. The slider itself is + /// updated immediately so it tracks the pointer. + /// + /// Level from 0 to 100. + /// + /// A task that completes once this level, or a newer one that superseded + /// it, has been sent. Does nothing when no session is active. Never + /// faults; failures land in . + /// + /// + /// + /// await viewModel.SetVolumeAsync(35); + /// + /// + public async Task SetVolumeAsync(int percent) + { + if (_disposed || _activeSession is not { } session) + { + return; + } + + var level = PlayerVolume.Clamp(percent); + Volume = level; + RememberAudibleVolume(session.PlayerId, level); + + if (_volumeSendInFlight) + { + _queuedVolume = level; + return; + } + + _volumeSendInFlight = true; + try + { + int? next = level; + while (next is { } value) + { + _queuedVolume = null; + await Guarded(() => _mediaSessionService.SetVolumeAsync( + session.PlayerId, + PlayerVolume.ToFraction(value), + _lifetime.Token)); + next = _queuedVolume; + } + } + finally + { + _volumeSendInFlight = false; + } + } + + /// + /// Mutes the active session, or restores its last audible level. + /// + /// + /// A task that completes once the new level has been sent. Does nothing + /// when no session is active. + /// + /// + /// + /// muteButton.Click += async (_, _) => await viewModel.ToggleMuteAsync(); + /// + /// + public Task ToggleMuteAsync() + { + if (_activeSession is not { } session) + { + return Task.CompletedTask; + } + + _lastAudibleVolumes.TryGetValue(session.PlayerId, out var lastAudible); + var target = PlayerVolume.MuteToggleTarget( + Volume, + lastAudible > 0 ? lastAudible : null); + return SetVolumeAsync(target); + } + + /// + /// Cancels any in-flight work and blocks further backend calls. + /// + /// + /// Safe to call more than once. The refresh gate is intentionally not + /// disposed: a poll may still be unwinding through its finally block, and + /// releasing a disposed semaphore would throw on a path that has no + /// handler. The disposed flag is what actually stops new work. + /// + public void Dispose() + { + if (_disposed) + { + return; + } + + _disposed = true; + _artworkLoad?.Cancel(); + _artworkLoad?.Dispose(); + _artworkLoad = null; + _lyricsLoad?.Cancel(); + _lyricsLoad?.Dispose(); + _lyricsLoad = null; + _lifetime.Cancel(); + _lifetime.Dispose(); + } + + /// + /// Maps a player id onto the mark the panel draws for it. + /// + /// + /// Matches on the bus-name stem, which browsers extend with an instance + /// suffix and wrapper apps prefix with their own name. + /// + /// Player id, or null for no session. + /// The mark to draw. + /// + /// + /// NowPlayingViewModel.LogoFor("spotify"); // PanelPlayerLogo.Spotify + /// + /// + public static PanelPlayerLogo LogoFor(string? playerId) + { + if (string.IsNullOrEmpty(playerId)) + { + return PanelPlayerLogo.Generic; + } + + if (playerId.Contains("spotify", StringComparison.OrdinalIgnoreCase)) + { + return PanelPlayerLogo.Spotify; + } + + return playerId.Contains("youtube", StringComparison.OrdinalIgnoreCase) + || playerId.Contains("YoutubeMusic", StringComparison.OrdinalIgnoreCase) + ? PanelPlayerLogo.YouTubeMusic + : PanelPlayerLogo.Generic; + } + + /// + /// Chooses the session to display, honouring the user's priorities. + /// + /// + /// Mirrors the macOS store: a remembered player that is playing wins + /// outright, otherwise the kind ordering from settings breaks ties + /// between sessions in the same playback state. + /// + /// Sessions from the latest poll. + /// Player ids that began playing in this poll. + /// The session to display, or null. + private MediaSessionSnapshot? SelectActive(IReadOnlyList sessions, IReadOnlyList started) + { + var preferences = _preferences(); + if (preferences.RemembersLastPlayedPlayer) + { + if (started.Count > 0) + { + var kind = LogoFor(started[0]); + if (!string.Equals(preferences.LastPlayedPlayer, kind.ToString(), StringComparison.OrdinalIgnoreCase)) + { + LastPlayedPlayerChanged?.Invoke(this, kind); + } + } + + if (Enum.TryParse(preferences.LastPlayedPlayer, ignoreCase: true, out var remembered)) + { + var match = sessions.FirstOrDefault(session => + session.Status == PlaybackStatus.Playing + && !string.IsNullOrEmpty(session.Title) + && LogoFor(session.PlayerId) == remembered); + if (match is not null) + { + return match; + } + } + } + + var order = PlayerPriority.Parse(preferences.PlayerDisplayPriority); + return ActiveSessionSelector.Select(sessions, PlayerPriority.PreferredIds(sessions, order)); + } + + /// + /// Notes which players just started, and pauses the others when asked to. + /// + /// + /// A player counts as started when it is playing now but was not in + /// the previous poll. With auto-pause on and more than one player + /// playing, every playing player other than the newest is paused, so + /// two apps never talk over each other. + /// + /// Sessions from the latest poll. + /// + /// Ids of the players that started in this poll, and ids of the players + /// that were just told to pause and should be treated as paused already. + /// + private async Task<(IReadOnlyList Started, IReadOnlyList Paused)> ReconcilePlaybackAsync( + IReadOnlyList sessions) + { + var playing = sessions + .Where(session => session.Status == PlaybackStatus.Playing) + .Select(session => session.PlayerId) + .ToList(); + var started = playing.Where(id => !_previouslyPlaying.Contains(id)).ToList(); + _previouslyPlaying = [.. playing]; + + if (!_preferences().AutomaticallyPausesOtherPlayer || playing.Count < 2) + { + return (started, []); + } + + var keep = started.Count > 0 ? started[0] : playing[0]; + var paused = new List(); + foreach (var id in playing.Where(id => id != keep)) + { + try + { + await _mediaSessionService.SendCommandAsync(id, PlaybackCommand.Pause, _lifetime.Token); + _previouslyPlaying.Remove(id); + paused.Add(id); + } + catch (Exception exception) when (exception is not OperationCanceledException) + { + SetCommandError(exception.Message); + } + } + + return (started, paused); + } + + /// + /// Settings used when no store is supplied. + /// + /// The built-in defaults. + private static DesktopPreferences DefaultPreferences() => new(); + + /// + /// Publishes a freshly selected session and reconciles the derived state. + /// + /// + /// Handles the three things a new sample can invalidate: a pending seek + /// that the sample now confirms or that has waited too long, a volume + /// level the user is not currently holding, and artwork whose URI has + /// changed. A change of track or player clears any seek outright, since + /// a position in the old track means nothing in the new one. + /// + /// The session to display, or null. + private void Apply(MediaSessionSnapshot? session) + { + var identity = session is null + ? null + : string.Join('\0', session.PlayerId, session.Title, session.Album, session.Artist); + if (identity != _trackIdentity) + { + _trackIdentity = identity; + _isSeeking = false; + _pendingSeek = null; + _unconfirmedSeekPolls = 0; + } + + ActiveSession = session; + ReconcilePendingSeek(session); + ReconcileVolume(session); + ReconcileArtwork(session?.ArtworkUri); + ReconcileLyrics(session); + } + + /// + /// Starts a lyrics lookup when the track changes, serving repeats from cache. + /// + /// + /// Keyed by the loose so a player that + /// re-reports the same song with cosmetic differences does not trigger + /// another network round trip. Misses are cached too, since asking + /// again a second later will not make lyrics appear. + /// + /// Latest sample, or null. + private void ReconcileLyrics(MediaSessionSnapshot? session) + { + if (_lyricsService is null) + { + return; + } + + var query = session is null ? null : LyricsTrackQuery.From(session); + if (Equals(query, _lyricsQuery) && (query is null || _lyricsState != LyricsLoadState.Idle)) + { + return; + } + + _lyricsLoad?.Cancel(); + _lyricsLoad?.Dispose(); + _lyricsLoad = null; + _lyricsQuery = query; + + if (query is null) + { + SetLyrics(null, LyricsLoadState.Idle); + return; + } + + if (_lyricsCache.TryGetValue(query, out var cached)) + { + SetLyrics(cached, cached is null ? LyricsLoadState.Unavailable : LyricsLoadState.Available); + return; + } + + SetLyrics(null, LyricsLoadState.Loading); + var load = CancellationTokenSource.CreateLinkedTokenSource(_lifetime.Token); + _lyricsLoad = load; + _ = LoadLyricsAsync(query, load.Token); + } + + /// + /// Fetches lyrics and publishes them if the track is still current. + /// + /// Track to look up. + /// Cancelled when the track changes. + /// A task that completes when the result has been applied or dropped. + private async Task LoadLyricsAsync(LyricsTrackQuery query, CancellationToken cancellationToken) + { + SyncedLyrics? lyrics; + try + { + lyrics = await _lyricsService!.FetchSyncedLyricsAsync(query, cancellationToken); + } + catch (OperationCanceledException) + { + return; + } + catch (Exception) + { + lyrics = null; + } + + if (cancellationToken.IsCancellationRequested) + { + return; + } + + _lyricsCache[query] = lyrics; + if (Equals(query, _lyricsQuery)) + { + SetLyrics(lyrics, lyrics is null ? LyricsLoadState.Unavailable : LyricsLoadState.Available); + } + } + + /// + /// Publishes a lyrics result and its state together. + /// + /// Lyrics to show, or null. + /// Lookup state to report. + private void SetLyrics(SyncedLyrics? lyrics, LyricsLoadState state) + { + var changed = !ReferenceEquals(_lyrics, lyrics) || _lyricsState != state; + _lyrics = lyrics; + _lyricsState = state; + if (changed) + { + OnPropertyChanged(nameof(Lyrics)); + OnPropertyChanged(nameof(LyricsState)); + } + } + + /// + /// Drops a pending seek once the player reflects it or stops waiting. + /// + /// Latest sample, or null. + private void ReconcilePendingSeek(MediaSessionSnapshot? session) + { + if (_pendingSeek is not { } pending || _isSeeking) + { + return; + } + + var confirmed = session?.Position is { } actual + && PlaybackPosition.ConfirmsSeek(actual, pending); + if (confirmed || ++_unconfirmedSeekPolls >= MaximumUnconfirmedSeekPolls) + { + _pendingSeek = null; + _unconfirmedSeekPolls = 0; + } + } + + /// + /// Follows the player's volume unless the user is holding the slider. + /// + /// Latest sample, or null. + private void ReconcileVolume(MediaSessionSnapshot? session) + { + if (session?.Volume is not { } fraction || _isVolumeEditing || _volumeSendInFlight) + { + return; + } + + var level = PlayerVolume.FromFraction(fraction); + Volume = level; + RememberAudibleVolume(session.PlayerId, level); + } + + /// + /// Starts loading artwork when its URI changes, cancelling any prior load. + /// + /// Artwork location of the current session, or null. + private void ReconcileArtwork(Uri? uri) + { + if (uri == _artworkUri) + { + return; + } + + _artworkLoad?.Cancel(); + _artworkLoad?.Dispose(); + _artworkLoad = null; + _artworkUri = uri; + + if (uri is null) + { + ArtworkData = null; + return; + } + + var load = CancellationTokenSource.CreateLinkedTokenSource(_lifetime.Token); + _artworkLoad = load; + _ = LoadArtworkAsync(uri, load.Token); + } + + /// + /// Fetches artwork and publishes it if it is still the wanted one. + /// + /// Location to load. + /// Cancelled when a newer URI supersedes this one. + /// A task that completes when the artwork has been applied or dropped. + private async Task LoadArtworkAsync(Uri uri, CancellationToken cancellationToken) + { + byte[]? bytes; + try + { + bytes = await _artworkLoader.LoadAsync(uri, cancellationToken); + } + catch (OperationCanceledException) + { + return; + } + + if (!cancellationToken.IsCancellationRequested && uri == _artworkUri) + { + ArtworkData = bytes; + } + } + + /// + /// Records a non-zero level so a later unmute can restore it. + /// + /// Player the level belongs to. + /// Level in percent. + private void RememberAudibleVolume(string playerId, int level) + { + if (level > PlayerVolume.Minimum) + { + _lastAudibleVolumes[playerId] = level; + } + } + + /// + /// Runs a command, routing its failure into . + /// + /// Backend call to run. + /// + /// A task that never faults, yielding true when the command succeeded. + /// + private async Task Guarded(Func action) + { + try + { + await action(); + SetCommandError(null); + return true; + } + catch (OperationCanceledException) when (_lifetime.IsCancellationRequested) + { + return false; + } + catch (Exception exception) + { + SetCommandError(exception.Message); + return false; + } + } + + /// + /// Updates the poll error and notifies if the visible message changed. + /// + /// New poll error, or null. + private void SetPollError(string? message) + { + var before = ErrorText; + _pollError = message; + if (before != ErrorText) + { + OnPropertyChanged(nameof(ErrorText)); + } + } + + /// + /// Updates the command error and notifies if the visible message changed. + /// + /// New command error, or null. + private void SetCommandError(string? message) + { + var before = ErrorText; + _commandError = message; + if (before != ErrorText) + { + OnPropertyChanged(nameof(ErrorText)); + } + } + + /// + /// Raises for the calling property. + /// + /// + /// Filled in by the compiler from the calling member's name. + /// + private void OnPropertyChanged([CallerMemberName] string? propertyName = null) + { + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); + } +} diff --git a/src/Reprise.Desktop/NowPlayingWindow.cs b/src/Reprise.Desktop/NowPlayingWindow.cs new file mode 100644 index 0000000..66679f6 --- /dev/null +++ b/src/Reprise.Desktop/NowPlayingWindow.cs @@ -0,0 +1,1109 @@ +using System.ComponentModel; +using System.Reflection; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.Primitives; +using Avalonia.Controls.Primitives.PopupPositioning; +using Avalonia.Input; +using Avalonia.Layout; +using Avalonia.Media; +using Avalonia.Media.Imaging; +using Avalonia.Platform; +using Avalonia.Styling; +using Avalonia.Threading; +using Reprise.Core; + +namespace Reprise.Desktop; + +/// +/// The player panel: artwork, title, transport, scrubber, and footer. +/// +/// +/// A port of the macOS PlayerPopoverView to Avalonia, laid out to the +/// same proportions: a 360-wide borderless panel with 16-point corners, a +/// card whose cover matches its text column, a 136-point lyrics strip when +/// synced lyrics exist, and a 30-point footer. It behaves like a popover too - +/// it hides when it loses focus or on Escape, and the tray icon brings it +/// back - rather than like a document window. +/// +/// Built in code rather than XAML so the desktop assembly stays a plain +/// library with no compiled markup, which keeps the platform heads that +/// reference it free of Avalonia's XAML build steps. Controls are updated +/// by pushing values from rather than through +/// bindings; at this size that is less machinery and easier to follow. +/// +/// +public sealed class NowPlayingWindow : Window +{ + /// + /// Width of the panel, matching PlayerPanelLayout.defaultSize. + /// + public const double PanelWidth = 360; + + /// + /// Corner radius of the panel, matching PlayerPanelLayout.cornerRadius. + /// + public const double PanelCornerRadius = 16; + + /// + /// Smallest the album cover is allowed to be. + /// + /// + /// The cover is square and as tall as the text column beside it, so its + /// size follows the type scale rather than a fixed number. This floor + /// only matters before the column has been measured. + /// + private const double MinimumArtworkSize = 72; + + /// + /// Gap kept between the panel and the edge of the screen when first shown. + /// + private const int ScreenMargin = 6; + + private static readonly Color WarningColor = Color.FromRgb(0xFF, 0x95, 0x00); + + /// + /// How long after being shown the panel may take its focus back. + /// + /// + /// Clicking a tray icon hands focus to the desktop shell, and some + /// shells keep it: the panel appears, loses focus in the same instant, + /// and hides itself again, so the icon looks like it needs a second + /// click. Within this window the panel asks for focus once instead of + /// hiding; after it, a focus loss dismisses the panel as usual. + /// + private static readonly TimeSpan FocusGracePeriod = TimeSpan.FromMilliseconds(600); + + private readonly NowPlayingViewModel _viewModel; + private readonly PreferencesStore _preferences; + private readonly DispatcherTimer _refreshTimer; + private readonly DispatcherTimer _progressTimer; + private readonly Border _root; + private readonly ContentControl _body; + private readonly Grid _card; + private readonly Border _artworkFrame; + private readonly Image _artworkImage; + private readonly Border _artworkPlaceholder; + private readonly PanelGlyph _artworkGlyph; + private readonly PanelTitleMarquee _title; + private readonly TextBlock _artist; + private readonly PanelGlyph _playerLogo; + private readonly PanelIconButton _previousButton; + private readonly PanelIconButton _playPauseButton; + private readonly PanelIconButton _nextButton; + private readonly CompactSlider _seekSlider; + private readonly TextBlock _leadingTime; + private readonly TextBlock _trailingTime; + private readonly StackPanel _empty; + private readonly PanelGlyph _emptyIcon; + private readonly TextBlock _emptyTitle; + private readonly TextBlock _emptyDescription; + private readonly PanelLyricsView _lyricsView; + private readonly Border _errorBanner; + private readonly PanelGlyph _errorIcon; + private readonly TextBlock _errorText; + private readonly Border _footerRule; + private readonly Grid _footer; + private readonly TextBlock _versionText; + private readonly PanelIconButton _volumeButton; + private readonly PanelIconButton _settingsButton; + private readonly PanelIconButton _exitButton; + private readonly Popup _volumePopup; + private readonly Border _volumePanel; + private readonly PanelIconButton _muteButton; + private readonly CompactSlider _volumeSlider; + private readonly TextBlock _volumeValue; + private PanelPalette _palette; + private Bitmap? _artworkBitmap; + private byte[]? _artworkBytes; + private bool _wasActivated; + private bool _positioned; + private DateTimeOffset _shownAt; + private bool _reclaimedFocus; + + /// + /// Whether a close request should actually close the window. + /// + /// + /// False during normal use, so a close request hides the panel to the + /// tray instead. Quitting sets this to true first, which is the only way + /// the window is allowed to close for real. + /// + public bool AllowClose { get; set; } + + /// + /// Raised when the user presses the footer's exit button. + /// + public event EventHandler? ExitRequested; + + /// + /// Raised when the user presses the footer's gear button. + /// + public event EventHandler? SettingsRequested; + + + + /// + /// Builds the panel and starts polling for playback state. + /// + /// + /// Takes ownership of the view model: the window disposes it when it + /// finally closes. Preferences are observed for the lifetime of the + /// window so a theme change from the settings menu repaints at once. + /// + /// State to display and command. + /// Panel settings to honour and edit. + /// + /// + /// desktop.MainWindow = new NowPlayingWindow( + /// new NowPlayingViewModel(service), + /// new PreferencesStore(PreferencesStore.DefaultPath)); + /// + /// + public NowPlayingWindow(NowPlayingViewModel viewModel, PreferencesStore preferences) + { + _viewModel = viewModel; + _preferences = preferences; + _palette = PanelPalette.Resolve( + preferences.Current.PanelTheme, + systemIsDark: true, + Colors.DodgerBlue, + PanelTranslucency.None); + + Title = "Reprise"; + Width = PanelWidth; + SizeToContent = SizeToContent.Height; + CanResize = false; + ShowInTaskbar = false; + Topmost = true; + WindowDecorations = WindowDecorations.None; + TransparencyLevelHint = + [ + WindowTransparencyLevel.Blur, + WindowTransparencyLevel.Transparent, + WindowTransparencyLevel.None, + ]; + Background = Brushes.Transparent; + + _artworkImage = new Image { Stretch = Stretch.UniformToFill }; + _artworkGlyph = new PanelGlyph + { + IconSize = Math.Max(MinimumArtworkSize * 0.32, 10), + HorizontalAlignment = HorizontalAlignment.Center, + VerticalAlignment = VerticalAlignment.Center, + }; + _artworkPlaceholder = new Border { Child = _artworkGlyph }; + _artworkFrame = new Border + { + Width = MinimumArtworkSize, + Height = MinimumArtworkSize, + CornerRadius = new CornerRadius(10), + ClipToBounds = true, + VerticalAlignment = VerticalAlignment.Top, + Child = new Panel { Children = { _artworkPlaceholder, _artworkImage } }, + }; + + _title = new PanelTitleMarquee { Height = 16, FontSize = PanelTypography.Title }; + _artist = new TextBlock + { + FontSize = PanelTypography.Subtitle, + TextTrimming = TextTrimming.CharacterEllipsis, + }; + _playerLogo = new PanelGlyph + { + Width = 21, + Height = 21, + Margin = new Thickness(8, 0, 0, 0), + VerticalAlignment = VerticalAlignment.Top, + }; + + _previousButton = CreateControlButton(PanelIcons.Backward, 19, "이전 곡"); + _playPauseButton = CreateControlButton(PanelIcons.Play, 25, "재생"); + _nextButton = CreateControlButton(PanelIcons.Forward, 19, "다음 곡"); + _previousButton.Click += async (_, _) => await _viewModel.SendAsync(PlaybackCommand.Previous); + _playPauseButton.Click += async (_, _) => await _viewModel.SendAsync(PlaybackCommand.PlayPause); + _nextButton.Click += async (_, _) => await _viewModel.SendAsync(PlaybackCommand.Next); + + _seekSlider = new CompactSlider { Minimum = 0, Maximum = 1 }; + _seekSlider.DragStarted += (_, _) => _viewModel.BeginSeek(TimeSpan.FromSeconds(_seekSlider.Value)); + _seekSlider.UserValueChanged += (_, value) => _viewModel.UpdateSeek(TimeSpan.FromSeconds(value)); + _seekSlider.DragCompleted += async (_, _) => await _viewModel.EndSeekAsync(); + ToolTip.SetTip(_seekSlider, "재생 위치 이동"); + _leadingTime = new TextBlock { FontSize = PanelTypography.Caption }; + _trailingTime = new TextBlock { FontSize = PanelTypography.Caption, HorizontalAlignment = HorizontalAlignment.Right }; + + _card = BuildCard(); + + _emptyIcon = new PanelGlyph + { + IconSize = 40, + HorizontalAlignment = HorizontalAlignment.Center, + }; + _emptyTitle = new TextBlock + { + FontSize = PanelTypography.Heading, + FontWeight = FontWeight.SemiBold, + TextAlignment = TextAlignment.Center, + TextWrapping = TextWrapping.Wrap, + }; + _emptyDescription = new TextBlock + { + FontSize = PanelTypography.Subtitle, + TextAlignment = TextAlignment.Center, + TextWrapping = TextWrapping.Wrap, + }; + _empty = new StackPanel + { + Spacing = 6, + Margin = new Thickness(16), + MinHeight = 150, + VerticalAlignment = VerticalAlignment.Center, + Children = { _emptyIcon, _emptyTitle, _emptyDescription }, + }; + + _body = new ContentControl + { + HorizontalContentAlignment = HorizontalAlignment.Stretch, + VerticalContentAlignment = VerticalAlignment.Stretch, + }; + _lyricsView = new PanelLyricsView { IsVisible = false }; + + _errorIcon = new PanelGlyph + { + Icon = PanelIcons.Warning, + IconSize = 13, + Foreground = new SolidColorBrush(WarningColor), + VerticalAlignment = VerticalAlignment.Top, + Margin = new Thickness(0, 1, 0, 0), + }; + _errorText = new TextBlock + { + FontSize = PanelTypography.Caption, + TextWrapping = TextWrapping.Wrap, + TextTrimming = TextTrimming.CharacterEllipsis, + MaxLines = 3, + Margin = new Thickness(8, 0, 0, 0), + }; + _errorBanner = new Border + { + Margin = new Thickness(14, 0, 14, 14), + Padding = new Thickness(10), + CornerRadius = new CornerRadius(9), + Background = new SolidColorBrush(PanelPalette.WithAlpha(WarningColor, 0.12)), + Child = new Grid + { + ColumnDefinitions = new ColumnDefinitions("Auto,*"), + Children = { _errorIcon, _errorText }, + }, + IsVisible = false, + }; + Grid.SetColumn(_errorText, 1); + + _versionText = new TextBlock + { + FontSize = PanelTypography.Caption, + Text = VersionText(), + VerticalAlignment = VerticalAlignment.Center, + }; + _volumeButton = CreateFooterButton(PanelIcons.SpeakerWave3, "음량"); + _settingsButton = CreateFooterButton(PanelIcons.Gear, "설정 열기"); + _exitButton = CreateFooterButton(PanelIcons.Exit, "Reprise 종료"); + _volumeButton.Click += (_, _) => ToggleVolumePopup(); + _settingsButton.Click += (_, _) => SettingsRequested?.Invoke(this, EventArgs.Empty); + _exitButton.Click += (_, _) => ExitRequested?.Invoke(this, EventArgs.Empty); + _footerRule = new Border { Height = 1 }; + _footer = new Grid + { + Height = 30, + Margin = new Thickness(14, 0), + ColumnDefinitions = new ColumnDefinitions("*,Auto,Auto,Auto"), + ColumnSpacing = 8, + Children = { _versionText, _volumeButton, _settingsButton, _exitButton }, + }; + Grid.SetColumn(_volumeButton, 1); + Grid.SetColumn(_settingsButton, 2); + Grid.SetColumn(_exitButton, 3); + + _muteButton = new PanelIconButton + { + Width = 18, + Height = 22, + IconSize = 11, + Icon = PanelIcons.SpeakerWave3, + }; + _muteButton.Click += async (_, _) => await _viewModel.ToggleMuteAsync(); + _volumeSlider = new CompactSlider { Minimum = 0, Maximum = 100, VerticalAlignment = VerticalAlignment.Center }; + _volumeSlider.DragStarted += (_, _) => _viewModel.IsVolumeEditing = true; + _volumeSlider.UserValueChanged += async (_, value) => + await _viewModel.SetVolumeAsync((int)Math.Round(value)); + _volumeSlider.DragCompleted += (_, _) => _viewModel.IsVolumeEditing = false; + _volumeValue = new TextBlock + { + FontSize = PanelTypography.Caption, + Width = 23, + TextAlignment = TextAlignment.Right, + VerticalAlignment = VerticalAlignment.Center, + }; + var volumeRow = new Grid + { + ColumnDefinitions = new ColumnDefinitions("Auto,*,Auto"), + ColumnSpacing = 8, + Children = { _muteButton, _volumeSlider, _volumeValue }, + }; + Grid.SetColumn(_volumeSlider, 1); + Grid.SetColumn(_volumeValue, 2); + _volumePanel = new Border + { + Width = 160, + Height = 36, + CornerRadius = new CornerRadius(10), + BorderThickness = new Thickness(1), + Padding = new Thickness(10, 0), + Child = volumeRow, + }; + _volumePopup = new Popup + { + Child = _volumePanel, + PlacementTarget = _footer, + Placement = PlacementMode.AnchorAndGravity, + PlacementAnchor = PopupAnchor.TopRight, + PlacementGravity = PopupGravity.TopLeft, + VerticalOffset = -6, + IsLightDismissEnabled = true, + }; + _volumePopup.Closed += (_, _) => _viewModel.IsVolumeEditing = false; + + _root = new Border + { + CornerRadius = new CornerRadius(PanelCornerRadius), + ClipToBounds = true, + Child = new Panel + { + Children = + { + new StackPanel + { + Children = { _body, _lyricsView, _errorBanner, _footerRule, _footer }, + }, + _volumePopup, + }, + }, + }; + _root.PointerPressed += HandleRootPointerPressed; + Content = _root; + + _refreshTimer = new DispatcherTimer( + TimeSpan.FromSeconds(1), + DispatcherPriority.Background, + async (_, _) => await _viewModel.RefreshAsync()); + _progressTimer = new DispatcherTimer( + TimeSpan.FromMilliseconds(250), + DispatcherPriority.Background, + (_, _) => UpdateProgress()); + + _viewModel.PropertyChanged += HandleViewModelChanged; + _preferences.Changed += HandlePreferencesChanged; + Opened += HandleOpened; + Activated += (_, _) => _wasActivated = true; + Deactivated += HandleDeactivated; + KeyDown += HandleKeyDown; + Closing += HandleClosing; + Closed += HandleClosed; + + ApplyPalette(); + UpdateView(); + _refreshTimer.Start(); + _progressTimer.Start(); + } + + /// + /// Shows the panel, placing it by the tray corner the first time. + /// + /// + /// Linux offers no way to learn where the tray icon is, so the first + /// placement is the top-right of the primary screen, where most desktops + /// keep their status area. The user can drag the panel anywhere and it + /// keeps that position for later shows. + /// + /// + /// + /// trayIcon.Clicked += (_, _) => window.ShowPanel(); + /// + /// + public void ShowPanel() + { + if (!_positioned) + { + PositionByTray(); + } + + _shownAt = DateTimeOffset.UtcNow; + _reclaimedFocus = false; + Show(); + Activate(); + if (_preferences.Current.ResetsMenuTitleWhenPanelOpens) + { + _title.RestartScroll(); + } + + _ = _viewModel.RefreshAsync(); + } + + /// + /// Hides the panel to the tray, closing any pop-ups it owns. + /// + public void HidePanel() + { + _volumePopup.IsOpen = false; + _wasActivated = false; + Hide(); + } + + /// + /// Shows the panel if hidden, hides it if visible. + /// + /// + /// + /// trayIcon.Clicked += (_, _) => window.TogglePanel(); + /// + /// + public void TogglePanel() + { + if (IsVisible) + { + HidePanel(); + } + else + { + ShowPanel(); + } + } + + /// + /// Repaints when the window's transparency is finally known. + /// + /// + /// The windowing system reports what it granted only after the window + /// exists, and the Liquid theme's opacity depends on the answer. + /// + /// Property change details. + protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) + { + base.OnPropertyChanged(change); + if (change.Property == ActualTransparencyLevelProperty) + { + ApplyPalette(); + } + } + + /// + /// Assembles the card shown while a track is loaded. + /// + /// + /// Artwork on the left, then a column of title and artist with the + /// player mark at its top-right, the transport centred, and the scrubber + /// with its two time labels at the bottom - the macOS card exactly. + /// + /// The column sizes itself to its content and the cover follows, rather + /// than both being pinned to one number: the two then stay exactly as + /// tall as each other whatever the type scale, and the card has no slack + /// to spread between its rows. + /// + /// + /// The card's content tree. + private Grid BuildCard() + { + var titles = new StackPanel + { + Spacing = 2, + Children = { _title, _artist }, + }; + var header = new Grid + { + ColumnDefinitions = new ColumnDefinitions("*,Auto"), + Children = { titles, _playerLogo }, + }; + Grid.SetColumn(_playerLogo, 1); + + var controls = new StackPanel + { + Orientation = Orientation.Horizontal, + Spacing = 28, + HorizontalAlignment = HorizontalAlignment.Center, + VerticalAlignment = VerticalAlignment.Center, + Children = { _previousButton, _playPauseButton, _nextButton }, + }; + + var times = new Grid + { + ColumnDefinitions = new ColumnDefinitions("*,Auto"), + Children = { _leadingTime, _trailingTime }, + }; + Grid.SetColumn(_trailingTime, 1); + var progress = new StackPanel + { + Spacing = 1, + Children = { _seekSlider, times }, + }; + + var column = new Grid + { + RowDefinitions = new RowDefinitions("Auto,Auto,Auto"), + RowSpacing = 6, + Margin = new Thickness(12, 0, 0, 0), + Children = { header, controls, progress }, + }; + Grid.SetRow(controls, 1); + Grid.SetRow(progress, 2); + column.PropertyChanged += (_, e) => + { + if (e.Property == BoundsProperty) + { + ResizeArtwork(column.Bounds.Height); + } + }; + + var card = new Grid + { + ColumnDefinitions = new ColumnDefinitions("Auto,*"), + Margin = new Thickness(14), + Children = { _artworkFrame, column }, + }; + Grid.SetColumn(column, 1); + return card; + } + + /// + /// Squares the album cover off against the height of the text column. + /// + /// Measured height of the column beside it. + private void ResizeArtwork(double height) + { + var size = Math.Round(Math.Max(height, MinimumArtworkSize)); + if (Math.Abs(_artworkFrame.Width - size) < 0.5) + { + return; + } + + _artworkFrame.Width = size; + _artworkFrame.Height = size; + _artworkFrame.CornerRadius = new CornerRadius(Math.Round(size * 0.1)); + _artworkGlyph.IconSize = Math.Max(size * 0.32, 10); + } + + /// + /// Creates one of the three transport buttons. + /// + /// Glyph to draw. + /// Glyph size; larger for play/pause. + /// Tooltip text. + /// A 36 by 34 chrome-less button. + private static PanelIconButton CreateControlButton(Geometry icon, double iconSize, string label) + { + var button = new PanelIconButton + { + Width = 36, + Height = 34, + Icon = icon, + IconSize = iconSize, + }; + ToolTip.SetTip(button, label); + return button; + } + + /// + /// Creates one of the footer's small buttons. + /// + /// Glyph to draw. + /// Tooltip text. + /// A 19 by 19 chrome-less button. + private static PanelIconButton CreateFooterButton(Geometry icon, string label) + { + var button = new PanelIconButton + { + Width = 19, + Height = 19, + Icon = icon, + IconSize = 13, + VerticalAlignment = VerticalAlignment.Center, + }; + ToolTip.SetTip(button, label); + return button; + } + + /// + /// Formats the footer's version line. + /// + /// + /// Reads the informational version so prerelease suffixes survive, and + /// drops the build metadata the SDK appends after a plus sign. + /// + /// Text such as Reprise v2.0.0-alpha.1. + private static string VersionText() + { + var informational = typeof(NowPlayingWindow).Assembly + .GetCustomAttribute()? + .InformationalVersion; + var version = string.IsNullOrWhiteSpace(informational) + ? typeof(NowPlayingWindow).Assembly.GetName().Version?.ToString(3) ?? "-" + : informational.Split('+', 2)[0]; + return $"Reprise v{version}"; + } + + /// + /// Places the panel at the top-right of the primary screen. + /// + private void PositionByTray() + { + var screen = Screens.Primary ?? Screens.All.FirstOrDefault(); + if (screen is null) + { + return; + } + + var area = screen.WorkingArea; + var scale = screen.Scaling; + var width = (int)Math.Round(PanelWidth * scale); + var margin = (int)Math.Round(ScreenMargin * scale); + Position = new PixelPoint( + Math.Max(area.X, area.Right - width - margin), + area.Y + margin); + _positioned = true; + } + + /// + /// Subscribes to desktop colour changes once the platform is reachable. + /// + /// The window. + /// Unused. + private void HandleOpened(object? sender, EventArgs e) + { + if (Application.Current?.PlatformSettings is { } settings) + { + settings.ColorValuesChanged += (_, _) => Dispatcher.UIThread.Post(ApplyPalette); + } + + ApplyPalette(); + } + + /// + /// Hides the panel when the user moves on, as a popover would. + /// + /// + /// Only after the window has actually been active once: a desktop that + /// never grants activation would otherwise hide the panel the instant it + /// appeared. Open pop-ups keep the panel, since dismissing them is what + /// took the focus. A focus loss in the first moments after the panel + /// appears is treated as the shell finishing its own click rather than + /// as the user moving on; see . + /// + /// The window. + /// Unused. + private void HandleDeactivated(object? sender, EventArgs e) + { + if (!_wasActivated || _volumePopup.IsOpen) + { + return; + } + + if (!_reclaimedFocus && DateTimeOffset.UtcNow - _shownAt < FocusGracePeriod) + { + _reclaimedFocus = true; + Activate(); + return; + } + + HidePanel(); + } + + /// + /// Lets Escape dismiss the panel. + /// + /// The window. + /// Key event. + private void HandleKeyDown(object? sender, KeyEventArgs e) + { + if (e.Key == Key.Escape) + { + HidePanel(); + e.Handled = true; + } + } + + /// + /// Turns a close request into a hide unless quitting. + /// + /// The window. + /// Close event, cancelled to hide instead. + private void HandleClosing(object? sender, WindowClosingEventArgs e) + { + if (AllowClose) + { + return; + } + + e.Cancel = true; + HidePanel(); + } + + /// + /// Stops the timers and releases the view model on a real close. + /// + /// The window. + /// Unused. + private void HandleClosed(object? sender, EventArgs e) + { + _refreshTimer.Stop(); + _progressTimer.Stop(); + _viewModel.PropertyChanged -= HandleViewModelChanged; + _preferences.Changed -= HandlePreferencesChanged; + _viewModel.Dispose(); + _artworkBitmap?.Dispose(); + } + + /// + /// Lets the user drag the borderless panel by any empty area. + /// + /// + /// Buttons and sliders mark their presses handled, so a press that + /// reaches here landed on inert content. + /// + /// The root border. + /// Pointer event. + private void HandleRootPointerPressed(object? sender, PointerPressedEventArgs e) + { + if (e.Handled || !e.GetCurrentPoint(_root).Properties.IsLeftButtonPressed) + { + return; + } + + BeginMoveDrag(e); + } + + /// + /// Marshals a view model change onto the UI thread and redraws. + /// + /// + /// The view model raises its event from whichever thread completed its + /// backend call, so the update is posted rather than applied directly. + /// Which property changed is ignored: rewrites + /// the whole panel, and posting one redraw per notification coalesces + /// naturally. + /// + /// The view model. + /// Name of the changed property; unused. + private void HandleViewModelChanged(object? sender, PropertyChangedEventArgs e) + { + Dispatcher.UIThread.Post(UpdateView); + } + + /// + /// Repaints after a settings change. + /// + /// The preferences store. + /// Unused. + private void HandlePreferencesChanged(object? sender, EventArgs e) + { + ApplyPalette(); + UpdateView(); + } + + /// + /// Resolves the palette for the current theme and pushes it to every control. + /// + /// + /// Also sets the window's theme variant, so the settings menu and tooltips + /// - which come from the Fluent theme - match the panel rather than the + /// desktop. Rounded corners need a see-through window; where the desktop + /// grants none, the corners are squared off and the fallback fill is + /// matched to the panel, since Avalonia would otherwise paint the area + /// outside the rounding white. + /// + private void ApplyPalette() + { + var colours = Application.Current?.PlatformSettings?.GetColorValues(); + var systemIsDark = colours?.ThemeVariant == PlatformThemeVariant.Dark; + var accent = colours?.AccentColor1 ?? Colors.DodgerBlue; + if (accent.A == 0) + { + accent = Colors.DodgerBlue; + } + + var translucency = ActualTransparencyLevel == WindowTransparencyLevel.Blur + || ActualTransparencyLevel == WindowTransparencyLevel.AcrylicBlur + || ActualTransparencyLevel == WindowTransparencyLevel.Mica + ? PanelTranslucency.Blurred + : ActualTransparencyLevel == WindowTransparencyLevel.Transparent + ? PanelTranslucency.Transparent + : PanelTranslucency.None; + + _palette = PanelPalette.Resolve( + _preferences.Current.PanelTheme, + systemIsDark, + accent, + translucency); + RequestedThemeVariant = _palette.IsDark ? ThemeVariant.Dark : ThemeVariant.Light; + + var primary = new SolidColorBrush(_palette.Primary); + var secondary = new SolidColorBrush(_palette.Secondary); + var control = new SolidColorBrush(_palette.Control); + var controlPressed = new SolidColorBrush(_palette.ControlPressed); + var separator = new SolidColorBrush(_palette.Separator); + var track = new SolidColorBrush(_palette.Track); + var accentBrush = new SolidColorBrush(_palette.Accent); + + _root.Background = new SolidColorBrush(_palette.Background); + _root.BorderThickness = new Thickness(_preferences.Current.PanelTheme == PanelTheme.Liquid ? 1 : 0); + _root.BorderBrush = new SolidColorBrush(PanelPalette.WithAlpha(_palette.Primary, 0.08)); + _root.CornerRadius = new CornerRadius(translucency == PanelTranslucency.None ? 0 : PanelCornerRadius); + TransparencyBackgroundFallback = new SolidColorBrush(PanelPalette.WithAlpha(_palette.Background, 1)); + + _artworkPlaceholder.Background = new LinearGradientBrush + { + StartPoint = new RelativePoint(0, 0, RelativeUnit.Relative), + EndPoint = new RelativePoint(1, 1, RelativeUnit.Relative), + GradientStops = + { + new GradientStop(PanelPalette.WithAlpha(_palette.Accent, 0.68), 0), + new GradientStop(PanelPalette.WithAlpha(_palette.Accent, 0.24), 1), + }, + }; + _artworkGlyph.Foreground = new SolidColorBrush(PanelPalette.WithAlpha(Colors.White, 0.9)); + + _title.Foreground = primary; + _title.AutomaticallyScrolls = _preferences.Current.AutomaticallyScrollsTitles; + _title.PointsPerSecond = _preferences.Current.MarqueePointsPerSecond; + _artist.Foreground = secondary; + _playerLogo.Foreground = primary; + + foreach (var button in new[] { _previousButton, _playPauseButton, _nextButton }) + { + button.Foreground = control; + button.PressedForeground = controlPressed; + } + + _seekSlider.TrackBrush = track; + _seekSlider.FillBrush = accentBrush; + _leadingTime.Foreground = secondary; + _trailingTime.Foreground = secondary; + + _emptyIcon.Foreground = secondary; + _emptyTitle.Foreground = primary; + _emptyDescription.Foreground = secondary; + _errorText.Foreground = primary; + _lyricsView.Foreground = primary; + _lyricsView.SeparatorBrush = separator; + + _footerRule.Background = separator; + _versionText.Foreground = secondary; + foreach (var button in new[] { _volumeButton, _settingsButton, _exitButton, _muteButton }) + { + button.Foreground = secondary; + button.PressedForeground = new SolidColorBrush(PanelPalette.WithAlpha(_palette.Primary, 0.35)); + } + + _volumePanel.Background = new SolidColorBrush(_palette.FloatingBackground); + _volumePanel.BorderBrush = separator; + _volumeSlider.TrackBrush = track; + _volumeSlider.FillBrush = accentBrush; + _volumeValue.Foreground = secondary; + } + + /// + /// Rewrites every control from the current view model state. + /// + /// + /// Chooses between the card and the empty state, then fills whichever is + /// showing. Must be called on the UI thread. + /// + private void UpdateView() + { + var session = _viewModel.ActiveSession; + var error = _viewModel.ErrorText; + + if (session is not null) + { + _body.Content = _card; + UpdateCard(session); + } + else + { + _body.Content = _empty; + UpdateEmpty(error); + } + + _errorBanner.IsVisible = session is not null && error is not null; + _errorText.Text = error ?? string.Empty; + + var lyrics = session is null ? null : _viewModel.Lyrics; + _lyricsView.Lyrics = lyrics; + _lyricsView.IsVisible = lyrics is not null; + + var volume = _viewModel.Volume; + _volumeButton.Icon = PanelIcons.Speaker(volume); + _volumeButton.IsEnabled = _viewModel.HasVolume; + _muteButton.Icon = PanelIcons.Speaker(volume); + if (!_volumeSlider.IsDragging) + { + _volumeSlider.Value = volume; + } + + _volumeValue.Text = volume.ToString(); + ToolTip.SetTip(_volumeButton, $"{session?.PlayerName ?? "플레이어"} 음량 {volume}%"); + ToolTip.SetTip(_muteButton, volume > 0 ? "음소거" : "음소거 해제"); + if (!_viewModel.HasVolume) + { + _volumePopup.IsOpen = false; + } + + UpdateArtwork(_viewModel.ArtworkData); + UpdateProgress(); + } + + /// + /// Fills the card from a session. + /// + /// + /// The transport stays enabled whenever there is a session, including + /// after a failed command: the macOS panel only disables it when the + /// player itself is unreachable, which here means there is no session + /// at all. + /// + /// Session on display. + private void UpdateCard(MediaSessionSnapshot session) + { + _title.Title = string.IsNullOrWhiteSpace(session.Title) ? "제목 없음" : session.Title; + _artist.Text = session.Artist; + _artist.IsVisible = !string.IsNullOrEmpty(session.Artist); + + var logo = _viewModel.PlayerLogo; + _playerLogo.Icon = logo switch + { + PanelPlayerLogo.Spotify => PanelIcons.SpotifyLogo, + PanelPlayerLogo.YouTubeMusic => PanelIcons.YouTubeMusicLogo, + _ => PanelIcons.MusicNote, + }; + _playerLogo.IconSize = logo == PanelPlayerLogo.Generic ? 17 : 21; + ToolTip.SetTip(_playerLogo, session.PlayerName); + _artworkGlyph.Icon = logo switch + { + PanelPlayerLogo.Spotify => PanelIcons.WaveformCircle, + PanelPlayerLogo.YouTubeMusic => PanelIcons.PlayRectangle, + _ => PanelIcons.MusicNote, + }; + + var playing = _viewModel.IsPlaying; + _playPauseButton.Icon = playing ? PanelIcons.Pause : PanelIcons.Play; + ToolTip.SetTip(_playPauseButton, playing ? "일시정지" : "재생"); + + var duration = _viewModel.Duration; + _seekSlider.Maximum = Math.Max(duration.TotalSeconds, 1); + _seekSlider.IsEnabled = duration > TimeSpan.Zero; + } + + /// + /// Fills the empty state for a missing player or a failed poll. + /// + /// Poll error, or null when there simply is no player. + private void UpdateEmpty(string? error) + { + if (error is not null) + { + _emptyIcon.Icon = PanelIcons.Warning; + _emptyTitle.Text = "플레이어에 접근할 수 없음"; + _emptyDescription.Text = error; + } + else + { + _emptyIcon.Icon = PanelIcons.Power; + _emptyTitle.Text = "실행 중인 플레이어 없음"; + _emptyDescription.Text = "MPRIS를 지원하는 앱을 실행하고 음악을 재생하면 여기에 표시됩니다."; + } + } + + /// + /// Swaps the decoded cover when the artwork bytes change. + /// + /// + /// Decodes at twice the display size, which is enough for a HiDPI panel + /// and far smaller than the originals players hand out. Undecodable data + /// falls back to the placeholder rather than an error. + /// + /// Encoded artwork, or null for none. + private void UpdateArtwork(byte[]? bytes) + { + if (ReferenceEquals(bytes, _artworkBytes)) + { + return; + } + + _artworkBytes = bytes; + _artworkBitmap?.Dispose(); + _artworkBitmap = null; + + if (bytes is not null) + { + try + { + using var stream = new MemoryStream(bytes); + _artworkBitmap = Bitmap.DecodeToWidth(stream, (int)Math.Max(_artworkFrame.Width * 2, MinimumArtworkSize * 2)); + } + catch (Exception) + { + _artworkBitmap = null; + } + } + + _artworkImage.Source = _artworkBitmap; + _artworkImage.IsVisible = _artworkBitmap is not null; + } + + /// + /// Advances the scrubber and time labels between polls. + /// + /// + /// Runs four times a second so a playing track appears to move + /// continuously even though the player is only sampled once a second. + /// + private void UpdateProgress() + { + if (_viewModel.ActiveSession is null || !IsVisible) + { + return; + } + + var duration = _viewModel.Duration; + var position = _viewModel.DisplayedPosition(); + var remaining = duration > position ? duration - position : TimeSpan.Zero; + + if (!_seekSlider.IsDragging) + { + _seekSlider.Value = position.TotalSeconds; + } + + _lyricsView.Advance(position); + + _leadingTime.Text = PanelTimeDisplay.LeadingText( + _preferences.Current.LeadingTimeStyle, + position); + _trailingTime.Text = PanelTimeDisplay.TrailingText( + _preferences.Current.TrailingTimeStyle, + duration, + remaining); + } + + /// + /// Opens or closes the floating volume slider. + /// + private void ToggleVolumePopup() + { + if (_volumePopup.IsOpen) + { + _volumePopup.IsOpen = false; + return; + } + + _volumeSlider.Value = _viewModel.Volume; + _volumePopup.IsOpen = true; + } +} diff --git a/src/Reprise.Desktop/PanelGlyph.cs b/src/Reprise.Desktop/PanelGlyph.cs new file mode 100644 index 0000000..57a7af1 --- /dev/null +++ b/src/Reprise.Desktop/PanelGlyph.cs @@ -0,0 +1,255 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Input; +using Avalonia.Media; + +namespace Reprise.Desktop; + +/// +/// Draws one geometry at a chosen size. +/// +/// +/// The geometry is fitted to by its own bounds and +/// centred in the control, so a glyph reads at the same visual size whatever +/// box it was authored in. Disabled glyphs are dimmed the way macOS dims a +/// disabled symbol. +/// +public class PanelGlyph : Control +{ + /// + /// Opacity applied while the control is disabled. + /// + private const double DisabledOpacity = 0.35; + + /// + /// Geometry to draw. + /// + public static readonly StyledProperty IconProperty = + AvaloniaProperty.Register(nameof(Icon)); + + /// + /// Length of the glyph's longer side, in device-independent pixels. + /// + public static readonly StyledProperty IconSizeProperty = + AvaloniaProperty.Register(nameof(IconSize), 16); + + /// + /// Brush the glyph is filled with. + /// + public static readonly StyledProperty ForegroundProperty = + AvaloniaProperty.Register(nameof(Foreground), Brushes.Black); + + /// + /// Registers the property watchers that trigger a redraw or re-measure. + /// + static PanelGlyph() + { + AffectsRender(IconProperty, IconSizeProperty, ForegroundProperty, IsEnabledProperty); + AffectsMeasure(IconSizeProperty); + } + + /// + /// Geometry to draw. + /// + public Geometry? Icon + { + get => GetValue(IconProperty); + set => SetValue(IconProperty, value); + } + + /// + /// Length of the glyph's longer side. + /// + public double IconSize + { + get => GetValue(IconSizeProperty); + set => SetValue(IconSizeProperty, value); + } + + /// + /// Brush the glyph is filled with. + /// + public IBrush? Foreground + { + get => GetValue(ForegroundProperty); + set => SetValue(ForegroundProperty, value); + } + + /// + /// Brush actually used when drawing, which subclasses may vary by state. + /// + protected virtual IBrush? EffectiveForeground => Foreground; + + /// + /// Draws the glyph centred and scaled to . + /// + /// + /// A transparent fill is laid down over the whole control first, since + /// Avalonia hit-tests painted content rather than layout bounds: without + /// it a click in the hollow centre of an outlined glyph, such as the + /// gear, would fall through to whatever lies beneath. + /// + /// Surface to draw on. + public override void Render(DrawingContext context) + { + context.DrawRectangle(Brushes.Transparent, null, new Rect(Bounds.Size)); + if (Icon is not { } icon || EffectiveForeground is not { } brush) + { + return; + } + + var source = icon.Bounds; + var longest = Math.Max(source.Width, source.Height); + if (longest <= 0) + { + return; + } + + var scale = IconSize / longest; + var drawnWidth = source.Width * scale; + var drawnHeight = source.Height * scale; + var offsetX = (Bounds.Width - drawnWidth) / 2; + var offsetY = (Bounds.Height - drawnHeight) / 2; + var transform = Matrix.CreateTranslation(-source.X, -source.Y) + * Matrix.CreateScale(scale, scale) + * Matrix.CreateTranslation(offsetX, offsetY); + + using var opacity = context.PushOpacity(IsEnabled ? 1 : DisabledOpacity); + using var pushed = context.PushTransform(transform); + context.DrawGeometry(brush, null, icon); + } + + /// + /// Reports the glyph's box when no explicit size was given. + /// + /// Space offered by the parent. + /// A square of per side. + protected override Size MeasureOverride(Size availableSize) => new(IconSize, IconSize); +} + +/// +/// A glyph that behaves as a chrome-less button. +/// +/// +/// Mirrors the plain and compact button styles of the macOS panel: no +/// background or border, a slight shrink and a dimmer fill while pressed, +/// and a click that fires only if the pointer is released over the glyph. +/// +public sealed class PanelIconButton : PanelGlyph +{ + /// + /// Scale applied while pressed. + /// + private const double PressedScale = 0.94; + + /// + /// Brush used while pressed. Falls back to . + /// + public static readonly StyledProperty PressedForegroundProperty = + AvaloniaProperty.Register(nameof(PressedForeground)); + + private bool _isPressed; + + /// + /// Registers the property watchers that trigger a redraw. + /// + static PanelIconButton() + { + AffectsRender(PressedForegroundProperty); + } + + /// + /// Creates the button with an arrow cursor, as macOS shows over its buttons. + /// + public PanelIconButton() + { + Cursor = new Cursor(StandardCursorType.Arrow); + RenderTransformOrigin = RelativePoint.Center; + Focusable = false; + } + + /// + /// Raised when the pointer is released over the button. + /// + public event EventHandler? Click; + + /// + /// Brush used while pressed. + /// + public IBrush? PressedForeground + { + get => GetValue(PressedForegroundProperty); + set => SetValue(PressedForegroundProperty, value); + } + + /// + /// The pressed brush while held, otherwise the resting brush. + /// + protected override IBrush? EffectiveForeground => + _isPressed ? PressedForeground ?? Foreground : Foreground; + + /// + /// Enters the pressed state and captures the pointer. + /// + /// Pointer event. + protected override void OnPointerPressed(PointerPressedEventArgs e) + { + base.OnPointerPressed(e); + if (!IsEnabled || !e.GetCurrentPoint(this).Properties.IsLeftButtonPressed) + { + return; + } + + e.Pointer.Capture(this); + SetPressed(true); + e.Handled = true; + } + + /// + /// Leaves the pressed state and clicks if released over the button. + /// + /// Pointer event. + protected override void OnPointerReleased(PointerReleasedEventArgs e) + { + base.OnPointerReleased(e); + if (!_isPressed) + { + return; + } + + var inside = new Rect(Bounds.Size).Contains(e.GetPosition(this)); + e.Pointer.Capture(null); + SetPressed(false); + e.Handled = true; + if (inside) + { + Click?.Invoke(this, EventArgs.Empty); + } + } + + /// + /// Leaves the pressed state without clicking when capture is taken away. + /// + /// Capture event. + protected override void OnPointerCaptureLost(PointerCaptureLostEventArgs e) + { + base.OnPointerCaptureLost(e); + SetPressed(false); + } + + /// + /// Applies or removes the pressed shrink and redraws. + /// + /// Whether the button is held. + private void SetPressed(bool pressed) + { + if (_isPressed == pressed) + { + return; + } + + _isPressed = pressed; + RenderTransform = pressed ? new ScaleTransform(PressedScale, PressedScale) : null; + InvalidateVisual(); + } +} diff --git a/src/Reprise.Desktop/PanelIcons.cs b/src/Reprise.Desktop/PanelIcons.cs new file mode 100644 index 0000000..0d405c8 --- /dev/null +++ b/src/Reprise.Desktop/PanelIcons.cs @@ -0,0 +1,165 @@ +using Avalonia.Media; + +namespace Reprise.Desktop; + +/// +/// Vector glyphs the panel draws, in place of the SF Symbols macOS uses. +/// +/// +/// Linux has no system symbol font, and shipping an icon font for a dozen +/// glyphs would be heavier than the glyphs themselves. Each shape is drawn +/// in a 24-unit box and scaled at the point of use, so one definition serves +/// the 11pt footer buttons and the 25pt transport controls alike. Filled +/// shapes are used throughout - outlines are built as rings - so every icon +/// renders through the same geometry fill. +/// +public static class PanelIcons +{ + /// + /// Outlined painter's palette with four wells, standing in for paintpalette. + /// + public static readonly Geometry Palette = Geometry.Parse( + "F0 M2 12 A10 10 0 1 1 22 12 A10 10 0 1 1 2 12 Z M3.8 12 A8.2 8.2 0 1 1 20.2 12 A8.2 8.2 0 1 1 3.8 12 Z M5.8 9.5 A1.7 1.7 0 1 1 9.2 9.5 A1.7 1.7 0 1 1 5.8 9.5 Z M9.8 6.5 A1.7 1.7 0 1 1 13.2 6.5 A1.7 1.7 0 1 1 9.8 6.5 Z M14.8 8.5 A1.7 1.7 0 1 1 18.2 8.5 A1.7 1.7 0 1 1 14.8 8.5 Z M15.3 14 A1.7 1.7 0 1 1 18.7 14 A1.7 1.7 0 1 1 15.3 14 Z M12 20.5 L15 20.5 A2 2 0 0 0 15 16.5 L12 16.5 Z"); + + /// + /// Outlined window with a filled title strip, standing in for menubar.rectangle. + /// + public static readonly Geometry MenuBarRectangle = Geometry.Parse( + "F0 M5 4.5 L19 4.5 A2.5 2.5 0 0 1 21.5 7 L21.5 17 A2.5 2.5 0 0 1 19 19.5 L5 19.5 A2.5 2.5 0 0 1 2.5 17 L2.5 7 A2.5 2.5 0 0 1 5 4.5 Z M5.5 6.2 L18.5 6.2 A1.3 1.3 0 0 1 19.8 7.5 L19.8 16.5 A1.3 1.3 0 0 1 18.5 17.8 L5.5 17.8 A1.3 1.3 0 0 1 4.2 16.5 L4.2 7.5 A1.3 1.3 0 0 1 5.5 6.2 Z M4.2 6.5 L19.8 6.5 L19.8 9.1 L4.2 9.1 Z"); + + /// + /// Outlined rounded square with an i cut out, standing in for info.square. + /// + public static readonly Geometry InfoSquare = Geometry.Parse( + "F0 M7 3 L17 3 A4 4 0 0 1 21 7 L21 17 A4 4 0 0 1 17 21 L7 21 A4 4 0 0 1 3 17 L3 7 A4 4 0 0 1 7 3 Z M7.3 4.8 L16.7 4.8 A2.5 2.5 0 0 1 19.2 7.3 L19.2 16.7 A2.5 2.5 0 0 1 16.7 19.2 L7.3 19.2 A2.5 2.5 0 0 1 4.8 16.7 L4.8 7.3 A2.5 2.5 0 0 1 7.3 4.8 Z M11 10.5 L13 10.5 L13 17 L11 17 Z M10.8 7.9 A1.2 1.2 0 1 1 13.2 7.9 A1.2 1.2 0 1 1 10.8 7.9 Z"); + + /// + /// Ring with a play triangle cut out, standing in for play.circle. + /// + public static readonly Geometry PlayCircle = Geometry.Parse( + "F0 M2 12 A10 10 0 1 1 22 12 A10 10 0 1 1 2 12 Z M3.8 12 A8.2 8.2 0 1 1 20.2 12 A8.2 8.2 0 1 1 3.8 12 Z M9.8 8.2 L16.2 12 L9.8 15.8 Z"); + + /// + /// Solid play triangle, standing in for SF Symbols play.fill. + /// + public static readonly Geometry Play = Geometry.Parse( + "M6.5 3.5 L20.5 12 L6.5 20.5 Z"); + + /// + /// Two solid bars, standing in for pause.fill. + /// + public static readonly Geometry Pause = Geometry.Parse( + "M5.5 4 L10 4 L10 20 L5.5 20 Z M14 4 L18.5 4 L18.5 20 L14 20 Z"); + + /// + /// Two solid left-pointing triangles, standing in for backward.fill. + /// + public static readonly Geometry Backward = Geometry.Parse( + "M22 5 L12.5 12 L22 19 Z M12 5 L2.5 12 L12 19 Z"); + + /// + /// Two solid right-pointing triangles, standing in for forward.fill. + /// + public static readonly Geometry Forward = Geometry.Parse( + "M2 5 L11.5 12 L2 19 Z M12 5 L21.5 12 L12 19 Z"); + + /// + /// Outlined eight-tooth gear, standing in for gearshape. + /// + public static readonly Geometry Gear = Geometry.Parse( + "F0 M13.08 1.05 L15.19 1.47 L16.2 4.15 L17.65 5.12 L20.5 5.02 L21.7 6.81 L20.52 9.42 L20.86 11.13 L22.95 13.08 L22.53 15.19 L19.85 16.2 L18.88 17.65 L18.98 20.5 L17.19 21.7 L14.58 20.52 L12.87 20.86 L10.92 22.95 L8.81 22.53 L7.8 19.85 L6.35 18.88 L3.5 18.98 L2.3 17.19 L3.48 14.58 L3.14 12.87 L1.05 10.92 L1.47 8.81 L4.15 7.8 L5.12 6.35 L5.02 3.5 L6.81 2.3 L9.42 3.48 L11.13 3.14 Z M12.9 2.84 L14.67 3.2 L15.35 5.74 L16.5 6.51 L19.11 6.16 L20.11 7.66 L18.79 9.94 L19.07 11.3 L21.16 12.9 L20.8 14.67 L18.26 15.35 L17.49 16.5 L17.84 19.11 L16.34 20.11 L14.06 18.79 L12.7 19.07 L11.1 21.16 L9.33 20.8 L8.65 18.26 L7.5 17.49 L4.89 17.84 L3.89 16.34 L5.21 14.06 L4.93 12.7 L2.84 11.1 L3.2 9.33 L5.74 8.65 L6.51 7.5 L6.16 4.89 L7.66 3.89 L9.94 5.21 L11.3 4.93 Z M7.6 12 A4.4 4.4 0 1 1 16.4 12 A4.4 4.4 0 1 1 7.6 12 Z M9.4 12 A2.6 2.6 0 1 1 14.6 12 A2.6 2.6 0 1 1 9.4 12 Z"); + + /// + /// Open portrait frame with an arrow leaving it, standing in for rectangle.portrait.and.arrow.right. + /// + public static readonly Geometry Exit = Geometry.Parse( + "M4 3 L14.5 3 L14.5 5.4 L6.4 5.4 L6.4 18.6 L14.5 18.6 L14.5 21 L4 21 Z M10.5 10.9 L17 10.9 L17 13.1 L10.5 13.1 Z M16.2 7.6 L21.6 12 L16.2 16.4 Z"); + + /// + /// Speaker with a cross beside it, standing in for speaker.slash.fill. + /// + public static readonly Geometry SpeakerMuted = Geometry.Parse( + "M3 9 L7 9 L12.5 4.5 L12.5 19.5 L7 15 L3 15 Z M14.29 9.71 L20.29 15.71 L21.71 14.29 L15.71 8.29 Z M20.29 8.29 L14.29 14.29 L15.71 15.71 L21.71 9.71 Z"); + + /// + /// Speaker with one sound wave, standing in for speaker.wave.1.fill. + /// + public static readonly Geometry SpeakerWave1 = Geometry.Parse( + "M3 9 L7 9 L12.5 4.5 L12.5 19.5 L7 15 L3 15 Z M15.36 8.52 L15.57 8.76 L15.76 9.02 L15.93 9.28 L16.09 9.56 L16.23 9.84 L16.35 10.14 L16.46 10.44 L16.55 10.74 L16.61 11.05 L16.66 11.37 L16.69 11.68 L16.7 12 L16.69 12.32 L16.66 12.63 L16.61 12.95 L16.55 13.26 L16.46 13.56 L16.35 13.86 L16.23 14.16 L16.09 14.44 L15.93 14.72 L15.76 14.98 L15.57 15.24 L15.36 15.48 L14.03 14.28 L14.16 14.12 L14.29 13.95 L14.4 13.78 L14.5 13.6 L14.59 13.41 L14.67 13.22 L14.74 13.02 L14.8 12.82 L14.84 12.62 L14.87 12.41 L14.89 12.21 L14.9 12 L14.89 11.79 L14.87 11.59 L14.84 11.38 L14.8 11.18 L14.74 10.98 L14.67 10.78 L14.59 10.59 L14.5 10.4 L14.4 10.22 L14.29 10.05 L14.16 9.88 L14.03 9.72 Z"); + + /// + /// Speaker with two sound waves, standing in for speaker.wave.2.fill. + /// + public static readonly Geometry SpeakerWave2 = Geometry.Parse( + "M3 9 L7 9 L12.5 4.5 L12.5 19.5 L7 15 L3 15 Z M15.36 8.52 L15.57 8.76 L15.76 9.02 L15.93 9.28 L16.09 9.56 L16.23 9.84 L16.35 10.14 L16.46 10.44 L16.55 10.74 L16.61 11.05 L16.66 11.37 L16.69 11.68 L16.7 12 L16.69 12.32 L16.66 12.63 L16.61 12.95 L16.55 13.26 L16.46 13.56 L16.35 13.86 L16.23 14.16 L16.09 14.44 L15.93 14.72 L15.76 14.98 L15.57 15.24 L15.36 15.48 L14.03 14.28 L14.16 14.12 L14.29 13.95 L14.4 13.78 L14.5 13.6 L14.59 13.41 L14.67 13.22 L14.74 13.02 L14.8 12.82 L14.84 12.62 L14.87 12.41 L14.89 12.21 L14.9 12 L14.89 11.79 L14.87 11.59 L14.84 11.38 L14.8 11.18 L14.74 10.98 L14.67 10.78 L14.59 10.59 L14.5 10.4 L14.4 10.22 L14.29 10.05 L14.16 9.88 L14.03 9.72 Z M17.54 6.16 L17.9 6.56 L18.24 6.98 L18.54 7.43 L18.82 7.88 L19.07 8.36 L19.29 8.85 L19.47 9.36 L19.63 9.87 L19.75 10.4 L19.83 10.93 L19.88 11.46 L19.9 12 L19.88 12.54 L19.83 13.07 L19.75 13.6 L19.63 14.13 L19.47 14.64 L19.29 15.15 L19.07 15.64 L18.82 16.12 L18.54 16.57 L18.24 17.02 L17.9 17.44 L17.54 17.84 L16.25 16.58 L16.53 16.27 L16.79 15.94 L17.04 15.59 L17.25 15.23 L17.45 14.86 L17.62 14.47 L17.77 14.08 L17.88 13.67 L17.98 13.26 L18.05 12.84 L18.09 12.42 L18.1 12 L18.09 11.58 L18.05 11.16 L17.98 10.74 L17.88 10.33 L17.77 9.92 L17.62 9.53 L17.45 9.14 L17.25 8.77 L17.04 8.41 L16.79 8.06 L16.53 7.73 L16.25 7.42 Z"); + + /// + /// Speaker with three sound waves, standing in for speaker.wave.3.fill. + /// + public static readonly Geometry SpeakerWave3 = Geometry.Parse( + "M3 9 L7 9 L12.5 4.5 L12.5 19.5 L7 15 L3 15 Z M15.36 8.52 L15.57 8.76 L15.76 9.02 L15.93 9.28 L16.09 9.56 L16.23 9.84 L16.35 10.14 L16.46 10.44 L16.55 10.74 L16.61 11.05 L16.66 11.37 L16.69 11.68 L16.7 12 L16.69 12.32 L16.66 12.63 L16.61 12.95 L16.55 13.26 L16.46 13.56 L16.35 13.86 L16.23 14.16 L16.09 14.44 L15.93 14.72 L15.76 14.98 L15.57 15.24 L15.36 15.48 L14.03 14.28 L14.16 14.12 L14.29 13.95 L14.4 13.78 L14.5 13.6 L14.59 13.41 L14.67 13.22 L14.74 13.02 L14.8 12.82 L14.84 12.62 L14.87 12.41 L14.89 12.21 L14.9 12 L14.89 11.79 L14.87 11.59 L14.84 11.38 L14.8 11.18 L14.74 10.98 L14.67 10.78 L14.59 10.59 L14.5 10.4 L14.4 10.22 L14.29 10.05 L14.16 9.88 L14.03 9.72 Z M17.54 6.16 L17.9 6.56 L18.24 6.98 L18.54 7.43 L18.82 7.88 L19.07 8.36 L19.29 8.85 L19.47 9.36 L19.63 9.87 L19.75 10.4 L19.83 10.93 L19.88 11.46 L19.9 12 L19.88 12.54 L19.83 13.07 L19.75 13.6 L19.63 14.13 L19.47 14.64 L19.29 15.15 L19.07 15.64 L18.82 16.12 L18.54 16.57 L18.24 17.02 L17.9 17.44 L17.54 17.84 L16.25 16.58 L16.53 16.27 L16.79 15.94 L17.04 15.59 L17.25 15.23 L17.45 14.86 L17.62 14.47 L17.77 14.08 L17.88 13.67 L17.98 13.26 L18.05 12.84 L18.09 12.42 L18.1 12 L18.09 11.58 L18.05 11.16 L17.98 10.74 L17.88 10.33 L17.77 9.92 L17.62 9.53 L17.45 9.14 L17.25 8.77 L17.04 8.41 L16.79 8.06 L16.53 7.73 L16.25 7.42 Z M19.56 3.66 L20.1 4.21 L20.6 4.81 L21.06 5.43 L21.48 6.08 L21.85 6.76 L22.18 7.47 L22.46 8.19 L22.69 8.93 L22.87 9.69 L23 10.45 L23.07 11.22 L23.1 12 L23.07 12.78 L23 13.55 L22.87 14.31 L22.69 15.07 L22.46 15.81 L22.18 16.53 L21.85 17.24 L21.48 17.92 L21.06 18.57 L20.6 19.19 L20.1 19.79 L19.56 20.34 L18.31 19.05 L18.76 18.58 L19.19 18.08 L19.58 17.55 L19.93 17 L20.24 16.42 L20.52 15.83 L20.76 15.22 L20.95 14.59 L21.1 13.95 L21.21 13.31 L21.28 12.66 L21.3 12 L21.28 11.34 L21.21 10.69 L21.1 10.05 L20.95 9.41 L20.76 8.78 L20.52 8.17 L20.24 7.58 L19.93 7 L19.58 6.45 L19.19 5.92 L18.76 5.42 L18.31 4.95 Z"); + + /// + /// Solid triangle with a cut-out exclamation mark, standing in for exclamationmark.triangle.fill. + /// + public static readonly Geometry Warning = Geometry.Parse( + "F0 M12 2.5 L22.5 20.5 L1.5 20.5 Z M11 9 L13 9 L13 14.6 L11 14.6 Z M11 15.9 L13 15.9 L13 17.9 L11 17.9 Z"); + + /// + /// Single eighth note, standing in for music.note. + /// + public static readonly Geometry MusicNote = Geometry.Parse( + "M10.5 3.5 L12.6 3.5 C16.2 4.6 18.4 6.8 18.4 10.6 C16.8 8.6 14.8 8 12.6 7.9 L12.6 17.2 C12.6 19.4 10.6 21 8.3 21 C6.2 21 4.6 19.8 4.6 18.2 C4.6 16.4 6.6 15 8.9 15 C9.5 15 10 15.1 10.5 15.3 Z"); + + /// + /// Broken ring with a vertical bar, standing in for power. + /// + public static readonly Geometry Power = Geometry.Parse( + "M16.35 4.97 L17.88 6.09 L19.13 7.51 L20.04 9.17 L20.57 10.99 L20.69 12.88 L20.4 14.75 L19.72 16.52 L18.66 18.09 L17.3 19.4 L15.68 20.38 L13.88 20.99 L12 21.2 L10.12 20.99 L8.32 20.38 L6.7 19.4 L5.34 18.09 L4.28 16.52 L3.6 14.75 L3.31 12.88 L3.43 10.99 L3.96 9.17 L4.87 7.51 L6.12 6.09 L7.65 4.97 L8.65 6.7 L7.47 7.56 L6.51 8.66 L5.81 9.94 L5.4 11.34 L5.31 12.79 L5.53 14.23 L6.06 15.59 L6.87 16.81 L7.92 17.82 L9.17 18.57 L10.55 19.04 L12 19.2 L13.45 19.04 L14.83 18.57 L16.08 17.82 L17.13 16.81 L17.94 15.59 L18.47 14.23 L18.69 12.79 L18.6 11.34 L18.19 9.94 L17.49 8.66 L16.53 7.56 L15.35 6.7 Z M11 2.5 L13 2.5 L13 11.5 L11 11.5 Z"); + + /// + /// Level bars inside a disc, standing in for waveform.circle.fill; the Spotify artwork placeholder. + /// + public static readonly Geometry WaveformCircle = Geometry.Parse( + "F0 M1 12 A11 11 0 1 1 23 12 A11 11 0 1 1 1 12 Z M5.5 9.5 L7.5 9.5 L7.5 14.5 L5.5 14.5 Z M8.5 7 L10.5 7 L10.5 17 L8.5 17 Z M11.5 5 L13.5 5 L13.5 19 L11.5 19 Z M14.5 8 L16.5 8 L16.5 16 L14.5 16 Z M17.5 10 L19.5 10 L19.5 14 L17.5 14 Z"); + + /// + /// Play triangle cut out of a rectangle, standing in for play.rectangle.fill; the YouTube Music artwork placeholder. + /// + public static readonly Geometry PlayRectangle = Geometry.Parse( + "F0 M5 5 L19 5 A2.5 2.5 0 0 1 21.5 7.5 L21.5 16.5 A2.5 2.5 0 0 1 19 19 L5 19 A2.5 2.5 0 0 1 2.5 16.5 L2.5 7.5 A2.5 2.5 0 0 1 5 5 Z M5.6 6.7 L18.4 6.7 A1.4 1.4 0 0 1 19.8 8.1 L19.8 15.9 A1.4 1.4 0 0 1 18.4 17.3 L5.6 17.3 A1.4 1.4 0 0 1 4.2 15.9 L4.2 8.1 A1.4 1.4 0 0 1 5.6 6.7 Z M10 8.8 L16.2 12 L10 15.2 Z"); + + /// + /// Spotify mark, converted from the vector asset the macOS app ships. + /// + public static readonly Geometry SpotifyLogo = Geometry.Parse( + "M122.37 3.31 C61.99 0.91 11.1 47.91 8.71 108.29 C6.31 168.67 53.32 219.55 113.69 221.95 C174.07 224.35 224.95 177.35 227.35 116.97 C229.74 56.59 182.74 5.7 122.37 3.31 Z M168.55 163.59 C167.19 165.99 164.54 167.19 161.96 166.83 C161.17 166.72 160.38 166.46 159.64 166.04 C145.18 157.81 129.42 152.45 112.8 150.11 C96.18 147.77 79.55 148.58 63.38 152.51 C59.87 153.36 56.34 151.21 55.49 147.7 C54.64 144.19 56.79 140.66 60.3 139.81 C78.08 135.49 96.36 134.6 114.62 137.17 C132.88 139.74 150.2 145.63 166.11 154.68 C169.24 156.47 170.34 160.45 168.56 163.59 Z M182.93 134.87 C180.7 138.99 175.54 140.53 171.42 138.3 C154.5 129.15 136.18 123.14 116.97 120.44 C97.76 117.74 78.5 118.47 59.71 122.6 C58.69 122.82 57.68 122.86 56.7 122.72 C53.29 122.24 50.37 119.7 49.59 116.13 C48.58 111.55 51.48 107.02 56.06 106.01 C76.83 101.44 98.12 100.63 119.34 103.61 C140.55 106.59 160.8 113.23 179.5 123.35 C183.63 125.58 185.16 130.73 182.93 134.86 Z M198.87 102.49 C196.77 106.53 192.4 108.62 188.14 108.02 C186.99 107.86 185.86 107.5 184.77 106.94 C165.07 96.69 143.85 89.92 121.7 86.81 C99.55 83.7 77.28 84.36 55.52 88.78 C49.86 89.93 44.35 86.27 43.2 80.62 C42.05 74.96 45.71 69.45 51.36 68.3 C75.46 63.41 100.1 62.68 124.61 66.12 C149.12 69.56 172.6 77.06 194.42 88.41 C199.54 91.07 201.53 97.38 198.87 102.5 Z"); + + /// + /// YouTube Music mark, converted from the vector asset the macOS app ships. + /// + public static readonly Geometry YouTubeMusicLogo = Geometry.Parse( + "F0 M1 12 A11 11 0 1 1 23 12 A11 11 0 1 1 1 12 Z M5.25 12 A6.75 6.75 0 1 0 18.75 12 A6.75 6.75 0 1 0 5.25 12 Z M9.75 8.25 L16.25 12 L9.75 15.75 Z"); + + /// + /// Picks the speaker glyph that matches a volume level. + /// + /// + /// Thresholds copy the macOS panel, so the two platforms flip between + /// wave counts at the same levels. + /// + /// Volume from 0 to 100. + /// The glyph for that level. + /// + /// + /// PanelIcons.Speaker(50); // PanelIcons.SpeakerWave2 + /// + /// + public static Geometry Speaker(int percent) => percent switch + { + <= 0 => SpeakerMuted, + < 34 => SpeakerWave1, + < 67 => SpeakerWave2, + _ => SpeakerWave3, + }; +} diff --git a/src/Reprise.Desktop/PanelLyricsView.cs b/src/Reprise.Desktop/PanelLyricsView.cs new file mode 100644 index 0000000..8752e0b --- /dev/null +++ b/src/Reprise.Desktop/PanelLyricsView.cs @@ -0,0 +1,457 @@ +using System.Globalization; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Media; +using Avalonia.Threading; +using Reprise.Core; + +namespace Reprise.Desktop; + +/// +/// Scrolling synced lyrics beneath the card, one line per row. +/// +/// +/// A port of the macOS InlineLyricsView. The line being sung sits +/// on the second row with the previous line above it and the next few +/// below, dimmed; the rows slide up as playback advances, with rows below +/// the current one starting a beat later so the move reads as a cascade, +/// and a vertical fade hides the rows entering and leaving. Everything is +/// drawn directly so the animation can run at frame rate without a +/// control per line. +/// +public sealed class PanelLyricsView : Control +{ + /// + /// Height of the view, matching the macOS lyrics viewport. + /// + public const double ViewportHeight = 136; + + /// + /// Height of one lyric row. + /// + private const double RowHeight = 29; + + /// + /// Space above the row that holds the previous line. + /// + private const double PreviousLineTopInset = 2; + + /// + /// Horizontal padding either side of the text. + /// + private const double HorizontalPadding = 18; + + /// + /// Furthest row below the current one that still gets a cascade delay. + /// + private const int MaximumCascadeStep = 3; + + /// + /// Opacity of every row other than the current line. + /// + private const double DimmedOpacity = 0.38; + + /// + /// Angular frequency of the slide, over its normalised duration. + /// + /// + /// SwiftUI's spring(duration:bounce:) defines the duration as one + /// period of the underlying spring, so a full turn of the circle spans + /// exactly . Getting this wrong is what makes + /// a spring feel like a snap: at twice this frequency the row has + /// travelled 96% of the way in a quarter of the time it was given. + /// + private const double SpringOmega = Math.Tau; + + /// + /// Damping ratio of the slide, the macOS bounce of 0.24 subtracted from one. + /// + private const double SpringDamping = 0.76; + + private static readonly TimeSpan CascadeDelay = TimeSpan.FromMilliseconds(45); + private static readonly TimeSpan MoveDuration = TimeSpan.FromMilliseconds(560); + private static readonly TimeSpan FadeDuration = TimeSpan.FromMilliseconds(220); + private static readonly Typeface LineTypeface = new(PanelTypography.Family, FontStyle.Normal, FontWeight.SemiBold); + + /// + /// Brush the lines are drawn with. + /// + public static readonly StyledProperty ForegroundProperty = + AvaloniaProperty.Register(nameof(Foreground), Brushes.Black); + + /// + /// Brush of the hairline along the top edge. + /// + public static readonly StyledProperty SeparatorBrushProperty = + AvaloniaProperty.Register(nameof(SeparatorBrush)); + + private readonly DispatcherTimer _frameTimer; + private readonly Dictionary _textCache = []; + private SyncedLyrics? _lyrics; + private int _focus; + private double _previousFocus; + private DateTimeOffset _moveStartedAt; + private bool _moving; + private int? _current; + private int? _previousCurrent; + private DateTimeOffset _fadeStartedAt; + private bool _fading; + private double _cachedWidth; + + /// + /// Registers the property watchers that trigger a redraw. + /// + static PanelLyricsView() + { + AffectsRender(ForegroundProperty, SeparatorBrushProperty); + } + + /// + /// Creates the view with its frame timer stopped. + /// + public PanelLyricsView() + { + _frameTimer = new DispatcherTimer( + TimeSpan.FromMilliseconds(16), + DispatcherPriority.Render, + (_, _) => Tick()); + _frameTimer.Stop(); + ClipToBounds = true; + Height = ViewportHeight; + } + + /// + /// Brush the lines are drawn with. + /// + public IBrush? Foreground + { + get => GetValue(ForegroundProperty); + set => SetValue(ForegroundProperty, value); + } + + /// + /// Brush of the hairline along the top edge. + /// + public IBrush? SeparatorBrush + { + get => GetValue(SeparatorBrushProperty); + set => SetValue(SeparatorBrushProperty, value); + } + + /// + /// Lyrics to display, or null for none. + /// + /// + /// Assigning new lyrics resets the view without animation, since the + /// rows of one song have no relation to the rows of the next. + /// + public SyncedLyrics? Lyrics + { + get => _lyrics; + set + { + if (ReferenceEquals(_lyrics, value)) + { + return; + } + + _lyrics = value; + _textCache.Clear(); + _focus = 0; + _previousFocus = 0; + _current = null; + _previousCurrent = null; + StopAnimation(); + InvalidateVisual(); + } + } + + /// + /// Moves the view to the line being sung at a position. + /// + /// + /// Called by the panel's progress timer. A change of line starts the + /// slide; an unchanged line costs nothing. + /// + /// Two indices are tracked, and the distinction matters. The focused + /// line is what the rows are positioned around and always exists once + /// playback has begun; the current line is the one actually being sung, + /// and is absent through an instrumental gap. So the sheet holds its + /// place while the highlight fades away, rather than jumping whenever + /// a line runs out. + /// + /// + /// Current playback position. + /// + /// + /// lyricsView.Advance(viewModel.DisplayedPosition()); + /// + /// + public void Advance(TimeSpan position) + { + if (_lyrics is null) + { + return; + } + + var now = DateTimeOffset.UtcNow; + var focus = _lyrics.FocusedLineIndex(position) ?? 0; + var current = _lyrics.LineIndex(position); + + if (focus != _focus) + { + _previousFocus = _moving ? AnimatedFocus(now, 0) : _focus; + _focus = focus; + _moveStartedAt = now; + _moving = true; + } + + if (current != _current) + { + _previousCurrent = _current; + _current = current; + _fadeStartedAt = now; + _fading = true; + } + + if (!_moving && !_fading) + { + return; + } + + _frameTimer.Start(); + InvalidateVisual(); + } + + /// + /// Draws the hairline, then every row that falls inside the viewport. + /// + /// Surface to draw on. + public override void Render(DrawingContext context) + { + var bounds = new Rect(Bounds.Size); + if (SeparatorBrush is { } separator) + { + context.DrawRectangle(separator, null, new Rect(0, 0, bounds.Width, 1)); + } + + if (_lyrics is not { } lyrics || lyrics.Lines.Count == 0 || bounds.Width <= 0) + { + return; + } + + if (Math.Abs(bounds.Width - _cachedWidth) > 0.5) + { + _cachedWidth = bounds.Width; + _textCache.Clear(); + } + + var now = DateTimeOffset.UtcNow; + using var mask = context.PushOpacityMask(CreateFadeMask(), bounds); + // Span both ends of a slide, or the rows the sheet is travelling + // from would pop into place instead of moving out of view. + var lowest = (int)Math.Floor(Math.Min(_previousFocus, _focus)); + var highest = (int)Math.Ceiling(Math.Max(_previousFocus, _focus)); + var firstVisible = Math.Max(0, lowest - 3); + var lastVisible = Math.Min(lyrics.Lines.Count - 1, highest + 6); + for (var index = firstVisible; index <= lastVisible; index++) + { + var relative = index - _focus; + var cascade = Math.Clamp(relative, 0, MaximumCascadeStep); + var y = PreviousLineTopInset + (index - AnimatedFocus(now, cascade) + 1) * RowHeight; + if (y + RowHeight < 0 || y > bounds.Height) + { + continue; + } + + var opacity = AnimatedOpacity(now, index); + var text = TextFor(index, lyrics.Lines[index].Text, bounds.Width); + using var pushed = context.PushOpacity(opacity); + context.DrawText(text, new Point(HorizontalPadding, y + (RowHeight - text.Height) / 2)); + } + } + + /// + /// Takes the full width offered and the fixed viewport height. + /// + /// Space offered by the parent. + /// The viewport size. + protected override Size MeasureOverride(Size availableSize) => + new(double.IsInfinity(availableSize.Width) ? 0 : availableSize.Width, ViewportHeight); + + /// + /// Stops the frame timer when the view leaves the tree. + /// + /// Tree event. + protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e) + { + base.OnDetachedFromVisualTree(e); + StopAnimation(); + } + + /// + /// Redraws the slide and stops the timer once every row has settled. + /// + private void Tick() + { + var now = DateTimeOffset.UtcNow; + if (_moving && now - _moveStartedAt >= MoveDuration + CascadeDelay * MaximumCascadeStep) + { + _moving = false; + _previousFocus = _focus; + } + + if (_fading && now - _fadeStartedAt >= FadeDuration) + { + _fading = false; + _previousCurrent = _current; + } + + if (!_moving && !_fading) + { + _frameTimer.Stop(); + } + + InvalidateVisual(); + } + + /// + /// Ends both animations and snaps every row to its final state. + /// + private void StopAnimation() + { + _moving = false; + _fading = false; + _previousFocus = _focus; + _previousCurrent = _current; + _frameTimer.Stop(); + } + + /// + /// The row index the view is centred on at a moment, mid-slide. + /// + /// Moment to evaluate at. + /// Delay steps for the row being placed. + /// A fractional row index between the previous and current focus. + private double AnimatedFocus(DateTimeOffset now, int cascadeStep) + { + if (!_moving) + { + return _focus; + } + + var elapsed = now - _moveStartedAt - CascadeDelay * cascadeStep; + var progress = Math.Clamp(elapsed.TotalMilliseconds / MoveDuration.TotalMilliseconds, 0, 1); + return _previousFocus + (_focus - _previousFocus) * Spring(progress); + } + + /// + /// Opacity of a row, fading between dimmed and full as focus moves. + /// + /// Moment to evaluate at. + /// Row to evaluate. + /// An opacity from to 1. + private double AnimatedOpacity(DateTimeOffset now, int index) + { + var target = index == _current ? 1 : DimmedOpacity; + if (!_fading) + { + return target; + } + + var previous = index == _previousCurrent ? 1 : DimmedOpacity; + var progress = Math.Clamp((now - _fadeStartedAt).TotalMilliseconds / FadeDuration.TotalMilliseconds, 0, 1); + return previous + (target - previous) * EaseInOut(progress); + } + + /// + /// A lightly under-damped spring, standing in for SwiftUI's bouncy spring. + /// + /// Normalised time from 0 to 1. + /// Position from 0 to about 1, with a small overshoot. + internal static double Spring(double progress) + { + if (progress >= 1) + { + return 1; + } + + var damped = SpringOmega * Math.Sqrt(1 - SpringDamping * SpringDamping); + return 1 - Math.Exp(-SpringDamping * SpringOmega * progress) + * (Math.Cos(damped * progress) + SpringDamping * SpringOmega / damped * Math.Sin(damped * progress)); + } + + /// + /// The ease the highlight crossfades on. + /// + /// + /// Stands in for SwiftUI's easeInOut. A straight ramp makes the + /// handover between two lines read as a dip in brightness, because both + /// sit at middling opacity for the whole of the middle of the fade. + /// + /// Normalised time from 0 to 1. + /// Eased position from 0 to 1. + internal static double EaseInOut(double progress) => + progress * progress * (3 - 2 * progress); + + /// + /// The mask that fades rows at the top and bottom edges. + /// + /// A vertical gradient, opaque through the middle. + private static LinearGradientBrush CreateFadeMask() => new() + { + StartPoint = new RelativePoint(0.5, 0, RelativeUnit.Relative), + EndPoint = new RelativePoint(0.5, 1, RelativeUnit.Relative), + GradientStops = + { + new GradientStop(Colors.Transparent, 0), + new GradientStop(Colors.Black, 0.18), + new GradientStop(Colors.Black, 0.82), + new GradientStop(Colors.Transparent, 1), + }, + }; + + /// + /// Lays out one line, caching the result per row. + /// + /// Row the text belongs to. + /// Words to lay out. + /// Current view width. + /// Shaped text centred in the row, trimmed to one line. + private FormattedText TextFor(int index, string text, double width) + { + if (_textCache.TryGetValue(index, out var cached)) + { + return cached; + } + + var formatted = new FormattedText( + text, + CultureInfo.CurrentUICulture, + FlowDirection.LeftToRight, + LineTypeface, + PanelTypography.Lyric, + Foreground ?? Brushes.Black) + { + MaxTextWidth = Math.Max(1, width - HorizontalPadding * 2), + MaxLineCount = 1, + Trimming = TextTrimming.CharacterEllipsis, + TextAlignment = TextAlignment.Center, + }; + _textCache[index] = formatted; + return formatted; + } + + /// + /// Drops cached text when the brush changes. + /// + /// Property change details. + protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) + { + base.OnPropertyChanged(change); + if (change.Property == ForegroundProperty) + { + _textCache.Clear(); + } + } +} diff --git a/src/Reprise.Desktop/PanelPalette.cs b/src/Reprise.Desktop/PanelPalette.cs new file mode 100644 index 0000000..2659b8c --- /dev/null +++ b/src/Reprise.Desktop/PanelPalette.cs @@ -0,0 +1,157 @@ +using Avalonia.Media; + +namespace Reprise.Desktop; + +/// +/// How much of the desktop a window can show through itself. +/// +/// +/// Reported by the windowing system after the window is shown, and used to +/// pick how opaque the Liquid theme has to be to stay readable. +/// +public enum PanelTranslucency +{ + /// + /// The window is always opaque. + /// + None, + + /// + /// The window can be see-through, but nothing behind it is blurred. + /// + Transparent, + + /// + /// The compositor blurs whatever lies behind the window. + /// + Blurred, +} + +/// +/// The colours one resolves to. +/// +/// +/// Resolved once per theme change rather than looked up per control, so +/// every part of the panel agrees on the same six values. Derived colours +/// are exposed as properties to keep the opacity choices in one place. +/// +/// Whether text is light on a dark ground. +/// Fill behind the whole panel. +/// Titles, glyphs, and the scrubber knob edge. +/// Artist, times, footer text, and footer glyphs. +/// Scrubber fill and artwork placeholder tint. +public sealed record PanelPalette( + bool IsDark, + Color Background, + Color Primary, + Color Secondary, + Color Accent) +{ + /// + /// Hairline between the card and the footer. + /// + public Color Separator => WithAlpha(Primary, 0.14); + + /// + /// Transport glyphs at rest. + /// + public Color Control => WithAlpha(Primary, 0.72); + + /// + /// Transport glyphs while pressed. + /// + public Color ControlPressed => WithAlpha(Primary, 0.55); + + /// + /// Unfilled part of a slider track. + /// + public Color Track => WithAlpha(Primary, IsDark ? 0.22 : 0.16); + + /// + /// Ground of the floating volume slider. + /// + public Color FloatingBackground => LiquidBackground(IsDark, PanelTranslucency.Transparent); + + /// + /// Resolves the palette for a theme in the current environment. + /// + /// + /// White and Dark ignore the desktop setting entirely, which is their + /// point; Liquid and System follow it. The accent is taken from the + /// desktop where available so the scrubber matches the user's other + /// controls. + /// + /// Theme the user chose. + /// Whether the desktop is in its dark mode. + /// Desktop accent colour. + /// What the window can show through. + /// The resolved colours. + /// + /// + /// var palette = PanelPalette.Resolve( + /// PanelTheme.Liquid, systemIsDark: true, accent, PanelTranslucency.Blurred); + /// + /// + public static PanelPalette Resolve( + PanelTheme theme, + bool systemIsDark, + Color accent, + PanelTranslucency translucency) + { + var isDark = theme switch + { + PanelTheme.White => false, + PanelTheme.Dark => true, + _ => systemIsDark, + }; + var primary = isDark ? Colors.White : Colors.Black; + var secondary = isDark + ? Color.FromArgb(0x8C, 0xFF, 0xFF, 0xFF) + : Color.FromArgb(0x80, 0x00, 0x00, 0x00); + var background = theme switch + { + PanelTheme.White => Colors.White, + PanelTheme.Dark => Color.FromRgb(0x1F, 0x1F, 0x1F), + PanelTheme.Liquid => LiquidBackground(isDark, translucency), + _ => isDark ? Color.FromRgb(0x32, 0x32, 0x32) : Color.FromRgb(0xEC, 0xEC, 0xEC), + }; + + return new PanelPalette(isDark, background, primary, secondary, accent); + } + + /// + /// Ground colour of the Liquid theme for a given translucency. + /// + /// + /// Behind a blur the ground can be quite open, since the blur supplies + /// the contrast. With plain transparency whatever sits behind the panel + /// would show through sharply, so it is nearly opaque; with none it is + /// fully opaque. + /// + /// Whether the desktop is in its dark mode. + /// What the window can show through. + /// A colour with the appropriate alpha. + public static Color LiquidBackground(bool isDark, PanelTranslucency translucency) + { + var alpha = translucency switch + { + PanelTranslucency.Blurred => 0.66, + PanelTranslucency.Transparent => 0.93, + _ => 1.0, + }; + var ground = isDark ? Color.FromRgb(0x1C, 0x1C, 0x1E) : Color.FromRgb(0xF4, 0xF4, 0xF6); + return WithAlpha(ground, alpha); + } + + /// + /// Copies a colour with a different opacity. + /// + /// Colour to copy. + /// Opacity from 0 to 1. + /// The colour with the new alpha channel. + public static Color WithAlpha(Color color, double alpha) + { + var channel = (byte)Math.Round(Math.Clamp(alpha, 0, 1) * 255); + return Color.FromArgb(channel, color.R, color.G, color.B); + } +} diff --git a/src/Reprise.Desktop/PanelTheme.cs b/src/Reprise.Desktop/PanelTheme.cs new file mode 100644 index 0000000..e5e9799 --- /dev/null +++ b/src/Reprise.Desktop/PanelTheme.cs @@ -0,0 +1,181 @@ +namespace Reprise.Desktop; + +/// +/// Visual treatment of the player panel, mirroring the macOS choices. +/// +/// +/// The raw values are the strings the macOS app stores, so a preference +/// file can be read the same way on every platform. +/// +public enum PanelTheme +{ + /// + /// Opaque white with dark text, regardless of the desktop theme. + /// + White, + + /// + /// Opaque near-black with light text, regardless of the desktop theme. + /// + Dark, + + /// + /// Translucent panel that follows the desktop's light or dark setting. + /// + Liquid, + + /// + /// Opaque panel in the desktop's own window colour and text colour. + /// + System, +} + +/// +/// Which value the left-hand time label under the scrubber shows. +/// +public enum PanelLeadingTimeStyle +{ + /// + /// Time elapsed in the current track. + /// + Elapsed, + + /// + /// A fixed 00:00, for users who prefer a static left edge. + /// + Zero, +} + +/// +/// Which value the right-hand time label under the scrubber shows. +/// +public enum PanelTrailingTimeStyle +{ + /// + /// Time left in the current track, shown with a leading minus sign. + /// + Remaining, + + /// + /// Total length of the current track. + /// + Duration, +} + +/// +/// What text the tray label shows beside the icon, mirroring the macOS menu +/// bar title format. +/// +public enum MenuBarTitleFormat +{ + /// + /// The track title alone, falling back to the artist when the title is empty. + /// + TitleOnly, + + /// + /// Title, a dash, then artist. + /// + TitleArtist, + + /// + /// Artist, a dash, then title. + /// + ArtistTitle, + + /// + /// No text at all; the tray shows only the icon. + /// + Hidden, +} + +/// +/// What the tray icon shows while a track is loaded. +/// +public enum MenuBarArtworkStyle +{ + /// + /// The album cover, rounded to fit the panel. + /// + AlbumArtwork, + + /// + /// The Reprise icon, regardless of the track. + /// + Hidden, +} + +/// +/// User-facing names for the panel options, matching the macOS settings. +/// +public static class PanelOptionNames +{ + /// + /// Returns the menu label for a theme. + /// + /// Theme to name. + /// The label the macOS settings window uses. + /// + /// + /// PanelOptionNames.DisplayName(PanelTheme.Liquid); // "Liquid" + /// + /// + public static string DisplayName(this PanelTheme theme) => theme switch + { + PanelTheme.White => "화이트", + PanelTheme.Dark => "다크", + PanelTheme.Liquid => "Liquid", + PanelTheme.System => "시스템 설정", + _ => theme.ToString(), + }; + + /// + /// Returns the menu label for a leading time style. + /// + /// Style to name. + /// The label the macOS settings window uses. + public static string DisplayName(this PanelLeadingTimeStyle style) => style switch + { + PanelLeadingTimeStyle.Elapsed => "현재 재생", + PanelLeadingTimeStyle.Zero => "00:00", + _ => style.ToString(), + }; + + /// + /// Returns the menu label for a trailing time style. + /// + /// Style to name. + /// The label the macOS settings window uses. + public static string DisplayName(this PanelTrailingTimeStyle style) => style switch + { + PanelTrailingTimeStyle.Remaining => "남은 시간", + PanelTrailingTimeStyle.Duration => "총 길이", + _ => style.ToString(), + }; + + /// + /// Returns the menu label for a title format. + /// + /// Format to name. + /// The label the macOS settings window uses. + public static string DisplayName(this MenuBarTitleFormat format) => format switch + { + MenuBarTitleFormat.TitleOnly => "제목만", + MenuBarTitleFormat.TitleArtist => "제목 - 아티스트", + MenuBarTitleFormat.ArtistTitle => "아티스트 - 제목", + MenuBarTitleFormat.Hidden => "없음", + _ => format.ToString(), + }; + + /// + /// Returns the menu label for an artwork style. + /// + /// Style to name. + /// The label the macOS settings window uses. + public static string DisplayName(this MenuBarArtworkStyle style) => style switch + { + MenuBarArtworkStyle.AlbumArtwork => "앨범", + MenuBarArtworkStyle.Hidden => "없음", + _ => style.ToString(), + }; +} diff --git a/src/Reprise.Desktop/PanelTimeDisplay.cs b/src/Reprise.Desktop/PanelTimeDisplay.cs new file mode 100644 index 0000000..ae7e425 --- /dev/null +++ b/src/Reprise.Desktop/PanelTimeDisplay.cs @@ -0,0 +1,83 @@ +namespace Reprise.Desktop; + +/// +/// Formats the two time labels under the scrubber. +/// +/// +/// Kept identical to the macOS PanelTimeDisplay so a track reads the +/// same on both platforms: minutes are not zero-padded, hours are folded +/// into minutes, and anything non-positive shows as 0:00. +/// +public static class PanelTimeDisplay +{ + /// + /// Text for the left-hand label. + /// + /// Which value the user chose to see. + /// Current position in the track. + /// The elapsed time, or a fixed 00:00. + /// + /// + /// PanelTimeDisplay.LeadingText(PanelLeadingTimeStyle.Elapsed, TimeSpan.FromSeconds(67)); + /// // "1:07" + /// + /// + public static string LeadingText(PanelLeadingTimeStyle style, TimeSpan position) => + style switch + { + PanelLeadingTimeStyle.Zero => "00:00", + _ => TimeString(position), + }; + + /// + /// Text for the right-hand label. + /// + /// Which value the user chose to see. + /// Length of the track. + /// Time left in the track. + /// + /// The remaining time with a leading minus sign, or the total length. + /// + /// + /// + /// PanelTimeDisplay.TrailingText( + /// PanelTrailingTimeStyle.Remaining, + /// TimeSpan.FromSeconds(180), + /// TimeSpan.FromSeconds(113)); + /// // "-1:53" + /// + /// + public static string TrailingText( + PanelTrailingTimeStyle style, + TimeSpan duration, + TimeSpan remaining) => + style switch + { + PanelTrailingTimeStyle.Duration => TimeString(duration), + _ => "-" + TimeString(remaining), + }; + + /// + /// Formats a duration as m:ss. + /// + /// Duration to format. + /// + /// Whole minutes and zero-padded seconds, or 0:00 for anything + /// that is not positive. + /// + /// + /// + /// PanelTimeDisplay.TimeString(TimeSpan.FromSeconds(3725)); // "62:05" + /// + /// + public static string TimeString(TimeSpan time) + { + if (time <= TimeSpan.Zero) + { + return "0:00"; + } + + var totalSeconds = (long)Math.Floor(time.TotalSeconds); + return $"{totalSeconds / 60}:{totalSeconds % 60:00}"; + } +} diff --git a/src/Reprise.Desktop/PanelTitleMarquee.cs b/src/Reprise.Desktop/PanelTitleMarquee.cs new file mode 100644 index 0000000..0a0ec08 --- /dev/null +++ b/src/Reprise.Desktop/PanelTitleMarquee.cs @@ -0,0 +1,395 @@ +using System.Globalization; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Input; +using Avalonia.Media; +using Avalonia.Threading; + +namespace Reprise.Desktop; + +/// +/// Single-line track title that scrolls when it does not fit. +/// +/// +/// A port of the macOS panel's title marquee. The title is drawn twice, a +/// fixed gap apart, and the pair slides left until the second copy reaches +/// where the first began, at which point the cycle restarts invisibly. A +/// fade at the trailing edge hides the cut. Titles that fit are drawn once +/// and never move. +/// +/// Drawn directly rather than composed from text blocks so the scrolling +/// offset can be snapped to whole device pixels each frame, which is what +/// keeps small text from shimmering as it moves. +/// +/// +public sealed class PanelTitleMarquee : Control +{ + /// + /// Space between the end of one copy of the title and the start of the next. + /// + private const double TitleGap = 24; + + /// + /// Width of the fade that hides the trailing edge of a scrolling title. + /// + private const double FadeWidth = 14; + + /// + /// How long a title rests before scrolling on its own. + /// + private static readonly TimeSpan InitialPause = TimeSpan.FromSeconds(1.4); + + /// + /// How long a title rests before scrolling in response to a hover. + /// + private static readonly TimeSpan HoverInitialPause = TimeSpan.FromSeconds(0.25); + + private static readonly Typeface TitleTypeface = new( + PanelTypography.Family, + FontStyle.Normal, + FontWeight.SemiBold); + + /// + /// Text to display. + /// + public static readonly StyledProperty TitleProperty = + AvaloniaProperty.Register(nameof(Title), string.Empty); + + /// + /// Brush the title is drawn with. + /// + public static readonly StyledProperty ForegroundProperty = + AvaloniaProperty.Register(nameof(Foreground), Brushes.Black); + + /// + /// Whether an overlong title scrolls unprompted, or only while hovered. + /// + public static readonly StyledProperty AutomaticallyScrollsProperty = + AvaloniaProperty.Register(nameof(AutomaticallyScrolls), true); + + /// + /// Scrolling speed in device-independent pixels per second. + /// + public static readonly StyledProperty PointsPerSecondProperty = + AvaloniaProperty.Register(nameof(PointsPerSecond), 30); + + /// + /// Font size of the title. + /// + public static readonly StyledProperty FontSizeProperty = + AvaloniaProperty.Register(nameof(FontSize), PanelTypography.Title); + + private readonly DispatcherTimer _frameTimer; + private DateTimeOffset _scrollStart; + private bool _isHovering; + private bool _isScrolling; + + /// + /// Registers the property watchers that trigger a redraw. + /// + static PanelTitleMarquee() + { + AffectsRender( + TitleProperty, + ForegroundProperty, + AutomaticallyScrollsProperty, + PointsPerSecondProperty, + FontSizeProperty); + AffectsMeasure(TitleProperty, FontSizeProperty); + } + + /// + /// Creates the marquee with its frame timer stopped. + /// + public PanelTitleMarquee() + { + _frameTimer = new DispatcherTimer( + TimeSpan.FromMilliseconds(16), + DispatcherPriority.Render, + (_, _) => InvalidateVisual()); + ClipToBounds = true; + } + + /// + /// Text to display. + /// + public string Title + { + get => GetValue(TitleProperty); + set => SetValue(TitleProperty, value); + } + + /// + /// Brush the title is drawn with. + /// + public IBrush? Foreground + { + get => GetValue(ForegroundProperty); + set => SetValue(ForegroundProperty, value); + } + + /// + /// Whether an overlong title scrolls unprompted, or only while hovered. + /// + public bool AutomaticallyScrolls + { + get => GetValue(AutomaticallyScrollsProperty); + set => SetValue(AutomaticallyScrollsProperty, value); + } + + /// + /// Scrolling speed in device-independent pixels per second. + /// + public double PointsPerSecond + { + get => GetValue(PointsPerSecondProperty); + set => SetValue(PointsPerSecondProperty, value); + } + + /// + /// Font size of the title. + /// + public double FontSize + { + get => GetValue(FontSizeProperty); + set => SetValue(FontSizeProperty, value); + } + + /// + /// Sends a scrolling title back to its beginning. + /// + /// + /// Called when the panel is summoned, so the user reads a long title + /// from the start rather than joining it mid-scroll. + /// + /// + /// + /// titleMarquee.RestartScroll(); + /// + /// + public void RestartScroll() + { + StopScrolling(); + InvalidateVisual(); + } + + /// + /// Draws the title, scrolling it when it overflows and scrolling is wanted. + /// + /// + /// Also owns the frame timer: it is started here when a scroll is due + /// and stopped as soon as the title fits or scrolling is no longer + /// wanted, so an idle panel costs no frames. The transparent fill makes + /// the whole line hoverable, which hover-to-scroll relies on. + /// + /// Surface to draw on. + public override void Render(DrawingContext context) + { + var title = Title; + var bounds = Bounds; + context.DrawRectangle(Brushes.Transparent, null, new Rect(bounds.Size)); + if (string.IsNullOrEmpty(title) || bounds.Width <= 0 || bounds.Height <= 0) + { + StopScrolling(); + return; + } + + var text = CreateText(title); + var textWidth = Math.Ceiling(text.Width); + var y = Math.Round((bounds.Height - text.Height) / 2); + var overflows = textWidth > bounds.Width; + var wantsScroll = overflows && (AutomaticallyScrolls || _isHovering); + + if (!wantsScroll) + { + StopScrolling(); + if (overflows) + { + using var fade = PushFade(context, bounds); + context.DrawText(text, new Point(0, y)); + } + else + { + context.DrawText(text, new Point(0, y)); + } + + return; + } + + if (!_isScrolling) + { + StartScrolling(); + } + + var distance = textWidth + TitleGap; + var offset = ScrollOffset(distance); + using (PushFade(context, bounds)) + { + context.DrawText(text, new Point(offset, y)); + context.DrawText(text, new Point(offset + distance, y)); + } + } + + /// + /// Reports the natural height of one line and no minimum width. + /// + /// + /// Width is left to the parent so the marquee fills whatever the layout + /// gives it and scrolls relative to that, exactly like the macOS view. + /// + /// Space offered by the parent. + /// The line height, and zero width. + protected override Size MeasureOverride(Size availableSize) + { + var text = CreateText(string.IsNullOrEmpty(Title) ? "M" : Title); + return new Size(0, Math.Ceiling(text.Height)); + } + + /// + /// Starts a hover scroll when automatic scrolling is off. + /// + /// Pointer event. + protected override void OnPointerEntered(PointerEventArgs e) + { + base.OnPointerEntered(e); + if (AutomaticallyScrolls) + { + return; + } + + _isHovering = true; + StopScrolling(); + InvalidateVisual(); + } + + /// + /// Ends a hover scroll and returns the title to its start. + /// + /// Pointer event. + protected override void OnPointerExited(PointerEventArgs e) + { + base.OnPointerExited(e); + if (!_isHovering) + { + return; + } + + _isHovering = false; + StopScrolling(); + InvalidateVisual(); + } + + /// + /// Stops the frame timer when the control leaves the tree. + /// + /// Tree event. + protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e) + { + base.OnDetachedFromVisualTree(e); + StopScrolling(); + } + + /// + /// Restarts the scroll from the beginning whenever the title changes. + /// + /// Property change details. + protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) + { + base.OnPropertyChanged(change); + if (change.Property == TitleProperty + || change.Property == AutomaticallyScrollsProperty + || change.Property == PointsPerSecondProperty) + { + StopScrolling(); + } + } + + /// + /// Lays out the title in the panel's title typeface. + /// + /// Text to lay out. + /// The shaped text, ready to measure or draw. + private FormattedText CreateText(string title) => new( + title, + CultureInfo.CurrentUICulture, + FlowDirection.LeftToRight, + TitleTypeface, + FontSize, + Foreground ?? Brushes.Black); + + /// + /// Computes how far the pair of titles has slid at this frame. + /// + /// + /// The cycle is a rest followed by a constant-speed slide of one + /// ; it then repeats from zero, which is + /// seamless because the second copy has by then reached the first + /// copy's origin. The result is snapped to the device pixel grid. + /// + /// Title width plus gap. + /// A non-positive horizontal offset. + private double ScrollOffset(double distance) + { + var speed = Math.Max(PointsPerSecond, 1); + var pause = _isHovering && !AutomaticallyScrolls ? HoverInitialPause : InitialPause; + var travel = TimeSpan.FromSeconds(distance / speed); + var cycle = pause + travel; + var elapsed = DateTimeOffset.UtcNow - _scrollStart; + var phase = TimeSpan.FromTicks(elapsed.Ticks % cycle.Ticks); + if (phase < pause) + { + return 0; + } + + var offset = -(phase - pause).TotalSeconds * speed; + var scale = TopLevel.GetTopLevel(this)?.RenderScaling ?? 1; + return Math.Round(offset * scale) / scale; + } + + /// + /// Applies the trailing-edge fade for the rest of a drawing scope. + /// + /// Surface being drawn. + /// Area the fade covers. + /// A scope that removes the fade when disposed. + private static DrawingContext.PushedState PushFade(DrawingContext context, Rect bounds) + { + var start = bounds.Width > 0 ? Math.Max(0, 1 - FadeWidth / bounds.Width) : 0; + var mask = new LinearGradientBrush + { + StartPoint = new RelativePoint(0, 0.5, RelativeUnit.Relative), + EndPoint = new RelativePoint(1, 0.5, RelativeUnit.Relative), + GradientStops = + { + new GradientStop(Colors.Black, 0), + new GradientStop(Colors.Black, start), + new GradientStop(Colors.Transparent, 1), + }, + }; + return context.PushOpacityMask(mask, bounds.WithX(0).WithY(0)); + } + + /// + /// Marks the start of a scroll cycle and begins issuing frames. + /// + private void StartScrolling() + { + _isScrolling = true; + _scrollStart = DateTimeOffset.UtcNow; + _frameTimer.Start(); + } + + /// + /// Stops issuing frames and returns the title to its resting offset. + /// + private void StopScrolling() + { + if (!_isScrolling) + { + return; + } + + _isScrolling = false; + _frameTimer.Stop(); + } +} diff --git a/src/Reprise.Desktop/PanelTypography.cs b/src/Reprise.Desktop/PanelTypography.cs new file mode 100644 index 0000000..b7b573a --- /dev/null +++ b/src/Reprise.Desktop/PanelTypography.cs @@ -0,0 +1,47 @@ +using Avalonia.Media; + +namespace Reprise.Desktop; + +/// +/// The typeface and sizes every Reprise surface shares. +/// +/// +/// Pretendard is bundled so the panel looks the same on every distribution +/// and covers Hangul without depending on which CJK fonts happen to be +/// installed; glyphs it lacks fall back to the system through Avalonia's +/// per-character matching. Sizes sit a step below the macOS point sizes, +/// since the same numbers render larger against Linux's 96 DPI baseline. +/// +public static class PanelTypography +{ + /// + /// Resource address of the bundled font family. + /// + public const string FamilyName = "avares://Reprise.Desktop/Assets/Fonts#Pretendard"; + + /// + /// The bundled family. + /// + public static readonly FontFamily Family = new(FamilyName); + + /// Track title in the card and the empty-state title. + public const double Title = 12.5; + + /// Artist line under the title. + public const double Subtitle = 10.5; + + /// Time labels, the footer, and the error banner. + public const double Caption = 9.5; + + /// Lyric lines in the panel strip. + public const double Lyric = 14; + + /// Empty-state heading. + public const double Heading = 14; + + /// Row labels in the settings window. + public const double Body = 12; + + /// Captions, footnotes, and segmented labels in the settings window. + public const double Small = 11; +} diff --git a/src/Reprise.Desktop/PlayerPriority.cs b/src/Reprise.Desktop/PlayerPriority.cs new file mode 100644 index 0000000..3e5fecd --- /dev/null +++ b/src/Reprise.Desktop/PlayerPriority.cs @@ -0,0 +1,157 @@ +using Reprise.Core; + +namespace Reprise.Desktop; + +/// +/// The user's ordering of player kinds, as the settings window edits it. +/// +/// +/// Stored as a comma-separated list of kind names, the way the macOS app +/// stores its display priority, so the preference file reads the same on +/// both platforms. Kinds missing from the stored list are appended in +/// their default order, which keeps an old file valid when a kind is added. +/// +public static class PlayerPriority +{ + /// + /// Kinds in their default priority. + /// + public static readonly IReadOnlyList DefaultOrder = + [ + PanelPlayerLogo.Spotify, + PanelPlayerLogo.YouTubeMusic, + PanelPlayerLogo.Generic, + ]; + + /// + /// Name shown for a kind in the priority list. + /// + /// Kind to name. + /// The display name. + /// + /// + /// PlayerPriority.DisplayName(PanelPlayerLogo.Generic); // "기타 플레이어" + /// + /// + public static string DisplayName(PanelPlayerLogo kind) => kind switch + { + PanelPlayerLogo.Spotify => "Spotify", + PanelPlayerLogo.YouTubeMusic => "YouTube Music", + _ => "기타 플레이어", + }; + + /// + /// Parses a stored priority string. + /// + /// Comma-separated kind names, possibly empty or stale. + /// Every kind exactly once, stored ones first in their order. + /// + /// + /// PlayerPriority.Parse("youTubeMusic"); // YouTubeMusic, Spotify, Generic + /// + /// + public static IReadOnlyList Parse(string? stored) + { + var order = new List(); + foreach (var name in (stored ?? string.Empty).Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) + { + if (Enum.TryParse(name, ignoreCase: true, out var kind) && !order.Contains(kind)) + { + order.Add(kind); + } + } + + foreach (var kind in DefaultOrder) + { + if (!order.Contains(kind)) + { + order.Add(kind); + } + } + + return order; + } + + /// + /// Serialises an ordering for storage. + /// + /// Kinds in priority order; missing kinds are appended. + /// Comma-separated kind names. + public static string Serialize(IEnumerable order) + { + var complete = order.Distinct().ToList(); + complete.AddRange(DefaultOrder.Where(kind => !complete.Contains(kind))); + return string.Join(",", complete.Select(kind => char.ToLowerInvariant(kind.ToString()[0]) + kind.ToString()[1..])); + } + + /// + /// Moves one kind to a new position. + /// + /// Current ordering. + /// Kind to move. + /// Index it should end up at. + /// The reordered list. + public static IReadOnlyList Move( + IReadOnlyList order, + PanelPlayerLogo kind, + int targetIndex) + { + var list = order.ToList(); + var source = list.IndexOf(kind); + if (source < 0) + { + return order; + } + + list.RemoveAt(source); + list.Insert(Math.Clamp(targetIndex, 0, list.Count), kind); + return list; + } + + /// + /// Orders sessions by the user's kind priority, for the active-session choice. + /// + /// + /// takes player ids rather than kinds, + /// so the sessions on the bus are sorted by their kind's rank and their ids + /// handed over in that order. + /// + /// Sessions from the latest poll. + /// Kind priority. + /// Player ids from most to least preferred. + /// + /// + /// var preferred = PlayerPriority.PreferredIds(sessions, PlayerPriority.Parse(prefs.PlayerDisplayPriority)); + /// var active = ActiveSessionSelector.Select(sessions, preferred); + /// + /// + public static IReadOnlyList PreferredIds( + IReadOnlyList sessions, + IReadOnlyList order) + { + return sessions + .OrderBy(session => Rank(order, NowPlayingViewModel.LogoFor(session.PlayerId))) + .ThenBy(session => session.PlayerId, StringComparer.Ordinal) + .Select(session => session.PlayerId) + .ToList(); + } + + /// + /// Position of a kind in an ordering, with unknown kinds last. + /// + /// Kind priority. + /// Kind to rank. + /// Zero for the most preferred kind. + private static int Rank(IReadOnlyList order, PanelPlayerLogo kind) + { + for (var index = 0; index < order.Count; index++) + { + if (order[index] == kind) + { + return index; + } + } + + return order.Count; + } +} diff --git a/src/Reprise.Desktop/Reprise.Desktop.csproj b/src/Reprise.Desktop/Reprise.Desktop.csproj new file mode 100644 index 0000000..c3596c1 --- /dev/null +++ b/src/Reprise.Desktop/Reprise.Desktop.csproj @@ -0,0 +1,18 @@ + + + Shared Avalonia desktop interface for Reprise. + + + + + + + + + + + + + diff --git a/src/Reprise.Desktop/RepriseApplication.cs b/src/Reprise.Desktop/RepriseApplication.cs new file mode 100644 index 0000000..ee6be04 --- /dev/null +++ b/src/Reprise.Desktop/RepriseApplication.cs @@ -0,0 +1,206 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.ApplicationLifetimes; +using Avalonia.Styling; +using Avalonia.Threading; +using Avalonia.Themes.Fluent; +using Reprise.Core; + +namespace Reprise.Desktop; + +/// +/// Avalonia application root shared by every desktop build of Reprise. +/// +/// +/// Owns the theme, the panel window, the settings window, the preferences, +/// and the tray entry, but deliberately not the media backend or the tray +/// transport: the platform entry point supplies those, which is what lets +/// this assembly stay free of any platform-specific reference. +/// +public sealed class RepriseApplication : Application +{ + private StatusItemController? _statusItem; + private SettingsWindow? _settings; + + /// + /// Supplies the platform's media session backend. + /// + /// + /// Static because Avalonia constructs the Application itself, leaving no + /// constructor for the entry point to inject through. The platform + /// Main assigns this before starting the lifetime, and it is read + /// exactly once during startup. + /// + /// + /// + /// RepriseApplication.MediaSessionServiceFactory = + /// static () => new MprisMediaSessionService(); + /// + /// + public static Func? MediaSessionServiceFactory + { + get; + set; + } + + /// + /// Supplies the platform's tray entry. + /// + /// + /// Optional: when unset, Avalonia's own tray icon is used, which shows + /// an icon and menu but no label. Linux sets this to its D-Bus + /// implementation so the track title can appear in the top bar. + /// + /// + /// + /// RepriseApplication.StatusItemFactory = + /// static () => new StatusNotifierItem(); + /// + /// + public static Func? StatusItemFactory + { + get; + set; + } + + /// + /// Supplies the service a shell extension reads to draw the top bar. + /// + /// + /// Optional. Without one, Reprise appears only as a tray entry, whose + /// label the desktop draws and which therefore cannot scroll smoothly. + /// + /// + /// + /// RepriseApplication.MenuBarPublisherFactory = + /// static () => new RepriseMenuBarService(); + /// + /// + public static Func? MenuBarPublisherFactory + { + get; + set; + } + + /// + /// Installs the control theme before any window is created. + /// + /// + /// The variant is left at the platform default here; the panel window + /// sets its own variant from the user's theme choice so its menus and + /// pop-ups match the panel rather than the desktop. + /// + public override void Initialize() + { + RequestedThemeVariant = ThemeVariant.Default; + Styles.Add(new FluentTheme()); + } + + /// + /// Builds the panel and tray entry once Avalonia is ready. + /// + /// + /// Shutdown is switched to explicit so dismissing the panel leaves + /// Reprise running in the tray, which is the behaviour a media controller + /// wants: the panel is something to summon, not the app itself. Clicking + /// the tray entry toggles the panel, and quitting goes through the + /// panel's exit button. + /// + /// The panel is shown once at launch so a desktop without a working tray + /// still gets to see it; from then on the tray entry toggles it. + /// + /// + /// + /// Thrown when no was installed, + /// which means a platform entry point was skipped. + /// + public override void OnFrameworkInitializationCompleted() + { + if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) + { + var service = MediaSessionServiceFactory?.Invoke() + ?? throw new InvalidOperationException( + "A platform media-session service was not configured."); + + desktop.ShutdownMode = ShutdownMode.OnExplicitShutdown; + var preferences = new PreferencesStore(PreferencesStore.DefaultPath); + var viewModel = new NowPlayingViewModel( + service, + lyricsService: new LyricsService(), + preferences: () => preferences.Current); + viewModel.LastPlayedPlayerChanged += (_, kind) => Dispatcher.UIThread.Post(() => + preferences.Update(p => p with { LastPlayedPlayer = kind.ToString() })); + var window = new NowPlayingWindow(viewModel, preferences); + window.ExitRequested += (_, _) => Quit(desktop, window); + window.SettingsRequested += (_, _) => + { + _settings ??= new SettingsWindow(viewModel, preferences); + _settings.Present(); + }; + desktop.MainWindow = window; + + var statusItem = StatusItemFactory?.Invoke() ?? new AvaloniaTrayStatusItem(this); + _statusItem = new StatusItemController( + statusItem, + viewModel, + preferences, + MenuBarPublisherFactory?.Invoke()); + _statusItem.Activated += (_, _) => window.TogglePanel(); + desktop.Exit += (_, _) => _statusItem?.Dispose(); + _ = StartStatusItemAsync(_statusItem); + + window.ShowPanel(); + } + + base.OnFrameworkInitializationCompleted(); + } + + /// + /// Registers the tray entry, reporting rather than failing when the + /// desktop has no status area. + /// + /// + /// A missing tray is not fatal - the panel is already on screen - but it + /// is worth a line on stderr, since the user will otherwise wonder why + /// closing the panel appears to quit the app. + /// + /// Controller to start. + /// A task that completes once registration has been attempted. + private static async Task StartStatusItemAsync(StatusItemController controller) + { + try + { + if (!await controller.StartAsync()) + { + Console.Error.WriteLine( + "Reprise: no status notifier host found; the tray entry will appear when one starts."); + } + } + catch (Exception exception) + { + Console.Error.WriteLine($"Reprise: tray entry unavailable: {exception.Message}"); + } + } + + /// + /// Ends the process cleanly from either quit entry point. + /// + /// + /// The panel cancels its own close to hide instead, so it is told to + /// allow the close first; otherwise shutdown would stall on the window. + /// + /// Lifetime to shut down. + /// Panel that must be allowed to close. + private void Quit( + IClassicDesktopStyleApplicationLifetime desktop, + NowPlayingWindow window) + { + window.AllowClose = true; + if (_settings is { } settings) + { + settings.AllowClose = true; + } + + desktop.Shutdown(); + } +} diff --git a/src/Reprise.Desktop/SettingsControls.cs b/src/Reprise.Desktop/SettingsControls.cs new file mode 100644 index 0000000..703ee8d --- /dev/null +++ b/src/Reprise.Desktop/SettingsControls.cs @@ -0,0 +1,501 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.Primitives; +using Avalonia.Input; +using Avalonia.Layout; +using Avalonia.Media; + +namespace Reprise.Desktop; + +/// +/// Builders for the grouped-form look of the macOS settings window. +/// +/// +/// SwiftUI's grouped form gives every section a caption, a rounded card +/// of rows, and a footnote. These helpers produce the same shapes from +/// Fluent theme resources, so the settings window reads like the Mac one +/// while still following the desktop's light or dark variant. +/// +public static class SettingsForm +{ + /// + /// Stacks sections with the spacing of a grouped form. + /// + /// Sections in order. + /// A scrollable page. + public static Control Page(params Control[] sections) + { + var stack = new StackPanel + { + Spacing = 18, + Margin = new Thickness(20, 14, 18, 18), + }; + foreach (var section in sections) + { + stack.Children.Add(section); + } + + return new ScrollViewer + { + Content = stack, + HorizontalScrollBarVisibility = ScrollBarVisibility.Disabled, + VerticalScrollBarVisibility = ScrollBarVisibility.Auto, + Padding = new Thickness(0, 0, 2, 0), + }; + } + + /// + /// One captioned card of rows with an optional footnote. + /// + /// Caption above the card, or null. + /// Note below the card, or null. + /// Rows inside the card, separated by hairlines. + /// The section. + public static Control Section(string? header, string? footer, params Control[] rows) + { + var stack = new StackPanel { Spacing = 6 }; + if (header is not null) + { + stack.Children.Add(Caption(header)); + } + + var card = new StackPanel(); + for (var index = 0; index < rows.Length; index++) + { + if (index > 0) + { + card.Children.Add(Hairline()); + } + + card.Children.Add(rows[index]); + } + + var border = new Border + { + CornerRadius = new CornerRadius(8), + BorderThickness = new Thickness(1), + Child = card, + }; + border.Bind(Border.BackgroundProperty, border.GetResourceObservable("SettingsCardBrush")); + border.Bind(Border.BorderBrushProperty, border.GetResourceObservable("SettingsLineBrush")); + stack.Children.Add(border); + + if (footer is not null) + { + stack.Children.Add(Footnote(footer)); + } + + return stack; + } + + /// + /// A row with a label on the left and a control on the right. + /// + /// Row title. + /// Control aligned to the trailing edge. + /// The row. + public static Control Row(string label, Control control) + { + control.HorizontalAlignment = HorizontalAlignment.Right; + control.VerticalAlignment = VerticalAlignment.Center; + var grid = new Grid + { + ColumnDefinitions = new ColumnDefinitions("*,Auto"), + Margin = new Thickness(14, 8), + MinHeight = 24, + Children = + { + Text(label, PanelTypography.Body), + control, + }, + }; + Grid.SetColumn(control, 1); + return grid; + } + + /// + /// A label in the window's primary colour. + /// + /// Text to show. + /// Font size. + /// Font weight. + /// The label. + public static TextBlock Text(string text, double size, FontWeight weight = FontWeight.Normal) + { + var block = new TextBlock + { + Text = text, + FontSize = size, + FontWeight = weight, + VerticalAlignment = VerticalAlignment.Center, + TextWrapping = TextWrapping.Wrap, + }; + block.Bind(TextBlock.ForegroundProperty, block.GetResourceObservable("SettingsPrimaryBrush")); + return block; + } + + /// + /// A row holding one control across its full width. + /// + /// Control to place. + /// The row. + public static Control Fill(Control control) + { + control.Margin = new Thickness(14, 10); + return control; + } + + /// + /// A row with a switch. + /// + /// Row title. + /// Initial state. + /// Runs when the user flips the switch. + /// Whether the switch can be used. + /// The row and its switch, so callers can enable or disable it later. + public static (Control Row, ToggleSwitch Switch) Toggle(string label, bool isOn, Action changed, bool isEnabled = true) + { + var toggle = new ToggleSwitch + { + IsChecked = isOn, + OnContent = null, + OffContent = null, + IsEnabled = isEnabled, + MinWidth = 0, + }; + toggle.IsCheckedChanged += (_, _) => changed(toggle.IsChecked == true); + return (Row(label, toggle), toggle); + } + + /// + /// A row showing a label and a read-only value. + /// + /// Row title. + /// Value text. + /// The row. + public static Control Value(string label, string value) + { + var text = new TextBlock { Text = value, FontSize = PanelTypography.Body }; + text.Bind(TextBlock.ForegroundProperty, text.GetResourceObservable("SettingsSecondaryBrush")); + return Row(label, text); + } + + /// + /// A row whose whole width is a segmented picker under a label. + /// + /// Row title, or null for the picker alone. + /// Picker to place. + /// The row. + public static Control Picker(string? label, SegmentedPicker picker) + { + if (label is null) + { + return Fill(picker); + } + + var stack = new StackPanel + { + Spacing = 8, + Margin = new Thickness(14, 10), + Children = + { + new TextBlock { Text = label, FontSize = PanelTypography.Body }, + picker, + }, + }; + return stack; + } + + /// + /// The small secondary caption above a card. + /// + /// Caption text. + /// The caption. + public static TextBlock Caption(string text) + { + var caption = new TextBlock + { + Text = text, + FontSize = PanelTypography.Small, + FontWeight = FontWeight.SemiBold, + Margin = new Thickness(12, 0, 0, 0), + }; + caption.Bind(TextBlock.ForegroundProperty, caption.GetResourceObservable("SettingsSecondaryBrush")); + return caption; + } + + /// + /// The small secondary note below a card. + /// + /// Note text. + /// The note. + public static TextBlock Footnote(string text) + { + var note = new TextBlock + { + Text = text, + FontSize = PanelTypography.Subtitle, + TextWrapping = TextWrapping.Wrap, + Margin = new Thickness(12, 0, 12, 0), + }; + note.Bind(TextBlock.ForegroundProperty, note.GetResourceObservable("SettingsSecondaryBrush")); + return note; + } + + /// + /// The one-pixel line between rows. + /// + /// The separator. + public static Control Hairline() + { + var line = new Border { Height = 1, Margin = new Thickness(14, 0, 0, 0) }; + line.Bind(Border.BackgroundProperty, line.GetResourceObservable("SettingsLineBrush")); + return line; + } + + /// + /// A link-styled button that opens an address in the browser. + /// + /// Link text. + /// Address to open. + /// The link. + public static Control Link(string text, Uri uri) + { + var link = new TextBlock + { + Text = text, + FontSize = PanelTypography.Body, + Cursor = new Cursor(StandardCursorType.Hand), + TextDecorations = TextDecorations.Underline, + }; + link.Bind(TextBlock.ForegroundProperty, link.GetResourceObservable("SettingsAccentBrush")); + link.PointerPressed += async (_, e) => + { + e.Handled = true; + if (TopLevel.GetTopLevel(link) is { } top) + { + await top.Launcher.LaunchUriAsync(uri); + } + }; + return link; + } +} + +/// +/// A horizontal group of mutually exclusive segments, like a macOS +/// segmented picker. +/// +/// +/// Avalonia has no segmented control; this one is a rounded well holding +/// equal-width cells, with the chosen cell raised on a card. Values are +/// opaque objects so enums of any type can be picked. +/// +public sealed class SegmentedPicker : Border +{ + private readonly List<(object Value, Border Cell)> _cells = []; + private object? _selected; + + /// + /// Creates the picker from labelled values. + /// + /// Segments in order. + /// Initially chosen value. + public SegmentedPicker(IEnumerable<(string Label, object Value)> options, object? selected) + { + CornerRadius = new CornerRadius(7); + Padding = new Thickness(2); + this.Bind(BackgroundProperty, this.GetResourceObservable("SettingsWellBrush")); + + var grid = new UniformGrid { Rows = 1 }; + foreach (var (label, value) in options) + { + var text = SettingsForm.Text(label, PanelTypography.Small); + text.HorizontalAlignment = HorizontalAlignment.Center; + var cell = new Border + { + CornerRadius = new CornerRadius(5), + Padding = new Thickness(6, 4), + Margin = new Thickness(1, 0), + Child = text, + Cursor = new Cursor(StandardCursorType.Arrow), + }; + var captured = value; + cell.PointerPressed += (_, e) => + { + e.Handled = true; + Select(captured, raise: true); + }; + _cells.Add((value, cell)); + grid.Children.Add(cell); + } + + Child = grid; + Select(selected, raise: false); + } + + /// + /// Raised when the user picks a different segment. + /// + public event EventHandler? SelectionChanged; + + /// + /// The chosen value. + /// + public object? SelectedValue + { + get => _selected; + set => Select(value, raise: false); + } + + /// + /// Applies a choice and restyles the cells. + /// + /// Value to choose. + /// Whether to notify listeners. + private void Select(object? value, bool raise) + { + var changed = !Equals(_selected, value); + _selected = value; + foreach (var (cellValue, cell) in _cells) + { + var chosen = Equals(cellValue, value); + if (chosen) + { + cell.Bind(BackgroundProperty, cell.GetResourceObservable("SettingsCardBrush")); + cell.BoxShadow = new BoxShadows(new BoxShadow + { + Blur = 3, + OffsetY = 1, + Color = Color.FromArgb(0x30, 0, 0, 0), + }); + } + else + { + cell.Background = Brushes.Transparent; + cell.BoxShadow = default; + } + } + + if (changed && raise && value is not null) + { + SelectionChanged?.Invoke(this, value); + } + } +} + +/// +/// The row of tabs across the top of the settings window. +/// +/// +/// Built by hand rather than from TabControl: the Fluent tab strip +/// left-aligns its items, tints their labels with the accent colour, and +/// underlines only the selected label, none of which resembles the macOS +/// settings toolbar. This draws evenly sized cells, centred, with the glyph +/// over its label and the selected cell on a rounded card. +/// +public sealed class SettingsTabBar : Border +{ + private readonly List<(Border Cell, PanelGlyph Glyph, TextBlock Label)> _tabs = []; + private int _selected = -1; + + /// + /// Builds the bar from a tab per entry. + /// + /// Label and glyph for each tab, in order. + public SettingsTabBar(IReadOnlyList<(string Title, Geometry Icon)> tabs) + { + ArgumentNullException.ThrowIfNull(tabs); + BorderThickness = new Thickness(0, 0, 0, 1); + this.Bind(BorderBrushProperty, this.GetResourceObservable("SettingsLineBrush")); + Padding = new Thickness(8, 8, 8, 6); + + var row = new StackPanel + { + Orientation = Orientation.Horizontal, + Spacing = 2, + HorizontalAlignment = HorizontalAlignment.Center, + }; + for (var index = 0; index < tabs.Count; index++) + { + var (title, icon) = tabs[index]; + var glyph = new PanelGlyph + { + Icon = icon, + IconSize = 17, + Width = 20, + Height = 20, + HorizontalAlignment = HorizontalAlignment.Center, + }; + var label = new TextBlock + { + Text = title, + FontSize = PanelTypography.Small, + HorizontalAlignment = HorizontalAlignment.Center, + TextAlignment = TextAlignment.Center, + }; + var cell = new Border + { + CornerRadius = new CornerRadius(6), + Padding = new Thickness(10, 5, 10, 4), + Cursor = new Cursor(StandardCursorType.Arrow), + Child = new StackPanel { Spacing = 2, Children = { glyph, label } }, + }; + var chosen = index; + cell.PointerPressed += (_, e) => + { + e.Handled = true; + Select(chosen); + }; + _tabs.Add((cell, glyph, label)); + row.Children.Add(cell); + } + + Child = row; + Select(0); + } + + /// + /// Raised when the user picks a different tab. + /// + public event EventHandler? SelectionChanged; + + /// + /// Index of the tab on show. + /// + public int SelectedIndex => _selected; + + /// + /// Shows one tab and restyles the cells. + /// + /// Tab to select. + public void Select(int index) + { + if (index < 0 || index >= _tabs.Count || index == _selected) + { + return; + } + + _selected = index; + for (var position = 0; position < _tabs.Count; position++) + { + var (cell, glyph, label) = _tabs[position]; + var chosen = position == index; + if (chosen) + { + cell.Bind(BackgroundProperty, cell.GetResourceObservable("SettingsWellBrush")); + glyph.Bind(PanelGlyph.ForegroundProperty, glyph.GetResourceObservable("SettingsAccentBrush")); + label.Bind(TextBlock.ForegroundProperty, label.GetResourceObservable("SettingsAccentBrush")); + label.FontWeight = FontWeight.SemiBold; + } + else + { + cell.Background = Brushes.Transparent; + glyph.Bind(PanelGlyph.ForegroundProperty, glyph.GetResourceObservable("SettingsSecondaryBrush")); + label.Bind(TextBlock.ForegroundProperty, label.GetResourceObservable("SettingsSecondaryBrush")); + label.FontWeight = FontWeight.Normal; + } + } + + SelectionChanged?.Invoke(this, index); + } +} diff --git a/src/Reprise.Desktop/SettingsPalette.cs b/src/Reprise.Desktop/SettingsPalette.cs new file mode 100644 index 0000000..7f1be51 --- /dev/null +++ b/src/Reprise.Desktop/SettingsPalette.cs @@ -0,0 +1,93 @@ +using Avalonia.Media; + +namespace Reprise.Desktop; + +/// +/// The colours the settings window paints itself with. +/// +/// +/// Defined here rather than taken from the Fluent theme's resource keys: +/// those keys are an implementation detail of the control set, and a key +/// that disappears leaves a control with no brush at all rather than a +/// wrong one, which is hard to notice. Resolving the five colours we +/// actually use from the desktop's own light or dark setting keeps the +/// window in step with the panel, which does the same. +/// +/// Window background, behind the cards. +/// Fill of a settings card. +/// Sunken fill: segmented pickers, selected tab, badges. +/// Row labels and values. +/// Captions, footnotes, and inactive tabs. +/// Card borders and the hairlines between rows. +/// Selected tab and links. +public sealed record SettingsPalette( + Color Ground, + Color Card, + Color Well, + Color Primary, + Color Secondary, + Color Line, + Color Accent) +{ + /// + /// Resource key each colour is published under, in declaration order. + /// + /// + /// Controls bind to these so a change of desktop theme repaints the + /// whole window without rebuilding it. + /// + public static readonly string[] ResourceKeys = + [ + "SettingsGroundBrush", + "SettingsCardBrush", + "SettingsWellBrush", + "SettingsPrimaryBrush", + "SettingsSecondaryBrush", + "SettingsLineBrush", + "SettingsAccentBrush", + ]; + + /// + /// Resolves the palette for the desktop's current appearance. + /// + /// Whether the desktop is in its dark mode. + /// Desktop accent colour. + /// The colours to paint with. + /// + /// + /// var palette = SettingsPalette.Resolve(isDark: false, Colors.DodgerBlue); + /// + /// + public static SettingsPalette Resolve(bool isDark, Color accent) => isDark + ? new SettingsPalette( + Ground: Color.FromRgb(0x1E, 0x1E, 0x20), + Card: Color.FromRgb(0x2A, 0x2A, 0x2D), + Well: Color.FromArgb(0x24, 0xFF, 0xFF, 0xFF), + Primary: Color.FromRgb(0xF2, 0xF2, 0xF4), + Secondary: Color.FromArgb(0x9E, 0xFF, 0xFF, 0xFF), + Line: Color.FromArgb(0x1F, 0xFF, 0xFF, 0xFF), + Accent: accent) + : new SettingsPalette( + Ground: Color.FromRgb(0xF2, 0xF2, 0xF4), + Card: Colors.White, + Well: Color.FromArgb(0x14, 0x00, 0x00, 0x00), + Primary: Color.FromRgb(0x1A, 0x1A, 0x1C), + Secondary: Color.FromArgb(0x99, 0x00, 0x00, 0x00), + Line: Color.FromArgb(0x1A, 0x00, 0x00, 0x00), + Accent: accent); + + /// + /// The colours in the order of . + /// + /// One brush per key. + public IReadOnlyList Brushes => + [ + new SolidColorBrush(Ground), + new SolidColorBrush(Card), + new SolidColorBrush(Well), + new SolidColorBrush(Primary), + new SolidColorBrush(Secondary), + new SolidColorBrush(Line), + new SolidColorBrush(Accent), + ]; +} diff --git a/src/Reprise.Desktop/SettingsPreviews.cs b/src/Reprise.Desktop/SettingsPreviews.cs new file mode 100644 index 0000000..67de838 --- /dev/null +++ b/src/Reprise.Desktop/SettingsPreviews.cs @@ -0,0 +1,300 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Layout; +using Avalonia.Media; + +namespace Reprise.Desktop; + +/// +/// The sample album cover the settings previews use, matching the macOS +/// PreviewAlbumArtwork: a conic rainbow with a music note. +/// +public sealed class PreviewArtwork : Border +{ + /// + /// Creates the cover at a size. + /// + /// Edge length. + /// Corner rounding. + /// Size of the note glyph. + public PreviewArtwork(double size, double cornerRadius, double symbolSize) + { + Width = size; + Height = size; + CornerRadius = new CornerRadius(cornerRadius); + ClipToBounds = true; + Background = new ConicGradientBrush + { + Center = new RelativePoint(0.5, 0.5, RelativeUnit.Relative), + GradientStops = + { + new GradientStop(Color.FromRgb(0xF0, 0x45, 0x59), 0), + new GradientStop(Color.FromRgb(0xFA, 0xAD, 0x38), 0.25), + new GradientStop(Color.FromRgb(0x38, 0xB8, 0xC7), 0.5), + new GradientStop(Color.FromRgb(0x6B, 0x40, 0xB8), 0.75), + new GradientStop(Color.FromRgb(0xF0, 0x45, 0x59), 1), + }, + }; + Child = new PanelGlyph + { + Icon = PanelIcons.MusicNote, + IconSize = symbolSize, + Foreground = Brushes.White, + HorizontalAlignment = HorizontalAlignment.Center, + VerticalAlignment = VerticalAlignment.Center, + }; + } +} + +/// +/// A live mock of the player panel, used by the theme tab. +/// +/// +/// Rebuilt from the same measurements as - +/// 360 by 140, a 112-point cover, the transport row - so the preview shows +/// the actual panel colours rather than an approximation. +/// +public sealed class ThemePanelPreview : Border +{ + private readonly TextBlock _title; + private readonly TextBlock _artist; + private readonly PanelGlyph _logo; + private readonly PanelGlyph[] _controls; + private readonly CompactSlider _slider; + private readonly TextBlock _leading; + private readonly TextBlock _trailing; + private const double PreviewDuration = 193; + + /// + /// Builds the mock with placeholder track data. + /// + public ThemePanelPreview() + { + Width = 360; + Height = 140; + CornerRadius = new CornerRadius(16); + ClipToBounds = true; + BorderThickness = new Thickness(1); + BorderBrush = new SolidColorBrush(Color.FromArgb(0x6B, 0, 0, 0)); + BoxShadow = new BoxShadows(new BoxShadow { Blur = 12, OffsetY = 2, Color = Color.FromArgb(0x29, 0, 0, 0) }); + HorizontalAlignment = HorizontalAlignment.Center; + + _title = new TextBlock { Text = "음악 제목 미리보기", FontSize = PanelTypography.Body, FontWeight = FontWeight.SemiBold }; + _artist = new TextBlock { Text = "아티스트", FontSize = PanelTypography.Subtitle }; + _logo = new PanelGlyph { Icon = PanelIcons.MusicNote, IconSize = 17, Width = 19, Height = 19, VerticalAlignment = VerticalAlignment.Top }; + _controls = + [ + new PanelGlyph { Icon = PanelIcons.Backward, IconSize = 18, Width = 36, Height = 34 }, + new PanelGlyph { Icon = PanelIcons.Pause, IconSize = 24, Width = 36, Height = 34 }, + new PanelGlyph { Icon = PanelIcons.Forward, IconSize = 18, Width = 36, Height = 34 }, + ]; + _slider = new CompactSlider { Minimum = 0, Maximum = PreviewDuration, Value = 69 }; + _slider.UserValueChanged += (_, _) => UpdateTimes(); + _leading = new TextBlock { FontSize = PanelTypography.Caption }; + _trailing = new TextBlock { FontSize = PanelTypography.Caption, HorizontalAlignment = HorizontalAlignment.Right }; + + var header = new Grid { ColumnDefinitions = new ColumnDefinitions("*,Auto") }; + var titles = new StackPanel { Spacing = 2, Children = { _title, _artist } }; + header.Children.Add(titles); + header.Children.Add(_logo); + Grid.SetColumn(_logo, 1); + + var controls = new StackPanel + { + Orientation = Orientation.Horizontal, + Spacing = 28, + HorizontalAlignment = HorizontalAlignment.Center, + VerticalAlignment = VerticalAlignment.Center, + }; + foreach (var control in _controls) + { + controls.Children.Add(control); + } + + var times = new Grid { ColumnDefinitions = new ColumnDefinitions("*,Auto"), Children = { _leading, _trailing } }; + Grid.SetColumn(_trailing, 1); + var progress = new StackPanel { Spacing = 1, Children = { _slider, times } }; + + var column = new Grid + { + RowDefinitions = new RowDefinitions("Auto,*,Auto"), + Margin = new Thickness(14, 0, 0, 0), + Height = 112, + Children = { header, controls, progress }, + }; + Grid.SetRow(controls, 1); + Grid.SetRow(progress, 2); + + var card = new Grid + { + ColumnDefinitions = new ColumnDefinitions("Auto,*"), + Margin = new Thickness(14), + Children = { new PreviewArtwork(112, 11, 28), column }, + }; + Grid.SetColumn(column, 1); + Child = card; + } + + /// + /// Repaints the mock for a theme and time settings. + /// + /// Theme to preview. + /// Time label settings. + /// Whether the desktop is in dark mode. + /// Desktop accent colour. + public void Update(PanelTheme theme, DesktopPreferences preferences, bool systemIsDark, Color accent) + { + var palette = PanelPalette.Resolve(theme, systemIsDark, accent, PanelTranslucency.Transparent); + Background = new SolidColorBrush(palette.Background); + var primary = new SolidColorBrush(palette.Primary); + var secondary = new SolidColorBrush(palette.Secondary); + _title.Foreground = primary; + _artist.Foreground = secondary; + _logo.Foreground = primary; + foreach (var control in _controls) + { + control.Foreground = new SolidColorBrush(palette.Control); + } + + _slider.TrackBrush = new SolidColorBrush(palette.Track); + _slider.FillBrush = new SolidColorBrush(palette.Accent); + _leading.Foreground = secondary; + _trailing.Foreground = secondary; + _preferences = preferences; + UpdateTimes(); + } + + private DesktopPreferences _preferences = new(); + + /// + /// Rewrites the time labels from the slider position. + /// + private void UpdateTimes() + { + var position = TimeSpan.FromSeconds(_slider.Value); + var duration = TimeSpan.FromSeconds(PreviewDuration); + _leading.Text = PanelTimeDisplay.LeadingText(_preferences.LeadingTimeStyle, position); + _trailing.Text = PanelTimeDisplay.TrailingText(_preferences.TrailingTimeStyle, duration, duration - position); + } +} + +/// +/// A mock of the tray entry on a blue top bar, used by the menu bar tab. +/// +/// +/// The preview scrolls smoothly because it is drawn here; the real tray +/// label steps a character at a time, which is as fine as a string label +/// can move. +/// +public sealed class MenuBarPreview : Border +{ + private readonly PreviewArtwork _artwork; + private readonly PanelTitleMarquee _marquee; + + /// + /// Builds the mock. + /// + public MenuBarPreview() + { + Height = 30; + CornerRadius = new CornerRadius(8); + Padding = new Thickness(10, 0); + HorizontalAlignment = HorizontalAlignment.Center; + Background = new LinearGradientBrush + { + StartPoint = new RelativePoint(0.5, 0, RelativeUnit.Relative), + EndPoint = new RelativePoint(0.5, 1, RelativeUnit.Relative), + GradientStops = + { + new GradientStop(Color.FromRgb(0x14, 0x6B, 0xA6), 0), + new GradientStop(Color.FromRgb(0x08, 0x4D, 0x85), 1), + }, + }; + _artwork = new PreviewArtwork(18, 4, 8) { VerticalAlignment = VerticalAlignment.Center }; + _marquee = new PanelTitleMarquee { Height = 30, Foreground = Brushes.White, VerticalAlignment = VerticalAlignment.Center }; + Child = new StackPanel + { + Orientation = Orientation.Horizontal, + Spacing = 5, + Children = { _artwork, _marquee }, + }; + } + + /// + /// Repaints the mock for the current tray settings. + /// + /// Tray settings. + public void Update(DesktopPreferences preferences) + { + _artwork.IsVisible = preferences.MenuBarArtworkStyle != MenuBarArtworkStyle.Hidden; + var hidden = preferences.MenuBarTitleFormat == MenuBarTitleFormat.Hidden; + _marquee.IsVisible = !hidden; + var text = preferences.MenuBarShowsLyrics && !hidden + ? "다시 만나요" + : MenuBarText.FormatTitle(preferences.MenuBarTitleFormat, "여기에 재생 중인 음악 제목이 표시됩니다", "미리보기 아티스트"); + _marquee.Title = text; + _marquee.AutomaticallyScrolls = preferences.AutomaticallyScrollsTitles; + _marquee.PointsPerSecond = preferences.MarqueePointsPerSecond; + var natural = Math.Min(text.Length * 7.5, 260); + _marquee.Width = preferences.MenuBarShowsLyrics && preferences.MenuBarReservesLabelWidth + ? Math.Max(natural, preferences.MenuBarLabelLength * 7.5) + : natural; + } +} + +/// +/// A mock of the scrubber and its time labels, used by the panel tab. +/// +public sealed class PanelTimePreview : Border +{ + private const double PreviewDuration = 193; + private readonly CompactSlider _slider; + private readonly TextBlock _leading; + private readonly TextBlock _trailing; + private DesktopPreferences _preferences = new(); + + /// + /// Builds the mock. + /// + public PanelTimePreview() + { + Width = 300; + Padding = new Thickness(12); + CornerRadius = new CornerRadius(10); + HorizontalAlignment = HorizontalAlignment.Center; + this.Bind(BackgroundProperty, this.GetResourceObservable("SettingsWellBrush")); + _slider = new CompactSlider { Minimum = 0, Maximum = PreviewDuration, Value = 69 }; + _slider.UserValueChanged += (_, _) => UpdateTimes(); + _slider.Bind(CompactSlider.TrackBrushProperty, _slider.GetResourceObservable("SettingsWellBrush")); + _slider.Bind(CompactSlider.FillBrushProperty, _slider.GetResourceObservable("SettingsAccentBrush")); + _leading = new TextBlock { FontSize = PanelTypography.Caption }; + _trailing = new TextBlock { FontSize = PanelTypography.Caption, HorizontalAlignment = HorizontalAlignment.Right }; + _leading.Bind(TextBlock.ForegroundProperty, _leading.GetResourceObservable("SettingsSecondaryBrush")); + _trailing.Bind(TextBlock.ForegroundProperty, _trailing.GetResourceObservable("SettingsSecondaryBrush")); + var times = new Grid { ColumnDefinitions = new ColumnDefinitions("*,Auto"), Children = { _leading, _trailing } }; + Grid.SetColumn(_trailing, 1); + Child = new StackPanel { Spacing = 1, Children = { _slider, times } }; + UpdateTimes(); + } + + /// + /// Repaints the labels for the chosen styles. + /// + /// Time label settings. + public void Update(DesktopPreferences preferences) + { + _preferences = preferences; + UpdateTimes(); + } + + /// + /// Rewrites the time labels from the slider position. + /// + private void UpdateTimes() + { + var position = TimeSpan.FromSeconds(_slider.Value); + var duration = TimeSpan.FromSeconds(PreviewDuration); + _leading.Text = PanelTimeDisplay.LeadingText(_preferences.LeadingTimeStyle, position); + _trailing.Text = PanelTimeDisplay.TrailingText(_preferences.TrailingTimeStyle, duration, duration - position); + } +} diff --git a/src/Reprise.Desktop/SettingsWindow.cs b/src/Reprise.Desktop/SettingsWindow.cs new file mode 100644 index 0000000..658d9d4 --- /dev/null +++ b/src/Reprise.Desktop/SettingsWindow.cs @@ -0,0 +1,825 @@ +using System.ComponentModel; +using System.Reflection; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Input; +using Avalonia.Layout; +using Avalonia.Media; +using Avalonia.Platform; +using Avalonia.Styling; +using Avalonia.Threading; +using Reprise.Core; + +namespace Reprise.Desktop; + +/// +/// The settings window, laid out like the macOS RepriseSettingsView. +/// +/// +/// Six tabs at the same 500 by 480 size: General, YouTube Music, Theme, +/// Menu bar, Panel, and System info, each a grouped form with the same +/// sections, captions, and footnotes as the Mac. The tab strip is drawn by +/// rather than a TabControl, which +/// left-aligns and accent-tints its items in a way the Mac toolbar does not. Where a Mac feature has no +/// Linux equivalent, the section explains what Linux does instead rather +/// than disappearing, so the two windows stay recognisably the same. +/// +/// One instance lives for the whole session and hides instead of closing, +/// so reopening it is instant and keeps the selected tab. +/// +/// +public sealed class SettingsWindow : Window +{ + private const double PriorityRowHeight = 39; + + private readonly NowPlayingViewModel _viewModel; + private readonly PreferencesStore _preferences; + private readonly AutostartEntry _autostart = new(); + private readonly DispatcherTimer _sessionTimer; + private readonly List _refreshers = []; + private StackPanel? _sessionList; + private StackPanel? _priorityList; + private TextBlock? _autostartError; + private ToggleSwitch? _autostartToggle; + + /// + /// Whether the application is shutting down and the window may close. + /// + public bool AllowClose { get; set; } + + /// + /// Builds the window and its tabs. + /// + /// Playback state for the sessions list. + /// Settings to show and edit. + /// + /// + /// var settings = new SettingsWindow(viewModel, preferences); + /// settings.Present(); + /// + /// + public SettingsWindow(NowPlayingViewModel viewModel, PreferencesStore preferences) + { + _viewModel = viewModel; + _preferences = preferences; + + Title = "Reprise 설정"; + Width = 500; + Height = 480; + CanResize = false; + ShowInTaskbar = true; + WindowStartupLocation = WindowStartupLocation.CenterScreen; + ApplyPalette(); + + var pages = new Control[] + { + BuildGeneralTab(), + BuildYouTubeMusicTab(), + BuildThemeTab(), + BuildMenuBarTab(), + BuildPanelTab(), + BuildSystemInfoTab(), + }; + var body = new ContentControl + { + Content = pages[0], + HorizontalContentAlignment = HorizontalAlignment.Stretch, + VerticalContentAlignment = VerticalAlignment.Stretch, + }; + var tabBar = new SettingsTabBar( + [ + ("일반", PanelIcons.Gear), + ("YouTube Music", PanelIcons.PlayCircle), + ("테마", PanelIcons.Palette), + ("메뉴바", PanelIcons.MenuBarRectangle), + ("패널", PanelIcons.PlayRectangle), + ("시스템 정보", PanelIcons.InfoSquare), + ]); + tabBar.SelectionChanged += (_, index) => body.Content = pages[index]; + + var layout = new Grid + { + RowDefinitions = new RowDefinitions("Auto,*"), + Children = { tabBar, body }, + }; + Grid.SetRow(body, 1); + Content = layout; + + _sessionTimer = new DispatcherTimer( + TimeSpan.FromMilliseconds(500), + DispatcherPriority.Background, + (_, _) => RefreshSessions()); + + _preferences.Changed += (_, _) => RefreshAll(); + Opened += (_, _) => + { + if (Application.Current?.PlatformSettings is { } settings) + { + settings.ColorValuesChanged += (_, _) => Dispatcher.UIThread.Post(ApplyPalette); + } + + ApplyPalette(); + RefreshAll(); + _sessionTimer.Start(); + }; + Closing += (_, e) => + { + if (AllowClose) + { + return; + } + + e.Cancel = true; + _sessionTimer.Stop(); + Hide(); + }; + KeyDown += (_, e) => + { + if (e.Key == Key.Escape) + { + Close(); + } + }; + } + + /// + /// Shows the window, or brings it forward if already open. + /// + public void Present() + { + Show(); + Activate(); + _sessionTimer.Start(); + RefreshAll(); + } + + /// + /// Publishes the window's colours, following the desktop appearance. + /// + /// + /// Every control binds to these resources, so replacing them repaints + /// the whole window without rebuilding a single tab. + /// + private void ApplyPalette() + { + var colours = Application.Current?.PlatformSettings?.GetColorValues(); + var accent = colours?.AccentColor1 is { A: > 0 } value ? value : Colors.DodgerBlue; + var palette = SettingsPalette.Resolve(colours?.ThemeVariant == PlatformThemeVariant.Dark, accent); + RequestedThemeVariant = colours?.ThemeVariant == PlatformThemeVariant.Dark + ? ThemeVariant.Dark + : ThemeVariant.Light; + + var brushes = palette.Brushes; + for (var index = 0; index < SettingsPalette.ResourceKeys.Length; index++) + { + Resources[SettingsPalette.ResourceKeys[index]] = brushes[index]; + } + + Background = brushes[0]; + } + + /// + /// Runs every registered refresher, on the UI thread. + /// + private void RefreshAll() + { + if (!Dispatcher.UIThread.CheckAccess()) + { + Dispatcher.UIThread.Post(RefreshAll); + return; + } + + foreach (var refresh in _refreshers) + { + refresh(); + } + } + + /// + /// Current settings, shortened for the builders below. + /// + private DesktopPreferences Current => _preferences.Current; + + /// + /// Applies a settings change. + /// + /// Produces the new settings. + private void Update(Func change) => _preferences.Update(change); + + /// + /// The General tab: playback, tray lyrics, display priority, autostart. + /// + /// The page. + private Control BuildGeneralTab() + { + var (autoPauseRow, autoPause) = SettingsForm.Toggle( + "다른 플레이어 자동 정지", + Current.AutomaticallyPausesOtherPlayer, + on => Update(p => p with { AutomaticallyPausesOtherPlayer = on })); + + var (lyricsRow, lyrics) = SettingsForm.Toggle( + "가사 표시", + Current.MenuBarShowsLyrics, + on => Update(p => p with { MenuBarShowsLyrics = on })); + var widthSlider = new Slider + { + Minimum = 10, + Maximum = 60, + TickFrequency = 5, + IsSnapToTickEnabled = true, + Width = 160, + Value = Current.MenuBarLabelLength, + }; + var widthValue = new TextBlock { FontSize = PanelTypography.Small, Width = 44, TextAlignment = TextAlignment.Right, VerticalAlignment = VerticalAlignment.Center }; + widthValue.Bind(TextBlock.ForegroundProperty, widthValue.GetResourceObservable("SettingsSecondaryBrush")); + widthSlider.ValueChanged += (_, _) => + { + var length = (int)Math.Round(widthSlider.Value); + widthValue.Text = $"{length}자"; + if (length != Current.MenuBarLabelLength) + { + Update(p => p with { MenuBarLabelLength = length }); + } + }; + widthSlider.IsEnabled = Current.MenuBarShowsLyrics; + var widthRow = SettingsForm.Row("가사 영역 너비", new StackPanel + { + Orientation = Orientation.Horizontal, + Spacing = 8, + Children = { widthSlider, widthValue }, + }); + var (reserveRow, reserve) = SettingsForm.Toggle( + "가사 공간 확보", + Current.MenuBarReservesLabelWidth, + on => Update(p => p with { MenuBarReservesLabelWidth = on })); + + var (rememberRow, remember) = SettingsForm.Toggle( + "마지막에 재생한 플레이어 기억", + Current.RemembersLastPlayedPlayer, + on => Update(p => p with { RemembersLastPlayedPlayer = on })); + _priorityList = new StackPanel(); + + var (autostartRow, autostartToggle) = SettingsForm.Toggle( + "로그인 시 Reprise 자동 실행", + _autostart.IsEnabled, + on => + { + var error = _autostart.SetEnabled(on); + if (_autostartError is not null) + { + _autostartError.Text = error ?? string.Empty; + _autostartError.IsVisible = error is not null; + } + }, + _autostart.IsAvailable); + _autostartToggle = autostartToggle; + _autostartError = new TextBlock + { + FontSize = PanelTypography.Subtitle, + Foreground = Brushes.IndianRed, + TextWrapping = TextWrapping.Wrap, + Margin = new Thickness(14, 0, 14, 8), + IsVisible = false, + }; + + _refreshers.Add(() => + { + var current = Current; + autoPause.IsChecked = current.AutomaticallyPausesOtherPlayer; + lyrics.IsChecked = current.MenuBarShowsLyrics; + widthSlider.Value = current.MenuBarLabelLength; + widthValue.Text = $"{current.MenuBarLabelLength}자"; + widthSlider.IsEnabled = current.MenuBarShowsLyrics; + reserve.IsChecked = current.MenuBarReservesLabelWidth; + reserve.IsEnabled = current.MenuBarShowsLyrics; + remember.IsChecked = current.RemembersLastPlayedPlayer; + RebuildPriorityList(); + }); + + return SettingsForm.Page( + SettingsForm.Section( + "재생", + "한 플레이어가 재생을 시작하면 기존에 재생 중이던 다른 플레이어를 일시 정지합니다.", + autoPauseRow), + SettingsForm.Section( + "제어 목록", + "GNOME 확장이 상단 바에서 가사를 이 너비만큼 보여 주며 흐르게 합니다. 공간 확보는 짧은 줄을 이 너비까지 늘려 주변 항목이 움직이지 않게 합니다.", + lyricsRow, + widthRow, + reserveRow), + SettingsForm.Section( + "표시 우선순위", + "기억을 켜면 마지막으로 재생을 시작한 플레이어를 우선 표시합니다. 그 외에는 아래 순서를 사용하며, 항목을 드래그하여 변경할 수 있습니다.", + rememberRow, + _priorityList), + SettingsForm.Section( + "자동 실행", + "로그인하면 Reprise를 자동으로 실행합니다. 항목은 ~/.config/autostart에 저장됩니다.", + autostartRow, + _autostartError)); + } + + /// + /// Rebuilds the draggable priority rows from the current ordering. + /// + private void RebuildPriorityList() + { + if (_priorityList is null) + { + return; + } + + _priorityList.Children.Clear(); + var order = PlayerPriority.Parse(Current.PlayerDisplayPriority); + for (var index = 0; index < order.Count; index++) + { + if (index > 0) + { + _priorityList.Children.Add(SettingsForm.Hairline()); + } + + _priorityList.Children.Add(PriorityRow(order, index)); + } + } + + /// + /// One row of the priority list, draggable to a new position. + /// + /// Current ordering. + /// Row being built. + /// The row. + private Control PriorityRow(IReadOnlyList order, int index) + { + var kind = order[index]; + var number = new TextBlock { Text = (index + 1).ToString(), Width = 16, FontSize = PanelTypography.Body, VerticalAlignment = VerticalAlignment.Center }; + number.Bind(TextBlock.ForegroundProperty, number.GetResourceObservable("SettingsSecondaryBrush")); + var icon = new PanelGlyph + { + Icon = kind switch + { + PanelPlayerLogo.Spotify => PanelIcons.SpotifyLogo, + PanelPlayerLogo.YouTubeMusic => PanelIcons.YouTubeMusicLogo, + _ => PanelIcons.MusicNote, + }, + IconSize = 16, + Width = 18, + Height = 18, + }; + icon.Bind(PanelGlyph.ForegroundProperty, icon.GetResourceObservable("SettingsPrimaryBrush")); + var handle = new TextBlock { Text = "☰", FontSize = PanelTypography.Body, VerticalAlignment = VerticalAlignment.Center }; + handle.Bind(TextBlock.ForegroundProperty, handle.GetResourceObservable("SettingsSecondaryBrush")); + + var row = new Grid + { + ColumnDefinitions = new ColumnDefinitions("Auto,Auto,*,Auto"), + ColumnSpacing = 10, + Height = PriorityRowHeight, + Margin = new Thickness(14, 0), + Background = Brushes.Transparent, + Cursor = new Cursor(StandardCursorType.SizeNorthSouth), + Children = + { + number, + icon, + new TextBlock { Text = PlayerPriority.DisplayName(kind), FontSize = PanelTypography.Body, VerticalAlignment = VerticalAlignment.Center }, + handle, + }, + }; + Grid.SetColumn(icon, 1); + Grid.SetColumn(row.Children[2], 2); + Grid.SetColumn(handle, 3); + + var startY = 0.0; + var dragging = false; + row.PointerPressed += (_, e) => + { + startY = e.GetPosition(_priorityList).Y; + dragging = true; + e.Pointer.Capture(row); + row.ZIndex = 1; + e.Handled = true; + }; + row.PointerMoved += (_, e) => + { + if (!dragging) + { + return; + } + + var offset = e.GetPosition(_priorityList).Y - startY; + row.RenderTransform = new TranslateTransform(0, offset); + }; + row.PointerReleased += (_, e) => + { + if (!dragging) + { + return; + } + + dragging = false; + e.Pointer.Capture(null); + row.RenderTransform = null; + row.ZIndex = 0; + var offset = e.GetPosition(_priorityList).Y - startY; + var target = Math.Clamp((int)Math.Round(index + offset / PriorityRowHeight), 0, order.Count - 1); + if (target != index) + { + var moved = PlayerPriority.Move(order, kind, target); + Update(p => p with { PlayerDisplayPriority = PlayerPriority.Serialize(moved) }); + } + }; + return row; + } + + /// + /// The YouTube Music tab: browser sessions seen over MPRIS. + /// + /// + /// On macOS this tab lists the browser extension's connections. Linux + /// browsers publish their media sessions over MPRIS themselves, so the + /// same list is filled from the players on the bus and no extension is + /// offered. + /// + /// The page. + private Control BuildYouTubeMusicTab() + { + _sessionList = new StackPanel(); + _refreshers.Add(RefreshSessions); + return SettingsForm.Page( + SettingsForm.Section( + "YouTube Music 세션", + "브라우저에서 열린 각 YouTube Music 탭을 표시합니다. 재생 상태와 표시 우선순위를 기준으로 제어할 세션을 자동 선택합니다.", + _sessionList), + SettingsForm.Section( + "브라우저 확장", + "Linux에서는 Chromium과 Firefox가 재생 중인 탭을 MPRIS로 직접 노출하므로 별도의 확장 프로그램이 필요하지 않습니다. YouTube Music 탭에서 재생을 시작하면 자동으로 연결됩니다.", + SettingsForm.Value("확장 프로그램", "필요 없음"))); + } + + /// + /// Rewrites the sessions list from the latest poll. + /// + private void RefreshSessions() + { + if (_sessionList is null || !IsVisible) + { + return; + } + + var sessions = _viewModel.Sessions + .Where(session => IsBrowserSession(session.PlayerId)) + .ToList(); + _sessionList.Children.Clear(); + if (sessions.Count == 0) + { + var empty = new StackPanel + { + Orientation = Orientation.Horizontal, + Spacing = 10, + Margin = new Thickness(14, 10), + }; + var glyph = new PanelGlyph { Icon = PanelIcons.Power, IconSize = 16, Width = 20, Height = 20, VerticalAlignment = VerticalAlignment.Top }; + glyph.Bind(PanelGlyph.ForegroundProperty, glyph.GetResourceObservable("SettingsSecondaryBrush")); + var text = new StackPanel + { + Spacing = 2, + Children = + { + new TextBlock { Text = "연결된 세션 없음", FontSize = PanelTypography.Body }, + SettingsForm.Footnote("브라우저에서 YouTube Music을 열고 재생을 시작해 주세요."), + }, + }; + ((TextBlock)text.Children[1]).Margin = new Thickness(0); + empty.Children.Add(glyph); + empty.Children.Add(text); + _sessionList.Children.Add(empty); + return; + } + + var active = _viewModel.ActiveSession?.PlayerId; + for (var index = 0; index < sessions.Count; index++) + { + if (index > 0) + { + _sessionList.Children.Add(SettingsForm.Hairline()); + } + + _sessionList.Children.Add(SessionRow(sessions[index], sessions[index].PlayerId == active)); + } + } + + /// + /// One row describing a browser session. + /// + /// Session to describe. + /// Whether it is the one on display. + /// The row. + private static Control SessionRow(MediaSessionSnapshot session, bool isActive) + { + var glyph = new PanelGlyph { Icon = PanelIcons.PlayRectangle, IconSize = 16, Width = 20, Height = 20, VerticalAlignment = VerticalAlignment.Center }; + glyph.Bind(PanelGlyph.ForegroundProperty, glyph.GetResourceObservable(isActive ? "SettingsAccentBrush" : "SettingsSecondaryBrush")); + + var name = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 6 }; + name.Children.Add(new TextBlock { Text = session.PlayerName, FontSize = PanelTypography.Body, FontWeight = FontWeight.Medium, VerticalAlignment = VerticalAlignment.Center }); + if (isActive) + { + var badge = new Border { CornerRadius = new CornerRadius(8), Padding = new Thickness(6, 2) }; + badge.Bind(Border.BackgroundProperty, badge.GetResourceObservable("SettingsWellBrush")); + var badgeText = new TextBlock { Text = "사용 중", FontSize = PanelTypography.Caption, FontWeight = FontWeight.SemiBold }; + badgeText.Bind(TextBlock.ForegroundProperty, badgeText.GetResourceObservable("SettingsAccentBrush")); + badge.Child = badgeText; + name.Children.Add(badge); + } + + var track = string.IsNullOrEmpty(session.Artist) ? session.Title : $"{session.Title} · {session.Artist}"; + var detail = session.Status switch + { + PlaybackStatus.Playing => track, + PlaybackStatus.Paused => $"일시 정지 · {track}", + PlaybackStatus.Stopped => $"정지 · {track}", + _ => string.IsNullOrEmpty(track) ? "재생 정보 없음" : track, + }; + var detailText = new TextBlock { Text = detail, FontSize = PanelTypography.Subtitle, TextTrimming = TextTrimming.CharacterEllipsis }; + detailText.Bind(TextBlock.ForegroundProperty, detailText.GetResourceObservable("SettingsSecondaryBrush")); + + var state = new PanelGlyph + { + Icon = session.Status == PlaybackStatus.Playing ? PanelIcons.Play : PanelIcons.Pause, + IconSize = 12, + Width = 16, + Height = 16, + VerticalAlignment = VerticalAlignment.Center, + Foreground = session.Status == PlaybackStatus.Playing ? Brushes.MediumSeaGreen : Brushes.Gray, + }; + + var row = new Grid + { + ColumnDefinitions = new ColumnDefinitions("Auto,*,Auto"), + ColumnSpacing = 10, + Margin = new Thickness(14, 8), + Children = + { + glyph, + new StackPanel { Spacing = 3, Children = { name, detailText } }, + state, + }, + }; + Grid.SetColumn(row.Children[1], 1); + Grid.SetColumn(state, 2); + return row; + } + + /// + /// Whether a player id belongs to a browser or a YouTube Music wrapper. + /// + /// MPRIS player id. + /// True for the players this tab lists. + private static bool IsBrowserSession(string playerId) + { + if (NowPlayingViewModel.LogoFor(playerId) == PanelPlayerLogo.YouTubeMusic) + { + return true; + } + + return new[] { "chromium", "chrome", "firefox", "brave", "edge", "opera", "vivaldi", "zen", "librewolf" } + .Any(browser => playerId.Contains(browser, StringComparison.OrdinalIgnoreCase)); + } + + /// + /// The Theme tab: a live panel preview and the theme picker. + /// + /// The page. + private Control BuildThemeTab() + { + var preview = new ThemePanelPreview { Margin = new Thickness(0, 10) }; + var picker = new SegmentedPicker( + Enum.GetValues().Select(theme => (theme.DisplayName(), (object)theme)), + Current.PanelTheme); + picker.SelectionChanged += (_, value) => Update(p => p with { PanelTheme = (PanelTheme)value }); + + _refreshers.Add(() => + { + var colours = Application.Current?.PlatformSettings?.GetColorValues(); + preview.Update( + Current.PanelTheme, + Current, + colours?.ThemeVariant == PlatformThemeVariant.Dark, + colours?.AccentColor1 is { A: > 0 } accent ? accent : Colors.DodgerBlue); + picker.SelectedValue = Current.PanelTheme; + }); + + return SettingsForm.Page( + SettingsForm.Section("미리보기", null, SettingsForm.Fill(preview)), + SettingsForm.Section("플레이어 패널", null, SettingsForm.Picker(null, picker))); + } + + /// + /// The Menu bar tab: a tray preview, text and icon choices, and the marquee. + /// + /// The page. + private Control BuildMenuBarTab() + { + var preview = new MenuBarPreview { Margin = new Thickness(0, 10) }; + var formatPicker = new SegmentedPicker( + Enum.GetValues().Select(format => (format.DisplayName(), (object)format)), + Current.MenuBarTitleFormat); + formatPicker.SelectionChanged += (_, value) => Update(p => + { + var format = (MenuBarTitleFormat)value; + var style = format == MenuBarTitleFormat.Hidden && p.MenuBarArtworkStyle == MenuBarArtworkStyle.Hidden + ? MenuBarArtworkStyle.AlbumArtwork + : p.MenuBarArtworkStyle; + return p with { MenuBarTitleFormat = format, MenuBarArtworkStyle = style }; + }); + var artworkPicker = new SegmentedPicker( + Enum.GetValues().Select(style => (style.DisplayName(), (object)style)), + Current.MenuBarArtworkStyle); + artworkPicker.SelectionChanged += (_, value) => Update(p => + { + var style = (MenuBarArtworkStyle)value; + var format = style == MenuBarArtworkStyle.Hidden && p.MenuBarTitleFormat == MenuBarTitleFormat.Hidden + ? MenuBarTitleFormat.TitleOnly + : p.MenuBarTitleFormat; + return p with { MenuBarArtworkStyle = style, MenuBarTitleFormat = format }; + }); + + var (scrollRow, scroll) = SettingsForm.Toggle( + "긴 곡 제목 캐러셀 움직이기", + Current.AutomaticallyScrollsTitles, + on => Update(p => p with { AutomaticallyScrollsTitles = on })); + var speedPicker = new SegmentedPicker( + new[] { ("느리게", (object)20.0), ("보통", 30.0), ("빠르게", 45.0) }, + Current.MarqueePointsPerSecond); + speedPicker.SelectionChanged += (_, value) => Update(p => p with { MarqueePointsPerSecond = (double)value }); + var speedRow = SettingsForm.Picker("캐러셀 속도", speedPicker); + var (resetRow, reset) = SettingsForm.Toggle( + "패널을 열면 제목을 처음으로 되돌리기", + Current.ResetsMenuTitleWhenPanelOpens, + on => Update(p => p with { ResetsMenuTitleWhenPanelOpens = on })); + + _refreshers.Add(() => + { + var current = Current; + preview.Update(current); + formatPicker.SelectedValue = current.MenuBarTitleFormat; + artworkPicker.SelectedValue = current.MenuBarArtworkStyle; + scroll.IsChecked = current.AutomaticallyScrollsTitles; + speedPicker.SelectedValue = current.MarqueePointsPerSecond; + reset.IsChecked = current.ResetsMenuTitleWhenPanelOpens; + var hidden = current.MenuBarTitleFormat == MenuBarTitleFormat.Hidden; + scrollRow.IsEnabled = !hidden; + speedRow.IsEnabled = !hidden && current.AutomaticallyScrollsTitles; + resetRow.IsEnabled = !hidden && current.AutomaticallyScrollsTitles; + }); + + return SettingsForm.Page( + SettingsForm.Section("미리보기", null, SettingsForm.Fill(preview)), + SettingsForm.Section( + "현재 재생 중인 곡", + null, + SettingsForm.Picker("텍스트 내용", formatPicker), + SettingsForm.Picker("제목 왼쪽 표시", artworkPicker)), + SettingsForm.Section( + "캐러셀", + "패널의 곡 제목과 GNOME 확장이 그리는 상단 바 항목에 적용됩니다. 확장 없이 트레이만 쓰는 데스크톱에서는 데스크톱이 문자열만 그리므로 줄 전체가 움직이지 않고 표시됩니다.", + scrollRow, + speedRow, + resetRow)); + } + + /// + /// The Panel tab: a time-label preview and the two time styles. + /// + /// The page. + private Control BuildPanelTab() + { + var preview = new PanelTimePreview { Margin = new Thickness(0, 10) }; + var leading = new SegmentedPicker( + Enum.GetValues().Select(style => (style.DisplayName(), (object)style)), + Current.LeadingTimeStyle); + leading.SelectionChanged += (_, value) => Update(p => p with { LeadingTimeStyle = (PanelLeadingTimeStyle)value }); + var trailing = new SegmentedPicker( + Enum.GetValues().Select(style => (style.DisplayName(), (object)style)), + Current.TrailingTimeStyle); + trailing.SelectionChanged += (_, value) => Update(p => p with { TrailingTimeStyle = (PanelTrailingTimeStyle)value }); + + _refreshers.Add(() => + { + preview.Update(Current); + leading.SelectedValue = Current.LeadingTimeStyle; + trailing.SelectedValue = Current.TrailingTimeStyle; + }); + + return SettingsForm.Page( + SettingsForm.Section("미리보기", null, SettingsForm.Fill(preview)), + SettingsForm.Section( + "시간 표시", + null, + SettingsForm.Picker("왼쪽", leading), + SettingsForm.Picker("오른쪽", trailing))); + } + + /// + /// The System info tab: version, updates, OS, displays, and the credit. + /// + /// + /// The credit ends in a text heart rather than the emoji the macOS + /// window uses: the bundled typeface carries no colour emoji, and the + /// fallback picked whichever glyph the system offered, which was not a + /// heart. + /// + /// The page. + private Control BuildSystemInfoTab() + { + var displays = new StackPanel(); + _refreshers.Add(() => + { + displays.Children.Clear(); + var screens = Screens.All; + for (var index = 0; index < screens.Count; index++) + { + if (index > 0) + { + displays.Children.Add(SettingsForm.Hairline()); + } + + var screen = screens[index]; + var name = string.IsNullOrWhiteSpace(screen.DisplayName) ? $"디스플레이 {index + 1}" : screen.DisplayName; + displays.Children.Add(SettingsForm.Value(name, $"{screen.Bounds.Width} × {screen.Bounds.Height}")); + } + }); + + var heart = SettingsForm.Footnote(" with ♥"); + heart.Foreground = new SolidColorBrush(Color.FromRgb(0xE0, 0x4B, 0x55)); + var credit = new StackPanel + { + Orientation = Orientation.Horizontal, + HorizontalAlignment = HorizontalAlignment.Center, + Margin = new Thickness(0, 0, 0, 8), + Children = + { + SettingsForm.Footnote("Made by "), + SettingsForm.Link("aodjo", new Uri("https://junx.dev")), + heart, + }, + }; + foreach (var child in credit.Children.OfType()) + { + child.Margin = new Thickness(0); + child.FontSize = PanelTypography.Subtitle; + } + + var page = new Grid { RowDefinitions = new RowDefinitions("*,Auto") }; + var form = SettingsForm.Page( + SettingsForm.Section("앱", null, SettingsForm.Value("버전", VersionText())), + SettingsForm.Section( + "업데이트", + "Linux 빌드는 GitHub 릴리스로 배포됩니다. 확인을 누르면 최신 릴리스 페이지를 엽니다.", + SettingsForm.Row("업데이트 확인…", SettingsForm.Link("릴리스 열기", new Uri("https://github.com/aodjo/Reprise/releases/latest")))), + SettingsForm.Section("시스템", null, SettingsForm.Value("운영체제", OperatingSystemText())), + SettingsForm.Section("연결된 디스플레이", null, displays)); + page.Children.Add(form); + page.Children.Add(credit); + Grid.SetRow(credit, 1); + return page; + } + + /// + /// The version line for the app section. + /// + /// Text such as 2.0.0-alpha.1. + private static string VersionText() + { + var informational = typeof(SettingsWindow).Assembly + .GetCustomAttribute()? + .InformationalVersion; + return string.IsNullOrWhiteSpace(informational) + ? typeof(SettingsWindow).Assembly.GetName().Version?.ToString(3) ?? "-" + : informational.Split('+', 2)[0]; + } + + /// + /// The distribution and kernel, as the system section shows them. + /// + /// Text such as Ubuntu 24.04.1 LTS (Linux 6.8.0). + private static string OperatingSystemText() + { + var pretty = string.Empty; + try + { + if (File.Exists("/etc/os-release")) + { + pretty = File.ReadLines("/etc/os-release") + .FirstOrDefault(line => line.StartsWith("PRETTY_NAME=", StringComparison.Ordinal))? + ["PRETTY_NAME=".Length..] + .Trim('"') ?? string.Empty; + } + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException) + { + } + + var kernel = $"{Environment.OSVersion.Platform switch { PlatformID.Unix => "Linux", _ => Environment.OSVersion.Platform.ToString() }} {Environment.OSVersion.Version.ToString(3)}"; + return pretty.Length > 0 ? $"{pretty} ({kernel})" : kernel; + } +} diff --git a/src/Reprise.Desktop/StatusItemController.cs b/src/Reprise.Desktop/StatusItemController.cs new file mode 100644 index 0000000..138de53 --- /dev/null +++ b/src/Reprise.Desktop/StatusItemController.cs @@ -0,0 +1,278 @@ +using System.ComponentModel; +using Avalonia.Threading; +using Reprise.Core; + +namespace Reprise.Desktop; + +/// +/// Keeps the tray entry in step with playback, the way the macOS status +/// item follows the menu bar store. +/// +/// +/// Owns the only timer that touches the tray, and pushes an update only +/// when something visible changed, so an idle player costs no bus traffic. +/// +/// The label it publishes is the whole line. A tray label is a string the +/// desktop draws, so it cannot move; swapping in a different slice of the +/// text each tick would read as the letters changing rather than the words +/// sliding past, which is not what a marquee looks like. Scrolling belongs +/// to the surfaces Reprise draws itself: the panel's own title, and the +/// GNOME extension fed through . +/// +/// +public sealed class StatusItemController : IDisposable +{ + /// + /// How close together two tray clicks have to be to count as one. + /// + private static readonly TimeSpan ActivationDebounce = TimeSpan.FromMilliseconds(250); + + /// + /// How often the tray content is recomputed. + /// + /// + /// Fine enough that a lyric line reaches the top bar about when it is + /// sung. Nothing is pushed unless the content actually changed, so a + /// still track costs no bus traffic between lines. + /// + private static readonly TimeSpan RefreshInterval = TimeSpan.FromMilliseconds(80); + + private readonly IStatusItem _item; + private readonly IMenuBarPublisher? _publisher; + private readonly NowPlayingViewModel _viewModel; + private readonly PreferencesStore _preferences; + private readonly DispatcherTimer _timer; + private byte[] _iconPng = []; + private MenuBarState? _lastPublished; + private byte[]? _iconSource; + private IReadOnlyList _icons = []; + private StatusItemState? _lastState; + private DateTimeOffset _lastActivated; + private bool _disposed; + + /// + /// Creates the controller and subscribes to its inputs. + /// + /// Tray entry to drive. + /// Playback state to reflect. + /// Label and icon settings. + /// + /// Feeds a shell extension that draws the top bar itself, when the + /// platform offers one. Null leaves the tray entry as the only surface. + /// + /// + /// + /// var controller = new StatusItemController(statusItem, viewModel, preferences); + /// await controller.StartAsync(); + /// + /// + public StatusItemController( + IStatusItem item, + NowPlayingViewModel viewModel, + PreferencesStore preferences, + IMenuBarPublisher? publisher = null) + { + _item = item; + _publisher = publisher; + _viewModel = viewModel; + _preferences = preferences; + _timer = new DispatcherTimer( + RefreshInterval, + DispatcherPriority.Background, + (_, _) => Refresh()); + + _item.Activated += (_, _) => Dispatcher.UIThread.Post(RaiseActivated); + if (_publisher is not null) + { + _publisher.Activated += (_, _) => Dispatcher.UIThread.Post(RaiseActivated); + } + _viewModel.PropertyChanged += HandleViewModelChanged; + _preferences.Changed += HandlePreferencesChanged; + } + + /// + /// Raised on the UI thread when the tray entry is clicked. + /// + public event EventHandler? Activated; + + /// + /// Registers the tray entry and starts following playback. + /// + /// Cancels the registration. + /// True when the desktop accepted the entry. + public async Task StartAsync(CancellationToken cancellationToken = default) + { + var registered = await _item.StartAsync(cancellationToken); + if (_publisher is not null) + { + await _publisher.StartAsync(cancellationToken); + } + + Refresh(); + _timer.Start(); + return registered; + } + + /// + /// Passes a tray click on, ignoring one that repeats too quickly. + /// + /// + /// Some hosts answer a single click with more than one call - an + /// activation and a context-menu request, say - and the panel toggles, + /// so the two would cancel out and nothing would appear. + /// + private void RaiseActivated() + { + var now = DateTimeOffset.UtcNow; + if (now - _lastActivated < ActivationDebounce) + { + return; + } + + _lastActivated = now; + Activated?.Invoke(this, EventArgs.Empty); + } + + /// + /// Stops updates and releases the tray entry. + /// + public void Dispose() + { + if (_disposed) + { + return; + } + + _disposed = true; + _timer.Stop(); + _viewModel.PropertyChanged -= HandleViewModelChanged; + _preferences.Changed -= HandlePreferencesChanged; + _item.Dispose(); + _publisher?.Dispose(); + } + + /// + /// Recomputes the tray content and pushes it if it changed. + /// + /// + /// Must run on the UI thread, both because the icon renderer needs it + /// and because the view model is read here. + /// + private void Refresh() + { + if (_disposed) + { + return; + } + + var preferences = _preferences.Current; + var session = _viewModel.ActiveSession; + var now = DateTimeOffset.UtcNow; + var lyric = preferences.MenuBarShowsLyrics ? _viewModel.CurrentLyricLine(now)?.Text : null; + var text = MenuBarText.Compose(session, lyric, preferences); + var label = text; + if (preferences.MenuBarShowsLyrics && preferences.MenuBarReservesLabelWidth && label.Length > 0) + { + label = MenuBarText.Reserve(label, preferences.MenuBarLabelLength); + } + + var tooltip = session is null + ? "Reprise" + : string.IsNullOrEmpty(session.Artist) ? session.Title : $"{session.Title} — {session.Artist}"; + + var state = new StatusItemState(label, tooltip, ResolveIcons(session, preferences)); + PublishToShell(session, text, tooltip, preferences); + if (state == _lastState) + { + return; + } + + _lastState = state; + _item.Update(state); + } + + /// + /// Hands the shell extension the material to draw its own item. + /// + /// + /// The extension gets the whole line rather than the stepped label: it + /// scrolls the text itself, pixel by pixel, so cutting it here would + /// only take away what it needs. + /// + /// Session on display, or null. + /// The full line for this moment. + /// Hover text. + /// Current settings. + private void PublishToShell( + MediaSessionSnapshot? session, + string text, + string tooltip, + DesktopPreferences preferences) + { + if (_publisher is null) + { + return; + } + + var state = new MenuBarState( + text, + tooltip, + _iconPng, + session is not null, + _viewModel.IsPlaying, + preferences.AutomaticallyScrollsTitles, + preferences.MarqueePointsPerSecond, + preferences.MenuBarLabelLength); + if (state == _lastPublished) + { + return; + } + + _lastPublished = state; + _publisher.Publish(state); + } + + /// + /// Picks the album cover or the application icon, rendering on change. + /// + /// Session on display, or null. + /// Icon setting. + /// Icons to show, reused while the source is unchanged. + private IReadOnlyList ResolveIcons(MediaSessionSnapshot? session, DesktopPreferences preferences) + { + var artwork = session is not null && preferences.MenuBarArtworkStyle == MenuBarArtworkStyle.AlbumArtwork + ? _viewModel.ArtworkData + : null; + if (ReferenceEquals(artwork, _iconSource) && _icons.Count > 0) + { + return _icons; + } + + _iconSource = artwork; + _icons = artwork is null + ? TrayIconRenderer.ApplicationIcons() + : TrayIconRenderer.RenderArtwork(artwork); + _iconPng = _publisher is null ? [] : TrayIconRenderer.RenderPng(artwork, 32); + return _icons; + } + + /// + /// Refreshes promptly when playback state changes. + /// + /// The view model. + /// Unused. + private void HandleViewModelChanged(object? sender, PropertyChangedEventArgs e) + { + Dispatcher.UIThread.Post(Refresh); + } + + /// + /// Refreshes when a tray setting changes. + /// + /// The preferences store. + /// Unused. + private void HandlePreferencesChanged(object? sender, EventArgs e) + { + Refresh(); + } +} diff --git a/src/Reprise.Desktop/TrayIconRenderer.cs b/src/Reprise.Desktop/TrayIconRenderer.cs new file mode 100644 index 0000000..b6f627f --- /dev/null +++ b/src/Reprise.Desktop/TrayIconRenderer.cs @@ -0,0 +1,261 @@ +using System.Runtime.InteropServices; +using Avalonia; +using Avalonia.Media; +using Avalonia.Media.Imaging; +using Avalonia.Platform; + +namespace Reprise.Desktop; + +/// +/// Rasterises tray icons into the pixel layout tray protocols want. +/// +/// +/// Tray hosts take raw ARGB pixmaps rather than image files, and want them +/// at panel sizes. Both the album cover and the application icon are drawn +/// through Avalonia's renderer here and read back, so the tray shows the +/// same rounded cover the panel does. Must be called on the UI thread. +/// +public static class TrayIconRenderer +{ + /// + /// Edge lengths rendered for every icon, covering common panel heights. + /// + /// + /// Hosts pick the pixmap nearest their icon size and scale it, so + /// offering the sizes GNOME, KDE, and Xfce actually use keeps the cover + /// crisp rather than resampled. + /// + public static readonly int[] Sizes = [16, 22, 24, 32]; + + /// + /// Share of the pixmap the album cover fills; the rest stays clear. + /// + /// + /// A cover drawn edge to edge stands taller than the label beside it, + /// because a panel sizes icons to the line height while text only + /// reaches its cap height. Insetting brings the two level, the way the + /// macOS menu bar draws an 18-point cover in a 22-point item. + /// + public const double ArtworkScale = 0.82; + + private static readonly Uri ApplicationIconUri = new("avares://Reprise.Desktop/Assets/reprise.png"); + private static IReadOnlyList? _applicationIcons; + + /// + /// Renders an album cover as rounded tray icons. + /// + /// Encoded cover image. + /// + /// One icon per entry of , or the application icons + /// when the data cannot be decoded. + /// + /// + /// + /// var icons = TrayIconRenderer.RenderArtwork(viewModel.ArtworkData!); + /// + /// + public static IReadOnlyList RenderArtwork(byte[] artwork) + { + ArgumentNullException.ThrowIfNull(artwork); + + Bitmap cover; + try + { + using var stream = new MemoryStream(artwork); + cover = Bitmap.DecodeToWidth(stream, Sizes.Max() * 2); + } + catch (Exception) + { + return ApplicationIcons(); + } + + using (cover) + { + return Sizes.Select(size => Render(size, (context, bounds) => + { + var target = Inset(bounds); + using var clip = context.PushClip(new RoundedRect(target, target.Width * 0.22)); + context.DrawImage(cover, CenteredSquare(cover.PixelSize), target); + })).ToList(); + } + } + + /// + /// The Reprise icon at every tray size. + /// + /// + /// Rendered once and cached, since the asset never changes. + /// + /// One icon per entry of . + public static IReadOnlyList ApplicationIcons() + { + if (_applicationIcons is not null) + { + return _applicationIcons; + } + + using var stream = AssetLoader.Open(ApplicationIconUri); + using var source = new Bitmap(stream); + _applicationIcons = Sizes.Select(size => Render(size, (context, bounds) => + context.DrawImage(source, new Rect(source.Size), Inset(bounds)))).ToList(); + return _applicationIcons; + } + + /// + /// Renders an album cover as a PNG for consumers that decode images. + /// + /// + /// The StatusNotifierItem protocol takes raw pixels, but a shell + /// extension loads an image far more easily than it assembles a + /// pixmap, so the same cover is offered in both forms. + /// + /// Encoded cover image, or null for the app icon. + /// Edge length in pixels. + /// PNG bytes, or an empty array when nothing could be drawn. + /// + /// + /// var png = TrayIconRenderer.RenderPng(viewModel.ArtworkData, 32); + /// + /// + public static byte[] RenderPng(byte[]? artwork, int size) + { + Bitmap? cover = null; + try + { + if (artwork is not null) + { + using var source = new MemoryStream(artwork); + cover = Bitmap.DecodeToWidth(source, size * 2); + } + } + catch (Exception) + { + cover = null; + } + + try + { + using var target = new RenderTargetBitmap(new PixelSize(size, size), new Vector(96, 96)); + using (var context = target.CreateDrawingContext(clear: true)) + { + var bounds = new Rect(0, 0, size, size); + if (cover is not null) + { + using var clip = context.PushClip(new RoundedRect(bounds, size * 0.22)); + context.DrawImage(cover, CenteredSquare(cover.PixelSize), bounds); + } + else + { + using var stream = AssetLoader.Open(ApplicationIconUri); + using var icon = new Bitmap(stream); + context.DrawImage(icon, new Rect(icon.Size), bounds); + } + } + + using var png = new MemoryStream(); + target.Save(png, PngBitmapEncoderOptions.Default); + return png.ToArray(); + } + catch (Exception) + { + return []; + } + finally + { + cover?.Dispose(); + } + } + + /// + /// Converts a bitmap's pixels into straight-alpha ARGB bytes. + /// + /// + /// Avalonia's render targets hold premultiplied BGRA; tray hosts expect + /// straight ARGB in byte order. Both the channel swap and the + /// un-premultiply happen here. + /// + /// Bitmap to read. + /// The icon with four bytes per pixel in A, R, G, B order. + public static StatusItemIcon ToStatusItemIcon(Bitmap bitmap) + { + ArgumentNullException.ThrowIfNull(bitmap); + + var width = bitmap.PixelSize.Width; + var height = bitmap.PixelSize.Height; + var stride = width * 4; + var pixels = new byte[stride * height]; + var handle = GCHandle.Alloc(pixels, GCHandleType.Pinned); + try + { + bitmap.CopyPixels(new PixelRect(0, 0, width, height), handle.AddrOfPinnedObject(), pixels.Length, stride); + } + finally + { + handle.Free(); + } + + var redFirst = bitmap.Format == PixelFormats.Rgba8888; + var premultiplied = bitmap.AlphaFormat != AlphaFormat.Unpremul; + var argb = new byte[pixels.Length]; + for (var offset = 0; offset < pixels.Length; offset += 4) + { + var alpha = pixels[offset + 3]; + var red = redFirst ? pixels[offset] : pixels[offset + 2]; + var green = pixels[offset + 1]; + var blue = redFirst ? pixels[offset + 2] : pixels[offset]; + if (premultiplied && alpha is > 0 and < 255) + { + red = (byte)Math.Min(255, red * 255 / alpha); + green = (byte)Math.Min(255, green * 255 / alpha); + blue = (byte)Math.Min(255, blue * 255 / alpha); + } + + argb[offset] = alpha; + argb[offset + 1] = red; + argb[offset + 2] = green; + argb[offset + 3] = blue; + } + + return new StatusItemIcon(width, height, argb); + } + + /// + /// Draws into a square render target and reads it back. + /// + /// Edge length in pixels. + /// Draws the icon into the given bounds. + /// The rendered icon. + private static StatusItemIcon Render(int size, Action draw) + { + using var target = new RenderTargetBitmap(new PixelSize(size, size), new Vector(96, 96)); + using (var context = target.CreateDrawingContext(clear: true)) + { + draw(context, new Rect(0, 0, size, size)); + } + + return ToStatusItemIcon(target); + } + + /// + /// The area an icon is drawn into, inset by . + /// + /// Full pixmap bounds. + /// A centred square on whole pixels. + private static Rect Inset(Rect bounds) + { + var edge = Math.Round(bounds.Width * ArtworkScale); + var offset = Math.Floor((bounds.Width - edge) / 2); + return new Rect(offset, offset, edge, edge); + } + + /// + /// The largest centred square inside an image, for a uniform-to-fill crop. + /// + /// Image size in pixels. + /// The source rectangle to draw. + private static Rect CenteredSquare(PixelSize size) + { + var edge = Math.Min(size.Width, size.Height); + return new Rect((size.Width - edge) / 2.0, (size.Height - edge) / 2.0, edge, edge); + } +} diff --git a/src/Reprise.Platform.Linux/.gitkeep b/src/Reprise.Platform.Linux/.gitkeep deleted file mode 100644 index 8b13789..0000000 --- a/src/Reprise.Platform.Linux/.gitkeep +++ /dev/null @@ -1 +0,0 @@ - diff --git a/src/Reprise.Platform.Linux/AssemblyInfo.cs b/src/Reprise.Platform.Linux/AssemblyInfo.cs new file mode 100644 index 0000000..2090c73 --- /dev/null +++ b/src/Reprise.Platform.Linux/AssemblyInfo.cs @@ -0,0 +1,8 @@ +using System.Runtime.CompilerServices; + +// Opens the MPRIS internals to the Linux test assembly. The IMprisBus seam and +// MprisPropertyMapper are internal because they expose Tmds.DBus types that are +// not part of the public surface, but they are also where the parsing and +// command-routing logic worth testing lives. Granting the test assembly access +// keeps them testable without widening the API. +[assembly: InternalsVisibleTo("Reprise.Platform.Linux.Tests")] diff --git a/src/Reprise.Platform.Linux/Mpris/DBusMprisBus.cs b/src/Reprise.Platform.Linux/Mpris/DBusMprisBus.cs new file mode 100644 index 0000000..82e9a2f --- /dev/null +++ b/src/Reprise.Platform.Linux/Mpris/DBusMprisBus.cs @@ -0,0 +1,458 @@ +using Tmds.DBus.Protocol; + +namespace Reprise.Platform.Linux.Mpris; + +/// +/// Talks to MPRIS players directly over the D-Bus session bus. +/// +/// +/// Reprise used to shell out to playerctl for this, which meant asking +/// every user to install a package before the app would do anything. +/// Tmds.DBus is already in the published output by way of Avalonia's +/// FreeDesktop support, so speaking the protocol here removes that dependency +/// at no size cost - and skips a process launch per poll, which happens once +/// a second. +/// +/// Every call routes through the connection Tmds.DBus owns for this process, +/// so the bus is connected once rather than per request. +/// +/// +internal sealed class DBusMprisBus : IMprisBus +{ + /// + /// Bus-name prefix that identifies a process as an MPRIS player. + /// + /// + /// Fixed by the MPRIS specification. Also serves as the boundary between + /// a bus name and the PlayerId Reprise shows, which is why it is visible + /// to rather than private. + /// + internal const string ServicePrefix = "org.mpris.MediaPlayer2."; + + /// + /// Object path every MPRIS player exports; fixed by the specification. + /// + private const string ObjectPath = "/org/mpris/MediaPlayer2"; + + /// + /// Interface carrying playback state and the transport methods. + /// + private const string PlayerInterface = "org.mpris.MediaPlayer2.Player"; + + /// + /// Standard D-Bus interface used to bulk-read the player's properties. + /// + private const string PropertiesInterface = "org.freedesktop.DBus.Properties"; + + /// + /// How long any single bus call may take before it is abandoned. + /// + /// + /// A wedged player would otherwise stall the UI's one-second refresh + /// indefinitely, since D-Bus method calls have no timeout of their own. + /// + private static readonly TimeSpan CallTimeout = TimeSpan.FromSeconds(5); + + /// + /// Decodes a GetAll reply body into a property dictionary. + /// + /// + /// Cached in a static field because Tmds.DBus invokes it per reply, and + /// an inline lambda would allocate a new closure on each call. + /// + private static readonly MessageValueReader> + PropertiesReader = static (Message message, object? _) => + { + var reader = message.GetBodyReader(); + return reader.ReadDictionaryOfStringToVariantValue(); + }; + + /// + /// Lists the bus names of every MPRIS player currently on the session bus. + /// + /// + /// Discovery works by filtering all owned bus names, because MPRIS has no + /// registry of its own. Results are ordered by name so repeated polls + /// present players in a stable sequence rather than whatever order the + /// bus happens to return. + /// + /// + /// Cancels the query. Defaults to default. + /// + /// + /// Bus names beginning with the MPRIS prefix, sorted ordinally. Empty + /// when no player is running. + /// + /// + /// Thrown when the session bus is unreachable. + /// + /// + /// Thrown when the bus does not reply within . + /// + /// + /// + /// var names = await bus.ListPlayerServicesAsync(); + /// // ["org.mpris.MediaPlayer2.firefox.instance42", "org.mpris.MediaPlayer2.spotify"] + /// + /// + public async Task> ListPlayerServicesAsync( + CancellationToken cancellationToken = default) + { + var connection = await ConnectAsync(cancellationToken); + var services = await connection + .ListServicesAsync() + .WaitAsync(CallTimeout, cancellationToken); + + return + [ + .. services + .Where(service => service.StartsWith(ServicePrefix, StringComparison.Ordinal)) + .OrderBy(service => service, StringComparer.Ordinal), + ]; + } + + /// + /// Reads every property one player exposes on its Player interface. + /// + /// + /// Uses a single GetAll rather than one call per property, so a + /// snapshot costs one round trip and cannot capture a track title and + /// position from two different moments. + /// + /// A player quitting between being listed and being queried is routine + /// rather than exceptional - the poll loop races every closing window - + /// so that case returns empty instead of throwing. + /// + /// + /// + /// Bus name from . + /// + /// + /// Cancels the query. Defaults to default. + /// + /// + /// Properties keyed by MPRIS property name, or an empty dictionary when + /// the player has left the bus. + /// + /// + /// Thrown when the session bus is unreachable. + /// + /// + /// Thrown when the player does not reply within . + /// + /// + /// + /// var properties = await bus.GetPlayerPropertiesAsync( + /// "org.mpris.MediaPlayer2.spotify"); + /// + /// + public async Task> GetPlayerPropertiesAsync( + string serviceName, + CancellationToken cancellationToken = default) + { + var connection = await ConnectAsync(cancellationToken); + try + { + return await connection + .CallMethodAsync(CreateGetAllMessage(connection, serviceName), PropertiesReader) + .WaitAsync(CallTimeout, cancellationToken); + } + catch (DBusErrorReplyException) + { + return new Dictionary(); + } + } + + /// + /// Calls a no-argument method on one player's Player interface. + /// + /// + /// Unlike a property read, a rejected command is worth surfacing: it + /// means a control the user just pressed did nothing. The D-Bus error is + /// left to propagate so the caller can turn it into a message. + /// + /// Bus name of the target player. + /// MPRIS method name, such as PlayPause. + /// + /// Cancels the call. Defaults to default. + /// + /// A task that completes once the player has replied. + /// + /// Thrown when the session bus is unreachable. + /// + /// + /// Thrown when the player rejects the call, which includes the case where + /// it has already quit. + /// + /// + /// Thrown when the player does not reply within . + /// + /// + /// + /// await bus.InvokeAsync("org.mpris.MediaPlayer2.spotify", "Next"); + /// + /// + public async Task InvokeAsync( + string serviceName, + string member, + CancellationToken cancellationToken = default) + { + var connection = await ConnectAsync(cancellationToken); + await connection + .CallMethodAsync(CreatePlayerCallMessage(connection, serviceName, member)) + .WaitAsync(CallTimeout, cancellationToken); + } + + /// + /// Moves one player to an absolute position within a track. + /// + /// + /// Goes through SetPosition rather than the relative Seek + /// because the panel knows where the user let go of the scrubber, not + /// how far that is from wherever playback has drifted to since the last + /// poll. Rejections propagate for the same reason as + /// . + /// + /// Bus name of the target player. + /// Object path of the track being seeked. + /// + /// Offset from the start of the track, in microseconds. + /// + /// + /// Cancels the call. Defaults to default. + /// + /// A task that completes once the player has replied. + /// + /// Thrown when the session bus is unreachable. + /// + /// + /// Thrown when the player rejects the call, which includes a player that + /// cannot seek or has already quit. + /// + /// + /// Thrown when the player does not reply within . + /// + /// + /// + /// await bus.SetPositionAsync(name, "/com/spotify/track/1", 90_000_000); + /// + /// + public async Task SetPositionAsync( + string serviceName, + string trackId, + long positionMicroseconds, + CancellationToken cancellationToken = default) + { + var connection = await ConnectAsync(cancellationToken); + await connection + .CallMethodAsync(CreateSetPositionMessage( + connection, + serviceName, + trackId, + positionMicroseconds)) + .WaitAsync(CallTimeout, cancellationToken); + } + + /// + /// Writes the Volume property of one player. + /// + /// + /// Uses the standard Properties.Set call, since MPRIS exposes + /// volume as a writable property rather than a method. + /// + /// Bus name of the target player. + /// Level from 0 to 1. + /// + /// Cancels the call. Defaults to default. + /// + /// A task that completes once the player has replied. + /// + /// Thrown when the session bus is unreachable. + /// + /// + /// Thrown when the player rejects the write. + /// + /// + /// Thrown when the player does not reply within . + /// + /// + /// + /// await bus.SetVolumeAsync("org.mpris.MediaPlayer2.spotify", 0.5); + /// + /// + public async Task SetVolumeAsync( + string serviceName, + double volume, + CancellationToken cancellationToken = default) + { + var connection = await ConnectAsync(cancellationToken); + await connection + .CallMethodAsync(CreateSetVolumeMessage(connection, serviceName, volume)) + .WaitAsync(CallTimeout, cancellationToken); + } + + /// + /// Builds the GetAll request for a player's Player interface. + /// + /// + /// Separate from only because + /// MessageWriter is a ref struct and so cannot live across an + /// await; the compiler rejects it inside an async method. + /// + /// + /// Connection whose writer allocates the buffer and assigns the message + /// serial. + /// + /// Bus name of the target player. + /// An encoded method call ready to send. + private static MessageBuffer CreateGetAllMessage( + DBusConnection connection, + string serviceName) + { + using var writer = connection.GetMessageWriter(); + writer.WriteMethodCallHeader( + destination: serviceName, + path: ObjectPath, + @interface: PropertiesInterface, + member: "GetAll", + signature: "s"); + writer.WriteString(PlayerInterface); + return writer.CreateMessage(); + } + + /// + /// Builds a no-argument method call against a player's Player interface. + /// + /// + /// Split out for the same ref struct reason as + /// . + /// + /// + /// Connection whose writer allocates the buffer and assigns the message + /// serial. + /// + /// Bus name of the target player. + /// MPRIS method name to invoke. + /// An encoded method call ready to send. + private static MessageBuffer CreatePlayerCallMessage( + DBusConnection connection, + string serviceName, + string member) + { + using var writer = connection.GetMessageWriter(); + writer.WriteMethodCallHeader( + destination: serviceName, + path: ObjectPath, + @interface: PlayerInterface, + member: member); + return writer.CreateMessage(); + } + + /// + /// Builds the SetPosition call for a player's Player interface. + /// + /// + /// Split out for the same ref struct reason as + /// . + /// + /// + /// Connection whose writer allocates the buffer and assigns the message + /// serial. + /// + /// Bus name of the target player. + /// Object path of the track being seeked. + /// Target offset in microseconds. + /// An encoded method call ready to send. + private static MessageBuffer CreateSetPositionMessage( + DBusConnection connection, + string serviceName, + string trackId, + long positionMicroseconds) + { + using var writer = connection.GetMessageWriter(); + writer.WriteMethodCallHeader( + destination: serviceName, + path: ObjectPath, + @interface: PlayerInterface, + member: "SetPosition", + signature: "ox"); + writer.WriteObjectPath(trackId); + writer.WriteInt64(positionMicroseconds); + return writer.CreateMessage(); + } + + /// + /// Builds the Properties.Set call that writes a player's volume. + /// + /// + /// Split out for the same ref struct reason as + /// . + /// + /// + /// Connection whose writer allocates the buffer and assigns the message + /// serial. + /// + /// Bus name of the target player. + /// Level from 0 to 1. + /// An encoded method call ready to send. + private static MessageBuffer CreateSetVolumeMessage( + DBusConnection connection, + string serviceName, + double volume) + { + using var writer = connection.GetMessageWriter(); + writer.WriteMethodCallHeader( + destination: serviceName, + path: ObjectPath, + @interface: PropertiesInterface, + member: "Set", + signature: "ssv"); + writer.WriteString(PlayerInterface); + writer.WriteString(MprisPropertyMapper.VolumeKey); + writer.WriteVariantDouble(volume); + return writer.CreateMessage(); + } + + /// + /// Returns the process-wide session bus connection, opening it on demand. + /// + /// + /// DBusConnection.Session is shared and owned by Tmds.DBus, so + /// connecting an already connected instance completes synchronously. That + /// makes this cheap enough to call at the head of every operation rather + /// than tracking connection state here. + /// + /// Any transport failure is translated into + /// , which turns a low-level socket + /// or address error into something the UI can explain. Cancellation is + /// deliberately excluded from that translation so it stays + /// distinguishable from a genuine failure. + /// + /// + /// + /// Cancels before and during the connection attempt. + /// + /// A connected session bus. + /// + /// Thrown when the session bus cannot be reached. + /// + /// + /// Thrown when is cancelled. + /// + private static async ValueTask ConnectAsync( + CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + + try + { + var connection = DBusConnection.Session; + await connection.ConnectAsync(); + return connection; + } + catch (Exception exception) when (exception is not OperationCanceledException) + { + throw new MprisUnavailableException(exception); + } + } +} diff --git a/src/Reprise.Platform.Linux/Mpris/IMprisBus.cs b/src/Reprise.Platform.Linux/Mpris/IMprisBus.cs new file mode 100644 index 0000000..eb3525c --- /dev/null +++ b/src/Reprise.Platform.Linux/Mpris/IMprisBus.cs @@ -0,0 +1,155 @@ +using Tmds.DBus.Protocol; + +namespace Reprise.Platform.Linux.Mpris; + +/// +/// The D-Bus operations MPRIS support is built from. +/// +/// +/// Exists to keep testable: a real +/// session bus needs a live desktop session and running media players, +/// neither of which CI has. Narrowing the transport to these calls lets the +/// service's session-building and command-routing logic run against an +/// in-memory fake, while everything that genuinely touches D-Bus stays in +/// . +/// +internal interface IMprisBus +{ + /// + /// Lists the bus names of every MPRIS player currently on the session bus. + /// + /// + /// Cancels the query. Defaults to default. + /// + /// + /// Fully qualified bus names, each beginning with + /// org.mpris.MediaPlayer2.. Empty when no player is running. + /// + /// + /// Thrown when the session bus is unreachable. + /// + /// + /// + /// var names = await bus.ListPlayerServicesAsync(); + /// // ["org.mpris.MediaPlayer2.spotify"] + /// + /// + Task> ListPlayerServicesAsync( + CancellationToken cancellationToken = default); + + /// + /// Reads every property one player exposes on its Player interface. + /// + /// + /// Bus name from . + /// + /// + /// Cancels the query. Defaults to default. + /// + /// + /// The player's properties keyed by MPRIS property name, or an empty + /// dictionary when the player is no longer reachable. + /// + /// + /// Thrown when the session bus is unreachable. + /// + /// + /// + /// var properties = await bus.GetPlayerPropertiesAsync(name); + /// var status = properties["PlaybackStatus"].GetString(); // "Playing" + /// + /// + Task> GetPlayerPropertiesAsync( + string serviceName, + CancellationToken cancellationToken = default); + + /// + /// Calls a no-argument method on one player's Player interface. + /// + /// Bus name of the target player. + /// MPRIS method name, such as PlayPause. + /// + /// Cancels the call. Defaults to default. + /// + /// A task that completes once the player has replied. + /// + /// Thrown when the session bus is unreachable. + /// + /// + /// Thrown when the player rejects the call. + /// + /// + /// + /// await bus.InvokeAsync("org.mpris.MediaPlayer2.spotify", "Next"); + /// + /// + Task InvokeAsync( + string serviceName, + string member, + CancellationToken cancellationToken = default); + + /// + /// Moves one player to an absolute position within a track. + /// + /// + /// MPRIS ties an absolute seek to a track id so a request that arrives + /// after the track has changed is ignored by the player instead of + /// landing somewhere in the next song. + /// + /// Bus name of the target player. + /// + /// Object path of the track the position belongs to, as published in + /// the player's mpris:trackid metadata. + /// + /// + /// Offset from the start of the track, in microseconds. + /// + /// + /// Cancels the call. Defaults to default. + /// + /// A task that completes once the player has replied. + /// + /// Thrown when the session bus is unreachable. + /// + /// + /// Thrown when the player rejects the call. + /// + /// + /// + /// await bus.SetPositionAsync( + /// "org.mpris.MediaPlayer2.spotify", + /// "/com/spotify/track/1", + /// 90_000_000); + /// + /// + Task SetPositionAsync( + string serviceName, + string trackId, + long positionMicroseconds, + CancellationToken cancellationToken = default); + + /// + /// Writes the Volume property of one player. + /// + /// Bus name of the target player. + /// Level from 0 to 1. + /// + /// Cancels the call. Defaults to default. + /// + /// A task that completes once the player has replied. + /// + /// Thrown when the session bus is unreachable. + /// + /// + /// Thrown when the player rejects the write. + /// + /// + /// + /// await bus.SetVolumeAsync("org.mpris.MediaPlayer2.spotify", 0.5); + /// + /// + Task SetVolumeAsync( + string serviceName, + double volume, + CancellationToken cancellationToken = default); +} diff --git a/src/Reprise.Platform.Linux/Mpris/MprisMediaSessionService.cs b/src/Reprise.Platform.Linux/Mpris/MprisMediaSessionService.cs new file mode 100644 index 0000000..8d64840 --- /dev/null +++ b/src/Reprise.Platform.Linux/Mpris/MprisMediaSessionService.cs @@ -0,0 +1,362 @@ +using Reprise.Core; +using Tmds.DBus.Protocol; + +namespace Reprise.Platform.Linux.Mpris; + +/// +/// Linux implementation of , backed by MPRIS +/// over D-Bus. +/// +/// +/// Composes the two halves of MPRIS support - the bus transport and the +/// property mapping - into the platform-neutral contract the desktop layer +/// consumes, and is the only public entry point into this namespace. +/// +public sealed class MprisMediaSessionService : IMediaSessionService +{ + private readonly IMprisBus _bus; + + /// + /// Creates a service bound to the process-wide D-Bus session connection. + /// + /// + /// The constructor the application uses; the bus is not contacted until + /// the first call, so constructing this outside a desktop session is + /// safe. + /// + /// + /// + /// RepriseApplication.MediaSessionServiceFactory = + /// static () => new MprisMediaSessionService(); + /// + /// + public MprisMediaSessionService() + : this(new DBusMprisBus()) + { + } + + /// + /// Creates a service over a supplied bus, for tests. + /// + /// + /// Internal rather than public because exposes + /// Tmds.DBus types that are an implementation detail; the seam is visible + /// to the test assembly through InternalsVisibleTo. + /// + /// Transport to route every call through. + internal MprisMediaSessionService(IMprisBus bus) + { + _bus = bus; + } + + /// + /// Reads the current state of every MPRIS player on the session bus. + /// + /// + /// Discovers players first, then queries each one in turn. The queries + /// are sequential on purpose: they share a single bus connection, so + /// issuing them concurrently would gain little while multiplying the work + /// a slow player can hold up. + /// + /// All players share one timestamp taken before the queries begin, so the + /// recency tie-break in does not + /// quietly favour whichever player happened to be polled last. + /// + /// + /// Players that quit mid-sweep are dropped rather than reported as empty + /// sessions, since a window closing during a poll is routine. + /// + /// + /// + /// Cancels the sweep. Defaults to default. + /// + /// + /// One snapshot per reachable player, ordered by bus name. Empty when + /// nothing is running. + /// + /// + /// Thrown when the session bus is unreachable. + /// + /// + /// Thrown when is cancelled. + /// + /// + /// + /// var sessions = await service.GetSessionsAsync(); + /// var active = ActiveSessionSelector.Select(sessions); + /// + /// + public async Task> GetSessionsAsync( + CancellationToken cancellationToken = default) + { + var serviceNames = await _bus.ListPlayerServicesAsync(cancellationToken); + if (serviceNames.Count == 0) + { + return []; + } + + var observedAt = DateTimeOffset.UtcNow; + var sessions = new List(serviceNames.Count); + foreach (var serviceName in serviceNames) + { + var properties = await _bus.GetPlayerPropertiesAsync( + serviceName, + cancellationToken); + if (properties.Count == 0) + { + continue; + } + + sessions.Add(MprisPropertyMapper.ToSnapshot( + MprisPropertyMapper.ToPlayerId(serviceName), + properties, + observedAt)); + } + + return sessions; + } + + /// + /// Sends a transport command to one MPRIS player. + /// + /// + /// Translates the command into the matching method on the player's Player + /// interface and calls it on the addressed player only. + /// + /// A D-Bus error reply is rewritten as an + /// so the failure crosses the + /// boundary without leaking a Tmds.DBus + /// type into the desktop layer, which has no reference to it. The bus + /// error text is folded into the message because it is what distinguishes + /// a player that refuses a command - a stream with no next track, say - + /// from one that has quit. + /// + /// + /// + /// of the target player. + /// + /// Transport control to invoke. + /// + /// Cancels the call. Defaults to default. + /// + /// A task that completes once the player accepted the command. + /// + /// Thrown when is null, empty, or whitespace. + /// + /// + /// Thrown when is not a defined value. + /// + /// + /// Thrown when the player rejects the command or has left the bus. + /// + /// + /// Thrown when the session bus is unreachable. + /// + /// + /// Thrown when is cancelled. + /// + /// + /// + /// await service.SendCommandAsync("spotify", PlaybackCommand.Next); + /// + /// + public async Task SendCommandAsync( + string playerId, + PlaybackCommand command, + CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(playerId); + var member = command switch + { + PlaybackCommand.Previous => "Previous", + PlaybackCommand.PlayPause => "PlayPause", + PlaybackCommand.Next => "Next", + PlaybackCommand.Pause => "Pause", + _ => throw new ArgumentOutOfRangeException(nameof(command)), + }; + + try + { + await _bus.InvokeAsync( + MprisPropertyMapper.ToServiceName(playerId), + member, + cancellationToken); + } + catch (DBusErrorReplyException exception) + { + throw Rejected(member, exception); + } + } + + /// + /// Moves one MPRIS player to an absolute position in its current track. + /// + /// + /// MPRIS requires the id of the track being seeked, which the panel does + /// not carry, so the player's properties are read first. That extra round + /// trip is confined to seeks, which are rare next to the once-a-second + /// poll. A player that publishes no track id cannot be seeked + /// absolutely and is reported as such rather than guessed at. + /// + /// + /// of the target player. + /// + /// Offset from the start of the track. + /// + /// Cancels the request. Defaults to default. + /// + /// A task that completes once the player accepted the seek. + /// + /// Thrown when is null, empty, or whitespace. + /// + /// + /// Thrown when is negative. + /// + /// + /// Thrown when the player has left the bus, publishes no track id, or + /// rejects the seek. + /// + /// + /// Thrown when the session bus is unreachable. + /// + /// + /// Thrown when is cancelled. + /// + /// + /// + /// await service.SeekAsync("spotify", TimeSpan.FromSeconds(90)); + /// + /// + public async Task SeekAsync( + string playerId, + TimeSpan position, + CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(playerId); + ArgumentOutOfRangeException.ThrowIfLessThan(position, TimeSpan.Zero); + + var serviceName = MprisPropertyMapper.ToServiceName(playerId); + var properties = await _bus.GetPlayerPropertiesAsync(serviceName, cancellationToken); + if (properties.Count == 0) + { + throw new InvalidOperationException( + $"MPRIS player {playerId} is no longer running."); + } + + var trackId = MprisPropertyMapper.ReadTrackId(properties) + ?? throw new InvalidOperationException( + $"MPRIS player {playerId} does not report a track id, so it cannot be seeked."); + + try + { + await _bus.SetPositionAsync( + serviceName, + trackId, + (long)Math.Round(position.TotalMicroseconds), + cancellationToken); + } + catch (DBusErrorReplyException exception) + { + throw Rejected("SetPosition", exception); + } + } + + /// + /// Sets the output volume of one MPRIS player. + /// + /// + /// The level is clamped into the 0..1 range MPRIS defines before it is + /// written, so a slider that overshoots by rounding cannot trip a + /// player's validation. + /// + /// + /// of the target player. + /// + /// Level from 0 to 1. + /// + /// Cancels the request. Defaults to default. + /// + /// A task that completes once the player accepted the level. + /// + /// Thrown when is null, empty, or whitespace. + /// + /// + /// Thrown when is not a number. + /// + /// + /// Thrown when the player rejects the write or has left the bus. + /// + /// + /// Thrown when the session bus is unreachable. + /// + /// + /// Thrown when is cancelled. + /// + /// + /// + /// await service.SetVolumeAsync("spotify", 0.5); + /// + /// + public async Task SetVolumeAsync( + string playerId, + double volume, + CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(playerId); + if (double.IsNaN(volume)) + { + throw new ArgumentOutOfRangeException(nameof(volume), "Volume must be a number."); + } + + try + { + await _bus.SetVolumeAsync( + MprisPropertyMapper.ToServiceName(playerId), + Math.Clamp(volume, 0, 1), + cancellationToken); + } + catch (DBusErrorReplyException exception) + { + throw Rejected("Volume", exception); + } + } + + /// + /// Wraps a D-Bus rejection in the exception the desktop layer expects. + /// + /// + /// Players written against scripting bindings reply with a whole stack + /// trace as the error text. Only its last line says what went wrong, so + /// that is what the panel gets, capped to a length that fits a banner. + /// The full reply survives as the inner exception for logs. + /// + /// MPRIS member or property that was rejected. + /// The bus error reply. + /// An exception carrying a one-line description. + /// + /// + /// throw Rejected("SetPosition", exception); + /// // "MPRIS SetPosition failed: Unknown method: SetPosition is not a valid method" + /// + /// + private static InvalidOperationException Rejected( + string operation, + DBusErrorReplyException exception) + { + var detail = exception.ErrorMessage? + .Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .LastOrDefault(); + if (string.IsNullOrEmpty(detail)) + { + detail = exception.ErrorName ?? "the player rejected the request"; + } + + const int maximumLength = 200; + if (detail.Length > maximumLength) + { + detail = detail[..(maximumLength - 1)] + "…"; + } + + return new InvalidOperationException($"MPRIS {operation} failed: {detail}", exception); + } +} diff --git a/src/Reprise.Platform.Linux/Mpris/MprisPropertyMapper.cs b/src/Reprise.Platform.Linux/Mpris/MprisPropertyMapper.cs new file mode 100644 index 0000000..2d1d90a --- /dev/null +++ b/src/Reprise.Platform.Linux/Mpris/MprisPropertyMapper.cs @@ -0,0 +1,501 @@ +using System.Globalization; +using Reprise.Core; +using Tmds.DBus.Protocol; + +namespace Reprise.Platform.Linux.Mpris; + +/// +/// Converts raw MPRIS property values into the shape Reprise.Core expects. +/// +/// +/// D-Bus is loosely typed at the edges: the same field can arrive as a plain +/// value or wrapped in one or more variants, track length can be signed or +/// unsigned, and artist can be a lone string or an array of them. Players +/// also disagree about which properties they publish at all. Everything here +/// is therefore written to degrade rather than throw - a field that is +/// missing, nested unexpectedly, or of the wrong type yields the neutral +/// value for its slot, so one eccentric player cannot take down the whole +/// snapshot. +/// +/// Being pure and free of any bus dependency, this is also where the parsing +/// rules are covered by tests. +/// +/// +internal static class MprisPropertyMapper +{ + /// + /// Player property naming the transport state, published as a string. + /// + internal const string PlaybackStatusKey = "PlaybackStatus"; + + /// + /// Player property holding the nested per-track metadata dictionary. + /// + internal const string MetadataKey = "Metadata"; + + /// + /// Player property holding the elapsed time, in microseconds. + /// + internal const string PositionKey = "Position"; + + /// + /// Player property holding the volume, nominally from 0 to 1. + /// + internal const string VolumeKey = "Volume"; + + /// + /// Metadata key for the player's opaque track identifier, an object path. + /// + internal const string TrackIdKey = "mpris:trackid"; + + /// + /// Track id a player publishes when no track is loaded at all. + /// + /// + /// Reserved by the MPRIS specification. Seeking against it is + /// meaningless, so it is reported as no track id rather than passed on. + /// + internal const string NoTrackId = "/org/mpris/MediaPlayer2/TrackList/NoTrack"; + + /// + /// Metadata key for the track title, from the Xesam ontology. + /// + internal const string TitleKey = "xesam:title"; + + /// + /// Metadata key for the artist credits, published as a string or an array. + /// + internal const string ArtistKey = "xesam:artist"; + + /// + /// Metadata key for the album name, from the Xesam ontology. + /// + internal const string AlbumKey = "xesam:album"; + + /// + /// Metadata key for the track length, in microseconds. + /// + internal const string LengthKey = "mpris:length"; + + /// + /// Metadata key for the absolute cover art URI. + /// + internal const string ArtUrlKey = "mpris:artUrl"; + + /// + /// Builds a from a player's properties. + /// + /// + /// Reads the flat player properties and the nested Metadata dictionary in + /// one pass, since the two live at different depths but describe the same + /// moment. The observation timestamp is supplied by the caller rather + /// than read from the clock here, so every player polled in the same + /// sweep shares one timestamp and the recency tie-break in + /// compares like with like. + /// + /// + /// Identifier for the player, already stripped of its bus-name prefix by + /// . + /// + /// + /// Player properties as returned by + /// . + /// + /// + /// Timestamp shared by every player in this polling sweep. + /// + /// + /// A fully populated snapshot; fields the player did not publish fall + /// back to empty strings or null. + /// + /// + /// + /// var snapshot = MprisPropertyMapper.ToSnapshot( + /// "spotify", properties, DateTimeOffset.UtcNow); + /// Console.WriteLine(snapshot.PlayerName); // "Spotify" + /// + /// + public static MediaSessionSnapshot ToSnapshot( + string playerId, + IReadOnlyDictionary properties, + DateTimeOffset observedAt) + { + var metadata = ReadMetadata(properties); + + return new MediaSessionSnapshot( + PlayerId: playerId, + PlayerName: FriendlyPlayerName(playerId), + Status: ParseStatus(ReadString(properties, PlaybackStatusKey)), + Title: ReadString(metadata, TitleKey), + Artist: ReadTextList(metadata, ArtistKey), + Album: ReadString(metadata, AlbumKey), + Duration: ReadMicroseconds(metadata, LengthKey), + Position: ReadMicroseconds(properties, PositionKey), + Volume: ReadVolume(properties, VolumeKey), + ArtworkUri: ReadArtworkUri(metadata, ArtUrlKey), + ObservedAt: observedAt); + } + + /// + /// Reduces a bus name to the identifier Reprise uses for a player. + /// + /// + /// Dropping the shared prefix leaves a value that is both short enough to + /// show and still unique per player instance, since browsers append an + /// instance suffix of their own. + /// + /// Fully qualified bus name. + /// + /// The name with the MPRIS prefix removed, or the input unchanged when it + /// does not carry the prefix. + /// + /// + /// + /// MprisPropertyMapper.ToPlayerId("org.mpris.MediaPlayer2.firefox.instance42"); + /// // "firefox.instance42" + /// + /// + internal static string ToPlayerId(string serviceName) => + serviceName.StartsWith(DBusMprisBus.ServicePrefix, StringComparison.Ordinal) + ? serviceName[DBusMprisBus.ServicePrefix.Length..] + : serviceName; + + /// + /// Restores the bus name for a player id, so a command can be addressed. + /// + /// + /// Tolerates an input that already carries the prefix, which keeps + /// callers from having to know whether a given id has been through + /// . + /// + /// + /// Player identifier, with or without the prefix. + /// + /// A fully qualified MPRIS bus name. + /// + /// + /// MprisPropertyMapper.ToServiceName("spotify"); + /// // "org.mpris.MediaPlayer2.spotify" + /// + /// + internal static string ToServiceName(string playerId) => + playerId.StartsWith(DBusMprisBus.ServicePrefix, StringComparison.Ordinal) + ? playerId + : DBusMprisBus.ServicePrefix + playerId; + + /// + /// Reads the id of the track a player currently has loaded. + /// + /// + /// The specification types this as an object path, but a string is + /// accepted too because some players publish it that way. The reserved + /// no-track path counts as absent. + /// + /// + /// Player properties as returned by + /// . + /// + /// + /// The track id, or null when the player publishes none or has no track + /// loaded. + /// + /// + /// + /// var trackId = MprisPropertyMapper.ReadTrackId(properties); + /// // "/com/spotify/track/4uLU6hMCjMI75M1A2tKUQC" + /// + /// + internal static string? ReadTrackId( + IReadOnlyDictionary properties) + { + if (!TryUnwrap(ReadMetadata(properties), TrackIdKey, out var value)) + { + return null; + } + + var trackId = value.Type switch + { + VariantValueType.ObjectPath => value.GetObjectPathAsString(), + VariantValueType.String => value.GetString(), + _ => null, + }; + + return string.IsNullOrEmpty(trackId) || trackId == NoTrackId + ? null + : trackId; + } + + /// + /// Extracts the nested Metadata dictionary from a player's properties. + /// + /// The player's properties. + /// + /// Track metadata, or an empty dictionary when the player publishes none. + /// Returning empty rather than null lets every field reader take the same + /// code path. + /// + private static IReadOnlyDictionary ReadMetadata( + IReadOnlyDictionary properties) + { + if (!TryUnwrap(properties, MetadataKey, out var value) + || value.Type != VariantValueType.Dictionary) + { + return new Dictionary(); + } + + return value.GetDictionary(); + } + + /// + /// Maps the MPRIS PlaybackStatus string onto the Reprise enum. + /// + /// Raw status string, or empty when absent. + /// + /// The matching status, or for + /// anything outside the three values the specification defines. + /// + private static PlaybackStatus ParseStatus(string value) => value switch + { + "Playing" => PlaybackStatus.Playing, + "Paused" => PlaybackStatus.Paused, + "Stopped" => PlaybackStatus.Stopped, + _ => PlaybackStatus.Unknown, + }; + + /// + /// Reads a string field, tolerating absence and type mismatches. + /// + /// Dictionary to read from. + /// Property or metadata key. + /// + /// The string value, or an empty string when the key is missing or holds + /// something other than a string. + /// + private static string ReadString( + IReadOnlyDictionary source, + string key) + { + return TryUnwrap(source, key, out var value) + && value.Type == VariantValueType.String + ? value.GetString() + : string.Empty; + } + + /// + /// Reads a field that may hold either one string or an array of them. + /// + /// + /// xesam:artist is specified as a string array, but enough players + /// publish a bare string that both forms have to be accepted. Arrays are + /// flattened into one display string, and non-string array entries are + /// skipped rather than rendered as placeholders. + /// + /// Dictionary to read from. + /// Metadata key holding the value. + /// + /// A single value as-is, array entries joined by ", ", or an empty + /// string when the key is missing or of an unusable type. + /// + /// + /// + /// // ["Daft Punk", "Julian Casablancas"] becomes: + /// // "Daft Punk, Julian Casablancas" + /// + /// + private static string ReadTextList( + IReadOnlyDictionary source, + string key) + { + if (!TryUnwrap(source, key, out var value)) + { + return string.Empty; + } + + if (value.Type == VariantValueType.String) + { + return value.GetString(); + } + + if (value.Type != VariantValueType.Array) + { + return string.Empty; + } + + var items = new List(value.Count); + for (var index = 0; index < value.Count; index++) + { + var item = Unwrap(value.GetItem(index)); + if (item.Type == VariantValueType.String) + { + items.Add(item.GetString()); + } + } + + return string.Join(", ", items); + } + + /// + /// Reads a microsecond duration into a . + /// + /// + /// MPRIS specifies these as signed 64-bit, but players in practice + /// publish unsigned, 32-bit, and even floating-point values, so all of + /// them are accepted. A negative result is treated as absent rather than + /// clamped to zero: some players use -1 to mean "unknown", and reporting + /// that as a real zero would make a live stream look like a track at its + /// start. + /// + /// Dictionary to read from. + /// Key holding the microsecond count. + /// + /// The duration, or null when the key is missing, holds a non-numeric + /// type, or carries a negative value. + /// + private static TimeSpan? ReadMicroseconds( + IReadOnlyDictionary source, + string key) + { + if (!TryUnwrap(source, key, out var value)) + { + return null; + } + + var microseconds = value.Type switch + { + VariantValueType.Int64 => value.GetInt64(), + VariantValueType.UInt64 => (long)value.GetUInt64(), + VariantValueType.Int32 => value.GetInt32(), + VariantValueType.UInt32 => value.GetUInt32(), + VariantValueType.Double => (long)value.GetDouble(), + _ => -1, + }; + + return microseconds >= 0 ? TimeSpan.FromMicroseconds(microseconds) : null; + } + + /// + /// Reads the player volume as a normalised fraction. + /// + /// + /// MPRIS allows values above 1 to mean amplification, which Reprise does + /// not display, so the result is clamped into the range the UI can + /// render. + /// + /// Dictionary to read from. + /// Key holding the volume. + /// + /// Volume from 0 to 1, or null when the key is missing or holds a + /// non-numeric type. + /// + private static double? ReadVolume( + IReadOnlyDictionary source, + string key) + { + if (!TryUnwrap(source, key, out var value)) + { + return null; + } + + return value.Type switch + { + VariantValueType.Double => Math.Clamp(value.GetDouble(), 0, 1), + VariantValueType.Int64 => Math.Clamp((double)value.GetInt64(), 0, 1), + _ => null, + }; + } + + /// + /// Reads the cover art location as an absolute URI. + /// + /// + /// Relative or malformed values are dropped rather than surfaced, since a + /// URI that cannot be resolved is of no use to the caller. The scheme is + /// left alone: players publish file:, http:, and + /// data: alike. + /// + /// Dictionary to read from. + /// Key holding the artwork location. + /// + /// The parsed absolute URI, or null when absent or unparsable. + /// + private static Uri? ReadArtworkUri( + IReadOnlyDictionary source, + string key) + { + return Uri.TryCreate(ReadString(source, key), UriKind.Absolute, out var artworkUri) + ? artworkUri + : null; + } + + /// + /// Looks a key up and unwraps the variant nesting around its value. + /// + /// + /// The single entry point every field reader shares, so lookup failure + /// and variant unwrapping are handled identically everywhere. + /// + /// Dictionary to read from. + /// Key to look up. + /// + /// Receives the unwrapped value, or default when the lookup fails. + /// + /// True when the key exists and yielded a usable value. + private static bool TryUnwrap( + IReadOnlyDictionary source, + string key, + out VariantValue value) + { + if (!source.TryGetValue(key, out var raw)) + { + value = default; + return false; + } + + value = Unwrap(raw); + return value.Type != VariantValueType.Invalid; + } + + /// + /// Strips any layers of variant wrapping from a value. + /// + /// + /// A GetAll reply wraps each property in a variant, and a player + /// that builds its Metadata dictionary generically may wrap the inner + /// values a second time. Looping instead of unwrapping once handles both + /// depths. + /// + /// Possibly wrapped value. + /// The innermost non-variant value. + private static VariantValue Unwrap(VariantValue value) + { + while (value.Type == VariantValueType.Variant) + { + value = value.GetVariantValue(); + } + + return value; + } + + /// + /// Derives a display name from a player id. + /// + /// + /// Bus names are lowercase and may carry an instance suffix, neither of + /// which belongs in the UI. Dropping everything after the first dot also + /// collapses a browser's per-tab instances into one recognisable name. + /// + /// + /// Player id with the bus prefix already removed. + /// + /// A title-cased name suitable for display. + /// + /// + /// // "firefox.instance42" becomes "Firefox" + /// + /// + private static string FriendlyPlayerName(string playerId) + { + var name = playerId.Split('.', 2)[0]; + return CultureInfo.InvariantCulture.TextInfo.ToTitleCase(name); + } +} diff --git a/src/Reprise.Platform.Linux/Mpris/MprisUnavailableException.cs b/src/Reprise.Platform.Linux/Mpris/MprisUnavailableException.cs new file mode 100644 index 0000000..603643d --- /dev/null +++ b/src/Reprise.Platform.Linux/Mpris/MprisUnavailableException.cs @@ -0,0 +1,41 @@ +namespace Reprise.Platform.Linux.Mpris; + +/// +/// Thrown when the D-Bus session bus itself cannot be reached. +/// +/// +/// This is the one MPRIS failure that is not the user's music player acting +/// up: without a session bus there is nothing to query at all, which normally +/// means the process was launched outside a desktop session - over plain SSH, +/// from a system service, or in a container with no +/// DBUS_SESSION_BUS_ADDRESS. It is kept distinct from the per-player +/// errors so the UI can explain how to fix it instead of reporting that no +/// music is playing. +/// +public sealed class MprisUnavailableException : InvalidOperationException +{ + /// + /// Creates the exception with the standard user-facing explanation. + /// + /// + /// The message is fixed rather than caller-supplied because the remedy is + /// always the same, and it is written in Korean to match the rest of the + /// desktop UI. The underlying transport error is preserved as the inner + /// exception for logs. + /// + /// + /// The D-Bus connection failure that caused this, when one is available. + /// Defaults to null. + /// + /// + /// + /// throw new MprisUnavailableException(connectFailure); + /// + /// + public MprisUnavailableException(Exception? innerException = null) + : base( + "D-Bus 세션 버스에 연결할 수 없습니다. 데스크톱 세션 안에서 실행하세요.", + innerException) + { + } +} diff --git a/src/Reprise.Platform.Linux/Program.cs b/src/Reprise.Platform.Linux/Program.cs new file mode 100644 index 0000000..970d7c5 --- /dev/null +++ b/src/Reprise.Platform.Linux/Program.cs @@ -0,0 +1,124 @@ +using System.Runtime.InteropServices; +using Avalonia; +using Avalonia.Media; +using Reprise.Desktop; +using Reprise.Platform.Linux.Mpris; +using Reprise.Platform.Linux.Tray; + +namespace Reprise.Platform.Linux; + +/// +/// Entry point for the Linux build of Reprise. +/// +/// +/// Keeps everything platform-specific in one place: the shared Reprise.Desktop +/// UI has no notion of MPRIS, and learns which backend to use only through +/// the factory this class installs before Avalonia starts. +/// +internal static class Program +{ + /// + /// Wires up the MPRIS backend and tray entry, then runs the Avalonia + /// desktop lifetime. + /// + /// + /// Handles --health-check before anything else. Packaging and CI + /// run the published binary on machines with no display and no session + /// bus, so the check has to answer without touching Avalonia or D-Bus; it + /// exists to prove the self-contained archive actually executes on the + /// target. + /// + /// The OS guard matters because the published output is self-contained + /// and can be launched anywhere. Failing with an explanation beats + /// letting the Avalonia platform detection crash further in. + /// + /// + /// + /// Command line arguments, forwarded to Avalonia once the + /// Reprise-specific flags have been handled. + /// + /// 0 on success, 1 when run on a non-Linux platform. + /// + /// + /// $ ./reprise --health-check + /// Reprise Linux 2.0.0-alpha.1 (linux-arm64) + /// + /// + [STAThread] + public static int Main(string[] args) + { + if (args.Contains("--health-check", StringComparer.Ordinal)) + { + Console.WriteLine( + $"Reprise Linux 2.0.0-alpha.1 ({RuntimeInformation.RuntimeIdentifier})"); + return OperatingSystem.IsLinux() ? 0 : 1; + } + + if (!OperatingSystem.IsLinux()) + { + Console.Error.WriteLine("Reprise.Platform.Linux can only run on Linux."); + return 1; + } + + RepriseApplication.MediaSessionServiceFactory = static () => + new MprisMediaSessionService(); + RepriseApplication.StatusItemFactory = static () => + new StatusNotifierItem(); + RepriseApplication.MenuBarPublisherFactory = static () => + new RepriseMenuBarService(); + + _ = EnableShellExtensionAsync(); + + return BuildAvaloniaApp().StartWithClassicDesktopLifetime(args); + } + + /// + /// Turns the GNOME Shell extension on, in the background. + /// + /// + /// Runs unawaited: the panel must not wait on the shell, and every + /// outcome is one line on stderr rather than anything the user has to + /// answer. On any other desktop the shell is simply absent and this + /// says nothing at all. + /// + /// A task that completes once the shell has answered. + private static async Task EnableShellExtensionAsync() + { + var result = await GnomeShellExtension.EnsureEnabledAsync(); + switch (result) + { + case GnomeShellExtensionResult.Enabled: + Console.Error.WriteLine("Reprise: enabled the GNOME top bar extension."); + break; + case GnomeShellExtensionResult.NeedsReload: + Console.Error.WriteLine( + "Reprise: installed the GNOME top bar extension. Log out and back in, or press Alt+F2 and type r on Xorg, to load it."); + break; + case GnomeShellExtensionResult.Failed: + Console.Error.WriteLine( + $"Reprise: could not enable the GNOME top bar extension. Try: gnome-extensions enable {GnomeShellExtension.Uuid}"); + break; + } + } + + /// + /// Builds the Avalonia application configuration. + /// + /// + /// Public and parameterless by convention: Avalonia's design-time tooling + /// and XAML previewer locate this method by name. + /// + /// + /// A configured builder with the windowing backend detected from the + /// environment, X11 or Wayland, and Pretendard as the default typeface. + /// + public static AppBuilder BuildAvaloniaApp() => + AppBuilder + .Configure() + .With(new FontManagerOptions + { + DefaultFamilyName = PanelTypography.FamilyName, + }) + .UsePlatformDetect() + .LogToTrace(); +} diff --git a/src/Reprise.Platform.Linux/README.md b/src/Reprise.Platform.Linux/README.md new file mode 100644 index 0000000..16b2b31 --- /dev/null +++ b/src/Reprise.Platform.Linux/README.md @@ -0,0 +1,69 @@ +# Reprise for Linux + +The Linux host is an Avalonia desktop application that talks to MPRIS players +over the D-Bus session bus. It renders the same player panel as the macOS app: +album art, a scrolling title, previous / play-pause / next, a scrubber with +elapsed and remaining time, a volume slider, and a footer with settings and +quit. The panel opens from the tray icon, hides when it loses focus or on +Escape, and can be dragged anywhere. + +The tray entry is Reprise's own StatusNotifierItem rather than Avalonia's, so +it can carry text beside the icon the way the macOS menu bar does. Panels that +honour the Ubuntu label extension - Ubuntu's GNOME, Budgie, MATE, and Xfce +with the indicator plugin - show the track title (or the current lyric line) +next to the album cover; KDE Plasma and other hosts show the cover alone. Long +label carries the line whole and still: a tray label is a string the desktop +draws, so it cannot move, and swapping in a different slice each tick reads +as the letters changing rather than the words sliding past. Reserving space +for lyrics pads the label with spaces rather than claiming a width it does +not use. The entry publishes no menu, so a +click opens the panel straight away as it does on macOS; quitting is in +the panel footer. Its font is the panel's own, which no application can +set. + +On GNOME the optional extension in `packaging/linux/gnome-extension` replaces +that entry with one the shell draws from Reprise's own D-Bus service, +`dev.junx.Reprise`. Because the extension owns the actor, the title slides +pixel by pixel like the macOS menu bar, and the service hands it the whole +line, the cover as a PNG, and the user's scrolling settings. While the +extension runs it holds `dev.junx.Reprise.Shell`, and the tray entry marks +itself Passive so the two never show at once. The Debian package installs and enables it, and Reprise asks the shell to +enable it on every start, so it needs no command from the user; the archive +build carries `install-gnome-extension.sh` for the same purpose. + +The interface is set in Pretendard, bundled with the application, so the panel +looks the same on every distribution and covers Hangul without depending on +which fonts are installed. + +The gear button in the panel footer opens a settings window with the same +six tabs as the macOS app - General, YouTube Music, Theme, Menu bar, Panel, +and System info - including the live previews. Settings live in +`$XDG_CONFIG_HOME/reprise/preferences.json` (normally +`~/.config/reprise/preferences.json`) and cover auto-pausing other players, +tray lyrics and their width, display priority and the remembered player, +launch at login (an XDG autostart entry), the panel theme, the tray text and +icon, the marquee, and the two time labels. The YouTube Music tab lists +browser tabs seen over MPRIS, since Linux browsers need no extension. + +Synced lyrics come from VIBE and LRCLIB, the same services the macOS app +uses, and are looked up once per track. + +## Requirements + +- Ubuntu 24.04 or a compatible modern Linux distribution +- X11, or Wayland with XWayland +- A D-Bus session bus, which every desktop session already provides +- A font with Hangul coverage, such as `fonts-noto-cjk`, since the panel's + labels are Korean like the macOS app's +- A compositor, for the translucent Liquid theme; without one the panel falls + back to an opaque ground + +```bash +dotnet run --project src/Reprise.Platform.Linux/Reprise.Platform.Linux.csproj +``` + +MPRIS is reached directly through `Tmds.DBus.Protocol`, so no command line +helper such as `playerctl` has to be installed. + +The CI workflow publishes self-contained `linux-x64` and `linux-arm64` +artifacts, so end-user packages do not require a separate .NET installation. diff --git a/src/Reprise.Platform.Linux/Reprise.Platform.Linux.csproj b/src/Reprise.Platform.Linux/Reprise.Platform.Linux.csproj new file mode 100644 index 0000000..4398e09 --- /dev/null +++ b/src/Reprise.Platform.Linux/Reprise.Platform.Linux.csproj @@ -0,0 +1,16 @@ + + + WinExe + reprise + Reprise.Platform.Linux + Linux desktop host and MPRIS integration for Reprise. + + + + + + + + + + diff --git a/src/Reprise.Platform.Linux/Tray/DBusReply.cs b/src/Reprise.Platform.Linux/Tray/DBusReply.cs new file mode 100644 index 0000000..6849ad5 --- /dev/null +++ b/src/Reprise.Platform.Linux/Tray/DBusReply.cs @@ -0,0 +1,109 @@ +using Tmds.DBus.Protocol; + +namespace Reprise.Platform.Linux.Tray; + +/// +/// Writes the body of a D-Bus message. +/// +/// Writer positioned after the header. +internal delegate void DBusBodyWriter(ref MessageWriter writer); + +/// +/// Reply helpers shared by the exported tray objects. +/// +/// +/// is a ref struct that has to be disposed and +/// cannot be captured or passed by reference from a using variable, +/// which makes every reply site the same eight lines. These helpers keep +/// that ceremony in one place. +/// +internal static class DBusReply +{ + /// + /// Sends a reply whose body the callback writes. + /// + /// The call being answered. + /// Body signature. + /// Writes the body. + public static void Send(MethodContext context, string signature, DBusBodyWriter body) + { + var writer = context.CreateReplyWriter(signature); + try + { + body(ref writer); + context.Reply(writer.CreateMessage()); + } + finally + { + writer.Dispose(); + } + } + + /// + /// Sends a reply with no body, unless the caller asked for none. + /// + /// The call being answered. + public static void SendEmpty(MethodContext context) + { + if (context.NoReplyExpected) + { + return; + } + + var writer = context.CreateReplyWriter(null!); + try + { + context.Reply(writer.CreateMessage()); + } + finally + { + writer.Dispose(); + } + } + + /// + /// Broadcasts a signal from an exported object. + /// + /// + /// A signal that cannot be sent - the connection closed under us, say - + /// is dropped: the host will simply re-read the properties later, and a + /// tray hiccup must never take the panel down. + /// + /// Connection to send on. + /// Object the signal comes from. + /// Interface the signal belongs to. + /// Signal name. + /// Body signature, or null when there is no body. + /// Writes the body, or null when there is none. + public static void Emit( + DBusConnection connection, + string path, + string interfaceName, + string member, + string? signature, + DBusBodyWriter? body) + { + try + { + var writer = connection.GetMessageWriter(); + try + { + writer.WriteSignalHeader( + destination: null, + path: path, + @interface: interfaceName, + member: member, + signature: signature); + body?.Invoke(ref writer); + connection.TrySendMessage(writer.CreateMessage()); + } + finally + { + writer.Dispose(); + } + } + catch (Exception exception) when (exception is InvalidOperationException or ObjectDisposedException or DBusExceptionBase) + { + } + } +} diff --git a/src/Reprise.Platform.Linux/Tray/GnomeShellExtension.cs b/src/Reprise.Platform.Linux/Tray/GnomeShellExtension.cs new file mode 100644 index 0000000..58430cd --- /dev/null +++ b/src/Reprise.Platform.Linux/Tray/GnomeShellExtension.cs @@ -0,0 +1,298 @@ +using Tmds.DBus.Protocol; + +namespace Reprise.Platform.Linux.Tray; + +/// +/// What became of an attempt to turn the shell extension on. +/// +public enum GnomeShellExtensionResult +{ + /// + /// No GNOME Shell is running, so there is nothing to enable. + /// + NoShell, + + /// + /// The extension was already enabled. + /// + AlreadyEnabled, + + /// + /// The extension was switched on and is running now. + /// + Enabled, + + /// + /// The files were put in place, but this shell has to be reloaded before + /// it will pick them up. + /// + NeedsReload, + + /// + /// The shell refused, or the bundled files could not be found. + /// + Failed, +} + +/// +/// Turns Reprise's GNOME Shell extension on without the user doing it. +/// +/// +/// Enabling an extension is per-user state in dconf, so a package's +/// post-install script - which runs as root, often with nobody logged in - +/// cannot reliably do it. Reprise itself runs as the user on their own +/// session bus, which is exactly where the shell expects the request to +/// come from, so it asks on every start: cheap when the extension is +/// already on, and the difference between "it works" and "run this command +/// first" when it is not. +/// +/// The extension only ever changes what Reprise's own entry looks like, and +/// the shell is asked rather than told - it can and does refuse - so doing +/// this unprompted stays within what installing Reprise implies. +/// +/// +public static class GnomeShellExtension +{ + /// + /// Identifier the extension is installed and enabled under. + /// + public const string Uuid = "reprise@junx.dev"; + + private const string ShellService = "org.gnome.Shell"; + + /// + /// Object carrying the extensions interface. + /// + /// + /// The shell exports org.gnome.Shell.Extensions on its main + /// object rather than on a path of the interface's own name, which is + /// the obvious guess and answers nothing. + /// + private const string ExtensionsPath = "/org/gnome/Shell"; + + private const string ExtensionsInterface = "org.gnome.Shell.Extensions"; + + /// + /// State value GNOME reports for an extension that is running. + /// + private const double EnabledState = 1; + + /// + /// How long any single call to the shell may take. + /// + private static readonly TimeSpan CallTimeout = TimeSpan.FromSeconds(5); + + /// + /// Files the extension is made of, in load order. + /// + private static readonly string[] ExtensionFiles = ["metadata.json", "extension.js"]; + + /// + /// Makes sure the extension is installed for this user and enabled. + /// + /// + /// Installing is only needed for the archive build, where the extension + /// travels beside the application rather than in a system directory the + /// shell already searches. A shell that has not rescanned since the + /// files appeared cannot enable them yet, which is reported rather than + /// treated as a failure: logging out once is all it takes. + /// + /// Cancels the attempt. + /// What happened, for the caller to log. + /// + /// + /// var result = await GnomeShellExtension.EnsureEnabledAsync(); + /// + /// + public static async Task EnsureEnabledAsync( + CancellationToken cancellationToken = default) + { + try + { + var connection = DBusConnection.Session; + await connection.ConnectAsync(); + + var state = await GetStateAsync(connection, cancellationToken); + if (state is null) + { + return GnomeShellExtensionResult.NoShell; + } + + if (state == EnabledState) + { + return GnomeShellExtensionResult.AlreadyEnabled; + } + + var installed = state > 0 || Install(); + if (!installed) + { + return GnomeShellExtensionResult.Failed; + } + + var enabled = await EnableAsync(connection, cancellationToken); + if (!enabled) + { + return GnomeShellExtensionResult.Failed; + } + + var confirmed = await GetStateAsync(connection, cancellationToken); + return confirmed == EnabledState + ? GnomeShellExtensionResult.Enabled + : GnomeShellExtensionResult.NeedsReload; + } + catch (Exception exception) when (exception is not OperationCanceledException) + { + return GnomeShellExtensionResult.NoShell; + } + } + + /// + /// Copies the bundled extension into the user's extension directory. + /// + /// + /// Looks beside the application, which is where the archive puts it. The + /// Debian package installs the same files system-wide, so nothing is + /// found and nothing is copied. + /// + /// True when the extension is now in place for this user. + private static bool Install() + { + var source = FindBundle(); + if (source is null) + { + return false; + } + + try + { + var dataHome = Environment.GetEnvironmentVariable("XDG_DATA_HOME"); + if (string.IsNullOrWhiteSpace(dataHome)) + { + dataHome = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), + ".local", + "share"); + } + + var target = Path.Combine(dataHome, "gnome-shell", "extensions", Uuid); + Directory.CreateDirectory(target); + foreach (var file in ExtensionFiles) + { + File.Copy(Path.Combine(source, file), Path.Combine(target, file), overwrite: true); + } + + return true; + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException) + { + return false; + } + } + + /// + /// Finds the extension shipped alongside the application. + /// + /// The directory holding it, or null when it is not there. + private static string? FindBundle() + { + var candidates = new[] + { + Path.Combine(AppContext.BaseDirectory, "gnome-extension"), + Path.Combine(AppContext.BaseDirectory, "..", "gnome-extension"), + }; + + return candidates.FirstOrDefault(candidate => + ExtensionFiles.All(file => File.Exists(Path.Combine(candidate, file)))); + } + + /// + /// Asks the shell what state the extension is in. + /// + /// Session bus connection. + /// Cancels the call. + /// + /// The state, zero when the shell does not know the extension, or null + /// when no shell answered. + /// + private static async Task GetStateAsync( + DBusConnection connection, + CancellationToken cancellationToken) + { + try + { + var info = await connection + .CallMethodAsync( + CreateCall(connection, "GetExtensionInfo", Uuid), + static (Message message, object? _) => + { + var reader = message.GetBodyReader(); + return reader.ReadDictionaryOfStringToVariantValue(); + }) + .WaitAsync(CallTimeout, cancellationToken); + + if (!info.TryGetValue("state", out var value)) + { + return 0; + } + + return value.Type switch + { + VariantValueType.Double => value.GetDouble(), + VariantValueType.Int32 => value.GetInt32(), + _ => 0, + }; + } + catch (Exception exception) when (exception is not OperationCanceledException) + { + return null; + } + } + + /// + /// Asks the shell to enable the extension. + /// + /// Session bus connection. + /// Cancels the call. + /// True when the shell accepted the request. + private static async Task EnableAsync( + DBusConnection connection, + CancellationToken cancellationToken) + { + try + { + return await connection + .CallMethodAsync( + CreateCall(connection, "EnableExtension", Uuid), + static (Message message, object? _) => message.GetBodyReader().ReadBool()) + .WaitAsync(CallTimeout, cancellationToken); + } + catch (Exception exception) when (exception is not OperationCanceledException) + { + return false; + } + } + + /// + /// Builds a call to the shell's extensions interface. + /// + /// + /// Separate from its callers because MessageWriter is a ref + /// struct and cannot live across an await. + /// + /// Connection whose writer builds the message. + /// Method to call. + /// Extension the call is about. + /// An encoded method call ready to send. + private static MessageBuffer CreateCall(DBusConnection connection, string member, string uuid) + { + using var writer = connection.GetMessageWriter(); + writer.WriteMethodCallHeader( + destination: ShellService, + path: ExtensionsPath, + @interface: ExtensionsInterface, + member: member, + signature: "s"); + writer.WriteString(uuid); + return writer.CreateMessage(); + } +} diff --git a/src/Reprise.Platform.Linux/Tray/RepriseMenuBarService.cs b/src/Reprise.Platform.Linux/Tray/RepriseMenuBarService.cs new file mode 100644 index 0000000..f3dcd2c --- /dev/null +++ b/src/Reprise.Platform.Linux/Tray/RepriseMenuBarService.cs @@ -0,0 +1,315 @@ +using System.Text; +using Reprise.Desktop; +using Reprise.Platform.Linux.Mpris; +using Tmds.DBus.Protocol; + +namespace Reprise.Platform.Linux.Tray; + +/// +/// Publishes what the top bar should show, for the Reprise shell extension. +/// +/// +/// A tray label is a string the desktop panel draws, so it can only ever +/// step a whole character at a time. A shell extension draws the item +/// itself and can scroll it pixel by pixel, exactly as the macOS menu bar +/// does - but it needs the raw material rather than a finished label. This +/// service hands it over on the session bus: the whole line, the cover as +/// a PNG, and the user's scrolling settings, with a change signal so the +/// extension redraws only when something moved. +/// +/// It runs alongside rather than instead +/// of it. Desktops without the extension keep the ordinary tray entry, and +/// the extension hides that entry while it is running. +/// +/// +public sealed class RepriseMenuBarService : IMenuBarPublisher +{ + /// + /// Bus name the extension looks for. + /// + public const string ServiceName = "dev.junx.Reprise"; + + /// + /// Object the extension talks to. + /// + public const string ObjectPath = "/dev/junx/Reprise"; + + /// + /// Interface carrying the top-bar content. + /// + public const string MenuBarInterface = "dev.junx.Reprise.MenuBar"; + + private const string PropertiesInterface = "org.freedesktop.DBus.Properties"; + + private readonly Handler _handler; + private DBusConnection? _connection; + private bool _disposed; + + /// + /// Creates the service; nothing touches the bus until . + /// + public RepriseMenuBarService() + { + _handler = new Handler(this); + } + + /// + public event EventHandler? Activated; + + /// + /// Claims the bus name and exports the object. + /// + /// + /// Uses a connection of its own, as Tmds.DBus reserves the shared + /// session connection for client calls and refuses to export on it. + /// + /// Cancels the start-up. + /// A task that completes once the object is exported. + /// + /// Thrown when the session bus is unreachable. + /// + public async Task StartAsync(CancellationToken cancellationToken = default) + { + var address = DBusAddress.Session ?? throw new MprisUnavailableException(); + var connection = new DBusConnection(address); + try + { + await connection.ConnectAsync(); + await connection.RequestNameAsync(ServiceName, RequestNameOptions.ReplaceExisting); + } + catch (Exception exception) when (exception is not OperationCanceledException) + { + connection.Dispose(); + throw new MprisUnavailableException(exception); + } + + connection.AddMethodHandler(_handler); + _connection = connection; + } + + /// + /// Publishes new content and tells the extension what changed. + /// + /// + /// The signal carries only the properties that actually differ, so a + /// still label costs one comparison and no bus traffic, while a + /// scrolling one sends just its text. + /// + /// What the top bar should show. + public void Publish(MenuBarState state) + { + ArgumentNullException.ThrowIfNull(state); + var previous = _handler.State; + _handler.State = state; + if (_connection is not { } connection || _disposed) + { + return; + } + + var changed = new List(4); + if (previous.Text != state.Text) changed.Add("Text"); + if (previous.ToolTip != state.ToolTip) changed.Add("ToolTip"); + if (!previous.IconPng.AsSpan().SequenceEqual(state.IconPng)) changed.Add("IconPng"); + if (previous.IsRunning != state.IsRunning) changed.Add("IsRunning"); + if (previous.IsPlaying != state.IsPlaying) changed.Add("IsPlaying"); + if (previous.ScrollsText != state.ScrollsText) changed.Add("ScrollsText"); + if (Math.Abs(previous.PointsPerSecond - state.PointsPerSecond) > 0.01) changed.Add("PointsPerSecond"); + if (previous.MaxWidthChars != state.MaxWidthChars) changed.Add("MaxWidthChars"); + if (changed.Count == 0) + { + return; + } + + DBusReply.Emit(connection, ObjectPath, PropertiesInterface, "PropertiesChanged", "sa{sv}as", + (ref MessageWriter writer) => + { + writer.WriteString(MenuBarInterface); + var dictionary = writer.WriteDictionaryStart(); + foreach (var name in changed) + { + writer.WriteDictionaryEntryStart(); + writer.WriteString(name); + Handler.WriteProperty(ref writer, state, name); + } + + writer.WriteDictionaryEnd(dictionary); + writer.WriteArray(Array.Empty()); + }); + } + + /// + /// Releases the bus name and stops serving. + /// + public void Dispose() + { + if (_disposed) + { + return; + } + + _disposed = true; + _connection?.Dispose(); + _connection = null; + } + + /// + /// Serves the exported object. + /// + private sealed class Handler : IPathMethodHandler + { + private static readonly string[] PropertyNames = + [ + "Text", "ToolTip", "IconPng", "IsRunning", "IsPlaying", + "ScrollsText", "PointsPerSecond", "MaxWidthChars", + ]; + + private static readonly ReadOnlyMemory IntrospectionXml = Encoding.UTF8.GetBytes(""" + + + + + + + + + + + + """); + + private readonly RepriseMenuBarService _owner; + + /// + /// Creates the handler for its owning service. + /// + /// Service whose events to raise. + public Handler(RepriseMenuBarService owner) + { + _owner = owner; + } + + /// + /// Content currently served. + /// + public MenuBarState State { get; set; } = + new(string.Empty, "Reprise", [], false, false, true, 30, 20); + + /// + public string Path => ObjectPath; + + /// + public bool HandlesChildPaths => false; + + /// + /// Answers property reads, the activation call, and introspection. + /// + /// The incoming call. + /// A completed task; every reply is written synchronously. + public ValueTask HandleMethodAsync(MethodContext context) + { + var request = context.Request; + if (context.IsDBusIntrospectRequest) + { + context.ReplyIntrospectXml([IntrospectionXml], Array.Empty()); + } + else if (request.InterfaceAsString == PropertiesInterface) + { + HandleProperties(context); + } + else if (request.InterfaceAsString == MenuBarInterface && request.MemberAsString == "Activate") + { + _owner.Activated?.Invoke(_owner, EventArgs.Empty); + DBusReply.SendEmpty(context); + } + else + { + context.ReplyUnknownMethodError(); + } + + return ValueTask.CompletedTask; + } + + /// + /// Writes one property as a variant. + /// + /// Writer positioned where the variant goes. + /// Content being served. + /// Property to write. + public static void WriteProperty(ref MessageWriter writer, MenuBarState state, string name) + { + switch (name) + { + case "Text": + writer.WriteVariantString(state.Text); + break; + case "ToolTip": + writer.WriteVariantString(state.ToolTip); + break; + case "IconPng": + writer.WriteSignature("ay"); + writer.WriteArray(state.IconPng); + break; + case "IsRunning": + writer.WriteVariantBool(state.IsRunning); + break; + case "IsPlaying": + writer.WriteVariantBool(state.IsPlaying); + break; + case "ScrollsText": + writer.WriteVariantBool(state.ScrollsText); + break; + case "PointsPerSecond": + writer.WriteVariantDouble(state.PointsPerSecond); + break; + case "MaxWidthChars": + writer.WriteVariantInt32(state.MaxWidthChars); + break; + } + } + + /// + /// Serves Get and GetAll. + /// + /// The incoming call. + private void HandleProperties(MethodContext context) + { + var reader = context.Request.GetBodyReader(); + var state = State; + switch (context.Request.MemberAsString) + { + case "Get": + { + reader.ReadString(); + var name = reader.ReadString(); + if (Array.IndexOf(PropertyNames, name) < 0) + { + context.ReplyError("org.freedesktop.DBus.Error.InvalidArgs", $"Unknown property {name}"); + break; + } + + DBusReply.Send(context, "v", (ref MessageWriter writer) => WriteProperty(ref writer, state, name)); + break; + } + + case "GetAll": + DBusReply.Send(context, "a{sv}", (ref MessageWriter writer) => + { + var dictionary = writer.WriteDictionaryStart(); + foreach (var name in PropertyNames) + { + writer.WriteDictionaryEntryStart(); + writer.WriteString(name); + WriteProperty(ref writer, state, name); + } + + writer.WriteDictionaryEnd(dictionary); + }); + break; + + default: + context.ReplyUnknownMethodError(); + break; + } + } + } +} diff --git a/src/Reprise.Platform.Linux/Tray/StatusNotifierItem.cs b/src/Reprise.Platform.Linux/Tray/StatusNotifierItem.cs new file mode 100644 index 0000000..08cf744 --- /dev/null +++ b/src/Reprise.Platform.Linux/Tray/StatusNotifierItem.cs @@ -0,0 +1,648 @@ +using System.Text; +using Reprise.Desktop; +using Reprise.Platform.Linux.Mpris; +using Tmds.DBus.Protocol; + +namespace Reprise.Platform.Linux.Tray; + +/// +/// Reprise's tray entry, spoken directly over the StatusNotifierItem protocol. +/// +/// +/// Avalonia already ships a tray icon for Linux, but it cannot show text +/// beside the icon. The Ubuntu extension of the protocol can - the +/// XAyatanaLabel property, which Ubuntu's GNOME, Budgie, MATE, and +/// Xfce panels display - and that label is how the track title and lyrics +/// reach the top bar, as they do in the macOS menu bar. So this class +/// exports the item itself: the org.kde.StatusNotifierItem object +/// and its registration with the desktop's watcher. +/// +/// No context menu is published, on purpose. A host that finds a menu +/// opens it on a click, which would put a menu between the user and the +/// panel; with none, hosts call Activate and the panel appears +/// straight away, as the macOS status item does. Quitting lives in the +/// panel footer. +/// +/// +/// The item owns a dedicated bus connection rather than sharing the +/// process-wide one the MPRIS client uses: Tmds.DBus reserves that shared +/// connection for client calls and refuses to export objects or send +/// signals on it. Method calls arrive on the connection's thread; the +/// events this class raises are therefore not on the UI thread. +/// +/// +public sealed class StatusNotifierItem : IStatusItem +{ + private const string ItemInterface = "org.kde.StatusNotifierItem"; + private const string ItemPath = "/StatusNotifierItem"; + + /// + /// Object path published when there is no menu to offer. + /// + private const string NoMenuPath = "/"; + private const string WatcherService = "org.kde.StatusNotifierWatcher"; + private const string WatcherPath = "/StatusNotifierWatcher"; + + private static readonly TimeSpan CallTimeout = TimeSpan.FromSeconds(5); + + /// + /// Bus name the GNOME extension holds while it draws Reprise itself. + /// + /// + /// Both surfaces would otherwise show at once, one drawn by the shell + /// extension and one by the panel's tray host, which is why the item + /// steps aside while that name is owned. + /// + private const string ShellExtensionName = "dev.junx.Reprise.Shell"; + + private readonly ItemHandler _item; + private DBusConnection? _connection; + private string? _serviceName; + private bool _disposed; + + /// + /// Creates the item; nothing touches the bus until . + /// + public StatusNotifierItem() + { + _item = new ItemHandler(this); + } + + /// + public event EventHandler? Activated; + + /// + /// Exports the item on the session bus and registers it with the watcher. + /// + /// + /// The well-known name follows the org.kde.StatusNotifierItem-PID-N + /// convention the specification suggests, which every host accepts. If + /// no watcher is running yet - the panel starts after login, or the + /// user's desktop needs an extension - the item stays exported and + /// registers itself the moment a watcher appears. + /// + /// Cancels the registration. + /// True when a watcher accepted the item right away. + /// + /// Thrown when the session bus itself is unreachable. + /// + public async Task StartAsync(CancellationToken cancellationToken = default) + { + var address = DBusAddress.Session + ?? throw new MprisUnavailableException(); + var connection = new DBusConnection(address); + try + { + await connection.ConnectAsync(); + } + catch (Exception exception) when (exception is not OperationCanceledException) + { + connection.Dispose(); + throw new MprisUnavailableException(exception); + } + + _connection = connection; + _serviceName = $"org.kde.StatusNotifierItem-{Environment.ProcessId}-1"; + try + { + await connection.RequestNameAsync(_serviceName, RequestNameOptions.None); + } + catch (Exception exception) when (exception is not OperationCanceledException) + { + _serviceName = connection.UniqueName; + } + + connection.AddMethodHandler(_item); + await WatchShellExtensionAsync(connection); + + var rule = new MatchRule + { + Type = MessageType.Signal, + Sender = "org.freedesktop.DBus", + Interface = "org.freedesktop.DBus", + Member = "NameOwnerChanged", + Arg0 = WatcherService, + }; + await connection.AddMatchAsync( + rule, + static (Message message, object? _) => + { + var reader = message.GetBodyReader(); + reader.ReadString(); + reader.ReadString(); + return reader.ReadString(); + }, + (Notification notification) => + { + if (notification.HasValue && notification.Value.Length > 0) + { + _ = RegisterAsync(CancellationToken.None); + } + }, + emitOnCapturedContext: false); + + return await RegisterAsync(cancellationToken); + } + + /// + /// Publishes new label, tooltip, and icon content to the host. + /// + /// + /// Hosts re-read a property only after the matching change signal, so + /// one is emitted per part that actually changed. + /// + /// Content to show. + public void Update(StatusItemState state) + { + ArgumentNullException.ThrowIfNull(state); + var previous = _item.State; + _item.State = state; + if (_connection is not { } connection || _disposed) + { + return; + } + + if (previous.Label != state.Label) + { + DBusReply.Emit(connection, ItemPath, ItemInterface, "XAyatanaNewLabel", "ss", (ref MessageWriter writer) => + { + writer.WriteString(state.Label); + writer.WriteString(state.Label); + }); + } + + if (previous.ToolTip != state.ToolTip) + { + DBusReply.Emit(connection, ItemPath, ItemInterface, "NewToolTip", null, null); + } + + if (!ReferenceEquals(previous.Icons, state.Icons)) + { + DBusReply.Emit(connection, ItemPath, ItemInterface, "NewIcon", null, null); + } + } + + /// + /// Withdraws the item from the bus. + /// + /// + /// Closing the connection drops the well-known name with it, which is + /// what makes hosts remove the entry rather than keep a dead icon. + /// + public void Dispose() + { + if (_disposed) + { + return; + } + + _disposed = true; + if (_connection is { } connection) + { + _connection = null; + connection.Dispose(); + } + } + + /// + /// Follows the shell extension, so only one of the two is ever shown. + /// + /// + /// Reads the current owner first and then watches for changes, so an + /// extension already running when Reprise starts is noticed as surely + /// as one enabled later. + /// + /// Connection the item is exported on. + /// A task that completes once the watch is in place. + private async Task WatchShellExtensionAsync(DBusConnection connection) + { + var rule = new MatchRule + { + Type = MessageType.Signal, + Sender = "org.freedesktop.DBus", + Interface = "org.freedesktop.DBus", + Member = "NameOwnerChanged", + Arg0 = ShellExtensionName, + }; + await connection.AddMatchAsync( + rule, + static (Message message, object? _) => + { + var reader = message.GetBodyReader(); + reader.ReadString(); + reader.ReadString(); + return reader.ReadString(); + }, + (Notification notification) => + { + if (notification.HasValue) + { + SetShellExtensionRunning(notification.Value.Length > 0); + } + }, + emitOnCapturedContext: false); + + try + { + var owned = await connection + .CallMethodAsync( + CreateNameHasOwnerMessage(connection), + static (Message message, object? _) => message.GetBodyReader().ReadBool()) + .WaitAsync(CallTimeout); + SetShellExtensionRunning(owned); + } + catch (Exception exception) when (exception is not OperationCanceledException) + { + } + } + + /// + /// Shows or withdraws the item as the extension comes and goes. + /// + /// + /// The specification's Passive status is the polite way to disappear: + /// hosts hide the item but keep it registered, so it comes straight + /// back when the extension is switched off. + /// + /// Whether the extension owns its bus name. + private void SetShellExtensionRunning(bool running) + { + if (_item.IsPassive == running) + { + return; + } + + _item.IsPassive = running; + if (_connection is { } connection && !_disposed) + { + DBusReply.Emit(connection, ItemPath, ItemInterface, "NewStatus", "s", + (ref MessageWriter writer) => writer.WriteString(running ? "Passive" : "Active")); + } + } + + /// + /// Builds the call that asks whether the extension is running. + /// + /// Connection whose writer builds the message. + /// An encoded method call ready to send. + private static MessageBuffer CreateNameHasOwnerMessage(DBusConnection connection) + { + using var writer = connection.GetMessageWriter(); + writer.WriteMethodCallHeader( + destination: "org.freedesktop.DBus", + path: "/org/freedesktop/DBus", + @interface: "org.freedesktop.DBus", + member: "NameHasOwner", + signature: "s"); + writer.WriteString(ShellExtensionName); + return writer.CreateMessage(); + } + + /// + /// Asks the watcher to take the item. + /// + /// Cancels the call. + /// True when the watcher accepted; false when none is running. + private async Task RegisterAsync(CancellationToken cancellationToken) + { + if (_connection is not { } connection || _serviceName is not { } serviceName || _disposed) + { + return false; + } + + try + { + await connection + .CallMethodAsync(CreateRegisterMessage(connection, serviceName)) + .WaitAsync(CallTimeout, cancellationToken); + return true; + } + catch (Exception exception) when (exception is not OperationCanceledException) + { + return false; + } + } + + /// + /// Builds the RegisterStatusNotifierItem call. + /// + /// Connection whose writer allocates the buffer. + /// Name the item is exported under. + /// An encoded method call ready to send. + private static MessageBuffer CreateRegisterMessage(DBusConnection connection, string serviceName) + { + using var writer = connection.GetMessageWriter(); + writer.WriteMethodCallHeader( + destination: WatcherService, + path: WatcherPath, + @interface: WatcherService, + member: "RegisterStatusNotifierItem", + signature: "s"); + writer.WriteString(serviceName); + return writer.CreateMessage(); + } + + /// + /// Serves the org.kde.StatusNotifierItem object. + /// + private sealed class ItemHandler : IPathMethodHandler + { + private const string PropertiesInterface = "org.freedesktop.DBus.Properties"; + + private static readonly string[] PropertyNames = + [ + "Category", "Id", "Title", "Status", "WindowId", "IconName", "IconPixmap", + "OverlayIconName", "OverlayIconPixmap", "AttentionIconName", "AttentionIconPixmap", + "AttentionMovieName", "ToolTip", "IconThemePath", "ItemIsMenu", "Menu", + "XAyatanaLabel", "XAyatanaLabelGuide", "XAyatanaOrderingIndex", + ]; + + private static readonly ReadOnlyMemory IntrospectionXml = Encoding.UTF8.GetBytes(""" + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + """); + + private readonly StatusNotifierItem _owner; + + /// + /// Creates the handler for its owning item. + /// + /// Item whose events to raise. + public ItemHandler(StatusNotifierItem owner) + { + _owner = owner; + } + + /// + /// Content currently served to hosts. + /// + public StatusItemState State { get; set; } = new(string.Empty, "Reprise", []); + + /// + /// Whether hosts should hide the item for now. + /// + public bool IsPassive { get; set; } + + /// + /// Serves the whole object tree, not just the item's own path. + /// + /// + /// Tmds.DBus leaves a call to an unregistered path unanswered, so + /// the caller waits out its own timeout. Hosts probe for objects + /// Reprise deliberately does not offer - a + /// com.canonical.dbusmenu object above all - and a host that + /// blocked for twenty-five seconds on that probe would take its + /// panel down with it. Owning the root lets every such call be + /// answered at once with the error the specification defines. + /// + public string Path => "/"; + + /// + public bool HandlesChildPaths => true; + + /// + /// Answers property reads, transport methods, and introspection. + /// + /// + /// Anything addressed to a path other than the item's is refused + /// rather than ignored; see . + /// + /// The incoming call. + /// A completed task; every reply is written synchronously. + public ValueTask HandleMethodAsync(MethodContext context) + { + var request = context.Request; + if (request.PathAsString != ItemPath) + { + if (context.IsDBusIntrospectRequest) + { + context.ReplyIntrospectXml([], request.PathAsString == "/" ? ["StatusNotifierItem"] : Array.Empty()); + } + else + { + context.ReplyError( + "org.freedesktop.DBus.Error.UnknownObject", + $"No object exists at path {request.PathAsString}."); + } + + return ValueTask.CompletedTask; + } + + if (context.IsDBusIntrospectRequest) + { + context.ReplyIntrospectXml([IntrospectionXml], Array.Empty()); + } + else if (request.InterfaceAsString == PropertiesInterface) + { + HandleProperties(context); + } + else if (request.InterfaceAsString == ItemInterface) + { + HandleItemMethod(context); + } + else + { + context.ReplyUnknownMethodError(); + } + + return ValueTask.CompletedTask; + } + + /// + /// Dispatches one org.kde.StatusNotifierItem method. + /// + /// + /// Every click - primary, secondary, and the context-menu request a + /// host sends for a right click - opens the panel, since there is no + /// menu to show instead. + /// + /// The incoming call. + private void HandleItemMethod(MethodContext context) + { + switch (context.Request.MemberAsString) + { + case "Activate": + case "SecondaryActivate": + case "ContextMenu": + _owner.Activated?.Invoke(_owner, EventArgs.Empty); + DBusReply.SendEmpty(context); + break; + case "Scroll": + case "ProvideXdgActivationToken": + DBusReply.SendEmpty(context); + break; + default: + context.ReplyUnknownMethodError(); + break; + } + } + + /// + /// Serves Get, GetAll, and a no-op Set. + /// + /// The incoming call. + private void HandleProperties(MethodContext context) + { + var reader = context.Request.GetBodyReader(); + switch (context.Request.MemberAsString) + { + case "Get": + { + reader.ReadString(); + var name = reader.ReadString(); + if (Array.IndexOf(PropertyNames, name) < 0) + { + context.ReplyError("org.freedesktop.DBus.Error.InvalidArgs", $"Unknown property {name}"); + break; + } + + var state = State; + var passive = IsPassive; + DBusReply.Send(context, "v", (ref MessageWriter writer) => WriteProperty(ref writer, state, passive, name)); + break; + } + + case "GetAll": + { + var state = State; + var passive = IsPassive; + DBusReply.Send(context, "a{sv}", (ref MessageWriter writer) => + { + var dictionary = writer.WriteDictionaryStart(); + foreach (var name in PropertyNames) + { + writer.WriteDictionaryEntryStart(); + writer.WriteString(name); + WriteProperty(ref writer, state, passive, name); + } + + writer.WriteDictionaryEnd(dictionary); + }); + break; + } + + case "Set": + DBusReply.SendEmpty(context); + break; + + default: + context.ReplyUnknownMethodError(); + break; + } + } + + /// + /// Writes one property as a variant. + /// + /// Writer positioned where the variant goes. + /// Content being served. + /// Whether hosts should hide the item. + /// Property to write. + private static void WriteProperty(ref MessageWriter writer, StatusItemState state, bool passive, string name) + { + switch (name) + { + case "Category": + writer.WriteVariantString("ApplicationStatus"); + break; + case "Id": + writer.WriteVariantString("reprise"); + break; + case "Title": + writer.WriteVariantString("Reprise"); + break; + case "Status": + writer.WriteVariantString(passive ? "Passive" : "Active"); + break; + case "WindowId": + case "XAyatanaOrderingIndex": + writer.WriteVariantUInt32(0); + break; + case "IconName": + case "OverlayIconName": + case "AttentionIconName": + case "AttentionMovieName": + case "IconThemePath": + writer.WriteVariantString(string.Empty); + break; + case "IconPixmap": + writer.WriteSignature("a(iiay)"); + WritePixmaps(ref writer, state.Icons); + break; + case "OverlayIconPixmap": + case "AttentionIconPixmap": + writer.WriteSignature("a(iiay)"); + WritePixmaps(ref writer, []); + break; + case "ToolTip": + writer.WriteSignature("(sa(iiay)ss)"); + writer.WriteStructureStart(); + writer.WriteString(string.Empty); + WritePixmaps(ref writer, []); + writer.WriteString("Reprise"); + writer.WriteString(state.ToolTip); + break; + case "ItemIsMenu": + writer.WriteVariantBool(false); + break; + case "Menu": + writer.WriteVariantObjectPath(NoMenuPath); + break; + case "XAyatanaLabel": + case "XAyatanaLabelGuide": + writer.WriteVariantString(state.Label); + break; + } + } + + /// + /// Writes an a(iiay) pixmap list. + /// + /// Writer positioned at the array. + /// Icons to encode. + private static void WritePixmaps(ref MessageWriter writer, IReadOnlyList icons) + { + var array = writer.WriteArrayStart(DBusType.Struct); + foreach (var icon in icons) + { + writer.WriteStructureStart(); + writer.WriteInt32(icon.Width); + writer.WriteInt32(icon.Height); + writer.WriteArray(icon.Argb); + } + + writer.WriteArrayEnd(array); + } + } +} diff --git a/src/Reprise.Platform.MacOS/Reprise/ArtworkView.swift b/src/Reprise.Platform.MacOS/Reprise/ArtworkView.swift index b0e587c..ad5636f 100644 --- a/src/Reprise.Platform.MacOS/Reprise/ArtworkView.swift +++ b/src/Reprise.Platform.MacOS/Reprise/ArtworkView.swift @@ -8,12 +8,30 @@ import AppKit import SwiftUI +/// Square album cover, with a branded placeholder when no artwork exists. +/// +/// Used at every size Reprise shows a cover, from the menu bar item to the +/// panel, so it always renders something: a track with no artwork still +/// occupies its slot instead of collapsing the surrounding layout. struct ArtworkView: View { + /// Encoded cover art, or `nil` to draw the placeholder. let data: Data? + + /// Edge length in points; the view is always square. let size: CGFloat + + /// Corner rounding, defaulting to the panel's card radius. var cornerRadius: CGFloat = 12 + + /// SF Symbol drawn on the placeholder. Defaults to a generic music note. var symbolName: String = "music.note" + /// Draws the cover, or an accent gradient carrying ``symbolName``. + /// + /// The whole view is collapsed into a single accessibility element: the + /// artwork conveys nothing a screen reader user cannot get from the track + /// title beside it, so exposing the gradient and symbol separately would + /// add noise without information. var body: some View { Group { if let image = displayImage { @@ -46,12 +64,19 @@ struct ArtworkView: View { } } + /// Decodes ``data`` into an image sized for this view. + /// + /// The explicit `size` assignment is load-bearing: `MenuBarExtra` measures + /// an `NSImage`'s intrinsic point size before SwiftUI applies view + /// modifiers, so a high-resolution cover would widen the macOS status item + /// itself. Stamping the intended point size up front keeps the menu bar + /// item at a fixed width whatever the source resolution. + /// + /// - Returns: The decoded image, or `nil` when there is no data or it is + /// not a format AppKit can read. private var displayImage: NSImage? { guard let data, let image = NSImage(data: data) else { return nil } - // MenuBarExtra measures an NSImage's intrinsic point size before SwiftUI - // applies view modifiers. Give it the intended point size up front so a - // high-resolution cover never expands the macOS status item. image.size = NSSize(width: size, height: size) return image } diff --git a/src/Reprise.Platform.MacOS/Reprise/ContentView.swift b/src/Reprise.Platform.MacOS/Reprise/ContentView.swift index 94e35a0..1ad422a 100644 --- a/src/Reprise.Platform.MacOS/Reprise/ContentView.swift +++ b/src/Reprise.Platform.MacOS/Reprise/ContentView.swift @@ -8,9 +8,14 @@ import SwiftUI /// Xcode previews and UI tests use the same content as the menu bar popover. +/// +/// Wraps ``PlayerPopoverView`` in a view that owns its own store, since the +/// real popover receives one from ``StatusBarController``. Without this, +/// previewing the panel would mean standing up the whole status bar stack. struct ContentView: View { @State private var store = NowPlayingStore() + /// The popover panel, with polling started once the view appears. var body: some View { PlayerPopoverView(store: store) .task { diff --git a/src/Reprise.Platform.MacOS/Reprise/LaunchAtLoginController.swift b/src/Reprise.Platform.MacOS/Reprise/LaunchAtLoginController.swift index 134b086..d17ceb5 100644 --- a/src/Reprise.Platform.MacOS/Reprise/LaunchAtLoginController.swift +++ b/src/Reprise.Platform.MacOS/Reprise/LaunchAtLoginController.swift @@ -8,12 +8,34 @@ import Combine import ServiceManagement +/// Login item registration state, reduced to what the settings toggle needs. +/// +/// Mirrors `SMAppService.Status` but collapses the cases the UI treats alike, +/// and stays stable if the system enum gains members. enum LaunchAtLoginState: Equatable { + /// Not registered; the toggle is off. case disabled + + /// Registered and active. case enabled + + /// Registered, but the user must approve it in System Settings before it + /// takes effect. case requiresApproval + + /// The service could not be found, so registration cannot be attempted. + /// + /// Normally means the app is running from a location macOS will not + /// register a login item from, such as a build directory. case unavailable + /// Maps a system status onto the state the UI works with. + /// + /// `@unknown default` folds into ``unavailable`` rather than ``disabled`` + /// so a future status never presents as a working "off" switch that + /// silently fails to turn on. + /// + /// - Parameter status: Status reported by `SMAppService`. init(status: SMAppService.Status) { switch status { case .notRegistered: @@ -29,27 +51,63 @@ enum LaunchAtLoginState: Equatable { } } + /// Whether the toggle should read as on. + /// + /// Includes ``requiresApproval``, because from the user's point of view + /// they have already switched it on; what remains is a system prompt, not + /// a further decision in Reprise. var isOn: Bool { self == .enabled || self == .requiresApproval } } +/// Drives the "launch at login" setting through `SMAppService`. +/// +/// Registration can fail or need approval, so the controller publishes both +/// the resulting state and any error rather than returning a plain success +/// flag, letting the settings pane explain what happened. @MainActor final class LaunchAtLoginController: ObservableObject { + /// Current registration state, refreshed after every change. @Published private(set) var state: LaunchAtLoginState + + /// Why the last change failed, or `nil` when it succeeded. @Published private(set) var errorMessage: String? private let service: SMAppService + /// Creates a controller over a login item service. + /// + /// - Parameter service: Service to register against. Defaults to the main + /// app's, and is injectable so tests can drive the state machine + /// without touching the user's real login items. init(service: SMAppService = .mainApp) { self.service = service state = LaunchAtLoginState(status: service.status) } + /// Re-reads the registration state from the system. + /// + /// Needed because approval happens in System Settings, outside the app, + /// with no callback: the state is only observed on the next look. func refresh() { state = LaunchAtLoginState(status: service.status) } + /// Turns launch at login on or off. + /// + /// Turning it on branches on the current state: an unregistered service is + /// registered, one awaiting approval sends the user to System Settings + /// since Reprise cannot approve on their behalf, and an already-enabled + /// one is left alone. Turning it off unregisters only when something is + /// actually registered. + /// + /// Failures are published to ``errorMessage`` rather than thrown, since + /// the caller is a SwiftUI toggle with nowhere to catch. The state is + /// re-read either way, so a failed change snaps the toggle back to the + /// truth instead of leaving it showing what was asked for. + /// + /// - Parameter enabled: Whether the login item should be registered. func setEnabled(_ enabled: Bool) { errorMessage = nil @@ -73,6 +131,10 @@ final class LaunchAtLoginController: ObservableObject { refresh() } + /// Opens the Login Items pane of System Settings. + /// + /// Offered alongside the toggle so a user stuck on ``requiresApproval`` + /// has a way to finish the job without hunting through System Settings. func openSystemSettings() { SMAppService.openSystemSettingsLoginItems() } diff --git a/src/Reprise.Platform.MacOS/Reprise/LyricsService.swift b/src/Reprise.Platform.MacOS/Reprise/LyricsService.swift index 5b118c8..20ee6f6 100644 --- a/src/Reprise.Platform.MacOS/Reprise/LyricsService.swift +++ b/src/Reprise.Platform.MacOS/Reprise/LyricsService.swift @@ -7,16 +7,40 @@ import Foundation +/// Where a set of lyrics came from. +/// +/// Surfaced in the UI so the user can see which service supplied the words, +/// and used to decide precedence when both have a match. enum LyricsSource: String, Equatable, Sendable { + /// Naver VIBE, which supplies its own per-line timings. case vibe = "VIBE" + + /// LRCLIB, which supplies LRC-format lyrics. case lrclib = "LRCLIB" } +/// One timed line of lyrics. struct LyricLine: Equatable, Sendable { + /// When the line begins, in seconds from the start of the track. let startTime: TimeInterval + + /// When the line stops being current, if known. + /// + /// `nil` means the line stays current until the next one begins, which is + /// how LRC behaves; VIBE supplies explicit ends, which is what lets a gap + /// between lines show no lyric at all rather than holding the last one. let endTime: TimeInterval? + + /// The line's text. let text: String + /// Creates a lyric line. + /// + /// - Parameters: + /// - startTime: Start offset in seconds. + /// - endTime: End offset in seconds. Defaults to `nil`, meaning the line + /// runs until the next one. + /// - text: The line's text. nonisolated init( startTime: TimeInterval, endTime: TimeInterval? = nil, @@ -28,10 +52,27 @@ struct LyricLine: Equatable, Sendable { } } +/// A full set of time-synced lyrics for one track. +/// +/// Lines are held sorted by start time, which the lookups below rely on to +/// binary search rather than scan - they run on every UI tick. struct SyncedLyrics: Equatable, Sendable { + /// Which service supplied these lyrics. let source: LyricsSource + + /// The lines, in ascending order of start time. let lines: [LyricLine] + /// Finds the last line that has begun by a given moment. + /// + /// Ignores end times, so it always returns a line once playback is past + /// the first one. That is what the scrolling lyric view wants: it needs a + /// line to centre on even during an instrumental gap, where the "current" + /// line has technically ended. + /// + /// - Parameter position: Playback position in seconds. + /// - Returns: Index of the most recently started line, or `nil` before the + /// first line, when there are none, or when the position is not finite. nonisolated func focusedLineIndex( at position: TimeInterval ) -> Int? { @@ -52,6 +93,14 @@ struct SyncedLyrics: Equatable, Sendable { return index >= 0 ? index : nil } + /// Finds the line that is genuinely current at a given moment. + /// + /// Unlike ``focusedLineIndex(at:)``, a line whose end time has passed + /// counts as over. The menu bar uses this so an instrumental break shows + /// nothing rather than leaving a stale line sitting there. + /// + /// - Parameter position: Playback position in seconds. + /// - Returns: Index of the current line, or `nil` when no line is active. nonisolated func lineIndex(at position: TimeInterval) -> Int? { guard let index = focusedLineIndex(at: position) else { return nil @@ -63,17 +112,38 @@ struct SyncedLyrics: Equatable, Sendable { return index } + /// The line that is current at a given moment. + /// + /// - Parameter position: Playback position in seconds. + /// - Returns: The active line, or `nil` when none is. nonisolated func line(at position: TimeInterval) -> LyricLine? { lineIndex(at: position).map { lines[$0] } } } +/// The identity of a track, for looking lyrics up and caching the result. +/// +/// Equality and hashing deliberately ignore duration and compare only +/// normalised text: the same recording reports slightly different lengths +/// across players and services, so including duration would miss cache hits +/// and re-fetch lyrics on every track change. Duration is still carried, +/// because it is a useful tie-breaker when ranking candidates. struct LyricsTrackQuery: Hashable, Sendable { + /// Track title, as the player reported it. let title: String + + /// Album name, as the player reported it. let album: String + + /// Artist credit, as the player reported it. let artist: String + + /// Track length in seconds, used only for ranking candidates. let duration: TimeInterval + /// Builds a query from a track. + /// + /// - Parameter track: Track to look lyrics up for. nonisolated init(track: Track) { title = track.title album = track.album @@ -81,12 +151,24 @@ struct LyricsTrackQuery: Hashable, Sendable { duration = track.duration } + /// Hashes the normalised text fields only. + /// + /// Must stay consistent with `==`, which is why duration is excluded here + /// as well. + /// + /// - Parameter hasher: Hasher to feed. nonisolated func hash(into hasher: inout Hasher) { hasher.combine(Self.normalized(title)) hasher.combine(Self.normalized(album)) hasher.combine(Self.normalized(artist)) } + /// Compares two queries on normalised text. + /// + /// - Parameters: + /// - lhs: First query. + /// - rhs: Second query. + /// - Returns: `true` when they describe the same recording. nonisolated static func == ( lhs: LyricsTrackQuery, rhs: LyricsTrackQuery @@ -96,6 +178,26 @@ struct LyricsTrackQuery: Hashable, Sendable { && normalized(lhs.artist) == normalized(rhs.artist) } + /// Reduces a title or artist to a form two services can be compared on. + /// + /// Case, accents, and full-width forms are folded away, then everything + /// that is not alphanumeric is dropped. That collapses the punctuation + /// differences that otherwise defeat matching - `Don't Stop (Remastered)` + /// against `Dont Stop Remastered`, or a Korean title spaced differently by + /// two catalogues. + /// + /// Folding is pinned to `en_US_POSIX` so the result never depends on the + /// user's locale, which would make a cached lookup behave differently on + /// two machines. + /// + /// - Parameter value: Raw text. + /// - Returns: A lowercase alphanumeric-only string. + /// + /// ## Example + /// ```swift + /// LyricsTrackQuery.normalized("Don't Stop (Remastered)") + /// // "dontstopremastered" + /// ``` nonisolated static func normalized(_ value: String) -> String { value .folding( @@ -110,26 +212,54 @@ struct LyricsTrackQuery: Hashable, Sendable { } } +/// Progress of a lyrics lookup, as the UI sees it. +/// +/// ``unavailable`` is a settled answer, not an error: it means the services +/// were asked and had nothing, so the UI should stop waiting rather than +/// retry. enum LyricsLoadState: Equatable, Sendable { + /// No lookup has been attempted for the current track. case idle + + /// A lookup is in flight. case loading + + /// Lyrics were found. case available(SyncedLyrics) + + /// No service had synced lyrics for this track. case unavailable + /// The lyrics, when there are any. var lyrics: SyncedLyrics? { guard case let .available(lyrics) = self else { return nil } return lyrics } } +/// Fetches time-synced lyrics from VIBE and LRCLIB. +/// +/// An actor so lookups stay off the main thread; it holds no cached state of +/// its own, since the caller caches by ``LyricsTrackQuery``. actor LyricsService { + /// VIBE's web API root. private static let vibeBaseURL = URL(string: "https://apis.naver.com/vibeWeb/musicapiweb")! + + /// LRCLIB's API root. private static let lrclibBaseURL = URL(string: "https://lrclib.net/api")! private let session: URLSession + /// Creates the service. + /// + /// The default session is ephemeral, so lyric requests leave nothing on + /// disk, and its timeouts are short because a lookup that outlives the + /// track it was for is worse than no lookup. + /// + /// - Parameter session: Session to use. Defaults to `nil`, which builds + /// the configured ephemeral session; tests pass a stub. init(session: URLSession? = nil) { if let session { self.session = session @@ -142,6 +272,17 @@ actor LyricsService { } } + /// Looks up synced lyrics for a track. + /// + /// VIBE is tried first because it supplies explicit per-line end times, + /// which LRC cannot express; LRCLIB is the broader fallback. Cancellation + /// is checked between the two so a track change does not pay for a second + /// lookup nobody is waiting on. + /// + /// - Parameter query: The track to find lyrics for. + /// - Returns: The lyrics, or `nil` when neither service has them. Never + /// throws: a lookup failure is indistinguishable from no match as far as + /// the UI is concerned. func fetchSyncedLyrics( for query: LyricsTrackQuery ) async -> SyncedLyrics? { @@ -152,6 +293,13 @@ actor LyricsService { return await fetchLRCLIBLyrics(for: query) } + /// Searches VIBE and fetches lyrics for the best matching track. + /// + /// Two round trips: VIBE's search does not return lyrics, so a track has + /// to be chosen first and its lyrics fetched by id. + /// + /// - Parameter query: The track to find lyrics for. + /// - Returns: The lyrics, or `nil` on no match or any failure. private func fetchVibeLyrics( for query: LyricsTrackQuery ) async -> SyncedLyrics? { @@ -187,6 +335,17 @@ actor LyricsService { } } + /// Fetches lyrics from LRCLIB, exactly then approximately. + /// + /// The `get` endpoint needs all four fields to agree and returns 404 when + /// they do not, which is common because players disagree on album naming + /// and round durations differently. A miss there is expected rather than + /// exceptional, so it falls through to the looser `search` endpoint and + /// ranks the results locally. + /// + /// - Parameter query: The track to find lyrics for. + /// - Returns: The lyrics, or `nil` when neither endpoint yields a usable + /// match. private func fetchLRCLIBLyrics( for query: LyricsTrackQuery ) async -> SyncedLyrics? { @@ -241,6 +400,20 @@ actor LyricsService { } } + /// Performs one API request and returns its body. + /// + /// Sends an identifying User-Agent because LRCLIB asks clients to, and + /// treats any non-2xx status as a failure so an HTML error page never + /// reaches a parser. + /// + /// - Parameters: + /// - path: Path relative to `baseURL`. + /// - baseURL: Service root. + /// - queryItems: Query parameters. Defaults to none. + /// - Returns: The response body. + /// - Throws: `URLError.badURL` if the components will not resolve, + /// `URLError.badServerResponse` on a non-2xx status, or any transport + /// error from `URLSession`. private func data( path: String, baseURL: URL, @@ -271,6 +444,14 @@ actor LyricsService { return data } + /// Builds the query for LRCLIB's exact-match endpoint. + /// + /// Album and duration are included only when known, since sending a blank + /// album or a zero duration would narrow the match to nothing rather than + /// being ignored. + /// + /// - Parameter query: The track to look up. + /// - Returns: Query items for the `get` endpoint. private static func lrclibQueryItems( for query: LyricsTrackQuery ) -> [URLQueryItem] { @@ -294,16 +475,37 @@ actor LyricsService { } extension LyricsService { + /// One track from a VIBE search result. struct VibeCandidate: Equatable, Sendable { + /// VIBE's track id, needed to fetch the lyrics. let trackID: String + + /// Track title as VIBE has it. let title: String + + /// Album title as VIBE has it. let album: String + + /// Every credited artist; VIBE lists collaborators separately. let artists: [String] + + /// Track length in seconds, parsed from VIBE's `mm:ss` field. let duration: TimeInterval + + /// Whether VIBE holds timed lyrics for this track. let hasSyncedLyrics: Bool + + /// Whether VIBE flags the track as adult-only. let isAdult: Bool } + /// Parses a VIBE search response into candidates. + /// + /// - Parameter data: XML body from the search endpoint. + /// - Returns: Every track with an id and a title. Entries missing either + /// are skipped rather than failing the parse, since one malformed row + /// should not lose the rest of the results. + /// - Throws: A parse error when the body is not usable XML. nonisolated static func parseVibeSearch( _ data: Data ) throws -> [VibeCandidate] { @@ -334,6 +536,22 @@ extension LyricsService { } } + /// Picks the VIBE candidate most likely to be the same recording. + /// + /// Candidates without timed lyrics are useless here and adult-flagged ones + /// are excluded outright, so both are filtered before scoring. + /// + /// A title match is mandatory - exact or one containing the other, which + /// tolerates suffixes like `(Live)`. Beyond that, at least one of artist, + /// album, or duration must corroborate it, so a common title alone cannot + /// carry a wrong track through. Scoring then prefers exact titles, and + /// weights artist above album above duration, since a shared artist is far + /// stronger evidence than a length two catalogues happen to agree on. + /// + /// - Parameters: + /// - query: The track being looked up. + /// - candidates: Search results to rank. + /// - Returns: The highest scoring candidate, or `nil` when none qualifies. nonisolated static func bestVibeCandidate( for query: LyricsTrackQuery, candidates: [VibeCandidate] @@ -385,6 +603,20 @@ extension LyricsService { .0 } + /// Parses VIBE's lyric response into timed lines. + /// + /// VIBE returns starts, ends, and texts as three parallel arrays rather + /// than as line elements, so they are zipped back together here. The + /// counts are required to agree before anything is built: a mismatch would + /// silently pair each line with the wrong timestamp. + /// + /// A track may carry translations, so the `default` language content is + /// preferred and the first is used only as a fallback. + /// + /// - Parameter data: XML body from the lyric endpoint. + /// - Returns: The lyrics, or `nil` when the track has none, the arrays + /// disagree, or every line is blank. + /// - Throws: A parse error when the body is not usable XML. nonisolated static func parseVibeLyrics( _ data: Data ) throws -> SyncedLyrics? { @@ -436,6 +668,31 @@ extension LyricsService { return SyncedLyrics(source: .vibe, lines: lines) } + /// Parses LRC-format lyrics into timed lines. + /// + /// Handles the parts of LRC that appear in real files: an `[offset:]` tag + /// shifting every subsequent timestamp, and repeated timestamps on one + /// line for a refrain, which are expanded into a separate entry each. + /// Metadata tags and untimed lines are skipped. + /// + /// Lines are sorted after parsing, because repeated timestamps and offsets + /// mean file order is not time order. Each line's end is the next line's + /// start, so no gap appears mid-song; the final line ends at the track + /// duration when one is known, which stops the last lyric hanging in the + /// menu bar after the song is over. + /// + /// - Parameters: + /// - value: LRC file contents. + /// - duration: Track length in seconds, used only to close the last + /// line. Defaults to 0, leaving it open-ended. + /// - Returns: Timed lines, sorted by start time. Empty when nothing timed + /// was found. + /// + /// ## Example + /// ```swift + /// LyricsService.parseLRC("[00:12.50]Hello", duration: 180) + /// // [LyricLine(startTime: 12.5, endTime: 180, text: "Hello")] + /// ``` nonisolated static func parseLRC( _ value: String, duration: TimeInterval = 0 @@ -500,6 +757,10 @@ extension LyricsService { } } + /// Parses VIBE's `mm:ss` duration field into seconds. + /// + /// - Parameter value: Clock string such as `3:52`. + /// - Returns: The duration in seconds, or 0 when it is not two components. private nonisolated static func parseClock( _ value: String ) -> TimeInterval { @@ -510,14 +771,36 @@ extension LyricsService { } private extension LyricsService { + /// One track record from LRCLIB. struct LRCLIBResult: Decodable, Sendable { + /// Track title. let trackName: String + + /// Artist credit. let artistName: String + + /// Album name, which LRCLIB may omit. let albumName: String? + + /// Track length in seconds, as LRCLIB has it. let duration: TimeInterval + + /// LRC-format lyrics, absent for records that are unsynced only. let syncedLyrics: String? } + /// Picks the LRCLIB result most likely to be the same recording. + /// + /// Records without synced lyrics are dropped first, since plain text + /// cannot be shown against playback. The rest must match on both title and + /// artist - LRCLIB's search is loose enough to return unrelated tracks - + /// after which duration decides, being the only remaining signal that + /// separates a single from an album version. + /// + /// - Parameters: + /// - query: The track being looked up. + /// - results: Search results to rank. + /// - Returns: The closest match by duration, or `nil` when none qualifies. nonisolated static func bestLRCLIBResult( for query: LyricsTrackQuery, results: [LRCLIBResult] @@ -545,6 +828,15 @@ private extension LyricsService { } } + /// Converts an LRCLIB record into lyrics. + /// + /// Prefers the player's duration over LRCLIB's for closing the last line, + /// since that is what playback will actually be measured against. + /// + /// - Parameters: + /// - result: The chosen LRCLIB record. + /// - duration: Track length from the player, or 0 when unknown. + /// - Returns: The lyrics, or `nil` when the record has no usable LRC. nonisolated static func syncedLyrics( from result: LRCLIBResult, duration: TimeInterval @@ -561,32 +853,66 @@ private extension LyricsService { } } +/// One element of a parsed XML document. +/// +/// A minimal tree, built because VIBE returns XML and `XMLParser` is +/// event-driven: the search and lyric responses both need to be walked by +/// name and re-associated across siblings, which streaming callbacks make +/// awkward. `XMLDocument` would do it, but is unavailable in a sandboxed app. private nonisolated final class XMLTreeNode { + /// Element name. let name: String + + /// Accumulated character data, which arrives in fragments. var text = "" + + /// Child elements, in document order. var children: [XMLTreeNode] = [] + /// Creates an empty node. + /// + /// - Parameter name: Element name. init(name: String) { self.name = name } + /// The element's text with whitespace trimmed. + /// + /// - Returns: The trimmed text, or `nil` when it is blank, so callers can + /// treat empty and absent alike. var trimmedText: String? { let value = text.trimmingCharacters(in: .whitespacesAndNewlines) return value.isEmpty ? nil : value } + /// Direct children with a given name. + /// + /// - Parameter name: Element name to match. + /// - Returns: Matching children, in document order. func children(named name: String) -> [XMLTreeNode] { children.filter { $0.name == name } } + /// The first direct child with a given name. + /// + /// - Parameter name: Element name to match. + /// - Returns: The child, or `nil`. func directChild(named name: String) -> XMLTreeNode? { children.first { $0.name == name } } + /// Text of the first direct child with a given name. + /// + /// - Parameter name: Element name to match. + /// - Returns: The trimmed text, or `nil` when the child is absent or blank. func directText(named name: String) -> String? { directChild(named: name)?.trimmedText } + /// Every descendant with a given name, at any depth. + /// + /// - Parameter name: Element name to match. + /// - Returns: Matching descendants, in document order. func descendants(named name: String) -> [XMLTreeNode] { children.flatMap { child in (child.name == name ? [child] : []) @@ -594,6 +920,13 @@ private nonisolated final class XMLTreeNode { } } + /// The first descendant with a given name, depth-first. + /// + /// Separate from ``descendants(named:)`` because it stops at the first + /// hit rather than walking the whole subtree. + /// + /// - Parameter name: Element name to match. + /// - Returns: The descendant, or `nil`. func firstDescendant(named name: String) -> XMLTreeNode? { for child in children { if child.name == name { @@ -606,16 +939,32 @@ private nonisolated final class XMLTreeNode { return nil } + /// Text of the first descendant with a given name. + /// + /// - Parameter name: Element name to match. + /// - Returns: The trimmed text, or `nil`. func firstDescendantText(named name: String) -> String? { firstDescendant(named: name)?.trimmedText } } +/// Builds an ``XMLTreeNode`` tree from XML data. private nonisolated final class XMLTreeParser: NSObject, XMLParserDelegate { private let document = XMLTreeNode(name: "document") private var stack: [XMLTreeNode] = [] private var parsingError: Error? + /// Parses XML into a tree. + /// + /// The delegate is held for the duration of the synchronous parse, so the + /// local reference is enough to keep it alive - `XMLParser.delegate` is + /// unowned. + /// + /// - Parameter data: XML document. + /// - Returns: A synthetic root whose children are the document's + /// top-level elements. + /// - Throws: The delegate's error, the parser's error, or + /// `URLError.cannotParseResponse` if neither was recorded. static func parse(_ data: Data) throws -> XMLTreeNode { let delegate = XMLTreeParser() let parser = XMLParser(data: data) @@ -628,6 +977,15 @@ private nonisolated final class XMLTreeParser: NSObject, XMLParserDelegate { return delegate.document } + /// Opens an element and descends into it. + /// + /// - Parameters: + /// - parser: Reporting parser. + /// - elementName: Name of the element being opened. + /// - namespaceURI: Namespace; unused. + /// - qName: Qualified name; unused. + /// - attributeDict: Attributes; unused, as no VIBE field is an + /// attribute. func parser( _ parser: XMLParser, didStartElement elementName: String, @@ -640,6 +998,14 @@ private nonisolated final class XMLTreeParser: NSObject, XMLParserDelegate { stack.append(node) } + /// Appends character data to the open element. + /// + /// Appends rather than assigns, because the parser delivers text in + /// fragments split around entities and buffer boundaries. + /// + /// - Parameters: + /// - parser: Reporting parser. + /// - string: Text fragment. func parser( _ parser: XMLParser, foundCharacters string: String @@ -647,6 +1013,14 @@ private nonisolated final class XMLTreeParser: NSObject, XMLParserDelegate { stack.last?.text += string } + /// Closes the current element. + /// + /// - Parameters: + /// - parser: Reporting parser. + /// - elementName: Name of the element being closed; unused, since the + /// stack already identifies it. + /// - namespaceURI: Namespace; unused. + /// - qName: Qualified name; unused. func parser( _ parser: XMLParser, didEndElement elementName: String, @@ -656,6 +1030,11 @@ private nonisolated final class XMLTreeParser: NSObject, XMLParserDelegate { _ = stack.popLast() } + /// Records a parse failure for ``parse(_:)`` to throw. + /// + /// - Parameters: + /// - parser: Reporting parser. + /// - parseError: The failure. func parser( _ parser: XMLParser, parseErrorOccurred parseError: Error diff --git a/src/Reprise.Platform.MacOS/Reprise/MediaAutomationService.swift b/src/Reprise.Platform.MacOS/Reprise/MediaAutomationService.swift index 15a0ab6..238b550 100644 --- a/src/Reprise.Platform.MacOS/Reprise/MediaAutomationService.swift +++ b/src/Reprise.Platform.MacOS/Reprise/MediaAutomationService.swift @@ -8,17 +8,44 @@ import AppKit import Foundation +/// Reads and controls the local media players. +/// +/// Spotify and Music are driven with AppleScript; YouTube Music has no app to +/// script, so it is delegated to the browser extension bridge. Presenting both +/// behind one type keeps that split out of the store and the UI. +/// +/// An actor because it owns caches that a once-a-second poll and the user's +/// button presses both reach, and because AppleScript execution is +/// synchronous and blocking - serialising it here keeps it off the main +/// thread without a lock at every call site. actor MediaAutomationService { + /// Artwork held for one track, so a poll does not re-fetch a cover that + /// has not changed. private struct ArtworkCacheEntry { + /// Identity of the track the artwork belongs to. let trackKey: String + + /// The cover data, or `nil` if the track genuinely has none. Caching + /// the absence matters as much as the data: it stops every poll + /// retrying a download for a track that will never have a cover. let data: Data? } + /// The last good snapshot for a player, with when it was taken. private struct SnapshotCacheEntry { + /// The snapshot itself. let snapshot: PlayerSnapshot + + /// When it was captured, for judging whether it is still usable. let observedAt: Date } + /// How long a stale snapshot may stand in for a failed read. + /// + /// AppleScript intermittently fails for a poll or two while a player + /// changes tracks or the system is busy. Bridging that gap keeps the panel + /// from flashing an error over a player that is working normally; beyond + /// it, the failure is real enough to show. private static let transientFailureGraceInterval: TimeInterval = 2 private var artworkCache: [MediaPlayerKind: ArtworkCacheEntry] = [:] @@ -26,12 +53,28 @@ actor MediaAutomationService { private var snapshotScriptCache: [MediaPlayerKind: NSAppleScript] = [:] private let youtubeMusicBridge: YouTubeMusicBridge + /// Creates the service. + /// + /// - Parameter youtubeMusicBridge: Bridge to the browser extension. + /// Defaults to the shared instance; injectable for tests. init( youtubeMusicBridge: YouTubeMusicBridge = .shared ) { self.youtubeMusicBridge = youtubeMusicBridge } + /// Polls every supported player once. + /// + /// Players are read sequentially rather than concurrently because each + /// AppleScript call blocks, and running them in parallel would multiply + /// the automation load without shortening the slowest one. + /// + /// - Parameter automaticallyPausesOtherYouTubeMusicSessions: Whether the + /// extension should pause its other tabs when one starts playing. Passed + /// through on every poll so a settings change takes effect without + /// needing its own notification path. + /// - Returns: One snapshot per player. Players that are not running are + /// present as unavailable rather than absent. func snapshots( automaticallyPausesOtherYouTubeMusicSessions: Bool ) async -> [MediaPlayerKind: PlayerSnapshot] { @@ -48,6 +91,15 @@ actor MediaAutomationService { return result } + /// Sends a transport command to one player. + /// + /// - Parameters: + /// - command: Transport control to invoke. + /// - player: Player to command. + /// - Throws: ``AutomationError/playerNotRunning(_:)`` when the app is not + /// open, ``AutomationError/appleScript(number:message:)`` when the + /// script fails - automation permission being the usual cause - or a + /// `YouTubeMusicBridgeProtocolError` from the extension. func perform( _ command: PlaybackCommand, on player: MediaPlayerKind @@ -72,6 +124,19 @@ actor MediaAutomationService { _ = try execute(source) } + /// Sets a player's volume and reports back what it actually took. + /// + /// The value is read back rather than assumed, because players round and + /// clamp differently; returning the real level keeps the slider from + /// drifting away from the player it controls. + /// + /// - Parameters: + /// - volume: Desired level from 0 to 100; clamped before use. + /// - player: Player to adjust. + /// - Returns: The level the player settled on, falling back to the + /// requested value if it reported something unparsable. + /// - Throws: ``AutomationError/playerNotRunning(_:)`` or + /// ``AutomationError/appleScript(number:message:)``. func setVolume( _ volume: Int, on player: MediaPlayerKind @@ -100,6 +165,22 @@ actor MediaAutomationService { ?? volume } + /// Seeks a player and waits for it to confirm the new position. + /// + /// Spotify and Music both accept a seek and then keep reporting the old + /// position for a short while. Returning immediately would hand the UI a + /// stale value and make the progress bar snap backwards, so the position + /// is polled until it agrees with the target within + /// ``PlaybackPosition/seekConfirmationTolerance``. Twelve attempts at + /// 100ms covers roughly a second, well past what a healthy player needs. + /// + /// - Parameters: + /// - position: Target position in seconds; non-finite values become 0. + /// - player: Player to seek. + /// - Returns: The confirmed position, as the player reports it. + /// - Throws: ``AutomationError/seekNotConfirmed(requested:observed:)`` if + /// the player never agrees, plus the errors the other commands can + /// raise. func setPosition( _ position: TimeInterval, on player: MediaPlayerKind @@ -130,8 +211,6 @@ actor MediaAutomationService { """ var lastObservedPosition: TimeInterval? - // Spotify and Music can briefly report the old position immediately - // after accepting a seek. Do not expose that stale value to the UI. for attempt in 0..<12 { if attempt > 0 { try await Task.sleep(for: .milliseconds(100)) @@ -159,6 +238,20 @@ actor MediaAutomationService { ) } + /// Reads one player's current state. + /// + /// A player that is not running clears its caches on the way out, so + /// relaunching it cannot serve artwork or a snapshot from its previous + /// session. + /// + /// Errors never propagate: a poll failure within the grace interval + /// replays the last good snapshot, and anything longer becomes an + /// unavailable snapshot carrying a readable message. The panel therefore + /// always has something to render, and one failing player never blocks + /// the others in the same sweep. + /// + /// - Parameter player: Player to read. + /// - Returns: The player's state, never a thrown error. private func snapshot(for player: MediaPlayerKind) async -> PlayerSnapshot { if player == .youtubeMusic { return await youtubeMusicBridge.snapshot() @@ -229,6 +322,15 @@ actor MediaAutomationService { } } + /// Whether a player's app is currently open. + /// + /// Checked before every script so Reprise never sends AppleScript to a + /// closed app, which macOS would answer by launching it - turning a + /// routine poll into an unwanted app launch. + /// + /// - Parameter player: Player to check. + /// - Returns: `true` when the app is running. Always `false` for players + /// with no bundle identifier, such as YouTube Music. private func isRunning(_ player: MediaPlayerKind) -> Bool { guard let bundleIdentifier = player.automationBundleIdentifier else { return false @@ -238,6 +340,19 @@ actor MediaAutomationService { ).isEmpty } + /// Builds the AppleScript that reads a player's whole state at once. + /// + /// One script returning a fixed eight-element list, rather than a call per + /// field: it is a single automation round trip, and every value describes + /// the same instant. A stopped player returns the same shape with empty + /// track fields, so the caller's index-based parsing never has to branch. + /// + /// Spotify's artwork URL read is wrapped in `try` because it fails on + /// local files and podcasts, where the rest of the track is still valid. + /// + /// - Parameter player: Player to build the script for. + /// - Returns: AppleScript source, or an empty string for players that are + /// not scripted. private func snapshotScript(for player: MediaPlayerKind) -> String { let trackStatements = switch player { case .spotify: @@ -290,6 +405,16 @@ actor MediaAutomationService { """ } + /// Runs the snapshot script, compiling it only once per player. + /// + /// Compiling AppleScript is the expensive part, and the source never + /// varies for a given player, so the compiled script is cached across the + /// once-a-second poll. + /// + /// - Parameter player: Player to read. + /// - Returns: The descriptor holding the eight-element result list. + /// - Throws: ``AutomationError/invalidScript`` if the source will not + /// compile, or ``AutomationError/appleScript(number:message:)``. private func executeSnapshotScript( for player: MediaPlayerKind ) throws -> NSAppleEventDescriptor { @@ -308,6 +433,18 @@ actor MediaAutomationService { return try execute(script) } + /// Fetches cover art for a track, reusing the cached copy when possible. + /// + /// The cache is keyed on the track's own identity rather than the player, + /// so the expensive part - a network download for Spotify, a second + /// AppleScript round trip for Music - happens once per track instead of + /// once per poll. + /// + /// - Parameters: + /// - player: Player the track belongs to. + /// - trackKey: Identity of the track, built from its text metadata. + /// - remoteURL: Artwork URL, used by Spotify only. + /// - Returns: Encoded artwork, or `nil` when there is none to be had. private func artwork( for player: MediaPlayerKind, trackKey: String, @@ -332,6 +469,20 @@ actor MediaAutomationService { return data } + /// Downloads cover art from a URL. + /// + /// The response is validated before it is trusted: status code, an 8MB + /// ceiling, and a decode check. Artwork is the one thing Reprise fetches + /// from a URL a third-party app supplies, so it is treated as untrusted + /// input - the size limit bounds memory, and the decode check keeps + /// something that is not an image from reaching the UI layer. + /// + /// The five-second timeout matters because this sits inside the poll: a + /// slow host would otherwise stall every player's refresh. + /// + /// - Parameter urlString: Absolute URL of the artwork. + /// - Returns: The image data, or `nil` on any failure. Never throws, since + /// a missing cover is not worth failing a snapshot over. private func downloadArtwork(from urlString: String) async -> Data? { guard let url = URL(string: urlString), !urlString.isEmpty else { return nil } @@ -354,6 +505,15 @@ actor MediaAutomationService { } } + /// Reads the current track's cover art out of the Music app. + /// + /// Music holds artwork as embedded data rather than a URL, so it comes + /// back over AppleScript instead of over the network. The count is checked + /// first because asking for `artwork 1` of a track with none raises a + /// script error rather than returning empty. + /// + /// - Returns: The raw image data, or `nil` when the track has no artwork + /// or the script fails. private func appleMusicArtwork() -> Data? { let source = """ tell application id "com.apple.Music" @@ -368,6 +528,17 @@ actor MediaAutomationService { return data.isEmpty ? nil : data } + /// Maps a transport command onto the player's AppleScript vocabulary. + /// + /// Mostly a one-to-one translation, except that Spotify exposes no stop + /// command: pausing and rewinding to zero produces the same observable + /// result the user expects from stop. + /// + /// - Parameters: + /// - command: Command to translate. + /// - player: Player whose vocabulary to use. + /// - Returns: AppleScript statements, or an empty string for players not + /// driven by script. private func appleScriptCommand( _ command: PlaybackCommand, for player: MediaPlayerKind @@ -382,8 +553,6 @@ actor MediaAutomationService { case .stop: switch player { case .spotify: - // Spotify does not expose a native stop command. Rewinding and - // pausing gives the user the same observable stop behavior. return """ pause set player position to 0 @@ -398,6 +567,12 @@ actor MediaAutomationService { } } + /// Compiles and runs AppleScript source. + /// + /// - Parameter source: Script source. + /// - Returns: The script's result descriptor. + /// - Throws: ``AutomationError/invalidScript`` if it will not compile, or + /// ``AutomationError/appleScript(number:message:)`` if it fails to run. private func execute(_ source: String) throws -> NSAppleEventDescriptor { guard let script = NSAppleScript(source: source) else { throw AutomationError.invalidScript @@ -406,6 +581,17 @@ actor MediaAutomationService { return try execute(script) } + /// Runs an already compiled script. + /// + /// `NSAppleScript` signals failure through an out-parameter dictionary + /// rather than by returning nil, so the presence of that dictionary - not + /// the result - is what decides whether the call succeeded. + /// + /// - Parameter script: Compiled script to run. + /// - Returns: The script's result descriptor. + /// - Throws: ``AutomationError/appleScript(number:message:)`` carrying the + /// OSA error number, which the caller needs to recognise a permission + /// denial. private func execute(_ script: NSAppleScript) throws -> NSAppleEventDescriptor { var details: NSDictionary? let result = script.executeAndReturnError(&details) @@ -419,6 +605,15 @@ actor MediaAutomationService { return result } + /// Turns any automation failure into a message worth showing a user. + /// + /// The case that matters is OSA error -1743, macOS refusing automation + /// access. It is the one failure the user can actually fix, and the raw + /// message does not say how, so it is matched specifically and answered + /// with the path through System Settings. + /// + /// - Parameter error: Error from any of the automation paths. + /// - Returns: A Korean message for the panel's error line. nonisolated static func userFacingMessage(for error: Error) -> String { if let bridgeError = error as? YouTubeMusicBridgeProtocolError { return bridgeError.localizedDescription @@ -443,10 +638,24 @@ actor MediaAutomationService { } } +/// Failures from the AppleScript control path. enum AutomationError: LocalizedError { + /// The player's app is not open. case playerNotRunning(MediaPlayerKind) + + /// The script source could not be compiled. case invalidScript + + /// The script ran and failed. + /// + /// The number is the OSA error code; -1743 means automation permission was + /// denied, which is the only value any caller inspects. case appleScript(number: Int, message: String) + + /// A seek was sent but the player never reported the new position. + /// + /// Carries the observed position, if one was ever read, to distinguish a + /// player that ignored the seek from one that never answered at all. case seekNotConfirmed( requested: TimeInterval, observed: TimeInterval? diff --git a/src/Reprise.Platform.MacOS/Reprise/MediaPlayer.swift b/src/Reprise.Platform.MacOS/Reprise/MediaPlayer.swift index 8789e39..5237350 100644 --- a/src/Reprise.Platform.MacOS/Reprise/MediaPlayer.swift +++ b/src/Reprise.Platform.MacOS/Reprise/MediaPlayer.swift @@ -7,13 +7,25 @@ import Foundation +/// One of the music sources Reprise can follow and control. +/// +/// The raw values are persisted in preferences, so renaming a case would +/// silently reset a user's player priority order. Every case is `nonisolated` +/// because the model is read from the automation actors as well as the UI. enum MediaPlayerKind: String, CaseIterable, Identifiable, Sendable { + /// The Spotify desktop app, driven through AppleScript. case spotify + + /// The Music app bundled with macOS, driven through AppleScript. case appleMusic + + /// YouTube Music in a browser, reached through the Reprise extension. case youtubeMusic + /// Stable identity for SwiftUI lists, backed by the persisted raw value. nonisolated var id: String { rawValue } + /// The player's name as it should appear in the UI. nonisolated var displayName: String { switch self { case .spotify: "Spotify" @@ -22,6 +34,11 @@ enum MediaPlayerKind: String, CaseIterable, Identifiable, Sendable { } } + /// Bundle identifier to target when sending AppleScript commands. + /// + /// `nil` for YouTube Music, which has no app of its own: it is reached + /// over the extension bridge instead. That `nil` is what distinguishes + /// the two control paths throughout the app. nonisolated var automationBundleIdentifier: String? { switch self { case .spotify: "com.spotify.client" @@ -30,6 +47,7 @@ enum MediaPlayerKind: String, CaseIterable, Identifiable, Sendable { } } + /// SF Symbol used wherever the player needs an icon. nonisolated var symbolName: String { switch self { case .spotify: "waveform.circle.fill" @@ -39,23 +57,65 @@ enum MediaPlayerKind: String, CaseIterable, Identifiable, Sendable { } } +/// Transport state of a player. +/// +/// `unavailable` covers a player that is not running or cannot be reached, +/// which is distinct from `stopped`: a stopped player is present and could +/// resume, while an unavailable one has nothing to command. enum PlaybackState: String, Sendable { + /// A track is advancing. case playing + + /// A track is loaded and its position is frozen. case paused + + /// The player is idle with no active track. case stopped + + /// The player is not running, or could not be reached. case unavailable + /// Whether playback is currently advancing. nonisolated var isPlaying: Bool { self == .playing } } +/// The track a player currently has loaded. +/// +/// Values are captured at one moment; `position` in particular goes stale +/// immediately, which is why `PlaybackPosition.estimated(...)` exists to +/// advance it between polls rather than polling at frame rate. struct Track: Equatable, Sendable { + /// Track title. let title: String + + /// Album name. let album: String + + /// Artist credit. let artist: String + + /// Total track length in seconds, or 0 when the player did not report one. let duration: TimeInterval + + /// Elapsed playback time in seconds, as of when this was captured. let position: TimeInterval + + /// Encoded cover art, or `nil` when the player supplied none. let artworkData: Data? + /// Creates a track description. + /// + /// The timing and artwork parameters default to empty so that callers + /// which only know the text metadata - the demo content and several + /// tests - can build a track without inventing placeholder timings. + /// + /// - Parameters: + /// - title: Track title. + /// - album: Album name. + /// - artist: Artist credit. + /// - duration: Total length in seconds. Defaults to 0, meaning unknown. + /// - position: Elapsed time in seconds. Defaults to 0. + /// - artworkData: Encoded cover art. Defaults to `nil`. nonisolated init( title: String, album: String, @@ -72,19 +132,50 @@ struct Track: Equatable, Sendable { self.artworkData = artworkData } + /// Fraction of the track already played, for the progress bar. + /// + /// A track with no reported duration reads as 0 rather than dividing by + /// zero, and the result is clamped because players briefly report a + /// position past the end while advancing to the next track. + /// + /// - Returns: Progress from 0 to 1 inclusive. nonisolated var progress: Double { guard duration > 0 else { return 0 } return min(max(position / duration, 0), 1) } + /// Time left in the track, never negative. nonisolated var remaining: TimeInterval { max(duration - position, 0) } } +/// Arithmetic for playback positions that outside data cannot be trusted for. +/// +/// Players report positions that are negative, past the end, or not finite at +/// all, and a seek is confirmed by polling back a value that only approximates +/// what was asked for. Keeping those rules here means the UI and the +/// automation layer cannot disagree about them. enum PlaybackPosition { + /// How far a polled position may sit from the requested one and still + /// count as the seek having landed. + /// + /// Sized for the round trip through AppleScript: a player that accepted + /// the seek will already have advanced slightly by the time its position + /// is read back, so an exact comparison would never confirm. nonisolated static let seekConfirmationTolerance: TimeInterval = 1.5 + /// Forces a position into the valid range for a track. + /// + /// Rejects non-finite inputs and unknown durations outright, since a NaN + /// reaching a SwiftUI layout value would break the whole panel rather + /// than just the progress bar. + /// + /// - Parameters: + /// - position: Position to constrain, in seconds. + /// - duration: Track length in seconds. + /// - Returns: A position from 0 to `duration`, or 0 when either input is + /// not a usable finite number. nonisolated static func clamped( _ position: TimeInterval, duration: TimeInterval @@ -97,6 +188,13 @@ enum PlaybackPosition { return min(max(position, 0), duration) } + /// Decides whether a polled position means a requested seek took effect. + /// + /// - Parameters: + /// - actual: Position read back from the player. + /// - target: Position that was requested. + /// - Returns: `true` when the two agree within + /// ``seekConfirmationTolerance``; `false` if either value is not finite. nonisolated static func confirmsSeek( actual: TimeInterval, target: TimeInterval @@ -108,6 +206,34 @@ enum PlaybackPosition { return abs(actual - target) <= seekConfirmationTolerance } + /// Projects a polled position forward to the current instant. + /// + /// Polling a player often enough for a smooth progress bar would be far + /// more expensive than advancing the last known position by the elapsed + /// wall-clock time, so the UI ticks on this and the poll only corrects it. + /// Time is only added while playing; a paused track keeps its position. + /// + /// - Parameters: + /// - observedPosition: Position reported by the last poll, in seconds. + /// - state: Playback state at that poll. + /// - observedAt: When that poll happened. + /// - date: Instant to project to, normally now. + /// - duration: Track length in seconds, used to clamp the result. + /// - playbackRate: Speed multiplier. Defaults to 1; negative rates are + /// treated as 0 rather than rewinding the estimate. + /// - Returns: The estimated current position, clamped to the track. + /// + /// ## Example + /// ```swift + /// // Playing, polled 2s ago at 0:10, so roughly 0:12 now. + /// PlaybackPosition.estimated( + /// observedPosition: 10, + /// state: .playing, + /// observedAt: now.addingTimeInterval(-2), + /// at: now, + /// duration: 180 + /// ) // 12 + /// ``` nonisolated static func estimated( observedPosition: TimeInterval, state: PlaybackState, @@ -127,15 +253,48 @@ enum PlaybackPosition { } } +/// Everything Reprise knows about one player after a single poll. +/// +/// Carries `isRunning` and `errorMessage` alongside the playback data so the +/// UI can tell apart the three ways a player yields no track: it is not +/// running, it is running but idle, or the attempt to reach it failed. struct PlayerSnapshot: Equatable, Sendable { + /// Which player this describes. let player: MediaPlayerKind + + /// Whether the player's app or bridge is currently available. let isRunning: Bool + + /// Transport state at the moment of the poll. let state: PlaybackState + + /// The loaded track, or `nil` when there is none. let track: Track? + + /// Player volume from 0 to 100, or `nil` when it does not report one. let volume: Int? + + /// Playback speed multiplier, normalised to a finite non-negative value. let playbackRate: Double + + /// Why the poll failed, or `nil` when it succeeded. let errorMessage: String? + /// Creates a snapshot, normalising the playback rate. + /// + /// The rate is sanitised at the boundary rather than at each use, because + /// it feeds position estimation where a NaN or negative value would + /// corrupt every later reading rather than just this one. + /// + /// - Parameters: + /// - player: Which player this describes. + /// - isRunning: Whether the player is available. + /// - state: Transport state. + /// - track: The loaded track, if any. + /// - volume: Volume from 0 to 100. Defaults to `nil`. + /// - playbackRate: Speed multiplier. Defaults to 1; non-finite values + /// fall back to 1 and negative values are raised to 0. + /// - errorMessage: Failure description, or `nil`. nonisolated init( player: MediaPlayerKind, isRunning: Bool, @@ -156,6 +315,13 @@ struct PlayerSnapshot: Equatable, Sendable { self.errorMessage = errorMessage } + /// Builds the snapshot for a player that is not running. + /// + /// Used on the common path where a player simply is not open, which is + /// not a failure and so carries no error message. + /// + /// - Parameter player: The player that is unavailable. + /// - Returns: An unavailable snapshot with no track and no error. nonisolated static func notRunning(_ player: MediaPlayerKind) -> PlayerSnapshot { PlayerSnapshot( player: player, @@ -166,19 +332,46 @@ struct PlayerSnapshot: Equatable, Sendable { ) } + /// Title to show in the menu bar, or `nil` when no track is loaded. var menuBarTitle: String? { track?.title } } +/// Volume rules shared by the slider, the mute button, and the players. enum PlayerVolume { + /// The scale every player's volume is expressed on. nonisolated static let range = 0...100 + + /// Level to restore to when unmuting with no remembered level. nonisolated static let defaultAudibleLevel = 50 + /// Forces a volume into the supported range. + /// + /// - Parameter value: Volume to constrain. + /// - Returns: A value within ``range``. nonisolated static func clamped(_ value: Int) -> Int { min(max(value, range.lowerBound), range.upperBound) } + /// Works out what a mute toggle should set the volume to. + /// + /// Muting is a jump to 0; unmuting restores the level from before the + /// mute. A remembered level of 0 is ignored in favour of + /// ``defaultAudibleLevel``, so unmuting always produces audible sound + /// rather than appearing to do nothing. + /// + /// - Parameters: + /// - current: Volume the player is at now. + /// - lastAudible: Level from before the mute, if one was remembered. + /// - Returns: 0 when currently audible, otherwise the level to restore. + /// + /// ## Example + /// ```swift + /// PlayerVolume.muteToggleTarget(current: 70, lastAudible: nil) // 0 + /// PlayerVolume.muteToggleTarget(current: 0, lastAudible: 70) // 70 + /// PlayerVolume.muteToggleTarget(current: 0, lastAudible: 0) // 50 + /// ``` nonisolated static func muteToggleTarget( current: Int, lastAudible: Int? @@ -193,10 +386,20 @@ enum PlayerVolume { } } +/// Transport command Reprise can send to a player. enum PlaybackCommand: Sendable { + /// Skip back to the previous track. case previous + + /// Pause without toggling. case pause + + /// Toggle between playing and paused. case playPause + + /// Stop playback and release the track. case stop + + /// Skip forward to the next track. case next } diff --git a/src/Reprise.Platform.MacOS/Reprise/NowPlayingStore.swift b/src/Reprise.Platform.MacOS/Reprise/NowPlayingStore.swift index 75cadab..d5fb0a7 100644 --- a/src/Reprise.Platform.MacOS/Reprise/NowPlayingStore.swift +++ b/src/Reprise.Platform.MacOS/Reprise/NowPlayingStore.swift @@ -8,16 +8,50 @@ import Foundation import Observation +/// The app's single source of truth for what is playing. +/// +/// Polls every player twice a second, decides which one the UI should follow, +/// fetches lyrics for it, and applies the user's commands. The panel, the menu +/// bar item, and the settings pane all read from here. +/// +/// Two problems shape most of this type. The first is that a command and a +/// poll race: the poll is already in flight when the user drags the volume +/// slider, and its stale answer would arrive afterwards and undo the change. +/// That is handled by ``stateMutationRevision`` and the per-operation ids, +/// which let a mutation invalidate any poll that overlapped it. +/// +/// The second is that polling at the rate a progress bar needs would be +/// prohibitively expensive, so playback position is anchored at each poll and +/// projected forward from the clock in between. @MainActor @Observable final class NowPlayingStore { + /// Latest Spotify state. private(set) var spotify = PlayerSnapshot.notRunning(.spotify) + + /// Latest Music state. private(set) var appleMusic = PlayerSnapshot.notRunning(.appleMusic) + + /// Latest YouTube Music state. private(set) var youtubeMusic = PlayerSnapshot.notRunning(.youtubeMusic) + + /// Whether a poll is in flight, so overlapping ones can be skipped. private(set) var isRefreshing = false + + /// Message from the last failed command, or `nil`. private(set) var commandError: String? + + /// Progress of the lyrics lookup for the current track. private(set) var lyricsState = LyricsLoadState.idle + + /// The lyric line showing right now, or `nil` when there is none. private(set) var displayedLyricText: String? + + /// Called when something the menu bar item draws has changed. + /// + /// A callback rather than observation because the status item is AppKit, + /// which has no way to observe an `@Observable`. Excluded from observation + /// so assigning it does not itself invalidate SwiftUI views. @ObservationIgnored var onMenuBarContentChange: (() -> Void)? @@ -35,6 +69,15 @@ final class NowPlayingStore { private var volumeOperationIDs: [MediaPlayerKind: UUID] = [:] private var seekOperationIDs: [MediaPlayerKind: UUID] = [:] + /// Creates the store. + /// + /// Nothing starts polling until ``start()``, so building a store is cheap + /// and safe in previews. + /// + /// - Parameters: + /// - lyricsService: Service to fetch lyrics with. Injectable for tests. + /// - demoMode: Whether to present fabricated content. Defaults to + /// whatever the launch environment asks for. init( lyricsService: LyricsService = LyricsService(), demoMode: Bool = RepriseDemoMode.isEnabled() @@ -47,11 +90,16 @@ final class NowPlayingStore { } } + /// The snapshot the panel should display. + /// + /// Falls back to the highest-priority player when nothing has a track, so + /// the panel always has something to render rather than going blank. var activeSnapshot: PlayerSnapshot { menuBarSnapshot ?? snapshot(for: playerDisplayOrder.first ?? .spotify) } + /// The snapshot the menu bar should follow, or `nil` when none qualifies. var menuBarSnapshot: PlayerSnapshot? { Self.preferredSnapshot( snapshots: snapshotsByPlayer, @@ -60,6 +108,7 @@ final class NowPlayingStore { ) } + /// The three snapshots keyed by player, for the selection helpers. private var snapshotsByPlayer: [MediaPlayerKind: PlayerSnapshot] { [ .spotify: spotify, @@ -68,14 +117,23 @@ final class NowPlayingStore { ] } + /// The user's player priority order. + /// + /// Read fresh each time rather than cached, so a change in settings takes + /// effect on the next poll with no notification to wire up. private var playerDisplayOrder: [MediaPlayerKind] { ReprisePreferences.playerDisplayOrder() } + /// Whether starting one player should pause the previous one. private var automaticallyPausesOtherPlayer: Bool { ReprisePreferences.automaticallyPausesOtherPlayer() } + /// The remembered player, when that setting is on. + /// + /// Returns `nil` when the setting is off even if a player is stored, so + /// callers do not have to check the setting themselves. private var rememberedPlayer: MediaPlayerKind? { guard ReprisePreferences.remembersLastPlayedPlayer() else { return nil @@ -83,14 +141,20 @@ final class NowPlayingStore { return ReprisePreferences.lastPlayedPlayer() } + /// Title for the menu bar, falling back to the app name. var menuBarTitle: String { menuBarSnapshot?.menuBarTitle ?? "Reprise" } + /// SF Symbol for the menu bar, falling back to a generic note. var menuBarSymbol: String { menuBarSnapshot?.player.symbolName ?? "music.note" } + /// VoiceOver description of the menu bar item. + /// + /// Names the player as well as the track, since the icon conveys which one + /// is playing to a sighted user and would otherwise be lost. var menuBarAccessibilityLabel: String { guard let snapshot = menuBarSnapshot, let track = snapshot.track else { return "Reprise, 재생 중인 음악 없음" @@ -98,10 +162,19 @@ final class NowPlayingStore { return "\(snapshot.player.displayName), \(track.title)" } + /// Lyrics for the current track, when they have been found. var syncedLyrics: SyncedLyrics? { lyricsState.lyrics } + /// Playback position projected to a given instant. + /// + /// The anchor is only trusted when it belongs to the track now playing; + /// otherwise the snapshot's own position is used, so a track change cannot + /// briefly show the previous track's progress advancing. + /// + /// - Parameter date: Instant to project to. Defaults to now. + /// - Returns: The estimated position in seconds, clamped to the track. func estimatedPlaybackPosition( at date: Date = Date() ) -> TimeInterval { @@ -128,6 +201,11 @@ final class NowPlayingStore { ) } + /// The lyric line to highlight at a given instant. + /// + /// - Parameter date: Instant to evaluate. Defaults to now. + /// - Returns: The focused line, or `nil` when there are no lyrics or + /// playback has not reached the first line. func currentLyricLine( at date: Date = Date() ) -> LyricLine? { @@ -140,6 +218,10 @@ final class NowPlayingStore { return syncedLyrics.lines[index] } + /// Index of the lyric line to highlight at a given instant. + /// + /// - Parameter date: Instant to evaluate. Defaults to now. + /// - Returns: The index, or `nil` when no line is focused. func currentLyricLineIndex( at date: Date = Date() ) -> Int? { @@ -148,12 +230,32 @@ final class NowPlayingStore { ) } + /// Index of the lyric line the scrolling view should centre on. + /// + /// Currently identical to ``currentLyricLineIndex(at:)``; kept as its own + /// entry point because the scroll view's idea of focus is a presentation + /// concern that may diverge from which line is strictly current. + /// + /// - Parameter date: Instant to evaluate. Defaults to now. + /// - Returns: The index, or `nil`. func focusedLyricLineIndex( at date: Date = Date() ) -> Int? { currentLyricLineIndex(at: date) } + /// Begins polling players and advancing the displayed lyric. + /// + /// Two loops, deliberately at different rates. Lyrics advance every 100ms + /// because a late line is immediately noticeable against the music, and it + /// costs only arithmetic against the anchor. Players are polled every + /// 500ms because each poll means AppleScript round trips. + /// + /// Demo mode runs the lyric loop but never polls, so it neither reads nor + /// touches the user's real players. + /// + /// Guarded so calling twice is harmless, which matters because the panel + /// can appear more than once over the app's life. func start() { guard pollingTask == nil, lyricsDisplayTask == nil else { return } @@ -177,6 +279,20 @@ final class NowPlayingStore { } } + /// Polls every player once and republishes the results. + /// + /// The revision check is what keeps a command from being undone: it is + /// captured before the poll and compared after, so if the user changed + /// something while the poll was in flight, its now-stale results are + /// discarded entirely. + /// + /// Results that survive that check still pass through + /// ``mergingPendingMutations(_:for:)``, which preserves the values of + /// operations that are still settling. + /// + /// The first poll is exempt from automatic pausing: at launch every + /// running player looks newly playing, and acting on that would pause + /// music the user started before Reprise did. func refresh() async { guard !isDemoMode else { updateDisplayedLyric() @@ -249,6 +365,13 @@ final class NowPlayingStore { } } + /// Sends a transport command to whichever player is on display. + /// + /// The brief sleep before refreshing lets the player settle: read + /// immediately, Spotify and Music often still report the previous track, + /// which would flash the old title in the panel. + /// + /// - Parameter command: Transport control to invoke. func perform(_ command: PlaybackCommand) async { commandError = nil if isDemoMode { @@ -267,6 +390,21 @@ final class NowPlayingStore { } } + /// Sets a player's volume. + /// + /// The new value is applied locally first so the slider tracks the + /// pointer, then reconciled with what the player actually took. On failure + /// the previous value is restored rather than left showing a change that + /// never happened. + /// + /// The operation id guards against overlapping drags: a slider produces + /// many calls in quick succession, and without it an earlier, slower one + /// could land after a later one and snap the volume backwards. Only the + /// most recent operation is allowed to write. + /// + /// - Parameters: + /// - volume: Desired level from 0 to 100. + /// - player: Player to adjust. func setVolume(_ volume: Int, for player: MediaPlayerKind) async { commandError = nil if isDemoMode { @@ -320,6 +458,20 @@ final class NowPlayingStore { } } + /// Seeks a player to a position. + /// + /// Applied locally first so the progress bar follows the drag, then + /// reconciled against the position the player confirms. + /// + /// Beyond the operation id, every write is gated on the track still being + /// the one that was seeked. A seek can take a second to confirm, which is + /// long enough for the track to change - and writing a position from the + /// previous track into the new one would put the progress bar and the + /// lyrics somewhere arbitrary. + /// + /// - Parameters: + /// - position: Target position in seconds. + /// - player: Player to seek. func seek( to position: TimeInterval, for player: MediaPlayerKind @@ -412,10 +564,23 @@ final class NowPlayingStore { } } + /// Starts a lyrics lookup for the current track if one is not under way. + /// + /// Called when the panel opens, since lyrics are only fetched when the + /// track changes and a user opening the panel mid-track would otherwise + /// see nothing until the next one. + /// + /// Passes no observation date, which leaves the playback anchor alone: the + /// anchor is still valid, and resetting it here would jump the progress + /// bar back to the last polled position. func ensureLyricsForActiveTrack() { synchronizeLyricsWithActiveTrack(observedAt: nil) } + /// The stored snapshot for one player. + /// + /// - Parameter player: Player to read. + /// - Returns: Its most recent snapshot. func snapshot(for player: MediaPlayerKind) -> PlayerSnapshot { switch player { case .spotify: spotify @@ -424,6 +589,25 @@ final class NowPlayingStore { } } + /// Chooses which player the UI should follow. + /// + /// Four passes, in order. A remembered player that is playing wins + /// outright, which is what makes that setting mean anything. Otherwise the + /// highest-priority player that is playing takes it. Failing that the same + /// two passes repeat against players that merely have a track loaded, so + /// the panel shows a paused track rather than nothing. + /// + /// Players absent from the display order are appended, so a stored order + /// written before a player existed still yields a complete ranking. + /// + /// Static and parameterised so the selection rules can be tested without a + /// store, real players, or preferences. + /// + /// - Parameters: + /// - snapshots: Current state of every player. + /// - displayOrder: The user's priority order. + /// - rememberedPlayer: Player to favour, or `nil`. Defaults to `nil`. + /// - Returns: The snapshot to follow, or `nil` when no player has a track. static func preferredSnapshot( snapshots: [MediaPlayerKind: PlayerSnapshot], displayOrder: [MediaPlayerKind], @@ -457,6 +641,17 @@ final class NowPlayingStore { .first(where: { $0.track != nil }) } + /// Two-player convenience over + /// ``preferredSnapshot(snapshots:displayOrder:rememberedPlayer:)``. + /// + /// Predates YouTube Music support and is kept for the tests written + /// against it; it fills YouTube Music in as not running. + /// + /// - Parameters: + /// - spotify: Spotify state. + /// - appleMusic: Music state. + /// - displayOrder: The user's priority order. + /// - Returns: The snapshot to follow, or `nil`. static func preferredSnapshot( spotify: PlayerSnapshot, appleMusic: PlayerSnapshot, @@ -472,6 +667,22 @@ final class NowPlayingStore { ) } + /// Decides which player to record as last played. + /// + /// A player that has just started outranks one that was already going, + /// since starting something is the clearest statement of what the user + /// wants to hear. + /// + /// Returns `nil` when the remembered player is still among those playing, + /// so a second player starting alongside it does not quietly steal the + /// memory from the one the user chose. + /// + /// - Parameters: + /// - previousSnapshots: State before this poll. + /// - currentSnapshots: State after this poll. + /// - displayOrder: The user's priority order. + /// - rememberedPlayer: Currently remembered player, if any. + /// - Returns: The player to remember, or `nil` to leave it unchanged. static func playerToRemember( previousSnapshots: [MediaPlayerKind: PlayerSnapshot], currentSnapshots: [MediaPlayerKind: PlayerSnapshot], @@ -499,6 +710,20 @@ final class NowPlayingStore { return playingPlayers.first } + /// Decides which players should be paused because another just started. + /// + /// Only fires on a transition into playing, and only pauses players that + /// were already playing before and still are. Both halves matter: without + /// the transition check every poll would re-pause, and without requiring + /// the other player to have been playing beforehand, two players starting + /// in the same poll would each try to pause the other. + /// + /// - Parameters: + /// - automaticPauseEnabled: Whether the setting is on. + /// - previousSnapshots: State before this poll. + /// - currentSnapshots: State after this poll. + /// - Returns: Players to pause, without duplicates. Empty when the setting + /// is off or nothing started. static func playersToPause( automaticPauseEnabled: Bool, previousSnapshots: [MediaPlayerKind: PlayerSnapshot], @@ -527,6 +752,19 @@ final class NowPlayingStore { return result } + /// Whether a change affects anything the menu bar draws. + /// + /// Deliberately ignores position and volume, which change on every poll + /// and are not shown in the menu bar. Redrawing on those would rebuild the + /// status item constantly and restart its marquee mid-scroll. + /// + /// Artwork is compared only on presence, since the bytes change with every + /// track but the layout only changes when a cover appears or disappears. + /// + /// - Parameters: + /// - previous: State before this poll. + /// - current: State after this poll. + /// - Returns: `true` when the menu bar item needs rebuilding. private static func menuBarContentChanged( from previous: PlayerSnapshot, to current: PlayerSnapshot @@ -541,6 +779,14 @@ final class NowPlayingStore { != (current.track?.artworkData == nil) } + /// Single-player convenience over + /// ``playersToPause(automaticPauseEnabled:previousSnapshots:currentSnapshots:)``. + /// + /// - Parameters: + /// - automaticPauseEnabled: Whether the setting is on. + /// - previousSnapshots: State before this poll. + /// - currentSnapshots: State after this poll. + /// - Returns: The first player to pause, or `nil`. static func playerToPause( automaticPauseEnabled: Bool, previousSnapshots: [MediaPlayerKind: PlayerSnapshot], @@ -553,6 +799,19 @@ final class NowPlayingStore { ).first } + /// Two-player convenience over + /// ``playerToPause(automaticPauseEnabled:previousSnapshots:currentSnapshots:)``. + /// + /// Predates YouTube Music support and is kept for the tests written + /// against it. + /// + /// - Parameters: + /// - automaticPauseEnabled: Whether the setting is on. + /// - previousSpotify: Spotify state before this poll. + /// - previousAppleMusic: Music state before this poll. + /// - currentSpotify: Spotify state after this poll. + /// - currentAppleMusic: Music state after this poll. + /// - Returns: The player to pause, or `nil`. static func playerToPause( automaticPauseEnabled: Bool, previousSpotify: PlayerSnapshot, @@ -577,6 +836,13 @@ final class NowPlayingStore { ) } + /// Pauses a player on the app's initiative, not the user's. + /// + /// The local state is updated immediately rather than waiting for the next + /// poll, so the panel does not show two players playing at once for the + /// half-second in between. + /// + /// - Parameter player: Player to pause. private func pauseAutomatically( _ player: MediaPlayerKind ) async { @@ -595,6 +861,14 @@ final class NowPlayingStore { } } + /// Records the last played player when that setting is on. + /// + /// Writes only on an actual change, since this runs on every poll and + /// `UserDefaults` would otherwise be written twice a second. + /// + /// - Parameters: + /// - previousSnapshots: State before this poll. + /// - currentSnapshots: State after this poll. private func rememberLastPlayedPlayerIfNeeded( previousSnapshots: [MediaPlayerKind: PlayerSnapshot], currentSnapshots: [MediaPlayerKind: PlayerSnapshot] @@ -612,6 +886,11 @@ final class NowPlayingStore { ReprisePreferences.setLastPlayedPlayer(player) } + /// Stores a snapshot into the property for its player. + /// + /// - Parameters: + /// - snapshot: New state. + /// - player: Player it belongs to. private func updateSnapshot( _ snapshot: PlayerSnapshot, for player: MediaPlayerKind @@ -626,6 +905,11 @@ final class NowPlayingStore { } } + /// Seeds the store with the fabricated demo track. + /// + /// Marks the initial refresh complete so the automatic-pause logic stays + /// dormant, and installs the lyrics directly rather than fetching them, so + /// demo mode makes no network requests. private func installDemoContent() { youtubeMusic = RepriseDemoContent.snapshot() lyricsState = .available(RepriseDemoContent.lyrics) @@ -635,6 +919,11 @@ final class NowPlayingStore { updateDisplayedLyric() } + /// Re-anchors demo playback at the current fabricated position. + /// + /// The demo snapshot carries a playback rate of 0, so the anchor holds the + /// position exactly where it was put - which is what keeps a screenshot + /// reproducible however long the app has been open. private func installDemoPlaybackAnchor() { guard let track = youtubeMusic.track else { playbackAnchor = nil @@ -650,6 +939,14 @@ final class NowPlayingStore { ) } + /// Applies a transport command to the fabricated demo state. + /// + /// Previous and next jump to fixed positions rather than changing track, + /// since demo mode has only one. The positions are chosen to land on + /// different lyric lines, so a screenshot of the controls shows the lyric + /// view responding. + /// + /// - Parameter command: Transport control to simulate. private func performDemoCommand(_ command: PlaybackCommand) { let current = youtubeMusic let state: PlaybackState @@ -680,6 +977,21 @@ final class NowPlayingStore { onMenuBarContentChange?() } + /// Keeps in-flight command values from being overwritten by a poll. + /// + /// A poll that began before a command still returns the pre-command state. + /// Where an operation is outstanding, the local value is carried into the + /// incoming snapshot so the slider or progress bar does not visibly snap + /// back and then forward again. + /// + /// Position is only preserved when the track has not changed: holding a + /// stale position across a track change would be worse than accepting the + /// poll's value. + /// + /// - Parameters: + /// - incomingSnapshot: Snapshot from the poll. + /// - player: Player it describes. + /// - Returns: The snapshot with pending values preserved. private func mergingPendingMutations( _ incomingSnapshot: PlayerSnapshot, for player: MediaPlayerKind @@ -705,6 +1017,20 @@ final class NowPlayingStore { return mergedSnapshot } + /// Re-anchors playback and fetches lyrics when the track changes. + /// + /// Runs on every poll, so it is written to do nothing in the common case + /// where the track has not changed - the lyrics lookup is gated on the + /// query differing. + /// + /// Results are cached by track, which matters because the same track comes + /// back on repeat or when switching between players, and re-fetching would + /// mean a needless round trip and a visible blank while it loads. + /// + /// - Parameter observedAt: When the position was observed, which re-anchors + /// the estimate. Pass `nil` to leave an existing anchor for the same + /// track alone, which is what callers not reporting a fresh position - + /// the panel opening - want. private func synchronizeLyricsWithActiveTrack( observedAt: Date? ) { @@ -758,6 +1084,13 @@ final class NowPlayingStore { } } + /// Recomputes the current lyric line and notifies when it changes. + /// + /// Called ten times a second, so it returns early unless the line actually + /// changed - roughly once every few seconds. Without that check the menu + /// bar item would be rebuilt on every tick. + /// + /// - Parameter date: Instant to evaluate. Defaults to now. private func updateDisplayedLyric( at date: Date = Date() ) { @@ -772,19 +1105,47 @@ final class NowPlayingStore { } } +/// A playback position with enough context to project it forward. +/// +/// The query is what ties it to a specific track, so a stale anchor is +/// recognised rather than applied to whatever is playing now. private struct PlaybackAnchor { + /// Track this anchor belongs to. let query: LyricsTrackQuery + + /// Position observed at ``observedAt``, in seconds. let position: TimeInterval + + /// Track length in seconds, for clamping the estimate. let duration: TimeInterval + + /// Playback state at the observation, which decides whether time advances. let state: PlaybackState + + /// Speed multiplier to project at. let playbackRate: Double + + /// When the position was observed. let observedAt: Date } +/// A settled lyrics lookup, cached by track. +/// +/// Distinct from ``LyricsLoadState`` because only the two final outcomes are +/// worth caching: `idle` and `loading` describe a lookup in progress, and +/// storing either would let a later read conclude the answer was already +/// known. Caching `unavailable` is the point - it stops a track with no lyrics +/// being looked up again every time it comes round. private enum LyricsCacheEntry { + /// Lyrics were found. case available(SyncedLyrics) + + /// Neither service had lyrics for this track. case unavailable + /// Wraps a lookup result. + /// + /// - Parameter lyrics: The lyrics found, or `nil`. init(lyrics: SyncedLyrics?) { if let lyrics { self = .available(lyrics) @@ -793,6 +1154,7 @@ private enum LyricsCacheEntry { } } + /// The published state this entry corresponds to. var state: LyricsLoadState { switch self { case let .available(lyrics): @@ -804,6 +1166,10 @@ private enum LyricsCacheEntry { } private extension PlayerSnapshot { + /// A copy with a different playback state. + /// + /// - Parameter state: New transport state. + /// - Returns: The updated snapshot. func withPlaybackState( _ state: PlaybackState ) -> PlayerSnapshot { @@ -818,6 +1184,10 @@ private extension PlayerSnapshot { ) } + /// A copy with a different volume. + /// + /// - Parameter volume: New level. + /// - Returns: The updated snapshot. func withVolume(_ volume: Int) -> PlayerSnapshot { PlayerSnapshot( player: player, @@ -830,6 +1200,13 @@ private extension PlayerSnapshot { ) } + /// A copy with a different playback position. + /// + /// Rebuilds the track because ``Track`` is immutable. A snapshot with no + /// track passes through unchanged, since there is nothing to position. + /// + /// - Parameter position: New position in seconds. + /// - Returns: The updated snapshot. func withPosition(_ position: TimeInterval) -> PlayerSnapshot { let updatedTrack = track.map { Track( @@ -855,6 +1232,10 @@ private extension PlayerSnapshot { } extension Notification.Name { + /// Posted when the lyric line on display changes. + /// + /// Lets the AppKit menu bar item update its lyric text, which cannot + /// observe the store's `@Observable` state. static let displayedLyricDidChange = Notification.Name( "dev.junx.Reprise.displayedLyricDidChange" ) diff --git a/src/Reprise.Platform.MacOS/Reprise/PanelTitleMarqueeView.swift b/src/Reprise.Platform.MacOS/Reprise/PanelTitleMarqueeView.swift index c6ca6da..e3a509a 100644 --- a/src/Reprise.Platform.MacOS/Reprise/PanelTitleMarqueeView.swift +++ b/src/Reprise.Platform.MacOS/Reprise/PanelTitleMarqueeView.swift @@ -9,16 +9,38 @@ import AppKit import CoreText import SwiftUI +/// Scrolling track title for the player panel. +/// +/// Bridges to AppKit because SwiftUI has no way to express what this needs: +/// a Core Animation keyframe track that steps in whole device pixels, so text +/// stays crisp while it moves. A SwiftUI `offset` animates on a continuous +/// timeline and lands on fractional pixels, which makes glyphs shimmer. struct PanelTitleMarqueeView: NSViewRepresentable { + /// Text to display. let title: String + + /// Whether an over-long title scrolls on its own, or only on hover. let automaticallyScrolls: Bool + + /// Scroll speed in points per second. let pointsPerSecond: CGFloat + + /// Text colour, supplied by the panel's theme. let foregroundColor: NSColor + /// Creates the backing AppKit view. + /// + /// - Parameter context: Representable context; unused. + /// - Returns: An empty marquee view, configured by the first update. func makeNSView(context: Context) -> PanelTitleMarqueeNSView { PanelTitleMarqueeNSView() } + /// Pushes the current values into the AppKit view. + /// + /// - Parameters: + /// - nsView: View to update. + /// - context: Representable context; unused. func updateNSView( _ nsView: PanelTitleMarqueeNSView, context: Context @@ -32,6 +54,16 @@ struct PanelTitleMarqueeView: NSViewRepresentable { } } +/// AppKit view that renders and scrolls the panel's track title. +/// +/// The title is rasterised once into a bitmap and shown twice, side by side, +/// inside a layer that translates leftwards. When the first copy scrolls out, +/// the second is exactly where the first began, so the loop is seamless +/// without re-rendering anything per frame. +/// +/// Everything is redrawn only when something that affects the rendering +/// actually changes - text, size, backing scale, or colour - because +/// rasterising text is far too expensive to repeat on every layout pass. @MainActor final class PanelTitleMarqueeNSView: NSView { private var title = "" @@ -49,6 +81,13 @@ final class PanelTitleMarqueeNSView: NSView { private var isHovering = false private var titleTrackingArea: NSTrackingArea? + /// Builds the layer tree and subscribes to panel visibility. + /// + /// The panel notifications matter because a menu bar popover is not + /// deallocated when dismissed: without them the marquee would keep + /// animating off screen, burning CPU for nothing. + /// + /// - Parameter frameRect: Initial frame. override init(frame frameRect: NSRect) { super.init(frame: frameRect) @@ -75,20 +114,33 @@ final class PanelTitleMarqueeNSView: NSView { ) } + /// Unavailable; this view is never loaded from a nib. + /// + /// - Parameter coder: Unarchiver; unused. @available(*, unavailable) required init?(coder: NSCoder) { nil } + /// Unsubscribes from the panel visibility notifications. deinit { NotificationCenter.default.removeObserver(self) } + /// Re-renders when the view's geometry changes. + /// + /// Passes `force: false`, so a layout pass that did not change the size + /// costs a comparison rather than a re-rasterisation. override func layout() { super.layout() refresh(force: false) } + /// Rebuilds the hover tracking region. + /// + /// Uses `inVisibleRect`, which is why the rect passed in is `.zero`: + /// AppKit recomputes it from the visible bounds, keeping the region + /// correct as the panel resizes without needing an explicit rect here. override func updateTrackingAreas() { super.updateTrackingAreas() @@ -109,23 +161,48 @@ final class PanelTitleMarqueeNSView: NSView { titleTrackingArea = trackingArea } + /// Starts a hover-driven scroll. + /// + /// Ignored when the title already scrolls on its own, since there is + /// nothing for hovering to trigger. + /// + /// - Parameter event: Mouse event; unused. override func mouseEntered(with event: NSEvent) { guard !automaticallyScrolls else { return } isHovering = true refresh(force: true) } + /// Ends a hover-driven scroll and returns the title to its start. + /// + /// - Parameter event: Mouse event; unused. override func mouseExited(with event: NSEvent) { guard isHovering else { return } isHovering = false refresh(force: true) } + /// Re-renders when the system appearance changes. + /// + /// The title is a bitmap baked with a resolved colour, so a light-to-dark + /// switch would otherwise leave the old colour on screen. override func viewDidChangeEffectiveAppearance() { super.viewDidChangeEffectiveAppearance() refresh(force: true) } + /// Applies new content and appearance from SwiftUI. + /// + /// Returns early when nothing changed, because SwiftUI calls + /// `updateNSView` on every re-render of the surrounding panel - which + /// happens once a second as the position ticks - and re-rasterising the + /// title each time would restart the animation and stutter. + /// + /// - Parameters: + /// - title: Text to display. + /// - automaticallyScrolls: Whether to scroll without hovering. + /// - pointsPerSecond: Scroll speed. + /// - foregroundColor: Text colour. func update( title: String, automaticallyScrolls: Bool, @@ -146,17 +223,38 @@ final class PanelTitleMarqueeNSView: NSView { refresh(force: true) } + /// Restarts the marquee when the panel becomes visible. + /// + /// Forced, so the scroll begins from the start of the title each time the + /// panel is opened rather than resuming mid-word. @objc private func playerPanelDidShow() { refresh(force: true) } + /// Stops animating once the panel is hidden. + /// + /// Also clears the hover flag, since no exit event arrives for a pointer + /// that was over the title when the panel closed. @objc private func playerPanelDidHide() { isHovering = false stopAnimation() } + /// Rebuilds the rendered title and restarts the animation. + /// + /// The single path through which everything is updated. It bails out + /// early when nothing that affects rendering has changed, since it runs + /// on every layout pass. + /// + /// Scrolling only starts when the title genuinely overflows; a title that + /// fits is left static and its duplicate hidden, so no second copy shows + /// through the gap. + /// + /// - Parameter force: Re-render even when the inputs look unchanged. Used + /// for changes the cached comparison cannot see, such as a new colour or + /// an appearance switch. private func refresh(force: Bool) { guard bounds.width > 0, bounds.height > 0, !title.isEmpty else { stopAnimation() @@ -248,12 +346,22 @@ final class PanelTitleMarqueeNSView: NSView { ) } + /// Halts the marquee and returns the title to its starting position. private func stopAnimation() { scrollingLayer.removeAnimation(forKey: "marquee") scrollingLayer.setAffineTransform(.identity) secondTitleLayer.isHidden = false } + /// Measures how wide the title will draw. + /// + /// Uses Core Text's typographic bounds rather than an `NSAttributedString` + /// size, so the measurement matches exactly what ``titleBitmap(_:scale:foregroundColor:)`` + /// draws with the same line. Any disagreement would show as a seam in the + /// loop. + /// + /// - Parameter text: Text to measure. + /// - Returns: Width in points, rounded up. private static func textWidth(_ text: String) -> CGFloat { let attributedText = NSAttributedString( string: text, @@ -266,6 +374,21 @@ final class PanelTitleMarqueeNSView: NSView { return ceil(CGFloat(CTLineGetTypographicBounds(line, nil, nil, nil))) } + /// Works out where the title bitmap should sit vertically. + /// + /// Centres the visual weight of the glyphs rather than the bitmap: a text + /// bitmap includes ascender and descender space that most titles do not + /// fill, so centring the box leaves the letters looking high. Positioning + /// by cap height and descender instead makes the title look centred + /// whether or not it happens to contain a descending letter. + /// + /// The result is snapped to a device pixel, since a half-pixel origin + /// blurs the whole line. + /// + /// - Parameters: + /// - availableHeight: Height of the view in points. + /// - scale: Backing scale factor. + /// - Returns: The bitmap's y origin in points. private static func titleOriginY( availableHeight: CGFloat, scale: CGFloat @@ -276,6 +399,21 @@ final class PanelTitleMarqueeNSView: NSView { return (origin * scale).rounded() / scale } + /// Rasterises the title into a bitmap at the display's scale. + /// + /// Drawing once into an image and animating that costs far less than + /// letting Core Animation re-render text on every frame, which is what + /// keeps a continuously scrolling title cheap. + /// + /// The context is scaled and the baseline placed at the descent, so the + /// glyphs land inside the bitmap with their descenders intact. + /// + /// - Parameters: + /// - title: Text to draw. + /// - scale: Backing scale factor. + /// - foregroundColor: Text colour, resolved before drawing. + /// - Returns: The image and its point size, or `nil` if the context could + /// not be created. private static func titleBitmap( _ title: String, scale: CGFloat, @@ -328,19 +466,52 @@ final class PanelTitleMarqueeNSView: NSView { return (image, pointSize) } + /// Font the panel title is drawn in. private static let font = NSFont.systemFont( ofSize: NSFont.systemFontSize, weight: .semibold ) + + /// Extra tracking between characters; none, matching system text. private static let characterSpacing: CGFloat = 0 + + /// Blank space between the two copies of the title. + /// + /// Also the extra distance the animation travels, so the loop reads as a + /// pause between repetitions rather than the title running into itself. private static let titleGap: CGFloat = 24 + + /// How long an auto-scrolling title rests before moving. + /// + /// Long enough to read the beginning of the title before it starts. private static let initialPause: TimeInterval = 1.4 + + /// How long a hover-scrolling title rests before moving. + /// + /// Much shorter, because hovering is a deliberate request to see the rest + /// and a long wait would feel unresponsive. private static let hoverInitialPause: TimeInterval = 0.25 } +/// Soft fade at the trailing edge of a scrolling title. +/// +/// Shared by the panel and the menu bar so both taper text the same way, +/// rather than clipping it at a hard edge. enum MarqueeFade { + /// Width of the fade in points. static let width: CGFloat = 14 + /// Where the gradient begins, as a fraction of the viewport width. + /// + /// Expressed as a fraction because `CAGradientLayer` locations are + /// normalised. Clamped at 0, since a viewport narrower than the fade + /// would otherwise produce a negative location and fade the whole line + /// out. + /// + /// - Parameters: + /// - viewportWidth: Visible width in points. + /// - fadeWidth: Fade width in points. Defaults to ``width``. + /// - Returns: A location from 0 to 1. static func startLocation( viewportWidth: CGFloat, fadeWidth: CGFloat = width @@ -349,6 +520,21 @@ enum MarqueeFade { return max(0, 1 - fadeWidth / viewportWidth) } + /// Applies or removes the fade mask on a layer. + /// + /// The mask is removed entirely when the text fits, so a title that does + /// not overflow is not needlessly dimmed at its end. + /// + /// Layer property changes are wrapped in a transaction with actions + /// disabled, since Core Animation would otherwise implicitly animate the + /// frame and colours - producing a visible sweep every time the title + /// changes. + /// + /// - Parameters: + /// - contentLayer: Layer to mask. + /// - maskLayer: Gradient layer to reuse as the mask. + /// - size: Size of the viewport. + /// - showsFade: Whether the content overflows and needs fading. static func update( contentLayer: CALayer?, maskLayer: CAGradientLayer, @@ -385,7 +571,31 @@ enum MarqueeFade { } } +/// Builds the marquee animation that moves in whole device pixels. enum PixelAlignedMarquee { + /// Creates the scrolling animation. + /// + /// A `CABasicAnimation` would interpolate continuously and land the text + /// on fractional pixels, where the glyph rasteriser resamples it and the + /// text visibly shimmers. This instead precomputes one keyframe per device + /// pixel of travel and uses discrete calculation mode, so the title only + /// ever sits on an exact pixel boundary and stays sharp throughout. + /// + /// The cost is a values array proportional to the distance - a few hundred + /// entries for a typical title, built once per render rather than per + /// frame. + /// + /// The pause is expressed as two identical leading keyframes, which holds + /// the title still at the start of each repetition. + /// + /// - Parameters: + /// - distance: Total travel in points, being the title width plus its + /// trailing gap. + /// - scale: Backing scale factor, which sets the step size. + /// - initialPause: Seconds to hold before moving. + /// - pointsPerSecond: Scroll speed. + /// - Returns: An infinitely repeating keyframe animation on + /// `transform.translation.x`. static func animation( distance: CGFloat, scale: CGFloat, @@ -424,9 +634,18 @@ enum PixelAlignedMarquee { } extension Notification.Name { + /// Posted when the player panel becomes visible. + /// + /// Lets marquee views restart from the beginning of their text. A popover + /// is reused rather than rebuilt, so there is no view lifecycle callback + /// that would serve. static let playerPanelDidShow = Notification.Name( "dev.junx.Reprise.playerPanelDidShow" ) + + /// Posted when the player panel is dismissed. + /// + /// Lets marquee views stop animating while off screen. static let playerPanelDidHide = Notification.Name( "dev.junx.Reprise.playerPanelDidHide" ) diff --git a/src/Reprise.Platform.MacOS/Reprise/PlayerPopoverView.swift b/src/Reprise.Platform.MacOS/Reprise/PlayerPopoverView.swift index 9121238..a172187 100644 --- a/src/Reprise.Platform.MacOS/Reprise/PlayerPopoverView.swift +++ b/src/Reprise.Platform.MacOS/Reprise/PlayerPopoverView.swift @@ -8,13 +8,25 @@ import AppKit import SwiftUI +/// Colours the panel uses that no system semantic colour provides. enum PlayerPanelPalette { + /// Background for the explicit dark theme. + /// + /// A fixed grey rather than a semantic colour, because the dark theme is a + /// deliberate choice and must not follow the system appearance. static let darkBackground = Color(white: 0.12) } +/// Translucent background for the Liquid theme. +/// +/// Uses macOS 26's glass effect where available and falls back to a material +/// elsewhere. Both are compile-time and runtime guarded, so the same source +/// builds against an older SDK. struct LiquidPanelBackground: View { + /// Corner rounding, matched to the container being filled. let cornerRadius: CGFloat + /// The glass effect, or a material on older systems. var body: some View { #if compiler(>=6.2) if #available(macOS 26.0, *) { @@ -34,6 +46,7 @@ struct LiquidPanelBackground: View { #endif } + /// Blurred material fallback for systems without the glass effect. private var materialBackground: some View { RoundedRectangle( cornerRadius: cornerRadius, @@ -43,6 +56,17 @@ struct LiquidPanelBackground: View { } } +/// The player panel shown beneath the menu bar item. +/// +/// Artwork, track details, transport, progress, and - when available - +/// scrolling lyrics, over a footer carrying volume, settings, and quit. +/// +/// Two interactions drive most of the state here. The volume slider and the +/// progress slider both produce a stream of values as the user drags, while +/// the store polls underneath at its own rate. Each therefore holds a local +/// value, debounces what it sends, and ignores incoming updates until the +/// interaction settles - otherwise a poll landing mid-drag would pull the +/// control out from under the pointer. struct PlayerPopoverView: View { @Environment(\.colorScheme) private var systemColorScheme @Environment(\.openSettings) private var openSettings @@ -73,12 +97,23 @@ struct PlayerPopoverView: View { @State private var isSeeking = false @State private var pendingSeekPosition: TimeInterval? @State private var seekRequestID: UUID? + + /// Playback state the panel presents and controls. @Bindable var store: NowPlayingStore + /// Creates the panel over a store. + /// + /// - Parameter store: Store to present and command. init(store: NowPlayingStore) { self.store = store } + /// The player currently on display. + /// + /// The three discarded reads are load-bearing: the store picks the active + /// player from preferences that SwiftUI cannot see it read, so touching + /// them here registers the dependency. Without it, changing the player + /// priority in Settings would not refresh the open panel. private var snapshot: PlayerSnapshot { _ = playerDisplayOrder _ = remembersLastPlayedPlayer @@ -86,24 +121,35 @@ struct PlayerPopoverView: View { return store.activeSnapshot } + /// Identity of the current track, for noticing a change. + /// + /// Excludes duration, so a player revising its reported length mid-track + /// does not read as a new song and cancel an in-flight seek. private var trackIdentity: String? { snapshot.track.map { [$0.title, $0.album, $0.artist].joined(separator: "\u{0}") } } + /// The user's chosen panel theme. private var theme: PlayerPanelTheme { PlayerPanelTheme(rawValue: playerPanelTheme) ?? .liquid } + /// What the time on the left of the progress bar shows. private var leadingTimeStyle: PanelLeadingTimeStyle { PanelLeadingTimeStyle(rawValue: panelLeadingTimeStyle) ?? .elapsed } + /// What the time on the right of the progress bar shows. private var trailingTimeStyle: PanelTrailingTimeStyle { PanelTrailingTimeStyle(rawValue: panelTrailingTimeStyle) ?? .remaining } + /// Colour scheme to force, or `nil` to follow the system. + /// + /// The Liquid theme returns `nil` so its translucency picks up whatever is + /// behind the panel rather than being pinned to one appearance. private var preferredColorScheme: ColorScheme? { switch theme { case .white: .light @@ -112,6 +158,11 @@ struct PlayerPopoverView: View { } } + /// Title colour for the AppKit marquee view. + /// + /// Resolved to a concrete `NSColor` rather than left semantic, because the + /// marquee bakes the title into a bitmap and needs a real colour at draw + /// time. private var panelTitleColor: NSColor { switch theme { case .white: @@ -123,6 +174,18 @@ struct PlayerPopoverView: View { } } + /// The panel's contents. + /// + /// The lyrics section is conditional and animated, which changes the + /// panel's height - hence the size notification, since an `NSPanel` does + /// not resize itself to its SwiftUI content. + /// + /// Switching player resets every interaction, because a drag in progress + /// refers to a player that is no longer on screen. + /// + /// The cursor is forced to an arrow on hover: the panel is hosted in a + /// non-activating window, where AppKit does not always reset the cursor + /// and it can arrive as an I-beam from whatever was underneath. var body: some View { VStack(spacing: 0) { if let track = snapshot.track { @@ -201,6 +264,7 @@ struct PlayerPopoverView: View { } } + /// The panel's background for the selected theme. @ViewBuilder private var panelBackground: some View { switch theme { @@ -217,6 +281,17 @@ struct PlayerPopoverView: View { } } + /// Artwork, metadata, transport, and progress for a loaded track. + /// + /// The right column is pinned to the artwork's height so the transport + /// buttons stay put whether or not the track has an artist line. + /// + /// The progress row is wrapped in a `TimelineView` ticking four times a + /// second: the position is projected from the store's anchor, so it + /// advances smoothly without the store polling anywhere near that often. + /// + /// - Parameter track: Track to display. + /// - Returns: The card view. private func playerCard(_ track: Track) -> some View { HStack(spacing: 14) { ArtworkView( @@ -264,6 +339,13 @@ struct PlayerPopoverView: View { } } + /// Placeholder shown when no track is loaded. + /// + /// Distinguishes three cases so the message is actionable: the player + /// could not be reached, it is open but idle, or it is not running at all. + /// + /// The minimum height keeps the panel from collapsing to the footer, which + /// would make it flicker in size as playback starts and stops. private var emptyPlayer: some View { VStack(spacing: 18) { if let error = snapshot.errorMessage { @@ -289,6 +371,10 @@ struct PlayerPopoverView: View { .frame(minHeight: 150) } + /// Previous, play/pause, and next. + /// + /// Disabled together when the player is unreachable, since a press would + /// only produce an error banner. private var controls: some View { HStack(spacing: 28) { controlButton( @@ -314,6 +400,7 @@ struct PlayerPopoverView: View { .disabled(!snapshot.isRunning || snapshot.errorMessage != nil) } + /// Gear button opening Settings. private var settingsButton: some View { Button { presentSettings() @@ -329,6 +416,10 @@ struct PlayerPopoverView: View { .accessibilityIdentifier("settingsButton") } + /// Button quitting Reprise. + /// + /// The panel is the only place quit is offered, since a menu bar app has + /// no window to close and no Dock icon to quit from. private var exitButton: some View { Button { NSApp.terminate(nil) @@ -344,6 +435,10 @@ struct PlayerPopoverView: View { .accessibilityIdentifier("exitButton") } + /// Version text with the volume, settings, and quit buttons. + /// + /// Raised above the rest of the panel so the volume slider, which is a + /// child window anchored here, is not overlapped by the content above it. private var footer: some View { HStack(spacing: 8) { Text(appVersionText) @@ -368,6 +463,10 @@ struct PlayerPopoverView: View { .zIndex(3) } + /// Version and build, read from the bundle. + /// + /// Shown because a menu bar app has no About window; it is what a user + /// reporting a problem can quote. private var appVersionText: String { let version = Bundle.main.object( forInfoDictionaryKey: "CFBundleShortVersionString" @@ -379,6 +478,13 @@ struct PlayerPopoverView: View { return "Reprise v\(version) (\(build))" } + /// Speaker button revealing the volume slider. + /// + /// The slider lives in a separate child window rather than a popover, + /// because the panel clips its contents and a popover from a + /// non-activating panel does not behave reliably. It is attached through + /// an invisible background view, which gives the child window something to + /// anchor to. private var volumeButton: some View { Button { withAnimation(.easeOut(duration: 0.14)) { @@ -420,6 +526,7 @@ struct PlayerPopoverView: View { .zIndex(showsVolumeSlider ? 2 : 0) } + /// Speaker symbol matching the current level. private var volumeSymbol: String { switch volume { case ...0: @@ -433,6 +540,15 @@ struct PlayerPopoverView: View { } } + /// Adopts the player's volume into the slider. + /// + /// Refuses while the user is dragging or a change is in flight, since + /// accepting the poll's older value then would drag the slider backwards + /// under the pointer. + /// + /// - Parameter force: Adopt regardless of an interaction in progress. Used + /// on a player switch, where the previous player's level does not apply. + /// Defaults to `false`. private func syncVolume(force: Bool = false) { guard force || ( !isVolumeEditing && volumeRequestID == nil @@ -444,6 +560,15 @@ struct PlayerPopoverView: View { rememberAudibleVolume(snapshotVolume, for: snapshot.player) } + /// Sends the slider's value to the player, debounced. + /// + /// A drag emits values continuously and each one costs an AppleScript + /// round trip, so all but the last 80ms are coalesced. The request id lets + /// a superseded update abandon itself, so an earlier slower call cannot + /// land after a later one. + /// + /// - Parameter immediately: Skip the debounce, for the end of a drag or a + /// mute toggle where the value is final. Defaults to `false`. private func scheduleVolumeUpdate(immediately: Bool = false) { volumeUpdateTask?.cancel() let requestID = UUID() @@ -471,12 +596,17 @@ struct PlayerPopoverView: View { } } + /// Abandons any pending volume change. private func cancelVolumeUpdate() { volumeUpdateTask?.cancel() volumeUpdateTask = nil volumeRequestID = nil } + /// Mutes, or restores the level from before the mute. + /// + /// The current level is remembered first, so muting from the button always + /// has something to come back to. private func toggleMute() { let player = snapshot.player let currentVolume = PlayerVolume.clamped(Int(volume.rounded())) @@ -491,6 +621,14 @@ struct PlayerPopoverView: View { scheduleVolumeUpdate(immediately: true) } + /// Records a non-zero level, per player, for unmuting. + /// + /// Kept per player because each has its own volume; unmuting Spotify to + /// Music's last level would be wrong. + /// + /// - Parameters: + /// - volume: Level to remember; zero is ignored. + /// - player: Player it belongs to. private func rememberAudibleVolume( _ volume: Int, for player: MediaPlayerKind @@ -500,6 +638,11 @@ struct PlayerPopoverView: View { lastAudibleVolumes[player.rawValue] = volume } + /// Opens Settings and brings it forward. + /// + /// Activation and the follow-up focus pass are both needed: the panel is + /// non-activating, so Reprise is not frontmost, and `openSettings` does + /// not reliably raise an already-open Settings window. private func presentSettings() { NSApp.activate(ignoringOtherApps: true) openSettings() @@ -509,6 +652,10 @@ struct PlayerPopoverView: View { } } + /// Asks the panel window to re-measure its content. + /// + /// An `NSPanel` hosting SwiftUI does not resize itself, so the height + /// change from lyrics appearing has to be announced. private func notifyPanelContentSizeChanged() { NotificationCenter.default.post( name: .playerPanelContentSizeDidChange, @@ -516,6 +663,15 @@ struct PlayerPopoverView: View { ) } + /// Builds one transport button. + /// + /// - Parameters: + /// - command: Command to send. + /// - symbol: SF Symbol to draw. + /// - label: Tooltip and accessibility label. + /// - prominent: Whether to draw larger, for play/pause. Defaults to + /// `false`. + /// - Returns: The button. private func controlButton( command: PlaybackCommand, symbol: String, @@ -537,6 +693,20 @@ struct PlayerPopoverView: View { .accessibilityIdentifier(accessibilityIdentifier(for: command)) } + /// Progress slider with the elapsed and remaining times. + /// + /// The displayed position comes from one of three sources in priority + /// order: the drag in progress, a seek that has been sent but not yet + /// confirmed, or the store's estimate. The middle case is what stops the + /// bar snapping back to where the track was while the player catches up. + /// + /// The slider's upper bound is floored at 1 so a track with no reported + /// duration still yields a valid range rather than an empty one. + /// + /// - Parameters: + /// - track: Track being played. + /// - date: Instant to evaluate the position at. + /// - Returns: The progress view. private func progress( _ track: Track, at date: Date @@ -625,6 +795,14 @@ struct PlayerPopoverView: View { } } + /// Sends the seek when the user releases the slider. + /// + /// The pending position is held until the store confirms, so the bar stays + /// where it was dropped rather than jumping back for the second or so the + /// player takes to settle. The request id and player check make sure a + /// slow seek cannot clear a newer one's pending state. + /// + /// - Parameter track: Track being seeked within. private func finishSeeking(_ track: Track) { guard isSeeking else { return } @@ -649,6 +827,10 @@ struct PlayerPopoverView: View { } } + /// Inline banner carrying a command or player error. + /// + /// - Parameter message: Text to display. + /// - Returns: The banner view. private func errorBanner(_ message: String) -> some View { HStack(alignment: .top, spacing: 8) { Image(systemName: "exclamationmark.triangle.fill") @@ -662,6 +844,10 @@ struct PlayerPopoverView: View { .background(.orange.opacity(0.12), in: RoundedRectangle(cornerRadius: 9)) } + /// Stable identifier for a transport button, for UI tests. + /// + /// - Parameter command: Command the button sends. + /// - Returns: The identifier. private func accessibilityIdentifier(for command: PlaybackCommand) -> String { switch command { case .previous: "previousButton" @@ -674,8 +860,18 @@ struct PlayerPopoverView: View { } +/// Raises the Settings window once SwiftUI has created it. @MainActor private enum SettingsWindowFocus { + /// Waits for the Settings window and brings it to the front. + /// + /// `openSettings()` returns before the window exists, so there is nothing + /// to raise at the moment it is called. This polls briefly rather than + /// guessing a delay - half a second is long enough for a cold open and + /// exits immediately once found. + /// + /// De-miniaturising first covers the case where the user minimised + /// Settings earlier, where ordering front alone would do nothing. static func bringToFront() async { for _ in 0..<10 { if let window = NSApp.windows.first(where: isSettingsWindow) { @@ -691,6 +887,14 @@ private enum SettingsWindowFocus { } } + /// Whether a window looks like the Settings window. + /// + /// Identified by shape rather than by title, which is localised. Panels + /// are excluded so the player panel and the volume slider are not mistaken + /// for it. + /// + /// - Parameter window: Window to test. + /// - Returns: `true` when it is probably Settings. private static func isSettingsWindow(_ window: NSWindow) -> Bool { !(window is NSPanel) && window.styleMask.contains(.titled) @@ -698,7 +902,15 @@ private enum SettingsWindowFocus { } } +/// Borderless press style for the transport buttons. +/// +/// Replaces the system button chrome, which would draw a bordered control +/// where the panel wants bare glyphs, with a dim-and-shrink press response. private struct CompactControlButtonStyle: ButtonStyle { + /// Applies the press treatment. + /// + /// - Parameter configuration: Button state from SwiftUI. + /// - Returns: The styled label. func makeBody(configuration: Configuration) -> some View { configuration.label .foregroundStyle(Color.primary.opacity(configuration.isPressed ? 0.55 : 0.72)) @@ -707,9 +919,20 @@ private struct CompactControlButtonStyle: ButtonStyle { } } +/// The current player's logo, shown beside the track title. private struct PlayerLogoView: View { + /// Player to represent. let player: MediaPlayerKind + /// Spotify's mark, sized and marked as a template. + /// + /// Loaded once and resized up front because `Image(nsImage:)` renders at + /// the image's own size before SwiftUI's frame applies. Template mode is + /// what lets it take the panel's foreground colour rather than shipping + /// the brand colour, which would clash with both themes. + /// + /// An empty image of the right size is the fallback, so a missing asset + /// leaves a gap rather than shifting the layout. private static let spotifyLogo: NSImage = { guard let source = NSImage(named: "SpotifyLogo"), let image = source.copy() as? NSImage else { @@ -721,6 +944,7 @@ private struct PlayerLogoView: View { return image }() + /// YouTube Music's mark, prepared the same way as ``spotifyLogo``. private static let youtubeMusicLogo: NSImage = { guard let source = NSImage(named: "YouTubeMusicLogo"), let image = source.copy() as? NSImage else { @@ -732,6 +956,10 @@ private struct PlayerLogoView: View { return image }() + /// The logo for the current player. + /// + /// Music uses an SF Symbol rather than a bundled asset, since Apple's mark + /// is not redistributable and the note glyph reads the same way. @ViewBuilder var body: some View { switch player { @@ -765,15 +993,41 @@ private struct PlayerLogoView: View { } } +/// Scrolling lyrics beneath the player card. +/// +/// Every line is laid out at once in a `ZStack` and offset by its distance +/// from the focused line, so advancing a line is a change of offset that +/// SwiftUI animates - rather than a scroll position to drive imperatively. +/// A gradient mask fades the top and bottom edges so lines enter and leave +/// without a hard cut. private struct InlineLyricsView: View { @Environment(\.accessibilityReduceMotion) private var reduceMotion + + /// Store supplying the lyrics and playback position. @Bindable var store: NowPlayingStore + + /// Height of one lyric line. private let rowHeight: CGFloat = 29 + + /// Height of the lyrics area, fitting roughly four lines. private let lyricsViewportHeight: CGFloat = 136 + + /// Nudge keeping the previous line clear of the top fade. private let previousLineTopInset: CGFloat = 2 + + /// How many lines ahead take a staggered delay. + /// + /// Beyond this the delay stops growing, or lines far down the list would + /// still be settling long after the current one had moved on. private let maximumCascadeStep = 3 + + /// Delay added per line of the cascade. private let cascadeDelay = 0.045 + /// The lyrics viewport. + /// + /// Ticks four times a second, which is enough for a lyric line - unlike + /// the progress bar, a line changes every few seconds. var body: some View { VStack(spacing: 0) { TimelineView(.periodic(from: .now, by: 0.25)) { context in @@ -802,6 +1056,20 @@ private struct InlineLyricsView: View { } } + /// Lays every lyric line out relative to the focused one. + /// + /// Two indices are used, and the distinction matters. The focused line is + /// what the list is positioned around and always exists once playback has + /// begun; the current line may be `nil` during an instrumental gap. So + /// scrolling stays put while the highlight fades out, rather than the + /// lyrics jumping whenever no line is active. + /// + /// Every line is rendered rather than only the visible few: a lyric sheet + /// is small enough that windowing would cost more than it saved, and lines + /// need to already exist to animate in. + /// + /// - Parameter date: Instant to evaluate. + /// - Returns: The stacked lines. private func lyricsBody(at date: Date) -> some View { let lyrics = store.syncedLyrics let currentIndex = store.currentLyricLineIndex(at: date) @@ -837,6 +1105,15 @@ private struct InlineLyricsView: View { } } + /// One lyric line. + /// + /// Lines are truncated rather than wrapped, since a wrapped line would + /// break the fixed row height the offsets depend on. + /// + /// - Parameters: + /// - text: Line text. + /// - isCurrent: Whether this is the active line, drawn at full opacity. + /// - Returns: The row view. private func lyricRow( _ text: String?, isCurrent: Bool @@ -858,6 +1135,18 @@ private struct InlineLyricsView: View { ) } + /// Animation for a line moving to its new position. + /// + /// Lines below the focus start fractionally later than those above, which + /// makes the sheet settle as a group rather than snapping as a block. The + /// spring's slight bounce gives it the weight of a physical scroll. + /// + /// Reduce Motion replaces all of it with a plain ease, since the cascade + /// and bounce are exactly the kind of movement that setting asks to avoid. + /// + /// - Parameter relativeIndex: Distance from the focused line; negative + /// above, positive below. + /// - Returns: The animation to apply. private func movementAnimation( relativeIndex: Int ) -> Animation { @@ -875,24 +1164,59 @@ private struct InlineLyricsView: View { } } +/// Hosts the volume slider in a child window anchored to the footer. +/// +/// A popover would be clipped by the panel and does not behave reliably from a +/// non-activating window, so the slider is its own borderless panel attached +/// as a child - which keeps it above the player panel and moving with it. +/// +/// The representable itself renders nothing: its view exists only to locate +/// the anchor and the parent window. private struct VolumeSliderPanelPresenter: NSViewRepresentable { + /// Whether the slider is showing. @Binding var isPresented: Bool + + /// Volume level, shared with the panel. @Binding var volume: Double + + /// Speaker symbol matching the level. let symbol: String + + /// Player name, for accessibility. let playerName: String + + /// Colour scheme to force, or `nil` to follow the system. let colorScheme: ColorScheme? + + /// Called as the slider moves. let onVolumeChanged: () -> Void + + /// Called when dragging starts and stops. let onEditingChanged: (Bool) -> Void + + /// Called when the speaker icon is clicked. let onToggleMute: () -> Void + /// Creates the coordinator owning the child window. + /// + /// - Returns: A new coordinator. func makeCoordinator() -> Coordinator { Coordinator() } + /// Creates the invisible anchor view. + /// + /// - Parameter context: Representable context; unused. + /// - Returns: An empty view, used only for positioning. func makeNSView(context: Context) -> NSView { NSView() } + /// Pushes current values into the child window. + /// + /// - Parameters: + /// - nsView: The anchor view. + /// - context: Representable context, carrying the coordinator. func updateNSView(_ nsView: NSView, context: Context) { let content = VolumeSliderPanelContent( volume: $volume, @@ -911,6 +1235,14 @@ private struct VolumeSliderPanelPresenter: NSViewRepresentable { ) } + /// Tears the child window down with the SwiftUI view. + /// + /// Required because the panel is a window rather than a subview, so it + /// would otherwise outlive the view that created it. + /// + /// - Parameters: + /// - nsView: The anchor view; unused. + /// - coordinator: Coordinator to tear down. static func dismantleNSView( _ nsView: NSView, coordinator: Coordinator @@ -918,6 +1250,7 @@ private struct VolumeSliderPanelPresenter: NSViewRepresentable { coordinator.tearDown() } + /// Owns the slider's child window and its dismissal. @MainActor final class Coordinator { private let panelSize = NSSize(width: 160, height: 36) @@ -932,6 +1265,19 @@ private struct VolumeSliderPanelPresenter: NSViewRepresentable { private var localEventMonitor: Any? private var globalEventMonitor: Any? + /// Shows, hides, or refreshes the slider window. + /// + /// The hosted root view is replaced on every call so the slider tracks + /// the volume even when the window is already up. + /// + /// The panel is created once and reused, since building a window per + /// presentation would flicker. + /// + /// - Parameters: + /// - anchorView: View to position relative to. + /// - isPresented: Binding driving visibility, also written on + /// dismissal so SwiftUI stays in step. + /// - content: Slider content to host. func update( anchorView: NSView, isPresented: Binding, @@ -960,6 +1306,7 @@ private struct VolumeSliderPanelPresenter: NSViewRepresentable { installEventMonitors() } + /// Closes and releases the slider window. func tearDown() { dismiss() panel?.contentViewController = nil @@ -968,6 +1315,14 @@ private struct VolumeSliderPanelPresenter: NSViewRepresentable { hostingController = nil } + /// Builds the slider's borderless window. + /// + /// Placed one level above the player panel so it is never obscured by + /// its own parent. `hidesOnDeactivate` is off because Reprise is + /// frequently not the active app while the panel is open. + /// + /// - Parameter content: Slider content to host. + /// - Returns: The configured panel. private func makePanel( content: VolumeSliderPanelContent ) -> VolumeSliderPanel { @@ -1007,6 +1362,15 @@ private struct VolumeSliderPanelPresenter: NSViewRepresentable { return panel } + /// Makes the slider a child of the player panel. + /// + /// Child status is what keeps the two moving together and correctly + /// ordered. Re-parenting is skipped when nothing changed, since + /// re-adding a child window makes it flicker. + /// + /// - Parameters: + /// - panel: Slider window. + /// - parentWindow: Player panel window. private func attach( _ panel: NSPanel, to parentWindow: NSWindow @@ -1022,6 +1386,16 @@ private struct VolumeSliderPanelPresenter: NSViewRepresentable { self.parentWindow = parentWindow } + /// Places the slider under the panel's trailing edge. + /// + /// Flips above the panel when there is not enough room below, which + /// happens with a panel near the bottom of the screen. Both axes are + /// clamped to the visible frame and the origin snapped to a device + /// pixel, since a fractional window origin blurs its contents. + /// + /// - Parameters: + /// - panel: Slider window. + /// - parentWindow: Player panel window. private func position( _ panel: NSPanel, relativeTo parentWindow: NSWindow @@ -1060,6 +1434,7 @@ private struct VolumeSliderPanelPresenter: NSViewRepresentable { ) } + /// Hides the slider and detaches it from its parent. private func dismiss() { removeEventMonitors() @@ -1070,6 +1445,11 @@ private struct VolumeSliderPanelPresenter: NSViewRepresentable { parentWindow = nil } + /// Arms click-outside dismissal for the slider. + /// + /// The local monitor lets clicks through to the slider itself and to + /// the speaker button, which would otherwise dismiss and immediately + /// re-present. The global monitor catches clicks in other apps. private func installEventMonitors() { guard localEventMonitor == nil, globalEventMonitor == nil else { @@ -1103,6 +1483,10 @@ private struct VolumeSliderPanelPresenter: NSViewRepresentable { } } + /// Whether a click landed on the speaker button. + /// + /// - Parameter event: Mouse event to test. + /// - Returns: `true` when the click was inside the anchor view. private func isEventInsideAnchor(_ event: NSEvent) -> Bool { guard let anchorView, event.window === anchorView.window else { @@ -1116,6 +1500,11 @@ private struct VolumeSliderPanelPresenter: NSViewRepresentable { return anchorView.bounds.contains(point) } + /// Dismisses the slider and tells SwiftUI it closed. + /// + /// Writing the binding matters: without it SwiftUI would still believe + /// the slider was showing, and the next button press would toggle it + /// closed rather than open. private func requestDismissal() { guard presentation?.wrappedValue == true else { return @@ -1125,6 +1514,7 @@ private struct VolumeSliderPanelPresenter: NSViewRepresentable { dismiss() } + /// Disarms the dismissal monitors. private func removeEventMonitors() { if let localEventMonitor { NSEvent.removeMonitor(localEventMonitor) @@ -1138,15 +1528,33 @@ private struct VolumeSliderPanelPresenter: NSViewRepresentable { } } +/// The mute button, slider, and readout inside the volume window. private struct VolumeSliderPanelContent: View { + /// Volume level, shared with the panel. @Binding var volume: Double + + /// Speaker symbol matching the level. let symbol: String + + /// Player name, for accessibility. let playerName: String + + /// Colour scheme to force, or `nil` to follow the system. let colorScheme: ColorScheme? + + /// Called as the slider moves. let onVolumeChanged: () -> Void + + /// Called when dragging starts and stops. let onEditingChanged: (Bool) -> Void + + /// Called when the speaker icon is clicked. let onToggleMute: () -> Void + /// The slider row. + /// + /// The numeric readout has a fixed width so the slider does not resize as + /// the number goes from one digit to three. var body: some View { HStack(spacing: 8) { Button(action: onToggleMute) { @@ -1194,8 +1602,15 @@ private struct VolumeSliderPanelContent: View { } } +/// Borderless window holding the volume slider. +/// +/// Non-activating like the player panel, so adjusting the volume never pulls +/// Reprise in front of whatever the user is working in. @MainActor private final class VolumeSliderPanel: NSPanel { + /// Creates the window at a fixed size. + /// + /// - Parameter size: Size of the slider content. init(size: NSSize) { super.init( contentRect: NSRect(origin: .zero, size: size), @@ -1207,10 +1622,15 @@ private final class VolumeSliderPanel: NSPanel { becomesKeyOnlyIfNeeded = true } + /// Allows the window to take key status. + /// + /// Required for the slider to receive drag events, which a borderless + /// window would otherwise refuse. override var canBecomeKey: Bool { true } + /// Keeps the window from becoming main. override var canBecomeMain: Bool { false } @@ -1221,9 +1641,17 @@ private final class VolumeSliderPanel: NSPanel { } extension Notification.Name { + /// Posted to open the Settings window. + /// + /// Used by the Command-comma hot key, which is handled in the app delegate + /// but has to reach the SwiftUI view holding the `openSettings` action. static let openRepriseSettings = Notification.Name( "dev.junx.Reprise.openSettings" ) + + /// Posted when the panel's SwiftUI content changes height. + /// + /// Lets the hosting `NSPanel` re-measure, which it does not do on its own. static let playerPanelContentSizeDidChange = Notification.Name( "dev.junx.Reprise.playerPanelContentSizeDidChange" ) diff --git a/src/Reprise.Platform.MacOS/Reprise/RepriseApp.swift b/src/Reprise.Platform.MacOS/Reprise/RepriseApp.swift index 20be222..056009f 100644 --- a/src/Reprise.Platform.MacOS/Reprise/RepriseApp.swift +++ b/src/Reprise.Platform.MacOS/Reprise/RepriseApp.swift @@ -7,15 +7,27 @@ import SwiftUI +/// SwiftUI entry point for the app. +/// +/// Declares only the Settings scene: Reprise has no main window, and its menu +/// bar item is built in AppKit by ``RepriseAppDelegate`` rather than through +/// `MenuBarExtra`, which cannot host the custom panel behaviour the app needs. @main struct RepriseApp: App { @NSApplicationDelegateAdaptor(RepriseAppDelegate.self) private var appDelegate + /// Registers preference defaults before any view can read them. + /// + /// Runs here rather than in the delegate because SwiftUI resolves + /// `@AppStorage` properties as scenes are built, which can happen before + /// `applicationDidFinishLaunching`. Registering later would let the first + /// read see a hardcoded fallback instead of the intended default. init() { ReprisePreferences.registerDefaults() } + /// The Settings window, opened from the panel or with Command-comma. var body: some Scene { Settings { RepriseSettingsView() diff --git a/src/Reprise.Platform.MacOS/Reprise/RepriseDemoContent.swift b/src/Reprise.Platform.MacOS/Reprise/RepriseDemoContent.swift index 8dfa29b..47ed5ee 100644 --- a/src/Reprise.Platform.MacOS/Reprise/RepriseDemoContent.swift +++ b/src/Reprise.Platform.MacOS/Reprise/RepriseDemoContent.swift @@ -8,7 +8,20 @@ import AppKit import Foundation +/// Detects the demo mode used for screenshots and store assets. enum RepriseDemoMode { + /// Whether Reprise should show fabricated content instead of real players. + /// + /// Accepts either a launch argument or an environment variable so the mode + /// can be turned on from an Xcode scheme, a UI test, and a shell command + /// alike. + /// + /// - Parameters: + /// - arguments: Process arguments. Defaults to the real ones; injectable + /// for tests. + /// - environment: Process environment. Defaults to the real one; + /// injectable for tests. + /// - Returns: `true` when demo mode is requested. nonisolated static func isEnabled( arguments: [String] = ProcessInfo.processInfo.arguments, environment: [String: String] = ProcessInfo.processInfo.environment @@ -18,16 +31,42 @@ enum RepriseDemoMode { } } +/// The fabricated track Reprise shows in demo mode. +/// +/// Exists so marketing screenshots do not depend on whatever happens to be +/// playing, and can be reproduced exactly. Everything is invented: the track, +/// the artist, and the cover art, which is drawn at runtime rather than +/// shipped as an asset so no third-party artwork is ever distributed. @MainActor enum RepriseDemoContent { + /// Player the demo track is attributed to. static let player = MediaPlayerKind.youtubeMusic + + /// Demo track title. static let title = "Midnight Signal" + + /// Demo album name. static let album = "Demo Sessions" + + /// Demo artist name. static let artist = "Reprise Studio" + + /// Demo track length in seconds. static let duration: TimeInterval = 214 + + /// Playback position the demo frame sits at, in seconds. + /// + /// Chosen to put the progress bar visibly past its start without + /// approaching the end, so a screenshot reads as mid-playback. static let position: TimeInterval = 78 + + /// Demo volume level. static let volume = 68 + /// Demo lyrics, spaced to line up with ``position``. + /// + /// Line timings are spread across ``duration`` so whichever moment a + /// screenshot captures has a lyric showing. static let lyrics = SyncedLyrics( source: .lrclib, lines: [ @@ -63,6 +102,18 @@ enum RepriseDemoContent { ] ) + /// Builds a snapshot of the demo track. + /// + /// The playback rate is pinned to 0 so position estimation never advances + /// the frame: the panel still renders as active playback - play icon, + /// spinning disc - while the progress bar and times stay exactly where a + /// screenshot needs them, however long the app is left open. + /// + /// - Parameters: + /// - state: Transport state to present. Defaults to `.playing`. + /// - requestedPosition: Position in seconds. Defaults to ``position``. + /// - requestedVolume: Volume level. Defaults to ``volume``. + /// - Returns: A snapshot that looks like a real playing player. static func snapshot( state: PlaybackState = .playing, position requestedPosition: TimeInterval? = nil, @@ -83,15 +134,24 @@ enum RepriseDemoContent { artworkData: artworkData ), volume: volume, - // Keep the promotional frame stable while the play icon and - // rotating-disc treatment still render as active playback. playbackRate: 0, errorMessage: nil ) } + /// Cover art for the demo track, rendered once on first use. private static let artworkData = makeArtworkData() + /// Draws the demo cover art as a PNG. + /// + /// Generated rather than bundled so the app ships no artwork it does not + /// own, and so the image scales to any size a screenshot needs. The + /// composition is a diagonal gradient, two soft circles for depth, and a + /// symmetric bar equaliser standing in for a waveform. + /// + /// - Returns: PNG data, or `nil` if the drawing context or gradient could + /// not be created, in which case ``ArtworkView`` falls back to its + /// placeholder. private static func makeArtworkData() -> Data? { let size = 512 let colorSpace = CGColorSpaceCreateDeviceRGB() diff --git a/src/Reprise.Platform.MacOS/Reprise/ReprisePreferences.swift b/src/Reprise.Platform.MacOS/Reprise/ReprisePreferences.swift index 0c455d8..30308f1 100644 --- a/src/Reprise.Platform.MacOS/Reprise/ReprisePreferences.swift +++ b/src/Reprise.Platform.MacOS/Reprise/ReprisePreferences.swift @@ -8,35 +8,80 @@ import CoreGraphics import Foundation +/// `UserDefaults` keys for every Reprise setting. +/// +/// Centralised so the settings pane, the status bar controller, and +/// `@AppStorage` declarations cannot drift apart on a spelling. Renaming any +/// of these discards the user's existing value, since nothing migrates old +/// keys. enum ReprisePreferenceKey { + /// Pause the previous player when another starts playing. static let automaticallyPausesOtherPlayer = "automaticallyPausesOtherPlayer" + + /// Scroll menu bar titles that are too long to fit. static let automaticallyScrollTitles = "automaticallyScrollTitles" + + /// Marquee scroll speed, stored as points per second. static let marqueeSpeed = "marqueeSpeed" + + /// Which artwork treatment the menu bar item uses. static let menuBarArtworkStyle = "menuBarArtworkStyle" + + /// Width in points reserved for lyrics in the menu bar. static let menuBarLyricsWidth = "menuBarLyricsWidth" + + /// Hold the lyrics width even when there is no lyric to show. static let menuBarReservesLyricsWidth = "menuBarReservesLyricsWidth" + + /// Show synced lyrics in the menu bar. static let menuBarShowsLyrics = "menuBarShowsLyrics" + + /// How the track title and artist are combined in the menu bar. static let menuBarTitleFormat = "menuBarTitleFormat" + + /// What the time on the left of the panel's progress bar shows. static let panelLeadingTimeStyle = "panelLeadingTimeStyle" + + /// What the time on the right of the panel's progress bar shows. static let panelTrailingTimeStyle = "panelTrailingTimeStyle" + + /// The player that was last seen playing, for restoring on launch. static let lastPlayedPlayer = "lastPlayedPlayer" + + /// Player display priority, stored as comma-separated raw values. static let playerDisplayPriority = "playerDisplayPriority" + + /// Colour theme for the player panel. static let playerPanelTheme = "playerPanelTheme" + + /// Restore the last playing player when Reprise launches. static let remembersLastPlayedPlayer = "remembersLastPlayedPlayer" + + /// Rewind the marquee to the start when the panel is opened. static let resetsMenuTitleWhenPanelOpens = "resetsMenuTitleWhenPanelOpens" } +/// How the menu bar item represents the current track visually. enum MenuBarArtworkStyle: String, CaseIterable, Identifiable { + /// The album cover itself. case albumArtwork + + /// A spinning disc that turns while playing. case compactDisc + + /// An animated level meter. case levelIndicator + + /// No visual at all, leaving only the title. case hidden + /// Stable identity for SwiftUI pickers. var id: String { rawValue } + /// Label shown in the settings picker. var displayName: String { switch self { case .albumArtwork: "앨범" @@ -47,16 +92,26 @@ enum MenuBarArtworkStyle: String, CaseIterable, Identifiable { } } +/// How the track title and artist are laid out in the menu bar. enum MenuBarTitleFormat: String, CaseIterable, Identifiable { + /// Title alone. case titleOnly + + /// Title, then artist. case titleArtist + + /// Artist, then title. case artistTitle + + /// No text, leaving only the artwork. case hidden + /// Stable identity for SwiftUI pickers. var id: String { rawValue } + /// Label shown in the settings picker. var displayName: String { switch self { case .titleOnly: "제목만" @@ -66,6 +121,23 @@ enum MenuBarTitleFormat: String, CaseIterable, Identifiable { } } + /// Renders the menu bar text for a track. + /// + /// Blank components are dropped rather than rendered, so a track missing + /// an artist never produces a dangling separator. ``titleOnly`` falls back + /// to the artist when the title is blank, since showing nothing at all + /// would read as Reprise having lost the track. + /// + /// - Parameters: + /// - title: Track title; surrounding whitespace is trimmed. + /// - artist: Artist credit; surrounding whitespace is trimmed. + /// - Returns: The text to draw, or an empty string for ``hidden``. + /// + /// ## Example + /// ```swift + /// MenuBarTitleFormat.titleArtist.text(title: "Redshift", artist: "") + /// // "Redshift" + /// ``` func text(title: String, artist: String) -> String { let title = title.trimmingCharacters(in: .whitespacesAndNewlines) let artist = artist.trimmingCharacters(in: .whitespacesAndNewlines) @@ -87,14 +159,20 @@ enum MenuBarTitleFormat: String, CaseIterable, Identifiable { } } +/// What the time to the left of the panel's progress bar shows. enum PanelLeadingTimeStyle: String, CaseIterable, Identifiable { + /// Time elapsed in the track. case elapsed + + /// A fixed `00:00`, for users who only want the track length. case zero + /// Stable identity for SwiftUI pickers. var id: String { rawValue } + /// Label shown in the settings picker. var displayName: String { switch self { case .elapsed: "현재 재생" @@ -103,14 +181,20 @@ enum PanelLeadingTimeStyle: String, CaseIterable, Identifiable { } } +/// What the time to the right of the panel's progress bar shows. enum PanelTrailingTimeStyle: String, CaseIterable, Identifiable { + /// Time left, shown as a negative value. case remaining + + /// Total track length. case duration + /// Stable identity for SwiftUI pickers. var id: String { rawValue } + /// Label shown in the settings picker. var displayName: String { switch self { case .remaining: "남은 시간" @@ -119,7 +203,14 @@ enum PanelTrailingTimeStyle: String, CaseIterable, Identifiable { } } +/// Formats the two times flanking the panel's progress bar. enum PanelTimeDisplay { + /// Renders the time to the left of the progress bar. + /// + /// - Parameters: + /// - style: The user's chosen leading style. + /// - position: Elapsed playback time in seconds. + /// - Returns: The formatted time. static func leadingText( style: PanelLeadingTimeStyle, position: TimeInterval @@ -132,6 +223,16 @@ enum PanelTimeDisplay { } } + /// Renders the time to the right of the progress bar. + /// + /// The remaining style is prefixed with a minus sign, matching how music + /// players conventionally distinguish a countdown from a total. + /// + /// - Parameters: + /// - style: The user's chosen trailing style. + /// - duration: Total track length in seconds. + /// - remaining: Time left in seconds. + /// - Returns: The formatted time. static func trailingText( style: PanelTrailingTimeStyle, duration: TimeInterval, @@ -145,6 +246,15 @@ enum PanelTimeDisplay { } } + /// Formats seconds as `m:ss`. + /// + /// Rounds down rather than to nearest so the displayed time never reaches + /// the track length before playback actually does. Non-finite and + /// negative inputs collapse to `0:00`, since a player reporting garbage + /// should not put `nan:aN` in the panel. + /// + /// - Parameter time: Duration in seconds. + /// - Returns: The formatted string, or `0:00` for unusable input. private static func timeString(_ time: TimeInterval) -> String { guard time.isFinite, time > 0 else { return "0:00" } let totalSeconds = Int(time.rounded(.down)) @@ -152,16 +262,26 @@ enum PanelTimeDisplay { } } +/// Colour treatment for the player panel. enum PlayerPanelTheme: String, CaseIterable, Identifiable { + /// Light panel. case white + + /// Dark panel. case black + + /// Translucent material that picks up the desktop behind it. case liquid + + /// Follow the system appearance. case system + /// Stable identity for SwiftUI pickers. var id: String { rawValue } + /// Label shown in the settings picker. var displayName: String { switch self { case .white: "화이트" @@ -172,15 +292,26 @@ enum PlayerPanelTheme: String, CaseIterable, Identifiable { } } +/// Marquee scroll speed, in points per second. +/// +/// Raw values are the actual speeds, so the stored preference stays +/// meaningful even if the presets are later renamed or extended. enum MarqueeSpeed: Double, CaseIterable, Identifiable { + /// 20 points per second. case slow = 20 + + /// 30 points per second. case normal = 30 + + /// 45 points per second. case fast = 45 + /// Stable identity for SwiftUI pickers, backed by the speed itself. var id: Double { rawValue } + /// Label shown in the settings picker. var displayName: String { switch self { case .slow: "느리게" @@ -190,28 +321,82 @@ enum MarqueeSpeed: Double, CaseIterable, Identifiable { } } +/// Bounds for the lyrics area in the menu bar. +/// +/// The menu bar is shared with every other app's status items, so this width +/// is capped: an unbounded lyric line would crowd them out or be truncated by +/// the system. enum MenuBarLyricsWidth { + /// Narrowest width that still fits a readable fragment. static let minimum = 80.0 + + /// Widest width Reprise will claim in the menu bar. static let maximum = 300.0 + + /// Width used until the user changes it. static let defaultValue = 170.0 + + /// Slider increment in the settings pane. static let step = 10.0 + + /// The full allowed range, for binding a slider. static let range = minimum...maximum + /// Forces a stored width into the allowed range. + /// + /// Applied on read as well as on write, so a value left over from an + /// older build with different bounds cannot produce an oversized item. + /// + /// - Parameter value: Width in points. + /// - Returns: A width within ``range``. static func clamped(_ value: Double) -> CGFloat { CGFloat(min(max(value, minimum), maximum)) } } +/// Every menu bar appearance setting, read once as a unit. +/// +/// The status bar item rebuilds from a single snapshot rather than reading +/// each key as it draws, so a settings change cannot be applied half-way and +/// leave the item in a combination the user never chose. struct MarqueePreferences: Equatable { + /// Whether over-long titles scroll. let automaticallyScrollsTitles: Bool + + /// Marquee speed in points per second. let pointsPerSecond: CGFloat + + /// Whether opening the panel rewinds the marquee. let resetsMenuTitleWhenPanelOpens: Bool + + /// Which artwork treatment to draw. let menuBarArtworkStyle: MenuBarArtworkStyle + + /// Width reserved for lyrics, already clamped. let menuBarLyricsWidth: CGFloat + + /// Whether to hold that width when no lyric is showing. let menuBarReservesLyricsWidth: Bool + + /// Whether lyrics appear in the menu bar at all. let menuBarShowsLyrics: Bool + + /// How title and artist are combined. let menuBarTitleFormat: MenuBarTitleFormat + /// Reads the current appearance settings. + /// + /// Unrecognised stored values fall back to their defaults rather than + /// failing, which keeps a preferences file edited by hand or written by + /// an older build from leaving the menu bar blank. + /// + /// Hiding both the artwork and the title would make the menu bar item + /// invisible and so unclickable, stranding the user with no way back into + /// Reprise. That combination is resolved by forcing the artwork back on. + /// + /// - Parameter defaults: Store to read from. Defaults to `.standard`, and + /// is injectable so tests can supply a suite of their own. + /// - Returns: The settings to render the menu bar item with. static func current( defaults: UserDefaults = .standard ) -> MarqueePreferences { @@ -260,11 +445,24 @@ struct MarqueePreferences: Equatable { } } +/// Reads and writes the settings that are not plain `@AppStorage` values. +/// +/// Covers the preferences needing encoding or repair on the way in or out - +/// the player order especially - plus the default registration the whole +/// scheme depends on. enum ReprisePreferences { + /// Player order used until the user rearranges it. + /// + /// Derived from the declaration order of ``MediaPlayerKind`` so a newly + /// supported player joins the default without a second edit here. static let defaultPlayerDisplayOrder = MediaPlayerKind.allCases .map(\.rawValue) .joined(separator: ",") + /// Reads the user's player display priority. + /// + /// - Parameter defaults: Store to read from. Defaults to `.standard`. + /// - Returns: Every player, in the user's chosen order. static func playerDisplayOrder( in defaults: UserDefaults = .standard ) -> [MediaPlayerKind] { @@ -275,6 +473,24 @@ enum ReprisePreferences { ) } + /// Decodes a stored priority string into a complete player order. + /// + /// The result always contains every player exactly once, whatever the + /// input: unknown names are dropped, duplicates collapse to their first + /// position, and players missing from the string are appended in + /// declaration order. That makes a stored value written by an older build + /// - which knew about fewer players - safe to read, and guarantees the + /// selector can never be handed an order with a gap in it. + /// + /// - Parameter rawValue: Comma-separated raw values, or `nil`. + /// - Returns: Every player, ordered by preference. + /// + /// ## Example + /// ```swift + /// // "spotify" alone still yields the full set, Spotify first. + /// ReprisePreferences.playerDisplayOrder(from: "spotify") + /// // [.spotify, .appleMusic, .youtubeMusic] + /// ``` static func playerDisplayOrder( from rawValue: String? ) -> [MediaPlayerKind] { @@ -290,6 +506,14 @@ enum ReprisePreferences { return result } + /// Encodes a player order for storage. + /// + /// Any player the caller left out is appended, so a partial list from the + /// settings pane still round-trips through + /// ``playerDisplayOrder(from:)`` as a complete order. + /// + /// - Parameter players: Players in the desired priority order. + /// - Returns: A comma-separated string covering every player. static func serializedPlayerDisplayOrder( _ players: [MediaPlayerKind] ) -> String { @@ -301,6 +525,10 @@ enum ReprisePreferences { .joined(separator: ",") } + /// Whether starting one player should pause the previous one. + /// + /// - Parameter defaults: Store to read from. Defaults to `.standard`. + /// - Returns: The user's setting. static func automaticallyPausesOtherPlayer( in defaults: UserDefaults = .standard ) -> Bool { @@ -310,6 +538,10 @@ enum ReprisePreferences { ) } + /// Whether the last playing player should be restored on launch. + /// + /// - Parameter defaults: Store to read from. Defaults to `.standard`. + /// - Returns: The user's setting. static func remembersLastPlayedPlayer( in defaults: UserDefaults = .standard ) -> Bool { @@ -318,6 +550,11 @@ enum ReprisePreferences { ) } + /// Reads the player that was last seen playing. + /// + /// - Parameter defaults: Store to read from. Defaults to `.standard`. + /// - Returns: The remembered player, or `nil` when none was stored or the + /// stored name is no longer a supported player. static func lastPlayedPlayer( in defaults: UserDefaults = .standard ) -> MediaPlayerKind? { @@ -326,6 +563,11 @@ enum ReprisePreferences { ).flatMap(MediaPlayerKind.init(rawValue:)) } + /// Records the player that is playing now. + /// + /// - Parameters: + /// - player: Player to remember. + /// - defaults: Store to write to. Defaults to `.standard`. static func setLastPlayedPlayer( _ player: MediaPlayerKind, in defaults: UserDefaults = .standard @@ -336,6 +578,14 @@ enum ReprisePreferences { ) } + /// Registers the default value for every Reprise setting. + /// + /// Must run before anything reads a preference. Registered defaults are + /// not written to disk, so this stays the single definition of what an + /// untouched setting means, and resetting one is a matter of removing the + /// user's value rather than writing the default back. + /// + /// - Parameter defaults: Store to register with. Defaults to `.standard`. static func registerDefaults( in defaults: UserDefaults = .standard ) { diff --git a/src/Reprise.Platform.MacOS/Reprise/RepriseSettingsView.swift b/src/Reprise.Platform.MacOS/Reprise/RepriseSettingsView.swift index 50a2ac3..06c9604 100644 --- a/src/Reprise.Platform.MacOS/Reprise/RepriseSettingsView.swift +++ b/src/Reprise.Platform.MacOS/Reprise/RepriseSettingsView.swift @@ -8,16 +8,31 @@ import AppKit import SwiftUI +/// The Settings window, split into six tabs. +/// +/// Every pane writes straight to `UserDefaults` through `@AppStorage`, so +/// there is no apply step and no settings model: a change reaches the panel +/// and the menu bar as soon as it is made. struct RepriseSettingsView: View { @Environment(\.controlActiveState) private var controlActiveState + /// Accent colour that dims when the window is not active. + /// + /// The tab strip is tinted manually, since a tinted `TabView` does not + /// follow the window's active state on its own and would keep a + /// full-strength accent in a background window. private var controlTint: Color { controlActiveState == .inactive ? Color(nsColor: .tertiaryLabelColor) : .accentColor } + /// The tab strip and its panes. + /// + /// Fixed size because a settings window with movable panes would reflow + /// its previews, which are laid out at the real dimensions of the menu bar + /// item and the player panel. var body: some View { TabView { GeneralSettingsView() @@ -60,6 +75,7 @@ struct RepriseSettingsView: View { } } +/// Playback, lyrics, player priority, and launch at login. private struct GeneralSettingsView: View { @StateObject private var launchAtLoginController = LaunchAtLoginController() @@ -80,12 +96,19 @@ private struct GeneralSettingsView: View { @State private var dragStartIndex: Int? @State private var dragOffset = CGFloat.zero + /// The user's player order, decoded from the stored string. private var orderedPlayers: [MediaPlayerKind] { ReprisePreferences.playerDisplayOrder( from: playerDisplayOrder ) } + /// The General pane. + /// + /// The login item state is re-read on appearance and whenever the app + /// becomes active, because approval happens in System Settings with no + /// callback: coming back to Reprise is the only cue that it may have + /// changed. var body: some View { Form { Section { @@ -247,6 +270,14 @@ private struct GeneralSettingsView: View { } } + /// Small icon for a player in the priority list. + /// + /// Bundled logos are rendered as templates so they take the list's + /// foreground colour; Music uses an SF Symbol, since Apple's mark is not + /// redistributable. + /// + /// - Parameter player: Player to represent. + /// - Returns: The icon view. @ViewBuilder private func playerIcon( for player: MediaPlayerKind @@ -268,6 +299,21 @@ private struct GeneralSettingsView: View { } } + /// Reorders the priority list as a row is dragged. + /// + /// Written by hand rather than using `List`'s move support, because the + /// rows live in a `Form` section alongside a toggle - a `List` there would + /// bring its own chrome and break the grouped layout. + /// + /// The drag translates a row's position into an index by dividing by the + /// row height, and reorders as soon as that index changes, so the list + /// rearranges under the pointer rather than only on release. The offset + /// applied afterwards is the remainder: it keeps the dragged row glued to + /// the pointer even though the rows beneath it have already moved. + /// + /// - Parameters: + /// - player: Player being dragged. + /// - translation: Vertical movement since the drag began. private func updateDrag( for player: MediaPlayerKind, translation: CGFloat @@ -317,6 +363,10 @@ private struct GeneralSettingsView: View { * PlayerPriorityDragMetrics.rowStep } + /// Settles the dragged row into place. + /// + /// The order has already been written during the drag, so this only + /// animates the offset back to zero. private func endDrag() { withAnimation( .spring(response: 0.22, dampingFraction: 0.86) @@ -327,6 +377,15 @@ private struct GeneralSettingsView: View { } } + /// Moves a player to a new position and stores the order. + /// + /// The offset adjustment is required by `move(fromOffsets:toOffset:)`, + /// which inserts before the given offset: moving downwards needs one more + /// to land after the target rather than before it. + /// + /// - Parameters: + /// - player: Player to move. + /// - targetIndex: Index to move it to. private func movePlayer( _ player: MediaPlayerKind, to targetIndex: Int @@ -350,9 +409,15 @@ private struct GeneralSettingsView: View { } } +/// Connected browser sessions and extension download links. private struct YouTubeMusicSettingsView: View { @State private var sessions: [YouTubeMusicSession] = [] + /// The YouTube Music pane. + /// + /// Polls the bridge twice a second while visible. Polling rather than + /// observing because the bridge is an actor with no change notification, + /// and this list is only on screen while the user is looking at it. var body: some View { Form { Section { @@ -423,6 +488,20 @@ private struct YouTubeMusicSettingsView: View { } } + /// One row describing a browser tab. + /// + /// The badge distinguishes three states the user would otherwise conflate: + /// the tab Reprise is controlling, one the extension selected but that is + /// not in use, and one that has stopped responding. + /// + /// Collapsed into a single accessibility element, since read out + /// separately the icon, badge, and version are noise around the one fact + /// that matters. + /// + /// - Parameters: + /// - session: Session to describe. + /// - displayName: Pre-computed name for the row. + /// - Returns: The row view. @ViewBuilder private func sessionRow( _ session: YouTubeMusicSession, @@ -489,6 +568,14 @@ private struct YouTubeMusicSettingsView: View { ) } + /// Names a session, numbering tabs when a browser has several. + /// + /// The tab number is positional rather than the browser's own tab id, + /// which is an arbitrary large integer that would mean nothing to a user. + /// It is omitted entirely when a browser has only one tab. + /// + /// - Parameter session: Session to name. + /// - Returns: The display name. private func sessionDisplayName( for session: YouTubeMusicSession ) -> String { @@ -504,6 +591,14 @@ private struct YouTubeMusicSettingsView: View { return "\(session.browserName) · 탭 \(index + 1)" } + /// Secondary line describing what a session is playing. + /// + /// A session with no title is distinguished by freshness: a fresh one has + /// genuinely nothing loaded, while a stale one may simply not have + /// reported yet. + /// + /// - Parameter session: Session to describe. + /// - Returns: The detail text. private func sessionDetailText( for session: YouTubeMusicSession ) -> String { @@ -528,6 +623,10 @@ private struct YouTubeMusicSettingsView: View { } } + /// SF Symbol standing in for a browser family. + /// + /// - Parameter browser: Browser to represent. + /// - Returns: The symbol name. private func browserSymbol( for browser: YouTubeMusicBrowserKind ) -> String { @@ -539,6 +638,10 @@ private struct YouTubeMusicSettingsView: View { } } + /// SF Symbol for a playback state. + /// + /// - Parameter state: State to represent. + /// - Returns: The symbol name. private func stateSymbol(for state: PlaybackState) -> String { switch state { case .playing: @@ -553,18 +656,27 @@ private struct YouTubeMusicSettingsView: View { } } +/// Geometry the priority drag converts movement into indices with. private enum PlayerPriorityDragMetrics { + /// Height of one row plus its spacing, in points. + /// + /// Measured from the rendered form rather than derived, since a grouped + /// `Form` does not expose its row metrics. It must track the row layout: + /// if the rows change height, dragging picks the wrong index. static let rowStep: CGFloat = 39 } +/// Panel theme picker with a live preview. private struct ThemeSettingsView: View { @AppStorage(ReprisePreferenceKey.playerPanelTheme) private var playerPanelTheme = PlayerPanelTheme.liquid.rawValue + /// The currently selected theme. private var theme: PlayerPanelTheme { PlayerPanelTheme(rawValue: playerPanelTheme) ?? .liquid } + /// The Theme pane. var body: some View { Form { Section("미리보기") { @@ -587,6 +699,11 @@ private struct ThemeSettingsView: View { } } +/// Non-functional replica of the player panel, for previewing a theme. +/// +/// Rebuilt rather than reusing ``PlayerPopoverView``, which would need a live +/// store and would send real commands from a preview. Kept at the panel's +/// actual dimensions so the preview is a true likeness. private struct ThemePanelPreview: View { @Environment(\.colorScheme) private var systemColorScheme @State private var previewPosition = 69.0 @@ -597,8 +714,14 @@ private struct ThemePanelPreview: View { @AppStorage(ReprisePreferenceKey.panelTrailingTimeStyle) private var panelTrailingTimeStyle = PanelTrailingTimeStyle.remaining.rawValue + /// Theme being previewed. let theme: PlayerPanelTheme + /// Colour scheme the preview renders in. + /// + /// Unlike the real panel this always resolves to a concrete scheme, since + /// the preview has to look right inside a Settings window that may be in + /// the opposite appearance. private var previewColorScheme: ColorScheme { switch theme { case .white: .light @@ -607,6 +730,7 @@ private struct ThemePanelPreview: View { } } + /// Title colour for the previewed theme. private var titleColor: Color { switch theme { case .white: .black @@ -616,14 +740,20 @@ private struct ThemePanelPreview: View { } } + /// What the time on the left shows, mirrored from the Panel pane. private var leadingTimeStyle: PanelLeadingTimeStyle { PanelLeadingTimeStyle(rawValue: panelLeadingTimeStyle) ?? .elapsed } + /// What the time on the right shows, mirrored from the Panel pane. private var trailingTimeStyle: PanelTrailingTimeStyle { PanelTrailingTimeStyle(rawValue: panelTrailingTimeStyle) ?? .remaining } + /// The preview panel. + /// + /// The border and shadow are reproduced too, since the Liquid theme's + /// translucency reads quite differently without them. var body: some View { HStack(spacing: 14) { PreviewAlbumArtwork(symbolSize: 28) @@ -712,6 +842,7 @@ private struct ThemePanelPreview: View { .animation(.easeInOut(duration: 0.2), value: theme) } + /// Background matching the previewed theme. @ViewBuilder private var themeBackground: some View { switch theme { @@ -727,6 +858,7 @@ private struct ThemePanelPreview: View { } } +/// Time display options for the player panel. private struct PanelSettingsView: View { @AppStorage(ReprisePreferenceKey.panelLeadingTimeStyle) private var panelLeadingTimeStyle = PanelLeadingTimeStyle.elapsed.rawValue @@ -734,14 +866,17 @@ private struct PanelSettingsView: View { @AppStorage(ReprisePreferenceKey.panelTrailingTimeStyle) private var panelTrailingTimeStyle = PanelTrailingTimeStyle.remaining.rawValue + /// Selected leading time style. private var leadingStyle: PanelLeadingTimeStyle { PanelLeadingTimeStyle(rawValue: panelLeadingTimeStyle) ?? .elapsed } + /// Selected trailing time style. private var trailingStyle: PanelTrailingTimeStyle { PanelTrailingTimeStyle(rawValue: panelTrailingTimeStyle) ?? .remaining } + /// The Panel pane. var body: some View { Form { Section("미리보기") { @@ -775,12 +910,21 @@ private struct PanelSettingsView: View { } } +/// Progress bar and time labels, previewing the time styles. +/// +/// The slider is draggable so the effect of each style can be seen at +/// different positions - a countdown reads very differently near the end of a +/// track than at its start. private struct PanelTimePreview: View { @State private var previewPosition = 69.0 + /// Leading style to preview. let leadingStyle: PanelLeadingTimeStyle + + /// Trailing style to preview. let trailingStyle: PanelTrailingTimeStyle + /// The preview. var body: some View { VStack(spacing: 1) { SettingsPreviewSlider( @@ -820,14 +964,30 @@ private struct PanelTimePreview: View { } } +/// AppKit slider used in the settings previews. +/// +/// SwiftUI's `Slider` inside a `Form` picks up the form's control sizing and +/// label treatment, which makes it look nothing like the compact slider in the +/// player panel. An `NSSlider` gives the preview the same appearance as the +/// real thing. private struct SettingsPreviewSlider: NSViewRepresentable { + /// Slider position. @Binding var value: Double + + /// Range the slider spans. let range: ClosedRange + /// Creates the coordinator that receives the slider's action. + /// + /// - Returns: A coordinator bound to the value. func makeCoordinator() -> Coordinator { Coordinator(value: $value) } + /// Builds the slider. + /// + /// - Parameter context: Representable context, carrying the coordinator. + /// - Returns: The configured slider. func makeNSView(context: Context) -> NSSlider { let slider = NSSlider( value: value, @@ -843,6 +1003,14 @@ private struct SettingsPreviewSlider: NSViewRepresentable { return slider } + /// Pushes the current value and range into the slider. + /// + /// The value is only written when it differs beyond a small epsilon, since + /// assigning during a drag would fight the user's pointer. + /// + /// - Parameters: + /// - slider: Slider to update. + /// - context: Representable context, carrying the coordinator. func updateNSView(_ slider: NSSlider, context: Context) { context.coordinator.value = $value slider.minValue = range.lowerBound @@ -853,13 +1021,24 @@ private struct SettingsPreviewSlider: NSViewRepresentable { } } + /// Bridges the slider's target-action back to the SwiftUI binding. final class Coordinator: NSObject { + /// Binding to write the slider's value into. + /// + /// Replaced on every update, because SwiftUI hands out a fresh binding + /// each time the view is rebuilt and a stale one would write nowhere. var value: Binding + /// Creates the coordinator. + /// + /// - Parameter value: Binding to write into. init(value: Binding) { self.value = value } + /// Forwards a slider change to the binding. + /// + /// - Parameter sender: The slider that changed. @objc func valueChanged(_ sender: NSSlider) { value.wrappedValue = sender.doubleValue @@ -867,6 +1046,7 @@ private struct SettingsPreviewSlider: NSViewRepresentable { } } +/// Menu bar appearance options, with a live preview. private struct MenuBarSettingsView: View { @AppStorage(ReprisePreferenceKey.automaticallyScrollTitles) private var automaticallyScrollTitles = true @@ -892,18 +1072,29 @@ private struct MenuBarSettingsView: View { @AppStorage(ReprisePreferenceKey.menuBarTitleFormat) private var menuBarTitleFormat = MenuBarTitleFormat.titleOnly.rawValue + /// Selected artwork style. private var artworkStyle: MenuBarArtworkStyle { MenuBarArtworkStyle(rawValue: menuBarArtworkStyle) ?? .albumArtwork } + /// Selected title format. private var titleFormat: MenuBarTitleFormat { MenuBarTitleFormat(rawValue: menuBarTitleFormat) ?? .titleOnly } + /// Marquee speed as a layout value. private var carouselSpeed: CGFloat { CGFloat(marqueeSpeed) } + /// The Menu Bar pane. + /// + /// The three handlers at the bottom enforce one invariant: the artwork and + /// the title cannot both be hidden. That combination leaves an invisible + /// menu bar item, which the user could not click to get back into Reprise + /// or reach Settings to undo it. Whichever option was not just changed is + /// restored instead. The check on appear covers a preferences file that + /// already holds that pair. var body: some View { Form { Section("미리보기") { @@ -986,15 +1177,41 @@ private struct MenuBarSettingsView: View { } } +/// Replica of the menu bar item, for previewing its appearance. +/// +/// Drawn over a blue gradient standing in for a desktop wallpaper, since the +/// real item composites its white template against whatever is behind the menu +/// bar and would be invisible on the settings background. +/// +/// Uses the real ``PanelTitleMarqueeView`` and ``MenuBarMarquee`` measurements, +/// so the preview scrolls and sizes exactly as the item will. private struct MenuBarArtworkPreview: View { + /// Artwork style to preview. let style: MenuBarArtworkStyle + + /// Title format to preview. let titleFormat: MenuBarTitleFormat + + /// Whether lyrics mode is on. let showsLyrics: Bool + + /// Width budget for lyrics. let lyricsWidth: CGFloat + + /// Whether the lyrics width is held when text is shorter. let reservesLyricsWidth: Bool + + /// Whether long titles scroll. let automaticallyScrolls: Bool + + /// Scroll speed. let pointsPerSecond: CGFloat + /// Sample text for the preview. + /// + /// A short line in lyrics mode and a deliberately long one otherwise, so + /// the scrolling and truncation behaviour is visible without waiting for a + /// real track that happens to be long. private var previewTitle: String { if showsLyrics, titleFormat != .hidden { return "다시 만나요" @@ -1005,12 +1222,14 @@ private struct MenuBarArtworkPreview: View { ) } + /// Text width budget for the preview. private var maximumTextWidth: CGFloat { showsLyrics ? lyricsWidth : MenuBarMarquee.maximumTextWidth } + /// The preview item. var body: some View { HStack(spacing: 5) { if style != .hidden { @@ -1062,9 +1281,12 @@ private struct MenuBarArtworkPreview: View { } } +/// Preview of the menu bar item's leading visual. private struct MenuBarLeadingArtworkPreview: View { + /// Style to preview. let style: MenuBarArtworkStyle + /// The chosen visual. var body: some View { switch style { case .albumArtwork: @@ -1080,9 +1302,18 @@ private struct MenuBarLeadingArtworkPreview: View { } } +/// Stand-in album cover for the settings previews. +/// +/// An angular gradient rather than a real image, so the previews ship no +/// third-party artwork and read as generic at any size. private struct PreviewAlbumArtwork: View { + /// Size of the centred note glyph. + /// + /// Set by the caller because the same artwork appears at 18 points in the + /// menu bar preview and 112 in the panel preview. var symbolSize: CGFloat = 8 + /// The gradient with its note. var body: some View { ZStack { AngularGradient( @@ -1104,9 +1335,15 @@ private struct PreviewAlbumArtwork: View { } } +/// Spinning disc preview for the compact-disc style. +/// +/// Uses a SwiftUI animation rather than the Core Animation rotation the real +/// item runs; they are visually equivalent, and this needs no layer plumbing. +/// It matches the real rotation duration so the speed is faithful. private struct RotatingDiscPreview: View { @State private var rotation = 0.0 + /// The rotating disc. var body: some View { PreviewAlbumArtwork() .clipShape(Circle()) @@ -1132,7 +1369,13 @@ private struct RotatingDiscPreview: View { } } +/// Animated level meter preview. +/// +/// Driven by a sine of the timeline's clock rather than keyframes, which is +/// less code for the same effect. Each bar is offset in phase so they never +/// move in unison. private struct PlayingIndicatorPreview: View { + /// The animated bars. var body: some View { TimelineView(.animation) { timeline in let time = timeline.date.timeIntervalSinceReferenceDate @@ -1152,7 +1395,13 @@ private struct PlayingIndicatorPreview: View { } } +/// Version, update settings, and system details. +/// +/// Serves as the About window a menu bar app has nowhere else to put, which is +/// why the version and display details are here: they are what a user quotes +/// in a bug report. private struct SystemInfoSettingsView: View { + /// Marketing version and build number. private var version: String { let shortVersion = Bundle.main.object( forInfoDictionaryKey: "CFBundleShortVersionString" @@ -1164,10 +1413,16 @@ private struct SystemInfoSettingsView: View { return "\(shortVersion) (\(build))" } + /// The macOS version string. private var operatingSystem: String { ProcessInfo.processInfo.operatingSystemVersionString } + /// The System Info pane. + /// + /// Screens are enumerated by offset rather than by name, since two + /// identical displays report the same localized name and would collide as + /// identifiers. var body: some View { VStack(spacing: 0) { Form { @@ -1217,6 +1472,13 @@ private struct SystemInfoSettingsView: View { } } + /// A display's resolution in pixels. + /// + /// Multiplies by the backing scale so a Retina display reports its real + /// pixel count rather than its point size. + /// + /// - Parameter screen: Screen to describe. + /// - Returns: A `width × height` string. private func resolution(of screen: NSScreen) -> String { let width = Int(screen.frame.width * screen.backingScaleFactor) let height = Int(screen.frame.height * screen.backingScaleFactor) @@ -1224,11 +1486,17 @@ private struct SystemInfoSettingsView: View { } } +/// Sparkle's update preferences. private struct UpdateSettingsView: View { @ObservedObject private var updateController: UpdateController @State private var automaticallyChecksForUpdates: Bool @State private var automaticallyDownloadsUpdates: Bool + /// Seeds the toggles from Sparkle's current settings. + /// + /// Sparkle stores these itself rather than in Reprise's preferences, so + /// they cannot be `@AppStorage` and are mirrored into local state, + /// initialised here and written back on change. init() { let updateController = UpdateController.shared self.updateController = updateController @@ -1240,6 +1508,11 @@ private struct UpdateSettingsView: View { ) } + /// The update controls. + /// + /// Automatic downloading is disabled without automatic checking, since + /// there would be nothing to download from. The check button follows + /// Sparkle's own readiness so it cannot be pressed mid-check. var body: some View { Toggle( "자동으로 업데이트 확인", diff --git a/src/Reprise.Platform.MacOS/Reprise/StatusBarController.swift b/src/Reprise.Platform.MacOS/Reprise/StatusBarController.swift index 43e5cd1..a06af35 100644 --- a/src/Reprise.Platform.MacOS/Reprise/StatusBarController.swift +++ b/src/Reprise.Platform.MacOS/Reprise/StatusBarController.swift @@ -11,13 +11,39 @@ import CoreText import Darwin import SwiftUI +/// Four-character code identifying Reprise's hot key registration. +/// +/// Carbon hot keys are process-global, so the signature is what keeps this +/// registration distinct from any other in the process. private let settingsHotKeySignature: OSType = 0x5250_5253 + +/// Identifier distinguishing the Settings hot key from any future one. private let settingsHotKeyIdentifier: UInt32 = 1 +/// How a menu bar title change should be animated. enum MenuBarTitleTransitionStyle: Equatable { + /// Replace the text with no animation. case immediate + + /// Push the new line up from below, as a lyric advancing. case lyricsUpward + /// Chooses the transition for a title change. + /// + /// The upward push is reserved for one line of lyrics giving way to the + /// next within the same track. Every condition here rules out a case where + /// it would mislead: animating a track change as a lyric advance would + /// suggest continuity that is not there, and animating the first title + /// would push in from an empty menu bar. + /// + /// - Parameters: + /// - previousTitle: Title currently shown, or `nil` if none. + /// - currentTitle: Title about to be shown. + /// - previousTrackKey: Track the previous title belonged to. + /// - currentTrackKey: Track the new title belongs to. + /// - isDisplayingLyrics: Whether the title is a lyric rather than a + /// track name. + /// - Returns: The transition to use. static func resolved( previousTitle: String?, currentTitle: String, @@ -36,7 +62,17 @@ enum MenuBarTitleTransitionStyle: Equatable { } } +/// Decides whether another app activating should dismiss the player panel. enum PlayerPanelActivationPolicy { + /// Whether the panel should close because a different app came forward. + /// + /// Reprise's own windows are exempt, so opening Settings from the panel + /// does not dismiss the panel that opened it. + /// + /// - Parameters: + /// - activatedProcessIdentifier: Process that just activated. + /// - repriseProcessIdentifier: Reprise's own process. + /// - Returns: `true` when the panel should close. static func shouldDismiss( activatedProcessIdentifier: pid_t, repriseProcessIdentifier: pid_t @@ -45,6 +81,15 @@ enum PlayerPanelActivationPolicy { } } +/// Carbon callback for the Command-comma hot key. +/// +/// A C function pointer, so it cannot capture context: the delegate is passed +/// through Carbon's `userData` and recovered here. Taken unretained because +/// the delegate outlives the handler it installed. +/// +/// The identity check matters because the handler is installed on the +/// application event target and sees every hot key press in the process, not +/// only Reprise's. private let settingsHotKeyEventHandler: EventHandlerUPP = { _, event, userData in guard let event, let userData else { @@ -76,6 +121,13 @@ private let settingsHotKeyEventHandler: EventHandlerUPP = { return noErr } +/// Owns the menu bar item, the player panel, and the app's lifecycle. +/// +/// The panel is a borderless `NSPanel` rather than an `NSPopover` because it +/// has to stay open while the user interacts with other Reprise windows and +/// close on its own terms otherwise - behaviour a popover's dismissal model +/// does not allow. That choice is why dismissal is assembled by hand from +/// three separate monitors below. @MainActor final class RepriseAppDelegate: NSObject, NSApplicationDelegate { private let store = NowPlayingStore() @@ -89,6 +141,15 @@ final class RepriseAppDelegate: NSObject, NSApplicationDelegate { private var settingsHotKeyHandler: EventHandlerRef? private var instanceLock: RepriseInstanceLock? + /// Builds the menu bar item and panel, and starts polling. + /// + /// Two guards come first. Under XCTest the app target is loaded by the + /// test host, and proceeding would put a status item in the tester's menu + /// bar and send Apple Events to their music apps. The instance lock then + /// ensures only one Reprise runs: two would fight over the bridge port and + /// each show their own menu bar item. + /// + /// - Parameter notification: Launch notification; unused. func applicationDidFinishLaunching(_ notification: Notification) { // Unit-test hosts also load the app target. Avoid creating a status // item or sending Apple Events to the user's media apps during tests. @@ -139,6 +200,12 @@ final class RepriseAppDelegate: NSObject, NSApplicationDelegate { store.start() } + /// Tears down monitors and the Carbon hot key registration. + /// + /// Carbon hot keys and event handlers are process-global and not cleaned + /// up automatically, so they are released explicitly. + /// + /// - Parameter notification: Termination notification; unused. func applicationWillTerminate(_ notification: Notification) { renderer?.invalidate() removeEventMonitors() @@ -147,6 +214,7 @@ final class RepriseAppDelegate: NSObject, NSApplicationDelegate { removeSettingsHotKeyHandler() } + /// Shows or hides the player panel. @objc private func togglePopover() { guard let button = statusItem?.button, @@ -161,6 +229,20 @@ final class RepriseAppDelegate: NSObject, NSApplicationDelegate { } } + /// Gives the panel its floating, rounded, shadowed appearance. + /// + /// The panel window is transparent and the rounding lives on the content + /// layer, so the SwiftUI content is what gets clipped rather than the + /// window. `canJoinAllSpaces` and `fullScreenAuxiliary` let it appear over + /// a full-screen app, which a menu bar utility has to do; `.transient` + /// keeps it out of Exposé and the window cycle. + /// + /// Animation is disabled because the panel is positioned manually and any + /// implicit animation shows as a slide from the wrong place. + /// + /// - Parameters: + /// - panel: Panel to configure. + /// - contentView: Its hosted content view. private func configurePanelAppearance( _ panel: PlayerPanel, contentView: NSView @@ -189,6 +271,15 @@ final class RepriseAppDelegate: NSObject, NSApplicationDelegate { .cgColor } + /// Positions and shows the panel, then arms its dismissal. + /// + /// `orderFrontRegardless` is required because the panel is + /// non-activating: an ordinary `orderFront` would not show it without + /// bringing Reprise forward. + /// + /// - Parameters: + /// - panel: Panel to show. + /// - button: Status item button to anchor beneath. private func showPlayerPanel( _ panel: PlayerPanel, below button: NSStatusBarButton @@ -204,6 +295,19 @@ final class RepriseAppDelegate: NSObject, NSApplicationDelegate { registerSettingsHotKey() } + /// Sizes the panel to its content and places it under the status item. + /// + /// Width is fixed while height follows the content, so the panel grows for + /// lyrics without changing shape. Placement is clamped to the screen's + /// visible frame, which matters for a status item near the right edge + /// where the panel would otherwise hang off screen. + /// + /// The x origin is floored to a device pixel: a half-pixel window origin + /// makes the whole panel's text render blurred. + /// + /// - Parameters: + /// - panel: Panel to position. + /// - button: Status item button to anchor beneath. private func layoutPlayerPanel( _ panel: PlayerPanel, below button: NSStatusBarButton @@ -237,6 +341,12 @@ final class RepriseAppDelegate: NSObject, NSApplicationDelegate { panel.setFrameOrigin(origin) } + /// Re-sizes the panel after its SwiftUI content changes height. + /// + /// Resized twice on purpose. The yield catches the new size once SwiftUI + /// has laid out, and the delayed second pass catches content that animates + /// into place - the lyrics section expanding - where the fitting size + /// keeps growing after the first measurement. @objc private func playerPanelContentSizeDidChange() { Task { @MainActor [weak self] in @@ -248,6 +358,7 @@ final class RepriseAppDelegate: NSObject, NSApplicationDelegate { } } + /// Re-runs panel layout, but only while it is on screen. private func resizePlayerPanelIfVisible() { guard let playerPanel, playerPanel.isVisible, @@ -257,6 +368,10 @@ final class RepriseAppDelegate: NSObject, NSApplicationDelegate { layoutPlayerPanel(playerPanel, below: button) } + /// Hides the panel and disarms its dismissal machinery. + /// + /// The hot key is unregistered here so Command-comma only reaches Reprise + /// while the panel is open, leaving it to other apps otherwise. private func closePlayerPanel() { playerPanel?.orderOut(nil) NotificationCenter.default.post( @@ -268,6 +383,15 @@ final class RepriseAppDelegate: NSObject, NSApplicationDelegate { unregisterSettingsHotKey() } + /// Arms the three ways the panel gets dismissed. + /// + /// Three are needed because no single one covers every case. The local + /// monitor sees clicks inside Reprise, where a `nil` window means the + /// click landed on the menu bar or the desktop rather than one of our + /// windows. The global monitor sees clicks in other apps, and checks the + /// point against Reprise's windows because a click on Settings arrives + /// there too. The workspace observer catches an app being activated + /// without a click at all - through Command-Tab, or a launch. private func installEventMonitors() { removeEventMonitors() @@ -326,6 +450,14 @@ final class RepriseAppDelegate: NSObject, NSApplicationDelegate { } } + /// Whether a screen point lands on one of Reprise's own windows. + /// + /// The status bar window is excluded so clicking the menu bar item reaches + /// the toggle rather than being read as a click inside the app, which + /// would leave the panel open. + /// + /// - Parameter point: Point in screen coordinates. + /// - Returns: `true` when the point is over a Reprise window. private func isPointInsideRepriseWindow(_ point: NSPoint) -> Bool { let statusBarWindow = statusItem?.button?.window @@ -336,6 +468,10 @@ final class RepriseAppDelegate: NSObject, NSApplicationDelegate { } } + /// Disarms every dismissal monitor. + /// + /// Called both on close and before re-arming, so monitors never stack up + /// across repeated openings. private func removeEventMonitors() { if let localEventMonitor { NSEvent.removeMonitor(localEventMonitor) @@ -353,6 +489,11 @@ final class RepriseAppDelegate: NSObject, NSApplicationDelegate { } } + /// Installs the Carbon handler that receives hot key presses. + /// + /// Installed once for the app's lifetime, separate from registering the + /// key itself, which comes and goes with the panel. `passUnretained` is + /// safe because the delegate outlives the handler. private func installSettingsHotKeyHandler() { guard settingsHotKeyHandler == nil else { return } @@ -371,12 +512,22 @@ final class RepriseAppDelegate: NSObject, NSApplicationDelegate { ) } + /// Removes the Carbon event handler. private func removeSettingsHotKeyHandler() { guard let settingsHotKeyHandler else { return } RemoveEventHandler(settingsHotKeyHandler) self.settingsHotKeyHandler = nil } + /// Claims Command-comma while the panel is open. + /// + /// A Carbon hot key is system-wide, which is the only way to catch the + /// shortcut for a non-activating panel that never becomes key. It is + /// therefore registered only while the panel is visible, so Reprise does + /// not hold Command-comma away from whatever app the user is in. + /// + /// A failed registration is ignored: another app may already hold the + /// combination, and the gear button remains. private func registerSettingsHotKey() { guard settingsHotKey == nil else { return } @@ -397,12 +548,18 @@ final class RepriseAppDelegate: NSObject, NSApplicationDelegate { settingsHotKey = hotKey } + /// Releases Command-comma back to the system. private func unregisterSettingsHotKey() { guard let settingsHotKey else { return } UnregisterEventHotKey(settingsHotKey) self.settingsHotKey = nil } + /// Opens Settings in response to the hot key. + /// + /// Activation comes first because the panel is non-activating, so Reprise + /// is not frontmost and the Settings window would otherwise open behind + /// whatever the user is looking at. fileprivate func openSettingsFromHotKey() { NSApp.activate(ignoringOtherApps: true) NotificationCenter.default.post( @@ -412,9 +569,18 @@ final class RepriseAppDelegate: NSObject, NSApplicationDelegate { } } +/// Holds an exclusive lock so only one Reprise runs at a time. +/// +/// A file lock rather than a bundle-identifier check, because it is released +/// by the kernel when the process dies: a crashed Reprise leaves nothing +/// behind that would block the next launch. private final class RepriseInstanceLock { private let fileDescriptor: Int32 + /// Acquires the lock, or fails when another instance holds it. + /// + /// `LOCK_NB` makes this fail immediately rather than wait, so a second + /// launch terminates at once instead of hanging invisibly. init?() { let lockURL = FileManager.default.temporaryDirectory .appendingPathComponent("dev.junx.Reprise.instance.lock") @@ -431,14 +597,22 @@ private final class RepriseInstanceLock { self.fileDescriptor = fileDescriptor } + /// Releases the lock and closes the descriptor. deinit { flock(fileDescriptor, LOCK_UN) Darwin.close(fileDescriptor) } } +/// Borderless floating window hosting the player panel. +/// +/// `nonactivatingPanel` is what lets the panel appear without pulling Reprise +/// to the front, so opening it does not take focus from the user's work. @MainActor private final class PlayerPanel: NSPanel { + /// Creates the panel around its content controller. + /// + /// - Parameter contentViewController: Controller hosting the SwiftUI panel. init(contentViewController: NSViewController) { super.init( contentRect: NSRect(origin: .zero, size: PlayerPanelLayout.defaultSize), @@ -451,21 +625,56 @@ private final class PlayerPanel: NSPanel { becomesKeyOnlyIfNeeded = true } + /// Allows the panel to take key status. + /// + /// Overridden because a borderless window refuses key by default, which + /// would leave the sliders and buttons inside unable to receive events. override var canBecomeKey: Bool { true } + /// Keeps the panel from becoming the main window. + /// + /// Becoming main would make Reprise present as the active application, + /// which is exactly what a non-activating panel exists to avoid. override var canBecomeMain: Bool { false } } +/// Geometry for the player panel. enum PlayerPanelLayout { + /// Panel size before content is measured. static let defaultSize = NSSize(width: 360, height: 140) + + /// Corner rounding of the panel's content. static let cornerRadius: CGFloat = 16 + + /// Gap between the status item and the panel's top edge. static let anchorSpacing: CGFloat = 5 + + /// Minimum gap kept between the panel and the screen edges. static let screenMargin: CGFloat = 6 + /// Places the panel under its status item, kept on screen. + /// + /// Aligns the panel's left edge with the status item, then clamps it + /// within the visible frame, which is what keeps a panel anchored near the + /// right of the menu bar fully visible. + /// + /// The clamp is written so the lower bound wins on a screen too narrow to + /// hold the panel: better to overflow the right edge, away from the menu + /// bar's controls, than the left. + /// + /// The x origin is floored to a device pixel, since a fractional window + /// origin blurs everything drawn inside it. + /// + /// - Parameters: + /// - anchorFrame: Status item frame in screen coordinates. + /// - panelSize: Size of the panel. + /// - visibleFrame: Usable area of the screen. + /// - backingScale: Display scale factor. + /// - Returns: The panel's origin in screen coordinates. static func origin( anchorFrame: NSRect, panelSize: NSSize, @@ -484,6 +693,15 @@ enum PlayerPanelLayout { } } +/// Keeps the menu bar item's contents in step with the store. +/// +/// The status item hosts a custom view rather than using its title and image, +/// because the menu bar has to show a scrolling title, a rotating disc, or an +/// animated level meter - none of which `NSStatusItem` provides. +/// +/// Rebuilding that view is expensive, so every update is gated on a content +/// key: the store changes several times a second and almost none of those +/// changes alter what the menu bar draws. @MainActor private final class MenuBarStatusRenderer: NSObject { private let statusItem: NSStatusItem @@ -494,6 +712,19 @@ private final class MenuBarStatusRenderer: NSObject { private var lastRenderedTrackKey: String? private var isPanelVisible = false + /// Installs the marquee view into the status item and starts observing. + /// + /// The button's own title and image are cleared, since the custom view + /// draws everything; constraints pin it to the button so the status item's + /// variable length governs the size. + /// + /// `UserDefaults.didChangeNotification` is observed so a settings change + /// is reflected at once without a channel of its own. + /// + /// - Parameters: + /// - statusItem: Status item to render into. + /// - store: Store to read playback state from. + /// - onClick: Called when the item is clicked. init( statusItem: NSStatusItem, store: NowPlayingStore, @@ -507,6 +738,9 @@ private final class MenuBarStatusRenderer: NSObject { button.image = nil button.title = "" marqueeView.onClick = onClick + marqueeView.onAppearanceChange = { [weak self] in + self?.updateContent() + } marqueeView.translatesAutoresizingMaskIntoConstraints = false button.addSubview(marqueeView) @@ -536,12 +770,22 @@ private final class MenuBarStatusRenderer: NSObject { updateContent() } + /// Stops observing and animating, before termination. + /// + /// The store's callback is cleared explicitly because it holds a closure + /// referencing this renderer. func invalidate() { NotificationCenter.default.removeObserver(self) store.onMenuBarContentChange = nil marqueeView.stopAnimation() } + /// Notes whether the panel is open and pauses the marquee if wanted. + /// + /// The setting exists because a title scrolling in the menu bar while the + /// same title sits still in the open panel is distracting. + /// + /// - Parameter isVisible: Whether the panel is on screen. func setPanelVisible(_ isVisible: Bool) { isPanelVisible = isVisible let preferences = MarqueePreferences.current() @@ -550,6 +794,17 @@ private final class MenuBarStatusRenderer: NSObject { ) } + /// Rebuilds the menu bar item from the current state. + /// + /// The title resolves in priority order: hidden wins outright, then a + /// lyric if lyrics are enabled and one is showing, then the formatted + /// track title. + /// + /// The content key check is what makes this cheap enough to call as often + /// as it is - it returns immediately unless something visible changed. + /// + /// The status item's length is set from the measured content width, since + /// a variable-length item does not size itself to a hosted view. @objc private func updateContent() { guard let button = statusItem.button else { return } @@ -584,7 +839,8 @@ private final class MenuBarStatusRenderer: NSObject { let contentKey = Self.contentKey( title: title, snapshot: snapshot, - preferences: preferences + preferences: preferences, + appearanceName: button.effectiveAppearance.name.rawValue ) guard contentKey != lastContentKey else { return } lastContentKey = contentKey @@ -618,19 +874,41 @@ private final class MenuBarStatusRenderer: NSObject { statusItem.length = contentWidth + MenuBarMarquee.horizontalPadding } + /// Rebuilds the item in response to a notification. + /// + /// - Parameter notification: Triggering notification; unused, since both + /// observed names lead to the same full rebuild. @objc private func refreshRequested(_ notification: Notification) { updateContent() } + /// Builds a string that changes only when the menu bar's look does. + /// + /// Every input that affects rendering is folded in, and nothing else: + /// position and volume are excluded because they change constantly and + /// are not drawn. Artwork contributes only its byte count, which is enough + /// to notice a different cover without hashing the image. + /// + /// - Parameters: + /// - title: Text about to be drawn. + /// - snapshot: Player state, or `nil`. + /// - preferences: Current appearance settings. + /// - appearanceName: Name of the menu bar's effective appearance. Part + /// of the key because the title and glyphs are baked with a colour + /// resolved from it, so a light-to-dark switch changes what is drawn + /// without changing anything else here. + /// - Returns: A key to compare against the previous render. private static func contentKey( title: String, snapshot: PlayerSnapshot?, - preferences: MarqueePreferences + preferences: MarqueePreferences, + appearanceName: String ) -> String { guard let snapshot, let track = snapshot.track else { return [ "none", + appearanceName, title, String(preferences.automaticallyScrollsTitles), String(describing: preferences.pointsPerSecond), @@ -645,6 +923,7 @@ private final class MenuBarStatusRenderer: NSObject { return [ snapshot.player.rawValue, snapshot.state.rawValue, + appearanceName, // Synced lyrics change independently of the track metadata. // Include the rendered title so every lyric line invalidates // the menu bar content. @@ -664,6 +943,15 @@ private final class MenuBarStatusRenderer: NSObject { ].joined(separator: "|") } + /// Identifies a track, for deciding whether a title change stayed within + /// one song. + /// + /// Distinct from the content key: this ignores appearance settings and the + /// rendered title, so it changes only when the actual track does. That is + /// what lets a lyric advance be animated while a track change is not. + /// + /// - Parameter snapshot: Player state, or `nil`. + /// - Returns: The track identity, or `nil` when no track is loaded. private static func trackKey( for snapshot: PlayerSnapshot? ) -> String? { @@ -680,10 +968,26 @@ private final class MenuBarStatusRenderer: NSObject { } } +/// The custom view drawn inside the menu bar item. +/// +/// Draws a leading visual - cover, spinning disc, or level meter - beside a +/// title that scrolls when it overflows. Built from Core Animation layers +/// rather than `draw(_:)` so the animations run on the render server and cost +/// the app nothing per frame, which matters for a view that may animate +/// continuously for hours. @MainActor private final class MenuBarMarqueeView: NSView { + /// Called when the item is clicked. var onClick: (() -> Void)? + /// Called when the menu bar's appearance changes. + /// + /// Everything this view draws is baked into layer contents with a resolved + /// colour, so a light-to-dark switch has to redraw rather than restyle. + /// The renderer owns the values needed to rebuild, so it is asked to run + /// the update again rather than this view caching its own arguments. + var onAppearanceChange: (() -> Void)? + private let artworkLayer = CALayer() private let artworkMaskLayer = CAShapeLayer() private let indicatorLayer = CALayer() @@ -701,6 +1005,13 @@ private final class MenuBarMarqueeView: NSView { private var marqueeStartedAt: CFTimeInterval? private var isPaused = false + /// Builds the layer tree. + /// + /// The title layers sit inside a clipping viewport layer, which is what + /// bounds the scrolling text; the view's own layer must not clip, or the + /// panel's shadow would be cut off. + /// + /// - Parameter frameRect: Initial frame. override init(frame frameRect: NSRect) { super.init(frame: frameRect) @@ -715,7 +1026,6 @@ private final class MenuBarMarqueeView: NSView { indicatorLayer.masksToBounds = false layer?.addSublayer(indicatorLayer) for bar in indicatorBars { - bar.backgroundColor = NSColor.white.cgColor bar.cornerRadius = 1 indicatorLayer.addSublayer(bar) } @@ -730,19 +1040,75 @@ private final class MenuBarMarqueeView: NSView { scrollingLayer.addSublayer(secondTitleLayer) } + /// Unavailable; this view is never loaded from a nib. + /// + /// - Parameter coder: Unarchiver; unused. @available(*, unavailable) required init?(coder: NSCoder) { nil } + /// Claims every click within the view. + /// + /// Returns `self` unconditionally so a click anywhere - including the gaps + /// between layers - opens the panel, rather than falling through to the + /// status button and doing nothing. + /// + /// - Parameter point: Point being tested; unused. + /// - Returns: Always this view. override func hitTest(_ point: NSPoint) -> NSView? { self } + /// Opens the panel on click. + /// + /// - Parameter event: Mouse event; unused. override func mouseDown(with event: NSEvent) { onClick?() } + /// Redraws when the menu bar switches between light and dark. + /// + /// The menu bar does not only follow the system appearance: in Light Mode + /// it turns dark over a dark desktop picture, and back again. Each of those + /// arrives here, and none of them changes the content the renderer would + /// otherwise notice, so without this the item keeps whatever colour it was + /// last baked with. + override func viewDidChangeEffectiveAppearance() { + super.viewDidChangeEffectiveAppearance() + onAppearanceChange?() + } + + /// Text colour that reads against the menu bar as it is right now. + /// + /// Resolved from this view's own effective appearance rather than the + /// app's, because a status item takes the menu bar's appearance - which is + /// what the item is actually drawn on, and which differs from the app's + /// whenever the menu bar is darkened over a desktop picture. + private var menuBarForegroundColor: NSColor { + MenuBarMarquee.foregroundColor(for: effectiveAppearance) + } + + /// Redraws the item and reports how wide it needs to be. + /// + /// The width has to be returned rather than derived from layout, because + /// the status item's length is set by the caller and Auto Layout inside a + /// status button does not drive it. + /// + /// Text width is capped differently for lyrics than for titles, since a + /// lyric line is generally longer and the user sets its budget explicitly. + /// + /// - Parameters: + /// - title: Text to display. + /// - artworkData: Cover art, or `nil`. + /// - symbolName: Fallback SF Symbol when there is no cover. + /// - artworkStyle: Which leading visual to draw. + /// - isPlaying: Whether to animate the disc or meter. + /// - reservesTextWidth: Whether to hold the full text width even when + /// the text is shorter, so the item does not resize on every lyric. + /// - transitionStyle: How to animate the title change. + /// - preferences: Current appearance settings. + /// - Returns: The width the status item should take. func update( title: String, artworkData: Data?, @@ -770,9 +1136,14 @@ private final class MenuBarMarqueeView: NSView { ) let availableHeight = max(bounds.height, NSStatusBar.system.thickness) let scale = window?.backingScaleFactor ?? NSScreen.main?.backingScaleFactor ?? 2 + let foregroundColor = menuBarForegroundColor let titleBitmap = title.isEmpty ? nil - : Self.titleBitmap(title, scale: scale) + : Self.titleBitmap( + title, + scale: scale, + foregroundColor: foregroundColor + ) let titleHeight = titleBitmap?.pointSize.height ?? MenuBarMarquee.font.pointSize let titleY = MenuBarMarquee.titleOriginY( availableHeight: availableHeight, @@ -793,12 +1164,14 @@ private final class MenuBarMarqueeView: NSView { ) artworkLayer.contents = Self.artworkContents( data: artworkData, - symbolName: symbolName + symbolName: symbolName, + foregroundColor: foregroundColor ) updateLeadingVisual( style: artworkStyle, availableHeight: availableHeight, - isPlaying: isPlaying + isPlaying: isPlaying, + foregroundColor: foregroundColor ) let textOrigin = MenuBarMarquee.leadingVisualWidth( @@ -868,6 +1241,13 @@ private final class MenuBarMarqueeView: NSView { return contentWidth } + /// Arms the push animation for a lyric line change. + /// + /// A `CATransition` on the viewport animates whatever its contents become + /// next, so it is added before the new bitmap is assigned. Any previous + /// transition is removed first, or a rapid change would stack them. + /// + /// - Parameter style: Transition to apply. private func prepareTitleTransition( _ style: MenuBarTitleTransitionStyle ) { @@ -889,10 +1269,22 @@ private final class MenuBarMarqueeView: NSView { ) } + /// Switches between the cover, disc, and meter treatments. + /// + /// Every branch stops the animations belonging to the other styles, so a + /// style change cannot leave a rotation or a meter running on a hidden + /// layer and burning frames. + /// + /// - Parameters: + /// - style: Visual to show. + /// - availableHeight: Height of the menu bar item. + /// - isPlaying: Whether the visual should animate. + /// - foregroundColor: Colour resolved for the current menu bar. private func updateLeadingVisual( style: MenuBarArtworkStyle, availableHeight: CGFloat, - isPlaying: Bool + isPlaying: Bool, + foregroundColor: NSColor ) { artworkLayer.isHidden = style == .levelIndicator || style == .hidden indicatorLayer.isHidden = style != .levelIndicator @@ -911,7 +1303,10 @@ private final class MenuBarMarqueeView: NSView { case .levelIndicator: artworkLayer.mask = nil stopDiscAnimation() - layoutIndicator(availableHeight: availableHeight) + layoutIndicator( + availableHeight: availableHeight, + foregroundColor: foregroundColor + ) updateIndicatorAnimation(isPlaying: isPlaying) case .hidden: artworkLayer.mask = nil @@ -920,6 +1315,10 @@ private final class MenuBarMarqueeView: NSView { } } + /// Cuts the centre hole that makes the cover read as a record. + /// + /// Two ellipses with an even-odd fill rule: the outer one rounds the + /// artwork and the inner one punches the spindle hole out of it. private func updateDiscMask() { let bounds = CGRect( origin: .zero, @@ -943,6 +1342,12 @@ private final class MenuBarMarqueeView: NSView { artworkLayer.mask = artworkMaskLayer } + /// Spins the disc while playing. + /// + /// Left alone if already running, so the rotation continues smoothly + /// across a track change instead of snapping back to zero. + /// + /// - Parameter isPlaying: Whether playback is advancing. private func updateDiscAnimation(isPlaying: Bool) { guard isPlaying else { stopDiscAnimation() @@ -962,12 +1367,24 @@ private final class MenuBarMarqueeView: NSView { artworkLayer.add(animation, forKey: "discRotation") } + /// Stops the disc and returns it upright. private func stopDiscAnimation() { artworkLayer.removeAnimation(forKey: "discRotation") artworkLayer.setAffineTransform(.identity) } - private func layoutIndicator(availableHeight: CGFloat) { + /// Positions the level meter's bars. + /// + /// Heights are deliberately uneven so the meter reads as a level display + /// even when paused and still. + /// + /// - Parameters: + /// - availableHeight: Height of the menu bar item. + /// - foregroundColor: Colour resolved for the current menu bar. + private func layoutIndicator( + availableHeight: CGFloat, + foregroundColor: NSColor + ) { indicatorLayer.frame = CGRect( x: 0, y: (availableHeight - MenuBarMarquee.artworkSize) / 2, @@ -977,6 +1394,7 @@ private final class MenuBarMarqueeView: NSView { for (index, bar) in indicatorBars.enumerated() { let height = MenuBarMarquee.indicatorBarHeights[index] + bar.backgroundColor = foregroundColor.cgColor bar.frame = CGRect( x: MenuBarMarquee.indicatorHorizontalInset + CGFloat(index) @@ -991,6 +1409,14 @@ private final class MenuBarMarqueeView: NSView { } } + /// Animates the level meter while playing. + /// + /// Each bar gets a slightly different duration and start delay, so they + /// drift out of phase and never resynchronise into an obvious loop. The + /// meter is decorative - it reflects that audio is playing, not its actual + /// level, which the players do not expose. + /// + /// - Parameter isPlaying: Whether playback is advancing. private func updateIndicatorAnimation(isPlaying: Bool) { guard isPlaying else { stopIndicatorAnimation() @@ -1010,12 +1436,14 @@ private final class MenuBarMarqueeView: NSView { } } + /// Freezes the level meter at its resting heights. private func stopIndicatorAnimation() { for bar in indicatorBars { bar.removeAnimation(forKey: "level") } } + /// Halts the marquee and returns the title to its start. func stopAnimation() { scrollingLayer.removeAnimation(forKey: "marquee") scrollingLayer.removeAnimation(forKey: "returnToStart") @@ -1024,6 +1452,12 @@ private final class MenuBarMarqueeView: NSView { marqueeStartedAt = nil } + /// Pauses or resumes scrolling as the panel opens and closes. + /// + /// Pausing rewinds through ``returnToStart()`` rather than freezing where + /// it is, so reopening the panel does not leave the title stopped mid-word. + /// + /// - Parameter paused: Whether scrolling should stop. func setPaused(_ paused: Bool) { guard isPaused != paused else { return } isPaused = paused @@ -1042,6 +1476,18 @@ private final class MenuBarMarqueeView: NSView { ) } + /// Rewinds a scrolled title with a short glide-and-fade. + /// + /// Snapping back would read as a glitch, so the title continues briefly in + /// its direction of travel while fading out, then reappears at the start. + /// The jump happens under cover of zero opacity, which is what the tightly + /// spaced key times at 0.4 achieve. + /// + /// Skipped entirely when the title was resting in its initial pause, since + /// there is nothing to rewind and the animation would be noise. + /// + /// The current offset is read from the presentation layer, the only place + /// the in-flight animated value exists. private func returnToStart() { let wasMoving = isMarqueeInMotion let currentX = CGFloat(( @@ -1082,6 +1528,11 @@ private final class MenuBarMarqueeView: NSView { scrollingLayer.add(transition, forKey: "returnToStart") } + /// Whether the title is currently moving rather than resting. + /// + /// Derived from elapsed time within the animation's cycle rather than read + /// from Core Animation, which exposes no such state. Used to decide + /// whether a rewind animation is worth playing at all. private var isMarqueeInMotion: Bool { guard let marqueeStartedAt, let pendingAnimation else { @@ -1100,6 +1551,15 @@ private final class MenuBarMarqueeView: NSView { return cycleTime > MenuBarMarquee.initialPause } + /// Starts the pixel-aligned scroll. + /// + /// The start time is recorded so ``isMarqueeInMotion`` can work out where + /// in its cycle the animation is. + /// + /// - Parameters: + /// - distance: Travel distance in points. + /// - scale: Backing scale factor. + /// - pointsPerSecond: Scroll speed. private func startAnimation( distance: CGFloat, scale: CGFloat, @@ -1118,15 +1578,29 @@ private final class MenuBarMarqueeView: NSView { marqueeStartedAt = CACurrentMediaTime() } + /// Rasterises the menu bar title. + /// + /// The colour is baked into the bitmap and cannot adapt afterwards, since + /// layer contents are composited as-is. It therefore has to be resolved + /// for the menu bar's current appearance by the caller, and the bitmap + /// rebuilt whenever that appearance changes. + /// + /// - Parameters: + /// - title: Text to draw. + /// - scale: Backing scale factor. + /// - foregroundColor: Colour resolved for the current menu bar. + /// - Returns: The image and its point size, or `nil` if the context could + /// not be created. private static func titleBitmap( _ title: String, - scale: CGFloat + scale: CGFloat, + foregroundColor: NSColor ) -> (image: CGImage, pointSize: CGSize)? { let attributedTitle = NSAttributedString( string: title, attributes: [ .font: MenuBarMarquee.font, - .foregroundColor: NSColor.white, + .foregroundColor: foregroundColor, .kern: MenuBarMarquee.characterSpacing, ] ) @@ -1169,9 +1643,28 @@ private final class MenuBarMarqueeView: NSView { return (image, pointSize) } + /// Resolves what to draw in the artwork slot. + /// + /// Falls back to the player's SF Symbol, so the slot is never empty and + /// the item's width does not change when a track happens to have no cover. + /// + /// Real cover art is used as-is; the fallback symbol is tinted, because a + /// symbol rasterises to a single opaque colour that would disappear + /// against half the menu bar appearances. Tinting is done by drawing the + /// glyph and filling through it with `sourceIn`, which keeps the fill only + /// where the glyph is opaque. + /// + /// - Parameters: + /// - data: Cover art, or `nil`. + /// - symbolName: Fallback SF Symbol name. + /// - foregroundColor: Colour resolved for the current menu bar. + /// - Returns: The image, or `nil` if neither could be produced. Falls back + /// to the untinted glyph if a drawing context cannot be created, since a + /// symbol in the wrong colour still beats an empty slot. private static func artworkContents( data: Data?, - symbolName: String + symbolName: String, + foregroundColor: NSColor ) -> CGImage? { if let data, let image = NSImage(data: data), @@ -1182,36 +1675,140 @@ private final class MenuBarMarqueeView: NSView { guard let symbol = NSImage( systemSymbolName: symbolName, accessibilityDescription: nil - ) else { + ), + let glyph = symbol.cgImage( + forProposedRect: nil, + context: nil, + hints: nil + ) else { return nil } - return symbol.cgImage(forProposedRect: nil, context: nil, hints: nil) + + guard let context = CGContext( + data: nil, + width: glyph.width, + height: glyph.height, + bitsPerComponent: 8, + bytesPerRow: 0, + space: CGColorSpaceCreateDeviceRGB(), + bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue + ) else { + return glyph + } + + let bounds = CGRect( + x: 0, + y: 0, + width: glyph.width, + height: glyph.height + ) + context.draw(glyph, in: bounds) + context.setBlendMode(.sourceIn) + context.setFillColor(foregroundColor.cgColor) + context.fill(bounds) + + return context.makeImage() ?? glyph } } +/// Metrics and measurement for the menu bar item. +/// +/// Separated from the view so the width arithmetic - which decides how much of +/// the user's menu bar Reprise occupies - can be tested without AppKit. enum MenuBarMarquee { + /// Text width budget when showing track titles. static let maximumTextWidth = CGFloat( MenuBarLyricsWidth.defaultValue ) + + /// Edge length of the leading visual. static let artworkSize: CGFloat = 18 + + /// Gap between the leading visual and the title. static let artworkTitleSpacing: CGFloat = 5 + + /// Inset of the disc's spindle hole. static let discHoleInset: CGFloat = 7 + + /// Seconds for one full rotation of the disc. static let discRotationDuration: TimeInterval = 4 + + /// Width of one level meter bar. static let indicatorBarWidth: CGFloat = 2 + + /// Gap between level meter bars. static let indicatorBarSpacing: CGFloat = 2 + + /// Inset before the first level meter bar. static let indicatorHorizontalInset: CGFloat = 2 + + /// Resting heights of the meter bars, uneven so it reads as a level. static let indicatorBarHeights: [CGFloat] = [8, 13, 10, 15] + + /// Padding added around the item's content. static let horizontalPadding: CGFloat = 8 + + /// Blank space between the two copies of a scrolling title. static let titleGap: CGFloat = 28 + + /// How long a title rests before scrolling. static let initialPause: TimeInterval = 1.4 + + /// Default scroll speed, used where no preference applies. static let pointsPerSecond: CGFloat = 30 + + /// Nudge applied after pixel alignment. + /// + /// The menu bar's own text sits fractionally lower than a purely + /// arithmetic centring puts it; this matches Reprise's title to the items + /// beside it. static let titleVerticalAdjustment: CGFloat = 0.5 + + /// How far a rewinding title glides before jumping back. static let returnGlideDistance: CGFloat = 4 + + /// Duration of the rewind transition. static let returnTransitionDuration: TimeInterval = 0.24 + + /// Duration of the lyric line push. static let lyricsTransitionDuration: TimeInterval = 0.28 + + /// The system menu bar font, at its natural size. static let font = NSFont.menuBarFont(ofSize: 0) + + /// Extra tracking between characters; none, matching system text. static let characterSpacing: CGFloat = 0 + /// Resolves the colour the menu bar item must draw itself in. + /// + /// The item is drawn into `CALayer` contents, which are composited + /// verbatim - unlike an `NSImage` marked as a template on the status + /// button, nothing tints them afterwards. So the colour has to be resolved + /// up front and baked into the bitmaps, and everything baked with it has + /// to be rebuilt when the appearance changes. + /// + /// `labelColor` is used rather than a fixed black or white so the result + /// tracks whatever the system considers legible on the menu bar, including + /// the Light Mode case where a dark desktop picture darkens the bar. + /// + /// - Parameter appearance: Appearance to resolve against, normally the + /// status item view's effective appearance. + /// - Returns: A concrete colour, safe to bake into a bitmap. + static func foregroundColor(for appearance: NSAppearance) -> NSColor { + var resolved = NSColor.labelColor + + appearance.performAsCurrentDrawingAppearance { + resolved = NSColor.labelColor.usingColorSpace(.sRGB) + ?? NSColor.labelColor + } + + return resolved + } + + /// Measures how wide text will draw in the menu bar font. + /// + /// - Parameter text: Text to measure. + /// - Returns: Width in points, rounded up. static func textWidth(_ text: String) -> CGFloat { let attributedText = NSAttributedString( string: text, @@ -1224,6 +1821,12 @@ enum MenuBarMarquee { return ceil(CGFloat(CTLineGetTypographicBounds(line, nil, nil, nil))) } + /// Whether a title is too wide to show at once. + /// + /// - Parameters: + /// - titleWidth: Measured title width. + /// - maximumWidth: Budget. Defaults to ``maximumTextWidth``. + /// - Returns: `true` when the title must scroll. static func requiresScrolling( titleWidth: CGFloat, maximumWidth: CGFloat = maximumTextWidth @@ -1231,6 +1834,17 @@ enum MenuBarMarquee { titleWidth > maximumWidth } + /// How wide the text area should be. + /// + /// Reserving the full width keeps the item from resizing as each lyric + /// line arrives, which would jitter every status item to its left. + /// + /// - Parameters: + /// - titleWidth: Measured title width. + /// - reservesMaximumTextWidth: Whether to always claim the full budget. + /// Defaults to `false`. + /// - maximumWidth: Budget. Defaults to ``maximumTextWidth``. + /// - Returns: Width of the text viewport in points. static func viewportWidth( for titleWidth: CGFloat, reservesMaximumTextWidth: Bool = false, @@ -1241,6 +1855,15 @@ enum MenuBarMarquee { : min(titleWidth, maximumWidth) } + /// Width the leading visual occupies, including its trailing gap. + /// + /// The gap is dropped when there is no title, so an artwork-only item is + /// not padded on its right. + /// + /// - Parameters: + /// - artworkStyle: Which visual is shown. + /// - titleWidth: Measured title width. + /// - Returns: Width in points; 0 when the visual is hidden. static func leadingVisualWidth( for artworkStyle: MenuBarArtworkStyle, titleWidth: CGFloat @@ -1249,6 +1872,15 @@ enum MenuBarMarquee { return artworkSize + (titleWidth > 0 ? artworkTitleSpacing : 0) } + /// Total content width of the menu bar item. + /// + /// - Parameters: + /// - titleWidth: Measured title width. + /// - artworkStyle: Which visual is shown. Defaults to album artwork. + /// - reservesMaximumTextWidth: Whether to always claim the full text + /// budget. Defaults to `false`. + /// - maximumWidth: Text budget. Defaults to ``maximumTextWidth``. + /// - Returns: Width in points, before the status item's padding. static func totalWidth( for titleWidth: CGFloat, artworkStyle: MenuBarArtworkStyle = .albumArtwork, @@ -1265,6 +1897,17 @@ enum MenuBarMarquee { ) } + /// Where the title bitmap should sit vertically. + /// + /// Centres the glyphs by cap height and descender rather than centring the + /// bitmap box, then snaps to a device pixel and applies + /// ``titleVerticalAdjustment`` so Reprise's title lines up with the system + /// items beside it. + /// + /// - Parameters: + /// - availableHeight: Height of the menu bar item. + /// - scale: Backing scale factor. + /// - Returns: The bitmap's y origin in points. static func titleOriginY( availableHeight: CGFloat, scale: CGFloat @@ -1276,6 +1919,17 @@ enum MenuBarMarquee { return pixelAlignedOrigin + titleVerticalAdjustment } + /// Where a scrolling title would be after a given elapsed time. + /// + /// The animation itself runs on the render server, so this is not used to + /// drive it; it expresses the same timing in a form that can be asserted + /// against, covering the initial pause and the constant-speed travel. + /// + /// - Parameters: + /// - elapsed: Seconds since the scroll began. + /// - titleWidth: Measured title width. + /// - Returns: The horizontal offset, 0 or negative. Always 0 for a title + /// that fits or during the initial pause. static func offset(elapsed: TimeInterval, titleWidth: CGFloat) -> CGFloat { guard requiresScrolling(titleWidth: titleWidth) else { return 0 } diff --git a/src/Reprise.Platform.MacOS/Reprise/UpdateController.swift b/src/Reprise.Platform.MacOS/Reprise/UpdateController.swift index 2a92900..23e1865 100644 --- a/src/Reprise.Platform.MacOS/Reprise/UpdateController.swift +++ b/src/Reprise.Platform.MacOS/Reprise/UpdateController.swift @@ -8,17 +8,38 @@ import Combine import Sparkle +/// Wraps Sparkle so the rest of the app can offer updates without importing it. +/// +/// Sparkle exposes its readiness through KVO, which SwiftUI cannot observe +/// directly; this bridges that into a published property. The updater is also +/// deliberately not started in `init`, so simply constructing the controller +/// - as previews and tests do - never reaches the network. @MainActor final class UpdateController: ObservableObject { + /// The app-wide updater. + /// + /// A singleton because Sparkle expects one updater per process; a second + /// would race the first over the same feed and user defaults. static let shared = UpdateController() + /// The underlying Sparkle controller, exposed for its standard UI. let updaterController: SPUStandardUpdaterController + /// Whether an update check can be started right now. + /// + /// False while a check is already running or the updater has not started, + /// and used to disable the menu item rather than let a press do nothing. @Published private(set) var canCheckForUpdates = false private var canCheckObservation: AnyCancellable? private var hasStarted = false + /// Builds the Sparkle controller and bridges its KVO state. + /// + /// `startingUpdater: false` defers all network activity to ``start()``, + /// keeping app launch free of an update check the user did not ask for. + /// The observation is delivered on the main run loop because it drives + /// published state that SwiftUI reads. private init() { updaterController = SPUStandardUpdaterController( startingUpdater: false, @@ -35,29 +56,46 @@ final class UpdateController: ObservableObject { } } + /// Whether Sparkle checks for updates on its own schedule. var automaticallyChecksForUpdates: Bool { updaterController.updater.automaticallyChecksForUpdates } + /// Whether Sparkle downloads a found update without asking first. var automaticallyDownloadsUpdates: Bool { updaterController.updater.automaticallyDownloadsUpdates } + /// Starts the updater, at most once per process. + /// + /// Guarded because Sparkle raises an exception on a second start, and the + /// call sites - app launch and ``checkForUpdates()`` - can both run first + /// depending on how quickly the user opens the menu. func start() { guard !hasStarted else { return } hasStarted = true updaterController.startUpdater() } + /// Runs an update check with Sparkle's own progress UI. + /// + /// Starts the updater first, so checking works even when the user reaches + /// for it before the scheduled start has happened. func checkForUpdates() { start() updaterController.checkForUpdates(nil) } + /// Turns Sparkle's scheduled update checks on or off. + /// + /// - Parameter enabled: Whether to check automatically. func setAutomaticallyChecksForUpdates(_ enabled: Bool) { updaterController.updater.automaticallyChecksForUpdates = enabled } + /// Turns automatic downloading of found updates on or off. + /// + /// - Parameter enabled: Whether to download without prompting. func setAutomaticallyDownloadsUpdates(_ enabled: Bool) { updaterController.updater.automaticallyDownloadsUpdates = enabled } diff --git a/src/Reprise.Platform.MacOS/Reprise/YouTubeMusicBridge.swift b/src/Reprise.Platform.MacOS/Reprise/YouTubeMusicBridge.swift index 4fe4bc3..c62e8e0 100644 --- a/src/Reprise.Platform.MacOS/Reprise/YouTubeMusicBridge.swift +++ b/src/Reprise.Platform.MacOS/Reprise/YouTubeMusicBridge.swift @@ -9,7 +9,24 @@ import AppKit import Foundation import Network +/// Reprise's side of the YouTube Music browser extension link. +/// +/// Runs a loopback WebSocket server, tracks every connected browser and the +/// tabs each reports, and picks one to present as the YouTube Music "player". +/// +/// The complexity here is that this is a one-to-many bridge where the other +/// players are one-to-one: a user can have Chrome and Firefox connected at +/// once, each with several YouTube Music tabs. Most of the state below exists +/// to choose a single tab out of that, keep the choice stable while it stays +/// valid, and abandon it the moment it goes quiet. +/// +/// An actor because callbacks arrive on the network queue while commands come +/// from the main actor. actor YouTubeMusicBridge { + /// The app-wide bridge. + /// + /// Singleton because it binds a fixed port; a second instance would fail + /// to listen and leave the extension connected to the wrong one. static let shared = YouTubeMusicBridge() private let port: UInt16 @@ -29,10 +46,20 @@ actor YouTubeMusicBridge { private var artworkURL: URL? private var artworkData: Data? + /// Creates the bridge without starting it. + /// + /// - Parameter port: Port to listen on. Defaults to the protocol's fixed + /// port; injectable so tests can bind an ephemeral one. init(port: UInt16 = YouTubeMusicBridgeProtocol.port) { self.port = port } + /// Starts listening for extension connections. + /// + /// Safe to call repeatedly - the poll loop does, on every sweep - because + /// an existing server short-circuits it. A bind failure is recorded as a + /// status rather than thrown, since the caller is a poll with nowhere to + /// report to, and the settings pane surfaces it instead. func start() { guard server == nil else { return } @@ -66,6 +93,11 @@ actor YouTubeMusicBridge { } } + /// Stops the server and discards all connection state. + /// + /// Everything is cleared rather than kept for a restart, because a browser + /// reconnecting is issued a new connection id and none of the old state + /// would apply to it. func stop() async { await server?.stop() server = nil @@ -82,18 +114,34 @@ actor YouTubeMusicBridge { clearArtwork() } + /// The bridge's current state, for the settings pane. + /// + /// - Returns: The status. func connectionStatus() -> YouTubeMusicBridgeStatus { status } + /// The port actually bound, which differs when 0 was requested. + /// + /// - Returns: The port, or `nil` when not listening. func listeningPort() -> UInt16? { server?.listeningPort } + /// Version of the extension on the active connection. + /// + /// - Returns: The version, or `nil` when nothing is connected. func connectedExtensionVersion() -> String? { extensionVersion } + /// Turns single-session enforcement on or off. + /// + /// Switching it on pauses the extra tabs immediately rather than waiting + /// for the next one to start playing, since a user enabling it while three + /// tabs play expects that to take effect now. + /// + /// - Parameter enabled: Whether only one tab may play at a time. func setAutomaticallyPausesOtherSessions(_ enabled: Bool) async { guard automaticallyPausesOtherSessions != enabled else { return } @@ -105,6 +153,18 @@ actor YouTubeMusicBridge { await pausePlayingSessions(except: retainedSession) } + /// Every YouTube Music tab across every connected browser. + /// + /// Two shapes are handled. Extensions that send a session list report all + /// their tabs; older ones report only the tab they selected, and a single + /// session is synthesised from their snapshot so they still appear. + /// + /// The sort puts the active session first and is otherwise fully + /// deterministic - browser, then connection, then tab - so the settings + /// list does not reshuffle on every refresh. + /// + /// - Parameter date: Instant to judge freshness against. Defaults to now. + /// - Returns: Sessions, active first. func sessions(at date: Date = Date()) -> [YouTubeMusicSession] { _ = selectActiveConnection(at: date) @@ -195,6 +255,22 @@ actor YouTubeMusicBridge { } } + /// Presents the selected tab as an ordinary player snapshot. + /// + /// This is what makes YouTube Music look like Spotify or Music to the rest + /// of the app despite having no app of its own. + /// + /// A stale snapshot yields a running-but-stopped player rather than a + /// track: the extension is connected, so YouTube Music is available, but + /// its last report is too old to show as current. + /// + /// Position is projected forward from the observation, since the extension + /// reports far less often than the progress bar updates. Artwork is only + /// attached when the cached image belongs to this exact track, so a stale + /// cover never appears over a new one. + /// + /// - Parameter date: Instant to evaluate. Defaults to now. + /// - Returns: A snapshot, never an error. func snapshot(at date: Date = Date()) -> PlayerSnapshot { guard status.isConnected else { return .notRunning(.youtubeMusic) @@ -250,10 +326,29 @@ actor YouTubeMusicBridge { ) } + /// Sends a transport command to the selected tab. + /// + /// - Parameter command: Transport control to invoke. + /// - Throws: ``YouTubeMusicBridgeProtocolError/extensionNotConnected``, + /// ``YouTubeMusicBridgeProtocolError/commandRejected(_:)``, or + /// ``YouTubeMusicBridgeProtocolError/commandNotConfirmed``. func perform(_ command: PlaybackCommand) async throws { _ = try await send(.playback(command)) } + /// Sets the volume and waits for a snapshot confirming it. + /// + /// Waiting for the acknowledgement is not enough: it only says the command + /// was received, and the caller needs the level the page settled on. So it + /// polls for a snapshot newer than the one before the command whose volume + /// is within one step of the target, tolerating YouTube Music's own + /// rounding. + /// + /// - Parameter volume: Desired level from 0 to 100. + /// - Returns: The level the page reports. + /// - Throws: ``YouTubeMusicBridgeProtocolError/commandNotConfirmed`` if no + /// matching snapshot arrives within about two seconds, plus the errors + /// sending can raise. func setVolume(_ volume: Int) async throws -> Int { let volume = PlayerVolume.clamped(volume) let dispatch = try await send(.setVolume(volume)) @@ -270,6 +365,17 @@ actor YouTubeMusicBridge { throw YouTubeMusicBridgeProtocolError.commandNotConfirmed } + /// Seeks and waits for a snapshot confirming the new position. + /// + /// Same reasoning as ``setVolume(_:)``, at a longer interval: a seek makes + /// the page buffer, so its next snapshot takes noticeably longer to + /// arrive. + /// + /// - Parameter position: Target position in seconds. + /// - Returns: The position the page reports. + /// - Throws: ``YouTubeMusicBridgeProtocolError/commandNotConfirmed`` if no + /// matching snapshot arrives within about 1.2 seconds, plus the errors + /// sending can raise. func setPosition(_ position: TimeInterval) async throws -> TimeInterval { let position = position.isFinite ? max(position, 0) : 0 let dispatch = try await send(.seek(to: position)) @@ -292,6 +398,13 @@ actor YouTubeMusicBridge { throw YouTubeMusicBridgeProtocolError.commandNotConfirmed } + /// Records a server status change, clearing state when disconnected. + /// + /// Losing the last connection invalidates everything: connection ids are + /// not reused, so keeping snapshots would leave the panel showing a track + /// from a browser that has gone. + /// + /// - Parameter status: New server status. private func setStatus(_ status: YouTubeMusicBridgeStatus) { self.status = status if !status.isConnected { @@ -306,6 +419,21 @@ actor YouTubeMusicBridge { } } + /// Handles one message from a connection. + /// + /// Everything except `hello` requires the connection to have identified + /// itself first, so a client that skipped the handshake cannot inject + /// state. Anything that fails to decode or validate is dropped silently: + /// the sender is a browser extension with no channel to report to, and a + /// malformed message is not worth surfacing to the user. + /// + /// Snapshots and session lists each carry their own sequence number and + /// older ones are discarded, since WebSocket ordering does not survive the + /// hop through the extension's own async plumbing. + /// + /// - Parameters: + /// - data: Raw frame. + /// - connectionID: Connection it arrived on. private func receive( _ data: Data, from connectionID: UUID @@ -429,6 +557,25 @@ actor YouTubeMusicBridge { } } + /// Sends a command and waits for its acknowledgement. + /// + /// The sequence number is captured before sending so callers that need to + /// confirm an effect can tell a snapshot from after the command apart from + /// one already in flight. + /// + /// Acknowledgements are matched by command id and required to come from + /// the connection the command was sent to, so a second browser cannot + /// answer for the first. Six seconds is generous, but a page that is + /// buffering can genuinely take that long. + /// + /// - Parameters: + /// - command: Command to send. + /// - targetConnectionID: Connection to send to. Defaults to `nil`, + /// meaning the active one. + /// - Returns: The dispatch record, for confirming the effect. + /// - Throws: ``YouTubeMusicBridgeProtocolError/extensionNotConnected``, + /// ``YouTubeMusicBridgeProtocolError/commandRejected(_:)``, or + /// ``YouTubeMusicBridgeProtocolError/commandNotConfirmed``. private func send( _ command: YouTubeMusicCommandMessage, to targetConnectionID: UUID? = nil @@ -475,6 +622,13 @@ actor YouTubeMusicBridge { throw YouTubeMusicBridgeProtocolError.commandNotConfirmed } + /// Picks which playing tab should keep playing. + /// + /// Ranked by visibility, then selection, then recency: the tab the user is + /// actually looking at is the best guess at what they meant to hear. + /// + /// - Parameter date: Instant to judge freshness against. Defaults to now. + /// - Returns: The tab to retain, or `nil` when none is playing. private func preferredPlayingSession( at date: Date = Date() ) -> YouTubeMusicSessionTarget? { @@ -492,6 +646,13 @@ actor YouTubeMusicBridge { } } + /// Every tab currently playing, across all connections. + /// + /// Stale tabs are excluded: one that has stopped reporting may well have + /// been closed, and pausing it would only waste a round trip. + /// + /// - Parameter date: Instant to judge freshness against. Defaults to now. + /// - Returns: Addressable targets for the playing tabs. private func playingSessionTargets( at date: Date = Date() ) -> [YouTubeMusicSessionTarget] { @@ -514,6 +675,16 @@ actor YouTubeMusicBridge { } } + /// Pauses every playing tab except one. + /// + /// Sent concurrently, since these go to different tabs and possibly + /// different browsers; doing them in sequence would leave the last one + /// audible for as long as the earlier ones took. + /// + /// Failures are ignored: a tab that will not pause is usually one that has + /// just been closed, and there is nothing useful to tell the user. + /// + /// - Parameter retainedSession: The tab allowed to keep playing. private func pausePlayingSessions( except retainedSession: YouTubeMusicSessionTarget ) async { @@ -536,6 +707,19 @@ actor YouTubeMusicBridge { } } + /// Chooses which browser connection to present as the player. + /// + /// Stale connections are excluded outright, then the rest are ranked by + /// ``selectionPriority(for:)``. + /// + /// The tie-break is what keeps the panel steady: the current connection is + /// kept whenever it still ties for the best priority, so two browsers both + /// playing do not trade the panel back and forth as their snapshots + /// arrive. Only when it drops behind does the most recently heard-from + /// connection take over. + /// + /// - Parameter date: Instant to judge freshness against. Defaults to now. + /// - Returns: The selected connection, or `nil` when none is fresh. @discardableResult private func selectActiveConnection(at date: Date = Date()) -> UUID? { let candidates = connectionSnapshots.filter { @@ -575,6 +759,12 @@ actor YouTubeMusicBridge { return selectedConnectionID } + /// Switches the active connection and resets what belonged to the old one. + /// + /// Artwork is cleared because it was cached for the previous connection's + /// track and would otherwise appear over the new one's. + /// + /// - Parameter connectionID: Connection to activate. private func activateConnection(_ connectionID: UUID) { guard activeConnectionID != connectionID else { return } @@ -583,6 +773,17 @@ actor YouTubeMusicBridge { clearArtwork() } + /// Cleans up after a browser disconnects. + /// + /// Commands still awaiting an acknowledgement from that connection are + /// failed immediately rather than left to time out, since the reply can + /// never arrive. + /// + /// If the active connection was the one that left, another is selected at + /// once - so closing one of two browsers falls back to the other rather + /// than blanking the panel until the next poll. + /// + /// - Parameter connectionID: Connection that closed. private func connectionClosed(_ connectionID: UUID) async { connectionIdentities[connectionID] = nil connectionSnapshots[connectionID] = nil @@ -609,6 +810,22 @@ actor YouTubeMusicBridge { } } + /// Downloads cover art for the selected track. + /// + /// Skipped when the track and URL are both unchanged, so a snapshot + /// arriving several times a second does not re-download the same image. + /// + /// The response is validated the same way as Spotify artwork - status, + /// size ceiling, decode check - because the URL came from a web page. + /// + /// The conditions are re-checked after the download because it is the one + /// suspension point here long enough for everything to change: the track + /// may have moved on, or another browser taken over. Without that, a slow + /// download would land a stale cover on a track it does not belong to. + /// + /// - Parameters: + /// - snapshot: Snapshot naming the track and its artwork URL. + /// - connectionID: Connection it came from, re-checked afterwards. private func updateArtwork( for snapshot: YouTubeMusicSnapshotPayload, from connectionID: UUID @@ -651,12 +868,25 @@ actor YouTubeMusicBridge { } } + /// Discards the cached cover art. private func clearArtwork() { artworkTrackKey = nil artworkURL = nil artworkData = nil } + /// Decides which timestamp a snapshot's position was measured at. + /// + /// The extension's own capture time is more accurate than arrival, since + /// it excludes the hop through the browser and the socket. But it comes + /// from another process's clock, so it is only trusted when it sits within + /// a sane window before arrival - a skewed or malicious clock would + /// otherwise throw every position estimate off. + /// + /// - Parameters: + /// - capturedAt: Time the extension reported, or `nil`. + /// - receivedAt: Time Reprise received the message. + /// - Returns: The timestamp to anchor position estimates on. private nonisolated static func observationDate( capturedAt: Date?, receivedAt: Date @@ -669,6 +899,14 @@ actor YouTubeMusicBridge { return capturedAt } + /// Ranks a connection by how much it deserves to be the active one. + /// + /// Playing with a track beats merely having a track, which beats nothing + /// at all - so a browser sitting on a paused tab never displaces one + /// actually playing music. + /// + /// - Parameter snapshot: Snapshot to rank. + /// - Returns: 2, 1, or 0, higher being better. private nonisolated static func selectionPriority( for snapshot: YouTubeMusicSnapshotPayload ) -> Int { @@ -681,6 +919,22 @@ actor YouTubeMusicBridge { return 0 } + /// Finds a tab that has just started playing in this session list. + /// + /// Comparing against the previous list is what makes this a transition + /// rather than a state: without it, every session message would re-pause + /// the other tabs for as long as one kept playing. + /// + /// Selection outranks visibility here, the reverse of + /// ``preferredPlayingSession(at:)``, because a tab starting on its own is + /// most likely the one the extension already considers selected. + /// + /// - Parameters: + /// - payload: The new session list. + /// - previousPayload: The previous list, or `nil` if this is the first. + /// - connectionID: Connection the list came from. + /// - date: Instant to judge freshness against. Defaults to now. + /// - Returns: The tab that just started, or `nil` when none did. private nonisolated static func newlyPlayingSession( in payload: YouTubeMusicSessionsPayload, previousPayload: YouTubeMusicSessionsPayload?, @@ -721,6 +975,15 @@ actor YouTubeMusicBridge { } } + /// Builds a stable identity for a tab across refreshes. + /// + /// Combines both parts because tab ids are only unique within a browser, + /// so two browsers could otherwise collide in the settings list. + /// + /// - Parameters: + /// - connectionID: Connection the tab belongs to. + /// - tabID: Tab id, or `nil` before one is known. + /// - Returns: The identifier. private nonisolated static func sessionIdentifier( connectionID: UUID, tabID: Int? @@ -729,35 +992,73 @@ actor YouTubeMusicBridge { } } +/// A snapshot with the two timestamps that matter for it. +/// +/// Both are kept because they answer different questions: arrival decides +/// whether the connection is still alive, while observation is what position +/// estimates are measured from. nonisolated private struct ConnectionSnapshot: Sendable { + /// The validated snapshot. let payload: YouTubeMusicSnapshotPayload + + /// When Reprise received it, used for staleness. let receivedAt: Date + + /// When the position was measured, used for estimation. let observedAt: Date } +/// The most recent tab list from one connection. nonisolated private struct ConnectionSessionList: Sendable { + /// The validated session list. let payload: YouTubeMusicSessionsPayload } +/// Enough to address one tab and rank it against others. nonisolated private struct YouTubeMusicSessionTarget: Sendable { + /// Connection the tab belongs to. let connectionID: UUID + + /// Browser-assigned tab id. let tabID: Int + + /// Whether the extension considers this its selected tab. let isSelected: Bool + + /// Whether the tab is visible in its window. let isVisible: Bool + + /// When the tab last reported. let updatedAt: Date } +/// Who is on the other end of a connection. nonisolated private struct ConnectionIdentity: Sendable { + /// Extension id from the handshake. let extensionID: String + + /// Extension version, for display. let extensionVersion: String + + /// Browser name, for display. let browserName: String } +/// What a sent command needs to remember to confirm its effect. nonisolated private struct CommandDispatch: Sendable { + /// Connection the command went to. let connectionID: UUID + + /// Sequence number before sending, so a later snapshot can be recognised. let startingSequence: Int } +/// Loopback WebSocket server the extension connects to. +/// +/// Wraps `Network.framework`, which is callback- and queue-based rather than +/// async. All mutable state is confined to one serial queue, which is what +/// `@unchecked Sendable` is asserting; the actor above never touches these +/// fields directly, only through the callbacks. nonisolated private final class YouTubeMusicWebSocketServer: @unchecked Sendable { private let queue = DispatchQueue( label: "dev.junx.Reprise.youtubeMusicBridge" @@ -770,6 +1071,13 @@ nonisolated private final class YouTubeMusicWebSocketServer: @unchecked Sendable private var connections: [UUID: NWConnection] = [:] private var readyConnectionIDs: Set = [] + /// Creates the server without starting it. + /// + /// - Parameters: + /// - port: Port to bind. + /// - onMessage: Called with each accepted text frame. + /// - onStatusChange: Called when the listener or connection set changes. + /// - onConnectionClosed: Called when a connection goes away. init( port: UInt16, onMessage: @escaping @Sendable (UUID, Data) -> Void, @@ -782,12 +1090,34 @@ nonisolated private final class YouTubeMusicWebSocketServer: @unchecked Sendable self.onConnectionClosed = onConnectionClosed } + /// The port actually bound. + /// + /// Reads through the queue because the listener is queue-confined. Only + /// safe from outside that queue, which is the sole caller. + /// + /// - Returns: The port, or `nil` when not listening. var listeningPort: UInt16? { queue.sync { listener?.port?.rawValue } } + /// Binds the port and begins accepting connections. + /// + /// Three settings define the security posture. `acceptLocalOnly` keeps the + /// listener off every non-loopback interface, so nothing on the network + /// can reach it. The message size cap is applied at the protocol layer, so + /// an oversized frame is refused before it is buffered. And the client + /// request handler runs + /// ``YouTubeMusicBridgeProtocol/acceptsHandshake(subprotocols:headers:)``, + /// rejecting the handshake outright rather than accepting and filtering + /// later. + /// + /// `allowLocalEndpointReuse` lets a relaunched Reprise rebind immediately + /// instead of waiting out the socket's lingering close. + /// + /// - Throws: ``YouTubeMusicWebSocketServerError/invalidPort`` for an + /// unusable port, or an `NWError` if the listener cannot be created. func start() throws { let parameters = NWParameters(tls: nil) parameters.allowLocalEndpointReuse = true @@ -836,6 +1166,11 @@ nonisolated private final class YouTubeMusicWebSocketServer: @unchecked Sendable listener.start(queue: queue) } + /// Closes every connection and stops listening. + /// + /// Bridged to async through a continuation so the caller knows the port is + /// released before it returns - important if the bridge is restarted, as + /// the rebind would otherwise race the teardown. func stop() async { await withCheckedContinuation { continuation in queue.async { [weak self] in @@ -854,6 +1189,17 @@ nonisolated private final class YouTubeMusicWebSocketServer: @unchecked Sendable } } + /// Sends a text frame to one connection. + /// + /// The connection must be in the ready set, not merely present: a socket + /// that is still handshaking would accept the write and drop it. + /// + /// - Parameters: + /// - data: Frame body. + /// - connectionID: Connection to send to. + /// - Throws: ``YouTubeMusicBridgeProtocolError/extensionNotConnected`` + /// when the connection is gone or not ready, or an `NWError` if the + /// write fails. func send( _ data: Data, to connectionID: UUID @@ -892,6 +1238,13 @@ nonisolated private final class YouTubeMusicWebSocketServer: @unchecked Sendable } } + /// Maps listener state onto a bridge status. + /// + /// Ready reports waiting or connected depending on whether any browser has + /// arrived, so the settings pane distinguishes "listening, nobody there" + /// from "listening, connected". + /// + /// - Parameter state: New listener state. private func handleListenerState(_ state: NWListener.State) { switch state { case .ready: @@ -909,6 +1262,12 @@ nonisolated private final class YouTubeMusicWebSocketServer: @unchecked Sendable } } + /// Registers an incoming connection and starts reading once it is ready. + /// + /// Reading only begins on `.ready`, since receiving before the handshake + /// completes would deliver nothing. + /// + /// - Parameter connection: The new connection. private func accept(_ connection: NWConnection) { let connectionID = UUID() connections[connectionID] = connection @@ -941,6 +1300,19 @@ nonisolated private final class YouTubeMusicWebSocketServer: @unchecked Sendable connection.start(queue: queue) } + /// Reads one frame and queues the next read. + /// + /// The recursive tail is how `Network.framework` models a continuous read; + /// it is not unbounded recursion, since each call returns before the + /// next is invoked from the completion handler. + /// + /// Only text frames are forwarded. The size is checked again here even + /// though the protocol layer caps it, because that cap governs reassembly + /// rather than what reaches this handler. + /// + /// - Parameters: + /// - connection: Connection to read from. + /// - connectionID: Its identifier. private func receiveNextMessage( from connection: NWConnection, connectionID: UUID @@ -988,6 +1360,13 @@ nonisolated private final class YouTubeMusicWebSocketServer: @unchecked Sendable } } + /// Forgets a connection and reports it closed, exactly once. + /// + /// The removal check is what makes it idempotent: failure, cancellation, + /// and a close frame can all fire for the same connection, and the bridge + /// must not be told about it more than once. + /// + /// - Parameter connectionID: Connection to remove. private func removeConnection(_ connectionID: UUID) { guard connections.removeValue(forKey: connectionID) != nil else { return @@ -997,9 +1376,12 @@ nonisolated private final class YouTubeMusicWebSocketServer: @unchecked Sendable } } +/// Failures from the bridge's WebSocket server. nonisolated private enum YouTubeMusicWebSocketServerError: LocalizedError { + /// The configured port is not a valid port number. case invalidPort + /// Korean message for the settings pane. var errorDescription: String? { "YouTube Music 브리지 포트를 열 수 없습니다." } diff --git a/src/Reprise.Platform.MacOS/Reprise/YouTubeMusicBridgeProtocol.swift b/src/Reprise.Platform.MacOS/Reprise/YouTubeMusicBridgeProtocol.swift index 11ae1e4..bbdfb70 100644 --- a/src/Reprise.Platform.MacOS/Reprise/YouTubeMusicBridgeProtocol.swift +++ b/src/Reprise.Platform.MacOS/Reprise/YouTubeMusicBridgeProtocol.swift @@ -7,18 +7,88 @@ import Foundation +/// Wire protocol between Reprise and the YouTube Music browser extension. +/// +/// YouTube Music has no scriptable app, so control goes through an extension +/// talking to a local WebSocket server inside Reprise. That server listens on +/// the loopback interface, which any process on the machine can reach, so +/// every constant and check here exists to keep the connection narrow: a +/// fixed subprotocol, an origin allowlist, and hard caps on message size and +/// session count. +/// +/// No user data ever leaves the machine over this link. nonisolated enum YouTubeMusicBridgeProtocol { + /// Wire format version, which both ends must agree on exactly. + /// + /// Rejecting a mismatch outright is deliberate: a partially understood + /// message is worse than a clear "update the extension". static let version = 1 + + /// Loopback port the bridge server listens on. + /// + /// Fixed rather than negotiated because the extension has no way to + /// discover a dynamic port. static let port: UInt16 = 19_436 + + /// WebSocket subprotocol the extension must request. + /// + /// The first filter on an incoming connection: a browser page or another + /// app that stumbles onto the port will not name this. static let subprotocolName = "reprise-youtube-music-v1" + + /// Extension id in Chromium browsers. static let extensionID = "apmolpbmjjndmedbogieopgmapoehdlp" + + /// Extension id in Firefox. static let firefoxExtensionID = "reprise-youtube-music@junx.dev" + + /// Public key pinning the Chromium extension's identity. + /// + /// Determines the extension id, so a build signed with a different key + /// cannot claim the same origin. static let extensionPublicKey = "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEArUGImdEvl2yZFlOUsVt/7lfhIuhpIPjLVbp4ihHMu5rHbIxFPGsA3q3W5IcPbcI/G6ujsh7C5LRC+1c9X4ftXBEcGEKryKPZ3WlfsmwuuXxEd3N6x6OzCw0ABEplzwuDh6ZMCFfDY8sG30Au1UoJTK3ZOunRCp9K/UFl+a76ozZRmKl284R8cWHjduDeEO5cMmjO+LTsXwT+df+rY14cWA88+tEvzdhiZpctHIwE7AIXUmr7jrcQlMbz+m4jalHCi2pd/np3BRbSAT5lQN6I4l2LVegdX5cpwQjueBMOROIgerOlIy0avCrmrWty0hF0J5ZOLDa/TvIhp9sAew0d9wIDAQAB" + + /// Exact `Origin` a Chromium extension connection must present. static let extensionOrigin = "chrome-extension://\(extensionID)" + + /// Largest inbound frame accepted, in bytes. + /// + /// Bounds what a client on the loopback port can make Reprise allocate. + /// Well above a real snapshot, which is a few hundred bytes. static let maximumMessageSize = 64 * 1_024 + + /// Most tab sessions accepted in one message. + /// + /// A user might have a handful of YouTube Music tabs; 32 leaves room while + /// keeping the session list bounded. static let maximumSessionCount = 32 + + /// How long a snapshot stays current before it is treated as stale. + /// + /// A tab that stops reporting - suspended, crashed, or closed without + /// notice - would otherwise sit in the panel indefinitely showing a track + /// that is no longer playing. static let staleSnapshotInterval: TimeInterval = 5 + /// Decides whether a WebSocket handshake should be accepted. + /// + /// Two gates. The subprotocol must be ours, which turns away anything that + /// merely found the port. Then the `Origin` must be the extension: Chromium + /// origins are compared against the exact pinned value, since the id is + /// derived from the signing key and cannot be forged. + /// + /// Firefox is the harder case - it assigns each install a random UUID + /// origin, so no fixed value exists to compare against. The origin is + /// instead required to be a bare `moz-extension://` URL whose host parses + /// as a UUID, with no user, password, port, path, query, or fragment. That + /// shape check is what stops a crafted origin from carrying anything past + /// the scheme, since it cannot confirm the origin is *our* extension. + /// + /// - Parameters: + /// - subprotocols: Subprotocols the client offered. + /// - headers: Handshake headers, matched case-insensitively as HTTP + /// requires. + /// - Returns: `true` when the connection may proceed. static func acceptsHandshake( subprotocols: [String], headers: [(name: String, value: String)] @@ -53,16 +123,26 @@ nonisolated enum YouTubeMusicBridgeProtocol { } } +/// State of the bridge, as the settings pane reports it. nonisolated enum YouTubeMusicBridgeStatus: Equatable, Sendable { + /// The server is not running. case stopped + + /// The server is listening but no extension has connected. case waiting + + /// An extension is connected. case connected + + /// The server could not start, with the reason attached. case failed(String) + /// Whether an extension is connected right now. var isConnected: Bool { self == .connected } + /// Korean status line shown in the settings pane. var displayText: String { switch self { case .stopped: @@ -77,10 +157,15 @@ nonisolated enum YouTubeMusicBridgeStatus: Equatable, Sendable { } } +/// Which browser family an extension connection came from. nonisolated enum YouTubeMusicBrowserKind: String, CaseIterable, Sendable { + /// Chrome, Edge, Brave, and other Chromium builds. case chromium + + /// Firefox and its derivatives. case firefox + /// Fallback name when the extension does not report the browser. var displayName: String { switch self { case .chromium: @@ -90,6 +175,10 @@ nonisolated enum YouTubeMusicBrowserKind: String, CaseIterable, Sendable { } } + /// Identifies the browser from the extension id it reported. + /// + /// - Parameter extensionID: Id from the hello message. + /// - Returns: The browser, or `nil` for an id that is not one of ours. init?(extensionID: String) { switch extensionID { case YouTubeMusicBridgeProtocol.extensionID: @@ -103,33 +192,93 @@ nonisolated enum YouTubeMusicBrowserKind: String, CaseIterable, Sendable { } /// A YouTube Music tab reported by one connected browser extension. +/// +/// The user may have several open across more than one browser, so the +/// settings pane lists them and lets one be chosen. Identity is the +/// connection and tab together, since tab ids are only unique per browser. nonisolated struct YouTubeMusicSession: Identifiable, Equatable, Sendable { + /// Stable identity across connections, for SwiftUI lists. let id: String + + /// Which extension connection reported this tab. let connectionID: UUID + + /// Browser family the tab is in. let browser: YouTubeMusicBrowserKind + + /// Browser name for display, as reported or inferred. let browserName: String + + /// Extension id that reported the tab. let extensionID: String + + /// Extension version, shown to help diagnose version mismatches. let extensionVersion: String + + /// Browser-assigned tab id, used to address commands. let tabID: Int? + + /// Transport state in this tab. let state: PlaybackState + + /// Track title in this tab. let title: String + + /// Artist credit in this tab. let artist: String + + /// Whether this is the tab Reprise is following. let isSelected: Bool + + /// Whether the tab is playing or paused with a track loaded. let isActive: Bool + + /// Whether the tab is visible in its browser window. let isVisible: Bool + + /// When the tab last reported, or `nil` if it never has. let lastUpdatedAt: Date? + + /// Whether the last report is recent enough to trust. let isFresh: Bool + /// Whether this tab has gone quiet and should not be trusted. var isStale: Bool { !isFresh } } +/// A message received from the extension. nonisolated enum YouTubeMusicInboundMessage: Sendable { + /// Connection handshake carrying the extension's identity. case hello(YouTubeMusicHelloMessage) + + /// Full playback state of the selected tab. case snapshot(YouTubeMusicSnapshotMessage) + + /// The list of YouTube Music tabs the extension can see. case sessions(YouTubeMusicSessionsMessage) + + /// Keep-alive proving the connection is still live. case heartbeat(YouTubeMusicHeartbeatMessage) + + /// Result of a command Reprise sent. case acknowledgement(YouTubeMusicAcknowledgementMessage) + /// Decodes one frame into a typed message. + /// + /// The size cap is applied before any parsing, so an oversized frame is + /// rejected without being decoded. The body is then read twice: once for + /// its `type` field, and again as the concrete message that names implies. + /// Decoding cannot be driven off a single pass because the payload shape + /// is only known after the type is read. + /// + /// Note this only produces the message; each type still has to be + /// validated before its contents are trusted. + /// + /// - Parameter data: Raw WebSocket frame. + /// - Returns: The decoded message. + /// - Throws: ``YouTubeMusicBridgeProtocolError/messageTooLarge``, + /// ``YouTubeMusicBridgeProtocolError/unknownMessageType(_:)``, or a + /// `DecodingError` when the body does not match its declared type. static func decode(from data: Data) throws -> Self { guard data.count <= YouTubeMusicBridgeProtocol.maximumMessageSize else { throw YouTubeMusicBridgeProtocolError.messageTooLarge @@ -159,17 +308,30 @@ nonisolated enum YouTubeMusicInboundMessage: Sendable { } } +/// Just enough of a message to learn which type it is. nonisolated private struct MessageHeader: Decodable { + /// Discriminator naming the concrete message type. let type: String } +/// Wire form of the tab list. nonisolated struct YouTubeMusicSessionsMessage: Decodable, Sendable { + /// Message discriminator; must be `sessions`. let type: String + + /// Protocol version the extension is speaking. let protocolVersion: Int + + /// Monotonic counter for ordering messages. let sequence: Int + + /// Tab the extension considers selected, if any. let selectedTabID: Int? + + /// One entry per YouTube Music tab. let sessions: [YouTubeMusicTabSessionMessage] + /// Maps the wire's `tabId` spelling onto Swift naming. enum CodingKeys: String, CodingKey { case type case protocolVersion @@ -178,6 +340,18 @@ nonisolated struct YouTubeMusicSessionsMessage: Decodable, Sendable { case sessions } + /// Checks the message and converts it into a trusted payload. + /// + /// Beyond the type, version, and count checks, two invariants are + /// enforced that later code depends on: tab ids must be unique, since they + /// key the session map and a duplicate would silently drop a tab, and a + /// declared selection must name a tab actually present, or Reprise would + /// follow a session it has no state for. + /// + /// - Returns: The validated payload. + /// - Throws: ``YouTubeMusicBridgeProtocolError/invalidMessage`` on any + /// violated invariant, or + /// ``YouTubeMusicBridgeProtocolError/unsupportedProtocolVersion(_:)``. func validated() throws -> YouTubeMusicSessionsPayload { guard type == "sessions", sequence >= 0, @@ -207,14 +381,27 @@ nonisolated struct YouTubeMusicSessionsMessage: Decodable, Sendable { } } +/// Wire form of one tab in the session list. nonisolated struct YouTubeMusicTabSessionMessage: Decodable, Sendable { + /// Browser-assigned tab id. let tabID: Int + + /// Playback state as a raw string. let state: String + + /// Track title. let title: String + + /// Artist credit, which the extension may omit. let artist: String? + + /// Whether the tab is visible in its window. let visible: Bool + + /// When the tab captured this, in milliseconds since the epoch. let updatedAtMilliseconds: Double + /// Maps the wire's abbreviated names onto Swift naming. enum CodingKeys: String, CodingKey { case tabID = "tabId" case state @@ -224,6 +411,20 @@ nonisolated struct YouTubeMusicTabSessionMessage: Decodable, Sendable { case updatedAtMilliseconds = "updatedAtMs" } + /// Checks the tab entry and converts it into a trusted payload. + /// + /// Text is trimmed and length-capped, since it is rendered in the menu bar + /// where an unbounded string would be a display problem. A timestamp more + /// than five seconds in the future is rejected: clocks drift a little, but + /// a far-future value would make the entry permanently outrank fresher + /// ones in the staleness comparison. + /// + /// A tab with no title is forced to `stopped` whatever it claimed, because + /// YouTube Music briefly reports playing with an empty title while a video + /// loads, and taking that at face value makes the panel flicker. + /// + /// - Returns: The validated payload. + /// - Throws: ``YouTubeMusicBridgeProtocolError/invalidMessage``. func validated() throws -> YouTubeMusicTabSessionPayload { guard tabID >= 0, let state = PlaybackState(rawValue: state), @@ -251,28 +452,57 @@ nonisolated struct YouTubeMusicTabSessionMessage: Decodable, Sendable { } } +/// A validated tab list, safe for the bridge to act on. nonisolated struct YouTubeMusicSessionsPayload: Equatable, Sendable { + /// Monotonic counter for ordering messages. let sequence: Int + + /// Tab the extension considers selected, guaranteed to be in ``sessions``. let selectedTabID: Int? + + /// The tabs, with unique ids. let sessions: [YouTubeMusicTabSessionPayload] } +/// A validated tab entry. nonisolated struct YouTubeMusicTabSessionPayload: Equatable, Sendable { + /// Browser-assigned tab id. let tabID: Int + + /// Playback state, never `unavailable`. let state: PlaybackState + + /// Track title, trimmed and length-capped. let title: String + + /// Artist credit, trimmed and length-capped. let artist: String + + /// Whether the tab is visible in its window. let isVisible: Bool + + /// When the tab captured this. let updatedAt: Date } +/// Wire form of the connection handshake. nonisolated struct YouTubeMusicHelloMessage: Decodable, Sendable { + /// Message discriminator; must be `hello`. let type: String + + /// Protocol version the extension is speaking. let protocolVersion: Int + + /// Extension version, for display and diagnostics. let extensionVersion: String + + /// Extension id, checked against the known ids. let extensionID: String + + /// Browser name, which the extension may omit. let browserName: String? + /// Maps the wire's `extensionId` spelling onto Swift naming. enum CodingKeys: String, CodingKey { case type case protocolVersion @@ -281,6 +511,16 @@ nonisolated struct YouTubeMusicHelloMessage: Decodable, Sendable { case browserName } + /// Checks the handshake. + /// + /// The extension id must be one of the two known values. Combined with the + /// origin check in + /// ``YouTubeMusicBridgeProtocol/acceptsHandshake(subprotocols:headers:)``, + /// this is the second point at which an unrelated client is turned away. + /// String lengths are capped because both are displayed in settings. + /// + /// - Throws: ``YouTubeMusicBridgeProtocolError/invalidMessage`` or + /// ``YouTubeMusicBridgeProtocolError/unsupportedProtocolVersion(_:)``. func validate() throws { guard type == "hello" else { throw YouTubeMusicBridgeProtocolError.invalidMessage @@ -297,10 +537,16 @@ nonisolated struct YouTubeMusicHelloMessage: Decodable, Sendable { } } + /// Browser family implied by the extension id. var browser: YouTubeMusicBrowserKind? { YouTubeMusicBrowserKind(extensionID: extensionID) } + /// Browser name to display. + /// + /// Prefers what the extension reported, since a user running Brave should + /// see Brave rather than the generic family name; falls back to the family + /// and finally to a neutral label. var resolvedBrowserName: String { let browserName = sanitized(browserName ?? "", maximumLength: 64) if !browserName.isEmpty { @@ -310,10 +556,18 @@ nonisolated struct YouTubeMusicHelloMessage: Decodable, Sendable { } } +/// Wire form of the keep-alive message. nonisolated struct YouTubeMusicHeartbeatMessage: Decodable, Sendable { + /// Message discriminator; must be `heartbeat`. let type: String + + /// Protocol version the extension is speaking. let protocolVersion: Int + /// Checks the heartbeat. + /// + /// - Throws: ``YouTubeMusicBridgeProtocolError/invalidMessage`` or + /// ``YouTubeMusicBridgeProtocolError/unsupportedProtocolVersion(_:)``. func validate() throws { guard type == "heartbeat" else { throw YouTubeMusicBridgeProtocolError.invalidMessage @@ -322,13 +576,30 @@ nonisolated struct YouTubeMusicHeartbeatMessage: Decodable, Sendable { } } +/// Wire form of a command result. nonisolated struct YouTubeMusicAcknowledgementMessage: Decodable, Sendable { + /// Message discriminator; must be `ack`. let type: String + + /// Protocol version the extension is speaking. let protocolVersion: Int + + /// Id of the command this answers. let id: String + + /// Whether the command succeeded. let success: Bool + + /// Why it failed, when it did. let error: String? + /// Checks the acknowledgement. + /// + /// The id must be non-empty because it is what matches this reply to the + /// caller waiting on it; lengths are capped as the error text is shown. + /// + /// - Throws: ``YouTubeMusicBridgeProtocolError/invalidMessage`` or + /// ``YouTubeMusicBridgeProtocolError/unsupportedProtocolVersion(_:)``. func validate() throws { guard type == "ack", !id.isEmpty, @@ -340,24 +611,57 @@ nonisolated struct YouTubeMusicAcknowledgementMessage: Decodable, Sendable { } } +/// Wire form of the full playback snapshot. nonisolated struct YouTubeMusicSnapshotMessage: Decodable, Sendable { + /// Message discriminator; must be `snapshot`. let type: String + + /// Protocol version the extension is speaking. let protocolVersion: Int + + /// Monotonic counter for ordering messages. let sequence: Int + + /// Tab this snapshot describes. let tabID: Int? + + /// Playback state as a raw string. let state: String + + /// Track title. let title: String + + /// Album name, which the extension may omit. let album: String? + + /// Artist credit, which the extension may omit. let artist: String? + + /// Track length in seconds. let duration: TimeInterval + + /// Playback position in seconds. let position: TimeInterval + + /// Volume on a 0 to 100 scale. let volume: Double + + /// Playback speed multiplier, which the extension may omit. let playbackRate: Double? + + /// When the tab captured this, in milliseconds since the epoch. let capturedAtMilliseconds: Double? + + /// Cover art URL. let artworkURL: String? + + /// YouTube video id, the most reliable track identity. let videoID: String? + + /// Link back to the track on YouTube Music. let trackURL: String? + /// Maps the wire's abbreviated names onto Swift naming. enum CodingKeys: String, CodingKey { case type case protocolVersion @@ -377,6 +681,26 @@ nonisolated struct YouTubeMusicSnapshotMessage: Decodable, Sendable { case trackURL = "trackUrl" } + /// Checks the snapshot and converts it into a trusted payload. + /// + /// Every number is required to be finite before use: these values reach + /// SwiftUI layout, where a NaN does not degrade gracefully. Playback rate + /// is additionally bounded to a sane range, since it multiplies the + /// position estimate between polls. + /// + /// URLs are the part most worth distrusting, since they come from a web + /// page. Both must be HTTPS and length-capped, and the track link must + /// point at `music.youtube.com`, so a snapshot cannot turn Reprise's link + /// into a jump to an arbitrary site. Failing rather than dropping a bad + /// URL is deliberate: a well-behaved extension never sends one, so its + /// presence means the message should not be trusted at all. + /// + /// As with a tab entry, an empty title forces `stopped` to avoid the + /// flicker while a video loads. + /// + /// - Returns: The validated payload. + /// - Throws: ``YouTubeMusicBridgeProtocolError/invalidMessage`` or + /// ``YouTubeMusicBridgeProtocolError/unsupportedProtocolVersion(_:)``. func validated() throws -> YouTubeMusicSnapshotPayload { guard type == "snapshot", sequence >= 0 else { throw YouTubeMusicBridgeProtocolError.invalidMessage @@ -441,22 +765,55 @@ nonisolated struct YouTubeMusicSnapshotMessage: Decodable, Sendable { } } +/// A validated playback snapshot, safe for the bridge to act on. nonisolated struct YouTubeMusicSnapshotPayload: Equatable, Sendable { + /// Monotonic counter for ordering messages. let sequence: Int + + /// Tab this snapshot describes. let tabID: Int? + + /// Playback state, never `unavailable`. let state: PlaybackState + + /// Track title, trimmed and length-capped. let title: String + + /// Album name, trimmed and length-capped. let album: String + + /// Artist credit, trimmed and length-capped. let artist: String + + /// Track length in seconds, never negative. let duration: TimeInterval + + /// Playback position in seconds, never negative. let position: TimeInterval + + /// Volume from 0 to 100. let volume: Int + + /// Playback speed, finite and within a sane range. let playbackRate: Double + + /// When the tab captured this, when it said. let capturedAt: Date? + + /// Validated HTTPS cover art URL. let artworkURL: URL? + + /// YouTube video id, empty when absent. let videoID: String + + /// Validated link on `music.youtube.com`. let trackURL: URL? + /// Identity used to decide whether the track has changed. + /// + /// Prefers the video id, which is exact. Text is the fallback for tracks + /// with no id - a live stream, or a page the extension read partially - + /// where a change in title is the only signal available. var trackKey: String { if !videoID.isEmpty { return videoID @@ -465,15 +822,30 @@ nonisolated struct YouTubeMusicSnapshotPayload: Equatable, Sendable { } } +/// A command sent from Reprise to the extension. nonisolated struct YouTubeMusicCommandMessage: Encodable, Equatable, Sendable { + /// Message discriminator. let type = "command" + + /// Protocol version Reprise speaks. let protocolVersion = YouTubeMusicBridgeProtocol.version + + /// Unique id the acknowledgement will quote back. let id: String + + /// Command name the extension understands. let command: String + + /// Tab to act on, or `nil` for the selected one. let tabID: Int? + + /// Target position in seconds, for `seek`. let position: TimeInterval? + + /// Target volume from 0 to 100, for `setVolume`. let volume: Int? + /// Maps the wire's `tabId` spelling onto Swift naming. enum CodingKeys: String, CodingKey { case type case protocolVersion @@ -484,6 +856,14 @@ nonisolated struct YouTubeMusicCommandMessage: Encodable, Equatable, Sendable { case volume } + /// Builds a transport command. + /// + /// - Parameters: + /// - command: Transport control to invoke. + /// - tabID: Tab to act on. Defaults to `nil`, meaning the selected tab. + /// - id: Correlation id. Defaults to a fresh UUID; injectable so tests + /// can assert on the encoded message. + /// - Returns: The command message. static func playback( _ command: PlaybackCommand, tabID: Int? = nil, @@ -505,6 +885,12 @@ nonisolated struct YouTubeMusicCommandMessage: Encodable, Equatable, Sendable { ) } + /// Builds a seek command. + /// + /// - Parameters: + /// - position: Target position in seconds; negatives become 0. + /// - id: Correlation id. Defaults to a fresh UUID. + /// - Returns: The command message. static func seek( to position: TimeInterval, id: UUID = UUID() @@ -518,6 +904,12 @@ nonisolated struct YouTubeMusicCommandMessage: Encodable, Equatable, Sendable { ) } + /// Builds a volume command. + /// + /// - Parameters: + /// - volume: Target level, clamped to 0 through 100. + /// - id: Correlation id. Defaults to a fresh UUID. + /// - Returns: The command message. static func setVolume( _ volume: Int, id: UUID = UUID() @@ -531,6 +923,13 @@ nonisolated struct YouTubeMusicCommandMessage: Encodable, Equatable, Sendable { ) } + /// Encodes the command for sending. + /// + /// Keys are sorted so the output is deterministic, which is what lets + /// tests compare against a fixed string. + /// + /// - Returns: The JSON body. + /// - Throws: An `EncodingError` if encoding fails. func encoded() throws -> Data { let encoder = JSONEncoder() encoder.outputFormatting = [.sortedKeys] @@ -538,15 +937,34 @@ nonisolated struct YouTubeMusicCommandMessage: Encodable, Equatable, Sendable { } } +/// Failures on the extension bridge. nonisolated enum YouTubeMusicBridgeProtocolError: LocalizedError, Equatable { + /// The extension speaks a different protocol version. case unsupportedProtocolVersion(Int) + + /// The message named a type Reprise does not handle. case unknownMessageType(String) + + /// The frame exceeded the size cap. case messageTooLarge + + /// The message failed validation. case invalidMessage + + /// No extension is connected. case extensionNotConnected + + /// A command was sent but never acknowledged. case commandNotConfirmed + + /// The extension rejected a command, with its reason if given. case commandRejected(String?) + /// Korean message for the panel's error line. + /// + /// The malformed-message cases share one wording: the distinction between + /// them matters when debugging, not to a user, whose remedy is the same in + /// every case. var errorDescription: String? { switch self { case .unsupportedProtocolVersion: @@ -567,6 +985,11 @@ nonisolated enum YouTubeMusicBridgeProtocolError: LocalizedError, Equatable { } } +/// Requires an exact protocol version match. +/// +/// - Parameter version: Version the extension reported. +/// - Throws: ``YouTubeMusicBridgeProtocolError/unsupportedProtocolVersion(_:)`` +/// when it differs from ``YouTubeMusicBridgeProtocol/version``. nonisolated private func validateProtocolVersion(_ version: Int) throws { guard version == YouTubeMusicBridgeProtocol.version else { throw YouTubeMusicBridgeProtocolError.unsupportedProtocolVersion( @@ -575,6 +998,15 @@ nonisolated private func validateProtocolVersion(_ version: Int) throws { } } +/// Trims and truncates text arriving from the extension. +/// +/// Every displayed string passes through here, so no web page can push an +/// unbounded value into the menu bar or the settings list. +/// +/// - Parameters: +/// - value: Raw text. +/// - maximumLength: Longest string to keep. +/// - Returns: The trimmed, truncated text. nonisolated private func sanitized( _ value: String, maximumLength: Int @@ -583,6 +1015,23 @@ nonisolated private func sanitized( return String(trimmed.prefix(maximumLength)) } +/// Validates a URL supplied by the extension. +/// +/// HTTPS is required so artwork and links cannot be downgraded to plaintext, +/// and the optional host requirement pins the track link to YouTube Music so +/// a snapshot cannot redirect the user somewhere else. +/// +/// An absent value is fine and yields `nil`; a present but invalid one throws, +/// because a well-behaved extension never sends one and its presence means +/// the whole message is suspect. +/// +/// - Parameters: +/// - value: Raw URL string, or `nil`. +/// - maximumLength: Longest string to accept. +/// - requiredHost: Host the URL must have. Defaults to `nil`, meaning any. +/// - Returns: The parsed URL, or `nil` when the input was absent or empty. +/// - Throws: ``YouTubeMusicBridgeProtocolError/invalidMessage`` when a +/// supplied value is too long, unparsable, not HTTPS, or on the wrong host. nonisolated private func validatedHTTPSURL( _ value: String?, maximumLength: Int, diff --git a/src/Reprise.Platform.MacOS/RepriseTests/RepriseDemoContentTests.swift b/src/Reprise.Platform.MacOS/RepriseTests/RepriseDemoContentTests.swift index 4817184..6f103c7 100644 --- a/src/Reprise.Platform.MacOS/RepriseTests/RepriseDemoContentTests.swift +++ b/src/Reprise.Platform.MacOS/RepriseTests/RepriseDemoContentTests.swift @@ -8,8 +8,17 @@ import Testing @testable import Reprise +/// Covers demo mode, which produces the app's promotional screenshots. +/// +/// Worth testing despite being non-shipping behaviour: the screenshots have to +/// be reproducible, so a change that made demo content drift would be caught +/// only by someone noticing a screenshot looked different. @MainActor struct RepriseDemoContentTests { + /// Either trigger turns demo mode on, and neither turns it on by accident. + /// + /// The negative case matters most: demo mode reaching a shipping build + /// would replace a user's real music with a fabricated track. @Test func demoModeCanBeEnabledByArgumentOrEnvironment() { #expect( @@ -32,6 +41,13 @@ struct RepriseDemoContentTests { ) } + /// A demo store presents the fixed promotional track. + /// + /// The playback rate assertion is the one that keeps screenshots + /// reproducible: at 0 the position never advances, so the progress bar and + /// the displayed lyric stay put however long the app has been open. The + /// lyric assertion follows from that - line four is what is current at the + /// pinned position. @Test func demoModeSeedsStablePromotionalContent() async { let store = NowPlayingStore(demoMode: true) @@ -52,6 +68,12 @@ struct RepriseDemoContentTests { ) } + /// Demo controls update the fabricated state without reaching a player. + /// + /// Demo mode has to stay self-contained: a screenshot session must never + /// pause or seek whatever the user actually has playing. Seeking to 118 + /// also moves the displayed lyric, confirming the lyric view is driven by + /// the same state the controls change. @Test func demoControlsStayLocalAndUpdateThePromotionalState() async { let store = NowPlayingStore(demoMode: true) diff --git a/src/Reprise.Platform.MacOS/RepriseTests/RepriseTests.swift b/src/Reprise.Platform.MacOS/RepriseTests/RepriseTests.swift index b5411eb..33efe3a 100644 --- a/src/Reprise.Platform.MacOS/RepriseTests/RepriseTests.swift +++ b/src/Reprise.Platform.MacOS/RepriseTests/RepriseTests.swift @@ -5,14 +5,28 @@ // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License. +import AppKit import CoreGraphics import Foundation import ServiceManagement import Testing @testable import Reprise +/// Covers the logic Reprise's behaviour rests on, away from AppKit. +/// +/// The app is almost entirely UI, so what is testable is what was deliberately +/// factored out of it: menu bar measurement, panel placement, player +/// selection, preference encoding, and lyrics parsing. Everything here runs +/// without a display, a media player, or the network. +/// +/// Preference tests each build a throwaway `UserDefaults` suite and remove it +/// afterwards, so they neither read nor disturb the developer's own settings. @MainActor struct RepriseTests { + /// The menu bar shows the track title alone, never the album. + /// + /// The album is carried in the snapshot and would be easy to append; the + /// menu bar has too little room to spend on it. @Test func menuBarTitleUsesOnlySongTitleBesideArtwork() { let snapshot = makeSnapshot( @@ -25,6 +39,11 @@ struct RepriseTests { #expect(snapshot.menuBarTitle == "Midnight City") } + /// A title that fits is left alone. + /// + /// Checks all three consequences together: no scrolling, no offset however + /// long it has been showing, and a viewport sized to the text rather than + /// padded out to the maximum. @Test func shortMenuBarTitleDoesNotScroll() { let title = "Antifreeze" @@ -35,6 +54,12 @@ struct RepriseTests { #expect(MenuBarMarquee.viewportWidth(for: titleWidth) == titleWidth) } + /// An over-long title scrolls at a constant rate after its pause. + /// + /// The two offsets are sampled 0.1s apart just past the initial pause, and + /// must differ by exactly the distance the default speed covers in that + /// time - which is what pins the scroll to a constant rate rather than an + /// eased one. @Test func longMenuBarTitleScrollsSmoothlyWithinMaximumWidth() { let title = "사랑하긴 했었나요 스쳐가는 인연이었나요 짧지 않은 우리 함께했던 시간들이" @@ -54,6 +79,11 @@ struct RepriseTests { #expect(abs(secondOffset + 6) < 0.001) } + /// The panel aligns with the status item and hangs below it. + /// + /// The fractional anchor is deliberate: status items rarely land on whole + /// points, and the x origin must survive pixel alignment. At 2x, 300.5 is + /// already on a pixel boundary and should pass through unchanged. @Test func playerPanelStartsAtTheStatusItemLeadingEdge() { let origin = PlayerPanelLayout.origin( @@ -66,6 +96,10 @@ struct RepriseTests { #expect(origin == CGPoint(x: 300.5, y: 755)) } + /// A panel anchored at the screen edge is pulled back into view. + /// + /// Happens whenever the status item sits at the far left of the menu bar, + /// where aligning with it would put the panel partly off screen. @Test func playerPanelStaysInsideTheVisibleScreenWidth() { let origin = PlayerPanelLayout.origin( @@ -78,6 +112,11 @@ struct RepriseTests { #expect(origin.x == PlayerPanelLayout.screenMargin) } + /// Another app taking focus closes the panel; Reprise itself does not. + /// + /// The negative case is the one that matters: opening Settings activates + /// Reprise, and dismissing on that would close the panel the user just + /// opened Settings from. @Test func playerPanelDismissesWhenAnotherApplicationActivates() { #expect( @@ -94,6 +133,10 @@ struct RepriseTests { ) } + /// Every `SMAppService` status maps onto a login-item state. + /// + /// `requiresApproval` reads as on, since the user has already made the + /// choice and only a system prompt remains. @Test func launchAtLoginReflectsServiceManagementStatus() { #expect( @@ -110,6 +153,11 @@ struct RepriseTests { #expect(LaunchAtLoginState.requiresApproval.isOn) } + /// Menu bar settings round-trip through `UserDefaults`. + /// + /// Every value is deliberately set away from its default, so a preference + /// that silently failed to read back would show as its default rather than + /// coincidentally matching. @Test func marqueePreferencesArePersistedAndReadBack() { let suiteName = "RepriseTests.\(UUID().uuidString)" @@ -168,6 +216,10 @@ struct RepriseTests { ) } + /// Each title format arranges title and artist as named. + /// + /// The blank-artist case guards the separator: a naive join would produce + /// a trailing `Song - ` in the menu bar. @Test func menuBarTitleFormatOrdersTitleAndArtist() { #expect( @@ -202,6 +254,7 @@ struct RepriseTests { ) } + /// A fresh install shows the title alone. @Test func titleOnlyIsTheDefaultMenuBarTitleFormat() { let suiteName = "RepriseTests.\(UUID().uuidString)" @@ -219,6 +272,7 @@ struct RepriseTests { ) } + /// A fresh install shows the album cover. @Test func albumArtworkIsTheDefaultMenuBarArtworkStyle() { let suiteName = "RepriseTests.\(UUID().uuidString)" @@ -236,6 +290,10 @@ struct RepriseTests { ) } + /// Hiding the artwork drops its trailing gap as well. + /// + /// Keeping the gap would leave the title floating away from the menu bar + /// items beside it. @Test func hiddenMenuBarArtworkRemovesItsSpacing() { let titleWidth: CGFloat = 80 @@ -256,6 +314,10 @@ struct RepriseTests { ) } + /// With no title, the item is exactly the artwork wide. + /// + /// The mirror of the previous case: the gap belongs between two things and + /// must vanish when either is absent. @Test func hiddenMenuBarTextLeavesOnlyTheArtworkWidth() { #expect( @@ -266,6 +328,10 @@ struct RepriseTests { ) } + /// Reserved width holds steady across lyric lines of different lengths. + /// + /// Without it the item would resize on every line, shifting every status + /// item to its left several times a minute. @Test func reservedLyricsWidthDoesNotChangeWithShortLines() { let shortLineWidth = MenuBarMarquee.textWidth("짧은 가사") @@ -287,6 +353,10 @@ struct RepriseTests { ) } + /// The configured lyrics width caps and reserves as expected. + /// + /// Covers all three cases: reserved, overflowing, and comfortably fitting. + /// Only the last should size to the text. @Test func configuredLyricsWidthControlsTheMenuBarViewport() { let configuredWidth: CGFloat = 240 @@ -312,6 +382,11 @@ struct RepriseTests { ) } + /// The upward push animates lyric lines only, within one track. + /// + /// The other two cases are what the animation must not claim: a track + /// change is not one lyric giving way to the next, and neither is a plain + /// title change. @Test func lyricLinesUseUpwardTransitionOnlyWithinTheSameTrack() { #expect( @@ -343,6 +418,11 @@ struct RepriseTests { ) } + /// Hiding both the artwork and the title restores the artwork. + /// + /// The important guard in the whole file: that combination leaves an + /// invisible menu bar item, and a user who reached it would have no way to + /// click back into Reprise and undo it. @Test func atLeastOneMenuBarElementRemainsVisible() { let suiteName = "RepriseTests.\(UUID().uuidString)" @@ -366,6 +446,7 @@ struct RepriseTests { #expect(preferences.menuBarTitleFormat == .hidden) } + /// A fresh install uses the Liquid panel theme. @Test func liquidIsTheDefaultPlayerPanelTheme() { let suiteName = "RepriseTests.\(UUID().uuidString)" @@ -383,6 +464,10 @@ struct RepriseTests { ) } + /// Reprise does not pause anything until asked to. + /// + /// Off by default deliberately: silently pausing a player the user started + /// elsewhere would be surprising behaviour to inherit on install. @Test func automaticallyPausingTheOtherPlayerIsOffByDefault() { let suiteName = "RepriseTests.\(UUID().uuidString)" @@ -400,6 +485,7 @@ struct RepriseTests { ) } + /// The remembered player starts empty and round-trips once set. @Test func rememberingTheLastPlayedPlayerIsOffByDefault() { let suiteName = "RepriseTests.\(UUID().uuidString)" @@ -427,6 +513,7 @@ struct RepriseTests { ) } + /// The default order follows the declaration order of the players. @Test func spotifyIsFirstInTheDefaultPlayerDisplayOrder() { let suiteName = "RepriseTests.\(UUID().uuidString)" @@ -444,6 +531,10 @@ struct RepriseTests { ) } + /// A saved order is honoured, with omitted players appended. + /// + /// Serialising two players and reading back three confirms the round trip + /// always yields a complete order. @Test func savedPlayerDisplayOrderIsUsed() { let suiteName = "RepriseTests.\(UUID().uuidString)" @@ -466,6 +557,10 @@ struct RepriseTests { ) } + /// A preference written before the setting was a list still decodes. + /// + /// Early builds stored a single player name. Reading one has to widen into + /// a full order rather than leaving the other players unranked. @Test func legacySinglePlayerPriorityBecomesACompleteOrder() { #expect( @@ -475,6 +570,11 @@ struct RepriseTests { ) } + /// The fade occupies a fixed width, however wide the viewport. + /// + /// A viewport narrower than the fade itself is the edge case: the location + /// must floor at 0 rather than going negative, which would fade the entire + /// line out. @Test func marqueeFadeUsesAFixedWidthAtTheTrailingEdge() { #expect( @@ -491,6 +591,36 @@ struct RepriseTests { ) } + /// The menu bar item draws in a colour that contrasts with the menu bar. + /// + /// Regression test. The title, the level meter, and the fallback glyph + /// were all drawn in a fixed white, which is invisible on a light menu bar + /// - and the item is drawn into layer contents, which nothing tints on the + /// app's behalf. Resolving `labelColor` per appearance is the fix, so the + /// two appearances have to land on opposite sides of mid grey. + /// + /// - Throws: Rethrows a requirement failure to the test runner. + @Test + func menuBarForegroundColorFollowsTheMenuBarAppearance() throws { + let lightAppearance = try #require(NSAppearance(named: .aqua)) + let darkAppearance = try #require(NSAppearance(named: .darkAqua)) + + let onLightMenuBar = try #require( + MenuBarMarquee.foregroundColor(for: lightAppearance) + .usingColorSpace(.sRGB) + ) + let onDarkMenuBar = try #require( + MenuBarMarquee.foregroundColor(for: darkAppearance) + .usingColorSpace(.sRGB) + ) + + #expect(onLightMenuBar.brightnessComponent < 0.5) + #expect(onDarkMenuBar.brightnessComponent > 0.5) + } + + /// A playing player outranks a paused one of higher priority. + /// + /// Priority is a tie-break, not an override: what is audible wins. @Test func playingPlayerWinsOverPausedDisplayPriority() { let spotify = makeSnapshot( @@ -516,6 +646,7 @@ struct RepriseTests { #expect(preferred?.track?.title == "Playing song") } + /// With both playing, the user's priority decides. @Test func displayPriorityWinsWhenBothArePlaying() { let spotify = makeSnapshot( @@ -540,6 +671,10 @@ struct RepriseTests { #expect(preferred?.player == .appleMusic) } + /// The remembered player wins among paused players. + /// + /// YouTube Music is last in the display order here, so only the memory can + /// account for it being chosen. @Test func rememberedPlayerWinsWhenNothingIsPlaying() { let spotify = makeSnapshot( @@ -568,6 +703,11 @@ struct RepriseTests { #expect(preferred?.player == .youtubeMusic) } + /// Starting a player makes it the remembered one. + /// + /// Spotify is playing throughout and is already remembered, so the switch + /// to YouTube Music can only come from the transition into playing being + /// what counts. @Test func newlyPlayingPlayerBecomesTheRememberedPlayer() { let previousSpotify = makeSnapshot( @@ -607,6 +747,9 @@ struct RepriseTests { #expect(player == .youtubeMusic) } + /// Starting Music pauses the Spotify that was already playing. + /// + /// The core case of the automatic pause setting. @Test func appleMusicStartingPausesPreviouslyPlayingSpotify() { let player = NowPlayingStore.playerToPause( @@ -640,6 +783,10 @@ struct RepriseTests { #expect(player == .spotify) } + /// The same rule holds with the players reversed. + /// + /// Guards against the pause target being decided by player order rather + /// than by which one just started. @Test func spotifyStartingPausesPreviouslyPlayingAppleMusic() { let player = NowPlayingStore.playerToPause( @@ -673,6 +820,11 @@ struct RepriseTests { #expect(player == .appleMusic) } + /// Two players appearing at once pause neither. + /// + /// This is the shape of Reprise's first poll after launch: everything + /// already running looks newly playing. Pausing on that would stop music + /// the user started before Reprise was open. @Test func simultaneousInitialPlaybackDoesNotPauseEitherPlayer() { let player = NowPlayingStore.playerToPause( @@ -696,6 +848,10 @@ struct RepriseTests { #expect(player == nil) } + /// A player that has already stopped is not paused again. + /// + /// Spotify goes from playing to paused on its own here, so there is + /// nothing left to pause and the command would be wasted. @Test func stoppedPreviousPlayerDoesNotReceiveAnAutomaticPause() { let player = NowPlayingStore.playerToPause( @@ -729,6 +885,10 @@ struct RepriseTests { #expect(player == nil) } + /// With the setting off, nothing is paused. + /// + /// Identical inputs to the passing case, so only the flag can account for + /// the difference. @Test func disabledAutomaticPauseIgnoresAPlayerStarting() { let player = NowPlayingStore.playerToPause( @@ -762,6 +922,10 @@ struct RepriseTests { #expect(player == nil) } + /// A track with no album still yields a clean menu bar title. + /// + /// Singles routinely report an empty album, and the title must not pick up + /// a separator or a blank from it. @Test func albumNameDoesNotChangeMenuBarTitle() { let snapshot = makeSnapshot( @@ -774,6 +938,10 @@ struct RepriseTests { #expect(snapshot.menuBarTitle == "Single") } + /// A denied automation permission explains how to grant it. + /// + /// OSA error -1743 is the one failure a user can actually fix, and the + /// system's own message does not say where to go. @Test func automationDenialProvidesRecoveryInstructions() { let message = MediaAutomationService.userFacingMessage( @@ -784,6 +952,10 @@ struct RepriseTests { #expect(message.contains("시스템 설정")) } + /// Progress and remaining time stay sane past the end of a track. + /// + /// Players briefly report a position beyond the duration while advancing; + /// progress must cap at 1 and remaining must not go negative. @Test func playbackProgressAndRemainingTimeAreCalculatedSafely() { let track = Track( @@ -809,6 +981,10 @@ struct RepriseTests { #expect(overrun.remaining == 0) } + /// Clamping handles negatives, overruns, infinity, and no duration. + /// + /// The infinity case matters most: a non-finite value reaching SwiftUI + /// layout breaks the whole panel rather than one control. @Test func playbackPositionIsClampedToTrackDuration() { #expect( @@ -828,6 +1004,11 @@ struct RepriseTests { ) } + /// Estimated position advances while playing and holds while paused. + /// + /// Four cases: normal advance, paused hold, clamping at the end of a + /// track, and a playback rate multiplying the elapsed time. This is what + /// lets the progress bar move smoothly between twice-a-second polls. @Test func playbackPositionAdvancesSmoothlyOnlyWhilePlaying() { let observedAt = Date(timeIntervalSinceReferenceDate: 100) @@ -872,6 +1053,11 @@ struct RepriseTests { ) } + /// A seek is confirmed within tolerance but not by an unrelated position. + /// + /// The tolerance exists because a player that accepted a seek has already + /// advanced by the time its position is read back; without it no seek + /// would ever confirm. @Test func seekOnlyCompletesAfterPlayerReportsTheTargetPosition() { #expect( @@ -888,6 +1074,7 @@ struct RepriseTests { ) } + /// Volume stays within 0 to 100. @Test func playerVolumeIsClampedToTheSupportedRange() { #expect(PlayerVolume.clamped(-1) == 0) @@ -895,6 +1082,10 @@ struct RepriseTests { #expect(PlayerVolume.clamped(101) == 100) } + /// Mute goes to zero; unmute returns to the remembered level. + /// + /// With nothing remembered it falls back to a default, so unmuting always + /// produces sound rather than appearing to do nothing. @Test func muteToggleRestoresTheLastAudibleVolume() { #expect( @@ -917,6 +1108,10 @@ struct RepriseTests { ) } + /// Both time styles render on each side of the progress bar. + /// + /// The remaining style carries a minus sign, which is what distinguishes a + /// countdown from a total at a glance. @Test func panelTimeStylesFormatBothSides() { #expect( @@ -947,6 +1142,7 @@ struct RepriseTests { ) } + /// A fresh install shows elapsed on the left and remaining on the right. @Test func elapsedAndRemainingAreTheDefaultPanelTimeStyles() { let suiteName = "RepriseTests.\(UUID().uuidString)" @@ -969,6 +1165,10 @@ struct RepriseTests { ) } + /// Lyrics are off by default, with width reserved once enabled. + /// + /// Checked both as raw defaults and through `MarqueePreferences`, since + /// the two read the same keys by different paths. @Test func lyricsDisplayPreferencesUseExpectedDefaults() { let suiteName = "RepriseTests.\(UUID().uuidString)" @@ -1006,6 +1206,14 @@ struct RepriseTests { ) } + /// VIBE search results are parsed and the right track chosen. + /// + /// The two candidates share a title, so only the artist and album can + /// separate them - and the artist is written as `백예린(Yerin Baek)` against + /// a query of `Yerin Baek`, which is exactly the punctuation and script + /// mismatch the normalisation exists to absorb. + /// + /// - Throws: Rethrows a parse failure to the test runner. @Test func vibeSearchParsesAndMatchesArtistAliases() throws { let xml = """ @@ -1049,6 +1257,11 @@ struct RepriseTests { ) } + /// A track's lyrics identity ignores its duration. + /// + /// Players report a duration of 0 for a moment when a track loads. If that + /// were part of the identity, the lyrics cache would miss and every track + /// would be fetched twice. @Test func lyricsTrackIdentityIgnoresTransientDurationChanges() { let unavailableDuration = LyricsTrackQuery( @@ -1074,6 +1287,13 @@ struct RepriseTests { ) } + /// VIBE lyrics use the original language and its explicit end times. + /// + /// The translation block is deliberately placed first, so picking the + /// `default` language has to be a real choice rather than taking whichever + /// came first. + /// + /// - Throws: Rethrows a parse failure to the test runner. @Test func vibeSyncedLyricsUseDefaultLanguageAndEndTimes() throws { let xml = """ @@ -1111,6 +1331,13 @@ struct RepriseTests { #expect(lyrics?.lines[0].endTime == 8.0) } + /// LRC parsing handles offsets, repeated timestamps, and metadata. + /// + /// All three appear in real files. The offset shifts every timestamp by + /// 0.2s, the repeated timestamps expand one refrain line into two entries, + /// and the `[ar:]` tag must be skipped rather than parsed as a lyric. The + /// last line closes at the track duration, so it does not hang in the menu + /// bar after the song ends. @Test func lrcParserSupportsOffsetsAndMultipleTimestamps() { let lrc = """ @@ -1130,6 +1357,11 @@ struct RepriseTests { #expect(lines[2].endTime == 10) } + /// A gap between lyric lines shows no line, but keeps the scroll position. + /// + /// The difference between the two lookups: at 6 seconds the first line has + /// ended and nothing is current, yet it stays focused so the lyric sheet + /// does not jump during an instrumental break. @Test func syncedLyricsReturnNoLineOutsideVibeTiming() { let lyrics = SyncedLyrics( @@ -1148,6 +1380,12 @@ struct RepriseTests { #expect(lyrics.line(at: 12) == nil) } + /// The shipped bundle points Sparkle at the signed appcast. + /// + /// These live in Info.plist rather than in code, so nothing else would + /// catch a bad merge or a build setting overwriting them - and a wrong + /// feed URL or public key is an update-channel problem, not a cosmetic + /// one. @Test func sparkleUsesTheSignedRepriseAppcast() { let info = Bundle.main.infoDictionary @@ -1165,6 +1403,17 @@ struct RepriseTests { #expect(info?["SUEnableInstallerLauncherService"] as? Bool == true) } + /// Builds a snapshot carrying only the fields these tests examine. + /// + /// Timing and volume are left at their defaults, since the selection and + /// title rules do not consider them. + /// + /// - Parameters: + /// - player: Player the snapshot describes. + /// - state: Transport state. + /// - title: Track title. + /// - album: Album name. + /// - Returns: A running snapshot with a loaded track. private func makeSnapshot( player: MediaPlayerKind, state: PlaybackState, diff --git a/src/Reprise.Platform.MacOS/RepriseTests/YouTubeMusicBridgeTests.swift b/src/Reprise.Platform.MacOS/RepriseTests/YouTubeMusicBridgeTests.swift index 01e465c..25c07aa 100644 --- a/src/Reprise.Platform.MacOS/RepriseTests/YouTubeMusicBridgeTests.swift +++ b/src/Reprise.Platform.MacOS/RepriseTests/YouTubeMusicBridgeTests.swift @@ -9,9 +9,26 @@ import Foundation import Testing @testable import Reprise +/// Covers the browser extension bridge, protocol and transport alike. +/// +/// Serialized because the transport test binds a real socket and drives a real +/// `URLSessionWebSocketTask`; running alongside another test would let the two +/// contend for ports and timing. +/// +/// Most cases here are validation: the bridge listens on loopback, so anything +/// on the machine can connect to it, and the protocol's checks are the only +/// thing separating the extension from an arbitrary client. @Suite(.serialized) @MainActor struct YouTubeMusicBridgeTests { + /// The handshake accepts only our extension, on either browser family. + /// + /// Five cases, and the rejections carry the weight. The wrong subprotocol + /// covers something that merely found the port. A Firefox origin is + /// accepted by shape, since its per-install UUID cannot be pinned - but + /// one whose host is not a UUID is refused, which is what stops the + /// `moz-extension` scheme becoming a way in. And an ordinary web origin is + /// refused outright, so a page cannot reach the bridge. @Test func handshakeRequiresTheFixedExtensionOriginAndSubprotocol() { let validHeaders = [ @@ -67,6 +84,14 @@ struct YouTubeMusicBridgeTests { ) } + /// A snapshot is decoded, sanitised, and clamped. + /// + /// The payload is deliberately awkward: a padded title that must be + /// trimmed before it reaches the menu bar, and a volume of 140 that must + /// clamp to 100. Both are the kind of value a page can produce, and + /// neither should reach the UI as sent. + /// + /// - Throws: Rethrows a decode or validation failure to the test runner. @Test func snapshotMessagesAreValidatedAndClamped() throws { let data = Data( @@ -91,6 +116,13 @@ struct YouTubeMusicBridgeTests { #expect(snapshot.trackURL?.host == "music.youtube.com") } + /// A session list preserves every tab and its selection. + /// + /// Timestamps are taken from the current clock rather than hardcoded, + /// because validation rejects anything far in the future and a fixed value + /// would eventually go stale. + /// + /// - Throws: Rethrows a decode or validation failure to the test runner. @Test func sessionMessagesExposeEveryBrowserTab() throws { let nowMilliseconds = Date().timeIntervalSince1970 * 1_000 @@ -117,6 +149,13 @@ struct YouTubeMusicBridgeTests { #expect(!payload.sessions[1].isVisible) } + /// Only the two known extension ids may identify themselves. + /// + /// The second gate after the origin check. The Firefox case also confirms + /// a reported browser name is preferred over the family name, so a + /// LibreWolf user does not simply see "Firefox". + /// + /// - Throws: Rethrows a decode failure to the test runner. @Test func helloMessagesRequireAKnownBrowserExtensionID() throws { let firefoxHello = Data( @@ -148,6 +187,12 @@ struct YouTubeMusicBridgeTests { } } + /// A wrong protocol version or an off-site track URL is rejected. + /// + /// The URL case is the security-relevant half: the track link is opened + /// from Reprise, so a snapshot that could point it at an arbitrary host + /// would turn a play button into a redirect. Pinning the host to + /// `music.youtube.com` closes that. @Test func snapshotRejectsUnsupportedProtocolAndUntrustedTrackURL() { let unsupportedVersion = Data( @@ -190,6 +235,13 @@ struct YouTubeMusicBridgeTests { } } + /// Commands encode with their version, clamped values, and tab target. + /// + /// Asserted against the real JSON rather than the Swift value, since the + /// extension parses the wire form and a renamed key would break it + /// silently. Fixed UUIDs make the output deterministic. + /// + /// - Throws: Rethrows an encoding failure to the test runner. @Test func commandMessagesUseTheVersionedProtocol() throws { let command = YouTubeMusicCommandMessage.setVolume( @@ -220,6 +272,12 @@ struct YouTubeMusicBridgeTests { #expect(targetedObject?["tabId"] as? Int == 42) } + /// YouTube Music behaves like any other player in the store's rules. + /// + /// It has no app of its own, so it is worth confirming it is not treated + /// as a second-class player: it can win the display priority, and it + /// triggers the automatic pause - including pausing two other players at + /// once when both were playing. @Test func youtubeMusicParticipatesInPriorityAndAutomaticPause() { let spotify = makeSnapshot( @@ -281,6 +339,28 @@ struct YouTubeMusicBridgeTests { #expect(playersToPause == [.spotify, .appleMusic]) } + /// End-to-end run of the bridge over a real loopback WebSocket. + /// + /// The only test that exercises the transport rather than the protocol + /// types, and it is deliberately one long scenario: the behaviours worth + /// checking are all about how the bridge moves between states, which + /// cannot be reached without the steps before them. It walks through + /// connect, snapshot, session list, command round trip, a second browser + /// joining, the active connection switching, and a disconnect. + /// + /// Port 0 is requested so the OS assigns a free one, letting the test run + /// even when a real Reprise holds the fixed port. + /// + /// The polling loops exist because state arrives through the network + /// queue: a fixed sleep would be both slower and flakier than checking for + /// the condition. The one-minute limit and the send/receive timeouts stop + /// a hung socket from stalling the whole suite. + /// + /// The three moments most worth reading are marked inline, since each + /// concerns a specific assertion in the middle of the sequence. + /// + /// - Throws: Rethrows socket, timeout, and requirement failures to the + /// test runner. @Test(.timeLimit(.minutes(1))) func localWebSocketTransfersSnapshotsAndCommands() async throws { let bridge = YouTubeMusicBridge(port: 0) @@ -357,8 +437,14 @@ struct YouTubeMusicBridgeTests { for _ in 0..<200 { let snapshot = await bridge.snapshot() let sessions = await bridge.sessions() + // Waiting on the background tab, not the selected one: until the + // session list is processed, `sessions()` synthesises a single + // entry for the selected tab from the snapshot alone. Tab 7 alone + // would therefore let this break one message too early, with the + // real list still in flight. if snapshot.track?.title == "Bridge Song", - sessions.contains(where: { $0.tabID == 7 }) { + sessions.contains(where: { $0.tabID == 7 }), + sessions.contains(where: { $0.tabID == 9 }) { pendingSnapshot = snapshot initialSessions = sessions break @@ -587,6 +673,13 @@ struct YouTubeMusicBridgeTests { secondSession.invalidateAndCancel() } + /// Builds a snapshot for the store's selection rules. + /// + /// - Parameters: + /// - player: Player the snapshot describes. + /// - state: Transport state. + /// - title: Track title; album and artist are fixed placeholders. + /// - Returns: A running snapshot with a loaded track. private func makeSnapshot( player: MediaPlayerKind, state: PlaybackState, @@ -606,12 +699,25 @@ struct YouTubeMusicBridgeTests { } } +/// Failures raised when the WebSocket test does not progress in time. +/// +/// Distinct cases so a failure report names the stage that stalled, which is +/// the difference between a bridge that never listened and one that listened +/// but refused the handshake. private enum WebSocketTestTimeoutError: Error, LocalizedError { + /// The bridge never reached the listening state. case listenerNotReady + + /// The test client never completed its handshake. case clientNotConnected + + /// A send exceeded its timeout. case send + + /// A receive exceeded its timeout. case receive + /// Description shown in the test report. var errorDescription: String? { switch self { case .listenerNotReady: @@ -627,6 +733,21 @@ private enum WebSocketTestTimeoutError: Error, LocalizedError { } private extension URLSessionWebSocketTask { + /// Sends a message, failing if it does not complete in time. + /// + /// `URLSessionWebSocketTask` has no per-operation timeout: a send against + /// a wedged socket simply never returns, which would hang the test until + /// the suite's own limit fired with no indication of where. Racing it + /// against a sleep bounds it and names the stage. + /// + /// The task is cancelled on timeout so the pending send is torn down + /// rather than left running after the group returns. + /// + /// - Parameters: + /// - message: Message to send. + /// - timeout: How long to allow. Defaults to five seconds. + /// - Throws: ``WebSocketTestTimeoutError/send`` on timeout, or the send's + /// own error. func sendWithTimeout( _ message: Message, timeout: Duration = .seconds(5) @@ -646,6 +767,16 @@ private extension URLSessionWebSocketTask { } } + /// Receives a message, failing if none arrives in time. + /// + /// Same reasoning as ``sendWithTimeout(_:timeout:)``, and more necessary: + /// a receive that never resolves is the likeliest way this test would + /// hang, since it waits on the bridge sending a command. + /// + /// - Parameter timeout: How long to allow. Defaults to five seconds. + /// - Returns: The received message. + /// - Throws: ``WebSocketTestTimeoutError/receive`` on timeout, or the + /// receive's own error. func receiveWithTimeout( timeout: Duration = .seconds(5) ) async throws -> Message { @@ -668,13 +799,25 @@ private extension URLSessionWebSocketTask { } } +/// Records whether a volume command has completed yet. +/// +/// The test needs to assert that a command has *not* completed, which awaiting +/// the task cannot express - awaiting it would block until it did. Writing the +/// result into shared state instead lets the absence be observed. An actor +/// because the command task and the test body reach it concurrently. private actor VolumeCompletionProbe { private var storedValue: Int? + /// Records the completed volume. + /// + /// - Parameter value: Level the command settled on. func store(_ value: Int) { storedValue = value } + /// The recorded volume, or `nil` if the command has not completed. + /// + /// - Returns: The stored level, or `nil`. func value() -> Int? { storedValue } diff --git a/src/Reprise.Platform.MacOS/RepriseUITests/RepriseUITests.swift b/src/Reprise.Platform.MacOS/RepriseUITests/RepriseUITests.swift index be031a2..c1dc280 100644 --- a/src/Reprise.Platform.MacOS/RepriseUITests/RepriseUITests.swift +++ b/src/Reprise.Platform.MacOS/RepriseUITests/RepriseUITests.swift @@ -7,35 +7,47 @@ import XCTest +/// End-to-end checks driving the built app. +/// +/// Coverage here is deliberately thin: Reprise lives in the menu bar, which +/// `XCUIApplication` cannot reach into the way it can a normal window, so the +/// behavioural testing sits in the unit target and this one guards launch. final class RepriseUITests: XCTestCase { + /// Stops the run at the first failure. + /// + /// Later assertions in a UI test almost always fail as a consequence of + /// the first, so continuing past it just buries the real cause. + /// + /// - Throws: Rethrows any setup failure to XCTest. override func setUpWithError() throws { - // Put setup code here. This method is called before the invocation of each test method in the class. - - // In UI tests it is usually best to stop immediately when a failure occurs. continueAfterFailure = false - - // In UI tests it’s important to set the initial state - such as interface orientation - required for your tests before they run. The setUp method is a good place to do this. } + /// Reserved for teardown; nothing needs releasing yet. + /// + /// - Throws: Rethrows any teardown failure to XCTest. override func tearDownWithError() throws { - // Put teardown code here. This method is called after the invocation of each test method in the class. } + /// Checks the app launches without crashing. + /// + /// There is nothing to assert beyond reaching the end: `launch()` fails + /// the test itself if the app does not come up, which makes this a smoke + /// test for the launch path rather than for any behaviour. + /// + /// - Throws: Rethrows any launch failure to XCTest. @MainActor func testExample() throws { - // UI tests must launch the application that they test. let app = XCUIApplication() app.launch() - - // Use XCTAssert and related functions to verify your tests produce the correct results. - // XCUIAutomation Documentation - // https://developer.apple.com/documentation/xcuiautomation } + /// Measures how long a cold launch takes. + /// + /// - Throws: Rethrows any launch failure to XCTest. @MainActor func testLaunchPerformance() throws { - // This measures how long it takes to launch your application. measure(metrics: [XCTApplicationLaunchMetric()]) { XCUIApplication().launch() } diff --git a/src/Reprise.Platform.MacOS/RepriseUITests/RepriseUITestsLaunchTests.swift b/src/Reprise.Platform.MacOS/RepriseUITests/RepriseUITestsLaunchTests.swift index 9c00293..ee53202 100644 --- a/src/Reprise.Platform.MacOS/RepriseUITests/RepriseUITestsLaunchTests.swift +++ b/src/Reprise.Platform.MacOS/RepriseUITests/RepriseUITestsLaunchTests.swift @@ -7,26 +7,39 @@ import XCTest +/// Captures a launch screenshot for each UI configuration. +/// +/// Separate from ``RepriseUITests`` because these run once per appearance and +/// language combination Xcode is configured with, and exist to produce +/// attachments for review rather than to assert anything. final class RepriseUITestsLaunchTests: XCTestCase { + /// Runs the tests once per target application UI configuration. + /// + /// What turns a single launch check into one screenshot per appearance and + /// localisation, which is the point of this class. override class var runsForEachTargetApplicationUIConfiguration: Bool { true } + /// Stops the run at the first failure. + /// + /// - Throws: Rethrows any setup failure to XCTest. override func setUpWithError() throws { continueAfterFailure = false } + /// Launches the app and attaches a screenshot. + /// + /// The attachment is kept regardless of outcome, since its value is the + /// visual record rather than evidence of a failure. + /// + /// - Throws: Rethrows any launch failure to XCTest. @MainActor func testLaunch() throws { let app = XCUIApplication() app.launch() - // Insert steps here to perform after app launch but before taking a screenshot, - // such as logging into a test account or navigating somewhere in the app - // XCUIAutomation Documentation - // https://developer.apple.com/documentation/xcuiautomation - let attachment = XCTAttachment(screenshot: app.screenshot()) attachment.name = "Launch Screen" attachment.lifetime = .keepAlways diff --git a/tests/integration/linux/common/.gitkeep b/tests/integration/linux/common/.gitkeep deleted file mode 100644 index 8b13789..0000000 --- a/tests/integration/linux/common/.gitkeep +++ /dev/null @@ -1 +0,0 @@ - diff --git a/tests/integration/linux/common/MprisMediaSessionServiceTests.cs b/tests/integration/linux/common/MprisMediaSessionServiceTests.cs new file mode 100644 index 0000000..19cd9ca --- /dev/null +++ b/tests/integration/linux/common/MprisMediaSessionServiceTests.cs @@ -0,0 +1,513 @@ +using Reprise.Core; +using Reprise.Platform.Linux.Mpris; +using Tmds.DBus.Protocol; +using Xunit; + +namespace Reprise.Platform.Linux.Tests; + +/// +/// Covers the MPRIS bridge from D-Bus property values to Reprise sessions. +/// +/// +/// Runs against a fake rather than a live session bus, +/// because CI has no desktop session and no media players. Real +/// VariantValue instances are still used throughout, so the variant +/// unwrapping and type handling that the mapper exists for are genuinely +/// exercised; only the transport is stood in for. +/// +public sealed class MprisMediaSessionServiceTests +{ + /// + /// Every field a player publishes reaches the snapshot intact. + /// + /// + /// The broad assertion is deliberate: it pins the whole property mapping + /// in one place, including the microsecond conversion and the derivation + /// of PlayerId and PlayerName from the bus name. + /// + [Fact] + public async Task PlayerPropertiesBecomeLinuxMediaSessions() + { + var bus = new FakeMprisBus + { + Players = + { + ["org.mpris.MediaPlayer2.spotify"] = PlayerProperties( + status: "Playing", + title: "Bridge Song", + artists: ["Bridge Artist"], + album: "Bridge Album", + lengthMicroseconds: 180_000_000, + positionMicroseconds: 12_000_000, + volume: 0.64, + artUrl: "https://example.com/art.jpg"), + }, + }; + var service = new MprisMediaSessionService(bus); + + var sessions = await service.GetSessionsAsync(); + + var session = Assert.Single(sessions); + Assert.Equal("spotify", session.PlayerId); + Assert.Equal("Spotify", session.PlayerName); + Assert.Equal(PlaybackStatus.Playing, session.Status); + Assert.Equal("Bridge Song", session.Title); + Assert.Equal("Bridge Artist", session.Artist); + Assert.Equal("Bridge Album", session.Album); + Assert.Equal(TimeSpan.FromSeconds(180), session.Duration); + Assert.Equal(TimeSpan.FromSeconds(12), session.Position); + Assert.Equal(0.64, session.Volume); + Assert.Equal(new Uri("https://example.com/art.jpg"), session.ArtworkUri); + } + + /// + /// An artist array collapses into one display string. + /// + /// + /// Also checks that a track with no artwork yields a null URI rather than + /// an empty or malformed one, since both fields are optional in the same + /// metadata dictionary. + /// + [Fact] + public async Task MultipleArtistsAreJoined() + { + var bus = new FakeMprisBus + { + Players = + { + ["org.mpris.MediaPlayer2.vlc"] = PlayerProperties( + status: "Paused", + title: "Duet", + artists: ["First Artist", "Second Artist"], + album: "Split Album", + lengthMicroseconds: 90_000_000, + positionMicroseconds: 0, + volume: 1.0, + artUrl: null), + }, + }; + var service = new MprisMediaSessionService(bus); + + var session = Assert.Single(await service.GetSessionsAsync()); + + Assert.Equal("First Artist, Second Artist", session.Artist); + Assert.Equal(PlaybackStatus.Paused, session.Status); + Assert.Null(session.ArtworkUri); + } + + /// + /// A desktop with no media player is an empty result, not a failure. + /// + /// + /// This is the state Reprise starts in on most machines, so it has to + /// stay on the quiet path rather than reaching the window's error line. + /// + [Fact] + public async Task NoMprisServiceMeansNoSessions() + { + var service = new MprisMediaSessionService(new FakeMprisBus()); + + var sessions = await service.GetSessionsAsync(); + + Assert.Empty(sessions); + } + + /// + /// A player that quits mid-poll is dropped instead of reported blank. + /// + /// + /// Reproduces the race the once-a-second refresh runs into whenever a + /// window is closed: the player is listed, then answers with nothing. + /// Without the skip it would surface as a session with no track at all. + /// + [Fact] + public async Task PlayerThatLeftTheBusIsSkipped() + { + var bus = new FakeMprisBus + { + Players = { ["org.mpris.MediaPlayer2.ghost"] = [] }, + }; + var service = new MprisMediaSessionService(bus); + + var sessions = await service.GetSessionsAsync(); + + Assert.Empty(sessions); + } + + /// + /// Each command maps to its MPRIS method and addresses the named player. + /// + /// + /// The service name assertion is the important half: a command must reach + /// the player the user was looking at, not whichever one is active now. + /// + /// Command passed to the service. + /// MPRIS method it should become. + [Theory] + [InlineData(PlaybackCommand.Previous, "Previous")] + [InlineData(PlaybackCommand.PlayPause, "PlayPause")] + [InlineData(PlaybackCommand.Next, "Next")] + [InlineData(PlaybackCommand.Pause, "Pause")] + public async Task PlaybackCommandsTargetTheSelectedPlayer( + PlaybackCommand command, + string expectedMember) + { + var bus = new FakeMprisBus(); + var service = new MprisMediaSessionService(bus); + + await service.SendCommandAsync("firefox.instance42", command); + + Assert.Equal("org.mpris.MediaPlayer2.firefox.instance42", bus.InvokedService); + Assert.Equal(expectedMember, bus.InvokedMember); + } + + /// + /// A PlayerId read from a snapshot addresses the same player on the way + /// back. + /// + /// + /// Uses a browser's dotted instance name, the case where stripping and + /// restoring the prefix is easiest to get wrong. Also confirms the + /// display name drops that instance suffix while the id keeps it. + /// + [Fact] + public async Task PlayerIdsAreRoundTrippedFromTheServiceName() + { + var bus = new FakeMprisBus + { + Players = + { + ["org.mpris.MediaPlayer2.firefox.instance42"] = PlayerProperties( + status: "Playing", + title: "Tab Audio", + artists: [], + album: string.Empty, + lengthMicroseconds: 0, + positionMicroseconds: 0, + volume: 0.5, + artUrl: null), + }, + }; + var service = new MprisMediaSessionService(bus); + + var session = Assert.Single(await service.GetSessionsAsync()); + Assert.Equal("firefox.instance42", session.PlayerId); + Assert.Equal("Firefox", session.PlayerName); + + await service.SendCommandAsync(session.PlayerId, PlaybackCommand.Next); + + Assert.Equal("org.mpris.MediaPlayer2.firefox.instance42", bus.InvokedService); + } + + /// + /// A seek is addressed to the track the player currently reports. + /// + /// + /// The track id is what makes an MPRIS seek safe against a track change + /// racing the request, so it has to come from the player rather than be + /// invented, and the position has to arrive in microseconds. + /// + [Fact] + public async Task SeekUsesTheReportedTrackId() + { + var bus = new FakeMprisBus + { + Players = + { + ["org.mpris.MediaPlayer2.spotify"] = PlayerProperties( + status: "Playing", + title: "Bridge Song", + artists: ["Bridge Artist"], + album: "Bridge Album", + lengthMicroseconds: 180_000_000, + positionMicroseconds: 12_000_000, + volume: 0.64, + artUrl: null), + }, + }; + var service = new MprisMediaSessionService(bus); + + await service.SeekAsync("spotify", TimeSpan.FromSeconds(90)); + + Assert.Equal("org.mpris.MediaPlayer2.spotify", bus.SeekedService); + Assert.Equal("/org/fake/track/1", bus.SeekedTrackId); + Assert.Equal(90_000_000, bus.SeekedPositionMicroseconds); + } + + /// + /// A player without a track id is reported as unseekable, not guessed at. + /// + [Fact] + public async Task SeekWithoutTrackIdFails() + { + var bus = new FakeMprisBus + { + Players = + { + ["org.mpris.MediaPlayer2.stream"] = new Dictionary + { + [MprisPropertyMapper.PlaybackStatusKey] = VariantValue.String("Playing"), + }, + }, + }; + var service = new MprisMediaSessionService(bus); + + await Assert.ThrowsAsync( + () => service.SeekAsync("stream", TimeSpan.FromSeconds(1))); + Assert.Null(bus.SeekedService); + } + + /// + /// Volume writes are clamped into the range MPRIS defines. + /// + /// Level handed to the service. + /// Level that should reach the bus. + [Theory] + [InlineData(0.5, 0.5)] + [InlineData(1.2, 1.0)] + [InlineData(-0.1, 0.0)] + public async Task VolumeIsClampedBeforeItIsWritten(double requested, double expected) + { + var bus = new FakeMprisBus(); + var service = new MprisMediaSessionService(bus); + + await service.SetVolumeAsync("vlc", requested); + + Assert.Equal("org.mpris.MediaPlayer2.vlc", bus.VolumeService); + Assert.Equal(expected, bus.WrittenVolume); + } + + /// + /// A rejection that arrives as a stack trace is reduced to its last line. + /// + /// + /// Python-based players reply with the whole traceback; the panel has + /// room for one line, and the last one is the one that names the fault. + /// + [Fact] + public async Task MultiLineBusErrorsAreReducedToTheirLastLine() + { + var bus = new FakeMprisBus + { + InvokeFailure = new DBusErrorReplyException( + "org.freedesktop.DBus.Error.UnknownMethod", + "Traceback (most recent call last):\n File \"service.py\", line 659\n\nUnknown method: Next is not a valid method\n"), + }; + var service = new MprisMediaSessionService(bus); + + var exception = await Assert.ThrowsAsync( + () => service.SendCommandAsync("vlc", PlaybackCommand.Next)); + + Assert.Equal("MPRIS Next failed: Unknown method: Next is not a valid method", exception.Message); + Assert.IsType(exception.InnerException); + } + + /// + /// Builds a property dictionary shaped the way a real player publishes + /// one. + /// + /// + /// The nesting is the part that matters: track fields live inside a + /// Metadata dictionary that is itself a variant, so a flat dictionary + /// here would let the mapper's unwrapping pass untested. + /// + /// Value for the PlaybackStatus property. + /// Track title. + /// Artist credits, published as an array. + /// Album name. + /// Track length in microseconds. + /// + /// Playback position in microseconds. + /// + /// Player volume from 0 to 1. + /// + /// Cover art URI, or null to omit the key entirely as a player without + /// artwork would. + /// + /// Player properties ready to hand to the fake bus. + private static Dictionary PlayerProperties( + string status, + string title, + string[] artists, + string album, + long lengthMicroseconds, + long positionMicroseconds, + double volume, + string? artUrl) + { + var metadata = new Dictionary + { + [MprisPropertyMapper.TrackIdKey] = + VariantValue.ObjectPath(new ObjectPath("/org/fake/track/1")), + [MprisPropertyMapper.TitleKey] = VariantValue.String(title), + [MprisPropertyMapper.ArtistKey] = VariantValue.Array(artists), + [MprisPropertyMapper.AlbumKey] = VariantValue.String(album), + [MprisPropertyMapper.LengthKey] = VariantValue.Int64(lengthMicroseconds), + }; + if (artUrl is not null) + { + metadata[MprisPropertyMapper.ArtUrlKey] = VariantValue.String(artUrl); + } + + return new Dictionary + { + [MprisPropertyMapper.PlaybackStatusKey] = VariantValue.String(status), + [MprisPropertyMapper.MetadataKey] = + new Dict(metadata).AsVariantValue(), + [MprisPropertyMapper.PositionKey] = VariantValue.Int64(positionMicroseconds), + [MprisPropertyMapper.VolumeKey] = VariantValue.Double(volume), + }; + } + + /// + /// In-memory stand-in for the session bus. + /// + /// + /// Serves players out of a dictionary and records the last method call, + /// so a test can assert on what would have gone over the bus. Commands + /// are accepted for any name, including one with no player behind it, + /// which is what lets the command tests skip building a session first. + /// + private sealed class FakeMprisBus : IMprisBus + { + /// Players to serve, keyed by bus name. + public Dictionary> Players { get; } = []; + + /// + /// Bus name from the most recent , or null if + /// it was never called. + /// + public string? InvokedService { get; private set; } + + /// + /// Member name from the most recent , or null + /// if it was never called. + /// + public string? InvokedMember { get; private set; } + + /// + /// Error every replies with, or null to accept. + /// + public DBusErrorReplyException? InvokeFailure { get; set; } + + /// + /// Bus name from the most recent , or + /// null if it was never called. + /// + public string? SeekedService { get; private set; } + + /// + /// Track id from the most recent . + /// + public string? SeekedTrackId { get; private set; } + + /// + /// Position from the most recent . + /// + public long? SeekedPositionMicroseconds { get; private set; } + + /// + /// Bus name from the most recent , or + /// null if it was never called. + /// + public string? VolumeService { get; private set; } + + /// + /// Level from the most recent . + /// + public double? WrittenVolume { get; private set; } + + /// + /// Returns the configured bus names in the order the real bus would. + /// + /// + /// Sorted to match , so a test that depends + /// on session order is not passing for the wrong reason. + /// + /// Ignored. + /// Configured bus names, sorted ordinally. + public Task> ListPlayerServicesAsync( + CancellationToken cancellationToken = default) + { + IReadOnlyList services = [.. Players.Keys.OrderBy(key => key, StringComparer.Ordinal)]; + return Task.FromResult(services); + } + + /// + /// Returns one player's configured properties. + /// + /// + /// An unknown name yields an empty dictionary rather than throwing, + /// mirroring how the real bus reports a player that has quit. + /// + /// Bus name to look up. + /// Ignored. + /// The player's properties, or an empty dictionary. + public Task> GetPlayerPropertiesAsync( + string serviceName, + CancellationToken cancellationToken = default) + { + IReadOnlyDictionary properties = + Players.TryGetValue(serviceName, out var found) + ? found + : new Dictionary(); + return Task.FromResult(properties); + } + + /// + /// Records a command instead of sending it. + /// + /// + /// Bus name the command was addressed to. + /// + /// MPRIS method that was requested. + /// Ignored. + /// An already completed task. + public Task InvokeAsync( + string serviceName, + string member, + CancellationToken cancellationToken = default) + { + InvokedService = serviceName; + InvokedMember = member; + return InvokeFailure is { } failure ? Task.FromException(failure) : Task.CompletedTask; + } + + /// + /// Records a seek instead of sending it. + /// + /// Bus name the seek was addressed to. + /// Track id the seek carried. + /// Requested position. + /// Ignored. + /// An already completed task. + public Task SetPositionAsync( + string serviceName, + string trackId, + long positionMicroseconds, + CancellationToken cancellationToken = default) + { + SeekedService = serviceName; + SeekedTrackId = trackId; + SeekedPositionMicroseconds = positionMicroseconds; + return Task.CompletedTask; + } + + /// + /// Records a volume write instead of sending it. + /// + /// Bus name the write was addressed to. + /// Requested level. + /// Ignored. + /// An already completed task. + public Task SetVolumeAsync( + string serviceName, + double volume, + CancellationToken cancellationToken = default) + { + VolumeService = serviceName; + WrittenVolume = volume; + return Task.CompletedTask; + } + } +} diff --git a/tests/integration/linux/common/Reprise.Platform.Linux.Tests.csproj b/tests/integration/linux/common/Reprise.Platform.Linux.Tests.csproj new file mode 100644 index 0000000..8e87f78 --- /dev/null +++ b/tests/integration/linux/common/Reprise.Platform.Linux.Tests.csproj @@ -0,0 +1,18 @@ + + + false + true + Reprise.Platform.Linux.Tests + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + diff --git a/tests/unit/Reprise.Core.Tests/.gitkeep b/tests/unit/Reprise.Core.Tests/.gitkeep deleted file mode 100644 index 8b13789..0000000 --- a/tests/unit/Reprise.Core.Tests/.gitkeep +++ /dev/null @@ -1 +0,0 @@ - diff --git a/tests/unit/Reprise.Core.Tests/ActiveSessionSelectorTests.cs b/tests/unit/Reprise.Core.Tests/ActiveSessionSelectorTests.cs new file mode 100644 index 0000000..2d23fe7 --- /dev/null +++ b/tests/unit/Reprise.Core.Tests/ActiveSessionSelectorTests.cs @@ -0,0 +1,145 @@ +using Reprise.Core; +using Xunit; + +namespace Reprise.Core.Tests; + +/// +/// Pins the ordering rules applies. +/// +/// +/// These cover the tie-breaks rather than the happy path, since a single +/// running player needs no selection logic and the rules only become visible +/// when several players compete for the panel. +/// +public sealed class ActiveSessionSelectorTests +{ + /// + /// Playback status outranks recency. + /// + /// + /// A paused player updating its state must not steal the panel from one + /// that is actually playing, which is what would happen if the sort led + /// with the observation timestamp. + /// + [Fact] + public void PlayingSessionWinsOverNewerPausedSession() + { + var now = DateTimeOffset.UtcNow; + var selected = ActiveSessionSelector.Select([ + Session("vlc", PlaybackStatus.Playing, now.AddSeconds(-5)), + Session("spotify", PlaybackStatus.Paused, now), + ]); + + Assert.Equal("vlc", selected?.PlayerId); + } + + /// + /// The preference list decides between players in the same state. + /// + /// + /// Covers the case the setting exists for: two players both playing, + /// where only the user's stated priority can pick one. + /// + [Fact] + public void PreferredPlayerBreaksEqualPlaybackStateTie() + { + var now = DateTimeOffset.UtcNow; + var selected = ActiveSessionSelector.Select( + [ + Session("firefox", PlaybackStatus.Playing, now), + Session("spotify", PlaybackStatus.Playing, now), + ], + ["spotify", "firefox"]); + + Assert.Equal("spotify", selected?.PlayerId); + } + + /// + /// No players means no selection, rather than a throw. + /// + [Fact] + public void EmptySessionListHasNoSelection() + { + Assert.Null(ActiveSessionSelector.Select([])); + } + + /// + /// A repeated preference entry keeps its first, strongest position. + /// + /// + /// Guards the TryAdd in the priority map: switching it to an + /// indexer would let a later duplicate demote the player the user ranked + /// highest. + /// + [Fact] + public void DuplicatePreferenceEntriesKeepTheirFirstPriority() + { + var now = DateTimeOffset.UtcNow; + var selected = ActiveSessionSelector.Select( + [ + Session("firefox", PlaybackStatus.Playing, now), + Session("spotify", PlaybackStatus.Playing, now), + ], + ["spotify", "spotify", "firefox"]); + + Assert.Equal("spotify", selected?.PlayerId); + } + + /// + /// Progress stays within 0 to 1 even when a player reports out of range. + /// + /// + /// Both ends occur in practice: a negative position while seeking, and a + /// position past the end while advancing to the next track. + /// + /// Position reported by the player. + /// Progress the UI should receive. + [Theory] + [InlineData(-5, 0)] + [InlineData(30, 0.5)] + [InlineData(90, 1)] + public void ProgressIsClamped(double positionSeconds, double expected) + { + var session = Session("spotify", PlaybackStatus.Playing) + with + { + Duration = TimeSpan.FromSeconds(60), + Position = TimeSpan.FromSeconds(positionSeconds), + }; + + Assert.Equal(expected, session.Progress, precision: 6); + } + + /// + /// Builds a snapshot carrying only the fields these tests sort on. + /// + /// + /// Everything else is filled with fixed placeholders so a test reads as + /// the ordering rule it covers rather than a wall of constructor + /// arguments. + /// + /// Player id, also used as the display name. + /// Status to rank the session by. + /// + /// Observation time; defaults to now for tests where recency is not the + /// rule under test. + /// + /// + /// A snapshot ready to pass to . + /// + private static MediaSessionSnapshot Session( + string id, + PlaybackStatus status, + DateTimeOffset? observedAt = null) => new( + PlayerId: id, + PlayerName: id, + Status: status, + Title: "Track", + Artist: "Artist", + Album: "Album", + Duration: null, + Position: null, + Volume: null, + ArtworkUri: null, + ObservedAt: observedAt ?? DateTimeOffset.UtcNow); +} diff --git a/tests/unit/Reprise.Core.Tests/LyricsTests.cs b/tests/unit/Reprise.Core.Tests/LyricsTests.cs new file mode 100644 index 0000000..73aeefc --- /dev/null +++ b/tests/unit/Reprise.Core.Tests/LyricsTests.cs @@ -0,0 +1,260 @@ +using System.Net; +using Reprise.Core; +using Xunit; + +namespace Reprise.Core.Tests; + +/// +/// Pins the lyrics model, the LRC parser, and the track query equality. +/// +public sealed class LyricsModelTests +{ + /// + /// Focus follows the last started line; the current line honours end times. + /// + [Fact] + public void FocusedAndCurrentLinesDiffer() + { + var lyrics = new SyncedLyrics(LyricsSource.Vibe, + [ + new LyricLine(TimeSpan.FromSeconds(10), TimeSpan.FromSeconds(12), "first"), + new LyricLine(TimeSpan.FromSeconds(20), TimeSpan.FromSeconds(24), "second"), + ]); + + Assert.Null(lyrics.FocusedLineIndex(TimeSpan.FromSeconds(5))); + Assert.Equal(0, lyrics.FocusedLineIndex(TimeSpan.FromSeconds(15))); + Assert.Null(lyrics.LineIndex(TimeSpan.FromSeconds(15))); + Assert.Equal(1, lyrics.LineIndex(TimeSpan.FromSeconds(21))); + Assert.Equal("second", lyrics.Line(TimeSpan.FromSeconds(23.9))?.Text); + Assert.Null(lyrics.Line(TimeSpan.FromSeconds(24))); + } + + /// + /// LRC lines with several timestamps, an offset, and metadata all parse. + /// + [Fact] + public void LrcParserHandlesTimestampsOffsetAndMetadata() + { + const string lrc = "[ar:Someone]\n[offset:500]\n[00:15.50][00:05.00]Chorus\n[00:10.000]Verse\n[00:12]\n"; + + var lines = LrcParser.Parse(lrc, TimeSpan.FromSeconds(20)); + + Assert.Equal(["Chorus", "Verse", "Chorus"], lines.Select(l => l.Text)); + Assert.Equal(TimeSpan.FromSeconds(5.5), lines[0].StartTime); + Assert.Equal(TimeSpan.FromSeconds(10.5), lines[0].EndTime); + Assert.Equal(TimeSpan.FromSeconds(16), lines[2].StartTime); + Assert.Equal(TimeSpan.FromSeconds(20), lines[2].EndTime); + } + + /// + /// Without a duration the last line stays open-ended. + /// + [Fact] + public void LrcParserLeavesLastLineOpenWithoutDuration() + { + var lines = LrcParser.Parse("[00:01.00]Only", TimeSpan.Zero); + + Assert.Single(lines); + Assert.Null(lines[0].EndTime); + } + + /// + /// Queries compare on normalised text and ignore duration. + /// + [Fact] + public void QueriesCompareLoosely() + { + var left = new LyricsTrackQuery("Café D'Amour", "Live!", "Édith", TimeSpan.FromSeconds(200)); + var right = new LyricsTrackQuery("cafe damour", "live", "edith", TimeSpan.FromSeconds(180)); + + Assert.Equal("cafedamour", left.NormalizedTitle); + Assert.Equal(left, right); + Assert.Equal(left.GetHashCode(), right.GetHashCode()); + Assert.NotEqual(left, new LyricsTrackQuery("Other", "live", "edith", TimeSpan.Zero)); + } + + /// + /// A session without a title yields no query. + /// + [Fact] + public void SessionWithoutTitleHasNoQuery() + { + var session = new MediaSessionSnapshot( + "spotify", "Spotify", PlaybackStatus.Playing, " ", "Artist", "Album", + TimeSpan.FromSeconds(180), TimeSpan.Zero, 1, null, DateTimeOffset.UtcNow); + + Assert.Null(LyricsTrackQuery.From(session)); + } +} + +/// +/// Covers the VIBE and LRCLIB parsing, scoring, and fallback order. +/// +public sealed class LyricsServiceTests +{ + private const string VibeSearchXml = """ + + 111Bridge Song + Bridge Artist + Bridge Album + 3:00truefalse + + 222Bridge Song (Live) + Bridge Artist + Live + 3:40falsefalse + + """; + + private const string VibeLyricXml = """ + true + 12.515.0 + 14.018.0 + default첫 줄둘째 줄 + + """; + + private static readonly LyricsTrackQuery Query = + new("Bridge Song", "Bridge Album", "Bridge Artist", TimeSpan.FromSeconds(180)); + + /// + /// The VIBE search reply is read into candidates with their flags. + /// + [Fact] + public void VibeSearchParsesCandidates() + { + var candidates = LyricsService.ParseVibeSearch(VibeSearchXml); + + Assert.Equal(2, candidates.Count); + Assert.Equal("111", candidates[0].TrackId); + Assert.Equal(["Bridge Artist"], candidates[0].Artists); + Assert.Equal(TimeSpan.FromSeconds(180), candidates[0].Duration); + Assert.True(candidates[0].HasSyncedLyrics); + Assert.False(candidates[1].HasSyncedLyrics); + } + + /// + /// Only a synced, plausible candidate is chosen. + /// + [Fact] + public void BestVibeCandidateRequiresSyncedLyricsAndAMatch() + { + var candidates = LyricsService.ParseVibeSearch(VibeSearchXml); + + Assert.Equal("111", LyricsService.BestVibeCandidate(Query, candidates)?.TrackId); + Assert.Null(LyricsService.BestVibeCandidate( + new LyricsTrackQuery("Unrelated", "", "Nobody", TimeSpan.Zero), + candidates)); + } + + /// + /// VIBE lyrics carry explicit end times. + /// + [Fact] + public void VibeLyricsParseWithEndTimes() + { + var lyrics = LyricsService.ParseVibeLyrics(VibeLyricXml); + + Assert.NotNull(lyrics); + Assert.Equal(LyricsSource.Vibe, lyrics.Source); + Assert.Equal(["첫 줄", "둘째 줄"], lyrics.Lines.Select(l => l.Text)); + Assert.Equal(TimeSpan.FromSeconds(14), lyrics.Lines[0].EndTime); + } + + /// + /// Among matching LRCLIB results the closest duration wins. + /// + [Fact] + public void BestLrclibResultPrefersClosestDuration() + { + var results = new List + { + new("Bridge Song", "Bridge Artist", null, 240, "[00:01.00]x"), + new("Bridge Song", "Bridge Artist", null, 182, "[00:01.00]y"), + new("Bridge Song", "Bridge Artist", null, 180, null), + new("Other", "Bridge Artist", null, 180, "[00:01.00]z"), + }; + + Assert.Equal(182, LyricsService.BestLrclibResult(Query, results)?.Duration); + } + + /// + /// VIBE is tried first, then LRCLIB's exact match, then its search. + /// + [Fact] + public async Task FallsBackFromVibeToLrclibSearch() + { + var handler = new FakeHandler + { + ["apis.naver.com"] = (HttpStatusCode.ServiceUnavailable, ""), + ["lrclib.net/api/get"] = (HttpStatusCode.NotFound, "{}"), + ["lrclib.net/api/search"] = (HttpStatusCode.OK, + """[{"trackName":"Bridge Song","artistName":"Bridge Artist","albumName":null,"duration":181.0,"syncedLyrics":"[00:12.00]Hello\n[00:15.00]World"}]"""), + }; + var service = new LyricsService(handler); + + var lyrics = await service.FetchSyncedLyricsAsync(Query); + + Assert.NotNull(lyrics); + Assert.Equal(LyricsSource.Lrclib, lyrics.Source); + Assert.Equal(["Hello", "World"], lyrics.Lines.Select(l => l.Text)); + Assert.Equal(TimeSpan.FromSeconds(180), lyrics.Lines[1].EndTime); + Assert.Contains(handler.Requests, r => r.Contains("v3/search/track") && r.Contains("query=Bridge%20Song%20Bridge%20Artist")); + Assert.Contains(handler.Requests, r => r.Contains("api/get?") && r.Contains("duration=180")); + } + + /// + /// A VIBE hit short-circuits the LRCLIB lookups. + /// + [Fact] + public async Task VibeHitSkipsLrclib() + { + var handler = new FakeHandler + { + ["v3/search/track"] = (HttpStatusCode.OK, VibeSearchXml), + ["vibe/v4/lyric/111"] = (HttpStatusCode.OK, VibeLyricXml), + }; + var service = new LyricsService(handler); + + var lyrics = await service.FetchSyncedLyricsAsync(Query); + + Assert.Equal(LyricsSource.Vibe, lyrics?.Source); + Assert.DoesNotContain(handler.Requests, r => r.Contains("lrclib")); + } + + /// + /// Scripted HTTP stack keyed by a substring of the request address. + /// + private sealed class FakeHandler : HttpMessageHandler + { + private readonly List<(string Match, HttpStatusCode Status, string Body)> _routes = []; + + /// + /// Addresses requested so far. + /// + public List Requests { get; } = []; + + /// + /// Adds a route. + /// + /// Substring of the address. + public (HttpStatusCode, string) this[string match] + { + set => _routes.Add((match, value.Item1, value.Item2)); + } + + /// + protected override Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken) + { + var address = request.RequestUri!.AbsoluteUri; + Requests.Add(address); + var route = _routes.FirstOrDefault(r => address.Contains(r.Match, StringComparison.Ordinal)); + var response = route.Match is null + ? new HttpResponseMessage(HttpStatusCode.NotFound) + : new HttpResponseMessage(route.Status) { Content = new StringContent(route.Body) }; + return Task.FromResult(response); + } + } +} diff --git a/tests/unit/Reprise.Core.Tests/PlaybackPositionTests.cs b/tests/unit/Reprise.Core.Tests/PlaybackPositionTests.cs new file mode 100644 index 0000000..4756fa4 --- /dev/null +++ b/tests/unit/Reprise.Core.Tests/PlaybackPositionTests.cs @@ -0,0 +1,101 @@ +using Reprise.Core; +using Xunit; + +namespace Reprise.Core.Tests; + +/// +/// Pins how a sampled position is projected and confirmed. +/// +public sealed class PlaybackPositionTests +{ + /// + /// A playing session advances by the time since it was observed. + /// + [Fact] + public void PlayingSessionAdvancesWithTime() + { + var observedAt = new DateTimeOffset(2026, 9, 6, 12, 0, 0, TimeSpan.Zero); + + var estimated = PlaybackPosition.Estimate( + TimeSpan.FromSeconds(10), + PlaybackStatus.Playing, + observedAt, + observedAt.AddSeconds(2.5), + TimeSpan.FromSeconds(180)); + + Assert.Equal(TimeSpan.FromSeconds(12.5), estimated); + } + + /// + /// A paused session stays where it was reported. + /// + [Fact] + public void PausedSessionDoesNotAdvance() + { + var observedAt = DateTimeOffset.UtcNow; + + var estimated = PlaybackPosition.Estimate( + TimeSpan.FromSeconds(10), + PlaybackStatus.Paused, + observedAt, + observedAt.AddSeconds(30), + TimeSpan.FromSeconds(180)); + + Assert.Equal(TimeSpan.FromSeconds(10), estimated); + } + + /// + /// Projection never runs past the end of the track. + /// + /// + /// Without the clamp a track that ended between polls would show a + /// position longer than its duration until the next poll corrected it. + /// + [Fact] + public void EstimateIsClampedToDuration() + { + var observedAt = DateTimeOffset.UtcNow; + + var estimated = PlaybackPosition.Estimate( + TimeSpan.FromSeconds(175), + PlaybackStatus.Playing, + observedAt, + observedAt.AddSeconds(20), + TimeSpan.FromSeconds(180)); + + Assert.Equal(TimeSpan.FromSeconds(180), estimated); + } + + /// + /// Without a duration there is no range, so the position reads as zero. + /// + [Theory] + [InlineData(null)] + [InlineData(0L)] + [InlineData(-5L)] + public void MissingDurationClampsToZero(long? durationSeconds) + { + TimeSpan? duration = durationSeconds is { } seconds + ? TimeSpan.FromSeconds(seconds) + : null; + + Assert.Equal(TimeSpan.Zero, PlaybackPosition.Clamp(TimeSpan.FromSeconds(30), duration)); + } + + /// + /// A seek counts as landed within the tolerance and not beyond it. + /// + [Theory] + [InlineData(90.0, 90.0, true)] + [InlineData(91.4, 90.0, true)] + [InlineData(88.6, 90.0, true)] + [InlineData(92.0, 90.0, false)] + public void SeekConfirmationUsesTolerance(double actual, double target, bool expected) + { + Assert.Equal( + expected, + PlaybackPosition.ConfirmsSeek( + TimeSpan.FromSeconds(actual), + TimeSpan.FromSeconds(target))); + } +} diff --git a/tests/unit/Reprise.Core.Tests/PlayerVolumeTests.cs b/tests/unit/Reprise.Core.Tests/PlayerVolumeTests.cs new file mode 100644 index 0000000..f958073 --- /dev/null +++ b/tests/unit/Reprise.Core.Tests/PlayerVolumeTests.cs @@ -0,0 +1,49 @@ +using Reprise.Core; +using Xunit; + +namespace Reprise.Core.Tests; + +/// +/// Pins the percent conversions and mute behaviour of the volume control. +/// +public sealed class PlayerVolumeTests +{ + /// + /// Fractions round to the nearest whole percent and stay in range. + /// + [Theory] + [InlineData(0.64, 64)] + [InlineData(0.645, 65)] + [InlineData(1.5, 100)] + [InlineData(-0.2, 0)] + [InlineData(double.NaN, 0)] + public void FractionsConvertToPercent(double fraction, int expected) + { + Assert.Equal(expected, PlayerVolume.FromFraction(fraction)); + } + + /// + /// Percent converts back to a clamped fraction. + /// + [Theory] + [InlineData(64, 0.64)] + [InlineData(250, 1.0)] + [InlineData(-3, 0.0)] + public void PercentConvertsToFraction(int percent, double expected) + { + Assert.Equal(expected, PlayerVolume.ToFraction(percent), precision: 9); + } + + /// + /// Toggling from an audible level mutes; toggling from silence restores. + /// + [Theory] + [InlineData(64, 64, 0)] + [InlineData(0, 64, 64)] + [InlineData(0, null, PlayerVolume.DefaultAudibleLevel)] + [InlineData(0, 0, PlayerVolume.DefaultAudibleLevel)] + public void MuteToggleRoundTrips(int current, int? lastAudible, int expected) + { + Assert.Equal(expected, PlayerVolume.MuteToggleTarget(current, lastAudible)); + } +} diff --git a/tests/unit/Reprise.Core.Tests/Reprise.Core.Tests.csproj b/tests/unit/Reprise.Core.Tests/Reprise.Core.Tests.csproj new file mode 100644 index 0000000..d99e89e --- /dev/null +++ b/tests/unit/Reprise.Core.Tests/Reprise.Core.Tests.csproj @@ -0,0 +1,18 @@ + + + false + true + Reprise.Core.Tests + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + diff --git a/tests/unit/Reprise.Desktop.Tests/MenuBarTextTests.cs b/tests/unit/Reprise.Desktop.Tests/MenuBarTextTests.cs new file mode 100644 index 0000000..1bca703 --- /dev/null +++ b/tests/unit/Reprise.Desktop.Tests/MenuBarTextTests.cs @@ -0,0 +1,82 @@ +using Reprise.Core; +using Reprise.Desktop; +using Xunit; + +namespace Reprise.Desktop.Tests; + +/// +/// Pins how the tray label is composed and scrolled. +/// +public sealed class MenuBarTextTests +{ + private static readonly MediaSessionSnapshot Session = new( + "spotify", "Spotify", PlaybackStatus.Playing, "Bridge Song", "Bridge Artist", "Bridge Album", + TimeSpan.FromSeconds(180), TimeSpan.Zero, 1, null, DateTimeOffset.UtcNow); + + /// + /// Each format arranges title and artist the macOS way. + /// + /// Format under test. + /// Expected text. + [Theory] + [InlineData(MenuBarTitleFormat.TitleOnly, "Bridge Song")] + [InlineData(MenuBarTitleFormat.TitleArtist, "Bridge Song - Bridge Artist")] + [InlineData(MenuBarTitleFormat.ArtistTitle, "Bridge Artist - Bridge Song")] + [InlineData(MenuBarTitleFormat.Hidden, "")] + public void FormatsArrangeTitleAndArtist(MenuBarTitleFormat format, string expected) + { + var preferences = new DesktopPreferences(MenuBarTitleFormat: format); + + Assert.Equal(expected, MenuBarText.Compose(Session, null, preferences)); + } + + /// + /// An empty part drops out along with its dash. + /// + [Fact] + public void EmptyPartsAreDropped() + { + Assert.Equal("Solo", MenuBarText.FormatTitle(MenuBarTitleFormat.TitleArtist, "Solo", " ")); + Assert.Equal("Band", MenuBarText.FormatTitle(MenuBarTitleFormat.TitleOnly, "", "Band")); + } + + /// + /// Lyrics replace the title only when enabled and present. + /// + [Fact] + public void LyricsReplaceTitleWhenEnabled() + { + Assert.Equal("la la", MenuBarText.Compose(Session, " la la ", new DesktopPreferences(MenuBarShowsLyrics: true))); + Assert.Equal("Bridge Song", MenuBarText.Compose(Session, "la la", new DesktopPreferences(MenuBarShowsLyrics: false))); + Assert.Equal("Bridge Song", MenuBarText.Compose(Session, null, new DesktopPreferences(MenuBarShowsLyrics: true))); + Assert.Equal("", MenuBarText.Compose(null, "la la", new DesktopPreferences(MenuBarShowsLyrics: true))); + } + + /// + /// A line reaches the tray whole, however long it is. + /// + /// + /// The width setting bounds what the GNOME extension shows at once; it + /// must never shorten what is published, or the extension would have + /// nothing left to scroll. + /// + [Fact] + public void LinesReachTheTrayWhole() + { + var title = new string('가', 80); + var session = Session with { Title = title }; + + Assert.Equal(title, MenuBarText.Compose(session, null, new DesktopPreferences(MenuBarLabelLength: 10))); + } + + /// + /// Reserving a width pads the label out and never trims it. + /// + [Fact] + public void ReserveHoldsAWidthWithoutOverstatingIt() + { + Assert.Equal("ab ", MenuBarText.Reserve("ab", 5)); + Assert.Equal("abcdef", MenuBarText.Reserve("abcdef", 4)); + Assert.Equal("🎵 ", MenuBarText.Reserve("🎵", 2)); + } +} diff --git a/tests/unit/Reprise.Desktop.Tests/NowPlayingViewModelTests.cs b/tests/unit/Reprise.Desktop.Tests/NowPlayingViewModelTests.cs new file mode 100644 index 0000000..f50ef6f --- /dev/null +++ b/tests/unit/Reprise.Desktop.Tests/NowPlayingViewModelTests.cs @@ -0,0 +1,523 @@ +using Reprise.Core; +using Reprise.Desktop; +using Xunit; + +namespace Reprise.Desktop.Tests; + +/// +/// Covers the panel view model against a scripted media backend. +/// +/// +/// The backend, artwork loader, and clock are all fakes, so these pin the +/// reconciliation rules - what the scrubber shows during a seek, how volume +/// changes coalesce, when artwork reloads - without any display or bus. +/// +public sealed class NowPlayingViewModelTests +{ + private static readonly DateTimeOffset Observed = + new(2026, 9, 6, 12, 0, 0, TimeSpan.Zero); + + /// + /// A poll publishes the selected session and fetches its artwork. + /// + [Fact] + public async Task RefreshPublishesSessionAndArtwork() + { + var art = new Uri("https://example.com/art.jpg"); + var backend = new FakeBackend { Sessions = [Session(art: art)] }; + var loader = new FakeArtworkLoader { [art] = [1, 2, 3] }; + using var viewModel = new NowPlayingViewModel(backend, loader, new FixedClock(Observed)); + + await viewModel.RefreshAsync(); + + Assert.Equal("spotify", viewModel.ActiveSession?.PlayerId); + Assert.True(viewModel.IsRunning); + Assert.Equal(PanelPlayerLogo.Spotify, viewModel.PlayerLogo); + Assert.Equal(new byte[] { 1, 2, 3 }, viewModel.ArtworkData); + Assert.Equal(1, loader.Loads); + } + + /// + /// Artwork is fetched once per URI, not once per poll. + /// + [Fact] + public async Task ArtworkIsNotReloadedWhileTheUriIsUnchanged() + { + var art = new Uri("file:///tmp/cover.png"); + var backend = new FakeBackend { Sessions = [Session(art: art)] }; + var loader = new FakeArtworkLoader { [art] = [9] }; + using var viewModel = new NowPlayingViewModel(backend, loader, new FixedClock(Observed)); + + await viewModel.RefreshAsync(); + await viewModel.RefreshAsync(); + backend.Sessions = [Session(art: null)]; + await viewModel.RefreshAsync(); + + Assert.Equal(1, loader.Loads); + Assert.Null(viewModel.ArtworkData); + } + + /// + /// A playing session's position moves forward with the clock. + /// + [Fact] + public async Task DisplayedPositionAdvancesWhilePlaying() + { + var clock = new FixedClock(Observed); + var backend = new FakeBackend { Sessions = [Session(position: 10)] }; + using var viewModel = new NowPlayingViewModel(backend, new FakeArtworkLoader(), clock); + await viewModel.RefreshAsync(); + + clock.Now = Observed.AddSeconds(2.5); + + Assert.Equal(TimeSpan.FromSeconds(12.5), viewModel.DisplayedPosition()); + } + + /// + /// The scrubber holds the seek target until the player confirms it. + /// + /// + /// Without this the knob would snap back to the pre-seek position for + /// the poll or two it takes the player to report the new one. + /// + [Fact] + public async Task SeekTargetIsHeldUntilConfirmed() + { + var clock = new FixedClock(Observed); + var backend = new FakeBackend { Sessions = [Session(position: 10)] }; + using var viewModel = new NowPlayingViewModel(backend, new FakeArtworkLoader(), clock); + await viewModel.RefreshAsync(); + + viewModel.BeginSeek(TimeSpan.FromSeconds(10)); + viewModel.UpdateSeek(TimeSpan.FromSeconds(90)); + Assert.True(viewModel.IsSeeking); + Assert.Equal(TimeSpan.FromSeconds(90), viewModel.DisplayedPosition()); + + await viewModel.EndSeekAsync(); + + Assert.Equal(("spotify", TimeSpan.FromSeconds(90)), backend.LastSeek); + Assert.False(viewModel.IsSeeking); + Assert.Equal(TimeSpan.FromSeconds(90), viewModel.DisplayedPosition()); + + backend.Sessions = [Session(position: 90.4)]; + await viewModel.RefreshAsync(); + clock.Now = Observed.AddSeconds(1); + + Assert.Equal(TimeSpan.FromSeconds(91.4), viewModel.DisplayedPosition()); + } + + /// + /// A seek the player never reflects is dropped after a few polls. + /// + [Fact] + public async Task UnconfirmedSeekIsAbandoned() + { + var clock = new FixedClock(Observed); + var backend = new FakeBackend { Sessions = [Session(position: 10, status: PlaybackStatus.Paused)] }; + using var viewModel = new NowPlayingViewModel(backend, new FakeArtworkLoader(), clock); + await viewModel.RefreshAsync(); + + await viewModel.SeekAsync(TimeSpan.FromSeconds(120)); + Assert.Equal(TimeSpan.FromSeconds(120), viewModel.DisplayedPosition()); + + await viewModel.RefreshAsync(); + await viewModel.RefreshAsync(); + + Assert.Equal(TimeSpan.FromSeconds(10), viewModel.DisplayedPosition()); + } + + /// + /// Volume follows the player, and muting round-trips through the last level. + /// + [Fact] + public async Task MuteTogglesBetweenSilenceAndTheLastAudibleLevel() + { + var backend = new FakeBackend { Sessions = [Session(volume: 0.64)] }; + using var viewModel = new NowPlayingViewModel(backend, new FakeArtworkLoader(), new FixedClock(Observed)); + await viewModel.RefreshAsync(); + Assert.Equal(64, viewModel.Volume); + + await viewModel.ToggleMuteAsync(); + Assert.Equal(0, viewModel.Volume); + Assert.Equal(0.0, backend.Volumes[^1]); + + await viewModel.ToggleMuteAsync(); + Assert.Equal(64, viewModel.Volume); + Assert.Equal(0.64, backend.Volumes[^1], precision: 9); + } + + /// + /// Rapid volume changes send only the first and the newest level. + /// + /// + /// A drag emits dozens of values a second; sending each would queue bus + /// calls faster than the player answers them. + /// + [Fact] + public async Task VolumeChangesCoalesceWhileOneIsInFlight() + { + var backend = new FakeBackend { Sessions = [Session(volume: 0.5)], HoldVolumeWrites = true }; + using var viewModel = new NowPlayingViewModel(backend, new FakeArtworkLoader(), new FixedClock(Observed)); + await viewModel.RefreshAsync(); + + var first = viewModel.SetVolumeAsync(10); + var second = viewModel.SetVolumeAsync(20); + var third = viewModel.SetVolumeAsync(30); + Assert.Equal(30, viewModel.Volume); + Assert.Equal([0.1], backend.Volumes); + + backend.ReleaseVolumeWrite(); + await Task.WhenAll(first, second, third); + + Assert.Equal([0.1, 0.3], backend.Volumes); + } + + /// + /// A failed command is shown, then cleared by the next successful one. + /// + [Fact] + public async Task CommandFailureSurfacesAndClears() + { + var backend = new FakeBackend { Sessions = [Session()] }; + using var viewModel = new NowPlayingViewModel(backend, new FakeArtworkLoader(), new FixedClock(Observed)); + await viewModel.RefreshAsync(); + + backend.CommandFailure = new InvalidOperationException("MPRIS Next failed"); + await viewModel.SendAsync(PlaybackCommand.Next); + Assert.Equal("MPRIS Next failed", viewModel.ErrorText); + + backend.CommandFailure = null; + await viewModel.SendAsync(PlaybackCommand.PlayPause); + Assert.Null(viewModel.ErrorText); + } + + /// + /// A failed poll empties the panel and reports why. + /// + [Fact] + public async Task PollFailureClearsTheSession() + { + var backend = new FakeBackend { Sessions = [Session()] }; + using var viewModel = new NowPlayingViewModel(backend, new FakeArtworkLoader(), new FixedClock(Observed)); + await viewModel.RefreshAsync(); + + backend.PollFailure = new InvalidOperationException("bus gone"); + await viewModel.RefreshAsync(); + + Assert.Null(viewModel.ActiveSession); + Assert.Equal("bus gone", viewModel.ErrorText); + } + + /// + /// Lyrics are fetched once per track and served from cache afterwards. + /// + [Fact] + public async Task LyricsAreLookedUpOncePerTrack() + { + var clock = new FixedClock(Observed); + var backend = new FakeBackend { Sessions = [Session(position: 30)] }; + var lyricsService = new FakeLyricsService + { + Lyrics = new SyncedLyrics(LyricsSource.Lrclib, + [ + new LyricLine(TimeSpan.FromSeconds(10), TimeSpan.FromSeconds(20), "first"), + new LyricLine(TimeSpan.FromSeconds(25), TimeSpan.FromSeconds(40), "second"), + ]), + }; + using var viewModel = new NowPlayingViewModel(backend, new FakeArtworkLoader(), clock, lyricsService); + + await viewModel.RefreshAsync(); + await viewModel.RefreshAsync(); + + Assert.Equal(LyricsLoadState.Available, viewModel.LyricsState); + Assert.Equal(1, lyricsService.Lookups); + Assert.Equal("second", viewModel.CurrentLyricLine()?.Text); + + backend.Sessions = [Session(position: 30) with { Title = "Other Song" }]; + await viewModel.RefreshAsync(); + Assert.Equal(2, lyricsService.Lookups); + + backend.Sessions = [Session(position: 30)]; + await viewModel.RefreshAsync(); + Assert.Equal(2, lyricsService.Lookups); + Assert.Equal("second", viewModel.CurrentLyricLine()?.Text); + } + + /// + /// A track without lyrics reports unavailable, and no track reports idle. + /// + [Fact] + public async Task MissingLyricsAreReportedAndClearedWithTheTrack() + { + var backend = new FakeBackend { Sessions = [Session()] }; + var lyricsService = new FakeLyricsService { Lyrics = null }; + using var viewModel = new NowPlayingViewModel(backend, new FakeArtworkLoader(), new FixedClock(Observed), lyricsService); + + await viewModel.RefreshAsync(); + Assert.Equal(LyricsLoadState.Unavailable, viewModel.LyricsState); + Assert.Null(viewModel.CurrentLyricLine()); + + backend.Sessions = []; + await viewModel.RefreshAsync(); + Assert.Equal(LyricsLoadState.Idle, viewModel.LyricsState); + } + + /// + /// The display priority breaks ties between players in the same state. + /// + [Fact] + public async Task DisplayPriorityBreaksTies() + { + var backend = new FakeBackend + { + Sessions = + [ + Session(status: PlaybackStatus.Paused), + Session(status: PlaybackStatus.Paused) with { PlayerId = "vlc", PlayerName = "VLC" }, + ], + }; + var preferences = new DesktopPreferences(PlayerDisplayPriority: "generic,spotify"); + using var viewModel = new NowPlayingViewModel(backend, new FakeArtworkLoader(), new FixedClock(Observed), preferences: () => preferences); + + await viewModel.RefreshAsync(); + + Assert.Equal("vlc", viewModel.ActiveSession?.PlayerId); + } + + /// + /// With auto-pause on, a player that starts pauses the one already playing. + /// + [Fact] + public async Task NewlyStartedPlayerPausesTheOther() + { + var backend = new FakeBackend { Sessions = [Session()] }; + var preferences = new DesktopPreferences(AutomaticallyPausesOtherPlayer: true); + using var viewModel = new NowPlayingViewModel(backend, new FakeArtworkLoader(), new FixedClock(Observed), preferences: () => preferences); + await viewModel.RefreshAsync(); + + backend.Sessions = [Session(), Session() with { PlayerId = "vlc", PlayerName = "VLC" }]; + await viewModel.RefreshAsync(); + + Assert.Equal([("spotify", PlaybackCommand.Pause)], backend.Commands); + Assert.Equal("vlc", viewModel.ActiveSession?.PlayerId); + } + + /// + /// A remembered player that is playing wins over the priority order. + /// + [Fact] + public async Task RememberedPlayerWinsWhilePlaying() + { + var backend = new FakeBackend + { + Sessions = [Session(), Session() with { PlayerId = "vlc", PlayerName = "VLC" }], + }; + var preferences = new DesktopPreferences(RemembersLastPlayedPlayer: true, LastPlayedPlayer: "Generic"); + using var viewModel = new NowPlayingViewModel(backend, new FakeArtworkLoader(), new FixedClock(Observed), preferences: () => preferences); + PanelPlayerLogo? remembered = null; + viewModel.LastPlayedPlayerChanged += (_, kind) => remembered = kind; + + await viewModel.RefreshAsync(); + + Assert.Equal("vlc", viewModel.ActiveSession?.PlayerId); + Assert.Equal(PanelPlayerLogo.Spotify, remembered); + } + + /// + /// Player ids map onto the marks the panel can draw. + /// + /// MPRIS player id. + /// Mark that should be drawn. + [Theory] + [InlineData("spotify", PanelPlayerLogo.Spotify)] + [InlineData("chromium.instance12", PanelPlayerLogo.Generic)] + [InlineData("YoutubeMusic", PanelPlayerLogo.YouTubeMusic)] + [InlineData("youtube-music", PanelPlayerLogo.YouTubeMusic)] + [InlineData(null, PanelPlayerLogo.Generic)] + public void PlayerIdsMapToLogos(string? playerId, PanelPlayerLogo expected) + { + Assert.Equal(expected, NowPlayingViewModel.LogoFor(playerId)); + } + + /// + /// Builds a playing Spotify session observed at the fixed test time. + /// + /// Position in seconds. + /// Playback status. + /// Volume fraction. + /// Artwork URI. + /// A snapshot for the fake backend to serve. + private static MediaSessionSnapshot Session( + double position = 10, + PlaybackStatus status = PlaybackStatus.Playing, + double? volume = 0.64, + Uri? art = null) => new( + PlayerId: "spotify", + PlayerName: "Spotify", + Status: status, + Title: "Bridge Song", + Artist: "Bridge Artist", + Album: "Bridge Album", + Duration: TimeSpan.FromSeconds(180), + Position: TimeSpan.FromSeconds(position), + Volume: volume, + ArtworkUri: art, + ObservedAt: Observed); + + /// + /// Clock the tests advance by hand. + /// + private sealed class FixedClock(DateTimeOffset now) : TimeProvider + { + /// + /// The time the clock reports. + /// + public DateTimeOffset Now { get; set; } = now; + + /// + /// Returns . + /// + /// The fixed time. + public override DateTimeOffset GetUtcNow() => Now; + } + + /// + /// Lyrics service returning one fixed answer and counting lookups. + /// + private sealed class FakeLyricsService : ILyricsService + { + /// + /// Answer for every lookup. + /// + public SyncedLyrics? Lyrics { get; set; } + + /// + /// Number of lookups performed. + /// + public int Lookups { get; private set; } + + /// + public Task FetchSyncedLyricsAsync(LyricsTrackQuery query, CancellationToken cancellationToken = default) + { + Lookups++; + return Task.FromResult(Lyrics); + } + } + + /// + /// Artwork loader serving bytes from a dictionary. + /// + private sealed class FakeArtworkLoader : Dictionary, IArtworkLoader + { + /// + /// Number of loads requested. + /// + public int Loads { get; private set; } + + /// + /// Returns the configured bytes, or null. + /// + /// Artwork location. + /// Ignored. + /// The bytes, or null when unconfigured. + public Task LoadAsync(Uri uri, CancellationToken cancellationToken) + { + Loads++; + return Task.FromResult(TryGetValue(uri, out var bytes) ? bytes : null); + } + } + + /// + /// Scripted media backend that records every command. + /// + private sealed class FakeBackend : IMediaSessionService + { + private TaskCompletionSource _volumeGate = new(); + + /// + /// Sessions the next poll returns. + /// + public IReadOnlyList Sessions { get; set; } = []; + + /// + /// Exception the next poll throws, or null. + /// + public Exception? PollFailure { get; set; } + + /// + /// Exception every command throws, or null. + /// + public Exception? CommandFailure { get; set; } + + /// + /// Whether volume writes block until . + /// + public bool HoldVolumeWrites { get; set; } + + /// + /// Every transport command sent, in order. + /// + public List<(string PlayerId, PlaybackCommand Command)> Commands { get; } = []; + + /// + /// Most recent seek, as player id and position. + /// + public (string PlayerId, TimeSpan Position)? LastSeek { get; private set; } + + /// + /// Every volume written, in order. + /// + public List Volumes { get; } = []; + + /// + /// Lets held volume writes complete. + /// + public void ReleaseVolumeWrite() + { + var gate = _volumeGate; + _volumeGate = new TaskCompletionSource(); + HoldVolumeWrites = false; + gate.SetResult(); + } + + /// + public Task> GetSessionsAsync( + CancellationToken cancellationToken = default) => + PollFailure is { } failure + ? Task.FromException>(failure) + : Task.FromResult(Sessions); + + /// + public Task SendCommandAsync( + string playerId, + PlaybackCommand command, + CancellationToken cancellationToken = default) + { + Commands.Add((playerId, command)); + return CommandFailure is { } failure ? Task.FromException(failure) : Task.CompletedTask; + } + + /// + public Task SeekAsync( + string playerId, + TimeSpan position, + CancellationToken cancellationToken = default) + { + LastSeek = (playerId, position); + return Task.CompletedTask; + } + + /// + public async Task SetVolumeAsync( + string playerId, + double volume, + CancellationToken cancellationToken = default) + { + Volumes.Add(volume); + if (HoldVolumeWrites) + { + await _volumeGate.Task; + } + } + } +} diff --git a/tests/unit/Reprise.Desktop.Tests/PanelLyricsViewTests.cs b/tests/unit/Reprise.Desktop.Tests/PanelLyricsViewTests.cs new file mode 100644 index 0000000..05b20a6 --- /dev/null +++ b/tests/unit/Reprise.Desktop.Tests/PanelLyricsViewTests.cs @@ -0,0 +1,75 @@ +using Reprise.Desktop; +using Xunit; + +namespace Reprise.Desktop.Tests; + +/// +/// Pins the lyric sheet's animation curves to the macOS originals. +/// +/// +/// The scrolling lyrics are the one part of the panel judged purely by how +/// it moves, and a wrong constant here does not fail anything else: the +/// rows still arrive in the right places, just far too quickly and tearing +/// apart on the way. So the curve itself is the thing worth asserting. +/// +public sealed class PanelLyricsViewTests +{ + /// + /// Tolerance on a curve value, loose enough for the reference figures + /// below to be quoted to three places. + /// + private const double Tolerance = 0.005; + + /// + /// The slide follows SwiftUI's spring of duration 0.56 and bounce 0.24. + /// + /// + /// Values are the analytic response of a spring with a damping ratio of + /// 0.76 whose period is the full duration, sampled at tenths of the way + /// through. An earlier curve reached 0.96 by the quarter mark, which is + /// what made the sheet jump rather than glide. + /// + [Fact] + public void SlideMatchesTheMacOsSpring() + { + Assert.Equal(0, PanelLyricsView.Spring(0), Tolerance); + Assert.Equal(0.143, PanelLyricsView.Spring(0.1), Tolerance); + Assert.Equal(0.539, PanelLyricsView.Spring(0.25), Tolerance); + Assert.Equal(0.946, PanelLyricsView.Spring(0.5), Tolerance); + Assert.Equal(1, PanelLyricsView.Spring(1), Tolerance); + } + + /// + /// The slide overshoots slightly and stays settled afterwards. + /// + /// + /// The bounce is what gives the sheet the weight of a physical scroll, + /// so a curve that never passes 1 has lost it; one that passes it by + /// much would visibly rebound. + /// + [Fact] + public void SlideOvershootsOnceAndSettles() + { + var peak = 0.0; + for (var step = 0; step <= 100; step++) + { + peak = Math.Max(peak, PanelLyricsView.Spring(step / 100.0)); + } + + Assert.InRange(peak, 1.01, 1.05); + Assert.Equal(1, PanelLyricsView.Spring(1.5), Tolerance); + } + + /// + /// The highlight crossfade eases in and out around a straight middle. + /// + [Fact] + public void CrossfadeEasesAtBothEnds() + { + Assert.Equal(0, PanelLyricsView.EaseInOut(0), Tolerance); + Assert.Equal(0.5, PanelLyricsView.EaseInOut(0.5), Tolerance); + Assert.Equal(1, PanelLyricsView.EaseInOut(1), Tolerance); + Assert.True(PanelLyricsView.EaseInOut(0.1) < 0.1); + Assert.True(PanelLyricsView.EaseInOut(0.9) > 0.9); + } +} diff --git a/tests/unit/Reprise.Desktop.Tests/PanelTimeDisplayTests.cs b/tests/unit/Reprise.Desktop.Tests/PanelTimeDisplayTests.cs new file mode 100644 index 0000000..b0b9004 --- /dev/null +++ b/tests/unit/Reprise.Desktop.Tests/PanelTimeDisplayTests.cs @@ -0,0 +1,51 @@ +using Reprise.Desktop; +using Xunit; + +namespace Reprise.Desktop.Tests; + +/// +/// Pins the time label formats so they match the macOS panel. +/// +public sealed class PanelTimeDisplayTests +{ + /// + /// Minutes are unpadded, seconds padded, hours folded into minutes. + /// + /// Duration in seconds. + /// Expected label. + [Theory] + [InlineData(0, "0:00")] + [InlineData(-5, "0:00")] + [InlineData(7, "0:07")] + [InlineData(67.9, "1:07")] + [InlineData(3725, "62:05")] + public void TimeStringMatchesTheMacPanel(double seconds, string expected) + { + Assert.Equal(expected, PanelTimeDisplay.TimeString(TimeSpan.FromSeconds(seconds))); + } + + /// + /// The leading label shows elapsed time or a fixed zero. + /// + [Fact] + public void LeadingLabelHonoursStyle() + { + var position = TimeSpan.FromSeconds(67); + + Assert.Equal("1:07", PanelTimeDisplay.LeadingText(PanelLeadingTimeStyle.Elapsed, position)); + Assert.Equal("00:00", PanelTimeDisplay.LeadingText(PanelLeadingTimeStyle.Zero, position)); + } + + /// + /// The trailing label shows remaining time with a minus, or the length. + /// + [Fact] + public void TrailingLabelHonoursStyle() + { + var duration = TimeSpan.FromSeconds(180); + var remaining = TimeSpan.FromSeconds(113); + + Assert.Equal("-1:53", PanelTimeDisplay.TrailingText(PanelTrailingTimeStyle.Remaining, duration, remaining)); + Assert.Equal("3:00", PanelTimeDisplay.TrailingText(PanelTrailingTimeStyle.Duration, duration, remaining)); + } +} diff --git a/tests/unit/Reprise.Desktop.Tests/PlayerPriorityTests.cs b/tests/unit/Reprise.Desktop.Tests/PlayerPriorityTests.cs new file mode 100644 index 0000000..7ccca58 --- /dev/null +++ b/tests/unit/Reprise.Desktop.Tests/PlayerPriorityTests.cs @@ -0,0 +1,117 @@ +using Reprise.Core; +using Reprise.Desktop; +using Xunit; + +namespace Reprise.Desktop.Tests; + +/// +/// Pins the stored form and behaviour of the display priority. +/// +public sealed class PlayerPriorityTests +{ + /// + /// A partial or stale list is completed in default order. + /// + [Fact] + public void ParseCompletesMissingKinds() + { + Assert.Equal( + [PanelPlayerLogo.YouTubeMusic, PanelPlayerLogo.Spotify, PanelPlayerLogo.Generic], + PlayerPriority.Parse("youTubeMusic")); + Assert.Equal(PlayerPriority.DefaultOrder, PlayerPriority.Parse("")); + Assert.Equal(PlayerPriority.DefaultOrder, PlayerPriority.Parse("bogus,spotify")); + } + + /// + /// Serialisation round-trips and uses the macOS-style camel case. + /// + [Fact] + public void SerializeRoundTrips() + { + var order = new[] { PanelPlayerLogo.Generic, PanelPlayerLogo.Spotify }; + + var stored = PlayerPriority.Serialize(order); + + Assert.Equal("generic,spotify,youTubeMusic", stored); + Assert.Equal([PanelPlayerLogo.Generic, PanelPlayerLogo.Spotify, PanelPlayerLogo.YouTubeMusic], PlayerPriority.Parse(stored)); + } + + /// + /// Moving a kind reorders the list and clamps the target. + /// + [Fact] + public void MoveReorders() + { + var moved = PlayerPriority.Move(PlayerPriority.DefaultOrder, PanelPlayerLogo.Generic, 0); + + Assert.Equal([PanelPlayerLogo.Generic, PanelPlayerLogo.Spotify, PanelPlayerLogo.YouTubeMusic], moved); + Assert.Equal(PlayerPriority.DefaultOrder, PlayerPriority.Move(PlayerPriority.DefaultOrder, PanelPlayerLogo.Generic, 9)); + } + + /// + /// Sessions are ranked by their kind's position, then by id. + /// + [Fact] + public void PreferredIdsFollowKindOrder() + { + var sessions = new[] { Session("vlc"), Session("spotify"), Session("chromium.instance2") }; + var order = PlayerPriority.Parse("generic,spotify"); + + Assert.Equal(["chromium.instance2", "vlc", "spotify"], PlayerPriority.PreferredIds(sessions, order)); + } + + /// + /// Builds a paused session for an id. + /// + /// Player id. + /// The session. + private static MediaSessionSnapshot Session(string id) => new( + id, id, PlaybackStatus.Paused, "T", "A", "B", TimeSpan.FromSeconds(100), TimeSpan.Zero, 1, null, DateTimeOffset.UtcNow); +} + +/// +/// Covers the XDG autostart entry. +/// +public sealed class AutostartEntryTests : IDisposable +{ + private readonly string _directory = Path.Combine(Path.GetTempPath(), "reprise-autostart-" + Guid.NewGuid().ToString("N")); + + /// + /// Enabling writes a desktop entry pointing at the executable; disabling removes it. + /// + [Fact] + public void EnableAndDisableManageTheDesktopFile() + { + var path = Path.Combine(_directory, "autostart", "dev.junx.Reprise.desktop"); + var entry = new AutostartEntry(path, "/opt/reprise/app/reprise"); + + Assert.True(entry.IsAvailable); + Assert.False(entry.IsEnabled); + Assert.Null(entry.SetEnabled(true)); + Assert.True(entry.IsEnabled); + Assert.Contains("Exec=\"/opt/reprise/app/reprise\"", File.ReadAllText(path)); + Assert.Null(entry.SetEnabled(false)); + Assert.False(File.Exists(path)); + } + + /// + /// Without a known executable the entry reports itself unavailable. + /// + [Fact] + public void UnknownExecutableIsUnavailable() + { + var entry = new AutostartEntry(Path.Combine(_directory, "x.desktop"), null); + + Assert.False(entry.IsAvailable); + Assert.NotNull(entry.SetEnabled(true)); + } + + /// + public void Dispose() + { + if (Directory.Exists(_directory)) + { + Directory.Delete(_directory, recursive: true); + } + } +} diff --git a/tests/unit/Reprise.Desktop.Tests/PreferencesStoreTests.cs b/tests/unit/Reprise.Desktop.Tests/PreferencesStoreTests.cs new file mode 100644 index 0000000..d18a8b9 --- /dev/null +++ b/tests/unit/Reprise.Desktop.Tests/PreferencesStoreTests.cs @@ -0,0 +1,93 @@ +using Reprise.Desktop; +using Xunit; + +namespace Reprise.Desktop.Tests; + +/// +/// Covers persistence of the panel settings. +/// +public sealed class PreferencesStoreTests : IDisposable +{ + private readonly string _directory = Path.Combine( + Path.GetTempPath(), + "reprise-tests-" + Guid.NewGuid().ToString("N")); + + private string FilePath => Path.Combine(_directory, "nested", "preferences.json"); + + /// + /// A missing file yields the macOS defaults. + /// + [Fact] + public void MissingFileYieldsDefaults() + { + var store = new PreferencesStore(FilePath); + + Assert.Equal(new DesktopPreferences(), store.Current); + Assert.Equal(PanelTheme.Liquid, store.Current.PanelTheme); + } + + /// + /// An update is written, then read back by a fresh store. + /// + [Fact] + public void UpdateRoundTripsThroughTheFile() + { + var store = new PreferencesStore(FilePath); + var notified = 0; + store.Changed += (_, _) => notified++; + + store.Update(current => current with + { + PanelTheme = PanelTheme.Dark, + TrailingTimeStyle = PanelTrailingTimeStyle.Duration, + AutomaticallyScrollsTitles = false, + }); + + Assert.Equal(1, notified); + Assert.True(File.Exists(FilePath)); + var reloaded = new PreferencesStore(FilePath); + Assert.Equal(store.Current, reloaded.Current); + Assert.Contains("\"panelTheme\": \"dark\"", File.ReadAllText(FilePath)); + } + + /// + /// Re-applying the current value neither writes nor notifies. + /// + [Fact] + public void UnchangedUpdateIsIgnored() + { + var store = new PreferencesStore(FilePath); + var notified = 0; + store.Changed += (_, _) => notified++; + + store.Update(current => current with { PanelTheme = current.PanelTheme }); + + Assert.Equal(0, notified); + Assert.False(File.Exists(FilePath)); + } + + /// + /// A damaged file falls back to defaults instead of failing startup. + /// + [Fact] + public void CorruptFileYieldsDefaults() + { + Directory.CreateDirectory(Path.GetDirectoryName(FilePath)!); + File.WriteAllText(FilePath, "{ not json"); + + var store = new PreferencesStore(FilePath); + + Assert.Equal(new DesktopPreferences(), store.Current); + } + + /// + /// Removes the temporary directory. + /// + public void Dispose() + { + if (Directory.Exists(_directory)) + { + Directory.Delete(_directory, recursive: true); + } + } +} diff --git a/tests/unit/Reprise.Desktop.Tests/Reprise.Desktop.Tests.csproj b/tests/unit/Reprise.Desktop.Tests/Reprise.Desktop.Tests.csproj new file mode 100644 index 0000000..3935784 --- /dev/null +++ b/tests/unit/Reprise.Desktop.Tests/Reprise.Desktop.Tests.csproj @@ -0,0 +1,18 @@ + + + false + true + Reprise.Desktop.Tests + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + +