feat(player): webview audio backend for platforms without a native one

Adds WebviewAudioBackend, used on non-Linux/non-Android targets (e.g.
Windows) where there is no libmpv/ExoPlayer. Instead of decoding, it
emits a WebviewAudioLoad event with the stream URL; a frontend <audio>
element (WebviewAudioAdapter + webviewAudio service) plays it and reports
state/position back through the existing player_report_* round-trip, so
the Rust PlayerController stays the single source of truth. Play/pause/
seek reach the element via the existing ControlCommand event.

All video already renders in the webview on every platform, so this
completes audio-only playback for Windows (video via WebView2, audio via
<audio>). Pure Rust + Tauri events, so it still cross-compiles from Linux.

Regenerates bindings.ts (adds webview_audio_load; also carries the
equalizer EQ bindings).

TRACES: UR-003, UR-004, UR-005 | DR-004
This commit is contained in:
2026-07-24 23:49:23 +02:00
parent c543f90ad3
commit d4e2cd120c
8 changed files with 643 additions and 5 deletions
+41 -2
View File
@@ -173,6 +173,16 @@ async playerSetAudioSettings(settings: AudioSettings) : Promise<AudioSettings> {
async playerGetAudioSettings() : Promise<AudioSettings> {
return await TAURI_INVOKE("player_get_audio_settings");
},
/**
* The built-in equalizer presets and their per-band gain curves (dB), for the
* settings UI. The curve numbers are domain data defined by the band layout,
* so the frontend reads them here rather than encoding them.
*
* TRACES: UR-027 | DR-030
*/
async playerGetEqPresets() : Promise<([EqPreset, number[]])[]> {
return await TAURI_INVOKE("player_get_eq_presets");
},
async playerSetVideoSettings(settings: VideoSettings) : Promise<VideoSettings> {
return await TAURI_INVOKE("player_set_video_settings", { settings });
},
@@ -1570,7 +1580,16 @@ normalizeVolume: boolean;
/**
* Target volume level for normalization
*/
volumeLevel: VolumeLevel }
volumeLevel: VolumeLevel;
/**
* Enable the graphic equalizer. When false, no EQ filter is applied.
*/
equalizerEnabled?: boolean;
/**
* Per-band gains in dB, one per [`EQ_BANDS`]. Normalised to 10 entries and
* clamped to [`EQ_GAIN_MIN`, `EQ_GAIN_MAX`] via [`Self::with_equalizer_normalised`].
*/
equalizerBands?: number[] }
/**
* Response for audio track switching operations
*/
@@ -1746,6 +1765,14 @@ export type DownloadVideoRequest = { itemId: string; userId: string; filePath: s
* Enhanced response with pre-computed stats
*/
export type DownloadsResponse = { downloads: DownloadInfo[]; stats: DownloadStats }
/**
* Built-in equalizer presets. A preset *is* a gain curve defined by the band
* layout above (a domain concept), not a mere label — the curve numbers live
* in Rust so the frontend never encodes the taxonomy.
*
* TRACES: UR-027 | DR-030
*/
export type EqPreset = "flat" | "rock" | "pop" | "jazz" | "classical" | "bassBoost" | "trebleBoost" | "vocal"
/**
* Genre
*/
@@ -2356,7 +2383,19 @@ export type PlayerStatusEvent =
* or remote so they can pause/play/seek/stop the webview element.
* `playerEvents.ts` routes this to the active PlayerAdapter via the facade.
*/
{ type: "control_command"; action: string; position: number | null }
{ type: "control_command"; action: string; position: number | null } |
/**
* Ask the frontend webview `<audio>` element to load and play a stream.
*
* Emitted by `WebviewAudioBackend` on platforms with no native audio
* backend (e.g. Windows): audio-only playback is rendered by an `<audio>`
* element in the webview, mirroring how all video already renders through
* the webview `<video>`. The element then reports its state/position back
* through the `player_report_*` commands, so the Rust controller stays the
* single source of truth. Subsequent play/pause/seek/stop reach the element
* via `ControlCommand`.
*/
{ type: "webview_audio_load"; url: string; media_id: string | null; position: number; autoplay: boolean }
/**
* Result of creating a playlist
*
@@ -0,0 +1,145 @@
/**
* Webview audio adapter — plays audio-only media through a hidden `<audio>`
* element on platforms with no native audio backend (currently Windows).
*
* All *video* already renders through the webview `<video>` element on every
* platform; libmpv/ExoPlayer only drive audio-only playback. On Windows there is
* no native audio backend, so the Rust `WebviewAudioBackend` hands the stream URL
* to the frontend via a `webview_audio_load` event and drives play/pause/seek
* through `control_command`. This adapter owns the `<audio>` element that plays
* it and reports state/position/duration/ended back to Rust through the same
* `player_report_*` round-trip the HTML5 video adapter uses (via {@link AdapterHost}).
*
* It implements the {@link PlayerAdapter} surface so it can be registered with
* `playerController.setActiveAdapter` — but only the methods `handleControlCommand`
* actually routes (`play`, `pause`, `seekElement`) carry audio-specific logic;
* the video-only members (subtitles, transcode reload) are inert stubs.
*
* TRACES: UR-003, UR-005 | DR-004
*/
import type { AdapterHost, PlayerAdapter, PlayerLoadOptions } from "./types";
export class WebviewAudioAdapter implements PlayerAdapter {
readonly kind = "html5" as const;
private audio: HTMLAudioElement;
private host: AdapterHost;
private endedFired = false;
constructor(audio: HTMLAudioElement, host: AdapterHost) {
this.audio = audio;
this.host = host;
this.wire();
}
private wire(): void {
const a = this.audio;
a.addEventListener("loadedmetadata", () => {
this.host.onMediaLoaded(Number.isFinite(a.duration) ? a.duration : 0);
});
a.addEventListener("timeupdate", () => {
this.host.onPosition(a.currentTime, Number.isFinite(a.duration) ? a.duration : 0);
});
a.addEventListener("playing", () => this.host.onState("playing"));
a.addEventListener("pause", () => {
// A pause fired at the natural end is part of "ended"; don't report paused.
if (!a.ended) this.host.onState("paused");
});
a.addEventListener("waiting", () => this.host.onBuffering(true));
a.addEventListener("canplay", () => this.host.onReady());
a.addEventListener("ended", () => {
if (this.endedFired) return;
this.endedFired = true;
this.host.onState("stopped");
this.host.onEnded();
});
a.addEventListener("error", () => {
const err = a.error;
this.host.onError(err ? `audio error code ${err.code}` : "unknown audio error");
});
}
/** Load `url` at `initialPosition` and (by default) begin playing. */
async load(url: string, options: PlayerLoadOptions): Promise<void> {
this.endedFired = false;
this.host.onState("loading");
this.host.onStreamUrlChanged(url);
this.audio.src = url;
this.audio.load();
if (options.initialPosition > 0) {
// Seek once metadata is ready so currentTime sticks.
const seekWhenReady = () => {
this.audio.currentTime = options.initialPosition;
this.audio.removeEventListener("loadedmetadata", seekWhenReady);
};
this.audio.addEventListener("loadedmetadata", seekWhenReady);
}
await this.play();
}
async play(): Promise<void> {
try {
await this.audio.play();
} catch (e) {
this.host.onError(`play() rejected: ${String(e)}`);
}
}
async pause(): Promise<void> {
this.audio.pause();
}
async toggle(): Promise<boolean> {
if (this.audio.paused) {
await this.play();
return true;
}
await this.pause();
return false;
}
async seekElement(positionSeconds: number, _offset: number): Promise<void> {
this.audio.currentTime = positionSeconds;
}
/** No transcode-reload concept for direct audio; treat as a fresh load. */
async reloadSource(url: string, offset: number): Promise<void> {
await this.load(url, {
mediaId: "",
mediaSourceId: null,
needsTranscoding: false,
initialPosition: offset,
isLive: false,
audioTrackIndex: null,
knownDuration: 0,
subtitleTracks: [],
});
}
attach(_element: HTMLVideoElement | null): void {
// The audio element is owned by the controller, not attached here.
}
setVolume(volume: number): void {
this.audio.volume = Math.max(0, Math.min(1, volume));
}
setMuted(muted: boolean): void {
this.audio.muted = muted;
}
async selectSubtitle(_streamIndex: number | null, _arrayIndex?: number): Promise<void> {
// No subtitles for audio-only playback.
}
getPosition(): number {
return this.audio.currentTime;
}
async dispose(): Promise<void> {
this.audio.pause();
this.audio.removeAttribute("src");
this.audio.load();
}
}
+109
View File
@@ -0,0 +1,109 @@
/**
* Webview audio controller — the frontend half of audio-only playback on
* platforms with no native audio backend (currently Windows).
*
* The Rust `WebviewAudioBackend` emits a `webview_audio_load` event carrying the
* stream URL whenever a track loads. This controller owns a single hidden
* `<audio>` element, plays that URL through a {@link WebviewAudioAdapter}, and
* registers the adapter with the player facade so backend `control_command`
* events (play/pause/seek — routed by playerEvents.ts) reach the element. The
* adapter reports state/position back through the standard `player_report_*`
* round-trip, keeping the Rust controller the single source of truth.
*
* No-op on platforms with a native audio backend (Linux/Android): the backend
* never emits `webview_audio_load` there, so even if initialized this listener
* stays idle. We still gate initialization on platform to avoid mounting a stray
* element.
*
* TRACES: UR-003, UR-005 | DR-004
*/
import { type UnlistenFn } from "@tauri-apps/api/event";
import { events } from "$lib/api/bindings";
import { playerController } from "$lib/player";
import { createRustReportHost } from "$lib/player/adapters/rustReportHost";
import { WebviewAudioAdapter } from "$lib/player/adapters/webviewAudioAdapter";
let unlisten: UnlistenFn | null = null;
let audioEl: HTMLAudioElement | null = null;
let adapter: WebviewAudioAdapter | null = null;
/** Platforms whose Rust backend renders audio in the webview rather than natively. */
function usesWebviewAudio(): boolean {
// Native audio backends exist only for Linux (mpv) and Android (ExoPlayer).
// Everything else (Windows, and any future desktop) uses the webview element.
// We detect "not linux/android" rather than "is windows" so new desktop
// targets are covered automatically, matching the Rust cfg gate.
if (typeof navigator === "undefined") return false;
const ua = navigator.userAgent.toLowerCase();
const isAndroid = ua.includes("android");
const isLinux = ua.includes("linux") && !isAndroid;
return !isAndroid && !isLinux;
}
/**
* Initialize the webview audio controller. Safe to call unconditionally from the
* root layout; it self-gates on platform and is idempotent.
*/
export async function initWebviewAudio(): Promise<void> {
if (unlisten) return;
if (!usesWebviewAudio()) return;
audioEl = document.createElement("audio");
audioEl.hidden = true;
audioEl.preload = "auto";
// Kept in the DOM so the browser keeps decoding it when not focused.
document.body.appendChild(audioEl);
unlisten = await events.playerStatusEvent.listen((event) => {
const p = event.payload;
if (p.type !== "webview_audio_load") return;
void handleLoad(p.url, p.media_id, p.position, p.autoplay);
});
}
async function handleLoad(
url: string,
mediaId: string | null,
position: number,
autoplay: boolean
): Promise<void> {
if (!audioEl) return;
// Fresh host/adapter per load so reporting targets the current media id.
const host = createRustReportHost(mediaId ?? "", {});
adapter = new WebviewAudioAdapter(audioEl, host);
playerController.setActiveAdapter(adapter);
await adapter.load(url, {
mediaId: mediaId ?? "",
mediaSourceId: null,
needsTranscoding: false,
initialPosition: position,
isLive: false,
audioTrackIndex: null,
knownDuration: 0,
subtitleTracks: [],
});
if (!autoplay) {
await adapter.pause();
}
}
/** Tear down the controller (idempotent). */
export function cleanupWebviewAudio(): void {
if (unlisten) {
unlisten();
unlisten = null;
}
if (adapter) {
playerController.clearActiveAdapter(adapter);
void adapter.dispose();
adapter = null;
}
if (audioEl) {
audioEl.remove();
audioEl = null;
}
}
+7
View File
@@ -7,6 +7,7 @@
import { auth, needsReauth, isAuthenticated } from "$lib/stores/auth";
import { connectivity, isConnected } from "$lib/stores/connectivity";
import { initPlayerEvents, cleanupPlayerEvents } from "$lib/services/playerEvents";
import { initWebviewAudio, cleanupWebviewAudio } from "$lib/services/webviewAudio";
import { downloads, initDownloadEvents, cleanupDownloadEvents } from "$lib/stores/downloads";
import { syncService } from "$lib/services/syncService";
import { onReconnected as onCatalogReconnected, syncCatalog, refreshSyncStatus, showServerCatalog, lastCatalogSync } from "$lib/services/offlineCatalog";
@@ -86,6 +87,11 @@
// Initialize player event listener for push-based updates
await initPlayerEvents();
// Initialize the webview audio controller (plays audio-only media in an
// <audio> element on platforms with no native audio backend, e.g. Windows;
// self-gates and is a no-op on Linux/Android).
await initWebviewAudio();
// Initialize download event listener
await initDownloadEvents();
@@ -122,6 +128,7 @@
onDestroy(() => {
stopNetworkReporting?.();
cleanupPlayerEvents();
cleanupWebviewAudio();
cleanupDownloadEvents();
connectivity.stopMonitoring();
syncService.stop();