Spaces:
Running
Running
| --- | |
| interface Props { | |
| src: string; | |
| } | |
| const { src } = Astro.props; | |
| const id = `video-${Math.random().toString(36).slice(2, 9)}`; | |
| --- | |
| <div class="video-player" data-video-player={id}> | |
| <video id={id} src={src} controls muted preload="auto" playsinline style="width:100%; border-radius: 8px; display: block; background: #000;" /> | |
| <div class="speed-controls"> | |
| <span class="speed-label">Speed:</span> | |
| <button class="speed-btn active" data-speed="1">1x</button> | |
| <button class="speed-btn" data-speed="2">2x</button> | |
| <button class="speed-btn" data-speed="4">4x</button> | |
| <button class="speed-btn" data-speed="8">8x</button> | |
| <button class="speed-btn" data-speed="16">16x</button> | |
| </div> | |
| </div> | |
| <script> | |
| document.querySelectorAll<HTMLElement>('[data-video-player]').forEach(player => { | |
| const videoId = player.dataset.videoPlayer!; | |
| const video = document.getElementById(videoId) as HTMLVideoElement; | |
| if (!video) return; | |
| let speed = 1; | |
| let rafId: number | null = null; | |
| let lastTime: number | null = null; | |
| let seeking = false; | |
| function stopSeekLoop() { | |
| if (rafId !== null) { | |
| cancelAnimationFrame(rafId); | |
| rafId = null; | |
| } | |
| lastTime = null; | |
| seeking = false; | |
| } | |
| function startSeekLoop() { | |
| stopSeekLoop(); | |
| if (speed <= 2) return; | |
| seeking = true; | |
| video.pause(); | |
| lastTime = performance.now(); | |
| function step(now: number) { | |
| if (!seeking || lastTime === null) return; | |
| const dt = (now - lastTime) / 1000; | |
| lastTime = now; | |
| video.currentTime = Math.min(video.currentTime + dt * speed, video.duration); | |
| if (video.currentTime >= video.duration) { | |
| stopSeekLoop(); | |
| return; | |
| } | |
| rafId = requestAnimationFrame(step); | |
| } | |
| rafId = requestAnimationFrame(step); | |
| } | |
| player.querySelectorAll<HTMLButtonElement>('.speed-btn').forEach(btn => { | |
| btn.addEventListener('click', () => { | |
| speed = parseFloat(btn.dataset.speed || '1'); | |
| player.querySelectorAll('.speed-btn').forEach(b => b.classList.remove('active')); | |
| btn.classList.add('active'); | |
| if (speed <= 2) { | |
| stopSeekLoop(); | |
| video.playbackRate = speed; | |
| if (video.paused && video.currentTime < video.duration) video.play(); | |
| } else { | |
| video.playbackRate = 1; | |
| startSeekLoop(); | |
| } | |
| }); | |
| }); | |
| video.addEventListener('play', () => { | |
| if (speed > 2) startSeekLoop(); | |
| }); | |
| video.addEventListener('pause', () => { | |
| if (speed > 2 && !seeking) stopSeekLoop(); | |
| }); | |
| }); | |
| </script> | |
| <style> | |
| .video-player { | |
| position: relative; | |
| } | |
| .speed-controls { | |
| display: flex; | |
| align-items: center; | |
| gap: 6px; | |
| margin-top: 8px; | |
| margin-bottom: 4px; | |
| } | |
| .speed-label { | |
| font-size: 0.8rem; | |
| color: var(--text-color-secondary, #888); | |
| margin-right: 2px; | |
| } | |
| .speed-btn { | |
| font-size: 0.75rem; | |
| padding: 3px 10px; | |
| border-radius: 4px; | |
| border: 1px solid var(--border-color, #ddd); | |
| background: var(--surface-bg, #f5f5f5); | |
| color: var(--text-color, #333); | |
| cursor: pointer; | |
| transition: background 0.15s, border-color 0.15s; | |
| } | |
| .speed-btn:hover { | |
| border-color: var(--text-color-secondary, #888); | |
| } | |
| .speed-btn.active { | |
| background: var(--text-color, #333); | |
| color: var(--surface-bg, #fff); | |
| border-color: var(--text-color, #333); | |
| } | |
| </style> | |