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:
@@ -722,6 +722,9 @@
|
||||
<button class="track-action-btn queue-insert-btn queue-end-btn" @click.stop="$store.queue.addToEnd([item.track])" title="{{ t.player_add_to_queue }}">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 6h14M5 12h14M5 18h7"/><path d="M17 15l4 3-4 3" fill="currentColor" stroke="none"/></svg>
|
||||
</button>
|
||||
<button class="track-action-btn track-share-btn" @click.stop="$store.sharing.copyTrack(item.track || item.track_id, $event.currentTarget)" title="{{ t.player_share_track }}" aria-label="{{ t.player_share_track }}">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/><path d="M8.6 10.6l6.8-3.9M8.6 13.4l6.8 3.9"/></svg>
|
||||
</button>
|
||||
<button class="track-action-btn playlist-add-btn" @click.stop="$store.playlists.showPicker([item.track_id])" title="{{ t.player_add_to_playlist }}">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M8 6h13M8 12h13M8 18h13M3 6h.01M3 12h.01M3 18h.01"/></svg>
|
||||
</button>
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
+70
-36
@@ -411,17 +411,6 @@
|
||||
<template x-if="!artist.image_url">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="12" cy="8" r="4"/><path d="M20 21a8 8 0 10-16 0"/></svg>
|
||||
</template>
|
||||
<button class="artist-follow-card-btn"
|
||||
:class="{ followed: $store.follows.has(artist.id) }"
|
||||
@click.stop="$store.follows.toggle(artist.id)"
|
||||
:title="$store.follows.has(artist.id) ? '{{ t.player_unfollow_artist }}' : '{{ t.player_follow_artist }}'">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M16 21v-2a4 4 0 00-4-4H6a4 4 0 00-4 4v2"/>
|
||||
<circle cx="9" cy="7" r="4"/>
|
||||
<path x-show="!$store.follows.has(artist.id)" d="M19 8v6M16 11h6"/>
|
||||
<path x-show="$store.follows.has(artist.id)" d="M16 11l2 2 4-5"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="search-artist-name" x-text="artist.name"></div>
|
||||
</div>
|
||||
@@ -504,6 +493,9 @@
|
||||
<button class="track-action-btn queue-insert-btn queue-end-btn" @click.stop="$store.queue.addToEnd([track])" title="{{ t.player_add_to_queue }}">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 6h14M5 12h14M5 18h7"/><path d="M17 15l4 3-4 3" fill="currentColor" stroke="none"/></svg>
|
||||
</button>
|
||||
<button class="track-action-btn track-share-btn" @click.stop="$store.sharing.copyTrack(track, $event.currentTarget)" title="{{ t.player_share_track }}" aria-label="{{ t.player_share_track }}">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/><path d="M8.6 10.6l6.8-3.9M8.6 13.4l6.8 3.9"/></svg>
|
||||
</button>
|
||||
<button class="track-action-btn playlist-add-btn" @click.stop="$store.playlists.showPicker([track.id])" title="{{ t.player_add_to_playlist }}">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M8 6h13M8 12h13M8 18h13M3 6h.01M3 12h.01M3 18h.01"/></svg>
|
||||
</button>
|
||||
@@ -725,6 +717,9 @@
|
||||
<button class="track-action-btn queue-insert-btn queue-end-btn" @click.stop="$store.queue.addToEnd([track])" title="{{ t.player_add_to_queue }}">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 6h14M5 12h14M5 18h7"/><path d="M17 15l4 3-4 3" fill="currentColor" stroke="none"/></svg>
|
||||
</button>
|
||||
<button class="track-action-btn track-share-btn" @click.stop="$store.sharing.copyTrack(track, $event.currentTarget)" title="{{ t.player_share_track }}" aria-label="{{ t.player_share_track }}">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/><path d="M8.6 10.6l6.8-3.9M8.6 13.4l6.8 3.9"/></svg>
|
||||
</button>
|
||||
<button class="track-action-btn playlist-add-btn" @click.stop="$store.playlists.showPicker([track.id])" title="{{ t.player_add_to_playlist }}">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M8 6h13M8 12h13M8 18h13M3 6h.01M3 12h.01M3 18h.01"/></svg>
|
||||
</button>
|
||||
@@ -780,25 +775,36 @@
|
||||
</div>
|
||||
<div class="release-year" x-text="$store.library.currentRelease.year || ''"></div>
|
||||
<div class="release-actions">
|
||||
<button class="release-action-btn secondary"
|
||||
@click.stop="$store.library.openReleaseInfo($store.library.currentRelease)"
|
||||
:title="$store.library.releaseInfo($store.library.currentRelease)"
|
||||
aria-label="{{ t.player_release_info }}">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="16" x2="12" y2="12"/><line x1="12" y1="8" x2="12.01" y2="8"/></svg>
|
||||
{{ t.player_info }}
|
||||
</button>
|
||||
<button class="release-action-btn primary" @click="$store.queue.playRelease($store.library.currentRelease.tracks, 0)">
|
||||
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z"/></svg>
|
||||
{{ t.player_play }}
|
||||
</button>
|
||||
<button class="release-action-btn secondary" @click="$store.queue.addToEnd($store.library.currentRelease.tracks)" title="{{ t.player_add_to_end_queue }}">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
|
||||
{{ t.player_queue }}
|
||||
</button>
|
||||
<button class="release-action-btn secondary" @click="$store.queue.addNextInQueue($store.library.currentRelease.tracks)" title="{{ t.player_play_next }}">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 6h14M5 12h8M5 18h14"/><path d="M17 10l4 3-4 3" fill="currentColor" stroke="none"/></svg>
|
||||
{{ t.player_next }}
|
||||
</button>
|
||||
<div class="release-action-row release-meta-actions">
|
||||
<button class="release-action-btn secondary"
|
||||
@click.stop="$store.library.openReleaseInfo($store.library.currentRelease)"
|
||||
:title="$store.library.releaseInfo($store.library.currentRelease)"
|
||||
aria-label="{{ t.player_release_info }}">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="16" x2="12" y2="12"/><line x1="12" y1="8" x2="12.01" y2="8"/></svg>
|
||||
{{ t.player_info }}
|
||||
</button>
|
||||
<button class="release-action-btn secondary release-share-btn"
|
||||
@click.stop="$store.sharing.copyRelease($store.library.currentRelease, $event.currentTarget)"
|
||||
title="{{ t.player_share }}"
|
||||
aria-label="{{ t.player_share }}">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/><path d="M8.6 10.6l6.8-3.9M8.6 13.4l6.8 3.9"/></svg>
|
||||
{{ t.player_share }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="release-action-row release-playback-actions">
|
||||
<button class="release-action-btn primary" @click="$store.queue.playRelease($store.library.currentRelease.tracks, 0)">
|
||||
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z"/></svg>
|
||||
{{ t.player_play }}
|
||||
</button>
|
||||
<button class="release-action-btn secondary" @click="$store.queue.addToEnd($store.library.currentRelease.tracks)" title="{{ t.player_add_to_end_queue }}">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
|
||||
{{ t.player_queue }}
|
||||
</button>
|
||||
<button class="release-action-btn secondary" @click="$store.queue.addNextInQueue($store.library.currentRelease.tracks)" title="{{ t.player_play_next }}">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 6h14M5 12h8M5 18h14"/><path d="M17 10l4 3-4 3" fill="currentColor" stroke="none"/></svg>
|
||||
{{ t.player_next }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -812,7 +818,8 @@
|
||||
</div>
|
||||
<template x-for="(track, idx) in $store.library.currentRelease.tracks" :key="track.id">
|
||||
<div class="track-row"
|
||||
:class="{ playing: $store.player.currentTrack && $store.player.currentTrack.id === track.id }"
|
||||
:data-shared-track-id="track.id"
|
||||
:class="{ playing: $store.player.currentTrack && $store.player.currentTrack.id === track.id, 'shared-target': $store.sharing.isSharedTrack(track) }"
|
||||
@dblclick="$store.queue.playRelease([track], 0)">
|
||||
<span class="track-num" x-text="track.track_number || (idx + 1)"></span>
|
||||
<div class="track-info">
|
||||
@@ -846,6 +853,9 @@
|
||||
<button class="track-action-btn queue-insert-btn queue-end-btn" @click.stop="$store.queue.addToEnd([track])" title="{{ t.player_add_to_queue }}">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 6h14M5 12h14M5 18h7"/><path d="M17 15l4 3-4 3" fill="currentColor" stroke="none"/></svg>
|
||||
</button>
|
||||
<button class="track-action-btn track-share-btn" @click.stop="$store.sharing.copyTrack(track, $event.currentTarget)" title="{{ t.player_share_track }}" aria-label="{{ t.player_share_track }}">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/><path d="M8.6 10.6l6.8-3.9M8.6 13.4l6.8 3.9"/></svg>
|
||||
</button>
|
||||
<button class="track-action-btn playlist-add-btn" @click.stop="$store.playlists.showPicker([track.id])" title="{{ t.player_add_to_playlist }}">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M8 6h13M8 12h13M8 18h13M3 6h.01M3 12h.01M3 18h.01"/></svg>
|
||||
</button>
|
||||
@@ -919,6 +929,9 @@
|
||||
<button class="track-action-btn queue-insert-btn queue-end-btn" @click.stop="$store.queue.addToEnd([track])" title="{{ t.player_add_to_queue }}">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 6h14M5 12h14M5 18h7"/><path d="M17 15l4 3-4 3" fill="currentColor" stroke="none"/></svg>
|
||||
</button>
|
||||
<button class="track-action-btn track-share-btn" @click.stop="$store.sharing.copyTrack(track, $event.currentTarget)" title="{{ t.player_share_track }}" aria-label="{{ t.player_share_track }}">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/><path d="M8.6 10.6l6.8-3.9M8.6 13.4l6.8 3.9"/></svg>
|
||||
</button>
|
||||
<button class="track-action-btn playlist-add-btn" @click.stop="$store.playlists.showPicker([track.id])" title="{{ t.player_add_to_playlist }}">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M8 6h13M8 12h13M8 18h13M3 6h.01M3 12h.01M3 18h.01"/></svg>
|
||||
</button>
|
||||
@@ -938,7 +951,12 @@
|
||||
<div class="queue-panel" :class="{ hidden: !$store.queue.visible }">
|
||||
<div class="queue-header">
|
||||
<h3>{{ t.player_queue }}</h3>
|
||||
<button class="queue-clear-btn" @click="$store.queue.clear()">{{ t.player_clear }}</button>
|
||||
<div class="queue-header-actions">
|
||||
<button class="queue-share-btn" @click="$store.sharing.copyQueue($event.currentTarget)" :disabled="$store.queue.tracks.length === 0" title="{{ t.player_share_queue }}" aria-label="{{ t.player_share_queue }}">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/><path d="M8.6 10.6l6.8-3.9M8.6 13.4l6.8 3.9"/></svg>
|
||||
</button>
|
||||
<button class="queue-clear-btn" @click="$store.queue.clear()">{{ t.player_clear }}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="queue-tracks">
|
||||
<template x-if="$store.queue.tracks.length === 0">
|
||||
@@ -1015,7 +1033,7 @@
|
||||
</button>
|
||||
<div class="player-now-playing">
|
||||
<template x-if="$store.player.currentTrack">
|
||||
<div style="display:flex;align-items:center;gap:12px;overflow:hidden">
|
||||
<div class="player-current-track">
|
||||
<div class="player-cover"
|
||||
@click.stop="$store.mobile.openPlayerFullscreen()">
|
||||
<template x-if="$store.player.currentTrack.cover_url">
|
||||
@@ -1035,6 +1053,12 @@
|
||||
aria-label="{{ t.player_like }}">
|
||||
<svg viewBox="0 0 24 24" :fill="$store.likes.has($store.player.currentTrack.id) ? 'currentColor' : 'none'" stroke="currentColor" stroke-width="2"><path d="M20.84 4.61a5.5 5.5 0 00-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 00-7.78 7.78L12 21.23l8.84-8.84a5.5 5.5 0 000-7.78z"/></svg>
|
||||
</button>
|
||||
<button class="track-action-btn player-current-share"
|
||||
@click.stop="$store.sharing.copyTrack($store.player.currentTrack, $event.currentTarget)"
|
||||
title="{{ t.player_share_track }}"
|
||||
aria-label="{{ t.player_share_track }}">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/><path d="M8.6 10.6l6.8-3.9M8.6 13.4l6.8 3.9"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="player-track-artist">
|
||||
<template x-for="(artist, artistIdx) in $store.library.trackArtistLinks($store.player.currentTrack)" :key="artist.label + '-' + artist.id + '-' + artistIdx">
|
||||
@@ -1060,7 +1084,7 @@
|
||||
<button class="player-btn" :class="{ active: $store.player.shuffle }" @click="$store.player.toggleShuffle()" title="{{ t.player_shuffle }}">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="16 3 21 3 21 8"/><line x1="4" y1="20" x2="21" y2="3"/><polyline points="21 16 21 21 16 21"/><line x1="15" y1="15" x2="21" y2="21"/><line x1="4" y1="4" x2="9" y2="9"/></svg>
|
||||
</button>
|
||||
<button class="player-btn" @click="$store.player.prev()" title="{{ t.player_previous }}">
|
||||
<button class="player-btn player-btn-prev" @click="$store.player.prev()" title="{{ t.player_previous }}">
|
||||
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M6 6h2v12H6zm3.5 6l8.5 6V6z"/></svg>
|
||||
</button>
|
||||
<button class="player-btn player-btn-play" @click="$store.player.toggle()">
|
||||
@@ -1071,7 +1095,7 @@
|
||||
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M6 4h4v16H6zM14 4h4v16h-4z"/></svg>
|
||||
</template>
|
||||
</button>
|
||||
<button class="player-btn" @click="$store.player.next()" title="{{ t.player_next }}">
|
||||
<button class="player-btn player-btn-next" @click="$store.player.next()" title="{{ t.player_next }}">
|
||||
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M6 18l8.5-6L6 6v12zM16 6v12h2V6h-2z"/></svg>
|
||||
</button>
|
||||
<button class="player-btn" :class="{ active: $store.player.repeatMode !== 'off' }" @click="$store.player.cycleRepeat()" title="{{ t.player_repeat }}">
|
||||
@@ -1085,7 +1109,9 @@
|
||||
</div>
|
||||
<div class="player-timeline">
|
||||
<span class="player-time" x-text="formatTime($store.player.currentTime)"></span>
|
||||
<div class="progress-bar" @click="$store.player.seekFromClick($event)">
|
||||
<div class="progress-bar"
|
||||
@pointerdown.prevent="$store.player.startProgressDrag($event)"
|
||||
aria-label="{{ t.player_duration }}">
|
||||
<div class="progress-bar-fill" :style="'width:' + $store.player.progress + '%'">
|
||||
<div class="progress-bar-thumb"></div>
|
||||
</div>
|
||||
@@ -1222,6 +1248,14 @@
|
||||
</button>
|
||||
<div class="jam-create-panel" x-show="$store.devices.jamPanelOpen" x-transition x-cloak>
|
||||
<div class="jam-panel-title" x-text="$store.devices.jamPanelMode === 'manage' ? 'Invite listeners' : 'Start Jam'"></div>
|
||||
<label class="jam-local-playback-toggle"
|
||||
x-show="$store.devices.jamPanelMode === 'manage' && $store.devices.canPlayJamLocally()"
|
||||
x-cloak>
|
||||
<input type="checkbox"
|
||||
:checked="$store.devices.jamLocalPlayback"
|
||||
@change="$store.devices.setJamLocalPlayback($event.target.checked)">
|
||||
<span>{{ t.player_jam_play_on_this_device }}</span>
|
||||
</label>
|
||||
<div class="jam-selected-users" x-show="$store.devices.jamSelectedUsers.length > 0">
|
||||
<template x-for="user in $store.devices.jamSelectedUsers" :key="user.id">
|
||||
<button class="jam-user-chip" @click="$store.devices.removeJamInvitee(user.id)">
|
||||
|
||||
+348
-45
@@ -735,6 +735,26 @@ button.user-stat:hover {
|
||||
.track-row:hover { background: var(--bg-hover); }
|
||||
.track-row.playing { color: var(--accent); }
|
||||
.track-row.playing .track-num { color: var(--accent); }
|
||||
.track-row.shared-target {
|
||||
background: rgba(29, 185, 84, 0.12);
|
||||
box-shadow: inset 3px 0 0 var(--accent);
|
||||
animation: shared-track-pulse 1.2s ease;
|
||||
}
|
||||
.track-row.shared-target .track-num,
|
||||
.track-row.shared-target .track-title {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
@keyframes shared-track-pulse {
|
||||
0% {
|
||||
background: rgba(29, 185, 84, 0.28);
|
||||
box-shadow: inset 3px 0 0 var(--accent), 0 0 0 1px rgba(29, 185, 84, 0.18);
|
||||
}
|
||||
100% {
|
||||
background: rgba(29, 185, 84, 0.12);
|
||||
box-shadow: inset 3px 0 0 var(--accent);
|
||||
}
|
||||
}
|
||||
|
||||
.track-num {
|
||||
font-size: 14px;
|
||||
@@ -953,6 +973,14 @@ button.user-stat:hover {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.release-action-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.release-action-btn {
|
||||
@@ -1080,6 +1108,88 @@ button.user-stat:hover {
|
||||
|
||||
.queue-header h3 { font-size: 14px; font-weight: 600; }
|
||||
|
||||
.queue-header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.queue-share-btn {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.queue-share-btn:hover:not(:disabled) {
|
||||
color: var(--text-primary);
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.queue-share-btn:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.queue-share-btn svg {
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
}
|
||||
|
||||
.track-share-btn.share-copy-flash,
|
||||
.queue-share-btn.share-copy-flash,
|
||||
.player-current-share.share-copy-flash,
|
||||
.release-share-btn.share-copy-flash {
|
||||
animation: share-copy-flash 0.72s ease;
|
||||
}
|
||||
|
||||
.track-share-btn.share-copy-failed,
|
||||
.queue-share-btn.share-copy-failed,
|
||||
.player-current-share.share-copy-failed,
|
||||
.release-share-btn.share-copy-failed {
|
||||
animation: share-copy-failed 0.72s ease;
|
||||
}
|
||||
|
||||
@keyframes share-copy-flash {
|
||||
0% {
|
||||
background: rgba(29,185,84,0.28);
|
||||
border-color: rgba(29,185,84,0.52);
|
||||
color: #c9ffd9;
|
||||
transform: scale(1);
|
||||
}
|
||||
35% {
|
||||
background: rgba(29,185,84,0.34);
|
||||
border-color: rgba(29,185,84,0.72);
|
||||
color: #d9ffe5;
|
||||
transform: scale(1.08);
|
||||
}
|
||||
100% {
|
||||
background: inherit;
|
||||
border-color: inherit;
|
||||
color: inherit;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes share-copy-failed {
|
||||
0%, 100% {
|
||||
color: inherit;
|
||||
transform: scale(1);
|
||||
}
|
||||
35% {
|
||||
background: rgba(229,96,96,0.2);
|
||||
border-color: rgba(229,96,96,0.5);
|
||||
color: #ffc7c7;
|
||||
transform: scale(1.05);
|
||||
}
|
||||
}
|
||||
|
||||
.queue-clear-btn {
|
||||
background: rgba(229, 96, 96, 0.13);
|
||||
border: 1px solid rgba(229, 96, 96, 0.18);
|
||||
@@ -1239,6 +1349,13 @@ button.user-stat:hover {
|
||||
|
||||
.player-now-playing > div { min-width: 0; }
|
||||
|
||||
.player-current-track {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.player-cover {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
@@ -1282,6 +1399,18 @@ button.user-stat:hover {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.player-current-share {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
padding: 4px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.player-current-share svg {
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
}
|
||||
|
||||
.player-track-artist {
|
||||
font-size: 11px;
|
||||
color: var(--text-subdued);
|
||||
@@ -1373,24 +1502,44 @@ button.user-stat:hover {
|
||||
|
||||
.progress-bar {
|
||||
flex: 1;
|
||||
height: 4px;
|
||||
background: var(--bg-active);
|
||||
border-radius: 2px;
|
||||
height: 28px;
|
||||
background: transparent;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
touch-action: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.progress-bar:hover { height: 6px; }
|
||||
.progress-bar::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: 50%;
|
||||
height: 4px;
|
||||
border-radius: 2px;
|
||||
background: var(--bg-active);
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
.progress-bar:hover::before { height: 6px; }
|
||||
|
||||
.progress-bar-fill {
|
||||
height: 100%;
|
||||
height: 4px;
|
||||
background: var(--text-primary);
|
||||
border-radius: 2px;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
pointer-events: none;
|
||||
transition: width 0.1s linear;
|
||||
}
|
||||
|
||||
.progress-bar:hover .progress-bar-fill { background: var(--accent); }
|
||||
.progress-bar:hover .progress-bar-fill {
|
||||
height: 6px;
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
.progress-bar-thumb {
|
||||
width: 12px;
|
||||
@@ -1405,7 +1554,8 @@ button.user-stat:hover {
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
.progress-bar:hover .progress-bar-thumb { opacity: 1; }
|
||||
.progress-bar:hover .progress-bar-thumb,
|
||||
.progress-bar:active .progress-bar-thumb { opacity: 1; }
|
||||
|
||||
.player-right {
|
||||
display: flex;
|
||||
@@ -1722,6 +1872,34 @@ button.user-stat:hover {
|
||||
font-weight: 750;
|
||||
}
|
||||
|
||||
.jam-local-playback-toggle {
|
||||
min-height: 30px;
|
||||
margin: -1px 0 7px;
|
||||
padding: 5px 6px;
|
||||
border-radius: 4px;
|
||||
background: rgba(82,145,255,0.055);
|
||||
color: var(--text-secondary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.jam-local-playback-toggle:hover {
|
||||
background: rgba(82,145,255,0.085);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.jam-local-playback-toggle input {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
margin: 0;
|
||||
accent-color: var(--accent);
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.jam-selected-users {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
@@ -3936,7 +4114,7 @@ button.user-stat:hover {
|
||||
|
||||
@media (max-width: 900px), (pointer: coarse) and (max-width: 1024px) {
|
||||
:root {
|
||||
--player-height: 168px;
|
||||
--player-height: 214px;
|
||||
--player-bar-space: calc(var(--player-height) + var(--safe-bottom));
|
||||
}
|
||||
|
||||
@@ -4109,12 +4287,13 @@ button.user-stat:hover {
|
||||
|
||||
.player-bar {
|
||||
position: relative;
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
grid-template-rows: 62px 58px;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
grid-template-rows: 62px 58px 44px;
|
||||
grid-template-areas:
|
||||
"now now"
|
||||
"buttons actions";
|
||||
gap: 4px 10px;
|
||||
"now"
|
||||
"buttons"
|
||||
"actions";
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
padding: 34px 12px calc(9px + var(--safe-bottom));
|
||||
touch-action: auto;
|
||||
@@ -4123,15 +4302,19 @@ button.user-stat:hover {
|
||||
|
||||
.player-now-playing {
|
||||
grid-area: now;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
justify-content: stretch;
|
||||
text-align: left;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.player-now-playing > div {
|
||||
flex-direction: row;
|
||||
justify-content: center;
|
||||
gap: 10px !important;
|
||||
.player-bar:not(.mobile-expanded) .player-current-track {
|
||||
display: grid;
|
||||
grid-template-columns: 52px minmax(0, 1fr) 30px 30px;
|
||||
grid-template-rows: repeat(3, auto);
|
||||
align-items: center;
|
||||
justify-content: stretch;
|
||||
column-gap: 10px;
|
||||
row-gap: 1px;
|
||||
width: 100%;
|
||||
max-width: 620px;
|
||||
margin: 0 auto;
|
||||
@@ -4144,23 +4327,64 @@ button.user-stat:hover {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.player-track-info {
|
||||
width: min(58vw, 360px);
|
||||
max-width: 360px;
|
||||
.player-bar:not(.mobile-expanded) .player-track-info {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
.player-track-title-row {
|
||||
justify-content: center;
|
||||
.player-bar:not(.mobile-expanded) .player-track-title-row {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
.player-current-like {
|
||||
display: none;
|
||||
.player-bar:not(.mobile-expanded) .player-current-like,
|
||||
.player-bar:not(.mobile-expanded) .player-current-share {
|
||||
display: flex;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
flex: 0 0 30px;
|
||||
padding: 5px;
|
||||
align-self: center;
|
||||
justify-self: end;
|
||||
}
|
||||
|
||||
.player-track-title,
|
||||
.player-track-artist,
|
||||
.player-track-release {
|
||||
text-align: center;
|
||||
.player-bar:not(.mobile-expanded) .player-current-like svg,
|
||||
.player-bar:not(.mobile-expanded) .player-current-share svg {
|
||||
width: 17px;
|
||||
height: 17px;
|
||||
}
|
||||
|
||||
.player-bar:not(.mobile-expanded) .player-track-title,
|
||||
.player-bar:not(.mobile-expanded) .player-track-artist,
|
||||
.player-bar:not(.mobile-expanded) .player-track-release {
|
||||
grid-column: 2;
|
||||
min-width: 0;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.player-bar:not(.mobile-expanded) .player-cover {
|
||||
grid-column: 1;
|
||||
grid-row: 1 / span 3;
|
||||
}
|
||||
|
||||
.player-bar:not(.mobile-expanded) .player-track-title {
|
||||
grid-row: 1;
|
||||
}
|
||||
|
||||
.player-bar:not(.mobile-expanded) .player-track-artist {
|
||||
grid-row: 2;
|
||||
}
|
||||
|
||||
.player-bar:not(.mobile-expanded) .player-track-release {
|
||||
grid-row: 3;
|
||||
}
|
||||
|
||||
.player-bar:not(.mobile-expanded) .player-current-share {
|
||||
grid-column: 3;
|
||||
grid-row: 1 / span 3;
|
||||
}
|
||||
|
||||
.player-bar:not(.mobile-expanded) .player-current-like {
|
||||
grid-column: 4;
|
||||
grid-row: 1 / span 3;
|
||||
}
|
||||
|
||||
.player-track-release {
|
||||
@@ -4178,7 +4402,7 @@ button.user-stat:hover {
|
||||
|
||||
.player-buttons {
|
||||
grid-area: buttons;
|
||||
justify-self: start;
|
||||
justify-self: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
@@ -4193,11 +4417,31 @@ button.user-stat:hover {
|
||||
padding: 7px;
|
||||
}
|
||||
|
||||
.player-btn-prev {
|
||||
min-width: 40px;
|
||||
min-height: 40px;
|
||||
}
|
||||
|
||||
.player-btn-next {
|
||||
min-width: 50px;
|
||||
min-height: 50px;
|
||||
}
|
||||
|
||||
.player-btn svg {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
}
|
||||
|
||||
.player-bar:not(.mobile-expanded) .player-btn-prev svg {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
}
|
||||
|
||||
.player-bar:not(.mobile-expanded) .player-btn-next svg {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
}
|
||||
|
||||
.player-btn-play {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
@@ -4238,7 +4482,12 @@ button.user-stat:hover {
|
||||
background: rgba(29, 185, 84, 0.18);
|
||||
}
|
||||
|
||||
.player-bar:not(.mobile-expanded) .progress-bar::before {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.player-bar:not(.mobile-expanded) .progress-bar-fill {
|
||||
height: 100%;
|
||||
border-radius: 0;
|
||||
background: var(--accent);
|
||||
}
|
||||
@@ -4267,8 +4516,8 @@ button.user-stat:hover {
|
||||
.player-version-chip {
|
||||
display: block;
|
||||
position: absolute;
|
||||
right: 12px;
|
||||
bottom: calc(12px + var(--safe-bottom));
|
||||
right: 8px;
|
||||
bottom: calc(2px + var(--safe-bottom));
|
||||
width: auto;
|
||||
max-width: none;
|
||||
margin-top: 0;
|
||||
@@ -4286,9 +4535,9 @@ button.user-stat:hover {
|
||||
|
||||
.player-right {
|
||||
grid-area: actions;
|
||||
justify-self: end;
|
||||
justify-self: stretch;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(132px, 1fr) 40px 40px;
|
||||
grid-template-columns: minmax(0, 1fr) 40px 40px;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
width: 100%;
|
||||
@@ -4297,12 +4546,19 @@ button.user-stat:hover {
|
||||
|
||||
.volume-control {
|
||||
display: grid;
|
||||
grid-template-columns: 30px minmax(112px, 1fr);
|
||||
grid-template-columns: 30px minmax(0, 1fr);
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
justify-self: center;
|
||||
width: min(80vw, 360px);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.player-right > .queue-toggle-btn,
|
||||
.player-right > .device-picker {
|
||||
justify-self: end;
|
||||
}
|
||||
|
||||
.volume-btn {
|
||||
min-width: 30px;
|
||||
min-height: 36px;
|
||||
@@ -4335,11 +4591,13 @@ button.user-stat:hover {
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
height: 6px;
|
||||
height: 34px;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.progress-bar::before,
|
||||
.progress-bar-fill {
|
||||
height: 6px;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
@@ -4482,6 +4740,12 @@ button.user-stat:hover {
|
||||
height: 38px;
|
||||
}
|
||||
|
||||
.player-bar.mobile-expanded .mobile-full-player .player-current-share {
|
||||
display: flex;
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
}
|
||||
|
||||
.player-bar.mobile-expanded .mobile-full-player .player-track-release {
|
||||
margin-top: 4px;
|
||||
font-size: 13px;
|
||||
@@ -4512,11 +4776,26 @@ button.user-stat:hover {
|
||||
min-height: 56px;
|
||||
}
|
||||
|
||||
.player-bar.mobile-expanded .mobile-full-player .player-btn-prev {
|
||||
min-width: 52px;
|
||||
min-height: 52px;
|
||||
}
|
||||
|
||||
.player-bar.mobile-expanded .mobile-full-player .player-btn-next {
|
||||
min-width: 64px;
|
||||
min-height: 64px;
|
||||
}
|
||||
|
||||
.player-bar.mobile-expanded .mobile-full-player .player-btn svg {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
}
|
||||
|
||||
.player-bar.mobile-expanded .mobile-full-player .player-btn-next svg {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
.player-bar.mobile-expanded .mobile-full-player .player-btn-play {
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
@@ -4532,10 +4811,15 @@ button.user-stat:hover {
|
||||
}
|
||||
|
||||
.player-bar.mobile-expanded .mobile-full-player .progress-bar {
|
||||
height: 7px;
|
||||
height: 36px;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.player-bar.mobile-expanded .mobile-full-player .progress-bar::before,
|
||||
.player-bar.mobile-expanded .mobile-full-player .progress-bar-fill {
|
||||
height: 7px;
|
||||
}
|
||||
|
||||
.player-bar.mobile-expanded .mobile-full-player .progress-bar-thumb {
|
||||
opacity: 1;
|
||||
}
|
||||
@@ -4698,7 +4982,7 @@ button.user-stat:hover {
|
||||
|
||||
@media (max-width: 560px) {
|
||||
:root {
|
||||
--player-height: 170px;
|
||||
--player-height: 214px;
|
||||
--player-bar-space: calc(var(--player-height) + var(--safe-bottom));
|
||||
}
|
||||
|
||||
@@ -4810,11 +5094,19 @@ button.user-stat:hover {
|
||||
}
|
||||
|
||||
.release-actions {
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
gap: 7px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.release-action-row {
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.release-action-btn {
|
||||
padding: 8px 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.track-list-header {
|
||||
@@ -5172,7 +5464,7 @@ button.user-stat:hover {
|
||||
gap: 8px;
|
||||
padding-left: 10px;
|
||||
padding-right: 10px;
|
||||
grid-template-rows: 60px 58px;
|
||||
grid-template-rows: 60px 56px 42px;
|
||||
}
|
||||
|
||||
.player-track-title { font-size: 12px; }
|
||||
@@ -5181,14 +5473,15 @@ button.user-stat:hover {
|
||||
.player-buttons { gap: 2px; }
|
||||
|
||||
.player-right {
|
||||
grid-template-columns: minmax(68px, 1fr) 34px 34px;
|
||||
grid-template-columns: minmax(0, 1fr) 34px 34px;
|
||||
width: 100%;
|
||||
gap: 3px;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.volume-control {
|
||||
grid-template-columns: 22px minmax(44px, 1fr);
|
||||
gap: 3px;
|
||||
grid-template-columns: 22px minmax(0, 1fr);
|
||||
width: min(80vw, 320px);
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.player-version-chip {
|
||||
@@ -5224,6 +5517,16 @@ button.user-stat:hover {
|
||||
padding: 7px;
|
||||
}
|
||||
|
||||
.player-btn-prev {
|
||||
min-width: 40px;
|
||||
min-height: 40px;
|
||||
}
|
||||
|
||||
.player-btn-next {
|
||||
min-width: 50px;
|
||||
min-height: 50px;
|
||||
}
|
||||
|
||||
.player-btn svg {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
|
||||
Reference in New Issue
Block a user