fix(playback): clarify external launch feedback

This commit is contained in:
4gray committed 2026-08-09 12:52:05 +02:00
1 parent c189acf147
commit 7ed1ccfd8e
50 files changed
+2254 -202

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,39 +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 (showActionButton()) {
<button
type="button"
mat-button
class="external-playback-dock__button"
(click)="closeClicked.emit()"
[attr.aria-label]="actionLabelKey() | translate"
(click)="onActionClick()"
>
<mat-icon>stop_circle</mat-icon>
<mat-icon aria-hidden="true">{{ actionIcon() }}</mat-icon>
@if (!compact()) {
<span>{{
'WORKSPACE.SHELL.EXTERNAL_PLAYBACK_CLOSE' | translate
}}</span>
<span>{{ actionLabelKey() | translate }}</span>
}
</button>
}
@@ -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: 'Playback failed',
},
},
});
@@ -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,44 @@ 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('Playback failed');
expect(action.nativeElement.textContent).toContain('Dismiss');
expect(action.nativeElement.textContent).not.toContain('Retry');
expect(action.nativeElement.getAttribute('aria-label')).toBe('Dismiss');
action.nativeElement.click();
expect(dismissSpy).toHaveBeenCalledTimes(1);
});
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,16 @@ export class ExternalPlaybackDockComponent {
readonly artworkInteractive = computed(
() => !!this.session().contentInfo?.playlistId
);
readonly showCloseButton = computed(
() => this.session().canClose && this.session().status !== 'error'
readonly showActionButton = computed(
() => this.session().status === 'error' || this.session().canClose
);
readonly actionLabelKey = computed(() =>
this.session().status === 'error'
? 'WORKSPACE.SHELL.EXTERNAL_PLAYBACK_DISMISS'
: 'WORKSPACE.SHELL.EXTERNAL_PLAYBACK_CLOSE'
);
readonly actionIcon = computed(() =>
this.session().status === 'error' ? 'close' : 'stop_circle'
);
constructor() {
@@ -120,4 +131,12 @@ export class ExternalPlaybackDockComponent {
if (!this.artworkInteractive()) return;
this.artworkClicked.emit();
}
onActionClick(): void {
if (this.session().status === 'error') {
this.dismissClicked.emit();
return;
}
this.closeClicked.emit();
}
}