diff --git a/src/main/index.js b/src/main/index.js
index a5c0c534..910d205c 100644
--- a/src/main/index.js
+++ b/src/main/index.js
@@ -4,6 +4,7 @@ import {
} from 'electron'
import Datastore from 'nedb'
import path from 'path'
+import cp from 'child_process'
if (process.argv.includes('--version')) {
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', () => {
if (process.platform !== 'darwin') {
app.quit()
diff --git a/src/renderer/App.js b/src/renderer/App.js
index 11af0ef6..fed5bf3a 100644
--- a/src/renderer/App.js
+++ b/src/renderer/App.js
@@ -76,6 +76,9 @@ export default Vue.extend({
},
defaultProfile: function () {
return this.$store.getters.getDefaultProfile
+ },
+ externalPlayer: function () {
+ return this.$store.getters.getExternalPlayer
}
},
mounted: function () {
@@ -87,8 +90,6 @@ export default Vue.extend({
this.checkThemeSettings()
await this.checkLocale()
- this.dataReady = true
-
if (this.usingElectron) {
console.log('User is using Electron')
ipcRenderer = require('electron').ipcRenderer
@@ -96,8 +97,11 @@ export default Vue.extend({
this.openAllLinksExternally()
this.enableOpenUrl()
this.setBoundsOnClose()
+ await this.checkExternalPlayer()
}
+ this.dataReady = true
+
setTimeout(() => {
this.checkForNewUpdates()
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) {
if (response !== false) {
this.showReleaseNotes = true
@@ -406,6 +418,7 @@ export default Vue.extend({
'getRegionData',
'getYoutubeUrlInfo',
'getLocale',
+ 'getExternalPlayerCmdArgumentsData',
'setUpListenerToSyncSettings'
])
}
diff --git a/src/renderer/components/external-player-settings/external-player-settings.js b/src/renderer/components/external-player-settings/external-player-settings.js
new file mode 100644
index 00000000..62a15040
--- /dev/null
+++ b/src/renderer/components/external-player-settings/external-player-settings.js
@@ -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'
+ ])
+ }
+})
diff --git a/src/renderer/components/external-player-settings/external-player-settings.sass b/src/renderer/components/external-player-settings/external-player-settings.sass
new file mode 100644
index 00000000..05cb0dfb
--- /dev/null
+++ b/src/renderer/components/external-player-settings/external-player-settings.sass
@@ -0,0 +1 @@
+@use "../../sass-partials/settings"
diff --git a/src/renderer/components/external-player-settings/external-player-settings.vue b/src/renderer/components/external-player-settings/external-player-settings.vue
new file mode 100644
index 00000000..8a945fea
--- /dev/null
+++ b/src/renderer/components/external-player-settings/external-player-settings.vue
@@ -0,0 +1,56 @@
+
+
+
+ {{ $t("Settings.External Player Settings.External Player Settings") }}
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/renderer/components/ft-list-playlist/ft-list-playlist.js b/src/renderer/components/ft-list-playlist/ft-list-playlist.js
index 01fcc483..1d941d12 100644
--- a/src/renderer/components/ft-list-playlist/ft-list-playlist.js
+++ b/src/renderer/components/ft-list-playlist/ft-list-playlist.js
@@ -1,7 +1,12 @@
import Vue from 'vue'
+import FtIconButton from '../ft-icon-button/ft-icon-button.vue'
+import { mapActions } from 'vuex'
export default Vue.extend({
- name: 'FtListVideo',
+ name: 'FtListPlaylist',
+ components: {
+ 'ft-icon-button': FtIconButton
+ },
props: {
data: {
type: Object,
@@ -40,6 +45,14 @@ export default Vue.extend({
let id = this.channelLink.replace('https://www.youtube.com/user/', '')
id = id.replace('https://www.youtube.com/channel/', '')
return id
+ },
+
+ externalPlayer: function () {
+ return this.$store.getters.getExternalPlayer
+ },
+
+ defaultPlayback: function () {
+ return this.$store.getters.getDefaultPlayback
}
},
mounted: function () {
@@ -50,6 +63,20 @@ export default Vue.extend({
}
},
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 () {
this.title = this.data.title
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.playlistLink = this.data.url
this.videoCount = this.data.length
- }
+ },
+
+ ...mapActions([
+ 'openInExternalPlayer'
+ ])
}
})
diff --git a/src/renderer/components/ft-list-playlist/ft-list-playlist.vue b/src/renderer/components/ft-list-playlist/ft-list-playlist.vue
index 091d576f..f737a989 100644
--- a/src/renderer/components/ft-list-playlist/ft-list-playlist.vue
+++ b/src/renderer/components/ft-list-playlist/ft-list-playlist.vue
@@ -23,6 +23,16 @@
+
{{ isLive ? $t("Video.Live") : duration }}
+
+
diff --git a/src/renderer/sass-partials/_ft-list-item.sass b/src/renderer/sass-partials/_ft-list-item.sass
index 5a2ce614..d8682bb1 100644
--- a/src/renderer/sass-partials/_ft-list-item.sass
+++ b/src/renderer/sass-partials/_ft-list-item.sass
@@ -89,6 +89,13 @@ $thumbnail-overlay-opacity: 0.85
@include is-watch-playlist-item
font-size: 12px
+ .externalPlayerIcon
+ position: absolute
+ bottom: 4px
+ left: 4px
+ font-size: 17px
+ opacity: $thumbnail-overlay-opacity
+
.favoritesIcon
position: absolute
top: 3px
@@ -144,6 +151,9 @@ $thumbnail-overlay-opacity: 0.85
.optionsButton
float: right // ohhhh man, float was finally the right choice for something
+ .externalPlayerButton
+ float: right
+
.title
font-size: 20px
@include low-contrast-when-watched(var(--primary-text-color))
diff --git a/src/renderer/sass-partials/_settings.sass b/src/renderer/sass-partials/_settings.sass
index 2eb4af2a..b9c52397 100644
--- a/src/renderer/sass-partials/_settings.sass
+++ b/src/renderer/sass-partials/_settings.sass
@@ -25,5 +25,5 @@
width: 90%
@media only screen and (max-width: 460px)
- .generalSettingsFlexBox, .playerSettingsFlexBox
+ .generalSettingsFlexBox, .playerSettingsFlexBox, .externalPlayerSettingsFlexBox
justify-content: flex-start
diff --git a/src/renderer/store/modules/settings.js b/src/renderer/store/modules/settings.js
index b81f5c69..c12356b3 100644
--- a/src/renderer/store/modules/settings.js
+++ b/src/renderer/store/modules/settings.js
@@ -177,6 +177,10 @@ const state = {
displayVideoPlayButton: true,
enableSearchSuggestions: true,
enableSubtitles: true,
+ externalPlayer: '',
+ externalPlayerExecutable: '',
+ externalPlayerIgnoreWarnings: false,
+ externalPlayerCustomArgs: '',
forceLocalBackendForLegacy: false,
hideActiveSubscriptions: false,
hideChannelSubscriptions: false,
diff --git a/src/renderer/store/modules/utils.js b/src/renderer/store/modules/utils.js
index c6ebc962..06cec337 100644
--- a/src/renderer/store/modules/utils.js
+++ b/src/renderer/store/modules/utils.js
@@ -52,7 +52,10 @@ const state = {
'#FFAB00',
'#FF6D00',
'#DD2C00'
- ]
+ ],
+ externalPlayerNames: [],
+ externalPlayerValues: [],
+ externalPlayerCmdArguments: {}
}
const getters = {
@@ -102,6 +105,18 @@ const getters = {
getRecentBlogPosts () {
return state.recentBlogPosts
+ },
+
+ getExternalPlayerNames () {
+ return state.externalPlayerNames
+ },
+
+ getExternalPlayerValues () {
+ return state.externalPlayerValues
+ },
+
+ getExternalPlayerCmdArguments () {
+ return state.externalPlayerCmdArguments
}
}
@@ -596,6 +611,181 @@ const actions = {
showToast (_, payload) {
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) {
state.recentBlogPosts = value
+ },
+
+ setExternalPlayerNames (state, value) {
+ state.externalPlayerNames = value
+ },
+
+ setExternalPlayerValues (state, value) {
+ state.externalPlayerValues = value
+ },
+
+ setExternalPlayerCmdArguments (state, value) {
+ state.externalPlayerCmdArguments = value
}
}
diff --git a/src/renderer/views/Playlist/Playlist.vue b/src/renderer/views/Playlist/Playlist.vue
index b4c12fa1..22a04704 100644
--- a/src/renderer/views/Playlist/Playlist.vue
+++ b/src/renderer/views/Playlist/Playlist.vue
@@ -18,6 +18,7 @@
:key="index"
:data="item"
:playlist-id="playlistId"
+ :playlist-index="index"
appearance="result"
force-list-type="list"
/>
diff --git a/src/renderer/views/Settings/Settings.js b/src/renderer/views/Settings/Settings.js
index d0c78bf2..4cfacc1e 100644
--- a/src/renderer/views/Settings/Settings.js
+++ b/src/renderer/views/Settings/Settings.js
@@ -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 ThemeSettings from '../../components/theme-settings/theme-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 PrivacySettings from '../../components/privacy-settings/privacy-settings.vue'
import DataSettings from '../../components/data-settings/data-settings.vue'
@@ -19,11 +20,17 @@ export default Vue.extend({
'general-settings': GeneralSettings,
'theme-settings': ThemeSettings,
'player-settings': PlayerSettings,
+ 'external-player-settings': ExternalPlayerSettings,
'subscription-settings': SubscriptionSettings,
'privacy-settings': PrivacySettings,
'data-settings': DataSettings,
'distraction-settings': DistractionSettings,
'proxy-settings': ProxySettings,
'sponsor-block-settings': SponsorBlockSettings
+ },
+ computed: {
+ usingElectron: function () {
+ return this.$store.getters.getUsingElectron
+ }
}
})
diff --git a/src/renderer/views/Settings/Settings.vue b/src/renderer/views/Settings/Settings.vue
index 9fbf0585..f801e6da 100644
--- a/src/renderer/views/Settings/Settings.vue
+++ b/src/renderer/views/Settings/Settings.vue
@@ -3,6 +3,7 @@
+
diff --git a/src/renderer/views/Watch/Watch.js b/src/renderer/views/Watch/Watch.js
index 103c06bf..b3a02723 100644
--- a/src/renderer/views/Watch/Watch.js
+++ b/src/renderer/views/Watch/Watch.js
@@ -1174,6 +1174,13 @@ export default Vue.extend({
}))
},
+ pausePlayer: function () {
+ const player = this.$refs.videoPlayer.player
+ if (player && !player.paused()) {
+ player.pause()
+ }
+ },
+
getWatchedProgress: function () {
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())
},
+ 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 () {
document.title = `${this.videoTitle} - FreeTube`
},
diff --git a/src/renderer/views/Watch/Watch.vue b/src/renderer/views/Watch/Watch.vue
index 8424841e..29e4a5d1 100644
--- a/src/renderer/views/Watch/Watch.vue
+++ b/src/renderer/views/Watch/Watch.vue
@@ -82,14 +82,19 @@
:is-live="isLive"
:is-upcoming="isUpcoming"
:download-links="downloadLinks"
- :watching-playlist="watchingPlaylist"
: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"
:length-seconds="videoLengthSeconds"
:video-thumbnail="thumbnail"
class="watchVideo"
:class="{ theatreWatchVideo: useTheatreMode }"
@theatre-mode="toggleTheatreMode"
+ @pause-player="pausePlayer"
/>