tauri-app: display game history with stats

This commit is contained in:
2026-03-25 10:53:21 +01:00
parent f90e549b1e
commit 079d72649f
5 changed files with 1243 additions and 293 deletions

View File

@@ -715,10 +715,10 @@ dependencies = [
]
[[package]]
name = "dirs"
name = "directories"
version = "5.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225"
checksum = "9a49173b84e034382284f27f1af4dcbbd231ffa358c0fe316541a7337f376a35"
dependencies = [
"dirs-sys 0.4.1",
]
@@ -3631,7 +3631,7 @@ dependencies = [
"anyhow",
"bytes",
"cookie",
"dirs 6.0.0",
"dirs",
"dunce",
"embed_plist",
"getrandom 0.3.4",
@@ -3678,7 +3678,7 @@ name = "tauri-app"
version = "0.1.0"
dependencies = [
"chrono",
"dirs 5.0.1",
"directories",
"serde",
"serde_json",
"tauri",
@@ -3695,7 +3695,7 @@ checksum = "4bbc990d1dbf57a8e1c7fa2327f2a614d8b757805603c1b9ba5c81bade09fd4d"
dependencies = [
"anyhow",
"cargo_toml",
"dirs 6.0.0",
"dirs",
"glob",
"heck 0.5.0",
"json-patch",
@@ -4219,7 +4219,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a5e85aa143ceb072062fc4d6356c1b520a51d636e7bc8e77ec94be3608e5e80c"
dependencies = [
"crossbeam-channel",
"dirs 6.0.0",
"dirs",
"libappindicator",
"muda",
"objc2",
@@ -5292,7 +5292,7 @@ dependencies = [
"block2",
"cookie",
"crossbeam-channel",
"dirs 6.0.0",
"dirs",
"dom_query",
"dpi",
"dunce",

View File

@@ -24,5 +24,5 @@ serde = { version = "1", features = ["derive"] }
serde_json = "1"
chrono = { version = "0.4", features = ["serde"] }
uuid = { version = "1", features = ["v4", "serde"] }
dirs = "5"
directories = "5"

View File

@@ -7,10 +7,10 @@ use uuid::Uuid;
/// A timestamped event in the timeline.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TimestampedEvent {
/// Video timestamp (offset from recording start) in seconds.
pub video_timestamp: i64,
/// Game timestamp (in-game time) in seconds.
pub game_timestamp: Option<i64>,
/// Video timestamp (offset from recording start) as [seconds, nanos].
pub video_timestamp: (i64, i32),
/// Game timestamp (in-game time) as [seconds, nanos].
pub game_timestamp: Option<(i64, i32)>,
/// Real-world timestamp.
pub timestamp: DateTime<Utc>,
/// Event type name.
@@ -19,7 +19,81 @@ pub struct TimestampedEvent {
pub description: String,
}
impl TimestampedEvent {
/// Get video timestamp in seconds.
pub fn video_timestamp_secs(&self) -> i64 {
self.video_timestamp.0
}
}
/// Final game statistics for the player.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GameFinalStats {
pub kills: u32,
pub deaths: u32,
pub assists: u32,
pub creep_score: u32,
pub gold_earned: u32,
pub damage_dealt: u64,
pub damage_taken: u64,
pub vision_score: f64,
pub game_duration: f64,
}
/// Rune page configuration.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RunePage {
pub primary_style_id: u32,
pub secondary_style_id: u32,
pub selected_perks: Vec<u32>,
#[serde(default)]
pub stat_modifiers: Vec<u32>,
pub name: Option<String>,
#[serde(default)]
pub current: bool,
}
/// Summoner spell information.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SummonerSpells {
pub spell1_id: u32,
pub spell2_id: u32,
pub spell1_name: Option<String>,
pub spell2_name: Option<String>,
}
/// Individual item information.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ItemInfo {
pub item_id: u32,
pub name: Option<String>,
pub slot: u32,
}
/// Item build at game end.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ItemBuild {
pub items: Vec<ItemInfo>,
pub trinket: Option<ItemInfo>,
}
/// Player identity information.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PlayerIdentityInfo {
pub puuid: String,
#[serde(default)]
pub summoner_name: String,
pub champion_name: Option<String>,
pub team: Option<u32>,
}
/// A timeline of events for a recording.
/// This matches the full JSON structure from record-daemon.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Timeline {
/// Recording ID.
@@ -32,39 +106,68 @@ pub struct Timeline {
pub duration_secs: i64,
/// Events in the timeline.
pub events: Vec<TimestampedEvent>,
}
/// Game history item for display in the UI.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GameHistoryItem {
/// Recording ID.
pub id: String,
/// Game start time.
pub start_time: DateTime<Utc>,
/// Game end time.
pub end_time: Option<DateTime<Utc>>,
/// Duration in seconds.
pub duration_secs: i64,
/// Formatted duration string (e.g., "32:15").
pub duration_formatted: String,
/// Number of events.
pub event_count: usize,
/// Champion played (if available).
/// Champion played.
#[serde(default)]
pub champion: Option<String>,
/// Game result (if available).
pub result: Option<String>,
/// Video file path.
pub video_path: Option<String>,
/// Skin name.
#[serde(default)]
pub skin_name: Option<String>,
/// Queue type.
#[serde(default)]
pub queue_type: Option<String>,
/// Queue ID.
#[serde(default)]
pub queue_id: Option<u32>,
/// Game mode.
#[serde(default)]
pub game_mode: Option<String>,
/// Map name.
#[serde(default)]
pub map_name: Option<String>,
/// Summoner name.
#[serde(default)]
pub summoner_name: Option<String>,
/// Player's PUUID.
#[serde(default)]
pub puuid: Option<String>,
/// Team (100 = blue, 200 = red).
#[serde(default)]
pub team: Option<u32>,
/// Whether the game was won.
#[serde(default)]
pub victory: Option<bool>,
/// Final player stats.
#[serde(default)]
pub final_stats: Option<GameFinalStats>,
/// Game ID.
#[serde(default)]
pub game_id: Option<u64>,
/// Match ID.
#[serde(default)]
pub match_id: Option<String>,
/// Rune page at game start.
#[serde(default)]
pub runes: Option<RunePage>,
/// Summoner spells.
#[serde(default)]
pub summoner_spells: Option<SummonerSpells>,
/// Final item build at game end.
#[serde(default)]
pub final_items: Option<ItemBuild>,
/// All players in the game.
#[serde(default)]
pub all_players: Vec<PlayerIdentityInfo>,
}
/// Get the default output directory for recordings.
/// Uses the same directory structure as the record-daemon.
fn get_default_output_dir() -> Option<PathBuf> {
dirs::video_dir()
.map(|p| p.join("LeagueRecorder"))
.or_else(|| dirs::home_dir().map(|p| p.join("Videos").join("LeagueRecorder")))
directories::ProjectDirs::from("com", "leaguerecorder", "record-daemon")
.map(|dirs| dirs.data_dir().join("recordings"))
}
/// Get the timeline storage directory.
/// Uses the same directory structure as the record-daemon.
fn get_timeline_dir() -> PathBuf {
get_default_output_dir()
.map(|p| p.join("timelines"))
@@ -98,105 +201,13 @@ fn load_timelines() -> Vec<Timeline> {
timelines
}
/// Format duration as MM:SS or HH:MM:SS.
fn format_duration(secs: i64) -> String {
let hours = secs / 3600;
let minutes = (secs % 3600) / 60;
let seconds = secs % 60;
if hours > 0 {
format!("{:02}:{:02}:{:02}", hours, minutes, seconds)
} else {
format!("{:02}:{:02}", minutes, seconds)
}
}
/// Extract game result from events.
fn extract_game_result(timeline: &Timeline) -> Option<String> {
for event in &timeline.events {
if event.event_type == "GameEnd" {
// Check description for win/loss
if event.description.to_lowercase().contains("win") {
return Some("Victory".to_string());
} else if event.description.to_lowercase().contains("loss")
|| event.description.to_lowercase().contains("defeat")
{
return Some("Defeat".to_string());
}
}
}
None
}
/// Extract champion from events.
fn extract_champion(timeline: &Timeline) -> Option<String> {
for event in &timeline.events {
if event.event_type == "GameStart" {
// Try to extract champion from description
let desc = &event.description;
if desc.contains("Playing as ") {
return Some(desc.replace("Playing as ", "").trim().to_string());
}
}
}
None
}
/// Extract video file path from timeline.
fn extract_video_path(timeline: &Timeline) -> Option<String> {
// Look for video file in the recordings directory
let output_dir = get_default_output_dir()?;
let video_dir = output_dir.join("videos");
if video_dir.exists() {
if let Ok(entries) = fs::read_dir(&video_dir) {
for entry in entries.flatten() {
let path = entry.path();
let file_stem = path.file_stem()?.to_string_lossy().to_string();
if file_stem.contains(&timeline.recording_id.to_string()) {
return Some(path.to_string_lossy().to_string());
}
}
}
}
// Also check for files named with the recording ID
let recording_id = timeline.recording_id.to_string();
if let Ok(entries) = fs::read_dir(&output_dir) {
for entry in entries.flatten() {
let path = entry.path();
if path.is_file() {
let file_stem = path.file_stem()?.to_string_lossy().to_string();
if file_stem.contains(&recording_id) {
return Some(path.to_string_lossy().to_string());
}
}
}
}
None
}
/// Get game history - returns full timeline data for each game.
#[tauri::command]
fn get_game_history() -> Vec<GameHistoryItem> {
let timelines = load_timelines();
timelines
.into_iter()
.map(|timeline| GameHistoryItem {
id: timeline.recording_id.to_string(),
start_time: timeline.start_time,
end_time: timeline.end_time,
duration_secs: timeline.duration_secs,
duration_formatted: format_duration(timeline.duration_secs),
event_count: timeline.events.len(),
champion: extract_champion(&timeline),
result: extract_game_result(&timeline),
video_path: extract_video_path(&timeline),
})
.collect()
fn get_game_history() -> Vec<Timeline> {
load_timelines()
}
/// Get a specific timeline by recording ID.
#[tauri::command]
fn get_timeline(recording_id: String) -> Option<Timeline> {
let timeline_dir = get_timeline_dir();
@@ -211,6 +222,7 @@ fn get_timeline(recording_id: String) -> Option<Timeline> {
None
}
/// Get the recordings directory path.
#[tauri::command]
fn get_recordings_dir() -> String {
get_default_output_dir()