Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/copilot-instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,3 +53,4 @@
- Run the smallest relevant validation for the change when possible.
- For documentation changes, prefer `pnpm run ci:docs` and the relevant Storybook build.
- For broader changes, use the repo validation commands already documented in Storybook contribution guidance.
- Keep coverage thresholds unchanged, but aim to stay at least 5 percentage points above the enforced minima when practical (currently branch >= 85% and functions >= 95%).
4 changes: 3 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -14,4 +14,6 @@ vitest.config.*.timestamp*
/dist/
/coverage
/artifacts/perf/**
!/artifacts/perf/README.md
!/artifacts/perf/README.md
.nx/polygraph
.nx/self-healing
3 changes: 2 additions & 1 deletion .prettierignore
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
# Add files here to ignore them from prettier formatting
/coverage
/.nx/cache
/.nx/workspace-data
/.nx/workspace-data
.nx/self-healing
27 changes: 25 additions & 2 deletions apps/demo/src/app/app.element.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,8 +65,8 @@ const createAppTemplate = ({
</a>
</div>
<div class="file-select-container" data-role="file-select-container">
<label class="file-picker" for="track-input">Select a karaoke zip (.zip)</label>
<input id="track-input" type="file" accept=".zip,application/zip" />
<label class="file-picker" for="track-input">Select audio or karaoke zip (audio/*, .zip)</label>
<input id="track-input" type="file" accept="audio/*,.zip,application/zip" />
${showPerfDiagnostics ? '<button type="button" class="perf-export-button" data-role="perf-export">Export speed report (.json)</button>' : ''}
</div>

Expand Down Expand Up @@ -147,6 +147,7 @@ export class AppElement extends HTMLElement {
private controlsModel: CdgControlsModel | null = null;
private controlsParts: DisposableControl[] = [];
private controlsUnsubscribe: (() => void) | null = null;
private canvasElement: HTMLCanvasElement | null = null;
private statusElement: HTMLElement | null = null;
private titleImage: HTMLElement | null = null;
private titleMeta: HTMLElement | null = null;
Expand All @@ -158,6 +159,7 @@ export class AppElement extends HTMLElement {
private lastStatus: string | null = null;
private statusFadeTimeoutId: number | null = null;
private hasPlaybackStarted = false;
private hasGraphicsTrack = true;
private readonly showPerfDiagnostics = isLocalDevelopmentRuntime();
private renderSamples: RenderSample[] = [];

Expand Down Expand Up @@ -205,6 +207,7 @@ export class AppElement extends HTMLElement {
const canvas = this.querySelector<HTMLCanvasElement>(
'[data-role="canvas"]',
);
this.canvasElement = canvas;
const audio = this.querySelector<HTMLAudioElement>('[data-role="audio"]');
const transportContainer = this.querySelector<HTMLElement>(
'[data-role="transport-bar"]',
Expand Down Expand Up @@ -341,6 +344,7 @@ export class AppElement extends HTMLElement {
}
this.syncTitleImage('idle');
this.syncLayout('idle');
this.syncStageGraphicsVisibility();

stage.addEventListener('click', (event) => {
// Stage click toggles play/pause, except when interacting with floating settings.
Expand Down Expand Up @@ -376,7 +380,9 @@ export class AppElement extends HTMLElement {

this.player.stop();
this.hasPlaybackStarted = false;
this.hasGraphicsTrack = true;
this.setTitleMetadata(null);
this.syncStageGraphicsVisibility();
this.syncTitleImage('loading');
this.syncLayout('loading');
this.setStatusMessage('Loading track...');
Expand Down Expand Up @@ -406,6 +412,9 @@ export class AppElement extends HTMLElement {
});
}

this.hasGraphicsTrack = loadedTrack?.hasGraphics ?? false;
this.syncStageGraphicsVisibility();

this.hasPlaybackStarted = false;
this.syncTitleImage('ready');
this.setStatusMessage('Track loaded.');
Expand All @@ -423,6 +432,8 @@ export class AppElement extends HTMLElement {
errorMessage: message,
});
this.setStatusMessage(`Load failed: ${message}`);
this.hasGraphicsTrack = true;
this.syncStageGraphicsVisibility();
this.syncTitleImage('error');
this.syncLayout('error');
})
Expand Down Expand Up @@ -556,6 +567,7 @@ export class AppElement extends HTMLElement {
const shouldShowPlayer = status !== 'idle';
this.appShell.classList.toggle('show-player', shouldShowPlayer);
this.appShell.classList.toggle('is-playing', status === 'playing');
this.appShell.classList.toggle('has-graphics', this.hasGraphicsTrack);

if (status === 'ready' || status === 'playing' || status === 'paused') {
this.appShell.classList.add('has-track');
Expand All @@ -565,6 +577,17 @@ export class AppElement extends HTMLElement {
this.appShell.classList.remove('has-track');
}

/**
* Hides canvas when the loaded track has no graphics stream.
*/
private syncStageGraphicsVisibility(): void {
if (!this.canvasElement) {
return;
}

this.canvasElement.style.display = this.hasGraphicsTrack ? 'block' : 'none';
}

