Skip to content
This repository has been archived by the owner on Jul 26, 2023. It is now read-only.

Support loading subtitles in multiple formats and customizing subtitle tracks #705

Open
wants to merge 22 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
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
173 changes: 173 additions & 0 deletions components/CustomVideoSubMenu.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
import { Dispatch, Fragment, SetStateAction, useEffect, useState } from 'react'
import { useTranslation } from 'next-i18next'
import { Dialog, Transition } from '@headlessui/react'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { DownloadButton } from './DownloadBtnGtoup'

function TrackContainer({
track,
index,
setTracks,
title,
}: {
track: Plyr.Track
index: number
setTracks: Dispatch<SetStateAction<Plyr.Track[]>>
title: string
}) {
const { t } = useTranslation()
const setTrack = (value: Plyr.Track) =>
setTracks(tracks => {
tracks[index] = value
return [...tracks]
})
const delTrack = () => {
setTracks(tracks => {
tracks.splice(index, 1)
return [...tracks]
})
}
return (
<div className="mt-4 w-full rounded border border-gray-600/10 bg-gray-50 p-2">
<div
className="float-right cursor-pointer rounded-full px-1.5 py-1 hover:bg-gray-300 dark:hover:bg-gray-600"
onClick={delTrack}
>
<FontAwesomeIcon icon={['far', 'times-circle']} />
</div>
<h3 className="text-md w-full font-semibold uppercase tracking-wider text-black">{title}</h3>
<div className="mt-2 w-full ">
<h4 className="w-full py-2 text-xs font-medium uppercase tracking-wider">{t('Subtitle label')}</h4>
<input
className="w-full rounded border border-gray-600/10 p-2.5 font-mono focus:outline-none focus:ring focus:ring-blue-300 dark:bg-gray-600 dark:text-white dark:focus:ring-blue-700"
defaultValue={track.label}
onChange={value =>
setTrack({
...track,
label: value.target.value,
})
}
/>
<div className="mt-2 w-full ">
<h4 className="w-full py-2 text-xs font-medium uppercase tracking-wider">{t('Subtitle source')}</h4>
<input
className="w-full rounded border border-gray-600/10 p-2.5 font-mono focus:outline-none focus:ring focus:ring-blue-300 dark:bg-gray-600 dark:text-white dark:focus:ring-blue-700"
defaultValue={track.src}
onChange={value =>
setTrack({
...track,
src: value.target.value,
})
}
/>
</div>
</div>
</div>
)
}

export default function CustomVideoSubMenu({
tracks,
setTracks,
menuOpen,
setMenuOpen,
}: {
tracks: Plyr.Track[]
setTracks: Dispatch<SetStateAction<Plyr.Track[]>>
menuOpen: boolean
setMenuOpen: Dispatch<SetStateAction<boolean>>
}) {
const { t } = useTranslation()

const closeMenu = () => setMenuOpen(false)

const initTracks = () => JSON.parse(JSON.stringify(tracks))
const [pendingTracks, setPendingTracks] = useState<Plyr.Track[]>(initTracks())
useEffect(() => {
if (menuOpen) {
setPendingTracks(initTracks())
}
}, [tracks, menuOpen])

return (
<Transition appear show={menuOpen} as={Fragment}>
<Dialog as="div" className="fixed inset-0 z-10 overflow-y-auto" onClose={closeMenu}>
<div className="min-h-screen px-4 text-center">
<Transition.Child
as={Fragment}
enter="ease-out duration-100"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-100"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<Dialog.Overlay className="fixed inset-0 bg-white/60 dark:bg-gray-800/60" />
</Transition.Child>

{/* This element is to trick the browser into centering the modal contents. */}
<span className="inline-block h-screen align-middle" aria-hidden="true">
&#8203;
</span>
<Transition.Child
as={Fragment}
enter="ease-out duration-100"
enterFrom="opacity-0 scale-95"
enterTo="opacity-100 scale-100"
leave="ease-in duration-100"
leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95"
>
<div className="inline-block max-h-[80vh] w-full max-w-3xl transform overflow-hidden overflow-y-scroll rounded border border-gray-400/30 bg-white p-4 text-left align-middle text-sm shadow-xl transition-all dark:bg-gray-900 dark:text-white">
<Dialog.Title as="h3" className="py-2 text-xl font-bold">
{t('Customise subtitle')}
</Dialog.Title>
<Dialog.Description as="p" className="py-2 opacity-80">
{t('Customise subtitle tracks of the media player.')}
</Dialog.Description>

<div className="my-4">
{pendingTracks.map((track, index) => (
<TrackContainer
key={JSON.stringify({ track, index })}
track={track}
index={index}
setTracks={setPendingTracks}
title={`#${index}`}
/>
))}
</div>

<div className="float-right flex flex-wrap gap-4">
<DownloadButton
onClickCallback={() => {
setPendingTracks([
...pendingTracks,
{
label: '',
src: '',
kind: 'subtitles',
},
])
}}
btnColor="teal"
btnText={t('Add a track')}
btnIcon="plus"
/>
<DownloadButton
onClickCallback={() => {
setTracks(pendingTracks)
closeMenu()
}}
btnColor="blue"
btnText={t('Apply tracks')}
btnIcon="download"
/>
</div>
</div>
</Transition.Child>
</div>
</Dialog>
</Transition>
)
}
123 changes: 85 additions & 38 deletions components/previews/VideoPreview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { useTranslation } from 'next-i18next'
import axios from 'axios'
import toast from 'react-hot-toast'
import Plyr from 'plyr-react'
import subsrt from '@openfun/subsrt'
import { useAsync } from 'react-async-hook'
import { useClipboard } from 'use-clipboard-copy'

