use iced::{
alignment,
widget::{
scrollable, text, text_editor::Binding, Button, Column, Container, Id, MouseArea, Row,
},
Color, Element, Length,
};
pub fn lyrics_scroll_id(pane: PaneId) -> Id {
Id::from(format!("lyrics_scroll:{pane}"))
}
use super::{
shared_components::{empty_state, loading_state, scope_tab_row_h_scroll},
styles::{
bg_secondary, button_style_danger, button_style_panel_item, button_style_primary,
fg_secondary,
},
theme, Message, MusicPlayer,
};
use crate::{
app::{pane::PaneId, LyricsState, LyricsViewMode},
load_state::LoadState,
theme::AppTheme,
};
pub(super) fn view_lyrics<'a>(
player: &'a MusicPlayer,
pane: PaneId,
lyrics_state: &'a LyricsState,
) -> Element<'a, Message, AppTheme> {
if lyrics_state.editing {
return view_custom_editor(player, pane, lyrics_state);
}
let track = player.queue.current();
let displayed = lyrics_state.displayed_lyrics();
let lyrics_ready = displayed.is_some();
let body: Element<'a, Message, AppTheme> = if lyrics_state.mode == LyricsViewMode::Selectable
&& lyrics_ready
{
view_select_editor(pane, &lyrics_state.editor)
} else {
let mode = lyrics_state.mode;
let scrolled = lyrics_state.scrolled_to;
let picked = lyrics_state.picked_line;
match (track, &lyrics_state.lyrics) {
(Some(_), LoadState::Ready(_)) => match displayed {
Some(lyrics) if lyrics.has_timed() && mode == LyricsViewMode::Synced => {
view_synced(pane, lyrics, scrolled)
}
Some(lyrics) => view_plain_notes(pane, lyrics, picked),
None => empty_state(""),
},
(Some(_), LoadState::Loading) => loading_state(player.strings.looking_up_lyrics),
(Some(_), LoadState::Failed(e)) => empty_state((player.strings.couldnt_load_lyrics)(e)),
(None, _) => empty_state(player.strings.play_a_track_for_lyrics),
}
};
let body: Element<'a, Message, AppTheme> = MouseArea::new(body)
.on_right_press(Message::CopyLyrics(pane))
.into();
let note_idx = lyrics_state.note_target();
let note_visible = match (displayed, note_idx) {
(Some(lyrics), Some(idx)) => lyrics.lines.get(idx).is_some_and(|line| {
!line.description.is_empty()
|| (lyrics_state.note_line == Some(idx)
&& lyrics_state.note_editor.text().trim_end() != line.description)
}),
_ => false,
};
let mut children: Vec<Element<'a, Message, AppTheme>> = Vec::with_capacity(5);
children.push(Container::new(body).height(Length::Fill).into());
if note_visible {
children.push(view_note_block(pane, lyrics_state));
}
if let Some(row) = view_translation_row(player, pane, lyrics_state) {
children.push(row);
}
if track.is_some() {
if let Some(name) = &lyrics_state.selected_custom {
children.push(view_edit_custom_button(player, pane, name));
}
}
children.push(view_bottom_controls(player, pane, lyrics_state).into());
Column::with_children(children)
.spacing(theme::SPACING_MD)
.into()
}
fn view_note_block(pane: PaneId, lyrics_state: &LyricsState) -> Element<'_, Message, AppTheme> {
let editor = iced::widget::text_editor(&lyrics_state.note_editor)
.on_action(move |a| Message::LyricNoteAction(pane, a))
.style(|theme: &AppTheme, _| {
let p = &theme.palette;
iced::widget::text_editor::Style {
background: Color::TRANSPARENT.into(),
border: iced::Border::default(),
placeholder: Color::TRANSPARENT,
value: p.fg_secondary,
selection: p.accent.scale_alpha(0.4),
}
})
.padding([theme::SPACING_XS, theme::SPACING_MD])
.height(Length::Shrink);
Container::new(editor)
.style(bg_secondary())
.padding(theme::SPACING_XS)
.into()
}
fn view_edit_custom_button<'a>(
player: &'a MusicPlayer,
pane: PaneId,
name: &'a str,
) -> Element<'a, Message, AppTheme> {
Button::new(Container::new(text(player.strings.edit_lyrics)).center_x(Length::Fill))
.width(Length::Fill)
.padding([theme::SPACING_XS, theme::SPACING_MD])
.on_press(Message::EditCustomLyrics(pane, name.to_string()))
.into()
}
fn view_custom_editor<'a>(
player: &'a MusicPlayer,
pane: PaneId,
lyrics_state: &'a LyricsState,
) -> Element<'a, Message, AppTheme> {
let editor = iced::widget::text_editor(&lyrics_state.edit_content)
.on_action(move |a| Message::CustomLyricsEditorAction(pane, a))
.style(|theme: &AppTheme, _| {
let p = &theme.palette;
iced::widget::text_editor::Style {
background: Color::TRANSPARENT.into(),
border: iced::Border::default(),
placeholder: Color::TRANSPARENT,
value: p.fg_secondary,
selection: p.accent.scale_alpha(0.4),
}
})
.padding(theme::SPACING_LG)
.height(Length::Fill);
let save_btn = Button::new(Container::new(text(player.strings.save)).center_x(Length::Fill))
.padding(theme::SPACING_SM)
.style(button_style_primary())
.on_press(Message::SaveCustomLyrics(pane));
let cancel_btn =
Button::new(Container::new(text(player.strings.cancel)).center_x(Length::Fill))
.padding(theme::SPACING_SM)
.on_press(Message::CancelCustomLyricsEdit(pane));
let mut buttons: Vec<Element<'a, Message, AppTheme>> = vec![cancel_btn.into(), save_btn.into()];
if lyrics_state.editing_custom_name.is_some() {
buttons.push(
Button::new(Container::new(text(player.strings.delete)).center_x(Length::Fill))
.padding(theme::SPACING_SM)
.style(button_style_danger())
.on_press(Message::DeleteCustomLyrics(pane))
.into(),
);
}
Column::with_children([
Container::new(
iced::widget::text_input(player.strings.lyrics_name, &lyrics_state.edit_name)
.on_input(move |s| Message::CustomLyricsNameChanged(pane, s))
.padding([theme::SPACING_SM, theme::SPACING_MD]),
)
.padding([0.0, theme::SPACING_XS])
.into(),
text(player.strings.lyrics_editor_hint)
.size(theme::TEXT_SIZE_SM)
.style(fg_secondary())
.into(),
Container::new(editor).height(Length::Fill).into(),
Row::with_children(buttons)
.spacing(theme::SPACING_SM)
.align_y(alignment::Vertical::Center)
.into(),
])
.spacing(theme::SPACING_MD)
.padding(theme::SPACING_MD)
.into()
}
fn view_translation_row<'a>(
player: &'a MusicPlayer,
pane: PaneId,
lyrics_state: &'a LyricsState,
) -> Option<Element<'a, Message, AppTheme>> {
let LoadState::Ready(lyrics) = &lyrics_state.lyrics else {
return None;
};
if lyrics.translations.is_empty() {
return None;
}
let selected = lyrics_state.selected_translation.as_deref();
let loading = lyrics_state.loading_translation.as_deref();
let original = (
player.strings.lyrics_original.to_string(),
selected.is_none(),
Some(Message::SelectLyricsTranslation(pane, None)),
);
let languages = lyrics.translations.iter().map(|translation| {
let language = translation.language.clone();
let busy = loading == Some(language.as_str());
(
crate::lyrics::language_name(&translation.language),
selected == Some(language.as_str()) && translation.is_loaded(),
(!busy).then_some(Message::SelectLyricsTranslation(pane, Some(language))),
)
});
let row = scope_tab_row_h_scroll(std::iter::once(original).chain(languages));
Some(
Row::with_children([
text(player.strings.lyrics_translations)
.size(theme::TEXT_SIZE_SM)
.style(fg_secondary())
.into(),
Container::new(row).align_left(Length::Fill).into(),
])
.spacing(theme::SPACING_SM)
.padding([0.0, theme::SPACING_SM])
.align_y(alignment::Vertical::Center)
.into(),
)
}
fn view_bottom_controls<'a>(
player: &'a MusicPlayer,
pane: PaneId,
lyrics_state: &'a LyricsState,
) -> Row<'a, Message, AppTheme> {
const MODES: [LyricsViewMode; 3] = [
LyricsViewMode::Selectable,
LyricsViewMode::Synced,
LyricsViewMode::Plain,
];
let labels = [
player.strings.lyrics_selectable,
player.strings.lyrics_synced,
player.strings.lyrics_plain,
];
let picker = scope_tab_row_h_scroll(MODES.iter().zip(labels).map(|(&mode, label)| {
(
label,
lyrics_state.mode == mode,
lyrics_state
.mode_available(mode)
.then_some(Message::SetLyricsViewMode(pane, mode)),
)
}));
let selected_provider = lyrics_state.provider;
let selected_custom = lyrics_state.selected_custom.as_deref();
let provider_row = scope_tab_row_h_scroll(
crate::lyrics::LyricsProvider::all()
.iter()
.map(|provider| {
(
provider.name().to_string(),
selected_custom.is_none() && *provider == selected_provider,
Some(Message::SelectLyricsProvider(pane, *provider)),
)
})
.chain(lyrics_state.custom_names.iter().map(|name| {
(
name.clone(),
selected_custom == Some(name.as_str()),
Some(Message::SelectCustomLyrics(pane, name.clone())),
)
}))
.chain([
(
player.strings.translate_with_ai.to_string(),
false,
Some(Message::OpenTranslateDialog(pane)),
),
(
player.strings.add_custom.to_string(),
false,
Some(Message::StartCustomLyricsEdit(pane)),
),
]),
);
Row::with_children([
Container::new(provider_row).align_left(Length::Fill).into(),
Container::new(picker).align_right(Length::Fill).into(),
])
.spacing(theme::SPACING_SM)
.padding(theme::SPACING_SM)
.align_y(alignment::Vertical::Center)
}
fn view_select_editor(
pane: PaneId,
editor_content: &iced::widget::text_editor::Content,
) -> Element<'_, Message, AppTheme> {
iced::widget::text_editor(editor_content)
.on_action(move |a| Message::LyricsEditorAction(pane, a))
.key_binding(|press| {
let binding = Binding::from_key_press(press)?;
match binding {
Binding::Copy
| Binding::Move(_)
| Binding::Select(_)
| Binding::SelectWord
| Binding::SelectLine
| Binding::SelectAll
| Binding::Unfocus => Some(binding),
_ => None,
}
})
.style(|theme: &AppTheme, _| {
let p = &theme.palette;
iced::widget::text_editor::Style {
background: Color::TRANSPARENT.into(),
border: iced::Border::default(),
placeholder: Color::TRANSPARENT,
value: p.fg_secondary,
selection: p.accent.scale_alpha(0.4),
}
})
.padding(theme::SPACING_LG)
.into()
}
fn view_plain_notes(
pane: PaneId,
lyrics: &crate::lyrics::Lyrics,
picked: Option<usize>,
) -> Element<'_, Message, AppTheme> {
let rows = lyrics.lines.iter().enumerate().map(|(i, line)| {
let centered = Container::new(
text(&line.text)
.size(theme::TEXT_SIZE_LG)
.style(fg_secondary()),
)
.center(Length::Fill);
Button::new(centered)
.padding([theme::SPACING_SM, theme::SPACING_LG])
.style(button_style_panel_item(picked == Some(i)))
.on_press(Message::SelectLyricLine(pane, i))
.into()
});
scrollable(
Column::with_children(rows)
.spacing(theme::SPACING_SM)
.padding(theme::SPACING_LG),
)
.into()
}
fn view_synced(
pane: PaneId,
lyrics: &crate::lyrics::Lyrics,
scrolled: Option<usize>,
) -> Element<'_, Message, AppTheme> {
let lines = lyrics.lines.iter().enumerate().filter_map(|(i, line)| {
let secs = line.time?;
let is_active = scrolled == Some(i);
let centered =
Container::new(text(&line.text).size(theme::TEXT_SIZE_XL)).center(Length::Fill);
Some(
Button::new(centered)
.padding([theme::SPACING_SM, theme::SPACING_LG])
.style(button_style_panel_item(is_active))
.on_press(Message::LyricsLineClicked(secs))
.into(),
)
});
scrollable(
Column::with_children(lines)
.spacing(theme::SPACING_SM)
.padding(theme::SPACING_LG),
)
.id(lyrics_scroll_id(pane))
.on_scroll(move |vp| Message::LyricsScrolled {
pane,
translation_y: vp.absolute_offset().y,
viewport_h: vp.bounds().height,
content_h: vp.content_bounds().height,
})
.into()
}