tauri-app: add highlights mode
All checks were successful
record-daemon / Build, check and test (push) Successful in 2m4s
All checks were successful
record-daemon / Build, check and test (push) Successful in 2m4s
This commit is contained in:
@@ -1,8 +1,9 @@
|
|||||||
<script setup lang="ts">
|
<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 { invoke } from "@tauri-apps/api/core";
|
||||||
import { convertFileSrc } 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 {
|
import {
|
||||||
getGameResult,
|
getGameResult,
|
||||||
formatDuration,
|
formatDuration,
|
||||||
@@ -17,6 +18,9 @@ import {
|
|||||||
getLpChange,
|
getLpChange,
|
||||||
formatLpDelta,
|
formatLpDelta,
|
||||||
formatRank,
|
formatRank,
|
||||||
|
computeHighlights,
|
||||||
|
DEFAULT_HIGHLIGHT_SETTINGS,
|
||||||
|
getHighlightTypeColor,
|
||||||
} from "../types/timeline";
|
} from "../types/timeline";
|
||||||
|
|
||||||
// Props
|
// Props
|
||||||
@@ -51,6 +55,129 @@ const isExporting = ref(false);
|
|||||||
const exportProgress = ref(0);
|
const exportProgress = ref(0);
|
||||||
const exportMessage = ref<string | null>(null);
|
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
|
// Get video URL for playback using Tauri's convertFileSrc
|
||||||
const videoUrl = computed(() => {
|
const videoUrl = computed(() => {
|
||||||
@@ -436,9 +563,38 @@ function handleKeydown(event: KeyboardEvent) {
|
|||||||
case "o":
|
case "o":
|
||||||
setClipEndPoint();
|
setClipEndPoint();
|
||||||
break;
|
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":
|
case "Escape":
|
||||||
if (clipStart.value !== null || clipEnd.value !== null) {
|
if (clipStart.value !== null || clipEnd.value !== null) {
|
||||||
clearClipPoints();
|
clearClipPoints();
|
||||||
|
} else if (highlightsMode.value) {
|
||||||
|
highlightsMode.value = false;
|
||||||
} else {
|
} else {
|
||||||
emit("back");
|
emit("back");
|
||||||
}
|
}
|
||||||
@@ -486,6 +642,18 @@ onUnmounted(() => {
|
|||||||
<span>Back to History</span>
|
<span>Back to History</span>
|
||||||
</button>
|
</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-info">
|
||||||
<div class="header-champion">
|
<div class="header-champion">
|
||||||
<img
|
<img
|
||||||
@@ -581,6 +749,22 @@ onUnmounted(() => {
|
|||||||
}"
|
}"
|
||||||
></div>
|
></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 -->
|
<!-- Event markers -->
|
||||||
<div
|
<div
|
||||||
v-for="(event, idx) in sortedEvents"
|
v-for="(event, idx) in sortedEvents"
|
||||||
@@ -724,8 +908,8 @@ onUnmounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Stats Sidebar -->
|
<!-- Stats Sidebar (shown when NOT in highlights mode) -->
|
||||||
<div class="stats-sidebar">
|
<div v-if="!highlightsMode" class="stats-sidebar">
|
||||||
<!-- Player Stats Panel -->
|
<!-- Player Stats Panel -->
|
||||||
<div class="stats-panel">
|
<div class="stats-panel">
|
||||||
<h3>Your Stats</h3>
|
<h3>Your Stats</h3>
|
||||||
@@ -800,8 +984,8 @@ onUnmounted(() => {
|
|||||||
<span>Damage</span>
|
<span>Damage</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
v-for="player in teamPlayers"
|
v-for="player in teamPlayers"
|
||||||
:key="player.puuid"
|
:key="player.puuid"
|
||||||
class="team-row"
|
class="team-row"
|
||||||
:class="{ local: player.isLocalPlayer }"
|
:class="{ local: player.isLocalPlayer }"
|
||||||
@@ -821,8 +1005,8 @@ onUnmounted(() => {
|
|||||||
<h4 class="enemy-header">Enemy Team</h4>
|
<h4 class="enemy-header">Enemy Team</h4>
|
||||||
|
|
||||||
<div class="team-table enemy-table">
|
<div class="team-table enemy-table">
|
||||||
<div
|
<div
|
||||||
v-for="player in enemyPlayers"
|
v-for="player in enemyPlayers"
|
||||||
:key="player.puuid"
|
:key="player.puuid"
|
||||||
class="team-row enemy"
|
class="team-row enemy"
|
||||||
>
|
>
|
||||||
@@ -839,6 +1023,24 @@ onUnmounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
@@ -885,6 +1087,57 @@ onUnmounted(() => {
|
|||||||
font-size: 1.2rem;
|
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 {
|
.header-info {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -1106,6 +1359,26 @@ onUnmounted(() => {
|
|||||||
pointer-events: none;
|
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 {
|
.event-marker {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 50%;
|
top: 50%;
|
||||||
|
|||||||
721
tauri-app/src/components/HighlightsPanel.vue
Normal file
721
tauri-app/src/components/HighlightsPanel.vue
Normal 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>
|
||||||
@@ -556,16 +556,16 @@ export function formatNumber(num: number): string {
|
|||||||
export function getChampionImageUrl(championName: string | null, size: 'square' | 'loading' = 'square'): string {
|
export function getChampionImageUrl(championName: string | null, size: 'square' | 'loading' = 'square'): string {
|
||||||
if (!championName) {
|
if (!championName) {
|
||||||
// Return placeholder for unknown champion
|
// 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
|
// Data Dragon uses specific champion name formatting
|
||||||
const formattedName = formatChampionName(championName);
|
const formattedName = formatChampionName(championName);
|
||||||
|
|
||||||
if (size === 'loading') {
|
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"
|
| "phase"
|
||||||
| "unknown";
|
| "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.
|
* Get the category of an event for styling purposes.
|
||||||
*/
|
*/
|
||||||
|
|||||||
Reference in New Issue
Block a user