@playpilot/tpi 5.32.0-beta.3 → 5.32.0-beta.youtube.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.
Files changed (32) hide show
  1. package/dist/link-injections.js +11 -11
  2. package/package.json +1 -1
  3. package/src/lib/data/translations.ts +5 -5
  4. package/src/lib/enums/SplitTest.ts +0 -5
  5. package/src/lib/fakeData.ts +1 -0
  6. package/src/lib/injection.ts +4 -35
  7. package/src/lib/scss/global.scss +0 -39
  8. package/src/lib/text.ts +2 -1
  9. package/src/lib/trailer.ts +22 -0
  10. package/src/lib/types/title.d.ts +1 -0
  11. package/src/routes/+page.svelte +2 -17
  12. package/src/routes/components/Button.svelte +61 -0
  13. package/src/routes/components/Debugger.svelte +0 -8
  14. package/src/routes/components/Icons/IconClose.svelte +9 -1
  15. package/src/routes/components/Icons/IconPlay.svelte +3 -0
  16. package/src/routes/components/Playlinks/PlaylinkIcon.svelte +4 -1
  17. package/src/routes/components/RoundButton.svelte +4 -5
  18. package/src/routes/components/Share.svelte +5 -23
  19. package/src/routes/components/Title.svelte +22 -22
  20. package/src/routes/components/Trailer.svelte +18 -0
  21. package/src/routes/components/YouTubeEmbedOverlay.svelte +96 -0
  22. package/src/tests/lib/injections.test.js +11 -0
  23. package/src/tests/lib/text.test.js +10 -0
  24. package/src/tests/lib/trailer.test.js +56 -0
  25. package/src/tests/routes/+page.test.js +0 -2
  26. package/src/tests/routes/components/Button.test.js +28 -0
  27. package/src/tests/routes/components/Share.test.js +12 -12
  28. package/src/tests/routes/components/Title.test.js +13 -0
  29. package/src/tests/routes/components/Trailer.test.js +20 -0
  30. package/src/tests/routes/components/YouTubeEmbedOverlay.test.js +31 -0
  31. package/src/routes/components/HighlightedInjection.svelte +0 -230
  32. package/src/tests/routes/components/HighlightedInjection.test.js +0 -98
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@playpilot/tpi",
3
- "version": "5.32.0-beta.3",
3
+ "version": "5.32.0-beta.youtube.1",
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "dev": "vite dev",
@@ -61,11 +61,6 @@ export const translations = {
61
61
  [Language.Swedish]: 'minuter',
62
62
  [Language.Danish]: 'minutter',
63
63
  },
