448 lines
14 KiB
Rust
448 lines
14 KiB
Rust
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<Lyrics>,
|
|
page: usize,
|
|
}
|
|
|
|
impl LyricsEmbed {
|
|
pub async fn create(
|
|
session: &Session,
|
|
handle: SessionHandle,
|
|
interaction: CommandInteraction,
|
|
) -> Result<Option<JoinHandle<()>>> {
|
|
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::<Vec<_>>();
|
|
|
|
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<Lyrics>, 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::<Vec<_>>()
|
|
.join(", "),
|
|
);
|
|
|
|
let description = page
|
|
.iter()
|
|
.map(|page| page.words.replace('♪', "\n♪\n"))
|
|
.collect::<Vec<_>>()
|
|
.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<Lyrics>, 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<Vec<Line>> {
|
|
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<usize> {
|
|
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::<u32>()
|
|
.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::<u32>()
|
|
.map(|v| v.saturating_sub(TIME_OFFSET)),
|
|
last.start_time_ms
|
|
.parse::<u32>()
|
|
.map(|v| v.saturating_sub(TIME_OFFSET)),
|
|
) else {
|
|
return None;
|
|
};
|
|
|
|
if position >= first && position <= last {
|
|
return Some(i);
|
|
}
|
|
}
|
|
}
|
|
|
|
Some(pages.len() - 1)
|
|
}
|