mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-10 10:06:15 -08:00
fix(playback): clarify external player launch feedback (#1388)
This commit is contained in:
1 parent
8442747c37
commit
d73acd6bfc
101 files changed
+8819
-850
No files matched your search
+62
-15
@@ -2,6 +2,7 @@
|
||||
class="external-playback-dock"
|
||||
[class.external-playback-dock--compact]="compact()"
|
||||
[class.external-playback-dock--error]="session().status === 'error'"
|
||||
[attr.aria-busy]="session().status === 'launching' ? 'true' : null"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
@@ -10,9 +11,7 @@
|
||||
artworkInteractive()
|
||||
"
|
||||
[disabled]="!artworkInteractive()"
|
||||
[attr.aria-label]="
|
||||
artworkInteractive() ? 'Open in playlist' : null
|
||||
"
|
||||
[attr.aria-label]="artworkInteractive() ? 'Open in playlist' : null"
|
||||
(click)="onArtworkClick()"
|
||||
>
|
||||
@if (showArtwork()) {
|
||||
@@ -31,35 +30,50 @@
|
||||
|
||||
<div class="external-playback-dock__meta">
|
||||
<div class="external-playback-dock__title">{{ displayTitle() }}</div>
|
||||
<div class="external-playback-dock__status">
|
||||
<span class="external-playback-dock__chip">{{ playerLabel() }}</span>
|
||||
<div class="external-playback-dock__status" aria-live="polite">
|
||||
<span class="external-playback-dock__chip">{{
|
||||
playerLabel()
|
||||
}}</span>
|
||||
<span
|
||||
class="external-playback-dock__status-separator"
|
||||
aria-hidden="true"
|
||||
>·</span
|
||||
>
|
||||
@if (showSpinner()) {
|
||||
<mat-spinner diameter="12" strokeWidth="2"></mat-spinner>
|
||||
<mat-spinner
|
||||
aria-hidden="true"
|
||||
diameter="12"
|
||||
strokeWidth="2"
|
||||
></mat-spinner>
|
||||
} @else if (showStatusIcon()) {
|
||||
<mat-icon class="external-playback-dock__status-icon">{{
|
||||
statusIcon()
|
||||
}}</mat-icon>
|
||||
<mat-icon
|
||||
aria-hidden="true"
|
||||
class="external-playback-dock__status-icon"
|
||||
>{{ statusIcon() }}</mat-icon
|
||||
>
|
||||
}
|
||||
<span class="external-playback-dock__status-text">{{
|
||||
statusLabel()
|
||||
}}</span>
|
||||
<span class="external-playback-dock__status-text">
|
||||
@if (session().status === 'error' && session().error) {
|
||||
{{ session().error }}
|
||||
} @else {
|
||||
{{ statusLabelKey() | translate }}
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="external-playback-dock__actions">
|
||||
@if (showCloseButton()) {
|
||||
@if (showCloseAction()) {
|
||||
<button
|
||||
type="button"
|
||||
mat-button
|
||||
class="external-playback-dock__button"
|
||||
(click)="closeClicked.emit()"
|
||||
[attr.aria-label]="
|
||||
'WORKSPACE.SHELL.EXTERNAL_PLAYBACK_CLOSE' | translate
|
||||
"
|
||||
(click)="onCloseClick()"
|
||||
>
|
||||
<mat-icon>stop_circle</mat-icon>
|
||||
<mat-icon aria-hidden="true">stop_circle</mat-icon>
|
||||
@if (!compact()) {
|
||||
<span>{{
|
||||
'WORKSPACE.SHELL.EXTERNAL_PLAYBACK_CLOSE' | translate
|
||||
@@ -67,5 +81,38 @@
|
||||
}
|
||||
</button>
|
||||
}
|
||||
@if (showDismissAction()) {
|
||||
@if (compact()) {
|
||||
<button
|
||||
type="button"
|
||||
mat-icon-button
|
||||
class="external-playback-dock__button external-playback-dock__dismiss"
|
||||
[attr.aria-label]="
|
||||
'WORKSPACE.SHELL.EXTERNAL_PLAYBACK_DISMISS' | translate
|
||||
"
|
||||
(click)="onDismissClick()"
|
||||
>
|
||||
<mat-icon aria-hidden="true">close</mat-icon>
|
||||
</button>
|
||||
} @else {
|
||||
<button
|
||||
type="button"
|
||||
mat-button
|
||||
class="external-playback-dock__button external-playback-dock__dismiss"
|
||||
[attr.aria-label]="
|
||||
'WORKSPACE.SHELL.EXTERNAL_PLAYBACK_DISMISS' | translate
|
||||
"
|
||||
(click)="onDismissClick()"
|
||||
>
|
||||
<mat-icon aria-hidden="true">close</mat-icon>
|
||||
@if (!compact()) {
|
||||
<span>{{
|
||||
'WORKSPACE.SHELL.EXTERNAL_PLAYBACK_DISMISS'
|
||||
| translate
|
||||
}}</span>
|
||||
}
|
||||
</button>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
+30
-33
@@ -9,27 +9,27 @@
|
||||
gap: 14px;
|
||||
min-height: 72px;
|
||||
padding: 2px 14px;
|
||||
border: 1px solid color-mix(in srgb, var(--mat-sys-outline-variant) 60%, transparent);
|
||||
border: 1px solid var(--app-widget-header-border, rgba(0, 0, 0, 0.08));
|
||||
border-radius: 15px 15px 0 0;
|
||||
background:
|
||||
linear-gradient(
|
||||
135deg,
|
||||
color-mix(in srgb, var(--mat-sys-surface-container-high) 88%, white 12%),
|
||||
color-mix(in srgb, var(--mat-sys-surface-container) 92%, transparent)
|
||||
);
|
||||
box-shadow:
|
||||
0 18px 42px rgba(15, 23, 42, 0.12),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.28);
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
color-mix(in srgb, var(--app-widget-bg, #fff) 88%, white 12%),
|
||||
var(--app-widget-header-bg, #fafbfc)
|
||||
);
|
||||
box-shadow: var(--app-widget-shadow, 0 18px 42px rgba(15, 23, 42, 0.12));
|
||||
}
|
||||
|
||||
.external-playback-dock--error {
|
||||
border-color: color-mix(in srgb, var(--mat-sys-error) 50%, transparent);
|
||||
background:
|
||||
linear-gradient(
|
||||
135deg,
|
||||
color-mix(in srgb, var(--mat-sys-error-container) 72%, transparent),
|
||||
color-mix(in srgb, var(--mat-sys-surface-container) 90%, transparent)
|
||||
);
|
||||
border-color: color-mix(
|
||||
in srgb,
|
||||
var(--app-expiry-expired-text, #b3261e) 50%,
|
||||
transparent
|
||||
);
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
var(--app-expiry-expired-surface, rgba(244, 67, 54, 0.14)),
|
||||
var(--app-widget-bg, #fff)
|
||||
);
|
||||
}
|
||||
|
||||
.external-playback-dock--compact {
|
||||
@@ -37,9 +37,7 @@
|
||||
padding: 8px 10px;
|
||||
gap: 10px;
|
||||
border-radius: 16px;
|
||||
box-shadow:
|
||||
0 12px 28px rgba(15, 23, 42, 0.16),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.18);
|
||||
box-shadow: var(--app-widget-shadow, 0 12px 28px rgba(15, 23, 42, 0.16));
|
||||
|
||||
.external-playback-dock__artwork {
|
||||
width: 38px;
|
||||
@@ -63,7 +61,7 @@
|
||||
border: 0;
|
||||
border-radius: 14px;
|
||||
overflow: hidden;
|
||||
background: color-mix(in srgb, var(--mat-sys-surface-container-highest) 92%, transparent);
|
||||
background: var(--app-widget-header-bg, #fafbfc);
|
||||
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.18);
|
||||
color: inherit;
|
||||
cursor: default;
|
||||
@@ -93,7 +91,7 @@
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--mat-sys-primary);
|
||||
outline: 2px solid var(--app-selection-color, #2f7bff);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
@@ -102,7 +100,7 @@
|
||||
.external-playback-dock__placeholder {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: var(--mat-sys-on-surface-variant);
|
||||
color: var(--app-body-color, #4a5060);
|
||||
}
|
||||
|
||||
.external-playback-dock__meta {
|
||||
@@ -114,14 +112,9 @@
|
||||
align-items: center;
|
||||
height: 18px;
|
||||
border-radius: 999px;
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--mat-sys-surface-container-highest) 80%,
|
||||
transparent
|
||||
);
|
||||
color: var(--mat-sys-on-surface-variant);
|
||||
border: 1px solid
|
||||
color-mix(in srgb, var(--mat-sys-outline-variant) 70%, transparent);
|
||||
background: var(--app-card-hover-bg, #f0f2f5);
|
||||
color: var(--app-body-color, #4a5060);
|
||||
border: 1px solid var(--app-widget-header-border, rgba(0, 0, 0, 0.08));
|
||||
font-size: 0.66rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
@@ -133,7 +126,7 @@
|
||||
white-space: nowrap;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 700;
|
||||
color: var(--mat-sys-on-surface);
|
||||
color: var(--app-heading-color, #1a1d23);
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
@@ -141,7 +134,7 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: var(--mat-sys-on-surface-variant);
|
||||
color: var(--app-body-color, #4a5060);
|
||||
font-size: 0.78rem;
|
||||
min-width: 0;
|
||||
|
||||
@@ -179,6 +172,10 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.external-playback-dock__dismiss {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.external-playback-dock {
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
|
||||
+88
-1
@@ -17,6 +17,11 @@ class FakeTranslateLoader implements TranslateLoader {
|
||||
WORKSPACE: {
|
||||
SHELL: {
|
||||
EXTERNAL_PLAYBACK_CLOSE: 'Close player',
|
||||
EXTERNAL_PLAYBACK_DISMISS: 'Dismiss',
|
||||
EXTERNAL_PLAYBACK_OPENING: 'Opening player…',
|
||||
EXTERNAL_PLAYBACK_STARTED: 'Player started',
|
||||
EXTERNAL_PLAYBACK_PLAYING: 'Playing',
|
||||
EXTERNAL_PLAYBACK_FAILED: 'External player error',
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -65,8 +70,18 @@ describe('ExternalPlaybackDockComponent', () => {
|
||||
const text = fixture.nativeElement.textContent;
|
||||
expect(text).toContain('Example Video');
|
||||
expect(text).toContain('MPV');
|
||||
expect(text).toContain('Launching');
|
||||
expect(text).toContain('Opening player');
|
||||
expect(text).toContain('Close player');
|
||||
expect(
|
||||
fixture.nativeElement
|
||||
.querySelector('.external-playback-dock')
|
||||
.getAttribute('aria-busy')
|
||||
).toBe('true');
|
||||
expect(
|
||||
fixture.nativeElement
|
||||
.querySelector('.external-playback-dock__status')
|
||||
.getAttribute('aria-live')
|
||||
).toBe('polite');
|
||||
});
|
||||
|
||||
it('emits a single close action when the close button is clicked', () => {
|
||||
@@ -96,6 +111,78 @@ describe('ExternalPlaybackDockComponent', () => {
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['opened', 'Player started'],
|
||||
['playing', 'Playing'],
|
||||
] as const)('renders exact %s status copy', (status, expected) => {
|
||||
fixture.componentRef.setInput('session', { ...session, status });
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(fixture.nativeElement.textContent).toContain(expected);
|
||||
expect(
|
||||
fixture.nativeElement
|
||||
.querySelector('.external-playback-dock')
|
||||
.getAttribute('aria-busy')
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('keeps a failed session visible with a safe dismiss action and no retry', () => {
|
||||
const dismissSpy = jest.fn();
|
||||
component.dismissClicked.subscribe(dismissSpy);
|
||||
fixture.componentRef.setInput('session', {
|
||||
...session,
|
||||
status: 'error',
|
||||
error: '',
|
||||
canClose: false,
|
||||
});
|
||||
fixture.detectChanges();
|
||||
|
||||
const action = fixture.debugElement.query(
|
||||
By.css('.external-playback-dock__button')
|
||||
);
|
||||
expect(fixture.nativeElement.textContent).toContain(
|
||||
'External player error'
|
||||
);
|
||||
expect(action.nativeElement.textContent).toContain('Dismiss');
|
||||
expect(action.nativeElement.textContent).not.toContain('Retry');
|
||||
expect(action.nativeElement.getAttribute('aria-label')).toBe('Dismiss');
|
||||
expect(action.nativeElement.classList).toContain('mat-mdc-button');
|
||||
expect(action.nativeElement.classList).not.toContain(
|
||||
'mat-mdc-icon-button'
|
||||
);
|
||||
|
||||
action.nativeElement.click();
|
||||
expect(dismissSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('preserves the only stop affordance when an errored process may still be alive', () => {
|
||||
const closeSpy = jest.fn();
|
||||
const dismissSpy = jest.fn();
|
||||
component.closeClicked.subscribe(closeSpy);
|
||||
component.dismissClicked.subscribe(dismissSpy);
|
||||
fixture.componentRef.setInput('session', {
|
||||
...session,
|
||||
status: 'error',
|
||||
error: 'Process exit was not confirmed',
|
||||
canClose: true,
|
||||
});
|
||||
fixture.detectChanges();
|
||||
|
||||
const stop = fixture.debugElement.query(
|
||||
By.css('.external-playback-dock__button')
|
||||
);
|
||||
const dismiss = fixture.debugElement.query(
|
||||
By.css('.external-playback-dock__dismiss')
|
||||
);
|
||||
|
||||
expect(stop.nativeElement.textContent).toContain('Close player');
|
||||
expect(dismiss).toBeNull();
|
||||
|
||||
stop.nativeElement.click();
|
||||
expect(closeSpy).toHaveBeenCalledTimes(1);
|
||||
expect(dismissSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('disables the artwork button when the session has no playlist target', () => {
|
||||
const artwork = fixture.debugElement.query(
|
||||
By.css('.external-playback-dock__artwork')
|
||||
|
||||
+19
-7
@@ -35,6 +35,7 @@ export class ExternalPlaybackDockComponent {
|
||||
readonly compact = input(false);
|
||||
|
||||
readonly closeClicked = output<void>();
|
||||
readonly dismissClicked = output<void>();
|
||||
readonly artworkClicked = output<void>();
|
||||
private readonly artworkFailed = signal(false);
|
||||
|
||||
@@ -55,25 +56,27 @@ export class ExternalPlaybackDockComponent {
|
||||
readonly artworkUrl = computed(
|
||||
() => this.session().thumbnail?.trim() ?? ''
|
||||
);
|
||||
readonly statusLabel = computed(() => {
|
||||
readonly statusLabelKey = computed(() => {
|
||||
const session = this.session();
|
||||
|
||||
switch (session.status) {
|
||||
case 'launching':
|
||||
return 'Launching…';
|
||||
return 'WORKSPACE.SHELL.EXTERNAL_PLAYBACK_OPENING';
|
||||
case 'opened':
|
||||
return 'WORKSPACE.SHELL.EXTERNAL_PLAYBACK_STARTED';
|
||||
case 'playing':
|
||||
return 'Opened';
|
||||
return 'WORKSPACE.SHELL.EXTERNAL_PLAYBACK_PLAYING';
|
||||
case 'error':
|
||||
return session.error || 'Playback failed';
|
||||
return 'WORKSPACE.SHELL.EXTERNAL_PLAYBACK_FAILED';
|
||||
default:
|
||||
return 'Closed';
|
||||
return 'WORKSPACE.SHELL.EXTERNAL_PLAYBACK_CLOSED';
|
||||
}
|
||||
});
|
||||
|
||||
readonly statusIcon = computed(() => {
|
||||
const status = this.session().status;
|
||||
if (status === 'error') return 'error_outline';
|
||||
if (status === 'playing') return 'play_circle';
|
||||
return 'open_in_new';
|
||||
});
|
||||
|
||||
@@ -101,8 +104,9 @@ export class ExternalPlaybackDockComponent {
|
||||
readonly artworkInteractive = computed(
|
||||
() => !!this.session().contentInfo?.playlistId
|
||||
);
|
||||
readonly showCloseButton = computed(
|
||||
() => this.session().canClose && this.session().status !== 'error'
|
||||
readonly showCloseAction = computed(() => this.session().canClose);
|
||||
readonly showDismissAction = computed(
|
||||
() => this.session().status === 'error' && !this.session().canClose
|
||||
);
|
||||
|
||||
constructor() {
|
||||
@@ -120,4 +124,12 @@ export class ExternalPlaybackDockComponent {
|
||||
if (!this.artworkInteractive()) return;
|
||||
this.artworkClicked.emit();
|
||||
}
|
||||
|
||||
onCloseClick(): void {
|
||||
this.closeClicked.emit();
|
||||
}
|
||||
|
||||
onDismissClick(): void {
|
||||
this.dismissClicked.emit();
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user