feat(streaming): integrate streaming playback in play_track command
- 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
This commit is contained in:
1 parent
5519fc7b25
commit
39dd249f98
3 files changed
+657
-16
No files matched your search
@@ -7,6 +7,7 @@ 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;
|
||||
@@ -18,6 +19,7 @@ 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);
|
||||
|
||||
@@ -89,8 +91,20 @@ pub async fn play_track(
|
||||
}
|
||||
}
|
||||
|
||||
// Not in any cache - download and cache in memory
|
||||
log::info!("Track {} not in any cache, streaming...", track_id);
|
||||
// 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;
|
||||
|
||||
@@ -102,6 +116,55 @@ pub async fn play_track(
|
||||
|
||||
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();
|
||||
@@ -215,6 +278,213 @@ async fn download_audio(url: &str) -> Result<Vec<u8>, String> {
|
||||
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;
|
||||
|
||||
|
||||
@@ -51,6 +51,15 @@ enum AudioCommand {
|
||||
sample_rate: u32,
|
||||
channels: u16,
|
||||
},
|
||||
/// Play from streaming source (BufferedMediaSource)
|
||||
PlayStreaming {
|
||||
source: Arc<BufferedMediaSource>,
|
||||
writer: BufferWriter,
|
||||
track_id: u64,
|
||||
sample_rate: u32,
|
||||
channels: u16,
|
||||
content_length: u64,
|
||||
},
|
||||
/// Pause playback
|
||||
Pause,
|
||||
/// Resume playback
|
||||
@@ -180,6 +189,96 @@ fn decode_with_symphonia(data: &[u8]) -> Result<AudioSpecs, String> {
|
||||
})
|
||||
}
|
||||
|
||||
/// Decode audio from a streaming source (BufferedMediaSource).
|
||||
/// Returns decoded samples as they become available.
|
||||
/// This allows playback to start before the full file is downloaded.
|
||||
fn decode_streaming_source(
|
||||
source: &BufferedMediaSource,
|
||||
) -> Result<AudioSpecs, String> {
|
||||
// Create a new reader from the source - shares buffer but has its own read position
|
||||
let reader = source.create_reader();
|
||||
let media_source = Box::new(reader) as Box<dyn MediaSource>;
|
||||
let mss = MediaSourceStream::new(media_source, Default::default());
|
||||
|
||||
let mut hint = Hint::new();
|
||||
hint.with_extension("m4a");
|
||||
|
||||
let format_opts = FormatOptions {
|
||||
enable_gapless: true,
|
||||
..Default::default()
|
||||
};
|
||||
let metadata_opts: MetadataOptions = Default::default();
|
||||
let mut probed = get_probe()
|
||||
.format(&hint, mss, &format_opts, &metadata_opts)
|
||||
.map_err(|err| format!("Symphonia probe failed for streaming: {}", err))?;
|
||||
|
||||
let track = probed
|
||||
.format
|
||||
.default_track()
|
||||
.ok_or_else(|| "Symphonia: no supported audio tracks in stream".to_string())?;
|
||||
let track_id = track.id;
|
||||
let codec_params = track.codec_params.clone();
|
||||
|
||||
let mut decoder = get_codecs()
|
||||
.make(&codec_params, &DecoderOptions::default())
|
||||
.map_err(|err| format!("Symphonia decoder init failed for streaming: {}", err))?;
|
||||
|
||||
let mut sample_rate = 0;
|
||||
let mut channels = 0u16;
|
||||
let mut samples: Vec<i16> = Vec::new();
|
||||
|
||||
loop {
|
||||
let packet = match probed.format.next_packet() {
|
||||
Ok(packet) => packet,
|
||||
Err(SymphoniaError::IoError(e)) => {
|
||||
// Check if we got WouldBlock (not enough data buffered yet)
|
||||
if e.kind() == std::io::ErrorKind::WouldBlock {
|
||||
// Wait for more data
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
continue;
|
||||
}
|
||||
// EOF or other IO error - we're done
|
||||
break;
|
||||
}
|
||||
Err(err) => return Err(format!("Symphonia read error in stream: {}", err)),
|
||||
};
|
||||
|
||||
if packet.track_id() != track_id {
|
||||
continue;
|
||||
}
|
||||
|
||||
match decoder.decode(&packet) {
|
||||
Ok(audio_buf) => {
|
||||
let spec = *audio_buf.spec();
|
||||
if sample_rate == 0 {
|
||||
sample_rate = spec.rate;
|
||||
channels = spec.channels.count() as u16;
|
||||
}
|
||||
|
||||
let mut sample_buf = SampleBuffer::<i16>::new(audio_buf.frames() as u64, spec);
|
||||
sample_buf.copy_interleaved_ref(audio_buf);
|
||||
samples.extend_from_slice(sample_buf.samples());
|
||||
}
|
||||
Err(SymphoniaError::DecodeError(_)) => continue,
|
||||
Err(SymphoniaError::ResetRequired) => {
|
||||
decoder.reset();
|
||||
continue;
|
||||
}
|
||||
Err(err) => return Err(format!("Symphonia decode error in stream: {}", err)),
|
||||
}
|
||||
}
|
||||
|
||||
if samples.is_empty() || sample_rate == 0 || channels == 0 {
|
||||
return Err("Symphonia streaming decode produced no audio".to_string());
|
||||
}
|
||||
|
||||
Ok(AudioSpecs {
|
||||
samples: SamplesBuffer::new(channels, sample_rate, samples),
|
||||
sample_rate,
|
||||
channels,
|
||||
})
|
||||
}
|
||||
|
||||
fn is_isomp4(data: &[u8]) -> bool {
|
||||
if data.len() < 12 {
|
||||
return false;
|
||||
@@ -1049,6 +1148,204 @@ impl Player {
|
||||
actual_duration
|
||||
);
|
||||
}
|
||||
AudioCommand::PlayStreaming { source, writer: _, track_id, sample_rate, channels, content_length } => {
|
||||
log::info!(
|
||||
"Audio thread: starting streaming playback for track {} ({}Hz, {} channels, {} bytes)",
|
||||
track_id,
|
||||
sample_rate,
|
||||
channels,
|
||||
content_length
|
||||
);
|
||||
*pause_suspend_deadline = None;
|
||||
|
||||
// Get DAC passthrough setting
|
||||
let dac_passthrough = thread_settings
|
||||
.lock()
|
||||
.ok()
|
||||
.map(|s| s.dac_passthrough)
|
||||
.unwrap_or(false);
|
||||
|
||||
// Check if we need to recreate the stream
|
||||
let format_changed = *current_sample_rate != Some(sample_rate)
|
||||
|| *current_channels != Some(channels);
|
||||
|
||||
let using_alsa_direct = thread_settings
|
||||
.lock()
|
||||
.ok()
|
||||
.and_then(|s| s.backend_type)
|
||||
.map(|b| b == AudioBackendType::Alsa)
|
||||
.unwrap_or(false);
|
||||
|
||||
let needs_new_stream = stream_opt.is_none()
|
||||
|| (dac_passthrough && format_changed)
|
||||
|| (using_alsa_direct && format_changed);
|
||||
|
||||
if needs_new_stream {
|
||||
if stream_opt.is_some() {
|
||||
if (dac_passthrough || using_alsa_direct) && format_changed {
|
||||
let mode = if using_alsa_direct { "ALSA Direct" } else { "DAC passthrough" };
|
||||
log::info!(
|
||||
"Streaming: Sample rate/channels changed to {}Hz/{}ch - recreating OutputStream ({})",
|
||||
sample_rate,
|
||||
channels,
|
||||
mode
|
||||
);
|
||||
}
|
||||
drop(stream_opt.take());
|
||||
}
|
||||
|
||||
let stream_result = if let Some(settings) = thread_settings.lock().ok() {
|
||||
match try_init_stream_with_backend(&settings, sample_rate, channels) {
|
||||
Some(result) => result,
|
||||
None => {
|
||||
log::info!("Backend system not configured, using legacy CPAL path");
|
||||
let device = if let Some(ref name) = *current_device_name {
|
||||
host.output_devices()
|
||||
.ok()
|
||||
.and_then(|mut devices| {
|
||||
devices.find(|d| d.name().ok().as_ref() == Some(name))
|
||||
})
|
||||
.or_else(|| host.default_output_device())
|
||||
} else {
|
||||
host.default_output_device()
|
||||
};
|
||||
|
||||
let Some(device) = device else {
|
||||
log::error!("No audio output device available for streaming");
|
||||
thread_state.set_stream_error(true);
|
||||
return;
|
||||
};
|
||||
|
||||
if let Ok(name) = device.name() {
|
||||
thread_state.set_current_device(Some(name));
|
||||
}
|
||||
|
||||
create_output_stream_with_config(
|
||||
&device,
|
||||
sample_rate,
|
||||
channels,
|
||||
dac_passthrough,
|
||||
).map(|(stream, handle)| StreamType::Rodio(stream, handle))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let device = host.default_output_device();
|
||||
let Some(device) = device else {
|
||||
log::error!("No audio output device available for streaming");
|
||||
thread_state.set_stream_error(true);
|
||||
return;
|
||||
};
|
||||
create_output_stream_with_config(&device, sample_rate, channels, dac_passthrough)
|
||||
.map(|(stream, handle)| StreamType::Rodio(stream, handle))
|
||||
};
|
||||
|
||||
match stream_result {
|
||||
Ok(stream) => {
|
||||
*stream_opt = Some(stream);
|
||||
*current_sample_rate = Some(sample_rate);
|
||||
*current_channels = Some(channels);
|
||||
thread_state.set_stream_error(false);
|
||||
log::info!("✅ Streaming audio stream ready at {}Hz", sample_rate);
|
||||
std::thread::sleep(Duration::from_millis(150));
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("❌ Failed to create stream for streaming at {}Hz: {}", sample_rate, e);
|
||||
thread_state.set_stream_error(true);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let Some(ref stream) = *stream_opt else {
|
||||
log::error!("Audio thread: no audio device available for streaming");
|
||||
return;
|
||||
};
|
||||
|
||||
// Stop previous engine
|
||||
if let Some(engine) = current_engine.take() {
|
||||
engine.stop();
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
}
|
||||
|
||||
// Create PlaybackEngine
|
||||
let mut engine = match stream {
|
||||
StreamType::Rodio(_stream, handle) => {
|
||||
match PlaybackEngine::new_rodio(handle) {
|
||||
Ok(e) => {
|
||||
*consecutive_sink_failures = 0;
|
||||
thread_state.set_stream_error(false);
|
||||
e
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("Failed to create engine for streaming: {}", e);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
StreamType::AlsaDirect(alsa_stream) => {
|
||||
let hardware_volume = thread_settings
|
||||
.lock()
|
||||
.ok()
|
||||
.map(|s| s.alsa_hardware_volume)
|
||||
.unwrap_or(false);
|
||||
PlaybackEngine::new_alsa_direct(alsa_stream.clone(), hardware_volume)
|
||||
}
|
||||
};
|
||||
|
||||
let volume = thread_state.volume.load(Ordering::SeqCst) as f32 / 100.0;
|
||||
engine.set_volume(volume);
|
||||
|
||||
// Wait for minimum buffer before decoding
|
||||
log::info!("Streaming: waiting for initial buffer...");
|
||||
let start_wait = Instant::now();
|
||||
let max_wait = Duration::from_secs(30);
|
||||
|
||||
while !source.has_min_buffer() && start_wait.elapsed() < max_wait {
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
}
|
||||
|
||||
if !source.has_min_buffer() {
|
||||
log::error!("Streaming: timeout waiting for initial buffer");
|
||||
return;
|
||||
}
|
||||
|
||||
log::info!("Streaming: initial buffer ready, starting decode...");
|
||||
|
||||
// Decode from streaming source (pass reference, it uses create_reader internally)
|
||||
let decoded = match decode_streaming_source(&source) {
|
||||
Ok(specs) => specs,
|
||||
Err(e) => {
|
||||
log::error!("Failed to decode streaming audio: {}", e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let actual_duration = decoded.samples
|
||||
.total_duration()
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0);
|
||||
thread_state.duration.store(actual_duration, Ordering::SeqCst);
|
||||
|
||||
// The samples are already a SamplesBuffer which implements Source
|
||||
let source_to_play: Box<dyn Source<Item = i16> + Send> = Box::new(decoded.samples);
|
||||
if let Err(e) = engine.append(source_to_play) {
|
||||
log::error!("Failed to append streaming source to engine: {}", e);
|
||||
return;
|
||||
}
|
||||
|
||||
thread_state.is_playing.store(true, Ordering::SeqCst);
|
||||
thread_state.position.store(0, Ordering::SeqCst);
|
||||
thread_state.current_track_id.store(track_id, Ordering::SeqCst);
|
||||
thread_state.start_playback_timer(0);
|
||||
|
||||
*current_engine = Some(engine);
|
||||
// Note: We don't store streaming data in current_audio_data since it's handled differently
|
||||
log::info!(
|
||||
"Audio thread: streaming playback started, duration: {}s",
|
||||
actual_duration
|
||||
);
|
||||
}
|
||||
AudioCommand::Pause => {
|
||||
if let Some(ref engine) = *current_engine {
|
||||
engine.pause();
|
||||
@@ -1441,6 +1738,48 @@ impl Player {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Play from streaming source (starts playback before full download)
|
||||
/// Returns the BufferWriter so caller can push data as it downloads
|
||||
pub fn play_streaming(
|
||||
&self,
|
||||
track_id: u64,
|
||||
sample_rate: u32,
|
||||
channels: u16,
|
||||
content_length: u64,
|
||||
buffer_seconds: u8,
|
||||
) -> Result<BufferWriter, String> {
|
||||
log::info!(
|
||||
"Player: Starting streaming playback for track {} ({}Hz, {}ch, {} bytes total)",
|
||||
track_id,
|
||||
sample_rate,
|
||||
channels,
|
||||
content_length
|
||||
);
|
||||
|
||||
// Use StreamingConfig::from_seconds for proper buffer sizing
|
||||
let config = StreamingConfig::from_seconds(buffer_seconds);
|
||||
|
||||
let (source, writer) = BufferedMediaSource::new(config, Some(content_length));
|
||||
let source = Arc::new(source);
|
||||
|
||||
self.tx
|
||||
.send(AudioCommand::PlayStreaming {
|
||||
source: source.clone(),
|
||||
writer: writer.clone(),
|
||||
track_id,
|
||||
sample_rate,
|
||||
channels,
|
||||
content_length,
|
||||
})
|
||||
.map_err(|e| {
|
||||
log::error!("Player: Failed to send streaming command: {}", e);
|
||||
format!("Failed to send streaming play command: {}", e)
|
||||
})?;
|
||||
|
||||
log::info!("Player: Streaming playback initiated");
|
||||
Ok(writer)
|
||||
}
|
||||
|
||||
/// Download audio from URL with timeout
|
||||
async fn download_audio(&self, url: &str) -> Result<Vec<u8>, String> {
|
||||
use std::time::Duration;
|
||||
|
||||
@@ -63,8 +63,6 @@ impl StreamingConfig {
|
||||
struct BufferState {
|
||||
/// Accumulated data from HTTP response
|
||||
data: Vec<u8>,
|
||||
/// Current read position
|
||||
read_pos: usize,
|
||||
/// True when HTTP download is complete
|
||||
download_complete: bool,
|
||||
/// Error from download, if any
|
||||
@@ -81,6 +79,8 @@ struct BufferState {
|
||||
pub struct BufferedMediaSource {
|
||||
state: Arc<(Mutex<BufferState>, Condvar)>,
|
||||
config: StreamingConfig,
|
||||
/// Each reader has its own read position
|
||||
read_pos: std::sync::atomic::AtomicU64,
|
||||
}
|
||||
|
||||
impl BufferedMediaSource {
|
||||
@@ -92,7 +92,6 @@ impl BufferedMediaSource {
|
||||
let state = Arc::new((
|
||||
Mutex::new(BufferState {
|
||||
data: Vec::with_capacity(config.initial_buffer_bytes),
|
||||
read_pos: 0,
|
||||
download_complete: false,
|
||||
download_error: None,
|
||||
total_size,
|
||||
@@ -102,7 +101,8 @@ impl BufferedMediaSource {
|
||||
|
||||
let source = Self {
|
||||
state: Arc::clone(&state),
|
||||
config,
|
||||
config: config.clone(),
|
||||
read_pos: std::sync::atomic::AtomicU64::new(0),
|
||||
};
|
||||
|
||||
let writer = BufferWriter { state };
|
||||
@@ -110,6 +110,16 @@ impl BufferedMediaSource {
|
||||
(source, writer)
|
||||
}
|
||||
|
||||
/// Create a new reader that shares the same buffer but has its own read position.
|
||||
/// This is used to pass to symphonia which needs ownership of the reader.
|
||||
pub fn create_reader(&self) -> Self {
|
||||
Self {
|
||||
state: Arc::clone(&self.state),
|
||||
config: self.config.clone(),
|
||||
read_pos: std::sync::atomic::AtomicU64::new(0),
|
||||
}
|
||||
}
|
||||
|
||||
/// Wait until initial buffer is filled or download completes.
|
||||
///
|
||||
/// This should be called before passing the source to the decoder,
|
||||
@@ -192,17 +202,34 @@ impl BufferedMediaSource {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if minimum buffer for playback is available
|
||||
///
|
||||
/// Returns true when initial_buffer_bytes have been buffered
|
||||
/// or the download is complete.
|
||||
pub fn has_min_buffer(&self) -> bool {
|
||||
let (lock, _) = &*self.state;
|
||||
if let Ok(state) = lock.lock() {
|
||||
state.data.len() >= self.config.initial_buffer_bytes || state.download_complete
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Read for BufferedMediaSource {
|
||||
fn read(&mut self, buf: &mut [u8]) -> IoResult<usize> {
|
||||
use std::sync::atomic::Ordering;
|
||||
|
||||
let (lock, cvar) = &*self.state;
|
||||
let mut state = lock.lock().map_err(|_| {
|
||||
IoError::new(ErrorKind::Other, "Failed to acquire buffer lock")
|
||||
})?;
|
||||
|
||||
let read_pos = self.read_pos.load(Ordering::SeqCst) as usize;
|
||||
|
||||
// Wait for data if we're ahead of buffer
|
||||
while state.read_pos >= state.data.len()
|
||||
while read_pos >= state.data.len()
|
||||
&& !state.download_complete
|
||||
&& state.download_error.is_none()
|
||||
{
|
||||
@@ -217,15 +244,15 @@ impl Read for BufferedMediaSource {
|
||||
}
|
||||
|
||||
// EOF if at end and download complete
|
||||
if state.read_pos >= state.data.len() && state.download_complete {
|
||||
if read_pos >= state.data.len() && state.download_complete {
|
||||
return Ok(0);
|
||||
}
|
||||
|
||||
// Read available data
|
||||
let available = state.data.len() - state.read_pos;
|
||||
let available = state.data.len() - read_pos;
|
||||
let to_read = buf.len().min(available);
|
||||
buf[..to_read].copy_from_slice(&state.data[state.read_pos..state.read_pos + to_read]);
|
||||
state.read_pos += to_read;
|
||||
buf[..to_read].copy_from_slice(&state.data[read_pos..read_pos + to_read]);
|
||||
self.read_pos.store((read_pos + to_read) as u64, Ordering::SeqCst);
|
||||
|
||||
Ok(to_read)
|
||||
}
|
||||
@@ -233,14 +260,18 @@ impl Read for BufferedMediaSource {
|
||||
|
||||
impl Seek for BufferedMediaSource {
|
||||
fn seek(&mut self, pos: SeekFrom) -> IoResult<u64> {
|
||||
use std::sync::atomic::Ordering;
|
||||
|
||||
let (lock, cvar) = &*self.state;
|
||||
let mut state = lock.lock().map_err(|_| {
|
||||
IoError::new(ErrorKind::Other, "Failed to acquire buffer lock")
|
||||
})?;
|
||||
|
||||
let current_pos = self.read_pos.load(Ordering::SeqCst) as i64;
|
||||
|
||||
let new_pos = match pos {
|
||||
SeekFrom::Start(offset) => offset as i64,
|
||||
SeekFrom::Current(offset) => state.read_pos as i64 + offset,
|
||||
SeekFrom::Current(offset) => current_pos + offset,
|
||||
SeekFrom::End(offset) => {
|
||||
// For End seeks, we need to know total size or have complete download
|
||||
if let Some(total) = state.total_size {
|
||||
@@ -264,10 +295,10 @@ impl Seek for BufferedMediaSource {
|
||||
));
|
||||
}
|
||||
|
||||
let new_pos = new_pos as usize;
|
||||
let new_pos_usize = new_pos as usize;
|
||||
|
||||
// If seeking forward beyond buffer, wait for data
|
||||
while new_pos > state.data.len()
|
||||
while new_pos_usize > state.data.len()
|
||||
&& !state.download_complete
|
||||
&& state.download_error.is_none()
|
||||
{
|
||||
@@ -281,14 +312,14 @@ impl Seek for BufferedMediaSource {
|
||||
}
|
||||
|
||||
// After download complete, check bounds
|
||||
if state.download_complete && new_pos > state.data.len() {
|
||||
if state.download_complete && new_pos_usize > state.data.len() {
|
||||
return Err(IoError::new(
|
||||
ErrorKind::InvalidInput,
|
||||
"Seek position beyond end of stream",
|
||||
));
|
||||
}
|
||||
|
||||
state.read_pos = new_pos;
|
||||
self.read_pos.store(new_pos as u64, Ordering::SeqCst);
|
||||
Ok(new_pos as u64)
|
||||
}
|
||||
}
|
||||
@@ -314,6 +345,7 @@ impl MediaSource for BufferedMediaSource {
|
||||
///
|
||||
/// This is the sender side that receives data from the HTTP response
|
||||
/// and makes it available to the `BufferedMediaSource` reader.
|
||||
#[derive(Clone)]
|
||||
pub struct BufferWriter {
|
||||
state: Arc<(Mutex<BufferState>, Condvar)>,
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user