import IsEqual from 'lodash.isequal' import FtToastEvents from '../../components/ft-toast/ft-toast-events' import fs from 'fs' const state = { isSideNavOpen: false, sessionSearchHistory: [], popularCache: null, trendingCache: { default: null, music: null, gaming: null, movies: null }, showProgressBar: false, progressBarPercentage: 0, regionNames: [], regionValues: [], recentBlogPosts: [], searchSettings: { sortBy: 'relevance', time: '', type: 'all', duration: '' }, colorClasses: [ 'mainRed', 'mainPink', 'mainPurple', 'mainDeepPurple', 'mainIndigo', 'mainBlue', 'mainLightBlue', 'mainCyan', 'mainTeal', 'mainGreen', 'mainLightGreen', 'mainLime', 'mainYellow', 'mainAmber', 'mainOrange', 'mainDeepOrange', 'mainDraculaCyan', 'mainDraculaGreen', 'mainDraculaOrange', 'mainDraculaPink', 'mainDraculaPurple', 'mainDraculaRed', 'mainDraculaYellow' ], colorValues: [ '#d50000', '#C51162', '#AA00FF', '#6200EA', '#304FFE', '#2962FF', '#0091EA', '#00B8D4', '#00BFA5', '#00C853', '#64DD17', '#AEEA00', '#FFD600', '#FFAB00', '#FF6D00', '#DD2C00', '#8BE9FD', '#50FA7B', '#FFB86C', '#FF79C6', '#BD93F9', '#FF5555', '#F1FA8C' ], externalPlayerNames: [], externalPlayerValues: [], externalPlayerCmdArguments: {} } const getters = { getIsSideNavOpen () { return state.isSideNavOpen }, getCurrentVolume () { return state.currentVolume }, getSessionSearchHistory () { return state.sessionSearchHistory }, getPopularCache () { return state.popularCache }, getTrendingCache () { return state.trendingCache }, getSearchSettings () { return state.searchSettings }, getColorValues () { return state.colorValues }, getShowProgressBar () { return state.showProgressBar }, getProgressBarPercentage () { return state.progressBarPercentage }, getRegionNames () { return state.regionNames }, getRegionValues () { return state.regionValues }, getRecentBlogPosts () { return state.recentBlogPosts }, getExternalPlayerNames () { return state.externalPlayerNames }, getExternalPlayerValues () { return state.externalPlayerValues }, getExternalPlayerCmdArguments () { return state.externalPlayerCmdArguments } } /** * Wrapper function that calls `ipcRenderer.invoke(IRCtype, payload)` if the user is * using Electron or a provided custom callback otherwise. * @param {Object} context Object * @param {String} IRCtype String * @param {Function} webCbk Function * @param {Object} payload any (default: null) */ async function invokeIRC(context, IRCtype, webCbk, payload = null) { let response = null const usingElectron = context.rootState.settings.usingElectron if (usingElectron) { const { ipcRenderer } = require('electron') response = await ipcRenderer.invoke(IRCtype, payload) } else if (webCbk) { response = await webCbk() } return response } const actions = { openExternalLink ({ rootState }, url) { const usingElectron = rootState.settings.usingElectron if (usingElectron) { const ipcRenderer = require('electron').ipcRenderer ipcRenderer.send('openExternalLink', url) } else { // Web placeholder } }, async getSystemLocale (context) { const webCbk = () => { if (navigator && navigator.language) { return navigator.language } } return (await invokeIRC(context, 'getSystemLocale', webCbk)) || 'en-US' }, async showOpenDialog (context, options) { // TODO: implement showOpenDialog web compatible callback const webCbk = () => null return await invokeIRC(context, 'showOpenDialog', webCbk, options) }, async showSaveDialog (context, options) { // TODO: implement showSaveDialog web compatible callback const webCbk = () => null return await invokeIRC(context, 'showSaveDialog', webCbk, options) }, async getUserDataPath (context) { // TODO: implement getUserDataPath web compatible callback const webCbk = () => null return await invokeIRC(context, 'getUserDataPath', webCbk) }, updateShowProgressBar ({ commit }, value) { commit('setShowProgressBar', value) }, getRandomColorClass () { const randomInt = Math.floor(Math.random() * state.colorClasses.length) return state.colorClasses[randomInt] }, getRandomColor () { const randomInt = Math.floor(Math.random() * state.colorValues.length) return state.colorValues[randomInt] }, getRegionData ({ commit }, payload) { let fileData /* eslint-disable-next-line */ const fileLocation = payload.isDev ? './static/geolocations/' : `${__dirname}/static/geolocations/` if (fs.existsSync(`${fileLocation}${payload.locale}`)) { fileData = fs.readFileSync(`${fileLocation}${payload.locale}/countries.json`) } else { fileData = fs.readFileSync(`${fileLocation}en-US/countries.json`) } const countries = JSON.parse(fileData).map((entry) => { return { id: entry.id, name: entry.name, code: entry.alpha2 } }) countries.sort((a, b) => { return a.id - b.id }) const regionNames = countries.map((entry) => { return entry.name }) const regionValues = countries.map((entry) => { return entry.code }) commit('setRegionNames', regionNames) commit('setRegionValues', regionValues) }, calculateColorLuminance (_, colorValue) { const cutHex = colorValue.substring(1, 7) const colorValueR = parseInt(cutHex.substring(0, 2), 16) const colorValueG = parseInt(cutHex.substring(2, 4), 16) const colorValueB = parseInt(cutHex.substring(4, 6), 16) const luminance = (0.299 * colorValueR + 0.587 * colorValueG + 0.114 * colorValueB) / 255 if (luminance > 0.5) { return '#000000' } else { return '#FFFFFF' } }, calculatePublishedDate(_, publishedText) { const date = new Date() if (publishedText === 'Live') { return publishedText } const textSplit = publishedText.split(' ') if (textSplit[0].toLowerCase() === 'streamed') { textSplit.shift() } const timeFrame = textSplit[1] const timeAmount = parseInt(textSplit[0]) let timeSpan = null if (timeFrame.indexOf('second') > -1) { timeSpan = timeAmount * 1000 } else if (timeFrame.indexOf('minute') > -1) { timeSpan = timeAmount * 60000 } else if (timeFrame.indexOf('hour') > -1) { timeSpan = timeAmount * 3600000 } else if (timeFrame.indexOf('day') > -1) { timeSpan = timeAmount * 86400000 } else if (timeFrame.indexOf('week') > -1) { timeSpan = timeAmount * 604800000 } else if (timeFrame.indexOf('month') > -1) { timeSpan = timeAmount * 2592000000 } else if (timeFrame.indexOf('year') > -1) { timeSpan = timeAmount * 31556952000 } return date.getTime() - timeSpan }, getVideoParamsFromUrl (_, url) { /** @type {URL} */ let urlObject const paramsObject = { videoId: null, timestamp: null, playlistId: null } try { urlObject = new URL(url) } catch (e) { return paramsObject } function extractParams(videoId) { paramsObject.videoId = videoId paramsObject.timestamp = urlObject.searchParams.get('t') } const extractors = [ // anything with /watch?v= function() { if (urlObject.pathname === '/watch' && urlObject.searchParams.has('v')) { extractParams(urlObject.searchParams.get('v')) paramsObject.playlistId = urlObject.searchParams.get('list') return paramsObject } }, // youtu.be function() { if (urlObject.host === 'youtu.be' && urlObject.pathname.match(/^\/[A-Za-z0-9_-]+$/)) { extractParams(urlObject.pathname.slice(1)) return paramsObject } }, // youtube.com/embed function() { if (urlObject.pathname.match(/^\/embed\/[A-Za-z0-9_-]+$/)) { extractParams(urlObject.pathname.replace('/embed/', '')) return paramsObject } }, // youtube.com/shorts function() { if (urlObject.pathname.match(/^\/shorts\/[A-Za-z0-9_-]+$/)) { extractParams(urlObject.pathname.replace('/shorts/', '')) return paramsObject } }, // cloudtube function() { if (urlObject.host.match(/^cadence\.(gq|moe)$/) && urlObject.pathname.match(/^\/cloudtube\/video\/[A-Za-z0-9_-]+$/)) { extractParams(urlObject.pathname.slice('/cloudtube/video/'.length)) return paramsObject } } ] return extractors.reduce((a, c) => a || c(), null) || paramsObject }, getYoutubeUrlInfo ({ state }, urlStr) { // Returns // - urlType [String] `video`, `playlist` // // If `urlType` is "video" // - videoId [String] // - timestamp [String] // // If `urlType` is "playlist" // - playlistId [String] // - query [Object] // // If `urlType` is "search" // - searchQuery [String] // - query [Object] // // If `urlType` is "hashtag" // Nothing else // // If `urlType` is "channel" // - channelId [String] // // If `urlType` is "unknown" // Nothing else // // If `urlType` is "invalid_url" // Nothing else const { videoId, timestamp, playlistId } = actions.getVideoParamsFromUrl(null, urlStr) if (videoId) { return { urlType: 'video', videoId, playlistId, timestamp } } let url try { url = new URL(urlStr) } catch { return { urlType: 'invalid_url' } } let urlType = 'unknown' const channelPattern = /^\/(?:(c|channel|user)\/)?(?[^/]+)(?:\/(join|featured|videos|playlists|about|community|channels))?\/?$/ const typePatterns = new Map([ ['playlist', /^\/playlist\/?$/], ['search', /^\/results\/?$/], ['hashtag', /^\/hashtag\/([^/?&#]+)$/], ['channel', channelPattern] ]) for (const [type, pattern] of typePatterns) { const matchFound = pattern.test(url.pathname) if (matchFound) { urlType = type break } } switch (urlType) { case 'playlist': { if (!url.searchParams.has('list')) { throw new Error('Playlist: "list" field not found') } const playlistId = url.searchParams.get('list') url.searchParams.delete('list') const query = {} for (const [param, value] of url.searchParams) { query[param] = value } return { urlType: 'playlist', playlistId, query } } case 'search': { if (!url.searchParams.has('search_query')) { throw new Error('Search: "search_query" field not found') } const searchQuery = url.searchParams.get('search_query') url.searchParams.delete('search_query') const searchSettings = state.searchSettings const query = { sortBy: searchSettings.sortBy, time: searchSettings.time, type: searchSettings.type, duration: searchSettings.duration } for (const [param, value] of url.searchParams) { query[param] = value } return { urlType: 'search', searchQuery, query } } case 'hashtag': { return { urlType: 'hashtag' } } /* Using RegExp named capture groups from ES2018 To avoid access to specific captured value broken Channel URL (ID-based) https://www.youtube.com/channel/UCfMJ2MchTSW2kWaT0kK94Yw https://www.youtube.com/channel/UCfMJ2MchTSW2kWaT0kK94Yw/about https://www.youtube.com/channel/UCfMJ2MchTSW2kWaT0kK94Yw/channels https://www.youtube.com/channel/UCfMJ2MchTSW2kWaT0kK94Yw/community https://www.youtube.com/channel/UCfMJ2MchTSW2kWaT0kK94Yw/featured https://www.youtube.com/channel/UCfMJ2MchTSW2kWaT0kK94Yw/join https://www.youtube.com/channel/UCfMJ2MchTSW2kWaT0kK94Yw/playlists https://www.youtube.com/channel/UCfMJ2MchTSW2kWaT0kK94Yw/videos Custom URL https://www.youtube.com/c/YouTubeCreators https://www.youtube.com/c/YouTubeCreators/about etc. Legacy Username URL https://www.youtube.com/user/ufoludek https://www.youtube.com/user/ufoludek/about etc. */ case 'channel': { const channelId = url.pathname.match(channelPattern).groups.channelId if (!channelId) { throw new Error('Channel: could not extract id') } let subPath = null switch (url.pathname.split('/').filter(i => i)[2]) { case 'playlists': subPath = 'playlists' break case 'channels': case 'about': subPath = 'about' break case 'community': default: subPath = 'videos' break } return { urlType: 'channel', channelId, subPath } } default: { // Unknown URL type return { urlType: 'unknown' } } } }, padNumberWithLeadingZeros(_, payload) { let numberString = payload.number.toString() while (numberString.length < payload.length) { numberString = '0' + numberString } return numberString }, async buildVTTFileLocally ({ dispatch }, Storyboard) { let vttString = 'WEBVTT\n\n' // how many images are in one image const numberOfSubImagesPerImage = Storyboard.sWidth * Storyboard.sHeight // the number of storyboard images const numberOfImages = Math.ceil(Storyboard.count / numberOfSubImagesPerImage) const intervalInSeconds = Storyboard.interval / 1000 let currentUrl = Storyboard.url let startHours = 0 let startMinutes = 0 let startSeconds = 0 let endHours = 0 let endMinutes = 0 let endSeconds = intervalInSeconds for (let i = 0; i < numberOfImages; i++) { let xCoord = 0 let yCoord = 0 for (let j = 0; j < numberOfSubImagesPerImage; j++) { // add the timestamp information const paddedStartHours = await dispatch('padNumberWithLeadingZeros', { number: startHours, length: 2 }) const paddedStartMinutes = await dispatch('padNumberWithLeadingZeros', { number: startMinutes, length: 2 }) const paddedStartSeconds = await dispatch('padNumberWithLeadingZeros', { number: startSeconds, length: 2 }) const paddedEndHours = await dispatch('padNumberWithLeadingZeros', { number: endHours, length: 2 }) const paddedEndMinutes = await dispatch('padNumberWithLeadingZeros', { number: endMinutes, length: 2 }) const paddedEndSeconds = await dispatch('padNumberWithLeadingZeros', { number: endSeconds, length: 2 }) vttString += `${paddedStartHours}:${paddedStartMinutes}:${paddedStartSeconds}.000 --> ${paddedEndHours}:${paddedEndMinutes}:${paddedEndSeconds}.000\n` // add the current image url as well as the x, y, width, height information vttString += currentUrl + `#xywh=${xCoord},${yCoord},${Storyboard.width},${Storyboard.height}\n\n` // update the variables startHours = endHours startMinutes = endMinutes startSeconds = endSeconds endSeconds += intervalInSeconds if (endSeconds >= 60) { endSeconds -= 60 endMinutes += 1 } if (endMinutes >= 60) { endMinutes -= 60 endHours += 1 } // x coordinate can only be smaller than the width of one subimage * the number of subimages per row xCoord = (xCoord + Storyboard.width) % (Storyboard.width * Storyboard.sWidth) // only if the x coordinate is , so in a new row, we have to update the y coordinate if (xCoord === 0) { yCoord += Storyboard.height } } // make sure that there is no value like M0 or M1 in the parameters that gets replaced currentUrl = currentUrl.replace('M' + i.toString() + '.jpg', 'M' + (i + 1).toString() + '.jpg') } return vttString }, toLocalePublicationString ({ dispatch }, payload) { if (payload.isLive) { return '0' + payload.liveStreamString } else if (payload.isUpcoming || payload.publishText === null) { // the check for null is currently just an inferring of knowledge, because there is no other possibility left return `${payload.upcomingString}: ${payload.publishText}` } else if (payload.isRSS) { return payload.publishText } const strings = payload.publishText.split(' ') // filters out the streamed x hours ago and removes the streamed in order to keep the rest of the code working if (strings[0].toLowerCase() === 'streamed') { strings.shift() } const singular = (strings[0] === '1') let publicationString = payload.templateString.replace('$', strings[0]) switch (strings[1].substring(0, 2)) { case 'se': if (singular) { publicationString = publicationString.replace('%', payload.timeStrings.Second) } else { publicationString = publicationString.replace('%', payload.timeStrings.Seconds) } break case 'mi': if (singular) { publicationString = publicationString.replace('%', payload.timeStrings.Minute) } else { publicationString = publicationString.replace('%', payload.timeStrings.Minutes) } break case 'ho': if (singular) { publicationString = publicationString.replace('%', payload.timeStrings.Hour) } else { publicationString = publicationString.replace('%', payload.timeStrings.Hours) } break case 'da': if (singular) { publicationString = publicationString.replace('%', payload.timeStrings.Day) } else { publicationString = publicationString.replace('%', payload.timeStrings.Days) } break case 'we': if (singular) { publicationString = publicationString.replace('%', payload.timeStrings.Week) } else { publicationString = publicationString.replace('%', payload.timeStrings.Weeks) } break case 'mo': if (singular) { publicationString = publicationString.replace('%', payload.timeStrings.Month) } else { publicationString = publicationString.replace('%', payload.timeStrings.Months) } break case 'ye': if (singular) { publicationString = publicationString.replace('%', payload.timeStrings.Year) } else { publicationString = publicationString.replace('%', payload.timeStrings.Years) } break } return publicationString }, clearSessionSearchHistory ({ commit }) { commit('setSessionSearchHistory', []) }, 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 // Append custom user-defined arguments, // or use the default ones specified for the external player. if (typeof customArgs === 'string' && customArgs !== '') { const custom = customArgs.split(';') args.push(...custom) } else if (typeof cmdArgs.defaultCustomArguments === 'string' && cmdArgs.defaultCustomArguments !== '') { const defaultCustomArguments = cmdArgs.defaultCustomArguments.split(';') args.push(...defaultCustomArguments) } 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}`) } } } 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 }) } } const mutations = { toggleSideNav (state) { state.isSideNavOpen = !state.isSideNavOpen }, setShowProgressBar (state, value) { state.showProgressBar = value }, setProgressBarPercentage (state, value) { state.progressBarPercentage = value }, setSessionSearchHistory (state, history) { state.sessionSearchHistory = history }, addToSessionSearchHistory (state, payload) { const sameSearch = state.sessionSearchHistory.findIndex((search) => { return search.query === payload.query && IsEqual(payload.searchSettings, search.searchSettings) }) if (sameSearch !== -1) { state.sessionSearchHistory[sameSearch].data = state.sessionSearchHistory[sameSearch].data.concat(payload.data) state.sessionSearchHistory[sameSearch].nextPageRef = payload.nextPageRef } else { state.sessionSearchHistory.push(payload) } }, setPopularCache (state, value) { state.popularCache = value }, setTrendingCache (state, value, page) { state.trendingCache[page] = value }, setSearchSortBy (state, value) { state.searchSettings.sortBy = value }, setSearchTime (state, value) { state.searchSettings.time = value }, setSearchType (state, value) { state.searchSettings.type = value }, setSearchDuration (state, value) { state.searchSettings.duration = value }, setRegionNames (state, value) { state.regionNames = value }, setRegionValues (state, value) { state.regionValues = value }, setRecentBlogPosts (state, value) { state.recentBlogPosts = value }, setExternalPlayerNames (state, value) { state.externalPlayerNames = value }, setExternalPlayerValues (state, value) { state.externalPlayerValues = value }, setExternalPlayerCmdArguments (state, value) { state.externalPlayerCmdArguments = value } } export default { state, getters, actions, mutations }