Compare commits

..

5 Commits

23 changed files with 994 additions and 294 deletions

View File

@ -7,11 +7,11 @@ edition = "2021"
[dependencies] [dependencies]
musicdb-lib = { path = "../musicdb-lib", default-features = false } musicdb-lib = { path = "../musicdb-lib", default-features = false }
clap = { version = "4.4.6", features = ["derive"] } clap = { version = "4.5.45", features = ["derive"] }
directories = "5.0.1" directories = "6.0.0"
regex = "1.9.3" regex = "1.11.1"
speedy2d = { version = "1.12.0", optional = true } speedy2d = { version = "2.1.0", optional = true }
toml = "0.7.6" toml = "0.9.5"
# musicdb-mers = { version = "0.1.0", path = "../musicdb-mers", optional = true } # musicdb-mers = { version = "0.1.0", path = "../musicdb-mers", optional = true }
uianimator = "0.1.1" uianimator = "0.1.1"
@ -27,7 +27,9 @@ default = ["gui", "default-playback"]
# enables syncplayer modes, where the client mirrors the server's playback # enables syncplayer modes, where the client mirrors the server's playback
gui = ["speedy2d"] gui = ["speedy2d"]
# merscfg = ["mers", "gui"] # merscfg = ["mers", "gui"]
merscfg = []
# mers = ["musicdb-mers"] # mers = ["musicdb-mers"]
mers = []
playback = [] playback = []
default-playback = ["playback", "musicdb-lib/default-playback"] default-playback = ["playback", "musicdb-lib/default-playback"]
playback-via-playback-rs = ["playback", "musicdb-lib/playback-via-playback-rs"] playback-via-playback-rs = ["playback", "musicdb-lib/playback-via-playback-rs"]

View File

@ -1258,8 +1258,8 @@ pub enum GuiAction {
Do(Box<dyn FnOnce(&mut Gui)>), Do(Box<dyn FnOnce(&mut Gui)>),
Exit, Exit,
EditSongs(Vec<Song>), EditSongs(Vec<Song>),
// EditAlbums(Vec<Album>), // EditAlbums(Vec<Album>, bool),
// EditArtists(Vec<Artist>), // EditArtists(Vec<Artist>, bool),
OpenAddSongsMenu, OpenAddSongsMenu,
CloseAddSongsMenu, CloseAddSongsMenu,
} }

View File

@ -1,130 +1,42 @@
use std::{ use std::time::Instant;
ops::{Add, AddAssign, Mul, MulAssign, Sub},
time::{Duration, Instant},
};
pub struct AnimationController<F> { use uianimator::{default_animator_f64_quadratic::DefaultAnimatorF64Quadratic, Animator};
pub last_updated: Instant,
pub value: F, pub struct AnimationController {
pub speed: F, speed: f64,
pub max_speed: F, anim: DefaultAnimatorF64Quadratic,
pub target: F,
/// while the time remaining to finish the animation is above this, we accelerate (higher -> stop acceleration earlier)
pub accel_until: F,
/// if the time remaining to finish the animation drops below this, we decelerate (higher -> start decelerating earlier)
pub decel_while: F,
pub acceleration: F,
} }
pub trait Float: impl AnimationController {
Sized pub fn new(value: f64, target: f64, speed: f64) -> Self {
+ Clone let mut anim = DefaultAnimatorF64Quadratic::new(value, speed);
+ Copy if value != target {
+ Add<Self, Output = Self> anim.set_target(target, Instant::now());
+ Sub<Self, Output = Self>
+ std::ops::Neg<Output = Self>
+ Mul<Self, Output = Self>
+ MulAssign<Self>
+ AddAssign<Self>
+ PartialOrd<Self>
{
fn zero() -> Self;
/// 1/1000
fn milli() -> Self;
fn duration_secs(d: Duration) -> Self;
fn abs(self) -> Self {
if self < Self::zero() {
-self
} else {
self
} }
AnimationController { speed, anim }
} }
pub fn target(&self) -> f64 {
self.anim.target()
} }
pub fn set_target(&mut self, now: Instant, target: f64) {
impl<F: Float> AnimationController<F> { self.anim.set_target(target, now);
pub fn new(
value: F,
target: F,
acceleration: F,
max_speed: F,
accel_until: F,
decel_while: F,
now: Instant,
) -> Self {
AnimationController {
last_updated: now,
value,
speed: F::zero(),
max_speed,
target,
accel_until,
decel_while,
acceleration,
} }
} pub fn update(&mut self, now: Instant, instant: bool) -> Result<f64, f64> {
pub fn ignore_elapsed_time(&mut self, now: Instant) { if self.anim.target() != self.anim.get_value(now) {
self.last_updated = now;
}
pub fn update(&mut self, now: Instant, instant: bool) -> bool {
let changed = if self.target != self.value {
if instant { if instant {
self.value = self.target; let target = self.anim.target();
self.anim = DefaultAnimatorF64Quadratic::new(target, self.speed);
Ok(target)
} else { } else {
let inc = self.target > self.value; Ok(self.anim.get_value(now))
let seconds = F::duration_secs(now.duration_since(self.last_updated)); }
let ref1 = self.value + self.speed * self.accel_until;
let ref2 = self.value + self.speed * self.decel_while;
let speed_diff = match (ref1 < self.target, ref2 > self.target) {
(true, false) => self.acceleration,
(false, true) => -self.acceleration,
(true, true) | (false, false) => F::zero(),
};
self.speed += speed_diff;
if self.speed.abs() > self.max_speed {
if self.speed < F::zero() {
self.speed = -self.max_speed;
} else { } else {
self.speed = self.max_speed; Err(self.anim.target())
} }
} }
self.value += self.speed * seconds; pub fn value(&mut self, now: Instant) -> f64 {
self.speed += speed_diff; match self.update(now, false) {
if (self.target - self.value).abs() < self.speed * F::milli() Ok(v) | Err(v) => v,
|| inc != (self.target > self.value)
{
// overshoot or target reached
self.value = self.target;
self.speed = F::zero();
} }
} }
true
} else {
false
};
self.last_updated = now;
changed
}
}
impl Float for f32 {
fn zero() -> Self {
0.0
}
fn milli() -> Self {
0.001
}
fn duration_secs(d: Duration) -> Self {
d.as_secs_f32().min(0.1)
}
}
impl Float for f64 {
fn zero() -> Self {
0.0
}
fn milli() -> Self {
0.001
}
fn duration_secs(d: Duration) -> Self {
d.as_secs_f64().min(0.1)
}
} }

View File

