Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -309,6 +309,34 @@ export default defineConfig({

If your videos are always spoken in a specific language and you want to include captions by default, you can use `disableTextTrackConfig: true` together with `defaultAutogeneratedSubtitleLang` to transcribe captions for every uploaded asset without needing user interaction.

### Accepted File Types

By default, the plugin accepts both video and audio files (`['video/*', 'audio/*']`). You can configure which file types are accepted by the input:

```js
import {muxInput} from 'sanity-plugin-mux-input'

export default defineConfig({
plugins: [
muxInput({
acceptedMimeTypes: ['video/*'], // Only accept video files
// or
acceptedMimeTypes: ['audio/*'], // Only accept audio files
// or
acceptedMimeTypes: ['video/*', 'audio/*'], // Accept both (default)
}),
],
})
```

The `acceptedMimeTypes` option controls the `accept` attribute on the file input, which filters which file types users can select when uploading. This affects both the file picker dialog and drag-and-drop file validation.

📌 **Note**: This option accepts an array of MIME type patterns. The valid values are:
- `'video/*'` - Accepts all video file types
- `'audio/*'` - Accepts all audio file types

For more information on the `accept` attribute, refer to the [MDN documentation](https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/accept).

## Contributing

Issues are actively monitored and PRs are welcome. When developing this plugin the easiest setup is:
Expand Down
1 change: 1 addition & 0 deletions src/_exports/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ export const defaultConfig: PluginConfig = {
defaultSigned: false,
tool: DEFAULT_TOOL_CONFIG,
allowedRolesForConfiguration: [],
acceptedMimeTypes: ['video/*', 'audio/*'],
}

/**
Expand Down
93 changes: 0 additions & 93 deletions src/components/FileInputArea.tsx

This file was deleted.

4 changes: 2 additions & 2 deletions src/components/FileInputButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ const Label = styled.label`

export interface FileInputButtonProps extends ButtonProps {
onSelect: (files: FileList) => void
accept?: string
accept: string
}
export const FileInputButton = ({onSelect, accept, ...props}: FileInputButtonProps) => {
const inputId = `FileSelect${useId()}`
Expand All @@ -34,7 +34,7 @@ export const FileInputButton = ({onSelect, accept, ...props}: FileInputButtonPro
return (
<Label htmlFor={inputId}>
<HiddenInput
accept={accept || 'video/*'}
accept={accept}
ref={inputRef}
tabIndex={0}
type="file"
Expand Down
7 changes: 4 additions & 3 deletions src/components/PlayerActionsMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,11 +25,11 @@ import {memo, useCallback, useEffect, useMemo, useState} from 'react'
import {PatchEvent, unset} from 'sanity'
import {styled} from 'styled-components'

import {useAccessControl} from '../hooks/useAccessControl'
import {type DialogState, type SetDialogState} from '../hooks/useDialogState'
import {getPlaybackPolicy} from '../util/getPlaybackPolicy'
import type {MuxInputProps, PluginConfig, VideoAssetDocument} from '../util/types'
import {FileInputMenuItem} from './FileInputMenuItem'
import {useAccessControl} from '../hooks/useAccessControl'

const LockCard = styled(Card)`
position: absolute;
Expand Down Expand Up @@ -57,9 +57,10 @@ function PlayerActionsMenu(
dialogState: DialogState
setDialogState: SetDialogState
config: PluginConfig
accept: string
}
) {
const {asset, readOnly, dialogState, setDialogState, onChange, onSelect} = props
const {asset, readOnly, dialogState, setDialogState, onChange, onSelect, accept} = props
const [open, setOpen] = useState(false)
const [menuElement, setMenuRef] = useState<HTMLDivElement | null>(null)
const isSigned = useMemo(() => getPlaybackPolicy(asset) === 'signed', [asset])
Expand Down Expand Up @@ -108,7 +109,7 @@ function PlayerActionsMenu(
</Label>
</Box>
<FileInputMenuItem
accept="video/*"
accept={accept}
icon={UploadIcon}
onSelect={onSelect}
text="Upload"
Expand Down
11 changes: 6 additions & 5 deletions src/components/UploadPlaceholder.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,11 @@
import {PlugIcon, SearchIcon, UploadIcon} from '@sanity/icons'
import {DocumentVideoIcon} from '@sanity/icons'
import {DocumentVideoIcon, PlugIcon, SearchIcon, UploadIcon} from '@sanity/icons'
import {Button, Card, Flex, Inline, Text} from '@sanity/ui'
import {useCallback} from 'react'

import type {SetDialogState} from '../hooks/useDialogState'
import {FileInputButton, type FileInputButtonProps} from './FileInputButton'
import {useAccessControl} from '../hooks/useAccessControl'
import type {SetDialogState} from '../hooks/useDialogState'
import {PluginConfig} from '../util/types'
import {FileInputButton, type FileInputButtonProps} from './FileInputButton'

interface UploadPlaceholderProps {
setDialogState: SetDialogState
Expand All @@ -15,9 +14,10 @@ interface UploadPlaceholderProps {
needsSetup: boolean
onSelect: FileInputButtonProps['onSelect']
config: PluginConfig
accept: string
}
export default function UploadPlaceholder(props: UploadPlaceholderProps) {
const {setDialogState, readOnly, onSelect, hovering, needsSetup} = props
const {setDialogState, readOnly, onSelect, hovering, needsSetup, accept} = props
const handleBrowse = useCallback(() => setDialogState('select-video'), [setDialogState])
const handleConfigureApi = useCallback(() => setDialogState('secrets'), [setDialogState])
const {hasConfigAccess} = useAccessControl(props.config)
Expand Down Expand Up @@ -54,6 +54,7 @@ export default function UploadPlaceholder(props: UploadPlaceholderProps) {
</Flex>
<Inline space={2}>
<FileInputButton
accept={accept}
mode="bleed"
tone="default"
icon={UploadIcon}
Expand Down
62 changes: 56 additions & 6 deletions src/components/Uploader.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import {ErrorOutlineIcon} from '@sanity/icons'
import {Button, CardTone, Flex, Text, useToast} from '@sanity/ui'
import React, {useEffect, useReducer, useRef, useState} from 'react'
import React, {useCallback, useEffect, useReducer, useRef, useState} from 'react'
import {type Observable, Subject, Subscription} from 'rxjs'
import {takeUntil, tap} from 'rxjs/operators'
import type {SanityClient} from 'sanity'
Expand Down Expand Up @@ -66,7 +66,7 @@ type UploaderStateAction =
| Extract<UploadUrlEvent, {type: 'url'}>
))
| {action: 'progress'; percent: number}
| {action: 'error'; error: any}
| {action: 'error'; error: Error}
| {action: 'complete' | 'reset'}

/**
Expand Down Expand Up @@ -101,6 +101,7 @@ export default function Uploader(props: Props) {
case 'commitUpload':
return Object.assign({}, prev, {uploadStatus: {progress: 0}})
case 'progressInfo': {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const {type, action: _, ...payload} = action
return Object.assign({}, prev, {
uploadStatus: {
Expand Down Expand Up @@ -257,12 +258,40 @@ export default function Uploader(props: Props) {
})
}

const invalidFileToast = useCallback(() => {
toast.push({
status: 'error',
title: `Invalid file type. Accepted types: ${props.config.acceptedMimeTypes?.join(', ')}`,
})
}, [props.config.acceptedMimeTypes, toast])

/**
* Validates if any file in the provided FileList or File array has an unsupported MIME type
* @param files - FileList or File array to validate
* @returns true if any file has an invalid MIME type, false if all files are valid
*/

const isInvalidFile = (files: FileList | File[]) => {
const isInvalid = Array.from(files).some((file) => {
return !props.config.acceptedMimeTypes?.some((acceptedType) =>
file.type.match(new RegExp(acceptedType))
)
})

if (isInvalid) {
invalidFileToast()
return true
}
return false
}

/* -------------------------- Upload Initialization ------------------------- */
// The below populate the uploadInput state field, which then triggers the
// upload configuration, or the startUpload function if no config is required.

// Stages an upload from the file selector
const handleUpload = (files: FileList | File[]) => {
if (isInvalidFile(files)) return
dispatch({
action: 'stageUpload',
input: {type: 'file', files},
Expand All @@ -273,8 +302,9 @@ export default function Uploader(props: Props) {
const handlePaste: React.ClipboardEventHandler<HTMLInputElement> = (event) => {
event.preventDefault()
event.stopPropagation()
const clipboardData = event.clipboardData || (window as any).clipboardData
const url = clipboardData.getData('text')?.trim()
const clipboardData =
event.clipboardData || (window as Window & {clipboardData?: DataTransfer}).clipboardData
const url = clipboardData?.getData('text')?.trim()
if (!isValidUrl(url)) {
toast.push({status: 'error', title: 'Invalid URL for Mux video input.'})
return
Expand All @@ -284,9 +314,14 @@ export default function Uploader(props: Props) {

// Stages and validates an upload from dragging+dropping files or folders
const handleDrop: React.DragEventHandler<HTMLDivElement> = (event) => {
setDragState(null)
event.preventDefault()
event.stopPropagation()
if (dragState === 'invalid') {
invalidFileToast()
setDragState(null)
return
}
setDragState(null)
extractDroppedFiles(event.nativeEvent.dataTransfer!).then((files) => {
dispatch({
action: 'stageUpload',
Expand All @@ -306,7 +341,16 @@ export default function Uploader(props: Props) {
event.stopPropagation()
dragEnteredEls.current.push(event.target)
const type = event.dataTransfer.items?.[0]?.type
setDragState(type?.startsWith('video/') ? 'valid' : 'invalid')
const mimeTypes = props.config.acceptedMimeTypes

// Check if the dragged file type matches any of the accepted mime types
const isValidType = mimeTypes?.some((acceptedType) => {
// Convert mime type pattern to regex (e.g., 'video/*' -> /^video\/.*$/)
const pattern = `^${acceptedType.replace('*', '.*')}$`
return new RegExp(pattern).test(type)
})

setDragState(isValidType ? 'valid' : 'invalid')
}

const handleDragLeave: React.DragEventHandler<HTMLDivElement> = (event) => {
Expand Down Expand Up @@ -370,6 +414,10 @@ export default function Uploader(props: Props) {
let tone: CardTone | undefined
if (dragState) tone = dragState === 'valid' ? 'positive' : 'critical'

const acceptMimeString = props.config?.acceptedMimeTypes?.length
? props.config.acceptedMimeTypes.join(',')
: 'video/*, audio/*'

return (
<>
<UploadCard
Expand All @@ -392,6 +440,7 @@ export default function Uploader(props: Props) {
onChange={props.onChange}
buttons={
<PlayerActionsMenu
accept={acceptMimeString}
asset={props.asset}
dialogState={props.dialogState}
setDialogState={props.setDialogState}
Expand All @@ -405,6 +454,7 @@ export default function Uploader(props: Props) {
</DialogStateProvider>
) : (
<UploadPlaceholder
accept={acceptMimeString}
hovering={dragState !== null}
onSelect={handleUpload}
readOnly={!!props.readOnly}
Expand Down
14 changes: 11 additions & 3 deletions src/components/VideoPlayer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -67,11 +67,18 @@ export default function VideoPlayer({

return (
<>
<Card tone="transparent" style={{aspectRatio: aspectRatio, position: 'relative'}}>
<Card
tone="transparent"
style={{
aspectRatio: aspectRatio,
position: 'relative',
...(isAudio && {display: 'flex'}),
}}
>
{videoSrc && (
<>
<MuxPlayer
poster={thumbnailSrc}
poster={isAudio ? undefined : thumbnailSrc}
ref={muxPlayer}
{...props}
playsInline
Expand All @@ -90,10 +97,11 @@ export default function VideoPlayer({
}}
audio={isAudio}
style={{
height: '100%',
...(!isAudio && {height: '100%'}),
width: '100%',
display: 'block',
objectFit: 'contain',
...(isAudio && {alignSelf: 'end'}),
}}
/>
{children}
Expand Down
Loading