Add music sharing and mobile player polish

Add track, release, and queue sharing with post-login redirects; support shared playlist links and highlighted shared tracks.

Add local synchronized playback for jams, constrain HTTP metrics to known routes, and refine mobile player controls/layout.
This commit is contained in:
Ultradesu
2026-06-03 17:35:55 +03:00
parent d31dce3ece
commit 3a9240b82c
13 changed files with 1550 additions and 163 deletions
+398 -6
View File
@@ -44,6 +44,10 @@ const T = {
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 }}",
@@ -285,6 +289,204 @@ document.addEventListener('alpine:init', () => {
},
});
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
// -----------------------------------------------------------------------
@@ -510,11 +712,18 @@ document.addEventListener('alpine:init', () => {
_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;
@@ -528,6 +737,10 @@ document.addEventListener('alpine:init', () => {
audio.addEventListener('ended', () => {
this._trackListenedDelta();
this._recordHistory(true);
if (Alpine.store('devices')?.shouldPlayJamLocally()) {
this.isPlaying = false;
return;
}
this.next();
});
@@ -555,7 +768,9 @@ document.addEventListener('alpine:init', () => {
}, 250);
// Restore state
this._restoreState();
if (!this._hasInitialShareLink()) {
this._restoreState();
}
// Save state on page unload
window.addEventListener('beforeunload', () => {
@@ -567,6 +782,12 @@ document.addEventListener('alpine:init', () => {
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,
@@ -583,6 +804,12 @@ document.addEventListener('alpine:init', () => {
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,
@@ -595,6 +822,7 @@ document.addEventListener('alpine:init', () => {
_playLocal(track, options = {}) {
this.currentTrack = track;
this._localSourceTrackId = track.id;
this._historyRecorded = false;
this._resetPlaybackTracking();
audio.src = track.stream_url;
@@ -626,6 +854,9 @@ document.addEventListener('alpine:init', () => {
this.isPlaying = false;
this._remoteStateBaseTime = this.currentTime;
this._remoteStateReceivedAt = Date.now();
if (this._shouldMirrorRemoteLocally()) {
this._pauseLocal();
}
this._sendRemote('pause');
return;
}
@@ -637,6 +868,9 @@ document.addEventListener('alpine:init', () => {
this.isPlaying = true;
this._remoteStateBaseTime = this.currentTime;
this._remoteStateReceivedAt = Date.now();
if (this._shouldMirrorRemoteLocally()) {
audio.play().catch(() => {});
}
this._sendRemote('resume');
return;
}
@@ -656,6 +890,10 @@ document.addEventListener('alpine:init', () => {
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;
}
@@ -673,13 +911,35 @@ document.addEventListener('alpine:init', () => {
seekFromClick(event) {
const bar = event.currentTarget;
this._setProgressFromClientX(event.clientX, bar);
},
_setProgressFromClientX(clientX, bar) {
const rect = bar.getBoundingClientRect();
const pct = (event.clientX - rect.left) / rect.width;
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', {
@@ -742,7 +1002,7 @@ document.addEventListener('alpine:init', () => {
setVolume(v) {
this._setVolumeLocal(v);
if (this._shouldSendRemote()) {
if (this._shouldSendRemote() && !this._shouldMirrorRemoteLocally()) {
this._sendRemote('set_volume', { volume: this.volume });
}
},
@@ -832,6 +1092,10 @@ document.addEventListener('alpine:init', () => {
return !!devices && !this._remoteExecuting && !devices.isActive();
},
_shouldMirrorRemoteLocally() {
return !!Alpine.store('devices')?.shouldPlayJamLocally();
},
_sendRemote(command, payload = {}) {
const devices = Alpine.store('devices');
if (!devices) return false;
@@ -906,8 +1170,57 @@ document.addEventListener('alpine:init', () => {
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.currentTrack) return;
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) {
@@ -1046,6 +1359,7 @@ document.addEventListener('alpine:init', () => {
: tracks[idx];
if (currentTrack) {
this.currentTrack = currentTrack;
this._localSourceTrackId = currentTrack.id;
this._historyRecorded = false;
this._resetPlaybackTracking();
audio.src = currentTrack.stream_url;
@@ -1177,6 +1491,7 @@ document.addEventListener('alpine:init', () => {
jamUsers: [],
jamSelectedUsers: [],
jamSearching: false,
jamLocalPlayback: false,
remoteHintVisible: false,
remoteHintDeviceName: '',
_remoteHintShown: false,
@@ -1184,6 +1499,7 @@ document.addEventListener('alpine:init', () => {
_pollTimer: null,
_jamSearchTimer: null,
_stateRefreshTick: 0,
_lastPlaybackState: null,
init() {
this.id = this._ensureId();
@@ -1239,6 +1555,7 @@ document.addEventListener('alpine:init', () => {
});
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');
@@ -1247,7 +1564,11 @@ document.addEventListener('alpine:init', () => {
}
if (player && !this.isActive()) {
if (data.playback_state) {
player._applyRemotePlaybackState(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();
}
@@ -1257,9 +1578,11 @@ document.addEventListener('alpine:init', () => {
_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);
@@ -1267,6 +1590,9 @@ document.addEventListener('alpine:init', () => {
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();
}
@@ -1350,6 +1676,41 @@ document.addEventListener('alpine:init', () => {
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;
@@ -1555,6 +1916,7 @@ document.addEventListener('alpine:init', () => {
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;
@@ -1588,6 +1950,7 @@ document.addEventListener('alpine:init', () => {
this.currentJamId = null;
this.jamPanelOpen = false;
this.jamPanelJamId = null;
this._setJamLocalPlayback(false, { pauseLocal: true });
sessionStorage.removeItem('furu_player_jam_id');
},
});
@@ -1881,6 +2244,9 @@ document.addEventListener('alpine:init', () => {
// Navigate to initial hash (if any)
this._navigateFromHash({ fromHash: true, restoreScroll: true });
this.$nextTick(() => {
Alpine.store('sharing')?.handleInitialShareLinks();
});
},
_setHash(hash) {
@@ -1957,7 +2323,11 @@ document.addEventListener('alpine:init', () => {
},
_afterNavigation(options = {}) {
if (options.restoreScroll) {
if (options.focusSharedTrackId) {
this.$nextTick(() => {
Alpine.store('sharing')?.focusSharedTrack(options.focusSharedTrackId);
});
} else if (options.restoreScroll) {
this._restoreScrollPosition(this._activeHash);
} else {
this.$nextTick(() => { this._scrollToTop(); });
@@ -2331,6 +2701,28 @@ document.addEventListener('alpine:init', () => {
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}`);