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

add support for synced lyrics #635

Merged
merged 6 commits into from
Dec 14, 2024
Merged
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
2 changes: 1 addition & 1 deletion .dir-locals.el
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,5 @@
;;; For more information see (info "(emacs) Directory Variables")

((rustic-mode . ((eglot-workspace-configuration
. (:rust-analyzer (:cargo (:features ["lyric-finder" "image" "notify"])
. (:rust-analyzer (:cargo (:features ["image" "notify"])
:check (:command "clippy")))))))
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ on:
env:
CARGO_TERM_COLOR: always
RUST_BACKTRACE: 1
RUST_FEATURES: "rodio-backend,lyric-finder,media-control,image,notify"
RUST_FEATURES: "rodio-backend,media-control,image,notify"

jobs:
rust-ci:
Expand Down
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
FROM rust as builder
WORKDIR app
COPY . .
RUN cargo build --release --bin spotify_player --no-default-features --features lyric-finder
RUN cargo build --release --bin spotify_player --no-default-features

FROM gcr.io/distroless/cc
# Create `./config` and `./cache` folders using WORKDIR commands.
Expand Down
129 changes: 58 additions & 71 deletions README.md

Large diffs are not rendered by default.

5 changes: 4 additions & 1 deletion docs/config.md
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,7 @@ To define application's component styles, the user can specify any of the below
- `selection`
- `secondary_row`
- `like`
- `lyrics_played`

A field in `component_style` is a struct with three **optional** fields: `fg` (foreground), `bg` (background) and `modifiers` (terminal effects):

Expand Down Expand Up @@ -261,7 +262,9 @@ current_playing = { fg = "Green", modifiers = ["Bold"] }
page_desc = { fg = "Cyan", modifiers = ["Bold"] }
playlist_desc = { fg = "BrightBlack", modifiers = ["Dim"] }
table_header = { fg = "Blue" }
selection = { modifiers = ["Bold", "Reversed"] }
secondary_row = {}
like = {]
lyrics_played = { modifiers = ["Dim"] }
```

## Keymaps
Expand Down
4 changes: 2 additions & 2 deletions examples/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,9 @@

![Search page example](https://user-images.githubusercontent.com/40011582/140253653-5b156a8f-538b-4e68-9d52-0a379477574f.png)

## Lyric page
## Lyrics page

![Lyric page example](https://user-images.githubusercontent.com/40011582/169437044-420cf0e2-5d75-4022-bd9f-34540f1fe230.png)
![Lyrics page example](https://user-images.githubusercontent.com/40011582/169437044-420cf0e2-5d75-4022-bd9f-34540f1fe230.png)

## Command help popup

Expand Down
3 changes: 1 addition & 2 deletions spotify_player/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ librespot-connect = { version = "0.6.0", optional = true }
librespot-core = "0.6.0"
librespot-oauth = "0.6.0"
librespot-playback = { version = "0.6.0", optional = true }
librespot-metadata = "0.6.0"
log = "0.4.22"
chrono = "0.4.38"
reqwest = { version = "0.12.9", features = ["json"] }
Expand All @@ -38,7 +39,6 @@ async-trait = "0.1.83"
parking_lot = "0.12.3"
tracing = "0.1.41"
tracing-subscriber = { version = "0.3.19", features = ["env-filter"] }
lyric_finder = { version = "0.1.8", path = "../lyric_finder", optional = true }
backtrace = "0.3.74"
souvlaki = { version = "0.7.3", optional = true }
viuer = { version = "0.9.1", optional = true }
Expand Down Expand Up @@ -85,7 +85,6 @@ rodiojack-backend = ["streaming", "librespot-playback/rodiojack-backend"]
sdl-backend = ["streaming", "librespot-playback/sdl-backend"]
gstreamer-backend = ["streaming", "librespot-playback/gstreamer-backend"]
streaming = ["librespot-playback", "librespot-connect"]
lyric-finder = ["lyric_finder"]
media-control = ["souvlaki", "winit", "windows"]
image = ["viuer", "dep:image"]
sixel = ["image", "viuer/sixel"]
Expand Down
7 changes: 3 additions & 4 deletions spotify_player/src/cli/handlers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -46,8 +46,7 @@ fn get_id_or_name(args: &ArgMatches) -> IdOrName {
}
}

#[allow(clippy::unnecessary_wraps)] // we need this to match the other functions return type
fn handle_get_subcommand(args: &ArgMatches) -> Result<Request> {
fn handle_get_subcommand(args: &ArgMatches) -> Request {
let (cmd, args) = args.subcommand().expect("playback subcommand is required");

let request = match cmd {
Expand All @@ -69,7 +68,7 @@ fn handle_get_subcommand(args: &ArgMatches) -> Result<Request> {
_ => unreachable!(),
};

Ok(request)
request
}

fn handle_playback_subcommand(args: &ArgMatches) -> Result<Request> {
Expand Down Expand Up @@ -202,7 +201,7 @@ pub fn handle_cli_subcommand(cmd: &str, args: &ArgMatches) -> Result<()> {

// construct a socket request based on the CLI command and its arguments
let request = match cmd {
"get" => handle_get_subcommand(args)?,
"get" => handle_get_subcommand(args),
"playback" => handle_playback_subcommand(args)?,
"playlist" => handle_playlist_subcommand(args)?,
"connect" => Request::Connect(get_id_or_name(args)),
Expand Down
25 changes: 12 additions & 13 deletions spotify_player/src/client/handlers.rs
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
use anyhow::Context;
use rspotify::model::Id;
use tracing::Instrument;

use crate::{
config,
state::{ContextId, ContextPageType, ContextPageUIState, PageState, PlayableId, SharedState},
};

#[cfg(feature = "lyric-finder")]
use crate::utils::map_join;

use super::ClientRequest;
Expand Down Expand Up @@ -167,25 +167,24 @@ fn handle_page_change_event(
}
}

#[cfg(feature = "lyric-finder")]
PageState::Lyric {
PageState::Lyrics {
track_uri,
track,
artists,
scroll_offset,
} => {
if let Some(rspotify::model::PlayableItem::Track(current_track)) =
state.player.read().currently_playing()
{
if current_track.name != *track {
tracing::info!("Current playing track \"{}\" is different from the track \"{track}\" shown up in the lyric page. Updating the track and fetching its lyric...", current_track.name);
track.clone_from(&current_track.name);
*artists = map_join(&current_track.artists, |a| &a.name, ", ");
*scroll_offset = 0;

client_pub.send(ClientRequest::GetLyric {
track: track.clone(),
artists: artists.clone(),
})?;
if let Some(id) = &current_track.id {
tracing::info!("Currently playing track \"{}\" is different from the track \"{track}\" shown up in the lyrics page. Fetching new track's lyrics...", current_track.name);
track.clone_from(&current_track.name);
*artists = map_join(&current_track.artists, |a| &a.name, ", ");
*track_uri = id.uri();
client_pub.send(ClientRequest::GetLyrics {
track_id: id.clone_static(),
})?;
}
}
}
}
Expand Down
38 changes: 24 additions & 14 deletions spotify_player/src/client/mod.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
use std::ops::Deref;
use std::{borrow::Cow, collections::HashMap, sync::Arc};

use crate::state::Lyrics;
use crate::{auth, config};
use crate::{
auth::AuthConfig,
Expand Down Expand Up @@ -329,22 +330,16 @@ impl Client {
.category_playlists
.insert(category.id, playlists);
}
#[cfg(feature = "lyric-finder")]
ClientRequest::GetLyric { track, artists } => {
let client = lyric_finder::Client::from_http_client(&self.http);
let query = format!("{track} {artists}");

if !state.data.read().caches.lyrics.contains_key(&query) {
let result = client.get_lyric(&query).await.context(format!(
"failed to get lyric for track {track} - artists {artists}"
))?;

ClientRequest::GetLyrics { track_id } => {
let uri = track_id.uri();
if !state.data.read().caches.lyrics.contains_key(&uri) {
let lyrics = self.lyrics(track_id).await?;
state
.data
.write()
.caches
.lyrics
.insert(query, result, *TTL_CACHE_DURATION);
.insert(uri, lyrics, *TTL_CACHE_DURATION);
}
}
#[cfg(feature = "streaming")]
Expand Down Expand Up @@ -609,6 +604,22 @@ impl Client {
Ok(())
}

/// Get lyrics of a given track, return None if no lyrics is available
pub async fn lyrics(&self, track_id: TrackId<'static>) -> Result<Option<Lyrics>> {
let session = self.session().await;
let id = librespot_core::spotify_id::SpotifyId::from_uri(&track_id.uri())?;
match librespot_metadata::Lyrics::get(&session, &id).await {
Ok(lyrics) => Ok(Some(lyrics.into())),
Err(err) => {
if err.to_string().to_lowercase().contains("not found") {
Ok(None)
} else {
Err(err.into())
}
}
}
}

/// Get user available devices
// This is a custom API to replace `rspotify::device` API to support Spotify Connect feature
pub async fn available_devices(&self) -> Result<Vec<rspotify::model::Device>> {
Expand Down Expand Up @@ -1035,15 +1046,14 @@ impl Client {
}

/// Search for items of a specific type matching a given query
#[allow(clippy::used_underscore_binding)] // false positive as this is because of a crate
pub async fn search_specific_type(
&self,
query: &str,
_type: rspotify::model::SearchType,
typ: rspotify::model::SearchType,
) -> Result<rspotify::model::SearchResult> {
Ok(self
.spotify
.search(query, _type, None, None, None, None)
.search(query, typ, None, None, None, None)
.await?)
}

Expand Down
6 changes: 2 additions & 4 deletions spotify_player/src/client/request.rs
Original file line number Diff line number Diff line change
Expand Up @@ -55,10 +55,8 @@ pub enum ClientRequest {
DeleteFromLibrary(ItemId),
Player(PlayerRequest),
GetCurrentUserQueue,
#[cfg(feature = "lyric-finder")]
GetLyric {
track: String,
artists: String,
GetLyrics {
track_id: TrackId<'static>,
},
#[cfg(feature = "streaming")]
RestartIntegratedClient,
Expand Down
6 changes: 2 additions & 4 deletions spotify_player/src/command.rs
Original file line number Diff line number Diff line change
Expand Up @@ -62,8 +62,7 @@ pub enum Command {
TopTrackPage,
RecentlyPlayedTrackPage,
LikedTrackPage,
#[cfg(feature = "lyric-finder")]
LyricPage,
LyricsPage,
LibraryPage,
SearchPage,
BrowsePage,
Expand Down Expand Up @@ -340,8 +339,7 @@ impl Command {
Self::TopTrackPage => "go to the user top track page",
Self::RecentlyPlayedTrackPage => "go to the user recently played track page",
Self::LikedTrackPage => "go to the user liked track page",
#[cfg(feature = "lyric-finder")]
Self::LyricPage => "go to the lyric page of the current track",
Self::LyricsPage => "go to the lyrics page of the current track",
Self::LibraryPage => "go to the user library page",
Self::SearchPage => "go to the search page",
Self::BrowsePage => "go to the browse page",
Expand Down
6 changes: 2 additions & 4 deletions spotify_player/src/config/keymap.rs
Original file line number Diff line number Diff line change
Expand Up @@ -168,15 +168,13 @@ impl Default for KeymapConfig {
key_sequence: "g y".into(),
command: Command::LikedTrackPage,
},
#[cfg(feature = "lyric-finder")]
Keymap {
key_sequence: "g L".into(),
command: Command::LyricPage,
command: Command::LyricsPage,
},
#[cfg(feature = "lyric-finder")]
Keymap {
key_sequence: "l".into(),
command: Command::LyricPage,
command: Command::LyricsPage,
},
Keymap {
key_sequence: "g l".into(),
Expand Down
10 changes: 10 additions & 0 deletions spotify_player/src/config/theme.rs
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ pub struct ComponentStyle {
pub selection: Option<Style>,
pub secondary_row: Option<Style>,
pub like: Option<Style>,
pub lyrics_played: Option<Style>,
}

#[derive(Default, Clone, Debug, Deserialize)]
Expand Down Expand Up @@ -165,6 +166,7 @@ impl ThemeConfig {
}
}

// TODO: cleanup implementation for style getter methods
impl Theme {
pub fn app(&self) -> style::Style {
let mut style = style::Style::default();
Expand Down Expand Up @@ -319,6 +321,14 @@ impl Theme {
Some(s) => s.style(&self.palette),
}
}
pub fn lyrics_played(&self) -> tui::style::Style {
match &self.component_style.lyrics_played {
None => Style::default()
.modifiers(vec![StyleModifier::Dim])
.style(&self.palette),
Some(s) => s.style(&self.palette),
}
}
}

impl Style {
Expand Down
25 changes: 12 additions & 13 deletions spotify_player/src/event/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@ use crate::{
utils::parse_uri,
};

#[cfg(feature = "lyric-finder")]
use crate::utils::map_join;
use anyhow::{Context as _, Result};

Expand Down Expand Up @@ -734,22 +733,22 @@ fn handle_global_command(
tracing::warn!("clipboard's content ({content}) is not a valid Spotify link!");
}
}
#[cfg(feature = "lyric-finder")]
Command::LyricPage => {
Command::LyricsPage => {
if let Some(rspotify::model::PlayableItem::Track(track)) =
state.player.read().currently_playing()
{
let artists = map_join(&track.artists, |a| &a.name, ", ");
ui.new_page(PageState::Lyric {
track: track.name.clone(),
artists: artists.clone(),
scroll_offset: 0,
});
if let Some(id) = &track.id {
let artists = map_join(&track.artists, |a| &a.name, ", ");
ui.new_page(PageState::Lyrics {
track_uri: id.uri(),
track: track.name.clone(),
artists,
});

client_pub.send(ClientRequest::GetLyric {
track: track.name.clone(),
artists,
})?;
client_pub.send(ClientRequest::GetLyrics {
track_id: id.clone_static(),
})?;
}
}
}
Command::SwitchDevice => {
Expand Down
Loading
Loading