4433 lines
172 KiB
HTML
4433 lines
172 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,
|
|
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;
|
|
}
|
|
},
|
|
|
|
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,
|
|
_listenedSeconds: 0,
|
|
_lastAudioTime: 0,
|
|
_localSourceTrackId: null,
|
|
_remoteExecuting: false,
|
|
_remoteStateBaseTime: 0,
|
|
_remoteStateReceivedAt: 0,
|
|
_remoteStateTimer: 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();
|
|
});
|
|
|
|
audio.addEventListener('ended', () => {
|
|
this._trackListenedDelta();
|
|
this._recordHistory(true);
|
|
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 (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);
|
|
},
|
|
|
|
_playLocal(track, options = {}) {
|
|
this.currentTrack = track;
|
|
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) 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');
|
|
return {
|
|
current_track_id: this.currentTrack ? this.currentTrack.id : null,
|
|
position_ms: Math.floor(this.currentTime * 1000),
|
|
queue: queue.tracks.map(t => t.id),
|
|
queue_position: queue.currentIndex,
|
|
shuffle: this.shuffle,
|
|
repeat_mode: this.repeatMode,
|
|
volume: this.volume,
|
|
};
|
|
},
|
|
|
|
_saveState() {
|
|
const queue = Alpine.store('queue');
|
|
if (!this.currentTrack && queue.tracks.length === 0) return;
|
|
const state = this._buildStatePayload();
|
|
fetch('/api/player/state', {
|
|
method: 'PUT',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(state),
|
|
}).catch(() => {});
|
|
},
|
|
|
|
_saveStateSync() {
|
|
const queue = Alpine.store('queue');
|
|
if (!this.currentTrack && queue.tracks.length === 0) return;
|
|
const state = this._buildStatePayload();
|
|
const blob = new Blob([JSON.stringify(state)], { type: 'application/json' });
|
|
navigator.sendBeacon('/api/player/state', blob);
|
|
},
|
|
|
|
async _restoreState() {
|
|
try {
|
|
const res = await fetch('/api/player/state');
|
|
if (!res.ok) return;
|
|
const state = await res.json();
|
|
this.shuffle = state.shuffle || false;
|
|
this.repeatMode = state.repeat_mode || 'off';
|
|
this._setVolumeLocal(typeof state.volume === 'number' ? state.volume : 0.7);
|
|
|
|
// 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(completed) {
|
|
if (this._historyRecorded || !this.currentTrack) return;
|
|
this._historyRecorded = true;
|
|
const listenedSeconds = this._historyListenedSeconds(completed);
|
|
fetch('/api/player/history', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
track_id: this.currentTrack.id,
|
|
started_at: this._playbackStartedAt,
|
|
duration_listened: listenedSeconds,
|
|
completed: completed,
|
|
}),
|
|
}).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(true);
|
|
return true;
|
|
},
|
|
|
|
_resetPlaybackTracking() {
|
|
this._nowPlayingSent = false;
|
|
this._playbackStartedAt = null;
|
|
this._listenedSeconds = 0;
|
|
this._lastAudioTime = 0;
|
|
},
|
|
|
|
_trackListenedDelta() {
|
|
if (!this.currentTrack) return;
|
|
const current = audio.currentTime || 0;
|
|
if (!this.isPlaying) {
|
|
this._lastAudioTime = current;
|
|
return;
|
|
}
|
|
const previous = Number.isFinite(this._lastAudioTime) ? this._lastAudioTime : current;
|
|
const delta = current - previous;
|
|
if (delta > 0 && delta < 5) {
|
|
this._listenedSeconds += delta;
|
|
}
|
|
this._lastAudioTime = current;
|
|
},
|
|
|
|
_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;
|
|
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,
|
|
remoteHintVisible: false,
|
|
remoteHintDeviceName: '',
|
|
_remoteHintShown: false,
|
|
_remoteHintTimer: null,
|
|
_pollTimer: null,
|
|
_jamSearchTimer: null,
|
|
_stateRefreshTick: 0,
|
|
_lastPlaybackState: null,
|
|
|
|
init() {
|
|
this.id = this._ensureId();
|
|
this.currentJamId = sessionStorage.getItem('furu_player_jam_id') || null;
|
|
this.heartbeat();
|
|
this._pollTimer = setInterval(() => this.poll(), 500);
|
|
document.addEventListener('visibilitychange', () => {
|
|
if (!document.hidden) this.poll();
|
|
});
|
|
},
|
|
|
|
_ensureId() {
|
|
let id = sessionStorage.getItem('furu_player_device_id');
|
|
if (!id) {
|
|
id = (crypto.randomUUID ? crypto.randomUUID() : this._fallbackId()).replace(/[^a-zA-Z0-9_-]/g, '');
|
|
sessionStorage.setItem('furu_player_device_id', id);
|
|
}
|
|
return id;
|
|
},
|
|
|
|
_fallbackId() {
|
|
return 'device-' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2);
|
|
},
|
|
|
|
requestPayload() {
|
|
const player = Alpine.store('player');
|
|
return {
|
|
device_id: this.id,
|
|
user_agent: navigator.userAgent || '',
|
|
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);
|
|
|
|
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();
|
|
},
|
|
|
|
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,
|
|
_previousView: 'artists',
|
|
_activeHash: location.hash || '#artists',
|
|
_scrollPositions: {},
|
|
|
|
_hashNav: false, // guard against circular hash updates
|
|
|
|
init() {
|
|
this._setupScroll();
|
|
|
|
// 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 === 'release' && id) {
|
|
this.openRelease(id, 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 (!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._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();
|
|
} catch {}
|
|
this._afterNavigation(options);
|
|
},
|
|
|
|
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) return;
|
|
Alpine.store('queue').playRelease(tracks, 0);
|
|
},
|
|
|
|
async openRelease(id, options = {}) {
|
|
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();
|
|
} catch {}
|
|
this._afterNavigation(options);
|
|
},
|
|
|
|
async openPlaylist(id, options = {}) {
|
|
this._beginNavigation('#playlist/' + id, options);
|
|
this.view = 'playlist_detail';
|
|
this.currentPlaylist = null;
|
|
try {
|
|
const res = await fetch(`/api/player/playlists/${id}`);
|
|
if (!res.ok) throw new Error('failed');
|
|
this.currentPlaylist = await res.json();
|
|
} catch {}
|
|
this._afterNavigation(options);
|
|
},
|
|
|
|
showSharedPlaylist(share, options = {}) {
|
|
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;
|
|
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.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(); });
|
|
}
|
|
}
|
|
},
|
|
|
|
playSearchTrack(idx) {
|
|
if (!this.searchResults || !this.searchResults.tracks) return;
|
|
Alpine.store('queue').playRelease(this.searchResults.tracks, idx);
|
|
},
|
|
|
|
_setupScroll() {
|
|
if (this._observer) this._observer.disconnect();
|
|
this.$nextTick(() => {
|
|
const sentinel = document.getElementById('artist-sentinel');
|
|
if (!sentinel) return;
|
|
this._observer = new IntersectionObserver((entries) => {
|
|
if (entries[0].isIntersecting && !this.loading && !this._allLoaded) {
|
|
this.loadArtists(this.artistsPage + 1);
|
|
}
|
|
}, { root: document.getElementById('center-scroll'), rootMargin: '900px 0px', threshold: 0.01 });
|
|
this._observer.observe(sentinel);
|
|
this._fillArtistViewport();
|
|
});
|
|
},
|
|
|
|
_fillArtistViewport() {
|
|
if (!(this.view === 'artists' || this.view === 'my_uploads') || this.loading || this._allLoaded) return;
|
|
const el = this._scrollElement();
|
|
if (!el) return;
|
|
if (el.scrollHeight <= el.clientHeight + 900) {
|
|
this.loadArtists(this.artistsPage + 1);
|
|
}
|
|
},
|
|
|
|
$nextTick(fn) {
|
|
setTimeout(fn, 50);
|
|
},
|
|
});
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Likes store
|
|
// -----------------------------------------------------------------------
|
|
Alpine.store('likes', {
|
|
_set: new Set(),
|
|
|
|
init() {
|
|
fetch('/api/player/likes')
|
|
.then(r => r.json())
|
|
.then(d => { this._set = new Set(d.track_ids || []); })
|
|
.catch(() => {});
|
|
},
|
|
|
|
has(trackId) {
|
|
return this._set.has(trackId);
|
|
},
|
|
|
|
async toggle(trackId) {
|
|
// Optimistic update
|
|
if (this._set.has(trackId)) {
|
|
this._set.delete(trackId);
|
|
} else {
|
|
this._set.add(trackId);
|
|
}
|
|
// Force Alpine reactivity
|
|
this._set = new Set(this._set);
|
|
try {
|
|
const res = await fetch(`/api/player/likes/toggle/${trackId}`, { method: 'POST' });
|
|
if (res.ok) {
|
|
const data = await res.json();
|
|
if (data.liked) {
|
|
this._set.add(trackId);
|
|
} else {
|
|
this._set.delete(trackId);
|
|
}
|
|
this._set = new Set(this._set);
|
|
Alpine.store('playlists').reload();
|
|
}
|
|
} catch {}
|
|
},
|
|
|
|
async toggleRelease(releaseId) {
|
|
try {
|
|
const res = await fetch(`/api/player/likes/release/${releaseId}`, { method: 'POST' });
|
|
if (res.ok) {
|
|
// Reload liked IDs
|
|
const likesRes = await fetch('/api/player/likes');
|
|
if (likesRes.ok) {
|
|
const d = await likesRes.json();
|
|
this._set = new Set(d.track_ids || []);
|
|
}
|
|
Alpine.store('playlists').reload();
|
|
}
|
|
} catch {}
|
|
},
|
|
|
|
isReleaseLiked(release) {
|
|
if (!release || !release.tracks || release.tracks.length === 0) return false;
|
|
return release.tracks.every(t => this._set.has(t.id));
|
|
},
|
|
});
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Artist follows store
|
|
// -----------------------------------------------------------------------
|
|
Alpine.store('follows', {
|
|
_set: new Set(),
|
|
artists: [],
|
|
|
|
init() {
|
|
this.reload();
|
|
},
|
|
|
|
has(artistId) {
|
|
return this._set.has(Number(artistId));
|
|
},
|
|
|
|
async reload() {
|
|
try {
|
|
const res = await fetch('/api/player/follows');
|
|
if (!res.ok) return;
|
|
const data = await res.json();
|
|
this._set = new Set((data.artist_ids || []).map(Number));
|
|
this.artists = data.artists || [];
|
|
} catch {}
|
|
},
|
|
|
|
_artistSnapshot(artistId) {
|
|
const id = Number(artistId);
|
|
const library = Alpine.store('library');
|
|
const fromLists = [
|
|
...(library.artists || []),
|
|
...((library.searchResults && library.searchResults.artists) || []),
|
|
].find(artist => Number(artist.id) === id);
|
|
if (fromLists) return fromLists;
|
|
if (library.currentArtist && Number(library.currentArtist.id) === id) {
|
|
return {
|
|
id,
|
|
name: library.currentArtist.name,
|
|
image_url: library.currentArtist.image_url,
|
|
release_count: (library.currentArtist.releases || []).length,
|
|
track_count: library.currentArtist.total_track_count || 0,
|
|
};
|
|
}
|
|
return null;
|
|
},
|
|
|
|
async toggle(artistId) {
|
|
const id = Number(artistId);
|
|
if (!id) return;
|
|
|
|
if (this._set.has(id)) {
|
|
this._set.delete(id);
|
|
this.artists = this.artists.filter(artist => Number(artist.id) !== id);
|
|
} else {
|
|
this._set.add(id);
|
|
const snapshot = this._artistSnapshot(id);
|
|
if (snapshot && !this.artists.some(artist => Number(artist.id) === id)) {
|
|
this.artists = [snapshot, ...this.artists];
|
|
}
|
|
}
|
|
this._set = new Set(this._set);
|
|
|
|
try {
|
|
const res = await fetch(`/api/player/follows/toggle/${id}`, { method: 'POST' });
|
|
if (res.ok) {
|
|
const data = await res.json();
|
|
if (data.followed) this._set.add(id);
|
|
else this._set.delete(id);
|
|
this._set = new Set(this._set);
|
|
}
|
|
} catch {}
|
|
|
|
await this.reload();
|
|
},
|
|
});
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Torrent import store
|
|
// -----------------------------------------------------------------------
|
|
Alpine.store('torrents', {
|
|
modal: false,
|
|
file: null,
|
|
localFiles: [],
|
|
magnet: '',
|
|
sessions: [],
|
|
loadingSessions: false,
|
|
currentJob: null,
|
|
previewData: null,
|
|
workspaceMode: 'empty',
|
|
treeRoot: null,
|
|
selected: new Set(),
|
|
expanded: new Set(),
|
|
loading: false,
|
|
message: '',
|
|
error: false,
|
|
_pollTimer: null,
|
|
_pollJobId: null,
|
|
_refreshTimer: null,
|
|
queuedTasks: 0,
|
|
processingTasks: 0,
|
|
loadingAgentStatus: false,
|
|
uploadProgress: 0,
|
|
uploadProgressText: '',
|
|
activeTab: 'import',
|
|
uploadTracks: [],
|
|
uploadReleases: [],
|
|
uploadPending: [],
|
|
uploadQueued: [],
|
|
uploadPendingTotal: 0,
|
|
uploadQueuedTotal: 0,
|
|
uploadQueueOffset: 0,
|
|
uploadQueuePageSize: 6,
|
|
uploadLoaded: false,
|
|
uploadLoading: false,
|
|
uploadEditId: null,
|
|
uploadSavingId: null,
|
|
uploadDraft: null,
|
|
uploadReviewEditId: null,
|
|
uploadReviewSavingId: null,
|
|
uploadReviewDraft: null,
|
|
uploadReleaseEditId: null,
|
|
uploadReleaseSavingId: null,
|
|
uploadReleaseDraft: null,
|
|
selectedUploadTracks: new Set(),
|
|
expandedUploadReleases: new Set(),
|
|
uploadBulkSaving: false,
|
|
uploadBulkDraft: {
|
|
artists: '',
|
|
featured_artists: '',
|
|
release_title: '',
|
|
release_type: '',
|
|
release_year: '',
|
|
hidden: '',
|
|
},
|
|
|
|
open() {
|
|
this.modal = true;
|
|
this.message = '';
|
|
this.error = false;
|
|
this.loadSessions();
|
|
this.loadAgentStatus();
|
|
if (this.activeTab === 'uploads') this.loadUploads();
|
|
this._startRefresh();
|
|
},
|
|
|
|
close() {
|
|
this.modal = false;
|
|
this._stopRefresh();
|
|
this._stopPoll();
|
|
},
|
|
|
|
_stopPoll() {
|
|
if (this._pollTimer) clearInterval(this._pollTimer);
|
|
this._pollTimer = null;
|
|
this._pollJobId = null;
|
|
},
|
|
|
|
isImporting() {
|
|
return this.workspaceMode === 'new';
|
|
},
|
|
|
|
showImportTab() {
|
|
this.activeTab = 'import';
|
|
this._setMessage('');
|
|
},
|
|
|
|
showUploadsTab() {
|
|
this.activeTab = 'uploads';
|
|
this._stopPoll();
|
|
this._setMessage('');
|
|
this.loadUploads();
|
|
},
|
|
|
|
addNew() {
|
|
if (this.loading) return;
|
|
this._stopPoll();
|
|
this.workspaceMode = 'new';
|
|
this.file = null;
|
|
this.localFiles = [];
|
|
this.magnet = '';
|
|
this.uploadProgress = 0;
|
|
this.uploadProgressText = '';
|
|
this.currentJob = null;
|
|
this.previewData = null;
|
|
this.treeRoot = null;
|
|
this.selected = new Set();
|
|
this.expanded = new Set();
|
|
this._setMessage(T.chooseTorrent);
|
|
},
|
|
|
|
_setMessage(message, error = false) {
|
|
this.message = message || '';
|
|
this.error = error;
|
|
},
|
|
|
|
bytes(value) {
|
|
if (!value) return '0 B';
|
|
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
|
let size = Number(value);
|
|
let idx = 0;
|
|
while (size >= 1024 && idx < units.length - 1) {
|
|
size /= 1024;
|
|
idx++;
|
|
}
|
|
return (idx === 0 ? size.toFixed(0) : size.toFixed(1)) + ' ' + units[idx];
|
|
},
|
|
|
|
activeCount() {
|
|
return this.sessions.filter(job => job.active || job.status === 'downloading' || job.status === 'moving').length;
|
|
},
|
|
|
|
isDownloading(job) {
|
|
return !!job && (job.active || job.status === 'downloading' || job.status === 'moving');
|
|
},
|
|
|
|
isCurrentDownloading() {
|
|
return this.isDownloading(this.currentJob);
|
|
},
|
|
|
|
isCompleted(job) {
|
|
return this.normalizedStatus(job) === 'completed';
|
|
},
|
|
|
|
isCurrentCompleted() {
|
|
return this.isCompleted(this.currentJob);
|
|
},
|
|
|
|
selectedArray() {
|
|
return [...this.selected].sort((a, b) => Number(a) - Number(b));
|
|
},
|
|
|
|
selectionMatchesJob(job) {
|
|
if (!job) return false;
|
|
const saved = Array.isArray(job.selected_files) ? job.selected_files : [];
|
|
const selected = this.selectedArray();
|
|
if (saved.length !== selected.length) return false;
|
|
const savedSet = new Set(saved.map(index => Number(index)));
|
|
return selected.every(index => savedSet.has(Number(index)));
|
|
},
|
|
|
|
hasCurrentSelectionChanges() {
|
|
return !!this.currentJob && !this.selectionMatchesJob(this.currentJob);
|
|
},
|
|
|
|
isCurrentCompletedLocked() {
|
|
return this.isCurrentCompleted() && !this.hasCurrentSelectionChanges();
|
|
},
|
|
|
|
normalizedStatus(job) {
|
|
const status = String(job?.status || 'preview').toLowerCase();
|
|
if (status === 'complete') return 'completed';
|
|
return status;
|
|
},
|
|
|
|
statusLabel(job) {
|
|
const labels = {
|
|
preview: T.preview,
|
|
resolving: T.resolving,
|
|
downloading: T.downloading,
|
|
moving: T.moving,
|
|
completed: T.completed,
|
|
failed: T.failed,
|
|
paused: T.paused,
|
|
};
|
|
const status = this.normalizedStatus(job);
|
|
return labels[status] || status;
|
|
},
|
|
|
|
statusBadgeClass(job) {
|
|
return 'status-' + this.normalizedStatus(job);
|
|
},
|
|
|
|
progressValue(job) {
|
|
if (!job) return 0;
|
|
if (this.normalizedStatus(job) === 'completed') return 100;
|
|
return Math.max(0, Math.min(100, Number(job.progress_percent || 0)));
|
|
},
|
|
|
|
clientSummary() {
|
|
const active = this.activeCount();
|
|
return active > 0 ? active + ' ' + T.active : T.clientIdle;
|
|
},
|
|
|
|
agentSummary() {
|
|
const queued = Number(this.queuedTasks || 0);
|
|
const processing = Number(this.processingTasks || 0);
|
|
if (queued === 0 && processing === 0) return T.aiIdle;
|
|
const parts = [];
|
|
if (processing > 0) parts.push(processing + ' ' + T.processing);
|
|
parts.push(queued + ' ' + T.queued);
|
|
return T.aiPrefix + ' ' + parts.join(' / ');
|
|
},
|
|
|
|
agentBusy() {
|
|
return Number(this.queuedTasks || 0) > 0 || Number(this.processingTasks || 0) > 0;
|
|
},
|
|
|
|
statusText(job) {
|
|
if (!job) return T.noTorrentSelected;
|
|
const state = job.client_state ? ' / ' + job.client_state : '';
|
|
return this.statusLabel(job) + state;
|
|
},
|
|
|
|
speedText(job) {
|
|
if (!job) return '0 B/s';
|
|
const down = Number(job.download_speed_mbps || 0);
|
|
return down.toFixed(2) + ' MiB/s';
|
|
},
|
|
|
|
peerText(job) {
|
|
if (!job) return 'n/a';
|
|
const live = job.peers_live == null ? '?' : job.peers_live;
|
|
const seen = job.peers_seen == null ? '?' : job.peers_seen;
|
|
return live + ' ' + T.live + ' / ' + seen + ' ' + T.seen;
|
|
},
|
|
|
|
etaText(job) {
|
|
return job && job.eta ? job.eta : '';
|
|
},
|
|
|
|
progressDetailText(job) {
|
|
if (!job) return '';
|
|
const size = this.bytes(job.selected_size || job.total_size);
|
|
if (this.isCompleted(job)) return size;
|
|
return this.bytes(job.downloaded_bytes) + ' / ' + size;
|
|
},
|
|
|
|
actionButtonClass() {
|
|
if (this.isCurrentCompletedLocked()) return 'modal-btn-ghost';
|
|
return this.isCurrentDownloading() ? 'modal-btn-pause' : 'modal-btn-primary';
|
|
},
|
|
|
|
actionButtonText() {
|
|
if (this.normalizedStatus(this.currentJob) === 'resolving') return T.resolving;
|
|
if (this.isCurrentCompletedLocked()) return T.completed;
|
|
return this.isCurrentDownloading() ? T.pauseDownload : T.downloadSelected;
|
|
},
|
|
|
|
actionButtonDisabled() {
|
|
return this.loading
|
|
|| this.isCurrentCompletedLocked()
|
|
|| this.normalizedStatus(this.currentJob) === 'resolving'
|
|
|| !this.previewData
|
|
|| !Array.isArray(this.previewData.files)
|
|
|| this.previewData.files.length === 0;
|
|
},
|
|
|
|
toggleDownloadAction() {
|
|
if (this.isCurrentCompletedLocked()) return;
|
|
if (this.isCurrentDownloading()) this.pause();
|
|
else this.start();
|
|
},
|
|
|
|
sessionMeta(job) {
|
|
if (!job) return '';
|
|
const size = this.bytes(job.selected_size || job.total_size);
|
|
return this.progressValue(job).toFixed(1) + '% - ' + size;
|
|
},
|
|
|
|
async loadAgentStatus() {
|
|
this.loadingAgentStatus = true;
|
|
try {
|
|
const res = await fetch('/api/player/agent-queue');
|
|
const data = await res.json();
|
|
if (!res.ok) throw new Error(data.error || T.loadAiQueueFailed);
|
|
this.queuedTasks = Number(data.queued_count || 0);
|
|
this.processingTasks = Number(data.processing_count || 0);
|
|
} catch {
|
|
this.queuedTasks = 0;
|
|
this.processingTasks = 0;
|
|
} finally {
|
|
this.loadingAgentStatus = false;
|
|
}
|
|
},
|
|
|
|
async loadUploads({ silent = false, preserveEditor = true } = {}) {
|
|
if (preserveEditor && this.uploadHasEditorOpen()) return;
|
|
if (this.uploadLoading) return;
|
|
this.uploadLoading = true;
|
|
try {
|
|
const res = await fetch('/api/player/uploads/tracks');
|
|
const data = await res.json();
|
|
if (!res.ok) throw new Error(data.error || T.loadUploadsFailed);
|
|
this.applyUploadPage(data);
|
|
this.uploadLoaded = true;
|
|
if (this.uploadEditId && !this.uploadTracks.some(item => item.track.id === this.uploadEditId)) {
|
|
this.cancelUploadEdit();
|
|
}
|
|
} catch (err) {
|
|
if (!silent) this._setMessage(err.message || String(err), true);
|
|
if (!this.uploadLoaded) {
|
|
this.uploadTracks = [];
|
|
this.uploadReleases = [];
|
|
this.uploadPending = [];
|
|
this.uploadQueued = [];
|
|
this.uploadPendingTotal = 0;
|
|
this.uploadQueuedTotal = 0;
|
|
}
|
|
} finally {
|
|
this.uploadLoading = false;
|
|
}
|
|
},
|
|
|
|
uploadSummary() {
|
|
const trackCount = this.uploadTracks.length;
|
|
const releaseCount = this.uploadReleases.length;
|
|
const parts = [trackCount + ' ' + T.trackWord, releaseCount + ' ' + T.releaseWord];
|
|
if (this.uploadPendingTotal > 0) parts.push(this.uploadPendingTotal + ' ' + T.needsApproval);
|
|
if (this.uploadQueuedTotal > 0) parts.push(this.uploadQueuedTotal + ' ' + T.queued);
|
|
return parts.join(' / ');
|
|
},
|
|
|
|
uploadArtistsText(item) {
|
|
const track = item?.track || item;
|
|
const names = [
|
|
...((track?.artists || []).map(artist => artist.name)),
|
|
...((track?.featured_artists || []).map(artist => T.featuredShort + ' ' + artist.name)),
|
|
];
|
|
return names.join(', ') || T.unknown;
|
|
},
|
|
|
|
uploadReleaseArtistsText(release) {
|
|
const names = (release?.artists || []).map(artist => artist.name);
|
|
return names.join(', ') || T.unknown;
|
|
},
|
|
|
|
uploadArtistGroups() {
|
|
const collator = new Intl.Collator(undefined, { sensitivity: 'base', numeric: true });
|
|
const groups = new Map();
|
|
for (const release of this.uploadReleases) {
|
|
const artists = Array.isArray(release?.artists) ? release.artists : [];
|
|
const artistKey = artists
|
|
.map(artist => artist?.id != null ? 'id:' + artist.id : 'name:' + String(artist?.name || '').trim().toLowerCase())
|
|
.filter(Boolean)
|
|
.join('|') || 'unknown';
|
|
if (!groups.has(artistKey)) {
|
|
groups.set(artistKey, {
|
|
key: artistKey,
|
|
name: this.uploadReleaseArtistsText(release),
|
|
releases: [],
|
|
trackCount: 0,
|
|
});
|
|
}
|
|
const group = groups.get(artistKey);
|
|
group.releases.push(release);
|
|
group.trackCount += Array.isArray(release?.tracks) ? release.tracks.length : 0;
|
|
}
|
|
return [...groups.values()]
|
|
.map(group => ({
|
|
...group,
|
|
releases: group.releases.slice().sort((a, b) => {
|
|
const aDate = String(a?.tracks?.[0]?.uploaded_at || '');
|
|
const bDate = String(b?.tracks?.[0]?.uploaded_at || '');
|
|
return bDate.localeCompare(aDate) || collator.compare(a?.title || '', b?.title || '');
|
|
}),
|
|
}))
|
|
.sort((a, b) => collator.compare(a.name, b.name));
|
|
},
|
|
|
|
uploadFeaturedArtistsText(item) {
|
|
const track = item?.track || item;
|
|
return (track?.featured_artists || []).map(artist => artist.name).join(', ');
|
|
},
|
|
|
|
compactQueuedUploads() {
|
|
const maxOffset = Math.max(0, this.uploadQueued.length - this.uploadQueuePageSize);
|
|
this.uploadQueueOffset = Math.max(0, Math.min(this.uploadQueueOffset, maxOffset));
|
|
return this.uploadQueued.slice(this.uploadQueueOffset, this.uploadQueueOffset + this.uploadQueuePageSize);
|
|
},
|
|
|
|
uploadQueueCanPrev() {
|
|
return this.uploadQueueOffset > 0;
|
|
},
|
|
|
|
uploadQueueCanNext() {
|
|
return this.uploadQueueOffset + this.uploadQueuePageSize < this.uploadQueued.length;
|
|
},
|
|
|
|
uploadQueuePrev() {
|
|
this.uploadQueueOffset = Math.max(0, this.uploadQueueOffset - this.uploadQueuePageSize);
|
|
},
|
|
|
|
uploadQueueNext() {
|
|
const maxOffset = Math.max(0, this.uploadQueued.length - this.uploadQueuePageSize);
|
|
this.uploadQueueOffset = Math.min(maxOffset, this.uploadQueueOffset + this.uploadQueuePageSize);
|
|
},
|
|
|
|
uploadQueueRangeText() {
|
|
if (this.uploadQueued.length === 0) return '';
|
|
const start = this.uploadQueueOffset + 1;
|
|
const end = Math.min(this.uploadQueueOffset + this.uploadQueuePageSize, this.uploadQueued.length);
|
|
const total = Math.max(this.uploadQueuedTotal || 0, this.uploadQueued.length);
|
|
return start + '-' + end + ' / ' + total;
|
|
},
|
|
|
|
uploadQueueOverflowText() {
|
|
const total = Math.max(this.uploadQueuedTotal || 0, this.uploadQueued.length);
|
|
return T.showing + ' ' + this.uploadQueued.length + ' ' + T.ofWord + ' ' + total;
|
|
},
|
|
|
|
uploadQueueTooltip(item) {
|
|
if (!item) return '';
|
|
const lines = [];
|
|
if (item.filename) lines.push(T.fileLabel + ': ' + item.filename);
|
|
if (item.status) lines.push(T.statusLabelText + ': ' + item.status);
|
|
if (item.created_at) lines.push(T.createdLabel + ': ' + item.created_at);
|
|
if (item.updated_at) lines.push(T.updatedLabel + ': ' + item.updated_at);
|
|
if (item.error_message) lines.push(T.errorLabel + ': ' + item.error_message);
|
|
return lines.join('\n');
|
|
},
|
|
|
|
uploadStatusLabel(status) {
|
|
const labels = {
|
|
pending: T.pending,
|
|
queued: T.queued,
|
|
processing: T.processing,
|
|
failed: T.failed,
|
|
};
|
|
return labels[String(status || '').toLowerCase()] || status || T.unknown;
|
|
},
|
|
|
|
uploadHasEditorOpen() {
|
|
return !!(this.uploadEditId || this.uploadReviewEditId || this.uploadReleaseEditId);
|
|
},
|
|
|
|
uploadEditorKicker() {
|
|
if (this.uploadReviewDraft) return T.needsApproval;
|
|
if (this.uploadReleaseDraft) return T.releaseMetadata;
|
|
if (this.uploadDraft) return T.trackMetadata;
|
|
return T.metadata;
|
|
},
|
|
|
|
uploadEditorTitle() {
|
|
if (this.uploadReviewDraft) return T.approveMetadata;
|
|
if (this.uploadReleaseDraft) return this.uploadReleaseDraft.title || T.editRelease;
|
|
if (this.uploadDraft) return this.uploadDraft.title || T.editTrack;
|
|
return T.editMetadata;
|
|
},
|
|
|
|
closeUploadEditor() {
|
|
this.cancelUploadEdit();
|
|
this.cancelUploadReleaseEdit();
|
|
this.cancelUploadReviewEdit();
|
|
},
|
|
|
|
uploadReleaseExpanded(releaseId) {
|
|
return this.expandedUploadReleases.has(releaseId);
|
|
},
|
|
|
|
toggleUploadRelease(releaseId) {
|
|
if (this.expandedUploadReleases.has(releaseId)) this.expandedUploadReleases.delete(releaseId);
|
|
else this.expandedUploadReleases.add(releaseId);
|
|
},
|
|
|
|
uploadReleaseTrackIds(release) {
|
|
return (release?.tracks || []).map(item => item.track.id);
|
|
},
|
|
|
|
uploadReleaseSelectionState(release) {
|
|
const ids = this.uploadReleaseTrackIds(release);
|
|
const selected = ids.filter(id => this.selectedUploadTracks.has(id)).length;
|
|
if (selected === 0) return 'empty';
|
|
return selected === ids.length ? 'checked' : 'partial';
|
|
},
|
|
|
|
toggleUploadReleaseSelection(release) {
|
|
const ids = this.uploadReleaseTrackIds(release);
|
|
const state = this.uploadReleaseSelectionState(release);
|
|
if (state === 'checked') ids.forEach(id => this.selectedUploadTracks.delete(id));
|
|
else ids.forEach(id => this.selectedUploadTracks.add(id));
|
|
},
|
|
|
|
toggleUploadTrackSelection(trackId) {
|
|
if (this.selectedUploadTracks.has(trackId)) this.selectedUploadTracks.delete(trackId);
|
|
else this.selectedUploadTracks.add(trackId);
|
|
},
|
|
|
|
uploadSelectedCount() {
|
|
return this.selectedUploadTracks.size;
|
|
},
|
|
|
|
clearUploadSelection() {
|
|
this.selectedUploadTracks.clear();
|
|
this.uploadBulkDraft = {
|
|
artists: '',
|
|
featured_artists: '',
|
|
release_title: '',
|
|
release_type: '',
|
|
release_year: '',
|
|
hidden: '',
|
|
};
|
|
},
|
|
|
|
pruneUploadSelection() {
|
|
const valid = new Set(this.uploadTracks.map(item => item.track.id));
|
|
for (const id of [...this.selectedUploadTracks]) {
|
|
if (!valid.has(id)) this.selectedUploadTracks.delete(id);
|
|
}
|
|
},
|
|
|
|
editUpload(item) {
|
|
if (!item || !item.track) return;
|
|
this.cancelUploadReleaseEdit();
|
|
this.cancelUploadReviewEdit();
|
|
this.uploadEditId = item.track.id;
|
|
this.uploadDraft = {
|
|
title: item.track.title || '',
|
|
artists: (item.track.artists || []).map(artist => artist.name).join(', '),
|
|
featured_artists: this.uploadFeaturedArtistsText(item),
|
|
release_title: item.track.release_title || '',
|
|
release_type: item.release_type || 'album',
|
|
release_year: item.track.release_year == null ? '' : String(item.track.release_year),
|
|
track_number: item.track.track_number == null ? '' : String(item.track.track_number),
|
|
disc_number: item.track.disc_number == null ? '' : String(item.track.disc_number),
|
|
is_hidden: !!item.is_hidden,
|
|
};
|
|
},
|
|
|
|
cancelUploadEdit() {
|
|
this.uploadEditId = null;
|
|
this.uploadSavingId = null;
|
|
this.uploadDraft = null;
|
|
},
|
|
|
|
async saveUploadEdit() {
|
|
if (!this.uploadEditId || !this.uploadDraft) return;
|
|
const id = this.uploadEditId;
|
|
const draft = this.uploadDraft;
|
|
const artistNames = String(draft.artists || '')
|
|
.split(',')
|
|
.map(name => name.trim())
|
|
.filter(Boolean);
|
|
this.uploadSavingId = id;
|
|
try {
|
|
const res = await fetch(`/api/player/uploads/tracks/${id}`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
title: draft.title,
|
|
artist_names: artistNames,
|
|
featured_artist_names: String(draft.featured_artists || '').split(',').map(name => name.trim()).filter(Boolean),
|
|
release_title: draft.release_title,
|
|
release_type: draft.release_type,
|
|
release_year: String(draft.release_year || ''),
|
|
track_number: String(draft.track_number || ''),
|
|
disc_number: String(draft.disc_number || ''),
|
|
is_hidden: !!draft.is_hidden,
|
|
}),
|
|
});
|
|
const data = await res.json();
|
|
if (!res.ok) throw new Error(data.error || T.failedSaveTrack);
|
|
this.uploadTracks = this.uploadTracks.map(item => item.track.id === id ? data : item);
|
|
this.cancelUploadEdit();
|
|
this.loadUploads({ silent: true, preserveEditor: false });
|
|
this._setMessage(T.trackMetadataSaved);
|
|
} catch (err) {
|
|
this._setMessage(err.message || String(err), true);
|
|
} finally {
|
|
this.uploadSavingId = null;
|
|
}
|
|
},
|
|
|
|
editUploadRelease(release) {
|
|
if (!release) return;
|
|
this.cancelUploadEdit();
|
|
this.cancelUploadReviewEdit();
|
|
this.uploadReleaseEditId = release.id;
|
|
this.uploadReleaseDraft = {
|
|
title: release.title || '',
|
|
artists: this.uploadReleaseArtistsText(release),
|
|
release_type: release.release_type || 'album',
|
|
year: release.year == null ? '' : String(release.year),
|
|
is_hidden: !!release.is_hidden,
|
|
};
|
|
},
|
|
|
|
cancelUploadReleaseEdit() {
|
|
this.uploadReleaseEditId = null;
|
|
this.uploadReleaseSavingId = null;
|
|
this.uploadReleaseDraft = null;
|
|
},
|
|
|
|
async saveUploadReleaseEdit() {
|
|
if (!this.uploadReleaseEditId || !this.uploadReleaseDraft) return;
|
|
const id = this.uploadReleaseEditId;
|
|
const draft = this.uploadReleaseDraft;
|
|
this.uploadReleaseSavingId = id;
|
|
try {
|
|
const res = await fetch(`/api/player/uploads/releases/${id}`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
title: draft.title,
|
|
artist_names: String(draft.artists || '').split(',').map(name => name.trim()).filter(Boolean),
|
|
release_type: draft.release_type,
|
|
year: String(draft.year || ''),
|
|
is_hidden: !!draft.is_hidden,
|
|
}),
|
|
});
|
|
const data = await res.json();
|
|
if (!res.ok) throw new Error(data.error || T.failedSaveRelease);
|
|
this.applyUploadPage(data);
|
|
this.cancelUploadReleaseEdit();
|
|
this._setMessage(T.releaseMetadataSaved);
|
|
} catch (err) {
|
|
this._setMessage(err.message || String(err), true);
|
|
} finally {
|
|
this.uploadReleaseSavingId = null;
|
|
}
|
|
},
|
|
|
|
uploadReviewPayload() {
|
|
const draft = this.uploadReviewDraft || {};
|
|
return {
|
|
title: draft.title,
|
|
artist: draft.artist,
|
|
album: draft.album,
|
|
year: String(draft.year || ''),
|
|
track_number: String(draft.track_number || ''),
|
|
genre: draft.genre,
|
|
featured_artists: String(draft.featured_artists || '').split(',').map(name => name.trim()).filter(Boolean),
|
|
release_type: draft.release_type || 'album',
|
|
notes: draft.notes,
|
|
};
|
|
},
|
|
|
|
editUploadReview(item) {
|
|
if (!item) return;
|
|
this.cancelUploadEdit();
|
|
this.cancelUploadReleaseEdit();
|
|
this.uploadReviewEditId = item.id;
|
|
this.uploadReviewDraft = {
|
|
title: item.fields?.title || '',
|
|
artist: item.fields?.artist || '',
|
|
album: item.fields?.album || '',
|
|
year: item.fields?.year || '',
|
|
track_number: item.fields?.track_number || '',
|
|
genre: item.fields?.genre || '',
|
|
featured_artists: (item.fields?.featured_artists || []).join(', '),
|
|
release_type: item.fields?.release_type || 'album',
|
|
notes: item.fields?.notes || '',
|
|
};
|
|
},
|
|
|
|
cancelUploadReviewEdit() {
|
|
this.uploadReviewEditId = null;
|
|
this.uploadReviewSavingId = null;
|
|
this.uploadReviewDraft = null;
|
|
},
|
|
|
|
async deleteUploadReview() {
|
|
if (!this.uploadReviewEditId) return;
|
|
const id = this.uploadReviewEditId;
|
|
this.uploadReviewSavingId = id;
|
|
try {
|
|
const res = await fetch(`/api/player/uploads/reviews/${id}`, {
|
|
method: 'DELETE',
|
|
});
|
|
const data = await res.json();
|
|
if (!res.ok) throw new Error(data.error || T.failedDeleteReview);
|
|
this.applyUploadPage(data);
|
|
this.cancelUploadReviewEdit();
|
|
this._setMessage(T.reviewDeleted);
|
|
} catch (err) {
|
|
this._setMessage(err.message || String(err), true);
|
|
} finally {
|
|
this.uploadReviewSavingId = null;
|
|
}
|
|
},
|
|
|
|
async approveUploadReview() {
|
|
if (!this.uploadReviewEditId || !this.uploadReviewDraft) return;
|
|
const id = this.uploadReviewEditId;
|
|
this.uploadReviewSavingId = id;
|
|
try {
|
|
const res = await fetch(`/api/player/uploads/reviews/${id}/approve`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(this.uploadReviewPayload()),
|
|
});
|
|
const data = await res.json();
|
|
if (!res.ok) throw new Error(data.error || T.failedApproveReview);
|
|
this.applyUploadPage(data);
|
|
this.cancelUploadReviewEdit();
|
|
this._setMessage(T.trackApprovedImported);
|
|
} catch (err) {
|
|
this._setMessage(err.message || String(err), true);
|
|
} finally {
|
|
this.uploadReviewSavingId = null;
|
|
}
|
|
},
|
|
|
|
async saveUploadBulkEdit() {
|
|
const trackIds = [...this.selectedUploadTracks];
|
|
if (trackIds.length === 0) return;
|
|
const draft = this.uploadBulkDraft;
|
|
const hidden = draft.hidden === '' ? null : draft.hidden === 'true';
|
|
const artists = String(draft.artists || '').split(',').map(name => name.trim()).filter(Boolean);
|
|
const featured = String(draft.featured_artists || '').split(',').map(name => name.trim()).filter(Boolean);
|
|
this.uploadBulkSaving = true;
|
|
try {
|
|
const res = await fetch('/api/player/uploads/bulk-tracks', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
track_ids: trackIds,
|
|
artist_names: artists.length ? artists : null,
|
|
featured_artist_names: featured.length ? featured : null,
|
|
release_title: draft.release_title || null,
|
|
release_type: draft.release_type || null,
|
|
release_year: draft.release_year === '' ? null : String(draft.release_year),
|
|
is_hidden: hidden,
|
|
}),
|
|
});
|
|
const data = await res.json();
|
|
if (!res.ok) throw new Error(data.error || T.failedUpdateSelectedTracks);
|
|
this.applyUploadPage(data);
|
|
this.clearUploadSelection();
|
|
this._setMessage(T.selectedTracksUpdated);
|
|
} catch (err) {
|
|
this._setMessage(err.message || String(err), true);
|
|
} finally {
|
|
this.uploadBulkSaving = false;
|
|
}
|
|
},
|
|
|
|
applyUploadPage(data) {
|
|
this.uploadTracks = Array.isArray(data.tracks) ? data.tracks : [];
|
|
this.uploadReleases = Array.isArray(data.releases) ? data.releases : [];
|
|
this.uploadPending = Array.isArray(data.pending) ? data.pending : [];
|
|
this.uploadQueued = Array.isArray(data.queued) ? data.queued : [];
|
|
this.uploadPendingTotal = Number(data.pending_total || this.uploadPending.length || 0);
|
|
this.uploadQueuedTotal = Number(data.queued_total || this.uploadQueued.length || 0);
|
|
const maxQueueOffset = Math.max(0, this.uploadQueued.length - this.uploadQueuePageSize);
|
|
this.uploadQueueOffset = Math.max(0, Math.min(this.uploadQueueOffset, maxQueueOffset));
|
|
this.pruneUploadSelection();
|
|
},
|
|
|
|
_startRefresh() {
|
|
this._stopRefresh();
|
|
this._refreshTimer = setInterval(() => {
|
|
if (!this.modal) return;
|
|
if (this.activeTab === 'uploads') {
|
|
this.loadUploads({ silent: true });
|
|
}
|
|
else this.loadSessions();
|
|
this.loadAgentStatus();
|
|
}, 5000);
|
|
},
|
|
|
|
_stopRefresh() {
|
|
if (this._refreshTimer) clearInterval(this._refreshTimer);
|
|
this._refreshTimer = null;
|
|
},
|
|
|
|
_rememberJob(job) {
|
|
if (!job || !job.id) return;
|
|
const rest = this.sessions.filter(item => item.id !== job.id);
|
|
this.sessions = [job, ...rest].sort((a, b) => String(b.created_at || '').localeCompare(String(a.created_at || '')));
|
|
if (this.currentJob && this.currentJob.id === job.id) this.currentJob = job;
|
|
},
|
|
|
|
_isSelectedJob(id) {
|
|
return this.workspaceMode === 'session'
|
|
&& this.currentJob
|
|
&& this.currentJob.id === id
|
|
&& this.previewData
|
|
&& this.previewData.id === id;
|
|
},
|
|
|
|
_syncCurrentJobFromSessions() {
|
|
if (!this.currentJob || !this.previewData) return;
|
|
const selected = this.sessions.find(job => job.id === this.currentJob.id && job.id === this.previewData.id);
|
|
if (selected) this.currentJob = selected;
|
|
},
|
|
|
|
_applySession(data) {
|
|
const preview = data.preview || data;
|
|
const job = data.job || null;
|
|
this.workspaceMode = 'session';
|
|
this.file = null;
|
|
this.localFiles = [];
|
|
this.magnet = '';
|
|
this.uploadProgress = 0;
|
|
this.uploadProgressText = '';
|
|
this.previewData = preview;
|
|
this.currentJob = job;
|
|
const selected = Array.isArray(data.selected_files) && data.selected_files.length
|
|
? data.selected_files
|
|
: (preview.files || []).filter(f => f.selected).map(f => f.index);
|
|
this.selected = new Set(selected);
|
|
this.treeRoot = this._buildTree(preview.files || []);
|
|
if (job) this._rememberJob(job);
|
|
},
|
|
|
|
async loadSessions() {
|
|
this.loadingSessions = true;
|
|
try {
|
|
const res = await fetch('/api/player/torrents');
|
|
const data = await res.json();
|
|
if (!res.ok) throw new Error(data.error || T.loadTorrentsFailed);
|
|
this.sessions = Array.isArray(data) ? data : [];
|
|
this._syncCurrentJobFromSessions();
|
|
await this._refreshResolvedSelection();
|
|
} catch (err) {
|
|
this._setMessage(err.message || String(err), true);
|
|
} finally {
|
|
this.loadingSessions = false;
|
|
}
|
|
},
|
|
|
|
async _refreshResolvedSelection() {
|
|
if (!this.currentJob || !this.previewData || (this.previewData.files || []).length > 0) return;
|
|
const selected = this.sessions.find(job => job.id === this.currentJob.id);
|
|
if (!selected || this.normalizedStatus(selected) === 'resolving') return;
|
|
try {
|
|
const res = await fetch(`/api/player/torrents/session/${selected.id}`);
|
|
const data = await res.json();
|
|
if (!res.ok) return;
|
|
this._applySession(data);
|
|
this._setMessage(T.allFilesSelected);
|
|
} catch {}
|
|
},
|
|
|
|
async openSession(id) {
|
|
if (!id || this.loading) return;
|
|
this._stopPoll();
|
|
this.loading = true;
|
|
this._setMessage(T.openingSavedTorrent);
|
|
try {
|
|
const res = await fetch(`/api/player/torrents/session/${id}`);
|
|
const data = await res.json();
|
|
if (!res.ok) throw new Error(data.error || T.openTorrentFailed);
|
|
this._applySession(data);
|
|
this._setMessage(T.savedTorrentOpened);
|
|
if (data.job && (data.job.active || data.job.status === 'downloading' || data.job.status === 'moving')) {
|
|
this._poll(data.job.id);
|
|
}
|
|
} catch (err) {
|
|
this._setMessage(err.message || String(err), true);
|
|
} finally {
|
|
this.loading = false;
|
|
}
|
|
},
|
|
|
|
async removeSession(id) {
|
|
if (!id || this.loading) return;
|
|
if (!confirm(T.removeTorrentConfirm)) return;
|
|
this.loading = true;
|
|
try {
|
|
const res = await fetch(`/api/player/torrents/session/${id}`, { method: 'DELETE' });
|
|
const data = await res.json();
|
|
if (!res.ok) throw new Error(data.error || T.deleteTorrentFailed);
|
|
this.sessions = this.sessions.filter(job => job.id !== id);
|
|
if (this.previewData && this.previewData.id === id) {
|
|
this.previewData = null;
|
|
this.currentJob = null;
|
|
this.treeRoot = null;
|
|
this.selected = new Set();
|
|
this.workspaceMode = this.sessions.length ? 'empty' : 'new';
|
|
}
|
|
this._setMessage(T.torrentRemoved);
|
|
} catch (err) {
|
|
this._setMessage(err.message || String(err), true);
|
|
} finally {
|
|
this.loading = false;
|
|
}
|
|
},
|
|
|
|
async _fileBase64(file) {
|
|
return new Promise((resolve, reject) => {
|
|
const reader = new FileReader();
|
|
reader.onerror = () => reject(reader.error);
|
|
reader.onload = () => {
|
|
const result = String(reader.result || '');
|
|
resolve(result.includes(',') ? result.split(',')[1] : result);
|
|
};
|
|
reader.readAsDataURL(file);
|
|
});
|
|
},
|
|
|
|
setLocalFiles(files) {
|
|
this.localFiles = Array.from(files || []);
|
|
},
|
|
|
|
localUploadBytes() {
|
|
return this.localFiles.reduce((sum, file) => sum + Number(file.size || 0), 0);
|
|
},
|
|
|
|
localUploadSummary() {
|
|
const count = this.localFiles.length;
|
|
if (count === 0) return '';
|
|
return count + ' ' + T.selected + ' - ' + this.bytes(this.localUploadBytes());
|
|
},
|
|
|
|
uploadLocalFile(file, loadedBefore, totalBytes) {
|
|
return new Promise((resolve, reject) => {
|
|
const xhr = new XMLHttpRequest();
|
|
xhr.open('POST', '/api/player/uploads/local');
|
|
xhr.setRequestHeader('Content-Type', file.type || 'application/octet-stream');
|
|
xhr.setRequestHeader('X-Furumusic-Filename', encodeURIComponent(file.name || 'upload.mp3'));
|
|
xhr.upload.onprogress = event => {
|
|
if (!event.lengthComputable || totalBytes <= 0) return;
|
|
const loaded = loadedBefore + event.loaded;
|
|
this.uploadProgress = Math.max(0, Math.min(100, loaded / totalBytes * 100));
|
|
this.uploadProgressText = this.uploadProgress.toFixed(1) + '%';
|
|
};
|
|
xhr.onload = () => {
|
|
let data = {};
|
|
try { data = JSON.parse(xhr.responseText || '{}'); } catch {}
|
|
if (xhr.status >= 200 && xhr.status < 300) resolve(data);
|
|
else reject(new Error(data.error || T.uploadFailed));
|
|
};
|
|
xhr.onerror = () => reject(new Error(T.uploadFailed));
|
|
xhr.send(file);
|
|
});
|
|
},
|
|
|
|
async uploadLocalFiles() {
|
|
if (this.loading || this.localFiles.length === 0) return;
|
|
this.loading = true;
|
|
this.uploadProgress = 0;
|
|
this.uploadProgressText = '0.0%';
|
|
this._setMessage(T.uploadingFiles);
|
|
const totalBytes = this.localUploadBytes();
|
|
let loadedBefore = 0;
|
|
try {
|
|
for (const file of this.localFiles) {
|
|
await this.uploadLocalFile(file, loadedBefore, totalBytes);
|
|
loadedBefore += Number(file.size || 0);
|
|
this.uploadProgress = totalBytes > 0 ? Math.min(100, loadedBefore / totalBytes * 100) : 100;
|
|
this.uploadProgressText = this.uploadProgress.toFixed(1) + '%';
|
|
}
|
|
this.localFiles = [];
|
|
this.uploadProgress = 100;
|
|
this.uploadProgressText = '100.0%';
|
|
this._setMessage(T.uploadComplete);
|
|
await this.loadAgentStatus();
|
|
} catch (err) {
|
|
this._setMessage(err.message || String(err), true);
|
|
} finally {
|
|
this.loading = false;
|
|
}
|
|
},
|
|
|
|
async preview() {
|
|
if (this.loading) return;
|
|
if (this.localFiles.length > 0) {
|
|
await this.uploadLocalFiles();
|
|
return;
|
|
}
|
|
const magnet = this.magnet.trim();
|
|
if (!this.file && !magnet) {
|
|
this._setMessage(T.chooseTorrent, true);
|
|
return;
|
|
}
|
|
|
|
this.loading = true;
|
|
this.previewData = null;
|
|
this.treeRoot = null;
|
|
this.currentJob = null;
|
|
this.workspaceMode = 'new';
|
|
this.selected = new Set();
|
|
this.expanded = new Set();
|
|
this._setMessage(this.file ? T.readingTorrent : T.resolvingMagnet);
|
|
|
|
try {
|
|
const payload = this.file
|
|
? { kind: 'torrent_file', torrent_base64: await this._fileBase64(this.file), source_label: this.file.name }
|
|
: { kind: 'magnet', magnet, source_label: magnet.slice(0, 120) };
|
|
const res = await fetch('/api/player/torrents/preview', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(payload),
|
|
});
|
|
const data = await res.json();
|
|
if (!res.ok) throw new Error(data.error || T.previewFailed);
|
|
|
|
this._applySession(data);
|
|
this._setMessage((data.preview?.files || []).length ? T.allFilesSelected : T.resolving);
|
|
await this.loadSessions();
|
|
} catch (err) {
|
|
this._setMessage(err.message || String(err), true);
|
|
} finally {
|
|
this.loading = false;
|
|
}
|
|
},
|
|
|
|
toggle(index, checked) {
|
|
const next = new Set(this.selected);
|
|
if (checked) next.add(index);
|
|
else next.delete(index);
|
|
this.selected = next;
|
|
},
|
|
|
|
selectAll(value) {
|
|
if (!this.previewData) return;
|
|
this.selected = value
|
|
? new Set(this.previewData.files.map(f => f.index))
|
|
: new Set();
|
|
},
|
|
|
|
clearSelection() {
|
|
this.selectAll(false);
|
|
},
|
|
|
|
selectedBytes() {
|
|
if (!this.previewData) return 0;
|
|
return this.previewData.files
|
|
.filter(file => this.selected.has(file.index))
|
|
.reduce((sum, file) => sum + Number(file.length || 0), 0);
|
|
},
|
|
|
|
_buildTree(files) {
|
|
const root = {
|
|
type: 'folder',
|
|
key: 'root',
|
|
name: 'root',
|
|
depth: -1,
|
|
size: 0,
|
|
fileIndexes: [],
|
|
children: [],
|
|
childMap: new Map(),
|
|
};
|
|
|
|
const ensureFolder = (parent, name, key, depth) => {
|
|
let node = parent.childMap.get(key);
|
|
if (!node) {
|
|
node = {
|
|
type: 'folder',
|
|
key,
|
|
name,
|
|
depth,
|
|
size: 0,
|
|
fileIndexes: [],
|
|
children: [],
|
|
childMap: new Map(),
|
|
};
|
|
parent.childMap.set(key, node);
|
|
parent.children.push(node);
|
|
}
|
|
return node;
|
|
};
|
|
|
|
files.forEach(file => {
|
|
const components = file.components && file.components.length
|
|
? file.components
|
|
: String(file.name || '').split('/').filter(Boolean);
|
|
const pathParts = components.length ? components : [file.name || ('file-' + file.index)];
|
|
let parent = root;
|
|
const folderChain = [root];
|
|
|
|
pathParts.slice(0, -1).forEach((part, depth) => {
|
|
const key = parent.key + '/' + part;
|
|
parent = ensureFolder(parent, part, key, depth);
|
|
folderChain.push(parent);
|
|
});
|
|
|
|
const fileNode = {
|
|
type: 'file',
|
|
key: 'file:' + file.index,
|
|
name: pathParts[pathParts.length - 1],
|
|
depth: Math.max(pathParts.length - 1, 0),
|
|
size: Number(file.length || 0),
|
|
fileIndex: file.index,
|
|
fileIndexes: [file.index],
|
|
children: [],
|
|
};
|
|
parent.children.push(fileNode);
|
|
|
|
folderChain.forEach(folder => {
|
|
folder.size += fileNode.size;
|
|
folder.fileIndexes.push(file.index);
|
|
});
|
|
});
|
|
|
|
const sortAndSeal = node => {
|
|
node.children.sort((a, b) => {
|
|
if (a.type !== b.type) return a.type === 'folder' ? -1 : 1;
|
|
return a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: 'base' });
|
|
});
|
|
node.children.forEach(sortAndSeal);
|
|
delete node.childMap;
|
|
};
|
|
sortAndSeal(root);
|
|
|
|
const initiallyExpanded = new Set();
|
|
const collectExpanded = node => {
|
|
if (node.type === 'folder' && node.depth < 1) initiallyExpanded.add(node.key);
|
|
node.children.forEach(collectExpanded);
|
|
};
|
|
collectExpanded(root);
|
|
this.expanded = initiallyExpanded;
|
|
|
|
return root;
|
|
},
|
|
|
|
visibleNodes() {
|
|
if (!this.treeRoot) return [];
|
|
const rows = [];
|
|
const visit = node => {
|
|
node.children.forEach(child => {
|
|
rows.push(child);
|
|
if (child.type === 'folder' && this.expanded.has(child.key)) {
|
|
visit(child);
|
|
}
|
|
});
|
|
};
|
|
visit(this.treeRoot);
|
|
return rows;
|
|
},
|
|
|
|
rowIndent(node) {
|
|
return Math.min(10 + Math.max(node.depth, 0) * 18, 82);
|
|
},
|
|
|
|
toggleExpand(node) {
|
|
if (node.type !== 'folder') return;
|
|
const next = new Set(this.expanded);
|
|
if (next.has(node.key)) next.delete(node.key);
|
|
else next.add(node.key);
|
|
this.expanded = next;
|
|
},
|
|
|
|
expandAll(value) {
|
|
if (!this.treeRoot) return;
|
|
const next = new Set();
|
|
const visit = node => {
|
|
if (node.type === 'folder' && value) next.add(node.key);
|
|
node.children.forEach(visit);
|
|
};
|
|
visit(this.treeRoot);
|
|
this.expanded = next;
|
|
},
|
|
|
|
nodeState(node) {
|
|
if (node.type === 'file') {
|
|
return this.selected.has(node.fileIndex) ? 'checked' : 'empty';
|
|
}
|
|
const total = node.fileIndexes.length;
|
|
const selected = node.fileIndexes.filter(index => this.selected.has(index)).length;
|
|
if (selected === 0) return 'empty';
|
|
if (selected === total) return 'checked';
|
|
return 'partial';
|
|
},
|
|
|
|
nodeCheckClass(node) {
|
|
const state = this.nodeState(node);
|
|
return {
|
|
checked: state === 'checked',
|
|
partial: state === 'partial',
|
|
};
|
|
},
|
|
|
|
toggleNode(node) {
|
|
const next = new Set(this.selected);
|
|
if (node.type === 'file') {
|
|
if (next.has(node.fileIndex)) next.delete(node.fileIndex);
|
|
else next.add(node.fileIndex);
|
|
this.selected = next;
|
|
return;
|
|
}
|
|
|
|
if (this.nodeState(node) === 'checked') {
|
|
node.fileIndexes.forEach(index => next.delete(index));
|
|
} else {
|
|
node.fileIndexes.forEach(index => next.add(index));
|
|
}
|
|
this.selected = next;
|
|
},
|
|
|
|
async start() {
|
|
if (!this.previewData || this.loading) return;
|
|
const selected = this.selectedArray();
|
|
if (selected.length === 0) {
|
|
this._setMessage(T.selectOneFile, true);
|
|
return;
|
|
}
|
|
|
|
this.loading = true;
|
|
this._setMessage(T.startingDownload);
|
|
try {
|
|
const res = await fetch(`/api/player/torrents/${this.previewData.id}/start`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ selected_files: selected }),
|
|
});
|
|
const data = await res.json();
|
|
if (!res.ok) throw new Error(data.error || T.startFailed);
|
|
this.currentJob = data;
|
|
this._rememberJob(data);
|
|
this._setMessage(T.downloadStarted);
|
|
this._poll(data.id);
|
|
await this.loadSessions();
|
|
} catch (err) {
|
|
this._setMessage(err.message || String(err), true);
|
|
} finally {
|
|
this.loading = false;
|
|
}
|
|
},
|
|
|
|
async pause() {
|
|
if (!this.currentJob || this.loading || !this.isCurrentDownloading()) return;
|
|
|
|
this.loading = true;
|
|
this._setMessage(T.pausingDownload);
|
|
try {
|
|
const res = await fetch(`/api/player/torrents/${this.currentJob.id}/pause`, {
|
|
method: 'POST',
|
|
});
|
|
const data = await res.json();
|
|
if (!res.ok) throw new Error(data.error || T.pauseFailed);
|
|
this.currentJob = data;
|
|
this._rememberJob(data);
|
|
this._setMessage(T.downloadPaused);
|
|
this._stopPoll();
|
|
await this.loadSessions();
|
|
} catch (err) {
|
|
this._setMessage(err.message || String(err), true);
|
|
} finally {
|
|
this.loading = false;
|
|
}
|
|
},
|
|
|
|
_poll(id) {
|
|
this._stopPoll();
|
|
this._pollJobId = id;
|
|
this._pollTimer = setInterval(async () => {
|
|
try {
|
|
const res = await fetch(`/api/player/torrents/${id}/status`);
|
|
const data = await res.json();
|
|
if (!res.ok) throw new Error(data.error || T.statusFailed);
|
|
this._rememberJob(data);
|
|
if (this._isSelectedJob(id)) {
|
|
this.currentJob = data;
|
|
this._setMessage(
|
|
this.statusLabel(data) + ' - ' + this.progressValue(data).toFixed(1) + '% - ' + this.bytes(data.downloaded_bytes),
|
|
data.status === 'failed'
|
|
);
|
|
}
|
|
if (data.status === 'complete' || data.status === 'failed') {
|
|
this._stopPoll();
|
|
this.loadSessions();
|
|
this.loadAgentStatus();
|
|
}
|
|
} catch (err) {
|
|
if (this._isSelectedJob(id)) this._setMessage(err.message || String(err), true);
|
|
this._stopPoll();
|
|
}
|
|
}, 2000);
|
|
},
|
|
});
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Playlists store
|
|
// -----------------------------------------------------------------------
|
|
Alpine.store('playlists', {
|
|
list: [],
|
|
modal: null, // { mode: 'create'|'rename', title: '', id?: number }
|
|
picker: null, // { trackIds: [1,2,3] }
|
|
|
|
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 || '');
|
|
},
|
|
|
|
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 };
|
|
},
|
|
|
|
async addToPicked(playlistId) {
|
|
if (!this.picker) return;
|
|
try {
|
|
await fetch(`/api/player/playlists/${playlistId}/tracks`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ track_ids: this.picker.trackIds }),
|
|
});
|
|
await this.reload();
|
|
} catch {}
|
|
this.picker = null;
|
|
},
|
|
});
|
|
});
|
|
|
|
function installConnectionFetchMonitor() {
|
|
if (window.__furumusicConnectionMonitorInstalled || !window.fetch) return;
|
|
window.__furumusicConnectionMonitorInstalled = true;
|
|
const nativeFetch = window.fetch.bind(window);
|
|
|
|
window.fetch = async (...args) => {
|
|
const tracked = isTrackedPlayerRequest(args[0]);
|
|
try {
|
|
const response = await nativeFetch(...args);
|
|
if (tracked) {
|
|
if (response.status >= 500) {
|
|
Alpine.store('connection')?.recordFailure();
|
|
} else {
|
|
Alpine.store('connection')?.recordSuccess();
|
|
}
|
|
}
|
|
return response;
|
|
} catch (error) {
|
|
if (tracked) Alpine.store('connection')?.recordFailure();
|
|
throw error;
|
|
}
|
|
};
|
|
}
|
|
|
|
function isTrackedPlayerRequest(input) {
|
|
const rawUrl = typeof input === 'string' ? input : input?.url;
|
|
if (!rawUrl) return false;
|
|
try {
|
|
const url = new URL(rawUrl, window.location.href);
|
|
return url.origin === window.location.origin && url.pathname.startsWith('/api/player/');
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
</script>
|