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:
+18
-2
@@ -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,12 +622,20 @@ 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
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,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();
|
||||||
|
|||||||
Reference in New Issue
Block a user