From 2be1326dce04d2e6208f341c5e91e63b344ca12b Mon Sep 17 00:00:00 2001 From: tonhowtf Date: Thu, 30 Jul 2026 17:56:49 -0300 Subject: [PATCH] fix(league): parar de redesenhar os oito paineis a cada evento e remover o LiveTab duplicado MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit O travamento nao vinha de um evento so. A pagina monta os oito paineis ao mesmo tempo e os mantem montados de proposito, para preservar estado entre abas — mas isso faz cada mudanca de estado reavaliar e redesenhar os oito. Como a webview desenha tambem a navegacao do app, saturar essa thread congela a janela inteira. Agora o painel inativo mantem instancia, estado e efeitos, mas nao renderiza: o auto-runas continua funcionando de qualquer aba e o custo de desenho cai para um painel. O LiveTab estava montado duas vezes desde a resolucao manual de conflito — duas instancias com seus proprios intervalos e efeitos. No backend, tres fontes de carga a menos: lobby e ready check passam pelo mesmo coalescing do champ select (o lobby republica a cada tique da estimativa de fila, o ready check a cada tique do contador); as mensagens da LCU sao filtradas por uri antes do parse, porque a assinatura e o firehose de todos os plugins do cliente; e as settings ficam em cache por 2s, em vez de reler o arquivo a cada evento. Por fim, get_client deixa de segurar o mutex durante a checagem de rede, que punha todos os comandos do league em fila atras de uma ida e volta. --- src-tauri/src/commands/league/mod.rs | 40 +- src-tauri/src/commands/league/ws.rs | 171 +++++++- src/components/league/AnalysisTab.svelte | 292 ++++++------- src/components/league/AutomationTab.svelte | 438 ++++++++++---------- src/components/league/GoalsTab.svelte | 56 +-- src/components/league/HistoryTab.svelte | 344 ++++++++-------- src/components/league/LiveTab.svelte | 286 ++++++------- src/components/league/MetaTab.svelte | 354 ++++++++-------- src/components/league/OverviewTab.svelte | 454 +++++++++++---------- src/components/league/SearchTab.svelte | 308 +++++++------- src/routes/league/+page.svelte | 24 +- 11 files changed, 1479 insertions(+), 1288 deletions(-) diff --git a/src-tauri/src/commands/league/mod.rs b/src-tauri/src/commands/league/mod.rs index 31992b3b..915a450c 100644 --- a/src-tauri/src/commands/league/mod.rs +++ b/src-tauri/src/commands/league/mod.rs @@ -48,12 +48,13 @@ async fn discover_client() -> Option { } async fn get_client() -> Result { - { - let cached = CACHED_CLIENT.lock().await; - if let Some(client) = cached.as_ref() { - if lcu_reachable(client).await { - return Ok(client.clone()); - } + // The cached client is copied out before the reachability probe: holding the + // lock across a request would put every league command in a single file + // behind one network round trip, and a slow client stalls all of them. + let cached = { CACHED_CLIENT.lock().await.clone() }; + if let Some(client) = cached { + if lcu_reachable(&client).await { + return Ok(client); } } let discovered = discover_client() @@ -249,8 +250,33 @@ async fn lcu_post_raw(client: &LcuClient, path: &str) -> Result { lcu_send(client, reqwest::Method::POST, path, None).await } +/// Settings are read on every websocket event, and reading them means parsing the +/// settings file from disk. A short cache keeps that off the hot path while still +/// picking up a toggle the user just flipped. +static SETTINGS_CACHE: Lazy< + std::sync::Mutex< + Option<( + omniget_core::models::settings::LeagueSettings, + std::time::Instant, + )>, + >, +> = Lazy::new(|| std::sync::Mutex::new(None)); + +const SETTINGS_TTL: std::time::Duration = std::time::Duration::from_millis(2000); + fn league_settings() -> omniget_core::models::settings::LeagueSettings { - crate::storage::config::load_settings_standalone().league + if let Ok(cache) = SETTINGS_CACHE.lock() { + if let Some((settings, at)) = cache.as_ref() { + if at.elapsed() < SETTINGS_TTL { + return settings.clone(); + } + } + } + let settings = crate::storage::config::load_settings_standalone().league; + if let Ok(mut cache) = SETTINGS_CACHE.lock() { + *cache = Some((settings.clone(), std::time::Instant::now())); + } + settings } fn league_enabled() -> bool { diff --git a/src-tauri/src/commands/league/ws.rs b/src-tauri/src/commands/league/ws.rs index 59945320..209082c7 100644 --- a/src-tauri/src/commands/league/ws.rs +++ b/src-tauri/src/commands/league/ws.rs @@ -17,9 +17,9 @@ static MESSAGE_SENT: AtomicBool = AtomicBool::new(false); static ACCEPT_PENDING: AtomicBool = AtomicBool::new(false); static NOTIFIED_READY_CHECK: AtomicBool = AtomicBool::new(false); #[allow(clippy::type_complexity)] -static LAST_CHAMP_SELECT: once_cell::sync::Lazy< - tokio::sync::Mutex>, -> = once_cell::sync::Lazy::new(|| tokio::sync::Mutex::new(None)); +static LAST_EMITTED: once_cell::sync::Lazy< + tokio::sync::Mutex>, +> = once_cell::sync::Lazy::new(|| tokio::sync::Mutex::new(std::collections::HashMap::new())); static TRADES_HANDLED: once_cell::sync::Lazy>> = once_cell::sync::Lazy::new(|| tokio::sync::Mutex::new(std::collections::HashSet::new())); #[allow(clippy::type_complexity)] @@ -177,6 +177,12 @@ async fn run_session(client: &LcuClient) -> Result<(), String> { if text.is_empty() { continue; } + // The subscription is the whole firehose, and the client emits from + // every plugin it runs. Scanning the raw text for a uri we handle is + // far cheaper than parsing megabytes of JSON we would discard. + if !is_interesting(&text) { + continue; + } if let Ok(value) = serde_json::from_str::(&text) { handle_event(client, &value).await; } @@ -283,21 +289,88 @@ fn champ_select_fingerprint(session: &Value) -> String { parts.join("|") } -/// True when this session is worth pushing to the UI: something the panels read -/// changed, or the heartbeat window elapsed. -async fn should_emit_champ_select(session: &Value) -> bool { +/// True when a payload is worth pushing to the UI: its fingerprint changed, or +/// the heartbeat window elapsed. Every hot event goes through here — the client +/// republishes lobby and ready-check state as often as champion select, and each +/// one that reaches the webview forces a re-render. +async fn should_emit(event: &'static str, fingerprint: String) -> bool { const HEARTBEAT: std::time::Duration = std::time::Duration::from_millis(1500); - let fingerprint = champ_select_fingerprint(session); - let mut last = LAST_CHAMP_SELECT.lock().await; - match last.as_ref() { + let mut seen = LAST_EMITTED.lock().await; + match seen.get(event) { Some((previous, at)) if previous == &fingerprint && at.elapsed() < HEARTBEAT => false, _ => { - *last = Some((fingerprint, std::time::Instant::now())); + seen.insert(event, (fingerprint, std::time::Instant::now())); true } } } +/// The lobby republishes on every queue-estimate tick while searching, so the +/// fingerprint covers the membership and the queue, not the countdown. +fn lobby_fingerprint(lobby: &Value) -> String { + let mut parts = vec![ + lobby + .get("gameConfig") + .and_then(|c| c.get("queueId")) + .and_then(Value::as_i64) + .unwrap_or(-1) + .to_string(), + lobby + .get("localMember") + .and_then(|m| m.get("isLeader")) + .and_then(Value::as_bool) + .unwrap_or(false) + .to_string(), + ]; + for member in lobby + .get("members") + .and_then(Value::as_array) + .unwrap_or(&vec![]) + { + parts.push(format!( + "{}:{}:{}", + member + .get("summonerId") + .and_then(Value::as_i64) + .unwrap_or(0), + member + .get("firstPositionPreference") + .and_then(Value::as_str) + .unwrap_or(""), + member + .get("ready") + .and_then(Value::as_bool) + .unwrap_or(false), + )); + } + parts.join("|") +} + +/// The ready check ticks its own timer; only the state and the answer matter. +fn ready_check_fingerprint(data: &Value) -> String { + format!( + "{}:{}", + data.get("state").and_then(Value::as_str).unwrap_or(""), + data.get("playerResponse") + .and_then(Value::as_str) + .unwrap_or(""), + ) +} + +/// Uris this client acts on. Anything else the League client publishes is noise +/// for us. +const HANDLED_URIS: [&str; 5] = [ + "/lol-gameflow/v1/gameflow-phase", + "/lol-matchmaking/v1/ready-check", + "/lol-champ-select/v1/session", + "/lol-lobby/v2/lobby", + "/lol-honor-v2/v1/ballot", +]; + +fn is_interesting(text: &str) -> bool { + HANDLED_URIS.iter().any(|uri| text.contains(uri)) +} + async fn handle_event(client: &LcuClient, value: &Value) { let payload = match value.as_array().and_then(|a| a.get(2)) { Some(p) => p, @@ -322,7 +395,9 @@ async fn handle_event(client: &LcuClient, value: &Value) { .get("playerResponse") .and_then(Value::as_str) .unwrap_or(""); - emit("league-ready-check", data.clone()); + if should_emit("league-ready-check", ready_check_fingerprint(&data)).await { + emit("league-ready-check", data.clone()); + } if !pending_ready_check(state) { ACCEPT_PENDING.store(false, Ordering::SeqCst); NOTIFIED_READY_CHECK.store(false, Ordering::SeqCst); @@ -376,11 +451,11 @@ async fn handle_event(client: &LcuClient, value: &Value) { TRADES_HANDLED.lock().await.clear(); SWAPS_HANDLED.lock().await.clear(); MESSAGE_SENT.store(false, Ordering::SeqCst); - *LAST_CHAMP_SELECT.lock().await = None; + LAST_EMITTED.lock().await.remove("league-champ-select"); emit("league-champ-select", Value::Null); return; } - if should_emit_champ_select(&data).await { + if should_emit("league-champ-select", champ_select_fingerprint(&data)).await { emit("league-champ-select", data.clone()); } let settings = league_settings(); @@ -394,14 +469,12 @@ async fn handle_event(client: &LcuClient, value: &Value) { send_auto_message(client, &settings); } "/lol-lobby/v2/lobby" => { - emit( - "league-lobby", - if event_type == "Delete" { - Value::Null - } else { - data.clone() - }, - ); + if event_type == "Delete" { + LAST_EMITTED.lock().await.remove("league-lobby"); + emit("league-lobby", Value::Null); + } else if should_emit("league-lobby", lobby_fingerprint(&data)).await { + emit("league-lobby", data.clone()); + } } "/lol-honor-v2/v1/ballot" => { if event_type != "Delete" && league_settings().auto_honor { @@ -750,4 +823,60 @@ mod tests { assert!(!champ_select_fingerprint(&json!({})).is_empty()); assert!(!champ_select_fingerprint(&Value::Null).is_empty()); } + + #[test] + fn only_messages_for_a_handled_uri_are_parsed() { + assert!(is_interesting( + r#"[8,"OnJsonApiEvent",{"uri":"/lol-champ-select/v1/session","eventType":"Update"}]"# + )); + assert!(is_interesting( + r#"[8,"OnJsonApiEvent",{"uri":"/lol-lobby/v2/lobby"}]"# + )); + // The client publishes from every plugin it runs; none of this is ours. + assert!(!is_interesting( + r#"[8,"OnJsonApiEvent",{"uri":"/lol-hovercard/v1/friend-info/42"}]"# + )); + assert!(!is_interesting( + r#"[8,"OnJsonApiEvent",{"uri":"/lol-loot/v1/player-loot-map"}]"# + )); + assert!(!is_interesting("")); + } + + #[test] + fn the_lobby_fingerprint_ignores_the_queue_countdown() { + let base = json!({ + "gameConfig": { "queueId": 420 }, + "localMember": { "isLeader": true }, + "members": [{ "summonerId": 7, "firstPositionPreference": "JUNGLE", "ready": true }] + }); + let mut ticked = base.clone(); + ticked["gameConfig"]["queueEstimate"] = json!(93); + assert_eq!(lobby_fingerprint(&base), lobby_fingerprint(&ticked)); + + let mut joined = base.clone(); + joined["members"][0]["summonerId"] = json!(8); + assert_ne!(lobby_fingerprint(&base), lobby_fingerprint(&joined)); + + let mut role = base.clone(); + role["members"][0]["firstPositionPreference"] = json!("MIDDLE"); + assert_ne!(lobby_fingerprint(&base), lobby_fingerprint(&role)); + } + + #[test] + fn the_ready_check_fingerprint_tracks_the_answer_not_the_clock() { + let base = json!({ "state": "InProgress", "playerResponse": "None", "timer": 8.4 }); + let mut ticked = base.clone(); + ticked["timer"] = json!(3.1); + assert_eq!( + ready_check_fingerprint(&base), + ready_check_fingerprint(&ticked) + ); + + let mut accepted = base.clone(); + accepted["playerResponse"] = json!("Accepted"); + assert_ne!( + ready_check_fingerprint(&base), + ready_check_fingerprint(&accepted) + ); + } } diff --git a/src/components/league/AnalysisTab.svelte b/src/components/league/AnalysisTab.svelte index 10cb46d2..7cb1c470 100644 --- a/src/components/league/AnalysisTab.svelte +++ b/src/components/league/AnalysisTab.svelte @@ -21,6 +21,7 @@ timesSeenBefore, platform, clientConnected, + active, }: { analysis: any; analysisLoading: boolean; @@ -36,6 +37,7 @@ timesSeenBefore: (puuid: string) => number; platform?: Platform; clientConnected?: boolean; + active?: boolean; } = $props(); let analysisFeature = featureById("analysis"); @@ -111,160 +113,162 @@ } -{#if analysis} -
-
-

{$t("league.win_title")}

- -
-
- -
+ {/each} + {/if} + + {/each} + + + {/if} {/if} diff --git a/src/components/league/AutomationTab.svelte b/src/components/league/AutomationTab.svelte index 57105210..f865b67f 100644 --- a/src/components/league/AutomationTab.svelte +++ b/src/components/league/AutomationTab.svelte @@ -8,9 +8,11 @@ let { champions, championById, + active, }: { champions: Champion[]; championById: Map; + active?: boolean; } = $props(); let settings = $derived(getSettings()); @@ -102,251 +104,253 @@ }); -
-
-

{$t("league.automation_title")}

-
-
-
- {$t("league.auto_accept")} - {$t("league.auto_accept_desc")} +{#if active !== false} +
+
+

{$t("league.automation_title")}

- -
- {#if autoAccept} -
- - {$t("league.accept_delay")} - - {acceptDelay === 0 ? $t("league.accept_delay_instant") : `${acceptDelay}s`} - - -
- 0s - - 11s +
+
+ {$t("league.auto_accept")} + {$t("league.auto_accept_desc")}
- {$t("league.accept_delay_desc")} -
- {/if} -
-
-
- {$t("league.notify_ready")} - {$t("league.notify_ready_desc")} -
- -
-
-
-
- {$t("league.auto_pick")} - {$t("league.auto_pick_desc")} +
- -
- {#if settings?.league?.auto_pick} -
- {$t("league.pick_list")} ({$t("league.list_hint")}) -
- {#each listFor("pick") as id (id)} - - - {championById.get(id)?.name ?? id} - + {#if autoAccept} +
+ + {$t("league.accept_delay")} + + {acceptDelay === 0 ? $t("league.accept_delay_instant") : `${acceptDelay}s`} - {/each} + +
+ 0s + + 11s +
+ {$t("league.accept_delay_desc")}
-
+ +{/if} diff --git a/src/components/league/GoalsTab.svelte b/src/components/league/GoalsTab.svelte index 35b88ccc..5eca433b 100644 --- a/src/components/league/GoalsTab.svelte +++ b/src/components/league/GoalsTab.svelte @@ -6,39 +6,43 @@ goalValue, setGoal, resetGoals, + active, }: { goalValue: (role: Role, key: GoalKey) => number; setGoal: (role: Role, key: GoalKey, value: number) => void; resetGoals: (role: Role) => void; + active?: boolean; } = $props(); let goalRole = $state("MIDDLE"); -
-
-

{$t("league.goals_title")}

- + {#each ROLES as r (r)} + + {/each} + +
+

{$t("league.goals_desc")}

+
+ {#each GOAL_FIELDS as field (field.key)} + {/each} - -
-

{$t("league.goals_desc")}

-
- {#each GOAL_FIELDS as field (field.key)} - - {/each} -
- -
+ + + +{/if} diff --git a/src/components/league/HistoryTab.svelte b/src/components/league/HistoryTab.svelte index 27d1dc25..cc10f6ee 100644 --- a/src/components/league/HistoryTab.svelte +++ b/src/components/league/HistoryTab.svelte @@ -11,11 +11,13 @@ loading, onRefresh, championById, + active, }: { games: any[]; loading: boolean; onRefresh: () => void; championById: Map; + active?: boolean; } = $props(); const QUEUE_NAMES: Record = { @@ -163,182 +165,184 @@ } -
-
-

{$t("league.history_title")}

- -
- {#if games.length === 0} -

{$t("league.history_empty")}

- {:else} - {#if availableQueues.length > 1} -
- - {#each availableQueues as id (id)} - +
+ {#if games.length === 0} +

{$t("league.history_empty")}

+ {:else} + {#if availableQueues.length > 1} +
+ - {/each} -
- {/if} -

- {summary.counted} - {$t("league.summary_games")} - {#if summary.winrate !== null} - · {summary.wins}{$t("league.summary_win_short")} {summary.losses}{$t("league.summary_loss_short")} · {summary.winrate}% - {/if} - {#if summary.kda !== null} - · KDA {summary.kda.toFixed(2)} - {/if} - {#if summary.remakes > 0} - · {summary.remakes} {$t("league.summary_remakes")} + {#each availableQueues as id (id)} + + {/each} + {/if} -

-
- {#each visibleGames as game (game.gameId)} - {@const p = playerStats(game)} - - {#if expandedGame === game.gameId} - {#if gameDetailLoading === game.gameId} -

- {:else if gameDetails[game.gameId]} -
- {#each scoreboardTeams(gameDetails[game.gameId]) as team (team.teamId)} - {@const objectives = teamObjectives(findTeam(gameDetails[game.gameId]?.teams, team.teamId))} - {@const bans = teamBans(findTeam(gameDetails[game.gameId]?.teams, team.teamId))} -
- - {team.players[0]?.win ? $t("league.victory") : $t("league.defeat")} - - {#if objectives} -
- {$t("league.obj_towers")} {objectives.towers} - {$t("league.obj_inhibitors")} {objectives.inhibitors} - {$t("league.objective_baron")} {objectives.barons} - {$t("league.objective_dragon")} {objectives.dragons} - {$t("league.obj_heralds")} {objectives.heralds} -
- {/if} - {#if bans.length > 0} -
- {$t("league.obj_bans")} - {#each bans as banId, i (`${banId}-${i}`)} - - {/each} -
- {/if} - {#each team.players as sp (sp.participantId)} -
-
- -
- {#each sp.spells ?? [] as spell (spell)} - { (e.currentTarget as HTMLImageElement).style.visibility = "hidden"; }} /> - {/each} -
- {#if sp.runes} -
- {#if perkIcons[sp.runes.perks?.[0]]} - - {/if} - {#if sp.runes.subStyle} - - {/if} -
- {/if} - {#if sp.puuid} - - {:else} - {sp.gameName || championById.get(sp.championId)?.name || "—"} - {/if} - {sp.kills}/{sp.deaths}/{sp.assists} - {$t("league.stat_level")} {sp.level} - {sp.cs} CS - {(sp.gold / 1000).toFixed(1)}k +

+ {summary.counted} + {$t("league.summary_games")} + {#if summary.winrate !== null} + · {summary.wins}{$t("league.summary_win_short")} {summary.losses}{$t("league.summary_loss_short")} · {summary.winrate}% + {/if} + {#if summary.kda !== null} + · KDA {summary.kda.toFixed(2)} + {/if} + {#if summary.remakes > 0} + · {summary.remakes} {$t("league.summary_remakes")} + {/if} +

+
+ {#each visibleGames as game (game.gameId)} + {@const p = playerStats(game)} + + {#if expandedGame === game.gameId} + {#if gameDetailLoading === game.gameId} +

+ {:else if gameDetails[game.gameId]} +
+ {#each scoreboardTeams(gameDetails[game.gameId]) as team (team.teamId)} + {@const objectives = teamObjectives(findTeam(gameDetails[game.gameId]?.teams, team.teamId))} + {@const bans = teamBans(findTeam(gameDetails[game.gameId]?.teams, team.teamId))} +
+ + {team.players[0]?.win ? $t("league.victory") : $t("league.defeat")} + + {#if objectives} +
+ {$t("league.obj_towers")} {objectives.towers} + {$t("league.obj_inhibitors")} {objectives.inhibitors} + {$t("league.objective_baron")} {objectives.barons} + {$t("league.objective_dragon")} {objectives.dragons} + {$t("league.obj_heralds")} {objectives.heralds}
-
- {#each sp.items ?? [] as item, idx (`${idx}-${item}`)} - {#if item > 0} - { (e.currentTarget as HTMLImageElement).style.visibility = "hidden"; }} /> - {:else} - - {/if} + {/if} + {#if bans.length > 0} +
+ {$t("league.obj_bans")} + {#each bans as banId, i (`${banId}-${i}`)} + {/each}
-
- {#each statLine(sp) as stat (stat.key)} - {$t(stat.label)} {stat.value} - {/each} - {#if sp.pentaKills > 0}{$t("league.stat_penta")}{/if} - {#if sp.quadraKills > 0}{$t("league.stat_quadra")}{/if} - {#if sp.tripleKills > 0}{$t("league.stat_triple")}{/if} - {#if sp.firstBlood}{$t("league.stat_first_blood")}{/if} - {#if sp.firstTower}{$t("league.stat_first_tower")}{/if} + {/if} + {#each team.players as sp (sp.participantId)} +
+
+ +
+ {#each sp.spells ?? [] as spell (spell)} + { (e.currentTarget as HTMLImageElement).style.visibility = "hidden"; }} /> + {/each} +
+ {#if sp.runes} +
+ {#if perkIcons[sp.runes.perks?.[0]]} + + {/if} + {#if sp.runes.subStyle} + + {/if} +
+ {/if} + {#if sp.puuid} + + {:else} + {sp.gameName || championById.get(sp.championId)?.name || "—"} + {/if} + {sp.kills}/{sp.deaths}/{sp.assists} + {$t("league.stat_level")} {sp.level} + {sp.cs} CS + {(sp.gold / 1000).toFixed(1)}k +
+
+ {#each sp.items ?? [] as item, idx (`${idx}-${item}`)} + {#if item > 0} + { (e.currentTarget as HTMLImageElement).style.visibility = "hidden"; }} /> + {:else} + + {/if} + {/each} +
+
+ {#each statLine(sp) as stat (stat.key)} + {$t(stat.label)} {stat.value} + {/each} + {#if sp.pentaKills > 0}{$t("league.stat_penta")}{/if} + {#if sp.quadraKills > 0}{$t("league.stat_quadra")}{/if} + {#if sp.tripleKills > 0}{$t("league.stat_triple")}{/if} + {#if sp.firstBlood}{$t("league.stat_first_blood")}{/if} + {#if sp.firstTower}{$t("league.stat_first_tower")}{/if} +
-
- {/each} -
- {/each} -
- {:else} -

{$t("league.match_detail_unavailable")}

+ {/each} +
+ {/each} +
+ {:else} +

{$t("league.match_detail_unavailable")}

+ {/if} {/if} - {/if} - {/each} -
- {/if} - {#if lookupPuuid} -
-
-

{lookupName}

- + {/each}
- {#if lookupLoading} -

- {:else if lookupError} - - {:else if lookupGames.length === 0} -

{$t("league.history_empty")}

- {:else} -
- {#each lookupGames as g (g.gameId)} - {@const lp = playerStats(g)} -
- -
- {lp.win ? $t("league.victory") : $t("league.defeat")} - {queueName(g.queueId, g.gameMode)} -
- {lp.kills} / {lp.deaths} / {lp.assists} - {timeAgo(g.gameCreation, $locale)} -
- {/each} + {/if} + {#if lookupPuuid} +
+
+

{lookupName}

+
- {/if} -
- {/if} -
+ {#if lookupLoading} +

+ {:else if lookupError} + + {:else if lookupGames.length === 0} +

{$t("league.history_empty")}

+ {:else} +
+ {#each lookupGames as g (g.gameId)} + {@const lp = playerStats(g)} +
+ +
+ {lp.win ? $t("league.victory") : $t("league.defeat")} + {queueName(g.queueId, g.gameMode)} +
+ {lp.kills} / {lp.deaths} / {lp.assists} + {timeAgo(g.gameCreation, $locale)} +
+ {/each} +
+ {/if} + + {/if} + +{/if} diff --git a/src/components/league/LiveTab.svelte b/src/components/league/LiveTab.svelte index 261ee0ce..d27462e0 100644 --- a/src/components/league/LiveTab.svelte +++ b/src/components/league/LiveTab.svelte @@ -10,6 +10,7 @@ goalValue, platform, clientConnected, + active, }: { liveMetrics: any; cooldowns: any; @@ -17,6 +18,7 @@ goalValue: (role: Role, key: GoalKey) => number; platform?: Platform; clientConnected?: boolean; + active?: boolean; } = $props(); let context = $derived({ @@ -164,158 +166,160 @@ ); -{#if liveMetrics?.players?.length} -
-
-

{$t("league.gold_title")}

- {formatGameTime(liveMetrics.gameTime ?? 0)} -
-

{$t("league.col_gold_hint")}

-
- {$t("league.your_team")}: {liveMetrics.teamGold?.[myTeam] ?? 0} - 0} class:bad={teamGoldLead < 0}> - {teamGoldLead > 0 ? "+" : ""}{teamGoldLead} - - {$t("league.enemy_team")}: {liveMetrics.teamGold?.[enemyTeam] ?? 0} -
-
-
- {$t("league.col_player")} - KDA - CS - {$t("league.col_gold")} - {$t("league.col_diff")} -
- {#each liveMetrics.players as row (row.riotId)} -
- - {(row.position ?? "?").slice(0, 3)} - {row.championName} - - {row.kills}/{row.deaths}/{row.assists} - {row.cs} ({row.csPerMin}/m) - {row.itemGold} - 0} class:bad={(row.goldDiff ?? 0) < 0}> - {#if row.goldDiff !== undefined && row.goldDiff !== null} - {row.goldDiff > 0 ? "+" : ""}{row.goldDiff}g - {(row.csDiff ?? 0) > 0 ? "+" : ""}{Math.round(row.csDiff ?? 0)}cs - {:else}—{/if} - -
- {/each} -
-
- - {#if enemyCooldowns.length > 0} +{#if active !== false} + {#if liveMetrics?.players?.length}
-

{$t("league.cd_title")}

- {$t("league.cd_estimated")} +

{$t("league.gold_title")}

+ {formatGameTime(liveMetrics.gameTime ?? 0)}
-

{$t("league.cd_desc")}

-
- {#each enemyCooldowns as p (p.riotId)} -
-
- {#if p.championId > 0} - - {/if} -
- {p.championName} - {$t("league.cd_haste")} {p.abilityHaste} · lv{p.level} -
-
-
- {#each p.abilities as ab (ab.key)} - - {#if ab.iconPath} - { (e.currentTarget as HTMLImageElement).style.visibility = "hidden"; }} /> - {/if} - {ab.key} - {ab.cooldown > 0 ? `${ab.cooldown}s` : "—"} - - {/each} - {#each p.spellTimers ?? [] as sp (sp.name)} - {@const remaining = spellRemaining(p.riotId, sp)} - - {/each} -
+

{$t("league.col_gold_hint")}

+
+ {$t("league.your_team")}: {liveMetrics.teamGold?.[myTeam] ?? 0} + 0} class:bad={teamGoldLead < 0}> + {teamGoldLead > 0 ? "+" : ""}{teamGoldLead} + + {$t("league.enemy_team")}: {liveMetrics.teamGold?.[enemyTeam] ?? 0} +
+
+
+ {$t("league.col_player")} + KDA + CS + {$t("league.col_gold")} + {$t("league.col_diff")} +
+ {#each liveMetrics.players as row (row.riotId)} +
+ + {(row.position ?? "?").slice(0, 3)} + {row.championName} + + {row.kills}/{row.deaths}/{row.assists} + {row.cs} ({row.csPerMin}/m) + {row.itemGold} + 0} class:bad={(row.goldDiff ?? 0) < 0}> + {#if row.goldDiff !== undefined && row.goldDiff !== null} + {row.goldDiff > 0 ? "+" : ""}{row.goldDiff}g + {(row.csDiff ?? 0) > 0 ? "+" : ""}{Math.round(row.csDiff ?? 0)}cs + {:else}—{/if} +
{/each}
- {/if} - {#if objectives.length > 0 || feed.length > 0} -
-
-

- {$t("league.objectives_title")} - {#if objectivesFeature && needsBadge(objectivesFeature)} - {$t(`league.badge_${objectivesFeature.state}`)} - {/if} -

-

{$t("league.objectives_title")}

-
- {#if objectives.length > 0} -
- {#each objectives as objective (objective.kind)} - - {$t(`league.objective_${objective.kind}`)} - {formatGameTime(objective.left)} - - {/each} + {#if enemyCooldowns.length > 0} +
+
+

{$t("league.cd_title")}

+ {$t("league.cd_estimated")}
-

{$t("league.objectives_estimate")}

- {/if} - {#if feed.length > 0} -
    - {#each feed as event (`${event.id}:${event.at}`)} -
  • - {formatGameTime(event.at)} - - {$t(EVENT_LABELS[event.name])} - {#if event.actor}{event.actor}{/if} - {#if event.target}→ {event.target}{/if} - {#if event.detail}({event.detail}){/if} - -
  • +

    {$t("league.cd_desc")}

    +
    + {#each enemyCooldowns as p (p.riotId)} +
    +
    + {#if p.championId > 0} + + {/if} +
    + {p.championName} + {$t("league.cd_haste")} {p.abilityHaste} · lv{p.level} +
    +
    +
    + {#each p.abilities as ab (ab.key)} + + {#if ab.iconPath} + { (e.currentTarget as HTMLImageElement).style.visibility = "hidden"; }} /> + {/if} + {ab.key} + {ab.cooldown > 0 ? `${ab.cooldown}s` : "—"} + + {/each} + {#each p.spellTimers ?? [] as sp (sp.name)} + {@const remaining = spellRemaining(p.riotId, sp)} + + {/each} +
    +
    {/each} -
- {/if} -
- {/if} +
+
+ {/if} - {#if selfRow} -
-
-

{$t("league.goals_live")}

- {selfRow.position ?? "?"} -
-
- {#each liveGoals as goal (goal.key)} -
- {$t(goal.labelKey)} -
= 1} style={`width:${Math.min(goal.ratio * 100, 100)}%`}>
- = 1}>{goal.current} / {goal.target} + {#if objectives.length > 0 || feed.length > 0} +
+
+

+ {$t("league.objectives_title")} + {#if objectivesFeature && needsBadge(objectivesFeature)} + {$t(`league.badge_${objectivesFeature.state}`)} + {/if} +

+

{$t("league.objectives_title")}

+
+ {#if objectives.length > 0} +
+ {#each objectives as objective (objective.kind)} + + {$t(`league.objective_${objective.kind}`)} + {formatGameTime(objective.left)} + + {/each}
- {/each} -
-
+

{$t("league.objectives_estimate")}

+ {/if} + {#if feed.length > 0} +
    + {#each feed as event (`${event.id}:${event.at}`)} +
  • + {formatGameTime(event.at)} + + {$t(EVENT_LABELS[event.name])} + {#if event.actor}{event.actor}{/if} + {#if event.target}→ {event.target}{/if} + {#if event.detail}({event.detail}){/if} + +
  • + {/each} +
+ {/if} + + {/if} + + {#if selfRow} +
+
+

{$t("league.goals_live")}

+ {selfRow.position ?? "?"} +
+
+ {#each liveGoals as goal (goal.key)} +
+ {$t(goal.labelKey)} +
= 1} style={`width:${Math.min(goal.ratio * 100, 100)}%`}>
+ = 1}>{goal.current} / {goal.target} +
+ {/each} +
+
+ {/if} + {:else} +
+

{liveAvailability.available ? $t("league.gold_unavailable") : $t(liveAvailability.reasonKey)}

+
{/if} -{:else} -
-

{liveAvailability.available ? $t("league.gold_unavailable") : $t(liveAvailability.reasonKey)}

-
{/if} diff --git a/src/components/league/MetaTab.svelte b/src/components/league/MetaTab.svelte index 8b8bdf84..10f6ae32 100644 --- a/src/components/league/MetaTab.svelte +++ b/src/components/league/MetaTab.svelte @@ -13,12 +13,14 @@ championById, champions, region, + active, }: { champSelectChampionId: number; myAssignedPosition: string; championById: Map; champions: Champion[]; region: string | null; + active?: boolean; } = $props(); let settings = $derived(getSettings()); @@ -236,198 +238,200 @@ }); -
-
-

{$t("league.runes_title")}

- {#if champSelectChampionId > 0} - {championById.get(champSelectChampionId)?.name ?? champSelectChampionId} - {/if} -
-

{$t("league.runes_desc")}

-
-
- {$t("league.runes_auto")} - {$t("league.runes_auto_desc")} +{#if active !== false} +
+
+

{$t("league.runes_title")}

+ {#if champSelectChampionId > 0} + {championById.get(champSelectChampionId)?.name ?? champSelectChampionId} + {/if}
- -
- {#if runeError} - - {/if} - {#if runePages.length > 0} -
- {#each runePages as page, i (page.recommendationId ?? i)} -
-
- {page.keystoneName ?? page.keystoneId} - {#if page.isDefault} - {$t("league.runes_default")} - {/if} -
-
- {#each page.selectedPerkIds as perkId (perkId)} - { (e.currentTarget as HTMLImageElement).style.visibility = "hidden"; }} - /> - {/each} -
-
- {$t("league.runes_spells")}: {(page.summonerSpellIds ?? []).map(spellName).join(" + ")} - -
-
- {/each} +

{$t("league.runes_desc")}

+
+
+ {$t("league.runes_auto")} + {$t("league.runes_auto_desc")} +
+
- {:else} -

{$t("league.runes_empty")}

- {/if} -
+ {#if runeError} + + {/if} + {#if runePages.length > 0} +
+ {#each runePages as page, i (page.recommendationId ?? i)} +
+
+ {page.keystoneName ?? page.keystoneId} + {#if page.isDefault} + {$t("league.runes_default")} + {/if} +
+
+ {#each page.selectedPerkIds as perkId (perkId)} + { (e.currentTarget as HTMLImageElement).style.visibility = "hidden"; }} + /> + {/each} +
+
+ {$t("league.runes_spells")}: {(page.summonerSpellIds ?? []).map(spellName).join(" + ")} + +
+
+ {/each} +
+ {:else} +

{$t("league.runes_empty")}

+ {/if} + -
-
-

{$t("league.build_title")}

- -
-

{$t("league.build_desc")}

-
- -
- {#if buildInfo && buildInfo.gamesSeen > 0} -

- {buildInfo.gamesSeen} {$t("league.build_samples")} · {buildInfo.winrate}% {$t("league.stat_winrate")} -

-
- {#each buildInfo.items as it (it.itemId)} - - { (e.currentTarget as HTMLImageElement).style.visibility = "hidden"; }} /> - {it.pickRate}% - - {/each} +
+
+

{$t("league.build_title")}

+
- {#if buildInfo.spells?.length} -

{$t("league.runes_spells")}: {buildInfo.spells.map((s: any) => s.spellIds.map(spellName).join(" + ")).join(" / ")}

- {/if} - {:else if buildInfo} -

{$t("league.build_empty")}

- {/if} -
-
-

- {$t("league.meta_reference")} - {#if buildFeature && needsBadge(buildFeature)} - {$t(`league.badge_${buildFeature.state}`)} - {/if} -

-

{$t("league.meta_reference")}

- -
- {#if metaError} - - {:else if metaLoading} -

- {:else if metaInfo} - {#if metaInfo.skillPriority?.length} +

{$t("league.build_desc")}

+
+ +
+ {#if buildInfo && buildInfo.gamesSeen > 0}

- {$t("league.skill_priority")}: {metaInfo.skillPriority.join(" › ")} + {buildInfo.gamesSeen} {$t("league.build_samples")} · {buildInfo.winrate}% {$t("league.stat_winrate")}

-
- {#each metaInfo.skillOrder as skill, i (`${i}-${skill}`)} - {skill} +
+ {#each buildInfo.items as it (it.itemId)} + + { (e.currentTarget as HTMLImageElement).style.visibility = "hidden"; }} /> + {it.pickRate}% + {/each}
+ {#if buildInfo.spells?.length} +

{$t("league.runes_spells")}: {buildInfo.spells.map((s: any) => s.spellIds.map(spellName).join(" + ")).join(" / ")}

+ {/if} + {:else if buildInfo} +

{$t("league.build_empty")}

{/if} - {#each [["starterItems", "items_starter"], ["coreItems", "items_core"], ["boots", "items_boots"], ["lastItems", "items_last"]] as [key, label] (key)} - {#if metaInfo[key]?.ids?.length} +
+
+

+ {$t("league.meta_reference")} + {#if buildFeature && needsBadge(buildFeature)} + {$t(`league.badge_${buildFeature.state}`)} + {/if} +

+

{$t("league.meta_reference")}

+ +
+ {#if metaError} + + {:else if metaLoading} +

+ {:else if metaInfo} + {#if metaInfo.skillPriority?.length} +

+ {$t("league.skill_priority")}: {metaInfo.skillPriority.join(" › ")} +

+
+ {#each metaInfo.skillOrder as skill, i (`${i}-${skill}`)} + {skill} + {/each} +
+ {/if} + {#each [["starterItems", "items_starter"], ["coreItems", "items_core"], ["boots", "items_boots"], ["lastItems", "items_last"]] as [key, label] (key)} + {#if metaInfo[key]?.ids?.length} +
+ {$t(`league.${label}`)} +
+ {#each metaInfo[key].ids as id (id)} + + { (e.currentTarget as HTMLImageElement).style.visibility = "hidden"; }} /> + + {/each} + {#if metaInfo[key].winrate !== null && metaInfo[key].winrate !== undefined} + {metaInfo[key].winrate}% + {/if} +
+
+ {/if} + {/each} + {#if metaInfo.counters?.length}
- {$t(`league.${label}`)} + {$t("league.counters")}
- {#each metaInfo[key].ids as id (id)} + {#each metaInfo.counters.slice(0, 6) as c (c.championId)} - { (e.currentTarget as HTMLImageElement).style.visibility = "hidden"; }} /> + + {c.winrate}% {/each} - {#if metaInfo[key].winrate !== null && metaInfo[key].winrate !== undefined} - {metaInfo[key].winrate}% - {/if}
{/if} - {/each} - {#if metaInfo.counters?.length} -
- {$t("league.counters")} -
- {#each metaInfo.counters.slice(0, 6) as c (c.championId)} - - - {c.winrate}% - - {/each} -
-
+

{$t("league.meta_source")}

{/if} -

{$t("league.meta_source")}

- {/if} -
+
-
-
-

{$t("league.tiers_title")}

-
- - -
-
-

{$t("league.tiers_desc")}

- {#if tiersError} - - {:else if tierRows.length > 0} -
- {#each tierRows as row (row.championId)} -
- {tierLabel(row.tier)} - - {championById.get(row.championId)?.name ?? row.championId} - = 52} class:bad={row.winRate <= 48}>{row.winRate}% - {$t("league.tiers_pick")} {row.pickRate}% - {$t("league.tiers_ban")} {row.banRate}% -
- {/each} +
+
+

{$t("league.tiers_title")}

+
+ + +
- {:else} -

{tiersLoading ? $t("league.searching_player") : $t("league.tiers_empty")}

- {/if} -
+

{$t("league.tiers_desc")}

+ {#if tiersError} + + {:else if tierRows.length > 0} +
+ {#each tierRows as row (row.championId)} +
+ {tierLabel(row.tier)} + + {championById.get(row.championId)?.name ?? row.championId} + = 52} class:bad={row.winRate <= 48}>{row.winRate}% + {$t("league.tiers_pick")} {row.pickRate}% + {$t("league.tiers_ban")} {row.banRate}% +
+ {/each} +
+ {:else} +

{tiersLoading ? $t("league.searching_player") : $t("league.tiers_empty")}

+ {/if} +
+{/if} diff --git a/src/components/league/OverviewTab.svelte b/src/components/league/OverviewTab.svelte index 0091df4d..9271b00c 100644 --- a/src/components/league/OverviewTab.svelte +++ b/src/components/league/OverviewTab.svelte @@ -16,6 +16,7 @@ championById, championByAlias, onAction, + active, }: { summoner: any; ranked: Record; @@ -29,6 +30,7 @@ championById: Map; championByAlias: Map; onAction: (cmd: string, args?: Record) => void; + active?: boolean; } = $props(); const PHASE_KEYS: Record = { @@ -153,247 +155,249 @@ } -{#if summoner} -
- -
- - {summoner.gameName ?? summoner.displayName}{#if summoner.tagLine}#{summoner.tagLine}{/if} - - {$t("league.level")} {summoner.summonerLevel} -
-
-
- {$t("league.ranked_solo")} - {rankLabel(ranked?.RANKED_SOLO_5x5)} +{#if active !== false} + {#if summoner} +
+ +
+ + {summoner.gameName ?? summoner.displayName}{#if summoner.tagLine}#{summoner.tagLine}{/if} + + {$t("league.level")} {summoner.summonerLevel}
-
- {$t("league.ranked_flex")} - {rankLabel(ranked?.RANKED_FLEX_SR)} -
-
-
-{/if} -{#if actionError} - -{/if} -{#if restartError} - -{/if} -{#if summoner} -
- {$t("league.profile_tools")} - {#if profileError} - - {/if} - {#if profileSaved} -

{profileSaved}

- {/if} -
- {$t("league.profile_icon")} - - -
-
- {$t("league.profile_status")} -
- {#each ["chat", "away", "dnd"] as value (value)} - - {/each} -
-
-
- {$t("league.profile_message")} - - -
-
- {$t("league.profile_background")} - - {#if ownedSkins.length > 0} -
- {#each ownedSkins as skin (skin.id)} - - {/each} +
+
+ {$t("league.ranked_solo")} + {rankLabel(ranked?.RANKED_SOLO_5x5)}
- {:else if bgChampion > 0} - {$t("league.profile_no_skins")} - {/if} -
-
-{/if} -
- {#if restartConfirming} - {$t("league.restart_ux_warning")} - - - {:else} - +
+ {$t("league.ranked_flex")} + {rankLabel(ranked?.RANKED_FLEX_SR)} +
+
+ {/if} - -{#if phase === "ChampSelect" && champSelect} -
-
-

{$t("league.champ_select_title")}

- {phaseLabel(phase)} -
-
- {#each myTeamPicks(champSelect) as pick (pick.cellId)} - {#if pick.championId > 0} - {championById.get(pick.championId)?.name - {:else} - - {/if} - {/each} -
- {#if champSelect.benchEnabled} -
- {$t("league.bench_title")} -
- {#each champSelect.benchChampions ?? [] as bc (bc.championId)} + {#if actionError} + + {/if} + {#if restartError} + + {/if} + {#if summoner} +
+ {$t("league.profile_tools")} + {#if profileError} + + {/if} + {#if profileSaved} +

{profileSaved}

+ {/if} +
+ {$t("league.profile_icon")} + + +
+
+ {$t("league.profile_status")} +
+ {#each ["chat", "away", "dnd"] as value (value)} + class="seg" + role="radio" + aria-checked={false} + onclick={() => runProfileAction(() => invoke("league_set_status", { availability: value }), $t("league.profile_status_saved") as string)} + >{$t(`league.status_${value}`)} {/each}
-
- - -
- {/if} - {#if dodgeError} - - {/if} -
- {#if dodgeConfirming} - {$t("league.dodge_warning")} - - - {:else} - - {/if} -
-
-{:else if phase === "InProgress" && liveGame?.stats} -
-
-

{$t("league.live_title")}

- {formatGameTime(liveGame.stats.gameTime ?? 0)} -
- {#if Array.isArray(liveGame.players)} - {@const teams = liveTeams(liveGame.players)} -
- {#each [teams.order, teams.chaos] as team, ti (ti)} -
- {#each team as p (p.riotId ?? p.summonerName ?? p.championName)} - {@const cid = liveChampionId(p)} -
- {#if cid} - - {:else} - - {/if} - {p.championName} - {p.scores?.kills ?? 0}/{p.scores?.deaths ?? 0}/{p.scores?.assists ?? 0} - {#if p.isDead && p.respawnTimer > 0} - {$t("league.respawn_in")} {Math.ceil(p.respawnTimer)}s - {/if} -
+
+ {$t("league.profile_message")} + + +
+
+ {$t("league.profile_background")} + + {#if ownedSkins.length > 0} +
+ {#each ownedSkins as skin (skin.id)} + {/each}
- {/each} + {:else if bgChampion > 0} + {$t("league.profile_no_skins")} + {/if}
+ + {/if} +
+ {#if restartConfirming} + {$t("league.restart_ux_warning")} + + + {:else} + {/if} -
-{:else} -
-
-

{$t("league.lobby_title")}

- {#if phase && phase !== "None"} +
+ {#if phase === "ChampSelect" && champSelect} +
+
+

{$t("league.champ_select_title")}

{phaseLabel(phase)} - {/if} -
- {#if phase === "ReadyCheck"} -
- -
- {:else if phase === "Matchmaking"} -
- {$t("league.searching")} -
- {:else if phase === "Lobby" && lobby} -
- - +
+ {#each myTeamPicks(champSelect) as pick (pick.cellId)} + {#if pick.championId > 0} + {championById.get(pick.championId)?.name + {:else} + + {/if} + {/each}
- {#if roleError} - + {#if champSelect.benchEnabled} +
+ {$t("league.bench_title")} +
+ {#each champSelect.benchChampions ?? [] as bc (bc.championId)} + + {/each} +
+
+ + +
+
{/if} -
- {$t("league.role_preference")} - - - + {#if dodgeError} + + {/if} +
+ {#if dodgeConfirming} + {$t("league.dodge_warning")} + + + {:else} + + {/if}
- {:else if phase === "EndOfGame" || phase === "PreEndOfGame" || phase === "WaitingForStats"} -
- +
+ {:else if phase === "InProgress" && liveGame?.stats} +
+
+

{$t("league.live_title")}

+ {formatGameTime(liveGame.stats.gameTime ?? 0)}
- {:else if queues.length > 0} -
- {#each queues as q (q.id)} - - {/each} + {#if Array.isArray(liveGame.players)} + {@const teams = liveTeams(liveGame.players)} +
+ {#each [teams.order, teams.chaos] as team, ti (ti)} +
+ {#each team as p (p.riotId ?? p.summonerName ?? p.championName)} + {@const cid = liveChampionId(p)} +
+ {#if cid} + + {:else} + + {/if} + {p.championName} + {p.scores?.kills ?? 0}/{p.scores?.deaths ?? 0}/{p.scores?.assists ?? 0} + {#if p.isDead && p.respawnTimer > 0} + {$t("league.respawn_in")} {Math.ceil(p.respawnTimer)}s + {/if} +
+ {/each} +
+ {/each} +
+ {/if} +
+ {:else} +
+
+

{$t("league.lobby_title")}

+ {#if phase && phase !== "None"} + {phaseLabel(phase)} + {/if}
- {:else} -

{$t("league.lobby_hint")}

- {/if} -
+ {#if phase === "ReadyCheck"} +
+ +
+ {:else if phase === "Matchmaking"} +
+ {$t("league.searching")} + +
+ {:else if phase === "Lobby" && lobby} +
+ + +
+ {#if roleError} + + {/if} +
+ {$t("league.role_preference")} + + + +
+ {:else if phase === "EndOfGame" || phase === "PreEndOfGame" || phase === "WaitingForStats"} +
+ +
+ {:else if queues.length > 0} +
+ {#each queues as q (q.id)} + + {/each} +
+ {:else} +

{$t("league.lobby_hint")}

+ {/if} +
+ {/if} {/if} diff --git a/src/components/league/SearchTab.svelte b/src/components/league/SearchTab.svelte index 710b6fbc..ed1b92cd 100644 --- a/src/components/league/SearchTab.svelte +++ b/src/components/league/SearchTab.svelte @@ -5,8 +5,10 @@ let { championById, + active, }: { championById: Map; + active?: boolean; } = $props(); let searchQuery = $state(""); @@ -85,178 +87,180 @@ } -
-
-

{$t("league.search_title")}

-
-
{ e.preventDefault(); runSearch(); }}> - - -
- {#if searchError} - - {:else} -

{$t("league.search_hint")}

- {/if} -
- -{#if searchResult} - {@const s = searchResult.summoner} - {@const r = searchResult.report} -
- -
- {s.gameName}#{s.tagLine} - {$t("league.level")} {s.summonerLevel ?? "—"} +{#if active !== false} +
+
+

{$t("league.search_title")}

-
-
- {$t("league.ranked_solo")} - {rankLabel(r?.solo)} -
-
- {$t("league.ranked_flex")} - {rankLabel(r?.flex)} -
-
- +
{ e.preventDefault(); runSearch(); }}> + + +
+ {#if searchError} + + {:else} +

{$t("league.search_hint")}

+ {/if}
- {#if spectateError} -

{spectateError}

- {/if} - {#if r?.stats?.games > 0} -
-

{$t("league.search_recent")}

-
-
- = 55} class:bad={r.stats.winrate <= 45}>{r.stats.winrate}% - {$t("league.stat_winrate")} ({r.stats.games}) -
-
- {r.stats.kda} - KDA + {#if searchResult} + {@const s = searchResult.summoner} + {@const r = searchResult.report} +
+ +
+ {s.gameName}#{s.tagLine} + {$t("league.level")} {s.summonerLevel ?? "—"} +
+
+
+ {$t("league.ranked_solo")} + {rankLabel(r?.solo)}
-
- {r.stats.streak?.length ?? 0} - {r.stats.streak?.win ? $t("league.tag_hot_streak") : $t("league.tag_cold_streak")} +
+ {$t("league.ranked_flex")} + {rankLabel(r?.flex)}
- {#if r.impact !== null && r.impact !== undefined} +
+ +
+ {#if spectateError} +

{spectateError}

+ {/if} + + {#if r?.stats?.games > 0} +
+

{$t("league.search_recent")}

+
- {r.impact}/10 - {$t("league.impact_label")} ({r.impactGames}) + = 55} class:bad={r.stats.winrate <= 45}>{r.stats.winrate}% + {$t("league.stat_winrate")} ({r.stats.games})
- {/if} - {#if searchResult.deep}
- {searchResult.deep.soloKillsPerGame} - {$t("league.solo_kills_label")} ({searchResult.deep.analysedGames}) + {r.stats.kda} + KDA
- {searchResult.deep.earlyDeathsPerGame} - {$t("league.early_deaths_label")} ({searchResult.deep.analysedGames}) + {r.stats.streak?.length ?? 0} + {r.stats.streak?.win ? $t("league.tag_hot_streak") : $t("league.tag_cold_streak")} +
+ {#if r.impact !== null && r.impact !== undefined} +
+ {r.impact}/10 + {$t("league.impact_label")} ({r.impactGames}) +
+ {/if} + {#if searchResult.deep} +
+ {searchResult.deep.soloKillsPerGame} + {$t("league.solo_kills_label")} ({searchResult.deep.analysedGames}) +
+
+ {searchResult.deep.earlyDeathsPerGame} + {$t("league.early_deaths_label")} ({searchResult.deep.analysedGames}) +
+ {/if} +
+ {#if r.stats.insights?.length} +
+ {#each r.stats.insights as tagId (tagId)} + {$t(TAG_KEYS[tagId] ?? tagId)} + {/each}
{/if} -
- {#if r.stats.insights?.length} +
+ {/if} + + {#if searchResult.champions?.length} +
+
+

{$t("league.search_champions")}

+ {$t("league.search_champions_hint")} +
+
+ {#each searchResult.champions as ch (ch.championId)} +
+ + {championById.get(ch.championId)?.name ?? ch.championId} + {ch.games} {$t("league.games_short")} + = 55} class:bad={ch.winrate <= 45}>{ch.winrate}% + {ch.kda} KDA + {ch.csPerMin}/m +
+ {/each} +
+
+ {/if} + + {#if r?.mastery?.length} +
+

{$t("league.search_mastery")}

- {#each r.stats.insights as tagId (tagId)} - {$t(TAG_KEYS[tagId] ?? tagId)} + {#each r.mastery as m (m.championId)} + + + {championById.get(m.championId)?.name ?? m.championId} + M{m.championLevel} · {Math.round((m.championPoints ?? 0) / 1000)}k + {/each}
- {/if} -
+
+ {/if} + + {#if jungleReport} +
+
+

{$t("league.jungle_title")}

+ {jungleReport.analysedGames} {$t("league.games_short")} +
+ {#if jungleReport.analysedGames > 0} +
+ {#each [["top", jungleReport.zones.top], ["mid", jungleReport.zones.mid], ["bot", jungleReport.zones.bot]] as [zone, pct] (zone)} +
+ {$t(`league.zone_${zone}`)} +
+ {pct}% +
+ {/each} +
+

+ {$t(`league.pref_${jungleReport.preference}`)} · {$t("league.jungle_invade")} {jungleReport.invadeRate}% · {$t("league.jungle_gank3")} {jungleReport.level3GankRate}% +

+ {:else} +

{$t("league.jungle_empty")}

+ {/if} +
+ {/if} {/if} - {#if searchResult.champions?.length} -
-
-

{$t("league.search_champions")}

- {$t("league.search_champions_hint")} -
+
+
+

{$t("league.duos_title")}

+ +
+

{$t("league.duos_desc")}

+ {#if duos?.duos?.length}
- {#each searchResult.champions as ch (ch.championId)} + {#each duos.duos as d (d.puuid)}
- - {championById.get(ch.championId)?.name ?? ch.championId} - {ch.games} {$t("league.games_short")} - = 55} class:bad={ch.winrate <= 45}>{ch.winrate}% - {ch.kda} KDA - {ch.csPerMin}/m + {d.gameName ?? "—"}{#if d.tagLine}#{d.tagLine}{/if} + {d.games} {$t("league.games_short")} + = 55} class:bad={d.winrate <= 45}>{d.winrate}% + {$t("league.duos_score")} {d.score}%
{/each}
-
- {/if} - - {#if r?.mastery?.length} -
-

{$t("league.search_mastery")}

-
- {#each r.mastery as m (m.championId)} - - - {championById.get(m.championId)?.name ?? m.championId} - M{m.championLevel} · {Math.round((m.championPoints ?? 0) / 1000)}k - - {/each} -
-
- {/if} - - {#if jungleReport} -
-
-

{$t("league.jungle_title")}

- {jungleReport.analysedGames} {$t("league.games_short")} -
- {#if jungleReport.analysedGames > 0} -
- {#each [["top", jungleReport.zones.top], ["mid", jungleReport.zones.mid], ["bot", jungleReport.zones.bot]] as [zone, pct] (zone)} -
- {$t(`league.zone_${zone}`)} -
- {pct}% -
- {/each} -
-

- {$t(`league.pref_${jungleReport.preference}`)} · {$t("league.jungle_invade")} {jungleReport.invadeRate}% · {$t("league.jungle_gank3")} {jungleReport.level3GankRate}% -

- {:else} -

{$t("league.jungle_empty")}

- {/if} -
- {/if} + {:else if duos} +

{$t("league.duos_empty")}

+ {/if} +
{/if} - -
-
-

{$t("league.duos_title")}

- -
-

{$t("league.duos_desc")}

- {#if duos?.duos?.length} -
- {#each duos.duos as d (d.puuid)} -
- {d.gameName ?? "—"}{#if d.tagLine}#{d.tagLine}{/if} - {d.games} {$t("league.games_short")} - = 55} class:bad={d.winrate <= 45}>{d.winrate}% - {$t("league.duos_score")} {d.score}% -
- {/each} -
- {:else if duos} -

{$t("league.duos_empty")}

- {/if} -
diff --git a/src/routes/league/+page.svelte b/src/routes/league/+page.svelte index c4e61902..2c5cee18 100644 --- a/src/routes/league/+page.svelte +++ b/src/routes/league/+page.svelte @@ -438,7 +438,12 @@ } }).then((u) => unlisteners.push(u)); listen("league-phase", (e) => { - phase = e.payload ?? ""; + const next = e.payload ?? ""; + // The client repeats the current phase on reconnects and on its own + // heartbeat; refreshing again for the same phase re-fetches everything for + // nothing. + if (next === phase) return; + phase = next; refreshPhaseData(); }).then((u) => unlisteners.push(u)); listen("league-champ-select", (e) => { @@ -492,29 +497,28 @@ timers, expanded games) survives switching, and the meta tab's auto-rune effect keeps working from any tab. -->
- +
- +
- +
- +
- - +
- +
- +
- +
{/if} {/if}