17 |
{
20 | setShow(true)
21 | }}
22 | >
23 | translate
24 |
25 | {show && (
26 | <>
27 |
{
30 | setShow(false)
31 | }}
32 | />
33 |
34 | {LANGUAGES.map(i => (
35 |
{
39 | dispath(updateLanguage(i.type))
40 | setShow(false)
41 | }}
42 | >
43 | check_circle
44 | {i.name}
45 |
46 | ))}
47 |
48 | >
49 | )}
50 |
51 | )
52 | }
53 |
--------------------------------------------------------------------------------
/src/utils/createEmptyAudio.ts:
--------------------------------------------------------------------------------
1 | // https://gist.github.com/ktcy/1e981cfee7a309beebb33cdab1e29715
2 |
3 | export async function createSilentAudio(seconds: number, name: string) {
4 | const sampleRate = 8000
5 | const numChannels = 1
6 | const bitsPerSample = 8
7 |
8 | const blockAlign = (numChannels * bitsPerSample) / 8
9 | const byteRate = sampleRate * blockAlign
10 | const dataSize = Math.ceil(seconds * sampleRate) * blockAlign
11 | const chunkSize = 36 + dataSize
12 | const byteLength = 8 + chunkSize
13 |
14 | const buffer = new ArrayBuffer(byteLength)
15 | const view = new DataView(buffer)
16 |
17 | view.setUint32(0, 0x52494646, false) // Chunk ID 'RIFF'
18 | view.setUint32(4, chunkSize, true) // File size
19 | view.setUint32(8, 0x57415645, false) // Format 'WAVE'
20 | view.setUint32(12, 0x666d7420, false) // Sub-chunk 1 ID 'fmt '
21 | view.setUint32(16, 16, true) // Sub-chunk 1 size
22 | view.setUint16(20, 1, true) // Audio format
23 | view.setUint16(22, numChannels, true) // Number of channels
24 | view.setUint32(24, sampleRate, true) // Sample rate
25 | view.setUint32(28, byteRate, true) // Byte rate
26 | view.setUint16(32, blockAlign, true) // Block align
27 | view.setUint16(34, bitsPerSample, true) // Bits per sample
28 | view.setUint32(36, 0x64617461, false) // Sub-chunk 2 ID 'data'
29 | view.setUint32(40, dataSize, true) // Sub-chunk 2 size
30 |
31 | for (let offset = 44; offset < byteLength; offset++) {
32 | view.setUint8(offset, 128)
33 | }
34 |
35 | const blob = new Blob([view])
36 | return new File([blob], name, { type: 'audio/wav' })
37 | }
38 |
--------------------------------------------------------------------------------
/src/components/Language.module.less:
--------------------------------------------------------------------------------
1 | @X: 36px;
2 | @X-half: 18px;
3 |
4 | .language {
5 | z-index: 1;
6 | position: absolute;
7 | top: 10px;
8 | right: 10px;
9 | cursor: pointer;
10 | background: #fff;
11 | width: @X;
12 | text-align: center;
13 | height: @X;
14 | border-radius: @X-half;
15 | box-shadow: var(--bs-sm);
16 | &:hover {
17 | box-shadow: var(--bs-md);
18 | }
19 |
20 | & > :global(.material-icons) {
21 | font-size: 24px;
22 | color: var(--black-500);
23 | line-height: @X;
24 | }
25 |
26 | .mask {
27 | position: fixed;
28 | top: 0;
29 | left: 0;
30 | width: 100vw;
31 | height: var(--100vh);
32 | }
33 |
34 | .tooltip {
35 | position: absolute;
36 | right: 0;
37 | top: 42px;
38 | border-radius: 6px;
39 | box-shadow: var(--bs-md);
40 | background: #fff;
41 | div {
42 | display: flex;
43 | align-items: center;
44 | padding: 5px 10px;
45 | &:hover {
46 | background: var(--bc-lighter);
47 | }
48 | :global(.material-icons) {
49 | font-size: 20px;
50 | color: var(--blue-600);
51 | margin-right: 5px;
52 | visibility: hidden;
53 | }
54 | &:global(.active) {
55 | :global(.material-icons) {
56 | visibility: visible;
57 | }
58 | }
59 | }
60 | &::after {
61 | content: '';
62 | position: absolute;
63 | top: -8px;
64 | right: 14px;
65 | border-width: 4px;
66 | border-style: solid;
67 | border-color: transparent transparent #fff transparent;
68 | }
69 | }
70 | }
71 |
--------------------------------------------------------------------------------
/src/components/Nav/form.tsx:
--------------------------------------------------------------------------------
1 | import { FC, useState } from 'react'
2 |
3 | interface NumberInputProps {
4 | isFloat?: boolean
5 | value: number
6 | onChange: (v: number) => void
7 | }
8 |
9 | export let NumberInput: FC
= ({ isFloat, value, onChange }) => {
10 | const [buf, setBuf] = useState('')
11 | const [editting, setEditting] = useState(false)
12 |
13 | return (
14 | {
18 | setBuf(value + '')
19 | setEditting(true)
20 | }}
21 | onChange={e => {
22 | setBuf(e.target.value)
23 | }}
24 | onBlur={() => {
25 | const v = isFloat ? parseFloat(buf) : parseInt(buf, 10)
26 | if (!isNaN(v)) {
27 | onChange(v)
28 | }
29 | setTimeout(() => {
30 | setEditting(false)
31 | }, 250)
32 | }}
33 | />
34 | )
35 | }
36 |
37 | interface TextInputProps {
38 | value: string
39 | onChange: (v: string) => void
40 | }
41 |
42 | export let TextInput: FC = ({ value, onChange }) => {
43 | const [buf, setBuf] = useState('')
44 | const [editting, setEditting] = useState(false)
45 |
46 | return (
47 | {
51 | setBuf(value + '')
52 | setEditting(true)
53 | }}
54 | onChange={e => {
55 | setBuf(e.target.value)
56 | }}
57 | onBlur={() => {
58 | onChange(buf)
59 | setEditting(false)
60 | }}
61 | />
62 | )
63 | }
64 |
--------------------------------------------------------------------------------
/sw/sw.ts:
--------------------------------------------------------------------------------
1 | declare var self: ServiceWorkerGlobalScope
2 |
3 | const PATH = '/srt-player/' // should be the same as base in vite config
4 | const SRT_CACHE = 'srt-cache'
5 | const CACHE_URLS = [
6 | '',
7 | 'index.js',
8 | 'index.css',
9 | 'version.js',
10 | 'srt-player.svg',
11 | 'github.png',
12 | 'fonts/MaterialIcons-Regular.ttf',
13 | 'fonts/MaterialIconsOutlined-Regular.otf',
14 | ]
15 | .map(i => PATH + i)
16 | .concat(PATH.substring(0, PATH.length - 1))
17 |
18 | const cacheAll = async () => {
19 | const cache = await caches.open(SRT_CACHE)
20 | const requests = CACHE_URLS.map(url => new Request(url, { cache: 'reload' }))
21 | return await cache.addAll(requests)
22 | }
23 |
24 | self.addEventListener('install', event => {
25 | event.waitUntil(Promise.resolve(cacheAll()))
26 | })
27 |
28 | self.addEventListener('fetch', event => {
29 | event.respondWith(
30 | (async () => {
31 | const cachedResponse = await caches.match(event.request)
32 | if (cachedResponse) {
33 | return cachedResponse
34 | } else {
35 | return await fetch(event.request)
36 | }
37 | })(),
38 | )
39 | })
40 |
41 | self.addEventListener('message', async event => {
42 | if (event.data.type === 'UPDATE') {
43 | const port = event.ports[0]
44 | await clearAndUpate()
45 | port.postMessage('updated')
46 | }
47 | })
48 |
49 | async function clearAndUpate() {
50 | const keys = await caches.keys()
51 | await Promise.all(
52 | keys.map(key => {
53 | if (key.startsWith('srt-')) {
54 | return caches.delete(key)
55 | } else {
56 | return Promise.resolve(true)
57 | }
58 | }),
59 | )
60 | await cacheAll()
61 | }
62 |
63 | export {}
64 |
--------------------------------------------------------------------------------
/src/index.css:
--------------------------------------------------------------------------------
1 | @import './reset.css';
2 | @import './theme.css';
3 |
4 | html {
5 | height: 100%;
6 | background-color: #fff;
7 | }
8 |
9 | body {
10 | margin: 0;
11 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans',
12 | 'Droid Sans', 'Helvetica Neue', sans-serif;
13 | -webkit-font-smoothing: antialiased;
14 | -moz-osx-font-smoothing: grayscale;
15 | }
16 |
17 | *[lang='jp'] {
18 | font-family: 'ヒラギノ角ゴ Pro W3', 'Hiragino Kaku Gothic Pro', Osaka, 'メイリオ', Meiryo, 'MS Pゴシック',
19 | 'MS PGothic', sans-serif;
20 | }
21 |
22 | *[lang='zh'] {
23 | font-family: Microsoft Jhenghei, PingFang HK, STHeitiTC-Light, tahoma, arial, sans-serif;
24 | }
25 |
26 | @font-face {
27 | font-family: 'Material Icons';
28 | font-style: normal;
29 | font-weight: 400;
30 | font-display: block;
31 | src: url(/fonts/MaterialIcons-Regular.ttf) format('truetype');
32 | }
33 |
34 | @font-face {
35 | font-family: 'Material Icons Outlined';
36 | font-style: normal;
37 | font-weight: 400;
38 | src: url(/fonts/MaterialIconsOutlined-Regular.otf) format('opentype');
39 | }
40 |
41 | .material-icons,
42 | .material-icons-outlined {
43 | font-weight: normal;
44 | font-style: normal;
45 | font-size: 24px;
46 | display: inline-block;
47 | line-height: 1;
48 | text-transform: none;
49 | letter-spacing: normal;
50 | word-wrap: normal;
51 | white-space: nowrap;
52 | direction: ltr;
53 |
54 | -webkit-font-smoothing: antialiased;
55 | text-rendering: optimizeLegibility;
56 | -moz-osx-font-smoothing: grayscale;
57 | }
58 |
59 | .material-icons {
60 | font-family: 'Material Icons';
61 | }
62 |
63 | .material-icons-outlined {
64 | font-family: 'Material Icons Outlined';
65 | }
66 |
--------------------------------------------------------------------------------
/src/utils/touchEmitter.ts:
--------------------------------------------------------------------------------
1 | import { useRef, useState, useEffect } from 'react'
2 |
3 | export type TouchEmitValue = number | 'start' | 'end'
4 | export interface TouchEmitterListener {
5 | (deltaX: TouchEmitValue): void
6 | }
7 |
8 | export const useTouchEmitter = (deps: any[]) => {
9 | const divRef = useRef(null)
10 | const [emitter] = useState(() => {
11 | let listener: TouchEmitterListener = () => {}
12 | return {
13 | broadcast(deltaX: TouchEmitValue) {
14 | listener(deltaX)
15 | },
16 | subscribe(fn: TouchEmitterListener) {
17 | listener = fn
18 | },
19 | }
20 | })
21 |
22 | useEffect(() => {
23 | const div = divRef.current
24 | if (!div) return
25 | let lastX: number
26 | const onTouchStart = (e: TouchEvent) => {
27 | emitter.broadcast('start')
28 | lastX = e.touches[0].clientX
29 | div.addEventListener('touchmove', onTouchMove)
30 | div.addEventListener('touchend', onTouchFinish)
31 | div.addEventListener('touchcancel', onTouchFinish)
32 | }
33 | const onTouchMove = (e: TouchEvent) => {
34 | e.preventDefault()
35 | const x = e.touches[0].clientX
36 | if (Math.abs(x - lastX) > 1) {
37 | emitter.broadcast(x - lastX)
38 | lastX = x
39 | }
40 | }
41 | const onTouchFinish = () => {
42 | emitter.broadcast('end')
43 | div.removeEventListener('touchmove', onTouchMove)
44 | div.removeEventListener('touchend', onTouchFinish)
45 | div.removeEventListener('touchcancel', onTouchFinish)
46 | }
47 | div.addEventListener('touchstart', onTouchStart)
48 | return () => {
49 | div.removeEventListener('touchstart', onTouchStart)
50 | }
51 | }, deps)
52 |
53 | return { divRef, emitter }
54 | }
55 |
--------------------------------------------------------------------------------
/src/components/Footer.module.less:
--------------------------------------------------------------------------------
1 | .footer {
2 | margin-top: auto;
3 | padding: 20px 0 10px 0;
4 | color: var(--black-600);
5 | font-size: 14px;
6 |
7 | .version {
8 | display: flex;
9 | justify-content: center;
10 | align-items: center;
11 |
12 | .icon {
13 | height: 16px;
14 | }
15 |
16 | .text {
17 | user-select: none;
18 | margin: 0 5px 0 5px;
19 | }
20 | .update {
21 | cursor: pointer;
22 | color: var(--blue-600);
23 | }
24 | }
25 |
26 | .line-2 {
27 | margin-top: 5px;
28 | display: flex;
29 | gap: 8px;
30 | justify-content: center;
31 | align-items: center;
32 | color: var(--black-600);
33 |
34 | :global(.material-icons) {
35 | font-size: 20px;
36 | color: #171516;
37 | }
38 |
39 | & > a,
40 | & > .feedback {
41 | display: flex;
42 | align-items: center;
43 | cursor: pointer;
44 | &:link,
45 | &:visited,
46 | &:focus,
47 | &:active {
48 | text-decoration: none;
49 | color: var(--black-600);
50 | }
51 | }
52 | }
53 | .separate {
54 | height: 14px;
55 | border-right: 1px solid var(--black-400);
56 | }
57 | .copy-right {
58 | img {
59 | width: 16px;
60 | margin-right: 5px;
61 | }
62 | }
63 | .modal {
64 | color: #000;
65 | .feedback-text {
66 | font-size: 14px;
67 | line-height: 16px;
68 | }
69 | .email,
70 | .video {
71 | padding: 4px 0;
72 | display: flex;
73 | align-items: center;
74 | :global(.material-icons-outlined) {
75 | font-size: 16px;
76 | margin-right: 8px;
77 | }
78 | color: var(--blue-900);
79 | a {
80 | color: var(--blue-900);
81 | }
82 | }
83 | }
84 | }
85 |
--------------------------------------------------------------------------------
/src/utils/idb.tsx:
--------------------------------------------------------------------------------
1 | import { openDB, DBSchema, IDBPDatabase } from 'idb'
2 | import { Node } from './subtitle'
3 |
4 | export interface VideoSubPair {
5 | video: FileSystemFileHandle | File | number
6 | subtitle?: string | Node[]
7 | }
8 |
9 | export enum EnableWaveForm {
10 | disable,
11 | video,
12 | audio,
13 | }
14 |
15 | export enum Languages {
16 | CN, // Chinese
17 | }
18 |
19 | export interface Bookmark {
20 | time: number // second
21 | name: string
22 | }
23 |
24 | export interface WatchHistory {
25 | currentTime: number
26 | duration: number
27 | subtitleTop: number // scroll position
28 | subtitleAuto: boolean
29 | subtitleDelay: number
30 | subtitleListeningMode: boolean
31 | subtitleLastActive: number | null
32 | subtitleLanguagesHided: Languages[]
33 | waveform: EnableWaveForm
34 | bookmarks: Bookmark[]
35 | }
36 |
37 | interface SRTPlayerDB extends DBSchema {
38 | 'audio-sampling': {
39 | key: string
40 | value: Blob[]
41 | }
42 | files: {
43 | key: string
44 | value: VideoSubPair
45 | }
46 | history: {
47 | key: string
48 | value: WatchHistory
49 | }
50 | global: {
51 | key: string
52 | value: string | number
53 | }
54 | }
55 |
56 | export let db: IDBPDatabase
57 | export const setGlobalDb = (_db: typeof db) => (db = _db)
58 |
59 | export const getDB = async () => {
60 | const _db = await openDB('srt-player', 1, {
61 | upgrade(db, oldVersion) {
62 | if (oldVersion === 0) {
63 | db.createObjectStore('audio-sampling')
64 | db.createObjectStore('files')
65 | db.createObjectStore('history')
66 | db.createObjectStore('global')
67 | }
68 | },
69 | blocking() {
70 | _db.close()
71 | location.reload()
72 | },
73 | })
74 | return _db
75 | }
76 |
--------------------------------------------------------------------------------
/vite.config.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig, Plugin, ResolvedConfig, build } from 'vite'
2 | import react from '@vitejs/plugin-react'
3 | import { readFileSync } from 'fs'
4 | import { resolve } from 'path'
5 |
6 | const SWPlugin = (): Plugin => {
7 | let config: ResolvedConfig
8 |
9 | return {
10 | name: 'vite-plugin-sw-middleware',
11 | config(config, env) {
12 | if (env.command === 'build') {
13 | return {
14 | ...config,
15 | build: {
16 | rollupOptions: {
17 | output: {
18 | entryFileNames: '[name].js',
19 | assetFileNames: '[name][extname]',
20 | chunkFileNames: 'common.js',
21 | manualChunks: undefined,
22 | },
23 | },
24 | },
25 | }
26 | } else {
27 | return config
28 | }
29 | },
30 | configResolved(resolvedConfig) {
31 | config = resolvedConfig
32 | },
33 | configureServer({ middlewares }) {
34 | // build({
35 | // build: {
36 | // rollupOptions: {
37 | // input: 'sw/sw.ts',
38 | // output: {
39 | // entryFileNames: `[name].js`,
40 | // manualChunks: undefined,
41 | // },
42 | // },
43 | // minify: false,
44 | // watch: {},
45 | // },
46 | // })
47 | // middlewares.use((req, res, next) => {
48 | // if (req.originalUrl === config.base + 'sw.js') {
49 | // const sw = readFileSync(resolve(__dirname, 'dist/sw.js'))
50 | // res.setHeader('Content-Type', 'text/javascript')
51 | // res.end(sw)
52 | // } else {
53 | // next()
54 | // }
55 | // })
56 | },
57 | }
58 | }
59 |
60 | export default defineConfig({
61 | plugins: [react(), SWPlugin()],
62 | base: '/srt-player/',
63 | })
64 |
--------------------------------------------------------------------------------
/src/state/filesSlice.ts:
--------------------------------------------------------------------------------
1 | import { createSlice, createAsyncThunk } from '@reduxjs/toolkit'
2 | import { deleteHistory, deleteSampling, getFileList, deletePair, Node, getSubtitle } from '../utils'
3 |
4 | export const getList = createAsyncThunk('files/getList', async () => {
5 | const fileNames = await getFileList()
6 | fileNames.sort((a, b) => a.localeCompare(b))
7 | return fileNames
8 | })
9 |
10 | export const setSelected = createAsyncThunk<{ selected: null | string; subtitleNoes: null | Node[] }, string | null>(
11 | 'files/setSelected',
12 | async f => {
13 | if (!f) {
14 | return {
15 | selected: null,
16 | subtitleNoes: null,
17 | }
18 | }
19 | try {
20 | const nodes = await getSubtitle(f)
21 | return {
22 | selected: f,
23 | subtitleNoes: nodes,
24 | }
25 | } catch {
26 | return {
27 | selected: f,
28 | subtitleNoes: [],
29 | }
30 | }
31 | },
32 | )
33 |
34 | export const deleteFile = createAsyncThunk('files/deleteFile', async (file, { dispatch }) => {
35 | await deletePair(file)
36 | await deleteHistory(file)
37 | await deleteSampling(file)
38 | dispatch(getList())
39 | })
40 |
41 | const initialState: {
42 | list: null | string[]
43 | selected: null | string
44 | subtitleNoes: null | Node[]
45 | } = { list: null, selected: null, subtitleNoes: null }
46 |
47 | export const filesSlice = createSlice({
48 | name: 'files',
49 | initialState,
50 | reducers: {},
51 | extraReducers: builder => {
52 | builder
53 | .addCase(setSelected.fulfilled, (state, action) => {
54 | state.selected = action.payload.selected
55 | state.subtitleNoes = action.payload.subtitleNoes
56 | })
57 | .addCase(getList.fulfilled, (state, action) => {
58 | state.list = action.payload
59 | })
60 | },
61 | })
62 |
63 | export const filesReducer = filesSlice.reducer
64 |
--------------------------------------------------------------------------------
/src/components/Modal.module.less:
--------------------------------------------------------------------------------
1 | .modal-container {
2 | :global(.hide-modal) & {
3 | visibility: hidden;
4 | }
5 | position: fixed;
6 | z-index: 10;
7 | top: 0;
8 | left: 0;
9 | width: 100%;
10 | height: 100%;
11 | display: flex;
12 | justify-content: center;
13 | align-items: center;
14 |
15 | .mask {
16 | width: 100%;
17 | height: 100%;
18 | background: rgba(0, 0, 0, 0.05);
19 | backdrop-filter: blur(2px);
20 | position: absolute;
21 | }
22 |
23 | .modal {
24 | background: #fff;
25 | z-index: 1;
26 | border-radius: 8px;
27 | max-width: calc(100vw - 40px);
28 | max-height: var(--100vh);
29 | box-shadow: var(--bs-lg);
30 | overflow: hidden;
31 |
32 | .header {
33 | display: flex;
34 | align-items: center;
35 | padding: 12px 16px 8px 16px;
36 | .title {
37 | font-size: 18px;
38 | & + span {
39 | flex-grow: 1;
40 | }
41 | }
42 | .icon {
43 | color: var(--black-500);
44 | cursor: pointer;
45 | }
46 | border-bottom: 1px solid var(--bc-lighter);
47 | }
48 |
49 | .body {
50 | overflow: auto;
51 | max-height: calc(var(--100vh) - 60px);
52 | position: relative;
53 | .padding {
54 | padding: 16px;
55 | width: fit-content;
56 | height: fit-content;
57 | }
58 | }
59 | }
60 | }
61 |
62 | .message,
63 | .confirm {
64 | .text {
65 | margin: 4px 0 16px 0;
66 | font-size: 16px;
67 | line-height: 20px;
68 | }
69 | .buttons {
70 | display: flex;
71 | justify-content: flex-end;
72 | .ok,
73 | .cancel {
74 | border-radius: 4px;
75 | cursor: pointer;
76 | padding: 4px 8px;
77 | }
78 | .ok {
79 | border: 1px solid var(--blue-600);
80 | background: var(--blue-600);
81 | color: #fff;
82 | margin-right: 10px;
83 | }
84 | .cancel {
85 | border: 1px solid var(--black-400);
86 | }
87 | }
88 | }
89 |
--------------------------------------------------------------------------------
/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | SRT Player
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
36 |
37 |
42 |
Loading...
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
--------------------------------------------------------------------------------
/src/components/WaveForm.module.less:
--------------------------------------------------------------------------------
1 | @H: 80px;
2 | @h: 40px;
3 | .media-mobile(@rules) {
4 | @media only screen and (max-height: 420px) {
5 | @rules();
6 | }
7 | }
8 | .responsive(@rules) {
9 | @height: @H;
10 | @rules();
11 | .media-mobile({
12 | @height: @h;
13 | @rules();
14 | });
15 | }
16 | @scrollbar-height: 8px;
17 |
18 | .waveform {
19 | position: relative;
20 | overflow-x: scroll;
21 | overflow-y: hidden;
22 | .responsive({
23 | height: @height + @scrollbar-height;
24 | });
25 | &.ready {
26 | scroll-behavior: smooth;
27 | }
28 | &:not(.ready) {
29 | .waveform-container {
30 | visibility: hidden;
31 | }
32 | }
33 | &.instant-scroll {
34 | scroll-behavior: auto;
35 | }
36 |
37 | /* firefox-only */
38 | /* scrollbar-width: thin; */
39 |
40 | &::-webkit-scrollbar {
41 | background-color: transparent;
42 | height: @scrollbar-height;
43 | min-width: 80px;
44 | }
45 |
46 | &::-webkit-scrollbar-track {
47 | background-color: var(--scrollbar-track-color);
48 | }
49 |
50 | &::-webkit-scrollbar-thumb {
51 | background-color: var(--scrollbar-thumb-color);
52 | border-radius: 16px;
53 | border: 4px solid transparent;
54 | }
55 |
56 | /* set button(top and bottom of the scrollbar) */
57 | &::-webkit-scrollbar-button {
58 | display: none;
59 | }
60 | }
61 |
62 | .waveform-container {
63 | position: relative;
64 | .responsive({
65 | height: @height;
66 | });
67 | display: inline-flex;
68 | img {
69 | pointer-events: none;
70 | height: @H;
71 | .media-mobile({
72 | transform:scaleY(0.5);
73 | transform-origin:top;
74 | });
75 | }
76 | .replay-indicator,
77 | .current-time-indicator,
78 | .bookmark-indicator {
79 | position: absolute;
80 | width: 1px;
81 | height: 100%;
82 | top: 0;
83 | left: 0;
84 | }
85 | .replay-indicator {
86 | background: #e6d874;
87 | }
88 | .current-time-indicator {
89 | background: #f92672;
90 | }
91 | .bookmark-indicator {
92 | background-color: #61afef;
93 | }
94 | }
95 |
--------------------------------------------------------------------------------
/src/components/List.module.less:
--------------------------------------------------------------------------------
1 | @height: 32px;
2 |
3 | .list {
4 | font-size: 16px;
5 |
6 | .item {
7 | position: relative;
8 | display: grid;
9 | grid-template-columns: minmax(0, 1fr) minmax(0, auto) minmax(0, auto);
10 | gap: 8px;
11 | padding-left: 10px;
12 | padding-right: 10px;
13 | height: @height;
14 | line-height: @height;
15 |
16 | &:nth-child(odd) {
17 | background: var(--bc-lighter);
18 | }
19 |
20 | .file-name {
21 | cursor: pointer;
22 | overflow: hidden;
23 | white-space: nowrap;
24 | text-overflow: ellipsis;
25 | }
26 |
27 | .label {
28 | font-variant-numeric: tabular-nums;
29 | font-size: 14px;
30 | }
31 |
32 | .icon {
33 | width: 15px;
34 | cursor: pointer;
35 | color: var(--red-500);
36 | line-height: @height;
37 | }
38 |
39 | &::after {
40 | content: '';
41 | position: absolute;
42 | height: 2px;
43 | left: 0;
44 | bottom: 0;
45 | width: var(--watch-progress);
46 | background: var(--blue-600);
47 | }
48 | }
49 | }
50 |
51 | .hidden {
52 | display: none;
53 | }
54 |
55 | .download-example {
56 | margin: 20px 10px;
57 | font-size: 14px;
58 | line-height: 20px;
59 | :global(.material-icons-outlined) {
60 | display: block;
61 | margin-bottom: 10px;
62 | }
63 | .message-box {
64 | max-width: 600px;
65 | box-sizing: border-box;
66 | margin: 0 auto;
67 | background: var(--blue-050);
68 | padding: 15px;
69 | border-radius: 6px;
70 | border-left: 6px solid var(--blue-600);
71 |
72 | .example-list {
73 | margin-top: 5px;
74 | .list-item {
75 | list-style: inside;
76 | }
77 | .downloading {
78 | a {
79 | text-decoration: none;
80 | cursor: wait;
81 | }
82 | .progress {
83 | display: inline;
84 | margin-left: 10px;
85 | }
86 | }
87 | .finished {
88 | display: none;
89 | }
90 | .progress {
91 | display: none;
92 | }
93 | }
94 | }
95 | }
96 |
--------------------------------------------------------------------------------
/src/utils/video.ts:
--------------------------------------------------------------------------------
1 | import { useEffect } from 'react'
2 | import { useSelector } from '../state'
3 |
4 | export const VIDEO_ID = 'srt-player-video'
5 |
6 | export function doVideo(cb: (v: HTMLVideoElement) => T): T | undefined {
7 | const videoElement = document.getElementById(VIDEO_ID) as HTMLVideoElement | null
8 | if (!videoElement) return undefined
9 | return cb(videoElement)
10 | }
11 |
12 | export function doVideoWithDefault(cb: (v: HTMLVideoElement) => T, defaultValue: T): T {
13 | const videoElement = document.getElementById(VIDEO_ID) as HTMLVideoElement | null
14 | if (!videoElement) return defaultValue
15 | return cb(videoElement)
16 | }
17 |
18 | export function isAudioOnly(fileName: string, cb: (r: boolean) => void) {
19 | if (/\.(m4a|flac|alac|mp3|wav|wma|aac|ogg)$/i.test(fileName)) {
20 | cb(true)
21 | return
22 | }
23 | setTimeout(() => {
24 | const audioOnly = doVideoWithDefault(v => {
25 | const video = v as any
26 | if (video.webkitVideoDecodedByteCount === 0) return true
27 | if (video.mozDecodedFrames === 0) return true
28 | return false
29 | }, false)
30 | cb(audioOnly)
31 | }, 1000)
32 | }
33 |
34 | interface VideoEvents {
35 | play?(): void
36 | pause?(): void
37 | seeked?(): void
38 | }
39 |
40 | export const useVideoEvents = (cbs: VideoEvents) => {
41 | const hasVideo = useSelector(s => s.video.hasVideo)
42 | useEffect(() => {
43 | if (!hasVideo) return
44 | return doVideo(video => {
45 | if (cbs.play) {
46 | if (!video.paused && !video.ended) cbs.play()
47 | video.addEventListener('play', cbs.play)
48 | }
49 | if (cbs.pause) {
50 | video.addEventListener('pause', cbs.pause)
51 | }
52 | if (cbs.seeked) {
53 | video.addEventListener('seeked', cbs.seeked)
54 | }
55 | return () => {
56 | if (cbs.play) {
57 | video.removeEventListener('play', cbs.play)
58 | }
59 | if (cbs.pause) {
60 | video.removeEventListener('pause', cbs.pause)
61 | }
62 | if (cbs.seeked) {
63 | video.removeEventListener('seeked', cbs.seeked)
64 | }
65 | }
66 | })
67 | }, [hasVideo])
68 | }
69 |
--------------------------------------------------------------------------------
/src/utils/audioSampling.ts:
--------------------------------------------------------------------------------
1 | import { db, getDB } from './idb'
2 |
3 | export interface Payload {
4 | file: string
5 | duration: number
6 | buffer: [ArrayBuffer, number, number]
7 | }
8 |
9 | export interface SamplingResult {
10 | buffer: Uint8Array
11 | }
12 |
13 | export interface RenderTask extends SamplingResult {
14 | file: string
15 | }
16 |
17 | export const SAMPLE_RATE = 44100
18 | export const WAVEFORM_HEIGHT = 80
19 | export const NEW_SAMPLING_RATE = 10
20 | export const PIXELS_PER_SECOND = 30
21 | export const SLICE_WIDTH = 4002 // canvas cannot be too wide
22 |
23 | export const getSampling = (file: string) => db.get('audio-sampling', file)
24 | export const deleteSampling = (file: string) => db.delete('audio-sampling', file)
25 | // saveSampling is called from web worker which does not have access to db
26 | export const saveSampling = async (file: string, blobs: Blob[]) => {
27 | const db = await getDB()
28 | return db.put('audio-sampling', blobs, file)
29 | }
30 |
31 | export enum StageEnum {
32 | stopped,
33 | decoding,
34 | resampling,
35 | imageGeneration,
36 | done,
37 | }
38 |
39 | interface ComputeAudioSampling {
40 | worker: Worker
41 | arrayBuffer: ArrayBuffer
42 | fileName: string
43 | audioDuration: number
44 | onProgress: (s: StageEnum) => void
45 | }
46 |
47 | export const computeAudioSampling = async (task: ComputeAudioSampling) => {
48 | const { worker, arrayBuffer, fileName, audioDuration, onProgress } = task
49 | const audioContext = new OfflineAudioContext(1, 2, SAMPLE_RATE)
50 | const audioBuffer = await audioContext.decodeAudioData(arrayBuffer)
51 | const float32Array = audioBuffer.getChannelData(0)
52 | const payload: Payload = {
53 | file: fileName,
54 | duration: audioDuration,
55 | buffer: [float32Array.buffer, float32Array.byteOffset, float32Array.byteLength / Float32Array.BYTES_PER_ELEMENT],
56 | }
57 | worker.postMessage(payload, [payload.buffer[0]])
58 | return await new Promise((resolve, reject) => {
59 | worker.onmessage = e => {
60 | if (e.data?.type === 'error') {
61 | reject(e.data?.error)
62 | return
63 | }
64 | const stage = e.data?.stage as StageEnum
65 | if (typeof stage !== 'number') return
66 | onProgress(stage)
67 | if (stage === StageEnum.done) {
68 | worker.terminate()
69 | resolve()
70 | }
71 | }
72 | })
73 | }
74 |
--------------------------------------------------------------------------------
/src/components/Subtitle.module.less:
--------------------------------------------------------------------------------
1 | .subtitle {
2 | background: #272822;
3 | color: #e6e6e0;
4 | height: 100%;
5 | box-sizing: border-box;
6 | overflow-x: hidden;
7 | overflow-y: auto;
8 |
9 | &:not(.ready) {
10 | .node {
11 | visibility: hidden;
12 | }
13 | }
14 |
15 | .node {
16 | padding: 4px;
17 | display: grid;
18 | grid-template-columns: minmax(0, auto) 1fr;
19 | column-gap: 16px;
20 | &:global(.highlighted-subtitle) {
21 | background: #484d5b;
22 | }
23 | }
24 |
25 | /* firefox-only */
26 | /* scrollbar-width: thin; */
27 |
28 | &::-webkit-scrollbar {
29 | background-color: transparent;
30 | width: 8px;
31 | min-height: 80px;
32 | }
33 |
34 | &::-webkit-scrollbar-track {
35 | background-color: var(--scrollbar-track-color);
36 | }
37 |
38 | &::-webkit-scrollbar-thumb {
39 | background-color: var(--scrollbar-thumb-color);
40 | border-radius: 16px;
41 | border: 4px solid transparent;
42 | }
43 |
44 | &::-webkit-scrollbar-button {
45 | display: none;
46 | }
47 | }
48 |
49 | .line {
50 | font-size: var(--subtitle-time);
51 | font-variant-numeric: tabular-nums;
52 | }
53 |
54 | .counter {
55 | color: #e6d874;
56 | font-size: var(--subtitle-counter);
57 | align-self: start;
58 | justify-self: start;
59 | cursor: pointer;
60 | }
61 |
62 | .start,
63 | .end {
64 | cursor: pointer;
65 | }
66 |
67 | .start {
68 | color: #a6e22e;
69 | }
70 |
71 | .end {
72 | color: #f92672;
73 | }
74 |
75 | .hyphen {
76 | white-space: pre;
77 | }
78 |
79 | .text {
80 | font-size: var(--subtitle-text);
81 | line-height: calc(var(--subtitle-text) + 4px);
82 | i {
83 | font-style: italic;
84 | }
85 | b {
86 | font-weight: bold;
87 | }
88 | u {
89 | text-decoration: underline;
90 | }
91 | &:first-of-type {
92 | margin-top: 2px;
93 | }
94 | }
95 |
96 | .text-blurred() {
97 | color: transparent;
98 | text-shadow: 0 0 8px rgb(255, 255, 255);
99 | }
100 |
101 | .subtitle {
102 | &.listening-mode {
103 | .node:global(.highlighted-subtitle) ~ .node {
104 | .text {
105 | .text-blurred();
106 | }
107 | }
108 | }
109 | &.CN-hided {
110 | .isCN {
111 | /* .text-blurred(); */
112 | display: none;
113 | }
114 | }
115 | }
116 |
--------------------------------------------------------------------------------
/src/locale/zh-CN.json:
--------------------------------------------------------------------------------
1 | {
2 | "import_video_and_subtitle": {
3 | "drag": "拖拽视频与字幕文件到这里",
4 | "browse": "浏览文件",
5 | "or": "或",
6 | "save": "保存",
7 | "save_cache": "复制文件到缓存"
8 | },
9 | "play_list": {
10 | "download_example": "播放列表为空,要下载一些示例吗?",
11 | "example": "示例 {0}",
12 | "downloading": "下载中..."
13 | },
14 | "footer": {
15 | "current_version": "当前版本",
16 | "update": "升级",
17 | "feedback": "反馈",
18 | "feedback_email": "如果你有遇到bug,有使用上的建议或者任何问题,都可以通过下面的邮箱反馈给我:",
19 | "feedback_video": "也可以在这个视频下面的评论区留言:",
20 | "video_url": "https://www.bilibili.com/video/BV1Ci4y1d7iA/"
21 | },
22 | "nav": {
23 | "info": {
24 | "name": "快捷键",
25 | "video": {
26 | "name": "视频",
27 | "play_pause": "播放 / 暂停",
28 | "back_10_seconds": "-10 秒",
29 | "back_3_seconds": "-3 秒",
30 | "forward_10_seconds": "+10 秒",
31 | "forward_3_seconds": "+3 秒",
32 | "fullscreen": "全屏",
33 | "add_bookmark": "添加书签"
34 | },
35 | "subtitle": {
36 | "name": "字幕",
37 | "page_up": "上翻",
38 | "page_down": "下翻",
39 | "toggle_auto": "开关自动模式",
40 | "toggle_listening_mode": "开关听力模式",
41 | "toggle_hide_chinese": "开关隐藏中文",
42 | "toggle_settings": "开关设置窗口",
43 | "adjust_delay": "调整延迟",
44 | "click_time": "点击开始或结束时间",
45 | "font_up": "字体加大",
46 | "font_down": "字体减小"
47 | },
48 | "waveform": {
49 | "name": "波形图",
50 | "toggle_settings": "开关设置窗口",
51 | "replay": "重播",
52 | "replay_position_left": "重播位置左移",
53 | "replay_position_left_quicker": "重播位置左移(快)",
54 | "replay_position_right": "重播位置右移",
55 | "replay_position_right_quicker": "重播位置右移(快)",
56 | "replay_position_at_current_time": "设置当前时间为重播位置"
57 | }
58 | },
59 | "subtitle": {
60 | "name": "字幕",
61 | "width": "宽度",
62 | "auto": "自动模式",
63 | "delay": "延迟(秒)",
64 | "font_size": "字体大小",
65 | "listening_mode": "听力模式",
66 | "hide_chinese": "隐藏中文"
67 | },
68 | "waveform": {
69 | "name": "波形图",
70 | "disable": "关闭",
71 | "enable": "启用",
72 | "with_existing": "从现有文件提取",
73 | "with_another": "其他文件源",
74 | "generating": "波形图生成中,请稍等...",
75 | "done": "完成"
76 | }
77 | },
78 | "confirm": {
79 | "ok": "确定",
80 | "cancel": "取消",
81 | "reload_update": "要升级吗?",
82 | "cannot_find_file": "找不到文件{0},从列表里移除吗?",
83 | "overwrite_existing_file": "{0}已经存在,要覆盖吗?"
84 | },
85 | "message": {
86 | "ok": "确定"
87 | },
88 | "error": {
89 | "database_initialize": "数据库初始化错误",
90 | "video_src_not_supported": "大变!😭
你的浏览器不支持这个视频格式。需要先用其他软件转码。
比如视频用h264(avc),音频用mp3,容器用mp4。"
91 | },
92 | "bookmark": {
93 | "edit": "编辑书签",
94 | "add_description": "添加备注"
95 | }
96 | }
97 |
--------------------------------------------------------------------------------
/src/state/videoSlice.ts:
--------------------------------------------------------------------------------
1 | import { createSlice, createAsyncThunk, PayloadAction } from '@reduxjs/toolkit'
2 | import { getBookmarks, saveBookmarks, Bookmark } from '../utils'
3 |
4 | interface InitialState {
5 | hasVideo: boolean
6 | playing: boolean
7 | total: number
8 | current: number
9 | bookmarks: Bookmark[]
10 | }
11 |
12 | const initialState: InitialState = { hasVideo: false, playing: false, total: 0, current: 0, bookmarks: [] }
13 |
14 | export const LoadBookmarks = createAsyncThunk('video/bookmarks', async (file: string) => {
15 | return await getBookmarks(file)
16 | })
17 |
18 | export const updateBookmarks = createAsyncThunk(
19 | 'video/updateBookmarks',
20 | async ({ file, bookmarks }) => {
21 | await saveBookmarks(file, bookmarks)
22 | return bookmarks
23 | },
24 | )
25 |
26 | export const addBookmark = createAsyncThunk(
27 | 'video/addBookmark',
28 | async ({ file, currentTime }, { getState }) => {
29 | const state = getState() as { video: InitialState }
30 | if (state.video.bookmarks.findIndex(b => b.time === currentTime) !== -1) return state.video.bookmarks
31 | const newBookmarks = [...state.video.bookmarks, { time: currentTime, name: '' }].sort((a, b) => a.time - b.time)
32 | await saveBookmarks(file, newBookmarks)
33 | return newBookmarks
34 | },
35 | )
36 |
37 | export const removeBookmark = createAsyncThunk(
38 | 'video/removeBookmark',
39 | async ({ file, currentTime }, { getState }) => {
40 | const state = getState() as { video: InitialState }
41 | const index = state.video.bookmarks.findIndex(b => b.time === currentTime)
42 | if (index === -1) return state.video.bookmarks
43 | const newBookmarks = [...state.video.bookmarks]
44 | newBookmarks.splice(index, 1)
45 | await saveBookmarks(file, newBookmarks)
46 | return newBookmarks
47 | },
48 | )
49 |
50 | export const videoSlice = createSlice({
51 | name: 'video',
52 | initialState,
53 | reducers: {
54 | setVideo: (state, action: PayloadAction<{ hasVideo: boolean; total?: number }>) => {
55 | state.hasVideo = action.payload.hasVideo
56 | if (action.payload.total) {
57 | state.total = action.payload.total
58 | }
59 | },
60 | updateVideoTime: (state, action: PayloadAction) => {
61 | state.current = action.payload
62 | },
63 | setVideoStatus: (state, action: PayloadAction) => {
64 | if (action.payload !== undefined) {
65 | state.playing = action.payload
66 | } else {
67 | state.playing = !state.playing
68 | }
69 | },
70 | },
71 | extraReducers: builder => {
72 | builder
73 | .addCase(LoadBookmarks.fulfilled, (state, action) => {
74 | state.bookmarks = action.payload
75 | })
76 | .addCase(updateBookmarks.fulfilled, (state, action) => {
77 | state.bookmarks = action.payload
78 | })
79 | .addCase(addBookmark.fulfilled, (state, action) => {
80 | state.bookmarks = action.payload
81 | })
82 | .addCase(removeBookmark.fulfilled, (state, action) => {
83 | state.bookmarks = action.payload
84 | })
85 | },
86 | })
87 |
88 | export const videoReducer = videoSlice.reducer
89 | export const { setVideo, updateVideoTime, setVideoStatus } = videoSlice.actions
90 |
--------------------------------------------------------------------------------
/src/theme.css:
--------------------------------------------------------------------------------
1 | :root {
2 | --white: hsl(0, 0%, 100%);
3 | --black: hsl(210, 8%, 5%);
4 | --orange: hsl(27, 90%, 55%);
5 | --yellow: hsl(47, 83%, 91%);
6 | --green: hsl(140, 40%, 55%);
7 | --blue: hsl(206, 100%, 40%);
8 | --powder: hsl(205, 46%, 92%);
9 | --red: hsl(358, 62%, 52%);
10 | --black-025: hsl(210, 8%, 97.5%);
11 | --black-050: hsl(210, 8%, 95%);
12 | --black-075: hsl(210, 8%, 90%);
13 | --black-100: hsl(210, 8%, 85%);
14 | --black-150: hsl(210, 8%, 80%);
15 | --black-200: hsl(210, 8%, 75%);
16 | --black-300: hsl(210, 8%, 65%);
17 | --black-350: hsl(210, 8%, 60%);
18 | --black-400: hsl(210, 8%, 55%);
19 | --black-500: hsl(210, 8%, 45%);
20 | --black-600: hsl(210, 8%, 35%);
21 | --black-700: hsl(210, 8%, 25%);
22 | --black-750: hsl(210, 8%, 20%);
23 | --black-800: hsl(210, 8%, 15%);
24 | --black-900: hsl(210, 8%, 5%);
25 | --orange-050: hsl(27, 100%, 97%);
26 | --orange-100: hsl(27, 95%, 90%);
27 | --orange-200: hsl(27, 90%, 83%);
28 | --orange-300: hsl(27, 90%, 70%);
29 | --orange-400: hsl(27, 90%, 55%);
30 | --orange-500: hsl(27, 90%, 50%);
31 | --orange-600: hsl(27, 90%, 45%);
32 | --orange-700: hsl(27, 90%, 39%);
33 | --orange-800: hsl(27, 87%, 35%);
34 | --orange-900: hsl(27, 80%, 30%);
35 | --blue-050: hsl(206, 100%, 97%);
36 | --blue-100: hsl(206, 96%, 90%);
37 | --blue-200: hsl(206, 93%, 83.5%);
38 | --blue-300: hsl(206, 90%, 69.5%);
39 | --blue-400: hsl(206, 85%, 57.5%);
40 | --blue-500: hsl(206, 100%, 52%);
41 | --blue-600: hsl(206, 100%, 40%);
42 | --blue-700: hsl(209, 100%, 37.5%);
43 | --blue-800: hsl(209, 100%, 32%);
44 | --blue-900: hsl(209, 100%, 26%);
45 | --green-025: hsl(140, 42%, 95%);
46 | --green-050: hsl(140, 40%, 90%);
47 | --green-100: hsl(140, 40%, 85%);
48 | --green-200: hsl(140, 40%, 75%);
49 | --green-300: hsl(140, 40%, 65%);
50 | --green-400: hsl(140, 40%, 55%);
51 | --green-500: hsl(140, 40%, 47%);
52 | --green-600: hsl(140, 40%, 40%);
53 | --green-700: hsl(140, 41%, 31%);
54 | --green-800: hsl(140, 40%, 27%);
55 | --green-900: hsl(140, 40%, 20%);
56 | --yellow-050: hsl(47, 87%, 94%);
57 | --yellow-100: hsl(47, 83%, 91%);
58 | --yellow-200: hsl(47, 65%, 84%);
59 | --yellow-300: hsl(47, 69%, 69%);
60 | --yellow-400: hsl(47, 79%, 58%);
61 | --yellow-500: hsl(47, 73%, 50%);
62 | --yellow-600: hsl(47, 76%, 46%);
63 | --yellow-700: hsl(47, 79%, 40%);
64 | --yellow-800: hsl(47, 82%, 34%);
65 | --yellow-900: hsl(47, 84%, 28%);
66 | --red-050: hsl(358, 75%, 97%);
67 | --red-100: hsl(358, 76%, 90%);
68 | --red-200: hsl(358, 74%, 83%);
69 | --red-300: hsl(358, 70%, 70%);
70 | --red-400: hsl(358, 68%, 59%);
71 | --red-500: hsl(358, 62%, 52%);
72 | --red-600: hsl(358, 62%, 47%);
73 | --red-700: hsl(358, 64%, 41%);
74 | --red-800: hsl(358, 64%, 35%);
75 | --red-900: hsl(358, 67%, 29%);
76 | --bc-lightest: var(--black-025);
77 | --bc-lighter: var(--black-050);
78 | --bc-light: var(--black-075);
79 | --bc-medium: var(--black-100);
80 | --bc-dark: var(--black-150);
81 | --bc-darker: var(--black-200);
82 | --bs-sm: 0 1px 2px hsla(0, 0%, 0%, 0.05), 0 1px 4px hsla(0, 0%, 0%, 0.05), 0 2px 8px hsla(0, 0%, 0%, 0.05);
83 | --bs-md: 0 1px 3px hsla(0, 0%, 0%, 0.06), 0 2px 6px hsla(0, 0%, 0%, 0.06), 0 3px 8px hsla(0, 0%, 0%, 0.09);
84 | --bs-lg: 0 1px 4px hsla(0, 0%, 0%, 0.09), 0 3px 8px hsla(0, 0%, 0%, 0.09), 0 4px 13px hsla(0, 0%, 0%, 0.13);
85 | --scrollbar-thumb-color: #babac0;
86 | --scrollbar-track-color: rgba(255, 255, 255, 0.1);
87 | }
88 |
--------------------------------------------------------------------------------
/src/locale/en-US.json:
--------------------------------------------------------------------------------
1 | {
2 | "import_video_and_subtitle": {
3 | "drag": "Drag video and subtitle files here",
4 | "browse": "Browse files",
5 | "or": "OR",
6 | "save": "Save",
7 | "save_cache": "Copy file(s) to cache"
8 | },
9 | "play_list": {
10 | "download_example": "There is currently no videos in the play list, would you like to download some examples?",
11 | "example": "Example {0}",
12 | "downloading": "Downloading..."
13 | },
14 | "footer": {
15 | "current_version": "Current version",
16 | "update": "Update",
17 | "feedback": "Feedback",
18 | "feedback_email": "Encountered bugs? Having suggestions? Or if you have any question regarding this app, you can send your feedback via email here:",
19 | "feedback_video": "Or you can comment here:",
20 | "video_url": "https://youtu.be/ZPnu17pJsIo"
21 | },
22 | "nav": {
23 | "info": {
24 | "name": "Shortcuts",
25 | "video": {
26 | "name": "Video",
27 | "play_pause": "Play / pause",
28 | "back_10_seconds": "-10 s",
29 | "back_3_seconds": "-3 s",
30 | "forward_10_seconds": "+10 s",
31 | "forward_3_seconds": "+3 s",
32 | "fullscreen": "Fullscreen",
33 | "add_bookmark": "Add bookmark"
34 | },
35 | "subtitle": {
36 | "name": "Subtitle",
37 | "page_up": "Page up",
38 | "page_down": "Page down",
39 | "toggle_auto": "Toggle auto",
40 | "toggle_listening_mode": "Toggle listening mode",
41 | "toggle_hide_chinese": "Toggle hide Chinese",
42 | "toggle_settings": "Toggle settings",
43 | "adjust_delay": "Adjust delay",
44 | "click_time": "Click start or end time",
45 | "font_up": "Increase font size",
46 | "font_down": "Decrease font size"
47 | },
48 | "waveform": {
49 | "name": "Waveform",
50 | "toggle_settings": "Toggle settings",
51 | "replay": "Replay",
52 | "replay_position_left": "Replay position left",
53 | "replay_position_left_quicker": "Replay position left (quicker)",
54 | "replay_position_right": "Replay position right",
55 | "replay_position_right_quicker": "Replay position right (quicker)",
56 | "replay_position_at_current_time": "Replay position at current time"
57 | }
58 | },
59 | "subtitle": {
60 | "name": "Subtitle",
61 | "width": "Width",
62 | "auto": "Auto mode",
63 | "delay": "Delay (seconds)",
64 | "font_size": "Font size",
65 | "listening_mode": "Listening mode",
66 | "hide_chinese": "Hide Chinese"
67 | },
68 | "waveform": {
69 | "name": "Waveform",
70 | "disable": "Disable",
71 | "enable": "Enable",
72 | "with_existing": "Using existing file",
73 | "with_another": "Using another file",
74 | "generating": "Generating waveform, this might take a while...",
75 | "done": "Done"
76 | }
77 | },
78 | "confirm": {
79 | "ok": "OK",
80 | "cancel": "Cancel",
81 | "reload_update": "Reload to update?",
82 | "cannot_find_file": "Cannot find {0}, remove it from list?",
83 | "overwrite_existing_file": "{0} already exsit(s), overwrite?"
84 | },
85 | "message": {
86 | "ok": "OK"
87 | },
88 | "error": {
89 | "database_initialize": "Failed to initialize database",
90 | "video_src_not_supported": "Sorry, this video's format is not supported in your broswer.
You need to convert it to a compatible format first using other software such HandBrake or ffmpeg.
Example format: h264(avc) for video, mp3 for audio and mp4 for container."
91 | },
92 | "bookmark": {
93 | "edit": "Edit bookmarks",
94 | "add_description": "Add description"
95 | }
96 | }
97 |
--------------------------------------------------------------------------------
/src/App.tsx:
--------------------------------------------------------------------------------
1 | import { FC, useEffect, MouseEventHandler, TouchEventHandler } from 'react'
2 | import cn from 'classnames'
3 | import { Language } from './components/Language'
4 | import { Uploader } from './components/Uploader'
5 | import { List } from './components/List'
6 | import { Footer } from './components/Footer'
7 | import { Nav } from './components/Nav'
8 | import { Subtitle } from './components/Subtitle'
9 | import { Video } from './components/Video'
10 | import { Message, Confirm } from './components/Modal'
11 | import { useDispatch, useSelector, getList, LoadSettingsFromLocal, updateSubtitleWidth } from './state'
12 | import styles from './App.module.less'
13 | import { useSaveHistory, migrate, IS_MOBILE, isSubtitleOnly } from './utils'
14 |
15 | const App: FC = migrate(() => {
16 | const dispatch = useDispatch()
17 | const subtitleNoes = useSelector(state => state.files.subtitleNoes)
18 | const locale = useSelector(state => state.settings.locale)
19 | const selected = useSelector(state => state.files.selected)
20 |
21 | useEffect(() => {
22 | dispatch(getList())
23 | dispatch(LoadSettingsFromLocal())
24 | }, [])
25 |
26 | if (locale === '') return null
27 |
28 | const isVideo = subtitleNoes !== null
29 | return (
30 | <>
31 |
32 | {isVideo && 0} hasVideo={!isSubtitleOnly(selected || '')} />}
33 | >
34 | )
35 | })
36 |
37 | const Home: FC<{ show: boolean }> = ({ show }) => {
38 | return (
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 | )
48 | }
49 |
50 | interface PlayProps {
51 | hasSubtitle: boolean
52 | hasVideo: boolean
53 | }
54 |
55 | const Play: FC = ({ hasSubtitle, hasVideo }) => {
56 | const saveHistory = useSaveHistory(5000)
57 |
58 | useEffect(() => {
59 | document.addEventListener('mouseleave', saveHistory)
60 | return () => {
61 | document.removeEventListener('mouseleave', saveHistory)
62 | }
63 | }, [])
64 |
65 | return (
66 | <>
67 |
68 |
69 |
70 |
71 |
72 |
73 |
74 |
75 | >
76 | )
77 | }
78 |
79 | const ResizeBar: FC = () => {
80 | const subtitleWidth = useSelector(s => s.settings.subtitleWidth)
81 | const dispatch = useDispatch()
82 |
83 | const handleMouse: MouseEventHandler = e => {
84 | const prev = e.clientX
85 | function onMove(e: MouseEvent) {
86 | const clientX = e.clientX
87 | const delta = clientX - prev
88 | dispatch(updateSubtitleWidth(subtitleWidth - delta))
89 | }
90 | function onEnd() {
91 | document.removeEventListener('mousemove', onMove)
92 | document.removeEventListener('mouseup', onEnd)
93 | }
94 | document.addEventListener('mousemove', onMove)
95 | document.addEventListener('mouseup', onEnd)
96 | }
97 |
98 | const handleTouch: TouchEventHandler = e => {
99 | const prev = e.touches[0].clientX
100 | function onMove(e: TouchEvent) {
101 | const clientX = e.touches[0].clientX
102 | const delta = clientX - prev
103 | dispatch(updateSubtitleWidth(subtitleWidth - delta))
104 | }
105 | function onEnd() {
106 | document.removeEventListener('touchmove', onMove)
107 | document.removeEventListener('touchend', onEnd)
108 | }
109 | document.addEventListener('touchmove', onMove)
110 | document.addEventListener('touchend', onEnd)
111 | }
112 |
113 | return (
114 |
115 | {IS_MOBILE &&
}
116 |
117 | )
118 | }
119 |
120 | if (IS_MOBILE) {
121 | document.documentElement.classList.add('is-mobile')
122 | document.documentElement.style.setProperty('--100vh', `${window.innerHeight}px`)
123 | window.addEventListener('resize', () => {
124 | document.documentElement.style.setProperty('--100vh', `${window.innerHeight}px`)
125 | })
126 | } else {
127 | document.documentElement.style.setProperty('--100vh', '100vh')
128 | }
129 |
130 | export default App
131 |
--------------------------------------------------------------------------------
/src/components/Nav/SubtitleSetting.tsx:
--------------------------------------------------------------------------------
1 | import { FC, useEffect } from 'react'
2 | import cn from 'classnames'
3 | import {
4 | useSelector,
5 | useDispatch,
6 | updateSubtitleWidth,
7 | updateSubtitleFontSize,
8 | updateSubtitleAuto,
9 | updateSubtitleDelay,
10 | updateSubtitleListeningMode,
11 | toggleSubtitleShowCN,
12 | } from '../../state'
13 | import { Modal } from '../Modal'
14 | import { NumberInput } from './form'
15 | import { useI18n, Languages } from '../../utils'
16 | import styles from './Nav.module.less'
17 |
18 | export const SubtitleSetting: FC<{ show: boolean; onClose: () => void }> = props => {
19 | const settings = useSelector(s => s.settings)
20 | const file = useSelector(s => s.files.selected) as string
21 | const { subtitleAuto, subtitleListeningMode, subtitleLanguagesHided } = settings
22 | const isCNHided = subtitleLanguagesHided.includes(Languages.CN)
23 | const dispatch = useDispatch()
24 | const i18n = useI18n()
25 |
26 | return (
27 |
28 |
29 |
{i18n('nav.subtitle.width')}
30 |
31 | {
34 | dispatch(updateSubtitleWidth(v))
35 | }}
36 | />
37 |
38 |
{i18n('nav.subtitle.font_size')}
39 |
40 | {
43 | dispatch(updateSubtitleFontSize(v))
44 | }}
45 | />
46 |
47 |
56 |
57 | {
60 | dispatch(updateSubtitleAuto({ file }))
61 | }}
62 | >
63 | {subtitleAuto ? 'check_box' : 'check_box_outline_blank'}
64 |
65 |
66 |
{i18n('nav.subtitle.delay')}
67 |
68 | {
71 | dispatch(updateSubtitleDelay({ file, delay: Math.round(v * 1000) }))
72 | }}
73 | isFloat
74 | />
75 | {
78 | dispatch(updateSubtitleDelay({ file, delay: 0 }))
79 | }}
80 | >
81 | cancel
82 |
83 |
84 |
93 |
94 | {
97 | dispatch(updateSubtitleListeningMode({ file }))
98 | }}
99 | >
100 | {subtitleListeningMode ? 'check_box' : 'check_box_outline_blank'}
101 |
102 |
103 |
112 |
113 | {
116 | dispatch(toggleSubtitleShowCN({ file }))
117 | }}
118 | >
119 | {isCNHided ? 'check_box' : 'check_box_outline_blank'}
120 |
121 |
122 |
123 |
124 | )
125 | }
126 |
--------------------------------------------------------------------------------
/src/components/Footer.tsx:
--------------------------------------------------------------------------------
1 | import { FC, useState, useEffect } from 'react'
2 | import { useI18n } from '../utils'
3 | import { confirm, Modal } from './Modal'
4 | import styles from './Footer.module.less'
5 |
6 | const BASE = '/srt-player/'
7 |
8 | async function getLatestVersion() {
9 | const randomNumber = Math.ceil(Math.random() * Math.pow(10, 10))
10 | const url = `${BASE}version.js?bypassCache=${randomNumber}`
11 | const latest = (await (await fetch(url)).text()).trim()
12 | const match = (latest || '').match(/__SRT_VERSION__\s?=\s?('(.*)'|"(.*)")/)
13 | return match?.[2] || match?.[3] || '0'
14 | }
15 |
16 | const clearAndUpate = () => {
17 | return new Promise(resolve => {
18 | const sw = navigator.serviceWorker.controller
19 | if (sw) {
20 | const channel = new MessageChannel()
21 | sw.postMessage(
22 | {
23 | type: 'UPDATE',
24 | },
25 | [channel.port2],
26 | )
27 | channel.port1.onmessage = event => {
28 | if (event.data === 'updated') {
29 | resolve()
30 | }
31 | }
32 | }
33 | })
34 | }
35 |
36 | const click5Times = {
37 | count: 0,
38 | waiting: false,
39 | timer: 0,
40 | click() {
41 | if (this.waiting) {
42 | this.count++
43 | clearTimeout(this.timer)
44 | if (this.count === 5) {
45 | this.count = 0
46 | clearAndUpate().then(() => {
47 | location.reload()
48 | })
49 | }
50 | } else {
51 | this.count = 1
52 | }
53 | this.waiting = true
54 | this.timer = setTimeout(() => {
55 | this.waiting = false
56 | }, 200)
57 | },
58 | }
59 |
60 | const Version: FC = () => {
61 | const [hasUpdate, setHasUpdate] = useState(false)
62 | const i18n = useI18n()
63 |
64 | useEffect(() => {
65 | ;(async () => {
66 | const latest = await getLatestVersion()
67 | if (window.__SRT_VERSION__ !== latest) {
68 | await clearAndUpate()
69 | setHasUpdate(true)
70 | }
71 | })()
72 | }, [])
73 |
74 | if (!window.__SRT_VERSION__) return null
75 | return (
76 |
77 |

78 |
79 | {i18n('footer.current_version')}: {window.__SRT_VERSION__}
80 |
81 | {hasUpdate && (
82 |
{
85 | const update = await confirm(i18n('confirm.reload_update'))
86 | if (update) {
87 | location.reload()
88 | }
89 | }}
90 | >
91 | {i18n('footer.update')}
92 |
93 | )}
94 |
95 | )
96 | }
97 |
98 | const Feedback: FC = () => {
99 | const i18n = useI18n()
100 | const [show, setShow] = useState(false)
101 |
102 | return (
103 | {
106 | setShow(true)
107 | }}
108 | >
109 |
bug_report
110 | {i18n('footer.feedback')}
111 |
{
115 | setShow(false)
116 | }}
117 | className={styles['modal']}
118 | >
119 | {i18n('footer.feedback_email')}
120 |
121 | email
122 | {atob('aac2hlbm1pbnpob3VAZ21haWwuY29t'.substring(2))}
123 |
124 |
125 | {i18n('footer.feedback_video')}
126 |
132 |
133 |
134 | )
135 | }
136 |
137 | export const Footer: FC = () => {
138 | return (
139 |
150 | )
151 | }
152 |
--------------------------------------------------------------------------------
/src/components/Modal.tsx:
--------------------------------------------------------------------------------
1 | import { FC, ReactNode, useState, useEffect, useRef } from 'react'
2 | import { createPortal } from 'react-dom'
3 | import { Subject, useI18n } from '../utils'
4 | import styles from './Modal.module.less'
5 | import cn from 'classnames'
6 |
7 | interface ModalProps {
8 | width?: number
9 | title?: string
10 | hideHeader?: boolean
11 | show: boolean
12 | onClose: () => void
13 | children?: ReactNode
14 | className?: string
15 | disableShortcuts?: boolean
16 | }
17 |
18 | export const Modal: FC = ({
19 | width,
20 | show,
21 | onClose,
22 | title,
23 | hideHeader,
24 | children,
25 | className,
26 | disableShortcuts: disableShortcutWhenShown,
27 | }) => {
28 | useEffect(() => {
29 | if (!disableShortcutWhenShown) return
30 | if (show) {
31 | window.__SRT_ENABLE_SHORTCUTS__ = false
32 | } else {
33 | window.__SRT_ENABLE_SHORTCUTS__ = true
34 | }
35 | return () => {
36 | window.__SRT_ENABLE_SHORTCUTS__ = true
37 | }
38 | }, [show])
39 |
40 | if (!show) return null
41 | return createPortal(
42 | {
45 | e.stopPropagation()
46 | }}
47 | >
48 |
49 |
50 | {hideHeader !== true && (
51 |
52 | {title}
53 |
54 |
55 | close
56 |
57 |
58 | )}
59 |
62 |
63 |
,
64 | document.body,
65 | )
66 | }
67 |
68 | const message$ = new Subject<{ text: string; cb: () => void }>()
69 |
70 | export const message = (m: string) => {
71 | return new Promise(resovle => {
72 | const cb = () => {
73 | resovle()
74 | }
75 | message$.next({ text: m, cb })
76 | })
77 | }
78 |
79 | export const Message: FC = () => {
80 | const i18n = useI18n()
81 | const [show, setShow] = useState(false)
82 | const [text, setText] = useState('')
83 | const cbRef = useRef<() => void>(() => {})
84 |
85 | useEffect(
86 | () =>
87 | message$.subscribe(({ text, cb }) => {
88 | setShow(true)
89 | setText(text)
90 | cbRef.current = cb
91 | }),
92 | [],
93 | )
94 |
95 | const onClose = () => {
96 | cbRef.current()
97 | cbRef.current = () => {}
98 | setShow(false)
99 | setText('')
100 | }
101 |
102 | return (
103 |
104 |
105 |
106 |
107 |
110 |
111 |
112 |
113 | )
114 | }
115 |
116 | const confirm$ = new Subject<{ text: string; cb: (ok: boolean) => void }>()
117 |
118 | export const confirm = (c: string) => {
119 | return new Promise(resolve => {
120 | const cb = (ok: boolean) => {
121 | resolve(ok)
122 | }
123 | confirm$.next({ text: c, cb })
124 | })
125 | }
126 |
127 | export const Confirm: FC = () => {
128 | const i18n = useI18n()
129 | const [show, setShow] = useState(false)
130 | const [text, setText] = useState('')
131 | const cbRef = useRef<(ok: boolean) => void>(() => {})
132 |
133 | useEffect(
134 | () =>
135 | confirm$.subscribe(({ text, cb }) => {
136 | setShow(true)
137 | setText(text)
138 | cbRef.current = cb
139 | }),
140 | [],
141 | )
142 |
143 | const onClick = (ok: boolean) => () => {
144 | cbRef.current(ok)
145 | cbRef.current = () => {}
146 | setShow(false)
147 | setText('')
148 | }
149 |
150 | return (
151 |
152 |
153 |
{text}
154 |
155 |
158 |
161 |
162 |
163 |
164 | )
165 | }
166 |
--------------------------------------------------------------------------------
/src/components/Nav/index.tsx:
--------------------------------------------------------------------------------
1 | import { FC, ReactNode, useEffect, useState } from 'react'
2 | import cn from 'classnames'
3 | import styles from './Nav.module.less'
4 | import {
5 | useDispatch,
6 | useSelector,
7 | setSelected,
8 | updateSubtitleAuto,
9 | updateSubtitleListeningMode,
10 | toggleSubtitleShowCN,
11 | } from '../../state'
12 | import { useSaveHistory, IS_MOBILE, trackGoBack, displayFileName, GO_BACK_ID } from '../../utils'
13 | import { SubtitleSetting } from './SubtitleSetting'
14 | import { Info } from './Info'
15 | import { WaveForm } from './WaveFormSetting'
16 |
17 | export const Nav = () => {
18 | const dispatch = useDispatch()
19 | const file = useSelector(s => s.files.selected) as string
20 | const subtitleAuto = useSelector(s => s.settings.subtitleAuto)
21 | const subtitleDelay = useSelector(s => s.settings.subtitleDelay)
22 | const delayText = subtitleDelay ? (subtitleDelay / 1000).toFixed(1) : ''
23 | const enableWaveForm = useSelector(s => s.settings.waveform)
24 | const [showSubtitle, setShowSubtitle] = useState(false)
25 | const [showInfo, setShowInfo] = useState(false)
26 | const [showWaveForm, setShowWaveForm] = useState(false)
27 | const saveHistory = useSaveHistory()
28 |
29 | useEffect(() => {
30 | function keyListener(e: KeyboardEvent) {
31 | if (!window.__SRT_ENABLE_SHORTCUTS__) return
32 | if (e.code === 'KeyS' && !e.repeat && !e.ctrlKey && e.metaKey !== true) {
33 | setShowSubtitle(s => !s)
34 | }
35 | if (e.code === 'KeyA' && !e.repeat && !e.ctrlKey && e.metaKey !== true) {
36 | dispatch(updateSubtitleAuto({ file }))
37 | }
38 | if (e.code === 'KeyL' && !e.repeat && !e.ctrlKey && e.metaKey !== true) {
39 | dispatch(updateSubtitleListeningMode({ file }))
40 | }
41 | if (e.code === 'KeyK' && !e.repeat && !e.ctrlKey && e.metaKey !== true) {
42 | dispatch(toggleSubtitleShowCN({ file }))
43 | }
44 | if (e.code === 'KeyW' && !e.repeat && !e.ctrlKey && e.metaKey !== true) {
45 | setShowWaveForm(s => !s)
46 | }
47 | if (e.code === 'KeyI' && !e.repeat && !e.ctrlKey && e.metaKey !== true) {
48 | setShowInfo(s => !s)
49 | }
50 | }
51 | window.addEventListener('keydown', keyListener)
52 | return () => {
53 | window.removeEventListener('keydown', keyListener)
54 | }
55 | }, [])
56 |
57 | return (
58 | <>
59 |
104 | {
107 | setShowWaveForm(false)
108 | }}
109 | />
110 | {
113 | setShowInfo(false)
114 | }}
115 | />
116 | {
119 | setShowSubtitle(false)
120 | }}
121 | />
122 | >
123 | )
124 | }
125 |
126 | window.__SRT_ENABLE_SHORTCUTS__ = true
127 |
128 | interface IconProps {
129 | type: string
130 | onClick: () => void
131 | disabled?: boolean
132 | children?: ReactNode
133 | id?: string
134 | }
135 |
136 | const Icon: FC = ({ type, onClick, disabled, children, id }) => {
137 | return (
138 |
139 | {type}
140 | {children && {children}}
141 |
142 | )
143 | }
144 |
--------------------------------------------------------------------------------
/src/components/Uploader.module.less:
--------------------------------------------------------------------------------
1 | .container {
2 | margin: 20px 10px;
3 | user-select: none;
4 |
5 | .upload-area {
6 | max-width: 600px;
7 | margin: 0 auto;
8 | padding: 24px;
9 | background: var(--black-025);
10 | border-radius: 10px;
11 | border: 2px dashed var(--black-150);
12 | font-size: 16px;
13 | &:global(.drag-over) {
14 | box-shadow: var(--bs-md);
15 | background: var(--blue-050);
16 | }
17 |
18 | display: flex;
19 | gap: 12px;
20 | flex-direction: column;
21 | align-items: center;
22 |
23 | :global(.material-icons-outlined) {
24 | font-size: 40px;
25 | color: var(--blue-600);
26 | }
27 | .upload-main {
28 | color: var(--black-600);
29 | }
30 | .separate {
31 | display: flex;
32 | padding: 0 24px;
33 | align-items: center;
34 | justify-content: center;
35 | width: 100%;
36 |
37 | .separate-line {
38 | border-top: 1px solid var(--black-200);
39 | flex: 1;
40 | max-width: 80px;
41 | }
42 | .separate-or {
43 | color: var(--black-200);
44 | margin: 0 8px;
45 | }
46 | }
47 | .browse-files {
48 | background-color: var(--blue-600);
49 | padding: 4px 12px;
50 | color: #fff;
51 | cursor: pointer;
52 | border-width: 0;
53 | border-radius: 4px;
54 | box-shadow: var(--bs-sm);
55 | }
56 | }
57 |
58 | .buffer-container {
59 | position: relative;
60 | left: 50%;
61 | right: 50%;
62 | .responsive(@rules) {
63 | @p: 24px;
64 | @rules();
65 | @media only screen and (max-width: 700px) {
66 | @p: 0px;
67 | @rules();
68 | }
69 | }
70 | .responsive({
71 | width: calc(100vw - @p * 2);
72 | margin-left: calc(-50vw + @p);
73 | margin-right: calc(-50vw + @p);
74 | });
75 | }
76 |
77 | .buffer {
78 | max-width: 1600px;
79 | margin: 20px auto 10px auto;
80 | display: grid;
81 | grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
82 | gap: 10px;
83 | align-items: start;
84 |
85 | .videos,
86 | .subtitles {
87 | background: var(--bc-lighter);
88 | padding: 5px 5px 0 5px;
89 | touch-action: none;
90 | user-select: none;
91 |
92 | & > :global(.material-icons) {
93 | text-align: center;
94 | display: block;
95 | margin-bottom: 5px;
96 | }
97 |
98 | ul.has-transition {
99 | li {
100 | transition: top 0.15s ease;
101 | /* transition: top 5s ease; */
102 | &.selected {
103 | transition: none;
104 | }
105 | }
106 | }
107 |
108 | li {
109 | @list-height: 26px;
110 | @list-margin-bottom: 5px;
111 |
112 | height: @list-height;
113 | background: var(--white);
114 | padding: 0 5px;
115 | margin-bottom: @list-margin-bottom;
116 | white-space: nowrap;
117 | display: grid;
118 | grid-template-columns: minmax(0, 1fr) 16px 16px 16px;
119 | gap: 2px;
120 | align-items: center;
121 | cursor: move;
122 |
123 | position: relative;
124 | top: 0;
125 | &.selected {
126 | background: var(--blue-100);
127 | z-index: 2;
128 | }
129 |
130 | &.upward {
131 | top: -@list-height - @list-margin-bottom;
132 | }
133 | &.downward {
134 | top: @list-height + @list-margin-bottom;
135 | }
136 |
137 | .file {
138 | overflow: hidden;
139 | text-overflow: ellipsis;
140 | line-height: @list-height;
141 | }
142 | & > :global(.material-icons) {
143 | font-size: 14px;
144 | padding: 1px 0;
145 | text-align: center;
146 | cursor: pointer;
147 | border-radius: 8px;
148 | background-color: var(--black-200);
149 | color: var(--white);
150 | @media (hover: hover) {
151 | &:hover {
152 | background-color: var(--black-500);
153 | }
154 | }
155 | }
156 | }
157 | }
158 | }
159 |
160 | .save-cache {
161 | display: flex;
162 | align-items: center;
163 | justify-content: center;
164 | margin-bottom: 10px;
165 | input {
166 | margin: 0 8px 0 0;
167 | width: 14px;
168 | height: 14px;
169 | }
170 | }
171 |
172 | .ok {
173 | display: flex;
174 | justify-content: center;
175 | button {
176 | border-width: 0;
177 | border-radius: 4px;
178 | background: var(--blue-600);
179 | color: #fff;
180 | padding: 4px 12px;
181 | font-size: 16px;
182 | cursor: pointer;
183 | box-shadow: var(--bs-sm);
184 | }
185 | }
186 | }
187 |
--------------------------------------------------------------------------------
/src/utils/history.ts:
--------------------------------------------------------------------------------
1 | import { useSelector } from '../state'
2 | import { doVideo } from './video'
3 | import { db, WatchHistory, EnableWaveForm, Languages, Bookmark } from './idb'
4 | import { previousHighlighted } from './subtitle'
5 |
6 | export interface WatchHistories {
7 | [s: string]: WatchHistory
8 | }
9 |
10 | function getSubtitleElm() {
11 | return document.getElementById('srt-player-subtitle') as HTMLDivElement | undefined
12 | }
13 |
14 | export const useRestoreSubtitle = () => {
15 | const file = useSelector(s => s.files.selected!)
16 | return async (): Promise => {
17 | const h = await getHistoryByFileName(file)
18 | const subtitleTop = h?.subtitleTop ?? 0
19 | const subtitle = getSubtitleElm()
20 | if (subtitle) {
21 | subtitle.scrollTop = subtitleTop
22 | }
23 | return h?.subtitleLastActive === undefined ? null : h.subtitleLastActive
24 | }
25 | }
26 |
27 | export const useRestoreVideo = () => {
28 | const file = useSelector(s => s.files.selected!)
29 | return async () => {
30 | const h = await getHistoryByFileName(file)
31 | const currentTime = h?.currentTime ?? 0
32 | doVideo(video => {
33 | video.currentTime = currentTime
34 | })
35 | }
36 | }
37 |
38 | export const getSubtitlePreference = async (f: string) => {
39 | const h = await getHistoryByFileName(f)
40 | return {
41 | auto: h?.subtitleAuto ?? true,
42 | delay: h?.subtitleDelay || 0,
43 | listeningMode: h?.subtitleListeningMode ?? false,
44 | languagesHided: h?.subtitleLanguagesHided ?? [],
45 | }
46 | }
47 |
48 | export const getBookmarks = async (f: string) => {
49 | const h = await getHistoryByFileName(f)
50 | return h?.bookmarks || []
51 | }
52 |
53 | export const getWaveFormPreference = async (f: string) => {
54 | const h = await getHistoryByFileName(f)
55 | return h?.waveform ?? EnableWaveForm.disable
56 | }
57 |
58 | export const saveSubtitleAuto = async (f: string, auto: boolean) => {
59 | return writeHelper(f, h => {
60 | h.subtitleAuto = auto
61 | })
62 | }
63 |
64 | export const saveSubtitleDelay = async (f: string, delay: number) => {
65 | return writeHelper(f, h => {
66 | h.subtitleDelay = delay
67 | })
68 | }
69 |
70 | export const saveSubtitleListeningMode = async (f: string, listeningMode: boolean) => {
71 | return writeHelper(f, h => {
72 | h.subtitleListeningMode = listeningMode
73 | })
74 | }
75 |
76 | export const saveSubtitleLanguagesHided = async (f: string, languagesHided: Languages[]) => {
77 | return writeHelper(f, h => {
78 | h.subtitleLanguagesHided = languagesHided
79 | })
80 | }
81 |
82 | export const saveSubtitleLastActive = async (f: string, lastActive: number) => {
83 | return writeHelper(f, h => {
84 | h.subtitleLastActive = lastActive
85 | })
86 | }
87 |
88 | export const saveEnableWaveForm = async (f: string, enable: EnableWaveForm) => {
89 | return writeHelper(f, h => {
90 | h.waveform = enable
91 | })
92 | }
93 |
94 | export const saveBookmarks = async (f: string, bookmarks: Bookmark[]) => {
95 | return writeHelper(f, h => {
96 | h.bookmarks = bookmarks
97 | })
98 | }
99 |
100 | export const useSaveHistory = (cooldown?: number) => {
101 | const file = useSelector(s => s.files.selected)
102 | let skip = false
103 | return async () => {
104 | if (!file || skip) return
105 | await writeHelper(file, h => {
106 | const subtitle = getSubtitleElm()
107 | if (subtitle) {
108 | h.subtitleTop = subtitle.scrollTop
109 | }
110 | doVideo(video => {
111 | h.currentTime = video.currentTime
112 | h.duration = video.duration
113 | })
114 | h.subtitleLastActive = previousHighlighted
115 | })
116 | if (cooldown) {
117 | skip = true
118 | setTimeout(() => {
119 | skip = false
120 | }, cooldown)
121 | }
122 | }
123 | }
124 |
125 | function getHistoryByFileName(file: string) {
126 | return db.get('history', file)
127 | }
128 |
129 | async function writeHelper(file: string, cb: (h: WatchHistory) => void) {
130 | const h = await getHistoryByFileName(file)
131 | const t = {
132 | subtitleTop: 0,
133 | currentTime: 0,
134 | duration: 0,
135 | subtitleAuto: true,
136 | subtitleDelay: 0,
137 | subtitleListeningMode: false,
138 | subtitleLastActive: null,
139 | subtitleLanguagesHided: [],
140 | waveform: EnableWaveForm.disable,
141 | bookmarks: [],
142 | ...(h || {}),
143 | }
144 | cb(t)
145 | return db.put('history', t, file)
146 | }
147 |
148 | export async function getWatchHistory() {
149 | const hs: WatchHistories = {}
150 | let cursor = await db.transaction('history').store.openCursor()
151 | while (cursor) {
152 | hs[cursor.key] = cursor.value
153 | cursor = await cursor.continue()
154 | }
155 | return hs
156 | }
157 |
158 | export async function deleteHistory(f: string) {
159 | return db.delete('history', f)
160 | }
161 |
--------------------------------------------------------------------------------
/src/web-workers/sampling.ts:
--------------------------------------------------------------------------------
1 | import {
2 | Payload,
3 | SamplingResult,
4 | RenderTask,
5 | NEW_SAMPLING_RATE,
6 | PIXELS_PER_SECOND,
7 | SLICE_WIDTH,
8 | WAVEFORM_HEIGHT,
9 | saveSampling,
10 | StageEnum,
11 | } from '../utils/audioSampling'
12 |
13 | self.addEventListener('message', async e => {
14 | const data: Payload = e.data
15 | if (typeof data.file === 'string' && typeof data.duration === 'number') {
16 | try {
17 | self.postMessage({ stage: StageEnum.resampling })
18 | const result = await sampling(data)
19 | self.postMessage({ stage: StageEnum.imageGeneration })
20 | await drawWaveForm({ ...result, file: data.file }, 'svg')
21 | self.postMessage({ stage: StageEnum.done })
22 | } catch (e) {
23 | self.postMessage({ type: 'error', error: e + '' })
24 | }
25 | }
26 | })
27 |
28 | async function sampling(payload: Payload): Promise {
29 | const { buffer: _buffer, duration } = payload
30 | const buffer = new Float32Array(..._buffer)
31 | let total = NEW_SAMPLING_RATE * duration
32 | const stepSize = buffer.length / total
33 | total = Math.ceil(total)
34 | const samples: number[] = Array.from({ length: total })
35 | let max = 0
36 | for (let i = 0; i < total; i++) {
37 | let tmp = 0
38 | for (let j = 0; j < stepSize; j++) {
39 | const index = Math.floor(i * stepSize) + j
40 | if (index < buffer.length) {
41 | tmp += Math.abs(buffer[index])
42 | }
43 | }
44 | if (isNaN(tmp)) {
45 | continue
46 | }
47 | samples[i] = tmp
48 | max = Math.max(max, tmp)
49 | }
50 | const result = new Uint8Array(total)
51 | if (max !== 0) {
52 | for (let i = 0; i < result.length; i++) {
53 | result[i] = Math.floor((samples[i] / max) * 256)
54 | }
55 | }
56 | return { buffer: result }
57 | }
58 |
59 | const LINE_WIDTH = 2
60 | const GAP_WIDTH = 1
61 |
62 | const drawWaveForm = async (task: RenderTask, target: 'webp' | 'svg') => {
63 | const { buffer, file } = task
64 | const pixelPerSample = PIXELS_PER_SECOND / NEW_SAMPLING_RATE
65 | const width = Math.ceil(buffer.length * pixelPerSample)
66 | const height = WAVEFORM_HEIGHT
67 |
68 | const conversions = (target === 'webp' ? toWebp : toSvg)({ width, height, pixelPerSample, buffer })
69 | const blobs = await Promise.all(conversions)
70 | await saveSampling(file, blobs)
71 | }
72 |
73 | interface GenerateImage {
74 | (i: { width: number; height: number; pixelPerSample: number; buffer: Uint8Array }): Promise[]
75 | }
76 |
77 | const toWebp: GenerateImage = ({ width, height, pixelPerSample, buffer }) => {
78 | const conversions: Promise[] = []
79 |
80 | const numofSlices = Math.ceil(width / SLICE_WIDTH)
81 | for (let i = 0; i < numofSlices; i++) {
82 | const canvas = new OffscreenCanvas(
83 | i === numofSlices - 1 ? width - (numofSlices - 1) * SLICE_WIDTH : SLICE_WIDTH,
84 | height,
85 | )
86 | const ctx = canvas.getContext('2d')
87 | if (!ctx) throw new Error()
88 |
89 | // draw line
90 | ctx.strokeStyle = '#fff'
91 | ctx.lineWidth = LINE_WIDTH
92 | const samplePerSlice = Math.floor(SLICE_WIDTH / pixelPerSample)
93 | const start = i * samplePerSlice
94 | const end = start + Math.floor(canvas.width / pixelPerSample)
95 | for (let idx = start; idx < end; idx++) {
96 | let x = GAP_WIDTH + (idx - start) * pixelPerSample
97 | x = Math.round(x)
98 | let h = (buffer[idx] / 256) * height
99 | h = Math.round(h)
100 | ctx.moveTo(x, (height - h) / 2)
101 | ctx.lineTo(x, (height + h) / 2)
102 | ctx.stroke()
103 | }
104 | conversions.push(canvas.convertToBlob({ type: 'image/webp' }))
105 | }
106 | return conversions
107 | }
108 |
109 | const toSvg: GenerateImage = ({ width, height, pixelPerSample, buffer }) => {
110 | const conversions: Promise[] = []
111 |
112 | const numofSlices = Math.ceil(width / SLICE_WIDTH)
113 |
114 | for (let i = 0; i < numofSlices; i++) {
115 | const svgWidth = i === numofSlices - 1 ? width - (numofSlices - 1) * SLICE_WIDTH : SLICE_WIDTH
116 | const svgPath: string[] = []
117 |
118 | const samplePerSlice = Math.floor(SLICE_WIDTH / pixelPerSample)
119 | const start = i * samplePerSlice
120 | const end = start + Math.floor(svgWidth / pixelPerSample)
121 | for (let idx = start; idx < end; idx++) {
122 | let mx = GAP_WIDTH + (idx - start) * pixelPerSample
123 | mx = Math.round(mx)
124 | let h = (buffer[idx] / 256) * height
125 | h = Math.round(h)
126 | let my = (height - h) / 2
127 | svgPath.push(`M${mx} ${my}v${h}`)
128 | }
129 | const svg = [
130 | `',
133 | ].join('\n')
134 | conversions.push(Promise.resolve(new Blob([svg], { type: 'image/svg+xml' })))
135 | }
136 | return conversions
137 | }
138 |
139 | export {}
140 |
--------------------------------------------------------------------------------
/src/components/Nav/WaveFormSetting.tsx:
--------------------------------------------------------------------------------
1 | import { FC, useState } from 'react'
2 | import { useSelector, useDispatch, updateEnableWaveForm } from '../../state'
3 | import {
4 | computeAudioSampling,
5 | getMediaDuration,
6 | doVideoWithDefault,
7 | useI18n,
8 | EnableWaveForm,
9 | StageEnum,
10 | getVideo,
11 | trackCreateWaveform,
12 | } from '../../utils'
13 | import { Modal, message } from '../Modal'
14 | import styles from './Nav.module.less'
15 | import SamplingWorker from '../../web-workers/sampling?worker&inline'
16 | import cn from 'classnames'
17 |
18 | interface WaveFormOptionProps {
19 | type: EnableWaveForm
20 | disabled: boolean
21 | setDisabled: (d: boolean) => void
22 | setStage: (s: StageEnum) => void
23 | }
24 |
25 | const WaveFormOption: FC = ({ type, disabled, setDisabled, setStage }) => {
26 | const dispatch = useDispatch()
27 | const i18n = useI18n()
28 | const enableStatus = useSelector(s => s.settings.waveform)
29 | const active = type === enableStatus
30 | const file = useSelector(s => s.files.selected) as string
31 |
32 | let mainText = ''
33 | let subText = ''
34 | let cb = async () => {}
35 | const createSampling = async (ab: ArrayBuffer, duration?: number) => {
36 | if (!ab) return
37 | const worker = new SamplingWorker()
38 | await computeAudioSampling({
39 | worker,
40 | arrayBuffer: ab,
41 | fileName: file,
42 | audioDuration: duration ?? doVideoWithDefault(video => video.duration, 0),
43 | onProgress: s => setStage(s),
44 | })
45 | }
46 | switch (type) {
47 | case EnableWaveForm.disable: {
48 | mainText = i18n('nav.waveform.disable')
49 | break
50 | }
51 | case EnableWaveForm.video: {
52 | mainText = i18n('nav.waveform.enable')
53 | subText = i18n('nav.waveform.with_existing')
54 |
55 | cb = async () => {
56 | setStage(StageEnum.decoding)
57 | const videoArrayBuffer = await (await getVideo(file))?.file.arrayBuffer()
58 | await createSampling(videoArrayBuffer as ArrayBuffer)
59 | trackCreateWaveform('video')
60 | }
61 | break
62 | }
63 | case EnableWaveForm.audio: {
64 | mainText = i18n('nav.waveform.enable')
65 | subText = i18n('nav.waveform.with_another')
66 |
67 | cb = async () => {
68 | const handles = await showOpenFilePicker({
69 | id: 'audio-file-for-waveform',
70 | types: [
71 | {
72 | description: 'Audio',
73 | accept: {
74 | 'audio/*': [],
75 | },
76 | },
77 | ],
78 | } as OpenFilePickerOptions)
79 | setStage(StageEnum.decoding)
80 | const file = await handles[0].getFile()
81 | const audioDuration = await getMediaDuration(file)
82 | const audioArrayBuffer = await file.arrayBuffer()
83 | await createSampling(audioArrayBuffer, audioDuration)
84 | trackCreateWaveform('audio')
85 | }
86 | break
87 | }
88 | }
89 |
90 | const setStatus = (s: EnableWaveForm) => {
91 | dispatch(updateEnableWaveForm({ file: file, enable: s }))
92 | }
93 |
94 | return (
95 | {
99 | if (enableStatus === type) return
100 | if (disabled) return
101 | setDisabled(true)
102 | try {
103 | setStage(StageEnum.stopped)
104 | await cb()
105 | setStatus(type)
106 | } catch (e) {
107 | let msg = typeof (e as any)?.toString === 'function' ? (e as any).toString() : 'Unexpected error'
108 | message(msg)
109 | setStage(StageEnum.stopped)
110 | } finally {
111 | setDisabled(false)
112 | }
113 | }}
114 | >
115 |
{active ? 'radio_button_checked' : 'radio_button_unchecked'}
116 |
{mainText}
117 | {subText &&
({subText})
}
118 |
119 | )
120 | }
121 |
122 | export const WaveForm: FC<{ show: boolean; onClose: () => void }> = props => {
123 | const i18n = useI18n()
124 | const [disabled, setDisabled] = useState(false)
125 | const [stage, setStage] = useState(StageEnum.stopped)
126 |
127 | const commonProps = {
128 | disabled,
129 | setDisabled,
130 | setStage,
131 | }
132 | return (
133 |
134 |
135 |
136 |
137 |
138 |
139 |
140 |
141 | )
142 | }
143 |
144 | const Progress: FC<{ stage?: StageEnum }> = ({ stage = StageEnum.stopped }) => {
145 | const i18n = useI18n()
146 | const text = stage === StageEnum.done ? i18n('nav.waveform.done') : i18n('nav.waveform.generating')
147 | const progress = Math.ceil((100 * stage) / StageEnum.done) + '%'
148 |
149 | if (stage === StageEnum.stopped) return null
150 | return (
151 |
155 | )
156 | }
157 |
--------------------------------------------------------------------------------
/src/components/Nav/Nav.module.less:
--------------------------------------------------------------------------------
1 | .nav {
2 | grid-column: 1 / 4;
3 | border-bottom: 1px solid;
4 | border-color: var(--black-400);
5 | box-sizing: border-box;
6 | display: grid;
7 | grid-template-columns: minmax(0, auto) minmax(0, 1fr) minmax(0, auto);
8 | overflow: hidden;
9 | }
10 |
11 | .hidden {
12 | display: none;
13 | }
14 |
15 | .icon {
16 | cursor: pointer;
17 | color: var(--black-500);
18 | height: 100%;
19 | :global(.material-icons) {
20 | line-height: var(--nav-height);
21 | }
22 | padding: 0 6px;
23 | &:global(.disabled) {
24 | color: #ebebeb;
25 | }
26 | @media (hover: hover) {
27 | &:hover {
28 | background: var(--bc-lighter);
29 | color: #000;
30 | }
31 | }
32 | }
33 |
34 | :global(.no-subtitle) {
35 | .icon:global(.closed_caption_off) {
36 | display: none;
37 | }
38 | }
39 | :global(.no-video) {
40 | .nav {
41 | grid-column: 1 / 2;
42 | }
43 | .icon:global(.graphic_eq) {
44 | display: none;
45 | }
46 | }
47 |
48 | .name {
49 | height: 100%;
50 | line-height: var(--nav-height);
51 | overflow: hidden;
52 | text-overflow: ellipsis;
53 | white-space: nowrap;
54 | text-align: center;
55 | }
56 |
57 | .right {
58 | .icon {
59 | position: relative;
60 | display: inline-block;
61 | &:last-child {
62 | margin-right: 0;
63 | }
64 | .delay {
65 | position: absolute;
66 | left: 0;
67 | right: 0;
68 | bottom: 2px;
69 | color: var(--red-500);
70 | font-size: 9px;
71 | font-variant-numeric: tabular-nums;
72 | text-align: center;
73 | }
74 | }
75 | }
76 |
77 | .settings,
78 | .info {
79 | display: grid;
80 | grid-template-columns: minmax(50px, auto) 1fr;
81 | gap: 10px;
82 | align-items: center;
83 | }
84 |
85 | .settings {
86 | gap: 24px 32px;
87 |
88 | .title {
89 | }
90 |
91 | .body {
92 | position: relative;
93 | display: flex;
94 | align-items: center;
95 | input[type='text'] {
96 | border: 1px solid var(--black-400);
97 | border-width: 0 0 1px 0;
98 | border-radius: 0;
99 | padding-bottom: 1px;
100 | width: 100px;
101 | &:focus {
102 | outline: none;
103 | border-color: var(--blue-600);
104 | border-width: 0 0 2px 0;
105 | padding-bottom: 0;
106 | }
107 | }
108 | :global(.material-icons):global(.checkbox) {
109 | font-size: 18px;
110 | cursor: pointer;
111 | color: var(--black-300);
112 | &:global(.checked) {
113 | color: var(--blue-600);
114 | }
115 | }
116 | :global(.material-icons):global(.clear) {
117 | cursor: pointer;
118 | position: absolute;
119 | right: 0;
120 | bottom: 2px;
121 | font-size: 16px;
122 | color: var(--black-300);
123 | @media (hover: hover) {
124 | &:hover {
125 | color: var(--black-800);
126 | }
127 | }
128 | }
129 | }
130 | }
131 |
132 | .shortcuts {
133 | text-align: center;
134 | font-size: 20px;
135 | margin-bottom: 20px;
136 | }
137 |
138 | .3cols {
139 | display: grid;
140 | grid-template-columns: auto auto auto;
141 | gap: 10px;
142 |
143 | :global(.column) {
144 | padding: 10px;
145 | border: 1px solid var(--bc-lighter);
146 | border-radius: 10px;
147 |
148 | :global(.column-title) {
149 | text-align: center;
150 | height: 30px;
151 | line-height: 30px;
152 | font-size: 18px;
153 | margin-bottom: 10px;
154 | }
155 | }
156 | }
157 |
158 | :global(.no-subtitle) {
159 | .3cols {
160 | :global(.column):global(.subtitle) {
161 | display: none;
162 | }
163 | }
164 | }
165 |
166 | .info {
167 | grid-template-columns: minmax(0, auto) auto;
168 | .title {
169 | display: flex;
170 | color: rgb(150, 159, 175);
171 | .key {
172 | display: flex;
173 | min-width: 12px;
174 | align-items: center;
175 | justify-content: center;
176 | background-image: linear-gradient(-225deg, rgb(213, 219, 228), rgb(248, 248, 248));
177 | box-shadow: rgb(205, 205, 230) 0px -2px 0px 0px inset, rgb(255, 255, 255) 0px 0px 1px 1px inset,
178 | rgba(30, 35, 90, 0.4) 0px 1px 2px 1px;
179 | border-radius: 3px;
180 | padding: 5px;
181 | margin-right: 10px;
182 | }
183 |
184 | .right-click {
185 | border-radius: 3px;
186 | padding: 5px;
187 | background: var(--bc-lighter);
188 | }
189 |
190 | span[class='material-icons'] {
191 | font-size: 16px;
192 | }
193 | }
194 |
195 | .body {
196 | }
197 | }
198 |
199 | .waveform {
200 | padding: 8px 0;
201 | display: flex;
202 | flex-direction: column;
203 | align-items: flex-start;
204 | gap: 4px;
205 | .waveform-option {
206 | cursor: pointer;
207 | display: flex;
208 | align-items: center;
209 | color: var(--black-600);
210 | :global(.material-icons) {
211 | font-size: 24px;
212 | margin-right: 8px;
213 | }
214 | font-size: 16px;
215 | .main {
216 | margin-right: 8px;
217 | }
218 | .sub {
219 | color: var(--black-400);
220 | }
221 | &.active {
222 | color: var(--black-900);
223 | }
224 | }
225 | }
226 |
227 | .progress {
228 | margin-top: 8px;
229 | font-size: 16px;
230 | color: var(--black-800);
231 | text-align: center;
232 | .progress-bar {
233 | position: absolute;
234 | bottom: 0;
235 | left: 0;
236 | height: 4px;
237 | background-color: var(--blue-600);
238 | transition: width 0.2s;
239 | }
240 | }
241 |
--------------------------------------------------------------------------------
/src/utils/subtitle.tsx:
--------------------------------------------------------------------------------
1 | export interface Node {
2 | counter: number
3 | start: SubtitleTime
4 | end: SubtitleTime
5 | text: string[]
6 | }
7 |
8 | interface SSANode extends Node {
9 | style: string
10 | }
11 |
12 | export const SUBTITLE_CONTAINER_ID = 'srt-player-subtitle'
13 | export let previousHighlighted: number
14 |
15 | // manual update for better performance
16 | // counter: starts from 1
17 | export function highlight(counter: number) {
18 | const container = document.querySelector(`#${SUBTITLE_CONTAINER_ID}`)
19 | if (!container) return
20 | const prev = container.children[previousHighlighted - 1]
21 | if (prev) {
22 | prev.classList.remove('highlighted-subtitle')
23 | }
24 | const elm = container.children[counter - 1] as HTMLElement
25 | if (elm) {
26 | previousHighlighted = counter
27 | elm.classList.add('highlighted-subtitle')
28 | }
29 | }
30 |
31 | function escapeHtmlTags(s: string) {
32 | return s.replace(/&/g, '&').replace(/<([^>]*)>/g, (match, s) => {
33 | // keep , ,
34 | if (/^\/?(i|b|u)$/i.test(s)) return match
35 | // escape the rest
36 | return `<${s}>`
37 | })
38 | }
39 |
40 | function filterText(s: string) {
41 | return s.replace(//gim, '')
42 | }
43 |
44 | function filterSSAText(s: string) {
45 | if (/\{[^\}]*p[1-9][^\}]*\}/.test(s)) {
46 | // https://aeg-dev.github.io/AegiSite/docs/3.2/ass_tags/#drawing-commands
47 | // ignore drawing commands
48 | return ''
49 | }
50 | return filterText(s)
51 | .replace(/\{[^\}]*\}/g, '') // ssa styling
52 | .replace(/&/g, '&') // escape html
53 | .replace(//g, '>')
55 | }
56 |
57 | export const SSA = '[SSA]'
58 |
59 | export function parseSubtitle(content: string): Node[] {
60 | // formats other than srt will have [format] at beginning
61 | if (content.startsWith(SSA)) {
62 | return parseSSA(content)
63 | } else {
64 | return parseSRT(content)
65 | }
66 | }
67 |
68 | function parseSRT(content: string): Node[] {
69 | const timeReg = /(\d{2}:\d{2}:\d{2},\d{3})\s-->\s(\d{2}:\d{2}:\d{2},\d{3})/
70 | const lines = content.split('\n').map(i => i.trim())
71 | let group: string[][] = []
72 | let p = 0
73 | for (let i = 0; i < lines.length; i++) {
74 | if (lines[i] === '') {
75 | const item = lines.slice(p, i)
76 | p = i + 1
77 |
78 | if (item.length < 3) continue
79 | if (!/^\d+$/.test(item[0])) continue
80 | if (!timeReg.test(item[1])) continue
81 | group.push(item)
82 | }
83 | }
84 | const nodes: Node[] = []
85 | let count = 0
86 | for (const i of group) {
87 | const matched = i[1].match(timeReg)!
88 | const start = parseTime(matched[1], 'srt')
89 | const end = parseTime(matched[2], 'srt')
90 | const text = i.slice(2).map(filterText).map(escapeHtmlTags)
91 | nodes.push({ counter: ++count, start, end, text })
92 | }
93 | return nodes
94 | }
95 |
96 | function parseSSA(content: string): Node[] {
97 | const lines = content
98 | .split('\n')
99 | .map(i => {
100 | const section = i.trim().match(/^Dialogue:(.*)$/)?.[1]
101 | if (!section) return ''
102 | return section.trim()
103 | })
104 | .filter(Boolean)
105 | const nodes: SSANode[] = []
106 | for (let i = 0; i < lines.length; i++) {
107 | const tmp =
108 | /^[^,]*,(?[^,]*),(?[^,]*),(?