diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml index c1685c54..aa49f1d1 100644 --- a/.github/workflows/cd.yml +++ b/.github/workflows/cd.yml @@ -28,31 +28,33 @@ jobs: # there, which is why `portaudio-backend` stays on the macOS rows). # The youtube source needs a yt-dlp binary at runtime (ffmpeg # recommended); without one it shows an actionable error. + # `soloist` adds the opt-in Soloist engine; it needs the Soloist binary + # the user downloads from Spotify, so nothing changes until it is set. - os: ubuntu-latest target: x86_64-unknown-linux-gnu artifact_prefix: linux-x86_64 # most Linux distros binary_postfix: "" - audio_features: "audio-viz,mpris,local-files,subsonic,internet-radio,youtube,qobuz" + audio_features: "audio-viz,mpris,local-files,subsonic,internet-radio,youtube,qobuz,soloist" - os: ubuntu-24.04-arm # free native ARM64 runner for public repos target: aarch64-unknown-linux-gnu artifact_prefix: linux-aarch64 # Raspberry Pi 4/5, ARM servers, Asahi Linux binary_postfix: "" - audio_features: "audio-viz,mpris,local-files,subsonic,internet-radio,youtube,qobuz" + audio_features: "audio-viz,mpris,local-files,subsonic,internet-radio,youtube,qobuz,soloist" - os: macos-15-intel # Intel runner for x86_64 (available until Aug 2027) target: x86_64-apple-darwin artifact_prefix: macos-x86_64 binary_postfix: "" - audio_features: "audio-viz-cpal,portaudio-backend,macos-media,local-files,subsonic,internet-radio,youtube,qobuz,apple-music" + audio_features: "audio-viz-cpal,portaudio-backend,macos-media,local-files,subsonic,internet-radio,youtube,qobuz,soloist,apple-music" - os: macos-latest # Apple Silicon runner for aarch64 target: aarch64-apple-darwin artifact_prefix: macos-aarch64 binary_postfix: "" - audio_features: "audio-viz-cpal,portaudio-backend,macos-media,local-files,subsonic,internet-radio,youtube,qobuz,apple-music" + audio_features: "audio-viz-cpal,portaudio-backend,macos-media,local-files,subsonic,internet-radio,youtube,qobuz,soloist,apple-music" - os: windows-latest target: x86_64-pc-windows-msvc artifact_prefix: windows-x86_64 binary_postfix: ".exe" - audio_features: "audio-viz-cpal,windows-media,local-files,subsonic,internet-radio,youtube,qobuz" + audio_features: "audio-viz-cpal,windows-media,local-files,subsonic,internet-radio,youtube,qobuz,soloist" steps: - name: Checkout sources diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fcd73301..6a11041e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -44,7 +44,7 @@ jobs: # whenever a feature is added there, or the release build becomes the # first place a break shows up. - name: all-sources - args: "--locked --no-default-features --features telemetry,tui,streaming,discord-rpc,cover-art,self-update,scripting,mcp-server,ai-dj,audio-viz,mpris,local-files,subsonic,internet-radio,youtube,qobuz" + args: "--locked --no-default-features --features telemetry,tui,streaming,discord-rpc,cover-art,self-update,scripting,mcp-server,ai-dj,audio-viz,mpris,local-files,subsonic,internet-radio,youtube,qobuz,soloist" # `mcp-server` and `ai-dj` are two front doors on the same `dj-core` # implementation, and each has to build without the other: the # field-level `#[cfg(feature = "ai-dj")]` on `DjState::setup` exists @@ -77,6 +77,10 @@ jobs: # frontend. - name: gui args: "--locked --no-default-features --features telemetry,gui,streaming,discord-rpc,self-update,scripting,mcp-server,mpris,local-files,subsonic,internet-radio,youtube,qobuz,audio-viz,audio-viz-cpal" + # The Soloist engine with both frontends: the GUI `owns_sound` arm and + # the TUI badge. all-sources carries `soloist` without `gui`. + - name: soloist + args: "--locked --no-default-features --features telemetry,tui,gui,streaming,discord-rpc,self-update,scripting,mcp-server,mpris,local-files,subsonic,internet-radio,youtube,qobuz,audio-viz,audio-viz-cpal,soloist" env: RUSTFLAGS: "-C linker-features=-lld" steps: @@ -107,7 +111,7 @@ jobs: - name: default args: "--locked" - name: all-sources - args: "--locked --no-default-features --features telemetry,tui,streaming,discord-rpc,cover-art,self-update,scripting,mcp-server,ai-dj,audio-viz,mpris,local-files,subsonic,internet-radio,youtube,qobuz" + args: "--locked --no-default-features --features telemetry,tui,streaming,discord-rpc,cover-art,self-update,scripting,mcp-server,ai-dj,audio-viz,mpris,local-files,subsonic,internet-radio,youtube,qobuz,soloist" # See the check job for why the two single-door DJ legs exist. - name: mcp-only args: "--locked --no-default-features --features telemetry,tui,mcp-server" @@ -121,6 +125,10 @@ jobs: # See the check job for why the gui leg exists. - name: gui args: "--locked --no-default-features --features telemetry,gui,streaming,discord-rpc,self-update,scripting,mcp-server,mpris,local-files,subsonic,internet-radio,youtube,qobuz,audio-viz,audio-viz-cpal" + # The Soloist engine with both frontends: the GUI `owns_sound` arm and + # the TUI badge. all-sources carries `soloist` without `gui`. + - name: soloist + args: "--locked --no-default-features --features telemetry,tui,gui,streaming,discord-rpc,self-update,scripting,mcp-server,mpris,local-files,subsonic,internet-radio,youtube,qobuz,audio-viz,audio-viz-cpal,soloist" env: RUSTFLAGS: "-C linker-features=-lld" steps: @@ -209,7 +217,7 @@ jobs: - name: default args: "--locked" - name: all-sources - args: "--locked --no-default-features --features telemetry,tui,streaming,discord-rpc,cover-art,self-update,scripting,mcp-server,ai-dj,audio-viz,mpris,local-files,subsonic,internet-radio,youtube,qobuz" + args: "--locked --no-default-features --features telemetry,tui,streaming,discord-rpc,cover-art,self-update,scripting,mcp-server,ai-dj,audio-viz,mpris,local-files,subsonic,internet-radio,youtube,qobuz,soloist" # See the check job for why the two single-door DJ legs exist. - name: mcp-only args: "--locked --no-default-features --features telemetry,tui,mcp-server" @@ -226,6 +234,10 @@ jobs: # See the check job for why the gui leg exists. - name: gui args: "--locked --no-default-features --features telemetry,gui,streaming,discord-rpc,self-update,scripting,mcp-server,mpris,local-files,subsonic,internet-radio,youtube,qobuz,audio-viz,audio-viz-cpal" + # The Soloist engine with both frontends: the GUI `owns_sound` arm and + # the TUI badge. all-sources carries `soloist` without `gui`. + - name: soloist + args: "--locked --no-default-features --features telemetry,tui,gui,streaming,discord-rpc,self-update,scripting,mcp-server,mpris,local-files,subsonic,internet-radio,youtube,qobuz,audio-viz,audio-viz-cpal,soloist" env: RUSTFLAGS: "-C linker-features=-lld" steps: @@ -268,7 +280,7 @@ jobs: runs-on: macos-latest needs: prepare env: - FEATURES: "telemetry,tui,streaming,discord-rpc,cover-art,self-update,scripting,mcp-server,ai-dj,audio-viz-cpal,portaudio-backend,macos-media,local-files,subsonic,internet-radio,youtube,qobuz,apple-music" + FEATURES: "telemetry,tui,streaming,discord-rpc,cover-art,self-update,scripting,mcp-server,ai-dj,audio-viz-cpal,portaudio-backend,macos-media,local-files,subsonic,internet-radio,youtube,qobuz,soloist,apple-music" steps: - uses: actions/checkout@master - uses: dtolnay/rust-toolchain@stable diff --git a/.github/workflows/desktop.yml b/.github/workflows/desktop.yml index b4cb58ff..c9d81173 100644 --- a/.github/workflows/desktop.yml +++ b/.github/workflows/desktop.yml @@ -26,7 +26,7 @@ jobs: os: ${{ github.event_name == 'pull_request' && fromJSON('["ubuntu-24.04"]') || fromJSON('["ubuntu-24.04", "macos-latest", "windows-latest"]') }} env: # Keep in sync with the spotatui dependency's features in desktop/Cargo.toml. - FEATURES: "telemetry,gui,streaming,discord-rpc,self-update,scripting,mcp-server,mpris,macos-media,windows-media,local-files,subsonic,internet-radio,youtube,qobuz,apple-music,audio-viz,audio-viz-cpal" + FEATURES: "telemetry,gui,streaming,discord-rpc,self-update,scripting,mcp-server,mpris,macos-media,windows-media,local-files,subsonic,internet-radio,youtube,qobuz,soloist,apple-music,audio-viz,audio-viz-cpal" defaults: run: shell: bash diff --git a/AGENTS.md b/AGENTS.md index 8dd4533a..999c4b82 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -9,7 +9,7 @@ when working with code in this repository. `CLAUDE.md` imports it: edit this fil # Full build (native streaming + audio viz + Lua scripting + OS integrations) cargo run -# Slim build - no librespot/audio/scripting; fastest iteration, one of CI's eight legs +# Slim build - no librespot/audio/scripting; fastest iteration, one of CI's nine legs cargo run --no-default-features --features telemetry,tui # With the alternative sources (Local/Subsonic/Radio/YouTube/Qobuz). These are NOT @@ -33,18 +33,19 @@ cargo test --no-default-features --features telemetry,tui These slim commands are the *fast local gate*, not the full picture. GitHub Actions (`.github/workflows/ci.yml`) runs `check`, `test`, and `clippy` on `ubuntu-latest` -across an **eight-leg** feature matrix, plus one `macos-latest` job (below): +across a **nine-leg** feature matrix, plus one `macos-latest` job (below): | Leg | Features | |-----|----------| | `default` | a plain `cargo test`: streaming + audio-viz-cpal + scripting + self-update + OS integrations | -| `all-sources` | the **Linux** release feature set from `cd.yml` (adds cover-art, mcp-server, ai-dj, audio-viz, all five sources) | +| `all-sources` | the **Linux** release feature set from `cd.yml` (adds cover-art, mcp-server, ai-dj, audio-viz, all five sources, soloist) | | `mcp-only` | `telemetry,tui,mcp-server` | | `ai-dj-only` | `telemetry,tui,ai-dj` | | `slim` | `telemetry,tui` | | `headless` | `telemetry` - one of three legs without `tui`. `mod tui` is feature-gated, so this leg turns any `crate::tui` import from core/infra/cli into a compile error - what keeps a second frontend from silently re-coupling to the terminal one | -| `headless-streaming` | `telemetry,streaming` - `check` + `clippy` only, no `test` job. Proves native-streaming startup (`runtime/streaming/`) and the player-event wiring type-check and pass clippy with no terminal frontend in scope. Its two entry points carry `allow(dead_code)` there, so the leg does not prove they are live | +| `headless-streaming` | `telemetry,streaming` - `check` + `clippy` only, no `test` job. Proves native-streaming startup (`runtime/streaming/`) and the player-event wiring type-check and pass clippy with no terminal frontend in scope. Its entry points carry `allow(dead_code)` there, so the leg does not prove they are live | | `gui` | `telemetry,gui,streaming,discord-rpc,self-update,scripting,mcp-server,mpris,local-files,subsonic,internet-radio,youtube,qobuz,audio-viz,audio-viz-cpal` - the third leg without `tui`: the Linux feature set a `spotatui-gui` build carries, the five sources and the player bar's visualizer included. `check`, `clippy` and `test` | +| `soloist` | the `gui` leg's list plus `tui,soloist`: the Soloist engine with both frontends (the GUI `owns_sound` arm, the TUI badge). `check`, `clippy` and `test` | - Every CI leg passes `--locked`; the local commands do not. Regenerate `Cargo.lock` after any `Cargo.toml` edit or all legs fail at once. @@ -81,7 +82,7 @@ across an **eight-leg** feature matrix, plus one `macos-latest` job (below): `cargo clippy --manifest-path desktop/Cargo.toml -- -D warnings` and `cargo test --manifest-path desktop/Cargo.toml`. - **Two locks.** `desktop/` has its own committed `Cargo.lock`, separate from the - root `Cargo.lock` the eight legs check. Desktop jobs run **without** + root `Cargo.lock` the nine legs check. Desktop jobs run **without** `--locked`, so a root dependency change only makes the desktop lock stale: the job prints a `::warning::` and keeps going. Refresh it in the release commit (`how_to_release.md` step 1), together with the `desktop/Cargo.toml` version. @@ -343,7 +344,7 @@ same predicate the row uses, so the two cannot disagree. ### Config & on-disk files -Seven files, seven owners - a value that changes as the app runs goes in state, +Eight files, eight owners - a value that changes as the app runs goes in state, never config: | File | Owner | Contents | @@ -354,6 +355,7 @@ never config: | `last_session.yml` (state dir) | `core/persisted_playback.rs` | non-Spotify playback + native queue | | `playlist_sync.yml` (state dir) | `core/playlist_sync/store.rs` | playlist links + match cache | | `qobuz_credentials.yml` (config dir) | `infra/qobuz/auth.rs` | the Qobuz login token (feature `qobuz`) | +| `soloist_credentials.yml` (config dir) | `infra/soloist/auth.rs` | the Soloist API key (feature `soloist`), written by the Soloist API Key settings row; `SPOTATUI_SOLOIST_API_KEY` wins over it. Soloist owns its own `/soloist/` and `/soloist/`; `infra/soloist/` writes only its playback keys (quality, normalization) in `settings/Users/*/prefs` and two `.spotatui-*` dot-files there. `infra/soloist/download.rs` owns `/soloist-bin/`, one folder per build it downloaded from Spotify | | `visualizer.yml` (config dir) | `core/visualizer_look.rs` (read only) | the GUI player-bar visualizer's look; `runtime/gui.rs` reads it at launch and again on each edit (feature `gui`, only where `gui::visualizer::AVAILABLE`) | - All paths resolve through `core/paths.rs`, never `dirs::` directly. @@ -402,6 +404,10 @@ never config: macOS rows enable it). Its code sits behind `all(feature = "apple-music", target_os = "macos")`; the `Source` variant and the `App` state compile everywhere so callers need no `#[cfg]`. +- `soloist` (pulls `streaming`) is the opt-in Spotify Soloist engine behind + spotatui's own device: `behavior.playback_engine: soloist` starts the binary + the user downloaded instead of a librespot Spirc device. Not in `default`; + `all-sources`, the release rows in cd.yml and the macOS job carry it. - `scripting` (mlua Lua plugins) is default-on and gates `src/infra/scripting/` + `src/cli/plugin.rs`; the slim gate never compiles it, so verify scripting changes with a default `cargo test`. @@ -432,6 +438,7 @@ reads a file in that directory. Other agents must open it. | Decoded repeat/shuffle | `src/infra/queue/AGENTS.md` | | Radio tune-in | `src/infra/radio/AGENTS.md` | | Qobuz streaming | `src/infra/qobuz/AGENTS.md` | +| The Soloist engine | `src/infra/soloist/AGENTS.md` | ### Alternative sources (Local / Subsonic / Radio / YouTube / Qobuz) diff --git a/Cargo.toml b/Cargo.toml index fefcca88..9ab153e6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -144,7 +144,7 @@ arboard = { version = "3.4", features = ["wayland-data-control"] } librespot-playback = { version = "=0.8.6", package = "spotatui-librespot-playback", optional = true, default-features = false, features = ["rodio-backend"] } [target.'cfg(target_os = "windows")'.dependencies] -windows-sys = { version = "0.61", features = ["Win32_Globalization"] } +windows-sys = { version = "0.61", features = ["Win32_Globalization", "Win32_Foundation", "Win32_Security", "Win32_System_JobObjects"] } arboard = "3.4" # On Windows we default to rodio for audio output. # Without this, librespot-playback falls back to the `pipe` sink which writes raw audio bytes @@ -155,6 +155,8 @@ smtc-tokio = { version = "0.1.0", optional = true } # MPRIS D-Bus support (Linux only) [target.'cfg(target_os = "linux")'.dependencies] mpris-server = { version = "0.10", optional = true } +# The Soloist child's parent-death signal. +libc = "0.2" # macOS: librespot-playback + Now Playing / Media Key support # On macOS we use portaudio-backend by default to avoid the pipe sink fallback (which outputs to stdout). @@ -224,8 +226,9 @@ queue-download = ["audio-decode-queue", "dep:tempfile"] # e.g. `cargo run --features all-sources`. Deliberately NOT part of `default`: # the release binaries enable these explicitly (see .github/workflows/cd.yml) and # adding them to `default` would pull lofty/symphonia/md-5/stream-download into -# every plain `cargo build`. -all-sources = ["local-files", "subsonic", "internet-radio", "youtube", "qobuz"] +# every plain `cargo build`. It also carries `soloist`, so the release binaries +# ship the Soloist engine. +all-sources = ["local-files", "subsonic", "internet-radio", "youtube", "qobuz", "soloist"] local-files = ["audio-decode-queue", "queue", "dep:lofty", "dep:symphonia"] subsonic = ["dep:md-5", "audio-decode-queue", "queue", "queue-download", "dep:tempfile", "reqwest/stream"] internet-radio = ["audio-decode", "dep:stream-download", "dep:icy-metadata"] @@ -237,6 +240,10 @@ youtube = ["audio-decode-queue", "queue", "queue-download"] # Qobuz through its encrypted CMAF stream: each track is decrypted and rebuilt # as a FLAC tempfile while it plays through the shared LocalPlayer. qobuz = ["audio-decode-queue", "queue", "queue-download", "reqwest/stream", "dep:stream-download", "dep:bytes", "dep:md-5", "dep:sha2", "dep:hex", "dep:base64", "dep:regex", "dep:aes", "dep:cbc", "dep:ctr", "dep:hkdf"] +# Spotify Soloist as the engine behind spotatui's own Connect device: a child +# process the user downloads from Spotify, driven over its loopback WebSocket. +# No new crates. Opt-in at runtime through `behavior.playback_engine`. +soloist = ["streaming"] # Apple Music on macOS: a remote for the Music app, which plays the audio itself. # No decoder and no Apple crates; off by default and not in `all-sources`, since # it only works on macOS. The macOS rows in cd.yml and the `macos` CI job enable it. diff --git a/desktop/Cargo.toml b/desktop/Cargo.toml index f399f525..9c4e4677 100644 --- a/desktop/Cargo.toml +++ b/desktop/Cargo.toml @@ -37,6 +37,7 @@ spotatui = { path = "..", default-features = false, features = [ "internet-radio", "youtube", "qobuz", + "soloist", "apple-music", "audio-viz", "audio-viz-cpal", diff --git a/gui/e2e/fixtures.ts b/gui/e2e/fixtures.ts index a1d1d4a3..a828c6f1 100644 --- a/gui/e2e/fixtures.ts +++ b/gui/e2e/fixtures.ts @@ -1021,7 +1021,14 @@ function setting( description: string, value: SettingRow["value"], ): SettingRow { - return { id: `behavior.${id}`, group, name, description, value }; + return { + id: `behavior.${id}`, + group, + name, + description, + value, + button: null, + }; } function visualizerRow(mode: "Spectrum" | "Waveform"): SettingRow { @@ -1152,6 +1159,16 @@ export const settings: ServerMessage[] = [ labels: ["96", "160", "320"], }, ), + { + ...setting( + "soloist_path", + "streaming", + "Soloist Path", + "The Soloist program the Soloist engine runs", + { kind: "text", value: "" }, + ), + button: { label: "Download from Spotify", busy: false }, + }, setting( "enable_discord_rpc", "integrations", diff --git a/gui/e2e/scenes.shot.ts b/gui/e2e/scenes.shot.ts index 96399358..c47fdfa8 100644 --- a/gui/e2e/scenes.shot.ts +++ b/gui/e2e/scenes.shot.ts @@ -177,6 +177,8 @@ test("status error", async ({ page }) => { message: null, is_error: false, api_error: "Spotify refused the request: 403 Forbidden", + soloist_pairing: null, + soloist_offer: null, }, }, ]); @@ -184,6 +186,64 @@ test("status error", async ({ page }) => { await shot(page, "status-error"); }); +test("soloist pairing", async ({ page }) => { + await openWith(page, [ + ...playing, + ...library, + { + kind: "status", + rev: 1, + payload: { + message: null, + is_error: false, + api_error: null, + soloist_pairing: { + title: "Pair spotatui with Spotify", + steps: [ + "1. Open Spotify on a computer on this network.", + "2. In its device menu, pick 'spotatui'.", + ], + waiting: "Waiting for Spotify…", + close: "Close", + }, + soloist_offer: null, + }, + }, + ]); + await page.getByRole("dialog").waitFor(); + await shot(page, "soloist-pairing"); +}); + +test("soloist offer", async ({ page }) => { + await openWith(page, [ + ...playing, + ...library, + { + kind: "status", + rev: 1, + payload: { + message: null, + is_error: false, + api_error: null, + soloist_pairing: null, + soloist_offer: { + title: "Play through Soloist?", + body: "Spotify refuses librespot the audio keys for these tracks, as it does for some accounts. Soloist, Spotify's own player, can play them. It needs Premium. In Settings:", + steps: [ + "1. Soloist Path: Download from Spotify.", + "2. Soloist API Key: Get a key, then enter it.", + "3. Playback Engine: Soloist.", + ], + set_up: "Set up Soloist", + not_now: "Not now", + }, + }, + }, + ]); + await page.getByRole("dialog").waitFor(); + await shot(page, "soloist-offer"); +}); + test("queue panel", async ({ page }) => { await openWith(page, [...playing, ...library]); await page.getByText("Set Fire to the Rain").first().waitFor(); diff --git a/gui/src/Settings.css b/gui/src/Settings.css index a8eb80e4..3e0390ab 100644 --- a/gui/src/Settings.css +++ b/gui/src/Settings.css @@ -167,6 +167,13 @@ border-color: var(--text); } +.setting-controls { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 8px; +} + .update-button { height: 32px; flex-shrink: 0; diff --git a/gui/src/Settings.tsx b/gui/src/Settings.tsx index e9b4e834..6da7c079 100644 --- a/gui/src/Settings.tsx +++ b/gui/src/Settings.tsx @@ -61,7 +61,14 @@ export const Settings = memo(function Settings({ {row.description} - + {row.button ? ( +
+ + +
+ ) : ( + + )} ))} @@ -138,6 +145,28 @@ export function UpdateRow({ ); } +/** A row's one button; it is off while its work runs. */ +export function RowButton({ + row, + send, +}: { + row: SettingRow; + send: (action: Action) => void; +}) { + if (!row.button) return null; + return ( + + ); +} + /** The control for one row: a switch, a select, or a field that commits on blur and Enter. */ export function SettingControl({ s = EN, diff --git a/gui/src/Shell.tsx b/gui/src/Shell.tsx index 6964e8bd..e83a207f 100644 --- a/gui/src/Shell.tsx +++ b/gui/src/Shell.tsx @@ -54,6 +54,8 @@ import { Room } from "./Room"; import { Search } from "./Search"; import { Session } from "./Session"; import { Settings } from "./Settings"; +import { SoloistOfferDialog } from "./SoloistOfferDialog"; +import { SoloistPairingDialog } from "./SoloistPairingDialog"; import { Stats } from "./Stats"; import { Toast } from "./Toast"; import { TopBar } from "./TopBar"; @@ -516,13 +518,16 @@ export function Shell({ ]); // A switch or a closed overlay hands the keyboard to the page; the first render leaves focus alone. - const shown = useRef({ page, overlay }); + // The Soloist offer opens Settings under itself, so it holds the focus until it closes. + const covered = + overlay !== null || channels.status?.payload.soloist_offer != null; + const shown = useRef({ page, covered }); useEffect(() => { const before = shown.current; - shown.current = { page, overlay }; - if (overlay || (before.page === page && !before.overlay)) return; + shown.current = { page, covered }; + if (covered || (before.page === page && !before.covered)) return; focusScreen(page); - }, [page, overlay]); + }, [page, covered]); // A closed Room also hands the keyboard back to the page. const roomShown = useRef(room); @@ -719,6 +724,15 @@ export function Shell({ active={channels.announcement?.payload.active ?? null} send={send} /> + + open({ page: "settings" })} + /> ); } diff --git a/gui/src/SoloistOfferDialog.tsx b/gui/src/SoloistOfferDialog.tsx new file mode 100644 index 00000000..a489cf10 --- /dev/null +++ b/gui/src/SoloistOfferDialog.tsx @@ -0,0 +1,88 @@ +import { + useEffect, + useRef, + type KeyboardEvent, + type MouseEvent, + type Ref, +} from "react"; +import { holdFocus } from "./AnnouncementDialog"; +import type { Action } from "./bindings/Action"; +import type { SoloistOffer } from "./bindings/SoloistOffer"; +import { trapTab } from "./ConfirmDialog"; + +type Props = { + offer: SoloistOffer | null; + send: (action: Action) => void; + /** Open the Settings page, where the Soloist rows are. */ + onSetUp: () => void; +}; + +/** The offer to set up Soloist in a modal, after librespot failed several tracks in a row. */ +export function SoloistOfferDialog(props: Props) { + const setUp = useRef(null); + const open = props.offer !== null; + useEffect( + () => (open ? holdFocus(setUp.current, document.activeElement) : undefined), + [open], + ); + return ; +} + +/** The modal without state, so a test can walk it. */ +export function SoloistOfferModal({ + offer, + send, + onSetUp, + setUp, +}: Props & { setUp?: Ref }) { + if (!offer) return null; + const answer = (set_up: boolean) => { + send({ AnswerSoloistOffer: { set_up } }); + if (set_up) onSetUp(); + }; + const onKeyDown = (event: KeyboardEvent) => { + event.stopPropagation(); + trapTab(event); + if (event.key !== "Escape") return; + event.preventDefault(); + answer(false); + }; + // A click on the backdrop neither closes the dialog nor takes the focus out of it. + const keepFocus = (event: MouseEvent) => { + if (event.target === event.currentTarget) event.preventDefault(); + }; + return ( +
+
+

{offer.title}

+

{offer.body}

+ {offer.steps.map((step) => ( +

{step}

+ ))} +
+ + +
+
+
+ ); +} diff --git a/gui/src/SoloistPairingDialog.tsx b/gui/src/SoloistPairingDialog.tsx new file mode 100644 index 00000000..a3403347 --- /dev/null +++ b/gui/src/SoloistPairingDialog.tsx @@ -0,0 +1,75 @@ +import { + useEffect, + useRef, + type KeyboardEvent, + type MouseEvent, + type Ref, +} from "react"; +import { holdFocus } from "./AnnouncementDialog"; +import type { Action } from "./bindings/Action"; +import type { SoloistPairingGuide } from "./bindings/SoloistPairingGuide"; +import { trapTab } from "./ConfirmDialog"; + +type Props = { + guide: SoloistPairingGuide | null; + send: (action: Action) => void; +}; + +/** The Soloist pairing guide in a modal, until Soloist logs in or Close dismisses it. */ +export function SoloistPairingDialog(props: Props) { + const close = useRef(null); + const open = props.guide !== null; + useEffect( + () => (open ? holdFocus(close.current, document.activeElement) : undefined), + [open], + ); + return ; +} + +/** The modal without state, so a test can walk it. */ +export function SoloistPairingModal({ + guide, + send, + close, +}: Props & { close?: Ref }) { + if (!guide) return null; + const dismiss = () => send("DismissSoloistPairing"); + const onKeyDown = (event: KeyboardEvent) => { + event.stopPropagation(); + trapTab(event); + if (event.key !== "Escape") return; + event.preventDefault(); + dismiss(); + }; + // A click on the backdrop neither closes the dialog nor takes the focus out of it. + const keepFocus = (event: MouseEvent) => { + if (event.target === event.currentTarget) event.preventDefault(); + }; + return ( +
+
+

{guide.title}

+ {guide.steps.map((step) => ( +

{step}

+ ))} +

{guide.waiting}

+
+ +
+
+
+ ); +} diff --git a/gui/src/bindings/Action.ts b/gui/src/bindings/Action.ts index c2c7be43..35f74534 100644 --- a/gui/src/bindings/Action.ts +++ b/gui/src/bindings/Action.ts @@ -27,4 +27,4 @@ export type Action = "Play" | "Pause" | "TogglePlayback" | "NextTrack" | "Previo /** * 0-based position of the track within the playlist. */ -position: number, } } | { "FollowPlaylist": string } | { "UnfollowPlaylist": string } | { "DeletePlaylist": string } | "OpenPlaylistSyncPicker" | { "LinkPlaylistTo": Source } | "RunPlaylistSync" | { "RemovePlaylistSyncLink": string } | { "ToggleSaveTrack": string } | "ToggleSaveCurrentItem" | { "SaveAlbum": string } | { "UnsaveAlbum": string } | { "SaveShow": string } | { "UnsaveShow": string } | { "FollowArtist": string } | { "UnfollowArtist": string } | { "AddFriendByCode": string } | { "AddFriendById": string } | { "UnfollowFriend": string } | { "SearchFriendUsers": string } | { "FavoriteRadioStation": TrackInfo } | { "RemoveRadioStation": string } | { "Notify": [string, number] } | { "NotifyError": [string, number] } | { "Navigate": NavTarget } | "Back" | { "LoadMore": ListTarget } | { "Sort": { context: SortContext, field: SortField, } } | { "ToggleSortOrder": SortContext } | { "Open": OpenTarget } | { "OpenShowEpisodes": ShowInfo } | { "OpenLibrary": LibraryTarget } | { "SelectSource": Source } | "OpenAddTrackDialog" | { "OpenAddTrackDialogFor": { track_id: string | null, track_name: string, } } | "OpenAddPlayingTrackDialog" | "OpenRemoveTrackDialog" | "JumpToAlbum" | "JumpToArtist" | "JumpToContext" | { "CopyUrl": CopyTarget } | "GenerateRecap" | "GenerateStatsRecap" | { "CycleStatsPeriod": { forward: boolean, } } | { "RecommendFromTrack": TrackInfo } | { "RecommendFromArtist": { id: string, name: string, } } | { "RecommendFromTrackId": { id: string, name: string, } } | "StartParty" | { "JoinParty": { code: string, name: string, } } | "LeaveParty" | "TogglePartyControlMode" | { "SetPlaybarSegment": { plugin: string, text: string | null, } } | { "ShowPopup": PluginPopup } | "ClosePopup" | { "SetTheme": Array<[ThemeField, Color]> } | "SaveSettings" | { "SetSetting": { id: string, value: SettingInput, } } | "CycleVisualizerStyle" | { "AnswerCommunityPinPrompt": { keep: boolean, } } | "OpenRecap" | "DismissRecapPrompt" | "DisableRecapPrompt" | { "DismissAnnouncement": { id: string, } } | { "OpenAnnouncementUrl": { id: string, } } | { "OpenChangelogLink": { url: string, } } | { "SetScreenContent": { name: string, content: PluginScreenContent, } } | { "ShowScreen": string } | { "CloseScreen": string } | { "QueueTracks": Array } | { "SetDjVibe": string | null } | { "AskDj": string } | "DjVibeShift" | "ToggleDjAutoQueue" | "ToggleDjFreshOnly" | "OpenDjSetup" | "DismissDjSetup" | "CommitDjSetup"; +position: number, } } | { "FollowPlaylist": string } | { "UnfollowPlaylist": string } | { "DeletePlaylist": string } | "OpenPlaylistSyncPicker" | { "LinkPlaylistTo": Source } | "RunPlaylistSync" | { "RemovePlaylistSyncLink": string } | { "ToggleSaveTrack": string } | "ToggleSaveCurrentItem" | { "SaveAlbum": string } | { "UnsaveAlbum": string } | { "SaveShow": string } | { "UnsaveShow": string } | { "FollowArtist": string } | { "UnfollowArtist": string } | { "AddFriendByCode": string } | { "AddFriendById": string } | { "UnfollowFriend": string } | { "SearchFriendUsers": string } | { "FavoriteRadioStation": TrackInfo } | { "RemoveRadioStation": string } | { "Notify": [string, number] } | { "NotifyError": [string, number] } | { "Navigate": NavTarget } | "Back" | { "LoadMore": ListTarget } | { "Sort": { context: SortContext, field: SortField, } } | { "ToggleSortOrder": SortContext } | { "Open": OpenTarget } | { "OpenShowEpisodes": ShowInfo } | { "OpenLibrary": LibraryTarget } | { "SelectSource": Source } | "OpenAddTrackDialog" | { "OpenAddTrackDialogFor": { track_id: string | null, track_name: string, } } | "OpenAddPlayingTrackDialog" | "OpenRemoveTrackDialog" | "JumpToAlbum" | "JumpToArtist" | "JumpToContext" | { "CopyUrl": CopyTarget } | "GenerateRecap" | "GenerateStatsRecap" | { "CycleStatsPeriod": { forward: boolean, } } | { "RecommendFromTrack": TrackInfo } | { "RecommendFromArtist": { id: string, name: string, } } | { "RecommendFromTrackId": { id: string, name: string, } } | "StartParty" | { "JoinParty": { code: string, name: string, } } | "LeaveParty" | "TogglePartyControlMode" | { "SetPlaybarSegment": { plugin: string, text: string | null, } } | { "ShowPopup": PluginPopup } | "ClosePopup" | { "SetTheme": Array<[ThemeField, Color]> } | "SaveSettings" | { "SetSetting": { id: string, value: SettingInput, } } | { "PressSettingButton": { id: string, } } | "CycleVisualizerStyle" | { "AnswerCommunityPinPrompt": { keep: boolean, } } | "DismissSoloistPairing" | { "AnswerSoloistOffer": { set_up: boolean, } } | "OpenRecap" | "DismissRecapPrompt" | "DisableRecapPrompt" | { "DismissAnnouncement": { id: string, } } | { "OpenAnnouncementUrl": { id: string, } } | { "OpenChangelogLink": { url: string, } } | { "SetScreenContent": { name: string, content: PluginScreenContent, } } | { "ShowScreen": string } | { "CloseScreen": string } | { "QueueTracks": Array } | { "SetDjVibe": string | null } | { "AskDj": string } | "DjVibeShift" | "ToggleDjAutoQueue" | "ToggleDjFreshOnly" | "OpenDjSetup" | "DismissDjSetup" | "CommitDjSetup"; diff --git a/gui/src/bindings/HelpStrings.ts b/gui/src/bindings/HelpStrings.ts index 13d39d96..aedb8fda 100644 --- a/gui/src/bindings/HelpStrings.ts +++ b/gui/src/bindings/HelpStrings.ts @@ -1,3 +1,3 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type HelpStrings = { arrow_key_template: string, key_alternative_template: string, next_page: string, previous_page: string, playlist_start: string, playlist_end: string, current_album: string, current_artist_albums: string, current_context: string, volume_up: string, volume_down: string, next_track: string, previous_track: string, force_previous_track: string, seek_backward: string, seek_forward: string, toggle_shuffle: string, copy_song_url: string, copy_album_url: string, cycle_repeat: string, move_left: string, move_down: string, move_up: string, move_right: string, list_top: string, list_middle: string, list_bottom: string, search: string, filter_help: string, filter_settings: string, toggle_playback: string, active_mode: string, audio_analysis: string, visualizer_style: string, lyrics_view: string, lyrics_scroll: string, lyrics_follow: string, lyrics_timing: string, miniplayer: string, cover_art: string, back: string, source_device: string, settings_open: string, settings_save: string, hover_mode: string, save_track: string, add_selected_to_playlist: string, add_playing_to_playlist: string, quick_add_playing: string, sidebar_narrower: string, sidebar_wider: string, panels_shorter: string, panels_taller: string, layout_reset: string, remove_playlist_track: string, search_playlist: string, clear_playlist_filter: string, play_selection: string, recommendations: string, artist_tracks: string, search_submit: string, cursor_left: string, cursor_right: string, input_clear: string, input_delete_before: string, input_delete_after: string, input_delete_word: string, input_start: string, input_end: string, input_escape: string, album_delete: string, artist_unfollow: string, show_remove: string, playlist_delete: string, playlist_mirror: string, radio_remove: string, follow_artist_playlist: string, album_save: string, playlist_random: string, episode_sort: string, show_follow: string, show_unfollow: string, queue_add: string, queue_show: string, queue_remove: string, queue_move: string, queue_play: string, toggle_saved: string, radio_favorite: string, listening_recap: string, stats_open: string, stats_period: string, stats_play: string, playlist_sync_open: string, playlist_sync_run: string, playlist_sync_remove: string, sort_open: string, party_open: string, dj_open: string, dj_auto_queue: string, dj_vibe: string, dj_fresh: string, dj_model: string, context_pagination: string, context_general: string, context_general_playlist_search: string, context_help: string, context_settings: string, context_lyrics: string, context_selected_block: string, context_add_track: string, context_playbar: string, context_global: string, context_layout: string, context_playlist_tracks: string, context_filtered_playlist_tracks: string, context_library_artists: string, context_search_input: string, context_library_albums: string, context_artist_unfollow: string, context_library_podcasts: string, context_playlist: string, context_radio: string, context_search_results: string, context_save_album: string, context_selected_playlist: string, context_selected_show: string, context_hovered_track: string, context_queue: string, context_stats: string, context_playlist_sync: string, context_sortable_lists: string, context_ai_dj: string, library_sidebar: string, title: string, column_description: string, column_event: string, column_context: string, no_matches_template: string, filter_prompt: string, filter_edit_hint: string, matches_template: string, filter_count_hint_template: string, filter_hint: string, needs_spotify: string, not_for_source_template: string, only_for_source_template: string, unavailable_description_template: string, }; +export type HelpStrings = { arrow_key_template: string, key_alternative_template: string, next_page: string, previous_page: string, playlist_start: string, playlist_end: string, current_album: string, current_artist_albums: string, current_context: string, volume_up: string, volume_down: string, next_track: string, previous_track: string, force_previous_track: string, seek_backward: string, seek_forward: string, toggle_shuffle: string, copy_song_url: string, copy_album_url: string, cycle_repeat: string, move_left: string, move_down: string, move_up: string, move_right: string, list_top: string, list_middle: string, list_bottom: string, search: string, filter_help: string, filter_settings: string, toggle_playback: string, active_mode: string, audio_analysis: string, visualizer_style: string, lyrics_view: string, lyrics_scroll: string, lyrics_follow: string, lyrics_timing: string, miniplayer: string, cover_art: string, back: string, source_device: string, settings_open: string, settings_save: string, settings_button: string, hover_mode: string, save_track: string, add_selected_to_playlist: string, add_playing_to_playlist: string, quick_add_playing: string, sidebar_narrower: string, sidebar_wider: string, panels_shorter: string, panels_taller: string, layout_reset: string, remove_playlist_track: string, search_playlist: string, clear_playlist_filter: string, play_selection: string, recommendations: string, artist_tracks: string, search_submit: string, cursor_left: string, cursor_right: string, input_clear: string, input_delete_before: string, input_delete_after: string, input_delete_word: string, input_start: string, input_end: string, input_escape: string, album_delete: string, artist_unfollow: string, show_remove: string, playlist_delete: string, playlist_mirror: string, radio_remove: string, follow_artist_playlist: string, album_save: string, playlist_random: string, episode_sort: string, show_follow: string, show_unfollow: string, queue_add: string, queue_show: string, queue_remove: string, queue_move: string, queue_play: string, toggle_saved: string, radio_favorite: string, listening_recap: string, stats_open: string, stats_period: string, stats_play: string, playlist_sync_open: string, playlist_sync_run: string, playlist_sync_remove: string, sort_open: string, party_open: string, dj_open: string, dj_auto_queue: string, dj_vibe: string, dj_fresh: string, dj_model: string, context_pagination: string, context_general: string, context_general_playlist_search: string, context_help: string, context_settings: string, context_lyrics: string, context_selected_block: string, context_add_track: string, context_playbar: string, context_global: string, context_layout: string, context_playlist_tracks: string, context_filtered_playlist_tracks: string, context_library_artists: string, context_search_input: string, context_library_albums: string, context_artist_unfollow: string, context_library_podcasts: string, context_playlist: string, context_radio: string, context_search_results: string, context_save_album: string, context_selected_playlist: string, context_selected_show: string, context_hovered_track: string, context_queue: string, context_stats: string, context_playlist_sync: string, context_sortable_lists: string, context_ai_dj: string, library_sidebar: string, title: string, column_description: string, column_event: string, column_context: string, no_matches_template: string, filter_prompt: string, filter_edit_hint: string, matches_template: string, filter_count_hint_template: string, filter_hint: string, needs_spotify: string, not_for_source_template: string, only_for_source_template: string, unavailable_description_template: string, }; diff --git a/gui/src/bindings/InfraStatusStrings.ts b/gui/src/bindings/InfraStatusStrings.ts index 9e80290a..da8a98b9 100644 --- a/gui/src/bindings/InfraStatusStrings.ts +++ b/gui/src/bindings/InfraStatusStrings.ts @@ -1,3 +1,3 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type InfraStatusStrings = { sync_spotify_not_connected: string, sync_qobuz_not_logged_in: string, sync_subsonic_not_configured: string, sync_source_not_compiled: string, sync_source_unsupported: string, sync_source_unreachable: string, search_query_unavailable: string, made_for_you_unavailable: string, made_for_you_missing: string, history_track_hit: string, history_play: string, local_replay_failed: string, local_no_tracks: string, local_no_folder: string, local_uri_invalid: string, local_play_failed: string, local_task_failed: string, local_audio_unavailable: string, audio_init_failed: string, local_scan_failed: string, local_scan_task_failed: string, local_read_failed: string, local_tracks_scan_failed: string, local_exhausted: string, subsonic_audio_unavailable: string, youtube_audio_unavailable: string, radio_audio_unavailable: string, qobuz_audio_unavailable: string, subsonic_no_tracks: string, subsonic_uri_invalid: string, subsonic_replay_failed: string, subsonic_playlists_failed: string, subsonic_playlist_failed: string, subsonic_search_failed: string, subsonic_download_failed: string, subsonic_play_failed: string, subsonic_task_failed: string, subsonic_no_server: string, radio_no_skip: string, radio_empty: string, radio_uri_invalid: string, radio_probe_timeout: string, radio_search_failed: string, radio_open_failed: string, radio_play_failed: string, radio_task_failed: string, radio_playing: string, youtube_playlist_missing: string, youtube_metadata_missing: string, youtube_no_tracks: string, youtube_no_live: string, youtube_uri_invalid: string, youtube_replay_failed: string, youtube_search_failed: string, youtube_playlists_failed: string, youtube_create_failed: string, youtube_delete_failed: string, youtube_add_failed: string, youtube_remove_failed: string, youtube_download_failed: string, youtube_task_failed: string, youtube_created: string, youtube_deleted: string, youtube_removed: string, fetching: string, youtube_added: string, youtube_already_added: string, youtube_decode_failed: string, qobuz_login_expired: string, qobuz_login_busy: string, qobuz_browser_login: string, qobuz_logged_in: string, qobuz_no_tracks: string, qobuz_uri_invalid: string, qobuz_replay_failed: string, qobuz_login_url: string, qobuz_login_failed: string, qobuz_step_constants: string, qobuz_step_login: string, qobuz_step_library: string, qobuz_step_tracks: string, qobuz_step_search: string, qobuz_step_stream: string, qobuz_operation_failed: string, music_busy_playback: string, music_wait_pause: string, music_not_running: string, music_shuffle_repeat: string, music_switch_spotify: string, music_busy: string, music_queue_unsupported: string, music_loading: string, music_loaded: string, music_status_unavailable: string, queue_repeat_unsupported: string, queue_shuffle_unsupported: string, queue_no_uri: string, queue_finished: string, audio_disconnected: string, radio_not_resumed: string, source_playback_unavailable: string, queue_skip_disconnected: string, queue_play_failed: string, queue_restore_failed: string, queue_playing: string, queue_audio_unavailable: string, spotify_not_connected: string, playlist_id_invalid: string, music_requires_macos: string, spotify_not_compiled: string, spotify_connected: string, party_disconnected: string, party_ended: string, spotify_login_timeout: string, spotify_login_failed: string, spotify_rate_wait: string, spotify_pair: string, recap_created: string, history_failed: string, recap_failed: string, party_start_failed: string, party_join_failed: string, party_error: string, party_joined: string, party_left: string, native_reconnecting: string, native_settling: string, shuffle_not_applied: string, spotify_rate_retry: string, spotify_poll_retry: string, spotify_server_retry: string, spotify_poll_rejected: string, native_resuming: string, native_reconnect_resume: string, native_connection_restored: string, native_no_state: string, native_restore_retry: string, other_source_playing: string, spotify_playing_elsewhere: string, queue_added: string, queue_load_failed: string, native_nothing_to_resume: string, native_start_failed: string, native_shuffle_failed: string, native_resume_failed: string, shuffle_context_failed: string, shuffle_capped: string, spotify_profile_retry: string, friend_added: string, friend_unfollowed: string, friend_added_code: string, friend_add_failed: string, friend_unfollow_failed: string, playlist_external_unavailable: string, liked_check_failed: string, playlist_search_scope: string, playlist_search_matches: string, playlist_refresh_partial: string, playlist_added: string, playlist_removed: string, playlist_created_no_id: string, playlist_added_named: string, playlist_created: string, native_recovered: string, native_lost_reconnecting: string, native_reconnected: string, native_restored: string, native_restored_paused: string, native_skip_unavailable: string, native_many_unavailable: string, native_recovery_dropped: string, native_recovery_failed: string, native_lost_recovering: string, native_stalled: string, native_stopped: string, native_moved: string, native_disconnected: string, native_stopped_recovering: string, sync_busy: string, sync_no_links: string, sync_dry_run: string, sync_removed: string, sync_failed: string, sync_link_failed: string, sync_remove_failed: string, sync_summary: string, sync_using_playlist: string, sync_progress: string, dj_indexed: string, dj_index_capped: string, dj_queued: string, dj_not_found: string, dj_owned: string, dj_duplicate: string, dj_status: string, dj_library_failed: string, dj_topped_up: string, dj_nothing_queued: string, dj_error: string, dj_steps_exhausted: string, history_plays: string, history_track_hits: string, history_minutes: string, history_hours_minutes: string, setup_no_api_key: string, setup_keep_command: string, setup_anthropic: string, setup_openai: string, setup_own_command: string, setup_not_installed: string, setup_anthropic_billing: string, setup_needs_key: string, setup_openai_endpoint: string, setup_claude_plan: string, setup_codex_plan: string, setup_agy_plan: string, setup_copilot_plan: string, setup_opencode_plan: string, setup_gemini_legacy: string, setup_agent_subscription: string, setup_keep_as_is: string, setup_own_model_note: string, setup_current: string, setup_cli_default: string, setup_no_model_flag: string, setup_builtin_default: string, setup_custom: string, setup_type_provider_model: string, setup_type_model: string, setup_haiku_note: string, setup_sonnet_note: string, setup_opus_note: string, setup_fable_note: string, setup_cheapest: string, setup_heaviest_gemini: string, setup_heaviest_all: string, setup_price_haiku: string, setup_price_sonnet: string, setup_price_opus: string, setup_price_fable: string, history_week: string, history_month: string, history_all: string, history_this_month: string, history_this_year: string, mcp_status: string, network_error: string, network_rate_error: string, monthly_recap_ready: string, queue_local_source: string, unnamed_folder: string, local_root_folder: string, local_unknown_track: string, cast_no_answer: string, cast_no_tls: string, cast_message_size: string, cast_malformed: string, cast_no_reply: string, cast_receiver_failed: string, cast_client_id_missing: string, cast_login_refused: string, cast_access_token_missing: string, speaker_token_failed: string, speaker_token_unsupported: string, speaker_client_id_missing: string, speaker_login_refused: string, speaker_not_joined: string, playlist_changed: string, spotify_api_error: string, }; +export type InfraStatusStrings = { sync_spotify_not_connected: string, sync_qobuz_not_logged_in: string, sync_subsonic_not_configured: string, sync_source_not_compiled: string, sync_source_unsupported: string, sync_source_unreachable: string, search_query_unavailable: string, made_for_you_unavailable: string, made_for_you_missing: string, history_track_hit: string, history_play: string, local_replay_failed: string, local_no_tracks: string, local_no_folder: string, local_uri_invalid: string, local_play_failed: string, local_task_failed: string, local_audio_unavailable: string, audio_init_failed: string, local_scan_failed: string, local_scan_task_failed: string, local_read_failed: string, local_tracks_scan_failed: string, local_exhausted: string, subsonic_audio_unavailable: string, youtube_audio_unavailable: string, radio_audio_unavailable: string, qobuz_audio_unavailable: string, subsonic_no_tracks: string, subsonic_uri_invalid: string, subsonic_replay_failed: string, subsonic_playlists_failed: string, subsonic_playlist_failed: string, subsonic_search_failed: string, subsonic_download_failed: string, subsonic_play_failed: string, subsonic_task_failed: string, subsonic_no_server: string, radio_no_skip: string, radio_empty: string, radio_uri_invalid: string, radio_probe_timeout: string, radio_search_failed: string, radio_open_failed: string, radio_play_failed: string, radio_task_failed: string, radio_playing: string, youtube_playlist_missing: string, youtube_metadata_missing: string, youtube_no_tracks: string, youtube_no_live: string, youtube_uri_invalid: string, youtube_replay_failed: string, youtube_search_failed: string, youtube_playlists_failed: string, youtube_create_failed: string, youtube_delete_failed: string, youtube_add_failed: string, youtube_remove_failed: string, youtube_download_failed: string, youtube_task_failed: string, youtube_created: string, youtube_deleted: string, youtube_removed: string, fetching: string, youtube_added: string, youtube_already_added: string, youtube_decode_failed: string, qobuz_login_expired: string, qobuz_login_busy: string, qobuz_browser_login: string, qobuz_logged_in: string, qobuz_no_tracks: string, qobuz_uri_invalid: string, qobuz_replay_failed: string, qobuz_login_url: string, qobuz_login_failed: string, qobuz_step_constants: string, qobuz_step_login: string, qobuz_step_library: string, qobuz_step_tracks: string, qobuz_step_search: string, qobuz_step_stream: string, qobuz_operation_failed: string, music_busy_playback: string, music_wait_pause: string, music_not_running: string, music_shuffle_repeat: string, music_switch_spotify: string, music_busy: string, music_queue_unsupported: string, music_loading: string, music_loaded: string, music_status_unavailable: string, queue_repeat_unsupported: string, queue_shuffle_unsupported: string, queue_no_uri: string, queue_finished: string, audio_disconnected: string, radio_not_resumed: string, source_playback_unavailable: string, queue_skip_disconnected: string, queue_play_failed: string, queue_restore_failed: string, queue_playing: string, queue_audio_unavailable: string, spotify_not_connected: string, playlist_id_invalid: string, music_requires_macos: string, spotify_not_compiled: string, spotify_connected: string, party_disconnected: string, party_ended: string, spotify_login_timeout: string, spotify_login_failed: string, spotify_rate_wait: string, spotify_pair: string, recap_created: string, history_failed: string, recap_failed: string, party_start_failed: string, party_join_failed: string, party_error: string, party_joined: string, party_left: string, native_reconnecting: string, native_settling: string, shuffle_not_applied: string, spotify_rate_retry: string, spotify_poll_retry: string, spotify_server_retry: string, spotify_poll_rejected: string, native_resuming: string, native_reconnect_resume: string, native_connection_restored: string, native_no_state: string, native_restore_retry: string, other_source_playing: string, spotify_playing_elsewhere: string, queue_added: string, queue_load_failed: string, native_nothing_to_resume: string, native_start_failed: string, native_shuffle_failed: string, native_resume_failed: string, shuffle_context_failed: string, shuffle_capped: string, spotify_profile_retry: string, friend_added: string, friend_unfollowed: string, friend_added_code: string, friend_add_failed: string, friend_unfollow_failed: string, playlist_external_unavailable: string, liked_check_failed: string, playlist_search_scope: string, playlist_search_matches: string, playlist_refresh_partial: string, playlist_added: string, playlist_removed: string, playlist_created_no_id: string, playlist_added_named: string, playlist_created: string, native_recovered: string, native_lost_reconnecting: string, native_reconnected: string, native_restored: string, native_restored_paused: string, native_skip_unavailable: string, native_many_unavailable: string, soloist_path_unset: string, soloist_missing: string, soloist_not_executable: string, soloist_wrong_cpu: string, soloist_start_failed: string, soloist_key_missing: string, soloist_not_paired: string, soloist_offline: string, soloist_bad_key: string, soloist_expired: string, soloist_expires_soon: string, soloist_dir_in_use: string, soloist_keeps_exiting: string, soloist_no_websocket: string, soloist_restarting: string, soloist_restart_quality: string, soloist_streams: string, soloist_lossy_hint: string, soloist_dropped: string, soloist_engine_unavailable: string, soloist_queue_refused: string, soloist_paired: string, engine_switching_soloist: string, engine_switching_librespot: string, engine_next_launch: string, soloist_download_started: string, soloist_downloading: string, soloist_downloaded: string, soloist_downloaded_engine_off: string, soloist_downloaded_next_launch: string, soloist_download_failed: string, soloist_no_build: string, soloist_key_saved: string, soloist_key_restarting: string, soloist_key_env_wins: string, soloist_key_save_failed: string, native_recovery_dropped: string, native_recovery_failed: string, native_lost_recovering: string, native_stalled: string, native_stopped: string, native_moved: string, native_disconnected: string, native_stopped_recovering: string, sync_busy: string, sync_no_links: string, sync_dry_run: string, sync_removed: string, sync_failed: string, sync_link_failed: string, sync_remove_failed: string, sync_summary: string, sync_using_playlist: string, sync_progress: string, dj_indexed: string, dj_index_capped: string, dj_queued: string, dj_not_found: string, dj_owned: string, dj_duplicate: string, dj_status: string, dj_library_failed: string, dj_topped_up: string, dj_nothing_queued: string, dj_error: string, dj_steps_exhausted: string, history_plays: string, history_track_hits: string, history_minutes: string, history_hours_minutes: string, setup_no_api_key: string, setup_keep_command: string, setup_anthropic: string, setup_openai: string, setup_own_command: string, setup_not_installed: string, setup_anthropic_billing: string, setup_needs_key: string, setup_openai_endpoint: string, setup_claude_plan: string, setup_codex_plan: string, setup_agy_plan: string, setup_copilot_plan: string, setup_opencode_plan: string, setup_gemini_legacy: string, setup_agent_subscription: string, setup_keep_as_is: string, setup_own_model_note: string, setup_current: string, setup_cli_default: string, setup_no_model_flag: string, setup_builtin_default: string, setup_custom: string, setup_type_provider_model: string, setup_type_model: string, setup_haiku_note: string, setup_sonnet_note: string, setup_opus_note: string, setup_fable_note: string, setup_cheapest: string, setup_heaviest_gemini: string, setup_heaviest_all: string, setup_price_haiku: string, setup_price_sonnet: string, setup_price_opus: string, setup_price_fable: string, history_week: string, history_month: string, history_all: string, history_this_month: string, history_this_year: string, mcp_status: string, network_error: string, network_rate_error: string, monthly_recap_ready: string, queue_local_source: string, unnamed_folder: string, local_root_folder: string, local_unknown_track: string, cast_no_answer: string, cast_no_tls: string, cast_message_size: string, cast_malformed: string, cast_no_reply: string, cast_receiver_failed: string, cast_client_id_missing: string, cast_login_refused: string, cast_access_token_missing: string, speaker_token_failed: string, speaker_token_unsupported: string, speaker_client_id_missing: string, speaker_login_refused: string, speaker_not_joined: string, playlist_changed: string, spotify_api_error: string, }; diff --git a/gui/src/bindings/PopupStrings.ts b/gui/src/bindings/PopupStrings.ts index 69f8caf9..e2fea2cb 100644 --- a/gui/src/bindings/PopupStrings.ts +++ b/gui/src/bindings/PopupStrings.ts @@ -1,3 +1,3 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type PopupStrings = { resume_template: string, now_playing: string, queue_empty_template: string, queue_up_next: string, queue_title_template: string, source_local: string, api_response: string, playback_check: string, check_premium: string, check_device: string, check_spotifyd: string, playback_hint: string, error_back: string, error_title: string, log_hint: string, log_file: string, delete_playlist_question: string, question_mark: string, confirm: string, remove_track_question: string, track_template: string, playlist_template: string, remove_track: string, fallback_terminal: string, fallback_question: string, save_shortcut_template: string, save_shortcut_fallback: string, remove_link_question: string, mirrors_stay: string, remove_link: string, ok: string, cancel: string, add_track_title: string, selected_track: string, choose_playlist_template: string, no_editable_playlists: string, collaborative_playlist_template: string, playlist_picker_controls_template: string, mirror_playlist: string, selected_playlist: string, mirror_onto_template: string, no_mirror_source: string, mirror_picker_controls_template: string, announcement_info: string, announcement_warning: string, announcement_critical: string, announcement_more_template: string, announcement_dismiss: string, announcement_title: string, recap_ready: string, recap_listens_template: string, recap_browser: string, recap_controls: string, recap_title: string, community_pinned: string, community_details: string, community_add: string, community_choice: string, community_controls: string, community_title: string, exit_question: string, exit_yes_no: string, exit_controls: string, exit_title: string, sort_tracks: string, sort_albums: string, sort_artists: string, sort_title: string, sort_default: string, sort_name: string, sort_date_added: string, sort_artist: string, sort_duration: string, sort_album: string, sort_release_date: string, party_code_prompt: string, party_name_prompt: string, party_join_enter: string, party_name_required: string, party_code_length_template: string, party_name_length_template: string, party_code_then_name: string, esc_cancel: string, listening_party: string, connecting: string, host_party: string, join_party: string, esc_close: string, party_hosting: string, generating: string, share_code: string, control_label: string, control_host_only: string, control_shared: string, waiting_guests: string, listener_one: string, listeners_template: string, party_toggle_control: string, party_leave: string, party_close_menu: string, party_guest: string, host_label: string, room_label: string, mode_label: string, following_host: string, party_hosting_title: string, party_joined_title: string, party_title: string, plugin_close_hint: string, fallback_session_only: string, create_playlist_title: string, playlist_name_label: string, create_continue_hint: string, add_tracks_title_template: string, create_search_title: string, unknown_artist: string, create_results_title: string, added_tracks_title_template: string, }; +export type PopupStrings = { resume_template: string, now_playing: string, queue_empty_template: string, queue_up_next: string, queue_title_template: string, source_local: string, api_response: string, playback_check: string, check_premium: string, check_device: string, check_spotifyd: string, playback_hint: string, error_back: string, error_title: string, log_hint: string, log_file: string, delete_playlist_question: string, question_mark: string, confirm: string, remove_track_question: string, track_template: string, playlist_template: string, remove_track: string, fallback_terminal: string, fallback_question: string, save_shortcut_template: string, save_shortcut_fallback: string, remove_link_question: string, mirrors_stay: string, remove_link: string, ok: string, cancel: string, add_track_title: string, selected_track: string, choose_playlist_template: string, no_editable_playlists: string, collaborative_playlist_template: string, playlist_picker_controls_template: string, mirror_playlist: string, selected_playlist: string, mirror_onto_template: string, no_mirror_source: string, mirror_picker_controls_template: string, announcement_info: string, announcement_warning: string, announcement_critical: string, announcement_more_template: string, announcement_dismiss: string, announcement_title: string, recap_ready: string, recap_listens_template: string, recap_browser: string, recap_controls: string, recap_title: string, community_pinned: string, community_details: string, community_add: string, community_choice: string, community_controls: string, community_title: string, exit_question: string, exit_yes_no: string, exit_controls: string, exit_title: string, sort_tracks: string, sort_albums: string, sort_artists: string, sort_title: string, sort_default: string, sort_name: string, sort_date_added: string, sort_artist: string, sort_duration: string, sort_album: string, sort_release_date: string, party_code_prompt: string, party_name_prompt: string, party_join_enter: string, party_name_required: string, party_code_length_template: string, party_name_length_template: string, party_code_then_name: string, esc_cancel: string, listening_party: string, connecting: string, host_party: string, join_party: string, esc_close: string, party_hosting: string, generating: string, share_code: string, control_label: string, control_host_only: string, control_shared: string, waiting_guests: string, listener_one: string, listeners_template: string, party_toggle_control: string, party_leave: string, party_close_menu: string, party_guest: string, host_label: string, room_label: string, mode_label: string, following_host: string, party_hosting_title: string, party_joined_title: string, party_title: string, plugin_close_hint: string, fallback_session_only: string, create_playlist_title: string, playlist_name_label: string, create_continue_hint: string, add_tracks_title_template: string, create_search_title: string, unknown_artist: string, create_results_title: string, added_tracks_title_template: string, soloist_pair_title: string, soloist_pair_open: string, soloist_pair_pick_template: string, soloist_pair_waiting: string, soloist_pair_close: string, soloist_offer_title: string, soloist_offer_body: string, soloist_offer_step_download: string, soloist_offer_step_key: string, soloist_offer_step_engine: string, soloist_offer_set_up: string, soloist_offer_not_now: string, soloist_offer_controls: string, }; diff --git a/gui/src/bindings/SettingRow.ts b/gui/src/bindings/SettingRow.ts index bba071f5..e130c07d 100644 --- a/gui/src/bindings/SettingRow.ts +++ b/gui/src/bindings/SettingRow.ts @@ -1,8 +1,13 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { SettingRowButton } from "./SettingRowButton"; import type { SettingRowValue } from "./SettingRowValue"; export type SettingRow = { id: string, /** * `interface`, `playback`, `streaming`, `integrations`, `library`, `sources` or `updates`. */ -group: string, name: string, description: string, value: SettingRowValue, }; +group: string, name: string, description: string, value: SettingRowValue, +/** + * The row's one button; pressing it sends `PressSettingButton`. + */ +button: SettingRowButton | null, }; diff --git a/gui/src/bindings/SettingRowButton.ts b/gui/src/bindings/SettingRowButton.ts new file mode 100644 index 00000000..8db83ae9 --- /dev/null +++ b/gui/src/bindings/SettingRowButton.ts @@ -0,0 +1,7 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type SettingRowButton = { label: string, +/** + * Its work runs, so the button is off. + */ +busy: boolean, }; diff --git a/gui/src/bindings/SettingsStrings.ts b/gui/src/bindings/SettingsStrings.ts index 8fdfefef..19d893a0 100644 --- a/gui/src/bindings/SettingsStrings.ts +++ b/gui/src/bindings/SettingsStrings.ts @@ -1,3 +1,3 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type SettingsStrings = { behavior_ytdlp_path_description: string, behavior_ytdlp_path_name: string, behavior_local_music_path_description: string, behavior_local_music_path_name: string, behavior_subsonic_password_description: string, behavior_subsonic_password_name: string, behavior_subsonic_username_description: string, behavior_subsonic_username_name: string, behavior_subsonic_url_description: string, behavior_subsonic_url_name: string, behavior_language_name: string, behavior_language_description: string, behavior_seek_milliseconds_name: string, behavior_seek_milliseconds_description: string, behavior_volume_increment_name: string, behavior_volume_increment_description: string, behavior_tick_rate_milliseconds_name: string, behavior_tick_rate_milliseconds_description: string, behavior_animation_tick_rate_milliseconds_name: string, behavior_animation_tick_rate_milliseconds_description: string, behavior_status_message_ttl_percent_name: string, behavior_status_message_ttl_percent_description: string, behavior_qobuz_quality_name: string, behavior_qobuz_quality_description: string, behavior_playback_poll_seconds_name: string, behavior_playback_poll_seconds_description: string, behavior_table_scroll_padding_name: string, behavior_table_scroll_padding_description: string, behavior_like_animation_frames_name: string, behavior_like_animation_frames_description: string, behavior_enable_text_emphasis_name: string, behavior_enable_text_emphasis_description: string, behavior_show_loading_indicator_name: string, behavior_show_loading_indicator_description: string, behavior_enforce_wide_search_bar_name: string, behavior_enforce_wide_search_bar_description: string, behavior_group_folders_first_name: string, behavior_group_folders_first_description: string, behavior_disable_mouse_inputs_name: string, behavior_disable_mouse_inputs_description: string, behavior_set_window_title_name: string, behavior_set_window_title_description: string, behavior_enable_discord_rpc_name: string, behavior_enable_discord_rpc_description: string, behavior_stop_after_current_track_name: string, behavior_stop_after_current_track_description: string, behavior_keepawake_enabled_name: string, behavior_keepawake_enabled_description: string, behavior_enable_media_keys_name: string, behavior_enable_media_keys_description: string, behavior_enable_streaming_name: string, behavior_enable_streaming_description: string, behavior_streaming_device_name_name: string, behavior_streaming_device_name_description: string, behavior_streaming_bitrate_name: string, behavior_streaming_bitrate_description: string, behavior_streaming_audio_cache_name: string, behavior_streaming_audio_cache_description: string, behavior_streaming_autoplay_name: string, behavior_streaming_autoplay_description: string, behavior_startup_behavior_name: string, behavior_startup_behavior_description: string, behavior_player_bar_visualizer_name: string, behavior_player_bar_visualizer_description: string, behavior_startup_route_name: string, behavior_startup_route_description: string, behavior_default_sort_playlist_tracks_name: string, behavior_default_sort_playlist_tracks_description: string, behavior_default_sort_saved_albums_name: string, behavior_default_sort_saved_albums_description: string, behavior_default_sort_saved_artists_name: string, behavior_default_sort_saved_artists_description: string, behavior_default_sort_recently_played_name: string, behavior_default_sort_recently_played_description: string, behavior_sidebar_position_name: string, behavior_sidebar_position_description: string, behavior_playbar_position_name: string, behavior_playbar_position_description: string, behavior_small_terminal_width_name: string, behavior_small_terminal_width_description: string, behavior_small_terminal_height_name: string, behavior_small_terminal_height_description: string, behavior_enable_announcements_name: string, behavior_enable_announcements_description: string, behavior_enable_lyrics_name: string, behavior_enable_lyrics_description: string, behavior_enable_monthly_recap_prompt_name: string, behavior_enable_monthly_recap_prompt_description: string, behavior_pin_community_playlist_name: string, behavior_pin_community_playlist_description: string, behavior_enable_global_song_count_name: string, behavior_enable_global_song_count_description: string, behavior_disable_auto_update_name: string, behavior_disable_auto_update_description: string, behavior_auto_update_delay_name: string, behavior_auto_update_delay_description: string, behavior_announcement_feed_url_name: string, behavior_announcement_feed_url_description: string, behavior_sync_token_name: string, behavior_sync_token_description: string, behavior_draw_cover_art_name: string, behavior_draw_cover_art_description: string, behavior_draw_cover_art_forced_name: string, behavior_draw_cover_art_forced_description: string, behavior_playbar_cover_art_size_percent_name: string, behavior_playbar_cover_art_size_percent_description: string, behavior_cover_art_dither_name: string, behavior_cover_art_dither_description: string, behavior_cover_art_dither_algorithm_name: string, behavior_cover_art_dither_algorithm_description: string, behavior_cover_art_dither_pixel_scale_name: string, behavior_cover_art_dither_pixel_scale_description: string, behavior_liked_icon_name: string, behavior_liked_icon_description: string, behavior_shuffle_icon_name: string, behavior_shuffle_icon_description: string, behavior_repeat_track_icon_name: string, behavior_repeat_track_icon_description: string, behavior_repeat_context_icon_name: string, behavior_repeat_context_icon_description: string, behavior_playing_icon_name: string, behavior_playing_icon_description: string, behavior_paused_icon_name: string, behavior_paused_icon_description: string, behavior_gauge_filled_icon_name: string, behavior_gauge_filled_icon_description: string, behavior_gauge_unfilled_icon_name: string, behavior_gauge_unfilled_icon_description: string, behavior_active_source_icon_name: string, behavior_active_source_icon_description: string, behavior_episode_played_icon_name: string, behavior_episode_played_icon_description: string, behavior_sort_ascending_icon_name: string, behavior_sort_ascending_icon_description: string, behavior_sort_descending_icon_name: string, behavior_sort_descending_icon_description: string, behavior_list_highlight_icon_name: string, behavior_list_highlight_icon_description: string, keys_back_name: string, keys_back_description: string, keys_move_up_name: string, keys_move_up_description: string, keys_move_down_name: string, keys_move_down_description: string, keys_move_left_name: string, keys_move_left_description: string, keys_move_right_name: string, keys_move_right_description: string, keys_next_page_name: string, keys_next_page_description: string, keys_previous_page_name: string, keys_previous_page_description: string, keys_jump_to_start_name: string, keys_jump_to_start_description: string, keys_jump_to_end_name: string, keys_jump_to_end_description: string, keys_toggle_playback_name: string, keys_toggle_playback_description: string, keys_seek_backwards_name: string, keys_seek_backwards_description: string, keys_seek_forwards_name: string, keys_seek_forwards_description: string, keys_next_track_name: string, keys_next_track_description: string, keys_previous_track_name: string, keys_previous_track_description: string, keys_force_previous_track_name: string, keys_force_previous_track_description: string, keys_shuffle_name: string, keys_shuffle_description: string, keys_repeat_name: string, keys_repeat_description: string, keys_search_name: string, keys_search_description: string, keys_help_name: string, keys_help_description: string, keys_open_settings_name: string, keys_open_settings_description: string, keys_save_settings_name: string, keys_save_settings_description: string, keys_jump_to_album_name: string, keys_jump_to_album_description: string, keys_jump_to_artist_album_name: string, keys_jump_to_artist_album_description: string, keys_jump_to_context_name: string, keys_jump_to_context_description: string, keys_manage_devices_name: string, keys_manage_devices_description: string, keys_listening_party_name: string, keys_listening_party_description: string, keys_decrease_volume_name: string, keys_decrease_volume_description: string, keys_increase_volume_name: string, keys_increase_volume_description: string, keys_add_item_to_queue_name: string, keys_add_item_to_queue_description: string, keys_show_queue_name: string, keys_show_queue_description: string, keys_remove_from_queue_name: string, keys_remove_from_queue_description: string, keys_like_track_name: string, keys_like_track_description: string, keys_generate_recap_name: string, keys_generate_recap_description: string, keys_copy_song_url_name: string, keys_copy_song_url_description: string, keys_copy_album_url_name: string, keys_copy_album_url_description: string, keys_audio_analysis_name: string, keys_audio_analysis_description: string, keys_lyrics_view_name: string, keys_lyrics_view_description: string, keys_miniplayer_view_name: string, keys_miniplayer_view_description: string, keys_cover_art_view_name: string, keys_cover_art_view_description: string, theme_preset_name: string, theme_preset_description: string, theme_active_name: string, theme_active_description: string, theme_banner_name: string, theme_banner_description: string, behavior_banner_gradient_name: string, behavior_banner_gradient_description: string, behavior_cover_art_theme_name: string, behavior_cover_art_theme_description: string, theme_hint_name: string, theme_hint_description: string, theme_hovered_name: string, theme_hovered_description: string, theme_selected_name: string, theme_selected_description: string, theme_inactive_name: string, theme_inactive_description: string, theme_text_name: string, theme_text_description: string, theme_error_text_name: string, theme_error_text_description: string, theme_error_border_name: string, theme_error_border_description: string, theme_playbar_background_name: string, theme_playbar_background_description: string, theme_playbar_progress_name: string, theme_playbar_progress_description: string, theme_cover_art_dither_color_name: string, theme_cover_art_dither_color_description: string, theme_playbar_progress_text_name: string, theme_playbar_progress_text_description: string, theme_playbar_text_name: string, theme_playbar_text_description: string, theme_highlighted_lyrics_name: string, theme_highlighted_lyrics_description: string, theme_background_name: string, theme_background_description: string, theme_header_name: string, theme_header_description: string, category_behavior: string, category_icons: string, category_keybindings: string, category_theme: string, on: string, off: string, bool_edit_on: string, bool_edit_off: string, bool_on: string, bool_off: string, tabs_title: string, effective_key_template: string, filtered_count_template: string, item_count_template: string, category_title_template: string, no_matches_template: string, enhancement_on: string, enhancement_available: string, enhancement_off: string, ctrl_comma_ok: string, ctrl_comma_degraded: string, ctrl_comma_unknown: string, terminal_input_template: string, filter_controls: string, bool_controls: string, number_controls: string, key_controls: string, cycle_controls: string, text_controls: string, navigation_controls_template: string, filter_edit_template: string, filter_applied_template: string, controls_title: string, unsaved_title: string, unsaved_message: string, yes_button: string, no_button: string, unsaved_controls: string, save_failed_template: string, icon_width_template: string, invalid_delay_template: string, invalid_color_template: string, reserved_key_template: string, key_conflict_template: string, option_auto: string, option_auto_color: string, option_en: string, option_zh_hans: string, option_zh_hant: string, option_continue: string, option_play: string, option_pause: string, option_home: string, option_recently_played: string, option_podcasts: string, option_discover: string, option_artists: string, option_album_list: string, option_stats: string, option_default: string, option_name: string, option_name_desc: string, option_date_added: string, option_date_added_desc: string, option_artist: string, option_artist_desc: string, option_album: string, option_album_desc: string, option_duration: string, option_duration_desc: string, option_release_date: string, option_release_date_desc: string, option_left: string, option_right: string, option_hidden: string, option_bottom: string, option_top: string, option_spectrum: string, option_waveform: string, option_custom: string, delay_days: string, delay_hours: string, delay_minutes: string, delay_seconds: string, delay_number: string, color_unknown_template: string, color_empty: string, color_invalid_digit: string, color_too_large: string, color_too_small: string, option_preset_default: string, option_preset_terminal: string, color_reset: string, color_black: string, color_red: string, color_green: string, color_yellow: string, color_blue: string, color_magenta: string, color_cyan: string, color_gray: string, color_dark_gray: string, color_light_red: string, color_light_green: string, color_light_yellow: string, color_light_blue: string, color_light_magenta: string, color_light_cyan: string, color_white: string, }; +export type SettingsStrings = { behavior_ytdlp_path_description: string, behavior_ytdlp_path_name: string, behavior_playback_engine_name: string, behavior_playback_engine_description: string, behavior_soloist_path_name: string, behavior_soloist_path_description: string, soloist_download_button: string, soloist_downloading_button: string, behavior_soloist_api_key_name: string, behavior_soloist_api_key_description: string, soloist_get_key_button: string, behavior_soloist_audio_quality_name: string, behavior_soloist_audio_quality_description: string, behavior_local_music_path_description: string, behavior_local_music_path_name: string, behavior_subsonic_password_description: string, behavior_subsonic_password_name: string, behavior_subsonic_username_description: string, behavior_subsonic_username_name: string, behavior_subsonic_url_description: string, behavior_subsonic_url_name: string, behavior_language_name: string, behavior_language_description: string, behavior_seek_milliseconds_name: string, behavior_seek_milliseconds_description: string, behavior_volume_increment_name: string, behavior_volume_increment_description: string, behavior_tick_rate_milliseconds_name: string, behavior_tick_rate_milliseconds_description: string, behavior_animation_tick_rate_milliseconds_name: string, behavior_animation_tick_rate_milliseconds_description: string, behavior_status_message_ttl_percent_name: string, behavior_status_message_ttl_percent_description: string, behavior_qobuz_quality_name: string, behavior_qobuz_quality_description: string, behavior_playback_poll_seconds_name: string, behavior_playback_poll_seconds_description: string, behavior_table_scroll_padding_name: string, behavior_table_scroll_padding_description: string, behavior_like_animation_frames_name: string, behavior_like_animation_frames_description: string, behavior_enable_text_emphasis_name: string, behavior_enable_text_emphasis_description: string, behavior_show_loading_indicator_name: string, behavior_show_loading_indicator_description: string, behavior_enforce_wide_search_bar_name: string, behavior_enforce_wide_search_bar_description: string, behavior_group_folders_first_name: string, behavior_group_folders_first_description: string, behavior_disable_mouse_inputs_name: string, behavior_disable_mouse_inputs_description: string, behavior_set_window_title_name: string, behavior_set_window_title_description: string, behavior_enable_discord_rpc_name: string, behavior_enable_discord_rpc_description: string, behavior_stop_after_current_track_name: string, behavior_stop_after_current_track_description: string, behavior_keepawake_enabled_name: string, behavior_keepawake_enabled_description: string, behavior_enable_media_keys_name: string, behavior_enable_media_keys_description: string, behavior_enable_streaming_name: string, behavior_enable_streaming_description: string, behavior_streaming_device_name_name: string, behavior_streaming_device_name_description: string, behavior_streaming_bitrate_name: string, behavior_streaming_bitrate_description: string, behavior_streaming_audio_cache_name: string, behavior_streaming_audio_cache_description: string, behavior_streaming_autoplay_name: string, behavior_streaming_autoplay_description: string, behavior_startup_behavior_name: string, behavior_startup_behavior_description: string, behavior_player_bar_visualizer_name: string, behavior_player_bar_visualizer_description: string, behavior_startup_route_name: string, behavior_startup_route_description: string, behavior_default_sort_playlist_tracks_name: string, behavior_default_sort_playlist_tracks_description: string, behavior_default_sort_saved_albums_name: string, behavior_default_sort_saved_albums_description: string, behavior_default_sort_saved_artists_name: string, behavior_default_sort_saved_artists_description: string, behavior_default_sort_recently_played_name: string, behavior_default_sort_recently_played_description: string, behavior_sidebar_position_name: string, behavior_sidebar_position_description: string, behavior_playbar_position_name: string, behavior_playbar_position_description: string, behavior_small_terminal_width_name: string, behavior_small_terminal_width_description: string, behavior_small_terminal_height_name: string, behavior_small_terminal_height_description: string, behavior_enable_announcements_name: string, behavior_enable_announcements_description: string, behavior_enable_lyrics_name: string, behavior_enable_lyrics_description: string, behavior_enable_monthly_recap_prompt_name: string, behavior_enable_monthly_recap_prompt_description: string, behavior_pin_community_playlist_name: string, behavior_pin_community_playlist_description: string, behavior_enable_global_song_count_name: string, behavior_enable_global_song_count_description: string, behavior_disable_auto_update_name: string, behavior_disable_auto_update_description: string, behavior_auto_update_delay_name: string, behavior_auto_update_delay_description: string, behavior_announcement_feed_url_name: string, behavior_announcement_feed_url_description: string, behavior_sync_token_name: string, behavior_sync_token_description: string, behavior_draw_cover_art_name: string, behavior_draw_cover_art_description: string, behavior_draw_cover_art_forced_name: string, behavior_draw_cover_art_forced_description: string, behavior_playbar_cover_art_size_percent_name: string, behavior_playbar_cover_art_size_percent_description: string, behavior_cover_art_dither_name: string, behavior_cover_art_dither_description: string, behavior_cover_art_dither_algorithm_name: string, behavior_cover_art_dither_algorithm_description: string, behavior_cover_art_dither_pixel_scale_name: string, behavior_cover_art_dither_pixel_scale_description: string, behavior_liked_icon_name: string, behavior_liked_icon_description: string, behavior_shuffle_icon_name: string, behavior_shuffle_icon_description: string, behavior_repeat_track_icon_name: string, behavior_repeat_track_icon_description: string, behavior_repeat_context_icon_name: string, behavior_repeat_context_icon_description: string, behavior_playing_icon_name: string, behavior_playing_icon_description: string, behavior_paused_icon_name: string, behavior_paused_icon_description: string, behavior_gauge_filled_icon_name: string, behavior_gauge_filled_icon_description: string, behavior_gauge_unfilled_icon_name: string, behavior_gauge_unfilled_icon_description: string, behavior_active_source_icon_name: string, behavior_active_source_icon_description: string, behavior_episode_played_icon_name: string, behavior_episode_played_icon_description: string, behavior_sort_ascending_icon_name: string, behavior_sort_ascending_icon_description: string, behavior_sort_descending_icon_name: string, behavior_sort_descending_icon_description: string, behavior_list_highlight_icon_name: string, behavior_list_highlight_icon_description: string, keys_back_name: string, keys_back_description: string, keys_move_up_name: string, keys_move_up_description: string, keys_move_down_name: string, keys_move_down_description: string, keys_move_left_name: string, keys_move_left_description: string, keys_move_right_name: string, keys_move_right_description: string, keys_next_page_name: string, keys_next_page_description: string, keys_previous_page_name: string, keys_previous_page_description: string, keys_jump_to_start_name: string, keys_jump_to_start_description: string, keys_jump_to_end_name: string, keys_jump_to_end_description: string, keys_toggle_playback_name: string, keys_toggle_playback_description: string, keys_seek_backwards_name: string, keys_seek_backwards_description: string, keys_seek_forwards_name: string, keys_seek_forwards_description: string, keys_next_track_name: string, keys_next_track_description: string, keys_previous_track_name: string, keys_previous_track_description: string, keys_force_previous_track_name: string, keys_force_previous_track_description: string, keys_shuffle_name: string, keys_shuffle_description: string, keys_repeat_name: string, keys_repeat_description: string, keys_search_name: string, keys_search_description: string, keys_help_name: string, keys_help_description: string, keys_open_settings_name: string, keys_open_settings_description: string, keys_save_settings_name: string, keys_save_settings_description: string, keys_jump_to_album_name: string, keys_jump_to_album_description: string, keys_jump_to_artist_album_name: string, keys_jump_to_artist_album_description: string, keys_jump_to_context_name: string, keys_jump_to_context_description: string, keys_manage_devices_name: string, keys_manage_devices_description: string, keys_listening_party_name: string, keys_listening_party_description: string, keys_decrease_volume_name: string, keys_decrease_volume_description: string, keys_increase_volume_name: string, keys_increase_volume_description: string, keys_add_item_to_queue_name: string, keys_add_item_to_queue_description: string, keys_show_queue_name: string, keys_show_queue_description: string, keys_remove_from_queue_name: string, keys_remove_from_queue_description: string, keys_like_track_name: string, keys_like_track_description: string, keys_generate_recap_name: string, keys_generate_recap_description: string, keys_copy_song_url_name: string, keys_copy_song_url_description: string, keys_copy_album_url_name: string, keys_copy_album_url_description: string, keys_audio_analysis_name: string, keys_audio_analysis_description: string, keys_lyrics_view_name: string, keys_lyrics_view_description: string, keys_miniplayer_view_name: string, keys_miniplayer_view_description: string, keys_cover_art_view_name: string, keys_cover_art_view_description: string, theme_preset_name: string, theme_preset_description: string, theme_active_name: string, theme_active_description: string, theme_banner_name: string, theme_banner_description: string, behavior_banner_gradient_name: string, behavior_banner_gradient_description: string, behavior_cover_art_theme_name: string, behavior_cover_art_theme_description: string, theme_hint_name: string, theme_hint_description: string, theme_hovered_name: string, theme_hovered_description: string, theme_selected_name: string, theme_selected_description: string, theme_inactive_name: string, theme_inactive_description: string, theme_text_name: string, theme_text_description: string, theme_error_text_name: string, theme_error_text_description: string, theme_error_border_name: string, theme_error_border_description: string, theme_playbar_background_name: string, theme_playbar_background_description: string, theme_playbar_progress_name: string, theme_playbar_progress_description: string, theme_cover_art_dither_color_name: string, theme_cover_art_dither_color_description: string, theme_playbar_progress_text_name: string, theme_playbar_progress_text_description: string, theme_playbar_text_name: string, theme_playbar_text_description: string, theme_highlighted_lyrics_name: string, theme_highlighted_lyrics_description: string, theme_background_name: string, theme_background_description: string, theme_header_name: string, theme_header_description: string, category_behavior: string, category_icons: string, category_keybindings: string, category_theme: string, on: string, off: string, bool_edit_on: string, bool_edit_off: string, bool_on: string, bool_off: string, tabs_title: string, effective_key_template: string, filtered_count_template: string, item_count_template: string, category_title_template: string, no_matches_template: string, enhancement_on: string, enhancement_available: string, enhancement_off: string, ctrl_comma_ok: string, ctrl_comma_degraded: string, ctrl_comma_unknown: string, terminal_input_template: string, filter_controls: string, bool_controls: string, number_controls: string, key_controls: string, cycle_controls: string, text_controls: string, navigation_controls_template: string, filter_edit_template: string, filter_applied_template: string, controls_title: string, unsaved_title: string, unsaved_message: string, yes_button: string, no_button: string, unsaved_controls: string, save_failed_template: string, icon_width_template: string, invalid_delay_template: string, invalid_color_template: string, reserved_key_template: string, key_conflict_template: string, option_auto: string, option_auto_color: string, option_en: string, option_zh_hans: string, option_zh_hant: string, option_continue: string, option_play: string, option_pause: string, option_home: string, option_recently_played: string, option_podcasts: string, option_discover: string, option_artists: string, option_album_list: string, option_stats: string, option_default: string, option_name: string, option_name_desc: string, option_date_added: string, option_date_added_desc: string, option_artist: string, option_artist_desc: string, option_album: string, option_album_desc: string, option_duration: string, option_duration_desc: string, option_release_date: string, option_release_date_desc: string, option_left: string, option_right: string, option_hidden: string, option_bottom: string, option_top: string, option_spectrum: string, option_waveform: string, option_custom: string, delay_days: string, delay_hours: string, delay_minutes: string, delay_seconds: string, delay_number: string, color_unknown_template: string, color_empty: string, color_invalid_digit: string, color_too_large: string, color_too_small: string, option_preset_default: string, option_preset_terminal: string, color_reset: string, color_black: string, color_red: string, color_green: string, color_yellow: string, color_blue: string, color_magenta: string, color_cyan: string, color_gray: string, color_dark_gray: string, color_light_red: string, color_light_green: string, color_light_yellow: string, color_light_blue: string, color_light_magenta: string, color_light_cyan: string, color_white: string, }; diff --git a/gui/src/bindings/SoloistOffer.ts b/gui/src/bindings/SoloistOffer.ts new file mode 100644 index 00000000..ccf95d5f --- /dev/null +++ b/gui/src/bindings/SoloistOffer.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type SoloistOffer = { title: string, body: string, steps: [string, string, string], set_up: string, not_now: string, }; diff --git a/gui/src/bindings/SoloistPairingGuide.ts b/gui/src/bindings/SoloistPairingGuide.ts new file mode 100644 index 00000000..80411fa7 --- /dev/null +++ b/gui/src/bindings/SoloistPairingGuide.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type SoloistPairingGuide = { title: string, steps: Array, waiting: string, close: string, }; diff --git a/gui/src/bindings/StatusPayload.ts b/gui/src/bindings/StatusPayload.ts index eda283a3..23ca0bd5 100644 --- a/gui/src/bindings/StatusPayload.ts +++ b/gui/src/bindings/StatusPayload.ts @@ -1,3 +1,13 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { SoloistOffer } from "./SoloistOffer"; +import type { SoloistPairingGuide } from "./SoloistPairingGuide"; -export type StatusPayload = { message: string | null, is_error: boolean, api_error: string | null, }; +export type StatusPayload = { message: string | null, is_error: boolean, api_error: string | null, +/** + * The Soloist pairing guide to show in a modal. + */ +soloist_pairing: SoloistPairingGuide | null, +/** + * The offer to set up Soloist to show in a modal. + */ +soloist_offer: SoloistOffer | null, }; diff --git a/gui/src/bindings/strings.json b/gui/src/bindings/strings.json index 815f70e9..96e4853b 100644 --- a/gui/src/bindings/strings.json +++ b/gui/src/bindings/strings.json @@ -641,6 +641,7 @@ "search_submit": "Search with input text", "seek_backward": "Seek backwards 5 seconds", "seek_forward": "Seek forwards 5 seconds", + "settings_button": "Press the selected setting's button", "settings_open": "Open settings", "settings_save": "Save settings", "show_follow": "Follow show", @@ -816,6 +817,19 @@ "selected_playlist": "Selected playlist", "selected_track": "Selected track", "share_code": "Share this code: ", + "soloist_offer_body": "Spotify refuses librespot the audio keys for these tracks, as it does for some accounts. Soloist, Spotify's own player, can play them. It needs Premium. In Settings:", + "soloist_offer_controls": "Enter: open Settings Esc: not now", + "soloist_offer_not_now": "Not now", + "soloist_offer_set_up": "Set up Soloist", + "soloist_offer_step_download": "1. Soloist Path: Download from Spotify.", + "soloist_offer_step_engine": "3. Playback Engine: Soloist.", + "soloist_offer_step_key": "2. Soloist API Key: Get a key, then enter it.", + "soloist_offer_title": "Play through Soloist?", + "soloist_pair_close": "Close", + "soloist_pair_open": "1. Open Spotify on a computer on this network.", + "soloist_pair_pick_template": "2. In its device menu, pick '{name}'.", + "soloist_pair_title": "Pair spotatui with Spotify", + "soloist_pair_waiting": "Waiting for Spotify…", "sort_album": "Album", "sort_albums": "Sort Albums", "sort_artist": "Artist", @@ -1204,6 +1218,8 @@ "behavior_paused_icon_name": "Paused Icon", "behavior_pin_community_playlist_description": "Pin the spotatui community playlist to the top of your Spotify playlists", "behavior_pin_community_playlist_name": "Community Playlist Pin", + "behavior_playback_engine_description": "What plays Spotify behind the Native Streaming device. Soloist is Spotify's own player: get it with the Soloist Path button and enter its key in Soloist API Key.", + "behavior_playback_engine_name": "Playback Engine", "behavior_playback_poll_seconds_description": "Seconds between regular playback refreshes", "behavior_playback_poll_seconds_name": "Playback Poll Seconds", "behavior_playbar_cover_art_size_percent_description": "Playbar cover art size as a percentage (25-200)", @@ -1234,6 +1250,12 @@ "behavior_small_terminal_height_name": "Small Terminal Height", "behavior_small_terminal_width_description": "Width below which compact layout is used", "behavior_small_terminal_width_name": "Small Terminal Width", + "behavior_soloist_api_key_description": "The key Soloist logs in with, saved in soloist_credentials.yml. Get a key opens Spotify's developer dashboard, where you create one. SPOTATUI_SOLOIST_API_KEY wins over this value. Other local programs can read the key from Soloist's command line.", + "behavior_soloist_api_key_name": "Soloist API Key", + "behavior_soloist_audio_quality_description": "The stream Soloist asks for (applies on restart). Lossless needs Premium in a lossless market; if Soloist still streams lossy, pick spotatui in the Spotify mobile app's device menu and set Lossless.", + "behavior_soloist_audio_quality_name": "Soloist Audio Quality", + "behavior_soloist_path_description": "The Soloist program the Soloist engine runs. Download from Spotify gets the official build for this system from Spotify; by using Soloist, you accept the Spotify Terms and Conditions of Use.", + "behavior_soloist_path_name": "Soloist Path", "behavior_sort_ascending_icon_description": "Single-cell icon for ascending sort", "behavior_sort_ascending_icon_name": "Sort Ascending Icon", "behavior_sort_descending_icon_description": "Single-cell icon for descending sort", @@ -1450,6 +1472,9 @@ "option_zh_hant": "繁體中文", "reserved_key_template": "The key {key} is reserved and cannot be remapped", "save_failed_template": "Failed to save settings: {error}", + "soloist_download_button": "Download from Spotify", + "soloist_downloading_button": "Downloading…", + "soloist_get_key_button": "Get a key", "tabs_title": "Settings (←/→ to switch tabs)", "terminal_input_template": "Terminal Input: {enhancement} | {ctrl_comma}", "text_controls": "Type to edit | Enter: Confirm | Esc: Cancel", @@ -1677,6 +1702,9 @@ "dj_status": "DJ: {summary}", "dj_steps_exhausted": "Stopped after {count} steps. Ask again if that is not where you wanted to end up.", "dj_topped_up": "DJ: topped up the queue", + "engine_next_launch": "The new playback engine starts at the next launch.", + "engine_switching_librespot": "Switching the playback engine to librespot.", + "engine_switching_soloist": "Switching the playback engine to Soloist.", "fetching": "Fetching {name}…", "friend_add_failed": "Could not add friend: {error}", "friend_added": "Added friend", @@ -1854,6 +1882,39 @@ "shuffle_capped": "Large context: shuffling the first {count} tracks", "shuffle_context_failed": "Shuffle: failed to load the context's track list", "shuffle_not_applied": "Playback started; couldn't apply shuffle.", + "soloist_bad_key": "Soloist rejected the API key. In Settings, Soloist API Key, enter a new one.", + "soloist_dir_in_use": "Another Soloist uses spotatui's Soloist data folder.", + "soloist_download_failed": "Soloist download failed: {error}", + "soloist_download_started": "Downloading Soloist from Spotify. By using Soloist, you accept the Spotify Terms and Conditions of Use.", + "soloist_downloaded": "Soloist downloaded; starting it.", + "soloist_downloaded_engine_off": "Soloist downloaded. Set Playback Engine to Soloist to play through it.", + "soloist_downloaded_next_launch": "Soloist downloaded. spotatui uses it from the next launch.", + "soloist_downloading": "Downloading Soloist from Spotify: {percent}%", + "soloist_dropped": "Soloist dropped to {quality}.", + "soloist_engine_unavailable": "This build has no Soloist engine; playing through librespot.", + "soloist_expired": "Soloist build expired. In Settings, Soloist Path, pick Download from Spotify for a new one.", + "soloist_expires_soon": "Soloist build expires in {count} {unit}: download a new one soon.", + "soloist_keeps_exiting": "Soloist keeps exiting; see the log.", + "soloist_key_env_wins": "Soloist API key saved, but SPOTATUI_SOLOIST_API_KEY is set and wins over it.", + "soloist_key_missing": "Soloist needs an API key. In Settings, Soloist API Key, pick Get a key, then enter the key (it is saved in {path}).", + "soloist_key_restarting": "Soloist API key saved; restarting Soloist with it.", + "soloist_key_save_failed": "Could not save the Soloist API key: {error}", + "soloist_key_saved": "Soloist API key saved.", + "soloist_lossy_hint": "Soloist streams {quality}. For lossless, pick spotatui in the Spotify mobile app's device menu and set Lossless.", + "soloist_missing": "Soloist not found at {path}. In Settings, Soloist Path, pick Download from Spotify.", + "soloist_no_build": "Spotify has no Soloist build for this system.", + "soloist_no_websocket": "Soloist started without its WebSocket.", + "soloist_not_executable": "Soloist at {path} is not executable: chmod +x {path}", + "soloist_not_paired": "Pair spotatui: open Spotify on a computer on this network and pick 'spotatui'.", + "soloist_offline": "Soloist cannot reach Spotify.", + "soloist_paired": "Paired: spotatui plays through Soloist.", + "soloist_path_unset": "Soloist is not installed. In Settings, Soloist Path, pick Download from Spotify.", + "soloist_queue_refused": "Queueing other sources behind Spotify needs the librespot engine.", + "soloist_restart_quality": "Restarting Soloist to apply the audio quality.", + "soloist_restarting": "Soloist stopped; restarting it.", + "soloist_start_failed": "Soloist could not start: {error}", + "soloist_streams": "Soloist streams {quality}.", + "soloist_wrong_cpu": "This Soloist build does not run on this CPU. In Settings, Soloist Path, pick Download from Spotify.", "source_playback_unavailable": "{source} playback isn't available in this build", "speaker_client_id_missing": "no clientID in getInfo", "speaker_login_refused": "the speaker refused the login: {message} ({error})", @@ -2652,6 +2713,7 @@ "search_submit": "搜索输入的内容", "seek_backward": "快退", "seek_forward": "快进", + "settings_button": "Press the selected setting's button", "settings_open": "打开设置", "settings_save": "保存设置", "show_follow": "关注节目", @@ -2827,6 +2889,19 @@ "selected_playlist": "所选播放列表", "selected_track": "所选曲目", "share_code": "分享此房间码:", + "soloist_offer_body": "Spotify refuses librespot the audio keys for these tracks, as it does for some accounts. Soloist, Spotify's own player, can play them. It needs Premium. In Settings:", + "soloist_offer_controls": "Enter: open Settings Esc: not now", + "soloist_offer_not_now": "Not now", + "soloist_offer_set_up": "Set up Soloist", + "soloist_offer_step_download": "1. Soloist Path: Download from Spotify.", + "soloist_offer_step_engine": "3. Playback Engine: Soloist.", + "soloist_offer_step_key": "2. Soloist API Key: Get a key, then enter it.", + "soloist_offer_title": "Play through Soloist?", + "soloist_pair_close": "Close", + "soloist_pair_open": "1. Open Spotify on a computer on this network.", + "soloist_pair_pick_template": "2. In its device menu, pick '{name}'.", + "soloist_pair_title": "Pair spotatui with Spotify", + "soloist_pair_waiting": "Waiting for Spotify…", "sort_album": "专辑", "sort_albums": "专辑排序", "sort_artist": "艺人", @@ -3215,6 +3290,8 @@ "behavior_paused_icon_name": "暂停图标", "behavior_pin_community_playlist_description": "将 spotatui 社区播放列表置顶于 Spotify 播放列表", "behavior_pin_community_playlist_name": "置顶社区播放列表", + "behavior_playback_engine_description": "What plays Spotify behind the Native Streaming device. Soloist is Spotify's own player: get it with the Soloist Path button and enter its key in Soloist API Key.", + "behavior_playback_engine_name": "Playback Engine", "behavior_playback_poll_seconds_description": "定期刷新播放状态的间隔秒数", "behavior_playback_poll_seconds_name": "播放状态刷新间隔(秒)", "behavior_playbar_cover_art_size_percent_description": "播放栏封面大小百分比(25–200)", @@ -3245,6 +3322,12 @@ "behavior_small_terminal_height_name": "紧凑边距高度阈值", "behavior_small_terminal_width_description": "终端宽度低于此值时使用紧凑布局", "behavior_small_terminal_width_name": "紧凑布局宽度阈值", + "behavior_soloist_api_key_description": "The key Soloist logs in with, saved in soloist_credentials.yml. Get a key opens Spotify's developer dashboard, where you create one. SPOTATUI_SOLOIST_API_KEY wins over this value. Other local programs can read the key from Soloist's command line.", + "behavior_soloist_api_key_name": "Soloist API Key", + "behavior_soloist_audio_quality_description": "The stream Soloist asks for (applies on restart). Lossless needs Premium in a lossless market; if Soloist still streams lossy, pick spotatui in the Spotify mobile app's device menu and set Lossless.", + "behavior_soloist_audio_quality_name": "Soloist Audio Quality", + "behavior_soloist_path_description": "The Soloist program the Soloist engine runs. Download from Spotify gets the official build for this system from Spotify; by using Soloist, you accept the Spotify Terms and Conditions of Use.", + "behavior_soloist_path_name": "Soloist Path", "behavior_sort_ascending_icon_description": "升序排序使用的单格图标", "behavior_sort_ascending_icon_name": "升序排序图标", "behavior_sort_descending_icon_description": "降序排序使用的单格图标", @@ -3461,6 +3544,9 @@ "option_zh_hant": "繁體中文", "reserved_key_template": "按键 {key} 已保留,不能重新绑定", "save_failed_template": "保存设置失败:{error}", + "soloist_download_button": "Download from Spotify", + "soloist_downloading_button": "Downloading…", + "soloist_get_key_button": "Get a key", "tabs_title": "设置(←/→ 切换页签)", "terminal_input_template": "终端输入:{enhancement} | {ctrl_comma}", "text_controls": "输入编辑 | Enter:确认 | Esc:取消", @@ -3688,6 +3774,9 @@ "dj_status": "DJ:{summary}", "dj_steps_exhausted": "已在 {count} 步后停止。如果结果不符合预期,请再次提问。", "dj_topped_up": "DJ:已补充队列", + "engine_next_launch": "The new playback engine starts at the next launch.", + "engine_switching_librespot": "Switching the playback engine to librespot.", + "engine_switching_soloist": "Switching the playback engine to Soloist.", "fetching": "正在获取 {name}…", "friend_add_failed": "无法添加好友:{error}", "friend_added": "已添加好友", @@ -3865,6 +3954,39 @@ "shuffle_capped": "列表较大:随机播放前 {count} 首曲目", "shuffle_context_failed": "随机播放:无法加载所属列表的曲目", "shuffle_not_applied": "已开始播放,但无法启用随机播放。", + "soloist_bad_key": "Soloist rejected the API key. In Settings, Soloist API Key, enter a new one.", + "soloist_dir_in_use": "Another Soloist uses spotatui's Soloist data folder.", + "soloist_download_failed": "Soloist download failed: {error}", + "soloist_download_started": "Downloading Soloist from Spotify. By using Soloist, you accept the Spotify Terms and Conditions of Use.", + "soloist_downloaded": "Soloist downloaded; starting it.", + "soloist_downloaded_engine_off": "Soloist downloaded. Set Playback Engine to Soloist to play through it.", + "soloist_downloaded_next_launch": "Soloist downloaded. spotatui uses it from the next launch.", + "soloist_downloading": "Downloading Soloist from Spotify: {percent}%", + "soloist_dropped": "Soloist dropped to {quality}.", + "soloist_engine_unavailable": "This build has no Soloist engine; playing through librespot.", + "soloist_expired": "Soloist build expired. In Settings, Soloist Path, pick Download from Spotify for a new one.", + "soloist_expires_soon": "Soloist build expires in {count} {unit}: download a new one soon.", + "soloist_keeps_exiting": "Soloist keeps exiting; see the log.", + "soloist_key_env_wins": "Soloist API key saved, but SPOTATUI_SOLOIST_API_KEY is set and wins over it.", + "soloist_key_missing": "Soloist needs an API key. In Settings, Soloist API Key, pick Get a key, then enter the key (it is saved in {path}).", + "soloist_key_restarting": "Soloist API key saved; restarting Soloist with it.", + "soloist_key_save_failed": "Could not save the Soloist API key: {error}", + "soloist_key_saved": "Soloist API key saved.", + "soloist_lossy_hint": "Soloist streams {quality}. For lossless, pick spotatui in the Spotify mobile app's device menu and set Lossless.", + "soloist_missing": "Soloist not found at {path}. In Settings, Soloist Path, pick Download from Spotify.", + "soloist_no_build": "Spotify has no Soloist build for this system.", + "soloist_no_websocket": "Soloist started without its WebSocket.", + "soloist_not_executable": "Soloist at {path} is not executable: chmod +x {path}", + "soloist_not_paired": "Pair spotatui: open Spotify on a computer on this network and pick 'spotatui'.", + "soloist_offline": "Soloist cannot reach Spotify.", + "soloist_paired": "Paired: spotatui plays through Soloist.", + "soloist_path_unset": "Soloist is not installed. In Settings, Soloist Path, pick Download from Spotify.", + "soloist_queue_refused": "Queueing other sources behind Spotify needs the librespot engine.", + "soloist_restart_quality": "Restarting Soloist to apply the audio quality.", + "soloist_restarting": "Soloist stopped; restarting it.", + "soloist_start_failed": "Soloist could not start: {error}", + "soloist_streams": "Soloist streams {quality}.", + "soloist_wrong_cpu": "This Soloist build does not run on this CPU. In Settings, Soloist Path, pick Download from Spotify.", "source_playback_unavailable": "此版本不支持 {source} 播放", "speaker_client_id_missing": "getInfo 中缺少 clientID", "speaker_login_refused": "音箱拒绝登录:{message}({error})", @@ -4663,6 +4785,7 @@ "search_submit": "搜尋輸入的內容", "seek_backward": "倒轉", "seek_forward": "快轉", + "settings_button": "Press the selected setting's button", "settings_open": "開啟設定", "settings_save": "儲存設定", "show_follow": "追蹤節目", @@ -4838,6 +4961,19 @@ "selected_playlist": "所選播放清單", "selected_track": "所選曲目", "share_code": "分享此房間碼:", + "soloist_offer_body": "Spotify refuses librespot the audio keys for these tracks, as it does for some accounts. Soloist, Spotify's own player, can play them. It needs Premium. In Settings:", + "soloist_offer_controls": "Enter: open Settings Esc: not now", + "soloist_offer_not_now": "Not now", + "soloist_offer_set_up": "Set up Soloist", + "soloist_offer_step_download": "1. Soloist Path: Download from Spotify.", + "soloist_offer_step_engine": "3. Playback Engine: Soloist.", + "soloist_offer_step_key": "2. Soloist API Key: Get a key, then enter it.", + "soloist_offer_title": "Play through Soloist?", + "soloist_pair_close": "Close", + "soloist_pair_open": "1. Open Spotify on a computer on this network.", + "soloist_pair_pick_template": "2. In its device menu, pick '{name}'.", + "soloist_pair_title": "Pair spotatui with Spotify", + "soloist_pair_waiting": "Waiting for Spotify…", "sort_album": "專輯", "sort_albums": "專輯排序", "sort_artist": "藝人", @@ -5226,6 +5362,8 @@ "behavior_paused_icon_name": "暫停圖示", "behavior_pin_community_playlist_description": "將 spotatui 社群播放清單置頂於 Spotify 播放清單", "behavior_pin_community_playlist_name": "置頂社群播放清單", + "behavior_playback_engine_description": "What plays Spotify behind the Native Streaming device. Soloist is Spotify's own player: get it with the Soloist Path button and enter its key in Soloist API Key.", + "behavior_playback_engine_name": "Playback Engine", "behavior_playback_poll_seconds_description": "定期更新播放狀態的間隔秒數", "behavior_playback_poll_seconds_name": "播放狀態更新間隔(秒)", "behavior_playbar_cover_art_size_percent_description": "播放列封面大小百分比(25–200)", @@ -5256,6 +5394,12 @@ "behavior_small_terminal_height_name": "精簡邊距高度門檻", "behavior_small_terminal_width_description": "終端機寬度低於此值時使用精簡版面", "behavior_small_terminal_width_name": "精簡版面寬度門檻", + "behavior_soloist_api_key_description": "The key Soloist logs in with, saved in soloist_credentials.yml. Get a key opens Spotify's developer dashboard, where you create one. SPOTATUI_SOLOIST_API_KEY wins over this value. Other local programs can read the key from Soloist's command line.", + "behavior_soloist_api_key_name": "Soloist API Key", + "behavior_soloist_audio_quality_description": "The stream Soloist asks for (applies on restart). Lossless needs Premium in a lossless market; if Soloist still streams lossy, pick spotatui in the Spotify mobile app's device menu and set Lossless.", + "behavior_soloist_audio_quality_name": "Soloist Audio Quality", + "behavior_soloist_path_description": "The Soloist program the Soloist engine runs. Download from Spotify gets the official build for this system from Spotify; by using Soloist, you accept the Spotify Terms and Conditions of Use.", + "behavior_soloist_path_name": "Soloist Path", "behavior_sort_ascending_icon_description": "遞增排序使用的單格圖示", "behavior_sort_ascending_icon_name": "遞增排序圖示", "behavior_sort_descending_icon_description": "遞減排序使用的單格圖示", @@ -5472,6 +5616,9 @@ "option_zh_hant": "繁體中文", "reserved_key_template": "按鍵 {key} 已保留,不能重新綁定", "save_failed_template": "儲存設定失敗:{error}", + "soloist_download_button": "Download from Spotify", + "soloist_downloading_button": "Downloading…", + "soloist_get_key_button": "Get a key", "tabs_title": "設定(←/→ 切換分頁)", "terminal_input_template": "終端機輸入:{enhancement} | {ctrl_comma}", "text_controls": "輸入編輯 | Enter:確認 | Esc:取消", @@ -5699,6 +5846,9 @@ "dj_status": "DJ:{summary}", "dj_steps_exhausted": "已在 {count} 步後停止。如果結果不符合預期,請再次提問。", "dj_topped_up": "DJ:已補充佇列", + "engine_next_launch": "The new playback engine starts at the next launch.", + "engine_switching_librespot": "Switching the playback engine to librespot.", + "engine_switching_soloist": "Switching the playback engine to Soloist.", "fetching": "正在取得 {name}…", "friend_add_failed": "無法新增好友:{error}", "friend_added": "已新增好友", @@ -5876,6 +6026,39 @@ "shuffle_capped": "清單較大:隨機播放前 {count} 首曲目", "shuffle_context_failed": "隨機播放:無法載入所屬清單的曲目", "shuffle_not_applied": "已開始播放,但無法啟用隨機播放。", + "soloist_bad_key": "Soloist rejected the API key. In Settings, Soloist API Key, enter a new one.", + "soloist_dir_in_use": "Another Soloist uses spotatui's Soloist data folder.", + "soloist_download_failed": "Soloist download failed: {error}", + "soloist_download_started": "Downloading Soloist from Spotify. By using Soloist, you accept the Spotify Terms and Conditions of Use.", + "soloist_downloaded": "Soloist downloaded; starting it.", + "soloist_downloaded_engine_off": "Soloist downloaded. Set Playback Engine to Soloist to play through it.", + "soloist_downloaded_next_launch": "Soloist downloaded. spotatui uses it from the next launch.", + "soloist_downloading": "Downloading Soloist from Spotify: {percent}%", + "soloist_dropped": "Soloist dropped to {quality}.", + "soloist_engine_unavailable": "This build has no Soloist engine; playing through librespot.", + "soloist_expired": "Soloist build expired. In Settings, Soloist Path, pick Download from Spotify for a new one.", + "soloist_expires_soon": "Soloist build expires in {count} {unit}: download a new one soon.", + "soloist_keeps_exiting": "Soloist keeps exiting; see the log.", + "soloist_key_env_wins": "Soloist API key saved, but SPOTATUI_SOLOIST_API_KEY is set and wins over it.", + "soloist_key_missing": "Soloist needs an API key. In Settings, Soloist API Key, pick Get a key, then enter the key (it is saved in {path}).", + "soloist_key_restarting": "Soloist API key saved; restarting Soloist with it.", + "soloist_key_save_failed": "Could not save the Soloist API key: {error}", + "soloist_key_saved": "Soloist API key saved.", + "soloist_lossy_hint": "Soloist streams {quality}. For lossless, pick spotatui in the Spotify mobile app's device menu and set Lossless.", + "soloist_missing": "Soloist not found at {path}. In Settings, Soloist Path, pick Download from Spotify.", + "soloist_no_build": "Spotify has no Soloist build for this system.", + "soloist_no_websocket": "Soloist started without its WebSocket.", + "soloist_not_executable": "Soloist at {path} is not executable: chmod +x {path}", + "soloist_not_paired": "Pair spotatui: open Spotify on a computer on this network and pick 'spotatui'.", + "soloist_offline": "Soloist cannot reach Spotify.", + "soloist_paired": "Paired: spotatui plays through Soloist.", + "soloist_path_unset": "Soloist is not installed. In Settings, Soloist Path, pick Download from Spotify.", + "soloist_queue_refused": "Queueing other sources behind Spotify needs the librespot engine.", + "soloist_restart_quality": "Restarting Soloist to apply the audio quality.", + "soloist_restarting": "Soloist stopped; restarting it.", + "soloist_start_failed": "Soloist could not start: {error}", + "soloist_streams": "Soloist streams {quality}.", + "soloist_wrong_cpu": "This Soloist build does not run on this CPU. In Settings, Soloist Path, pick Download from Spotify.", "source_playback_unavailable": "此版本不支援 {source} 播放", "speaker_client_id_missing": "getInfo 中缺少 clientID", "speaker_login_refused": "喇叭拒絕登入:{message}({error})", diff --git a/gui/src/currentMainLocalization.test.tsx b/gui/src/currentMainLocalization.test.tsx index ab45e84c..39e0e0cd 100644 --- a/gui/src/currentMainLocalization.test.tsx +++ b/gui/src/currentMainLocalization.test.tsx @@ -71,6 +71,7 @@ describe("main additions share the active language table", () => { group: "sources", name: "Subsonic", description: "", + button: null, value: { kind: "secret", set }, }; const html = renderToStaticMarkup( diff --git a/gui/src/localization.test.tsx b/gui/src/localization.test.tsx index 4f33824f..162b21a7 100644 --- a/gui/src/localization.test.tsx +++ b/gui/src/localization.test.tsx @@ -106,6 +106,7 @@ describe("shared browser strings", () => { group: "interface", name: "語言", description: "", + button: null, value: { kind: "choice", value: "en", diff --git a/gui/src/settings.test.tsx b/gui/src/settings.test.tsx index 58fb2715..e0ce15d1 100644 --- a/gui/src/settings.test.tsx +++ b/gui/src/settings.test.tsx @@ -6,6 +6,7 @@ import type { SettingRow } from "./bindings/SettingRow"; import type { UpdateAsk } from "./bindings/UpdateAsk"; import type { UpdateStatus } from "./bindings/UpdateStatus"; import { + RowButton, SettingControl, Settings, SettingsSearch, @@ -19,6 +20,7 @@ const rows: SettingRow[] = [ group: "playback", name: "Seek Duration (ms)", description: "Milliseconds to skip when seeking", + button: null, value: { kind: "number", value: 5000 }, }, { @@ -26,6 +28,7 @@ const rows: SettingRow[] = [ group: "streaming", name: "Streaming Bitrate", description: "Audio quality for native streaming", + button: null, value: { kind: "choice", value: "320", @@ -38,6 +41,7 @@ const rows: SettingRow[] = [ group: "integrations", name: "Discord Rich Presence", description: "Show the playing track on Discord", + button: null, value: { kind: "bool", value: true }, }, { @@ -45,6 +49,7 @@ const rows: SettingRow[] = [ group: "updates", name: "Auto-update delay", description: "How long a release must be out before it downloads", + button: null, value: { kind: "text", value: "0" }, }, ]; @@ -110,6 +115,7 @@ describe("Settings", () => { group: "library", name: "Default Playlist Sort", description: "How a playlist's tracks are sorted when it opens", + button: null, value: { kind: "choice", value: "name:desc", @@ -134,7 +140,14 @@ describe("Settings", () => { id: string, name: string, value: SettingRow["value"], - ): SettingRow => ({ id, group: "sources", name, description: "", value }); + ): SettingRow => ({ + id, + group: "sources", + name, + description: "", + value, + button: null, + }); const shown = [ rows[0], source("behavior.subsonic_url", "Subsonic Server URL", { @@ -180,6 +193,7 @@ describe("Settings", () => { group: "sources", name: "Subsonic Password", description: "", + button: null, value: { kind: "secret", set: false }, }; const actions = sent(password, (control) => { @@ -196,6 +210,33 @@ describe("Settings", () => { ]); }); + it("a row button sends its press, and a busy one is off", () => { + const soloist = (busy: boolean): SettingRow => ({ + id: "behavior.soloist_path", + group: "streaming", + name: "Soloist Path", + description: "", + button: { label: "Download from Spotify", busy }, + value: { kind: "text", value: "" }, + }); + const actions: Action[] = []; + const button = byName( + actions.push(action)} + />, + "Download from Spotify", + ); + (button.props.onClick as () => void)(); + expect(actions).toEqual([ + { PressSettingButton: { id: "behavior.soloist_path" } }, + ]); + expect(page(null, [soloist(true)])).toMatch( + /]*disabled=""[^>]*>Download from Spotify { const html = page(null, rows.slice(0, 3)); expect(html).not.toContain(">Updates"); @@ -317,6 +358,7 @@ describe("Settings", () => { group: "library", name: "Default Playlist Sort", description: "", + button: null, value: { kind: "choice", value: "default", diff --git a/gui/src/settingsModel.test.ts b/gui/src/settingsModel.test.ts index b8673c68..a2567846 100644 --- a/gui/src/settingsModel.test.ts +++ b/gui/src/settingsModel.test.ts @@ -15,7 +15,14 @@ const row = ( id: string, group: string, value: SettingRow["value"], -): SettingRow => ({ id, group, name: id, description: "", value }); +): SettingRow => ({ + id, + group, + name: id, + description: "", + value, + button: null, +}); const seek = row("behavior.seek_milliseconds", "playback", { kind: "number", @@ -97,6 +104,7 @@ describe("sections with a query", () => { group, name, description, + button: null, value: { kind: "bool", value: true }, }); const all = [ diff --git a/gui/src/soloistOfferDialog.test.tsx b/gui/src/soloistOfferDialog.test.tsx new file mode 100644 index 00000000..fe7b9b9d --- /dev/null +++ b/gui/src/soloistOfferDialog.test.tsx @@ -0,0 +1,68 @@ +import { renderToStaticMarkup } from "react-dom/server"; +import type { KeyboardEvent } from "react"; +import { describe, expect, it } from "vitest"; +import type { Action } from "./bindings/Action"; +import type { SoloistOffer } from "./bindings/SoloistOffer"; +import { SoloistOfferModal } from "./SoloistOfferDialog"; +import { byName, hostElements } from "./testTree"; + +const offer: SoloistOffer = { + title: "Play through Soloist?", + body: "Soloist can play them. In Settings:", + steps: [ + "1. Soloist Path: Download from Spotify.", + "2. Soloist API Key: Get a key, then enter it.", + "3. Playback Engine: Soloist.", + ], + set_up: "Set up Soloist", + not_now: "Not now", +}; + +const dialog = ( + value: SoloistOffer | null, + sent: Action[] = [], + opened: string[] = [], +) => ( + sent.push(action)} + onSetUp={() => opened.push("settings")} + /> +); + +describe("SoloistOfferDialog", () => { + it("renders nothing without an offer", () => { + expect(renderToStaticMarkup(dialog(null))).toBe(""); + }); + + it("lists the setup steps in a labelled modal", () => { + const html = renderToStaticMarkup(dialog(offer)); + expect(html).toContain('aria-labelledby="soloist-offer-title"'); + expect(html).toContain("2. Soloist API Key: Get a key, then enter it."); + }); + + it("opens Settings on Set up, and only answers on Not now and Escape", () => { + const sent: Action[] = []; + const opened: string[] = []; + const tree = dialog(offer, sent, opened); + (byName(tree, "Set up Soloist").props.onClick as () => void)(); + (byName(tree, "Not now").props.onClick as () => void)(); + const modal = hostElements(tree).find( + (element) => element.props.role === "dialog", + )!; + const escape = { + key: "Escape", + preventDefault: () => {}, + stopPropagation: () => {}, + } as unknown as KeyboardEvent; + (modal.props.onKeyDown as (event: KeyboardEvent) => void)( + escape, + ); + expect(sent).toEqual([ + { AnswerSoloistOffer: { set_up: true } }, + { AnswerSoloistOffer: { set_up: false } }, + { AnswerSoloistOffer: { set_up: false } }, + ]); + expect(opened).toEqual(["settings"]); + }); +}); diff --git a/gui/src/soloistPairingDialog.test.tsx b/gui/src/soloistPairingDialog.test.tsx new file mode 100644 index 00000000..042bd5af --- /dev/null +++ b/gui/src/soloistPairingDialog.test.tsx @@ -0,0 +1,52 @@ +import { renderToStaticMarkup } from "react-dom/server"; +import type { KeyboardEvent } from "react"; +import { describe, expect, it } from "vitest"; +import type { Action } from "./bindings/Action"; +import type { SoloistPairingGuide } from "./bindings/SoloistPairingGuide"; +import { SoloistPairingModal } from "./SoloistPairingDialog"; +import { byName, hostElements } from "./testTree"; + +const guide: SoloistPairingGuide = { + title: "Pair spotatui with Spotify", + steps: [ + "1. Open Spotify on a computer on this network.", + "2. In its device menu, pick 'spotatui'.", + ], + waiting: "Waiting for Spotify…", + close: "Close", +}; + +const dialog = (value: SoloistPairingGuide | null, sent: Action[] = []) => ( + sent.push(action)} /> +); + +describe("SoloistPairingDialog", () => { + it("renders nothing while Soloist needs no pairing", () => { + expect(renderToStaticMarkup(dialog(null))).toBe(""); + }); + + it("shows the steps and the live wait in a labelled modal", () => { + const html = renderToStaticMarkup(dialog(guide)); + expect(html).toContain('aria-labelledby="soloist-pairing-title"'); + expect(html).toContain("pick 'spotatui'"); + expect(html).toContain('

Waiting for Spotify…

'); + }); + + it("closes with Close and with Escape", () => { + const sent: Action[] = []; + const tree = dialog(guide, sent); + (byName(tree, "Close").props.onClick as () => void)(); + const modal = hostElements(tree).find( + (element) => element.props.role === "dialog", + )!; + const escape = { + key: "Escape", + preventDefault: () => {}, + stopPropagation: () => {}, + } as unknown as KeyboardEvent; + (modal.props.onKeyDown as (event: KeyboardEvent) => void)( + escape, + ); + expect(sent).toEqual(["DismissSoloistPairing", "DismissSoloistPairing"]); + }); +}); diff --git a/gui/src/toast.test.tsx b/gui/src/toast.test.tsx index 980a5aaf..27ac87ba 100644 --- a/gui/src/toast.test.tsx +++ b/gui/src/toast.test.tsx @@ -12,6 +12,8 @@ describe("Toast", () => { message: "Added to queue", is_error: false, api_error: "Spotify refused the request", + soloist_pairing: null, + soloist_offer: null, }); expect(html).toContain( '', @@ -22,7 +24,13 @@ describe("Toast", () => { it("marks an error status message and keeps empty live regions without one", () => { expect( - toast({ message: "No device", is_error: true, api_error: null }), + toast({ + message: "No device", + is_error: true, + api_error: null, + soloist_pairing: null, + soloist_offer: null, + }), ).toContain('
No device'); const empty = toast(null); expect(empty).toContain('role="status">
'); diff --git a/gui/src/toastModel.test.ts b/gui/src/toastModel.test.ts index f2bc8963..c4f28aa5 100644 --- a/gui/src/toastModel.test.ts +++ b/gui/src/toastModel.test.ts @@ -5,7 +5,13 @@ import { toastView } from "./toastModel"; const status = ( api_error: string | null, message: string | null = null, -): StatusPayload => ({ api_error, message, is_error: message !== null }); +): StatusPayload => ({ + api_error, + message, + is_error: message !== null, + soloist_pairing: null, + soloist_offer: null, +}); describe("toastView", () => { it("keeps a dismissed error hidden through the handoff, then shows it again", () => { diff --git a/src/core/action/apply.rs b/src/core/action/apply.rs index 983c69d8..9f7e9971 100644 --- a/src/core/action/apply.rs +++ b/src/core/action/apply.rs @@ -282,8 +282,11 @@ impl App { saved: self.save_settings_from_items(), }; } + Action::PressSettingButton { id } => self.press_setting_button(&id), Action::CycleVisualizerStyle => self.cycle_visualizer_style(), Action::AnswerCommunityPinPrompt { keep } => self.answer_community_pin_prompt(keep), + Action::DismissSoloistPairing => self.dismiss_soloist_pairing(), + Action::AnswerSoloistOffer { set_up } => self.answer_soloist_offer(set_up), Action::OpenRecap => self.open_recap(), Action::DismissRecapPrompt => self.dismiss_recap_prompt(), Action::DisableRecapPrompt => self.disable_recap_prompt(), diff --git a/src/core/action/mod.rs b/src/core/action/mod.rs index 0e30f0fb..1bb1ef27 100644 --- a/src/core/action/mod.rs +++ b/src/core/action/mod.rs @@ -345,6 +345,11 @@ pub enum Action { id: String, value: SettingInput, }, + /// Press the button of one settings row (Soloist Path: download from + /// Spotify). A row without a button changes nothing. + PressSettingButton { + id: String, + }, /// Cycle the visualizer style and persist it. CycleVisualizerStyle, /// Answer the community-playlist pin prompt: keep the pin, or hide it and @@ -352,6 +357,12 @@ pub enum Action { AnswerCommunityPinPrompt { keep: bool, }, + /// Close the Soloist pairing guide; pairing in Spotify still works after. + DismissSoloistPairing, + /// Answer the offer to set up Soloist: open Settings at the Playback Engine row, or close it. + AnswerSoloistOffer { + set_up: bool, + }, /// Open the monthly recap in the browser and close its popup. OpenRecap, /// Close the recap popup without opening the recap. diff --git a/src/core/action/tests.rs b/src/core/action/tests.rs index 95f055bf..8fd1c37d 100644 --- a/src/core/action/tests.rs +++ b/src/core/action/tests.rs @@ -273,6 +273,28 @@ fn toggle_shuffle_flips_the_spotify_shuffle_state() { assert!(matches!(rx.try_recv(), Ok(IoEvent::Shuffle(true)))); } +#[test] +fn cycle_repeat_on_a_remote_device_dispatches_the_next_state() { + use rspotify::model::RepeatState; + for (current, next) in [ + (RepeatState::Off, RepeatState::Context), + (RepeatState::Context, RepeatState::Track), + (RepeatState::Track, RepeatState::Off), + ] { + let (mut app, rx) = app_with_channel(); + let mut ctx = playback_context(true, false); + ctx.repeat_state = current; + app.current_playback_context = Some(ctx); + + app.apply(Action::CycleRepeat); + + match rx.try_recv() { + Ok(IoEvent::Repeat(state)) => assert_eq!(state, next), + _other => panic!("expected Repeat (IoEvent is not Debug)"), + } + } +} + // --- transport without a Spotify session --- #[test] diff --git a/src/core/app/construction.rs b/src/core/app/construction.rs index 8abe2b4f..5c1571b5 100644 --- a/src/core/app/construction.rs +++ b/src/core/app/construction.rs @@ -194,6 +194,16 @@ impl Default for App { last_dispatched_volume: None, #[cfg(feature = "streaming")] streaming_player: None, + #[cfg(feature = "soloist")] + soloist_link: None, + #[cfg(feature = "soloist")] + soloist_downloading: false, + #[cfg(feature = "soloist")] + librespot_standby: None, + soloist_pairing: false, + soloist_offer: false, + #[cfg(feature = "soloist")] + soloist_offered: false, decoded_repeat: RepeatMode::Off, decoded_shuffle: false, #[cfg(feature = "local-files")] diff --git a/src/core/app/mod.rs b/src/core/app/mod.rs index ec80c3db..fdcd3b16 100644 --- a/src/core/app/mod.rs +++ b/src/core/app/mod.rs @@ -129,6 +129,8 @@ mod session; mod settings_apply; mod settings_schema; mod shuffle_repeat; +#[cfg(feature = "soloist")] +mod soloist; mod status; mod tick; mod transport; @@ -563,6 +565,23 @@ pub struct App { /// Reference to the native streaming player for direct control (bypasses event channel) #[cfg(feature = "streaming")] pub streaming_player: Option>, + /// The running Soloist engine; `streaming_player` stays `None` while it is set. + #[cfg(feature = "soloist")] + soloist_link: Option>, + /// A Soloist download from Spotify runs. + #[cfg(feature = "soloist")] + soloist_downloading: bool, + /// The librespot recovery sender while Soloist is the engine, for a switch back. + #[cfg(feature = "soloist")] + librespot_standby: + Option>, + /// The Soloist pairing guide shows: Soloist waits for its first login. + soloist_pairing: bool, + /// The offer to set up Soloist shows, after librespot failed several tracks in a row. + soloist_offer: bool, + /// That offer was made in this run; it shows once. + #[cfg(feature = "soloist")] + soloist_offered: bool, /// Player-global repeat mode for the decoded (non-Spotify) sources /// (Local / Subsonic / YouTube). Decoded sources have no Spotify /// `current_playback_context`, so their repeat lives here instead of in diff --git a/src/core/app/native_backend.rs b/src/core/app/native_backend.rs index 16670264..35ed688f 100644 --- a/src/core/app/native_backend.rs +++ b/src/core/app/native_backend.rs @@ -112,7 +112,8 @@ impl App { /// TCP: `is_connected` true, Spirc commands silently dropped). #[cfg(feature = "streaming")] pub fn force_native_streaming_recovery(&mut self, reselect_device: bool) { - if !self.native_should_drive() { + // A librespot recovery would clear Soloist's device id. + if !self.native_should_drive() || self.soloist_engine_installed() { return; } let position_ms = u32::try_from(self.song_progress_ms).unwrap_or(u32::MAX); @@ -230,6 +231,12 @@ impl App { /// another owner of the sink. An install ends the park. #[cfg(feature = "streaming")] pub(crate) fn accept_rebuilt_native_backend(&mut self) -> bool { + // A rebuild that started before a switch to Soloist must not register a second device. + #[cfg(feature = "soloist")] + if self.soloist_engine_selected() { + self.native_backend_pending = false; + return false; + } // A rebuild may have started before Music took over. Park it, so an // explicit Spotify start later reacquires it instead of it resuming now. if self.apple_music_owns_playback() { diff --git a/src/core/app/playback_routing.rs b/src/core/app/playback_routing.rs index 52521719..de42f83c 100644 --- a/src/core/app/playback_routing.rs +++ b/src/core/app/playback_routing.rs @@ -358,7 +358,8 @@ impl App { pub fn spotify_external_device_active(&self) -> bool { #[cfg(feature = "streaming")] { - self.current_playback_context.is_some() + // Soloist keeps its own queue: a queued Spotify track goes to it, never to librespot. + (self.current_playback_context.is_some() || self.soloist_engine_installed()) && !self.is_native_streaming_active_for_playback() && !self.native_parked_owns_context() } @@ -368,6 +369,148 @@ impl App { } } + /// Whether the Soloist engine runs in place of librespot. + #[cfg(feature = "streaming")] + pub fn soloist_engine_installed(&self) -> bool { + #[cfg(feature = "soloist")] + { + self.soloist_link.is_some() + } + #[cfg(not(feature = "soloist"))] + { + false + } + } + + /// The device name to pick in Spotify while the Soloist pairing guide shows. + pub fn soloist_pairing_device(&self) -> Option<&str> { + self + .soloist_pairing + .then_some(self.user_config.behavior.streaming_device_name.as_str()) + } + + pub(crate) fn dismiss_soloist_pairing(&mut self) { + self.set_soloist_pairing(false); + } + + pub(super) fn set_soloist_pairing(&mut self, shown: bool) { + if self.soloist_pairing != shown { + self.soloist_pairing = shown; + self.display_revisions.bump(DisplayDomain::Status); + } + } + + /// Whether the offer to set up Soloist shows. + pub fn soloist_offer_shows(&self) -> bool { + self.soloist_offer + } + + /// Offer Soloist once per run, when this build can run it and librespot is the engine. + #[cfg(feature = "streaming")] + pub(crate) fn offer_soloist_setup(&mut self) { + #[cfg(feature = "soloist")] + if !self.soloist_offered + && !self.soloist_engine_selected() + && !cfg!(all(target_os = "macos", target_arch = "x86_64")) + { + self.soloist_offered = true; + self.soloist_offer = true; + self.display_revisions.bump(DisplayDomain::Status); + } + } + + pub(crate) fn answer_soloist_offer(&mut self, set_up: bool) { + if !self.soloist_offer { + return; + } + self.soloist_offer = false; + self.display_revisions.bump(DisplayDomain::Status); + if set_up { + self.view.settings_category = SettingsCategory::Behavior; + self.open_settings_screen(); + if let Some(index) = self + .settings_items + .iter() + .position(|item| item.id == "behavior.playback_engine") + { + self.view.settings_selected_index = index; + } + } + } + + /// Whether a Spotify command goes to Soloist: its device is the active one, + /// or no device is and Soloist is logged in. + pub fn soloist_is_target(&self) -> bool { + #[cfg(feature = "soloist")] + { + let Some(link) = self.soloist_link.as_ref() else { + return false; + }; + self.playback_owner() == PlaybackOwner::Spotify + && soloist_targets( + link.snapshot().logged_in, + self + .current_playback_context + .as_ref() + .and_then(|context| context.device.id.as_deref()), + link.device_id(), + ) + } + #[cfg(not(feature = "soloist"))] + { + false + } + } + + /// Write Soloist's live state over the polled context when the context is Soloist's. + #[cfg(feature = "soloist")] + pub(crate) fn overlay_soloist_onto(&self, context: &mut rspotify::model::CurrentPlaybackContext) { + let Some(link) = self.soloist_link.as_ref() else { + return; + }; + if context.device.id.as_deref() != Some(link.device_id()) { + return; + } + overlay_soloist( + context, + &link.snapshot(), + chrono::Utc::now().timestamp_millis(), + ); + } + + /// Apply the overlay to the stored context and restart the tick's position clock from it. + #[cfg(feature = "soloist")] + pub(super) fn overlay_soloist_snapshot(&mut self) { + let Some(mut context) = self.current_playback_context.take() else { + return; + }; + self.overlay_soloist_onto(&mut context); + if let Some(progress) = context.progress { + self.song_progress_ms = progress.num_milliseconds().max(0) as u128; + self.instant_since_last_current_playback_poll = Instant::now(); + } + self.current_playback_context = Some(context); + } + + /// The stream Soloist reports, worded like the Qobuz badge, while it plays for us. + pub(crate) fn soloist_quality_label(&self) -> Option { + #[cfg(feature = "soloist")] + { + if !self.soloist_is_target() { + return None; + } + self + .soloist_link + .as_ref() + .and_then(|link| link.quality()) + .map(|quality| quality.label()) + } + #[cfg(not(feature = "soloist"))] + { + None + } + } + /// Whether any decoded-audio source (local file, Subsonic, internet radio, or /// YouTube) currently owns the playback session. /// @@ -679,11 +822,14 @@ impl App { .current_playback_context .as_ref() .map(|ctx| ctx.device.name.clone()); - let quality = snapshot + let item_uri = snapshot .as_ref() - .and_then(|snapshot| snapshot.item_uri.as_deref()) - .filter(|uri| uri.starts_with("qobuz:")) - .and_then(|_| self.qobuz_quality_label()); + .and_then(|snapshot| snapshot.item_uri.as_deref()); + let quality = match item_uri { + Some(uri) if uri.starts_with("qobuz:") => self.qobuz_quality_label(), + Some(uri) if uri.starts_with("spotify:") => self.soloist_quality_label(), + _ => None, + }; let context_name = self.playback_context_name(snapshot.as_ref()); let view = ( snapshot, @@ -746,6 +892,42 @@ pub(crate) fn context_display_name( } } +/// By id only: another device can carry the name "spotatui". +#[cfg(feature = "soloist")] +fn soloist_targets(logged_in: bool, active_device_id: Option<&str>, soloist_id: &str) -> bool { + match active_device_id { + Some(id) => id == soloist_id, + None => logged_in, + } +} + +#[cfg(feature = "soloist")] +fn overlay_soloist( + context: &mut rspotify::model::CurrentPlaybackContext, + snapshot: &crate::infra::soloist::link::Snapshot, + now_ms: i64, +) { + use crate::infra::soloist::protocol::Repeat; + context.is_playing = snapshot.status.is_playing(); + if let Some(anchor) = snapshot.position { + let position = if context.is_playing { + anchor.at(now_ms) + } else { + anchor.position_ms + }; + context.progress = Some(chrono::Duration::milliseconds(position as i64)); + } + if let Some(volume) = snapshot.volume { + context.device.volume_percent = Some(u32::from(volume)); + } + context.shuffle_state = snapshot.shuffle; + context.repeat_state = match snapshot.repeat { + Repeat::Off => rspotify::model::RepeatState::Off, + Repeat::Context => rspotify::model::RepeatState::Context, + Repeat::Track => rspotify::model::RepeatState::Track, + }; +} + #[cfg(test)] mod tests { use super::*; @@ -1185,3 +1367,108 @@ mod tests { assert!(!app.native_context_should_drive()); } } + +#[cfg(all(test, feature = "soloist"))] +mod soloist_tests { + use super::*; + use crate::core::app::test_support::*; + use crate::infra::soloist::protocol::{Event, PlaybackState, PositionAnchor, Repeat, Status}; + use crate::infra::soloist::SoloistLink; + + const SOLOIST_ID: &str = "soloist-device"; + + fn app_with_soloist() -> (App, Arc) { + let mut app = make_app_simple(); + let (link, _rx) = SoloistLink::test_pair(SOLOIST_ID); + app.install_soloist_link(link.clone()); + (app, link) + } + + fn soloist_context() -> rspotify::model::CurrentPlaybackContext { + let mut context = make_external_context(); + context.device.id = Some(SOLOIST_ID.to_string()); + context.device.name = "spotatui".to_string(); + context.is_playing = false; + context + } + + fn playing_state() -> Event { + Event::State(Box::new(PlaybackState { + status: Status::Playing, + item_uri: Some("spotify:track:x".to_string()), + item_is_episode: false, + context_uri: None, + position: Some(PositionAnchor { + position_ms: 5_000, + timestamp_ms: chrono::Utc::now().timestamp_millis(), + speed: 1.0, + }), + volume: Some(30), + is_active: true, + shuffle: true, + repeat: Repeat::Track, + })) + } + + #[test] + fn soloist_targets_matches_by_id_not_name() { + assert!(soloist_targets(false, Some(SOLOIST_ID), SOLOIST_ID)); + assert!(!soloist_targets(true, Some("phone"), SOLOIST_ID)); + assert!(soloist_targets(true, None, SOLOIST_ID)); + assert!(!soloist_targets(false, None, SOLOIST_ID)); + } + + #[test] + fn soloist_is_the_target_when_no_device_is_active() { + let (mut app, link) = app_with_soloist(); + assert!(!app.soloist_is_target(), "not logged in yet"); + app.apply_soloist_event( + &link, + &Event::Auth { + logged_in: true, + is_active: false, + }, + ); + assert!(app.soloist_is_target()); + app.current_playback_context = Some(make_external_context()); + assert!(!app.soloist_is_target(), "another device plays"); + } + + #[test] + fn overlay_writes_the_websocket_state_onto_soloists_context() { + let (mut app, link) = app_with_soloist(); + app.current_playback_context = Some(soloist_context()); + app.apply_soloist_event(&link, &playing_state()); + + let context = app.current_playback_context.as_ref().unwrap(); + assert!(context.is_playing); + assert!(context.shuffle_state); + assert_eq!(context.repeat_state, rspotify::model::RepeatState::Track); + assert_eq!(context.device.volume_percent, Some(30)); + assert!(context.progress.unwrap().num_milliseconds() >= 5_000); + assert!(app.song_progress_ms >= 5_000); + } + + #[test] + fn overlay_ignores_another_device() { + let (mut app, link) = app_with_soloist(); + app.current_playback_context = Some(make_external_context()); + app.apply_soloist_event(&link, &playing_state()); + + let context = app.current_playback_context.as_ref().unwrap(); + assert!(!context.shuffle_state); + assert_eq!(context.device.volume_percent, Some(50)); + } + + #[test] + fn poll_overlay_keeps_websocket_state_over_a_lagging_cluster() { + let (mut app, link) = app_with_soloist(); + app.current_playback_context = Some(soloist_context()); + app.apply_soloist_event(&link, &playing_state()); + + let mut polled = soloist_context(); + app.overlay_soloist_onto(&mut polled); + assert!(polled.is_playing); + assert_eq!(polled.device.volume_percent, Some(30)); + } +} diff --git a/src/core/app/queue.rs b/src/core/app/queue.rs index e598a05d..ff478837 100644 --- a/src/core/app/queue.rs +++ b/src/core/app/queue.rs @@ -57,6 +57,11 @@ impl App { self.dispatch(IoEvent::AddItemToQueue(uri)); return; } + // Soloist reports no track end, so nothing would take over after its current track. + if self.soloist_is_target() { + self.set_status_message(self.strings().status.infra.soloist_queue_refused, 4); + return; + } let name = track.display_name(self.strings()).to_owned(); self.native_queue.push(track); info!( diff --git a/src/core/app/settings_apply.rs b/src/core/app/settings_apply.rs index 2fa0e966..1d06f837 100644 --- a/src/core/app/settings_apply.rs +++ b/src/core/app/settings_apply.rs @@ -111,6 +111,8 @@ impl App { let mut settings_error: Option = None; let sources_before = self.source_settings(); + #[cfg(feature = "soloist")] + let engine_before = self.user_config.behavior.playback_engine; // What is actually on screen right now, put back by the reconciliation at // the end so an adaptive-theme fade continues from it. let theme_before = self.user_config.theme; @@ -327,6 +329,26 @@ impl App { self.user_config.behavior.streaming_autoplay = *v; } } + #[cfg(feature = "soloist")] + "behavior.playback_engine" => { + if let SettingValue::Cycle(v, _) = &setting.value { + self.user_config.behavior.playback_engine = + crate::core::user_config::PlaybackEngine::from_name(v); + } + } + #[cfg(feature = "soloist")] + "behavior.soloist_path" => { + if let SettingValue::String(v) = &setting.value { + self.user_config.behavior.soloist_path = trimmed(v); + } + } + #[cfg(feature = "soloist")] + "behavior.soloist_audio_quality" => { + if let SettingValue::Cycle(v, _) = &setting.value { + self.user_config.behavior.soloist_audio_quality = + crate::core::user_config::SoloistQuality::from_name(v); + } + } "behavior.enable_announcements" => { if let SettingValue::Bool(v) = &setting.value { self.user_config.behavior.enable_announcements = *v; @@ -957,6 +979,10 @@ impl App { _ => {} } } + #[cfg(feature = "soloist")] + if !self.save_soloist_key_row() && self.user_config.behavior.playback_engine != engine_before { + self.playback_engine_changed(); + } self.refresh_language(); self.refresh_settings_labels(); #[cfg(feature = "art-decode")] @@ -1038,7 +1064,12 @@ impl App { } /// A text setting without its padding; empty means unset. -#[cfg(any(feature = "subsonic", feature = "local-files", feature = "youtube"))] +#[cfg(any( + feature = "subsonic", + feature = "local-files", + feature = "youtube", + feature = "soloist" +))] fn trimmed(value: &str) -> Option { Some(value.trim().to_string()).filter(|value| !value.is_empty()) } @@ -1048,15 +1079,6 @@ mod tests { use super::*; use crate::core::app::test_support::*; - fn setting(id: &str, value: SettingValue) -> SettingItem { - SettingItem { - id: id.to_string(), - name: String::new(), - description: String::new(), - value, - } - } - #[test] fn saving_a_theme_preset_bumps_the_theme_revision_and_an_unrelated_save_does_not() { let mut app = make_app_simple(); diff --git a/src/core/app/settings_schema.rs b/src/core/app/settings_schema.rs index 65ab81ab..b81eb28f 100644 --- a/src/core/app/settings_schema.rs +++ b/src/core/app/settings_schema.rs @@ -110,6 +110,10 @@ pub(crate) fn setting_requirement(id: &str) -> Requirement { | "behavior.streaming_bitrate" | "behavior.streaming_audio_cache" | "behavior.streaming_autoplay" + | "behavior.playback_engine" + | "behavior.soloist_path" + | "behavior.soloist_api_key" + | "behavior.soloist_audio_quality" | "keys.jump_to_album" | "keys.jump_to_artist_album" | "keys.jump_to_context" @@ -126,6 +130,59 @@ pub(crate) fn setting_requirement(id: &str) -> Requirement { } } +/// The one button a settings row can carry, beside its value. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum SettingButton { + DownloadSoloist, + GetSoloistKey, +} + +pub(crate) fn setting_button(id: &str) -> Option { + match id { + "behavior.soloist_path" if cfg!(feature = "soloist") => Some(SettingButton::DownloadSoloist), + "behavior.soloist_api_key" if cfg!(feature = "soloist") => Some(SettingButton::GetSoloistKey), + _ => None, + } +} + +impl App { + /// A row's button label, and whether its work runs (the button is then off). + pub(crate) fn setting_button_label(&self, id: &str) -> Option<(&'static str, bool)> { + let strings = &self.strings().settings; + setting_button(id).map(|button| match button { + SettingButton::DownloadSoloist if self.soloist_download_runs() => { + (strings.soloist_downloading_button, true) + } + SettingButton::DownloadSoloist => (strings.soloist_download_button, false), + SettingButton::GetSoloistKey => (strings.soloist_get_key_button, false), + }) + } + + pub(crate) fn press_setting_button(&mut self, id: &str) { + match setting_button(id) { + Some(SettingButton::DownloadSoloist) => { + #[cfg(feature = "soloist")] + self.download_soloist(); + } + Some(SettingButton::GetSoloistKey) => { + self.open_external_url("https://developer.spotify.com/dashboard/soloist"); + } + None => {} + } + } + + fn soloist_download_runs(&self) -> bool { + #[cfg(feature = "soloist")] + { + self.soloist_downloading + } + #[cfg(not(feature = "soloist"))] + { + false + } + } +} + const PLAYLIST_TRACK_SORT_SETTING_OPTIONS: &[&str] = &[ "default", "name", @@ -483,6 +540,67 @@ impl App { description: strings.behavior_streaming_autoplay_description.to_string(), value: SettingValue::Bool(self.user_config.behavior.streaming_autoplay), }, + // Soloist has no build for Intel Macs. + #[cfg(all( + feature = "soloist", + not(all(target_os = "macos", target_arch = "x86_64")) + ))] + SettingItem { + id: "behavior.playback_engine".to_string(), + name: strings.behavior_playback_engine_name.to_string(), + description: strings.behavior_playback_engine_description.to_string(), + value: SettingValue::Cycle( + self.user_config.behavior.playback_engine.name().to_string(), + crate::core::user_config::PlaybackEngine::options(), + ), + }, + #[cfg(all( + feature = "soloist", + not(all(target_os = "macos", target_arch = "x86_64")) + ))] + SettingItem { + id: "behavior.soloist_path".to_string(), + name: strings.behavior_soloist_path_name.to_string(), + description: strings.behavior_soloist_path_description.to_string(), + value: SettingValue::String( + self + .user_config + .behavior + .soloist_path + .clone() + .unwrap_or_default(), + ), + }, + #[cfg(all( + feature = "soloist", + not(all(target_os = "macos", target_arch = "x86_64")) + ))] + SettingItem { + id: "behavior.soloist_api_key".to_string(), + name: strings.behavior_soloist_api_key_name.to_string(), + description: strings.behavior_soloist_api_key_description.to_string(), + value: SettingValue::Secret(self.saved_soloist_key()), + }, + #[cfg(all( + feature = "soloist", + not(all(target_os = "macos", target_arch = "x86_64")) + ))] + SettingItem { + id: "behavior.soloist_audio_quality".to_string(), + name: strings.behavior_soloist_audio_quality_name.to_string(), + description: strings + .behavior_soloist_audio_quality_description + .to_string(), + value: SettingValue::Cycle( + self + .user_config + .behavior + .soloist_audio_quality + .name() + .to_string(), + crate::core::user_config::SoloistQuality::options(), + ), + }, SettingItem { id: "behavior.startup_behavior".to_string(), name: strings.behavior_startup_behavior_name.to_string(), diff --git a/src/core/app/shuffle_repeat.rs b/src/core/app/shuffle_repeat.rs index 25708e14..7dff1d6f 100644 --- a/src/core/app/shuffle_repeat.rs +++ b/src/core/app/shuffle_repeat.rs @@ -251,6 +251,12 @@ impl App { .map(|context| context.repeat_state) { info!("toggling repeat mode: {:?}", current_repeat_state); + use rspotify::model::enums::RepeatState as Rs; + let next_repeat_state = match current_repeat_state { + Rs::Off => Rs::Context, + Rs::Context => Rs::Track, + Rs::Track => Rs::Off, + }; // Use native streaming player for instant control (bypasses event channel latency) #[cfg(feature = "streaming")] @@ -258,13 +264,6 @@ impl App { if let Some(player) = self.streaming_player.clone() { // Try to set repeat on the native player (pass current state, not next) let _ = player.set_repeat(current_repeat_state); - - // Calculate next state for UI update - let next_repeat_state = match current_repeat_state { - RepeatState::Off => RepeatState::Context, - RepeatState::Context => RepeatState::Track, - RepeatState::Track => RepeatState::Off, - }; self.set_native_recovery_repeat(next_repeat_state); // Update UI state immediately @@ -288,7 +287,7 @@ impl App { } // Fallback to API-based repeat for external devices - self.dispatch_spotify_fallback(IoEvent::Repeat(current_repeat_state)); + self.dispatch_spotify_fallback(IoEvent::Repeat(next_repeat_state)); } } } diff --git a/src/core/app/soloist.rs b/src/core/app/soloist.rs new file mode 100644 index 00000000..eeeae522 --- /dev/null +++ b/src/core/app/soloist.rs @@ -0,0 +1,695 @@ +//! The Soloist engine's state on the App: the link, its events, the notices. + +use super::*; + +impl App { + pub(crate) fn soloist_link(&self) -> Option> { + self.soloist_link.clone() + } + + pub(crate) fn is_current_soloist(&self, link: &Arc) -> bool { + self + .soloist_link + .as_ref() + .is_some_and(|current| Arc::ptr_eq(current, link)) + } + + /// Starts wait for the engine, as they wait for librespot's deferred start. + pub(crate) fn soloist_starting(&mut self) { + self.native_backend_pending = true; + } + + /// The engine gave up: starts stop waiting, and a parked one goes out now. + pub(crate) fn soloist_failed(&mut self, notice: Option) { + if let Some(notice) = notice { + self.soloist_notice(notice); + } + self.native_backend_pending = false; + self.set_soloist_pairing(false); + self.replay_pending_start_playback(); + } + + pub(crate) fn install_soloist_link(&mut self, link: Arc) { + self.native_device_id = Some(link.device_id().to_string()); + self.soloist_link = Some(link); + self.native_backend_pending = false; + } + + pub(crate) fn uninstall_soloist_link(&mut self, link: &Arc) { + if self.is_current_soloist(link) { + self.soloist_link = None; + self.display_revisions.bump(DisplayDomain::Playback); + } + } + + /// The first login of this launch: the configured startup play or pause, then a parked start. + pub(crate) fn soloist_logged_in( + &mut self, + behavior: Option, + shuffle: bool, + ) { + self.run_spotify_startup_behavior(behavior, shuffle); + self.replay_pending_start_playback(); + } + + pub(crate) fn apply_soloist_event( + &mut self, + link: &Arc, + event: &crate::infra::soloist::protocol::Event, + ) { + use crate::infra::soloist::protocol::Event; + if !self.is_current_soloist(link) { + return; + } + link.apply(event); + let snapshot = link.snapshot(); + if snapshot.logged_in && self.soloist_pairing { + self.set_soloist_pairing(false); + let paired = self.strings().status.infra.soloist_paired; + self.set_status_message(paired, 6); + } + match event { + Event::Track { .. } | Event::Context(_) | Event::Device { .. } => self.poll_playback_soon(), + Event::Volume(volume) if snapshot.volume == Some(*volume) => { + self.runtime_state.volume_percent = *volume; + self.schedule_state_save(PersistedRuntimeState::volume_percent(*volume)); + } + _ => {} + } + // A phone cannot start Soloist over a decoded source or Music that owns the sink. + if matches!( + self.playback_owner(), + PlaybackOwner::Decoded | PlaybackOwner::AppleMusic | PlaybackOwner::Queue + ) { + link.pause(); + } + self.overlay_soloist_snapshot(); + } + + /// Report the stream Soloist plays: once per engine start, and again on a drop from lossless. + pub(crate) fn note_soloist_quality( + &mut self, + link: &Arc, + previous: Option, + quality: &crate::infra::soloist::StreamQuality, + ) { + if !self.is_current_soloist(link) { + return; + } + self.display_revisions.bump(DisplayDomain::Playback); + // Podcasts are always lossy; they say nothing about the quality setting. + if link.snapshot().item_is_episode { + return; + } + let strings = self.strings().status.infra; + let label = quality.label(); + match previous { + None if quality.is_lossless() => self.set_status_message(strings.soloist_streams(&label), 4), + None => self.set_status_message(strings.soloist_lossy_hint(&label), 10), + Some(previous) if previous.is_lossless() && !quality.is_lossless() => { + self.set_status_message(strings.soloist_dropped(&label), 8); + } + Some(_) => {} + } + } + + /// Download the official build from Spotify, one download at a time. + pub(crate) fn download_soloist(&mut self) { + if self.soloist_downloading { + return; + } + self.soloist_downloading = true; + self.display_revisions.bump(DisplayDomain::Settings); + let started = self.strings().status.infra.soloist_download_started; + self.set_status_message(started, 8); + self.dispatch_without_spinner(IoEvent::DownloadSoloist); + } + + pub(crate) fn soloist_download_progress(&mut self, percent: u8) { + let message = self.strings().status.infra.soloist_downloading(percent); + self.set_status_message(message, 5); + } + + /// Save the new path; the start to run when the engine waits for it. + pub(crate) fn soloist_download_finished( + &mut self, + result: Result, + ) -> Option { + use crate::infra::soloist::download::Failure; + self.soloist_downloading = false; + self.display_revisions.bump(DisplayDomain::Settings); + let strings = self.strings().status.infra; + let path = match result { + Ok(path) => path.display().to_string(), + Err(Failure::NoBuild) => { + self.set_error_status_message(strings.soloist_no_build, 10); + return None; + } + Err(Failure::Failed(error)) => { + self.set_error_status_message(strings.soloist_download_failed(&error), 15); + return None; + } + }; + self.user_config.behavior.soloist_path = Some(path.clone()); + if let Err(e) = self.user_config.save_config() { + let message = self.strings().settings.save_failed(&e.to_string()); + self.handle_error_with_message(anyhow::anyhow!("Failed to save settings: {}", e), message); + return None; + } + // An open Settings screen saves its staged rows later; they must not put the old path back. + for item in self + .settings_items + .iter_mut() + .chain(self.settings_saved_items.iter_mut()) + .filter(|item| item.id == "behavior.soloist_path") + { + item.value = SettingValue::String(path.clone()); + } + let engine_on = self.user_config.behavior.playback_engine + == crate::core::user_config::PlaybackEngine::Soloist; + let start = self.soloist_start_now(); + match (engine_on, start.is_some()) { + (false, _) => self.set_status_message(strings.soloist_downloaded_engine_off, 10), + (true, false) => self.set_status_message(strings.soloist_downloaded_next_launch, 8), + (true, true) => self.set_status_message(strings.soloist_downloaded, 4), + } + start + } + + /// The start to run when the Soloist engine is on and nothing starts or runs it. + pub(crate) fn soloist_start_now( + &mut self, + ) -> Option { + let behavior = &self.user_config.behavior; + if behavior.playback_engine != crate::core::user_config::PlaybackEngine::Soloist + || !behavior.enable_streaming + || self.native_backend_pending + || crate::infra::soloist::supervisor::running() + { + return None; + } + let start = crate::infra::soloist::startup::SoloistStart::from_behavior( + behavior, + self.runtime_state.volume_percent, + None, + false, + ); + self.soloist_starting(); + Some(start) + } + + pub(crate) fn stand_by_librespot( + &mut self, + recovery_tx: tokio::sync::mpsc::UnboundedSender, + ) { + self.librespot_standby = Some(recovery_tx); + } + + /// The settings name Soloist, and this system has a Soloist build. + pub(crate) fn soloist_engine_selected(&self) -> bool { + self.user_config.behavior.playback_engine == crate::core::user_config::PlaybackEngine::Soloist + && !cfg!(all(target_os = "macos", target_arch = "x86_64")) + } + + /// Playback Engine changed in Settings: switch now, without a restart. + pub(crate) fn playback_engine_changed(&mut self) { + if !self.user_config.behavior.enable_streaming { + return; + } + let strings = self.strings().status.infra; + let message = if self.soloist_engine_selected() { + strings.engine_switching_soloist + } else { + strings.engine_switching_librespot + }; + self.set_status_message(message, 4); + self.dispatch_without_spinner(IoEvent::ApplyPlaybackEngine); + } + + /// After Soloist stopped: give the device to the engine the settings name; the Soloist start, if any. + pub(crate) fn apply_playback_engine( + &mut self, + ) -> Option { + if self.soloist_engine_selected() { + self.stop_librespot_for_soloist(); + return self.soloist_start_now(); + } + if let Some(recovery_tx) = self.librespot_standby.take() { + self.streaming_recovery_tx = Some(recovery_tx); + } + if self.streaming_player.is_some() || !self.user_config.behavior.enable_streaming { + return None; + } + // Soloist's id; librespot registers its own. + self.native_device_id = None; + self.native_backend_pending = self.streaming_recovery_tx.as_ref().is_some_and(|tx| { + tx.send(crate::infra::player::StreamingRecoveryRequest::default()) + .is_ok() + }); + if !self.native_backend_pending { + let message = self.strings().status.infra.engine_next_launch; + self.set_status_message(message, 8); + } + None + } + + /// Shut librespot down before Soloist registers the same device name; keep its recovery sender. + fn stop_librespot_for_soloist(&mut self) { + if let Some(recovery_tx) = self.streaming_recovery_tx.take() { + self.librespot_standby = Some(recovery_tx); + } + self.pause_native_playback(); + if let Some(player) = self.streaming_player.take() { + player.shutdown(); + // The last handle joins librespot's player thread: drop it off the lock. + tokio::task::spawn_blocking(move || drop(player)); + } + self.native_parked = false; + self.native_backend_pending = false; + self.native_playback_recovery = None; + self.native_load_watchdog = None; + self.is_streaming_active = false; + self.native_activation_pending = false; + self.native_device_id = None; + self.native_is_playing = Some(false); + self.native_track_info = None; + self.display_revisions.bump(DisplayDomain::Playback); + } + + /// `soloist_credentials.yml`, beside `config.yml`. + fn soloist_key_file(&self) -> Option { + let config = &self.user_config.path_to_config.as_ref()?.config_file_path; + Some(config.parent()?.join("soloist_credentials.yml")) + } + + pub(crate) fn saved_soloist_key(&self) -> String { + self + .soloist_key_file() + .and_then(|path| crate::infra::soloist::auth::read_saved(&path)) + .unwrap_or_default() + } + + /// Write a changed API key row, then restart the engine on it so a bad key shows at once; + /// true when that restart is dispatched. + pub(crate) fn save_soloist_key_row(&mut self) -> bool { + let env_key = std::env::var(crate::infra::soloist::auth::KEY_ENV).ok(); + self.save_soloist_key_row_with(env_key) + } + + fn save_soloist_key_row_with(&mut self, env_key: Option) -> bool { + use crate::infra::soloist::auth; + let Some(key) = self + .settings_items + .iter() + .find_map(|item| match &item.value { + SettingValue::Secret(key) if item.id == "behavior.soloist_api_key" => { + Some(key.trim().to_string()) + } + _ => None, + }) + else { + return false; + }; + let Some(path) = self.soloist_key_file() else { + return false; + }; + if key == self.saved_soloist_key().trim() { + return false; + } + let strings = self.strings().status.infra; + if let Err(e) = auth::save_key(&path, &key) { + self.set_error_status_message(strings.soloist_key_save_failed(&e.to_string()), 10); + return false; + } + if auth::key_with(env_key, None).is_some() { + self.set_status_message(strings.soloist_key_env_wins, 10); + return false; + } + if key.is_empty() + || !self.soloist_engine_selected() + || !self.user_config.behavior.enable_streaming + { + self.set_status_message(strings.soloist_key_saved, 4); + return false; + } + self.set_status_message(strings.soloist_key_restarting, 4); + self.dispatch_without_spinner(IoEvent::ApplyPlaybackEngine); + true + } + + pub(crate) fn soloist_notice(&mut self, notice: crate::infra::soloist::Notice) { + use crate::infra::soloist::Notice; + let strings = self.strings().status.infra; + match notice { + Notice::PathUnset => self.set_error_status_message(strings.soloist_path_unset, 15), + Notice::Missing(path) => { + self.set_error_status_message(strings.soloist_missing(&path.display().to_string()), 15); + } + Notice::NotExecutable(path) => { + let path = path.display().to_string(); + self.set_error_status_message(strings.soloist_not_executable(&path), 15); + } + Notice::WrongCpu => self.set_error_status_message(strings.soloist_wrong_cpu, 15), + Notice::StartFailed(error) => { + self.set_error_status_message(strings.soloist_start_failed(&error), 15); + } + Notice::KeyMissing(path) => { + let path = path.display().to_string(); + self.set_error_status_message(strings.soloist_key_missing(&path), 20); + } + Notice::NotPaired => { + self.set_status_message(strings.soloist_not_paired, 12); + self.set_soloist_pairing(true); + } + Notice::Offline => self.set_status_message(strings.soloist_offline, 8), + Notice::BadKey => self.set_error_status_message(strings.soloist_bad_key, 15), + Notice::Expired => self.set_error_status_message(strings.soloist_expired, 20), + Notice::ExpiresSoon { count, unit } => { + self.set_status_message(strings.soloist_expires_soon(count, &unit), 10); + } + Notice::DirInUse => self.set_error_status_message(strings.soloist_dir_in_use, 15), + Notice::KeepsExiting => self.set_error_status_message(strings.soloist_keeps_exiting, 15), + Notice::NoWebSocket => self.set_error_status_message(strings.soloist_no_websocket, 15), + Notice::Restarting => self.set_status_message(strings.soloist_restarting, 4), + Notice::RestartingForQuality => self.set_status_message(strings.soloist_restart_quality, 4), + } + } +} + +#[cfg(test)] +mod tests { + use crate::core::action::Action; + use crate::core::app::test_support::*; + use crate::core::app::{App, SettingValue}; + use crate::core::user_config::PlaybackEngine; + use crate::infra::network::IoEvent; + use crate::infra::soloist::download::Failure; + use crate::infra::soloist::protocol::{Command, Event, Status}; + use crate::infra::soloist::SoloistLink; + + #[test] + fn install_sets_the_native_device_id_and_ends_the_wait() { + let mut app = make_app_simple(); + app.soloist_starting(); + let (link, _rx) = SoloistLink::test_pair("soloist-device"); + app.install_soloist_link(link); + assert_eq!(app.native_device_id.as_deref(), Some("soloist-device")); + assert!(!app.native_backend_pending); + assert!(app.soloist_engine_installed()); + } + + #[test] + fn stale_soloist_link_writes_nothing() { + let mut app = make_app_simple(); + let (old, _old_rx) = SoloistLink::test_pair("old"); + let (new, _new_rx) = SoloistLink::test_pair("new"); + app.install_soloist_link(old.clone()); + app.install_soloist_link(new); + app.apply_soloist_event(&old, &Event::Volume(70)); + assert_eq!(old.snapshot().volume, None); + app.uninstall_soloist_link(&old); + assert!(app.soloist_engine_installed()); + } + + #[test] + fn pause_native_playback_pauses_soloist() { + let mut app = make_app_simple(); + let (link, mut rx) = SoloistLink::test_pair("soloist-device"); + app.install_soloist_link(link.clone()); + app.apply_soloist_event(&link, &Event::Playback(Status::Playing)); + app.pause_native_playback(); + assert_eq!(rx.try_recv().ok(), Some(Command::Pause)); + } + + fn with_config(app: &mut App, dir: &std::path::Path) { + app.user_config.path_to_config = Some(crate::core::user_config::UserConfigPaths { + config_file_path: dir.join("config.yml"), + }); + } + + fn app_with_config(dir: &std::path::Path) -> App { + let mut app = make_app_simple(); + with_config(&mut app, dir); + app + } + + fn path_row(value: &str) -> crate::core::app::SettingItem { + setting( + "behavior.soloist_path", + SettingValue::String(value.to_string()), + ) + } + + #[test] + fn the_download_button_dispatches_one_download_at_a_time() { + let (mut app, rx) = session_free_app(); + let press = || Action::PressSettingButton { + id: "behavior.soloist_path".to_string(), + }; + app.apply(press()); + app.apply(press()); + assert!(matches!(rx.try_recv(), Ok(IoEvent::DownloadSoloist))); + assert!(rx.try_recv().is_err()); + assert_eq!( + app.setting_button_label("behavior.soloist_path"), + Some(("Downloading…", true)) + ); + app.soloist_download_finished(Err(Failure::Failed("timed out".to_string()))); + assert!(app.status_message_is_error()); + assert_eq!( + app.setting_button_label("behavior.soloist_path"), + Some(("Download from Spotify", false)) + ); + } + + #[test] + fn a_finished_download_saves_the_path_where_an_open_settings_screen_keeps_it() { + let dir = tempfile::tempdir().unwrap(); + let mut app = app_with_config(dir.path()); + app.settings_items = vec![path_row("")]; + app.settings_saved_items = vec![path_row("")]; + let binary = dir.path().join("soloist.exe"); + + let start = app.soloist_download_finished(Ok(binary.clone())); + + let path = binary.display().to_string(); + assert!(start.is_none(), "the librespot engine starts no Soloist"); + assert_eq!( + app.user_config.behavior.soloist_path.as_deref(), + Some(path.as_str()) + ); + assert!(std::fs::read_to_string(dir.path().join("config.yml")) + .unwrap() + .contains("soloist_path")); + assert_eq!( + app.settings_items[0].value, + SettingValue::String(path.clone()) + ); + assert_eq!( + app.settings_saved_items[0].value, + SettingValue::String(path) + ); + } + + #[test] + fn a_download_starts_soloist_when_the_engine_waits_for_it() { + let dir = tempfile::tempdir().unwrap(); + let mut app = app_with_config(dir.path()); + app.user_config.behavior.enable_streaming = true; + app.user_config.behavior.playback_engine = PlaybackEngine::Soloist; + + let start = app.soloist_download_finished(Ok(dir.path().join("soloist"))); + + assert!(start.is_some()); + assert!(app.native_backend_pending); + } + + fn key_row(value: &str) -> crate::core::app::SettingItem { + setting( + "behavior.soloist_api_key", + SettingValue::Secret(value.to_string()), + ) + } + + fn soloist_app_with_config(dir: &std::path::Path) -> (App, std::sync::mpsc::Receiver) { + let (mut app, rx) = session_free_app(); + with_config(&mut app, dir); + app.user_config.behavior.enable_streaming = true; + app.user_config.behavior.playback_engine = PlaybackEngine::Soloist; + (app, rx) + } + + #[test] + fn a_new_key_is_saved_beside_the_config_and_restarts_soloist() { + let dir = tempfile::tempdir().unwrap(); + let (mut app, rx) = soloist_app_with_config(dir.path()); + app.settings_items = vec![key_row(" new-key ")]; + + app.save_soloist_key_row_with(None); + + let file = dir.path().join("soloist_credentials.yml"); + assert_eq!( + crate::infra::soloist::auth::read_saved(&file).as_deref(), + Some("new-key") + ); + assert_eq!(app.saved_soloist_key(), "new-key"); + assert!(matches!(rx.try_recv(), Ok(IoEvent::ApplyPlaybackEngine))); + } + + #[test] + fn an_unchanged_key_or_an_env_key_restarts_nothing() { + let dir = tempfile::tempdir().unwrap(); + let (mut app, rx) = soloist_app_with_config(dir.path()); + std::fs::write( + dir.path().join("soloist_credentials.yml"), + "api_key: same\n", + ) + .unwrap(); + app.settings_items = vec![key_row("same")]; + app.save_soloist_key_row_with(None); + assert!(rx.try_recv().is_err()); + + app.settings_items = vec![key_row("other")]; + app.save_soloist_key_row_with(Some("env-key".to_string())); + assert_eq!(app.saved_soloist_key(), "other"); + assert!(rx.try_recv().is_err()); + assert_eq!( + app.status_message(), + Some(app.strings().status.infra.soloist_key_env_wins) + ); + } + + #[test] + fn the_key_row_offers_the_dashboard_button() { + let app = make_app_simple(); + assert_eq!( + app.setting_button_label("behavior.soloist_api_key"), + Some(("Get a key", false)) + ); + } + + #[test] + fn the_pairing_guide_shows_until_soloist_logs_in() { + let mut app = make_app_simple(); + let (link, _rx) = SoloistLink::test_pair("soloist-device"); + app.install_soloist_link(link.clone()); + app.soloist_notice(crate::infra::soloist::Notice::NotPaired); + let device = app.user_config.behavior.streaming_device_name.clone(); + assert_eq!(app.soloist_pairing_device(), Some(device.as_str())); + + let auth = |logged_in| Event::Auth { + logged_in, + is_active: false, + }; + app.apply_soloist_event(&link, &auth(false)); + assert!(app.soloist_pairing_device().is_some()); + app.apply_soloist_event(&link, &auth(true)); + assert_eq!(app.soloist_pairing_device(), None); + assert_eq!( + app.status_message(), + Some(app.strings().status.infra.soloist_paired) + ); + } + + #[test] + fn an_engine_change_in_settings_dispatches_the_switch() { + let (mut app, rx) = session_free_app(); + app.user_config.behavior.enable_streaming = true; + app.settings_items = vec![setting( + "behavior.playback_engine", + SettingValue::Cycle( + PlaybackEngine::Soloist.name().to_string(), + PlaybackEngine::options(), + ), + )]; + + app.apply_settings_changes(); + + assert!(matches!(rx.try_recv(), Ok(IoEvent::ApplyPlaybackEngine))); + assert_eq!( + app.status_message(), + Some(app.strings().status.infra.engine_switching_soloist) + ); + } + + #[test] + fn a_switch_to_soloist_keeps_the_librespot_sender_aside_and_starts_soloist() { + let mut app = make_app_simple(); + let (tx, _recovery_rx) = tokio::sync::mpsc::unbounded_channel(); + app.streaming_recovery_tx = Some(tx); + app.native_device_id = Some("librespot-device".to_string()); + app.user_config.behavior.enable_streaming = true; + app.user_config.behavior.playback_engine = PlaybackEngine::Soloist; + + let start = app.apply_playback_engine(); + + assert!(start.is_some()); + assert!(app.streaming_recovery_tx.is_none()); + assert!(app.librespot_standby.is_some()); + assert_eq!(app.native_device_id, None); + assert!(app.native_backend_pending); + assert!(!app.accept_rebuilt_native_backend()); + } + + #[test] + fn a_switch_to_librespot_asks_the_standby_supervisor_for_an_idle_player() { + let mut app = make_app_simple(); + let (tx, mut recovery_rx) = tokio::sync::mpsc::unbounded_channel(); + app.stand_by_librespot(tx); + app.native_device_id = Some("soloist-device".to_string()); + app.user_config.behavior.enable_streaming = true; + + assert!(app.apply_playback_engine().is_none()); + + let request = recovery_rx.try_recv().unwrap(); + assert!(!request.restore_playback && !request.reselect_device && !request.reacquire); + assert!(app.streaming_recovery_tx.is_some()); + assert_eq!(app.native_device_id, None); + assert!(app.native_backend_pending); + } + + #[test] + fn the_soloist_offer_shows_once_and_set_up_opens_the_playback_engine_row() { + let mut app = make_app_simple(); + app.offer_soloist_setup(); + assert!(app.soloist_offer_shows()); + + app.apply(Action::AnswerSoloistOffer { set_up: true }); + + assert!(!app.soloist_offer_shows()); + assert_eq!( + app.get_current_route().id, + crate::core::app::RouteId::Settings + ); + assert_eq!( + app.settings_items[app.view.settings_selected_index].id, + "behavior.playback_engine" + ); + app.offer_soloist_setup(); + assert!(!app.soloist_offer_shows()); + } + + #[test] + fn soloist_is_not_offered_when_it_is_already_the_engine() { + let mut app = make_app_simple(); + app.user_config.behavior.playback_engine = PlaybackEngine::Soloist; + app.offer_soloist_setup(); + assert!(!app.soloist_offer_shows()); + } + + #[test] + fn queue_refuses_a_decoded_item_while_soloist_plays() { + let mut app = make_app_simple(); + let (link, _rx) = SoloistLink::test_pair("soloist-device"); + app.install_soloist_link(link.clone()); + app.apply_soloist_event( + &link, + &Event::Auth { + logged_in: true, + is_active: true, + }, + ); + app.add_track_to_native_queue(queue_track(Some("file:///music/a.flac"), "a")); + assert!(app.native_queue.is_empty()); + } +} diff --git a/src/core/app/test_support.rs b/src/core/app/test_support.rs index 819fa128..3bcc8758 100644 --- a/src/core/app/test_support.rs +++ b/src/core/app/test_support.rs @@ -104,6 +104,16 @@ pub(super) fn saved_tracks_page( } } +/// A settings row with no name or description. +pub(super) fn setting(id: &str, value: SettingValue) -> SettingItem { + SettingItem { + id: id.to_string(), + name: String::new(), + description: String::new(), + value, + } +} + pub(super) fn make_app_simple() -> App { let (tx, _rx) = channel(); App::new(tx, UserConfig::new(), Some(SystemTime::now())) diff --git a/src/core/app/transport.rs b/src/core/app/transport.rs index 354b76fc..441bf423 100644 --- a/src/core/app/transport.rs +++ b/src/core/app/transport.rs @@ -24,6 +24,10 @@ impl App { if let Some(player) = &self.streaming_player { player.pause(); } + #[cfg(feature = "soloist")] + if let Some(link) = &self.soloist_link { + link.pause(); + } if let Some(ctx) = &mut self.current_playback_context { ctx.is_playing = false; } diff --git a/src/core/i18n/help.rs b/src/core/i18n/help.rs index 7a49ba68..c8417372 100644 --- a/src/core/i18n/help.rs +++ b/src/core/i18n/help.rs @@ -49,6 +49,7 @@ pub struct HelpStrings { pub source_device: &'static str, pub settings_open: &'static str, pub settings_save: &'static str, + pub settings_button: &'static str, pub hover_mode: &'static str, pub save_track: &'static str, pub add_selected_to_playlist: &'static str, @@ -200,6 +201,7 @@ pub const EN: HelpStrings = HelpStrings { source_device: "Switch music source / select playback device", settings_open: "Open settings", settings_save: "Save settings", + settings_button: "Press the selected setting's button", hover_mode: "Enter hover mode", save_track: "Save track in list or table", add_selected_to_playlist: "Add selected track to playlist", diff --git a/src/core/i18n/popups.rs b/src/core/i18n/popups.rs index ce01e583..f1edb20f 100644 --- a/src/core/i18n/popups.rs +++ b/src/core/i18n/popups.rs @@ -121,6 +121,19 @@ pub struct PopupStrings { pub unknown_artist: &'static str, pub create_results_title: &'static str, pub added_tracks_title_template: &'static str, + pub soloist_pair_title: &'static str, + pub soloist_pair_open: &'static str, + pub soloist_pair_pick_template: &'static str, + pub soloist_pair_waiting: &'static str, + pub soloist_pair_close: &'static str, + pub soloist_offer_title: &'static str, + pub soloist_offer_body: &'static str, + pub soloist_offer_step_download: &'static str, + pub soloist_offer_step_key: &'static str, + pub soloist_offer_step_engine: &'static str, + pub soloist_offer_set_up: &'static str, + pub soloist_offer_not_now: &'static str, + pub soloist_offer_controls: &'static str, } pub const EN: PopupStrings = PopupStrings { @@ -242,6 +255,19 @@ pub const EN: PopupStrings = PopupStrings { unknown_artist: "Unknown", create_results_title: "Results (Enter to add)", added_tracks_title_template: "Added ({count}) — d=remove, Enter=create", + soloist_pair_title: "Pair spotatui with Spotify", + soloist_pair_open: "1. Open Spotify on a computer on this network.", + soloist_pair_pick_template: "2. In its device menu, pick '{name}'.", + soloist_pair_waiting: "Waiting for Spotify…", + soloist_pair_close: "Close", + soloist_offer_title: "Play through Soloist?", + soloist_offer_body: "Spotify refuses librespot the audio keys for these tracks, as it does for some accounts. Soloist, Spotify's own player, can play them. It needs Premium. In Settings:", + soloist_offer_step_download: "1. Soloist Path: Download from Spotify.", + soloist_offer_step_key: "2. Soloist API Key: Get a key, then enter it.", + soloist_offer_step_engine: "3. Playback Engine: Soloist.", + soloist_offer_set_up: "Set up Soloist", + soloist_offer_not_now: "Not now", + soloist_offer_controls: "Enter: open Settings Esc: not now", }; // Keep ..EN so new English fields compile before their translations are added. @@ -494,6 +520,13 @@ pub const ZH_HANT: PopupStrings = PopupStrings { ..EN }; +#[cfg(any(feature = "tui", feature = "gui", test))] +impl PopupStrings { + pub fn soloist_pair_pick(&self, name: &str) -> String { + super::format_template(self.soloist_pair_pick_template, &[("name", name)]) + } +} + #[cfg(any(feature = "tui", test))] impl PopupStrings { #[cfg(any(feature = "internet-radio", test))] diff --git a/src/core/i18n/settings.rs b/src/core/i18n/settings.rs index 6866739a..3966352e 100644 --- a/src/core/i18n/settings.rs +++ b/src/core/i18n/settings.rs @@ -12,6 +12,17 @@ pub use zh_hant::ZH_HANT; pub struct SettingsStrings { pub behavior_ytdlp_path_description: &'static str, pub behavior_ytdlp_path_name: &'static str, + pub behavior_playback_engine_name: &'static str, + pub behavior_playback_engine_description: &'static str, + pub behavior_soloist_path_name: &'static str, + pub behavior_soloist_path_description: &'static str, + pub soloist_download_button: &'static str, + pub soloist_downloading_button: &'static str, + pub behavior_soloist_api_key_name: &'static str, + pub behavior_soloist_api_key_description: &'static str, + pub soloist_get_key_button: &'static str, + pub behavior_soloist_audio_quality_name: &'static str, + pub behavior_soloist_audio_quality_description: &'static str, pub behavior_local_music_path_description: &'static str, pub behavior_local_music_path_name: &'static str, pub behavior_subsonic_password_description: &'static str, @@ -385,6 +396,22 @@ impl SettingsStrings { self.behavior_ytdlp_path_name, self.behavior_ytdlp_path_description, ), + "behavior.playback_engine" => ( + self.behavior_playback_engine_name, + self.behavior_playback_engine_description, + ), + "behavior.soloist_path" => ( + self.behavior_soloist_path_name, + self.behavior_soloist_path_description, + ), + "behavior.soloist_api_key" => ( + self.behavior_soloist_api_key_name, + self.behavior_soloist_api_key_description, + ), + "behavior.soloist_audio_quality" => ( + self.behavior_soloist_audio_quality_name, + self.behavior_soloist_audio_quality_description, + ), "behavior.local_music_path" => ( self.behavior_local_music_path_name, self.behavior_local_music_path_description, diff --git a/src/core/i18n/settings/en.rs b/src/core/i18n/settings/en.rs index 16defc2d..9b72df58 100644 --- a/src/core/i18n/settings/en.rs +++ b/src/core/i18n/settings/en.rs @@ -3,6 +3,17 @@ use super::SettingsStrings; pub const EN: SettingsStrings = SettingsStrings { behavior_ytdlp_path_description: "The yt-dlp program YouTube runs; empty looks it up on PATH", behavior_ytdlp_path_name: "yt-dlp Path", + behavior_playback_engine_name: "Playback Engine", + behavior_playback_engine_description: "What plays Spotify behind the Native Streaming device. Soloist is Spotify's own player: get it with the Soloist Path button and enter its key in Soloist API Key.", + behavior_soloist_path_name: "Soloist Path", + behavior_soloist_path_description: "The Soloist program the Soloist engine runs. Download from Spotify gets the official build for this system from Spotify; by using Soloist, you accept the Spotify Terms and Conditions of Use.", + soloist_download_button: "Download from Spotify", + soloist_downloading_button: "Downloading…", + behavior_soloist_api_key_name: "Soloist API Key", + behavior_soloist_api_key_description: "The key Soloist logs in with, saved in soloist_credentials.yml. Get a key opens Spotify's developer dashboard, where you create one. SPOTATUI_SOLOIST_API_KEY wins over this value. Other local programs can read the key from Soloist's command line.", + soloist_get_key_button: "Get a key", + behavior_soloist_audio_quality_name: "Soloist Audio Quality", + behavior_soloist_audio_quality_description: "The stream Soloist asks for (applies on restart). Lossless needs Premium in a lossless market; if Soloist still streams lossy, pick spotatui in the Spotify mobile app's device menu and set Lossless.", behavior_local_music_path_description: "The folder Local Files reads", behavior_local_music_path_name: "Music Folder", behavior_subsonic_password_description: "Saved in config.yml; SPOTATUI_SUBSONIC_PASSWORD overrides it", diff --git a/src/core/i18n/status_infra.rs b/src/core/i18n/status_infra.rs index eb5851e0..370d5106 100644 --- a/src/core/i18n/status_infra.rs +++ b/src/core/i18n/status_infra.rs @@ -173,6 +173,42 @@ pub struct InfraStatusStrings { pub native_restored_paused: &'static str, pub native_skip_unavailable: &'static str, pub native_many_unavailable: &'static str, + pub soloist_path_unset: &'static str, + pub soloist_missing: &'static str, + pub soloist_not_executable: &'static str, + pub soloist_wrong_cpu: &'static str, + pub soloist_start_failed: &'static str, + pub soloist_key_missing: &'static str, + pub soloist_not_paired: &'static str, + pub soloist_offline: &'static str, + pub soloist_bad_key: &'static str, + pub soloist_expired: &'static str, + pub soloist_expires_soon: &'static str, + pub soloist_dir_in_use: &'static str, + pub soloist_keeps_exiting: &'static str, + pub soloist_no_websocket: &'static str, + pub soloist_restarting: &'static str, + pub soloist_restart_quality: &'static str, + pub soloist_streams: &'static str, + pub soloist_lossy_hint: &'static str, + pub soloist_dropped: &'static str, + pub soloist_engine_unavailable: &'static str, + pub soloist_queue_refused: &'static str, + pub soloist_paired: &'static str, + pub engine_switching_soloist: &'static str, + pub engine_switching_librespot: &'static str, + pub engine_next_launch: &'static str, + pub soloist_download_started: &'static str, + pub soloist_downloading: &'static str, + pub soloist_downloaded: &'static str, + pub soloist_downloaded_engine_off: &'static str, + pub soloist_downloaded_next_launch: &'static str, + pub soloist_download_failed: &'static str, + pub soloist_no_build: &'static str, + pub soloist_key_saved: &'static str, + pub soloist_key_restarting: &'static str, + pub soloist_key_env_wins: &'static str, + pub soloist_key_save_failed: &'static str, pub native_recovery_dropped: &'static str, pub native_recovery_failed: &'static str, pub native_lost_recovering: &'static str, @@ -293,6 +329,45 @@ pub struct InfraStatusStrings { allow(dead_code) )] impl InfraStatusStrings { + pub fn soloist_missing(&self, path: &str) -> String { + super::format_template(self.soloist_missing, &[("path", path)]) + } + pub fn soloist_not_executable(&self, path: &str) -> String { + super::format_template(self.soloist_not_executable, &[("path", path)]) + } + pub fn soloist_start_failed(&self, error: &str) -> String { + super::format_template(self.soloist_start_failed, &[("error", error)]) + } + pub fn soloist_downloading(&self, percent: u8) -> String { + super::format_template( + self.soloist_downloading, + &[("percent", &percent.to_string())], + ) + } + pub fn soloist_download_failed(&self, error: &str) -> String { + super::format_template(self.soloist_download_failed, &[("error", error)]) + } + pub fn soloist_key_save_failed(&self, error: &str) -> String { + super::format_template(self.soloist_key_save_failed, &[("error", error)]) + } + pub fn soloist_key_missing(&self, path: &str) -> String { + super::format_template(self.soloist_key_missing, &[("path", path)]) + } + pub fn soloist_expires_soon(&self, count: u32, unit: &str) -> String { + super::format_template( + self.soloist_expires_soon, + &[("count", &count.to_string()), ("unit", unit)], + ) + } + pub fn soloist_streams(&self, quality: &str) -> String { + super::format_template(self.soloist_streams, &[("quality", quality)]) + } + pub fn soloist_lossy_hint(&self, quality: &str) -> String { + super::format_template(self.soloist_lossy_hint, &[("quality", quality)]) + } + pub fn soloist_dropped(&self, quality: &str) -> String { + super::format_template(self.soloist_dropped, &[("quality", quality)]) + } pub fn sync_source_not_compiled(&self, source: &str, feature: &str) -> String { super::format_template( self.sync_source_not_compiled, @@ -947,6 +1022,42 @@ pub const EN: InfraStatusStrings = InfraStatusStrings { native_restored_paused: "Native playback restored in paused state.", native_skip_unavailable: "Couldn't play this track natively (unavailable or blocked); skipping.", native_many_unavailable: "Several tracks in a row couldn't be played natively, so playback was stopped. They may be unavailable on your account or region. Press 'd' to switch to an official Spotify Connect device.", + soloist_path_unset: "Soloist is not installed. In Settings, Soloist Path, pick Download from Spotify.", + soloist_missing: "Soloist not found at {path}. In Settings, Soloist Path, pick Download from Spotify.", + soloist_not_executable: "Soloist at {path} is not executable: chmod +x {path}", + soloist_wrong_cpu: "This Soloist build does not run on this CPU. In Settings, Soloist Path, pick Download from Spotify.", + soloist_start_failed: "Soloist could not start: {error}", + soloist_key_missing: "Soloist needs an API key. In Settings, Soloist API Key, pick Get a key, then enter the key (it is saved in {path}).", + soloist_not_paired: "Pair spotatui: open Spotify on a computer on this network and pick 'spotatui'.", + soloist_offline: "Soloist cannot reach Spotify.", + soloist_bad_key: "Soloist rejected the API key. In Settings, Soloist API Key, enter a new one.", + soloist_expired: "Soloist build expired. In Settings, Soloist Path, pick Download from Spotify for a new one.", + soloist_expires_soon: "Soloist build expires in {count} {unit}: download a new one soon.", + soloist_dir_in_use: "Another Soloist uses spotatui's Soloist data folder.", + soloist_keeps_exiting: "Soloist keeps exiting; see the log.", + soloist_no_websocket: "Soloist started without its WebSocket.", + soloist_restarting: "Soloist stopped; restarting it.", + soloist_restart_quality: "Restarting Soloist to apply the audio quality.", + soloist_streams: "Soloist streams {quality}.", + soloist_lossy_hint: "Soloist streams {quality}. For lossless, pick spotatui in the Spotify mobile app's device menu and set Lossless.", + soloist_dropped: "Soloist dropped to {quality}.", + soloist_engine_unavailable: "This build has no Soloist engine; playing through librespot.", + soloist_queue_refused: "Queueing other sources behind Spotify needs the librespot engine.", + soloist_download_started: "Downloading Soloist from Spotify. By using Soloist, you accept the Spotify Terms and Conditions of Use.", + soloist_downloading: "Downloading Soloist from Spotify: {percent}%", + soloist_downloaded: "Soloist downloaded; starting it.", + soloist_downloaded_engine_off: "Soloist downloaded. Set Playback Engine to Soloist to play through it.", + soloist_downloaded_next_launch: "Soloist downloaded. spotatui uses it from the next launch.", + soloist_download_failed: "Soloist download failed: {error}", + soloist_no_build: "Spotify has no Soloist build for this system.", + soloist_paired: "Paired: spotatui plays through Soloist.", + engine_switching_soloist: "Switching the playback engine to Soloist.", + engine_switching_librespot: "Switching the playback engine to librespot.", + engine_next_launch: "The new playback engine starts at the next launch.", + soloist_key_saved: "Soloist API key saved.", + soloist_key_restarting: "Soloist API key saved; restarting Soloist with it.", + soloist_key_env_wins: "Soloist API key saved, but SPOTATUI_SOLOIST_API_KEY is set and wins over it.", + soloist_key_save_failed: "Could not save the Soloist API key: {error}", native_recovery_dropped: "Native recovery failed; playback request dropped: {error}", native_recovery_failed: "Native recovery failed: {error}", native_lost_recovering: "Native streaming connection lost; attempting recovery.", diff --git a/src/core/i18n/template_tests.rs b/src/core/i18n/template_tests.rs index 8a03ccd0..c3fafdf1 100644 --- a/src/core/i18n/template_tests.rs +++ b/src/core/i18n/template_tests.rs @@ -376,6 +376,11 @@ fn every_typed_message_fills_its_placeholders_and_preserves_all_arguments() { strings.popups.add_tracks_title("SENTINEL_NAME_中"), ["SENTINEL_NAME_中"] ); + check!( + "popups.soloist_pair_pick", + strings.popups.soloist_pair_pick("SENTINEL_NAME_中"), + ["SENTINEL_NAME_中"] + ); check!( "popups.added_tracks_title", strings.popups.added_tracks_title(9101), @@ -1405,6 +1410,79 @@ fn every_typed_message_fills_its_placeholders_and_preserves_all_arguments() { strings.status.infra.shuffle_capped(9101), ["9101"] ); + check!( + "status_infra.soloist_missing", + strings.status.infra.soloist_missing("SENTINEL_PATH_中"), + ["SENTINEL_PATH_中"] + ); + check!( + "status_infra.soloist_not_executable", + strings + .status + .infra + .soloist_not_executable("SENTINEL_PATH_中"), + ["SENTINEL_PATH_中"] + ); + check!( + "status_infra.soloist_start_failed", + strings + .status + .infra + .soloist_start_failed("SENTINEL_ERROR_中"), + ["SENTINEL_ERROR_中"] + ); + check!( + "status_infra.soloist_downloading", + strings.status.infra.soloist_downloading(87), + ["87"] + ); + check!( + "status_infra.soloist_download_failed", + strings + .status + .infra + .soloist_download_failed("SENTINEL_ERROR_中"), + ["SENTINEL_ERROR_中"] + ); + check!( + "status_infra.soloist_key_save_failed", + strings + .status + .infra + .soloist_key_save_failed("SENTINEL_ERROR_中"), + ["SENTINEL_ERROR_中"] + ); + check!( + "status_infra.soloist_key_missing", + strings.status.infra.soloist_key_missing("SENTINEL_PATH_中"), + ["SENTINEL_PATH_中"] + ); + check!( + "status_infra.soloist_expires_soon", + strings + .status + .infra + .soloist_expires_soon(9101, "SENTINEL_UNIT_中"), + ["9101", "SENTINEL_UNIT_中"] + ); + check!( + "status_infra.soloist_streams", + strings.status.infra.soloist_streams("SENTINEL_QUALITY_中"), + ["SENTINEL_QUALITY_中"] + ); + check!( + "status_infra.soloist_lossy_hint", + strings + .status + .infra + .soloist_lossy_hint("SENTINEL_QUALITY_中"), + ["SENTINEL_QUALITY_中"] + ); + check!( + "status_infra.soloist_dropped", + strings.status.infra.soloist_dropped("SENTINEL_QUALITY_中"), + ["SENTINEL_QUALITY_中"] + ); check!( "status_infra.friend_added_code", strings.status.infra.friend_added_code("SENTINEL_CODE_中"), diff --git a/src/core/paths.rs b/src/core/paths.rs index d92a0cdb..0067e27d 100644 --- a/src/core/paths.rs +++ b/src/core/paths.rs @@ -62,6 +62,30 @@ pub(crate) fn qobuz_credentials_path() -> Option { app_config_dir().map(|dir| dir.join("qobuz_credentials.yml")) } +/// The Soloist API key: `/soloist_credentials.yml`. +#[cfg(feature = "soloist")] +pub(crate) fn soloist_credentials_path() -> Option { + app_config_dir().map(|dir| dir.join("soloist_credentials.yml")) +} + +/// Soloist's own data dir (device id, paired session, prefs): `/soloist`. +#[cfg(feature = "soloist")] +pub(crate) fn soloist_data_dir() -> Option { + app_state_dir().map(|dir| dir.join("soloist")) +} + +/// The Soloist builds spotatui downloaded from Spotify: `/soloist-bin`. +#[cfg(feature = "soloist")] +pub(crate) fn soloist_bin_dir() -> Option { + app_state_dir().map(|dir| dir.join("soloist-bin")) +} + +/// Soloist's playback cache: `/soloist`. +#[cfg(feature = "soloist")] +pub(crate) fn soloist_cache_dir() -> Option { + app_cache_dir().map(|dir| dir.join("soloist")) +} + /// The player-bar visualizer's look: `/visualizer.yml`, never written by the app. #[cfg(feature = "gui")] pub(crate) fn visualizer_config_path() -> Option { diff --git a/src/core/user_config.rs b/src/core/user_config.rs index 6b939318..a6ea4134 100644 --- a/src/core/user_config.rs +++ b/src/core/user_config.rs @@ -337,6 +337,83 @@ impl StartupBehavior { } } +/// What plays the audio behind spotatui's own Connect device. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Default, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum PlaybackEngine { + #[default] + Librespot, + /// Spotify Soloist, a binary the user downloads from Spotify. + Soloist, +} + +#[cfg(feature = "soloist")] +impl PlaybackEngine { + pub fn name(self) -> &'static str { + match self { + PlaybackEngine::Librespot => "librespot", + PlaybackEngine::Soloist => "Soloist", + } + } + + pub fn options() -> &'static [&'static str] { + &["librespot", "Soloist"] + } + + pub fn from_name(name: &str) -> Self { + match name { + "Soloist" => PlaybackEngine::Soloist, + _ => PlaybackEngine::Librespot, + } + } +} + +/// The stream quality Soloist asks for. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Default, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum SoloistQuality { + Normal, + High, + VeryHigh, + #[default] + Lossless, +} + +#[cfg(feature = "soloist")] +impl SoloistQuality { + pub fn name(self) -> &'static str { + match self { + SoloistQuality::Normal => "Normal", + SoloistQuality::High => "High", + SoloistQuality::VeryHigh => "Very high", + SoloistQuality::Lossless => "Lossless", + } + } + + pub fn options() -> &'static [&'static str] { + &["Normal", "High", "Very high", "Lossless"] + } + + pub fn from_name(name: &str) -> Self { + match name { + "Normal" => SoloistQuality::Normal, + "High" => SoloistQuality::High, + "Very high" => SoloistQuality::VeryHigh, + _ => SoloistQuality::Lossless, + } + } + + /// Spotify's `audio.play_bitrate_enumeration`; only 5 (FLAC) is measured on Soloist. + pub fn enumeration(self) -> u8 { + match self { + SoloistQuality::Normal => 2, + SoloistQuality::High => 3, + SoloistQuality::VeryHigh => 4, + SoloistQuality::Lossless => 5, + } + } +} + fn parse_key(key: String) -> Result { // "ctrl" with no dash and "ctrl-" with nothing after the dash are config // typos; they must surface as config errors naming the binding, never a @@ -694,6 +771,9 @@ pub struct BehaviorConfigString { pub subsonic_username: Option, pub subsonic_password: Option, pub ytdlp_path: Option, + pub playback_engine: Option, + pub soloist_path: Option, + pub soloist_audio_quality: Option, pub qobuz_quality: Option, #[serde(skip_serializing_if = "Option::is_none")] pub radio_stations: Option>, @@ -896,6 +976,11 @@ pub struct BehaviorConfig { /// Path to the `yt-dlp` binary used by the YouTube source. `None` resolves /// plain `yt-dlp` through `$PATH`. pub ytdlp_path: Option, + /// What plays behind the Native Streaming device; a change in Settings switches at once. + pub playback_engine: PlaybackEngine, + /// Path to the Soloist binary the user downloaded from Spotify. + pub soloist_path: Option, + pub soloist_audio_quality: SoloistQuality, /// Qobuz stream quality: 5 (MP3 320), 6 (FLAC 16/44.1), 7 (FLAC 24/96), /// 27 (FLAC 24/192). Default 6. pub qobuz_quality: u8, @@ -1362,6 +1447,9 @@ impl UserConfig { subsonic_username: None, subsonic_password: None, ytdlp_path: None, + playback_engine: PlaybackEngine::Librespot, + soloist_path: None, + soloist_audio_quality: SoloistQuality::Lossless, qobuz_quality: QOBUZ_QUALITY_DEFAULT, radio_stations: Vec::new(), // --- Phase 2: icons / glyphs / labels (defaults = today's glyphs) --- @@ -1972,6 +2060,15 @@ impl UserConfig { if let Some(ytdlp_path) = trim_to_none(behavior_config.ytdlp_path) { self.behavior.ytdlp_path = Some(ytdlp_path); } + if let Some(playback_engine) = behavior_config.playback_engine { + self.behavior.playback_engine = playback_engine; + } + if let Some(soloist_path) = trim_to_none(behavior_config.soloist_path) { + self.behavior.soloist_path = Some(soloist_path); + } + if let Some(quality) = behavior_config.soloist_audio_quality { + self.behavior.soloist_audio_quality = quality; + } if let Some(qobuz_quality) = behavior_config.qobuz_quality { self.behavior.qobuz_quality = qobuz_quality_or_default(qobuz_quality); } @@ -2410,6 +2507,9 @@ impl UserConfig { subsonic_username: self.behavior.subsonic_username.clone(), subsonic_password: self.behavior.subsonic_password.clone(), ytdlp_path: self.behavior.ytdlp_path.clone(), + playback_engine: Some(self.behavior.playback_engine), + soloist_path: self.behavior.soloist_path.clone(), + soloist_audio_quality: Some(self.behavior.soloist_audio_quality), qobuz_quality: Some(self.behavior.qobuz_quality), radio_stations: if self.behavior.radio_stations.is_empty() { None @@ -3200,6 +3300,19 @@ mod tests { assert_eq!(config.startup_behavior, None); } + #[test] + fn soloist_settings_parse_in_every_build() { + use super::{BehaviorConfigString, PlaybackEngine, SoloistQuality}; + + let config: BehaviorConfigString = serde_yaml::from_str( + "playback_engine: soloist\nsoloist_path: C:/soloist.exe\nsoloist_audio_quality: very_high", + ) + .unwrap(); + assert_eq!(config.playback_engine, Some(PlaybackEngine::Soloist)); + assert_eq!(config.soloist_path.as_deref(), Some("C:/soloist.exe")); + assert_eq!(config.soloist_audio_quality, Some(SoloistQuality::VeryHigh)); + } + #[test] fn tick_rates_load_defaults_explicit_values_and_legacy_defaults() { use super::{ diff --git a/src/gui/protocol.rs b/src/gui/protocol.rs index c93eefe3..f5f6f4f4 100644 --- a/src/gui/protocol.rs +++ b/src/gui/protocol.rs @@ -190,6 +190,16 @@ pub(crate) struct SettingRow { name: String, description: String, value: SettingRowValue, + /// The row's one button; pressing it sends `PressSettingButton`. + button: Option, +} + +#[derive(Serialize)] +#[cfg_attr(all(test, feature = "gui"), derive(ts_rs::TS))] +pub(crate) struct SettingRowButton { + label: String, + /// Its work runs, so the button is off. + busy: bool, } #[derive(Serialize)] @@ -239,6 +249,14 @@ const GUI_SETTINGS: &[(&str, &str)] = &[ ("behavior.streaming_bitrate", "streaming"), ("behavior.streaming_audio_cache", "streaming"), ("behavior.streaming_autoplay", "streaming"), + #[cfg(feature = "soloist")] + ("behavior.playback_engine", "streaming"), + #[cfg(feature = "soloist")] + ("behavior.soloist_path", "streaming"), + #[cfg(feature = "soloist")] + ("behavior.soloist_api_key", "streaming"), + #[cfg(feature = "soloist")] + ("behavior.soloist_audio_quality", "streaming"), #[cfg(feature = "discord-rpc")] ("behavior.enable_discord_rpc", "integrations"), ("behavior.enable_global_song_count", "integrations"), @@ -321,6 +339,29 @@ pub(crate) struct StatusPayload { message: Option, is_error: bool, api_error: Option, + /// The Soloist pairing guide to show in a modal. + soloist_pairing: Option, + /// The offer to set up Soloist to show in a modal. + soloist_offer: Option, +} + +#[derive(Serialize)] +#[cfg_attr(all(test, feature = "gui"), derive(ts_rs::TS))] +pub(crate) struct SoloistOffer { + title: &'static str, + body: &'static str, + steps: [&'static str; 3], + set_up: &'static str, + not_now: &'static str, +} + +#[derive(Serialize)] +#[cfg_attr(all(test, feature = "gui"), derive(ts_rs::TS))] +pub(crate) struct SoloistPairingGuide { + title: &'static str, + steps: Vec, + waiting: &'static str, + close: &'static str, } #[derive(Serialize)] @@ -843,6 +884,32 @@ fn channel_message( api_error: Some(app.api_error()) .filter(|error| !error.is_empty()) .map(str::to_string), + soloist_pairing: app.soloist_pairing_device().map(|device| { + let strings = &app.strings().popups; + SoloistPairingGuide { + title: strings.soloist_pair_title, + steps: vec![ + strings.soloist_pair_open.to_string(), + strings.soloist_pair_pick(device), + ], + waiting: strings.soloist_pair_waiting, + close: strings.soloist_pair_close, + } + }), + soloist_offer: app.soloist_offer_shows().then(|| { + let strings = &app.strings().popups; + SoloistOffer { + title: strings.soloist_offer_title, + body: strings.soloist_offer_body, + steps: [ + strings.soloist_offer_step_download, + strings.soloist_offer_step_key, + strings.soloist_offer_step_engine, + ], + set_up: strings.soloist_offer_set_up, + not_now: strings.soloist_offer_not_now, + } + }), }, }, DisplayDomain::Theme => ServerMessage::Theme { @@ -1083,6 +1150,12 @@ fn settings_for(app: &App, phone: bool) -> Vec { _ => item.description.clone(), }, value, + button: app + .setting_button_label(id) + .map(|(label, busy)| SettingRowButton { + label: label.to_string(), + busy, + }), }) }) .collect() @@ -1643,6 +1716,42 @@ mod tests { (App::new(tx, UserConfig::new(), Some(SystemTime::now())), rx) } + #[cfg(feature = "soloist")] + #[test] + fn the_status_channel_carries_the_soloist_pairing_guide_until_the_page_closes_it() { + let (mut app, _rx) = app(); + let before = app.display_revisions(); + app.soloist_notice(crate::infra::soloist::Notice::NotPaired); + let status = pushed(&diff(&before, &app, Language::En), "status"); + let pick = app + .strings() + .popups + .soloist_pair_pick(&app.user_config.behavior.streaming_device_name); + assert_eq!(status["payload"]["soloist_pairing"]["steps"][1], pick); + + let closed = apply_from_page(&mut app, &action_frame(Action::DismissSoloistPairing)); + assert!(pushed(&closed, "status")["payload"]["soloist_pairing"].is_null()); + } + + #[cfg(feature = "soloist")] + #[test] + fn the_status_channel_carries_the_soloist_offer_until_the_page_answers_it() { + let (mut app, _rx) = app(); + let before = app.display_revisions(); + app.offer_soloist_setup(); + let status = pushed(&diff(&before, &app, Language::En), "status"); + assert_eq!( + status["payload"]["soloist_offer"]["set_up"], + app.strings().popups.soloist_offer_set_up + ); + + let answered = apply_from_page( + &mut app, + &action_frame(Action::AnswerSoloistOffer { set_up: false }), + ); + assert!(pushed(&answered, "status")["payload"]["soloist_offer"].is_null()); + } + fn apply_from_page(app: &mut App, text: &str) -> Vec { let before = app.display_revisions(); let ClientMessage::Action { action } = serde_json::from_str(text).unwrap() else { diff --git a/src/infra/media_metadata.rs b/src/infra/media_metadata.rs index 098b20bc..17e7834e 100644 --- a/src/infra/media_metadata.rs +++ b/src/infra/media_metadata.rs @@ -175,7 +175,7 @@ fn playback_snapshot_with( let context_uri = context .and_then(|ctx| ctx.context.as_ref()) .map(|context| context.uri.clone()); - let source = if app.is_streaming_active { + let source = if app.is_streaming_active || app.soloist_is_target() { match app.native_playback_origin.unwrap_or_else(|| { if context_uri.is_some() { NativePlaybackOrigin::Context diff --git a/src/infra/mod.rs b/src/infra/mod.rs index 80c0d614..a0be63a0 100644 --- a/src/infra/mod.rs +++ b/src/infra/mod.rs @@ -33,6 +33,8 @@ pub mod radio; pub mod redirect_uri; #[cfg(feature = "scripting")] pub mod scripting; +#[cfg(feature = "soloist")] +pub mod soloist; #[cfg(feature = "spotify-session")] pub mod spotify; #[cfg(feature = "subsonic")] diff --git a/src/infra/network/AGENTS.md b/src/infra/network/AGENTS.md index 566874a0..f7547119 100644 --- a/src/infra/network/AGENTS.md +++ b/src/infra/network/AGENTS.md @@ -38,7 +38,9 @@ worth knowing before adding an event: the sink. One held back by a rate-limit window is stamped with the `PlaybackOwner` at deferral time, dropped at the flush when the owner changed, and otherwise re-sent on the pump's channel so the routers see it. - A new IoEvent must be classified against all four lists. + A new IoEvent must be classified against all four lists. One exception skips + the hold: while the Soloist engine is the target, the transports it serves over + its loopback WebSocket (`soloist_ws_transport`) go through at once. ### Listening Party / sync diff --git a/src/infra/network/library.rs b/src/infra/network/library.rs index 8c22c70c..b9d6016c 100644 --- a/src/infra/network/library.rs +++ b/src/infra/network/library.rs @@ -29,8 +29,6 @@ use std::sync::Arc; use std::time::Duration; use tokio::sync::Mutex; -#[cfg(feature = "streaming")] -use crate::infra::player::StreamingPlayer; #[cfg(feature = "streaming")] use librespot_core::SpotifyUri; #[cfg(feature = "streaming")] @@ -446,14 +444,20 @@ async fn fetch_playlist_tracks_page( } } +/// The native player's session, else the shared one, so a build or engine without librespot playback still reads folders. #[cfg(feature = "streaming")] async fn streaming_session(app: &Arc>) -> anyhow::Result { let player = { let app_guard = app.lock().await; app_guard.streaming_player.clone() + }; + if let Some(player) = player { + return Ok(player.session()); } - .ok_or_else(|| anyhow!("native streaming session is unavailable"))?; - Ok(player.session()) + crate::infra::spotify::session::current() + .await + .map(|session| session.session().clone()) + .map_err(|_| anyhow!("native streaming session is unavailable")) } #[cfg(feature = "streaming")] @@ -1354,20 +1358,17 @@ async fn finish_playlists_fetch( #[cfg(feature = "streaming")] let folder_nodes = { - let streaming_player = { - let app = app.lock().await; - app.streaming_player.clone() - }; - let nodes = fetch_rootlist_folders(streaming_player.clone()).await; + let session = streaming_session(&app).await.ok(); + let nodes = fetch_rootlist_folders(session.as_ref()).await; // Spotify-owned playlists (Release Radar, daylist, the Mixes, editorial // lists) sit in the rootlist, but the Development Mode Web API leaves // them out of `me/playlists`. Resolve those rootlist-only ids through the // librespot session so they show up in the sidebar. Their owner is // Spotify, so opening one takes the external-playlist librespot fallback. - if let (Some(nodes), Some(player)) = (nodes.as_ref(), streaming_player) { + if let (Some(nodes), Some(session)) = (nodes.as_ref(), session.as_ref()) { let missing = rootlist_only_playlist_ids(nodes, &all_playlists); if !missing.is_empty() { - let resolved = resolve_rootlist_only_playlists(&player.session(), &missing).await; + let resolved = resolve_rootlist_only_playlists(session, &missing).await; log::debug!( "rootlist-only playlists: {} missing from the Web API, {} resolved via librespot", missing.len(), @@ -2805,10 +2806,9 @@ mod tests { #[cfg(feature = "streaming")] async fn fetch_rootlist_folders( - streaming_player: Option>, + session: Option<&librespot_core::Session>, ) -> Option> { - let player = streaming_player?; - let session = player.session(); + let session = session?; let bytes = match session.spclient().get_rootlist(0, Some(100_000)).await { Ok(bytes) => bytes, diff --git a/src/infra/network/mod.rs b/src/infra/network/mod.rs index 9bccc40b..49e7517b 100644 --- a/src/infra/network/mod.rs +++ b/src/infra/network/mod.rs @@ -349,6 +349,13 @@ pub enum IoEvent { /// auth. #[cfg(feature = "art-decode")] FetchCoverArt(crate::core::art::CoverArtRequest), + /// Download the official Soloist build for this system from Spotify (the + /// Soloist Path row's button). Needs no Spotify login. + #[cfg(feature = "soloist")] + DownloadSoloist, + /// Stop Soloist, then start the engine the settings name (a new engine or API key). + #[cfg(feature = "soloist")] + ApplyPlaybackEngine, /// A DJ tool call that needs the live Spotify client, plus the channel to /// answer on. /// @@ -454,6 +461,23 @@ struct Deferred { owner: PlaybackOwner, } +/// The transports a Soloist target serves over its WebSocket, with no Spotify request. +#[cfg(feature = "soloist")] +fn soloist_ws_transport(io_event: &IoEvent) -> bool { + matches!( + io_event, + IoEvent::PausePlayback + | IoEvent::StartPlayback(None, None, None) + | IoEvent::NextTrack + | IoEvent::PreviousTrack + | IoEvent::ForcePreviousTrack + | IoEvent::Seek(_) + | IoEvent::ChangeVolume(_) + | IoEvent::Shuffle(_) + | IoEvent::Repeat(_) + ) +} + /// Whether `queued`, the owner at deferral time, still holds the sink under /// `now`. librespot and an external device are one Spotify owner: a poll can /// flip between them inside one window. @@ -502,6 +526,13 @@ impl Network { if matches!(io_event, IoEvent::FetchCoverArt(_)) { return true; } + #[cfg(feature = "soloist")] + if matches!( + io_event, + IoEvent::DownloadSoloist | IoEvent::ApplyPlaybackEngine + ) { + return true; + } #[cfg(feature = "streaming")] if matches!( io_event, @@ -590,6 +621,13 @@ impl Network { if matches!(io_event, IoEvent::FetchCoverArt(_)) { return true; } + #[cfg(feature = "soloist")] + if matches!( + io_event, + IoEvent::DownloadSoloist | IoEvent::ApplyPlaybackEngine + ) { + return true; + } #[cfg(feature = "ai-dj")] if matches!(io_event, IoEvent::AskDj(_) | IoEvent::DjTopUp(..)) { return true; @@ -1000,6 +1038,14 @@ impl Network { IoEvent::FetchCoverArt(request) => { self.fetch_cover_art(request).await; } + #[cfg(feature = "soloist")] + IoEvent::DownloadSoloist => { + crate::infra::soloist::download::run(std::sync::Arc::clone(&self.app)).await; + } + #[cfg(feature = "soloist")] + IoEvent::ApplyPlaybackEngine => { + crate::infra::soloist::startup::apply_engine(std::sync::Arc::clone(&self.app)).await; + } #[cfg(any(feature = "mcp-server", feature = "ai-dj"))] IoEvent::DjToolCall(payload) => { let (call, responder) = *payload; @@ -1180,6 +1226,11 @@ impl Network { let Some(left) = left else { return Some(io_event); }; + // These go to Soloist over its loopback WebSocket, never to Spotify's servers. + #[cfg(feature = "soloist")] + if soloist_ws_transport(&io_event) && self.app.lock().await.soloist_is_target() { + return Some(io_event); + } if !self.defers_rate_limited { tokio::time::sleep(left).await; return Some(io_event); diff --git a/src/infra/network/playback.rs b/src/infra/network/playback.rs index 39682fe8..dc58d894 100644 --- a/src/infra/network/playback.rs +++ b/src/infra/network/playback.rs @@ -474,6 +474,8 @@ async fn native_can_claim_player_command(network: &Network) -> bool { .is_some_and(|p| p.is_available()) || app.native_backend_pending || app.native_activation_pending + // Soloist is the device: a retry on the saved device must never pick another one. + || app.soloist_engine_installed() } #[cfg(not(feature = "streaming"))] { @@ -482,6 +484,13 @@ async fn native_can_claim_player_command(network: &Network) -> bool { } } +/// The Soloist link, when a Spotify command goes to Soloist over its WebSocket. +#[cfg(feature = "soloist")] +async fn soloist_target(network: &Network) -> Option> { + let app = network.app.lock().await; + app.soloist_link().filter(|_| app.soloist_is_target()) +} + /// The saved device to retry a player command on, or `None` to surface the /// original error. Sending `device_id` with `me/player/*` both targets and /// activates the device, which a bare command cannot do — without it every @@ -1301,6 +1310,9 @@ impl PlaybackNetwork for Network { { c.is_playing = false; } + // Soloist's WebSocket is newer than the cluster the poll read. + #[cfg(feature = "soloist")] + app.overlay_soloist_onto(&mut c); if !stale_api_item_for_native { // Cover art (Spotify album/episode image) is fetched by the shared @@ -1531,6 +1543,18 @@ impl PlaybackNetwork for Network { if decoded_source_owns_playback(self).await { return; } + #[cfg(feature = "soloist")] + let soloist = soloist_target(self).await; + // A restored Soloist is paused and inactive: activate it, then play. + #[cfg(feature = "soloist")] + if let (Some(link), None, None, None) = (&soloist, &context_id, &uris, offset) { + use crate::infra::soloist::protocol::Command; + if !link.snapshot().is_active { + link.send(Command::Activate); + } + link.send(Command::Play); + return; + } // A handed-off device holds no track: a bare resume pulls the last playback // over first, Spirc ignores a transfer once activated (#693). #[cfg(feature = "streaming")] @@ -1860,8 +1884,17 @@ impl PlaybackNetwork for Network { } let body = api_playback_body(context_id.as_ref(), uris.as_deref(), offset); + // WS `play` takes no offset, so Soloist starts go over connect-state, aimed at its id. + #[cfg(feature = "soloist")] + let query: Vec<(&str, String)> = soloist + .iter() + .map(|link| ("device_id", link.device_id().to_string())) + .collect(); + #[cfg(not(feature = "soloist"))] + let query: Vec<(&str, String)> = Vec::new(); let result = - player_command_with_saved_device_retry(self, Method::PUT, "me/player/play", &[], body).await; + player_command_with_saved_device_retry(self, Method::PUT, "me/player/play", &query, body) + .await; match result { Ok(_) => { @@ -2164,6 +2197,12 @@ impl PlaybackNetwork for Network { if decoded_source_owns_playback(self).await { return; } + #[cfg(feature = "soloist")] + if let Some(link) = soloist_target(self).await { + self.app.lock().await.pending_start_playback = None; + link.pause(); + return; + } #[cfg(feature = "streaming")] { let mut app = self.app.lock().await; @@ -2224,6 +2263,11 @@ impl PlaybackNetwork for Network { app.pending_start_playback = None; app.native_load_watchdog = None; } + #[cfg(feature = "soloist")] + if let Some(link) = soloist_target(self).await { + link.send(crate::infra::soloist::protocol::Command::SkipNext); + return; + } #[cfg(feature = "streaming")] if let PlaybackBackend::Native(player) = symmetric_playback_backend(self).await { player.next(); @@ -2252,6 +2296,11 @@ impl PlaybackNetwork for Network { app.pending_start_playback = None; app.native_load_watchdog = None; } + #[cfg(feature = "soloist")] + if let Some(link) = soloist_target(self).await { + link.send(crate::infra::soloist::protocol::Command::SkipPrev); + return; + } #[cfg(feature = "streaming")] if let PlaybackBackend::Native(player) = symmetric_playback_backend(self).await { player.prev(); @@ -2275,6 +2324,21 @@ impl PlaybackNetwork for Network { if decoded_source_owns_playback(self).await { return; } + #[cfg(feature = "soloist")] + if let Some(link) = soloist_target(self).await { + use crate::infra::soloist::protocol::Command; + link.send(Command::SkipPrev); + // The second skip_prev moves back once the position reset to 0, off the pump. + let app = Arc::clone(&self.app); + tokio::spawn(async move { + tokio::time::sleep(std::time::Duration::from_millis(500)).await; + let app = app.lock().await; + if app.soloist_is_target() && app.is_current_soloist(&link) { + link.send(Command::SkipPrev); + } + }); + return; + } #[cfg(feature = "streaming")] if let PlaybackBackend::Native(player) = symmetric_playback_backend(self).await { player.prev(); @@ -2338,6 +2402,13 @@ impl PlaybackNetwork for Network { if decoded_source_owns_playback(self).await { return; } + #[cfg(feature = "soloist")] + if let Some(link) = soloist_target(self).await { + link.send(crate::infra::soloist::protocol::Command::Seek( + position_ms.into(), + )); + return; + } #[cfg(feature = "streaming")] if let PlaybackBackend::Native(player) = symmetric_playback_backend(self).await { player.seek(position_ms); @@ -2367,6 +2438,18 @@ impl PlaybackNetwork for Network { if decoded_source_owns_playback(self).await { return; } + #[cfg(feature = "soloist")] + if let Some(link) = soloist_target(self).await { + link.send(crate::infra::soloist::protocol::Command::SetShuffle( + shuffle_state, + )); + let mut app = self.app.lock().await; + app.runtime_state.shuffle_enabled = shuffle_state; + app.schedule_state_save(crate::core::state::PersistedRuntimeState::shuffle_enabled( + shuffle_state, + )); + return; + } #[cfg(feature = "streaming")] if let PlaybackBackend::Native(player) = symmetric_playback_backend(self).await { let _ = player.set_shuffle(shuffle_state); @@ -2416,9 +2499,16 @@ impl PlaybackNetwork for Network { if decoded_source_owns_playback(self).await { return; } + #[cfg(feature = "soloist")] + if let Some(link) = soloist_target(self).await { + for command in crate::infra::soloist::protocol::repeat_commands(repeat_state) { + link.send(command); + } + return; + } #[cfg(feature = "streaming")] if let PlaybackBackend::Native(player) = symmetric_playback_backend(self).await { - let _ = player.set_repeat(repeat_state); + let _ = player.set_repeat_mode(repeat_state); let mut app = self.app.lock().await; app.set_native_recovery_repeat(repeat_state); if let Some(ctx) = &mut app.current_playback_context { @@ -2467,6 +2557,14 @@ impl PlaybackNetwork for Network { if decoded_source_owns_playback(self).await { return; } + #[cfg(feature = "soloist")] + if let Some(link) = soloist_target(self).await { + link.set_volume(volume.min(100)); + let mut app = self.app.lock().await; + app.is_volume_change_in_flight = false; + app.last_dispatched_volume = Some(volume); + return; + } #[cfg(feature = "streaming")] if let PlaybackBackend::Native(player) = symmetric_playback_backend(self).await { player.set_volume(volume); diff --git a/src/infra/network/user.rs b/src/infra/network/user.rs index d9a5bbc0..98fdc459 100644 --- a/src/infra/network/user.rs +++ b/src/infra/network/user.rs @@ -51,14 +51,22 @@ fn include_native_streaming_device( _ => return, }; - // The parked row matches by id: another device can carry the same name. - if let Some(device) = payload.devices.iter_mut().find(|device| { - if parked { - device.id.as_deref() == Some(device_id.as_str()) - } else { - device.name.eq_ignore_ascii_case(device_name) - } - }) { + // Match by id first: another device can carry the same name. Only a live + // backend falls back to the name, for a row that has no id yet. + let position = payload + .devices + .iter() + .position(|device| device.id.as_deref() == Some(device_id.as_str())) + .or_else(|| { + if parked { + return None; + } + payload + .devices + .iter() + .position(|device| device.name.eq_ignore_ascii_case(device_name)) + }); + if let Some(device) = position.map(|index| &mut payload.devices[index]) { if parked { device.is_active = false; } else if device.id.is_none() { diff --git a/src/infra/player/events.rs b/src/infra/player/events.rs index f2d6a19a..251070e0 100644 --- a/src/infra/player/events.rs +++ b/src/infra/player/events.rs @@ -79,6 +79,8 @@ pub struct PlayerEventContext { pub windows_media_manager: Option>, } +// Only a frontend launch starts the supervisor (`runtime/startup.rs`). +#[cfg_attr(not(any(feature = "tui", feature = "gui")), allow(dead_code))] pub struct StreamingRecoveryContext { pub app: Arc>, pub shared_position: Arc, @@ -95,6 +97,7 @@ pub struct StreamingRecoveryContext { pub windows_media_manager: Option>, } +#[cfg_attr(not(any(feature = "tui", feature = "gui")), allow(dead_code))] pub fn spawn_streaming_recovery_handler(ctx: StreamingRecoveryContext) { tokio::spawn(async move { handle_streaming_recovery(ctx).await; @@ -1261,6 +1264,7 @@ async fn handle_player_events( let mut app = app.lock().await; let strings = app.strings(); app.set_status_message(strings.status.infra.native_many_unavailable, 20); + app.offer_soloist_setup(); } } PlayerEvent::Loading { diff --git a/src/infra/soloist/AGENTS.md b/src/infra/soloist/AGENTS.md new file mode 100644 index 00000000..c59c1961 --- /dev/null +++ b/src/infra/soloist/AGENTS.md @@ -0,0 +1,41 @@ +### The Soloist engine (feature `soloist`) + +Spotify Soloist plays the audio behind spotatui's own Connect device when +`behavior.playback_engine: soloist`. Soloist IS the device: it registers itself +under `streaming_device_name`, and spotatui starts no librespot Spirc device. + +- In soloist mode `App.streaming_player` and `streaming_recovery_tx` stay `None` + and `native_parked` is never set, so every librespot path stays off. The owner + stays `PlaybackOwner::Spotify`; the engine adds narrow + `App::soloist_is_target()` arms instead of a new owner. +- The librespot recovery supervisor runs under both engines. Under Soloist its + sender waits in `App.librespot_standby`. A Playback Engine change in Settings + dispatches `IoEvent::ApplyPlaybackEngine`: Soloist stops, then + `App::apply_playback_engine` shuts librespot down before Soloist starts, or + moves the sender back and asks for an idle librespot player. A rebuild that + ends under Soloist is refused (`accept_rebuilt_native_backend`). +- When librespot fails three tracks in a row (the audio-key block), the player + event handler calls `App::offer_soloist_setup`: once per run, a modal offers + Soloist and opens Settings at the Playback Engine row. +- Call sites outside this folder and `core/app/soloist.rs` use only the + ungated bool predicates + `soloist_is_target()` and `soloist_engine_installed()`. +- Every write from the supervisor or the WebSocket reader goes through an `App` + method that checks `Arc::ptr_eq` against the installed link first. A restart + installs a new link, so a reader of a replaced child cannot write. +- Transports go over the loopback WebSocket. Context and URI-list starts go over + connect-state with `device_id=`, because WS `play` takes no offset; + they pay one round trip on the serial pump. WS transports skip the 429 hold + only while Soloist is the target (`soloist_ws_transport`). +- State comes from WebSocket events, never from `command_result`, which only + means "dispatched". +- The API key goes on Soloist's argv (no other input exists), so other local + programs can read it. Never log the argv, the key, or the account name: + `logline::redact` runs on every stdout line before it reaches the log. +- The playback keys in `settings/Users/*/prefs` (quality, + `audio.normalize_v2=false`) are undocumented internal state; the official + quality path is the per-device menu in the Spotify mobile app. + The playbar badge shows what the log measured, never the setting. +- Never host, bundle or redistribute the Soloist binary. The Soloist Path + row's button downloads the official build from Spotify's CDN + (`download.rs`), and only when the user presses it. diff --git a/src/infra/soloist/CLAUDE.md b/src/infra/soloist/CLAUDE.md new file mode 100644 index 00000000..43c994c2 --- /dev/null +++ b/src/infra/soloist/CLAUDE.md @@ -0,0 +1 @@ +@AGENTS.md diff --git a/src/infra/soloist/auth.rs b/src/infra/soloist/auth.rs new file mode 100644 index 00000000..9c41ada0 --- /dev/null +++ b/src/infra/soloist/auth.rs @@ -0,0 +1,113 @@ +//! The Soloist API key: `SPOTATUI_SOLOIST_API_KEY`, else `soloist_credentials.yml`. + +use std::path::Path; + +use serde::{Deserialize, Serialize}; + +pub const KEY_ENV: &str = "SPOTATUI_SOLOIST_API_KEY"; + +/// A Soloist API key; `Debug` never prints it. +#[derive(Clone, PartialEq, Eq)] +pub struct ApiKey(String); + +impl ApiKey { + pub fn expose(&self) -> &str { + &self.0 + } +} + +impl std::fmt::Debug for ApiKey { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("ApiKey(***)") + } +} + +#[derive(Serialize, Deserialize)] +struct Saved { + api_key: String, +} + +pub fn read_saved(path: &Path) -> Option { + let text = std::fs::read_to_string(path).ok()?; + serde_yaml::from_str::(&text) + .ok() + .map(|saved| saved.api_key) +} + +/// The env value first, then the saved one; blank counts as missing. +pub fn key_with(env: Option, saved: Option) -> Option { + [env, saved] + .into_iter() + .flatten() + .map(|key| key.trim().to_string()) + .find(|key| !key.is_empty()) + .map(ApiKey) +} + +/// Write the key to the credentials file; a blank key removes the file. +pub fn save_key(path: &Path, key: &str) -> anyhow::Result<()> { + let key = key.trim(); + if key.is_empty() { + return match std::fs::remove_file(path) { + Err(error) if error.kind() != std::io::ErrorKind::NotFound => Err(error.into()), + _ => Ok(()), + }; + } + if let Some(dir) = path.parent() { + std::fs::create_dir_all(dir)?; + } + let text = serde_yaml::to_string(&Saved { + api_key: key.to_string(), + })?; + crate::core::auth::write_private_file_atomic(path, text.as_bytes()) +} + +pub fn current_key() -> Option { + key_with( + std::env::var(KEY_ENV).ok(), + crate::core::paths::soloist_credentials_path().and_then(|path| read_saved(&path)), + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn env_key_overrides_the_saved_key() { + let key = key_with(Some("env".to_string()), Some("saved".to_string())); + assert_eq!(key.unwrap().expose(), "env"); + } + + #[test] + fn empty_keys_count_as_missing() { + assert_eq!(key_with(Some(" ".to_string()), Some(String::new())), None); + let key = key_with(Some(" ".to_string()), Some(" saved ".to_string())); + assert_eq!(key.unwrap().expose(), "saved"); + } + + #[test] + fn debug_redacts_the_key() { + let key = key_with(Some("secret".to_string()), None).unwrap(); + assert_eq!(format!("{key:?}"), "ApiKey(***)"); + } + + #[test] + fn the_saved_file_holds_api_key() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("soloist_credentials.yml"); + std::fs::write(&path, "api_key: abc\n").unwrap(); + assert_eq!(read_saved(&path).as_deref(), Some("abc")); + } + + #[test] + fn a_saved_key_reads_back_and_a_blank_one_removes_the_file() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("spotatui").join("soloist_credentials.yml"); + save_key(&path, " k: ey ").unwrap(); + assert_eq!(read_saved(&path).as_deref(), Some("k: ey")); + save_key(&path, "").unwrap(); + assert!(!path.exists()); + save_key(&path, "").unwrap(); + } +} diff --git a/src/infra/soloist/download.rs b/src/infra/soloist/download.rs new file mode 100644 index 00000000..39cb6cb9 --- /dev/null +++ b/src/infra/soloist/download.rs @@ -0,0 +1,235 @@ +//! Gets the official Soloist build for this system from Spotify's CDN when the user asks. +//! spotatui never hosts or bundles the binary. + +use std::path::{Path, PathBuf}; +use std::process::Stdio; +use std::sync::Arc; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +use tokio::io::AsyncWriteExt; +use tokio::sync::Mutex; + +use crate::core::app::App; + +const CDN: &str = "https://soloist-builds.spotifycdn.com/"; +const BINARY: &str = if cfg!(windows) { + "soloist.exe" +} else { + "soloist" +}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Failure { + /// Spotify publishes no build for this OS and CPU. + NoBuild, + Failed(String), +} + +fn failed(error: impl std::fmt::Display) -> Failure { + Failure::Failed(error.to_string()) +} + +/// The archive Spotify publishes for `os` and `arch`, in `std::env::consts` names. +pub fn archive_name(os: &str, arch: &str) -> Option<&'static str> { + match (os, arch) { + ("linux", "x86_64") => Some("soloist_release_x86_64.tar.gz"), + ("linux", "aarch64") => Some("soloist_release_arm64.tar.gz"), + ("linux", "arm") => Some("soloist_release_arm32.tar.gz"), + ("macos", "aarch64") => Some("soloist_release_osx_arm64.tar.gz"), + ("windows", "x86_64") => Some("soloist_release_windows_x86_64.zip"), + _ => None, + } +} + +/// The share of `total` in whole percent; `None` when the size is unknown. +fn percent(received: u64, total: Option) -> Option { + let total = total.filter(|total| *total > 0)?; + Some((received.min(total) * 100 / total) as u8) +} + +/// The binary in an extracted archive, at most three folders down. +fn find_binary(dir: &Path, depth: u8) -> Option { + let entries: Vec<_> = std::fs::read_dir(dir).ok()?.flatten().collect(); + if let Some(entry) = entries + .iter() + .find(|entry| entry.file_name() == BINARY && entry.path().is_file()) + { + return Some(entry.path()); + } + if depth == 0 { + return None; + } + entries + .iter() + .filter(|entry| entry.path().is_dir()) + .find_map(|entry| find_binary(&entry.path(), depth - 1)) +} + +/// Download, extract, and return the new binary; the App then saves its path. +pub async fn run(app: Arc>) { + let result = install(&app).await; + if let Err(Failure::Failed(error)) = &result { + log::warn!("soloist: download failed: {error}"); + } + let start = app.lock().await.soloist_download_finished(result); + if let Some(start) = start { + super::startup::deferred_soloist_startup(app, start); + } +} + +async fn install(app: &Arc>) -> Result { + let name = archive_name(std::env::consts::OS, std::env::consts::ARCH).ok_or(Failure::NoBuild)?; + let root = crate::core::paths::soloist_bin_dir().ok_or_else(|| failed("no state directory"))?; + let stamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|since| since.as_secs()) + .unwrap_or_default(); + // A new folder per build: Windows cannot replace a running soloist.exe. + let dir = root.join(stamp.to_string()); + tokio::fs::create_dir_all(&dir).await.map_err(failed)?; + let archive = root.join(format!(".{name}")); + let result = async { + fetch(app, &format!("{CDN}{name}"), &archive).await?; + extract(&archive, &dir).await?; + find_binary(&dir, 3).ok_or_else(|| failed(format!("no {BINARY} in {name}"))) + } + .await; + let _ = tokio::fs::remove_file(&archive).await; + match &result { + Ok(binary) => { + log::info!("soloist: downloaded {name} to {}", binary.display()); + remove_other_builds(&root, &dir); + } + Err(_) => { + let _ = tokio::fs::remove_dir_all(&dir).await; + } + } + result +} + +async fn fetch(app: &Arc>, url: &str, to: &Path) -> Result<(), Failure> { + let client = reqwest::Client::builder() + .user_agent(concat!("spotatui/", env!("CARGO_PKG_VERSION"))) + .connect_timeout(Duration::from_secs(15)) + .read_timeout(Duration::from_secs(30)) + .build() + .map_err(failed)?; + let mut response = client + .get(url) + .send() + .await + .and_then(reqwest::Response::error_for_status) + .map_err(failed)?; + let total = response.content_length(); + let mut file = tokio::fs::File::create(to).await.map_err(failed)?; + let mut received = 0; + let mut shown = 0; + while let Some(chunk) = response.chunk().await.map_err(failed)? { + file.write_all(&chunk).await.map_err(failed)?; + received += chunk.len() as u64; + if let Some(now) = percent(received, total).filter(|now| *now >= shown + 10) { + shown = now; + app.lock().await.soloist_download_progress(now); + } + } + file.flush().await.map_err(failed) +} + +/// The system tar reads both archive kinds: Windows 10 and later ship bsdtar, which reads zip. +async fn extract(archive: &Path, into: &Path) -> Result<(), Failure> { + // A GNU tar earlier on PATH (Git for Windows) cannot read zip. + #[cfg(windows)] + let program = std::env::var_os("SystemRoot") + .map(|root| Path::new(&root).join("System32").join("tar.exe")) + .unwrap_or_else(|| PathBuf::from("tar")); + #[cfg(not(windows))] + let program = PathBuf::from("tar"); + let mut command = tokio::process::Command::new(program); + command + .arg("-xf") + .arg(archive) + .arg("-C") + .arg(into) + .stdin(Stdio::null()) + .kill_on_drop(true); + #[cfg(windows)] + command.creation_flags(super::supervisor::CREATE_NO_WINDOW); + let output = command.output().await.map_err(failed)?; + if output.status.success() { + return Ok(()); + } + let stderr = String::from_utf8_lossy(&output.stderr); + Err(failed(format!("tar: {}", stderr.trim()))) +} + +/// Older downloads; one that still runs on Windows stays until the next download. +fn remove_other_builds(root: &Path, keep: &Path) { + let Ok(entries) = std::fs::read_dir(root) else { + return; + }; + for entry in entries.flatten() { + let path = entry.path(); + if path != keep && path.is_dir() { + let _ = std::fs::remove_dir_all(&path); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn each_published_build_maps_to_its_archive() { + assert_eq!( + archive_name("windows", "x86_64"), + Some("soloist_release_windows_x86_64.zip") + ); + assert_eq!( + archive_name("macos", "aarch64"), + Some("soloist_release_osx_arm64.tar.gz") + ); + assert_eq!( + archive_name("linux", "arm"), + Some("soloist_release_arm32.tar.gz") + ); + assert_eq!(archive_name("macos", "x86_64"), None); + assert_eq!(archive_name("windows", "aarch64"), None); + } + + #[test] + fn progress_needs_a_known_size_and_stops_at_one_hundred() { + assert_eq!(percent(50, Some(200)), Some(25)); + assert_eq!(percent(300, Some(200)), Some(100)); + assert_eq!(percent(50, None), None); + assert_eq!(percent(50, Some(0)), None); + } + + #[test] + fn the_binary_is_found_at_the_top_or_in_a_folder() { + let top = tempfile::tempdir().unwrap(); + std::fs::write(top.path().join("CHANGELOG.md"), "").unwrap(); + std::fs::write(top.path().join(BINARY), "").unwrap(); + assert_eq!(find_binary(top.path(), 3), Some(top.path().join(BINARY))); + + let nested = tempfile::tempdir().unwrap(); + let folder = nested.path().join("soloist_release_x86_64"); + std::fs::create_dir(&folder).unwrap(); + std::fs::create_dir(nested.path().join(BINARY)).unwrap(); + std::fs::write(folder.join(BINARY), "").unwrap(); + assert_eq!(find_binary(nested.path(), 3), Some(folder.join(BINARY))); + assert_eq!(find_binary(nested.path(), 0), None); + } + + #[test] + fn other_builds_are_removed_and_the_new_one_stays() { + let root = tempfile::tempdir().unwrap(); + let old = root.path().join("100"); + let new = root.path().join("200"); + std::fs::create_dir(&old).unwrap(); + std::fs::create_dir(&new).unwrap(); + remove_other_builds(root.path(), &new); + assert!(!old.exists()); + assert!(new.exists()); + } +} diff --git a/src/infra/soloist/link.rs b/src/infra/soloist/link.rs new file mode 100644 index 00000000..efda9fa3 --- /dev/null +++ b/src/infra/soloist/link.rs @@ -0,0 +1,278 @@ +//! The handle the App holds for the running Soloist child. + +use std::sync::Mutex; +use std::time::{Duration, Instant}; + +use tokio::sync::mpsc::UnboundedSender; + +use super::protocol::{Command, Event, PositionAnchor, Repeat, Status}; + +/// What the WebSocket last said about Soloist. +#[derive(Debug, Clone, Default, PartialEq)] +pub struct Snapshot { + pub logged_in: bool, + pub is_active: bool, + pub status: Status, + pub item_is_episode: bool, + pub position: Option, + pub volume: Option, + pub shuffle: bool, + pub repeat: Repeat, +} + +impl Snapshot { + fn apply(&mut self, event: &Event) { + match event { + Event::Auth { + logged_in, + is_active, + } => { + self.logged_in = *logged_in; + self.is_active = *is_active; + } + Event::State(state) => { + self.logged_in = true; + self.is_active = state.is_active; + self.status = state.status; + self.item_is_episode = state.item_is_episode; + self.position = state.position; + self.volume = state.volume.or(self.volume); + self.shuffle = state.shuffle; + self.repeat = state.repeat; + } + Event::Track { is_episode, .. } => self.item_is_episode = *is_episode, + Event::Playback(status) => self.status = *status, + Event::Volume(volume) => self.volume = Some(*volume), + Event::Device { is_active } => self.is_active = *is_active, + Event::Context(_) => {} + Event::Options { shuffle, repeat } => { + self.shuffle = shuffle.unwrap_or(self.shuffle); + self.repeat = repeat.unwrap_or(self.repeat); + } + Event::Position(anchor) => self.position = Some(*anchor), + Event::Error(_) => {} + } + } +} + +/// The stream Soloist reports in its log for the current track. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct StreamQuality { + pub codec: String, + pub sample_rate: Option, + pub bitrate: Option, +} + +impl StreamQuality { + pub fn is_lossless(&self) -> bool { + self.codec == "flac" + } + + /// `FLAC 44.1` or `Vorbis 320`, worded like the Qobuz badge. + pub fn label(&self) -> String { + if self.is_lossless() { + return match self.sample_rate { + Some(rate) if rate % 1000 == 0 => format!("FLAC {}", rate / 1000), + Some(rate) => format!("FLAC {:.1}", f64::from(rate) / 1000.0), + None => "FLAC".to_string(), + }; + } + let codec = match self.codec.as_str() { + "vorbis" => "Vorbis".to_string(), + "mp3" | "aac" => self.codec.to_ascii_uppercase(), + other => other.to_string(), + }; + match self.bitrate { + Some(bitrate) => format!("{codec} {}", bitrate / 1000), + None => codec, + } + } +} + +const VOLUME_WINDOW: Duration = Duration::from_millis(150); +/// How long a volume echo that differs from the last request is ignored. +const VOLUME_ECHO: Duration = Duration::from_secs(1); + +/// At most one volume send per window; the latest value wins. +#[derive(Debug, Default)] +pub struct VolumeGate { + last_sent: Option, + pending: Option, +} + +impl VolumeGate { + /// The value to send now, or `None` when it waits for the window. + pub fn offer(&mut self, volume: u8, now: Instant) -> Option { + match self.last_sent { + Some(sent) if now.saturating_duration_since(sent) < VOLUME_WINDOW => { + self.pending = Some(volume); + None + } + _ => { + self.last_sent = Some(now); + self.pending = None; + Some(volume) + } + } + } + + /// When the waiting value may go out. + pub fn deadline(&self) -> Option { + self + .pending + .and(self.last_sent.map(|sent| sent + VOLUME_WINDOW)) + } + + pub fn due(&mut self, now: Instant) -> Option { + let volume = self.pending?; + (now >= self.deadline()?).then(|| { + self.pending = None; + self.last_sent = Some(now); + volume + }) + } +} + +pub struct SoloistLink { + device_id: String, + commands: UnboundedSender, + snapshot: Mutex, + wanted_volume: Mutex>, + quality: Mutex>, +} + +impl SoloistLink { + pub fn new(device_id: String, commands: UnboundedSender) -> Self { + Self { + device_id, + commands, + snapshot: Mutex::new(Snapshot::default()), + wanted_volume: Mutex::new(None), + quality: Mutex::new(None), + } + } + + pub fn device_id(&self) -> &str { + &self.device_id + } + + pub fn send(&self, command: Command) { + let _ = self.commands.send(command); + } + + pub fn snapshot(&self) -> Snapshot { + self.snapshot.lock().map(|s| s.clone()).unwrap_or_default() + } + + /// Pause unless Soloist is already paused or idle: a pause there is refused. + pub fn pause(&self) { + if self.snapshot().status.is_playing() { + self.send(Command::Pause); + } + } + + pub fn set_volume(&self, volume: u8) { + if let Ok(mut wanted) = self.wanted_volume.lock() { + *wanted = Some((volume, Instant::now())); + } + self.send(Command::SetVolume(volume)); + } + + /// Fold an event into the snapshot; a stale volume echo during a held key is dropped. + pub fn apply(&self, event: &Event) { + if let Event::Volume(volume) = event { + if let Ok(mut wanted) = self.wanted_volume.lock() { + match *wanted { + Some((want, _)) if want == *volume => *wanted = None, + Some((_, at)) if at.elapsed() < VOLUME_ECHO => return, + _ => *wanted = None, + } + } + } + if let Ok(mut snapshot) = self.snapshot.lock() { + snapshot.apply(event); + } + } + + pub fn quality(&self) -> Option { + self.quality.lock().ok().and_then(|q| q.clone()) + } + + pub fn set_quality(&self, quality: StreamQuality) { + if let Ok(mut slot) = self.quality.lock() { + *slot = Some(quality); + } + } + + #[cfg(test)] + pub fn test_pair( + device_id: &str, + ) -> ( + std::sync::Arc, + tokio::sync::mpsc::UnboundedReceiver, + ) { + let (tx, rx) = tokio::sync::mpsc::unbounded_channel(); + ( + std::sync::Arc::new(Self::new(device_id.to_string(), tx)), + rx, + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn volume_gate_sends_only_the_latest_value_per_window() { + let start = Instant::now(); + let mut gate = VolumeGate::default(); + assert_eq!(gate.offer(10, start), Some(10)); + assert_eq!(gate.offer(11, start + Duration::from_millis(20)), None); + assert_eq!(gate.offer(12, start + Duration::from_millis(40)), None); + assert_eq!(gate.due(start + Duration::from_millis(100)), None); + assert_eq!(gate.deadline(), Some(start + VOLUME_WINDOW)); + assert_eq!(gate.due(start + VOLUME_WINDOW), Some(12)); + assert_eq!(gate.deadline(), None); + } + + #[test] + fn volume_echo_during_a_held_key_does_not_move_the_snapshot() { + let (link, _rx) = SoloistLink::test_pair("dev"); + link.set_volume(40); + link.apply(&Event::Volume(35)); + assert_eq!(link.snapshot().volume, None); + link.apply(&Event::Volume(40)); + assert_eq!(link.snapshot().volume, Some(40)); + link.apply(&Event::Volume(30)); + assert_eq!(link.snapshot().volume, Some(30)); + } + + #[test] + fn pause_is_skipped_when_soloist_is_already_paused() { + let (link, mut rx) = SoloistLink::test_pair("dev"); + link.pause(); + assert!(rx.try_recv().is_err()); + link.apply(&Event::Playback(Status::Playing)); + link.pause(); + assert_eq!(rx.try_recv().ok(), Some(Command::Pause)); + } + + #[test] + fn quality_labels_follow_the_qobuz_wording() { + let flac = StreamQuality { + codec: "flac".to_string(), + sample_rate: Some(44100), + bitrate: Some(812_000), + }; + assert_eq!(flac.label(), "FLAC 44.1"); + assert!(flac.is_lossless()); + let vorbis = StreamQuality { + codec: "vorbis".to_string(), + sample_rate: Some(44100), + bitrate: Some(160_000), + }; + assert_eq!(vorbis.label(), "Vorbis 160"); + assert!(!vorbis.is_lossless()); + } +} diff --git a/src/infra/soloist/logline.rs b/src/infra/soloist/logline.rs new file mode 100644 index 00000000..9bf567e3 --- /dev/null +++ b/src/infra/soloist/logline.rs @@ -0,0 +1,297 @@ +//! Soloist's stdout: the facts spotatui reads from it, and redaction. +//! +//! A line looks like `09:50:11.277 I [/f:462 ] ready, device_name=..`, with an +//! optional `YYYY-MM-DD HH:MM:SS.mmm: ` prefix (spotify/soloist#18). + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum LogEvent { + Ready { device_id: String }, + WsListening { port: u16 }, + WaitingForLogin, + LoginFailed, + ExpiresIn { count: u32, unit: String }, + Expired, + AnotherSession, + Codec(String), + SampleRate(u32), + AverageBitrate(u32), +} + +/// The message part of a line, after the time, level and source field. +fn message(line: &str) -> &str { + match line.find("] ") { + Some(end) if line[..end].contains('[') => &line[end + 2..], + _ => line, + } +} + +/// The digits after `marker`, and the text after those digits. +fn number_after<'a>(text: &'a str, marker: &str) -> Option<(&'a str, &'a str)> { + let start = text.find(marker)? + marker.len(); + let rest = text[start..].trim_start(); + let end = rest + .find(|c: char| !c.is_ascii_digit()) + .unwrap_or(rest.len()); + (end > 0).then(|| rest.split_at(end)) +} + +pub fn parse_line(line: &str) -> Option { + let text = message(line); + let trimmed = text.trim(); + if let Some(start) = text.find("device_id=") { + if text.contains("ready, device_name=") { + let id = text[start + "device_id=".len()..] + .split_whitespace() + .next()?; + return Some(LogEvent::Ready { + device_id: id.to_string(), + }); + } + } + if let Some(start) = text.find("websocket server listening on ") { + let address = text[start + "websocket server listening on ".len()..].trim(); + let port = address.rsplit(':').next()?.parse().ok()?; + return Some(LogEvent::WsListening { port }); + } + if text.contains("waiting for login") { + return Some(LogEvent::WaitingForLogin); + } + if text.contains("login failed") { + return Some(LogEvent::LoginFailed); + } + if let Some((count, after)) = number_after(text, "client expires in ") { + return Some(LogEvent::ExpiresIn { + count: count.parse().ok()?, + unit: after + .split_whitespace() + .next() + .unwrap_or_default() + .to_string(), + }); + } + if text.contains("client expired") || text.contains("build expired") { + return Some(LogEvent::Expired); + } + if text.contains("another session is running") { + return Some(LogEvent::AnotherSession); + } + if let Some(codec) = trimmed.strip_prefix("codec: ") { + return Some(LogEvent::Codec(codec.trim().to_ascii_lowercase())); + } + if trimmed.starts_with("sample rate: ") { + return number_after(trimmed, "sample rate: ")? + .0 + .parse() + .ok() + .map(LogEvent::SampleRate); + } + if let Some((bitrate, _)) = number_after(text, "FileStreamer file average bitrate: ") { + return bitrate.parse().ok().map(LogEvent::AverageBitrate); + } + None +} + +/// Remove ANSI escape sequences (`ESC [ ... letter`). +pub fn strip_ansi(line: &str) -> String { + let mut out = String::with_capacity(line.len()); + let mut chars = line.chars().peekable(); + while let Some(c) = chars.next() { + if c == '\u{1b}' { + if chars.peek() == Some(&'[') { + chars.next(); + for next in chars.by_ref() { + if next.is_ascii_alphabetic() { + break; + } + } + } + continue; + } + out.push(c); + } + out +} + +fn mask_after(line: &mut String, marker: &str, stop: impl Fn(char) -> bool) { + let mut from = 0; + while let Some(found) = line[from..].find(marker) { + let start = from + found + marker.len(); + let end = line[start..] + .find(&stop) + .map_or(line.len(), |end| start + end); + from = if end > start { + line.replace_range(start..end, "***"); + start + 3 + } else { + start + }; + } +} + +/// The line without the API key, the account name and URL query strings. +pub fn redact(line: &str, api_key: &str) -> String { + let mut out = if api_key.is_empty() { + line.to_string() + } else { + line.replace(api_key, "***") + }; + mask_after(&mut out, "session:", |c| c == ']' || c.is_whitespace()); + mask_after(&mut out, "logged in as ", char::is_whitespace); + mask_after(&mut out, "Users\\", |c| c == '\\' || c.is_whitespace()); + mask_after(&mut out, "Users/", |c| c == '/' || c.is_whitespace()); + for scheme in ["http://", "https://"] { + let mut from = 0; + while let Some(found) = out[from..].find(scheme) { + let start = from + found; + let end = out[start..] + .find(char::is_whitespace) + .map_or(out.len(), |end| start + end); + if let Some(query) = out[start..end].find('?') { + out.replace_range(start + query..end, "?***"); + } + from = (start + scheme.len()).min(out.len()); + } + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_line_reads_the_ready_line_device_id() { + let line = "09:50:11.277 I [/f:462 ] ready, device_name=spotatui device_id=70623e1b-ddb7-411c-8fb7-06be482a580e"; + assert_eq!( + parse_line(line), + Some(LogEvent::Ready { + device_id: "70623e1b-ddb7-411c-8fb7-06be482a580e".to_string() + }) + ); + } + + #[test] + fn parse_line_reads_the_websocket_port() { + let line = "09:50:11.277 I [/f:1021 ] api: websocket server listening on 127.0.0.1:54321"; + assert_eq!( + parse_line(line), + Some(LogEvent::WsListening { port: 54321 }) + ); + } + + #[test] + fn parse_line_spots_waiting_for_login_with_the_dash() { + let line = "09:43:32.275 I [/f:475 ] waiting for login \u{2014} connect to \"spotatui\" from your Spotify app"; + assert_eq!(parse_line(line), Some(LogEvent::WaitingForLogin)); + } + + #[test] + fn parse_line_strips_the_timestamp_prefix() { + let line = "2026-10-25 09:50:11.537: 09:50:11.537 I [/f:415 ] waiting for login"; + assert_eq!(parse_line(line), Some(LogEvent::WaitingForLogin)); + } + + #[test] + fn parse_line_spots_login_failed_and_another_session() { + assert_eq!( + parse_line("E [/f:1 ] login failed: make sure --api-key is valid"), + Some(LogEvent::LoginFailed) + ); + assert_eq!( + parse_line("E [/f:1 ] another session is running for data directory"), + Some(LogEvent::AnotherSession) + ); + } + + #[test] + fn parse_line_reads_expiry_count_and_unit() { + let line = "09:50:11.221 I [/f:128 ] client expires in 88 days"; + assert_eq!( + parse_line(line), + Some(LogEvent::ExpiresIn { + count: 88, + unit: "days".to_string() + }) + ); + } + + #[test] + fn the_expiry_unit_follows_the_count_even_when_the_digits_appear_earlier() { + let line = "build 1.3.10 client expires in 10 days"; + assert_eq!( + parse_line(line), + Some(LogEvent::ExpiresIn { + count: 10, + unit: "days".to_string() + }) + ); + } + + #[test] + fn parse_line_spots_both_expired_texts() { + assert_eq!( + parse_line( + "E [/f:1 ] client expired, please update to a newer version. Exiting with exit code 10" + ), + Some(LogEvent::Expired) + ); + assert_eq!( + parse_line("Spotify Soloist build expired"), + Some(LogEvent::Expired) + ); + } + + #[test] + fn parse_line_reads_codec_sample_rate_and_average_bitrate() { + assert_eq!( + parse_line("09:50:21.819 I [/f:347 ] codec: vorbis"), + Some(LogEvent::Codec("vorbis".to_string())) + ); + assert_eq!( + parse_line("09:50:21.819 I [/f:349 ] sample rate: 44100 Hz"), + Some(LogEvent::SampleRate(44100)) + ); + assert_eq!( + parse_line("09:50:21.816 I [/f:134 ] FileStreamer file average bitrate: 160000"), + Some(LogEvent::AverageBitrate(160000)) + ); + } + + #[test] + fn parse_line_ignores_unknown_lines() { + assert_eq!( + parse_line("09:50:11.468 I [/f:98 ] DSP CPU features: SSE1"), + None + ); + } + + #[test] + fn redact_removes_the_key_from_a_line() { + assert_eq!(redact("args -k abc123 -n x", "abc123"), "args -k *** -n x"); + } + + #[test] + fn redact_masks_the_account_name_and_url_queries() { + assert_eq!( + redact( + "Session restored from store, session: [session:someone]", + "" + ), + "Session restored from store, session: [session:***]" + ); + assert_eq!(redact("logged in as someone", ""), "logged in as ***"); + assert_eq!( + redact(r"open C:\soloist\settings\Users\someone\prefs", ""), + r"open C:\soloist\settings\Users\***\prefs" + ); + assert_eq!( + redact("GET https://host/path?token=abc done", ""), + "GET https://host/path?*** done" + ); + } + + #[test] + fn strip_ansi_removes_color_codes() { + assert_eq!(strip_ansi("\u{1b}[31mred\u{1b}[0m text"), "red text"); + } +} diff --git a/src/infra/soloist/mod.rs b/src/infra/soloist/mod.rs new file mode 100644 index 00000000..f09485fd --- /dev/null +++ b/src/infra/soloist/mod.rs @@ -0,0 +1,40 @@ +//! Spotify Soloist as the engine behind spotatui's own Connect device. +//! +//! spotatui starts the Soloist binary the user downloaded, reads its device id +//! and WebSocket port from its stdout, and drives it over that loopback socket. +//! Rules for this module: `AGENTS.md` next to this file. + +pub mod auth; +pub mod download; +pub mod link; +pub mod logline; +pub mod prefs; +pub mod process; +pub mod protocol; +pub mod startup; +pub mod supervisor; + +use std::path::PathBuf; + +pub use link::{SoloistLink, StreamQuality}; + +/// What the supervisor tells the user; the App words it. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Notice { + PathUnset, + Missing(PathBuf), + NotExecutable(PathBuf), + WrongCpu, + StartFailed(String), + KeyMissing(PathBuf), + NotPaired, + Offline, + BadKey, + Expired, + ExpiresSoon { count: u32, unit: String }, + DirInUse, + KeepsExiting, + NoWebSocket, + Restarting, + RestartingForQuality, +} diff --git a/src/infra/soloist/prefs.rs b/src/infra/soloist/prefs.rs new file mode 100644 index 00000000..e5567164 --- /dev/null +++ b/src/infra/soloist/prefs.rs @@ -0,0 +1,177 @@ +//! The playback keys in Soloist's per-user prefs files. +//! +//! Undocumented internal state (the docs say Soloist reads no config file): +//! `audio.play_bitrate_enumeration=5` streams FLAC on 1.3.10, and +//! `audio.normalize_v2=false` turns loudness normalization off, as librespot +//! runs here. The official quality path is the per-device menu in the Spotify +//! mobile app. + +use std::collections::BTreeSet; +use std::path::{Path, PathBuf}; + +const ENUMERATION: &str = "audio.play_bitrate_enumeration"; +const NON_METERED: &str = "audio.play_bitrate_non_metered_enumeration"; +const MIGRATED: &str = "audio.play_bitrate_non_metered_migrated"; +const NORMALIZE: &str = "audio.normalize_v2"; +/// The user dirs spotatui already seeded and restarted Soloist for. +const SEEDED_MARKER: &str = ".spotatui-prefs-seeded"; + +/// `existing` with spotatui's keys set, every other line kept, LF line endings. +pub fn merge_prefs(existing: &str, enumeration: u8) -> String { + let wanted = [ + (ENUMERATION, enumeration.to_string()), + (NON_METERED, enumeration.to_string()), + (MIGRATED, "true".to_string()), + (NORMALIZE, "false".to_string()), + ]; + let mut lines: Vec = existing + .split('\n') + .map(|line| line.trim_end_matches('\r')) + .filter(|line| !line.is_empty()) + .filter(|line| { + let key = line.split('=').next().unwrap_or_default(); + !wanted.iter().any(|(wanted, _)| *wanted == key) + }) + .map(str::to_string) + .collect(); + lines.extend(wanted.iter().map(|(key, value)| format!("{key}={value}"))); + let mut out = lines.join("\n"); + out.push('\n'); + out +} + +fn users_dir(data_dir: &Path) -> PathBuf { + data_dir.join("settings").join("Users") +} + +/// The account dirs that hold a prefs file. +pub fn user_dirs(data_dir: &Path) -> BTreeSet { + let Ok(entries) = std::fs::read_dir(users_dir(data_dir)) else { + return BTreeSet::new(); + }; + entries + .filter_map(Result::ok) + .filter(|entry| entry.path().join("prefs").is_file()) + .filter_map(|entry| entry.file_name().into_string().ok()) + .collect() +} + +/// Write the quality keys into every account's prefs; returns how many files changed. +pub fn write_quality(data_dir: &Path, enumeration: u8) -> std::io::Result { + let mut written = 0; + for user in user_dirs(data_dir) { + let path = users_dir(data_dir).join(user).join("prefs"); + let existing = std::fs::read_to_string(&path)?; + let merged = merge_prefs(&existing, enumeration); + if merged != existing { + crate::core::auth::write_private_file_atomic(&path, merged.as_bytes()) + .map_err(std::io::Error::other)?; + written += 1; + } + } + Ok(written) +} + +pub fn seeded(data_dir: &Path) -> BTreeSet { + std::fs::read_to_string(data_dir.join(SEEDED_MARKER)) + .map(|text| { + text + .lines() + .map(str::trim) + .filter(|line| !line.is_empty()) + .map(str::to_string) + .collect() + }) + .unwrap_or_default() +} + +pub fn mark_seeded(data_dir: &Path, users: &BTreeSet) -> std::io::Result<()> { + let text: String = users.iter().map(|user| format!("{user}\n")).collect(); + std::fs::write(data_dir.join(SEEDED_MARKER), text) +} + +/// Accounts that appeared after the spawn and still need one restart to pick up the keys. +pub fn unseeded(present: &BTreeSet, seeded: &BTreeSet) -> BTreeSet { + present.difference(seeded).cloned().collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + const SPIKE_PREFS: &str = "audio.play_bitrate_non_metered_migrated=true\naudio.crossfade_migrated_to_ps=true\naudio.gapless_migrated_to_ps=true\n"; + + #[test] + fn merge_prefs_sets_lossless_and_turns_normalization_off() { + let merged = merge_prefs(SPIKE_PREFS, 5); + assert!(merged.contains("audio.play_bitrate_enumeration=5\n")); + assert!(merged.contains("audio.play_bitrate_non_metered_enumeration=5\n")); + assert!(merged.contains("audio.normalize_v2=false\n")); + assert_eq!( + merged + .matches("audio.play_bitrate_non_metered_migrated=true") + .count(), + 1 + ); + } + + #[test] + fn merge_prefs_keeps_engine_owned_keys() { + let merged = merge_prefs(SPIKE_PREFS, 5); + assert!(merged.contains("audio.crossfade_migrated_to_ps=true\n")); + assert!(merged.contains("audio.gapless_migrated_to_ps=true\n")); + } + + #[test] + fn merge_prefs_replaces_an_old_enumeration() { + let merged = merge_prefs("audio.play_bitrate_enumeration=3\n", 5); + assert!(!merged.contains("=3")); + assert!(merged.contains("audio.play_bitrate_enumeration=5")); + } + + #[test] + fn merge_prefs_is_idempotent() { + let once = merge_prefs(SPIKE_PREFS, 5); + assert_eq!(merge_prefs(&once, 5), once); + } + + #[test] + fn merge_prefs_writes_lf_only_line_endings() { + let merged = merge_prefs("a=1\r\nb=2\r\n", 4); + assert!(!merged.contains('\r')); + assert!(merged.ends_with('\n')); + } + + #[test] + fn a_new_account_needs_one_seeding_restart() { + let present: BTreeSet = ["old", "new"].map(String::from).into(); + let seeded: BTreeSet = ["old"].map(String::from).into(); + assert_eq!( + unseeded(&present, &seeded), + ["new"].map(String::from).into() + ); + assert!(unseeded(&present, &present).is_empty()); + } + + #[test] + fn write_quality_touches_only_account_prefs() { + let dir = tempfile::tempdir().unwrap(); + let user = dir.path().join("settings").join("Users").join("someone"); + std::fs::create_dir_all(&user).unwrap(); + std::fs::write(user.join("prefs"), SPIKE_PREFS).unwrap(); + std::fs::write( + dir.path().join("settings").join("prefs"), + "core.clock_delta=-1\n", + ) + .unwrap(); + + assert_eq!(write_quality(dir.path(), 5).unwrap(), 1); + assert_eq!(write_quality(dir.path(), 5).unwrap(), 0); + assert_eq!( + std::fs::read_to_string(dir.path().join("settings").join("prefs")).unwrap(), + "core.clock_delta=-1\n" + ); + mark_seeded(dir.path(), &user_dirs(dir.path())).unwrap(); + assert_eq!(seeded(dir.path()), ["someone"].map(String::from).into()); + } +} diff --git a/src/infra/soloist/process.rs b/src/infra/soloist/process.rs new file mode 100644 index 00000000..3b4881d3 --- /dev/null +++ b/src/infra/soloist/process.rs @@ -0,0 +1,220 @@ +//! Pure decisions around the Soloist child: its argv, spawn errors, exits, restarts. + +use std::ffi::OsString; +use std::path::Path; +use std::time::{Duration, Instant}; + +use super::auth::ApiKey; + +pub struct SpawnPlan<'a> { + pub device_name: &'a str, + pub key: &'a ApiKey, + pub data_dir: &'a Path, + pub cache_dir: &'a Path, + pub initial_volume: u8, +} + +/// The cache cap in MB; FLAC fills the cache about five times faster than 160 kbps. +const CACHE_MB: u32 = 2048; + +/// Never log this: it holds the API key. +pub fn args(plan: &SpawnPlan) -> Vec { + vec![ + "-n".into(), + plan.device_name.into(), + "-k".into(), + plan.key.expose().into(), + "-D".into(), + plan.data_dir.into(), + "-C".into(), + plan.cache_dir.into(), + "-z".into(), + CACHE_MB.to_string().into(), + "-i".into(), + plan.initial_volume.min(100).to_string().into(), + "-w".into(), + "127.0.0.1:0".into(), + "-v".into(), + ] +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SpawnFailure { + Missing, + NotExecutable, + WrongCpu, + Other, +} + +/// `ENOEXEC` is 8 on Linux and macOS; macOS reports a foreign CPU as `EBADARCH` (86). +pub fn classify_spawn_error(kind: std::io::ErrorKind, raw_os_error: Option) -> SpawnFailure { + match (kind, raw_os_error) { + (std::io::ErrorKind::NotFound, _) => SpawnFailure::Missing, + (_, Some(8)) if cfg!(unix) => SpawnFailure::WrongCpu, + (_, Some(86)) if cfg!(target_os = "macos") => SpawnFailure::WrongCpu, + (_, Some(193)) if cfg!(windows) => SpawnFailure::WrongCpu, + (std::io::ErrorKind::PermissionDenied, _) => SpawnFailure::NotExecutable, + _ => SpawnFailure::Other, + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Exit { + /// spotatui stopped it. + Stopped, + Expired, + DataDirInUse, + Crashed(Option), +} + +pub fn classify_exit( + code: Option, + shutting_down: bool, + saw_another_session: bool, + saw_expired: bool, +) -> Exit { + if shutting_down { + return Exit::Stopped; + } + if code == Some(10) || saw_expired { + return Exit::Expired; + } + if code == Some(1) && saw_another_session { + return Exit::DataDirInUse; + } + Exit::Crashed(code) +} + +const RESTART_WINDOW: Duration = Duration::from_secs(600); + +/// The wait before the next restart, given the earlier exits; `None` after three in ten minutes. +pub fn restart_delay(exits: &[Instant], now: Instant) -> Option { + let recent = exits + .iter() + .filter(|exit| now.saturating_duration_since(**exit) < RESTART_WINDOW) + .count(); + match recent { + 0 | 1 => Some(Duration::from_secs(2)), + 2 => Some(Duration::from_secs(5)), + 3 => Some(Duration::from_secs(15)), + _ => None, + } +} + +/// The pid in `soloist ctl status` output (`running (pid 1234)`), when it runs. +pub fn parse_ctl_status(text: &str) -> Option { + let start = text.find("running (pid ")? + "running (pid ".len(); + let rest = &text[start..]; + rest[..rest.find(')')?].trim().parse().ok() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn key() -> ApiKey { + super::super::auth::key_with(Some("secret".to_string()), None).unwrap() + } + + #[test] + fn soloist_args_bind_the_websocket_to_loopback_port_zero() { + let key = key(); + let args = args(&SpawnPlan { + device_name: "spotatui", + key: &key, + data_dir: Path::new("data"), + cache_dir: Path::new("cache"), + initial_volume: 140, + }); + let args: Vec = args + .iter() + .map(|a| a.to_string_lossy().into_owned()) + .collect(); + let after = |flag: &str| args[args.iter().position(|a| a == flag).unwrap() + 1].clone(); + assert_eq!(after("-w"), "127.0.0.1:0"); + assert_eq!(after("-n"), "spotatui"); + assert_eq!(after("-D"), "data"); + assert_eq!(after("-C"), "cache"); + assert_eq!(after("-z"), "2048"); + assert_eq!(after("-i"), "100"); + assert!(args.contains(&"-v".to_string())); + } + + #[test] + fn spawn_errors_name_the_cause() { + use std::io::ErrorKind; + assert_eq!( + classify_spawn_error(ErrorKind::NotFound, Some(2)), + SpawnFailure::Missing + ); + assert_eq!( + classify_spawn_error(ErrorKind::Other, Some(12345)), + SpawnFailure::Other + ); + #[cfg(unix)] + assert_eq!( + classify_spawn_error(ErrorKind::PermissionDenied, Some(13)), + SpawnFailure::NotExecutable + ); + #[cfg(unix)] + assert_eq!( + classify_spawn_error(ErrorKind::Other, Some(8)), + SpawnFailure::WrongCpu + ); + #[cfg(windows)] + assert_eq!( + classify_spawn_error(ErrorKind::Other, Some(193)), + SpawnFailure::WrongCpu + ); + } + + #[test] + fn exit_code_ten_means_build_expired() { + assert_eq!(classify_exit(Some(10), false, false, false), Exit::Expired); + assert_eq!(classify_exit(Some(1), false, false, true), Exit::Expired); + } + + #[test] + fn exit_code_one_with_another_session_means_data_dir_in_use() { + assert_eq!( + classify_exit(Some(1), false, true, false), + Exit::DataDirInUse + ); + assert_eq!( + classify_exit(Some(1), false, false, false), + Exit::Crashed(Some(1)) + ); + } + + #[test] + fn exit_after_shutdown_started_is_not_a_crash() { + assert_eq!(classify_exit(None, true, false, false), Exit::Stopped); + assert_eq!(classify_exit(Some(10), true, false, true), Exit::Stopped); + } + + #[test] + fn restart_delay_stops_after_three_exits_in_ten_minutes() { + let now = Instant::now() + Duration::from_secs(3600); + let at = |ago: u64| now - Duration::from_secs(ago); + assert_eq!(restart_delay(&[at(1)], now), Some(Duration::from_secs(2))); + assert_eq!( + restart_delay(&[at(60), at(1)], now), + Some(Duration::from_secs(5)) + ); + assert_eq!( + restart_delay(&[at(120), at(60), at(1)], now), + Some(Duration::from_secs(15)) + ); + assert_eq!(restart_delay(&[at(180), at(120), at(60), at(1)], now), None); + assert_eq!( + restart_delay(&[at(1200), at(900), at(700), at(1)], now), + Some(Duration::from_secs(2)) + ); + } + + #[test] + fn ctl_status_reads_the_running_pid() { + assert_eq!(parse_ctl_status("running (pid 17984)\n"), Some(17984)); + assert_eq!(parse_ctl_status("not running\n"), None); + } +} diff --git a/src/infra/soloist/protocol.rs b/src/infra/soloist/protocol.rs new file mode 100644 index 00000000..92a1b3ff --- /dev/null +++ b/src/infra/soloist/protocol.rs @@ -0,0 +1,355 @@ +//! The Soloist WebSocket protocol: command JSON out, events in. +//! +//! The envelope is flat: `{"type":"command","command":"seek","position_ms":0}`. +//! A nested `{"command":{"type":..}}` is rejected by Soloist. + +use serde_json::{json, Value}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Command { + Play, + Pause, + SkipNext, + SkipPrev, + Seek(u64), + SetVolume(u8), + SetShuffle(bool), + SetRepeatContext(bool), + SetRepeatTrack(bool), + Activate, + Deactivate, +} + +impl Command { + pub fn to_json(self) -> String { + let mut message = json!({ "type": "command" }); + let (name, field) = match self { + Command::Play => ("play", None), + Command::Pause => ("pause", None), + Command::SkipNext => ("skip_next", None), + Command::SkipPrev => ("skip_prev", None), + Command::Seek(position) => ("seek", Some(("position_ms", json!(position)))), + Command::SetVolume(volume) => ("set_volume", Some(("volume", json!(volume.min(100))))), + Command::SetShuffle(on) => ("set_shuffle", Some(("enabled", json!(on)))), + Command::SetRepeatContext(on) => ("set_repeat_context", Some(("enabled", json!(on)))), + Command::SetRepeatTrack(on) => ("set_repeat_track", Some(("enabled", json!(on)))), + Command::Activate => ("activate", None), + Command::Deactivate => ("deactivate", None), + }; + message["command"] = json!(name); + if let Some((key, value)) = field { + message[key] = value; + } + message.to_string() + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum Repeat { + #[default] + Off, + Context, + Track, +} + +impl Repeat { + fn parse(text: &str) -> Option { + match text { + "off" => Some(Repeat::Off), + "context" => Some(Repeat::Context), + "track" => Some(Repeat::Track), + _ => None, + } + } +} + +/// The two flags for a repeat target, the one that turns off first. +pub fn repeat_commands(target: rspotify::model::RepeatState) -> [Command; 2] { + use rspotify::model::RepeatState; + match target { + RepeatState::Off => [ + Command::SetRepeatTrack(false), + Command::SetRepeatContext(false), + ], + RepeatState::Context => [ + Command::SetRepeatTrack(false), + Command::SetRepeatContext(true), + ], + RepeatState::Track => [ + Command::SetRepeatContext(false), + Command::SetRepeatTrack(true), + ], + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum Status { + #[default] + Idle, + Playing, + Paused, + Buffering, +} + +impl Status { + fn parse(text: &str) -> Option { + match text { + "idle" => Some(Status::Idle), + "playing" => Some(Status::Playing), + "paused" => Some(Status::Paused), + "buffering" => Some(Status::Buffering), + _ => None, + } + } + + pub fn is_playing(self) -> bool { + matches!(self, Status::Playing | Status::Buffering) + } +} + +/// A position at a wall-clock instant (ms since the Unix epoch), moving at `speed`. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct PositionAnchor { + pub position_ms: u64, + pub timestamp_ms: i64, + pub speed: f64, +} + +impl PositionAnchor { + fn parse(value: &Value) -> Option { + Some(Self { + position_ms: value["position_ms"].as_u64()?, + timestamp_ms: value["timestamp_ms"].as_i64()?, + speed: value["speed"].as_f64().unwrap_or(1.0), + }) + } + + pub fn at(&self, now_ms: i64) -> u64 { + let elapsed = (now_ms - self.timestamp_ms).max(0) as f64 * self.speed.max(0.0); + self.position_ms.saturating_add(elapsed as u64) + } +} + +#[derive(Debug, Clone, PartialEq, Default)] +pub struct PlaybackState { + pub status: Status, + pub item_uri: Option, + pub item_is_episode: bool, + pub context_uri: Option, + pub position: Option, + pub volume: Option, + pub is_active: bool, + pub shuffle: bool, + pub repeat: Repeat, +} + +#[derive(Debug, Clone, PartialEq)] +pub enum Event { + Auth { + logged_in: bool, + is_active: bool, + }, + State(Box), + Track { + uri: Option, + is_episode: bool, + }, + Playback(Status), + Volume(u8), + Device { + is_active: bool, + }, + Context(Option), + Options { + shuffle: Option, + repeat: Option, + }, + Position(PositionAnchor), + Error(String), +} + +fn item(value: &Value) -> (Option, bool) { + ( + value["uri"].as_str().map(str::to_string), + value["entity_type"].as_str() == Some("episode"), + ) +} + +fn volume(value: &Value) -> Option { + value.as_u64().map(|volume| volume.min(100) as u8) +} + +fn options(value: &Value) -> (Option, Option) { + ( + value["shuffle"].as_bool(), + value["repeat"].as_str().and_then(Repeat::parse), + ) +} + +/// One WebSocket text frame; `None` for types spotatui does not use. +pub fn parse_event(text: &str) -> Option { + let value: Value = serde_json::from_str(text).ok()?; + match value["type"].as_str()? { + "auth_state" => Some(Event::Auth { + logged_in: value["logged_in"].as_bool().unwrap_or(false), + is_active: value["is_active"].as_bool().unwrap_or(false), + }), + "playback_state" => { + let (item_uri, item_is_episode) = item(&value["item"]); + let (shuffle, repeat) = options(&value["options"]); + Some(Event::State(Box::new(PlaybackState { + status: value["status"] + .as_str() + .and_then(Status::parse) + .unwrap_or_default(), + item_uri, + item_is_episode, + context_uri: value["context"]["uri"].as_str().map(str::to_string), + position: PositionAnchor::parse(&value["position"]), + volume: volume(&value["volume"]), + is_active: value["is_active"].as_bool().unwrap_or(false), + shuffle: shuffle.unwrap_or(false), + repeat: repeat.unwrap_or_default(), + }))) + } + "track_changed" => { + let (uri, is_episode) = item(&value["item"]); + Some(Event::Track { uri, is_episode }) + } + "playback_changed" => value["status"] + .as_str() + .and_then(Status::parse) + .map(Event::Playback), + "volume_changed" => volume(&value["volume"]).map(Event::Volume), + "device_changed" => Some(Event::Device { + is_active: value["is_active"].as_bool().unwrap_or(false), + }), + "context_changed" => Some(Event::Context( + value["context"]["uri"].as_str().map(str::to_string), + )), + "options_changed" => { + let (shuffle, repeat) = options(&value["options"]); + Some(Event::Options { shuffle, repeat }) + } + "position_sync" => PositionAnchor::parse(&value["position"]).map(Event::Position), + "error" => Some(Event::Error( + value["message"].as_str().unwrap_or_default().to_string(), + )), + _ => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const EXPERIMENT_STATE: &str = r#"{"type":"playback_state","status":"playing","item":{"uri":"spotify:track:35pbQNMzooB4Qg6mRPt37b","entity_type":"track","decorations":{"identity":{"name":"This Dream Of You"},"playback":{"duration_ms":286406,"content_ratings":[]}}},"context":{"uri":"spotify:playlist:0N9wg7UZwjb6UO0MBzUscR","entity_type":"playlist"},"position":{"position_ms":21344,"timestamp_ms":1792921722717,"speed":1},"volume":80,"is_active":true,"options":{"shuffle":true,"repeat":"off","playback_speed":1,"modes":{"context_enhancement":"NONE"}},"available_actions":{"pause":{},"seek":{}}}"#; + + #[test] + fn commands_use_the_flat_envelope() { + let pause: Value = serde_json::from_str(&Command::Pause.to_json()).unwrap(); + assert_eq!(pause, json!({ "type": "command", "command": "pause" })); + } + + #[test] + fn seek_and_set_volume_put_fields_at_top_level() { + let seek: Value = serde_json::from_str(&Command::Seek(1500).to_json()).unwrap(); + assert_eq!(seek["command"], "seek"); + assert_eq!(seek["position_ms"], 1500); + let volume: Value = serde_json::from_str(&Command::SetVolume(140).to_json()).unwrap(); + assert_eq!(volume["volume"], 100); + } + + #[test] + fn repeat_commands_turn_the_other_flag_off_first() { + assert_eq!( + repeat_commands(rspotify::model::RepeatState::Track), + [ + Command::SetRepeatContext(false), + Command::SetRepeatTrack(true) + ] + ); + assert_eq!( + repeat_commands(rspotify::model::RepeatState::Context), + [ + Command::SetRepeatTrack(false), + Command::SetRepeatContext(true) + ] + ); + assert_eq!( + repeat_commands(rspotify::model::RepeatState::Off), + [ + Command::SetRepeatTrack(false), + Command::SetRepeatContext(false) + ] + ); + } + + #[test] + fn parse_event_reads_the_experiment_playback_state() { + let Some(Event::State(state)) = parse_event(EXPERIMENT_STATE) else { + panic!("expected a playback state"); + }; + assert_eq!(state.status, Status::Playing); + assert_eq!( + state.item_uri.as_deref(), + Some("spotify:track:35pbQNMzooB4Qg6mRPt37b") + ); + assert!(!state.item_is_episode); + assert_eq!( + state.context_uri.as_deref(), + Some("spotify:playlist:0N9wg7UZwjb6UO0MBzUscR") + ); + assert_eq!(state.volume, Some(80)); + assert!(state.is_active && state.shuffle); + assert_eq!(state.repeat, Repeat::Off); + } + + #[test] + fn position_anchor_moves_with_the_wall_clock() { + let Some(Event::State(state)) = parse_event(EXPERIMENT_STATE) else { + panic!("expected a playback state"); + }; + let anchor = state.position.unwrap(); + assert_eq!(anchor.at(1792921722717), 21344); + assert_eq!(anchor.at(1792921723717), 22344); + assert_eq!(anchor.at(1792921720000), 21344); + } + + #[test] + fn parse_event_reads_the_auth_and_change_events() { + assert_eq!( + parse_event( + r#"{"type":"auth_state","logged_in":true,"is_active":false,"device_name":"spotatui"}"# + ), + Some(Event::Auth { + logged_in: true, + is_active: false + }) + ); + assert_eq!( + parse_event(r#"{"type":"playback_changed","status":"paused"}"#), + Some(Event::Playback(Status::Paused)) + ); + assert_eq!( + parse_event(r#"{"type":"options_changed","options":{"shuffle":false,"repeat":"track"}}"#), + Some(Event::Options { + shuffle: Some(false), + repeat: Some(Repeat::Track) + }) + ); + } + + #[test] + fn parse_event_ignores_unknown_types() { + assert_eq!( + parse_event(r#"{"type":"command_result","command":"pause"}"#), + None + ); + assert_eq!( + parse_event(r#"{"type":"queue_changed","previous":[]}"#), + None + ); + assert_eq!(parse_event("not json"), None); + } +} diff --git a/src/infra/soloist/startup.rs b/src/infra/soloist/startup.rs new file mode 100644 index 00000000..9dbbb285 --- /dev/null +++ b/src/infra/soloist/startup.rs @@ -0,0 +1,95 @@ +//! The Soloist engine's bring-up: the session gate, the binary, the key, then the supervisor. + +use std::sync::Arc; + +use tokio::sync::Mutex; + +use super::{auth, supervisor, Notice}; +use crate::core::app::App; +use crate::core::user_config::{BehaviorConfig, StartupBehavior}; + +pub(crate) struct SoloistStart { + pub soloist_path: Option, + pub device_name: String, + pub quality: u8, + pub initial_volume: u8, + pub startup_behavior: Option, + pub startup_shuffle: bool, +} + +impl SoloistStart { + pub(crate) fn from_behavior( + behavior: &BehaviorConfig, + initial_volume: u8, + startup_behavior: Option, + startup_shuffle: bool, + ) -> Self { + Self { + soloist_path: behavior.soloist_path.clone(), + device_name: behavior.streaming_device_name.clone(), + quality: behavior.soloist_audio_quality.enumeration(), + initial_volume, + startup_behavior, + startup_shuffle, + } + } +} + +/// Stop a running Soloist, then start the engine the settings name, with the saved key. +pub(crate) async fn apply_engine(app: Arc>) { + if !supervisor::stop_for_restart(std::time::Duration::from_secs(5)).await { + log::warn!("soloist: the old engine did not stop; not switching"); + return; + } + let start = app.lock().await.apply_playback_engine(); + if let Some(start) = start { + deferred_soloist_startup(app, start); + } +} + +/// Runs after the first frame, like librespot's deferred start. +pub(crate) fn deferred_soloist_startup(app: Arc>, start: SoloistStart) { + tokio::spawn(async move { + if let Err(unavailable) = crate::infra::spotify::session::current().await { + log::info!("soloist: not started: {}", unavailable.message()); + app.lock().await.soloist_failed(None); + return; + } + let Some(path) = start.soloist_path else { + app.lock().await.soloist_failed(Some(Notice::PathUnset)); + return; + }; + let Some(key) = auth::current_key() else { + let file = crate::core::paths::soloist_credentials_path().unwrap_or_default(); + app + .lock() + .await + .soloist_failed(Some(Notice::KeyMissing(file))); + return; + }; + let (Some(data_dir), Some(cache_dir)) = ( + crate::core::paths::soloist_data_dir(), + crate::core::paths::soloist_cache_dir(), + ) else { + app + .lock() + .await + .soloist_failed(Some(Notice::StartFailed("no state directory".to_string()))); + return; + }; + supervisor::start( + app, + supervisor::Config { + path: path.into(), + device_name: start.device_name, + key, + data_dir, + cache_dir, + quality: start.quality, + initial_volume: start.initial_volume, + startup_behavior: start.startup_behavior, + startup_shuffle: start.startup_shuffle, + }, + ); + }); +} diff --git a/src/infra/soloist/supervisor.rs b/src/infra/soloist/supervisor.rs new file mode 100644 index 00000000..6af6dd16 --- /dev/null +++ b/src/infra/soloist/supervisor.rs @@ -0,0 +1,714 @@ +//! Starts, watches and stops the Soloist child, and bridges its WebSocket to the App. + +use std::collections::BTreeSet; +use std::path::{Path, PathBuf}; +use std::process::Stdio; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex as StdMutex, OnceLock}; +use std::time::{Duration, Instant}; + +use futures::{Sink, SinkExt, StreamExt}; +use log::{debug, info, warn}; +use tokio::io::{AsyncBufReadExt, AsyncRead, BufReader}; +use tokio::process::{Child, Command as ProcessCommand}; +use tokio::sync::mpsc::{self, UnboundedReceiver, UnboundedSender}; +use tokio::sync::{oneshot, Mutex, Notify}; +use tokio_tungstenite::tungstenite::Message; + +use super::auth::ApiKey; +use super::link::{SoloistLink, StreamQuality, VolumeGate}; +use super::logline::{self, LogEvent}; +use super::process::{self, Exit, SpawnFailure, SpawnPlan}; +use super::protocol::{self, Command, Event}; +use super::{prefs, Notice}; +use crate::core::app::App; +use crate::core::user_config::StartupBehavior; + +pub struct Config { + pub path: PathBuf, + pub device_name: String, + pub key: ApiKey, + pub data_dir: PathBuf, + pub cache_dir: PathBuf, + pub quality: u8, + pub initial_volume: u8, + pub startup_behavior: Option, + pub startup_shuffle: bool, +} + +#[derive(Default)] +struct Shared { + shutting_down: AtomicBool, + running: AtomicBool, + stop: Notify, + link: StdMutex>>, + done: StdMutex>>, +} + +fn shared() -> &'static Arc { + static SHARED: OnceLock> = OnceLock::new(); + SHARED.get_or_init(Arc::default) +} + +/// Soloist's pid as spotatui spawned it, to tell an orphan from a Soloist the user runs. +const CHILD_PID: &str = ".spotatui-child.pid"; +const STALE_FILES: [&str; 3] = ["ws.addr", "ws.port", "soloist.pid"]; +const START_BUDGET: Duration = Duration::from_secs(20); +const OFFLINE_AFTER: Duration = Duration::from_secs(15); +#[cfg(windows)] +pub(super) const CREATE_NO_WINDOW: u32 = 0x0800_0000; + +pub fn start(app: Arc>, config: Config) { + let shared = shared().clone(); + if shared.shutting_down.load(Ordering::SeqCst) { + return; + } + clear_stale_stop(&shared); + shared.running.store(true, Ordering::SeqCst); + let (done_tx, done_rx) = oneshot::channel(); + if let Ok(mut done) = shared.done.lock() { + *done = Some(done_rx); + } + tokio::spawn(async move { + supervise(&app, &config, &shared).await; + shared.running.store(false, Ordering::SeqCst); + let _ = done_tx.send(()); + }); +} + +/// Drop the permit a `stop` left when no supervisor waited, so it cannot stop the next one. +fn clear_stale_stop(shared: &Shared) { + let _ = futures::FutureExt::now_or_never(shared.stop.notified()); +} + +/// A supervisor runs: it starts, watches or restarts Soloist. +pub fn running() -> bool { + shared().running.load(Ordering::SeqCst) +} + +/// Pause, give up the Connect device, and stop the child, waiting at most `timeout`. +pub async fn stop(timeout: Duration) { + let shared = shared(); + shared.shutting_down.store(true, Ordering::SeqCst); + let link = shared.link.lock().ok().and_then(|link| link.clone()); + if let Some(link) = link { + link.pause(); + link.send(Command::Deactivate); + tokio::time::sleep(Duration::from_millis(300)).await; + } + shared.stop.notify_one(); + let done = shared.done.lock().ok().and_then(|mut done| done.take()); + if let Some(done) = done { + let _ = tokio::time::timeout(timeout, done).await; + } +} + +/// `stop`, after which `start` works again; true when no supervisor runs any more. +pub async fn stop_for_restart(timeout: Duration) -> bool { + stop(timeout).await; + shared().shutting_down.store(false, Ordering::SeqCst); + !running() +} + +enum Outcome { + Exited(Exit), + Fatal(Notice), + Restart { reactivate: bool }, +} + +async fn notify(app: &Arc>, notice: Notice) { + app.lock().await.soloist_notice(notice); +} + +async fn supervise(app: &Arc>, config: &Config, shared: &Shared) { + let mut exits = Vec::new(); + let mut retried_dir = false; + let mut reactivate = false; + let mut first_login = true; + loop { + if shared.shutting_down.load(Ordering::SeqCst) { + break; + } + match run_once(app, config, shared, reactivate, &mut first_login).await { + Outcome::Restart { reactivate: again } => reactivate = again, + Outcome::Fatal(notice) => { + app.lock().await.soloist_failed(Some(notice)); + break; + } + Outcome::Exited(exit) => { + reactivate = false; + match exit { + Exit::Stopped => break, + Exit::Expired => { + app.lock().await.soloist_failed(Some(Notice::Expired)); + break; + } + Exit::DataDirInUse if !retried_dir => { + retried_dir = true; + clear_orphan(config).await; + } + Exit::DataDirInUse => { + app.lock().await.soloist_failed(Some(Notice::DirInUse)); + break; + } + Exit::Crashed(code) => { + warn!("soloist: exited with {code:?}"); + let now = Instant::now(); + exits.push(now); + let Some(delay) = process::restart_delay(&exits, now) else { + app.lock().await.soloist_failed(Some(Notice::KeepsExiting)); + break; + }; + { + let mut app = app.lock().await; + app.soloist_starting(); + app.soloist_notice(Notice::Restarting); + } + tokio::select! { + _ = tokio::time::sleep(delay) => {} + _ = shared.stop.notified() => break, + } + } + } + } + } + } +} + +fn spawn(config: &Config) -> std::io::Result { + let mut command = quiet_command(&config.path); + command + .args(process::args(&SpawnPlan { + device_name: &config.device_name, + key: &config.key, + data_dir: &config.data_dir, + cache_dir: &config.cache_dir, + initial_volume: config.initial_volume, + })) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + #[cfg(target_os = "linux")] + // SAFETY: prctl is async-signal-safe; the signal fires when the spawning thread exits. + unsafe { + command.pre_exec(|| { + libc::prctl(libc::PR_SET_PDEATHSIG, libc::SIGTERM); + Ok(()) + }); + } + command.spawn() +} + +fn spawn_notice(config: &Config, error: &std::io::Error) -> Notice { + match process::classify_spawn_error(error.kind(), error.raw_os_error()) { + SpawnFailure::Missing => Notice::Missing(config.path.clone()), + SpawnFailure::NotExecutable => Notice::NotExecutable(config.path.clone()), + SpawnFailure::WrongCpu => Notice::WrongCpu, + SpawnFailure::Other => Notice::StartFailed(error.to_string()), + } +} + +async fn drain(reader: impl AsyncRead + Unpin, events: UnboundedSender, key: String) { + let mut reader = BufReader::new(reader); + let mut buf = Vec::new(); + loop { + buf.clear(); + match reader.read_until(b'\n', &mut buf).await { + Ok(0) | Err(_) => break, + Ok(_) => {} + } + let line = logline::strip_ansi(String::from_utf8_lossy(&buf).trim_end()); + debug!("soloist: {}", logline::redact(&line, &key)); + if let Some(event) = logline::parse_line(&line) { + let _ = events.send(event); + } + } +} + +async fn write_commands(mut sink: S, mut commands: UnboundedReceiver) +where + S: Sink + Unpin, +{ + let mut gate = VolumeGate::default(); + loop { + let deadline = gate.deadline(); + let command = tokio::select! { + command = commands.recv() => match command { + Some(Command::SetVolume(volume)) => match gate.offer(volume, Instant::now()) { + Some(volume) => Command::SetVolume(volume), + None => continue, + }, + Some(command) => command, + None => break, + }, + _ = sleep_until(deadline) => match gate.due(Instant::now()) { + Some(volume) => Command::SetVolume(volume), + None => continue, + }, + }; + if sink + .send(Message::Text(command.to_json().into())) + .await + .is_err() + { + break; + } + } +} + +async fn sleep_until(deadline: Option) { + match deadline { + Some(deadline) => tokio::time::sleep_until(tokio::time::Instant::from_std(deadline)).await, + None => std::future::pending().await, + } +} + +fn read_pid(path: &Path) -> Option { + std::fs::read_to_string(path).ok()?.trim().parse().ok() +} + +fn remove_stale(data_dir: &Path) { + for file in STALE_FILES { + let _ = std::fs::remove_file(data_dir.join(file)); + } +} + +fn quiet_command(program: impl AsRef) -> ProcessCommand { + let mut command = ProcessCommand::new(program); + command + .stdin(Stdio::null()) + .stderr(Stdio::null()) + .kill_on_drop(true); + #[cfg(windows)] + command.creation_flags(CREATE_NO_WINDOW); + command +} + +/// Stop a Soloist that spotatui spawned and lost (a hard kill), when it still runs. +async fn clear_orphan(config: &Config) { + let (Some(ours), Some(theirs)) = ( + read_pid(&config.data_dir.join(CHILD_PID)), + read_pid(&config.data_dir.join("soloist.pid")), + ) else { + return; + }; + if ours != theirs { + return; + } + let mut status = quiet_command(&config.path); + status + .args(["ctl", "status", "-D"]) + .arg(&config.data_dir) + .stdout(Stdio::piped()); + let running = tokio::time::timeout(Duration::from_secs(3), status.output()) + .await + .ok() + .and_then(Result::ok) + .and_then(|output| process::parse_ctl_status(&String::from_utf8_lossy(&output.stdout))); + if running != Some(ours) { + return; + } + warn!("soloist: stopping an orphaned child (pid {ours})"); + let pid = ours.to_string(); + #[cfg(windows)] + let mut kill = { + let mut kill = quiet_command("taskkill"); + kill.args(["/PID", &pid, "/F"]); + kill + }; + #[cfg(not(windows))] + let mut kill = { + let mut kill = quiet_command("kill"); + kill.args(["-TERM", &pid]); + kill + }; + let _ = kill.stdout(Stdio::null()).status().await; + tokio::time::sleep(Duration::from_secs(2)).await; +} + +/// SIGTERM first where spotatui can send it, then a hard kill. +async fn terminate(child: &mut Child) { + #[cfg(target_os = "linux")] + if let Some(pid) = child.id() { + // SAFETY: a plain signal to our own child. + unsafe { + libc::kill(pid as libc::pid_t, libc::SIGTERM); + } + if tokio::time::timeout(Duration::from_secs(1), child.wait()) + .await + .is_ok() + { + return; + } + } + let _ = child.kill().await; +} + +#[derive(Default)] +struct Seen { + another_session: bool, + expired: bool, + logged_in: bool, + pairing_shown: bool, + expiry_warned: bool, +} + +impl Seen { + fn note(&mut self, event: &LogEvent) { + match event { + LogEvent::AnotherSession => self.another_session = true, + LogEvent::Expired => self.expired = true, + _ => {} + } + } + + fn exit(&self, status: std::io::Result, shared: &Shared) -> Exit { + process::classify_exit( + status.ok().and_then(|status| status.code()), + shared.shutting_down.load(Ordering::SeqCst), + self.another_session, + self.expired, + ) + } +} + +/// The codec lines arrive one per line; the sample rate closes the set. +#[derive(Default)] +struct PendingQuality { + codec: Option, + bitrate: Option, +} + +fn expires_soon(count: u32, unit: &str) -> bool { + unit.starts_with("hour") || unit.starts_with("minute") || (unit.starts_with("day") && count < 7) +} + +/// The id and port from stdout, else from the files of this very child. +fn id_and_port_from_files(data_dir: &Path, pid: Option) -> (Option, Option) { + if pid.is_none() || read_pid(&data_dir.join("soloist.pid")) != pid { + return (None, None); + } + let id = std::fs::read_to_string(data_dir.join(".device_id")) + .ok() + .map(|id| id.trim().to_string()) + .filter(|id| !id.is_empty()); + let port = std::fs::read_to_string(data_dir.join("ws.port")) + .ok() + .and_then(|port| port.trim().parse().ok()); + (id, port) +} + +async fn connect( + port: u16, +) -> Option< + tokio_tungstenite::WebSocketStream>, +> { + let url = format!("ws://127.0.0.1:{port}"); + for _ in 0..10 { + if let Ok((socket, _)) = tokio_tungstenite::connect_async(url.as_str()).await { + return Some(socket); + } + tokio::time::sleep(Duration::from_millis(300)).await; + } + None +} + +/// Seed the quality keys for accounts that appeared since the spawn: `None` while Soloist +/// has written no account prefs yet, else whether Soloist must restart to read them. +fn seed_new_accounts(data_dir: &Path, quality: u8, before: &BTreeSet) -> Option { + let present = prefs::user_dirs(data_dir); + if present.is_empty() { + return None; + } + let fresh = prefs::unseeded(&present, &prefs::seeded(data_dir)); + if fresh.is_empty() { + return Some(false); + } + if let Err(error) = prefs::write_quality(data_dir, quality) { + warn!("soloist: could not write the quality prefs: {error}"); + return Some(false); + } + let _ = prefs::mark_seeded(data_dir, &present); + Some(fresh.iter().any(|user| !before.contains(user))) +} + +async fn run_once( + app: &Arc>, + config: &Config, + shared: &Shared, + reactivate: bool, + first_login: &mut bool, +) -> Outcome { + if let Err(error) = std::fs::create_dir_all(&config.data_dir) + .and_then(|_| std::fs::create_dir_all(&config.cache_dir)) + { + return Outcome::Fatal(Notice::StartFailed(error.to_string())); + } + clear_orphan(config).await; + remove_stale(&config.data_dir); + match prefs::write_quality(&config.data_dir, config.quality) { + Ok(0) => {} + Ok(written) => { + info!("soloist: set the quality and normalization keys in {written} prefs file(s)") + } + Err(error) => warn!("soloist: could not write the prefs keys: {error}"), + } + let users_before = prefs::user_dirs(&config.data_dir); + let _ = prefs::mark_seeded(&config.data_dir, &users_before); + + let mut child = match spawn(config) { + Ok(child) => child, + Err(error) => return Outcome::Fatal(spawn_notice(config, &error)), + }; + #[cfg(windows)] + let _job = job::Job::contain(&child); + let pid = child.id(); + if let Some(pid) = pid { + let _ = std::fs::write(config.data_dir.join(CHILD_PID), pid.to_string()); + } + let (log_tx, mut log_rx) = mpsc::unbounded_channel(); + if let Some(stdout) = child.stdout.take() { + tokio::spawn(drain( + stdout, + log_tx.clone(), + config.key.expose().to_string(), + )); + } + if let Some(stderr) = child.stderr.take() { + tokio::spawn(drain(stderr, log_tx, config.key.expose().to_string())); + } + + let mut seen = Seen::default(); + let mut device_id = None; + let mut port = None; + let deadline = tokio::time::Instant::now() + START_BUDGET; + while device_id.is_none() || port.is_none() { + tokio::select! { + event = log_rx.recv() => match event { + Some(event) => { + seen.note(&event); + match event { + LogEvent::Ready { device_id: id } => device_id = Some(id), + LogEvent::WsListening { port: listening } => port = Some(listening), + _ => {} + } + } + None => break, + }, + status = child.wait() => return Outcome::Exited(seen.exit(status, shared)), + _ = tokio::time::sleep_until(deadline) => break, + _ = shared.stop.notified() => { + terminate(&mut child).await; + return Outcome::Exited(Exit::Stopped); + } + } + } + let (file_id, file_port) = id_and_port_from_files(&config.data_dir, pid); + if device_id.is_some() && file_id.is_some() && device_id != file_id { + warn!("soloist: the device id in the log and in .device_id disagree"); + } + let (Some(device_id), Some(port)) = (device_id.or(file_id), port.or(file_port)) else { + terminate(&mut child).await; + return Outcome::Fatal(Notice::NoWebSocket); + }; + let Some(socket) = connect(port).await else { + terminate(&mut child).await; + return Outcome::Fatal(Notice::NoWebSocket); + }; + info!("soloist: running, WebSocket on port {port}"); + let (sink, mut stream) = socket.split(); + let (command_tx, command_rx) = mpsc::unbounded_channel(); + let writer = tokio::spawn(write_commands(sink, command_rx)); + let link = Arc::new(SoloistLink::new(device_id, command_tx)); + if let Ok(mut slot) = shared.link.lock() { + *slot = Some(link.clone()); + } + app.lock().await.install_soloist_link(link.clone()); + if reactivate { + link.send(Command::Activate); + } + + let mut quality = PendingQuality::default(); + let mut log_open = true; + let mut seed_restart = false; + // Soloist writes a new account's prefs just after it reports the login. + let mut seed_watch = false; + let offline_at = tokio::time::Instant::now() + OFFLINE_AFTER; + let mut offline_pending = !users_before.is_empty(); + let outcome = loop { + tokio::select! { + event = log_rx.recv(), if log_open => { + let Some(event) = event else { + log_open = false; + continue; + }; + seen.note(&event); + match event { + LogEvent::WaitingForLogin if !seen.pairing_shown => { + seen.pairing_shown = true; + notify(app, Notice::NotPaired).await; + } + LogEvent::LoginFailed => notify(app, Notice::BadKey).await, + LogEvent::ExpiresIn { count, unit } if !seen.expiry_warned && expires_soon(count, &unit) => { + seen.expiry_warned = true; + notify(app, Notice::ExpiresSoon { count, unit }).await; + } + LogEvent::AverageBitrate(bitrate) => quality.bitrate = Some(bitrate), + LogEvent::Codec(codec) => quality.codec = Some(codec), + LogEvent::SampleRate(rate) => { + if let Some(codec) = quality.codec.take() { + let measured = StreamQuality { + codec, + sample_rate: Some(rate), + bitrate: quality.bitrate.take(), + }; + info!("soloist: streaming {}", measured.label()); + let previous = link.quality(); + link.set_quality(measured.clone()); + app.lock().await.note_soloist_quality(&link, previous, &measured); + } + } + _ => {} + } + } + message = stream.next() => match message { + Some(Ok(Message::Text(text))) => { + let Some(event) = protocol::parse_event(&text) else { + continue; + }; + if let Event::Error(message) = &event { + debug!("soloist: command refused: {message}"); + } + let logged_in = matches!(event, Event::Auth { logged_in: true, .. } | Event::State(_)); + app.lock().await.apply_soloist_event(&link, &event); + if logged_in && !seen.logged_in { + seen.logged_in = true; + offline_pending = false; + seed_watch = true; + if std::mem::take(first_login) { + app + .lock() + .await + .soloist_logged_in(config.startup_behavior, config.startup_shuffle); + } + } + if seed_watch { + if let Some(restart) = seed_new_accounts(&config.data_dir, config.quality, &users_before) { + seed_watch = false; + seed_restart = restart; + } + } + let snapshot = link.snapshot(); + if seed_restart && !snapshot.status.is_playing() { + notify(app, Notice::RestartingForQuality).await; + terminate(&mut child).await; + break Outcome::Restart { reactivate: snapshot.is_active }; + } + } + Some(Ok(_)) => {} + Some(Err(_)) | None => { + warn!("soloist: the WebSocket closed"); + terminate(&mut child).await; + break Outcome::Exited(Exit::Crashed(None)); + } + }, + status = child.wait() => break Outcome::Exited(seen.exit(status, shared)), + _ = tokio::time::sleep_until(offline_at), if offline_pending => { + offline_pending = false; + notify(app, Notice::Offline).await; + } + _ = shared.stop.notified() => { + terminate(&mut child).await; + break Outcome::Exited(Exit::Stopped); + } + } + }; + writer.abort(); + if let Ok(mut slot) = shared.link.lock() { + *slot = None; + } + app.lock().await.uninstall_soloist_link(&link); + let _ = std::fs::remove_file(config.data_dir.join(CHILD_PID)); + remove_stale(&config.data_dir); + outcome +} + +#[cfg(windows)] +mod job { + use windows_sys::Win32::Foundation::{CloseHandle, HANDLE}; + use windows_sys::Win32::System::JobObjects::{ + AssignProcessToJobObject, CreateJobObjectW, JobObjectExtendedLimitInformation, + SetInformationJobObject, JOBOBJECT_EXTENDED_LIMIT_INFORMATION, + JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE, + }; + + /// A job that kills Soloist when spotatui's handle closes, also on a hard kill of spotatui. + pub struct Job(HANDLE); + + // SAFETY: a job handle is a kernel object handle, valid on any thread. + unsafe impl Send for Job {} + unsafe impl Sync for Job {} + + impl Job { + pub fn contain(child: &tokio::process::Child) -> Option { + let process = child.raw_handle()?; + // SAFETY: plain Win32 calls on a fresh job handle and our own child's handle. + unsafe { + let handle = CreateJobObjectW(std::ptr::null(), std::ptr::null()); + if handle.is_null() { + return None; + } + let job = Job(handle); + let mut info: JOBOBJECT_EXTENDED_LIMIT_INFORMATION = std::mem::zeroed(); + info.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE; + let limited = SetInformationJobObject( + handle, + JobObjectExtendedLimitInformation, + std::ptr::from_ref(&info).cast(), + std::mem::size_of::() as u32, + ) != 0; + (limited && AssignProcessToJobObject(handle, process as HANDLE) != 0).then_some(job) + } + } + } + + impl Drop for Job { + fn drop(&mut self) { + // SAFETY: the handle came from CreateJobObjectW and is closed once. + unsafe { + CloseHandle(self.0); + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_stop_with_no_supervisor_does_not_stop_the_next_one() { + let shared = Shared::default(); + shared.stop.notify_one(); + clear_stale_stop(&shared); + assert!(futures::FutureExt::now_or_never(shared.stop.notified()).is_none()); + } + + #[test] + fn the_seed_waits_for_the_account_prefs_then_restarts_once() { + let dir = tempfile::tempdir().unwrap(); + let before = BTreeSet::new(); + assert_eq!(seed_new_accounts(dir.path(), 5, &before), None); + + let user = dir.path().join("settings").join("Users").join("someone"); + std::fs::create_dir_all(&user).unwrap(); + std::fs::write(user.join("prefs"), "app.first=1\n").unwrap(); + + assert_eq!(seed_new_accounts(dir.path(), 5, &before), Some(true)); + let prefs = std::fs::read_to_string(user.join("prefs")).unwrap(); + assert!( + prefs.contains("audio.play_bitrate_enumeration=5\n"), + "{prefs}" + ); + assert_eq!(seed_new_accounts(dir.path(), 5, &before), Some(false)); + } +} diff --git a/src/runtime/gui.rs b/src/runtime/gui.rs index 85f73bab..fc5c3c7e 100644 --- a/src/runtime/gui.rs +++ b/src/runtime/gui.rs @@ -482,7 +482,7 @@ fn owns_sound(app: &App) -> bool { matches!( app.playback_owner(), PlaybackOwner::Queue | PlaybackOwner::Decoded | PlaybackOwner::NativeSpotify - ) + ) || app.soloist_is_target() } /// Send the media controls `app`'s state when they must hear of it. diff --git a/src/runtime/startup.rs b/src/runtime/startup.rs index a79c4e79..2342c0f8 100644 --- a/src/runtime/startup.rs +++ b/src/runtime/startup.rs @@ -286,7 +286,26 @@ pub(super) async fn prepare_frontend(boot: Boot) -> FrontendHandles { // UI is up (see `deferred_streaming_startup`) instead of gating the first // frame for seconds (worst case tens of seconds). #[cfg(feature = "streaming")] - let streaming_attempted = user_config.behavior.enable_streaming && spotify.is_some(); + let engine = super::streaming::effective_engine( + user_config.behavior.playback_engine, + cfg!(feature = "soloist"), + cfg!(all(target_os = "macos", target_arch = "x86_64")), + ); + #[cfg(feature = "streaming")] + if engine != user_config.behavior.playback_engine && user_config.behavior.enable_streaming { + let mut app = app.lock().await; + let message = app.strings().status.infra.soloist_engine_unavailable; + app.set_status_message(message, 8); + } + #[cfg(feature = "streaming")] + let streaming_on = user_config.behavior.enable_streaming && spotify.is_some(); + // In soloist mode no Spirc device starts: Soloist is the device. + #[cfg(feature = "streaming")] + let streaming_attempted = + streaming_on && engine == crate::core::user_config::PlaybackEngine::Librespot; + #[cfg(feature = "soloist")] + let soloist_attempted = + streaming_on && engine == crate::core::user_config::PlaybackEngine::Soloist; // Create shared atomic for real-time position updates from native player // This avoids lock contention - the player event handler can update position @@ -310,15 +329,22 @@ pub(super) async fn prepare_frontend(boot: Boot) -> FrontendHandles { #[cfg(feature = "streaming")] let (streaming_recovery_tx, streaming_recovery_rx) = tokio::sync::mpsc::unbounded_channel::(); - // The supervisor that drains this channel starts inside - // `deferred_streaming_startup`, so install the sender on the same - // condition; otherwise a recovery request would latch - // `native_backend_pending` on a channel nobody reads. + // The supervisor that drains this channel starts below for either engine, + // so install the sender on the same condition; otherwise a recovery request + // would latch `native_backend_pending` on a channel nobody reads. #[cfg(feature = "streaming")] if streaming_attempted { let mut app_mut = app.lock().await; app_mut.streaming_recovery_tx = Some(streaming_recovery_tx.clone()); } + // Soloist mode keeps the sender aside, for a switch to librespot in Settings. + #[cfg(feature = "soloist")] + if soloist_attempted { + app + .lock() + .await + .stand_by_librespot(streaming_recovery_tx.clone()); + } // Initialize MPRIS D-Bus integration for desktop media control // This registers spotatui as a controllable media player on the session bus @@ -516,6 +542,37 @@ pub(super) async fn prepare_frontend(boot: Boot) -> FrontendHandles { #[cfg(all(feature = "windows-media", target_os = "windows"))] let windows_media_for_events = windows_media_manager.clone(); + #[cfg(feature = "streaming")] + let streaming_config = { + let behavior = &user_config.behavior; + player::StreamingConfig { + device_name: behavior.streaming_device_name.clone(), + bitrate: crate::core::user_config::streaming_bitrate_or_default(behavior.streaming_bitrate), + audio_cache: behavior.streaming_audio_cache, + autoplay: behavior.streaming_autoplay, + cache_path: crate::infra::spotify::credentials::get_default_cache_path(), + initial_volume: runtime_state.volume_percent, + } + }; + // The recovery supervisor builds every librespot player after the first one, + // and under Soloist the first one too, on a switch in Settings. + #[cfg(feature = "streaming")] + if streaming_on { + player::spawn_streaming_recovery_handler(player::StreamingRecoveryContext { + app: Arc::clone(&app), + shared_position: Arc::clone(&shared_position_for_events), + shared_is_playing: Arc::clone(&shared_is_playing_for_events), + recovery_rx: streaming_recovery_rx, + recovery_tx: streaming_recovery_tx.clone(), + streaming_config: streaming_config.clone(), + #[cfg(all(feature = "mpris", target_os = "linux"))] + mpris_manager: mpris_for_events.clone(), + #[cfg(all(feature = "macos-media", target_os = "macos"))] + macos_media_manager: macos_media_for_events.clone(), + #[cfg(all(feature = "windows-media", target_os = "windows"))] + windows_media_manager: windows_media_for_events.clone(), + }); + } // Kick off the deferred native-streaming startup: account probe, librespot // handshake, player event handler, and saved-device startup decision all // run on a background task while the UI renders (see @@ -535,17 +592,9 @@ pub(super) async fn prepare_frontend(boot: Boot) -> FrontendHandles { // parks itself for replay instead of erroring (the task clears this and // replays whatever parked when it finishes, whatever the outcome). app.lock().await.native_backend_pending = true; - let behavior = &user_config.behavior; deferred_streaming_startup(DeferredStreamingContext { app: Arc::clone(&app), - streaming_config: player::StreamingConfig { - device_name: behavior.streaming_device_name.clone(), - bitrate: crate::core::user_config::streaming_bitrate_or_default(behavior.streaming_bitrate), - audio_cache: behavior.streaming_audio_cache, - autoplay: behavior.streaming_autoplay, - cache_path: crate::infra::spotify::credentials::get_default_cache_path(), - initial_volume: runtime_state.volume_percent, - }, + streaming_config, device_startup_behavior, // A restored non-Spotify session owns the startup play/pause decision; // otherwise the deferred task fires it once the device is selected. @@ -556,7 +605,6 @@ pub(super) async fn prepare_frontend(boot: Boot) -> FrontendHandles { }, initial_shuffle_enabled, recovery_tx: streaming_recovery_tx, - recovery_rx: streaming_recovery_rx, shared_position: shared_position_for_events, shared_is_playing: shared_is_playing_for_events, #[cfg(all(feature = "mpris", target_os = "linux"))] @@ -568,6 +616,24 @@ pub(super) async fn prepare_frontend(boot: Boot) -> FrontendHandles { }); } + #[cfg(feature = "soloist")] + if soloist_attempted { + app.lock().await.soloist_starting(); + crate::infra::soloist::startup::deferred_soloist_startup( + Arc::clone(&app), + crate::infra::soloist::startup::SoloistStart::from_behavior( + &user_config.behavior, + runtime_state.volume_percent, + if restore_playback.is_some() { + None + } else { + Some(initial_startup_behavior) + }, + initial_shuffle_enabled, + ), + ); + } + let cloned_app = Arc::clone(&app); info!("spawning spotify network event handler"); tokio::spawn(async move { @@ -606,6 +672,9 @@ pub(super) async fn prepare_frontend(boot: Boot) -> FrontendHandles { let startup_behavior_runs_here = !streaming_attempted; #[cfg(not(feature = "streaming"))] let startup_behavior_runs_here = true; + // Soloist fires it at its first login. + #[cfg(feature = "soloist")] + let startup_behavior_runs_here = startup_behavior_runs_here && !soloist_attempted; if startup_behavior_runs_here { match initial_startup_behavior { StartupBehavior::Continue => {} @@ -732,6 +801,8 @@ pub(super) async fn shutdown_frontend( #[cfg(feature = "streaming")] let native_teardown = park_native_playback_before_exit(app).await; + #[cfg(feature = "soloist")] + crate::infra::soloist::supervisor::stop(std::time::Duration::from_millis(1500)).await; // Stop the collector and all network work it owns before the final sync and // clear. In particular, a pause-triggered now-playing push must not race the diff --git a/src/runtime/streaming/launch.rs b/src/runtime/streaming/launch.rs index 2dd5a77d..6ec2de07 100644 --- a/src/runtime/streaming/launch.rs +++ b/src/runtime/streaming/launch.rs @@ -42,8 +42,8 @@ impl StartupDeviceEvent { } /// Everything native streaming needs that used to gate the first frame: -/// account probe, librespot session handshake, player event handler, recovery -/// supervisor, and the saved-device startup decision. Bundled so +/// account probe, librespot session handshake, player event handler, and the +/// saved-device startup decision. Bundled so /// `deferred_streaming_startup` can run it all on a background task after the /// UI is already up. pub(crate) struct DeferredStreamingContext { @@ -56,7 +56,6 @@ pub(crate) struct DeferredStreamingContext { pub(crate) spotify_startup_behavior: Option, pub(crate) initial_shuffle_enabled: bool, pub(crate) recovery_tx: tokio::sync::mpsc::UnboundedSender, - pub(crate) recovery_rx: tokio::sync::mpsc::UnboundedReceiver, pub(crate) shared_position: Arc, pub(crate) shared_is_playing: Arc, #[cfg(all(feature = "mpris", target_os = "linux"))] @@ -68,9 +67,9 @@ pub(crate) struct DeferredStreamingContext { } /// Initialize native streaming in the background. The UI renders its -/// first frame immediately; this task spawns the recovery supervisor, probes -/// the account (reusing the auth `/me` when available), performs the librespot -/// handshake with the same double-timeout as before, stores the player in +/// first frame immediately; this task probes the account (reusing the auth +/// `/me` when available), performs the librespot handshake with the same +/// double-timeout as before, stores the player in /// `App`, spawns the player event handler, and finally makes the saved-device /// startup decision — dispatching its outcome through the normal IoEvent pump. // CLI mode never starts native streaming; only a frontend launch does. @@ -94,21 +93,6 @@ pub(crate) fn deferred_streaming_startup(ctx: DeferredStreamingContext) { } async fn deferred_streaming_startup_inner(ctx: DeferredStreamingContext) { - player::spawn_streaming_recovery_handler(player::StreamingRecoveryContext { - app: Arc::clone(&ctx.app), - shared_position: Arc::clone(&ctx.shared_position), - shared_is_playing: Arc::clone(&ctx.shared_is_playing), - recovery_rx: ctx.recovery_rx, - recovery_tx: ctx.recovery_tx.clone(), - streaming_config: ctx.streaming_config.clone(), - #[cfg(all(feature = "mpris", target_os = "linux"))] - mpris_manager: ctx.mpris_manager.clone(), - #[cfg(all(feature = "macos-media", target_os = "macos"))] - macos_media_manager: ctx.macos_media_manager.clone(), - #[cfg(all(feature = "windows-media", target_os = "windows"))] - windows_media_manager: ctx.windows_media_manager.clone(), - }); - // The session refuses a non-Premium account, so it answers the plan check. if let Err(reason) = session::current().await { info!("native streaming not started: {}", reason.message()); @@ -206,6 +190,7 @@ async fn deferred_streaming_startup_inner(ctx: DeferredStreamingContext) { // streaming). This used to run inline in the network task before the pump // started; the decision's outcome now dispatches through the pump. let device_name = streaming_player.device_name().to_string(); + let device_id = streaming_player.device_id(); let saved_device_id = ctx.app.lock().await.saved_spotify_device_id(); let mut devices_snapshot = None; if let Ok(devices) = @@ -225,6 +210,7 @@ async fn deferred_streaming_startup_inner(ctx: DeferredStreamingContext) { saved_device_id, devices_snapshot.as_deref(), &device_name, + &device_id, strings, ); diff --git a/src/runtime/streaming/mod.rs b/src/runtime/streaming/mod.rs index 67dab366..d7e05894 100644 --- a/src/runtime/streaming/mod.rs +++ b/src/runtime/streaming/mod.rs @@ -7,6 +7,21 @@ use crate::core::user_config::StartupBehavior; #[cfg(feature = "streaming")] pub(super) mod launch; +/// Soloist runs only when it is compiled in and Spotify ships a build for this CPU. +#[cfg(all(feature = "streaming", any(feature = "tui", feature = "gui")))] +pub(super) fn effective_engine( + setting: crate::core::user_config::PlaybackEngine, + soloist_compiled: bool, + intel_mac: bool, +) -> crate::core::user_config::PlaybackEngine { + use crate::core::user_config::PlaybackEngine; + if setting == PlaybackEngine::Soloist && soloist_compiled && !intel_mac { + PlaybackEngine::Soloist + } else { + PlaybackEngine::Librespot + } +} + #[derive(Debug, PartialEq, Eq)] enum StartupDeviceEvent { Transfer { @@ -30,6 +45,7 @@ fn startup_device_decision( saved_device_id: Option, devices_snapshot: Option<&[rspotify::model::device::Device]>, native_device_name: &str, + native_id: &str, strings: &crate::core::i18n::Strings, ) -> StartupDeviceDecision { if startup_behavior != StartupBehavior::Play { @@ -43,7 +59,8 @@ fn startup_device_decision( Some(saved_device_id) => { if let Some(devices) = devices_snapshot { let mut saved_device_available = false; - let mut native_device_id = None; + let mut by_id = None; + let mut by_name = None; for device in devices { if device.id.as_ref() == Some(&saved_device_id) { @@ -51,10 +68,15 @@ fn startup_device_decision( break; } - if native_device_id.is_none() && device.name.eq_ignore_ascii_case(native_device_name) { - native_device_id = device.id.clone(); + if by_id.is_none() && device.id.as_deref() == Some(native_id) { + by_id = device.id.clone(); + } + if by_name.is_none() && device.name.eq_ignore_ascii_case(native_device_name) { + by_name = device.id.clone(); } } + // Another device can carry the native name; the id is exact. + let native_device_id = by_id.or(by_name); if saved_device_available { Some(StartupDeviceEvent::Transfer { @@ -152,6 +174,7 @@ mod tests { saved_device_id, devices_snapshot, NATIVE_NAME, + NATIVE_ID, &crate::core::i18n::EN, ) .event @@ -254,6 +277,7 @@ mod tests { Some(EXTERNAL_ID.to_string()), Some(&devices), NATIVE_NAME, + NATIVE_ID, &crate::core::i18n::EN, ); @@ -270,6 +294,26 @@ mod tests { ); } + #[test] + fn play_with_unavailable_saved_device_picks_the_native_id_over_a_row_with_its_name() { + let devices = vec![ + device("leftover-device", NATIVE_NAME), + device(NATIVE_ID, NATIVE_NAME), + ]; + + assert_eq!( + startup_device_event( + StartupBehavior::Play, + Some(EXTERNAL_ID.to_string()), + Some(&devices) + ), + Some(StartupDeviceEvent::Transfer { + device_id: NATIVE_ID.to_string(), + persist_device_id: false, + }) + ); + } + #[test] fn play_with_unavailable_saved_device_auto_selects_native_without_persisting() { let devices = vec![device("other-device", "Other speaker")]; @@ -279,6 +323,7 @@ mod tests { Some(EXTERNAL_ID.to_string()), Some(&devices), NATIVE_NAME, + NATIVE_ID, &crate::core::i18n::EN, ); @@ -302,6 +347,7 @@ mod tests { Some(EXTERNAL_ID.to_string()), None, NATIVE_NAME, + NATIVE_ID, &crate::core::i18n::EN, ); @@ -315,3 +361,33 @@ mod tests { assert_eq!(decision.status_message, None); } } + +#[cfg(all(test, feature = "streaming", any(feature = "tui", feature = "gui")))] +mod engine_tests { + use super::effective_engine; + use crate::core::user_config::PlaybackEngine; + + #[test] + fn effective_engine_falls_back_when_soloist_is_not_compiled() { + assert_eq!( + effective_engine(PlaybackEngine::Soloist, false, false), + PlaybackEngine::Librespot + ); + assert_eq!( + effective_engine(PlaybackEngine::Soloist, true, false), + PlaybackEngine::Soloist + ); + } + + #[test] + fn effective_engine_picks_librespot_on_an_intel_mac() { + assert_eq!( + effective_engine(PlaybackEngine::Soloist, true, true), + PlaybackEngine::Librespot + ); + assert_eq!( + effective_engine(PlaybackEngine::Librespot, true, false), + PlaybackEngine::Librespot + ); + } +} diff --git a/src/tui/handlers/mod.rs b/src/tui/handlers/mod.rs index 7d715a4b..ab622562 100644 --- a/src/tui/handlers/mod.rs +++ b/src/tui/handlers/mod.rs @@ -115,6 +115,23 @@ pub fn handle_app(key: Key, app: &mut App) { return; } + if app.soloist_offer_shows() { + if key == Key::Enter { + app.apply(Action::AnswerSoloistOffer { set_up: true }); + } else if matches!(key, Key::Esc | Key::Char('q')) || key == app.user_config.keys.back { + app.apply(Action::AnswerSoloistOffer { set_up: false }); + } + return; + } + + // The Soloist pairing guide is a modal too, under the plugin popup. + if app.soloist_pairing_device().is_some() { + if matches!(key, Key::Esc | Key::Enter | Key::Char('q')) || key == app.user_config.keys.back { + app.apply(Action::DismissSoloistPairing); + } + return; + } + // Help filtering is an inline modal input. Give it priority over global // bindings so queries can contain keys such as d, n, p, q, or ?. if app.get_current_route().active_block == ActiveBlock::HelpMenu @@ -131,7 +148,8 @@ pub fn handle_app(key: Key, app: &mut App) { && (app.view.settings_unsaved_prompt_visible || app.view.settings_edit_mode || app.view.settings_filter_editing - || key == app.user_config.keys.search) + || key == app.user_config.keys.search + || (key == settings::BUTTON_KEY && settings::selected_row_has_button(app))) { settings::handler(key, app); return; diff --git a/src/tui/handlers/settings.rs b/src/tui/handlers/settings.rs index 3212a841..a05ae6a5 100644 --- a/src/tui/handlers/settings.rs +++ b/src/tui/handlers/settings.rs @@ -6,8 +6,20 @@ use crate::tui::handlers::common_key_events::{ down_event, left_event, on_down_press_handler, on_up_press_handler, right_event, up_event, }; use crate::tui::handlers::filter_input::{self, FilterEdit}; +pub(super) use crate::tui::ui::settings::BUTTON_KEY; use crate::tui::ui::settings::{filtered_setting_indices, selected_setting_position}; +fn selected_row_id(app: &App) -> Option<&str> { + app + .settings_items + .get(app.view.settings_selected_index) + .map(|setting| setting.id.as_str()) +} + +pub(super) fn selected_row_has_button(app: &App) -> bool { + selected_row_id(app).is_some_and(|id| app.setting_button_label(id).is_some()) +} + pub fn handler(key: Key, app: &mut App) { if app.view.settings_unsaved_prompt_visible { handle_unsaved_changes_prompt(key, app); @@ -40,6 +52,12 @@ fn handle_navigation(key: Key, app: &mut App) { // Enter edit mode Key::Enter => enter_edit_mode(app), + BUTTON_KEY => { + if let Some(id) = selected_row_id(app).map(str::to_string) { + app.apply(Action::PressSettingButton { id }); + } + } + // Save settings key if key == app.effective_save_settings_key() => { let _ = save_settings(app); @@ -640,6 +658,25 @@ mod tests { .unwrap_or_else(|| panic!("expected a {id} setting")) } + #[cfg(feature = "soloist")] + #[test] + fn o_presses_the_selected_row_button_past_the_global_jump_key() { + let mut app = App::default(); + assert_eq!(app.user_config.keys.jump_to_context, BUTTON_KEY); + open_settings(&mut app); + app.view.settings_selected_index = setting_index(&app, "behavior.soloist_path"); + + handle_app(BUTTON_KEY, &mut app); + + assert_eq!( + app + .setting_button_label("behavior.soloist_path") + .map(|(_, busy)| busy), + Some(true) + ); + assert_eq!(app.get_current_route().id, RouteId::Settings); + } + fn first_bool_setting_index(app: &App) -> usize { app .settings_items diff --git a/src/tui/help_english_5d66123b.json b/src/tui/help_english_5d66123b.json index 6a728b3e..bbaca952 100644 --- a/src/tui/help_english_5d66123b.json +++ b/src/tui/help_english_5d66123b.json @@ -100,22 +100,23 @@ ["Toggle DJ auto-queue (keep the queue topped up)", "", "AI DJ"], ["Shift the vibe (drop the DJ's queued tracks and re-ask)", "", "AI DJ"], ["Toggle \"only tracks I do not already have\"", "", "AI DJ"], - ["Choose which AI and model the DJ uses", "", "AI DJ"] + ["Choose which AI and model the DJ uses", "", "AI DJ"], + ["Press the selected setting's button", "o", "Settings"] ], "views": [ - ["Spotify", false, [0, 1, 2, 3, [4, "Jump to currently playing album (needs Spotify)"], [5, "Jump to currently playing artist's album list (needs Spotify)"], [6, "Jump to current play context (needs Spotify)"], 7, 8, 9, 10, 11, 12, 13, 14, [15, "Copy url to currently playing song/episode (needs Spotify)"], [16, "Copy url to currently playing album/show (needs Spotify)"], 17, 18, 19, 20, 21, 22, 23, 24, [25, "Enter input for search (needs Spotify)"], 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 47, 48, 49, 50, 51, [54, "Clear playlist track search filter (needs Spotify)"], 55, 59, 60, 61, 62, 63, 64, 65, 66, 67, 76, 80, 81, 82, 83, 84, [85, "Toggle saved state for currently playing track/episode (needs Spotify)"], [86, "Favorite highlighted/playing radio station (Internet Radio only)"], 87, 88, 89, 91, 92, 93, 94, [95, "Open Listening Party menu (needs Spotify)"], 96, 97, 98, 99, 100]], - ["Local", false, [0, 1, 2, 3, [4, "Jump to currently playing album (needs Spotify)"], [5, "Jump to currently playing artist's album list (needs Spotify)"], [6, "Jump to current play context (needs Spotify)"], 7, 8, 9, 10, 11, 12, 13, 14, [15, "Copy url to currently playing song/episode (needs Spotify)"], [16, "Copy url to currently playing album/show (needs Spotify)"], 17, 18, 19, 20, 21, 22, 23, 24, [25, "Enter input for search (not for Local Files)"], 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 47, 48, 49, 50, 51, [54, "Clear playlist track search filter (Spotify only)"], 55, 59, 60, 61, 62, 63, 64, 65, 66, 67, 76, 80, 81, 82, 83, 84, [85, "Toggle saved state for currently playing track/episode (not for Local Files)"], [86, "Favorite highlighted/playing radio station (Internet Radio only)"], 87, 88, 89, 91, 92, 93, 94, [95, "Open Listening Party menu (needs Spotify)"], 96, 97, 98, 99, 100]], - ["Subsonic", false, [0, 1, 2, 3, [4, "Jump to currently playing album (needs Spotify)"], [5, "Jump to currently playing artist's album list (needs Spotify)"], [6, "Jump to current play context (needs Spotify)"], 7, 8, 9, 10, 11, 12, 13, 14, [15, "Copy url to currently playing song/episode (needs Spotify)"], [16, "Copy url to currently playing album/show (needs Spotify)"], 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 47, 48, 49, 50, 51, [54, "Clear playlist track search filter (Spotify only)"], 55, 58, 59, 60, 61, 62, 63, 64, 65, 66, 67, 72, 76, 80, 81, 82, 83, 84, [85, "Toggle saved state for currently playing track/episode (not for Subsonic)"], [86, "Favorite highlighted/playing radio station (Internet Radio only)"], 87, 88, 89, 91, 92, 93, 94, [95, "Open Listening Party menu (needs Spotify)"], 96, 97, 98, 99, 100]], - ["Radio", false, [0, 1, 2, 3, [4, "Jump to currently playing album (needs Spotify)"], [5, "Jump to currently playing artist's album list (needs Spotify)"], [6, "Jump to current play context (needs Spotify)"], 7, 8, 9, 10, 11, 12, 13, 14, [15, "Copy url to currently playing song/episode (needs Spotify)"], [16, "Copy url to currently playing album/show (needs Spotify)"], 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 47, 48, 49, 50, 51, [54, "Clear playlist track search filter (Spotify only)"], 55, 58, 59, 60, 61, 62, 63, 64, 65, 66, 67, 73, 76, 80, 81, 82, 83, 84, [85, "Toggle saved state for currently playing track/episode (not for Internet Radio)"], 86, 87, 88, 89, 91, 92, 93, 94, [95, "Open Listening Party menu (needs Spotify)"], 96, 97, 98, 99, 100]], - ["YouTube", false, [0, 1, 2, 3, [4, "Jump to currently playing album (needs Spotify)"], [5, "Jump to currently playing artist's album list (needs Spotify)"], [6, "Jump to current play context (needs Spotify)"], 7, 8, 9, 10, 11, 12, 13, 14, [15, "Copy url to currently playing song/episode (needs Spotify)"], [16, "Copy url to currently playing album/show (needs Spotify)"], 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 44, 47, 48, 49, 50, 51, 52, [54, "Clear playlist track search filter (Spotify only)"], 55, 58, 59, 60, 61, 62, 63, 64, 65, 66, 67, 71, 72, 76, 80, 81, 82, 83, 84, [85, "Toggle saved state for currently playing track/episode (not for YouTube)"], [86, "Favorite highlighted/playing radio station (Internet Radio only)"], 87, 88, 89, 91, 92, 93, 94, [95, "Open Listening Party menu (needs Spotify)"], 96, 97, 98, 99, 100]], - ["Qobuz", false, [0, 1, 2, 3, [4, "Jump to currently playing album (needs Spotify)"], [5, "Jump to currently playing artist's album list (needs Spotify)"], [6, "Jump to current play context (needs Spotify)"], 7, 8, 9, 10, 11, 12, 13, 14, [15, "Copy url to currently playing song/episode (needs Spotify)"], [16, "Copy url to currently playing album/show (needs Spotify)"], 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 47, 48, 49, 50, 51, [54, "Clear playlist track search filter (Spotify only)"], 55, 58, 59, 60, 61, 62, 63, 64, 65, 66, 67, 72, 76, 80, 81, 82, 83, 84, [85, "Toggle saved state for currently playing track/episode (not for Qobuz)"], [86, "Favorite highlighted/playing radio station (Internet Radio only)"], 87, 88, 89, 91, 92, 93, 94, [95, "Open Listening Party menu (needs Spotify)"], 96, 97, 98, 99, 100]], - ["AppleMusic", false, [0, 1, 2, 3, [4, "Jump to currently playing album (needs Spotify)"], [5, "Jump to currently playing artist's album list (needs Spotify)"], [6, "Jump to current play context (needs Spotify)"], 7, 8, 9, 10, 11, 12, 13, 14, [15, "Copy url to currently playing song/episode (needs Spotify)"], [16, "Copy url to currently playing album/show (needs Spotify)"], 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 47, 48, 49, 50, 51, [54, "Clear playlist track search filter (Spotify only)"], 55, 58, 59, 60, 61, 62, 63, 64, 65, 66, 67, 76, 80, 81, 82, 83, 84, [85, "Toggle saved state for currently playing track/episode (not for Apple Music)"], [86, "Favorite highlighted/playing radio station (Internet Radio only)"], 87, 88, 89, 91, 92, 93, 94, [95, "Open Listening Party menu (needs Spotify)"], 96, 97, 98, 99, 100]], - ["Spotify", true, [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72, 74, 75, 76, 77, 78, 79, 80, 81, 82, 83, 84, 85, [86, "Favorite highlighted/playing radio station (Internet Radio only)"], 87, 88, 89, 90, 91, 92, 93, 94, 95, 96, 97, 98, 99, 100]], - ["Local", true, [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, [25, "Enter input for search (not for Local Files)"], 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 45, 46, 47, 48, 49, 50, 51, [54, "Clear playlist track search filter (Spotify only)"], 55, 59, 60, 61, 62, 63, 64, 65, 66, 67, 76, 80, 81, 82, 83, 84, [85, "Toggle saved state for currently playing track/episode (not for Local Files)"], [86, "Favorite highlighted/playing radio station (Internet Radio only)"], 87, 88, 89, 90, 91, 92, 93, 94, 95, 96, 97, 98, 99, 100]], - ["Subsonic", true, [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 45, 46, 47, 48, 49, 50, 51, [54, "Clear playlist track search filter (Spotify only)"], 55, 58, 59, 60, 61, 62, 63, 64, 65, 66, 67, 72, 76, 80, 81, 82, 83, 84, [85, "Toggle saved state for currently playing track/episode (not for Subsonic)"], [86, "Favorite highlighted/playing radio station (Internet Radio only)"], 87, 88, 89, 90, 91, 92, 93, 94, 95, 96, 97, 98, 99, 100]], - ["Radio", true, [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 45, 46, 47, 48, 49, 50, 51, [54, "Clear playlist track search filter (Spotify only)"], 55, 58, 59, 60, 61, 62, 63, 64, 65, 66, 67, 73, 76, 80, 81, 82, 83, 84, [85, "Toggle saved state for currently playing track/episode (not for Internet Radio)"], 86, 87, 88, 89, 90, 91, 92, 93, 94, 95, 96, 97, 98, 99, 100]], - ["YouTube", true, [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 44, 45, 46, 47, 48, 49, 50, 51, 52, [54, "Clear playlist track search filter (Spotify only)"], 55, 58, 59, 60, 61, 62, 63, 64, 65, 66, 67, 71, 72, 76, 80, 81, 82, 83, 84, [85, "Toggle saved state for currently playing track/episode (not for YouTube)"], [86, "Favorite highlighted/playing radio station (Internet Radio only)"], 87, 88, 89, 90, 91, 92, 93, 94, 95, 96, 97, 98, 99, 100]], - ["Qobuz", true, [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 45, 46, 47, 48, 49, 50, 51, [54, "Clear playlist track search filter (Spotify only)"], 55, 58, 59, 60, 61, 62, 63, 64, 65, 66, 67, 72, 76, 80, 81, 82, 83, 84, [85, "Toggle saved state for currently playing track/episode (not for Qobuz)"], [86, "Favorite highlighted/playing radio station (Internet Radio only)"], 87, 88, 89, 90, 91, 92, 93, 94, 95, 96, 97, 98, 99, 100]], - ["AppleMusic", true, [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 45, 46, 47, 48, 49, 50, 51, [54, "Clear playlist track search filter (Spotify only)"], 55, 58, 59, 60, 61, 62, 63, 64, 65, 66, 67, 76, 80, 81, 82, 83, 84, [85, "Toggle saved state for currently playing track/episode (not for Apple Music)"], [86, "Favorite highlighted/playing radio station (Internet Radio only)"], 87, 88, 89, 90, 91, 92, 93, 94, 95, 96, 97, 98, 99, 100]] + ["Spotify", false, [0, 1, 2, 3, [4, "Jump to currently playing album (needs Spotify)"], [5, "Jump to currently playing artist's album list (needs Spotify)"], [6, "Jump to current play context (needs Spotify)"], 7, 8, 9, 10, 11, 12, 13, 14, [15, "Copy url to currently playing song/episode (needs Spotify)"], [16, "Copy url to currently playing album/show (needs Spotify)"], 17, 18, 19, 20, 21, 22, 23, 24, [25, "Enter input for search (needs Spotify)"], 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 47, 48, 49, 50, 51, [54, "Clear playlist track search filter (needs Spotify)"], 55, 59, 60, 61, 62, 63, 64, 65, 66, 67, 76, 80, 81, 82, 83, 84, [85, "Toggle saved state for currently playing track/episode (needs Spotify)"], [86, "Favorite highlighted/playing radio station (Internet Radio only)"], 87, 88, 89, 91, 92, 93, 94, [95, "Open Listening Party menu (needs Spotify)"], 96, 97, 98, 99, 100, 101]], + ["Local", false, [0, 1, 2, 3, [4, "Jump to currently playing album (needs Spotify)"], [5, "Jump to currently playing artist's album list (needs Spotify)"], [6, "Jump to current play context (needs Spotify)"], 7, 8, 9, 10, 11, 12, 13, 14, [15, "Copy url to currently playing song/episode (needs Spotify)"], [16, "Copy url to currently playing album/show (needs Spotify)"], 17, 18, 19, 20, 21, 22, 23, 24, [25, "Enter input for search (not for Local Files)"], 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 47, 48, 49, 50, 51, [54, "Clear playlist track search filter (Spotify only)"], 55, 59, 60, 61, 62, 63, 64, 65, 66, 67, 76, 80, 81, 82, 83, 84, [85, "Toggle saved state for currently playing track/episode (not for Local Files)"], [86, "Favorite highlighted/playing radio station (Internet Radio only)"], 87, 88, 89, 91, 92, 93, 94, [95, "Open Listening Party menu (needs Spotify)"], 96, 97, 98, 99, 100, 101]], + ["Subsonic", false, [0, 1, 2, 3, [4, "Jump to currently playing album (needs Spotify)"], [5, "Jump to currently playing artist's album list (needs Spotify)"], [6, "Jump to current play context (needs Spotify)"], 7, 8, 9, 10, 11, 12, 13, 14, [15, "Copy url to currently playing song/episode (needs Spotify)"], [16, "Copy url to currently playing album/show (needs Spotify)"], 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 47, 48, 49, 50, 51, [54, "Clear playlist track search filter (Spotify only)"], 55, 58, 59, 60, 61, 62, 63, 64, 65, 66, 67, 72, 76, 80, 81, 82, 83, 84, [85, "Toggle saved state for currently playing track/episode (not for Subsonic)"], [86, "Favorite highlighted/playing radio station (Internet Radio only)"], 87, 88, 89, 91, 92, 93, 94, [95, "Open Listening Party menu (needs Spotify)"], 96, 97, 98, 99, 100, 101]], + ["Radio", false, [0, 1, 2, 3, [4, "Jump to currently playing album (needs Spotify)"], [5, "Jump to currently playing artist's album list (needs Spotify)"], [6, "Jump to current play context (needs Spotify)"], 7, 8, 9, 10, 11, 12, 13, 14, [15, "Copy url to currently playing song/episode (needs Spotify)"], [16, "Copy url to currently playing album/show (needs Spotify)"], 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 47, 48, 49, 50, 51, [54, "Clear playlist track search filter (Spotify only)"], 55, 58, 59, 60, 61, 62, 63, 64, 65, 66, 67, 73, 76, 80, 81, 82, 83, 84, [85, "Toggle saved state for currently playing track/episode (not for Internet Radio)"], 86, 87, 88, 89, 91, 92, 93, 94, [95, "Open Listening Party menu (needs Spotify)"], 96, 97, 98, 99, 100, 101]], + ["YouTube", false, [0, 1, 2, 3, [4, "Jump to currently playing album (needs Spotify)"], [5, "Jump to currently playing artist's album list (needs Spotify)"], [6, "Jump to current play context (needs Spotify)"], 7, 8, 9, 10, 11, 12, 13, 14, [15, "Copy url to currently playing song/episode (needs Spotify)"], [16, "Copy url to currently playing album/show (needs Spotify)"], 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 44, 47, 48, 49, 50, 51, 52, [54, "Clear playlist track search filter (Spotify only)"], 55, 58, 59, 60, 61, 62, 63, 64, 65, 66, 67, 71, 72, 76, 80, 81, 82, 83, 84, [85, "Toggle saved state for currently playing track/episode (not for YouTube)"], [86, "Favorite highlighted/playing radio station (Internet Radio only)"], 87, 88, 89, 91, 92, 93, 94, [95, "Open Listening Party menu (needs Spotify)"], 96, 97, 98, 99, 100, 101]], + ["Qobuz", false, [0, 1, 2, 3, [4, "Jump to currently playing album (needs Spotify)"], [5, "Jump to currently playing artist's album list (needs Spotify)"], [6, "Jump to current play context (needs Spotify)"], 7, 8, 9, 10, 11, 12, 13, 14, [15, "Copy url to currently playing song/episode (needs Spotify)"], [16, "Copy url to currently playing album/show (needs Spotify)"], 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 47, 48, 49, 50, 51, [54, "Clear playlist track search filter (Spotify only)"], 55, 58, 59, 60, 61, 62, 63, 64, 65, 66, 67, 72, 76, 80, 81, 82, 83, 84, [85, "Toggle saved state for currently playing track/episode (not for Qobuz)"], [86, "Favorite highlighted/playing radio station (Internet Radio only)"], 87, 88, 89, 91, 92, 93, 94, [95, "Open Listening Party menu (needs Spotify)"], 96, 97, 98, 99, 100, 101]], + ["AppleMusic", false, [0, 1, 2, 3, [4, "Jump to currently playing album (needs Spotify)"], [5, "Jump to currently playing artist's album list (needs Spotify)"], [6, "Jump to current play context (needs Spotify)"], 7, 8, 9, 10, 11, 12, 13, 14, [15, "Copy url to currently playing song/episode (needs Spotify)"], [16, "Copy url to currently playing album/show (needs Spotify)"], 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 47, 48, 49, 50, 51, [54, "Clear playlist track search filter (Spotify only)"], 55, 58, 59, 60, 61, 62, 63, 64, 65, 66, 67, 76, 80, 81, 82, 83, 84, [85, "Toggle saved state for currently playing track/episode (not for Apple Music)"], [86, "Favorite highlighted/playing radio station (Internet Radio only)"], 87, 88, 89, 91, 92, 93, 94, [95, "Open Listening Party menu (needs Spotify)"], 96, 97, 98, 99, 100, 101]], + ["Spotify", true, [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72, 74, 75, 76, 77, 78, 79, 80, 81, 82, 83, 84, 85, [86, "Favorite highlighted/playing radio station (Internet Radio only)"], 87, 88, 89, 90, 91, 92, 93, 94, 95, 96, 97, 98, 99, 100, 101]], + ["Local", true, [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, [25, "Enter input for search (not for Local Files)"], 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 45, 46, 47, 48, 49, 50, 51, [54, "Clear playlist track search filter (Spotify only)"], 55, 59, 60, 61, 62, 63, 64, 65, 66, 67, 76, 80, 81, 82, 83, 84, [85, "Toggle saved state for currently playing track/episode (not for Local Files)"], [86, "Favorite highlighted/playing radio station (Internet Radio only)"], 87, 88, 89, 90, 91, 92, 93, 94, 95, 96, 97, 98, 99, 100, 101]], + ["Subsonic", true, [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 45, 46, 47, 48, 49, 50, 51, [54, "Clear playlist track search filter (Spotify only)"], 55, 58, 59, 60, 61, 62, 63, 64, 65, 66, 67, 72, 76, 80, 81, 82, 83, 84, [85, "Toggle saved state for currently playing track/episode (not for Subsonic)"], [86, "Favorite highlighted/playing radio station (Internet Radio only)"], 87, 88, 89, 90, 91, 92, 93, 94, 95, 96, 97, 98, 99, 100, 101]], + ["Radio", true, [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 45, 46, 47, 48, 49, 50, 51, [54, "Clear playlist track search filter (Spotify only)"], 55, 58, 59, 60, 61, 62, 63, 64, 65, 66, 67, 73, 76, 80, 81, 82, 83, 84, [85, "Toggle saved state for currently playing track/episode (not for Internet Radio)"], 86, 87, 88, 89, 90, 91, 92, 93, 94, 95, 96, 97, 98, 99, 100, 101]], + ["YouTube", true, [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 44, 45, 46, 47, 48, 49, 50, 51, 52, [54, "Clear playlist track search filter (Spotify only)"], 55, 58, 59, 60, 61, 62, 63, 64, 65, 66, 67, 71, 72, 76, 80, 81, 82, 83, 84, [85, "Toggle saved state for currently playing track/episode (not for YouTube)"], [86, "Favorite highlighted/playing radio station (Internet Radio only)"], 87, 88, 89, 90, 91, 92, 93, 94, 95, 96, 97, 98, 99, 100, 101]], + ["Qobuz", true, [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 45, 46, 47, 48, 49, 50, 51, [54, "Clear playlist track search filter (Spotify only)"], 55, 58, 59, 60, 61, 62, 63, 64, 65, 66, 67, 72, 76, 80, 81, 82, 83, 84, [85, "Toggle saved state for currently playing track/episode (not for Qobuz)"], [86, "Favorite highlighted/playing radio station (Internet Radio only)"], 87, 88, 89, 90, 91, 92, 93, 94, 95, 96, 97, 98, 99, 100, 101]], + ["AppleMusic", true, [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 45, 46, 47, 48, 49, 50, 51, [54, "Clear playlist track search filter (Spotify only)"], 55, 58, 59, 60, 61, 62, 63, 64, 65, 66, 67, 76, 80, 81, 82, 83, 84, [85, "Toggle saved state for currently playing track/episode (not for Apple Music)"], [86, "Favorite highlighted/playing radio station (Internet Radio only)"], 87, 88, 89, 90, 91, 92, 93, 94, 95, 96, 97, 98, 99, 100, 101]] ] } diff --git a/src/tui/keymap.rs b/src/tui/keymap.rs index d0ae9793..e3d5fa4e 100644 --- a/src/tui/keymap.rs +++ b/src/tui/keymap.rs @@ -510,6 +510,8 @@ pub fn help_entries_for(strings: &crate::core::i18n::Strings) -> Vec Binding(|k| k.dj_pick_model), help.context_ai_dj, ), + // Last, so the pinned English table keeps its indices. + row(help.settings_button, Literal("o"), help.context_settings), ] } @@ -749,6 +751,7 @@ pub fn default_binding(action: &Action) -> Exposure { Action::SetTheme(_) => Unbound(PLUGIN_ONLY), Action::SaveSettings => binding(|k| k.save_settings), Action::SetSetting { .. } => Unbound("the GUI's settings page sets one row"), + Action::PressSettingButton { .. } => literal("o", "Settings"), Action::CycleVisualizerStyle => literal("V", "Audio analysis"), Action::SetScreenContent { .. } => Unbound(PLUGIN_ONLY), Action::ShowScreen(_) => Unbound(PLUGIN_ONLY), @@ -829,6 +832,9 @@ pub fn default_binding(action: &Action) -> Exposure { Action::AnswerCommunityPinPrompt { keep: true } => literal(ENTER, "Community pin prompt"), Action::OpenRecap => literal(ENTER, "Recap prompt"), Action::DismissRecapPrompt => literal(ESC, "Recap prompt"), + Action::DismissSoloistPairing => literal(ESC, "Soloist pairing guide"), + Action::AnswerSoloistOffer { set_up: false } => literal(ESC, "Soloist offer"), + Action::AnswerSoloistOffer { set_up: true } => literal(ENTER, "Soloist offer"), Action::DisableRecapPrompt => literal("d", "Recap prompt"), Action::DismissAnnouncement { .. } => literal(ENTER, "Announcement prompt"), Action::OpenAnnouncementUrl { .. } => { @@ -1439,10 +1445,13 @@ mod tests { id: text(), value: SettingInput::Bool(true), }, + Action::PressSettingButton { id: text() }, Action::CycleVisualizerStyle, Action::AnswerCommunityPinPrompt { keep: false }, Action::OpenRecap, Action::DismissRecapPrompt, + Action::DismissSoloistPairing, + Action::AnswerSoloistOffer { set_up: true }, Action::DisableRecapPrompt, Action::DismissAnnouncement { id: text() }, Action::OpenAnnouncementUrl { id: text() }, diff --git a/src/tui/runner.rs b/src/tui/runner.rs index 253782dc..1a8ef9a3 100644 --- a/src/tui/runner.rs +++ b/src/tui/runner.rs @@ -131,6 +131,9 @@ fn dispatch_key(key: Key, app: &mut App) -> bool { } _ => {} } + } else if app.soloist_offer_shows() || app.soloist_pairing_device().is_some() { + // A Soloist modal sits over every screen, an open input included. + handlers::handle_app(key, app); } else if current_active_block == ActiveBlock::Input { handlers::input_handler(key, app); } else if key_reaches_handlers_before_back(app, key) { @@ -512,6 +515,20 @@ mod tests { assert!(dispatch_key(Key::Char('y'), &mut app)); } + + #[cfg(feature = "soloist")] + #[test] + fn the_back_key_closes_a_soloist_modal_over_an_input_instead_of_the_screen() { + let mut app = app(); + app.push_navigation_stack(RouteId::Search, ActiveBlock::Input); + app.soloist_notice(crate::infra::soloist::Notice::NotPaired); + assert!(app.soloist_pairing_device().is_some()); + + assert!(!dispatch_key(app.user_config.keys.back, &mut app)); + + assert_eq!(app.soloist_pairing_device(), None); + assert_eq!(app.get_current_route().active_block, ActiveBlock::Input); + } } /// The terminal state `start_ui` leaves for the exit path; fields drop in the order the old locals did. @@ -685,6 +702,8 @@ pub async fn start_ui( _ => ui::draw_main_layout(f, &app), } + ui::draw_soloist_pairing(f, &app); + ui::draw_soloist_offer(f, &app); // Plugin popup overlays every screen. ui::draw_plugin_popup(f, &app); diff --git a/src/tui/ui/mod.rs b/src/tui/ui/mod.rs index 29c0c3b5..14e84479 100644 --- a/src/tui/ui/mod.rs +++ b/src/tui/ui/mod.rs @@ -42,7 +42,7 @@ pub use self::plugin_screen::draw_plugin_screen; pub use self::popups::{ draw_announcement_prompt, draw_community_pin_prompt, draw_dialog, draw_error_screen, draw_exit_prompt, draw_help_menu, draw_party, draw_plugin_popup, draw_queue, draw_recap_prompt, - draw_sort_menu, ensure_help_menu_model, + draw_soloist_offer, draw_soloist_pairing, draw_sort_menu, ensure_help_menu_model, }; pub use self::search::{draw_input_and_help_box, draw_search_results}; pub use self::stats::draw_stats; diff --git a/src/tui/ui/player.rs b/src/tui/ui/player.rs index e78cd8c8..5f145d1b 100644 --- a/src/tui/ui/player.rs +++ b/src/tui/ui/player.rs @@ -562,7 +562,9 @@ fn playbar_controls_available(app: &App) -> bool { } if app.current_playback_context.as_ref().is_some_and(|ctx| { - ctx.item.is_some() || (app.is_streaming_active && app.native_device_id.is_some()) + ctx.item.is_some() + || (app.is_streaming_active && app.native_device_id.is_some()) + || app.soloist_is_target() }) { return true; } @@ -770,6 +772,33 @@ fn draw_playbar_controls(f: &mut Frame<'_>, app: &App, controls_area: Rect) { } } +/// A badge on the free left part of the controls row, up to the first control. +fn draw_playbar_badge(f: &mut Frame<'_>, app: &App, controls_area: Rect, badge: String) { + let first_control = playbar_control_hitboxes_in_area_localized( + controls_area, + &app.user_config.behavior, + &playbar_supported_controls(app), + &app.strings().screens, + ) + .into_iter() + .map(|hitbox| hitbox.rect.x) + .min() + .unwrap_or_else(|| controls_area.right()); + let width = first_control + .saturating_sub(controls_area.x) + .saturating_sub(1); + if width == 0 || controls_area.height == 0 { + return; + } + let style = Style::default() + .fg(app.user_config.theme.playbar_text.into()) + .add_modifier(app.user_config.behavior.emphasis(Modifier::DIM)); + f.render_widget( + Paragraph::new(Span::styled(badge, style)), + Rect::new(controls_area.x, controls_area.y, width, 1), + ); +} + #[cfg(feature = "cover-art")] pub fn draw_cover_art_view(f: &mut Frame<'_>, app: &App) { let (content_area, playbar_area) = fullscreen_view_layout(&app.runtime_state, f.area()); @@ -1566,6 +1595,9 @@ pub fn draw_playbar(f: &mut Frame<'_>, app: &App, layout_chunk: Rect) { ); f.render_widget(artist, artist_area); draw_playbar_controls(f, app, playbar_areas.controls_area); + if let Some(quality) = app.soloist_quality_label() { + draw_playbar_badge(f, app, playbar_areas.controls_area, quality); + } let progress_ms = match app.seek_ms { Some(seek_ms) => seek_ms, @@ -1649,7 +1681,8 @@ pub fn draw_playbar(f: &mut Frame<'_>, app: &App, layout_chunk: Rect) { } drew_playbar = true; - } else if app.is_streaming_active && app.native_device_id.is_some() { + } else if (app.is_streaming_active && app.native_device_id.is_some()) || app.soloist_is_target() + { let shuffle_text = if current_playback_context.shuffle_state { app.strings().screens.on } else { @@ -2448,6 +2481,31 @@ mod tests { ); } + #[test] + fn playbar_badge_fills_the_free_left_part_of_the_controls_row() { + let app = App::default(); + let area = Rect::new(0, 0, 100, 1); + let mut terminal = ratatui::Terminal::new(ratatui::backend::TestBackend::new(100, 1)).unwrap(); + terminal + .draw(|f| { + draw_playbar_controls(f, &app, area); + draw_playbar_badge(f, &app, area, "FLAC 44.1".to_string()); + }) + .unwrap(); + let row: String = terminal + .backend() + .buffer() + .content() + .iter() + .map(|cell| cell.symbol()) + .collect(); + assert!(row.starts_with("FLAC 44.1"), "badge at the left: {row}"); + assert!( + row.trim_end().len() > "FLAC 44.1".len(), + "controls kept: {row}" + ); + } + /// A live (radio) view must render the station, the ICY subtitle and a LIVE /// elapsed label — and must not divide by the zero duration (which would show /// a bogus `0:00/0:00` countdown instead). diff --git a/src/tui/ui/popups.rs b/src/tui/ui/popups.rs index 36fe8f15..1e3ff4a1 100644 --- a/src/tui/ui/popups.rs +++ b/src/tui/ui/popups.rs @@ -1366,6 +1366,134 @@ pub fn draw_recap_prompt(f: &mut Frame<'_>, app: &App) { f.render_widget(paragraph, rect); } +/// The offer to set up Soloist, after librespot failed several tracks in a row. +pub fn draw_soloist_offer(f: &mut Frame<'_>, app: &App) { + if !app.soloist_offer_shows() { + return; + } + let strings = &app.strings().popups; + let width = std::cmp::min(f.area().width.saturating_sub(4), 70); + let rect = f + .area() + .centered(Constraint::Length(width), Constraint::Length(13)); + f.render_widget(Clear, rect); + let text = vec![ + Line::from(strings.soloist_offer_body), + Line::from(""), + Line::from(strings.soloist_offer_step_download), + Line::from(strings.soloist_offer_step_key), + Line::from(strings.soloist_offer_step_engine), + Line::from(""), + Line::from(Span::styled( + strings.soloist_offer_controls, + Style::default().fg(app.user_config.theme.inactive.into()), + )), + ]; + let paragraph = Paragraph::new(text) + .style(app.user_config.theme.base_style()) + .wrap(Wrap { trim: false }) + .block( + Block::default() + .borders(Borders::ALL) + .style(app.user_config.theme.base_style()) + .border_style(Style::default().fg(app.user_config.theme.active.into())) + .title(strings.soloist_offer_title), + ); + f.render_widget(paragraph, rect); +} + +/// The Soloist pairing guide over every screen, until Soloist logs in or Esc closes it. +pub fn draw_soloist_pairing(f: &mut Frame<'_>, app: &App) { + let Some(device) = app.soloist_pairing_device() else { + return; + }; + let strings = &app.strings().popups; + let width = std::cmp::min(f.area().width.saturating_sub(4), 60); + let rect = f + .area() + .centered(Constraint::Length(width), Constraint::Length(9)); + f.render_widget(Clear, rect); + let text = vec![ + Line::from(strings.soloist_pair_open), + Line::from(strings.soloist_pair_pick(device)), + Line::from(""), + Line::from(Span::styled( + strings.soloist_pair_waiting, + Style::default().fg(app.user_config.theme.active.into()), + )), + Line::from(""), + Line::from(Span::styled( + strings.plugin_close_hint, + Style::default().fg(app.user_config.theme.inactive.into()), + )), + ]; + let paragraph = Paragraph::new(text) + .style(app.user_config.theme.base_style()) + .wrap(Wrap { trim: false }) + .block( + Block::default() + .borders(Borders::ALL) + .style(app.user_config.theme.base_style()) + .border_style(Style::default().fg(app.user_config.theme.active.into())) + .title(strings.soloist_pair_title), + ); + f.render_widget(paragraph, rect); +} + +#[cfg(all(test, feature = "soloist"))] +mod soloist_pairing_tests { + use super::*; + use crate::tui::event::Key; + use ratatui::{backend::TestBackend, Terminal}; + + #[test] + fn the_pairing_guide_names_the_device_until_esc_closes_it() { + let mut app = App::default(); + app.soloist_notice(crate::infra::soloist::Notice::NotPaired); + let mut terminal = Terminal::new(TestBackend::new(80, 20)).unwrap(); + terminal.draw(|f| draw_soloist_pairing(f, &app)).unwrap(); + let content: String = terminal + .backend() + .buffer() + .content() + .iter() + .map(|cell| cell.symbol()) + .collect(); + let pick = app + .strings() + .popups + .soloist_pair_pick(&app.user_config.behavior.streaming_device_name); + assert!(content.contains(&pick), "{content}"); + + crate::tui::handlers::handle_app(Key::Esc, &mut app); + assert_eq!(app.soloist_pairing_device(), None); + } + + #[test] + fn the_soloist_offer_lists_the_steps_until_esc_closes_it() { + let mut app = App::default(); + app.offer_soloist_setup(); + let mut terminal = Terminal::new(TestBackend::new(80, 20)).unwrap(); + terminal.draw(|f| draw_soloist_offer(f, &app)).unwrap(); + let content: String = terminal + .backend() + .buffer() + .content() + .iter() + .map(|cell| cell.symbol()) + .collect(); + let strings = &app.strings().popups; + assert!(content.contains(strings.soloist_offer_title), "{content}"); + assert!( + content.contains(strings.soloist_offer_step_key), + "{content}" + ); + + crate::tui::handlers::handle_app(Key::Esc, &mut app); + assert!(!app.soloist_offer_shows()); + } +} + pub fn draw_community_pin_prompt(f: &mut Frame<'_>, app: &App) { let width = std::cmp::min(f.area().width.saturating_sub(4), 66); let height = 14; diff --git a/src/tui/ui/settings.rs b/src/tui/ui/settings.rs index 38af22b8..2d6e88dc 100644 --- a/src/tui/ui/settings.rs +++ b/src/tui/ui/settings.rs @@ -1,4 +1,6 @@ use crate::core::app::{setting_requirement, App, SettingItem, SettingValue, SettingsCategory}; +use crate::core::user_config::key_to_config_string; +use crate::tui::event::Key; use crate::tui::fuzzy::fuzzy_match; use crate::tui::theme::{EmphasisExt, ThemeExt}; use crate::tui::ui::popups::highlighted_spans; @@ -36,6 +38,9 @@ pub fn settings_layout(area: Rect) -> [Rect; 3] { /// its config id). pub type FilteredSetting = (usize, Vec<(usize, usize)>); +/// Presses the selected row's button; global `jump_to_context` keeps the key on other rows. +pub const BUTTON_KEY: Key = Key::Char('o'); + /// Rows that survive the active filter, best match first. With no filter this /// is every row in schema order. pub fn filtered_settings(app: &App) -> Vec { @@ -258,6 +263,20 @@ fn draw_settings_list(f: &mut Frame<'_>, app: &App, area: Rect) { Style::default().fg(app.user_config.theme.inactive.into()), )); } + if let Some((label, busy)) = app + .setting_button_label(&setting.id) + .filter(|_| is_selected) + { + let text = if busy { + format!(" {label}") + } else { + format!(" [{}] {label}", key_to_config_string(&BUTTON_KEY)) + }; + spans.push(Span::styled( + text, + Style::default().fg(app.user_config.theme.hint.into()), + )); + } ListItem::new(Line::from(spans)) }) @@ -568,6 +587,28 @@ mod tests { assert!(!rebound_rendered.contains("Esc/q"), "{rebound_rendered}"); } + #[cfg(feature = "soloist")] + #[test] + fn only_the_selected_soloist_path_row_names_its_download_key() { + let row = |id: &str, name: &str| SettingItem { + id: id.to_string(), + name: name.to_string(), + description: String::new(), + value: SettingValue::String(String::new()), + }; + let mut app = filtered_app("", false); + app.spotify_connected = true; + app.settings_items = vec![ + row("behavior.soloist_path", "Soloist Path"), + row("behavior.ytdlp_path", "yt-dlp Path"), + ]; + app.view.settings_selected_index = 0; + let selected = rendered(&app); + assert!(selected.contains("[o] Download from Spotify"), "{selected}"); + app.view.settings_selected_index = 1; + assert!(!rendered(&app).contains("Download from Spotify")); + } + fn rendered(app: &App) -> String { let mut terminal = Terminal::new(TestBackend::new(WIDTH, HEIGHT)).unwrap(); terminal.draw(|f| draw_settings(f, app)).unwrap();