| 1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957195819591960196119621963196419651966196719681969197019711972197319741975197619771978197919801981198219831984198519861987198819891990199119921993199419951996199719981999200020012002200320042005200620072008200920102011201220132014201520162017201820192020202120222023202420252026202720282029203020312032203320342035203620372038203920402041204220432044204520462047204820492050205120522053205420552056205720582059206020612062206320642065206620672068206920702071207220732074207520762077207820792080208120822083208420852086208720882089209020912092209320942095209620972098209921002101210221032104210521062107210821092110211121122113211421152116211721182119212021212122212321242125212621272128212921302131213221332134213521362137213821392140214121422143214421452146214721482149215021512152215321542155215621572158215921602161216221632164216521662167216821692170217121722173217421752176217721782179218021812182218321842185218621872188218921902191219221932194219521962197219821992200220122022203220422052206220722082209221022112212221322142215221622172218221922202221222222232224 |
- /*
- Musicify - Alpine.js Application Component
- Modern music player for ArozOS
- */
- // Set to true to enable verbose playback debug logging in the browser console.
- const MUSICIFY_DEBUG = false;
- // ─── Default cover art SVG (music note) ──────────────────────────────────────
- 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";
- function musicifyApp() {
- return {
- // ── Navigation ──────────────────────────────────────────────────────
- view: 'home', // 'home' | 'folders' | 'artists' | 'recent' | 'playlist'
- sidebarOpen: false,
- isMobileLandscape: false, // true when a mobile browser is rotated into landscape
- loading: false,
- loadingMsg: '',
- // ── Folder Browser ──────────────────────────────────────────────────
- folderRoot: 'user:/Music',
- folderPath: 'user:/Music',
- folderStack: [], // stack of previous paths for back navigation
- folderContents: { folders: [], songs: [] },
- musicLibraries: [], // [ { label, root } ] from listRoots.js
- // ── Artists ─────────────────────────────────────────────────────────
- artists: [],
- selectedArtist: null, // full artist object for dedicated artist songs view
- artistDetailOpen: false,
- artistsFromCache: false,
- artistsRefreshing: false,
- artistsCacheUpdatedAt: 0,
- _artistsFetchInFlight: false,
- _artistsUpdateFlash: false,
- _artistsUpdateFlashTimer: null,
- _artistsWorker: null,
- _artistsWorkerReqId: 0,
- _artistsActiveReqId: 0,
- _artistsWatchdogTimer: null,
- // Artist virtual scrolling
- artistRowHeight: 65, // must match CSS .artist-row height
- artistOverscan: 120, //artistRowHeight * artistOverscan = overscan px, Should be large enough for playlist expansion
- artistScrollTop: 0,
- artistListScrollTop: 0,
- selectedArtistListScrollTop: 0,
- // ── Recent ──────────────────────────────────────────────────────────
- recentSongs: [],
- // ── Playlists ────────────────────────────────────────────────────────
- playlists: [],
- currentPlaylistName: null,
- currentPlaylistSongs: [],
- showNewPlaylistModal: false,
- newPlaylistName: '',
- showAddToPlaylistModal: false,
- addToPlaylistSong: null,
- // ── Search ───────────────────────────────────────────────────────────
- searchQuery: '',
- searchResults: [],
- // ── Player ───────────────────────────────────────────────────────────
- queue: [], // current ordered play queue
- shuffledQueue: [], // shuffled copy used when shuffle is on
- queueIndex: -1,
- currentTrack: null,
- isPlaying: false,
- currentTime: 0,
- duration: 0,
- isSeeking: false,
- volume: 80,
- isMuted: false,
- forceShowVolumeControl: false, // user override: force-show the in-app volume slider on mobile devices
- shuffle: false,
- repeat: 'none', // 'none' | 'all' | 'one'
- showQueue: false,
- coverError: false,
- // ── Sleep Timer ──────────────────────────────────────────────────────
- showSleepModal: false,
- sleepActive: false,
- sleepMinutes: 30,
- sleepCountdown: '',
- _sleepTimer: null,
- _sleepEnd: 0,
- // ── Recently Played (localStorage) ───────────────────────────────────
- recentlyPlayed: [], // last 12 tracks
- // ── Track Info Panel ─────────────────────────────────────────────────
- showTrackInfo: false,
- trackInfoSong: null,
- // ── Now Playing full-screen overlay ──────────────────────────────────
- showNowPlaying: false,
- _npTouchStartX: 0,
- _npTouchStartY: 0,
- _npSwiping: false,
- _npSwipeDir: '', // 'left' | 'right' | '' — drives the slide animation
- // ── Internal playback guard ──────────────────────────────────────────
- _suppressEnded: false, // true while a new track is loading (prevents double-skip)
- // ── Helpers (accessible from Alpine template expressions) ─────────────
- // A "desktop" sidebar is a static, always-visible column. Mobile browsers
- // rotated to landscape are wide enough to pass the 768px check but should
- // still get the collapsible/overlay sidebar, so they're excluded here.
- isSidebarDesktop() { return window.innerWidth > 768 && !this._isMobile(); },
- // ── Transcode ────────────────────────────────────────────────────────
- transcodeMode: '48', // 'disabled' | '16' | '24' | '48' (kHz)
- _transcodeSeekOffset: 0, // seconds already seeked past in current transcode stream
- _currentTrackTranscoded: false,// true when current track is served via transcode endpoint
- _transcodeEndFallbackTimer: null, // guards against 'ended' not firing on Safari
- // ── Full Buffer Mode ─────────────────────────────────────────────────
- fullBufferMode: false, // true = buffer entire track before playback (iOS default)
- _fullBufferLoading: false, // true while waiting for server-side buffer to finish
- // ── Privacy / Audio Visualizer Mode ──────────────────────────────────
- privacyVisualizerMode: false, // true = replace album art with an audio-reactive visualizer
- _vizColor: '168,85,247', // 'r,g,b' infill color sampled from the current cover art
- _audioCtx: null,
- _analyser: null,
- _vizData: null,
- _vizAnimId: null,
- _syntheticViz: false, // iOS: animate the bars without Web Audio so screen-lock doesn't stop playback
- // ── Arozcast ─────────────────────────────────────────────────────────
- castMode: false,
- castConnected: false,
- castConnecting: false,
- showCastModal: false,
- castCode: '',
- castCodeInput: '',
- castError: '',
- _castWs: null,
- _castPingTimer: null,
- _castWatchTimer: null,
- _castLastSeen: 0,
- _castReconnectTimer: null,
- _castReconnectCount: 0,
- _castPendingCode: null,
- // ── Internal refs ────────────────────────────────────────────────────
- _audio: null,
- // ════════════════════════════════════════════════════════════════════
- // INIT
- // ════════════════════════════════════════════════════════════════════
- init() {
- this._audio = document.getElementById('musicPlayer');
- const self = this;
- this._audio.addEventListener('timeupdate', () => {
- if (!self.isSeeking) {
- self.currentTime = self._audio.currentTime + self._transcodeSeekOffset;
- }
- // Near-end handling for transcoded streams:
- // - Log when within 3 s of the pre-fetched duration for debugging.
- // - Arm a fallback timer so _onEnded fires even if the browser never
- // emits 'ended' (common on iOS Safari with chunked MP3 streams).
- if (self._currentTrackTranscoded && self.duration > 0 && !self._suppressEnded) {
- var displayTime = self._audio.currentTime + self._transcodeSeekOffset;
- var remaining = self.duration - displayTime;
- if (MUSICIFY_DEBUG && remaining >= 0 && remaining <= 3) {
- console.log('[Musicify transcode] near-end timeupdate – pos:', displayTime.toFixed(2), '/ dur:', self.duration.toFixed(2), '| remaining:', remaining.toFixed(2));
- }
- if (remaining >= 0 && remaining <= 2) {
- if (self._transcodeEndFallbackTimer) clearTimeout(self._transcodeEndFallbackTimer);
- self._transcodeEndFallbackTimer = setTimeout(function() {
- self._transcodeEndFallbackTimer = null;
- if (!self._currentTrackTranscoded || self._suppressEnded) return;
- if (MUSICIFY_DEBUG) console.log('[Musicify transcode] end fallback fired – ended event did not arrive in time');
- self._onEnded();
- }, (remaining + 1.5) * 1000);
- }
- }
- });
- this._audio.addEventListener('loadedmetadata', () => {
- var d = self._audio.duration;
- if (self._currentTrackTranscoded) {
- // Don't override the pre-fetched duration for transcoded streams
- if (!self.duration && d && isFinite(d) && d > 0) self.duration = d;
- } else {
- self.duration = (d && isFinite(d)) ? d : 0;
- }
- });
- this._audio.addEventListener('ended', () => {
- if (MUSICIFY_DEBUG) console.log('[Musicify] audio ended – pos:', (self._audio.currentTime + self._transcodeSeekOffset).toFixed(2), '/ dur:', self.duration.toFixed(2), '| transcoded:', self._currentTrackTranscoded);
- self._onEnded();
- });
- this._audio.addEventListener('error', () => {
- if (MUSICIFY_DEBUG) console.warn('[Musicify] audio error – code:', self._audio.error && self._audio.error.code, self._audio.error && self._audio.error.message);
- self._onError();
- });
- this._audio.addEventListener('stalled', () => {
- if (MUSICIFY_DEBUG) console.log('[Musicify] audio stalled – pos:', (self._audio.currentTime + self._transcodeSeekOffset).toFixed(2), '/ dur:', self.duration.toFixed(2), '| transcoded:', self._currentTrackTranscoded);
- });
- this._audio.addEventListener('waiting', () => {
- if (MUSICIFY_DEBUG) console.log('[Musicify] audio waiting – pos:', (self._audio.currentTime + self._transcodeSeekOffset).toFixed(2), '/ dur:', self.duration.toFixed(2), '| transcoded:', self._currentTrackTranscoded);
- });
- this._audio.addEventListener('play', () => {
- self.isPlaying = true; self._suppressEnded = false; self._fullBufferLoading = false; self._updateMediaSession();
- if (self._audioCtx && self._audioCtx.state === 'suspended') self._audioCtx.resume().catch(() => {});
- });
- this._audio.addEventListener('pause', () => { self.isPlaying = false; self._updateMediaSession(); });
- // Restore volume
- var savedVol = localStorage.getItem('musicify_volume');
- if (savedVol !== null) {
- this.volume = parseInt(savedVol);
- this._audio.volume = this.volume / 100;
- } else {
- this._audio.volume = this.volume / 100;
- }
- // Mobile Volume Control: Safari/iOS and most Android browsers expect the
- // device's hardware buttons to control volume, so default to full volume
- // and hide the in-app slider on mobile unless the user overrides it.
- var _savedForceVol = localStorage.getItem('musicify_forceShowVolumeControl');
- if (_savedForceVol !== null) {
- this.forceShowVolumeControl = (_savedForceVol === 'true');
- }
- if (this._isMobile() && !this.forceShowVolumeControl) {
- this.volume = 100;
- this.isMuted = false;
- this._audio.volume = 1;
- }
- // Restore shuffle / repeat / recently-played from server-side prefs
- // (cross-device: stored per user, not per browser)
- ao_module_storage.loadStorage("Musicify", "shuffle", function(val) {
- if (val !== null && val !== undefined) self.shuffle = (val === 'true');
- });
- ao_module_storage.loadStorage("Musicify", "repeat", function(val) {
- if (val === 'all' || val === 'one' || val === 'none') self.repeat = val;
- });
- ao_module_storage.loadStorage("Musicify", "recent", function(val) {
- if (val) {
- try { self.recentlyPlayed = JSON.parse(val).slice(0, 12); } catch(e) {}
- }
- });
- var _savedTranscode = localStorage.getItem('musicify_transcodeMode');
- if (_savedTranscode === 'disabled' || _savedTranscode === '16' || _savedTranscode === '24' || _savedTranscode === '48') {
- this.transcodeMode = _savedTranscode;
- }
- // Full Buffer Mode: auto-enable on iOS, otherwise restore from localStorage
- var _savedFBM = localStorage.getItem('musicify_fullBufferMode');
- if (_savedFBM !== null) {
- this.fullBufferMode = (_savedFBM === 'true');
- } else {
- // Auto-detect iOS and enable by default
- this.fullBufferMode = this._isIOS();
- }
- // Privacy / Audio Visualizer Mode: restore from localStorage
- var _savedPrivacyViz = localStorage.getItem('musicify_privacyVisualizer');
- if (_savedPrivacyViz !== null) {
- this.privacyVisualizerMode = (_savedPrivacyViz === 'true');
- }
- if (this.privacyVisualizerMode) {
- this._ensureAnalyser();
- this._updateVizColor(this.currentTrack);
- this._startVizLoop();
- }
- // MediaSession
- this._setupMediaSession();
- // Load playlists for sidebar
- this._loadPlaylists();
- // Pre-load available music library roots for the folder-view switcher
- this._loadMusicLibraries();
- window.addEventListener('beforeunload', () => {
- if (this._artistsWorker) {
- this._artistsWorker.terminate();
- this._artistsWorker = null;
- }
- });
- // Handle #folder=<path> hash from embedded player's "Open in Musicify" button
- var _hash = window.location.hash;
- if (_hash.startsWith('#folder=')) {
- var _folder = decodeURIComponent(_hash.substring(8));
- window.history.replaceState(null, '', window.location.pathname);
- this.view = 'folders';
- this.folderStack = [];
- this.loadFolder(_folder);
- }
- // Listen for other apps taking over the Arozcast session
- try {
- var _acCh = new BroadcastChannel('arozcast');
- _acCh.onmessage = (evt) => {
- if (evt.data && evt.data.type === 'arozcast.takeover' && self.castMode) {
- self.disconnectCast();
- }
- };
- } catch(e) {}
- // When the user returns to this tab after the phone was asleep, reconnect immediately
- document.addEventListener('visibilitychange', function() {
- if (document.visibilityState === 'visible' && self._castPendingCode) {
- clearTimeout(self._castReconnectTimer);
- self._castReconnectTimer = null;
- self._attemptCastReconnect();
- }
- });
- // Android player-bar class
- if (this._isAndroid()) {
- document.querySelector('.player-bar').classList.add('android');
- }
- // Responsive sidebar
- this._updateLandscapeState();
- this.sidebarOpen = window.innerWidth > 768 && !this.isMobileLandscape;
- var resizeT;
- window.addEventListener('resize', () => {
- clearTimeout(resizeT);
- resizeT = setTimeout(() => {
- this._updateLandscapeState();
- if (window.innerWidth <= 768 || this.isMobileLandscape) this.sidebarOpen = false;
- }, 150);
- });
- window.addEventListener('orientationchange', () => this._updateLandscapeState());
- },
- // ════════════════════════════════════════════════════════════════════
- // NAVIGATION
- // ════════════════════════════════════════════════════════════════════
- navigateTo(v) {
- this.view = v;
- this.searchQuery = '';
- if (window.innerWidth <= 768) this.sidebarOpen = false;
- if (v === 'folders') {
- if (this.musicLibraries.length === 0) this._loadMusicLibraries();
- if (this.folderContents.songs.length === 0 && this.folderContents.folders.length === 0) {
- this.loadFolder(this.folderRoot);
- }
- } else if (v === 'artists') {
- this._loadArtists();
- } else if (v === 'recent' && this.recentSongs.length === 0) {
- this._loadRecent();
- }
- //Close playing overlay if open
- if (this.showNowPlaying) this.closeNowPlaying();
- },
- openPlaylistView(name) {
- this.currentPlaylistName = name;
- this.view = 'playlist';
- if (window.innerWidth <= 768) this.sidebarOpen = false;
- this._loadPlaylistSongs(name);
- },
- // ════════════════════════════════════════════════════════════════════
- // LIBRARY ROOTS
- // ════════════════════════════════════════════════════════════════════
- _loadMusicLibraries() {
- const self = this;
- fetch(ao_root + 'system/ajgi/interface?script=Musicify/backend/listRoots.js', {
- method: 'POST', cache: 'no-cache',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({})
- }).then(r => r.json()).then(data => {
- // Remove tmp:/ and trash:/ from the array
- data = Array.isArray(data) ? data.map(d => {
- if (d.root.startsWith('tmp:/') || d.root.startsWith('trash:/')) {
- return null;
- }
- return d;
- }) : [];
- self.musicLibraries = Array.isArray(data) ? data : [];
- }).catch(() => {});
- },
- switchLibrary(root) {
- this.folderRoot = root;
- this.folderStack = [];
- this.folderContents = { folders: [], songs: [] };
- this.loadFolder(root, false);
- },
- // ════════════════════════════════════════════════════════════════════
- // FOLDER BROWSER
- // ════════════════════════════════════════════════════════════════════
- loadFolder(path, showLoading = true) {
- if (showLoading) {
- this.loadingMsg = 'Loading folder…';
- this.loading = true;
- }
- const self = this;
- fetch(ao_root + 'system/ajgi/interface?script=Musicify/backend/listFolder.js', {
- method: 'POST', cache: 'no-cache',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ folder: path })
- }).then(r => r.json()).then(data => {
- if (data.error) { self._showToast(data.error, 'error'); if (showLoading) self.loading = false; return; }
- self.folderContents = data;
- self.folderPath = path;
- if (showLoading) {
- setTimeout(() => { self.loading = false; }, 100); // slight delay for smoother UX
- };
- }).catch(() => { if (showLoading){
- setTimeout(() => { self.loading = false; }, 100); // slight delay for smoother UX
- } });
- },
- folderNavigate(path) {
- this.folderStack.push(this.folderPath);
- this.artistDetailOpen = false;
- this.selectedArtist = null;
- this.loadFolder(path);
- },
- folderBack() {
- if (this.folderStack.length === 0) return;
- var prev = this.folderStack.pop();
- this.loadFolder(prev);
- },
- getFolderBreadcrumbs() {
- var parts = this.folderPath.split('/');
- var crumbs = [];
- var acc = '';
- for (var i = 0; i < parts.length; i++) {
- acc = i === 0 ? parts[0] : acc + '/' + parts[i];
- crumbs.push({ name: parts[i], path: acc });
- }
- return crumbs;
- },
- // ════════════════════════════════════════════════════════════════════
- // ARTISTS
- // ════════════════════════════════════════════════════════════════════
- _loadArtists(opts) {
- opts = opts || {};
- var forceNetwork = !!opts.forceNetwork;
- var self = this;
- // Artists refresh should never block the entire content panel.
- this.loading = false;
- // ── Start the network scan immediately — never wait for cache ─────
- if (this._artistsFetchInFlight) return;
- this._artistsFetchInFlight = true;
- this.artistsRefreshing = true;
- var reqId = ++this._artistsWorkerReqId;
- this._artistsActiveReqId = reqId;
- this._startArtistsWatchdog(reqId);
- // Use worker first to keep fetch + JSON parsing off the UI thread.
- var startedInWorker = this._dispatchArtistsFetchToWorker(reqId);
- if (!startedInWorker) {
- this._dispatchArtistsFetchFallback(reqId);
- }
- // ── In parallel: read server-side cache to pre-populate the UI ────
- // Only applies the cache if the network scan has not yet returned.
- if (!forceNetwork) {
- this._readArtistsCache(function(cache) {
- if (self.artistsRefreshing && cache && Array.isArray(cache.items)) {
- self.artists = cache.items;
- self.artistsFromCache = true;
- self.artistsCacheUpdatedAt = cache.ts || 0;
- }
- });
- }
- },
- _dispatchArtistsFetchToWorker(reqId) {
- if (!('Worker' in window)) return false;
- const self = this;
- if (!this._artistsWorker) {
- try {
- this._artistsWorker = new Worker('artistsWorker.js');
- } catch (e) {
- this._artistsWorker = null;
- return false;
- }
- this._artistsWorker.onmessage = function(evt) {
- var msg = evt && evt.data ? evt.data : {};
- if (msg.type === 'artistsResult') {
- self._applyArtistsResult(msg.items, msg.reqId);
- } else if (msg.type === 'artistsError') {
- self._handleArtistsError(msg.reqId);
- }
- };
- this._artistsWorker.onerror = function() {
- self._handleArtistsError(self._artistsActiveReqId);
- if (self._artistsWorker) {
- self._artistsWorker.terminate();
- self._artistsWorker = null;
- }
- };
- }
- try {
- this._artistsWorker.postMessage({
- type: 'fetchArtists',
- reqId: reqId,
- endpoint: ao_root + 'system/ajgi/interface?script=Musicify/backend/listArtists.js'
- });
- return true;
- } catch (e) {
- return false;
- }
- },
- _dispatchArtistsFetchFallback(reqId) {
- fetch(ao_root + 'system/ajgi/interface?script=Musicify/backend/listArtists.js', {
- method: 'POST', cache: 'no-cache',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({})
- }).then(r => r.json()).then(data => {
- this._applyArtistsResult(data, reqId);
- }).catch(() => {
- this._handleArtistsError(reqId);
- });
- },
- _applyArtistsResult(data, reqId) {
- if (reqId !== this._artistsActiveReqId) return;
- data = Array.isArray(data) ? data : [];
- var selectedPath = this.selectedArtist ? this.selectedArtist.path : null;
- this.artists = data;
- this.artistsFromCache = false;
- this.artistsCacheUpdatedAt = Date.now();
- this._writeArtistsCache(data, this.artistsCacheUpdatedAt);
- this._flashArtistsUpdated();
- if (selectedPath) {
- var matched = null;
- for (var i = 0; i < data.length; i++) {
- if (data[i].path === selectedPath) {
- matched = data[i];
- break;
- }
- }
- this.selectedArtist = matched;
- }
- this._finalizeArtistsFetch(reqId);
- },
- _handleArtistsError(reqId) {
- if (reqId !== this._artistsActiveReqId) return;
- this._finalizeArtistsFetch(reqId);
- },
- _startArtistsWatchdog(reqId) {
- if (this._artistsWatchdogTimer) clearTimeout(this._artistsWatchdogTimer);
- const self = this;
- this._artistsWatchdogTimer = setTimeout(() => {
- if (reqId !== self._artistsActiveReqId) return;
- self._finalizeArtistsFetch(reqId);
- if (self._artistsWorker) {
- self._artistsWorker.terminate();
- self._artistsWorker = null;
- }
- }, 25000);
- },
- _finalizeArtistsFetch(reqId) {
- if (reqId !== this._artistsActiveReqId) return;
- if (this._artistsWatchdogTimer) {
- clearTimeout(this._artistsWatchdogTimer);
- this._artistsWatchdogTimer = null;
- }
- this.artistsRefreshing = false;
- this._artistsFetchInFlight = false;
- },
- // Reads the server-side artists cache (user:/.appdata/Musicify/).
- // Async — calls callback(cache) where cache is { ts, items } or null.
- _readArtistsCache(callback) {
- fetch(ao_root + 'system/ajgi/interface?script=Musicify/backend/getArtistsCache.js', {
- method: 'POST', cache: 'no-cache',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({})
- }).then(function(r) { return r.json(); })
- .then(function(data) {
- if (data && !data.error && Array.isArray(data.items)) {
- callback({ ts: data.ts || 0, items: data.items });
- } else {
- callback(null);
- }
- }).catch(function() { callback(null); });
- },
- // Cache is now written server-side by listArtists.js before it sends its
- // response — no client-side write needed.
- _writeArtistsCache(items, updatedAt) {},
- _flashArtistsUpdated() {
- this._artistsUpdateFlash = true;
- if (this._artistsUpdateFlashTimer) clearTimeout(this._artistsUpdateFlashTimer);
- const self = this;
- this._artistsUpdateFlashTimer = setTimeout(() => {
- self._artistsUpdateFlash = false;
- }, 3000);
- },
- artistsStatusText() {
- if (this.artistsRefreshing && this.artistsFromCache) {
- return 'Showing cached artists while refreshing in background';
- }
- if (this.artistsFromCache) {
- return 'Showing cached artists';
- }
- if (this.artistsRefreshing) {
- return 'Refreshing artist list';
- }
- if (this._artistsUpdateFlash) {
- return 'Artist list updated';
- }
- return 'Live artist list';
- },
- artistsUpdatedTimeText() {
- if (!this.artistsCacheUpdatedAt) return '';
- var d = new Date(this.artistsCacheUpdatedAt);
- return 'Updated at ' + d.toLocaleTimeString([], {
- hour: '2-digit',
- minute: '2-digit',
- timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
- timeZoneName: 'short'
- });
- },
- _getSelectedArtistListContainer() {
- return document.getElementById('artist-selected-content-body');
- },
- _getArtistListContainer() {
- return document.getElementById('artist-content-body');
- },
- _getMainContentContainer() {
- return document.getElementById('mainContent');
- },
- _getArtistViewportHeight() {
- var artistListContainer = this._getArtistListContainer();
- if (artistListContainer && artistListContainer.clientHeight) {
- return artistListContainer.clientHeight;
- }
- var mainContainer = this._getMainContentContainer();
- if (mainContainer && mainContainer.clientHeight) {
- return mainContainer.clientHeight;
- }
- return window.innerHeight;
- },
- selectArtist(artist) {
- var mainContainer = this._getMainContentContainer();
- if (mainContainer) {
- this.artistListScrollTop = mainContainer.scrollTop;
- this.artistScrollTop = mainContainer.scrollTop;
- }
- this.selectedArtist = artist;
- this.artistDetailOpen = true;
- this.$nextTick(() => {
- this.$nextTick(() => {
- var mainContainer = this._getMainContentContainer();
- if (mainContainer) {
- mainContainer.scrollTop = 0;
- };
- });
- });
- },
- backToArtistList() {
- this.artistDetailOpen = false;
- var targetScrollTop = this.artistListScrollTop || 0;
- this.artistScrollTop = targetScrollTop;
- this.$nextTick(() => {
- this.$nextTick(() => {
- var mainContainer = this._getMainContentContainer();
- if (mainContainer) {
- mainContainer.scrollTop = targetScrollTop;
- }
- });
- });
- },
- visibleArtists() {
- const viewportHeight = this._getArtistViewportHeight();
- const start =
- Math.max(
- 0,
- Math.floor(this.artistScrollTop / this.artistRowHeight)
- - this.artistOverscan
- );
- const count =
- Math.ceil(viewportHeight / this.artistRowHeight)
- + (this.artistOverscan * 2);
- return this.artists.slice(start, start + count);
- },
- artistStartIndex() {
- return Math.max(
- 0,
- Math.floor(this.artistScrollTop / this.artistRowHeight)
- - this.artistOverscan
- );
- },
- artistTopSpacerHeight() {
- return this.artistStartIndex() * this.artistRowHeight;
- },
- artistBottomSpacerHeight() {
- const rendered =
- this.visibleArtists().length;
- return Math.max(
- 0,
- (this.artists.length -
- this.artistStartIndex() -
- rendered) * this.artistRowHeight
- );
- },
- onArtistScroll(e) {
- var eventScrollTop = e && e.target ? e.target.scrollTop : 0;
- var artistListContainer = this._getArtistListContainer();
- var mainContainer = this._getMainContentContainer();
- var scrollTop = Math.max(
- eventScrollTop,
- artistListContainer ? artistListContainer.scrollTop : 0,
- mainContainer ? mainContainer.scrollTop : 0
- );
- this.artistScrollTop = scrollTop;
- this.artistListScrollTop = scrollTop;
- },
- onMainContentScroll(e) {
- if (this.view !== 'artists' || this.artistDetailOpen) return;
- this.onArtistScroll(e);
- },
- onSelectedArtistListScroll(e) {
- this.selectedArtistListScrollTop = e.target.scrollTop;
- },
- // ════════════════════════════════════════════════════════════════════
- // RECENT
- // ════════════════════════════════════════════════════════════════════
- _loadRecent() {
- this.loading = true;
- this.loadingMsg = 'Loading recent tracks…';
- const self = this;
- fetch(ao_root + 'system/ajgi/interface?script=Musicify/backend/listRecent.js', {
- method: 'POST', cache: 'no-cache',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({})
- }).then(r => r.json()).then(data => {
- self.recentSongs = data;
- self.loading = false;
- }).catch(() => { self.loading = false; });
- },
- // ════════════════════════════════════════════════════════════════════
- // PLAYLISTS
- // ════════════════════════════════════════════════════════════════════
- _loadPlaylists() {
- const self = this;
- fetch(ao_root + 'system/ajgi/interface?script=Musicify/backend/playlist.js', {
- method: 'POST', cache: 'no-cache',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ opr: 'list_all' })
- }).then(r => r.json()).then(data => {
- self.playlists = Array.isArray(data) ? data : [];
- }).catch(() => {});
- },
- _loadPlaylistSongs(name) {
- this.loading = true;
- this.loadingMsg = 'Loading playlist…';
- const self = this;
- fetch(ao_root + 'system/ajgi/interface?script=Musicify/backend/playlist.js', {
- method: 'POST', cache: 'no-cache',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ opr: 'get', name: name })
- }).then(r => r.json()).then(data => {
- self.currentPlaylistSongs = Array.isArray(data) ? data : [];
- self.loading = false;
- }).catch(() => { self.loading = false; });
- },
- createPlaylist() {
- var n = this.newPlaylistName.trim();
- if (!n) return;
- const self = this;
- fetch(ao_root + 'system/ajgi/interface?script=Musicify/backend/playlist.js', {
- method: 'POST', cache: 'no-cache',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ opr: 'create', name: n })
- }).then(r => r.json()).then(data => {
- if (data.error) { self._showToast(data.error, 'error'); return; }
- self.newPlaylistName = '';
- self.showNewPlaylistModal = false;
- self._loadPlaylists();
- self._showToast('Playlist "' + n + '" created');
- });
- },
- deletePlaylist(name) {
- if (!confirm('Delete playlist "' + name + '"?')) return;
- const self = this;
- fetch(ao_root + 'system/ajgi/interface?script=Musicify/backend/playlist.js', {
- method: 'POST', cache: 'no-cache',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ opr: 'delete', name: name })
- }).then(() => {
- if (self.currentPlaylistName === name) { self.currentPlaylistName = null; self.view = 'home'; }
- self._loadPlaylists();
- self._showToast('Playlist deleted');
- });
- },
- promptAddToPlaylist(song, event) {
- if (event) event.stopPropagation();
- this.addToPlaylistSong = song;
- this.showAddToPlaylistModal = true;
- },
- addSongToPlaylist(playlistName) {
- if (!this.addToPlaylistSong) return;
- const self = this;
- const song = this.addToPlaylistSong;
- fetch(ao_root + 'system/ajgi/interface?script=Musicify/backend/playlist.js', {
- method: 'POST', cache: 'no-cache',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ opr: 'add', name: playlistName, song: encodeURIComponent(song.filepath) })
- }).then(r => r.json()).then(data => {
- self.showAddToPlaylistModal = false;
- self.addToPlaylistSong = null;
- if (data.error) { self._showToast(data.error, 'error'); return; }
- if (data.duplicate) { self._showToast('Already in playlist'); return; }
- self._showToast('Added to "' + playlistName + '"');
- self._loadPlaylists();
- if (self.currentPlaylistName === playlistName) self._loadPlaylistSongs(playlistName);
- });
- },
- removeFromCurrentPlaylist(index, event) {
- if (event) event.stopPropagation();
- const self = this;
- fetch(ao_root + 'system/ajgi/interface?script=Musicify/backend/playlist.js', {
- method: 'POST', cache: 'no-cache',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ opr: 'remove', name: self.currentPlaylistName, index: index })
- }).then(() => {
- self._loadPlaylistSongs(self.currentPlaylistName);
- self._loadPlaylists();
- });
- },
- // ════════════════════════════════════════════════════════════════════
- // SEARCH
- // ════════════════════════════════════════════════════════════════════
- doSearch() {
- var q = this.searchQuery.toLowerCase().trim();
- if (!q) { this.searchResults = []; return; }
- // Search across already-loaded data pools
- var results = [];
- var seen = {};
- function addIfNew(song) {
- if (!seen[song.filepath]) {
- seen[song.filepath] = true;
- results.push(song);
- }
- }
- // Folder contents
- (this.folderContents.songs || []).forEach(s => { if (s.name.toLowerCase().includes(q)) addIfNew(s); });
- // Recent
- (this.recentSongs || []).forEach(s => { if (s.name.toLowerCase().includes(q)) addIfNew(s); });
- // Artists
- (this.artists || []).forEach(a => {
- (a.songs || []).forEach(s => { if (s.name.toLowerCase().includes(q) || a.name.toLowerCase().includes(q)) addIfNew(s); });
- });
- // Current playlist
- (this.currentPlaylistSongs || []).forEach(s => { if (s.name.toLowerCase().includes(q)) addIfNew(s); });
- // Recently played
- (this.recentlyPlayed || []).forEach(s => { if (s.name.toLowerCase().includes(q)) addIfNew(s); });
- this.searchResults = results.slice(0, 100);
- },
- // ════════════════════════════════════════════════════════════════════
- // PLAYER – Queue management
- // ════════════════════════════════════════════════════════════════════
- playList(songs, startIndex) {
- if (!songs || songs.length === 0) return;
- startIndex = startIndex || 0;
- this.queue = songs.slice();
- this.queueIndex = startIndex;
- if (this.shuffle) this._buildShuffledQueue(startIndex);
- this._loadTrack(this._effectiveQueue()[this._effectiveIndex(startIndex)]);
- // Starting playback brings up the full-screen Now Playing view
- // only in mobile / small-screen mode, not on desktop.
- if (window.innerWidth <= 768) this.openNowPlaying();
- },
- playSong(song, sourceList, event) {
- if (event) event.stopPropagation();
- // Re-selecting the track that is already loaded shouldn't restart it from
- // the beginning — surface the Now Playing panel (album art / visualizer)
- // instead. Keyed on isCurrentTrack so the row highlighted as .active is
- // exactly the row that opens the panel, paused or playing.
- if (this.isCurrentTrack(song)) {
- this.openNowPlaying();
- return;
- }
- if (!sourceList || sourceList.length === 0) sourceList = [song];
- var idx = 0;
- for (var i = 0; i < sourceList.length; i++) {
- if (sourceList[i].filepath === song.filepath) { idx = i; break; }
- }
- this.playList(sourceList, idx);
- },
- addToQueue(song, event) {
- if (event) event.stopPropagation();
- this.queue.push(song);
- if (this.shuffle) this.shuffledQueue.push(song);
- this._showToast('Added to queue');
- },
- playNext(song, event) {
- if (event) event.stopPropagation();
- var insertAt = this.queueIndex + 1;
- this.queue.splice(insertAt, 0, song);
- if (this.shuffle) this.shuffledQueue.splice(this._effectiveIndex(this.queueIndex) + 1, 0, song);
- this._showToast('Playing next');
- },
- removeFromQueue(index, event) {
- if (event) event.stopPropagation();
- if (index === this.queueIndex) return; // can't remove currently playing
- this.queue.splice(index, 1);
- if (index < this.queueIndex) this.queueIndex--;
- },
- _effectiveQueue() { return this.shuffle ? this.shuffledQueue : this.queue; },
- _effectiveIndex(rawIndex) {
- if (!this.shuffle) return rawIndex;
- var track = this.queue[rawIndex];
- if (!track) return 0;
- for (var i = 0; i < this.shuffledQueue.length; i++) {
- if (this.shuffledQueue[i].filepath === track.filepath) return i;
- }
- return 0;
- },
- _buildShuffledQueue(currentIndex) {
- var arr = this.queue.slice();
- var current = arr.splice(currentIndex, 1)[0];
- for (var i = arr.length - 1; i > 0; i--) {
- var j = Math.floor(Math.random() * (i + 1));
- var tmp = arr[i]; arr[i] = arr[j]; arr[j] = tmp;
- }
- this.shuffledQueue = current ? [current].concat(arr) : arr;
- },
- // ════════════════════════════════════════════════════════════════════
- // PLAYER – Playback control
- // ════════════════════════════════════════════════════════════════════
- _loadTrack(song) {
- if (!song) return;
- this._suppressEnded = true;
- if (this._transcodeEndFallbackTimer) {
- clearTimeout(this._transcodeEndFallbackTimer);
- this._transcodeEndFallbackTimer = null;
- }
- this.currentTrack = song;
- this._updateVizColor(song);
- this.coverError = false;
- this.currentTime = 0;
- this.duration = 0;
- this._transcodeSeekOffset = 0;
- this._currentTrackTranscoded = false;
- if (this.castMode) {
- this._castSend('media.load', {
- filepath: song.filepath,
- name: song.name,
- artist: this.getArtistLabel(song),
- cover: song.cover || '',
- type: 'audio'
- });
- this._audio.pause();
- this.isPlaying = true;
- } else {
- var willTranscode = (this.transcodeMode !== 'disabled' && this._needsTranscode(song));
- this._currentTrackTranscoded = willTranscode;
- if (willTranscode && this.fullBufferMode) {
- this._playViaFullBuffer(song, 0, true);
- } else {
- this._playViaStream(song, 0, true);
- }
- }
- this._saveRecentlyPlayed(song);
- this._setupMediaSession();
- document.title = song.name + ' – Musicify';
- if (ao_module_virtualDesktop){
- ao_module_setWindowTitle('Musicify - ' + song.name);
- }
- this.trackInfoSong = song;
- },
- togglePlay() {
- if (!this.currentTrack) return;
- if (this.castMode) {
- if (this.isPlaying) {
- this._castSend('media.pause', {});
- this.isPlaying = false;
- } else {
- this._castSend('media.play', {});
- this.isPlaying = true;
- }
- return;
- }
- if (this._audio.paused) {
- if (this._audioCtx && this._audioCtx.state === 'suspended') this._audioCtx.resume().catch(() => {});
- this._audio.play().catch(() => {});
- }
- else { this._audio.pause(); }
- },
- nextTrack() {
- var eq = this._effectiveQueue();
- var ei = this._effectiveIndex(this.queueIndex);
- if (eq.length === 0) return;
- var next = ei + 1;
- if (next >= eq.length) {
- if (this.repeat === 'all' || this.repeat === 'one') next = 0;
- else { this._audio.pause(); this.isPlaying = false; return; }
- }
- // Map back to queue index for shuffle mode
- if (this.shuffle) {
- var nextSong = this.shuffledQueue[next];
- for (var i = 0; i < this.queue.length; i++) {
- if (this.queue[i].filepath === nextSong.filepath) { this.queueIndex = i; break; }
- }
- } else {
- this.queueIndex = next;
- }
- this._loadTrack(eq[next]);
- },
- prevTrack() {
- var eq = this._effectiveQueue();
- var ei = this._effectiveIndex(this.queueIndex);
- var prev = ei - 1;
- if (prev < 0) {
- if (this.repeat === 'all' || this.repeat === 'one'){
- prev = eq.length - 1;
- }else{
- this._audio.pause(); this.isPlaying = false; return;
- }
- }
- if (this.shuffle) {
- var prevSong = this.shuffledQueue[prev];
- for (var i = 0; i < this.queue.length; i++) {
- if (this.queue[i].filepath === prevSong.filepath) { this.queueIndex = i; break; }
- }
- } else {
- this.queueIndex = prev;
- }
- this._loadTrack(eq[prev]);
- },
- seekTo(val) {
- val = parseFloat(val);
- if (this.castMode) {
- this._castSend('media.seek', { time: val });
- this.currentTime = val;
- return;
- }
- if (this._currentTrackTranscoded && this.currentTrack) {
- // Seek by reloading the transcode stream from the new position
- this._transcodeSeekOffset = val;
- this.currentTime = val;
- this._audio.src = this._getAudioSrc(this.currentTrack, val);
- this._audio.load();
- this._audio.play().catch(() => {});
- return;
- }
- this._audio.currentTime = val;
- this.currentTime = this._audio.currentTime;
- },
- beginSeek() { this.isSeeking = true; },
- endSeek(val) { this.isSeeking = false; this.seekTo(val); },
- setVolume(val) {
- this.volume = parseInt(val);
- this.isMuted = this.volume === 0;
- localStorage.setItem('musicify_volume', this.volume);
- if (this.castMode) {
- this._castSend('media.volume', { volume: this.volume, muted: this.isMuted });
- return;
- }
- this._audio.volume = this.volume / 100;
- },
- toggleMute() {
- this.isMuted = !this.isMuted;
- if (this.castMode) {
- this._castSend('media.volume', { volume: this.volume, muted: this.isMuted });
- return;
- }
- this._audio.muted = this.isMuted;
- },
- // True when the in-app volume slider should be shown: always on desktop,
- // and on mobile only if the user has explicitly turned the override on.
- shouldShowVolumeSlider() {
- return !this._isMobile() || this.forceShowVolumeControl;
- },
- saveForceShowVolumeControl() {
- localStorage.setItem('musicify_forceShowVolumeControl', String(this.forceShowVolumeControl));
- if (this.forceShowVolumeControl) {
- this._showToast('Volume slider shown on this device');
- } else if (this._isMobile()) {
- this.volume = 100;
- this.isMuted = false;
- this._audio.volume = 1;
- localStorage.setItem('musicify_volume', this.volume);
- this._showToast('Using native volume control – volume set to max');
- }
- },
- toggleQueue(){
- this.showQueue = !this.showQueue;
- this.updateQueuePanelPosition();
- },
- updateQueuePanelPosition(){
- var bottomPos = window.innerHeight - document.getElementsByClassName("player-bar")[0].getBoundingClientRect().top;
- var queueEl = document.getElementById("queue-panel");
- if (this.showQueue) {
- queueEl.style.bottom = bottomPos + "px";
- } else {
- queueEl.style.bottom = -queueEl.offsetHeight + "px";
- }
- },
- toggleShuffle() {
- this.shuffle = !this.shuffle;
- ao_module_storage.setStorage("Musicify", "shuffle", String(this.shuffle));
- if (this.shuffle) this._buildShuffledQueue(this.queueIndex);
- },
- cycleRepeat() {
- var modes = ['none', 'all', 'one'];
- var idx = modes.indexOf(this.repeat);
- this.repeat = modes[(idx + 1) % modes.length];
- ao_module_storage.setStorage("Musicify", "repeat", this.repeat);
- if (this.castMode) {
- this._castSend('media.repeat', { mode: this.repeat });
- }
- },
- // ════════════════════════════════════════════════════════════════════
- // TRANSCODE HELPERS
- // ════════════════════════════════════════════════════════════════════
- _needsTranscode(song) {
- if (!song || !song.ext) return false;
- var nonNative = ['flac', 'ogg', 'wma', 'webm', 'opus'];
- return nonNative.indexOf(song.ext.toLowerCase()) !== -1;
- },
- _isIOS() {
- return /iPad|iPhone|iPod/.test(navigator.userAgent) && !window.MSStream;
- },
- _isAndroid() {
- return /Android/.test(navigator.userAgent);
- },
- _isMobile() {
- return this._isIOS() || this._isAndroid();
- },
- _updateLandscapeState() {
- this.isMobileLandscape = this._isMobile() && window.innerWidth > window.innerHeight;
- },
- // Returns the playback URL for a song, using the transcode endpoint when needed.
- // startTime (seconds) is only appended when seeking a transcoded stream.
- _getAudioSrc(song, startTime) {
- if (!song) return '';
- if (this.transcodeMode !== 'disabled' && this._needsTranscode(song)) {
- var url = ao_root + 'media/transcode/audio/?file=' + encodeURIComponent(song.filepath) +
- '&samplerate=' + this.transcodeMode + '000';
- if (startTime && startTime > 0.001) url += '&start=' + parseFloat(startTime).toFixed(3);
- return url;
- }
- return ao_root + 'media?file=' + encodeURIComponent(song.filepath);
- },
- // Full Buffer Mode: ask the server to transcode the WHOLE track to a static
- // MP3 first, then play that completed file. A complete file (served via /media
- // with byte-range support) behaves like a native source, so iOS can seek it and
- // never resets the stream to t=0 mid-playback.
- // resumeAt – seconds to seek to once loaded (0 = from start)
- // wasPlaying – auto-play after the source is ready
- _playViaFullBuffer(song, resumeAt, wasPlaying) {
- var self = this;
- var _bufSong = song;
- resumeAt = resumeAt || 0;
- this._fullBufferLoading = true;
- if (MUSICIFY_DEBUG) console.log('[Musicify FBM] requesting server buffer:', song.filepath, '@', this.transcodeMode + 'kHz', 'resumeAt:', resumeAt);
- fetch(ao_root + 'system/ajgi/interface?script=Musicify/backend/fullbuffer.js', {
- method: 'POST', cache: 'no-cache',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ file: song.filepath, samplerate: self.transcodeMode })
- }).then(r => r.json()).then(data => {
- self._fullBufferLoading = false;
- this.$nextTick(() => {
- this.$nextTick(() => {
- _fullBufferLoading = false; // prevent racing for cached media file
- });
- });
- // Abort if the user already switched to a different track
- if (!self.currentTrack || self.currentTrack.filepath !== _bufSong.filepath) return;
- if (data.error || !data.path) {
- if (MUSICIFY_DEBUG) console.warn('[Musicify FBM] server buffer failed:', data.error || '(no path returned)');
- self._showToast('Full buffer failed – streaming instead', 'error');
- self._playViaStream(_bufSong, resumeAt, wasPlaying);
- return;
- }
- if (MUSICIFY_DEBUG) console.log('[Musicify FBM] buffered file ready:', data.path);
- // Completed static MP3 — behaves like a native file (iOS-safe, seekable)
- self._currentTrackTranscoded = false;
- self._transcodeSeekOffset = 0;
- self.duration = 0;
- self._audio.src = ao_root + 'media?file=' + encodeURIComponent(data.path);
- self._audio.load();
- if (resumeAt > 0) {
- self._audio.addEventListener('loadedmetadata', function() {
- try { self._audio.currentTime = resumeAt; } catch (e) {}
- }, { once: true });
- }
- if (wasPlaying) {
- // On iOS the audio element must already be unlocked by a prior
- // gesture-initiated play; if not, play() rejects and we reflect the
- // paused state so the user can tap play.
- self._audio.play().catch(function() { self.isPlaying = false; });
- }
- }).catch(() => {
- self._fullBufferLoading = false;
- if (!self.currentTrack || self.currentTrack.filepath !== _bufSong.filepath) return;
- if (MUSICIFY_DEBUG) console.warn('[Musicify FBM] network error contacting fullbuffer.js');
- self._showToast('Full buffer error – streaming instead', 'error');
- self._playViaStream(_bufSong, resumeAt, wasPlaying);
- });
- },
- // Stream a track through the realtime transcode endpoint (or directly via /media
- // for web-native formats). Used for non-FBM playback and as the FBM fallback.
- // resumeAt – seconds to seek to once loaded (0 = from start)
- // wasPlaying – auto-play after the source is ready (default true)
- _playViaStream(song, resumeAt, wasPlaying) {
- var self = this;
- resumeAt = resumeAt || 0;
- if (wasPlaying === undefined) wasPlaying = true;
- this._currentTrackTranscoded = (this.transcodeMode !== 'disabled' && this._needsTranscode(song));
- this._transcodeSeekOffset = 0;
- this.duration = 0;
- if (this._currentTrackTranscoded && resumeAt > 0.001) {
- this._transcodeSeekOffset = resumeAt;
- this._audio.src = this._getAudioSrc(song, resumeAt);
- } else {
- this._audio.src = this._getAudioSrc(song);
- }
- this._audio.load();
- if (this._currentTrackTranscoded) {
- // Transcoded streams have no Content-Length — pre-fetch duration for the seek bar
- var _song = song;
- fetch(ao_root + 'media/duration/?file=' + encodeURIComponent(_song.filepath))
- .then(r => r.json())
- .then(data => {
- if (data.duration > 0 && self.currentTrack && self.currentTrack.filepath === _song.filepath) {
- self.duration = data.duration;
- }
- }).catch(() => {});
- } else if (resumeAt > 0) {
- // Native source: seek once metadata is ready
- this._audio.addEventListener('loadedmetadata', function() {
- try { self._audio.currentTime = resumeAt; } catch (e) {}
- }, { once: true });
- }
- if (wasPlaying) {
- this._audio.play().catch(() => {});
- }
- },
- saveFullBufferMode() {
- localStorage.setItem('musicify_fullBufferMode', String(this.fullBufferMode));
- this._showToast('Full Buffer Mode: ' + (this.fullBufferMode ? 'enabled' : 'disabled'));
- // Reload the current track immediately so the change takes effect now
- // (not just on the next track). Preserves the current playback position.
- if (this.currentTrack && this._needsTranscode(this.currentTrack) &&
- this.transcodeMode !== 'disabled' && !this.castMode) {
- if (this._transcodeEndFallbackTimer) {
- clearTimeout(this._transcodeEndFallbackTimer);
- this._transcodeEndFallbackTimer = null;
- }
- var resumeAt = this.currentTime;
- var wasPlaying = this.isPlaying;
- this._suppressEnded = true;
- if (MUSICIFY_DEBUG) console.log('[Musicify FBM] toggle ->', this.fullBufferMode ? 'buffer' : 'stream', 'reloading at', resumeAt);
- if (this.fullBufferMode) {
- this._playViaFullBuffer(this.currentTrack, resumeAt, wasPlaying);
- } else {
- this._playViaStream(this.currentTrack, resumeAt, wasPlaying);
- }
- }
- },
- saveTranscodeMode() {
- localStorage.setItem('musicify_transcodeMode', this.transcodeMode);
- // If a non-native track is currently loaded, reload it immediately at the
- // current position so the new sample rate (and seeking) take effect now.
- // Honour Full Buffer Mode: re-buffer at the new rate when it is enabled.
- if (this.currentTrack && this._needsTranscode(this.currentTrack) && !this.castMode) {
- if (this._transcodeEndFallbackTimer) {
- clearTimeout(this._transcodeEndFallbackTimer);
- this._transcodeEndFallbackTimer = null;
- }
- var resumeAt = this.currentTime; // already includes _transcodeSeekOffset
- var wasPlaying = this.isPlaying;
- this._suppressEnded = true;
- if (this.transcodeMode !== 'disabled' && this.fullBufferMode) {
- this._playViaFullBuffer(this.currentTrack, resumeAt, wasPlaying);
- } else {
- this._playViaStream(this.currentTrack, resumeAt, wasPlaying);
- }
- }
- this._showToast('Transcode: ' + (this.transcodeMode === 'disabled' ? 'disabled' : this.transcodeMode + ' kHz'));
- },
- _onEnded() {
- if (this._suppressEnded) return;
- // Clear any pending end-fallback timer — the real ended path is now running
- if (this._transcodeEndFallbackTimer) {
- clearTimeout(this._transcodeEndFallbackTimer);
- this._transcodeEndFallbackTimer = null;
- }
- if (this.repeat === 'one') {
- if (this.castMode) {
- this._castSend('media.seek', { time: 0 });
- this._castSend('media.play', {});
- } else if (this._currentTrackTranscoded && this.currentTrack) {
- // Transcoded streams can't seek natively — reload from the beginning
- this._suppressEnded = true;
- this._transcodeSeekOffset = 0;
- this.currentTime = 0;
- this._audio.src = this._getAudioSrc(this.currentTrack);
- this._audio.load();
- this._audio.play().catch(() => {});
- } else {
- this._audio.currentTime = 0;
- this._audio.play().catch(() => {});
- }
- return;
- }
- this.nextTrack();
- },
- _onError() {
- this._showToast('Playback error – skipping', 'error');
- setTimeout(() => { this.nextTrack(); }, 1500);
- },
- isCurrentTrack(song) {
- return this.currentTrack && this.currentTrack.filepath === song.filepath;
- },
- isCurrentQueueItem(index) {
- if (!this.shuffle) return index === this.queueIndex;
- var eq = this._effectiveQueue();
- var current = eq[this._effectiveIndex(this.queueIndex)];
- return current && this.queue[index].filepath === current.filepath;
- },
- // ════════════════════════════════════════════════════════════════════
- // SLEEP TIMER
- // ════════════════════════════════════════════════════════════════════
- startSleepTimer() {
- this.cancelSleepTimer();
- this._sleepEnd = Date.now() + this.sleepMinutes * 60000;
- this.sleepActive = true;
- this.showSleepModal = false;
- const self = this;
- this._sleepTimer = setInterval(() => {
- var rem = self._sleepEnd - Date.now();
- if (rem <= 0) {
- self._fadeOutAndPause();
- self.cancelSleepTimer();
- } else {
- var m = Math.floor(rem / 60000);
- var s = Math.floor((rem % 60000) / 1000);
- self.sleepCountdown = m + ':' + String(s).padStart(2, '0');
- }
- }, 1000);
- this._showToast('Sleep timer set for ' + this.sleepMinutes + ' min');
- },
- cancelSleepTimer() {
- if (this._sleepTimer) clearInterval(this._sleepTimer);
- this._sleepTimer = null;
- this.sleepActive = false;
- this.sleepCountdown = '';
- },
- _fadeOutAndPause() {
- const audio = this._audio;
- const originalVol = audio.volume;
- const self = this;
- var fadeInterval = setInterval(() => {
- if (audio.volume > 0.05) {
- audio.volume = Math.max(0, audio.volume - 0.04);
- } else {
- audio.volume = 0;
- audio.pause();
- audio.volume = originalVol;
- self.isPlaying = false;
- clearInterval(fadeInterval);
- self._showToast('Sleep timer: music stopped');
- }
- }, 150);
- },
- // ════════════════════════════════════════════════════════════════════
- // MEDIA SESSION API
- // ════════════════════════════════════════════════════════════════════
- _setupMediaSession() {
- if (!('mediaSession' in navigator) || !this.currentTrack) return;
- const self = this;
- navigator.mediaSession.metadata = new MediaMetadata({
- title: this.currentTrack.name,
- artist: this._getArtistName(this.currentTrack),
- album: '',
- artwork: [{ src: this.getCoverUrl(this.currentTrack), sizes: '512x512', type: 'image/jpeg' }]
- });
- navigator.mediaSession.setActionHandler('play', () => self._audio.play());
- navigator.mediaSession.setActionHandler('pause', () => self._audio.pause());
- navigator.mediaSession.setActionHandler('previoustrack', () => self.prevTrack());
- navigator.mediaSession.setActionHandler('nexttrack', () => self.nextTrack());
- navigator.mediaSession.setActionHandler('seekto', details => {
- self._audio.currentTime = details.seekTime;
- });
- },
- _updateMediaSession() {
- if (!('mediaSession' in navigator)) return;
- navigator.mediaSession.playbackState = this.isPlaying ? 'playing' : 'paused';
- if (this.duration > 0) {
- try {
- navigator.mediaSession.setPositionState({
- duration: this.duration,
- playbackRate: 1,
- position: Math.min(this.currentTime, this.duration)
- });
- } catch(e) {}
- }
- },
- // ════════════════════════════════════════════════════════════════════
- // RECENTLY PLAYED (server-side, cross-device)
- // ════════════════════════════════════════════════════════════════════
- _saveRecentlyPlayed(song) {
- var list = this.recentlyPlayed.filter(s => s.filepath !== song.filepath);
- list.unshift(song);
- list = list.slice(0, 12);
- this.recentlyPlayed = list;
- ao_module_storage.setStorage("Musicify", "recent", JSON.stringify(list));
- },
- // ════════════════════════════════════════════════════════════════════
- // HELPERS
- // ════════════════════════════════════════════════════════════════════
- formatTime(s) {
- if (!s || isNaN(s)) return '0:00';
- s = Math.floor(s);
- return Math.floor(s / 60) + ':' + String(s % 60).padStart(2, '0');
- },
- getCoverUrl(song) {
- if (!song) return 'img/placeholder.png';
- return ao_root + 'system/file_system/loadThumbnail?bytes=true&vpath=' + encodeURIComponent(song.filepath);
- },
- handleCoverError(event) {
- event.target.src = 'img/placeholder.png';
- event.target.onerror = null;
- },
- // ── Privacy / Audio Visualizer Mode ──────────────────────────────────
- savePrivacyVisualizerMode() {
- localStorage.setItem('musicify_privacyVisualizer', String(this.privacyVisualizerMode));
- if (this.privacyVisualizerMode) {
- this._ensureAnalyser();
- if (this._audioCtx && this._audioCtx.state === 'suspended') this._audioCtx.resume().catch(() => {});
- this._updateVizColor(this.currentTrack);
- this._startVizLoop();
- this._showToast('Privacy Mode: album art hidden');
- } else {
- this._stopVizLoop();
- this._showToast('Privacy Mode: album art shown');
- }
- },
- // Lazily wires the <audio> element into a Web Audio graph. This can only be
- // done once per element (createMediaElementSource throws on a second call),
- // so the analyser is created once and simply left connected afterwards.
- _ensureAnalyser() {
- if (this._analyser || this._syntheticViz || !this._audio) return;
- // iOS: routing the <audio> element through a Web Audio AudioContext
- // (createMediaElementSource) hands playback to that context, which iOS
- // suspends when the screen locks — stopping background audio. Skip Web
- // Audio there and drive the bars with a synthetic animation instead, so
- // playback behaves exactly like non-privacy mode.
- if (this._isIOS()) {
- this._syntheticViz = true;
- return;
- }
- try {
- var AudioCtx = window.AudioContext || window.webkitAudioContext;
- this._audioCtx = new AudioCtx();
- var source = this._audioCtx.createMediaElementSource(this._audio);
- this._analyser = this._audioCtx.createAnalyser();
- this._analyser.fftSize = 64;
- this._analyser.smoothingTimeConstant = 0.8;
- source.connect(this._analyser);
- this._analyser.connect(this._audioCtx.destination);
- this._vizData = new Uint8Array(this._analyser.frequencyBinCount);
- } catch (e) {
- if (MUSICIFY_DEBUG) console.warn('[Musicify] visualizer unavailable', e);
- this._analyser = null;
- }
- },
- _startVizLoop() {
- if (this._vizAnimId) return;
- var self = this;
- (function frame() {
- self._vizAnimId = requestAnimationFrame(frame);
- self._renderViz();
- })();
- },
- _stopVizLoop() {
- if (this._vizAnimId) { cancelAnimationFrame(this._vizAnimId); this._vizAnimId = null; }
- },
- _renderViz() {
- var bars = 27;
- var mid = Math.floor(bars / 2);
- var heights = new Array(bars);
- if (this._analyser && this._vizData) {
- this._analyser.getByteFrequencyData(this._vizData);
- var binCount = this._vizData.length;
- var step = Math.max(1, Math.floor(binCount / (mid + 2)));
- for (var i = 0; i < bars; i++) {
- var dist = Math.abs(i - mid);
- var idx = Math.min(binCount - 1, dist * step);
- heights[i] = Math.max(0.06, this._vizData[idx] / 255);
- }
- } else if (this._syntheticViz) {
- // iOS fallback: procedurally animated, center-weighted bars that
- // pulse while playing and settle when paused (no real FFT).
- var t = performance.now() / 1000;
- var active = this.isPlaying ? 1 : 0;
- for (var k = 0; k < bars; k++) {
- var envelope = 1 - (Math.abs(k - mid) / mid) * 0.7; // taller in the middle
- var wave = 0.5 + 0.30 * Math.sin(t * 3.1 + k * 0.5) + 0.15 * Math.sin(t * 5.7 + k * 0.9);
- heights[k] = Math.max(0.06, Math.min(1, 0.08 + active * Math.max(0, wave) * envelope * 0.9));
- }
- } else {
- for (var j = 0; j < bars; j++) heights[j] = 0.06;
- }
- this._paintVizCanvas(document.getElementById('np-viz-canvas'), heights);
- this._paintVizCanvas(document.getElementById('mini-viz-canvas'), heights);
- },
- _paintVizCanvas(canvas, heights) {
- if (!canvas || !canvas.offsetParent) return;
- var dpr = window.devicePixelRatio || 1;
- var w = canvas.clientWidth, h = canvas.clientHeight;
- if (!w || !h) return;
- if (canvas.width !== Math.round(w * dpr) || canvas.height !== Math.round(h * dpr)) {
- canvas.width = Math.round(w * dpr);
- canvas.height = Math.round(h * dpr);
- }
- var ctx = canvas.getContext('2d');
- ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
- ctx.clearRect(0, 0, w, h);
- var n = heights.length;
- var gap = w * 0.02;
- var barW = (w - gap * (n - 1)) / n;
- ctx.fillStyle = 'rgb(' + this._vizColor + ')';
- for (var i = 0; i < n; i++) {
- var bh = Math.max(2, heights[i] * h);
- var x = i * (barW + gap);
- var y = (h - bh) / 2;
- this._roundRectPath(ctx, x, y, barW, bh, barW / 2);
- ctx.fill();
- }
- },
- _roundRectPath(ctx, x, y, w, h, r) {
- r = Math.max(0, Math.min(r, w / 2, h / 2));
- ctx.beginPath();
- ctx.moveTo(x + r, y);
- ctx.arcTo(x + w, y, x + w, y + h, r);
- ctx.arcTo(x + w, y + h, x, y + h, r);
- ctx.arcTo(x, y + h, x, y, r);
- ctx.arcTo(x, y, x + w, y, r);
- ctx.closePath();
- },
- // Samples the current cover art's average color (boosted for contrast against
- // the blurred backdrop) and uses it as the visualizer bar color.
- _updateVizColor(song) {
- var self = this;
- if (!song) { self._vizColor = '168,85,247'; return; }
- var img = new Image();
- img.onload = function() {
- try {
- var c = document.createElement('canvas');
- c.width = 24; c.height = 24;
- var cctx = c.getContext('2d');
- cctx.drawImage(img, 0, 0, 24, 24);
- var data = cctx.getImageData(0, 0, 24, 24).data;
- var r = 0, g = 0, b = 0, n = 0;
- for (var i = 0; i < data.length; i += 4) {
- if (data[i + 3] < 10) continue;
- r += data[i]; g += data[i + 1]; b += data[i + 2]; n++;
- }
- if (n === 0) { self._vizColor = '168,85,247'; return; }
- r = Math.round(r / n); g = Math.round(g / n); b = Math.round(b / n);
- self._vizColor = self._boostColorRGB(r, g, b);
- } catch (e) {
- self._vizColor = '168,85,247';
- }
- };
- img.onerror = function() { self._vizColor = '168,85,247'; };
- img.src = this.getCoverUrl(song);
- },
- _boostColorRGB(r, g, b) {
- var hsl = this._rgbToHsl(r, g, b);
- var s = Math.max(hsl[1], 0.65);
- var l = Math.min(Math.max(hsl[2], 0.62), 0.82);
- var rgb = this._hslToRgb(hsl[0], s, l);
- return rgb[0] + ',' + rgb[1] + ',' + rgb[2];
- },
- _rgbToHsl(r, g, b) {
- r /= 255; g /= 255; b /= 255;
- var max = Math.max(r, g, b), min = Math.min(r, g, b);
- var h = 0, s = 0, l = (max + min) / 2;
- if (max !== min) {
- var d = max - min;
- s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
- switch (max) {
- case r: h = (g - b) / d + (g < b ? 6 : 0); break;
- case g: h = (b - r) / d + 2; break;
- case b: h = (r - g) / d + 4; break;
- }
- h /= 6;
- }
- return [h, s, l];
- },
- _hslToRgb(h, s, l) {
- var r, g, b;
- if (s === 0) { r = g = b = l; }
- else {
- var hue2rgb = function(p, q, t) {
- if (t < 0) t += 1;
- if (t > 1) t -= 1;
- if (t < 1 / 6) return p + (q - p) * 6 * t;
- if (t < 1 / 2) return q;
- if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6;
- return p;
- };
- var q = l < 0.5 ? l * (1 + s) : l + s - l * s;
- var p = 2 * l - q;
- r = hue2rgb(p, q, h + 1 / 3);
- g = hue2rgb(p, q, h);
- b = hue2rgb(p, q, h - 1 / 3);
- }
- return [Math.round(r * 255), Math.round(g * 255), Math.round(b * 255)];
- },
- _getArtistName(song) {
- if (!song) return '';
- var parts = song.filepath.split('/');
- // /user:/Music/ArtistName/... → index 2
- if (parts.length >= 3) return parts[parts.length - 2];
- return '';
- },
- getArtistLabel(song) {
- return this._getArtistName(song) || '';
- },
- progressPercent() {
- if (!this.duration) return 0;
- return (this.currentTime / this.duration) * 100;
- },
- volumeIcon() {
- if (this.isMuted || this.volume === 0) return 'volume off';
- if (this.volume < 40) return 'volume down';
- return 'volume up';
- },
- repeatIcon() {
- if (this.repeat === 'one') return 'repeat';
- return 'redo alternate';
- },
- repeatTitle() {
- if (this.repeat === 'none') return 'Repeat: off';
- if (this.repeat === 'all') return 'Repeat: all';
- return 'Repeat: one';
- },
- // ════════════════════════════════════════════════════════════════════
- // TRACK INFO PANEL
- // ════════════════════════════════════════════════════════════════════
- // Toggle the song-info section docked at the bottom of the Now Playing
- // overlay. Revealing it scrolls the overlay down to bring it into view.
- toggleTrackInfo() {
- if (!this.currentTrack) return;
- this.trackInfoSong = this.currentTrack;
- this.showTrackInfo = !this.showTrackInfo;
- if (this.showTrackInfo) {
- if (!ao_module_virtualDesktop){
- // Not in webdesktop mode, so "Open in Player View" doesn't make sense – hide it
- $("#open-in-embedded").hide();
- }else{
- $("#open-in-embedded").show();
- }
- this.$nextTick(() => {
- var c = document.querySelector('.now-playing-overlay .np-content');
- if (c) c.scrollTo({ top: c.scrollHeight, behavior: 'smooth' });
- });
- }
- },
- // ════════════════════════════════════════════════════════════════════
- // NOW PLAYING FULL-SCREEN OVERLAY
- // ════════════════════════════════════════════════════════════════════
- openNowPlaying() {
- if (!this.currentTrack) return;
- var mc = document.getElementById('mainContent');
- // Pin the overlay to the current scroll position before Alpine shows it,
- // then freeze scrolling underneath.
- var overlay = mc ? mc.querySelector('.now-playing-overlay') : null;
- if (overlay) overlay.style.top = (mc.scrollTop) + 'px';
- if (mc) mc.style.overflow = 'hidden';
- this.showTrackInfo = false; // start collapsed
- this.trackInfoSong = this.currentTrack;
- this.showNowPlaying = true;
- this.$nextTick(() => {
- var c = overlay ? overlay.querySelector('.np-content') : null;
- if (c) c.scrollTop = 0;
- if (overlay) overlay.style.top = (mc.scrollTop) + 'px'; // readjust after content is revealed
- this.updateQueuePanelPosition();
- });
-
- },
- toggleNowPlaying() {
- if (this.showNowPlaying) {
- this.closeNowPlaying();
- } else {
- this.openNowPlaying();
- }
- },
- closeNowPlaying() {
- this.showNowPlaying = false;
- this.showTrackInfo = false;
- this._npSwipeDir = '';
- var mc = document.getElementById('mainContent');
- if (mc) mc.style.overflow = '';
- this.$nextTick(() => {
- this.updateQueuePanelPosition();
- });
- },
- npTouchStart(e) {
- var t = e.changedTouches ? e.changedTouches[0] : e;
- this._npTouchStartX = t.clientX;
- this._npTouchStartY = t.clientY;
- this._npSwiping = true;
- },
- npTouchEnd(e) {
- if (!this._npSwiping) return;
- this._npSwiping = false;
- var t = e.changedTouches ? e.changedTouches[0] : e;
- var dx = t.clientX - this._npTouchStartX;
- var dy = t.clientY - this._npTouchStartY;
- // Horizontal swipe → change track; vertical swipe down → close
- if (Math.abs(dx) > 60 && Math.abs(dx) > Math.abs(dy)) {
- if (dx < 0) {
- this._npSwipeDir = 'left';
- this.nextTrack();
- } else {
- this._npSwipeDir = 'right';
- this.prevTrack();
- }
- var self = this;
- setTimeout(function() { self._npSwipeDir = ''; }, 280);
- } else if (dy > 90 && Math.abs(dy) > Math.abs(dx)) {
- //this.closeNowPlaying();
- }
- },
- copyTrackTitle(song) {
- if (!song) return;
- var text = song.name;
- if (navigator.clipboard) {
- navigator.clipboard.writeText(text)
- .then(() => { this._showToast('Title copied!'); })
- .catch(() => { this._showToast('Failed to copy', 'error'); });
- } else {
- var el = document.createElement('textarea');
- el.value = text;
- document.body.appendChild(el);
- el.select();
- document.execCommand('copy');
- document.body.removeChild(el);
- this._showToast('Title copied!');
- }
- },
- openInFileManager(song) {
- if (!song) return;
- var parts = song.filepath.split('/');
- var filename = parts.pop();
- var folder = parts.join('/');
- ao_module_openPath(folder, filename);
- },
- openInEmbedded(song) {
- if (!song) return;
- var fileList = [{
- filename: song.name + (song.ext ? '.' + song.ext : ''),
- filepath: song.filepath
- }];
- ao_module_newfw({
- url: 'Musicify/embedded.html#' + encodeURIComponent(JSON.stringify(fileList)),
- title: song.name,
- appicon: 'Musicify/img/module_icon.png',
- width: 360,
- height: 254
- });
- },
- searchOnYoutube(song) {
- if (!song) return;
- var q = encodeURIComponent(song.name + ' ' + this.getArtistLabel(song));
- window.open('https://www.youtube.com/results?search_query=' + q, '_blank');
- },
- downloadSong(song) {
- if (!song) return;
- var a = document.createElement('a');
- a.href = ao_root + 'media?file=' + encodeURIComponent(song.filepath);
- a.download = song.name + (song.ext ? '.' + song.ext : '');
- document.body.appendChild(a);
- a.click();
- document.body.removeChild(a);
- },
- getTrackFolder(song) {
- if (!song) return '';
- var parts = song.filepath.split('/');
- parts.pop();
- return parts.join('/');
- },
- // ════════════════════════════════════════════════════════════════════
- // AROZCAST
- // ════════════════════════════════════════════════════════════════════
- connectToCast() {
- var code = this.castCodeInput.trim();
- if (!/^\d{4}$/.test(code)) {
- this.castError = 'Enter a valid 4-digit code.';
- return;
- }
- this.castError = '';
- this.castConnecting = true;
- var self = this;
- fetch(ao_root + 'api/arozcast/ping?code=' + code)
- .then(function(r) { return r.json(); })
- .then(function(data) {
- if (!data.exists) {
- self.castConnecting = false;
- self.castError = 'Room not found. Check the code and try again.';
- return;
- }
- self._castOpen(code);
- })
- .catch(function() {
- self.castConnecting = false;
- self.castError = 'Connection failed. Is Arozcast running?';
- });
- },
- _castOpen(code) {
- var self = this;
- // Cancel any pending auto-reconnect to the old room — user is opening a new session
- clearTimeout(this._castReconnectTimer); this._castReconnectTimer = null;
- this._castReconnectCount = 0; this._castPendingCode = null;
- var wsUrl = new URL(ao_root + 'api/arozcast/ws?code=' + code, window.location.href);
- wsUrl.protocol = (location.protocol === 'https:') ? 'wss:' : 'ws:';
- var ws = new WebSocket(wsUrl.toString());
- ws.onopen = function() {
- self.castConnecting = false;
- self.castConnected = true;
- self.castMode = true;
- self.castCode = code;
- self._castWs = ws;
- self.showCastModal = false;
- self.castCodeInput = '';
- self._castLastSeen = Date.now();
- // Pause local audio; remote screen takes over
- self._audio.pause();
- // Announce presence; sync volume first so _loadMedia reads the right level
- ws.send(JSON.stringify({ topic: 'peer.hello', payload: {} }));
- self._castSend('media.volume', { volume: self.volume, muted: self.isMuted });
- if (self.currentTrack) {
- self._castSend('media.load', {
- filepath: self.currentTrack.filepath,
- name: self.currentTrack.name,
- artist: self.getArtistLabel(self.currentTrack),
- cover: self.currentTrack.cover || '',
- type: 'audio',
- startTime: self.currentTime // sync mid-playback position
- });
- // Explicitly mirror play/pause state rather than relying on autoplay
- if (self.isPlaying) {
- self._castSend('media.play', {});
- } else {
- self._castSend('media.pause', {});
- }
- self._castSend('media.repeat', { mode: self.repeat });
- }
- // Heartbeat: tell Arozcast we are still here every 5 s
- self._castPingTimer = setInterval(function() {
- self._castSend('peer.heartbeat', {});
- }, 5000);
- // Watchdog: if Arozcast stops sending for 12 s, force-close the WS
- self._castWatchTimer = setInterval(function() {
- if (Date.now() - self._castLastSeen > 12000) {
- if (self._castWs) self._castWs.close();
- }
- }, 4000);
- self._showToast('Connected to Arozcast');
- };
- ws.onclose = function() {
- clearInterval(self._castPingTimer);
- clearInterval(self._castWatchTimer);
- self._castPingTimer = null;
- self._castWatchTimer = null;
- var wasActive = self.castMode;
- var savedCode = self.castCode;
- self.castConnected = false;
- self.castMode = false;
- self._castWs = null;
- if (wasActive) { self._startCastReconnect(savedCode); }
- };
- ws.onerror = function() {
- self.castConnecting = false;
- self.castError = 'WebSocket error. Check your connection.';
- };
- ws.onmessage = function(evt) {
- self._castLastSeen = Date.now();
- try {
- var msg = JSON.parse(evt.data);
- if (msg.topic === 'status.update') {
- if (!self.isSeeking) self.currentTime = msg.payload.currentTime || 0;
- self.duration = msg.payload.duration || 0;
- self.isPlaying = msg.payload.isPlaying || false;
- }
- } catch(e) {}
- };
- },
- // ── Auto-reconnect helpers ────────────────────────────────────────────
- _startCastReconnect(code) {
- var self = this;
- var DELAYS = [2000, 5000, 12000];
- if (!code || this._castReconnectCount >= DELAYS.length) {
- if (this._castReconnectCount > 0) {
- // All retries exhausted — fall back to local playback
- if (this.currentTrack) {
- var resumeAt = this.currentTime;
- var self = this;
- this._currentTrackTranscoded = (this.transcodeMode !== 'disabled' && this._needsTranscode(this.currentTrack));
- this._transcodeSeekOffset = 0;
- if (this._currentTrackTranscoded && resumeAt > 0.001) {
- this._transcodeSeekOffset = resumeAt;
- this._audio.src = this._getAudioSrc(this.currentTrack, resumeAt);
- } else {
- this._audio.src = this._getAudioSrc(this.currentTrack);
- }
- this._audio.volume = this.volume / 100;
- this._audio.muted = this.isMuted;
- this._audio.load();
- if (!this._currentTrackTranscoded && resumeAt > 0) {
- this._audio.addEventListener('loadedmetadata', function() {
- self._audio.currentTime = resumeAt;
- }, { once: true });
- }
- this.isPlaying = false;
- this._showToast('Arozcast: reconnect failed — resuming locally', 'error');
- }
- }
- this._castReconnectCount = 0; this._castPendingCode = null;
- return;
- }
- this._castPendingCode = code;
- var delay = DELAYS[this._castReconnectCount++];
- clearTimeout(this._castReconnectTimer);
- this._castReconnectTimer = setTimeout(function() {
- self._castReconnectTimer = null;
- self._attemptCastReconnect();
- }, delay);
- this._showToast('Arozcast disconnected — reconnecting…');
- },
- _attemptCastReconnect() {
- var self = this;
- if (!this._castPendingCode) return;
- var code = this._castPendingCode;
- var wsUrl = new URL(ao_root + 'api/arozcast/ws?code=' + code, window.location.href);
- wsUrl.protocol = (location.protocol === 'https:') ? 'wss:' : 'ws:';
- var ws = new WebSocket(wsUrl.toString());
- var openTimer = setTimeout(function() {
- ws.onopen = ws.onclose = ws.onerror = null; ws.close();
- self._startCastReconnect(code);
- }, 8000);
- ws.onopen = function() { clearTimeout(openTimer); self._castPendingCode = null; self._castDidReconnect(ws, code); };
- ws.onerror = function() {};
- ws.onclose = function() { clearTimeout(openTimer); self._startCastReconnect(code); };
- },
- _castDidReconnect(ws, code) {
- var self = this;
- this._castWs = ws;
- this.castCode = code;
- this.castMode = true;
- this.castConnected = true;
- this._castLastSeen = Date.now();
- ws.onmessage = function(evt) {
- self._castLastSeen = Date.now();
- try {
- var msg = JSON.parse(evt.data);
- if (msg.topic === 'status.update') {
- self._castReconnectCount = 0; // receiver confirmed alive — reset retry counter
- if (!self.isSeeking) self.currentTime = msg.payload.currentTime || 0;
- self.duration = msg.payload.duration || 0;
- self.isPlaying = msg.payload.isPlaying || false;
- } else if (msg.topic === 'media.ended') {
- self._onEnded();
- }
- } catch(e) {}
- };
- ws.onclose = function() {
- clearInterval(self._castPingTimer); clearInterval(self._castWatchTimer);
- self._castPingTimer = null; self._castWatchTimer = null;
- var wasActive = self.castMode;
- var savedCode = self.castCode;
- self.castConnected = false; self.castMode = false; self._castWs = null;
- if (wasActive) { self._startCastReconnect(savedCode); }
- };
- // Re-announce presence and sync volume only — do NOT resend media.load.
- // Arozcast kept playing while the phone was asleep; its next status.update
- // will immediately sync currentTime to the live remote position.
- ws.send(JSON.stringify({ topic: 'peer.hello', payload: {} }));
- this._castSend('media.volume', { volume: this.volume, muted: this.isMuted });
- this._castSend('media.repeat', { mode: this.repeat });
- clearInterval(this._castPingTimer); clearInterval(this._castWatchTimer);
- this._castPingTimer = setInterval(function() { self._castSend('peer.heartbeat', {}); }, 5000);
- this._castWatchTimer = setInterval(function() {
- if (Date.now() - self._castLastSeen > 12000 && self._castWs) self._castWs.close();
- }, 4000);
- this._showToast('Arozcast reconnected');
- },
- disconnectCast() {
- // Capture play state before we tear anything down
- var wasPlaying = this.isPlaying;
- // Cancel any pending auto-reconnect before tearing down
- clearTimeout(this._castReconnectTimer); this._castReconnectTimer = null;
- this._castReconnectCount = 0; this._castPendingCode = null;
- clearInterval(this._castPingTimer);
- clearInterval(this._castWatchTimer);
- this._castPingTimer = null;
- this._castWatchTimer = null;
- this.castMode = false;
- this.castConnected = false;
- this.showCastModal = false;
- if (this._castWs) {
- // Send stop so Arozcast halts — this is an explicit user disconnect,
- // not a sleep/drop (those suppress onclose and never reach here).
- this._castSend('media.stop', {});
- this._castWs.onclose = null; // suppress reconnect trigger
- this._castWs.close();
- this._castWs = null;
- }
- this.castCode = '';
- this.castCodeInput = '';
- this.castError = '';
- this.isPlaying = false;
- // Resume local playback at the last known remote position,
- // but only auto-start if the remote was actually playing.
- if (this.currentTrack) {
- var resumeAt = this.currentTime;
- var self = this;
- this._currentTrackTranscoded = (this.transcodeMode !== 'disabled' && this._needsTranscode(this.currentTrack));
- this._transcodeSeekOffset = 0;
- if (this._currentTrackTranscoded && resumeAt > 0.001) {
- this._transcodeSeekOffset = resumeAt;
- this._audio.src = this._getAudioSrc(this.currentTrack, resumeAt);
- } else {
- this._audio.src = this._getAudioSrc(this.currentTrack);
- }
- this._audio.volume = this.volume / 100;
- this._audio.muted = this.isMuted;
- this._audio.load();
- if (!this._currentTrackTranscoded && resumeAt > 0) {
- this._audio.addEventListener('loadedmetadata', function() {
- self._audio.currentTime = resumeAt;
- }, { once: true });
- }
- if (wasPlaying) {
- this._audio.play().catch(function() {});
- }
- }
- this._showToast('Disconnected from Arozcast');
- },
- _castSend(topic, payload) {
- if (!this._castWs || this._castWs.readyState !== WebSocket.OPEN) return;
- this._castWs.send(JSON.stringify({ topic: topic, payload: payload }));
- },
- // Toast notification (simple, injected into DOM)
- _toastTimer: null,
- toastMsg: '',
- toastType: 'info',
- showToast: false,
- _showToast(msg, type) {
- this.toastMsg = msg;
- this.toastType = type || 'info';
- this.showToast = true;
- if (this._toastTimer) clearTimeout(this._toastTimer);
- const self = this;
- this._toastTimer = setTimeout(() => { self.showToast = false; }, 2500);
- }
- };
- }
|