Files
furumusic/templates/player/scripts.html
T

3591 lines
138 KiB
HTML
Raw Normal View History

2026-05-26 12:55:11 +03:00
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3/dist/cdn.min.js"></script>
<script>
2026-05-26 14:47:10 +03:00
const T = {
info: "{{ t.player_info }}",
noDetails: "{{ t.player_no_details }}",
2026-05-27 12:55:31 +03:00
trackInfoTitle: "{{ t.player_track_info }}",
releaseInfoTitle: "{{ t.player_release_info }}",
2026-05-26 14:47:10 +03:00
loadingHistory: "{{ t.player_loading_history }}",
failedLoadHistory: "{{ t.player_failed_load_history }}",
totalPlays: "{{ t.player_total_plays }}",
unknown: "{{ t.player_unknown }}",
unknownSize: "{{ t.player_unknown_size }}",
title: "{{ t.player_title }}",
release: "{{ t.player_release }}",
2026-05-26 14:47:10 +03:00
unknownRelease: "{{ t.player_unknown_release }}",
unknownTrack: "{{ t.player_unknown_track }}",
unknownAudio: "{{ t.player_unknown_audio }}",
type: "{{ t.player_type }}",
year: "{{ t.player_year }}",
tracks: "{{ t.player_tracks }}",
uploaders: "{{ t.player_uploaders }}",
artists: "{{ t.player_artists }}",
releaseYear: "{{ t.player_release_year }}",
duration: "{{ t.player_duration }}",
audio: "{{ t.player_audio }}",
size: "{{ t.player_size }}",
uploader: "{{ t.player_uploader }}",
2026-05-26 18:16:34 +03:00
lastfmRating: "{{ t.player_lastfm_rating }}",
lastfmListeners: "{{ t.player_lastfm_listeners }}",
lastfmPlaycount: "{{ t.player_lastfm_playcount }}",
lastfmUpdated: "{{ t.player_lastfm_updated }}",
lastfmNotLoaded: "{{ t.player_lastfm_not_loaded }}",
2026-05-27 16:40:06 +03:00
lastfmProfile: "{{ t.player_lastfm_profile }}",
lastfmConnect: "{{ t.player_lastfm_connect }}",
lastfmConnected: "{{ t.player_lastfm_connected }}",
lastfmReconnect: "{{ t.player_lastfm_reconnect }}",
lastfmNotConfigured: "{{ t.player_lastfm_not_configured }}",
2026-05-28 15:17:59 +03:00
lastfmStatusConnect: "{{ t.player_lastfm_status_connect }}",
lastfmStatusConnected: "{{ t.player_lastfm_status_connected }}",
lastfmStatusReconnect: "{{ t.player_lastfm_status_reconnect }}",
lastfmStatusNotConfigured: "{{ t.player_lastfm_status_not_configured }}",
2026-05-27 16:40:06 +03:00
lastfmDisconnectConfirm: "{{ t.player_lastfm_disconnect_confirm }}",
lastfmConnectFailed: "{{ t.player_lastfm_connect_failed }}",
lastfmDisconnectFailed: "{{ t.player_lastfm_disconnect_failed }}",
connectionLost: "{{ t.player_connection_lost }}",
connectionLostDetail: "{{ t.player_connection_lost_detail }}",
2026-05-26 14:47:10 +03:00
trackWord: "{{ t.player_tracks_count }}",
clientIdle: "{{ t.player_client_idle }}",
active: "{{ t.player_active }}",
aiIdle: "{{ t.player_ai_idle }}",
aiPrefix: "{{ t.player_ai_prefix }}",
processing: "{{ t.player_processing }}",
queued: "{{ t.player_queued }}",
saved: "{{ t.player_saved }}",
2026-05-26 16:21:21 +03:00
chooseSavedOrAddTorrent: "{{ t.player_choose_saved_or_add_torrent }}",
2026-05-26 16:59:36 +03:00
uploadFailed: "{{ t.player_upload_failed }}",
uploadComplete: "{{ t.player_upload_complete }}",
uploadingFiles: "{{ t.player_uploading_files }}",
2026-05-26 14:47:10 +03:00
preview: "{{ t.player_preview }}",
2026-05-26 16:59:36 +03:00
resolving: "{{ t.player_resolving }}",
2026-05-26 14:47:10 +03:00
downloading: "{{ t.player_downloading }}",
moving: "{{ t.player_moving }}",
completed: "{{ t.player_completed }}",
failed: "{{ t.player_failed }}",
paused: "{{ t.player_paused }}",
noTorrentSelected: "{{ t.player_no_torrent_selected }}",
2026-05-26 16:21:21 +03:00
downloaded: "{{ t.player_downloaded }}",
speed: "{{ t.player_speed }}",
2026-05-26 14:47:10 +03:00
down: "{{ t.player_down }}",
up: "{{ t.player_up }}",
peers: "{{ t.player_peers }}",
live: "{{ t.player_live }}",
seen: "{{ t.player_seen }}",
eta: "{{ t.player_eta }}",
selected: "{{ t.player_selected }}",
2026-05-26 16:21:21 +03:00
downloadSelected: "{{ t.player_download_selected }}",
pauseDownload: "{{ t.player_pause_download }}",
2026-05-26 14:47:10 +03:00
chooseTorrent: "{{ t.player_choose_torrent }}",
readingTorrent: "{{ t.player_reading_torrent }}",
resolvingMagnet: "{{ t.player_resolving_magnet }}",
previewFailed: "{{ t.player_preview_failed }}",
allFilesSelected: "{{ t.player_all_files_selected }}",
openingSavedTorrent: "{{ t.player_opening_saved_torrent }}",
savedTorrentOpened: "{{ t.player_saved_torrent_opened }}",
removeTorrentConfirm: "{{ t.player_remove_torrent_confirm }}",
torrentRemoved: "{{ t.player_torrent_removed }}",
selectOneFile: "{{ t.player_select_one_file }}",
startingDownload: "{{ t.player_starting_download }}",
downloadStarted: "{{ t.player_download_started }}",
pausingDownload: "{{ t.player_pausing_download }}",
downloadPaused: "{{ t.player_download_paused }}",
statusFailed: "{{ t.player_status_failed }}",
startFailed: "{{ t.player_start_failed }}",
pauseFailed: "{{ t.player_pause_failed }}",
loadTorrentsFailed: "{{ t.player_load_torrents_failed }}",
openTorrentFailed: "{{ t.player_open_torrent_failed }}",
deleteTorrentFailed: "{{ t.player_delete_torrent_failed }}",
loadAiQueueFailed: "{{ t.player_load_ai_queue_failed }}",
deletePlaylistConfirm: "{{ t.player_delete_playlist_confirm }}",
albums: "{{ t.player_albums }}",
eps: "{{ t.player_eps }}",
singles: "{{ t.player_singles }}",
compilations: "{{ t.player_compilations }}",
mixtapes: "{{ t.player_mixtapes }}",
liveReleases: "{{ t.player_live_releases }}",
soundtracks: "{{ t.player_soundtracks }}",
likesPlaylist: "{{ t.player_likes_playlist }}",
};
2026-05-26 12:55:11 +03:00
function formatTime(seconds) {
if (!seconds || isNaN(seconds)) return '0:00';
const s = Math.floor(seconds);
const m = Math.floor(s / 60);
const sec = s % 60;
return m + ':' + (sec < 10 ? '0' : '') + sec;
}
2026-05-27 00:28:39 +03:00
function coverVariantUrl(url, variant) {
if (!url) return url;
return url.replace(/\/(small|medium|large)$/, '/' + variant);
}
2026-05-26 12:55:11 +03:00
document.addEventListener('alpine:init', () => {
// -----------------------------------------------------------------------
// Connection monitor
// -----------------------------------------------------------------------
Alpine.store('connection', {
failureCount: 0,
disconnected: false,
threshold: 2,
init() {
if (navigator.onLine === false) {
this.failureCount = this.threshold;
this.disconnected = true;
}
window.addEventListener('online', () => this.recordSuccess());
window.addEventListener('offline', () => this.recordFailure());
},
message() {
return T.connectionLostDetail;
},
recordSuccess() {
this.failureCount = 0;
this.disconnected = false;
},
recordFailure() {
this.failureCount += 1;
if (this.failureCount >= this.threshold) {
this.disconnected = true;
}
},
});
installConnectionFetchMonitor();
2026-05-26 12:55:11 +03:00
// -----------------------------------------------------------------------
// Audio element
// -----------------------------------------------------------------------
const audio = new Audio();
audio.preload = 'auto';
Alpine.store('mobile', {
libraryOpen: false,
2026-05-29 02:37:21 +03:00
playerExpanded: false,
playerDragging: false,
playerDragOffset: 0,
playerCloseOffset: 0,
_playerDragStartY: 0,
2026-05-29 02:56:14 +03:00
_playerDragStartX: 0,
_playerDragTracking: false,
_playerDragMode: null,
2026-05-29 02:37:21 +03:00
_playerDragPointerId: null,
_playerDragElement: null,
_playerDragMove: null,
_playerDragEnd: null,
2026-05-29 02:56:14 +03:00
_playerSuppressClickUntil: 0,
2026-05-26 12:55:11 +03:00
toggleLibrary() {
this.libraryOpen = !this.libraryOpen;
if (this.libraryOpen) Alpine.store('user').menuOpen = false;
},
closeLibrary() {
this.libraryOpen = false;
},
2026-05-29 02:37:21 +03:00
isMobilePlayer() {
return window.matchMedia && window.matchMedia('(max-width: 720px)').matches;
},
openPlayerFullscreen() {
if (!this.isMobilePlayer() || !Alpine.store('player').currentTrack) return;
this.playerExpanded = true;
this.playerDragging = false;
this.playerDragOffset = 0;
this.playerCloseOffset = 0;
Alpine.store('queue').visible = false;
Alpine.store('devices').open = false;
},
closePlayerFullscreen() {
this.playerExpanded = false;
this.playerDragging = false;
this.playerDragOffset = 0;
this.playerCloseOffset = 0;
},
playerDragStyle() {
return `--mobile-player-drag:${this.playerDragOffset}px; --mobile-player-close-drag:${this.playerCloseOffset}px;`;
},
2026-05-29 02:56:14 +03:00
handlePlayerClick(event) {
if (Date.now() <= this._playerSuppressClickUntil) {
event.preventDefault();
event.stopPropagation();
this._playerSuppressClickUntil = 0;
}
},
2026-05-29 02:37:21 +03:00
startPlayerDrag(event, force = false) {
if (!this.isMobilePlayer() || !Alpine.store('player').currentTrack) return;
if (event.button && event.button !== 0) return;
const target = event.target;
2026-05-29 02:56:14 +03:00
const isDragBlocked = target.closest('input, select, textarea, .volume-slider, .device-popover, .mobile-expanded-queue');
2026-05-29 02:37:21 +03:00
if (!force) {
if (this.playerExpanded) {
const isCloseHandle = target.closest('.player-now-playing');
const scroller = event.currentTarget?.classList?.contains('player-bar') ? event.currentTarget : null;
2026-05-29 02:56:14 +03:00
if (!isCloseHandle || isDragBlocked || (scroller && scroller.scrollTop > 4)) return;
} else if (isDragBlocked) {
2026-05-29 02:37:21 +03:00
return;
}
}
2026-05-29 02:56:14 +03:00
if (this._playerDragTracking) this.endPlayerDrag({ type: 'pointercancel' });
this.playerDragging = false;
this._playerDragTracking = true;
this._playerDragMode = this.playerExpanded ? 'close' : 'open';
2026-05-29 02:37:21 +03:00
this._playerDragStartY = event.clientY;
2026-05-29 02:56:14 +03:00
this._playerDragStartX = event.clientX;
2026-05-29 02:37:21 +03:00
this._playerDragPointerId = event.pointerId;
this._playerDragElement = event.currentTarget;
this.playerDragOffset = 0;
this.playerCloseOffset = 0;
this._playerDragMove = e => this.movePlayerDrag(e);
this._playerDragEnd = e => this.endPlayerDrag(e);
window.addEventListener('pointermove', this._playerDragMove, { passive: false });
window.addEventListener('pointerup', this._playerDragEnd, { passive: false });
window.addEventListener('pointercancel', this._playerDragEnd, { passive: false });
},
movePlayerDrag(event) {
2026-05-29 02:56:14 +03:00
if (!this._playerDragTracking) return;
2026-05-29 02:37:21 +03:00
const delta = this._playerDragStartY - event.clientY;
2026-05-29 02:56:14 +03:00
const absDelta = Math.abs(delta);
if (!this.playerDragging) {
const horizontalDelta = Math.abs(event.clientX - this._playerDragStartX);
const wantsOpen = this._playerDragMode === 'open' && delta > 0;
const wantsClose = this._playerDragMode === 'close' && delta < 0;
if (absDelta < 8 || absDelta < horizontalDelta * 1.15 || (!wantsOpen && !wantsClose)) return;
this.playerDragging = true;
try {
this._playerDragElement?.setPointerCapture?.(this._playerDragPointerId);
} catch (_) {}
}
event.preventDefault();
if (this._playerDragMode === 'close') {
this.playerCloseOffset = Math.max(0, Math.min(window.innerHeight, -delta));
2026-05-29 02:37:21 +03:00
} else {
const max = Math.max(0, window.innerHeight - 132);
this.playerDragOffset = Math.max(0, Math.min(max, delta));
}
},
endPlayerDrag(event) {
const openThreshold = Math.min(180, Math.max(90, window.innerHeight * 0.18));
2026-05-29 02:56:14 +03:00
const closeThreshold = Math.min(110, Math.max(64, window.innerHeight * 0.1));
2026-05-29 02:37:21 +03:00
const wasCancelled = event?.type === 'pointercancel';
2026-05-29 02:56:14 +03:00
const wasDragging = this.playerDragging;
if (wasDragging) this._playerSuppressClickUntil = Date.now() + 450;
if (this._playerDragMode === 'close') {
2026-05-29 02:37:21 +03:00
if (!wasCancelled && this.playerCloseOffset > closeThreshold) this.closePlayerFullscreen();
else {
this.playerCloseOffset = 0;
this.playerDragging = false;
}
2026-05-29 02:56:14 +03:00
} else if (this._playerDragMode === 'open' && !wasCancelled && this.playerDragOffset > openThreshold) {
2026-05-29 02:37:21 +03:00
this.openPlayerFullscreen();
} else {
this.playerDragOffset = 0;
this.playerDragging = false;
}
try {
if (this._playerDragPointerId !== null && this._playerDragElement?.hasPointerCapture?.(this._playerDragPointerId)) {
this._playerDragElement.releasePointerCapture(this._playerDragPointerId);
}
} catch (_) {}
if (this._playerDragMove) window.removeEventListener('pointermove', this._playerDragMove);
if (this._playerDragEnd) {
window.removeEventListener('pointerup', this._playerDragEnd);
window.removeEventListener('pointercancel', this._playerDragEnd);
}
2026-05-29 02:56:14 +03:00
this._playerDragTracking = false;
this._playerDragMode = null;
2026-05-29 02:37:21 +03:00
this._playerDragPointerId = null;
this._playerDragElement = null;
this._playerDragMove = null;
this._playerDragEnd = null;
},
2026-05-26 12:55:11 +03:00
});
Alpine.store('info', {
modal: null,
open(title, body) {
if (Array.isArray(body)) {
this.openRows(title, body);
return;
}
2026-05-26 12:55:11 +03:00
this.modal = {
2026-05-26 14:47:10 +03:00
title: title || T.info,
body: body || T.noDetails,
rows: null,
};
},
openRows(title, rows) {
this.modal = {
title: title || T.info,
body: '',
rows: (rows || []).filter(row => row && ((row.value !== undefined && row.value !== null && row.value !== '') || (row.links && row.links.length))),
2026-05-26 12:55:11 +03:00
};
},
close() {
this.modal = null;
},
navigate(link) {
if (!link || !link.id) return;
this.close();
const library = Alpine.store('library');
if (link.type === 'release') library.openRelease(link.id);
if (link.type === 'artist') library.openArtist(link.id);
},
2026-05-26 12:55:11 +03:00
});
// -----------------------------------------------------------------------
// User store
// -----------------------------------------------------------------------
Alpine.store('user', {
profile: null,
menuOpen: false,
2026-05-27 16:40:06 +03:00
lastfm: { configured: false, connected: false, username: null, reauth_required: false, last_error: null },
lastfmBusy: false,
2026-05-26 12:55:11 +03:00
init() {
this.cleanLastfmQuery();
2026-05-26 12:55:11 +03:00
this.load();
2026-05-27 16:40:06 +03:00
this.loadLastfm();
2026-05-26 12:55:11 +03:00
},
cleanLastfmQuery() {
const url = new URL(window.location.href);
if (!url.searchParams.has('lastfm')) return;
url.searchParams.delete('lastfm');
const clean = `${url.pathname}${url.search}${url.hash}`;
window.history.replaceState({}, document.title, clean || '/');
},
2026-05-26 12:55:11 +03:00
async load() {
try {
const res = await fetch('/api/player/me');
if (!res.ok) throw new Error('failed');
this.profile = await res.json();
} catch {
this.profile = null;
}
},
2026-05-27 16:40:06 +03:00
async loadLastfm() {
try {
const res = await fetch('/api/player/lastfm/status');
if (!res.ok) throw new Error('failed');
this.lastfm = await res.json();
const player = Alpine.store('player');
if (player?.isPlaying) player._sendNowPlaying();
} catch {
this.lastfm = { configured: false, connected: false, username: null, reauth_required: false, last_error: null };
}
},
lastfmLabel() {
if (!this.lastfm?.configured) return T.lastfmNotConfigured;
if (this.lastfm?.connected && this.lastfm?.reauth_required) return T.lastfmReconnect;
if (this.lastfm?.connected) {
const user = this.lastfm.username || T.unknown;
return T.lastfmConnected.replace('{user}', user);
}
return T.lastfmConnect;
},
2026-05-28 15:17:59 +03:00
lastfmStatusLabel() {
if (!this.lastfm?.configured) return T.lastfmStatusNotConfigured;
if (this.lastfm?.connected && this.lastfm?.reauth_required) return T.lastfmStatusReconnect;
if (this.lastfm?.connected) {
const user = this.lastfm.username || T.unknown;
return `${T.lastfmStatusConnected}: ${user}`;
}
return T.lastfmStatusConnect;
},
2026-05-27 16:40:06 +03:00
lastfmClass() {
if (!this.lastfm?.configured) return 'not-configured';
if (this.lastfm?.connected && this.lastfm?.reauth_required) return 'needs-auth';
if (this.lastfm?.connected) return 'connected';
return 'available';
},
async handleLastfm() {
if (this.lastfmBusy) return;
if (!this.lastfm?.configured) return;
if (!this.lastfm?.connected || this.lastfm?.reauth_required) {
window.location.href = '/api/player/lastfm/connect';
return;
}
const user = this.lastfm.username || T.unknown;
if (!window.confirm(T.lastfmDisconnectConfirm.replace('{user}', user))) return;
this.lastfmBusy = true;
try {
const res = await fetch('/api/player/lastfm/disconnect', { method: 'POST' });
if (!res.ok) throw new Error(T.lastfmDisconnectFailed);
await this.loadLastfm();
} catch {
alert(T.lastfmDisconnectFailed);
} finally {
this.lastfmBusy = false;
}
},
2026-05-26 12:55:11 +03:00
initials() {
const name = this.profile?.name || '';
return name.trim().charAt(0) || '?';
},
format(value) {
return new Intl.NumberFormat().format(value || 0);
},
duration(minutes) {
let value = Number(minutes || 0);
const units = [
['y', 525600],
['mo', 43800],
['d', 1440],
['h', 60],
['m', 1],
];
const parts = [];
for (const [label, size] of units) {
if (value >= size) {
const count = Math.floor(value / size);
value -= count * size;
parts.push(count + label);
}
if (parts.length >= 2) break;
}
return parts.length ? parts.join(' ') : '0m';
},
logout() {
window.location.href = '/logout';
},
});
// -----------------------------------------------------------------------
// Play history store
// -----------------------------------------------------------------------
Alpine.store('history', {
modal: false,
items: [],
page: 1,
perPage: 20,
total: 0,
loading: false,
message: '',
error: false,
open() {
this.modal = true;
this.load(1);
},
close() {
this.modal = false;
},
totalPages() {
return Math.max(1, Math.ceil(this.total / this.perPage));
},
async load(page) {
page = Math.max(1, page || 1);
this.loading = true;
this.error = false;
2026-05-26 14:47:10 +03:00
this.message = T.loadingHistory;
2026-05-26 12:55:11 +03:00
try {
const res = await fetch(`/api/player/history?page=${page}&limit=${this.perPage}`);
const data = await res.json();
2026-05-26 14:47:10 +03:00
if (!res.ok) throw new Error(data.error || T.failedLoadHistory);
2026-05-26 12:55:11 +03:00
this.items = data.items || [];
this.page = data.page || page;
this.perPage = data.per_page || this.perPage;
this.total = data.total || 0;
2026-05-26 14:47:10 +03:00
this.message = this.total ? (this.total + ' ' + T.totalPlays) : '';
2026-05-26 12:55:11 +03:00
} catch (err) {
this.error = true;
this.message = err.message || String(err);
} finally {
this.loading = false;
}
},
date(value) {
if (!value) return '';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return new Intl.DateTimeFormat(undefined, {
year: 'numeric',
month: 'short',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
}).format(date);
},
duration(seconds) {
if (!seconds) return '0:00';
return formatTime(Number(seconds));
},
});
// -----------------------------------------------------------------------
// Player store
// -----------------------------------------------------------------------
Alpine.store('player', {
currentTrack: null,
isPlaying: false,
currentTime: 0,
duration: 0,
volume: 0.7,
_prevVolume: 0.7,
2026-05-28 23:38:07 +03:00
_volumeCurve: 2.4,
2026-05-26 12:55:11 +03:00
shuffle: false,
repeatMode: 'off', // off, all, one
progress: 0,
_saveTimer: null,
_historyRecorded: false,
2026-05-27 16:40:06 +03:00
_nowPlayingSent: false,
_playbackStartedAt: null,
_listenedSeconds: 0,
_lastAudioTime: 0,
_remoteExecuting: false,
_remoteStateBaseTime: 0,
_remoteStateReceivedAt: 0,
_remoteStateTimer: null,
2026-05-26 12:55:11 +03:00
init() {
audio.volume = this.volume;
audio.addEventListener('timeupdate', () => {
this.currentTime = audio.currentTime;
this.duration = audio.duration || 0;
this.progress = this.duration > 0 ? (this.currentTime / this.duration) * 100 : 0;
2026-05-27 16:40:06 +03:00
this._trackListenedDelta();
2026-05-26 12:55:11 +03:00
});
audio.addEventListener('ended', () => {
2026-05-27 16:52:36 +03:00
this._trackListenedDelta();
2026-05-26 12:55:11 +03:00
this._recordHistory(true);
this.next();
});
2026-05-27 16:40:06 +03:00
audio.addEventListener('play', () => {
this.isPlaying = true;
if (!this._playbackStartedAt) this._playbackStartedAt = Math.floor(Date.now() / 1000);
this._lastAudioTime = audio.currentTime || 0;
this._sendNowPlaying();
});
audio.addEventListener('pause', () => {
this.isPlaying = false;
this._lastAudioTime = audio.currentTime || 0;
});
2026-05-26 12:55:11 +03:00
audio.addEventListener('loadedmetadata', () => {
this.duration = audio.duration || 0;
});
// Periodic state save
this._saveTimer = setInterval(() => {
if (this._isLocalPlaybackDevice()) this._saveState();
2026-05-26 12:55:11 +03:00
}, 10000);
this._remoteStateTimer = setInterval(() => {
this._tickRemoteProgress();
}, 250);
2026-05-26 12:55:11 +03:00
// Restore state
this._restoreState();
// Save state on page unload
window.addEventListener('beforeunload', () => {
if (this._isLocalPlaybackDevice()) this._saveStateSync();
2026-05-26 12:55:11 +03:00
});
},
play(track) {
if (!track) return;
if (this._shouldSendRemote()) {
this._mirrorRemoteTrack(track, true, 0);
this._sendRemote('play_track', this._remotePlaybackPayload(track, {
position_seconds: 0,
paused: false,
}));
return;
}
this._playLocal(track);
},
playQueueIndex(idx) {
const queue = Alpine.store('queue');
if (!queue || idx < 0 || idx >= queue.tracks.length) return;
queue.currentIndex = idx;
const track = queue.tracks[idx];
if (this._shouldSendRemote()) {
this._mirrorRemoteTrack(track, true, 0);
this._sendRemote('play_from_index', this._remotePlaybackPayload(track, {
index: idx,
position_seconds: 0,
paused: false,
}));
return;
}
this._playLocal(track);
},
_playLocal(track, options = {}) {
2026-05-26 12:55:11 +03:00
this.currentTrack = track;
this._historyRecorded = false;
2026-05-27 16:40:06 +03:00
this._resetPlaybackTracking();
2026-05-26 12:55:11 +03:00
audio.src = track.stream_url;
const seekSeconds = Number(options.position_seconds || 0);
if (seekSeconds > 0) {
const onLoaded = () => {
audio.currentTime = seekSeconds;
this._lastAudioTime = audio.currentTime || 0;
audio.removeEventListener('loadedmetadata', onLoaded);
};
audio.addEventListener('loadedmetadata', onLoaded);
}
if (options.paused) {
audio.pause();
this.isPlaying = false;
} else {
audio.play().catch(() => {});
}
2026-05-26 12:55:11 +03:00
this._updateMediaSession();
},
_pauseLocal() {
audio.pause();
this.isPlaying = false;
},
pause() {
if (this._shouldSendRemote()) {
this.isPlaying = false;
this._remoteStateBaseTime = this.currentTime;
this._remoteStateReceivedAt = Date.now();
this._sendRemote('pause');
return;
}
this._pauseLocal();
},
resume() {
if (this._shouldSendRemote()) {
this.isPlaying = true;
this._remoteStateBaseTime = this.currentTime;
this._remoteStateReceivedAt = Date.now();
this._sendRemote('resume');
return;
}
audio.play().catch(() => {});
},
2026-05-26 12:55:11 +03:00
toggle() {
if (!this.currentTrack) return;
if (this.isPlaying) { this.pause(); }
else { this.resume(); }
},
seek(time) {
const nextTime = Math.max(0, Number(time || 0));
if (this._shouldSendRemote()) {
this.currentTime = nextTime;
this.progress = this.duration > 0 ? (this.currentTime / this.duration) * 100 : 0;
this._remoteStateBaseTime = nextTime;
this._remoteStateReceivedAt = Date.now();
this._sendRemote('seek', { time: nextTime });
return;
}
audio.currentTime = nextTime;
2026-05-27 16:40:06 +03:00
this._lastAudioTime = audio.currentTime || 0;
2026-05-26 12:55:11 +03:00
},
seekRelative(delta) {
if (!this.currentTrack) return;
const duration = this.duration || audio.duration || 0;
const current = this._shouldSendRemote() ? this.currentTime : audio.currentTime;
const max = duration > 0 ? duration : Number.MAX_SAFE_INTEGER;
this.seek(Math.max(0, Math.min(max, current + delta)));
2026-05-26 12:55:11 +03:00
},
seekFromClick(event) {
const bar = event.currentTarget;
const rect = bar.getBoundingClientRect();
const pct = (event.clientX - rect.left) / rect.width;
if (this.duration > 0) {
this.seek(pct * this.duration);
}
},
next() {
if (this._shouldSendRemote()) {
this._sendRemote('next', {
shuffle: this.shuffle,
repeat_mode: this.repeatMode,
});
return;
}
2026-05-26 12:55:11 +03:00
const queue = Alpine.store('queue');
if (queue.tracks.length === 0) return;
this._recordHistoryIfListenThresholdReached();
2026-05-26 12:55:11 +03:00
let nextIdx;
if (this.repeatMode === 'one') {
this.seek(0);
this._historyRecorded = false;
this._resetPlaybackTracking();
2026-05-26 12:55:11 +03:00
this.resume();
return;
} else if (this.shuffle) {
nextIdx = Math.floor(Math.random() * queue.tracks.length);
} else {
nextIdx = queue.currentIndex + 1;
if (nextIdx >= queue.tracks.length) {
if (this.repeatMode === 'all') {
nextIdx = 0;
} else {
this.isPlaying = false;
return;
}
}
}
this.playQueueIndex(nextIdx);
2026-05-26 12:55:11 +03:00
},
prev() {
if (this._shouldSendRemote()) {
this._sendRemote('prev');
return;
}
2026-05-26 12:55:11 +03:00
if (this.currentTime > 3) {
this.seek(0);
return;
}
const queue = Alpine.store('queue');
if (queue.tracks.length === 0) return;
let prevIdx = queue.currentIndex - 1;
if (prevIdx < 0) {
if (this.repeatMode === 'all') {
prevIdx = queue.tracks.length - 1;
} else {
this.seek(0);
return;
}
}
this.playQueueIndex(prevIdx);
2026-05-26 12:55:11 +03:00
},
setVolume(v) {
this._setVolumeLocal(v);
if (this._shouldSendRemote()) {
this._sendRemote('set_volume', { volume: this.volume });
}
},
_setVolumeLocal(v) {
this.volume = Math.max(0, Math.min(1, Number(v || 0)));
2026-05-26 12:55:11 +03:00
audio.volume = this.volume;
},
2026-05-28 23:38:07 +03:00
volumeSliderPercent() {
if (this.volume <= 0) return 0;
return Math.pow(this.volume, 1 / this._volumeCurve) * 100;
},
_volumeFromSliderPosition(position) {
const pct = Math.max(0, Math.min(1, Number(position || 0)));
if (pct <= 0.002) return 0;
return Math.pow(pct, this._volumeCurve);
},
2026-05-26 12:55:11 +03:00
_setVolumeFromClientX(clientX, bar) {
const rect = bar.getBoundingClientRect();
const pct = rect.width > 0 ? (clientX - rect.left) / rect.width : 0;
2026-05-28 23:38:07 +03:00
this.setVolume(this._volumeFromSliderPosition(pct));
2026-05-26 12:55:11 +03:00
},
setVolumeFromClick(event) {
this._setVolumeFromClientX(event.clientX, event.currentTarget);
},
startVolumeDrag(event) {
const bar = event.currentTarget;
this._setVolumeFromClientX(event.clientX, bar);
bar.setPointerCapture?.(event.pointerId);
const move = (e) => {
this._setVolumeFromClientX(e.clientX, bar);
};
const stop = () => {
window.removeEventListener('pointermove', move);
window.removeEventListener('pointerup', stop);
window.removeEventListener('pointercancel', stop);
};
window.addEventListener('pointermove', move);
window.addEventListener('pointerup', stop);
window.addEventListener('pointercancel', stop);
},
toggleMute() {
if (this.volume > 0) {
this._prevVolume = this.volume;
this.setVolume(0);
} else {
this.setVolume(this._prevVolume || 0.7);
}
},
toggleShuffle() {
this.shuffle = !this.shuffle;
if (this._shouldSendRemote()) {
this._sendRemote('set_options', {
shuffle: this.shuffle,
repeat_mode: this.repeatMode,
});
}
2026-05-26 12:55:11 +03:00
},
cycleRepeat() {
if (this.repeatMode === 'off') this.repeatMode = 'all';
else if (this.repeatMode === 'all') this.repeatMode = 'one';
else this.repeatMode = 'off';
if (this._shouldSendRemote()) {
this._sendRemote('set_options', {
shuffle: this.shuffle,
repeat_mode: this.repeatMode,
});
}
},
_isLocalPlaybackDevice() {
const devices = Alpine.store('devices');
return !devices || devices.isActive();
},
_shouldSendRemote() {
const devices = Alpine.store('devices');
return !!devices && !this._remoteExecuting && !devices.isActive();
},
_sendRemote(command, payload = {}) {
const devices = Alpine.store('devices');
if (!devices) return false;
devices.sendCommand(command, payload);
return true;
},
_remotePlaybackPayload(track, overrides = {}) {
const queue = Alpine.store('queue');
const tracks = queue?.tracks?.length ? queue.tracks : (track ? [track] : []);
let index = Number.isInteger(overrides.index) ? overrides.index : (queue?.currentIndex ?? 0);
if (track && tracks[index]?.id !== track.id) {
const foundIndex = tracks.findIndex(item => item.id === track.id);
index = foundIndex >= 0 ? foundIndex : 0;
}
return {
track,
tracks,
index,
position_seconds: overrides.position_seconds ?? this.currentTime,
duration_seconds: overrides.duration_seconds ?? this._trackDuration(),
paused: overrides.paused ?? !this.isPlaying,
shuffle: this.shuffle,
repeat_mode: this.repeatMode,
volume: this.volume,
};
},
_devicePlaybackStatePayload() {
const queue = Alpine.store('queue');
const track = this.currentTrack || queue?.tracks?.[queue.currentIndex] || null;
if (!track && (!queue || queue.tracks.length === 0)) return null;
2026-05-28 15:17:59 +03:00
return this._remotePlaybackPayload(track, {
position_seconds: audio.currentTime || this.currentTime || 0,
duration_seconds: this._trackDuration(),
paused: !this.isPlaying,
});
},
_mirrorRemoteTrack(track, playing, positionSeconds = null) {
if (!track) return;
this.currentTrack = track;
this.isPlaying = !!playing;
if (positionSeconds !== null) this.currentTime = Math.max(0, Number(positionSeconds || 0));
this.duration = Number(track.duration_seconds || this.duration || 0);
this.progress = this.duration > 0 ? (this.currentTime / this.duration) * 100 : 0;
this._remoteStateBaseTime = this.currentTime;
this._remoteStateReceivedAt = Date.now();
this._updateMediaSession();
},
_applyRemotePlaybackState(state) {
if (!state) return;
const queue = Alpine.store('queue');
const tracks = Array.isArray(state.tracks) ? state.tracks.filter(Boolean) : [];
if (queue && tracks.length > 0) {
queue.tracks = tracks;
queue.currentIndex = Math.max(0, Math.min(Number(state.index || 0), tracks.length - 1));
}
const track = state.track || queue?.tracks?.[queue.currentIndex] || null;
if (track) {
this.currentTrack = track;
}
this.shuffle = !!state.shuffle;
this.repeatMode = state.repeat_mode || 'off';
if (typeof state.volume === 'number') this._setVolumeLocal(state.volume);
this.duration = Number(state.duration_seconds || track?.duration_seconds || this.duration || 0);
this.isPlaying = !state.paused;
this._remoteStateBaseTime = Math.max(0, Number(state.position_seconds || 0));
this._remoteStateReceivedAt = Date.now();
this._tickRemoteProgress(true);
this._updateMediaSession();
},
_tickRemoteProgress(force = false) {
if (this._isLocalPlaybackDevice() || !this.currentTrack) return;
if (!force && !this.isPlaying) return;
let nextTime = Number(this._remoteStateBaseTime || 0);
if (this.isPlaying && this._remoteStateReceivedAt > 0) {
nextTime += (Date.now() - this._remoteStateReceivedAt) / 1000;
}
const duration = Number(this.duration || this.currentTrack?.duration_seconds || 0);
if (duration > 0) nextTime = Math.min(nextTime, duration);
this.currentTime = Math.max(0, nextTime);
this.progress = duration > 0 ? (this.currentTime / duration) * 100 : 0;
},
_executeRemoteCommand(command) {
if (!command || !command.command) return;
const payload = command.payload || {};
const queue = Alpine.store('queue');
this._remoteExecuting = true;
try {
if (typeof payload.shuffle === 'boolean') this.shuffle = payload.shuffle;
if (payload.repeat_mode) this.repeatMode = payload.repeat_mode;
if (typeof payload.volume === 'number') this._setVolumeLocal(payload.volume);
2026-05-28 15:17:59 +03:00
if (command.command === 'play_track' || command.command === 'play_from_index' || command.command === 'transfer_state') {
if (Array.isArray(payload.tracks) && payload.tracks.length > 0) {
queue.tracks = payload.tracks;
queue.currentIndex = Math.max(0, Math.min(Number(payload.index || 0), queue.tracks.length - 1));
}
const track = payload.track || queue.tracks[queue.currentIndex];
if (track) this._playLocal(track, payload);
} else if (command.command === 'pause') {
this.pause();
} else if (command.command === 'resume') {
this.resume();
} else if (command.command === 'seek') {
this.seek(Number(payload.time || 0));
} else if (command.command === 'next') {
this.next();
} else if (command.command === 'prev') {
this.prev();
} else if (command.command === 'set_volume') {
this.setVolume(payload.volume);
} else if (command.command === 'set_options') {
// Options were already applied above.
2026-05-28 17:34:37 +03:00
} else if (command.command === 'queue_add_end') {
queue._addToEndLocal(payload.tracks || []);
} else if (command.command === 'queue_add_next') {
queue._addNextLocal(payload.tracks || []);
} else if (command.command === 'queue_remove') {
queue._removeLocal(Number(payload.index));
} else if (command.command === 'queue_move') {
queue._moveTrackLocal(Number(payload.from_index), Number(payload.to_index));
} else if (command.command === 'queue_clear') {
queue._clearLocal();
}
this._saveState();
Alpine.store('devices')?.heartbeat();
} finally {
this._remoteExecuting = false;
}
2026-05-26 12:55:11 +03:00
},
_updateMediaSession() {
if (!('mediaSession' in navigator) || !this.currentTrack) return;
const t = this.currentTrack;
navigator.mediaSession.metadata = new MediaMetadata({
title: t.title,
artist: t.artists.map(a => a.name).join(', '),
2026-05-27 00:28:39 +03:00
artwork: t.cover_url ? [{ src: coverVariantUrl(t.cover_url, 'large'), sizes: '512x512', type: 'image/jpeg' }] : [],
2026-05-26 12:55:11 +03:00
});
navigator.mediaSession.setActionHandler('play', () => this.resume());
navigator.mediaSession.setActionHandler('pause', () => this.pause());
navigator.mediaSession.setActionHandler('previoustrack', () => this.prev());
navigator.mediaSession.setActionHandler('nexttrack', () => this.next());
navigator.mediaSession.setActionHandler('seekto', (d) => { if (d.seekTime != null) this.seek(d.seekTime); });
},
_buildStatePayload() {
const queue = Alpine.store('queue');
return {
current_track_id: this.currentTrack ? this.currentTrack.id : null,
position_ms: Math.floor(this.currentTime * 1000),
queue: queue.tracks.map(t => t.id),
queue_position: queue.currentIndex,
shuffle: this.shuffle,
repeat_mode: this.repeatMode,
volume: this.volume,
};
},
_saveState() {
const queue = Alpine.store('queue');
if (!this.currentTrack && queue.tracks.length === 0) return;
const state = this._buildStatePayload();
fetch('/api/player/state', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(state),
}).catch(() => {});
},
_saveStateSync() {
const queue = Alpine.store('queue');
if (!this.currentTrack && queue.tracks.length === 0) return;
const state = this._buildStatePayload();
const blob = new Blob([JSON.stringify(state)], { type: 'application/json' });
navigator.sendBeacon('/api/player/state', blob);
},
async _restoreState() {
try {
const res = await fetch('/api/player/state');
if (!res.ok) return;
const state = await res.json();
this.shuffle = state.shuffle || false;
this.repeatMode = state.repeat_mode || 'off';
this._setVolumeLocal(typeof state.volume === 'number' ? state.volume : 0.7);
2026-05-26 12:55:11 +03:00
// Restore queue if there are track IDs
if (state.queue && state.queue.length > 0) {
try {
const tracksRes = await fetch('/api/player/tracks-by-ids', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ids: state.queue }),
});
if (tracksRes.ok) {
const tracks = await tracksRes.json();
if (tracks.length > 0) {
const queue = Alpine.store('queue');
queue.tracks = tracks;
const idx = Math.max(0, Math.min(state.queue_position, tracks.length - 1));
queue.currentIndex = idx;
// Restore current track
const currentTrack = state.current_track_id
? tracks.find(t => t.id === state.current_track_id)
: tracks[idx];
if (currentTrack) {
this.currentTrack = currentTrack;
this._historyRecorded = false;
2026-05-27 16:40:06 +03:00
this._resetPlaybackTracking();
2026-05-26 12:55:11 +03:00
audio.src = currentTrack.stream_url;
// Seek to saved position once metadata is loaded
const seekMs = state.position_ms || 0;
if (seekMs > 0) {
const onLoaded = () => {
audio.currentTime = seekMs / 1000;
audio.removeEventListener('loadedmetadata', onLoaded);
};
audio.addEventListener('loadedmetadata', onLoaded);
}
this._updateMediaSession();
}
}
}
} catch {}
}
} catch {}
},
_recordHistory(completed) {
if (this._historyRecorded || !this.currentTrack) return;
this._historyRecorded = true;
2026-05-27 18:07:02 +03:00
const listenedSeconds = this._historyListenedSeconds(completed);
2026-05-26 12:55:11 +03:00
fetch('/api/player/history', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
track_id: this.currentTrack.id,
2026-05-27 18:07:02 +03:00
started_at: this._playbackStartedAt,
duration_listened: listenedSeconds,
2026-05-26 12:55:11 +03:00
completed: completed,
}),
}).catch(() => {});
},
2026-05-27 16:40:06 +03:00
_recordHistoryIfListenThresholdReached() {
if (this._historyRecorded || !this.currentTrack) return false;
this._trackListenedDelta();
const duration = this._trackDuration();
if (duration <= 0) return false;
const listened = Math.floor(Number(this._listenedSeconds || 0));
const threshold = Math.ceil(duration / 2);
if (threshold <= 0 || listened < threshold) return false;
this._recordHistory(true);
return true;
},
2026-05-27 16:40:06 +03:00
_resetPlaybackTracking() {
this._nowPlayingSent = false;
this._playbackStartedAt = null;
this._listenedSeconds = 0;
this._lastAudioTime = 0;
},
_trackListenedDelta() {
if (!this.currentTrack) return;
const current = audio.currentTime || 0;
if (!this.isPlaying) {
this._lastAudioTime = current;
return;
}
const previous = Number.isFinite(this._lastAudioTime) ? this._lastAudioTime : current;
const delta = current - previous;
if (delta > 0 && delta < 5) {
this._listenedSeconds += delta;
}
this._lastAudioTime = current;
},
2026-05-27 16:52:36 +03:00
_trackDuration() {
const candidates = [
this.currentTrack?.duration_seconds,
this.duration,
audio.duration,
];
for (const value of candidates) {
const duration = Number(value || 0);
if (Number.isFinite(duration) && duration > 0) return duration;
}
return 0;
},
_scrobbleThreshold(duration) {
return Math.min(duration / 2, 240);
},
2026-05-27 18:07:02 +03:00
_historyListenedSeconds(completed) {
const duration = this._trackDuration();
const listened = Number(this._listenedSeconds || 0);
const finalGrace = completed ? 1 : 0;
const precise = Math.floor(listened + finalGrace);
if (precise > 0) return precise;
const current = Number(audio.currentTime || this.currentTime || 0);
if (duration > 0 && Number.isFinite(current)) {
return Math.floor(Math.min(current, duration));
}
return Math.floor(current || 0);
},
2026-05-27 16:40:06 +03:00
_sendNowPlaying() {
if (this._nowPlayingSent || !this.currentTrack) return;
const lastfm = Alpine.store('user')?.lastfm;
if (!lastfm?.configured || !lastfm?.connected || lastfm?.reauth_required) return;
this._nowPlayingSent = true;
fetch('/api/player/lastfm/now-playing', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ track_id: this.currentTrack.id }),
}).catch(() => {});
},
2026-05-26 12:55:11 +03:00
});
// -----------------------------------------------------------------------
// Playback devices store
// -----------------------------------------------------------------------
Alpine.store('devices', {
id: null,
devices: [],
2026-05-28 17:34:37 +03:00
jams: [],
activeDeviceId: null,
2026-05-28 17:34:37 +03:00
currentJamId: null,
open: false,
2026-05-28 17:34:37 +03:00
jamPanelOpen: false,
jamQuery: '',
jamUsers: [],
jamSelectedUsers: [],
jamSearching: false,
_pollTimer: null,
2026-05-28 17:34:37 +03:00
_jamSearchTimer: null,
_stateRefreshTick: 0,
init() {
this.id = this._ensureId();
2026-05-28 17:34:37 +03:00
this.currentJamId = sessionStorage.getItem('furu_player_jam_id') || null;
this.heartbeat();
2026-05-28 15:17:59 +03:00
this._pollTimer = setInterval(() => this.poll(), 500);
document.addEventListener('visibilitychange', () => {
if (!document.hidden) this.poll();
});
},
_ensureId() {
let id = sessionStorage.getItem('furu_player_device_id');
if (!id) {
id = (crypto.randomUUID ? crypto.randomUUID() : this._fallbackId()).replace(/[^a-zA-Z0-9_-]/g, '');
sessionStorage.setItem('furu_player_device_id', id);
}
return id;
},
_fallbackId() {
return 'device-' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2);
},
requestPayload() {
const player = Alpine.store('player');
return {
device_id: this.id,
user_agent: navigator.userAgent || '',
2026-05-28 17:34:37 +03:00
current_jam_id: this.currentJamId,
playback_state: player && this.isActive() ? player._devicePlaybackStatePayload() : null,
};
},
async heartbeat() {
try {
const res = await fetch('/api/player/devices/heartbeat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(this.requestPayload()),
});
if (!res.ok) return;
this._apply(await res.json());
} catch {}
},
async poll() {
try {
const res = await fetch('/api/player/devices/poll', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(this.requestPayload()),
});
if (!res.ok) return;
const data = await res.json();
this._apply(data);
const player = Alpine.store('player');
if (player && Array.isArray(data.commands)) {
data.commands.forEach(command => player._executeRemoteCommand(command));
}
if (player && !this.isActive()) {
if (data.playback_state) {
player._applyRemotePlaybackState(data.playback_state);
} else if (++this._stateRefreshTick % 8 === 0) {
player._restoreState();
}
}
} catch {}
},
_apply(data) {
const wasActive = this.isActive();
this.activeDeviceId = data.active_device_id || null;
this.devices = Array.isArray(data.devices) ? data.devices : [];
2026-05-28 17:34:37 +03:00
this.jams = Array.isArray(data.jams) ? data.jams : [];
if (data.current_jam_id) {
this.currentJamId = data.current_jam_id;
sessionStorage.setItem('furu_player_jam_id', this.currentJamId);
} else if (this.currentJamId && !this.jams.some(jam => jam.id === this.currentJamId)) {
this.currentJamId = null;
sessionStorage.removeItem('furu_player_jam_id');
}
if (wasActive && !this.isActive()) {
Alpine.store('player')?._pauseLocal();
}
},
2026-05-28 17:34:37 +03:00
selectedJam() {
return this.currentJamId ? this.jams.find(jam => jam.id === this.currentJamId) : null;
},
isControllingRemoteJam() {
const jam = this.selectedJam();
return !!jam && !jam.is_owner;
},
isActive() {
2026-05-28 17:34:37 +03:00
if (this.isControllingRemoteJam()) return false;
return !this.activeDeviceId || this.activeDeviceId === this.id;
},
activeLabel() {
2026-05-28 17:34:37 +03:00
const jam = this.selectedJam();
if (jam) return jam.name;
const active = this.devices.find(device => device.id === this.activeDeviceId);
return active ? active.name : 'Devices';
},
toggle() {
this.open = !this.open;
if (this.open) this.poll();
},
async select(deviceId) {
if (!deviceId) return;
const player = Alpine.store('player');
try {
2026-05-28 17:34:37 +03:00
this.clearJamSelection();
const res = await fetch('/api/player/devices/active', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
device_id: deviceId,
current_device_id: this.id,
}),
});
if (!res.ok) return;
2026-05-28 15:17:59 +03:00
const data = await res.json();
this._apply(data);
this.open = false;
2026-05-28 15:17:59 +03:00
if (deviceId === this.id && data.playback_state && player) {
player._executeRemoteCommand({
command: 'transfer_state',
payload: data.playback_state,
});
} else if (deviceId !== this.id && player && this.id !== this.activeDeviceId) {
player._applyRemotePlaybackState(data.playback_state);
}
} catch {}
},
async sendCommand(command, payload = {}, targetDeviceId = null) {
2026-05-28 17:34:37 +03:00
const jamId = this.isControllingRemoteJam() ? this.currentJamId : null;
const target = jamId ? null : (targetDeviceId || this.activeDeviceId);
if (!jamId && (!target || target === this.id)) return false;
try {
const res = await fetch('/api/player/devices/command', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
target_device_id: target,
2026-05-28 17:34:37 +03:00
jam_id: jamId,
command,
payload,
}),
});
return res.ok;
} catch {
return false;
}
},
2026-05-28 17:34:37 +03:00
openJamPanel() {
this.jamPanelOpen = !this.jamPanelOpen;
if (this.jamPanelOpen && this.jamQuery.trim()) this.searchJamUsers();
},
queueJamSearch() {
clearTimeout(this._jamSearchTimer);
this._jamSearchTimer = setTimeout(() => this.searchJamUsers(), 180);
},
async searchJamUsers() {
const query = this.jamQuery.trim();
if (!query) {
this.jamUsers = [];
return;
}
this.jamSearching = true;
try {
const res = await fetch('/api/player/jams/users?q=' + encodeURIComponent(query));
if (!res.ok) return;
const selected = new Set(this.jamSelectedUsers.map(user => user.id));
this.jamUsers = (await res.json()).filter(user => !selected.has(user.id));
} catch {
} finally {
this.jamSearching = false;
}
},
addJamInvitee(user) {
if (!user || this.jamSelectedUsers.some(item => item.id === user.id)) return;
this.jamSelectedUsers.push(user);
this.jamUsers = this.jamUsers.filter(item => item.id !== user.id);
this.jamQuery = '';
},
removeJamInvitee(userId) {
this.jamSelectedUsers = this.jamSelectedUsers.filter(user => user.id !== userId);
},
async createJam() {
try {
const res = await fetch('/api/player/jams', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
device_id: this.id,
invitee_user_ids: this.jamSelectedUsers.map(user => user.id),
}),
});
if (!res.ok) return;
const data = await res.json();
this._apply(data);
this.jamPanelOpen = false;
this.jamQuery = '';
this.jamUsers = [];
this.jamSelectedUsers = [];
this.open = false;
} catch {}
},
async selectJam(jam) {
if (!jam) return;
try {
if (jam.is_pending) {
const ok = window.confirm('Join this Jam and control the shared queue?');
if (!ok) return;
}
const res = await fetch('/api/player/jams/join', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
jam_id: jam.id,
device_id: this.id,
}),
});
if (!res.ok) return;
const data = await res.json();
this.currentJamId = jam.id;
sessionStorage.setItem('furu_player_jam_id', jam.id);
this._apply(data);
this.open = false;
const player = Alpine.store('player');
if (player && this.isControllingRemoteJam() && data.playback_state) {
player._applyRemotePlaybackState(data.playback_state);
}
} catch {}
},
async leaveJam(jamId = null) {
const id = jamId || this.currentJamId;
if (!id) return;
try {
const res = await fetch('/api/player/jams/leave', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
jam_id: id,
device_id: this.id,
}),
});
if (res.ok) this._apply(await res.json());
} catch {
} finally {
this.clearJamSelection();
}
},
clearJamSelection() {
this.currentJamId = null;
sessionStorage.removeItem('furu_player_jam_id');
},
});
2026-05-26 12:55:11 +03:00
// -----------------------------------------------------------------------
// Queue store
// -----------------------------------------------------------------------
Alpine.store('queue', {
tracks: [],
currentIndex: 0,
visible: false,
_dragIdx: null,
add(track) {
2026-05-28 17:34:37 +03:00
this.addToEnd([track]);
2026-05-26 12:55:11 +03:00
},
2026-05-29 02:37:21 +03:00
upcoming(limit = 12) {
const start = Math.max(0, this.currentIndex + 1);
return this.tracks.slice(start, start + limit);
},
2026-05-26 12:55:11 +03:00
addToEnd(tracks) {
2026-05-28 17:34:37 +03:00
const items = this._trackList(tracks);
if (!items.length) return;
if (this._sendRemoteQueueCommand('queue_add_end', { tracks: items })) {
this._addToEndLocal(items);
return;
}
this._addToEndLocal(items);
2026-05-26 12:55:11 +03:00
},
addNextInQueue(tracks) {
2026-05-28 17:34:37 +03:00
const items = this._trackList(tracks);
if (!items.length) return;
if (this._sendRemoteQueueCommand('queue_add_next', { tracks: items })) {
this._addNextLocal(items);
return;
}
this._addNextLocal(items);
2026-05-26 12:55:11 +03:00
},
playRelease(tracks, startIndex) {
this.tracks = [...tracks];
this.playFromIndex(startIndex || 0);
},
playFromIndex(idx) {
if (idx < 0 || idx >= this.tracks.length) return;
Alpine.store('player').playQueueIndex(idx);
2026-05-26 12:55:11 +03:00
},
remove(idx) {
2026-05-28 17:34:37 +03:00
if (this._sendRemoteQueueCommand('queue_remove', { index: idx })) {
this._removeLocal(idx);
return;
}
this._removeLocal(idx);
},
moveTrack(fromIdx, toIdx) {
if (this._sendRemoteQueueCommand('queue_move', { from_index: fromIdx, to_index: toIdx })) {
this._moveTrackLocal(fromIdx, toIdx);
return;
}
this._moveTrackLocal(fromIdx, toIdx);
},
clear() {
if (this._sendRemoteQueueCommand('queue_clear')) {
this._clearLocal();
return;
}
this._clearLocal();
},
_trackList(tracks) {
return (Array.isArray(tracks) ? tracks : [tracks]).filter(Boolean);
},
_sendRemoteQueueCommand(command, payload = {}) {
const player = Alpine.store('player');
if (!player?._shouldSendRemote()) return false;
Alpine.store('devices')?.sendCommand(command, payload);
return true;
},
_addToEndLocal(tracks) {
const items = this._trackList(tracks);
if (!items.length) return;
this.tracks = [...this.tracks, ...items];
},
_addNextLocal(tracks) {
const items = this._trackList(tracks);
if (!items.length) return;
const insertAt = Math.min(this.currentIndex + 1, this.tracks.length);
this.tracks.splice(insertAt, 0, ...items);
},
_removeLocal(idx) {
2026-05-26 12:55:11 +03:00
if (idx < 0 || idx >= this.tracks.length) return;
this.tracks.splice(idx, 1);
if (this.tracks.length === 0) {
this.currentIndex = 0;
} else if (idx < this.currentIndex) {
this.currentIndex--;
} else if (idx === this.currentIndex) {
if (this.currentIndex >= this.tracks.length) {
this.currentIndex = this.tracks.length - 1;
}
}
},
2026-05-28 17:34:37 +03:00
_moveTrackLocal(fromIdx, toIdx) {
2026-05-26 12:55:11 +03:00
if (fromIdx === toIdx) return;
if (fromIdx < 0 || fromIdx >= this.tracks.length) return;
if (toIdx < 0 || toIdx >= this.tracks.length) return;
const [track] = this.tracks.splice(fromIdx, 1);
this.tracks.splice(toIdx, 0, track);
// Adjust currentIndex to follow the currently playing track
if (this.currentIndex === fromIdx) {
this.currentIndex = toIdx;
} else if (fromIdx < this.currentIndex && toIdx >= this.currentIndex) {
this.currentIndex--;
} else if (fromIdx > this.currentIndex && toIdx <= this.currentIndex) {
this.currentIndex++;
}
},
2026-05-28 17:34:37 +03:00
_clearLocal() {
2026-05-26 12:55:11 +03:00
this.tracks = [];
this.currentIndex = 0;
},
});
// -----------------------------------------------------------------------
// Library store
// -----------------------------------------------------------------------
Alpine.store('library', {
view: 'artists',
artists: [],
artistsPage: 0,
artistsTotal: 0,
loading: false,
_allLoaded: false,
currentArtist: null,
currentRelease: null,
currentPlaylist: null,
_observer: null,
searchQuery: '',
searchResults: null,
searchLoading: false,
_previousView: 'artists',
2026-05-27 00:28:39 +03:00
_activeHash: location.hash || '#artists',
_scrollPositions: {},
2026-05-26 12:55:11 +03:00
_hashNav: false, // guard against circular hash updates
init() {
this.loadArtists(1);
this._setupScroll();
// Listen for browser back/forward
window.addEventListener('hashchange', () => {
2026-05-27 00:28:39 +03:00
if (this._hashNav) return;
const nextHash = location.hash || '#artists';
this._saveScrollPosition(this._activeHash);
this._activeHash = nextHash;
this._navigateFromHash({ fromHash: true, restoreScroll: true });
2026-05-26 12:55:11 +03:00
});
// Navigate to initial hash (if any)
2026-05-27 00:28:39 +03:00
this._navigateFromHash({ fromHash: true, restoreScroll: true });
2026-05-26 12:55:11 +03:00
},
_setHash(hash) {
this._hashNav = true;
2026-05-27 00:28:39 +03:00
this._activeHash = hash;
2026-05-26 12:55:11 +03:00
location.hash = hash;
// Reset guard after a tick
setTimeout(() => { this._hashNav = false; }, 0);
},
2026-05-27 00:28:39 +03:00
_navigateFromHash(options = {}) {
2026-05-26 12:55:11 +03:00
if (this._hashNav) return;
const hash = location.hash || '#artists';
const match = hash.match(/^#(\w+)(?:\/([-]?\d+))?(?:\?(.*))?$/);
if (!match) {
2026-05-27 00:28:39 +03:00
this.goArtists(options);
2026-05-26 12:55:11 +03:00
return;
}
const view = match[1];
const id = match[2] ? parseInt(match[2], 10) : null;
const params = match[3] || '';
if (view === 'artists' && !id) {
2026-05-27 00:28:39 +03:00
if (this.view !== 'artists') this.goArtists(options);
else if (options.restoreScroll) this._restoreScrollPosition(hash);
2026-05-26 12:55:11 +03:00
} else if (view === 'artist' && id) {
2026-05-27 00:28:39 +03:00
this.openArtist(id, options);
2026-05-26 12:55:11 +03:00
} else if (view === 'release' && id) {
2026-05-27 00:28:39 +03:00
this.openRelease(id, options);
2026-05-26 12:55:11 +03:00
} else if (view === 'playlist' && id) {
2026-05-27 00:28:39 +03:00
this.openPlaylist(id, options);
2026-05-26 12:55:11 +03:00
} else if (view === 'search') {
const qMatch = params.match(/q=([^&]*)/);
if (qMatch) {
const q = decodeURIComponent(qMatch[1]);
this.searchQuery = q;
2026-05-27 00:28:39 +03:00
this.search(q, options);
2026-05-26 12:55:11 +03:00
}
} else {
2026-05-27 00:28:39 +03:00
this.goArtists(options);
}
},
_scrollElement() {
return document.getElementById('center-scroll');
},
_saveScrollPosition(hash = this._activeHash) {
const el = this._scrollElement();
if (!el || !hash) return;
this._scrollPositions[hash] = el.scrollTop;
},
_scrollToTop() {
const el = this._scrollElement();
if (el) el.scrollTop = 0;
},
_restoreScrollPosition(hash = this._activeHash) {
const top = this._scrollPositions[hash];
if (top == null) return;
const restore = () => {
const el = this._scrollElement();
if (el) el.scrollTop = top;
};
this.$nextTick(() => {
restore();
requestAnimationFrame(restore);
setTimeout(restore, 150);
});
},
_afterNavigation(options = {}) {
if (options.restoreScroll) {
this._restoreScrollPosition(this._activeHash);
} else {
this.$nextTick(() => { this._scrollToTop(); });
}
},
_beginNavigation(hash, options = {}) {
if (!options.fromHash) {
this._saveScrollPosition(this._activeHash);
this._setHash(hash);
} else {
this._activeHash = hash;
2026-05-26 12:55:11 +03:00
}
},
2026-05-27 00:28:39 +03:00
goArtists(options = {}) {
this._beginNavigation('#artists', options);
2026-05-26 12:55:11 +03:00
this.view = 'artists';
this.currentArtist = null;
this.currentRelease = null;
this.currentPlaylist = null;
this.searchQuery = '';
this.searchResults = null;
this._previousView = 'artists';
this.$nextTick(() => { this._setupScroll(); });
2026-05-27 00:28:39 +03:00
this._afterNavigation(options);
2026-05-26 12:55:11 +03:00
},
async loadArtists(page) {
if (this.loading || this._allLoaded) return;
this.loading = true;
try {
const res = await fetch(`/api/player/artists?page=${page}&limit=60`);
if (!res.ok) throw new Error('failed');
const data = await res.json();
if (page === 1) {
this.artists = data.items;
} else {
this.artists = [...this.artists, ...data.items];
}
this.artistsPage = data.page;
this.artistsTotal = data.total;
if (this.artists.length >= data.total) {
this._allLoaded = true;
}
} catch {}
this.loading = false;
},
2026-05-27 00:28:39 +03:00
async openArtist(id, options = {}) {
this._beginNavigation('#artist/' + id, options);
2026-05-26 12:55:11 +03:00
this.searchQuery = '';
this.searchResults = null;
this.view = 'artist_detail';
this.currentArtist = null;
try {
const res = await fetch(`/api/player/artists/${id}`);
if (!res.ok) throw new Error('failed');
this.currentArtist = await res.json();
} catch {}
2026-05-27 00:28:39 +03:00
this._afterNavigation(options);
2026-05-26 12:55:11 +03:00
},
artistReleaseGroups() {
const releases = this.currentArtist?.releases || [];
const order = ['album', 'ep', 'single', 'compilation', 'mixtape', 'live', 'soundtrack'];
const labels = {
2026-05-26 14:47:10 +03:00
album: T.albums,
ep: T.eps,
single: T.singles,
compilation: T.compilations,
mixtape: T.mixtapes,
live: T.liveReleases,
soundtrack: T.soundtracks,
2026-05-26 12:55:11 +03:00
};
const groups = new Map();
for (const release of releases) {
const type = (release.release_type || 'other').toLowerCase();
if (!groups.has(type)) {
groups.set(type, []);
}
groups.get(type).push(release);
}
return Array.from(groups.entries())
.sort(([a], [b]) => {
const ai = order.includes(a) ? order.indexOf(a) : order.length;
const bi = order.includes(b) ? order.indexOf(b) : order.length;
return ai === bi ? a.localeCompare(b) : ai - bi;
})
.map(([type, groupReleases]) => ({
type,
label: labels[type] || type,
releases: groupReleases,
}));
},
trackArtistLinks(track) {
const main = (track?.artists || []).map(artist => ({
id: artist.id,
label: artist.name,
}));
const featured = (track?.featured_artists || []).map(artist => ({
id: artist.id,
label: 'ft. ' + artist.name,
}));
return [...main, ...featured];
},
bytes(value) {
2026-05-26 14:47:10 +03:00
if (!value) return T.unknownSize;
2026-05-26 12:55:11 +03:00
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
let size = Number(value);
let idx = 0;
while (size >= 1024 && idx < units.length - 1) {
size /= 1024;
idx++;
}
return (idx === 0 ? size.toFixed(0) : size.toFixed(1)) + ' ' + units[idx];
},
2026-05-27 12:55:31 +03:00
trackPopularityValue(track) {
const value = Number(track?.lastfm_rating);
return Number.isFinite(value) && value > 0 ? value : null;
},
hasPopularity(track) {
return this.trackPopularityValue(track) != null;
},
popularityLabel(track) {
const value = this.trackPopularityValue(track);
if (value == null) return 'i';
if (value >= 10000) return Math.round(value / 1000) + 'k';
if (value >= 1000) return (value / 1000).toFixed(1).replace(/\.0$/, '') + 'k';
return Math.round(value).toString();
},
popularityStyle(track) {
const value = this.trackPopularityValue(track);
if (value == null) return '';
const t = Math.max(0, Math.min(1, Math.log1p(value) / Math.log1p(180)));
const hue = 210 - (190 * t);
const saturation = 42 + (46 * t);
const lightness = 30 + (16 * t);
const bg = `hsla(${hue.toFixed(0)}, ${saturation.toFixed(0)}%, ${lightness.toFixed(0)}%, 0.28)`;
const hoverBg = `hsla(${hue.toFixed(0)}, ${saturation.toFixed(0)}%, ${Math.min(54, lightness + 6).toFixed(0)}%, 0.36)`;
const border = `hsla(${hue.toFixed(0)}, ${saturation.toFixed(0)}%, ${Math.min(66, lightness + 16).toFixed(0)}%, 0.52)`;
const fg = `hsl(${hue.toFixed(0)}, ${Math.min(96, saturation + 8).toFixed(0)}%, ${Math.min(86, lightness + 34).toFixed(0)}%)`;
return `--popularity-bg:${bg};--popularity-hover-bg:${hoverBg};--popularity-border:${border};--popularity-fg:${fg}`;
},
trackInfoTitle(track) {
const value = this.trackPopularityValue(track);
if (value == null) return this.trackInfo(track);
return `${T.lastfmRating}: ${Math.round(value)}\n${this.trackInfo(track)}`;
},
openTrackInfo(track) {
Alpine.store('info').openRows(T.trackInfoTitle, this.trackInfoRows(track));
2026-05-27 12:55:31 +03:00
},
2026-05-26 12:55:11 +03:00
uploadersInfo(uploaders) {
const rows = uploaders || [];
if (!rows.length) return 'UFO';
return rows
2026-05-26 14:47:10 +03:00
.map(row => `${row.name || 'UFO'} (${row.track_count} ${T.trackWord})`)
2026-05-26 12:55:11 +03:00
.join(', ');
},
releaseInfo(release) {
if (!release) return '';
const lines = [
2026-05-26 14:47:10 +03:00
release.title || T.unknownRelease,
`${T.type}: ${release.release_type || T.unknown}`,
`${T.year}: ${release.year || T.unknown}`,
`${T.tracks}: ${release.track_count || release.tracks?.length || 0}`,
`${T.uploaders}: ${this.uploadersInfo(release.uploaders || [])}`,
2026-05-26 12:55:11 +03:00
];
return lines.join('\n');
},
openReleaseInfo(release) {
Alpine.store('info').openRows(T.releaseInfoTitle, this.releaseInfoRows(release));
},
infoLinks(items, type) {
const seen = new Set();
return (items || [])
.filter(item => item && item.id && !seen.has(Number(item.id)) && seen.add(Number(item.id)))
.map(item => ({ type, id: item.id, label: item.label || item.name || item.title || String(item.id) }));
},
releaseInfoRows(release) {
if (!release) return [];
const rows = [
{ label: T.release, value: release.title || T.unknownRelease },
{ label: T.type, value: release.release_type || T.unknown },
{ label: T.year, value: release.year || T.unknown },
{ label: T.tracks, value: release.track_count || release.tracks?.length || 0 },
{ label: T.uploaders, value: this.uploadersInfo(release.uploaders || []) },
];
const artistLinks = this.infoLinks(release.artists || [], 'artist');
if (artistLinks.length) rows.splice(1, 0, { label: T.artists, links: artistLinks });
return rows;
},
2026-05-26 12:55:11 +03:00
trackInfo(track) {
if (!track) return '';
2026-05-26 14:47:10 +03:00
const artists = this.trackArtistLinks(track).map(artist => artist.label).join(', ') || T.unknown;
2026-05-26 12:55:11 +03:00
const audio = [
track.audio_format || null,
track.audio_bitrate ? `${track.audio_bitrate} kbps` : null,
track.audio_sample_rate ? `${track.audio_sample_rate} Hz` : null,
track.audio_bit_depth ? `${track.audio_bit_depth}-bit` : null,
2026-05-26 14:47:10 +03:00
].filter(Boolean).join(' · ') || T.unknownAudio;
2026-05-26 12:55:11 +03:00
const lines = [
2026-05-26 14:47:10 +03:00
track.title || T.unknownTrack,
`${T.artists}: ${artists}`,
`${T.releaseYear}: ${track.release_year || T.unknown}`,
`${T.duration}: ${formatTime(track.duration_seconds)}`,
`${T.audio}: ${audio}`,
`${T.size}: ${this.bytes(track.file_size_bytes)}`,
`${T.uploader}: ${track.uploader_name || 'UFO'}`,
2026-05-26 12:55:11 +03:00
];
2026-05-26 18:16:34 +03:00
if (track.lastfm_rating != null || track.lastfm_listeners != null || track.lastfm_playcount != null) {
const rating = Number(track.lastfm_rating || 0);
2026-05-27 12:55:31 +03:00
lines.push(`${T.lastfmRating}: ${Number.isFinite(rating) ? Math.round(rating) : T.unknown}`);
2026-05-26 18:16:34 +03:00
lines.push(`${T.lastfmListeners}: ${new Intl.NumberFormat().format(track.lastfm_listeners || 0)}`);
lines.push(`${T.lastfmPlaycount}: ${new Intl.NumberFormat().format(track.lastfm_playcount || 0)}`);
if (track.lastfm_updated_at) lines.push(`${T.lastfmUpdated}: ${track.lastfm_updated_at}`);
} else {
lines.push(`${T.lastfmRating}: ${T.lastfmNotLoaded}`);
}
2026-05-26 12:55:11 +03:00
return lines.join('\n');
},
trackInfoRows(track) {
if (!track) return [];
const artistLinks = this.infoLinks(this.trackArtistLinks(track), 'artist');
const releaseLinks = track.release_id
? [{ type: 'release', id: track.release_id, label: track.release_title || T.unknownRelease }]
: [];
const audio = [
track.audio_format || null,
track.audio_bitrate ? `${track.audio_bitrate} kbps` : null,
track.audio_sample_rate ? `${track.audio_sample_rate} Hz` : null,
track.audio_bit_depth ? `${track.audio_bit_depth}-bit` : null,
].filter(Boolean).join(' · ') || T.unknownAudio;
const rows = [
{ label: T.title, value: track.title || T.unknownTrack },
{ label: T.release, links: releaseLinks },
{ label: T.artists, links: artistLinks },
{ label: T.releaseYear, value: track.release_year || T.unknown },
{ label: T.duration, value: formatTime(track.duration_seconds) },
{ label: T.audio, value: audio },
{ label: T.size, value: this.bytes(track.file_size_bytes) },
{ label: T.uploader, value: track.uploader_name || 'UFO' },
];
if (track.lastfm_rating != null || track.lastfm_listeners != null || track.lastfm_playcount != null) {
const rating = Number(track.lastfm_rating || 0);
rows.push({ label: T.lastfmRating, value: Number.isFinite(rating) ? Math.round(rating) : T.unknown });
rows.push({ label: T.lastfmListeners, value: new Intl.NumberFormat().format(track.lastfm_listeners || 0) });
rows.push({ label: T.lastfmPlaycount, value: new Intl.NumberFormat().format(track.lastfm_playcount || 0) });
if (track.lastfm_updated_at) rows.push({ label: T.lastfmUpdated, value: track.lastfm_updated_at });
} else {
rows.push({ label: T.lastfmRating, value: T.lastfmNotLoaded });
}
return rows;
},
2026-05-27 00:28:39 +03:00
async openRelease(id, options = {}) {
this._beginNavigation('#release/' + id, options);
2026-05-26 12:55:11 +03:00
this.searchQuery = '';
this.searchResults = null;
this.view = 'release_detail';
this.currentRelease = null;
try {
const res = await fetch(`/api/player/releases/${id}`);
if (!res.ok) throw new Error('failed');
this.currentRelease = await res.json();
} catch {}
2026-05-27 00:28:39 +03:00
this._afterNavigation(options);
2026-05-26 12:55:11 +03:00
},
2026-05-27 00:28:39 +03:00
async openPlaylist(id, options = {}) {
this._beginNavigation('#playlist/' + id, options);
2026-05-26 12:55:11 +03:00
this.view = 'playlist_detail';
this.currentPlaylist = null;
try {
const res = await fetch(`/api/player/playlists/${id}`);
if (!res.ok) throw new Error('failed');
this.currentPlaylist = await res.json();
} catch {}
2026-05-27 00:28:39 +03:00
this._afterNavigation(options);
2026-05-26 12:55:11 +03:00
},
async playRelease(releaseId) {
try {
const res = await fetch(`/api/player/releases/${releaseId}`);
if (!res.ok) return;
const release = await res.json();
if (release.tracks.length > 0) {
Alpine.store('queue').playRelease(release.tracks, 0);
}
} catch {}
},
async enqueueRelease(releaseId) {
try {
const res = await fetch(`/api/player/releases/${releaseId}`);
if (!res.ok) return;
const release = await res.json();
if (release.tracks.length > 0) {
Alpine.store('queue').addToEnd(release.tracks);
}
} catch {}
},
async enqueueReleaseNext(releaseId) {
try {
const res = await fetch(`/api/player/releases/${releaseId}`);
if (!res.ok) return;
const release = await res.json();
if (release.tracks.length > 0) {
Alpine.store('queue').addNextInQueue(release.tracks);
}
} catch {}
},
2026-05-27 00:28:39 +03:00
async search(query, options = {}) {
2026-05-26 12:55:11 +03:00
const q = (query || '').trim();
if (!q) {
this.clearSearch();
return;
}
2026-05-27 00:28:39 +03:00
this._beginNavigation('#search?q=' + encodeURIComponent(q), options);
2026-05-26 12:55:11 +03:00
if (this.view !== 'search') {
this._previousView = this.view;
}
this.view = 'search';
this.searchLoading = true;
try {
const res = await fetch(`/api/player/search?q=${encodeURIComponent(q)}&limit=10`);
if (!res.ok) throw new Error('failed');
this.searchResults = await res.json();
} catch {
this.searchResults = { artists: [], releases: [], tracks: [] };
}
this.searchLoading = false;
2026-05-27 00:28:39 +03:00
this._afterNavigation(options);
2026-05-26 12:55:11 +03:00
},
clearSearch() {
this.searchQuery = '';
this.searchResults = null;
this.searchLoading = false;
if (this.view === 'search') {
this.view = this._previousView || 'artists';
this._setHash('#artists');
if (this.view === 'artists') {
this.$nextTick(() => { this._setupScroll(); });
}
}
},
playSearchTrack(idx) {
if (!this.searchResults || !this.searchResults.tracks) return;
Alpine.store('queue').playRelease(this.searchResults.tracks, idx);
},
_setupScroll() {
if (this._observer) this._observer.disconnect();
this.$nextTick(() => {
const sentinel = document.getElementById('artist-sentinel');
if (!sentinel) return;
this._observer = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting && !this.loading && !this._allLoaded) {
this.loadArtists(this.artistsPage + 1);
}
}, { root: document.getElementById('center-scroll'), threshold: 0.1 });
this._observer.observe(sentinel);
});
},
$nextTick(fn) {
setTimeout(fn, 50);
},
});
// -----------------------------------------------------------------------
// Likes store
// -----------------------------------------------------------------------
Alpine.store('likes', {
_set: new Set(),
init() {
fetch('/api/player/likes')
.then(r => r.json())
.then(d => { this._set = new Set(d.track_ids || []); })
.catch(() => {});
},
has(trackId) {
return this._set.has(trackId);
},
async toggle(trackId) {
// Optimistic update
if (this._set.has(trackId)) {
this._set.delete(trackId);
} else {
this._set.add(trackId);
}
// Force Alpine reactivity
this._set = new Set(this._set);
try {
const res = await fetch(`/api/player/likes/toggle/${trackId}`, { method: 'POST' });
if (res.ok) {
const data = await res.json();
if (data.liked) {
this._set.add(trackId);
} else {
this._set.delete(trackId);
}
this._set = new Set(this._set);
Alpine.store('playlists').reload();
}
} catch {}
},
async toggleRelease(releaseId) {
try {
const res = await fetch(`/api/player/likes/release/${releaseId}`, { method: 'POST' });
if (res.ok) {
// Reload liked IDs
const likesRes = await fetch('/api/player/likes');
if (likesRes.ok) {
const d = await likesRes.json();
this._set = new Set(d.track_ids || []);
}
Alpine.store('playlists').reload();
}
} catch {}
},
isReleaseLiked(release) {
if (!release || !release.tracks || release.tracks.length === 0) return false;
return release.tracks.every(t => this._set.has(t.id));
},
});
// -----------------------------------------------------------------------
// Artist follows store
// -----------------------------------------------------------------------
Alpine.store('follows', {
_set: new Set(),
artists: [],
init() {
this.reload();
},
has(artistId) {
return this._set.has(Number(artistId));
},
async reload() {
try {
const res = await fetch('/api/player/follows');
if (!res.ok) return;
const data = await res.json();
this._set = new Set((data.artist_ids || []).map(Number));
this.artists = data.artists || [];
} catch {}
},
_artistSnapshot(artistId) {
const id = Number(artistId);
const library = Alpine.store('library');
const fromLists = [
...(library.artists || []),
...((library.searchResults && library.searchResults.artists) || []),
].find(artist => Number(artist.id) === id);
if (fromLists) return fromLists;
if (library.currentArtist && Number(library.currentArtist.id) === id) {
return {
id,
name: library.currentArtist.name,
image_url: library.currentArtist.image_url,
release_count: (library.currentArtist.releases || []).length,
track_count: library.currentArtist.total_track_count || 0,
};
}
return null;
},
async toggle(artistId) {
const id = Number(artistId);
if (!id) return;
if (this._set.has(id)) {
this._set.delete(id);
this.artists = this.artists.filter(artist => Number(artist.id) !== id);
} else {
this._set.add(id);
const snapshot = this._artistSnapshot(id);
if (snapshot && !this.artists.some(artist => Number(artist.id) === id)) {
this.artists = [snapshot, ...this.artists];
}
}
this._set = new Set(this._set);
try {
const res = await fetch(`/api/player/follows/toggle/${id}`, { method: 'POST' });
if (res.ok) {
const data = await res.json();
if (data.followed) this._set.add(id);
else this._set.delete(id);
this._set = new Set(this._set);
}
} catch {}
await this.reload();
},
});
// -----------------------------------------------------------------------
// Torrent import store
// -----------------------------------------------------------------------
Alpine.store('torrents', {
modal: false,
file: null,
2026-05-26 16:59:36 +03:00
localFiles: [],
2026-05-26 12:55:11 +03:00
magnet: '',
sessions: [],
loadingSessions: false,
currentJob: null,
previewData: null,
2026-05-26 16:21:21 +03:00
workspaceMode: 'empty',
2026-05-26 12:55:11 +03:00
treeRoot: null,
selected: new Set(),
expanded: new Set(),
loading: false,
message: '',
error: false,
_pollTimer: null,
2026-05-26 16:21:21 +03:00
_pollJobId: null,
2026-05-26 14:47:10 +03:00
_refreshTimer: null,
queuedTasks: 0,
processingTasks: 0,
loadingAgentStatus: false,
2026-05-26 16:59:36 +03:00
uploadProgress: 0,
uploadProgressText: '',
2026-05-28 17:34:37 +03:00
activeTab: 'import',
uploadTracks: [],
uploadReleases: [],
uploadPending: [],
uploadQueued: [],
uploadPendingTotal: 0,
uploadQueuedTotal: 0,
uploadLoaded: false,
uploadLoading: false,
uploadEditId: null,
uploadSavingId: null,
uploadDraft: null,
uploadReviewEditId: null,
uploadReviewSavingId: null,
uploadReviewDraft: null,
uploadReleaseEditId: null,
uploadReleaseSavingId: null,
uploadReleaseDraft: null,
selectedUploadTracks: new Set(),
expandedUploadReleases: new Set(),
uploadBulkSaving: false,
uploadBulkDraft: {
artists: '',
featured_artists: '',
release_title: '',
release_type: '',
release_year: '',
hidden: '',
},
2026-05-26 12:55:11 +03:00
open() {
this.modal = true;
this.message = '';
this.error = false;
this.loadSessions();
2026-05-26 14:47:10 +03:00
this.loadAgentStatus();
2026-05-28 17:34:37 +03:00
if (this.activeTab === 'uploads') this.loadUploads();
2026-05-26 14:47:10 +03:00
this._startRefresh();
2026-05-26 12:55:11 +03:00
},
close() {
this.modal = false;
2026-05-26 14:47:10 +03:00
this._stopRefresh();
2026-05-26 16:21:21 +03:00
this._stopPoll();
},
_stopPoll() {
if (this._pollTimer) clearInterval(this._pollTimer);
this._pollTimer = null;
this._pollJobId = null;
},
isImporting() {
return this.workspaceMode === 'new';
},
2026-05-28 17:34:37 +03:00
showImportTab() {
this.activeTab = 'import';
this._setMessage('');
},
showUploadsTab() {
this.activeTab = 'uploads';
this._stopPoll();
this._setMessage('');
this.loadUploads();
},
2026-05-26 16:21:21 +03:00
addNew() {
if (this.loading) return;
this._stopPoll();
this.workspaceMode = 'new';
this.file = null;
2026-05-26 16:59:36 +03:00
this.localFiles = [];
2026-05-26 16:21:21 +03:00
this.magnet = '';
2026-05-26 16:59:36 +03:00
this.uploadProgress = 0;
this.uploadProgressText = '';
2026-05-26 16:21:21 +03:00
this.currentJob = null;
this.previewData = null;
this.treeRoot = null;
this.selected = new Set();
this.expanded = new Set();
this._setMessage(T.chooseTorrent);
2026-05-26 12:55:11 +03:00
},
_setMessage(message, error = false) {
this.message = message || '';
this.error = error;
},
bytes(value) {
if (!value) return '0 B';
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
let size = Number(value);
let idx = 0;
while (size >= 1024 && idx < units.length - 1) {
size /= 1024;
idx++;
}
return (idx === 0 ? size.toFixed(0) : size.toFixed(1)) + ' ' + units[idx];
},
activeCount() {
return this.sessions.filter(job => job.active || job.status === 'downloading' || job.status === 'moving').length;
},
2026-05-26 14:47:10 +03:00
isDownloading(job) {
return !!job && (job.active || job.status === 'downloading' || job.status === 'moving');
},
isCurrentDownloading() {
return this.isDownloading(this.currentJob);
},
2026-05-26 16:21:21 +03:00
isCompleted(job) {
return this.normalizedStatus(job) === 'completed';
},
isCurrentCompleted() {
return this.isCompleted(this.currentJob);
},
selectedArray() {
return [...this.selected].sort((a, b) => Number(a) - Number(b));
},
selectionMatchesJob(job) {
if (!job) return false;
const saved = Array.isArray(job.selected_files) ? job.selected_files : [];
const selected = this.selectedArray();
if (saved.length !== selected.length) return false;
const savedSet = new Set(saved.map(index => Number(index)));
return selected.every(index => savedSet.has(Number(index)));
},
hasCurrentSelectionChanges() {
return !!this.currentJob && !this.selectionMatchesJob(this.currentJob);
},
isCurrentCompletedLocked() {
return this.isCurrentCompleted() && !this.hasCurrentSelectionChanges();
},
2026-05-26 14:47:10 +03:00
normalizedStatus(job) {
const status = String(job?.status || 'preview').toLowerCase();
if (status === 'complete') return 'completed';
return status;
},
statusLabel(job) {
const labels = {
preview: T.preview,
2026-05-26 16:59:36 +03:00
resolving: T.resolving,
2026-05-26 14:47:10 +03:00
downloading: T.downloading,
moving: T.moving,
completed: T.completed,
failed: T.failed,
paused: T.paused,
};
const status = this.normalizedStatus(job);
return labels[status] || status;
},
statusBadgeClass(job) {
return 'status-' + this.normalizedStatus(job);
},
progressValue(job) {
if (!job) return 0;
if (this.normalizedStatus(job) === 'completed') return 100;
return Math.max(0, Math.min(100, Number(job.progress_percent || 0)));
},
2026-05-26 12:55:11 +03:00
clientSummary() {
const active = this.activeCount();
2026-05-26 14:47:10 +03:00
return active > 0 ? active + ' ' + T.active : T.clientIdle;
},
agentSummary() {
const queued = Number(this.queuedTasks || 0);
const processing = Number(this.processingTasks || 0);
if (queued === 0 && processing === 0) return T.aiIdle;
const parts = [];
if (processing > 0) parts.push(processing + ' ' + T.processing);
parts.push(queued + ' ' + T.queued);
return T.aiPrefix + ' ' + parts.join(' / ');
},
agentBusy() {
return Number(this.queuedTasks || 0) > 0 || Number(this.processingTasks || 0) > 0;
2026-05-26 12:55:11 +03:00
},
statusText(job) {
2026-05-26 14:47:10 +03:00
if (!job) return T.noTorrentSelected;
2026-05-26 12:55:11 +03:00
const state = job.client_state ? ' / ' + job.client_state : '';
2026-05-26 14:47:10 +03:00
return this.statusLabel(job) + state;
2026-05-26 12:55:11 +03:00
},
speedText(job) {
if (!job) return '0 B/s';
const down = Number(job.download_speed_mbps || 0);
2026-05-26 16:21:21 +03:00
return down.toFixed(2) + ' MiB/s';
2026-05-26 12:55:11 +03:00
},
peerText(job) {
2026-05-26 16:21:21 +03:00
if (!job) return 'n/a';
2026-05-26 12:55:11 +03:00
const live = job.peers_live == null ? '?' : job.peers_live;
const seen = job.peers_seen == null ? '?' : job.peers_seen;
2026-05-26 16:21:21 +03:00
return live + ' ' + T.live + ' / ' + seen + ' ' + T.seen;
},
etaText(job) {
return job && job.eta ? job.eta : '';
},
progressDetailText(job) {
if (!job) return '';
const size = this.bytes(job.selected_size || job.total_size);
if (this.isCompleted(job)) return size;
return this.bytes(job.downloaded_bytes) + ' / ' + size;
},
actionButtonClass() {
if (this.isCurrentCompletedLocked()) return 'modal-btn-ghost';
return this.isCurrentDownloading() ? 'modal-btn-pause' : 'modal-btn-primary';
},
actionButtonText() {
2026-05-26 16:59:36 +03:00
if (this.normalizedStatus(this.currentJob) === 'resolving') return T.resolving;
2026-05-26 16:21:21 +03:00
if (this.isCurrentCompletedLocked()) return T.completed;
return this.isCurrentDownloading() ? T.pauseDownload : T.downloadSelected;
},
actionButtonDisabled() {
2026-05-26 16:59:36 +03:00
return this.loading
|| this.isCurrentCompletedLocked()
|| this.normalizedStatus(this.currentJob) === 'resolving'
|| !this.previewData
|| !Array.isArray(this.previewData.files)
|| this.previewData.files.length === 0;
2026-05-26 16:21:21 +03:00
},
toggleDownloadAction() {
if (this.isCurrentCompletedLocked()) return;
if (this.isCurrentDownloading()) this.pause();
else this.start();
2026-05-26 12:55:11 +03:00
},
sessionMeta(job) {
if (!job) return '';
const size = this.bytes(job.selected_size || job.total_size);
2026-05-26 14:47:10 +03:00
return this.progressValue(job).toFixed(1) + '% - ' + size;
},
async loadAgentStatus() {
this.loadingAgentStatus = true;
try {
const res = await fetch('/api/player/agent-queue');
const data = await res.json();
if (!res.ok) throw new Error(data.error || T.loadAiQueueFailed);
this.queuedTasks = Number(data.queued_count || 0);
this.processingTasks = Number(data.processing_count || 0);
} catch {
this.queuedTasks = 0;
this.processingTasks = 0;
} finally {
this.loadingAgentStatus = false;
}
},
2026-05-28 17:34:37 +03:00
async loadUploads({ silent = false, preserveEditor = true } = {}) {
if (preserveEditor && this.uploadHasEditorOpen()) return;
if (this.uploadLoading) return;
this.uploadLoading = true;
try {
const res = await fetch('/api/player/uploads/tracks');
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Failed to load uploaded tracks');
this.applyUploadPage(data);
this.uploadLoaded = true;
if (this.uploadEditId && !this.uploadTracks.some(item => item.track.id === this.uploadEditId)) {
this.cancelUploadEdit();
}
} catch (err) {
if (!silent) this._setMessage(err.message || String(err), true);
if (!this.uploadLoaded) {
this.uploadTracks = [];
this.uploadReleases = [];
this.uploadPending = [];
this.uploadQueued = [];
this.uploadPendingTotal = 0;
this.uploadQueuedTotal = 0;
}
} finally {
this.uploadLoading = false;
}
},
uploadSummary() {
const trackCount = this.uploadTracks.length;
const releaseCount = this.uploadReleases.length;
const parts = [trackCount + ' tracks', releaseCount + ' releases'];
if (this.uploadPendingTotal > 0) parts.push(this.uploadPendingTotal + ' need approval');
if (this.uploadQueuedTotal > 0) parts.push(this.uploadQueuedTotal + ' queued');
return parts.join(' / ');
},
uploadArtistsText(item) {
const track = item?.track || item;
const names = [
...((track?.artists || []).map(artist => artist.name)),
...((track?.featured_artists || []).map(artist => 'ft. ' + artist.name)),
];
return names.join(', ') || T.unknown;
},
uploadReleaseArtistsText(release) {
const names = (release?.artists || []).map(artist => artist.name);
return names.join(', ') || T.unknown;
},
uploadFeaturedArtistsText(item) {
const track = item?.track || item;
return (track?.featured_artists || []).map(artist => artist.name).join(', ');
},
compactQueuedUploads() {
return this.uploadQueued.slice(0, 6);
},
uploadHasEditorOpen() {
return !!(this.uploadEditId || this.uploadReviewEditId || this.uploadReleaseEditId);
},
uploadEditorKicker() {
if (this.uploadReviewDraft) return 'Needs approval';
if (this.uploadReleaseDraft) return 'Release metadata';
if (this.uploadDraft) return 'Track metadata';
return 'Metadata';
},
uploadEditorTitle() {
if (this.uploadReviewDraft) return 'Approve metadata';
if (this.uploadReleaseDraft) return this.uploadReleaseDraft.title || 'Edit release';
if (this.uploadDraft) return this.uploadDraft.title || 'Edit track';
return 'Edit metadata';
},
closeUploadEditor() {
this.cancelUploadEdit();
this.cancelUploadReleaseEdit();
this.cancelUploadReviewEdit();
},
uploadReleaseExpanded(releaseId) {
return this.expandedUploadReleases.has(releaseId);
},
toggleUploadRelease(releaseId) {
if (this.expandedUploadReleases.has(releaseId)) this.expandedUploadReleases.delete(releaseId);
else this.expandedUploadReleases.add(releaseId);
},
uploadReleaseTrackIds(release) {
return (release?.tracks || []).map(item => item.track.id);
},
uploadReleaseSelectionState(release) {
const ids = this.uploadReleaseTrackIds(release);
const selected = ids.filter(id => this.selectedUploadTracks.has(id)).length;
if (selected === 0) return 'empty';
return selected === ids.length ? 'checked' : 'partial';
},
toggleUploadReleaseSelection(release) {
const ids = this.uploadReleaseTrackIds(release);
const state = this.uploadReleaseSelectionState(release);
if (state === 'checked') ids.forEach(id => this.selectedUploadTracks.delete(id));
else ids.forEach(id => this.selectedUploadTracks.add(id));
},
toggleUploadTrackSelection(trackId) {
if (this.selectedUploadTracks.has(trackId)) this.selectedUploadTracks.delete(trackId);
else this.selectedUploadTracks.add(trackId);
},
uploadSelectedCount() {
return this.selectedUploadTracks.size;
},
clearUploadSelection() {
this.selectedUploadTracks.clear();
this.uploadBulkDraft = {
artists: '',
featured_artists: '',
release_title: '',
release_type: '',
release_year: '',
hidden: '',
};
},
pruneUploadSelection() {
const valid = new Set(this.uploadTracks.map(item => item.track.id));
for (const id of [...this.selectedUploadTracks]) {
if (!valid.has(id)) this.selectedUploadTracks.delete(id);
}
},
editUpload(item) {
if (!item || !item.track) return;
this.cancelUploadReleaseEdit();
this.cancelUploadReviewEdit();
this.uploadEditId = item.track.id;
this.uploadDraft = {
title: item.track.title || '',
artists: (item.track.artists || []).map(artist => artist.name).join(', '),
featured_artists: this.uploadFeaturedArtistsText(item),
release_title: item.track.release_title || '',
release_type: item.release_type || 'album',
release_year: item.track.release_year == null ? '' : String(item.track.release_year),
track_number: item.track.track_number == null ? '' : String(item.track.track_number),
disc_number: item.track.disc_number == null ? '' : String(item.track.disc_number),
is_hidden: !!item.is_hidden,
};
},
cancelUploadEdit() {
this.uploadEditId = null;
this.uploadSavingId = null;
this.uploadDraft = null;
},
async saveUploadEdit() {
if (!this.uploadEditId || !this.uploadDraft) return;
const id = this.uploadEditId;
const draft = this.uploadDraft;
const artistNames = String(draft.artists || '')
.split(',')
.map(name => name.trim())
.filter(Boolean);
this.uploadSavingId = id;
try {
const res = await fetch(`/api/player/uploads/tracks/${id}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
title: draft.title,
artist_names: artistNames,
featured_artist_names: String(draft.featured_artists || '').split(',').map(name => name.trim()).filter(Boolean),
release_title: draft.release_title,
release_type: draft.release_type,
release_year: String(draft.release_year || ''),
track_number: String(draft.track_number || ''),
disc_number: String(draft.disc_number || ''),
is_hidden: !!draft.is_hidden,
}),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Failed to save track');
this.uploadTracks = this.uploadTracks.map(item => item.track.id === id ? data : item);
this.cancelUploadEdit();
this.loadUploads({ silent: true, preserveEditor: false });
this._setMessage('Track metadata saved');
} catch (err) {
this._setMessage(err.message || String(err), true);
} finally {
this.uploadSavingId = null;
}
},
editUploadRelease(release) {
if (!release) return;
this.cancelUploadEdit();
this.cancelUploadReviewEdit();
this.uploadReleaseEditId = release.id;
this.uploadReleaseDraft = {
title: release.title || '',
artists: this.uploadReleaseArtistsText(release),
release_type: release.release_type || 'album',
year: release.year == null ? '' : String(release.year),
is_hidden: !!release.is_hidden,
};
},
cancelUploadReleaseEdit() {
this.uploadReleaseEditId = null;
this.uploadReleaseSavingId = null;
this.uploadReleaseDraft = null;
},
async saveUploadReleaseEdit() {
if (!this.uploadReleaseEditId || !this.uploadReleaseDraft) return;
const id = this.uploadReleaseEditId;
const draft = this.uploadReleaseDraft;
this.uploadReleaseSavingId = id;
try {
const res = await fetch(`/api/player/uploads/releases/${id}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
title: draft.title,
artist_names: String(draft.artists || '').split(',').map(name => name.trim()).filter(Boolean),
release_type: draft.release_type,
year: String(draft.year || ''),
is_hidden: !!draft.is_hidden,
}),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Failed to save release');
this.applyUploadPage(data);
this.cancelUploadReleaseEdit();
this._setMessage('Release metadata saved');
} catch (err) {
this._setMessage(err.message || String(err), true);
} finally {
this.uploadReleaseSavingId = null;
}
},
uploadReviewPayload() {
const draft = this.uploadReviewDraft || {};
return {
title: draft.title,
artist: draft.artist,
album: draft.album,
year: String(draft.year || ''),
track_number: String(draft.track_number || ''),
genre: draft.genre,
featured_artists: String(draft.featured_artists || '').split(',').map(name => name.trim()).filter(Boolean),
release_type: draft.release_type || 'album',
notes: draft.notes,
};
},
editUploadReview(item) {
if (!item) return;
this.cancelUploadEdit();
this.cancelUploadReleaseEdit();
this.uploadReviewEditId = item.id;
this.uploadReviewDraft = {
title: item.fields?.title || '',
artist: item.fields?.artist || '',
album: item.fields?.album || '',
year: item.fields?.year || '',
track_number: item.fields?.track_number || '',
genre: item.fields?.genre || '',
featured_artists: (item.fields?.featured_artists || []).join(', '),
release_type: item.fields?.release_type || 'album',
notes: item.fields?.notes || '',
};
},
cancelUploadReviewEdit() {
this.uploadReviewEditId = null;
this.uploadReviewSavingId = null;
this.uploadReviewDraft = null;
},
2026-05-28 23:38:07 +03:00
async deleteUploadReview() {
if (!this.uploadReviewEditId) return;
2026-05-28 17:34:37 +03:00
const id = this.uploadReviewEditId;
this.uploadReviewSavingId = id;
try {
const res = await fetch(`/api/player/uploads/reviews/${id}`, {
2026-05-28 23:38:07 +03:00
method: 'DELETE',
2026-05-28 17:34:37 +03:00
});
const data = await res.json();
2026-05-28 23:38:07 +03:00
if (!res.ok) throw new Error(data.error || 'Failed to delete review');
this.applyUploadPage(data);
this.cancelUploadReviewEdit();
this._setMessage('Review deleted');
2026-05-28 17:34:37 +03:00
} catch (err) {
this._setMessage(err.message || String(err), true);
} finally {
this.uploadReviewSavingId = null;
}
},
async approveUploadReview() {
if (!this.uploadReviewEditId || !this.uploadReviewDraft) return;
const id = this.uploadReviewEditId;
this.uploadReviewSavingId = id;
try {
const res = await fetch(`/api/player/uploads/reviews/${id}/approve`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(this.uploadReviewPayload()),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Failed to approve review');
this.applyUploadPage(data);
this.cancelUploadReviewEdit();
this._setMessage('Track approved and imported');
} catch (err) {
this._setMessage(err.message || String(err), true);
} finally {
this.uploadReviewSavingId = null;
}
},
async saveUploadBulkEdit() {
const trackIds = [...this.selectedUploadTracks];
if (trackIds.length === 0) return;
const draft = this.uploadBulkDraft;
const hidden = draft.hidden === '' ? null : draft.hidden === 'true';
const artists = String(draft.artists || '').split(',').map(name => name.trim()).filter(Boolean);
const featured = String(draft.featured_artists || '').split(',').map(name => name.trim()).filter(Boolean);
this.uploadBulkSaving = true;
try {
const res = await fetch('/api/player/uploads/bulk-tracks', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
track_ids: trackIds,
artist_names: artists.length ? artists : null,
featured_artist_names: featured.length ? featured : null,
release_title: draft.release_title || null,
release_type: draft.release_type || null,
release_year: draft.release_year === '' ? null : String(draft.release_year),
is_hidden: hidden,
}),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Failed to update selected tracks');
this.applyUploadPage(data);
this.clearUploadSelection();
this._setMessage('Selected tracks updated');
} catch (err) {
this._setMessage(err.message || String(err), true);
} finally {
this.uploadBulkSaving = false;
}
},
applyUploadPage(data) {
this.uploadTracks = Array.isArray(data.tracks) ? data.tracks : [];
this.uploadReleases = Array.isArray(data.releases) ? data.releases : [];
this.uploadPending = Array.isArray(data.pending) ? data.pending : [];
this.uploadQueued = Array.isArray(data.queued) ? data.queued : [];
this.uploadPendingTotal = Number(data.pending_total || this.uploadPending.length || 0);
this.uploadQueuedTotal = Number(data.queued_total || this.uploadQueued.length || 0);
this.pruneUploadSelection();
},
2026-05-26 14:47:10 +03:00
_startRefresh() {
this._stopRefresh();
this._refreshTimer = setInterval(() => {
if (!this.modal) return;
2026-05-28 17:34:37 +03:00
if (this.activeTab === 'uploads') {
this.loadUploads({ silent: true });
}
else this.loadSessions();
2026-05-26 14:47:10 +03:00
this.loadAgentStatus();
}, 5000);
},
_stopRefresh() {
if (this._refreshTimer) clearInterval(this._refreshTimer);
this._refreshTimer = null;
2026-05-26 12:55:11 +03:00
},
_rememberJob(job) {
if (!job || !job.id) return;
const rest = this.sessions.filter(item => item.id !== job.id);
2026-05-26 16:59:36 +03:00
this.sessions = [job, ...rest].sort((a, b) => String(b.created_at || '').localeCompare(String(a.created_at || '')));
2026-05-26 12:55:11 +03:00
if (this.currentJob && this.currentJob.id === job.id) this.currentJob = job;
},
2026-05-26 16:21:21 +03:00
_isSelectedJob(id) {
return this.workspaceMode === 'session'
&& this.currentJob
&& this.currentJob.id === id
&& this.previewData
&& this.previewData.id === id;
},
_syncCurrentJobFromSessions() {
if (!this.currentJob || !this.previewData) return;
const selected = this.sessions.find(job => job.id === this.currentJob.id && job.id === this.previewData.id);
if (selected) this.currentJob = selected;
},
2026-05-26 12:55:11 +03:00
_applySession(data) {
const preview = data.preview || data;
const job = data.job || null;
2026-05-26 16:21:21 +03:00
this.workspaceMode = 'session';
this.file = null;
2026-05-26 16:59:36 +03:00
this.localFiles = [];
2026-05-26 16:21:21 +03:00
this.magnet = '';
2026-05-26 16:59:36 +03:00
this.uploadProgress = 0;
this.uploadProgressText = '';
2026-05-26 12:55:11 +03:00
this.previewData = preview;
this.currentJob = job;
const selected = Array.isArray(data.selected_files) && data.selected_files.length
? data.selected_files
: (preview.files || []).filter(f => f.selected).map(f => f.index);
this.selected = new Set(selected);
this.treeRoot = this._buildTree(preview.files || []);
if (job) this._rememberJob(job);
},
async loadSessions() {
this.loadingSessions = true;
try {
const res = await fetch('/api/player/torrents');
const data = await res.json();
2026-05-26 14:47:10 +03:00
if (!res.ok) throw new Error(data.error || T.loadTorrentsFailed);
2026-05-26 12:55:11 +03:00
this.sessions = Array.isArray(data) ? data : [];
2026-05-26 16:21:21 +03:00
this._syncCurrentJobFromSessions();
2026-05-26 16:59:36 +03:00
await this._refreshResolvedSelection();
2026-05-26 12:55:11 +03:00
} catch (err) {
this._setMessage(err.message || String(err), true);
} finally {
this.loadingSessions = false;
}
},
2026-05-26 16:59:36 +03:00
async _refreshResolvedSelection() {
if (!this.currentJob || !this.previewData || (this.previewData.files || []).length > 0) return;
const selected = this.sessions.find(job => job.id === this.currentJob.id);
if (!selected || this.normalizedStatus(selected) === 'resolving') return;
try {
const res = await fetch(`/api/player/torrents/session/${selected.id}`);
const data = await res.json();
if (!res.ok) return;
this._applySession(data);
this._setMessage(T.allFilesSelected);
} catch {}
},
2026-05-26 12:55:11 +03:00
async openSession(id) {
if (!id || this.loading) return;
2026-05-26 16:21:21 +03:00
this._stopPoll();
2026-05-26 12:55:11 +03:00
this.loading = true;
2026-05-26 14:47:10 +03:00
this._setMessage(T.openingSavedTorrent);
2026-05-26 12:55:11 +03:00
try {
const res = await fetch(`/api/player/torrents/session/${id}`);
const data = await res.json();
2026-05-26 14:47:10 +03:00
if (!res.ok) throw new Error(data.error || T.openTorrentFailed);
2026-05-26 12:55:11 +03:00
this._applySession(data);
2026-05-26 14:47:10 +03:00
this._setMessage(T.savedTorrentOpened);
2026-05-26 12:55:11 +03:00
if (data.job && (data.job.active || data.job.status === 'downloading' || data.job.status === 'moving')) {
this._poll(data.job.id);
}
} catch (err) {
this._setMessage(err.message || String(err), true);
} finally {
this.loading = false;
}
},
async removeSession(id) {
if (!id || this.loading) return;
2026-05-26 14:47:10 +03:00
if (!confirm(T.removeTorrentConfirm)) return;
2026-05-26 12:55:11 +03:00
this.loading = true;
try {
const res = await fetch(`/api/player/torrents/session/${id}`, { method: 'DELETE' });
const data = await res.json();
2026-05-26 14:47:10 +03:00
if (!res.ok) throw new Error(data.error || T.deleteTorrentFailed);
2026-05-26 12:55:11 +03:00
this.sessions = this.sessions.filter(job => job.id !== id);
if (this.previewData && this.previewData.id === id) {
this.previewData = null;
this.currentJob = null;
this.treeRoot = null;
this.selected = new Set();
2026-05-26 16:21:21 +03:00
this.workspaceMode = this.sessions.length ? 'empty' : 'new';
2026-05-26 12:55:11 +03:00
}
2026-05-26 14:47:10 +03:00
this._setMessage(T.torrentRemoved);
2026-05-26 12:55:11 +03:00
} catch (err) {
this._setMessage(err.message || String(err), true);
} finally {
this.loading = false;
}
},
async _fileBase64(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onerror = () => reject(reader.error);
reader.onload = () => {
const result = String(reader.result || '');
resolve(result.includes(',') ? result.split(',')[1] : result);
};
reader.readAsDataURL(file);
});
},
2026-05-26 16:59:36 +03:00
setLocalFiles(files) {
this.localFiles = Array.from(files || []);
},
localUploadBytes() {
return this.localFiles.reduce((sum, file) => sum + Number(file.size || 0), 0);
},
localUploadSummary() {
const count = this.localFiles.length;
if (count === 0) return '';
return count + ' ' + T.selected + ' - ' + this.bytes(this.localUploadBytes());
},
uploadLocalFile(file, loadedBefore, totalBytes) {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open('POST', '/api/player/uploads/local');
xhr.setRequestHeader('Content-Type', file.type || 'application/octet-stream');
xhr.setRequestHeader('X-Furumusic-Filename', encodeURIComponent(file.name || 'upload.mp3'));
xhr.upload.onprogress = event => {
if (!event.lengthComputable || totalBytes <= 0) return;
const loaded = loadedBefore + event.loaded;
this.uploadProgress = Math.max(0, Math.min(100, loaded / totalBytes * 100));
this.uploadProgressText = this.uploadProgress.toFixed(1) + '%';
};
xhr.onload = () => {
let data = {};
try { data = JSON.parse(xhr.responseText || '{}'); } catch {}
if (xhr.status >= 200 && xhr.status < 300) resolve(data);
else reject(new Error(data.error || T.uploadFailed));
};
xhr.onerror = () => reject(new Error(T.uploadFailed));
xhr.send(file);
});
},
async uploadLocalFiles() {
if (this.loading || this.localFiles.length === 0) return;
this.loading = true;
this.uploadProgress = 0;
this.uploadProgressText = '0.0%';
this._setMessage(T.uploadingFiles);
const totalBytes = this.localUploadBytes();
let loadedBefore = 0;
try {
for (const file of this.localFiles) {
await this.uploadLocalFile(file, loadedBefore, totalBytes);
loadedBefore += Number(file.size || 0);
this.uploadProgress = totalBytes > 0 ? Math.min(100, loadedBefore / totalBytes * 100) : 100;
this.uploadProgressText = this.uploadProgress.toFixed(1) + '%';
}
this.localFiles = [];
this.uploadProgress = 100;
this.uploadProgressText = '100.0%';
this._setMessage(T.uploadComplete);
await this.loadAgentStatus();
} catch (err) {
this._setMessage(err.message || String(err), true);
} finally {
this.loading = false;
}
},
2026-05-26 12:55:11 +03:00
async preview() {
if (this.loading) return;
2026-05-26 16:59:36 +03:00
if (this.localFiles.length > 0) {
await this.uploadLocalFiles();
return;
}
2026-05-26 12:55:11 +03:00
const magnet = this.magnet.trim();
if (!this.file && !magnet) {
2026-05-26 14:47:10 +03:00
this._setMessage(T.chooseTorrent, true);
2026-05-26 12:55:11 +03:00
return;
}
this.loading = true;
this.previewData = null;
this.treeRoot = null;
this.currentJob = null;
2026-05-26 16:21:21 +03:00
this.workspaceMode = 'new';
2026-05-26 12:55:11 +03:00
this.selected = new Set();
this.expanded = new Set();
2026-05-26 14:47:10 +03:00
this._setMessage(this.file ? T.readingTorrent : T.resolvingMagnet);
2026-05-26 12:55:11 +03:00
try {
const payload = this.file
? { kind: 'torrent_file', torrent_base64: await this._fileBase64(this.file), source_label: this.file.name }
: { kind: 'magnet', magnet, source_label: magnet.slice(0, 120) };
const res = await fetch('/api/player/torrents/preview', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
const data = await res.json();
2026-05-26 14:47:10 +03:00
if (!res.ok) throw new Error(data.error || T.previewFailed);
2026-05-26 12:55:11 +03:00
this._applySession(data);
2026-05-26 16:59:36 +03:00
this._setMessage((data.preview?.files || []).length ? T.allFilesSelected : T.resolving);
2026-05-26 12:55:11 +03:00
await this.loadSessions();
} catch (err) {
this._setMessage(err.message || String(err), true);
} finally {
this.loading = false;
}
},
toggle(index, checked) {
const next = new Set(this.selected);
if (checked) next.add(index);
else next.delete(index);
this.selected = next;
},
selectAll(value) {
if (!this.previewData) return;
this.selected = value
? new Set(this.previewData.files.map(f => f.index))
: new Set();
},
clearSelection() {
this.selectAll(false);
},
selectedBytes() {
if (!this.previewData) return 0;
return this.previewData.files
.filter(file => this.selected.has(file.index))
.reduce((sum, file) => sum + Number(file.length || 0), 0);
},
_buildTree(files) {
const root = {
type: 'folder',
key: 'root',
name: 'root',
depth: -1,
size: 0,
fileIndexes: [],
children: [],
childMap: new Map(),
};
const ensureFolder = (parent, name, key, depth) => {
let node = parent.childMap.get(key);
if (!node) {
node = {
type: 'folder',
key,
name,
depth,
size: 0,
fileIndexes: [],
children: [],
childMap: new Map(),
};
parent.childMap.set(key, node);
parent.children.push(node);
}
return node;
};
files.forEach(file => {
const components = file.components && file.components.length
? file.components
: String(file.name || '').split('/').filter(Boolean);
const pathParts = components.length ? components : [file.name || ('file-' + file.index)];
let parent = root;
const folderChain = [root];
pathParts.slice(0, -1).forEach((part, depth) => {
const key = parent.key + '/' + part;
parent = ensureFolder(parent, part, key, depth);
folderChain.push(parent);
});
const fileNode = {
type: 'file',
key: 'file:' + file.index,
name: pathParts[pathParts.length - 1],
depth: Math.max(pathParts.length - 1, 0),
size: Number(file.length || 0),
fileIndex: file.index,
fileIndexes: [file.index],
children: [],
};
parent.children.push(fileNode);
folderChain.forEach(folder => {
folder.size += fileNode.size;
folder.fileIndexes.push(file.index);
});
});
const sortAndSeal = node => {
node.children.sort((a, b) => {
if (a.type !== b.type) return a.type === 'folder' ? -1 : 1;
return a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: 'base' });
});
node.children.forEach(sortAndSeal);
delete node.childMap;
};
sortAndSeal(root);
const initiallyExpanded = new Set();
const collectExpanded = node => {
if (node.type === 'folder' && node.depth < 1) initiallyExpanded.add(node.key);
node.children.forEach(collectExpanded);
};
collectExpanded(root);
this.expanded = initiallyExpanded;
return root;
},
visibleNodes() {
if (!this.treeRoot) return [];
const rows = [];
const visit = node => {
node.children.forEach(child => {
rows.push(child);
if (child.type === 'folder' && this.expanded.has(child.key)) {
visit(child);
}
});
};
visit(this.treeRoot);
return rows;
},
rowIndent(node) {
return Math.min(10 + Math.max(node.depth, 0) * 18, 82);
},
toggleExpand(node) {
if (node.type !== 'folder') return;
const next = new Set(this.expanded);
if (next.has(node.key)) next.delete(node.key);
else next.add(node.key);
this.expanded = next;
},
expandAll(value) {
if (!this.treeRoot) return;
const next = new Set();
const visit = node => {
if (node.type === 'folder' && value) next.add(node.key);
node.children.forEach(visit);
};
visit(this.treeRoot);
this.expanded = next;
},
nodeState(node) {
if (node.type === 'file') {
return this.selected.has(node.fileIndex) ? 'checked' : 'empty';
}
const total = node.fileIndexes.length;
const selected = node.fileIndexes.filter(index => this.selected.has(index)).length;
if (selected === 0) return 'empty';
if (selected === total) return 'checked';
return 'partial';
},
nodeCheckClass(node) {
const state = this.nodeState(node);
return {
checked: state === 'checked',
partial: state === 'partial',
};
},
toggleNode(node) {
const next = new Set(this.selected);
if (node.type === 'file') {
if (next.has(node.fileIndex)) next.delete(node.fileIndex);
else next.add(node.fileIndex);
this.selected = next;
return;
}
if (this.nodeState(node) === 'checked') {
node.fileIndexes.forEach(index => next.delete(index));
} else {
node.fileIndexes.forEach(index => next.add(index));
}
this.selected = next;
},
async start() {
if (!this.previewData || this.loading) return;
2026-05-26 16:21:21 +03:00
const selected = this.selectedArray();
2026-05-26 12:55:11 +03:00
if (selected.length === 0) {
2026-05-26 14:47:10 +03:00
this._setMessage(T.selectOneFile, true);
2026-05-26 12:55:11 +03:00
return;
}
this.loading = true;
2026-05-26 14:47:10 +03:00
this._setMessage(T.startingDownload);
2026-05-26 12:55:11 +03:00
try {
const res = await fetch(`/api/player/torrents/${this.previewData.id}/start`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ selected_files: selected }),
});
const data = await res.json();
2026-05-26 14:47:10 +03:00
if (!res.ok) throw new Error(data.error || T.startFailed);
2026-05-26 12:55:11 +03:00
this.currentJob = data;
this._rememberJob(data);
2026-05-26 14:47:10 +03:00
this._setMessage(T.downloadStarted);
2026-05-26 12:55:11 +03:00
this._poll(data.id);
await this.loadSessions();
} catch (err) {
this._setMessage(err.message || String(err), true);
} finally {
this.loading = false;
}
},
2026-05-26 14:47:10 +03:00
async pause() {
if (!this.currentJob || this.loading || !this.isCurrentDownloading()) return;
this.loading = true;
this._setMessage(T.pausingDownload);
try {
const res = await fetch(`/api/player/torrents/${this.currentJob.id}/pause`, {
method: 'POST',
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || T.pauseFailed);
this.currentJob = data;
this._rememberJob(data);
this._setMessage(T.downloadPaused);
2026-05-26 16:21:21 +03:00
this._stopPoll();
2026-05-26 14:47:10 +03:00
await this.loadSessions();
} catch (err) {
this._setMessage(err.message || String(err), true);
} finally {
this.loading = false;
}
},
2026-05-26 12:55:11 +03:00
_poll(id) {
2026-05-26 16:21:21 +03:00
this._stopPoll();
this._pollJobId = id;
2026-05-26 12:55:11 +03:00
this._pollTimer = setInterval(async () => {
try {
const res = await fetch(`/api/player/torrents/${id}/status`);
const data = await res.json();
2026-05-26 14:47:10 +03:00
if (!res.ok) throw new Error(data.error || T.statusFailed);
2026-05-26 12:55:11 +03:00
this._rememberJob(data);
2026-05-26 16:21:21 +03:00
if (this._isSelectedJob(id)) {
this.currentJob = data;
this._setMessage(
this.statusLabel(data) + ' - ' + this.progressValue(data).toFixed(1) + '% - ' + this.bytes(data.downloaded_bytes),
data.status === 'failed'
);
}
2026-05-26 12:55:11 +03:00
if (data.status === 'complete' || data.status === 'failed') {
2026-05-26 16:21:21 +03:00
this._stopPoll();
2026-05-26 12:55:11 +03:00
this.loadSessions();
2026-05-26 14:47:10 +03:00
this.loadAgentStatus();
2026-05-26 12:55:11 +03:00
}
} catch (err) {
2026-05-26 16:21:21 +03:00
if (this._isSelectedJob(id)) this._setMessage(err.message || String(err), true);
this._stopPoll();
2026-05-26 12:55:11 +03:00
}
}, 2000);
},
});
// -----------------------------------------------------------------------
// Playlists store
// -----------------------------------------------------------------------
Alpine.store('playlists', {
list: [],
modal: null, // { mode: 'create'|'rename', title: '', id?: number }
picker: null, // { trackIds: [1,2,3] }
init() {
this.reload();
},
async reload() {
try {
const res = await fetch('/api/player/playlists');
if (res.ok) this.list = await res.json();
} catch {}
},
regularList() {
return this.list.filter(pl => (
pl.kind === 'likes' ||
pl.is_own ||
!pl.is_public
));
},
publishedList() {
return this.list.filter(pl => (
pl.kind === 'user' &&
!pl.is_own &&
pl.is_public
));
},
2026-05-26 14:47:10 +03:00
displayTitle(pl) {
return pl?.kind === 'likes' ? T.likesPlaylist : (pl?.title || '');
},
2026-05-26 12:55:11 +03:00
showCreate() {
this.modal = { mode: 'create', title: '' };
},
startRename(pl) {
this.modal = { mode: 'rename', title: pl.title, id: pl.id };
},
async submitModal() {
if (!this.modal) return;
const title = this.modal.title.trim();
if (!title) return;
if (this.modal.mode === 'create') {
try {
await fetch('/api/player/playlists', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title }),
});
await this.reload();
} catch {}
} else if (this.modal.mode === 'rename') {
try {
await fetch(`/api/player/playlists/${this.modal.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title }),
});
await this.reload();
} catch {}
}
this.modal = null;
},
async deletePlaylist(id) {
2026-05-26 14:47:10 +03:00
if (!confirm(T.deletePlaylistConfirm)) return;
2026-05-26 12:55:11 +03:00
try {
await fetch(`/api/player/playlists/${id}`, { method: 'DELETE' });
await this.reload();
} catch {}
},
showPicker(trackIds) {
this.picker = { trackIds };
},
async addToPicked(playlistId) {
if (!this.picker) return;
try {
await fetch(`/api/player/playlists/${playlistId}/tracks`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ track_ids: this.picker.trackIds }),
});
await this.reload();
} catch {}
this.picker = null;
},
});
});
function installConnectionFetchMonitor() {
if (window.__furumusicConnectionMonitorInstalled || !window.fetch) return;
window.__furumusicConnectionMonitorInstalled = true;
const nativeFetch = window.fetch.bind(window);
window.fetch = async (...args) => {
const tracked = isTrackedPlayerRequest(args[0]);
try {
const response = await nativeFetch(...args);
if (tracked) {
if (response.status >= 500) {
Alpine.store('connection')?.recordFailure();
} else {
Alpine.store('connection')?.recordSuccess();
}
}
return response;
} catch (error) {
if (tracked) Alpine.store('connection')?.recordFailure();
throw error;
}
};
}
function isTrackedPlayerRequest(input) {
const rawUrl = typeof input === 'string' ? input : input?.url;
if (!rawUrl) return false;
try {
const url = new URL(rawUrl, window.location.href);
return url.origin === window.location.origin && url.pathname.startsWith('/api/player/');
} catch {
return false;
}
}
2026-05-26 12:55:11 +03:00
</script>