sanity-plugin-mux-input 2.4.0 → 2.5.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sanity-plugin-mux-input",
3
- "version": "2.4.0",
3
+ "version": "2.5.0",
4
4
  "description": "An input component that integrates Sanity Studio with Mux video encoding/hosting service.",
5
5
  "keywords": [
6
6
  "sanity",
@@ -41,8 +41,7 @@
41
41
  ],
42
42
  "scripts": {
43
43
  "build": "plugin-kit verify-package --silent && pkg-utils build --strict --check --clean",
44
- "clean": "rimraf lib",
45
- "dev": "plugin-kit link-watch --strict",
44
+ "dev": "sanity dev",
46
45
  "format": "prettier --write --cache --ignore-unknown .",
47
46
  "link-watch": "plugin-kit link-watch",
48
47
  "lint": "eslint .",
@@ -71,15 +70,14 @@
71
70
  "use-error-boundary": "^2.0.6"
72
71
  },
73
72
  "devDependencies": {
74
- "@sanity/client": "^6.22.4",
75
- "@sanity/pkg-utils": "^6.11.11",
76
- "@sanity/plugin-kit": "4.0.18",
73
+ "@sanity/client": "^6.24.1",
74
+ "@sanity/pkg-utils": "^6.12.1",
75
+ "@sanity/plugin-kit": "4.0.19",
77
76
  "@sanity/semantic-release-preset": "^5.0.0",
78
- "@sanity/vision": "^3.64.1",
77
+ "@sanity/vision": "^3.74.0",
79
78
  "@types/lodash": "^4.17.13",
80
- "@types/react": "^18.3.12",
81
- "@types/react-is": "^18.3.0",
82
- "@types/styled-components": "^5.1.34",
79
+ "@types/react": "^18.3.17",
80
+ "@types/react-is": "^18.3.1",
83
81
  "@typescript-eslint/eslint-plugin": "^7.18.0",
84
82
  "@typescript-eslint/parser": "^7.18.0",
85
83
  "eslint": "^8.57.1",
@@ -88,22 +86,20 @@
88
86
  "eslint-config-sanity": "^7.1.3",
89
87
  "eslint-plugin-import": "^2.31.0",
90
88
  "eslint-plugin-prettier": "^5.2.1",
91
- "eslint-plugin-react-hooks": "^5.0.0",
89
+ "eslint-plugin-react-hooks": "^5.1.0",
92
90
  "eslint-plugin-simple-import-sort": "^12.1.1",
93
91
  "husky": "^9.0.11",
94
92
  "lint-staged": "^15.2.2",
95
93
  "npm-run-all2": "^5.0.2",
96
- "prettier": "^3.3.3",
97
- "prettier-plugin-packagejson": "^2.5.3",
94
+ "prettier": "^3.4.2",
95
+ "prettier-plugin-packagejson": "^2.5.6",
98
96
  "react": "^18.3.1",
99
97
  "react-dom": "^18.3.1",
100
98
  "react-is": "^18.3.1",
101
- "rimraf": "^5.0.7",
102
- "sanity": "^3.64.1",
99
+ "sanity": "^3.74.0",
103
100
  "semantic-release": "^24.2.0",
104
101
  "styled-components": "^6.1.13",
105
- "typescript": "^5.6.3",
106
- "yalc": "1.0.0-pre.53"
102
+ "typescript": "5.7.3"
107
103
  },
