refactor(qbz-app): add playback context
cx
This commit is contained in:
2 files changed
+323
No files matched your search
@@ -1,6 +1,7 @@
|
|||||||
pub mod boot_watchdog;
|
pub mod boot_watchdog;
|
||||||
pub mod graphics_autoconfig;
|
pub mod graphics_autoconfig;
|
||||||
pub mod graphics_detection;
|
pub mod graphics_detection;
|
||||||
|
pub mod playback_context;
|
||||||
pub mod runtime;
|
pub mod runtime;
|
||||||
pub mod session_store;
|
pub mod session_store;
|
||||||
pub mod settings;
|
pub mod settings;
|
||||||
|
|||||||
@@ -0,0 +1,322 @@
|
|||||||
|
//! Playback context model.
|
||||||
|
//!
|
||||||
|
//! A playback context describes the semantic origin of playback. It is not the
|
||||||
|
//! queue itself; it is the source boundary used by commands that need to know
|
||||||
|
//! whether the current playback came from an album, playlist, radio session,
|
||||||
|
//! search result, or another app-level source.
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::sync::Mutex;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum ContextType {
|
||||||
|
Album,
|
||||||
|
Playlist,
|
||||||
|
ArtistTop,
|
||||||
|
LabelTop,
|
||||||
|
HomeList,
|
||||||
|
DailyQ,
|
||||||
|
WeeklyQ,
|
||||||
|
FavQ,
|
||||||
|
TopQ,
|
||||||
|
Favorites,
|
||||||
|
LocalLibrary,
|
||||||
|
Radio,
|
||||||
|
Search,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
pub enum ContentSource {
|
||||||
|
Qobuz,
|
||||||
|
Local,
|
||||||
|
Plex,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct PlaybackContext {
|
||||||
|
#[serde(rename = "type")]
|
||||||
|
pub context_type: ContextType,
|
||||||
|
pub id: String,
|
||||||
|
pub label: String,
|
||||||
|
pub source: ContentSource,
|
||||||
|
pub track_ids: Vec<u64>,
|
||||||
|
pub current_position: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PlaybackContext {
|
||||||
|
pub fn new(
|
||||||
|
context_type: ContextType,
|
||||||
|
id: String,
|
||||||
|
label: String,
|
||||||
|
source: ContentSource,
|
||||||
|
track_ids: Vec<u64>,
|
||||||
|
start_position: usize,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
context_type,
|
||||||
|
id,
|
||||||
|
label,
|
||||||
|
source,
|
||||||
|
track_ids,
|
||||||
|
current_position: start_position,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn next_track_id(&self) -> Option<u64> {
|
||||||
|
let next_pos = self.current_position + 1;
|
||||||
|
if next_pos < self.track_ids.len() {
|
||||||
|
self.track_ids.get(next_pos).copied()
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn upcoming_track_ids(&self, count: usize) -> Vec<u64> {
|
||||||
|
let start_pos = self.current_position + 1;
|
||||||
|
self.track_ids
|
||||||
|
.iter()
|
||||||
|
.skip(start_pos)
|
||||||
|
.take(count)
|
||||||
|
.copied()
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn advance(&mut self) -> bool {
|
||||||
|
let next_pos = self.current_position + 1;
|
||||||
|
if next_pos < self.track_ids.len() {
|
||||||
|
self.current_position = next_pos;
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn has_next(&self) -> bool {
|
||||||
|
self.current_position + 1 < self.track_ids.len()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn total_tracks(&self) -> usize {
|
||||||
|
self.track_ids.len()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn display_info(&self) -> String {
|
||||||
|
let type_str = match self.context_type {
|
||||||
|
ContextType::Album => "Album",
|
||||||
|
ContextType::Playlist => "Playlist",
|
||||||
|
ContextType::ArtistTop => "Artist Top Songs",
|
||||||
|
ContextType::LabelTop => "Label Top Songs",
|
||||||
|
ContextType::HomeList => "Home List",
|
||||||
|
ContextType::DailyQ => "DailyQ",
|
||||||
|
ContextType::WeeklyQ => "WeeklyQ",
|
||||||
|
ContextType::FavQ => "FavQ",
|
||||||
|
ContextType::TopQ => "TopQ",
|
||||||
|
ContextType::Favorites => "Favorites",
|
||||||
|
ContextType::LocalLibrary => "Local Library",
|
||||||
|
ContextType::Radio => "Radio",
|
||||||
|
ContextType::Search => "Search Results",
|
||||||
|
};
|
||||||
|
format!("{} · {}", type_str, self.label)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct ContextManager {
|
||||||
|
current: Mutex<Option<PlaybackContext>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for ContextManager {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ContextManager {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
current: Mutex::new(None),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_context(&self, context: PlaybackContext) {
|
||||||
|
let mut current = self.current.lock().unwrap();
|
||||||
|
*current = Some(context);
|
||||||
|
log::info!(
|
||||||
|
"Playback context set: {:?}",
|
||||||
|
current.as_ref().map(|c| &c.label)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn clear_context(&self) {
|
||||||
|
let mut current = self.current.lock().unwrap();
|
||||||
|
*current = None;
|
||||||
|
log::info!("Playback context cleared");
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_context(&self) -> Option<PlaybackContext> {
|
||||||
|
self.current.lock().unwrap().clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn has_context(&self) -> bool {
|
||||||
|
self.current.lock().unwrap().is_some()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn next_track_id(&self) -> Option<u64> {
|
||||||
|
self.current
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|ctx| ctx.next_track_id())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn upcoming_track_ids(&self, count: usize) -> Vec<u64> {
|
||||||
|
self.current
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.as_ref()
|
||||||
|
.map(|ctx| ctx.upcoming_track_ids(count))
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn advance_context(&self) -> bool {
|
||||||
|
let mut current = self.current.lock().unwrap();
|
||||||
|
if let Some(ctx) = current.as_mut() {
|
||||||
|
let advanced = ctx.advance();
|
||||||
|
if !advanced {
|
||||||
|
log::info!("Playback context ended (no more tracks)");
|
||||||
|
}
|
||||||
|
advanced
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_position(&self, track_id: u64) {
|
||||||
|
let mut current = self.current.lock().unwrap();
|
||||||
|
if let Some(ctx) = current.as_mut() {
|
||||||
|
if let Some(pos) = ctx.track_ids.iter().position(|&id| id == track_id) {
|
||||||
|
ctx.current_position = pos;
|
||||||
|
log::debug!("Context position updated to {}", pos);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn append_track_ids(&self, new_track_ids: Vec<u64>) {
|
||||||
|
let mut current = self.current.lock().unwrap();
|
||||||
|
if let Some(ctx) = current.as_mut() {
|
||||||
|
let count = new_track_ids.len();
|
||||||
|
ctx.track_ids.extend(new_track_ids);
|
||||||
|
log::debug!(
|
||||||
|
"Appended {} track IDs to context (total: {})",
|
||||||
|
count,
|
||||||
|
ctx.track_ids.len()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn album_context() -> PlaybackContext {
|
||||||
|
PlaybackContext::new(
|
||||||
|
ContextType::Album,
|
||||||
|
"album-1".to_string(),
|
||||||
|
"Album Title".to_string(),
|
||||||
|
ContentSource::Qobuz,
|
||||||
|
vec![10, 11, 12, 13],
|
||||||
|
1,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn playback_context_reports_next_and_upcoming_tracks() {
|
||||||
|
let context = album_context();
|
||||||
|
|
||||||
|
assert_eq!(context.next_track_id(), Some(12));
|
||||||
|
assert_eq!(context.upcoming_track_ids(2), vec![12, 13]);
|
||||||
|
assert_eq!(context.upcoming_track_ids(10), vec![12, 13]);
|
||||||
|
assert!(context.has_next());
|
||||||
|
assert_eq!(context.total_tracks(), 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn playback_context_advance_updates_position_until_end() {
|
||||||
|
let mut context = album_context();
|
||||||
|
|
||||||
|
assert!(context.advance());
|
||||||
|
assert_eq!(context.current_position, 2);
|
||||||
|
assert_eq!(context.next_track_id(), Some(13));
|
||||||
|
assert!(context.advance());
|
||||||
|
assert_eq!(context.current_position, 3);
|
||||||
|
assert_eq!(context.next_track_id(), None);
|
||||||
|
assert!(!context.has_next());
|
||||||
|
assert!(!context.advance());
|
||||||
|
assert_eq!(context.current_position, 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn playback_context_display_info_matches_existing_labels() {
|
||||||
|
assert_eq!(album_context().display_info(), "Album · Album Title");
|
||||||
|
|
||||||
|
let radio = PlaybackContext::new(
|
||||||
|
ContextType::Radio,
|
||||||
|
"radio-1".to_string(),
|
||||||
|
"Seed".to_string(),
|
||||||
|
ContentSource::Qobuz,
|
||||||
|
vec![1],
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
assert_eq!(radio.display_info(), "Radio · Seed");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn context_manager_sets_clears_and_reports_context() {
|
||||||
|
let manager = ContextManager::new();
|
||||||
|
assert!(!manager.has_context());
|
||||||
|
assert_eq!(manager.next_track_id(), None);
|
||||||
|
assert!(manager.upcoming_track_ids(3).is_empty());
|
||||||
|
assert!(!manager.advance_context());
|
||||||
|
|
||||||
|
manager.set_context(album_context());
|
||||||
|
|
||||||
|
assert!(manager.has_context());
|
||||||
|
assert_eq!(manager.next_track_id(), Some(12));
|
||||||
|
assert_eq!(manager.upcoming_track_ids(3), vec![12, 13]);
|
||||||
|
assert_eq!(
|
||||||
|
manager.get_context().map(|ctx| ctx.label),
|
||||||
|
Some("Album Title".to_string())
|
||||||
|
);
|
||||||
|
|
||||||
|
manager.clear_context();
|
||||||
|
assert!(!manager.has_context());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn context_manager_updates_position_by_track_id() {
|
||||||
|
let manager = ContextManager::new();
|
||||||
|
manager.set_context(album_context());
|
||||||
|
|
||||||
|
manager.set_position(13);
|
||||||
|
let context = manager.get_context().expect("context exists");
|
||||||
|
assert_eq!(context.current_position, 3);
|
||||||
|
assert_eq!(context.next_track_id(), None);
|
||||||
|
|
||||||
|
manager.set_position(999);
|
||||||
|
let context = manager.get_context().expect("context exists");
|
||||||
|
assert_eq!(context.current_position, 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn context_manager_appends_radio_refill_track_ids() {
|
||||||
|
let manager = ContextManager::new();
|
||||||
|
manager.set_context(album_context());
|
||||||
|
|
||||||
|
manager.append_track_ids(vec![14, 15]);
|
||||||
|
let context = manager.get_context().expect("context exists");
|
||||||
|
|
||||||
|
assert_eq!(context.track_ids, vec![10, 11, 12, 13, 14, 15]);
|
||||||
|
assert_eq!(context.upcoming_track_ids(10), vec![12, 13, 14, 15]);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user