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:
parent
ca2799e999
commit
3321fa91e4
|
@ -45,6 +45,18 @@ export default Vue.extend({
|
||||||
},
|
},
|
||||||
hideActiveSubscriptions: function () {
|
hideActiveSubscriptions: function () {
|
||||||
return this.$store.getters.getHideActiveSubscriptions
|
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: {
|
methods: {
|
||||||
|
@ -68,7 +80,11 @@ export default Vue.extend({
|
||||||
'updateHideLiveChat',
|
'updateHideLiveChat',
|
||||||
'updateHideActiveSubscriptions',
|
'updateHideActiveSubscriptions',
|
||||||
'updatePlayNextVideo',
|
'updatePlayNextVideo',
|
||||||
'updateDefaultTheatreMode'
|
'updateDefaultTheatreMode',
|
||||||
|
'updateHideVideoDescription',
|
||||||
|
'updateHideComments',
|
||||||
|
'updateHideLiveStreams',
|
||||||
|
'updateHideSharingActions'
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
|
@ -38,6 +38,18 @@
|
||||||
:default-value="hideActiveSubscriptions"
|
:default-value="hideActiveSubscriptions"
|
||||||
@change="updateHideActiveSubscriptions"
|
@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>
|
||||||
<div class="switchColumn">
|
<div class="switchColumn">
|
||||||
<ft-toggle-switch
|
<ft-toggle-switch
|
||||||
|
@ -70,6 +82,18 @@
|
||||||
:default-value="hideLiveChat"
|
:default-value="hideLiveChat"
|
||||||
@change="updateHideLiveChat"
|
@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>
|
||||||
</div>
|
</div>
|
||||||
<br>
|
<br>
|
||||||
|
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
|
@ -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%
|
|
@ -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" />
|
|
@ -33,6 +33,11 @@ export default Vue.extend({
|
||||||
visible: this.firstScreen
|
visible: this.firstScreen
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
computed: {
|
||||||
|
hideLiveStreams: function() {
|
||||||
|
return this.$store.getters.getHideLiveStreams
|
||||||
|
}
|
||||||
|
},
|
||||||
methods: {
|
methods: {
|
||||||
onVisibilityChanged: function (visible) {
|
onVisibilityChanged: function (visible) {
|
||||||
this.visible = visible
|
this.visible = visible
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
|
v-if="data.type !== undefined && (data.type === 'video' ? ((!data.liveNow && (data.lengthSeconds != null)) || (!hideLiveStreams)) : true)"
|
||||||
v-observe-visibility="firstScreen ? false : {
|
v-observe-visibility="firstScreen ? false : {
|
||||||
callback: onVisibilityChanged,
|
callback: onVisibilityChanged,
|
||||||
once: true,
|
once: true,
|
||||||
|
|
|
@ -117,69 +117,76 @@ export default Vue.extend({
|
||||||
return (this.watchProgress / this.data.lengthSeconds) * 100
|
return (this.watchProgress / this.data.lengthSeconds) * 100
|
||||||
},
|
},
|
||||||
|
|
||||||
|
hideSharingActions: function() {
|
||||||
|
return this.$store.getters.getHideSharingActions
|
||||||
|
},
|
||||||
|
|
||||||
dropdownOptions: function () {
|
dropdownOptions: function () {
|
||||||
const options = []
|
const options = []
|
||||||
|
|
||||||
options.push(
|
options.push(
|
||||||
{
|
{
|
||||||
label: this.watched
|
label: this.watched
|
||||||
? this.$t('Video.Remove From History')
|
? this.$t('Video.Remove From History')
|
||||||
: this.$t('Video.Mark As Watched'),
|
: this.$t('Video.Mark As Watched'),
|
||||||
value: 'history'
|
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
|
return options
|
||||||
},
|
},
|
||||||
|
@ -203,6 +210,11 @@ export default Vue.extend({
|
||||||
return `${baseUrl}/vi/${this.id}/mqdefault.jpg`
|
return `${baseUrl}/vi/${this.id}/mqdefault.jpg`
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
hideLiveStreams: function() {
|
||||||
|
return this.$store.getters.getHideLiveStreams
|
||||||
|
},
|
||||||
|
|
||||||
hideVideoViews: function () {
|
hideVideoViews: function () {
|
||||||
return this.$store.getters.getHideVideoViews
|
return this.$store.getters.getHideVideoViews
|
||||||
},
|
},
|
||||||
|
|
|
@ -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'
|
||||||
|
])
|
||||||
|
}
|
||||||
|
})
|
|
@ -0,0 +1 @@
|
||||||
|
@use "../../sass-partials/settings"
|
|
@ -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" />
|
|
@ -35,6 +35,10 @@ export default Vue.extend({
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
|
hideSharingActions: function() {
|
||||||
|
return this.$store.getters.getHideSharingActions
|
||||||
|
},
|
||||||
|
|
||||||
currentInvidiousInstance: function () {
|
currentInvidiousInstance: function () {
|
||||||
return this.$store.getters.getCurrentInvidiousInstance
|
return this.$store.getters.getCurrentInvidiousInstance
|
||||||
},
|
},
|
||||||
|
|
|
@ -47,6 +47,7 @@
|
||||||
<br>
|
<br>
|
||||||
|
|
||||||
<ft-list-dropdown
|
<ft-list-dropdown
|
||||||
|
v-if="!hideSharingActions"
|
||||||
:title="$t('Playlist.Share Playlist.Share Playlist')"
|
:title="$t('Playlist.Share Playlist.Share Playlist')"
|
||||||
:label-names="shareHeaders"
|
:label-names="shareHeaders"
|
||||||
:label-values="shareValues"
|
:label-values="shareValues"
|
||||||
|
|
|
@ -32,6 +32,10 @@ export default Vue.extend({
|
||||||
return this.$store.getters.getUsingElectron
|
return this.$store.getters.getUsingElectron
|
||||||
},
|
},
|
||||||
|
|
||||||
|
hideSearchBar: function () {
|
||||||
|
return this.$store.getters.getHideSearchBar
|
||||||
|
},
|
||||||
|
|
||||||
enableSearchSuggestions: function () {
|
enableSearchSuggestions: function () {
|
||||||
return this.$store.getters.getEnableSearchSuggestions
|
return this.$store.getters.getEnableSearchSuggestions
|
||||||
},
|
},
|
||||||
|
|
|
@ -33,6 +33,7 @@
|
||||||
@keypress="historyForward"
|
@keypress="historyForward"
|
||||||
/>
|
/>
|
||||||
<font-awesome-icon
|
<font-awesome-icon
|
||||||
|
v-if="!hideSearchBar"
|
||||||
class="navSearchIcon navIcon"
|
class="navSearchIcon navIcon"
|
||||||
icon="search"
|
icon="search"
|
||||||
role="button"
|
role="button"
|
||||||
|
@ -66,6 +67,7 @@
|
||||||
<div class="middle">
|
<div class="middle">
|
||||||
<div class="searchContainer">
|
<div class="searchContainer">
|
||||||
<ft-input
|
<ft-input
|
||||||
|
v-if="!hideSearchBar"
|
||||||
ref="searchInput"
|
ref="searchInput"
|
||||||
:placeholder="$t('Search / Go to URL')"
|
:placeholder="$t('Search / Go to URL')"
|
||||||
class="searchInput"
|
class="searchInput"
|
||||||
|
@ -78,6 +80,7 @@
|
||||||
@click="goToSearch"
|
@click="goToSearch"
|
||||||
/>
|
/>
|
||||||
<font-awesome-icon
|
<font-awesome-icon
|
||||||
|
v-if="!hideSearchBar"
|
||||||
class="navFilterIcon navIcon"
|
class="navFilterIcon navIcon"
|
||||||
:class="{ filterChanged: searchFilterValueChanged }"
|
:class="{ filterChanged: searchFilterValueChanged }"
|
||||||
icon="filter"
|
icon="filter"
|
||||||
|
@ -88,6 +91,7 @@
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<ft-search-filters
|
<ft-search-filters
|
||||||
|
v-if="!hideSearchBar"
|
||||||
v-show="showFilters"
|
v-show="showFilters"
|
||||||
class="searchFilters"
|
class="searchFilters"
|
||||||
@filterValueUpdated="handleSearchFilterValueChanged"
|
@filterValueUpdated="handleSearchFilterValueChanged"
|
||||||
|
|
|
@ -126,6 +126,14 @@ export default Vue.extend({
|
||||||
return this.$store.getters.getCurrentInvidiousInstance
|
return this.$store.getters.getCurrentInvidiousInstance
|
||||||
},
|
},
|
||||||
|
|
||||||
|
hideSharingActions: function() {
|
||||||
|
return this.$store.getters.getHideSharingActions
|
||||||
|
},
|
||||||
|
|
||||||
|
hideUnsubscribeButton: function() {
|
||||||
|
return this.$store.getters.getHideUnsubscribeButton
|
||||||
|
},
|
||||||
|
|
||||||
currentLocale: function () {
|
currentLocale: function () {
|
||||||
return this.$store.getters.getCurrentLocale
|
return this.$store.getters.getCurrentLocale
|
||||||
},
|
},
|
||||||
|
|
|
@ -27,6 +27,7 @@
|
||||||
{{ channelName }}
|
{{ channelName }}
|
||||||
</div>
|
</div>
|
||||||
<ft-button
|
<ft-button
|
||||||
|
v-if="!hideUnsubscribeButton"
|
||||||
:label="subscribedText"
|
:label="subscribedText"
|
||||||
class="subscribeButton"
|
class="subscribeButton"
|
||||||
background-color="var(--primary-color)"
|
background-color="var(--primary-color)"
|
||||||
|
@ -113,6 +114,7 @@
|
||||||
@click="handleFormatChange"
|
@click="handleFormatChange"
|
||||||
/>
|
/>
|
||||||
<ft-share-button
|
<ft-share-button
|
||||||
|
v-if="!hideSharingActions"
|
||||||
:id="id"
|
:id="id"
|
||||||
:get-timestamp="getTimestamp"
|
:get-timestamp="getTimestamp"
|
||||||
:playlist-id="playlistId"
|
:playlist-id="playlistId"
|
||||||
|
|
|
@ -192,11 +192,17 @@ const state = {
|
||||||
hideActiveSubscriptions: false,
|
hideActiveSubscriptions: false,
|
||||||
hideChannelSubscriptions: false,
|
hideChannelSubscriptions: false,
|
||||||
hideCommentLikes: false,
|
hideCommentLikes: false,
|
||||||
|
hideComments: false,
|
||||||
|
hideVideoDescription: false,
|
||||||
hideLiveChat: false,
|
hideLiveChat: false,
|
||||||
|
hideLiveStreams: false,
|
||||||
hidePlaylists: false,
|
hidePlaylists: false,
|
||||||
hidePopularVideos: false,
|
hidePopularVideos: false,
|
||||||
hideRecommendedVideos: false,
|
hideRecommendedVideos: false,
|
||||||
|
hideSearchBar: false,
|
||||||
|
hideSharingActions: false,
|
||||||
hideTrendingVideos: false,
|
hideTrendingVideos: false,
|
||||||
|
hideUnsubscribeButton: false,
|
||||||
hideVideoLikesAndDislikes: false,
|
hideVideoLikesAndDislikes: false,
|
||||||
hideVideoViews: false,
|
hideVideoViews: false,
|
||||||
hideWatchedSubs: false,
|
hideWatchedSubs: false,
|
||||||
|
@ -213,6 +219,7 @@ const state = {
|
||||||
rememberHistory: true,
|
rememberHistory: true,
|
||||||
removeVideoMetaFiles: true,
|
removeVideoMetaFiles: true,
|
||||||
saveWatchedProgress: true,
|
saveWatchedProgress: true,
|
||||||
|
showFamilyFriendlyOnly: false,
|
||||||
sponsorBlockShowSkippedToast: true,
|
sponsorBlockShowSkippedToast: true,
|
||||||
sponsorBlockUrl: 'https://sponsor.ajay.app',
|
sponsorBlockUrl: 'https://sponsor.ajay.app',
|
||||||
sponsorBlockSponsor: {
|
sponsorBlockSponsor: {
|
||||||
|
|
|
@ -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 FtChannelBubble from '../../components/ft-channel-bubble/ft-channel-bubble.vue'
|
||||||
import FtLoader from '../../components/ft-loader/ft-loader.vue'
|
import FtLoader from '../../components/ft-loader/ft-loader.vue'
|
||||||
import FtElementList from '../../components/ft-element-list/ft-element-list.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 ytch from 'yt-channel-info'
|
||||||
import autolinker from 'autolinker'
|
import autolinker from 'autolinker'
|
||||||
|
@ -23,7 +24,8 @@ export default Vue.extend({
|
||||||
'ft-flex-box': FtFlexBox,
|
'ft-flex-box': FtFlexBox,
|
||||||
'ft-channel-bubble': FtChannelBubble,
|
'ft-channel-bubble': FtChannelBubble,
|
||||||
'ft-loader': FtLoader,
|
'ft-loader': FtLoader,
|
||||||
'ft-element-list': FtElementList
|
'ft-element-list': FtElementList,
|
||||||
|
'ft-age-restricted': FtAgeRestricted
|
||||||
},
|
},
|
||||||
data: function () {
|
data: function () {
|
||||||
return {
|
return {
|
||||||
|
@ -50,6 +52,7 @@ export default Vue.extend({
|
||||||
searchResults: [],
|
searchResults: [],
|
||||||
shownElementList: [],
|
shownElementList: [],
|
||||||
apiUsed: '',
|
apiUsed: '',
|
||||||
|
isFamilyFriendly: false,
|
||||||
errorMessage: '',
|
errorMessage: '',
|
||||||
videoSelectValues: [
|
videoSelectValues: [
|
||||||
'newest',
|
'newest',
|
||||||
|
@ -75,6 +78,14 @@ export default Vue.extend({
|
||||||
return this.$store.getters.getBackendFallback
|
return this.$store.getters.getBackendFallback
|
||||||
},
|
},
|
||||||
|
|
||||||
|
hideUnsubscribeButton: function() {
|
||||||
|
return this.$store.getters.getHideUnsubscribeButton
|
||||||
|
},
|
||||||
|
|
||||||
|
showFamilyFriendlyOnly: function() {
|
||||||
|
return this.$store.getters.getShowFamilyFriendlyOnly
|
||||||
|
},
|
||||||
|
|
||||||
currentInvidiousInstance: function () {
|
currentInvidiousInstance: function () {
|
||||||
return this.$store.getters.getCurrentInvidiousInstance
|
return this.$store.getters.getCurrentInvidiousInstance
|
||||||
},
|
},
|
||||||
|
@ -264,6 +275,7 @@ export default Vue.extend({
|
||||||
const channelThumbnailUrl = response.authorThumbnails[2].url
|
const channelThumbnailUrl = response.authorThumbnails[2].url
|
||||||
this.id = channelId
|
this.id = channelId
|
||||||
this.channelName = channelName
|
this.channelName = channelName
|
||||||
|
this.isFamilyFriendly = response.isFamilyFriendly
|
||||||
document.title = `${this.channelName} - ${process.env.PRODUCT_NAME}`
|
document.title = `${this.channelName} - ${process.env.PRODUCT_NAME}`
|
||||||
if (this.hideChannelSubscriptions || response.subscriberCount === 0) {
|
if (this.hideChannelSubscriptions || response.subscriberCount === 0) {
|
||||||
this.subCount = null
|
this.subCount = null
|
||||||
|
@ -383,6 +395,7 @@ export default Vue.extend({
|
||||||
this.channelName = channelName
|
this.channelName = channelName
|
||||||
document.title = `${this.channelName} - ${process.env.PRODUCT_NAME}`
|
document.title = `${this.channelName} - ${process.env.PRODUCT_NAME}`
|
||||||
this.id = channelId
|
this.id = channelId
|
||||||
|
this.isFamilyFriendly = response.isFamilyFriendly
|
||||||
if (this.hideChannelSubscriptions) {
|
if (this.hideChannelSubscriptions) {
|
||||||
this.subCount = null
|
this.subCount = null
|
||||||
} else {
|
} else {
|
||||||
|
|
|
@ -7,7 +7,7 @@
|
||||||
:fullscreen="true"
|
:fullscreen="true"
|
||||||
/>
|
/>
|
||||||
<ft-card
|
<ft-card
|
||||||
v-else
|
v-else-if="(isFamilyFriendly || !showFamilyFriendlyOnly)"
|
||||||
class="card channelDetails"
|
class="card channelDetails"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
|
@ -52,6 +52,7 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ft-button
|
<ft-button
|
||||||
|
v-if="!hideUnsubscribeButton"
|
||||||
:label="subscribedText"
|
:label="subscribedText"
|
||||||
background-color="var(--primary-color)"
|
background-color="var(--primary-color)"
|
||||||
text-color="var(--text-with-main-color)"
|
text-color="var(--text-with-main-color)"
|
||||||
|
@ -113,7 +114,7 @@
|
||||||
</div>
|
</div>
|
||||||
</ft-card>
|
</ft-card>
|
||||||
<ft-card
|
<ft-card
|
||||||
v-if="!isLoading && !errorMessage"
|
v-if="!isLoading && !errorMessage && (isFamilyFriendly || !showFamilyFriendlyOnly)"
|
||||||
class="card"
|
class="card"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
|
@ -203,6 +204,11 @@
|
||||||
{{ errorMessage }}
|
{{ errorMessage }}
|
||||||
</p>
|
</p>
|
||||||
</ft-card>
|
</ft-card>
|
||||||
|
<ft-age-restricted
|
||||||
|
v-else-if="!isLoading && (!isFamilyFriendly && showFamilyFriendlyOnly)"
|
||||||
|
class="ageRestricted"
|
||||||
|
:content-type-string="'Channel'"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|
|
@ -37,6 +37,13 @@ export default Vue.extend({
|
||||||
|
|
||||||
backendFallback: function () {
|
backendFallback: function () {
|
||||||
return this.$store.getters.getBackendFallback
|
return this.$store.getters.getBackendFallback
|
||||||
|
},
|
||||||
|
|
||||||
|
hideLiveStreams: function() {
|
||||||
|
return this.$store.getters.getHideLiveStreams
|
||||||
|
},
|
||||||
|
showFamilyFriendlyOnly: function() {
|
||||||
|
return this.$store.getters.getShowFamilyFriendlyOnly
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
watch: {
|
watch: {
|
||||||
|
@ -94,6 +101,7 @@ export default Vue.extend({
|
||||||
|
|
||||||
if (sameSearch.length > 0) {
|
if (sameSearch.length > 0) {
|
||||||
console.log(sameSearch)
|
console.log(sameSearch)
|
||||||
|
|
||||||
// Replacing the data right away causes a strange error where the data
|
// 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
|
// Shown is mixed from 2 different search results. So we'll wait a moment
|
||||||
// Before showing the results.
|
// Before showing the results.
|
||||||
|
@ -118,6 +126,8 @@ export default Vue.extend({
|
||||||
payload.options.pages = 1
|
payload.options.pages = 1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
payload.options.safeSearch = this.showFamilyFriendlyOnly
|
||||||
|
|
||||||
this.ytSearch(payload).then((result) => {
|
this.ytSearch(payload).then((result) => {
|
||||||
console.log(result)
|
console.log(result)
|
||||||
if (!result) {
|
if (!result) {
|
||||||
|
|
|
@ -12,6 +12,7 @@ import DataSettings from '../../components/data-settings/data-settings.vue'
|
||||||
import DistractionSettings from '../../components/distraction-settings/distraction-settings.vue'
|
import DistractionSettings from '../../components/distraction-settings/distraction-settings.vue'
|
||||||
import ProxySettings from '../../components/proxy-settings/proxy-settings.vue'
|
import ProxySettings from '../../components/proxy-settings/proxy-settings.vue'
|
||||||
import SponsorBlockSettings from '../../components/sponsor-block-settings/sponsor-block-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({
|
export default Vue.extend({
|
||||||
name: 'Settings',
|
name: 'Settings',
|
||||||
|
@ -28,7 +29,8 @@ export default Vue.extend({
|
||||||
'distraction-settings': DistractionSettings,
|
'distraction-settings': DistractionSettings,
|
||||||
'proxy-settings': ProxySettings,
|
'proxy-settings': ProxySettings,
|
||||||
'sponsor-block-settings': SponsorBlockSettings,
|
'sponsor-block-settings': SponsorBlockSettings,
|
||||||
'download-settings': DownloadSettings
|
'download-settings': DownloadSettings,
|
||||||
|
'parental-control-settings': ParentControlSettings
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
usingElectron: function () {
|
usingElectron: function () {
|
||||||
|
|
|
@ -20,6 +20,8 @@
|
||||||
<hr>
|
<hr>
|
||||||
<download-settings v-if="usingElectron" />
|
<download-settings v-if="usingElectron" />
|
||||||
<hr>
|
<hr>
|
||||||
|
<parental-control-settings />
|
||||||
|
<hr>
|
||||||
<sponsor-block-settings />
|
<sponsor-block-settings />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
@ -86,6 +86,10 @@ export default Vue.extend({
|
||||||
|
|
||||||
activeSubscriptionList: function () {
|
activeSubscriptionList: function () {
|
||||||
return this.activeProfile.subscriptions
|
return this.activeProfile.subscriptions
|
||||||
|
},
|
||||||
|
|
||||||
|
hideLiveStreams: function() {
|
||||||
|
return this.$store.getters.getHideLiveStreams
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
watch: {
|
watch: {
|
||||||
|
@ -178,7 +182,11 @@ export default Vue.extend({
|
||||||
videoList = await Promise.all(videoList.sort((a, b) => {
|
videoList = await Promise.all(videoList.sort((a, b) => {
|
||||||
return b.publishedDate - a.publishedDate
|
return b.publishedDate - a.publishedDate
|
||||||
}))
|
}))
|
||||||
|
if (this.hideLiveStreams) {
|
||||||
|
videoList = videoList.filter(item => {
|
||||||
|
return (!item.liveNow && !item.isUpcoming)
|
||||||
|
})
|
||||||
|
}
|
||||||
const profileSubscriptions = {
|
const profileSubscriptions = {
|
||||||
activeProfile: this.activeProfile._id,
|
activeProfile: this.activeProfile._id,
|
||||||
videoList: videoList,
|
videoList: videoList,
|
||||||
|
|
|
@ -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 WatchVideoLiveChat from '../../components/watch-video-live-chat/watch-video-live-chat.vue'
|
||||||
import WatchVideoPlaylist from '../../components/watch-video-playlist/watch-video-playlist.vue'
|
import WatchVideoPlaylist from '../../components/watch-video-playlist/watch-video-playlist.vue'
|
||||||
import WatchVideoRecommendations from '../../components/watch-video-recommendations/watch-video-recommendations.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({
|
export default Vue.extend({
|
||||||
name: 'Watch',
|
name: 'Watch',
|
||||||
|
@ -26,7 +27,8 @@ export default Vue.extend({
|
||||||
'watch-video-comments': WatchVideoComments,
|
'watch-video-comments': WatchVideoComments,
|
||||||
'watch-video-live-chat': WatchVideoLiveChat,
|
'watch-video-live-chat': WatchVideoLiveChat,
|
||||||
'watch-video-playlist': WatchVideoPlaylist,
|
'watch-video-playlist': WatchVideoPlaylist,
|
||||||
'watch-video-recommendations': WatchVideoRecommendations
|
'watch-video-recommendations': WatchVideoRecommendations,
|
||||||
|
'ft-age-restricted': FtAgeRestricted
|
||||||
},
|
},
|
||||||
beforeRouteLeave: function (to, from, next) {
|
beforeRouteLeave: function (to, from, next) {
|
||||||
this.handleRouteChange(this.videoId)
|
this.handleRouteChange(this.videoId)
|
||||||
|
@ -42,6 +44,7 @@ export default Vue.extend({
|
||||||
showLegacyPlayer: false,
|
showLegacyPlayer: false,
|
||||||
showYouTubeNoCookieEmbed: false,
|
showYouTubeNoCookieEmbed: false,
|
||||||
hidePlayer: false,
|
hidePlayer: false,
|
||||||
|
isFamilyFriendly: false,
|
||||||
isLive: false,
|
isLive: false,
|
||||||
isLiveContent: false,
|
isLiveContent: false,
|
||||||
isUpcoming: false,
|
isUpcoming: false,
|
||||||
|
@ -134,6 +137,15 @@ export default Vue.extend({
|
||||||
hideLiveChat: function () {
|
hideLiveChat: function () {
|
||||||
return this.$store.getters.getHideLiveChat
|
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 () {
|
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>`
|
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
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.isFamilyFriendly = result.videoDetails.isFamilySafe
|
||||||
this.recommendedVideos = result.related_videos.map((video) => {
|
this.recommendedVideos = result.related_videos.map((video) => {
|
||||||
video.videoId = video.id
|
video.videoId = video.id
|
||||||
video.authorId = video.author.id
|
video.authorId = video.author.id
|
||||||
|
@ -586,6 +599,7 @@ export default Vue.extend({
|
||||||
return format
|
return format
|
||||||
})
|
})
|
||||||
this.isLive = result.liveNow
|
this.isLive = result.liveNow
|
||||||
|
this.isFamilyFriendly = result.isFamilyFriendly
|
||||||
this.captionHybridList = result.captions.map(caption => {
|
this.captionHybridList = result.captions.map(caption => {
|
||||||
caption.url = this.currentInvidiousInstance + caption.url
|
caption.url = this.currentInvidiousInstance + caption.url
|
||||||
caption.type = ''
|
caption.type = ''
|
||||||
|
|
|
@ -6,6 +6,12 @@
|
||||||
|
|
||||||
=single-column-template
|
=single-column-template
|
||||||
grid-template: "video" auto "info" auto "sidebar" auto / auto
|
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
|
.videoLayout
|
||||||
display: grid
|
display: grid
|
||||||
|
|
|
@ -11,7 +11,10 @@
|
||||||
v-if="isLoading"
|
v-if="isLoading"
|
||||||
:fullscreen="true"
|
:fullscreen="true"
|
||||||
/>
|
/>
|
||||||
<div class="videoArea">
|
<div
|
||||||
|
v-if="(isFamilyFriendly || !showFamilyFriendlyOnly)"
|
||||||
|
class="videoArea"
|
||||||
|
>
|
||||||
<div class="videoAreaMargin">
|
<div class="videoAreaMargin">
|
||||||
<ft-video-player
|
<ft-video-player
|
||||||
v-if="!isLoading && !hidePlayer && !isUpcoming"
|
v-if="!isLoading && !hidePlayer && !isUpcoming"
|
||||||
|
@ -64,7 +67,15 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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
|
<watch-video-info
|
||||||
v-if="!isLoading"
|
v-if="!isLoading"
|
||||||
:id="videoId"
|
:id="videoId"
|
||||||
|
@ -96,7 +107,7 @@
|
||||||
@pause-player="pausePlayer"
|
@pause-player="pausePlayer"
|
||||||
/>
|
/>
|
||||||
<watch-video-description
|
<watch-video-description
|
||||||
v-if="!isLoading"
|
v-if="!isLoading && !hideVideoDescription"
|
||||||
:published="videoPublished"
|
:published="videoPublished"
|
||||||
:description="videoDescription"
|
:description="videoDescription"
|
||||||
:description-html="videoDescriptionHtml"
|
:description-html="videoDescriptionHtml"
|
||||||
|
@ -105,7 +116,7 @@
|
||||||
@timestamp-event="changeTimestamp"
|
@timestamp-event="changeTimestamp"
|
||||||
/>
|
/>
|
||||||
<watch-video-comments
|
<watch-video-comments
|
||||||
v-if="!isLoading && !isLive"
|
v-if="!isLoading && !isLive && !hideComments"
|
||||||
:id="videoId"
|
:id="videoId"
|
||||||
class="watchVideo"
|
class="watchVideo"
|
||||||
:class="{ theatreWatchVideo: useTheatreMode }"
|
:class="{ theatreWatchVideo: useTheatreMode }"
|
||||||
|
@ -114,7 +125,10 @@
|
||||||
@timestamp-event="changeTimestamp"
|
@timestamp-event="changeTimestamp"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="sidebarArea">
|
<div
|
||||||
|
v-if="(isFamilyFriendly || !showFamilyFriendlyOnly)"
|
||||||
|
class="sidebarArea"
|
||||||
|
>
|
||||||
<watch-video-live-chat
|
<watch-video-live-chat
|
||||||
v-if="!isLoading && isLive"
|
v-if="!isLoading && isLive"
|
||||||
:video-id="videoId"
|
:video-id="videoId"
|
||||||
|
|
|
@ -289,6 +289,10 @@ Settings:
|
||||||
Hide Playlists: Hide Playlists
|
Hide Playlists: Hide Playlists
|
||||||
Hide Live Chat: Hide Live Chat
|
Hide Live Chat: Hide Live Chat
|
||||||
Hide Active Subscriptions: Hide Active Subscriptions
|
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: Data Settings
|
Data Settings: Data Settings
|
||||||
Select Import Type: Select Import Type
|
Select Import Type: Select Import Type
|
||||||
|
@ -361,6 +365,11 @@ Settings:
|
||||||
Prompt To Skip: Prompt To Skip
|
Prompt To Skip: Prompt To Skip
|
||||||
Do Nothing: Do Nothing
|
Do Nothing: Do Nothing
|
||||||
Category Color: Category Color
|
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: Download Settings
|
Download Settings: Download Settings
|
||||||
Ask Download Path: Ask for download path
|
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
|
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
|
'The playlist has ended. Enable loop to continue playing': 'The playlist has ended. Enable
|
||||||
loop to continue playing'
|
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'
|
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'
|
Downloading has completed: '"$" has finished downloading'
|
||||||
Starting download: 'Starting download of "$"'
|
Starting download: 'Starting download of "$"'
|
||||||
|
|
Loading…
Reference in New Issue