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 + + - {{ getHostActionText(id === GET_HOST_ID) }} - - - - Remove from room - + + +
- {{ user.state || 'Connecting' }} + {{ readinessLabel(user, id) }} {{ driftLabel(user) }}