melors 0.2.2

Keyboard-first terminal MP3 player with queue, search, and tag editing
use super::*;

impl App {
    pub fn persist_playback_state(&mut self) -> Result<()> {
        self.playback_state_dirty = true;
        self.flush_playback_state(false)
    }

    pub fn persist_playback_state_now(&mut self) -> Result<()> {
        self.playback_state_dirty = true;
        self.flush_playback_state(true)
    }

    pub fn play_track(&mut self, track_id: i64) -> Result<()> {
        self.ensure_track_in_queue(track_id)?;
        self.play_track_from_position(track_id, 0, true)?;
        Ok(())
    }

    pub fn next_track(&mut self) -> Result<()> {
        let current_idx = self.current_queue_index().unwrap_or(usize::MAX);

        let Some(next_idx) = compute_next_track_index(
            self.session.queue.len(),
            current_idx,
            self.session.playback_state.repeat_mode,
        ) else {
            return Ok(());
        };

        let id = self.session.queue[next_idx];
        self.play_track(id)
    }

    pub fn prev_track(&mut self) -> Result<()> {
        let current_idx = self.current_queue_index().unwrap_or(0);

        let Some(prev_idx) = compute_prev_track_index(self.session.queue.len(), current_idx) else {
            return Ok(());
        };

        let id = self.session.queue[prev_idx];
        self.play_track(id)
    }

    pub fn seek(&mut self, delta_secs: i64) -> Result<()> {
        let next = self.player.seek_relative(delta_secs)?;
        self.session.playback_state.position_secs = next;
        self.persist_playback_state()?;
        Ok(())
    }

    pub fn toggle_play_pause(&mut self) -> Result<bool> {
        if self.player.has_active_sink() {
            let paused = self.player.toggle_pause();
            return Ok(paused);
        }

        if let Some(track_id) = self.session.playback_state.current_track_id {
            let start_at = self.session.playback_state.position_secs;
            self.play_track_from_position(track_id, start_at, false)?;
            return Ok(false);
        }

        if let Some(first_track) = self.session.tracks.first() {
            self.play_track(first_track.id)?;
            return Ok(false);
        }

        Ok(true)
    }

    pub fn refresh_playback_position(&mut self) -> Result<()> {
        self.player.poll_analysis_results();

        if self.player.consume_track_finished() {
            let previous_track = self.session.playback_state.current_track_id;
            self.next_track()?;

            if self.session.playback_state.current_track_id == previous_track
                && !self.player.has_active_sink()
            {
                self.session.playback_state.current_track_id = None;
                self.session.playback_state.position_secs = 0;
                self.persist_playback_state()?;
            }
        }

        self.session.playback_state.position_secs = self.player.current_position_secs();
        self.flush_playback_state(false)?;
        Ok(())
    }

    pub fn toggle_repeat(&mut self) -> Result<RepeatMode> {
        self.session.playback_state.repeat_mode = self.session.playback_state.repeat_mode.cycle();
        self.persist_playback_state()?;
        Ok(self.session.playback_state.repeat_mode)
    }

    pub fn adjust_volume(&mut self, delta: f32) -> u8 {
        self.player.adjust_volume(delta)
    }

    pub fn volume_percent(&self) -> u8 {
        self.player.volume_percent()
    }

    pub fn is_actively_playing(&self) -> bool {
        self.player.has_active_sink() && !self.player.is_paused()
    }

    pub fn visualizer_levels(&self, bars: usize) -> Vec<(f32, f32)> {
        self.player.visualizer_levels(bars)
    }

    pub(super) fn flush_playback_state(&mut self, force: bool) -> Result<()> {
        if !self.playback_state_dirty {
            return Ok(());
        }

        let unchanged = self
            .last_persisted_playback_state
            .as_ref()
            .is_some_and(|last| *last == self.session.playback_state);
        if unchanged {
            self.playback_state_dirty = false;
            return Ok(());
        }

        if !force
            && self
                .last_persisted_at
                .is_some_and(|at| at.elapsed() < PLAYBACK_PERSIST_DEBOUNCE)
        {
            return Ok(());
        }

        self.storage
            .save_playback_state(&self.session.playback_state)?;
        self.last_persisted_playback_state = Some(self.session.playback_state.clone());
        self.last_persisted_at = Some(std::time::Instant::now());
        self.playback_state_dirty = false;
        Ok(())
    }

    fn current_queue_index(&self) -> Option<usize> {
        self.session.playback_state.current_track_id.and_then(|id| {
            self.session
                .queue
                .iter()
                .position(|queue_id| *queue_id == id)
        })
    }

    fn play_track_from_position(
        &mut self,
        track_id: i64,
        start_at: i64,
        increment_play_count: bool,
    ) -> Result<()> {
        let Some((path, mtime)) = self
            .track_by_id(track_id)
            .map(|track| (track.path.clone(), track.mtime))
        else {
            return Ok(());
        };

        self.player.play_file(&path, mtime, start_at)?;
        self.session.playback_state.current_track_id = Some(track_id);
        self.session.playback_state.position_secs = start_at;
        if increment_play_count {
            self.storage.increment_play_count(track_id)?;
        }
        self.persist_playback_state()?;
        Ok(())
    }
}

fn compute_next_track_index(
    queue_len: usize,
    current_idx: usize,
    repeat_mode: RepeatMode,
) -> Option<usize> {
    if queue_len == 0 {
        return None;
    }
    if current_idx == usize::MAX {
        return Some(0);
    }
    if current_idx + 1 < queue_len {
        return Some(current_idx + 1);
    }
    match repeat_mode {
        RepeatMode::RepeatAll => Some(0),
        RepeatMode::RepeatOne => Some(current_idx),
        RepeatMode::Off => None,
    }
}

fn compute_prev_track_index(queue_len: usize, current_idx: usize) -> Option<usize> {
    if queue_len == 0 {
        return None;
    }
    if current_idx == 0 {
        Some(0)
    } else {
        Some(current_idx - 1)
    }
}

#[cfg(test)]
mod tests {
    use super::{compute_next_track_index, compute_prev_track_index};
    use crate::core::model::RepeatMode;

    #[test]
    fn next_track_index_respects_repeat_modes() {
        assert_eq!(
            compute_next_track_index(3, 2, RepeatMode::Off),
            None,
            "no wrap when repeat is off"
        );
        assert_eq!(
            compute_next_track_index(3, 2, RepeatMode::RepeatAll),
            Some(0),
            "wrap when repeat all"
        );
        assert_eq!(
            compute_next_track_index(3, 2, RepeatMode::RepeatOne),
            Some(2),
            "stay on same when repeat one"
        );
    }

    #[test]
    fn prev_track_index_clamps_to_zero() {
        assert_eq!(compute_prev_track_index(3, 0), Some(0));
        assert_eq!(compute_prev_track_index(3, 2), Some(1));
        assert_eq!(compute_prev_track_index(0, 0), None);
    }
}