Add Parental Controls (#1456)

* start to implement some parental controls

* Hide share + Hide Unsubscribe

* Hide live streams

* fix hide live streams

* Add "parental-control-settings"

* Implement Hide Live Streams & Hide "Age Restricted"

* Hide live streams from Subscriptions + fix hide live streams from search

* enable safe search on showFamilyFriendlyOnly

* Move some settings from parental control to distraction free

* fix channel loading

* make parental control settings collapsible

* fix lint

* dont show age restricted on videos that are loading

* improve hide live videos

* code refactor

* grammar

* nvm im dumb

* use named placeholder for age restricted message

* improve readability

* change Hide Description to Hide Video Description

* update translated strings

* fix age restricted component

Co-authored-by: Preston <freetubeapp@protonmail.com>
Co-authored-by: peepopoggers <72892531+peepopoggers@users.noreply.github.com>
This commit is contained in:
ChunkyProgrammer 2022-06-21 02:14:15 -04:00 committed by GitHub
parent ca2799e999
commit 3321fa91e4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
28 changed files with 368 additions and 65 deletions

View File

@ -45,6 +45,18 @@ export default Vue.extend({
},
hideActiveSubscriptions: function () {
return this.$store.getters.getHideActiveSubscriptions
},
hideVideoDescription: function () {
return this.$store.getters.getHideVideoDescription
},
hideComments: function () {
return this.$store.getters.getHideComments
},
hideLiveStreams: function() {
return this.$store.getters.getHideLiveStreams
},
hideSharingActions: function() {
return this.$store.getters.getHideSharingActions
}
},
methods: {
@ -68,7 +80,11 @@ export default Vue.extend({
'updateHideLiveChat',
'updateHideActiveSubscriptions',
'updatePlayNextVideo',
'updateDefaultTheatreMode'
'updateDefaultTheatreMode',
'updateHideVideoDescription',
'updateHideComments',
'updateHideLiveStreams',
'updateHideSharingActions'
])
}
})

View File

@ -38,6 +38,18 @@
:default-value="hideActiveSubscriptions"
@change="updateHideActiveSubscriptions"
/>
<ft-toggle-switch
:label="$t('Settings.Distraction Free Settings.Hide Video Description')"
:compact="true"
:default-value="hideVideoDescription"
@change="updateHideVideoDescription"
/>
<ft-toggle-switch
:label="$t('Settings.Distraction Free Settings.Hide Sharing Actions')"
:compact="true"
:default-value="hideSharingActions"
@change="updateHideSharingActions"
/>
</div>
<div class="switchColumn">
<ft-toggle-switch
@ -70,6 +82,18 @@
:default-value="hideLiveChat"
@change="updateHideLiveChat"
/>
<ft-toggle-switch
:label="$t('Settings.Distraction Free Settings.Hide Live Streams')"
:compact="true"
:default-value="hideLiveStreams"
@change="updateHideLiveStreams"
/>
<ft-toggle-switch
:label="$t('Settings.Distraction Free Settings.Hide Comments')"
:compact="true"
:default-value="hideComments"
@change="updateHideComments"
/>
</div>
</div>
<br>

View File

@ -0,0 +1,22 @@
import Vue from 'vue'
export default Vue.extend({
name: 'FtAgeRestricted',
props: {
contentTypeString: {
type: String,
required: true
}
},
computed: {
emoji: function () {
const emojis = ['😵', '😦', '🙁', '☹️', '😦', '🤫', '😕']
return emojis[Math.floor(Math.random() * emojis.length)]
},
restrictedMessage: function () {
const contentType = this.$t('Age Restricted.Type.' + this.contentTypeString)
return this.$t('Age Restricted.This $contentType is age restricted').replace('$contentType', contentType)
}
}
})

View File

@ -0,0 +1,14 @@
.ft-age-restricted
color: var(--primary-text-color)
h2
width: 100%
text-align: center
background-color: var(--card-bg-color)
padding: 10px 0
.frown
width: 100%
text-align: center
background-color: var(--card-bg-color)
font-size: 10em
padding: 20px 0
height: 100%

View File

@ -0,0 +1,15 @@
<template>
<div
class="ft-age-restricted"
>
<h2>
{{ restrictedMessage }}
</h2>
<div class="frown">
{{ emoji }}
</div>
</div>
</template>
<script src="./ft-age-restricted.js" />
<style scoped lang="sass" src="./ft-age-restricted.sass" />

