This commit is contained in:
@@ -1251,7 +1251,12 @@ document.addEventListener('alpine:init', () => {
|
||||
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);
|
||||
if (track?.unavailable || (track && !track.stream_url)) {
|
||||
this._applyRemotePlaybackState({ ...payload, track, paused: true });
|
||||
this._pauseLocal();
|
||||
} else if (track) {
|
||||
this._playLocal(track, payload);
|
||||
}
|
||||
} else if (command.command === 'pause') {
|
||||
this.pause();
|
||||
} else if (command.command === 'resume') {
|
||||
@@ -1496,12 +1501,18 @@ document.addEventListener('alpine:init', () => {
|
||||
jamSelectedUsers: [],
|
||||
jamSearching: false,
|
||||
jamLocalPlayback: false,
|
||||
fed: null,
|
||||
fedInvite: '',
|
||||
fedInviteInput: '',
|
||||
fedBusy: false,
|
||||
fedError: '',
|
||||
remoteHintVisible: false,
|
||||
remoteHintDeviceName: '',
|
||||
_remoteHintShown: false,
|
||||
_remoteHintTimer: null,
|
||||
_pollTimer: null,
|
||||
_jamSearchTimer: null,
|
||||
_fedRefreshTick: 0,
|
||||
_stateRefreshTick: 0,
|
||||
_lastPlaybackState: null,
|
||||
|
||||
@@ -1509,9 +1520,13 @@ document.addEventListener('alpine:init', () => {
|
||||
this.id = this._ensureId();
|
||||
this.currentJamId = sessionStorage.getItem('furu_player_jam_id') || null;
|
||||
this.heartbeat();
|
||||
this.loadFedDevices();
|
||||
this._pollTimer = setInterval(() => this.poll(), 500);
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (!document.hidden) this.poll();
|
||||
if (!document.hidden) {
|
||||
this.poll();
|
||||
this.loadFedDevices();
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
@@ -1561,6 +1576,9 @@ document.addEventListener('alpine:init', () => {
|
||||
const data = await res.json();
|
||||
if (data.playback_state) this._lastPlaybackState = data.playback_state;
|
||||
this._apply(data);
|
||||
if (this.open || (++this._fedRefreshTick % 10 === 0)) {
|
||||
this.loadFedDevices();
|
||||
}
|
||||
|
||||
const player = Alpine.store('player');
|
||||
if (player && Array.isArray(data.commands)) {
|
||||
@@ -1730,7 +1748,142 @@ document.addEventListener('alpine:init', () => {
|
||||
toggle() {
|
||||
this.dismissRemoteHint();
|
||||
this.open = !this.open;
|
||||
if (this.open) this.poll();
|
||||
if (this.open) {
|
||||
this.poll();
|
||||
this.loadFedDevices();
|
||||
}
|
||||
},
|
||||
|
||||
fedDevices() {
|
||||
return (this.fed?.devices || []).filter(device => !device.revoked);
|
||||
},
|
||||
|
||||
fedPending() {
|
||||
return this.fed?.pending || [];
|
||||
},
|
||||
|
||||
fedSummary() {
|
||||
if (!this.fed) return 'Fed sync unavailable';
|
||||
const outbox = Number(this.fed.outbox_ops || 0);
|
||||
const unresolved = Number(this.fed.unresolved_items || 0);
|
||||
return `${this.fed.active_devices || 0} linked · ${outbox} queued · ${unresolved} unresolved`;
|
||||
},
|
||||
|
||||
async loadFedDevices() {
|
||||
try {
|
||||
const res = await fetch('/api/player/fed-devices');
|
||||
if (!res.ok) {
|
||||
this.fedError = await this._errorText(res);
|
||||
return;
|
||||
}
|
||||
this.fed = await res.json();
|
||||
this.fedError = '';
|
||||
} catch (err) {
|
||||
this.fedError = String(err?.message || err || 'Fed sync unavailable');
|
||||
}
|
||||
},
|
||||
|
||||
async generateFedInvite() {
|
||||
if (this.fedBusy) return;
|
||||
this.fedBusy = true;
|
||||
try {
|
||||
const res = await fetch('/api/player/fed-devices/invite', { method: 'POST' });
|
||||
if (!res.ok) throw new Error(await this._errorText(res));
|
||||
const data = await res.json();
|
||||
this.fedInvite = data.invite || '';
|
||||
if (this.fedInvite && navigator.clipboard?.writeText) {
|
||||
navigator.clipboard.writeText(this.fedInvite).catch(() => {});
|
||||
}
|
||||
await this.loadFedDevices();
|
||||
} catch (err) {
|
||||
this.fedError = String(err?.message || err || 'Invite failed');
|
||||
} finally {
|
||||
this.fedBusy = false;
|
||||
}
|
||||
},
|
||||
|
||||
async connectFedInvite() {
|
||||
const invite = this.fedInviteInput.trim();
|
||||
if (!invite || this.fedBusy) return;
|
||||
this.fedBusy = true;
|
||||
try {
|
||||
const res = await fetch('/api/player/fed-devices/connect', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ invite }),
|
||||
});
|
||||
if (!res.ok) throw new Error(await this._errorText(res));
|
||||
this.fedInviteInput = '';
|
||||
await this.loadFedDevices();
|
||||
} catch (err) {
|
||||
this.fedError = String(err?.message || err || 'Connect failed');
|
||||
} finally {
|
||||
this.fedBusy = false;
|
||||
}
|
||||
},
|
||||
|
||||
async answerFedPairing(request, accept, useRequesterGroup = false) {
|
||||
if (!request?.request_id || this.fedBusy) return;
|
||||
this.fedBusy = true;
|
||||
try {
|
||||
const res = await fetch('/api/player/fed-devices/pairing', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
request_id: request.request_id,
|
||||
accept,
|
||||
use_requester_group: useRequesterGroup,
|
||||
}),
|
||||
});
|
||||
if (!res.ok) throw new Error(await this._errorText(res));
|
||||
await this.loadFedDevices();
|
||||
} catch (err) {
|
||||
this.fedError = String(err?.message || err || 'Pairing failed');
|
||||
} finally {
|
||||
this.fedBusy = false;
|
||||
}
|
||||
},
|
||||
|
||||
async revokeFedDevice(device) {
|
||||
if (!device?.device_id || device.is_self || this.fedBusy) return;
|
||||
if (!window.confirm(`Revoke ${device.name || device.device_id}?`)) return;
|
||||
this.fedBusy = true;
|
||||
try {
|
||||
const res = await fetch('/api/player/fed-devices/revoke', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ device_id: device.device_id }),
|
||||
});
|
||||
if (!res.ok) throw new Error(await this._errorText(res));
|
||||
await this.loadFedDevices();
|
||||
} catch (err) {
|
||||
this.fedError = String(err?.message || err || 'Revoke failed');
|
||||
} finally {
|
||||
this.fedBusy = false;
|
||||
}
|
||||
},
|
||||
|
||||
async syncFedDevices() {
|
||||
if (this.fedBusy) return;
|
||||
this.fedBusy = true;
|
||||
try {
|
||||
const res = await fetch('/api/player/fed-devices/sync', { method: 'POST' });
|
||||
if (!res.ok) throw new Error(await this._errorText(res));
|
||||
await this.loadFedDevices();
|
||||
} catch (err) {
|
||||
this.fedError = String(err?.message || err || 'Sync failed');
|
||||
} finally {
|
||||
this.fedBusy = false;
|
||||
}
|
||||
},
|
||||
|
||||
async _errorText(res) {
|
||||
try {
|
||||
const data = await res.json();
|
||||
return data.error || res.statusText || 'Request failed';
|
||||
} catch {
|
||||
return res.statusText || 'Request failed';
|
||||
}
|
||||
},
|
||||
|
||||
async select(deviceId) {
|
||||
|
||||
@@ -1335,6 +1335,67 @@
|
||||
</span>
|
||||
</button>
|
||||
</template>
|
||||
<div class="device-section-label fed-section-label">Fed Clients</div>
|
||||
<div class="fed-device-panel">
|
||||
<div class="fed-device-status" x-text="$store.devices.fedSummary()"></div>
|
||||
<template x-if="$store.devices.fedError">
|
||||
<div class="fed-device-error" x-text="$store.devices.fedError"></div>
|
||||
</template>
|
||||
<template x-for="request in $store.devices.fedPending()" :key="request.request_id">
|
||||
<div class="fed-pairing-card">
|
||||
<div class="fed-pairing-title" x-text="request.name || request.device_id"></div>
|
||||
<div class="fed-pairing-meta"
|
||||
x-text="request.requester_group_id ? 'Already in another sync group' : (request.client_version || 'waiting for approval')"></div>
|
||||
<template x-if="request.requester_group_id">
|
||||
<div class="fed-pairing-note">Recommended keeps the existing group intact.</div>
|
||||
</template>
|
||||
<div class="fed-device-actions">
|
||||
<button class="fed-action-btn primary"
|
||||
@click="$store.devices.answerFedPairing(request, true, !!request.requester_group_id)"
|
||||
x-text="request.requester_group_id ? 'Recommended' : 'Approve'"></button>
|
||||
<button class="fed-action-btn"
|
||||
@click="$store.devices.answerFedPairing(request, false, false)">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template x-for="device in $store.devices.fedDevices()" :key="device.device_id">
|
||||
<div class="fed-device-row" :class="{ self: device.is_self }">
|
||||
<span class="fed-device-dot" :class="{ self: device.is_self }"></span>
|
||||
<span class="fed-device-main">
|
||||
<span class="fed-device-name" x-text="device.name || device.device_id"></span>
|
||||
<span class="fed-device-meta"
|
||||
x-text="(device.is_self ? 'WEB' : (device.client_version || 'unknown'))"></span>
|
||||
</span>
|
||||
<button class="fed-revoke-btn"
|
||||
x-show="!device.is_self"
|
||||
@click="$store.devices.revokeFedDevice(device)">Revoke</button>
|
||||
</div>
|
||||
</template>
|
||||
<div class="fed-device-actions">
|
||||
<button class="fed-action-btn primary"
|
||||
:disabled="$store.devices.fedBusy"
|
||||
@click="$store.devices.generateFedInvite()">Invite</button>
|
||||
<button class="fed-action-btn"
|
||||
:disabled="$store.devices.fedBusy"
|
||||
@click="$store.devices.syncFedDevices()">Sync</button>
|
||||
</div>
|
||||
<template x-if="$store.devices.fedInvite">
|
||||
<input class="fed-device-input"
|
||||
readonly
|
||||
:value="$store.devices.fedInvite"
|
||||
@focus="$event.target.select()">
|
||||
</template>
|
||||
<div class="fed-connect-row">
|
||||
<input class="fed-device-input"
|
||||
type="text"
|
||||
placeholder="Paste frid:// invite"
|
||||
x-model="$store.devices.fedInviteInput"
|
||||
@keydown.enter.prevent="$store.devices.connectFedInvite()">
|
||||
<button class="fed-action-btn"
|
||||
:disabled="$store.devices.fedBusy || !$store.devices.fedInviteInput.trim()"
|
||||
@click="$store.devices.connectFedInvite()">Connect</button>
|
||||
</div>
|
||||
</div>
|
||||
<template x-if="$store.devices.jams.length > 0">
|
||||
<div class="device-section-label jam-section-label">Jams</div>
|
||||
</template>
|
||||
|
||||
@@ -1856,9 +1856,9 @@ button.user-stat:hover {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
bottom: 38px;
|
||||
width: 260px;
|
||||
width: 320px;
|
||||
max-width: calc(100vw - 24px);
|
||||
max-height: min(320px, calc(100dvh - var(--player-bar-space) - 24px));
|
||||
max-height: min(440px, calc(100dvh - var(--player-bar-space) - 24px));
|
||||
overflow-y: auto;
|
||||
padding: 6px;
|
||||
border: 1px solid var(--border-color);
|
||||
@@ -1955,6 +1955,142 @@ button.user-stat:hover {
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.fed-section-label {
|
||||
margin-top: 4px;
|
||||
color: #b8d6ff;
|
||||
}
|
||||
|
||||
.fed-device-panel {
|
||||
margin: 2px 2px 6px;
|
||||
padding: 8px;
|
||||
border: 1px solid rgba(82,145,255,0.18);
|
||||
border-radius: 6px;
|
||||
background: rgba(82,145,255,0.045);
|
||||
display: grid;
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
.fed-device-status,
|
||||
.fed-device-error,
|
||||
.fed-pairing-note {
|
||||
color: var(--text-subdued);
|
||||
font-size: 11px;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.fed-device-error {
|
||||
color: #ffb2b2;
|
||||
}
|
||||
|
||||
.fed-device-row {
|
||||
min-height: 32px;
|
||||
display: grid;
|
||||
grid-template-columns: 9px minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.fed-device-dot {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 999px;
|
||||
background: #73d795;
|
||||
}
|
||||
|
||||
.fed-device-dot.self {
|
||||
background: #ffd166;
|
||||
}
|
||||
|
||||
.fed-device-main {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: 1px;
|
||||
}
|
||||
|
||||
.fed-device-name,
|
||||
.fed-pairing-title {
|
||||
color: var(--text-primary);
|
||||
font-size: 12px;
|
||||
font-weight: 750;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.fed-device-meta,
|
||||
.fed-pairing-meta {
|
||||
color: var(--text-subdued);
|
||||
font-size: 11px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.fed-pairing-card {
|
||||
padding: 7px;
|
||||
border-radius: 5px;
|
||||
background: rgba(255,255,255,0.04);
|
||||
display: grid;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.fed-device-actions,
|
||||
.fed-connect-row {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.fed-connect-row .fed-device-input {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.fed-action-btn,
|
||||
.fed-revoke-btn {
|
||||
height: 28px;
|
||||
border: 0;
|
||||
border-radius: 4px;
|
||||
background: rgba(255,255,255,0.08);
|
||||
color: var(--text-secondary);
|
||||
padding: 0 8px;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.fed-action-btn:hover,
|
||||
.fed-revoke-btn:hover {
|
||||
background: rgba(255,255,255,0.13);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.fed-action-btn.primary {
|
||||
background: rgba(82,145,255,0.16);
|
||||
color: #c9dcff;
|
||||
}
|
||||
|
||||
.fed-action-btn:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.fed-revoke-btn {
|
||||
background: rgba(255,96,96,0.1);
|
||||
color: #ffb2b2;
|
||||
}
|
||||
|
||||
.fed-device-input {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
height: 30px;
|
||||
border: 1px solid rgba(82,145,255,0.2);
|
||||
border-radius: 4px;
|
||||
background: rgba(0,0,0,0.18);
|
||||
color: var(--text-primary);
|
||||
padding: 0 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.jam-section-label,
|
||||
.jam-row,
|
||||
.start-jam-row,
|
||||
|
||||
Reference in New Issue
Block a user