@playpilot/tpi 7.1.0 → 8.0.0-beta.explore-home.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.
Files changed (32) hide show
  1. package/.env +1 -0
  2. package/dist/editorial.mount.js +9 -9
  3. package/dist/link-injections.js +1 -1
  4. package/dist/mount.js +7 -7
  5. package/events.md +1 -0
  6. package/package.json +1 -1
  7. package/src/lib/api/youtubeAvailability.ts +26 -0
  8. package/src/lib/enums/TrackingEvent.ts +1 -0
  9. package/src/lib/explore.ts +2 -2
  10. package/src/lib/trailer.ts +31 -0
  11. package/src/lib/types/explore.d.ts +5 -0
  12. package/src/routes/components/Button.svelte +2 -1
  13. package/src/routes/components/Explore/ExploreLayout.svelte +102 -0
  14. package/src/routes/components/Explore/ExploreModal.svelte +2 -2
  15. package/src/routes/components/Explore/ExploreRouter.svelte +35 -0
  16. package/src/routes/components/Explore/Routes/ExploreHome.svelte +15 -0
  17. package/src/routes/components/Explore/{Explore.svelte → Routes/ExploreResults.svelte} +46 -115
  18. package/src/routes/components/Rails/TitlesRail.svelte +61 -9
  19. package/src/routes/components/YouTubeEmbed.svelte +35 -0
  20. package/src/routes/components/YouTubeEmbedOverlay.svelte +14 -28
  21. package/src/routes/elements/+page.svelte +12 -2
  22. package/src/routes/explore/+page.svelte +1 -1
  23. package/src/tests/lib/api/youtubeAvailability.test.js +70 -0
  24. package/src/tests/lib/trailer.test.js +57 -1
  25. package/src/tests/routes/components/Explore/ExploreLayout.test.js +52 -0
  26. package/src/tests/routes/components/Explore/ExploreRouter.test.js +20 -0
  27. package/src/tests/routes/components/Explore/Routes/ExploreHome.test.js +18 -0
  28. package/src/tests/routes/components/Explore/{Explore.test.js → Routes/ExploreResults.test.js} +29 -22
  29. package/src/tests/routes/components/Rails/TitlesRail.test.js +51 -0
  30. package/src/tests/routes/components/YouTubeEmbed.test.js +31 -0
  31. package/src/tests/routes/components/YouTubeEmbedOverlay.test.js +1 -8
  32. package/vite.config.js +8 -0
package/events.md CHANGED
@@ -94,6 +94,7 @@ Event | Action | Info | Payload
94
94
  `ali_trailer_button_click` | _Fires any time the trailer button is clicked for a title_ | | `Title`
95
95
  `ali_expand_title_description` | _Fires any time the "show more" button is clicked for a title description_ | | `Title`
96
96
  `ali_region_request_failed` | _Fires when requests to external service for getting the user region fails_ | | `message` (error message as returned by the request)
97
+ `ali_youtube_availability_request_failed` | _Fires when requests to external service for getting the youtube availability fails_ | | `message` (error message as returned by the request)
97
98
 
98
99
  ### Explore
