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..a5ae0c1fc5 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,19 @@ 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');
+ await vi.advanceTimersByTimeAsync(6000);
+ expect(new DOMWrapper(document.body).text()).toContain('Next episode starts in 1 second');
+ if (mode === 'automatic') await vi.advanceTimersByTimeAsync(1000);
+ 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..33e5d18643 100644
--- a/src/__tests__/participant-health.test.js
+++ b/src/__tests__/participant-health.test.js
@@ -37,4 +37,16 @@ describe('participant timing across Plex servers', () => {
media: {},
})).toBe('Different media');
});
+ it('keeps unknown playback states connecting', () => {
+ expect(UserList.methods.readinessLabel.call(context, {}, 'viewer')).toBe('Connecting');
+ expect(UserList.methods.readinessLabel.call(context, { media }, 'viewer')).toBe('Connecting');
+ });
+ 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..eece04e825
--- /dev/null
+++ b/src/__tests__/participant-moderation.test.js
@@ -0,0 +1,121 @@
+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 = (friendOptions = {}) => {
+ 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', friendOptions),
+ 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();
+ try {
+ 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);
+ } finally {
+ wrapper.unmount();
+ }
+ });
+ it.each([[3000, 'In sync'], [500, 'Catching up']])('renders readiness with tolerance %s', (tolerance, label) => {
+ const { wrapper } = setup({ time: 8000, syncFlexibility: tolerance });
+ try {
+ const row = wrapper.findAll('.v-list-item').find((item) => item.text().includes('Morgan'));
+ expect(row.text()).toContain(label);
+ } finally {
+ 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__/sidebar-room-actions.test.js b/src/__tests__/sidebar-room-actions.test.js
index ce481e0bce..43cc6d6e86 100644
--- a/src/__tests__/sidebar-room-actions.test.js
+++ b/src/__tests__/sidebar-room-actions.test.js
@@ -16,7 +16,7 @@ vi.mock('@/components/ThePwaInstall.vue', () => ({ default: { template: '
vi.mock('@/components/ReportProblem.vue', () => ({ default: { template: '' } }));
vi.mock('@/components/TheSettingsDialog.vue', () => ({ default: { template: '' } }));
-const setup = (component, inRoom = true) => {
+const setup = (component, inRoom = true, host = null) => {
const disconnect = vi.fn();
const store = createStore({
state: { isLeftSidebarOpen: true, isRightSidebarOpen: true },
@@ -31,7 +31,7 @@ const setup = (component, inRoom = true) => {
getters: {
IS_IN_ROOM: () => inRoom,
GET_USERS: () => ({ me: { username: 'Me' } }),
- GET_HOST_USER: () => null,
+ GET_HOST_USER: () => host,
AM_I_HOST: () => false,
IS_PARTY_PAUSING_ENABLED: () => false,
IS_AUTO_HOST_ENABLED: () => false,
@@ -79,6 +79,14 @@ describe('sidebar close and room exit', () => {
} finally { wrapper.unmount(); }
});
+ it('omits host-only control rows for a viewer during playback', () => {
+ const { wrapper } = setup(TheSidebarRight, true, { username: 'Host', state: 'playing' });
+ try {
+ expect(wrapper.findAll('.switch-item')).toHaveLength(0);
+ expect(wrapper.get('[aria-label="Pause party"]').exists()).toBe(true);
+ } finally { wrapper.unmount(); }
+ });
+
it('disconnects only through the labeled navigation action', async () => {
const { wrapper, store, disconnect } = setup(TheSidebarLeft);
try {
diff --git a/src/__tests__/usability-recovery.test.js b/src/__tests__/usability-recovery.test.js
new file mode 100644
index 0000000000..5d69bde788
--- /dev/null
+++ b/src/__tests__/usability-recovery.test.js
@@ -0,0 +1,233 @@
+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, true);
+ store.commit('settings/SET_CUSTOM_SERVER_URL', store.getters.GET_CONFIG.servers[0].url);
+ await wrapper.get('[aria-label="Connect to custom SyncLounge service"]').trigger('click');
+ await flushPromises();
+ expect(connect).toHaveBeenCalledTimes(2);
+ 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 @@
@@ -50,11 +51,18 @@
density="compact"
class="pa-0 ma-0 sidebar-switch"
color="primary"
- label="Party Pausing"
+ label="Let anyone pause the party"
:model-value="IS_PARTY_PAUSING_ENABLED"
@update:model-value="SEND_SET_PARTY_PAUSING_ENABLED"
/>
+
+ Allow viewers to pause or resume everyone.
+
+
@@ -219,7 +227,7 @@ export default {
display: flex;
align-items: flex-start;
gap: 12px;
- padding: 16px;
+ padding: 8px 12px;
border-bottom: 1px solid var(--sl-border);
}
@@ -231,19 +239,17 @@ export default {
.party-body {
display: flex;
flex-direction: column;
- min-height: 100%;
+ height: 100%;
+ min-height: 0;
}
-.party-controls,
-.party-users {
- flex: 0 0 auto;
-}
+.party-controls { flex: 0 0 auto; }
+.party-users { flex: 0 1 auto; min-height: 0; }
.messages {
overflow-y: auto;
- flex: 1 0 180px;
- max-height: 60dvh;
- min-height: 180px;
+ flex: 1 1 96px;
+ min-height: 96px;
}
.participant-count {
@@ -251,12 +257,17 @@ export default {
color: rgb(255 255 255 / 70%);
}
+.party-controls :deep(.v-selection-control) { min-height: 36px; }
+.party-controls :deep(.v-input) { flex: 0 0 auto; }
+
+.party-controls :deep(.v-label) { white-space: normal; }
+
.switch-item :deep(.v-list-item__content) {
overflow: visible;
}
.sidebar-switch :deep(.v-selection-control) {
- min-height: 44px;
+ min-height: 36px;
}
.sidebar-switch :deep(.v-switch__track) {
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..d1489fab7c 100644
--- a/src/components/UserList.vue
+++ b/src/components/UserList.vue
@@ -6,7 +6,7 @@
(you)
- {{ getTimeFromMs(getAdjustedTime(user)) }}
+
+ {{ getTimeFromMs(getAdjustedTime(user)) }}
+ {{ readinessLabel(user, id) }}
+
{{ getTitle(user.media) }}
@@ -57,56 +60,46 @@
-
+ Host
+
+
-
- star
-
- {{ getHostActionText(id === GET_HOST_ID) }}
-
-
-
-
+
-
- Remove from room
-
+
+
+
-
-
{{ user.state || 'Connecting' }}
-
{{ driftLabel(user) }}
+
+ {{ 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 +175,9 @@ export default {
],
data: () => ({
+ moderation: null,
+ moderationPending: false,
+ moderationError: null,
stateIcons: {
stopped: 'stop',
paused: 'pause',
@@ -153,6 +191,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 +237,40 @@ 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) return 'Connecting';
+ 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,8 +371,12 @@ export default {
diff --git a/src/views/AdvancedRoomJoin.vue b/src/views/AdvancedRoomJoin.vue
index 7124c0dc16..c6134e0e9c 100644
--- a/src/views/AdvancedRoomJoin.vue
+++ b/src/views/AdvancedRoomJoin.vue
@@ -83,8 +83,14 @@
v-else
class="text-center text-red"
>
- Unavailable
+ {{ healthChecking ? 'Checking availability…' : 'Unavailable' }}
+
+ This service did not respond. Retry the availability check or choose another service.
+
@@ -93,7 +99,8 @@
variant="flat"
color="primary"
class="welcome-primary"
- :disabled="connectionPending"
+ :disabled="connectionPending || !GET_SERVER_HEALTH(server.url)"
+ :aria-label="`Connect to ${server.name}`"
@click="connect(server.url)"
>
Connect
@@ -124,7 +131,10 @@
Connect
@@ -152,6 +163,15 @@
+
+ Retry availability check
+
+
({
serverError: null,
+ healthChecking: true,
+ healthRequestPending: false,
connectionPending: false,
testConnectionInterval: null,
}),
computed: {
+ customServerError() {
+ try {
+ const address = new URL(this.customServerUrl.trim());
+ if (!['http:', 'https:'].includes(address.protocol) || !address.hostname
+ || address.username || address.password || address.search || address.hash) {
+ return 'Enter an http:// or https:// service address without credentials, query, or fragment.';
+ }
+ return '';
+ } catch {
+ return 'Enter a complete address, such as https://lounge.example.com.';
+ }
+ },
...mapGetters([
'GET_CONFIG',
]),
@@ -228,16 +262,29 @@ export default {
clearInterval(this.testConnectionInterval);
},
- async created() {
- await this.FETCH_SERVERS_HEALTH();
+ created() {
+ this.refreshHealth();
this.testConnectionInterval = setInterval(
- () => this.FETCH_SERVERS_HEALTH(),
+ () => this.refreshHealth(),
5000,
);
},
methods: {
+ async refreshHealth() {
+ if (this.healthRequestPending) return;
+ this.healthRequestPending = true;
+ this.healthChecking = true;
+ try {
+ await this.FETCH_SERVERS_HEALTH();
+ } catch {
+ this.serverError = 'Couldn’t check service availability. Try again.';
+ } finally {
+ this.healthChecking = false;
+ this.healthRequestPending = false;
+ }
+ },
...mapMutations('settings', [
'SET_CUSTOM_SERVER_URL',
]),
@@ -274,13 +321,17 @@ export default {
return ['text-white'];
},
- async connect(server) {
+ async connect(server, custom = false) {
+ if (this.connectionPending) return;
+ if (server === this.customServerUrl && this.customServerError) return;
+ if (!custom && this.GET_CONFIG.servers.some((configured) => configured.url === server)
+ && !this.GET_SERVER_HEALTH(server)) return;
this.serverError = null;
this.connectionPending = true;
try {
await this.SET_AND_CONNECT_AND_JOIN_ROOM({
- server,
+ server: server.trim(),
room: getRandomRoomId(),
});
diff --git a/src/views/PlexHome.vue b/src/views/PlexHome.vue
index 0e403b66cb..099c3a04ac 100644
--- a/src/views/PlexHome.vue
+++ b/src/views/PlexHome.vue
@@ -10,7 +10,27 @@
You’re in the room, but there’s no playback to restore. Rooms clear when everyone leaves.
Pick a movie below, or wait for your host to start one.
-
+
+ Your Plex servers are hidden. Use each server’s crossed-out eye button below to include it in browsing and search.
+
+
+ Couldn’t refresh your Plex servers. Check your connection and try again.
+
+
Continue watching from {{ GET_LAST_SERVER.name }}
@@ -22,7 +42,10 @@
size="small"
icon
variant="text"
- @click="FETCH_PLEX_DEVICES"
+ aria-label="Refresh Plex servers"
+ title="Refresh Plex servers"
+ :loading="refreshPending"
+ @click="refreshServers"
>
refresh
@@ -38,6 +61,15 @@
>
No Plex Servers found.
Make sure your server owner has shared libraries with you!
+
+
+ Retry
+
+
- Unable to connect.
- Try disabling your adblocker
+ Can’t reach this Plex server.
+
+ Retry connection
+
+
+ Troubleshooting
+ Check that the server is online and your network can reach it. Browser extensions
+ may also block requests; check them if the problem continues.
+
- Disabled
+ Hidden from browsing and search
@@ -138,9 +184,14 @@ export default {
data: () => ({
abortController: null,
+ refreshPending: false,
+ refreshError: false,
}),
computed: {
+ hasEnabledServers() {
+ return this.GET_PLEX_SERVERS.some((server) => this.IS_PLEX_SERVER_ENABLED(server.clientIdentifier));
+ },
...mapGetters('plexservers', [
'GET_LAST_SERVER',
'GET_LAST_SERVER_ID',
@@ -159,6 +210,21 @@ export default {
},
methods: {
+ visibilityLabel(server) {
+ return `${this.IS_PLEX_SERVER_ENABLED(server.clientIdentifier) ? 'Hide' : 'Show'} libraries from ${server.name}`;
+ },
+ async refreshServers() {
+ if (this.refreshPending) return;
+ this.refreshPending = true;
+ this.refreshError = false;
+ try {
+ await this.FETCH_PLEX_DEVICES();
+ } catch {
+ this.refreshError = true;
+ } finally {
+ this.refreshPending = false;
+ }
+ },
...mapActions('plexservers', [
'FETCH_AND_SET_RANDOM_BACKGROUND_IMAGE',
]),
diff --git a/src/views/PlexLibrary.vue b/src/views/PlexLibrary.vue
index 8c9df2095b..24b6b03928 100644
--- a/src/views/PlexLibrary.vue
+++ b/src/views/PlexLibrary.vue
@@ -12,6 +12,7 @@
+
+ Your loaded items are still available. Check the Plex server connection and try again.
+
+
+ Retry
+
+
+
+
+ {{ activeFirstCharacter ? 'No titles match this letter filter.'
+ : 'There are no titles available in this library.' }}
+
+
+ Clear filters
+
+
+
+