diff --git a/src/components/SendspinPlayer.vue b/src/components/SendspinPlayer.vue index 4b3b33974..ed846a926 100644 --- a/src/components/SendspinPlayer.vue +++ b/src/components/SendspinPlayer.vue @@ -347,7 +347,10 @@ onMounted(() => { if (!shouldBePlaying || !audioRef.value) return; audioRef.value.play().catch((error) => { - console.warn("Sendspin: Failed to recover audio element playback:", error); + console.warn( + "Sendspin: Failed to recover audio element playback:", + error, + ); }); }); } diff --git a/src/components/party-mode/PartyModeQR.vue b/src/components/party-mode/PartyModeQR.vue index 166a658f3..86f95488a 100644 --- a/src/components/party-mode/PartyModeQR.vue +++ b/src/components/party-mode/PartyModeQR.vue @@ -4,19 +4,22 @@
- -

{{ $t("providers.party_mode.guest_access_disabled") }}

+ +

+ {{ $t("providers.party_mode.guest_access_disabled") }} +

{{ $t("providers.party_mode.enable_in_settings") }}

- +

{{ instructionText }}

@@ -33,9 +36,10 @@ import { Spinner } from "@/components/ui/spinner"; import { usePartyModeConfig } from "@/composables/usePartyModeConfig"; import api from "@/plugins/api"; -import { EventType, RemoteAccessInfo } from "@/plugins/api/interfaces"; +import { EventType } from "@/plugins/api/interfaces"; import { $t } from "@/plugins/i18n"; -import { AlertCircle, QrCode } from "lucide-vue-next"; +import { copyToClipboard } from "@/helpers/utils"; +import { AlertCircle, Check, QrCode } from "lucide-vue-next"; import QRCode from "qrcode"; import { onMounted, onUnmounted, ref, watch } from "vue"; @@ -45,8 +49,8 @@ const qrCodeUrl = ref(""); const guestAccessEnabled = ref(false); const loading = ref(true); const qrSize = ref(320); +const copyFeedback = ref(""); const instructionText = ref($t("providers.party_mode.scan_to_join")); -const lastRemoteAccessEnabled = ref(null); const { config: partyConfig, fetchConfig: fetchPartyConfig } = usePartyModeConfig(); let unsubscribe: (() => void) | null = null; @@ -62,26 +66,6 @@ const calculateQRSize = () => { return Math.max(160, Math.min(1024, availableSize)); }; -const checkRemoteAccessStatus = async () => { - try { - const info = (await api.sendCommand( - "remote_access/info", - )) as RemoteAccessInfo; - const currentEnabled = info.enabled; - - // If remote access status changed, regenerate QR code - if ( - lastRemoteAccessEnabled.value !== null && - lastRemoteAccessEnabled.value !== currentEnabled - ) { - await generateQRCode(); - } - lastRemoteAccessEnabled.value = currentEnabled; - } catch { - // Ignore errors - remote_access/info may not be available - } -}; - const fetchQrConfig = async () => { const config = await fetchPartyConfig(); if (config) { @@ -96,6 +80,17 @@ const fetchQrConfig = async () => { } }; +const copyUrlToClipboard = async () => { + if (!qrCodeUrl.value) return; + const success = await copyToClipboard(qrCodeUrl.value); + copyFeedback.value = success + ? $t("providers.party_mode.link_copy_success") + : $t("providers.party_mode.link_copy_fail"); + setTimeout(() => { + copyFeedback.value = ""; + }, 2000); +}; + const renderQRToCanvas = async () => { if (!qrCanvas.value || !qrCodeUrl.value) return; qrSize.value = calculateQRSize(); @@ -159,9 +154,6 @@ onMounted(async () => { await fetchQrConfig(); await generateQRCode(); - // Initialize remote access status tracking - await checkRemoteAccessStatus(); - // Set up ResizeObserver to regenerate QR code when container size changes if (qrContainer.value) { resizeObserver = new ResizeObserver(() => { @@ -175,21 +167,41 @@ onMounted(async () => { resizeObserver.observe(qrContainer.value); } - // Subscribe to PROVIDERS_UPDATED to detect when party_mode or remote_access - // provider is reloaded. Config refresh is handled by the composable automatically. - unsubscribe = api.subscribe(EventType.PROVIDERS_UPDATED, async () => { - await checkRemoteAccessStatus(); + // Subscribe to PROVIDERS_UPDATED to detect when party_mode provider is + // loaded/unloaded. Config refresh is handled by the composable automatically. + const unsubProviders = api.subscribe( + EventType.PROVIDERS_UPDATED, + async () => { + const hasPartyMode = Object.values(api.providers).some( + (p) => p.domain === "party_mode", + ); + if (hasPartyMode) { + await generateQRCode(); + } else { + guestAccessEnabled.value = false; + qrCodeUrl.value = ""; + } + }, + ); + + // Subscribe to CORE_STATE_UPDATED to detect when remote access is toggled, + // which changes the party mode join URL between local and remote. + const unsubCoreState = api.subscribe( + EventType.CORE_STATE_UPDATED, + async () => { + const hasPartyMode = Object.values(api.providers).some( + (p) => p.domain === "party_mode", + ); + if (hasPartyMode) { + await generateQRCode(); + } + }, + ); - const hasPartyMode = Object.values(api.providers).some( - (p) => p.domain === "party_mode", - ); - if (hasPartyMode) { - await generateQRCode(); - } else { - guestAccessEnabled.value = false; - qrCodeUrl.value = ""; - } - }); + unsubscribe = () => { + unsubProviders(); + unsubCoreState(); + }; }); onUnmounted(() => { @@ -222,6 +234,7 @@ onUnmounted(() => { } .qr-link { + position: relative; display: block; cursor: pointer; transition: @@ -234,6 +247,43 @@ onUnmounted(() => { opacity: 0.9; } +.copy-bubble { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + display: flex; + align-items: center; + gap: 6px; + padding: 8px 16px; + background: rgba(var(--v-theme-surface), 0.9); + color: rgb(var(--v-theme-success)); + font-size: 0.9rem; + font-weight: 600; + border-radius: 8px; + white-space: nowrap; + pointer-events: none; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4); +} + +.copy-toast-enter-active { + transition: all 0.2s ease-out; +} + +.copy-toast-leave-active { + transition: all 0.3s ease-in; +} + +.copy-toast-enter-from { + opacity: 0; + transform: translate(-50%, -50%) scale(0.8); +} + +.copy-toast-leave-to { + opacity: 0; + transform: translate(-50%, -50%) scale(0.8); +} + .qr-display canvas { display: block; border-radius: 8px; @@ -249,13 +299,32 @@ onUnmounted(() => { } .qr-disabled { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; text-align: center; + padding: 3rem; +} + +.qr-disabled-icon { + width: clamp(64px, 10vw, 120px); + height: clamp(64px, 10vw, 120px); opacity: 0.5; + margin-bottom: 1.5rem; } -.qr-disabled p { - margin-top: 0.5rem; - color: rgba(255, 255, 255, 0.5); +.qr-disabled-title { + font-size: 2rem; + font-weight: 600; + margin-bottom: 1rem; + color: rgba(255, 255, 255, 0.9); +} + +.qr-disabled .qr-hint { + font-size: 1.25rem; + color: rgba(255, 255, 255, 0.7); + max-width: 500px; } .qr-error { diff --git a/src/components/party-mode/PartyModeQueueItem.vue b/src/components/party-mode/PartyModeQueueItem.vue index 0d174464e..bdd379c36 100644 --- a/src/components/party-mode/PartyModeQueueItem.vue +++ b/src/components/party-mode/PartyModeQueueItem.vue @@ -4,42 +4,62 @@ :class="{ 'queue-item-current': absoluteIndex === currentQueueIndex, 'queue-item-played': absoluteIndex < currentQueueIndex, + 'queue-item--expanded': isExpanded, }" + @click="onItemClick" > -
- - {{ absoluteIndex + 1 }} -
- - - - - -
- - {{ title }} - - - {{ subtitle }} - +
+
+ + {{ absoluteIndex + 1 }} +
+ + + + + +
+ + {{ title }} + + + {{ subtitle }} + +
+ + + + {{ badgeIcon }} + {{ badgeLabel }} +
- - - {{ badgeIcon }} - {{ badgeLabel }} - + +
+ + mdi-rocket-launch + {{ $t("providers.party_mode.boost") }} + +
@@ -59,8 +79,32 @@ const props = defineProps<{ isPlaying: boolean; boostBadgeColor: string; requestBadgeColor: string; + boostEnabled: boolean; + rateLimitingEnabled: boolean; + boostTokens: number; + boosting: boolean; + isExpanded: boolean; +}>(); + +const emit = defineEmits<{ + boost: [item: QueueItem]; + toggleExpand: [queueItemId: string]; }>(); +const canBoost = computed( + () => props.boostEnabled && absoluteIndex.value > props.currentQueueIndex, +); + +const boostDisabled = computed( + () => props.rateLimitingEnabled && props.boostTokens <= 0, +); + +const onItemClick = () => { + if (absoluteIndex.value > props.currentQueueIndex) { + emit("toggleExpand", props.item.queue_item_id); + } +}; + const absoluteIndex = computed(() => props.queueFetchOffset + props.index); const imageUrl = computed(() => { @@ -111,13 +155,38 @@ const badgeLabel = computed(() => diff --git a/src/translations/en.json b/src/translations/en.json index 353763dbe..aa8121dd2 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -288,12 +288,16 @@ "add": "Add", "boost": "Boost", "check_network": "Check your network settings", - "enable_in_settings": "Enable in party mode plugin settings", + "enable_in_settings": "Enable in party mode provider settings", "get_started": "Play some music to get the party started!", "guest_access_disabled": "Guest Access Disabled", + "no_player_access": "Player Not Accessible", + "no_player_access_detail": "You do not have access to the configured player. Please contact the admin.", "nothing_playing": "Nothing Playing Right Now", "qr_failed": "Failed to generate QR code", "request": "Request", + "link_copy_fail": "Failed to copy link", + "link_copy_success": "Link copied!", "scan_to_join": "Scan to join!" } }, diff --git a/src/views/Login.vue b/src/views/Login.vue index e111329c3..727f7776d 100644 --- a/src/views/Login.vue +++ b/src/views/Login.vue @@ -858,16 +858,25 @@ const autoConnect = async () => { } } - // If remote_id is in URL, pre-fill and auto-connect (when in remote-only mode) + // If remote_id is in URL, pre-fill and auto-connect (when in remote-only mode). + // This also serves as a fallback when both remote_id and join are present but + // the combined handler above didn't succeed (e.g., connection error). let urlRemoteIdForAutoConnect: string | null = null; - if (urlRemoteId && !urlJoinCode) { + if (urlRemoteId) { console.debug("[Login] Found remote_id in URL:", urlRemoteId); const cleanRemoteId = urlRemoteId.toUpperCase().replace(/[^A-Z0-9]/g, ""); if (cleanRemoteId.length === 26) { setRemoteIdFromString(cleanRemoteId); urlRemoteIdForAutoConnect = cleanRemoteId; + // Store the join code in sessionStorage so the remote-only auto-connect + // path can pick it up after establishing the WebRTC connection. + if (urlJoinCode) { + sessionStorage.setItem(SESSION_KEY_PENDING_JOIN_CODE, urlJoinCode); + localStorage.setItem(STORAGE_KEY_REMOTE_ID, cleanRemoteId); + } // Clean up the URL urlParams.delete("remote_id"); + urlParams.delete("join"); const queryString = urlParams.toString(); const newUrl = window.location.origin + diff --git a/src/views/PartyModeDashboardView.vue b/src/views/PartyModeDashboardView.vue index 9eaa18ae2..73cecfd21 100644 --- a/src/views/PartyModeDashboardView.vue +++ b/src/views/PartyModeDashboardView.vue @@ -9,7 +9,20 @@ class="background-image" :style="{ backgroundImage: `url(${albumArtUrl})` }" >
-
+ +
+
+ mdi-lock-alert +

+ {{ $t("providers.party_mode.no_player_access") }} +

+

+ {{ $t("providers.party_mode.no_player_access_detail") }} +

+
+
+ +
@@ -76,8 +89,22 @@ const theme = useTheme(); const route = useRoute(); const { config: partyConfig, fetchConfig } = usePartyModeConfig(); +const refreshPartyPlayer = async () => { + const partyPlayerId = await api.sendCommand( + "party_mode/player", + ); + accessError.value = ""; + if (partyPlayerId) { + store.activePlayerId = partyPlayerId; + if (!api.players[partyPlayerId]) { + accessError.value = "no_access"; + } + } +}; + const albumArtBackgroundEnabled = ref(true); // Default to true const showPlayerControls = ref(false); // Whether footer player controls are shown +const accessError = ref(""); // Badge colors (hex values from config) const requestBadgeColor = ref(""); const boostBadgeColor = ref(""); @@ -352,14 +379,7 @@ onMounted(async () => { // Set active player from party mode config (needed when opened in a new tab // where the Default layout's player selection logic doesn't run) - if (!store.activePlayerId) { - const partyPlayerId = await api.sendCommand( - "party_mode/player", - ); - if (partyPlayerId) { - store.activePlayerId = partyPlayerId; - } - } + await refreshPartyPlayer(); // Fetch party mode configuration via shared composable const config = await fetchConfig(); @@ -398,7 +418,13 @@ onMounted(async () => { fetchQueueItems(); }); - unsubscribeFunctions.value = [unsub1, unsub2]; + // Subscribe to provider updates to detect party mode player config changes + const unsub3 = api.subscribe(EventType.PROVIDERS_UPDATED, async () => { + await refreshPartyPlayer(); + fetchQueueItems(true); + }); + + unsubscribeFunctions.value = [unsub1, unsub2, unsub3]; }); // Cleanup when leaving the party view @@ -557,6 +583,32 @@ watch( opacity: 0; } +/* Access error state */ +.access-error-content { + justify-content: center; +} + +.access-error { + display: flex; + flex-direction: column; + align-items: center; + text-align: center; + gap: 1rem; + padding: 2rem; +} + +.access-error-title { + font-size: 1.5rem; + font-weight: 600; + color: rgba(255, 255, 255, 0.9); +} + +.access-error-message { + font-size: 1rem; + color: rgba(255, 255, 255, 0.7); + max-width: 400px; +} + /* Responsive adjustments */ @media (max-width: 1024px) { .party-content { diff --git a/src/views/PartyModeGuestView.vue b/src/views/PartyModeGuestView.vue index 860e4bc59..9f3fe6360 100644 --- a/src/views/PartyModeGuestView.vue +++ b/src/views/PartyModeGuestView.vue @@ -8,6 +8,7 @@ :show-back="hasSearched || !!selectedArtist" @clear="clearSearch" @back="goBack" + @submit="performSearch" /> @@ -65,7 +66,11 @@ :boost-badge-color="boostBadgeColor" :request-badge-color="requestBadgeColor" :adding-items="addingItems" + :is-expanded=" + expandedResultItemId === `${track.media_type}-${track.item_id}` + " @add-to-queue="addToQueue" + @toggle-expand="toggleExpandedResult" />
@@ -119,8 +124,12 @@ :boost-badge-color="boostBadgeColor" :request-badge-color="requestBadgeColor" :adding-items="addingItems" + :is-expanded=" + expandedResultItemId === `${item.media_type}-${item.item_id}` + " @add-to-queue="addToQueue" @select-artist="selectArtist" + @toggle-expand="toggleExpandedResult" />
@@ -162,8 +171,12 @@ :skip-token-countdown="skipTokenCountdown" :boost-badge-color="boostBadgeColor" :request-badge-color="requestBadgeColor" + :boost-enabled="boostEnabled" + :boost-tokens="boostTokens" + :boosting-item-id="boostingQueueItemId" @skip="skipCurrentSong" @queue-scroll="handleQueueScroll" + @boost-queue-item="boostQueueItem" /> @@ -174,7 +187,9 @@ import api from "@/plugins/api"; import { store } from "@/plugins/store"; import { type Artist, + EventType, PlaybackState, + type QueueItem, type Track, } from "@/plugins/api/interfaces"; import { $t } from "@/plugins/i18n"; @@ -229,26 +244,6 @@ const isPlaying = computed( () => store.activePlayer?.playback_state === PlaybackState.PLAYING, ); -watch(isPlaying, (val) => { - console.debug("[GuestView] isPlaying changed:", val); - console.debug("[GuestView] activePlayer:", store.activePlayer?.player_id); - console.debug( - "[GuestView] playback_state:", - store.activePlayer?.playback_state, - ); -}); - -watch( - () => store.activePlayer, - (player) => { - console.debug( - "[GuestView] activePlayer changed:", - player?.player_id, - player?.playback_state, - ); - }, -); - const search = useGuestSearch(); const { searchQuery, @@ -261,6 +256,7 @@ const { loadingArtistTracks, displayedResults, resultsListRef, + performSearch, clearSearch, selectArtist, clearArtistSelection, @@ -270,6 +266,13 @@ const { // --- Template-specific state --- const addingItems = ref(new Set()); const skippingSong = ref(false); +const boostingQueueItemId = ref(""); +const expandedResultItemId = ref(""); + +const toggleExpandedResult = (itemId: string) => { + expandedResultItemId.value = + expandedResultItemId.value === itemId ? "" : itemId; +}; const queueSectionRef = ref | null>( null, ); @@ -316,24 +319,19 @@ const addToQueue = async (item: Track | Artist, position: "next" | "end") => { } if (rateLimitingEnabled.value) { - if (position === "next") { - if (!consumeBoostToken()) { - const minutesUntilNext = getTimeUntilNextToken(); - toast.warning( - $t("providers.party_mode.boost_limit_reached", [minutesUntilNext]), - ); - return; - } - } else { - if (!consumeAddQueueToken()) { - const minutesUntilNext = getTimeUntilNextAddQueueToken(); - toast.warning( - $t("providers.party_mode.add_queue_limit_reached", [ - minutesUntilNext, - ]), - ); - return; - } + if (position === "next" && boostTokens.value <= 0) { + const minutesUntilNext = getTimeUntilNextToken(); + toast.warning( + $t("providers.party_mode.boost_limit_reached", [minutesUntilNext]), + ); + return; + } + if (position === "end" && addQueueTokens.value <= 0) { + const minutesUntilNext = getTimeUntilNextAddQueueToken(); + toast.warning( + $t("providers.party_mode.add_queue_limit_reached", [minutesUntilNext]), + ); + return; } } @@ -350,6 +348,14 @@ const addToQueue = async (item: Track | Artist, position: "next" | "end") => { throw new Error("Server rejected the request"); } + if (rateLimitingEnabled.value) { + if (position === "next") { + consumeBoostToken(); + } else { + consumeAddQueueToken(); + } + } + const message = position === "next" ? $t("providers.party_mode.guest_page.item_boosted", [item.name]) @@ -365,22 +371,58 @@ const addToQueue = async (item: Track | Artist, position: "next" | "end") => { } }; +const boostQueueItem = async (item: QueueItem) => { + if (!boostEnabled.value) { + toast.warning($t("providers.party_mode.boost_disabled")); + return; + } + + if (rateLimitingEnabled.value && boostTokens.value <= 0) { + const minutesUntilNext = getTimeUntilNextToken(); + toast.warning( + $t("providers.party_mode.boost_limit_reached", [minutesUntilNext]), + ); + return; + } + + boostingQueueItemId.value = item.queue_item_id; + try { + const result = (await api.sendCommand("party_mode/boost_queue_item", { + queue_item_id: item.queue_item_id, + })) as { success: boolean }; + + if (!result.success) { + throw new Error("Server rejected the request"); + } + + if (rateLimitingEnabled.value) { + consumeBoostToken(); + } + + const name = item.media_item?.name || item.name; + toast.success($t("providers.party_mode.guest_page.item_boosted", [name])); + } catch (error) { + console.error("Failed to boost queue item:", error); + toast.error($t("providers.party_mode.add_to_queue_failed")); + } finally { + boostingQueueItemId.value = ""; + } +}; + const skipCurrentSong = async () => { if (!skipSongEnabled.value) { toast.warning($t("providers.party_mode.guest_page.skip_disabled")); return; } - if (rateLimitingEnabled.value) { - if (!consumeSkipSongToken()) { - const minutesUntilNext = getTimeUntilNextSkipToken(); - toast.warning( - $t("providers.party_mode.guest_page.skip_limit_reached", [ - minutesUntilNext, - ]), - ); - return; - } + if (rateLimitingEnabled.value && skipSongTokens.value <= 0) { + const minutesUntilNext = getTimeUntilNextSkipToken(); + toast.warning( + $t("providers.party_mode.guest_page.skip_limit_reached", [ + minutesUntilNext, + ]), + ); + return; } skippingSong.value = true; @@ -393,6 +435,10 @@ const skipCurrentSong = async () => { throw new Error("Server rejected the request"); } + if (rateLimitingEnabled.value) { + consumeSkipSongToken(); + } + toast.success($t("providers.party_mode.guest_page.song_skipped")); } catch (error) { console.error("Failed to skip song:", error); @@ -412,6 +458,17 @@ watch(partyConfig, (newConfig) => { // --- Lifecycle --- let cleanupCountdown: (() => void) | null = null; let cleanupQueueEvents: (() => void) | null = null; +let cleanupProvidersSub: (() => void) | null = null; + +const refreshPartyPlayer = async () => { + const partyPlayerId = await api.sendCommand( + "party_mode/player", + ); + partyModeQueueId.value = partyPlayerId; + if (partyPlayerId) { + store.activePlayerId = partyPlayerId; + } +}; const fetchAndApplyConfig = async () => { const config = await fetchConfig(); @@ -429,31 +486,30 @@ onMounted(async () => { cleanupCountdown = rateLimit.startCountdown(); try { - const partyPlayerId = await api.sendCommand( - "party_mode/player", - ); - partyModeQueueId.value = partyPlayerId; - if (partyPlayerId && !store.activePlayerId) { - store.activePlayerId = partyPlayerId; - } - console.debug("[GuestView] partyPlayerId:", partyPlayerId); - console.debug("[GuestView] store.activePlayerId:", store.activePlayerId); - console.debug("[GuestView] store.activePlayer:", store.activePlayer); - console.debug("[GuestView] api.players keys:", Object.keys(api.players)); - console.debug("[GuestView] api.queues keys:", Object.keys(api.queues)); - console.debug("[GuestView] queue state:", queue.currentQueue.value?.state); + await refreshPartyPlayer(); } catch (error) { console.error("Failed to fetch party mode player:", error); } fetchQueueItems(); cleanupQueueEvents = queue.subscribeToEvents(); + + // Re-fetch player when party mode config changes + const unsubProviders = api.subscribe( + EventType.PROVIDERS_UPDATED, + async () => { + await refreshPartyPlayer(); + fetchQueueItems(true); + }, + ); + cleanupProvidersSub = unsubProviders; }); onBeforeUnmount(() => { window.removeEventListener("popstate", handleBack); cleanupQueueEvents?.(); cleanupCountdown?.(); + cleanupProvidersSub?.(); search.cleanup(); });