mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-20 09:03:43 +02:00
Some small improvements for timelapse videos; Added new video player with speed controls
This commit is contained in:
@@ -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"}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user