@revenuecat/purchases-ui-js 4.1.0 → 4.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (30) hide show
  1. package/dist/components/carousel/Carousel.stories.svelte +29 -0
  2. package/dist/components/carousel/Carousel.svelte +74 -8
  3. package/dist/components/paywall/BackgroundVideoSurface.svelte +162 -0
  4. package/dist/components/paywall/BackgroundVideoSurface.svelte.d.ts +12 -0
  5. package/dist/components/paywall/Paywall.stories.svelte +133 -0
  6. package/dist/components/paywall/Sheet.svelte +66 -3
  7. package/dist/components/paywall/ViewportBackdrop.svelte +40 -9
  8. package/dist/components/paywall/fixtures/sheet-video-stacking-paywall.d.ts +6 -0
  9. package/dist/components/paywall/fixtures/sheet-video-stacking-paywall.js +42 -0
  10. package/dist/components/stack/Stack.stories.svelte +74 -0
  11. package/dist/components/stack/Stack.svelte +58 -3
  12. package/dist/components/tabs/Tabs.stories.svelte +27 -0
  13. package/dist/components/tabs/Tabs.svelte +69 -4
  14. package/dist/components/tabs/tabs-video-stacking-story-args.d.ts +3 -0
  15. package/dist/components/tabs/tabs-video-stacking-story-args.js +131 -0
  16. package/dist/components/video/Video.svelte +32 -56
  17. package/dist/stories/video-background-story-fixture.d.ts +3 -0
  18. package/dist/stories/video-background-story-fixture.js +36 -0
  19. package/dist/types/background.d.ts +9 -1
  20. package/dist/ui/molecules/button.svelte +2 -2
  21. package/dist/utils/background-utils.d.ts +30 -1
  22. package/dist/utils/background-utils.js +53 -5
  23. package/dist/utils/match-media-compat.d.ts +4 -0
  24. package/dist/utils/match-media-compat.js +11 -0
  25. package/dist/utils/video-background-host.d.ts +31 -0
  26. package/dist/utils/video-background-host.js +25 -0
  27. package/dist/utils/video-inline-playback.d.ts +25 -0
  28. package/dist/utils/video-inline-playback.js +78 -0
  29. package/dist/web-components/index.css +1 -1
  30. package/package.json +1 -1
@@ -0,0 +1,6 @@
1
+ import type { PaywallData } from "../../../types/paywall";
2
+ /**
3
+ * Minimal variant of {@link SHEET_PAYWALL} with a sheet-level video background for
4
+ * Storybook z-order regression checks (`rc-sheet-video-bg`).
5
+ */
6
+ export declare const SHEET_PAYWALL_VIDEO_STACKING: PaywallData;
@@ -0,0 +1,42 @@
1
+ import { STORY_BACKGROUND_VIDEO_FILL } from "../../../stories/video-background-story-fixture";
2
+ import { SHEET_PAYWALL } from "./sheet-paywall";
3
+ /**
4
+ * Minimal variant of {@link SHEET_PAYWALL} with a sheet-level video background for
5
+ * Storybook z-order regression checks (`rc-sheet-video-bg`).
6
+ */
7
+ export const SHEET_PAYWALL_VIDEO_STACKING = (() => {
8
+ const data = structuredClone(SHEET_PAYWALL);
9
+ data.id = "sheet_paywall_video_stacking";
10
+ data.components_localizations ??= {};
11
+ data.components_localizations.en_US ??= {};
12
+ Object.assign(data.components_localizations.en_US, {
13
+ "5GWqi3MXpt": "Content above tinted video",
14
+ E6CK5Xd1cE: "Open sheet · video backdrop",
15
+ });
16
+ const first = data.components_config.base.stack.components[0];
17
+ if (!first || first.type !== "button") {
18
+ return data;
19
+ }
20
+ const { action } = first;
21
+ if (!action ||
22
+ action.type !== "navigate_to" ||
23
+ action.destination !== "sheet") {
24
+ return data;
25
+ }
26
+ const { sheet } = action;
27
+ if (!sheet) {
28
+ return data;
29
+ }
30
+ sheet.background = STORY_BACKGROUND_VIDEO_FILL;
31
+ sheet.stack.background = null;
32
+ const headline = sheet.stack.components[0];
33
+ if (headline?.type === "text") {
34
+ headline.horizontal_alignment = "center";
35
+ headline.color = {
36
+ light: { type: "hex", value: "#ffffff" },
37
+ };
38
+ headline.font_size = "heading_m";
39
+ headline.font_weight = "semibold";
40
+ }
41
+ return data;
42
+ })();
@@ -6,6 +6,7 @@
6
6
  import { localizationDecorator } from "../../stories/localization-decorator";
