musicify.js 102 KB

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