- Add PlayStreaming variant to AudioCommand enum - Add play_streaming() method to Player for starting stream-first playback - Modify play_track command to check stream_first_track setting - Add get_stream_info() for HEAD request to get content-length - Add extract_audio_format_from_header() to probe audio format from initial bytes - Add download_and_stream() for async chunked download with buffer push - Update BufferedMediaSource with create_reader() for independent read positions - Add has_min_buffer() method for playback readiness check - Implement Clone for BufferWriter Phases 3-4/7: Conditional streaming path with async download integration cpvsc
986 lines
32 KiB
Rust
986 lines
32 KiB
Rust
//! Playback-related Tauri commands
|
|
|
|
use std::sync::Arc;
|
|
use tauri::State;
|
|
use tokio::sync::Mutex;
|
|
|
|
use crate::api::client::QobuzClient;
|
|
use crate::api::models::Quality;
|
|
use crate::cache::AudioCache;
|
|
use crate::config::audio_settings::AudioSettingsState;
|
|
use crate::offline_cache::OfflineCacheState;
|
|
use crate::player::PlaybackState;
|
|
use crate::queue::QueueManager;
|
|
use crate::AppState;
|
|
|
|
/// Play a track by ID (with caching support)
|
|
#[tauri::command]
|
|
pub async fn play_track(
|
|
track_id: u64,
|
|
state: State<'_, AppState>,
|
|
offline_cache: State<'_, OfflineCacheState>,
|
|
audio_settings: State<'_, AudioSettingsState>,
|
|
) -> Result<(), String> {
|
|
log::info!("Command: play_track {}", track_id);
|
|
|
|
// First check offline cache (persistent disk cache)
|
|
{
|
|
let db = offline_cache.db.lock().await;
|
|
if let Ok(Some(file_path)) = db.get_file_path(track_id) {
|
|
let path = std::path::Path::new(&file_path);
|
|
if path.exists() {
|
|
log::info!("Playing track {} from offline cache: {:?}", track_id, path);
|
|
|
|
// Update last accessed time
|
|
let _ = db.touch(track_id);
|
|
drop(db); // Release lock before reading file
|
|
|
|
// Read file and play
|
|
let audio_data = std::fs::read(path)
|
|
.map_err(|e| format!("Failed to read cached file: {}", e))?;
|
|
|
|
state.player.play_data(audio_data, track_id)?;
|
|
|
|
// Prefetch next track in background
|
|
spawn_prefetch(
|
|
state.client.clone(),
|
|
state.audio_cache.clone(),
|
|
&state.queue,
|
|
);
|
|
|
|
return Ok(());
|
|
}
|
|
}
|
|
}
|
|
|
|
let cache = state.audio_cache.clone();
|
|
|
|
// Check if track is in memory cache (L1)
|
|
if let Some(cached) = cache.get(track_id) {
|
|
log::info!("Playing track {} from memory cache ({} bytes)", track_id, cached.size_bytes);
|
|
state.player.play_data(cached.data, track_id)?;
|
|
|
|
// Prefetch next track in background
|
|
spawn_prefetch(
|
|
state.client.clone(),
|
|
state.audio_cache.clone(),
|
|
&state.queue,
|
|
);
|
|
|
|
return Ok(());
|
|
}
|
|
|
|
// Check if track is in playback cache (L2 - disk)
|
|
if let Some(playback_cache) = cache.get_playback_cache() {
|
|
if let Some(audio_data) = playback_cache.get(track_id) {
|
|
log::info!("Playing track {} from playback cache ({} bytes)", track_id, audio_data.len());
|
|
|
|
// Promote back to memory cache
|
|
cache.insert(track_id, audio_data.clone());
|
|
|
|
state.player.play_data(audio_data, track_id)?;
|
|
|
|
// Prefetch next track in background
|
|
spawn_prefetch(
|
|
state.client.clone(),
|
|
state.audio_cache.clone(),
|
|
&state.queue,
|
|
);
|
|
|
|
return Ok(());
|
|
}
|
|
}
|
|
|
|
// Not in any cache - check if streaming is enabled
|
|
log::info!("Track {} not in any cache, checking streaming setting...", track_id);
|
|
|
|
// Check if stream_first_track is enabled
|
|
let (stream_first_enabled, buffer_seconds) = {
|
|
let store = audio_settings.store.lock().map_err(|e| format!("Lock error: {}", e))?;
|
|
match store.get_settings() {
|
|
Ok(settings) => (settings.stream_first_track, settings.stream_buffer_seconds),
|
|
Err(e) => {
|
|
log::warn!("Failed to get audio settings, using defaults: {}", e);
|
|
(false, 3)
|
|
}
|
|
}
|
|
};
|
|
|
|
let client = state.client.lock().await;
|
|
|
|
// Get the stream URL with highest quality available
|
|
let stream_url = client
|
|
.get_stream_url_with_fallback(track_id, Quality::UltraHiRes)
|
|
.await
|
|
.map_err(|e| format!("Failed to get stream URL: {}", e))?;
|
|
|
|
log::info!("Got stream URL for track {}", track_id);
|
|
|
|
if stream_first_enabled {
|
|
// Use streaming playback - start playing before full download
|
|
log::info!("Streaming mode enabled - starting stream-first playback for track {}", track_id);
|
|
|
|
// Get content length and audio info via HEAD request
|
|
let (content_length, sample_rate, channels) = get_stream_info(&stream_url.url).await?;
|
|
|
|
log::info!(
|
|
"Stream info: {} bytes, {}Hz, {} channels",
|
|
content_length,
|
|
sample_rate,
|
|
channels
|
|
);
|
|
|
|
// Start streaming playback and get buffer writer
|
|
let buffer_writer = state.player.play_streaming(
|
|
track_id,
|
|
sample_rate,
|
|
channels,
|
|
content_length,
|
|
buffer_seconds,
|
|
)?;
|
|
|
|
// Release client lock before spawning background download
|
|
drop(client);
|
|
|
|
// Spawn background task to download and push data to buffer
|
|
let url = stream_url.url.clone();
|
|
let cache_clone = cache.clone();
|
|
tokio::spawn(async move {
|
|
match download_and_stream(&url, buffer_writer, track_id, cache_clone).await {
|
|
Ok(()) => log::info!("Streaming download complete for track {}", track_id),
|
|
Err(e) => log::error!("Streaming download failed for track {}: {}", track_id, e),
|
|
}
|
|
});
|
|
|
|
// Prefetch next track in background
|
|
spawn_prefetch(
|
|
state.client.clone(),
|
|
state.audio_cache.clone(),
|
|
&state.queue,
|
|
);
|
|
|
|
return Ok(());
|
|
}
|
|
|
|
// Standard download path (streaming disabled)
|
|
log::info!("Standard download mode for track {}", track_id);
|
|
|
|
// Download the audio
|
|
let audio_data = download_audio(&stream_url.url).await?;
|
|
let data_size = audio_data.len();
|
|
|
|
// Cache it
|
|
cache.insert(track_id, audio_data.clone());
|
|
|
|
// Play it
|
|
state.player.play_data(audio_data, track_id)?;
|
|
|
|
log::info!("Playing track {} ({} bytes)", track_id, data_size);
|
|
|
|
// Release client lock before prefetching
|
|
drop(client);
|
|
|
|
// Prefetch next track in background
|
|
spawn_prefetch(
|
|
state.client.clone(),
|
|
state.audio_cache.clone(),
|
|
&state.queue,
|
|
);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Prefetch a track into the in-memory cache without starting playback
|
|
#[tauri::command]
|
|
pub async fn prefetch_track(
|
|
track_id: u64,
|
|
state: State<'_, AppState>,
|
|
offline_cache: State<'_, OfflineCacheState>,
|
|
) -> Result<(), String> {
|
|
log::info!("Command: prefetch_track {}", track_id);
|
|
|
|
let cache = state.audio_cache.clone();
|
|
|
|
if cache.contains(track_id) {
|
|
log::info!("Track {} already in memory cache", track_id);
|
|
return Ok(());
|
|
}
|
|
|
|
if cache.is_fetching(track_id) {
|
|
log::info!("Track {} already being fetched", track_id);
|
|
return Ok(());
|
|
}
|
|
|
|
cache.mark_fetching(track_id);
|
|
let result = async {
|
|
// Check persistent offline cache first
|
|
{
|
|
let db = offline_cache.db.lock().await;
|
|
if let Ok(Some(file_path)) = db.get_file_path(track_id) {
|
|
let path = std::path::Path::new(&file_path);
|
|
if path.exists() {
|
|
log::info!("Prefetching track {} from offline cache", track_id);
|
|
drop(db);
|
|
let audio_data = std::fs::read(path)
|
|
.map_err(|e| format!("Failed to read cached file: {}", e))?;
|
|
cache.insert(track_id, audio_data);
|
|
return Ok(());
|
|
}
|
|
}
|
|
}
|
|
|
|
let client = state.client.lock().await;
|
|
let stream_url = client
|
|
.get_stream_url_with_fallback(track_id, Quality::UltraHiRes)
|
|
.await
|
|
.map_err(|e| format!("Failed to get stream URL: {}", e))?;
|
|
drop(client);
|
|
|
|
let audio_data = download_audio(&stream_url.url).await?;
|
|
cache.insert(track_id, audio_data);
|
|
Ok(())
|
|
}
|
|
.await;
|
|
|
|
cache.unmark_fetching(track_id);
|
|
result
|
|
}
|
|
|
|
/// Download audio from URL
|
|
async fn download_audio(url: &str) -> Result<Vec<u8>, String> {
|
|
use std::time::Duration;
|
|
|
|
let client = reqwest::Client::builder()
|
|
.timeout(Duration::from_secs(120))
|
|
.connect_timeout(Duration::from_secs(10))
|
|
.build()
|
|
.map_err(|e| format!("Failed to create HTTP client: {}", e))?;
|
|
|
|
log::info!("Downloading audio...");
|
|
|
|
let response = client
|
|
.get(url)
|
|
.header("User-Agent", "Mozilla/5.0")
|
|
.send()
|
|
.await
|
|
.map_err(|e| format!("Failed to fetch audio: {}", e))?;
|
|
|
|
if !response.status().is_success() {
|
|
return Err(format!("HTTP error: {}", response.status()));
|
|
}
|
|
|
|
let bytes = response
|
|
.bytes()
|
|
.await
|
|
.map_err(|e| format!("Failed to read audio bytes: {}", e))?;
|
|
|
|
log::info!("Downloaded {} bytes", bytes.len());
|
|
Ok(bytes.to_vec())
|
|
}
|
|
|
|
/// Get stream info (content length, sample rate, channels) via HEAD request and initial bytes
|
|
async fn get_stream_info(url: &str) -> Result<(u64, u32, u16), String> {
|
|
use std::time::Duration;
|
|
|
|
let client = reqwest::Client::builder()
|
|
.timeout(Duration::from_secs(30))
|
|
.connect_timeout(Duration::from_secs(10))
|
|
.build()
|
|
.map_err(|e| format!("Failed to create HTTP client: {}", e))?;
|
|
|
|
// First, do a HEAD request to get content length
|
|
let head_response = client
|
|
.head(url)
|
|
.header("User-Agent", "Mozilla/5.0")
|
|
.send()
|
|
.await
|
|
.map_err(|e| format!("Failed HEAD request: {}", e))?;
|
|
|
|
if !head_response.status().is_success() {
|
|
return Err(format!("HEAD request failed: {}", head_response.status()));
|
|
}
|
|
|
|
let content_length = head_response
|
|
.headers()
|
|
.get("content-length")
|
|
.and_then(|v| v.to_str().ok())
|
|
.and_then(|s| s.parse::<u64>().ok())
|
|
.ok_or_else(|| "No content-length header".to_string())?;
|
|
|
|
// Download first ~64KB to probe audio format
|
|
// This is enough for FLAC/M4A headers
|
|
let range_response = client
|
|
.get(url)
|
|
.header("User-Agent", "Mozilla/5.0")
|
|
.header("Range", "bytes=0-65535")
|
|
.send()
|
|
.await
|
|
.map_err(|e| format!("Failed range request: {}", e))?;
|
|
|
|
if !range_response.status().is_success() && range_response.status() != reqwest::StatusCode::PARTIAL_CONTENT {
|
|
return Err(format!("Range request failed: {}", range_response.status()));
|
|
}
|
|
|
|
let initial_bytes = range_response
|
|
.bytes()
|
|
.await
|
|
.map_err(|e| format!("Failed to read initial bytes: {}", e))?;
|
|
|
|
// Try to extract audio format from initial bytes
|
|
let (sample_rate, channels) = extract_audio_format_from_header(&initial_bytes)?;
|
|
|
|
Ok((content_length, sample_rate, channels))
|
|
}
|
|
|
|
/// Extract sample rate and channels from audio file header
|
|
fn extract_audio_format_from_header(data: &[u8]) -> Result<(u32, u16), String> {
|
|
use std::io::{BufReader, Cursor};
|
|
use rodio::Decoder;
|
|
|
|
// Check if this is FLAC
|
|
if data.len() >= 4 && &data[0..4] == b"fLaC" {
|
|
// Parse FLAC STREAMINFO block
|
|
// FLAC format: "fLaC" + METADATA_BLOCK_HEADER (4 bytes) + STREAMINFO
|
|
if data.len() >= 26 {
|
|
// STREAMINFO starts at byte 8
|
|
// Bytes 10-12: sample rate (20 bits) + channels (3 bits) + bits per sample (5 bits)
|
|
let sr_high = ((data[18] as u32) << 12) | ((data[19] as u32) << 4) | ((data[20] as u32) >> 4);
|
|
let sample_rate = sr_high;
|
|
let channels = ((data[20] >> 1) & 0x07) + 1;
|
|
return Ok((sample_rate, channels as u16));
|
|
}
|
|
}
|
|
|
|
// Check if this is M4A/MP4 (ftyp box)
|
|
if data.len() >= 12 && &data[4..8] == b"ftyp" {
|
|
// M4A uses AAC or ALAC, try symphonia probe
|
|
use symphonia::core::formats::FormatOptions;
|
|
use symphonia::core::io::MediaSourceStream;
|
|
use symphonia::core::meta::MetadataOptions;
|
|
use symphonia::core::probe::Hint;
|
|
use symphonia::default::get_probe;
|
|
|
|
let cursor = Box::new(Cursor::new(data.to_vec())) as Box<dyn std::io::Read + Send + Sync>;
|
|
// For probing, we need to create a MediaSource
|
|
// Use a simple wrapper
|
|
struct ProbeSource {
|
|
inner: Cursor<Vec<u8>>,
|
|
len: u64,
|
|
}
|
|
impl std::io::Read for ProbeSource {
|
|
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
|
|
self.inner.read(buf)
|
|
}
|
|
}
|
|
impl std::io::Seek for ProbeSource {
|
|
fn seek(&mut self, pos: std::io::SeekFrom) -> std::io::Result<u64> {
|
|
self.inner.seek(pos)
|
|
}
|
|
}
|
|
impl symphonia::core::io::MediaSource for ProbeSource {
|
|
fn is_seekable(&self) -> bool { true }
|
|
fn byte_len(&self) -> Option<u64> { Some(self.len) }
|
|
}
|
|
|
|
let len = data.len() as u64;
|
|
let source = Box::new(ProbeSource { inner: Cursor::new(data.to_vec()), len });
|
|
let mss = MediaSourceStream::new(source, Default::default());
|
|
|
|
let mut hint = Hint::new();
|
|
hint.with_extension("m4a");
|
|
|
|
let format_opts = FormatOptions {
|
|
enable_gapless: true,
|
|
..Default::default()
|
|
};
|
|
|
|
if let Ok(probed) = get_probe().format(&hint, mss, &format_opts, &MetadataOptions::default()) {
|
|
if let Some(track) = probed.format.default_track() {
|
|
let sample_rate = track.codec_params.sample_rate.unwrap_or(44100);
|
|
let channels = track.codec_params.channels.map(|c| c.count() as u16).unwrap_or(2);
|
|
return Ok((sample_rate, channels));
|
|
}
|
|
}
|
|
|
|
// Default to common values for M4A
|
|
return Ok((44100, 2));
|
|
}
|
|
|
|
// Try rodio decoder for other formats
|
|
match Decoder::new(BufReader::new(Cursor::new(data.to_vec()))) {
|
|
Ok(decoder) => {
|
|
use rodio::Source;
|
|
Ok((decoder.sample_rate(), decoder.channels()))
|
|
}
|
|
Err(_) => {
|
|
// Default fallback
|
|
log::warn!("Could not determine audio format, using defaults (44100Hz, stereo)");
|
|
Ok((44100, 2))
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Download audio chunks and stream them to the buffer writer
|
|
/// Also caches the complete data when download finishes
|
|
async fn download_and_stream(
|
|
url: &str,
|
|
writer: crate::player::BufferWriter,
|
|
track_id: u64,
|
|
cache: Arc<AudioCache>,
|
|
) -> Result<(), String> {
|
|
use std::time::Duration;
|
|
use futures_util::StreamExt;
|
|
|
|
let client = reqwest::Client::builder()
|
|
.timeout(Duration::from_secs(300)) // Longer timeout for streaming
|
|
.connect_timeout(Duration::from_secs(10))
|
|
.build()
|
|
.map_err(|e| format!("Failed to create HTTP client: {}", e))?;
|
|
|
|
log::info!("Starting streaming download for track {}", track_id);
|
|
|
|
let response = client
|
|
.get(url)
|
|
.header("User-Agent", "Mozilla/5.0")
|
|
.send()
|
|
.await
|
|
.map_err(|e| format!("Failed to start stream: {}", e))?;
|
|
|
|
if !response.status().is_success() {
|
|
return Err(format!("Stream request failed: {}", response.status()));
|
|
}
|
|
|
|
let mut all_data = Vec::new();
|
|
let mut stream = response.bytes_stream();
|
|
let mut bytes_received = 0u64;
|
|
|
|
while let Some(chunk_result) = stream.next().await {
|
|
let chunk = chunk_result.map_err(|e| format!("Stream chunk error: {}", e))?;
|
|
bytes_received += chunk.len() as u64;
|
|
|
|
// Accumulate for caching
|
|
all_data.extend_from_slice(&chunk);
|
|
|
|
// Push to streaming buffer
|
|
if let Err(e) = writer.push_chunk(&chunk) {
|
|
log::error!("Failed to push chunk to buffer: {}", e);
|
|
}
|
|
|
|
// Log progress every ~1MB
|
|
if bytes_received % (1024 * 1024) < chunk.len() as u64 {
|
|
log::debug!("Streaming download progress: {} bytes", bytes_received);
|
|
}
|
|
}
|
|
|
|
// Mark stream as complete
|
|
if let Err(e) = writer.complete() {
|
|
log::error!("Failed to mark buffer complete: {}", e);
|
|
}
|
|
|
|
log::info!("Streaming download complete: {} bytes total", bytes_received);
|
|
|
|
// Cache the complete file for future plays
|
|
cache.insert(track_id, all_data);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Number of Qobuz tracks to prefetch (not total tracks, just Qobuz)
|
|
const QOBUZ_PREFETCH_COUNT: usize = 3;
|
|
|
|
/// How far ahead to look for tracks to prefetch (to handle mixed playlists)
|
|
const PREFETCH_LOOKAHEAD: usize = 15;
|
|
|
|
/// Spawn background tasks to prefetch upcoming Qobuz tracks
|
|
/// For mixed playlists, we look further ahead to find Qobuz tracks past local ones
|
|
fn spawn_prefetch(
|
|
client: Arc<Mutex<QobuzClient>>,
|
|
cache: Arc<AudioCache>,
|
|
queue: &QueueManager,
|
|
) {
|
|
// Look further ahead to find Qobuz tracks in mixed playlists
|
|
let upcoming_tracks = queue.peek_upcoming(PREFETCH_LOOKAHEAD);
|
|
|
|
if upcoming_tracks.is_empty() {
|
|
log::debug!("No upcoming tracks to prefetch");
|
|
return;
|
|
}
|
|
|
|
let mut qobuz_prefetched = 0;
|
|
|
|
for track in upcoming_tracks {
|
|
// Stop once we've prefetched enough Qobuz tracks
|
|
if qobuz_prefetched >= QOBUZ_PREFETCH_COUNT {
|
|
break;
|
|
}
|
|
|
|
let track_id = track.id;
|
|
let track_title = track.title.clone();
|
|
|
|
// Skip local tracks - they don't need prefetching from Qobuz
|
|
if track.is_local {
|
|
log::debug!("Skipping prefetch for local track: {} - {}", track_id, track_title);
|
|
continue;
|
|
}
|
|
|
|
// Check if already cached or being fetched
|
|
if cache.contains(track_id) {
|
|
log::debug!("Track {} already cached", track_id);
|
|
qobuz_prefetched += 1; // Count as "handled"
|
|
continue;
|
|
}
|
|
|
|
if cache.is_fetching(track_id) {
|
|
log::debug!("Track {} already being fetched", track_id);
|
|
qobuz_prefetched += 1; // Count as "handled"
|
|
continue;
|
|
}
|
|
|
|
// Mark as fetching
|
|
cache.mark_fetching(track_id);
|
|
qobuz_prefetched += 1;
|
|
|
|
let client_clone = client.clone();
|
|
let cache_clone = cache.clone();
|
|
|
|
log::info!("Prefetching track: {} - {}", track_id, track_title);
|
|
|
|
// Spawn background task for each track
|
|
tokio::spawn(async move {
|
|
let result = async {
|
|
let client_guard = client_clone.lock().await;
|
|
let stream_url = client_guard
|
|
.get_stream_url_with_fallback(track_id, Quality::UltraHiRes)
|
|
.await
|
|
.map_err(|e| format!("Failed to get stream URL: {}", e))?;
|
|
drop(client_guard);
|
|
|
|
let data = download_audio(&stream_url.url).await?;
|
|
Ok::<Vec<u8>, String>(data)
|
|
}
|
|
.await;
|
|
|
|
match result {
|
|
Ok(data) => {
|
|
cache_clone.insert(track_id, data);
|
|
log::info!("Prefetch complete for track {}", track_id);
|
|
}
|
|
Err(e) => {
|
|
log::warn!("Prefetch failed for track {}: {}", track_id, e);
|
|
}
|
|
}
|
|
|
|
cache_clone.unmark_fetching(track_id);
|
|
});
|
|
}
|
|
}
|
|
|
|
/// Pause playback
|
|
#[tauri::command]
|
|
pub fn pause_playback(state: State<'_, AppState>) -> Result<(), String> {
|
|
log::info!("Command: pause_playback");
|
|
state.media_controls.set_playback(false);
|
|
state.player.pause()
|
|
}
|
|
|
|
/// Resume playback
|
|
#[tauri::command]
|
|
pub fn resume_playback(state: State<'_, AppState>) -> Result<(), String> {
|
|
log::info!("Command: resume_playback");
|
|
state.media_controls.set_playback(true);
|
|
state.player.resume()
|
|
}
|
|
|
|
/// Stop playback
|
|
#[tauri::command]
|
|
pub fn stop_playback(state: State<'_, AppState>) -> Result<(), String> {
|
|
log::info!("Command: stop_playback");
|
|
state.media_controls.set_stopped();
|
|
state.player.stop()
|
|
}
|
|
|
|
/// Set media controls metadata (for MPRIS integration)
|
|
#[tauri::command]
|
|
pub fn set_media_metadata(
|
|
title: String,
|
|
artist: String,
|
|
album: String,
|
|
duration_secs: Option<u64>,
|
|
cover_url: Option<String>,
|
|
state: State<'_, AppState>,
|
|
) -> Result<(), String> {
|
|
log::info!("Command: set_media_metadata - {} by {}", title, artist);
|
|
crate::update_media_controls_metadata(
|
|
&state.media_controls,
|
|
&title,
|
|
&artist,
|
|
&album,
|
|
duration_secs,
|
|
cover_url,
|
|
);
|
|
state.media_controls.set_playback_with_progress(true, 0);
|
|
Ok(())
|
|
}
|
|
|
|
/// Set volume (0.0 - 1.0)
|
|
#[tauri::command]
|
|
pub fn set_volume(volume: f32, state: State<'_, AppState>) -> Result<(), String> {
|
|
log::info!("Command: set_volume {}", volume);
|
|
state.player.set_volume(volume)
|
|
}
|
|
|
|
/// Seek to position in seconds
|
|
#[tauri::command]
|
|
pub fn seek(position: u64, state: State<'_, AppState>) -> Result<(), String> {
|
|
log::info!("Command: seek {}", position);
|
|
let result = state.player.seek(position);
|
|
|
|
// Update MPRIS with new position
|
|
let playback_state = state.player.get_state().unwrap_or_default();
|
|
state.media_controls.set_playback_with_progress(
|
|
playback_state.is_playing,
|
|
position,
|
|
);
|
|
|
|
result
|
|
}
|
|
|
|
/// Get current playback state (also updates MPRIS progress)
|
|
#[tauri::command]
|
|
pub fn get_playback_state(state: State<'_, AppState>) -> Result<PlaybackState, String> {
|
|
let playback_state = state.player.get_state()?;
|
|
|
|
// Update MPRIS with current progress (called every ~500ms from frontend)
|
|
state.media_controls.set_playback_with_progress(
|
|
playback_state.is_playing,
|
|
playback_state.position,
|
|
);
|
|
|
|
Ok(playback_state)
|
|
}
|
|
|
|
/// Audio device information
|
|
#[derive(serde::Serialize)]
|
|
pub struct AudioDevice {
|
|
pub name: String, // Technical name (internal use)
|
|
pub display_name: String, // Friendly name for UI
|
|
pub is_default: bool,
|
|
pub device_type: Option<String>, // "USB", "PCI", "HDMI", etc.
|
|
}
|
|
|
|
/// Get friendly device names from PipeWire/PulseAudio
|
|
#[cfg(target_os = "linux")]
|
|
fn get_pipewire_device_descriptions() -> std::collections::HashMap<String, String> {
|
|
use std::process::Command;
|
|
|
|
let mut map = std::collections::HashMap::new();
|
|
|
|
// Try pactl (PipeWire/PulseAudio)
|
|
if let Ok(output) = Command::new("pactl").args(&["list", "sinks"]).output() {
|
|
if let Ok(text) = String::from_utf8(output.stdout) {
|
|
let mut current_name: Option<String> = None;
|
|
|
|
for line in text.lines() {
|
|
let line = line.trim();
|
|
|
|
// Extract Name: field
|
|
if let Some(name) = line.strip_prefix("Name: ") {
|
|
current_name = Some(name.to_string());
|
|
}
|
|
|
|
// Extract Description: field
|
|
if let Some(desc) = line.strip_prefix("Description: ") {
|
|
if let Some(name) = ¤t_name {
|
|
map.insert(name.clone(), desc.to_string());
|
|
log::debug!("Device mapping: {} -> {}", name, desc);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
log::info!("Found {} device descriptions from PipeWire/PulseAudio", map.len());
|
|
map
|
|
}
|
|
|
|
#[cfg(not(target_os = "linux"))]
|
|
fn get_pipewire_device_descriptions() -> std::collections::HashMap<String, String> {
|
|
std::collections::HashMap::new()
|
|
}
|
|
|
|
/// Get available audio output devices
|
|
#[tauri::command]
|
|
pub fn get_audio_devices() -> Result<Vec<AudioDevice>, String> {
|
|
log::info!("Command: get_audio_devices");
|
|
|
|
use rodio::cpal::traits::{DeviceTrait, HostTrait};
|
|
|
|
let host = rodio::cpal::default_host();
|
|
|
|
let default_device_name = host
|
|
.default_output_device()
|
|
.and_then(|d| d.name().ok());
|
|
|
|
// Get friendly names from PipeWire/PulseAudio
|
|
let friendly_names = get_pipewire_device_descriptions();
|
|
|
|
let devices: Vec<AudioDevice> = host
|
|
.output_devices()
|
|
.map_err(|e| format!("Failed to enumerate devices: {}", e))?
|
|
.filter_map(|device| {
|
|
device.name().ok().map(|name| {
|
|
let is_default = default_device_name.as_ref().map(|d| d == &name).unwrap_or(false);
|
|
|
|
// Try to get friendly name from PipeWire/PulseAudio
|
|
let display_name = friendly_names
|
|
.get(&name)
|
|
.cloned()
|
|
.unwrap_or_else(|| {
|
|
// Fallback: clean up technical name slightly
|
|
name.replace("alsa_output.", "")
|
|
.replace("_", " ")
|
|
.replace("pro-output", "")
|
|
.trim()
|
|
.to_string()
|
|
});
|
|
|
|
// Detect device type from technical name
|
|
let device_type = if name.contains("usb") || name.contains("USB") {
|
|
Some("USB".to_string())
|
|
} else if name.contains("hdmi") || name.contains("HDMI") {
|
|
Some("HDMI".to_string())
|
|
} else if name.contains("pci") || name.contains("PCI") {
|
|
Some("PCI".to_string())
|
|
} else if name.contains("bluetooth") || name.contains("bluez") {
|
|
Some("Bluetooth".to_string())
|
|
} else {
|
|
None
|
|
};
|
|
|
|
log::debug!(
|
|
"Device: {} -> {} (type: {:?}, default: {})",
|
|
name,
|
|
display_name,
|
|
device_type,
|
|
is_default
|
|
);
|
|
|
|
AudioDevice {
|
|
name,
|
|
display_name,
|
|
is_default,
|
|
device_type,
|
|
}
|
|
})
|
|
})
|
|
.collect();
|
|
|
|
log::info!("Found {} audio output devices", devices.len());
|
|
Ok(devices)
|
|
}
|
|
|
|
/// Current audio output status
|
|
#[derive(serde::Serialize)]
|
|
pub struct AudioOutputStatus {
|
|
pub device_name: Option<String>,
|
|
pub is_playing: bool,
|
|
}
|
|
|
|
/// Get current audio output status (what device is actually being used)
|
|
#[tauri::command]
|
|
pub fn get_audio_output_status(
|
|
state: tauri::State<'_, crate::AppState>,
|
|
) -> Result<AudioOutputStatus, String> {
|
|
let device_name = state.player.state.current_device();
|
|
let is_playing = state.player.state.is_playing();
|
|
|
|
Ok(AudioOutputStatus {
|
|
device_name,
|
|
is_playing,
|
|
})
|
|
}
|
|
|
|
/// Reinitialize audio device (releases and re-acquires)
|
|
/// Call this when changing audio settings like exclusive mode or output device
|
|
#[tauri::command]
|
|
pub fn reinit_audio_device(
|
|
device: Option<String>,
|
|
state: tauri::State<'_, crate::AppState>,
|
|
audio_settings_state: tauri::State<'_, crate::config::audio_settings::AudioSettingsState>,
|
|
) -> Result<(), String> {
|
|
log::info!("Command: reinit_audio_device {:?}", device);
|
|
|
|
// Reload settings from database to ensure Player has latest config (including backend_type)
|
|
if let Ok(store) = audio_settings_state.store.lock() {
|
|
if let Ok(fresh_settings) = store.get_settings() {
|
|
log::info!("Reloading audio settings before reinit (backend_type: {:?})", fresh_settings.backend_type);
|
|
let _ = state.player.reload_settings(fresh_settings);
|
|
}
|
|
}
|
|
|
|
state.player.reinit_device(device)
|
|
}
|
|
|
|
/// PipeWire/PulseAudio sink information (cross-platform compatible struct)
|
|
#[derive(serde::Serialize, Clone)]
|
|
pub struct PipewireSink {
|
|
/// Internal name (e.g., "alsa_output.usb-XXX" on Linux, device name on Mac)
|
|
pub name: String,
|
|
/// User-friendly description (e.g., "AB13X Headset Adapter Analog Stereo")
|
|
pub description: String,
|
|
/// Current volume percentage (0-100) - only available on Linux
|
|
pub volume: Option<u32>,
|
|
/// Whether this is the default sink
|
|
pub is_default: bool,
|
|
}
|
|
|
|
/// Get audio output devices using CPAL (works on all platforms including Linux/PipeWire)
|
|
/// CRITICAL: Uses CPAL device names so they match what the player can open
|
|
#[cfg(target_os = "linux")]
|
|
#[tauri::command]
|
|
pub fn get_pipewire_sinks() -> Result<Vec<PipewireSink>, String> {
|
|
log::info!("Command: get_pipewire_sinks (Linux, using CPAL)");
|
|
|
|
use rodio::cpal::traits::{DeviceTrait, HostTrait};
|
|
|
|
let host = rodio::cpal::default_host();
|
|
|
|
// Get default device name from CPAL
|
|
let default_device_name = host
|
|
.default_output_device()
|
|
.and_then(|d| d.name().ok());
|
|
|
|
log::info!("CPAL default device: {:?}", default_device_name);
|
|
|
|
// Enumerate all output devices using CPAL
|
|
let sinks: Vec<PipewireSink> = host
|
|
.output_devices()
|
|
.map_err(|e| format!("Failed to enumerate devices: {}", e))?
|
|
.enumerate()
|
|
.filter_map(|(idx, device)| {
|
|
match device.name() {
|
|
Ok(name) => {
|
|
let is_default = default_device_name.as_ref().map(|d| d == &name).unwrap_or(false);
|
|
|
|
// Get detailed device info for logging
|
|
let configs_info = device.supported_output_configs()
|
|
.ok()
|
|
.map(|configs| {
|
|
let config_strs: Vec<String> = configs
|
|
.take(3) // Just first 3 configs for brevity
|
|
.map(|c| format!("{}ch/{}Hz", c.channels(), c.max_sample_rate().0))
|
|
.collect();
|
|
config_strs.join(", ")
|
|
})
|
|
.unwrap_or_else(|| "no configs".to_string());
|
|
|
|
log::info!(" [{}] Device: '{}' (default: {}) - Configs: {}",
|
|
idx, name, is_default, configs_info);
|
|
|
|
// CRITICAL: Use CPAL device name as both name and description
|
|
// This ensures the name we save is the exact name CPAL can find later
|
|
Some(PipewireSink {
|
|
name: name.clone(),
|
|
description: name, // CPAL names are already user-friendly in PipeWire
|
|
volume: None, // Volume not available via CPAL
|
|
is_default,
|
|
})
|
|
}
|
|
Err(e) => {
|
|
log::warn!(" [{}] Failed to get device name: {}", idx, e);
|
|
None
|
|
}
|
|
}
|
|
})
|
|
.collect();
|
|
|
|
log::info!("Found {} audio output devices via CPAL", sinks.len());
|
|
|
|
Ok(sinks)
|
|
}
|
|
|
|
/// Get audio output devices using cpal (macOS/Windows fallback)
|
|
/// Returns devices in the same format as Linux for UI compatibility
|
|
#[cfg(not(target_os = "linux"))]
|
|
#[tauri::command]
|
|
pub fn get_pipewire_sinks() -> Result<Vec<PipewireSink>, String> {
|
|
log::info!("Command: get_pipewire_sinks (non-Linux, using cpal)");
|
|
|
|
use rodio::cpal::traits::{DeviceTrait, HostTrait};
|
|
|
|
let host = rodio::cpal::default_host();
|
|
|
|
let default_device_name = host
|
|
.default_output_device()
|
|
.and_then(|d| d.name().ok());
|
|
|
|
let sinks: Vec<PipewireSink> = host
|
|
.output_devices()
|
|
.map_err(|e| format!("Failed to enumerate devices: {}", e))?
|
|
.filter_map(|device| {
|
|
device.name().ok().map(|name| {
|
|
let is_default = default_device_name.as_ref().map(|d| d == &name).unwrap_or(false);
|
|
PipewireSink {
|
|
name: name.clone(),
|
|
description: name, // On Mac/Windows, name is usually descriptive enough
|
|
volume: None, // Volume not available via cpal
|
|
is_default,
|
|
}
|
|
})
|
|
})
|
|
.collect();
|
|
|
|
log::info!("Found {} audio output devices", sinks.len());
|
|
Ok(sinks)
|
|
}
|
|
|
|
/// Set the default PipeWire/PulseAudio sink
|
|
/// This is used when the user selects an audio device in settings
|
|
#[cfg(target_os = "linux")]
|
|
#[tauri::command]
|
|
pub fn set_pipewire_default_sink(sink_name: String) -> Result<(), String> {
|
|
use std::process::Command;
|
|
|
|
log::info!("Command: set_pipewire_default_sink {}", sink_name);
|
|
|
|
let output = Command::new("pactl")
|
|
.args(["set-default-sink", &sink_name])
|
|
.output()
|
|
.map_err(|e| format!("Failed to run pactl: {}", e))?;
|
|
|
|
if !output.status.success() {
|
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
|
return Err(format!("pactl set-default-sink failed: {}", stderr));
|
|
}
|
|
|
|
log::info!("Set default sink to: {}", sink_name);
|
|
Ok(())
|
|
}
|
|
|
|
/// Set the default sink (no-op on non-Linux)
|
|
#[cfg(not(target_os = "linux"))]
|
|
#[tauri::command]
|
|
pub fn set_pipewire_default_sink(_sink_name: String) -> Result<(), String> {
|
|
log::info!("Command: set_pipewire_default_sink (no-op on non-Linux)");
|
|
Ok(())
|
|
}
|
|
|
|
/// DEBUG: Get CPAL device names for comparison with PipeWire sinks
|
|
#[tauri::command]
|
|
pub fn debug_get_cpal_devices() -> Result<Vec<String>, String> {
|
|
use rodio::cpal::traits::{DeviceTrait, HostTrait};
|
|
|
|
log::info!("Command: debug_get_cpal_devices");
|
|
|
|
let host = rodio::cpal::default_host();
|
|
|
|
let devices: Vec<String> = host
|
|
.output_devices()
|
|
.map_err(|e| format!("Failed to enumerate devices: {}", e))?
|
|
.filter_map(|device| device.name().ok())
|
|
.collect();
|
|
|
|
log::info!("CPAL devices: {:?}", devices);
|
|
Ok(devices)
|
|
}
|