Some small improvements for timelapse videos; Added new video player with speed controls

This commit is contained in:
Martin Ziegler
2025-11-29 10:32:22 +01:00
parent eaeac8be93
commit 526de63acd
7 changed files with 600 additions and 412 deletions
+15
View File
@@ -1,8 +1,11 @@
from pathlib import Path
import zipfile
import io
import logging
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Request
logger = logging.getLogger(__name__)
from fastapi.responses import FileResponse, Response
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func
@@ -596,6 +599,18 @@ async def scan_timelapse(
if best_match and best_diff < timedelta(hours=2): # Within 2 hours
matching_file = best_match
# Strategy 3: If only one timelapse exists and archive was recently completed, use it
# This handles cases where printer clock is wrong or timezone issues exist
if not matching_file and len(mp4_files) == 1:
from datetime import datetime, timedelta
archive_completed = archive.completed_at or archive.created_at
if archive_completed:
time_since_completion = datetime.now() - archive_completed
# If archive was completed within the last hour, assume the single timelapse is for it
if time_since_completion < timedelta(hours=1):
matching_file = mp4_files[0]
logger.info(f"Using single timelapse file as fallback: {mp4_files[0].get('name')}")
if not matching_file:
return {"status": "not_found", "message": "No matching timelapse found on printer"}
+202
View File
@@ -0,0 +1,202 @@
import { useState, useRef, useEffect } from 'react';
import { X, Download, Film, Play, Pause, SkipBack, SkipForward } from 'lucide-react';
import { Button } from './Button';
interface TimelapseViewerProps {
src: string;
title: string;
downloadFilename: string;
onClose: () => void;
}
const SPEED_OPTIONS = [0.25, 0.5, 0.75, 1, 1.5, 2, 3, 4];
export function TimelapseViewer({ src, title, downloadFilename, onClose }: TimelapseViewerProps) {
const videoRef = useRef<HTMLVideoElement>(null);
const [isPlaying, setIsPlaying] = useState(true);
const [playbackRate, setPlaybackRate] = useState(0.5); // Default to 0.5x for timelapse
const [currentTime, setCurrentTime] = useState(0);
const [duration, setDuration] = useState(0);
useEffect(() => {
const video = videoRef.current;
if (video) {
video.playbackRate = playbackRate;
}
}, [playbackRate]);
useEffect(() => {
const video = videoRef.current;
if (!video) return;
const handleTimeUpdate = () => setCurrentTime(video.currentTime);
const handleDurationChange = () => setDuration(video.duration);
const handlePlay = () => setIsPlaying(true);
const handlePause = () => setIsPlaying(false);
video.addEventListener('timeupdate', handleTimeUpdate);
video.addEventListener('durationchange', handleDurationChange);
video.addEventListener('play', handlePlay);
video.addEventListener('pause', handlePause);
return () => {
video.removeEventListener('timeupdate', handleTimeUpdate);
video.removeEventListener('durationchange', handleDurationChange);
video.removeEventListener('play', handlePlay);
video.removeEventListener('pause', handlePause);
};
}, []);
const togglePlay = () => {
const video = videoRef.current;
if (!video) return;
if (isPlaying) {
video.pause();
} else {
video.play();
}
};
const handleSeek = (e: React.ChangeEvent<HTMLInputElement>) => {
const video = videoRef.current;
if (!video) return;
video.currentTime = parseFloat(e.target.value);
};
const skipBackward = () => {
const video = videoRef.current;
if (!video) return;
video.currentTime = Math.max(0, video.currentTime - 5);
};
const skipForward = () => {
const video = videoRef.current;
if (!video) return;
video.currentTime = Math.min(duration, video.currentTime + 5);
};
const formatTime = (time: number) => {
const minutes = Math.floor(time / 60);
const seconds = Math.floor(time % 60);
return `${minutes}:${seconds.toString().padStart(2, '0')}`;
};
const handleDownload = () => {
const link = document.createElement('a');
link.href = src;
link.download = downloadFilename;
link.click();
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/80">
<div className="relative bg-bambu-dark-secondary rounded-xl max-w-4xl w-full mx-4 overflow-hidden">
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-bambu-dark-tertiary">
<h3 className="text-lg font-semibold text-white flex items-center gap-2">
<Film className="w-5 h-5 text-bambu-green" />
{title}
</h3>
<div className="flex items-center gap-2">
<Button variant="secondary" size="sm" onClick={handleDownload}>
<Download className="w-4 h-4" />
Download
</Button>
<button
onClick={onClose}
className="p-1 hover:bg-bambu-dark-tertiary rounded transition-colors"
>
<X className="w-5 h-5 text-bambu-gray" />
</button>
</div>
</div>
{/* Video */}
<div className="p-4">
<video
ref={videoRef}
src={src}
autoPlay
className="w-full rounded-lg"
onClick={togglePlay}
/>
{/* Custom Controls */}
<div className="mt-4 space-y-3">
{/* Progress bar */}
<div className="flex items-center gap-3">
<span className="text-xs text-bambu-gray w-12 text-right">
{formatTime(currentTime)}
</span>
<input
type="range"
min={0}
max={duration || 100}
value={currentTime}
onChange={handleSeek}
className="flex-1 h-1 bg-bambu-dark-tertiary rounded-lg appearance-none cursor-pointer
[&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-3 [&::-webkit-slider-thumb]:h-3
[&::-webkit-slider-thumb]:bg-bambu-green [&::-webkit-slider-thumb]:rounded-full
[&::-webkit-slider-thumb]:cursor-pointer"
/>
<span className="text-xs text-bambu-gray w-12">
{formatTime(duration)}
</span>
</div>
{/* Playback controls */}
<div className="flex items-center justify-between">
{/* Left: Play controls */}
<div className="flex items-center gap-2">
<button
onClick={skipBackward}
className="p-2 hover:bg-bambu-dark-tertiary rounded-lg transition-colors"
title="Skip back 5s"
>
<SkipBack className="w-5 h-5 text-bambu-gray" />
</button>
<button
onClick={togglePlay}
className="p-2 bg-bambu-green hover:bg-bambu-green-dark rounded-lg transition-colors"
>
{isPlaying ? (
<Pause className="w-5 h-5 text-white" />
) : (
<Play className="w-5 h-5 text-white" />
)}
</button>
<button
onClick={skipForward}
className="p-2 hover:bg-bambu-dark-tertiary rounded-lg transition-colors"
title="Skip forward 5s"
>
<SkipForward className="w-5 h-5 text-bambu-gray" />
</button>
</div>
{/* Right: Speed control */}
<div className="flex items-center gap-2">
<span className="text-sm text-bambu-gray">Speed:</span>
<div className="flex gap-1">
{SPEED_OPTIONS.map((speed) => (
<button
key={speed}
onClick={() => setPlaybackRate(speed)}
className={`px-2 py-1 text-xs rounded transition-colors ${
playbackRate === speed
? 'bg-bambu-green text-white'
: 'bg-bambu-dark-tertiary text-bambu-gray hover:bg-bambu-dark-tertiary/80'
}`}
>
{speed}x
</button>
))}
</div>
</div>
</div>
</div>
</div>
</div>
</div>
);
}
+7 -39
View File
@@ -50,6 +50,7 @@ import { CalendarView } from '../components/CalendarView';
import { QRCodeModal } from '../components/QRCodeModal';
import { PhotoGalleryModal } from '../components/PhotoGalleryModal';
import { ProjectPageModal } from '../components/ProjectPageModal';
import { TimelapseViewer } from '../components/TimelapseViewer';
import { useToast } from '../contexts/ToastContext';
function formatFileSize(bytes: number): string {
@@ -565,45 +566,12 @@ function ArchiveCard({
{/* Timelapse Viewer Modal */}
{showTimelapse && archive.timelapse_path && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/80">
<div className="relative bg-bambu-dark-secondary rounded-xl max-w-4xl w-full mx-4 overflow-hidden">
<div className="flex items-center justify-between p-4 border-b border-bambu-dark-tertiary">
<h3 className="text-lg font-semibold text-white flex items-center gap-2">
<Film className="w-5 h-5 text-bambu-green" />
{archive.print_name || archive.filename} - Timelapse
</h3>
<div className="flex items-center gap-2">
<Button
variant="secondary"
size="sm"
onClick={() => {
const link = document.createElement('a');
link.href = api.getArchiveTimelapse(archive.id);
link.download = `${archive.print_name || archive.filename}_timelapse.mp4`;
link.click();
}}
>
<Download className="w-4 h-4" />
Download
</Button>
<button
onClick={() => setShowTimelapse(false)}
className="p-1 hover:bg-bambu-dark-tertiary rounded transition-colors"
>
<X className="w-5 h-5 text-bambu-gray" />
</button>
</div>
</div>
<div className="p-4">
<video
src={api.getArchiveTimelapse(archive.id)}
controls
autoPlay
className="w-full rounded-lg"
/>
</div>
</div>
</div>
<TimelapseViewer
src={api.getArchiveTimelapse(archive.id)}
title={`${archive.print_name || archive.filename} - Timelapse`}
downloadFilename={`${archive.print_name || archive.filename}_timelapse.mp4`}
onClose={() => setShowTimelapse(false)}
/>
)}
{/* QR Code Modal */}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -7,8 +7,8 @@
<link rel="icon" type="image/png" sizes="32x32" href="/img/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="/img/favicon-16x16.png" />
<link rel="apple-touch-icon" sizes="180x180" href="/img/apple-touch-icon.png" />
<script type="module" crossorigin src="/assets/index-BPRATuOd.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DUX4pLTn.css">
<script type="module" crossorigin src="/assets/index-in5STeRb.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-q7064R9p.css">
</head>
<body>
<div id="root"></div>