Compare commits

...

27 Commits

Author SHA1 Message Date
Emi Simpson 64ca3d972e
Take advantage of a few Window settings 2022-02-12 18:05:04 -05:00
Emi Simpson 436403d129
Add a whole bunch more clippy lints 2022-01-26 18:42:31 -05:00
Emi Simpson 29d36a8010
Minor clippy tweak 2022-01-26 17:20:17 -05:00
Emi Simpson 746715be39
Redo metadata -> song pipeline
Following the rejection of https://github.com/RustAudio/rodio/pull/410
2022-01-26 17:18:23 -05:00
Emi Simpson b97ae618b0
Fix images in background 2022-01-26 16:24:10 -05:00
Emi Simpson 415b4e7fc2
Massive refactor to match the Elm architecture better 2022-01-22 10:39:18 -05:00
Emi Simpson 31d59fd0ee
Set timestamp on newline 2022-01-09 17:10:12 -05:00
Emi Simpson eddf56ae64
Tweak the opacity of some faded colors 2022-01-09 09:48:52 -05:00
Emi Simpson 18572a6eff
Fix bug where images weren't rendered until image is resized 2022-01-09 09:48:52 -05:00
Emi Simpson 020e6c4f92
Automatically switch into a "light mode" on light backgrounds 2022-01-09 09:48:52 -05:00
Emi Simpson ace47d3225
Give the editor a bit of a facelift 2022-01-09 09:48:41 -05:00
Emi Simpson 5dd4841e55
And clippy lints too 2022-01-08 10:28:42 -05:00
Emi Simpson 34f548748a
Clean up warnings 2022-01-08 09:14:11 -05:00
Emi Simpson c39b545029
Add a timestamp doodad to the editor 2022-01-07 21:07:11 -05:00
Emi Simpson b5c1b088f3
Re-center the contents of the Lyrics 2022-01-07 18:16:59 -05:00
Emi Simpson b715155b20
Fixed the FUCKING bug
y'know the one where the progress bar disappeared at many resolutions
2022-01-07 17:39:24 -05:00
Emi Simpson 807289ca46
Only blur the cover when running in release mode
This was taking so much time omg
2022-01-07 17:31:02 -05:00
Emi Simpson b9883d2ad1
Run song length calculations asynchronously 2022-01-05 15:42:12 -05:00
Emi Simpson 7e64590279
Merge peripheries into the editor 2022-01-05 15:26:06 -05:00
Emi Simpson 4e44e612ab
Minor tweaks to peri height 2022-01-03 18:20:30 -05:00
Emi Simpson c211e79658
Music and controls!!!!! 🎉 2022-01-03 18:19:10 -05:00
Emi Simpson 02fbd15f44
Split Lyrics -> Editor + Lyrics 2022-01-01 23:11:47 -05:00
Emi Simpson 9b2a6ffde3
Add images on the size 2022-01-01 22:08:00 -05:00
Emi Simpson bc19df18cb
Add support for grabbing color from the song metadata 2022-01-01 13:21:34 -05:00
Emi Simpson a2f37bf3f3
Add basic metadata parsing for songs 2022-01-01 12:28:23 -05:00
Emi Simpson eecb67909a
Move everything out of the app dir 2022-01-01 11:20:25 -05:00
Emi Simpson 5724b25a88
Add support for selecting a file 2022-01-01 01:02:54 -05:00
19 changed files with 2627 additions and 1418 deletions

4
.gitmodules vendored Normal file
View File

@ -0,0 +1,4 @@
[submodule "iced"]
path = iced
url = https://github.com/Alch-Emi/iced.git
branch = image-modes

1981
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -14,17 +14,40 @@ 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"
# 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]
# Display windows & graphics
features = ["canvas"]
version = "0.3.0"
features = ["canvas", "image"]
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]
# Display windows & graphics
features = ["smol"]
version = "0.3.0"
path = "./iced/futures"
[dependencies.rodio]
# Playing audio
default-features = false
features = ["symphonia-all"]
version = "0.15"

View File

@ -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.

BIN
fonts/vg5000/VG5000.otf (Stored with Git LFS) Normal file

Binary file not shown.

105
src/app.rs Normal file
View File

@ -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
])
}
}

View File

@ -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();
}
}

View File

@ -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,
}
}
}

View File

@ -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
}
}

164
src/controls.rs Normal file
View File

@ -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),
}
}
}

134
src/editor.rs Normal file
View File

@ -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)
}

165
src/file_select.rs Normal file
View File

@ -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),
}
}

100
src/load_song.rs Normal file
View File

@ -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 {}

View File

@ -1,9 +1,24 @@
use iced::Application;
use iced::settings::Settings;
use iced::window;
mod palette;
mod app;
mod styles;
mod file_select;
mod load_song;
mod editor;
mod player;
mod controls;
mod model;
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();
}

369
src/model/editing.rs Normal file
View File

@ -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)
}
}

67
src/model/mod.rs Normal file
View File

@ -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)
},
}
}
}

View File

@ -18,7 +18,9 @@ pub struct 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
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_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 {
img
};
// Convert to exoquant image
let width = thumb.width();
let exo_img: Vec<Color> = thumb.into_rgb8()
let exo_img: Vec<Color> = thumb.to_rgb8()
.pixels()
.map(|p| Color {r: p[0], g: p[1], b: p[2], a: 255})
.collect();
// Generate histogram
let histogram = exo_img.iter().cloned().collect();
let histogram = exo_img.iter().copied().collect();
// Generate raw palette
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
.iter()
.enumerate()
@ -86,6 +89,6 @@ impl Palette {
.unwrap()
.0;
return &self.raw_colors[max_index];
self.raw_colors[max_index]
}
}

291
src/player.rs Normal file
View File

@ -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 { }

138
src/styles.rs Normal file
View File

@ -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()
}