7
7
  import type { StackProps } from "../../types/components/stack";
8
8
  import type { Component } from "../../types/component";
9
+ import { STORY_BACKGROUND_VIDEO_FILL } from "../../stories/video-background-story-fixture";
9
10
  import { DEFAULT_TEXT_COLOR } from "../../utils/constants";
10
11
  import { defineMeta } from "@storybook/addon-svelte-csf";
11
12
 
@@ -153,6 +154,79 @@
153
154
  }}
154
155
  />
155
156
 
157
+ <Story
158
+ name="Surface — video background"
159
+ parameters={{
160
+ chromatic: { disableSnapshot: true },
161
+ docs: {
162
+ description: {
163
+ story:
164
+ "**Z-order:** `BackgroundVideoSurface` (`z-index: 1`), `::before` tint (`z-index: 2`), siblings (`z-index: 3`). Content and controls should remain sharp above the tint; motion is disabled in Chromatic.",
165
+ },
166
+ },
167
+ }}
168
+ args={{
169
+ size: {
170
+ width: { type: "fixed", value: 380 },
171
+ height: { type: "fixed", value: 260 },
172
+ },
173
+ shape: {
174
+ type: "rectangle",
175
+ corners: {
176
+ top_leading: 20,
177
+ top_trailing: 20,
178
+ bottom_leading: 20,
179
+ bottom_trailing: 20,
180
+ },
181
+ },
182
+ background: STORY_BACKGROUND_VIDEO_FILL,
183
+ components: [
184
+ {
185
+ type: "text",
186
+ size: {
187
+ width: { type: "fit" },
188
+ height: { type: "fit" },
189
+ },
190
+ horizontal_alignment: "center",
191
+ name: "Item 1",
192
+ id: "vbg-1",
193
+ text_lid: "id1",
194
+ color: {
195
+ light: { type: "hex", value: "#ffffff" },
196
+ },
197
+ font_name: null,
198
+ font_size: "heading_s",
199
+ font_weight: "bold",
200
+ background_color: {
201
+ dark: { type: "alias", value: "transparent" },
202
+ light: { type: "alias", value: "transparent" },
203
+ },
204
+ },
205
+ {
206
+ type: "text",
207
+ size: {
208
+ width: { type: "fill" },
209
+ height: { type: "fit" },
210
+ },
211
+ horizontal_alignment: "center",
212
+ name: "Item 2",
213
+ id: "vbg-2",
214
+ text_lid: "id2",
215
+ color: {
216
+ light: { type: "hex", value: "#e2e8f0" },
217
+ },
218
+ font_name: null,
219
+ font_size: "body_m",
220
+ font_weight: "regular",
221
+ background_color: {
222
+ dark: { type: "alias", value: "transparent" },
223
+ light: { type: "alias", value: "transparent" },
224
+ },
225
+ },
226
+ ] as unknown as TextNodeProps[],
227
+ }}
228
+ />
229
+
156
230
  <Story
157
231
  name="Z Layer"
