5914 lines
240 KiB
HTML
5914 lines
240 KiB
HTML
<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);
|
||
Alpine.store('likes')?.reload();
|
||
const history = Alpine.store('history');
|
||
if (history?.modal) history.load(history.page);
|
||
}
|
||
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() {
|
||
this.reload();
|
||
},
|
||
|
||
async reload() {
|
||
try {
|
||
const response = await fetch('/api/player/likes');
|
||
if (!response.ok) return;
|
||
const data = await response.json();
|
||
this._set = new Set(data.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>
|