@ -355,6 +355,14 @@ impl<C: GuiElemChildren> Button<C> {
action: Arc::new(action), action: Arc::new(action),
} }
} }
pub fn disable(&mut self) {
self.config.mouse_events = false;
self.config.keyboard_events_focus = false;
}
pub fn enable(&mut self) {
self.config.mouse_events = true;
self.config.keyboard_events_focus = true;
}
} }
impl<C: GuiElemChildren + 'static> GuiElem for Button<C> { impl<C: GuiElemChildren + 'static> GuiElem for Button<C> {
fn config(&self) -> &GuiElemCfg { fn config(&self) -> &GuiElemCfg {
@ -604,7 +612,7 @@ impl GuiElem for Slider {
fn draw(&mut self, info: &mut DrawInfo, g: &mut speedy2d::Graphics2D) { fn draw(&mut self, info: &mut DrawInfo, g: &mut speedy2d::Graphics2D) {
if self.display != (self.config.mouse_down.0 || info.pos.contains(info.mouse_pos)) { if self.display != (self.config.mouse_down.0 || info.pos.contains(info.mouse_pos)) {
self.display = !self.display; self.display = !self.display;
self.display_since = Some(Instant::now()); self.display_since = Some(info.time);
self.config.redraw = true; self.config.redraw = true;
} }
let dot_size = (info.pos.height() * 0.9).min(info.pos.width() * 0.25); let dot_size = (info.pos.height() * 0.9).min(info.pos.width() * 0.25);

View File

@ -0,0 +1,315 @@
use std::{collections::BTreeSet, time::Instant};
use speedy2d::{
color::Color,
dimen::{Vec2, Vector2},
shape::Rectangle,
Graphics2D,
};
use crate::{
gui::{DrawInfo, GuiElem, GuiElemCfg},
gui_anim::AnimationController,
gui_base::{Button, ScrollBox},
gui_text::{Label, TextField},
};
pub const ELEM_HEIGHT: f32 = 32.0;
pub enum Event {
RemoveTag(String),
AddTag(String),
}
pub struct EditorForAnyTagInList {
config: GuiElemCfg,
pub tag: String,
label: Label,
rm_button: Button<[IconDelete; 1]>,
}
impl EditorForAnyTagInList {
pub fn new<T: From<Event> + 'static>(
tag: String,
sender: std::sync::mpsc::Sender<T>,
config: GuiElemCfg,
) -> Self {
Self {
config,
tag: tag.clone(),
label: Label::new(
GuiElemCfg::default(),
tag.clone(),
Color::WHITE,
None,
Vector2::new(0.0, 0.5),
),
rm_button: Button::new(
GuiElemCfg::default(),
move |btn| {
btn.disable();
sender.send(Event::RemoveTag(tag.clone()).into()).unwrap();
vec![]
},
[IconDelete::new(GuiElemCfg::default())],
),
}
}
}
impl GuiElem for EditorForAnyTagInList {
fn draw(&mut self, info: &mut DrawInfo, g: &mut Graphics2D) {
let rm_button_size = (info.pos.height() * 0.8).min(info.pos.width() * 0.33);
let rm_button_padding = (info.pos.height() - rm_button_size) / 2.0;
let label_padding = info.pos.height() * 0.05;
let x_split = (info.pos.width() - rm_button_size) / info.pos.width();
self.rm_button.config_mut().pos = Rectangle::from_tuples(
(x_split, rm_button_padding / info.pos.height()),
(1.0, 1.0 - rm_button_padding / info.pos.height()),
);
self.label.config_mut().pos = Rectangle::from_tuples(
(0.0, label_padding / info.pos.height()),
(x_split, 1.0 - label_padding / info.pos.height()),
);
}
fn config(&self) -> &GuiElemCfg {
&self.config
}
fn config_mut(&mut self) -> &mut GuiElemCfg {
&mut self.config
}
fn children(&mut self) -> Box<dyn Iterator<Item = &mut dyn GuiElem> + '_> {
Box::new([self.label.elem_mut(), self.rm_button.elem_mut()].into_iter())
}
fn any(&self) -> &dyn std::any::Any {
self
}
fn any_mut(&mut self) -> &mut dyn std::any::Any {
self
}
fn elem(&self) -> &dyn GuiElem {
self
}
fn elem_mut(&mut self) -> &mut dyn GuiElem {
self
}
}
struct IconDelete {
config: GuiElemCfg,
}
impl IconDelete {
pub fn new(config: GuiElemCfg) -> Self {
Self { config }
}
}
impl GuiElem for IconDelete {
fn draw(&mut self, info: &mut DrawInfo, g: &mut Graphics2D) {
let thickness = (info.pos.height() * 0.01).max(1.0);
g.draw_line(
*info.pos.top_left(),
*info.pos.bottom_right(),
thickness,
Color::GRAY,
);
g.draw_line(
info.pos.top_right(),
info.pos.bottom_left(),
thickness,
Color::GRAY,
);
}
fn config(&self) -> &GuiElemCfg {
&self.config
}
fn config_mut(&mut self) -> &mut GuiElemCfg {
&mut self.config
}
fn children(&mut self) -> Box<dyn Iterator<Item = &mut dyn GuiElem> + '_> {
Box::new([].into_iter())
}
fn any(&self) -> &dyn std::any::Any {
self
}
fn any_mut(&mut self) -> &mut dyn std::any::Any {
self
}
fn elem(&self) -> &dyn GuiElem {
self
}
fn elem_mut(&mut self) -> &mut dyn GuiElem {
self
}
}
pub struct EditorForAnyTagAdder<T: From<Event>> {
config: GuiElemCfg,
event_sender: std::sync::mpsc::Sender<T>,
/// `1.0` = collapsed, `self.expand_to` = expanded (shows `c_picker` of height 7-1=6)
pub open_prog: AnimationController,
expand_to: f32,
c_value: TextField,
c_picker: ScrollBox<Vec<Button<[Label; 1]>>>,
last_search: String,
}
impl<T: From<Event> + 'static> EditorForAnyTagAdder<T> {
pub fn new(event_sender: std::sync::mpsc::Sender<T>) -> Self {
let expand_to = 7.0;
Self {
config: GuiElemCfg::default(),
event_sender,
open_prog: AnimationController::new(1.0, 1.0, 4.0),
expand_to,
c_value: TextField::new(
GuiElemCfg::default(),
"artist".to_owned(),
Color::DARK_GRAY,
Color::WHITE,
),
c_picker: ScrollBox::new(
GuiElemCfg::default().disabled(),
crate::gui_base::ScrollBoxSizeUnit::Pixels,
vec![],
vec![],
ELEM_HEIGHT,
),
last_search: String::from("\n"),
}
}
pub fn clear(&mut self, now: Instant) {
self.last_search = "\n".to_owned();
self.c_value.c_input.content.text().clear();
self.open_prog.set_target(now, 1.0);
self.config_mut().redraw = true;
}
}
impl<T: From<Event> + 'static> GuiElem for EditorForAnyTagAdder<T> {
fn draw(&mut self, info: &mut crate::gui::DrawInfo, _g: &mut speedy2d::Graphics2D) {
let picker_enabled = self.open_prog.value(info.time) > 1.0;
self.c_picker.config_mut().enabled = picker_enabled;
if picker_enabled {
let split = 1.0 / self.open_prog.value(info.time) as f32;
self.c_value.config_mut().pos = Rectangle::from_tuples((0.0, 0.0), (1.0, split));
self.c_picker.config_mut().pos = Rectangle::from_tuples((0.0, split), (1.0, 1.0));
} else {
self.c_value.config_mut().pos = Rectangle::from_tuples((0.0, 0.0), (1.0, 1.0));
}
let search = self.c_value.c_input.content.get_text().to_lowercase();
let search_changed = &self.last_search != &search;
if self.config.redraw || search_changed {
*self.c_value.c_input.content.color() = Color::WHITE;
if search_changed {
if search.is_empty() {
self.open_prog.set_target(info.time, 1.0);
} else {
self.open_prog.set_target(info.time, self.expand_to as f64);
}
}
let mut tags = info
.database
.songs()
.values()
.flat_map(|s| s.general.tags.iter())
.chain(
info.database
.songs()
.values()
.flat_map(|s| s.general.tags.iter()),
)
.chain(
info.database
.songs()
.values()
.flat_map(|s| s.general.tags.iter()),
)
.filter(|tag| tag.to_lowercase().contains(&search))
.map(|tag| tag.clone())
.collect::<BTreeSet<_>>();
if !tags.contains(self.c_value.c_input.content.get_text()) {
tags.insert(self.c_value.c_input.content.get_text().clone());
}
self.c_picker.children = tags
.iter()
.map(|tag| {
let sender = self.event_sender.clone();
Button::new(
GuiElemCfg::default(),
{
let tag = tag.clone();
move |_| {
sender.send(Event::AddTag(tag.clone()).into()).unwrap();
vec![]
}
},
[Label::new(
GuiElemCfg::default(),
tag.clone(),
Color::LIGHT_GRAY,
None,
Vec2::new(0.0, 0.5),
)],
)
})
.collect();
self.c_picker.config_mut().redraw = true;
self.last_search = search;
}
}
fn config(&self) -> &GuiElemCfg {
&self.config
}
fn config_mut(&mut self) -> &mut GuiElemCfg {
&mut self.config
}
fn children(&mut self) -> Box<dyn Iterator<Item = &mut dyn GuiElem> + '_> {
Box::new([self.c_value.elem_mut(), self.c_picker.elem_mut()].into_iter())
}
fn any(&self) -> &dyn std::any::Any {
self
}
fn any_mut(&mut self) -> &mut dyn std::any::Any {
self
}
fn elem(&self) -> &dyn GuiElem {
self
}
fn elem_mut(&mut self) -> &mut dyn GuiElem {
self
}
}
pub struct SpacerForScrollBox {
config: GuiElemCfg,
}
impl SpacerForScrollBox {
pub fn new() -> Self {
Self {
config: GuiElemCfg::default(),
}
}
}
impl GuiElem for SpacerForScrollBox {
fn draw(&mut self, _info: &mut DrawInfo, _g: &mut Graphics2D) {}
fn config(&self) -> &GuiElemCfg {
&self.config
}
fn config_mut(&mut self) -> &mut GuiElemCfg {
&mut self.config
}
fn children(&mut self) -> Box<dyn Iterator<Item = &mut dyn GuiElem> + '_> {
Box::new([].into_iter())
}
fn any(&self) -> &dyn std::any::Any {
self
}
fn any_mut(&mut self) -> &mut dyn std::any::Any {
self
}
fn elem(&self) -> &dyn GuiElem {
self
}
fn elem_mut(&mut self) -> &mut dyn GuiElem {
self
}
}

View File

