@playpilot/tpi 8.11.0 → 8.12.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@playpilot/tpi",
3
- "version": "8.11.0",
3
+ "version": "8.12.1",
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "dev": "vite dev",
@@ -166,6 +166,11 @@ export const translations = {
166
166
  [Language.Swedish]: 'Streaming Guide',
167
167
  [Language.Danish]: 'Streaming Guide',
168
168
  },
169
+ 'Streaming Guide Subheading': {
170
+ [Language.English]: 'Discover and search all movies and tv-shows',
171
+ [Language.Swedish]: 'Upptäck och sök bland alla filmer och tv-serier',
172
+ [Language.Danish]: 'Opdag og søg i alle film og tv-serier',
173
+ },
169
174
  'Streaming Guide Description': {
170
175
  [Language.English]: 'Find where to watch movies online - the ultimate guide that helps you find the best movies and shows across streaming services.',
171
176
  [Language.Swedish]: 'Sök bland alla filmer och serier för att ta reda på var du kan streama dem',
@@ -42,6 +42,8 @@ export type ScriptConfig = {
42
42
  config?: ConfigResponse
43
43
  // All ads as fetched from the ads endpoint. This is used as the primary store for ads, each individual ads gets it's data from here.
44
44
  ads?: Campaign[]
45
+ // Estimate playtimes of video embeds listed by their video id.
46
+ video_playtimes?: Record<string, number>
45
47
  // The region the user is in, either fetched from an external service or based on the default region in the config object
46
48
  region?: string | null
47
49
  // The time at which the script was initialized
package/src/main.ts CHANGED
@@ -23,6 +23,7 @@ window.PlayPilotLinkInjections ||= {
23
23
  initial_link_injections_promise: null,
24
24
  time_at_initialize: 0,
25
25
  ads: [],
26
+ video_playtimes: {},
26
27
  require_consent: true,
27
28
  no_affiliate: false,
28
29
  consents: {
@@ -48,6 +48,10 @@
48
48
  {t('Streaming Guide')}
49
49
  </div>
50
50
 
51
+ <div class="subheading" use:heading={2}>
52
+ {t('Streaming Guide Subheading')}
53
+ </div>
54
+
51
55
  {#if !useExploreRouter()}
52
56
  <p class="description">
53
57
  {t('Streaming Guide Description')}
@@ -99,7 +103,7 @@
99
103
  display: flex;
100
104
  flex-direction: column;
101
105
  gap: margin(0.5);
102
- margin: theme(explore-header-margin, 0 0 margin(2));
106
+ margin: theme(explore-header-margin, 0 0 margin(1));
103
107
  width: 100%;
104
108
  }
105
109
 
@@ -110,8 +114,8 @@
110
114
  background: theme(explore-divider-background, text-color);
111
115
  }
112
116
 
113
- .heading {
114
- margin: theme(explore-heading-margin, margin(0.25) 0);
117
+ .heading,
118
+ .subheading {
115
119
  color: theme(text-color);
116
120
  font-size: theme(explore-heading-size, clamp(margin(1.5), 5vw, margin(2)));
117
121
  font-weight: theme(explore-heading-font-weight, font-bold);
@@ -119,6 +123,16 @@
119
123
  line-height: theme(explore-heading-line-height, 1.5);
120
124
  }
121
125
 
126
+ .subheading {
127
+ margin-top: margin(0.5);
128
+ max-width: margin(15);
129
+ font-size: theme(explore-subheading-size, clamp(margin(1), 2.5vw, margin(1.25)));
130
+
131
+ @include desktop {
132
+ max-width: 100%;
133
+ }
134
+ }
135
+
122
136
  .description {
123
137
  max-width: theme(explore-header-max-width, 600px);
124
138
  margin: 0;
@@ -6,9 +6,11 @@
6
6
  import { track } from '$lib/tracking'
7
7
  import { TrackingEvent } from '$lib/enums/TrackingEvent'
8
8
  import TitlesRail from '../../Rails/TitlesRail.svelte'
9
+ import { mobileBreakpoint } from '$lib/constants'
9
10
 
10
11
  let expandedTitle: TitleData | null = $state(null)
11
12
  let expandedRailKey: string | null = $state(null)
13
+ let windowWidth: number = $state(window.innerWidth)
12
14
 
13
15
  const rails: { heading: string, params: Record<string, any>, properties: Record<string, any> }[] = [{
14
16
  heading: 'List: Trending',
@@ -17,7 +19,7 @@
17
19
  }, {
18
20
  heading: 'List: New',
19
21
  params: { from_playlist_sid: 'li42WR', include_playable_types: 'SVOD,FREE' },
20
- properties: { aside: true },
22
+ properties: { aside: true, size: 'flexible' },
21
23
  }, {
22
24
  heading: 'List: Upcoming',
23
25
  params: { from_playlist_sid: 'li42wf', region: null, no_region_filter: true },
@@ -29,7 +31,7 @@
29
31
  }, {
30
32
  heading: 'List: Demand',
31
33
  params: { from_playlist_sid: 'licBS' },
32
- properties: { aside: true },
34
+ properties: { aside: true, size: 'flexible' },
33
35
  }]
34
36
 
35
37
  async function getListTitles(params: Record<string, any> = {}): Promise<TitleData[]> {
@@ -43,7 +45,7 @@
43
45
  }
44
46
  </script>
45
47
 
46
- <svelte:window {onscroll} />
48
+ <svelte:window {onscroll} bind:innerWidth={windowWidth} />
47
49
 
48
50
  <div data-testid="explore-home"></div>
49
51
 
@@ -52,7 +54,7 @@
52
54
  <TitlesRail
53
55
  heading={t(heading)}
54
56
  titles={getListTitles(params)}
55
- size="flexible"
57
+ size={windowWidth >= mobileBreakpoint ? 'flexible' : 'huge'}
56
58
  minimumLength={7}
57
59
  {...properties}
58
60
  bind:expandedTitle
@@ -78,7 +78,7 @@
78
78
  </Modal>
79
79
 
80
80
  <style lang="scss">
81
- $size: min(600px, 80vw);
81
+ $size: min(600px, 85vw);
82
82
 
83
83
  .rail-modal {
84
84
  --gap: #{margin(0.25)};
@@ -131,7 +131,7 @@
131
131
  border-radius: theme(rail-modal-item-border-radius, border-radius-huge) theme(rail-modal-item-border-radius, border-radius-huge) 0 0;
132
132
  background: theme(rail-modal-item-background, light);
133
133
  box-shadow: none;
134
- height: 80vh;
134
+ height: 90vh;
135
135
  overflow: auto;
136
136
  overscroll-behavior: contain;
137
137
  transition: box-shadow var(--transition-duration);
@@ -156,7 +156,7 @@
156
156
  }
157
157
 
158
158
  .arrow {
159
- --offset: #{margin(-2)};
159
+ --offset: #{margin(-1.25)};
160
160
  --scale: 1;
161
161
  cursor: pointer;
162
162
  z-index: 2;
@@ -4,7 +4,7 @@
4
4
  import type { TitleData } from '$lib/types/title'
5
5
  import { getFirstAdOfType } from '$lib/api/ads'
6
6
  import { exploreParentSelector } from '$lib/explore'
7
- import { onMount } from 'svelte'
7
+ import { onMount, setContext } from 'svelte'
8
8
  import { isPixelAllowed } from '$lib/pixel'
9
9
  import { trackViaPixel } from '@playpilot/retargeting-tracking'
10
10
  import Modal from './Modal.svelte'
@@ -20,6 +20,8 @@
20
20
 
21
21
  const { title, initialScrollPosition = 0, ...restProps }: Props = $props()
22
22
 
23
+ setContext('modal', 'title')
24
+
23
25
  const topScrollAd = getFirstAdOfType('top_scroll')
24
26
  const displayAd = getFirstAdOfType('card')
25
27
  const insertExploreCta = window.PlayPilotLinkInjections?.config?.explore_insert_cta_in_tpi
@@ -16,7 +16,7 @@
16
16
  titles: Promise<TitleData[]> | TitleData[]
17
17
  minimumLength?: number,
18
18
  heading?: string,
19
- size?: 'small' | 'large' | 'flexible'
19
+ size?: 'small' | 'large' | 'huge' | 'flexible'
20
20
  aside?: boolean,
21
21
  expandable?: boolean,
22
22
  expandedTitle?: TitleData | null,
@@ -60,11 +60,16 @@
60
60
  const activeElement = element?.querySelectorAll('.title')[index]!
61
61
  const parentOffset = element?.getBoundingClientRect().right || 0
62
62
  const elementOffsetInParent = parentOffset - activeElement.getBoundingClientRect().right
63
+ const isFullyVisible = elementOffsetInParent > activeElement.clientWidth * 2
63
64
 
64
65
  recentlyExpanded = true
65
66
  setTimeout(() => recentlyExpanded = false, 500)
66
67
 
67
- if (elementOffsetInParent < activeElement.clientWidth * 2) slider?.setIndex(index - 2)
68
+ if (size === 'flexible' && !isFullyVisible) {
69
+ slider?.setIndex(index - 2)
70
+ } else if (size === 'huge' && !isFullyVisible) {
71
+ slider?.setIndex(index)
72
+ }
68
73
 
69
74
  expandTitleIntoTrailer(title)
70
75
 
@@ -206,7 +211,7 @@
206
211
  .titles {
207
212
  --width: #{theme(rail-size-default, margin(6))};
208
213
  --image-height: #{calc(var(--width) * 3 / 2)};
209
- --expanded-width: #{calc(var(--image-height) / 9 * 16)};
214
+ --expanded-width: var(--width);
210
215
  --border-radius: #{theme(rail-border-radius, border-radius)};
211
216
 
212
217
  &.with-aside {
@@ -217,8 +222,13 @@
217
222
  --width: #{theme(rail-size-large, margin(7.5))};
218
223
  }
219
224
 
225
+ &.huge {
226
+ --width: #{theme(rail-size-large, min(explore-width(65), margin(15)))};
227
+ }
228
+
220
229
  &.flexible {
221
- --width: #{theme(rail-size-flexible, clamp(margin(6), explore-width(18), margin(11.5)))};
230
+ --width: #{theme(rail-size-flexible, clamp(margin(8), explore-width(20), margin(11.5)))};
231
+ --expanded-width: #{calc(var(--image-height) / 9 * 16)};
222
232
  }
223
233
  }
224
234
 
@@ -286,6 +296,14 @@
286
296
  :global(iframe) {
287
297
  opacity: 0;
288
298
  animation: fade-iframe 500ms 500ms forwards;
299
+
300
+ @include mobile {
301
+ position: absolute;
302
+ width: 225%;
303
+ top: 50%;
304
+ left: 50%;
305
+ transform: translateX(-50%) translateY(-50%);
306
+ }
289
307
  }
290
308
 
291
309
  :global(+ .poster) {
@@ -14,7 +14,7 @@
14
14
  import { heading } from '$lib/actions/heading'
15
15
  import { removeImageUrlPrefix } from '$lib/image'
16
16
  import { titleUrl } from '$lib/routes'
17
- import { setContext } from 'svelte'
17
+ import { getContext, setContext } from 'svelte'
18
18
  import { track } from '$lib/tracking'
19
19
  import { TrackingEvent } from '$lib/enums/TrackingEvent'
20
20
  import { isYouTubeVideoAvailableInRegion } from '$lib/api/youtubeAvailability'
@@ -32,6 +32,7 @@
32
32
 
33
33
  setContext('title', title)
34
34
 
35
+ const modal = getContext('modal')
35
36
  const showDescription = $derived((!small || noAffiliate) && title.description)
36
37
 
37
38
  let posterLoaded = $state(false)
@@ -96,7 +97,7 @@
96
97
  </div>
97
98
  </div>
98
99
 
99
- <div class="background" class:small>
100
+ <div class="background" class:small class:offset-mute={modal === 'title'}>
100
101
  {#if useVideoBackground && hasYouTubeVideoAvailable}
101
102
  <YouTubeEmbedBackground embeddable_url={title.embeddable_url!} />
102
103
  {:else}
@@ -131,7 +132,6 @@
131
132
  }
132
133
 
133
134
  .content {
134
- z-index: 1;
135
135
  position: relative;
136
136
  padding: margin(1);
137
137
  color: theme(detail-text-color, text-color);
@@ -154,7 +154,7 @@
154
154
  }
155
155
 
156
156
  .header {
157
- padding: margin(10) 0 margin(1);
157
+ padding: min(margin(17), 70vw) 0 margin(1);
158
158
 
159
159
  @media (min-width: 390px) {
160
160
  display: grid;
@@ -179,6 +179,8 @@
179
179
  }
180
180
 
181
181
  .poster {
182
+ z-index: 1;
183
+ position: relative;
182
184
  grid-area: poster;
183
185
  display: block;
184
186
  width: $poster-width;
@@ -244,7 +246,6 @@
244
246
  overflow: hidden;
245
247
  background: theme(detail-background, lighter);
246
248
  mask-image: linear-gradient(to bottom, black 40%, transparent);
247
- z-index: 0;
248
249
 
249
250
  @include desktop() {
250
251
  height: margin(12);
@@ -263,6 +264,11 @@
263
264
  }
264
265
  }
265
266
 
267
+ &.offset-mute {
268
+ --mute-top: #{margin(3)};
269
+ --mute-right: #{margin(0.25)};
270
+ }
271
+
266
272
  img {
267
273
  width: 100%;
268
274
  height: 100%;
@@ -43,8 +43,10 @@
43
43
  return '.' + classnames.join('.')
44
44
  }
45
45
 
46
- function elementHasDirectTextContent(element: Element): Node | undefined {
47
- return Array.from(element.childNodes).find(node => node.nodeName === '#text' && !!node.textContent?.trim())
46
+ function elementHasDirectTextContent(element: Element | undefined): Node | null {
47
+ if (!element?.childNodes?.length) return null
48
+
49
+ return Array.from(element.childNodes).find(node => node.nodeName === '#text' && !!node.textContent?.trim()) || null
48
50
  }
49
51
  </script>
50
52
 
@@ -1,5 +1,6 @@
1
1
  <script lang="ts">
2
2
  import { getVideoId } from '$lib/trailer'
3
+ import { onMount } from 'svelte'
3
4
  import IconMute from './Icons/IconMute.svelte'
4
5
 
5
6
  interface Props {
@@ -14,11 +15,26 @@
14
15
  const { embeddable_url = '', controls = [], muted = false, loop = false, captions = false, showMuteControls = false }: Props = $props()
15
16
 
16
17
  const videoId = $derived(getVideoId(embeddable_url))
18
+ const startTime = $derived((window?.PlayPilotLinkInjections?.video_playtimes?.[videoId || ''] || 1) - 1)
17
19
  const color = window?.getComputedStyle(document.body).getPropertyValue('--playpilot-primary')?.replace('#', '') || 'fa548a'
18
20
 
19
21
  let iframe: HTMLIFrameElement | null = $state(null)
20
22
  let isMuted = $state(muted)
21
23
 
24
+ onMount(() => {
25
+ if (!videoId) return
26
+
27
+ if (!window.PlayPilotLinkInjections.video_playtimes) window.PlayPilotLinkInjections.video_playtimes = {}
28
+
29
+ let elapsedSeconds = startTime
30
+ const interval = setInterval(() => {
31
+ elapsedSeconds++
32
+ window.PlayPilotLinkInjections.video_playtimes![videoId] = elapsedSeconds
33
+ }, 1000)
34
+
35
+ return () => clearInterval(interval)
36
+ })
37
+
22
38
  export function toggleMute(state = !isMuted): void {
23
39
  if (isMuted != state) iframe?.contentWindow?.postMessage('mute', '*')
24
40
  isMuted = state
@@ -30,7 +46,7 @@
30
46
  bind:this={iframe}
31
47
  width="600"
32
48
  height="338"
33
- src="https://video.playpilot.net/?video_id={videoId}&color={color}&muted={muted}&loop={loop}&captions={captions}&controls={controls.join(',')}&autoplay=true&playsinline=true"
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"
34
50
  title="YouTube video player"
35
51
  frameborder="0"
36
52
  referrerpolicy="strict-origin-when-cross-origin"
@@ -58,8 +74,8 @@
58
74
  align-items: center;
59
75
  justify-content: center;
60
76
  position: absolute;
61
- top: margin(0.5);
62
- right: margin(0.5);
77
+ top: calc(margin(0.5) + var(--mute-top, 0px));
78
+ right: calc(margin(0.5) + var(--mute-right, 0px));
63
79
  padding: margin(0.25);
64
80
  border: 0;
65
81
  border-radius: 50%;
@@ -14,15 +14,11 @@
14
14
  </script>
15
15
 
16
16
  <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 />
17
+ <YouTubeEmbed {embeddable_url} muted loop showMuteControls />
18
18
  </div>
19
19
 
20
20
  <style lang="scss">
21
21
  .video-background {
22
- width: 100%;
23
- height: 100%;
24
- margin-left: 50%;
25
- transform: translateX(-50%);
26
22
  background: black;
27
23
 
28
24
  :global(iframe) {
@@ -7,7 +7,7 @@ describe('YouTubeEmbed.svelte', () => {
7
7
  it('Should render embed iframe with given video url and default options', () => {
8
8
  const { container } = render(YouTubeEmbed, { embeddable_url: 'youtube.com/watch?v=abc' })
9
9
 
10
- expect(/** @type {HTMLIFrameElement} */ (container.querySelector('iframe')).src).toBe('https://video.playpilot.net/?video_id=abc&color=fa548a&muted=false&loop=false&captions=false&controls=&autoplay=true&playsinline=true')
10
+ expect(/** @type {HTMLIFrameElement} */ (container.querySelector('iframe')).src).toBe('https://video.playpilot.net/?video_id=abc&color=fa548a&muted=false&loop=false&captions=false&controls=&start_time=0&autoplay=true&playsinline=true')
11
11
  })
12
12
 
13
13
  it('Should render embed iframe with given controls', () => {
@@ -40,4 +40,15 @@ describe('YouTubeEmbed.svelte', () => {
40
40
  expect(container.querySelector('iframe')).not.toBeTruthy()
41
41
  expect(getByText('Something went wrong')).toBeTruthy()
42
42
  })
43
+
44
+ it('Should include start_time for given video id if present, minus 1 second', () => {
45
+ // @ts-ignore
46
+ window.PlayPilotLinkInjections = {
47
+ video_playtimes: { abc: 5 },
48
+ }
49
+
50
+ const { container } = render(YouTubeEmbed, { embeddable_url: 'youtube.com/watch?v=abc', captions: true })
51
+
52
+ expect(/** @type {HTMLIFrameElement} */ (container.querySelector('iframe')).src).toContain('&start_time=4')
53
+ })
43
54
  })
@@ -8,6 +8,6 @@ describe('YouTubeEmbedBackground.svelte', () => {
8
8
  const { container } = render(YouTubeEmbedBackground, { embeddable_url: 'youtube.com/watch?v=abc' })
9
9
 
10
10
  // @ts-ignore
11
- expect(container.querySelector('iframe').src).toBe('https://video.playpilot.net/?video_id=abc&color=fa548a&muted=true&loop=true&captions=false&controls=&autoplay=true&playsinline=true')
11
+ expect(container.querySelector('iframe').src).toBe('https://video.playpilot.net/?video_id=abc&color=fa548a&muted=true&loop=true&captions=false&controls=&start_time=0&autoplay=true&playsinline=true')
12
12
  })
13
13
  })
@@ -8,7 +8,7 @@ describe('YouTubeEmbedOverlay.svelte', () => {
8
8
  const { container } = render(YouTubeEmbedOverlay, { embeddable_url: 'youtube.com/watch?v=abc', onclose: () => null })
9
9
 
10
10
  // @ts-ignore
11
- expect(container.querySelector('iframe').src).toBe('https://video.playpilot.net/?video_id=abc&color=fa548a&muted=false&loop=false&captions=false&controls=current-time,fullscreen,mute,play,progress&autoplay=true&playsinline=true')
11
+ expect(container.querySelector('iframe').src).toBe('https://video.playpilot.net/?video_id=abc&color=fa548a&muted=false&loop=false&captions=false&controls=current-time,fullscreen,mute,play,progress&start_time=0&autoplay=true&playsinline=true')
12
12
  })
13
13
 
14
14
  it('Should fire given onclose function on click of close button and backdrop', async () => {