/*
Photo.js
Author: tobychui
This is a complete rewrite of the legacy Photo module for ArozOS
*/
// Number of photos to load per page (infinite scroll batch size)
const PAGE_SIZE = 40; //large enough to fill the whole page on load, but small enough to keep initial load fast and responsive
let photoList = [];
let prePhoto = "";
let nextPhoto = "";
let currentModel = "";
let currentPhotoAllIndex = -1; // index of current photo in allImages (full server list)
let currentPhotoFilepath = null; // filepath of the photo open in the viewer (download / rating)
let currentPhotoRating = 0; // star rating (0-5) of the open photo
let isMobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent);
// Check if image should use compression (only JPG/PNG)
function shouldUseCompression(filepath, filesize) {
const ext = filepath.split('.').pop().toLowerCase();
const isJpgOrPng = (ext === 'jpg' || ext === 'jpeg' || ext === 'png');
const COMPRESSION_THRESHOLD = 5 * 1024 * 1024; // 5MB
return isJpgOrPng && filesize && filesize > COMPRESSION_THRESHOLD;
}
// Get viewable image URL (handles RAW files)
function getViewableImageUrl(filepath, callback) {
// Both RAW and regular images now use backend rendering
const imageUrl = "../media?file=" + encodeURIComponent(filepath);
callback(imageUrl, true, false, isRawImage(filepath) ? 'backend_raw' : 'direct');
}
// Grid zoom: 0 = responsive (auto column count by width); a positive value pins
// the number of columns, set by the user with Ctrl/⌘ + mouse wheel over the grid
// (Google-Photos-style gallery zoom). Smaller column count => larger thumbnails.
let photoGridColumns = 0;
const PHOTO_GRID_MIN_COLS = 2;
const PHOTO_GRID_MAX_COLS = 12;
function getContainerWidth(){
const container = document.getElementById('viewboxContainer');
return container ? container.clientWidth : (window.innerWidth - 210);
}
// Responsive column count for a given container width (used until the user
// pins a zoom level via the mouse wheel).
function autoColumnCount(containerWidth){
if (containerWidth < 400) return 2;
if (containerWidth < 600) return 3;
if (containerWidth < 900) return 4;
if (containerWidth < 1100) return 5;
if (containerWidth < 1400) return 6;
return 8;
}
function getColumnCount(){
if (photoGridColumns > 0) return photoGridColumns;
return autoColumnCount(getContainerWidth());
}
function getImageWidth(){
// Use the actual viewbox container width so the sidebar and scrollbar are
// already subtracted — this prevents gaps when the window is resized.
return Math.floor(getContainerWidth() / getColumnCount());
}
function updateImageSizes(){
let newImageWidth = getImageWidth();
//Updates all the size of the images
$(".imagecard").css({
width: newImageWidth,
height: newImageWidth
});
}
// Briefly show the current columns-per-row while zooming the grid.
let _gridZoomHintTimer = null;
function showGridZoomHint(cols){
const el = document.getElementById('grid-zoom-snackbar');
if (!el) return;
el.textContent = cols + ' per row';
el.classList.add('visible');
clearTimeout(_gridZoomHintTimer);
_gridZoomHintTimer = setTimeout(function(){ el.classList.remove('visible'); }, 1100);
}
// ── Date grouping (Google-Photos-style Year / Month sections) ──────────────────
// Capture date for a grid item: the EXIF shoot time (taken_date, supplied by
// both search results and folder listings via the photo index) with file mtime
// as the fallback for photos the background indexer has not reached yet.
function photoImageDateUnix(img){
if (!img) return null;
if (img.taken_date) return img.taken_date;
if (img.mtime) return img.mtime;
if (img.modified_date) return img.modified_date;
return null;
}
// "June 2023" style header for a unix-second timestamp (PHOTO_MONTHS: search.js).
function photoGroupLabel(unixSec){
if (!unixSec) return 'Undated';
var d = new Date(unixSec * 1000);
if (isNaN(d.getTime())) return 'Undated';
return PHOTO_MONTHS[d.getMonth()] + ' ' + d.getFullYear();
}
// Newest-first ordering so each Year/Month section is contiguous regardless of
// the folder's own sort order.
function sortImagesByDateDesc(arr){
return arr.slice().sort(function(a, b){
return (photoImageDateUnix(b) || 0) - (photoImageDateUnix(a) || 0);
});
}
function extractFolderName(folderpath){
return folderpath.split("/").pop();
}
function parseExifValue(value) {
if (typeof value === 'string' && value.includes('/')) {
let parts = value.split('/');
if (parts.length === 2) {
let num = parseFloat(parts[0]);
let den = parseFloat(parts[1]);
if (den !== 0) {
return num / den;
}
}
}
return parseFloat(value) || value;
}
function formatShutterSpeed(value) {
let num = parseExifValue(value);
if (num < 1) {
return "1/" + Math.round(1 / num);
} else {
return num ;
}
}
function photoListObject() {
return {
// data
pathWildcard: "user:/Photo/*",
currentPath: "user:/Photo",
renderSize: 200,
vroots: [],
allImages: [], // full list from server
images: [], // currently displayed slice
folders: [],
sortOrder: 'smart',
groupByDate: true, // Google-Photos-style Year / Month grid sections
ratingFilter: 0, // active "rating ≥ N stars" quick filter (0 = off)
restored: false,
hasMoreImages: false,
isLoadingMore: false, // guard: blocks new batch until DOM has updated
sidebarOpen: !isMobile, // start hidden on mobile, visible on desktop
// search state (tags input)
searchTags: [], // committed filter chips: {label, value, type}
searchInput: '', // text currently being typed in the box
searchMode: false, // true while showing search results instead of a folder
suggestions: [],
showSuggestions: false,
suggestIndex: -1, // keyboard-highlighted suggestion (-1 = none)
searchTotal: 0,
indexing: false,
indexStatusText: '',
_suggestTimer: null,
_searchTimer: null,
// init
init() {
this.getFolderInfo();
this.getRootInfo();
this.renderSize = getImageWidth();
updateImageSizes();
this.restored = false;
this.$nextTick(() => { this.setupInfiniteScroll(); this.setupGridZoom(); });
// Kick off background (auto) indexing shortly after the first paint so
// it doesn't compete with the initial folder load.
setTimeout(() => { this.startAutoIndex(); }, 1200);
const MOBILE_BP = 768;
let _prevMobile = window.innerWidth <= MOBILE_BP;
let _resizeTimer;
window.addEventListener('resize', () => {
clearTimeout(_resizeTimer);
_resizeTimer = setTimeout(() => {
// Recalculate tile sizes
this.renderSize = getImageWidth();
updateImageSizes();
// Auto-manage sidebar visibility on breakpoint crossing
const nowMobile = window.innerWidth <= MOBILE_BP;
if (nowMobile && !_prevMobile) {
// Desktop → mobile: hide sidebar so it doesn't overlay content
this.sidebarOpen = false;
} else if (!nowMobile && _prevMobile) {
// Mobile → desktop: sidebar is back in normal flow, keep state clean
this.sidebarOpen = false;
}
_prevMobile = nowMobile;
}, 80);
});
},
updateRenderingPath(newPath, callback = null){
this.currentPath = JSON.parse(JSON.stringify(newPath));
this.pathWildcard = newPath + '/*';
this.restored = false;
if (isMobile) this.sidebarOpen = false;
this.getFolderInfo(callback);
},
// Returns path segments for the sidebar breadcrumb tree
getPathSegments() {
const parts = this.currentPath.split('/');
let segments = [];
let accumulated = '';
for (let i = 0; i < parts.length; i++) {
accumulated = i === 0 ? parts[0] : accumulated + '/' + parts[i];
segments.push({ name: parts[i], path: accumulated, depth: i, isDiskRoot: i === 0 });
}
return segments;
},
getFolderInfo(callback = null) {
fetch(ao_root + "system/ajgi/interface?script=Photo/backend/listFolder.js", {
method: 'POST',
cache: 'no-cache',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
"folder": this.pathWildcard,
"sort": this.sortOrder
})
}).then(resp => {
resp.json().then(data => {
console.log(data);
this.folders = data[0];
this.allImages = data[1];
// Date grouping reads cleanest newest-first; sort the loaded
// list so Year/Month sections are contiguous as you scroll.
if (this.groupByDate) { this.allImages = sortImagesByDateDesc(this.allImages); }
this.images = this.allImages.slice(0, PAGE_SIZE);
this.hasMoreImages = this.allImages.length > PAGE_SIZE;
this.isLoadingMore = false;
if (this.allImages.length == 0){
$("#noimg").show();
}else{
$("#noimg").hide();
}
console.log(this.folders);
if (!this.restored) { restoreFromHash(); this.restored = true; }
if (callback) callback();
});
});
},
getRootInfo() {
fetch(ao_root + "system/ajgi/interface?script=Photo/backend/listRoots.js", {
method: 'POST',
cache: 'no-cache',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({})
}).then(resp => {
resp.json().then(data => {
this.vroots = data;
this.$nextTick(() => {
$('.ui.dropdown').dropdown();
});
});
})
},
changeSort(newSort) {
this.sortOrder = newSort;
this.getFolderInfo();
},
// Load the next PAGE_SIZE images into the displayed list
loadMoreImages() {
if (this.isLoadingMore) return;
const current = this.images.length;
if (current >= this.allImages.length) return;
this.isLoadingMore = true;
const next = this.allImages.slice(current, current + PAGE_SIZE);
this.images = this.images.concat(next);
this.hasMoreImages = this.images.length < this.allImages.length;
// Release the guard only after Alpine has re-rendered and scrollHeight has grown
this.$nextTick(() => { this.isLoadingMore = false; });
},
// Attach a scroll listener to the viewbox container for infinite scroll
setupInfiniteScroll() {
const container = document.getElementById('viewboxContainer');
if (!container) return;
container.addEventListener('scroll', () => {
const { scrollTop, scrollHeight, clientHeight } = container;
// Trigger when within 300px of the bottom
if (scrollTop + clientHeight >= scrollHeight - 300) {
this.loadMoreImages();
}
});
},
// ── Grid zoom (Ctrl/⌘ + mouse wheel) ───────────────────────────────────
// Recompute the tile size from the current column count and push it to
// both the reactive binding and the already-rendered cards.
applyRenderSize() {
this.renderSize = getImageWidth();
updateImageSizes();
},
// Ctrl/⌘ + wheel (and trackpad pinch, which also sets ctrlKey) zooms the
// whole grid; a plain wheel keeps scrolling the gallery.
setupGridZoom() {
const container = document.getElementById('viewboxContainer');
if (!container) return;
container.addEventListener('wheel', (e) => {
if (!e.ctrlKey && !e.metaKey) return;
e.preventDefault();
this.zoomGrid(e.deltaY < 0 ? 1 : -1);
}, { passive: false });
},
// direction: +1 = zoom in (fewer columns, larger photos), -1 = zoom out.
zoomGrid(direction) {
const cur = getColumnCount();
let next = cur - direction;
if (next < PHOTO_GRID_MIN_COLS) next = PHOTO_GRID_MIN_COLS;
if (next > PHOTO_GRID_MAX_COLS) next = PHOTO_GRID_MAX_COLS;
if (next === cur) return;
photoGridColumns = next;
this.applyRenderSize();
showGridZoomHint(next);
},
// ── Date grouping ──────────────────────────────────────────────────────
// Split the loaded slice into contiguous Year/Month sections for the grid.
// Returns a single unlabelled group when grouping is disabled.
groupedImages() {
const imgs = this.images;
if (!this.groupByDate) {
return [{ key: 'all', label: '', images: imgs }];
}
const groups = [];
let cur = null;
for (let i = 0; i < imgs.length; i++) {
const img = imgs[i];
const d = photoImageDateUnix(img);
let key, label;
if (d) {
const dt = new Date(d * 1000);
key = dt.getFullYear() + '-' + (dt.getMonth() + 1);
label = photoGroupLabel(d);
} else {
key = 'undated';
label = 'Undated';
}
if (!cur || cur.key !== key) {
cur = { key: key, label: label, images: [] };
groups.push(cur);
}
cur.images.push(img);
}
return groups;
},
// Re-fetch the current view so the new ordering / headers take effect.
onGroupByDateChange() {
if (this.searchMode) { this.runSearch(); }
else { this.getFolderInfo(); }
},
// ── Rating quick-filter (sidebar stars) ────────────────────────────────
// Select "rating ≥ n" (tapping the active level again clears it). Driven
// through the same search-chip pipeline as every other filter.
setRatingFilter(n) {
n = parseInt(n, 10) || 0;
if (n === this.ratingFilter) n = 0;
this.ratingFilter = n;
this.searchTags = this.searchTags.filter(function (t) { return t.type !== 'rating'; });
if (n > 0) {
this.searchTags.push({ label: '★ ≥ ' + n, value: 'rating:>=' + n, type: 'rating' });
}
this.runSearch();
},
// Keep the sidebar stars in step with whatever rating chip is present
// (a chip may also be typed, picked from autocomplete or removed by hand).
syncRatingFilterFromTags() {
let found = 0;
for (let i = 0; i < this.searchTags.length; i++) {
const t = this.searchTags[i];
if (t.type === 'rating') {
const m = ('' + t.value).match(/(\d)/);
if (m) found = parseInt(m[1], 10);
}
}
this.ratingFilter = found;
},
// ── Search ────────────────────────────────────────────────────────────
suggestIcon(type) { return photoSuggestIcon(type); },
// The committed query is the chips; currentQuery also folds in the text
// still being typed so results update live.
committedQuery() { return this.searchTags.map(t => t.value).join(' '); },
currentQuery() {
let q = this.committedQuery();
const inp = this.searchInput.trim();
if (inp) q = (q ? q + ' ' : '') + inp;
return q;
},
searchActive() { return this.searchTags.length > 0 || this.searchInput.trim().length > 0; },
// Debounced autocomplete + live search on every keystroke.
onSearchInput() {
this.suggestIndex = -1;
const q = this.searchInput;
clearTimeout(this._suggestTimer);
this._suggestTimer = setTimeout(() => { this.fetchSuggestions(q); }, 150);
this.scheduleSearch();
},
fetchSuggestions(q) {
aoPhotoBackend("Photo/backend/searchSuggest.js", { q: q }).then(data => {
this.suggestions = (data && data.suggestions) ? data.suggestions : [];
this.showSuggestions = this.suggestions.length > 0;
}).catch(() => {
this.suggestions = [];
this.showSuggestions = false;
});
},
scheduleSearch() {
clearTimeout(this._searchTimer);
this._searchTimer = setTimeout(() => { this.runSearch(); }, 400);
},
// Add a chip from an explicit {label, value, type}.
addTag(tag) {
if (!tag || !tag.value) return;
// Ignore exact duplicates (same token already a chip).
if (this.searchTags.some(t => t.value === tag.value)) {
this.searchInput = '';
this.showSuggestions = false;
return;
}
this.searchTags.push({ label: tag.label, value: tag.value, type: tag.type || 'search' });
this.searchInput = '';
this.suggestions = [];
this.showSuggestions = false;
this.suggestIndex = -1;
this.runSearch();
},
// A picked autocomplete suggestion becomes a chip.
applySuggestion(s) { this.addTag({ label: s.label, value: s.value, type: s.type }); },
// Commit whatever text is in the box as a chip (Enter / Space).
commitInput() {
const text = this.searchInput.trim();
if (text.length === 0) return;
this.addTag(photoParseTagToken(text));
},
removeTag(i) {
this.searchTags.splice(i, 1);
this.runSearch();
this.$nextTick(() => { const el = document.getElementById('photo-search-input'); if (el) el.focus(); });
},
removeLastTag() {
if (this.searchTags.length > 0) {
this.searchTags.pop();
this.runSearch();
}
},
runSearch() {
// NOTE: this is also the live/debounced search fired while typing, so it
// must NOT close the autocomplete dropdown — only explicit actions
// (pick/commit/Escape/click-outside/clear) hide it. Closing it here made
// the dropdown flash and vanish ~400ms after each keystroke.
clearTimeout(this._searchTimer);
this.syncRatingFilterFromTags();
const q = this.currentQuery();
if (q.length === 0) {
// Nothing to search — fall back to normal folder browsing.
if (this.searchMode) { this.searchMode = false; this.searchTotal = 0; this.getFolderInfo(); }
return;
}
this.searchMode = true;
aoPhotoBackend("Photo/backend/searchPhotos.js", {
q: q,
sort: 'taken_desc',
limit: 1000
}).then(data => {
const results = (data && data.results) ? data.results : [];
this.searchTotal = (data && typeof data.total === 'number') ? data.total : results.length;
// Carry the date + rating through so the grid can group by month
// and the viewer can show the star rating without a refetch.
this.allImages = results.map(r => ({
filepath: r.filepath, filesize: r.filesize,
taken_date: r.taken_date, modified_date: r.modified_date, rating: r.rating
}));
if (this.groupByDate) { this.allImages = sortImagesByDateDesc(this.allImages); }
this.images = this.allImages.slice(0, PAGE_SIZE);
this.hasMoreImages = this.allImages.length > PAGE_SIZE;
this.isLoadingMore = false;
this.folders = [];
if (this.allImages.length == 0) { $("#noimg").show(); } else { $("#noimg").hide(); }
this.$nextTick(() => { updateImageSizes(); });
}).catch(() => { /* leave current view untouched on error */ });
},
clearSearch() {
this.searchTags = [];
this.searchInput = '';
this.suggestions = [];
this.showSuggestions = false;
this.suggestIndex = -1;
this.searchTotal = 0;
this.searchMode = false;
this.ratingFilter = 0;
clearTimeout(this._searchTimer);
this.getFolderInfo(); // restore normal folder browsing
},
onSearchKeydown(e) {
if (e.key === 'Enter') {
e.preventDefault();
if (this.showSuggestions && this.suggestIndex >= 0 && this.suggestions[this.suggestIndex]) {
this.applySuggestion(this.suggestions[this.suggestIndex]);
} else if (this.searchInput.trim().length > 0) {
this.commitInput();
} else {
this.runSearch();
}
} else if (e.key === ' ') {
// Space turns the current token into a chip (unless mid-quote/"key:").
if (photoInputCommittable(this.searchInput)) {
e.preventDefault();
this.commitInput();
}
} else if (e.key === 'Backspace') {
if (this.searchInput.length === 0 && this.searchTags.length > 0) {
e.preventDefault();
this.removeLastTag();
}
} else if (e.key === 'ArrowDown') {
if (this.showSuggestions) {
e.preventDefault();
this.suggestIndex = Math.min(this.suggestIndex + 1, this.suggestions.length - 1);
}
} else if (e.key === 'ArrowUp') {
if (this.showSuggestions) {
e.preventDefault();
this.suggestIndex = Math.max(this.suggestIndex - 1, -1);
}
} else if (e.key === 'Escape') {
if (this.showSuggestions) { this.showSuggestions = false; }
else if (this.searchActive()) { this.clearSearch(); }
}
},
// ── Auto indexing ─────────────────────────────────────────────────────
// Loops indexPhotos.js in the background until the whole library is
// indexed. Incremental, so steady-state runs finish in a single pass.
startAutoIndex() {
if (this.indexing) return;
this.indexing = true;
let indexedThisRun = 0;
const step = () => {
aoPhotoBackend("Photo/backend/indexPhotos.js", { mode: 'incremental' }).then(data => {
if (data && data.error) { this.indexing = false; this.indexStatusText = ''; return; }
const total = (data && data.total) ? data.total : 0;
indexedThisRun += (data && data.indexed) ? data.indexed : 0;
if (data && data.hasMore) {
this.indexStatusText = 'Indexing… ' + total + ' photos';
setTimeout(step, 50);
} else {
this.indexing = false;
this.indexStatusText = total ? (total + ' photos indexed') : '';
// Newly indexed photos may carry EXIF shoot times the
// current grid grouped without (mtime fallback) — reload
// the view so the Year/Month sections use them.
if (indexedThisRun > 0 && this.groupByDate && !this.searchMode) {
this.getFolderInfo();
}
setTimeout(() => { if (!this.indexing) this.indexStatusText = ''; }, 4000);
}
}).catch(() => { this.indexing = false; this.indexStatusText = ''; });
};
step();
},
// Force a full re-index (used by the "Rebuild" action). The first request
// wipes the index ("full"); subsequent rounds loop incrementally so the
// batch loop advances to completion.
rebuildIndex() {
if (this.indexing) return;
this.indexing = true;
this.indexStatusText = 'Rebuilding index…';
const step = (mode) => {
aoPhotoBackend("Photo/backend/indexPhotos.js", { mode: mode }).then(data => {
if (data && data.error) { this.indexing = false; this.indexStatusText = ''; return; }
const total = (data && data.total) ? data.total : 0;
if (data && data.hasMore) {
this.indexStatusText = 'Rebuilding… ' + total + ' photos';
setTimeout(() => step('incremental'), 50);
} else {
this.indexing = false;
this.indexStatusText = total ? (total + ' photos indexed') : '';
if (this.searchMode) { this.runSearch(); }
else if (this.groupByDate) { this.getFolderInfo(); } // refresh EXIF shoot times
setTimeout(() => { if (!this.indexing) this.indexStatusText = ''; }, 4000);
}
}).catch(() => { this.indexing = false; this.indexStatusText = ''; });
};
step('full');
}
}
}
function renderImageList(object){
var fd = $(object).attr("filedata");
fd = JSON.parse(decodeURIComponent(fd));
console.log(fd);
}
function ShowModal(){
$('#photo-viewer').show();
}
function closeViewer(){
$('#photo-viewer').hide();
if (!ao_module_virtualDesktop){
// Only update hash if not under WebDesktop mode
// to prevent iframe refresh
window.location.hash = '';
}
ao_module_setWindowTitle("Photo");
setTimeout(function(){
$("#fullImage").attr("src","img/loading.png");
$("#compressedImage").attr("src","").hide().removeClass('hidden');
$("#bg-image").attr("src","");
$("#info-filename").text("");
$("#info-filepath").text("");
$("#info-dimensions").text("Loading...");
// Reset EXIF data display
$('#basic-info-section').hide();
$('#shooting-params-section').hide();
$('#tone-analysis-section').hide();
$('#device-info-section').hide();
$('#shooting-mode-section').hide();
$('#technical-params-section').hide();
$('#no-exif-message').hide();
$('.ui.divider').hide();
// Clear histogram canvas
const canvas = document.getElementById('histogram-canvas');
if (canvas) {
const ctx = canvas.getContext('2d');
ctx.clearRect(0, 0, canvas.width, canvas.height);
}
}, 300);
}
let compressedImageLoaded = false;
let fullsizeImageLoaded = false;
function showImage(object){
// Reset zoom level when switching photos
if (typeof resetZoom === 'function') {
resetZoom();
}
if (!$(object).hasClass("imagecard")){
// Not an image card, do nothing
return;
}
// Reset loading flags
compressedImageLoaded = false;
fullsizeImageLoaded = false;
var fd = JSON.parse(decodeURIComponent($(object).attr("filedata")));
_currentCastFilepath = fd.filepath;
currentPhotoFilepath = fd.filepath;
fetchPhotoRating(fd.filepath);
if (_photoCastConnected()) _photoCastSendPhoto(fd.filepath);
$("#info-dimensions").text("Calculating...");
// Check if we should use compression (only for JPG/PNG > 5MB)
const useCompression = shouldUseCompression(fd.filepath, fd.filesize);
// Set thumbnail as placeholder for full image
const thumbnailUrl = $(object).find('img').attr('src');
$("#fullImage").attr("src", thumbnailUrl);
$("#fullImage").hide();
$("#compressedImage").show();
$("#compressedImage").attr("src", thumbnailUrl);
$("#bg-image").attr("src", thumbnailUrl);
// Get image URL (backend handles RAW files automatically)
getViewableImageUrl(fd.filepath, (imageUrl, isSupported, isBlob, method) => {
$("#loading-progress").show();
const compressedImg = document.getElementById('compressedImage');
const fullImg = document.getElementById('fullImage');
const bgImg = document.getElementById('bg-image');
$("#loading-progress").html(` Loading`);
if (useCompression) {
// Use compressed version for large JPG/PNG files
console.log('Large JPG/PNG detected (' + (fd.filesize / 1024 / 1024).toFixed(2) + 'MB), loading compressed version first');
fetch(ao_root + "system/ajgi/interface?script=Photo/backend/getCompressedImg.js", {
method: 'POST',
cache: 'no-cache',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
"filepath": fd.filepath
})
}).then(resp => {
resp.text().then(dataURL => {
$("#loading-progress").html(` Optimizing Resolution`);
compressedImageLoaded = true;
// Only show compressed image if full-size hasn't loaded yet
if (!fullsizeImageLoaded) {
compressedImg.src = dataURL;
compressedImg.style.display = 'block';
bgImg.src = dataURL;
} else {
console.log('Full-size image already loaded, skipping compressed image display');
}
});
}).catch(error => {
console.error('Failed to load compressed image:', error);
// Fall back to full size image
fullImg.src = imageUrl;
bgImg.src = imageUrl;
});
// Start loading full-size image in background
loadFullSizeImageInBackground(imageUrl, fd);
} else {
$("#compressedImage").hide();
$("#fullImage").show();
$("#loading-progress").hide();
// Use full image URL directly for RAW, WEBP, or small JPG/PNG files
if (method === 'backend_raw') {
console.log('RAW file: Rendered by backend');
}
fullImg.src = imageUrl;
bgImg.src = imageUrl;
}
// Update image dimensions and generate histogram when full image loads
$("#fullImage").off("load").on('load', function() {
fullsizeImageLoaded = true;
let width = this.naturalWidth;
let height = this.naturalHeight;
$("#info-dimensions").text(width + ' × ' + height + "px");
// Hide the compressed image once full image is loaded
$("#compressedImage").hide();
$("#fullImage").show();
$("#loading-progress").hide();
const canvas = document.getElementById('histogram-canvas');
if (canvas) {
generateHistogram(this, canvas);
}
});
$("#info-filename").text(fd.filename);
$("#info-filepath").text(fd.filepath);
var nextCard = $(object).next();
var prevCard = $(object).prev();
if (nextCard.length > 0){
nextPhoto = nextCard[0];
}else{
nextPhoto = null;
}
if (prevCard.length > 0){
prePhoto = prevCard[0];
}else{
prePhoto = null;
}
// Track position in the full allImages list for index-based navigation
const _appEl = document.querySelector('[x-data*="photoListObject"]');
if (_appEl && _appEl._x_dataStack) {
const _app = _appEl._x_dataStack[0];
currentPhotoAllIndex = _app.allImages.findIndex(img => img.filepath === fd.filepath);
// Proactively load next batch when within PAGE_SIZE of the end of rendered images
if (currentPhotoAllIndex >= 0 && _app.hasMoreImages &&
currentPhotoAllIndex >= _app.images.length - PAGE_SIZE) {
_app.loadMoreImages();
}
}
// Update navigation buttons state
if (typeof updateNavigationButtons === 'function') {
updateNavigationButtons();
}
ao_module_setWindowTitle("Photo - " + fd.filename);
if (!ao_module_virtualDesktop){
window.location.hash = encodeURIComponent(JSON.stringify({filename: fd.filename, filepath: fd.filepath}));
}
// Check for EXIF data
fetch(ao_root + "system/ajgi/interface?script=Photo/backend/getExif.js", {
method: 'POST',
cache: 'no-cache',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
"filepath": fd.filepath
})
}).then(resp => {
resp.json().then(data => {
formatExifData(data, fd);
})
}).catch(error => {
console.error('Failed to fetch EXIF data:', error);
formatExifData({}, fd); // Call with empty EXIF to show tone analysis
});
});
}
// Function to load full-size image in background with progress tracking
function loadFullSizeImageInBackground(fullSizeUrl, fileData) {
console.log('Starting background download of full-size image...');
const fullImage = document.getElementById('fullImage');
fullImage.src = fullSizeUrl;
}
// ── Download current photo ─────────────────────────────────────────────────────
// Streams the *original* file (RAW included) via the media server's download
// mode, which sets a Content-Disposition: attachment header.
function downloadCurrentPhoto() {
if (!currentPhotoFilepath) return;
var url = ao_root + 'media?download=true&file=' + encodeURIComponent(currentPhotoFilepath);
var a = document.createElement('a');
a.href = url;
a.download = currentPhotoFilepath.split('/').pop();
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
}
// ── Star rating (viewer) ───────────────────────────────────────────────────────
// Paint the 1-5 star widget to reflect `rating` (and remember it).
function renderPhotoRating(rating) {
currentPhotoRating = rating || 0;
var container = document.getElementById('photo-rating-stars');
if (container) {
var stars = container.querySelectorAll('.photo-star');
for (var i = 0; i < stars.length; i++) {
stars[i].classList.toggle('filled', (i + 1) <= currentPhotoRating);
stars[i].classList.remove('hover');
}
}
var label = document.getElementById('photo-rating-label');
if (label) label.textContent = currentPhotoRating ? (currentPhotoRating + ' / 5') : 'Rate';
}
// Persist a new rating for the open photo (tapping the current value clears it).
function setPhotoRating(n) {
if (!currentPhotoFilepath) return;
if (n === currentPhotoRating) n = 0;
renderPhotoRating(n); // optimistic update
var target = currentPhotoFilepath;
fetch(ao_root + "system/ajgi/interface?script=Photo/backend/setRating.js", {
method: 'POST',
cache: 'no-cache',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ filepath: target, rating: n })
}).then(function (r) { return r.json(); }).then(function (data) {
if (target !== currentPhotoFilepath) return; // user already moved on
if (data && typeof data.rating === 'number') renderPhotoRating(data.rating);
}).catch(function () { /* keep the optimistic value */ });
}
// Load the stored rating for a freshly-opened photo.
function fetchPhotoRating(filepath) {
renderPhotoRating(0);
fetch(ao_root + "system/ajgi/interface?script=Photo/backend/getRating.js", {
method: 'POST',
cache: 'no-cache',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ filepath: filepath })
}).then(function (r) { return r.json(); }).then(function (data) {
if (filepath !== currentPhotoFilepath) return; // raced past this photo
renderPhotoRating(data && data.rating ? data.rating : 0);
}).catch(function () { renderPhotoRating(0); });
}
// Wire click + hover-preview on the star widget once.
function initPhotoRatingWidget() {
var container = document.getElementById('photo-rating-stars');
if (!container) return;
var stars = container.querySelectorAll('.photo-star');
for (var i = 0; i < stars.length; i++) {
(function (star, value) {
star.addEventListener('click', function () { setPhotoRating(value); });
star.addEventListener('mouseenter', function () {
for (var j = 0; j < stars.length; j++) {
stars[j].classList.toggle('hover', j < value);
}
});
})(stars[i], i + 1);
}
container.addEventListener('mouseleave', function () {
for (var k = 0; k < stars.length; k++) stars[k].classList.remove('hover');
});
}
document.addEventListener('DOMContentLoaded', initPhotoRatingWidget);
$(document).on("keydown", function(e){
if (e.keyCode == 27){ // Escape
if ($('#photo-viewer').is(':visible')) {
closeViewer();
}
} else if (e.keyCode == 37){
//Left
if (typeof showPreviousImage === 'function') {
showPreviousImage();
} else if (prePhoto != null){
showImage(prePhoto);
}
}else if (e.keyCode == 39){
//Right
if (typeof showNextImage === 'function') {
showNextImage();
} else if (nextPhoto != null){
showImage(nextPhoto);
}
}
})
function generateToneAnalysis(imageElement) {
analysis_tone_types(imageElement, function(result) {
if (result) {
// Update tone type based on brightness, contrast, shadow and highlight ratios
let toneType = get_tone_type(result.brightness, result.contrast, result.shadowRatio, result.highlightRatio);
$('.tone-type-value').text(toneType);
$('.brightness-value').text(result.brightness);
$('.contrast-value').text(result.contrast);
$('.shadow-ratio-value').text(result.shadowRatio);
$('.highlight-ratio-value').text(result.highlightRatio);
} else {
$('.tone-type-value').text("N/A");
$('.brightness-value').text("N/A");
$('.contrast-value').text("N/A");
$('.shadow-ratio-value').text("N/A");
$('.highlight-ratio-value').text("N/A");
}
});
}
function formatExifData(exif, fileData) {
// Hide all sections initially
$('#basic-info-section').hide();
$('#shooting-params-section').hide();
$('#tone-analysis-section').hide();
$('#device-info-section').hide();
$('#shooting-mode-section').hide();
$('#technical-params-section').hide();
$('#no-exif-message').hide();
// Hide all dividers
$('.ui.divider').hide();
if (!exif || Object.keys(exif).length === 0) {
$('#no-exif-message').show();
//Generate histogram and tone analysis only
generateHistogram(document.getElementById('fullImage'), document.getElementById('histogram-canvas'));
generateToneAnalysis(document.getElementById('fullImage'));
$('#tone-analysis-section').show();
return;
}
let sectionsShown = 0;
// Section 1: Basic Information
let basicInfoShown = false;
if (fileData.filename) {
let ext = fileData.filename.split('.').pop().toUpperCase();
$('#format-value').text(ext);
$('#format-row').show();
basicInfoShown = true;
} else {
$('#format-row').hide();
}
if (exif.PixelXDimension && exif.PixelYDimension) {
$('#dimensions-value').text(`${exif.PixelXDimension} × ${exif.PixelYDimension}`);
$('#dimensions-row').show();
let pixels = (exif.PixelXDimension * exif.PixelYDimension / 1000000).toFixed(1);
$('#pixels-value').text(`${pixels} MP`);
$('#pixels-row').show();
basicInfoShown = true;
} else {
$('#dimensions-row').hide();
$('#pixels-row').hide();
}
if (exif.ColorSpace !== undefined) {
exif.ColorSpace = JSON.parse(exif.ColorSpace);
let colorSpace = exif.ColorSpace === 1 ? "sRGB" : exif.ColorSpace === 65535 ? "Uncalibrated" : "Unknown";
$('#color-space-value').text(colorSpace);
$('#color-space-row').show();
basicInfoShown = true;
} else {
$('#color-space-row').hide();
}
if (exif.DateTimeOriginal) {
exif.DateTimeOriginal = JSON.parse(exif.DateTimeOriginal);
$('#shooting-time-value').text(exif.DateTimeOriginal.replace(/:/g, '/').replace(' ', ' '));
$('#shooting-time-row').show();
basicInfoShown = true;
} else {
$('#shooting-time-row').hide();
}
if (exif.Software) {
exif.Software = JSON.parse(exif.Software);
$('#software-value').text(exif.Software);
$('#software-row').show();
basicInfoShown = true;
} else {
$('#software-row').hide();
}
if (basicInfoShown) {
$('#basic-info-section').show();
sectionsShown++;
if (sectionsShown > 1) $('#basic-info-divider').show();
}
// Section 2: Shooting Parameters
let shootingParamsShown = false;
if (exif.FocalLength) {
$('#focal-length-value').text(JSON.parse(exif.FocalLength));
$('#focal-length-row').show();
shootingParamsShown = true;
} else {
$('#focal-length-row').hide();
}
if (exif.FNumber) {
exif.FNumber = JSON.parse(exif.FNumber);
let aperture = parseExifValue(exif.FNumber);
let formattedAperture = aperture % 1 === 0 ? aperture.toString() : aperture.toFixed(1);
$('#aperture-value').text('f/' + formattedAperture);
$('#aperture-row').show();
shootingParamsShown = true;
} else {
$('#aperture-row').hide();
}
if (exif.ExposureTime) {
let exposureTime = JSON.parse(exif.ExposureTime);
let formattedExposure = formatShutterSpeed(exposureTime);
$('#shutter-speed-value').text(formattedExposure + 's');
$('#shutter-speed-row').show();
shootingParamsShown = true;
} else {
$('#shutter-speed-row').hide();
}
if (exif.ISOSpeedRatings) {
$('#iso-value').text(exif.ISOSpeedRatings);
$('#iso-row').show();
shootingParamsShown = true;
} else {
$('#iso-row').hide();
}
if (exif.ExposureBiasValue) {
$('#ev-value').text(JSON.parse(exif.ExposureBiasValue));
$('#ev-row').show();
shootingParamsShown = true;
} else {
$('#ev-row').hide();
}
if (shootingParamsShown) {
$('#shooting-params-section').show();
sectionsShown++;
if (sectionsShown > 1) $('#shooting-params-divider').show();
}
// Section 3: Tone Analysis
$('#tone-analysis-section').show();
sectionsShown++;
if (sectionsShown > 1) $('#tone-analysis-divider').show();
generateToneAnalysis(document.getElementById('fullImage'));
// Section 4: Device Information
let deviceInfoShown = false;
if (exif.Make && exif.Model) {
exif.Make = JSON.parse(exif.Make);
exif.Model = JSON.parse(exif.Model);
$('#camera-value').text(`${exif.Make} ${exif.Model}`);
$('#camera-row').show();
deviceInfoShown = true;
} else if (exif.Model) {
exif.Model = JSON.parse(exif.Model);
$('#camera-value').text(exif.Model);
$('#camera-row').show();
deviceInfoShown = true;
} else {
$('#camera-row').hide();
}
if (exif.LensModel) {
exif.LensModel = JSON.parse(exif.LensModel);
$('#lens-value').text(exif.LensModel);
$('#lens-row').show();
deviceInfoShown = true;
} else {
$('#lens-row').hide();
}
if (exif.FocalLength) {
exif.FocalLength = JSON.parse(exif.FocalLength);
$('#focal-length-device-value').text(`${exif.FocalLength}mm`);
$('#focal-length-device-row').show();
deviceInfoShown = true;
} else {
$('#focal-length-device-row').hide();
}
if (exif.MaxApertureValue) {
exif.MaxApertureValue = JSON.parse(exif.MaxApertureValue);
$('#max-aperture-value').text(exif.MaxApertureValue);
$('#max-aperture-row').show();
deviceInfoShown = true;
} else {
$('#max-aperture-row').hide();
}
if (deviceInfoShown) {
$('#device-info-section').show();
sectionsShown++;
if (sectionsShown > 1) $('#device-info-divider').show();
}
// Section 5: Shooting Mode
let shootingModeShown = false;
if (exif.ExposureProgram !== undefined) {
let programs = ["Not defined", "Manual", "Normal program", "Aperture priority", "Shutter priority", "Creative program", "Action program", "Portrait mode", "Landscape mode"];
let program = programs[exif.ExposureProgram] || "Unknown";
$('#exposure-program-value').text(program);
$('#exposure-program-row').show();
shootingModeShown = true;
} else {
$('#exposure-program-row').hide();
}
if (exif.ExposureMode !== undefined) {
let modes = ["Auto exposure", "Manual exposure", "Auto bracket"];
let mode = modes[exif.ExposureMode] || "Unknown";
$('#exposure-mode-value').text(mode);
$('#exposure-mode-row').show();
shootingModeShown = true;
} else {
$('#exposure-mode-row').hide();
}
if (exif.MeteringMode !== undefined) {
let metering = ["Unknown", "Average", "Center-weighted average", "Spot", "Multi-spot", "Pattern", "Partial"];
let meter = metering[exif.MeteringMode] || "Unknown";
$('#metering-mode-value').text(meter);
$('#metering-mode-row').show();
shootingModeShown = true;
} else {
$('#metering-mode-row').hide();
}
if (exif.WhiteBalance !== undefined) {
let wb = exif.WhiteBalance === 0 ? "Auto" : "Manual";
$('#white-balance-value').text(wb);
$('#white-balance-row').show();
shootingModeShown = true;
} else {
$('#white-balance-row').hide();
}
if (exif.Flash !== undefined) {
let flash = (exif.Flash & 1) ? "On" : "Off";
$('#flash-value').text(flash);
$('#flash-row').show();
shootingModeShown = true;
} else {
$('#flash-row').hide();
}
if (exif.SceneCaptureType !== undefined) {
let scenes = ["Standard", "Landscape", "Portrait", "Night scene"];
let scene = scenes[exif.SceneCaptureType] || "Unknown";
$('#scene-capture-value').text(scene);
$('#scene-capture-row').show();
shootingModeShown = true;
} else {
$('#scene-capture-row').hide();
}
if (shootingModeShown) {
$('#shooting-mode-section').show();
sectionsShown++;
if (sectionsShown > 1) $('#shooting-mode-divider').show();
}
// Section 6: Technical Parameters
let technicalParamsShown = false;
if (exif.ShutterSpeedValue) {
exif.ShutterSpeedValue = JSON.parse(exif.ShutterSpeedValue);
let apexValue = parseExifValue(exif.ShutterSpeedValue);
let shutterSpeedSeconds = Math.pow(2, -apexValue);
let shutterValue = formatShutterSpeed(shutterSpeedSeconds);
$('#shutter-speed-tech-value').text(shutterValue);
$('#shutter-speed-tech-row').show();
technicalParamsShown = true;
} else {
$('#shutter-speed-tech-row').hide();
}
if (exif.ApertureValue) {
exif.ApertureValue = JSON.parse(exif.ApertureValue);
let apexValue = parseExifValue(exif.ApertureValue);
let apertureValue = Math.pow(2, apexValue / 2);
$('#aperture-value-value').text(apertureValue.toFixed(1) + ' EV');
$('#aperture-value-row').show();
technicalParamsShown = true;
} else {
$('#aperture-value-row').hide();
}
if (exif.FocalPlaneXResolution && exif.FocalPlaneYResolution) {
exif.FocalPlaneXResolution = JSON.parse(exif.FocalPlaneXResolution);
exif.FocalPlaneYResolution = JSON.parse(exif.FocalPlaneYResolution);
let xRes = parseExifValue(exif.FocalPlaneXResolution);
let yRes = parseExifValue(exif.FocalPlaneYResolution);
$('#focal-plane-res-value').text(Math.round(xRes) + ' × ' + Math.round(yRes));
$('#focal-plane-res-row').show();
technicalParamsShown = true;
} else {
$('#focal-plane-res-row').hide();
}
if (technicalParamsShown) {
$('#technical-params-section').show();
sectionsShown++;
if (sectionsShown > 1) $('#technical-params-divider').show();
}
}
function restoreFromHash() {
if (window.location.hash) {
let hashData = decodeURIComponent(window.location.hash.substring(1));
try {
let data = JSON.parse(hashData);
// Find the element with matching filepath
let elements = document.querySelectorAll('[filedata]');
for (let el of elements) {
let fdStr = el.getAttribute('filedata');
if (fdStr) {
let fd = JSON.parse(decodeURIComponent(fdStr));
if (fd.filepath === data.filepath) {
showImage(el);
ShowModal();
break;
}
}
}
} catch (e) {
console.error('Invalid hash data', e);
}
}
}
// Modify the window onload event to ensure folder and thumbnails are loaded first
window.addEventListener('load', () => {
setTimeout(function(){
if (window.location.hash) {
const hashData = decodeURIComponent(window.location.hash.substring(1));
try {
const data = JSON.parse(hashData);
let filename = data.filename;
let filepath = data.filepath;
let dir = filepath.split("/").slice(0, -1).join("/");
// Access the Alpine data instance
const appElement = document.querySelector('[x-data*="photoListObject"]');
if (appElement) {
const app = appElement._x_dataStack[0];
if (app.currentPath !== dir) {
app.updateRenderingPath(dir, () => {
setTimeout(function(){
console.log("Test")
restoreFromHash();
}, 100);
});
} else {
// Folder is already loaded, try to restore immediately
restoreFromHash();
}
}
} catch (e) {
console.error('Invalid hash data', e);
}
}
}, 100);
});
// ── Arozcast Photo Cast ───────────────────────────────────────────────────────
let _photoCastWs = null;
let _photoCastCode = null;
let _photoCastPingTimer = null;
let _photoCastWatchTimer = null;
let _photoCastLastSeen = 0;
let _currentCastFilepath = null;
let _photoCastReconnectTimer = null;
let _photoCastReconnectCount = 0;
let _photoCastPendingCode = null;
function _photoCastConnected() { return _photoCastWs !== null && _photoCastWs.readyState === WebSocket.OPEN; }
function openPhotoCastDialog() {
const modal = document.getElementById('cast-photo-modal');
const inp = document.getElementById('cast-photo-code');
const err = document.getElementById('cast-photo-error');
const stat = document.getElementById('cast-photo-status');
const connBtn = document.getElementById('cast-photo-conn-btn');
const discBtn = document.getElementById('cast-photo-disc-btn');
err.textContent = '';
if (_photoCastConnected()) {
inp.style.display = 'none';
stat.style.display = 'block';
stat.textContent = 'Connected to room ' + _photoCastCode;
connBtn.style.display = 'none';
discBtn.style.display = '';
} else {
inp.style.display = '';
inp.value = '';
stat.style.display = 'none';
connBtn.style.display = '';
discBtn.style.display = 'none';
}
modal.classList.add('visible');
if (inp.style.display !== 'none') inp.focus();
}
function closePhotoCastDialog() {
document.getElementById('cast-photo-modal').classList.remove('visible');
}
function connectPhotoCast() {
const inp = document.getElementById('cast-photo-code');
const err = document.getElementById('cast-photo-error');
const code = inp.value.trim();
if (code.length !== 4) { err.textContent = 'Enter a 4-digit room code.'; return; }
err.textContent = '';
fetch(ao_root + 'api/arozcast/ping?code=' + code)
.then(r => r.json())
.then(data => {
if (!data.exists) { err.textContent = 'Room not found. Is Arozcast running?'; return; }
// Disconnect any existing photo cast session
if (_photoCastWs) {
_photoCastWs.onclose = null;
_photoCastWs.close();
_photoCastWs = null;
}
clearInterval(_photoCastPingTimer);
clearInterval(_photoCastWatchTimer);
// Cancel any pending auto-reconnect to the old room — user is opening a new session
clearTimeout(_photoCastReconnectTimer); _photoCastReconnectTimer = null;
_photoCastReconnectCount = 0; _photoCastPendingCode = null;
// Signal other apps (e.g. Musicify) to yield the cast session
try { new BroadcastChannel('arozcast').postMessage({ type: 'arozcast.takeover' }); } catch(e) {}
_photoCastCode = code;
const wsUrl = new URL(ao_root + 'api/arozcast/ws?code=' + code, window.location.href);
wsUrl.protocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
const ws = new WebSocket(wsUrl.toString());
_photoCastWs = ws;
ws.onopen = function() {
_photoCastLastSeen = Date.now();
ws.send(JSON.stringify({ topic: 'peer.hello', payload: {} }));
if (_currentCastFilepath) _photoCastSendPhoto(_currentCastFilepath);
_photoCastPingTimer = setInterval(function() {
if (_photoCastConnected()) ws.send(JSON.stringify({ topic: 'peer.heartbeat', payload: {} }));
}, 5000);
_photoCastWatchTimer = setInterval(function() {
if (Date.now() - _photoCastLastSeen > 12000) { ws.close(); }
}, 4000);
// Update modal to connected state
document.getElementById('cast-photo-status').style.display = 'block';
document.getElementById('cast-photo-status').textContent = 'Connected to room ' + code;
document.getElementById('cast-photo-code').style.display = 'none';
document.getElementById('cast-photo-conn-btn').style.display = 'none';
document.getElementById('cast-photo-disc-btn').style.display = '';
document.getElementById('cast-photo-btn').classList.add('casting');
};
ws.onmessage = function() { _photoCastLastSeen = Date.now(); };
ws.onclose = function() {
clearInterval(_photoCastPingTimer);
clearInterval(_photoCastWatchTimer);
var savedCode = _photoCastCode;
_photoCastWs = null;
_photoCastCode = null;
document.getElementById('cast-photo-btn').classList.remove('casting');
_startPhotoCastReconnect(savedCode);
};
ws.onerror = function() { err.textContent = 'Connection error.'; };
closePhotoCastDialog();
})
.catch(function() { err.textContent = 'Network error.'; });
}
function disconnectPhotoCast() {
// Cancel any pending auto-reconnect before tearing down
clearTimeout(_photoCastReconnectTimer); _photoCastReconnectTimer = null;
_photoCastReconnectCount = 0; _photoCastPendingCode = null;
if (_photoCastWs) {
_photoCastWs.onclose = null; // suppress reconnect trigger
_photoCastWs.close();
_photoCastWs = null;
}
clearInterval(_photoCastPingTimer);
clearInterval(_photoCastWatchTimer);
_photoCastCode = null;
document.getElementById('cast-photo-btn').classList.remove('casting');
closePhotoCastDialog();
}
function _photoCastSendPhoto(filepath) {
if (!_photoCastConnected()) return;
var fileUrl = ao_root + 'media?file=' + encodeURIComponent(filepath);
_photoCastWs.send(JSON.stringify({
topic: 'media.load',
payload: { filepath: filepath, name: filepath.split('/').pop(), type: 'photo', src: fileUrl }
}));
}
// ── Arozcast photo auto-reconnect ─────────────────────────────────────────────
var _PHOTO_CAST_RECONNECT_DELAYS = [2000, 5000, 12000];
function _startPhotoCastReconnect(code) {
if (!code || _photoCastReconnectCount >= _PHOTO_CAST_RECONNECT_DELAYS.length) {
_photoCastReconnectCount = 0; _photoCastPendingCode = null;
return;
}
_photoCastPendingCode = code;
var delay = _PHOTO_CAST_RECONNECT_DELAYS[_photoCastReconnectCount++];
clearTimeout(_photoCastReconnectTimer);
_photoCastReconnectTimer = setTimeout(function() {
_photoCastReconnectTimer = null;
_attemptPhotoCastReconnect();
}, delay);
}
function _attemptPhotoCastReconnect() {
if (!_photoCastPendingCode) return;
var code = _photoCastPendingCode;
var wsUrl = new URL(ao_root + 'api/arozcast/ws?code=' + code, window.location.href);
wsUrl.protocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
var ws = new WebSocket(wsUrl.toString());
var openTimer = setTimeout(function() {
ws.onopen = ws.onclose = ws.onerror = null; ws.close();
_startPhotoCastReconnect(code);
}, 8000);
ws.onopen = function() { clearTimeout(openTimer); _photoCastPendingCode = null; _photoCastDidReconnect(ws, code); };
ws.onerror = function() {};
ws.onclose = function() { clearTimeout(openTimer); _startPhotoCastReconnect(code); };
}
function _photoCastDidReconnect(ws, code) {
_photoCastWs = ws;
_photoCastCode = code;
_photoCastLastSeen = Date.now();
ws.onmessage = function() { _photoCastLastSeen = Date.now(); _photoCastReconnectCount = 0; }; // any reply = receiver alive — reset retry counter
ws.onclose = function() {
clearInterval(_photoCastPingTimer); clearInterval(_photoCastWatchTimer);
var savedCode = _photoCastCode;
_photoCastWs = null; _photoCastCode = null;
document.getElementById('cast-photo-btn').classList.remove('casting');
_startPhotoCastReconnect(savedCode);
};
// Re-announce and re-push the current photo
ws.send(JSON.stringify({ topic: 'peer.hello', payload: {} }));
if (_currentCastFilepath) _photoCastSendPhoto(_currentCastFilepath);
clearInterval(_photoCastPingTimer); clearInterval(_photoCastWatchTimer);
_photoCastPingTimer = setInterval(function() {
if (_photoCastConnected()) ws.send(JSON.stringify({ topic: 'peer.heartbeat', payload: {} }));
}, 5000);
_photoCastWatchTimer = setInterval(function() {
if (Date.now() - _photoCastLastSeen > 12000) { ws.close(); }
}, 4000);
document.getElementById('cast-photo-btn').classList.add('casting');
var stat = document.getElementById('cast-photo-status');
if (stat) { stat.style.display = 'block'; stat.textContent = 'Reconnected to room ' + code; }
}
// When the user returns to this tab after the phone was asleep, reconnect immediately
document.addEventListener('visibilitychange', function() {
if (document.visibilityState === 'visible' && _photoCastPendingCode) {
clearTimeout(_photoCastReconnectTimer);
_photoCastReconnectTimer = null;
_attemptPhotoCastReconnect();
}
});