@ -11,13 +11,12 @@ use crate::{
gui::{GuiAction, GuiElem, GuiElemCfg, GuiElemChildren}, gui::{GuiAction, GuiElem, GuiElemCfg, GuiElemChildren},
gui_anim::AnimationController, gui_anim::AnimationController,
gui_base::{Button, Panel, ScrollBox}, gui_base::{Button, Panel, ScrollBox},
gui_edit_any::{EditorForAnyTagAdder, EditorForAnyTagInList, SpacerForScrollBox, ELEM_HEIGHT},
gui_text::{Label, TextField}, gui_text::{Label, TextField},
}; };
// TODO: Fix bug where after selecting an artist you can't mouse-click the buttons anymore (to change it) // TODO: Fix bug where after selecting an artist you can't mouse-click the buttons anymore (to change it)
const ELEM_HEIGHT: f32 = 32.0;
pub struct EditorForSongs { pub struct EditorForSongs {
config: GuiElemCfg, config: GuiElemCfg,
songs: Vec<Song>, songs: Vec<Song>,
@ -33,11 +32,21 @@ pub enum Event {
Close, Close,
Apply, Apply,
SetArtist(String, Option<ArtistId>), SetArtist(String, Option<ArtistId>),
GeneralEvent(super::gui_edit_any::Event),
} }
impl From<super::gui_edit_any::Event> for Event {
fn from(value: super::gui_edit_any::Event) -> Self {
Self::GeneralEvent(value)
}
}
pub struct EditorForSongElems { pub struct EditorForSongElems {
c_title: TextField, c_title: TextField,
c_artist: EditorForSongArtistChooser, c_artist: EditorForSongArtistChooser,
c_album: Label, c_album: Label,
c_tags: Vec<EditorForAnyTagInList>,
c_new_tag: EditorForAnyTagAdder<Event>,
c_spacers: [SpacerForScrollBox; 4],
} }
impl GuiElemChildren for EditorForSongElems { impl GuiElemChildren for EditorForSongElems {
fn iter(&mut self) -> Box<dyn Iterator<Item = &mut dyn crate::gui::GuiElem> + '_> { fn iter(&mut self) -> Box<dyn Iterator<Item = &mut dyn crate::gui::GuiElem> + '_> {
@ -47,11 +56,13 @@ impl GuiElemChildren for EditorForSongElems {
self.c_artist.elem_mut(), self.c_artist.elem_mut(),
self.c_album.elem_mut(), self.c_album.elem_mut(),
] ]
.into_iter(), .into_iter()
.chain(self.c_tags.iter_mut().map(|e| e.elem_mut()))
.chain(std::iter::once(self.c_new_tag.elem_mut())),
) )
} }
fn len(&self) -> usize { fn len(&self) -> usize {
3 3 + self.c_tags.len() + 1 + self.c_spacers.len()
} }
} }
@ -96,6 +107,32 @@ impl EditorForSongs {
None, None,
Vec2::new(0.0, 0.5), Vec2::new(0.0, 0.5),
), ),
c_tags: {
let mut tags = Vec::new();
for song in songs.iter() {
for tag in song.general.tags.iter() {
if !tags.contains(&tag.as_str()) {
tags.push(tag.as_str());
}
}
}
tags.into_iter()
.map(|tag| {
EditorForAnyTagInList::new(
tag.to_owned(),
sender.clone(),
GuiElemCfg::default(),
)
})
.collect()
},
c_new_tag: EditorForAnyTagAdder::new(sender.clone()),
c_spacers: [
SpacerForScrollBox::new(),
SpacerForScrollBox::new(),
SpacerForScrollBox::new(),
SpacerForScrollBox::new(),
],
}, },
vec![], vec![],
ELEM_HEIGHT, ELEM_HEIGHT,
@ -169,7 +206,8 @@ impl GuiElem for EditorForSongs {
gui.gui.set_normal_ui_enabled(true); gui.gui.set_normal_ui_enabled(true);
}))), }))),
Event::Apply => { Event::Apply => {
for song in &self.songs { let mut actions = Vec::new();
for song in self.songs.iter() {
let mut song = song.clone(); let mut song = song.clone();
let new_title = self let new_title = self
@ -188,17 +226,24 @@ impl GuiElem for EditorForSongs {
song.artist = artist_id; song.artist = artist_id;
song.album = None; song.album = None;
} }
actions.push(Action::ModifySong(song, Req::none()));
}
if actions.len() == 1 {
info.actions info.actions
.push(GuiAction::SendToServer(Action::ModifySong( .push(GuiAction::SendToServer(actions.pop().unwrap()));
song, } else if actions.len() > 1 {
Req::none(), info.actions
))); .push(GuiAction::SendToServer(Action::Multiple(actions)));
} }
} }
Event::SetArtist(name, id) => { Event::SetArtist(name, id) => {
self.c_scrollbox.children.c_artist.chosen_id = id; self.c_scrollbox.children.c_artist.chosen_id = id;
self.c_scrollbox.children.c_artist.last_search = name.to_lowercase(); self.c_scrollbox.children.c_artist.last_search = name.to_lowercase();
self.c_scrollbox.children.c_artist.open_prog.target = 1.0; self.c_scrollbox
.children
.c_artist
.open_prog
.set_target(info.time, 1.0);
*self *self
.c_scrollbox .c_scrollbox
.children .children
@ -209,6 +254,52 @@ impl GuiElem for EditorForSongs {
.text() = name; .text() = name;
self.c_scrollbox.children.c_artist.config_mut().redraw = true; self.c_scrollbox.children.c_artist.config_mut().redraw = true;
} }
Event::GeneralEvent(e) => {
use super::gui_edit_any::Event as GeneralEvent;
match e {
GeneralEvent::RemoveTag(tag) => {
for song in self.songs.iter_mut() {
if let Some(i) =
song.general.tags.iter().position(|t| *t == tag)
{
song.general.tags.remove(i);
}
}
if let Some(i) = (&self.c_scrollbox.children.c_tags)
.into_iter()
.position(|e| e.tag == tag)
{
self.c_scrollbox.children.c_tags.remove(i);
self.c_scrollbox.config_mut().redraw = true;
}
}
GeneralEvent::AddTag(tag) => {
self.c_scrollbox.children.c_new_tag.clear(info.time);
for song in self.songs.iter_mut() {
if !song.general.tags.contains(&tag) {
song.general.tags.push(tag.clone());
}
}
if !(&self.c_scrollbox.children.c_tags)
.into_iter()
.any(|e| e.tag == tag)
{
self.c_scrollbox.children_heights.insert(
3 + self.c_scrollbox.children.c_tags.len(),
ELEM_HEIGHT,
);
self.c_scrollbox.children.c_tags.push(
EditorForAnyTagInList::new(
tag,
self.event_sender.clone(),
GuiElemCfg::default(),
),
);
self.c_scrollbox.config_mut().redraw = true;
}
}
}
}
}, },
Err(_) => break, Err(_) => break,
} }
@ -232,15 +323,34 @@ impl GuiElem for EditorForSongs {
} }
} }
// artist sel // artist sel
if self if let Ok(val) = self
.c_scrollbox .c_scrollbox
.children .children
.c_artist .c_artist
.open_prog .open_prog
.update(Instant::now(), false) .update(info.time, false)
{ {
if let Some(v) = self.c_scrollbox.children_heights.get_mut(1) { if let Some(v) = self.c_scrollbox.children_heights.get_mut(1) {
*v = ELEM_HEIGHT * self.c_scrollbox.children.c_artist.open_prog.value; *v = ELEM_HEIGHT * val as f32;
self.c_scrollbox.config_mut().redraw = true;
}
if let Some(h) = &info.helper {
h.request_redraw();
}
}
if let Ok(val) = self
.c_scrollbox
.children
.c_new_tag
.open_prog
.update(info.time, false)
{
if let Some(v) = self
.c_scrollbox
.children_heights
.get_mut(3 + self.c_scrollbox.children.c_tags.len())
{
*v = ELEM_HEIGHT * val as f32;
self.c_scrollbox.config_mut().redraw = true; self.c_scrollbox.config_mut().redraw = true;
} }
if let Some(h) = &info.helper { if let Some(h) = &info.helper {
@ -272,7 +382,7 @@ pub struct EditorForSongArtistChooser {
config: GuiElemCfg, config: GuiElemCfg,
event_sender: std::sync::mpsc::Sender<Event>, event_sender: std::sync::mpsc::Sender<Event>,
/// `1.0` = collapsed, `self.expand_to` = expanded (shows `c_picker` of height 7-1=6) /// `1.0` = collapsed, `self.expand_to` = expanded (shows `c_picker` of height 7-1=6)
open_prog: AnimationController<f32>, open_prog: AnimationController,
expand_to: f32, expand_to: f32,
chosen_id: Option<ArtistId>, chosen_id: Option<ArtistId>,
c_name: TextField, c_name: TextField,
@ -285,7 +395,7 @@ impl EditorForSongArtistChooser {
Self { Self {
config: GuiElemCfg::default(), config: GuiElemCfg::default(),
event_sender, event_sender,
open_prog: AnimationController::new(1.0, 1.0, 0.3, 8.0, 0.5, 0.6, Instant::now()), open_prog: AnimationController::new(1.0, 1.0, 4.0),
expand_to, expand_to,
chosen_id: None, chosen_id: None,
c_name: TextField::new( c_name: TextField::new(
@ -307,10 +417,10 @@ impl EditorForSongArtistChooser {
} }
impl GuiElem for EditorForSongArtistChooser { impl GuiElem for EditorForSongArtistChooser {
fn draw(&mut self, info: &mut crate::gui::DrawInfo, _g: &mut speedy2d::Graphics2D) { fn draw(&mut self, info: &mut crate::gui::DrawInfo, _g: &mut speedy2d::Graphics2D) {
let picker_enabled = self.open_prog.value > 1.0; let picker_enabled = self.open_prog.value(info.time) > 1.0;
self.c_picker.config_mut().enabled = picker_enabled; self.c_picker.config_mut().enabled = picker_enabled;
if picker_enabled { if picker_enabled {
let split = 1.0 / self.open_prog.value; let split = 1.0 / self.open_prog.value(info.time) as f32;
self.c_name.config_mut().pos = Rectangle::from_tuples((0.0, 0.0), (1.0, split)); self.c_name.config_mut().pos = Rectangle::from_tuples((0.0, 0.0), (1.0, split));
self.c_picker.config_mut().pos = Rectangle::from_tuples((0.0, split), (1.0, 1.0)); self.c_picker.config_mut().pos = Rectangle::from_tuples((0.0, split), (1.0, 1.0));
} else { } else {
@ -327,9 +437,10 @@ impl GuiElem for EditorForSongArtistChooser {
}; };
if search_changed { if search_changed {
self.chosen_id = None; self.chosen_id = None;
self.open_prog.target = self.expand_to;
if search.is_empty() { if search.is_empty() {
self.open_prog.target = 1.0; self.open_prog.set_target(info.time, 1.0);
} else {
self.open_prog.set_target(info.time, self.expand_to as f64);
} }
} }
let artists = info let artists = info

View File

@ -1,7 +1,4 @@
use std::{ use std::sync::{atomic::AtomicBool, Arc};
sync::{atomic::AtomicBool, Arc},
time::Instant,
};
use musicdb_lib::data::ArtistId; use musicdb_lib::data::ArtistId;
use speedy2d::{color::Color, dimen::Vec2, image::ImageHandle, shape::Rectangle}; use speedy2d::{color::Color, dimen::Vec2, image::ImageHandle, shape::Rectangle};
@ -27,8 +24,8 @@ pub struct IdleDisplay {
pub c_buttons: PlayPause, pub c_buttons: PlayPause,
pub c_buttons_custom_pos: bool, pub c_buttons_custom_pos: bool,
pub cover_aspect_ratio: AnimationController<f32>, pub cover_aspect_ratio: AnimationController,
pub artist_image_aspect_ratio: AnimationController<f32>, pub artist_image_aspect_ratio: AnimationController,
pub cover_pos: Option<Rectangle>, pub cover_pos: Option<Rectangle>,
pub cover_left: f32, pub cover_left: f32,
@ -76,24 +73,8 @@ impl IdleDisplay {
is_fav: (false, Arc::clone(&is_fav)), is_fav: (false, Arc::clone(&is_fav)),
c_buttons: PlayPause::new(GuiElemCfg::default(), is_fav), c_buttons: PlayPause::new(GuiElemCfg::default(), is_fav),
c_buttons_custom_pos: false, c_buttons_custom_pos: false,
cover_aspect_ratio: AnimationController::new( cover_aspect_ratio: AnimationController::new(1.0, 1.0, 1.0),
1.0, artist_image_aspect_ratio: AnimationController::new(0.0, 0.0, 1.0),
1.0,
0.01,
1.0,
0.8,
0.6,
Instant::now(),
),
artist_image_aspect_ratio: AnimationController::new(
0.0,
0.0,
0.01,
1.0,
0.8,
0.6,
Instant::now(),
),
cover_pos: None, cover_pos: None,
cover_left: 0.01, cover_left: 0.01,
cover_top: 0.21, cover_top: 0.21,
@ -181,7 +162,7 @@ impl GuiElem for IdleDisplay {
.is_some_and(|(a, _)| *a != artist_id) .is_some_and(|(a, _)| *a != artist_id)
{ {
self.current_artist_image = Some((artist_id, None)); self.current_artist_image = Some((artist_id, None));
self.artist_image_aspect_ratio.target = 0.0; self.artist_image_aspect_ratio.set_target(info.time, 0.0);
if let Some(artist) = info.database.artists().get(&artist_id) { if let Some(artist) = info.database.artists().get(&artist_id) {
for tag in &artist.general.tags { for tag in &artist.general.tags {
if tag.starts_with("ImageExt=") { if tag.starts_with("ImageExt=") {
@ -205,7 +186,7 @@ impl GuiElem for IdleDisplay {
} else { } else {
if self.current_artist_image.is_some() { if self.current_artist_image.is_some() {
self.current_artist_image = None; self.current_artist_image = None;
self.artist_image_aspect_ratio.target = 0.0; self.artist_image_aspect_ratio.set_target(info.time, 0.0);
} }
} }
} }
@ -213,7 +194,7 @@ impl GuiElem for IdleDisplay {
self.current_info.new_cover = false; self.current_info.new_cover = false;
match self.current_info.current_cover { match self.current_info.current_cover {
None | Some((_, Some(None))) => { None | Some((_, Some(None))) => {
self.cover_aspect_ratio.target = 0.0; self.cover_aspect_ratio.set_target(info.time, 0.0);
} }
Some((_, None)) | Some((_, Some(Some(_)))) => {} Some((_, None)) | Some((_, Some(Some(_)))) => {}
} }
@ -243,6 +224,7 @@ impl GuiElem for IdleDisplay {
info.pos.top_left().x + info.pos.height() * self.cover_left, info.pos.top_left().x + info.pos.height() * self.cover_left,
info.pos.top_left().y + info.pos.height() * self.cover_top, info.pos.top_left().y + info.pos.height() * self.cover_top,
info.pos.top_left().y + info.pos.height() * self.cover_bottom, info.pos.top_left().y + info.pos.height() * self.cover_bottom,
info.time,
&mut self.cover_aspect_ratio, &mut self.cover_aspect_ratio,
); );
} }
@ -260,20 +242,23 @@ impl GuiElem for IdleDisplay {
info.pos.top_left().x + info.pos.height() * self.cover_left, info.pos.top_left().x + info.pos.height() * self.cover_left,
top, top,
bottom, bottom,
self.cover_aspect_ratio.value, self.cover_aspect_ratio.value(info.time) as f32,
) + info.pos.height() * self.artist_image_to_cover_margin, ) + info.pos.height() * self.artist_image_to_cover_margin,
top + (bottom - top) * self.artist_image_top, top + (bottom - top) * self.artist_image_top,
bottom, bottom,
info.time,
&mut self.artist_image_aspect_ratio, &mut self.artist_image_aspect_ratio,
); );
} }
// move children to make space for cover // move children to make space for cover
let ar_updated = self let ar_updated = self
.cover_aspect_ratio .cover_aspect_ratio
.update(info.time.clone(), info.high_performance) .update(info.time, info.high_performance)
.is_ok()
| self | self
.artist_image_aspect_ratio .artist_image_aspect_ratio
.update(info.time.clone(), info.high_performance); .update(info.time, info.high_performance)
.is_ok();
if ar_updated || info.pos.size() != self.config.pixel_pos.size() { if ar_updated || info.pos.size() != self.config.pixel_pos.size() {
if let Some(h) = &info.helper { if let Some(h) = &info.helper {
h.request_redraw(); h.request_redraw();
@ -281,8 +266,12 @@ impl GuiElem for IdleDisplay {
// make thing be relative to width instead of to height by multiplying with this // make thing be relative to width instead of to height by multiplying with this
let top = self.cover_top; let top = self.cover_top;
let bottom = self.cover_bottom; let bottom = self.cover_bottom;
let left = (get_right_x(self.cover_left, top, bottom, self.cover_aspect_ratio.value) let left = (get_right_x(
+ self.artist_image_to_cover_margin) self.cover_left,
top,
bottom,
self.cover_aspect_ratio.value(info.time) as f32,
) + self.artist_image_to_cover_margin)
* info.pos.height() * info.pos.height()
/ info.pos.width(); / info.pos.width();
let ai_top = top + (bottom - top) * self.artist_image_top; let ai_top = top + (bottom - top) * self.artist_image_top;
@ -293,7 +282,7 @@ impl GuiElem for IdleDisplay {
left, left,
ai_top * info.pos.height() / info.pos.width(), ai_top * info.pos.height() / info.pos.width(),
bottom * info.pos.height() / info.pos.width(), bottom * info.pos.height() / info.pos.width(),
self.artist_image_aspect_ratio.value, self.artist_image_aspect_ratio.value(info.time) as f32,
); );
self.c_side2_label.config_mut().pos = self.c_side2_label.config_mut().pos =
Rectangle::from_tuples((left, ai_top), (max_right, bottom)); Rectangle::from_tuples((left, ai_top), (max_right, bottom));

View File

@ -6,7 +6,6 @@ use std::{
atomic::{AtomicBool, AtomicUsize}, atomic::{AtomicBool, AtomicUsize},
mpsc, Mutex, mpsc, Mutex,
}, },
time::Instant,
}; };
use musicdb_lib::data::{ use musicdb_lib::data::{
@ -70,7 +69,7 @@ pub struct LibraryBrowser {
search_song: String, search_song: String,
search_song_regex: Option<Regex>, search_song_regex: Option<Regex>,
filter_target_state: Arc<AtomicBool>, filter_target_state: Arc<AtomicBool>,
filter_state: AnimationController<f32>, filter_state: AnimationController,
library_updated: bool, library_updated: bool,
search_settings_changed: Arc<AtomicBool>, search_settings_changed: Arc<AtomicBool>,
search_is_case_sensitive: Arc<AtomicBool>, search_is_case_sensitive: Arc<AtomicBool>,
@ -203,7 +202,7 @@ impl LibraryBrowser {
search_song: String::new(), search_song: String::new(),
search_song_regex: None, search_song_regex: None,
filter_target_state, filter_target_state,
filter_state: AnimationController::new(0.0, 0.0, 0.25, 25.0, 0.1, 0.2, Instant::now()), filter_state: AnimationController::new(0.0, 0.0, 4.0),
library_updated: true, library_updated: true,
search_settings_changed, search_settings_changed,
search_is_case_sensitive, search_is_case_sensitive,
@ -380,18 +379,19 @@ impl GuiElem for LibraryBrowser {
let filter_target_state = self let filter_target_state = self
.filter_target_state .filter_target_state
.load(std::sync::atomic::Ordering::Relaxed); .load(std::sync::atomic::Ordering::Relaxed);
self.filter_state.target = if filter_target_state { 1.0 } else { 0.0 }; self.filter_state
if self.filter_state.update(info.time, info.high_performance) { .set_target(info.time, if filter_target_state { 1.0 } else { 0.0 });
if let Ok(val) = self.filter_state.update(info.time, info.high_performance) {
if let Some(h) = &info.helper { if let Some(h) = &info.helper {
h.request_redraw(); h.request_redraw();
} }
let y = LP_LIB1 + (LP_LIB1S - LP_LIB1) * self.filter_state.value; let y = LP_LIB1 + (LP_LIB1S - LP_LIB1) * val as f32;
self.c_scroll_box.config_mut().pos = self.c_scroll_box.config_mut().pos =
Rectangle::new(Vec2::new(0.0, y), Vec2::new(1.0, LP_LIB2)); Rectangle::new(Vec2::new(0.0, y), Vec2::new(1.0, LP_LIB2));
let filter_panel = &mut self.c_filter_panel; let filter_panel = &mut self.c_filter_panel;
filter_panel.config_mut().pos = filter_panel.config_mut().pos =
Rectangle::new(Vec2::new(0.0, LP_LIB1), Vec2::new(1.0, y)); Rectangle::new(Vec2::new(0.0, LP_LIB1), Vec2::new(1.0, y));
filter_panel.config.enabled = self.filter_state.value > 0.0; filter_panel.config.enabled = val > 0.0;
} }
// - // -
if self.library_updated { if self.library_updated {
@ -1275,24 +1275,54 @@ impl GuiElem for ListSong {
fn mouse_pressed(&mut self, e: &mut EventInfo, button: MouseButton) -> Vec<GuiAction> { fn mouse_pressed(&mut self, e: &mut EventInfo, button: MouseButton) -> Vec<GuiAction> {
if button == MouseButton::Right && e.take() { if button == MouseButton::Right && e.take() {
let id = self.id; let id = self.id;
let mut menu_actions: Vec<Box<dyn GuiElem + 'static>> = vec![Box::new(Button::new(
GuiElemCfg::default(),
move |_| {
vec![GuiAction::Build(Box::new(move |db| { vec![GuiAction::Build(Box::new(move |db| {
if let Some(me) = db.songs().get(&id) { if let Some(me) = db.get_song(&id) {
let me = me.clone(); vec![GuiAction::EditSongs(vec![me.clone()])]
vec![GuiAction::ContextMenu(Some(vec![Box::new(Button::new(
GuiElemCfg::default(),
move |_| vec![GuiAction::EditSongs(vec![me.clone()])],
[Label::new(
GuiElemCfg::default(),
format!("Edit"),
Color::WHITE,
None,
Vec2::new_y(0.5),
)],
))]))]
} else { } else {
vec![] vec![]
} }
}))] }))]
},
[Label::new(
GuiElemCfg::default(),
format!("Edit this song"),
Color::WHITE,
None,
Vec2::new_y(0.5),
)],
))];
if self.selected.contains_song(&id) {
menu_actions.push(Box::new(Button::new(
GuiElemCfg::default(),
{
let selected = self.selected.clone();
move |_| {
let selected = selected.clone();
vec![GuiAction::Build(Box::new(move |db| {
vec![GuiAction::EditSongs(selected.view(
|(artists, albums, songs)| {
songs
.iter()
.filter_map(|id| db.get_song(id).cloned())
.collect()
},
))]
}))]
}
},
[Label::new(
GuiElemCfg::default(),
format!("Edit selected songs"),
Color::WHITE,
None,
Vec2::new_y(0.5),
)],
)));
}
vec![GuiAction::ContextMenu(Some(menu_actions))]
} else { } else {
vec![] vec![]
} }

View File

@ -1,4 +1,7 @@
use std::{sync::Arc, time::Duration}; use std::{
sync::Arc,
time::{Duration, Instant},
};
use musicdb_lib::data::{CoverId, SongId}; use musicdb_lib::data::{CoverId, SongId};
use speedy2d::{color::Color, dimen::Vec2, image::ImageHandle, shape::Rectangle}; use speedy2d::{color::Color, dimen::Vec2, image::ImageHandle, shape::Rectangle};
@ -151,25 +154,29 @@ pub fn image_display(
left: f32, left: f32,
top: f32, top: f32,
bottom: f32, bottom: f32,
aspect_ratio: &mut AnimationController<f32>, now: Instant,
aspect_ratio: &mut AnimationController,
) { ) {
if let Some(cover) = &img { if let Some(cover) = &img {
let cover_size = cover.size(); let cover_size = cover.size();
aspect_ratio.target = if cover_size.x > 0 && cover_size.y > 0 {
let pos = if let Some(pos) = pos { let pos = if let Some(pos) = pos {
pos pos
} else { } else {
let right_x = get_right_x(left, top, bottom, aspect_ratio.value); let right_x = get_right_x(left, top, bottom, aspect_ratio.value(now) as f32);
Rectangle::from_tuples((left, top), (right_x, bottom)) Rectangle::from_tuples((left, top), (right_x, bottom))
}; };
let aspect_ratio = cover_size.x as f32 / cover_size.y as f32;
g.draw_rectangle_image(pos, cover); g.draw_rectangle_image(pos, cover);
aspect_ratio aspect_ratio.set_target(
now,
if cover_size.x > 0 && cover_size.y > 0 {
let aspect_ratio = cover_size.x as f32 / cover_size.y as f32;
aspect_ratio as f64
} else { } else {
0.0 0.0
}; },
);
} else { } else {
aspect_ratio.target = 0.0; aspect_ratio.set_target(now, 0.0);
} }
} }
pub fn get_right_x(left: f32, top: f32, bottom: f32, aspect_ratio: f32) -> f32 { pub fn get_right_x(left: f32, top: f32, bottom: f32, aspect_ratio: f32) -> f32 {

View File

@ -403,7 +403,7 @@ impl GuiElem for GuiScreen {
false false
}; };
// request_redraw for animations // request_redraw for animations
let idle_value = self.idle.get_value(Instant::now()) as f32; let idle_value = self.idle.get_value(info.time) as f32;
let idle_changed = self.idle_prev_val != idle_value; let idle_changed = self.idle_prev_val != idle_value;
if idle_changed || idle_exit_anim || self.settings.1.is_some() { if idle_changed || idle_exit_anim || self.settings.1.is_some() {
self.idle_prev_val = idle_value; self.idle_prev_val = idle_value;

View File

@ -1,7 +1,4 @@
use std::{ use std::sync::{atomic::AtomicBool, Arc};
sync::{atomic::AtomicBool, Arc},
time::Instant,
};
use speedy2d::{dimen::Vec2, shape::Rectangle}; use speedy2d::{dimen::Vec2, shape::Rectangle};
@ -17,7 +14,7 @@ pub struct StatusBar {
config: GuiElemCfg, config: GuiElemCfg,
pub idle_mode: f32, pub idle_mode: f32,
current_info: CurrentInfo, current_info: CurrentInfo,
cover_aspect_ratio: AnimationController<f32>, cover_aspect_ratio: AnimationController,
c_song_label: AdvancedLabel, c_song_label: AdvancedLabel,
pub force_reset_texts: bool, pub force_reset_texts: bool,
c_buttons: PlayPause, c_buttons: PlayPause,
@ -31,15 +28,7 @@ impl StatusBar {
config, config,
idle_mode: 0.0, idle_mode: 0.0,
current_info: CurrentInfo::new(), current_info: CurrentInfo::new(),
cover_aspect_ratio: AnimationController::new( cover_aspect_ratio: AnimationController::new(0.0, 0.0, 1.0),
0.0,
0.0,
0.01,
1.0,
0.8,
0.6,
Instant::now(),
),
c_song_label: AdvancedLabel::new(GuiElemCfg::default(), Vec2::new(0.0, 0.5), vec![]), c_song_label: AdvancedLabel::new(GuiElemCfg::default(), Vec2::new(0.0, 0.5), vec![]),
force_reset_texts: false, force_reset_texts: false,
is_fav: (false, Arc::clone(&is_fav)), is_fav: (false, Arc::clone(&is_fav)),
@ -82,7 +71,7 @@ impl GuiElem for StatusBar {
self.current_info.new_cover = false; self.current_info.new_cover = false;
match self.current_info.current_cover { match self.current_info.current_cover {
None | Some((_, Some(None))) => { None | Some((_, Some(None))) => {
self.cover_aspect_ratio.target = 0.0; self.cover_aspect_ratio.set_target(info.time, 0.0);
} }
Some((_, None)) | Some((_, Some(Some(_)))) => {} Some((_, None)) | Some((_, Some(Some(_)))) => {}
} }
@ -90,7 +79,8 @@ impl GuiElem for StatusBar {
// move children to make space for cover // move children to make space for cover
let ar_updated = self let ar_updated = self
.cover_aspect_ratio .cover_aspect_ratio
.update(info.time.clone(), info.high_performance); .update(info.time, info.high_performance)
.is_ok();
if ar_updated || info.pos.size() != self.config.pixel_pos.size() { if ar_updated || info.pos.size() != self.config.pixel_pos.size() {
if let Some(h) = &info.helper { if let Some(h) = &info.helper {
h.request_redraw(); h.request_redraw();
@ -105,7 +95,8 @@ impl GuiElem for StatusBar {
); );
self.c_song_label.config_mut().pos = Rectangle::from_tuples( self.c_song_label.config_mut().pos = Rectangle::from_tuples(
( (
self.cover_aspect_ratio.value * info.pos.height() / info.pos.width(), self.cover_aspect_ratio.value(info.time) as f32 * info.pos.height()
/ info.pos.width(),
0.0, 0.0,
), ),
(buttons_right_pos - buttons_width, 1.0), (buttons_right_pos - buttons_width, 1.0),
@ -125,6 +116,7 @@ impl GuiElem for StatusBar {
info.pos.top_left().x + info.pos.height() * 0.05, info.pos.top_left().x + info.pos.height() * 0.05,
info.pos.top_left().y + info.pos.height() * 0.05, info.pos.top_left().y + info.pos.height() * 0.05,
info.pos.top_left().y + info.pos.height() * 0.95, info.pos.top_left().y + info.pos.height() * 0.95,
info.time,
&mut self.cover_aspect_ratio, &mut self.cover_aspect_ratio,
); );
} }

View File

@ -1,4 +1,4 @@
use std::{fmt::Display, rc::Rc, sync::Arc}; use std::{fmt::Display, sync::Arc};
use musicdb_lib::data::CoverId; use musicdb_lib::data::CoverId;
use speedy2d::{ use speedy2d::{
@ -30,7 +30,7 @@ pub struct Content {
text: String, text: String,
color: Color, color: Color,
background: Option<Color>, background: Option<Color>,
formatted: Option<Rc<FormattedTextBlock>>, formatted: Option<FormattedTextBlock>,
} }
#[allow(unused)] #[allow(unused)]

View File

@ -1,4 +1,5 @@
// #![allow(unused)] #![allow(dead_code)]
#![allow(unused_variables)]
use std::{ use std::{
io::{BufReader, Write}, io::{BufReader, Write},
@ -31,6 +32,7 @@ mod gui;
mod gui_anim; mod gui_anim;
#[cfg(feature = "speedy2d")] #[cfg(feature = "speedy2d")]
mod gui_base; mod gui_base;
pub mod gui_edit_any;
#[cfg(feature = "speedy2d")] #[cfg(feature = "speedy2d")]
mod gui_edit_song; mod gui_edit_song;
#[cfg(feature = "speedy2d")] #[cfg(feature = "speedy2d")]
@ -146,7 +148,6 @@ fn main() {
| Mode::GuiSyncplayerNetwork | Mode::GuiSyncplayerNetwork
); );
#[cfg(feature = "playback")] #[cfg(feature = "playback")]
#[allow(unused)]
let mut cache_manager = None; let mut cache_manager = None;
#[cfg(feature = "playback")] #[cfg(feature = "playback")]
let mut player = if is_syncplayer { let mut player = if is_syncplayer {
@ -160,6 +161,12 @@ fn main() {
} else { } else {
None None
}; };
// prevent unused assignment warning, we might
// need cache manager at some point -_-
#[cfg(feature = "playback")]
if false {
drop(cache_manager);
}
#[allow(unused_labels)] #[allow(unused_labels)]
'ifstatementworkaround: { 'ifstatementworkaround: {
// use if+break instead of if-else because we can't #[cfg(feature)] the if statement, // use if+break instead of if-else because we can't #[cfg(feature)] the if statement,

View File

@ -6,6 +6,6 @@ edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies] [dependencies]
id3 = "1.16.0" id3 = "1.16.3"
mp3-duration = "0.1.10" mp3-duration = "0.1.10"
musicdb-lib = { version = "0.1.0", path = "../musicdb-lib" } musicdb-lib = { version = "0.1.0", path = "../musicdb-lib" }

View File

@ -4,7 +4,8 @@ use std::{
fs, fs,
io::Write, io::Write,
path::{Path, PathBuf}, path::{Path, PathBuf},
sync::{Arc, Mutex}, time::SystemTime, sync::{Arc, Mutex},
time::SystemTime,
}; };
use id3::TagLike; use id3::TagLike;
@ -26,21 +27,31 @@ fn main() {
std::process::exit(1); std::process::exit(1);
}; };
let mut bad_arg = false; let mut bad_arg = false;
let mut dbdir = ".".to_owned();
let mut skip_duration = false; let mut skip_duration = false;
let mut custom_files = None; let mut custom_files = None;
let mut artist_txt = false;
let mut artist_img = false; let mut artist_img = false;
let mut export_custom_files = None;
loop { loop {
match args.next() { match args.next() {
None => break, None => break,
Some(arg) => match arg.as_str() { Some(arg) => match arg.as_str() {
"--help" => { "--help" => {
eprintln!("--dbdir <path>: Save dbfile in the <path> directory (default: `.`)");
eprintln!("--skip-duration: Don't try to figure out the songs duration from file contents. This means mp3 files with the Duration field unset will have a duration of 0."); eprintln!("--skip-duration: Don't try to figure out the songs duration from file contents. This means mp3 files with the Duration field unset will have a duration of 0.");
eprintln!("--custom-files <path>: server will use <path> as its custom-files directory."); eprintln!("--custom-files <path>: server will use <path> as its custom-files directory. Additional data is loaded from here.");
eprintln!("--cf-artist-txt: For each artist, check for an <artist>.txt file. If it exists, add each line as a tag to that artist.");
eprintln!("--cf-artist-img: For each artist, check for an <artist>.{{jpg,png,...}} file. If it exists, add ImageExt=<extension> tag to the artist, so the image can be loaded by clients later."); eprintln!("--cf-artist-img: For each artist, check for an <artist>.{{jpg,png,...}} file. If it exists, add ImageExt=<extension> tag to the artist, so the image can be loaded by clients later.");
eprintln!("--export-custom-files <path>: Create <path> as a directory containing metadata from the *existing* dbfile, so that it can be loaded again using --custom-files <same-path>.");
return; return;
} }
"--dbdir" => {
if let Some(dir) = args.next() {
dbdir = dir;
} else {
bad_arg = true;
eprintln!("--dbdir <path> :: missing <path>!");
}
}
"--skip-duration" => skip_duration = true, "--skip-duration" => skip_duration = true,
"--custom-files" => { "--custom-files" => {
if let Some(path) = args.next() { if let Some(path) = args.next() {
@ -50,8 +61,15 @@ fn main() {
eprintln!("--custom-files <path> :: missing <path>!"); eprintln!("--custom-files <path> :: missing <path>!");
} }
} }
"--cf-artist-txt" => artist_txt = true,
"--cf-artist-img" => artist_img = true, "--cf-artist-img" => artist_img = true,
"--export-custom-files" => {
if let Some(path) = args.next() {
export_custom_files = Some(PathBuf::from(path));
} else {
bad_arg = true;
eprintln!("--export-custom-files <path> :: missing <path>!");
}
}
arg => { arg => {
bad_arg = true; bad_arg = true;
eprintln!("Unknown argument: {arg}"); eprintln!("Unknown argument: {arg}");
@ -59,9 +77,19 @@ fn main() {
}, },
} }
} }
if export_custom_files.is_some() {
if skip_duration || custom_files.is_some() || artist_img {
bad_arg = true;
eprintln!("--export-custom-files :: incompatible with other arguments except --dbdir!");
}
}
if bad_arg { if bad_arg {
return; return;
} }
if let Some(path) = export_custom_files {
export_to_custom_files_dir(dbdir, path);
return;
}
eprintln!("Library: {lib_dir}. press enter to start. result will be saved in 'dbfile'."); eprintln!("Library: {lib_dir}. press enter to start. result will be saved in 'dbfile'.");
std::io::stdin().read_line(&mut String::new()).unwrap(); std::io::stdin().read_line(&mut String::new()).unwrap();
// start // start
@ -90,7 +118,7 @@ fn main() {
} }
} }
eprintln!("\nloaded metadata of {} files.", songs.len()); eprintln!("\nloaded metadata of {} files.", songs.len());
let mut database = Database::new_empty_in_dir(PathBuf::from("."), PathBuf::from(&lib_dir)); let mut database = Database::new_empty_in_dir(PathBuf::from(dbdir), PathBuf::from(&lib_dir));
let unknown_artist = database.add_artist_new(Artist { let unknown_artist = database.add_artist_new(Artist {
id: 0, id: 0,
name: format!("<unknown>"), name: format!("<unknown>"),
@ -133,21 +161,21 @@ fn main() {
let mut general = GeneralData::default(); let mut general = GeneralData::default();
match (song_tags.track(), song_tags.total_tracks()) { match (song_tags.track(), song_tags.total_tracks()) {
(None, None) => {} (None, None) => {}
(Some(n), Some(t)) => general.tags.push(format!("TrackNr={n}/{t}")), (Some(n), Some(t)) => general.tags.push(format!("SRCFILE:TrackNr={n}/{t}")),
(Some(n), None) => general.tags.push(format!("TrackNr={n}")), (Some(n), None) => general.tags.push(format!("SRCFILE:TrackNr={n}")),
(None, Some(t)) => general.tags.push(format!("TrackNr=?/{t}")), (None, Some(t)) => general.tags.push(format!("SRCFILE:TrackNr=?/{t}")),
} }
match (song_tags.disc(), song_tags.total_discs()) { match (song_tags.disc(), song_tags.total_discs()) {
(None, None) => {} (None, None) => {}
(Some(n), Some(t)) => general.tags.push(format!("DiscNr={n}/{t}")), (Some(n), Some(t)) => general.tags.push(format!("SRCFILE:DiscNr={n}/{t}")),
(Some(n), None) => general.tags.push(format!("DiscNr={n}")), (Some(n), None) => general.tags.push(format!("SRCFILE:DiscNr={n}")),
(None, Some(t)) => general.tags.push(format!("DiscNr=?/{t}")), (None, Some(t)) => general.tags.push(format!("SRCFILE:DiscNr=?/{t}")),
} }
if let Some(year) = song_tags.year() { if let Some(year) = song_tags.year() {
general.tags.push(format!("Year={year}")); general.tags.push(format!("SRCFILE:Year={year}"));
} }
if let Some(genre) = song_tags.genre_parsed() { if let Some(genre) = song_tags.genre_parsed() {
general.tags.push(format!("Genre={genre}")); general.tags.push(format!("SRCFILE:Genre={genre}"));
} }
let (artist_id, album_id) = if let Some(artist) = song_tags let (artist_id, album_id) = if let Some(artist) = song_tags
.album_artist() .album_artist()
@ -249,7 +277,6 @@ fn main() {
song_path song_path
); );
None None
} }
}, },
title.clone(), title.clone(),
@ -280,6 +307,30 @@ fn main() {
general, general,
)); ));
} }
{
let (artists, albums, songs) = database.artists_albums_songs_mut();
fn unsrcfile(tags: &mut Vec<String>) {
let srcfile_tags = tags
.iter()
.filter_map(|tag| tag.strip_prefix("SRCFILE:"))
.map(|tag| tag.to_owned())
.collect::<Vec<_>>();
for tag in srcfile_tags {
if !tags.contains(&tag) {
tags.push(tag.to_owned());
}
}
}
for v in artists.values_mut() {
unsrcfile(&mut v.general.tags);
}
for v in albums.values_mut() {
unsrcfile(&mut v.general.tags);
}
for v in songs.values_mut() {
unsrcfile(&mut v.general.tags);
}
}
eprintln!("searching for covers..."); eprintln!("searching for covers...");
let mut multiple_cover_options = vec![]; let mut multiple_cover_options = vec![];
let mut single_images = HashMap::new(); let mut single_images = HashMap::new();
@ -335,23 +386,6 @@ fn main() {
} }
} }
if let Some(custom_files) = custom_files { if let Some(custom_files) = custom_files {
if artist_txt {
eprintln!("[info] Searching for <artist>.txt files in custom-files dir...");
let l = database.artists().len();
let mut c = 0;
for (i, artist) in database.artists_mut().values_mut().enumerate() {
if let Ok(info) =
fs::read_to_string(custom_files.join(format!("{}.txt", artist.name)))
{
c += 1;
for line in info.lines() {
artist.general.tags.push(line.to_owned());
}
}
eprint!(" {}/{l} ({c})\r", i + 1);
}
eprintln!();
}
if artist_img { if artist_img {
eprintln!("[info] Searching for <artist>.{{png,jpg,...}} files in custom-files dir..."); eprintln!("[info] Searching for <artist>.{{png,jpg,...}} files in custom-files dir...");
match fs::read_dir(&custom_files) { match fs::read_dir(&custom_files) {
@ -382,12 +416,102 @@ fn main() {
} }
for artist in database.artists_mut().values_mut() { for artist in database.artists_mut().values_mut() {
if let Some(ext) = files.get(&artist.name) { if let Some(ext) = files.get(&artist.name) {
artist.general.tags.push(format!("SRCFILE:ImageExt={ext}"));
artist.general.tags.push(format!("ImageExt={ext}")); artist.general.tags.push(format!("ImageExt={ext}"));
} }
} }
} }
} }
} }
eprintln!("[info] Searching for <artist>.tags, <artist>.d/<album>.tags, <artist>.d/singles.d/<song>.tags, <artist>.d/<album>.d/<song>.tags in custom-files dir...");
let l = database.artists().len() + database.albums().len() + database.songs().len();
let mut cc = 0;
let mut c = 0;
let (artists, albums, songs) = database.artists_albums_songs_mut();
fn push_tags(info: &str, tags: &mut Vec<String>) {
for line in info.lines() {
let tag = normalized_str_to_tag(line);
if !tags.contains(&tag) {
tags.push(tag);
}
}
}
for artist in artists.values_mut() {
// <artist>.tags
cc += 1;
if let Ok(info) = fs::read_to_string(custom_files.join(format!(
"{}.tags",
normalize_to_file_path_component_for_custom_files(&artist.name)
))) {
c += 1;
push_tags(&info, &mut artist.general.tags);
}
// <artist>.d/
let dir = custom_files.join(format!(
"{}.d",
normalize_to_file_path_component_for_custom_files(&artist.name)
));
if fs::metadata(&dir).is_ok_and(|meta| meta.is_dir()) {
// <artist>.d/singles/
{
let dir = dir.join("singles");
for song in artist.singles.iter() {
// <artist>.d/singles/<song>.tags
cc += 1;
if let Some(song) = songs.get_mut(song) {
if let Ok(info) = fs::read_to_string(dir.join(format!(
"{}.tags",
normalize_to_file_path_component_for_custom_files(&song.title)
))) {
c += 1;
push_tags(&info, &mut song.general.tags);
}
}
}
}
for album in artist.albums.iter() {
eprint!(" {cc}/{l} ({c})\r");
cc += 1;
if let Some(album) = albums.get_mut(album) {
// <artist>.d/<album>.tags
if let Ok(info) = fs::read_to_string(dir.join(format!(
"{}.tags",
normalize_to_file_path_component_for_custom_files(&album.name)
))) {
c += 1;
push_tags(&info, &mut album.general.tags);
}
// <artist>.d/<album>.d/
let dir = dir.join(format!(
"{}.d",
normalize_to_file_path_component_for_custom_files(&album.name)
));
for song in album.songs.iter() {
cc += 1;
if let Some(song) = songs.get_mut(song) {
// <artist>.d/<album>.d/<song>.tags
if let Ok(info) = fs::read_to_string(dir.join(format!(
"{}.tags",
normalize_to_file_path_component_for_custom_files(&song.title)
))) {
c += 1;
push_tags(&info, &mut song.general.tags);
}
}
}
}
}
} else {
cc += artist.albums.len();
for album in artist.albums.iter() {
if let Some(album) = albums.get(album) {
cc += album.songs.len();
}
}
}
eprint!(" {cc}/{l} ({c})\r");
}
eprintln!();
} }
eprintln!("saving dbfile..."); eprintln!("saving dbfile...");
database.save_database(None).unwrap(); database.save_database(None).unwrap();
@ -471,3 +595,199 @@ fn get_cover(
None None
} }
} }
fn normalize_to_file_path_component_for_custom_files(str: &str) -> String {
str.replace('%', "%p")
.replace('\0', "%0")
.replace('/', "%s")
.replace('\\', "%S")
.replace('\t', "%t")
.replace('\r', "%r")
.replace('\n', "%n")
}
fn normalize_tag_to_str(str: &str) -> String {
str.replace('\\', "\\S")
.replace('\n', "\\n")
.replace('\r', "\\r")
}
fn normalized_str_to_tag(str: &str) -> String {
str.replace(['\n', '\r'], "")
.replace("\\n", "\n")
.replace("\\r", "\r")
.replace("\\S", "\\")
}
fn export_to_custom_files_dir(dbdir: String, path: PathBuf) {
let database = Database::load_database_from_dir(dbdir.into(), PathBuf::new()).unwrap();
for artist in database.artists().values() {
export_custom_files_tags(
&artist.general.tags,
&path.join(format!(
"{}.tags",
normalize_to_file_path_component_for_custom_files(&artist.name)
)),
);
let dir = path.join(format!(
"{}.d",
normalize_to_file_path_component_for_custom_files(&artist.name)
));
{
let dir = dir.join("singles");
for song in artist.singles.iter() {
if let Some(song) = database.songs().get(song) {
export_custom_files_tags(
&song.general.tags,
&dir.join(format!(
"{}.tags",
normalize_to_file_path_component_for_custom_files(&song.title,)
)),
);
}
}
}
for album in artist.albums.iter() {
if let Some(album) = database.albums().get(album) {
export_custom_files_tags(
&album.general.tags,
&dir.join(format!(
"{}.tags",
normalize_to_file_path_component_for_custom_files(&album.name,)
)),
);
let dir = dir.join(format!(
"{}.d",
normalize_to_file_path_component_for_custom_files(&album.name,)
));
for song in album.songs.iter() {
if let Some(song) = database.songs().get(song) {
export_custom_files_tags(
&song.general.tags,
&dir.join(format!(
"{}.tags",
normalize_to_file_path_component_for_custom_files(&song.title,)
)),
);
}
}
}
}
}
}
fn export_custom_files_tags(tags: &Vec<String>, path: &Path) {
if let Some(file_name) = path.file_name().and_then(|n| n.to_str()) {
let mut normalized_tags = None;
fn mk_normalized_tags<'a>(
normalized_tags: &'a mut Option<Vec<String>>,
tags: &'_ Vec<String>,
) -> &'a Vec<String> {
&*normalized_tags.get_or_insert_with(|| {
let mut tags = tags.clone();
let mut rm = Vec::new();
for (i, srcfile_tag_stripped) in tags
.iter()
.enumerate()
.filter_map(|(i, tag)| Some((i, tag.strip_prefix("SRCFILE:")?)))
{
match rm.binary_search(&i) {
Ok(_) => {}
Err(v) => rm.insert(v, i),
}
if let Some(i) = tags.iter().position(|tag| tag == srcfile_tag_stripped) {
// There is a tag which just repeats the information
// which is already present in the source (audio) file.
// We do not want to save this information, so that,
// if the audio file is replaced in the future, its new
// information is used by musicdb, and musicdb-internal
// information is only used if it was changed to be different
// from the source file by the user.
match rm.binary_search(&i) {
Ok(_) => {}
Err(v) => rm.insert(v, i),
}
}
}
for i in rm.into_iter().rev() {
tags.remove(i);
}
tags
})
}
let allow_write = match fs::exists(path) {
Err(e) => {
eprintln!("Cannot check for {}, skipping. Error: {e}", path.display());
false
}
Ok(false) => true,
Ok(true) => {
if fs::read_to_string(path).is_ok_and(|file| {
file.lines()
.map(|str| normalized_str_to_tag(str))
.collect::<Vec<String>>()
== *mk_normalized_tags(&mut normalized_tags, tags)
}) {
// file contains the same tags as database, don't write,
// but don't create backup either
false
} else {
let backup_path = path.with_file_name(format!("{file_name}.backup"));
match fs::exists(&backup_path) {
Err(e) => {
eprintln!(
"Cannot check for {}, skipping {}. Error: {e}",
backup_path.display(),
path.display()
);
false
}
Ok(true) => {
eprintln!(
"Backup {} exists, skipping {}.",
backup_path.display(),
path.display()
);
false
}
Ok(false) => {
if let Err(e) = fs::rename(path, &backup_path) {
eprintln!(
"Failed to move previous file/dir {} to {}: {e}",
path.display(),
backup_path.display()
);
false
} else {
true
}
}
}
}
}
};
if allow_write {
if !mk_normalized_tags(&mut normalized_tags, tags).is_empty() {
if let Some(p) = path.parent() {
if let Err(e) = fs::create_dir_all(p) {
eprintln!(
"Could not create directory to contain {}: {e}",
path.display()
);
}
}
if let Err(e) = fs::write(
path,
mk_normalized_tags(&mut normalized_tags, tags)
.iter()
.map(|tag| normalize_tag_to_str(tag) + "\n")
.collect::<String>(),
) {
eprintln!("Could not save {}: {e}", path.display());
}
}
}
} else {
eprintln!(
"[ERR] Somehow created a non-unicode path {path:?}! This should not have happened!"
);
}
}

View File

@ -7,10 +7,10 @@ edition = "2021"
base64 = "0.22.1" base64 = "0.22.1"
colorize = "0.1.0" colorize = "0.1.0"
playback-rs = { version = "0.4.4", optional = true } playback-rs = { version = "0.4.4", optional = true }
rand = "0.8.5" rand = "0.9.2"
rc-u8-reader = "2.0.16" rc-u8-reader = "2.0.16"
rodio = { version = "0.20.1", optional = true } rodio = { version = "0.21.1", optional = true }
sysinfo = "0.30.12" sysinfo = "0.37.0"
[features] [features]
default = [] default = []

View File

@ -39,8 +39,8 @@ impl CacheManager {
let sleep_dur_long = Duration::from_secs(20); let sleep_dur_long = Duration::from_secs(20);
let sleep_dur_short = Duration::from_secs(1); let sleep_dur_short = Duration::from_secs(1);
let mut si = sysinfo::System::new_with_specifics( let mut si = sysinfo::System::new_with_specifics(
sysinfo::RefreshKind::new() sysinfo::RefreshKind::nothing()
.with_memory(sysinfo::MemoryRefreshKind::new().with_ram()), .with_memory(sysinfo::MemoryRefreshKind::nothing().with_ram()),
); );
eprintln!("[{}] Starting CacheManager", "INFO".cyan()); eprintln!("[{}] Starting CacheManager", "INFO".cyan());
let mut sleep_short = true; let mut sleep_short = true;
@ -54,7 +54,7 @@ impl CacheManager {
}); });
sleep_short = false; sleep_short = false;
// memory stuff // memory stuff
si.refresh_memory_specifics(sysinfo::MemoryRefreshKind::new().with_ram()); si.refresh_memory_specifics(sysinfo::MemoryRefreshKind::nothing().with_ram());
let available_memory = si.available_memory(); let available_memory = si.available_memory();
let min_avail_mem = min_avail_mem.load(std::sync::atomic::Ordering::Relaxed); let min_avail_mem = min_avail_mem.load(std::sync::atomic::Ordering::Relaxed);
let low_memory = available_memory < min_avail_mem; let low_memory = available_memory < min_avail_mem;

View File

@ -9,7 +9,7 @@ use std::{
}; };
use colorize::AnsiColor; use colorize::AnsiColor;
use rand::thread_rng; use rand::rng;
use crate::{ use crate::{
load::ToFromBytes, load::ToFromBytes,
@ -690,7 +690,7 @@ impl Database {
}) = elem.content_mut() }) = elem.content_mut()
{ {
let mut ord: Vec<usize> = (0..content.len()).collect(); let mut ord: Vec<usize> = (0..content.len()).collect();
ord.shuffle(&mut thread_rng()); ord.shuffle(&mut rng());
self.apply_action_unchecked_seq( self.apply_action_unchecked_seq(
Action::QueueSetShuffle(path, ord, set_index), Action::QueueSetShuffle(path, ord, set_index),
client, client,
@ -1145,6 +1145,15 @@ impl Database {
self.modified_data(); self.modified_data();
&mut self.covers &mut self.covers
} }
pub fn artists_albums_songs_mut(
&mut self,
) -> (
&mut HashMap<ArtistId, Artist>,
&mut HashMap<AlbumId, Album>,
&mut HashMap<SongId, Song>,
) {
(&mut self.artists, &mut self.albums, &mut self.songs)
}
} }
#[derive(Clone, Debug)] #[derive(Clone, Debug)]

View File

@ -418,7 +418,7 @@ impl Queue {
} }
impl QueueFolder { impl QueueFolder {
pub fn iter(&self) -> QueueFolderIter { pub fn iter(&self) -> QueueFolderIter<'_> {
QueueFolderIter { QueueFolderIter {
folder: self, folder: self,
index: 0, index: 0,

View File

@ -1,7 +1,7 @@
use std::{ffi::OsStr, sync::Arc}; use std::{ffi::OsStr, sync::Arc};
use rc_u8_reader::ArcU8Reader; use rc_u8_reader::ArcU8Reader;
use rodio::{decoder::DecoderError, Decoder, OutputStream, OutputStreamHandle, Sink, Source}; use rodio::{decoder::DecoderError, Decoder, OutputStream, Sink, Source};
use crate::{ use crate::{
data::{song::Song, SongId}, data::{song::Song, SongId},
@ -13,8 +13,6 @@ use super::PlayerBackend;
pub struct PlayerBackendRodio<T> { pub struct PlayerBackendRodio<T> {
#[allow(unused)] #[allow(unused)]
output_stream: OutputStream, output_stream: OutputStream,
#[allow(unused)]
output_stream_handle: OutputStreamHandle,
sink: Sink, sink: Sink,
stopped: bool, stopped: bool,
current: Option<(SongId, Arc<Vec<u8>>, Option<u128>, T)>, current: Option<(SongId, Arc<Vec<u8>>, Option<u128>, T)>,
@ -34,11 +32,11 @@ impl<T> PlayerBackendRodio<T> {
pub fn new_with_optional_command_sending( pub fn new_with_optional_command_sending(
command_sender: Option<std::sync::mpsc::Sender<(Command, Option<u64>)>>, command_sender: Option<std::sync::mpsc::Sender<(Command, Option<u64>)>>,
) -> Result<Self, Box<dyn std::error::Error>> { ) -> Result<Self, Box<dyn std::error::Error>> {
let (output_stream, output_stream_handle) = rodio::OutputStream::try_default()?; let output_stream =
let sink = Sink::try_new(&output_stream_handle)?; rodio::OutputStreamBuilder::from_default_device()?.open_stream_or_fallback()?;
let sink = Sink::connect_new(&output_stream.mixer());
Ok(Self { Ok(Self {
output_stream, output_stream,
output_stream_handle,
sink, sink,
stopped: true, stopped: true,
current: None, current: None,

View File

@ -437,7 +437,7 @@ pub fn run_server_caching_thread_opt(
pub fn handle_one_connection_as_main( pub fn handle_one_connection_as_main(
db: Arc<Mutex<Database>>, db: Arc<Mutex<Database>>,
connection: &mut impl Read, connection: &mut impl Read,
mut send_to: (impl Write + Sync + Send + 'static), mut send_to: impl Write + Sync + Send + 'static,
command_sender: &mpsc::Sender<(Command, Option<u64>)>, command_sender: &mpsc::Sender<(Command, Option<u64>)>,
) -> Result<(), std::io::Error> { ) -> Result<(), std::io::Error> {
// sync database // sync database

View File

@ -7,12 +7,12 @@ edition = "2021"
[dependencies] [dependencies]
musicdb-lib = { path = "../musicdb-lib" } musicdb-lib = { path = "../musicdb-lib" }
clap = { version = "4.4.6", features = ["derive"] } clap = { version = "4.5.45", features = ["derive"] }
headers = "0.3.8" headers = "0.4.1"
serde = { version = "1.0", features = ["derive"] } serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0" serde_json = "1.0"
tokio = { version = "1.37.0", optional = true, features = ["rt"] } tokio = { version = "1.47.1", optional = true, features = ["rt"] }
rocket = { version = "0.5.0", optional = true } rocket = { version = "0.5.1", optional = true }
html-escape = { version = "0.2.13", optional = true } html-escape = { version = "0.2.13", optional = true }
rocket_ws = "0.1.1" rocket_ws = "0.1.1"
rocket_seek_stream = "0.2.6" rocket_seek_stream = "0.2.6"

View File

@ -302,14 +302,14 @@ fn now_playing_ids(data: &State<Data>) -> String {
} }
#[get("/song/<id>")] #[get("/song/<id>")]
fn song1(data: &State<Data>, id: SongId) -> Option<SeekStream> { fn song1(data: &State<Data>, id: SongId) -> Option<SeekStream<'_>> {
song(data, id) song(data, id)
} }
#[get("/song/<id>/<_>")] #[get("/song/<id>/<_>")]
fn song2(data: &State<Data>, id: SongId) -> Option<SeekStream> { fn song2(data: &State<Data>, id: SongId) -> Option<SeekStream<'_>> {
song(data, id) song(data, id)
} }
fn song(data: &State<Data>, id: SongId) -> Option<SeekStream> { fn song(data: &State<Data>, id: SongId) -> Option<SeekStream<'_>> {
let db = data.db.lock().unwrap(); let db = data.db.lock().unwrap();
if let Some(song) = db.get_song(&id) { if let Some(song) = db.get_song(&id) {
song.cached_data().cache_data_start_thread(&*db, song); song.cached_data().cache_data_start_thread(&*db, song);