@playpilot/tpi 8.29.10 → 8.30.0

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/eslint.config.js CHANGED
@@ -50,6 +50,7 @@ export default [
50
50
  'comma-dangle': ['error', 'always-multiline'],
51
51
  'no-trailing-spaces': ['error'],
52
52
  'indent': ['warn', 2],
53
+ 'no-useless-escape': ['off'],
53
54
  'no-unused-vars': [
54
55
  'error',
55
56
  {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@playpilot/tpi",
3
- "version": "8.29.10",
3
+ "version": "8.30.0",
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "dev": "vite dev",
@@ -26,7 +26,7 @@ export function insertAfterArticlePlaylinks(elements: HTMLElement[], injections:
26
26
  target,
27
27
  props: {
28
28
  linkInjections: injections,
29
- onclickmodal: (event, injection) => openModal({ event, injection, data: injection.title_details }),
29
+ onclickmodal: (event: MouseEvent, injection: LinkInjection) => openModal({ event, injection, data: injection.title_details }),
30
30
  },
31
31
  })
32
32
  }
@@ -1,4 +1,3 @@
1
- import { participants } from '$lib/fakeData'
2
1
  import { getLanguage } from '$lib/language'
3
2
  import { getApiToken } from '$lib/token'
4
3
  import type { ParticipantData } from '$lib/types/participant'
@@ -89,7 +89,7 @@ export function injectLinksInDocument(elements: HTMLElement[], injections: LinkI
89
89
  // This is a crude way of checking if a match contains HTML elements
90
90
  // If the title is directly inside of an element we discard the rest.
91
91
  // This happens sometimes in lists that consists of nothing but titles and may contain special characters
92
- if (match.match.includes(">") && match.match.includes("<")) {
92
+ if (match.match.includes('>') && match.match.includes('<')) {
93
93
  const encodedTitle = encodeHtmlEntities(injection.title)
94
94
  const matchInsideHTMLIndex = match.match.search(new RegExp(`>\s*${encodedTitle}\s*<`)) + 1
95
95
 
@@ -12,6 +12,7 @@ declare global {
12
12
  mount(): void
13
13
  destroy(): void
14
14
  }
15
+ Plyr: any
15
16
  }
16
17
  }
17
18
 
@@ -138,12 +138,16 @@
138
138
 
139
139
  {#if value?.length}
140
140
  {#if Array.isArray(value)}
141
- {#each value as { label, data }}
142
- <div class="item">
143
- <span class="label">{label}</span>:
144
- <span class="data">{JSON.stringify(data)}</span>
145
- </div>
146
- {/each}
141
+ <table border="0" cellpadding="0">
142
+ <tbody>
143
+ {#each value as { label, data }}
144
+ <tr>
145
+ <td><span class="label">{label}</span></td>
146
+ <td><span class="data">{JSON.stringify(data)}</span></td>
147
+ </tr>
148
+ {/each}
149
+ </tbody>
150
+ </table>
147
151
  {/if}
148
152
  {:else}
149
153
  No data
@@ -293,12 +297,21 @@
293
297
  color: theme(primary);
294
298
  }
295
299
 
296
- .item {
300
+ table {
301
+ display: block;
302
+ max-width: 100%;
303
+ max-height: 15lh;
297
304
  white-space: nowrap;
298
305
  overflow-x: auto;
299
306
  scrollbar-width: thin;
300
307
  }
301
308
 
309
+ tr {
310
+ &:hover {
311
+ background: rgba(255, 255, 255, 0.15);
312
+ }
313
+ }
314
+
302
315
  .form-group {
303
316
  display: flex;
304
317
  align-items: center;
@@ -1,107 +1,107 @@
1
- <script lang="ts">
2
- import { fetchParticipantBySid } from '$lib/api/participants'
3
- import { fetchSimilarTitles, fetchTitleBySid } from '$lib/api/titles'
4
- import { mobileBreakpoint } from '$lib/constants'
5
- import { SplitTest } from '$lib/enums/SplitTest'
6
- import { t } from '$lib/localization'
7
- import { openModal, type ModalType } from '$lib/modal'
8
- import { getSplitTestVariantName, trackSplitTestView } from '$lib/splitTest'
9
- import type { TitleData } from '$lib/types/title'
10
- import Modal from '../../Modals/Modal.svelte'
11
-
12
- interface Props {
13
- navigate?: (key: string, pushState?: boolean) => void
14
- }
15
-
16
- const { navigate = () => null }: Props = $props()
17
-
18
- async function openModalViaRoute(): Promise<void> {
19
- const currentUrl = new URL(document.location.toString())
20
- const sid = currentUrl.searchParams.get('sid')
21
-
22
- if (!sid) return
23
-
24
- let type: ModalType = sid.startsWith('pr') ? 'participant' : 'title' // Sids with with `pr` for participants, `ti` for titles
25
- let data: any = null
26
-
27
- if (type === 'title') {
28
- const isMobile = window.innerWidth < mobileBreakpoint
29
- const isInReelSplitTest = isMobile && getSplitTestVariantName(SplitTest.Reels) === 'Reel'
30
-
31
- if (isMobile) trackSplitTestView(SplitTest.Reels)
32
-
33
- type = isMobile && isInReelSplitTest ? 'titles-reel' : 'titles-rail'
34
-
35
- const [title, railTitles] = (await Promise.allSettled([fetchTitleBySid(sid), fetchSimilarTitles({ sid } as unknown as TitleData)])).map((promise) =>
36
- promise.status === 'fulfilled' ? promise.value : null,
37
- )
38
- data = [title as TitleData, ...(railTitles as TitleData[])]
39
- } else if (type === 'participant') {
40
- data = await fetchParticipantBySid(sid)
41
- }
42
-
43
- openModal({
44
- type,
45
- data,
46
- props: {
47
- onclose: () => navigate('home'),
48
- pushState: false,
49
- },
50
- })
51
- }
52
- </script>
53
-
54
- {#await openModalViaRoute()}
55
- <Modal blur pushState={false}>
56
- {#snippet dialog()}
57
- <div class="loading">
58
- <div class="bar"></div>
59
- <div class="bar"></div>
60
- <div class="bar"></div>
61
- </div>
62
- {/snippet}
63
- </Modal>
64
- {:catch}
65
- <Modal blur onclose={() => navigate('home')} pushState={false}>
66
- <div class="error">
67
- {t('An Error Occurred')}
68
- </div>
69
- </Modal>
70
- {/await}
71
-
72
- <style lang="scss">
73
- .loading {
74
- display: flex;
75
- align-items: center;
76
- justify-content: center;
77
- height: 100%;
78
- gap: margin(0.5);
79
- }
80
-
81
- @keyframes animate-bar {
82
- to {
83
- height: margin(3);
84
- opacity: 0.5;
85
- }
86
- }
87
-
88
- .bar {
89
- height: margin(4);
90
- width: margin(1);
91
- border-radius: margin(0.25);
92
- background: white;
93
- animation: animate-bar 750ms infinite;
94
-
95
- @for $i from 0 through 2 {
96
- &:nth-child(#{$i}) {
97
- animation-delay: $i * 250ms;
98
- }
99
- }
100
- }
101
-
102
- .error {
103
- padding: margin(2);
104
- font-size: 1.25em;
105
- color: theme(text-color);
106
- }
107
- </style>
1
+ <script lang="ts">
2
+ import { fetchParticipantBySid } from '$lib/api/participants'
3
+ import { fetchSimilarTitles, fetchTitleBySid } from '$lib/api/titles'
4
+ import { mobileBreakpoint } from '$lib/constants'
5
+ import { SplitTest } from '$lib/enums/SplitTest'
6
+ import { t } from '$lib/localization'
7
+ import { openModal, type ModalType } from '$lib/modal'
8
+ import { getSplitTestVariantName, trackSplitTestView } from '$lib/splitTest'
9
+ import type { TitleData } from '$lib/types/title'
10
+ import Modal from '../../Modals/Modal.svelte'
11
+
12
+ interface Props {
13
+ navigate?: (key: string, pushState?: boolean) => void
14
+ }
15
+
16
+ const { navigate = () => null }: Props = $props()
17
+
18
+ async function openModalViaRoute(): Promise<void> {
19
+ const currentUrl = new URL(document.location.toString())
20
+ const sid = currentUrl.searchParams.get('sid')
21
+
22
+ if (!sid) return
23
+
24
+ let type: ModalType = sid.startsWith('pr') ? 'participant' : 'title' // Sids with with `pr` for participants, `ti` for titles
25
+ let data: any = null
26
+
27
+ if (type === 'title') {
28
+ const isMobile = window.innerWidth < mobileBreakpoint
29
+ const isInReelSplitTest = isMobile && getSplitTestVariantName(SplitTest.Reels) === 'Reel'
30
+
31
+ if (isMobile) trackSplitTestView(SplitTest.Reels)
32
+
33
+ type = isMobile && isInReelSplitTest ? 'titles-reel' : 'titles-rail'
34
+
35
+ const [title, railTitles] = (await Promise.allSettled([fetchTitleBySid(sid), fetchSimilarTitles({ sid } as unknown as TitleData)])).map((promise) =>
36
+ promise.status === 'fulfilled' ? promise.value : null,
37
+ )
38
+ data = [title as TitleData, ...(railTitles as TitleData[])]
39
+ } else if (type === 'participant') {
40
+ data = await fetchParticipantBySid(sid)
41
+ }
42
+
43
+ openModal({
44
+ type,
45
+ data,
46
+ props: {
47
+ onclose: () => navigate('home'),
48
+ pushState: false,
49
+ },
50
+ })
51
+ }
52
+ </script>
53
+
54
+ {#await openModalViaRoute()}
55
+ <Modal blur pushState={false}>
56
+ {#snippet dialog()}
57
+ <div class="loading">
58
+ <div class="bar"></div>
59
+ <div class="bar"></div>
60
+ <div class="bar"></div>
61
+ </div>
62
+ {/snippet}
63
+ </Modal>
64
+ {:catch}
65
+ <Modal blur onclose={() => navigate('home')} pushState={false}>
66
+ <div class="error">
67
+ {t('An Error Occurred')}
68
+ </div>
69
+ </Modal>
70
+ {/await}
71
+
72
+ <style lang="scss">
73
+ .loading {
74
+ display: flex;
75
+ align-items: center;
76
+ justify-content: center;
77
+ height: 100%;
78
+ gap: margin(0.5);
79
+ }
80
+
81
+ @keyframes animate-bar {
82
+ to {
83
+ height: margin(3);
84
+ opacity: 0.5;
85
+ }
86
+ }
87
+
88
+ .bar {
89
+ height: margin(4);
90
+ width: margin(1);
91
+ border-radius: margin(0.25);
92
+ background: white;
93
+ animation: animate-bar 750ms infinite;
94
+
95
+ @for $i from 0 through 2 {
96
+ &:nth-child(#{$i}) {
97
+ animation-delay: $i * 250ms;
98
+ }
99
+ }
100
+ }
101
+
102
+ .error {
103
+ padding: margin(2);
104
+ font-size: 1.25em;
105
+ color: theme(text-color);
106
+ }
107
+ </style>
@@ -22,7 +22,7 @@
22
22
  onclose?: () => void
23
23
  }
24
24
 
25
- const { titles, onclose = () => null }: Props = $props()
25
+ const { titles, onclose = () => null, ...restProps }: Props = $props()
26
26
 
27
27
 
28
28
  let interval: ReturnType<typeof setInterval> | null = null
@@ -74,7 +74,7 @@
74
74
  {/snippet}
75
75
 
76
76
  <div class="reel" style:--available-height="{availableHeight}px">
77
- <Modal {prepend}>
77
+ <Modal {prepend} {onclose} {...restProps}>
78
78
  {#snippet dialog()}
79
79
  <div class="slider" bind:this={element}>
80
80
  <TinySlider vertical threshold={50} moveThreshold={10} {onchange}>
@@ -142,7 +142,7 @@
142
142
  overscroll-behavior: contain;
143
143
  }
144
144
 
145
- :global(iframe) {
145
+ :global(.plyr) {
146
146
  pointer-events: none;
147
147
  z-index: 0;
148
148
  }
@@ -309,7 +309,7 @@
309
309
  background: black;
310
310
  z-index: 1;
311
311
 
312
- :global(iframe) {
312
+ :global(.plyr) {
313
313
  opacity: 0;
314
314
  animation: fade-iframe 500ms 500ms forwards;
315
315
 
@@ -14,14 +14,14 @@
14
14
  fallbackStartTime?: number
15
15
  }
16
16
 
17
- const { embeddable_url = '', controls = [], muted = false, loop = false, captions = false, autoplay = true, showMuteControls = false, fallbackStartTime = 0 }: Props = $props()
17
+ let { embeddable_url = '', controls = [], muted = false, loop = false, captions = false, autoplay = true, showMuteControls = false, fallbackStartTime = 0 }: Props = $props()
18
18
 
19
19
  const videoId = $derived(getVideoId(embeddable_url))
20
20
  const startTime = $derived((window?.PlayPilotLinkInjections?.video_playtimes?.[videoId || ''] || (fallbackStartTime + 1)) - 1)
21
21
  const color = window?.getComputedStyle(document.body).getPropertyValue('--playpilot-primary')?.replace('#', '') || 'fa548a'
22
22
 
23
- let iframe: HTMLIFrameElement | null = $state(null)
24
- let isMuted = $state(muted)
23
+ let element: HTMLElement | null = $state(null)
24
+ let player: any
25
25
 
26
26
  onMount(() => {
27
27
  if (!videoId) return
@@ -37,37 +37,74 @@
37
37
  return () => clearInterval(interval)
38
38
  })
39
39
 
40
- export function toggleMute(state = !isMuted): void {
41
- if (isMuted != state) iframe?.contentWindow?.postMessage('mute', '*')
42
- isMuted = state
40
+ function initialize(): void {
41
+ if (typeof window.Plyr === 'undefined') return
42
+ if (!element) return
43
+
44
+ element.dataset.plyrEmbedId = videoId!
45
+ element.style.setProperty('--plyr-color-main', '#' + color)
46
+
47
+ player = new window.Plyr(element, {
48
+ controls,
49
+ playsinline: true,
50
+ autoplay,
51
+ muted,
52
+ clickToPlay: false,
53
+ youtube: {
54
+ noCookie: true,
55
+ cc_load_policy: captions ? 1 : 0,
56
+ cc_lang_pref: 'auto',
57
+ },
58
+ })
59
+
60
+ player.on('ready', () => {
61
+ player.currentTime = startTime
62
+ toggleMute(muted)
63
+ })
64
+
65
+ if (!loop) return
66
+
67
+ player.on('statechange', (event: { detail: { code: number } }) => {
68
+ if (event.detail.code !== 0) return
69
+
70
+ requestAnimationFrame(() => {
71
+ player.play()
72
+ toggleMute(muted)
73
+ })
74
+ })
75
+ }
76
+
77
+ export function toggleMute(state = !muted): void {
78
+ player.muted = state
79
+ player.volume = muted ? 0 : 1
80
+
81
+ muted = state
43
82
  }
44
83
 
45
84
  export function play(event: CustomEvent): void {
46
- if (event.detail === embeddable_url) iframe?.contentWindow?.postMessage('play', '*')
85
+ if (event.detail === embeddable_url) player?.play()
47
86
  }
48
87
 
49
88
  export function pause(event: CustomEvent): void {
50
- if (event.detail === embeddable_url) iframe?.contentWindow?.postMessage('pause', '*')
89
+ if (event.detail === embeddable_url) player?.pause()
51
90
  }
52
91
  </script>
53
92
 
54
93
  <svelte:window onplayembed={play} onpauseembed={pause} />
55
94
 
95
+ <svelte:head>
96
+ {#if videoId}
97
+ <script src="https://unpkg.com/plyr@3" onload={initialize}></script>
98
+ <link rel="stylesheet" href="https://unpkg.com/plyr@3/dist/plyr.css" />
99
+ {/if}
100
+ </svelte:head>
101
+
56
102
  {#if videoId}
57
- <iframe
58
- bind:this={iframe}
59
- width="600"
60
- height="338"
61
- 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"
62
- title="YouTube video player"
63
- frameborder="0"
64
- referrerpolicy="strict-origin-when-cross-origin"
65
- allowfullscreen>
66
- </iframe>
103
+ <div bind:this={element} class="player" data-plyr-provider="youtube" data-plyr-embed-id data-testid={embeddable_url}></div>
67
104
 
68
105
  {#if showMuteControls}
69
106
  <button class="mute" onclick={() => toggleMute()} aria-label="Mute">
70
- <IconMute muted={isMuted} />
107
+ <IconMute {muted} />
71
108
  </button>
72
109
  {/if}
73
110
  {:else}
@@ -75,7 +112,11 @@
75
112
  {/if}
76
113
 
77
114
  <style lang="scss">
78
- iframe {
115
+ .player {
116
+ --plyr-control-icon-size: 24px;
117
+ --plyr-video-controls-background: linear-gradient(rgba(0, 0, 0, 0), black);
118
+ --plyr-font-size-time: 16px;
119
+ --plyr-font-family: 'Consolas', monospace;
79
120
  z-index: -1;
80
121
  position: relative;
81
122
  width: 100%;
@@ -22,6 +22,7 @@
22
22
  .video-background {
23
23
  background: black;
24
24
 
25
+ :global(.plyr),
25
26
  :global(iframe) {
26
27
  position: absolute;
27
28
  height: var(--height);
@@ -37,7 +37,7 @@
37
37
  left: 0;
38
38
  background: theme(detail-backdrop, rgba(0, 0, 0, 0.95));
39
39
 
40
- :global(iframe) {
40
+ :global(.plyr) {
41
41
  z-index: 1;
42
42
  position: relative;
43
43
  display: block;
@@ -22,6 +22,7 @@
22
22
  import TitlesRail from '../components/Rails/TitlesRail.svelte'
23
23
  import ParticipantsRail from '../components/Rails/ParticipantsRail.svelte'
24
24
  import InfiniteTitlesRail from '../components/Rails/InfiniteTitlesRail.svelte'
25
+ import YouTubeEmbed from '../components/YouTubeEmbed.svelte'
25
26
 
26
27
  if (browser) {
27
28
  // @ts-ignore
@@ -43,6 +44,12 @@
43
44
 
44
45
  <p>This page lays out various elements used in TPI. It does not currently include editor elements.</p>
45
46
 
47
+ <h2>Youtube Embed</h2>
48
+
49
+ <div class="video">
50
+ <YouTubeEmbed embeddable_url="https://www.youtube.com/watch?v=bBMajXwi6Cs" controls={['progress', 'play']} loop showMuteControls muted />
51
+ </div>
52
+
46
53
  <h2>Title</h2>
47
54
 
48
55
  <div class="group">
@@ -267,4 +274,9 @@
267
274
  padding: margin(2);
268
275
  margin-bottom: margin(1);
269
276
  }
277
+
278
+ .video {
279
+ position: relative;
280
+ max-width: 400px;
281
+ }
270
282
  </style>
@@ -469,10 +469,10 @@ describe('injection.ts', () => {
469
469
  })
470
470
 
471
471
  it('Should return sentence message if text was found in full body text but not in individual elements', () => {
472
- document.body.innerHTML = `<article><p>Some sentence</p><p>that is broken in two</p></main>`
472
+ document.body.innerHTML = '<article><p>Some sentence</p><p>that is broken in two</p></main>'
473
473
 
474
474
  const elements = Array.from(document.body.querySelectorAll('p'))
475
- const linkInjections = [generateInjection('Some sentence that is broken in two', 'broken'),]
475
+ const linkInjections = [generateInjection('Some sentence that is broken in two', 'broken')]
476
476
 
477
477
  const results = injectLinksInDocument(elements, { aiInjections: linkInjections, manualInjections: [] })
478
478