Skip to main content

danceinterpreter_rs/
main.rs

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                    // Request folder access first in flatpak environment
338                    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                // Open playlist file
353                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        // send fake state update message to enforce sync refresh
753        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}