Files
leaguerecorder/tauri-app/src/components/HighlightsPanel.vue
T
vhaudiquet abb35b8fac
record-daemon / Build, check and test (push) Failing after 15m23s
fix: tryfix multikill grouping
2026-05-30 19:27:31 +02:00

722 lines
16 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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"),
multi_kill: DEFAULT_HIGHLIGHT_SETTINGS.included_types.includes("multi_kill"),
trade_kill: DEFAULT_HIGHLIGHT_SETTINGS.included_types.includes("trade_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 multiKillCount = computed(() => props.highlights.filter(h => h.highlight_type === "multi_kill").length);
const tradeKillCount = computed(() => props.highlights.filter(h => h.highlight_type === "trade_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: 'multi_kill' as HighlightType, label: 'Multi Kills', icon: '🔥' },
{ type: 'trade_kill' as HighlightType, label: 'Trade 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="multiKillCount > 0">
<span>🔥</span> {{ multiKillCount }}
</div>
<div class="stat-pill" v-if="tradeKillCount > 0">
<span>🔄</span> {{ tradeKillCount }}
</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 multi-kills 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%;
overflow: hidden;
padding: 1rem;
}
/* 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;
min-height: 0;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 0.4rem;
padding-right: 0.25rem;
}
.highlight-card {
display: flex;
align-items: stretch;
gap: 0;
min-height: 56px;
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;
flex-shrink: 0;
}
.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.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>