/**
* Records one render telemetry event, keeps the sample window bounded,
* then refreshes all diagnostics outputs (global artifact, HUD, export state).
Expand Down
8 changes: 6 additions & 2 deletions apps/framework-demo/src/App.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -231,7 +231,9 @@ describe('App', () => {
fireEvent.click(canvas.parentElement);
expect(harness.controlsModel.togglePlayPause).toHaveBeenCalledTimes(2);

const fileInput = screen.getByLabelText('Select a karaoke zip (.zip)');
const fileInput = screen.getByLabelText(
'Select audio or karaoke zip (audio/*, .zip)',
);
const file = new File(['zip-content'], 'demo-track.zip', {
type: 'application/zip',
});
Expand Down Expand Up @@ -310,7 +312,9 @@ describe('App', () => {
.disabled,
).toBe(true);

const fileInput = screen.getByLabelText('Select a karaoke zip (.zip)');
const fileInput = screen.getByLabelText(
'Select audio or karaoke zip (audio/*, .zip)',
);
const file = new File(['zip-content'], 'broken-track.zip', {
type: 'application/zip',
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ const createContextValue = (overrides: Record<string, unknown> = {}) => ({
},
titleMetadata: null,
setTitleMetadata: vi.fn(),
hasGraphicsTrack: true,
setHasGraphicsTrack: vi.fn(),
perfSummary: null,
hasTrack: false,
showTitle: false,
Expand Down Expand Up @@ -119,7 +121,9 @@ describe('Framework demo components', () => {

render(<FilePickerRow />);

const input = screen.getByLabelText('Select a karaoke zip (.zip)');
const input = screen.getByLabelText(
'Select audio or karaoke zip (audio/*, .zip)',
);
fireEvent.change(input, { target: { files: [] } });
expect(player.stop).not.toHaveBeenCalled();

Expand Down Expand Up @@ -255,4 +259,25 @@ describe('Framework demo components', () => {
screen.getByText(/does not support CSS Anchor Positioning/i),
).toBeTruthy();
});

it('renders audio-only stage state with hidden canvas and placeholder perf copy', () => {
mockUseFrameworkDemoContext.mockReturnValue(
createContextValue({
hasGraphicsTrack: false,
hasTrack: true,
showTitle: false,
showPerfDiagnostics: true,
perfSummary: null,
isStatusVisible: false,
}),
);

const { container } = render(<StageDisplay />);

const canvas = container.querySelector('canvas');
expect(canvas?.className).toMatch(/canvasHidden/);
expect(
screen.getByText('Speed check: play a song to collect samples...'),
).toBeTruthy();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ const createContextValue = (overrides: Record<string, unknown> = {}) => ({
player: null,
controlsModel: null,
setTitleMetadata: vi.fn(),
setHasGraphicsTrack: vi.fn(),
showPerfDiagnostics: false,
perfSummary: null,
showStatusMessage: vi.fn(),
Expand Down Expand Up @@ -84,6 +85,40 @@ describe('Framework demo hook edge cases', () => {
expect(controlsModel.setPitchSemitones).not.toHaveBeenCalled();
});

it('parses valid settings values even when no controls model is available', () => {
mockUseFrameworkDemoContext.mockReturnValue(
createContextValue({
controlsModel: null,
viewState: {
status: 'ready',
trackId: 'track',
currentTimeMs: 0,
durationMs: 120_000,
volume: 0.5,
playbackRate: 1,
pitchSemitones: 0,
isPlayable: true,
isPlaying: false,
progressPercent: 0,
},
}),
);

const { result } = renderHook(() => useSettingsPanelProps());

expect(() => {
result.current.handleSetVolume({
target: { value: '0.75' },
} as never);
result.current.handleSetTempo({
target: { value: '1.25' },
} as never);
result.current.handleSetPitchSemitones({
target: { value: '-4' },
} as never);
}).not.toThrow();
});

it('ignores transport actions when the model is missing or seek input is invalid', () => {
mockUseFrameworkDemoContext.mockReturnValue(
createContextValue({
Expand Down Expand Up @@ -137,6 +172,34 @@ describe('Framework demo hook edge cases', () => {
expect(controlsModel.seekPercent).not.toHaveBeenCalled();
});

it('parses valid seek input even when the controls model is missing', () => {
mockUseFrameworkDemoContext.mockReturnValue(
createContextValue({
controlsModel: null,
viewState: {
status: 'ready',
trackId: 'track',
currentTimeMs: 15_000,
durationMs: 60_000,
volume: 1,
playbackRate: 1,
pitchSemitones: 0,
isPlayable: true,
isPlaying: false,
progressPercent: 25,
},
}),
);

const { result } = renderHook(() => useTransportBarProps());

expect(() => {
result.current.handleSeekPercent({
target: { value: '42.5' },
} as never);
}).not.toThrow();
});

it('handles absent players and non-Error load failures in the file picker hook', async () => {
const showStatusMessage = vi.fn();

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import useFilePickerRowProps from './hooks/useFilePickerRowProps.memo';
import styles from './FilePickerRow.module.css';

/**
* File input row for loading karaoke zip files and exporting diagnostics.
* File input row for loading browser-supported audio or karaoke zip files.
*/
function FilePickerRow() {
const {
Expand All @@ -15,11 +15,13 @@ function FilePickerRow() {
return (
// This row is intentionally simple: choose file + optional speed-report export.
<div className={styles.filePickerRow}>
<label htmlFor="track-input">Select a karaoke zip (.zip)</label>
<label htmlFor="track-input">
Select audio or karaoke zip (audio/*, .zip)
</label>
<input
id="track-input"
type="file"
accept=".zip,application/zip"
accept="audio/*,.zip,application/zip"
onChange={handleTrackSelect}
/>
{showPerfDiagnostics ? (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ function useFilePickerRowProps(): FilePickerRowResolvedProps {
const {
player,
setTitleMetadata,
setHasGraphicsTrack,
showPerfDiagnostics,
perfSummary,
showStatusMessage,
Expand All @@ -37,6 +38,7 @@ function useFilePickerRowProps(): FilePickerRowResolvedProps {
player.stop();
resetPlaybackStarted();
setTitleMetadata(null);
setHasGraphicsTrack(true);
showStatusMessage('Loading track...');

try {
Expand All @@ -57,6 +59,8 @@ function useFilePickerRowProps(): FilePickerRowResolvedProps {
});
}

setHasGraphicsTrack(loadedTrack?.hasGraphics ?? false);

showStatusMessage('Track loaded.');
} catch (errorValue: unknown) {
// Keep status user-friendly while preserving details through Error.message.
Expand All @@ -67,7 +71,13 @@ function useFilePickerRowProps(): FilePickerRowResolvedProps {
showStatusMessage(`Load failed: ${message}`);
}
},
[player, resetPlaybackStarted, setTitleMetadata, showStatusMessage],
[
player,
resetPlaybackStarted,
setTitleMetadata,
setHasGraphicsTrack,
showStatusMessage,
],
);

const handleTrackSelect = useCallback(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ function StageDisplay({ children }: StageDisplayProps) {
isStatusVisible,
viewState,
titleMetadata,
hasGraphicsTrack,
perfSummary,
hasTrack,
showTitle,
Expand Down Expand Up @@ -53,7 +54,12 @@ function StageDisplay({ children }: StageDisplayProps) {
) : null}
</div>
) : null}
<canvas ref={canvasRef} width={300} height={216} />
<canvas
ref={canvasRef}
width={300}
height={216}
className={hasGraphicsTrack ? undefined : styles.canvasHidden}
/>
<div
className={`${styles.status}${isStatusVisible || compatibilityWarning ? ` ${styles.isVisible}` : ''}`}
>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
@layer components {
/*
* These top-level selectors are state/marker classes consumed by TSX.
* Visual behavior is defined in nested rules below (for example &.hasTrack).
*/
.hasTrack {
}

Expand All @@ -20,6 +24,10 @@
.compatibilityWarning {
}

/* Applied to canvas in audio-only mode; rule is scoped under .stage below. */
.canvasHidden {
}

.stageWrap {
--cdg-panel-opacity: 0;
--cdg-panel-visibility: hidden;
Expand Down Expand Up @@ -133,6 +141,10 @@
background: var(--cdg-color-stage);
image-rendering: pixelated;
}

.canvasHidden {
display: none;
}
}

.titleImage {
Expand Down
Loading
Loading