tauri-app: add highlights mode
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
@@ -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>