/* 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 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'); } function getImageWidth(){ // Use the actual viewbox container width so the sidebar and scrollbar are // already subtracted — this prevents gaps when the window is resized. const container = document.getElementById('viewboxContainer'); const containerWidth = container ? container.clientWidth : (window.innerWidth - 210); let boxCount; if (containerWidth < 400) { boxCount = 2; } else if (containerWidth < 600) { boxCount = 3; } else if (containerWidth < 900) { boxCount = 4; } else if (containerWidth < 1100) { boxCount = 5; } else if (containerWidth < 1400) { boxCount = 6; } else { boxCount = 8; } return Math.floor(containerWidth / boxCount); } function updateImageSizes(){ let newImageWidth = getImageWidth(); console.log(newImageWidth, $("#viewbox").width()); //Updates all the size of the images $(".imagecard").css({ width: newImageWidth, height: newImageWidth }); } 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', restored: false, hasMoreImages: false, isLoadingMore: false, // guard: blocks new batch until DOM has updated sidebarOpen: !isMobile, // start hidden on mobile, visible on desktop // init init() { this.getFolderInfo(); this.getRootInfo(); this.renderSize = getImageWidth(); updateImageSizes(); this.restored = false; this.$nextTick(() => { this.setupInfiniteScroll(); }); 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]; 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(); } }); } } } 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(); 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"))); $("#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; } // Update navigation buttons state if (typeof updateNavigationButtons === 'function') { updateNavigationButtons(); } ao_module_setWindowTitle("Photo - " + fd.filename); 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; } $(document).on("keydown", function(e){ if (e.keyCode == 27){ // Escape if ($('#photo-viewer').is(':visible')) { closeViewer(); } } else if (e.keyCode == 37){ //Left if (prePhoto != null){ showImage(prePhoto); } }else if (e.keyCode == 39){ //Right 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); });