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
7 changes: 0 additions & 7 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,6 @@ jobs:
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
# Full history so `changeset status` can diff against the base branch.
fetch-depth: 0
persist-credentials: false

# Reads the pnpm version from `packageManager` in package.json.
Expand All @@ -50,8 +48,3 @@ jobs:

# Biome (lint + format), then build, types, tests and bundle size via Turbo.
- run: pnpm run ci

# Fails if a package changed without a changeset. Changes that must not
# release anything (docs, CI) can add an empty one: `pnpm changeset --empty`.
- name: Check for a changeset
run: pnpm changeset status --since="origin/${{ github.base_ref }}"
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ Run from the repo root (Turborepo fans out to every workspace):
- `pnpm build` / `pnpm check-types` / `pnpm test` / `pnpm size`
- `pnpm lint` / `pnpm format`: Biome check / check with `--write`
- `pnpm ci`: what CI runs on every PR (`biome ci` + build, types, tests, size)
- `pnpm changeset`: add a changeset. CI fails on a PR that changes a package without one (`pnpm changeset --empty` for changes that release nothing)
- `pnpm changeset`: add a changeset. Only changes with a changeset get released
- One package: `pnpm --filter @rustrak/openshowcase-<name> <script>`
- Extension dev: `pnpm --filter @rustrak/openshowcase-extension dev`, then load `apps/extension/.output/chrome-mv3` unpacked in `chrome://extensions`

Expand Down
44 changes: 44 additions & 0 deletions packages/player-core/src/__test__/integration/Player.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ describe("Player (mount.ts) integration", () => {
});

afterEach(() => {
vi.restoreAllMocks();
player?.destroy();
container.remove();
});
Expand Down Expand Up @@ -133,6 +134,49 @@ describe("Player (mount.ts) integration", () => {
expect(player.currentIndex).toBe(2);
});

it("reveals the photo's hotspot again after seeking back to the video step before it", async () => {
// no real clip to decode here: play() is stubbed and the video step is ended by hand
vi.spyOn(HTMLMediaElement.prototype, "play").mockResolvedValue();
const onStepChange = vi.fn();
player = new Player({
container,
onStepChange,
demo: {
id: "demo-2",
title: "Video then photo",
theme: { wrapper: "none", autoplay: false, appearance: "light" },
video: {
src: "data:video/webm;base64,",
width: 800,
height: 600,
durationSec: 2,
},
steps: [
{ id: "clip", type: "video", startTime: 0, endTime: 1 },
{
id: "photo",
type: "photo",
image: { src: TINY_PNG, width: 800, height: 600 },
hotspot: { x: 0.5, y: 0.5, label: "Click here" },
},
],
},
});
player.mount();
const hotspot = () => container.querySelector('[aria-label="Hotspot"]');
// wait out the initial render before navigating (see the background-click test)
await vi.waitFor(() => expect(onStepChange).toHaveBeenCalled());

player.next();
await vi.waitFor(() => expect(hotspot()).not.toBeNull());

player.goTo(0);
await vi.waitFor(() => expect(hotspot()).toBeNull());

player.next();
await vi.waitFor(() => expect(hotspot()).not.toBeNull());
});

it("destroys cleanly, leaving the container empty", async () => {
player = new Player({ container, demo: createDemo() });
player.mount();
Expand Down
16 changes: 11 additions & 5 deletions packages/player-core/src/components/PhotoLayer.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,12 @@ interface Props {
stageSize: { width: number; height: number };
hotspot?: PhotoHotspotVisual;
tooltip?: PhotoTooltipVisual;
/** Fires once per `src` change, after the browser has decoded the new frame (or failed to) —
* the signal the parent waits for before swapping video/photo visibility, so the swap never
* flashes a not-yet-decoded frame. */
/** Bumped by the parent for every photo step it shows, so showing the same `src` again (e.g.
* returning to a photo after replaying the video before it) still fires `onReady`. */
renderId?: number;
/** Fires once per `src`/`renderId` change, after the browser has decoded the new frame (or
* failed to) — the signal the parent waits for before swapping video/photo visibility, so the
* swap never flashes a not-yet-decoded frame. */
onReady?: () => void;
/** Clicking the hotspot/tooltip themselves always means "seen it, continue". */
onHotspotAdvance?: () => void;
Expand All @@ -41,6 +44,7 @@ let {
stageSize,
hotspot,
tooltip,
renderId = 0,
onReady,
onHotspotAdvance,
}: Props = $props();
Expand All @@ -59,8 +63,10 @@ let lastShown: { src: string; transform: string; visible: boolean } | null =
null;

$effect(() => {
// re-run whenever `src` changes; decode (success or failure) is the "ready to reveal" signal.
// The cleanup guards against a superseded decode (rapid navigation) reporting ready late.
// re-run whenever `src` or `renderId` changes; decode (success or failure) is the "ready to
// reveal" signal. The cleanup guards against a superseded decode (rapid navigation) reporting
// ready late.
void renderId;
const current = src;
const el = imgEl;
if (!el) return;
Expand Down
5 changes: 5 additions & 0 deletions packages/player-core/src/components/Player.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,8 @@ let stageSize = $state({ width: 0, height: 0 });
let photo = $state<{
visible: boolean;
src: string;
/** Bumped per photo step render: PhotoLayer re-reports ready even when `src` is unchanged. */
renderId: number;
alt: string;
transform: string;
transformInstant: boolean;
Expand All @@ -86,6 +88,7 @@ let photo = $state<{
}>({
visible: false,
src: "",
renderId: 0,
alt: "",
transform: IDENTITY_ZOOM_TRANSFORM,
transformInstant: false,
Expand Down Expand Up @@ -308,6 +311,7 @@ function renderPhotoStep(step: PhotoStep, previousIndex: number): void {
overlay.hide();
}
photo.src = resolveAssetUrl(step.image.src, assetBaseUrl);
photo.renderId += 1;
photo.alt = step.hotspot?.label ?? `Step ${index + 1}`;
// the actual reveal happens in handlePhotoReady, once PhotoLayer's decode resolves
}
Expand Down Expand Up @@ -485,6 +489,7 @@ onMount(() => {
<PhotoLayer
visible={photo.visible}
src={photo.src}
renderId={photo.renderId}
alt={photo.alt}
transform={photo.transform}
transformInstant={photo.transformInstant}
Expand Down
2 changes: 1 addition & 1 deletion turbo.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
"dependsOn": ["^build"]
},
"size": {
"dependsOn": ["^build"]
"dependsOn": ["build"]
},
"dev": {
"dependsOn": ["^build"],
Expand Down
Loading