musicify.js 126 KB

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