use std::{ops::ControlFlow, time::Duration}; use anyhow::Result; use librespot::{ core::SpotifyId, metadata::{ lyrics::{Line, SyncType}, Lyrics, }, }; use log::error; use serenity::{ all::{ CommandInteraction, ComponentInteraction, ComponentInteractionCollector, Context, CreateActionRow, CreateButton, CreateEmbed, CreateEmbedFooter, CreateInteractionResponse, CreateInteractionResponseMessage, EditMessage, Message, }, futures::StreamExt, }; use spoticord_player::info::PlaybackInfo; use spoticord_utils::discord::Colors; use tokio::task::JoinHandle; use crate::{Session, SessionHandle}; const PAGE_LENGTH: usize = 3000; const TIME_OFFSET: u32 = 1000; pub struct LyricsEmbed { guild_id: String, ctx: Context, session: SessionHandle, message: Message, track: SpotifyId, lyrics: Option, page: usize, } impl LyricsEmbed { pub async fn create( session: &Session, handle: SessionHandle, interaction: CommandInteraction, ) -> Result>> { let ctx = session.context.clone(); if !session.active { respond_not_playing(&ctx, interaction).await?; return Ok(None); } let Some(playback_info) = session.player.playback_info().await? else { respond_not_playing(&ctx, interaction).await?; return Ok(None); }; let guild_id = interaction .guild_id .expect("interaction was outside of a guild") .to_string(); let lyrics = session.player.get_lyrics().await?; // Send initial message interaction .create_response( &ctx, CreateInteractionResponse::Message( CreateInteractionResponseMessage::new() .embed(lyrics_embed(&lyrics, &playback_info, 0)) .components(vec![lyrics_buttons(&guild_id, &lyrics, 0)]), ), ) .await?; // Retrieve message instead of editing interaction response, as those tokens are only valid for 15 minutes let message = interaction.get_response(&ctx).await?; let this = Self { guild_id: guild_id.clone(), ctx: ctx.clone(), session: handle, message, track: playback_info.track_id(), lyrics, page: 0, }; let collector = ComponentInteractionCollector::new(&ctx) .filter(move |press| { let parts = press.data.custom_id.split(':').collect::>(); matches!(parts.first(), Some(&"lyrics")) && matches!(parts.last(), Some(id) if id == &guild_id) }) .timeout(Duration::from_secs(3600 * 24)); let handle = tokio::spawn(this.run(collector)); Ok(Some(handle)) } async fn run(mut self, collector: ComponentInteractionCollector) { let mut stream = collector.stream(); let mut interval = tokio::time::interval(Duration::from_secs(2)); loop { tokio::select! { _ = interval.tick() => { if self.handle_tick().await.is_break() { break; } } opt_press = stream.next() => { let Some(press) = opt_press else { break; }; // Immediately acknowledge, we don't have to inform the user about the update _ = press .create_response(&self.ctx, CreateInteractionResponse::Acknowledge) .await; if self.handle_press(press).await.is_break() { break; } } } } } async fn handle_tick(&mut self) -> ControlFlow<(), ()> { let Ok(player) = self.session.player().await else { // Failure means that the session is gone, so we quit return ControlFlow::Break(()); }; if !matches!(self.session.active().await, Ok(true)) { // If the session is currently not active, just wait until it becomes active again return ControlFlow::Continue(()); } let Ok(Some(playback_info)) = player.playback_info().await else { // If we're not playing anything, just wait until we are return ControlFlow::Continue(()); }; if playback_info.track_id() != self.track { // We're playing another track, reload the lyrics! let lyrics = match player.get_lyrics().await { Ok(lyrics) => lyrics, Err(why) => { error!("Failed to retrieve lyrics: {why}"); return ControlFlow::Break(()); } }; self.lyrics = lyrics; self.page = 0; self.track = playback_info.track_id(); if let Err(why) = self .message .edit( &self.ctx, EditMessage::new() .embed(lyrics_embed(&self.lyrics, &playback_info, self.page)) .components(vec![lyrics_buttons( &self.guild_id, &self.lyrics, self.page, )]), ) .await { error!("Failed to update lyrics: {why}"); return ControlFlow::Break(()); } return ControlFlow::Continue(()); } // We're still playing the same song, check if we need to update the page let Some(lyrics) = &self.lyrics else { // No lyrics in current song, just continue until we have one with return ControlFlow::Continue(()); }; if !matches!(lyrics.lyrics.sync_type, SyncType::LineSynced) { // Only synced lyrics should auto-swap to new pages return ControlFlow::Continue(()); } let new_page = page_at_position(lyrics, playback_info.current_position()).unwrap_or(0); if new_page != self.page { // We've arrived on a new page: swap em up! self.page = new_page; if let Err(why) = self .message .edit( &self.ctx, EditMessage::new() .embed(lyrics_embed(&self.lyrics, &playback_info, new_page)) .components(vec![lyrics_buttons(&self.guild_id, &self.lyrics, new_page)]), ) .await { error!("Failed to update lyrics: {why}"); return ControlFlow::Break(()); } } ControlFlow::Continue(()) } async fn handle_press(&mut self, press: ComponentInteraction) -> ControlFlow<(), ()> { let next = match press.data.custom_id.split(':').nth(1) { Some("next") => true, Some("prev") => false, _ => return ControlFlow::Continue(()), }; let Some(lyrics) = &self.lyrics else { return ControlFlow::Continue(()); }; if !matches!(lyrics.lyrics.sync_type, SyncType::Unsynced) { // Only allow manual swapping if lyrics are unsynced return ControlFlow::Continue(()); } let length = lyrics .lyrics .lines .iter() .fold(0, |acc, line| acc + line.words.len()); let pages = length / PAGE_LENGTH + if length % PAGE_LENGTH > 0 { 1 } else { 0 }; let Ok(player) = self.session.player().await else { return ControlFlow::Continue(()); }; let Ok(Some(playback_info)) = player.playback_info().await else { return ControlFlow::Continue(()); }; match next { true if self.page < pages - 1 => self.page += 1, false if self.page > 0 => self.page -= 1, _ => return ControlFlow::Continue(()), } if let Err(why) = self .message .edit( &self.ctx, EditMessage::new() .embed(lyrics_embed(&self.lyrics, &playback_info, self.page)) .components(vec![lyrics_buttons( &self.guild_id, &self.lyrics, self.page, )]), ) .await { error!("Failed to update lyrics: {why}"); return ControlFlow::Break(()); } ControlFlow::Continue(()) } } async fn respond_not_playing(context: &Context, interaction: CommandInteraction) -> Result<()> { interaction .create_response( context, CreateInteractionResponse::Message( CreateInteractionResponseMessage::new() .embed(not_playing_embed()) .ephemeral(true), ), ) .await?; Ok(()) } fn not_playing_embed() -> CreateEmbed { CreateEmbed::new() .title("Cannot get lyrics") .description("I'm currently not playing any music in this server.") .color(Colors::Error) } fn lyrics_embed(lyrics: &Option, playback_info: &PlaybackInfo, page: usize) -> CreateEmbed { match (lyrics, playback_info.artists()) { (Some(lyrics), Some(artists)) => { let length = lyrics .lyrics .lines .iter() .fold(0, |acc, line| acc + line.words.len()); let page = &into_pages(&lyrics.lyrics.lines) [if page * PAGE_LENGTH > length { 0 } else { page }]; let title = format!( "{} - {}", playback_info.name(), artists .0 .into_iter() .map(|artist| artist.name) .collect::>() .join(", "), ); let description = page .iter() .map(|page| page.words.replace('♪', "\n♪\n")) .collect::>() .join("\n"); let mut footer = format!("Lyrics provided by {}", lyrics.lyrics.provider_display_name); if matches!(lyrics.lyrics.sync_type, SyncType::LineSynced) { footer.push_str(" | Synced to song"); } CreateEmbed::new() .title(title) .description(description) .footer(CreateEmbedFooter::new(footer)) .color(Colors::Info) } _ => CreateEmbed::new() .title("No lyrics available") .description("This current track has no lyrics available. Just enjoy the tunes!") .color(Colors::Info), } } fn lyrics_buttons(id: &str, lyrics: &Option, page: usize) -> CreateActionRow { let (can_prev, can_next) = match lyrics { Some(lyrics) => match lyrics.lyrics.sync_type { SyncType::Unsynced => { // Only unsynced lyrics can have its pages flipped through by the user let length = lyrics .lyrics .lines .iter() .fold(0, |acc, line| acc + line.words.len()); let pages = length / PAGE_LENGTH + if length % PAGE_LENGTH > 0 { 1 } else { 0 }; (page > 0, page < pages - 1) } SyncType::LineSynced => (false, false), }, None => (false, false), }; CreateActionRow::Buttons(vec![ CreateButton::new(format!("lyrics:prev:{id}")) .disabled(!can_prev) .label("<"), CreateButton::new(format!("lyrics:next:{id}")) .disabled(!can_next) .label(">"), ]) } fn into_pages(lines: &[Line]) -> Vec> { let mut result = vec![]; let mut current = vec![]; let mut current_position = 0; for line in lines { if current_position + line.words.len() > PAGE_LENGTH { result.push(current); current = vec![line.clone()]; current_position = line.words.len(); continue; } current.push(line.clone()); current_position += line.words.len(); } result.push(current); result } fn page_at_position(lyrics: &Lyrics, position: u32) -> Option { let pages = into_pages(&lyrics.lyrics.lines); for (i, line) in pages.iter().enumerate() { if let Some(first) = line.first() { let Ok(time) = first .start_time_ms .parse::() .map(|v| v.saturating_sub(TIME_OFFSET)) else { return None; }; if position < time { return Some(if i == 0 { 0 } else { i - 1 }); } } if let (Some(first), Some(last)) = (line.first(), line.last()) { let (Ok(first), Ok(last)) = ( first .start_time_ms .parse::() .map(|v| v.saturating_sub(TIME_OFFSET)), last.start_time_ms .parse::() .map(|v| v.saturating_sub(TIME_OFFSET)), ) else { return None; }; if position >= first && position <= last { return Some(i); } } } Some(pages.len() - 1) }