diff --git a/src/play.rs b/src/play.rs index 64ffed5..41306eb 100644 --- a/src/play.rs +++ b/src/play.rs @@ -65,6 +65,9 @@ pub async fn play(args: Args) -> eyre::Result<(), player::Error> { .await .map_err(player::Error::PersistentVolumeSave)?; + // Save the bookmarks for the next session. + player.bookmarks.save().await?; + drop(stream); player.sink.stop(); ui.map(|x| x.abort()); diff --git a/src/player.rs b/src/player.rs index 9f8336a..cfa2feb 100644 --- a/src/player.rs +++ b/src/player.rs @@ -2,14 +2,7 @@ //! This also has the code for the underlying //! audio server which adds new tracks. -use std::{ - collections::VecDeque, - sync::{ - atomic::{AtomicBool, Ordering}, - Arc, - }, - time::Duration, -}; +use std::{collections::VecDeque, sync::Arc, time::Duration}; use arc_swap::ArcSwapOption; use downloader::Downloader; @@ -29,7 +22,7 @@ use mpris_server::{PlaybackStatus, PlayerInterface, Property}; use crate::{ messages::Message, - player::{self, persistent_volume::PersistentVolume}, + player::{self, bookmark::Bookmarks, persistent_volume::PersistentVolume}, tracks::{self, list::List}, Args, }; @@ -64,9 +57,6 @@ pub struct Player { /// The internal buffer size. pub buffer_size: usize, - /// Whether the current track has been bookmarked. - bookmarked: AtomicBool, - /// The [`TrackInfo`] of the current track. /// This is [`None`] when lowfi is buffering/loading. current: ArcSwapOption, @@ -77,6 +67,9 @@ pub struct Player { /// This is populated specifically by the [Downloader]. tracks: RwLock>, + /// The bookmarks, which are saved on quit. + pub bookmarks: Bookmarks, + /// The actual list of tracks to be played. list: List, @@ -108,6 +101,9 @@ impl Player { /// /// This also will load the track list & persistent volume. pub async fn new(args: &Args) -> eyre::Result<(Self, OutputStream), player::Error> { + // Load the bookmarks. + let bookmarks = Bookmarks::load().await?; + // Load the volume file. let volume = PersistentVolume::load() .await @@ -146,11 +142,11 @@ impl Player { tracks: RwLock::new(VecDeque::with_capacity(args.buffer_size)), buffer_size: args.buffer_size, current: ArcSwapOption::new(None), + bookmarks, client, sink, volume, list, - bookmarked: AtomicBool::new(false), }; Ok((player, stream)) @@ -223,7 +219,7 @@ impl Player { match msg { Message::Next | Message::Init | Message::TryAgain => { - player.bookmarked.swap(false, Ordering::Relaxed); + player.bookmarks.set_bookmarked(false); // We manually skipped, so we shouldn't actually wait for the song // to be over until we recieve the `NewSong` signal. @@ -297,18 +293,17 @@ impl Player { let current = player.current.load(); let current = current.as_ref().unwrap(); - let bookmarked = bookmark::bookmark( - current.full_path.clone(), - if current.custom_name { - Some(current.display_name.clone()) - } else { - None - }, - ) - .await - .map_err(player::Error::Bookmark)?; - - player.bookmarked.swap(bookmarked, Ordering::Relaxed); + player + .bookmarks + .bookmark( + current.full_path.clone(), + if current.custom_name { + Some(current.display_name.clone()) + } else { + None + }, + ) + .await?; } Message::Quit => break, } diff --git a/src/player/bookmark.rs b/src/player/bookmark.rs index 1825dcd..c3012ee 100644 --- a/src/player/bookmark.rs +++ b/src/player/bookmark.rs @@ -1,50 +1,108 @@ use std::io::SeekFrom; +use std::sync::atomic::AtomicBool; -use tokio::fs::{create_dir_all, OpenOptions}; -use tokio::io::{AsyncReadExt, AsyncSeekExt, AsyncWriteExt}; +use tokio::fs::{create_dir_all, File, OpenOptions}; +use tokio::io::{self, AsyncReadExt, AsyncSeekExt, AsyncWriteExt}; +use tokio::sync::RwLock; use crate::data_dir; -/// Bookmarks a given track with a full path and optional custom name. -/// -/// Returns whether the track is now bookmarked, or not. -pub async fn bookmark(path: String, custom: Option) -> eyre::Result { - let mut entry = path.to_string(); - if let Some(custom) = custom { - entry.push('!'); - entry.push_str(&custom); - } +#[derive(Debug, thiserror::Error)] +pub enum BookmarkError { + #[error("data directory not found")] + DataDir, - let data_dir = data_dir()?; - create_dir_all(data_dir.clone()).await?; - - // TODO: Only open and close the file at startup and shutdown, not every single bookmark. - // TODO: Sort of like PersistentVolume, but for bookmarks. - let mut file = OpenOptions::new() - .create(true) - .write(true) - .read(true) - .append(false) - .truncate(true) - .open(data_dir.join("bookmarks.txt")) - .await?; - - let mut text = String::new(); - file.read_to_string(&mut text).await?; - - let mut lines: Vec<&str> = text.trim().lines().filter(|x| !x.is_empty()).collect(); - let idx = lines.iter().position(|x| **x == entry); - - if let Some(idx) = idx { - lines.remove(idx); - } else { - lines.push(&entry); - } - - let text = format!("\n{}", lines.join("\n")); - file.seek(SeekFrom::Start(0)).await?; - file.set_len(0).await?; - file.write_all(text.as_bytes()).await?; - - Ok(idx.is_none()) + #[error("io failure")] + Io(#[from] io::Error), +} + +/// Manages the bookmarks in the current player. +pub struct Bookmarks { + entries: RwLock>, + file: RwLock, + bookmarked: AtomicBool, +} + +impl Bookmarks { + pub async fn load() -> eyre::Result { + let data_dir = data_dir().map_err(|_| BookmarkError::DataDir)?; + create_dir_all(data_dir.clone()).await?; + + let mut file = OpenOptions::new() + .create(true) + .write(true) + .read(true) + .append(false) + .truncate(false) + .open(data_dir.join("bookmarks.txt")) + .await?; + + let mut text = String::new(); + file.read_to_string(&mut text).await?; + + let lines: Vec = text + .trim() + .lines() + .filter_map(|x| { + if !x.is_empty() { + Some(x.to_string()) + } else { + None + } + }) + .collect(); + + Ok(Self { + entries: RwLock::new(lines), + file: RwLock::new(file), + bookmarked: AtomicBool::new(false), + }) + } + + pub async fn save(&self) -> eyre::Result<(), BookmarkError> { + let text = format!("\n{}", self.entries.read().await.join("\n")); + + let mut lock = self.file.write().await; + lock.seek(SeekFrom::Start(0)).await?; + lock.set_len(0).await?; + lock.write_all(text.as_bytes()).await?; + lock.flush().await?; + + Ok(()) + } + + /// Bookmarks a given track with a full path and optional custom name. + /// + /// Returns whether the track is now bookmarked, or not. + pub async fn bookmark( + &self, + mut entry: String, + custom: Option, + ) -> eyre::Result<(), BookmarkError> { + if let Some(custom) = custom { + entry.push('!'); + entry.push_str(&custom); + } + + let idx = self.entries.read().await.iter().position(|x| **x == entry); + + if let Some(idx) = idx { + self.entries.write().await.remove(idx); + } else { + self.entries.write().await.push(entry); + }; + + self.set_bookmarked(idx.is_none()); + + Ok(()) + } + + pub fn bookmarked(&self) -> bool { + self.bookmarked.load(std::sync::atomic::Ordering::Relaxed) + } + + pub fn set_bookmarked(&self, val: bool) { + self.bookmarked + .swap(val, std::sync::atomic::Ordering::Relaxed); + } } diff --git a/src/player/error.rs b/src/player/error.rs index 77e752c..e417e2e 100644 --- a/src/player/error.rs +++ b/src/player/error.rs @@ -1,6 +1,6 @@ use std::ffi::NulError; -use crate::messages::Message; +use crate::{messages::Message, player::bookmark::BookmarkError}; use tokio::sync::mpsc::error::SendError; #[cfg(feature = "mpris")] @@ -43,9 +43,9 @@ pub enum Error { #[error("unable to notify downloader")] DownloaderNotify(#[from] SendError<()>), - #[error("unable to bookmark track")] - Bookmark(eyre::Error), - #[error("unable to find data directory")] DataDir, + + #[error("bookmarking load/unload failed")] + Bookmark(#[from] BookmarkError), } diff --git a/src/player/ui/components.rs b/src/player/ui/components.rs index 1dfd417..c927479 100644 --- a/src/player/ui/components.rs +++ b/src/player/ui/components.rs @@ -1,11 +1,7 @@ //! Various different individual components that //! appear in lowfi's UI, like the progress bar. -use std::{ - ops::Deref as _, - sync::{atomic::Ordering, Arc}, - time::Duration, -}; +use std::{ops::Deref as _, sync::Arc, time::Duration}; use crossterm::style::Stylize as _; use unicode_segmentation::UnicodeSegmentation as _; @@ -108,7 +104,7 @@ pub fn action(player: &Player, current: Option<&Arc>, width: usize) -> Str ActionBar::Playing(info) } }) - .format(player.bookmarked.load(Ordering::Relaxed)); + .format(player.bookmarks.bookmarked()); if len > width { let chopped: String = main.graphemes(true).take(width + 1).collect();