mirror of
https://github.com/NoCrypt/migu.git
synced 2026-04-20 16:12:31 +00:00
playback
This commit is contained in:
parent
12fd3cdcfc
commit
8f7c943def
10 changed files with 1897 additions and 9 deletions
|
|
@ -15,6 +15,7 @@
|
|||
"concurrently": "^7.0.0",
|
||||
"electron": "^16.0.10",
|
||||
"electron-builder": "^22.14.13",
|
||||
"matroska-subtitles": "^3.3.2",
|
||||
"svelte": "^3.46.4",
|
||||
"vite": "^2.8.6",
|
||||
"vite-plugin-commonjs-externals": "^0.1.1",
|
||||
|
|
|
|||
82
src/renderer/public/sw.js
Normal file
82
src/renderer/public/sw.js
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
self.addEventListener('install', () => {
|
||||
self.skipWaiting()
|
||||
})
|
||||
|
||||
self.addEventListener('fetch', event => {
|
||||
const res = proxyResponse(event)
|
||||
if (res) event.respondWith(res)
|
||||
})
|
||||
|
||||
self.addEventListener('activate', evt => {
|
||||
evt.waitUntil(self.clients.claim())
|
||||
})
|
||||
|
||||
const portTimeoutDuration = 5000
|
||||
function proxyResponse (event) {
|
||||
const { url } = event.request
|
||||
if (!(url.includes(self.registration.scope) && url.includes('/webtorrent/')) || url.includes('?')) return null
|
||||
if (url.includes(self.registration.scope) && url.includes('/webtorrent/keepalive/')) return new Response()
|
||||
|
||||
return serve(event)
|
||||
}
|
||||
|
||||
async function serve ({ request }) {
|
||||
const { url, method, headers, destination } = request
|
||||
const clientlist = await clients.matchAll({ type: 'window', includeUncontrolled: true })
|
||||
|
||||
const [data, port] = await new Promise(resolve => {
|
||||
// Use race condition for whoever controls the response stream
|
||||
for (const client of clientlist) {
|
||||
const messageChannel = new MessageChannel()
|
||||
const { port1, port2 } = messageChannel
|
||||
port1.onmessage = ({ data }) => {
|
||||
resolve([data, port1])
|
||||
}
|
||||
client.postMessage({
|
||||
url,
|
||||
method,
|
||||
headers: Object.fromEntries(headers.entries()),
|
||||
scope: self.registration.scope,
|
||||
destination,
|
||||
type: 'webtorrent'
|
||||
}, [port2])
|
||||
}
|
||||
})
|
||||
|
||||
if (data.body !== 'STREAM' && data.body !== 'DOWNLOAD') return new Response(data.body, data)
|
||||
|
||||
let timeOut = null
|
||||
return new Response(new ReadableStream({
|
||||
pull (controller) {
|
||||
return new Promise(resolve => {
|
||||
port.onmessage = ({ data }) => {
|
||||
if (data) {
|
||||
controller.enqueue(data) // event.data is Uint8Array
|
||||
} else {
|
||||
clearTimeout(timeOut)
|
||||
controller.close() // event.data is null, means the stream ended
|
||||
port.onmessage = null
|
||||
}
|
||||
resolve()
|
||||
}
|
||||
|
||||
// 'media player' does NOT signal a close on the stream and we cannot close it because it's locked to the reader,
|
||||
// so we just empty it after 5s of inactivity, the browser will request another port anyways
|
||||
clearTimeout(timeOut)
|
||||
if (data.body === 'STREAM') {
|
||||
timeOut = setTimeout(() => {
|
||||
controller.close()
|
||||
port.postMessage(false) // send timeout
|
||||
port.onmessage = null
|
||||
resolve()
|
||||
}, portTimeoutDuration)
|
||||
}
|
||||
port.postMessage(true) // send a pull request
|
||||
})
|
||||
},
|
||||
cancel () {
|
||||
// This event is never executed
|
||||
port.postMessage(false) // send a cancel request
|
||||
}
|
||||
}), data)
|
||||
}
|
||||
|
|
@ -3,17 +3,23 @@
|
|||
import Player from './pages/Player.svelte'
|
||||
import Settings from './pages/Settings.svelte'
|
||||
import Schedule from './pages/Schedule.svelte'
|
||||
import { client } from '@/modules/torrent.js'
|
||||
export let page = 'home'
|
||||
let files = []
|
||||
client.on('torrent', torrent => {
|
||||
console.log('hash', torrent.infoHash)
|
||||
page = 'player'
|
||||
files = torrent.files
|
||||
})
|
||||
</script>
|
||||
|
||||
<div class="overflow-y-hidden content-wrapper">
|
||||
{#if page === 'player'}
|
||||
<Player />
|
||||
{:else if page === 'schedule'}
|
||||
<Player {files} />
|
||||
{#if page === 'schedule'}
|
||||
<Schedule />
|
||||
{:else if page === 'settings'}
|
||||
<Settings />
|
||||
{:else}
|
||||
{:else if page === 'home'}
|
||||
<Home />
|
||||
{/if}
|
||||
</div>
|
||||
|
|
|
|||
138
src/renderer/src/lib/pages/Keyboard.svelte
Normal file
138
src/renderer/src/lib/pages/Keyboard.svelte
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
<div class="d-flex flex-column w-820 p-10 rounded">
|
||||
<div class="row">
|
||||
<div><div class="material-icons bg-dark">help_outline</div></div>
|
||||
<div><div></div></div>
|
||||
<div><div></div></div>
|
||||
<div><div></div></div>
|
||||
<div><div></div></div>
|
||||
<div><div></div></div>
|
||||
<div><div></div></div>
|
||||
<div><div></div></div>
|
||||
<div><div></div></div>
|
||||
<div><div></div></div>
|
||||
<div><div></div></div>
|
||||
<div><div></div></div>
|
||||
<div><div></div></div>
|
||||
<div class="w-100"><div class="bg-dark"></div></div>
|
||||
<div><div class="bg-dark"></div></div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="w-75"><div class="bg-dark"></div></div>
|
||||
<div><div></div></div>
|
||||
<div><div></div></div>
|
||||
<div><div></div></div>
|
||||
<div><div>-90</div></div>
|
||||
<div><div></div></div>
|
||||
<div><div></div></div>
|
||||
<div><div></div></div>
|
||||
<div><div class="material-icons">list</div></div>
|
||||
<div><div></div></div>
|
||||
<div><div class="material-icons">picture_in_picture</div></div>
|
||||
<div><div class="material-icons">history</div></div>
|
||||
<div><div class="material-icons shadow-lg">update</div></div>
|
||||
<div class="w-75"><div class="material-icons bg-dark">schedule</div></div>
|
||||
<div><div class="bg-dark"></div></div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="w-90"><div class="bg-dark"></div></div>
|
||||
<div><div></div></div>
|
||||
<div><div>+90</div></div>
|
||||
<div><div class="material-icons">cast</div></div>
|
||||
<div><div class="material-icons">fullscreen</div></div>
|
||||
<div><div></div></div>
|
||||
<div><div></div></div>
|
||||
<div><div></div></div>
|
||||
<div><div></div></div>
|
||||
<div><div></div></div>
|
||||
<div><div></div></div>
|
||||
<div><div></div></div>
|
||||
<div class="w-110"><div class="bg-dark"></div></div>
|
||||
<div><div class="bg-dark"></div></div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="w-115"><div class="bg-dark"></div></div>
|
||||
<div><div></div></div>
|
||||
<div><div></div></div>
|
||||
<div><div class="material-icons">subtitles</div></div>
|
||||
<div><div></div></div>
|
||||
<div><div></div></div>
|
||||
<div><div class="material-icons">skip_next</div></div>
|
||||
<div><div class="material-icons">volume_off</div></div>
|
||||
<div><div class="material-icons">fast_rewind</div></div>
|
||||
<div><div class="material-icons">fast_forward</div></div>
|
||||
<div><div></div></div>
|
||||
<div class="w-85"><div class="bg-dark"></div></div>
|
||||
<div><div class="material-icons bg-dark">volume_up</div></div>
|
||||
<div><div class="bg-dark"></div></div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="w-75"><div class="bg-dark"></div></div>
|
||||
<div><div class="bg-dark"></div></div>
|
||||
<div class="w-75"><div class="bg-dark"></div></div>
|
||||
<div class="w-300"><div class="material-icons bg-dark">play_arrow</div></div>
|
||||
<div class="w-75"><div class="bg-dark"></div></div>
|
||||
<div class="w-75"><div class="bg-dark"></div></div>
|
||||
<div><div class="bg-dark">-2</div></div>
|
||||
<div><div class="material-icons bg-dark">volume_down</div></div>
|
||||
<div><div class="bg-dark">+2</div></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.material-icons, .row > div > div {
|
||||
font-size: 1.8rem;
|
||||
font-weight: bold;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
.material-icons {
|
||||
font-size: 2.2rem !important;
|
||||
font-weight: unset !important;
|
||||
}
|
||||
.row > div {
|
||||
height: 5rem;
|
||||
width: 5rem;
|
||||
padding: 0.5rem;
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
.row > div:hover {
|
||||
transform: scale(0.9);
|
||||
}
|
||||
.row > div > div {
|
||||
background-color: var(--dark-color-light);
|
||||
height: 100%;
|
||||
border-radius: var(--base-border-radius) !important;
|
||||
}
|
||||
.w-75 {
|
||||
width: 7.5rem !important;
|
||||
}
|
||||
.w-85 {
|
||||
width: 8.5rem !important;
|
||||
}
|
||||
.w-90 {
|
||||
width: 9rem !important;
|
||||
}
|
||||
.w-110 {
|
||||
width: 11rem !important;
|
||||
}
|
||||
.w-115 {
|
||||
width: 11.5rem !important;
|
||||
}
|
||||
.w-820 {
|
||||
width: 82rem;
|
||||
animation: .3s ease 0s 1 load-in;
|
||||
background: rgba(0, 0, 0, 0.8)
|
||||
}
|
||||
@keyframes load-in {
|
||||
from {
|
||||
bottom: -1.2rem;
|
||||
transform: scale(.75);
|
||||
}
|
||||
|
||||
to {
|
||||
bottom: 0;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large
Load diff
197
src/renderer/src/modules/Peer.js
Normal file
197
src/renderer/src/modules/Peer.js
Normal file
|
|
@ -0,0 +1,197 @@
|
|||
/* eslint-env browser */
|
||||
function waitToCompleteIceGathering (pc, state = pc.iceGatheringState) {
|
||||
return state !== 'complete' && new Promise(resolve => {
|
||||
pc.addEventListener('icegatheringstatechange', () => (pc.iceGatheringState === 'complete') && resolve())
|
||||
})
|
||||
}
|
||||
/**
|
||||
* @typedef {AddEventListenerOptions} Test~options
|
||||
* @property {AbortSignal} signal - funkis?
|
||||
*/
|
||||
|
||||
export default class Peer {
|
||||
/**
|
||||
* @param {{
|
||||
* polite: boolean,
|
||||
* trickle: boolean,
|
||||
* iceServers: RTCIceServer[]
|
||||
* signal: AbortSignal
|
||||
* }} [options]
|
||||
*/
|
||||
constructor (options = {}) {
|
||||
let { polite = true, trickle = true, quality = {} } = options
|
||||
|
||||
let { port1, port2 } = new MessageChannel()
|
||||
let send = msg => port2.postMessage(JSON.stringify(msg))
|
||||
|
||||
const pc = new RTCPeerConnection({
|
||||
iceServers: options?.iceServers || [{
|
||||
urls: [
|
||||
'stun:stun.l.google.com:19302',
|
||||
'stun:global.stun.twilio.com:3478'
|
||||
]
|
||||
}]
|
||||
})
|
||||
|
||||
const ctrl = new AbortController()
|
||||
|
||||
/** @type {any} dummy alias for AbortSignal to make TS happy */
|
||||
const signal = { signal: ctrl.signal }
|
||||
|
||||
pc.addEventListener('iceconnectionstatechange', () => {
|
||||
if (
|
||||
pc.iceConnectionState === 'disconnected' ||
|
||||
pc.iceConnectionState === 'failed'
|
||||
) {
|
||||
ctrl.abort()
|
||||
}
|
||||
}, signal)
|
||||
|
||||
const dc = pc.createDataChannel('both', { negotiated: true, id: 0 })
|
||||
|
||||
this.pc = pc
|
||||
this.dc = dc
|
||||
this.signal = ctrl.signal
|
||||
this.polite = polite
|
||||
this.signalingPort = port1
|
||||
|
||||
this.ready = new Promise(resolve => {
|
||||
dc.addEventListener('open', () => {
|
||||
// At this point we start to trickle over datachannel instead
|
||||
// we also close the message channel as we do not need it anymore
|
||||
trickle = true
|
||||
send = (msg) => dc.send(JSON.stringify(msg))
|
||||
port1.close()
|
||||
port2.close()
|
||||
this.ready = port2 = port1 = port2.onmessage = null
|
||||
resolve()
|
||||
}, { once: true, ...signal })
|
||||
})
|
||||
|
||||
pc.addEventListener('icecandidate', ({ candidate }) => {
|
||||
trickle && send({ candidate })
|
||||
}, { ...signal })
|
||||
|
||||
// The rest is the polite peer negotiation logic, copied from this blog
|
||||
|
||||
let makingOffer = false; let ignoreOffer = false
|
||||
|
||||
pc.addEventListener('negotiationneeded', async () => {
|
||||
makingOffer = true
|
||||
const offer = await pc.createOffer()
|
||||
if (pc.signalingState !== 'stable') return
|
||||
offer.sdp = setQuality(offer.sdp, quality)
|
||||
await pc.setLocalDescription(offer)
|
||||
makingOffer = false
|
||||
if (trickle) {
|
||||
send({ description: pc.localDescription })
|
||||
} else {
|
||||
await waitToCompleteIceGathering(pc)
|
||||
const description = pc.localDescription.toJSON()
|
||||
description.sdp = description.sdp.replace(/a=ice-options:trickle\s\n/g, '')
|
||||
send({ description })
|
||||
}
|
||||
}, { ...signal })
|
||||
|
||||
async function onmessage ({ data }) {
|
||||
const { description, candidate } = typeof data === 'string' ? JSON.parse(data) : data
|
||||
|
||||
if (description) {
|
||||
const offerCollision = description.type === 'offer' &&
|
||||
(makingOffer || pc.signalingState !== 'stable')
|
||||
|
||||
ignoreOffer = !this.polite && offerCollision
|
||||
if (ignoreOffer) {
|
||||
return
|
||||
}
|
||||
|
||||
if (offerCollision) {
|
||||
await Promise.all([
|
||||
pc.setLocalDescription({ type: 'rollback' }),
|
||||
pc.setRemoteDescription(description)
|
||||
])
|
||||
} else {
|
||||
try {
|
||||
(description.type === 'answer' && pc.signalingState === 'stable') ||
|
||||
await pc.setRemoteDescription(description)
|
||||
} catch (err) { }
|
||||
}
|
||||
if (description.type === 'offer') {
|
||||
const answ = await pc.createAnswer()
|
||||
answ.sdp = setQuality(answ.sdp, quality)
|
||||
await pc.setLocalDescription(answ)
|
||||
// Edge didn't set the state to 'new' after calling the above :[
|
||||
if (!trickle) await waitToCompleteIceGathering(pc, 'new')
|
||||
send({ description: pc.localDescription })
|
||||
}
|
||||
} else if (candidate) {
|
||||
await pc.addIceCandidate(candidate)
|
||||
}
|
||||
}
|
||||
|
||||
port2.onmessage = onmessage.bind(this)
|
||||
dc.addEventListener('message', onmessage.bind(this), { ...signal })
|
||||
}
|
||||
}
|
||||
|
||||
// I cannot describe the sheer anger and hatred I bear towards whoever came up with SDP
|
||||
function setQuality (sdp, opts = {}) {
|
||||
if (!sdp || (!opts.video && !opts.audio)) return sdp
|
||||
let newSDP = sdp
|
||||
if (opts.video) { // bitrate, codecs[]
|
||||
const videoData = sdp.matchAll(/^m=video.*SAVPF (.*)$/gm).next().value
|
||||
if (videoData && videoData[1]) {
|
||||
const RTPIndex = videoData[1]
|
||||
const RTPMaps = {}
|
||||
let last = null
|
||||
for (const [match, id, type] of [...sdp.matchAll(/^a=rtpmap:(\d{1,3}) (.*)\/90000$/gm)]) {
|
||||
if (type === 'rtx') {
|
||||
RTPMaps[last].push(id)
|
||||
} else {
|
||||
if (!RTPMaps[type]) RTPMaps[type] = []
|
||||
RTPMaps[type].push(id)
|
||||
last = type
|
||||
if (opts.video.bitrate) {
|
||||
const fmtp = `a=fmtp:${id} x-google-min-bitrate=${opts.video.bitrate}; x-google-max-bitrate=${opts.video.bitrate}\n`
|
||||
newSDP = newSDP.replace(match, fmtp + match)
|
||||
}
|
||||
}
|
||||
}
|
||||
const newIndex = Object.entries(RTPMaps).sort((a, b) => {
|
||||
const indexA = opts.video.codecs.indexOf(a[0])
|
||||
const indexB = opts.video.codecs.indexOf(b[0])
|
||||
return (indexA === -1 ? opts.video.codecs.length : indexA) - (indexB === -1 ? opts.video.codecs.length : indexB)
|
||||
}).map(value => {
|
||||
return value[1].join(' ')
|
||||
}).join(' ')
|
||||
newSDP = newSDP.replace(RTPIndex, newIndex)
|
||||
}
|
||||
}
|
||||
if (opts.audio) {
|
||||
const audioData = sdp.matchAll(/^a=rtpmap:(\d{1,3}) opus\/48000\/2$/gm).next().value
|
||||
if (audioData && audioData[0]) {
|
||||
const regex = new RegExp(`^a=fmtp:${audioData[1]}.*$`, 'gm')
|
||||
const FMTPData = sdp.match(regex)
|
||||
if (FMTPData && FMTPData[0]) {
|
||||
let newFMTPData = FMTPData[0].slice(0, FMTPData[0].indexOf(' ') + 1)
|
||||
newFMTPData += 'stereo=' + (opts.audio.stereo != null ? opts.audio.stereo : '1')
|
||||
newFMTPData += ';sprop-stereo=' + (opts.audio['sprop-stereo'] != null ? opts.audio['sprop-stereo'] : '1')
|
||||
|
||||
if (opts.audio.maxaveragebitrate != null) newFMTPData += '; maxaveragebitrate=' + (opts.audio.maxaveragebitrate || 128 * 1024 * 8)
|
||||
|
||||
if (opts.audio.maxplaybackrate != null) newFMTPData += '; maxplaybackrate=' + (opts.audio.maxplaybackrate || 128 * 1024 * 8)
|
||||
|
||||
if (opts.audio.cbr != null) newFMTPData += '; cbr=' + opts.audio.cbr
|
||||
|
||||
if (opts.audio.useinbandfec != null) newFMTPData += '; useinbandfec=' + opts.audio.useinbandfec
|
||||
|
||||
if (opts.audio.usedtx != null) newFMTPData += '; usedtx=' + opts.audio.usedtx
|
||||
|
||||
if (opts.audio.maxptime != null) newFMTPData += ';maxptime:' + opts.audio.maxptime
|
||||
if (opts.audio.minptime != null) newFMTPData += '; minptime:' + opts.audio.minptime
|
||||
newSDP = newSDP.replace(FMTPData[0], newFMTPData)
|
||||
}
|
||||
}
|
||||
}
|
||||
return newSDP
|
||||
}
|
||||
|
|
@ -0,0 +1,320 @@
|
|||
/* eslint-env browser */
|
||||
import SubtitlesOctopus from './subtitles-octopus.js'
|
||||
import { toTS, videoRx, subRx } from './util.js'
|
||||
import { SubtitleParser, SubtitleStream } from 'matroska-subtitles'
|
||||
|
||||
const defaultHeader = `[V4+ Styles]
|
||||
Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding
|
||||
Style: Default, Roboto Medium,26,&H00FFFFFF,&H000000FF,&H00020713,&H00000000,0,0,0,0,100,100,0,0,1,1.3,0,2,20,20,23,1
|
||||
[Events]
|
||||
|
||||
`
|
||||
export default class Subtitles {
|
||||
constructor (video, files, selected, onHeader) {
|
||||
this.video = video
|
||||
this.selected = selected || null
|
||||
this.files = files || []
|
||||
this.headers = []
|
||||
this.tracks = []
|
||||
this.fonts = ['Roboto.ttf']
|
||||
this.renderer = null
|
||||
this.parsed = false
|
||||
this.stream = null
|
||||
this.parser = null
|
||||
this.current = 0
|
||||
this.onHeader = onHeader
|
||||
this.videoFiles = files.filter(file => videoRx.test(file.name))
|
||||
this.subtitleFiles = []
|
||||
this.timeout = null
|
||||
|
||||
if (this.selected.name.endsWith('.mkv') && this.selected.createReadStream) {
|
||||
let lastStream = null
|
||||
this.selected.on('stream', ({ stream }) => { lastStream = stream })
|
||||
this.initParser(this.selected).then(() => {
|
||||
this.selected.on('stream', ({ stream, file, req }, cb) => {
|
||||
if (req.destination === 'video' && !this.parsed) {
|
||||
this.stream = new SubtitleStream(this.stream)
|
||||
this.handleSubtitleParser(this.stream, true)
|
||||
stream.pipe(this.stream)
|
||||
cb(this.stream)
|
||||
}
|
||||
})
|
||||
lastStream?.destroy()
|
||||
})
|
||||
}
|
||||
this.findSubtitleFiles(this.selected)
|
||||
}
|
||||
|
||||
findSubtitleFiles (targetFile) {
|
||||
const videoName = targetFile.name.substring(0, targetFile.name.lastIndexOf('.')) || targetFile.name
|
||||
// array of subtitle files that match video name, or all subtitle files when only 1 vid file
|
||||
const subfiles = this.files.filter(file => {
|
||||
return !this.subtitleFiles.some(sub => { // exclude already existing files
|
||||
return sub.lastModified === file.lastModified && sub.name === file.name && sub.size === file.size
|
||||
}) && subRx.test(file.name) && (this.videoFiles.length === 1 ? true : file.name.includes(videoName))
|
||||
})
|
||||
if (subfiles.length) {
|
||||
this.parsed = true
|
||||
const length = this.headers.length
|
||||
for (const [i, file] of subfiles.entries()) {
|
||||
const index = i + length
|
||||
this.subtitleFiles[index] = file
|
||||
const type = file.name.substring(file.name.lastIndexOf('.') + 1).toLowerCase()
|
||||
const subname = file.name.slice(0, file.name.lastIndexOf('.'))
|
||||
// sub name could contain video name with or without extension, possibly followed by lang, or not.
|
||||
const name = subname.includes(targetFile.name)
|
||||
? subname.replace(targetFile.name, '')
|
||||
: subname.replace(targetFile.name.slice(0, targetFile.name.lastIndexOf('.')), '')
|
||||
this.headers[index] = {
|
||||
header: defaultHeader,
|
||||
language: name.replace(/[,._-]/g, ' ').trim() || 'Track ' + index,
|
||||
number: index,
|
||||
type
|
||||
}
|
||||
this.onHeader()
|
||||
this.tracks[index] = []
|
||||
this.constructor.convertSubFile(file, type, subtitles => { // why does .constructor work ;-;
|
||||
if (type === 'ass') {
|
||||
this.headers[index].header = subtitles
|
||||
} else {
|
||||
this.tracks[index] = subtitles
|
||||
}
|
||||
})
|
||||
}
|
||||
if (!this.current) {
|
||||
this.current = 0
|
||||
if (!this.renderer) this.initSubtitleRenderer()
|
||||
this.selectCaptions(this.current)
|
||||
this.onHeader()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async initSubtitleRenderer () {
|
||||
if (!this.renderer) {
|
||||
const options = {
|
||||
video: this.video,
|
||||
subContent: this.headers[this.current].header.slice(0, -1),
|
||||
fonts: this.fonts,
|
||||
workerUrl: 'lib/subtitles-octopus-worker.js'
|
||||
}
|
||||
if (!this.renderer) {
|
||||
this.renderer = new SubtitlesOctopus(options)
|
||||
this.selectCaptions(this.current)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static convertSubFile (file, type, callback) {
|
||||
const srtRx = /(?:\d+\n)?(\S{9,12})\s?-->\s?(\S{9,12})(.*)\n([\s\S]*)$/i
|
||||
const srt = text => {
|
||||
const subtitles = []
|
||||
const replaced = text.replace(/\r/g, '')
|
||||
for (const split of replaced.split('\n\n')) {
|
||||
const match = split.match(srtRx)
|
||||
if (match) {
|
||||
// timestamps
|
||||
match[1] = match[1].match(/.*[.,]\d{2}/)[0]
|
||||
match[2] = match[2].match(/.*[.,]\d{2}/)[0]
|
||||
if (match[1].length === 9) {
|
||||
match[1] = '0:' + match[1]
|
||||
} else {
|
||||
if (match[1][0] === '0') {
|
||||
match[1] = match[1].substring(1)
|
||||
}
|
||||
}
|
||||
match[1].replace(',', '.')
|
||||
if (match[2].length === 9) {
|
||||
match[2] = '0:' + match[2]
|
||||
} else {
|
||||
if (match[2][0] === '0') {
|
||||
match[2] = match[2].substring(1)
|
||||
}
|
||||
}
|
||||
match[2].replace(',', '.')
|
||||
// create array of all tags
|
||||
const matches = match[4].match(/<[^>]+>/g)
|
||||
if (matches) {
|
||||
matches.forEach(matched => {
|
||||
if (/<\//.test(matched)) { // check if its a closing tag
|
||||
match[4] = match[4].replace(matched, matched.replace('</', '{\\').replace('>', '0}'))
|
||||
} else {
|
||||
match[4] = match[4].replace(matched, matched.replace('<', '{\\').replace('>', '1}'))
|
||||
}
|
||||
})
|
||||
}
|
||||
subtitles.push('Dialogue: 0,' + match[1].replace(',', '.') + ',' + match[2].replace(',', '.') + ',Default,,0,0,0,,' + match[4])
|
||||
}
|
||||
}
|
||||
callback(subtitles)
|
||||
}
|
||||
const subRx = /[{[](\d+)[}\]][{[](\d+)[}\]](.+)/i
|
||||
const sub = text => {
|
||||
const subtitles = []
|
||||
const replaced = text.replace(/\r/g, '')
|
||||
let frames = 1000 / Number(replaced.match(subRx)[3])
|
||||
if (!frames || isNaN(frames)) frames = 41.708
|
||||
for (const split of replaced.split('\n')) {
|
||||
const match = split.match(subRx)
|
||||
if (match) subtitles.push('Dialogue: 0,' + toTS((match[1] * frames) / 1000, 1) + ',' + toTS((match[2] * frames) / 1000, 1) + ',Default,,0,0,0,,' + match[3].replace('|', '\n'))
|
||||
}
|
||||
callback(subtitles)
|
||||
}
|
||||
file.text().then(text => {
|
||||
const subtitles = type === 'ass' ? text : []
|
||||
if (type === 'ass') {
|
||||
callback(subtitles)
|
||||
} else if (type === 'srt' || type === 'vtt') {
|
||||
srt(text)
|
||||
} else if (type === 'sub') {
|
||||
sub(text)
|
||||
} else {
|
||||
// subbers have a tendency to not set the extensions properly
|
||||
if (srtRx.test(text)) srt(text)
|
||||
if (subRx.test(text)) sub(text)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
static constructSub (subtitle, isNotAss) {
|
||||
if (isNotAss === true) { // converts VTT or other to SSA
|
||||
const matches = subtitle.text.match(/<[^>]+>/g) // create array of all tags
|
||||
if (matches) {
|
||||
matches.forEach(match => {
|
||||
if (/<\//.test(match)) { // check if its a closing tag
|
||||
subtitle.text = subtitle.text.replace(match, match.replace('</', '{\\').replace('>', '0}'))
|
||||
} else {
|
||||
subtitle.text = subtitle.text.replace(match, match.replace('<', '{\\').replace('>', '1}'))
|
||||
}
|
||||
})
|
||||
}
|
||||
// replace all html special tags with normal ones
|
||||
subtitle.text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/ /g, '\\h')
|
||||
}
|
||||
return 'Dialogue: ' +
|
||||
(subtitle.layer || 0) + ',' +
|
||||
toTS(subtitle.time / 1000, 1) + ',' +
|
||||
toTS((subtitle.time + subtitle.duration) / 1000, 1) + ',' +
|
||||
(subtitle.style || 'Default') + ',' +
|
||||
(subtitle.name || '') + ',' +
|
||||
(subtitle.marginL || '0') + ',' +
|
||||
(subtitle.marginR || '0') + ',' +
|
||||
(subtitle.marginV || '0') + ',' +
|
||||
(subtitle.effect || '') + ',' +
|
||||
subtitle.text || ''
|
||||
}
|
||||
|
||||
parseSubtitles () { // parse all existing subtitles for a file
|
||||
return new Promise((resolve) => {
|
||||
if (this.selected.name.endsWith('.mkv')) {
|
||||
let parser = new SubtitleParser()
|
||||
this.handleSubtitleParser(parser, true)
|
||||
const finish = () => {
|
||||
console.log('Sub parsing finished', toTS((performance.now() - t0) / 1000))
|
||||
this.parsed = true
|
||||
this.stream?.destroy()
|
||||
fileStream?.destroy()
|
||||
this.parser?.destroy()
|
||||
this.stream = undefined
|
||||
this.parser = undefined
|
||||
this.selectCaptions(this.current)
|
||||
parser = undefined
|
||||
resolve()
|
||||
}
|
||||
parser.once('tracks', tracks => {
|
||||
if (!tracks.length) finish()
|
||||
})
|
||||
parser.once('finish', finish)
|
||||
const t0 = performance.now()
|
||||
console.log('Sub parsing started')
|
||||
const fileStream = this.selected.createReadStream()
|
||||
this.parser = fileStream.pipe(parser)
|
||||
} else {
|
||||
resolve()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
initParser (file) {
|
||||
return new Promise(resolve => {
|
||||
this.stream = new SubtitleParser()
|
||||
this.handleSubtitleParser(this.stream)
|
||||
this.stream.once('tracks', tracks => {
|
||||
if (!tracks.length) {
|
||||
this.parsed = true
|
||||
resolve()
|
||||
this.stream.destroy()
|
||||
fileStreamStream.destroy()
|
||||
}
|
||||
})
|
||||
this.stream.once('subtitle', () => {
|
||||
resolve()
|
||||
fileStreamStream.destroy()
|
||||
})
|
||||
const fileStreamStream = file.createReadStream()
|
||||
fileStreamStream.pipe(this.stream)
|
||||
})
|
||||
}
|
||||
|
||||
handleSubtitleParser (parser, skipFile) {
|
||||
parser.once('tracks', tracks => {
|
||||
if (!tracks.length) {
|
||||
this.parsed = true
|
||||
parser?.destroy()
|
||||
} else {
|
||||
for (const track of tracks) {
|
||||
if (!this.tracks[track.number]) {
|
||||
// overwrite webvtt or other header with custom one
|
||||
if (track.type !== 'ass') track.header = defaultHeader
|
||||
if (!this.current) {
|
||||
this.current = track.number
|
||||
}
|
||||
this.tracks[track.number] = new Set()
|
||||
this.headers[track.number] = track
|
||||
this.onHeader()
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
parser.on('subtitle', (subtitle, trackNumber) => {
|
||||
if (!this.parsed) {
|
||||
if (!this.renderer) this.initSubtitleRenderer()
|
||||
this.tracks[trackNumber].add(this.constructor.constructSub(subtitle, this.headers[trackNumber].type !== 'ass'))
|
||||
if (this.current === trackNumber) this.selectCaptions(trackNumber) // yucky
|
||||
}
|
||||
})
|
||||
if (!skipFile) {
|
||||
parser.on('file', file => {
|
||||
if (file.mimetype === 'application/x-truetype-font' || file.mimetype === 'application/font-woff' || file.mimetype === 'application/vnd.ms-opentype') {
|
||||
this.fonts.push(URL.createObjectURL(new Blob([file.data], { type: file.mimetype })))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
selectCaptions (trackNumber) {
|
||||
if (trackNumber !== undefined) {
|
||||
this.current = Number(trackNumber)
|
||||
this.onHeader()
|
||||
if (!this.timeout) {
|
||||
this.timeout = setTimeout(() => {
|
||||
this.timeout = undefined
|
||||
if (this.renderer && this.headers) this.renderer.setTrack(this.current !== -1 ? this.headers[this.current].header.slice(0, -1) + Array.from(this.tracks[this.current]).join('\n') : defaultHeader)
|
||||
}, 1000)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
destroy () {
|
||||
this.stream?.destroy()
|
||||
this.parser?.destroy()
|
||||
this.renderer?.destroy()
|
||||
this.files = null
|
||||
this.video = null
|
||||
this.selected = null
|
||||
this.tracks = null
|
||||
this.headers = null
|
||||
this.onHeader()
|
||||
this.fonts?.forEach(file => URL.revokeObjectURL(file))
|
||||
}
|
||||
}
|
||||
|
|
@ -1,8 +1,32 @@
|
|||
const WebTorrent = require('webtorrent')
|
||||
import WebTorrent from 'webtorrent'
|
||||
export const client = new WebTorrent()
|
||||
window.client = client
|
||||
// save loaded torrent for persistence
|
||||
|
||||
const scope = location.pathname.substr(0, location.pathname.lastIndexOf('/') + 1)
|
||||
const worker = location.origin + scope + 'sw.js' === navigator.serviceWorker?.controller?.scriptURL && navigator.serviceWorker.controller
|
||||
const handleWorker = worker => {
|
||||
const checkState = worker => {
|
||||
return worker.state === 'activated' && client.loadWorker(worker)
|
||||
}
|
||||
if (!checkState(worker)) {
|
||||
worker.addEventListener('statechange', ({ target }) => checkState(target))
|
||||
}
|
||||
}
|
||||
if (worker) {
|
||||
handleWorker(worker)
|
||||
} else {
|
||||
navigator.serviceWorker.register('sw.js', { scope }).then(reg => {
|
||||
handleWorker(reg.active || reg.waiting || reg.installing)
|
||||
}).catch(e => {
|
||||
if (String(e) === 'InvalidStateError: Failed to register a ServiceWorker: The document is in an invalid state.') {
|
||||
location.reload() // weird workaround for a weird bug
|
||||
} else {
|
||||
throw e
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function add (torrentID) {
|
||||
if (torrentID) {
|
||||
if (client.torrents.length) client.remove(client.torrents[0].infoHash)
|
||||
|
|
|
|||
|
|
@ -10,8 +10,36 @@ export function countdown (s) {
|
|||
if (d || h) tmp.push(h + 'h')
|
||||
if (d || h || m) tmp.push(m + 'm')
|
||||
return tmp.join(' ')
|
||||
|
||||
}
|
||||
|
||||
export const DOMPARSER = DOMParser.prototype.parseFromString.bind(new DOMParser())
|
||||
|
||||
export const sleep = t => new Promise(resolve => setTimeout(resolve, t))
|
||||
|
||||
export const videoExtensions = ['3g2', '3gp', 'asf', 'avi', 'dv', 'flv', 'gxf', 'm2ts', 'm4a', 'm4b', 'm4p', 'm4r', 'm4v', 'mkv', 'mov', 'mp4', 'mpd', 'mpeg', 'mpg', 'mxf', 'nut', 'ogm', 'ogv', 'swf', 'ts', 'vob', 'webm', 'wmv', 'wtv']
|
||||
export const videoRx = new RegExp(`.(${videoExtensions.join('|')})$`, 'i')
|
||||
|
||||
export const subtitleExtensions = ['srt', 'vtt', 'ass', 'ssa', 'sub', 'txt']
|
||||
export const subRx = new RegExp(`.(${subtitleExtensions.join('|')})$`, 'i')
|
||||
|
||||
export function toTS (sec, full) {
|
||||
if (isNaN(sec) || sec < 0) {
|
||||
switch (full) {
|
||||
case 1:
|
||||
return '0:00:00.00'
|
||||
case 2:
|
||||
return '0:00:00'
|
||||
case 3:
|
||||
return '00:00'
|
||||
default:
|
||||
return '0:00'
|
||||
}
|
||||
}
|
||||
const hours = Math.floor(sec / 3600)
|
||||
let minutes = Math.floor(sec / 60) - hours * 60
|
||||
let seconds = full === 1 ? (sec % 60).toFixed(2) : Math.floor(sec % 60)
|
||||
if (minutes < 10 && (hours > 0 || full)) minutes = '0' + minutes
|
||||
if (seconds < 10) seconds = '0' + seconds
|
||||
return (hours > 0 || full === 1 || full === 2) ? hours + ':' + minutes + ':' + seconds : minutes + ':' + seconds
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import path from 'path'
|
||||
import process from 'process'
|
||||
import { defineConfig } from 'vite'
|
||||
// import builtinModules from 'builtin-modules'
|
||||
import { svelte } from '@sveltejs/vite-plugin-svelte'
|
||||
import commonjsExternals from 'vite-plugin-commonjs-externals'
|
||||
|
||||
|
|
@ -12,18 +11,17 @@ const commonjsPackages = [
|
|||
// 'electron/renderer',
|
||||
// 'original-fs',
|
||||
'webtorrent',
|
||||
'buffer'
|
||||
// ...builtinModules
|
||||
'matroska-subtitles'
|
||||
]
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [svelte(), commonjsExternals({ externals: commonjsPackages })],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve('src/renderer/src')
|
||||
}
|
||||
},
|
||||
plugins: [commonjsExternals({ externals: commonjsPackages }), svelte()],
|
||||
root: path.resolve(process.cwd(), 'src/renderer'),
|
||||
base: './'
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in a new issue