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 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 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}