158
232
  args={{
@@ -6,8 +6,16 @@
6
6
  import { getPaywallContext } from "../../stores/paywall";
7
7
  import { getSelectedStateContext } from "../../stores/selected";
8
8
  import { getOptionalVariablesContext } from "../../stores/variables";
9
+ import BackgroundVideoSurface from "../paywall/BackgroundVideoSurface.svelte";
9
10
  import type { StackProps } from "../../types/components/stack";
10
- import { mapBackground } from "../../utils/background-utils";
11
+ import {
12
+ mapBackground,
13
+ resolveBackgroundVideoForSurface,
14
+ } from "../../utils/background-utils";
15
+ import {
16
+ rcVideoBackgroundHostClass,
17
+ videoBackgroundHostStyles,
18
+ } from "../../utils/video-background-host";
11
19
  import {
12
20
  css,
13
21
  mapBorder,
@@ -66,6 +74,15 @@
66
74
  const getColorMode = getColorModeContext();
67
75
  const colorMode = $derived(getColorMode());
68
76
 
77
+ const hasVideoBg = $derived(background?.type === "video");
78
+ const videoSurface = $derived.by(() => {
79
+ if (!hasVideoBg) return null;
80
+ const bg = background;
81
+ if (!bg || bg.type !== "video") return null;
82
+ return resolveBackgroundVideoForSurface(colorMode, bg);
83
+ });
84
+ const borderRadiusCss = $derived(mapBorderRadius(shape));
85
+
69
86
  const stackStyle = $derived(
70
87
  css({
71
88
  display: "flex",
@@ -78,8 +95,12 @@
78
95
  padding: mapSpacing(padding),
79
96
  ...mapBackground(colorMode, background_color, background),
80
97
  ...mapBorder(colorMode, border),
81
- "border-radius": mapBorderRadius(shape),
98
+ "border-radius": borderRadiusCss,
82
99
  "box-shadow": mapShadow(colorMode, shadow),
100
+ ...videoBackgroundHostStyles({
101
+ hasVideoBg,
102
+ clipOverflow: borderRadiusCss !== "0",
103
+ }),
83
104
  // Read from props proxy so $derived re-evaluates when parent updates style
84
105
  ...props.style,
85
106
  }),
@@ -124,9 +145,28 @@
124
145
  role={onclick !== undefined ? "button" : undefined}
125
146
  {onclick}
126
147
  style={stackStyle}
127
- class={["stack", "rc-gradient-border", classProp].filter(Boolean).join(" ")}
148
+ class={[
149
+ "stack",
150
+ "rc-gradient-border",
151
+ classProp,
152
+ hasVideoBg ? rcVideoBackgroundHostClass("stack") : "",
153
+ ]
154
+ .filter(Boolean)
155
+ .join(" ")}
128
156
  data-testid={testId}
129
157
  >
158
+ {#if videoSurface}
159
+ <BackgroundVideoSurface
160
+ url={videoSurface.url}
161
+ urlLowRes={videoSurface.url_low_res}
162
+ objectFit={videoSurface.fit}
163
+ objectPosition={videoSurface.position}
164
+ poster={videoSurface.posterWebp}
165
+ mute={videoSurface.mute}
166
+ loop={videoSurface.loop}
167
+ />
168
+ {/if}
169
+
130
170
  {#if badge}
131
171
  <div style={badgeStyle}>
132
172
  <Node nodeData={badge.stack} />
@@ -173,4 +213,19 @@
173
213
  background: var(--overlay);
174
214
  }
175
215
  }
216
+
217
+ .stack.rc-stack-video-bg::before {
218
+ z-index: 2;
219
+ pointer-events: none;
220
+ }
221
+
222
+ .stack.rc-stack-video-bg > :global(.rc-bg-video) {
223
+ z-index: 1;
224
+ }
225
+
226
+ /* Keep flex children interactive and above tinted overlay (::before z-index). */
227
+ .stack.rc-stack-video-bg > :global(:not(.rc-bg-video)) {
228
+ position: relative;
229
+ z-index: 3;
230
+ }
176
231
  </style>
@@ -5,6 +5,7 @@
5
5
  import { variablesDecorator } from "../../stories/variables-decorator";
6
6
  import { defineMeta } from "@storybook/addon-svelte-csf";
7
7
  import { VARIABLES } from "../paywall/fixtures/variables";
8
+ import { TABS_VIDEO_STACKING_STORY_ARGS } from "./tabs-video-stacking-story-args";
8
9
 
9
10
  const defaultLocale = "en_US";
10
11
 
@@ -1428,3 +1429,29 @@
1428
1429
  type: "tabs",
1429
1430
  }}
1430
1431
  />
1432
+
1433
+ <Story
1434
+ name="Surface — video background (stacking / z-order)"
1435
+ parameters={{
1436
+ chromatic: { disableSnapshot: true },
1437
+ docs: {
1438
+ description: {
1439
+ story:
1440
+ "**Z-order:** video layer → tinted `::before` → tab content clip. Rounded corners should clip the backdrop; Chromatic skips motion snapshots.",
1441
+ },
1442
+ },
1443
+ }}
1444
+ decorators={[
1445
+ componentDecorator(),
1446
+ localizationDecorator({
1447
+ defaultLocale,
1448
+ localizations: {
1449
+ [defaultLocale]: {
1450
+ "t-a-body": "Tab A · content above video",
1451
+ "t-b-body": "Tab B · content above video",
1452
+ },
1453
+ },
1454
+ }),
1455
+ ]}
1456
+ args={TABS_VIDEO_STACKING_STORY_ARGS}
1457
+ />
@@ -4,8 +4,16 @@
4
4
  import { getPaywallContext } from "../../stores/paywall";
5
5
  import { getSelectedStateContext } from "../../stores/selected";
6
6
  import { getOptionalVariablesContext } from "../../stores/variables";
7
+ import BackgroundVideoSurface from "../paywall/BackgroundVideoSurface.svelte";
7
8
  import type { TabsProps } from "../../types/components/tabs";
8
- import { mapBackground } from "../../utils/background-utils";
9
+ import {
10
+ mapBackground,
11
+ resolveBackgroundVideoForSurface,
12
+ } from "../../utils/background-utils";
13
+ import {
14
+ rcVideoBackgroundHostClass,
15
+ videoBackgroundHostStyles,
16
+ } from "../../utils/video-background-host";
9
17
  import {
10
18
  css,
11
19
  mapBorder,
@@ -40,15 +48,30 @@
40
48
  const getColorMode = getColorModeContext();
41
49
  const colorMode = $derived(getColorMode());
42
50
 
51
+ const hasVideoBg = $derived(background?.type === "video");
52
+ const videoSurface = $derived.by(() => {
53
+ if (!hasVideoBg) return null;
54
+ const bg = background;
55
+ if (!bg || bg.type !== "video") return null;
56
+ return resolveBackgroundVideoForSurface(colorMode, bg);
57
+ });
58
+
59
+ const tabsBorderRadiusCss = $derived(mapBorderRadius(shape));
60
+
43
61
  const styles = $derived(
44
62
  css({
63
+ position: "relative",
45
64
  width: mapSize(size.width),
46
65
  height: mapSize(size.height),
47
66
  margin: mapSpacing(margin),
48
67
  padding: mapSpacing(padding),
68
+ ...videoBackgroundHostStyles({
69
+ hasVideoBg,
70
+ clipOverflow: tabsBorderRadiusCss !== "0",
71
+ }),
49
72
  ...mapBackground(colorMode, null, background),
50
73
  ...mapBorder(colorMode, border),
51
- "border-radius": mapBorderRadius(shape),
74
+ "border-radius": tabsBorderRadiusCss,
52
75
  "box-shadow": mapShadow(colorMode, shadow),
53
76
  }),
54
77
  );
@@ -117,9 +140,51 @@
117
140
  </script>
118
141
 
119
142
  {#if isVisible}
120
- <div class="rc-gradient-border" style={styles}>
143
+ <div
144
+ class={[
145
+ "rc-gradient-border",
146
+ hasVideoBg ? rcVideoBackgroundHostClass("tabs") : "",
147
+ ]
148
+ .filter(Boolean)
149
+ .join(" ")}
150
+ style={styles}
151
+ >
152
+ {#if videoSurface}
153
+ <BackgroundVideoSurface
154
+ url={videoSurface.url}
155
+ urlLowRes={videoSurface.url_low_res}
156
+ objectFit={videoSurface.fit}
157
+ objectPosition={videoSurface.position}
158
+ poster={videoSurface.posterWebp}
159
+ mute={videoSurface.mute}
160
+ loop={videoSurface.loop}
161
+ />
162
+ {/if}
121
163
  {#if $tab !== undefined}
122
- <Stack {...$tab.stack} />
164
+ <Stack
165
+ {...$tab.stack}
166
+ class={hasVideoBg ? "rc-tabs-stack-front" : undefined}
167
+ />
123
168
  {/if}
124
169
  </div>
125
170
  {/if}
171
+
172
+ <style>
173
+ .rc-tabs-video-bg::before {
174
+ content: "";
175
+ position: absolute;
176
+ inset: 0;
177
+ background: var(--overlay);
178
+ pointer-events: none;
179
+ z-index: 2;
180
+ }
181
+
182
+ .rc-tabs-video-bg > :global(.rc-bg-video) {
183
+ z-index: 1;
184
+ }
185
+
186
+ .rc-tabs-video-bg > :global(.rc-tabs-stack-front) {
187
+ position: relative;
188
+ z-index: 3;
189
+ }
190
+ </style>
@@ -0,0 +1,3 @@
1
+ import type { TabsProps } from "../../types/components/tabs";
2
+ /** Minimal Tabs tree for Storybook layering checks (`rc-tabs-video-bg`). */
3
+ export declare const TABS_VIDEO_STACKING_STORY_ARGS: TabsProps;
@@ -0,0 +1,131 @@
1
+ import { STORY_BACKGROUND_VIDEO_FILL } from "../../stories/video-background-story-fixture";
2
+ const spacer = { bottom: 0, leading: 0, top: 0, trailing: 0 };
3
+ const simpleTextStack = ({ tid, textLid, }) => ({
4
+ type: "stack",
5
+ id: `${tid}-inner`,
6
+ name: "",
7
+ background_color: null,
8
+ background: null,
9
+ badge: null,
10
+ border: null,
11
+ shadow: null,
12
+ spacing: 0,
13
+ dimension: {
14
+ alignment: "center",
15
+ distribution: "center",
16
+ type: "vertical",
17
+ },
18
+ shape: null,
19
+ margin: spacer,
20
+ padding: {
21
+ bottom: 24,
22
+ leading: 24,
23
+ top: 24,
24
+ trailing: 24,
25
+ },
26
+ size: {
27
+ width: { type: "fill" },
28
+ height: { type: "fit" },
29
+ },
30
+ components: [
31
+ {
32
+ type: "text",
33
+ id: `${tid}-t`,
34
+ name: "",
35
+ text_lid: textLid,
36
+ background_color: null,
37
+ color: {
38
+ light: { type: "hex", value: "#ffffff" },
39
+ },
40
+ font_name: null,
41
+ font_size: "heading_m",
42
+ font_weight: "semibold",
43
+ horizontal_alignment: "center",
44
+ margin: spacer,
45
+ padding: spacer,
46
+ size: {
47
+ width: { type: "fit" },
48
+ height: { type: "fit" },
49
+ },
50
+ },
51
+ ],
52
+ });
53
+ const tabToggle = {
54
+ type: "tab_control_toggle",
55
+ id: "vbg-toggle",
56
+ name: "",
57
+ default_value: false,
58
+ thumb_color_on: {
59
+ light: { type: "hex", value: "#ffffffff" },
60
+ },
61
+ thumb_color_off: {
62
+ light: { type: "hex", value: "#ccccccff" },
63
+ },
64
+ track_color_on: {
65
+ light: { type: "hex", value: "#4488ffff" },
66
+ },
67
+ track_color_off: {
68
+ light: { type: "hex", value: "#00000044" },
69
+ },
70
+ };
71
+ function tab(contentId, bodyLid) {
72
+ return {
73
+ type: "tab",
74
+ id: contentId,
75
+ name: "",
76
+ stack: simpleTextStack({ tid: contentId, textLid: bodyLid }),
77
+ };
78
+ }
79
+ /** Minimal Tabs tree for Storybook layering checks (`rc-tabs-video-bg`). */
80
+ export const TABS_VIDEO_STACKING_STORY_ARGS = {
81
+ type: "tabs",
82
+ id: "tabs-video-z",
83
+ name: "Tabs · video layering",
84
+ size: {
85
+ width: { type: "fixed", value: 400 },
86
+ height: { type: "fixed", value: 300 },
87
+ },
88
+ padding: { bottom: 12, leading: 12, top: 12, trailing: 12 },
89
+ margin: spacer,
90
+ background: STORY_BACKGROUND_VIDEO_FILL,
91
+ shape: {
92
+ corners: {
93
+ bottom_leading: 20,
94
+ bottom_trailing: 20,
95
+ top_leading: 20,
96
+ top_trailing: 20,
97
+ },
98
+ type: "rectangle",
99
+ },
100
+ border: null,
101
+ shadow: null,
102
+ default_tab_id: "tab-a",
103
+ control: {
104
+ type: "toggle",
105
+ stack: {
106
+ type: "stack",
107
+ id: "vbg-toggle-stack",
108
+ name: "",
109
+ background_color: null,
110
+ background: null,
111
+ badge: null,
112
+ border: null,
113
+ shadow: null,
114
+ spacing: 16,
115
+ dimension: {
116
+ alignment: "center",
117
+ distribution: "center",
118
+ type: "horizontal",
119
+ },
120
+ shape: null,
121
+ margin: { bottom: 8, leading: 0, top: 0, trailing: 0 },
122
+ padding: spacer,
123
+ size: {
124
+ width: { type: "fill" },
125
+ height: { type: "fit" },
126
+ },
127
+ components: [tabToggle],
128
+ },
129
+ },
130
+ tabs: [tab("tab-a", "t-a-body"), tab("tab-b", "t-b-body")],
131
+ };
@@ -20,6 +20,10 @@
20
20
  evaluateVisibilityConditions,
21
21
  getActiveStateProps,
22
22
  } from "../../utils/style-utils";
23
+ import {
24
+ applyInlineVideoPlaybackFlags,
25
+ subscribeDecorativeVideoAutoplayAttempts,
26
+ } from "../../utils/video-inline-playback";
23
27
  import ClipPath from "../image/ClipPath.svelte";
24
28
  import Overlay from "../image/Overlay.svelte";
25
29
 
@@ -208,11 +212,9 @@
208
212
  const shouldShowFallback = $derived(hasVideoError || !video);
209
213
 
210
214
  // Autoplay: native `autoplay` + programmatic `play()`. Unmuted autoplay is often blocked; we retry
211
- // once with muted playback (`playbackMutedOverride`) then try to restore sound. Controls follow
212
- // `show_controls` only. Bootstrap once per URL; `play()` when `readyState >= HAVE_CURRENT_DATA`.
213
- // Microtask + rAF retries cover decode/policy ordering; refresh leaves `document` already
214
- // `complete` (`load` won't fire)—extra microtask retries `tryPlay` for muted→unmuted paths.
215
- // Pair with `translateZ(0)` + rc-workflows `screen-slide-fade`.
215
+ // once with muted playback (`playbackMutedOverride`) then try to restore sound (WebKit quirks).
216
+ // Bootstrap timing is shared via `subscribeDecorativeVideoAutoplayAttempts` (+ microtasks / `load`).
217
+ // Pair `translateZ(0)` on `<video>` with rc-workflows `screen-slide-fade` where needed.
216
218
  $effect(() => {
217
219
  if (!auto_play) {
218
220
  untrack(() => {
@@ -243,10 +245,12 @@
243
245
 
244
246
  let cancelled = false;
245
247
 
246
- /** Keep `muted` / `playsInline` aligned with `mute_audio`, `playbackMutedOverride`, and the template. */
248
+ /** Keep muted / playsInline aligned with `mute_audio`, `playbackMutedOverride`, and the template. */
247
249
  const applyVideoPlaybackFlags = () => {
248
- el.muted = mute_audio || playbackMutedOverride;
249
- el.playsInline = true;
250
+ applyInlineVideoPlaybackFlags(el, {
251
+ preferMuted: mute_audio,
252
+ mutedPlaybackOverride: playbackMutedOverride,
253
+ });
250
254
  };
251
255
 
252
256
  let playingUnmuteListener: (() => void) | null = null;
@@ -271,16 +275,25 @@
271
275
  const playPromise = el.play();
272
276
  if (playPromise !== undefined) {
273
277
  playPromise.catch(() => {
278
+ if (cancelled || !el.isConnected) return;
274
279
  if (!mute_audio && !playbackMutedOverride) {
275
280
  untrack(() => {
276
281
  playbackMutedOverride = true;
277
282
  });
278
- applyVideoPlaybackFlags();
283
+ applyInlineVideoPlaybackFlags(el, {
284
+ preferMuted: mute_audio,
285
+ mutedPlaybackOverride: true,
286
+ });
279
287
 
280
288
  /** Unmuted `play()` after a forced-muted start sometimes only works tied to playback (WebKit). */
281
289
  let unmuteAfterMutedStartHandled = false;
282
290
  const tryRestoreAudioAfterMutedAutoplay = () => {
283
- if (unmuteAfterMutedStartHandled || mute_audio || cancelled)
291
+ if (
292
+ unmuteAfterMutedStartHandled ||
293
+ mute_audio ||
294
+ cancelled ||
295
+ !el.isConnected
296
+ )
284
297
  return;
285
298
  unmuteAfterMutedStartHandled = true;
286
299
  queueMicrotask(() => {
@@ -293,6 +306,7 @@
293
306
  const p3 = el.play();
294
307
  if (p3 === undefined) return;
295
308
  p3.catch(() => {
309
+ if (cancelled || !el.isConnected) return;
296
310
  untrack(() => {
297
311
  playbackMutedOverride = true;
298
312
  });
@@ -303,6 +317,7 @@
303
317
  };
304
318
 
305
319
  const onPlayingAfterMutedBootstrap = () => {
320
+ if (cancelled || !el.isConnected) return;
306
321
  playingUnmuteListener = null;
307
322
  tryRestoreAudioAfterMutedAutoplay();
308
323
  };
@@ -339,17 +354,6 @@
339
354
  }
340
355
  };
341
356
 
342
- const onMediaReady = () => {
343
- tryPlay();
344
- };
345
-
346
- if (el.readyState < HTMLMediaElement.HAVE_CURRENT_DATA) {
347
- el.addEventListener("loadeddata", onMediaReady);
348
- el.addEventListener("canplay", onMediaReady);
349
- } else {
350
- tryPlay();
351
- }
352
-
353
357
  /**
354
358
  * Once per `video.url`: muted `play()` nudge helps iOS decode before `HAVE_CURRENT_DATA`.
355
359
  * When `mute_audio` is false, `load()` reloads the media so poster/metadata can attach; unmuted
@@ -368,49 +372,21 @@
368
372
  }
369
373
  }
370
374
  };
375
+
371
376
  if (needsBootstrap && el.readyState < HTMLMediaElement.HAVE_CURRENT_DATA) {
372
377
  nudgePlayback();
373
378
  }
374
379
 
375
- /** Extra `tryPlay` ticks: next microtask vs next frame vs cold `load`; WebKit reordering varies. */
376
- const deferTryPlayWithReadyGate = () => {
377
- queueMicrotask(() => {
378
- if (cancelled) return;
379
- if (el.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA) {
380
- tryPlay();
381
- }
382
- });
383
- requestAnimationFrame(() => {
384
- if (cancelled) return;
385
- if (el.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA) {
386
- tryPlay();
387
- }
388
- });
389
- };
390
-
391
- deferTryPlayWithReadyGate();
392
-
393
- const onWindowLoad = () => {
394
- tryPlay();
395
- };
396
- if (typeof document !== "undefined" && typeof window !== "undefined") {
397
- if (document.readyState === "complete") {
398
- queueMicrotask(() => {
399
- if (!cancelled) tryPlay();
400
- });
401
- } else {
402
- window.addEventListener("load", onWindowLoad);
403
- }
404
- }
380
+ const unsubAutoplay = subscribeDecorativeVideoAutoplayAttempts(
381
+ el,
382
+ tryPlay,
383
+ () => cancelled,
384
+ );
405
385
 
406
386
  return () => {
407
387
  cancelled = true;
408
388
  clearPlayingUnmuteListener();
409
- el.removeEventListener("loadeddata", onMediaReady);
410
- el.removeEventListener("canplay", onMediaReady);
411
- if (typeof window !== "undefined") {
412
- window.removeEventListener("load", onWindowLoad);
413
- }
389
+ unsubAutoplay();
414
390
  };
415
391
  });
416
392
 
@@ -0,0 +1,3 @@
1
+ import type { BackgroundVideo } from "../types/background";
2
+ /** Reusable Storybook payload for carousel/stack/tabs video-background demos. */
3
+ export declare const STORY_BACKGROUND_VIDEO_FILL: BackgroundVideo;