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:
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user