Initial commit: Momentry Studio v0.1.0

This commit is contained in:
2026-06-13 17:49:02 +08:00
commit 79e0a862d4
14019 changed files with 1129062 additions and 0 deletions

View File

@@ -0,0 +1,199 @@
<template>
<div v-if="visible" class="video-player-modal" @click.self="close">
<div class="video-player-box">
<button class="close-btn" @click="close">×</button>
<video ref="videoEl" class="video" controls autoplay @loadedmetadata="onLoaded" @timeupdate="onTimeUpdate">
<source :src="videoUrl" type="video/mp4" />
</video>
<div class="video-info">
<h3>{{ title }}</h3>
<div class="time-display">
<span>{{ formatTime(currentTime) }}</span>
<span class="separator">/</span>
<span>{{ formatTime(duration) }}</span>
<span v-if="hasRange" class="range">({{ formatTime(rangeStart) }} - {{ formatTime(rangeEnd) }})</span>
</div>
</div>
<div v-if="hasRange" class="segment-controls">
<button @click="seekToStart" class="seg-btn">⏮ 跳到起點</button>
<button @click="togglePlay" class="seg-btn">{{ isPlaying ? '⏸ 暫停' : '▶ 播放' }}</button>
<button @click="seekToEnd" class="seg-btn">跳到結尾 ⏭</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
const props = defineProps<{
fileUuid: string
startTime?: number
endTime?: number
title?: string
}>()
const emit = defineEmits(['close'])
const videoEl = ref<HTMLVideoElement | null>(null)
const visible = ref(true)
const currentTime = ref(0)
const duration = ref(0)
const isPlaying = ref(false)
const CORE_API = 'http://localhost:3002'
const API_KEY = 'muser_68600856036340bcafc01930eb4bd839_1774418104_97221b69'
const videoUrl = computed(() => {
const start = props.startTime ?? 0
const end = props.endTime ?? 99999
return `${CORE_API}/api/v1/file/${props.fileUuid}/video?api_key=${API_KEY}&start_time=${start}&end_time=${end}`
})
const hasRange = computed(() => {
return props.startTime !== undefined && props.endTime !== undefined
})
const rangeStart = computed(() => props.startTime ?? 0)
const rangeEnd = computed(() => props.endTime ?? 0)
function close() {
visible.value = false
emit('close')
}
function onLoaded() {
if (videoEl.value && props.startTime) {
videoEl.value.currentTime = props.startTime
videoEl.value.play()
isPlaying.value = true
}
}
function onTimeUpdate() {
if (videoEl.value) {
currentTime.value = videoEl.value.currentTime
duration.value = videoEl.value.duration || 0
// 如果播放到 range end,暫停
if (hasRange.value && videoEl.value.currentTime >= (props.endTime ?? 0)) {
videoEl.value.pause()
isPlaying.value = false
}
}
}
function seekToStart() {
if (videoEl.value && props.startTime) {
videoEl.value.currentTime = props.startTime
videoEl.value.play()
isPlaying.value = true
}
}
function seekToEnd() {
if (videoEl.value && props.endTime) {
videoEl.value.currentTime = props.endTime - 1
videoEl.value.play()
isPlaying.value = true
}
}
function togglePlay() {
if (videoEl.value) {
if (videoEl.value.paused) {
videoEl.value.play()
isPlaying.value = true
} else {
videoEl.value.pause()
isPlaying.value = false
}
}
}
function formatTime(sec: number): string {
if (!sec || isNaN(sec)) return '0:00'
const m = Math.floor(sec / 60)
const s = Math.floor(sec % 60)
return `${m}:${s.toString().padStart(2, '0')}`
}
// 鍵盤快捷鍵
function onKeydown(e: KeyboardEvent) {
if (!visible.value) return
switch (e.key) {
case 'Escape':
close()
break
case ' ':
e.preventDefault()
togglePlay()
break
case 'ArrowLeft':
if (videoEl.value) videoEl.value.currentTime = Math.max(0, videoEl.value.currentTime - 5)
break
case 'ArrowRight':
if (videoEl.value) videoEl.value.currentTime = Math.min(duration.value, videoEl.value.currentTime + 5)
break
}
}
onMounted(() => {
document.addEventListener('keydown', onKeydown)
})
onUnmounted(() => {
document.removeEventListener('keydown', onKeydown)
})
</script>
<style scoped>
.video-player-modal {
position: fixed; inset: 0;
background: rgba(0,0,0,0.85);
display: flex; align-items: center; justify-content: center;
z-index: 2000;
animation: fadeIn 0.2s ease;
}
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
.video-player-box {
background: #111;
border-radius: 16px;
padding: 20px;
max-width: 90vw;
position: relative;
animation: slideUp 0.3s ease;
}
@keyframes slideUp { from { transform: translateY(20px); opacity: 0; } to { transform: translateY(0); opacity: 1; } }
.close-btn {
position: absolute; top: -12px; right: -12px;
width: 36px; height: 36px; border-radius: 50%;
background: #fff; border: none; font-size: 1.5rem;
cursor: pointer; z-index: 10;
display: flex; align-items: center; justify-content: center;
box-shadow: 0 2px 8px rgba(0,0,0,0.3);
}
.close-btn:hover { background: #f3f4f6; }
.video {
max-width: 80vw; max-height: 60vh;
border-radius: 8px; display: block;
}
.video-info {
color: #fff; margin-top: 16px; text-align: center;
}
.video-info h3 { font-size: 1rem; margin-bottom: 8px; font-weight: 500; }
.time-display { font-size: 0.85rem; color: #9ca3af; display: flex; align-items: center; justify-content: center; gap: 6px; }
.range { color: #4f46e5; }
.segment-controls {
display: flex; justify-content: center; gap: 12px; margin-top: 12px;
}
.seg-btn {
padding: 8px 16px; background: #1f2937; color: #fff;
border: 1px solid #374151; border-radius: 8px;
cursor: pointer; font-size: 0.85rem; transition: background 0.15s;
}
.seg-btn:hover { background: #374151; }
</style>