diff --git a/tauri-app/src-tauri/Cargo.lock b/tauri-app/src-tauri/Cargo.lock index 458093b..8739b70 100644 --- a/tauri-app/src-tauri/Cargo.lock +++ b/tauri-app/src-tauri/Cargo.lock @@ -451,8 +451,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" dependencies = [ "iana-time-zone", + "js-sys", "num-traits", "serde", + "wasm-bindgen", "windows-link 0.2.1", ] @@ -712,13 +714,34 @@ dependencies = [ "crypto-common", ] +[[package]] +name = "dirs" +version = "5.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225" +dependencies = [ + "dirs-sys 0.4.1", +] + [[package]] name = "dirs" version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" dependencies = [ - "dirs-sys", + "dirs-sys 0.5.0", +] + +[[package]] +name = "dirs-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" +dependencies = [ + "libc", + "option-ext", + "redox_users 0.4.6", + "windows-sys 0.48.0", ] [[package]] @@ -729,7 +752,7 @@ checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" dependencies = [ "libc", "option-ext", - "redox_users", + "redox_users 0.5.2", "windows-sys 0.61.2", ] @@ -2883,6 +2906,17 @@ dependencies = [ "bitflags 2.11.0", ] +[[package]] +name = "redox_users" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 1.0.69", +] + [[package]] name = "redox_users" version = "0.5.2" @@ -3597,7 +3631,7 @@ dependencies = [ "anyhow", "bytes", "cookie", - "dirs", + "dirs 6.0.0", "dunce", "embed_plist", "getrandom 0.3.4", @@ -3643,11 +3677,14 @@ dependencies = [ name = "tauri-app" version = "0.1.0" dependencies = [ + "chrono", + "dirs 5.0.1", "serde", "serde_json", "tauri", "tauri-build", "tauri-plugin-opener", + "uuid", ] [[package]] @@ -3658,7 +3695,7 @@ checksum = "4bbc990d1dbf57a8e1c7fa2327f2a614d8b757805603c1b9ba5c81bade09fd4d" dependencies = [ "anyhow", "cargo_toml", - "dirs", + "dirs 6.0.0", "glob", "heck 0.5.0", "json-patch", @@ -4182,7 +4219,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a5e85aa143ceb072062fc4d6356c1b520a51d636e7bc8e77ec94be3608e5e80c" dependencies = [ "crossbeam-channel", - "dirs", + "dirs 6.0.0", "libappindicator", "muda", "objc2", @@ -4817,6 +4854,15 @@ dependencies = [ "windows-targets 0.42.2", ] +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + [[package]] name = "windows-sys" version = "0.59.0" @@ -4859,6 +4905,21 @@ dependencies = [ "windows_x86_64_msvc 0.42.2", ] +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + [[package]] name = "windows-targets" version = "0.52.6" @@ -4916,6 +4977,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" @@ -4934,6 +5001,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + [[package]] name = "windows_aarch64_msvc" version = "0.52.6" @@ -4952,6 +5025,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + [[package]] name = "windows_i686_gnu" version = "0.52.6" @@ -4982,6 +5061,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + [[package]] name = "windows_i686_msvc" version = "0.52.6" @@ -5000,6 +5085,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + [[package]] name = "windows_x86_64_gnu" version = "0.52.6" @@ -5018,6 +5109,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" @@ -5036,6 +5133,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + [[package]] name = "windows_x86_64_msvc" version = "0.52.6" @@ -5189,7 +5292,7 @@ dependencies = [ "block2", "cookie", "crossbeam-channel", - "dirs", + "dirs 6.0.0", "dom_query", "dpi", "dunce", diff --git a/tauri-app/src-tauri/Cargo.toml b/tauri-app/src-tauri/Cargo.toml index 6875383..0f88542 100644 --- a/tauri-app/src-tauri/Cargo.toml +++ b/tauri-app/src-tauri/Cargo.toml @@ -22,4 +22,7 @@ tauri = { version = "2", features = [] } tauri-plugin-opener = "2" serde = { version = "1", features = ["derive"] } serde_json = "1" +chrono = { version = "0.4", features = ["serde"] } +uuid = { version = "1", features = ["v4", "serde"] } +dirs = "5" diff --git a/tauri-app/src-tauri/src/lib.rs b/tauri-app/src-tauri/src/lib.rs index 4a277ef..3e341da 100644 --- a/tauri-app/src-tauri/src/lib.rs +++ b/tauri-app/src-tauri/src/lib.rs @@ -1,14 +1,232 @@ -// Learn more about Tauri commands at https://tauri.app/develop/calling-rust/ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use std::fs; +use std::path::PathBuf; +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, + /// Real-world timestamp. + pub timestamp: DateTime, + /// Event type name. + pub event_type: String, + /// Human-readable description. + pub description: String, +} + +/// A timeline of events for a recording. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Timeline { + /// Recording ID. + pub recording_id: Uuid, + /// Recording start time. + pub start_time: DateTime, + /// Recording end time. + pub end_time: Option>, + /// Total duration in seconds. + pub duration_secs: i64, + /// Events in the timeline. + pub events: Vec, +} + +/// 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, + /// Game end time. + pub end_time: Option>, + /// 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). + pub champion: Option, + /// Game result (if available). + pub result: Option, + /// Video file path. + pub video_path: Option, +} + +/// Get the default output directory for recordings. +fn get_default_output_dir() -> Option { + dirs::video_dir() + .map(|p| p.join("LeagueRecorder")) + .or_else(|| dirs::home_dir().map(|p| p.join("Videos").join("LeagueRecorder"))) +} + +/// Get the timeline storage directory. +fn get_timeline_dir() -> PathBuf { + get_default_output_dir() + .map(|p| p.join("timelines")) + .unwrap_or_else(|| PathBuf::from("./recordings/timelines")) +} + +/// Load all timelines from disk. +fn load_timelines() -> Vec { + let timeline_dir = get_timeline_dir(); + let mut timelines = Vec::new(); + + if !timeline_dir.exists() { + return timelines; + } + + if let Ok(entries) = fs::read_dir(&timeline_dir) { + for entry in entries.flatten() { + let path = entry.path(); + if path.extension().map(|e| e == "json").unwrap_or(false) { + if let Ok(contents) = fs::read_to_string(&path) { + if let Ok(timeline) = serde_json::from_str::(&contents) { + timelines.push(timeline); + } + } + } + } + } + + // Sort by start time, newest first + timelines.sort_by(|a, b| b.start_time.cmp(&a.start_time)); + 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 { + 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 { + 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 { + // 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 +} + #[tauri::command] -fn greet(name: &str) -> String { - format!("Hello, {}! You've been greeted from Rust!", name) +fn get_game_history() -> Vec { + 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() +} + +#[tauri::command] +fn get_timeline(recording_id: String) -> Option { + let timeline_dir = get_timeline_dir(); + let path = timeline_dir.join(format!("{}.json", recording_id)); + + if path.exists() { + if let Ok(contents) = fs::read_to_string(&path) { + return serde_json::from_str::(&contents).ok(); + } + } + + None +} + +#[tauri::command] +fn get_recordings_dir() -> String { + get_default_output_dir() + .map(|p| p.to_string_lossy().to_string()) + .unwrap_or_else(|| "./recordings".to_string()) } #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { tauri::Builder::default() .plugin(tauri_plugin_opener::init()) - .invoke_handler(tauri::generate_handler![greet]) + .invoke_handler(tauri::generate_handler![ + get_game_history, + get_timeline, + get_recordings_dir + ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); } diff --git a/tauri-app/src/App.vue b/tauri-app/src/App.vue index 5a26f11..b7f8bce 100644 --- a/tauri-app/src/App.vue +++ b/tauri-app/src/App.vue @@ -1,160 +1,41 @@ - \ No newline at end of file + diff --git a/tauri-app/src/components/GameHistory.vue b/tauri-app/src/components/GameHistory.vue new file mode 100644 index 0000000..7fac7d1 --- /dev/null +++ b/tauri-app/src/components/GameHistory.vue @@ -0,0 +1,456 @@ + + + + + diff --git a/tauri-app/src/types/timeline.ts b/tauri-app/src/types/timeline.ts new file mode 100644 index 0000000..833954d --- /dev/null +++ b/tauri-app/src/types/timeline.ts @@ -0,0 +1,154 @@ +/** + * TypeScript types for timeline data from the record-daemon. + */ + +/** + * A timestamped event in the timeline. + */ +export interface TimestampedEvent { + /** Video timestamp (offset from recording start) in seconds. */ + video_timestamp: number; + /** Game timestamp (in-game time) in seconds. */ + game_timestamp: number | null; + /** Real-world timestamp (ISO 8601). */ + timestamp: string; + /** Event type name. */ + event_type: string; + /** Human-readable description. */ + description: string; +} + +/** + * A timeline of events for a recording. + */ +export interface Timeline { + /** Recording ID (UUID). */ + recording_id: string; + /** Recording start time (ISO 8601). */ + start_time: string; + /** Recording end time (ISO 8601). */ + end_time: string | null; + /** Total duration in seconds. */ + duration_secs: number; + /** Events in the timeline. */ + events: TimestampedEvent[]; +} + +/** + * Game history item for display in the UI. + */ +export interface GameHistoryItem { + /** Recording ID (UUID). */ + id: string; + /** Game start time (ISO 8601). */ + start_time: string; + /** Game end time (ISO 8601). */ + end_time: string | null; + /** Duration in seconds. */ + duration_secs: number; + /** Formatted duration string (e.g., "32:15"). */ + duration_formatted: string; + /** Number of events. */ + event_count: number; + /** Champion played (if available). */ + champion: string | null; + /** Game result (Victory/Defeat, if available). */ + result: string | null; + /** Video file path. */ + video_path: string | null; +} + +/** + * Event type definitions for styling. + */ +export type EventCategory = + | "kill" + | "death" + | "objective" + | "game" + | "phase" + | "unknown"; + +/** + * Get the category of an event for styling purposes. + */ +export function getEventCategory(eventType: string): EventCategory { + switch (eventType.toLowerCase()) { + case "kill": + return "kill"; + case "death": + return "death"; + case "objective": + return "objective"; + case "gameend": + case "gamestart": + case "matchfound": + return "game"; + case "phasechange": + return "phase"; + default: + return "unknown"; + } +} + +/** + * Get a human-readable label for an event type. + */ +export function getEventLabel(eventType: string): string { + switch (eventType.toLowerCase()) { + case "kill": + return "Kill"; + case "death": + return "Death"; + case "objective": + return "Objective"; + case "gameend": + return "Game End"; + case "gamestart": + return "Game Start"; + case "matchfound": + return "Match Found"; + case "phasechange": + return "Phase Change"; + default: + return eventType; + } +} + +/** + * Format a date string for display. + */ +export function formatDateTime(isoString: string): string { + const date = new Date(isoString); + return date.toLocaleString(undefined, { + year: "numeric", + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + }); +} + +/** + * Format a relative time (e.g., "2 hours ago"). + */ +export function formatRelativeTime(isoString: string): string { + const date = new Date(isoString); + const now = new Date(); + const diffMs = now.getTime() - date.getTime(); + const diffMins = Math.floor(diffMs / 60000); + const diffHours = Math.floor(diffMins / 60); + const diffDays = Math.floor(diffHours / 24); + + if (diffDays > 7) { + return formatDateTime(isoString); + } else if (diffDays > 0) { + return `${diffDays} day${diffDays > 1 ? "s" : ""} ago`; + } else if (diffHours > 0) { + return `${diffHours} hour${diffHours > 1 ? "s" : ""} ago`; + } else if (diffMins > 0) { + return `${diffMins} minute${diffMins > 1 ? "s" : ""} ago`; + } else { + return "Just now"; + } +}