Spaces:
Running
Running
<template> | |
<div class="timeline-section"> | |
<div class="timeline-container"> | |
<div class="controls"> | |
<button class="play-pause-btn" @click="togglePlayPause"> | |
<!-- Triangle creux pour le bouton play --> | |
<svg v-if="!isPlaying" viewBox="0 0 24 24" width="20" height="20"> | |
<polygon points="5,3 19,12 5,21" fill="none" stroke="white" stroke-width="1.5"/> | |
</svg> | |
<!-- Icône pause --> | |
<svg v-else viewBox="0 0 24 24" width="20" height="20"> | |
<rect x="6" y="4" width="4" height="16" fill="white"/> | |
<rect x="14" y="4" width="4" height="16" fill="white"/> | |
</svg> | |
</button> | |
</div> | |
<div class="video-timeline"> | |
<div class="timeline-track"> | |
<div class="time-marker" :style="{ left: progressPercentage + '%' }"> | |
<div class="time-indicator">{{ formatTimeSimple(preciseTime) }}</div> | |
<div class="marker-head"></div> | |
<div class="marker-line"></div> | |
</div> | |
<!-- Suppression des marqueurs d'annotation --> | |
<div class="frames-container"> | |
<div | |
class="frame" | |
v-for="(thumbnail, index) in thumbnails" | |
:key="index" | |
:style="{ backgroundImage: `url(${thumbnail})` }" | |
></div> | |
</div> | |
<input | |
type="range" | |
min="0" | |
:max="duration" | |
step="0.01" | |
v-model="currentTime" | |
@input="seekVideo" | |
class="timeline-slider" | |
/> | |
</div> | |
</div> | |
<div class="timeline-tools"></div> | |
</div> | |
</div> | |
</template> | |
<script> | |
import { useVideoStore } from '@/stores/videoStore' | |
import { useAnnotationStore } from '@/stores/annotationStore' | |
export default { | |
name: 'TimelineSection', | |
data() { | |
return { | |
videoStore: useVideoStore(), | |
annotationStore: useAnnotationStore(), | |
isPlaying: false, | |
currentTime: 0, | |
duration: 100, // Valeur par défaut, sera mise à jour quand la vidéo sera chargée | |
videoElement: null, | |
thumbnails: [], | |
thumbnailCount: 6, | |
timeUpdateInterval: null, | |
keyboardListener: null, | |
unsubscribeTimeUpdate: null, | |
frameRate: 30, // Taux d'images par défaut, à mettre à jour lors du chargement de la vidéo | |
currentFrame: 0 // Numéro de frame actuel | |
} | |
}, | |
computed: { | |
progressPercentage() { | |
// Utiliser directement la valeur du store pour s'assurer que les mises à jour sont reflétées | |
const storeTime = this.videoStore.currentTime || this.currentTime | |
return (storeTime / this.duration) * 100 || 0 | |
}, | |
// Calculer le temps exact basé sur le numéro de frame | |
preciseTime() { | |
return this.currentFrame / this.frameRate | |
}, | |
// Récupérer toutes les frames qui ont des annotations | |
annotationFrames() { | |
return this.annotationStore.frameAnnotations || {} | |
} | |
}, | |
mounted() { | |
// Synchroniser le frameRate avec le store au démarrage | |
this.frameRate = this.videoStore.fps || 25 | |
// Ajouter un écouteur d'événement pour les touches Entrée et Espace | |
this.keyboardListener = (event) => { | |
if (event.key === ' ' || event.code === 'Space') { | |
// Empêcher le comportement par défaut (comme le défilement de la page avec la barre d'espace) | |
event.preventDefault(); | |
this.togglePlayPause(); | |
} | |
}; | |
document.addEventListener('keydown', this.keyboardListener); | |
// S'abonner aux changements de temps dans le store | |
this.unsubscribeTimeUpdate = this.videoStore.$subscribe((mutation, state) => { | |
if (state.currentTime !== this.currentTime) { | |
this.currentTime = state.currentTime | |
// Mettre à jour également le numéro de frame | |
this.currentFrame = this.getCurrentFrame() | |
} | |
}) | |
}, | |
watch: { | |
'videoStore.selectedVideo': { | |
handler(newVideo) { | |
if (newVideo) { | |
console.log('Nouvelle vidéo sélectionnée dans Timeline:', newVideo) | |
this.resetPlayer() | |
this.loadVideo(newVideo.path) | |
// Mettre à jour le frameRate dans le store d'annotation | |
if (this.annotationStore.currentSession) { | |
this.annotationStore.currentSession.videoId = newVideo.id || newVideo.path | |
this.annotationStore.currentSession.frameRate = this.frameRate | |
} | |
} | |
}, | |
immediate: true | |
}, | |
// Watcher pour les changements de FPS | |
'videoStore.fps': { | |
handler(newFps) { | |
if (newFps && newFps !== this.frameRate) { | |
console.log('FPS changé de', this.frameRate, 'à', newFps) | |
// Mettre à jour le frameRate | |
this.frameRate = newFps | |
// Recalculer la frame actuelle basée sur le nouveau FPS | |
this.currentFrame = this.getCurrentFrame() | |
// Mettre à jour le frameRate dans le store d'annotation | |
if (this.annotationStore.currentSession) { | |
this.annotationStore.currentSession.frameRate = this.frameRate | |
} | |
console.log('Frame actuelle après changement de FPS:', this.currentFrame) | |
} | |
}, | |
immediate: true | |
} | |
}, | |
methods: { | |
async loadVideo(videoPath) { | |
try { | |
// Utiliser la méthode du store pour charger la vidéo | |
const { duration, videoElement, frameRate } = await this.videoStore.loadVideoMetadata(videoPath) | |
// Mettre à jour les propriétés locales | |
this.duration = duration | |
this.videoElement = videoElement | |
// Utiliser le FPS du store plutôt que celui détecté de la vidéo | |
this.frameRate = this.videoStore.fps || frameRate || 25 | |
// Mettre à jour le frameRate dans le store d'annotation | |
if (this.annotationStore.currentSession) { | |
this.annotationStore.currentSession.frameRate = this.frameRate | |
} | |
// Générer les vignettes | |
this.generateThumbnails(videoElement) | |
} catch (error) { | |
console.error('Erreur lors du chargement de la vidéo:', error) | |
} | |
}, | |
// Calculer la position d'une frame sur la timeline (en pourcentage) | |
getFramePosition(frameNumber) { | |
const timeInSeconds = frameNumber / this.frameRate | |
return (timeInSeconds / this.duration) * 100 | |
}, | |
// Obtenir la couleur pour un marqueur d'annotation | |
getAnnotationColor(frameNumber) { | |
const annotations = this.annotationStore.getAnnotationsForFrame(frameNumber) | |
if (annotations.length > 0) { | |
// Utiliser la couleur du premier objet annoté | |
const objectId = annotations[0].objectId | |
const object = this.annotationStore.objects[objectId] | |
return object ? object.color : '#FFFFFF' | |
} | |
return '#FFFFFF' // Couleur par défaut | |
}, | |
async generateThumbnails(videoEl) { | |
this.thumbnails = [] | |
// Créer un canvas pour dessiner les vignettes | |
const canvas = document.createElement('canvas') | |
const ctx = canvas.getContext('2d') | |
// Définir la taille du canvas | |
canvas.width = 160 // Largeur de la vignette | |
canvas.height = 90 // Hauteur de la vignette (ratio 16:9) | |
// Générer les vignettes à intervalles réguliers | |
for (let i = 0; i < this.thumbnailCount; i++) { | |
const timePoint = (i / (this.thumbnailCount - 1)) * this.duration | |
// Positionner la vidéo au point temporel | |
videoEl.currentTime = timePoint | |
// Attendre que la vidéo soit positionnée | |
await new Promise(resolve => { | |
videoEl.addEventListener('seeked', resolve, { once: true }) | |
}) | |
// Dessiner l'image sur le canvas | |
ctx.drawImage(videoEl, 0, 0, canvas.width, canvas.height) | |
// Convertir le canvas en URL de données | |
const thumbnailUrl = canvas.toDataURL('image/jpeg') | |
this.thumbnails.push(thumbnailUrl) | |
} | |
}, | |
// Méthode pour aller à une frame spécifique | |
goToFrame(frameNumber) { | |
this.currentFrame = frameNumber | |
const preciseTime = frameNumber / this.frameRate | |
// Mettre à jour le temps avec une précision à l'image près | |
this.currentTime = preciseTime | |
this.videoStore.currentTime = preciseTime | |
if (this.videoElement) { | |
// Utiliser requestAnimationFrame pour s'assurer que le DOM est prêt | |
requestAnimationFrame(() => { | |
this.videoElement.currentTime = preciseTime | |
}) | |
} | |
// Mettre à jour l'interface | |
this.seekVideo() | |
}, | |
// Méthode pour obtenir le numéro de frame actuel à partir du temps | |
getCurrentFrame() { | |
// Utiliser Math.round au lieu de Math.floor pour une meilleure précision | |
return Math.round(this.currentTime * this.frameRate) | |
}, | |
togglePlayPause() { | |
this.isPlaying = !this.isPlaying | |
// Mettre à jour le store pour que VideoSection soit informé | |
this.videoStore.isPlaying = this.isPlaying | |
if (this.videoElement) { | |
if (this.isPlaying) { | |
// S'assurer que la vidéo commence à la position exacte de la frame actuelle | |
const preciseTime = this.currentFrame / this.frameRate | |
this.videoElement.currentTime = preciseTime | |
this.videoElement.play() | |
this.startTimeUpdate() | |
} else { | |
this.videoElement.pause() | |
this.stopTimeUpdate() | |
// Capturer le numéro de frame exact lors de la pause | |
this.currentFrame = this.getCurrentFrame() | |
} | |
} else { | |
// Mode simulation | |
if (this.isPlaying) { | |
// Commencer la simulation à partir de la frame actuelle | |
this.startTimeUpdate() | |
} else { | |
this.stopTimeUpdate() | |
// Capturer le numéro de frame exact lors de la pause | |
this.currentFrame = this.getCurrentFrame() | |
} | |
} | |
}, | |
seekVideo() { | |
// Calculer le numéro de frame exact | |
this.currentFrame = this.getCurrentFrame() | |
// Calculer le temps précis basé sur le numéro de frame | |
const preciseTime = this.currentFrame / this.frameRate | |
if (this.videoElement) { | |
this.videoElement.currentTime = preciseTime | |
} | |
// Mettre à jour le store avec le temps précis | |
this.videoStore.currentTime = preciseTime | |
}, | |
startTimeUpdate() { | |
// Utiliser requestAnimationFrame pour une meilleure fluidité | |
const updateTime = () => { | |
if (this.videoElement) { | |
this.currentTime = this.videoElement.currentTime | |
// Mettre à jour le numéro de frame actuel | |
this.currentFrame = this.getCurrentFrame() | |
// Mettre à jour le store à chaque frame | |
this.videoStore.currentTime = this.currentTime | |
} else { | |
// Simulation pour test - avancer d'une frame à la fois | |
this.currentFrame += 1 | |
this.currentTime = this.currentFrame / this.frameRate | |
// Vérifier si on a atteint la fin | |
if (this.currentTime >= this.duration) { | |
this.isPlaying = false | |
this.videoStore.isPlaying = false | |
this.stopTimeUpdate() | |
return | |
} | |
// Mettre à jour le store même en mode simulation | |
this.videoStore.currentTime = this.currentTime | |
} | |
if (this.isPlaying) { | |
this.animationFrameId = requestAnimationFrame(updateTime) | |
} | |
} | |
this.animationFrameId = requestAnimationFrame(updateTime) | |
}, | |
stopTimeUpdate() { | |
if (this.animationFrameId) { | |
cancelAnimationFrame(this.animationFrameId) | |
this.animationFrameId = null | |
} | |
}, | |
resetPlayer() { | |
this.isPlaying = false | |
this.currentTime = 0 | |
this.stopTimeUpdate() | |
this.thumbnails = [] | |
}, | |
formatTimeSimple(seconds) { | |
const secs = Math.floor(seconds) | |
const cs = Math.floor((seconds - secs) * 100) | |
// Ajouter le numéro de frame pour plus de précision | |
const frame = this.getCurrentFrame() | |
return `${secs}:${cs < 10 ? '0' : ''}${cs} (f:${frame})` | |
} | |
}, | |
beforeUnmount() { | |
this.stopTimeUpdate() | |
if (this.videoElement) { | |
this.videoElement.pause() | |
this.videoElement.src = '' | |
this.videoElement = null | |
} | |
// Supprimer l'écouteur d'événement lors du démontage du composant | |
if (this.keyboardListener) { | |
document.removeEventListener('keydown', this.keyboardListener); | |
} | |
// Désabonner de l'écoute des changements dans le store | |
if (this.unsubscribeTimeUpdate) { | |
this.unsubscribeTimeUpdate() | |
} | |
} | |
} | |
</script> | |
<style scoped> | |
.timeline-section { | |
padding-top: 25px; | |
width: 100%; | |
} | |
.timeline-container { | |
display: flex; | |
align-items: center; | |
gap: 20px; | |
height: 100%; | |
} | |
.controls { | |
display: flex; | |
align-items: center; | |
} | |
.play-pause-btn { | |
width: 40px; | |
height: 40px; | |
background: transparent; | |
border: none; | |
color: white; | |
cursor: pointer; | |
display: flex; | |
align-items: center; | |
justify-content: center; | |
padding: 0; | |
} | |
.video-timeline { | |
flex-grow: 1; | |
position: relative; | |
} | |
.timeline-track { | |
position: relative; | |
height: 50px; | |
} | |
.frames-container { | |
display: flex; | |
width: 100%; | |
height: 100%; | |
border-radius: 8px; | |
overflow: hidden; | |
} | |
.frame { | |
flex: 1; | |
height: 100%; | |
background-size: cover; | |
background-position: center; | |
border-right: 2px solid black; | |
} | |
.frame:last-child { | |
border-right: none; | |
} | |
.time-marker { | |
position: absolute; | |
bottom: 0; | |
transform: translateX(-50%); | |
z-index: 10; | |
display: flex; | |
flex-direction: column; | |
align-items: center; | |
} | |
.time-indicator { | |
color: white; | |
padding: 2px 6px; | |
border-radius: 4px; | |
font-size: 12px; | |
margin-bottom: 4px; | |
white-space: nowrap; | |
} | |
.marker-head { | |
width: 6px; | |
height: 3px; | |
background: white; | |
} | |
.marker-line { | |
width: 2px; | |
height: 50px; | |
background: white; | |
} | |
.timeline-slider { | |
position: absolute; | |
top: 0; | |
left: 0; | |
width: 100%; | |
height: 100%; | |
opacity: 0; | |
cursor: pointer; | |
z-index: 5; | |
} | |
.timeline-tools { | |
width: 50px; | |
height: 50px; | |
} | |
.annotation-marker { | |
position: absolute; | |
bottom: 0; | |
transform: translateX(-50%); | |
z-index: 8; | |
cursor: pointer; | |
} | |
.annotation-dot { | |
width: 8px; | |
height: 8px; | |
border-radius: 50%; | |
background-color: white; | |
margin-bottom: 2px; | |
} | |
</style> |