Add subtitle support (via drag-n-drop) (#361)
* issue template * cleanup closePlayer() and stopServer() * Add subtitle support (via drag-n-drop) Drag and drop a subtitles file (.srt or .vtt) onto the player (or the app icon on OS X) to add subtitles to the currently playing video. For #281 * add multiple subtitles structure * add open subtitle dialog from cc player controls
This commit is contained in:
2
.github/ISSUE_TEMPLATE.md
vendored
2
.github/ISSUE_TEMPLATE.md
vendored
@@ -1,4 +1,4 @@
|
|||||||
**What version of WebTorrent Desktop?**
|
**What version of WebTorrent Desktop?** (See the 'About WebTorrent' menu)
|
||||||
|
|
||||||
**What operating system and version?**
|
**What operating system and version?**
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,7 @@
|
|||||||
"application-config-path": "^0.1.0",
|
"application-config-path": "^0.1.0",
|
||||||
"bitfield": "^1.0.2",
|
"bitfield": "^1.0.2",
|
||||||
"chromecasts": "^1.8.0",
|
"chromecasts": "^1.8.0",
|
||||||
|
"concat-stream": "^1.5.1",
|
||||||
"create-torrent": "^3.22.1",
|
"create-torrent": "^3.22.1",
|
||||||
"deep-equal": "^1.0.1",
|
"deep-equal": "^1.0.1",
|
||||||
"dlnacasts": "^0.0.3",
|
"dlnacasts": "^0.0.3",
|
||||||
@@ -34,6 +35,7 @@
|
|||||||
"prettier-bytes": "^1.0.1",
|
"prettier-bytes": "^1.0.1",
|
||||||
"rimraf": "^2.5.2",
|
"rimraf": "^2.5.2",
|
||||||
"simple-get": "^2.0.0",
|
"simple-get": "^2.0.0",
|
||||||
|
"srt-to-vtt": "^1.0.3",
|
||||||
"upload-element": "^1.0.1",
|
"upload-element": "^1.0.1",
|
||||||
"virtual-dom": "^2.1.1",
|
"virtual-dom": "^2.1.1",
|
||||||
"webtorrent": "^0.90.0",
|
"webtorrent": "^0.90.0",
|
||||||
|
|||||||
@@ -677,6 +677,7 @@ body.drag .app::after {
|
|||||||
|
|
||||||
.player-controls .device,
|
.player-controls .device,
|
||||||
.player-controls .fullscreen,
|
.player-controls .fullscreen,
|
||||||
|
.player-controls .closed-captions,
|
||||||
.player-controls .volume-icon,
|
.player-controls .volume-icon,
|
||||||
.player-controls .back {
|
.player-controls .back {
|
||||||
display: block;
|
display: block;
|
||||||
@@ -691,6 +692,7 @@ body.drag .app::after {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.player-controls .device,
|
.player-controls .device,
|
||||||
|
.player-controls .closed-captions,
|
||||||
.player-controls .fullscreen {
|
.player-controls .fullscreen {
|
||||||
float: right;
|
float: right;
|
||||||
}
|
}
|
||||||
@@ -752,6 +754,15 @@ body.drag .app::after {
|
|||||||
height: 14px;
|
height: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
::cue {
|
||||||
|
background: none;
|
||||||
|
color: #FFF;
|
||||||
|
font: 24px;
|
||||||
|
line-height: 1.3em;
|
||||||
|
text-shadow: #000 -1px 0 1px, #000 1px 0 1px, #000 0 -1px 1px, #000 0 1px 1px, rgba(50, 50, 50, 0.5) 2px 2px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* CHROMECAST / AIRPLAY CONTROLS
|
* CHROMECAST / AIRPLAY CONTROLS
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
console.time('init')
|
console.time('init')
|
||||||
|
|
||||||
var cfg = require('application-config')('WebTorrent')
|
var cfg = require('application-config')('WebTorrent')
|
||||||
|
var concat = require('concat-stream')
|
||||||
var dragDrop = require('drag-drop')
|
var dragDrop = require('drag-drop')
|
||||||
var electron = require('electron')
|
var electron = require('electron')
|
||||||
var EventEmitter = require('events')
|
var EventEmitter = require('events')
|
||||||
@@ -8,6 +9,7 @@ var fs = require('fs')
|
|||||||
var mainLoop = require('main-loop')
|
var mainLoop = require('main-loop')
|
||||||
var path = require('path')
|
var path = require('path')
|
||||||
var remote = require('remote')
|
var remote = require('remote')
|
||||||
|
var srtToVtt = require('srt-to-vtt')
|
||||||
|
|
||||||
var createElement = require('virtual-dom/create-element')
|
var createElement = require('virtual-dom/create-element')
|
||||||
var diff = require('virtual-dom/diff')
|
var diff = require('virtual-dom/diff')
|
||||||
@@ -255,6 +257,9 @@ function dispatch (action, ...args) {
|
|||||||
if (action === 'setVolume') {
|
if (action === 'setVolume') {
|
||||||
setVolume(args[0] /* increase */)
|
setVolume(args[0] /* increase */)
|
||||||
}
|
}
|
||||||
|
if (action === 'openSubtitles') {
|
||||||
|
openSubtitles()
|
||||||
|
}
|
||||||
if (action === 'mediaPlaying') {
|
if (action === 'mediaPlaying') {
|
||||||
state.playing.isPaused = false
|
state.playing.isPaused = false
|
||||||
ipcRenderer.send('blockPowerSave')
|
ipcRenderer.send('blockPowerSave')
|
||||||
@@ -331,7 +336,6 @@ function changeVolume (delta) {
|
|||||||
setVolume(state.playing.volume + delta)
|
setVolume(state.playing.volume + delta)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: never called. Either remove or make a volume control that calls it
|
|
||||||
function setVolume (volume) {
|
function setVolume (volume) {
|
||||||
// check if its in [0.0 - 1.0] range
|
// check if its in [0.0 - 1.0] range
|
||||||
volume = Math.max(0, Math.min(1, volume))
|
volume = Math.max(0, Math.min(1, volume))
|
||||||
@@ -342,6 +346,17 @@ function setVolume (volume) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openSubtitles () {
|
||||||
|
dialog.showOpenDialog({
|
||||||
|
title: 'Select a subtitles file.',
|
||||||
|
filters: [ { name: 'Subtitles', extensions: ['vtt', 'srt'] } ],
|
||||||
|
properties: [ 'openFile' ]
|
||||||
|
}, function (filenames) {
|
||||||
|
if (!Array.isArray(filenames)) return
|
||||||
|
addSubtitle({path: filenames[0]})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// Checks whether we are connected and already casting
|
// Checks whether we are connected and already casting
|
||||||
// Returns false if we not casting (state.playing.location === 'local')
|
// Returns false if we not casting (state.playing.location === 'local')
|
||||||
// or if we're trying to connect but haven't yet ('chromecast-pending', etc)
|
// or if we're trying to connect but haven't yet ('chromecast-pending', etc)
|
||||||
@@ -459,12 +474,35 @@ function onOpen (files) {
|
|||||||
if (!Array.isArray(files)) files = [ files ]
|
if (!Array.isArray(files)) files = [ files ]
|
||||||
|
|
||||||
// .torrent file = start downloading the torrent
|
// .torrent file = start downloading the torrent
|
||||||
files.filter(isTorrent).forEach(function (torrentFile) {
|
files.filter(isTorrent).forEach(addTorrent)
|
||||||
addTorrent(torrentFile)
|
|
||||||
})
|
// subtitle file
|
||||||
|
files.filter(isSubtitle).forEach(addSubtitle)
|
||||||
|
|
||||||
// everything else = seed these files
|
// everything else = seed these files
|
||||||
createTorrentFromFileObjects(files.filter(isNotTorrent))
|
var rest = files.filter(not(isTorrent)).filter(not(isSubtitle))
|
||||||
|
if (rest.length > 0) {
|
||||||
|
createTorrentFromFileObjects(rest)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isTorrent (file) {
|
||||||
|
var name = typeof file === 'string' ? file : file.name
|
||||||
|
var isTorrentFile = path.extname(name).toLowerCase() === '.torrent'
|
||||||
|
var isMagnet = typeof file === 'string' && /^magnet:/.test(file)
|
||||||
|
return isTorrentFile || isMagnet
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSubtitle (file) {
|
||||||
|
var name = typeof file === 'string' ? file : file.name
|
||||||
|
var ext = path.extname(name).toLowerCase()
|
||||||
|
return ext === '.srt' || ext === '.vtt'
|
||||||
|
}
|
||||||
|
|
||||||
|
function not (test) {
|
||||||
|
return function (...args) {
|
||||||
|
return !test(...args)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function onPaste (e) {
|
function onPaste (e) {
|
||||||
@@ -478,17 +516,6 @@ function onPaste (e) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function isTorrent (file) {
|
|
||||||
var name = typeof file === 'string' ? file : file.name
|
|
||||||
var isTorrentFile = path.extname(name).toLowerCase() === '.torrent'
|
|
||||||
var isMagnet = typeof file === 'string' && /^magnet:/.test(file)
|
|
||||||
return isTorrentFile || isMagnet
|
|
||||||
}
|
|
||||||
|
|
||||||
function isNotTorrent (file) {
|
|
||||||
return !isTorrent(file)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Gets a torrent summary {name, infoHash, status} from state.saved.torrents
|
// Gets a torrent summary {name, infoHash, status} from state.saved.torrents
|
||||||
// Returns undefined if we don't know that infoHash
|
// Returns undefined if we don't know that infoHash
|
||||||
function getTorrentSummary (torrentKey) {
|
function getTorrentSummary (torrentKey) {
|
||||||
@@ -509,6 +536,23 @@ function addTorrent (torrentId) {
|
|||||||
ipcRenderer.send('wt-start-torrenting', torrentKey, torrentId, path)
|
ipcRenderer.send('wt-start-torrenting', torrentKey, torrentId, path)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function addSubtitle (file) {
|
||||||
|
if (state.playing.type !== 'video') return
|
||||||
|
fs.createReadStream(file.path || file).pipe(srtToVtt()).pipe(concat(function (buf) {
|
||||||
|
// Set the cue text position so it appears above the player controls.
|
||||||
|
// The only way to change cue text position is by modifying the VTT. It is not
|
||||||
|
// possible via CSS.
|
||||||
|
var subtitles = Buffer(buf.toString().replace(/(-->.*)/g, '$1 line:88%'))
|
||||||
|
var track = {
|
||||||
|
buffer: 'data:text/vtt;base64,' + subtitles.toString('base64'),
|
||||||
|
language: 'Language ' + state.playing.subtitles.tracks.length,
|
||||||
|
selected: true
|
||||||
|
}
|
||||||
|
state.playing.subtitles.tracks.push(track)
|
||||||
|
state.playing.subtitles.enabled = true
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
// Starts downloading and/or seeding a given torrentSummary. Returns WebTorrent object
|
// Starts downloading and/or seeding a given torrentSummary. Returns WebTorrent object
|
||||||
function startTorrentingSummary (torrentSummary) {
|
function startTorrentingSummary (torrentSummary) {
|
||||||
var s = torrentSummary
|
var s = torrentSummary
|
||||||
@@ -744,12 +788,6 @@ function pickFileToPlay (files) {
|
|||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
function stopServer () {
|
|
||||||
ipcRenderer.send('wt-stop-server')
|
|
||||||
state.playing = State.getDefaultPlayState()
|
|
||||||
state.server = null
|
|
||||||
}
|
|
||||||
|
|
||||||
// Opens the video player
|
// Opens the video player
|
||||||
function openPlayer (infoHash, index, cb) {
|
function openPlayer (infoHash, index, cb) {
|
||||||
var torrentSummary = getTorrentSummary(infoHash)
|
var torrentSummary = getTorrentSummary(infoHash)
|
||||||
@@ -817,23 +855,23 @@ function openPlayerFromActiveTorrent (torrentSummary, index, timeout, cb) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function closePlayer (cb) {
|
function closePlayer (cb) {
|
||||||
state.window.title = config.APP_WINDOW_TITLE
|
|
||||||
update() /* needed for OSX: toggleFullScreen animation w/ correct title */
|
|
||||||
|
|
||||||
if (isCasting()) {
|
if (isCasting()) {
|
||||||
Cast.close()
|
Cast.close()
|
||||||
}
|
}
|
||||||
|
state.window.title = config.APP_WINDOW_TITLE
|
||||||
|
state.playing = State.getDefaultPlayState()
|
||||||
|
state.server = null
|
||||||
|
|
||||||
if (state.window.isFullScreen) {
|
if (state.window.isFullScreen) {
|
||||||
dispatch('toggleFullScreen', false)
|
dispatch('toggleFullScreen', false)
|
||||||
}
|
}
|
||||||
restoreBounds()
|
restoreBounds()
|
||||||
stopServer()
|
|
||||||
update()
|
|
||||||
|
|
||||||
|
ipcRenderer.send('wt-stop-server')
|
||||||
ipcRenderer.send('unblockPowerSave')
|
ipcRenderer.send('unblockPowerSave')
|
||||||
ipcRenderer.send('onPlayerClose')
|
ipcRenderer.send('onPlayerClose')
|
||||||
|
|
||||||
|
update()
|
||||||
cb()
|
cb()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -70,7 +70,11 @@ function getDefaultPlayState () {
|
|||||||
isPaused: true,
|
isPaused: true,
|
||||||
isStalled: false,
|
isStalled: false,
|
||||||
lastTimeUpdate: 0, /* Unix time in ms */
|
lastTimeUpdate: 0, /* Unix time in ms */
|
||||||
mouseStationarySince: 0 /* Unix time in ms */
|
mouseStationarySince: 0, /* Unix time in ms */
|
||||||
|
subtitles: {
|
||||||
|
tracks: [], /* subtitles file (Buffer) */
|
||||||
|
enabled: false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -54,6 +54,23 @@ function renderMedia (state) {
|
|||||||
state.playing.volume = mediaElement.volume
|
state.playing.volume = mediaElement.volume
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Add subtitles to the <video> tag
|
||||||
|
var trackTags = []
|
||||||
|
|
||||||
|
if (state.playing.subtitles.enabled && state.playing.subtitles.tracks.length > 0) {
|
||||||
|
for (var i = 0; i < state.playing.subtitles.tracks.length; i++) {
|
||||||
|
var track = state.playing.subtitles.tracks[i]
|
||||||
|
|
||||||
|
trackTags.push(hx`
|
||||||
|
<track
|
||||||
|
default=${track.selected ? 'default' : ''}
|
||||||
|
label=${track.language}
|
||||||
|
type='subtitles'
|
||||||
|
src=${track.buffer}>
|
||||||
|
`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Create the <audio> or <video> tag
|
// Create the <audio> or <video> tag
|
||||||
var mediaTag = hx`
|
var mediaTag = hx`
|
||||||
<div
|
<div
|
||||||
@@ -66,9 +83,10 @@ function renderMedia (state) {
|
|||||||
onstalling=${dispatcher('mediaStalled')}
|
onstalling=${dispatcher('mediaStalled')}
|
||||||
ontimeupdate=${dispatcher('mediaTimeUpdate')}
|
ontimeupdate=${dispatcher('mediaTimeUpdate')}
|
||||||
autoplay>
|
autoplay>
|
||||||
|
${trackTags}
|
||||||
</div>
|
</div>
|
||||||
`
|
`
|
||||||
mediaTag.tagName = mediaType
|
mediaTag.tagName = mediaType // conditional tag name
|
||||||
|
|
||||||
// Show the media.
|
// Show the media.
|
||||||
return hx`
|
return hx`
|
||||||
@@ -236,6 +254,15 @@ function renderPlayerControls (state) {
|
|||||||
`
|
`
|
||||||
]
|
]
|
||||||
|
|
||||||
|
// show closed captions icon
|
||||||
|
elements.push(hx`
|
||||||
|
<i.icon.closed-captions
|
||||||
|
class=${state.playing.subtitles.enabled ? 'active' : 'disabled'}
|
||||||
|
onclick=${handleSubtitles}>
|
||||||
|
closed_captions
|
||||||
|
</i>
|
||||||
|
`)
|
||||||
|
|
||||||
// If we've detected a Chromecast or AppleTV, the user can play video there
|
// If we've detected a Chromecast or AppleTV, the user can play video there
|
||||||
var isOnChromecast = state.playing.location.startsWith('chromecast')
|
var isOnChromecast = state.playing.location.startsWith('chromecast')
|
||||||
var isOnAirplay = state.playing.location.startsWith('airplay')
|
var isOnAirplay = state.playing.location.startsWith('airplay')
|
||||||
@@ -385,6 +412,18 @@ function renderPlayerControls (state) {
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleSubtitles (e) {
|
||||||
|
if (!state.playing.subtitles.tracks.length) {
|
||||||
|
// if no subtitles available select it
|
||||||
|
dispatch('openSubtitles')
|
||||||
|
} else {
|
||||||
|
// TODO: Show subtitles selector / disable
|
||||||
|
// dispatch('showSubtitlesMenu')
|
||||||
|
// meanwhile, just enable/disable
|
||||||
|
state.playing.subtitles.enabled = !state.playing.subtitles.enabled
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// lets scrub without sending to volume backend
|
// lets scrub without sending to volume backend
|
||||||
|
|||||||
Reference in New Issue
Block a user