@playpilot/tpi 8.22.0-beta.1 → 8.23.0-beta.1

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.
@@ -1,24 +1,19 @@
1
1
  <script lang="ts">
2
- import Genres from './Genres.svelte'
3
2
  import Playlinks from './Playlinks/Playlinks.svelte'
4
3
  import Description from './Description.svelte'
5
- import IconIMDb from './Icons/IconIMDb.svelte'
6
4
  import ParticipantsRail from './Rails/ParticipantsRail.svelte'
7
5
  import SimilarRail from './Rails/SimilarRail.svelte'
8
6
  import TitlePoster from './TitlePoster.svelte'
9
7
  import Share from './Share.svelte'
10
8
  import Trailer from './Trailer.svelte'
11
- import YouTubeEmbedBackground from './YouTubeEmbedBackground.svelte'
12
- import { t } from '$lib/localization'
13
9
  import type { TitleData } from '$lib/types/title'
14
10
  import { heading } from '$lib/actions/heading'
15
- import { removeImageUrlPrefix } from '$lib/image'
16
11
  import { titleUrl } from '$lib/routes'
17
12
  import { getContext, setContext } from 'svelte'
18
13
  import { track } from '$lib/tracking'
19
14
  import { TrackingEvent } from '$lib/enums/TrackingEvent'
20
- import { isYouTubeVideoAvailableInRegion } from '$lib/api/youtubeAvailability'
21
- import { getVideoId } from '$lib/trailer'
15
+ import TitleBackground from './TitleBackground.svelte'
16
+ import TitleInfo from './TitleInfo.svelte'
22
17
 
