Improvements to the video handling (download, gallery view etc..)
This commit is contained in:
parent
1854ecd8e0
commit
499094c25a
13 changed files with 123 additions and 31 deletions
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}>
|
||||
← 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}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -264,7 +264,6 @@ impl ImmichClient {
|
|||
.to_string();
|
||||
|
||||
let bytes = response.bytes().await?;
|
||||
tracing::debug!("Thumbnail bytes received: {}", bytes.len());
|
||||
Ok((bytes, content_type))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue