mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-11 11:06:16 -08:00
fix(m3u): let the guide own the keyboard and gate its entry points
While the programme guide is open the docked player carries `data-player-shortcuts-suspended`, which `ControlsShortcuts` now honours alongside `[inert]` — the arrows moved the player's volume instead of the guide's row focus. The external-player strip loses its Collapse toggle (nothing to reveal, no preference to write), the header action and its palette command report `disabled` when the guide cannot open, the docked strip derives its programme from the active channel's own schedule instead of the retained NgRx value, switching playlists closes the guide, and the collapsed strip can reach 48 px on phones. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
1 parent
376fde096d
commit
8d73286d93
16 files changed
+297
-44
No files matched your search
@@ -1132,12 +1132,20 @@ Guide mode is host layout, not an overlay: `VideoPlayerComponent.guideOpen`
|
||||
hides the sidebar and the timeline, renders the guide, and CSS reflows the
|
||||
untouched `app-web-player-view` into a 128 px docked strip
|
||||
(`.content-container.is-guide`) beside `app-epg-guide-now-playing`; the strip
|
||||
collapses to one line (`epg-guide:dock-collapsed`). Nothing remounts, so
|
||||
playback and native-view Embedded MPV bounds survive. Entry points: the
|
||||
workspace header action (`m3u-epg-guide`), the command palette, the Guide
|
||||
button in the timeline toolbar (`EpgTimelineComponent.openGuide`) and the `G`
|
||||
key on the player page. Player fullscreen, radio, recognised movies and the
|
||||
PWA close or withhold it. Inside the guide: single click on a row or an
|
||||
collapses to one 48 px line (`epg-guide:dock-collapsed`), which is also the
|
||||
fixed height of the strip shown for an external MPV/VLC session — that one has
|
||||
no video to reveal, so it hides the Collapse toggle (`collapsible=false`) and
|
||||
writes no preference. Nothing remounts, so playback and native-view Embedded
|
||||
MPV bounds survive. While the guide is open the docked `.video-player` carries
|
||||
`data-player-shortcuts-suspended`, which makes `ControlsShortcuts` (shared and
|
||||
legacy player shortcuts alike) yield ↑/↓, Space, F and M to the guide's own
|
||||
keyboard controller. Entry points: the workspace header action
|
||||
(`m3u-epg-guide`), the command palette, the Guide button in the timeline
|
||||
toolbar (`EpgTimelineComponent.openGuide`) and the `G` key on the player page.
|
||||
The header action reports `disabled` whenever the guide cannot open, which
|
||||
greys out the header button and disables its palette command instead of
|
||||
offering a no-op. Player fullscreen, radio, recognised movies, switching to
|
||||
another playlist and the PWA close or withhold it. Inside the guide: single click on a row or an
|
||||
"on now" card switches the channel and keeps the guide open, double-click or
|
||||
Enter switches and closes, other cards open the programme dialog; ↑/↓ ←/→
|
||||
navigate, I details, N now, PgUp/PgDn day, Esc close — the keyboard controller
|
||||
|
||||
@@ -189,7 +189,7 @@ token set; card radius is 8 px (timeline uses 11 px on 96 px cards).
|
||||
`guideOpen()` is true the sidebar and the `.epg` block are not rendered;
|
||||
`<app-epg-guide>` takes their place; `.content-container` gets `is-guide`
|
||||
and CSS turns `.video-player` into a 128 px strip (16:9 video on the left,
|
||||
`app-epg-guide-now-playing` beside it; 40 px single line when collapsed).
|
||||
`app-epg-guide-now-playing` beside it; 48 px single line when collapsed).
|
||||
`app-web-player-view` keeps its DOM position, so HTML5/hls.js, Video.js,
|
||||
ArtPlayer and both Embedded MPV engines keep playing; native-view syncs
|
||||
bounds as on any resize.
|
||||
|
||||
@@ -88,7 +88,12 @@
|
||||
activeChannel.radio === 'true' ||
|
||||
shouldShowInlinePlayer(activeChannel)
|
||||
) {
|
||||
<div class="video-player">
|
||||
<div
|
||||
class="video-player"
|
||||
[attr.data-player-shortcuts-suspended]="
|
||||
guideOpen() ? '' : null
|
||||
"
|
||||
>
|
||||
@if (activeChannel.radio === 'true') {
|
||||
<app-audio-player
|
||||
[url]="activeChannel.url"
|
||||
@@ -118,7 +123,6 @@
|
||||
}
|
||||
@if (guideOpen()) {
|
||||
<app-epg-guide-now-playing
|
||||
class="guide-dock__info"
|
||||
[channelName]="timelineChannelName()"
|
||||
[program]="guideNowPlayingProgram()"
|
||||
[offsetMinutes]="epgOffsetMinutes()"
|
||||
@@ -137,8 +141,8 @@
|
||||
[program]="guideNowPlayingProgram()"
|
||||
[offsetMinutes]="epgOffsetMinutes()"
|
||||
[collapsed]="true"
|
||||
[collapsible]="false"
|
||||
(closeRequested)="closeGuide()"
|
||||
(collapsedChange)="setGuideDockCollapsed($event)"
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -153,6 +153,10 @@
|
||||
.video-player {
|
||||
flex: 0 0 auto;
|
||||
height: 128px;
|
||||
// The shared player mixin floors `.video-player` at 96px on phones;
|
||||
// the docked strip is 128px expanded and 48px collapsed, so the floor
|
||||
// has to go or the collapsed strip cannot shrink.
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
gap: 18px;
|
||||
|
||||
@@ -36,6 +36,7 @@ import type { VideoPlayerComponent as VideoPlayerComponentInstance } from './vid
|
||||
import {
|
||||
activeChannel,
|
||||
activeEpgProgram,
|
||||
buildAiringProgram,
|
||||
activePlaybackUrl,
|
||||
channels,
|
||||
channels$,
|
||||
@@ -400,6 +401,93 @@ describe('VideoPlayerComponent', () => {
|
||||
localStorage.removeItem('epg-guide:dock-collapsed');
|
||||
});
|
||||
|
||||
it('suspends the docked player shortcuts while the guide is open', () => {
|
||||
syncStoreState(sampleChannel);
|
||||
player.set(VideoPlayer.VideoJs);
|
||||
fixture.detectChanges();
|
||||
const dock = fixture.nativeElement.querySelector(
|
||||
'.video-player'
|
||||
) as HTMLElement;
|
||||
expect(dock.hasAttribute('data-player-shortcuts-suspended')).toBe(
|
||||
false
|
||||
);
|
||||
|
||||
component.openGuide();
|
||||
fixture.detectChanges();
|
||||
expect(dock.hasAttribute('data-player-shortcuts-suspended')).toBe(true);
|
||||
|
||||
component.closeGuide();
|
||||
fixture.detectChanges();
|
||||
expect(dock.hasAttribute('data-player-shortcuts-suspended')).toBe(
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
it('derives the docked strip programme from the active channel schedule', () => {
|
||||
// The NgRx `currentEpgProgram` retains the previous channel's value
|
||||
// across a switch, so the strip must not read it.
|
||||
currentEpgProgram.set(buildAiringProgram('Retained Show'));
|
||||
syncStoreState(sampleChannel);
|
||||
player.set(VideoPlayer.VideoJs);
|
||||
epgPrograms$.next([buildAiringProgram('Sample Now')]);
|
||||
fixture.detectChanges();
|
||||
component.openGuide();
|
||||
fixture.detectChanges();
|
||||
const stripProgram = () =>
|
||||
fixture.debugElement
|
||||
.query(By.directive(StubEpgGuideNowPlayingComponent))
|
||||
.componentInstance.program();
|
||||
|
||||
expect(stripProgram()).toEqual(
|
||||
expect.objectContaining({ title: 'Sample Now' })
|
||||
);
|
||||
|
||||
syncStoreState({
|
||||
...sampleChannel,
|
||||
id: 'channel-2',
|
||||
url: 'http://localhost/other.m3u8',
|
||||
name: 'Other TV',
|
||||
tvg: { id: 'other-tvg-id', name: 'Other TV', logo: '' },
|
||||
} as Channel);
|
||||
epgPrograms$.next([buildAiringProgram('Other Now', 'other-tvg-id')]);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(stripProgram()).toEqual(
|
||||
expect.objectContaining({ title: 'Other Now' })
|
||||
);
|
||||
|
||||
epgPrograms$.next([]);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(stripProgram()).toBeNull();
|
||||
});
|
||||
|
||||
it('disables the guide header action while nothing can host the guide', () => {
|
||||
syncStoreState(null);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(headerContext.action()?.disabled?.()).toBe(true);
|
||||
|
||||
syncStoreState(sampleChannel);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(headerContext.action()?.disabled?.()).toBe(false);
|
||||
});
|
||||
|
||||
it('closes the guide when the active playlist changes', () => {
|
||||
syncStoreState(sampleChannel);
|
||||
player.set(VideoPlayer.VideoJs);
|
||||
fixture.detectChanges();
|
||||
component.openGuide();
|
||||
fixture.detectChanges();
|
||||
expect(component.guideOpen()).toBe(true);
|
||||
|
||||
playlistId.set('playlist-2');
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.guideOpen()).toBe(false);
|
||||
});
|
||||
|
||||
it('strips country prefixes from the timeline and player titles when enabled', () => {
|
||||
stripCountryPrefix.set(true);
|
||||
syncStoreState({ ...sampleChannel, name: 'US | CNN' } as Channel);
|
||||
|
||||
@@ -322,9 +322,22 @@ export class VideoPlayerComponent
|
||||
!this.showMovieDetail()
|
||||
);
|
||||
});
|
||||
/** Programme shown in the docked strip (catch-up selection wins). */
|
||||
readonly guideNowPlayingProgram = computed(
|
||||
() => this.activeEpgProgramOrNull() ?? this.epgProgram() ?? null
|
||||
/**
|
||||
* Programme shown in the docked strip (catch-up selection wins). The
|
||||
* live fallback is derived from the ACTIVE channel's own schedule and the
|
||||
* 30 s clock, exactly like {@link recordingMetadata} — the NgRx
|
||||
* `currentEpgProgram` keeps its last value across a channel switch and
|
||||
* through EPG gaps, so reading it here left the strip advertising the
|
||||
* previous channel's programme.
|
||||
*/
|
||||
readonly guideNowPlayingProgram = computed<EpgProgram | null>(
|
||||
() =>
|
||||
this.activeEpgProgramOrNull() ??
|
||||
findCurrentEpgProgram(
|
||||
this.epgPrograms(),
|
||||
epgProviderClockMs(this.epgNowMs(), this.epgOffsetMinutes())
|
||||
) ??
|
||||
null
|
||||
);
|
||||
readonly archivePlaybackAvailable = computed(() =>
|
||||
isM3uCatchupPlaybackSupported(this.activeChannel())
|
||||
@@ -717,6 +730,19 @@ export class VideoPlayerComponent
|
||||
}
|
||||
});
|
||||
|
||||
// Switching to another playlist keeps this component mounted, so an
|
||||
// open guide would survive and show the new playlist's channels under
|
||||
// the previous one's initial scope. Close it and let the user reopen.
|
||||
let lastGuidePlaylistId = this.activePlaylistId();
|
||||
effect(() => {
|
||||
const playlistId = this.activePlaylistId();
|
||||
if (playlistId === lastGuidePlaylistId) {
|
||||
return;
|
||||
}
|
||||
lastGuidePlaylistId = playlistId;
|
||||
untracked(() => this.closeGuide());
|
||||
});
|
||||
|
||||
// React to settings changes
|
||||
effect(() => {
|
||||
this.playerSettings = {
|
||||
@@ -1607,6 +1633,9 @@ export class VideoPlayerComponent
|
||||
tooltipKey: 'TOP_MENU.OPEN_EPG_GUIDE',
|
||||
ariaLabelKey: 'TOP_MENU.OPEN_EPG_GUIDE',
|
||||
active: () => this.guideOpen(),
|
||||
// Nothing to dock (no channel, radio, a recognised movie): the
|
||||
// header button and the palette command would both open nothing.
|
||||
disabled: () => !this.canOpenGuide(),
|
||||
palette: {
|
||||
labelKey: 'TOP_MENU.OPEN_EPG_GUIDE',
|
||||
descriptionKey:
|
||||
|
||||
@@ -134,3 +134,26 @@ export function syncStoreState(channel: Channel | null): void {
|
||||
channels.set(channel ? [channel] : []);
|
||||
channels$.next(channel ? [channel] : []);
|
||||
}
|
||||
|
||||
/**
|
||||
* Programme covering "now" on `channel`. The docked guide strip and the
|
||||
* recording snapshot both derive the live programme from the active channel's
|
||||
* own schedule, so their assertions need a window the clock falls inside.
|
||||
*/
|
||||
export function buildAiringProgram(
|
||||
title: string,
|
||||
channel = 'sample-tvg-id'
|
||||
): EpgProgram {
|
||||
const startMs = Date.now() - 10 * 60_000;
|
||||
const stopMs = startMs + 60 * 60_000;
|
||||
return {
|
||||
start: new Date(startMs).toISOString(),
|
||||
stop: new Date(stopMs).toISOString(),
|
||||
channel,
|
||||
title,
|
||||
desc: null,
|
||||
category: null,
|
||||
startTimestamp: Math.floor(startMs / 1000),
|
||||
stopTimestamp: Math.floor(stopMs / 1000),
|
||||
};
|
||||
}
|
||||
@@ -135,6 +135,7 @@ export class StubEpgGuideNowPlayingComponent {
|
||||
readonly program = input<EpgProgram | null>(null);
|
||||
readonly offsetMinutes = input(0);
|
||||
readonly collapsed = input(false);
|
||||
readonly collapsible = input(true);
|
||||
readonly closeRequested = output<void>();
|
||||
readonly collapsedChange = output<boolean>();
|
||||
}
|
||||
|
||||
@@ -8,6 +8,12 @@ export interface WorkspaceHeaderAction {
|
||||
ariaLabelKey: string;
|
||||
/** Pressed/highlighted state for toggling actions (e.g. the guide). */
|
||||
active?: () => boolean;
|
||||
/**
|
||||
* Whether the action currently cannot run (e.g. the guide with no live
|
||||
* channel to dock). Disables the header button and its palette command,
|
||||
* so neither entry point promises something the host would refuse.
|
||||
*/
|
||||
disabled?: () => boolean;
|
||||
run: () => void;
|
||||
palette?: WorkspaceHeaderCommandMetadata;
|
||||
}
|
||||
|
||||
@@ -17,21 +17,26 @@ export class WorkspaceViewCommandService {
|
||||
[]
|
||||
);
|
||||
|
||||
readonly commands = computed<readonly WorkspaceCommandContribution[]>(() => {
|
||||
const commands = this.registeredCommands().map(
|
||||
(entry) => entry.command
|
||||
);
|
||||
const headerAction = this.headerContext.action();
|
||||
const headerCommand = headerAction
|
||||
? this.toHeaderCommand(headerAction)
|
||||
: null;
|
||||
readonly commands = computed<readonly WorkspaceCommandContribution[]>(
|
||||
() => {
|
||||
const commands = this.registeredCommands().map(
|
||||
(entry) => entry.command
|
||||
);
|
||||
const headerAction = this.headerContext.action();
|
||||
const headerCommand = headerAction
|
||||
? this.toHeaderCommand(headerAction)
|
||||
: null;
|
||||
|
||||
return headerCommand ? [headerCommand, ...commands] : commands;
|
||||
});
|
||||
return headerCommand ? [headerCommand, ...commands] : commands;
|
||||
}
|
||||
);
|
||||
|
||||
registerCommand(command: WorkspaceCommandContribution): () => void {
|
||||
const token = Symbol(command.id);
|
||||
this.registeredCommands.update((entries) => [...entries, { token, command }]);
|
||||
this.registeredCommands.update((entries) => [
|
||||
...entries,
|
||||
{ token, command },
|
||||
]);
|
||||
|
||||
return () => {
|
||||
this.registeredCommands.update((entries) =>
|
||||
@@ -56,7 +61,11 @@ export class WorkspaceViewCommandService {
|
||||
priority: palette?.priority ?? 0,
|
||||
keywords: palette?.keywords,
|
||||
visible: palette?.visible,
|
||||
enabled: palette?.enabled,
|
||||
// The header action's own gate wins: a disabled button must not
|
||||
// leave a runnable command behind in the palette.
|
||||
enabled: action.disabled
|
||||
? () => !action.disabled?.()
|
||||
: palette?.enabled,
|
||||
run: () => action.run(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -36,18 +36,20 @@
|
||||
>
|
||||
{{ 'EPG.GUIDE.CLOSE' | translate }} <kbd>Esc</kbd>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
mat-button
|
||||
(click)="collapsedChange.emit(!collapsed())"
|
||||
[attr.aria-expanded]="!collapsed()"
|
||||
>
|
||||
{{
|
||||
(collapsed()
|
||||
? 'EPG.GUIDE.EXPAND_PLAYER'
|
||||
: 'EPG.GUIDE.COLLAPSE_PLAYER'
|
||||
) | translate
|
||||
}}
|
||||
</button>
|
||||
@if (collapsible()) {
|
||||
<button
|
||||
type="button"
|
||||
mat-button
|
||||
(click)="collapsedChange.emit(!collapsed())"
|
||||
[attr.aria-expanded]="!collapsed()"
|
||||
>
|
||||
{{
|
||||
(collapsed()
|
||||
? 'EPG.GUIDE.EXPAND_PLAYER'
|
||||
: 'EPG.GUIDE.COLLAPSE_PLAYER'
|
||||
) | translate
|
||||
}}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
@@ -31,6 +31,13 @@ export class EpgGuideNowPlayingComponent implements OnInit, OnDestroy {
|
||||
readonly program = input<EpgProgram | null>(null);
|
||||
readonly offsetMinutes = input(0);
|
||||
readonly collapsed = input(false);
|
||||
/**
|
||||
* Whether the strip offers its Collapse/Expand toggle. False where the
|
||||
* strip is already at its minimum — the external-player dock has no video
|
||||
* to collapse away, so the button would only write a preference the user
|
||||
* cannot see the effect of.
|
||||
*/
|
||||
readonly collapsible = input(true);
|
||||
|
||||
readonly closeRequested = output<void>();
|
||||
readonly collapsedChange = output<boolean>();
|
||||
|
||||
@@ -81,6 +81,34 @@ describe('ControlsShortcuts', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('ignores every shortcut while the host surface suspends player shortcuts', () => {
|
||||
const suspendedRegion = document.createElement('div');
|
||||
suspendedRegion.setAttribute('data-player-shortcuts-suspended', '');
|
||||
const host = document.createElement('div');
|
||||
suspendedRegion.appendChild(host);
|
||||
document.body.appendChild(suspendedRegion);
|
||||
(
|
||||
handlers as unknown as { hostElement: () => HTMLElement }
|
||||
).hostElement = () => host;
|
||||
|
||||
try {
|
||||
expect(dispatchKey(' ')).toBe(false);
|
||||
expect(dispatchKey('ArrowUp')).toBe(false);
|
||||
expect(dispatchKey('ArrowDown')).toBe(false);
|
||||
expect(dispatchKey('f')).toBe(false);
|
||||
expect(dispatchKey('m')).toBe(false);
|
||||
dispatchKey('Escape');
|
||||
|
||||
expect(handlers.togglePaused).not.toHaveBeenCalled();
|
||||
expect(handlers.adjustVolume).not.toHaveBeenCalled();
|
||||
expect(handlers.toggleFullscreen).not.toHaveBeenCalled();
|
||||
expect(handlers.toggleMute).not.toHaveBeenCalled();
|
||||
expect(handlers.onEscape).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
suspendedRegion.remove();
|
||||
}
|
||||
});
|
||||
|
||||
it('handles shortcuts when the host is not inside an inert region', () => {
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
|
||||
@@ -3,8 +3,11 @@ export interface ControlsShortcutHandlers {
|
||||
/**
|
||||
* The player's host element, used to opt out of global shortcuts while
|
||||
* an ancestor is `inert` (e.g. behind the workspace's phone context
|
||||
* drawer): inert strips pointer and Tab access, but document-level
|
||||
* keydown listeners still fire, so shortcuts must check it themselves.
|
||||
* drawer) or marked `data-player-shortcuts-suspended` (a host surface
|
||||
* that owns the keyboard itself, such as the programme guide with the
|
||||
* player docked): both strip the player of keyboard ownership, but
|
||||
* document-level keydown listeners still fire, so shortcuts must check
|
||||
* it themselves.
|
||||
*/
|
||||
hostElement?: () => HTMLElement | null;
|
||||
canTogglePaused: () => boolean;
|
||||
@@ -86,10 +89,17 @@ export class ControlsShortcuts {
|
||||
}
|
||||
|
||||
// A player inside an inert region is outside the interaction model
|
||||
// entirely — a modal surface above it owns the keyboard. This also
|
||||
// covers Escape: the modal decides what Escape means, not the
|
||||
// obscured player.
|
||||
if (handlers.hostElement?.()?.closest('[inert]')) {
|
||||
// entirely — a modal surface above it owns the keyboard. The same
|
||||
// applies to a host surface that marks its player container
|
||||
// `data-player-shortcuts-suspended` (the programme guide docks the
|
||||
// player but keeps the arrows, Space and F for itself). This also
|
||||
// covers Escape: the surface above decides what Escape means, not
|
||||
// the player it obscures or demotes.
|
||||
if (
|
||||
handlers
|
||||
.hostElement?.()
|
||||
?.closest('[inert], [data-player-shortcuts-suspended]')
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
+1
@@ -121,6 +121,7 @@
|
||||
class="header-shortcut"
|
||||
mat-icon-button
|
||||
[class.is-active]="action.active?.() ?? false"
|
||||
[disabled]="action.disabled?.() ?? false"
|
||||
[attr.aria-pressed]="action.active ? action.active() : null"
|
||||
(click)="onHeaderShortcutRequested()"
|
||||
[matTooltip]="action.tooltipKey | translate"
|
||||
|
||||
+33
@@ -922,6 +922,39 @@ describe('WorkspaceShellFacade', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('disables the programme guide command while the header action reports disabled', () => {
|
||||
const headerContext = TestBed.inject(WorkspaceHeaderContextService);
|
||||
|
||||
activePlaylistSignal.set({
|
||||
_id: 'pl-m3u',
|
||||
title: 'Playlist M3U',
|
||||
count: 10,
|
||||
importDate: '2026-04-22T10:00:00.000Z',
|
||||
autoRefresh: false,
|
||||
});
|
||||
facade.currentUrl.set('/workspace/playlists/pl-m3u/groups');
|
||||
const guideCommandEnabled = () =>
|
||||
facade
|
||||
.commandPaletteCommands()
|
||||
.find((command) => command.id === 'm3u-epg-guide')?.enabled;
|
||||
const registerGuideAction = (disabled: () => boolean) =>
|
||||
headerContext.setAction({
|
||||
id: 'm3u-epg-guide',
|
||||
icon: 'grid_view',
|
||||
tooltipKey: 'TOP_MENU.OPEN_EPG_GUIDE',
|
||||
ariaLabelKey: 'TOP_MENU.OPEN_EPG_GUIDE',
|
||||
disabled,
|
||||
palette: { labelKey: 'TOP_MENU.OPEN_EPG_GUIDE' },
|
||||
run: jest.fn(),
|
||||
});
|
||||
|
||||
registerGuideAction(() => true);
|
||||
expect(guideCommandEnabled()).toBe(false);
|
||||
|
||||
registerGuideAction(() => false);
|
||||
expect(guideCommandEnabled()).toBe(true);
|
||||
});
|
||||
|
||||
it('opens the Xtream account dialog with credentials and session counts for the active playlist', () => {
|
||||
activePlaylistSignal.set({
|
||||
_id: 'pl-xtream',
|
||||
|
||||
Reference in new issue
Block a user