├── src ├── components │ ├── shared.less │ ├── Error.module.less │ ├── Error.tsx │ ├── Language.tsx │ ├── Language.module.less │ ├── Nav │ │ ├── form.tsx │ │ ├── SubtitleSetting.tsx │ │ ├── index.tsx │ │ ├── WaveFormSetting.tsx │ │ ├── Nav.module.less │ │ └── Info.tsx │ ├── Footer.module.less │ ├── Modal.module.less │ ├── WaveForm.module.less │ ├── List.module.less │ ├── Subtitle.module.less │ ├── Footer.tsx │ ├── Modal.tsx │ ├── Uploader.module.less │ ├── Video.module.less │ ├── List.tsx │ ├── WaveForm.tsx │ ├── Subtitle.tsx │ ├── Uploader.tsx │ └── Video.tsx ├── vite-env.d.ts ├── utils │ ├── ua.ts │ ├── subject.ts │ ├── duration.ts │ ├── index.ts │ ├── language.ts │ ├── format.ts │ ├── sessionHistory.ts │ ├── i18n.ts │ ├── migrate.tsx │ ├── toggleFullScreen.ts │ ├── ga.ts │ ├── createEmptyAudio.ts │ ├── touchEmitter.ts │ ├── idb.tsx │ ├── video.ts │ ├── audioSampling.ts │ ├── history.ts │ ├── subtitle.tsx │ └── pair.ts ├── srt-player.d.ts ├── locale │ ├── index.ts │ ├── zh-CN.json │ └── en-US.json ├── state │ ├── index.ts │ ├── hooks.ts │ ├── store.ts │ ├── filesSlice.ts │ ├── videoSlice.ts │ └── settingsSlice.ts ├── main.tsx ├── reset.css ├── App.module.less ├── index.css ├── theme.css ├── App.tsx └── web-workers │ └── sampling.ts ├── .yarnrc ├── public ├── version.js ├── github.png ├── fonts │ ├── MaterialIcons-Regular.ttf │ └── MaterialIconsOutlined-Regular.otf ├── gtag.js └── srt-player.svg ├── docs ├── chrome_48x48.png ├── edge_48x48.png ├── safari_48x48.png ├── screenshot.jpg └── firefox_48x48.png ├── .gitignore ├── sw ├── tsconfig.json └── sw.ts ├── .prettierrc.js ├── tsconfig.node.json ├── deploy.sh ├── tsconfig.json ├── package.json ├── LICENSE ├── readme.md ├── vite.config.ts └── index.html /src/components/shared.less: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.yarnrc: -------------------------------------------------------------------------------- 1 | registry "https://registry.yarnpkg.com" 2 | -------------------------------------------------------------------------------- /public/version.js: -------------------------------------------------------------------------------- 1 | window.__SRT_VERSION__ = '1.6.3' 2 | -------------------------------------------------------------------------------- /src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /public/github.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Shenmin-Z/srt-player/HEAD/public/github.png -------------------------------------------------------------------------------- /docs/chrome_48x48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Shenmin-Z/srt-player/HEAD/docs/chrome_48x48.png -------------------------------------------------------------------------------- /docs/edge_48x48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Shenmin-Z/srt-player/HEAD/docs/edge_48x48.png -------------------------------------------------------------------------------- /docs/safari_48x48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Shenmin-Z/srt-player/HEAD/docs/safari_48x48.png -------------------------------------------------------------------------------- /docs/screenshot.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Shenmin-Z/srt-player/HEAD/docs/screenshot.jpg -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .DS_Store 3 | dist 4 | dist-ssr 5 | *.local 6 | *.log 7 | yarn.lock 8 | -------------------------------------------------------------------------------- /docs/firefox_48x48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Shenmin-Z/srt-player/HEAD/docs/firefox_48x48.png -------------------------------------------------------------------------------- /public/fonts/MaterialIcons-Regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Shenmin-Z/srt-player/HEAD/public/fonts/MaterialIcons-Regular.ttf -------------------------------------------------------------------------------- /public/fonts/MaterialIconsOutlined-Regular.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Shenmin-Z/srt-player/HEAD/public/fonts/MaterialIconsOutlined-Regular.otf -------------------------------------------------------------------------------- /sw/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "lib": ["WebWorker", "ES2015"], 4 | "strict": true 5 | }, 6 | "files": ["sw.ts"] 7 | } 8 | -------------------------------------------------------------------------------- /src/utils/ua.ts: -------------------------------------------------------------------------------- 1 | export const IS_MOBILE = /Android|iPhone|iPad/i.test(navigator.userAgent) 2 | export const IS_IOS = /iPad|iPhone/.test(navigator.userAgent) 3 | -------------------------------------------------------------------------------- /src/srt-player.d.ts: -------------------------------------------------------------------------------- 1 | export {} 2 | 3 | declare global { 4 | interface Window { 5 | __SRT_ENABLE_SHORTCUTS__: boolean 6 | __SRT_VERSION__: string 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /src/locale/index.ts: -------------------------------------------------------------------------------- 1 | import enUS from './en-US.json' 2 | import zhCN from './zh-CN.json' 3 | 4 | export const locale: any = { 5 | 'en-US': enUS, 6 | 'zh-CN': zhCN, 7 | } 8 | -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | semi: false, 3 | tabWidth: 2, 4 | trailingComma: 'all', 5 | singleQuote: true, 6 | printWidth: 120, 7 | arrowParens: 'avoid', 8 | }; 9 | -------------------------------------------------------------------------------- /tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "module": "esnext", 5 | "moduleResolution": "node" 6 | }, 7 | "include": ["vite.config.ts"] 8 | } 9 | -------------------------------------------------------------------------------- /src/state/index.ts: -------------------------------------------------------------------------------- 1 | export * from './filesSlice' 2 | export * from './videoSlice' 3 | export * from './settingsSlice' 4 | export * from './hooks' 5 | export { store as globalStore } from './store' 6 | -------------------------------------------------------------------------------- /src/state/hooks.ts: -------------------------------------------------------------------------------- 1 | import { TypedUseSelectorHook, useDispatch as _useDispatch, useSelector as _useSelector } from 'react-redux' 2 | import type { RootState, AppDispatch } from './store' 3 | 4 | export let useDispatch = () => _useDispatch() 5 | export let useSelector: TypedUseSelectorHook = _useSelector 6 | -------------------------------------------------------------------------------- /public/gtag.js: -------------------------------------------------------------------------------- 1 | window['dataLayer'] = window['dataLayer'] || [] 2 | window['dataLayer'].push({ 'gtm.start': new Date().getTime(), event: 'gtm.js' }) 3 | var f = document.getElementsByTagName('script')[0], 4 | j = document.createElement('script'), 5 | dl = 'dataLayer' != 'dataLayer' ? '&l=' + 'dataLayer' : '' 6 | j.async = true 7 | j.src = '' 8 | f.parentNode.insertBefore(j, f) 9 | -------------------------------------------------------------------------------- /public/srt-player.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /src/utils/subject.ts: -------------------------------------------------------------------------------- 1 | export class Subject { 2 | listeners: ((v: T) => void)[] 3 | constructor() { 4 | this.listeners = [] 5 | } 6 | next(v: T) { 7 | this.listeners.forEach(f => { 8 | f(v) 9 | }) 10 | } 11 | subscribe(f: (v: T) => void) { 12 | this.listeners.push(f) 13 | return () => { 14 | this.listeners = this.listeners.filter(i => i !== f) 15 | } 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /deploy.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | # abort on errors 4 | set -e 5 | 6 | rm -rf dist 7 | 8 | # build 9 | yarn build 10 | 11 | # navigate into the build output directory 12 | cd dist 13 | 14 | git init 15 | git config user.name "Shenmin Zhou" 16 | git config user.email "shenminzhou@gmail.com" 17 | git add -A 18 | git commit -m 'deploy' 19 | 20 | git push -f git@github.com:Shenmin-Z/srt-player.git master:gh-pages 21 | 22 | cd - 23 | -------------------------------------------------------------------------------- /src/state/store.ts: -------------------------------------------------------------------------------- 1 | import { configureStore } from '@reduxjs/toolkit' 2 | import { filesReducer } from './filesSlice' 3 | import { videoReducer } from './videoSlice' 4 | import { settingsReducer } from './settingsSlice' 5 | 6 | export let store = configureStore({ reducer: { files: filesReducer, video: videoReducer, settings: settingsReducer } }) 7 | 8 | export type RootState = ReturnType 9 | export type AppDispatch = typeof store.dispatch 10 | -------------------------------------------------------------------------------- /src/utils/duration.ts: -------------------------------------------------------------------------------- 1 | export async function getMediaDuration(f: File) { 2 | const video = document.createElement('video') 3 | video.preload = 'metadata' 4 | 5 | return new Promise((resolve, reject) => { 6 | video.onloadedmetadata = () => { 7 | URL.revokeObjectURL(video.src) 8 | resolve(video.duration) 9 | } 10 | 11 | video.onerror = () => { 12 | reject(`Failed to get duration of ${f.name}.`) 13 | } 14 | 15 | video.src = URL.createObjectURL(f) 16 | }) 17 | } 18 | -------------------------------------------------------------------------------- /src/utils/index.ts: -------------------------------------------------------------------------------- 1 | export * from './history' 2 | export * from './subtitle' 3 | export * from './video' 4 | export * from './idb' 5 | export * from './migrate' 6 | export * from './audioSampling' 7 | export * from './subject' 8 | export * from './i18n' 9 | export * from './pair' 10 | export * from './ua' 11 | export * from './duration' 12 | export * from './ga' 13 | export * from './toggleFullScreen' 14 | export * from './language' 15 | export * from './touchEmitter' 16 | export * from './sessionHistory' 17 | export * from './format' 18 | -------------------------------------------------------------------------------- /src/utils/language.ts: -------------------------------------------------------------------------------- 1 | import { Node } from './subtitle' 2 | 3 | export function subtitleInJP(nodes: Node[]) { 4 | for (let i = 0; i < Math.min(20, nodes.length); i++) { 5 | const text = nodes[i].text.join('') 6 | const jpCharacters = text.match(/[ぁ-ゔァ-ヴ]/g) || [] 7 | if (jpCharacters.length >= 5) { 8 | return true 9 | } 10 | } 11 | return false 12 | } 13 | 14 | export function lineInCN(line: string) { 15 | // contains Chinese but not Kana 16 | return /\p{Script=Han}/u.test(line) && !/[ぁ-ゔァ-ヴ]+/.test(line) 17 | } 18 | -------------------------------------------------------------------------------- /src/components/Error.module.less: -------------------------------------------------------------------------------- 1 | .container { 2 | position: fixed; 3 | top: 0; 4 | left: 0; 5 | width: 100vw; 6 | height: var(--100vh); 7 | display: flex; 8 | justify-content: center; 9 | align-items: center; 10 | } 11 | 12 | .content { 13 | text-align: center; 14 | :global(.material-icons-outlined) { 15 | font-size: 64px; 16 | color: var(--red-400); 17 | margin-bottom: 8px; 18 | } 19 | .main { 20 | font-size: 20px; 21 | line-height: 28px; 22 | } 23 | .secondary { 24 | margin-top: 8px; 25 | font-size: 14px; 26 | color: var(--black-500); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/components/Error.tsx: -------------------------------------------------------------------------------- 1 | import { FC } from 'react' 2 | import styles from './Error.module.less' 3 | 4 | interface Props { 5 | main: string 6 | secondary?: string 7 | } 8 | 9 | export const Error: FC = props => { 10 | const { main, secondary } = props 11 | return ( 12 |
13 |
14 |
error
15 |
{main}
16 | {secondary &&
{secondary}
} 17 |
18 |
19 | ) 20 | } 21 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "useDefineForClassFields": true, 5 | "lib": ["DOM", "DOM.Iterable", "ESNext"], 6 | "allowJs": false, 7 | "skipLibCheck": true, 8 | "esModuleInterop": false, 9 | "allowSyntheticDefaultImports": true, 10 | "strict": true, 11 | "forceConsistentCasingInFileNames": true, 12 | "module": "ESNext", 13 | "moduleResolution": "Node", 14 | "resolveJsonModule": true, 15 | "isolatedModules": true, 16 | "noEmit": true, 17 | "jsx": "react-jsx" 18 | }, 19 | "include": ["src"], 20 | "exclude": ["sw"], 21 | "references": [{ "path": "./tsconfig.node.json" }] 22 | } 23 | -------------------------------------------------------------------------------- /src/utils/format.ts: -------------------------------------------------------------------------------- 1 | // time: second 2 | export function formatTime(t: number, fractionDigits?: number, paddingZerosForHour?: boolean) { 3 | const h = t / 3600 4 | const m = (t % 3600) / 60 5 | const s = padding(t % 60, fractionDigits) 6 | if (h > 1 || paddingZerosForHour) { 7 | return `${padding(h)}:${padding(m)}:${s}` 8 | } else { 9 | return `${padding(m)}:${s}` 10 | } 11 | } 12 | 13 | function padding(t: number, fractionDigits?: number) { 14 | const integer = Math.trunc(t) 15 | const decimal = t % 1 16 | const paddedInteger = `${integer < 10 ? '0' : ''}${integer}` 17 | return fractionDigits ? `${paddedInteger}.${decimal.toFixed(fractionDigits).split('.')[1]}` : paddedInteger 18 | } 19 | -------------------------------------------------------------------------------- /src/main.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { createRoot } from 'react-dom/client' 3 | import { Provider } from 'react-redux' 4 | import './index.css' 5 | import App from './App' 6 | import { store } from './state/store' 7 | 8 | const container = document.getElementById('root') as HTMLElement 9 | const root = createRoot(container) 10 | 11 | document.fonts.ready.then(() => { 12 | document.getElementById('loading')?.remove() 13 | 14 | root.render( 15 | 16 | 17 | 18 | 19 | , 20 | ) 21 | }) 22 | 23 | if ('serviceWorker' in navigator) { 24 | navigator.serviceWorker.register('/srt-player/sw.js') 25 | } 26 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "srt-player", 3 | "version": "0.0.0", 4 | "scripts": { 5 | "dev": "vite", 6 | "build": "tsc && vite build && node build/index.js", 7 | "serve": "vite preview" 8 | }, 9 | "dependencies": { 10 | "@reduxjs/toolkit": "^1.8.1", 11 | "browser-fs-access": "^0.24.0", 12 | "classnames": "^2.3.1", 13 | "idb": "^7.0.0", 14 | "react": "^18.0.0", 15 | "react-dom": "^18.0.0", 16 | "react-redux": "^8.0.1" 17 | }, 18 | "devDependencies": { 19 | "@types/offscreencanvas": "^2019.6.4", 20 | "@types/react": "^18.0.0", 21 | "@types/react-dom": "^18.0.0", 22 | "@types/wicg-file-system-access": "^2020.9.5", 23 | "@vitejs/plugin-react": "^1.3.2", 24 | "less": "^4.1.2", 25 | "typescript": "^4.6.3", 26 | "vite": "^2.9.9" 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/utils/sessionHistory.ts: -------------------------------------------------------------------------------- 1 | export const GO_BACK_ID = 'go-back-to-list' 2 | export const OPEN_PREVIOUS_ID = 'open-previoius' 3 | 4 | enum SessionState { 5 | list = 1, 6 | video, 7 | } 8 | 9 | history.replaceState({ type: SessionState.list }, '') 10 | 11 | export function pushHistory() { 12 | history.pushState({ type: SessionState.video }, '') 13 | } 14 | 15 | window.addEventListener('popstate', ({ state }) => { 16 | switch (state.type) { 17 | case SessionState.list: { 18 | // assusme in video page 19 | const goBackElm = document.getElementById(GO_BACK_ID) 20 | if (goBackElm) { 21 | goBackElm.click() 22 | } 23 | break 24 | } 25 | case SessionState.video: { 26 | // assusme in list page 27 | const previousElm = document.getElementById(OPEN_PREVIOUS_ID) 28 | if (previousElm) { 29 | previousElm.click() 30 | } 31 | break 32 | } 33 | } 34 | }) 35 | -------------------------------------------------------------------------------- /src/utils/i18n.ts: -------------------------------------------------------------------------------- 1 | import { useSelector } from '../state/hooks' 2 | import { locale } from '../locale' 3 | 4 | export const INIT_LANG = (() => { 5 | if (/^zh/i.test(navigator.language)) { 6 | return 'zh-CN' 7 | } 8 | return 'en-US' 9 | })() 10 | 11 | export const useI18n = (_language?: string): ((path: string, ...args: string[]) => string) => { 12 | let language = useSelector(s => s.settings.locale) 13 | language = _language ?? language 14 | const text = locale[language] 15 | if (!text) return () => '' 16 | 17 | return (path: string, ...args: string[]) => { 18 | const paths = path.split('.') 19 | let tmp = text 20 | for (let i = 0; i < paths.length; i++) { 21 | tmp = tmp[paths[i]] 22 | if (!tmp) return '' 23 | } 24 | if (typeof tmp !== 'string') return '' 25 | return tmp.replace(/{(\d+)}/g, (match, number) => (typeof args[number] != 'undefined' ? args[number] : match)) 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/utils/migrate.tsx: -------------------------------------------------------------------------------- 1 | import { FC, useEffect, useState } from 'react' 2 | import { Error } from '../components/Error' 3 | import { useI18n, INIT_LANG } from './i18n' 4 | import { setGlobalDb, getDB } from './idb' 5 | 6 | export const migrate = (FC: FC): FC => { 7 | return props => { 8 | const [migrated, setMigrated] = useState<'init' | 'success' | 'failed'>('init') 9 | const [errorMsg, setErrorMsg] = useState('') 10 | const i18n = useI18n(INIT_LANG) 11 | 12 | useEffect(() => { 13 | ;(async () => { 14 | try { 15 | setGlobalDb(await getDB()) 16 | setMigrated('success') 17 | } catch (_e) { 18 | setMigrated('failed') 19 | const e = _e as any 20 | if (typeof e?.toString === 'function') { 21 | setErrorMsg(e.toString()) 22 | } 23 | } 24 | })() 25 | }, []) 26 | 27 | if (migrated === 'init') return null 28 | if (migrated === 'success') return 29 | return 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Zhou Shenmin 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/utils/toggleFullScreen.ts: -------------------------------------------------------------------------------- 1 | declare const document: any 2 | 3 | export function isFullscreen() { 4 | return !!(document.fullscreenElement || document.mozFullScreenElement || document.webkitFullscreenElement) 5 | } 6 | 7 | export const FULLSCREEN_ENABLED = !!( 8 | document.documentElement.requestFullscreen || 9 | document.documentElement.mozRequestFullScreen || 10 | document.documentElement.webkitRequestFullscreen 11 | ) 12 | 13 | export function toggleFullScreen() { 14 | if (isFullscreen()) { 15 | // exit fullscreen 16 | if (document.cancelFullScreen) { 17 | document.cancelFullScreen() 18 | } else if (document.mozCancelFullScreen) { 19 | document.mozCancelFullScreen() 20 | } else if (document.webkitCancelFullScreen) { 21 | document.webkitCancelFullScreen() 22 | } 23 | } else { 24 | // enter fullscreen 25 | const element = document.body 26 | if (element.requestFullscreen) { 27 | element.requestFullscreen() 28 | } else if (element.mozRequestFullScreen) { 29 | element.mozRequestFullScreen() 30 | } else if (element.webkitRequestFullscreen) { 31 | element.webkitRequestFullscreen() 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/utils/ga.ts: -------------------------------------------------------------------------------- 1 | declare global { 2 | interface Window { 3 | dataLayer: any 4 | 'ga-disable-G-STHJFZ79XM': boolean 5 | } 6 | } 7 | 8 | const GA_MEASUREMENT_ID = 'G-STHJFZ79XM' 9 | if (location.hostname !== 'shenmin-z.github.io') { 10 | // disable if not prod env 11 | window['ga-disable-G-STHJFZ79XM'] = true 12 | } 13 | 14 | window.dataLayer = window.dataLayer || [] 15 | function gtag(..._: any[]) { 16 | window.dataLayer.push(arguments) 17 | } 18 | gtag('js', new Date()) 19 | gtag('config', GA_MEASUREMENT_ID) 20 | 21 | function track(name: string, params: any) { 22 | gtag('event', name, params) 23 | } 24 | 25 | export function trackImportFiles(num_of_videos: number, num_of_subtitles: number) { 26 | track('import_files', { 27 | num_of_videos, 28 | num_of_subtitles, 29 | }) 30 | } 31 | 32 | export function trackOpenFile(file: string) { 33 | track('open_file', { 34 | open_file_type: file.split('.').pop(), // just extension 35 | }) 36 | } 37 | 38 | export function trackGoBack() { 39 | track('go_back', { click_go_back: 'back to main page' }) 40 | } 41 | 42 | export function trackCreateWaveform(type: 'video' | 'audio') { 43 | track('create_waveform', { create_waveform_type: type }) 44 | } 45 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 |