View File

@ -33,6 +33,11 @@ export default Vue.extend({
visible: this.firstScreen
}
},
computed: {
hideLiveStreams: function() {
return this.$store.getters.getHideLiveStreams
}
},
methods: {
onVisibilityChanged: function (visible) {
this.visible = visible

View File

@ -1,5 +1,6 @@
<template>
<div
v-if="data.type !== undefined && (data.type === 'video' ? ((!data.liveNow && (data.lengthSeconds != null)) || (!hideLiveStreams)) : true)"
v-observe-visibility="firstScreen ? false : {
callback: onVisibilityChanged,
once: true,

View File

@ -117,69 +117,76 @@ export default Vue.extend({
return (this.watchProgress / this.data.lengthSeconds) * 100
},
hideSharingActions: function() {
return this.$store.getters.getHideSharingActions
},
dropdownOptions: function () {
const options = []
options.push(
{
label: this.watched
? this.$t('Video.Remove From History')
: this.$t('Video.Mark As Watched'),
value: 'history'
},
{
type: 'divider'
},
{
label: this.$t('Video.Copy YouTube Link'),
value: 'copyYoutube'
},
{
label: this.$t('Video.Copy YouTube Embedded Player Link'),
value: 'copyYoutubeEmbed'
},
{
label: this.$t('Video.Copy Invidious Link'),
value: 'copyInvidious'
},
{
type: 'divider'
},
{
label: this.$t('Video.Open in YouTube'),
value: 'openYoutube'
},
{
label: this.$t('Video.Open YouTube Embedded Player'),
value: 'openYoutubeEmbed'
},
{
label: this.$t('Video.Open in Invidious'),
value: 'openInvidious'
},
{
type: 'divider'
},
{
label: this.$t('Video.Copy YouTube Channel Link'),
value: 'copyYoutubeChannel'
},
{
label: this.$t('Video.Copy Invidious Channel Link'),
value: 'copyInvidiousChannel'
},
{
type: 'divider'
},
{
label: this.$t('Video.Open Channel in YouTube'),
value: 'openYoutubeChannel'
},
{
label: this.$t('Video.Open Channel in Invidious'),
value: 'openInvidiousChannel'
}
)
if (!this.hideSharingActions) {
options.push(
{
type: 'divider'
},
{
label: this.$t('Video.Copy YouTube Link'),
value: 'copyYoutube'
},
{
label: this.$t('Video.Copy YouTube Embedded Player Link'),
value: 'copyYoutubeEmbed'
},
{
label: this.$t('Video.Copy Invidious Link'),
value: 'copyInvidious'
},
{
type: 'divider'
},
{
label: this.$t('Video.Open in YouTube'),
value: 'openYoutube'
},
{
label: this.$t('Video.Open YouTube Embedded Player'),
value: 'openYoutubeEmbed'
},
{
label: this.$t('Video.Open in Invidious'),
value: 'openInvidious'
},
{
type: 'divider'
},
{
label: this.$t('Video.Copy YouTube Channel Link'),
value: 'copyYoutubeChannel'
},
{
label: this.$t('Video.Copy Invidious Channel Link'),
value: 'copyInvidiousChannel'
},
{
type: 'divider'
},
{
label: this.$t('Video.Open Channel in YouTube'),
value: 'openYoutubeChannel'
},
{
label: this.$t('Video.Open Channel in Invidious'),
value: 'openInvidiousChannel'
}
)
}
return options
},
@ -203,6 +210,11 @@ export default Vue.extend({
return `${baseUrl}/vi/${this.id}/mqdefault.jpg`
}
},
hideLiveStreams: function() {
return this.$store.getters.getHideLiveStreams
},
hideVideoViews: function () {
return this.$store.getters.getHideVideoViews
},

View File

@ -0,0 +1,36 @@
import Vue from 'vue'
import { mapActions } from 'vuex'
import FtCard from '../ft-card/ft-card.vue'
import FtToggleSwitch from '../ft-toggle-switch/ft-toggle-switch.vue'
import FtButton from '../ft-button/ft-button.vue'
import FtSelect from '../ft-select/ft-select.vue'
import FtFlexBox from '../ft-flex-box/ft-flex-box.vue'
export default Vue.extend({
name: 'ParentalControlSettings',
components: {
'ft-card': FtCard,
'ft-toggle-switch': FtToggleSwitch,
'ft-button': FtButton,
'ft-select': FtSelect,
'ft-flex-box': FtFlexBox
},
computed: {
hideSearchBar: function () {
return this.$store.getters.getHideSearchBar
},
hideUnsubscribeButton: function() {
return this.$store.getters.getHideUnsubscribeButton
},
showFamilyFriendlyOnly: function() {
return this.$store.getters.getShowFamilyFriendlyOnly
}
},
methods: {
...mapActions([
'updateHideSearchBar',
'updateHideUnsubscribeButton',
'updateShowFamilyFriendlyOnly'
])
}
})

View File

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

View File

@ -0,0 +1,37 @@
<template>
<details>
<summary>
<h3>
{{ $t("Settings.Parental Control Settings.Parental Control Settings") }}
</h3>
</summary>
<hr>
<div class="switchColumnGrid">
<div class="switchColumn">
<ft-toggle-switch
:label="$t('Settings.Parental Control Settings.Hide Unsubscribe Button')"
:compact="true"
:default-value="hideUnsubscribeButton"
@change="updateHideUnsubscribeButton"
/>
<ft-toggle-switch
:label="$t('Settings.Parental Control Settings.Show Family Friendly Only')"
:compact="true"
:default-value="showFamilyFriendlyOnly"
@change="updateShowFamilyFriendlyOnly"
/>
</div>
<div class="switchColumn">
<ft-toggle-switch
:label="$t('Settings.Parental Control Settings.Hide Search Bar')"
:compact="true"
:default-value="hideSearchBar"
@change="updateHideSearchBar"
/>
</div>
</div>
</details>
</template>
<script src="./parental-control-settings.js" />
<style scoped lang="sass" src="./parental-control-settings.sass" />

View File

@ -35,6 +35,10 @@ export default Vue.extend({
}
},
computed: {
hideSharingActions: function() {
return this.$store.getters.getHideSharingActions
},
currentInvidiousInstance: function () {
return this.$store.getters.getCurrentInvidiousInstance
},

View File

@ -47,6 +47,7 @@
<br>
<ft-list-dropdown
v-if="!hideSharingActions"
:title="$t('Playlist.Share Playlist.Share Playlist')"
:label-names="shareHeaders"
:label-values="shareValues"

View File

@ -32,6 +32,10 @@ export default Vue.extend({
return this.$store.getters.getUsingElectron
},
hideSearchBar: function () {
return this.$store.getters.getHideSearchBar
},
enableSearchSuggestions: function () {
return this.$store.getters.getEnableSearchSuggestions
},

View File

@ -33,6 +33,7 @@
@keypress="historyForward"
/>
<font-awesome-icon
v-if="!hideSearchBar"
class="navSearchIcon navIcon"
icon="search"
role="button"
@ -66,6 +67,7 @@
<div class="middle">
<div class="searchContainer">
<ft-input
v-if="!hideSearchBar"
ref="searchInput"
:placeholder="$t('Search / Go to URL')"
class="searchInput"
@ -78,6 +80,7 @@
@click="goToSearch"
/>
<font-awesome-icon
v-if="!hideSearchBar"
class="navFilterIcon navIcon"
:class="{ filterChanged: searchFilterValueChanged }"
icon="filter"
@ -88,6 +91,7 @@
/>
</div>
<ft-search-filters
v-if="!hideSearchBar"
v-show="showFilters"
class="searchFilters"
@filterValueUpdated="handleSearchFilterValueChanged"

View File

@ -126,6 +126,14 @@ export default Vue.extend({
return this.$store.getters.getCurrentInvidiousInstance
},
hideSharingActions: function() {
return this.$store.getters.getHideSharingActions
},
hideUnsubscribeButton: function() {
return this.$store.getters.getHideUnsubscribeButton
},
currentLocale: function () {
return this.$store.getters.getCurrentLocale
},

View File

@ -27,6 +27,7 @@
{{ channelName }}
</div>
<ft-button
v-if="!hideUnsubscribeButton"
:label="subscribedText"
class="subscribeButton"
background-color="var(--primary-color)"
@ -113,6 +114,7 @@
@click="handleFormatChange"
/>
<ft-share-button
v-if="!hideSharingActions"
:id="id"
:get-timestamp="getTimestamp"
:playlist-id="playlistId"

View File

@ -192,11 +192,17 @@ const state = {
hideActiveSubscriptions: false,
hideChannelSubscriptions: false,
hideCommentLikes: false,
hideComments: false,
hideVideoDescription: false,
hideLiveChat: false,
hideLiveStreams: false,
hidePlaylists: false,
hidePopularVideos: false,
hideRecommendedVideos: false,
hideSearchBar: false,
hideSharingActions: false,
hideTrendingVideos: false,
hideUnsubscribeButton: false,
hideVideoLikesAndDislikes: false,
hideVideoViews: false,
hideWatchedSubs: false,
@ -213,6 +219,7 @@ const state = {
rememberHistory: true,
removeVideoMetaFiles: true,
saveWatchedProgress: true,
showFamilyFriendlyOnly: false,
sponsorBlockShowSkippedToast: true,
sponsorBlockUrl: 'https://sponsor.ajay.app',
sponsorBlockSponsor: {

View File

@ -8,6 +8,7 @@ import FtFlexBox from '../../components/ft-flex-box/ft-flex-box.vue'
import FtChannelBubble from '../../components/ft-channel-bubble/ft-channel-bubble.vue'
import FtLoader from '../../components/ft-loader/ft-loader.vue'
import FtElementList from '../../components/ft-element-list/ft-element-list.vue'
import FtAgeRestricted from '../../components/ft-age-restricted/ft-age-restricted.vue'
import ytch from 'yt-channel-info'
import autolinker from 'autolinker'
@ -23,7 +24,8 @@ export default Vue.extend({
'ft-flex-box': FtFlexBox,
'ft-channel-bubble': FtChannelBubble,
'ft-loader': FtLoader,
'ft-element-list': FtElementList
'ft-element-list': FtElementList,
'ft-age-restricted': FtAgeRestricted
},
data: function () {
return {
@ -50,6 +52,7 @@ export default Vue.extend({
searchResults: [],
shownElementList: [],
apiUsed: '',
isFamilyFriendly: false,
errorMessage: '',
videoSelectValues: [
'newest',
@ -75,6 +78,14 @@ export default Vue.extend({
return this.$store.getters.getBackendFallback
},
hideUnsubscribeButton: function() {
return this.$store.getters.getHideUnsubscribeButton
},
showFamilyFriendlyOnly: function() {
return this.$store.getters.getShowFamilyFriendlyOnly
},
currentInvidiousInstance: function () {
return this.$store.getters.getCurrentInvidiousInstance
},
@ -264,6 +275,7 @@ export default Vue.extend({
const channelThumbnailUrl = response.authorThumbnails[2].url
this.id = channelId
this.channelName = channelName
this.isFamilyFriendly = response.isFamilyFriendly
document.title = `${this.channelName} - ${process.env.PRODUCT_NAME}`
if (this.hideChannelSubscriptions || response.subscriberCount === 0) {
this.subCount = null
@ -383,6 +395,7 @@ export default Vue.extend({
this.channelName = channelName
document.title = `${this.channelName} - ${process.env.PRODUCT_NAME}`
this.id = channelId
this.isFamilyFriendly = response.isFamilyFriendly
if (this.hideChannelSubscriptions) {
this.subCount = null
} else {

View File

@ -7,7 +7,7 @@
:fullscreen="true"
/>
<ft-card
v-else
v-else-if="(isFamilyFriendly || !showFamilyFriendlyOnly)"
class="card channelDetails"
>
<div
@ -52,6 +52,7 @@
</div>
<ft-button
v-if="!hideUnsubscribeButton"
:label="subscribedText"
background-color="var(--primary-color)"
text-color="var(--text-with-main-color)"
@ -113,7 +114,7 @@
</div>
</ft-card>
<ft-card
v-if="!isLoading && !errorMessage"
v-if="!isLoading && !errorMessage && (isFamilyFriendly || !showFamilyFriendlyOnly)"
class="card"
>
<div
@ -203,6 +204,11 @@
{{ errorMessage }}
</p>
</ft-card>
<ft-age-restricted
v-else-if="!isLoading && (!isFamilyFriendly && showFamilyFriendlyOnly)"
class="ageRestricted"
:content-type-string="'Channel'"
/>
</div>
</template>

View File

@ -37,6 +37,13 @@ export default Vue.extend({
backendFallback: function () {
return this.$store.getters.getBackendFallback
},
hideLiveStreams: function() {
return this.$store.getters.getHideLiveStreams
},
showFamilyFriendlyOnly: function() {
return this.$store.getters.getShowFamilyFriendlyOnly
}
},
watch: {
@ -94,6 +101,7 @@ export default Vue.extend({
if (sameSearch.length > 0) {
console.log(sameSearch)
// Replacing the data right away causes a strange error where the data
// Shown is mixed from 2 different search results. So we'll wait a moment
// Before showing the results.
@ -118,6 +126,8 @@ export default Vue.extend({
payload.options.pages = 1
}
payload.options.safeSearch = this.showFamilyFriendlyOnly
this.ytSearch(payload).then((result) => {
console.log(result)
if (!result) {

View File

@ -12,6 +12,7 @@ import DataSettings from '../../components/data-settings/data-settings.vue'
import DistractionSettings from '../../components/distraction-settings/distraction-settings.vue'
import ProxySettings from '../../components/proxy-settings/proxy-settings.vue'
import SponsorBlockSettings from '../../components/sponsor-block-settings/sponsor-block-settings.vue'
import ParentControlSettings from '../../components/parental-control-settings/parental-control-settings.vue'
export default Vue.extend({
name: 'Settings',
@ -28,7 +29,8 @@ export default Vue.extend({
'distraction-settings': DistractionSettings,
'proxy-settings': ProxySettings,
'sponsor-block-settings': SponsorBlockSettings,
'download-settings': DownloadSettings
'download-settings': DownloadSettings,
'parental-control-settings': ParentControlSettings
},
computed: {
usingElectron: function () {

View File

@ -20,6 +20,8 @@
<hr>
<download-settings v-if="usingElectron" />
<hr>
<parental-control-settings />
<hr>
<sponsor-block-settings />
</div>
</template>

View File

@ -86,6 +86,10 @@ export default Vue.extend({
activeSubscriptionList: function () {
return this.activeProfile.subscriptions
},
hideLiveStreams: function() {
return this.$store.getters.getHideLiveStreams
}
},
watch: {
@ -178,7 +182,11 @@ export default Vue.extend({
videoList = await Promise.all(videoList.sort((a, b) => {
return b.publishedDate - a.publishedDate
}))
if (this.hideLiveStreams) {
videoList = videoList.filter(item => {
return (!item.liveNow && !item.isUpcoming)
})
}
const profileSubscriptions = {
activeProfile: this.activeProfile._id,
videoList: videoList,

View File

@ -13,6 +13,7 @@ import WatchVideoComments from '../../components/watch-video-comments/watch-vide
import WatchVideoLiveChat from '../../components/watch-video-live-chat/watch-video-live-chat.vue'
import WatchVideoPlaylist from '../../components/watch-video-playlist/watch-video-playlist.vue'
import WatchVideoRecommendations from '../../components/watch-video-recommendations/watch-video-recommendations.vue'
import FtAgeRestricted from '../../components/ft-age-restricted/ft-age-restricted.vue'
export default Vue.extend({
name: 'Watch',
@ -26,7 +27,8 @@ export default Vue.extend({
'watch-video-comments': WatchVideoComments,
'watch-video-live-chat': WatchVideoLiveChat,
'watch-video-playlist': WatchVideoPlaylist,
'watch-video-recommendations': WatchVideoRecommendations
'watch-video-recommendations': WatchVideoRecommendations,
'ft-age-restricted': FtAgeRestricted
},
beforeRouteLeave: function (to, from, next) {
this.handleRouteChange(this.videoId)
@ -42,6 +44,7 @@ export default Vue.extend({
showLegacyPlayer: false,
showYouTubeNoCookieEmbed: false,
hidePlayer: false,
isFamilyFriendly: false,
isLive: false,
isLiveContent: false,
isUpcoming: false,
@ -134,6 +137,15 @@ export default Vue.extend({
hideLiveChat: function () {
return this.$store.getters.getHideLiveChat
},
hideComments: function () {
return this.$store.getters.getHideComments
},
hideVideoDescription: function () {
return this.$store.getters.getHideVideoDescription
},
showFamilyFriendlyOnly: function() {
return this.$store.getters.getShowFamilyFriendlyOnly
},
youtubeNoCookieEmbeddedFrame: function () {
return `<iframe width='560' height='315' src='https://www.youtube-nocookie.com/embed/${this.videoId}?rel=0' frameborder='0' allow='autoplay; encrypted-media' allowfullscreen></iframe>`
@ -300,6 +312,7 @@ export default Vue.extend({
break
}
this.isFamilyFriendly = result.videoDetails.isFamilySafe
this.recommendedVideos = result.related_videos.map((video) => {
video.videoId = video.id
video.authorId = video.author.id
@ -586,6 +599,7 @@ export default Vue.extend({
return format
})
this.isLive = result.liveNow
this.isFamilyFriendly = result.isFamilyFriendly
this.captionHybridList = result.captions.map(caption => {
caption.url = this.currentInvidiousInstance + caption.url
caption.type = ''

View File

@ -6,6 +6,12 @@
=single-column-template
grid-template: "video" auto "info" auto "sidebar" auto / auto
.ageRestricted
max-width: calc(80vh * 1.78)
display: inline-block
+single-column-template
@media only screen and (min-width: 901px)
width: 300%
.videoLayout
display: grid

View File

@ -11,7 +11,10 @@
v-if="isLoading"
:fullscreen="true"
/>
<div class="videoArea">
<div
v-if="(isFamilyFriendly || !showFamilyFriendlyOnly)"
class="videoArea"
>
<div class="videoAreaMargin">
<ft-video-player
v-if="!isLoading && !hidePlayer && !isUpcoming"
@ -64,7 +67,15 @@
</div>
</div>
</div>
<div class="infoArea">
<ft-age-restricted
v-if="(!isLoading && !isFamilyFriendly && showFamilyFriendlyOnly)"
class="ageRestricted"
:content-type-string="'Video'"
/>
<div
v-if="(isFamilyFriendly || !showFamilyFriendlyOnly)"
class="infoArea"
>
<watch-video-info
v-if="!isLoading"
:id="videoId"
@ -96,7 +107,7 @@
@pause-player="pausePlayer"
/>
<watch-video-description
v-if="!isLoading"
v-if="!isLoading && !hideVideoDescription"
:published="videoPublished"
:description="videoDescription"
:description-html="videoDescriptionHtml"
@ -105,7 +116,7 @@
@timestamp-event="changeTimestamp"
/>
<watch-video-comments
v-if="!isLoading && !isLive"
v-if="!isLoading && !isLive && !hideComments"
:id="videoId"
class="watchVideo"
:class="{ theatreWatchVideo: useTheatreMode }"
@ -114,7 +125,10 @@
@timestamp-event="changeTimestamp"
/>
</div>
<div class="sidebarArea">
<div
v-if="(isFamilyFriendly || !showFamilyFriendlyOnly)"
class="sidebarArea"
>
<watch-video-live-chat
v-if="!isLoading && isLive"
:video-id="videoId"

View File

@ -289,6 +289,10 @@ Settings:
Hide Playlists: Hide Playlists
Hide Live Chat: Hide Live Chat
Hide Active Subscriptions: Hide Active Subscriptions
Hide Video Description: Hide Video Description
Hide Comments: Hide Comments
Hide Live Streams: Hide Live Streams
Hide Sharing Actions: Hide Sharing Actions
Data Settings:
Data Settings: Data Settings
Select Import Type: Select Import Type
@ -361,6 +365,11 @@ Settings:
Prompt To Skip: Prompt To Skip
Do Nothing: Do Nothing
Category Color: Category Color
Parental Control Settings:
Parental Control Settings: Parental Control Settings
Hide Unsubscribe Button: Hide Unsubscribe Button
Show Family Friendly Only: Show Family Friendly Only
Hide Search Bar: Hide Search Bar
Download Settings:
Download Settings: Download Settings
Ask Download Path: Ask for download path
@ -756,6 +765,12 @@ Default Invidious instance has been set to $: Default Invidious instance has bee
Default Invidious instance has been cleared: Default Invidious instance has been cleared
'The playlist has ended. Enable loop to continue playing': 'The playlist has ended. Enable
loop to continue playing'
Age Restricted:
# $contentType is replaced with video or channel
This $contentType is age restricted: This $ is age restricted
Type:
Channel: Channel
Video: Video
External link opening has been disabled in the general settings: 'External link opening has been disabled in the general settings'
Downloading has completed: '"$" has finished downloading'
Starting download: 'Starting download of "$"'