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