diff --git a/docs/architecture/parental-lock.md b/docs/architecture/parental-lock.md index 86a3b365a..d6c8b1f7b 100644 --- a/docs/architecture/parental-lock.md +++ b/docs/architecture/parental-lock.md @@ -223,6 +223,10 @@ on either side. whenever the store selection is cleared: its template mounts the player only with a selection, and the held stream must not resurface with the next one. +- **M3U groups rail:** `ChannelListContainerComponent` derives + `withheldGroupCount` (groups of the playlist the active lock withholds) + and the groups rail renders the same "N locked · Enter PIN to show" row + as the portal category rail, so locked groups do not simply vanish. - **Xtream (PWA):** `PwaXtreamDataSource` drops withheld categories, streams and search hits at read time; the same reloads apply. - **Warm-cache detection (Electron):** the filtered category/content reads diff --git a/libs/services/src/lib/parental-lock/parental-lock-settings-writer.ts b/libs/services/src/lib/parental-lock/parental-lock-settings-writer.ts index 53fa16202..3feddcbf6 100644 --- a/libs/services/src/lib/parental-lock/parental-lock-settings-writer.ts +++ b/libs/services/src/lib/parental-lock/parental-lock-settings-writer.ts @@ -64,11 +64,14 @@ export async function persistParentalLockEnabled( export async function persistParentalLockRelockMinutes( settings: SettingsWriter, minutes: number, - previous: number + readCurrent: () => number ): Promise { if (!(await ensureParentalLockSettingsReadable(settings))) { return false; } + // Read AFTER the retry: a recovered read replaces the defaults, and a + // rollback to the pre-retry value would write the default back. + const previous = readCurrent(); try { await settings.updateSettings({ parentalLockRelockMinutes: diff --git a/libs/services/src/lib/parental-lock/parental-lock.service.spec.ts b/libs/services/src/lib/parental-lock/parental-lock.service.spec.ts index a58ec462e..019c35da1 100644 --- a/libs/services/src/lib/parental-lock/parental-lock.service.spec.ts +++ b/libs/services/src/lib/parental-lock/parental-lock.service.spec.ts @@ -248,6 +248,27 @@ describe('ParentalLockService', () => { }); }); + it('rolls a failed relock write back to the value recovered by the retry', async () => { + storageFailure.set('load'); + const service = await createService(); + const loadSettings = TestBed.inject(SettingsStore) + .loadSettings as jest.Mock; + loadSettings.mockImplementationOnce(async () => { + storageFailure.set(null); + parentalLockRelockMinutes.set(60); + }); + updateSettings.mockImplementationOnce(async () => { + parentalLockRelockMinutes.set(30); + throw new Error('QuotaExceededError'); + }); + + await expect(service.setRelockMinutes(30)).resolves.toBe(false); + + expect(updateSettings).toHaveBeenLastCalledWith({ + parentalLockRelockMinutes: 60, + }); + }); + it('refuses parental-lock settings writes while settings stay unreadable', async () => { storageFailure.set('load'); prompt.requestPin.mockResolvedValue('1234'); diff --git a/libs/services/src/lib/parental-lock/parental-lock.service.ts b/libs/services/src/lib/parental-lock/parental-lock.service.ts index f78069876..bd6c1d8db 100644 --- a/libs/services/src/lib/parental-lock/parental-lock.service.ts +++ b/libs/services/src/lib/parental-lock/parental-lock.service.ts @@ -335,7 +335,7 @@ export class ParentalLockService { return persistParentalLockRelockMinutes( this.settingsStore, minutes, - this.relockMinutes() + () => this.relockMinutes() ); } diff --git a/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.html b/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.html index ac74d21a4..67eac8676 100644 --- a/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.html +++ b/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.html @@ -63,6 +63,7 @@ [favoriteIds]="favoriteIds()" [hiddenGroupTitles]="hiddenGroupTitles()" [lockedGroupTitles]="lockedGroupTitles()" + [withheldGroupCount]="withheldGroupCount()" [managementGroups]="managementGroups()" [sidebarWidth]="sidebarWidth()" (channelSelected)="onChannelSelected($event)" diff --git a/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.ts b/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.ts index 55d56e5e9..29ed08880 100644 --- a/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.ts +++ b/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.ts @@ -340,6 +340,20 @@ export class ChannelListContainerComponent implements OnInit, OnDestroy { } return this.parentalLock.lockedGroupTitles(playlistId); }); + /** + * Groups of this playlist the lock currently withholds; the groups rail + * shows one "N locked · Enter PIN to show" row for them, as the portal + * category rail does. + */ + readonly withheldGroupCount = computed(() => { + const withheld = this.withheldGroupTitles(); + if (withheld.size === 0) { + return 0; + } + return this.managementGroups().filter((group) => + withheld.has(group.key) + ).length; + }); /** Group titles currently withheld: locked AND the lock is active. */ private readonly withheldGroupTitles = computed(() => { this.parentalLock.version(); diff --git a/libs/ui/components/src/lib/channel-list-container/groups-view/groups-view.component.html b/libs/ui/components/src/lib/channel-list-container/groups-view/groups-view.component.html index 0311f79f8..c04e800a1 100644 --- a/libs/ui/components/src/lib/channel-list-container/groups-view/groups-view.component.html +++ b/libs/ui/components/src/lib/channel-list-container/groups-view/groups-view.component.html @@ -170,6 +170,29 @@ } } + @if (withheldGroupCount() > 0) { + + } diff --git a/libs/ui/components/src/lib/channel-list-container/groups-view/groups-view.component.scss b/libs/ui/components/src/lib/channel-list-container/groups-view/groups-view.component.scss index e072a21a0..85b670f35 100644 --- a/libs/ui/components/src/lib/channel-list-container/groups-view/groups-view.component.scss +++ b/libs/ui/components/src/lib/channel-list-container/groups-view/groups-view.component.scss @@ -327,3 +327,50 @@ padding-inline-end: 0; } } + +// Same affordance as the portal category rail's locked row: the parent +// sees that groups are withheld and can unlock from here. +.groups-locked-row { + display: flex; + align-items: center; + gap: 8px; + width: 100%; + margin-top: 4px; + padding: 8px 12px; + border: 1px dashed var(--mat-sys-outline-variant); + border-radius: 10px; + background: transparent; + color: var(--mat-sys-on-surface-variant); + font: inherit; + font-size: 0.8125rem; + text-align: left; + cursor: pointer; + + &:hover, + &:focus-visible { + background: var(--mat-sys-surface-container); + color: var(--mat-sys-on-surface); + outline: none; + } + + mat-icon { + font-size: 18px; + width: 18px; + height: 18px; + flex-shrink: 0; + } + + &__label { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + &__hint { + font-size: 0.6875rem; + opacity: 0.8; + white-space: nowrap; + } +} diff --git a/libs/ui/components/src/lib/channel-list-container/groups-view/groups-view.component.spec.ts b/libs/ui/components/src/lib/channel-list-container/groups-view/groups-view.component.spec.ts index 7570b78e6..26f92865a 100644 --- a/libs/ui/components/src/lib/channel-list-container/groups-view/groups-view.component.spec.ts +++ b/libs/ui/components/src/lib/channel-list-container/groups-view/groups-view.component.spec.ts @@ -565,6 +565,21 @@ describe('GroupsViewComponent', () => { expect(groupLockToggled).toHaveBeenCalledTimes(2); }); + it('shows the locked-groups row while groups are withheld and unlocks from it', async () => { + const row = () => + fixture.nativeElement.querySelector( + '[data-test-id="groups-locked-row"]' + ) as HTMLButtonElement | null; + expect(row()).toBeNull(); + + fixture.componentRef.setInput('withheldGroupCount', 2); + fixture.detectChanges(); + expect(row()).not.toBeNull(); + + row()?.click(); + expect(parentalLock.requestUnlock).toHaveBeenCalled(); + }); + it('offers no group lock menu while the feature is off', () => { const event = new MouseEvent('contextmenu', { cancelable: true }); component.onGroupContextMenu('Sports', event); diff --git a/libs/ui/components/src/lib/channel-list-container/groups-view/groups-view.component.ts b/libs/ui/components/src/lib/channel-list-container/groups-view/groups-view.component.ts index 9e61ebdc6..0b425b675 100644 --- a/libs/ui/components/src/lib/channel-list-container/groups-view/groups-view.component.ts +++ b/libs/ui/components/src/lib/channel-list-container/groups-view/groups-view.component.ts @@ -157,6 +157,8 @@ export class GroupsViewComponent { readonly managementGroups = input( null ); + /** Groups the active lock withholds; > 0 renders the unlock row. */ + readonly withheldGroupCount = input(0); /** Current outer sidebar width */ readonly sidebarWidth = input(null); @@ -523,6 +525,10 @@ export class GroupsViewComponent { this.groupLockToggled.emit({ groupKey, locked: lock }); } + async requestParentalUnlock(): Promise { + await this.parentalLock.requestUnlock(); + } + async openGroupManagement(): Promise { // The dialog lists every group by name, locked ones included, and can // rewrite the locks — so it sits behind the PIN like the portal