44 <Spinner class =" size-12" />
55 </div >
66 <div v-else-if =" !guestAccessEnabled" class =" qr-disabled" >
7- <QrCode :size =" 64" />
8- <p >{{ $t("providers.party_mode.guest_access_disabled") }}</p >
7+ <QrCode class =" qr-disabled-icon" />
8+ <p class =" qr-disabled-title" >
9+ {{ $t("providers.party_mode.guest_access_disabled") }}
10+ </p >
911 <p class =" qr-hint" >{{ $t("providers.party_mode.enable_in_settings") }}</p >
1012 </div >
1113 <div v-else-if =" qrCodeUrl" class =" qr-display" >
12- <a
13- :href =" qrCodeUrl"
14- target =" _blank"
15- rel =" noopener noreferrer"
16- class =" qr-link"
17- >
14+ <div class =" qr-link" @click =" copyUrlToClipboard" >
1815 <canvas ref =" qrCanvas" ></canvas >
19- </a >
16+ <Transition name =" copy-toast" >
17+ <div v-if =" copyFeedback" class =" copy-bubble" >
18+ <Check :size =" 16" />
19+ {{ copyFeedback }}
20+ </div >
21+ </Transition >
22+ </div >
2023 <p v-if =" instructionText" class =" qr-instructions text-h4" >
2124 {{ instructionText }}
2225 </p >
3336import { Spinner } from " @/components/ui/spinner" ;
3437import { usePartyModeConfig } from " @/composables/usePartyModeConfig" ;
3538import api from " @/plugins/api" ;
36- import { EventType , RemoteAccessInfo } from " @/plugins/api/interfaces" ;
39+ import { EventType } from " @/plugins/api/interfaces" ;
3740import { $t } from " @/plugins/i18n" ;
38- import { AlertCircle , QrCode } from " lucide-vue-next" ;
41+ import { copyToClipboard } from " @/helpers/utils" ;
42+ import { AlertCircle , Check , QrCode } from " lucide-vue-next" ;
3943import QRCode from " qrcode" ;
4044import { onMounted , onUnmounted , ref , watch } from " vue" ;
4145
@@ -45,8 +49,8 @@ const qrCodeUrl = ref<string>("");
4549const guestAccessEnabled = ref <boolean >(false );
4650const loading = ref (true );
4751const qrSize = ref (320 );
52+ const copyFeedback = ref <string >(" " );
4853const instructionText = ref ($t (" providers.party_mode.scan_to_join" ));
49- const lastRemoteAccessEnabled = ref <boolean | null >(null );
5054const { config : partyConfig, fetchConfig : fetchPartyConfig } =
5155 usePartyModeConfig ();
5256let unsubscribe: (() => void ) | null = null ;
@@ -62,26 +66,6 @@ const calculateQRSize = () => {
6266 return Math .max (160 , Math .min (1024 , availableSize ));
6367};
6468
65- const checkRemoteAccessStatus = async () => {
66- try {
67- const info = (await api .sendCommand (
68- " remote_access/info" ,
69- )) as RemoteAccessInfo ;
70- const currentEnabled = info .enabled ;
71-
72- // If remote access status changed, regenerate QR code
73- if (
74- lastRemoteAccessEnabled .value !== null &&
75- lastRemoteAccessEnabled .value !== currentEnabled
76- ) {
77- await generateQRCode ();
78- }
79- lastRemoteAccessEnabled .value = currentEnabled ;
80- } catch {
81- // Ignore errors - remote_access/info may not be available
82- }
83- };
84-
8569const fetchQrConfig = async () => {
8670 const config = await fetchPartyConfig ();
8771 if (config ) {
@@ -96,6 +80,17 @@ const fetchQrConfig = async () => {
9680 }
9781};
9882
83+ const copyUrlToClipboard = async () => {
84+ if (! qrCodeUrl .value ) return ;
85+ const success = await copyToClipboard (qrCodeUrl .value );
86+ copyFeedback .value = success
87+ ? $t (" providers.party_mode.link_copy_success" )
88+ : $t (" providers.party_mode.link_copy_fail" );
89+ setTimeout (() => {
90+ copyFeedback .value = " " ;
91+ }, 2000 );
92+ };
93+
9994const renderQRToCanvas = async () => {
10095 if (! qrCanvas .value || ! qrCodeUrl .value ) return ;
10196 qrSize .value = calculateQRSize ();
@@ -159,9 +154,6 @@ onMounted(async () => {
159154 await fetchQrConfig ();
160155 await generateQRCode ();
161156
162- // Initialize remote access status tracking
163- await checkRemoteAccessStatus ();
164-
165157 // Set up ResizeObserver to regenerate QR code when container size changes
166158 if (qrContainer .value ) {
167159 resizeObserver = new ResizeObserver (() => {
@@ -175,21 +167,41 @@ onMounted(async () => {
175167 resizeObserver .observe (qrContainer .value );
176168 }
177169
178- // Subscribe to PROVIDERS_UPDATED to detect when party_mode or remote_access
179- // provider is reloaded. Config refresh is handled by the composable automatically.
180- unsubscribe = api .subscribe (EventType .PROVIDERS_UPDATED , async () => {
181- await checkRemoteAccessStatus ();
170+ // Subscribe to PROVIDERS_UPDATED to detect when party_mode provider is
171+ // loaded/unloaded. Config refresh is handled by the composable automatically.
172+ const unsubProviders = api .subscribe (
173+ EventType .PROVIDERS_UPDATED ,
174+ async () => {
175+ const hasPartyMode = Object .values (api .providers ).some (
176+ (p ) => p .domain === " party_mode" ,
177+ );
178+ if (hasPartyMode ) {
179+ await generateQRCode ();
180+ } else {
181+ guestAccessEnabled .value = false ;
182+ qrCodeUrl .value = " " ;
183+ }
184+ },
185+ );
186+
187+ // Subscribe to CORE_STATE_UPDATED to detect when remote access is toggled,
188+ // which changes the party mode join URL between local and remote.
189+ const unsubCoreState = api .subscribe (
190+ EventType .CORE_STATE_UPDATED ,
191+ async () => {
192+ const hasPartyMode = Object .values (api .providers ).some (
193+ (p ) => p .domain === " party_mode" ,
194+ );
195+ if (hasPartyMode ) {
196+ await generateQRCode ();
197+ }
198+ },
199+ );
182200
183- const hasPartyMode = Object .values (api .providers ).some (
184- (p ) => p .domain === " party_mode" ,
185- );
186- if (hasPartyMode ) {
187- await generateQRCode ();
188- } else {
189- guestAccessEnabled .value = false ;
190- qrCodeUrl .value = " " ;
191- }
192- });
201+ unsubscribe = () => {
202+ unsubProviders ();
203+ unsubCoreState ();
204+ };
193205});
194206
195207onUnmounted (() => {
@@ -222,6 +234,7 @@ onUnmounted(() => {
222234}
223235
224236.qr-link {
237+ position : relative ;
225238 display : block ;
226239 cursor : pointer ;
227240 transition :
@@ -234,6 +247,43 @@ onUnmounted(() => {
234247 opacity : 0.9 ;
235248}
236249
250+ .copy-bubble {
251+ position : absolute ;
252+ top : 50% ;
253+ left : 50% ;
254+ transform : translate (-50% , -50% );
255+ display : flex ;
256+ align-items : center ;
257+ gap : 6px ;
258+ padding : 8px 16px ;
259+ background : rgba (var (--v-theme-surface ), 0.9 );
260+ color : rgb (var (--v-theme-success ));
261+ font-size : 0.9rem ;
262+ font-weight : 600 ;
263+ border-radius : 8px ;
264+ white-space : nowrap ;
265+ pointer-events : none ;
266+ box-shadow : 0 4px 12px rgba (0 , 0 , 0 , 0.4 );
267+ }
268+
269+ .copy-toast-enter-active {
270+ transition : all 0.2s ease-out ;
271+ }
272+
273+ .copy-toast-leave-active {
274+ transition : all 0.3s ease-in ;
275+ }
276+
277+ .copy-toast-enter-from {
278+ opacity : 0 ;
279+ transform : translate (-50% , -50% ) scale (0.8 );
280+ }
281+
282+ .copy-toast-leave-to {
283+ opacity : 0 ;
284+ transform : translate (-50% , -50% ) scale (0.8 );
285+ }
286+
237287.qr-display canvas {
238288 display : block ;
239289 border-radius : 8px ;
@@ -249,13 +299,32 @@ onUnmounted(() => {
249299}
250300
251301.qr-disabled {
302+ display : flex ;
303+ flex-direction : column ;
304+ align-items : center ;
305+ justify-content : center ;
252306 text-align : center ;
307+ padding : 3rem ;
308+ }
309+
310+ .qr-disabled-icon {
311+ width : clamp (64px , 10vw , 120px );
312+ height : clamp (64px , 10vw , 120px );
253313 opacity : 0.5 ;
314+ margin-bottom : 1.5rem ;
254315}
255316
256- .qr-disabled p {
257- margin-top : 0.5rem ;
258- color : rgba (255 , 255 , 255 , 0.5 );
317+ .qr-disabled-title {
318+ font-size : 2rem ;
319+ font-weight : 600 ;
320+ margin-bottom : 1rem ;
321+ color : rgba (255 , 255 , 255 , 0.9 );
322+ }
323+
324+ .qr-disabled .qr-hint {
325+ font-size : 1.25rem ;
326+ color : rgba (255 , 255 , 255 , 0.7 );
327+ max-width : 500px ;
259328}
260329
261330.qr-error {
0 commit comments