tauri-app: add highlights mode
All checks were successful
record-daemon / Build, check and test (push) Successful in 2m4s

This commit is contained in:
2026-05-17 15:36:52 +02:00
parent 36e1031bb1
commit 8138b542b7
3 changed files with 1363 additions and 11 deletions

View File

@@ -1,8 +1,9 @@
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from "vue";
import { ref, computed, onMounted, onUnmounted, watch } from "vue";
import { invoke } from "@tauri-apps/api/core";
import { convertFileSrc } from "@tauri-apps/api/core";
import type { TimestampedEvent, GameHistoryItem } from "../types/timeline";
import HighlightsPanel from "./HighlightsPanel.vue";
import type { TimestampedEvent, GameHistoryItem, HighlightClip, HighlightSettings } from "../types/timeline";
import {
getGameResult,
formatDuration,
@@ -17,6 +18,9 @@ import {
getLpChange,
formatLpDelta,
formatRank,
computeHighlights,
DEFAULT_HIGHLIGHT_SETTINGS,
getHighlightTypeColor,
} from "../types/timeline";
// Props
@@ -51,6 +55,129 @@ const isExporting = ref(false);
const exportProgress = ref(0);
const exportMessage = ref<string | null>(null);
// Highlights mode state
const highlightsMode = ref(false);
const highlightSettings = ref<HighlightSettings>({ ...DEFAULT_HIGHLIGHT_SETTINGS });
const playingHighlightId = ref<number | null>(null);
const exportingHighlightId = ref<number | null>(null);
// Computed highlights from timeline events
const highlights = computed(() => {
return computeHighlights(
props.game.events,
duration.value,
localPlayer.value?.riotIdGameName || null,
highlightSettings.value,
);
});
// Handle highlight settings changes (re-compute is automatic via computed)
function onHighlightSettingsChanged(settings: HighlightSettings) {
highlightSettings.value = { ...settings };
}
// Seek to a highlight clip
function seekToHighlight(time: number) {
seekTo(time);
}
// Play a specific highlight clip
function playHighlightClip(clip: HighlightClip) {
playingHighlightId.value = clip.id;
seekTo(clip.start_time);
if (videoRef.value) {
videoRef.value.play();
}
}
// Play all highlights sequentially
function playAllHighlights() {
if (highlights.value.length === 0) return;
playingHighlightId.value = highlights.value[0].id;
seekTo(highlights.value[0].start_time);
if (videoRef.value) {
videoRef.value.play();
}
}
// Export a single highlight clip
async function exportHighlightClip(clip: HighlightClip) {
if (!videoPath.value) return;
exportingHighlightId.value = clip.id;
isExporting.value = true;
exportMessage.value = "Exporting highlight...";
try {
const result = await invoke<string>("export_clip", {
videoPath: videoPath.value,
startTime: clip.start_time,
endTime: clip.end_time,
outputName: `highlight_${props.game.recording_id}_${clip.highlight_type}_${clip.id}`,
});
exportMessage.value = `Exported: ${result}`;
} catch (error) {
exportMessage.value = `Export failed: ${error}`;
} finally {
isExporting.value = false;
exportingHighlightId.value = null;
}
}
// Export all highlight clips
async function exportAllHighlights() {
if (!videoPath.value || highlights.value.length === 0) return;
isExporting.value = true;
exportMessage.value = `Exporting ${highlights.value.length} highlights...`;
let exported = 0;
let failed = 0;
for (const clip of highlights.value) {
exportingHighlightId.value = clip.id;
try {
await invoke<string>("export_clip", {
videoPath: videoPath.value,
startTime: clip.start_time,
endTime: clip.end_time,
outputName: `highlight_${props.game.recording_id}_${clip.highlight_type}_${clip.id}`,
});
exported++;
} catch {
failed++;
}
}
exportMessage.value = `Exported ${exported} highlights${failed > 0 ? ` (${failed} failed)` : ""}`;
isExporting.value = false;
exportingHighlightId.value = null;
}
// Watch video time to auto-advance through highlights
watch(() => currentTime.value, (time) => {
if (playingHighlightId.value === null || !highlightsMode.value) return;
const currentClip = highlights.value.find(h => h.id === playingHighlightId.value);
if (!currentClip) return;
// If we've passed the end of the current clip, jump to the next one
if (time > currentClip.end_time + 0.5) {
const currentIndex = highlights.value.findIndex(h => h.id === playingHighlightId.value);
if (currentIndex < highlights.value.length - 1) {
const nextClip = highlights.value[currentIndex + 1];
playingHighlightId.value = nextClip.id;
seekTo(nextClip.start_time);
} else {
// Last clip finished
playingHighlightId.value = null;
if (videoRef.value) {
videoRef.value.pause();
}
}
}
});
// Get video URL for playback using Tauri's convertFileSrc
const videoUrl = computed(() => {
@@ -436,9 +563,38 @@ function handleKeydown(event: KeyboardEvent) {
case "o":
setClipEndPoint();
break;
case "h":
highlightsMode.value = !highlightsMode.value;
break;
case "n":
if (highlightsMode.value && highlights.value.length > 0) {
// Jump to next highlight
const nextHl = highlights.value.find(h => h.start_time > currentTime.value + 1);
if (nextHl) {
seekToHighlight(nextHl.start_time);
} else {
// Wrap to first
seekToHighlight(highlights.value[0].start_time);
}
}
break;
case "p":
if (highlightsMode.value && highlights.value.length > 0) {
// Jump to previous highlight
const prevHl = [...highlights.value].reverse().find(h => h.end_time < currentTime.value - 1);
if (prevHl) {
seekToHighlight(prevHl.start_time);
} else {
// Wrap to last
seekToHighlight(highlights.value[highlights.value.length - 1].start_time);
}
}
break;
case "Escape":
if (clipStart.value !== null || clipEnd.value !== null) {
clearClipPoints();
} else if (highlightsMode.value) {
highlightsMode.value = false;
} else {
emit("back");
}
@@ -486,6 +642,18 @@ onUnmounted(() => {
<span>Back to History</span>
</button>
<!-- Highlights Mode Toggle -->
<button
class="highlights-toggle"
:class="{ active: highlightsMode }"
@click="highlightsMode = !highlightsMode"
title="Toggle Highlights mode"
>
<span class="toggle-icon">🎬</span>
<span class="toggle-label">Highlights</span>
<span v-if="highlights.length > 0" class="toggle-badge">{{ highlights.length }}</span>
</button>
<div class="header-info">
<div class="header-champion">
<img
@@ -581,6 +749,22 @@ onUnmounted(() => {
}"
></div>
<!-- Highlight regions (shown in highlights mode) -->
<div
v-if="highlightsMode"
v-for="clip in highlights"
:key="'hl-' + clip.id"
class="highlight-region"
:class="{ active: playingHighlightId === clip.id }"
:style="{
left: `${(clip.start_time / duration) * 100}%`,
width: `${((clip.end_time - clip.start_time) / duration) * 100}%`,
backgroundColor: getHighlightTypeColor(clip.highlight_type),
}"
@click.stop="seekToHighlight(clip.start_time)"
:title="`${clip.title} (${formatDuration(clip.end_time - clip.start_time)})`"
></div>
<!-- Event markers -->
<div
v-for="(event, idx) in sortedEvents"
@@ -724,8 +908,8 @@ onUnmounted(() => {
</div>
</div>
<!-- Stats Sidebar -->
<div class="stats-sidebar">
<!-- Stats Sidebar (shown when NOT in highlights mode) -->
<div v-if="!highlightsMode" class="stats-sidebar">
<!-- Player Stats Panel -->
<div class="stats-panel">
<h3>Your Stats</h3>
@@ -839,6 +1023,24 @@ onUnmounted(() => {
</div>
</div>
</div>
<!-- Highlights Sidebar (shown when in highlights mode) -->
<div v-else class="stats-sidebar highlights-sidebar">
<HighlightsPanel
:highlights="highlights"
:current-video-time="currentTime"
:video-duration="duration"
:is-playing="isPlaying"
:is-exporting="isExporting"
:exporting-clip-id="exportingHighlightId"
@seek-to="seekToHighlight"
@play-clip="playHighlightClip"
@play-all="playAllHighlights"
@export-clip="exportHighlightClip"
@export-all="exportAllHighlights"
@settings-changed="onHighlightSettingsChanged"
/>
</div>
</div>
</div>
@@ -885,6 +1087,57 @@ onUnmounted(() => {
font-size: 1.2rem;
}
/* Highlights Mode Toggle */
.highlights-toggle {
display: flex;
align-items: center;
gap: 0.4rem;
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(255, 255, 255, 0.12);
color: #94a3b8;
padding: 0.45rem 0.85rem;
border-radius: 6px;
cursor: pointer;
transition: all 0.2s;
font-size: 0.85rem;
font-weight: 500;
}
.highlights-toggle:hover {
background: rgba(255, 255, 255, 0.1);
color: #e2e8f0;
}
.highlights-toggle.active {
background: rgba(249, 115, 22, 0.15);
border-color: rgba(249, 115, 22, 0.4);
color: #f97316;
}
.highlights-toggle .toggle-icon {
font-size: 1rem;
}
.highlights-toggle .toggle-label {
font-size: 0.85rem;
}
.highlights-toggle .toggle-badge {
font-size: 0.7rem;
font-weight: 700;
background: rgba(249, 115, 22, 0.3);
color: #f97316;
padding: 0.1rem 0.4rem;
border-radius: 8px;
min-width: 1.2rem;
text-align: center;
}
.highlights-toggle.active .toggle-badge {
background: rgba(249, 115, 22, 0.5);
color: #fff;
}
.header-info {
display: flex;
align-items: center;
@@ -1106,6 +1359,26 @@ onUnmounted(() => {
pointer-events: none;
}
/* Highlight regions on timeline */
.highlight-region {
position: absolute;
top: 0;
height: 100%;
opacity: 0.2;
border-radius: 2px;
cursor: pointer;
transition: opacity 0.2s;
z-index: 0;
}
.highlight-region:hover {
opacity: 0.4;
}
.highlight-region.active {
opacity: 0.45;
}
.event-marker {
position: absolute;
top: 50%;

View File

@@ -0,0 +1,721 @@
<script setup lang="ts">
import { ref, computed, watch } from "vue";
import type {
HighlightClip,
HighlightSettings,
HighlightType,
} from "../types/timeline";
import {
DEFAULT_HIGHLIGHT_SETTINGS,
getHighlightTypeColor,
getHighlightTypeIcon,
formatDuration,
} from "../types/timeline";
// Props
const props = defineProps<{
highlights: HighlightClip[];
currentVideoTime: number;
videoDuration: number;
isPlaying: boolean;
isExporting: boolean;
exportingClipId: number | null;
}>();
// Emits
const emit = defineEmits<{
(e: "seek-to", time: number): void;
(e: "play-clip", clip: HighlightClip): void;
(e: "play-all"): void;
(e: "export-clip", clip: HighlightClip): void;
(e: "export-all"): void;
(e: "settings-changed", settings: HighlightSettings): void;
}>();
// Settings panel state
const showSettings = ref(false);
const settings = ref<HighlightSettings>({ ...DEFAULT_HIGHLIGHT_SETTINGS });
// Type filter toggles
const typeToggles = ref<Record<HighlightType, boolean>>({
kill: DEFAULT_HIGHLIGHT_SETTINGS.included_types.includes("kill"),
death: DEFAULT_HIGHLIGHT_SETTINGS.included_types.includes("death"),
assist: DEFAULT_HIGHLIGHT_SETTINGS.included_types.includes("assist"),
objective: DEFAULT_HIGHLIGHT_SETTINGS.included_types.includes("objective"),
multi_kill: DEFAULT_HIGHLIGHT_SETTINGS.included_types.includes("multi_kill"),
});
// Watch type toggles and update settings
watch(
typeToggles,
(toggles) => {
const included_types = (Object.entries(toggles) as [HighlightType, boolean][])
.filter(([, enabled]) => enabled)
.map(([type]) => type);
settings.value = { ...settings.value, included_types };
emit("settings-changed", settings.value);
},
{ deep: true },
);
// Watch buffer settings and emit
watch(
() => [settings.value.buffer_before, settings.value.buffer_after, settings.value.merge_overlapping, settings.value.merge_gap_secs],
() => {
emit("settings-changed", settings.value);
},
);
// Currently active clip (the one whose time range contains the current video time)
const activeClipId = computed(() => {
for (const clip of props.highlights) {
if (
props.currentVideoTime >= clip.start_time &&
props.currentVideoTime <= clip.end_time
) {
return clip.id;
}
}
return null;
});
// Stats
const killCount = computed(() => props.highlights.filter(h => h.highlight_type === "kill").length);
const deathCount = computed(() => props.highlights.filter(h => h.highlight_type === "death").length);
const objectiveCount = computed(() => props.highlights.filter(h => h.highlight_type === "objective").length);
const multiKillCount = computed(() => props.highlights.filter(h => h.highlight_type === "multi_kill").length);
const assistCount = computed(() => props.highlights.filter(h => h.highlight_type === "assist").length);
const totalDuration = computed(() => {
const total = props.highlights.reduce((sum, h) => sum + (h.end_time - h.start_time), 0);
return total;
});
// Seek to the start of a clip
function seekToClip(clip: HighlightClip) {
emit("seek-to", clip.start_time);
}
// Play a specific clip
function playClip(clip: HighlightClip) {
emit("play-clip", clip);
}
// Export a single clip
function exportClip(clip: HighlightClip) {
emit("export-clip", clip);
}
// Format clip time range
function formatClipTime(clip: HighlightClip): string {
return `${formatDuration(clip.start_time)} - ${formatDuration(clip.end_time)}`;
}
// Format clip duration
function formatClipDuration(clip: HighlightClip): string {
return formatDuration(clip.end_time - clip.start_time);
}
</script>
<template>
<div class="highlights-panel">
<!-- Header -->
<div class="highlights-header">
<h3>
<span class="header-icon">🎬</span>
Highlights
</h3>
<button
class="settings-toggle"
:class="{ active: showSettings }"
@click="showSettings = !showSettings"
title="Highlight settings"
>
</button>
</div>
<!-- Settings Panel (collapsible) -->
<div v-if="showSettings" class="settings-panel">
<div class="settings-group">
<label class="settings-label">Buffer Before</label>
<div class="slider-row">
<input
type="range"
min="2"
max="30"
step="1"
v-model.number="settings.buffer_before"
class="settings-slider"
/>
<span class="slider-value">{{ settings.buffer_before }}s</span>
</div>
</div>
<div class="settings-group">
<label class="settings-label">Buffer After</label>
<div class="slider-row">
<input
type="range"
min="2"
max="30"
step="1"
v-model.number="settings.buffer_after"
class="settings-slider"
/>
<span class="slider-value">{{ settings.buffer_after }}s</span>
</div>
</div>
<div class="settings-group">
<label class="settings-label">Include Types</label>
<div class="type-toggles">
<label
v-for="typeInfo in [
{ type: 'kill' as HighlightType, label: 'Kills', icon: '⚔️' },
{ type: 'death' as HighlightType, label: 'Deaths', icon: '💀' },
{ type: 'assist' as HighlightType, label: 'Assists', icon: '🤝' },
{ type: 'objective' as HighlightType, label: 'Objectives', icon: '🏰' },
{ type: 'multi_kill' as HighlightType, label: 'Multi Kills', icon: '🔥' },
]"
:key="typeInfo.type"
class="type-toggle"
:style="{ borderColor: typeToggles[typeInfo.type] ? getHighlightTypeColor(typeInfo.type) : 'rgba(255,255,255,0.1)' }"
>
<input
type="checkbox"
v-model="typeToggles[typeInfo.type]"
class="toggle-checkbox"
/>
<span class="toggle-icon">{{ typeInfo.icon }}</span>
<span class="toggle-label">{{ typeInfo.label }}</span>
</label>
</div>
</div>
<div class="settings-group">
<label class="settings-row-label">
<input
type="checkbox"
v-model="settings.merge_overlapping"
class="toggle-checkbox"
/>
Merge overlapping clips
</label>
</div>
<div v-if="settings.merge_overlapping" class="settings-group">
<label class="settings-label">Merge Gap</label>
<div class="slider-row">
<input
type="range"
min="0"
max="20"
step="1"
v-model.number="settings.merge_gap_secs"
class="settings-slider"
/>
<span class="slider-value">{{ settings.merge_gap_secs }}s</span>
</div>
</div>
</div>
<!-- Quick Stats -->
<div class="highlights-stats">
<div class="stat-pill" v-if="killCount > 0">
<span>⚔️</span> {{ killCount }}
</div>
<div class="stat-pill" v-if="deathCount > 0">
<span>💀</span> {{ deathCount }}
</div>
<div class="stat-pill" v-if="assistCount > 0">
<span>🤝</span> {{ assistCount }}
</div>
<div class="stat-pill" v-if="objectiveCount > 0">
<span>🏰</span> {{ objectiveCount }}
</div>
<div class="stat-pill" v-if="multiKillCount > 0">
<span>🔥</span> {{ multiKillCount }}
</div>
<div class="stat-pill total">
<span>⏱️</span> {{ formatDuration(totalDuration) }}
</div>
</div>
<!-- Action Buttons -->
<div class="highlights-actions">
<button
class="action-btn play-all"
@click="emit('play-all')"
:disabled="highlights.length === 0"
>
▶ Play All
</button>
<button
class="action-btn export-all"
@click="emit('export-all')"
:disabled="highlights.length === 0 || isExporting"
>
{{ isExporting ? "Exporting..." : "📦 Export All" }}
</button>
</div>
<!-- Empty State -->
<div v-if="highlights.length === 0" class="empty-highlights">
<div class="empty-icon">🎬</div>
<p>No highlights detected</p>
<p class="empty-hint">Highlights are generated from kills, deaths, and objectives during the game.</p>
</div>
<!-- Highlights List -->
<div v-else class="highlights-list">
<div
v-for="clip in highlights"
:key="clip.id"
class="highlight-card"
:class="{
active: activeClipId === clip.id,
[clip.highlight_type]: true,
}"
@click="seekToClip(clip)"
>
<div class="highlight-indicator" :style="{ backgroundColor: getHighlightTypeColor(clip.highlight_type) }"></div>
<div class="highlight-content">
<div class="highlight-title-row">
<span class="highlight-icon">{{ getHighlightTypeIcon(clip.highlight_type) }}</span>
<span class="highlight-title">{{ clip.title }}</span>
<span class="highlight-duration">{{ formatClipDuration(clip) }}</span>
</div>
<div v-if="clip.subtitle" class="highlight-subtitle">
{{ clip.subtitle }}
</div>
<div class="highlight-time-row">
<span class="highlight-time">{{ formatClipTime(clip) }}</span>
</div>
</div>
<div class="highlight-actions">
<button
class="mini-btn play"
@click.stop="playClip(clip)"
title="Play this clip"
>
</button>
<button
class="mini-btn export"
@click.stop="exportClip(clip)"
:disabled="isExporting && exportingClipId === clip.id"
title="Export this clip"
>
💾
</button>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.highlights-panel {
display: flex;
flex-direction: column;
gap: 0.75rem;
height: 100%;
}
/* Header */
.highlights-header {
display: flex;
align-items: center;
justify-content: space-between;
}
.highlights-header h3 {
margin: 0;
font-size: 1rem;
color: #c8aa6e;
display: flex;
align-items: center;
gap: 0.5rem;
}
.header-icon {
font-size: 1.1rem;
}
.settings-toggle {
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 6px;
padding: 0.3rem 0.5rem;
cursor: pointer;
font-size: 0.85rem;
transition: all 0.2s;
line-height: 1;
}
.settings-toggle:hover {
background: rgba(255, 255, 255, 0.1);
}
.settings-toggle.active {
background: rgba(200, 170, 110, 0.15);
border-color: #c8aa6e;
}
/* Settings Panel */
.settings-panel {
background: rgba(0, 0, 0, 0.2);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 8px;
padding: 0.75rem;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.settings-group {
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.settings-label {
font-size: 0.75rem;
color: #888;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.settings-row-label {
font-size: 0.8rem;
color: #ccc;
display: flex;
align-items: center;
gap: 0.5rem;
cursor: pointer;
}
.slider-row {
display: flex;
align-items: center;
gap: 0.5rem;
}
.settings-slider {
flex: 1;
height: 4px;
-webkit-appearance: none;
background: rgba(255, 255, 255, 0.15);
border-radius: 2px;
cursor: pointer;
}
.settings-slider::-webkit-slider-thumb {
-webkit-appearance: none;
width: 14px;
height: 14px;
background: #c8aa6e;
border-radius: 50%;
cursor: pointer;
}
.slider-value {
font-family: monospace;
font-size: 0.8rem;
color: #c8aa6e;
min-width: 2.5rem;
text-align: right;
}
.type-toggles {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
}
.type-toggle {
display: flex;
align-items: center;
gap: 0.3rem;
padding: 0.3rem 0.6rem;
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 6px;
cursor: pointer;
transition: all 0.2s;
font-size: 0.75rem;
}
.type-toggle:hover {
background: rgba(255, 255, 255, 0.05);
}
.toggle-checkbox {
display: none;
}
.toggle-icon {
font-size: 0.85rem;
}
.toggle-label {
color: #ccc;
}
/* Quick Stats */
.highlights-stats {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
}
.stat-pill {
display: flex;
align-items: center;
gap: 0.25rem;
padding: 0.25rem 0.6rem;
background: rgba(255, 255, 255, 0.05);
border-radius: 12px;
font-size: 0.75rem;
color: #ccc;
}
.stat-pill.total {
color: #c8aa6e;
background: rgba(200, 170, 110, 0.1);
}
/* Action Buttons */
.highlights-actions {
display: flex;
gap: 0.5rem;
}
.action-btn {
flex: 1;
padding: 0.5rem 0.75rem;
border: 1px solid rgba(255, 255, 255, 0.15);
border-radius: 6px;
cursor: pointer;
font-size: 0.8rem;
font-weight: 500;
transition: all 0.2s;
text-align: center;
}
.action-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.action-btn.play-all {
background: rgba(74, 222, 128, 0.15);
border-color: rgba(74, 222, 128, 0.3);
color: #4ade80;
}
.action-btn.play-all:hover:not(:disabled) {
background: rgba(74, 222, 128, 0.25);
}
.action-btn.export-all {
background: rgba(200, 170, 110, 0.15);
border-color: rgba(200, 170, 110, 0.3);
color: #c8aa6e;
}
.action-btn.export-all:hover:not(:disabled) {
background: rgba(200, 170, 110, 0.25);
}
/* Empty State */
.empty-highlights {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 2rem 1rem;
color: #666;
text-align: center;
}
.empty-icon {
font-size: 2.5rem;
margin-bottom: 0.75rem;
opacity: 0.5;
}
.empty-highlights p {
margin: 0.25rem 0;
font-size: 0.85rem;
}
.empty-hint {
font-size: 0.75rem !important;
color: #555;
}
/* Highlights List */
.highlights-list {
flex: 1;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 0.4rem;
padding-right: 0.25rem;
}
.highlight-card {
display: flex;
align-items: stretch;
gap: 0;
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 8px;
cursor: pointer;
transition: all 0.2s;
overflow: hidden;
}
.highlight-card:hover {
background: rgba(255, 255, 255, 0.06);
border-color: rgba(255, 255, 255, 0.15);
}
.highlight-card.active {
background: rgba(255, 255, 255, 0.08);
border-color: rgba(255, 255, 255, 0.2);
}
.highlight-card.active.kill {
border-color: rgba(74, 222, 128, 0.4);
background: rgba(74, 222, 128, 0.08);
}
.highlight-card.active.death {
border-color: rgba(248, 113, 113, 0.4);
background: rgba(248, 113, 113, 0.08);
}
.highlight-card.active.assist {
border-color: rgba(167, 139, 250, 0.4);
background: rgba(167, 139, 250, 0.08);
}
.highlight-card.active.objective {
border-color: rgba(251, 191, 36, 0.4);
background: rgba(251, 191, 36, 0.08);
}
.highlight-card.active.multi_kill {
border-color: rgba(249, 115, 22, 0.4);
background: rgba(249, 115, 22, 0.08);
}
.highlight-indicator {
width: 4px;
min-height: 100%;
flex-shrink: 0;
}
.highlight-content {
flex: 1;
padding: 0.5rem 0.65rem;
min-width: 0;
}
.highlight-title-row {
display: flex;
align-items: center;
gap: 0.35rem;
}
.highlight-icon {
font-size: 0.85rem;
flex-shrink: 0;
}
.highlight-title {
font-size: 0.85rem;
font-weight: 600;
color: #fff;
flex: 1;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.highlight-duration {
font-family: monospace;
font-size: 0.7rem;
color: #888;
flex-shrink: 0;
}
.highlight-subtitle {
font-size: 0.72rem;
color: #999;
margin-top: 0.15rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.highlight-time-row {
margin-top: 0.2rem;
}
.highlight-time {
font-family: monospace;
font-size: 0.65rem;
color: #666;
}
.highlight-actions {
display: flex;
flex-direction: column;
justify-content: center;
gap: 0.25rem;
padding: 0.35rem 0.5rem;
opacity: 0;
transition: opacity 0.2s;
}
.highlight-card:hover .highlight-actions,
.highlight-card.active .highlight-actions {
opacity: 1;
}
.mini-btn {
background: rgba(255, 255, 255, 0.08);
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 4px;
padding: 0.2rem 0.4rem;
cursor: pointer;
font-size: 0.7rem;
transition: all 0.15s;
line-height: 1;
}
.mini-btn:hover {
background: rgba(255, 255, 255, 0.15);
}
.mini-btn.play:hover {
background: rgba(74, 222, 128, 0.2);
border-color: rgba(74, 222, 128, 0.3);
}
.mini-btn.export:hover {
background: rgba(200, 170, 110, 0.2);
border-color: rgba(200, 170, 110, 0.3);
}
.mini-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
</style>

View File

@@ -556,16 +556,16 @@ export function formatNumber(num: number): string {
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';
return 'https://ddragon.leagueoflegends.com/cdn/16.10.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.10.1/img/champion/${formattedName}_0.jpg`;
}
return `https://ddragon.leagueoflegends.com/cdn/16.6.1/img/champion/${formattedName}.png`;
return `https://ddragon.leagueoflegends.com/cdn/16.10.1/img/champion/${formattedName}.png`;
}
/**
@@ -687,6 +687,364 @@ export type EventCategory =
| "phase"
| "unknown";
// ---------------------------------------------------------------------------
// Highlights
// ---------------------------------------------------------------------------
/**
* The type of highlight clip.
*/
export type HighlightType = "kill" | "death" | "assist" | "objective" | "multi_kill";
/**
* Configuration for highlight generation.
*/
export interface HighlightSettings {
/** Seconds before the event to include in the clip. */
buffer_before: number;
/** Seconds after the event to include in the clip. */
buffer_after: number;
/** Minimum clip duration in seconds. */
min_duration: number;
/** Which highlight types to include. */
included_types: HighlightType[];
/** Whether to merge overlapping clips into one. */
merge_overlapping: boolean;
/** Gap in seconds below which adjacent clips are merged (when merge_overlapping is true). */
merge_gap_secs: number;
}
/**
* Default highlight settings.
*/
export const DEFAULT_HIGHLIGHT_SETTINGS: HighlightSettings = {
buffer_before: 10,
buffer_after: 8,
min_duration: 5,
included_types: ["kill", "death", "objective", "multi_kill"],
merge_overlapping: true,
merge_gap_secs: 5,
};
/**
* A single highlight clip derived from one or more timeline events.
*/
export interface HighlightClip {
/** Unique ID for this clip (index-based). */
id: number;
/** Start time in seconds (video time). */
start_time: number;
/** End time in seconds (video time). */
end_time: number;
/** The primary highlight type. */
highlight_type: HighlightType;
/** All timeline events that contributed to this clip. */
events: TimestampedEvent[];
/** Human-readable title. */
title: string;
/** Human-readable subtitle (e.g. "Double Kill"). */
subtitle: string | null;
}
/**
* Determine the highlight type of a kill event based on player involvement.
*/
export function getKillHighlightType(
event: TimestampedEvent,
localPlayerName: string | null,
): HighlightType | null {
const rawData = event.raw_data as {
KillerName?: string;
VictimName?: string;
Assisters?: string[];
} | null;
if (!rawData) return null;
const killer = rawData.KillerName;
const victim = rawData.VictimName;
const assisters = rawData.Assisters || [];
if (!localPlayerName) return null;
if (killer === localPlayerName) return "kill";
if (victim === localPlayerName) return "death";
if (assisters.includes(localPlayerName)) return "assist";
return null; // Not involved — skip
}
/**
* Check if an objective event is relevant (always include objectives).
*/
export function isObjectiveHighlight(event: TimestampedEvent): boolean {
return event.event_type === "objective";
}
/**
* Detect multi-kills from a sequence of kill events.
* Returns groups of kills that happened within `windowSecs` seconds.
*/
export function detectMultiKills(
killEvents: TimestampedEvent[],
windowSecs: number = 12,
): TimestampedEvent[][] {
if (killEvents.length === 0) return [];
const sorted = [...killEvents].sort((a, b) => {
const aTime = a.video_timestamp[0] + a.video_timestamp[1] / 1e9;
const bTime = b.video_timestamp[0] + b.video_timestamp[1] / 1e9;
return aTime - bTime;
});
const groups: TimestampedEvent[][] = [];
let currentGroup: TimestampedEvent[] = [sorted[0]];
for (let i = 1; i < sorted.length; i++) {
const prevTime =
currentGroup[currentGroup.length - 1].video_timestamp[0] +
currentGroup[currentGroup.length - 1].video_timestamp[1] / 1e9;
const currTime = sorted[i].video_timestamp[0] + sorted[i].video_timestamp[1] / 1e9;
if (currTime - prevTime <= windowSecs) {
currentGroup.push(sorted[i]);
} else {
if (currentGroup.length >= 2) {
groups.push(currentGroup);
}
currentGroup = [sorted[i]];
}
}
if (currentGroup.length >= 2) {
groups.push(currentGroup);
}
return groups;
}
/**
* Get the multi-kill label based on number of kills.
*/
export function getMultiKillLabel(count: number): string | null {
switch (count) {
case 2: return "Double Kill";
case 3: return "Triple Kill";
case 4: return "Quadra Kill";
case 5: return "Penta Kill";
default: return count > 5 ? `${count}x Kill` : null;
}
}
/**
* Get the highlight type color for display.
*/
export function getHighlightTypeColor(type: HighlightType): string {
switch (type) {
case "kill": return "#4ade80"; // green
case "death": return "#f87171"; // red
case "assist": return "#a78bfa"; // purple
case "objective": return "#fbbf24"; // yellow/gold
case "multi_kill": return "#f97316"; // orange
}
}
/**
* Get a short icon/emoji for a highlight type.
*/
export function getHighlightTypeIcon(type: HighlightType): string {
switch (type) {
case "kill": return "⚔️";
case "death": return "💀";
case "assist": return "🤝";
case "objective": return "🏰";
case "multi_kill": return "🔥";
}
}
/**
* Compute highlight clips from a game's timeline events.
*
* @param events - All timeline events for the game.
* @param duration - Total video duration in seconds.
* @param localPlayerName - The local player's summoner name (for kill/death classification).
* @param settings - Highlight generation settings.
* @returns Array of HighlightClip sorted by start_time.
*/
export function computeHighlights(
events: TimestampedEvent[],
duration: number,
localPlayerName: string | null,
settings: HighlightSettings = DEFAULT_HIGHLIGHT_SETTINGS,
): HighlightClip[] {
const clips: HighlightClip[] = [];
let clipId = 0;
// Collect individual kill/assist/death events
const playerKills: TimestampedEvent[] = [];
const playerDeaths: TimestampedEvent[] = [];
const playerAssists: TimestampedEvent[] = [];
const objectiveEvents: TimestampedEvent[] = [];
for (const event of events) {
if (event.event_type === "kill") {
const type = getKillHighlightType(event, localPlayerName);
if (type === "kill" && settings.included_types.includes("kill")) {
playerKills.push(event);
} else if (type === "death" && settings.included_types.includes("death")) {
playerDeaths.push(event);
} else if (type === "assist" && settings.included_types.includes("assist")) {
playerAssists.push(event);
}
} else if (event.event_type === "objective" && settings.included_types.includes("objective")) {
objectiveEvents.push(event);
}
}
// Create clips for individual kills
for (const event of playerKills) {
const eventTime = event.video_timestamp[0] + event.video_timestamp[1] / 1e9;
const rawData = event.raw_data as { VictimName?: string } | null;
const victimName = rawData?.VictimName || "enemy";
clips.push({
id: clipId++,
start_time: Math.max(0, eventTime - settings.buffer_before),
end_time: Math.min(duration, eventTime + settings.buffer_after),
highlight_type: "kill",
events: [event],
title: `Kill`,
subtitle: `Killed ${victimName}`,
});
}
// Create clips for deaths
for (const event of playerDeaths) {
const eventTime = event.video_timestamp[0] + event.video_timestamp[1] / 1e9;
const rawData = event.raw_data as { KillerName?: string } | null;
const killerName = rawData?.KillerName || "enemy";
clips.push({
id: clipId++,
start_time: Math.max(0, eventTime - settings.buffer_before),
end_time: Math.min(duration, eventTime + settings.buffer_after),
highlight_type: "death",
events: [event],
title: `Death`,
subtitle: `Killed by ${killerName}`,
});
}
// Create clips for assists
for (const event of playerAssists) {
const eventTime = event.video_timestamp[0] + event.video_timestamp[1] / 1e9;
const rawData = event.raw_data as { KillerName?: string; VictimName?: string } | null;
const killerName = rawData?.KillerName || "ally";
const victimName = rawData?.VictimName || "enemy";
clips.push({
id: clipId++,
start_time: Math.max(0, eventTime - settings.buffer_before),
end_time: Math.min(duration, eventTime + settings.buffer_after),
highlight_type: "assist",
events: [event],
title: `Assist`,
subtitle: `Assisted ${killerName} vs ${victimName}`,
});
}
// Create clips for objectives
for (const event of objectiveEvents) {
const eventTime = event.video_timestamp[0] + event.video_timestamp[1] / 1e9;
const rawData = event.raw_data as { EventName?: string; objectiveType?: string } | null;
const objName = rawData?.EventName || rawData?.objectiveType || "Objective";
clips.push({
id: clipId++,
start_time: Math.max(0, eventTime - settings.buffer_before),
end_time: Math.min(duration, eventTime + settings.buffer_after),
highlight_type: "objective",
events: [event],
title: `Objective`,
subtitle: objName.replace(/([A-Z])/g, " $1").trim(),
});
}
// Detect multi-kills and create clips for them (only if enabled)
if (settings.included_types.includes("multi_kill")) {
const multiKillGroups = detectMultiKills(playerKills);
for (const group of multiKillGroups) {
const firstTime = group[0].video_timestamp[0] + group[0].video_timestamp[1] / 1e9;
const lastTime =
group[group.length - 1].video_timestamp[0] +
group[group.length - 1].video_timestamp[1] / 1e9;
const label = getMultiKillLabel(group.length);
clips.push({
id: clipId++,
start_time: Math.max(0, firstTime - settings.buffer_before),
end_time: Math.min(duration, lastTime + settings.buffer_after),
highlight_type: "multi_kill",
events: group,
title: label || `${group.length}x Kill`,
subtitle: `${group.length} kills in quick succession`,
});
}
}
// Sort by start time
clips.sort((a, b) => a.start_time - b.start_time);
// Merge overlapping clips if enabled
if (settings.merge_overlapping && clips.length > 1) {
const merged: HighlightClip[] = [];
let current = { ...clips[0] };
for (let i = 1; i < clips.length; i++) {
const next = clips[i];
// Merge if overlapping or within merge gap
if (next.start_time <= current.end_time + settings.merge_gap_secs) {
// Extend current clip
current.end_time = Math.max(current.end_time, next.end_time);
current.events = [...current.events, ...next.events];
// Keep the "more important" highlight type
const typePriority: HighlightType[] = ["multi_kill", "kill", "objective", "death", "assist"];
const currentIdx = typePriority.indexOf(current.highlight_type);
const nextIdx = typePriority.indexOf(next.highlight_type);
if (nextIdx < currentIdx) {
current.highlight_type = next.highlight_type;
current.title = next.title;
current.subtitle = next.subtitle;
}
// If both are kills, check if combined they form a multi-kill
const killEvents = current.events.filter(e => e.event_type === "kill");
if (killEvents.length >= 2) {
const label = getMultiKillLabel(killEvents.length);
if (label) {
current.highlight_type = "multi_kill";
current.title = label;
current.subtitle = `${killEvents.length} kills`;
}
}
} else {
merged.push(current);
current = { ...next };
}
}
merged.push(current);
// Re-enforce minimum duration and re-ID
return merged
.filter(c => c.end_time - c.start_time >= settings.min_duration)
.map((c, idx) => ({ ...c, id: idx }));
}
// Filter by minimum duration and re-ID
return clips
.filter(c => c.end_time - c.start_time >= settings.min_duration)
.map((c, idx) => ({ ...c, id: idx }));
}
/**
* Get the category of an event for styling purposes.
*/