FreeTube/src/renderer/store/modules/settings.js

440 lines
13 KiB
JavaScript

import { settingsDb } from '../datastores'
import i18n from '../../i18n/index'
/*
* Due to the complexity of the settings module in FreeTube, a more
* in-depth explanation for adding new settings is required.
*
* The explanation will be written with the assumption that
* the reader knows how Vuex works.
*
* And no, there's no need to read the entire wall of text.
* We'll direct you where you need to go as we walk you through it.
* Additionally, the text actually looks bigger than it truly is.
* Each line has, at most, 72 characters.
*
****
* Introduction
*
* You can add a new setting in three different methods.
*
* The first two methods benefit from the auto-generation of
* a getter, a mutation and a few actions related to the setting.
* Those two methods should be preferred whenever possible:
* - `state`
* - `stateWithSideEffects`
*
* The last one DOES NOT feature any kind of auto-generation and should
* only be used in scenarios that don't fall under the other 2 options:
* - `customState`
*
****
* ASIDE:
* The aforementioned "side effects" cover a large area
* of interactions with other modules
* A good example would be a setting that utilizes the Electron API
* when its value changes.
*
****
* First and foremost, you have to understand what type of setting
* you intend to add to the app.
*
* You'll have to select one of these three scenarios:
*
* 1) You just want to add a simple setting that does not actively
* interact with the Electron API, `localStorage` or
* other parts outside of the settings module.
* -> Please consult the `state` section.
*
* 2) You want to add a more complex setting that interacts
* with other parts of the app and tech stack.
* -> Please consult the `state` and `stateWithSideEffects` sections.
*
* 3) You want to add a completely custom state based setting
* that does not work like the usual settings.
* -> Please consult the `state` and `customState` sections.
*
****
* `state`
* This object contains settings that have NO SIDE EFFECTS.
*
* A getter, mutation and an action function is auto-generated
* for every setting present in the `state` object.
* They have the following format (exemplified with setting 'example'):
*
* Getter: `getExample` (gets the value from current state)
* Mutation:
* `setExample`
* (takes a value
* and uses it to update the current state)
* Action:
* `updateExample`
* (takes a value,
* saves it to the database
* and calls `setExample` with it)
*
***
* `stateWithSideEffects`
* This object contains settings that have SIDE EFFECTS.
*
* Each one of these settings must specify an object
* with the following properties:
* - `defaultValue`
* (which is the value you would put down if
* you were to add the setting to the regular `state` object)
*
* - `sideEffectsHandler`
* (which should essentially be a callback of type
* `(store, value) => void`
* that deals with the side effects for that setting)
*
* NOTE: Example implementations of such settings can be found
* in the `stateWithSideEffects` object in case
* the explanation isn't clear enough.
*
* All functions auto-generated for settings in `state`
* (if you haven't read the `state` section, do it now),
* are also auto-generated for settings in `stateWithSideEffects`,
* with a few key differences (exemplified with setting 'example'):
*
* - an additional action is auto-generated:
* - `triggerExampleSideEffects`
* (triggers the `sideEffectsHandler` for that setting;
* you'll most likely never call this directly)
*
* - the behavior of `updateExample` changes a bit:
* - `updateExample`
* (saves value to the database,
* calls `triggerExampleSideEffects` and calls `setExample`)
*
***
* `customState`
* This object contains settings that
* don't linearly fall under the other two options.
*
* No auto-generation of any kind is performed
* when a setting is added to `customState`
*
* You must manually add any getters, mutations and actions to
* `customGetters`, `customMutations` and `customActions` respectively
* that you find appropriate for that setting.
*
* NOTE:
* When adding a setting to the `customState`,
* additional consultation with the FreeTube team is preferred
* to evaluate if it is truly necessary
* and to ensure that the implementation works as intended.
*
* A good example of a setting of this type would be `usingElectron`.
* This setting doesn't need to be persisted in the database
* and it doesn't change over time.
* Therefore, it needs a getter (which we add to `customGetters`), but
* has no need for a mutation or any sort of action.
*
****
* ENDING NOTES
*
* Only two more things that need mentioning.
*
* 1) It's perfectly fine to add extra functionality
* to the `customGetters`, `customMutations` and `customActions`,
* whether it's related to a setting or just serving as
* standalone functionality for the module
* (e.g. `grabUserSettings` (standalone action))
*
* 2) It's also possible to OVERRIDE auto-generated functionality by
* adding functions with the same identifier to
* the respective `custom__` object,
* but you must have an acceptable reason for doing so.
****
*/
// HELPERS
const capitalize = str => str.replace(/^\w/, c => c.toUpperCase())
const defaultGetterId = settingId => 'get' + capitalize(settingId)
const defaultMutationId = settingId => 'set' + capitalize(settingId)
const defaultUpdaterId = settingId => 'update' + capitalize(settingId)
const defaultSideEffectsTriggerId = settingId =>
'trigger' + capitalize(settingId) + 'SideEffects'
/*****/
const state = {
autoplayPlaylists: true,
autoplayVideos: true,
backendFallback: true,
backendPreference: 'local',
barColor: false,
checkForBlogPosts: true,
checkForUpdates: true,
// currentTheme: 'lightRed',
defaultCaptionSettings: '{}',
defaultInterval: 5,
defaultPlayback: 1,
defaultProfile: 'allChannels',
defaultQuality: '720',
defaultSkipInterval: 5,
defaultTheatreMode: false,
defaultVideoFormat: 'dash',
disableSmoothScrolling: false,
displayVideoPlayButton: true,
enableSearchSuggestions: true,
enableSubtitles: true,
externalLinkHandling: '',
externalPlayer: '',
externalPlayerExecutable: '',
externalPlayerIgnoreWarnings: false,
externalPlayerCustomArgs: '',
forceLocalBackendForLegacy: false,
hideActiveSubscriptions: false,
hideChannelSubscriptions: false,
hideCommentLikes: false,
hideLiveChat: false,
hidePlaylists: false,
hidePopularVideos: false,
hideRecommendedVideos: false,
hideTrendingVideos: false,
hideVideoLikesAndDislikes: false,
hideVideoViews: false,
hideWatchedSubs: false,
hideLabelsSideBar: false,
landingPage: 'subscriptions',
listType: 'grid',
playNextVideo: false,
proxyHostname: '127.0.0.1',
proxyPort: '9050',
proxyProtocol: 'socks5',
proxyVideos: false,
region: 'US',
rememberHistory: true,
removeVideoMetaFiles: true,
saveWatchedProgress: true,
sponsorBlockShowSkippedToast: true,
sponsorBlockUrl: 'https://sponsor.ajay.app',
thumbnailPreference: '',
useProxy: false,
useRssFeeds: false,
useSponsorBlock: false,
videoVolumeMouseScroll: false
}
const stateWithSideEffects = {
currentLocale: {
defaultValue: 'en-US',
sideEffectsHandler: async function ({ dispatch }, value) {
const defaultLocale = 'en-US'
let targetLocale = value
if (value === 'system') {
const systemLocale = await dispatch('getSystemLocale')
targetLocale = Object.keys(i18n.messages).find((locale) => {
const localeName = locale.replace('-', '_')
return localeName.includes(systemLocale.replace('-', '_'))
})
// Go back to default value if locale is unavailable
if (!targetLocale) {
targetLocale = defaultLocale
// Translating this string isn't necessary
// because the user will always see it in the default locale
// (in this case, English (US))
dispatch('showToast',
{ message: `Locale not found, defaulting to ${defaultLocale}` }
)
}
}
i18n.locale = targetLocale
dispatch('getRegionData', {
isDev: process.env.NODE_ENV === 'development',
locale: targetLocale
})
}
},
defaultInvidiousInstance: {
defaultValue: '',
sideEffectsHandler: ({ commit, getters }, value) => {
if (value !== '' && getters.getCurrentInvidiousInstance !== value) {
commit('setCurrentInvidiousInstance', value)
}
}
},
defaultVolume: {
defaultValue: 1,
sideEffectsHandler: (_, value) => {
sessionStorage.setItem('volume', value)
}
},
uiScale: {
defaultValue: 100,
sideEffectsHandler: ({ state: { usingElectron } }, value) => {
if (usingElectron) {
const { webFrame } = require('electron')
webFrame.setZoomFactor(value / 100)
}
}
}
}
const customState = {
usingElectron: (window?.process?.type === 'renderer')
}
const customGetters = {
getUsingElectron: (state) => state.usingElectron
}
const customMutations = {}
/**********/
/*
* DO NOT TOUCH THIS SECTION
* If you wanna add to custom data or logic to the module,
* do so in the aproppriate `custom_` variable
*
* Some of the custom actions below use these properties, so I'll be
* adding them here instead of further down for clarity's sake
*/
Object.assign(customState, {
settingsWithSideEffects: Object.keys(stateWithSideEffects)
})
Object.assign(customGetters, {
settingHasSideEffects: (state) => {
return (id) => state.settingsWithSideEffects.includes(id)
}
})
/**********/
const customActions = {
grabUserSettings: async ({ commit, dispatch, getters }) => {
const userSettings = await settingsDb.find({
_id: { $ne: 'bounds' }
})
for (const setting of userSettings) {
const { _id, value } = setting
if (getters.settingHasSideEffects(_id)) {
dispatch(defaultSideEffectsTriggerId(_id), value)
}
commit(defaultMutationId(_id), value)
}
},
// Should be a root action, but we'll tolerate
setupListenerToSyncWindows: ({ commit, dispatch, getters }) => {
// Already known to be Electron, no need to check
const { ipcRenderer } = require('electron')
ipcRenderer.on('syncWindows', (_, payload) => {
const { type, data } = payload
switch (type) {
case 'setting':
// `data` is a single setting => { _id, value }
if (getters.settingHasSideEffects(data._id)) {
dispatch(defaultSideEffectsTriggerId(data._id), data.value)
}
commit(defaultMutationId(data._id), data.value)
break
case 'history':
// `data` is the whole history => Array of history entries
commit('setHistoryCache', data)
break
case 'playlist':
// TODO: Not implemented
break
case 'profile':
// TODO: Not implemented
break
}
})
}
}
/**********************/
/*
* DO NOT TOUCH ANYTHING BELOW
* (unless you plan to change the architecture of this module)
*/
const getters = {}
const mutations = {}
const actions = {}
// Add settings that contain side effects to the state
Object.assign(
state,
Object.fromEntries(
Object.keys(stateWithSideEffects).map(
(key) => [
key,
stateWithSideEffects[key].defaultValue
]
)
)
)
// Build default getters, mutations and actions for every setting id
for (const settingId of Object.keys(state)) {
const getterId = defaultGetterId(settingId)
const mutationId = defaultMutationId(settingId)
const updaterId = defaultUpdaterId(settingId)
const triggerId = defaultSideEffectsTriggerId(settingId)
getters[getterId] = (state) => state[settingId]
mutations[mutationId] = (state, value) => { state[settingId] = value }
// If setting has side effects, generate action to handle them
if (Object.keys(stateWithSideEffects).includes(settingId)) {
actions[triggerId] = stateWithSideEffects[settingId].sideEffectsHandler
}
actions[updaterId] = async ({ commit, dispatch, getters }, value) => {
await settingsDb.update(
{ _id: settingId },
{ _id: settingId, value: value },
{ upsert: true }
)
const {
getUsingElectron: usingElectron,
settingHasSideEffects
} = getters
if (settingHasSideEffects(settingId)) {
dispatch(triggerId, value)
}
commit(mutationId, value)
if (usingElectron) {
const { ipcRenderer } = require('electron')
// Propagate settings to all other existing windows
ipcRenderer.send('syncWindows', {
type: 'setting',
data: { _id: settingId, value: value }
})
}
}
}
// Add all custom data/logic to their respective objects
Object.assign(state, customState)
Object.assign(getters, customGetters)
Object.assign(mutations, customMutations)
Object.assign(actions, customActions)
export default {
state,
getters,
actions,
mutations
}