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
146 lines
4.6 KiB
TypeScript
146 lines
4.6 KiB
TypeScript
/**
|
|
* 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();
|
|
}
|
|
}
|