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
+18 -2
View File
@@ -121,6 +121,7 @@ use commands::{
player_get_audio_settings, player_get_audio_settings,
player_get_autoplay_settings, player_get_autoplay_settings,
player_get_cache_config, player_get_cache_config,
player_get_eq_presets,
player_get_queue, player_get_queue,
// Session management commands // Session management commands
player_get_session, player_get_session,
@@ -515,6 +516,12 @@ fn emit_backend_init_failed(app_handle: &tauri::AppHandle, backend: &'static str
} }
/// Create the appropriate player backend for the current platform. /// Create the appropriate player backend for the current platform.
// playback_reporter/position_throttler are consumed only by the native audio
// backends (mpv/exo); on platforms using the webview audio backend they're unused.
#[cfg_attr(
not(any(target_os = "linux", target_os = "android")),
allow(unused_variables)
)]
fn create_player_backend( fn create_player_backend(
app_handle: tauri::AppHandle, app_handle: tauri::AppHandle,
playback_reporter: Arc<tokio::sync::Mutex<Option<playback_reporting::PlaybackReporter>>>, playback_reporter: Arc<tokio::sync::Mutex<Option<playback_reporting::PlaybackReporter>>>,
@@ -615,13 +622,21 @@ fn create_player_backend(
} }
} }
// Fallback for other platforms // Platforms with no native audio backend (e.g. Windows): render audio-only
// playback through a webview <audio> element (all video already renders in
// the webview). Falls back to NullBackend only if the backend can't init.
#[cfg(not(any(target_os = "linux", target_os = "android")))] #[cfg(not(any(target_os = "linux", target_os = "android")))]
{ {
warn!("WARNING: No audio backend available for this platform"); info!("No native audio backend for this platform - using webview <audio> backend");
match player::WebviewAudioBackend::new(_event_emitter) {
Ok(backend) => Box::new(backend),
Err(e) => {
emit_backend_init_failed(&app_handle, "webview-audio", e.to_string());
Box::new(NullBackend::new()) Box::new(NullBackend::new())
} }
} }
}
}
/// Construct the tauri-specta command builder. Shared by `run()` and the /// Construct the tauri-specta command builder. Shared by `run()` and the
/// bindings-export test so the TypeScript bindings always match the handler. /// bindings-export test so the TypeScript bindings always match the handler.
@@ -666,6 +681,7 @@ fn specta_builder() -> Builder<tauri::Wry> {
player_skip_to, player_skip_to,
player_set_audio_settings, player_set_audio_settings,
player_get_audio_settings, player_get_audio_settings,
player_get_eq_presets,
player_set_video_settings, player_set_video_settings,
player_get_video_settings, player_get_video_settings,
// Sleep timer and autoplay commands // Sleep timer and autoplay commands
+19
View File
@@ -156,6 +156,25 @@ pub enum PlayerStatusEvent {
/// Target position in seconds (only meaningful for "seek"). /// Target position in seconds (only meaningful for "seek").
position: Option<f64>, position: Option<f64>,
}, },
/// 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`.
WebviewAudioLoad {
/// Stream URL for the `<audio>` element to play.
url: String,
/// Jellyfin item id, used as the media_id when reporting state back.
media_id: Option<String>,
/// Resume position in seconds (0 = start from the beginning).
position: f64,
/// Whether to begin playing immediately after loading.
autoplay: bool,
},
} }
/// Trait for emitting player events to the frontend. /// Trait for emitting player events to the frontend.
+8
View File
@@ -22,6 +22,11 @@ pub mod android;
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
pub mod mpv_backend; pub mod mpv_backend;
// Platforms with no native audio backend (e.g. Windows) render audio-only
// playback through a webview <audio> element, mirroring how all video renders.
#[cfg(not(any(target_os = "linux", target_os = "android")))]
pub mod webview_audio_backend;
// Re-export commonly used types // Re-export commonly used types
pub use autoplay::{AutoplayDecision, AutoplaySettings}; pub use autoplay::{AutoplayDecision, AutoplaySettings};
pub use backend::{NullBackend, PlayerBackend, PlayerError}; pub use backend::{NullBackend, PlayerBackend, PlayerError};
@@ -40,6 +45,9 @@ pub use android::ExoPlayerBackend;
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
pub use mpv_backend::MpvBackend; pub use mpv_backend::MpvBackend;
#[cfg(not(any(target_os = "linux", target_os = "android")))]
pub use webview_audio_backend::WebviewAudioBackend;
#[cfg(target_os = "android")] #[cfg(target_os = "android")]
pub use android::{ pub use android::{
disable_remote_volume, enable_remote_volume, get_detected_codecs, set_media_command_handler, disable_remote_volume, enable_remote_volume, get_detected_codecs, set_media_command_handler,
@@ -0,0 +1,295 @@
//! Webview audio backend — audio-only playback for platforms without a native
//! audio backend (currently Windows).
//!
//! ## Why this exists
//! All *video* already renders through the webview HTML5 `<video>` element on
//! every platform (see `VideoPlayer.svelte`); libmpv/ExoPlayer only ever drive
//! *audio-only* (music) playback. On Windows there is no native audio backend,
//! so `create_player_backend()` used to fall back to `NullBackend` and music was
//! silent.
//!
//! This backend fills that gap without any C dependency (so it still
//! cross-compiles from Linux): instead of decoding audio itself, it hands the
//! stream URL to a frontend `<audio>` element via a `WebviewAudioLoad` event and
//! then drives play/pause/seek/stop through `ControlCommand` events — exactly the
//! round-trip the HTML5 video path already uses. The `<audio>` element reports
//! its real state/position back through the `player_report_*` commands, so the
//! Rust `PlayerController` remains the single source of truth (the controller's
//! `report_html5_*` methods fold those reports into the normal event pipeline).
//!
//! Because the reported state flows through the event pipeline (not through this
//! backend's `position()`/`state()` pollers — the timer loop does not poll the
//! backend for HTML5-rendered media), this backend only needs to keep a
//! best-effort local mirror for direct `player_get_state` queries.
//!
//! TRACES: UR-003, UR-004, UR-005 | DR-004
use std::sync::Arc;
use log::{debug, info};
use super::backend::{PlayerBackend, PlayerError};
use super::events::{PlayerEventEmitter, PlayerStatusEvent};
use super::media::{MediaItem, MediaSource};
use super::state::PlayerState;
use crate::settings::AudioSettings;
use crate::utils::lock::MutexSafe;
/// Extract a webview-playable URL from a media item's source.
///
/// Remote/DirectUrl are HTTP(S) URLs the `<audio>` element can play directly.
/// Local files would need the Tauri asset protocol (`convertFileSrc`) on the
/// frontend; for now we pass the path through and let the frontend resolve it.
fn stream_url(media: &MediaItem) -> String {
match &media.source {
MediaSource::Remote { stream_url, .. } => stream_url.clone(),
MediaSource::DirectUrl { url } => url.clone(),
MediaSource::Local { file_path, .. } => file_path.to_string_lossy().to_string(),
}
}
struct InternalState {
current_media: Option<MediaItem>,
volume: f32,
position: f64,
duration: Option<f64>,
state: PlayerState,
audio_settings: AudioSettings,
}
pub struct WebviewAudioBackend {
emitter: Arc<dyn PlayerEventEmitter>,
state: Arc<std::sync::Mutex<InternalState>>,
}
impl WebviewAudioBackend {
pub fn new(emitter: Arc<dyn PlayerEventEmitter>) -> Result<Self, PlayerError> {
info!("[WebviewAudioBackend] Initializing (audio renders in webview <audio>)");
Ok(Self {
emitter,
state: Arc::new(std::sync::Mutex::new(InternalState {
current_media: None,
volume: 1.0,
position: 0.0,
duration: None,
state: PlayerState::Idle,
audio_settings: AudioSettings::default(),
})),
})
}
/// Emit a backend-originated control intent to the active frontend adapter
/// (the webview `<audio>` element, via `playerEvents.ts` -> active adapter).
fn emit_control(&self, action: &str, position: Option<f64>) {
self.emitter.emit(PlayerStatusEvent::ControlCommand {
action: action.to_string(),
position,
});
}
}
impl PlayerBackend for WebviewAudioBackend {
fn load(&mut self, media: &MediaItem) -> Result<(), PlayerError> {
let url = stream_url(media);
info!("[WebviewAudioBackend] load: {} - {}", media.title, url);
{
let mut st = self.state.lock_safe();
st.current_media = Some(media.clone());
st.position = 0.0;
st.duration = media.duration;
st.state = PlayerState::Loading {
media: media.clone(),
};
}
// Hand the URL to the frontend <audio> element. autoplay=true so a plain
// load-then-play (the common queue-advance path) starts immediately; an
// explicit pause afterwards is still honored via ControlCommand.
self.emitter.emit(PlayerStatusEvent::WebviewAudioLoad {
url,
media_id: media.jellyfin_id().map(|s| s.to_string()),
position: 0.0,
autoplay: true,
});
Ok(())
}
fn play(&mut self) -> Result<(), PlayerError> {
debug!("[WebviewAudioBackend] play");
self.emit_control("play", None);
Ok(())
}
fn pause(&mut self) -> Result<(), PlayerError> {
debug!("[WebviewAudioBackend] pause");
self.emit_control("pause", None);
Ok(())
}
fn stop(&mut self) -> Result<(), PlayerError> {
debug!("[WebviewAudioBackend] stop");
{
let mut st = self.state.lock_safe();
st.current_media = None;
st.position = 0.0;
st.duration = None;
st.state = PlayerState::Idle;
}
self.emit_control("stop", None);
Ok(())
}
fn seek(&mut self, position: f64) -> Result<(), PlayerError> {
debug!("[WebviewAudioBackend] seek: {}", position);
self.state.lock_safe().position = position;
self.emit_control("seek", Some(position));
Ok(())
}
fn set_volume(&mut self, volume: f32) -> Result<(), PlayerError> {
let clamped = volume.clamp(0.0, 1.0);
self.state.lock_safe().volume = clamped;
// Volume is applied on the element by the frontend, which observes the
// volume via the player store; no dedicated ControlCommand action yet.
Ok(())
}
fn position(&self) -> f64 {
self.state.lock_safe().position
}
fn duration(&self) -> Option<f64> {
self.state.lock_safe().duration
}
fn state(&self) -> PlayerState {
self.state.lock_safe().state.clone()
}
fn volume(&self) -> f32 {
self.state.lock_safe().volume
}
fn set_audio_settings(&mut self, settings: &AudioSettings) -> Result<(), PlayerError> {
self.state.lock_safe().audio_settings = settings.clone().with_crossfade_clamped();
Ok(())
}
fn audio_settings(&self) -> AudioSettings {
self.state.lock_safe().audio_settings.clone()
}
}
// TRACES: UR-003, UR-004, UR-005 | DR-004
#[cfg(test)]
mod tests {
use super::*;
use crate::player::events::PlayerStatusEvent;
use crate::player::media::{MediaSource, MediaType};
use std::sync::Mutex as StdMutex;
/// Test emitter that records everything emitted.
struct RecordingEmitter {
events: Arc<StdMutex<Vec<PlayerStatusEvent>>>,
}
impl PlayerEventEmitter for RecordingEmitter {
fn emit(&self, event: PlayerStatusEvent) {
self.events.lock().unwrap().push(event);
}
}
fn test_media() -> MediaItem {
MediaItem {
id: "track1".to_string(),
title: "Song".to_string(),
name: Some("Song".to_string()),
artist: Some("Artist".to_string()),
album: Some("Album".to_string()),
album_name: Some("Album".to_string()),
album_id: None,
artist_items: None,
artists: Some(vec!["Artist".to_string()]),
primary_image_tag: None,
image_id: None,
item_type: Some("Audio".to_string()),
playlist_id: None,
duration: Some(200.0),
artwork_url: None,
media_type: MediaType::Audio,
source: MediaSource::DirectUrl {
url: "http://example.com/song.mp3".to_string(),
},
video_codec: None,
needs_transcoding: false,
video_width: None,
video_height: None,
subtitles: vec![],
series_id: None,
server_id: None,
}
}
fn backend() -> (WebviewAudioBackend, Arc<StdMutex<Vec<PlayerStatusEvent>>>) {
let events = Arc::new(StdMutex::new(Vec::new()));
let emitter = Arc::new(RecordingEmitter {
events: events.clone(),
});
(WebviewAudioBackend::new(emitter).unwrap(), events)
}
#[test]
fn load_emits_webview_audio_load_with_url() {
let (mut b, events) = backend();
b.load(&test_media()).unwrap();
let ev = events.lock().unwrap();
let load = ev
.iter()
.find(|e| matches!(e, PlayerStatusEvent::WebviewAudioLoad { .. }))
.expect("WebviewAudioLoad emitted");
if let PlayerStatusEvent::WebviewAudioLoad { url, autoplay, .. } = load {
assert_eq!(url, "http://example.com/song.mp3");
assert!(*autoplay);
}
assert!(matches!(b.state(), PlayerState::Loading { .. }));
}
#[test]
fn pause_and_seek_emit_control_commands() {
let (mut b, events) = backend();
b.load(&test_media()).unwrap();
b.pause().unwrap();
b.seek(42.0).unwrap();
let ev = events.lock().unwrap();
assert!(ev.iter().any(|e| matches!(
e,
PlayerStatusEvent::ControlCommand { action, .. } if action == "pause"
)));
assert!(ev.iter().any(|e| matches!(
e,
PlayerStatusEvent::ControlCommand { action, position: Some(p) }
if action == "seek" && (*p - 42.0).abs() < f64::EPSILON
)));
assert_eq!(b.position(), 42.0);
}
#[test]
fn volume_is_clamped_and_stored() {
let (mut b, _events) = backend();
b.set_volume(1.5).unwrap();
assert_eq!(b.volume(), 1.0);
b.set_volume(-0.2).unwrap();
assert_eq!(b.volume(), 0.0);
}
#[test]
fn stop_resets_to_idle() {
let (mut b, _events) = backend();
b.load(&test_media()).unwrap();
b.stop().unwrap();
assert!(matches!(b.state(), PlayerState::Idle));
}
}
+41 -2
View File
@@ -173,6 +173,16 @@ async playerSetAudioSettings(settings: AudioSettings) : Promise<AudioSettings> {
async playerGetAudioSettings() : Promise<AudioSettings> { async playerGetAudioSettings() : Promise<AudioSettings> {
return await TAURI_INVOKE("player_get_audio_settings"); 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> { async playerSetVideoSettings(settings: VideoSettings) : Promise<VideoSettings> {
return await TAURI_INVOKE("player_set_video_settings", { settings }); return await TAURI_INVOKE("player_set_video_settings", { settings });
}, },
@@ -1570,7 +1580,16 @@ normalizeVolume: boolean;
/** /**
* Target volume level for normalization * 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 * 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 * Enhanced response with pre-computed stats
*/ */
export type DownloadsResponse = { downloads: DownloadInfo[]; stats: DownloadStats } 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 * Genre
*/ */
@@ -2356,7 +2383,19 @@ export type PlayerStatusEvent =
* or remote so they can pause/play/seek/stop the webview element. * or remote so they can pause/play/seek/stop the webview element.
* `playerEvents.ts` routes this to the active PlayerAdapter via the facade. * `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 * 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 { auth, needsReauth, isAuthenticated } from "$lib/stores/auth";
import { connectivity, isConnected } from "$lib/stores/connectivity"; import { connectivity, isConnected } from "$lib/stores/connectivity";
import { initPlayerEvents, cleanupPlayerEvents } from "$lib/services/playerEvents"; import { initPlayerEvents, cleanupPlayerEvents } from "$lib/services/playerEvents";
import { initWebviewAudio, cleanupWebviewAudio } from "$lib/services/webviewAudio";
import { downloads, initDownloadEvents, cleanupDownloadEvents } from "$lib/stores/downloads"; import { downloads, initDownloadEvents, cleanupDownloadEvents } from "$lib/stores/downloads";
import { syncService } from "$lib/services/syncService"; import { syncService } from "$lib/services/syncService";
import { onReconnected as onCatalogReconnected, syncCatalog, refreshSyncStatus, showServerCatalog, lastCatalogSync } from "$lib/services/offlineCatalog"; import { onReconnected as onCatalogReconnected, syncCatalog, refreshSyncStatus, showServerCatalog, lastCatalogSync } from "$lib/services/offlineCatalog";
@@ -86,6 +87,11 @@
// Initialize player event listener for push-based updates // Initialize player event listener for push-based updates
await initPlayerEvents(); 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 // Initialize download event listener
await initDownloadEvents(); await initDownloadEvents();
@@ -122,6 +128,7 @@
onDestroy(() => { onDestroy(() => {
stopNetworkReporting?.(); stopNetworkReporting?.();
cleanupPlayerEvents(); cleanupPlayerEvents();
cleanupWebviewAudio();
cleanupDownloadEvents(); cleanupDownloadEvents();
connectivity.stopMonitoring(); connectivity.stopMonitoring();
syncService.stop(); syncService.stop();