@growth-labs/video 0.3.0 → 0.3.2

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@growth-labs/video",
3
- "version": "0.3.0",
3
+ "version": "0.3.2",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": {
@@ -36,7 +36,14 @@ const src = premium
36
36
  ? premiumManifestUrl(config.premium!.playbackRoute, videoId)
37
37
  : manifestUrl(config.publicDomain, videoId)
38
38
 
39
- const poster = Astro.props.poster ?? posterUrl(config.publicDomain, videoId)
39
+ // Convention-URL fallback fires only when the caller omits `poster` entirely.
40
+ // Explicit `poster={null | undefined | ""}` is treated as "no poster" so that
41
+ // consumers passing through video metadata with a missing posterUrl don't get
42
+ // a 404'd /cdn-cgi/image/.../poster.jpg attribute (broken-image icon).
43
+ const poster =
44
+ 'poster' in Astro.props
45
+ ? Astro.props.poster || undefined
46
+ : posterUrl(config.publicDomain, videoId)
40
47
  const chaptersSrc = showChapters ? chaptersUrl(config.publicDomain, videoId) : null
41
48
  const captionsSrc = showCaptions
42
49
  ? captionsUrl(config.publicDomain, videoId, captionsLang)
@@ -47,12 +54,13 @@ const captionsSrc = showCaptions
47
54
  data-video-id={videoId}
48
55
  data-premium={premium ? 'true' : 'false'}
49
56
  data-session-route={premium ? config.premium?.sessionRoute : undefined}
57
+ data-pending-src={premium ? src : undefined}
50
58
  data-chapters-src={chaptersSrc}
51
59
  data-captions-src={captionsSrc}
52
60
  data-captions-lang={captionsLang}
53
61
  data-progress-marks={JSON.stringify(config.analytics.progressMarks)}
54
62
  title={title}
55
- src={src}
63
+ src={premium ? undefined : src}
56
64
  poster={poster}
57
65
  preload={config.player.preload}
58
66
  autoplay={Astro.props.autoplay ?? config.player.autoplay}
@@ -75,6 +83,8 @@ const captionsSrc = showCaptions
75
83
  </media-player>
76
84
 
77
85
  <script>
