tauri-app: display game history with stats
This commit is contained in:
14
tauri-app/src-tauri/Cargo.lock
generated
14
tauri-app/src-tauri/Cargo.lock
generated
@@ -715,10 +715,10 @@ dependencies = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dirs"
|
name = "directories"
|
||||||
version = "5.0.1"
|
version = "5.0.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225"
|
checksum = "9a49173b84e034382284f27f1af4dcbbd231ffa358c0fe316541a7337f376a35"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"dirs-sys 0.4.1",
|
"dirs-sys 0.4.1",
|
||||||
]
|
]
|
||||||
@@ -3631,7 +3631,7 @@ dependencies = [
|
|||||||
"anyhow",
|
"anyhow",
|
||||||
"bytes",
|
"bytes",
|
||||||
"cookie",
|
"cookie",
|
||||||
"dirs 6.0.0",
|
"dirs",
|
||||||
"dunce",
|
"dunce",
|
||||||
"embed_plist",
|
"embed_plist",
|
||||||
"getrandom 0.3.4",
|
"getrandom 0.3.4",
|
||||||
@@ -3678,7 +3678,7 @@ name = "tauri-app"
|
|||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"chrono",
|
"chrono",
|
||||||
"dirs 5.0.1",
|
"directories",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"tauri",
|
"tauri",
|
||||||
@@ -3695,7 +3695,7 @@ checksum = "4bbc990d1dbf57a8e1c7fa2327f2a614d8b757805603c1b9ba5c81bade09fd4d"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"cargo_toml",
|
"cargo_toml",
|
||||||
"dirs 6.0.0",
|
"dirs",
|
||||||
"glob",
|
"glob",
|
||||||
"heck 0.5.0",
|
"heck 0.5.0",
|
||||||
"json-patch",
|
"json-patch",
|
||||||
@@ -4219,7 +4219,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "a5e85aa143ceb072062fc4d6356c1b520a51d636e7bc8e77ec94be3608e5e80c"
|
checksum = "a5e85aa143ceb072062fc4d6356c1b520a51d636e7bc8e77ec94be3608e5e80c"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"crossbeam-channel",
|
"crossbeam-channel",
|
||||||
"dirs 6.0.0",
|
"dirs",
|
||||||
"libappindicator",
|
"libappindicator",
|
||||||
"muda",
|
"muda",
|
||||||
"objc2",
|
"objc2",
|
||||||
@@ -5292,7 +5292,7 @@ dependencies = [
|
|||||||
"block2",
|
"block2",
|
||||||
"cookie",
|
"cookie",
|
||||||
"crossbeam-channel",
|
"crossbeam-channel",
|
||||||
"dirs 6.0.0",
|
"dirs",
|
||||||
"dom_query",
|
"dom_query",
|
||||||
"dpi",
|
"dpi",
|
||||||
"dunce",
|
"dunce",
|
||||||
|
|||||||
@@ -24,5 +24,5 @@ serde = { version = "1", features = ["derive"] }
|
|||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
chrono = { version = "0.4", features = ["serde"] }
|
chrono = { version = "0.4", features = ["serde"] }
|
||||||
uuid = { version = "1", features = ["v4", "serde"] }
|
uuid = { version = "1", features = ["v4", "serde"] }
|
||||||
dirs = "5"
|
directories = "5"
|
||||||
|
|
||||||
|
|||||||
@@ -7,10 +7,10 @@ use uuid::Uuid;
|
|||||||
/// A timestamped event in the timeline.
|
/// A timestamped event in the timeline.
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct TimestampedEvent {
|
pub struct TimestampedEvent {
|
||||||
/// Video timestamp (offset from recording start) in seconds.
|
/// Video timestamp (offset from recording start) as [seconds, nanos].
|
||||||
pub video_timestamp: i64,
|
pub video_timestamp: (i64, i32),
|
||||||
/// Game timestamp (in-game time) in seconds.
|
/// Game timestamp (in-game time) as [seconds, nanos].
|
||||||
pub game_timestamp: Option<i64>,
|
pub game_timestamp: Option<(i64, i32)>,
|
||||||
/// Real-world timestamp.
|
/// Real-world timestamp.
|
||||||
pub timestamp: DateTime<Utc>,
|
pub timestamp: DateTime<Utc>,
|
||||||
/// Event type name.
|
/// Event type name.
|
||||||
@@ -19,7 +19,81 @@ pub struct TimestampedEvent {
|
|||||||
pub description: String,
|
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.
|
/// A timeline of events for a recording.
|
||||||
|
/// This matches the full JSON structure from record-daemon.
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct Timeline {
|
pub struct Timeline {
|
||||||
/// Recording ID.
|
/// Recording ID.
|
||||||
@@ -32,39 +106,68 @@ pub struct Timeline {
|
|||||||
pub duration_secs: i64,
|
pub duration_secs: i64,
|
||||||
/// Events in the timeline.
|
/// Events in the timeline.
|
||||||
pub events: Vec<TimestampedEvent>,
|
pub events: Vec<TimestampedEvent>,
|
||||||
}
|
/// Champion played.
|
||||||
|
#[serde(default)]
|
||||||
/// 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).
|
|
||||||
pub champion: Option<String>,
|
pub champion: Option<String>,
|
||||||
/// Game result (if available).
|
/// Skin name.
|
||||||
pub result: Option<String>,
|
#[serde(default)]
|
||||||
/// Video file path.
|
pub skin_name: Option<String>,
|
||||||
pub video_path: 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.
|
/// Get the default output directory for recordings.
|
||||||
|
/// Uses the same directory structure as the record-daemon.
|
||||||
fn get_default_output_dir() -> Option<PathBuf> {
|
fn get_default_output_dir() -> Option<PathBuf> {
|
||||||
dirs::video_dir()
|
directories::ProjectDirs::from("com", "leaguerecorder", "record-daemon")
|
||||||
.map(|p| p.join("LeagueRecorder"))
|
.map(|dirs| dirs.data_dir().join("recordings"))
|
||||||
.or_else(|| dirs::home_dir().map(|p| p.join("Videos").join("LeagueRecorder")))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get the timeline storage directory.
|
/// Get the timeline storage directory.
|
||||||
|
/// Uses the same directory structure as the record-daemon.
|
||||||
fn get_timeline_dir() -> PathBuf {
|
fn get_timeline_dir() -> PathBuf {
|
||||||
get_default_output_dir()
|
get_default_output_dir()
|
||||||
.map(|p| p.join("timelines"))
|
.map(|p| p.join("timelines"))
|
||||||
@@ -98,105 +201,13 @@ fn load_timelines() -> Vec<Timeline> {
|
|||||||
timelines
|
timelines
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Format duration as MM:SS or HH:MM:SS.
|
/// Get game history - returns full timeline data for each game.
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
fn get_game_history() -> Vec<GameHistoryItem> {
|
fn get_game_history() -> Vec<Timeline> {
|
||||||
let timelines = load_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()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Get a specific timeline by recording ID.
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
fn get_timeline(recording_id: String) -> Option<Timeline> {
|
fn get_timeline(recording_id: String) -> Option<Timeline> {
|
||||||
let timeline_dir = get_timeline_dir();
|
let timeline_dir = get_timeline_dir();
|
||||||
@@ -211,6 +222,7 @@ fn get_timeline(recording_id: String) -> Option<Timeline> {
|
|||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Get the recordings directory path.
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
fn get_recordings_dir() -> String {
|
fn get_recordings_dir() -> String {
|
||||||
get_default_output_dir()
|
get_default_output_dir()
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -4,12 +4,13 @@
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* A timestamped event in the timeline.
|
* A timestamped event in the timeline.
|
||||||
|
* Video timestamp is serialized as [seconds, nanos] tuple from Rust.
|
||||||
*/
|
*/
|
||||||
export interface TimestampedEvent {
|
export interface TimestampedEvent {
|
||||||
/** Video timestamp (offset from recording start) in seconds. */
|
/** Video timestamp (offset from recording start) as [seconds, nanos]. */
|
||||||
video_timestamp: number;
|
video_timestamp: [number, number];
|
||||||
/** Game timestamp (in-game time) in seconds. */
|
/** Game timestamp (in-game time) as [seconds, nanos]. */
|
||||||
game_timestamp: number | null;
|
game_timestamp: [number, number] | null;
|
||||||
/** Real-world timestamp (ISO 8601). */
|
/** Real-world timestamp (ISO 8601). */
|
||||||
timestamp: string;
|
timestamp: string;
|
||||||
/** Event type name. */
|
/** Event type name. */
|
||||||
@@ -18,44 +19,383 @@ export interface TimestampedEvent {
|
|||||||
description: string;
|
description: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Final game statistics for the player.
|
||||||
|
*/
|
||||||
|
export interface GameFinalStats {
|
||||||
|
/** Kills. */
|
||||||
|
kills: number;
|
||||||
|
/** Deaths. */
|
||||||
|
deaths: number;
|
||||||
|
/** Assists. */
|
||||||
|
assists: number;
|
||||||
|
/** Creep score. */
|
||||||
|
creep_score: number;
|
||||||
|
/** Gold earned. */
|
||||||
|
gold_earned: number;
|
||||||
|
/** Damage dealt. */
|
||||||
|
damage_dealt: number;
|
||||||
|
/** Damage taken. */
|
||||||
|
damage_taken: number;
|
||||||
|
/** Vision score. */
|
||||||
|
vision_score: number;
|
||||||
|
/** Game duration in seconds. */
|
||||||
|
game_duration: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rune page configuration.
|
||||||
|
*/
|
||||||
|
export interface RunePage {
|
||||||
|
/** Primary rune style (keystone tree). */
|
||||||
|
primary_style_id: number;
|
||||||
|
/** Secondary rune style. */
|
||||||
|
secondary_style_id: number;
|
||||||
|
/** Selected rune perk IDs. */
|
||||||
|
selected_perks: number[];
|
||||||
|
/** Stat modifier IDs. */
|
||||||
|
stat_modifiers: number[];
|
||||||
|
/** Rune page name. */
|
||||||
|
name: string | null;
|
||||||
|
/** Whether this is the current page. */
|
||||||
|
current: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Summoner spell information.
|
||||||
|
*/
|
||||||
|
export interface SummonerSpells {
|
||||||
|
/** First summoner spell ID. */
|
||||||
|
spell1Id: number;
|
||||||
|
/** Second summoner spell ID. */
|
||||||
|
spell2Id: number;
|
||||||
|
/** First summoner spell name. */
|
||||||
|
spell1Name: string | null;
|
||||||
|
/** Second summoner spell name. */
|
||||||
|
spell2Name: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Individual item information.
|
||||||
|
*/
|
||||||
|
export interface ItemInfo {
|
||||||
|
/** Item ID. */
|
||||||
|
itemId: number;
|
||||||
|
/** Item name. */
|
||||||
|
name: string | null;
|
||||||
|
/** Slot index (0-5 for main items, 6 for trinket). */
|
||||||
|
slot: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Item build at game end.
|
||||||
|
*/
|
||||||
|
export interface ItemBuild {
|
||||||
|
/** Final items (up to 6 item slots). */
|
||||||
|
items: ItemInfo[];
|
||||||
|
/** Trinket item. */
|
||||||
|
trinket: ItemInfo | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Player identity information for puuid to summoner name mapping.
|
||||||
|
*/
|
||||||
|
export interface PlayerIdentityInfo {
|
||||||
|
/** Player's PUUID. */
|
||||||
|
puuid: string;
|
||||||
|
/** Player's summoner name. */
|
||||||
|
summoner_name: string;
|
||||||
|
/** Player's champion name. */
|
||||||
|
champion_name: string | null;
|
||||||
|
/** Player's team (100 or 200). */
|
||||||
|
team: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Game result type.
|
||||||
|
*/
|
||||||
|
export type GameResult = 'Victory' | 'Defeat' | 'Terminated';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A timeline of events for a recording.
|
* A timeline of events for a recording.
|
||||||
|
* This is the main data structure returned by the backend.
|
||||||
*/
|
*/
|
||||||
export interface Timeline {
|
export interface Timeline {
|
||||||
/** Recording ID (UUID). */
|
/** Recording ID (UUID). */
|
||||||
recording_id: string;
|
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). */
|
/** Game start time (ISO 8601). */
|
||||||
start_time: string;
|
start_time: string;
|
||||||
/** Game end time (ISO 8601). */
|
/** Game end time (ISO 8601). */
|
||||||
end_time: string | null;
|
end_time: string | null;
|
||||||
/** Duration in seconds. */
|
/** Duration in seconds. */
|
||||||
duration_secs: number;
|
duration_secs: number;
|
||||||
/** Formatted duration string (e.g., "32:15"). */
|
/** Events in the timeline. */
|
||||||
duration_formatted: string;
|
events: TimestampedEvent[];
|
||||||
/** Number of events. */
|
|
||||||
event_count: number;
|
// Player information
|
||||||
/** Champion played (if available). */
|
/** Champion played. */
|
||||||
champion: string | null;
|
champion: string | null;
|
||||||
/** Game result (Victory/Defeat, if available). */
|
/** Skin name. */
|
||||||
result: string | null;
|
skin_name: string | null;
|
||||||
/** Video file path. */
|
/** Player's summoner name. */
|
||||||
video_path: string | null;
|
summoner_name: string | null;
|
||||||
|
/** Player's PUUID. */
|
||||||
|
puuid: string | null;
|
||||||
|
/** Team (100 = blue, 200 = red). */
|
||||||
|
team: number | null;
|
||||||
|
|
||||||
|
// Game information
|
||||||
|
/** Queue type (ranked, normal, aram, etc.). */
|
||||||
|
queue_type: string | null;
|
||||||
|
/** Queue ID. */
|
||||||
|
queue_id: number | null;
|
||||||
|
/** Game mode. */
|
||||||
|
game_mode: string | null;
|
||||||
|
/** Map name. */
|
||||||
|
map_name: string | null;
|
||||||
|
/** Game ID. */
|
||||||
|
game_id: number | null;
|
||||||
|
/** Match ID. */
|
||||||
|
match_id: string | null;
|
||||||
|
|
||||||
|
// Result
|
||||||
|
/** Whether the game was won. */
|
||||||
|
victory: boolean | null;
|
||||||
|
/** Final player stats. */
|
||||||
|
final_stats: GameFinalStats | null;
|
||||||
|
|
||||||
|
// Player metadata (runes, summoner spells, items)
|
||||||
|
/** Rune page at game start. */
|
||||||
|
runes: RunePage | null;
|
||||||
|
/** Summoner spells. */
|
||||||
|
summoner_spells: SummonerSpells | null;
|
||||||
|
/** Final item build at game end. */
|
||||||
|
final_items: ItemBuild | null;
|
||||||
|
|
||||||
|
// All players in the game (puuid to summoner name mapping)
|
||||||
|
/** All players in the game. */
|
||||||
|
all_players: PlayerIdentityInfo[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Game history item for display in the UI.
|
||||||
|
* Alias for Timeline since the backend returns full timeline data.
|
||||||
|
*/
|
||||||
|
export type GameHistoryItem = Timeline;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the game result as a display string.
|
||||||
|
*/
|
||||||
|
export function getGameResult(game: GameHistoryItem): GameResult {
|
||||||
|
if (game.victory === true) return 'Victory';
|
||||||
|
if (game.victory === false) return 'Defeat';
|
||||||
|
return 'Terminated';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the result color class.
|
||||||
|
*/
|
||||||
|
export function getResultColor(result: GameResult): string {
|
||||||
|
switch (result) {
|
||||||
|
case 'Victory': return '#4ade80'; // green
|
||||||
|
case 'Defeat': return '#f87171'; // red
|
||||||
|
case 'Terminated': return '#9ca3af'; // gray
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the result background color (with alpha).
|
||||||
|
*/
|
||||||
|
export function getResultBgColor(result: GameResult): string {
|
||||||
|
switch (result) {
|
||||||
|
case 'Victory': return 'rgba(74, 222, 128, 0.15)';
|
||||||
|
case 'Defeat': return 'rgba(248, 113, 113, 0.15)';
|
||||||
|
case 'Terminated': return 'rgba(156, 163, 175, 0.15)';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format duration in MM:SS format.
|
||||||
|
*/
|
||||||
|
export function formatDuration(seconds: number): string {
|
||||||
|
const mins = Math.floor(seconds / 60);
|
||||||
|
const secs = Math.floor(seconds % 60);
|
||||||
|
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format KDA as "K/D/A" string.
|
||||||
|
*/
|
||||||
|
export function formatKDA(stats: GameFinalStats | null): string {
|
||||||
|
if (!stats) return '0/0/0';
|
||||||
|
return `${stats.kills}/${stats.deaths}/${stats.assists}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate KDA ratio.
|
||||||
|
*/
|
||||||
|
export function calculateKDA(stats: GameFinalStats | null): string {
|
||||||
|
if (!stats) return '0.0';
|
||||||
|
const kda = stats.deaths === 0
|
||||||
|
? stats.kills + stats.assists
|
||||||
|
: (stats.kills + stats.assists) / stats.deaths;
|
||||||
|
return kda.toFixed(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format CS per minute.
|
||||||
|
*/
|
||||||
|
export function formatCSPerMin(stats: GameFinalStats | null): string {
|
||||||
|
if (!stats || stats.game_duration === 0) return '0.0';
|
||||||
|
const csPerMin = stats.creep_score / (stats.game_duration / 60);
|
||||||
|
return csPerMin.toFixed(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format gold per minute.
|
||||||
|
*/
|
||||||
|
export function formatGoldPerMin(stats: GameFinalStats | null): string {
|
||||||
|
if (!stats || stats.game_duration === 0) return '0';
|
||||||
|
const goldPerMin = stats.gold_earned / (stats.game_duration / 60);
|
||||||
|
return Math.round(goldPerMin).toLocaleString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format large numbers (e.g., damage, gold).
|
||||||
|
*/
|
||||||
|
export function formatNumber(num: number): string {
|
||||||
|
if (num >= 1000000) {
|
||||||
|
return (num / 1000000).toFixed(1) + 'M';
|
||||||
|
} else if (num >= 1000) {
|
||||||
|
return (num / 1000).toFixed(1) + 'K';
|
||||||
|
}
|
||||||
|
return num.toLocaleString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get champion image URL from Data Dragon.
|
||||||
|
*/
|
||||||
|
export function getChampionImageUrl(championName: string | null, size: 'square' | 'loading' = 'square'): string {
|
||||||
|
if (!championName) {
|
||||||
|
// Return placeholder for unknown champion
|
||||||
|
return 'https://ddragon.leagueoflegends.com/cdn/16.6.1/img/champion/Aatrox.png';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Data Dragon uses specific champion name formatting
|
||||||
|
const formattedName = formatChampionName(championName);
|
||||||
|
|
||||||
|
if (size === 'loading') {
|
||||||
|
return `https://ddragon.leagueoflegends.com/cdn/16.6.1/img/champion/${formattedName}_0.jpg`;
|
||||||
|
}
|
||||||
|
return `https://ddragon.leagueoflegends.com/cdn/16.6.1/img/champion/${formattedName}.png`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format champion name for Data Dragon URL.
|
||||||
|
*/
|
||||||
|
function formatChampionName(name: string): string {
|
||||||
|
// Handle special cases
|
||||||
|
const specialCases: Record<string, string> = {
|
||||||
|
'Wukong': 'MonkeyKing',
|
||||||
|
'Nunu & Willump': 'Nunu',
|
||||||
|
'Renata Glasc': 'Renata',
|
||||||
|
'K\'Tanthi Aatrox': 'Aatrox',
|
||||||
|
'K\'Tanthi Varus': 'Varus',
|
||||||
|
};
|
||||||
|
|
||||||
|
if (specialCases[name]) {
|
||||||
|
return specialCases[name];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove spaces and special characters, capitalize first letter of each word
|
||||||
|
return name
|
||||||
|
.split(/[\s&]+/)
|
||||||
|
.map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
|
||||||
|
.join('')
|
||||||
|
.replace(/[^a-zA-Z]/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get summoner spell image URL from Data Dragon.
|
||||||
|
*/
|
||||||
|
export function getSummonerSpellUrl(spellId: number): string {
|
||||||
|
// Map common spell IDs to names
|
||||||
|
// Reference: https://github.com/RiotGames/developer-relations/issues/478
|
||||||
|
const spellNames: Record<number, string> = {
|
||||||
|
1: 'SummonerBoost', // Cleanse
|
||||||
|
3: 'SummonerExhaust', // Exhaust
|
||||||
|
4: 'SummonerFlash', // Flash
|
||||||
|
6: 'SummonerHaste', // Ghost (ID 6 is Ghost, not Heal!)
|
||||||
|
7: 'SummonerHeal', // Heal
|
||||||
|
11: 'SummonerSmite', // Smite
|
||||||
|
12: 'SummonerTeleport', // Teleport
|
||||||
|
13: 'SummonerClarity', // Clarity
|
||||||
|
14: 'SummonerIgnite', // Ignite
|
||||||
|
21: 'SummonerBarrier', // Barrier
|
||||||
|
32: 'SummonerMark', // Mark (ARAM snowball)
|
||||||
|
39: 'SummonerSnowURFSnowball_Mark', // Mark (URF)
|
||||||
|
54: 'SummonerPod', // Porcelain
|
||||||
|
55: 'SummonerPoroRecall', // Poro Recall
|
||||||
|
56: 'SummonerPoroThrow', // Poro Throw
|
||||||
|
};
|
||||||
|
|
||||||
|
const spellName = spellNames[spellId] || 'SummonerFlash';
|
||||||
|
return `https://ddragon.leagueoflegends.com/cdn/16.6.1/img/spell/${spellName}.png`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get item image URL from Data Dragon.
|
||||||
|
*/
|
||||||
|
export function getItemImageUrl(itemId: number): string {
|
||||||
|
if (itemId === 0 || itemId === null) {
|
||||||
|
return ''; // Empty item slot
|
||||||
|
}
|
||||||
|
return `https://ddragon.leagueoflegends.com/cdn/16.6.1/img/item/${itemId}.png`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get queue type display name.
|
||||||
|
*/
|
||||||
|
export function getQueueDisplayName(queueType: string | null, queueId: number | null): string {
|
||||||
|
if (queueType) {
|
||||||
|
// Clean up queue type names
|
||||||
|
if (queueType.includes('Practice Tool')) return 'Practice Tool';
|
||||||
|
if (queueType.includes('Ranked Solo')) return 'Ranked Solo';
|
||||||
|
if (queueType.includes('Ranked Flex')) return 'Ranked Flex';
|
||||||
|
if (queueType.includes('ARAM')) return 'ARAM';
|
||||||
|
if (queueType.includes('Normal')) return 'Normal';
|
||||||
|
if (queueType.includes('Co-op')) return 'Co-op vs AI';
|
||||||
|
return queueType;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to queue ID mapping
|
||||||
|
const queueNames: Record<number, string> = {
|
||||||
|
400: 'Normal Draft',
|
||||||
|
420: 'Ranked Solo',
|
||||||
|
430: 'Normal Blind',
|
||||||
|
440: 'Ranked Flex',
|
||||||
|
450: 'ARAM',
|
||||||
|
700: 'Clash',
|
||||||
|
800: 'Co-op vs AI',
|
||||||
|
830: 'Co-op vs AI',
|
||||||
|
840: 'Co-op vs AI',
|
||||||
|
850: 'Co-op vs AI',
|
||||||
|
900: 'URF',
|
||||||
|
1020: 'One for All',
|
||||||
|
1300: 'Nexus Blitz',
|
||||||
|
1400: 'Ultimate Spellbook',
|
||||||
|
1900: 'URF',
|
||||||
|
2000: 'Tutorial',
|
||||||
|
2010: 'Tutorial',
|
||||||
|
2020: 'Tutorial',
|
||||||
|
3140: 'Practice Tool',
|
||||||
|
};
|
||||||
|
|
||||||
|
if (queueId && queueNames[queueId]) {
|
||||||
|
return queueNames[queueId];
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'Custom Game';
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -152,3 +492,31 @@ export function formatRelativeTime(isoString: string): string {
|
|||||||
return "Just now";
|
return "Just now";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format game start time for display (e.g., "Today at 3:45 PM").
|
||||||
|
*/
|
||||||
|
export function formatGameStartTime(isoString: string): string {
|
||||||
|
const date = new Date(isoString);
|
||||||
|
const now = new Date();
|
||||||
|
const isToday = date.toDateString() === now.toDateString();
|
||||||
|
const yesterday = new Date(now);
|
||||||
|
yesterday.setDate(yesterday.getDate() - 1);
|
||||||
|
const isYesterday = date.toDateString() === yesterday.toDateString();
|
||||||
|
|
||||||
|
const timeStr = date.toLocaleTimeString(undefined, {
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isToday) {
|
||||||
|
return `Today at ${timeStr}`;
|
||||||
|
} else if (isYesterday) {
|
||||||
|
return `Yesterday at ${timeStr}`;
|
||||||
|
} else {
|
||||||
|
return date.toLocaleDateString(undefined, {
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
}) + ` at ${timeStr}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user