Skip to main content

oxidio_ctl/
processor.rs

1//! Command processor — processes commands and broadcasts state updates.
2//!
3//! The `CommandProcessor` receives commands from any frontend client via the
4//! control channel, executes them against the shared `Player`, and broadcasts
5//! state updates to all subscribers.
6
7use std::path::{ Path, PathBuf };
8use std::sync::{ Arc, Mutex };
9use std::time::{ Duration, Instant };
10
11use tokio::sync::{ broadcast, mpsc };
12
13use oxidio_core::player::PlaybackState;
14use oxidio_core::library::LibraryScanner;
15use oxidio_core::{ Player, Playlist, RepeatMode };
16
17use oxidio_protocol::{
18    AppCommand, BrowserEntry, BrowserSnapshot, PlaybackStateValue,
19    RepeatModeValue, SettingsSnapshot, StateSnapshot, StateUpdate,
20    TrackEntry, TrackInfo,
21};
22
23
24/// Application settings managed by the processor.
25#[derive( Debug, Clone, serde::Serialize, serde::Deserialize )]
26#[serde( default )]
27pub struct ProcessorSettings {
28    pub discord_enabled: bool,
29    pub smtc_enabled: bool,
30    pub web_enabled: bool,
31    pub web_port: u16,
32    pub web_bind: String,
33}
34
35
36impl Default for ProcessorSettings {
37    fn default() -> Self {
38        Self {
39            discord_enabled: true,
40            smtc_enabled: true,
41            web_enabled: false,
42            web_port: 8384,
43            web_bind: "127.0.0.1".to_string(),
44        }
45    }
46}
47
48
49impl ProcessorSettings {
50    /// Returns the path to the settings file.
51    fn settings_path() -> Option<PathBuf> {
52        dirs::config_dir().map( |p| p.join( "oxidio" ).join( "settings.json" ) )
53    }
54
55
56    /// Loads settings from disk, or returns defaults if not found.
57    pub fn load() -> Self {
58        let path = match Self::settings_path() {
59            Some( p ) => p,
60            None => return Self::default(),
61        };
62
63        if !path.exists() {
64            return Self::default();
65        }
66
67        match std::fs::read_to_string( &path ) {
68            Ok( contents ) => {
69                serde_json::from_str( &contents ).unwrap_or_default()
70            }
71            Err( e ) => {
72                tracing::warn!( "Failed to read settings: {}", e );
73                Self::default()
74            }
75        }
76    }
77
78
79    /// Saves settings to disk.
80    pub fn save( &self ) {
81        let path = match Self::settings_path() {
82            Some( p ) => p,
83            None => return,
84        };
85
86        if let Some( parent ) = path.parent() {
87            if !parent.exists() {
88                if let Err( e ) = std::fs::create_dir_all( parent ) {
89                    tracing::warn!( "Failed to create settings directory: {}", e );
90                    return;
91                }
92            }
93        }
94
95        match serde_json::to_string_pretty( self ) {
96            Ok( json ) => {
97                if let Err( e ) = std::fs::write( &path, json ) {
98                    tracing::warn!( "Failed to save settings: {}", e );
99                }
100            }
101            Err( e ) => {
102                tracing::warn!( "Failed to serialize settings: {}", e );
103            }
104        }
105    }
106
107
108    /// Converts to a protocol snapshot.
109    pub fn to_snapshot( &self ) -> SettingsSnapshot {
110        SettingsSnapshot {
111            discord_enabled: self.discord_enabled,
112            smtc_enabled: self.smtc_enabled,
113            web_enabled: self.web_enabled,
114            web_port: self.web_port,
115            web_bind: self.web_bind.clone(),
116        }
117    }
118}
119
120
121/// Lightweight browser state for web clients.
122struct BrowserState {
123    current_dir: PathBuf,
124    entries: Vec<BrowserEntryInternal>,
125    selected: usize,
126}
127
128
129#[derive( Debug, Clone )]
130struct BrowserEntryInternal {
131    path: PathBuf,
132    name: String,
133    is_dir: bool,
134    is_audio: bool,
135}
136
137
138/// Audio file extensions for browser highlighting.
139const AUDIO_EXTENSIONS: &[&str] = &[
140    "mp3", "flac", "ogg", "wav", "m4a", "aac", "opus", "wma", "aiff", "alac",
141];
142
143
144impl BrowserState {
145    fn new( path: PathBuf ) -> Self {
146        let mut state = Self {
147            current_dir: path,
148            entries: Vec::new(),
149            selected: 0,
150        };
151        state.refresh();
152        state
153    }
154
155
156    fn refresh( &mut self ) {
157        self.entries.clear();
158        self.selected = 0;
159
160        if let Some( parent ) = self.current_dir.parent() {
161            self.entries.push( BrowserEntryInternal {
162                path: parent.to_path_buf(),
163                name: "..".to_string(),
164                is_dir: true,
165                is_audio: false,
166            });
167        }
168
169        let mut dirs = Vec::new();
170        let mut files = Vec::new();
171
172        if let Ok( read_dir ) = std::fs::read_dir( &self.current_dir ) {
173            for entry in read_dir.flatten() {
174                let path = entry.path();
175                let name = entry.file_name().to_string_lossy().to_string();
176
177                if name.starts_with( '.' ) {
178                    continue;
179                }
180
181                let is_dir = path.is_dir();
182                let is_audio = !is_dir && Self::is_audio_file( &path );
183
184                let browser_entry = BrowserEntryInternal { path, name, is_dir, is_audio };
185
186                if is_dir {
187                    dirs.push( browser_entry );
188                } else if is_audio {
189                    files.push( browser_entry );
190                }
191            }
192        }
193
194        dirs.sort_by( |a, b| a.name.to_lowercase().cmp( &b.name.to_lowercase() ) );
195        files.sort_by( |a, b| a.name.to_lowercase().cmp( &b.name.to_lowercase() ) );
196
197        self.entries.extend( dirs );
198        self.entries.extend( files );
199    }
200
201
202    fn navigate_to( &mut self, path: &std::path::Path ) {
203        let canonical = if path.is_absolute() {
204            path.to_path_buf()
205        } else {
206            self.current_dir.join( path )
207        };
208
209        if canonical.is_dir() {
210            self.current_dir = canonical;
211            self.refresh();
212        }
213    }
214
215
216    fn to_snapshot( &self ) -> BrowserSnapshot {
217        BrowserSnapshot {
218            current_dir: self.current_dir.to_string_lossy().to_string(),
219            entries: self.entries.iter().map( |e| BrowserEntry {
220                name: e.name.clone(),
221                path: e.path.to_string_lossy().to_string(),
222                is_dir: e.is_dir,
223                is_audio: e.is_audio,
224            }).collect(),
225            selected_index: self.selected,
226        }
227    }
228
229
230    fn is_audio_file( path: &std::path::Path ) -> bool {
231        path.extension()
232            .and_then( |e| e.to_str() )
233            .map( |e| AUDIO_EXTENSIONS.contains( &e.to_lowercase().as_str() ) )
234            .unwrap_or( false )
235    }
236}
237
238
239/// Processes commands from the control channel and broadcasts state updates.
240///
241/// Accepts a shared `Arc<Player>` so that the TUI can also read from the
242/// same player instance for rendering.
243pub struct CommandProcessor {
244    player: Arc<Player>,
245    settings: ProcessorSettings,
246    browser: BrowserState,
247
248    // Channels
249    command_rx: mpsc::Receiver<AppCommand>,
250    broadcast_tx: broadcast::Sender<StateUpdate>,
251
252    // State tracking for change detection
253    last_playback_state: PlaybackState,
254    last_track_path: Option<PathBuf>,
255    last_volume: f32,
256    last_shuffle: bool,
257    last_repeat: RepeatMode,
258    last_position_broadcast: Instant,
259    last_vis_broadcast: Instant,
260
261    // Current view mode (tracked for connected clients)
262    view_mode: String,
263
264    // Shared cover art path (read by web server's /api/cover endpoint)
265    cover_art_path: Arc<Mutex<Option<PathBuf>>>,
266}
267
268
269impl CommandProcessor {
270    /// Creates a new command processor with a shared player.
271    ///
272    /// @param player - Shared player instance (also held by the TUI)
273    /// @param settings - Application settings
274    /// @param start_path - Starting directory for the file browser
275    /// @param browse - Whether to start in browse mode
276    /// @param command_rx - Receiver for commands from frontends
277    /// @param broadcast_tx - Sender for broadcasting state updates
278    pub fn new(
279        player: Arc<Player>,
280        settings: ProcessorSettings,
281        start_path: PathBuf,
282        browse: bool,
283        command_rx: mpsc::Receiver<AppCommand>,
284        broadcast_tx: broadcast::Sender<StateUpdate>,
285    ) -> Self {
286        let browser = BrowserState::new( start_path );
287
288        let volume = player.volume();
289        let shuffle = player.playlist().read().unwrap().shuffle();
290        let repeat = player.playlist().read().unwrap().repeat();
291
292        let view_mode = if browse {
293            "browser".to_string()
294        } else {
295            "playlist".to_string()
296        };
297
298        Self {
299            player,
300            settings,
301            browser,
302            command_rx,
303            broadcast_tx,
304            last_playback_state: PlaybackState::Stopped,
305            last_track_path: None,
306            last_volume: volume,
307            last_shuffle: shuffle,
308            last_repeat: repeat,
309            last_position_broadcast: Instant::now(),
310            last_vis_broadcast: Instant::now(),
311            view_mode,
312            cover_art_path: Arc::new( Mutex::new( None ) ),
313        }
314    }
315
316
317    /// Returns a cloneable reference to the shared cover art path.
318    ///
319    /// Used by the web server to serve album art via `/api/cover`.
320    pub fn cover_art_path( &self ) -> Arc<Mutex<Option<PathBuf>>> {
321        Arc::clone( &self.cover_art_path )
322    }
323
324
325    /// Runs the command processing loop.
326    ///
327    /// Blocks until the channel is closed or a Quit command is received.
328    pub async fn run( &mut self ) {
329        let mut tick_interval = tokio::time::interval( Duration::from_millis( 100 ) );
330
331        loop {
332            tokio::select! {
333                Some( cmd ) = self.command_rx.recv() => {
334                    if matches!( cmd, AppCommand::Quit ) {
335                        self.save_session();
336                        break;
337                    }
338                    self.handle_command( cmd );
339                }
340
341                _ = tick_interval.tick() => {
342                    self.tick();
343                }
344            }
345        }
346    }
347
348
349    /// Periodic update — handles auto-advance and broadcasts state changes.
350    fn tick( &mut self ) {
351        // Auto-advance to next track when current ends
352        if self.player.track_ended() {
353            match self.player.play_next() {
354                Ok( true ) => {
355                    tracing::info!( "Auto-advanced to next track" );
356                }
357                Ok( false ) => {}
358                Err( e ) => {
359                    tracing::warn!( "Auto-advance error: {}", e );
360                    let _ = self.broadcast_tx.send( StateUpdate::StatusMessage {
361                        message: format!( "Auto-advance error: {}", e ),
362                    });
363                }
364            }
365        }
366
367        self.broadcast_changes();
368    }
369
370
371    /// Detects state changes and broadcasts incremental updates.
372    fn broadcast_changes( &mut self ) {
373        let current_state = self.player.state();
374        let current_track = self.player.current_track();
375        let current_volume = self.player.volume();
376
377        let playlist_arc = self.player.playlist();
378        let playlist = playlist_arc.read().unwrap();
379        let current_shuffle = playlist.shuffle();
380        let current_repeat = playlist.repeat();
381        drop( playlist );
382
383        // Playback state changed
384        if current_state != self.last_playback_state {
385            let _ = self.broadcast_tx.send( StateUpdate::PlaybackStateChanged {
386                state: playback_state_to_value( current_state ),
387            });
388            self.last_playback_state = current_state;
389        }
390
391        // Track changed
392        if current_track != self.last_track_path {
393            let track_info = current_track.as_ref().map( |path| {
394                self.build_track_info( path )
395            });
396            let duration = self.player.duration().map( |d| d.as_secs_f64() );
397
398            let _ = self.broadcast_tx.send( StateUpdate::TrackChanged {
399                track: track_info,
400                duration_secs: duration,
401            });
402            self.last_track_path = current_track;
403        }
404
405        // Volume changed
406        if ( current_volume - self.last_volume ).abs() > 0.001 {
407            let _ = self.broadcast_tx.send( StateUpdate::VolumeChanged {
408                level: current_volume,
409            });
410            self.last_volume = current_volume;
411        }
412
413        // Shuffle/repeat changed
414        if current_shuffle != self.last_shuffle || current_repeat != self.last_repeat {
415            let _ = self.broadcast_tx.send( StateUpdate::ModeChanged {
416                shuffle: current_shuffle,
417                repeat_mode: repeat_mode_to_value( current_repeat ),
418            });
419            self.last_shuffle = current_shuffle;
420            self.last_repeat = current_repeat;
421        }
422
423        // Position update (throttled to ~2Hz)
424        let now = Instant::now();
425        if current_state == PlaybackState::Playing
426            && now.duration_since( self.last_position_broadcast ) >= Duration::from_millis( 500 )
427        {
428            let position = self.player.position();
429            let _ = self.broadcast_tx.send( StateUpdate::Position {
430                secs: position.as_secs_f64(),
431            });
432            self.last_position_broadcast = now;
433        }
434
435        // Visualizer data (throttled to ~10Hz)
436        if current_state == PlaybackState::Playing
437            && now.duration_since( self.last_vis_broadcast ) >= Duration::from_millis( 100 )
438        {
439            if let Some( vis ) = self.player.vis_data() {
440                let _ = self.broadcast_tx.send( StateUpdate::VisualizerData {
441                    bars: vis.to_vec(),
442                });
443                self.last_vis_broadcast = now;
444            }
445        }
446    }
447
448
449    /// Handles a single command from any frontend.
450    fn handle_command( &mut self, cmd: AppCommand ) {
451        match cmd {
452            // Playback
453            AppCommand::Play => {
454                self.play_selected();
455            }
456            AppCommand::Pause => {
457                let _ = self.player.pause();
458            }
459            AppCommand::Resume => {
460                let _ = self.player.resume();
461            }
462            AppCommand::TogglePlayback => {
463                match self.player.state() {
464                    PlaybackState::Playing => { let _ = self.player.pause(); }
465                    PlaybackState::Paused => { let _ = self.player.resume(); }
466                    PlaybackState::Stopped => { self.play_selected(); }
467                }
468            }
469            AppCommand::Stop => {
470                let _ = self.player.stop();
471            }
472            AppCommand::Next => {
473                self.play_next();
474            }
475            AppCommand::Previous => {
476                self.play_previous();
477            }
478            AppCommand::Seek { position_secs } => {
479                let _ = self.player.seek( Duration::from_secs_f64( position_secs ) );
480            }
481            AppCommand::SetVolume { level } => {
482                self.player.set_volume( level.clamp( 0.0, 1.5 ) );
483            }
484            AppCommand::VolumeUp => {
485                let vol = ( self.player.volume() + 0.05 ).min( 1.5 );
486                self.player.set_volume( vol );
487            }
488            AppCommand::VolumeDown => {
489                let vol = ( self.player.volume() - 0.05 ).max( 0.0 );
490                self.player.set_volume( vol );
491            }
492
493            // Playlist
494            AppCommand::PlayTrack { index } => {
495                let track = {
496                    let playlist_arc = self.player.playlist();
497                    let mut playlist = playlist_arc.write().unwrap();
498                    playlist.jump_to( index ).cloned()
499                };
500                if let Some( path ) = track {
501                    let _ = self.player.play( path );
502                }
503                self.broadcast_playlist();
504            }
505            AppCommand::AddPath { path } => {
506                let path = PathBuf::from( &path );
507                if path.is_dir() {
508                    let mut scanner = LibraryScanner::new();
509                    scanner.add_root( path );
510                    if let Ok( tracks ) = scanner.scan() {
511                        let playlist_arc = self.player.playlist();
512                        let mut playlist = playlist_arc.write().unwrap();
513                        playlist.add_many( tracks.into_iter().map( |t| t.path ) );
514                    }
515                } else {
516                    let playlist_arc = self.player.playlist();
517                    let mut playlist = playlist_arc.write().unwrap();
518                    playlist.add( path );
519                }
520                self.broadcast_playlist();
521            }
522            AppCommand::RemoveTrack { index } => {
523                let playlist_arc = self.player.playlist();
524                let mut playlist = playlist_arc.write().unwrap();
525                playlist.remove( index );
526                drop( playlist );
527                self.broadcast_playlist();
528            }
529            AppCommand::ClearPlaylist => {
530                let _ = self.player.stop();
531                let playlist_arc = self.player.playlist();
532                let mut playlist = playlist_arc.write().unwrap();
533                playlist.clear();
534                drop( playlist );
535                self.broadcast_playlist();
536            }
537            AppCommand::ToggleShuffle => {
538                let playlist_arc = self.player.playlist();
539                let mut playlist = playlist_arc.write().unwrap();
540                let new_val = !playlist.shuffle();
541                playlist.set_shuffle( new_val );
542            }
543            AppCommand::SetRepeat { mode } => {
544                let repeat = match mode {
545                    RepeatModeValue::Off => RepeatMode::Off,
546                    RepeatModeValue::One => RepeatMode::One,
547                    RepeatModeValue::All => RepeatMode::All,
548                };
549                let playlist_arc = self.player.playlist();
550                let mut playlist = playlist_arc.write().unwrap();
551                playlist.set_repeat( repeat );
552            }
553            AppCommand::CycleRepeat => {
554                let playlist_arc = self.player.playlist();
555                let mut playlist = playlist_arc.write().unwrap();
556                let next = match playlist.repeat() {
557                    RepeatMode::Off => RepeatMode::One,
558                    RepeatMode::One => RepeatMode::All,
559                    RepeatMode::All => RepeatMode::Off,
560                };
561                playlist.set_repeat( next );
562            }
563            AppCommand::MoveTrack { from, to } => {
564                let playlist_arc = self.player.playlist();
565                let mut playlist = playlist_arc.write().unwrap();
566                playlist.move_track( from, to );
567                drop( playlist );
568                self.broadcast_playlist();
569            }
570            AppCommand::Dedup => {
571                let playlist_arc = self.player.playlist();
572                let mut playlist = playlist_arc.write().unwrap();
573                let removed = playlist.dedup();
574                drop( playlist );
575                if removed > 0 {
576                    self.broadcast_playlist();
577                    let _ = self.broadcast_tx.send( StateUpdate::StatusMessage {
578                        message: format!( "Removed {} duplicate(s)", removed ),
579                    });
580                }
581            }
582            AppCommand::SavePlaylist { name } => {
583                if let Some( dir ) = Playlist::ensure_playlist_dir() {
584                    let path = dir.join( format!( "{}.m3u", name ) );
585                    let playlist_arc = self.player.playlist();
586                    let playlist = playlist_arc.read().unwrap();
587                    match playlist.save( &path ) {
588                        Ok(()) => {
589                            let _ = self.broadcast_tx.send( StateUpdate::StatusMessage {
590                                message: format!( "Saved playlist: {}", name ),
591                            });
592                        }
593                        Err( e ) => {
594                            let _ = self.broadcast_tx.send( StateUpdate::StatusMessage {
595                                message: format!( "Save error: {}", e ),
596                            });
597                        }
598                    }
599                }
600            }
601            AppCommand::LoadPlaylist { name } => {
602                if let Some( dir ) = Playlist::playlist_dir() {
603                    let path = dir.join( format!( "{}.m3u", name ) );
604                    match Playlist::load( &path ) {
605                        Ok( loaded ) => {
606                            let _ = self.player.stop();
607                            let playlist_arc = self.player.playlist();
608                            let mut playlist = playlist_arc.write().unwrap();
609                            *playlist = loaded;
610                            drop( playlist );
611                            self.broadcast_playlist();
612                            let _ = self.broadcast_tx.send( StateUpdate::StatusMessage {
613                                message: format!( "Loaded playlist: {}", name ),
614                            });
615                        }
616                        Err( e ) => {
617                            let _ = self.broadcast_tx.send( StateUpdate::StatusMessage {
618                                message: format!( "Load error: {}", e ),
619                            });
620                        }
621                    }
622                }
623            }
624
625            // Browser
626            AppCommand::BrowseTo { path } => {
627                self.browser.navigate_to( &PathBuf::from( &path ) );
628                let _ = self.broadcast_tx.send( StateUpdate::BrowserChanged {
629                    browser: self.browser.to_snapshot(),
630                });
631            }
632            AppCommand::BrowseUp => {
633                if let Some( parent ) = self.browser.current_dir.parent() {
634                    let parent = parent.to_path_buf();
635                    self.browser.navigate_to( &parent );
636                    let _ = self.broadcast_tx.send( StateUpdate::BrowserChanged {
637                        browser: self.browser.to_snapshot(),
638                    });
639                }
640            }
641            AppCommand::BrowseHome => {
642                if let Some( home ) = dirs::home_dir() {
643                    self.browser.navigate_to( &home );
644                    let _ = self.broadcast_tx.send( StateUpdate::BrowserChanged {
645                        browser: self.browser.to_snapshot(),
646                    });
647                }
648            }
649            AppCommand::BrowseOpen { index } => {
650                if let Some( entry ) = self.browser.entries.get( index ).cloned() {
651                    if entry.is_dir {
652                        self.browser.navigate_to( &entry.path );
653                        let _ = self.broadcast_tx.send( StateUpdate::BrowserChanged {
654                            browser: self.browser.to_snapshot(),
655                        });
656                    }
657                }
658            }
659            AppCommand::BrowseAddToPlaylist { index } => {
660                if let Some( entry ) = self.browser.entries.get( index ).cloned() {
661                    let path_str = entry.path.to_string_lossy().to_string();
662                    self.handle_command( AppCommand::AddPath { path: path_str } );
663                }
664            }
665
666            // Playlist management
667            AppCommand::ListPlaylists => {
668                if let Some( dir ) = Playlist::playlist_dir() {
669                    let mut names = Vec::new();
670                    if let Ok( entries ) = std::fs::read_dir( &dir ) {
671                        for entry in entries.flatten() {
672                            let path = entry.path();
673                            if path.extension().and_then( |e| e.to_str() ) == Some( "m3u" ) {
674                                if let Some( name ) = path.file_stem().and_then( |s| s.to_str() ) {
675                                    if name != "_last" {
676                                        names.push( name.to_string() );
677                                    }
678                                }
679                            }
680                        }
681                    }
682                    names.sort();
683                    let msg = if names.is_empty() {
684                        "No saved playlists".to_string()
685                    } else {
686                        format!( "Playlists: {}", names.join( ", " ) )
687                    };
688                    let _ = self.broadcast_tx.send( StateUpdate::StatusMessage { message: msg } );
689                }
690            }
691            AppCommand::DeletePlaylist { name } => {
692                if let Some( dir ) = Playlist::playlist_dir() {
693                    let path = dir.join( format!( "{}.m3u", name ) );
694                    if path.exists() {
695                        match std::fs::remove_file( &path ) {
696                            Ok(()) => {
697                                let _ = self.broadcast_tx.send( StateUpdate::StatusMessage {
698                                    message: format!( "Deleted playlist: {}", name ),
699                                });
700                            }
701                            Err( e ) => {
702                                let _ = self.broadcast_tx.send( StateUpdate::StatusMessage {
703                                    message: format!( "Delete error: {}", e ),
704                                });
705                            }
706                        }
707                    } else {
708                        let _ = self.broadcast_tx.send( StateUpdate::StatusMessage {
709                            message: format!( "Playlist not found: {}", name ),
710                        });
711                    }
712                }
713            }
714
715            // Settings
716            AppCommand::ToggleSetting { key } => {
717                match key.as_str() {
718                    "discord_enabled" => {
719                        self.settings.discord_enabled = !self.settings.discord_enabled;
720                    }
721                    "smtc_enabled" => {
722                        self.settings.smtc_enabled = !self.settings.smtc_enabled;
723                    }
724                    "web_enabled" => {
725                        self.settings.web_enabled = !self.settings.web_enabled;
726                    }
727                    _ => {
728                        tracing::warn!( "Unknown setting key: {}", key );
729                        return;
730                    }
731                }
732                self.settings.save();
733                let _ = self.broadcast_tx.send( StateUpdate::SettingsChanged {
734                    settings: self.settings.to_snapshot(),
735                });
736            }
737
738            // View mode
739            AppCommand::SetView { view } => {
740                self.view_mode = view;
741            }
742
743            // Full state request
744            AppCommand::RequestFullState => {
745                let snapshot = self.build_full_snapshot();
746                let _ = self.broadcast_tx.send( StateUpdate::FullState { state: snapshot } );
747            }
748
749            // Quit is handled in run() before reaching here
750            AppCommand::Quit => {}
751        }
752    }
753
754
755    /// Plays the track at the current playlist index.
756    fn play_selected( &self ) {
757        let track = {
758            let playlist_arc = self.player.playlist();
759            let playlist = playlist_arc.read().unwrap();
760            playlist.current().cloned()
761                .or_else( || {
762                    if !playlist.is_empty() {
763                        Some( playlist.tracks()[ 0 ].clone() )
764                    } else {
765                        None
766                    }
767                })
768        };
769
770        if let Some( path ) = track {
771            {
772                let playlist_arc = self.player.playlist();
773                let mut playlist = playlist_arc.write().unwrap();
774                if playlist.current_index().is_none() && !playlist.is_empty() {
775                    playlist.jump_to( 0 );
776                }
777            }
778            let _ = self.player.play( path );
779        }
780    }
781
782
783    /// Advances to the next track.
784    fn play_next( &self ) {
785        match self.player.play_next() {
786            Ok( true ) => {
787                self.broadcast_playlist();
788            }
789            Ok( false ) => {
790                let _ = self.broadcast_tx.send( StateUpdate::StatusMessage {
791                    message: "End of playlist".to_string(),
792                });
793            }
794            Err( e ) => {
795                let _ = self.broadcast_tx.send( StateUpdate::StatusMessage {
796                    message: format!( "Next track error: {}", e ),
797                });
798            }
799        }
800    }
801
802
803    /// Goes to the previous track.
804    fn play_previous( &self ) {
805        match self.player.play_previous() {
806            Ok( true ) => {
807                self.broadcast_playlist();
808            }
809            Ok( false ) => {}
810            Err( e ) => {
811                let _ = self.broadcast_tx.send( StateUpdate::StatusMessage {
812                    message: format!( "Previous track error: {}", e ),
813                });
814            }
815        }
816    }
817
818
819    /// Broadcasts current playlist state.
820    fn broadcast_playlist( &self ) {
821        let playlist_arc = self.player.playlist();
822        let playlist = playlist_arc.read().unwrap();
823        let entries = build_track_entries( &playlist );
824        let index = playlist.current_index();
825        drop( playlist );
826
827        let _ = self.broadcast_tx.send( StateUpdate::PlaylistChanged {
828            playlist: entries,
829            index,
830        });
831    }
832
833
834    /// Builds a full state snapshot.
835    fn build_full_snapshot( &self ) -> StateSnapshot {
836        let playlist_arc = self.player.playlist();
837        let playlist = playlist_arc.read().unwrap();
838        let entries = build_track_entries( &playlist );
839        let playlist_index = playlist.current_index();
840        let shuffle = playlist.shuffle();
841        let repeat = playlist.repeat();
842        drop( playlist );
843
844        let current_track = self.player.current_track().map( |path| {
845            self.build_track_info( &path )
846        });
847
848        StateSnapshot {
849            playback_state: playback_state_to_value( self.player.state() ),
850            current_track,
851            position_secs: self.player.position().as_secs_f64(),
852            duration_secs: self.player.duration().map( |d| d.as_secs_f64() ),
853            volume: self.player.volume(),
854            playlist: entries,
855            playlist_index,
856            shuffle,
857            repeat_mode: repeat_mode_to_value( repeat ),
858            view_mode: self.view_mode.clone(),
859            visualizer_data: self.player.vis_data().map( |v| v.to_vec() ),
860            browser: Some( self.browser.to_snapshot() ),
861            settings: self.settings.to_snapshot(),
862        }
863    }
864
865
866    /// Builds track info from a path + player metadata.
867    fn build_track_info( &self, path: &PathBuf ) -> TrackInfo {
868        let metadata = self.player.metadata();
869        let duration = self.player.duration();
870
871        // Find and cache cover art for web UI
872        let cover_art = find_cover_art( path );
873        let has_cover_art = cover_art.is_some();
874        if let Ok( mut guard ) = self.cover_art_path.lock() {
875            *guard = cover_art;
876        }
877
878        TrackInfo {
879            path: path.to_string_lossy().to_string(),
880            title: metadata.as_ref().and_then( |m| m.title.clone() ),
881            artist: metadata.as_ref().and_then( |m| m.artist.clone() ),
882            album: metadata.as_ref().and_then( |m| m.album.clone() ),
883            album_artist: metadata.as_ref().and_then( |m| m.album_artist.clone() ),
884            track_number: metadata.as_ref().and_then( |m| m.track_number ),
885            genre: metadata.as_ref().and_then( |m| m.genre.clone() ),
886            year: metadata.as_ref().and_then( |m| m.year ),
887            codec: metadata.as_ref().and_then( |m| m.codec.clone() ),
888            bitrate: metadata.as_ref().and_then( |m| m.bitrate ),
889            sample_rate: metadata.as_ref().and_then( |m| m.sample_rate ),
890            channels: metadata.as_ref().and_then( |m| m.channels ),
891            duration_secs: duration.map( |d| d.as_secs_f64() ),
892            has_cover_art,
893        }
894    }
895
896
897    /// Saves the current session state.
898    fn save_session( &self ) {
899        let playlist_arc = self.player.playlist();
900        let playlist = playlist_arc.read().unwrap();
901
902        if playlist.is_empty() {
903            return;
904        }
905
906        if let Some( dir ) = Playlist::ensure_playlist_dir() {
907            let path = dir.join( "_last.m3u" );
908            let _ = playlist.save( &path );
909
910            let session = oxidio_core::SessionState {
911                playlist_name: "_last".to_string(),
912                track_index: playlist.current_index(),
913                shuffle: playlist.shuffle(),
914                repeat: playlist.repeat(),
915                volume: self.player.volume(),
916            };
917            let _ = Playlist::save_session( &session );
918        }
919    }
920
921
922    /// Gets a reference to the current settings.
923    pub fn settings( &self ) -> &ProcessorSettings {
924        &self.settings
925    }
926}
927
928
929/// Searches for album art image files in the same directory as the track.
930///
931/// Looks for common cover art filenames (cover, folder, album, front, etc.)
932/// with image extensions (jpg, jpeg, png, bmp, gif). Prioritizes exact name
933/// matches over generic image files.
934///
935/// @param track_path - Path to the audio file
936///
937/// @returns Path to the cover art file, or None if not found
938fn find_cover_art( track_path: &Path ) -> Option<PathBuf> {
939    let parent = track_path.parent()?;
940
941    let art_names = [
942        "cover", "folder", "album", "front", "art", "albumart", "album_art",
943    ];
944    let extensions = [ "jpg", "jpeg", "png", "bmp", "gif" ];
945
946    let mut found_path: Option<PathBuf> = None;
947
948    match std::fs::read_dir( parent ) {
949        Ok( entries ) => {
950            for entry in entries.flatten() {
951                let path = entry.path();
952                let filename = path.file_stem()
953                    .and_then( |s| s.to_str() )
954                    .map( |s| s.to_lowercase() );
955                let ext = path.extension()
956                    .and_then( |e| e.to_str() )
957                    .map( |e| e.to_lowercase() );
958
959                if let ( Some( name ), Some( ext ) ) = ( filename, ext ) {
960                    if extensions.contains( &ext.as_str() ) {
961                        if art_names.contains( &name.as_str() ) {
962                            found_path = Some( path );
963                            break;
964                        }
965                        if found_path.is_none() {
966                            found_path = Some( path );
967                        }
968                    }
969                }
970            }
971        }
972        Err( e ) => {
973            tracing::warn!( "Failed to read directory {:?}: {}", parent, e );
974        }
975    }
976
977    found_path
978}
979
980
981/// Helper: build track entries from a playlist.
982fn build_track_entries( playlist: &Playlist ) -> Vec<TrackEntry> {
983    playlist.tracks().iter().enumerate().map( |( i, path )| {
984        let display_name = path.file_stem()
985            .map( |s| s.to_string_lossy().to_string() )
986            .unwrap_or_else( || path.to_string_lossy().to_string() );
987        TrackEntry {
988            index: i,
989            path: path.to_string_lossy().to_string(),
990            display_name,
991        }
992    }).collect()
993}
994
995
996/// Convert core PlaybackState to protocol value.
997fn playback_state_to_value( state: PlaybackState ) -> PlaybackStateValue {
998    match state {
999        PlaybackState::Stopped => PlaybackStateValue::Stopped,
1000        PlaybackState::Playing => PlaybackStateValue::Playing,
1001        PlaybackState::Paused => PlaybackStateValue::Paused,
1002    }
1003}
1004
1005
1006/// Convert core RepeatMode to protocol value.
1007fn repeat_mode_to_value( mode: RepeatMode ) -> RepeatModeValue {
1008    match mode {
1009        RepeatMode::Off => RepeatModeValue::Off,
1010        RepeatMode::One => RepeatModeValue::One,
1011        RepeatMode::All => RepeatModeValue::All,
1012    }
1013}