86
+ import { bootstrapPremiumPlayer } from './premium-bootstrap'
87
+
78
88
  import('vidstack/elements').then((mod: any) => {
79
89
  if (typeof mod.defineCustomElements === 'function') {
80
90
  mod.defineCustomElements()
@@ -83,36 +93,16 @@ const captionsSrc = showCaptions
83
93
 
84
94
  const VIDEO_ACCESS_DENIED = 'gl:video-access-denied'
85
95
 
96
+ // Premium players ship without `src` and with `data-pending-src` carrying
97
+ // the manifest URL. Establish the session cookie *before* Vidstack loads
98
+ // the manifest — otherwise preload="metadata" 401s on mount.
86
99
  document.querySelectorAll<HTMLElement>('media-player[data-premium="true"]').forEach((player) => {
87
- const videoId = player.dataset.videoId
88
- const sessionRoute = player.dataset.sessionRoute
89
- if (!videoId || !sessionRoute) return
90
-
91
- let sessionChecked = false
92
- const ensure = async () => {
93
- if (sessionChecked) return
94
- sessionChecked = true
95
- try {
96
- const res = await fetch(sessionRoute, {
97
- method: 'POST',
98
- credentials: 'same-origin',
99
- headers: { 'Content-Type': 'application/json' },
100
- body: JSON.stringify({ videoId }),
101
- })
102
- if (!res.ok) {
103
- window.dispatchEvent(
104
- new CustomEvent(VIDEO_ACCESS_DENIED, { detail: { videoId } }),
105
- )
106
- }
107
- } catch {
100
+ bootstrapPremiumPlayer(player as unknown as Parameters<typeof bootstrapPremiumPlayer>[0], {
101
+ emitAccessDenied: (videoId) =>
108
102
  window.dispatchEvent(
109
103
  new CustomEvent(VIDEO_ACCESS_DENIED, { detail: { videoId } }),
110
- )
111
- }
112
- }
113
-
114
- player.addEventListener('pointerenter', ensure, { once: true })
115
- player.addEventListener('play', ensure)
104
+ ),
105
+ })
116
106
  })
117
107
 
118
108
  const emit = (name: string, detail: unknown) =>
@@ -6,6 +6,7 @@ interface Item {
6
6
  title: string
7
7
  href: string
8
8
  durationSeconds?: number
9
+ poster?: string | null
9
10
  }
10
11
 
11
12
  interface Props {
@@ -14,16 +15,24 @@ interface Props {
14
15
  }
15
16
 
16
17
  const { items, class: className } = Astro.props
18
+ // Only forward `poster` to VideoPreview when the item literal carries the key.
19
+ // Preserves VideoPreview's discriminator: items without a `poster` key fall
20
+ // back to the convention URL; items with `poster: null | undefined | ""`
21
+ // explicitly opt out of any <img> render.
22
+ const previewItems = items.map((item) => ({
23
+ common: {
24
+ videoId: item.videoId,
25
+ title: item.title,
26
+ href: item.href,
27
+ durationSeconds: item.durationSeconds,
28
+ },
29
+ posterProps: ('poster' in item ? { poster: item.poster } : {}) as { poster?: string | null },
30
+ }))
17
31
  ---
18
32
 
19
33
  <div class={`gl-video-gallery ${className ?? ''}`.trim()}>
20
- {items.map((item) => (
21
- <VideoPreview
22
- videoId={item.videoId}
23
- title={item.title}
24
- href={item.href}
25
- durationSeconds={item.durationSeconds}
26
- />
34
+ {previewItems.map(({ common, posterProps }) => (
35
+ <VideoPreview {...common} {...posterProps} />
27
36
  ))}
28
37
  </div>
29
38
 
@@ -7,18 +7,26 @@ interface Props {
7
7
  title: string
8
8
  href: string
9
9
  durationSeconds?: number
10
+ poster?: string | null
10
11
  class?: string
11
12
  }
12
13
 
13
14
  const { videoId, title, href, durationSeconds, class: className } = Astro.props
14
- const poster = posterUrl(config.publicDomain, videoId)
15
+ // Convention-URL fallback fires only when the caller omits `poster` entirely.
16
+ // Explicit `poster={null | undefined | ""}` is treated as "no poster" so that
17
+ // gallery items passing through video metadata with a missing posterUrl don't
18
+ // render a 404'd /cdn-cgi/image/.../poster.jpg <img> (broken-image icon).
19
+ const poster =
20
+ 'poster' in Astro.props
21
+ ? Astro.props.poster || undefined
22
+ : posterUrl(config.publicDomain, videoId)
15
23
  const durationLabel = durationSeconds
16
24
  ? `${Math.floor(durationSeconds / 60)}:${String(durationSeconds % 60).padStart(2, '0')}`
17
25
  : null
18
26
  ---
19
27
 
20
28
  <a class={`gl-video-preview ${className ?? ''}`.trim()} href={href} aria-label={title}>
21
- <img src={poster} alt="" loading="lazy" decoding="async" />
29
+ {poster && <img src={poster} alt="" loading="lazy" decoding="async" />}
22
30
  <span aria-hidden="true" class="gl-video-preview-play">▶</span>
23
31
  <span class="gl-video-preview-title">{title}</span>
24
32
  {durationLabel ? <span class="gl-video-preview-duration">{durationLabel}</span> : null}
@@ -0,0 +1,38 @@
1
+ export interface PremiumPlayerLike {
2
+ dataset: { videoId?: string; sessionRoute?: string; pendingSrc?: string }
3
+ src?: string
4
+ }
5
+
6
+ export interface BootstrapOptions {
7
+ fetch?: typeof fetch
8
+ emitAccessDenied?: (videoId: string) => void
9
+ }
10
+
11
+ export async function bootstrapPremiumPlayer(
12
+ player: PremiumPlayerLike,
13
+ opts: BootstrapOptions = {},
14
+ ): Promise<'authorized' | 'denied' | 'skipped'> {
15
+ const { videoId, sessionRoute, pendingSrc } = player.dataset
16
+ if (!videoId || !sessionRoute) return 'skipped'
17
+
18
+ const f = opts.fetch ?? fetch
19
+ let ok = false
20
+ try {
21
+ const res = await f(sessionRoute, {
22
+ method: 'POST',
23
+ credentials: 'same-origin',
24
+ headers: { 'Content-Type': 'application/json' },
25
+ body: JSON.stringify({ videoId }),
26
+ })
27
+ ok = res.ok
28
+ } catch {
29
+ ok = false
30
+ }
31
+
32
+ if (ok) {
33
+ if (pendingSrc) player.src = pendingSrc
34
+ return 'authorized'
35
+ }
36
+ opts.emitAccessDenied?.(videoId)
37
+ return 'denied'
38
+ }