64
- 'Minutes Short': {
65
- [Language.English]: 'min.',
66
- [Language.Swedish]: 'min.',
67
- [Language.Danish]: 'min.',
68
- },
69
64
  'Type: movie': {
70
65
  [Language.English]: 'Movie',
71
66
  [Language.Swedish]: 'Film',
@@ -131,6 +126,11 @@ export const translations = {
131
126
  [Language.Swedish]: 'Liknande filmer och serier',
132
127
  [Language.Danish]: 'Lignende film og serier',
133
128
  },
129
+ 'Watch Trailer': {
130
+ [Language.English]: 'Watch trailer',
131
+ [Language.Swedish]: 'Se trailer',
132
+ [Language.Danish]: 'Se trailer',
133
+ },
134
134
 
135
135
  // Genres
136
136
  'All': {
@@ -9,9 +9,4 @@ export const SplitTest = {
9
9
  numberOfVariants: 2,
10
10
  variantNames: ['Image', 'Label'] as string[],
11
11
  },
12
- InTextEngagement: {
13
- key: 'in-text-engagement',
14
- numberOfVariants: 4,
15
- variantNames: ['Default', '(i) Icon', 'Providers', 'Highlight'] as string[]
16
- }
17
12
  } as const
@@ -38,6 +38,7 @@ export const title: TitleData = {
38
38
  standing_poster: 'https://img.playpilot.tech/6239ee86a58f11efb0b50a58a9feac02/src/img?optimizer=image&class=2by3x18',
39
39
  title: 'Dune: Prophecy',
40
40
  original_title: 'Dune: Prophecy',
41
+ embeddable_url: null,
41
42
  }
42
43
 
43
44
  export const linkInjections: LinkInjection[] = [{
@@ -13,9 +13,6 @@ import { destroyAllModals, openModal } from './modal'
13
13
  import { track } from './tracking'
14
14
  import { TrackingEvent } from './enums/TrackingEvent'
15
15
  import InTextDisclaimer from '../routes/components/InTextDisclaimer.svelte'
16
- import { getSplitTestVariantName, trackSplitTestAction } from './splitTest'
17
- import { SplitTest } from './enums/SplitTest'
18
- import { mergePlaylinks } from './playlink'
19
16
 
20
17
  const keyDataAttribute = 'data-playpilot-injection-key'
21
18
  const keySelector = `[${keyDataAttribute}]`
@@ -200,6 +197,10 @@ export function injectLinksInDocument(elements: HTMLElement[], injections: LinkI
200
197
 
201
198
  const startIndex = match.index + leadingSpaces
202
199
  const endIndex = match.index + match.match.length - trailingSpaces
200
+
201
+ // Stop if the content already contains a link, which we identify soley by if the contain string contains a href attribute
202
+ if (element.innerHTML.slice(startIndex, endIndex).includes('href=')) continue
203
+
203
204
  element.innerHTML = replaceBetween(element.innerHTML, injectionElement.outerHTML, startIndex, endIndex)
204
205
 
205
206
  replacementIndex = match.index
@@ -310,33 +311,6 @@ function createLinkInjectionElement(injection: LinkInjection): { injectionElemen
310
311
  linkElement.target = '_blank'
311
312
  linkElement.rel = 'noopener nofollow noreferrer'
312
313
 
313
- // Part of a split test, insert an absolute positioned (i) icon in the top right of links.
314
- if (getSplitTestVariantName(SplitTest.InTextEngagement) === '(i) Icon') {
315
- const iconElement = document.createElement('span')
316
- iconElement.classList.add('playpilot-injection-info-icon')
317
- iconElement.dataset.playpilotElement = 'true'
318
-
319
- linkElement.insertAdjacentElement('beforeend', iconElement)
320
- }
321
-
322
- // Part of a split test, insert a list of max 3 provider icons after the link, these are inline and cause page shift.
323
- if (getSplitTestVariantName(SplitTest.InTextEngagement) === 'Providers') {
324
- const size = parseFloat(getComputedStyle(document.documentElement).fontSize) * 0.875 // 0.875 rem, 14 pixels if 1rem is 16 pixels
325
-
326
- for (const provider of mergePlaylinks(injection.title_details!.providers).slice(0, 3)) {
327
- const imageElement = document.createElement('img')
328
- imageElement.loading = 'lazy'
329
- imageElement.src = provider.logo_url
330
- imageElement.alt = '' // Intentionally ignore the image or TTS would make a mess of the sentence
331
- imageElement.width = size
332
- imageElement.height = size
333
- imageElement.dataset.playpilotElement = 'true'
334
- imageElement.classList.add('playpilot-injection-provider-icon')
335
-
336
- linkElement.insertAdjacentElement('beforeend', imageElement)
337
- }
338
- }
339
-
340
314
  injectionElement.insertAdjacentElement('beforeend', linkElement)
341
315
 
342
316
  return { injectionElement, linkElement }
@@ -452,8 +426,6 @@ function addLinkInjectionEventListeners(injections: LinkInjection[]): void {
452
426
 
453
427
  event.preventDefault()
454
428
 
455
- trackSplitTestAction(SplitTest.InTextEngagement, 'click')
456
-
457
429
  playFallbackViewTransition(() => {
458
430
  destroyLinkPopover(false)
459
431
  openModal({ event, injection, data: injection.title_details })
@@ -634,9 +606,6 @@ export function clearLinkInjection(key: string): void {
634
606
  const element: HTMLAnchorElement | null = document.querySelector(`[${keyDataAttribute}="${key}"]`)
635
607
  if (!element) return
636
608
 
637
- const playpilotElements = element.querySelectorAll('[data-playpilot-element]')
638
- playpilotElements.forEach(element => element.remove())
639
-
640
609
  const linkContent = element.querySelector('a')?.innerHTML
641
610
  element.outerHTML = linkContent || ''
642
611
  }
@@ -11,45 +11,6 @@
11
11
  position: relative;
12
12
  }
13
13
 
14
- .playpilot-injection-info-icon {
15
- position: relative;
16
- display: inline-block;
17
- height: 1em;
18
-
19
- &::before {
20
- content: "i";
21
- display: inline-flex;
22
- justify-content: center;
23
- align-items: center;
24
- position: absolute;
25
- top: -0.25em;
26
- right: -1em;
27
- width: 0.75em;
28
- height: 0.75em;
29
- border-radius: 50%;
30
- box-shadow: 0 0 0 1px currentColor;
31
- line-height: 1;
32
- font-size: 0.65em;
33
- text-align: center;
34
- }
35
-
36
- h1 &, h2 &, h3 &, h4 &, h5 &, h6 & {
37
- display: none
38
- }
39
- }
40
-
41
- .playpilot-injection-provider-icon {
42
- display: inline-block;
43
- margin-left: margin(0.25);
44
- border-radius: 2px;
45
- box-shadow: 0 0 1px rgba(0, 0, 0, 0.25);
46
- overflow: hidden;
47
-
48
- h1 &, h2 &, h3 &, h4 &, h5 &, h6 & {
49
- display: none
50
- }
51
- }
52
-
53
14
  .playpilot-injection-highlight {
54
15
  outline: margin(0.25) solid var(--playpilot-primary) !important;
55
16
  outline-offset: margin(0.5) !important;
package/src/lib/text.ts CHANGED
@@ -100,7 +100,8 @@ export function findSurroundingPhrases(parentNode: Node, startIndex: number, end
100
100
  if (!sentenceNode || !parentNode.textContent) return { before: '', after: '' }
101
101
 
102
102
  // Include the index of where the text is contained, as parentNode might start later within the sentenceNode
103
- const parentNodeStartIndex = sentenceNode.innerText.indexOf(parentNode.textContent)
103
+ // Use 0 if no index was found. This happens when the sentenceNode and parentNode don't match
104
+ const parentNodeStartIndex = Math.max(sentenceNode.innerText.indexOf(parentNode.textContent), 0)
104
105
 
105
106
  const stringBefore = reverseString(sentenceNode.innerText.slice(0, startIndex + parentNodeStartIndex))
106
107
  const stringAfter = sentenceNode.innerText.slice(endIndex + parentNodeStartIndex)
@@ -0,0 +1,22 @@
1
+ import { mount, unmount } from "svelte"
2
+ import { getPlayPilotWrapperElement } from "./injection"
3
+ import type { TitleData } from "./types/title"
4
+ import YouTubeEmbedOverlay from "../routes/components/YouTubeEmbedOverlay.svelte"
5
+
6
+ let currentTrailerComponent: object | null = {}
7
+
8
+ export function openTrailerOverlay(title: TitleData) {
9
+ const target = getPlayPilotWrapperElement()
10
+ // !! Temporarily falls back to a placeholder is while embeddable_url is not yet present
11
+ const props = { onclose: closeTrailerOverlay, embeddable_url: title.embeddable_url || 'https://www.youtube.com/watch?v=xGTq0blCPVQ' }
12
+
13
+ currentTrailerComponent = mount(YouTubeEmbedOverlay, { target, props })
14
+ }
15
+
16
+ export function closeTrailerOverlay(): void {
17
+ if (!currentTrailerComponent) return
18
+
19
+ unmount(currentTrailerComponent, { outro: true })
20
+
21
+ currentTrailerComponent = null
22
+ }
@@ -17,6 +17,7 @@ export type TitleData = {
17
17
  standing_poster: string
18
18
  title: string
19
19
  original_title: string
20
+ embeddable_url: string | null
20
21
  length?: number
21
22
  blurb?: string
22
23
  participants?: ParticipantData[]
@@ -9,8 +9,6 @@
9
9
  import { fetchAds } from '$lib/api/ads'
10
10
  import { fetchConfig } from '$lib/api/config'
11
11
  import { authorize, getAuthToken, isEditorialModeEnabled, removeAuthCookie, setEditorialParamInUrl } from '$lib/api/auth'
12
- import { getSplitTestVariantName, trackSplitTestView } from '$lib/splitTest'
13
- import { SplitTest } from '$lib/enums/SplitTest'
14
12
  import type { LinkInjectionResponse, LinkInjection, LinkInjectionTypes } from '$lib/types/injection'
15
13
  import Editor from './components/Editorial/Editor.svelte'
16
14
  import EditorTrigger from './components/Editorial/EditorTrigger.svelte'
@@ -18,7 +16,6 @@
18
16
  import TrackingPixels from './components/TrackingPixels.svelte'
19
17
  import Consent from './components/Consent.svelte'
20
18
  import Debugger from './components/Debugger.svelte'
21
- import HighlightedInjection from './components/HighlightedInjection.svelte'
22
19
 
23
20
  let parentElement: HTMLElement | null = $state(null)
24
21
  let elements: HTMLElement[] = $state([])
@@ -30,7 +27,6 @@
30
27
  let loading = $state(true)
31
28
  let isUrlExcluded = $state(true)
32
29
  let linkInjections: LinkInjection[] = $state([])
33
- let randomRenderId = $state(Math.random())
34
30
 
35
31
  // @ts-ignore It's ok if the response is empty
36
32
  const { ai_injections: aiInjections = [], manual_injections: manualInjections = [] } = $derived(response || {})
@@ -58,11 +54,7 @@
58
54
  fireQueuedTrackingEvents()
59
55
  track(TrackingEvent.ArticlePageView)
60
56
 
61
- if (!aiInjections.length && !manualInjections.length) return
62
-
63
- window.PlayPilotLinkInjections.ads = await fetchAds()
64
-
65
- trackSplitTestView(SplitTest.InTextEngagement)
57
+ if (aiInjections.length || manualInjections.length) window.PlayPilotLinkInjections.ads = await fetchAds()
66
58
  }
67
59
 
68
60
  async function initialize(): Promise<void> {
@@ -124,7 +116,6 @@
124
116
 
125
117
  function rerender(): void {
126
118
  inject(separateLinkInjectionTypes(linkInjections))
127
- randomRenderId = Math.random()
128
119
  }
129
120
 
130
121
  function reinitializeEditor(): void {
@@ -234,13 +225,7 @@
234
225
  </svelte:boundary>
235
226
  {/if}
236
227
 
237
- {#key randomRenderId}
238
- {#if linkInjections.length && getSplitTestVariantName(SplitTest.InTextEngagement) === 'Highlight'}
239
- <HighlightedInjection {linkInjections} />
240
- {/if}
241
- {/key}
242
-
243
- <Debugger onrerender={rerender} />
228
+ <Debugger />
244
229
  </div>
245
230
 
246
231
  {#if response?.pixels?.length}
@@ -0,0 +1,61 @@
1
+ <script lang="ts">
2
+ import type { Snippet } from 'svelte'
3
+
4
+ interface Props {
5
+ variant?: 'filled' | 'border'
6
+ // eslint-disable-next-line no-unused-vars
7
+ onclick?: (event: MouseEvent) => void
8
+ children?: Snippet
9
+ }
10
+
11
+ const { variant = 'filled', onclick, children }: Props = $props()
12
+ </script>
13
+
14
+ <button class="button {variant}" {onclick}>
15
+ {@render children?.()}
16
+ </button>
17
+
18
+ <style lang="scss">
19
+ .button {
20
+ appearance: none;
21
+ display: flex;
22
+ height: 100%;
23
+ align-items: center;
24
+ gap: margin(0.25);
25
+ border: 0;
26
+ padding: 0.25em 0.5em;
27
+ border-radius: theme(button-border-radius, border-radius);
28
+ background: theme(button-background, content);
29
+ color: theme(button-text-color, text-color-alt);
30
+ font-size: inherit;
31
+ font-family: inherit;
32
+ font-weight: theme(button-font-weight, normal);
33
+ cursor: pointer;
34
+
35
+ :global(svg) {
36
+ width: 1.5em;
37
+ height: 1.5em;
38
+ opacity: 0.75;
39
+ }
40
+ }
41
+
42
+ .filled {
43
+ &:hover,
44
+ &:active {
45
+ background: theme(button-hover-background, content-light);
46
+ color: theme(button-hover-text-color, text-color);
47
+ }
48
+ }
49
+
50
+ .border {
51
+ background: transparent;
52
+ box-shadow: inset 0 0 0 1px theme(button-border-color, content-light);
53
+
54
+ &:hover,
55
+ &:active {
56
+ background: theme(button-border-color, content);
57
+ color: theme(button-hover-text-color, text-color);
58
+ }
59
+ }
60
+ </style>
61
+
@@ -3,12 +3,6 @@
3
3
  import { getFullUrlPath } from '$lib/url'
4
4
  import { onDestroy } from 'svelte'
5
5
 
6
- interface Props {
7
- onrerender: () => void
8
- }
9
-
10
- const { onrerender }: Props = $props()
11
-
12
6
  const secrets = ['tpidebug', 'debugtpi']
13
7
  const lastInputs: string[] = []
14
8
  const isUsingBetaScript = !!document.querySelector('script[src*="scripts.playpilot.com/link-injection@next"]')
@@ -157,8 +151,6 @@
157
151
 
158
152
  <hr />
159
153
 
160
- <button onclick={onrerender}>Re-inject</button>
161
-
162
154
  {#if isUsingBetaScript}
163
155
  <small>You are using the beta version of TPI</small>
164
156
  {:else}
@@ -1,3 +1,11 @@
1
- <svg width="16" height="16" viewBox="0 0 16 16" fill="none">
1
+ <script lang="ts">
2
+ interface Props {
3
+ size?: number
4
+ }
5
+
6
+ const { size = 16 }: Props = $props()
7
+ </script>
8
+
9
+ <svg width={size} height={size} viewBox="0 0 16 16" fill="none">
2
10
  <path d="M14 2L2 14M2 2L14 14" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
3
11
  </svg>
@@ -0,0 +1,3 @@
1
+ <svg width="24px" height="24px" viewBox="0 -960 960 960">
2
+ <path fill="currentColor" d="m380-300 280-180-280-180v360ZM480-80q-83 0-156-31.5T197-197q-54-54-85.5-127T80-480q0-83 31.5-156T197-763q54-54 127-85.5T480-880q83 0 156 31.5T763-763q54 54 85.5 127T880-480q0 83-31.5 156T763-197q-54 54-127 85.5T480-80Zm0-80q134 0 227-93t93-227q0-134-93-227t-227-93q-134 0-227 93t-93 227q0 134 93 227t227 93Zm0-320Z"/>
3
+ </svg>
@@ -14,13 +14,15 @@
14
14
  const { name, url, logo_url } = $derived(playlink)
15
15
  </script>
16
16
 
17
- <a href={url} target="_blank" class="playlink" data-playlink={name} rel="sponsored" {onclick}>
17
+ <a href={url} target="_blank" class="playlink" data-playlink={name} rel="sponsored" {onclick} style:--size="{size}px">
18
18
  <img src={removeImageUrlPrefix(logo_url)} alt={name} height={size} width={size} />
19
19
  </a>
20
20
 
21
21
  <style lang="scss">
22
22
  .playlink {
23
23
  display: inline-block;
24
+ width: var(--size);
25
+ height: var(--size);
24
26
  background: theme(playlink-background, light);
25
27
  border-radius: theme(playlink-border-radius, border-radius);
26
28
  overflow: hidden;
@@ -36,6 +38,7 @@
36
38
  img {
37
39
  display: block;
38
40
  margin: 0;
41
+ width: auto;
39
42
  }
40
43
  }
41
44
  </style>
@@ -2,11 +2,10 @@
2
2
  import type { Snippet } from 'svelte'
3
3
 
4
4
  interface Props {
5
- size?: string
6
- children?: Snippet
7
- // eslint-disable-next-line no-unused-vars
8
- onclick?: (event: MouseEvent) => void
9
- [key: string]: any
5
+ size?: string;
6
+ children?: Snippet;
7
+ onclick?: () => void;
8
+ [key: string]: any;
10
9
  }
11
10
 
12
11
  const { children, size = '32px', onclick = () => null, ...rest }: Props = $props()
@@ -9,6 +9,7 @@
9
9
  import IconShare from './Icons/IconShare.svelte'
10
10
  import IconLink from './Icons/IconLink.svelte'
11
11
  import IconEmail from './Icons/IconEmail.svelte'
12
+ import Button from './Button.svelte'
12
13
  import { onMount } from 'svelte'
13
14
 
14
15
  interface Props {
@@ -55,9 +56,9 @@
55
56
  <svelte:window onclick={() => showContextMenu = false} />
56
57
 
57
58
  <div class="share">
58
- <button class="button" onclick={toggle} aria-label={t('Share')}>
59
- <IconShare />
60
- </button>
59
+ <Button onclick={toggle}>
60
+ <IconShare /> Share
61
+ </Button>
61
62
 
62
63
  {#if showContextMenu}
63
64
  <div class="context-menu" transition:scale={{ duration: 50, start: 0.85 }}>
@@ -79,30 +80,11 @@
79
80
  position: relative;
80
81
  }
81
82
 
82
- .button {
83
- display: flex;
84
- align-items: center;
85
- justify-content: center;
86
- cursor: pointer;
87
- appearance: none;
88
- border: 0;
89
- border-radius: margin(3);
90
- aspect-ratio: 1 / 1;
91
- background: transparent;
92
- color: theme(detail-text-color-alt, text-color-alt);
93
-
94
- &:hover {
95
- color: theme(detail-text-color, text-color);
96
- background: theme(share-button-hover-background, content);
97
- box-shadow: 0 0 0 2px theme(share-button-hover-background, content);
98
- }
99
- }
100
-
101
83
  .context-menu {
102
84
  z-index: 10;
103
85
  position: absolute;
104
86
  bottom: calc(100% + margin(0.5));
105
- right: 0;
87
+ left: 0;
106
88
  max-width: margin(15);
107
89
  border-radius: $border-radius;
108
90
  background: theme(detail-background, lighter);
@@ -7,6 +7,7 @@
7
7
  import SimilarRail from './Rails/SimilarRail.svelte'
8
8
  import TitlePoster from './TitlePoster.svelte'
9
9
  import Share from './Share.svelte'
10
+ import Trailer from './Trailer.svelte'
10
11
  import { t } from '$lib/localization'
11
12
  import type { TitleData } from '$lib/types/title'
12
13
  import { heading } from '$lib/actions/heading'
@@ -36,26 +37,28 @@
36
37
 
37
38
  <div class="heading" use:heading={2} class:truncate={small} id="title">{title.title}</div>
38
39
 
39
- <div class="row">
40
- <div class="info">
41
- <div class="imdb">
42
- <IconIMDb />
43
- {title.imdb_score}
44
- </div>
40
+ <div class="info">
41
+ <div class="imdb">
42
+ <IconIMDb />
43
+ {title.imdb_score}
44
+ </div>
45
45
 
46
- <Genres genres={title.genres} />
46
+ <Genres genres={title.genres} />
47
47
 
48
- <div>{title.year}</div>
49
- <div class="capitalize">{t(`Type: ${title.type}`)}</div>
48
+ <div>{title.year}</div>
49
+ <div class="capitalize">{t(`Type: ${title.type}`)}</div>
50
50
 
51
- {#if !small && title.length}
52
- <div>{title.length} {t('Minutes')}</div>
53
- {/if}
54
- </div>
51
+ {#if !small && title.length}
52
+ <div>{title.length} {t('Minutes')}</div>
53
+ {/if}
54
+ </div>
55
55
 
56
- <div class="action">
56
+ <div class="actions">
57
+ <!-- !! Button is temporarily always visible while embeddable_url is not yet available -->
58
+ {#if true || title.embeddable_url}
59
+ <Trailer title={title} />
57
60
  <Share title={title.title} url={titleUrl(title)} />
58
- </div>
61
+ {/if}
59
62
  </div>
60
63
  </div>
61
64
 
@@ -146,11 +149,6 @@
146
149
  }
147
150
  }
148
151
 
149
- .row {
150
- display: flex;
151
- align-items: flex-start;
152
- }
153
-
154
152
  .info {
155
153
  display: flex;
156
154
  flex-wrap: wrap;
@@ -182,8 +180,10 @@
182
180
  }
183
181
  }
184
182
 
185
- .action {
186
- margin: margin(-0.125) 0 0 auto;
183
+ .actions {
184
+ display: flex;
185
+ gap: margin(0.5);
186
+ margin-top: margin(0.5);
187
187
  }
188
188
 
189
189
  .background {
@@ -0,0 +1,18 @@
1
+ <script lang="ts">
2
+ import { t } from '$lib/localization'
3
+ import { openTrailerOverlay } from '$lib/trailer'
4
+ import type { TitleData } from '$lib/types/title'
5
+ import Button from './Button.svelte'
6
+ import IconPlay from './Icons/IconPlay.svelte'
7
+
8
+ interface Props {
9
+ title: TitleData
10
+ }
11
+
12
+ const { title }: Props = $props()
13
+ </script>
14
+
15
+ <Button onclick={() => openTrailerOverlay(title)}>
16
+ <IconPlay />
17
+ {t('Watch Trailer')}
18
+ </Button>
@@ -0,0 +1,96 @@
1
+ <script lang="ts">
2
+ import { fade } from 'svelte/transition'
3
+ import IconClose from './Icons/IconClose.svelte'
4
+
5
+ interface Props {
6
+ embeddable_url: string,
7
+ onclose: () => void
8
+ }
9
+
10
+ const { embeddable_url, onclose }: Props = $props()
11
+
12
+ const videoId = $derived(getVideoId(embeddable_url))
13
+
14
+ // Gets the YouTube ID from a url, can be a large number of differnet formats
15
+ // https://stackoverflow.com/a/54200105/1665157
16
+ function getVideoId(url: string): string | null {
17
+ const regExp = /^.*((youtu.be\/)|(v\/)|(\/u\/\w\/)|(embed\/)|(watch\?))\??v?=?([^#&?]*).*/
18
+ const match = url.match(regExp)
19
+
20
+ return match?.[7] || null
21
+ }
22
+ </script>
23
+
24
+ <div class="overlay" transition:fade={{ duration: 100 }}>
25
+ {#if videoId}
26
+ <iframe width="600" height="338" src="https://www.youtube.com/embed/{videoId}?autoplay=true" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen></iframe>
27
+ {:else}
28
+ Something went wrong
29
+ {/if}
30
+
31
+ <!-- svelte-ignore a11y_click_events_have_key_events -->
32
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
33
+ <div class="backdrop" onclick={onclose} data-testid="backdrop"></div>
34
+
35
+ <button class="close" onclick={onclose} aria-label="Close">
36
+ <IconClose size={24} />
37
+ </button>
38
+ </div>
39
+
40
+ <style lang="scss">
41
+ iframe {
42
+ z-index: 1;
43
+ position: relative;
44
+ display: block;
45
+ width: 95vmin;
46
+ height: auto;
47
+ aspect-ratio: 16/9;
48
+ box-shadow: 0 0 margin(4) rgba(255, 255, 255, 0.15);
49
+ background: black;
50
+ }
51
+
52
+ .overlay {
53
+ z-index: 2147483647; // As high as she goes
54
+ box-sizing: border-box;
55
+ display: flex;
56
+ align-items: center;
57
+ justify-content: center;
58
+ position: fixed;
59
+ top: 0;
60
+ right: 0;
61
+ bottom: 0;
62
+ left: 0;
63
+ background: theme(detail-backdrop, rgba(0, 0, 0, 0.95));
64
+ }
65
+
66
+ .backdrop {
67
+ z-index: 0;
68
+ position: fixed;
69
+ top: 0;
70
+ right: 0;
71
+ bottom: 0;
72
+ left: 0;
73
+ }
74
+
75
+ .close {
76
+ appearance: none;
77
+ z-index: 1;
78
+ position: fixed;
79
+ top: margin(2);
80
+ right: margin(2);
81
+ padding: 0;
82
+ margin: 0;
83
+ border: 0;
84
+ background: transparent;
85
+ color: white;
86
+ cursor: pointer;
87
+
88
+ &:hover {
89
+ transform: scale(1.1);
90
+ }
91
+
92
+ &:active {
93
+ transform: scale(1);
94
+ }
95
+ }
96
+ </style>