musicify.js 101 KB

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