108
104
  "peerDependencies": {
109
105
  "react": "^18",
@@ -0,0 +1,122 @@
1
+ import {Button, Dialog, Flex, Stack, Text, TextInput} from '@sanity/ui'
2
+ import React, {useId, useMemo, useState} from 'react'
3
+ import {getDevicePixelRatio} from 'use-device-pixel-ratio'
4
+
5
+ import {useDialogStateContext} from '../context/DialogStateContext'
6
+ import {useClient} from '../hooks/useClient'
7
+ import {
8
+ formatSecondsToHHMMSS,
9
+ getSecondsFromTimeFormat,
10
+ isValidTimeFormat,
11
+ } from '../util/formatSeconds'
12
+ import type {VideoAssetDocument} from '../util/types'
13
+ import VideoThumbnail from './VideoThumbnail'
14
+
15
+ export interface Props {
16
+ asset: VideoAssetDocument
17
+ currentTime?: number
18
+ }
19
+
20
+ export default function EditThumbnailDialog({asset, currentTime = 0}: Props) {
21
+ const client = useClient()
22
+
23
+ const {setDialogState} = useDialogStateContext()
24
+ const dialogId = `EditThumbnailDialog${useId()}`
25
+
26
+ const [timeFormatted, setTimeFormatted] = useState<string>(() =>
27
+ formatSecondsToHHMMSS(currentTime)
28
+ )
29
+ const [nextTime, setNextTime] = useState<number>(currentTime)
30
+ const [inputError, setInputError] = useState<string>('')
31
+
32
+ const assetWithNewThumbnail = useMemo(() => ({...asset, thumbTime: nextTime}), [asset, nextTime])
33
+ const [saving, setSaving] = useState(false)
34
+ const [saveThumbnailError, setSaveThumbnailError] = useState<Error | null>(null)
35
+ const handleSave = () => {
36
+ setSaving(true)
37
+ client
38
+ .patch(asset._id!)
39
+ .set({thumbTime: nextTime})
40
+ .commit({returnDocuments: false})
41
+ .then(() => void setDialogState(false))
42
+ .catch(setSaveThumbnailError)
43
+ .finally(() => void setSaving(false))
44
+ }
45
+ const width = 300 * getDevicePixelRatio({maxDpr: 2})
46
+
47
+ if (saveThumbnailError) {
48
+ // eslint-disable-next-line no-warning-comments
49
+ // @TODO handle errors more gracefully
50
+ throw saveThumbnailError
51
+ }
52
+
53
+ const handleInputChange = (event: React.FormEvent<HTMLInputElement>) => {
54
+ const value = event.currentTarget.value
55
+ setTimeFormatted(value)
56
+
57
+ if (isValidTimeFormat(value)) {
58
+ setInputError('')
59
+ const totalSeconds = getSecondsFromTimeFormat(value)
60
+ setNextTime(totalSeconds)
61
+ } else {
62
+ setInputError('Invalid time format')
63
+ }
64
+ }
65
+
66
+ return (
67
+ <Dialog
68
+ id={dialogId}
69
+ header="Edit thumbnail"
70
+ onClose={() => setDialogState(false)}
71
+ footer={
72
+ <Stack padding={3}>
73
+ <Button
74
+ key="thumbnail"
75
+ disabled={inputError !== ''}
76
+ mode="ghost"
77
+ tone="primary"
78
+ loading={saving}
79
+ onClick={handleSave}
80
+ text="Set new thumbnail"
81
+ />
82
+ </Stack>
83
+ }
84
+ >
85
+ <Stack space={3} padding={3}>
86
+ <Stack space={2}>
87
+ <Text size={1} weight="semibold">
88
+ Current:
89
+ </Text>
90
+ <VideoThumbnail asset={asset} width={width} staticImage />
91
+ </Stack>
92
+ <Stack space={2}>
93
+ <Text size={1} weight="semibold">
94
+ New:
95
+ </Text>
96
+ <VideoThumbnail asset={assetWithNewThumbnail} width={width} staticImage />
97
+ </Stack>
98
+
99
+ <Stack space={2}>
100
+ <Flex align={'center'} justify={'center'}>
101
+ <Text size={5} weight="semibold">
102
+ Or
103
+ </Text>
104
+ </Flex>
105
+ </Stack>
106
+
107
+ <Stack space={2}>
108
+ <Text size={1} weight="semibold">
109
+ Selected time for thumbnail (hh:mm:ss):
110
+ </Text>
111
+ <TextInput
112
+ size={1}
113
+ value={timeFormatted}
114
+ placeholder="hh:mm:ss"
115
+ onChange={handleInputChange}
116
+ customValidity={inputError}
117
+ />
118
+ </Stack>
119
+ </Stack>
120
+ </Dialog>
121
+ )
122
+ }
@@ -1,5 +1,6 @@
1
1
  import {
2
2
  EllipsisHorizontalIcon,
3
+ ImageIcon,
3
4
  LockIcon,
4
5
  PlugIcon,
5
6
  ResetIcon,
@@ -43,6 +44,11 @@ const LockButton = styled(Button)`
43
44
  color: white;
44
45
  `
45
46
 
47
+ // @TODO: add support for audio type (asset._type) when uploading an audio file so we can hide the thumbnail option.
48
+ const isVideoAsset = (asset: VideoAssetDocument) => {
49
+ return asset._type === 'mux.videoAsset'
50
+ }
51
+
46
52
  function PlayerActionsMenu(
47
53
  props: Pick<MuxInputProps, 'onChange' | 'readOnly'> & {
48
54
  asset: VideoAssetDocument
@@ -111,6 +117,13 @@ function PlayerActionsMenu(
111
117
  text="Browse"
112
118
  onClick={() => setDialogState('select-video')}
113
119
  />
120
+ {isVideoAsset(asset) && (
121
+ <MenuItem
122
+ icon={ImageIcon}
123
+ text="Thumbnail"
124
+ onClick={() => setDialogState('edit-thumbnail')}
125
+ />
126
+ )}
114
127
  <MenuDivider />
115
128
  <MenuItem
116
129
  icon={PlugIcon}
@@ -7,6 +7,7 @@ import type {SanityClient} from 'sanity'
7
7
  import {PatchEvent, set, setIfMissing} from 'sanity'
8
8
 
9
9
  import {uploadFile, uploadUrl} from '../actions/upload'
10
+ import {DialogStateProvider} from '../context/DialogStateContext'
10
11
  import {type DialogState, type SetDialogState} from '../hooks/useDialogState'
11
12
  import {isValidUrl} from '../util/asserters'
12
13
  import {extractDroppedFiles} from '../util/extractFiles'
@@ -347,21 +348,26 @@ export default function Uploader(props: Props) {
347
348
  ref={containerRef}
348
349
  >
349
350
  {props.asset ? (
350
- <Player
351
- readOnly={props.readOnly}
352
- asset={props.asset}
353
- onChange={props.onChange}
354
- buttons={
355
- <PlayerActionsMenu
356
- asset={props.asset}
357
- dialogState={props.dialogState}
358
- setDialogState={props.setDialogState}
359
- onChange={props.onChange}
360
- onSelect={handleUpload}
361
- readOnly={props.readOnly}
362
- />
363
- }
364
- />
351
+ <DialogStateProvider
352
+ dialogState={props.dialogState}
353
+ setDialogState={props.setDialogState}
354
+ >
355
+ <Player
356
+ readOnly={props.readOnly}
357
+ asset={props.asset}
358
+ onChange={props.onChange}
359
+ buttons={
360
+ <PlayerActionsMenu
361
+ asset={props.asset}
362
+ dialogState={props.dialogState}
363
+ setDialogState={props.setDialogState}
364
+ onChange={props.onChange}
365
+ onSelect={handleUpload}
366
+ readOnly={props.readOnly}
367
+ />
368
+ }
369
+ />
370
+ </DialogStateProvider>
365
371
  ) : (
366
372
  <UploadPlaceholder
367
373
  hovering={dragState !== null}
@@ -1,23 +1,32 @@
1
- import MuxPlayer, {type MuxPlayerProps} from '@mux/mux-player-react'
1
+ import MuxPlayer, {type MuxPlayerProps, type MuxPlayerRefAttributes} from '@mux/mux-player-react'
2
2
  import {ErrorOutlineIcon} from '@sanity/icons'
3
3
  import {Card, Text} from '@sanity/ui'
4
- import {type PropsWithChildren, useMemo} from 'react'
4
+ import {type PropsWithChildren, useMemo, useRef} from 'react'
5
5
 
6
+ import {useDialogStateContext} from '../context/DialogStateContext'
6
7
  import {useClient} from '../hooks/useClient'
7
8
  import {AUDIO_ASPECT_RATIO, MIN_ASPECT_RATIO} from '../util/constants'
9
+ import {getPosterSrc} from '../util/getPosterSrc'
8
10
  import {getVideoSrc} from '../util/getVideoSrc'
9
11
  import type {VideoAssetDocument} from '../util/types'
12
+ import EditThumbnailDialog from './EditThumbnailDialog'
10
13
 
11
14
  export default function VideoPlayer({
12
15
  asset,
16
+ thumbnailWidth = 250,
13
17
  children,
14
18
  ...props
15
19
  }: PropsWithChildren<
16
- {asset: VideoAssetDocument; forceAspectRatio?: number} & Partial<Pick<MuxPlayerProps, 'autoPlay'>>
20
+ {asset: VideoAssetDocument; thumbnailWidth?: number; forceAspectRatio?: number} & Partial<
21
+ Pick<MuxPlayerProps, 'autoPlay'>
22
+ >
17
23
  >) {
18
24
  const client = useClient()
25
+ const {dialogState} = useDialogStateContext()
19
26
 
20
27
  const isAudio = assetIsAudio(asset)
28
+ const muxPlayer = useRef<MuxPlayerRefAttributes>(null)
29
+ const thumbnail = getPosterSrc({asset, client, width: thumbnailWidth})
21
30
 
22
31
  const {src: videoSrc, error} = useMemo(() => {
23
32
  try {
@@ -52,55 +61,63 @@ export default function VideoPlayer({
52
61
  }
53
62
 
54
63
  return (
55
- <Card tone="transparent" style={{aspectRatio: aspectRatio, position: 'relative'}}>
56
- {videoSrc && (
57
- <>
58
- <MuxPlayer
59
- {...props}
60
- playsInline
61
- playbackId={asset.playbackId}
62
- tokens={
63
- signedToken
64
- ? {playback: signedToken, thumbnail: signedToken, storyboard: signedToken}
65
- : undefined
66
- }
67
- preload="metadata"
68
- crossOrigin="anonymous"
69
- metadata={{
70
- player_name: 'Sanity Admin Dashboard',
71
- player_version: process.env.PKG_VERSION,
72
- page_type: 'Preview Player',
73
- }}
74
- audio={isAudio}
64
+ <>
65
+ <Card tone="transparent" style={{aspectRatio: aspectRatio, position: 'relative'}}>
66
+ {videoSrc && (
67
+ <>
68
+ <MuxPlayer
69
+ poster={thumbnail}
70
+ ref={muxPlayer}
71
+ {...props}
72
+ playsInline
73
+ playbackId={asset.playbackId}
74
+ tokens={
75
+ signedToken
76
+ ? {playback: signedToken, thumbnail: signedToken, storyboard: signedToken}
77
+ : undefined
78
+ }
79
+ preload="metadata"
80
+ crossOrigin="anonymous"
81
+ metadata={{
82
+ player_name: 'Sanity Admin Dashboard',
83
+ player_version: process.env.PKG_VERSION,
84
+ page_type: 'Preview Player',
85
+ }}
86
+ audio={isAudio}
87
+ style={{
88
+ height: '100%',
89
+ width: '100%',
90
+ display: 'block',
91
+ objectFit: 'contain',
92
+ }}
93
+ />
94
+ {children}
95
+ </>
96
+ )}
97
+ {error ? (
98
+ <div
75
99
  style={{
76
- height: '100%',
77
- width: '100%',
78
- display: 'block',
79
- objectFit: 'contain',
100
+ position: 'absolute',
101
+ top: '50%',
102
+ left: '50%',
103
+ transform: 'translate(-50%, -50%)',
80
104
  }}
81
- />
82
- {children}
83
- </>
105
+ >
106
+ <Text muted>
107
+ <ErrorOutlineIcon style={{marginRight: '0.15em'}} />
108
+ {typeof error === 'object' && 'message' in error && typeof error.message === 'string'
109
+ ? error.message
110
+ : 'Error loading video'}
111
+ </Text>
112
+ </div>
113
+ ) : null}
114
+ {children}
115
+ </Card>
116
+
117
+ {dialogState === 'edit-thumbnail' && (
118
+ <EditThumbnailDialog asset={asset} currentTime={muxPlayer?.current?.currentTime} />
84
119
  )}
85
- {error ? (
86
- <div
87
- style={{
88
- position: 'absolute',
89
- top: '50%',
90
- left: '50%',
91
- transform: 'translate(-50%, -50%)',
92
- }}
93
- >
94
- <Text muted>
95
- <ErrorOutlineIcon style={{marginRight: '0.15em'}} />
96
- {typeof error === 'object' && 'message' in error && typeof error.message === 'string'
97
- ? error.message
98
- : 'Error loading video'}
99
- </Text>
100
- </div>
101
- ) : null}
102
- {children}
103
- </Card>
120
+ </>
104
121
  )
105
122
  }
106
123
 
@@ -6,8 +6,9 @@ import {styled} from 'styled-components'
6
6
  import {useClient} from '../hooks/useClient'
7
7
  import useInView from '../hooks/useInView'
8
8
  import {THUMBNAIL_ASPECT_RATIO} from '../util/constants'
9
- import {type AnimatedPosterSrcOptions, getAnimatedPosterSrc} from '../util/getAnimatedPosterSrc'
10
- import {VideoAssetDocument} from '../util/types'
9
+ import {getAnimatedPosterSrc} from '../util/getAnimatedPosterSrc'
10
+ import {getPosterSrc} from '../util/getPosterSrc'
11
+ import {AssetThumbnailOptions, MuxAnimatedThumbnailUrl, MuxThumbnailUrl} from '../util/types'
11
12
 
12
13
  const Image = styled.img`
13
14
  transition: opacity 0.175s ease-out 0s;
@@ -29,9 +30,11 @@ const STATUS_TO_TONE: Record<ImageStatus, CardTone> = {
29
30
  export default function VideoThumbnail({
30
31
  asset,
31
32
  width,
33
+ staticImage = false,
32
34
  }: {
33
- asset: AnimatedPosterSrcOptions['asset'] & Pick<VideoAssetDocument, 'filename' | 'assetId'>
35
+ asset: AssetThumbnailOptions['asset']
34
36
  width?: number
37
+ staticImage?: boolean
35
38
  }) {
36
39
  const {inView, ref} = useInView()
37
40
  const posterWidth = width || 250
@@ -39,14 +42,18 @@ export default function VideoThumbnail({
39
42
  const [status, setStatus] = useState<ImageStatus>('loading')
40
43
  const client = useClient()
41
44
 
42
- const animatedSrc = useMemo(() => {
45
+ const src = useMemo(() => {
43
46
  try {
44
- return getAnimatedPosterSrc({asset, client, width: posterWidth})
47
+ let thumbnail: MuxAnimatedThumbnailUrl | MuxThumbnailUrl
48
+ if (staticImage) thumbnail = getPosterSrc({asset, client, width: posterWidth})
49
+ else thumbnail = getAnimatedPosterSrc({asset, client, width: posterWidth})
50
+
51
+ return thumbnail
45
52
  } catch {
46
53
  if (status !== 'error') setStatus('error')
47
54
  return undefined
48
55
  }
49
- }, [asset, client, posterWidth, status])
56
+ }, [asset, client, posterWidth, status, staticImage])
50
57
 
51
58
  function handleLoad() {
52
59
  setStatus('loaded')
@@ -105,8 +112,8 @@ export default function VideoThumbnail({
105
112
  </Stack>
106
113
  )}
107
114
  <Image
108
- src={animatedSrc}
109
- alt={`Preview for video ${asset.filename || asset.assetId}`}
115
+ src={src}
116
+ alt={`Preview for ${staticImage ? 'image' : 'video'} ${asset.filename || asset.assetId}`}
110
117
  onLoad={handleLoad}
111
118
  onError={handleError}
112
119
  style={{
@@ -0,0 +1,36 @@
1
+ import React, {createContext, useContext} from 'react'
2
+
3
+ import {type DialogState, type SetDialogState} from '../hooks/useDialogState'
4
+
5
+ type DialogStateContextProps = {
6
+ dialogState: DialogState
7
+ setDialogState: SetDialogState
8
+ }
9
+
10
+ const DialogStateContext = createContext<DialogStateContextProps>({
11
+ dialogState: false,
12
+ setDialogState: () => {
13
+ return null
14
+ },
15
+ })
16
+
17
+ interface DialogStateProviderProps extends DialogStateContextProps {
18
+ children: React.ReactNode
19
+ }
20
+
21
+ export const DialogStateProvider = ({
22
+ dialogState,
23
+ setDialogState,
24
+ children,
25
+ }: DialogStateProviderProps) => {
26
+ return (
27
+ <DialogStateContext.Provider value={{dialogState, setDialogState}}>
28
+ {children}
29
+ </DialogStateContext.Provider>
30
+ )
31
+ }
32
+
33
+ export const useDialogStateContext = () => {
34
+ const context = useContext(DialogStateContext)
35
+ return context
36
+ }
@@ -1,5 +1,6 @@
1
1
  import {useMemo, useState} from 'react'
2
- import {collate, createHookFromObservableFactory, DocumentStore, useDocumentStore} from 'sanity'
2
+ import {useObservable} from 'react-rx'
3
+ import {collate, DocumentStore, useDocumentStore} from 'sanity'
3
4
 
4
5
  import {SANITY_API_VERSION} from '../hooks/useClient'
5
6
  import {createSearchFilter} from '../util/createSearchFilter'
@@ -14,44 +15,49 @@ export const ASSET_SORT_OPTIONS = {
14
15
 
15
16
  export type SortOption = keyof typeof ASSET_SORT_OPTIONS
16
17
 
17
- const useAssetDocuments = createHookFromObservableFactory<
18
- VideoAssetDocument[],
19
- {
20
- documentStore: DocumentStore
21
- sort: SortOption
22
- searchQuery: string
23
- }
24
- >(({documentStore, sort, searchQuery}) => {
25
- const search = createSearchFilter(searchQuery)
26
- const filter = [`_type == "mux.videoAsset"`, ...search.filter].filter(Boolean).join(' && ')
18
+ const useAssetDocuments = ({
19
+ documentStore,
20
+ sort,
21
+ searchQuery,
22
+ }: {
23
+ documentStore: DocumentStore
24
+ sort: SortOption
25
+ searchQuery: string
26
+ }): VideoAssetDocument[] | undefined => {
27
+ const memoizedObservable = useMemo(() => {
28
+ const search = createSearchFilter(searchQuery)
29
+ const filter = [`_type == "mux.videoAsset"`, ...search.filter].filter(Boolean).join(' && ')
30
+ const sortFragment = ASSET_SORT_OPTIONS[sort].groq
31
+ return documentStore.listenQuery(
32
+ /* groq */ `*[${filter}] | order(${sortFragment})`,
33
+ search.params,
34
+ {
35
+ apiVersion: SANITY_API_VERSION,
36
+ }
37
+ )
38
+ }, [documentStore, sort, searchQuery])
27
39
 
28
- const sortFragment = ASSET_SORT_OPTIONS[sort].groq
29
- return documentStore.listenQuery(
30
- /* groq */ `*[${filter}] | order(${sortFragment})`,
31
- search.params,
32
- {
33
- apiVersion: SANITY_API_VERSION,
34
- }
35
- )
36
- })
40
+ return useObservable(memoizedObservable, undefined)
41
+ }
37
42
 
38
43
  export default function useAssets() {
39
44
  const documentStore = useDocumentStore()
40
45
  const [sort, setSort] = useState<SortOption>('createdDesc')
41
46
  const [searchQuery, setSearchQuery] = useState('')
42
47
 
43
- const [assetDocuments = [], isLoading] = useAssetDocuments({documentStore, sort, searchQuery})
48
+ const assetDocumentsObservable = useAssetDocuments({documentStore, sort, searchQuery})
49
+ const isLoading = assetDocumentsObservable === undefined
44
50
  const assets = useMemo(
45
51
  () =>
46
52
  // Avoid displaying both drafts & published assets by collating them together and giving preference to drafts
47
- collate<VideoAssetDocument>(assetDocuments).map(
53
+ collate<VideoAssetDocument>(assetDocumentsObservable ?? []).map(
48
54
  (collated) =>
49
55
  ({
50
56
  ...(collated.draft || collated.published || {}),
51
57
  _id: collated.id,
52
58
  }) as VideoAssetDocument
53
59
  ),
54
- [assetDocuments]
60
+ [assetDocumentsObservable]
55
61
  )
56
62
 
57
63
  return {
@@ -0,0 +1,25 @@
1
+ import type {SanityClient} from 'sanity'
2
+
3
+ import {Audience, generateJwt} from './generateJwt'
4
+ import {getPlaybackId} from './getPlaybackId'
5
+ import {getPlaybackPolicy} from './getPlaybackPolicy'
6
+ import type {AssetThumbnailOptions} from './types'
7
+
8
+ export function createUrlParamsObject(
9
+ client: SanityClient,
10
+ asset: AssetThumbnailOptions['asset'],
11
+ params: object,
12
+ audience: Audience
13
+ ) {
14
+ const playbackId = getPlaybackId(asset)
15
+
16
+ let searchParams = new URLSearchParams(
17
+ JSON.parse(JSON.stringify(params, (_, v) => v ?? undefined))
18
+ )
19
+ if (getPlaybackPolicy(asset) === 'signed') {
20
+ const token = generateJwt(client, playbackId, audience, params)
21
+ searchParams = new URLSearchParams({token})
22
+ }
23
+
24
+ return {playbackId, searchParams}
25
+ }
@@ -1,6 +1,6 @@
1
1
  /* eslint-disable */
2
2
  // From: https://stackoverflow.com/a/11486026/10433647
3
- export default function formatSeconds(seconds: number): string {
3
+ export function formatSeconds(seconds: number): string {
4
4
  if (typeof seconds !== 'number' || Number.isNaN(seconds)) {
5
5
  return ''
6
6
  }
@@ -20,3 +20,30 @@ export default function formatSeconds(seconds: number): string {
20
20
  ret += '' + secs
21
21
  return ret
22
22
  }
23
+
24
+ // Output like "05:14:01"
25
+ export function formatSecondsToHHMMSS(seconds: number): string {
26
+ const hrs = Math.floor(seconds / 3600)
27
+ .toString()
28
+ .padStart(2, '0')
29
+ const mins = Math.floor((seconds % 3600) / 60)
30
+ .toString()
31
+ .padStart(2, '0')
32
+ const secs = Math.floor(seconds % 60)
33
+ .toString()
34
+ .padStart(2, '0')
35
+
36
+ return `${hrs}:${mins}:${secs}`
37
+ }
38
+
39
+ // Checks if time has a HH:MM:SS format like "05:14:01"
40
+ export function isValidTimeFormat(time: string) {
41
+ const regex = /^([0-1]?[0-9]|2[0-3]):([0-5]?[0-9]):([0-5]?[0-9])$/
42
+ return regex.test(time) || time === ''
43
+ }
44
+
45
+ // Converts a time like "05:14:01" to seconds
46
+ export function getSecondsFromTimeFormat(time: string): number {
47
+ const [hh = 0, mm = 0, ss = 0] = time.split(':').map(Number)
48
+ return hh * 3600 + mm * 60 + ss
49
+ }