2 | wstunnel logo 3 |

4 | 5 | # SRT Player 6 | 7 | [介绍](https://zhuanlan.zhihu.com/p/469289749) 8 | 9 | Video player with separate subtitle display and waveform 10 | 11 | [Try it here](https://shenmin-z.github.io/srt-player/) 12 | 13 | Screenshot on android Chrome 14 | 15 | ![screenshot](./docs/screenshot.jpg) 16 | 17 | Video demo: [youtube](https://youtu.be/ZPnu17pJsIo), [哔哩哔哩](https://www.bilibili.com/video/BV1Ci4y1d7iA/) 18 | 19 | ## Features 20 | 21 | ### Subtitle 22 | 23 | - adjust delay in a breeze (`click` on start or end time that is above individual subtitle text) 24 | 25 | ### Waveform 26 | 27 | - find the exact location to replay (`click` to set replay position) 28 | 29 | ### Offline usable 30 | 31 | - onced loaded, can be used without internet next time 32 | 33 | ## About importing videos 34 | 35 | To persist imported videos: 36 | 37 | - use desktop Chrome or Edge, which supports reading local files(user permission required) 38 | - check the `Copy file(s) to cache` option when importing, which will make a copy of video file to browser storage 39 | 40 | ## Limitations 41 | 42 | - video has to be in codecs that browser supports 43 | - mordern browser is recommended 44 | -------------------------------------------------------------------------------- /src/reset.css: -------------------------------------------------------------------------------- 1 | /* http://meyerweb.com/eric/tools/css/reset/ 2 | v2.0 | 20110126 3 | License: none (public domain) 4 | */ 5 | 6 | html, body, div, span, applet, object, iframe, 7 | h1, h2, h3, h4, h5, h6, p, blockquote, pre, 8 | a, abbr, acronym, address, big, cite, code, 9 | del, dfn, em, img, ins, kbd, q, s, samp, 10 | small, strike, strong, sub, sup, tt, var, 11 | b, u, i, center, 12 | dl, dt, dd, ol, ul, li, 13 | fieldset, form, label, legend, 14 | table, caption, tbody, tfoot, thead, tr, th, td, 15 | article, aside, canvas, details, embed, 16 | figure, figcaption, footer, header, hgroup, 17 | menu, nav, output, ruby, section, summary, 18 | time, mark, audio, video { 19 | margin: 0; 20 | padding: 0; 21 | border: 0; 22 | font-size: 100%; 23 | font: inherit; 24 | vertical-align: baseline; 25 | } 26 | /* HTML5 display-role reset for older browsers */ 27 | article, aside, details, figcaption, figure, 28 | footer, header, hgroup, menu, nav, section { 29 | display: block; 30 | } 31 | body { 32 | line-height: 1; 33 | } 34 | ol, ul { 35 | list-style: none; 36 | } 37 | blockquote, q { 38 | quotes: none; 39 | } 40 | blockquote:before, blockquote:after, 41 | q:before, q:after { 42 | content: ''; 43 | content: none; 44 | } 45 | table { 46 | border-collapse: collapse; 47 | border-spacing: 0; 48 | } 49 | 50 | * { 51 | -webkit-tap-highlight-color: transparent; 52 | } 53 | -------------------------------------------------------------------------------- /src/App.module.less: -------------------------------------------------------------------------------- 1 | .home { 2 | max-width: 1080px; 3 | min-height: var(--100vh); 4 | margin: 0 auto; 5 | display: flex; 6 | flex-direction: column; 7 | &.hidden { 8 | display: none; 9 | } 10 | } 11 | 12 | .play { 13 | --nav-height: 40px; 14 | height: var(--100vh); 15 | display: grid; 16 | grid-template-columns: minmax(0, 1fr) 3px var(--subtitle-width); 17 | grid-template-rows: var(--nav-height) minmax(0, 1fr); 18 | 19 | &:global(.no-subtitle) { 20 | grid-template-columns: 1fr 0 0; 21 | .resize .fat-bar { 22 | display: none; 23 | } 24 | } 25 | &:global(.no-video) { 26 | grid-template-columns: 1fr; 27 | grid-template-rows: var(--nav-height) 0 minmax(0, 1fr); 28 | } 29 | } 30 | 31 | .hide() { 32 | .play { 33 | --nav-height: 0; 34 | } 35 | } 36 | body:fullscreen { 37 | .hide(); 38 | } 39 | body:-moz-full-screen { 40 | .hide(); 41 | } 42 | body:-webkit-full-screen { 43 | .hide(); 44 | } 45 | 46 | .resize { 47 | cursor: col-resize; 48 | position: relative; 49 | background: var(--bc-lighter); 50 | &:hover { 51 | @media (hover: hover) { 52 | background: var(--black-400); 53 | } 54 | } 55 | z-index: 1; 56 | overflow: visible; 57 | .fat-bar { 58 | width: 15px; 59 | position: absolute; 60 | height: 100%; 61 | left: 50%; 62 | transform: translate(-50%, 0); 63 | background-color: transparent; 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/components/Language.tsx: -------------------------------------------------------------------------------- 1 | import { FC, useState } from 'react' 2 | import { useSelector, useDispatch, updateLanguage } from '../state' 3 | import styles from './Language.module.less' 4 | 5 | const LANGUAGES = [ 6 | { type: 'en-US', name: 'English' }, 7 | { type: 'zh-CN', name: '中文' }, 8 | ] 9 | 10 | export const Language: FC = () => { 11 | const [show, setShow] = useState(false) 12 | const language = useSelector(s => s.settings.locale) 13 | const dispath = useDispatch() 14 | 15 | return ( 16 |
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 | 38 | 39 | 40 | 41 | 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 |
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 |
140 | 141 |
142 | 143 | 144 | Github 145 | 146 |
147 | 148 |
149 |
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 |
60 |
{children}
61 |
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 | ``, 131 | ``, 132 | '', 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 |
152 | {text} 153 |
154 |
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 | /^[^,]*,(?[^,]*),(?[^,]*),(?