722 lines
16 KiB
Vue
722 lines
16 KiB
Vue
<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>
|