musicify.js 89 KB

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