neiam
/koco
public · Issues · Pulls · Labels · Forks · Compare · Actions failure · Badges · Packages
⭐
Log in to mark this repository.
cleanup etc
564537a · gmorell · 2025-12-26 03:11
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 | + | |
| 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