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