Massive refactor to match the Elm architecture better
parent
31d59fd0ee
commit
415b4e7fc2
File diff suppressed because it is too large
Load Diff
14
Cargo.toml
14
Cargo.toml
|
@ -14,7 +14,7 @@ exoquant = "0.2.0"
|
|||
image = "0.23.14"
|
||||
|
||||
# Display windows & graphics
|
||||
iced_native = "0.4.0"
|
||||
#iced_native = "0.4.0"
|
||||
|
||||
# Native file dialogs
|
||||
rfd = "0.6.3"
|
||||
|
@ -31,12 +31,20 @@ version = "0.3.0"
|
|||
[dependencies.iced]
|
||||
# Display windows & graphics
|
||||
features = ["canvas", "image"]
|
||||
version = "0.3.0"
|
||||
git = "https://github.com/iced-rs/iced.git"
|
||||
|
||||
[dependencies.iced_native]
|
||||
# Native-display only GUI features
|
||||
git = "https://github.com/iced-rs/iced.git"
|
||||
|
||||
[dependencies.iced_lazy]
|
||||
# Responsive widget design
|
||||
git = "https://github.com/iced-rs/iced.git"
|
||||
|
||||
[dependencies.iced_futures]
|
||||
# Display windows & graphics
|
||||
features = ["smol"]
|
||||
version = "0.3.0"
|
||||
git = "https://github.com/iced-rs/iced.git"
|
||||
|
||||
[dependencies.rodio]
|
||||
# Playing audio
|
||||
|
|
110
src/app.rs
110
src/app.rs
|
@ -1,13 +1,10 @@
|
|||
use crate::lyrics::LyricEvent;
|
||||
use crate::editor::view_editor;
|
||||
use crate::file_select::view_fileselector;
|
||||
use crate::model::Model;
|
||||
use crate::controls::ControlsEvent;
|
||||
use crate::load_song::load_song;
|
||||
use crate::editor::Editor;
|
||||
use crate::file_select::FileSelector;
|
||||
use rfd::AsyncFileDialog;
|
||||
use std::path::PathBuf;
|
||||
use core::time::Duration;
|
||||
use iced::Subscription;
|
||||
use iced::Clipboard;
|
||||
use iced::Command;
|
||||
use iced::Application;
|
||||
use iced::Element;
|
||||
|
@ -17,12 +14,9 @@ use iced_native::subscription;
|
|||
use iced_native::keyboard;
|
||||
use iced_native::window;
|
||||
use iced_native::event::Event;
|
||||
use crate::model::editing::LyricEvent;
|
||||
|
||||
pub struct DelyriumApp {
|
||||
lyrics_component: Option<Editor>,
|
||||
file_selector: FileSelector,
|
||||
size: (u32, u32),
|
||||
}
|
||||
pub struct DelyriumApp(Model);
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum Message {
|
||||
|
@ -31,10 +25,10 @@ pub enum Message {
|
|||
kind: LyricEvent,
|
||||
},
|
||||
PasteSent,
|
||||
PasteRead(String),
|
||||
Tick,
|
||||
PromptForFile,
|
||||
FileOpened(PathBuf),
|
||||
Resized(u32, u32),
|
||||
ControlsEvent(ControlsEvent),
|
||||
Null,
|
||||
}
|
||||
|
@ -46,11 +40,7 @@ impl Application for DelyriumApp {
|
|||
|
||||
fn new(_: Self::Flags) -> (Self, Command<Message>) {
|
||||
(
|
||||
DelyriumApp {
|
||||
lyrics_component: None,
|
||||
file_selector: FileSelector::default(),
|
||||
size: (0, 0),
|
||||
},
|
||||
DelyriumApp(Model::DEFAULT),
|
||||
Command::none(),
|
||||
)
|
||||
}
|
||||
|
@ -59,75 +49,18 @@ impl Application for DelyriumApp {
|
|||
String::from("Delyrium")
|
||||
}
|
||||
|
||||
fn update(&mut self, message: Message, clipboard: &mut Clipboard) -> Command<Message>{
|
||||
let mut command = None;
|
||||
match message {
|
||||
Message::LyricEvent { line_no, kind } => {
|
||||
if let Some(lyrics) = self.lyrics_component.as_mut() {
|
||||
lyrics.handle_lyric_event(line_no, kind);
|
||||
}
|
||||
},
|
||||
Message::PasteSent => {
|
||||
if let Some(lyrics) = self.lyrics_component.as_mut() {
|
||||
#[allow(clippy::or_fun_call)] // This is a const
|
||||
let clip_text = clipboard.read().unwrap_or(String::new());
|
||||
let clip_pasted_len = clip_text.chars()
|
||||
.filter(|c| *c != '\r' && *c != '\n')
|
||||
.count();
|
||||
let line = lyrics.current_line_mut().1;
|
||||
line.value.truncate(line.value.len() - clip_pasted_len);
|
||||
lyrics.insert_text(clip_text);
|
||||
}
|
||||
},
|
||||
Message::Tick => {
|
||||
if self.lyrics_component.is_none() {
|
||||
self.file_selector.tick();
|
||||
}
|
||||
},
|
||||
Message::FileOpened(path) => {
|
||||
println!("File opened! {}", path.display());
|
||||
let song = load_song(&path).unwrap().format;
|
||||
let (editor, cmd) = Editor::new(song, self.size);
|
||||
self.lyrics_component = Some(editor);
|
||||
command = Some(cmd);
|
||||
},
|
||||
Message::PromptForFile => {
|
||||
let task = async {
|
||||
let handle = AsyncFileDialog::new()
|
||||
.add_filter("Song Files", &["mp3", "flac", "ogg", "opus", "wav", "mkv"])
|
||||
.set_title("Select a song")
|
||||
.pick_file()
|
||||
.await;
|
||||
if let Some(h) = handle {
|
||||
Message::FileOpened(h.path().to_owned())
|
||||
} else {
|
||||
Message::Null
|
||||
}
|
||||
};
|
||||
command = Some(task.into());
|
||||
},
|
||||
Message::Resized(w, h) => {
|
||||
self.size = (w, h);
|
||||
if let Some(lyrics) = self.lyrics_component.as_mut() {
|
||||
lyrics.notify_resized(w, h);
|
||||
}
|
||||
},
|
||||
Message::ControlsEvent(e) => {
|
||||
if let Some(lyrics) = self.lyrics_component.as_mut() {
|
||||
lyrics.handle_controls_event(e);
|
||||
}
|
||||
},
|
||||
Message::Null => { },
|
||||
}
|
||||
|
||||
command.unwrap_or_else(Command::none)
|
||||
fn update(&mut self, message: Message) -> Command<Message>{
|
||||
self.0.update(message)
|
||||
}
|
||||
|
||||
fn view(&mut self) -> Element<Message> {
|
||||
if let Some(lyrics) = self.lyrics_component.as_mut() {
|
||||
lyrics.view()
|
||||
} else {
|
||||
self.file_selector.view()
|
||||
match &mut self.0 {
|
||||
Model::Editing(editing) => {
|
||||
view_editor(editing)
|
||||
},
|
||||
Model::FilePicker { tick } => {
|
||||
view_fileselector(*tick)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -138,8 +71,8 @@ impl Application for DelyriumApp {
|
|||
match (key_code, modifiers) {
|
||||
(
|
||||
keyboard::KeyCode::V,
|
||||
keyboard::Modifiers { control, .. }
|
||||
) if control => {
|
||||
modifiers
|
||||
) if modifiers.control() => {
|
||||
Some(Message::PasteSent)
|
||||
}
|
||||
_ => { None }
|
||||
|
@ -148,15 +81,12 @@ impl Application for DelyriumApp {
|
|||
Event::Window(window::Event::FileDropped(path)) => {
|
||||
Some(Message::FileOpened(path))
|
||||
},
|
||||
Event::Window(window::Event::Resized{width,height}) => {
|
||||
Some(Message::Resized(width,height))
|
||||
},
|
||||
_ => { None }
|
||||
}
|
||||
});
|
||||
|
||||
let is_animating = if let Some(editor) = &self.lyrics_component {
|
||||
editor.is_animating()
|
||||
let is_animating = if let Model::Editing(e) = &self.0 {
|
||||
e.is_animating()
|
||||
} else {
|
||||
true
|
||||
};
|
||||
|
|
|
@ -26,7 +26,7 @@ pub enum ControlsEvent {
|
|||
DurationDiscovered(Duration),
|
||||
}
|
||||
|
||||
enum ErrorState {
|
||||
pub enum ErrorState {
|
||||
Error(PlayerError),
|
||||
NoError {
|
||||
player: Player,
|
||||
|
@ -36,9 +36,7 @@ enum ErrorState {
|
|||
|
||||
use ErrorState::*;
|
||||
|
||||
pub struct Controls {
|
||||
error_state: ErrorState,
|
||||
}
|
||||
pub struct Controls(pub ErrorState);
|
||||
|
||||
impl Controls {
|
||||
pub fn new(song: Box<dyn FormatReader>) -> (Self, Command<Message>) {
|
||||
|
@ -50,17 +48,16 @@ impl Controls {
|
|||
|d| Message::ControlsEvent(ControlsEvent::DurationDiscovered(d)),
|
||||
);
|
||||
|
||||
(Controls {
|
||||
error_state: NoError {
|
||||
(
|
||||
Controls(NoError {
|
||||
has_device: player.has_output_device(),
|
||||
player,
|
||||
}
|
||||
}, duration_cmd)
|
||||
}),
|
||||
duration_cmd
|
||||
)
|
||||
},
|
||||
Err(e) => {
|
||||
(Controls {
|
||||
error_state: Error(e)
|
||||
}, Command::none())
|
||||
(Controls(Error(e)), Command::none())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -72,7 +69,7 @@ impl Controls {
|
|||
}
|
||||
|
||||
pub fn handle_event(&mut self, event: ControlsEvent) {
|
||||
if let NoError { player, has_device } = &mut self.error_state {
|
||||
if let NoError { player, has_device } = &mut self.0 {
|
||||
let result = match event {
|
||||
ControlsEvent::SeekPosition(pos) => player.seek_percentage(pos),
|
||||
ControlsEvent::TogglePlay => player.toggle_play(),
|
||||
|
@ -88,14 +85,14 @@ impl Controls {
|
|||
*has_device = now_has_device;
|
||||
},
|
||||
Err(e) => {
|
||||
self.error_state = Error(e);
|
||||
self.0 = Error(e);
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_playing(&self) -> bool {
|
||||
if let NoError { player, has_device: true } = &self.error_state {
|
||||
if let NoError { player, has_device: true } = &self.0 {
|
||||
player.is_playing()
|
||||
} else {
|
||||
false
|
||||
|
@ -107,7 +104,7 @@ impl Controls {
|
|||
/// If there was an error, this will be `None`. In all other cases, this will return
|
||||
/// `Some`.
|
||||
pub fn position(&self) -> Option<Duration> {
|
||||
if let NoError { player, .. } = &self.error_state {
|
||||
if let NoError { player, .. } = &self.0 {
|
||||
Some(player.position())
|
||||
} else {
|
||||
None
|
||||
|
@ -119,7 +116,7 @@ impl Program<Message> for (&Controls, Theme) {
|
|||
fn draw(&self, bounds: Rectangle<f32>, _cursor: Cursor) -> Vec<Geometry> {
|
||||
let mut frame = Frame::new(bounds.size());
|
||||
|
||||
match &self.0.error_state {
|
||||
match &self.0.0 {
|
||||
NoError { player, has_device: true } => {
|
||||
let mut background = self.1.text_color;
|
||||
background.a = 0.2;
|
||||
|
|
245
src/editor.rs
245
src/editor.rs
|
@ -1,136 +1,135 @@
|
|||
use crate::lyrics::LyricEvent;
|
||||
use iced::Command;
|
||||
use iced::Image;
|
||||
use iced::image::Handle;
|
||||
use image::DynamicImage;
|
||||
use crate::controls::ControlsEvent;
|
||||
use crate::model::editing::Lyric;
|
||||
use iced::Text;
|
||||
use crate::model::editing::LyricEvent;
|
||||
use iced::widget::text_input::TextInput;
|
||||
use iced::Alignment;
|
||||
use iced::widget::scrollable::{self, Scrollable};
|
||||
use iced::Space;
|
||||
use iced::Canvas;
|
||||
use crate::styles::{Theme, FONT_VG5000};
|
||||
use crate::controls::Controls;
|
||||
use crate::lyrics::Lyric;
|
||||
use crate::lyrics::Lyrics;
|
||||
use crate::model::Editing;
|
||||
use iced::Image;
|
||||
use crate::app::Message;
|
||||
use iced::Element;
|
||||
use crate::styles::Theme;
|
||||
use iced::Container;
|
||||
use iced::Row;
|
||||
use crate::palette::Palette;
|
||||
use crate::load_song::extract_cover;
|
||||
use iced::Length;
|
||||
use image::imageops::FilterType;
|
||||
|
||||
use symphonia::core::formats::FormatReader;
|
||||
pub fn view_editor(editing: &mut Editing) -> Element<Message> {
|
||||
let row = if let Some(margin_bg) = &editing.cached_resized_bg {
|
||||
|
||||
use image::GenericImageView;
|
||||
pub struct Editor {
|
||||
lyrics: Lyrics,
|
||||
theme: Theme,
|
||||
controls: Controls,
|
||||
bg_img: DynamicImage,
|
||||
cached_resized_bg: Option<Handle>,
|
||||
dimensions: (u32, u32),
|
||||
let (img1, img2) = (
|
||||
Image::new(margin_bg.clone())
|
||||
.width(Length::FillPortion(1))
|
||||
.height(Length::Fill),
|
||||
Image::new(margin_bg.clone())
|
||||
.width(Length::FillPortion(1))
|
||||
.height(Length::Fill),
|
||||
);
|
||||
|
||||
Row::new()
|
||||
.push(img1)
|
||||
.push(view_progress(&editing.controls, editing.theme))
|
||||
.push(view_lyrics(&mut editing.lyrics, &mut editing.scroll_state, editing.theme))
|
||||
.push(img2)
|
||||
} else {
|
||||
Row::new()
|
||||
.push(view_progress(&editing.controls, editing.theme))
|
||||
.push(view_lyrics(&mut editing.lyrics, &mut editing.scroll_state, editing.theme))
|
||||
};
|
||||
|
||||
Container::new(row)
|
||||
.style(editing.theme)
|
||||
.height(Length::Fill)
|
||||
.into()
|
||||
}
|
||||
|
||||
impl Editor {
|
||||
pub fn new(mut song: Box<dyn FormatReader>, size: (u32, u32)) -> (Self, Command<Message>) {
|
||||
let cover = extract_cover(song.as_mut());
|
||||
pub fn view_lyrics<'a>(lyrics: &'a mut [Lyric], scroll_state: &'a mut scrollable::State, theme: Theme) -> Element<'a, Message> {
|
||||
let is_sole_line = lyrics.len() == 1;
|
||||
let spacers = (
|
||||
Space::new(Length::Fill, Length::Units(30)),
|
||||
Space::new(Length::Fill, Length::Units(30)),
|
||||
);
|
||||
|
||||
let theme = cover.as_ref()
|
||||
.map(|cover| {
|
||||
Theme::from_palette(
|
||||
Palette::generate(cover)
|
||||
)
|
||||
}).unwrap_or_else(Theme::default);
|
||||
let scroller = lyrics.iter_mut()
|
||||
.enumerate()
|
||||
.map(|(i, l)| view_lyric(l, is_sole_line, i, theme))
|
||||
.fold(Scrollable::new(scroll_state).push(spacers.0), |s, l| s.push(l))
|
||||
.push(spacers.1)
|
||||
.width(Length::Fill)
|
||||
.align_items(Alignment::Center);
|
||||
|
||||
let cover = cover.expect("TODO");
|
||||
|
||||
#[cfg(not(debug_assertions))]
|
||||
let cover = cover.blur((cover.width() / 100) as f32);
|
||||
|
||||
let bg_img = DynamicImage::ImageBgra8(cover.into_bgra8());
|
||||
|
||||
let (controls, cmd) = Controls::new(song);
|
||||
|
||||
let mut editor = Self {
|
||||
lyrics: Lyrics::new(),
|
||||
dimensions: size,
|
||||
cached_resized_bg: None,
|
||||
controls, theme, bg_img,
|
||||
};
|
||||
|
||||
editor.notify_resized(size.0, size.1);
|
||||
|
||||
(editor, cmd)
|
||||
}
|
||||
|
||||
// TODO: work on untangling this mess
|
||||
pub fn handle_controls_event(&mut self, event: ControlsEvent) {
|
||||
self.controls.handle_event(event)
|
||||
}
|
||||
pub fn insert_text(&mut self, text: String) {
|
||||
self.lyrics.insert_text(text);
|
||||
}
|
||||
pub fn handle_lyric_event(&mut self, line_no: usize, kind: LyricEvent) {
|
||||
self.lyrics.handle_event(line_no, kind, || self.controls.position())
|
||||
}
|
||||
pub fn current_line_mut(&mut self) -> (usize, &mut Lyric) {
|
||||
self.lyrics.current_line_mut()
|
||||
}
|
||||
|
||||
pub fn is_animating(&self) -> bool {
|
||||
self.controls.is_playing()
|
||||
}
|
||||
|
||||
fn calculate_margin_width(&self) -> u32 {
|
||||
let (w, _h) = self.dimensions;
|
||||
let body_size = (w / 5).max(550);
|
||||
|
||||
w.saturating_sub(body_size) / 2
|
||||
}
|
||||
|
||||
pub fn notify_resized(&mut self, w: u32, h: u32) {
|
||||
self.dimensions = (w, h);
|
||||
|
||||
let (_w, h) = self.dimensions;
|
||||
let margin_w = self.calculate_margin_width();
|
||||
|
||||
self.cached_resized_bg = if margin_w != 0 {
|
||||
let resized_bg = self.bg_img.resize_to_fill(margin_w, h, FilterType::Nearest);
|
||||
|
||||
Some(Handle::from_pixels(
|
||||
resized_bg.width(),
|
||||
resized_bg.height(),
|
||||
resized_bg.into_bgra8().into_raw()
|
||||
))
|
||||
} else { None };
|
||||
}
|
||||
|
||||
pub fn view(&mut self) -> Element<Message> {
|
||||
|
||||
let row = if let Some(margin_bg) = &self.cached_resized_bg {
|
||||
let (w, _h) = self.dimensions;
|
||||
|
||||
let (img1, img2) = (
|
||||
Image::new(margin_bg.clone())
|
||||
.width(Length::Units(w as u16))
|
||||
.height(Length::Fill),
|
||||
Image::new(margin_bg.clone())
|
||||
.width(Length::Units(w as u16))
|
||||
.height(Length::Fill),
|
||||
);
|
||||
|
||||
Row::new()
|
||||
.push(img1)
|
||||
.push(self.controls.view_progress(self.theme))
|
||||
.push(self.lyrics.view(self.theme))
|
||||
.push(img2)
|
||||
} else {
|
||||
Row::new()
|
||||
.push(self.controls.view_progress(self.theme))
|
||||
.push(self.lyrics.view(self.theme))
|
||||
};
|
||||
|
||||
Container::new(row)
|
||||
.style(self.theme)
|
||||
.height(Length::Fill)
|
||||
.into()
|
||||
}
|
||||
Container::new(scroller)
|
||||
.height(Length::Fill)
|
||||
.width(Length::Units(400))
|
||||
.center_y()
|
||||
.into()
|
||||
}
|
||||
|
||||
pub fn view_lyric(lyric: &mut Lyric, show_placeholder: bool, line_no: usize, theme: Theme) -> Row<Message> {
|
||||
|
||||
const SMALL_SIZE: u16 = 20;
|
||||
const LARGE_SIZE: u16 = 25;
|
||||
const TIMESTAMP_W: u16 = 67;
|
||||
const LINE_HEIGHT: u16 = 26;
|
||||
const TOTAL_W: u16 = 400;
|
||||
|
||||
let is_focused = lyric.is_selected();
|
||||
|
||||
let placeholder = if show_placeholder {
|
||||
"Paste some lyrics to get started"
|
||||
} else if is_focused {
|
||||
"..."
|
||||
} else { "" };
|
||||
|
||||
let size = if is_focused { LARGE_SIZE } else { SMALL_SIZE };
|
||||
|
||||
let timestamp_input = TextInput::new(
|
||||
&mut lyric.timestamp_state,
|
||||
"",
|
||||
&lyric.timestamp_raw,
|
||||
move|new_value| LyricEvent::TimestampChanged(new_value).into_msg(line_no),
|
||||
)
|
||||
.style(theme)
|
||||
.size(SMALL_SIZE)
|
||||
.width(Length::Units(TIMESTAMP_W))
|
||||
.on_submit(LyricEvent::LineAdvanced.into_msg(line_no))
|
||||
.font(FONT_VG5000)
|
||||
.into();
|
||||
|
||||
let text_input = TextInput::new(
|
||||
&mut lyric.main_state,
|
||||
placeholder,
|
||||
&lyric.value,
|
||||
move|new_value| LyricEvent::LyricChanged(new_value).into_msg(line_no),
|
||||
)
|
||||
.style(theme.active_lyric(is_focused))
|
||||
.size(size)
|
||||
.width(Length::Fill)
|
||||
.on_submit(LyricEvent::LineAdvanced.into_msg(line_no))
|
||||
.font(FONT_VG5000)
|
||||
.into();
|
||||
|
||||
let l_bracket = Text::new("[")
|
||||
.size(SMALL_SIZE)
|
||||
.color(theme.reduced_text_color())
|
||||
.font(FONT_VG5000)
|
||||
.into();
|
||||
let r_bracket = Text::new("] ")
|
||||
.size(SMALL_SIZE)
|
||||
.color(theme.reduced_text_color())
|
||||
.font(FONT_VG5000)
|
||||
.into();
|
||||
|
||||
Row::with_children(vec![l_bracket, timestamp_input, r_bracket, text_input])
|
||||
.width(Length::Units(TOTAL_W))
|
||||
.height(Length::Units(LINE_HEIGHT))
|
||||
.align_items(Alignment::Center)
|
||||
}
|
||||
|
||||
pub fn view_progress(controls: &Controls, theme: Theme) -> Canvas<Message, (&Controls, Theme)> {
|
||||
Canvas::new((&*controls, theme))
|
||||
.width(Length::Units(50))
|
||||
.height(Length::Fill)
|
||||
}
|
||||
|
|
|
@ -15,8 +15,7 @@ use iced::mouse;
|
|||
use iced::Color;
|
||||
use iced::Rectangle;
|
||||
use iced::Length;
|
||||
use iced::HorizontalAlignment;
|
||||
use iced::VerticalAlignment;
|
||||
use iced::alignment::{Vertical, Horizontal};
|
||||
use iced::widget::canvas::{self, Canvas};
|
||||
|
||||
/* RYGCBM
|
||||
|
@ -45,26 +44,16 @@ const FONT_MR_PIXEL: Font = Font::External {
|
|||
bytes: include_bytes!("../fonts/mister-pixel/mister-pixel.otf"),
|
||||
};
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
|
||||
pub struct FileSelector {
|
||||
tick: usize,
|
||||
pub fn view_fileselector(tick: usize) -> Element<'static, Message> {
|
||||
Canvas::new(tick)
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fill)
|
||||
.into()
|
||||
}
|
||||
|
||||
impl FileSelector {
|
||||
pub fn tick(&mut self) {
|
||||
self.tick = (self.tick + 1) % MAX_TICKS;
|
||||
}
|
||||
|
||||
pub fn view(&mut self) -> Element<Message> {
|
||||
Canvas::new(self)
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fill)
|
||||
.into()
|
||||
}
|
||||
}
|
||||
|
||||
impl Program<Message> for FileSelector {
|
||||
impl Program<Message> for usize {
|
||||
fn draw(&self, bounds: Rectangle, _cursor: Cursor) -> Vec<Geometry> {
|
||||
let tick = self;
|
||||
let offset_per_index = MAX_TICKS / COLORS.len();
|
||||
|
||||
const TEXT_RECT_W: f32 = 350.;
|
||||
|
@ -93,7 +82,7 @@ impl Program<Message> for FileSelector {
|
|||
.enumerate()
|
||||
.map(|(index, color)| {
|
||||
let size =
|
||||
((self.tick + offset_per_index * index) % MAX_TICKS) as f32 /
|
||||
((tick + offset_per_index * index) % MAX_TICKS) as f32 /
|
||||
MAX_TICKS as f32;
|
||||
|
||||
let top_left = interpolate(text_rect.0, Point::ORIGIN, size);
|
||||
|
@ -114,8 +103,8 @@ impl Program<Message> for FileSelector {
|
|||
frame.fill_text(
|
||||
Text {
|
||||
content: String::from("select a song to start"),
|
||||
horizontal_alignment: HorizontalAlignment::Center,
|
||||
vertical_alignment: VerticalAlignment::Center,
|
||||
horizontal_alignment: Horizontal::Center,
|
||||
vertical_alignment: Vertical::Center,
|
||||
size: 32.,
|
||||
color: Color::WHITE,
|
||||
font: FONT_MR_PIXEL,
|
||||
|
|
|
@ -3,13 +3,13 @@ use iced::settings::Settings;
|
|||
|
||||
mod palette;
|
||||
mod app;
|
||||
mod lyrics;
|
||||
mod styles;
|
||||
mod file_select;
|
||||
mod load_song;
|
||||
mod editor;
|
||||
mod player;
|
||||
mod controls;
|
||||
mod model;
|
||||
|
||||
fn main() {
|
||||
app::DelyriumApp::run(Settings::default()).unwrap();
|
||||
|
|
|
@ -1,19 +1,44 @@
|
|||
use iced::Space;
|
||||
use iced_native::text_input::Value;
|
||||
use image::GenericImageView;
|
||||
use core::ops::RangeInclusive;
|
||||
use core::time::Duration;
|
||||
use iced::Row;
|
||||
use iced::Text;
|
||||
use iced::Container;
|
||||
use iced::Length;
|
||||
use iced::Element;
|
||||
use crate::styles::{Theme, FONT_VG5000};
|
||||
use crate::app::Message;
|
||||
|
||||
use iced::widget::text_input::{self, TextInput};
|
||||
use iced::widget::scrollable::{self, Scrollable};
|
||||
use iced::Align;
|
||||
use iced_native::widget::text_input::Value;
|
||||
use iced_native::widget::text_input::cursor::State;
|
||||
use iced::image::Handle;
|
||||
use crate::palette::Palette;
|
||||
use crate::load_song::extract_cover;
|
||||
use iced::Command;
|
||||
use crate::app::Message;
|
||||
use std::time::Duration;
|
||||
use crate::styles::Theme;
|
||||
use crate::controls::Controls;
|
||||
use image::DynamicImage;
|
||||
use symphonia::core::formats::FormatReader;
|
||||
|
||||
use iced::widget::text_input;
|
||||
use iced::widget::scrollable;
|
||||
use iced::clipboard;
|
||||
use iced_lazy::responsive;
|
||||
|
||||
pub struct Editing {
|
||||
pub lyrics: Vec<Lyric>,
|
||||
pub bg_img: Option<DynamicImage>,
|
||||
pub controls: Controls,
|
||||
pub theme: Theme,
|
||||
|
||||
/* UI state */
|
||||
pub cached_resized_bg: Option<Handle>,
|
||||
pub scroll_state: scrollable::State,
|
||||
pub image_responsiveness_state: (responsive::State, responsive::State),
|
||||
}
|
||||
|
||||
pub struct Lyric {
|
||||
pub value: String,
|
||||
pub timestamp: Duration,
|
||||
pub timestamp_raw: String,
|
||||
|
||||
/* UI state */
|
||||
pub main_state: text_input::State,
|
||||
pub timestamp_state: text_input::State,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum LyricEvent {
|
||||
|
@ -23,7 +48,7 @@ pub enum LyricEvent {
|
|||
}
|
||||
|
||||
impl LyricEvent {
|
||||
fn into_msg(self, line_no: usize) -> Message {
|
||||
pub fn into_msg(self, line_no: usize) -> Message {
|
||||
Message::LyricEvent {
|
||||
kind: self,
|
||||
line_no,
|
||||
|
@ -31,20 +56,128 @@ impl LyricEvent {
|
|||
}
|
||||
}
|
||||
|
||||
pub struct Lyrics {
|
||||
lines: Vec<Lyric>,
|
||||
scroll_state: scrollable::State,
|
||||
}
|
||||
impl Editing {
|
||||
pub fn new(mut song: Box<dyn FormatReader>) -> (Self, Command<Message>) {
|
||||
let cover = extract_cover(song.as_mut());
|
||||
|
||||
impl Lyrics {
|
||||
pub fn new() -> Lyrics {
|
||||
let mut lyric = Lyric::new();
|
||||
lyric.select();
|
||||
let theme = cover.as_ref()
|
||||
.map(|cover| {
|
||||
Theme::from_palette(
|
||||
Palette::generate(cover)
|
||||
)
|
||||
}).unwrap_or_else(Theme::default);
|
||||
|
||||
Self {
|
||||
lines: vec![lyric],
|
||||
scroll_state: scrollable::State::new(),
|
||||
let cover = cover.map(|cover| {
|
||||
#[cfg(not(debug_assertions))]
|
||||
let cover = cover.blur((cover.width() / 100) as f32);
|
||||
|
||||
DynamicImage::ImageBgra8(cover.into_bgra8())
|
||||
});
|
||||
|
||||
let cached_resized_bg = cover.as_ref().map(|cover| Handle::from_pixels(
|
||||
cover.width(),
|
||||
cover.height(),
|
||||
cover.to_bgra8().into_raw(),
|
||||
));
|
||||
|
||||
let (controls, cmd) = Controls::new(song);
|
||||
|
||||
let mut lyrics = Vec::with_capacity(70);
|
||||
lyrics.push(Lyric::new());
|
||||
|
||||
let editor = Self {
|
||||
scroll_state: scrollable::State::default(),
|
||||
image_responsiveness_state: Default::default(),
|
||||
bg_img: cover,
|
||||
controls, theme, cached_resized_bg, lyrics,
|
||||
};
|
||||
|
||||
(editor, cmd)
|
||||
}
|
||||
|
||||
pub fn update(&mut self, message: Message) -> Command<Message> {
|
||||
let mut command = None;
|
||||
match message {
|
||||
Message::LyricEvent { line_no, kind: LyricEvent::LyricChanged(newval) } => {
|
||||
self.lyrics[line_no].value = newval
|
||||
},
|
||||
Message::LyricEvent { line_no, kind: LyricEvent::TimestampChanged(newval) } => {
|
||||
self.lyrics[line_no].timestamp_update(newval)
|
||||
},
|
||||
Message::LyricEvent { line_no, kind: LyricEvent::LineAdvanced } => {
|
||||
self.advance_line(line_no, self.controls.position())
|
||||
},
|
||||
Message::PasteSent => {
|
||||
command = Some(clipboard::read(|clip|
|
||||
if let Some(clip) = clip {
|
||||
Message::PasteRead(clip)
|
||||
} else {
|
||||
Message::Null
|
||||
}
|
||||
));
|
||||
},
|
||||
Message::PasteRead(clip_text) => {
|
||||
let clip_pasted_len = clip_text.chars()
|
||||
.filter(|c| *c != '\r' && *c != '\n')
|
||||
.count();
|
||||
if let Some(line) = self.current_line_mut() {
|
||||
line.value.truncate(line.value.len() - clip_pasted_len);
|
||||
self.insert_text(clip_text);
|
||||
}
|
||||
},
|
||||
Message::ControlsEvent(e) => {
|
||||
self.controls.handle_event(e)
|
||||
},
|
||||
Message::Null |
|
||||
Message::PromptForFile |
|
||||
Message::FileOpened(..) |
|
||||
Message::Tick => { },
|
||||
}
|
||||
|
||||
command.unwrap_or_else(Command::none)
|
||||
}
|
||||
|
||||
pub fn advance_line(&mut self, current_line: usize, timestamp: Option<Duration>) {
|
||||
let new_line = current_line + 1;
|
||||
|
||||
let line = if new_line == self.lyrics.len() {
|
||||
self.insert_line(new_line, None)
|
||||
} else {
|
||||
self.lyrics.get_mut(new_line)
|
||||
.expect("Unexpected .advance_line with index beyond # of lines")
|
||||
};
|
||||
|
||||
line.select();
|
||||
|
||||
let previous_line = self.lyrics.get_mut(current_line).unwrap();
|
||||
previous_line.deselect();
|
||||
if let Some(timestamp) = timestamp {
|
||||
previous_line.set_timestamp(timestamp);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn insert_line(&mut self, index: usize, content: Option<String>) -> &mut Lyric {
|
||||
self.lyrics.insert(index, match content {
|
||||
Some(content) => Lyric::new_with_value(content),
|
||||
None => Lyric::new(),
|
||||
});
|
||||
self.lyrics.get_mut(index).unwrap()
|
||||
}
|
||||
|
||||
pub fn current_line(&self) -> Option<usize> {
|
||||
self.lyrics
|
||||
.iter()
|
||||
.position(Lyric::is_selected)
|
||||
}
|
||||
|
||||
pub fn current_line_mut(&mut self) -> Option<&mut Lyric> {
|
||||
self.lyrics
|
||||
.iter_mut()
|
||||
.find(|l| l.is_selected())
|
||||
}
|
||||
|
||||
pub fn is_animating(&self) -> bool {
|
||||
self.controls.is_playing()
|
||||
}
|
||||
|
||||
pub fn insert_text(&mut self, text: String) {
|
||||
|
@ -53,107 +186,24 @@ impl Lyrics {
|
|||
.split('\n')
|
||||
.map(str::trim);
|
||||
|
||||
let (line_no, current_line) = self.current_line_mut();
|
||||
if let Some(line_no) = self.current_line() {
|
||||
let current_line = self.lyrics.get_mut(line_no).unwrap();
|
||||
|
||||
current_line.deselect();
|
||||
current_line.value.push_str(pieces.next().unwrap());
|
||||
current_line.deselect();
|
||||
current_line.value.push_str(pieces.next().unwrap());
|
||||
|
||||
let pieces = pieces
|
||||
.collect::<Vec<_>>()
|
||||
.into_iter()
|
||||
.map(str::to_owned)
|
||||
.map(Lyric::new_with_value);
|
||||
let n_pieces = pieces.size_hint().0;
|
||||
let pieces = pieces
|
||||
.collect::<Vec<_>>()
|
||||
.into_iter()
|
||||
.map(str::to_owned)
|
||||
.map(Lyric::new_with_value);
|
||||
let n_pieces = pieces.size_hint().0;
|
||||
|
||||
self.lines.splice((line_no + 1)..(line_no + 1), pieces);
|
||||
self.lyrics.splice((line_no + 1)..(line_no + 1), pieces);
|
||||
|
||||
self.lines[line_no + n_pieces].select();
|
||||
}
|
||||
|
||||
pub fn handle_event(&mut self, line_no: usize, kind: LyricEvent, timestamp: impl Fn() -> Option<Duration>) {
|
||||
match kind {
|
||||
LyricEvent::LyricChanged(newval) => {
|
||||
self.update_line(newval, line_no)
|
||||
},
|
||||
LyricEvent::TimestampChanged(newval) => {
|
||||
self.lines[line_no].timestamp_update(newval)
|
||||
},
|
||||
LyricEvent::LineAdvanced => {
|
||||
self.advance_line(line_no, timestamp())
|
||||
},
|
||||
self.lyrics[line_no + n_pieces].select();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn update_line(&mut self, new_content: String, line_no: usize) {
|
||||
self.lines[line_no].value = new_content;
|
||||
}
|
||||
|
||||
pub fn advance_line(&mut self, current_line: usize, timestamp: Option<Duration>) {
|
||||
let new_line = current_line + 1;
|
||||
|
||||
let line = if new_line == self.lines.len() {
|
||||
self.insert_line(new_line, None)
|
||||
} else {
|
||||
self.lines.get_mut(new_line)
|
||||
.expect("Unexpected .advance_line with index beyond # of lines")
|
||||
};
|
||||
|
||||
line.select();
|
||||
|
||||
let previous_line = self.lines.get_mut(current_line).unwrap();
|
||||
previous_line.deselect();
|
||||
if let Some(timestamp) = timestamp {
|
||||
previous_line.set_timestamp(timestamp);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn insert_line(&mut self, index: usize, content: Option<String>) -> &mut Lyric {
|
||||
self.lines.insert(index, match content {
|
||||
Some(content) => Lyric::new_with_value(content),
|
||||
None => Lyric::new(),
|
||||
});
|
||||
self.lines.get_mut(index).unwrap()
|
||||
}
|
||||
|
||||
pub fn current_line_mut(&mut self) -> (usize, &mut Lyric) {
|
||||
self.lines
|
||||
.iter_mut()
|
||||
.enumerate()
|
||||
.find(|(_, l)| l.is_selected())
|
||||
.expect("no line currently selected")
|
||||
}
|
||||
|
||||
pub fn view(&mut self, theme: Theme) -> Element<Message> {
|
||||
let is_sole_line = self.lines.len() == 1;
|
||||
let spacers = (
|
||||
Space::new(Length::Fill, Length::Units(30)),
|
||||
Space::new(Length::Fill, Length::Units(30)),
|
||||
);
|
||||
|
||||
let scroller = self.lines.iter_mut()
|
||||
.enumerate()
|
||||
.map(|(i, l)| l.view(is_sole_line, i, theme))
|
||||
.fold(Scrollable::new(&mut self.scroll_state).push(spacers.0), |s, l| s.push(l))
|
||||
.push(spacers.1)
|
||||
.width(Length::Fill)
|
||||
.align_items(Align::Center);
|
||||
|
||||
Container::new(scroller)
|
||||
.height(Length::Fill)
|
||||
.width(Length::Fill)
|
||||
.center_y()
|
||||
.into()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Lyric {
|
||||
main_state: text_input::State,
|
||||
timestamp_state: text_input::State,
|
||||
pub value: String,
|
||||
pub timestamp: Duration,
|
||||
timestamp_raw: String,
|
||||
}
|
||||
|
||||
impl Lyric {
|
||||
|
@ -171,66 +221,8 @@ impl Lyric {
|
|||
}
|
||||
}
|
||||
|
||||
pub fn view(&mut self, show_placeholder: bool, line_no: usize, theme: Theme) -> Element<Message> {
|
||||
|
||||
const SMALL_SIZE: u16 = 20;
|
||||
const LARGE_SIZE: u16 = 25;
|
||||
const TIMESTAMP_W: u16 = 67;
|
||||
const LINE_HEIGHT: u16 = 26;
|
||||
const TOTAL_W: u16 = 400;
|
||||
|
||||
let is_focused = self.is_selected();
|
||||
|
||||
let placeholder = if show_placeholder {
|
||||
"Paste some lyrics to get started"
|
||||
} else if is_focused {
|
||||
"..."
|
||||
} else { "" };
|
||||
|
||||
let size = if is_focused { LARGE_SIZE } else { SMALL_SIZE };
|
||||
|
||||
let timestamp_input = TextInput::new(
|
||||
&mut self.timestamp_state,
|
||||
"",
|
||||
&self.timestamp_raw,
|
||||
move|new_value| LyricEvent::TimestampChanged(new_value).into_msg(line_no),
|
||||
)
|
||||
.style(theme)
|
||||
.size(SMALL_SIZE)
|
||||
.width(Length::Units(TIMESTAMP_W))
|
||||
.on_submit(LyricEvent::LineAdvanced.into_msg(line_no))
|
||||
.font(FONT_VG5000)
|
||||
.into();
|
||||
|
||||
let text_input = TextInput::new(
|
||||
&mut self.main_state,
|
||||
placeholder,
|
||||
&self.value,
|
||||
move|new_value| LyricEvent::LyricChanged(new_value).into_msg(line_no),
|
||||
)
|
||||
.style(theme.active_lyric(is_focused))
|
||||
.size(size)
|
||||
.width(Length::Fill)
|
||||
.on_submit(LyricEvent::LineAdvanced.into_msg(line_no))
|
||||
.font(FONT_VG5000)
|
||||
.into();
|
||||
|
||||
let l_bracket = Text::new("[")
|
||||
.size(SMALL_SIZE)
|
||||
.color(theme.reduced_text_color())
|
||||
.font(FONT_VG5000)
|
||||
.into();
|
||||
let r_bracket = Text::new("] ")
|
||||
.size(SMALL_SIZE)
|
||||
.color(theme.reduced_text_color())
|
||||
.font(FONT_VG5000)
|
||||
.into();
|
||||
|
||||
Row::with_children(vec![l_bracket, timestamp_input, r_bracket, text_input])
|
||||
.width(Length::Units(TOTAL_W))
|
||||
.height(Length::Units(LINE_HEIGHT))
|
||||
.align_items(Align::Center)
|
||||
.into()
|
||||
pub fn is_selected(&self) -> bool {
|
||||
self.main_state.is_focused() || self.timestamp_state.is_focused()
|
||||
}
|
||||
|
||||
pub fn select(&mut self) {
|
||||
|
@ -238,10 +230,6 @@ impl Lyric {
|
|||
self.main_state.move_cursor_to_end();
|
||||
}
|
||||
|
||||
pub fn is_selected(&self) -> bool {
|
||||
self.main_state.is_focused() || self.timestamp_state.is_focused()
|
||||
}
|
||||
|
||||
pub fn deselect(&mut self) {
|
||||
self.main_state.unfocus();
|
||||
self.timestamp_state.unfocus();
|
|
@ -0,0 +1,61 @@
|
|||
pub mod editing;
|
||||
|
||||
use rfd::FileHandle;
|
||||
use rfd::AsyncFileDialog;
|
||||
use crate::load_song::load_song;
|
||||
use iced::Command;
|
||||
use crate::app::Message;
|
||||
|
||||
pub use editing::Editing;
|
||||
|
||||
// We allow a large enum variant here because this is only used in one place, so it's okay
|
||||
// to reserve the extra stack space for when FilePicker becomes Editing
|
||||
#[allow(clippy::large_enum_variant)]
|
||||
pub enum Model {
|
||||
FilePicker { tick: usize },
|
||||
Editing(Editing),
|
||||
}
|
||||
|
||||
const MAX_TICKS: usize = 900;
|
||||
|
||||
impl Model {
|
||||
pub const DEFAULT: Self = Model::FilePicker { tick: 0 };
|
||||
|
||||
pub fn update(&mut self, message: Message) -> Command<Message> {
|
||||
match self {
|
||||
Self::FilePicker { tick } => {
|
||||
match message {
|
||||
Message::Tick => {
|
||||
*tick = (*tick + 1) % MAX_TICKS;
|
||||
Command::none()
|
||||
},
|
||||
Message::FileOpened(path) => {
|
||||
println!("File opened! {}", path.display());
|
||||
let song = load_song(&path).unwrap().format;
|
||||
let (editing, cmd) = Editing::new(song);
|
||||
*self = Self::Editing(editing);
|
||||
cmd
|
||||
},
|
||||
Message::PromptForFile => {
|
||||
let show_dialog = AsyncFileDialog::new()
|
||||
.add_filter("Song Files", &["mp3", "flac", "ogg", "opus", "wav", "mkv"])
|
||||
.set_title("Select a song")
|
||||
.pick_file();
|
||||
|
||||
let to_message = |handle: Option<FileHandle>| if let Some(h) = handle {
|
||||
Message::FileOpened(h.path().to_owned())
|
||||
} else {
|
||||
Message::Null
|
||||
};
|
||||
|
||||
Command::perform(show_dialog, to_message)
|
||||
},
|
||||
_ => { Command::none() }
|
||||
}
|
||||
},
|
||||
Self::Editing(model) => {
|
||||
model.update(message)
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue