From 815f55d6b967543664fce5c763645b995c4d7d96 Mon Sep 17 00:00:00 2001
From: Chris Alves <25260078+chrisae9@users.noreply.github.com>
Date: Sun, 11 Oct 2026 22:47:47 -0400
Subject: [PATCH 1/4] fix: improve watch-party controls and recovery feedback
---
package-lock.json | 4 +-
package.json | 2 +-
src/__tests__/desktop-ui-controls.test.js | 14 +-
src/__tests__/participant-health.test.js | 7 +
src/__tests__/participant-moderation.test.js | 109 +++++++++
src/__tests__/roomjoin-stale-url.test.js | 2 +
src/__tests__/usability-recovery.test.js | 230 +++++++++++++++++++
src/components/TheSettingsDialog.vue | 14 ++
src/components/TheSidebarRight.vue | 11 +-
src/components/TheUpnextDialog.vue | 23 +-
src/components/UserList.vue | 151 +++++++++---
src/views/AdvancedRoomJoin.vue | 67 +++++-
src/views/PlexHome.vue | 78 ++++++-
src/views/PlexLibrary.vue | 58 ++++-
src/views/PlexSearch.vue | 103 +++++++--
test/fixtures/ui-preview.js | 71 +++++-
test/fixtures/ui-preview.vue | 34 ++-
17 files changed, 891 insertions(+), 87 deletions(-)
create mode 100644 src/__tests__/participant-moderation.test.js
create mode 100644 src/__tests__/usability-recovery.test.js
diff --git a/package-lock.json b/package-lock.json
index 173a9acede..bb39857f94 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "synclounge",
- "version": "6.4.10",
+ "version": "6.4.11",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "synclounge",
- "version": "6.4.10",
+ "version": "6.4.11",
"license": "MIT",
"dependencies": {
"cors": "^2.8.5",
diff --git a/package.json b/package.json
index dfd64df450..d9724ca49a 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "synclounge",
- "version": "6.4.10",
+ "version": "6.4.11",
"description": "Synced playback of Plex content",
"author": "Chris Alves",
"bin": "./server.js",
diff --git a/src/__tests__/desktop-ui-controls.test.js b/src/__tests__/desktop-ui-controls.test.js
index 83f0914a3b..4f665833e1 100644
--- a/src/__tests__/desktop-ui-controls.test.js
+++ b/src/__tests__/desktop-ui-controls.test.js
@@ -143,7 +143,7 @@ describe('watch party opener', () => {
});
describe('up next cancellation', () => {
- it('cancels the pending automatic playback through the remaining Cancel action', async () => {
+ it.each(['cancel', 'automatic', 'play now'])('handles next-episode playback: %s', async (mode) => {
vi.useFakeTimers();
const playNext = vi.fn();
const clearNext = vi.fn();
@@ -168,11 +168,17 @@ describe('up next cancellation', () => {
try {
await wrapper.vm.$nextTick();
const buttons = new DOMWrapper(document.body).findAll('.v-bottom-sheet button');
- expect(buttons.map((button) => button.text())).toEqual(['Play Now', 'Cancel']);
- await buttons[1].trigger('click');
+ expect(buttons.map((button) => button.text())).toEqual(['Play Now', 'Don’t play next episode']);
+ expect(new DOMWrapper(document.body).text()).toContain('Next episode starts in 10 seconds');
+ await vi.advanceTimersByTimeAsync(3000);
+ expect(new DOMWrapper(document.body).text()).toContain('Next episode starts in 7 seconds');
+ if (mode === 'automatic') await vi.advanceTimersByTimeAsync(7000);
+ else await buttons[mode === 'cancel' ? 1 : 0].trigger('click');
expect(clearNext).toHaveBeenCalledExactlyOnceWith(null);
+ expect(wrapper.vm.countdownIntervalId).toBe(null);
await vi.advanceTimersByTimeAsync(10001);
- expect(playNext).not.toHaveBeenCalled();
+ if (mode === 'cancel') expect(playNext).not.toHaveBeenCalled();
+ else expect(playNext).toHaveBeenCalledTimes(1);
} finally {
wrapper.unmount();
vi.useRealTimers();
diff --git a/src/__tests__/participant-health.test.js b/src/__tests__/participant-health.test.js
index 4a751fe447..6a93f157db 100644
--- a/src/__tests__/participant-health.test.js
+++ b/src/__tests__/participant-health.test.js
@@ -36,5 +36,12 @@ describe('participant timing across Plex servers', () => {
expect(UserList.methods.driftLabel.call({ ...context, GET_HOST_USER: { media: {} } }, {
media: {},
})).toBe('Different media');
+ }); it('uses the viewer tolerance for basic status while retaining detailed drift', () => {
+ const basicContext = { ...context, driftLabel: UserList.methods.driftLabel.bind(context) };
+ const user = { media: context.GET_HOST_USER.media, state: 'playing', syncFlexibility: 3000 };
+ expect(UserList.methods.readinessLabel.call(basicContext, user, 'viewer')).toBe('In sync');
+ expect(UserList.methods.readinessLabel.call(basicContext, { ...user, syncFlexibility: 500 }, 'viewer'))
+ .toBe('Catching up');
+ expect(UserList.methods.driftLabel.call(context, user)).toBe('2.0s behind');
});
});
diff --git a/src/__tests__/participant-moderation.test.js b/src/__tests__/participant-moderation.test.js
new file mode 100644
index 0000000000..046e19e372
--- /dev/null
+++ b/src/__tests__/participant-moderation.test.js
@@ -0,0 +1,109 @@
+import {
+ describe, expect, it, vi,
+} from 'vitest';
+import { mount, flushPromises, DOMWrapper } from '@vue/test-utils';
+import { createStore } from 'vuex';
+import { createVuetify } from 'vuetify';
+import * as components from 'vuetify/components';
+import * as directives from 'vuetify/directives';
+import UserList from '@/components/UserList.vue';
+
+vi.stubGlobal('visualViewport', {
+ width: 1280, height: 800, addEventListener() {}, removeEventListener() {},
+});
+
+const media = {
+ type: 'movie', title: 'Movie', machineIdentifier: 'one', ratingKey: '1',
+};
+const participant = (username, extra = {}) => ({
+ username, state: 'playing', media, time: 10000, updatedAt: Date.now(), playbackRate: 1, duration: 60000, ...extra,
+});
+const setup = () => {
+ const kick = vi.fn().mockResolvedValue();
+ const transfer = vi.fn().mockResolvedValue();
+ const store = createStore({
+ getters: { GET_CONFIG: () => ({ sidebar_time_update_interval: 1000 }) },
+ modules: {
+ settings: { namespaced: true, getters: { GET_ADVANCED_PARTY_MODE: () => false } },
+ plexservers: { namespaced: true, getters: { GET_PLEX_SERVER: () => () => ({ name: 'Library' }) } },
+ synclounge: {
+ namespaced: true,
+ state: {
+ host: 'me',
+ users: {
+ me: participant('Host viewer'),
+ friend: participant('Morgan with a long display name'),
+ buffering: participant('Sam', { state: 'buffering' }),
+ different: participant('Taylor', { media: { ...media, title: 'Another movie', ratingKey: '2' } }),
+ stopped: participant('Alex', { state: 'stopped', media: null }),
+ },
+ },
+ getters: {
+ GET_USERS: (state) => state.users,
+ GET_HOST_ID: (state) => state.host,
+ GET_HOST_USER: (state) => state.users[state.host],
+ GET_SOCKET_ID: () => 'me',
+ AM_I_HOST: (state) => state.host === 'me',
+ GET_ADJUSTED_HOST_TIME: () => () => 10000,
+ },
+ actions: { KICK_USER: (_, id) => kick(id), TRANSFER_HOST: (_, id) => transfer(id) },
+ },
+ },
+ });
+ const wrapper = mount(UserList, {
+ attachTo: document.body, global: { plugins: [store, createVuetify({ components, directives })] },
+ });
+ return {
+ wrapper, store, kick, transfer,
+ };
+};
+const body = () => new DOMWrapper(document.body);
+const clickText = async (text) => {
+ const item = body().findAll('.v-list-item, button').find((element) => element.text() === text);
+ await item.trigger('click');
+ await flushPromises();
+};
+
+describe('participant actions and readable status', () => {
+ it('shows basic readiness and the Host badge without advanced controls', () => {
+ const { wrapper } = setup();
+ expect(wrapper.text()).toContain('Host');
+ expect(wrapper.text()).toContain('In sync');
+ expect(wrapper.text()).toContain('Buffering');
+ expect(wrapper.text()).toContain('Different movie or episode');
+ expect(wrapper.text()).toContain('Not watching');
+ expect(wrapper.find('[aria-label="Remove Morgan with a long display name from the room"]').exists()).toBe(false);
+ wrapper.unmount();
+ });
+ const moderationCases = [['Remove from room', 'kick'], ['Make host', 'transfer']];
+ it.each(moderationCases)('requires named confirmation for %s', async (label, action) => {
+ const { wrapper, kick, transfer } = setup();
+ try {
+ await wrapper.get('[aria-label="Actions for Morgan with a long display name"]').trigger('click');
+ await flushPromises();
+ await clickText(label);
+ expect(kick).not.toHaveBeenCalled();
+ expect(transfer).not.toHaveBeenCalled();
+ expect(body().findAll('.v-dialog')).toHaveLength(1);
+ expect(body().get('.v-dialog').text()).toContain('Morgan with a long display name');
+ await body().findAll('.v-dialog button').find((button) => button.text() === label).trigger('click');
+ await flushPromises();
+ expect(action === 'kick' ? kick : transfer).toHaveBeenCalledExactlyOnceWith('friend');
+ expect(action === 'kick' ? transfer : kick).not.toHaveBeenCalled();
+ } finally { wrapper.unmount(); }
+ });
+ it('cancels safely and refuses a stale confirmation after host permissions change', async () => {
+ const { wrapper, store, kick } = setup();
+ try {
+ wrapper.vm.requestModeration('friend', 'remove');
+ await flushPromises();
+ await body().findAll('.v-dialog button').find((button) => button.text() === 'Cancel').trigger('click');
+ expect(kick).not.toHaveBeenCalled();
+ expect(wrapper.vm.moderation).toBe(null);
+ wrapper.vm.requestModeration('friend', 'remove');
+ store.state.synclounge.host = 'friend';
+ await wrapper.vm.confirmModeration();
+ expect(kick).not.toHaveBeenCalled();
+ } finally { wrapper.unmount(); }
+ });
+});
diff --git a/src/__tests__/roomjoin-stale-url.test.js b/src/__tests__/roomjoin-stale-url.test.js
index 4b45563b89..a275b79b00 100644
--- a/src/__tests__/roomjoin-stale-url.test.js
+++ b/src/__tests__/roomjoin-stale-url.test.js
@@ -158,6 +158,8 @@ describe('cancelled room creation', () => {
['advanced creation', AdvancedRoomJoin.methods.connect],
])('%s does not tear down a replacement join', async (label, action) => {
const ctx = {
+ GET_CONFIG: { servers: [{ url: 'https://server.invalid' }] },
+ GET_SERVER_HEALTH: () => ({ latency: 0 }),
$store: { commit: vi.fn() },
SET_AND_CONNECT_AND_JOIN_ROOM: vi.fn().mockRejectedValue(new DOMException('Cancelled', 'AbortError')),
DISCONNECT_IF_CONNECTED: vi.fn(),
diff --git a/src/__tests__/usability-recovery.test.js b/src/__tests__/usability-recovery.test.js
new file mode 100644
index 0000000000..af4cc45cfb
--- /dev/null
+++ b/src/__tests__/usability-recovery.test.js
@@ -0,0 +1,230 @@
+import {
+ describe, expect, it, vi,
+} from 'vitest';
+import { mount, flushPromises } from '@vue/test-utils';
+import { createStore } from 'vuex';
+import { createVuetify } from 'vuetify';
+import * as components from 'vuetify/components';
+import * as directives from 'vuetify/directives';
+import PlexSearch from '@/views/PlexSearch.vue';
+import PlexLibrary from '@/views/PlexLibrary.vue';
+import PlexHome from '@/views/PlexHome.vue';
+import AdvancedRoomJoin from '@/views/AdvancedRoomJoin.vue';
+
+vi.mock('@/components/PlexThumbnail.vue', () => ({
+ __esModule: true, default: { template: '
Movie result
' },
+}));
+vi.mock('@/components/PlexOnDeck.vue', () => ({ __esModule: true, default: { template: '
' } }));
+const deferred = () => {
+ let resolve;
+ const promise = new Promise((done) => { resolve = done; });
+ return { promise, resolve };
+};
+const server = {
+ clientIdentifier: 'one',
+ name: 'Movie library',
+ chosenConnection: {},
+ libraries: { 1: { title: 'Movies', type: 'movie' } },
+};
+const setup = (component, options = {}) => {
+ const search = options.search || vi.fn().mockResolvedValue([]);
+ const fetchContents = options.fetchContents || vi.fn().mockResolvedValue([]);
+ const refresh = options.refresh || vi.fn().mockResolvedValue();
+ const connect = vi.fn().mockResolvedValue();
+ const store = createStore({
+ state: { listView: false },
+ getters: {
+ IS_LIBRARY_LIST_VIEW: (state) => state.listView,
+ GET_CONFIG: () => ({ servers: [{ url: 'https://service.invalid', name: 'Movie night' }] }),
+ },
+ mutations: { SET_ACTIVE_METADATA() {} },
+ modules: {
+ settings: {
+ namespaced: true,
+ state: { customServerUrl: 'https://' },
+ mutations: { SET_CUSTOM_SERVER_URL(state, value) { state.customServerUrl = value; } },
+ },
+ plex: { namespaced: true, actions: { FETCH_PLEX_DEVICES: () => refresh() } },
+ synclounge: {
+ namespaced: true,
+ state: { health: {} },
+ getters: {
+ GET_ROOM: () => 'room',
+ GET_SERVER: () => 'https://service.invalid',
+ GET_SERVER_HEALTH: (state) => (url) => state.health[url],
+ },
+ actions: {
+ FETCH_SERVERS_HEALTH: () => {},
+ SET_AND_CONNECT_AND_JOIN_ROOM: (_, payload) => connect(payload),
+ DISCONNECT_IF_CONNECTED() {},
+ },
+ },
+ plexservers: {
+ namespaced: true,
+ state: { enabled: options.enabled ?? true, available: options.available ?? true },
+ getters: {
+ GET_PLEX_SERVERS: () => [options.server || server],
+ GET_LAST_SERVER: () => options.server || server,
+ GET_LAST_SERVER_ID: () => 'one',
+ GET_PLEX_SERVER: () => () => server,
+ GET_ENABLED_PLEX_SERVER_IDS: (state) => (state.enabled && state.available ? (options.ids || ['one']) : []),
+ IS_PLEX_SERVER_ENABLED: (state) => () => state.enabled,
+ GET_SERVER_LIBRARY_SIZE: () => () => 0,
+ GET_MEDIA_IMAGE_URL: () => () => '',
+ },
+ actions: {
+ SEARCH_PLEX_SERVER_HUB: (_, payload) => search(payload),
+ FETCH_LIBRARY_CONTENTS: (_, payload) => fetchContents(payload),
+ FETCH_AND_SET_RANDOM_BACKGROUND_IMAGE() {},
+ },
+ mutations: { TOGGLE_SERVER_ENABLED(state) { state.enabled = !state.enabled; } },
+ },
+ },
+ });
+ let props = {};
+ if (component === PlexSearch) props = { query: 'Missing' };
+ if (component === PlexLibrary) props = { machineIdentifier: 'one', sectionId: '1' };
+ const wrapper = mount(component, {
+ props: options.props || props,
+ global: {
+ plugins: [store, createVuetify({ components, directives })],
+ mocks: { $route: { name: 'AdvancedRoomJoin', query: {} }, $router: { push: vi.fn() } },
+ stubs: { RouterLink: { template: ' ' } },
+ directives: { intersect: {} },
+ },
+ });
+ return {
+ wrapper, store, search, fetchContents, refresh, connect,
+ };
+};
+
+describe('search recovery', () => {
+ it.each([
+ [{}, 'No matches'],
+ [{ enabled: false }, 'No libraries enabled'],
+ [{ available: false }, 'Couldn’t reach your libraries'],
+ ])('distinguishes library search state: %s', async (options, title) => {
+ const { wrapper } = setup(PlexSearch, options);
+ await flushPromises();
+ expect(wrapper.text()).toContain(title);
+ wrapper.unmount();
+ });
+ it('retries a failed search without presenting it as no matches', async () => {
+ const search = vi.fn().mockRejectedValueOnce(new Error('offline')).mockResolvedValueOnce([]);
+ const { wrapper } = setup(PlexSearch, { search });
+ await flushPromises();
+ expect(wrapper.text()).toContain('Couldn’t search all libraries');
+ await wrapper.get('button').trigger('click');
+ await flushPromises();
+ expect(search).toHaveBeenCalledTimes(2);
+ expect(wrapper.text()).toContain('No matches');
+ wrapper.unmount();
+ });
+ it('retains results from healthy servers when another server fails', async () => {
+ const search = vi.fn(({ machineIdentifier }) => (machineIdentifier === 'one'
+ ? Promise.resolve([{ hubIdentifier: 'movies', title: 'Available movies', Metadata: [{ ratingKey: '1' }] }])
+ : Promise.reject(new Error('offline'))));
+ const { wrapper } = setup(PlexSearch, { search, ids: ['one', 'two'] });
+ await flushPromises();
+ expect(wrapper.text()).toContain('Available movies');
+ expect(wrapper.text()).toContain('Couldn’t search all libraries');
+ wrapper.unmount();
+ });
+ it('ignores obsolete search responses even if a server ignores abort', async () => {
+ const old = deferred();
+ const current = deferred();
+ const search = vi.fn().mockReturnValueOnce(old.promise).mockReturnValueOnce(current.promise);
+ const { wrapper } = setup(PlexSearch, { search });
+ await wrapper.setProps({ query: 'New query' });
+ current.resolve([{ hubIdentifier: 'new', title: 'Current results', Metadata: [{ ratingKey: '2' }] }]);
+ await flushPromises();
+ old.resolve([{ hubIdentifier: 'old', title: 'Obsolete results', Metadata: [{ ratingKey: '1' }] }]);
+ await flushPromises();
+ expect(wrapper.text()).toContain('Current results');
+ expect(wrapper.text()).not.toContain('Obsolete results');
+ expect(wrapper.vm.loading).toBe(false);
+ wrapper.unmount();
+ });
+});
+
+describe('library recovery', () => {
+ it('offers inline retry without advancing the failed page', async () => {
+ const fetchContents = vi.fn().mockRejectedValueOnce(new Error('offline')).mockResolvedValueOnce([]);
+ const { wrapper } = setup(PlexLibrary, { fetchContents });
+ await wrapper.vm.fetchMoreContent();
+ expect(wrapper.text()).toContain('Couldn’t load this library');
+ expect(wrapper.vm.itemsPerPage).toBe(0);
+ await wrapper.findAll('button').find((button) => button.text() === 'Retry').trigger('click');
+ await flushPromises();
+ expect(wrapper.text()).toContain('This library is empty');
+ expect(fetchContents.mock.calls.map(([payload]) => payload.start)).toEqual([0, 0]);
+ wrapper.unmount();
+ });
+ it('clears a letter filter after no matches', async () => {
+ const { wrapper, fetchContents } = setup(PlexLibrary);
+ await wrapper.vm.setFirstCharacter('Z');
+ await flushPromises();
+ expect(wrapper.text()).toContain('No matches');
+ await wrapper.findAll('button').find((button) => button.text() === 'Clear filters').trigger('click');
+ await flushPromises();
+ expect(fetchContents.mock.calls.at(-1)[0].firstCharacter).toBe(null);
+ expect(wrapper.text()).toContain('This library is empty');
+ wrapper.unmount();
+ });
+ it('discards old library pages when the filter changes', async () => {
+ const old = deferred();
+ const fetchContents = vi.fn().mockReturnValueOnce(old.promise).mockResolvedValueOnce([]);
+ const { wrapper } = setup(PlexLibrary, { fetchContents });
+ const pending = wrapper.vm.fetchMoreContent();
+ await wrapper.vm.setFirstCharacter('Z');
+ await flushPromises();
+ old.resolve([{ ratingKey: 'obsolete' }]);
+ await pending;
+ expect(wrapper.vm.contents).toEqual([]);
+ wrapper.unmount();
+ });
+});
+
+describe('connection recovery', () => {
+ it('names server controls and retries unavailable Plex servers', async () => {
+ const { wrapper, refresh } = setup(PlexHome, { server: { ...server, chosenConnection: null } });
+ await flushPromises();
+ expect(wrapper.text()).toContain('Can’t reach this Plex server');
+ expect(wrapper.text()).not.toContain('Try disabling your adblocker');
+ expect(wrapper.get('[aria-label="Refresh Plex servers"]').exists()).toBe(true);
+ await wrapper.findAll('button').find((button) => button.text() === 'Retry connection').trigger('click');
+ await flushPromises();
+ expect(refresh).toHaveBeenCalledTimes(1);
+ expect(wrapper.get('[aria-label="Hide libraries from Movie library"]').exists()).toBe(true);
+ wrapper.unmount();
+ });
+ it('shows hidden libraries separately from failed connections', async () => {
+ const { wrapper } = setup(PlexHome, { enabled: false });
+ await flushPromises();
+ expect(wrapper.text()).toContain('No libraries enabled');
+ expect(wrapper.text()).not.toContain('Can’t reach this Plex server');
+ expect(wrapper.get('[aria-label="Show libraries from Movie library"]').exists()).toBe(true);
+ wrapper.unmount();
+ });
+ it('blocks malformed custom addresses and unavailable configured services', async () => {
+ const { wrapper, store, connect } = setup(AdvancedRoomJoin);
+ await flushPromises();
+ expect(wrapper.get('[aria-label="Connect to Movie night"]').attributes('disabled')).toBeDefined();
+ expect(wrapper.get('[aria-label="Connect to custom SyncLounge service"]').attributes('disabled')).toBeDefined();
+ await wrapper.vm.connect('https://');
+ expect(connect).not.toHaveBeenCalled();
+ const invalidAddresses = [
+ 'ftp://example.com', 'https://user:password@example.com', 'https://example.com?token=test',
+ ];
+ for (const address of invalidAddresses) {
+ store.commit('settings/SET_CUSTOM_SERVER_URL', address);
+ expect(wrapper.vm.customServerError).not.toBe('');
+ }
+ store.commit('settings/SET_CUSTOM_SERVER_URL', 'https://example.com/lounge');
+ await wrapper.vm.$nextTick();
+ expect(wrapper.get('[aria-label="Connect to custom SyncLounge service"]').attributes('disabled')).toBeUndefined();
+ await wrapper.vm.connect(store.state.settings.customServerUrl);
+ expect(connect).toHaveBeenCalledTimes(1);
+ wrapper.unmount();
+ });
+});
diff --git a/src/components/TheSettingsDialog.vue b/src/components/TheSettingsDialog.vue
index d72b88b0d0..2edd3c4838 100644
--- a/src/components/TheSettingsDialog.vue
+++ b/src/components/TheSettingsDialog.vue
@@ -70,6 +70,7 @@
+
+ Viewers can pause or resume everyone’s playback when this is enabled.
+
+
@@ -251,6 +258,8 @@ export default {
color: rgb(255 255 255 / 70%);
}
+.party-controls :deep(.v-label) { white-space: normal; }
+
.switch-item :deep(.v-list-item__content) {
overflow: visible;
}
diff --git a/src/components/TheUpnextDialog.vue b/src/components/TheUpnextDialog.vue
index 8b9f0c0c5f..c4f9b24bb3 100644
--- a/src/components/TheUpnextDialog.vue
+++ b/src/components/TheUpnextDialog.vue
@@ -54,6 +54,14 @@
{{ getSecondaryTitle(GET_UP_NEXT_POST_PLAY_DATA) }}
+
+ Next episode starts in {{ secondsRemaining }} {{ secondsRemaining === 1 ? 'second' : 'seconds' }}
+
+
From {{ server.name }}
@@ -79,7 +87,7 @@
color="white"
@click="cancelPressed"
>
- Cancel
+ Don’t play next episode
@@ -117,6 +125,9 @@ export default {
sheet: true,
transitionBarWithStyle: {},
timeoutId: null,
+ countdownIntervalId: null,
+ deadline: null,
+ secondsRemaining: 0,
}),
computed: {
@@ -191,7 +202,15 @@ export default {
},
startTimer() {
+ this.cancelTimer();
+ const lifetime = this.GET_CONFIG.synclounge_upnext_popup_lifetime;
+ this.deadline = Date.now() + lifetime;
+ this.secondsRemaining = Math.ceil(lifetime / 1000);
+ this.countdownIntervalId = setInterval(() => {
+ this.secondsRemaining = Math.max(0, Math.ceil((this.deadline - Date.now()) / 1000));
+ }, 250);
this.timeoutId = setTimeout(() => {
+ this.cancelTimer();
this.playMedia();
}, this.GET_CONFIG.synclounge_upnext_popup_lifetime);
@@ -207,6 +226,8 @@ export default {
},
cancelTimer() {
+ clearInterval(this.countdownIntervalId);
+ this.countdownIntervalId = null;
if (this.timeoutId != null) {
clearTimeout(this.timeoutId);
this.timeoutId = null;
diff --git a/src/components/UserList.vue b/src/components/UserList.vue
index b346b550ce..5f9f143fad 100644
--- a/src/components/UserList.vue
+++ b/src/components/UserList.vue
@@ -57,55 +57,42 @@
-
+ Host
+
+
-
- star
-
- {{ getHostActionText(id === GET_HOST_ID) }}
-
-
-
-
+
-
- Remove from room
-
+
+
+
- {{ user.state || 'Connecting' }}
+ {{ readinessLabel(user, id) }}
{{ driftLabel(user) }}
+
+
+
+ {{ moderationTitle }}
+
+
+ {{ moderation.action === 'remove'
+ ? 'This disconnects them from the watch party. They can rejoin with the room link.'
+ : 'They will control what the party watches and manage room settings.' }}
+
+ {{ moderationError }}
+
+
+
+
+ Cancel
+
+
+ {{ moderation.action === 'remove' ? 'Remove from room' : 'Make host' }}
+
+
+
+
@@ -140,6 +168,9 @@ export default {
],
data: () => ({
+ moderation: null,
+ moderationPending: false,
+ moderationError: null,
stateIcons: {
stopped: 'stop',
paused: 'pause',
@@ -153,6 +184,16 @@ export default {
}),
computed: {
+ moderationTitle() {
+ if (!this.moderationTarget) return '';
+ return this.moderation.action === 'remove'
+ ? `Remove ${this.moderationTarget.username}?`
+ : `Make ${this.moderationTarget.username} the host?`;
+ },
+ moderationTarget() {
+ if (!this.moderation || this.moderation.id === this.GET_HOST_ID) return null;
+ return this.GET_USERS[this.moderation.id];
+ },
...mapGetters('settings', ['GET_ADVANCED_PARTY_MODE']),
...mapGetters([
'GET_CONFIG',
@@ -189,6 +230,39 @@ export default {
'KICK_USER',
]),
+ requestModeration(id, action) {
+ if (!this.AM_I_HOST || id === this.GET_HOST_ID || !this.GET_USERS[id]) return;
+ this.moderationError = null;
+ this.moderation = { id, action };
+ },
+
+ async confirmModeration() {
+ if (!this.AM_I_HOST || !this.moderationTarget || this.moderationPending) return;
+ this.moderationPending = true;
+ try {
+ const { id, action } = this.moderation;
+ await (action === 'remove' ? this.KICK_USER(id) : this.TRANSFER_HOST(id));
+ this.moderation = null;
+ } catch {
+ this.moderationError = 'Couldn’t update this participant. Try again.';
+ } finally {
+ this.moderationPending = false;
+ }
+ },
+
+ readinessLabel(user, id) {
+ if (user.state === 'buffering') return 'Buffering';
+ if (!user.media || user.state === 'stopped') return 'Not watching';
+ if (id === this.GET_HOST_ID) return user.state === 'paused' ? 'Paused' : 'Playing';
+ const timing = this.driftLabel(user);
+ if (timing === 'Different media') return 'Different movie or episode';
+ if (timing === 'Timing unavailable') return 'Sync status unavailable';
+ const tolerance = Number.isFinite(user.syncFlexibility) ? user.syncFlexibility : 500;
+ const difference = Math.abs(this.getAdjustedTime(user) - this.GET_ADJUSTED_HOST_TIME());
+ if (difference <= tolerance) return user.state === 'paused' ? 'Paused · In sync' : 'In sync';
+ return user.state === 'paused' ? 'Paused · Catching up' : 'Catching up';
+ },
+
driftLabel(user) {
const hostMedia = this.GET_HOST_USER?.media;
if (!hostMedia || !user.media) return 'Timing unavailable';
@@ -289,6 +363,9 @@ export default {
diff --git a/test/fixtures/ui-preview.js b/test/fixtures/ui-preview.js
index e98902cf14..7242fca078 100644
--- a/test/fixtures/ui-preview.js
+++ b/test/fixtures/ui-preview.js
@@ -14,11 +14,12 @@ const image = (text, background = '#29231d') => `data:image/svg+xml,${encodeURIC
// eslint-disable-next-line max-len
`${text} `,
)}`;
-const users = Object.fromEntries(['Alex', 'Morgan', 'Sam', 'Taylor with a long name'].map((username, i) => [
+const participantNames = ['Alex', 'Morgan', 'Sam', 'Taylor with a long name', 'Jordan', 'Riley'];
+const users = Object.fromEntries(participantNames.map((username, i) => [
`user-${i}`, {
username,
- thumb: image(username.slice(0, 1), ['#303e52', '#49304b', '#28504b', '#503b27'][i]),
- state: ['playing', 'paused', 'buffering', 'playing'][i],
+ thumb: image(username.slice(0, 1), ['#303e52', '#49304b', '#28504b', '#503b27'][i % 4]),
+ state: ['playing', 'paused', 'buffering', 'playing', 'playing', 'playing'][i],
time: 360000,
duration: 7200000,
updatedAt: Date.now(),
@@ -155,6 +156,8 @@ const store = createStore({
IS_AUTO_HOST_ENABLED: (state) => state.autoHost,
},
mutations: {
+ SET_PREVIEW_HOST(state, id) { state.hostId = id; },
+ SET_PREVIEW_MESSAGES(state, messages) { state.messages = messages; },
ADD_MESSAGE(state, text) { state.messages.push({ senderId: 'user-0', time: Date.now(), text }); },
SET_SYNC_PRESET(state, value) { state.syncPreset = value; },
SET_PAUSING(state, value) { state.pausing = value; },
diff --git a/test/fixtures/ui-preview.vue b/test/fixtures/ui-preview.vue
index 88b61a135c..1819a0403f 100644
--- a/test/fixtures/ui-preview.vue
+++ b/test/fixtures/ui-preview.vue
@@ -114,7 +114,7 @@
Watch party preview
- Four sample participants. Messages and party controls stay in this fixture’s memory.
+ Six sample participants. Messages and party controls stay in this fixture’s memory.
Open navigation
+ Preview as viewer
+ Empty chat