fix(playback): clarify external player launch feedback (#1388)

This commit is contained in:
4gray authored and GitHub committed 2026-08-09 13:33:07 +02:00
1 parent 8442747c37
commit d73acd6bfc
101 files changed
+8819 -850

No files matched your search

@@ -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>
@@ -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;
@@ -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')
@@ -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();
}
}