cleanup etc

564537a · gmorell · 2025-12-26 03:11

4 files +204 -56

Files changed

modified README.md
+7 −0
@@ -2,3 +2,10 @@
2 2
3 3 A Minimal Desktop **Ko**di **Co**ntroller
4 4
5 +![img.png](img.png)
6 +
7 +## Features
8 +
9 +- Multi Instance Support
10 +- Now Playing in the window title
11 +- Keyboard Shortcuts
modified ?
+0 −0

Binary file — not rendered.

modified src/main.rs
+197 −56
@@ -6,6 +6,7 @@ use std::sync::{Arc, Mutex};
6 6 use tokio::runtime::Runtime;
7 7 use tokio_tungstenite::{connect_async, tungstenite::protocol::Message};
8 8 use futures_util::{StreamExt, SinkExt};
9 +use log::{debug, info, warn};
9 10 use serde_json::Value;
10 11
11 12 const DEFAULT_HTTP_PORT : usize = 8080;
@@ -88,6 +89,8 @@ struct NowPlaying {
88 89 artist: String,
89 90 album: String,
90 91 playing: bool,
92 + duration: f64, // Total duration in seconds
93 + position: f64, // Current position in seconds
91 94 }
92 95
93 96 impl NowPlaying {
@@ -96,6 +99,8 @@ impl NowPlaying {
96 99 self.artist.clear();
97 100 self.album.clear();
98 101 self.playing = false;
102 + self.duration = 0.0;
103 + self.position = 0.0;
99 104 }
100 105 }
101 106
@@ -380,16 +385,16 @@ impl KocoConfig {
380 385 Ok(contents) => {
381 386 match toml::from_str(&contents) {
382 387 Ok(config) => {
383 − println!("Loaded config from {:?}", path);
388 + info!("Loaded config from {:?}", path);
384 389 return config;
385 390 }
386 391 Err(e) => {
387 − eprintln!("Failed to parse config: {}. Using default.", e);
392 + info!("Failed to parse config: {}. Using default.", e);
388 393 }
389 394 }
390 395 }
391 396 Err(e) => {
392 − eprintln!("Failed to read config: {}. Using default.", e);
397 + info!("Failed to read config: {}. Using default.", e);
393 398 }
394 399 }
395 400 }
@@ -408,7 +413,7 @@ impl KocoConfig {
408 413 file.write_all(toml_string.as_bytes())
409 414 .map_err(|e| format!("Failed to write config file: {}", e))?;
410 415
411 − println!("Saved config to {:?}", path);
416 + info!("Saved config to {:?}", path);
412 417 Ok(())
413 418 }
414 419
@@ -471,6 +476,9 @@ impl Koco {
471 476 // Connect to websocket on startup
472 477 app.connect_websocket();
473 478
479 + // Start position updater
480 + app.start_position_updater();
481 +
474 482 app
475 483 }
476 484
@@ -482,13 +490,14 @@ impl Koco {
482 490
483 491 let ws_url = format!("ws://{}:{}/jsonrpc", instance.hostname, DEFAULT_WS_PORT);
484 492 let now_playing = Arc::clone(&self.now_playing);
493 + let current_player_id = Arc::new(Mutex::new(None::<i64>));
485 494 let _username = instance.username.clone();
486 495 let _password = instance.password.clone();
487 496
488 497 self.runtime.spawn(async move {
489 498 match connect_async(&ws_url).await {
490 499 Ok((mut ws_stream, _)) => {
491 − println!("Connected to Kodi WebSocket at {}", ws_url);
500 + debug!("Connected to Kodi WebSocket at {}", ws_url);
492 501
493 502 // Subscribe to player notifications
494 503 // Note: Kodi doesn't require explicit subscription, it broadcasts all notifications
@@ -500,9 +509,9 @@ impl Koco {
500 509 "id": 1
501 510 });
502 511
503 − println!("Requesting active players...");
512 + debug!("Requesting active players...");
504 513 if let Err(e) = ws_stream.send(Message::Text(get_active_players_msg.to_string())).await {
505 − eprintln!("Failed to get active players: {}", e);
514 + debug!("Failed to get active players: {}", e);
506 515 return;
507 516 }
508 517
@@ -510,39 +519,66 @@ impl Koco {
510 519 while let Some(msg) = ws_stream.next().await {
511 520 match msg {
512 521 Ok(Message::Text(text)) => {
513 − println!("Received WebSocket message: {}", text);
522 + debug!("Received WebSocket message: {}", text);
514 523 if let Ok(json) = serde_json::from_str::<Value>(&text) {
515 524 if let Some(method) = json.get("method").and_then(|m| m.as_str()) {
516 − println!("Method: {}", method);
525 + debug!("Method: {}", method);
517 526 match method {
518 527 "Player.OnPlay" | "Player.OnResume" => {
519 − println!("Player event detected, requesting item info...");
528 + warn!("Player event detected, requesting item info...");
520 529 // Set playing state to true for play/resume
521 530 if let Ok(mut np) = now_playing.lock() {
522 531 np.playing = true;
523 532 }
533 +
534 + // Get player ID from the notification
535 + let player_id = json.get("params")
536 + .and_then(|p| p.get("data"))
537 + .and_then(|d| d.get("player"))
538 + .and_then(|p| p.get("playerid"))
539 + .and_then(|id| id.as_i64())
540 + .unwrap_or(0);
541 +
542 + if let Ok(mut pid) = current_player_id.lock() {
543 + *pid = Some(player_id);
544 + }
545 +
546 + debug!("Using player ID: {}", player_id);
547 +
548 + // Request player properties for position/duration
549 + let properties_msg = serde_json::json!({
550 + "jsonrpc": "2.0",
551 + "method": "Player.GetProperties",
552 + "params": {
553 + "playerid": player_id,
554 + "properties": ["time", "totaltime", "percentage"]
555 + },
556 + "id": 4
557 + });
558 + let _ = ws_stream.send(Message::Text(properties_msg.to_string())).await;
559 +
524 560 // Request current player info
525 561 let player_info_msg = serde_json::json!({
526 562 "jsonrpc": "2.0",
527 563 "method": "Player.GetItem",
528 564 "params": {
529 − "playerid": 0,
530 − "properties": ["title", "artist", "album", "duration"]
565 + "playerid": player_id,
566 + "properties": ["title", "artist", "album", "duration", "showtitle", "season", "episode"]
531 567 },
532 568 "id": 2
533 569 });
534 570 let _ = ws_stream.send(Message::Text(player_info_msg.to_string())).await;
535 571 }
536 572 "Player.OnPause" => {
537 − println!("Player paused");
573 + warn!("Player paused");
538 574 if let Ok(mut np) = now_playing.lock() {
539 575 np.playing = false;
540 576 }
541 577 }
542 578 "Player.OnStop" => {
543 − println!("Player stopped");
579 + warn!("Player stopped");
544 580 if let Ok(mut np) = now_playing.lock() {
545 − println!("Clearing now playing info");
581 + debug!("Clearing now playing info");
546 582 np.clear();
547 583 }
548 584 }
@@ -550,33 +586,100 @@ impl Koco {
550 586 }
551 587 }
552 588
589 + // Handle errors
590 + if let Some(error) = json.get("error") {
591 + info!("Kodi API error: {:?}", error);
592 + info!("Request ID: {:?}", json.get("id"));
593 + }
594 +
553 595 if let Some(result) = json.get("result") {
554 − println!("Got result: {:?}", result);
555 − // Parse player info response
596 + info!("Got result: {:?}", result);
597 + info!("Request ID from response: {:?}", json.get("id"));
598 +
599 + // Check if this is a direct properties response (not nested in item)
600 + if let Some(totaltime_obj) = result.get("totaltime") {
601 + debug!("Got totaltime at root level: {:?}", totaltime_obj);
602 + if let Ok(mut np) = now_playing.lock() {
603 + let hours = totaltime_obj.get("hours").and_then(|h| h.as_f64()).unwrap_or(0.0);
604 + let minutes = totaltime_obj.get("minutes").and_then(|m| m.as_f64()).unwrap_or(0.0);
605 + let seconds = totaltime_obj.get("seconds").and_then(|s| s.as_f64()).unwrap_or(0.0);
606 + np.duration = hours * 3600.0 + minutes * 60.0 + seconds;
607 + debug!("Updated duration from root properties: {}", np.duration);
608 + }
609 + }
610 +
611 + // Parse player item info response
556 612 if let Some(item) = result.get("item") {
557 − println!("Parsing item: {:?}", item);
613 + debug!("Parsing item: {:?}", item);
558 614 if let Ok(mut np) = now_playing.lock() {
559 − np.title = item.get("title").and_then(|t| t.as_str()).unwrap_or("").to_string();
615 + let title = item.get("title").and_then(|t| t.as_str()).unwrap_or("");
616 + let label = item.get("label").and_then(|l| l.as_str()).unwrap_or("");
617 +
618 + // Use label if title is empty
619 + np.title = if !title.is_empty() { title.to_string() } else { label.to_string() };
560 620 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();
561 621 np.album = item.get("album").and_then(|a| a.as_str()).unwrap_or("").to_string();
622 +
623 + debug!("Parsed - Title: '{}', Artist: '{}', Album: '{}'", np.title, np.artist, np.album);
624 + np.playing = true;
625 + }
626 + // Don't try to get duration from item - it comes from GetProperties
627 + }
628 +
629 + // Handle position from root level
630 + if let Some(position_obj) = result.get("time").or_else(|| result.get("position")) {
631 + debug!("Got time/position at root level: {:?}", position_obj);
632 + if let Ok(mut np) = now_playing.lock() {
633 + let hours = position_obj.get("hours").and_then(|h| h.as_f64()).unwrap_or(0.0);
634 + let minutes = position_obj.get("minutes").and_then(|m| m.as_f64()).unwrap_or(0.0);
635 + let seconds = position_obj.get("seconds").and_then(|s| s.as_f64()).unwrap_or(0.0);
636 + np.position = hours * 3600.0 + minutes * 60.0 + seconds;
637 + debug!("Updated position: {} / {}", np.position, np.duration);
562 638 np.playing = true;
563 − println!("Updated now playing: {} - {} ({})", np.title, np.artist, np.album);
639 + }
640 + }
641 +
642 + // Log summary after parsing
643 + if result.get("item").is_some() || result.get("totaltime").is_some() || result.get("time").is_some() {
644 + if let Ok(np) = now_playing.lock() {
645 + warn!("==> Now Playing: {} - {} ({}) - {:.0}s / {:.0}s ({:.1}%)",
646 + np.title, np.artist, np.album, np.position, np.duration,
647 + if np.duration > 0.0 { (np.position / np.duration) * 100.0 } else { 0.0 });
564 648 }
565 649 } else if let Some(players) = result.as_array() {
566 650 // Response to GetActivePlayers
567 − println!("Active players: {:?}", players);
651 + debug!("Active players: {:?}", players);
568 652 if !players.is_empty() {
569 653 // Get the first active player's info
570 654 if let Some(player_id) = players[0].get("playerid").and_then(|id| id.as_i64()) {
571 − println!("Found active player with id: {}", player_id);
655 + debug!("Found active player with id: {}", player_id);
656 +
657 + // Store the player ID
658 + if let Ok(mut pid) = current_player_id.lock() {
659 + *pid = Some(player_id);
660 + }
661 +
662 + // Request properties first
663 + let properties_msg = serde_json::json!({
664 + "jsonrpc": "2.0",
665 + "method": "Player.GetProperties",
666 + "params": {
667 + "playerid": player_id,
668 + "properties": ["time", "totaltime", "percentage"]
669 + },
670 + "id": 5
671 + });
672 + let _ = ws_stream.send(Message::Text(properties_msg.to_string())).await;
673 +
674 + // Then request item info
572 675 let player_info_msg = serde_json::json!({
573 676 "jsonrpc": "2.0",
574 677 "method": "Player.GetItem",
575 678 "params": {
576 679 "playerid": player_id,
577 − "properties": ["title", "artist", "album", "duration"]
680 + "properties": ["title", "artist", "album", "duration", "showtitle", "season", "episode"]
578 681 },
579 − "id": 3
682 + "id": 6
580 683 });
581 684 let _ = ws_stream.send(Message::Text(player_info_msg.to_string())).await;
582 685 }
@@ -586,11 +689,11 @@ impl Koco {
586 689 }
587 690 }
588 691 Ok(Message::Close(_)) => {
589 − println!("WebSocket closed");
692 + debug!("WebSocket closed");
590 693 break;
591 694 }
592 695 Err(e) => {
593 − eprintln!("WebSocket error: {}", e);
696 + debug!("WebSocket error: {}", e);
594 697 break;
595 698 }
596 699 _ => {}
@@ -598,13 +701,29 @@ impl Koco {
598 701 }
599 702 }
600 703 Err(e) => {
601 − eprintln!("Failed to connect to WebSocket: {}", e);
704 + debug!("Failed to connect to WebSocket: {}", e);
602 705 }
603 706 }
604 707 });
605 708 }
606 709 }
607 710
711 + fn start_position_updater(&self) {
712 + let now_playing = Arc::clone(&self.now_playing);
713 +
714 + self.runtime.spawn(async move {
715 + let mut interval = tokio::time::interval(tokio::time::Duration::from_secs(1));
716 + loop {
717 + interval.tick().await;
718 + if let Ok(mut np) = now_playing.lock() {
719 + if np.playing && np.position < np.duration {
720 + np.position += 1.0;
721 + }
722 + }
723 + }
724 + });
725 + }
726 +
608 727 fn apply_theme(&self, ctx: &egui::Context) {
609 728 let theme = self.config.get_current_theme_or_default();
610 729 let mut visuals = egui::Visuals::dark();
@@ -731,10 +850,10 @@ impl Koco {
731 850 Ok(instance) => {
732 851 self.config.instances[self.current_instance_index] = instance;
733 852 if let Err(e) = self.config.save() {
734 − eprintln!("Failed to save config: {}", e);
853 + warn!("Failed to save config: {}", e);
735 854 }
736 855 }
737 − Err(e) => eprintln!("Invalid instance data: {}", e),
856 + Err(e) => info!("Invalid instance data: {}", e),
738 857 }
739 858 }
740 859
@@ -745,7 +864,7 @@ impl Koco {
745 864 self.config_ui_state.load_from_instance(&self.config.instances[self.current_instance_index]);
746 865 }
747 866 if let Err(e) = self.config.save() {
748 − eprintln!("Failed to save config: {}", e);
867 + warn!("Failed to save config: {}", e);
749 868 }
750 869 }
751 870 });
@@ -805,10 +924,10 @@ impl Koco {
805 924 self.config_ui_state.new_events_port.clear();
806 925
807 926 if let Err(e) = self.config.save() {
808 − eprintln!("Failed to save config: {}", e);
927 + warn!("Failed to save config: {}", e);
809 928 }
810 929 }
811 − Err(e) => eprintln!("Failed to add instance: {}", e),
930 + Err(e) => warn!("Failed to add instance: {}", e),
812 931 }
813 932 }
814 933 }
@@ -856,37 +975,37 @@ impl eframe::App for Koco {
856 975 ctx.input(|i| {
857 976 if i.key_pressed(egui::Key::ArrowUp) {
858 977 if let Err(e) = instance.send_key("up") {
859 − eprintln!("Failed to send up command: {}", e);
978 + info!("Failed to send up command: {}", e);
860 979 }
861 980 }
862 981 if i.key_pressed(egui::Key::ArrowDown) {
863 982 if let Err(e) = instance.send_key("down") {
864 − eprintln!("Failed to send down command: {}", e);
983 + info!("Failed to send down command: {}", e);
865 984 }
866 985 }
867 986 if i.key_pressed(egui::Key::ArrowLeft) {
868 987 if let Err(e) = instance.send_key("left") {
869 − eprintln!("Failed to send left command: {}", e);
988 + info!("Failed to send left command: {}", e);
870 989 }
871 990 }
872 991 if i.key_pressed(egui::Key::ArrowRight) {
873 992 if let Err(e) = instance.send_key("right") {
874 − eprintln!("Failed to send right command: {}", e);
993 + info!("Failed to send right command: {}", e);
875 994 }
876 995 }
877 996 if i.key_pressed(egui::Key::Enter) {
878 997 if let Err(e) = instance.send_key("select") {
879 − eprintln!("Failed to send select command: {}", e);
998 + info!("Failed to send select command: {}", e);
880 999 }
881 1000 }
882 1001 if i.key_pressed(egui::Key::Escape) || i.key_pressed(egui::Key::Backspace) {
883 1002 if let Err(e) = instance.send_key("back") {
884 − eprintln!("Failed to send back command: {}", e);
1003 + info!("Failed to send back command: {}", e);
885 1004 }
886 1005 }
887 1006 if i.key_pressed(egui::Key::Space) {
888 1007 if let Err(e) = instance.send_key("playpause") {
889 − eprintln!("Failed to send playpause command: {}", e);
1008 + info!("Failed to send playpause command: {}", e);
890 1009 }
891 1010 }
892 1011 });
@@ -928,7 +1047,7 @@ impl eframe::App for Koco {
928 1047 egui::Button::new(egui::RichText::new(egui_material_icons::icons::ICON_ARROW_UPWARD).size(48.0))
929 1048 ).clicked() {
930 1049 if let Err(e) = instance.send_key("up") {
931 − eprintln!("Failed to send up command: {}", e);
1050 + info!("Failed to send up command: {}", e);
932 1051 }
933 1052 }
934 1053 ui.label(""); // Empty cell
@@ -939,21 +1058,21 @@ impl eframe::App for Koco {
939 1058 egui::Button::new(egui::RichText::new(egui_material_icons::icons::ICON_ARROW_BACK).size(48.0))
940 1059 ).clicked() {
941 1060 if let Err(e) = instance.send_key("left") {
942 − eprintln!("Failed to send left command: {}", e);
1061 + info!("Failed to send left command: {}", e);
943 1062 }
944 1063 }
945 1064 if ui.add_sized([80.0, 80.0],
946 1065 egui::Button::new(egui::RichText::new(egui_material_icons::icons::ICON_RADIO_BUTTON_CHECKED).size(48.0))
947 1066 ).clicked() {
948 1067 if let Err(e) = instance.send_key("select") {
949 − eprintln!("Failed to send select command: {}", e);
1068 + info!("Failed to send select command: {}", e);
950 1069 }
951 1070 }
952 1071 if ui.add_sized([80.0, 80.0],
953 1072 egui::Button::new(egui::RichText::new(egui_material_icons::icons::ICON_ARROW_FORWARD).size(48.0))
954 1073 ).clicked() {
955 1074 if let Err(e) = instance.send_key("right") {
956 − eprintln!("Failed to send right command: {}", e);
1075 + info!("Failed to send right command: {}", e);
957 1076 }
958 1077 }
959 1078 ui.end_row();
@@ -963,14 +1082,14 @@ impl eframe::App for Koco {
963 1082 egui::Button::new(egui::RichText::new(egui_material_icons::icons::ICON_REPLAY).size(48.0))
964 1083 ).clicked() {
965 1084 if let Err(e) = instance.send_key("back") {
966 − eprintln!("Failed to send back command: {}", e);
1085 + info!("Failed to send back command: {}", e);
967 1086 }
968 1087 }
969 1088 if ui.add_sized([80.0, 80.0],
970 1089 egui::Button::new(egui::RichText::new(egui_material_icons::icons::ICON_ARROW_DOWNWARD).size(48.0))
971 1090 ).clicked() {
972 1091 if let Err(e) = instance.send_key("down") {
973 − eprintln!("Failed to send down command: {}", e);
1092 + info!("Failed to send down command: {}", e);
974 1093 }
975 1094 }
976 1095 ui.label(""); // Empty cell
@@ -989,21 +1108,43 @@ impl eframe::App for Koco {
989 1108
990 1109 // Bottom panel for now playing
991 1110 egui::TopBottomPanel::bottom("now_playing_panel").show(ctx, |ui| {
992 − ui.add_space(5.0);
993 1111 if let Ok(np) = self.now_playing.lock() {
994 1112 if !np.title.is_empty() {
995 − ui.vertical(|ui| {
1113 + // Calculate progress percentage
1114 + let percentage = if np.duration > 0.0 {
1115 + (np.position / np.duration).min(1.0).max(0.0)
1116 + } else {
1117 + 0.0
1118 + };
1119 +
1120 + // Draw custom background with progress
1121 + let available_rect = ui.available_rect_before_wrap();
1122 + let progress_width = available_rect.width() * percentage as f32;
1123 +
1124 + // Draw progress background
1125 + let progress_rect = egui::Rect::from_min_size(
1126 + available_rect.min,
1127 + egui::vec2(progress_width, available_rect.height())
1128 + );
1129 + ui.painter().rect_filled(
1130 + progress_rect,
1131 + 0.0,
1132 + egui::Color32::from_rgba_unmultiplied(100, 100, 255, 50)
1133 + );
1134 +
1135 + ui.add_space(5.0);
1136 + ui.horizontal(|ui| {
1137 + let status_icon = if np.playing { "▶" } else { "⏸" };
1138 + ui.label(egui::RichText::new(status_icon).size(16.0));
996 1139 ui.horizontal(|ui| {
997 − let status_icon = if np.playing { "▶" } else { "⏸" };
998 − ui.label(egui::RichText::new(status_icon).size(16.0));
999 − ui.label(egui::RichText::new(&np.title).strong());
1140 + ui.label(egui::RichText::new(&np.title).size(16.0).strong());
1141 + if !np.artist.is_empty() {
1142 + ui.label(egui::RichText::new(&np.artist).size(12.0).weak());
1143 + }
1144 + if !np.album.is_empty() {
1145 + ui.label(egui::RichText::new(&np.album).size(11.0).weak());
1146 + }
1000 1147 });
1001 − if !np.artist.is_empty() {
1002 − ui.label(egui::RichText::new(&np.artist).size(12.0).weak());
1003 − }
1004 − if !np.album.is_empty() {
1005 − ui.label(egui::RichText::new(&np.album).size(11.0).weak());
1006 − }
1007 1148 });
1008 1149 } else {
1009 1150 ui.label("No media playing");

Parents: 31a9839