* feat(inspector): expose bounded physical file evidence * fix(inspector): distinguish ambiguous saved associations * feat(inspector): derive conservative generation facts * fix(inspector): guard fact ancestry and inspection state * feat(inspector): share complete file details and exports * fix(inspector): preserve fact states and touch targets * feat(viewer): navigate the loaded media collection Refs #216 * fix(viewer): refit after current stage geometry changes Ref #216. Observe the current media stage after readiness and wrapped title changes without rebuilding focused controls or resetting transforms at unchanged dimensions. * feat(viewer): add lifecycle-safe slideshow and media preferences * fix(viewer): resume gallery continuation after close (#216) * fix(images): guard source recovery and wrap viewer actions Clear stale root and folder intent, cancel old pages, and wait for current source discovery before loading a refreshed collection. Preserve safe retry through failed, superseded, and disposed discovery. Wrap narrow viewer footer actions and wait for measured gallery viewport settling in responsive checks. Refs #216; addresses PR #224 review. * test(viewer): await gallery layout after viewport changes Require actual inner, root client, and document widths to agree after each responsive-case viewport and CSS zoom transition. Keep the existing exact frame, overflow, focus, and coarse target assertions unchanged. Observed PR #224 CI initial mobile resize at537px before pending card relayout settled to356px. Refs #216.
74 lines
3.4 KiB
JavaScript
74 lines
3.4 KiB
JavaScript
import {imageKey} from './images-state.mjs';
|
|
import {inspectPath} from './inspection-state.mjs';
|
|
|
|
/** A viewer visits only the descriptors loaded by its opening query. */
|
|
export function createViewerSession({items, initialKey, onChange = () => {}, setTimer = setTimeout, clearTimer = clearTimeout}) {
|
|
if (!Array.isArray(items) || !items.length) throw new TypeError('A loaded media collection is required');
|
|
const keys = new Set();
|
|
const loaded = Object.freeze(items.map(item => {
|
|
inspectPath(item);
|
|
const key = imageKey(item);
|
|
if (keys.has(key)) throw new TypeError('Duplicate viewer identity');
|
|
keys.add(key);
|
|
return Object.freeze({...item});
|
|
}));
|
|
const index = loaded.findIndex(item => imageKey(item) === initialKey);
|
|
if (index < 0) throw new TypeError('Selected media is not loaded');
|
|
let state = Object.freeze({key:initialKey,index,total:loaded.length,phase:'stopped',requested:false,mediaReady:false});
|
|
let timer = null, deadline = 0;
|
|
function cancel() {
|
|
deadline++;
|
|
if (timer !== null) clearTimer(timer);
|
|
timer = null;
|
|
}
|
|
function publish(update) {
|
|
state = Object.freeze({...state,...update});
|
|
onChange(state, loaded[state.index]);
|
|
}
|
|
function phase(ready = state.mediaReady) {
|
|
return !state.requested ? 'stopped' : loaded[state.index].is_video ? 'paused-video' : ready ? 'playing-image' : 'waiting-image';
|
|
}
|
|
function schedule() {
|
|
if (state.phase !== 'playing-image' || timer !== null) return;
|
|
const key = state.key, token = ++deadline;
|
|
timer = setTimer(() => {
|
|
if (token !== deadline || state.phase !== 'playing-image' || !state.requested || state.key !== key) return;
|
|
timer = null;navigate(1);
|
|
},3500);
|
|
}
|
|
function navigate(direction) {
|
|
if (state.phase === 'disposed') return;
|
|
if (direction !== -1 && direction !== 1) throw new TypeError('Viewer direction must be -1 or 1');
|
|
if (loaded.length === 1) return;
|
|
cancel();
|
|
const next = (state.index + direction + loaded.length) % loaded.length;
|
|
publish({index:next,key:imageKey(loaded[next]),phase:state.requested ? loaded[next].is_video ? 'paused-video' : 'waiting-image' : 'stopped',mediaReady:false});
|
|
}
|
|
publish({});
|
|
return {
|
|
get state() { return state; },
|
|
navigate,
|
|
play() {
|
|
if (state.phase === 'disposed' || state.phase === 'error' || state.requested || loaded.length < 2 || loaded[state.index].is_video) return;
|
|
publish({requested:true,phase:state.mediaReady ? 'playing-image' : 'waiting-image'});
|
|
schedule();
|
|
},
|
|
stop() {
|
|
if (state.phase === 'disposed') return;
|
|
cancel();publish({requested:false,phase:state.phase === 'error' ? 'error' : 'stopped'});
|
|
},
|
|
ready(key) {
|
|
if (state.phase === 'disposed' || key !== state.key || state.mediaReady) return;
|
|
publish({mediaReady:true,phase:phase(true)});schedule();
|
|
},
|
|
failed(key) {
|
|
if (state.phase === 'disposed' || key !== state.key) return;
|
|
cancel();publish({mediaReady:false,phase:'error',requested:false});
|
|
},
|
|
dispose() {
|
|
if (state.phase === 'disposed') return;
|
|
cancel();publish({phase:'disposed',requested:false,mediaReady:false});
|
|
},
|
|
};
|
|
}
|