mirror of
https://github.com/mit-regressions/viewer.git
synced 2025-04-09 14:20:15 -04:00
port (working!) webvtt-player over entirely
This commit is contained in:
parent
dfe3eeb7a5
commit
e04dd91fd6
|
@ -4,5 +4,6 @@ Language: en
|
|||
|
||||
NOTE We could have a separation between MUSIC SOURCE, VIDEO SOURCE, NARRATION SOURCE, COMMENTARY, and TRANSCRIPT.
|
||||
|
||||
NOTE ["The Vanishing American Family" by ScubaZ]
|
||||
00:00:01.550 --> 00:01:15.448
|
||||
["The Vanishing American Family" by ScubaZ]
|
||||
{"keywords_alt": "", "gpspoints": {"gps_zoom": "11", "gps_text_alt": "", "gps_text": "Stockholm, Sweden", "gps": "59.3388502,18.0616981"}, "synopsis": "", "subjects": "", "hyperlinks": {"hyperlink_text_alt": "", "hyperlink_text": "", "hyperlink": ""}, "synopsis_alt": "", "title": "The Vanishing American Family", "keywords": "ScubaZ", "title_alt": "", "subjects_alt": ""}
|
|
@ -2,7 +2,7 @@ import dynamic from "next/dynamic";
|
|||
const ReactPlayer = dynamic(() => import("react-player/lazy"), { ssr: false });
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { useRouter } from 'next/router';
|
||||
import { Player as WebVttPlayer } from "webvtt-player";
|
||||
import WebVttPlayer from "./WebVttPlayer/WebVttPlayer";
|
||||
|
||||
// functional component PlayerReact that uses ReactPlayer
|
||||
// TODO: parameterize video source and VTT source with props (general spec for 3rd party use!). must define spec.
|
||||
|
@ -39,12 +39,11 @@ export default function Player() {
|
|||
/>
|
||||
</div>
|
||||
<div>
|
||||
{/* div "control-panel" has multiple buttons for displaying settings on WebVttPlayer */}
|
||||
<div id="control-panel" className="flex flex-row">
|
||||
<button id="show-metadata" className="bg-gray-200 hover:bg-gray-400 dark:bg-gray-800 dark:hover:bg-gray-700 text-gray-800 dark:text-gray-200 py-2 px-4">Show Metadata</button>
|
||||
</div>
|
||||
<WebVttPlayer
|
||||
// className="custom-webvtt-player"
|
||||
preload={false}
|
||||
audio={audioUrl}
|
||||
transcript={transcriptUrl}
|
||||
metadata={metadataUrl} />
|
||||
|
|
34
viewer/src/components/WebVttPlayer/Metadata.tsx
Normal file
34
viewer/src/components/WebVttPlayer/Metadata.tsx
Normal file
|
@ -0,0 +1,34 @@
|
|||
import React, { Component } from 'react'
|
||||
import MetadataPoint from './MetadataPoint'
|
||||
|
||||
class Metadata extends Component<MetadataProps> {
|
||||
|
||||
render() {
|
||||
const lines = []
|
||||
if (this.props.track && this.props.track.cues) {
|
||||
for (let i = 0; i < this.props.track.cues.length; i++) {
|
||||
lines.push(
|
||||
<MetadataPoint
|
||||
key={`point-${i}`}
|
||||
cue={this.props.track.cues[i]}
|
||||
active={false}
|
||||
seek={this.props.seek} />
|
||||
)
|
||||
}
|
||||
}
|
||||
return (
|
||||
<div className="track">
|
||||
{lines}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
type MetadataProps = {
|
||||
url: string,
|
||||
track: TextTrack,
|
||||
seek: (time: number) => void
|
||||
}
|
||||
|
||||
export default Metadata
|
113
viewer/src/components/WebVttPlayer/MetadataPoint.tsx
Normal file
113
viewer/src/components/WebVttPlayer/MetadataPoint.tsx
Normal file
|
@ -0,0 +1,113 @@
|
|||
import React, { Component } from 'react'
|
||||
// import './MetadataPoint.css'
|
||||
|
||||
|
||||
type MetadataPointProps = {
|
||||
cue: VTTCue
|
||||
active: boolean
|
||||
seek: (time: number) => void
|
||||
}
|
||||
|
||||
class MetadataPoint extends Component<MetadataPointProps> {
|
||||
|
||||
constructor(props: MetadataPointProps) {
|
||||
super(props)
|
||||
this.onClick = this.onClick.bind(this)
|
||||
}
|
||||
|
||||
render() {
|
||||
const data = JSON.parse(this.props.cue.text)
|
||||
const titleAlt = data.title_alt
|
||||
? <h3 className="titleAlt">{data.title_alt}</h3>
|
||||
: ""
|
||||
const synopsis = data.synopsis
|
||||
? <div className="field">
|
||||
<span>Synopsis</span>
|
||||
{data.synopsis}
|
||||
</div>
|
||||
: ""
|
||||
const synopsisAlt = data.synopsis_alt
|
||||
? <div>{data.synopsis_alt}</div>
|
||||
: ""
|
||||
const keywords = data.keywords
|
||||
? <div className="field">
|
||||
<span>Keywords: </span>
|
||||
{data.keywords}
|
||||
</div>
|
||||
: ""
|
||||
const keywordsAlt = data.keywords_alt
|
||||
? <div className="field">
|
||||
<span>Alternative Keywords: </span>
|
||||
{data.keywords_alt}
|
||||
</div>
|
||||
: ""
|
||||
const subjects = data.subjects
|
||||
? <div className="field">
|
||||
<span>Subjects: </span>
|
||||
{data.subjects}
|
||||
</div>
|
||||
: ""
|
||||
const subjectsAlt = data.subjects_alt
|
||||
? <div className="field">
|
||||
<span>Alternative Subjects: </span>
|
||||
{data.subjects_alt}
|
||||
</div>
|
||||
: ""
|
||||
const gpsLink = data.gpspoints.gps
|
||||
? <div className="field">
|
||||
<span>Geo: </span>
|
||||
<a href={`https://www.google.com/maps/@?api=1&map_action=map¢er=${data.gpspoints.gps}&zoom=${data.gpspoints.gps_zoom}`}>{data.gpspoints.gps_text}</a>
|
||||
</div>
|
||||
: ""
|
||||
const hyperlinks = data.hyperlinks.hyperlink_text
|
||||
? <div className="field">
|
||||
<span>Links: </span>
|
||||
<a href={data.hyperlinks.hyperlink}>{data.hyperlinks.hyperlink_text}</a>
|
||||
</div>
|
||||
: ""
|
||||
return (
|
||||
<div className="point">
|
||||
<div className="time" onClick={this.onClick}>
|
||||
[{this.startTime()}]
|
||||
</div>
|
||||
<div className="text">
|
||||
<h2 className="title" onClick={this.onClick}>{data.title}</h2>
|
||||
{titleAlt}
|
||||
{synopsis}
|
||||
{synopsisAlt}
|
||||
{keywords}
|
||||
{keywordsAlt}
|
||||
{subjects}
|
||||
{subjectsAlt}
|
||||
{gpsLink}
|
||||
{hyperlinks}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
onClick() {
|
||||
this.props.seek(this.props.cue.startTime)
|
||||
}
|
||||
|
||||
startTime() {
|
||||
return this.formatSeconds(this.props.cue.startTime)
|
||||
}
|
||||
|
||||
formatSeconds(t) {
|
||||
let mins = Math.floor(t / 60)
|
||||
if (mins < 10) {
|
||||
mins = `0${mins}`
|
||||
}
|
||||
|
||||
let secs = Math.floor(t % 60)
|
||||
if (secs < 10) {
|
||||
secs = `0${secs}`
|
||||
}
|
||||
|
||||
return `${mins}:${secs}`
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default MetadataPoint
|
25
viewer/src/components/WebVttPlayer/Search.module.css
Normal file
25
viewer/src/components/WebVttPlayer/Search.module.css
Normal file
|
@ -0,0 +1,25 @@
|
|||
.search {
|
||||
border-top: thin solid #ccc;
|
||||
padding: 5px 5px 5px 5px;
|
||||
font-size: 14pt;
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
.search .container {
|
||||
border: thin solid #ccc;
|
||||
}
|
||||
|
||||
.search input {
|
||||
font-size: 12pt;
|
||||
border: none;
|
||||
width: 90%;
|
||||
}
|
||||
|
||||
.search input:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.search .icon {
|
||||
padding-left: 5px;
|
||||
padding-right: 5px;
|
||||
}
|
24
viewer/src/components/WebVttPlayer/Search.tsx
Normal file
24
viewer/src/components/WebVttPlayer/Search.tsx
Normal file
|
@ -0,0 +1,24 @@
|
|||
import React, { FunctionComponent } from "react" // TODO: fix warning
|
||||
import styles from "./Search.module.css"
|
||||
|
||||
const Search: FunctionComponent<SearchProps> = ({ query, updateQuery }) => {
|
||||
return (
|
||||
<div className={styles.search}>
|
||||
<div className={styles.container}>
|
||||
<span className={styles.icon}>🔍</span>
|
||||
<input
|
||||
value={query}
|
||||
onChange={e => updateQuery(e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// set prop types using TypeScript
|
||||
type SearchProps = {
|
||||
query: string,
|
||||
updateQuery: (query: string) => void
|
||||
}
|
||||
|
||||
|
||||
export default Search
|
37
viewer/src/components/WebVttPlayer/Transcript.tsx
Normal file
37
viewer/src/components/WebVttPlayer/Transcript.tsx
Normal file
|
@ -0,0 +1,37 @@
|
|||
import React, { Component } from 'react'
|
||||
import TranscriptLine from './TranscriptLine'
|
||||
// import './Track.css' // currently exists in global instead. gotta consolidate this
|
||||
|
||||
class Transcript extends Component<TranscriptProps> {
|
||||
|
||||
render() {
|
||||
const lines = []
|
||||
if (this.props.track && this.props.track.cues) {
|
||||
for (let i = 0; i < this.props.track.cues.length; i++) {
|
||||
lines.push(
|
||||
<TranscriptLine
|
||||
key={`line-${i}`}
|
||||
cue={this.props.track.cues[i]}
|
||||
active={false}
|
||||
seek={this.props.seek}
|
||||
query={this.props.query} />
|
||||
)
|
||||
}
|
||||
}
|
||||
return (
|
||||
<div className="track">
|
||||
{lines}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
type TranscriptProps = {
|
||||
track: TextTrack,
|
||||
url: string,
|
||||
seek: (time: number) => void,
|
||||
query: string
|
||||
}
|
||||
|
||||
export default Transcript
|
79
viewer/src/components/WebVttPlayer/TranscriptLine.tsx
Normal file
79
viewer/src/components/WebVttPlayer/TranscriptLine.tsx
Normal file
|
@ -0,0 +1,79 @@
|
|||
import React, { Component } from 'react'
|
||||
import './TranscriptLine.module.css'
|
||||
|
||||
class TranscriptLine extends Component<TranscriptLineProps, { isActive: boolean }> {
|
||||
|
||||
constructor(props: TranscriptLineProps) {
|
||||
super(props)
|
||||
this.state = {
|
||||
isActive: false
|
||||
}
|
||||
this.props.cue.onenter = this.onEnter.bind(this)
|
||||
this.props.cue.onexit = this.onExit.bind(this)
|
||||
this.onClick = this.onClick.bind(this)
|
||||
}
|
||||
|
||||
render() {
|
||||
let style = ''
|
||||
if (this.props.query && this.props.cue.text.match(new RegExp(this.props.query, 'i'))) {
|
||||
style = 'match'
|
||||
} else if (this.state.isActive) {
|
||||
style = 'active'
|
||||
}
|
||||
|
||||
// note: dangerouslySetInnerHTML is used because the text may contain HTML
|
||||
return (
|
||||
<div className={`${style} line`} onClick={this.onClick}>
|
||||
<div className="time">
|
||||
[{this.startTime()} - {this.endTime()}]
|
||||
</div>
|
||||
<div
|
||||
className={`${style} text`}
|
||||
dangerouslySetInnerHTML={{__html: this.props.cue.text}} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
onEnter() {
|
||||
this.setState({isActive: true})
|
||||
}
|
||||
|
||||
onExit() {
|
||||
this.setState({isActive: false})
|
||||
}
|
||||
|
||||
onClick() {
|
||||
this.props.seek(this.props.cue.startTime)
|
||||
}
|
||||
|
||||
startTime() {
|
||||
return this.formatSeconds(this.props.cue.startTime)
|
||||
}
|
||||
|
||||
endTime() {
|
||||
return this.formatSeconds(this.props.cue.endTime)
|
||||
}
|
||||
|
||||
formatSeconds(t) {
|
||||
let mins = Math.floor(t / 60)
|
||||
if (mins < 10) {
|
||||
mins = `0${mins}`
|
||||
}
|
||||
|
||||
let secs = Math.floor(t % 60)
|
||||
if (secs < 10) {
|
||||
secs = `0${secs}`
|
||||
}
|
||||
|
||||
return `${mins}:${secs}`
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
type TranscriptLineProps = {
|
||||
cue: TextTrackCue,
|
||||
seek: (time: number) => void,
|
||||
query: string
|
||||
}
|
||||
|
||||
export default TranscriptLine
|
120
viewer/src/components/WebVttPlayer/WebVttPlayer.tsx
Normal file
120
viewer/src/components/WebVttPlayer/WebVttPlayer.tsx
Normal file
|
@ -0,0 +1,120 @@
|
|||
import React, { Component } from 'react'
|
||||
import Transcript from './Transcript'
|
||||
import Metadata from './Metadata'
|
||||
import Search from './Search'
|
||||
import './WebVttPlayer.module.css'
|
||||
|
||||
// type for props
|
||||
type WebVttProps = {
|
||||
audio: string,
|
||||
transcript: string,
|
||||
metadata: string,
|
||||
preload: boolean
|
||||
}
|
||||
|
||||
|
||||
class WebVttPlayer extends Component<WebVttProps, { loaded: boolean, currentTime: number, query: string }> {
|
||||
metatrack: React.RefObject<unknown>
|
||||
audio: React.RefObject<unknown>
|
||||
track: React.RefObject<unknown>
|
||||
|
||||
|
||||
constructor(props: WebVttProps) {
|
||||
super(props)
|
||||
this.state = {
|
||||
loaded: false,
|
||||
currentTime: 0,
|
||||
query: ''
|
||||
}
|
||||
|
||||
this.track = React.createRef()
|
||||
this.metatrack = React.createRef()
|
||||
this.audio = React.createRef()
|
||||
|
||||
this.onLoaded = this.onLoaded.bind(this)
|
||||
this.seek = this.seek.bind(this)
|
||||
this.checkIfLoaded = this.checkIfLoaded.bind(this)
|
||||
this.updateQuery = this.updateQuery.bind(this)
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.checkIfLoaded()
|
||||
}
|
||||
|
||||
render() {
|
||||
let track = null
|
||||
let metatrack = null
|
||||
if (this.state.loaded) {
|
||||
track = this.track.current.track
|
||||
metatrack = this.metatrack.current.track
|
||||
}
|
||||
const preload = this.props.preload ? "true" : "false"
|
||||
const metadata = this.props.metadata
|
||||
? <Metadata
|
||||
url={this.props.metadata}
|
||||
seek={this.seek}
|
||||
track={metatrack} />
|
||||
: ""
|
||||
|
||||
return (
|
||||
<div className="webvtt-player">
|
||||
<div className="media">
|
||||
<div className="player">
|
||||
<audio
|
||||
controls
|
||||
crossOrigin="anonymous"
|
||||
onLoad={this.onLoaded}
|
||||
preload={preload}
|
||||
ref={this.audio}>
|
||||
<source src={this.props.audio} />
|
||||
<track default
|
||||
kind="subtitles"
|
||||
src={this.props.transcript}
|
||||
ref={this.track} />
|
||||
<track default
|
||||
kind="metadata"
|
||||
src={this.props.metadata}
|
||||
ref={this.metatrack} />
|
||||
</audio>
|
||||
</div>
|
||||
<div className="tracks">
|
||||
<Transcript
|
||||
url={this.props.transcript}
|
||||
seek={this.seek}
|
||||
track={track}
|
||||
query={this.state.query} />
|
||||
{metadata}
|
||||
</div>
|
||||
<Search query={this.state.query} updateQuery={this.updateQuery} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
onLoaded() {
|
||||
this.setState({ loaded: true })
|
||||
}
|
||||
|
||||
checkIfLoaded(tries = 0) {
|
||||
tries += 1
|
||||
const e = this.track.current
|
||||
if (e && e.track && e.track.cues && e.track.cues.length > 0) {
|
||||
this.onLoaded()
|
||||
} else if (!this.state.loaded) {
|
||||
const wait = 25 * Math.pow(tries, 2)
|
||||
setTimeout(this.checkIfLoaded, wait, tries)
|
||||
}
|
||||
}
|
||||
|
||||
seek(secs: number) {
|
||||
this.audio.current.currentTime = secs
|
||||
this.audio.current.play()
|
||||
}
|
||||
|
||||
updateQuery(query: string) {
|
||||
this.setState({ query: query })
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default WebVttPlayer
|
|
@ -20,7 +20,7 @@
|
|||
|
||||
/* tailwind flex-CUSTOMNUMBER */
|
||||
/* @apply h-96 p-[15px] overflow-y-scroll flex-[6]; */
|
||||
@apply text-orange-200;
|
||||
/* @apply text-orange-200; /* this works lol */
|
||||
}
|
||||
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue
Block a user