Improvements to the video handling (download, gallery view etc..)

This commit is contained in:
Arnaud_Cayrol 2026-01-30 18:23:16 +01:00
parent 1854ecd8e0
commit 499094c25a
13 changed files with 123 additions and 31 deletions

View file

@ -18,6 +18,7 @@
// View state management for gallery
let currentView = $state('main'); // 'main' | 'gallery'
let galleryFolder = $state(null);
let savedScrollPosition = $state(0);
// Output folder management
let outputFolderRefreshKey = $state(0);
@ -27,13 +28,48 @@
jobStatus === 'running' || jobStatus === 'compiling_video' || jobStatus === 'cancelling'
);
// Match backend's sanitize_folder_name logic for video path construction
function sanitizeFolderName(name, id) {
const base = name && name.trim() ? name : id;
let sanitized = '';
for (const c of base) {
if (/^[\p{L}\p{N}\-_ ]$/u.test(c)) {
sanitized += c;
} else {
sanitized += '_';
}
}
sanitized = sanitized.trim();
return sanitized.length > 50 ? sanitized.slice(0, 50) : sanitized;
}
// Compute folder name from progress for video display
let completedFolderName = $derived(
progress.person_name || progress.person_id
? sanitizeFolderName(progress.person_name, progress.person_id)
: null
);
function handleFoldersLoaded(folders) {
outputFolders = folders;
}
function handleFolderDeleted(folderName) {
// If the deleted folder matches the completed job's folder, reset to idle
// folderName === null means all folders were deleted
if (jobStatus === 'completed' && (folderName === null || folderName === completedFolderName)) {
jobStatus = 'idle';
progress = { completed: 0, total: 0, message: '' };
}
}
function openGallery(folder) {
// Save scroll position before switching to gallery
savedScrollPosition = window.scrollY;
galleryFolder = folder;
currentView = 'gallery';
// Scroll to top for gallery view
window.scrollTo(0, 0);
}
function closeGallery() {
@ -41,6 +77,10 @@
currentView = 'main';
// Check for any running job (e.g., video compilation started from gallery)
checkAndPollProgress();
// Restore scroll position after DOM updates
requestAnimationFrame(() => {
window.scrollTo(0, savedScrollPosition);
});
}
function handleConnectionChange(data) {
@ -70,11 +110,16 @@
const res = await fetch('/api/progress');
const data = await res.json();
jobStatus = data.status;
progress = data;
// Only restore status if a job is actively running, or if we're not in idle state.
// This prevents restoring 'completed' status after we've manually dismissed it.
const isActiveJob = data.status === 'running' || data.status === 'compiling_video' || data.status === 'cancelling';
if (isActiveJob || jobStatus !== 'idle') {
jobStatus = data.status;
progress = data;
}
// If a job is running, start polling
if (data.status === 'running' || data.status === 'compiling_video' || data.status === 'cancelling') {
if (isActiveJob) {
startPolling();
}
} catch (e) {
@ -170,7 +215,7 @@
{#if jobStatus === 'completed'}
<section class="results">
<ResultsView />
<ResultsView folderName={completedFolderName} />
</section>
{/if}
@ -180,6 +225,7 @@
onOpenGallery={openGallery}
refreshKey={outputFolderRefreshKey}
onFoldersLoaded={handleFoldersLoaded}
onFolderDeleted={handleFolderDeleted}
/>
</section>
{/if}

View file

@ -45,7 +45,7 @@
<span>Immich {version}</span>
{:else}
<span class="indicator error"></span>
<button onclick={checkConnection} class="retry">
<button type="button" onclick={checkConnection} class="retry">
{error} - Retry
</button>
{/if}

View file

@ -131,7 +131,7 @@
<div class="gallery-view">
<header class="gallery-header">
<button class="back-btn" onclick={onBack} disabled={disabled || deleting || compiling}>
<button type="button" class="back-btn" onclick={onBack} disabled={disabled || deleting || compiling}>
&larr; Back
</button>
<h2>
@ -152,6 +152,7 @@
<div class="toolbar">
<div class="selection-controls">
<button
type="button"
class="toolbar-btn"
onclick={selectAll}
disabled={disabled || deleting || compiling || allSelected}
@ -159,6 +160,7 @@
Select All
</button>
<button
type="button"
class="toolbar-btn"
onclick={deselectAll}
disabled={disabled || deleting || compiling || selectedCount === 0}
@ -166,6 +168,7 @@
Deselect All
</button>
<button
type="button"
class="toolbar-btn delete-btn"
onclick={deleteSelected}
disabled={disabled || deleting || compiling || selectedCount === 0}
@ -213,6 +216,7 @@
{/if}
</div>
<button
type="button"
class="compile-btn"
onclick={compileVideo}
disabled={disabled || deleting || compiling || images.length === 0}

View file

@ -1,7 +1,7 @@
<script>
import { onMount } from 'svelte';
let { disabled = false, onOpenGallery, refreshKey = 0, onFoldersLoaded } = $props();
let { disabled = false, onOpenGallery, refreshKey = 0, onFoldersLoaded, onFolderDeleted } = $props();
let folders = $state([]);
let loading = $state(true);
@ -45,6 +45,8 @@
const data = await res.json().catch(() => ({}));
throw new Error(data.message || 'Failed to delete folder');
}
// Notify parent that folder was deleted
onFolderDeleted?.(name);
// Reload the list
await loadFolders();
} catch (e) {
@ -68,6 +70,8 @@
const data = await res.json().catch(() => ({}));
throw new Error(data.message || 'Failed to delete folders');
}
// Notify parent that all folders were deleted (null = all)
onFolderDeleted?.(null);
// Reload the list
await loadFolders();
} catch (e) {
@ -91,7 +95,7 @@
<div class="output-manager">
<div class="header">
<h3>Output Folders</h3>
<button class="refresh-btn" onclick={loadFolders} disabled={loading || disabled}>
<button type="button" class="refresh-btn" onclick={loadFolders} disabled={loading || disabled}>
{loading ? '...' : '↻'}
</button>
</div>
@ -118,14 +122,22 @@
<div class="folder-actions">
{#if folder.has_video}
<a
href="/output/{encodeURIComponent(folder.name)}/timelapse.mp4"
href="/output/{encodeURIComponent(folder.name)}/{encodeURIComponent(folder.name)}.mp4"
target="_blank"
class="view-btn"
class="action-btn"
>
View
</a>
<a
href="/output/{encodeURIComponent(folder.name)}/{encodeURIComponent(folder.name)}.mp4"
download="{folder.name}.mp4"
class="action-btn"
>
Download
</a>
{/if}
<button
type="button"
class="gallery-btn"
onclick={() => onOpenGallery?.(folder)}
disabled={disabled || deleting !== null}
@ -133,6 +145,7 @@
Gallery
</button>
<button
type="button"
class="delete-btn"
onclick={() => deleteFolder(folder.name)}
disabled={disabled || deleting !== null}
@ -146,6 +159,7 @@
<div class="actions">
<button
type="button"
class="delete-all-btn"
onclick={deleteAll}
disabled={disabled || deleting !== null || folders.length === 0}
@ -273,7 +287,7 @@
gap: 0.5rem;
}
.view-btn {
.action-btn {
padding: 0.375rem 0.75rem;
background: #333;
border: none;
@ -285,7 +299,7 @@
transition: all 0.15s ease;
}
.view-btn:hover {
.action-btn:hover {
background: #4f46e5;
}

View file

@ -50,7 +50,7 @@
{:else if error}
<div class="error">
{error}
<button onclick={loadPeople}>Retry</button>
<button type="button" onclick={loadPeople}>Retry</button>
</div>
{:else}
<input
@ -64,6 +64,7 @@
<div class="people-grid">
{#each filteredPeople as person (person.id)}
<button
type="button"
class="person-card"
class:selected={selectedId === person.id}
onclick={() => selectPerson(person)}

View file

@ -155,7 +155,7 @@
</div>
<div class="actions">
<button class="start-btn" onclick={handleStartClick}>
<button type="button" class="start-btn" onclick={handleStartClick}>
Start Processing
</button>
</div>

View file

@ -84,7 +84,7 @@
<span class="count">{progress.completed} / {progress.total}</span>
{/if}
{#if canCancel}
<button class="cancel-btn" onclick={cancelProcessing}>Cancel</button>
<button type="button" class="cancel-btn" onclick={cancelProcessing}>Cancel</button>
{/if}
</div>
</div>

View file

@ -5,7 +5,13 @@
// - Skipped images with reasons
// - Face landmark visualization
let videoUrl = $state('/output/timelapse.mp4');
let { folderName = null } = $props();
let videoUrl = $derived(
folderName
? `/output/${encodeURIComponent(folderName)}/${encodeURIComponent(folderName)}.mp4`
: null
);
let videoError = $state(false);
function handleVideoError() {
@ -16,7 +22,12 @@
<div class="results-view">
<h2>Result</h2>
{#if videoError}
{#if !videoUrl}
<div class="video-error">
<p>No video available.</p>
<p class="hint">Person information was not provided.</p>
</div>
{:else if videoError}
<div class="video-error">
<p>Video not available yet.</p>
<p class="hint">The video file may still be processing or the path may have changed.</p>
@ -36,7 +47,7 @@
</div>
<div class="actions">
<a href={videoUrl} download="timelapse.mp4" class="download-btn">
<a href={videoUrl} download="{folderName}.mp4" class="download-btn">
Download Video
</a>
</div>

View file

@ -79,7 +79,7 @@
</script>
<div class="settings-panel">
<button class="toggle-btn" onclick={toggle}>
<button type="button" class="toggle-btn" onclick={toggle}>
<span class="icon">{isOpen ? '▼' : '▶'}</span>
Settings
</button>
@ -90,11 +90,12 @@
<p class="loading">Loading settings...</p>
{:else if error}
<p class="error">{error}</p>
<button class="retry-btn" onclick={loadConfig}>Retry</button>
<button type="button" class="retry-btn" onclick={loadConfig}>Retry</button>
{:else}
<!-- Tab navigation -->
<div class="tabs">
<button
type="button"
class="tab"
class:active={activeTab === 'face'}
onclick={() => (activeTab = 'face')}
@ -102,6 +103,7 @@
Face
</button>
<button
type="button"
class="tab"
class:active={activeTab === 'output'}
onclick={() => (activeTab = 'output')}
@ -109,6 +111,7 @@
Output
</button>
<button
type="button"
class="tab"
class:active={activeTab === 'video'}
onclick={() => (activeTab = 'video')}
@ -276,7 +279,7 @@
</div>
<div class="actions">
<button class="save-btn" onclick={saveConfig} disabled={disabled || saving}>
<button type="button" class="save-btn" onclick={saveConfig} disabled={disabled || saving}>
{saving ? 'Saving...' : 'Save Settings'}
</button>
{#if saveMessage}

View file

@ -264,7 +264,6 @@ impl ImmichClient {
.to_string();
let bytes = response.bytes().await?;
tracing::debug!("Thumbnail bytes received: {}", bytes.len());
Ok((bytes, content_type))
}
}

View file

@ -180,9 +180,10 @@ async fn run_job_inner(
None
};
let video_filename = format!("{}.mp4", folder_name);
let output_dirs = OutputDirs {
images: images_dir.clone(),
video: person_dir.join("timelapse.mp4"),
video: person_dir.join(&video_filename),
debug,
};

View file

@ -339,9 +339,9 @@ async fn start_processing(
}
}
// Reset progress with person info
// Reset progress with person info (bypasses terminal state check)
state
.update_progress(Progress {
.reset_progress(Progress {
status: JobStatus::Running,
completed: 0,
total: 0,
@ -485,8 +485,9 @@ async fn list_output_folders(
}
}
// Check for video file
let has_video = path.join("timelapse.mp4").exists();
// Check for video file (named after the folder/person)
let video_filename = format!("{}.mp4", name);
let has_video = path.join(&video_filename).exists();
folders.push(OutputFolderInfo {
name,
@ -678,10 +679,11 @@ async fn list_folder_images(
images.sort_by(|a, b| a.filename.cmp(&b.filename));
let total_count = images.len() as u32;
let video_filename = format!("{}.mp4", folder_name);
let video_exists = config
.output_dir
.join(&folder_name)
.join("timelapse.mp4")
.join(&video_filename)
.exists();
Ok(Json(FolderImagesResponse {
@ -890,9 +892,9 @@ async fn compile_folder_video(
));
}
// Set status to compiling video
// Set status to compiling video (bypasses terminal state check)
state
.update_progress(Progress {
.reset_progress(Progress {
status: JobStatus::CompilingVideo,
completed: 0,
total: image_count,
@ -908,7 +910,8 @@ async fn compile_folder_video(
// Clone values needed for the async task
let video_config = config.video.clone();
let output_path = folder_path.join("timelapse.mp4");
let video_filename = format!("{}.mp4", folder_name);
let output_path = folder_path.join(&video_filename);
let job_state = state.clone();
let folder_name_clone = folder_name.clone();

View file

@ -133,6 +133,16 @@ impl AppState {
let _ = self.progress_tx.send(progress);
}
/// Reset progress for a new job, bypassing the terminal state check.
///
/// Use this when starting a new job to clear any previous terminal state
/// (Completed, Cancelled, Error) that would otherwise block progress updates.
pub async fn reset_progress(&self, progress: Progress) {
let mut current = self.progress.write().await;
*current = progress.clone();
let _ = self.progress_tx.send(progress);
}
/// Request cancellation of the current job.
pub async fn request_cancel(&self) -> bool {
let cancel_token = self.cancel_token.read().await;