1mod async_utils;
2mod dataloading;
3mod macros;
4mod traktor_api;
5mod ui;
6
7use crate::async_utils::run_subscription_with;
8use crate::dataloading::dataprovider::song_data_provider::{
9 SongChange, SongDataEdit, SongDataProvider, SongDataSource,
10};
11use crate::dataloading::id3tagreader::read_song_info_from_filepath;
12use crate::dataloading::m3uloader::load_tag_data_from_m3u;
13use crate::dataloading::songinfo::SongInfo;
14use crate::traktor_api::{ServerMessage, StateUpdate, TraktorMessage, TraktorSyncAction};
15use crate::ui::config_window::sidebar::SidebarMessage;
16use crate::ui::config_window::{ConfigWindow, PLAYLIST_SCROLLABLE_ID};
17use crate::ui::song_window::SongWindow;
18use iced::keyboard::key::Named;
19use iced::keyboard::{Key, Modifiers};
20use iced::widget::operation::{scroll_by, snap_to};
21use iced::widget::scrollable::{AbsoluteOffset, RelativeOffset};
22use iced::widget::space::horizontal;
23use iced::window::icon::from_file_data;
24use iced::{Element, Size, Subscription, Task, Theme, exit, keyboard, system, theme, window};
25use iced_aw::ICED_AW_FONT_BYTES;
26use rfd::FileDialog;
27use std::env::var;
28use std::path::PathBuf;
29use std::time::Instant;
30
31fn main() -> iced::Result {
32 iced::daemon(
33 DanceInterpreter::new,
34 DanceInterpreter::update,
35 DanceInterpreter::view,
36 )
37 .title(DanceInterpreter::title)
38 .theme(DanceInterpreter::theme)
39 .font(ICED_AW_FONT_BYTES)
40 .subscription(DanceInterpreter::subscription)
41 .run()
42}
43
44pub trait Window {
45 fn new(id: window::Id) -> Self;
46
47 fn on_resize(&mut self, size: Size);
48 fn on_close(&mut self);
49
50 fn is_closed(&self) -> bool;
51}
52
53struct DanceInterpreter {
54 config_window: ConfigWindow,
55 song_window: SongWindow,
56
57 data_provider: SongDataProvider,
58}
59
60#[derive(Debug, Clone)]
61pub enum Message {
62 Noop,
63
64 WindowOpened(window::Id),
65 WindowResized((window::Id, Size)),
66 WindowClosed(window::Id),
67
68 ThemeChanged(theme::Mode),
69 SetConfigTheme(Theme),
70
71 ToggleFullscreen,
72 SetFullscreen(bool),
73
74 OpenPlaylist,
75 ReloadStatics,
76 AddSong(SongInfo),
77 DeleteSong(SongDataSource),
78 ScrollBy(f32),
79 SnapTo(RelativeOffset),
80 ToggleStaticsView,
81 AddBlankSong(RelativeOffset),
82 Sidebar(SidebarMessage),
83 Animate,
84
85 FileDropped(PathBuf),
86 SongChanged(SongChange),
87 SongDataEdit(usize, SongDataEdit),
88 SetNextSong(SongDataSource),
89
90 EnableImage(bool),
91 EnableNextDance(bool),
92 ChangeSongWindowScale(f32),
93 EnableAutoscroll(bool),
94 EnableFollowSystemTheme(bool),
95
96 Traktor(TraktorMessage),
97}
98
99impl DanceInterpreter {
100 pub fn new() -> (Self, Task<Message>) {
101 let mut tasks = Vec::new();
102
103 let icon = from_file_data(
104 include_bytes!(res_file!("icon_light.png")),
105 Some(image::ImageFormat::Png),
106 )
107 .ok();
108
109 let (config_window, cw_opened) = Self::open_window(window::Settings {
110 platform_specific: Self::get_platform_specific(),
111 icon: icon.clone(),
112 ..Default::default()
113 });
114 let (song_window, sw_opened) = Self::open_window(window::Settings {
115 platform_specific: Self::get_platform_specific(),
116 icon: icon.clone(),
117 ..Default::default()
118 });
119
120 let state = Self {
121 config_window,
122 song_window,
123
124 data_provider: SongDataProvider::default(),
125 };
126
127 tasks.push(cw_opened);
128 tasks.push(sw_opened);
129 tasks.push(system::theme().map(Message::ThemeChanged));
130
131 tasks.push(
132 iced::font::load(include_bytes!(res_file!("symbols.ttf"))).map(|_| Message::Noop),
133 );
134
135 tasks.push(Task::done(Message::ReloadStatics));
136
137 (state, Task::batch(tasks))
138 }
139
140 fn open_window<T: Window>(settings: window::Settings) -> (T, Task<Message>) {
141 let (id, open) = window::open(settings);
142 (T::new(id), open.map(Message::WindowOpened))
143 }
144
145 fn get_platform_specific() -> window::settings::PlatformSpecific {
146 #[cfg(target_os = "linux")]
147 return window::settings::PlatformSpecific {
148 application_id: "danceinterpreter".to_string(),
149 ..Default::default()
150 };
151
152 #[cfg(not(target_os = "linux"))]
153 return Default::default();
154 }
155
156 pub fn title(&self, window_id: window::Id) -> String {
157 if self.config_window.id == window_id {
158 "Config Window".to_string()
159 } else if self.song_window.id == window_id {
160 "Song Window".to_string()
161 } else {
162 String::new()
163 }
164 }
165
166 pub fn view(&self, window_id: window::Id) -> Element<'_, Message> {
167 if self.config_window.id == window_id {
168 self.config_window.view(self)
169 } else if self.song_window.id == window_id {
170 self.song_window.view(self)
171 } else {
172 horizontal().into()
173 }
174 }
175
176 pub fn update(&mut self, message: Message) -> Task<Message> {
177 self.config_window.sidebar.power_button_cache.clear();
178 self.config_window.sidebar.restart_button_cache.clear();
179
180 match message {
181 Message::WindowOpened(_) => ().into(),
182 Message::WindowResized((window_id, size)) => {
183 if self.config_window.id == window_id {
184 self.config_window.on_resize(size);
185 } else if self.song_window.id == window_id {
186 self.song_window.on_resize(size);
187 }
188
189 ().into()
190 }
191 Message::WindowClosed(window_id) => {
192 if self.config_window.id == window_id {
193 self.config_window.on_close();
194
195 if self.song_window.is_closed() {
196 exit()
197 } else {
198 window::close(self.song_window.id)
199 }
200 } else if self.song_window.id == window_id {
201 self.song_window.on_close();
202
203 if self.config_window.is_closed() {
204 exit()
205 } else {
206 window::close(self.config_window.id)
207 }
208 } else {
209 ().into()
210 }
211 }
212
213 Message::ThemeChanged(mode) => {
214 if self.config_window.follow_system_theme {
215 self.config_window.theme = match mode {
216 theme::Mode::Light => Theme::Light,
217 theme::Mode::Dark | theme::Mode::None => Theme::Dark,
218 };
219 }
220
221 let icon = from_file_data(
222 match mode {
223 theme::Mode::Light | theme::Mode::None => {
224 include_bytes!(res_file!("icon_light.png"))
225 }
226 theme::Mode::Dark => include_bytes!(res_file!("icon_dark.png")),
227 },
228 Some(image::ImageFormat::Png),
229 );
230
231 if let Ok(icon) = icon {
232 Task::batch([
233 window::set_icon(self.song_window.id, icon.clone()),
234 window::set_icon(self.config_window.id, icon),
235 ])
236 } else {
237 ().into()
238 }
239 }
240
241 Message::SetConfigTheme(theme) => {
242 self.config_window.theme = theme;
243 self.config_window.follow_system_theme = false;
244 ().into()
245 }
246
247 Message::ToggleFullscreen => {
248 let song_window_id = self.song_window.id;
249
250 window::mode(song_window_id)
251 .map(|mode| Message::SetFullscreen(mode != window::Mode::Fullscreen))
252 }
253 Message::SetFullscreen(fullscreen) => {
254 let song_window_id = self.song_window.id;
255
256 window::set_mode(
257 song_window_id,
258 if fullscreen {
259 window::Mode::Fullscreen
260 } else {
261 window::Mode::Windowed
262 },
263 )
264 }
265
266 Message::OpenPlaylist => {
267 #[cfg(target_os = "linux")]
268 if var("container").is_ok() {
269 let folder = FileDialog::new()
271 .set_title(
272 "Select folder containing m3u AND audio files (required in flatpak)",
273 )
274 .set_directory(
275 dirs::audio_dir()
276 .unwrap_or(dirs::home_dir().unwrap_or(PathBuf::from("."))),
277 )
278 .pick_folders();
279 if folder.is_none() {
280 return ().into();
281 }
282 }
283
284 let file = FileDialog::new()
286 .add_filter("Playlist", &["m3u", "m3u8"])
287 .add_filter("Any(*)", &["*"])
288 .set_title("Select playlist file")
289 .set_directory(
290 dirs::audio_dir().unwrap_or(dirs::home_dir().unwrap_or(PathBuf::from("."))),
291 )
292 .pick_file();
293
294 let Some(file) = file else {
295 return ().into();
296 };
297 println!("Selected file: {:?}", file);
298
299 let Ok(playlist) = load_tag_data_from_m3u(&file) else {
300 return ().into();
301 };
302
303 self.data_provider.set_vec(playlist);
304
305 ().into()
306 }
307
308 Message::ReloadStatics => {
309 let file_content = std::fs::read_to_string("./statics.txt");
310 let statics = file_content
311 .map(|c| {
312 c.trim()
313 .lines()
314 .filter_map(|l| {
315 let trimmed = l.trim();
316 (!trimmed.is_empty()).then_some(trimmed)
317 })
318 .map(|l| SongInfo::with_dance(l.to_owned()))
319 .collect()
320 })
321 .unwrap_or_default();
322
323 self.data_provider.set_statics(statics);
324
325 ().into()
326 }
327
328 Message::FileDropped(path) => {
329 if let Ok(playlist) = load_tag_data_from_m3u(&path) {
330 self.data_provider.set_vec(playlist);
331 } else if let Ok(song_info) = read_song_info_from_filepath(&path) {
332 self.data_provider.append_song(song_info);
333 }
334
335 ().into()
336 }
337
338 Message::SongChanged(song_change) => {
339 self.data_provider.handle_song_change(song_change);
340 self.try_scroll_to_song()
341 }
342
343 Message::SongDataEdit(i, edit) => {
344 self.data_provider.handle_song_data_edit(i, edit);
345 ().into()
346 }
347
348 Message::AddSong(song) => {
349 self.data_provider.append_song(song);
350 ().into()
351 }
352
353 Message::AddBlankSong(offset) => {
354 self.data_provider.append_song(SongInfo::default());
355 Task::done(Message::SnapTo(offset))
356 }
357
358 Message::DeleteSong(song) => {
359 self.data_provider.delete_song(song);
360 ().into()
361 }
362
363 Message::SetNextSong(i) => {
364 self.data_provider.set_next(i);
365 ().into()
366 }
367
368 Message::EnableImage(state) => {
369 self.song_window.enable_image = state;
370 ().into()
371 }
372
373 Message::EnableNextDance(state) => {
374 self.song_window.enable_next_dance = state;
375 ().into()
376 }
377
378 Message::ChangeSongWindowScale(value) => {
379 if (0.5..=3.0).contains(&value) {
380 self.song_window.scale = (value * 100.0).round() / 100.0;
381 } else {
382 self.song_window.scale = (((self.song_window.scale + value) * 100.0).round()
383 / 100.0)
384 .clamp(0.5, 3.0);
385 }
386 ().into()
387 }
388
389 Message::EnableAutoscroll(state) => {
390 self.config_window.enable_autoscroll = state;
391 ().into()
392 }
393
394 Message::EnableFollowSystemTheme(state) => {
395 self.config_window.follow_system_theme = state;
396
397 if state {
398 system::theme().map(Message::ThemeChanged)
399 } else {
400 ().into()
401 }
402 }
403
404 Message::ScrollBy(frac) => scroll_by(
405 PLAYLIST_SCROLLABLE_ID.clone(),
406 AbsoluteOffset {
407 x: 0.0,
408 y: self.config_window.size.height / frac,
409 },
410 ),
411
412 Message::SnapTo(offset) => snap_to(PLAYLIST_SCROLLABLE_ID.clone(), offset),
413
414 Message::ToggleStaticsView => {
415 self.config_window.is_statics_view = !self.config_window.is_statics_view;
416 ().into()
417 }
418
419 Message::Sidebar(msg) => match msg {
420 SidebarMessage::Toggle => {
421 self.config_window
422 .sidebar
423 .state
424 .go_mut(!self.config_window.sidebar.state.value(), Instant::now());
425 ().into()
426 }
427 SidebarMessage::UpdateAddressPresets => {
428 self.config_window
429 .sidebar
430 .update_network_interface_selection(&self.data_provider);
431 ().into()
432 }
433 },
434
435 Message::Traktor(msg) => match msg {
436 TraktorMessage::ServerMessage(msg) => {
437 self.data_provider.process_traktor_message(*msg);
438 self.run_traktor_sync_action();
439
440 self.try_scroll_to_song()
441 }
442
443 TraktorMessage::EnableServer(enabled) => {
444 self.data_provider.traktor_provider.set_enabled(enabled);
445 ().into()
446 }
447
448 TraktorMessage::ChangeAddress(addr) => {
449 self.data_provider.traktor_provider.address = addr;
450 ().into()
451 }
452
453 TraktorMessage::SubmitAddress => {
454 self.data_provider.traktor_provider.submitted_address =
455 self.data_provider.traktor_provider.address.clone();
456 ().into()
457 }
458
459 TraktorMessage::ChangeAndSubmitAddress(addr) => {
460 self.data_provider.traktor_provider.address = addr;
461 self.data_provider.traktor_provider.submitted_address =
462 self.data_provider.traktor_provider.address.clone();
463 ().into()
464 }
465
466 TraktorMessage::EnableDebugLogging(enabled) => {
467 self.data_provider.traktor_provider.debug_logging = enabled;
468 self.data_provider.traktor_provider.reconnect();
469 ().into()
470 }
471
472 TraktorMessage::Reconnect => {
473 self.data_provider.traktor_provider.reconnect();
474 ().into()
475 }
476
477 TraktorMessage::EnableSync(enabled) => {
478 self.data_provider.traktor_provider.sync = enabled;
479 self.traktor_provider_force_update()
480 }
481
482 TraktorMessage::SetSyncMode(mode) => {
483 self.data_provider.traktor_provider.sync_mode = mode;
484 self.traktor_provider_force_update()
485 }
486
487 TraktorMessage::SetNextMode(mode) => {
488 self.data_provider.traktor_provider.next_mode = mode;
489 self.traktor_provider_force_update()
490 }
491
492 TraktorMessage::SetNextModeFallback(mode) => {
493 self.data_provider.traktor_provider.next_mode_fallback = mode;
494 self.traktor_provider_force_update()
495 }
496 },
497
498 Message::Animate => Task::none(),
499
500 _ => ().into(),
501 }
502 }
503
504 fn traktor_provider_force_update(&mut self) -> Task<Message> {
505 if let Some(mixer_state) = self
507 .data_provider
508 .traktor_provider
509 .state
510 .as_ref()
511 .map(|s| s.mixer)
512 {
513 self.data_provider
514 .process_traktor_message(ServerMessage::Update(StateUpdate::Mixer(mixer_state)));
515 self.run_traktor_sync_action();
516 self.try_scroll_to_song()
517 } else {
518 ().into()
519 }
520 }
521
522 fn run_traktor_sync_action(&mut self) {
523 let action = self.data_provider.traktor_provider.take_sync_action();
524 if !self.data_provider.traktor_provider.sync {
525 return;
526 }
527
528 match action {
529 TraktorSyncAction::Relative(offset) => {
530 if offset >= 0 {
531 for _ in 0..offset {
532 self.data_provider.next();
533 }
534 } else {
535 for _ in 0..(-offset) {
536 self.data_provider.prev();
537 }
538 }
539 }
540 TraktorSyncAction::PlaylistAbsolute(pos) => {
541 self.data_provider
542 .set_current(SongDataSource::Playlist(pos));
543 }
544 }
545 }
546
547 fn try_scroll_to_song(&mut self) -> Task<Message> {
548 if let Some(index) = self.data_provider.take_scroll_index() {
549 let offset_y =
550 index as f32 / std::cmp::max(1, self.data_provider.playlist_songs.len() - 1) as f32;
551
552 Task::done(Message::SnapTo(RelativeOffset {
553 x: 0.0,
554 y: offset_y,
555 }))
556 } else {
557 ().into()
558 }
559 }
560
561 fn theme(&self, window_id: window::Id) -> Theme {
562 if self.song_window.id == window_id {
563 Theme::Dark
564 } else {
565 self.config_window.theme.clone()
566 }
567 }
568
569 pub fn subscription(&self) -> Subscription<Message> {
570 let mut subscriptions = vec![
571 window::close_events().map(Message::WindowClosed),
572 window::resize_events().map(Message::WindowResized),
573 window::events().map(|(_, event)| match event {
574 window::Event::FileDropped(path) => Message::FileDropped(path),
575 _ => Message::Noop,
576 }),
577 keyboard::listen().filter_map(|event| {
578 let keyboard::Event::KeyPressed { key, .. } = event else {
579 return None;
580 };
581
582 match key {
583 Key::Named(Named::ArrowRight) | Key::Named(Named::Space) => {
584 Some(Message::SongChanged(SongChange::Next))
585 }
586 Key::Named(Named::ArrowLeft) => {
587 Some(Message::SongChanged(SongChange::Previous))
588 }
589 Key::Named(Named::End) => {
590 Some(Message::SongChanged(SongChange::StaticAbsolute(0)))
591 }
592 Key::Named(Named::F11) => Some(Message::ToggleFullscreen),
593 Key::Named(Named::F5) => Some(Message::ReloadStatics),
594 Key::Named(Named::PageUp) => Some(Message::ScrollBy(-10.0)),
595 Key::Named(Named::PageDown) => Some(Message::ScrollBy(10.0)),
596 _ => None,
597 }
598 }),
599 keyboard::listen().filter_map(|event| {
600 let keyboard::Event::KeyPressed { key, modifiers, .. } = event else {
601 return None;
602 };
603 match (key.as_ref(), modifiers) {
604 (Key::Character("n"), Modifiers::CTRL) => {
605 Some(Message::AddBlankSong(RelativeOffset::END))
606 }
607 (Key::Character("+"), Modifiers::CTRL) => {
608 Some(Message::ChangeSongWindowScale(0.1))
609 }
610 (Key::Character("-"), Modifiers::CTRL) => {
611 Some(Message::ChangeSongWindowScale(-0.1))
612 }
613 (Key::Character("c"), Modifiers::ALT) => {
614 Some(Message::Sidebar(SidebarMessage::Toggle))
615 }
616 _ => None,
617 }
618 }),
619 system::theme_changes().map(Message::ThemeChanged),
620 if self
621 .config_window
622 .sidebar
623 .state
624 .is_animating(Instant::now())
625 {
626 window::frames().map(|_| Message::Animate)
627 } else {
628 Subscription::none()
629 },
630 ];
631
632 if let Some(addr) = self.data_provider.traktor_provider.get_socket_addr() {
633 subscriptions.push(
634 run_subscription_with(addr, |addr| traktor_api::run_server(*addr))
635 .map(|m| Message::Traktor(TraktorMessage::ServerMessage(Box::new(m)))),
636 );
637 }
638
639 Subscription::batch(subscriptions)
640 }
641}