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