photo.js 62 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560
  1. /*
  2. Photo.js
  3. Author: tobychui
  4. This is a complete rewrite of the legacy Photo module for ArozOS
  5. */
  6. // Number of photos to load per page (infinite scroll batch size)
  7. const PAGE_SIZE = 40; //large enough to fill the whole page on load, but small enough to keep initial load fast and responsive
  8. let photoList = [];
  9. let prePhoto = "";
  10. let nextPhoto = "";
  11. let currentModel = "";
  12. let currentPhotoAllIndex = -1; // index of current photo in allImages (full server list)
  13. let currentPhotoFilepath = null; // filepath of the photo open in the viewer (download / rating)
  14. let currentPhotoRating = 0; // star rating (0-5) of the open photo
  15. let isMobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent);
  16. // Check if image should use compression (only JPG/PNG)
  17. function shouldUseCompression(filepath, filesize) {
  18. const ext = filepath.split('.').pop().toLowerCase();
  19. const isJpgOrPng = (ext === 'jpg' || ext === 'jpeg' || ext === 'png');
  20. const COMPRESSION_THRESHOLD = 5 * 1024 * 1024; // 5MB
  21. return isJpgOrPng && filesize && filesize > COMPRESSION_THRESHOLD;
  22. }
  23. // Get viewable image URL (handles RAW files)
  24. function getViewableImageUrl(filepath, callback) {
  25. // Both RAW and regular images now use backend rendering
  26. const imageUrl = "../media?file=" + encodeURIComponent(filepath);
  27. callback(imageUrl, true, false, isRawImage(filepath) ? 'backend_raw' : 'direct');
  28. }
  29. // Grid zoom: 0 = responsive (auto column count by width); a positive value pins
  30. // the number of columns, set by the user with Ctrl/⌘ + mouse wheel over the grid
  31. // (Google-Photos-style gallery zoom). Smaller column count => larger thumbnails.
  32. let photoGridColumns = 0;
  33. const PHOTO_GRID_MIN_COLS = 2;
  34. const PHOTO_GRID_MAX_COLS = 12;
  35. function getContainerWidth(){
  36. const container = document.getElementById('viewboxContainer');
  37. return container ? container.clientWidth : (window.innerWidth - 210);
  38. }
  39. // Responsive column count for a given container width (used until the user
  40. // pins a zoom level via the mouse wheel).
  41. function autoColumnCount(containerWidth){
  42. if (containerWidth < 400) return 2;
  43. if (containerWidth < 600) return 3;
  44. if (containerWidth < 900) return 4;
  45. if (containerWidth < 1100) return 5;
  46. if (containerWidth < 1400) return 6;
  47. return 8;
  48. }
  49. function getColumnCount(){
  50. if (photoGridColumns > 0) return photoGridColumns;
  51. return autoColumnCount(getContainerWidth());
  52. }
  53. function getImageWidth(){
  54. // Use the actual viewbox container width so the sidebar and scrollbar are
  55. // already subtracted — this prevents gaps when the window is resized.
  56. return Math.floor(getContainerWidth() / getColumnCount());
  57. }
  58. function updateImageSizes(){
  59. let newImageWidth = getImageWidth();
  60. //Updates all the size of the images
  61. $(".imagecard").css({
  62. width: newImageWidth,
  63. height: newImageWidth
  64. });
  65. }
  66. // Briefly show the current columns-per-row while zooming the grid.
  67. let _gridZoomHintTimer = null;
  68. function showGridZoomHint(cols){
  69. const el = document.getElementById('grid-zoom-snackbar');
  70. if (!el) return;
  71. el.textContent = cols + ' per row';
  72. el.classList.add('visible');
  73. clearTimeout(_gridZoomHintTimer);
  74. _gridZoomHintTimer = setTimeout(function(){ el.classList.remove('visible'); }, 1100);
  75. }
  76. // ── Date grouping (Google-Photos-style Year / Month sections) ──────────────────
  77. // Capture date for a grid item: the EXIF shoot time (taken_date, supplied by
  78. // both search results and folder listings via the photo index) with file mtime
  79. // as the fallback for photos the background indexer has not reached yet.
  80. function photoImageDateUnix(img){
  81. if (!img) return null;
  82. if (img.taken_date) return img.taken_date;
  83. if (img.mtime) return img.mtime;
  84. if (img.modified_date) return img.modified_date;
  85. return null;
  86. }
  87. // "June 2023" style header for a unix-second timestamp (PHOTO_MONTHS: search.js).
  88. function photoGroupLabel(unixSec){
  89. if (!unixSec) return 'Undated';
  90. var d = new Date(unixSec * 1000);
  91. if (isNaN(d.getTime())) return 'Undated';
  92. return PHOTO_MONTHS[d.getMonth()] + ' ' + d.getFullYear();
  93. }
  94. // Newest-first ordering so each Year/Month section is contiguous regardless of
  95. // the folder's own sort order.
  96. function sortImagesByDateDesc(arr){
  97. return arr.slice().sort(function(a, b){
  98. return (photoImageDateUnix(b) || 0) - (photoImageDateUnix(a) || 0);
  99. });
  100. }
  101. function extractFolderName(folderpath){
  102. return folderpath.split("/").pop();
  103. }
  104. function parseExifValue(value) {
  105. if (typeof value === 'string' && value.includes('/')) {
  106. let parts = value.split('/');
  107. if (parts.length === 2) {
  108. let num = parseFloat(parts[0]);
  109. let den = parseFloat(parts[1]);
  110. if (den !== 0) {
  111. return num / den;
  112. }
  113. }
  114. }
  115. return parseFloat(value) || value;
  116. }
  117. function formatShutterSpeed(value) {
  118. let num = parseExifValue(value);
  119. if (num < 1) {
  120. return "1/" + Math.round(1 / num);
  121. } else {
  122. return num ;
  123. }
  124. }
  125. function photoListObject() {
  126. return {
  127. // data
  128. pathWildcard: "user:/Photo/*",
  129. currentPath: "user:/Photo",
  130. renderSize: 200,
  131. vroots: [],
  132. allImages: [], // full list from server
  133. images: [], // currently displayed slice
  134. folders: [],
  135. sortOrder: 'smart',
  136. groupByDate: true, // Google-Photos-style Year / Month grid sections
  137. ratingFilter: 0, // active "rating ≥ N stars" quick filter (0 = off)
  138. restored: false,
  139. hasMoreImages: false,
  140. isLoadingMore: false, // guard: blocks new batch until DOM has updated
  141. sidebarOpen: !isMobile, // start hidden on mobile, visible on desktop
  142. // search state (tags input)
  143. searchTags: [], // committed filter chips: {label, value, type}
  144. searchInput: '', // text currently being typed in the box
  145. searchMode: false, // true while showing search results instead of a folder
  146. suggestions: [],
  147. showSuggestions: false,
  148. suggestIndex: -1, // keyboard-highlighted suggestion (-1 = none)
  149. searchTotal: 0,
  150. indexing: false,
  151. indexStatusText: '',
  152. _suggestTimer: null,
  153. _searchTimer: null,
  154. // init
  155. init() {
  156. this.getFolderInfo();
  157. this.getRootInfo();
  158. this.renderSize = getImageWidth();
  159. updateImageSizes();
  160. this.restored = false;
  161. this.$nextTick(() => { this.setupInfiniteScroll(); this.setupGridZoom(); });
  162. // Kick off background (auto) indexing shortly after the first paint so
  163. // it doesn't compete with the initial folder load.
  164. setTimeout(() => { this.startAutoIndex(); }, 1200);
  165. const MOBILE_BP = 768;
  166. let _prevMobile = window.innerWidth <= MOBILE_BP;
  167. let _resizeTimer;
  168. window.addEventListener('resize', () => {
  169. clearTimeout(_resizeTimer);
  170. _resizeTimer = setTimeout(() => {
  171. // Recalculate tile sizes
  172. this.renderSize = getImageWidth();
  173. updateImageSizes();
  174. // Auto-manage sidebar visibility on breakpoint crossing
  175. const nowMobile = window.innerWidth <= MOBILE_BP;
  176. if (nowMobile && !_prevMobile) {
  177. // Desktop → mobile: hide sidebar so it doesn't overlay content
  178. this.sidebarOpen = false;
  179. } else if (!nowMobile && _prevMobile) {
  180. // Mobile → desktop: sidebar is back in normal flow, keep state clean
  181. this.sidebarOpen = false;
  182. }
  183. _prevMobile = nowMobile;
  184. }, 80);
  185. });
  186. },
  187. updateRenderingPath(newPath, callback = null){
  188. this.currentPath = JSON.parse(JSON.stringify(newPath));
  189. this.pathWildcard = newPath + '/*';
  190. this.restored = false;
  191. if (isMobile) this.sidebarOpen = false;
  192. this.getFolderInfo(callback);
  193. },
  194. // Returns path segments for the sidebar breadcrumb tree
  195. getPathSegments() {
  196. const parts = this.currentPath.split('/');
  197. let segments = [];
  198. let accumulated = '';
  199. for (let i = 0; i < parts.length; i++) {
  200. accumulated = i === 0 ? parts[0] : accumulated + '/' + parts[i];
  201. segments.push({ name: parts[i], path: accumulated, depth: i, isDiskRoot: i === 0 });
  202. }
  203. return segments;
  204. },
  205. getFolderInfo(callback = null) {
  206. fetch(ao_root + "system/ajgi/interface?script=Photo/backend/listFolder.js", {
  207. method: 'POST',
  208. cache: 'no-cache',
  209. headers: {
  210. 'Content-Type': 'application/json'
  211. },
  212. body: JSON.stringify({
  213. "folder": this.pathWildcard,
  214. "sort": this.sortOrder
  215. })
  216. }).then(resp => {
  217. resp.json().then(data => {
  218. console.log(data);
  219. this.folders = data[0];
  220. this.allImages = data[1];
  221. // Date grouping reads cleanest newest-first; sort the loaded
  222. // list so Year/Month sections are contiguous as you scroll.
  223. if (this.groupByDate) { this.allImages = sortImagesByDateDesc(this.allImages); }
  224. this.images = this.allImages.slice(0, PAGE_SIZE);
  225. this.hasMoreImages = this.allImages.length > PAGE_SIZE;
  226. this.isLoadingMore = false;
  227. if (this.allImages.length == 0){
  228. $("#noimg").show();
  229. }else{
  230. $("#noimg").hide();
  231. }
  232. console.log(this.folders);
  233. if (!this.restored) { restoreFromHash(); this.restored = true; }
  234. if (callback) callback();
  235. });
  236. });
  237. },
  238. getRootInfo() {
  239. fetch(ao_root + "system/ajgi/interface?script=Photo/backend/listRoots.js", {
  240. method: 'POST',
  241. cache: 'no-cache',
  242. headers: {
  243. 'Content-Type': 'application/json'
  244. },
  245. body: JSON.stringify({})
  246. }).then(resp => {
  247. resp.json().then(data => {
  248. this.vroots = data;
  249. this.$nextTick(() => {
  250. $('.ui.dropdown').dropdown();
  251. });
  252. });
  253. })
  254. },
  255. changeSort(newSort) {
  256. this.sortOrder = newSort;
  257. this.getFolderInfo();
  258. },
  259. // Load the next PAGE_SIZE images into the displayed list
  260. loadMoreImages() {
  261. if (this.isLoadingMore) return;
  262. const current = this.images.length;
  263. if (current >= this.allImages.length) return;
  264. this.isLoadingMore = true;
  265. const next = this.allImages.slice(current, current + PAGE_SIZE);
  266. this.images = this.images.concat(next);
  267. this.hasMoreImages = this.images.length < this.allImages.length;
  268. // Release the guard only after Alpine has re-rendered and scrollHeight has grown
  269. this.$nextTick(() => { this.isLoadingMore = false; });
  270. },
  271. // Attach a scroll listener to the viewbox container for infinite scroll
  272. setupInfiniteScroll() {
  273. const container = document.getElementById('viewboxContainer');
  274. if (!container) return;
  275. container.addEventListener('scroll', () => {
  276. const { scrollTop, scrollHeight, clientHeight } = container;
  277. // Trigger when within 300px of the bottom
  278. if (scrollTop + clientHeight >= scrollHeight - 300) {
  279. this.loadMoreImages();
  280. }
  281. });
  282. },
  283. // ── Grid zoom (Ctrl/⌘ + mouse wheel) ───────────────────────────────────
  284. // Recompute the tile size from the current column count and push it to
  285. // both the reactive binding and the already-rendered cards.
  286. applyRenderSize() {
  287. this.renderSize = getImageWidth();
  288. updateImageSizes();
  289. },
  290. // Ctrl/⌘ + wheel (and trackpad pinch, which also sets ctrlKey) zooms the
  291. // whole grid; a plain wheel keeps scrolling the gallery.
  292. setupGridZoom() {
  293. const container = document.getElementById('viewboxContainer');
  294. if (!container) return;
  295. container.addEventListener('wheel', (e) => {
  296. if (!e.ctrlKey && !e.metaKey) return;
  297. e.preventDefault();
  298. this.zoomGrid(e.deltaY < 0 ? 1 : -1);
  299. }, { passive: false });
  300. },
  301. // direction: +1 = zoom in (fewer columns, larger photos), -1 = zoom out.
  302. zoomGrid(direction) {
  303. const cur = getColumnCount();
  304. let next = cur - direction;
  305. if (next < PHOTO_GRID_MIN_COLS) next = PHOTO_GRID_MIN_COLS;
  306. if (next > PHOTO_GRID_MAX_COLS) next = PHOTO_GRID_MAX_COLS;
  307. if (next === cur) return;
  308. photoGridColumns = next;
  309. this.applyRenderSize();
  310. showGridZoomHint(next);
  311. },
  312. // ── Date grouping ──────────────────────────────────────────────────────
  313. // Split the loaded slice into contiguous Year/Month sections for the grid.
  314. // Returns a single unlabelled group when grouping is disabled.
  315. groupedImages() {
  316. const imgs = this.images;
  317. if (!this.groupByDate) {
  318. return [{ key: 'all', label: '', images: imgs }];
  319. }
  320. const groups = [];
  321. let cur = null;
  322. for (let i = 0; i < imgs.length; i++) {
  323. const img = imgs[i];
  324. const d = photoImageDateUnix(img);
  325. let key, label;
  326. if (d) {
  327. const dt = new Date(d * 1000);
  328. key = dt.getFullYear() + '-' + (dt.getMonth() + 1);
  329. label = photoGroupLabel(d);
  330. } else {
  331. key = 'undated';
  332. label = 'Undated';
  333. }
  334. if (!cur || cur.key !== key) {
  335. cur = { key: key, label: label, images: [] };
  336. groups.push(cur);
  337. }
  338. cur.images.push(img);
  339. }
  340. return groups;
  341. },
  342. // Re-fetch the current view so the new ordering / headers take effect.
  343. onGroupByDateChange() {
  344. if (this.searchMode) { this.runSearch(); }
  345. else { this.getFolderInfo(); }
  346. },
  347. // ── Rating quick-filter (sidebar stars) ────────────────────────────────
  348. // Select "rating ≥ n" (tapping the active level again clears it). Driven
  349. // through the same search-chip pipeline as every other filter.
  350. setRatingFilter(n) {
  351. n = parseInt(n, 10) || 0;
  352. if (n === this.ratingFilter) n = 0;
  353. this.ratingFilter = n;
  354. this.searchTags = this.searchTags.filter(function (t) { return t.type !== 'rating'; });
  355. if (n > 0) {
  356. this.searchTags.push({ label: '★ ≥ ' + n, value: 'rating:>=' + n, type: 'rating' });
  357. }
  358. this.runSearch();
  359. },
  360. // Keep the sidebar stars in step with whatever rating chip is present
  361. // (a chip may also be typed, picked from autocomplete or removed by hand).
  362. syncRatingFilterFromTags() {
  363. let found = 0;
  364. for (let i = 0; i < this.searchTags.length; i++) {
  365. const t = this.searchTags[i];
  366. if (t.type === 'rating') {
  367. const m = ('' + t.value).match(/(\d)/);
  368. if (m) found = parseInt(m[1], 10);
  369. }
  370. }
  371. this.ratingFilter = found;
  372. },
  373. // ── Search ────────────────────────────────────────────────────────────
  374. suggestIcon(type) { return photoSuggestIcon(type); },
  375. // The committed query is the chips; currentQuery also folds in the text
  376. // still being typed so results update live.
  377. committedQuery() { return this.searchTags.map(t => t.value).join(' '); },
  378. currentQuery() {
  379. let q = this.committedQuery();
  380. const inp = this.searchInput.trim();
  381. if (inp) q = (q ? q + ' ' : '') + inp;
  382. return q;
  383. },
  384. searchActive() { return this.searchTags.length > 0 || this.searchInput.trim().length > 0; },
  385. // Debounced autocomplete + live search on every keystroke.
  386. onSearchInput() {
  387. this.suggestIndex = -1;
  388. const q = this.searchInput;
  389. clearTimeout(this._suggestTimer);
  390. this._suggestTimer = setTimeout(() => { this.fetchSuggestions(q); }, 150);
  391. this.scheduleSearch();
  392. },
  393. fetchSuggestions(q) {
  394. aoPhotoBackend("Photo/backend/searchSuggest.js", { q: q }).then(data => {
  395. this.suggestions = (data && data.suggestions) ? data.suggestions : [];
  396. this.showSuggestions = this.suggestions.length > 0;
  397. }).catch(() => {
  398. this.suggestions = [];
  399. this.showSuggestions = false;
  400. });
  401. },
  402. scheduleSearch() {
  403. clearTimeout(this._searchTimer);
  404. this._searchTimer = setTimeout(() => { this.runSearch(); }, 400);
  405. },
  406. // Add a chip from an explicit {label, value, type}.
  407. addTag(tag) {
  408. if (!tag || !tag.value) return;
  409. // Ignore exact duplicates (same token already a chip).
  410. if (this.searchTags.some(t => t.value === tag.value)) {
  411. this.searchInput = '';
  412. this.showSuggestions = false;
  413. return;
  414. }
  415. this.searchTags.push({ label: tag.label, value: tag.value, type: tag.type || 'search' });
  416. this.searchInput = '';
  417. this.suggestions = [];
  418. this.showSuggestions = false;
  419. this.suggestIndex = -1;
  420. this.runSearch();
  421. },
  422. // A picked autocomplete suggestion becomes a chip.
  423. applySuggestion(s) { this.addTag({ label: s.label, value: s.value, type: s.type }); },
  424. // Commit whatever text is in the box as a chip (Enter / Space).
  425. commitInput() {
  426. const text = this.searchInput.trim();
  427. if (text.length === 0) return;
  428. this.addTag(photoParseTagToken(text));
  429. },
  430. removeTag(i) {
  431. this.searchTags.splice(i, 1);
  432. this.runSearch();
  433. this.$nextTick(() => { const el = document.getElementById('photo-search-input'); if (el) el.focus(); });
  434. },
  435. removeLastTag() {
  436. if (this.searchTags.length > 0) {
  437. this.searchTags.pop();
  438. this.runSearch();
  439. }
  440. },
  441. runSearch() {
  442. // NOTE: this is also the live/debounced search fired while typing, so it
  443. // must NOT close the autocomplete dropdown — only explicit actions
  444. // (pick/commit/Escape/click-outside/clear) hide it. Closing it here made
  445. // the dropdown flash and vanish ~400ms after each keystroke.
  446. clearTimeout(this._searchTimer);
  447. this.syncRatingFilterFromTags();
  448. const q = this.currentQuery();
  449. if (q.length === 0) {
  450. // Nothing to search — fall back to normal folder browsing.
  451. if (this.searchMode) { this.searchMode = false; this.searchTotal = 0; this.getFolderInfo(); }
  452. return;
  453. }
  454. this.searchMode = true;
  455. aoPhotoBackend("Photo/backend/searchPhotos.js", {
  456. q: q,
  457. sort: 'taken_desc',
  458. limit: 1000
  459. }).then(data => {
  460. const results = (data && data.results) ? data.results : [];
  461. this.searchTotal = (data && typeof data.total === 'number') ? data.total : results.length;
  462. // Carry the date + rating through so the grid can group by month
  463. // and the viewer can show the star rating without a refetch.
  464. this.allImages = results.map(r => ({
  465. filepath: r.filepath, filesize: r.filesize,
  466. taken_date: r.taken_date, modified_date: r.modified_date, rating: r.rating
  467. }));
  468. if (this.groupByDate) { this.allImages = sortImagesByDateDesc(this.allImages); }
  469. this.images = this.allImages.slice(0, PAGE_SIZE);
  470. this.hasMoreImages = this.allImages.length > PAGE_SIZE;
  471. this.isLoadingMore = false;
  472. this.folders = [];
  473. if (this.allImages.length == 0) { $("#noimg").show(); } else { $("#noimg").hide(); }
  474. this.$nextTick(() => { updateImageSizes(); });
  475. }).catch(() => { /* leave current view untouched on error */ });
  476. },
  477. clearSearch() {
  478. this.searchTags = [];
  479. this.searchInput = '';
  480. this.suggestions = [];
  481. this.showSuggestions = false;
  482. this.suggestIndex = -1;
  483. this.searchTotal = 0;
  484. this.searchMode = false;
  485. this.ratingFilter = 0;
  486. clearTimeout(this._searchTimer);
  487. this.getFolderInfo(); // restore normal folder browsing
  488. },
  489. onSearchKeydown(e) {
  490. if (e.key === 'Enter') {
  491. e.preventDefault();
  492. if (this.showSuggestions && this.suggestIndex >= 0 && this.suggestions[this.suggestIndex]) {
  493. this.applySuggestion(this.suggestions[this.suggestIndex]);
  494. } else if (this.searchInput.trim().length > 0) {
  495. this.commitInput();
  496. } else {
  497. this.runSearch();
  498. }
  499. } else if (e.key === ' ') {
  500. // Space turns the current token into a chip (unless mid-quote/"key:").
  501. if (photoInputCommittable(this.searchInput)) {
  502. e.preventDefault();
  503. this.commitInput();
  504. }
  505. } else if (e.key === 'Backspace') {
  506. if (this.searchInput.length === 0 && this.searchTags.length > 0) {
  507. e.preventDefault();
  508. this.removeLastTag();
  509. }
  510. } else if (e.key === 'ArrowDown') {
  511. if (this.showSuggestions) {
  512. e.preventDefault();
  513. this.suggestIndex = Math.min(this.suggestIndex + 1, this.suggestions.length - 1);
  514. }
  515. } else if (e.key === 'ArrowUp') {
  516. if (this.showSuggestions) {
  517. e.preventDefault();
  518. this.suggestIndex = Math.max(this.suggestIndex - 1, -1);
  519. }
  520. } else if (e.key === 'Escape') {
  521. if (this.showSuggestions) { this.showSuggestions = false; }
  522. else if (this.searchActive()) { this.clearSearch(); }
  523. }
  524. },
  525. // ── Auto indexing ─────────────────────────────────────────────────────
  526. // Loops indexPhotos.js in the background until the whole library is
  527. // indexed. Incremental, so steady-state runs finish in a single pass.
  528. startAutoIndex() {
  529. if (this.indexing) return;
  530. this.indexing = true;
  531. let indexedThisRun = 0;
  532. const step = () => {
  533. aoPhotoBackend("Photo/backend/indexPhotos.js", { mode: 'incremental' }).then(data => {
  534. if (data && data.error) { this.indexing = false; this.indexStatusText = ''; return; }
  535. const total = (data && data.total) ? data.total : 0;
  536. indexedThisRun += (data && data.indexed) ? data.indexed : 0;
  537. if (data && data.hasMore) {
  538. this.indexStatusText = 'Indexing… ' + total + ' photos';
  539. setTimeout(step, 50);
  540. } else {
  541. this.indexing = false;
  542. this.indexStatusText = total ? (total + ' photos indexed') : '';
  543. // Newly indexed photos may carry EXIF shoot times the
  544. // current grid grouped without (mtime fallback) — reload
  545. // the view so the Year/Month sections use them.
  546. if (indexedThisRun > 0 && this.groupByDate && !this.searchMode) {
  547. this.getFolderInfo();
  548. }
  549. setTimeout(() => { if (!this.indexing) this.indexStatusText = ''; }, 4000);
  550. }
  551. }).catch(() => { this.indexing = false; this.indexStatusText = ''; });
  552. };
  553. step();
  554. },
  555. // Force a full re-index (used by the "Rebuild" action). The first request
  556. // wipes the index ("full"); subsequent rounds loop incrementally so the
  557. // batch loop advances to completion.
  558. rebuildIndex() {
  559. if (this.indexing) return;
  560. this.indexing = true;
  561. this.indexStatusText = 'Rebuilding index…';
  562. const step = (mode) => {
  563. aoPhotoBackend("Photo/backend/indexPhotos.js", { mode: mode }).then(data => {
  564. if (data && data.error) { this.indexing = false; this.indexStatusText = ''; return; }
  565. const total = (data && data.total) ? data.total : 0;
  566. if (data && data.hasMore) {
  567. this.indexStatusText = 'Rebuilding… ' + total + ' photos';
  568. setTimeout(() => step('incremental'), 50);
  569. } else {
  570. this.indexing = false;
  571. this.indexStatusText = total ? (total + ' photos indexed') : '';
  572. if (this.searchMode) { this.runSearch(); }
  573. else if (this.groupByDate) { this.getFolderInfo(); } // refresh EXIF shoot times
  574. setTimeout(() => { if (!this.indexing) this.indexStatusText = ''; }, 4000);
  575. }
  576. }).catch(() => { this.indexing = false; this.indexStatusText = ''; });
  577. };
  578. step('full');
  579. }
  580. }
  581. }
  582. function renderImageList(object){
  583. var fd = $(object).attr("filedata");
  584. fd = JSON.parse(decodeURIComponent(fd));
  585. console.log(fd);
  586. }
  587. function ShowModal(){
  588. $('#photo-viewer').show();
  589. }
  590. function closeViewer(){
  591. $('#photo-viewer').hide();
  592. if (!ao_module_virtualDesktop){
  593. // Only update hash if not under WebDesktop mode
  594. // to prevent iframe refresh
  595. window.location.hash = '';
  596. }
  597. ao_module_setWindowTitle("Photo");
  598. setTimeout(function(){
  599. $("#fullImage").attr("src","img/loading.png");
  600. $("#compressedImage").attr("src","").hide().removeClass('hidden');
  601. $("#bg-image").attr("src","");
  602. $("#info-filename").text("");
  603. $("#info-filepath").text("");
  604. $("#info-dimensions").text("Loading...");
  605. // Reset EXIF data display
  606. $('#basic-info-section').hide();
  607. $('#shooting-params-section').hide();
  608. $('#tone-analysis-section').hide();
  609. $('#device-info-section').hide();
  610. $('#shooting-mode-section').hide();
  611. $('#technical-params-section').hide();
  612. $('#no-exif-message').hide();
  613. $('.ui.divider').hide();
  614. // Clear histogram canvas
  615. const canvas = document.getElementById('histogram-canvas');
  616. if (canvas) {
  617. const ctx = canvas.getContext('2d');
  618. ctx.clearRect(0, 0, canvas.width, canvas.height);
  619. }
  620. }, 300);
  621. }
  622. let compressedImageLoaded = false;
  623. let fullsizeImageLoaded = false;
  624. function showImage(object){
  625. // Reset zoom level when switching photos
  626. if (typeof resetZoom === 'function') {
  627. resetZoom();
  628. }
  629. if (!$(object).hasClass("imagecard")){
  630. // Not an image card, do nothing
  631. return;
  632. }
  633. // Reset loading flags
  634. compressedImageLoaded = false;
  635. fullsizeImageLoaded = false;
  636. var fd = JSON.parse(decodeURIComponent($(object).attr("filedata")));
  637. _currentCastFilepath = fd.filepath;
  638. currentPhotoFilepath = fd.filepath;
  639. fetchPhotoRating(fd.filepath);
  640. if (_photoCastConnected()) _photoCastSendPhoto(fd.filepath);
  641. $("#info-dimensions").text("Calculating...");
  642. // Check if we should use compression (only for JPG/PNG > 5MB)
  643. const useCompression = shouldUseCompression(fd.filepath, fd.filesize);
  644. // Set thumbnail as placeholder for full image
  645. const thumbnailUrl = $(object).find('img').attr('src');
  646. $("#fullImage").attr("src", thumbnailUrl);
  647. $("#fullImage").hide();
  648. $("#compressedImage").show();
  649. $("#compressedImage").attr("src", thumbnailUrl);
  650. $("#bg-image").attr("src", thumbnailUrl);
  651. // Get image URL (backend handles RAW files automatically)
  652. getViewableImageUrl(fd.filepath, (imageUrl, isSupported, isBlob, method) => {
  653. $("#loading-progress").show();
  654. const compressedImg = document.getElementById('compressedImage');
  655. const fullImg = document.getElementById('fullImage');
  656. const bgImg = document.getElementById('bg-image');
  657. $("#loading-progress").html(`<i class="loading spinner icon"></i> Loading`);
  658. if (useCompression) {
  659. // Use compressed version for large JPG/PNG files
  660. console.log('Large JPG/PNG detected (' + (fd.filesize / 1024 / 1024).toFixed(2) + 'MB), loading compressed version first');
  661. fetch(ao_root + "system/ajgi/interface?script=Photo/backend/getCompressedImg.js", {
  662. method: 'POST',
  663. cache: 'no-cache',
  664. headers: {
  665. 'Content-Type': 'application/json'
  666. },
  667. body: JSON.stringify({
  668. "filepath": fd.filepath
  669. })
  670. }).then(resp => {
  671. resp.text().then(dataURL => {
  672. $("#loading-progress").html(`<i class="loading spinner icon"></i> Optimizing Resolution`);
  673. compressedImageLoaded = true;
  674. // Only show compressed image if full-size hasn't loaded yet
  675. if (!fullsizeImageLoaded) {
  676. compressedImg.src = dataURL;
  677. compressedImg.style.display = 'block';
  678. bgImg.src = dataURL;
  679. } else {
  680. console.log('Full-size image already loaded, skipping compressed image display');
  681. }
  682. });
  683. }).catch(error => {
  684. console.error('Failed to load compressed image:', error);
  685. // Fall back to full size image
  686. fullImg.src = imageUrl;
  687. bgImg.src = imageUrl;
  688. });
  689. // Start loading full-size image in background
  690. loadFullSizeImageInBackground(imageUrl, fd);
  691. } else {
  692. $("#compressedImage").hide();
  693. $("#fullImage").show();
  694. $("#loading-progress").hide();
  695. // Use full image URL directly for RAW, WEBP, or small JPG/PNG files
  696. if (method === 'backend_raw') {
  697. console.log('RAW file: Rendered by backend');
  698. }
  699. fullImg.src = imageUrl;
  700. bgImg.src = imageUrl;
  701. }
  702. // Update image dimensions and generate histogram when full image loads
  703. $("#fullImage").off("load").on('load', function() {
  704. fullsizeImageLoaded = true;
  705. let width = this.naturalWidth;
  706. let height = this.naturalHeight;
  707. $("#info-dimensions").text(width + ' × ' + height + "px");
  708. // Hide the compressed image once full image is loaded
  709. $("#compressedImage").hide();
  710. $("#fullImage").show();
  711. $("#loading-progress").hide();
  712. const canvas = document.getElementById('histogram-canvas');
  713. if (canvas) {
  714. generateHistogram(this, canvas);
  715. }
  716. });
  717. $("#info-filename").text(fd.filename);
  718. $("#info-filepath").text(fd.filepath);
  719. var nextCard = $(object).next();
  720. var prevCard = $(object).prev();
  721. if (nextCard.length > 0){
  722. nextPhoto = nextCard[0];
  723. }else{
  724. nextPhoto = null;
  725. }
  726. if (prevCard.length > 0){
  727. prePhoto = prevCard[0];
  728. }else{
  729. prePhoto = null;
  730. }
  731. // Track position in the full allImages list for index-based navigation
  732. const _appEl = document.querySelector('[x-data*="photoListObject"]');
  733. if (_appEl && _appEl._x_dataStack) {
  734. const _app = _appEl._x_dataStack[0];
  735. currentPhotoAllIndex = _app.allImages.findIndex(img => img.filepath === fd.filepath);
  736. // Proactively load next batch when within PAGE_SIZE of the end of rendered images
  737. if (currentPhotoAllIndex >= 0 && _app.hasMoreImages &&
  738. currentPhotoAllIndex >= _app.images.length - PAGE_SIZE) {
  739. _app.loadMoreImages();
  740. }
  741. }
  742. // Update navigation buttons state
  743. if (typeof updateNavigationButtons === 'function') {
  744. updateNavigationButtons();
  745. }
  746. ao_module_setWindowTitle("Photo - " + fd.filename);
  747. if (!ao_module_virtualDesktop){
  748. window.location.hash = encodeURIComponent(JSON.stringify({filename: fd.filename, filepath: fd.filepath}));
  749. }
  750. // Check for EXIF data
  751. fetch(ao_root + "system/ajgi/interface?script=Photo/backend/getExif.js", {
  752. method: 'POST',
  753. cache: 'no-cache',
  754. headers: {
  755. 'Content-Type': 'application/json'
  756. },
  757. body: JSON.stringify({
  758. "filepath": fd.filepath
  759. })
  760. }).then(resp => {
  761. resp.json().then(data => {
  762. formatExifData(data, fd);
  763. })
  764. }).catch(error => {
  765. console.error('Failed to fetch EXIF data:', error);
  766. formatExifData({}, fd); // Call with empty EXIF to show tone analysis
  767. });
  768. });
  769. }
  770. // Function to load full-size image in background with progress tracking
  771. function loadFullSizeImageInBackground(fullSizeUrl, fileData) {
  772. console.log('Starting background download of full-size image...');
  773. const fullImage = document.getElementById('fullImage');
  774. fullImage.src = fullSizeUrl;
  775. }
  776. // ── Download current photo ─────────────────────────────────────────────────────
  777. // Streams the *original* file (RAW included) via the media server's download
  778. // mode, which sets a Content-Disposition: attachment header.
  779. function downloadCurrentPhoto() {
  780. if (!currentPhotoFilepath) return;
  781. var url = ao_root + 'media?download=true&file=' + encodeURIComponent(currentPhotoFilepath);
  782. var a = document.createElement('a');
  783. a.href = url;
  784. a.download = currentPhotoFilepath.split('/').pop();
  785. document.body.appendChild(a);
  786. a.click();
  787. document.body.removeChild(a);
  788. }
  789. // ── Star rating (viewer) ───────────────────────────────────────────────────────
  790. // Paint the 1-5 star widget to reflect `rating` (and remember it).
  791. function renderPhotoRating(rating) {
  792. currentPhotoRating = rating || 0;
  793. var container = document.getElementById('photo-rating-stars');
  794. if (container) {
  795. var stars = container.querySelectorAll('.photo-star');
  796. for (var i = 0; i < stars.length; i++) {
  797. stars[i].classList.toggle('filled', (i + 1) <= currentPhotoRating);
  798. stars[i].classList.remove('hover');
  799. }
  800. }
  801. var label = document.getElementById('photo-rating-label');
  802. if (label) label.textContent = currentPhotoRating ? (currentPhotoRating + ' / 5') : 'Rate';
  803. }
  804. // Persist a new rating for the open photo (tapping the current value clears it).
  805. function setPhotoRating(n) {
  806. if (!currentPhotoFilepath) return;
  807. if (n === currentPhotoRating) n = 0;
  808. renderPhotoRating(n); // optimistic update
  809. var target = currentPhotoFilepath;
  810. fetch(ao_root + "system/ajgi/interface?script=Photo/backend/setRating.js", {
  811. method: 'POST',
  812. cache: 'no-cache',
  813. headers: { 'Content-Type': 'application/json' },
  814. body: JSON.stringify({ filepath: target, rating: n })
  815. }).then(function (r) { return r.json(); }).then(function (data) {
  816. if (target !== currentPhotoFilepath) return; // user already moved on
  817. if (data && typeof data.rating === 'number') renderPhotoRating(data.rating);
  818. }).catch(function () { /* keep the optimistic value */ });
  819. }
  820. // Load the stored rating for a freshly-opened photo.
  821. function fetchPhotoRating(filepath) {
  822. renderPhotoRating(0);
  823. fetch(ao_root + "system/ajgi/interface?script=Photo/backend/getRating.js", {
  824. method: 'POST',
  825. cache: 'no-cache',
  826. headers: { 'Content-Type': 'application/json' },
  827. body: JSON.stringify({ filepath: filepath })
  828. }).then(function (r) { return r.json(); }).then(function (data) {
  829. if (filepath !== currentPhotoFilepath) return; // raced past this photo
  830. renderPhotoRating(data && data.rating ? data.rating : 0);
  831. }).catch(function () { renderPhotoRating(0); });
  832. }
  833. // Wire click + hover-preview on the star widget once.
  834. function initPhotoRatingWidget() {
  835. var container = document.getElementById('photo-rating-stars');
  836. if (!container) return;
  837. var stars = container.querySelectorAll('.photo-star');
  838. for (var i = 0; i < stars.length; i++) {
  839. (function (star, value) {
  840. star.addEventListener('click', function () { setPhotoRating(value); });
  841. star.addEventListener('mouseenter', function () {
  842. for (var j = 0; j < stars.length; j++) {
  843. stars[j].classList.toggle('hover', j < value);
  844. }
  845. });
  846. })(stars[i], i + 1);
  847. }
  848. container.addEventListener('mouseleave', function () {
  849. for (var k = 0; k < stars.length; k++) stars[k].classList.remove('hover');
  850. });
  851. }
  852. document.addEventListener('DOMContentLoaded', initPhotoRatingWidget);
  853. $(document).on("keydown", function(e){
  854. if (e.keyCode == 27){ // Escape
  855. if ($('#photo-viewer').is(':visible')) {
  856. closeViewer();
  857. }
  858. } else if (e.keyCode == 37){
  859. //Left
  860. if (typeof showPreviousImage === 'function') {
  861. showPreviousImage();
  862. } else if (prePhoto != null){
  863. showImage(prePhoto);
  864. }
  865. }else if (e.keyCode == 39){
  866. //Right
  867. if (typeof showNextImage === 'function') {
  868. showNextImage();
  869. } else if (nextPhoto != null){
  870. showImage(nextPhoto);
  871. }
  872. }
  873. })
  874. function generateToneAnalysis(imageElement) {
  875. analysis_tone_types(imageElement, function(result) {
  876. if (result) {
  877. // Update tone type based on brightness, contrast, shadow and highlight ratios
  878. let toneType = get_tone_type(result.brightness, result.contrast, result.shadowRatio, result.highlightRatio);
  879. $('.tone-type-value').text(toneType);
  880. $('.brightness-value').text(result.brightness);
  881. $('.contrast-value').text(result.contrast);
  882. $('.shadow-ratio-value').text(result.shadowRatio);
  883. $('.highlight-ratio-value').text(result.highlightRatio);
  884. } else {
  885. $('.tone-type-value').text("N/A");
  886. $('.brightness-value').text("N/A");
  887. $('.contrast-value').text("N/A");
  888. $('.shadow-ratio-value').text("N/A");
  889. $('.highlight-ratio-value').text("N/A");
  890. }
  891. });
  892. }
  893. function formatExifData(exif, fileData) {
  894. // Hide all sections initially
  895. $('#basic-info-section').hide();
  896. $('#shooting-params-section').hide();
  897. $('#tone-analysis-section').hide();
  898. $('#device-info-section').hide();
  899. $('#shooting-mode-section').hide();
  900. $('#technical-params-section').hide();
  901. $('#no-exif-message').hide();
  902. // Hide all dividers
  903. $('.ui.divider').hide();
  904. if (!exif || Object.keys(exif).length === 0) {
  905. $('#no-exif-message').show();
  906. //Generate histogram and tone analysis only
  907. generateHistogram(document.getElementById('fullImage'), document.getElementById('histogram-canvas'));
  908. generateToneAnalysis(document.getElementById('fullImage'));
  909. $('#tone-analysis-section').show();
  910. return;
  911. }
  912. let sectionsShown = 0;
  913. // Section 1: Basic Information
  914. let basicInfoShown = false;
  915. if (fileData.filename) {
  916. let ext = fileData.filename.split('.').pop().toUpperCase();
  917. $('#format-value').text(ext);
  918. $('#format-row').show();
  919. basicInfoShown = true;
  920. } else {
  921. $('#format-row').hide();
  922. }
  923. if (exif.PixelXDimension && exif.PixelYDimension) {
  924. $('#dimensions-value').text(`${exif.PixelXDimension} × ${exif.PixelYDimension}`);
  925. $('#dimensions-row').show();
  926. let pixels = (exif.PixelXDimension * exif.PixelYDimension / 1000000).toFixed(1);
  927. $('#pixels-value').text(`${pixels} MP`);
  928. $('#pixels-row').show();
  929. basicInfoShown = true;
  930. } else {
  931. $('#dimensions-row').hide();
  932. $('#pixels-row').hide();
  933. }
  934. if (exif.ColorSpace !== undefined) {
  935. exif.ColorSpace = JSON.parse(exif.ColorSpace);
  936. let colorSpace = exif.ColorSpace === 1 ? "sRGB" : exif.ColorSpace === 65535 ? "Uncalibrated" : "Unknown";
  937. $('#color-space-value').text(colorSpace);
  938. $('#color-space-row').show();
  939. basicInfoShown = true;
  940. } else {
  941. $('#color-space-row').hide();
  942. }
  943. if (exif.DateTimeOriginal) {
  944. exif.DateTimeOriginal = JSON.parse(exif.DateTimeOriginal);
  945. $('#shooting-time-value').text(exif.DateTimeOriginal.replace(/:/g, '/').replace(' ', ' '));
  946. $('#shooting-time-row').show();
  947. basicInfoShown = true;
  948. } else {
  949. $('#shooting-time-row').hide();
  950. }
  951. if (exif.Software) {
  952. exif.Software = JSON.parse(exif.Software);
  953. $('#software-value').text(exif.Software);
  954. $('#software-row').show();
  955. basicInfoShown = true;
  956. } else {
  957. $('#software-row').hide();
  958. }
  959. if (basicInfoShown) {
  960. $('#basic-info-section').show();
  961. sectionsShown++;
  962. if (sectionsShown > 1) $('#basic-info-divider').show();
  963. }
  964. // Section 2: Shooting Parameters
  965. let shootingParamsShown = false;
  966. if (exif.FocalLength) {
  967. $('#focal-length-value').text(JSON.parse(exif.FocalLength));
  968. $('#focal-length-row').show();
  969. shootingParamsShown = true;
  970. } else {
  971. $('#focal-length-row').hide();
  972. }
  973. if (exif.FNumber) {
  974. exif.FNumber = JSON.parse(exif.FNumber);
  975. let aperture = parseExifValue(exif.FNumber);
  976. let formattedAperture = aperture % 1 === 0 ? aperture.toString() : aperture.toFixed(1);
  977. $('#aperture-value').text('f/' + formattedAperture);
  978. $('#aperture-row').show();
  979. shootingParamsShown = true;
  980. } else {
  981. $('#aperture-row').hide();
  982. }
  983. if (exif.ExposureTime) {
  984. let exposureTime = JSON.parse(exif.ExposureTime);
  985. let formattedExposure = formatShutterSpeed(exposureTime);
  986. $('#shutter-speed-value').text(formattedExposure + 's');
  987. $('#shutter-speed-row').show();
  988. shootingParamsShown = true;
  989. } else {
  990. $('#shutter-speed-row').hide();
  991. }
  992. if (exif.ISOSpeedRatings) {
  993. $('#iso-value').text(exif.ISOSpeedRatings);
  994. $('#iso-row').show();
  995. shootingParamsShown = true;
  996. } else {
  997. $('#iso-row').hide();
  998. }
  999. if (exif.ExposureBiasValue) {
  1000. $('#ev-value').text(JSON.parse(exif.ExposureBiasValue));
  1001. $('#ev-row').show();
  1002. shootingParamsShown = true;
  1003. } else {
  1004. $('#ev-row').hide();
  1005. }
  1006. if (shootingParamsShown) {
  1007. $('#shooting-params-section').show();
  1008. sectionsShown++;
  1009. if (sectionsShown > 1) $('#shooting-params-divider').show();
  1010. }
  1011. // Section 3: Tone Analysis
  1012. $('#tone-analysis-section').show();
  1013. sectionsShown++;
  1014. if (sectionsShown > 1) $('#tone-analysis-divider').show();
  1015. generateToneAnalysis(document.getElementById('fullImage'));
  1016. // Section 4: Device Information
  1017. let deviceInfoShown = false;
  1018. if (exif.Make && exif.Model) {
  1019. exif.Make = JSON.parse(exif.Make);
  1020. exif.Model = JSON.parse(exif.Model);
  1021. $('#camera-value').text(`${exif.Make} ${exif.Model}`);
  1022. $('#camera-row').show();
  1023. deviceInfoShown = true;
  1024. } else if (exif.Model) {
  1025. exif.Model = JSON.parse(exif.Model);
  1026. $('#camera-value').text(exif.Model);
  1027. $('#camera-row').show();
  1028. deviceInfoShown = true;
  1029. } else {
  1030. $('#camera-row').hide();
  1031. }
  1032. if (exif.LensModel) {
  1033. exif.LensModel = JSON.parse(exif.LensModel);
  1034. $('#lens-value').text(exif.LensModel);
  1035. $('#lens-row').show();
  1036. deviceInfoShown = true;
  1037. } else {
  1038. $('#lens-row').hide();
  1039. }
  1040. if (exif.FocalLength) {
  1041. exif.FocalLength = JSON.parse(exif.FocalLength);
  1042. $('#focal-length-device-value').text(`${exif.FocalLength}mm`);
  1043. $('#focal-length-device-row').show();
  1044. deviceInfoShown = true;
  1045. } else {
  1046. $('#focal-length-device-row').hide();
  1047. }
  1048. if (exif.MaxApertureValue) {
  1049. exif.MaxApertureValue = JSON.parse(exif.MaxApertureValue);
  1050. $('#max-aperture-value').text(exif.MaxApertureValue);
  1051. $('#max-aperture-row').show();
  1052. deviceInfoShown = true;
  1053. } else {
  1054. $('#max-aperture-row').hide();
  1055. }
  1056. if (deviceInfoShown) {
  1057. $('#device-info-section').show();
  1058. sectionsShown++;
  1059. if (sectionsShown > 1) $('#device-info-divider').show();
  1060. }
  1061. // Section 5: Shooting Mode
  1062. let shootingModeShown = false;
  1063. if (exif.ExposureProgram !== undefined) {
  1064. let programs = ["Not defined", "Manual", "Normal program", "Aperture priority", "Shutter priority", "Creative program", "Action program", "Portrait mode", "Landscape mode"];
  1065. let program = programs[exif.ExposureProgram] || "Unknown";
  1066. $('#exposure-program-value').text(program);
  1067. $('#exposure-program-row').show();
  1068. shootingModeShown = true;
  1069. } else {
  1070. $('#exposure-program-row').hide();
  1071. }
  1072. if (exif.ExposureMode !== undefined) {
  1073. let modes = ["Auto exposure", "Manual exposure", "Auto bracket"];
  1074. let mode = modes[exif.ExposureMode] || "Unknown";
  1075. $('#exposure-mode-value').text(mode);
  1076. $('#exposure-mode-row').show();
  1077. shootingModeShown = true;
  1078. } else {
  1079. $('#exposure-mode-row').hide();
  1080. }
  1081. if (exif.MeteringMode !== undefined) {
  1082. let metering = ["Unknown", "Average", "Center-weighted average", "Spot", "Multi-spot", "Pattern", "Partial"];
  1083. let meter = metering[exif.MeteringMode] || "Unknown";
  1084. $('#metering-mode-value').text(meter);
  1085. $('#metering-mode-row').show();
  1086. shootingModeShown = true;
  1087. } else {
  1088. $('#metering-mode-row').hide();
  1089. }
  1090. if (exif.WhiteBalance !== undefined) {
  1091. let wb = exif.WhiteBalance === 0 ? "Auto" : "Manual";
  1092. $('#white-balance-value').text(wb);
  1093. $('#white-balance-row').show();
  1094. shootingModeShown = true;
  1095. } else {
  1096. $('#white-balance-row').hide();
  1097. }
  1098. if (exif.Flash !== undefined) {
  1099. let flash = (exif.Flash & 1) ? "On" : "Off";
  1100. $('#flash-value').text(flash);
  1101. $('#flash-row').show();
  1102. shootingModeShown = true;
  1103. } else {
  1104. $('#flash-row').hide();
  1105. }
  1106. if (exif.SceneCaptureType !== undefined) {
  1107. let scenes = ["Standard", "Landscape", "Portrait", "Night scene"];
  1108. let scene = scenes[exif.SceneCaptureType] || "Unknown";
  1109. $('#scene-capture-value').text(scene);
  1110. $('#scene-capture-row').show();
  1111. shootingModeShown = true;
  1112. } else {
  1113. $('#scene-capture-row').hide();
  1114. }
  1115. if (shootingModeShown) {
  1116. $('#shooting-mode-section').show();
  1117. sectionsShown++;
  1118. if (sectionsShown > 1) $('#shooting-mode-divider').show();
  1119. }
  1120. // Section 6: Technical Parameters
  1121. let technicalParamsShown = false;
  1122. if (exif.ShutterSpeedValue) {
  1123. exif.ShutterSpeedValue = JSON.parse(exif.ShutterSpeedValue);
  1124. let apexValue = parseExifValue(exif.ShutterSpeedValue);
  1125. let shutterSpeedSeconds = Math.pow(2, -apexValue);
  1126. let shutterValue = formatShutterSpeed(shutterSpeedSeconds);
  1127. $('#shutter-speed-tech-value').text(shutterValue);
  1128. $('#shutter-speed-tech-row').show();
  1129. technicalParamsShown = true;
  1130. } else {
  1131. $('#shutter-speed-tech-row').hide();
  1132. }
  1133. if (exif.ApertureValue) {
  1134. exif.ApertureValue = JSON.parse(exif.ApertureValue);
  1135. let apexValue = parseExifValue(exif.ApertureValue);
  1136. let apertureValue = Math.pow(2, apexValue / 2);
  1137. $('#aperture-value-value').text(apertureValue.toFixed(1) + ' EV');
  1138. $('#aperture-value-row').show();
  1139. technicalParamsShown = true;
  1140. } else {
  1141. $('#aperture-value-row').hide();
  1142. }
  1143. if (exif.FocalPlaneXResolution && exif.FocalPlaneYResolution) {
  1144. exif.FocalPlaneXResolution = JSON.parse(exif.FocalPlaneXResolution);
  1145. exif.FocalPlaneYResolution = JSON.parse(exif.FocalPlaneYResolution);
  1146. let xRes = parseExifValue(exif.FocalPlaneXResolution);
  1147. let yRes = parseExifValue(exif.FocalPlaneYResolution);
  1148. $('#focal-plane-res-value').text(Math.round(xRes) + ' × ' + Math.round(yRes));
  1149. $('#focal-plane-res-row').show();
  1150. technicalParamsShown = true;
  1151. } else {
  1152. $('#focal-plane-res-row').hide();
  1153. }
  1154. if (technicalParamsShown) {
  1155. $('#technical-params-section').show();
  1156. sectionsShown++;
  1157. if (sectionsShown > 1) $('#technical-params-divider').show();
  1158. }
  1159. }
  1160. function restoreFromHash() {
  1161. if (window.location.hash) {
  1162. let hashData = decodeURIComponent(window.location.hash.substring(1));
  1163. try {
  1164. let data = JSON.parse(hashData);
  1165. // Find the element with matching filepath
  1166. let elements = document.querySelectorAll('[filedata]');
  1167. for (let el of elements) {
  1168. let fdStr = el.getAttribute('filedata');
  1169. if (fdStr) {
  1170. let fd = JSON.parse(decodeURIComponent(fdStr));
  1171. if (fd.filepath === data.filepath) {
  1172. showImage(el);
  1173. ShowModal();
  1174. break;
  1175. }
  1176. }
  1177. }
  1178. } catch (e) {
  1179. console.error('Invalid hash data', e);
  1180. }
  1181. }
  1182. }
  1183. // Modify the window onload event to ensure folder and thumbnails are loaded first
  1184. window.addEventListener('load', () => {
  1185. setTimeout(function(){
  1186. if (window.location.hash) {
  1187. const hashData = decodeURIComponent(window.location.hash.substring(1));
  1188. try {
  1189. const data = JSON.parse(hashData);
  1190. let filename = data.filename;
  1191. let filepath = data.filepath;
  1192. let dir = filepath.split("/").slice(0, -1).join("/");
  1193. // Access the Alpine data instance
  1194. const appElement = document.querySelector('[x-data*="photoListObject"]');
  1195. if (appElement) {
  1196. const app = appElement._x_dataStack[0];
  1197. if (app.currentPath !== dir) {
  1198. app.updateRenderingPath(dir, () => {
  1199. setTimeout(function(){
  1200. console.log("Test")
  1201. restoreFromHash();
  1202. }, 100);
  1203. });
  1204. } else {
  1205. // Folder is already loaded, try to restore immediately
  1206. restoreFromHash();
  1207. }
  1208. }
  1209. } catch (e) {
  1210. console.error('Invalid hash data', e);
  1211. }
  1212. }
  1213. }, 100);
  1214. });
  1215. // ── Arozcast Photo Cast ───────────────────────────────────────────────────────
  1216. let _photoCastWs = null;
  1217. let _photoCastCode = null;
  1218. let _photoCastPingTimer = null;
  1219. let _photoCastWatchTimer = null;
  1220. let _photoCastLastSeen = 0;
  1221. let _currentCastFilepath = null;
  1222. let _photoCastReconnectTimer = null;
  1223. let _photoCastReconnectCount = 0;
  1224. let _photoCastPendingCode = null;
  1225. function _photoCastConnected() { return _photoCastWs !== null && _photoCastWs.readyState === WebSocket.OPEN; }
  1226. function openPhotoCastDialog() {
  1227. const modal = document.getElementById('cast-photo-modal');
  1228. const inp = document.getElementById('cast-photo-code');
  1229. const err = document.getElementById('cast-photo-error');
  1230. const stat = document.getElementById('cast-photo-status');
  1231. const connBtn = document.getElementById('cast-photo-conn-btn');
  1232. const discBtn = document.getElementById('cast-photo-disc-btn');
  1233. err.textContent = '';
  1234. if (_photoCastConnected()) {
  1235. inp.style.display = 'none';
  1236. stat.style.display = 'block';
  1237. stat.textContent = 'Connected to room ' + _photoCastCode;
  1238. connBtn.style.display = 'none';
  1239. discBtn.style.display = '';
  1240. } else {
  1241. inp.style.display = '';
  1242. inp.value = '';
  1243. stat.style.display = 'none';
  1244. connBtn.style.display = '';
  1245. discBtn.style.display = 'none';
  1246. }
  1247. modal.classList.add('visible');
  1248. if (inp.style.display !== 'none') inp.focus();
  1249. }
  1250. function closePhotoCastDialog() {
  1251. document.getElementById('cast-photo-modal').classList.remove('visible');
  1252. }
  1253. function connectPhotoCast() {
  1254. const inp = document.getElementById('cast-photo-code');
  1255. const err = document.getElementById('cast-photo-error');
  1256. const code = inp.value.trim();
  1257. if (code.length !== 4) { err.textContent = 'Enter a 4-digit room code.'; return; }
  1258. err.textContent = '';
  1259. fetch(ao_root + 'api/arozcast/ping?code=' + code)
  1260. .then(r => r.json())
  1261. .then(data => {
  1262. if (!data.exists) { err.textContent = 'Room not found. Is Arozcast running?'; return; }
  1263. // Disconnect any existing photo cast session
  1264. if (_photoCastWs) {
  1265. _photoCastWs.onclose = null;
  1266. _photoCastWs.close();
  1267. _photoCastWs = null;
  1268. }
  1269. clearInterval(_photoCastPingTimer);
  1270. clearInterval(_photoCastWatchTimer);
  1271. // Cancel any pending auto-reconnect to the old room — user is opening a new session
  1272. clearTimeout(_photoCastReconnectTimer); _photoCastReconnectTimer = null;
  1273. _photoCastReconnectCount = 0; _photoCastPendingCode = null;
  1274. // Signal other apps (e.g. Musicify) to yield the cast session
  1275. try { new BroadcastChannel('arozcast').postMessage({ type: 'arozcast.takeover' }); } catch(e) {}
  1276. _photoCastCode = code;
  1277. const wsUrl = new URL(ao_root + 'api/arozcast/ws?code=' + code, window.location.href);
  1278. wsUrl.protocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
  1279. const ws = new WebSocket(wsUrl.toString());
  1280. _photoCastWs = ws;
  1281. ws.onopen = function() {
  1282. _photoCastLastSeen = Date.now();
  1283. ws.send(JSON.stringify({ topic: 'peer.hello', payload: {} }));
  1284. if (_currentCastFilepath) _photoCastSendPhoto(_currentCastFilepath);
  1285. _photoCastPingTimer = setInterval(function() {
  1286. if (_photoCastConnected()) ws.send(JSON.stringify({ topic: 'peer.heartbeat', payload: {} }));
  1287. }, 5000);
  1288. _photoCastWatchTimer = setInterval(function() {
  1289. if (Date.now() - _photoCastLastSeen > 12000) { ws.close(); }
  1290. }, 4000);
  1291. // Update modal to connected state
  1292. document.getElementById('cast-photo-status').style.display = 'block';
  1293. document.getElementById('cast-photo-status').textContent = 'Connected to room ' + code;
  1294. document.getElementById('cast-photo-code').style.display = 'none';
  1295. document.getElementById('cast-photo-conn-btn').style.display = 'none';
  1296. document.getElementById('cast-photo-disc-btn').style.display = '';
  1297. document.getElementById('cast-photo-btn').classList.add('casting');
  1298. };
  1299. ws.onmessage = function() { _photoCastLastSeen = Date.now(); };
  1300. ws.onclose = function() {
  1301. clearInterval(_photoCastPingTimer);
  1302. clearInterval(_photoCastWatchTimer);
  1303. var savedCode = _photoCastCode;
  1304. _photoCastWs = null;
  1305. _photoCastCode = null;
  1306. document.getElementById('cast-photo-btn').classList.remove('casting');
  1307. _startPhotoCastReconnect(savedCode);
  1308. };
  1309. ws.onerror = function() { err.textContent = 'Connection error.'; };
  1310. closePhotoCastDialog();
  1311. })
  1312. .catch(function() { err.textContent = 'Network error.'; });
  1313. }
  1314. function disconnectPhotoCast() {
  1315. // Cancel any pending auto-reconnect before tearing down
  1316. clearTimeout(_photoCastReconnectTimer); _photoCastReconnectTimer = null;
  1317. _photoCastReconnectCount = 0; _photoCastPendingCode = null;
  1318. if (_photoCastWs) {
  1319. _photoCastWs.onclose = null; // suppress reconnect trigger
  1320. _photoCastWs.close();
  1321. _photoCastWs = null;
  1322. }
  1323. clearInterval(_photoCastPingTimer);
  1324. clearInterval(_photoCastWatchTimer);
  1325. _photoCastCode = null;
  1326. document.getElementById('cast-photo-btn').classList.remove('casting');
  1327. closePhotoCastDialog();
  1328. }
  1329. function _photoCastSendPhoto(filepath) {
  1330. if (!_photoCastConnected()) return;
  1331. var fileUrl = ao_root + 'media?file=' + encodeURIComponent(filepath);
  1332. _photoCastWs.send(JSON.stringify({
  1333. topic: 'media.load',
  1334. payload: { filepath: filepath, name: filepath.split('/').pop(), type: 'photo', src: fileUrl }
  1335. }));
  1336. }
  1337. // ── Arozcast photo auto-reconnect ─────────────────────────────────────────────
  1338. var _PHOTO_CAST_RECONNECT_DELAYS = [2000, 5000, 12000];
  1339. function _startPhotoCastReconnect(code) {
  1340. if (!code || _photoCastReconnectCount >= _PHOTO_CAST_RECONNECT_DELAYS.length) {
  1341. _photoCastReconnectCount = 0; _photoCastPendingCode = null;
  1342. return;
  1343. }
  1344. _photoCastPendingCode = code;
  1345. var delay = _PHOTO_CAST_RECONNECT_DELAYS[_photoCastReconnectCount++];
  1346. clearTimeout(_photoCastReconnectTimer);
  1347. _photoCastReconnectTimer = setTimeout(function() {
  1348. _photoCastReconnectTimer = null;
  1349. _attemptPhotoCastReconnect();
  1350. }, delay);
  1351. }
  1352. function _attemptPhotoCastReconnect() {
  1353. if (!_photoCastPendingCode) return;
  1354. var code = _photoCastPendingCode;
  1355. var wsUrl = new URL(ao_root + 'api/arozcast/ws?code=' + code, window.location.href);
  1356. wsUrl.protocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
  1357. var ws = new WebSocket(wsUrl.toString());
  1358. var openTimer = setTimeout(function() {
  1359. ws.onopen = ws.onclose = ws.onerror = null; ws.close();
  1360. _startPhotoCastReconnect(code);
  1361. }, 8000);
  1362. ws.onopen = function() { clearTimeout(openTimer); _photoCastPendingCode = null; _photoCastDidReconnect(ws, code); };
  1363. ws.onerror = function() {};
  1364. ws.onclose = function() { clearTimeout(openTimer); _startPhotoCastReconnect(code); };
  1365. }
  1366. function _photoCastDidReconnect(ws, code) {
  1367. _photoCastWs = ws;
  1368. _photoCastCode = code;
  1369. _photoCastLastSeen = Date.now();
  1370. ws.onmessage = function() { _photoCastLastSeen = Date.now(); _photoCastReconnectCount = 0; }; // any reply = receiver alive — reset retry counter
  1371. ws.onclose = function() {
  1372. clearInterval(_photoCastPingTimer); clearInterval(_photoCastWatchTimer);
  1373. var savedCode = _photoCastCode;
  1374. _photoCastWs = null; _photoCastCode = null;
  1375. document.getElementById('cast-photo-btn').classList.remove('casting');
  1376. _startPhotoCastReconnect(savedCode);
  1377. };
  1378. // Re-announce and re-push the current photo
  1379. ws.send(JSON.stringify({ topic: 'peer.hello', payload: {} }));
  1380. if (_currentCastFilepath) _photoCastSendPhoto(_currentCastFilepath);
  1381. clearInterval(_photoCastPingTimer); clearInterval(_photoCastWatchTimer);
  1382. _photoCastPingTimer = setInterval(function() {
  1383. if (_photoCastConnected()) ws.send(JSON.stringify({ topic: 'peer.heartbeat', payload: {} }));
  1384. }, 5000);
  1385. _photoCastWatchTimer = setInterval(function() {
  1386. if (Date.now() - _photoCastLastSeen > 12000) { ws.close(); }
  1387. }, 4000);
  1388. document.getElementById('cast-photo-btn').classList.add('casting');
  1389. var stat = document.getElementById('cast-photo-status');
  1390. if (stat) { stat.style.display = 'block'; stat.textContent = 'Reconnected to room ' + code; }
  1391. }
  1392. // When the user returns to this tab after the phone was asleep, reconnect immediately
  1393. document.addEventListener('visibilitychange', function() {
  1394. if (document.visibilityState === 'visible' && _photoCastPendingCode) {
  1395. clearTimeout(_photoCastReconnectTimer);
  1396. _photoCastReconnectTimer = null;
  1397. _attemptPhotoCastReconnect();
  1398. }
  1399. });