Files
furumusic/templates/player/scripts.html
T

5905 lines
240 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3/dist/cdn.min.js"></script>
<script>
const T = {
info: "{{ t.player_info }}",
noDetails: "{{ t.player_no_details }}",
trackInfoTitle: "{{ t.player_track_info }}",
releaseInfoTitle: "{{ t.player_release_info }}",
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 }}",
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 }}",
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 }}",
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 }}",
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 }}",
lastfmDisconnectConfirm: "{{ t.player_lastfm_disconnect_confirm }}",
lastfmConnectFailed: "{{ t.player_lastfm_connect_failed }}",
lastfmDisconnectFailed: "{{ t.player_lastfm_disconnect_failed }}",
startRadio: "{{ t.player_start_radio }}",
radioFailed: "{{ t.player_radio_failed }}",
share: "{{ t.player_share }}",
shareTrack: "{{ t.player_share_track }}",
shareQueue: "{{ t.player_share_queue }}",
sharedPlaylist: "{{ t.player_shared_playlist }}",
connectionLost: "{{ t.player_connection_lost }}",
connectionLostDetail: "{{ t.player_connection_lost_detail }}",
activeDevice: "{{ t.player_active_device }}",
trackWord: "{{ t.player_tracks_count }}",
releaseWord: "{{ t.player_releases_count }}",
ofWord: "{{ t.player_of }}",
needsApproval: "{{ t.player_needs_approval }}",
showing: "{{ t.player_showing }}",
statusLabelText: "{{ t.player_status }}",
fileLabel: "{{ t.player_file }}",
createdLabel: "{{ t.player_created }}",
updatedLabel: "{{ t.player_updated }}",
errorLabel: "{{ t.player_error }}",
pending: "{{ t.player_pending }}",
featuredShort: "{{ t.player_featured_short }}",
noYear: "{{ t.player_no_year }}",
loadUploadsFailed: "{{ t.player_failed_load_uploaded_tracks }}",
releaseMetadata: "{{ t.player_release_metadata }}",
trackMetadata: "{{ t.player_track_metadata }}",
metadata: "{{ t.player_metadata }}",
approveMetadata: "{{ t.player_approve_metadata }}",
editRelease: "{{ t.player_edit_release }}",
editTrack: "{{ t.player_edit_track }}",
editMetadata: "{{ t.player_edit_metadata }}",
failedSaveTrack: "{{ t.player_failed_save_track }}",
trackMetadataSaved: "{{ t.player_track_metadata_saved }}",
failedSaveRelease: "{{ t.player_failed_save_release }}",
releaseMetadataSaved: "{{ t.player_release_metadata_saved }}",
failedDeleteReview: "{{ t.player_failed_delete_review }}",
reviewDeleted: "{{ t.player_review_deleted }}",
failedApproveReview: "{{ t.player_failed_approve_review }}",
trackApprovedImported: "{{ t.player_track_approved_imported }}",
failedUpdateSelectedTracks: "{{ t.player_failed_update_selected_tracks }}",
selectedTracksUpdated: "{{ t.player_selected_tracks_updated }}",
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 }}",
chooseSavedOrAddTorrent: "{{ t.player_choose_saved_or_add_torrent }}",
uploadFailed: "{{ t.player_upload_failed }}",
uploadComplete: "{{ t.player_upload_complete }}",
uploadingFiles: "{{ t.player_uploading_files }}",
preview: "{{ t.player_preview }}",
resolving: "{{ t.player_resolving }}",
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 }}",
downloaded: "{{ t.player_downloaded }}",
speed: "{{ t.player_speed }}",
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 }}",
downloadSelected: "{{ t.player_download_selected }}",
pauseDownload: "{{ t.player_pause_download }}",
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 }}",
};
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;
}
function coverVariantUrl(url, variant) {
if (!url) return url;
return url.replace(/\/(small|medium|large)$/, '/' + variant);
}
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();
// -----------------------------------------------------------------------
// Audio element
// -----------------------------------------------------------------------
const audio = new Audio();
audio.preload = 'auto';
Alpine.store('mobile', {
libraryOpen: false,
playerExpanded: false,
toggleLibrary() {
this.libraryOpen = !this.libraryOpen;
if (this.libraryOpen) Alpine.store('user').menuOpen = false;
},
closeLibrary() {
this.libraryOpen = false;
},
isMobilePlayer() {
if (!window.matchMedia) return false;
return window.matchMedia('(max-width: 900px)').matches
|| window.matchMedia('(pointer: coarse) and (max-width: 1024px)').matches;
},
openPlayerFullscreen() {
if (!this.isMobilePlayer() || !Alpine.store('player').currentTrack) return;
this.playerExpanded = true;
Alpine.store('queue').visible = false;
Alpine.store('devices').open = false;
this.resetPlayerFullscreenScroll();
},
closePlayerFullscreen() {
this.playerExpanded = false;
Alpine.store('queue').visible = false;
Alpine.store('devices').open = false;
},
resetPlayerFullscreenScroll() {
requestAnimationFrame(() => {
const full = document.querySelector('.player-bar .mobile-full-player');
if (full) {
full.scrollTop = 0;
full.scrollLeft = 0;
}
});
},
});
Alpine.store('info', {
modal: null,
open(title, body, actions = []) {
if (Array.isArray(body)) {
this.openRows(title, body, actions);
return;
}
this.modal = {
title: title || T.info,
body: body || T.noDetails,
rows: null,
actions: actions || [],
};
},
openRows(title, rows, actions = []) {
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))),
actions: actions || [],
};
},
close() {
this.modal = null;
},
async runAction(actionOrIndex) {
const index = Number.isInteger(actionOrIndex) ? actionOrIndex : this.modal?.actions?.indexOf(actionOrIndex);
const action = index >= 0 ? this.modal?.actions?.[index] : actionOrIndex;
if (!action || action.busy === true || typeof action.run !== 'function') return;
if (index >= 0 && this.modal?.actions) {
this.modal.actions[index] = { ...action, busy: true };
this.modal.actions = [...this.modal.actions];
}
try {
await action.run();
} finally {
if (index >= 0 && this.modal?.actions?.[index]) {
this.modal.actions[index] = { ...this.modal.actions[index], busy: false };
this.modal.actions = [...this.modal.actions];
}
}
},
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);
},
});
Alpine.store('sharing', {
_handledInitialLink: false,
sharedTrackId: null,
absoluteUrl(path) {
return new URL(path, window.location.origin).href;
},
async copyText(text) {
try {
if (navigator.clipboard?.writeText && window.isSecureContext) {
await navigator.clipboard.writeText(text);
return true;
}
} catch {}
const textarea = document.createElement('textarea');
textarea.value = text;
textarea.setAttribute('readonly', '');
textarea.style.position = 'fixed';
textarea.style.left = '-9999px';
textarea.style.top = '0';
document.body.appendChild(textarea);
textarea.select();
let ok = false;
try {
ok = document.execCommand('copy');
} catch {}
textarea.remove();
return ok;
},
async copyUrl(path, trigger = null) {
const copied = await this.copyText(this.absoluteUrl(path));
this.flashTrigger(trigger, copied);
return copied;
},
copyTrack(track, trigger = null) {
const id = Number(track?.id || track);
if (!id) return;
this.copyUrl(`/share/track/${id}`, trigger);
},
async copyQueue(trigger = null) {
const queue = Alpine.store('queue');
return this.copyTracks(queue?.tracks || [], T.sharedPlaylist, trigger);
},
async copyRelease(release, trigger = null) {
const id = Number(release?.id || release || 0);
if (!id) return;
return this.copyUrl(`/share/release/${id}`, trigger);
},
async copyTracks(tracks, title = T.sharedPlaylist, trigger = null) {
const trackIds = (tracks || []).map(track => Number(track.id)).filter(Boolean);
if (!trackIds.length) return;
try {
const res = await fetch('/api/player/share-playlist', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ track_ids: trackIds, title }),
});
if (!res.ok) throw new Error('share failed');
const data = await res.json();
await this.copyUrl(data.url || `/share/playlist/${data.token}`, trigger);
} catch (err) {
this.flashTrigger(trigger, false);
console.warn(err);
}
},
flashTrigger(trigger, copied) {
if (!trigger) return;
const className = copied ? 'share-copy-flash' : 'share-copy-failed';
trigger.classList.remove('share-copy-flash', 'share-copy-failed');
void trigger.offsetWidth;
trigger.classList.add(className);
window.setTimeout(() => trigger.classList.remove(className), 720);
},
markSharedTrack(trackId) {
const id = Number(trackId || 0);
this.sharedTrackId = id > 0 ? id : null;
},
isSharedTrack(track) {
const id = Number(track?.id || track || 0);
return id > 0 && id === Number(this.sharedTrackId || 0);
},
focusSharedTrack(trackId) {
const run = () => this.scrollSharedTrackIntoView(trackId);
requestAnimationFrame(run);
setTimeout(run, 120);
setTimeout(run, 360);
setTimeout(run, 720);
},
scrollSharedTrackIntoView(trackId) {
const id = Number(trackId || 0);
if (!id) return false;
const row = document.querySelector(`#center-scroll [data-shared-track-id="${id}"]`);
const container = document.getElementById('center-scroll');
if (!row || !container) return false;
const containerRect = container.getBoundingClientRect();
const rowRect = row.getBoundingClientRect();
const top = container.scrollTop
+ rowRect.top
- containerRect.top
- ((container.clientHeight - rowRect.height) / 2);
container.scrollTo({ top: Math.max(0, top), behavior: 'smooth' });
return true;
},
async handleInitialShareLinks() {
if (this._handledInitialLink) return;
this._handledInitialLink = true;
const params = new URLSearchParams(window.location.search);
const trackId = Number(params.get('track') || 0);
const releaseId = Number(params.get('release') || 0);
const playlistToken = (params.get('playlist_share') || '').trim();
if (trackId > 0) {
await this.openSharedTrack(trackId);
this._clearShareQuery();
} else if (releaseId > 0) {
await this.openSharedRelease(releaseId);
this._clearShareQuery();
} else if (playlistToken) {
await this.openSharedPlaylist(playlistToken);
this._clearShareQuery();
}
},
async openSharedTrack(trackId) {
try {
const res = await fetch('/api/player/tracks-by-ids', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ids: [Number(trackId)] }),
});
if (!res.ok) throw new Error('track load failed');
const tracks = await res.json();
const track = Array.isArray(tracks) ? tracks[0] : null;
if (!track) return;
this.markSharedTrack(track.id);
if (track.release_id) {
await Alpine.store('library').openRelease(track.release_id, { focusSharedTrackId: track.id });
}
const queue = Alpine.store('queue');
queue.tracks = [track];
queue.currentIndex = 0;
Alpine.store('player')._playLocal(track, { paused: true });
this.focusSharedTrack(track.id);
} catch (err) {
console.warn(err);
}
},
async openSharedRelease(releaseId) {
const id = Number(releaseId || 0);
if (!id) return;
this.markSharedTrack(null);
await Alpine.store('library').openRelease(id);
},
async openSharedPlaylist(token) {
try {
const res = await fetch(`/api/player/share-playlist/${encodeURIComponent(token)}`);
if (!res.ok) throw new Error('playlist load failed');
const data = await res.json();
const tracks = Array.isArray(data.tracks) ? data.tracks : [];
if (!tracks.length) return;
const queue = Alpine.store('queue');
queue.tracks = tracks;
queue.currentIndex = 0;
Alpine.store('player')._playLocal(tracks[0], { paused: true });
Alpine.store('library').showSharedPlaylist(data);
} catch (err) {
console.warn(err);
}
},
_clearShareQuery() {
const url = new URL(window.location.href);
url.searchParams.delete('track');
url.searchParams.delete('release');
url.searchParams.delete('playlist_share');
const query = url.searchParams.toString();
const next = `${url.pathname}${query ? '?' + query : ''}${window.location.hash}`;
history.replaceState({}, '', next);
},
});
// -----------------------------------------------------------------------
// User store
// -----------------------------------------------------------------------
Alpine.store('user', {
profile: null,
menuOpen: false,
settingsOpen: false,
lastfm: { configured: false, connected: false, username: null, reauth_required: false, last_error: null },
lastfmBusy: false,
init() {
this.cleanLastfmQuery();
this.load();
this.loadLastfm();
},
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 || '/');
},
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;
}
},
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;
},
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;
},
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;
}
},
openSettings() {
this.menuOpen = false;
this.settingsOpen = true;
this.loadLastfm();
Alpine.store('devices')?.loadFedDevices();
},
closeSettings() {
this.settingsOpen = false;
},
openHistoryFromSettings() {
Alpine.store('history').open();
},
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));
},
tracks() {
return (this.items || []).map(item => item.track).filter(Boolean);
},
playFrom(index) {
const tracks = this.tracks();
if (!tracks.length || index < 0 || index >= tracks.length) return;
Alpine.store('queue').playRelease(tracks, index);
},
async load(page) {
page = Math.max(1, page || 1);
this.loading = true;
this.error = false;
this.message = T.loadingHistory;
try {
const res = await fetch(`/api/player/history?page=${page}&limit=${this.perPage}`);
const data = await res.json();
if (!res.ok) throw new Error(data.error || T.failedLoadHistory);
this.items = data.items || [];
this.page = data.page || page;
this.perPage = data.per_page || this.perPage;
this.total = data.total || 0;
this.message = this.total ? (this.total + ' ' + T.totalPlays) : '';
} 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,
_volumeCurve: 2.4,
shuffle: false,
repeatMode: 'off', // off, all, one
progress: 0,
_saveTimer: null,
_historyRecorded: false,
_nowPlayingSent: false,
_playbackStartedAt: null,
_listenId: null,
_listenedSeconds: 0,
_lastAudioTime: 0,
_localSourceTrackId: null,
_remoteExecuting: false,
_remoteStateBaseTime: 0,
_remoteStateReceivedAt: 0,
_remoteStateTimer: null,
_prefetchedQueueKey: null,
_hasInitialShareLink() {
const params = new URLSearchParams(window.location.search);
return Number(params.get('track') || 0) > 0
|| (params.get('playlist_share') || '').trim().length > 0;
},
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;
this._trackListenedDelta();
this._maybePrefetchNext();
});
audio.addEventListener('ended', () => {
this._trackListenedDelta();
this._recordHistory('finished');
if (Alpine.store('devices')?.shouldPlayJamLocally()) {
this.isPlaying = false;
return;
}
this.next();
});
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;
});
audio.addEventListener('loadedmetadata', () => {
this.duration = audio.duration || 0;
});
// Periodic state save
this._saveTimer = setInterval(() => {
if (this._isLocalPlaybackDevice()) this._saveState();
}, 10000);
this._remoteStateTimer = setInterval(() => {
this._tickRemoteProgress();
}, 250);
// Restore state
if (!this._hasInitialShareLink()) {
this._restoreState();
}
// Save state on page unload
window.addEventListener('beforeunload', () => {
if (this._isLocalPlaybackDevice()) this._saveStateSync();
});
},
play(track) {
if (!track) return;
if (this._shouldSendRemote()) {
this._mirrorRemoteTrack(track, true, 0);
if (this._shouldMirrorRemoteLocally()) {
this._playLocal(track, {
position_seconds: 0,
paused: false,
});
}
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 (track?.federation_pending && track?._federationTrack) {
Alpine.store('library')?.playFederatedTrack(track._federationTrack, {
queueIndex: idx,
});
return;
}
if (this._shouldSendRemote()) {
this._mirrorRemoteTrack(track, true, 0);
if (this._shouldMirrorRemoteLocally()) {
this._playLocal(track, {
position_seconds: 0,
paused: false,
});
}
this._sendRemote('play_from_index', this._remotePlaybackPayload(track, {
index: idx,
position_seconds: 0,
paused: false,
}));
return;
}
this._playLocal(track);
},
_maybePrefetchNext() {
if (this.progress < 90 || !this.isPlaying) return;
const queue = Alpine.store('queue');
const nextIndex = queue?.currentIndex + 1;
if (!queue || nextIndex < 0 || nextIndex >= queue.tracks.length) return;
const next = queue.tracks[nextIndex];
if (!next?.federation_pending || !next?._federationTrack) return;
const key = next.content_id || next.id;
if (!key || this._prefetchedQueueKey === key) return;
this._prefetchedQueueKey = key;
Alpine.store('library')?.playFederatedTrack(next._federationTrack, {
queueIndex: nextIndex,
playAfterPrepare: false,
});
},
_playLocal(track, options = {}) {
this.currentTrack = track;
this._prefetchedQueueKey = null;
Alpine.store('queue')?.syncCurrentIndexToTrack(track);
this._localSourceTrackId = track.id;
this._historyRecorded = false;
this._resetPlaybackTracking();
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(() => {});
}
this._updateMediaSession();
},
_pauseLocal() {
audio.pause();
this.isPlaying = false;
},
pause() {
if (this._shouldSendRemote()) {
this.isPlaying = false;
this._remoteStateBaseTime = this.currentTime;
this._remoteStateReceivedAt = Date.now();
if (this._shouldMirrorRemoteLocally()) {
this._pauseLocal();
}
this._sendRemote('pause');
return;
}
this._pauseLocal();
},
resume() {
if (this._shouldSendRemote()) {
this.isPlaying = true;
this._remoteStateBaseTime = this.currentTime;
this._remoteStateReceivedAt = Date.now();
if (this._shouldMirrorRemoteLocally()) {
audio.play().catch(() => {});
}
this._sendRemote('resume');
return;
}
audio.play().catch(() => {});
},
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();
if (this._shouldMirrorRemoteLocally() && this._localSourceTrackId === this.currentTrack?.id) {
audio.currentTime = nextTime;
this._lastAudioTime = audio.currentTime || 0;
}
this._sendRemote('seek', { time: nextTime });
return;
}
audio.currentTime = nextTime;
this._lastAudioTime = audio.currentTime || 0;
},
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)));
},
seekFromClick(event) {
const bar = event.currentTarget;
this._setProgressFromClientX(event.clientX, bar);
},
_setProgressFromClientX(clientX, bar) {
const rect = bar.getBoundingClientRect();
const pct = rect.width > 0 ? Math.max(0, Math.min(1, (clientX - rect.left) / rect.width)) : 0;
if (this.duration > 0) {
this.seek(pct * this.duration);
}
},
startProgressDrag(event) {
const bar = event.currentTarget;
this._setProgressFromClientX(event.clientX, bar);
bar.setPointerCapture?.(event.pointerId);
const move = (e) => {
this._setProgressFromClientX(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);
},
next() {
if (this._shouldSendRemote()) {
this._sendRemote('next', {
shuffle: this.shuffle,
repeat_mode: this.repeatMode,
});
return;
}
const queue = Alpine.store('queue');
if (queue.tracks.length === 0) return;
this._recordHistoryIfListenThresholdReached();
let nextIdx;
if (this.repeatMode === 'one') {
this.seek(0);
this._historyRecorded = false;
this._resetPlaybackTracking();
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);
},
prev() {
if (this._shouldSendRemote()) {
this._sendRemote('prev');
return;
}
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);
},
setVolume(v) {
this._setVolumeLocal(v);
if (this._shouldSendRemote() && !this._shouldMirrorRemoteLocally()) {
this._sendRemote('set_volume', { volume: this.volume });
}
},
_setVolumeLocal(v) {
this.volume = Math.max(0, Math.min(1, Number(v || 0)));
audio.volume = this.volume;
},
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);
},
_setVolumeFromClientX(clientX, bar) {
const rect = bar.getBoundingClientRect();
const pct = rect.width > 0 ? (clientX - rect.left) / rect.width : 0;
this.setVolume(this._volumeFromSliderPosition(pct));
},
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,
});
}
},
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();
},
_shouldMirrorRemoteLocally() {
return !!Alpine.store('devices')?.shouldPlayJamLocally();
},
_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._tracksWithJamDefaults(queue.tracks) : (track ? queue?._tracksWithJamDefaults([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;
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;
Alpine.store('queue')?.syncCurrentIndexToTrack(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 = queue._tracksWithJamDefaults(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;
queue?.syncCurrentIndexToTrack(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();
},
_syncLocalPlaybackState(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 = queue._tracksWithJamDefaults(tracks);
queue.currentIndex = Math.max(0, Math.min(Number(state.index || 0), queue.tracks.length - 1));
}
const track = state.track || queue?.tracks?.[queue.currentIndex] || null;
if (!track) return;
const desiredTime = Math.max(0, Number(state.position_seconds || 0));
const duration = Number(state.duration_seconds || track.duration_seconds || this.duration || 0);
this.currentTrack = track;
this.shuffle = !!state.shuffle;
this.repeatMode = state.repeat_mode || 'off';
this.duration = duration;
this._remoteStateBaseTime = desiredTime;
this._remoteStateReceivedAt = Date.now();
if (this._localSourceTrackId !== track.id) {
this._playLocal(track, {
position_seconds: desiredTime,
paused: !!state.paused,
});
} else {
const drift = Math.abs((audio.currentTime || 0) - desiredTime);
const driftLimit = state.paused ? 0.08 : 0.75;
if (drift > driftLimit) {
audio.currentTime = desiredTime;
this._lastAudioTime = audio.currentTime || 0;
}
if (state.paused) {
if (!audio.paused) audio.pause();
this.isPlaying = false;
} else if (audio.paused) {
audio.play().catch(() => {});
this.isPlaying = true;
} else {
this.isPlaying = true;
}
}
this.currentTime = audio.currentTime || desiredTime;
this.progress = duration > 0 ? (this.currentTime / duration) * 100 : 0;
this._updateMediaSession();
},
_tickRemoteProgress(force = false) {
if (this._isLocalPlaybackDevice() || this._shouldMirrorRemoteLocally() || !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);
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 = queue._tracksWithJamDefaults(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?.unavailable || (track && !track.stream_url)) {
this._applyRemotePlaybackState({ ...payload, track, paused: true });
this._pauseLocal();
} else 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.
} 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;
}
},
_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(', '),
artwork: t.cover_url ? [{ src: coverVariantUrl(t.cover_url, 'large'), sizes: '512x512', type: 'image/jpeg' }] : [],
});
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');
const localQueue = queue.tracks.filter(track => Number.isInteger(Number(track.id)));
const currentLocalId = this.currentTrack && Number.isInteger(Number(this.currentTrack.id))
? Number(this.currentTrack.id)
: null;
return {
current_track_id: currentLocalId,
position_ms: Math.floor(this.currentTime * 1000),
queue: localQueue.map(track => Number(track.id)),
queue_position: currentLocalId == null
? 0
: Math.max(0, localQueue.findIndex(track => Number(track.id) === currentLocalId)),
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);
// 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;
queue.syncCurrentIndexToTrack(currentTrack);
this._localSourceTrackId = currentTrack.id;
this._historyRecorded = false;
this._resetPlaybackTracking();
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(endedReason) {
if (this._historyRecorded || !this.currentTrack) return;
if (!Number.isInteger(Number(this.currentTrack.id)) || this.currentTrack.federated_cache) {
return;
}
this._historyRecorded = true;
const completed = endedReason === 'finished';
const listenedSeconds = this._historyListenedSeconds(completed);
fetch('/api/player/history', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
track_id: this.currentTrack.id,
listen_id: this._listenId,
started_at: this._playbackStartedAt,
duration_listened: listenedSeconds,
completed: completed,
ended_reason: endedReason,
}),
}).catch(() => {});
},
_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('unknown');
return true;
},
_resetPlaybackTracking() {
this._nowPlayingSent = false;
this._playbackStartedAt = null;
this._listenId = (globalThis.crypto?.randomUUID?.()
|| `${Date.now()}-${Math.random().toString(16).slice(2)}`);
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;
},
_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);
},
_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);
},
_sendNowPlaying() {
if (this._nowPlayingSent || !this.currentTrack) return;
if (!Number.isInteger(Number(this.currentTrack.id)) || this.currentTrack.federated_cache) {
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(() => {});
},
});
// -----------------------------------------------------------------------
// Playback devices store
// -----------------------------------------------------------------------
Alpine.store('devices', {
id: null,
devices: [],
jams: [],
activeDeviceId: null,
currentJamId: null,
open: false,
jamPanelOpen: false,
jamPanelMode: 'create',
jamPanelJamId: null,
jamQuery: '',
jamUsers: [],
jamSelectedUsers: [],
jamSearching: false,
jamLocalPlayback: false,
fed: null,
fedInvite: '',
fedInviteInput: '',
fedBusy: false,
fedError: '',
remoteHintVisible: false,
remoteHintDeviceName: '',
_remoteHintShown: false,
_remoteHintTimer: null,
_pollTimer: null,
_jamSearchTimer: null,
_fedRefreshTick: 0,
_stateRefreshTick: 0,
_lastPlaybackState: null,
init() {
this.id = this._ensureId();
this.currentJamId = sessionStorage.getItem('furu_player_jam_id') || null;
this.heartbeat();
this.loadFedDevices();
this._pollTimer = setInterval(() => this.poll(), 500);
document.addEventListener('visibilitychange', () => {
if (!document.hidden) {
this.poll();
this.loadFedDevices();
}
});
},
_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 || '',
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();
if (data.playback_state) this._lastPlaybackState = data.playback_state;
this._apply(data);
if (this.open || (++this._fedRefreshTick % 10 === 0)) {
this.loadFedDevices();
}
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) {
if (this.shouldPlayJamLocally()) {
player._syncLocalPlaybackState(data.playback_state);
} else {
player._applyRemotePlaybackState(data.playback_state);
}
} else if (++this._stateRefreshTick % 8 === 0) {
player._restoreState();
}
}
} catch {}
},
_apply(data) {
const wasActive = this.isActive();
const previousJamId = this.currentJamId;
this.activeDeviceId = data.active_device_id || null;
this.devices = Array.isArray(data.devices) ? data.devices : [];
this.jams = Array.isArray(data.jams) ? data.jams : [];
if (data.playback_state) this._lastPlaybackState = data.playback_state;
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 (previousJamId !== this.currentJamId || !this.canPlayJamLocally()) {
this._setJamLocalPlayback(false, { pauseLocal: true });
}
if (wasActive && !this.isActive()) {
Alpine.store('player')?._pauseLocal();
}
this._maybeShowRemoteHint();
},
remoteActiveDevice() {
if (!this.activeDeviceId || this.activeDeviceId === this.id) return null;
return this.devices.find(device => device.id === this.activeDeviceId) || null;
},
_maybeShowRemoteHint() {
if (this._remoteHintShown || this.remoteHintVisible || this.open) return;
const active = this.remoteActiveDevice();
if (!active) return;
this.remoteHintDeviceName = active.name || 'Devices';
this.remoteHintVisible = true;
this._remoteHintShown = true;
clearTimeout(this._remoteHintTimer);
this._remoteHintTimer = setTimeout(() => this.dismissRemoteHint(), 60000);
},
dismissRemoteHint() {
this.remoteHintVisible = false;
this._remoteHintShown = true;
clearTimeout(this._remoteHintTimer);
this._remoteHintTimer = null;
},
remoteHintText() {
return `${T.activeDevice}: ${this.remoteHintDeviceName || this.activeLabel()}`;
},
selectedJam() {
return this.currentJamId ? this.jams.find(jam => jam.id === this.currentJamId) : null;
},
currentJamUser(jam = this.selectedJam()) {
const profile = Alpine.store('user')?.profile;
if (profile?.id) {
return { id: profile.id, name: profile.name || 'User' };
}
const current = (jam?.members || []).find(member => member.is_current_user);
if (current?.user_id) {
return { id: current.user_id, name: current.name || 'User' };
}
if (jam?.is_owner && jam.host_user_id) {
return { id: jam.host_user_id, name: jam.host_name || 'User' };
}
return null;
},
hasJoinedJam() {
return this.jams.some(jam => jam.is_member);
},
activeJamMembers() {
const jam = this.selectedJam();
return (jam?.members || []).filter(member => member.is_joined && Number(member.last_seen_ms || 0) <= 45000);
},
jamMemberIds(jamId = this.jamPanelJamId || this.currentJamId) {
const jam = this.jams.find(item => item.id === jamId);
return new Set((jam?.members || []).map(member => Number(member.user_id)));
},
userColorStyle(userId, name = '') {
const palette = ['#4cc9f0', '#f72585', '#f9c74f', '#90be6d', '#f8961e', '#b5179e', '#43aa8b', '#577590'];
const raw = String(userId || name || '');
let hash = 0;
for (let i = 0; i < raw.length; i++) hash = ((hash * 31) + raw.charCodeAt(i)) >>> 0;
const hex = palette[hash % palette.length];
const r = parseInt(hex.slice(1, 3), 16);
const g = parseInt(hex.slice(3, 5), 16);
const b = parseInt(hex.slice(5, 7), 16);
return `--jam-contributor-color:${hex};--jam-contributor-bg:rgba(${r},${g},${b},0.13);--jam-contributor-bg-active:rgba(${r},${g},${b},0.2)`;
},
isControllingRemoteJam() {
const jam = this.selectedJam();
return !!jam && !jam.is_owner;
},
canPlayJamLocally() {
const jam = this.selectedJam();
return !!jam && jam.is_member && !jam.is_owner && jam.host_device_online;
},
shouldPlayJamLocally() {
return this.jamLocalPlayback && this.canPlayJamLocally();
},
setJamLocalPlayback(enabled) {
this._setJamLocalPlayback(enabled, { pauseLocal: true });
if (!this.jamLocalPlayback) return;
const player = Alpine.store('player');
if (player && this._lastPlaybackState) {
player._syncLocalPlaybackState(this._lastPlaybackState);
} else if (player?.currentTrack) {
player._syncLocalPlaybackState(player._remotePlaybackPayload(player.currentTrack, {
position_seconds: player.currentTime || 0,
duration_seconds: player.duration || 0,
paused: !player.isPlaying,
}));
}
this.poll();
},
_setJamLocalPlayback(enabled, options = {}) {
const next = !!enabled && this.canPlayJamLocally();
const wasEnabled = this.jamLocalPlayback;
this.jamLocalPlayback = next;
if (wasEnabled && !next && options.pauseLocal) {
Alpine.store('player')?._pauseLocal();
}
},
isActive() {
if (this.isControllingRemoteJam()) return false;
return !this.activeDeviceId || this.activeDeviceId === this.id;
},
activeLabel() {
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.dismissRemoteHint();
this.open = !this.open;
if (this.open) {
this.poll();
this.loadFedDevices();
}
},
fedDevices() {
return (this.fed?.devices || []).filter(device => !device.revoked);
},
webDevices() {
return this.devices.filter(device => device.kind !== 'fed');
},
playbackFedDevices() {
return this.devices.filter(device => device.kind === 'fed');
},
fedPending() {
return this.fed?.pending || [];
},
fedSummary() {
if (!this.fed) return 'Fed sync unavailable';
const outbox = Number(this.fed.outbox_ops || 0);
const unresolved = Number(this.fed.unresolved_items || 0);
return `${this.fed.active_devices || 0} linked · ${outbox} queued · ${unresolved} unresolved`;
},
async loadFedDevices() {
try {
const res = await fetch('/api/player/fed-devices');
if (!res.ok) {
this.fedError = await this._errorText(res);
return;
}
this.fed = await res.json();
this.fedError = '';
} catch (err) {
this.fedError = String(err?.message || err || 'Fed sync unavailable');
}
},
async generateFedInvite() {
if (this.fedBusy) return;
this.fedBusy = true;
try {
const res = await fetch('/api/player/fed-devices/invite', { method: 'POST' });
if (!res.ok) throw new Error(await this._errorText(res));
const data = await res.json();
this.fedInvite = data.invite || '';
if (this.fedInvite && navigator.clipboard?.writeText) {
navigator.clipboard.writeText(this.fedInvite).catch(() => {});
}
await this.loadFedDevices();
} catch (err) {
this.fedError = String(err?.message || err || 'Invite failed');
} finally {
this.fedBusy = false;
}
},
async connectFedInvite() {
const invite = this.fedInviteInput.trim();
if (!invite || this.fedBusy) return;
this.fedBusy = true;
try {
const res = await fetch('/api/player/fed-devices/connect', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ invite }),
});
if (!res.ok) throw new Error(await this._errorText(res));
this.fedInviteInput = '';
await this.loadFedDevices();
} catch (err) {
this.fedError = String(err?.message || err || 'Connect failed');
} finally {
this.fedBusy = false;
}
},
async answerFedPairing(request, accept, useRequesterGroup = false) {
if (!request?.request_id || this.fedBusy) return;
this.fedBusy = true;
try {
const res = await fetch('/api/player/fed-devices/pairing', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
request_id: request.request_id,
accept,
use_requester_group: useRequesterGroup,
}),
});
if (!res.ok) throw new Error(await this._errorText(res));
await this.loadFedDevices();
} catch (err) {
this.fedError = String(err?.message || err || 'Pairing failed');
} finally {
this.fedBusy = false;
}
},
async revokeFedDevice(device) {
if (!device?.device_id || device.is_self || this.fedBusy) return;
if (!window.confirm(`Revoke ${device.name || device.device_id}?`)) return;
this.fedBusy = true;
try {
const res = await fetch('/api/player/fed-devices/revoke', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ device_id: device.device_id }),
});
if (!res.ok) throw new Error(await this._errorText(res));
await this.loadFedDevices();
} catch (err) {
this.fedError = String(err?.message || err || 'Revoke failed');
} finally {
this.fedBusy = false;
}
},
async syncFedDevices() {
if (this.fedBusy) return;
this.fedBusy = true;
try {
const res = await fetch('/api/player/fed-devices/sync', { method: 'POST' });
if (!res.ok) throw new Error(await this._errorText(res));
await this.loadFedDevices();
} catch (err) {
this.fedError = String(err?.message || err || 'Sync failed');
} finally {
this.fedBusy = false;
}
},
async _errorText(res) {
try {
const data = await res.json();
return data.error || res.statusText || 'Request failed';
} catch {
return res.statusText || 'Request failed';
}
},
async select(deviceId) {
if (!deviceId) return;
const player = Alpine.store('player');
try {
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;
const data = await res.json();
this._apply(data);
this.open = false;
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) {
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,
jam_id: jamId,
command,
payload,
}),
});
return res.ok;
} catch {
return false;
}
},
openJamPanel() {
if (this.hasJoinedJam()) return;
this.jamPanelMode = 'create';
this.jamPanelJamId = null;
this.jamSelectedUsers = [];
this.jamUsers = [];
this.jamQuery = '';
this.jamPanelOpen = !this.jamPanelOpen;
if (this.jamPanelOpen && this.jamQuery.trim()) this.searchJamUsers();
},
openJamManagePanel(jam) {
if (!jam?.is_member) return;
if (this.jamPanelOpen && this.jamPanelMode === 'manage' && this.jamPanelJamId === jam.id) {
this.jamPanelOpen = false;
return;
}
this.jamPanelMode = 'manage';
this.jamPanelJamId = jam.id;
this.jamSelectedUsers = [];
this.jamUsers = [];
this.jamQuery = '';
this.jamPanelOpen = true;
},
handleJamRowClick(jam) {
if (!jam) return;
if (jam.is_active && jam.is_member) {
this.openJamManagePanel(jam);
return;
}
this.selectJam(jam);
},
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));
const existing = this.jamMemberIds();
this.jamUsers = (await res.json()).filter(user => !selected.has(user.id) && !existing.has(Number(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);
},
submitJamPanel() {
if (this.jamPanelMode === 'manage') this.inviteToJam();
else this.createJam();
},
async createJam() {
if (this.hasJoinedJam()) return;
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);
Alpine.store('queue')?._ensureCurrentJamAttribution();
this.jamPanelOpen = false;
this.jamQuery = '';
this.jamUsers = [];
this.jamSelectedUsers = [];
this.open = false;
} catch {}
},
async inviteToJam() {
if (!this.jamPanelJamId || this.jamSelectedUsers.length === 0) return;
try {
const res = await fetch('/api/player/jams/invite', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
jam_id: this.jamPanelJamId,
device_id: this.id,
invitee_user_ids: this.jamSelectedUsers.map(user => user.id),
}),
});
if (!res.ok) return;
this._apply(await res.json());
this.jamQuery = '';
this.jamUsers = [];
this.jamSelectedUsers = [];
} 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);
if (data.playback_state) this._lastPlaybackState = data.playback_state;
this._apply(data);
Alpine.store('queue')?._ensureCurrentJamAttribution();
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;
this.jamPanelOpen = false;
this.jamPanelJamId = null;
this._setJamLocalPlayback(false, { pauseLocal: true });
sessionStorage.removeItem('furu_player_jam_id');
},
});
// -----------------------------------------------------------------------
// Queue store
// -----------------------------------------------------------------------
Alpine.store('queue', {
tracks: [],
currentIndex: 0,
visible: false,
_dragIdx: null,
_dragOverIdx: null,
_pointerDragMove: null,
_pointerDragEnd: null,
add(track) {
this.addToEnd([track]);
},
upcoming(limit = 12) {
const start = Math.max(0, this.currentIndex + 1);
return this.tracks.slice(start, start + limit);
},
effectiveCurrentIndex() {
const currentTrack = Alpine.store('player')?.currentTrack || null;
if (currentTrack?.id) {
return this.tracks.findIndex(track => Number(track?.id) === Number(currentTrack.id));
}
if (!this.tracks.length) return -1;
return Math.max(0, Math.min(Number(this.currentIndex || 0), this.tracks.length - 1));
},
queueItemState(index) {
const current = this.effectiveCurrentIndex();
if (current < 0) return 'upcoming';
if (index < current) return 'played';
if (index === current) return 'current';
return 'upcoming';
},
displayItems() {
const current = this.effectiveCurrentIndex();
const playerTrack = Alpine.store('player')?.currentTrack || null;
const items = this.tracks.map((track, index) => ({
track,
index,
key: `${index}-${track?.id || 'track'}`,
state: current >= 0
? (index < current ? 'played' : (index === current ? 'current' : 'upcoming'))
: 'upcoming',
synthetic: false,
}));
if (playerTrack?.id && current < 0) {
items.unshift({
track: playerTrack,
index: -1,
key: `current-${playerTrack.id}`,
state: 'current',
synthetic: true,
});
}
return items;
},
syncCurrentIndexToTrack(track) {
if (!track?.id || !this.tracks.length) return -1;
const index = this.tracks.findIndex(item => Number(item?.id) === Number(track.id));
if (index >= 0) this.currentIndex = index;
return index;
},
addToEnd(tracks) {
const items = this._tracksForQueueAdd(tracks);
if (!items.length) return;
if (this._sendRemoteQueueCommand('queue_add_end', { tracks: items })) {
this._addToEndLocal(items);
return;
}
this._addToEndLocal(items);
},
addNextInQueue(tracks) {
const items = this._tracksForQueueAdd(tracks);
if (!items.length) return;
if (this._sendRemoteQueueCommand('queue_add_next', { tracks: items })) {
this._addNextLocal(items);
return;
}
this._addNextLocal(items);
},
playRelease(tracks, startIndex) {
this.tracks = this._tracksForQueueAdd(tracks);
this.playFromIndex(startIndex || 0);
},
playFromIndex(idx) {
if (idx < 0 || idx >= this.tracks.length) return;
Alpine.store('player').playQueueIndex(idx);
},
remove(idx) {
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);
},
startPointerReorder(event, idx) {
if (event.pointerType === 'mouse') return;
if (event.button && event.button !== 0) return;
if (idx < 0 || idx >= this.tracks.length) return;
event.preventDefault();
this._endPointerReorder(false);
this._dragIdx = idx;
this._dragOverIdx = idx;
const handle = event.currentTarget;
try {
handle?.setPointerCapture?.(event.pointerId);
} catch (_) {}
this._pointerDragMove = (moveEvent) => {
moveEvent.preventDefault();
this._autoScrollDuringReorder(moveEvent.clientY);
const target = document
.elementFromPoint(moveEvent.clientX, moveEvent.clientY)
?.closest?.('.queue-track[data-queue-index]');
const targetIdx = Number(target?.dataset?.queueIndex);
if (!Number.isInteger(targetIdx) || targetIdx < 0 || targetIdx >= this.tracks.length) return;
this._dragOverIdx = targetIdx;
document.querySelectorAll('.queue-track.drag-over').forEach(el => el.classList.remove('drag-over'));
if (targetIdx !== this._dragIdx) target.classList.add('drag-over');
};
this._pointerDragEnd = (endEvent) => {
try {
if (handle?.hasPointerCapture?.(endEvent.pointerId)) handle.releasePointerCapture(endEvent.pointerId);
} catch (_) {}
this._endPointerReorder(true);
};
window.addEventListener('pointermove', this._pointerDragMove, { passive: false });
window.addEventListener('pointerup', this._pointerDragEnd, { passive: false });
window.addEventListener('pointercancel', this._pointerDragEnd, { passive: false });
},
_autoScrollDuringReorder(clientY) {
const scroller = document.querySelector('.queue-tracks');
if (!scroller) return;
const rect = scroller.getBoundingClientRect();
const edge = 52;
if (clientY < rect.top + edge) {
scroller.scrollTop -= Math.ceil((rect.top + edge - clientY) / 4);
} else if (clientY > rect.bottom - edge) {
scroller.scrollTop += Math.ceil((clientY - (rect.bottom - edge)) / 4);
}
},
_endPointerReorder(commit) {
if (this._pointerDragMove) window.removeEventListener('pointermove', this._pointerDragMove);
if (this._pointerDragEnd) {
window.removeEventListener('pointerup', this._pointerDragEnd);
window.removeEventListener('pointercancel', this._pointerDragEnd);
}
document.querySelectorAll('.queue-track.drag-over').forEach(el => el.classList.remove('drag-over'));
const fromIdx = this._dragIdx;
const toIdx = this._dragOverIdx;
this._pointerDragMove = null;
this._pointerDragEnd = null;
this._dragIdx = null;
this._dragOverIdx = null;
if (commit && Number.isInteger(fromIdx) && Number.isInteger(toIdx) && fromIdx !== toIdx) {
this.moveTrack(fromIdx, toIdx);
}
},
clear() {
if (this._sendRemoteQueueCommand('queue_clear')) {
this._clearLocal();
return;
}
this._clearLocal();
},
_trackList(tracks) {
return (Array.isArray(tracks) ? tracks : [tracks]).filter(Boolean);
},
_tracksForQueueAdd(tracks) {
const items = this._trackList(tracks).map(track => ({ ...track }));
const devices = Alpine.store('devices');
const jam = devices?.selectedJam?.();
const user = devices?.currentJamUser?.(jam);
if (!jam || !jam.is_member || !user?.id) return items;
return items.map(track => ({
...track,
added_by_user_id: user.id,
added_by_user_name: user.name || 'User',
}));
},
_tracksWithJamDefaults(tracks) {
const items = this._trackList(tracks);
const jam = Alpine.store('devices')?.selectedJam?.();
if (!jam?.is_member || !jam.host_user_id) return items;
return items.map(track => {
if (track?.added_by_user_id) return track;
return {
...track,
added_by_user_id: jam.host_user_id,
added_by_user_name: jam.host_name || 'Host',
};
});
},
_ensureCurrentJamAttribution() {
this.tracks = this._tracksWithJamDefaults(this.tracks);
},
isForeignJamTrack(track) {
const devices = Alpine.store('devices');
const jam = devices?.selectedJam?.();
const userId = Alpine.store('user')?.profile?.id;
if (!jam || !jam.is_member || !track?.added_by_user_id || !userId) return false;
return String(track.added_by_user_id) !== String(userId);
},
contributorTitle(track) {
return track?.added_by_user_name ? `Added by ${track.added_by_user_name}` : 'Added by another listener';
},
contributorStyle(track) {
return Alpine.store('devices')?.userColorStyle(track?.added_by_user_id, track?.added_by_user_name) || '';
},
_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._tracksWithJamDefaults(tracks);
if (!items.length) return;
this.tracks = [...this.tracks, ...items];
},
_addNextLocal(tracks) {
const items = this._tracksWithJamDefaults(tracks);
if (!items.length) return;
const insertAt = Math.min(this.currentIndex + 1, this.tracks.length);
this.tracks.splice(insertAt, 0, ...items);
},
_removeLocal(idx) {
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;
}
}
},
_moveTrackLocal(fromIdx, toIdx) {
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++;
}
},
_clearLocal() {
this.tracks = [];
this.currentIndex = 0;
},
});
// -----------------------------------------------------------------------
// Library store
// -----------------------------------------------------------------------
Alpine.store('library', {
view: 'artists',
artistFilter: 'all',
artists: [],
artistsPage: 0,
artistsTotal: 0,
loading: false,
_allLoaded: false,
_artistsLoadToken: 0,
currentArtist: null,
currentRelease: null,
currentPlaylist: null,
_observer: null,
searchQuery: '',
searchResults: null,
searchLoading: false,
federationSearch: { loading: false, error: '', artists: [], releases: [], tracks: [] },
artistFederation: { loading: false, error: '', releases: [], tracks: [] },
federationPreparing: {},
federationFailures: {},
trackAvailability: {},
_federatedReleaseCache: {},
federationLiked: [],
_federationSearchSource: null,
_artistFederationSource: null,
_releaseFederationSource: null,
releaseFederationLoading: false,
_previousView: 'artists',
_activeHash: location.hash || '#artists',
_scrollPositions: {},
_hashNav: false, // guard against circular hash updates
init() {
this._setupScroll();
this.loadFederationLikes();
window.addEventListener('furumi:track-availability', event => {
if (event.detail?.source !== 'library') {
this.applyTrackAvailability(event.detail || {});
}
});
// Listen for browser back/forward
window.addEventListener('hashchange', () => {
if (this._hashNav) return;
const nextHash = location.hash || '#artists';
this._saveScrollPosition(this._activeHash);
this._activeHash = nextHash;
this._navigateFromHash({ fromHash: true, restoreScroll: true });
});
// Navigate to initial hash (if any)
this._navigateFromHash({ fromHash: true, restoreScroll: true });
this.$nextTick(() => {
Alpine.store('sharing')?.handleInitialShareLinks();
});
},
_setHash(hash) {
this._hashNav = true;
this._activeHash = hash;
location.hash = hash;
// Reset guard after a tick
setTimeout(() => { this._hashNav = false; }, 0);
},
_navigateFromHash(options = {}) {
if (this._hashNav) return;
const hash = location.hash || '#artists';
const match = hash.match(/^#(\w+)(?:\/([-]?\d+))?(?:\?(.*))?$/);
if (!match) {
this.goArtists(options);
return;
}
const view = match[1];
const id = match[2] ? parseInt(match[2], 10) : null;
const params = match[3] || '';
if (view === 'artists' && !id) {
if (this.view !== 'artists' || this.artistsPage === 0) this.goArtists(options);
else if (options.restoreScroll) this._restoreScrollPosition(hash);
} else if (view === 'uploads' && !id) {
if (this.view !== 'my_uploads' || this.artistsPage === 0) this.goMyUploads(options);
else if (options.restoreScroll) this._restoreScrollPosition(hash);
} else if (view === 'artist' && id) {
this.openArtist(id, options);
} else if (view === 'artistfed') {
const nameMatch = params.match(/name=([^&]*)/);
if (nameMatch) this.openFederatedArtist(decodeURIComponent(nameMatch[1]), options);
} else if (view === 'release' && id) {
this.openRelease(id, options);
} else if (view === 'releasefed') {
const keyMatch = params.match(/key=([^&]*)/);
const release = keyMatch
? this._federatedReleaseCache[decodeURIComponent(keyMatch[1])]
: null;
if (release) this.openFederatedRelease(release, options);
else this.goArtists(options);
} else if (view === 'playlist' && id) {
this.openPlaylist(id, options);
} else if (view === 'search') {
const qMatch = params.match(/q=([^&]*)/);
if (qMatch) {
const q = decodeURIComponent(qMatch[1]);
this.searchQuery = q;
this.search(q, options);
}
} else {
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.focusSharedTrackId) {
this.$nextTick(() => {
Alpine.store('sharing')?.focusSharedTrack(options.focusSharedTrackId);
});
} else if (options.restoreScroll) {
this._restoreScrollPosition(this._activeHash);
} else {
this.$nextTick(() => { this._scrollToTop(); });
}
},
_beginNavigation(hash, options = {}) {
if (!hash.startsWith('#artistfed')) this.stopArtistFederation();
if (!hash.startsWith('#release/')) this.stopReleaseFederation();
if (!options.fromHash) {
this._saveScrollPosition(this._activeHash);
this._setHash(hash);
} else {
this._activeHash = hash;
}
},
_resetArtistList(filter) {
this.artistFilter = filter;
this.artists = [];
this.artistsPage = 0;
this.artistsTotal = 0;
this._allLoaded = false;
this.loading = false;
this._artistsLoadToken += 1;
},
goArtists(options = {}) {
this._beginNavigation('#artists', options);
if (this.artistFilter !== 'all' || this.artistsPage === 0) {
this._resetArtistList('all');
this.loadArtists(1);
}
this.view = 'artists';
this.currentArtist = null;
this.currentRelease = null;
this.currentPlaylist = null;
this.searchQuery = '';
this.searchResults = null;
this._previousView = 'artists';
this.$nextTick(() => { this._setupScroll(); });
this._afterNavigation(options);
},
goMyUploads(options = {}) {
this._beginNavigation('#uploads', options);
if (this.artistFilter !== 'uploads' || this.artistsPage === 0) {
this._resetArtistList('uploads');
this.loadArtists(1);
}
this.view = 'my_uploads';
this.currentArtist = null;
this.currentRelease = null;
this.currentPlaylist = null;
this.searchQuery = '';
this.searchResults = null;
this._previousView = 'my_uploads';
this.$nextTick(() => { this._setupScroll(); });
this._afterNavigation(options);
},
async loadArtists(page) {
if (this.loading || this._allLoaded) return;
this.loading = true;
const filter = this.artistFilter;
const token = this._artistsLoadToken + 1;
this._artistsLoadToken = token;
try {
const mine = filter === 'uploads' ? '&mine=true' : '';
const res = await fetch(`/api/player/artists?page=${page}&limit=80${mine}`);
if (!res.ok) throw new Error('failed');
const data = await res.json();
if (token !== this._artistsLoadToken || filter !== this.artistFilter) return;
const incoming = Array.isArray(data.items) ? data.items : [];
if (page === 1) {
this.artists = incoming;
} else {
const existing = new Set(this.artists.map(artist => artist.id));
this.artists = [...this.artists, ...incoming.filter(artist => !existing.has(artist.id))];
}
this.artistsPage = data.page;
this.artistsTotal = data.total;
if (data.has_more === false || this.artists.length >= data.total) {
this._allLoaded = true;
}
} catch {}
if (token === this._artistsLoadToken) {
this.loading = false;
this.$nextTick(() => { this._fillArtistViewport(); });
}
},
isFeaturedOnlyArtist(artist) {
return Number(artist?.release_count || 0) <= 0 && Number(artist?.track_count || 0) > 0;
},
shouldShowFeaturedSeparator(index) {
if (!(this.view === 'artists' || this.view === 'my_uploads') || index <= 0) return false;
const current = this.artists[index];
const previous = this.artists[index - 1];
return this.isFeaturedOnlyArtist(current) && !this.isFeaturedOnlyArtist(previous);
},
async openArtist(id, options = {}) {
this.stopArtistFederation();
this._beginNavigation('#artist/' + id, options);
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();
this.startArtistFederation(this.currentArtist.name);
} catch {}
this._afterNavigation(options);
},
stopArtistFederation() {
if (this._artistFederationSource) {
this._artistFederationSource.close();
this._artistFederationSource = null;
}
this.artistFederation = { loading: false, error: '', releases: [], tracks: [] };
},
async openFederatedArtist(artist, options = {}) {
const name = typeof artist === 'string' ? artist : artist?.name;
if (!name) return;
this.stopFederationSearch();
this.stopArtistFederation();
this._beginNavigation('#artistfed?name=' + encodeURIComponent(name), options);
this.searchQuery = '';
this.searchResults = null;
this.view = 'artist_detail';
this.currentArtist = {
id: null,
name,
image_url: null,
releases: [],
top_tracks: [],
featured_tracks: [],
total_track_count: 0,
total_play_count: 0,
federated: true,
};
this.startArtistFederation(name);
const requestedKey = name.trim().toLocaleLowerCase();
try {
const search = await fetch(`/api/player/search?q=${encodeURIComponent(name)}&limit=50`);
if (search.ok) {
const results = await search.json();
const local = (results.artists || []).find(candidate =>
candidate.name?.trim().toLocaleLowerCase() === requestedKey
);
if (local && this.view === 'artist_detail'
&& this.currentArtist?.name?.trim().toLocaleLowerCase() === requestedKey) {
const detail = await fetch(`/api/player/artists/${local.id}`);
if (detail.ok) {
const localArtist = await detail.json();
if (this.currentArtist?.name?.trim().toLocaleLowerCase() === requestedKey) {
this.currentArtist = { ...localArtist, federated: true };
}
}
}
}
} catch {}
this._afterNavigation(options);
},
startArtistFederation(name) {
this.artistFederation = { loading: true, error: '', releases: [], tracks: [] };
const source = new EventSource(
`/api/player/federation/artists/events?name=${encodeURIComponent(name)}`
);
this._artistFederationSource = source;
source.addEventListener('federation.artist.catalog', event => {
if (this._artistFederationSource !== source) return;
const payload = JSON.parse(event.data);
const owner = payload.peer;
const artist = payload.entity || {};
const artistName = artist.name || name;
const artworkBase = this.federationDiscoveredArtwork(artistName);
if (!this.currentArtist?.image_url) {
this.currentArtist = { ...this.currentArtist, image_url: artworkBase };
}
const releases = [...this.artistFederation.releases];
const tracks = [...this.artistFederation.tracks];
const catalogReleases = [
...(artist.releases || []),
...(artist.appears_on || []).map(appearance => ({
title: appearance.release_title,
release_type: appearance.release_type,
year: appearance.year,
tracks: [appearance.track],
})),
];
for (const release of catalogReleases) {
const releaseKey = [
release.title?.trim().toLocaleLowerCase(),
release.year || '',
release.release_type || '',
].join('|');
const wireTracks = (release.tracks || [])
.filter(item => item.content_id && item.item_id)
.map(item => this.withTrackAvailability({
key: { content_id: item.content_id },
metadata: {
title: item.title,
artists: (item.artists || []).map(value => ({
key: { normalized_name: value.trim().toLocaleLowerCase() },
name: value,
local_id: null,
})),
featured_artists: (item.featured_artists || []).map(value => ({
key: { normalized_name: value.trim().toLocaleLowerCase() },
name: value,
local_id: null,
})),
release: { title: release.title },
year: release.year,
duration_seconds: item.duration_seconds,
track_number: item.track_number,
disc_number: item.disc_number,
cover_url: `/api/player/federation/tracks/artwork?owner=${owner}&item_id=${item.item_id}`,
},
availability: {
state: 'federated',
local: null,
federation: [{ owner, item_id: item.item_id }],
},
}));
const existingRelease = releases.find(item => item.key === releaseKey);
if (existingRelease) {
const known = new Set(existingRelease.tracks.map(item => item.key.content_id));
existingRelease.tracks = [
...existingRelease.tracks,
...wireTracks.filter(item => !known.has(item.key.content_id)),
];
if (!existingRelease.owners.includes(owner)) existingRelease.owners.push(owner);
} else {
releases.push({
key: releaseKey,
title: release.title,
release_type: release.release_type || 'other',
year: release.year,
owners: [owner],
cover_url: this.federationDiscoveredArtwork(artistName, release.title),
tracks: wireTracks,
});
}
for (const track of wireTracks) {
const existingTrack = tracks.find(item => item.key.content_id === track.key.content_id);
if (existingTrack) {
const sourceInfo = track.availability.federation[0];
if (!existingTrack.availability.federation.some(item =>
item.owner === sourceInfo.owner && item.item_id === sourceInfo.item_id
)) existingTrack.availability.federation.push(sourceInfo);
} else {
tracks.push(track);
}
}
}
this.artistFederation = {
...this.artistFederation,
releases: [...releases],
tracks: [...tracks],
};
if (this.currentArtist?.releases?.length) {
this.currentArtist = {
...this.currentArtist,
releases: this.currentArtist.releases.map(localRelease => ({
...localRelease,
cover_url: localRelease.cover_url || this.federationDiscoveredArtwork(
artistName,
localRelease.title,
),
})),
};
}
});
source.addEventListener('artist.completed', () => {
if (this._artistFederationSource === source) {
this.artistFederation = { ...this.artistFederation, loading: false };
this._artistFederationSource = null;
}
source.close();
});
source.onerror = () => {
if (this._artistFederationSource !== source) return;
this.artistFederation = {
...this.artistFederation,
loading: false,
error: 'Federation connection interrupted',
};
this._artistFederationSource = null;
source.close();
};
},
artistReleaseGroups() {
const releases = this.currentArtist?.releases || [];
const order = ['album', 'ep', 'single', 'compilation', 'mixtape', 'live', 'soundtrack'];
const labels = {
album: T.albums,
ep: T.eps,
single: T.singles,
compilation: T.compilations,
mixtape: T.mixtapes,
live: T.liveReleases,
soundtrack: T.soundtracks,
};
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) {
if (!value) return T.unknownSize;
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];
},
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)}`;
},
async startRadio(kind, id) {
if (!kind || !id) return;
try {
const res = await fetch(`/api/player/radio/${encodeURIComponent(kind)}/${encodeURIComponent(id)}`);
if (!res.ok) throw new Error('radio failed');
const tracks = await res.json();
if (!Array.isArray(tracks) || tracks.length === 0) throw new Error('radio empty');
Alpine.store('queue').playRelease(tracks, 0);
Alpine.store('info').close();
} catch (err) {
console.warn(err);
window.alert(T.radioFailed);
}
},
openTrackInfo(track) {
Alpine.store('info').openRows(T.trackInfoTitle, this.trackInfoRows(track), [
{ label: T.startRadio, run: () => this.startRadio('track', track?.id) },
]);
},
uploadersInfo(uploaders) {
const rows = uploaders || [];
if (!rows.length) return 'UFO';
return rows
.map(row => `${row.name || 'UFO'} (${row.track_count} ${T.trackWord})`)
.join(', ');
},
releaseInfo(release) {
if (!release) return '';
const lines = [
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 || [])}`,
];
return lines.join('\n');
},
openReleaseInfo(release) {
Alpine.store('info').openRows(T.releaseInfoTitle, this.releaseInfoRows(release), [
{ label: T.startRadio, run: () => this.startRadio('release', release?.id) },
]);
},
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;
},
trackInfo(track) {
if (!track) return '';
const artists = this.trackArtistLinks(track).map(artist => artist.label).join(', ') || T.unknown;
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 lines = [
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'}`,
];
if (track.lastfm_rating != null || track.lastfm_listeners != null || track.lastfm_playcount != null) {
const rating = Number(track.lastfm_rating || 0);
lines.push(`${T.lastfmRating}: ${Number.isFinite(rating) ? Math.round(rating) : T.unknown}`);
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}`);
}
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;
},
playArtistTopTracks() {
const tracks = this.currentArtist?.top_tracks || [];
if (tracks.length) {
Alpine.store('queue').playRelease(tracks, 0);
return;
}
const federated = this.artistFederation.tracks[0];
if (federated) this.playFederatedTrack(federated);
},
async openRelease(id, options = {}) {
this.stopReleaseFederation();
this._beginNavigation('#release/' + id, options);
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();
if (!this.currentRelease.cover_url && this.currentRelease.artists?.[0]?.name) {
this.currentRelease = {
...this.currentRelease,
cover_url: this.federationDiscoveredArtwork(
this.currentRelease.artists[0].name,
this.currentRelease.title,
),
};
}
this.startReleaseFederation(this.currentRelease);
} catch {}
this._afterNavigation(options);
},
stopReleaseFederation() {
if (this._releaseFederationSource) {
this._releaseFederationSource.close();
this._releaseFederationSource = null;
}
this.releaseFederationLoading = false;
},
startReleaseFederation(release) {
const artistName = release?.artists?.[0]?.name;
if (!artistName || !release?.title) return;
this.stopReleaseFederation();
this.releaseFederationLoading = true;
const source = new EventSource(
`/api/player/federation/artists/events?name=${encodeURIComponent(artistName)}`
);
this._releaseFederationSource = source;
const wantedTitle = this.normalizeFederationSearchText(release.title);
source.addEventListener('federation.artist.catalog', event => {
if (this._releaseFederationSource !== source || !this.currentRelease) return;
const payload = JSON.parse(event.data);
const owner = payload.peer;
const catalog = payload.entity || {};
const matches = (catalog.releases || []).filter(candidate =>
this.normalizeFederationSearchText(candidate.title) === wantedTitle
&& (!release.year || !candidate.year || Number(candidate.year) === Number(release.year))
);
if (!matches.length) return;
const existing = new Set(
(this.currentRelease.tracks || []).map(track => track.content_id).filter(Boolean)
);
const additions = [];
for (const candidate of matches) {
for (const item of candidate.tracks || []) {
if (!item.content_id || !item.item_id || existing.has(item.content_id)) continue;
const federated = this.withTrackAvailability({
key: { content_id: item.content_id },
metadata: {
title: item.title,
artists: (item.artists || []).map(name => ({
key: { normalized_name: this.normalizeFederationSearchText(name) },
name,
local_id: null,
})),
featured_artists: (item.featured_artists || []).map(name => ({
key: { normalized_name: this.normalizeFederationSearchText(name) },
name,
local_id: null,
})),
release: { title: candidate.title },
year: candidate.year,
duration_seconds: item.duration_seconds,
track_number: item.track_number,
disc_number: item.disc_number,
cover_url: this.federationDiscoveredArtwork(artistName, candidate.title),
},
availability: {
state: 'federated',
local: null,
federation: [{ owner, item_id: item.item_id }],
},
});
additions.push(this.federationQueueTrack(federated));
existing.add(item.content_id);
}
}
if (additions.length) {
const tracks = [...this.currentRelease.tracks, ...additions].sort((left, right) =>
Number(left.disc_number || 1) - Number(right.disc_number || 1)
|| Number(left.track_number || 9999) - Number(right.track_number || 9999)
|| String(left.title).localeCompare(String(right.title))
);
this.currentRelease = { ...this.currentRelease, tracks };
}
});
source.addEventListener('artist.completed', () => {
if (this._releaseFederationSource === source) {
this._releaseFederationSource = null;
this.releaseFederationLoading = false;
}
source.close();
});
source.onerror = () => {
if (this._releaseFederationSource === source) {
this._releaseFederationSource = null;
this.releaseFederationLoading = false;
}
source.close();
};
},
async openPlaylist(id, options = {}) {
this._beginNavigation('#playlist/' + id, options);
Alpine.store('playlists')?.stopEdit?.();
this.view = 'playlist_detail';
this.currentPlaylist = null;
try {
const res = await fetch(`/api/player/playlists/${id}`);
if (!res.ok) throw new Error('failed');
const playlist = await res.json();
const federationResponse = await fetch(`/api/player/playlists/${id}/federation-tracks`);
if (federationResponse.ok) {
const references = await federationResponse.json();
const federated = (references || [])
.map(reference => {
const track = this.federationTrackFromWire(reference.federation);
if (!track) return null;
return {
...this.federationQueueTrack(track),
sort_key: Number(reference.position || 0),
};
})
.filter(Boolean);
playlist.tracks = [...(playlist.tracks || []), ...federated];
if (Number(id) === -1) playlist.tracks = this.sortLikeTracks(playlist.tracks);
}
this.currentPlaylist = playlist;
} catch {}
this._afterNavigation(options);
},
showSharedPlaylist(share, options = {}) {
Alpine.store('playlists')?.stopEdit?.();
this._saveScrollPosition(this._activeHash);
this.searchQuery = '';
this.searchResults = null;
this.currentArtist = null;
this.currentRelease = null;
this.currentPlaylist = {
id: 0,
title: share?.title || T.sharedPlaylist,
description: null,
is_own: false,
owner_name: null,
is_public: false,
is_saved: false,
kind: 'shared',
tracks: Array.isArray(share?.tracks) ? share.tracks : [],
};
this.view = 'playlist_detail';
this._previousView = 'artists';
this._afterNavigation(options);
},
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 {}
},
async search(query, options = {}) {
const q = (query || '').trim();
if (!q) {
this.clearSearch();
return;
}
this._beginNavigation('#search?q=' + encodeURIComponent(q), options);
if (this.view !== 'search') {
this._previousView = this.view;
}
this.view = 'search';
this.searchLoading = true;
this.startFederationSearch(q);
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;
this._afterNavigation(options);
},
clearSearch() {
this.stopFederationSearch();
this.searchQuery = '';
this.searchResults = null;
this.searchLoading = false;
if (this.view === 'search') {
this.view = this._previousView || 'artists';
this._setHash(this.view === 'my_uploads' ? '#uploads' : '#artists');
if (this.view === 'artists' || this.view === 'my_uploads') {
this.$nextTick(() => { this._setupScroll(); });
}
}
},
stopFederationSearch() {
if (this._federationSearchSource) {
this._federationSearchSource.close();
this._federationSearchSource = null;
}
this.federationSearch = { loading: false, error: '', artists: [], releases: [], tracks: [] };
},
startFederationSearch(query) {
this.stopFederationSearch();
this.federationSearch = { loading: true, error: '', artists: [], releases: [], tracks: [] };
const source = new EventSource(`/api/player/federation/search/events?q=${encodeURIComponent(query)}`);
this._federationSearchSource = source;
const updateResults = (kind, keyFor, labelFor, entity) => {
if (this._federationSearchSource !== source) return;
const items = [...this.federationSearch[kind]];
const key = keyFor(entity);
const index = items.findIndex(item => keyFor(item) === key);
if (index >= 0) items.splice(index, 1, entity);
else items.push(entity);
items.sort((left, right) => {
const score = this.federationSearchScore(labelFor(right), query)
- this.federationSearchScore(labelFor(left), query);
return score || String(labelFor(left) || '').localeCompare(
String(labelFor(right) || ''),
undefined,
{ sensitivity: 'base' },
);
});
this.federationSearch = { ...this.federationSearch, [kind]: items };
};
const watchdog = window.setTimeout(() => {
if (!this.federationSearch.loading || this._federationSearchSource !== source) return;
this.federationSearch = {
...this.federationSearch,
error: 'Federation search timed out',
loading: false,
};
source.close();
this._federationSearchSource = null;
}, 25000);
const upsertTrack = event => {
const payload = JSON.parse(event.data);
const track = this.withTrackAvailability(payload.entity);
if (!track?.key?.content_id) return;
updateResults('tracks', item => item.key.content_id, item => item.metadata?.title, track);
};
source.addEventListener('federation.track', upsertTrack);
source.addEventListener('federation.artist', event => {
const artist = JSON.parse(event.data)?.entity;
const key = artist?.key?.normalized_name;
if (!key) return;
updateResults('artists', item => item.key.normalized_name, item => item.name, artist);
});
source.addEventListener('federation.release', event => {
const release = JSON.parse(event.data)?.entity;
if (!release?.key) return;
updateResults('releases', item => JSON.stringify(item.key || {}), item => item.title, release);
});
source.addEventListener('search.completed', () => {
window.clearTimeout(watchdog);
if (this._federationSearchSource === source) {
this.federationSearch = { ...this.federationSearch, loading: false };
}
source.close();
if (this._federationSearchSource === source) this._federationSearchSource = null;
});
source.addEventListener('search.failed', event => {
window.clearTimeout(watchdog);
let error = 'Federation search failed';
try {
error = JSON.parse(event.data)?.entity?.message || error;
} catch {}
if (this._federationSearchSource === source) {
this.federationSearch = { ...this.federationSearch, error, loading: false };
}
source.close();
if (this._federationSearchSource === source) this._federationSearchSource = null;
});
source.onerror = () => {
if (!this.federationSearch.loading || this._federationSearchSource !== source) return;
window.clearTimeout(watchdog);
this.federationSearch = {
...this.federationSearch,
error: 'Federation connection interrupted',
loading: false,
};
source.close();
if (this._federationSearchSource === source) this._federationSearchSource = null;
};
},
normalizeFederationSearchText(value) {
return String(value || '')
.normalize('NFKD')
.toLocaleLowerCase()
.replace(/ё/g, 'е')
.replace(/[^\p{L}\p{N}]+/gu, ' ')
.trim()
.replace(/\s+/g, ' ');
},
federationSearchScore(value, query) {
const candidate = this.normalizeFederationSearchText(value);
const wanted = this.normalizeFederationSearchText(query);
if (!candidate || !wanted) return 0;
if (candidate === wanted) return 100000;
if (candidate.startsWith(wanted + ' ')) return 90000;
if (candidate.includes(wanted)) return 80000;
const candidateWords = candidate.split(' ');
const wantedWords = wanted.split(' ');
const exactWords = wantedWords.filter(word => candidateWords.includes(word)).length;
const prefixWords = wantedWords.filter(word =>
candidateWords.some(candidateWord => candidateWord.startsWith(word))
).length;
const allExact = exactWords === wantedWords.length;
const allPrefixes = prefixWords === wantedWords.length;
if (allExact) return 70000 - Math.abs(candidateWords.length - wantedWords.length) * 100;
if (allPrefixes) return 60000 - Math.abs(candidateWords.length - wantedWords.length) * 100;
return exactWords * 5000 + prefixWords * 2500
- Math.abs(candidate.length - wanted.length);
},
federationArtistLine(track) {
return [...(track?.metadata?.artists || []), ...(track?.metadata?.featured_artists || [])]
.map(artist => artist.name)
.filter(Boolean)
.join(', ');
},
federationArtistImage(artist) {
if (!artist?.name) return '';
return this.federationDiscoveredArtwork(artist.name);
},
federationDiscoveredArtwork(artist, release = '') {
if (!artist) return '';
const params = new URLSearchParams({ artist });
if (release) params.set('release', release);
return `/api/player/federation/catalog/artwork/discover?${params.toString()}`;
},
async loadFederationLikes() {
try {
const response = await fetch('/api/player/tracks/content/like');
if (response.ok) this.federationLiked = await response.json();
} catch {}
},
federationIsLiked(track) {
return this.federationLiked.includes(track?.key?.content_id);
},
federationWireTrack(track) {
const metadata = track?.metadata || {};
const source = track?.availability?.federation?.[0] || {};
return {
item_id: source.item_id || '',
owner: source.owner || '',
title: metadata.title || '',
artist_names: (metadata.artists || []).map(artist => artist.name),
featured_artist_names: (metadata.featured_artists || []).map(artist => artist.name),
year: metadata.year || null,
duration_seconds: metadata.duration_seconds == null ? null : Math.round(metadata.duration_seconds),
content_id: track?.key?.content_id || '',
release_title: metadata.release?.title || null,
track_number: metadata.track_number || null,
disc_number: metadata.disc_number || null,
};
},
federationTrackFromWire(wire) {
if (!wire?.content_id || !wire?.owner || !wire?.item_id) return null;
return this.withTrackAvailability({
key: { content_id: wire.content_id },
metadata: {
title: wire.title || 'Unknown track',
artists: (wire.artist_names || []).map(name => ({
key: { normalized_name: name.trim().toLocaleLowerCase() },
name,
local_id: null,
})),
featured_artists: (wire.featured_artist_names || []).map(name => ({
key: { normalized_name: name.trim().toLocaleLowerCase() },
name,
local_id: null,
})),
release: wire.release_title ? { title: wire.release_title } : null,
year: wire.year,
duration_seconds: wire.duration_seconds,
track_number: wire.track_number,
disc_number: wire.disc_number,
cover_url: `/api/player/federation/tracks/artwork?owner=${encodeURIComponent(wire.owner)}&item_id=${encodeURIComponent(wire.item_id)}`,
},
availability: {
state: 'federated',
local: null,
federation: [{ owner: wire.owner, item_id: wire.item_id }],
},
});
},
federationQueueTrack(track) {
const metadata = track?.metadata || {};
return {
id: `fed:${track.key.content_id}`,
content_id: track.key.content_id,
title: metadata.title || 'Unknown track',
track_number: metadata.track_number,
disc_number: metadata.disc_number,
duration_seconds: metadata.duration_seconds || 0,
artists: (metadata.artists || []).map(artist => ({
id: artist.local_id || null,
name: artist.name,
})),
featured_artists: (metadata.featured_artists || []).map(artist => ({
id: artist.local_id || null,
name: artist.name,
})),
release_id: null,
release_title: metadata.release?.title || '',
release_year: metadata.year || null,
cover_url: metadata.cover_url || null,
stream_url: '',
uploader_name: 'Federation',
federation_pending: true,
_federationTrack: track,
};
},
openFederatedRelease(release, options = {}) {
if (!release?.key) return;
this._federatedReleaseCache[release.key] = release;
this._beginNavigation('#releasefed?key=' + encodeURIComponent(release.key), options);
const queuedTracks = (release.tracks || []).map(track => this.federationQueueTrack(track));
const first = queuedTracks[0];
this.currentRelease = {
id: null,
title: release.title,
release_type: release.release_type || 'release',
year: release.year,
cover_url: release.cover_url,
artists: first?.artists || [],
tracks: queuedTracks,
uploaders: (release.owners || []).map(owner => ({
name: `Federation ${owner.slice(0, 10)}`,
track_count: queuedTracks.length,
})),
federated: true,
federationRelease: release,
};
this.view = 'release_detail';
this._afterNavigation(options);
},
playFederatedRelease(release) {
const tracks = (release?.tracks || []).map(track => this.federationQueueTrack(track));
if (tracks.length) Alpine.store('queue').playRelease(tracks, 0);
},
enqueueFederatedTrack(track, next = false) {
const queued = this.federationQueueTrack(track);
if (next) Alpine.store('queue').addNextInQueue([queued]);
else Alpine.store('queue').addToEnd([queued]);
},
openFederationTrackInfo(track) {
const metadata = track?.metadata || {};
const sources = track?.availability?.federation || [];
const rows = [
{ label: T.title, value: metadata.title || T.unknown },
{ label: T.artists, value: this.federationArtistLine(track) || T.unknown },
{ label: T.release, value: metadata.release?.title || T.unknown },
{ label: T.year, value: metadata.year || T.unknown },
{ label: T.duration, value: formatTime(metadata.duration_seconds) },
{ label: 'Content ID', value: track?.key?.content_id || T.unknown },
{ label: 'Availability', value: this.isTrackLocal(track) ? 'Local' : `${sources.length} peer${sources.length === 1 ? '' : 's'}` },
{ label: 'Source', value: 'Federation' },
];
Alpine.store('info').openRows(T.trackInfoTitle, rows);
},
withTrackAvailability(track) {
const contentId = track?.key?.content_id;
const known = contentId ? this.trackAvailability[contentId] : null;
if (!known) return track;
return {
...track,
availability: {
...(track.availability || {}),
state: known.state,
local: known.local ?? track.availability?.local ?? null,
},
};
},
isTrackLocal(track) {
const contentId = track?.key?.content_id;
return (contentId && this.trackAvailability[contentId]?.state === 'local')
|| track?.availability?.state === 'local'
|| !!track?.availability?.local;
},
applyTrackAvailability(change) {
const contentId = change?.content_id;
if (!contentId || !change.state) return;
const previous = this.trackAvailability[contentId] || {};
const availability = {
...previous,
...change,
content_id: contentId,
};
this.trackAvailability = {
...this.trackAvailability,
[contentId]: availability,
};
const updateTrack = track => track?.key?.content_id === contentId
? this.withTrackAvailability(track)
: track;
this.federationSearch = {
...this.federationSearch,
tracks: this.federationSearch.tracks.map(updateTrack),
};
this.artistFederation = {
...this.artistFederation,
tracks: this.artistFederation.tracks.map(updateTrack),
releases: this.artistFederation.releases.map(release => ({
...release,
tracks: (release.tracks || []).map(updateTrack),
})),
};
},
publishTrackAvailability(change) {
this.applyTrackAvailability(change);
window.dispatchEvent(new CustomEvent('furumi:track-availability', {
detail: { ...change, source: 'library' },
}));
},
refreshMaterializedArtwork(track) {
const metadata = track?.metadata || {};
const names = (metadata.artists || []).map(artist =>
this.normalizeFederationSearchText(artist.name)
);
if (names.length) {
this.artists = this.artists.map(artist => {
if (artist.image_url
|| !names.includes(this.normalizeFederationSearchText(artist.name))) {
return artist;
}
return {
...artist,
image_url: this.federationDiscoveredArtwork(artist.name),
};
});
}
},
async toggleFederationLike(track) {
const contentId = track?.key?.content_id;
if (!contentId) return;
const liked = !this.federationLiked.includes(contentId);
return this.setFederationLike(track, liked);
},
async setFederationLike(track, liked) {
const contentId = track?.key?.content_id;
if (!contentId) return;
const response = await fetch('/api/player/tracks/content/like', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
content_id: contentId,
liked,
federation: this.federationWireTrack(track),
}),
});
if (!response.ok) return;
if (liked) this.federationLiked.push(contentId);
else this.federationLiked = this.federationLiked.filter(id => id !== contentId);
if (this.currentPlaylist?.id === -1) {
if (liked && !this.currentPlaylist.tracks.some(item => item.content_id === contentId)) {
this.currentPlaylist = {
...this.currentPlaylist,
tracks: this.sortLikeTracks([
...this.currentPlaylist.tracks,
{
...this.federationQueueTrack(track),
sort_key: Date.now(),
},
]),
};
} else if (!liked) {
this.currentPlaylist = {
...this.currentPlaylist,
tracks: this.currentPlaylist.tracks.filter(item => item.content_id !== contentId),
};
}
}
},
sortLikeTracks(tracks) {
return [...(tracks || [])].sort((left, right) => {
const order = Number(right.sort_key || 0) - Number(left.sort_key || 0);
if (order) return order;
const stable = String(left.content_id || left.id || '')
.localeCompare(String(right.content_id || right.id || ''));
if (stable) return stable;
const title = String(left.title || '').localeCompare(
String(right.title || ''),
undefined,
{ sensitivity: 'base' },
);
return title;
});
},
isMixedReleaseLiked(release) {
const tracks = release?.tracks || [];
if (!tracks.length) return false;
return tracks.every(track => track.federation_pending
? this.federationIsLiked(track._federationTrack)
: Alpine.store('likes').has(track.id)
);
},
async toggleMixedReleaseLike(release) {
const tracks = release?.tracks || [];
if (!tracks.length) return;
const liked = !this.isMixedReleaseLiked(release);
const operations = [];
for (const track of tracks) {
if (track.federation_pending) {
if (this.federationIsLiked(track._federationTrack) !== liked) {
operations.push(this.setFederationLike(track._federationTrack, liked));
}
} else if (Alpine.store('likes').has(track.id) !== liked) {
operations.push(Alpine.store('likes').toggle(track.id));
}
}
await Promise.allSettled(operations);
Alpine.store('playlists').reload();
},
async playFederatedTrack(track, options = {}) {
const contentId = track?.key?.content_id;
const sources = track?.availability?.federation || [];
const knownPlayable = contentId && this.trackAvailability[contentId]?.playable;
if (knownPlayable) {
if (Number.isInteger(options.queueIndex)) {
const queue = Alpine.store('queue');
queue.tracks = queue.tracks.map((item, index) =>
index === options.queueIndex ? knownPlayable : item
);
if (options.playAfterPrepare !== false) {
Alpine.store('player').playQueueIndex(options.queueIndex);
}
} else {
Alpine.store('queue').playRelease([knownPlayable], 0);
}
return;
}
if (!contentId || sources.length === 0) return;
if (this.federationFailures[contentId]) {
const failures = { ...this.federationFailures };
delete failures[contentId];
this.federationFailures = failures;
}
if (this.federationPreparing[contentId]) {
if (options.playAfterPrepare !== false) {
this.federationPreparing = {
...this.federationPreparing,
[contentId]: {
...this.federationPreparing[contentId],
playWhenReady: true,
queueIndex: options.queueIndex,
},
};
}
return;
}
this.federationPreparing = {
...this.federationPreparing,
[contentId]: {
phase: 'connecting',
received: 0,
total: 0,
percent: 0,
bytesPerSecond: 0,
sampledAt: performance.now(),
playWhenReady: options.playAfterPrepare !== false,
queueIndex: options.queueIndex,
},
};
try {
let prepared = null;
let lastError = 'Federated track download failed';
for (const source of sources) {
const preparedResponse = await fetch('/api/player/federation/tracks/prepare', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
content_id: contentId,
owner: source.owner,
item_id: source.item_id,
}),
});
if (!preparedResponse.ok || !preparedResponse.body) {
const failure = await preparedResponse.json().catch(() => ({}));
lastError = failure.error || lastError;
continue;
}
const reader = preparedResponse.body.getReader();
const decoder = new TextDecoder();
let buffered = '';
let sourceFailed = false;
const consume = line => {
if (!line.trim()) return;
const event = JSON.parse(line);
if (event.kind === 'progress') {
const previous = this.federationPreparing[contentId];
if (!previous) return;
const now = performance.now();
const seconds = Math.max((now - previous.sampledAt) / 1000, 0.001);
const instantSpeed = event.received > previous.received
? (event.received - previous.received) / seconds
: previous.bytesPerSecond;
const speed = previous.bytesPerSecond > 0 && instantSpeed > 0
? previous.bytesPerSecond * 0.65 + instantSpeed * 0.35
: instantSpeed;
this.federationPreparing = {
...this.federationPreparing,
[contentId]: {
...previous,
phase: event.phase,
received: Number(event.received || 0),
total: Number(event.total || 0),
percent: event.total > 0
? Math.min(100, event.received / event.total * 100)
: 0,
bytesPerSecond: speed,
sampledAt: now,
},
};
} else if (event.kind === 'completed') {
prepared = event;
} else if (event.kind === 'failed') {
sourceFailed = true;
lastError = event.error || lastError;
}
};
while (true) {
const { value, done } = await reader.read();
buffered += decoder.decode(value || new Uint8Array(), { stream: !done });
const lines = buffered.split('\n');
buffered = lines.pop() || '';
for (const line of lines) consume(line);
if (done) {
consume(buffered);
break;
}
}
if (prepared) break;
if (!sourceFailed) lastError = 'Federated download ended unexpectedly';
}
if (!prepared) throw new Error(lastError);
let playable;
if (prepared.local_track_id != null) {
const tracksResponse = await fetch('/api/player/tracks-by-ids', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ids: [prepared.local_track_id] }),
});
const tracks = await tracksResponse.json();
playable = tracks?.[0];
} else {
const metadata = track.metadata || {};
playable = {
id: `fed:${contentId}`,
content_id: contentId,
title: metadata.title || 'Unknown track',
duration_seconds: metadata.duration_seconds || 0,
artists: metadata.artists || [],
featured_artists: metadata.featured_artists || [],
release_id: null,
release_title: metadata.release?.title || '',
release_year: metadata.year || metadata.release?.key?.year || null,
cover_url: metadata.cover_url || null,
stream_url: prepared.stream_url,
uploader_name: 'Federation',
federated_cache: true,
federation: { content_id: contentId, sources: track.availability.federation },
};
}
if (!playable) throw new Error('Prepared track is unavailable');
const queueCover = Number.isInteger(options.queueIndex)
? Alpine.store('queue')?.tracks?.[options.queueIndex]?.cover_url
: null;
if (!playable.cover_url) {
playable.cover_url = queueCover || track?.metadata?.cover_url || null;
}
if (prepared.local_track_id != null) {
this.publishTrackAvailability({
content_id: contentId,
state: 'local',
local: {
track_id: prepared.local_track_id,
stream_url: prepared.stream_url,
},
playable,
});
this.refreshMaterializedArtwork(track);
}
const finalProgress = this.federationPreparing[contentId] || {};
const queueIndex = Number.isInteger(finalProgress.queueIndex)
? finalProgress.queueIndex
: options.queueIndex;
const playWhenReady = finalProgress.playWhenReady !== false;
if (Number.isInteger(queueIndex)) {
const queue = Alpine.store('queue');
queue.tracks = queue.tracks.map((item, index) =>
index === queueIndex ? playable : item
);
if (playWhenReady) Alpine.store('player').playQueueIndex(queueIndex);
} else {
Alpine.store('queue').playRelease([playable], 0);
}
} catch (error) {
this.federationFailures = {
...this.federationFailures,
[contentId]: {
message: error?.message || 'Federated track is temporarily unavailable',
failedAt: Date.now(),
},
};
} finally {
const next = { ...this.federationPreparing };
delete next[contentId];
this.federationPreparing = next;
}
},
federationDownload(contentId) {
return this.federationPreparing[contentId] || null;
},
federationFailure(contentId) {
return this.federationFailures[contentId] || null;
},
federationDownloadTooltip(contentId) {
const progress = this.federationDownload(contentId);
if (!progress) return '';
const phases = {
checking: 'Checking local library',
connecting: 'Connecting to peer',
discovering: 'Looking for another peer',
downloading: 'Downloading',
verifying: 'Verifying content ID',
saving: 'Adding to library',
ready: 'Ready',
};
let text = phases[progress.phase] || 'Preparing';
if (progress.total > 0) {
text += ` · ${progress.percent.toFixed(0)}%`;
text += ` · ${this.formatFederationBytes(progress.received)} / ${this.formatFederationBytes(progress.total)}`;
}
if (progress.bytesPerSecond > 0 && progress.phase === 'downloading') {
text += ` · ${this.formatFederationBytes(progress.bytesPerSecond)}/s`;
}
return text;
},
formatFederationBytes(value) {
const bytes = Math.max(0, Number(value || 0));
if (bytes < 1024) return `${Math.round(bytes)} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
if (bytes < 1024 * 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
return `${(bytes / 1024 / 1024 / 1024).toFixed(1)} GB`;
},
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'), rootMargin: '900px 0px', threshold: 0.01 });
this._observer.observe(sentinel);
this._fillArtistViewport();
});
},
_fillArtistViewport() {
if (!(this.view === 'artists' || this.view === 'my_uploads') || this.loading || this._allLoaded) return;
const el = this._scrollElement();
if (!el) return;
if (el.scrollHeight <= el.clientHeight + 900) {
this.loadArtists(this.artistsPage + 1);
}
},
$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,
localFiles: [],
magnet: '',
sessions: [],
loadingSessions: false,
currentJob: null,
previewData: null,
workspaceMode: 'empty',
treeRoot: null,
selected: new Set(),
expanded: new Set(),
loading: false,
message: '',
error: false,
_pollTimer: null,
_pollJobId: null,
_refreshTimer: null,
queuedTasks: 0,
processingTasks: 0,
loadingAgentStatus: false,
uploadProgress: 0,
uploadProgressText: '',
activeTab: 'import',
uploadTracks: [],
uploadReleases: [],
uploadPending: [],
uploadQueued: [],
uploadPendingTotal: 0,
uploadQueuedTotal: 0,
uploadQueueOffset: 0,
uploadQueuePageSize: 6,
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: '',
},
open() {
this.modal = true;
this.message = '';
this.error = false;
this.loadSessions();
this.loadAgentStatus();
if (this.activeTab === 'uploads') this.loadUploads();
this._startRefresh();
},
close() {
this.modal = false;
this._stopRefresh();
this._stopPoll();
},
_stopPoll() {
if (this._pollTimer) clearInterval(this._pollTimer);
this._pollTimer = null;
this._pollJobId = null;
},
isImporting() {
return this.workspaceMode === 'new';
},
showImportTab() {
this.activeTab = 'import';
this._setMessage('');
},
showUploadsTab() {
this.activeTab = 'uploads';
this._stopPoll();
this._setMessage('');
this.loadUploads();
},
addNew() {
if (this.loading) return;
this._stopPoll();
this.workspaceMode = 'new';
this.file = null;
this.localFiles = [];
this.magnet = '';
this.uploadProgress = 0;
this.uploadProgressText = '';
this.currentJob = null;
this.previewData = null;
this.treeRoot = null;
this.selected = new Set();
this.expanded = new Set();
this._setMessage(T.chooseTorrent);
},
_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;
},
isDownloading(job) {
return !!job && (job.active || job.status === 'downloading' || job.status === 'moving');
},
isCurrentDownloading() {
return this.isDownloading(this.currentJob);
},
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();
},
normalizedStatus(job) {
const status = String(job?.status || 'preview').toLowerCase();
if (status === 'complete') return 'completed';
return status;
},
statusLabel(job) {
const labels = {
preview: T.preview,
resolving: T.resolving,
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)));
},
clientSummary() {
const active = this.activeCount();
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;
},
statusText(job) {
if (!job) return T.noTorrentSelected;
const state = job.client_state ? ' / ' + job.client_state : '';
return this.statusLabel(job) + state;
},
speedText(job) {
if (!job) return '0 B/s';
const down = Number(job.download_speed_mbps || 0);
return down.toFixed(2) + ' MiB/s';
},
peerText(job) {
if (!job) return 'n/a';
const live = job.peers_live == null ? '?' : job.peers_live;
const seen = job.peers_seen == null ? '?' : job.peers_seen;
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() {
if (this.normalizedStatus(this.currentJob) === 'resolving') return T.resolving;
if (this.isCurrentCompletedLocked()) return T.completed;
return this.isCurrentDownloading() ? T.pauseDownload : T.downloadSelected;
},
actionButtonDisabled() {
return this.loading
|| this.isCurrentCompletedLocked()
|| this.normalizedStatus(this.currentJob) === 'resolving'
|| !this.previewData
|| !Array.isArray(this.previewData.files)
|| this.previewData.files.length === 0;
},
toggleDownloadAction() {
if (this.isCurrentCompletedLocked()) return;
if (this.isCurrentDownloading()) this.pause();
else this.start();
},
sessionMeta(job) {
if (!job) return '';
const size = this.bytes(job.selected_size || job.total_size);
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;
}
},
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 || T.loadUploadsFailed);
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 + ' ' + T.trackWord, releaseCount + ' ' + T.releaseWord];
if (this.uploadPendingTotal > 0) parts.push(this.uploadPendingTotal + ' ' + T.needsApproval);
if (this.uploadQueuedTotal > 0) parts.push(this.uploadQueuedTotal + ' ' + T.queued);
return parts.join(' / ');
},
uploadArtistsText(item) {
const track = item?.track || item;
const names = [
...((track?.artists || []).map(artist => artist.name)),
...((track?.featured_artists || []).map(artist => T.featuredShort + ' ' + artist.name)),
];
return names.join(', ') || T.unknown;
},
uploadReleaseArtistsText(release) {
const names = (release?.artists || []).map(artist => artist.name);
return names.join(', ') || T.unknown;
},
uploadArtistGroups() {
const collator = new Intl.Collator(undefined, { sensitivity: 'base', numeric: true });
const groups = new Map();
for (const release of this.uploadReleases) {
const artists = Array.isArray(release?.artists) ? release.artists : [];
const artistKey = artists
.map(artist => artist?.id != null ? 'id:' + artist.id : 'name:' + String(artist?.name || '').trim().toLowerCase())
.filter(Boolean)
.join('|') || 'unknown';
if (!groups.has(artistKey)) {
groups.set(artistKey, {
key: artistKey,
name: this.uploadReleaseArtistsText(release),
releases: [],
trackCount: 0,
});
}
const group = groups.get(artistKey);
group.releases.push(release);
group.trackCount += Array.isArray(release?.tracks) ? release.tracks.length : 0;
}
return [...groups.values()]
.map(group => ({
...group,
releases: group.releases.slice().sort((a, b) => {
const aDate = String(a?.tracks?.[0]?.uploaded_at || '');
const bDate = String(b?.tracks?.[0]?.uploaded_at || '');
return bDate.localeCompare(aDate) || collator.compare(a?.title || '', b?.title || '');
}),
}))
.sort((a, b) => collator.compare(a.name, b.name));
},
uploadFeaturedArtistsText(item) {
const track = item?.track || item;
return (track?.featured_artists || []).map(artist => artist.name).join(', ');
},
compactQueuedUploads() {
const maxOffset = Math.max(0, this.uploadQueued.length - this.uploadQueuePageSize);
this.uploadQueueOffset = Math.max(0, Math.min(this.uploadQueueOffset, maxOffset));
return this.uploadQueued.slice(this.uploadQueueOffset, this.uploadQueueOffset + this.uploadQueuePageSize);
},
uploadQueueCanPrev() {
return this.uploadQueueOffset > 0;
},
uploadQueueCanNext() {
return this.uploadQueueOffset + this.uploadQueuePageSize < this.uploadQueued.length;
},
uploadQueuePrev() {
this.uploadQueueOffset = Math.max(0, this.uploadQueueOffset - this.uploadQueuePageSize);
},
uploadQueueNext() {
const maxOffset = Math.max(0, this.uploadQueued.length - this.uploadQueuePageSize);
this.uploadQueueOffset = Math.min(maxOffset, this.uploadQueueOffset + this.uploadQueuePageSize);
},
uploadQueueRangeText() {
if (this.uploadQueued.length === 0) return '';
const start = this.uploadQueueOffset + 1;
const end = Math.min(this.uploadQueueOffset + this.uploadQueuePageSize, this.uploadQueued.length);
const total = Math.max(this.uploadQueuedTotal || 0, this.uploadQueued.length);
return start + '-' + end + ' / ' + total;
},
uploadQueueOverflowText() {
const total = Math.max(this.uploadQueuedTotal || 0, this.uploadQueued.length);
return T.showing + ' ' + this.uploadQueued.length + ' ' + T.ofWord + ' ' + total;
},
uploadQueueTooltip(item) {
if (!item) return '';
const lines = [];
if (item.filename) lines.push(T.fileLabel + ': ' + item.filename);
if (item.status) lines.push(T.statusLabelText + ': ' + item.status);
if (item.created_at) lines.push(T.createdLabel + ': ' + item.created_at);
if (item.updated_at) lines.push(T.updatedLabel + ': ' + item.updated_at);
if (item.error_message) lines.push(T.errorLabel + ': ' + item.error_message);
return lines.join('\n');
},
uploadStatusLabel(status) {
const labels = {
pending: T.pending,
queued: T.queued,
processing: T.processing,
failed: T.failed,
};
return labels[String(status || '').toLowerCase()] || status || T.unknown;
},
uploadHasEditorOpen() {
return !!(this.uploadEditId || this.uploadReviewEditId || this.uploadReleaseEditId);
},
uploadEditorKicker() {
if (this.uploadReviewDraft) return T.needsApproval;
if (this.uploadReleaseDraft) return T.releaseMetadata;
if (this.uploadDraft) return T.trackMetadata;
return T.metadata;
},
uploadEditorTitle() {
if (this.uploadReviewDraft) return T.approveMetadata;
if (this.uploadReleaseDraft) return this.uploadReleaseDraft.title || T.editRelease;
if (this.uploadDraft) return this.uploadDraft.title || T.editTrack;
return T.editMetadata;
},
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 || T.failedSaveTrack);
this.uploadTracks = this.uploadTracks.map(item => item.track.id === id ? data : item);
this.cancelUploadEdit();
this.loadUploads({ silent: true, preserveEditor: false });
this._setMessage(T.trackMetadataSaved);
} 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 || T.failedSaveRelease);
this.applyUploadPage(data);
this.cancelUploadReleaseEdit();
this._setMessage(T.releaseMetadataSaved);
} 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;
},
async deleteUploadReview() {
if (!this.uploadReviewEditId) return;
const id = this.uploadReviewEditId;
this.uploadReviewSavingId = id;
try {
const res = await fetch(`/api/player/uploads/reviews/${id}`, {
method: 'DELETE',
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || T.failedDeleteReview);
this.applyUploadPage(data);
this.cancelUploadReviewEdit();
this._setMessage(T.reviewDeleted);
} 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 || T.failedApproveReview);
this.applyUploadPage(data);
this.cancelUploadReviewEdit();
this._setMessage(T.trackApprovedImported);
} 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 || T.failedUpdateSelectedTracks);
this.applyUploadPage(data);
this.clearUploadSelection();
this._setMessage(T.selectedTracksUpdated);
} 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);
const maxQueueOffset = Math.max(0, this.uploadQueued.length - this.uploadQueuePageSize);
this.uploadQueueOffset = Math.max(0, Math.min(this.uploadQueueOffset, maxQueueOffset));
this.pruneUploadSelection();
},
_startRefresh() {
this._stopRefresh();
this._refreshTimer = setInterval(() => {
if (!this.modal) return;
if (this.activeTab === 'uploads') {
this.loadUploads({ silent: true });
}
else this.loadSessions();
this.loadAgentStatus();
}, 5000);
},
_stopRefresh() {
if (this._refreshTimer) clearInterval(this._refreshTimer);
this._refreshTimer = null;
},
_rememberJob(job) {
if (!job || !job.id) return;
const rest = this.sessions.filter(item => item.id !== job.id);
this.sessions = [job, ...rest].sort((a, b) => String(b.created_at || '').localeCompare(String(a.created_at || '')));
if (this.currentJob && this.currentJob.id === job.id) this.currentJob = job;
},
_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;
},
_applySession(data) {
const preview = data.preview || data;
const job = data.job || null;
this.workspaceMode = 'session';
this.file = null;
this.localFiles = [];
this.magnet = '';
this.uploadProgress = 0;
this.uploadProgressText = '';
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();
if (!res.ok) throw new Error(data.error || T.loadTorrentsFailed);
this.sessions = Array.isArray(data) ? data : [];
this._syncCurrentJobFromSessions();
await this._refreshResolvedSelection();
} catch (err) {
this._setMessage(err.message || String(err), true);
} finally {
this.loadingSessions = false;
}
},
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 {}
},
async openSession(id) {
if (!id || this.loading) return;
this._stopPoll();
this.loading = true;
this._setMessage(T.openingSavedTorrent);
try {
const res = await fetch(`/api/player/torrents/session/${id}`);
const data = await res.json();
if (!res.ok) throw new Error(data.error || T.openTorrentFailed);
this._applySession(data);
this._setMessage(T.savedTorrentOpened);
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;
if (!confirm(T.removeTorrentConfirm)) return;
this.loading = true;
try {
const res = await fetch(`/api/player/torrents/session/${id}`, { method: 'DELETE' });
const data = await res.json();
if (!res.ok) throw new Error(data.error || T.deleteTorrentFailed);
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();
this.workspaceMode = this.sessions.length ? 'empty' : 'new';
}
this._setMessage(T.torrentRemoved);
} 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);
});
},
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;
}
},
async preview() {
if (this.loading) return;
if (this.localFiles.length > 0) {
await this.uploadLocalFiles();
return;
}
const magnet = this.magnet.trim();
if (!this.file && !magnet) {
this._setMessage(T.chooseTorrent, true);
return;
}
this.loading = true;
this.previewData = null;
this.treeRoot = null;
this.currentJob = null;
this.workspaceMode = 'new';
this.selected = new Set();
this.expanded = new Set();
this._setMessage(this.file ? T.readingTorrent : T.resolvingMagnet);
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();
if (!res.ok) throw new Error(data.error || T.previewFailed);
this._applySession(data);
this._setMessage((data.preview?.files || []).length ? T.allFilesSelected : T.resolving);
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;
const selected = this.selectedArray();
if (selected.length === 0) {
this._setMessage(T.selectOneFile, true);
return;
}
this.loading = true;
this._setMessage(T.startingDownload);
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();
if (!res.ok) throw new Error(data.error || T.startFailed);
this.currentJob = data;
this._rememberJob(data);
this._setMessage(T.downloadStarted);
this._poll(data.id);
await this.loadSessions();
} catch (err) {
this._setMessage(err.message || String(err), true);
} finally {
this.loading = false;
}
},
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);
this._stopPoll();
await this.loadSessions();
} catch (err) {
this._setMessage(err.message || String(err), true);
} finally {
this.loading = false;
}
},
_poll(id) {
this._stopPoll();
this._pollJobId = id;
this._pollTimer = setInterval(async () => {
try {
const res = await fetch(`/api/player/torrents/${id}/status`);
const data = await res.json();
if (!res.ok) throw new Error(data.error || T.statusFailed);
this._rememberJob(data);
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'
);
}
if (data.status === 'complete' || data.status === 'failed') {
this._stopPoll();
this.loadSessions();
this.loadAgentStatus();
}
} catch (err) {
if (this._isSelectedJob(id)) this._setMessage(err.message || String(err), true);
this._stopPoll();
}
}, 2000);
},
});
// -----------------------------------------------------------------------
// Playlists store
// -----------------------------------------------------------------------
Alpine.store('playlists', {
list: [],
modal: null, // { mode: 'create'|'rename', title: '', id?: number }
picker: null, // { trackIds: [1,2,3] }
editingPlaylistId: null,
_dragIdx: null,
_dragOverIdx: null,
_pointerDragMove: null,
_pointerDragEnd: null,
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
));
},
displayTitle(pl) {
return pl?.kind === 'likes' ? T.likesPlaylist : (pl?.title || '');
},
currentPlaylist() {
return Alpine.store('library')?.currentPlaylist || null;
},
canEditCurrent() {
const playlist = this.currentPlaylist();
return !!playlist && playlist.kind === 'user' && playlist.is_own && Number(playlist.id) > 0;
},
isEditingCurrent() {
const playlist = this.currentPlaylist();
return this.canEditCurrent() && Number(this.editingPlaylistId) === Number(playlist.id);
},
toggleEditCurrent() {
if (this.isEditingCurrent()) {
this.stopEdit();
return;
}
if (!this.canEditCurrent()) return;
this.editingPlaylistId = Number(this.currentPlaylist().id);
},
stopEdit() {
this._endPointerReorder(false);
this.endDrag();
this.editingPlaylistId = null;
},
_updateListTrackCount(playlistId, count) {
const id = Number(playlistId);
this.list = this.list.map(pl => (
Number(pl.id) === id ? { ...pl, track_count: count } : pl
));
},
_playlistTrackIds(tracks) {
return (tracks || []).map(track => Number(track?.playlist_track_id || 0)).filter(Boolean);
},
async removeCurrentTrack(track, idx) {
if (!this.isEditingCurrent() || !track) return;
const playlist = this.currentPlaylist();
const playlistId = Number(playlist.id);
const playlistTrackId = Number(track.playlist_track_id || 0);
const previous = (playlist.tracks || []).slice();
if (!playlistTrackId) return;
playlist.tracks = previous.filter(item => Number(item.playlist_track_id || 0) !== playlistTrackId);
this._updateListTrackCount(playlistId, playlist.tracks.length);
try {
const res = await fetch(`/api/player/playlists/${playlistId}/tracks`, {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ playlist_track_id: playlistTrackId }),
});
if (!res.ok) throw new Error('remove failed');
await this.reload();
} catch (err) {
if (Number(this.currentPlaylist()?.id) === playlistId) {
this.currentPlaylist().tracks = previous;
this._updateListTrackCount(playlistId, previous.length);
}
console.warn(err);
}
},
startDrag(event, idx) {
const tracks = this.currentPlaylist()?.tracks || [];
if (!this.isEditingCurrent() || idx < 0 || idx >= tracks.length || !tracks[idx]?.playlist_track_id) {
return false;
}
this._dragIdx = idx;
this._dragOverIdx = idx;
if (event?.dataTransfer) {
event.dataTransfer.effectAllowed = 'move';
event.dataTransfer.setData('text/plain', String(tracks[idx].playlist_track_id));
}
return true;
},
dragOver(event, idx) {
if (!this.isEditingCurrent() || this._dragIdx === null) return;
const tracks = this.currentPlaylist()?.tracks || [];
if (idx < 0 || idx >= tracks.length) return;
this._dragOverIdx = idx;
if (event?.dataTransfer) event.dataTransfer.dropEffect = 'move';
document.querySelectorAll('.playlist-track-row.drag-over').forEach(el => el.classList.remove('drag-over'));
if (idx !== this._dragIdx) event.currentTarget.classList.add('drag-over');
},
dropOn(idx) {
const fromIdx = this._dragIdx;
this.endDrag();
if (!Number.isInteger(fromIdx) || fromIdx === idx) return;
this.moveCurrentTrack(fromIdx, idx);
},
endDrag() {
document.querySelectorAll('.playlist-track-row.drag-over').forEach(el => el.classList.remove('drag-over'));
this._dragIdx = null;
this._dragOverIdx = null;
},
async moveCurrentTrack(fromIdx, toIdx) {
if (!this.isEditingCurrent()) return;
const playlist = this.currentPlaylist();
const playlistId = Number(playlist.id);
const previous = (playlist.tracks || []).slice();
if (fromIdx < 0 || fromIdx >= previous.length || toIdx < 0 || toIdx >= previous.length) return;
const next = previous.slice();
const [track] = next.splice(fromIdx, 1);
next.splice(toIdx, 0, track);
playlist.tracks = next;
const playlistTrackIds = this._playlistTrackIds(next);
if (playlistTrackIds.length !== next.length) {
playlist.tracks = previous;
return;
}
try {
const res = await fetch(`/api/player/playlists/${playlistId}/tracks`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ playlist_track_ids: playlistTrackIds }),
});
if (!res.ok) throw new Error('reorder failed');
await this.reload();
} catch (err) {
if (Number(this.currentPlaylist()?.id) === playlistId) {
this.currentPlaylist().tracks = previous;
}
console.warn(err);
}
},
startPointerReorder(event, idx) {
if (event.pointerType === 'mouse') return;
if (event.button && event.button !== 0) return;
const tracks = this.currentPlaylist()?.tracks || [];
if (!this.isEditingCurrent() || idx < 0 || idx >= tracks.length || !tracks[idx]?.playlist_track_id) return;
event.preventDefault();
this._endPointerReorder(false);
this._dragIdx = idx;
this._dragOverIdx = idx;
const handle = event.currentTarget;
try {
handle?.setPointerCapture?.(event.pointerId);
} catch (_) {}
this._pointerDragMove = (moveEvent) => {
moveEvent.preventDefault();
this._autoScrollDuringReorder(moveEvent.clientY);
const target = document
.elementFromPoint(moveEvent.clientX, moveEvent.clientY)
?.closest?.('.playlist-track-row[data-playlist-index]');
const targetIdx = Number(target?.dataset?.playlistIndex);
const currentTracks = this.currentPlaylist()?.tracks || [];
if (!Number.isInteger(targetIdx) || targetIdx < 0 || targetIdx >= currentTracks.length) return;
this._dragOverIdx = targetIdx;
document.querySelectorAll('.playlist-track-row.drag-over').forEach(el => el.classList.remove('drag-over'));
if (targetIdx !== this._dragIdx) target.classList.add('drag-over');
};
this._pointerDragEnd = (endEvent) => {
try {
if (handle?.hasPointerCapture?.(endEvent.pointerId)) handle.releasePointerCapture(endEvent.pointerId);
} catch (_) {}
this._endPointerReorder(true);
};
window.addEventListener('pointermove', this._pointerDragMove, { passive: false });
window.addEventListener('pointerup', this._pointerDragEnd, { passive: false });
window.addEventListener('pointercancel', this._pointerDragEnd, { passive: false });
},
_autoScrollDuringReorder(clientY) {
const scroller = document.getElementById('center-scroll');
if (!scroller) return;
const rect = scroller.getBoundingClientRect();
const edge = 52;
if (clientY < rect.top + edge) {
scroller.scrollTop -= Math.ceil((rect.top + edge - clientY) / 4);
} else if (clientY > rect.bottom - edge) {
scroller.scrollTop += Math.ceil((clientY - (rect.bottom - edge)) / 4);
}
},
_endPointerReorder(commit) {
if (this._pointerDragMove) window.removeEventListener('pointermove', this._pointerDragMove);
if (this._pointerDragEnd) {
window.removeEventListener('pointerup', this._pointerDragEnd);
window.removeEventListener('pointercancel', this._pointerDragEnd);
}
document.querySelectorAll('.playlist-track-row.drag-over').forEach(el => el.classList.remove('drag-over'));
const fromIdx = this._dragIdx;
const toIdx = this._dragOverIdx;
this._pointerDragMove = null;
this._pointerDragEnd = null;
this._dragIdx = null;
this._dragOverIdx = null;
if (commit && Number.isInteger(fromIdx) && Number.isInteger(toIdx) && fromIdx !== toIdx) {
this.moveCurrentTrack(fromIdx, toIdx);
}
},
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) {
if (!confirm(T.deletePlaylistConfirm)) return;
try {
await fetch(`/api/player/playlists/${id}`, { method: 'DELETE' });
await this.reload();
} catch {}
},
showPicker(trackIds) {
this.picker = { trackIds };
},
showFederationPicker(track) {
this.picker = { trackIds: [], federationTrack: track };
},
async addToPicked(playlistId) {
if (!this.picker) return;
try {
if (this.picker.federationTrack) {
const track = this.picker.federationTrack;
await fetch('/api/player/tracks/content/playlist', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
playlist_id: Number(playlistId),
content_id: track.key.content_id,
federation: Alpine.store('library').federationWireTrack(track),
}),
});
} else {
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;
}
}
</script>