Compare commits

..

36 Commits

Author SHA1 Message Date
DC
0a15db2892 0.6.0 2016-05-24 03:05:31 -07:00
DC
63dda10380 changelog 2016-05-24 03:03:42 -07:00
DC
6e651df083 authors 2016-05-24 02:42:29 -07:00
DC
3a8fe24eec Fix scrub cursor 2016-05-24 02:39:28 -07:00
DC
918a35e091 Fix scrub button position 2016-05-24 02:09:17 -07:00
DC
c76abeb8c0 Remove cursor:pointer 2016-05-24 01:59:51 -07:00
DC
d389b8ab38 Bugfix: window title shouldn't be stuck on 'Preferences' 2016-05-24 01:56:39 -07:00
DC
a59faacbd7 Simplify prefs window 2016-05-24 01:52:31 -07:00
grunjol
12f9709601 Add preferences page
* For now, the prefs page has just a single option, Downloads Folder
* For now, you can't type in a folder, you must use the chooser
* Further fixes coming om master
* Written by @ChrisMorrisOrg and @grunjol, rebased by @dcposch
2016-05-23 22:31:09 -07:00
Feross Aboukhadijeh
455c9c02b9 Reduce startup jank, improve startup time (#568)
* Reduce jank on app startup

This feels a lot better on my 12" macbook (underpowered machine)

* Defer loading iso-639-1 and simple-concat
2016-05-23 22:12:04 -07:00
Feross Aboukhadijeh
1b49c6568b Cleanup unsupported codec detection (pt 2) (#570) 2016-05-23 22:03:38 -07:00
Feross Aboukhadijeh
30e81c7699 Cleanup for PR #571 2016-05-23 15:15:52 -07:00
Feross Aboukhadijeh
2dafc68301 Merge pull request #571 from Lurk/242
#242 add sort by file name
2016-05-23 15:08:31 -07:00
Feross Aboukhadijeh
c310222af2 Merge pull request #566 from feross/ui-improvements
More consistent controls
2016-05-23 14:49:49 -07:00
Feross Aboukhadijeh
b4bb9a6603 Fix rate UX for new design 2016-05-23 14:47:02 -07:00
Feross Aboukhadijeh
279c621d23 More consistent controls, delete verbose css 2016-05-23 14:16:51 -07:00
DC
eb11dbdcbd Fix error handling in dispatch('play') 2016-05-23 06:17:17 -07:00
DC
8dfdb34d31 Bugfix: default file to play on default torrents 2016-05-23 05:42:37 -07:00
Sergey Bargamon
fc9a73d67f #242 add sort by file name 2016-05-23 10:43:55 +03:00
DC
4b5b84a0fc Resume playback from saved position, even if we had to restart the torrent 2016-05-23 00:33:17 -07:00
DC
327c95d754 Show video position on circular progress bars 2016-05-23 00:33:17 -07:00
DC
6e969e5d07 Cleaner look for the torrent file list 2016-05-23 00:33:17 -07:00
DC
ca7c872420 Save video position 2016-05-23 00:33:17 -07:00
Sergey Bargamon
8af4f42c42 Add additional video player keyboard shortcuts (#275)
*  Skip forward 10 seconds ((CMD OR CTRL) ALT right)
 Skip back 10 seconds ((CMD OR CTRL) ALT left)
 Increase video speed ((CMD OR CTRL) +)
 Decrease video speed ((CMD OR CTRL) -)

* Codestyle fix

* The 'steps' should be implemented in base2, standard players use 1x, 2x, 4x, 8x, 16x

fixed bug with shift + "=" which is "+"

* resolve conflicts

* remove ide specific data
make playback rate more granular
add to menu skip and speed entries

* intendation fix

* conflict resolve

* rename setPlaybackRate to changePlaybackRate
setRate return boolean depending on whether this cast target supports setting the playback rate.
if setRate returns false - don`t change state
redundant else if statement in changePlaybackRate function
2016-05-23 00:15:57 -07:00
Feross Aboukhadijeh
ffce76a9b1 Cleanup unsupported codec detection (#569)
Review: @dcposch
2016-05-22 23:35:29 -07:00
Feross Aboukhadijeh
fca1d9dae4 Fix Uncaught TypeError: Cannot read property 'update' of undefined (#567)
Closes #539.
2016-05-22 23:20:30 -07:00
Feross Aboukhadijeh
eba09430e3 Merge pull request #563 from mathiasvr/patch
Handle unsupported video codec (e.g. H.265)
2016-05-22 23:13:50 -07:00
Feross Aboukhadijeh
6bc8de7625 Merge pull request #562 from demoneaux/video-progress
Add support for video progress time in player controls.
2016-05-22 19:51:59 -07:00
Benjamin Tan
8a08ed8538 Add support for video progress time in player controls.
Closes #351.
2016-05-23 10:39:58 +08:00
Mathias Rasmussen
56d802f741 Handle unsupported video codec (e.g. H.265) 2016-05-22 23:23:45 +02:00
Benjamin Tan
f7b46336fd Use poster.jpg file as the poster image if available. (#558)
Closes #501.
2016-05-22 02:14:48 -07:00
Feross Aboukhadijeh
510187c2ae electron-prebuilt@1.1.1 (#555) 2016-05-21 17:00:46 -07:00
Feross Aboukhadijeh
ff6ff8db00 Fewer click handlers (#552)
One more bit of cleanup for PR #529.

We can register `handleClick` once on the `<tr>` tag and just have the
onclick handler on `col-select` too. Because dispatcher calls
`event.stopPropagation()` we don't need to worry about event bubbling
up to the `<tr>`.
2016-05-21 16:50:18 -07:00
Feross Aboukhadijeh
014017604d Improve subtitle positioning (#551)
Before this commit, we tweaked the subtitle position by modifying the
VTT file, line by line with a regex because I did not know it was
possible to use CSS for it.

But apparently there are Shadow DOM elements that we can use instead.

This new approach improves:

- Wrapping long lines. Before, the text would go off the edge of the
screen. Now it wraps intelligently.

- The subtitles move up to get out of the way of the controls when
those are visible.
2016-05-21 16:49:30 -07:00
Feross Aboukhadijeh
8cf544d54f Associate .torrent files to WebTorrent Desktop (OS X) (#553)
Before this change, .torrent files would only be associated to
WebTorrent Desktop if another torrent client (like Transmission) was
installed on the system.

That's because one of the user's apps needs to define
"UTExportedTypeDeclarations".

On a fresh machine, without Transmission, WebTorrent Desktop now
associates .torrent files correctly.

So it will appear in the "Open With..." menu and the webtorrent
.torrent icon will be used for .torrent files.

Closes #542.
2016-05-21 16:47:57 -07:00
Benjamin Tan
870dd893fc Add support for pasting a instant.io link. (#559)
Closes #547.
2016-05-21 16:23:16 -07:00
19 changed files with 946 additions and 301 deletions

2
.gitignore vendored
View File

@@ -1,2 +1,2 @@
node_modules node_modules
dist dist

View File

@@ -18,5 +18,8 @@
- Karlo Luis Martinez Martos <karlo.luis.m@gmail.com> - Karlo Luis Martinez Martos <karlo.luis.m@gmail.com>
- gabriel <furstenheim@gmail.com> - gabriel <furstenheim@gmail.com>
- Rolando Guedes <rolando.guedes@3gnt.net> - Rolando Guedes <rolando.guedes@3gnt.net>
- Benjamin Tan <demoneaux@gmail.com>
- Mathias Rasmussen <mathiasvr@gmail.com>
- Sergey Bargamon <sergey@bargamon.ru>
#### Generated by bin/update-authors.sh. #### Generated by bin/update-authors.sh.

View File

@@ -1,5 +1,34 @@
# WebTorrent Desktop Version History # WebTorrent Desktop Version History
## v0.6.0 - 2016-05-24
### Added
- Added Preferences page
- Save video position, resume playback from saved position
- Add additional video player keyboard shortcuts (#275)
- Use `poster.jpg` file as the poster image if available (#558)
- Associate .torrent files to WebTorrent Desktop (OS X) (#553)
- Add support for pasting a `instant.io` links (#559)
- Add announcement feature
### Changed
- Nicer player UI
- Reduce startup jank, improve startup time (#568)
- Cleanup unsupported codec detection (#569, #570)
- Cleaner look for the torrent file list
- Improve subtitle positioning (#551)
### Fixed
- Fix Uncaught TypeError: Cannot read property 'update' of undefined (#567)
- Fix bugs in LocationHistory
- When player is active, and magnet link is pasted, go back to list
- After deleting torrent, remove just the player from forward stack
- After creating torrent, remove create torrent page from forward stack
- Cancel button on create torrent page should only go back one page
## v0.5.1 - 2016-05-18 ## v0.5.1 - 2016-05-18
### Fixed ### Fixed

View File

@@ -103,7 +103,7 @@ var all = {
var darwin = { var darwin = {
// Build for OS X // Build for OS X
platform: 'mas', platform: 'darwin',
// Build 64 bit binaries only. // Build 64 bit binaries only.
arch: 'x64', arch: 'x64',
@@ -182,8 +182,6 @@ function buildDarwin (cb) {
var infoPlistPath = path.join(contentsPath, 'Info.plist') var infoPlistPath = path.join(contentsPath, 'Info.plist')
var infoPlist = plist.parse(fs.readFileSync(infoPlistPath, 'utf8')) var infoPlist = plist.parse(fs.readFileSync(infoPlistPath, 'utf8'))
// TODO: Use new `extend-info` and `extra-resource` opts to electron-packager,
// available as of v6.
infoPlist.CFBundleDocumentTypes = [ infoPlist.CFBundleDocumentTypes = [
{ {
CFBundleTypeExtensions: [ 'torrent' ], CFBundleTypeExtensions: [ 'torrent' ],
@@ -211,7 +209,24 @@ function buildDarwin (cb) {
} }
] ]
infoPlist.ElectronTeamID = '5MAMC8G3L8' infoPlist.UTExportedTypeDeclarations = [
{
UTTypeConformsTo: [
'public.data',
'public.item',
'com.bittorrent.torrent'
],
UTTypeDescription: 'BitTorrent Document',
UTTypeIconFile: path.basename(config.APP_FILE_ICON) + '.icns',
UTTypeIdentifier: 'org.bittorrent.torrent',
UTTypeReferenceURL: 'http://www.bittorrent.org/beps/bep_0000.html',
UTTypeTagSpecification: {
'com.apple.ostype': 'TORR',
'public.filename-extension': [ 'torrent' ],
'public.mime-type': 'application/x-bittorrent'
}
}
]
fs.writeFileSync(infoPlistPath, plist.build(infoPlist)) fs.writeFileSync(infoPlistPath, plist.build(infoPlist))
@@ -250,9 +265,8 @@ function buildDarwin (cb) {
*/ */
var signOpts = { var signOpts = {
app: appPath, app: appPath,
entitlements: path.join(config.STATIC_PATH, 'parent.entitlements'), platform: 'darwin',
'entitlements-inherit': path.join(config.STATIC_PATH, 'child.entitlements'), verbose: true
platform: 'mas'
} }
console.log('OS X: Signing app...') console.log('OS X: Signing app...')

View File

@@ -26,14 +26,14 @@ if (process.platform === 'win32') {
argv = argv.filter((arg) => arg.indexOf('--squirrel') === -1) argv = argv.filter((arg) => arg.indexOf('--squirrel') === -1)
} }
// if (!shouldQuit) { if (!shouldQuit) {
// // Prevent multiple instances of app from running at same time. New instances signal // Prevent multiple instances of app from running at same time. New instances signal
// // this instance and quit. // this instance and quit.
// shouldQuit = app.makeSingleInstance(onAppOpen) shouldQuit = app.makeSingleInstance(onAppOpen)
// if (shouldQuit) { if (shouldQuit) {
// app.quit() app.quit()
// } }
// } }
if (!shouldQuit) { if (!shouldQuit) {
init() init()

View File

@@ -92,6 +92,35 @@ function openSubtitles () {
} }
} }
function skipForward () {
if (windows.main) {
windows.main.send('dispatch', 'skip', 1)
}
}
function skipBack () {
if (windows.main) {
windows.main.send('dispatch', 'skip', -1)
}
}
function increasePlaybackRate () {
if (windows.main) {
windows.main.send('dispatch', 'changePlaybackRate', 1)
}
}
function decreasePlaybackRate () {
if (windows.main) {
windows.main.send('dispatch', 'changePlaybackRate', -1)
}
}
// Open the preferences window
function showPreferences () {
windows.main.send('dispatch', 'preferences')
}
function onWindowShow () { function onWindowShow () {
log('onWindowShow') log('onWindowShow')
getMenuItem('Full Screen').enabled = true getMenuItem('Full Screen').enabled = true
@@ -110,6 +139,10 @@ function onPlayerOpen () {
getMenuItem('Increase Volume').enabled = true getMenuItem('Increase Volume').enabled = true
getMenuItem('Decrease Volume').enabled = true getMenuItem('Decrease Volume').enabled = true
getMenuItem('Add Subtitles File...').enabled = true getMenuItem('Add Subtitles File...').enabled = true
getMenuItem('Step Forward').enabled = true
getMenuItem('Step Backward').enabled = true
getMenuItem('Increase Speed').enabled = true
getMenuItem('Decrease Speed').enabled = true
} }
function onPlayerClose () { function onPlayerClose () {
@@ -118,6 +151,10 @@ function onPlayerClose () {
getMenuItem('Increase Volume').enabled = false getMenuItem('Increase Volume').enabled = false
getMenuItem('Decrease Volume').enabled = false getMenuItem('Decrease Volume').enabled = false
getMenuItem('Add Subtitles File...').enabled = false getMenuItem('Add Subtitles File...').enabled = false
getMenuItem('Step Forward').enabled = false
getMenuItem('Step Backward').enabled = false
getMenuItem('Increase Speed').enabled = false
getMenuItem('Decrease Speed').enabled = false
} }
function onToggleFullScreen (isFullScreen) { function onToggleFullScreen (isFullScreen) {
@@ -237,6 +274,14 @@ function getAppMenuTemplate () {
label: 'Select All', label: 'Select All',
accelerator: 'CmdOrCtrl+A', accelerator: 'CmdOrCtrl+A',
role: 'selectall' role: 'selectall'
},
{
type: 'separator'
},
{
label: 'Preferences',
accelerator: 'CmdOrCtrl+,',
click: () => showPreferences()
} }
] ]
}, },
@@ -307,6 +352,36 @@ function getAppMenuTemplate () {
{ {
type: 'separator' type: 'separator'
}, },
{
label: 'Step Forward',
accelerator: 'CmdOrCtrl+Alt+Right',
click: skipForward,
enabled: false
},
{
label: 'Step Backward',
accelerator: 'CmdOrCtrl+Alt+Left',
click: skipBack,
enabled: false
},
{
type: 'separator'
},
{
label: 'Increase Speed',
accelerator: 'CmdOrCtrl+=',
click: increasePlaybackRate,
enabled: false
},
{
label: 'Decrease Speed',
accelerator: 'CmdOrCtrl+-',
click: decreasePlaybackRate,
enabled: false
},
{
type: 'separator'
},
{ {
label: 'Add Subtitles File...', label: 'Add Subtitles File...',
click: openSubtitles, click: openSubtitles,
@@ -349,6 +424,14 @@ function getAppMenuTemplate () {
{ {
type: 'separator' type: 'separator'
}, },
{
label: 'Preferences',
accelerator: 'Cmd+,',
click: () => showPreferences()
},
{
type: 'separator'
},
{ {
label: 'Services', label: 'Services',
role: 'services', role: 'services',

View File

@@ -1,7 +1,7 @@
{ {
"name": "webtorrent-desktop", "name": "webtorrent-desktop",
"description": "WebTorrent, the streaming torrent client. For OS X, Windows, and Linux.", "description": "WebTorrent, the streaming torrent client. For OS X, Windows, and Linux.",
"version": "0.5.1", "version": "0.6.0",
"author": { "author": {
"name": "WebTorrent, LLC", "name": "WebTorrent, LLC",
"email": "feross@feross.org", "email": "feross@feross.org",
@@ -39,7 +39,8 @@
"virtual-dom": "^2.1.1", "virtual-dom": "^2.1.1",
"vlc-command": "^1.0.1", "vlc-command": "^1.0.1",
"webtorrent": "0.x", "webtorrent": "0.x",
"winreg": "^1.2.0" "winreg": "^1.2.0",
"zero-fill": "^2.2.3"
}, },
"devDependencies": { "devDependencies": {
"cross-zip": "^2.0.1", "cross-zip": "^2.0.1",

View File

@@ -50,19 +50,22 @@ table {
} }
@keyframes fadein { @keyframes fadein {
from { opacity: 0; } from {
to { opacity: 1; } opacity: 0;
}
to {
opacity: 1;
}
} }
.app { .app {
-webkit-user-select: none; -webkit-user-select: none;
-webkit-app-region: drag; -webkit-app-region: drag;
height: 100%; height: 100%;
display: flex; display: flex;
flex-flow: column; flex-flow: column;
animation: fadein 0.3s;
background: rgb(40, 40, 40); background: rgb(40, 40, 40);
animation: fadein 1s;
} }
.app:not(.is-focused) { .app:not(.is-focused) {
@@ -94,11 +97,20 @@ table {
word-wrap: normal; word-wrap: normal;
white-space: nowrap; white-space: nowrap;
direction: ltr; direction: ltr;
opacity: 0.85;
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility; text-rendering: optimizeLegibility;
} }
.icon.disabled {
opacity: 0.3;
}
.icon:not(.disabled):hover {
opacity: 1;
}
/* /*
* UTILITY CLASSES * UTILITY CLASSES
*/ */
@@ -109,8 +121,8 @@ table {
white-space: nowrap; white-space: nowrap;
} }
.disabled { .float-left {
opacity: 0.3; float: left;
} }
.float-right { .float-right {
@@ -144,8 +156,8 @@ table {
.header { .header {
background: rgb(40, 40, 40); background: rgb(40, 40, 40);
border-bottom: 1px solid rgb(20, 20, 20); border-bottom: 1px solid rgb(20, 20, 20);
height: 37px; /* vertically center OS menu buttons (OS X) */ height: 38px; /* vertically center OS menu buttons (OS X) */
padding-top: 6px; padding-top: 7px;
overflow: hidden; overflow: hidden;
flex: 0 1 auto; flex: 0 1 auto;
opacity: 1; opacity: 1;
@@ -164,7 +176,13 @@ table {
} }
.app.view-player .header { .app.view-player .header {
opacity: 0.8; background: rgba(40, 40, 40, 0.8);
border-bottom: none;
}
.app.view-player.is-win32 .header,
.app.view-player.is-linux .header {
background: none;
} }
.app.hide-video-controls.view-player .header { .app.hide-video-controls.view-player .header {
@@ -172,12 +190,8 @@ table {
cursor: none; cursor: none;
} }
.app.hide-header .header {
display: none;
}
.header .title { .header .title {
opacity: 0.6; opacity: 0.7;
position: absolute; position: absolute;
margin-top: 1px; margin-top: 1px;
padding: 0 150px 0 150px; padding: 0 150px 0 150px;
@@ -188,35 +202,22 @@ table {
.header .nav { .header .nav {
font-weight: bold; font-weight: bold;
margin-right: 9px;
} }
.header .nav.left { .header .nav.left {
float: left; margin-left: 10px;
}
.header .nav.right {
margin-right: 10px;
} }
.app.is-darwin:not(.is-fullscreen) .header .nav.left { .app.is-darwin:not(.is-fullscreen) .header .nav.left {
margin-left: 78px; margin-left: 78px;
} }
.header .nav.right { .header .back,
float: right; .header .forward {
}
.header .nav * {
opacity: 0.6;
}
.header .nav .disabled {
opacity: 0.1;
}
.header .nav *:not(.disabled):hover {
opacity: 1;
}
.header .nav .back,
.header .nav .forward {
font-size: 30px; font-size: 30px;
margin-top: -3px; margin-top: -3px;
} }
@@ -535,6 +536,11 @@ input {
} }
} }
.torrent .buttons .play.resume-position {
position: relative;
-webkit-clip-path: circle(18px at center);
}
.torrent .buttons .delete { .torrent .buttons .delete {
opacity: 0.5; opacity: 0.5;
} }
@@ -543,6 +549,10 @@ input {
opacity: 0.7; opacity: 0.7;
} }
.torrent .buttons .radial-progress {
position: absolute;
}
.torrent .name { .torrent .name {
font-size: 18px; font-size: 18px;
font-weight: bold; font-weight: bold;
@@ -592,7 +602,7 @@ body.drag .app::after {
} }
.torrent-details { .torrent-details {
padding: 8em 12px 20px 20px; padding: 8em 0 20px 0;
} }
.torrent-details table { .torrent-details table {
@@ -617,7 +627,7 @@ body.drag .app::after {
.torrent-details td { .torrent-details td {
overflow: hidden; overflow: hidden;
padding: 0; padding: 0;
vertical-align: bottom; vertical-align: middle;
} }
.torrent-details td .icon { .torrent-details td .icon {
@@ -627,7 +637,14 @@ body.drag .app::after {
} }
.torrent-details td.col-icon { .torrent-details td.col-icon {
width: 2em; width: 3em;
padding-left: 16px;
}
.torrent-details td.col-icon .radial-progress {
position: absolute;
margin-top: 4px;
margin-left: 0.5px;
} }
.torrent-details td.col-name { .torrent-details td.col-name {
@@ -646,7 +663,8 @@ body.drag .app::after {
} }
.torrent-details td.col-select { .torrent-details td.col-select {
width: 2em; width: 3em;
padding-right: 13px;
text-align: right; text-align: right;
} }
@@ -678,7 +696,7 @@ body.drag .app::after {
* PLAYER CONTROLS * PLAYER CONTROLS
*/ */
.player-controls { .player .controls {
position: fixed; position: fixed;
background: rgba(40, 40, 40, 0.8); background: rgba(40, 40, 40, 0.8);
width: 100%; width: 100%;
@@ -687,7 +705,63 @@ body.drag .app::after {
transition: opacity 0.15s ease-out; transition: opacity 0.15s ease-out;
} }
.app.hide-video-controls .player-controls { .player .controls .icon {
display: block;
margin: 8px;
font-size: 22px;
opacity: 0.85;
/*
* Fix for overflowing captions icon
* https://github.com/feross/webtorrent-desktop/issues/467
*/
max-width: 23px;
overflow: hidden;
}
.player .controls .icon:hover {
opacity: 1;
}
.player .controls .play-pause {
font-size: 28px;
margin-top: 5px;
margin-right: 10px;
margin-left: 15px;
}
.player .controls .volume-slider {
-webkit-appearance: none;
-webkit-app-region: no-drag;
width: 60px;
height: 3px;
margin: 18px 8px 8px 0;
border: none;
padding: 0;
opacity: 0.85;
vertical-align: sub;
}
.player .controls .time,
.player .controls .rate {
font-weight: 100;
font-size: 13px;
margin: 9px 8px 8px 8px;
opacity: 0.8;
}
.player .controls .icon.closed-captions {
font-size: 26px;
margin-top: 6px;
}
.player .controls .icon.fullscreen {
font-size: 26px;
margin-right: 15px;
margin-top: 6px;
}
.app.hide-video-controls .player .controls {
opacity: 0; opacity: 0;
} }
@@ -695,13 +769,16 @@ body.drag .app::after {
cursor: none; cursor: none;
} }
.app.hide-video-controls .player .player-controls:hover { /* TODO: find better way to handle this (that also
* keeps the header visible too).
*/
.app.hide-video-controls .player .controls:hover {
opacity: 1; opacity: 1;
cursor: default; cursor: default;
} }
/* invisible click target for scrubbing */ /* invisible click target for scrubbing */
.player-controls .scrub-bar { .player .controls .scrub-bar {
position: absolute; position: absolute;
width: 100%; width: 100%;
height: 23px; /* 3px .loading-bar plus 10px above and below */ height: 23px; /* 3px .loading-bar plus 10px above and below */
@@ -710,7 +787,7 @@ body.drag .app::after {
-webkit-app-region: no-drag; -webkit-app-region: no-drag;
} }
.player-controls .loading-bar { .player .controls .loading-bar {
position: relative; position: relative;
width: 100%; width: 100%;
top: -3px; top: -3px;
@@ -720,14 +797,14 @@ body.drag .app::after {
position: absolute; position: absolute;
} }
.player-controls .loading-bar-part { .player .controls .loading-bar-part {
position: absolute; position: absolute;
background-color: #dd0000; background-color: #dd0000;
top: 0; top: 0;
height: 100%; height: 100%;
} }
.player-controls .playback-cursor { .player .controls .playback-cursor {
position: absolute; position: absolute;
top: -3px; top: -3px;
background-color: #FFF; background-color: #FFF;
@@ -736,94 +813,26 @@ body.drag .app::after {
border-radius: 50%; border-radius: 50%;
margin-top: 0; margin-top: 0;
margin-left: 0; margin-left: 0;
transition-property: width, height, border-radius, margin-top, margin-left; transition-property: width, height, top, margin-left;
transition-duration: 0.1s; transition-duration: 0.1s;
transition-timing-function: ease-out; transition-timing-function: ease-out;
} }
.player-controls .play-pause { .player .controls .closed-captions.active,
display: block; .player .controls .device.active {
width: 30px;
height: 30px;
padding: 5px;
margin: 0 auto;
}
.player-controls .device,
.player-controls .fullscreen,
.player-controls .closed-captions,
.player-controls .volume-icon,
.player-controls .back {
display: block;
height: 20px;
margin: 5px;
/*
* Fix for overflowing captions icon
* https://github.com/feross/webtorrent-desktop/issues/467
*/
max-width: 22px;
overflow: hidden;
}
.player-controls .volume,
.player-controls .back {
float: left;
}
.player-controls .device,
.player-controls .closed-captions,
.player-controls .fullscreen {
float: right;
}
.player-controls .fullscreen {
margin-right: 15px;
}
.player-controls .volume-icon,
.player-controls .device {
font-size: 18px; /* make the cast icons less huge */
margin-top: 8px !important;
}
.player-controls .closed-captions.active,
.player-controls .device.active {
color: #9af; color: #9af;
} }
.player-controls .volume { .player .controls .volume-slider::-webkit-slider-thumb {
display: block;
width: 90px;
}
.player-controls .volume-icon {
float: left;
margin-right: 0px;
}
.player-controls .volume-slider {
-webkit-appearance: none; -webkit-appearance: none;
width: 50px;
height: 3px;
border: none;
padding: 0;
vertical-align: sub;
-webkit-app-region: no-drag; -webkit-app-region: no-drag;
}
.player-controls .volume-slider::-webkit-slider-thumb {
-webkit-appearance: none;
background-color: #fff; background-color: #fff;
opacity: 1.0; width: 13px;
width: 10px; height: 13px;
height: 10px;
border: 1px solid #303233;
border-radius: 50%; border-radius: 50%;
-webkit-app-region: no-drag;
} }
.player-controls .volume-slider:focus { .player .controls .volume-slider:focus {
outline: none; outline: none;
} }
@@ -833,19 +842,27 @@ body.drag .app::after {
.player .playback-bar:hover .playback-cursor { .player .playback-bar:hover .playback-cursor {
top: -8px; top: -8px;
margin-left: -5px;
width: 14px; width: 14px;
height: 14px; height: 14px;
} }
/**
* Set the cue text position so it appears above the player controls.
*/
video::-webkit-media-text-track-container {
bottom: 60px;
transition: bottom 0.1s ease-out;
}
.app.hide-video-controls video::-webkit-media-text-track-container {
bottom: 30px;
}
::cue { ::cue {
background: none; background: none;
color: #FFF; 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; 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
*/ */
@@ -895,6 +912,173 @@ body.drag .app::after {
margin-right: 4px !important; margin-right: 4px !important;
} }
/*
* Preferences page, based on Atom settings style
*/
.preferences {
font-size: 12px;
line-height: calc(10/7);
}
.preferences .text {
color: #a8a8a8;
}
.preferences .icon {
color: rgba(170, 170, 170, 0.6);
font-size: 16px;
margin-right: 0.2em;
}
.preferences .btn {
display: inline-block;
-webkit-appearance: button;
margin: 0;
font-weight: normal;
text-align: center;
vertical-align: middle;
border-color: #cccccc;
border-radius: 3px;
color: #9da5b4;
text-shadow: none;
border: 1px solid #181a1f;
background-color: #3d3d3d;
white-space: initial;
font-size: 0.889em;
line-height: 1;
padding: 0.5em 0.75em;
}
.preferences .btn .icon {
margin: 0;
color: #a8a8a8;
}
.preferences .help .icon {
vertical-align: sub;
}
.preferences .preferences-panel .control-group + .control-group {
margin-top: 1.5em;
}
.preferences .section {
padding: 20px;
border-top: 1px solid #181a1f;
}
.preferences .section:first {
border-top: 0px;
}
.preferences .section:first-child,
.preferences .section:last-child {
padding: 20px;
}
.preferences .section.section:empty {
padding: 0;
border-top: none;
}
.preferences .section-container {
width: 100%;
max-width: 800px;
}
.preferences section .section-heading,
.preferences .section .section-heading {
margin-bottom: 10px;
color: #dcdcdc;
font-size: 1.75em;
font-weight: bold;
line-height: 1;
-webkit-user-select: none;
cursor: default;
}
.preferences .sub-section-heading.icon:before,
.preferences .section-heading.icon:before {
margin-right: 8px;
}
.preferences .section-heading-count {
margin-left: .5em;
}
.preferences .section-body {
margin-top: 20px;
}
.preferences .sub-section {
margin: 20px 0;
}
.preferences .sub-section .sub-section-heading {
color: #dcdcdc;
font-size: 1.4em;
font-weight: bold;
line-height: 1;
-webkit-user-select: none;
}
.preferences .preferences-panel label {
color: #a8a8a8;
}
.preferences .preferences-panel .control-group + .control-group {
margin-top: 1.5em;
}
.preferences .preferences-panel .control-group .editor-container {
margin: 0;
}
.preferences .preference-title {
font-size: 1.2em;
-webkit-user-select: none;
display: inline-block;
}
.preferences .preference-description {
color: rgba(170, 170, 170, 0.6);
-webkit-user-select: none;
cursor: default;
}
.preferences input {
font-size: 1.1em;
line-height: 1.15em;
max-height: none;
width: 100%;
padding-left: 0.5em;
border-radius: 3px;
color: #a8a8a8;
border: 1px solid #181a1f;
background-color: #1b1d23;
}
.preferences input::-webkit-input-placeholder {
color: rgba(170, 170, 170, 0.6);
}
.preferences .control-group input {
margin-top: 0.2em;
}
.preferences .control-group input.file-picker-text {
width: calc(100% - 40px);
}
.preferences .control-group .checkbox .icon {
font-size: 1.5em;
margin: 0;
vertical-align: text-bottom;
}
/* /*
* MEDIA OVERLAY / AUDIO DETAILS * MEDIA OVERLAY / AUDIO DETAILS
*/ */
@@ -963,10 +1147,6 @@ body.drag .app::after {
z-index: 1; z-index: 1;
} }
.app.hide-header .error-popover {
top: 0px;
}
.error-popover.hidden { .error-popover.hidden {
display: none; display: none;
} }
@@ -994,3 +1174,66 @@ body.drag .app::after {
.error-text { .error-text {
color: #c44; color: #c44;
} }
/*
* RADIAL PROGRESS BAR
*/
.radial-progress {
width: 16px;
height: 16px;
border-radius: 50%;
background-color: #888;
}
.radial-progress .circle .mask,
.radial-progress .circle .fill {
width: 16px;
height: 16px;
position: absolute;
border-radius: 50%;
-webkit-backface-visibility: hidden;
}
.radial-progress .circle .mask {
clip: rect(0px, 16px, 16px, 8px);
}
.radial-progress .circle .fill {
clip: rect(0px, 8px, 16px, 0px);
background-color: white;
}
.radial-progress .inset {
position: absolute;
width: 12px;
height: 12px;
margin: 2px 0 0 2px;
border-radius: 50%;
background-color: black;
}
.radial-progress-large {
width: 40px;
height: 40px;
}
.radial-progress-large .circle .mask,
.radial-progress-large .circle .fill {
width: 40px;
height: 40px;
}
.radial-progress-large .circle .mask {
clip: rect(0px, 40px, 40px, 20px);
}
.radial-progress-large .circle .fill {
clip: rect(0px, 20px, 40px, 0px);
background-color: white;
}
.radial-progress-large .inset {
width: 32px;
height: 32px;
margin: 4px 0 0 4px;
}

View File

@@ -14,10 +14,8 @@ var ipcRenderer = electron.ipcRenderer
setupIpc() setupIpc()
var appConfig = require('application-config')('WebTorrent') var appConfig = require('application-config')('WebTorrent')
var concat = require('simple-concat')
var dragDrop = require('drag-drop') var dragDrop = require('drag-drop')
var fs = require('fs-extra') var fs = require('fs-extra')
var iso639 = require('iso-639-1')
var mainLoop = require('main-loop') var mainLoop = require('main-loop')
var parallel = require('run-parallel') var parallel = require('run-parallel')
var path = require('path') var path = require('path')
@@ -42,18 +40,29 @@ appConfig.filePath = path.join(config.CONFIG_PATH, 'config.json')
// This dependency is the slowest-loading, so we lazy load it // This dependency is the slowest-loading, so we lazy load it
var Cast = null var Cast = null
// For easy debugging in Developer Tools
var state = global.state = State.getInitialState()
// Push the first page into the location history
state.location.go({ url: 'home' })
var vdomLoop var vdomLoop
var state = State.getInitialState()
state.location.go({ url: 'home' }) // Add first page to location history
// All state lives in state.js. `state.saved` is read from and written to a file. // All state lives in state.js. `state.saved` is read from and written to a file.
// All other state is ephemeral. First we load state.saved then initialize the app. // All other state is ephemeral. First we load state.saved then initialize the app.
loadState(init) loadState(init)
function loadState (cb) {
appConfig.read(function (err, data) {
if (err) console.error(err)
// populate defaults if they're not there
state.saved = Object.assign({}, State.getDefaultSavedState(), data)
state.saved.torrents.forEach(function (torrentSummary) {
if (torrentSummary.displayName) torrentSummary.name = torrentSummary.displayName
})
if (cb) cb()
})
}
/** /**
* Called once when the application loads. (Not once per window.) * Called once when the application loads. (Not once per window.)
* Connects to the torrent networks, sets up the UI and OS integrations like * Connects to the torrent networks, sets up the UI and OS integrations like
@@ -183,7 +192,7 @@ function render (state) {
// Calls render() to go from state -> UI, then applies to vdom to the real DOM. // Calls render() to go from state -> UI, then applies to vdom to the real DOM.
function update () { function update () {
showOrHidePlayerControls() showOrHidePlayerControls()
vdomLoop.update(state) if (vdomLoop) vdomLoop.update(state)
updateElectron() updateElectron()
} }
@@ -273,18 +282,17 @@ function dispatch (action, ...args) {
playPause() playPause()
} }
if (action === 'play') { if (action === 'play') {
state.location.go({ playFile(args[0] /* infoHash */, args[1] /* index */)
url: 'player',
onbeforeload: function (cb) {
play()
openPlayer(args[0] /* infoHash */, args[1] /* index */, cb)
},
onbeforeunload: closePlayer
})
} }
if (action === 'playbackJump') { if (action === 'playbackJump') {
jumpToTime(args[0] /* seconds */) jumpToTime(args[0] /* seconds */)
} }
if (action === 'skip') {
jumpToTime(state.playing.currentTime + (args[0] /* direction */ * 10))
}
if (action === 'changePlaybackRate') {
changePlaybackRate(args[0] /* direction */)
}
if (action === 'changeVolume') { if (action === 'changeVolume') {
changeVolume(args[0] /* increase */) changeVolume(args[0] /* increase */)
} }
@@ -338,6 +346,26 @@ function dispatch (action, ...args) {
if (action === 'exitModal') { if (action === 'exitModal') {
state.modal = null state.modal = null
} }
if (action === 'preferences') {
state.location.go({
url: 'preferences',
onbeforeload: function (cb) {
// initialize preferences
state.window.title = 'Preferences'
state.unsaved = Object.assign(state.unsaved || {}, {prefs: state.saved.prefs || {}})
cb()
},
onbeforeunload: function (cb) {
// save state after preferences
savePreferences()
state.window.title = config.APP_WINDOW_TITLE
cb()
}
})
}
if (action === 'updatePreferences') {
updatePreferences(args[0], args[1] /* property, value */)
}
if (action === 'updateAvailable') { if (action === 'updateAvailable') {
updateAvailable(args[0] /* version */) updateAvailable(args[0] /* version */)
} }
@@ -399,7 +427,26 @@ function jumpToTime (time) {
state.playing.jumpToTime = time state.playing.jumpToTime = time
} }
} }
function changePlaybackRate (direction) {
var rate = state.playing.playbackRate
if (direction > 0 && rate >= 0.25 && rate < 2) {
rate += 0.25
} else if (direction < 0 && rate > 0.25 && rate <= 2) {
rate -= 0.25
} else if (direction < 0 && rate === 0.25) { /* when we set playback rate at 0 in html 5, playback hangs ;( */
rate = -1
} else if (direction > 0 && rate === -1) {
rate = 0.25
} else if ((direction > 0 && rate >= 1 && rate < 16) || (direction < 0 && rate > -16 && rate <= -1)) {
rate *= 2
} else if ((direction < 0 && rate > 1 && rate <= 16) || (direction > 0 && rate >= -16 && rate < -1)) {
rate /= 2
}
state.playing.playbackRate = rate
if (lazyLoadCast().isCasting() && !Cast.setRate(rate)) {
state.playing.playbackRate = 1
}
}
function changeVolume (delta) { function changeVolume (delta) {
// change volume with delta value // change volume with delta value
setVolume(state.playing.volume + delta) setVolume(state.playing.volume + delta)
@@ -489,22 +536,6 @@ function setupIpc () {
ipcRenderer.on('wt-server-running', (e, ...args) => torrentServerRunning(...args)) ipcRenderer.on('wt-server-running', (e, ...args) => torrentServerRunning(...args))
} }
// Load state.saved from the JSON state file
function loadState (cb) {
appConfig.read(function (err, data) {
if (err) console.error(err)
console.log('loaded state from ' + appConfig.filePath)
// populate defaults if they're not there
state.saved = Object.assign({}, State.getDefaultSavedState(), data)
state.saved.torrents.forEach(function (torrentSummary) {
if (torrentSummary.displayName) torrentSummary.name = torrentSummary.displayName
})
if (cb) cb()
})
}
// Starts all torrents that aren't paused on program startup // Starts all torrents that aren't paused on program startup
function resumeTorrents () { function resumeTorrents () {
state.saved.torrents state.saved.torrents
@@ -512,6 +543,27 @@ function resumeTorrents () {
.forEach((x) => startTorrentingSummary(x)) .forEach((x) => startTorrentingSummary(x))
} }
// Updates a single property in the UNSAVED prefs
// For example: updatePreferences("foo.bar", "baz")
// Call savePreferences to save to config.json
function updatePreferences (property, value) {
var path = property.split('.')
var key = state.unsaved.prefs
for (var i = 0; i < path.length - 1; i++) {
if (typeof key[path[i]] === 'undefined') {
key[path[i]] = {}
}
key = key[path[i]]
}
key[path[i]] = value
}
// All unsaved prefs take effect atomically, and are saved to config.json
function savePreferences () {
state.saved.prefs = Object.assign(state.saved.prefs || {}, state.unsaved.prefs)
saveState()
}
// Don't write state.saved to file more than once a second // Don't write state.saved to file more than once a second
function saveStateThrottled () { function saveStateThrottled () {
if (state.saveStateTimeout) return if (state.saveStateTimeout) return
@@ -537,7 +589,7 @@ function saveState () {
if (key === 'progress' || key === 'torrentKey') { if (key === 'progress' || key === 'torrentKey') {
continue // Don't save progress info or key for the webtorrent process continue // Don't save progress info or key for the webtorrent process
} }
if (key === 'playStatus' && x.playStatus !== 'unplayable') { if (key === 'playStatus') {
continue // Don't save whether a torrent is playing / pending continue // Don't save whether a torrent is playing / pending
} }
torrent[key] = x[key] torrent[key] = x[key]
@@ -605,14 +657,19 @@ function getTorrentSummary (torrentKey) {
// Adds a torrent to the list, starts downloading/seeding. TorrentID can be a // Adds a torrent to the list, starts downloading/seeding. TorrentID can be a
// magnet URI, infohash, or torrent file: https://github.com/feross/webtorrent#clientaddtorrentid-opts-function-ontorrent-torrent- // magnet URI, infohash, or torrent file: https://github.com/feross/webtorrent#clientaddtorrentid-opts-function-ontorrent-torrent-
var instantIoRegex = /^(https:\/\/)?instant\.io\/#/
function addTorrent (torrentId) { function addTorrent (torrentId) {
backToList() backToList()
var torrentKey = state.nextTorrentKey++ var torrentKey = state.nextTorrentKey++
var path = state.saved.downloadPath var path = state.saved.prefs.downloadPath
if (torrentId.path) { if (torrentId.path) {
// Use path string instead of W3C File object // Use path string instead of W3C File object
torrentId = torrentId.path torrentId = torrentId.path
} }
// Allow a instant.io link to be pasted
if (typeof torrentId === 'string' && instantIoRegex.test(torrentId)) {
torrentId = torrentId.slice(torrentId.indexOf('#') + 1)
}
ipcRenderer.send('wt-start-torrenting', torrentKey, torrentId, path) ipcRenderer.send('wt-start-torrenting', torrentKey, torrentId, path)
} }
@@ -647,8 +704,9 @@ function addSubtitles (files, autoSelect) {
} }
function loadSubtitle (file, cb) { function loadSubtitle (file, cb) {
var srtToVtt = require('srt-to-vtt') var concat = require('simple-concat')
var LanguageDetect = require('languagedetect') var LanguageDetect = require('languagedetect')
var srtToVtt = require('srt-to-vtt')
// Read the .SRT or .VTT file, parse it, add subtitle track // Read the .SRT or .VTT file, parse it, add subtitle track
var filePath = file.path || file var filePath = file.path || file
@@ -664,12 +722,8 @@ function loadSubtitle (file, cb) {
langDetected = langDetected.length ? langDetected[0][0] : 'subtitle' langDetected = langDetected.length ? langDetected[0][0] : 'subtitle'
langDetected = langDetected.slice(0, 1).toUpperCase() + langDetected.slice(1) langDetected = langDetected.slice(0, 1).toUpperCase() + langDetected.slice(1)
// 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 = { var track = {
buffer: 'data:text/vtt;base64,' + subtitles.toString('base64'), buffer: 'data:text/vtt;base64,' + buf.toString('base64'),
language: langDetected, language: langDetected,
label: langDetected, label: langDetected,
filePath: filePath filePath: filePath
@@ -686,6 +740,7 @@ function selectSubtitle (ix) {
// Checks whether a language name like "English" or "German" matches the system // Checks whether a language name like "English" or "German" matches the system
// language, aka the current locale // language, aka the current locale
function isSystemLanguage (language) { function isSystemLanguage (language) {
var iso639 = require('iso-639-1')
var osLangISO = window.navigator.language.split('-')[0] // eg "en" var osLangISO = window.navigator.language.split('-')[0] // eg "en"
var langIso = iso639.getCode(language) // eg "de" if language is "German" var langIso = iso639.getCode(language) // eg "de" if language is "German"
return langIso === osLangISO return langIso === osLangISO
@@ -728,7 +783,7 @@ function startTorrentingSummary (torrentSummary) {
if (!s.torrentKey) s.torrentKey = state.nextTorrentKey++ if (!s.torrentKey) s.torrentKey = state.nextTorrentKey++
// Use Downloads folder by default // Use Downloads folder by default
var path = s.path || state.saved.downloadPath var path = s.path || state.saved.prefs.downloadPath
var torrentID var torrentID
if (s.torrentFileName) { // Load torrent file from disk if (s.torrentFileName) { // Load torrent file from disk
@@ -847,11 +902,17 @@ function torrentMetadata (torrentKey, torrentInfo) {
torrentSummary.status = 'downloading' torrentSummary.status = 'downloading'
torrentSummary.name = torrentSummary.displayName || torrentInfo.name torrentSummary.name = torrentSummary.displayName || torrentInfo.name
torrentSummary.path = torrentInfo.path torrentSummary.path = torrentInfo.path
torrentSummary.files = torrentInfo.files
torrentSummary.magnetURI = torrentInfo.magnetURI torrentSummary.magnetURI = torrentInfo.magnetURI
// TODO: make torrentInfo immutable, save separately as torrentSummary.info
// For now, check whether torrentSummary.files has already been set:
var hasDetailedFileInfo = torrentSummary.files && torrentSummary.files[0].path
if (!hasDetailedFileInfo) {
torrentSummary.files = torrentInfo.files
}
if (!torrentSummary.selections) { if (!torrentSummary.selections) {
torrentSummary.selections = torrentSummary.files.map((x) => true) torrentSummary.selections = torrentSummary.files.map((x) => true)
} }
torrentSummary.defaultPlayFileIndex = pickFileToPlay(torrentInfo.files)
update() update()
// Save the .torrent file, if it hasn't been saved already // Save the .torrent file, if it hasn't been saved already
@@ -960,11 +1021,25 @@ function pickFileToPlay (files) {
return undefined return undefined
} }
// Opens the video player function playFile (infoHash, index) {
state.location.go({
url: 'player',
onbeforeload: function (cb) {
play()
openPlayer(infoHash, index, cb)
},
onbeforeunload: closePlayer
}, function (err) {
if (err) onError(err)
})
}
// Opens the video player to a specific torrent
function openPlayer (infoHash, index, cb) { function openPlayer (infoHash, index, cb) {
var torrentSummary = getTorrentSummary(infoHash) var torrentSummary = getTorrentSummary(infoHash)
// automatically choose which file in the torrent to play, if necessary // automatically choose which file in the torrent to play, if necessary
if (index === undefined) index = torrentSummary.defaultPlayFileIndex
if (index === undefined) index = pickFileToPlay(torrentSummary.files) if (index === undefined) index = pickFileToPlay(torrentSummary.files)
if (index === undefined) return cb(new errors.UnplayableError()) if (index === undefined) return cb(new errors.UnplayableError())
@@ -976,7 +1051,7 @@ function openPlayer (infoHash, index, cb) {
var timeout = setTimeout(function () { var timeout = setTimeout(function () {
torrentSummary.playStatus = 'timeout' /* no seeders available? */ torrentSummary.playStatus = 'timeout' /* no seeders available? */
sound.play('ERROR') sound.play('ERROR')
cb(new Error('playback timed out')) cb(new Error('Playback timed out. Try again.'))
update() update()
}, 10000) /* give it a few seconds */ }, 10000) /* give it a few seconds */
@@ -999,6 +1074,15 @@ function openPlayerFromActiveTorrent (torrentSummary, index, timeout, cb) {
: TorrentPlayer.isAudio(fileSummary) ? 'audio' : TorrentPlayer.isAudio(fileSummary) ? 'audio'
: 'other' : 'other'
// pick up where we left off
if (fileSummary.currentTime) {
var fraction = fileSummary.currentTime / fileSummary.duration
var secondsLeft = fileSummary.duration - fileSummary.currentTime
if (fraction < 0.9 && secondsLeft > 10) {
state.playing.jumpToTime = fileSummary.currentTime
}
}
// if it's audio, parse out the metadata (artist, title, etc) // if it's audio, parse out the metadata (artist, title, etc)
if (state.playing.type === 'audio' && !fileSummary.audioInfo) { if (state.playing.type === 'audio' && !fileSummary.audioInfo) {
ipcRenderer.send('wt-get-audio-metadata', torrentSummary.infoHash, index) ipcRenderer.send('wt-get-audio-metadata', torrentSummary.infoHash, index)
@@ -1148,7 +1232,7 @@ function saveTorrentFileAs (torrentSummary) {
var newFileName = `${path.parse(torrentSummary.name).name}.torrent` var newFileName = `${path.parse(torrentSummary.name).name}.torrent`
var opts = { var opts = {
title: 'Save Torrent File', title: 'Save Torrent File',
defaultPath: path.join(state.saved.downloadPath, newFileName), defaultPath: path.join(state.saved.prefs.downloadPath, newFileName),
filters: [ filters: [
{ name: 'Torrent Files', extensions: ['torrent'] }, { name: 'Torrent Files', extensions: ['torrent'] },
{ name: 'All Files', extensions: ['*'] } { name: 'All Files', extensions: ['*'] }

View File

@@ -8,7 +8,8 @@ module.exports = {
play, play,
pause, pause,
seek, seek,
setVolume setVolume,
setRate
} }
var airplay = require('airplay-js') var airplay = require('airplay-js')
@@ -344,6 +345,22 @@ function pause () {
} }
} }
function setRate (rate) {
var device
var result = true
if (state.playing.location === 'chromecast') {
// TODO find how to control playback rate on chromecast
castCallback()
result = false
} else if (state.playing.location === 'airplay') {
device = state.devices.airplay
device.rate(rate, castCallback)
} else {
result = false
}
return result
}
function seek (time) { function seek (time) {
var device = getDevice() var device = getDevice()
if (device) { if (device) {

View File

@@ -4,12 +4,18 @@ var captureVideoFrame = require('./capture-video-frame')
var path = require('path') var path = require('path')
function torrentPoster (torrent, cb) { function torrentPoster (torrent, cb) {
// First, try to use the largest video file // First, try to use a poster image if available
var posterFile = torrent.files.filter(function (file) {
return /^poster\.(jpg|png|gif)$/.test(file.name)
})[0]
if (posterFile) return torrentPosterFromImage(posterFile, torrent, cb)
// Second, try to use the largest video file
// Filter out file formats that the <video> tag definitely can't play // Filter out file formats that the <video> tag definitely can't play
var videoFile = getLargestFileByExtension(torrent, ['.mp4', '.m4v', '.webm', '.mov', '.mkv']) var videoFile = getLargestFileByExtension(torrent, ['.mp4', '.m4v', '.webm', '.mov', '.mkv'])
if (videoFile) return torrentPosterFromVideo(videoFile, torrent, cb) if (videoFile) return torrentPosterFromVideo(videoFile, torrent, cb)
// Second, try to use the largest image file // Third, try to use the largest image file
var imgFile = getLargestFileByExtension(torrent, ['.gif', '.jpg', '.png']) var imgFile = getLargestFileByExtension(torrent, ['.gif', '.jpg', '.png'])
if (imgFile) return torrentPosterFromImage(imgFile, torrent, cb) if (imgFile) return torrentPosterFromImage(imgFile, torrent, cb)

View File

@@ -9,8 +9,7 @@ var LocationHistory = require('./lib/location-history')
module.exports = { module.exports = {
getInitialState, getInitialState,
getDefaultPlayState, getDefaultPlayState,
getDefaultSavedState, getDefaultSavedState
getPlayingTorrentSummary
} }
function getInitialState () { function getInitialState () {
@@ -63,7 +62,8 @@ function getInitialState () {
/* /*
* Getters, for convenience * Getters, for convenience
*/ */
getPlayingTorrentSummary getPlayingTorrentSummary,
getPlayingFileSummary
} }
} }
@@ -80,6 +80,7 @@ function getDefaultPlayState () {
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 */
playbackRate: 1,
subtitles: { subtitles: {
tracks: [], /* subtitle tracks, each {label, language, ...} */ tracks: [], /* subtitle tracks, each {label, language, ...} */
selectedIndex: -1, /* current subtitle track */ selectedIndex: -1, /* current subtitle track */
@@ -265,9 +266,11 @@ function getDefaultSavedState () {
] ]
} }
], ],
downloadPath: config.IS_PORTABLE prefs: {
? path.join(config.CONFIG_PATH, 'Downloads') downloadPath: config.IS_PORTABLE
: remote.app.getPath('downloads') ? path.join(config.CONFIG_PATH, 'Downloads')
: remote.app.getPath('downloads')
}
} }
} }
@@ -275,3 +278,9 @@ function getPlayingTorrentSummary () {
var infoHash = this.playing.infoHash var infoHash = this.playing.infoHash
return this.saved.torrents.find((x) => x.infoHash === infoHash) return this.saved.torrents.find((x) => x.infoHash === infoHash)
} }
function getPlayingFileSummary () {
var torrentSummary = this.getPlayingTorrentSummary()
if (!torrentSummary) return null
return torrentSummary.files[this.playing.fileIndex]
}

View File

@@ -8,7 +8,8 @@ var Header = require('./header')
var Views = { var Views = {
'home': require('./torrent-list'), 'home': require('./torrent-list'),
'player': require('./player'), 'player': require('./player'),
'create-torrent': require('./create-torrent-page') 'create-torrent': require('./create-torrent-page'),
'preferences': require('./preferences')
} }
var Modals = { var Modals = {
'open-torrent-address-modal': require('./open-torrent-address-modal'), 'open-torrent-address-modal': require('./open-torrent-address-modal'),
@@ -26,11 +27,8 @@ function App (state) {
state.playing.mouseStationarySince !== 0 && state.playing.mouseStationarySince !== 0 &&
new Date().getTime() - state.playing.mouseStationarySince > 2000 && new Date().getTime() - state.playing.mouseStationarySince > 2000 &&
!state.playing.isPaused && !state.playing.isPaused &&
state.playing.location === 'local' state.playing.location === 'local' &&
state.playing.playbackRate === 1
// Hide the header on Windows/Linux when in the player
// On OSX, the header appears as part of the title bar
var hideHeader = process.platform !== 'darwin' && state.location.url() === 'player'
var cls = [ var cls = [
'view-' + state.location.url(), /* e.g. view-home, view-player */ 'view-' + state.location.url(), /* e.g. view-home, view-player */
@@ -39,7 +37,6 @@ function App (state) {
if (state.window.isFullScreen) cls.push('is-fullscreen') if (state.window.isFullScreen) cls.push('is-fullscreen')
if (state.window.isFocused) cls.push('is-focused') if (state.window.isFocused) cls.push('is-focused')
if (hideControls) cls.push('hide-video-controls') if (hideControls) cls.push('hide-video-controls')
if (hideHeader) cls.push('hide-header')
return hx` return hx`
<div class='app ${cls.join(' ')}'> <div class='app ${cls.join(' ')}'>

View File

@@ -10,7 +10,7 @@ function Header (state) {
return hx` return hx`
<div class='header'> <div class='header'>
${getTitle()} ${getTitle()}
<div class='nav left'> <div class='nav left float-left'>
<i.icon.back <i.icon.back
class=${state.location.hasBack() ? '' : 'disabled'} class=${state.location.hasBack() ? '' : 'disabled'}
title='Back' title='Back'
@@ -24,7 +24,7 @@ function Header (state) {
chevron_right chevron_right
</i> </i>
</div> </div>
<div class='nav right'> <div class='nav right float-right'>
${getAddButton()} ${getAddButton()}
</div> </div>
</div> </div>
@@ -37,7 +37,7 @@ function Header (state) {
} }
function getAddButton () { function getAddButton () {
if (state.location.url() !== 'player') { if (state.location.url() === 'home') {
return hx` return hx`
<i <i
class='icon add' class='icon add'

View File

@@ -4,8 +4,9 @@ var h = require('virtual-dom/h')
var hyperx = require('hyperx') var hyperx = require('hyperx')
var hx = hyperx(h) var hx = hyperx(h)
var prettyBytes = require('prettier-bytes')
var Bitfield = require('bitfield') var Bitfield = require('bitfield')
var prettyBytes = require('prettier-bytes')
var zeroFill = require('zero-fill')
var TorrentSummary = require('../lib/torrent-summary') var TorrentSummary = require('../lib/torrent-summary')
var {dispatch, dispatcher} = require('../lib/dispatcher') var {dispatch, dispatcher} = require('../lib/dispatcher')
@@ -48,6 +49,9 @@ function renderMedia (state) {
mediaElement.currentTime = state.playing.jumpToTime mediaElement.currentTime = state.playing.jumpToTime
state.playing.jumpToTime = null state.playing.jumpToTime = null
} }
if (state.playing.playbackRate !== mediaElement.playbackRate) {
mediaElement.playbackRate = state.playing.playbackRate
}
// Set volume // Set volume
if (state.playing.setVolume !== null && isFinite(state.playing.setVolume)) { if (state.playing.setVolume !== null && isFinite(state.playing.setVolume)) {
mediaElement.volume = state.playing.setVolume mediaElement.volume = state.playing.setVolume
@@ -60,8 +64,10 @@ function renderMedia (state) {
tracks[j].mode = (j === state.playing.subtitles.selectedIndex) ? 'showing' : 'hidden' tracks[j].mode = (j === state.playing.subtitles.selectedIndex) ? 'showing' : 'hidden'
} }
state.playing.currentTime = mediaElement.currentTime // Save video position
state.playing.duration = mediaElement.duration var file = state.getPlayingFileSummary()
file.currentTime = state.playing.currentTime = mediaElement.currentTime
file.duration = state.playing.duration = mediaElement.duration
state.playing.volume = mediaElement.volume state.playing.volume = mediaElement.volume
} }
@@ -125,12 +131,13 @@ function renderMedia (state) {
} }
function onCanPlay (e) { function onCanPlay (e) {
var video = e.target var elem = e.target
if (video.webkitVideoDecodedByteCount > 0 && if (state.playing.type === 'video' && elem.webkitVideoDecodedByteCount === 0) {
video.webkitAudioDecodedByteCount === 0) { dispatch('mediaError', 'Video codec unsupported')
} else if (elem.webkitAudioDecodedByteCount === 0) {
dispatch('mediaError', 'Audio codec unsupported') dispatch('mediaError', 'Audio codec unsupported')
} else { } else {
video.play() elem.play()
} }
} }
} }
@@ -161,8 +168,7 @@ function renderOverlay (state) {
} }
function renderAudioMetadata (state) { function renderAudioMetadata (state) {
var torrentSummary = state.getPlayingTorrentSummary() var fileSummary = state.getPlayingFileSummary()
var fileSummary = torrentSummary.files[state.playing.fileIndex]
if (!fileSummary.audioInfo) return if (!fileSummary.audioInfo) return
var info = fileSummary.audioInfo var info = fileSummary.audioInfo
@@ -292,7 +298,7 @@ function renderSubtitlesOptions (state) {
function renderPlayerControls (state) { function renderPlayerControls (state) {
var positionPercent = 100 * state.playing.currentTime / state.playing.duration var positionPercent = 100 * state.playing.currentTime / state.playing.duration
var playbackCursorStyle = { left: 'calc(' + positionPercent + '% - 8px)' } var playbackCursorStyle = { left: 'calc(' + positionPercent + '% - 3px)' }
var captionsClass = state.playing.subtitles.tracks.length === 0 var captionsClass = state.playing.subtitles.tracks.length === 0
? 'disabled' ? 'disabled'
: state.playing.subtitles.selectedIndex >= 0 : state.playing.subtitles.selectedIndex >= 0
@@ -303,15 +309,27 @@ function renderPlayerControls (state) {
hx` hx`
<div class='playback-bar'> <div class='playback-bar'>
${renderLoadingBar(state)} ${renderLoadingBar(state)}
<div class='playback-cursor' style=${playbackCursorStyle}></div> <div
<div class='scrub-bar' class='playback-cursor'
style=${playbackCursorStyle}>
</div>
<div
class='scrub-bar'
draggable='true' draggable='true'
ondragstart=${handleDragStart}
onclick=${handleScrub}, onclick=${handleScrub},
ondrag=${handleScrub}></div> ondrag=${handleScrub}>
</div>
</div> </div>
`, `,
hx` hx`
<i class='icon fullscreen' <i class='icon play-pause float-left' onclick=${dispatcher('playPause')}>
${state.playing.isPaused ? 'play_arrow' : 'pause'}
</i>
`,
hx`
<i
class='icon fullscreen float-right'
onclick=${dispatcher('toggleFullScreen')}> onclick=${dispatcher('toggleFullScreen')}>
${state.window.isFullScreen ? 'fullscreen_exit' : 'fullscreen'} ${state.window.isFullScreen ? 'fullscreen_exit' : 'fullscreen'}
</i> </i>
@@ -321,7 +339,7 @@ function renderPlayerControls (state) {
if (state.playing.type === 'video') { if (state.playing.type === 'video') {
// show closed captions icon // show closed captions icon
elements.push(hx` elements.push(hx`
<i.icon.closed-captions <i.icon.closed-captions.float-right
class=${captionsClass} class=${captionsClass}
onclick=${handleSubtitles}> onclick=${handleSubtitles}>
closed_captions closed_captions
@@ -366,7 +384,7 @@ function renderPlayerControls (state) {
if (state.devices.chromecast || isOnChromecast) { if (state.devices.chromecast || isOnChromecast) {
var castIcon = isOnChromecast ? 'cast_connected' : 'cast' var castIcon = isOnChromecast ? 'cast_connected' : 'cast'
elements.push(hx` elements.push(hx`
<i.icon.device <i.icon.device.float-right
class=${chromecastClass} class=${chromecastClass}
onclick=${chromecastHandler}> onclick=${chromecastHandler}>
${castIcon} ${castIcon}
@@ -375,7 +393,7 @@ function renderPlayerControls (state) {
} }
if (state.devices.airplay || isOnAirplay) { if (state.devices.airplay || isOnAirplay) {
elements.push(hx` elements.push(hx`
<i.icon.device <i.icon.device.float-right
class=${airplayClass} class=${airplayClass}
onclick=${airplayHandler}> onclick=${airplayHandler}>
airplay airplay
@@ -384,7 +402,8 @@ function renderPlayerControls (state) {
} }
if (state.devices.dlna || isOnDlna) { if (state.devices.dlna || isOnDlna) {
elements.push(hx` elements.push(hx`
<i.icon.device <i
class='icon device float-right'
class=${dlnaClass} class=${dlnaClass}
onclick=${dlnaHandler}> onclick=${dlnaHandler}>
tv tv
@@ -392,17 +411,6 @@ function renderPlayerControls (state) {
`) `)
} }
// On OSX, the back button is in the title bar of the window; see app.js
// On other platforms, we render one over the video on mouseover
if (process.platform !== 'darwin') {
elements.push(hx`
<i.icon.back
onclick=${dispatcher('back')}>
chevron_left
</i>
`)
}
// render volume // render volume
var volume = state.playing.volume var volume = state.playing.volume
var volumeIcon = 'volume_' + (volume === 0 ? 'off' : volume < 0.3 ? 'mute' : volume < 0.6 ? 'down' : 'up') var volumeIcon = 'volume_' + (volume === 0 ? 'off' : volume < 0.3 ? 'mute' : volume < 0.6 ? 'down' : 'up')
@@ -412,34 +420,54 @@ function renderPlayerControls (state) {
} }
elements.push(hx` elements.push(hx`
<div.volume> <div class='volume float-left'>
<i.icon.volume-icon onmousedown=${handleVolumeMute}> <i
${volumeIcon} class='icon volume-icon float-left'
</i> onmousedown=${handleVolumeMute}>
<input.volume-slider ${volumeIcon}
type='range' min='0' max='1' step='0.05' value=${volumeChanging !== false ? volumeChanging : volume} </i>
onmousedown=${handleVolumeScrub} <input
onmouseup=${handleVolumeScrub} class='volume-slider float-right'
onmousemove=${handleVolumeScrub} type='range' min='0' max='1' step='0.05' value=${volumeChanging !== false ? volumeChanging : volume}
style=${volumeStyle} onmousedown=${handleVolumeScrub}
/> onmouseup=${handleVolumeScrub}
onmousemove=${handleVolumeScrub}
style=${volumeStyle}
/>
</div> </div>
`) `)
// Finally, the big button in the center plays or pauses the video // Show video playback progress
elements.push(hx` elements.push(hx`
<i class='icon play-pause' onclick=${dispatcher('playPause')}> <span class='time float-left'>
${state.playing.isPaused ? 'play_arrow' : 'pause'} ${formatTime(state.playing.currentTime)} / ${formatTime(state.playing.duration)}
</i> </span>
`) `)
// render playback rate
if (state.playing.playbackRate !== 1) {
elements.push(hx`
<span class='rate float-left'>
${state.playing.playbackRate}x
</span>
`)
}
return hx` return hx`
<div class='player-controls'> <div class='controls'>
${elements} ${elements}
${renderSubtitlesOptions(state)} ${renderSubtitlesOptions(state)}
</div> </div>
` `
function handleDragStart (e) {
// Prevent the cursor from changing, eg to a green + icon on Mac
if (e.dataTransfer) {
var dt = e.dataTransfer
dt.effectAllowed = 'none'
}
}
// Handles a click or drag to scrub (jump to another position in the video) // Handles a click or drag to scrub (jump to another position in the video)
function handleScrub (e) { function handleScrub (e) {
dispatch('mediaMouseMoved') dispatch('mediaMouseMoved')
@@ -540,3 +568,18 @@ function cssBackgroundImageDarkGradient () {
return 'radial-gradient(circle at center, ' + return 'radial-gradient(circle at center, ' +
'rgba(0,0,0,0.4) 0%, rgba(0,0,0,1) 100%)' 'rgba(0,0,0,0.4) 0%, rgba(0,0,0,1) 100%)'
} }
function formatTime (time) {
if (typeof time !== 'number' || Number.isNaN(time)) {
return '0:00'
}
var hours = Math.floor(time / 3600)
var minutes = Math.floor(time % 3600 / 60)
if (hours > 0) {
minutes = zeroFill(2, minutes)
}
var seconds = zeroFill(2, Math.floor(time % 60))
return (hours > 0 ? hours + ':' : '') + minutes + ':' + seconds
}

View File

@@ -0,0 +1,104 @@
module.exports = Preferences
var h = require('virtual-dom/h')
var hyperx = require('hyperx')
var hx = hyperx(h)
var {dispatch} = require('../lib/dispatcher')
var remote = require('electron').remote
var dialog = remote.dialog
function Preferences (state) {
return hx`
<div class='preferences'>
${renderGeneralSection(state)}
</div>
`
}
function renderGeneralSection (state) {
return renderSection({
title: 'General',
description: '',
icon: 'settings'
}, [
renderDownloadDirSelector(state)
])
}
function renderDownloadDirSelector (state) {
return renderFileSelector({
label: 'Download Path',
description: 'Data from torrents will be saved here',
property: 'downloadPath',
options: {
title: 'Select download directory',
properties: [ 'openDirectory' ]
}
},
state.unsaved.prefs.downloadPath,
function (filePath) {
setStateValue('downloadPath', filePath)
})
}
// Renders a prefs section.
// - definition should be {icon, title, description}
// - controls should be an array of vdom elements
function renderSection (definition, controls) {
var helpElem = !definition.description ? null : hx`
<div class='help text'>
<i.icon>help_outline</i>${definition.description}
</div>
`
return hx`
<section class='section preferences-panel'>
<div class='section-container'>
<div class='section-heading'>
<i.icon>${definition.icon}</i>${definition.title}
</div>
${helpElem}
<div class='section-body'>
${controls}
</div>
</div>
</section>
`
}
// Creates a file chooser
// - defition should be {label, description, options}
// options are passed to dialog.showOpenDialog
// - value should be the current pref, a file or folder path
// - callback takes a new file or folder path
function renderFileSelector (definition, value, callback) {
return hx`
<div class='control-group'>
<div class='controls'>
<label class='control-label'>
<div class='preference-title'>${definition.label}</div>
<div class='preference-description'>${definition.description}</div>
</label>
<div class='controls'>
<input type='text' class='file-picker-text'
id=${definition.property}
disabled='disabled'
value=${value} />
<button class='btn' onclick=${handleClick}>
<i.icon>folder_open</i>
</button>
</div>
</div>
</div>
`
function handleClick () {
dialog.showOpenDialog(remote.getCurrentWindow(), definition.options, function (filenames) {
if (!Array.isArray(filenames)) return
callback(filenames[0])
})
}
}
function setStateValue (property, value) {
dispatch('updatePreferences', property, value)
}

View File

@@ -118,12 +118,7 @@ function TorrentList (state) {
var infoHash = torrentSummary.infoHash var infoHash = torrentSummary.infoHash
var playIcon, playTooltip, playClass var playIcon, playTooltip, playClass
if (torrentSummary.playStatus === 'unplayable') { if (torrentSummary.playStatus === 'timeout') {
playIcon = 'play_arrow'
playClass = 'disabled'
playTooltip = 'Sorry, WebTorrent can\'t play any of the files in this torrent. ' +
'View details and click on individual files to open them in another program.'
} else if (torrentSummary.playStatus === 'timeout') {
playIcon = 'warning' playIcon = 'warning'
playTooltip = 'Playback timed out. No seeds? No internet? Click to try again.' playTooltip = 'Playback timed out. No seeds? No internet? Click to try again.'
} else { } else {
@@ -143,6 +138,18 @@ function TorrentList (state) {
downloadTooltip = 'Click to start torrenting.' downloadTooltip = 'Click to start torrenting.'
} }
// Do we have a saved position? Show it using a radial progress bar on top
// of the play button, unless already showing a spinner there:
var positionElem
var willShowSpinner = torrentSummary.playStatus === 'requested'
var defaultFile = torrentSummary.files &&
torrentSummary.files[torrentSummary.defaultPlayFileIndex]
if (defaultFile && defaultFile.currentTime && !willShowSpinner) {
var fraction = defaultFile.currentTime / defaultFile.duration
positionElem = renderRadialProgressBar(fraction, 'radial-progress-large')
playClass = 'resume-position'
}
// Only show the play button for torrents that contain playable media // Only show the play button for torrents that contain playable media
var playButton var playButton
if (TorrentPlayer.isPlayableTorrent(torrentSummary)) { if (TorrentPlayer.isPlayableTorrent(torrentSummary)) {
@@ -158,6 +165,7 @@ function TorrentList (state) {
return hx` return hx`
<div class='buttons'> <div class='buttons'>
${positionElem}
${playButton} ${playButton}
<i.button-round.icon.download <i.button-round.icon.download
class=${torrentSummary.status} class=${torrentSummary.status}
@@ -186,11 +194,16 @@ function TorrentList (state) {
filesElement = hx`<div class='files warning'>${message}</div>` filesElement = hx`<div class='files warning'>${message}</div>`
} else { } else {
// We do know the files. List them and show download stats for each one // We do know the files. List them and show download stats for each one
var fileRows = torrentSummary.files.map( var fileRows = torrentSummary.files
(file, index) => renderFileRow(torrentSummary, file, index)) .sort(function (a, b) {
if (a.name < b.name) return -1
if (b.name < a.name) return 1
return 0
})
.map((file, index) => renderFileRow(torrentSummary, file, index))
filesElement = hx` filesElement = hx`
<div class='files'> <div class='files'>
<strong>Files</strong>
<table> <table>
${fileRows} ${fileRows}
</table> </table>
@@ -217,7 +230,14 @@ function TorrentList (state) {
progress = Math.round(100 * fileProg.numPiecesPresent / fileProg.numPieces) + '%' progress = Math.round(100 * fileProg.numPiecesPresent / fileProg.numPieces) + '%'
} }
// Second, render the file as a table row // Second, for media files where we saved our position, show how far we got
var positionElem
if (file.currentTime) {
// Radial progress bar. 0% = start from 0:00, 270% = 3/4 of the way thru
positionElem = renderRadialProgressBar(file.currentTime / file.duration)
}
// Finally, render the file as a table row
var isPlayable = TorrentPlayer.isPlayable(file) var isPlayable = TorrentPlayer.isPlayable(file)
var infoHash = torrentSummary.infoHash var infoHash = torrentSummary.infoHash
var icon var icon
@@ -233,17 +253,18 @@ function TorrentList (state) {
if (!isSelected) rowClass = 'disabled' // File deselected, not being torrented if (!isSelected) rowClass = 'disabled' // File deselected, not being torrented
if (!isDone && !isPlayable) rowClass = 'disabled' // Can't open yet, can't stream if (!isDone && !isPlayable) rowClass = 'disabled' // Can't open yet, can't stream
return hx` return hx`
<tr> <tr onclick=${handleClick}>
<td class='col-icon ${rowClass}' onclick=${handleClick}> <td class='col-icon ${rowClass}'>
${positionElem}
<i class='icon'>${icon}</i> <i class='icon'>${icon}</i>
</td> </td>
<td class='col-name ${rowClass}' onclick=${handleClick}> <td class='col-name ${rowClass}'>
${file.name} ${file.name}
</td> </td>
<td class='col-progress ${rowClass}' onclick=${handleClick}> <td class='col-progress ${rowClass}'>
${isSelected ? progress : ''} ${isSelected ? progress : ''}
</td> </td>
<td class='col-size ${rowClass}' onclick=${handleClick}> <td class='col-size ${rowClass}'>
${prettyBytes(file.length)} ${prettyBytes(file.length)}
</td> </td>
<td class='col-select' <td class='col-select'
@@ -254,3 +275,24 @@ function TorrentList (state) {
` `
} }
} }
function renderRadialProgressBar (fraction, cssClass) {
var rotation = 360 * fraction
var transformFill = {transform: 'rotate(' + (rotation / 2) + 'deg)'}
var transformFix = {transform: 'rotate(' + rotation + 'deg)'}
return hx`
<div class="radial-progress ${cssClass}">
<div class="circle">
<div class="mask full" style=${transformFill}>
<div class="fill" style=${transformFill}></div>
</div>
<div class="mask half">
<div class="fill" style=${transformFill}></div>
<div class="fill fix" style=${transformFix}></div>
</div>
</div>
<div class="inset"></div>
</div>
`
}

View File

@@ -1,10 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.app-sandbox</key>
<true/>
<key>com.apple.security.inherit</key>
<true/>
</dict>
</plist>

View File

@@ -1,20 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.app-sandbox</key>
<true/>
<key>com.apple.security.files.downloads.read-write</key>
<true/>
<key>com.apple.security.files.user-selected.read-write</key>
<true/>
<key>com.apple.security.network.client</key>
<true/>
<key>com.apple.security.network.server</key>
<true/>
<key>com.apple.security.application-groups</key>
<array>
<string>5MAMC8G3L8.io.webtorrent.webtorrent</string>
</array>
</dict>
</plist>