Add support for External Players (closes #418) (#1271)

* feat: add support for opening videos/playlists in external players (like mpv) #418

Signed-off-by: Randshot <randshot@norealm.xyz>

* feat: move external player settings into own section
feat: add warnings for when the external player doesn't support the current action (e.g. reversing playlists)
feat: add toggle in settings for ignoring unsupported action warnings

Signed-off-by: Randshot <randshot@norealm.xyz>

* improvement: do not append start offset argument when the watch progress is 0

Signed-off-by: Randshot <randshot@norealm.xyz>

* fix: fix undefined showToast error when clicking on the external player playlist button

Signed-off-by: Randshot <randshot@norealm.xyz>

* feat: add icon button for external player to watch-video-info (below video player) component
improvement: refactor the code for opening the external player into a separate function in utils.js

Signed-off-by: Randshot <randshot@norealm.xyz>

* feat: add support for ytdl protocol urls (supportsYtdlProtocol)
chore: fix lint error

Signed-off-by: Randshot <randshot@norealm.xyz>

* feat: add support for passing default playback rate to external player
improvement: add warning message for when the external player does not support starting playback at
             a given offset
chore: rename reverse, shuffle, and loopPlaylist fields for consistency

Signed-off-by: Randshot <randshot@norealm.xyz>

* feat: add setting for custom external player command line arguments

Signed-off-by: Randshot <randshot@norealm.xyz>

* chore: fix lint error

Signed-off-by: Randshot <randshot@norealm.xyz>

* improvement(watch-video-info.js): change the default for playlistId back to null (consistent with other occurrences)
improvement(utils.js/openInExternalPlayer): also check for empty playlistId string
fix(watch-video-info.js): fix merge error

Signed-off-by: Randshot <randshot@norealm.xyz>

* improvement(components/ft-list-video): check whether watch history is turned on, before adding a video to it
fix(store/utils): fix playlistReverse typo, causing `undefined` being set as a command line argument
fix(store/utils): check for 'string' type, instead of `null` and `undefined`
fix(views/Watch): fix getPlaylistIndex returning an incorrect index, when reverse was turned on
chore(locales/en-US): fix thumbnail and suppress typo
chore(locales/en_GB): fix thumbnail and suppress typo

Signed-off-by: Randshot <randshot@norealm.xyz>

* feat: pause player when opening video in external player

Signed-off-by: Randshot <randshot@norealm.xyz>

* feat(externalPlayer): refactor externalPlayerCmdArguments into a separate static file `static/external-player-map.json`
chore(components/ft-list-video): fix lint error

Signed-off-by: Randshot <randshot@norealm.xyz>

* Revert "feat: pause player when opening video in external player"

This reverts commit 28b4713334bf941be9e403abf517bb4b89beb04f.

* feat: pause the app's player when opening video in external player

* This commit addresses above requested changes.

improvement(components/external-player-settings): move `externalPlayer` check to `ft-flex-box`
improvement(components/external-player-settings): use `update*` methods, instead of `handle*`

improvement(store/utils): move child_process invocation to `main/index.js` via IPC call to renderer
improvement(store/utils): use `dispatch` for calling actions
improvement(store/utils): get external player related settings directly in the action

improvement(renderer/App): move `checkExternalPlayer` call down into `usingElectron` if statement
fix(renderer/App): fix lint error

improvement(components/ft-list-playlist): remove unnecessary payload fields
fix(components/ft-list-playlist): fix typo in component name

improvement(components/ft-list-video): remove unnecessary payload fields

improvement(components/watch-video-info): remove unnecessary payload fields
improvement(views/Settings): add `usingElectron` condition

Signed-off-by: Randshot <randshot@norealm.xyz>

* fix(store/utils): fix toast message error

Signed-off-by: Randshot <randshot@norealm.xyz>

* fix(store/utils): fix a few code mess-ups

Co-authored-by: Svallinn <41585298+Svallinn@users.noreply.github.com>
This commit is contained in:
kuhaku 2021-06-13 15:31:43 +00:00 committed by GitHub
parent 003eeabb78
commit 52fa523df1
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
24 changed files with 641 additions and 9 deletions

View File

@ -4,6 +4,7 @@ import {
} from 'electron' } from 'electron'
import Datastore from 'nedb' import Datastore from 'nedb'
import path from 'path' import path from 'path'
import cp from 'child_process'
if (process.argv.includes('--version')) { if (process.argv.includes('--version')) {
console.log(`v${app.getVersion()}`) console.log(`v${app.getVersion()}`)
@ -397,6 +398,11 @@ function runApp() {
} }
}) })
ipcMain.on('openInExternalPlayer', (_, payload) => {
const child = cp.spawn(payload.executable, payload.args, { detached: true, stdio: 'ignore' })
child.unref()
})
app.on('window-all-closed', () => { app.on('window-all-closed', () => {
if (process.platform !== 'darwin') { if (process.platform !== 'darwin') {
app.quit() app.quit()

View File

@ -76,6 +76,9 @@ export default Vue.extend({
}, },
defaultProfile: function () { defaultProfile: function () {
return this.$store.getters.getDefaultProfile return this.$store.getters.getDefaultProfile
},
externalPlayer: function () {
return this.$store.getters.getExternalPlayer
} }
}, },
mounted: function () { mounted: function () {
@ -87,8 +90,6 @@ export default Vue.extend({
this.checkThemeSettings() this.checkThemeSettings()
await this.checkLocale() await this.checkLocale()
this.dataReady = true
if (this.usingElectron) { if (this.usingElectron) {
console.log('User is using Electron') console.log('User is using Electron')
ipcRenderer = require('electron').ipcRenderer ipcRenderer = require('electron').ipcRenderer
@ -96,8 +97,11 @@ export default Vue.extend({
this.openAllLinksExternally() this.openAllLinksExternally()
this.enableOpenUrl() this.enableOpenUrl()
this.setBoundsOnClose() this.setBoundsOnClose()
await this.checkExternalPlayer()
} }
this.dataReady = true
setTimeout(() => { setTimeout(() => {
this.checkForNewUpdates() this.checkForNewUpdates()
this.checkForNewBlogPosts() this.checkForNewBlogPosts()
@ -230,6 +234,14 @@ export default Vue.extend({
} }
}, },
checkExternalPlayer: async function () {
const payload = {
isDev: this.isDev,
externalPlayer: this.externalPlayer
}
this.getExternalPlayerCmdArgumentsData(payload)
},
handleUpdateBannerClick: function (response) { handleUpdateBannerClick: function (response) {
if (response !== false) { if (response !== false) {
this.showReleaseNotes = true this.showReleaseNotes = true
@ -406,6 +418,7 @@ export default Vue.extend({
'getRegionData', 'getRegionData',
'getYoutubeUrlInfo', 'getYoutubeUrlInfo',
'getLocale', 'getLocale',
'getExternalPlayerCmdArgumentsData',
'setUpListenerToSyncSettings' 'setUpListenerToSyncSettings'
]) ])
} }

View File

@ -0,0 +1,53 @@
import Vue from 'vue'
import { mapActions } from 'vuex'
import FtCard from '../ft-card/ft-card.vue'
import FtSelect from '../ft-select/ft-select.vue'
import FtInput from '../ft-input/ft-input.vue'
import FtToggleSwitch from '../ft-toggle-switch/ft-toggle-switch.vue'
import FtFlexBox from '../ft-flex-box/ft-flex-box.vue'
export default Vue.extend({
name: 'ExternalPlayerSettings',
components: {
'ft-card': FtCard,
'ft-select': FtSelect,
'ft-input': FtInput,
'ft-toggle-switch': FtToggleSwitch,
'ft-flex-box': FtFlexBox
},
data: function () {
return {}
},
computed: {
isDev: function () {
return process.env.NODE_ENV === 'development'
},
externalPlayerNames: function () {
return this.$store.getters.getExternalPlayerNames
},
externalPlayerValues: function () {
return this.$store.getters.getExternalPlayerValues
},
externalPlayer: function () {
return this.$store.getters.getExternalPlayer
},
externalPlayerExecutable: function () {
return this.$store.getters.getExternalPlayerExecutable
},
externalPlayerIgnoreWarnings: function () {
return this.$store.getters.getExternalPlayerIgnoreWarnings
},
externalPlayerCustomArgs: function () {
return this.$store.getters.getExternalPlayerCustomArgs
}
},
methods: {
...mapActions([
'updateExternalPlayer',
'updateExternalPlayerExecutable',
'updateExternalPlayerIgnoreWarnings',
'updateExternalPlayerCustomArgs'
])
}
})

View File

@ -0,0 +1 @@
@use "../../sass-partials/settings"

View File

@ -0,0 +1,56 @@
<template>
<ft-card
class="relative card"
>
<h3
class="videoTitle"
>
{{ $t("Settings.External Player Settings.External Player Settings") }}
</h3>
<div class="switchColumnGrid">
<div class="switchColumn">
<ft-select
:placeholder="$t('Settings.External Player Settings.External Player')"
:value="externalPlayer"
:select-names="externalPlayerNames"
:select-values="externalPlayerValues"
:tooltip="$t('Tooltips.External Player Settings.External Player')"
@change="updateExternalPlayer"
/>
</div>
<div class="switchColumn">
<ft-toggle-switch
:label="$t('Settings.External Player Settings.Ignore Unsupported Action Warnings')"
:default-value="externalPlayerIgnoreWarnings"
:compact="true"
:tooltip="$t('Tooltips.External Player Settings.Ignore Warnings')"
@change="updateExternalPlayerIgnoreWarnings"
/>
</div>
</div>
<ft-flex-box
v-if="externalPlayer !== ''"
class="externalPlayerSettingsFlexBox"
>
<ft-input
:placeholder="$t('Settings.External Player Settings.Custom External Player Executable')"
:show-arrow="false"
:show-label="true"
:value="externalPlayerExecutable"
:tooltip="$t('Tooltips.External Player Settings.Custom External Player Executable')"
@input="updateExternalPlayerExecutable"
/>
<ft-input
:placeholder="$t('Settings.External Player Settings.Custom External Player Arguments')"
:show-arrow="false"
:show-label="true"
:value="externalPlayerCustomArgs"
:tooltip="$t('Tooltips.External Player Settings.Custom External Player Arguments')"
@input="updateExternalPlayerCustomArgs"
/>
</ft-flex-box>
</ft-card>
</template>
<script src="./external-player-settings.js" />
<style scoped lang="sass" src="./external-player-settings.sass" />

View File

@ -1,7 +1,12 @@
import Vue from 'vue' import Vue from 'vue'
import FtIconButton from '../ft-icon-button/ft-icon-button.vue'
import { mapActions } from 'vuex'
export default Vue.extend({ export default Vue.extend({
name: 'FtListVideo', name: 'FtListPlaylist',
components: {
'ft-icon-button': FtIconButton
},
props: { props: {
data: { data: {
type: Object, type: Object,
@ -40,6 +45,14 @@ export default Vue.extend({
let id = this.channelLink.replace('https://www.youtube.com/user/', '') let id = this.channelLink.replace('https://www.youtube.com/user/', '')
id = id.replace('https://www.youtube.com/channel/', '') id = id.replace('https://www.youtube.com/channel/', '')
return id return id
},
externalPlayer: function () {
return this.$store.getters.getExternalPlayer
},
defaultPlayback: function () {
return this.$store.getters.getDefaultPlayback
} }
}, },
mounted: function () { mounted: function () {
@ -50,6 +63,20 @@ export default Vue.extend({
} }
}, },
methods: { methods: {
handleExternalPlayer: function () {
this.openInExternalPlayer({
strings: this.$t('Video.External Player'),
watchProgress: 0,
playbackRate: this.defaultPlayback,
videoId: null,
playlistId: this.playlistId,
playlistIndex: null,
playlistReverse: null,
playlistShuffle: null,
playlistLoop: null
})
},
parseInvidiousData: function () { parseInvidiousData: function () {
this.title = this.data.title this.title = this.data.title
this.thumbnail = this.data.playlistThumbnail.replace('https://i.ytimg.com', this.invidiousInstance).replace('hqdefault', 'mqdefault') this.thumbnail = this.data.playlistThumbnail.replace('https://i.ytimg.com', this.invidiousInstance).replace('hqdefault', 'mqdefault')
@ -70,6 +97,10 @@ export default Vue.extend({
this.channelLink = this.data.owner.url this.channelLink = this.data.owner.url
this.playlistLink = this.data.url this.playlistLink = this.data.url
this.videoCount = this.data.length this.videoCount = this.data.length
} },
...mapActions([
'openInExternalPlayer'
])
} }
}) })

View File

@ -23,6 +23,16 @@
</div> </div>
</router-link> </router-link>
<div class="info"> <div class="info">
<ft-icon-button
v-if="externalPlayer !== ''"
:title="$t('Video.External Player.OpenInTemplate').replace('$', externalPlayer)"
icon="external-link-alt"
class="externalPlayerButton"
theme="base-no-default"
:size="16"
:use-shadow="false"
@click="handleExternalPlayer"
/>
<router-link <router-link
class="title" class="title"
:to="`/playlist/${playlistId}`" :to="`/playlist/${playlistId}`"

View File

@ -16,6 +16,22 @@ export default Vue.extend({
type: String, type: String,
default: null default: null
}, },
playlistIndex: {
type: Number,
default: null
},
playlistReverse: {
type: Boolean,
default: false
},
playlistShuffle: {
type: Boolean,
default: false
},
playlistLoop: {
type: Boolean,
default: false
},
forceListType: { forceListType: {
type: String, type: String,
default: null default: null
@ -178,6 +194,18 @@ export default Vue.extend({
favoriteIconTheme: function () { favoriteIconTheme: function () {
return this.inFavoritesPlaylist ? 'base favorite' : 'base' return this.inFavoritesPlaylist ? 'base favorite' : 'base'
},
externalPlayer: function () {
return this.$store.getters.getExternalPlayer
},
defaultPlayback: function () {
return this.$store.getters.getDefaultPlayback
},
saveWatchedProgress: function () {
return this.$store.getters.getSaveWatchedProgress
} }
}, },
mounted: function () { mounted: function () {
@ -185,6 +213,26 @@ export default Vue.extend({
this.checkIfWatched() this.checkIfWatched()
}, },
methods: { methods: {
handleExternalPlayer: function () {
this.$emit('pause-player')
this.openInExternalPlayer({
strings: this.$t('Video.External Player'),
watchProgress: this.watchProgress,
playbackRate: this.defaultPlayback,
videoId: this.id,
playlistId: this.playlistId,
playlistIndex: this.playlistIndex,
playlistReverse: this.playlistReverse,
playlistShuffle: this.playlistShuffle,
playlistLoop: this.playlistLoop
})
if (this.saveWatchedProgress && !this.watched) {
this.markAsWatched()
}
},
toggleSave: function () { toggleSave: function () {
if (this.inFavoritesPlaylist) { if (this.inFavoritesPlaylist) {
this.removeFromPlaylist() this.removeFromPlaylist()
@ -365,7 +413,7 @@ export default Vue.extend({
title: this.title, title: this.title,
author: this.channelName, author: this.channelName,
authorId: this.channelId, authorId: this.channelId,
published: this.publishedText.split(',')[0], published: this.publishedText ? this.publishedText.split(',')[0] : this.publishedText,
description: this.description, description: this.description,
viewCount: this.viewCount, viewCount: this.viewCount,
lengthSeconds: this.data.lengthSeconds, lengthSeconds: this.data.lengthSeconds,
@ -437,6 +485,7 @@ export default Vue.extend({
...mapActions([ ...mapActions([
'showToast', 'showToast',
'toLocalePublicationString', 'toLocalePublicationString',
'openInExternalPlayer',
'updateHistory', 'updateHistory',
'removeFromHistory', 'removeFromHistory',
'addVideo', 'addVideo',

View File

@ -31,6 +31,16 @@
> >
{{ isLive ? $t("Video.Live") : duration }} {{ isLive ? $t("Video.Live") : duration }}
</div> </div>
<ft-icon-button
v-if="externalPlayer !== ''"
:title="$t('Video.External Player.OpenInTemplate').replace('$', externalPlayer)"
icon="external-link-alt"
class="externalPlayerIcon"
theme="base"
:padding="appearance === `watchPlaylistItem` ? 6 : 7"
:size="appearance === `watchPlaylistItem` ? 12 : 16"
@click="handleExternalPlayer"
/>
<ft-icon-button <ft-icon-button
v-if="!isLive" v-if="!isLive"
:title="$t('Video.Save Video')" :title="$t('Video.Save Video')"

View File

@ -84,7 +84,23 @@ export default Vue.extend({
}, },
playlistId: { playlistId: {
type: String, type: String,
default: '' default: null
},
getPlaylistIndex: {
type: Function,
required: true
},
getPlaylistReverse: {
type: Function,
required: true
},
getPlaylistShuffle: {
type: Function,
required: true
},
getPlaylistLoop: {
type: Function,
required: true
}, },
theatrePossible: { theatrePossible: {
type: Boolean, type: Boolean,
@ -224,6 +240,14 @@ export default Vue.extend({
} else { } else {
return this.$t('Video.Published on') return this.$t('Video.Published on')
} }
},
externalPlayer: function () {
return this.$store.getters.getExternalPlayer
},
defaultPlayback: function () {
return this.$store.getters.getDefaultPlayback
} }
}, },
mounted: function () { mounted: function () {
@ -243,6 +267,22 @@ export default Vue.extend({
} }
}, },
methods: { methods: {
handleExternalPlayer: function () {
this.$emit('pause-player')
this.openInExternalPlayer({
strings: this.$t('Video.External Player'),
watchProgress: this.getTimestamp(),
playbackRate: this.defaultPlayback,
videoId: this.id,
playlistId: this.playlistId,
playlistIndex: this.getPlaylistIndex(),
playlistReverse: this.getPlaylistReverse(),
playlistShuffle: this.getPlaylistShuffle(),
playlistLoop: this.getPlaylistLoop()
})
},
goToChannel: function () { goToChannel: function () {
this.$router.push({ path: `/channel/${this.channelId}` }) this.$router.push({ path: `/channel/${this.channelId}` })
}, },
@ -388,6 +428,7 @@ export default Vue.extend({
...mapActions([ ...mapActions([
'showToast', 'showToast',
'openInExternalPlayer',
'updateProfile', 'updateProfile',
'addVideo', 'addVideo',
'removeVideo', 'removeVideo',

View File

@ -70,6 +70,14 @@
:theme="favoriteIconTheme" :theme="favoriteIconTheme"
@click="toggleSave" @click="toggleSave"
/> />
<ft-icon-button
v-if="externalPlayer !== ''"
:title="$t('Video.External Player.OpenInTemplate').replace('$', externalPlayer)"
icon="external-link-alt"
class="option"
theme="secondary"
@click="handleExternalPlayer"
/>
<ft-icon-button <ft-icon-button
v-if="theatrePossible" v-if="theatrePossible"
:title="$t('Toggle Theatre Mode')" :title="$t('Toggle Theatre Mode')"

View File

@ -83,8 +83,13 @@
<ft-list-video <ft-list-video
:data="item" :data="item"
:playlist-id="playlistId" :playlist-id="playlistId"
:playlist-index="reversePlaylist ? playlistItems.length - index - 1 : index"
:playlist-reverse="reversePlaylist"
:playlist-shuffle="shuffleEnabled"
:playlist-loop="loopEnabled"
appearance="watchPlaylistItem" appearance="watchPlaylistItem"
force-list-type="list" force-list-type="list"
@pause-player="$emit('pause-player')"
/> />
</div> </div>
</div> </div>

View File

@ -89,6 +89,13 @@ $thumbnail-overlay-opacity: 0.85
@include is-watch-playlist-item @include is-watch-playlist-item
font-size: 12px font-size: 12px
.externalPlayerIcon
position: absolute
bottom: 4px
left: 4px
font-size: 17px
opacity: $thumbnail-overlay-opacity
.favoritesIcon .favoritesIcon
position: absolute position: absolute
top: 3px top: 3px
@ -144,6 +151,9 @@ $thumbnail-overlay-opacity: 0.85
.optionsButton .optionsButton
float: right // ohhhh man, float was finally the right choice for something float: right // ohhhh man, float was finally the right choice for something
.externalPlayerButton
float: right
.title .title
font-size: 20px font-size: 20px
@include low-contrast-when-watched(var(--primary-text-color)) @include low-contrast-when-watched(var(--primary-text-color))

View File

@ -25,5 +25,5 @@
width: 90% width: 90%
@media only screen and (max-width: 460px) @media only screen and (max-width: 460px)
.generalSettingsFlexBox, .playerSettingsFlexBox .generalSettingsFlexBox, .playerSettingsFlexBox, .externalPlayerSettingsFlexBox
justify-content: flex-start justify-content: flex-start

View File

@ -177,6 +177,10 @@ const state = {
displayVideoPlayButton: true, displayVideoPlayButton: true,
enableSearchSuggestions: true, enableSearchSuggestions: true,
enableSubtitles: true, enableSubtitles: true,
externalPlayer: '',
externalPlayerExecutable: '',
externalPlayerIgnoreWarnings: false,
externalPlayerCustomArgs: '',
forceLocalBackendForLegacy: false, forceLocalBackendForLegacy: false,
hideActiveSubscriptions: false, hideActiveSubscriptions: false,
hideChannelSubscriptions: false, hideChannelSubscriptions: false,

View File

@ -52,7 +52,10 @@ const state = {
'#FFAB00', '#FFAB00',
'#FF6D00', '#FF6D00',
'#DD2C00' '#DD2C00'
] ],
externalPlayerNames: [],
externalPlayerValues: [],
externalPlayerCmdArguments: {}
} }
const getters = { const getters = {
@ -102,6 +105,18 @@ const getters = {
getRecentBlogPosts () { getRecentBlogPosts () {
return state.recentBlogPosts return state.recentBlogPosts
},
getExternalPlayerNames () {
return state.externalPlayerNames
},
getExternalPlayerValues () {
return state.externalPlayerValues
},
getExternalPlayerCmdArguments () {
return state.externalPlayerCmdArguments
} }
} }
@ -596,6 +611,181 @@ const actions = {
showToast (_, payload) { showToast (_, payload) {
FtToastEvents.$emit('toast-open', payload.message, payload.action, payload.time) FtToastEvents.$emit('toast-open', payload.message, payload.action, payload.time)
},
showExternalPlayerUnsupportedActionToast: function ({ dispatch }, payload) {
if (!payload.ignoreWarnings) {
const toastMessage = payload.template
.replace('$', payload.externalPlayer)
.replace('%', payload.action)
dispatch('showToast', {
message: toastMessage
})
}
},
getExternalPlayerCmdArgumentsData ({ commit }, payload) {
const fileName = 'external-player-map.json'
let fileData
/* eslint-disable-next-line */
const fileLocation = payload.isDev ? './static/' : `${__dirname}/static/`
if (fs.existsSync(`${fileLocation}${fileName}`)) {
fileData = fs.readFileSync(`${fileLocation}${fileName}`)
} else {
fileData = '[{"name":"None","value":"","cmdArguments":null}]'
}
const externalPlayerMap = JSON.parse(fileData).map((entry) => {
return { name: entry.name, value: entry.value, cmdArguments: entry.cmdArguments }
})
const externalPlayerNames = externalPlayerMap.map((entry) => { return entry.name })
const externalPlayerValues = externalPlayerMap.map((entry) => { return entry.value })
const externalPlayerCmdArguments = externalPlayerMap.reduce((result, item) => {
result[item.value] = item.cmdArguments
return result
}, {})
commit('setExternalPlayerNames', externalPlayerNames)
commit('setExternalPlayerValues', externalPlayerValues)
commit('setExternalPlayerCmdArguments', externalPlayerCmdArguments)
},
openInExternalPlayer ({ dispatch, state, rootState }, payload) {
const args = []
const externalPlayer = rootState.settings.externalPlayer
const cmdArgs = state.externalPlayerCmdArguments[externalPlayer]
const executable = rootState.settings.externalPlayerExecutable !== ''
? rootState.settings.externalPlayerExecutable
: cmdArgs.defaultExecutable
const ignoreWarnings = rootState.settings.externalPlayerIgnoreWarnings
const customArgs = rootState.settings.externalPlayerCustomArgs
if (payload.watchProgress > 0) {
if (typeof cmdArgs.startOffset === 'string') {
args.push(`${cmdArgs.startOffset}${payload.watchProgress}`)
} else {
dispatch('showExternalPlayerUnsupportedActionToast', {
ignoreWarnings,
externalPlayer,
template: payload.strings.UnsupportedActionTemplate,
action: payload.strings['Unsupported Actions']['starting video at offset']
})
}
}
if (payload.playbackRate !== null) {
if (typeof cmdArgs.playbackRate === 'string') {
args.push(`${cmdArgs.playbackRate}${payload.playbackRate}`)
} else {
dispatch('showExternalPlayerUnsupportedActionToast', {
ignoreWarnings,
externalPlayer,
template: payload.strings.UnsupportedActionTemplate,
action: payload.strings['Unsupported Actions']['setting a playback rate']
})
}
}
// Check whether the video is in a playlist
if (typeof cmdArgs.playlistUrl === 'string' && payload.playlistId !== null && payload.playlistId !== '') {
if (payload.playlistIndex !== null) {
if (typeof cmdArgs.playlistIndex === 'string') {
args.push(`${cmdArgs.playlistIndex}${payload.playlistIndex}`)
} else {
dispatch('showExternalPlayerUnsupportedActionToast', {
ignoreWarnings,
externalPlayer,
template: payload.strings.UnsupportedActionTemplate,
action: payload.strings['Unsupported Actions']['opening specific video in a playlist (falling back to opening the video)']
})
}
}
if (payload.playlistReverse) {
if (typeof cmdArgs.playlistReverse === 'string') {
args.push(cmdArgs.playlistReverse)
} else {
dispatch('showExternalPlayerUnsupportedActionToast', {
ignoreWarnings,
externalPlayer,
template: payload.strings.UnsupportedActionTemplate,
action: payload.strings['Unsupported Actions']['reversing playlists']
})
}
}
if (payload.playlistShuffle) {
if (typeof cmdArgs.playlistShuffle === 'string') {
args.push(cmdArgs.playlistShuffle)
} else {
dispatch('showExternalPlayerUnsupportedActionToast', {
ignoreWarnings,
externalPlayer,
template: payload.strings.UnsupportedActionTemplate,
action: payload.strings['Unsupported Actions']['shuffling playlists']
})
}
}
if (payload.playlistLoop) {
if (typeof cmdArgs.playlistLoop === 'string') {
args.push(cmdArgs.playlistLoop)
} else {
dispatch('showExternalPlayerUnsupportedActionToast', {
ignoreWarnings,
externalPlayer,
template: payload.strings.UnsupportedActionTemplate,
action: payload.strings['Unsupported Actions']['looping playlists']
})
}
}
if (cmdArgs.supportsYtdlProtocol) {
args.push(`${cmdArgs.playlistUrl}ytdl://${payload.playlistId}`)
} else {
args.push(`${cmdArgs.playlistUrl}https://youtube.com/playlist?list=${payload.playlistId}`)
}
} else {
if (payload.playlistId !== null && payload.playlistId !== '') {
dispatch('showExternalPlayerUnsupportedActionToast', {
ignoreWarnings,
externalPlayer,
template: payload.strings.UnsupportedActionTemplate,
action: payload.strings['Unsupported Actions']['opening playlists']
})
}
if (payload.videoId !== null) {
if (cmdArgs.supportsYtdlProtocol) {
args.push(`${cmdArgs.videoUrl}ytdl://${payload.videoId}`)
} else {
args.push(`${cmdArgs.videoUrl}https://www.youtube.com/watch?v=${payload.videoId}`)
}
}
}
// Append custom user-defined arguments
if (customArgs !== null) {
const custom = customArgs.split(';')
args.push(...custom)
}
const openingToast = payload.strings.OpeningTemplate
.replace('$', payload.playlistId === null || payload.playlistId === ''
? payload.strings.video
: payload.strings.playlist)
.replace('%', externalPlayer)
dispatch('showToast', {
message: openingToast
})
console.log(executable, args)
const { ipcRenderer } = require('electron')
ipcRenderer.send('openInExternalPlayer', {
executable,
args
})
} }
} }
@ -663,6 +853,18 @@ const mutations = {
setRecentBlogPosts (state, value) { setRecentBlogPosts (state, value) {
state.recentBlogPosts = value state.recentBlogPosts = value
},
setExternalPlayerNames (state, value) {
state.externalPlayerNames = value
},
setExternalPlayerValues (state, value) {
state.externalPlayerValues = value
},
setExternalPlayerCmdArguments (state, value) {
state.externalPlayerCmdArguments = value
} }
} }

View File

@ -18,6 +18,7 @@
:key="index" :key="index"
:data="item" :data="item"
:playlist-id="playlistId" :playlist-id="playlistId"
:playlist-index="index"
appearance="result" appearance="result"
force-list-type="list" force-list-type="list"
/> />

View File

@ -4,6 +4,7 @@ import FtElementList from '../../components/ft-element-list/ft-element-list.vue'
import GeneralSettings from '../../components/general-settings/general-settings.vue' import GeneralSettings from '../../components/general-settings/general-settings.vue'
import ThemeSettings from '../../components/theme-settings/theme-settings.vue' import ThemeSettings from '../../components/theme-settings/theme-settings.vue'
import PlayerSettings from '../../components/player-settings/player-settings.vue' import PlayerSettings from '../../components/player-settings/player-settings.vue'
import ExternalPlayerSettings from '../../components/external-player-settings/external-player-settings.vue'
import SubscriptionSettings from '../../components/subscription-settings/subscription-settings.vue' import SubscriptionSettings from '../../components/subscription-settings/subscription-settings.vue'
import PrivacySettings from '../../components/privacy-settings/privacy-settings.vue' import PrivacySettings from '../../components/privacy-settings/privacy-settings.vue'
import DataSettings from '../../components/data-settings/data-settings.vue' import DataSettings from '../../components/data-settings/data-settings.vue'
@ -19,11 +20,17 @@ export default Vue.extend({
'general-settings': GeneralSettings, 'general-settings': GeneralSettings,
'theme-settings': ThemeSettings, 'theme-settings': ThemeSettings,
'player-settings': PlayerSettings, 'player-settings': PlayerSettings,
'external-player-settings': ExternalPlayerSettings,
'subscription-settings': SubscriptionSettings, 'subscription-settings': SubscriptionSettings,
'privacy-settings': PrivacySettings, 'privacy-settings': PrivacySettings,
'data-settings': DataSettings, 'data-settings': DataSettings,
'distraction-settings': DistractionSettings, 'distraction-settings': DistractionSettings,
'proxy-settings': ProxySettings, 'proxy-settings': ProxySettings,
'sponsor-block-settings': SponsorBlockSettings 'sponsor-block-settings': SponsorBlockSettings
},
computed: {
usingElectron: function () {
return this.$store.getters.getUsingElectron
}
} }
}) })

View File

@ -3,6 +3,7 @@
<general-settings /> <general-settings />
<theme-settings /> <theme-settings />
<player-settings /> <player-settings />
<external-player-settings v-if="usingElectron" />
<subscription-settings /> <subscription-settings />
<distraction-settings /> <distraction-settings />
<privacy-settings /> <privacy-settings />

View File

@ -1174,6 +1174,13 @@ export default Vue.extend({
})) }))
}, },
pausePlayer: function () {
const player = this.$refs.videoPlayer.player
if (player && !player.paused()) {
player.pause()
}
},
getWatchedProgress: function () { getWatchedProgress: function () {
return this.$refs.videoPlayer && this.$refs.videoPlayer.player ? this.$refs.videoPlayer.player.currentTime() : 0 return this.$refs.videoPlayer && this.$refs.videoPlayer.player ? this.$refs.videoPlayer.player.currentTime() : 0
}, },
@ -1182,6 +1189,26 @@ export default Vue.extend({
return Math.floor(this.getWatchedProgress()) return Math.floor(this.getWatchedProgress())
}, },
getPlaylistIndex: function () {
return this.$refs.watchVideoPlaylist
? this.getPlaylistReverse()
? this.$refs.watchVideoPlaylist.playlistItems.length - this.$refs.watchVideoPlaylist.currentVideoIndex
: this.$refs.watchVideoPlaylist.currentVideoIndex - 1
: -1
},
getPlaylistReverse: function () {
return this.$refs.watchVideoPlaylist ? this.$refs.watchVideoPlaylist.reversePlaylist : false
},
getPlaylistShuffle: function () {
return this.$refs.watchVideoPlaylist ? this.$refs.watchVideoPlaylist.shuffleEnabled : false
},
getPlaylistLoop: function () {
return this.$refs.watchVideoPlaylist ? this.$refs.watchVideoPlaylist.loopEnabled : false
},
updateTitle: function () { updateTitle: function () {
document.title = `${this.videoTitle} - FreeTube` document.title = `${this.videoTitle} - FreeTube`
}, },

View File

@ -82,14 +82,19 @@
:is-live="isLive" :is-live="isLive"
:is-upcoming="isUpcoming" :is-upcoming="isUpcoming"
:download-links="downloadLinks" :download-links="downloadLinks"
:watching-playlist="watchingPlaylist"
:playlist-id="playlistId" :playlist-id="playlistId"
:watching-playlist="watchingPlaylist"
:get-playlist-index="getPlaylistIndex"
:get-playlist-reverse="getPlaylistReverse"
:get-playlist-shuffle="getPlaylistShuffle"
:get-playlist-loop="getPlaylistLoop"
:theatre-possible="theatrePossible" :theatre-possible="theatrePossible"
:length-seconds="videoLengthSeconds" :length-seconds="videoLengthSeconds"
:video-thumbnail="thumbnail" :video-thumbnail="thumbnail"
class="watchVideo" class="watchVideo"
:class="{ theatreWatchVideo: useTheatreMode }" :class="{ theatreWatchVideo: useTheatreMode }"
@theatre-mode="toggleTheatreMode" @theatre-mode="toggleTheatreMode"
@pause-player="pausePlayer"
/> />
<watch-video-description <watch-video-description
v-if="!isLoading" v-if="!isLoading"
@ -125,6 +130,7 @@
:video-id="videoId" :video-id="videoId"
class="watchVideoSideBar watchVideoPlaylist" class="watchVideoSideBar watchVideoPlaylist"
:class="{ theatrePlaylist: useTheatreMode }" :class="{ theatrePlaylist: useTheatreMode }"
@pause-player="pausePlayer"
/> />
<watch-video-recommendations <watch-video-recommendations
v-if="!isLoading" v-if="!isLoading"

View File

@ -0,0 +1,23 @@
[
{
"name": "None",
"value": "",
"cmdArguments": null
},
{
"name": "mpv",
"value": "mpv",
"cmdArguments": {
"defaultExecutable": "mpv",
"supportsYtdlProtocol": true,
"videoUrl": "",
"playlistUrl": "",
"startOffset": "--start=",
"playbackRate": "--speed=",
"playlistIndex": "--playlist-start=",
"playlistReverse": null,
"playlistShuffle": "--shuffle",
"playlistLoop": "--loop-playlist"
}
}
]

View File

@ -195,6 +195,12 @@ Settings:
1440p: 1440p 1440p: 1440p
4k: 4k 4k: 4k
8k: 8k 8k: 8k
External Player Settings:
External Player Settings: External Player Settings
External Player: External Player
Ignore Unsupported Action Warnings: Ignore Unsupported Action Warnings
Custom External Player Executable: Custom External Player Executable
Custom External Player Arguments: Custom External Player Arguments
Privacy Settings: Privacy Settings:
Privacy Settings: Privacy Settings Privacy Settings: Privacy Settings
Remember History: Remember History Remember History: Remember History
@ -487,6 +493,23 @@ Video:
self-promotion: self-promotion self-promotion: self-promotion
interaction: interaction interaction: interaction
music offtopic: music offtopic music offtopic: music offtopic
External Player:
# $ is replaced with the external player
OpenInTemplate: Open in $
video: video
playlist: playlist
# $ is replaced with the current context (see video/playlist above) and % the external player setting
OpeningTemplate: Opening $ in %...
# $ is replaced with the external player and % with the unsupported action
UnsupportedActionTemplate: '$ does not support: %'
Unsupported Actions:
starting video at offset: starting video at offset
setting a playback rate: setting a playback rate
opening playlists: opening playlists
opening specific video in a playlist (falling back to opening the video): opening specific video in a playlist (falling back to opening the video)
reversing playlists: reversing playlists
shuffling playlists: shuffling playlists
looping playlists: looping playlists
#& Videos #& Videos
Videos: Videos:
#& Sort By #& Sort By
@ -586,6 +609,16 @@ Tooltips:
Default Video Format: Set the formats used when a video plays. DASH formats can Default Video Format: Set the formats used when a video plays. DASH formats can
play higher qualities. Legacy formats are limited to a max of 720p but use less play higher qualities. Legacy formats are limited to a max of 720p but use less
bandwidth. Audio formats are audio only streams. bandwidth. Audio formats are audio only streams.
External Player Settings:
External Player: Choosing an external player will display an icon, for opening the
video (playlist if supported) in the external player, on the thumbnail.
Custom External Player Executable: By default, FreeTube will assume that the chosen external
player can be found via the PATH environment variable. If needed, a custom path can
be set here.
Ignore Warnings: Suppress warnings for when the current external player does not support
the current action (e.g. reversing playlists, etc.).
Custom External Player Arguments: Any custom command line arguments, separated by semicolons (';'),
you want to be passed to the external player.
Subscription Settings: Subscription Settings:
Fetch Feeds from RSS: When enabled, FreeTube will use RSS instead of its default Fetch Feeds from RSS: When enabled, FreeTube will use RSS instead of its default
method for grabbing your subscription feed. RSS is faster and prevents IP blocking, method for grabbing your subscription feed. RSS is faster and prevents IP blocking,

View File

@ -130,6 +130,8 @@ Settings:
#! List countries #! List countries
View all Invidious instance information: View all Invidious instance information View all Invidious instance information: View all Invidious instance information
System Default: System Default System Default: System Default
External Player: External Player
External Player Executable: Custom External Player Executable
Theme Settings: Theme Settings:
Theme Settings: 'Theme Settings' Theme Settings: 'Theme Settings'
Match Top Bar with Main Color: 'Match top bar with main colour' Match Top Bar with Main Color: 'Match top bar with main colour'
@ -193,6 +195,12 @@ Settings:
Playlist Next Video Interval: Playlist Next Video Interval Playlist Next Video Interval: Playlist Next Video Interval
Next Video Interval: Next Video Interval Next Video Interval: Next Video Interval
Display Play Button In Video Player: Display Play Button In Video Player Display Play Button In Video Player: Display Play Button In Video Player
External Player Settings:
External Player Settings: External Player Settings
External Player: External Player
Ignore Unsupported Action Warnings: Ignore Unsupported Action Warnings
Custom External Player Executable: Custom External Player Executable
Custom External Player Arguments: Custom External Player Arguments
Privacy Settings: Privacy Settings:
Privacy Settings: 'Privacy Settings' Privacy Settings: 'Privacy Settings'
Remember History: 'Remember History' Remember History: 'Remember History'
@ -529,6 +537,23 @@ Video:
intro: intro intro: intro
sponsor: sponsor sponsor: sponsor
Skipped segment: Skipped segment Skipped segment: Skipped segment
External Player:
# $ is replaced with the external player
OpenInTemplate: Open in $
video: video
playlist: playlist
# $ is replaced with the current context (see video/playlist above) and % the external player setting
OpeningTemplate: Opening $ in %...
# $ is replaced with the external player and % with the unsupported action
UnsupportedActionTemplate: '$ does not support: %'
Unsupported Actions:
starting video at offset: starting video at offset
setting a playback rate: setting a playback rate
opening playlists: opening playlists
opening specific video in a playlist (falling back to opening the video): opening specific video in a playlist (falling back to opening the video)
reversing playlists: reversing playlists
shuffling playlists: shuffling playlists
looping playlists: looping playlists
Videos: Videos:
#& Sort By #& Sort By
Sort By: Sort By:
@ -654,6 +679,16 @@ Tooltips:
Preferred API Backend: Choose the back-end that FreeTube uses to obtain data. Preferred API Backend: Choose the back-end that FreeTube uses to obtain data.
The local API is a built-in extractor. The Invidious API requires an Invidious The local API is a built-in extractor. The Invidious API requires an Invidious
server to connect to. server to connect to.
External Player Settings:
External Player: Choosing an external player will display an icon, for opening the
video (playlist if supported) in the external player, on the thumbnail.
Custom External Player Executable: By default, FreeTube will assume that the chosen external
player can be found via the PATH environment variable. If needed, a custom path can
be set here.
Ignore Warnings: Suppress warnings for when the current external player does not support
the current action (e.g. reversing playlists, etc.).
Custom External Player Arguments: Any custom command line arguments, separated by semicolons (';'),
you want to be passed to the external player.
Privacy Settings: Privacy Settings:
Remove Video Meta Files: When enabled, FreeTube automatically deletes meta files Remove Video Meta Files: When enabled, FreeTube automatically deletes meta files
created during video playback, when the watch page is closed. created during video playback, when the watch page is closed.