Compare commits
27 Commits
febc0737bf
...
main
Author | SHA1 | Date |
---|---|---|
Emi Simpson | 64ca3d972e | |
Emi Simpson | 436403d129 | |
Emi Simpson | 29d36a8010 | |
Emi Simpson | 746715be39 | |
Emi Simpson | b97ae618b0 | |
Emi Simpson | 415b4e7fc2 | |
Emi Simpson | 31d59fd0ee | |
Emi Simpson | eddf56ae64 | |
Emi Simpson | 18572a6eff | |
Emi Simpson | 020e6c4f92 | |
Emi Simpson | ace47d3225 | |
Emi Simpson | 5dd4841e55 | |
Emi Simpson | 34f548748a | |
Emi Simpson | c39b545029 | |
Emi Simpson | b5c1b088f3 | |
Emi Simpson | b715155b20 | |
Emi Simpson | 807289ca46 | |
Emi Simpson | b9883d2ad1 | |
Emi Simpson | 7e64590279 | |
Emi Simpson | 4e44e612ab | |
Emi Simpson | c211e79658 | |
Emi Simpson | 02fbd15f44 | |
Emi Simpson | 9b2a6ffde3 | |
Emi Simpson | bc19df18cb | |
Emi Simpson | a2f37bf3f3 | |
Emi Simpson | eecb67909a | |
Emi Simpson | 5724b25a88 |
|
@ -0,0 +1,4 @@
|
||||||
|
[submodule "iced"]
|
||||||
|
path = iced
|
||||||
|
url = https://github.com/Alch-Emi/iced.git
|
||||||
|
branch = image-modes
|
File diff suppressed because it is too large
Load Diff
31
Cargo.toml
31
Cargo.toml
|
@ -14,17 +14,40 @@ exoquant = "0.2.0"
|
||||||
image = "0.23.14"
|
image = "0.23.14"
|
||||||
|
|
||||||
# Display windows & graphics
|
# Display windows & graphics
|
||||||
iced_native = "0.4.0"
|
#iced_native = "0.4.0"
|
||||||
|
|
||||||
# Native file dialogs
|
# Native file dialogs
|
||||||
rfd = "0.6.3"
|
rfd = "0.6.3"
|
||||||
|
|
||||||
|
# Run long-running blocking tasks in another thread
|
||||||
|
# Also used by iced, so we share a threadpool
|
||||||
|
blocking = "1.1.0"
|
||||||
|
|
||||||
|
[dependencies.symphonia]
|
||||||
|
# Music decoding and metadata parsing
|
||||||
|
features = ["isomp4", "aac", "mp3"]
|
||||||
|
version = "0.4.0"
|
||||||
|
|
||||||
[dependencies.iced]
|
[dependencies.iced]
|
||||||
# Display windows & graphics
|
# Display windows & graphics
|
||||||
features = ["canvas"]
|
features = ["canvas", "image"]
|
||||||
version = "0.3.0"
|
path = "./iced/"
|
||||||
|
|
||||||
|
[dependencies.iced_native]
|
||||||
|
# Native-display only GUI features
|
||||||
|
path = "./iced/native"
|
||||||
|
|
||||||
|
[dependencies.iced_lazy]
|
||||||
|
# Responsive widget design
|
||||||
|
path = "./iced/lazy"
|
||||||
|
|
||||||
[dependencies.iced_futures]
|
[dependencies.iced_futures]
|
||||||
# Display windows & graphics
|
# Display windows & graphics
|
||||||
features = ["smol"]
|
features = ["smol"]
|
||||||
version = "0.3.0"
|
path = "./iced/futures"
|
||||||
|
|
||||||
|
[dependencies.rodio]
|
||||||
|
# Playing audio
|
||||||
|
default-features = false
|
||||||
|
features = ["symphonia-all"]
|
||||||
|
version = "0.15"
|
||||||
|
|
|
@ -0,0 +1,103 @@
|
||||||
|
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||||
|
This license is copied below, and is also available with a FAQ at:
|
||||||
|
http://scripts.sil.org/OFL
|
||||||
|
|
||||||
|
SIL Open Font License v1.1
|
||||||
|
====================================================
|
||||||
|
|
||||||
|
|
||||||
|
Preamble
|
||||||
|
----------
|
||||||
|
|
||||||
|
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||||
|
development of collaborative font projects, to support the font creation
|
||||||
|
efforts of academic and linguistic communities, and to provide a free and
|
||||||
|
open framework in which fonts may be shared and improved in partnership
|
||||||
|
with others.
|
||||||
|
|
||||||
|
The OFL allows the licensed fonts to be used, studied, modified and
|
||||||
|
redistributed freely as long as they are not sold by themselves. The
|
||||||
|
fonts, including any derivative works, can be bundled, embedded,
|
||||||
|
redistributed and/or sold with any software provided that any reserved
|
||||||
|
names are not used by derivative works. The fonts and derivatives,
|
||||||
|
however, cannot be released under any other type of license. The
|
||||||
|
requirement for fonts to remain under this license does not apply
|
||||||
|
to any document created using the fonts or their derivatives.
|
||||||
|
|
||||||
|
|
||||||
|
Definitions
|
||||||
|
-------------
|
||||||
|
|
||||||
|
`"Font Software"` refers to the set of files released by the Copyright
|
||||||
|
Holder(s) under this license and clearly marked as such. This may
|
||||||
|
include source files, build scripts and documentation.
|
||||||
|
|
||||||
|
`"Reserved Font Name"` refers to any names specified as such after the
|
||||||
|
copyright statement(s).
|
||||||
|
|
||||||
|
`"Original Version"` refers to the collection of Font Software components as
|
||||||
|
distributed by the Copyright Holder(s).
|
||||||
|
|
||||||
|
`"Modified Version"` refers to any derivative made by adding to, deleting,
|
||||||
|
or substituting -- in part or in whole -- any of the components of the
|
||||||
|
Original Version, by changing formats or by porting the Font Software to a
|
||||||
|
new environment.
|
||||||
|
|
||||||
|
`"Author"` refers to any designer, engineer, programmer, technical
|
||||||
|
writer or other person who contributed to the Font Software.
|
||||||
|
|
||||||
|
|
||||||
|
Permission & Conditions
|
||||||
|
------------------------
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining
|
||||||
|
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||||
|
redistribute, and sell modified and unmodified copies of the Font
|
||||||
|
Software, subject to the following conditions:
|
||||||
|
|
||||||
|
1. Neither the Font Software nor any of its individual components,
|
||||||
|
in Original or Modified Versions, may be sold by itself.
|
||||||
|
|
||||||
|
2. Original or Modified Versions of the Font Software may be bundled,
|
||||||
|
redistributed and/or sold with any software, provided that each copy
|
||||||
|
contains the above copyright notice and this license. These can be
|
||||||
|
included either as stand-alone text files, human-readable headers or
|
||||||
|
in the appropriate machine-readable metadata fields within text or
|
||||||
|
binary files as long as those fields can be easily viewed by the user.
|
||||||
|
|
||||||
|
3. No Modified Version of the Font Software may use the Reserved Font
|
||||||
|
Name(s) unless explicit written permission is granted by the corresponding
|
||||||
|
Copyright Holder. This restriction only applies to the primary font name as
|
||||||
|
presented to the users.
|
||||||
|
|
||||||
|
4. The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||||
|
Software shall not be used to promote, endorse or advertise any
|
||||||
|
Modified Version, except to acknowledge the contribution(s) of the
|
||||||
|
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||||
|
permission.
|
||||||
|
|
||||||
|
5. The Font Software, modified or unmodified, in part or in whole,
|
||||||
|
must be distributed entirely under this license, and must not be
|
||||||
|
distributed under any other license. The requirement for fonts to
|
||||||
|
remain under this license does not apply to any document created
|
||||||
|
using the Font Software.
|
||||||
|
|
||||||
|
|
||||||
|
Termination
|
||||||
|
-----------
|
||||||
|
|
||||||
|
This license becomes null and void if any of the above conditions are
|
||||||
|
not met.
|
||||||
|
|
||||||
|
|
||||||
|
DISCLAIMER
|
||||||
|
|
||||||
|
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||||
|
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||||
|
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||||
|
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||||
|
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||||
|
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||||
|
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||||
|
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||||
|
OTHER DEALINGS IN THE FONT SOFTWARE.
|
Binary file not shown.
|
@ -0,0 +1,105 @@
|
||||||
|
use crate::editor::view_editor;
|
||||||
|
use crate::file_select::view_fileselector;
|
||||||
|
use crate::model::Model;
|
||||||
|
use crate::controls::ControlsEvent;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use core::time::Duration;
|
||||||
|
use iced::Subscription;
|
||||||
|
use iced::Command;
|
||||||
|
use iced::Application;
|
||||||
|
use iced::Element;
|
||||||
|
use iced::executor;
|
||||||
|
use iced::time;
|
||||||
|
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(Model);
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub enum Message {
|
||||||
|
LyricEvent {
|
||||||
|
line_no: usize,
|
||||||
|
kind: LyricEvent,
|
||||||
|
},
|
||||||
|
PasteSent,
|
||||||
|
PasteRead(String),
|
||||||
|
Tick,
|
||||||
|
PromptForFile,
|
||||||
|
FileOpened(PathBuf),
|
||||||
|
ControlsEvent(ControlsEvent),
|
||||||
|
Null,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Application for DelyriumApp {
|
||||||
|
type Message = Message;
|
||||||
|
type Executor = executor::Default;
|
||||||
|
type Flags = ();
|
||||||
|
|
||||||
|
fn new(_: Self::Flags) -> (Self, Command<Message>) {
|
||||||
|
(
|
||||||
|
Self(Model::DEFAULT),
|
||||||
|
Command::none(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn title(&self) -> String {
|
||||||
|
String::from("Delyrium")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn update(&mut self, message: Message) -> Command<Message>{
|
||||||
|
self.0.update(message)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn view(&mut self) -> Element<Message> {
|
||||||
|
match &mut self.0 {
|
||||||
|
Model::Editing(editing) => {
|
||||||
|
view_editor(editing)
|
||||||
|
},
|
||||||
|
Model::FilePicker { tick } => {
|
||||||
|
view_fileselector(*tick)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn subscription(&self) -> Subscription<Message> {
|
||||||
|
let runtime_events = subscription::events_with(|event, _| {
|
||||||
|
match event {
|
||||||
|
Event::Keyboard(keyboard::Event::KeyPressed {key_code, modifiers}) => {
|
||||||
|
match (key_code, modifiers) {
|
||||||
|
(
|
||||||
|
keyboard::KeyCode::V,
|
||||||
|
modifiers
|
||||||
|
) if modifiers.control() => {
|
||||||
|
Some(Message::PasteSent)
|
||||||
|
}
|
||||||
|
_ => { None }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Event::Window(window::Event::FileDropped(path)) => {
|
||||||
|
Some(Message::FileOpened(path))
|
||||||
|
},
|
||||||
|
_ => { None }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let is_animating = if let Model::Editing(e) = &self.0 {
|
||||||
|
e.is_animating()
|
||||||
|
} else {
|
||||||
|
true
|
||||||
|
};
|
||||||
|
|
||||||
|
let fps30 = if is_animating {
|
||||||
|
time::every(Duration::from_millis(1000 / 30)).map(|_| Message::Tick)
|
||||||
|
} else {
|
||||||
|
Subscription::none()
|
||||||
|
};
|
||||||
|
|
||||||
|
Subscription::batch([
|
||||||
|
runtime_events,
|
||||||
|
fps30
|
||||||
|
])
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,152 +0,0 @@
|
||||||
use crate::app::Theme;
|
|
||||||
use crate::app::Message;
|
|
||||||
use crate::app::Element;
|
|
||||||
|
|
||||||
use iced::Align;
|
|
||||||
use iced::Length;
|
|
||||||
use iced::widget::text_input::{self, TextInput};
|
|
||||||
use iced::widget::scrollable::{self, Scrollable};
|
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
|
||||||
pub struct Lyrics {
|
|
||||||
lines: Vec<Lyric>,
|
|
||||||
scroll_state: scrollable::State,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Lyrics {
|
|
||||||
pub fn new() -> Self {
|
|
||||||
let mut lyric = Lyric::new();
|
|
||||||
lyric.select();
|
|
||||||
Lyrics {
|
|
||||||
lines: vec![lyric],
|
|
||||||
scroll_state: scrollable::State::new()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn insert_text(&mut self, text: String) {
|
|
||||||
|
|
||||||
let mut pieces = text.trim_end()
|
|
||||||
.split('\n')
|
|
||||||
.map(str::trim);
|
|
||||||
|
|
||||||
let (line_no, current_line) = self.current_line_mut();
|
|
||||||
|
|
||||||
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;
|
|
||||||
|
|
||||||
self.lines.splice((line_no + 1)..(line_no + 1), pieces);
|
|
||||||
|
|
||||||
self.lines[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) {
|
|
||||||
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();
|
|
||||||
|
|
||||||
self.lines.get_mut(current_line)
|
|
||||||
.unwrap()
|
|
||||||
.deselect();
|
|
||||||
}
|
|
||||||
|
|
||||||
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()
|
|
||||||
.filter(|(_, l)| l.is_selected())
|
|
||||||
.next()
|
|
||||||
.expect("no line currently selected")
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn view(&mut self, theme: Theme) -> Element<Message> {
|
|
||||||
let is_sole_line = self.lines.len() == 1;
|
|
||||||
self.lines.iter_mut()
|
|
||||||
.enumerate()
|
|
||||||
.map(|(i, l)| l.view(is_sole_line, i, theme))
|
|
||||||
.fold(Scrollable::new(&mut self.scroll_state), |s, l| s.push(l))
|
|
||||||
.width(Length::Fill)
|
|
||||||
.align_items(Align::Center)
|
|
||||||
.into()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
|
||||||
pub struct Lyric {
|
|
||||||
state: text_input::State,
|
|
||||||
pub value: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Lyric {
|
|
||||||
pub fn new() -> Self {
|
|
||||||
Self::new_with_value(String::with_capacity(70))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn new_with_value(val: String) -> Self {
|
|
||||||
Lyric {
|
|
||||||
state: text_input::State::new(),
|
|
||||||
value: val,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn view(&mut self, show_placeholder: bool, line_no: usize, theme: Theme) -> Element<Message> {
|
|
||||||
|
|
||||||
let placeholder = if show_placeholder {
|
|
||||||
"Paste some lyrics to get started"
|
|
||||||
} else if self.state.is_focused() {
|
|
||||||
"..."
|
|
||||||
} else { "" };
|
|
||||||
|
|
||||||
let size = if self.state.is_focused() { 30 } else { 20 };
|
|
||||||
|
|
||||||
TextInput::new(
|
|
||||||
&mut self.state,
|
|
||||||
placeholder,
|
|
||||||
&self.value,
|
|
||||||
move|new_value| Message::LyricChanged { line_no, new_value },
|
|
||||||
)
|
|
||||||
.style(theme)
|
|
||||||
.size(size)
|
|
||||||
.width(Length::Units(350))
|
|
||||||
.on_submit(Message::LineAdvanced(line_no))
|
|
||||||
.into()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn select(&mut self) {
|
|
||||||
self.state.focus();
|
|
||||||
self.state.move_cursor_to_end();
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn is_selected(&self) -> bool {
|
|
||||||
self.state.is_focused()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn deselect(&mut self) {
|
|
||||||
self.state.unfocus();
|
|
||||||
}
|
|
||||||
}
|
|
146
src/app/mod.rs
146
src/app/mod.rs
|
@ -1,146 +0,0 @@
|
||||||
use core::time::Duration;
|
|
||||||
use iced::Subscription;
|
|
||||||
use iced::Clipboard;
|
|
||||||
use iced::Command;
|
|
||||||
use iced::Application;
|
|
||||||
use iced::Container;
|
|
||||||
use iced::Row;
|
|
||||||
use iced::Element;
|
|
||||||
use iced::Length;
|
|
||||||
use iced::Align;
|
|
||||||
use iced::executor;
|
|
||||||
use iced_futures::time;
|
|
||||||
use iced_native::subscription;
|
|
||||||
use iced_native::keyboard;
|
|
||||||
use iced_native::event::Event;
|
|
||||||
|
|
||||||
mod lyrics;
|
|
||||||
mod styles;
|
|
||||||
mod file_select;
|
|
||||||
|
|
||||||
use styles::Theme;
|
|
||||||
use file_select::FileSelector;
|
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
|
||||||
pub struct DelyriumApp {
|
|
||||||
lyrics_component: lyrics::Lyrics,
|
|
||||||
theme: Theme,
|
|
||||||
mode: AppMode,
|
|
||||||
file_selector: file_select::FileSelector,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
|
||||||
enum AppMode {
|
|
||||||
FileSelect, Main
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
|
||||||
pub enum Message {
|
|
||||||
LyricChanged {
|
|
||||||
line_no: usize,
|
|
||||||
new_value: String,
|
|
||||||
},
|
|
||||||
LineAdvanced(usize),
|
|
||||||
PasteSent,
|
|
||||||
Tick,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Application for DelyriumApp {
|
|
||||||
type Message = Message;
|
|
||||||
type Executor = executor::Default;
|
|
||||||
type Flags = ();
|
|
||||||
|
|
||||||
fn new(_: Self::Flags) -> (Self, Command<Message>) {
|
|
||||||
(
|
|
||||||
DelyriumApp {
|
|
||||||
lyrics_component: lyrics::Lyrics::new(),
|
|
||||||
theme: Theme::default(),
|
|
||||||
mode: AppMode::FileSelect,
|
|
||||||
file_selector: FileSelector::default(),
|
|
||||||
},
|
|
||||||
Command::none(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn title(&self) -> String {
|
|
||||||
String::from("Delyrium")
|
|
||||||
}
|
|
||||||
|
|
||||||
fn update(&mut self, message: Message, clipboard: &mut Clipboard) -> Command<Message>{
|
|
||||||
match message {
|
|
||||||
Message::LyricChanged { line_no, new_value } => {
|
|
||||||
self.lyrics_component.update_line(new_value, line_no);
|
|
||||||
},
|
|
||||||
Message::LineAdvanced(current_line) => {
|
|
||||||
self.lyrics_component.advance_line(current_line);
|
|
||||||
},
|
|
||||||
Message::PasteSent => {
|
|
||||||
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 = self.lyrics_component.current_line_mut().1;
|
|
||||||
line.value.truncate(line.value.len() - clip_pasted_len);
|
|
||||||
self.lyrics_component.insert_text(clip_text);
|
|
||||||
},
|
|
||||||
Message::Tick => {
|
|
||||||
match self.mode {
|
|
||||||
AppMode::FileSelect => {
|
|
||||||
self.file_selector.tick();
|
|
||||||
},
|
|
||||||
_ => { },
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Command::none()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn view(&mut self) -> Element<Message> {
|
|
||||||
match self.mode {
|
|
||||||
AppMode::Main => {
|
|
||||||
Container::new(
|
|
||||||
Row::new()
|
|
||||||
.push(self.lyrics_component.view(self.theme))
|
|
||||||
)
|
|
||||||
.align_y(Align::Center)
|
|
||||||
.style(self.theme)
|
|
||||||
.height(Length::Fill)
|
|
||||||
.into()
|
|
||||||
},
|
|
||||||
AppMode::FileSelect => {
|
|
||||||
self.file_selector.view()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn subscription(&self) -> Subscription<Message> {
|
|
||||||
let runtime_events = subscription::events_with(|event, _| {
|
|
||||||
match event {
|
|
||||||
Event::Keyboard(keyboard::Event::KeyPressed {key_code, modifiers}) => {
|
|
||||||
match (key_code, modifiers) {
|
|
||||||
(
|
|
||||||
keyboard::KeyCode::V,
|
|
||||||
keyboard::Modifiers { control, .. }
|
|
||||||
) if control == true => {
|
|
||||||
Some(Message::PasteSent)
|
|
||||||
}
|
|
||||||
_ => { None }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ => { None }
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
let fps30 = time::every(Duration::from_millis(1000 / 30)).map(|_| Message::Tick);
|
|
||||||
|
|
||||||
match self.mode {
|
|
||||||
AppMode::FileSelect => {
|
|
||||||
Subscription::batch([
|
|
||||||
runtime_events,
|
|
||||||
fps30
|
|
||||||
])
|
|
||||||
},
|
|
||||||
AppMode::Main => runtime_events,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,60 +0,0 @@
|
||||||
use iced::Background;
|
|
||||||
use iced::Color;
|
|
||||||
use iced::widget::container;
|
|
||||||
use iced::widget::text_input;
|
|
||||||
|
|
||||||
const TRANSPARENT: Color = Color { r: 0., g: 0., b: 0., a: 0. };
|
|
||||||
|
|
||||||
#[derive(Copy, Clone, Debug)]
|
|
||||||
pub struct Theme {
|
|
||||||
pub base_color: Color,
|
|
||||||
pub text_color: Color,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for Theme {
|
|
||||||
fn default() -> Self {
|
|
||||||
Theme {
|
|
||||||
base_color: Color {r: 236. / 255., g: 63. / 255., b: 53. / 255., a: 1.},
|
|
||||||
text_color: Color {r: 1., g: 1., b: 1., a: 1.},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl container::StyleSheet for Theme {
|
|
||||||
fn style(&self) -> container::Style {
|
|
||||||
container::Style {
|
|
||||||
text_color: Some(self.text_color),
|
|
||||||
background: Some(Background::Color(self.base_color)),
|
|
||||||
..Default::default()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl text_input::StyleSheet for Theme {
|
|
||||||
fn active(&self) -> text_input::Style {
|
|
||||||
text_input::Style {
|
|
||||||
background: Background::Color(self.base_color),
|
|
||||||
border_radius: 0.,
|
|
||||||
border_width: 0.,
|
|
||||||
..Default::default()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn focused(&self) -> text_input::Style {
|
|
||||||
self.active()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn placeholder_color(&self) -> Color {
|
|
||||||
let mut color = self.text_color;
|
|
||||||
color.a = 0.5;
|
|
||||||
color
|
|
||||||
}
|
|
||||||
|
|
||||||
fn value_color(&self) -> Color {
|
|
||||||
self.text_color
|
|
||||||
}
|
|
||||||
|
|
||||||
fn selection_color(&self) -> Color {
|
|
||||||
self.text_color
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -0,0 +1,164 @@
|
||||||
|
use std::fs::File;
|
||||||
|
use iced::Command;
|
||||||
|
use core::time::Duration;
|
||||||
|
use iced::Length;
|
||||||
|
use iced::Color;
|
||||||
|
use iced::Point;
|
||||||
|
use crate::player::PlayerError;
|
||||||
|
use iced::canvas::event::Status;
|
||||||
|
use iced::canvas::Event;
|
||||||
|
use crate::styles::Theme;
|
||||||
|
use iced::canvas::Frame;
|
||||||
|
use iced::canvas::Geometry;
|
||||||
|
use iced::canvas::Cursor;
|
||||||
|
use iced::Rectangle;
|
||||||
|
use crate::app::Message;
|
||||||
|
use iced::canvas::Program;
|
||||||
|
use iced::Canvas;
|
||||||
|
use iced::mouse::{self, Button};
|
||||||
|
use crate::player::Player;
|
||||||
|
use blocking::unblock;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
pub enum ControlsEvent {
|
||||||
|
SeekPosition(f32),
|
||||||
|
TogglePlay,
|
||||||
|
DurationDiscovered(Duration),
|
||||||
|
}
|
||||||
|
|
||||||
|
pub enum ErrorState {
|
||||||
|
Error(PlayerError),
|
||||||
|
NoError {
|
||||||
|
player: Player,
|
||||||
|
has_device: bool
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
use ErrorState::*;
|
||||||
|
|
||||||
|
pub struct Controls(pub ErrorState);
|
||||||
|
|
||||||
|
impl Controls {
|
||||||
|
pub fn new(song: File) -> (Self, Command<Message>) {
|
||||||
|
match Player::new(song) {
|
||||||
|
Ok(player) => {
|
||||||
|
let duration_task = unblock(player.compute_duration());
|
||||||
|
let duration_cmd = Command::perform(
|
||||||
|
duration_task,
|
||||||
|
|d| Message::ControlsEvent(ControlsEvent::DurationDiscovered(d)),
|
||||||
|
);
|
||||||
|
|
||||||
|
(
|
||||||
|
Self(NoError {
|
||||||
|
has_device: player.has_output_device(),
|
||||||
|
player,
|
||||||
|
}),
|
||||||
|
duration_cmd
|
||||||
|
)
|
||||||
|
},
|
||||||
|
Err(e) => {
|
||||||
|
(Self(Error(e)), Command::none())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn view_progress(&mut self, theme: Theme) -> Canvas<Message, (&Self, Theme)> {
|
||||||
|
Canvas::new((&*self, theme))
|
||||||
|
.width(Length::Units(50))
|
||||||
|
.height(Length::Fill)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn handle_event(&mut self, event: ControlsEvent) {
|
||||||
|
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(),
|
||||||
|
ControlsEvent::DurationDiscovered(d) => {
|
||||||
|
player.set_duration(d);
|
||||||
|
println!("Found duration! {:?}", d);
|
||||||
|
Ok(player.has_output_device())
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
match result {
|
||||||
|
Ok(now_has_device) => {
|
||||||
|
*has_device = now_has_device;
|
||||||
|
},
|
||||||
|
Err(e) => {
|
||||||
|
self.0 = Error(e);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_playing(&self) -> bool {
|
||||||
|
if let NoError { player, has_device: true } = &self.0 {
|
||||||
|
player.is_playing()
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the current position of the playhead
|
||||||
|
///
|
||||||
|
/// 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.0 {
|
||||||
|
Some(player.position())
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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.0 {
|
||||||
|
NoError { player, has_device: true } => {
|
||||||
|
let mut background = self.1.text_color;
|
||||||
|
background.a = 0.2;
|
||||||
|
|
||||||
|
frame.fill_rectangle(Point::ORIGIN, bounds.size(), background);
|
||||||
|
|
||||||
|
let mut played_size = bounds.size();
|
||||||
|
played_size.height *= player.position_percent();
|
||||||
|
|
||||||
|
frame.fill_rectangle(Point::ORIGIN, played_size, self.1.text_color);
|
||||||
|
},
|
||||||
|
NoError { player: _, has_device: false } => {
|
||||||
|
let mut background = self.1.text_color;
|
||||||
|
background.a = 0.1;
|
||||||
|
|
||||||
|
frame.fill_rectangle(Point::ORIGIN, bounds.size(), background);
|
||||||
|
},
|
||||||
|
Error(e) => {
|
||||||
|
let background = Color {r: 1., g: 0.1, b: 0.1, a: 1.};
|
||||||
|
|
||||||
|
frame.fill_rectangle(Point::ORIGIN, bounds.size(), background);
|
||||||
|
eprintln!("Error!!! {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
vec![frame.into_geometry()]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn update(&mut self, event: Event, bounds: Rectangle<f32>, cursor: Cursor) -> (Status, Option<Message>) {
|
||||||
|
match (event, cursor) {
|
||||||
|
(Event::Mouse(mouse::Event::ButtonReleased(Button::Left)), Cursor::Available(pos))
|
||||||
|
if bounds.contains(pos) => {
|
||||||
|
let sought = (pos.y - bounds.position().y) / bounds.size().height;
|
||||||
|
|
||||||
|
(Status::Captured, Some(Message::ControlsEvent(ControlsEvent::SeekPosition(sought))))
|
||||||
|
},
|
||||||
|
(Event::Mouse(mouse::Event::ButtonReleased(Button::Right)), Cursor::Available(pos))
|
||||||
|
if bounds.contains(pos) => {
|
||||||
|
// TODO! This should be somewhere intuitive
|
||||||
|
(Status::Captured, Some(Message::ControlsEvent(ControlsEvent::TogglePlay)))
|
||||||
|
},
|
||||||
|
_ => (Status::Ignored, None),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,134 @@
|
||||||
|
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 iced::image::ContentFit;
|
||||||
|
use crate::styles::Theme;
|
||||||
|
use crate::controls::Controls;
|
||||||
|
use crate::model::Editing;
|
||||||
|
use iced::Image;
|
||||||
|
use crate::app::Message;
|
||||||
|
use iced::Element;
|
||||||
|
use iced::Container;
|
||||||
|
use iced::Row;
|
||||||
|
use iced::Length;
|
||||||
|
|
||||||
|
pub fn view_editor(editing: &mut Editing) -> Element<Message> {
|
||||||
|
let row = if let Some(margin_bg) = &editing.cached_resized_bg {
|
||||||
|
|
||||||
|
let (img1, img2) = (
|
||||||
|
Image::new(margin_bg.clone())
|
||||||
|
.width(Length::FillPortion(1))
|
||||||
|
.height(Length::Fill)
|
||||||
|
.fit(ContentFit::Cover),
|
||||||
|
Image::new(margin_bg.clone())
|
||||||
|
.width(Length::FillPortion(1))
|
||||||
|
.height(Length::Fill)
|
||||||
|
.fit(ContentFit::Cover),
|
||||||
|
);
|
||||||
|
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
|
||||||
|
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 scroller = lyrics.iter_mut()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(i, l)| view_lyric(l, is_sole_line, i, theme))
|
||||||
|
.fold(Scrollable::new(scroll_state).push(spacers.0), Scrollable::push)
|
||||||
|
.push(spacers.1)
|
||||||
|
.width(Length::Fill)
|
||||||
|
.align_items(Alignment::Center);
|
||||||
|
|
||||||
|
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))
|
||||||
|
.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))
|
||||||
|
.into();
|
||||||
|
|
||||||
|
let l_bracket = Text::new("[")
|
||||||
|
.size(SMALL_SIZE)
|
||||||
|
.color(theme.reduced_text_color())
|
||||||
|
.into();
|
||||||
|
let r_bracket = Text::new("] ")
|
||||||
|
.size(SMALL_SIZE)
|
||||||
|
.color(theme.reduced_text_color())
|
||||||
|
.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)
|
||||||
|
}
|
|
@ -0,0 +1,165 @@
|
||||||
|
use iced::canvas::event::Status;
|
||||||
|
use iced::Font;
|
||||||
|
use iced::canvas::Text;
|
||||||
|
use iced::canvas::Stroke;
|
||||||
|
use iced::Size;
|
||||||
|
use iced::Point;
|
||||||
|
use iced::canvas::Path;
|
||||||
|
use iced::canvas::Frame;
|
||||||
|
use iced::canvas::Geometry;
|
||||||
|
use iced::canvas::Cursor;
|
||||||
|
use iced::Element;
|
||||||
|
use crate::app::Message;
|
||||||
|
use iced::canvas::Program;
|
||||||
|
use iced::mouse;
|
||||||
|
use iced::Color;
|
||||||
|
use iced::Rectangle;
|
||||||
|
use iced::Length;
|
||||||
|
use iced::alignment::{Vertical, Horizontal};
|
||||||
|
use iced::widget::canvas::{self, Canvas};
|
||||||
|
|
||||||
|
/* RYGCBM
|
||||||
|
const COLORS: [Color; 6] = [
|
||||||
|
Color { r: 1., g: 0., b: 0., a: 1. },
|
||||||
|
Color { r: 1., g: 1., b: 0., a: 1. },
|
||||||
|
Color { r: 0., g: 1., b: 0., a: 1. },
|
||||||
|
Color { r: 0., g: 1., b: 1., a: 1. },
|
||||||
|
Color { r: 0., g: 0., b: 1., a: 1. },
|
||||||
|
Color { r: 1., g: 0., b: 1., a: 1. },
|
||||||
|
];*/
|
||||||
|
#[allow(clippy::eq_op)]
|
||||||
|
const COLORS: [Color; 6] = [
|
||||||
|
Color { r: 255./255., g: 129./255., b: 126./255., a: 1. },
|
||||||
|
Color { r: 239./255., g: 190./255., b: 125./255., a: 1. },
|
||||||
|
Color { r: 233./255., g: 236./255., b: 107./255., a: 1. },
|
||||||
|
Color { r: 119./255., g: 221./255., b: 119./255., a: 1. },
|
||||||
|
Color { r: 139./255., g: 211./255., b: 230./255., a: 1. },
|
||||||
|
Color { r: 177./255., g: 162./255., b: 202./255., a: 1. },
|
||||||
|
];
|
||||||
|
|
||||||
|
const MAX_TICKS: usize = 900;
|
||||||
|
|
||||||
|
const FONT_MR_PIXEL: Font = Font::External {
|
||||||
|
name: "Mister Pixel",
|
||||||
|
bytes: include_bytes!("../fonts/mister-pixel/mister-pixel.otf"),
|
||||||
|
};
|
||||||
|
|
||||||
|
pub fn view_fileselector(tick: usize) -> Element<'static, Message> {
|
||||||
|
Canvas::new(tick)
|
||||||
|
.width(Length::Fill)
|
||||||
|
.height(Length::Fill)
|
||||||
|
.into()
|
||||||
|
}
|
||||||
|
|
||||||
|
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.;
|
||||||
|
const TEXT_RECT_H: f32 = 50.;
|
||||||
|
const RECT_RATIO: f32 = TEXT_RECT_W / TEXT_RECT_H;
|
||||||
|
let text_rect_scale = TEXT_RECT_W / bounds.width;
|
||||||
|
let text_rect: (Point, Size) = centered_rectangle(&bounds, RECT_RATIO, text_rect_scale);
|
||||||
|
let text_rect_far_point = Point {
|
||||||
|
x: text_rect.0.x + text_rect.1.width,
|
||||||
|
y: text_rect.0.y + text_rect.1.height,
|
||||||
|
};
|
||||||
|
let bound_far_point: Point = Point {
|
||||||
|
x: bounds.x + bounds.width,
|
||||||
|
y: bounds.y + bounds.height,
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut frame = Frame::new(bounds.size());
|
||||||
|
|
||||||
|
frame.fill_rectangle(
|
||||||
|
Point::new(bounds.x, bounds.y),
|
||||||
|
Size::new(bounds.width, bounds.height),
|
||||||
|
Color::WHITE,
|
||||||
|
);
|
||||||
|
|
||||||
|
COLORS.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(index, color)| {
|
||||||
|
let size =
|
||||||
|
((tick + offset_per_index * index) % MAX_TICKS) as f32 /
|
||||||
|
MAX_TICKS as f32;
|
||||||
|
|
||||||
|
let top_left = interpolate(text_rect.0, Point::ORIGIN, size);
|
||||||
|
let far_corner = interpolate(text_rect_far_point, bound_far_point, size);
|
||||||
|
let rect_size = Size::new(far_corner.x - top_left.x, far_corner.y - top_left.y);
|
||||||
|
|
||||||
|
(Path::rectangle(top_left, rect_size), color)
|
||||||
|
})
|
||||||
|
.for_each(|(rect, color)|
|
||||||
|
frame.stroke(
|
||||||
|
&rect,
|
||||||
|
Stroke::default()
|
||||||
|
.with_color(*color)
|
||||||
|
.with_width(5.)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
frame.fill_text(
|
||||||
|
Text {
|
||||||
|
content: String::from("select a song to start"),
|
||||||
|
horizontal_alignment: Horizontal::Center,
|
||||||
|
vertical_alignment: Vertical::Center,
|
||||||
|
size: 32.,
|
||||||
|
color: Color::WHITE,
|
||||||
|
font: FONT_MR_PIXEL,
|
||||||
|
position: Point::new(
|
||||||
|
bounds.x + bounds.width * 0.5,
|
||||||
|
bounds.y + bounds.height * 0.5,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
frame.fill(
|
||||||
|
&Path::rectangle(text_rect.0, text_rect.1),
|
||||||
|
Color::BLACK,
|
||||||
|
);
|
||||||
|
frame.stroke(
|
||||||
|
&Path::rectangle(text_rect.0, text_rect.1),
|
||||||
|
Stroke::default()
|
||||||
|
.with_color(Color::BLACK)
|
||||||
|
.with_width(2.)
|
||||||
|
);
|
||||||
|
|
||||||
|
vec![frame.into_geometry()]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn update(
|
||||||
|
&mut self,
|
||||||
|
event: canvas::Event,
|
||||||
|
_: Rectangle<f32>,
|
||||||
|
_: Cursor
|
||||||
|
) -> (Status, Option<Message>) {
|
||||||
|
match event {
|
||||||
|
canvas::Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)) => {
|
||||||
|
(Status::Captured, Some(Message::PromptForFile))
|
||||||
|
},
|
||||||
|
_ => (Status::Ignored, None),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn centered_rectangle(bounds: &Rectangle, ratio: f32, scale: f32) -> (Point, Size) {
|
||||||
|
let center_x = bounds.x + 0.5 * bounds.width;
|
||||||
|
let center_y = bounds.y + 0.5 * bounds.height;
|
||||||
|
let width = bounds.width * scale;
|
||||||
|
let height = width / ratio;
|
||||||
|
let top_x = center_x - width * 0.5;
|
||||||
|
let top_y = center_y - height * 0.5;
|
||||||
|
(
|
||||||
|
Point::new(top_x, top_y),
|
||||||
|
Size::new(width, height),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn interpolate(from: Point, to: Point, amount: f32) -> Point {
|
||||||
|
Point {
|
||||||
|
x: from.x + amount * (to.x - from.x),
|
||||||
|
y: from.y + amount * (to.y - from.y),
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,100 @@
|
||||||
|
use std::fs::File;
|
||||||
|
use image::DynamicImage;
|
||||||
|
use std::error::Error;
|
||||||
|
use std::fs::OpenOptions;
|
||||||
|
use std::path::Path;
|
||||||
|
use std::io;
|
||||||
|
use std::fmt;
|
||||||
|
use std::ffi::OsStr;
|
||||||
|
use symphonia::default;
|
||||||
|
use symphonia::core::io::MediaSourceStream;
|
||||||
|
use symphonia::core::probe::{Hint, ProbeResult};
|
||||||
|
use symphonia::core::meta::StandardVisualKey;
|
||||||
|
use symphonia::core::formats::FormatReader;
|
||||||
|
|
||||||
|
pub fn load_song(path: &Path) -> Result<(File, ProbeResult), LoadError> {
|
||||||
|
let probe = default::get_probe();
|
||||||
|
|
||||||
|
let file = OpenOptions::new()
|
||||||
|
.read(true)
|
||||||
|
.write(false)
|
||||||
|
.open(path)
|
||||||
|
.map_err(LoadError::OpenError)?;
|
||||||
|
let media_source_stream = MediaSourceStream::new(
|
||||||
|
Box::new(
|
||||||
|
file.try_clone()
|
||||||
|
.map_err(LoadError::OpenError)?
|
||||||
|
),
|
||||||
|
Default::default()
|
||||||
|
);
|
||||||
|
|
||||||
|
let ext_hint = path.extension()
|
||||||
|
.and_then(OsStr::to_str)
|
||||||
|
.map_or_else(
|
||||||
|
Hint::new,
|
||||||
|
|ext| {
|
||||||
|
let mut h = Hint::new();
|
||||||
|
h.with_extension(ext);
|
||||||
|
h
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
probe.format(
|
||||||
|
&ext_hint,
|
||||||
|
media_source_stream,
|
||||||
|
&Default::default(),
|
||||||
|
&Default::default(),
|
||||||
|
).map_err(LoadError::SymphoniaError)
|
||||||
|
.map(|fr| (file, fr))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn extract_cover(format: &mut dyn FormatReader) -> Option<DynamicImage>{
|
||||||
|
#[allow(clippy::zero_prefixed_literal)]
|
||||||
|
format.metadata()
|
||||||
|
.current()
|
||||||
|
.into_iter()
|
||||||
|
// Replace this whole closure with MetadataRef::usage once we update
|
||||||
|
.flat_map(|meta| meta.visuals().iter().map(|v|(v.data.clone(), v.usage)).collect::<Vec<_>>())
|
||||||
|
.filter_map(|(data, usage)| image::load_from_memory(&data).ok().map(|img| (usage, img)))
|
||||||
|
.max_by_key(|(usage, _)|
|
||||||
|
usage.map(|usage| match usage {
|
||||||
|
StandardVisualKey::FrontCover => 00,
|
||||||
|
StandardVisualKey::FileIcon => 01,
|
||||||
|
StandardVisualKey::Illustration => 02,
|
||||||
|
StandardVisualKey::BandArtistLogo => 03,
|
||||||
|
StandardVisualKey::BackCover => 04,
|
||||||
|
StandardVisualKey::Media => 05,
|
||||||
|
StandardVisualKey::Leaflet => 06,
|
||||||
|
StandardVisualKey::OtherIcon => 07,
|
||||||
|
StandardVisualKey::LeadArtistPerformerSoloist => 08,
|
||||||
|
StandardVisualKey::ArtistPerformer => 09,
|
||||||
|
StandardVisualKey::Conductor => 10,
|
||||||
|
StandardVisualKey::BandOrchestra => 11,
|
||||||
|
StandardVisualKey::Composer => 12,
|
||||||
|
StandardVisualKey::Lyricist => 13,
|
||||||
|
StandardVisualKey::RecordingLocation => 14,
|
||||||
|
StandardVisualKey::RecordingSession => 15,
|
||||||
|
StandardVisualKey::Performance => 16,
|
||||||
|
StandardVisualKey::ScreenCapture => 17,
|
||||||
|
StandardVisualKey::PublisherStudioLogo => 18,
|
||||||
|
}).unwrap_or(u32::MAX)
|
||||||
|
)
|
||||||
|
.map(|(_, img)| img)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub enum LoadError {
|
||||||
|
OpenError(io::Error),
|
||||||
|
SymphoniaError(symphonia::core::errors::Error),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for LoadError {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
|
||||||
|
match self {
|
||||||
|
Self::OpenError(e) => write!(f, "Problem opening song: {}", e),
|
||||||
|
Self::SymphoniaError(e) => write!(f, "Symphonia encountered a problem: {}", e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Error for LoadError {}
|
17
src/main.rs
17
src/main.rs
|
@ -1,9 +1,24 @@
|
||||||
use iced::Application;
|
use iced::Application;
|
||||||
use iced::settings::Settings;
|
use iced::settings::Settings;
|
||||||
|
use iced::window;
|
||||||
|
|
||||||
mod palette;
|
mod palette;
|
||||||
mod app;
|
mod app;
|
||||||
|
mod styles;
|
||||||
|
mod file_select;
|
||||||
|
mod load_song;
|
||||||
|
mod editor;
|
||||||
|
mod player;
|
||||||
|
mod controls;
|
||||||
|
mod model;
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
app::DelyriumApp::run(Settings::default()).unwrap();
|
app::DelyriumApp::run(Settings {
|
||||||
|
default_font: Some(include_bytes!("../fonts/vg5000/VG5000.otf")),
|
||||||
|
window: window::Settings {
|
||||||
|
min_size: Some((450, 0)),
|
||||||
|
..window::Settings::default()
|
||||||
|
},
|
||||||
|
..Settings::default()
|
||||||
|
}).unwrap();
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,369 @@
|
||||||
|
use std::io::Seek;
|
||||||
|
use crate::load_song::LoadError;
|
||||||
|
use crate::model::load_song;
|
||||||
|
use std::path::Path;
|
||||||
|
use image::GenericImageView;
|
||||||
|
use core::ops::RangeInclusive;
|
||||||
|
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 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 {
|
||||||
|
LyricChanged(String),
|
||||||
|
TimestampChanged(String),
|
||||||
|
LineAdvanced,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LyricEvent {
|
||||||
|
pub fn into_msg(self, line_no: usize) -> Message {
|
||||||
|
Message::LyricEvent {
|
||||||
|
kind: self,
|
||||||
|
line_no,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Editing {
|
||||||
|
pub fn new(song_path: &Path) -> Result<(Self, Command<Message>), LoadError> {
|
||||||
|
let (mut file, fr) = load_song(song_path)?;
|
||||||
|
let mut fr = fr.format;
|
||||||
|
|
||||||
|
let cover = extract_cover(fr.as_mut());
|
||||||
|
|
||||||
|
let theme = cover.as_ref()
|
||||||
|
.map_or_else(
|
||||||
|
Theme::default,
|
||||||
|
|cover|
|
||||||
|
Theme::from_palette(
|
||||||
|
&Palette::generate(cover)
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
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(),
|
||||||
|
));
|
||||||
|
|
||||||
|
// Reset file handle and drop the format reader
|
||||||
|
file.rewind().map_err(LoadError::OpenError)?;
|
||||||
|
|
||||||
|
let (controls, cmd) = Controls::new(file);
|
||||||
|
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok((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| clip.map_or(
|
||||||
|
Message::Null,
|
||||||
|
Message::PasteRead,
|
||||||
|
)));
|
||||||
|
},
|
||||||
|
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: &str) {
|
||||||
|
|
||||||
|
let mut pieces = text.trim_end()
|
||||||
|
.split('\n')
|
||||||
|
.map(str::trim);
|
||||||
|
|
||||||
|
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());
|
||||||
|
|
||||||
|
let pieces = pieces
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.into_iter()
|
||||||
|
.map(str::to_owned)
|
||||||
|
.map(Lyric::new_with_value);
|
||||||
|
let n_pieces = pieces.size_hint().0;
|
||||||
|
|
||||||
|
self.lyrics.splice((line_no + 1)..(line_no + 1), pieces);
|
||||||
|
|
||||||
|
self.lyrics[line_no + n_pieces].select();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Lyric {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self::new_with_value(String::with_capacity(70))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn new_with_value(val: String) -> Self {
|
||||||
|
Self {
|
||||||
|
main_state: text_input::State::new(),
|
||||||
|
timestamp_state: text_input::State::new(),
|
||||||
|
timestamp: Duration::ZERO,
|
||||||
|
timestamp_raw: String::from("0:00.000"),
|
||||||
|
value: val,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_selected(&self) -> bool {
|
||||||
|
self.main_state.is_focused() || self.timestamp_state.is_focused()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn select(&mut self) {
|
||||||
|
self.main_state.focus();
|
||||||
|
self.main_state.move_cursor_to_end();
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn deselect(&mut self) {
|
||||||
|
self.main_state.unfocus();
|
||||||
|
self.timestamp_state.unfocus();
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_timestamp(&mut self, timestamp: Duration) {
|
||||||
|
self.timestamp = timestamp;
|
||||||
|
let seconds = timestamp.as_secs();
|
||||||
|
let minutes = seconds / 60;
|
||||||
|
let seconds = seconds % 60;
|
||||||
|
let millis = timestamp.as_millis() % 1000;
|
||||||
|
self.timestamp_raw = format!("{}:{:02}.{:03}", minutes, seconds, millis);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub (crate) fn timestamp_update(&mut self, newval: String) {
|
||||||
|
if let Some((shift, validated)) = Self::clean_timestamp(newval) {
|
||||||
|
self.timestamp = dbg!(Self::parse_validated_timestamp(&validated));
|
||||||
|
self.timestamp_raw = validated;
|
||||||
|
if shift != 0 {
|
||||||
|
self.timestamp_state.move_cursor_to(
|
||||||
|
match self.timestamp_state.cursor().state(&Value::new(&self.timestamp_raw)) {
|
||||||
|
State::Index(p) => ((p as isize) + shift) as usize,
|
||||||
|
State::Selection { start, ..} => {
|
||||||
|
// Should be impossible, but lets handle it anyway
|
||||||
|
((start as isize) + shift) as usize
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn clean_timestamp(mut raw: String) -> Option<(isize, String)> {
|
||||||
|
// Rules:
|
||||||
|
// - [R0] Must have exactly 1 colon (:)
|
||||||
|
// - [R1] Must have exactly 1 period (.)
|
||||||
|
// - [R2] The period must follow the colon
|
||||||
|
// - [R3] No characters outside 0-9, colon, and period
|
||||||
|
// - [R4] Unnecessary leading zeros besides normal padding are trimmed
|
||||||
|
// - [R5] Each section is padded to the appropriate length (reversed for millis)
|
||||||
|
const VALID_CHARS: RangeInclusive<char> = '0'..=':';
|
||||||
|
const MIN_DIGIT_COUNTS: [usize; 3] = [1, 2, 3];
|
||||||
|
let mut colon_count = 0;
|
||||||
|
let mut period_count = 0;
|
||||||
|
let mut digit_counts = [0; 3];
|
||||||
|
for c in raw.chars() {
|
||||||
|
match c {
|
||||||
|
':' => {
|
||||||
|
colon_count += 1;
|
||||||
|
if colon_count > 1 {
|
||||||
|
return None; // Rejected [R0]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'.' => {
|
||||||
|
period_count += 1;
|
||||||
|
if colon_count == 0 /* [R2] */ || period_count > 1 /* [R1] */ {
|
||||||
|
return None; // Rejected
|
||||||
|
}
|
||||||
|
},
|
||||||
|
_ if VALID_CHARS.contains(&c) || c == '.' => {
|
||||||
|
let section = colon_count + period_count;
|
||||||
|
digit_counts[section] += 1;
|
||||||
|
},
|
||||||
|
_ => {
|
||||||
|
return None; // Rejected [R3]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if period_count == 0 {
|
||||||
|
return None; //Rejected [R1]
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut i = 0;
|
||||||
|
let mut cursor_shift = 0;
|
||||||
|
for section in 0..3 {
|
||||||
|
while digit_counts[section] < MIN_DIGIT_COUNTS[section] {
|
||||||
|
// [R5]
|
||||||
|
if section == 2 {
|
||||||
|
raw.push('0');
|
||||||
|
} else {
|
||||||
|
raw.insert(i, '0');
|
||||||
|
cursor_shift += 1;
|
||||||
|
}
|
||||||
|
digit_counts[section] += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
while
|
||||||
|
digit_counts[section] > MIN_DIGIT_COUNTS[section]
|
||||||
|
&& if section == 2 {
|
||||||
|
raw.ends_with('0')
|
||||||
|
} else {
|
||||||
|
raw.chars().nth(i).unwrap() == '0'
|
||||||
|
}
|
||||||
|
{
|
||||||
|
// [R4]
|
||||||
|
if section == 2 {
|
||||||
|
raw.truncate(raw.len() - 1);
|
||||||
|
} else {
|
||||||
|
raw.remove(i);
|
||||||
|
cursor_shift -= 1;
|
||||||
|
}
|
||||||
|
digit_counts[section] -= 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
i += digit_counts[section] + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
Some((cursor_shift, raw))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_validated_timestamp(s: &str) -> Duration {
|
||||||
|
let (minutes, s) = s.split_at(s.find(':').expect(
|
||||||
|
"parse_validated_timestamp received a timestamp without a :"
|
||||||
|
));
|
||||||
|
let (seconds, millis) = s.split_at(s.find('.').expect(
|
||||||
|
"parse_validated_timestamp received a timestamp without a . after the :"
|
||||||
|
));
|
||||||
|
|
||||||
|
let minutes: u64 = minutes.parse()
|
||||||
|
.expect("parse_validated_timestamp received an invalid number of minutes");
|
||||||
|
let seconds: u64 = seconds[1..].parse()
|
||||||
|
.expect("parse_validated_timestamp received an invalid number of seconds");
|
||||||
|
let millis: u32 = millis[1..4].parse()
|
||||||
|
.expect("parse_validated_timestamp received an invalid number of millis");
|
||||||
|
|
||||||
|
Duration::new(seconds + minutes * 60, millis * 1_000_000)
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,67 @@
|
||||||
|
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 = Self::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());
|
||||||
|
match Editing::new(&path) {
|
||||||
|
Ok((editing, cmd)) => {
|
||||||
|
*self = Self::Editing(editing);
|
||||||
|
cmd
|
||||||
|
},
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("yikeys! Problem opening doodad: {}", e);
|
||||||
|
//TODO! Report the error on the file loader
|
||||||
|
Command::none()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
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>| handle.map_or(
|
||||||
|
Message::Null,
|
||||||
|
|h| Message::FileOpened(h.path().to_owned()),
|
||||||
|
);
|
||||||
|
|
||||||
|
Command::perform(show_dialog, to_message)
|
||||||
|
},
|
||||||
|
_ => { Command::none() }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Self::Editing(model) => {
|
||||||
|
model.update(message)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -18,7 +18,9 @@ pub struct Palette {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Palette {
|
impl Palette {
|
||||||
pub fn generate(img: DynamicImage) -> Self {
|
pub fn generate(img: &DynamicImage) -> Self {
|
||||||
|
|
||||||
|
let thumb;
|
||||||
|
|
||||||
// Scale the image down if it's too big
|
// Scale the image down if it's too big
|
||||||
let thumb = if img.width() * img.height() > MAX_SIZE_PIXELS {
|
let thumb = if img.width() * img.height() > MAX_SIZE_PIXELS {
|
||||||
|
@ -29,20 +31,21 @@ impl Palette {
|
||||||
let new_width = (ratio * RESIZE_TARGET_PIXELS).sqrt();
|
let new_width = (ratio * RESIZE_TARGET_PIXELS).sqrt();
|
||||||
let new_height = RESIZE_TARGET_PIXELS / new_width;
|
let new_height = RESIZE_TARGET_PIXELS / new_width;
|
||||||
|
|
||||||
img.thumbnail(new_width as u32, new_height as u32)
|
thumb = img.thumbnail(new_width as u32, new_height as u32);
|
||||||
|
&thumb
|
||||||
} else {
|
} else {
|
||||||
img
|
img
|
||||||
};
|
};
|
||||||
|
|
||||||
// Convert to exoquant image
|
// Convert to exoquant image
|
||||||
let width = thumb.width();
|
let width = thumb.width();
|
||||||
let exo_img: Vec<Color> = thumb.into_rgb8()
|
let exo_img: Vec<Color> = thumb.to_rgb8()
|
||||||
.pixels()
|
.pixels()
|
||||||
.map(|p| Color {r: p[0], g: p[1], b: p[2], a: 255})
|
.map(|p| Color {r: p[0], g: p[1], b: p[2], a: 255})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
// Generate histogram
|
// Generate histogram
|
||||||
let histogram = exo_img.iter().cloned().collect();
|
let histogram = exo_img.iter().copied().collect();
|
||||||
|
|
||||||
// Generate raw palette
|
// Generate raw palette
|
||||||
let colorspace = SimpleColorSpace::default();
|
let colorspace = SimpleColorSpace::default();
|
||||||
|
@ -78,7 +81,7 @@ impl Palette {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn dominant_color(&self) -> &Rgb<u8> {
|
pub fn dominant_color(&self) -> Rgb<u8> {
|
||||||
let max_index = self.color_frequencies
|
let max_index = self.color_frequencies
|
||||||
.iter()
|
.iter()
|
||||||
.enumerate()
|
.enumerate()
|
||||||
|
@ -86,6 +89,6 @@ impl Palette {
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.0;
|
.0;
|
||||||
|
|
||||||
return &self.raw_colors[max_index];
|
self.raw_colors[max_index]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,291 @@
|
||||||
|
use std::fs::File;
|
||||||
|
use std::time::Instant;
|
||||||
|
use core::time::Duration;
|
||||||
|
use rodio::Decoder;
|
||||||
|
use rodio::source::Buffered;
|
||||||
|
use rodio::OutputStream;
|
||||||
|
use rodio::Sink;
|
||||||
|
use rodio::Source;
|
||||||
|
|
||||||
|
use std::fmt;
|
||||||
|
|
||||||
|
pub type Song = Buffered<Decoder<File>>;
|
||||||
|
|
||||||
|
pub struct Player {
|
||||||
|
// [Buffered] is a pointer to a linked-list representation of the song, and so cloning
|
||||||
|
// it is as cheap as cloning an [Arc]. This should always point to the start of the
|
||||||
|
// song though, and so should not be changed after being initialized.
|
||||||
|
song: Song,
|
||||||
|
sink: Option<(Sink, OutputStream)>,
|
||||||
|
duration: Duration,
|
||||||
|
|
||||||
|
/// The position of the playhead when playback started or was last stopped
|
||||||
|
start_position: Duration,
|
||||||
|
|
||||||
|
/// The [`Instant`] playback started
|
||||||
|
playback_started: Option<Instant>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Player {
|
||||||
|
|
||||||
|
/// Create a new player from a song
|
||||||
|
///
|
||||||
|
/// IMPORTANT: Because computing the duration of a song is currently a very expensive
|
||||||
|
/// task, this DOES NOT COMPUTE THE DURATION. All calculations that require a
|
||||||
|
/// duration assume a duration of 2:30s until a duration is manually calculated with
|
||||||
|
/// [`compute_duration()`] AND passed back into [`set_duration()`].
|
||||||
|
pub fn new(song: File) -> Result<Self, PlayerError> {
|
||||||
|
|
||||||
|
let song = Decoder::new(song)
|
||||||
|
.map_err(PlayerError::Decoder)?
|
||||||
|
.buffered();
|
||||||
|
|
||||||
|
let duration = Duration::from_secs(150);
|
||||||
|
|
||||||
|
let mut player = Self {
|
||||||
|
sink: None,
|
||||||
|
start_position: Duration::ZERO,
|
||||||
|
playback_started: None,
|
||||||
|
song, duration, };
|
||||||
|
player.get_or_set_sink()?;
|
||||||
|
|
||||||
|
Ok(player)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if an audio sink is available
|
||||||
|
///
|
||||||
|
/// Returns `true` if an output device is currently loaded, or `false` if a device was
|
||||||
|
/// not availble last time we tried to access one.
|
||||||
|
pub fn has_output_device(&self) -> bool {
|
||||||
|
self.sink.is_some()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Attempt to re-request access to the output sink
|
||||||
|
///
|
||||||
|
/// Returns `&self.sink`
|
||||||
|
///
|
||||||
|
/// Can produce a [PlayerError::PlayError]
|
||||||
|
fn try_set_sink(&mut self) -> Result<Option<&Sink>, PlayerError> {
|
||||||
|
self.sink = Some(OutputStream::try_default())
|
||||||
|
.and_then(|result|
|
||||||
|
if let Err(&rodio::StreamError::NoDevice) = result.as_ref() {
|
||||||
|
None // This is okay and doesn't need to raise an error
|
||||||
|
} else {
|
||||||
|
Some(result) // We'll report this error
|
||||||
|
})
|
||||||
|
.transpose()
|
||||||
|
.map_err(PlayerError::Stream)?
|
||||||
|
.map(|(stream, handle)| Sink::try_new(&handle).map(|sink| (sink, stream)))
|
||||||
|
.transpose()
|
||||||
|
.map_err(PlayerError::Play)?;
|
||||||
|
|
||||||
|
Ok(self.sink.as_ref().map(|(s, _)| s))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Return the current active sink, or attempt to request a new one
|
||||||
|
///
|
||||||
|
/// Equivilent to calling [`try_set_sink()`] if the sink is missing
|
||||||
|
fn get_or_set_sink(&mut self) -> Result<Option<&Sink>, PlayerError> {
|
||||||
|
if self.sink.is_some() {
|
||||||
|
Ok(self.sink.as_ref().map(|(s, _)| s))
|
||||||
|
} else {
|
||||||
|
let song = self.song.clone();
|
||||||
|
let out = self.try_set_sink();
|
||||||
|
|
||||||
|
if let Ok(Some(sink)) = out.as_ref() {
|
||||||
|
sink.pause();
|
||||||
|
sink.append(song);
|
||||||
|
}
|
||||||
|
|
||||||
|
out
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Attempt to resume playback, or start fresh
|
||||||
|
///
|
||||||
|
/// Returns `true` if playback was resumed, `false` if there is no audio sink
|
||||||
|
/// available to start play, and [PlayerError] if there was a problem requesting
|
||||||
|
/// access to the audio sink.
|
||||||
|
pub fn play(&mut self) -> Result<bool, PlayerError> {
|
||||||
|
if let Some(sink) = self.get_or_set_sink()? {
|
||||||
|
sink.play();
|
||||||
|
self.playback_started = Some(Instant::now());
|
||||||
|
Ok(true)
|
||||||
|
} else {
|
||||||
|
Ok(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pause playback if playing, do nothing otherwise
|
||||||
|
///
|
||||||
|
/// May be resumed later with [`play()`]
|
||||||
|
pub fn pause(&mut self) {
|
||||||
|
if let Some((sink, _)) = &self.sink {
|
||||||
|
sink.pause();
|
||||||
|
self.start_position = self.position();
|
||||||
|
self.playback_started = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns true if the song is currently playing
|
||||||
|
///
|
||||||
|
/// That is, if playback has started, has not been paused, and has not ended naturally
|
||||||
|
/// yet.
|
||||||
|
pub fn is_playing(&self) -> bool {
|
||||||
|
self.playback_started.is_some() && self.position() < self.duration
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Toggle playing
|
||||||
|
///
|
||||||
|
/// This calls [`play()`] is the track is currently paused or stopped, and calls
|
||||||
|
/// [`pause()`] is the track is currently playing. If the track was already playing,
|
||||||
|
/// this will always return `Ok(true)`.
|
||||||
|
pub fn toggle_play(&mut self) -> Result<bool, PlayerError> {
|
||||||
|
if self.position() == self.duration {
|
||||||
|
self.seek(Duration::ZERO)
|
||||||
|
.and_then(|device| if device { self.play() } else { Ok(false) } )
|
||||||
|
} else if self.playback_started.is_none() {
|
||||||
|
self.play()
|
||||||
|
} else {
|
||||||
|
self.pause();
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Return the duration of this track
|
||||||
|
///
|
||||||
|
/// This is cheap and exact. All processing is done before hand.
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub fn get_duration(&self) -> Duration {
|
||||||
|
self.duration
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Attempt to seek to a given duration
|
||||||
|
///
|
||||||
|
/// This is pretty expensive, since due to technical limitations, this means resetting
|
||||||
|
/// the player to the beginning before skipping the given duration.
|
||||||
|
///
|
||||||
|
/// This can fail if there is no current output sink, and the attempt to access a
|
||||||
|
/// new one fails. If this is the case, false will be returned, unless the reason the
|
||||||
|
/// access failed was due to an error.
|
||||||
|
pub fn seek(&mut self, duration: Duration) -> Result<bool, PlayerError> {
|
||||||
|
|
||||||
|
let was_stopped = self.sink
|
||||||
|
.as_mut()
|
||||||
|
.map_or(false, |(s, _)| s.is_paused() || s.empty());
|
||||||
|
|
||||||
|
let song = self.song.clone();
|
||||||
|
if let Some(sink) = self.try_set_sink()? {
|
||||||
|
sink.pause();
|
||||||
|
sink.append(
|
||||||
|
song.skip_duration(duration)
|
||||||
|
);
|
||||||
|
if was_stopped {
|
||||||
|
self.playback_started = None;
|
||||||
|
} else {
|
||||||
|
sink.play();
|
||||||
|
self.playback_started = Some(Instant::now());
|
||||||
|
}
|
||||||
|
self.start_position = duration;
|
||||||
|
Ok(true)
|
||||||
|
} else {
|
||||||
|
Ok(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Seek to a specific percentage (out of 1.0)
|
||||||
|
///
|
||||||
|
/// Performs a [`seek()`] operation, seeking to a given percentage of the song's full
|
||||||
|
/// length. See [`seek()`] for more details
|
||||||
|
pub fn seek_percentage(&mut self, percent: f32) -> Result<bool, PlayerError> {
|
||||||
|
self.seek(
|
||||||
|
self.duration.mul_f32(percent)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How far into the song the playback head currently is
|
||||||
|
///
|
||||||
|
/// Computes the duration of the song that is before the playback head. This is
|
||||||
|
/// really an approximation based on how much time has elapsed since playback started,
|
||||||
|
/// but it should be close enough for almost all purposes.
|
||||||
|
pub fn position(&self) -> Duration {
|
||||||
|
self.duration.min(
|
||||||
|
self.start_position +
|
||||||
|
self.playback_started.map_or(
|
||||||
|
Duration::ZERO,
|
||||||
|
|ts| Instant::now() - ts,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Computes the position as a fraction of the song duration
|
||||||
|
///
|
||||||
|
/// 0.0 represents the beginning of the song, while 1.0 represents the end. See
|
||||||
|
/// [`position()`] for more information.
|
||||||
|
pub fn position_percent(&self) -> f32 {
|
||||||
|
// nightly: self.position().div_duration_f32(self.duration)
|
||||||
|
self.position().as_secs_f32() / self.duration.as_secs_f32()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Computes the exact duration of the current song.
|
||||||
|
///
|
||||||
|
/// This is an EXPENSIVE, BLOCKING long-running function that should be run in a
|
||||||
|
/// seperate thread. Hopefully this will be optimized in the future, but this is what
|
||||||
|
/// we have for now.
|
||||||
|
///
|
||||||
|
/// This does not set the duration of this song, so be sure to call [`set_duration()`]
|
||||||
|
/// with the result of this call after it finishes executing.
|
||||||
|
///
|
||||||
|
/// Note: This doesn't actually preform the calculation, but instead returns a
|
||||||
|
/// `'static FnOnce` that can be used to do so, in order to simplify running the
|
||||||
|
/// transaction in another thread.
|
||||||
|
///
|
||||||
|
/// Technical Details: Currently, this involves decoding and inefficiently counting
|
||||||
|
/// the number of samples in the song. Hopefully, we'll be able to do this more
|
||||||
|
/// efficiently in the future, pending mostly on
|
||||||
|
/// <https://github.com/RustAudio/rodio/issues/405>
|
||||||
|
pub fn compute_duration(&self) -> impl FnOnce() -> Duration {
|
||||||
|
let sample_rate = self.song.sample_rate() as u64;
|
||||||
|
let n_channels = self.song.channels() as u64;
|
||||||
|
let song_clone = self.song.clone();
|
||||||
|
|
||||||
|
move|| {
|
||||||
|
let n_samples = song_clone.count() as u64; // expensive!
|
||||||
|
let n_whole_seconds = n_samples / n_channels / sample_rate;
|
||||||
|
let remaining_samples = n_samples % sample_rate;
|
||||||
|
let n_nanos = remaining_samples * 1_000_000_000 / sample_rate;
|
||||||
|
Duration::new(n_whole_seconds, n_nanos as u32)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set the duration of the song
|
||||||
|
///
|
||||||
|
/// This struct does not automatically compute its own duration, so until this method
|
||||||
|
/// is called, we just assume the duration is 2:30s for any calculations that require
|
||||||
|
/// it.
|
||||||
|
///
|
||||||
|
/// In order to use an exact duration, use another thread to run
|
||||||
|
/// [`compute_duration()`] and pass the result into this method.
|
||||||
|
pub fn set_duration(&mut self, duration: Duration) {
|
||||||
|
self.duration = duration;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub enum PlayerError {
|
||||||
|
Decoder(rodio::decoder::DecoderError),
|
||||||
|
Play(rodio::PlayError),
|
||||||
|
Stream(rodio::StreamError),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for PlayerError {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
|
||||||
|
match self {
|
||||||
|
Self::Decoder(e) => write!(f, "Could not decode the provided song: {}", e),
|
||||||
|
Self::Play(e) => write!(f, "Problem playing to the audio output: {}", e),
|
||||||
|
Self::Stream(e) => write!(f, "Problem connecting to the audio output: {}", e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::error::Error for PlayerError { }
|
|
@ -0,0 +1,138 @@
|
||||||
|
use image::Rgb;
|
||||||
|
use crate::palette::Palette;
|
||||||
|
use iced::Background;
|
||||||
|
use iced::Color;
|
||||||
|
use iced::widget::container;
|
||||||
|
use iced::widget::text_input;
|
||||||
|
|
||||||
|
#[derive(Copy, Clone, Debug)]
|
||||||
|
pub struct Theme {
|
||||||
|
pub base_color: Color,
|
||||||
|
pub text_color: Color,
|
||||||
|
subtype: Subtype,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
|
||||||
|
enum Subtype {
|
||||||
|
Base,
|
||||||
|
ActiveLyric,
|
||||||
|
}
|
||||||
|
use Subtype::*;
|
||||||
|
|
||||||
|
impl Theme {
|
||||||
|
pub fn from_palette(palette: &Palette) -> Self {
|
||||||
|
let base_color = img_color_to_iced(palette.dominant_color());
|
||||||
|
let luma = relative_lum(base_color);
|
||||||
|
let text_color = if luma > 0.2 {
|
||||||
|
Color {
|
||||||
|
a: 0.8,
|
||||||
|
..Color::BLACK
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Color::WHITE
|
||||||
|
};
|
||||||
|
Self {
|
||||||
|
subtype: Base,
|
||||||
|
base_color, text_color,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const fn reduced_text_color(&self) -> Color {
|
||||||
|
Color {
|
||||||
|
a: 0.7,
|
||||||
|
..self.text_color
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const fn active_lyric(&self, active: bool) -> Self {
|
||||||
|
if active {
|
||||||
|
self.set_subtype(ActiveLyric)
|
||||||
|
} else {
|
||||||
|
*self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fn set_subtype(&self, subtype: Subtype) -> Self {
|
||||||
|
Self {
|
||||||
|
subtype,
|
||||||
|
..*self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for Theme {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
base_color: Color {r: 236. / 255., g: 63. / 255., b: 53. / 255., a: 1.},
|
||||||
|
text_color: Color {r: 1., g: 1., b: 1., a: 1.},
|
||||||
|
subtype: Base,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl container::StyleSheet for Theme {
|
||||||
|
fn style(&self) -> container::Style {
|
||||||
|
container::Style {
|
||||||
|
text_color: Some(self.text_color),
|
||||||
|
background: Some(Background::Color(self.base_color)),
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl text_input::StyleSheet for Theme {
|
||||||
|
fn active(&self) -> text_input::Style {
|
||||||
|
text_input::Style {
|
||||||
|
background: Background::Color(self.base_color),
|
||||||
|
border_radius: 0.,
|
||||||
|
border_width: 0.,
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn focused(&self) -> text_input::Style {
|
||||||
|
self.active()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn placeholder_color(&self) -> Color {
|
||||||
|
Color {
|
||||||
|
a: 0.3,
|
||||||
|
..self.text_color
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn value_color(&self) -> Color {
|
||||||
|
if self.subtype == ActiveLyric {
|
||||||
|
self.text_color
|
||||||
|
} else {
|
||||||
|
self.reduced_text_color()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn selection_color(&self) -> Color {
|
||||||
|
self.text_color
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn img_color_to_iced(color: Rgb<u8>) -> Color {
|
||||||
|
Color {
|
||||||
|
r: color[0] as f32 / 255.,
|
||||||
|
g: color[1] as f32 / 255.,
|
||||||
|
b: color[2] as f32 / 255.,
|
||||||
|
a: 1.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn relative_lum(color: Color) -> f32 {
|
||||||
|
let mut t = [color.r, color.g, color.b]
|
||||||
|
.into_iter()
|
||||||
|
.map(|val| {
|
||||||
|
if val < 0.03928 {
|
||||||
|
val / 12.92
|
||||||
|
} else {
|
||||||
|
((val+0.055)/1.055).powf(2.4)
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
0.2126 * t.next().unwrap() + 0.7152 * t.next().unwrap() + 0.0722 * t.next().unwrap()
|
||||||
|
}
|
Loading…
Reference in New Issue