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 @@
-
- {{ 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. + + @@ -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 + +
+
+