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