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.
Jump to
Jump to file
Failed to load files.
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