Expand All @@ -19,6 +20,7 @@ import { DownloadBtnContainer, PreviewContainer } from './Containers'
import FourOhFour from '../FourOhFour'
import Loading from '../Loading'
import CustomEmbedLinkMenu from '../CustomEmbedLinkMenu'
import CustomVideoSubMenu from '../CustomVideoSubMenu'

import 'plyr-react/dist/plyr.css'

Expand All @@ -28,22 +30,20 @@ const VideoPlayer: FC<{
width?: number
height?: number
thumbnail: string
subtitle: string
tracks: Plyr.Track[]
isFlv: boolean
mpegts: any
}> = ({ videoName, videoUrl, width, height, thumbnail, subtitle, isFlv, mpegts }) => {
useEffect(() => {
// Really really hacky way to inject subtitles as file blobs into the video element
axios
.get(subtitle, { responseType: 'blob' })
.then(resp => {
const track = document.querySelector('track')
track?.setAttribute('src', URL.createObjectURL(resp.data))
})
.catch(() => {
console.log('Could not load subtitle.')
})
}> = ({ videoName, videoUrl, width, height, thumbnail, tracks, isFlv, mpegts }) => {
const { t } = useTranslation()

// Store transcoded blob links
const [convertedTracks, setConvertedTracks] = useState<Plyr.Track[]>([])

// Common plyr configs, including the video source and plyr options
const [plyrSource, setPlyrSource] = useState<Plyr.SourceInfo>({ type: 'video', sources: [] })
const [plyrOptions, setPlyrOptions] = useState<Plyr.Options>({})

useEffect(() => {
if (isFlv) {
const loadFlv = () => {
// Really hacky way to get the exposed video element from Plyr
Expand All @@ -54,41 +54,76 @@ const VideoPlayer: FC<{
}
loadFlv()
}
}, [videoUrl, isFlv, mpegts, subtitle])
setPlyrSource({
type: 'video',
title: videoName,
poster: thumbnail,
tracks: convertedTracks,
sources: isFlv ? [] : [{ src: videoUrl }],
})
setPlyrOptions({
ratio: `${width ?? 16}:${height ?? 9}`,
captions: { update: true },
})
}, [videoUrl, isFlv, mpegts, videoName, thumbnail, convertedTracks, width, height])

useAsync(async () => {
const toastId = toast.loading(t('Loading subtitles...'))
// Remove duplicated items
const noDupTracks = tracks.filter(
(value1, index, self) =>
index === self.findIndex(value2 => Object.keys(value2).every(key => value2[key] == value1[key]))
)
// Get src of transcoded subtitles and build new subtitle tracks
const convertedTrackResults = await Promise.allSettled(
noDupTracks.map(async track => {
const resp = await axios.get(track.src, { responseType: 'blob' })
let sub: string = await resp.data.text()
if (subsrt.detect(sub) != 'vtt') {
sub = subsrt.convert(sub, { format: 'vtt' })
}
return { ...track, src: URL.createObjectURL(new Blob([sub])) } as Plyr.Track
})
)
setConvertedTracks(
convertedTrackResults
.filter(track => track.status === 'fulfilled')
.map(track => (track as PromiseFulfilledResult<Plyr.Track>).value)
)
toast.dismiss(toastId)
}, [tracks])

// Common plyr configs, including the video source and plyr options
const plyrSource = {
type: 'video',
title: videoName,
poster: thumbnail,
tracks: [{ kind: 'captions', label: videoName, src: '', default: true }],
}
const plyrOptions: Plyr.Options = {
ratio: `${width ?? 16}:${height ?? 9}`,
fullscreen: { iosNative: true },
}
if (!isFlv) {
// If the video is not in flv format, we can use the native plyr and add sources directly with the video URL
plyrSource['sources'] = [{ src: videoUrl }]
}
return <Plyr id="plyr" source={plyrSource as Plyr.SourceInfo} options={plyrOptions} />
return (
// Add translate="no" to avoid "Uncaught DOMException: Failed to execute 'removeChild' on 'Node'" error.
// https://github.com/facebook/react/issues/11538
<div translate="no">
<Plyr id="plyr" source={plyrSource} options={plyrOptions} />
</div>
)
}

const VideoPreview: FC<{ file: OdFileObject }> = ({ file }) => {
const { asPath } = useRouter()
const hashedToken = getStoredToken(asPath)
const clipboard = useClipboard()

const [menuOpen, setMenuOpen] = useState(false)
const [linkMenuOpen, setLinkMenuOpen] = useState(false)
const [trackMenuOpen, setTrackMenuOpen] = useState(false)
const [tracks, setTracks] = useState<Plyr.Track[]>(() =>
Array.from(['.vtt', '.ass', '.srt']).map(suffix => ({
kind: 'subtitles',
label: `${file.name.substring(0, file.name.lastIndexOf('.'))}${suffix}`,
src: `/api/raw/?path=${asPath.substring(0, asPath.lastIndexOf('.'))}${suffix}${
hashedToken ? `&odpt=${hashedToken}` : ''
}`,
}))
)

const { t } = useTranslation()

// OneDrive generates thumbnails for its video files, we pick the thumbnail with the highest resolution
const thumbnail = `/api/thumbnail/?path=${asPath}&size=large${hashedToken ? `&odpt=${hashedToken}` : ''}`

// We assume subtitle files are beside the video with the same name, only webvtt '.vtt' files are supported
const vtt = `${asPath.substring(0, asPath.lastIndexOf('.'))}.vtt`
const subtitle = `/api/raw/?path=${vtt}${hashedToken ? `&odpt=${hashedToken}` : ''}`

// We also format the raw video file for the in-browser player as well as all other players
const videoUrl = `/api/raw/?path=${asPath}${hashedToken ? `&odpt=${hashedToken}` : ''}`

Expand All @@ -105,7 +140,13 @@ const VideoPreview: FC<{ file: OdFileObject }> = ({ file }) => {

return (
<>
<CustomEmbedLinkMenu path={asPath} menuOpen={menuOpen} setMenuOpen={setMenuOpen} />
<CustomEmbedLinkMenu path={asPath} menuOpen={linkMenuOpen} setMenuOpen={setLinkMenuOpen} />
<CustomVideoSubMenu
tracks={tracks}
setTracks={setTracks}
menuOpen={trackMenuOpen}
setMenuOpen={setTrackMenuOpen}
/>
<PreviewContainer>
{error ? (
<FourOhFour errorMsg={error.message} />
Expand All @@ -118,7 +159,7 @@ const VideoPreview: FC<{ file: OdFileObject }> = ({ file }) => {
width={file.video?.width}
height={file.video?.height}
thumbnail={thumbnail}
subtitle={subtitle}
tracks={tracks}
isFlv={isFlv}
mpegts={mpegts}
/>
Expand All @@ -143,11 +184,17 @@ const VideoPreview: FC<{ file: OdFileObject }> = ({ file }) => {
btnIcon="copy"
/>
<DownloadButton
onClickCallback={() => setMenuOpen(true)}
onClickCallback={() => setLinkMenuOpen(true)}
btnColor="teal"
btnText={t('Customise link')}
btnIcon="pen"
/>
<DownloadButton
onClickCallback={() => setTrackMenuOpen(true)}
btnColor="blue"
btnText={t('Customise subtitle')}
btnIcon="pen"
/>

<DownloadButton
onClickCallback={() => window.open(`iina://weblink?url=${getBaseUrl()}${videoUrl}`)}
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
"@fortawesome/free-solid-svg-icons": "^5.15.3",
"@fortawesome/react-fontawesome": "^0.1.14",
"@headlessui/react": "^1.4.0",
"@openfun/subsrt": "^1.0.5",
"@tailwindcss/line-clamp": "^0.3.1",
"awesome-debounce-promise": "^2.1.0",
"axios": "^0.25.0",
Expand Down
2 changes: 2 additions & 0 deletions pages/_app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
faEnvelope,
faFlag,
faCheckCircle,
faTimesCircle,
} from '@fortawesome/free-regular-svg-icons'
import {
faSearch,
Expand Down Expand Up @@ -110,6 +111,7 @@ library.add(
faThList,
faLanguage,
faPen,
faTimesCircle,
...iconList
)

Expand Down
Loading