1mod async_utils;
2mod dataloading;
3mod macros;
4mod traktor_api;
5mod ui;
6
7use crate::async_utils::run_subscription_with;
8use crate::dataloading::dataprovider::{
9 DataProvider, ItemChange, ItemSource, SongDataEdit, SubmitStaticResult,
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 fuzzy_matcher::FuzzyMatcher;
19use fuzzy_matcher::skim::SkimMatcherV2 as Matcher;
20use iced::keyboard::key::Named;
21use iced::keyboard::{Key, Modifiers};
22use iced::widget::operation::{scroll_by, snap_to};
23use iced::widget::scrollable::{AbsoluteOffset, RelativeOffset};
24use iced::widget::space::horizontal;
25use iced::window::icon::from_file_data;
26use iced::{Element, Size, Subscription, Task, Theme, exit, keyboard, system, theme, window};
27use iced_aw::ICED_AW_FONT_BYTES;
28use rfd::FileDialog;
29use std::env::var;
30use std::path::PathBuf;
31use std::time::Instant;
32
33fn main() -> iced::Result {
34 iced::daemon(
35 DanceInterpreter::new,
36 DanceInterpreter::update,
37 DanceInterpreter::view,
38 )
39 .title(DanceInterpreter::title)
40 .theme(DanceInterpreter::theme)
41 .font(ICED_AW_FONT_BYTES)
42 .subscription(DanceInterpreter::subscription)
43 .run()
44}
45
46pub trait Window {
47 fn new(id: window::Id) -> Self;
48
49 fn on_resize(&mut self, size: Size);
50 fn on_close(&mut self);
51
52 fn is_closed(&self) -> bool;
53}
54
55struct DanceInterpreter {
56 config_window: ConfigWindow,
57 song_window: SongWindow,
58
59 data_provider: DataProvider,
60 modifiers: Modifiers,
61}
62
63#[derive(Debug, Clone)]
64pub enum SearchMessage {
65 Toggle,
66 Changed(String),
67 Clear,
68}
69
70#[derive(Debug, Clone)]
71pub enum StaticsMessage {
72 Reload,
73 ToggleFavorite(String),
74 UpdateName(String, String),
75 SubmitName(String),
76 UpdateColor(String, iced::Color),
77 PreviewColor(String, iced::Color),
78 ToggleColorPicker(String),
79 AddBlank,
80 RequestMerge(String, String),
81 ConfirmMerge(String, String),
82}
83
84#[derive(Debug, Clone)]
85pub enum SongWindowMessage {
86 EnableImage(bool),
87 EnableNextDance(bool),
88 ChangeScale(f32),
89}
90
91#[derive(Debug, Clone)]
92pub enum DummySongMessage {
93 UpdateTitle(String),
94 UpdateArtist(String),
95 UpdateDance(String),
96 Submit,
97}
98
99#[derive(Debug, Clone)]
100pub enum DialogMessage {
101 Cancel,
102 RequestDelete(ItemSource),
103 ConfirmDelete,
104 CancelDelete,
105 UndoDelete,
106}
107
108#[derive(Debug, Clone)]
109pub enum Message {
110 Noop,
111
112 WindowOpened(window::Id),
113 WindowResized((window::Id, Size)),
114 WindowClosed(window::Id),
115
116 ThemeChanged(theme::Mode),
117 SetConfigTheme(Theme),
118
119 ToggleFullscreen,
120 SetFullscreen(bool),
121
122 ToggleStaticsView,
123 Sidebar(SidebarMessage),
124 Traktor(TraktorMessage),
125 Search(SearchMessage),
126 Statics(StaticsMessage),
127 SongWindow(SongWindowMessage),
128 DummySong(DummySongMessage),
129 Dialog(DialogMessage),
130
131 EnableAutoscroll(bool),
132 EnableFollowSystemTheme(bool),
133
134 ScrollBy(f32),
135 SnapTo(RelativeOffset),
136
137 OpenPlaylist,
138 AddBlankEntry,
139
140 AddSong(SongInfo),
141 DeleteItem(ItemSource),
142 AddBlankSong(RelativeOffset),
143 SongDataEdit(usize, SongDataEdit),
144 SubmitPlaylistDance(usize),
145
146 AddTraktorSong,
147
148 FileDropped(PathBuf),
149 ItemChanged(ItemChange),
150 SetNextItem(ItemSource),
151
152 Animate,
153 ModifiersChanged(Modifiers),
154}
155
156impl DanceInterpreter {
157 pub fn new() -> (Self, Task<Message>) {
158 let mut tasks = Vec::new();
159
160 let icon = from_file_data(
161 include_bytes!(res_file!("icon_light.png")),
162 Some(image::ImageFormat::Png),
163 )
164 .ok();
165
166 let (config_window, cw_opened) = Self::open_window(window::Settings {
167 platform_specific: Self::get_platform_specific(),
168 icon: icon.clone(),
169 ..Default::default()
170 });
171 let (song_window, sw_opened) = Self::open_window(window::Settings {
172 platform_specific: Self::get_platform_specific(),
173 icon: icon.clone(),
174 ..Default::default()
175 });
176
177 let state = Self {
178 config_window,
179 song_window,
180
181 data_provider: DataProvider::default(),
182 modifiers: Modifiers::default(),
183 };
184
185 tasks.push(cw_opened);
186 tasks.push(sw_opened);
187
188 tasks.push(system::theme().map(Message::ThemeChanged));
189
190 tasks.push(
191 iced::font::load(include_bytes!(res_file!("material-symbols.ttf")))
192 .map(|_| Message::Noop),
193 );
194 tasks.push(
195 iced::font::load(include_bytes!(res_file!("material-icons.ttf")))
196 .map(|_| Message::Noop),
197 );
198
199 tasks.push(Task::done(Message::Statics(StaticsMessage::Reload)));
200
201 (state, Task::batch(tasks))
202 }
203
204 fn open_window<T: Window>(settings: window::Settings) -> (T, Task<Message>) {
205 let (id, open) = window::open(settings);
206 (T::new(id), open.map(Message::WindowOpened))
207 }
208
209 fn get_platform_specific() -> window::settings::PlatformSpecific {
210 #[cfg(target_os = "linux")]
211 return window::settings::PlatformSpecific {
212 application_id: "danceinterpreter".to_string(),
213 ..Default::default()
214 };
215
216 #[cfg(not(target_os = "linux"))]
217 return Default::default();
218 }
219
220 pub fn title(&self, window_id: window::Id) -> String {
221 if self.config_window.id == window_id {
222 "Config Window".to_string()
223 } else if self.song_window.id == window_id {
224 "Song Window".to_string()
225 } else {
226 String::new()
227 }
228 }
229
230 pub fn view(&self, window_id: window::Id) -> Element<'_, Message> {
231 if self.config_window.id == window_id {
232 self.config_window.view(self)
233 } else if self.song_window.id == window_id {
234 self.song_window.view(self)
235 } else {
236 horizontal().into()
237 }
238 }
239
240 pub fn update(&mut self, message: Message) -> Task<Message> {
241 self.config_window.sidebar.power_button_cache.clear();
242 self.config_window.sidebar.restart_button_cache.clear();
243
244 match message {
245 Message::WindowOpened(_) => ().into(),
246 Message::ModifiersChanged(modifiers) => {
247 self.modifiers = modifiers;
248 ().into()
249 }
250 Message::WindowResized((window_id, size)) => {
251 if self.config_window.id == window_id {
252 self.config_window.on_resize(size);
253 } else if self.song_window.id == window_id {
254 self.song_window.on_resize(size);
255 }
256
257 ().into()
258 }
259 Message::WindowClosed(window_id) => {
260 if self.config_window.id == window_id {
261 self.config_window.on_close();
262
263 if self.song_window.is_closed() {
264 exit()
265 } else {
266 window::close(self.song_window.id)
267 }
268 } else if self.song_window.id == window_id {
269 self.song_window.on_close();
270
271 if self.config_window.is_closed() {
272 exit()
273 } else {
274 window::close(self.config_window.id)
275 }
276 } else {
277 ().into()
278 }
279 }
280
281 Message::ThemeChanged(mode) => {
282 if self.config_window.follow_system_theme {
283 self.config_window.theme = match mode {
284 theme::Mode::Light => Theme::Light,
285 theme::Mode::Dark | theme::Mode::None => Theme::Dark,
286 };
287 }
288
289 let icon = from_file_data(
290 match mode {
291 theme::Mode::Light | theme::Mode::None => {
292 include_bytes!(res_file!("icon_light.png"))
293 }
294 theme::Mode::Dark => include_bytes!(res_file!("icon_dark.png")),
295 },
296 Some(image::ImageFormat::Png),
297 );
298
299 if let Ok(icon) = icon {
300 Task::batch([
301 window::set_icon(self.song_window.id, icon.clone()),
302 window::set_icon(self.config_window.id, icon),
303 ])
304 } else {
305 ().into()
306 }
307 }
308
309 Message::SetConfigTheme(theme) => {
310 self.config_window.theme = theme;
311 self.config_window.follow_system_theme = false;
312 ().into()
313 }
314
315 Message::ToggleFullscreen => {
316 let song_window_id = self.song_window.id;
317
318 window::mode(song_window_id)
319 .map(|mode| Message::SetFullscreen(mode != window::Mode::Fullscreen))
320 }
321 Message::SetFullscreen(fullscreen) => {
322 let song_window_id = self.song_window.id;
323
324 window::set_mode(
325 song_window_id,
326 if fullscreen {
327 window::Mode::Fullscreen
328 } else {
329 window::Mode::Windowed
330 },
331 )
332 }
333
334 Message::OpenPlaylist => {
335 #[cfg(target_os = "linux")]
336 if var("container").is_ok() {
337 let folder = FileDialog::new()
339 .set_title(
340 "Select folder containing m3u AND audio files (required in flatpak)",
341 )
342 .set_directory(
343 dirs::audio_dir()
344 .unwrap_or(dirs::home_dir().unwrap_or(PathBuf::from("."))),
345 )
346 .pick_folders();
347 if folder.is_none() {
348 return ().into();
349 }
350 }
351
352 let file = FileDialog::new()
354 .add_filter("Playlist", &["m3u", "m3u8"])
355 .add_filter("Any(*)", &["*"])
356 .set_title("Select playlist file")
357 .set_directory(
358 dirs::audio_dir().unwrap_or(dirs::home_dir().unwrap_or(PathBuf::from("."))),
359 )
360 .pick_file();
361
362 let Some(file) = file else {
363 return ().into();
364 };
365 println!("Selected file: {:?}", file);
366
367 let Ok(playlist) = load_tag_data_from_m3u(&file) else {
368 return ().into();
369 };
370
371 self.data_provider.set_vec(playlist);
372
373 ().into()
374 }
375
376 Message::AddBlankEntry => {
377 if self.config_window.is_statics_view {
378 Task::done(Message::Statics(StaticsMessage::AddBlank))
379 } else {
380 Task::done(Message::AddBlankSong(RelativeOffset::END))
381 }
382 }
383
384 Message::Statics(msg) => match msg {
385 StaticsMessage::Reload => {
386 self.data_provider.load_statics();
387 ().into()
388 }
389 StaticsMessage::ToggleFavorite(name) => {
390 self.data_provider.toggle_static_favorite(&name);
391 ().into()
392 }
393 StaticsMessage::UpdateName(old_name, new_name) => {
394 let _ = self.data_provider.rename_static(&old_name, &new_name);
395 ().into()
396 }
397 StaticsMessage::SubmitName(old_name) => {
398 match self.data_provider.process_static_name_submit(&old_name) {
399 SubmitStaticResult::NeedsMerge { old_name, new_name } => {
400 Task::perform(async move { new_name }, move |new_name| {
401 Message::Statics(StaticsMessage::RequestMerge(
402 old_name.clone(),
403 new_name,
404 ))
405 })
406 }
407 SubmitStaticResult::Success
408 | SubmitStaticResult::Unchanged
409 | SubmitStaticResult::NotFound => ().into(),
410 }
411 }
412 StaticsMessage::UpdateColor(name, mut color) => {
413 color.a = 1.0;
414 let _ = self
415 .data_provider
416 .update_static_color(&name, Some(color), false);
417 self.config_window.color_picker_open = None;
418 self.config_window.color_picker_old_color = None;
419 ().into()
420 }
421 StaticsMessage::PreviewColor(name, mut color) => {
422 color.a = 1.0;
423 let _ = self
424 .data_provider
425 .update_static_color(&name, Some(color), false);
426 ().into()
427 }
428 StaticsMessage::ToggleColorPicker(name) => {
429 if self.config_window.color_picker_open.as_ref() == Some(&name) {
430 if let Some(old_color) = self.config_window.color_picker_old_color.take() {
431 let _ = self.data_provider.update_static_color(
432 &name,
433 Some(old_color),
434 false,
435 );
436 } else {
437 let _ = self.data_provider.update_static_color(&name, None, false);
438 }
439 self.config_window.color_picker_open = None;
440 } else {
441 if let Some(static_info) = self.data_provider.statics().get(&name) {
442 self.config_window.color_picker_old_color = static_info.color;
443 }
444 self.config_window.color_picker_open = Some(name);
445 }
446 ().into()
447 }
448 StaticsMessage::AddBlank => {
449 self.data_provider.add_static();
450 ().into()
451 }
452 StaticsMessage::RequestMerge(old_name, new_name) => {
453 self.config_window.active_dialog =
454 Some(ui::config_window::DialogState::MergeStatic { old_name, new_name });
455 ().into()
456 }
457 StaticsMessage::ConfirmMerge(old_name, new_name) => {
458 let _ = self.data_provider.merge_statics(&old_name, &new_name);
459 self.config_window.active_dialog = None;
460 ().into()
461 }
462 },
463
464 Message::DummySong(msg) => match msg {
465 DummySongMessage::UpdateTitle(title) => {
466 self.config_window.dummy_song_title = title;
467 ().into()
468 }
469 DummySongMessage::UpdateArtist(artist) => {
470 self.config_window.dummy_song_artist = artist;
471 ().into()
472 }
473 DummySongMessage::UpdateDance(dance) => {
474 self.config_window.dummy_song_dance = dance;
475 ().into()
476 }
477 DummySongMessage::Submit => {
478 if self.config_window.dummy_song_title.is_empty()
479 && self.config_window.dummy_song_artist.is_empty()
480 && self.config_window.dummy_song_dance.is_empty()
481 {
482 return ().into();
483 }
484
485 let song = SongInfo {
486 title: self.config_window.dummy_song_title.clone(),
487 artist: self.config_window.dummy_song_artist.clone(),
488 dance: self.config_window.dummy_song_dance.clone(),
489 ..Default::default()
490 };
491 self.data_provider.append_song(song);
492 self.config_window.dummy_song_title.clear();
493 self.config_window.dummy_song_artist.clear();
494 self.config_window.dummy_song_dance.clear();
495 self.data_provider.ensure_statics_for_playlist();
496 ().into()
497 }
498 },
499
500 Message::Search(msg) => match msg {
501 SearchMessage::Toggle => {
502 self.config_window.search_visible = !self.config_window.search_visible;
503 if !self.config_window.search_visible {
504 self.config_window.search_query.clear();
505 }
506 ().into()
507 }
508 SearchMessage::Changed(query) => {
509 self.config_window.search_query = query;
510 self.scroll_to_first_search_match()
511 }
512 SearchMessage::Clear => {
513 self.config_window.search_query.clear();
514 ().into()
515 }
516 },
517
518 Message::Dialog(msg) => match msg {
519 DialogMessage::Cancel => {
520 if let Some(ui::config_window::DialogState::MergeStatic { old_name, .. }) =
521 &self.config_window.active_dialog
522 {
523 let _ = self.data_provider.rename_static(old_name, old_name);
524 }
525 self.config_window.active_dialog = None;
526 ().into()
527 }
528 DialogMessage::RequestDelete(item) => {
529 if self.modifiers.shift() {
530 return Task::done(Message::DeleteItem(item));
531 }
532
533 self.config_window.active_dialog =
534 Some(ui::config_window::DialogState::Delete(item));
535 ().into()
536 }
537 DialogMessage::ConfirmDelete => {
538 if let Some(ui::config_window::DialogState::Delete(item)) =
539 self.config_window.active_dialog.take()
540 {
541 self.data_provider.delete_item(item);
542 }
543 ().into()
544 }
545 DialogMessage::CancelDelete => {
546 self.config_window.active_dialog = None;
547 ().into()
548 }
549 DialogMessage::UndoDelete => {
550 self.data_provider.undo_delete();
551 ().into()
552 }
553 },
554
555 Message::AddTraktorSong => {
556 if let Some(song) = self.data_provider.traktor_provider.get_song_info() {
557 let in_playlist = self.data_provider.playlist().iter().any(|item| {
558 item.song.title == song.title && item.song.artist == song.artist
559 });
560 if !in_playlist {
561 self.data_provider.append_song(song.clone());
562 self.data_provider.ensure_statics_for_playlist();
563 }
564 Task::done(Message::SnapTo(RelativeOffset::END))
565 } else {
566 ().into()
567 }
568 }
569
570 Message::FileDropped(path) => {
571 if let Ok(playlist) = load_tag_data_from_m3u(&path) {
572 self.data_provider.set_vec(playlist);
573 } else if let Ok(song_info) = read_song_info_from_filepath(&path) {
574 self.data_provider.append_song(song_info);
575 }
576
577 ().into()
578 }
579
580 Message::ItemChanged(song_change) => {
581 self.data_provider.handle_item_change(song_change);
582 self.try_scroll_to_song()
583 }
584
585 Message::SongDataEdit(i, edit) => {
586 self.data_provider.handle_song_data_edit(i, edit);
587 ().into()
588 }
589
590 Message::SubmitPlaylistDance(i) => {
591 self.data_provider.handle_song_data_submit(i);
592 ().into()
593 }
594
595 Message::AddSong(song) => {
596 self.data_provider.append_song(song);
597 ().into()
598 }
599
600 Message::AddBlankSong(offset) => {
601 self.data_provider.append_song(SongInfo::default());
602 Task::done(Message::SnapTo(offset))
603 }
604
605 Message::DeleteItem(song) => {
606 self.data_provider.delete_item(song);
607 ().into()
608 }
609
610 Message::SetNextItem(i) => {
611 self.data_provider.set_next(i);
612 ().into()
613 }
614
615 Message::SongWindow(msg) => match msg {
616 SongWindowMessage::EnableImage(state) => {
617 self.song_window.enable_image = state;
618 ().into()
619 }
620 SongWindowMessage::EnableNextDance(state) => {
621 self.song_window.enable_next_dance = state;
622 ().into()
623 }
624 SongWindowMessage::ChangeScale(value) => {
625 if (0.5..=3.0).contains(&value) {
626 self.song_window.scale = (value * 100.0).round() / 100.0;
627 } else {
628 self.song_window.scale =
629 (((self.song_window.scale + value) * 100.0).round() / 100.0)
630 .clamp(0.5, 3.0);
631 }
632 ().into()
633 }
634 },
635
636 Message::EnableAutoscroll(state) => {
637 self.config_window.enable_autoscroll = state;
638 ().into()
639 }
640
641 Message::EnableFollowSystemTheme(state) => {
642 self.config_window.follow_system_theme = state;
643
644 if state {
645 system::theme().map(Message::ThemeChanged)
646 } else {
647 ().into()
648 }
649 }
650
651 Message::ScrollBy(frac) => scroll_by(
652 PLAYLIST_SCROLLABLE_ID.clone(),
653 AbsoluteOffset {
654 x: 0.0,
655 y: self.config_window.size.height / frac,
656 },
657 ),
658
659 Message::SnapTo(offset) => snap_to(PLAYLIST_SCROLLABLE_ID.clone(), offset),
660
661 Message::ToggleStaticsView => {
662 self.config_window.is_statics_view = !self.config_window.is_statics_view;
663 ().into()
664 }
665
666 Message::Sidebar(msg) => match msg {
667 SidebarMessage::Toggle => {
668 self.config_window
669 .sidebar
670 .state
671 .go_mut(!self.config_window.sidebar.state.value(), Instant::now());
672 ().into()
673 }
674 SidebarMessage::UpdateAddressPresets => {
675 self.config_window
676 .sidebar
677 .update_network_interface_selection(&self.data_provider);
678 ().into()
679 }
680 },
681
682 Message::Traktor(msg) => match msg {
683 TraktorMessage::ServerMessage(msg) => {
684 self.data_provider.process_traktor_message(*msg);
685 self.run_traktor_sync_action();
686
687 self.try_scroll_to_song()
688 }
689
690 TraktorMessage::EnableServer(enabled) => {
691 self.data_provider.traktor_provider.set_enabled(enabled);
692 ().into()
693 }
694
695 TraktorMessage::ChangeAddress(addr) => {
696 self.data_provider.traktor_provider.address = addr;
697 ().into()
698 }
699
700 TraktorMessage::SubmitAddress => {
701 self.data_provider.traktor_provider.submitted_address =
702 self.data_provider.traktor_provider.address.clone();
703 ().into()
704 }
705
706 TraktorMessage::ChangeAndSubmitAddress(addr) => {
707 self.data_provider.traktor_provider.address = addr;
708 self.data_provider.traktor_provider.submitted_address =
709 self.data_provider.traktor_provider.address.clone();
710 ().into()
711 }
712
713 TraktorMessage::EnableDebugLogging(enabled) => {
714 self.data_provider.traktor_provider.debug_logging = enabled;
715 self.data_provider.traktor_provider.reconnect();
716 ().into()
717 }
718
719 TraktorMessage::Reconnect => {
720 self.data_provider.traktor_provider.reconnect();
721 ().into()
722 }
723
724 TraktorMessage::EnableSync(enabled) => {
725 self.data_provider.traktor_provider.sync = enabled;
726 self.traktor_provider_force_update()
727 }
728
729 TraktorMessage::SetSyncMode(mode) => {
730 self.data_provider.traktor_provider.sync_mode = mode;
731 self.traktor_provider_force_update()
732 }
733
734 TraktorMessage::SetNextMode(mode) => {
735 self.data_provider.traktor_provider.next_mode = mode;
736 self.traktor_provider_force_update()
737 }
738
739 TraktorMessage::SetNextModeFallback(mode) => {
740 self.data_provider.traktor_provider.next_mode_fallback = mode;
741 self.traktor_provider_force_update()
742 }
743 },
744
745 Message::Animate => Task::none(),
746
747 _ => ().into(),
748 }
749 }
750
751 fn traktor_provider_force_update(&mut self) -> Task<Message> {
752 if let Some(mixer_state) = self
754 .data_provider
755 .traktor_provider
756 .state
757 .as_ref()
758 .map(|s| s.mixer)
759 {
760 self.data_provider
761 .process_traktor_message(ServerMessage::Update(StateUpdate::Mixer(mixer_state)));
762 self.run_traktor_sync_action();
763 self.try_scroll_to_song()
764 } else {
765 ().into()
766 }
767 }
768
769 fn scroll_to_first_search_match(&mut self) -> Task<Message> {
770 if self.config_window.search_query.is_empty() {
771 return ().into();
772 }
773
774 let matcher = Matcher::default();
775 let query = &self.config_window.search_query;
776
777 let first_match = self
778 .data_provider
779 .playlist()
780 .iter()
781 .enumerate()
782 .find(|(_, item)| {
783 let song = &item.song;
784 matcher
785 .fuzzy_match(
786 &format!("{} {} {}", song.title, song.artist, song.dance),
787 query,
788 )
789 .is_some()
790 });
791
792 if let Some((index, _)) = first_match {
793 let offset_y =
794 index as f32 / std::cmp::max(1, self.data_provider.playlist().len() - 1) as f32;
795
796 Task::done(Message::SnapTo(RelativeOffset {
797 x: 0.0,
798 y: offset_y,
799 }))
800 } else {
801 ().into()
802 }
803 }
804
805 fn run_traktor_sync_action(&mut self) {
806 let action = self.data_provider.traktor_provider.take_sync_action();
807 if !self.data_provider.traktor_provider.sync {
808 return;
809 }
810
811 match action {
812 TraktorSyncAction::Relative(offset) => {
813 if offset >= 0 {
814 for _ in 0..offset {
815 self.data_provider.next();
816 }
817 } else {
818 for _ in 0..(-offset) {
819 self.data_provider.prev();
820 }
821 }
822 }
823 TraktorSyncAction::PlaylistAbsolute(pos) => {
824 self.data_provider.set_current(ItemSource::Playlist(pos));
825 }
826 }
827 }
828
829 fn try_scroll_to_song(&mut self) -> Task<Message> {
830 if let Some(index) = self.data_provider.take_scroll_index() {
831 let offset_y =
832 index as f32 / std::cmp::max(1, self.data_provider.playlist().len() - 1) as f32;
833
834 Task::done(Message::SnapTo(RelativeOffset {
835 x: 0.0,
836 y: offset_y,
837 }))
838 } else {
839 ().into()
840 }
841 }
842
843 fn theme(&self, window_id: window::Id) -> Theme {
844 if self.song_window.id == window_id {
845 Theme::Dark
846 } else {
847 self.config_window.theme.clone()
848 }
849 }
850
851 pub fn subscription(&self) -> Subscription<Message> {
852 let mut subscriptions = vec![
853 window::close_events().map(Message::WindowClosed),
854 window::resize_events().map(Message::WindowResized),
855 window::events().map(|(_, event)| match event {
856 window::Event::FileDropped(path) => Message::FileDropped(path),
857 _ => Message::Noop,
858 }),
859 keyboard::listen().filter_map(|event| {
860 if let keyboard::Event::ModifiersChanged(modifiers) = event {
861 return Some(Message::ModifiersChanged(modifiers));
862 }
863 let keyboard::Event::KeyPressed { key, .. } = event else {
864 return None;
865 };
866
867 match key {
868 Key::Named(Named::ArrowRight) | Key::Named(Named::Space) => {
869 Some(Message::ItemChanged(ItemChange::Next))
870 }
871 Key::Named(Named::ArrowLeft) => {
872 Some(Message::ItemChanged(ItemChange::Previous))
873 }
874 Key::Named(Named::End) => Some(Message::ItemChanged(ItemChange::Blank)),
875 Key::Named(Named::F11) => Some(Message::ToggleFullscreen),
876 Key::Named(Named::F5) => Some(Message::Statics(StaticsMessage::Reload)),
877 Key::Named(Named::PageUp) => Some(Message::ScrollBy(-10.0)),
878 Key::Named(Named::PageDown) => Some(Message::ScrollBy(10.0)),
879 _ => None,
880 }
881 }),
882 keyboard::listen().filter_map(move |event| {
883 let keyboard::Event::KeyPressed { key, modifiers, .. } = event else {
884 return None;
885 };
886 match (key.as_ref(), modifiers) {
887 (Key::Character("n"), Modifiers::CTRL) => Some(Message::AddBlankEntry),
888 (Key::Character("z"), Modifiers::CTRL) => {
889 Some(Message::Dialog(DialogMessage::UndoDelete))
890 }
891 (Key::Character("+"), Modifiers::CTRL) => {
892 Some(Message::SongWindow(SongWindowMessage::ChangeScale(0.1)))
893 }
894 (Key::Character("-"), Modifiers::CTRL) => {
895 Some(Message::SongWindow(SongWindowMessage::ChangeScale(-0.1)))
896 }
897 (Key::Character("f"), Modifiers::CTRL) => {
898 Some(Message::Search(SearchMessage::Toggle))
899 }
900 (Key::Character("c"), Modifiers::ALT) => {
901 Some(Message::Sidebar(SidebarMessage::Toggle))
902 }
903 _ => None,
904 }
905 }),
906 system::theme_changes().map(Message::ThemeChanged),
907 if self
908 .config_window
909 .sidebar
910 .state
911 .is_animating(Instant::now())
912 {
913 window::frames().map(|_| Message::Animate)
914 } else {
915 Subscription::none()
916 },
917 ];
918
919 if let Some(addr) = self.data_provider.traktor_provider.get_socket_addr() {
920 subscriptions.push(
921 run_subscription_with(addr, |addr| traktor_api::run_server(*addr))
922 .map(|m| Message::Traktor(TraktorMessage::ServerMessage(Box::new(m)))),
923 );
924 }
925
926 Subscription::batch(subscriptions)
927 }
928}