99
100
  Event | Action | Info | Payload
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@playpilot/tpi",
3
- "version": "7.1.0",
3
+ "version": "8.0.0-beta.explore-home.2",
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "dev": "vite dev",
@@ -0,0 +1,26 @@
1
+ import { PUBLIC_YOUTUBE_AVAILABILITY_URL } from '$env/static/public'
2
+ import { TrackingEvent } from '$lib/enums/TrackingEvent'
3
+ import { track } from '$lib/tracking'
4
+
5
+ export async function isYouTubeVideoAvailableInRegion(videoId: string): Promise<boolean> {
6
+ const region = window.PlayPilotLinkInjections?.region?.toUpperCase()
7
+
8
+ if (!region) return true
9
+
10
+ try {
11
+ const response = await fetch(`${PUBLIC_YOUTUBE_AVAILABILITY_URL}/?video_id=${videoId}`)
12
+
13
+ if (!response.ok) throw new Error('Response was not ok')
14
+
15
+ const data = await response.json()
16
+
17
+ if (data.blocked?.[region]) return false
18
+ if (!data.blocked && !!data.allowed && !data.allowed?.includes(region)) return false
19
+
20
+ return true
21
+ } catch (error: any) {
22
+ track(TrackingEvent.YouTubeAvailabilityRequestFailed, null, { message: error.message })
23
+
24
+ return false
25
+ }
26
+ }
@@ -60,6 +60,7 @@ export const TrackingEvent = {
60
60
  TrailerClick: 'ali_trailer_button_click',
61
61
  ExpandTitleDescription: 'ali_expand_title_description',
62
62
  RegionRequestFailed: 'ali_region_request_failed',
63
+ YouTubeAvailabilityRequestFailed: 'ali_youtube_availability_request_failed',
63
64
 
64
65
  // Explore
65
66
  // These deliberately do not use the `ali_` prefix. We might want to separate this from TPI at some point
@@ -1,5 +1,5 @@
1
1
  import { mount, unmount } from 'svelte'
2
- import Explore from '../routes/components/Explore/Explore.svelte'
2
+ import ExploreRouter from '../routes/components/Explore/ExploreRouter.svelte'
3
3
 
4
4
  export const exploreParentSelector = '[data-playpilot-explore]'
5
5
  export const explorePreConsentSelector = '[data-playpilot-pre-consent-explore]'
@@ -18,7 +18,7 @@ export function insertExplore(): void {
18
18
  document.querySelector<HTMLElement>(explorePreConsentSelector)?.remove()
19
19
 
20
20
  target.innerHTML = ''
21
- exploreInsertedComponent = mount(Explore, { target })
21
+ exploreInsertedComponent = mount(ExploreRouter, { target })
22
22
  }
23
23
 
24
24
  export function destroyExplore(): void {
@@ -2,6 +2,7 @@ import { mount, unmount } from 'svelte'
2
2
  import { getPlayPilotWrapperElement } from './injection'
3
3
  import type { TitleData } from './types/title'
4
4
  import YouTubeEmbedOverlay from '../routes/components/YouTubeEmbedOverlay.svelte'
5
+ import { isYouTubeVideoAvailableInRegion } from './api/youtubeAvailability'
5
6
 
6
7
  let currentTrailerComponent: object | null = {}
7
8
 
@@ -19,3 +20,33 @@ export function closeTrailerOverlay(): void {
19
20
 
20
21
  currentTrailerComponent = null
21
22
  }
23
+
24
+ // Gets the YouTube ID from a url, can be a large number of different formats
25
+ // https://stackoverflow.com/a/54200105/1665157
26
+ export function getVideoId(url: string): string | null {
27
+ const regExp = /^.*((youtu.be\/)|(v\/)|(\/u\/\w\/)|(embed\/)|(watch\?))\??v?=?([^#&?]*).*/
28
+ const match = url.match(regExp)
29
+
30
+ return match?.[7] || null
31
+ }
32
+
33
+ export async function getFirstTitleWithAvailableTrailer(titles: TitleData[], limit = 3): Promise<TitleData | null> {
34
+ let index = 0
35
+ let titleWithAvailableTrailer: TitleData | null = null
36
+
37
+ while (index < limit && !titleWithAvailableTrailer) {
38
+ const title = titles[index]
39
+ const videoId = title?.embeddable_url && getVideoId(title.embeddable_url)
40
+
41
+ try {
42
+ const isTrailerAvailable = videoId && await isYouTubeVideoAvailableInRegion(videoId)
43
+ if (isTrailerAvailable) titleWithAvailableTrailer = title
44
+ } catch {
45
+ // ignore
46
+ }
47
+
48
+ index++
49
+ }
50
+
51
+ return titleWithAvailableTrailer
52
+ }
@@ -0,0 +1,5 @@
1
+ export type ExploreRoute = {
2
+ key: string
3
+ label: string
4
+ component: any
5
+ }
@@ -67,11 +67,12 @@
67
67
  &:hover,
68
68
  &:active,
69
69
  &.active {
70
- background: theme(button-border-color, lighter);
70
+ background: theme(button-hover-border-color, lighter);
71
71
  color: theme(button-hover-text-color, text-color);
72
72
  }
73
73
 
74
74
  &.active {
75
+ box-shadow: inset 0 0 0 1px theme(button-active-border-color, text-color-alt);
75
76
  filter: brightness(theme(hover-filter-brightness));
76
77
  }
77
78
  }
@@ -0,0 +1,102 @@
1
+ <script lang="ts">
2
+ import { heading } from '$lib/actions/heading'
3
+ import { exploreParentSelector } from '$lib/explore'
4
+ import { t } from '$lib/localization'
5
+ import type { ExploreRoute } from '$lib/types/explore'
6
+ import type { Snippet } from 'svelte'
7
+ import Search from './Filter/Search.svelte'
8
+ import Button from '../Button.svelte'
9
+
10
+ interface Props {
11
+ routes: ExploreRoute[]
12
+ currentRoute: ExploreRoute
13
+ navigate?: (route: ExploreRoute) => void
14
+ searchQuery?: string
15
+ children: Snippet
16
+ }
17
+
18
+ let { routes, currentRoute, navigate = () => null, searchQuery = $bindable(''), children }: Props = $props()
19
+
20
+ let element: HTMLElement | null = null
21
+ let height: string | null = $state(null)
22
+
23
+ $effect(() => {
24
+ // Set the height of this element to match that of it's container. That way we can
25
+ // allow our element to be scrollable, rather than the parent needing to be scrollable.
26
+ height = element?.closest<HTMLElement>(exploreParentSelector)?.style.height || null
27
+ })
28
+ </script>
29
+
30
+ <div class="explore playpilot-styled-scrollbar" bind:this={element} style:height>
31
+ <div class="header" role="banner">
32
+ <div>
33
+ <div class="divider"></div>
34
+
35
+ <div class="heading" use:heading>
36
+ {t('Streaming Guide')}
37
+ </div>
38
+ </div>
39
+ </div>
40
+
41
+ <div class="navigation" role="navigation">
42
+ {#each routes as route}
43
+ <Button variant="border" size="large" active={currentRoute.key === route.key} onclick={() => navigate(route)}>
44
+ {t(route.label)}
45
+ </Button>
46
+ {/each}
47
+ </div>
48
+
49
+ <Search oninput={(query) => searchQuery = query} />
50
+
51
+ {@render children()}
52
+ </div>
53
+
54
+ <style lang="scss">
55
+ .explore {
56
+ background: theme(explore-background, light);
57
+ border-radius: theme(border-radius-large);
58
+ max-width: theme(explore-max-width, 1200px);
59
+ min-height: 75vh;
60
+ margin: 0 auto;
61
+ padding: theme(explore-padding, margin(1) margin(1) margin(6));
62
+ overflow: auto;
63
+ font-family: theme(font-family);
64
+ font-family: theme(detail-font-family, font-family);
65
+ font-weight: theme(detail-font-weight, normal);
66
+ font-size: theme(detail-font-size, font-size-base);
67
+
68
+ :global(*) {
69
+ box-sizing: border-box;
70
+ }
71
+ }
72
+
73
+ .header {
74
+ display: flex;
75
+ flex-direction: column;
76
+ gap: margin(0.5);
77
+ margin: theme(explore-header-margin, 0 0 margin(2));
78
+ width: 100%;
79
+ }
80
+
81
+ .divider {
82
+ width: 100%;
83
+ height: theme(explore-divider-height, 0);
84
+ max-width: theme(explore-header-max-width, 600px);
85
+ background: theme(explore-divider-background, text-color);
86
+ }
87
+
88
+ .heading {
89
+ margin: theme(explore-heading-margin, margin(0.25) 0);
90
+ color: theme(text-color);
91
+ font-size: theme(explore-heading-size, clamp(margin(1.5), 5vw, margin(2)));
92
+ font-weight: theme(explore-heading-font-weight, font-bold);
93
+ text-transform: theme(explore-heading-text-transform, normal);
94
+ line-height: theme(explore-heading-line-height, 1.5);
95
+ }
96
+
97
+ .navigation {
98
+ display: flex;
99
+ gap: margin(0.5);
100
+ margin-bottom: margin(0.5);
101
+ }
102
+ </style>
@@ -1,7 +1,7 @@
1
1
 
2
2
  <script lang="ts">
3
3
  import Modal from '../Modal.svelte'
4
- import Explore from './Explore.svelte'
4
+ import ExploreRouter from './ExploreRouter.svelte'
5
5
 
6
6
  interface Props {
7
7
  initialScrollPosition?: number
@@ -12,7 +12,7 @@
12
12
 
13
13
  <Modal {initialScrollPosition} closeButtonStyle="flat" wide>
14
14
  <div class="content">
15
- <Explore />
15
+ <ExploreRouter />
16
16
  </div>
17
17
  </Modal>
18
18
 
@@ -0,0 +1,35 @@
1
+ <script lang="ts">
2
+ import ExploreHome from './Routes/ExploreHome.svelte'
3
+ import ExploreResults from './Routes/ExploreResults.svelte'
4
+ import type { ExploreRoute } from '$lib/types/explore'
5
+ import ExploreLayout from './ExploreLayout.svelte'
6
+
7
+ const routes: ExploreRoute[] = [
8
+ {
9
+ key: 'home',
10
+ label: 'Home',
11
+ component: ExploreHome,
12
+ }, {
13
+ key: 'results',
14
+ label: 'Explore',
15
+ component: ExploreResults,
16
+ },
17
+ ]
18
+
19
+ let currentRoute: ExploreRoute = $state(routes[0])
20
+ let searchQuery: string = $state('')
21
+
22
+ const CurrentRouteComponent = $derived(currentRoute.component)
23
+
24
+ $effect(() => {
25
+ if (searchQuery) currentRoute = routes.find(route => route.key === 'results')!
26
+ })
27
+
28
+ function navigate(route: ExploreRoute): void {
29
+ currentRoute = route
30
+ }
31
+ </script>
32
+
33
+ <ExploreLayout {routes} {currentRoute} {navigate} bind:searchQuery>
34
+ <CurrentRouteComponent {searchQuery} />
35
+ </ExploreLayout>
@@ -0,0 +1,15 @@
1
+ <script>
2
+ import { fetchSimilarTitles } from '$lib/api/titles'
3
+ import { title } from '$lib/fakeData'
4
+ import TitlesRail from '../../Rails/TitlesRail.svelte'
5
+ </script>
6
+
7
+ <div data-testid="explore-home"></div>
8
+
9
+ {#await fetchSimilarTitles(title)}
10
+ Loading...
11
+ {:then titles}
12
+ <TitlesRail heading="Some titles" {titles} expandable />
13
+ <TitlesRail heading="Some other titles" titles={fetchSimilarTitles(titles[1])} expandable />
14
+ <TitlesRail heading="Some more titles" titles={fetchSimilarTitles(titles[5])} expandable />
15
+ {/await}
@@ -1,9 +1,7 @@
1
1
  <script lang="ts">
2
- import { heading } from '$lib/actions/heading'
3
2
  import { fetchAds } from '$lib/api/ads'
4
3
  import { fetchTitles } from '$lib/api/titles'
5
4
  import { MetaEvent, TrackingEvent } from '$lib/enums/TrackingEvent'
6
- import { exploreParentSelector } from '$lib/explore'
7
5
  import { openModal } from '$lib/modal'
8
6
  import { track } from '$lib/tracking'
9
7
  import type { APIPaginatedResult } from '$lib/types/api'
@@ -13,25 +11,27 @@
13
11
  import { trackViaPixel } from '@playpilot/retargeting-tracking'
14
12
  import { onMount } from 'svelte'
15
13
  import { t } from '$lib/localization'
16
- import Button from '../Button.svelte'
17
- import GridTitle from '../GridTitle.svelte'
18
- import GridTitleSkeleton from '../GridTitleSkeleton.svelte'
19
- import ListTitle from '../ListTitle.svelte'
20
- import ListTitleSkeleton from '../ListTitleSkeleton.svelte'
21
- import Filter from './Filter/Filter.svelte'
22
- import Search from './Filter/Search.svelte'
23
- import Empty from './Empty.svelte'
14
+ import Button from '../../Button.svelte'
15
+ import GridTitle from '../../GridTitle.svelte'
16
+ import GridTitleSkeleton from '../../GridTitleSkeleton.svelte'
17
+ import ListTitle from '../../ListTitle.svelte'
18
+ import ListTitleSkeleton from '../../ListTitleSkeleton.svelte'
19
+ import Filter from '../Filter/Filter.svelte'
20
+ import Empty from '../Empty.svelte'
21
+
22
+ interface Props {
23
+ searchQuery?: string
24
+ }
25
+
26
+ const { searchQuery = '' }: Props = $props()
24
27
 
25
28
  const filter: ExploreFilter = $state({})
26
29
 
27
- let element: HTMLElement | null = null
28
30
  let titles: TitleData[] = $state([])
29
31
  let page = $state(1)
30
32
  let debounce: ReturnType<typeof setTimeout> | null = null
31
33
  let latestRequestId = 0
32
- let searchQuery = $state('')
33
34
  let promise = $state(getTitlesForFilter())
34
- let height: string | null = $state(null)
35
35
  let width = $state(0)
36
36
 
37
37
  const grid = $derived(width > 500)
@@ -45,9 +45,7 @@
45
45
  const SkeletonComponent = $derived(grid ? GridTitleSkeleton : ListTitleSkeleton)
46
46
 
47
47
  $effect(() => {
48
- // Set the height of this element to match that of it's container. That way we can
49
- // allow our element to be scrollable, rather than the parent needing to be scrollable.
50
- height = element?.closest<HTMLElement>(exploreParentSelector)?.style.height || null
48
+ if (searchQuery) search(searchQuery)
51
49
  })
52
50
 
53
51
  onMount(async () => {
@@ -85,8 +83,6 @@
85
83
  }
86
84
 
87
85
  async function search(query: string): Promise<void> {
88
- searchQuery = query
89
-
90
86
  if (debounce) clearTimeout(debounce)
91
87
 
92
88
  debounce = setTimeout(() => {
@@ -123,114 +119,49 @@
123
119
  }
124
120
  </script>
125
121
 
126
- <div class="explore playpilot-styled-scrollbar" class:grid bind:this={element} bind:clientWidth={width} style:height>
127
- <div class="header" role="banner">
128
- <div>
129
- <div class="divider"></div>
130
-
131
- <div class="heading" use:heading>
132
- {t('Streaming Guide')}
133
- </div>
134
-
135
- <p class="description">
136
- {t('Streaming Guide Description')}
137
- </p>
138
- </div>
122
+ {#key grid}
123
+ <div class="filter">
124
+ <Filter {filter} limit={!grid} onchange={setFilter} showSorting={!searchQuery} />
125
+ </div>
126
+ {/key}
139
127
 
140
- {#key grid}
141
- <Search oninput={search} />
142
- <Filter {filter} limit={!grid} onchange={setFilter} showSorting={!searchQuery} />
128
+ <div class="titles" class:grid role="main" style:--grid-columns={gridColumns} bind:clientWidth={width} data-testid="explore-results">
129
+ {#each titles as title}
130
+ {#key title.sid}
131
+ <TitleComponent {title} onclick={(event: MouseEvent) => openTitle(event, title)} />
143
132
  {/key}
144
- </div>
133
+ {/each}
145
134
 
146
- <div class="titles" role="main" style:--grid-columns={gridColumns}>
147
- {#each titles as title}
148
- {#key title.sid}
149
- <TitleComponent {title} onclick={(event: MouseEvent) => openTitle(event, title)} />
150
- {/key}
135
+ {#await promise}
136
+ {#each { length: 24 } as _}
137
+ <SkeletonComponent />
151
138
  {/each}
152
-
153
- {#await promise}
154
- {#each { length: 24 } as _}
155
- <SkeletonComponent />
156
- {/each}
157
- {:catch}
158
- <!-- This is handled below -->
159
- {/await}
160
- </div>
161
-
162
- {#await promise then { next }}
163
- {#if next}
164
- <div class="show-more">
165
- <Button size="large" onclick={fetchMoreTitles}>{t('Show More')}</Button>
166
- </div>
167
- {/if}
168
-
169
- {#if !titles?.length && page === 1}
170
- <Empty />
171
- {/if}
172
139
  {:catch}
173
- <p>Something went wrong</p>
140
+ <!-- This is handled below -->
174
141
  {/await}
175
142
  </div>
176
143
 
177
- <style lang="scss">
178
- .explore {
179
- background: theme(explore-background, light);
180
- border-radius: theme(border-radius-large);
181
- max-width: theme(explore-max-width, 1200px);
182
- min-height: 75vh;
183
- margin: 0 auto;
184
- padding: theme(explore-padding, margin(1) margin(1) margin(6));
185
- overflow: auto;
186
- font-family: theme(font-family);
187
- font-family: theme(detail-font-family, font-family);
188
- font-weight: theme(detail-font-weight, normal);
189
- font-size: theme(detail-font-size, font-size-base);
190
-
191
- :global(*) {
192
- box-sizing: border-box;
193
- }
194
- }
144
+ {#await promise then { next }}
145
+ {#if next}
146
+ <div class="show-more" class:grid>
147
+ <Button size="large" onclick={fetchMoreTitles}>{t('Show More')}</Button>
148
+ </div>
149
+ {/if}
150
+
151
+ {#if !titles?.length && page === 1}
152
+ <Empty />
153
+ {/if}
154
+ {:catch}
155
+ <p>Something went wrong</p>
156
+ {/await}
195
157
 
196
- .header {
158
+ <style lang="scss">
159
+ .filter {
197
160
  display: flex;
198
161
  flex-direction: column;
199
162
  gap: margin(0.5);
200
- margin: theme(explore-header-margin, 0 0 margin(2));
201
163
  width: 100%;
202
-
203
- .grid & {
204
- gap: margin(1);
205
- }
206
- }
207
-
208
- .divider {
209
- width: 100%;
210
- height: theme(explore-divider-height, 0);
211
- max-width: theme(explore-header-max-width, 600px);
212
- background: theme(explore-divider-background, text-color);
213
- }
214
-
215
- .heading {
216
- margin: theme(explore-heading-margin, margin(0.25) 0);
217
- color: theme(text-color);
218
- font-size: theme(explore-heading-size, clamp(margin(1.5), 5vw, margin(2)));
219
- font-weight: theme(explore-heading-font-weight, font-bold);
220
- text-transform: theme(explore-heading-text-transform, normal);
221
- line-height: theme(explore-heading-line-height, 1.5);
222
- }
223
-
224
- .description {
225
- max-width: theme(explore-header-max-width, 600px);
226
- margin: theme(explore-description-margin, 0 0 margin(1));
227
- color: theme(text-color-alt);
228
- font-size: theme(font-size-small);
229
- line-height: 1.35;
230
-
231
- .grid & {
232
- font-size: theme(font-size-base);
233
- }
164
+ margin: margin(0.5) 0 margin(2);
234
165
  }
235
166
 
236
167
  .titles {
@@ -242,7 +173,7 @@
242
173
  flex-direction: column;
243
174
  gap: margin(0.5);
244
175
 
245
- .grid & {
176
+ &.grid {
246
177
  display: grid;
247
178
  grid-template-columns: repeat(var(--grid-columns), minmax(120px, 1fr));
248
179
  gap: margin(1);
@@ -254,7 +185,7 @@
254
185
  display: grid;
255
186
  margin-top: margin(1);
256
187
 
257
- .grid & {
188
+ &.grid {
258
189
  margin: margin(2) auto 0;
259
190
  max-width: 600px;
260
191
  width: 100%;
@@ -1,32 +1,53 @@
1
1
  <script lang="ts">
2
+ import { slide } from 'svelte/transition'
2
3
  import TitlePoster from '../TitlePoster.svelte'
4
+ import YouTubeEmbed from '../YouTubeEmbed.svelte'
3
5
  import Rail from './Rail.svelte'
4
6
  import type { TitleData } from '$lib/types/title'
5
7
  import { openModal } from '$lib/modal'
6
8
  import { titleUrl } from '$lib/routes'
7
- import { getContext } from 'svelte'
9
+ import { getFirstTitleWithAvailableTrailer } from '$lib/trailer'
10
+ import { getContext, onMount } from 'svelte'
8
11
 
9
12
  interface Props {
10
13
  titles: Promise<TitleData[]> | TitleData[]
11
14
  heading?: string,
15
+ expandable?: boolean,
12
16
  onclick?: (title: TitleData) => void
13
17
  }
14
18
 
15
- const { titles, heading = '', onclick = () => null }: Props = $props()
19
+ const { titles, heading = '', expandable = false, onclick = () => null }: Props = $props()
16
20
 
17
21
  const isPopover = getContext('scope') === 'popover'
18
22
  const returnToTitle: TitleData | null = isPopover ? getContext('title') : null
19
23
 
24
+ let expandedTitle: TitleData | null = $state(null)
25
+
26
+ onMount(() => {
27
+ if (expandable) expandTitleIntoTrailer()
28
+ })
29
+
20
30
  function openTitle(event: MouseEvent, title: TitleData): void {
21
31
  openModal({ event, data: title, returnToTitle })
22
32
  onclick(title)
23
33
  }
34
+
35
+ async function expandTitleIntoTrailer(): Promise<void> {
36
+ const response = await titles
37
+
38
+ const title = await getFirstTitleWithAvailableTrailer(response)
39
+ if (!title) return
40
+
41
+ setTimeout(() => {
42
+ expandedTitle = title
43
+ }, 1500)
44
+ }
24
45
  </script>
25
46
 
26
47
  <div class="titles">
27
48
  <Rail {heading}>
28
49
  {#await titles}
29
- {#each { length: 8 }}
50
+ {#each { length: 12 }}
30
51
  <div class="title" data-testid="skeleton">
31
52
  <div class="poster"></div>
32
53
 
@@ -38,10 +59,18 @@
38
59
  {/each}
39
60
  {:then titles}
40
61
  {#each titles as title}
41
- <div class="title" data-testid="title">
42
- <a class="poster" href={titleUrl(title)} onclick={(event) => openTitle(event, title)}>
43
- <TitlePoster {title} width={96} height={144} />
44
- </a>
62
+ {@const expanded = title.sid === expandedTitle?.sid && title.embeddable_url}
63
+
64
+ <div class="title" class:expanded data-testid="title">
65
+ {#if expanded}
66
+ <div class="video" transition:slide={{ axis: 'x' }}>
67
+ <YouTubeEmbed embeddable_url={title.embeddable_url!} muted />
68
+ </div>
69
+ {:else}
70
+ <a class="poster" href={titleUrl(title)} onclick={(event) => openTitle(event, title)}>
71
+ <TitlePoster {title} width={96} height={144} />
72
+ </a>
73
+ {/if}
45
74
 
46
75
  <a href={titleUrl(title)} class="heading" onclick={(event) => openTitle(event, title)}>
47
76
  {title.title}
@@ -62,16 +91,39 @@
62
91
  &:active {
63
92
  filter: brightness(1.1);
64
93
  }
94
+
95
+ &.expanded {
96
+ width: auto;
97
+ }
65
98
  }
66
99
 
67
- .poster {
68
- display: block;
100
+ @keyframes fade-iframe {
101
+ to {
102
+ opacity: 1;
103
+ }
104
+ }
105
+
106
+ .video {
107
+ height: calc($width * 3 / 2);
108
+ aspect-ratio: 16/9;
69
109
  border-radius: theme(rail-border-radius, border-radius);
70
110
  overflow: hidden;
111
+ background: black;
112
+
113
+ :global(iframe) {
114
+ opacity: 0;
115
+ animation: fade-iframe 500ms 500ms forwards;
116
+ }
117
+ }
118
+
119
+ .poster {
120
+ display: block;
71
121
  width: 100%;
72
122
  aspect-ratio: 2 / 3;
123
+ border-radius: theme(rail-border-radius, border-radius);
73
124
  background: theme(detail-background-light, lighter);
74
125
  text-decoration: none;
126
+ overflow: hidden;
75
127
  }
76
128
 
77
129
  .heading {