up rust version in ci, fmt

7119455 · gmorell · 2025-12-26 03:18

5 files +416 -293

Files changed

modified .github/workflows/rust-ci.yml
+1 −1
@@ -51,7 +51,7 @@ jobs:
51 51 - name: Install Rust
52 52 uses: dtolnay/rust-toolchain@stable
53 53 with:
54 − toolchain: "1.86"
54 + toolchain: "1.92"
55 55 components: rustfmt, clippy
56 56
57 57 - name: Setup Rust cache
modified src/main.rs
+415 −292
@@ -1,16 +1,16 @@
1 +use futures_util::{SinkExt, StreamExt};
2 +use log::{debug, info, warn};
1 3 use serde::{Deserialize, Serialize};
4 +use serde_json::Value;
2 5 use std::fs;
3 −use std::path::PathBuf;
4 6 use std::io::Write;
7 +use std::path::PathBuf;
5 8 use std::sync::{Arc, Mutex};
6 9 use tokio::runtime::Runtime;
7 10 use tokio_tungstenite::{connect_async, tungstenite::protocol::Message};
8 −use futures_util::{StreamExt, SinkExt};
9 −use log::{debug, info, warn};
10 −use serde_json::Value;
11 11
12 −const DEFAULT_HTTP_PORT : usize = 8080;
13 −const DEFAULT_EVENT_PORT : usize = 9777;
12 +const DEFAULT_HTTP_PORT: usize = 8080;
13 +const DEFAULT_EVENT_PORT: usize = 9777;
14 14
15 15 const DEFAULT_WS_PORT: usize = 9090;
16 16
@@ -18,11 +18,11 @@ fn default_http_port() -> usize {
18 18 DEFAULT_HTTP_PORT
19 19 }
20 20
21 −fn default_event_port() -> usize {
21 +fn default_event_port() -> usize {
22 22 DEFAULT_EVENT_PORT
23 23 }
24 24
25 −fn truthy () -> bool {
25 +fn truthy() -> bool {
26 26 true
27 27 }
28 28 #[derive(Serialize, Deserialize, Clone, Debug)]
@@ -42,7 +42,7 @@ struct Instance {
42 42 events: bool,
43 43
44 44 #[serde(default = "default_event_port")]
45 − events_port: usize
45 + events_port: usize,
46 46 }
47 47
48 48 impl Instance {
@@ -56,29 +56,31 @@ impl Instance {
56 56 },
57 57 "id": 1
58 58 });
59 −
59 +
60 60 // Build the URL
61 61 let url = format!("http://{}:{}/jsonrpc", self.hostname, self.http_port);
62 −
62 +
63 63 // Create the HTTP client
64 64 let client = reqwest::blocking::Client::new();
65 − let mut req_builder = client.post(&url)
65 + let mut req_builder = client
66 + .post(&url)
66 67 .header("Content-Type", "application/json")
67 68 .json(&request);
68 −
69 +
69 70 // Add authentication if present
70 71 if let (Some(username), Some(password)) = (&self.username, &self.password) {
71 72 req_builder = req_builder.basic_auth(username, Some(password));
72 73 }
73 −
74 +
74 75 // Send the request
75 − let response = req_builder.send()
76 + let response = req_builder
77 + .send()
76 78 .map_err(|e| format!("Failed to send request: {}", e))?;
77 −
79 +
78 80 if !response.status().is_success() {
79 81 return Err(format!("Request failed with status: {}", response.status()));
80 82 }
81 −
83 +
82 84 Ok(())
83 85 }
84 86 }
@@ -89,8 +91,8 @@ struct NowPlaying {
89 91 artist: String,
90 92 album: String,
91 93 playing: bool,
92 − duration: f64, // Total duration in seconds
93 − position: f64, // Current position in seconds
94 + duration: f64, // Total duration in seconds
95 + position: f64, // Current position in seconds
94 96 }
95 97
96 98 impl NowPlaying {
@@ -131,7 +133,7 @@ struct ConfigUIState {
131 133 password: String,
132 134 events: bool,
133 135 events_port: String,
134 −
136 +
135 137 // For adding new instance
136 138 new_label: String,
137 139 new_hostname: String,
@@ -152,47 +154,70 @@ impl ConfigUIState {
152 154 self.events = instance.events;
153 155 self.events_port = instance.events_port.to_string();
154 156 }
155 −
157 +
156 158 fn to_instance(&self) -> Result<Instance, String> {
157 − let http_port = self.http_port.parse::<usize>()
159 + let http_port = self
160 + .http_port
161 + .parse::<usize>()
158 162 .map_err(|_| "Invalid HTTP port number")?;
159 − let events_port = self.events_port.parse::<usize>()
163 + let events_port = self
164 + .events_port
165 + .parse::<usize>()
160 166 .map_err(|_| "Invalid events port number")?;
161 −
167 +
162 168 Ok(Instance {
163 169 label: self.label.clone(),
164 170 hostname: self.hostname.clone(),
165 171 http_port,
166 − username: if self.username.is_empty() { None } else { Some(self.username.clone()) },
167 − password: if self.password.is_empty() { None } else { Some(self.password.clone()) },
172 + username: if self.username.is_empty() {
173 + None
174 + } else {
175 + Some(self.username.clone())
176 + },
177 + password: if self.password.is_empty() {
178 + None
179 + } else {
180 + Some(self.password.clone())
181 + },
168 182 events: self.events,
169 183 events_port,
170 184 })
171 185 }
172 −
186 +
173 187 fn new_instance(&self) -> Result<Instance, String> {
174 188 if self.new_hostname.is_empty() {
175 189 return Err("Hostname cannot be empty".to_string());
176 190 }
177 −
178 − let http_port = self.new_http_port.parse::<usize>()
191 +
192 + let http_port = self
193 + .new_http_port
194 + .parse::<usize>()
179 195 .unwrap_or(DEFAULT_HTTP_PORT);
180 − let events_port = self.new_events_port.parse::<usize>()
196 + let events_port = self
197 + .new_events_port
198 + .parse::<usize>()
181 199 .unwrap_or(DEFAULT_EVENT_PORT);
182 −
200 +
183 201 Ok(Instance {
184 202 label: self.new_label.clone(),
185 203 hostname: self.new_hostname.clone(),
186 204 http_port,
187 − username: if self.new_username.is_empty() { None } else { Some(self.new_username.clone()) },
188 − password: if self.new_password.is_empty() { None } else { Some(self.new_password.clone()) },
205 + username: if self.new_username.is_empty() {
206 + None
207 + } else {
208 + Some(self.new_username.clone())
209 + },
210 + password: if self.new_password.is_empty() {
211 + None
212 + } else {
213 + Some(self.new_password.clone())
214 + },
189 215 events: self.new_events,
190 216 events_port,
191 217 })
192 218 }
193 219 }
194 220
195 −
196 221 #[derive(Debug, Clone, Serialize, Deserialize)]
197 222 struct Theme {
198 223 name: String,
@@ -219,7 +244,6 @@ impl Default for Theme {
219 244 }
220 245 }
221 246
222 −
223 247 #[derive(Debug, Clone, Serialize, Deserialize)]
224 248 struct KocoConfig {
225 249 instances: Vec<Instance>,
@@ -372,55 +396,53 @@ impl KocoConfig {
372 396 let config_dir = dirs::config_dir()
373 397 .unwrap_or_else(|| PathBuf::from("."))
374 398 .join("koco");
375 −
399 +
376 400 fs::create_dir_all(&config_dir).ok();
377 401 config_dir.join("config.toml")
378 402 }
379 −
403 +
380 404 fn load() -> Self {
381 405 let path = Self::config_path();
382 −
406 +
383 407 if path.exists() {
384 408 match fs::read_to_string(&path) {
385 − Ok(contents) => {
386 − match toml::from_str(&contents) {
387 − Ok(config) => {
388 − info!("Loaded config from {:?}", path);
389 − return config;
390 − }
391 − Err(e) => {
392 − info!("Failed to parse config: {}. Using default.", e);
393 − }
409 + Ok(contents) => match toml::from_str(&contents) {
410 + Ok(config) => {
411 + info!("Loaded config from {:?}", path);
412 + return config;
394 413 }
395 − }
414 + Err(e) => {
415 + info!("Failed to parse config: {}. Using default.", e);
416 + }
417 + },
396 418 Err(e) => {
397 419 info!("Failed to read config: {}. Using default.", e);
398 420 }
399 421 }
400 422 }
401 −
423 +
402 424 Self::default()
403 425 }
404 −
426 +
405 427 fn save(&self) -> Result<(), String> {
406 428 let path = Self::config_path();
407 429 let toml_string = toml::to_string_pretty(self)
408 430 .map_err(|e| format!("Failed to serialize config: {}", e))?;
409 −
410 − let mut file = fs::File::create(&path)
411 − .map_err(|e| format!("Failed to create config file: {}", e))?;
412 −
431 +
432 + let mut file =
433 + fs::File::create(&path).map_err(|e| format!("Failed to create config file: {}", e))?;
434 +
413 435 file.write_all(toml_string.as_bytes())
414 436 .map_err(|e| format!("Failed to write config file: {}", e))?;
415 −
437 +
416 438 info!("Saved config to {:?}", path);
417 439 Ok(())
418 440 }
419 −
441 +
420 442 fn get_current_theme(&self) -> Option<&Theme> {
421 443 self.themes.iter().find(|t| t.name == self.current_theme)
422 444 }
423 −
445 +
424 446 fn get_current_theme_or_default(&self) -> Theme {
425 447 self.get_current_theme().cloned().unwrap_or_default()
426 448 }
@@ -430,39 +452,39 @@ impl Koco {
430 452 fn new(cc: &eframe::CreationContext<'_>) -> Self {
431 453 // Initialize material icons
432 454 egui_material_icons::initialize(&cc.egui_ctx);
433 −
455 +
434 456 let config = KocoConfig::load();
435 −
457 +
436 458 // Apply theme
437 459 let theme = config.get_current_theme_or_default();
438 460 let mut visuals = egui::Visuals::dark();
439 −
461 +
440 462 // Set background color
441 463 visuals.panel_fill = egui::Color32::from_rgb(
442 464 theme.background_color[0],
443 465 theme.background_color[1],
444 466 theme.background_color[2],
445 467 );
446 −
468 +
447 469 // Set text color
448 470 visuals.override_text_color = Some(egui::Color32::from_rgb(
449 471 theme.text_color[0],
450 472 theme.text_color[1],
451 473 theme.text_color[2],
452 474 ));
453 −
475 +
454 476 cc.egui_ctx.set_visuals(visuals);
455 −
477 +
456 478 let mut config_ui_state = ConfigUIState::default();
457 −
479 +
458 480 // Initialize UI state with first instance
459 481 if !config.instances.is_empty() {
460 482 config_ui_state.load_from_instance(&config.instances[0]);
461 483 }
462 −
484 +
463 485 let now_playing = Arc::new(Mutex::new(NowPlaying::default()));
464 486 let runtime = Arc::new(Runtime::new().expect("Failed to create Tokio runtime"));
465 −
487 +
466 488 let app = Self {
467 489 config,
468 490 current_instance_index: 0,
@@ -472,49 +494,49 @@ impl Koco {
472 494 now_playing,
473 495 runtime,
474 496 };
475 −
497 +
476 498 // Connect to websocket on startup
477 499 app.connect_websocket();
478 −
500 +
479 501 // Start position updater
480 502 app.start_position_updater();
481 −
503 +
482 504 app
483 505 }
484 −
506 +
485 507 fn connect_websocket(&self) {
486 508 if let Some(instance) = self.current_instance() {
487 509 if !instance.events {
488 510 return;
489 511 }
490 −
512 +
491 513 let ws_url = format!("ws://{}:{}/jsonrpc", instance.hostname, DEFAULT_WS_PORT);
492 514 let now_playing = Arc::clone(&self.now_playing);
493 515 let current_player_id = Arc::new(Mutex::new(None::<i64>));
494 516 let _username = instance.username.clone();
495 517 let _password = instance.password.clone();
496 −
518 +
497 519 self.runtime.spawn(async move {
498 520 match connect_async(&ws_url).await {
499 521 Ok((mut ws_stream, _)) => {
500 522 debug!("Connected to Kodi WebSocket at {}", ws_url);
501 −
523 +
502 524 // Subscribe to player notifications
503 525 // Note: Kodi doesn't require explicit subscription, it broadcasts all notifications
504 −
526 +
505 527 // Request initial player state
506 528 let get_active_players_msg = serde_json::json!({
507 529 "jsonrpc": "2.0",
508 530 "method": "Player.GetActivePlayers",
509 531 "id": 1
510 532 });
511 −
533 +
512 534 debug!("Requesting active players...");
513 535 if let Err(e) = ws_stream.send(Message::Text(get_active_players_msg.to_string())).await {
514 536 debug!("Failed to get active players: {}", e);
515 537 return;
516 538 }
517 −
539 +
518 540 // Listen for messages
519 541 while let Some(msg) = ws_stream.next().await {
520 542 match msg {
@@ -530,7 +552,7 @@ impl Koco {
530 552 if let Ok(mut np) = now_playing.lock() {
531 553 np.playing = true;
532 554 }
533 −
555 +
534 556 // Get player ID from the notification
535 557 let player_id = json.get("params")
536 558 .and_then(|p| p.get("data"))
@@ -538,13 +560,13 @@ impl Koco {
538 560 .and_then(|p| p.get("playerid"))
539 561 .and_then(|id| id.as_i64())
540 562 .unwrap_or(0);
541 −
563 +
542 564 if let Ok(mut pid) = current_player_id.lock() {
543 565 *pid = Some(player_id);
544 566 }
545 −
567 +
546 568 debug!("Using player ID: {}", player_id);
547 −
569 +
548 570 // Request player properties for position/duration
549 571 let properties_msg = serde_json::json!({
550 572 "jsonrpc": "2.0",
@@ -556,7 +578,7 @@ impl Koco {
556 578 "id": 4
557 579 });
558 580 let _ = ws_stream.send(Message::Text(properties_msg.to_string())).await;
559 −
581 +
560 582 // Request current player info
561 583 let player_info_msg = serde_json::json!({
562 584 "jsonrpc": "2.0",
@@ -585,17 +607,17 @@ impl Koco {
585 607 _ => {}
586 608 }
587 609 }
588 −
610 +
589 611 // Handle errors
590 612 if let Some(error) = json.get("error") {
591 613 info!("Kodi API error: {:?}", error);
592 614 info!("Request ID: {:?}", json.get("id"));
593 615 }
594 −
616 +
595 617 if let Some(result) = json.get("result") {
596 618 info!("Got result: {:?}", result);
597 619 info!("Request ID from response: {:?}", json.get("id"));
598 −
620 +
599 621 // Check if this is a direct properties response (not nested in item)
600 622 if let Some(totaltime_obj) = result.get("totaltime") {
601 623 debug!("Got totaltime at root level: {:?}", totaltime_obj);
@@ -607,25 +629,25 @@ impl Koco {
607 629 debug!("Updated duration from root properties: {}", np.duration);
608 630 }
609 631 }
610 −
632 +
611 633 // Parse player item info response
612 634 if let Some(item) = result.get("item") {
613 635 debug!("Parsing item: {:?}", item);
614 636 if let Ok(mut np) = now_playing.lock() {
615 637 let title = item.get("title").and_then(|t| t.as_str()).unwrap_or("");
616 638 let label = item.get("label").and_then(|l| l.as_str()).unwrap_or("");
617 −
639 +
618 640 // Use label if title is empty
619 641 np.title = if !title.is_empty() { title.to_string() } else { label.to_string() };
620 642 np.artist = item.get("artist").and_then(|a| a.as_array()).and_then(|arr| arr.get(0)).and_then(|a| a.as_str()).unwrap_or("").to_string();
621 643 np.album = item.get("album").and_then(|a| a.as_str()).unwrap_or("").to_string();
622 −
644 +
623 645 debug!("Parsed - Title: '{}', Artist: '{}', Album: '{}'", np.title, np.artist, np.album);
624 646 np.playing = true;
625 647 }
626 648 // Don't try to get duration from item - it comes from GetProperties
627 649 }
628 −
650 +
629 651 // Handle position from root level
630 652 if let Some(position_obj) = result.get("time").or_else(|| result.get("position")) {
631 653 debug!("Got time/position at root level: {:?}", position_obj);
@@ -638,7 +660,7 @@ impl Koco {
638 660 np.playing = true;
639 661 }
640 662 }
641 −
663 +
642 664 // Log summary after parsing
643 665 if result.get("item").is_some() || result.get("totaltime").is_some() || result.get("time").is_some() {
644 666 if let Ok(np) = now_playing.lock() {
@@ -653,12 +675,12 @@ impl Koco {
653 675 // Get the first active player's info
654 676 if let Some(player_id) = players[0].get("playerid").and_then(|id| id.as_i64()) {
655 677 debug!("Found active player with id: {}", player_id);
656 −
678 +
657 679 // Store the player ID
658 680 if let Ok(mut pid) = current_player_id.lock() {
659 681 *pid = Some(player_id);
660 682 }
661 −
683 +
662 684 // Request properties first
663 685 let properties_msg = serde_json::json!({
664 686 "jsonrpc": "2.0",
@@ -670,7 +692,7 @@ impl Koco {
670 692 "id": 5
671 693 });
672 694 let _ = ws_stream.send(Message::Text(properties_msg.to_string())).await;
673 −
695 +
674 696 // Then request item info
675 697 let player_info_msg = serde_json::json!({
676 698 "jsonrpc": "2.0",
@@ -707,10 +729,10 @@ impl Koco {
707 729 });
708 730 }
709 731 }
710 −
732 +
711 733 fn start_position_updater(&self) {
712 734 let now_playing = Arc::clone(&self.now_playing);
713 −
735 +
714 736 self.runtime.spawn(async move {
715 737 let mut interval = tokio::time::interval(tokio::time::Duration::from_secs(1));
716 738 loop {
@@ -723,228 +745,267 @@ impl Koco {
723 745 }
724 746 });
725 747 }
726 −
748 +
727 749 fn apply_theme(&self, ctx: &egui::Context) {
728 750 let theme = self.config.get_current_theme_or_default();
729 751 let mut visuals = egui::Visuals::dark();
730 −
752 +
731 753 visuals.panel_fill = egui::Color32::from_rgb(
732 754 theme.background_color[0],
733 755 theme.background_color[1],
734 756 theme.background_color[2],
735 757 );
736 −
758 +
737 759 visuals.override_text_color = Some(egui::Color32::from_rgb(
738 760 theme.heading_colors[0][0],
739 761 theme.heading_colors[0][1],
740 762 theme.heading_colors[0][2],
741 763 ));
742 −
764 +
743 765 ctx.set_visuals(visuals);
744 766 }
745 −
767 +
746 768 fn render_settings_window(&mut self, ctx: &egui::Context) {
747 769 let mut theme_changed = false;
748 −
770 +
749 771 egui::Window::new("Koco Settings")
750 772 .open(&mut self.show_settings_window)
751 773 .resizable(true)
752 774 .default_size([500.0, 600.0])
753 775 .show(ctx, |ui| {
754 − egui::ScrollArea::vertical().show(ui, |ui| {
755 − ui.add_space(10.0);
756 −
757 − // Instance selector
758 − ui.horizontal(|ui| {
759 − ui.label("Instance:");
760 − egui::ComboBox::from_id_salt("instance_selector")
761 − .selected_text(if self.current_instance_index < self.config.instances.len() {
762 − &self.config.instances[self.current_instance_index].label
763 − } else {
764 − "None"
765 − })
766 − .show_ui(ui, |ui| {
767 − for (i, instance) in self.config.instances.iter().enumerate() {
768 − if ui.selectable_label(i == self.current_instance_index, &instance.hostname).clicked() {
769 − self.current_instance_index = i;
770 − self.config_ui_state.load_from_instance(instance);
776 + egui::ScrollArea::vertical().show(ui, |ui| {
777 + ui.add_space(10.0);
778 +
779 + // Instance selector
780 + ui.horizontal(|ui| {
781 + ui.label("Instance:");
782 + egui::ComboBox::from_id_salt("instance_selector")
783 + .selected_text(
784 + if self.current_instance_index < self.config.instances.len() {
785 + &self.config.instances[self.current_instance_index].label
786 + } else {
787 + "None"
788 + },
789 + )
790 + .show_ui(ui, |ui| {
791 + for (i, instance) in self.config.instances.iter().enumerate() {
792 + if ui
793 + .selectable_label(
794 + i == self.current_instance_index,
795 + &instance.hostname,
796 + )
797 + .clicked()
798 + {
799 + self.current_instance_index = i;
800 + self.config_ui_state.load_from_instance(instance);
801 + }
771 802 }
772 − }
773 − });
774 − });
775 −
776 − ui.separator();
777 −
778 − // Theme selector
779 − ui.horizontal(|ui| {
780 − ui.label("Theme:");
781 − egui::ComboBox::from_id_salt("theme_selector")
782 − .selected_text(&self.config.current_theme)
783 − .show_ui(ui, |ui| {
784 − for theme in &self.config.themes {
785 − if ui.selectable_label(
786 − theme.name == self.config.current_theme,
787 − &theme.name
788 − ).clicked() {
789 − self.config.current_theme = theme.name.clone();
790 − theme_changed = true;
791 − let _ = self.config.save();
803 + });
804 + });
805 +
806 + ui.separator();
807 +
808 + // Theme selector
809 + ui.horizontal(|ui| {
810 + ui.label("Theme:");
811 + egui::ComboBox::from_id_salt("theme_selector")
812 + .selected_text(&self.config.current_theme)
813 + .show_ui(ui, |ui| {
814 + for theme in &self.config.themes {
815 + if ui
816 + .selectable_label(
817 + theme.name == self.config.current_theme,
818 + &theme.name,
819 + )
820 + .clicked()
821 + {
822 + self.config.current_theme = theme.name.clone();
823 + theme_changed = true;
824 + let _ = self.config.save();
825 + }
792 826 }
827 + });
828 + });
829 +
830 + ui.separator();
831 +
832 + // Tabs for Edit/Add
833 + ui.horizontal(|ui| {
834 + ui.selectable_value(
835 + &mut self.settings_tab,
836 + SettingsTab::Edit,
837 + "Edit Instance",
838 + );
839 + ui.selectable_value(
840 + &mut self.settings_tab,
841 + SettingsTab::Add,
842 + "Add Instance",
843 + );
844 + });
845 +
846 + ui.separator();
847 +
848 + match self.settings_tab {
849 + SettingsTab::Edit => {
850 + // Edit current instance
851 + if self.current_instance_index < self.config.instances.len() {
852 + ui.heading("Edit Instance");
853 +
854 + ui.horizontal(|ui| {
855 + ui.label("Label:");
856 + ui.text_edit_singleline(&mut self.config_ui_state.label);
857 + });
858 +
859 + ui.horizontal(|ui| {
860 + ui.label("Hostname:");
861 + ui.text_edit_singleline(&mut self.config_ui_state.hostname);
862 + });
863 +
864 + ui.horizontal(|ui| {
865 + ui.label("HTTP Port:");
866 + ui.text_edit_singleline(&mut self.config_ui_state.http_port);
867 + });
868 +
869 + ui.horizontal(|ui| {
870 + ui.label("Username:");
871 + ui.text_edit_singleline(&mut self.config_ui_state.username);
872 + });
873 +
874 + ui.horizontal(|ui| {
875 + ui.label("Password:");
876 + ui.add(
877 + egui::TextEdit::singleline(
878 + &mut self.config_ui_state.password,
879 + )
880 + .password(true),
881 + );
882 + });
883 +
884 + ui.checkbox(&mut self.config_ui_state.events, "Enable Events");
885 +
886 + if self.config_ui_state.events {
887 + ui.horizontal(|ui| {
888 + ui.label("Events Port:");
889 + ui.text_edit_singleline(
890 + &mut self.config_ui_state.events_port,
891 + );
892 + });
893 + }
894 +
895 + ui.horizontal(|ui| {
896 + if ui.button("Update Instance").clicked() {
897 + match self.config_ui_state.to_instance() {
898 + Ok(instance) => {
899 + self.config.instances
900 + [self.current_instance_index] = instance;
901 + if let Err(e) = self.config.save() {
902 + warn!("Failed to save config: {}", e);
903 + }
904 + }
905 + Err(e) => info!("Invalid instance data: {}", e),
906 + }
907 + }
908 +
909 + if ui.button("Delete Instance").clicked()
910 + && self.config.instances.len() > 1
911 + {
912 + self.config.instances.remove(self.current_instance_index);
913 + self.current_instance_index =
914 + self.current_instance_index.saturating_sub(1);
915 + if !self.config.instances.is_empty() {
916 + self.config_ui_state.load_from_instance(
917 + &self.config.instances[self.current_instance_index],
918 + );
919 + }
920 + if let Err(e) = self.config.save() {
921 + warn!("Failed to save config: {}", e);
922 + }
923 + }
924 + });
925 + } else {
926 + ui.label("No instance selected");
793 927 }
794 − });
795 − });
796 −
797 − ui.separator();
798 −
799 − // Tabs for Edit/Add
800 − ui.horizontal(|ui| {
801 − ui.selectable_value(&mut self.settings_tab, SettingsTab::Edit, "Edit Instance");
802 − ui.selectable_value(&mut self.settings_tab, SettingsTab::Add, "Add Instance");
803 − });
804 −
805 − ui.separator();
806 −
807 − match self.settings_tab {
808 − SettingsTab::Edit => {
809 − // Edit current instance
810 − if self.current_instance_index < self.config.instances.len() {
811 − ui.heading("Edit Instance");
812 −
928 + }
929 + SettingsTab::Add => {
930 + // Add new instance
931 + ui.heading("Add New Instance");
932 +
813 933 ui.horizontal(|ui| {
814 934 ui.label("Label:");
815 − ui.text_edit_singleline(&mut self.config_ui_state.label);
935 + ui.text_edit_singleline(&mut self.config_ui_state.new_label);
816 936 });
817 −
937 +
818 938 ui.horizontal(|ui| {
819 939 ui.label("Hostname:");
820 − ui.text_edit_singleline(&mut self.config_ui_state.hostname);
940 + ui.text_edit_singleline(&mut self.config_ui_state.new_hostname);
821 941 });
822 −
942 +
823 943 ui.horizontal(|ui| {
824 944 ui.label("HTTP Port:");
825 − ui.text_edit_singleline(&mut self.config_ui_state.http_port);
945 + ui.text_edit_singleline(&mut self.config_ui_state.new_http_port);
826 946 });
827 −
947 +
828 948 ui.horizontal(|ui| {
829 949 ui.label("Username:");
830 − ui.text_edit_singleline(&mut self.config_ui_state.username);
950 + ui.text_edit_singleline(&mut self.config_ui_state.new_username);
831 951 });
832 −
952 +
833 953 ui.horizontal(|ui| {
834 954 ui.label("Password:");
835 − ui.add(egui::TextEdit::singleline(&mut self.config_ui_state.password).password(true));
955 + ui.add(
956 + egui::TextEdit::singleline(
957 + &mut self.config_ui_state.new_password,
958 + )
959 + .password(true),
960 + );
836 961 });
837 −
838 − ui.checkbox(&mut self.config_ui_state.events, "Enable Events");
839 −
840 − if self.config_ui_state.events {
962 +
963 + ui.checkbox(&mut self.config_ui_state.new_events, "Enable Events");
964 +
965 + if self.config_ui_state.new_events {
841 966 ui.horizontal(|ui| {
842 967 ui.label("Events Port:");
843 − ui.text_edit_singleline(&mut self.config_ui_state.events_port);
968 + ui.text_edit_singleline(
969 + &mut self.config_ui_state.new_events_port,
970 + );
844 971 });
845 972 }
846 −
847 − ui.horizontal(|ui| {
848 − if ui.button("Update Instance").clicked() {
849 − match self.config_ui_state.to_instance() {
850 − Ok(instance) => {
851 − self.config.instances[self.current_instance_index] = instance;
852 − if let Err(e) = self.config.save() {
853 − warn!("Failed to save config: {}", e);
854 − }
973 +
974 + if ui.button("Add Instance").clicked() {
975 + match self.config_ui_state.new_instance() {
976 + Ok(instance) => {
977 + self.config.instances.push(instance);
978 + // Clear new instance fields
979 + self.config_ui_state.new_label.clear();
980 + self.config_ui_state.new_hostname.clear();
981 + self.config_ui_state.new_http_port.clear();
982 + self.config_ui_state.new_username.clear();
983 + self.config_ui_state.new_password.clear();
984 + self.config_ui_state.new_events = true;
985 + self.config_ui_state.new_events_port.clear();
986 +
987 + if let Err(e) = self.config.save() {
988 + warn!("Failed to save config: {}", e);
855 989 }
856 − Err(e) => info!("Invalid instance data: {}", e),
857 − }
858 − }
859 −
860 − if ui.button("Delete Instance").clicked() && self.config.instances.len() > 1 {
861 − self.config.instances.remove(self.current_instance_index);
862 − self.current_instance_index = self.current_instance_index.saturating_sub(1);
863 − if !self.config.instances.is_empty() {
864 − self.config_ui_state.load_from_instance(&self.config.instances[self.current_instance_index]);
865 − }
866 − if let Err(e) = self.config.save() {
867 − warn!("Failed to save config: {}", e);
868 − }
869 − }
870 − });
871 − } else {
872 − ui.label("No instance selected");
873 − }
874 − }
875 − SettingsTab::Add => {
876 − // Add new instance
877 − ui.heading("Add New Instance");
878 −
879 − ui.horizontal(|ui| {
880 − ui.label("Label:");
881 − ui.text_edit_singleline(&mut self.config_ui_state.new_label);
882 − });
883 −
884 − ui.horizontal(|ui| {
885 − ui.label("Hostname:");
886 − ui.text_edit_singleline(&mut self.config_ui_state.new_hostname);
887 − });
888 −
889 − ui.horizontal(|ui| {
890 − ui.label("HTTP Port:");
891 − ui.text_edit_singleline(&mut self.config_ui_state.new_http_port);
892 − });
893 −
894 − ui.horizontal(|ui| {
895 − ui.label("Username:");
896 − ui.text_edit_singleline(&mut self.config_ui_state.new_username);
897 − });
898 −
899 − ui.horizontal(|ui| {
900 − ui.label("Password:");
901 − ui.add(egui::TextEdit::singleline(&mut self.config_ui_state.new_password).password(true));
902 − });
903 −
904 − ui.checkbox(&mut self.config_ui_state.new_events, "Enable Events");
905 −
906 − if self.config_ui_state.new_events {
907 − ui.horizontal(|ui| {
908 − ui.label("Events Port:");
909 − ui.text_edit_singleline(&mut self.config_ui_state.new_events_port);
910 − });
911 − }
912 −
913 − if ui.button("Add Instance").clicked() {
914 − match self.config_ui_state.new_instance() {
915 − Ok(instance) => {
916 − self.config.instances.push(instance);
917 − // Clear new instance fields
918 − self.config_ui_state.new_label.clear();
919 − self.config_ui_state.new_hostname.clear();
920 − self.config_ui_state.new_http_port.clear();
921 − self.config_ui_state.new_username.clear();
922 − self.config_ui_state.new_password.clear();
923 − self.config_ui_state.new_events = true;
924 − self.config_ui_state.new_events_port.clear();
925 −
926 − if let Err(e) = self.config.save() {
927 − warn!("Failed to save config: {}", e);
928 990 }
991 + Err(e) => warn!("Failed to add instance: {}", e),
929 992 }
930 − Err(e) => warn!("Failed to add instance: {}", e),
931 993 }
932 994 }
933 995 }
934 − }
935 −
936 − ui.separator();
937 − ui.label(format!("Config file: {:?}", KocoConfig::config_path()));
938 − ui.add_space(10.0);
996 +
997 + ui.separator();
998 + ui.label(format!("Config file: {:?}", KocoConfig::config_path()));
999 + ui.add_space(10.0);
1000 + });
939 1001 });
940 − });
941 −
1002 +
942 1003 // Apply theme outside the closure to avoid borrow checker issues
943 1004 if theme_changed {
944 1005 self.apply_theme(ctx);
945 1006 }
946 1007 }
947 −
1008 +
948 1009 fn current_instance(&self) -> Option<&Instance> {
949 1010 self.config.instances.get(self.current_instance_index)
950 1011 }
@@ -967,9 +1028,9 @@ impl eframe::App for Koco {
967 1028 } else {
968 1029 "Koco - Kodi Control".to_string()
969 1030 };
970 −
1031 +
971 1032 ctx.send_viewport_cmd(egui::ViewportCommand::Title(window_title));
972 −
1033 +
973 1034 // Handle keyboard input
974 1035 if let Some(instance) = self.current_instance() {
975 1036 ctx.input(|i| {
@@ -1011,30 +1072,38 @@ impl eframe::App for Koco {
1011 1072 });
1012 1073 }
1013 1074
1014 − let label =self.current_instance().map(|i| i.label.clone()).unwrap_or_else(|| "No instance selected".to_string());
1015 −
1075 + let label = self
1076 + .current_instance()
1077 + .map(|i| i.label.clone())
1078 + .unwrap_or_else(|| "No instance selected".to_string());
1079 +
1016 1080 // Top menu bar
1017 1081 egui::TopBottomPanel::top("top_panel").show(ctx, |ui| {
1018 1082 ui.horizontal(|ui| {
1019 1083 ui.heading(format!("Koco - {}", label));
1020 1084 ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
1021 − if ui.button(egui::RichText::new(egui_material_icons::icons::ICON_SETTINGS).size(20.0)).clicked() {
1085 + if ui
1086 + .button(
1087 + egui::RichText::new(egui_material_icons::icons::ICON_SETTINGS)
1088 + .size(20.0),
1089 + )
1090 + .clicked()
1091 + {
1022 1092 self.show_settings_window = true;
1023 1093 }
1024 1094 });
1025 1095 });
1026 1096 });
1027 −
1097 +
1028 1098 // Show settings window
1029 1099 if self.show_settings_window {
1030 1100 self.render_settings_window(ctx);
1031 1101 }
1032 −
1102 +
1033 1103 // Main panel
1034 1104 egui::CentralPanel::default().show(ctx, |ui| {
1035 1105 if let Some(instance) = self.current_instance() {
1036 1106 ui.vertical_centered(|ui| {
1037 −
1038 1107 ui.add_space(20.0);
1039 1108
1040 1109 // Grid for the DPAD and Select button with sized buttons
@@ -1043,9 +1112,18 @@ impl eframe::App for Koco {
1043 1112 .show(ui, |ui| {
1044 1113 // Row 1: Empty, Up, Empty
1045 1114 ui.label(""); // Empty cell
1046 − if ui.add_sized([80.0, 80.0],
1047 − egui::Button::new(egui::RichText::new(egui_material_icons::icons::ICON_ARROW_UPWARD).size(48.0))
1048 − ).clicked() {
1115 + if ui
1116 + .add_sized(
1117 + [80.0, 80.0],
1118 + egui::Button::new(
1119 + egui::RichText::new(
1120 + egui_material_icons::icons::ICON_ARROW_UPWARD,
1121 + )
1122 + .size(48.0),
1123 + ),
1124 + )
1125 + .clicked()
1126 + {
1049 1127 if let Err(e) = instance.send_key("up") {
1050 1128 info!("Failed to send up command: {}", e);
1051 1129 }
@@ -1054,23 +1132,50 @@ impl eframe::App for Koco {
1054 1132 ui.end_row();
1055 1133
1056 1134 // Row 2: Left, Select, Right
1057 − if ui.add_sized([80.0, 80.0],
1058 − egui::Button::new(egui::RichText::new(egui_material_icons::icons::ICON_ARROW_BACK).size(48.0))
1059 − ).clicked() {
1135 + if ui
1136 + .add_sized(
1137 + [80.0, 80.0],
1138 + egui::Button::new(
1139 + egui::RichText::new(
1140 + egui_material_icons::icons::ICON_ARROW_BACK,
1141 + )
1142 + .size(48.0),
1143 + ),
1144 + )
1145 + .clicked()
1146 + {
1060 1147 if let Err(e) = instance.send_key("left") {
1061 1148 info!("Failed to send left command: {}", e);
1062 1149 }
1063 1150 }
1064 − if ui.add_sized([80.0, 80.0],
1065 − egui::Button::new(egui::RichText::new(egui_material_icons::icons::ICON_RADIO_BUTTON_CHECKED).size(48.0))
1066 − ).clicked() {
1151 + if ui
1152 + .add_sized(
1153 + [80.0, 80.0],
1154 + egui::Button::new(
1155 + egui::RichText::new(
1156 + egui_material_icons::icons::ICON_RADIO_BUTTON_CHECKED,
1157 + )
1158 + .size(48.0),
1159 + ),
1160 + )
1161 + .clicked()
1162 + {
1067 1163 if let Err(e) = instance.send_key("select") {
1068 1164 info!("Failed to send select command: {}", e);
1069 1165 }
1070 1166 }
1071 − if ui.add_sized([80.0, 80.0],
1072 − egui::Button::new(egui::RichText::new(egui_material_icons::icons::ICON_ARROW_FORWARD).size(48.0))
1073 − ).clicked() {
1167 + if ui
1168 + .add_sized(
1169 + [80.0, 80.0],
1170 + egui::Button::new(
1171 + egui::RichText::new(
1172 + egui_material_icons::icons::ICON_ARROW_FORWARD,
1173 + )
1174 + .size(48.0),
1175 + ),
1176 + )
1177 + .clicked()
1178 + {
1074 1179 if let Err(e) = instance.send_key("right") {
1075 1180 info!("Failed to send right command: {}", e);
1076 1181 }
@@ -1078,16 +1183,34 @@ impl eframe::App for Koco {
1078 1183 ui.end_row();
1079 1184
1080 1185 // Row 3: Empty, Down, Empty
1081 − if ui.add_sized([80.0, 80.0],
1082 − egui::Button::new(egui::RichText::new(egui_material_icons::icons::ICON_REPLAY).size(48.0))
1083 − ).clicked() {
1186 + if ui
1187 + .add_sized(
1188 + [80.0, 80.0],
1189 + egui::Button::new(
1190 + egui::RichText::new(
1191 + egui_material_icons::icons::ICON_REPLAY,
1192 + )
1193 + .size(48.0),
1194 + ),
1195 + )
1196 + .clicked()
1197 + {
1084 1198 if let Err(e) = instance.send_key("back") {
1085 1199 info!("Failed to send back command: {}", e);
1086 1200 }
1087 1201 }
1088 − if ui.add_sized([80.0, 80.0],
1089 − egui::Button::new(egui::RichText::new(egui_material_icons::icons::ICON_ARROW_DOWNWARD).size(48.0))
1090 − ).clicked() {
1202 + if ui
1203 + .add_sized(
1204 + [80.0, 80.0],
1205 + egui::Button::new(
1206 + egui::RichText::new(
1207 + egui_material_icons::icons::ICON_ARROW_DOWNWARD,
1208 + )
1209 + .size(48.0),
1210 + ),
1211 + )
1212 + .clicked()
1213 + {
1091 1214 if let Err(e) = instance.send_key("down") {
1092 1215 info!("Failed to send down command: {}", e);
1093 1216 }
@@ -1105,7 +1228,7 @@ impl eframe::App for Koco {
1105 1228 });
1106 1229 }
1107 1230 });
1108 −
1231 +
1109 1232 // Bottom panel for now playing
1110 1233 egui::TopBottomPanel::bottom("now_playing_panel").show(ctx, |ui| {
1111 1234 if let Ok(np) = self.now_playing.lock() {
@@ -1116,22 +1239,22 @@ impl eframe::App for Koco {
1116 1239 } else {
1117 1240 0.0
1118 1241 };
1119 −
1242 +
1120 1243 // Draw custom background with progress
1121 1244 let available_rect = ui.available_rect_before_wrap();
1122 1245 let progress_width = available_rect.width() * percentage as f32;
1123 −
1246 +
1124 1247 // Draw progress background
1125 1248 let progress_rect = egui::Rect::from_min_size(
1126 1249 available_rect.min,
1127 − egui::vec2(progress_width, available_rect.height())
1250 + egui::vec2(progress_width, available_rect.height()),
1128 1251 );
1129 1252 ui.painter().rect_filled(
1130 1253 progress_rect,
1131 1254 0.0,
1132 − egui::Color32::from_rgba_unmultiplied(100, 100, 255, 50)
1255 + egui::Color32::from_rgba_unmultiplied(100, 100, 255, 50),
1133 1256 );
1134 −
1257 +
1135 1258 ui.add_space(5.0);
1136 1259 ui.horizontal(|ui| {
1137 1260 let status_icon = if np.playing { "▶" } else { "⏸" };

Parents: 23c3f77