Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feature/lyrics #1123

Draft
wants to merge 7 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
571 changes: 340 additions & 231 deletions Cargo.lock

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,8 @@ toml = "0.8"
unicode-width = "0.1.13"
url = "2.5"
cursive_buffered_backend = "0.6.1"
cursive-async-view = "0.6.0"
urlencoding = "2.1.3"

[target.'cfg(unix)'.dependencies]
signal-hook = "0.3.0"
Expand Down
14 changes: 13 additions & 1 deletion src/application.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ use crate::library::Library;
use crate::queue::Queue;
use crate::spotify::{PlayerEvent, Spotify};
use crate::ui::create_cursive;
use crate::{authentication, ui, utils};
use crate::{authentication, ui, lyrics, lyrics_fetcher, utils};
use crate::{command, queue, spotify};

#[cfg(feature = "mpris")]
Expand Down Expand Up @@ -130,6 +130,15 @@ impl Application {
library.clone(),
));

println!("Building lyrics manager");

let lyrics_manager = Arc::new(lyrics::LyricsManager::new(
queue.clone(),
lyrics_fetcher::default_fetcher(configuration.clone()),
));

println!("Built lyrics manager");

#[cfg(feature = "mpris")]
let mpris_manager = mpris::MprisManager::new(
event_manager.clone(),
Expand Down Expand Up @@ -176,6 +185,8 @@ impl Application {

let queueview = ui::queue::QueueView::new(queue.clone(), library.clone());

let lyricsview = ui::lyrics::LyricsView::new(lyrics_manager.clone());

#[cfg(feature = "cover")]
let coverview = ui::cover::CoverView::new(queue.clone(), library.clone(), &configuration);

Expand All @@ -185,6 +196,7 @@ impl Application {
ui::layout::Layout::new(status, &event_manager, theme, Arc::clone(&configuration))
.screen("search", search.with_name("search"))
.screen("library", libraryview.with_name("library"))
.screen("lyrics", lyricsview.with_name("lyrics"))
.screen("queue", queueview);

#[cfg(feature = "cover")]
Expand Down
2 changes: 1 addition & 1 deletion src/command.rs
Original file line number Diff line number Diff line change
Expand Up @@ -433,7 +433,7 @@ pub fn parse(input: &str) -> Result<Vec<Command>, CommandParseError> {
"focus" => {
let &target = args.first().ok_or(E::InsufficientArgs {
cmd: command.into(),
hint: Some("queue|search|library".into()),
hint: Some("queue|search|library|lyrics".into()), // TODO: these names should come from a "central" registrar for views so that contributors don't need to always keep updating it
})?;
// TODO: this really should be strongly typed
Command::Focus(target.into())
Expand Down
1 change: 1 addition & 0 deletions src/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -473,6 +473,7 @@ impl CommandManager {
kb.insert("F1".into(), vec![Command::Focus("queue".into())]);
kb.insert("F2".into(), vec![Command::Focus("search".into())]);
kb.insert("F3".into(), vec![Command::Focus("library".into())]);
kb.insert("F4".into(), vec![Command::Focus("lyrics".into())]);
#[cfg(feature = "cover")]
kb.insert("F8".into(), vec![Command::Focus("cover".into())]);
kb.insert("?".into(), vec![Command::Help]);
Expand Down
78 changes: 78 additions & 0 deletions src/lyrics.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
use log::debug;
use std::{cell::RefCell, collections::HashMap, sync::Arc};

use crate::{
lyrics_fetcher::LyricsFetcher,
model::{playable::Playable, track::Track},
queue::Queue,
};

pub struct LyricsManager {
queue: Arc<Queue>,
fetcher: Box<dyn LyricsFetcher>,
cache: RefCell<HashMap<String, String>>,
}

impl LyricsManager {
pub fn new(queue: Arc<Queue>, fetcher: Box<dyn LyricsFetcher>) -> Self {
LyricsManager {
queue,
fetcher,
cache: RefCell::new(HashMap::new()),
}
}

/// Saves the given lyrics to the user's filesystem.
///
/// Returns an optional message indicating the outcome of this operation.
pub fn save_lyrics(&self, lyrics: String) -> Option<String> {
Some(lyrics)
}

/// Fetches and returns the lyrics of the given track
pub fn get_lyrics(&self, track: Track) -> String {
// TODO: see if this panics later on
let track_id = track.id.as_ref().unwrap();

{
// insert new scope so that we can perform both borrows from the RefCell
// the immutable borrow present in this scope is dropped,
// so it is safe to do another borrow after

let cache = self.cache.borrow();

if cache.contains_key(track_id) {
debug!("Retrieving cached lyrics for {}", track.title);
return cache.get(track_id).unwrap().into();
}
}

// if we reach this point it means that the cache does not contain this entry yet, update it
let mut cache = self.cache.borrow_mut();

// make network request to fetch track's lyrics
let lyrics = self.fetcher.fetch(&track);

cache.insert(track_id.to_owned(), lyrics.clone());

lyrics
}

/// Fetches and returns the lyrics of the currently playing track
pub fn get_lyrics_for_current(&self) -> String {
match self.get_current_track() {
None => String::from("No track currently playing: could not get lyrics"),
Some(track) => self.get_lyrics(track),
}
}

/// Returns the track being played currently, or nothing if the user is listening to a podcast episode
pub fn get_current_track(&self) -> Option<Track> {
let playable = self.queue.get_current().unwrap();

match playable {
Playable::Track(track) => Some(track),
_ => None,
}
}
}
57 changes: 57 additions & 0 deletions src/lyrics_fetcher.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
use std::sync::Arc;

use log::debug;

use crate::{config::Config, model::track::Track};
use urlencoding::encode;
pub trait LyricsFetcher {
fn fetch(&self, track: &Track) -> String;
}

pub struct OVHLyricsFetcher;

impl LyricsFetcher for OVHLyricsFetcher {
fn fetch(&self, track: &Track) -> String {
let track_title = track.title.clone();
let track_authors = track.artists.join(", ");

debug!("Fetching lyrics for {} by {}", track_title, track_authors);

let client = reqwest::blocking::Client::new();

let endpoint = reqwest::Url::parse(
format!(
"https://api.lyrics.ovh/v1/{}/{}",
encode(track.artists[0].as_str()).into_owned(),
encode(track_title.as_str()).into_owned()
)
.as_str(),
)
.unwrap();

// TODO: probably should not be blocking
let response = client.get(endpoint).send().unwrap();

if response.status() != 200 {
debug!(
"Error fetching lyrics for {}: {}",
track_title,
response.status()
);
return format!("Error fetching lyrics for {}", track_title);
}

// Do this since we do not have a specific body type to parse into
let text = response.text().unwrap();
let json: serde_json::Value = serde_json::from_str(&text).unwrap();

debug!("Received {:?}", json);

json["lyrics"].to_string()
}
}

/// Create a default lyrics fetcher.
pub fn default_fetcher(cfg: Arc<Config>) -> Box<dyn LyricsFetcher> {
Box::new(OVHLyricsFetcher {})
}
2 changes: 2 additions & 0 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ mod config;
mod events;
mod ext_traits;
mod library;
mod lyrics;
mod lyrics_fetcher;
mod model;
mod panic;
mod queue;
Expand Down
5 changes: 5 additions & 0 deletions src/traits.rs
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ pub trait ViewExt: View {
}

fn on_leave(&self) {}
fn on_enter(&mut self) {} // TODO: see if there are args that should go here

fn on_command(&mut self, _s: &mut Cursive, _cmd: &Command) -> Result<CommandResult, String> {
Ok(CommandResult::Ignored)
Expand All @@ -91,6 +92,10 @@ impl<V: ViewExt> ViewExt for NamedView<V> {
self.with_view(|v| v.on_leave());
}

fn on_enter(&mut self) {
self.with_view_mut(|v| v.on_enter());
}

fn on_command(&mut self, s: &mut Cursive, cmd: &Command) -> Result<CommandResult, String> {
self.with_view_mut(move |v| v.on_command(s, cmd)).unwrap()
}
Expand Down
14 changes: 13 additions & 1 deletion src/ui/layout.rs
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,11 @@ impl Layout {
self.focus = Some(s);
self.cmdline_focus = false;

self.screens
.get_mut(self.focus.as_ref().unwrap())
.unwrap()
.on_enter();

// trigger a redraw
self.ev.trigger();
}
Expand All @@ -173,12 +178,13 @@ impl Layout {
self.result.clone()
}

pub fn push_view(&mut self, view: Box<dyn ViewExt>) {
pub fn push_view(&mut self, mut view: Box<dyn ViewExt>) {
if let Some(view) = self.get_top_view() {
view.on_leave();
}

if let Some(stack) = self.get_focussed_stack_mut() {
view.on_enter();
stack.push(view)
}
}
Expand Down Expand Up @@ -495,4 +501,10 @@ impl ViewExt for Layout {
}
}
}

fn on_enter(&mut self) {
if let Some(view) = self.get_current_view_mut() {
view.on_enter()
}
}
}
128 changes: 128 additions & 0 deletions src/ui/lyrics.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
use std::sync::Arc;

use cursive::{
theme::Effect,
view::ViewWrapper,
views::{DummyView, LinearLayout, ResizedView, ScrollView, TextContent, TextView},
};

use crate::{command::Command, commands::CommandResult, lyrics::LyricsManager, traits::ViewExt};

pub struct LyricsView {
manager: Arc<LyricsManager>,
view: LinearLayout,
track_lyrics: TextContent,
track_title: TextContent,
track_authors: TextContent,
track_album: TextContent,
}

impl LyricsView {
pub fn new(manager: Arc<LyricsManager>) -> LyricsView {
// INITIALIZE THESE WITH "TRASHY" VALUE THAT IS GOING TO BE REPLACED AFTER
let track_title = TextContent::new("No track being played");
let track_authors = TextContent::new("No track being played");
let track_album = TextContent::new("No track being played");
let track_lyrics = TextContent::new("No track being played");

let lyrics_view =
ScrollView::new(TextView::new_with_content(track_lyrics.clone()).center());

let view = LinearLayout::vertical()
.child(ResizedView::with_full_width(
ResizedView::with_fixed_height(5, DummyView),
))
.child(
TextView::new_with_content(track_title.clone())
.center()
.style(Effect::Bold),
)
.child(TextView::new_with_content(track_authors.clone()).center())
.child(
TextView::new_with_content(track_album.clone())
.center()
.style(Effect::Italic),
)
.child(DummyView)
.child(lyrics_view);

let lyrics_view = LyricsView {
manager,
view,
track_lyrics,
track_album,
track_authors,
track_title,
};

lyrics_view.update_lyrics();

lyrics_view
}

fn update_lyrics(&self) {
// TODO: this should be done in a separate thread and the UI should be updated when the lyrics are fetched (or an error occurs)

let current_track = self.manager.get_current_track();

if let Some(track) = current_track {
let track_title_str = track.clone().title;

let track_authors_str = track.artists.join(", ");

let track_album_str = match track.clone().album {
None => String::default(),
Some(album_name) => album_name,
};

let track_lyrics_str = self.manager.get_lyrics(track);

self.track_title.set_content(track_title_str);
self.track_authors.set_content(track_authors_str);
self.track_album.set_content(track_album_str);
self.track_lyrics.set_content(track_lyrics_str);
}
}

/// Saves the lyrics of the current song
pub fn save_lyrics(&self) -> Result<CommandResult, String> {
let result = self
.manager
.save_lyrics(self.manager.get_lyrics_for_current());

Ok(CommandResult::Consumed(result))
}
}

impl ViewWrapper for LyricsView {
wrap_impl!(self.view: LinearLayout);
}

impl ViewExt for LyricsView {
fn title(&self) -> String {
"Lyrics".to_string()
}

fn title_sub(&self) -> String {
"".to_string()
}

fn on_enter(&mut self) {
self.update_lyrics();
}

fn on_command(
&mut self,
_s: &mut cursive::Cursive,
cmd: &Command,
) -> Result<CommandResult, String> {
match cmd {
Command::Save => self.save_lyrics(),
Command::Next | Command::Previous => {
// still does not work
Ok(CommandResult::Ignored) // return ignored so it is processed by the default command handler
}
_ => Ok(CommandResult::Ignored),
}
}
}
Loading