musicify.js 108 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957195819591960196119621963196419651966196719681969197019711972197319741975197619771978197919801981198219831984198519861987198819891990199119921993199419951996199719981999200020012002200320042005200620072008200920102011201220132014201520162017201820192020202120222023202420252026202720282029203020312032203320342035203620372038203920402041204220432044204520462047204820492050205120522053205420552056205720582059206020612062206320642065206620672068206920702071207220732074207520762077207820792080208120822083208420852086208720882089209020912092209320942095209620972098209921002101210221032104210521062107210821092110211121122113211421152116211721182119212021212122212321242125212621272128212921302131213221332134213521362137213821392140214121422143214421452146214721482149215021512152215321542155215621572158215921602161216221632164216521662167216821692170217121722173217421752176217721782179218021812182218321842185218621872188218921902191219221932194219521962197219821992200220122022203220422052206220722082209221022112212221322142215221622172218221922202221222222232224222522262227222822292230223122322233223422352236223722382239224022412242224322442245224622472248224922502251225222532254225522562257225822592260226122622263226422652266226722682269227022712272227322742275227622772278227922802281228222832284228522862287228822892290229122922293229422952296229722982299230023012302230323042305230623072308230923102311231223132314231523162317231823192320232123222323232423252326232723282329233023312332233323342335233623372338233923402341234223432344
  1. /*
  2. Musicify - Alpine.js Application Component
  3. Modern music player for ArozOS
  4. */
  5. // Set to true to enable verbose playback debug logging in the browser console.
  6. const MUSICIFY_DEBUG = false;
  7. // ─── Default cover art SVG (music note) ──────────────────────────────────────
  8. const DEFAULT_COVER = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'%3E%3Crect width='100' height='100' fill='%231e1e26'/%3E%3Ctext x='50' y='62' font-size='48' text-anchor='middle' fill='%23a855f7'%3E%F0%9F%8E%B5%3C/text%3E%3C/svg%3E";
  9. function musicifyApp() {
  10. return {
  11. // ── Navigation ──────────────────────────────────────────────────────
  12. view: 'home', // 'home' | 'folders' | 'artists' | 'recent' | 'playlist'
  13. sidebarOpen: false,
  14. isMobileLandscape: false, // true when a mobile browser is rotated into landscape
  15. loading: false,
  16. loadingMsg: '',
  17. // ── Folder Browser ──────────────────────────────────────────────────
  18. folderRoot: 'user:/Music',
  19. folderPath: 'user:/Music',
  20. folderStack: [], // stack of previous paths for back navigation
  21. folderContents: { folders: [], songs: [] },
  22. musicLibraries: [], // [ { label, root } ] from listRoots.js
  23. // ── Favorite (pinned) folders ───────────────────────────────────────
  24. favoriteFolders: [], // [ { path, name, root } ] persisted per user
  25. // ── Artists ─────────────────────────────────────────────────────────
  26. artists: [],
  27. selectedArtist: null, // full artist object for dedicated artist songs view
  28. artistDetailOpen: false,
  29. artistsFromCache: false,
  30. artistsRefreshing: false,
  31. artistsCacheUpdatedAt: 0,
  32. _artistsFetchInFlight: false,
  33. _artistsUpdateFlash: false,
  34. _artistsUpdateFlashTimer: null,
  35. _artistsWorker: null,
  36. _artistsWorkerReqId: 0,
  37. _artistsActiveReqId: 0,
  38. _artistsWatchdogTimer: null,
  39. // Artist virtual scrolling
  40. artistRowHeight: 65, // must match CSS .artist-row height
  41. artistOverscan: 120, //artistRowHeight * artistOverscan = overscan px, Should be large enough for playlist expansion
  42. artistScrollTop: 0,
  43. artistListScrollTop: 0,
  44. selectedArtistListScrollTop: 0,
  45. // ── Recent ──────────────────────────────────────────────────────────
  46. recentSongs: [],
  47. // ── Playlists ────────────────────────────────────────────────────────
  48. playlists: [],
  49. currentPlaylistName: null,
  50. currentPlaylistSongs: [],
  51. showNewPlaylistModal: false,
  52. newPlaylistName: '',
  53. showAddToPlaylistModal: false,
  54. addToPlaylistSong: null,
  55. // ── Search ───────────────────────────────────────────────────────────
  56. searchQuery: '',
  57. searchResults: [],
  58. // ── Player ───────────────────────────────────────────────────────────
  59. queue: [], // current ordered play queue
  60. shuffledQueue: [], // shuffled copy used when shuffle is on
  61. queueIndex: -1,
  62. currentTrack: null,
  63. isPlaying: false,
  64. currentTime: 0,
  65. duration: 0,
  66. isSeeking: false,
  67. volume: 80,
  68. isMuted: false,
  69. forceShowVolumeControl: false, // user override: force-show the in-app volume slider on mobile devices
  70. shuffle: false,
  71. repeat: 'none', // 'none' | 'all' | 'one'
  72. showQueue: false,
  73. coverError: false,
  74. // ── Sleep Timer ──────────────────────────────────────────────────────
  75. showSleepModal: false,
  76. sleepActive: false,
  77. sleepMinutes: 30,
  78. sleepCountdown: '',
  79. _sleepTimer: null,
  80. _sleepEnd: 0,
  81. // ── Recently Played (localStorage) ───────────────────────────────────
  82. recentlyPlayed: [], // last 12 tracks
  83. // ── Track Info Panel ─────────────────────────────────────────────────
  84. showTrackInfo: false,
  85. trackInfoSong: null,
  86. // ── Now Playing full-screen overlay ──────────────────────────────────
  87. showNowPlaying: false,
  88. _npTouchStartX: 0,
  89. _npTouchStartY: 0,
  90. _npSwiping: false,
  91. _npSwipeDir: '', // 'left' | 'right' | '' — drives the slide animation
  92. // ── Internal playback guard ──────────────────────────────────────────
  93. _suppressEnded: false, // true while a new track is loading (prevents double-skip)
  94. _skipTimer: null, // pending "skip after playback error" timeout
  95. _skipTrack: null, // the track that failed and armed _skipTimer
  96. // ── Helpers (accessible from Alpine template expressions) ─────────────
  97. // A "desktop" sidebar is a static, always-visible column. Mobile browsers
  98. // rotated to landscape are wide enough to pass the 768px check but should
  99. // still get the collapsible/overlay sidebar, so they're excluded here.
  100. isSidebarDesktop() { return window.innerWidth > 768 && !this._isMobile(); },
  101. // ── Transcode ────────────────────────────────────────────────────────
  102. transcodeMode: '48', // 'disabled' | '16' | '24' | '48' (kHz)
  103. _transcodeSeekOffset: 0, // seconds already seeked past in current transcode stream
  104. _currentTrackTranscoded: false,// true when current track is served via transcode endpoint
  105. _transcodeEndFallbackTimer: null, // guards against 'ended' not firing on Safari
  106. // ── Full Buffer Mode ─────────────────────────────────────────────────
  107. fullBufferMode: false, // true = buffer entire track before playback (iOS default)
  108. _fullBufferLoading: false, // true while waiting for server-side buffer to finish
  109. // ── Privacy / Audio Visualizer Mode ──────────────────────────────────
  110. privacyVisualizerMode: false, // true = replace album art with an audio-reactive visualizer
  111. _vizColor: '168,85,247', // 'r,g,b' infill color sampled from the current cover art
  112. _audioCtx: null,
  113. _analyser: null,
  114. _vizData: null,
  115. _vizAnimId: null,
  116. _syntheticViz: false, // iOS: animate the bars without Web Audio so screen-lock doesn't stop playback
  117. // ── Arozcast ─────────────────────────────────────────────────────────
  118. castMode: false,
  119. castConnected: false,
  120. castConnecting: false,
  121. showCastModal: false,
  122. castCode: '',
  123. castCodeInput: '',
  124. castError: '',
  125. _castWs: null,
  126. _castPingTimer: null,
  127. _castWatchTimer: null,
  128. _castLastSeen: 0,
  129. _castReconnectTimer: null,
  130. _castReconnectCount: 0,
  131. _castPendingCode: null,
  132. // ── Internal refs ────────────────────────────────────────────────────
  133. _audio: null,
  134. // ════════════════════════════════════════════════════════════════════
  135. // INIT
  136. // ════════════════════════════════════════════════════════════════════
  137. init() {
  138. this._audio = document.getElementById('musicPlayer');
  139. const self = this;
  140. this._audio.addEventListener('timeupdate', () => {
  141. if (!self.isSeeking) {
  142. self.currentTime = self._audio.currentTime + self._transcodeSeekOffset;
  143. }
  144. // Near-end handling for transcoded streams:
  145. // - Log when within 3 s of the pre-fetched duration for debugging.
  146. // - Arm a fallback timer so _onEnded fires even if the browser never
  147. // emits 'ended' (common on iOS Safari with chunked MP3 streams).
  148. if (self._currentTrackTranscoded && self.duration > 0 && !self._suppressEnded) {
  149. var displayTime = self._audio.currentTime + self._transcodeSeekOffset;
  150. var remaining = self.duration - displayTime;
  151. if (MUSICIFY_DEBUG && remaining >= 0 && remaining <= 3) {
  152. console.log('[Musicify transcode] near-end timeupdate – pos:', displayTime.toFixed(2), '/ dur:', self.duration.toFixed(2), '| remaining:', remaining.toFixed(2));
  153. }
  154. if (remaining >= 0 && remaining <= 2) {
  155. if (self._transcodeEndFallbackTimer) clearTimeout(self._transcodeEndFallbackTimer);
  156. self._transcodeEndFallbackTimer = setTimeout(function() {
  157. self._transcodeEndFallbackTimer = null;
  158. if (!self._currentTrackTranscoded || self._suppressEnded) return;
  159. if (MUSICIFY_DEBUG) console.log('[Musicify transcode] end fallback fired – ended event did not arrive in time');
  160. self._onEnded();
  161. }, (remaining + 1.5) * 1000);
  162. }
  163. }
  164. });
  165. this._audio.addEventListener('loadedmetadata', () => {
  166. var d = self._audio.duration;
  167. if (self._currentTrackTranscoded) {
  168. // Don't override the pre-fetched duration for transcoded streams
  169. if (!self.duration && d && isFinite(d) && d > 0) self.duration = d;
  170. } else {
  171. self.duration = (d && isFinite(d)) ? d : 0;
  172. }
  173. });
  174. this._audio.addEventListener('ended', () => {
  175. if (MUSICIFY_DEBUG) console.log('[Musicify] audio ended – pos:', (self._audio.currentTime + self._transcodeSeekOffset).toFixed(2), '/ dur:', self.duration.toFixed(2), '| transcoded:', self._currentTrackTranscoded);
  176. self._onEnded();
  177. });
  178. this._audio.addEventListener('error', () => {
  179. if (MUSICIFY_DEBUG) console.warn('[Musicify] audio error – code:', self._audio.error && self._audio.error.code, self._audio.error && self._audio.error.message);
  180. self._onError();
  181. });
  182. this._audio.addEventListener('stalled', () => {
  183. if (MUSICIFY_DEBUG) console.log('[Musicify] audio stalled – pos:', (self._audio.currentTime + self._transcodeSeekOffset).toFixed(2), '/ dur:', self.duration.toFixed(2), '| transcoded:', self._currentTrackTranscoded);
  184. });
  185. this._audio.addEventListener('waiting', () => {
  186. if (MUSICIFY_DEBUG) console.log('[Musicify] audio waiting – pos:', (self._audio.currentTime + self._transcodeSeekOffset).toFixed(2), '/ dur:', self.duration.toFixed(2), '| transcoded:', self._currentTrackTranscoded);
  187. });
  188. this._audio.addEventListener('play', () => {
  189. self.isPlaying = true; self._suppressEnded = false; self._fullBufferLoading = false; self._cancelErrorSkip(); self._updateMediaSession();
  190. if (self._audioCtx && self._audioCtx.state === 'suspended') self._audioCtx.resume().catch(() => {});
  191. });
  192. this._audio.addEventListener('pause', () => { self.isPlaying = false; self._updateMediaSession(); });
  193. // Restore volume
  194. var savedVol = localStorage.getItem('musicify_volume');
  195. if (savedVol !== null) {
  196. this.volume = parseInt(savedVol);
  197. this._audio.volume = this.volume / 100;
  198. } else {
  199. this._audio.volume = this.volume / 100;
  200. }
  201. // Mobile Volume Control: Safari/iOS and most Android browsers expect the
  202. // device's hardware buttons to control volume, so default to full volume
  203. // and hide the in-app slider on mobile unless the user overrides it.
  204. var _savedForceVol = localStorage.getItem('musicify_forceShowVolumeControl');
  205. if (_savedForceVol !== null) {
  206. this.forceShowVolumeControl = (_savedForceVol === 'true');
  207. }
  208. if (this._isMobile() && !this.forceShowVolumeControl) {
  209. this.volume = 100;
  210. this.isMuted = false;
  211. this._audio.volume = 1;
  212. }
  213. // Restore shuffle / repeat / recently-played from server-side prefs
  214. // (cross-device: stored per user, not per browser)
  215. ao_module_storage.loadStorage("Musicify", "shuffle", function(val) {
  216. if (val !== null && val !== undefined) self.shuffle = (val === 'true');
  217. });
  218. ao_module_storage.loadStorage("Musicify", "repeat", function(val) {
  219. if (val === 'all' || val === 'one' || val === 'none') self.repeat = val;
  220. });
  221. ao_module_storage.loadStorage("Musicify", "recent", function(val) {
  222. if (val) {
  223. try { self.recentlyPlayed = JSON.parse(val).slice(0, 12); } catch(e) {}
  224. }
  225. });
  226. var _savedTranscode = localStorage.getItem('musicify_transcodeMode');
  227. if (_savedTranscode === 'disabled' || _savedTranscode === '16' || _savedTranscode === '24' || _savedTranscode === '48') {
  228. this.transcodeMode = _savedTranscode;
  229. }
  230. // Full Buffer Mode: auto-enable on iOS, otherwise restore from localStorage
  231. var _savedFBM = localStorage.getItem('musicify_fullBufferMode');
  232. if (_savedFBM !== null) {
  233. this.fullBufferMode = (_savedFBM === 'true');
  234. } else {
  235. // Auto-detect iOS and enable by default
  236. this.fullBufferMode = this._isIOS();
  237. }
  238. // Privacy / Audio Visualizer Mode: restore from localStorage
  239. var _savedPrivacyViz = localStorage.getItem('musicify_privacyVisualizer');
  240. if (_savedPrivacyViz !== null) {
  241. this.privacyVisualizerMode = (_savedPrivacyViz === 'true');
  242. }
  243. if (this.privacyVisualizerMode) {
  244. this._ensureAnalyser();
  245. this._updateVizColor(this.currentTrack);
  246. this._startVizLoop();
  247. }
  248. // MediaSession
  249. this._setupMediaSession();
  250. // Load playlists for sidebar
  251. this._loadPlaylists();
  252. // Load pinned folders for the sidebar Favorites section
  253. this._loadFavoriteFolders();
  254. // Pre-load available music library roots for the folder-view switcher
  255. this._loadMusicLibraries();
  256. window.addEventListener('beforeunload', () => {
  257. if (this._artistsWorker) {
  258. this._artistsWorker.terminate();
  259. this._artistsWorker = null;
  260. }
  261. });
  262. // Handle #folder=<path> hash from embedded player's "Open in Musicify" button
  263. var _hash = window.location.hash;
  264. if (_hash.startsWith('#folder=')) {
  265. var _folder = decodeURIComponent(_hash.substring(8));
  266. window.history.replaceState(null, '', window.location.pathname);
  267. this.view = 'folders';
  268. this.folderStack = [];
  269. this.loadFolder(_folder);
  270. }
  271. // Listen for other apps taking over the Arozcast session
  272. try {
  273. var _acCh = new BroadcastChannel('arozcast');
  274. _acCh.onmessage = (evt) => {
  275. if (evt.data && evt.data.type === 'arozcast.takeover' && self.castMode) {
  276. self.disconnectCast();
  277. }
  278. };
  279. } catch(e) {}
  280. // When the user returns to this tab after the phone was asleep, reconnect immediately
  281. document.addEventListener('visibilitychange', function() {
  282. if (document.visibilityState === 'visible' && self._castPendingCode) {
  283. clearTimeout(self._castReconnectTimer);
  284. self._castReconnectTimer = null;
  285. self._attemptCastReconnect();
  286. }
  287. });
  288. // Android player-bar class
  289. if (this._isAndroid()) {
  290. document.querySelector('.player-bar').classList.add('android');
  291. }
  292. // Responsive sidebar
  293. this._updateLandscapeState();
  294. this.sidebarOpen = window.innerWidth > 768 && !this.isMobileLandscape;
  295. var resizeT;
  296. window.addEventListener('resize', () => {
  297. clearTimeout(resizeT);
  298. resizeT = setTimeout(() => {
  299. this._updateLandscapeState();
  300. if (window.innerWidth <= 768 || this.isMobileLandscape) this.sidebarOpen = false;
  301. }, 150);
  302. });
  303. window.addEventListener('orientationchange', () => this._updateLandscapeState());
  304. },
  305. // ════════════════════════════════════════════════════════════════════
  306. // NAVIGATION
  307. // ════════════════════════════════════════════════════════════════════
  308. navigateTo(v) {
  309. this.view = v;
  310. this.searchQuery = '';
  311. if (window.innerWidth <= 768) this.sidebarOpen = false;
  312. if (v === 'folders') {
  313. if (this.musicLibraries.length === 0) this._loadMusicLibraries();
  314. if (this.folderContents.songs.length === 0 && this.folderContents.folders.length === 0) {
  315. this.loadFolder(this.folderRoot);
  316. }
  317. } else if (v === 'artists') {
  318. this._loadArtists();
  319. } else if (v === 'recent' && this.recentSongs.length === 0) {
  320. this._loadRecent();
  321. }
  322. //Close playing overlay if open
  323. if (this.showNowPlaying) this.closeNowPlaying();
  324. },
  325. openPlaylistView(name) {
  326. this.currentPlaylistName = name;
  327. this.view = 'playlist';
  328. if (window.innerWidth <= 768) this.sidebarOpen = false;
  329. this._loadPlaylistSongs(name);
  330. },
  331. // ════════════════════════════════════════════════════════════════════
  332. // LIBRARY ROOTS
  333. // ════════════════════════════════════════════════════════════════════
  334. _loadMusicLibraries() {
  335. const self = this;
  336. fetch(ao_root + 'system/ajgi/interface?script=Musicify/backend/listRoots.js', {
  337. method: 'POST', cache: 'no-cache',
  338. headers: { 'Content-Type': 'application/json' },
  339. body: JSON.stringify({})
  340. }).then(r => r.json()).then(data => {
  341. // Remove tmp:/ and trash:/ from the array
  342. data = Array.isArray(data) ? data.map(d => {
  343. if (d.root.startsWith('tmp:/') || d.root.startsWith('trash:/')) {
  344. return null;
  345. }
  346. return d;
  347. }) : [];
  348. self.musicLibraries = Array.isArray(data) ? data : [];
  349. }).catch(() => {});
  350. },
  351. switchLibrary(root) {
  352. this.folderRoot = root;
  353. this.folderStack = [];
  354. this.folderContents = { folders: [], songs: [] };
  355. this.loadFolder(root, false);
  356. },
  357. // ════════════════════════════════════════════════════════════════════
  358. // FOLDER BROWSER
  359. // ════════════════════════════════════════════════════════════════════
  360. loadFolder(path, showLoading = true) {
  361. if (showLoading) {
  362. this.loadingMsg = 'Loading folder…';
  363. this.loading = true;
  364. }
  365. const self = this;
  366. fetch(ao_root + 'system/ajgi/interface?script=Musicify/backend/listFolder.js', {
  367. method: 'POST', cache: 'no-cache',
  368. headers: { 'Content-Type': 'application/json' },
  369. body: JSON.stringify({ folder: path })
  370. }).then(r => r.json()).then(data => {
  371. if (data.error) { self._showToast(data.error, 'error'); if (showLoading) self.loading = false; return; }
  372. self.folderContents = data;
  373. self.folderPath = path;
  374. if (showLoading) {
  375. setTimeout(() => { self.loading = false; }, 100); // slight delay for smoother UX
  376. };
  377. }).catch(() => { if (showLoading){
  378. setTimeout(() => { self.loading = false; }, 100); // slight delay for smoother UX
  379. } });
  380. },
  381. folderNavigate(path) {
  382. this.folderStack.push(this.folderPath);
  383. this.artistDetailOpen = false;
  384. this.selectedArtist = null;
  385. this.loadFolder(path);
  386. },
  387. folderBack() {
  388. if (this.folderStack.length === 0) return;
  389. var prev = this.folderStack.pop();
  390. this.loadFolder(prev);
  391. },
  392. getFolderBreadcrumbs() {
  393. var parts = this.folderPath.split('/');
  394. var crumbs = [];
  395. var acc = '';
  396. for (var i = 0; i < parts.length; i++) {
  397. acc = i === 0 ? parts[0] : acc + '/' + parts[i];
  398. crumbs.push({ name: parts[i], path: acc });
  399. }
  400. return crumbs;
  401. },
  402. // ════════════════════════════════════════════════════════════════════
  403. // FAVORITE (PINNED) FOLDERS
  404. // ════════════════════════════════════════════════════════════════════
  405. _loadFavoriteFolders() {
  406. const self = this;
  407. ao_module_storage.loadStorage("Musicify", "favorites", function(val) {
  408. if (!val) return;
  409. try {
  410. var list = JSON.parse(val);
  411. if (Array.isArray(list)) {
  412. self.favoriteFolders = list.filter(f => f && f.path);
  413. }
  414. } catch(e) {}
  415. });
  416. },
  417. _saveFavoriteFolders() {
  418. ao_module_storage.setStorage("Musicify", "favorites", JSON.stringify(this.favoriteFolders));
  419. },
  420. // The library root itself is always reachable from the Folders tab, so
  421. // there is nothing to gain from pinning it.
  422. canFavoriteCurrentFolder() {
  423. return !!this.folderPath && this.folderPath !== this.folderRoot;
  424. },
  425. isFolderFavorited(path) {
  426. var p = path || this.folderPath;
  427. return this.favoriteFolders.some(f => f.path === p);
  428. },
  429. toggleFavoriteFolder() {
  430. if (!this.canFavoriteCurrentFolder()) return;
  431. var path = this.folderPath;
  432. var name = path.split('/').filter(s => s !== '').pop() || path;
  433. if (this.isFolderFavorited(path)) {
  434. this.favoriteFolders = this.favoriteFolders.filter(f => f.path !== path);
  435. this._saveFavoriteFolders();
  436. this._showToast('Removed "' + name + '" from Favorites');
  437. } else {
  438. this.favoriteFolders.push({ path: path, name: name, root: this.folderRoot });
  439. this._saveFavoriteFolders();
  440. this._showToast('Added "' + name + '" to Favorites');
  441. }
  442. },
  443. removeFavoriteFolder(path, event) {
  444. if (event) event.stopPropagation();
  445. var fav = this.favoriteFolders.find(f => f.path === path);
  446. this.favoriteFolders = this.favoriteFolders.filter(f => f.path !== path);
  447. this._saveFavoriteFolders();
  448. this._showToast('Removed "' + ((fav && fav.name) || 'folder') + '" from Favorites');
  449. },
  450. // Open the Folders view straight at a pinned folder. The ancestor paths
  451. // between the library root and the target are pushed onto the folder
  452. // stack so the Back button still walks back up the tree.
  453. openFavoriteFolder(fav) {
  454. if (!fav || !fav.path) return;
  455. this.view = 'folders';
  456. this.searchQuery = '';
  457. this.artistDetailOpen = false;
  458. this.selectedArtist = null;
  459. if (window.innerWidth <= 768) this.sidebarOpen = false;
  460. if (this.showNowPlaying) this.closeNowPlaying();
  461. var root = fav.root || this.folderRoot;
  462. this.folderRoot = root;
  463. this.folderStack = [];
  464. if (fav.path.indexOf(root + '/') === 0) {
  465. var rest = fav.path.substring(root.length + 1).split('/');
  466. var acc = root;
  467. this.folderStack.push(acc);
  468. for (var i = 0; i < rest.length - 1; i++) {
  469. acc = acc + '/' + rest[i];
  470. this.folderStack.push(acc);
  471. }
  472. }
  473. this.loadFolder(fav.path);
  474. },
  475. // ════════════════════════════════════════════════════════════════════
  476. // ARTISTS
  477. // ════════════════════════════════════════════════════════════════════
  478. _loadArtists(opts) {
  479. opts = opts || {};
  480. var forceNetwork = !!opts.forceNetwork;
  481. var self = this;
  482. // Artists refresh should never block the entire content panel.
  483. this.loading = false;
  484. // ── Start the network scan immediately — never wait for cache ─────
  485. if (this._artistsFetchInFlight) return;
  486. this._artistsFetchInFlight = true;
  487. this.artistsRefreshing = true;
  488. var reqId = ++this._artistsWorkerReqId;
  489. this._artistsActiveReqId = reqId;
  490. this._startArtistsWatchdog(reqId);
  491. // Use worker first to keep fetch + JSON parsing off the UI thread.
  492. var startedInWorker = this._dispatchArtistsFetchToWorker(reqId);
  493. if (!startedInWorker) {
  494. this._dispatchArtistsFetchFallback(reqId);
  495. }
  496. // ── In parallel: read server-side cache to pre-populate the UI ────
  497. // Only applies the cache if the network scan has not yet returned.
  498. if (!forceNetwork) {
  499. this._readArtistsCache(function(cache) {
  500. if (self.artistsRefreshing && cache && Array.isArray(cache.items)) {
  501. self.artists = cache.items;
  502. self.artistsFromCache = true;
  503. self.artistsCacheUpdatedAt = cache.ts || 0;
  504. }
  505. });
  506. }
  507. },
  508. _dispatchArtistsFetchToWorker(reqId) {
  509. if (!('Worker' in window)) return false;
  510. const self = this;
  511. if (!this._artistsWorker) {
  512. try {
  513. this._artistsWorker = new Worker('artistsWorker.js');
  514. } catch (e) {
  515. this._artistsWorker = null;
  516. return false;
  517. }
  518. this._artistsWorker.onmessage = function(evt) {
  519. var msg = evt && evt.data ? evt.data : {};
  520. if (msg.type === 'artistsResult') {
  521. self._applyArtistsResult(msg.items, msg.reqId);
  522. } else if (msg.type === 'artistsError') {
  523. self._handleArtistsError(msg.reqId);
  524. }
  525. };
  526. this._artistsWorker.onerror = function() {
  527. self._handleArtistsError(self._artistsActiveReqId);
  528. if (self._artistsWorker) {
  529. self._artistsWorker.terminate();
  530. self._artistsWorker = null;
  531. }
  532. };
  533. }
  534. try {
  535. this._artistsWorker.postMessage({
  536. type: 'fetchArtists',
  537. reqId: reqId,
  538. endpoint: ao_root + 'system/ajgi/interface?script=Musicify/backend/listArtists.js'
  539. });
  540. return true;
  541. } catch (e) {
  542. return false;
  543. }
  544. },
  545. _dispatchArtistsFetchFallback(reqId) {
  546. fetch(ao_root + 'system/ajgi/interface?script=Musicify/backend/listArtists.js', {
  547. method: 'POST', cache: 'no-cache',
  548. headers: { 'Content-Type': 'application/json' },
  549. body: JSON.stringify({})
  550. }).then(r => r.json()).then(data => {
  551. this._applyArtistsResult(data, reqId);
  552. }).catch(() => {
  553. this._handleArtistsError(reqId);
  554. });
  555. },
  556. _applyArtistsResult(data, reqId) {
  557. if (reqId !== this._artistsActiveReqId) return;
  558. data = Array.isArray(data) ? data : [];
  559. var selectedPath = this.selectedArtist ? this.selectedArtist.path : null;
  560. this.artists = data;
  561. this.artistsFromCache = false;
  562. this.artistsCacheUpdatedAt = Date.now();
  563. this._writeArtistsCache(data, this.artistsCacheUpdatedAt);
  564. this._flashArtistsUpdated();
  565. if (selectedPath) {
  566. var matched = null;
  567. for (var i = 0; i < data.length; i++) {
  568. if (data[i].path === selectedPath) {
  569. matched = data[i];
  570. break;
  571. }
  572. }
  573. this.selectedArtist = matched;
  574. }
  575. this._finalizeArtistsFetch(reqId);
  576. },
  577. _handleArtistsError(reqId) {
  578. if (reqId !== this._artistsActiveReqId) return;
  579. this._finalizeArtistsFetch(reqId);
  580. },
  581. _startArtistsWatchdog(reqId) {
  582. if (this._artistsWatchdogTimer) clearTimeout(this._artistsWatchdogTimer);
  583. const self = this;
  584. this._artistsWatchdogTimer = setTimeout(() => {
  585. if (reqId !== self._artistsActiveReqId) return;
  586. self._finalizeArtistsFetch(reqId);
  587. if (self._artistsWorker) {
  588. self._artistsWorker.terminate();
  589. self._artistsWorker = null;
  590. }
  591. }, 25000);
  592. },
  593. _finalizeArtistsFetch(reqId) {
  594. if (reqId !== this._artistsActiveReqId) return;
  595. if (this._artistsWatchdogTimer) {
  596. clearTimeout(this._artistsWatchdogTimer);
  597. this._artistsWatchdogTimer = null;
  598. }
  599. this.artistsRefreshing = false;
  600. this._artistsFetchInFlight = false;
  601. },
  602. // Reads the server-side artists cache (user:/.appdata/Musicify/).
  603. // Async — calls callback(cache) where cache is { ts, items } or null.
  604. _readArtistsCache(callback) {
  605. fetch(ao_root + 'system/ajgi/interface?script=Musicify/backend/getArtistsCache.js', {
  606. method: 'POST', cache: 'no-cache',
  607. headers: { 'Content-Type': 'application/json' },
  608. body: JSON.stringify({})
  609. }).then(function(r) { return r.json(); })
  610. .then(function(data) {
  611. if (data && !data.error && Array.isArray(data.items)) {
  612. callback({ ts: data.ts || 0, items: data.items });
  613. } else {
  614. callback(null);
  615. }
  616. }).catch(function() { callback(null); });
  617. },
  618. // Cache is now written server-side by listArtists.js before it sends its
  619. // response — no client-side write needed.
  620. _writeArtistsCache(items, updatedAt) {},
  621. _flashArtistsUpdated() {
  622. this._artistsUpdateFlash = true;
  623. if (this._artistsUpdateFlashTimer) clearTimeout(this._artistsUpdateFlashTimer);
  624. const self = this;
  625. this._artistsUpdateFlashTimer = setTimeout(() => {
  626. self._artistsUpdateFlash = false;
  627. }, 3000);
  628. },
  629. artistsStatusText() {
  630. if (this.artistsRefreshing && this.artistsFromCache) {
  631. return 'Showing cached artists while refreshing in background';
  632. }
  633. if (this.artistsFromCache) {
  634. return 'Showing cached artists';
  635. }
  636. if (this.artistsRefreshing) {
  637. return 'Refreshing artist list';
  638. }
  639. if (this._artistsUpdateFlash) {
  640. return 'Artist list updated';
  641. }
  642. return 'Live artist list';
  643. },
  644. artistsUpdatedTimeText() {
  645. if (!this.artistsCacheUpdatedAt) return '';
  646. var d = new Date(this.artistsCacheUpdatedAt);
  647. return 'Updated at ' + d.toLocaleTimeString([], {
  648. hour: '2-digit',
  649. minute: '2-digit',
  650. timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
  651. timeZoneName: 'short'
  652. });
  653. },
  654. _getSelectedArtistListContainer() {
  655. return document.getElementById('artist-selected-content-body');
  656. },
  657. _getArtistListContainer() {
  658. return document.getElementById('artist-content-body');
  659. },
  660. _getMainContentContainer() {
  661. return document.getElementById('mainContent');
  662. },
  663. _getArtistViewportHeight() {
  664. var artistListContainer = this._getArtistListContainer();
  665. if (artistListContainer && artistListContainer.clientHeight) {
  666. return artistListContainer.clientHeight;
  667. }
  668. var mainContainer = this._getMainContentContainer();
  669. if (mainContainer && mainContainer.clientHeight) {
  670. return mainContainer.clientHeight;
  671. }
  672. return window.innerHeight;
  673. },
  674. selectArtist(artist) {
  675. var mainContainer = this._getMainContentContainer();
  676. if (mainContainer) {
  677. this.artistListScrollTop = mainContainer.scrollTop;
  678. this.artistScrollTop = mainContainer.scrollTop;
  679. }
  680. this.selectedArtist = artist;
  681. this.artistDetailOpen = true;
  682. this.$nextTick(() => {
  683. this.$nextTick(() => {
  684. var mainContainer = this._getMainContentContainer();
  685. if (mainContainer) {
  686. mainContainer.scrollTop = 0;
  687. };
  688. });
  689. });
  690. },
  691. backToArtistList() {
  692. this.artistDetailOpen = false;
  693. var targetScrollTop = this.artistListScrollTop || 0;
  694. this.artistScrollTop = targetScrollTop;
  695. this.$nextTick(() => {
  696. this.$nextTick(() => {
  697. var mainContainer = this._getMainContentContainer();
  698. if (mainContainer) {
  699. mainContainer.scrollTop = targetScrollTop;
  700. }
  701. });
  702. });
  703. },
  704. visibleArtists() {
  705. const viewportHeight = this._getArtistViewportHeight();
  706. const start =
  707. Math.max(
  708. 0,
  709. Math.floor(this.artistScrollTop / this.artistRowHeight)
  710. - this.artistOverscan
  711. );
  712. const count =
  713. Math.ceil(viewportHeight / this.artistRowHeight)
  714. + (this.artistOverscan * 2);
  715. return this.artists.slice(start, start + count);
  716. },
  717. artistStartIndex() {
  718. return Math.max(
  719. 0,
  720. Math.floor(this.artistScrollTop / this.artistRowHeight)
  721. - this.artistOverscan
  722. );
  723. },
  724. artistTopSpacerHeight() {
  725. return this.artistStartIndex() * this.artistRowHeight;
  726. },
  727. artistBottomSpacerHeight() {
  728. const rendered =
  729. this.visibleArtists().length;
  730. return Math.max(
  731. 0,
  732. (this.artists.length -
  733. this.artistStartIndex() -
  734. rendered) * this.artistRowHeight
  735. );
  736. },
  737. onArtistScroll(e) {
  738. var eventScrollTop = e && e.target ? e.target.scrollTop : 0;
  739. var artistListContainer = this._getArtistListContainer();
  740. var mainContainer = this._getMainContentContainer();
  741. var scrollTop = Math.max(
  742. eventScrollTop,
  743. artistListContainer ? artistListContainer.scrollTop : 0,
  744. mainContainer ? mainContainer.scrollTop : 0
  745. );
  746. this.artistScrollTop = scrollTop;
  747. this.artistListScrollTop = scrollTop;
  748. },
  749. onMainContentScroll(e) {
  750. if (this.view !== 'artists' || this.artistDetailOpen) return;
  751. this.onArtistScroll(e);
  752. },
  753. onSelectedArtistListScroll(e) {
  754. this.selectedArtistListScrollTop = e.target.scrollTop;
  755. },
  756. // ════════════════════════════════════════════════════════════════════
  757. // RECENT
  758. // ════════════════════════════════════════════════════════════════════
  759. _loadRecent() {
  760. this.loading = true;
  761. this.loadingMsg = 'Loading recent tracks…';
  762. const self = this;
  763. fetch(ao_root + 'system/ajgi/interface?script=Musicify/backend/listRecent.js', {
  764. method: 'POST', cache: 'no-cache',
  765. headers: { 'Content-Type': 'application/json' },
  766. body: JSON.stringify({})
  767. }).then(r => r.json()).then(data => {
  768. self.recentSongs = data;
  769. self.loading = false;
  770. }).catch(() => { self.loading = false; });
  771. },
  772. // ════════════════════════════════════════════════════════════════════
  773. // PLAYLISTS
  774. // ════════════════════════════════════════════════════════════════════
  775. _loadPlaylists() {
  776. const self = this;
  777. fetch(ao_root + 'system/ajgi/interface?script=Musicify/backend/playlist.js', {
  778. method: 'POST', cache: 'no-cache',
  779. headers: { 'Content-Type': 'application/json' },
  780. body: JSON.stringify({ opr: 'list_all' })
  781. }).then(r => r.json()).then(data => {
  782. self.playlists = Array.isArray(data) ? data : [];
  783. }).catch(() => {});
  784. },
  785. _loadPlaylistSongs(name) {
  786. this.loading = true;
  787. this.loadingMsg = 'Loading playlist…';
  788. const self = this;
  789. fetch(ao_root + 'system/ajgi/interface?script=Musicify/backend/playlist.js', {
  790. method: 'POST', cache: 'no-cache',
  791. headers: { 'Content-Type': 'application/json' },
  792. body: JSON.stringify({ opr: 'get', name: name })
  793. }).then(r => r.json()).then(data => {
  794. self.currentPlaylistSongs = Array.isArray(data) ? data : [];
  795. self.loading = false;
  796. }).catch(() => { self.loading = false; });
  797. },
  798. createPlaylist() {
  799. var n = this.newPlaylistName.trim();
  800. if (!n) return;
  801. const self = this;
  802. fetch(ao_root + 'system/ajgi/interface?script=Musicify/backend/playlist.js', {
  803. method: 'POST', cache: 'no-cache',
  804. headers: { 'Content-Type': 'application/json' },
  805. body: JSON.stringify({ opr: 'create', name: n })
  806. }).then(r => r.json()).then(data => {
  807. if (data.error) { self._showToast(data.error, 'error'); return; }
  808. self.newPlaylistName = '';
  809. self.showNewPlaylistModal = false;
  810. self._loadPlaylists();
  811. self._showToast('Playlist "' + n + '" created');
  812. });
  813. },
  814. deletePlaylist(name) {
  815. if (!confirm('Delete playlist "' + name + '"?')) return;
  816. const self = this;
  817. fetch(ao_root + 'system/ajgi/interface?script=Musicify/backend/playlist.js', {
  818. method: 'POST', cache: 'no-cache',
  819. headers: { 'Content-Type': 'application/json' },
  820. body: JSON.stringify({ opr: 'delete', name: name })
  821. }).then(() => {
  822. if (self.currentPlaylistName === name) { self.currentPlaylistName = null; self.view = 'home'; }
  823. self._loadPlaylists();
  824. self._showToast('Playlist deleted');
  825. });
  826. },
  827. promptAddToPlaylist(song, event) {
  828. if (event) event.stopPropagation();
  829. this.addToPlaylistSong = song;
  830. this.showAddToPlaylistModal = true;
  831. },
  832. addSongToPlaylist(playlistName) {
  833. if (!this.addToPlaylistSong) return;
  834. const self = this;
  835. const song = this.addToPlaylistSong;
  836. fetch(ao_root + 'system/ajgi/interface?script=Musicify/backend/playlist.js', {
  837. method: 'POST', cache: 'no-cache',
  838. headers: { 'Content-Type': 'application/json' },
  839. body: JSON.stringify({ opr: 'add', name: playlistName, song: encodeURIComponent(song.filepath) })
  840. }).then(r => r.json()).then(data => {
  841. self.showAddToPlaylistModal = false;
  842. self.addToPlaylistSong = null;
  843. if (data.error) { self._showToast(data.error, 'error'); return; }
  844. if (data.duplicate) { self._showToast('Already in playlist'); return; }
  845. self._showToast('Added to "' + playlistName + '"');
  846. self._loadPlaylists();
  847. if (self.currentPlaylistName === playlistName) self._loadPlaylistSongs(playlistName);
  848. });
  849. },
  850. removeFromCurrentPlaylist(index, event) {
  851. if (event) event.stopPropagation();
  852. const self = this;
  853. fetch(ao_root + 'system/ajgi/interface?script=Musicify/backend/playlist.js', {
  854. method: 'POST', cache: 'no-cache',
  855. headers: { 'Content-Type': 'application/json' },
  856. body: JSON.stringify({ opr: 'remove', name: self.currentPlaylistName, index: index })
  857. }).then(() => {
  858. self._loadPlaylistSongs(self.currentPlaylistName);
  859. self._loadPlaylists();
  860. });
  861. },
  862. // ════════════════════════════════════════════════════════════════════
  863. // SEARCH
  864. // ════════════════════════════════════════════════════════════════════
  865. doSearch() {
  866. var q = this.searchQuery.toLowerCase().trim();
  867. if (!q) { this.searchResults = []; return; }
  868. // Search across already-loaded data pools
  869. var results = [];
  870. var seen = {};
  871. function addIfNew(song) {
  872. if (!seen[song.filepath]) {
  873. seen[song.filepath] = true;
  874. results.push(song);
  875. }
  876. }
  877. // Folder contents
  878. (this.folderContents.songs || []).forEach(s => { if (s.name.toLowerCase().includes(q)) addIfNew(s); });
  879. // Recent
  880. (this.recentSongs || []).forEach(s => { if (s.name.toLowerCase().includes(q)) addIfNew(s); });
  881. // Artists
  882. (this.artists || []).forEach(a => {
  883. (a.songs || []).forEach(s => { if (s.name.toLowerCase().includes(q) || a.name.toLowerCase().includes(q)) addIfNew(s); });
  884. });
  885. // Current playlist
  886. (this.currentPlaylistSongs || []).forEach(s => { if (s.name.toLowerCase().includes(q)) addIfNew(s); });
  887. // Recently played
  888. (this.recentlyPlayed || []).forEach(s => { if (s.name.toLowerCase().includes(q)) addIfNew(s); });
  889. this.searchResults = results.slice(0, 100);
  890. },
  891. // ════════════════════════════════════════════════════════════════════
  892. // PLAYER – Queue management
  893. // ════════════════════════════════════════════════════════════════════
  894. playList(songs, startIndex) {
  895. if (!songs || songs.length === 0) return;
  896. startIndex = startIndex || 0;
  897. this.queue = songs.slice();
  898. this.queueIndex = startIndex;
  899. if (this.shuffle) this._buildShuffledQueue(startIndex);
  900. this._loadTrack(this._effectiveQueue()[this._effectiveIndex(startIndex)]);
  901. // Starting playback brings up the full-screen Now Playing view
  902. // only in mobile / small-screen mode, not on desktop.
  903. if (window.innerWidth <= 768) this.openNowPlaying();
  904. },
  905. playSong(song, sourceList, event) {
  906. if (event) event.stopPropagation();
  907. // Re-selecting the track that is already loaded shouldn't restart it from
  908. // the beginning — surface the Now Playing panel (album art / visualizer)
  909. // instead. Keyed on isCurrentTrack so the row highlighted as .active is
  910. // exactly the row that opens the panel, paused or playing.
  911. if (this.isCurrentTrack(song)) {
  912. this.openNowPlaying();
  913. return;
  914. }
  915. if (!sourceList || sourceList.length === 0) sourceList = [song];
  916. var idx = 0;
  917. for (var i = 0; i < sourceList.length; i++) {
  918. if (sourceList[i].filepath === song.filepath) { idx = i; break; }
  919. }
  920. this.playList(sourceList, idx);
  921. },
  922. addToQueue(song, event) {
  923. if (event) event.stopPropagation();
  924. this.queue.push(song);
  925. if (this.shuffle) this.shuffledQueue.push(song);
  926. this._showToast('Added to queue');
  927. },
  928. playNext(song, event) {
  929. if (event) event.stopPropagation();
  930. var insertAt = this.queueIndex + 1;
  931. this.queue.splice(insertAt, 0, song);
  932. if (this.shuffle) this.shuffledQueue.splice(this._effectiveIndex(this.queueIndex) + 1, 0, song);
  933. this._showToast('Playing next');
  934. },
  935. removeFromQueue(index, event) {
  936. if (event) event.stopPropagation();
  937. if (index === this.queueIndex) return; // can't remove currently playing
  938. this.queue.splice(index, 1);
  939. if (index < this.queueIndex) this.queueIndex--;
  940. },
  941. _effectiveQueue() { return this.shuffle ? this.shuffledQueue : this.queue; },
  942. _effectiveIndex(rawIndex) {
  943. if (!this.shuffle) return rawIndex;
  944. var track = this.queue[rawIndex];
  945. if (!track) return 0;
  946. for (var i = 0; i < this.shuffledQueue.length; i++) {
  947. if (this.shuffledQueue[i].filepath === track.filepath) return i;
  948. }
  949. return 0;
  950. },
  951. _buildShuffledQueue(currentIndex) {
  952. var arr = this.queue.slice();
  953. var current = arr.splice(currentIndex, 1)[0];
  954. for (var i = arr.length - 1; i > 0; i--) {
  955. var j = Math.floor(Math.random() * (i + 1));
  956. var tmp = arr[i]; arr[i] = arr[j]; arr[j] = tmp;
  957. }
  958. this.shuffledQueue = current ? [current].concat(arr) : arr;
  959. },
  960. // ════════════════════════════════════════════════════════════════════
  961. // PLAYER – Playback control
  962. // ════════════════════════════════════════════════════════════════════
  963. _loadTrack(song) {
  964. if (!song) return;
  965. this._suppressEnded = true;
  966. if (this._transcodeEndFallbackTimer) {
  967. clearTimeout(this._transcodeEndFallbackTimer);
  968. this._transcodeEndFallbackTimer = null;
  969. }
  970. this._cancelErrorSkip();
  971. this.currentTrack = song;
  972. this._updateVizColor(song);
  973. this.coverError = false;
  974. this.currentTime = 0;
  975. this.duration = 0;
  976. this._transcodeSeekOffset = 0;
  977. this._currentTrackTranscoded = false;
  978. if (this.castMode) {
  979. this._castSend('media.load', {
  980. filepath: song.filepath,
  981. name: song.name,
  982. artist: this.getArtistLabel(song),
  983. cover: song.cover || '',
  984. type: 'audio'
  985. });
  986. this._audio.pause();
  987. this.isPlaying = true;
  988. } else {
  989. var willTranscode = (this.transcodeMode !== 'disabled' && this._needsTranscode(song));
  990. this._currentTrackTranscoded = willTranscode;
  991. if (willTranscode && this.fullBufferMode) {
  992. this._playViaFullBuffer(song, 0, true);
  993. } else {
  994. this._playViaStream(song, 0, true);
  995. }
  996. }
  997. this._saveRecentlyPlayed(song);
  998. this._setupMediaSession();
  999. document.title = song.name + ' – Musicify';
  1000. if (ao_module_virtualDesktop){
  1001. ao_module_setWindowTitle('Musicify - ' + song.name);
  1002. }
  1003. this.trackInfoSong = song;
  1004. },
  1005. togglePlay() {
  1006. if (!this.currentTrack) return;
  1007. if (this.castMode) {
  1008. if (this.isPlaying) {
  1009. this._castSend('media.pause', {});
  1010. this.isPlaying = false;
  1011. } else {
  1012. this._castSend('media.play', {});
  1013. this.isPlaying = true;
  1014. }
  1015. return;
  1016. }
  1017. if (this._audio.paused) {
  1018. if (this._audioCtx && this._audioCtx.state === 'suspended') this._audioCtx.resume().catch(() => {});
  1019. this._audio.play().catch(() => {});
  1020. }
  1021. else { this._audio.pause(); }
  1022. },
  1023. nextTrack() {
  1024. var eq = this._effectiveQueue();
  1025. var ei = this._effectiveIndex(this.queueIndex);
  1026. if (eq.length === 0) return;
  1027. var next = ei + 1;
  1028. if (next >= eq.length) {
  1029. if (this.repeat === 'all' || this.repeat === 'one') next = 0;
  1030. else { this._audio.pause(); this.isPlaying = false; return; }
  1031. }
  1032. // Map back to queue index for shuffle mode
  1033. if (this.shuffle) {
  1034. var nextSong = this.shuffledQueue[next];
  1035. for (var i = 0; i < this.queue.length; i++) {
  1036. if (this.queue[i].filepath === nextSong.filepath) { this.queueIndex = i; break; }
  1037. }
  1038. } else {
  1039. this.queueIndex = next;
  1040. }
  1041. this._loadTrack(eq[next]);
  1042. },
  1043. prevTrack() {
  1044. var eq = this._effectiveQueue();
  1045. var ei = this._effectiveIndex(this.queueIndex);
  1046. var prev = ei - 1;
  1047. if (prev < 0) {
  1048. if (this.repeat === 'all' || this.repeat === 'one'){
  1049. prev = eq.length - 1;
  1050. }else{
  1051. this._audio.pause(); this.isPlaying = false; return;
  1052. }
  1053. }
  1054. if (this.shuffle) {
  1055. var prevSong = this.shuffledQueue[prev];
  1056. for (var i = 0; i < this.queue.length; i++) {
  1057. if (this.queue[i].filepath === prevSong.filepath) { this.queueIndex = i; break; }
  1058. }
  1059. } else {
  1060. this.queueIndex = prev;
  1061. }
  1062. this._loadTrack(eq[prev]);
  1063. },
  1064. seekTo(val) {
  1065. val = parseFloat(val);
  1066. if (this.castMode) {
  1067. this._castSend('media.seek', { time: val });
  1068. this.currentTime = val;
  1069. return;
  1070. }
  1071. if (this._currentTrackTranscoded && this.currentTrack) {
  1072. // Seek by reloading the transcode stream from the new position
  1073. this._transcodeSeekOffset = val;
  1074. this.currentTime = val;
  1075. this._audio.src = this._getAudioSrc(this.currentTrack, val);
  1076. this._audio.load();
  1077. this._audio.play().catch(() => {});
  1078. return;
  1079. }
  1080. this._audio.currentTime = val;
  1081. this.currentTime = this._audio.currentTime;
  1082. },
  1083. beginSeek() { this.isSeeking = true; },
  1084. endSeek(val) { this.isSeeking = false; this.seekTo(val); },
  1085. setVolume(val) {
  1086. this.volume = parseInt(val);
  1087. this.isMuted = this.volume === 0;
  1088. localStorage.setItem('musicify_volume', this.volume);
  1089. if (this.castMode) {
  1090. this._castSend('media.volume', { volume: this.volume, muted: this.isMuted });
  1091. return;
  1092. }
  1093. this._audio.volume = this.volume / 100;
  1094. },
  1095. toggleMute() {
  1096. this.isMuted = !this.isMuted;
  1097. if (this.castMode) {
  1098. this._castSend('media.volume', { volume: this.volume, muted: this.isMuted });
  1099. return;
  1100. }
  1101. this._audio.muted = this.isMuted;
  1102. },
  1103. // True when the in-app volume slider should be shown: always on desktop,
  1104. // and on mobile only if the user has explicitly turned the override on.
  1105. shouldShowVolumeSlider() {
  1106. return !this._isMobile() || this.forceShowVolumeControl;
  1107. },
  1108. saveForceShowVolumeControl() {
  1109. localStorage.setItem('musicify_forceShowVolumeControl', String(this.forceShowVolumeControl));
  1110. if (this.forceShowVolumeControl) {
  1111. this._showToast('Volume slider shown on this device');
  1112. } else if (this._isMobile()) {
  1113. this.volume = 100;
  1114. this.isMuted = false;
  1115. this._audio.volume = 1;
  1116. localStorage.setItem('musicify_volume', this.volume);
  1117. this._showToast('Using native volume control – volume set to max');
  1118. }
  1119. },
  1120. toggleQueue(){
  1121. this.showQueue = !this.showQueue;
  1122. this.updateQueuePanelPosition();
  1123. },
  1124. updateQueuePanelPosition(){
  1125. var bottomPos = window.innerHeight - document.getElementsByClassName("player-bar")[0].getBoundingClientRect().top;
  1126. var queueEl = document.getElementById("queue-panel");
  1127. if (this.showQueue) {
  1128. queueEl.style.bottom = bottomPos + "px";
  1129. } else {
  1130. queueEl.style.bottom = -queueEl.offsetHeight + "px";
  1131. }
  1132. },
  1133. toggleShuffle() {
  1134. this.shuffle = !this.shuffle;
  1135. ao_module_storage.setStorage("Musicify", "shuffle", String(this.shuffle));
  1136. if (this.shuffle) this._buildShuffledQueue(this.queueIndex);
  1137. },
  1138. cycleRepeat() {
  1139. var modes = ['none', 'all', 'one'];
  1140. var idx = modes.indexOf(this.repeat);
  1141. this.repeat = modes[(idx + 1) % modes.length];
  1142. ao_module_storage.setStorage("Musicify", "repeat", this.repeat);
  1143. if (this.castMode) {
  1144. this._castSend('media.repeat', { mode: this.repeat });
  1145. }
  1146. },
  1147. // ════════════════════════════════════════════════════════════════════
  1148. // TRANSCODE HELPERS
  1149. // ════════════════════════════════════════════════════════════════════
  1150. _needsTranscode(song) {
  1151. if (!song || !song.ext) return false;
  1152. var nonNative = ['flac', 'ogg', 'wma', 'webm', 'opus'];
  1153. return nonNative.indexOf(song.ext.toLowerCase()) !== -1;
  1154. },
  1155. _isIOS() {
  1156. return /iPad|iPhone|iPod/.test(navigator.userAgent) && !window.MSStream;
  1157. },
  1158. _isAndroid() {
  1159. return /Android/.test(navigator.userAgent);
  1160. },
  1161. _isMobile() {
  1162. return this._isIOS() || this._isAndroid();
  1163. },
  1164. _updateLandscapeState() {
  1165. this.isMobileLandscape = this._isMobile() && window.innerWidth > window.innerHeight;
  1166. },
  1167. // Returns the playback URL for a song, using the transcode endpoint when needed.
  1168. // startTime (seconds) is only appended when seeking a transcoded stream.
  1169. _getAudioSrc(song, startTime) {
  1170. if (!song) return '';
  1171. if (this.transcodeMode !== 'disabled' && this._needsTranscode(song)) {
  1172. var url = ao_root + 'media/transcode/audio/?file=' + encodeURIComponent(song.filepath) +
  1173. '&samplerate=' + this.transcodeMode + '000';
  1174. if (startTime && startTime > 0.001) url += '&start=' + parseFloat(startTime).toFixed(3);
  1175. return url;
  1176. }
  1177. return ao_root + 'media?file=' + encodeURIComponent(song.filepath);
  1178. },
  1179. // Full Buffer Mode: ask the server to transcode the WHOLE track to a static
  1180. // MP3 first, then play that completed file. A complete file (served via /media
  1181. // with byte-range support) behaves like a native source, so iOS can seek it and
  1182. // never resets the stream to t=0 mid-playback.
  1183. // resumeAt – seconds to seek to once loaded (0 = from start)
  1184. // wasPlaying – auto-play after the source is ready
  1185. _playViaFullBuffer(song, resumeAt, wasPlaying) {
  1186. var self = this;
  1187. var _bufSong = song;
  1188. resumeAt = resumeAt || 0;
  1189. this._fullBufferLoading = true;
  1190. if (MUSICIFY_DEBUG) console.log('[Musicify FBM] requesting server buffer:', song.filepath, '@', this.transcodeMode + 'kHz', 'resumeAt:', resumeAt);
  1191. fetch(ao_root + 'system/ajgi/interface?script=Musicify/backend/fullbuffer.js', {
  1192. method: 'POST', cache: 'no-cache',
  1193. headers: { 'Content-Type': 'application/json' },
  1194. body: JSON.stringify({ file: song.filepath, samplerate: self.transcodeMode })
  1195. }).then(r => r.json()).then(data => {
  1196. self._fullBufferLoading = false;
  1197. this.$nextTick(() => {
  1198. this.$nextTick(() => {
  1199. _fullBufferLoading = false; // prevent racing for cached media file
  1200. });
  1201. });
  1202. // Abort if the user already switched to a different track
  1203. if (!self.currentTrack || self.currentTrack.filepath !== _bufSong.filepath) return;
  1204. if (data.error || !data.path) {
  1205. if (MUSICIFY_DEBUG) console.warn('[Musicify FBM] server buffer failed:', data.error || '(no path returned)');
  1206. self._showToast('Full buffer failed – streaming instead', 'error');
  1207. self._playViaStream(_bufSong, resumeAt, wasPlaying);
  1208. return;
  1209. }
  1210. if (MUSICIFY_DEBUG) console.log('[Musicify FBM] buffered file ready:', data.path);
  1211. // Completed static MP3 — behaves like a native file (iOS-safe, seekable)
  1212. self._currentTrackTranscoded = false;
  1213. self._transcodeSeekOffset = 0;
  1214. self.duration = 0;
  1215. self._audio.src = ao_root + 'media?file=' + encodeURIComponent(data.path);
  1216. self._audio.load();
  1217. if (resumeAt > 0) {
  1218. self._audio.addEventListener('loadedmetadata', function() {
  1219. try { self._audio.currentTime = resumeAt; } catch (e) {}
  1220. }, { once: true });
  1221. }
  1222. if (wasPlaying) {
  1223. // On iOS the audio element must already be unlocked by a prior
  1224. // gesture-initiated play; if not, play() rejects and we reflect the
  1225. // paused state so the user can tap play.
  1226. self._audio.play().catch(function() { self.isPlaying = false; });
  1227. }
  1228. }).catch(() => {
  1229. self._fullBufferLoading = false;
  1230. if (!self.currentTrack || self.currentTrack.filepath !== _bufSong.filepath) return;
  1231. if (MUSICIFY_DEBUG) console.warn('[Musicify FBM] network error contacting fullbuffer.js');
  1232. self._showToast('Full buffer error – streaming instead', 'error');
  1233. self._playViaStream(_bufSong, resumeAt, wasPlaying);
  1234. });
  1235. },
  1236. // Stream a track through the realtime transcode endpoint (or directly via /media
  1237. // for web-native formats). Used for non-FBM playback and as the FBM fallback.
  1238. // resumeAt – seconds to seek to once loaded (0 = from start)
  1239. // wasPlaying – auto-play after the source is ready (default true)
  1240. _playViaStream(song, resumeAt, wasPlaying) {
  1241. var self = this;
  1242. resumeAt = resumeAt || 0;
  1243. if (wasPlaying === undefined) wasPlaying = true;
  1244. this._currentTrackTranscoded = (this.transcodeMode !== 'disabled' && this._needsTranscode(song));
  1245. this._transcodeSeekOffset = 0;
  1246. this.duration = 0;
  1247. if (this._currentTrackTranscoded && resumeAt > 0.001) {
  1248. this._transcodeSeekOffset = resumeAt;
  1249. this._audio.src = this._getAudioSrc(song, resumeAt);
  1250. } else {
  1251. this._audio.src = this._getAudioSrc(song);
  1252. }
  1253. this._audio.load();
  1254. if (this._currentTrackTranscoded) {
  1255. // Transcoded streams have no Content-Length — pre-fetch duration for the seek bar
  1256. var _song = song;
  1257. fetch(ao_root + 'media/duration/?file=' + encodeURIComponent(_song.filepath))
  1258. .then(r => r.json())
  1259. .then(data => {
  1260. if (data.duration > 0 && self.currentTrack && self.currentTrack.filepath === _song.filepath) {
  1261. self.duration = data.duration;
  1262. }
  1263. }).catch(() => {});
  1264. } else if (resumeAt > 0) {
  1265. // Native source: seek once metadata is ready
  1266. this._audio.addEventListener('loadedmetadata', function() {
  1267. try { self._audio.currentTime = resumeAt; } catch (e) {}
  1268. }, { once: true });
  1269. }
  1270. if (wasPlaying) {
  1271. this._audio.play().catch(() => {});
  1272. }
  1273. },
  1274. saveFullBufferMode() {
  1275. localStorage.setItem('musicify_fullBufferMode', String(this.fullBufferMode));
  1276. this._showToast('Full Buffer Mode: ' + (this.fullBufferMode ? 'enabled' : 'disabled'));
  1277. // Reload the current track immediately so the change takes effect now
  1278. // (not just on the next track). Preserves the current playback position.
  1279. if (this.currentTrack && this._needsTranscode(this.currentTrack) &&
  1280. this.transcodeMode !== 'disabled' && !this.castMode) {
  1281. if (this._transcodeEndFallbackTimer) {
  1282. clearTimeout(this._transcodeEndFallbackTimer);
  1283. this._transcodeEndFallbackTimer = null;
  1284. }
  1285. var resumeAt = this.currentTime;
  1286. var wasPlaying = this.isPlaying;
  1287. this._suppressEnded = true;
  1288. if (MUSICIFY_DEBUG) console.log('[Musicify FBM] toggle ->', this.fullBufferMode ? 'buffer' : 'stream', 'reloading at', resumeAt);
  1289. if (this.fullBufferMode) {
  1290. this._playViaFullBuffer(this.currentTrack, resumeAt, wasPlaying);
  1291. } else {
  1292. this._playViaStream(this.currentTrack, resumeAt, wasPlaying);
  1293. }
  1294. }
  1295. },
  1296. saveTranscodeMode() {
  1297. localStorage.setItem('musicify_transcodeMode', this.transcodeMode);
  1298. // If a non-native track is currently loaded, reload it immediately at the
  1299. // current position so the new sample rate (and seeking) take effect now.
  1300. // Honour Full Buffer Mode: re-buffer at the new rate when it is enabled.
  1301. if (this.currentTrack && this._needsTranscode(this.currentTrack) && !this.castMode) {
  1302. if (this._transcodeEndFallbackTimer) {
  1303. clearTimeout(this._transcodeEndFallbackTimer);
  1304. this._transcodeEndFallbackTimer = null;
  1305. }
  1306. var resumeAt = this.currentTime; // already includes _transcodeSeekOffset
  1307. var wasPlaying = this.isPlaying;
  1308. this._suppressEnded = true;
  1309. if (this.transcodeMode !== 'disabled' && this.fullBufferMode) {
  1310. this._playViaFullBuffer(this.currentTrack, resumeAt, wasPlaying);
  1311. } else {
  1312. this._playViaStream(this.currentTrack, resumeAt, wasPlaying);
  1313. }
  1314. }
  1315. this._showToast('Transcode: ' + (this.transcodeMode === 'disabled' ? 'disabled' : this.transcodeMode + ' kHz'));
  1316. },
  1317. _onEnded() {
  1318. if (this._suppressEnded) return;
  1319. // Clear any pending end-fallback timer — the real ended path is now running
  1320. if (this._transcodeEndFallbackTimer) {
  1321. clearTimeout(this._transcodeEndFallbackTimer);
  1322. this._transcodeEndFallbackTimer = null;
  1323. }
  1324. if (this.repeat === 'one') {
  1325. if (this.castMode) {
  1326. this._castSend('media.seek', { time: 0 });
  1327. this._castSend('media.play', {});
  1328. } else if (this._currentTrackTranscoded && this.currentTrack) {
  1329. // Transcoded streams can't seek natively — reload from the beginning
  1330. this._suppressEnded = true;
  1331. this._transcodeSeekOffset = 0;
  1332. this.currentTime = 0;
  1333. this._audio.src = this._getAudioSrc(this.currentTrack);
  1334. this._audio.load();
  1335. this._audio.play().catch(() => {});
  1336. } else {
  1337. this._audio.currentTime = 0;
  1338. this._audio.play().catch(() => {});
  1339. }
  1340. return;
  1341. }
  1342. this.nextTrack();
  1343. },
  1344. _onError() {
  1345. var err = this._audio.error;
  1346. // MEDIA_ERR_ABORTED is not a playback failure: swapping the source while a
  1347. // fetch is still in flight raises one on Safari, and acting on it would skip
  1348. // a track that never even got a chance to play.
  1349. if (err && err.code === 1) return;
  1350. if (!this._audio.getAttribute('src')) return;
  1351. // Only one skip may be queued at a time. A single unplayable file can emit
  1352. // more than one error event (Safari does this when the failed source is torn
  1353. // down as the next track loads); without this guard the extra event queues a
  1354. // second skip and two tracks get passed over instead of one.
  1355. if (this._skipTimer) return;
  1356. var self = this;
  1357. var failed = this.currentTrack;
  1358. this._skipTrack = failed;
  1359. this._showToast('Playback error – skipping', 'error');
  1360. this._skipTimer = setTimeout(function() {
  1361. self._skipTimer = null;
  1362. self._skipTrack = null;
  1363. // The user (or another code path) may have moved on during the countdown
  1364. if (failed && (!self.currentTrack || self.currentTrack.filepath !== failed.filepath)) return;
  1365. self.nextTrack();
  1366. }, 1500);
  1367. },
  1368. // Drop a queued error-skip — called whenever a new track starts loading or
  1369. // playback recovers, so a stale timer can't skip the track that follows.
  1370. _cancelErrorSkip() {
  1371. if (this._skipTimer) {
  1372. clearTimeout(this._skipTimer);
  1373. this._skipTimer = null;
  1374. }
  1375. this._skipTrack = null;
  1376. },
  1377. isCurrentTrack(song) {
  1378. return this.currentTrack && this.currentTrack.filepath === song.filepath;
  1379. },
  1380. isCurrentQueueItem(index) {
  1381. if (!this.shuffle) return index === this.queueIndex;
  1382. var eq = this._effectiveQueue();
  1383. var current = eq[this._effectiveIndex(this.queueIndex)];
  1384. return current && this.queue[index].filepath === current.filepath;
  1385. },
  1386. // ════════════════════════════════════════════════════════════════════
  1387. // SLEEP TIMER
  1388. // ════════════════════════════════════════════════════════════════════
  1389. startSleepTimer() {
  1390. this.cancelSleepTimer();
  1391. this._sleepEnd = Date.now() + this.sleepMinutes * 60000;
  1392. this.sleepActive = true;
  1393. this.showSleepModal = false;
  1394. const self = this;
  1395. this._sleepTimer = setInterval(() => {
  1396. var rem = self._sleepEnd - Date.now();
  1397. if (rem <= 0) {
  1398. self._fadeOutAndPause();
  1399. self.cancelSleepTimer();
  1400. } else {
  1401. var m = Math.floor(rem / 60000);
  1402. var s = Math.floor((rem % 60000) / 1000);
  1403. self.sleepCountdown = m + ':' + String(s).padStart(2, '0');
  1404. }
  1405. }, 1000);
  1406. this._showToast('Sleep timer set for ' + this.sleepMinutes + ' min');
  1407. },
  1408. cancelSleepTimer() {
  1409. if (this._sleepTimer) clearInterval(this._sleepTimer);
  1410. this._sleepTimer = null;
  1411. this.sleepActive = false;
  1412. this.sleepCountdown = '';
  1413. },
  1414. _fadeOutAndPause() {
  1415. const audio = this._audio;
  1416. const originalVol = audio.volume;
  1417. const self = this;
  1418. var fadeInterval = setInterval(() => {
  1419. if (audio.volume > 0.05) {
  1420. audio.volume = Math.max(0, audio.volume - 0.04);
  1421. } else {
  1422. audio.volume = 0;
  1423. audio.pause();
  1424. audio.volume = originalVol;
  1425. self.isPlaying = false;
  1426. clearInterval(fadeInterval);
  1427. self._showToast('Sleep timer: music stopped');
  1428. }
  1429. }, 150);
  1430. },
  1431. // ════════════════════════════════════════════════════════════════════
  1432. // MEDIA SESSION API
  1433. // ════════════════════════════════════════════════════════════════════
  1434. _setupMediaSession() {
  1435. if (!('mediaSession' in navigator) || !this.currentTrack) return;
  1436. const self = this;
  1437. navigator.mediaSession.metadata = new MediaMetadata({
  1438. title: this.currentTrack.name,
  1439. artist: this._getArtistName(this.currentTrack),
  1440. album: '',
  1441. artwork: [{ src: this.getCoverUrl(this.currentTrack), sizes: '512x512', type: 'image/jpeg' }]
  1442. });
  1443. navigator.mediaSession.setActionHandler('play', () => self._audio.play());
  1444. navigator.mediaSession.setActionHandler('pause', () => self._audio.pause());
  1445. navigator.mediaSession.setActionHandler('previoustrack', () => self.prevTrack());
  1446. navigator.mediaSession.setActionHandler('nexttrack', () => self.nextTrack());
  1447. navigator.mediaSession.setActionHandler('seekto', details => {
  1448. self._audio.currentTime = details.seekTime;
  1449. });
  1450. },
  1451. _updateMediaSession() {
  1452. if (!('mediaSession' in navigator)) return;
  1453. navigator.mediaSession.playbackState = this.isPlaying ? 'playing' : 'paused';
  1454. if (this.duration > 0) {
  1455. try {
  1456. navigator.mediaSession.setPositionState({
  1457. duration: this.duration,
  1458. playbackRate: 1,
  1459. position: Math.min(this.currentTime, this.duration)
  1460. });
  1461. } catch(e) {}
  1462. }
  1463. },
  1464. // ════════════════════════════════════════════════════════════════════
  1465. // RECENTLY PLAYED (server-side, cross-device)
  1466. // ════════════════════════════════════════════════════════════════════
  1467. _saveRecentlyPlayed(song) {
  1468. var list = this.recentlyPlayed.filter(s => s.filepath !== song.filepath);
  1469. list.unshift(song);
  1470. list = list.slice(0, 12);
  1471. this.recentlyPlayed = list;
  1472. ao_module_storage.setStorage("Musicify", "recent", JSON.stringify(list));
  1473. },
  1474. // ════════════════════════════════════════════════════════════════════
  1475. // HELPERS
  1476. // ════════════════════════════════════════════════════════════════════
  1477. formatTime(s) {
  1478. if (!s || isNaN(s)) return '0:00';
  1479. s = Math.floor(s);
  1480. return Math.floor(s / 60) + ':' + String(s % 60).padStart(2, '0');
  1481. },
  1482. getCoverUrl(song) {
  1483. if (!song) return 'img/placeholder.png';
  1484. return ao_root + 'system/file_system/loadThumbnail?bytes=true&vpath=' + encodeURIComponent(song.filepath);
  1485. },
  1486. handleCoverError(event) {
  1487. event.target.src = 'img/placeholder.png';
  1488. event.target.onerror = null;
  1489. },
  1490. // ── Privacy / Audio Visualizer Mode ──────────────────────────────────
  1491. savePrivacyVisualizerMode() {
  1492. localStorage.setItem('musicify_privacyVisualizer', String(this.privacyVisualizerMode));
  1493. if (this.privacyVisualizerMode) {
  1494. this._ensureAnalyser();
  1495. if (this._audioCtx && this._audioCtx.state === 'suspended') this._audioCtx.resume().catch(() => {});
  1496. this._updateVizColor(this.currentTrack);
  1497. this._startVizLoop();
  1498. this._showToast('Privacy Mode: album art hidden');
  1499. } else {
  1500. this._stopVizLoop();
  1501. this._showToast('Privacy Mode: album art shown');
  1502. }
  1503. },
  1504. // Lazily wires the <audio> element into a Web Audio graph. This can only be
  1505. // done once per element (createMediaElementSource throws on a second call),
  1506. // so the analyser is created once and simply left connected afterwards.
  1507. _ensureAnalyser() {
  1508. if (this._analyser || this._syntheticViz || !this._audio) return;
  1509. // iOS: routing the <audio> element through a Web Audio AudioContext
  1510. // (createMediaElementSource) hands playback to that context, which iOS
  1511. // suspends when the screen locks — stopping background audio. Skip Web
  1512. // Audio there and drive the bars with a synthetic animation instead, so
  1513. // playback behaves exactly like non-privacy mode.
  1514. if (this._isIOS()) {
  1515. this._syntheticViz = true;
  1516. return;
  1517. }
  1518. try {
  1519. var AudioCtx = window.AudioContext || window.webkitAudioContext;
  1520. this._audioCtx = new AudioCtx();
  1521. var source = this._audioCtx.createMediaElementSource(this._audio);
  1522. this._analyser = this._audioCtx.createAnalyser();
  1523. this._analyser.fftSize = 64;
  1524. this._analyser.smoothingTimeConstant = 0.8;
  1525. source.connect(this._analyser);
  1526. this._analyser.connect(this._audioCtx.destination);
  1527. this._vizData = new Uint8Array(this._analyser.frequencyBinCount);
  1528. } catch (e) {
  1529. if (MUSICIFY_DEBUG) console.warn('[Musicify] visualizer unavailable', e);
  1530. this._analyser = null;
  1531. }
  1532. },
  1533. _startVizLoop() {
  1534. if (this._vizAnimId) return;
  1535. var self = this;
  1536. (function frame() {
  1537. self._vizAnimId = requestAnimationFrame(frame);
  1538. self._renderViz();
  1539. })();
  1540. },
  1541. _stopVizLoop() {
  1542. if (this._vizAnimId) { cancelAnimationFrame(this._vizAnimId); this._vizAnimId = null; }
  1543. },
  1544. _renderViz() {
  1545. var bars = 27;
  1546. var mid = Math.floor(bars / 2);
  1547. var heights = new Array(bars);
  1548. if (this._analyser && this._vizData) {
  1549. this._analyser.getByteFrequencyData(this._vizData);
  1550. var binCount = this._vizData.length;
  1551. var step = Math.max(1, Math.floor(binCount / (mid + 2)));
  1552. for (var i = 0; i < bars; i++) {
  1553. var dist = Math.abs(i - mid);
  1554. var idx = Math.min(binCount - 1, dist * step);
  1555. heights[i] = Math.max(0.06, this._vizData[idx] / 255);
  1556. }
  1557. } else if (this._syntheticViz) {
  1558. // iOS fallback: procedurally animated, center-weighted bars that
  1559. // pulse while playing and settle when paused (no real FFT).
  1560. var t = performance.now() / 1000;
  1561. var active = this.isPlaying ? 1 : 0;
  1562. for (var k = 0; k < bars; k++) {
  1563. var envelope = 1 - (Math.abs(k - mid) / mid) * 0.7; // taller in the middle
  1564. var wave = 0.5 + 0.30 * Math.sin(t * 3.1 + k * 0.5) + 0.15 * Math.sin(t * 5.7 + k * 0.9);
  1565. heights[k] = Math.max(0.06, Math.min(1, 0.08 + active * Math.max(0, wave) * envelope * 0.9));
  1566. }
  1567. } else {
  1568. for (var j = 0; j < bars; j++) heights[j] = 0.06;
  1569. }
  1570. this._paintVizCanvas(document.getElementById('np-viz-canvas'), heights);
  1571. this._paintVizCanvas(document.getElementById('mini-viz-canvas'), heights);
  1572. },
  1573. _paintVizCanvas(canvas, heights) {
  1574. if (!canvas || !canvas.offsetParent) return;
  1575. var dpr = window.devicePixelRatio || 1;
  1576. var w = canvas.clientWidth, h = canvas.clientHeight;
  1577. if (!w || !h) return;
  1578. if (canvas.width !== Math.round(w * dpr) || canvas.height !== Math.round(h * dpr)) {
  1579. canvas.width = Math.round(w * dpr);
  1580. canvas.height = Math.round(h * dpr);
  1581. }
  1582. var ctx = canvas.getContext('2d');
  1583. ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
  1584. ctx.clearRect(0, 0, w, h);
  1585. var n = heights.length;
  1586. var gap = w * 0.02;
  1587. var barW = (w - gap * (n - 1)) / n;
  1588. ctx.fillStyle = 'rgb(' + this._vizColor + ')';
  1589. for (var i = 0; i < n; i++) {
  1590. var bh = Math.max(2, heights[i] * h);
  1591. var x = i * (barW + gap);
  1592. var y = (h - bh) / 2;
  1593. this._roundRectPath(ctx, x, y, barW, bh, barW / 2);
  1594. ctx.fill();
  1595. }
  1596. },
  1597. _roundRectPath(ctx, x, y, w, h, r) {
  1598. r = Math.max(0, Math.min(r, w / 2, h / 2));
  1599. ctx.beginPath();
  1600. ctx.moveTo(x + r, y);
  1601. ctx.arcTo(x + w, y, x + w, y + h, r);
  1602. ctx.arcTo(x + w, y + h, x, y + h, r);
  1603. ctx.arcTo(x, y + h, x, y, r);
  1604. ctx.arcTo(x, y, x + w, y, r);
  1605. ctx.closePath();
  1606. },
  1607. // Samples the current cover art's average color (boosted for contrast against
  1608. // the blurred backdrop) and uses it as the visualizer bar color.
  1609. _updateVizColor(song) {
  1610. var self = this;
  1611. if (!song) { self._vizColor = '168,85,247'; return; }
  1612. var img = new Image();
  1613. img.onload = function() {
  1614. try {
  1615. var c = document.createElement('canvas');
  1616. c.width = 24; c.height = 24;
  1617. var cctx = c.getContext('2d');
  1618. cctx.drawImage(img, 0, 0, 24, 24);
  1619. var data = cctx.getImageData(0, 0, 24, 24).data;
  1620. var r = 0, g = 0, b = 0, n = 0;
  1621. for (var i = 0; i < data.length; i += 4) {
  1622. if (data[i + 3] < 10) continue;
  1623. r += data[i]; g += data[i + 1]; b += data[i + 2]; n++;
  1624. }
  1625. if (n === 0) { self._vizColor = '168,85,247'; return; }
  1626. r = Math.round(r / n); g = Math.round(g / n); b = Math.round(b / n);
  1627. self._vizColor = self._boostColorRGB(r, g, b);
  1628. } catch (e) {
  1629. self._vizColor = '168,85,247';
  1630. }
  1631. };
  1632. img.onerror = function() { self._vizColor = '168,85,247'; };
  1633. img.src = this.getCoverUrl(song);
  1634. },
  1635. _boostColorRGB(r, g, b) {
  1636. var hsl = this._rgbToHsl(r, g, b);
  1637. var s = Math.max(hsl[1], 0.65);
  1638. var l = Math.min(Math.max(hsl[2], 0.62), 0.82);
  1639. var rgb = this._hslToRgb(hsl[0], s, l);
  1640. return rgb[0] + ',' + rgb[1] + ',' + rgb[2];
  1641. },
  1642. _rgbToHsl(r, g, b) {
  1643. r /= 255; g /= 255; b /= 255;
  1644. var max = Math.max(r, g, b), min = Math.min(r, g, b);
  1645. var h = 0, s = 0, l = (max + min) / 2;
  1646. if (max !== min) {
  1647. var d = max - min;
  1648. s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
  1649. switch (max) {
  1650. case r: h = (g - b) / d + (g < b ? 6 : 0); break;
  1651. case g: h = (b - r) / d + 2; break;
  1652. case b: h = (r - g) / d + 4; break;
  1653. }
  1654. h /= 6;
  1655. }
  1656. return [h, s, l];
  1657. },
  1658. _hslToRgb(h, s, l) {
  1659. var r, g, b;
  1660. if (s === 0) { r = g = b = l; }
  1661. else {
  1662. var hue2rgb = function(p, q, t) {
  1663. if (t < 0) t += 1;
  1664. if (t > 1) t -= 1;
  1665. if (t < 1 / 6) return p + (q - p) * 6 * t;
  1666. if (t < 1 / 2) return q;
  1667. if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6;
  1668. return p;
  1669. };
  1670. var q = l < 0.5 ? l * (1 + s) : l + s - l * s;
  1671. var p = 2 * l - q;
  1672. r = hue2rgb(p, q, h + 1 / 3);
  1673. g = hue2rgb(p, q, h);
  1674. b = hue2rgb(p, q, h - 1 / 3);
  1675. }
  1676. return [Math.round(r * 255), Math.round(g * 255), Math.round(b * 255)];
  1677. },
  1678. _getArtistName(song) {
  1679. if (!song) return '';
  1680. var parts = song.filepath.split('/');
  1681. // /user:/Music/ArtistName/... → index 2
  1682. if (parts.length >= 3) return parts[parts.length - 2];
  1683. return '';
  1684. },
  1685. getArtistLabel(song) {
  1686. return this._getArtistName(song) || '';
  1687. },
  1688. progressPercent() {
  1689. if (!this.duration) return 0;
  1690. return (this.currentTime / this.duration) * 100;
  1691. },
  1692. volumeIcon() {
  1693. if (this.isMuted || this.volume === 0) return 'volume off';
  1694. if (this.volume < 40) return 'volume down';
  1695. return 'volume up';
  1696. },
  1697. repeatIcon() {
  1698. if (this.repeat === 'one') return 'repeat';
  1699. return 'redo alternate';
  1700. },
  1701. repeatTitle() {
  1702. if (this.repeat === 'none') return 'Repeat: off';
  1703. if (this.repeat === 'all') return 'Repeat: all';
  1704. return 'Repeat: one';
  1705. },
  1706. // ════════════════════════════════════════════════════════════════════
  1707. // TRACK INFO PANEL
  1708. // ════════════════════════════════════════════════════════════════════
  1709. // Toggle the song-info section docked at the bottom of the Now Playing
  1710. // overlay. Revealing it scrolls the overlay down to bring it into view.
  1711. toggleTrackInfo() {
  1712. if (!this.currentTrack) return;
  1713. this.trackInfoSong = this.currentTrack;
  1714. this.showTrackInfo = !this.showTrackInfo;
  1715. if (this.showTrackInfo) {
  1716. if (!ao_module_virtualDesktop){
  1717. // Not in webdesktop mode, so "Open in Player View" doesn't make sense – hide it
  1718. $("#open-in-embedded").hide();
  1719. }else{
  1720. $("#open-in-embedded").show();
  1721. }
  1722. this.$nextTick(() => {
  1723. var c = document.querySelector('.now-playing-overlay .np-content');
  1724. if (c) c.scrollTo({ top: c.scrollHeight, behavior: 'smooth' });
  1725. });
  1726. }
  1727. },
  1728. // ════════════════════════════════════════════════════════════════════
  1729. // NOW PLAYING FULL-SCREEN OVERLAY
  1730. // ════════════════════════════════════════════════════════════════════
  1731. openNowPlaying() {
  1732. if (!this.currentTrack) return;
  1733. var mc = document.getElementById('mainContent');
  1734. // Pin the overlay to the current scroll position before Alpine shows it,
  1735. // then freeze scrolling underneath.
  1736. var overlay = mc ? mc.querySelector('.now-playing-overlay') : null;
  1737. if (overlay) overlay.style.top = (mc.scrollTop) + 'px';
  1738. if (mc) mc.style.overflow = 'hidden';
  1739. this.showTrackInfo = false; // start collapsed
  1740. this.trackInfoSong = this.currentTrack;
  1741. this.showNowPlaying = true;
  1742. this.$nextTick(() => {
  1743. var c = overlay ? overlay.querySelector('.np-content') : null;
  1744. if (c) c.scrollTop = 0;
  1745. if (overlay) overlay.style.top = (mc.scrollTop) + 'px'; // readjust after content is revealed
  1746. this.updateQueuePanelPosition();
  1747. });
  1748. },
  1749. toggleNowPlaying() {
  1750. if (this.showNowPlaying) {
  1751. this.closeNowPlaying();
  1752. } else {
  1753. this.openNowPlaying();
  1754. }
  1755. },
  1756. closeNowPlaying() {
  1757. this.showNowPlaying = false;
  1758. this.showTrackInfo = false;
  1759. this._npSwipeDir = '';
  1760. var mc = document.getElementById('mainContent');
  1761. if (mc) mc.style.overflow = '';
  1762. this.$nextTick(() => {
  1763. this.updateQueuePanelPosition();
  1764. });
  1765. },
  1766. npTouchStart(e) {
  1767. var t = e.changedTouches ? e.changedTouches[0] : e;
  1768. this._npTouchStartX = t.clientX;
  1769. this._npTouchStartY = t.clientY;
  1770. this._npSwiping = true;
  1771. },
  1772. npTouchEnd(e) {
  1773. if (!this._npSwiping) return;
  1774. this._npSwiping = false;
  1775. var t = e.changedTouches ? e.changedTouches[0] : e;
  1776. var dx = t.clientX - this._npTouchStartX;
  1777. var dy = t.clientY - this._npTouchStartY;
  1778. // Horizontal swipe → change track; vertical swipe down → close
  1779. if (Math.abs(dx) > 60 && Math.abs(dx) > Math.abs(dy)) {
  1780. if (dx < 0) {
  1781. this._npSwipeDir = 'left';
  1782. this.nextTrack();
  1783. } else {
  1784. this._npSwipeDir = 'right';
  1785. this.prevTrack();
  1786. }
  1787. var self = this;
  1788. setTimeout(function() { self._npSwipeDir = ''; }, 280);
  1789. } else if (dy > 90 && Math.abs(dy) > Math.abs(dx)) {
  1790. //this.closeNowPlaying();
  1791. }
  1792. },
  1793. copyTrackTitle(song) {
  1794. if (!song) return;
  1795. var text = song.name;
  1796. if (navigator.clipboard) {
  1797. navigator.clipboard.writeText(text)
  1798. .then(() => { this._showToast('Title copied!'); })
  1799. .catch(() => { this._showToast('Failed to copy', 'error'); });
  1800. } else {
  1801. var el = document.createElement('textarea');
  1802. el.value = text;
  1803. document.body.appendChild(el);
  1804. el.select();
  1805. document.execCommand('copy');
  1806. document.body.removeChild(el);
  1807. this._showToast('Title copied!');
  1808. }
  1809. },
  1810. openInFileManager(song) {
  1811. if (!song) return;
  1812. var parts = song.filepath.split('/');
  1813. var filename = parts.pop();
  1814. var folder = parts.join('/');
  1815. ao_module_openPath(folder, filename);
  1816. },
  1817. openInEmbedded(song) {
  1818. if (!song) return;
  1819. var fileList = [{
  1820. filename: song.name + (song.ext ? '.' + song.ext : ''),
  1821. filepath: song.filepath
  1822. }];
  1823. ao_module_newfw({
  1824. url: 'Musicify/embedded.html#' + encodeURIComponent(JSON.stringify(fileList)),
  1825. title: song.name,
  1826. appicon: 'Musicify/img/module_icon.png',
  1827. width: 360,
  1828. height: 254
  1829. });
  1830. },
  1831. searchOnYoutube(song) {
  1832. if (!song) return;
  1833. var q = encodeURIComponent(song.name + ' ' + this.getArtistLabel(song));
  1834. window.open('https://www.youtube.com/results?search_query=' + q, '_blank');
  1835. },
  1836. downloadSong(song) {
  1837. if (!song) return;
  1838. var a = document.createElement('a');
  1839. a.href = ao_root + 'media?file=' + encodeURIComponent(song.filepath);
  1840. a.download = song.name + (song.ext ? '.' + song.ext : '');
  1841. document.body.appendChild(a);
  1842. a.click();
  1843. document.body.removeChild(a);
  1844. },
  1845. getTrackFolder(song) {
  1846. if (!song) return '';
  1847. var parts = song.filepath.split('/');
  1848. parts.pop();
  1849. return parts.join('/');
  1850. },
  1851. // ════════════════════════════════════════════════════════════════════
  1852. // AROZCAST
  1853. // ════════════════════════════════════════════════════════════════════
  1854. connectToCast() {
  1855. var code = this.castCodeInput.trim();
  1856. if (!/^\d{4}$/.test(code)) {
  1857. this.castError = 'Enter a valid 4-digit code.';
  1858. return;
  1859. }
  1860. this.castError = '';
  1861. this.castConnecting = true;
  1862. var self = this;
  1863. fetch(ao_root + 'api/arozcast/ping?code=' + code)
  1864. .then(function(r) { return r.json(); })
  1865. .then(function(data) {
  1866. if (!data.exists) {
  1867. self.castConnecting = false;
  1868. self.castError = 'Room not found. Check the code and try again.';
  1869. return;
  1870. }
  1871. self._castOpen(code);
  1872. })
  1873. .catch(function() {
  1874. self.castConnecting = false;
  1875. self.castError = 'Connection failed. Is Arozcast running?';
  1876. });
  1877. },
  1878. _castOpen(code) {
  1879. var self = this;
  1880. // Cancel any pending auto-reconnect to the old room — user is opening a new session
  1881. clearTimeout(this._castReconnectTimer); this._castReconnectTimer = null;
  1882. this._castReconnectCount = 0; this._castPendingCode = null;
  1883. var wsUrl = new URL(ao_root + 'api/arozcast/ws?code=' + code, window.location.href);
  1884. wsUrl.protocol = (location.protocol === 'https:') ? 'wss:' : 'ws:';
  1885. var ws = new WebSocket(wsUrl.toString());
  1886. ws.onopen = function() {
  1887. self.castConnecting = false;
  1888. self.castConnected = true;
  1889. self.castMode = true;
  1890. self.castCode = code;
  1891. self._castWs = ws;
  1892. self.showCastModal = false;
  1893. self.castCodeInput = '';
  1894. self._castLastSeen = Date.now();
  1895. // Pause local audio; remote screen takes over
  1896. self._audio.pause();
  1897. // Announce presence; sync volume first so _loadMedia reads the right level
  1898. ws.send(JSON.stringify({ topic: 'peer.hello', payload: {} }));
  1899. self._castSend('media.volume', { volume: self.volume, muted: self.isMuted });
  1900. if (self.currentTrack) {
  1901. self._castSend('media.load', {
  1902. filepath: self.currentTrack.filepath,
  1903. name: self.currentTrack.name,
  1904. artist: self.getArtistLabel(self.currentTrack),
  1905. cover: self.currentTrack.cover || '',
  1906. type: 'audio',
  1907. startTime: self.currentTime // sync mid-playback position
  1908. });
  1909. // Explicitly mirror play/pause state rather than relying on autoplay
  1910. if (self.isPlaying) {
  1911. self._castSend('media.play', {});
  1912. } else {
  1913. self._castSend('media.pause', {});
  1914. }
  1915. self._castSend('media.repeat', { mode: self.repeat });
  1916. }
  1917. // Heartbeat: tell Arozcast we are still here every 5 s
  1918. self._castPingTimer = setInterval(function() {
  1919. self._castSend('peer.heartbeat', {});
  1920. }, 5000);
  1921. // Watchdog: if Arozcast stops sending for 12 s, force-close the WS
  1922. self._castWatchTimer = setInterval(function() {
  1923. if (Date.now() - self._castLastSeen > 12000) {
  1924. if (self._castWs) self._castWs.close();
  1925. }
  1926. }, 4000);
  1927. self._showToast('Connected to Arozcast');
  1928. };
  1929. ws.onclose = function() {
  1930. clearInterval(self._castPingTimer);
  1931. clearInterval(self._castWatchTimer);
  1932. self._castPingTimer = null;
  1933. self._castWatchTimer = null;
  1934. var wasActive = self.castMode;
  1935. var savedCode = self.castCode;
  1936. self.castConnected = false;
  1937. self.castMode = false;
  1938. self._castWs = null;
  1939. if (wasActive) { self._startCastReconnect(savedCode); }
  1940. };
  1941. ws.onerror = function() {
  1942. self.castConnecting = false;
  1943. self.castError = 'WebSocket error. Check your connection.';
  1944. };
  1945. ws.onmessage = function(evt) {
  1946. self._castLastSeen = Date.now();
  1947. try {
  1948. var msg = JSON.parse(evt.data);
  1949. if (msg.topic === 'status.update') {
  1950. if (!self.isSeeking) self.currentTime = msg.payload.currentTime || 0;
  1951. self.duration = msg.payload.duration || 0;
  1952. self.isPlaying = msg.payload.isPlaying || false;
  1953. }
  1954. } catch(e) {}
  1955. };
  1956. },
  1957. // ── Auto-reconnect helpers ────────────────────────────────────────────
  1958. _startCastReconnect(code) {
  1959. var self = this;
  1960. var DELAYS = [2000, 5000, 12000];
  1961. if (!code || this._castReconnectCount >= DELAYS.length) {
  1962. if (this._castReconnectCount > 0) {
  1963. // All retries exhausted — fall back to local playback
  1964. if (this.currentTrack) {
  1965. var resumeAt = this.currentTime;
  1966. var self = this;
  1967. this._currentTrackTranscoded = (this.transcodeMode !== 'disabled' && this._needsTranscode(this.currentTrack));
  1968. this._transcodeSeekOffset = 0;
  1969. if (this._currentTrackTranscoded && resumeAt > 0.001) {
  1970. this._transcodeSeekOffset = resumeAt;
  1971. this._audio.src = this._getAudioSrc(this.currentTrack, resumeAt);
  1972. } else {
  1973. this._audio.src = this._getAudioSrc(this.currentTrack);
  1974. }
  1975. this._audio.volume = this.volume / 100;
  1976. this._audio.muted = this.isMuted;
  1977. this._audio.load();
  1978. if (!this._currentTrackTranscoded && resumeAt > 0) {
  1979. this._audio.addEventListener('loadedmetadata', function() {
  1980. self._audio.currentTime = resumeAt;
  1981. }, { once: true });
  1982. }
  1983. this.isPlaying = false;
  1984. this._showToast('Arozcast: reconnect failed — resuming locally', 'error');
  1985. }
  1986. }
  1987. this._castReconnectCount = 0; this._castPendingCode = null;
  1988. return;
  1989. }
  1990. this._castPendingCode = code;
  1991. var delay = DELAYS[this._castReconnectCount++];
  1992. clearTimeout(this._castReconnectTimer);
  1993. this._castReconnectTimer = setTimeout(function() {
  1994. self._castReconnectTimer = null;
  1995. self._attemptCastReconnect();
  1996. }, delay);
  1997. this._showToast('Arozcast disconnected — reconnecting…');
  1998. },
  1999. _attemptCastReconnect() {
  2000. var self = this;
  2001. if (!this._castPendingCode) return;
  2002. var code = this._castPendingCode;
  2003. var wsUrl = new URL(ao_root + 'api/arozcast/ws?code=' + code, window.location.href);
  2004. wsUrl.protocol = (location.protocol === 'https:') ? 'wss:' : 'ws:';
  2005. var ws = new WebSocket(wsUrl.toString());
  2006. var openTimer = setTimeout(function() {
  2007. ws.onopen = ws.onclose = ws.onerror = null; ws.close();
  2008. self._startCastReconnect(code);
  2009. }, 8000);
  2010. ws.onopen = function() { clearTimeout(openTimer); self._castPendingCode = null; self._castDidReconnect(ws, code); };
  2011. ws.onerror = function() {};
  2012. ws.onclose = function() { clearTimeout(openTimer); self._startCastReconnect(code); };
  2013. },
  2014. _castDidReconnect(ws, code) {
  2015. var self = this;
  2016. this._castWs = ws;
  2017. this.castCode = code;
  2018. this.castMode = true;
  2019. this.castConnected = true;
  2020. this._castLastSeen = Date.now();
  2021. ws.onmessage = function(evt) {
  2022. self._castLastSeen = Date.now();
  2023. try {
  2024. var msg = JSON.parse(evt.data);
  2025. if (msg.topic === 'status.update') {
  2026. self._castReconnectCount = 0; // receiver confirmed alive — reset retry counter
  2027. if (!self.isSeeking) self.currentTime = msg.payload.currentTime || 0;
  2028. self.duration = msg.payload.duration || 0;
  2029. self.isPlaying = msg.payload.isPlaying || false;
  2030. } else if (msg.topic === 'media.ended') {
  2031. self._onEnded();
  2032. }
  2033. } catch(e) {}
  2034. };
  2035. ws.onclose = function() {
  2036. clearInterval(self._castPingTimer); clearInterval(self._castWatchTimer);
  2037. self._castPingTimer = null; self._castWatchTimer = null;
  2038. var wasActive = self.castMode;
  2039. var savedCode = self.castCode;
  2040. self.castConnected = false; self.castMode = false; self._castWs = null;
  2041. if (wasActive) { self._startCastReconnect(savedCode); }
  2042. };
  2043. // Re-announce presence and sync volume only — do NOT resend media.load.
  2044. // Arozcast kept playing while the phone was asleep; its next status.update
  2045. // will immediately sync currentTime to the live remote position.
  2046. ws.send(JSON.stringify({ topic: 'peer.hello', payload: {} }));
  2047. this._castSend('media.volume', { volume: this.volume, muted: this.isMuted });
  2048. this._castSend('media.repeat', { mode: this.repeat });
  2049. clearInterval(this._castPingTimer); clearInterval(this._castWatchTimer);
  2050. this._castPingTimer = setInterval(function() { self._castSend('peer.heartbeat', {}); }, 5000);
  2051. this._castWatchTimer = setInterval(function() {
  2052. if (Date.now() - self._castLastSeen > 12000 && self._castWs) self._castWs.close();
  2053. }, 4000);
  2054. this._showToast('Arozcast reconnected');
  2055. },
  2056. disconnectCast() {
  2057. // Capture play state before we tear anything down
  2058. var wasPlaying = this.isPlaying;
  2059. // Cancel any pending auto-reconnect before tearing down
  2060. clearTimeout(this._castReconnectTimer); this._castReconnectTimer = null;
  2061. this._castReconnectCount = 0; this._castPendingCode = null;
  2062. clearInterval(this._castPingTimer);
  2063. clearInterval(this._castWatchTimer);
  2064. this._castPingTimer = null;
  2065. this._castWatchTimer = null;
  2066. this.castMode = false;
  2067. this.castConnected = false;
  2068. this.showCastModal = false;
  2069. if (this._castWs) {
  2070. // Send stop so Arozcast halts — this is an explicit user disconnect,
  2071. // not a sleep/drop (those suppress onclose and never reach here).
  2072. this._castSend('media.stop', {});
  2073. this._castWs.onclose = null; // suppress reconnect trigger
  2074. this._castWs.close();
  2075. this._castWs = null;
  2076. }
  2077. this.castCode = '';
  2078. this.castCodeInput = '';
  2079. this.castError = '';
  2080. this.isPlaying = false;
  2081. // Resume local playback at the last known remote position,
  2082. // but only auto-start if the remote was actually playing.
  2083. if (this.currentTrack) {
  2084. var resumeAt = this.currentTime;
  2085. var self = this;
  2086. this._currentTrackTranscoded = (this.transcodeMode !== 'disabled' && this._needsTranscode(this.currentTrack));
  2087. this._transcodeSeekOffset = 0;
  2088. if (this._currentTrackTranscoded && resumeAt > 0.001) {
  2089. this._transcodeSeekOffset = resumeAt;
  2090. this._audio.src = this._getAudioSrc(this.currentTrack, resumeAt);
  2091. } else {
  2092. this._audio.src = this._getAudioSrc(this.currentTrack);
  2093. }
  2094. this._audio.volume = this.volume / 100;
  2095. this._audio.muted = this.isMuted;
  2096. this._audio.load();
  2097. if (!this._currentTrackTranscoded && resumeAt > 0) {
  2098. this._audio.addEventListener('loadedmetadata', function() {
  2099. self._audio.currentTime = resumeAt;
  2100. }, { once: true });
  2101. }
  2102. if (wasPlaying) {
  2103. this._audio.play().catch(function() {});
  2104. }
  2105. }
  2106. this._showToast('Disconnected from Arozcast');
  2107. },
  2108. _castSend(topic, payload) {
  2109. if (!this._castWs || this._castWs.readyState !== WebSocket.OPEN) return;
  2110. this._castWs.send(JSON.stringify({ topic: topic, payload: payload }));
  2111. },
  2112. // Toast notification (simple, injected into DOM)
  2113. _toastTimer: null,
  2114. toastMsg: '',
  2115. toastType: 'info',
  2116. showToast: false,
  2117. _showToast(msg, type) {
  2118. this.toastMsg = msg;
  2119. this.toastType = type || 'info';
  2120. this.showToast = true;
  2121. if (this._toastTimer) clearTimeout(this._toastTimer);
  2122. const self = this;
  2123. this._toastTimer = setTimeout(() => { self.showToast = false; }, 2500);
  2124. }
  2125. };
  2126. }