23
18
  interface Props {
24
19
  title: TitleData
@@ -36,18 +31,6 @@
36
31
  const showDescription = $derived((!small || noAffiliate) && title.description)
37
32
 
38
33
  let posterLoaded = $state(false)
39
- let backgroundLoaded = $state(false)
40
- let useBackgroundFallback = $state(false)
41
- let hasYouTubeVideoAvailable = $state(false)
42
-
43
- $effect(() => {
44
- if (!useVideoBackground) return
45
- if (!title.embeddable_url) return
46
-
47
- isYouTubeVideoAvailableInRegion(getVideoId(title.embeddable_url)!).then(isAvailable => {
48
- hasYouTubeVideoAvailable = isAvailable
49
- })
50
- })
51
34
  </script>
52
35
 
53
36
  <div class="content" class:small>
@@ -58,21 +41,7 @@
58
41
 
59
42
  <div class="heading" use:heading={2} class:truncate={small} id="heading">{title.title}</div>
60
43
 
61
- <div class="info">
62
- <div class="imdb">
63
- <IconIMDb />
64
- {title.imdb_score}
65
- </div>
66
-
67
- <Genres genres={title.genres} />
68
-
69
- <div>{title.year}</div>
70
- <div class="capitalize">{t(`Type: ${title.type}`)}</div>
71
-
72
- {#if !small && title.length}
73
- <div>{title.length} {t('Minutes')}</div>
74
- {/if}
75
- </div>
44
+ <TitleInfo {title} {small} />
76
45
 
77
46
  <div class="actions">
78
47
  {#if title.embeddable_url}
@@ -97,21 +66,7 @@
97
66
  </div>
98
67
  </div>
99
68
 
100
- <div class="background" class:small class:offset-mute={modal === 'title'}>
101
- {#if useVideoBackground && hasYouTubeVideoAvailable}
102
- <YouTubeEmbedBackground embeddable_url={title.embeddable_url!} />
103
- {:else}
104
- {#key useBackgroundFallback}
105
- <img
106
- src={removeImageUrlPrefix(useBackgroundFallback ? title.standing_poster : title.medium_poster)}
107
- alt=""
108
- class:loaded={backgroundLoaded}
109
- class:blur={useBackgroundFallback}
110
- onload={() => backgroundLoaded = true}
111
- onerror={() => useBackgroundFallback = true} />
112
- {/key}
113
- {/if}
114
- </div>
69
+ <TitleBackground {title} {small} {useVideoBackground} offsetMute={modal === 'title'} />
115
70
 
116
71
  <style lang="scss">
117
72
  $poster-width: margin(4.5);
@@ -199,45 +154,11 @@
199
154
  }
200
155
  }
201
156
 
202
- .info {
203
- z-index: 1;
204
- position: relative;
205
- grid-area: info;
206
- display: flex;
207
- flex-wrap: wrap;
208
- align-items: center;
209
- gap: margin(0.25) margin(0.75);
210
-
211
- @include desktop {
212
- gap: margin(0.5) margin(1);
213
- }
214
-
215
- .small & {
216
- gap: margin(0.5) margin(0.75);
217
- }
218
- }
219
-
220
157
  .main {
221
158
  z-index: 1;
222
159
  position: relative;
223
160
  }
224
161
 
225
- .imdb {
226
- display: flex;
227
- align-items: center;
228
- gap: margin(0.25);
229
-
230
- :global(svg) {
231
- margin-top: margin(-0.125);
232
-
233
- .small & {
234
- margin-top: 0;
235
- width: margin(0.75);
236
- height: margin(0.75);
237
- }
238
- }
239
- }
240
-
241
162
  .actions {
242
163
  z-index: 1;
243
164
  position: relative;
@@ -247,65 +168,6 @@
247
168
  margin-top: margin(1);
248
169
  }
249
170
 
250
- .background {
251
- position: absolute;
252
- top: 0;
253
- left: 0;
254
- width: 100%;
255
- height: theme(detail-background-height, margin(20));
256
- border-radius: theme(detail-background-border-radius, 0px);
257
- overflow: hidden;
258
- background: theme(detail-background, lighter);
259
- mask-image: linear-gradient(to bottom, black 40%, transparent);
260
- opacity: theme(detail-background-opacity, 1);
261
-
262
- @include desktop() {
263
- height: theme(detail-background-height, margin(12));
264
- mask-image: linear-gradient(to bottom, black 60%, transparent);
265
- }
266
-
267
- @include mobile {
268
- :global(.video-background) {
269
- width: 225%;
270
- }
271
- }
272
-
273
- &.small {
274
- :global(.video-background) {
275
- width: 150%;
276
- }
277
- }
278
-
279
- &.offset-mute {
280
- --mute-top: #{margin(3)};
281
- --mute-right: #{margin(0.25)};
282
- }
283
-
284
- img {
285
- width: 100%;
286
- height: 100%;
287
- object-fit: cover;
288
- object-position: center;
289
- margin: 0;
290
- opacity: 0;
291
- z-index: 0;
292
-
293
- &.loaded {
294
- opacity: 1;
295
- }
296
-
297
- &.blur {
298
- opacity: 0.5;
299
- transform: scale(1.1);
300
- filter: blur(0.75rem);
301
- }
302
- }
303
- }
304
-
305
- .capitalize {
306
- text-transform: capitalize;
307
- }
308
-
309
171
  .truncate {
310
172
  overflow: hidden;
311
173
  text-overflow: ellipsis;
@@ -0,0 +1,110 @@
1
+ <script lang="ts">
2
+ import { isYouTubeVideoAvailableInRegion } from '$lib/api/youtubeAvailability'
3
+ import { removeImageUrlPrefix } from '$lib/image'
4
+ import { getVideoId } from '$lib/trailer'
5
+ import type { TitleData } from '$lib/types/title'
6
+ import YouTubeEmbedBackground from './YouTubeEmbedBackground.svelte'
7
+
8
+ interface Props {
9
+ title: TitleData,
10
+ small?: boolean,
11
+ offsetMute?: boolean,
12
+ useVideoBackground?: boolean,
13
+ autoplay?: boolean,
14
+ inline?: boolean,
15
+ }
16
+
17
+ const { title, small = false, offsetMute = false, useVideoBackground = true, autoplay = true, inline = false }: Props = $props()
18
+
19
+ let backgroundLoaded = $state(false)
20
+ let useBackgroundFallback = $state(false)
21
+ let hasYouTubeVideoAvailable = $state(false)
22
+
23
+ $effect(() => {
24
+ if (!useVideoBackground) return
25
+ if (!title.embeddable_url) return
26
+
27
+ isYouTubeVideoAvailableInRegion(getVideoId(title.embeddable_url)!).then(isAvailable => {
28
+ hasYouTubeVideoAvailable = isAvailable
29
+ })
30
+ })
31
+ </script>
32
+
33
+ <div class="background" class:small class:inline class:offset-mute={offsetMute}>
34
+ {#if useVideoBackground && hasYouTubeVideoAvailable}
35
+ <YouTubeEmbedBackground embeddable_url={title.embeddable_url!} {autoplay} />
36
+ {:else}
37
+ {#key useBackgroundFallback}
38
+ <img
39
+ src={removeImageUrlPrefix(useBackgroundFallback ? title.standing_poster : title.medium_poster)}
40
+ alt=""
41
+ class:loaded={backgroundLoaded}
42
+ class:blur={useBackgroundFallback}
43
+ onload={() => backgroundLoaded = true}
44
+ onerror={() => useBackgroundFallback = true} />
45
+ {/key}
46
+ {/if}
47
+ </div>
48
+
49
+ <style lang="scss">
50
+ .background {
51
+ position: absolute;
52
+ top: 0;
53
+ left: 0;
54
+ width: 100%;
55
+ height: theme(detail-background-height, margin(20));
56
+ border-radius: theme(detail-background-border-radius, 0px);
57
+ overflow: hidden;
58
+ background: theme(detail-background, lighter);
59
+ mask-image: linear-gradient(to bottom, black 40%, transparent);
60
+ opacity: theme(detail-background-opacity, 1);
61
+
62
+ @include desktop() {
63
+ height: theme(detail-background-height, margin(12));
64
+ mask-image: linear-gradient(to bottom, black 60%, transparent);
65
+ }
66
+
67
+ @include mobile {
68
+ :global(.video-background) {
69
+ width: theme(detail-background-width, 225%);
70
+ }
71
+ }
72
+
73
+ &.small {
74
+ :global(.video-background) {
75
+ width: 150%;
76
+ }
77
+ }
78
+
79
+ &.inline {
80
+ overflow: visible;
81
+ position: unset;
82
+ mask-image: unset;
83
+ }
84
+
85
+ &.offset-mute {
86
+ --mute-top: #{margin(3)};
87
+ --mute-right: #{margin(0.25)};
88
+ }
89
+
90
+ img {
91
+ width: 100%;
92
+ height: 100%;
93
+ object-fit: cover;
94
+ object-position: center;
95
+ margin: 0;
96
+ opacity: 0;
97
+ z-index: 0;
98
+
99
+ &.loaded {
100
+ opacity: 1;
101
+ }
102
+
103
+ &.blur {
104
+ opacity: 0.5;
105
+ transform: scale(1.1);
106
+ filter: blur(0.75rem);
107
+ }
108
+ }
109
+ }
110
+ </style>
@@ -0,0 +1,69 @@
1
+ <script lang="ts">
2
+ import { t } from '$lib/localization'
3
+ import type { TitleData } from '$lib/types/title'
4
+ import Genres from './Genres.svelte'
5
+ import IconIMDb from './Icons/IconIMDb.svelte'
6
+
7
+ interface Props {
8
+ title: TitleData,
9
+ small?: boolean,
10
+ }
11
+
12
+ const { title, small = false }: Props = $props()
13
+ </script>
14
+
15
+ <div class="info" class:small>
16
+ <div class="imdb">
17
+ <IconIMDb />
18
+ {title.imdb_score}
19
+ </div>
20
+
21
+ <Genres genres={title.genres} />
22
+
23
+ <div>{title.year}</div>
24
+ <div class="capitalize">{t(`Type: ${title.type}`)}</div>
25
+
26
+ {#if !small && title.length}
27
+ <div>{title.length} {t('Minutes')}</div>
28
+ {/if}
29
+ </div>
30
+
31
+ <style lang="scss">
32
+ .info {
33
+ z-index: 1;
34
+ position: relative;
35
+ grid-area: info;
36
+ display: flex;
37
+ flex-wrap: wrap;
38
+ align-items: center;
39
+ gap: margin(0.25) margin(0.75);
40
+
41
+ @include desktop {
42
+ gap: margin(0.5) margin(1);
43
+ }
44
+
45
+ &.small {
46
+ gap: margin(0.5) margin(0.75);
47
+ }
48
+ }
49
+
50
+ .imdb {
51
+ display: flex;
52
+ align-items: center;
53
+ gap: margin(0.25);
54
+
55
+ :global(svg) {
56
+ margin-top: margin(-0.125);
57
+
58
+ .small & {
59
+ margin-top: 0;
60
+ width: margin(0.75);
61
+ height: margin(0.75);
62
+ }
63
+ }
64
+ }
65
+
66
+ .capitalize {
67
+ text-transform: capitalize;
68
+ }
69
+ </style>
@@ -9,10 +9,11 @@
9
9
  muted?: boolean
10
10
  loop?: boolean
11
11
  captions?: boolean
12
+ autoplay?: boolean
12
13
  showMuteControls?: boolean
13
14
  }
14
15
 
15
- const { embeddable_url = '', controls = [], muted = false, loop = false, captions = false, showMuteControls = false }: Props = $props()
16
+ const { embeddable_url = '', controls = [], muted = false, loop = false, captions = false, autoplay = true, showMuteControls = false }: Props = $props()
16
17
 
17
18
  const videoId = $derived(getVideoId(embeddable_url))
18
19
  const startTime = $derived((window?.PlayPilotLinkInjections?.video_playtimes?.[videoId || ''] || 1) - 1)
@@ -39,14 +40,24 @@
39
40
  if (isMuted != state) iframe?.contentWindow?.postMessage('mute', '*')
40
41
  isMuted = state
41
42
  }
43
+
44
+ export function play(event: CustomEvent): void {
45
+ if (event.detail === embeddable_url) iframe?.contentWindow?.postMessage('play', '*')
46
+ }
47
+
48
+ export function pause(event: CustomEvent): void {
49
+ if (event.detail === embeddable_url) iframe?.contentWindow?.postMessage('pause', '*')
50
+ }
42
51
  </script>
43
52
 
53
+ <svelte:window onplayembed={play} onpauseembed={pause} />
54
+
44
55
  {#if videoId}
45
56
  <iframe
46
57
  bind:this={iframe}
47
58
  width="600"
48
59
  height="338"
49
- src="https://video.playpilot.net/?video_id={videoId}&color={color}&muted={muted}&loop={loop}&captions={captions}&controls={controls.join(',')}&start_time={startTime}&autoplay=true&playsinline=true"
60
+ src="https://video.playpilot.net/?video_id={videoId}&color={color}&muted={muted}&loop={loop}&captions={captions}&controls={controls.join(',')}&start_time={startTime}&autoplay={autoplay}&playsinline=true"
50
61
  title="YouTube video player"
51
62
  frameborder="0"
52
63
  referrerpolicy="strict-origin-when-cross-origin"
@@ -3,9 +3,10 @@
3
3
 
4
4
  interface Props {
5
5
  embeddable_url: string
6
+ autoplay?: boolean
6
7
  }
7
8
 
8
- const { embeddable_url = '' }: Props = $props()
9
+ const { embeddable_url = '', autoplay = true }: Props = $props()
9
10
 
10
11
  let clientWidth = $state(0)
11
12
  let clientHeight = $state(0)
@@ -14,7 +15,7 @@
14
15
  </script>
15
16
 
16
17
  <div class="video-background" style="--height: {height}px; --width: {clientWidth}px;" bind:clientWidth bind:clientHeight data-testid="video-background">
17
- <YouTubeEmbed {embeddable_url} muted loop showMuteControls />
18
+ <YouTubeEmbed {embeddable_url} {autoplay} muted loop showMuteControls />
18
19
  </div>
19
20
 
20
21
  <style lang="scss">
@@ -87,6 +87,16 @@
87
87
  <button onclick={() => openModal({ type: 'participant', data: participants[0] })}>Show participant modal</button>
88
88
  </div>
89
89
  </div>
90
+
91
+ <div>
92
+ <h3>TitlesReelModal.svelte</h3>
93
+
94
+ {#await fetchSimilarTitles(title) then titles}
95
+ <div class="item">
96
+ <button onclick={() => openModal({ type: 'titles-reel', data: titles })}>Show titles reel modal</button>
97
+ </div>
98
+ {/await}
99
+ </div>
90
100
  </div>
91
101
 
92
102
  <h2>Title elements</h2>
@@ -1,34 +1,23 @@
1
- import { render, waitFor } from '@testing-library/svelte'
1
+ import { render } from '@testing-library/svelte'
2
2
  import { beforeEach, describe, expect, it, vi } from 'vitest'
3
3
 
4
4
  import ExploreHome from '../../../../../routes/components/Explore/Routes/ExploreHome.svelte'
5
5
  import { fetchTitles } from '$lib/api/titles'
6
- import { api } from '$lib/api/api'
7
6
 
8
7
  vi.mock('$lib/api/titles', () => ({
9
8
  fetchTitles: vi.fn(),
10
9
  }))
11
10
 
12
- vi.mock('$lib/api/api', () => ({
13
- api: vi.fn(),
14
- }))
15
-
16
11
  describe('ExploreResults.svelte', () => {
17
12
  beforeEach(() => {
18
13
  vi.resetAllMocks()
19
14
  })
20
15
 
21
- it('Should fetch rails endpoint and titles for each rail', async () => {
22
- const rail = { headings: {}, properties: {}, params: {} }
23
-
24
- vi.mocked(api).mockResolvedValue([rail, rail, rail, rail, rail])
16
+ it('Should fetch titles for each rail', () => {
25
17
  vi.mocked(fetchTitles).mockResolvedValue({ next: null, previous: null, results: [] })
26
18
 
27
19
  render(ExploreHome)
28
20
 
29
- await waitFor(() => {
30
- expect(api).toHaveBeenCalledOnce()
31
- expect(fetchTitles).toHaveBeenCalledTimes(5)
32
- })
21
+ expect(fetchTitles).toHaveBeenCalledTimes(5)
33
22
  })
34
23
  })
@@ -38,43 +38,6 @@ describe('Playlinks.svelte', () => {
38
38
  expect(getByText('Some other playlink')).toBeTruthy()
39
39
  })
40
40
 
41
- it('Should show disclaimer text when playlinks are given', () => {
42
- const playlinks = [
43
- { name: 'Some playlink', logo_url: 'logo', extra_info: { category: 'SVOD' } },
44
- { name: 'Some other playlink', logo_url: 'logo', extra_info: { category: 'SVOD' } },
45
- ]
46
- // @ts-ignore
47
- const { getByTestId, getByText } = render(Playlinks, { playlinks, title })
48
-
49
- expect(getByTestId('commission-disclaimer')).toBeTruthy()
50
- expect(getByText('We may earn a commission', { exact: false })).toBeTruthy()
51
- })
52
-
53
- it('Should replace default disclaimer text with config value when given', () => {
54
- // @ts-ignore
55
- window.PlayPilotLinkInjections = {
56
- config: { playlinks_disclaimer_text: 'Some disclaimer' },
57
- }
58
-
59
- const playlinks = [
60
- { name: 'Some playlink', logo_url: 'logo', extra_info: { category: 'SVOD' } },
61
- ]
62
-
63
- // @ts-ignore
64
- const { getByText } = render(Playlinks, { playlinks, title })
65
-
66
- expect(getByText('Some disclaimer')).toBeTruthy()
67
- })
68
-
69
- it('Should show empty state without commission disclaimer when no playlinks are given', () => {
70
- /** @type {import('$lib/types/playlink').PlaylinkData[]} */
71
- const playlinks = []
72
- const { queryByTestId, getByText } = render(Playlinks, { playlinks, title })
73
-
74
- expect(getByText('This title is not currently available to stream.')).toBeTruthy()
75
- expect(queryByTestId('commission-disclaimer')).not.toBeTruthy()
76
- })
77
-
78
41
  it('Should call track function when clicked', async () => {
79
42
  const playlinks = [
80
43
  { name: 'Some playlink', logo_url: 'logo', extra_info: { category: 'SVOD' } },
@@ -0,0 +1,49 @@
1
+ import { render } from '@testing-library/svelte'
2
+ import { beforeEach, describe, expect, it } from 'vitest'
3
+
4
+ import Playlinks from '../../../../routes/components/Playlinks/Playlinks.svelte'
5
+ import { title } from '$lib/fakeData'
6
+
7
+ describe('Playlinks.svelte', () => {
8
+ beforeEach(() => {
9
+ // @ts-ignore
10
+ window.PlayPilotLinkInjections = {}
11
+ })
12
+
13
+ it('Should show disclaimer text when playlinks are given', () => {
14
+ const playlinks = [
15
+ { name: 'Some playlink', logo_url: 'logo', extra_info: { category: 'SVOD' } },
16
+ { name: 'Some other playlink', logo_url: 'logo', extra_info: { category: 'SVOD' } },
17
+ ]
18
+ // @ts-ignore
19
+ const { getByTestId, getByText } = render(Playlinks, { playlinks, title })
20
+
21
+ expect(getByTestId('commission-disclaimer')).toBeTruthy()
22
+ expect(getByText('We may earn a commission', { exact: false })).toBeTruthy()
23
+ })
24
+
25
+ it('Should replace default disclaimer text with config value when given', () => {
26
+ // @ts-ignore
27
+ window.PlayPilotLinkInjections = {
28
+ config: { playlinks_disclaimer_text: 'Some disclaimer' },
29
+ }
30
+
31
+ const playlinks = [
32
+ { name: 'Some playlink', logo_url: 'logo', extra_info: { category: 'SVOD' } },
33
+ ]
34
+
35
+ // @ts-ignore
36
+ const { getByText } = render(Playlinks, { playlinks, title })
37
+
38
+ expect(getByText('Some disclaimer')).toBeTruthy()
39
+ })
40
+
41
+ it('Should show empty state without commission disclaimer when no playlinks are given', () => {
42
+ /** @type {import('$lib/types/playlink').PlaylinkData[]} */
43
+ const playlinks = []
44
+ const { queryByTestId, getByText } = render(Playlinks, { playlinks, title })
45
+
46
+ expect(getByText('This title is not currently available to stream.')).toBeTruthy()
47
+ expect(queryByTestId('commission-disclaimer')).not.toBeTruthy()
48
+ })
49
+ })
@@ -40,6 +40,12 @@ describe('YouTubeEmbed.svelte', () => {
40
40
  expect(/** @type {HTMLIFrameElement} */ (container.querySelector('iframe')).src).toContain('&captions=true')
41
41
  })
42
42
 
43
+ it('Should render embed iframe with autoplay set to false if given', () => {
44
+ const { container } = render(YouTubeEmbed, { embeddable_url: 'youtube.com/watch?v=abc', autoplay: false })
45
+
46
+ expect(/** @type {HTMLIFrameElement} */ (container.querySelector('iframe')).src).toContain('&autoplay=false')
47
+ })
48
+
43
49
  it('Should render error message if embeddable_url is invalid', () => {
44
50
  const { container, getByText } = render(YouTubeEmbed, { embeddable_url: '-' })
45
51