diff --git a/src/renderer/components/distraction-settings/distraction-settings.js b/src/renderer/components/distraction-settings/distraction-settings.js index 7f5e3741fc962..0c7ce3efa06cd 100644 --- a/src/renderer/components/distraction-settings/distraction-settings.js +++ b/src/renderer/components/distraction-settings/distraction-settings.js @@ -4,6 +4,8 @@ import FtSettingsSection from '../ft-settings-section/ft-settings-section.vue' import FtToggleSwitch from '../ft-toggle-switch/ft-toggle-switch.vue' import FtInputTags from '../../components/ft-input-tags/ft-input-tags.vue' import FtFlexBox from '../ft-flex-box/ft-flex-box.vue' +import { showToast } from '../../helpers/utils' +import { checkYoutubeId, findChannelTagInfo } from '../../helpers/channels' export default defineComponent({ name: 'PlayerSettings', @@ -13,7 +15,18 @@ export default defineComponent({ 'ft-input-tags': FtInputTags, 'ft-flex-box': FtFlexBox, }, + data: function () { + return { + channelHiderDisabled: false, + } + }, computed: { + backendOptions: function () { + return { + preference: this.$store.getters.getBackendPreference, + fallback: this.$store.getters.getBackendFallback + } + }, hideVideoViews: function () { return this.$store.getters.getHideVideoViews }, @@ -92,7 +105,7 @@ export default defineComponent({ hideSubscriptionsLive: function () { return this.$store.getters.getHideSubscriptionsLive }, - hideSubscriptionsCommunity: function() { + hideSubscriptionsCommunity: function () { return this.$store.getters.getHideSubscriptionsCommunity }, showDistractionFreeTitles: function () { @@ -105,7 +118,13 @@ export default defineComponent({ return this.$store.getters.getBlurThumbnails }, channelsHidden: function () { - return JSON.parse(this.$store.getters.getChannelsHidden) + return JSON.parse(this.$store.getters.getChannelsHidden).map((ch) => { + // Legacy support + if (typeof ch === 'string') { + return { name: ch, preferredName: '', icon: '' } + } + return ch + }) }, hideSubscriptionsLiveTooltip: function () { return this.$t('Tooltips.Distraction Free Settings.Hide Subscriptions Live', { @@ -115,6 +134,9 @@ export default defineComponent({ }) } }, + mounted: function () { + this.verifyChannelsHidden() + }, methods: { handleHideRecommendedVideos: function (value) { if (value) { @@ -123,9 +145,51 @@ export default defineComponent({ this.updateHideRecommendedVideos(value) }, + handleInvalidChannel: function () { + showToast(this.$t('Settings.Distraction Free Settings.Hide Channels Invalid')) + }, + handleChannelAPIError: function () { + showToast(this.$t('Settings.Distraction Free Settings.Hide Channels API Error')) + }, handleChannelsHidden: function (value) { this.updateChannelsHidden(JSON.stringify(value)) }, + handleChannelsExists: function () { + showToast(this.$t('Settings.Distraction Free Settings.Hide Channels Already Exists')) + }, + validateChannelId: function (text) { + return checkYoutubeId(text) + }, + findChannelTagInfo: async function (text) { + return await findChannelTagInfo(text, this.backendOptions) + }, + verifyChannelsHidden: async function () { + const channelsHiddenCpy = [...this.channelsHidden] + + for (let i = 0; i < channelsHiddenCpy.length; i++) { + const tag = this.channelsHidden[i] + + // if channel has been processed and confirmed as non existent, skip + if (tag.invalid) continue + + // process if no preferred name and is possibly a YouTube ID + if (tag.preferredName === '' && checkYoutubeId(tag.name)) { + this.channelHiderDisabled = true + + const { preferredName, icon, iconHref, invalidId } = await this.findChannelTagInfo(tag.name) + if (invalidId) { + channelsHiddenCpy[i] = { name: tag.name, invalid: invalidId } + } else { + channelsHiddenCpy[i] = { name: tag.name, preferredName, icon, iconHref } + } + + // update on every tag in case it closes + this.handleChannelsHidden(channelsHiddenCpy) + } + } + + this.channelHiderDisabled = false + }, ...mapActions([ 'updateHideVideoViews', diff --git a/src/renderer/components/distraction-settings/distraction-settings.vue b/src/renderer/components/distraction-settings/distraction-settings.vue index 5b6f06db4a4a1..b30e8fd68bef7 100644 --- a/src/renderer/components/distraction-settings/distraction-settings.vue +++ b/src/renderer/components/distraction-settings/distraction-settings.vue @@ -237,12 +237,19 @@
diff --git a/src/renderer/components/ft-input-tags/ft-input-tags.css b/src/renderer/components/ft-input-tags/ft-input-tags.css index f32f00ca0d300..c1a81925adc13 100644 --- a/src/renderer/components/ft-input-tags/ft-input-tags.css +++ b/src/renderer/components/ft-input-tags/ft-input-tags.css @@ -7,8 +7,13 @@ inline-size: 60%; } +.disabledMsg { + color: rgb(233, 255, 108); + padding-block-end: 10px; +} + .ft-tag-box ul { - overflow: auto; + overflow: visible; display: block; padding: 0; margin: 0; @@ -19,24 +24,36 @@ background-color: var(--card-bg-color); margin: 5px; border-radius: 5px; - display:flex; + display: flex; float: var(--float-left-ltr-rtl-value); } .ft-tag-box li>span { padding-block: 10px; - padding-inline-start: 10px; + padding-inline: 10px; overflow-wrap: break-word; word-wrap: break-word; word-break: break-all; hyphens: auto; + user-select: text; } +.tag-icon { + border-radius: 50%; + block-size: 24px; + vertical-align: middle; +} + +.tag-icon-link { + margin: auto; + margin-inline-start: 10px; +} .removeTagButton { color: var(--primary-text-color); opacity: 0.5; padding: 10px; + padding-inline-start: 0px; } .removeTagButton:hover { diff --git a/src/renderer/components/ft-input-tags/ft-input-tags.js b/src/renderer/components/ft-input-tags/ft-input-tags.js index a621b47216757..f517159959424 100644 --- a/src/renderer/components/ft-input-tags/ft-input-tags.js +++ b/src/renderer/components/ft-input-tags/ft-input-tags.js @@ -1,15 +1,21 @@ import { defineComponent } from 'vue' import FtInput from '../ft-input/ft-input.vue' -import FtTooltip from '../ft-tooltip/ft-tooltip.vue' export default defineComponent({ name: 'FtInputTags', components: { 'ft-input': FtInput, - 'ft-tooltip': FtTooltip }, props: { - placeholder: { + disabled: { + type: Boolean, + default: false + }, + disabledMsg: { + type: String, + default: '' + }, + tagNamePlaceholder: { type: String, required: true }, @@ -28,27 +34,48 @@ export default defineComponent({ tooltip: { type: String, default: '' + }, + validateTagName: { + type: Function, + default: (_) => true + }, + findTagInfo: { + type: Function, + default: (_) => ({ preferredName: '', icon: '' }), } }, methods: { - updateTags: function (text, e) { + updateTags: async function (text, _e) { // text entered add tag and update tag list - const trimmedText = text.trim() - if (!this.tagList.includes(trimmedText)) { - const newList = this.tagList.slice(0) - newList.push(trimmedText) - this.$emit('change', newList) + const name = text.trim() + + if (!this.validateTagName(name)) { + this.$emit('invalid-name') + return } + + if (!this.tagList.some((tag) => tag.name === name)) { + // tag info searching allow api calls to be used + const { preferredName, icon, iconHref, err } = await this.findTagInfo(name) + + if (err) { + this.$emit('error-find-tag-info') + return + } + + const newTag = { name, preferredName, icon, iconHref } + this.$emit('change', [...this.tagList, newTag]) + } else { + this.$emit('already-exists') + } + // clear input box - this.$refs.childinput.handleClearTextClick() + this.$refs.tagNameInput.handleClearTextClick() }, removeTag: function (tag) { // Remove tag from list - const tagName = tag.trim() - if (this.tagList.includes(tagName)) { - const newList = this.tagList.slice(0) - const index = newList.indexOf(tagName) - newList.splice(index, 1) + if (this.tagList.some((tmpTag) => tmpTag.name === tag.name)) { + const newList = this.tagList.filter((tmpTag) => tmpTag.name !== tag.name) this.$emit('change', newList) } } diff --git a/src/renderer/components/ft-input-tags/ft-input-tags.vue b/src/renderer/components/ft-input-tags/ft-input-tags.vue index 5e4e33e89a70f..6b236e937ca77 100644 --- a/src/renderer/components/ft-input-tags/ft-input-tags.vue +++ b/src/renderer/components/ft-input-tags/ft-input-tags.vue @@ -2,9 +2,16 @@
+
+ {{ disabledMsg }} +
-
  • - {{ tag }} + + + + {{ (tag.preferredName) ? tag.preferredName : tag.name }} { + // Legacy support + if (typeof ch === 'string') { + return { name: ch, preferredName: '', icon: '' } + } + return ch + }) }, hideUpcomingPremieres: function () { return this.$store.getters.getHideUpcomingPremieres @@ -87,7 +93,7 @@ export default defineComponent({ // hide upcoming return false } - if (this.channelsHidden.includes(data.authorId) || this.channelsHidden.includes(data.author)) { + if (this.channelsHidden.some(ch => ch.name === data.authorId) || this.channelsHidden.some(ch => ch.name === data.author)) { // hide videos by author return false } @@ -101,7 +107,7 @@ export default defineComponent({ data.author, data.authorId, ] - if (attrsToCheck.some(a => a != null && this.channelsHidden.includes(a))) { + if (attrsToCheck.some(a => a != null && this.channelsHidden.some(ch => ch.name === a))) { // hide channels by author return false } @@ -115,7 +121,7 @@ export default defineComponent({ data.author, data.authorId, ] - if (attrsToCheck.some(a => a != null && this.channelsHidden.includes(a))) { + if (attrsToCheck.some(a => a != null && this.channelsHidden.some(ch => ch.name === a))) { // hide playlists by author return false } diff --git a/src/renderer/components/ft-list-video-lazy/ft-list-video-lazy.js b/src/renderer/components/ft-list-video-lazy/ft-list-video-lazy.js index c255110b4b3a8..3a4f3b6780054 100644 --- a/src/renderer/components/ft-list-video-lazy/ft-list-video-lazy.js +++ b/src/renderer/components/ft-list-video-lazy/ft-list-video-lazy.js @@ -58,12 +58,18 @@ export default defineComponent({ // Some component users like channel view will have this disabled if (!this.useChannelsHiddenPreference) { return [] } - return JSON.parse(this.$store.getters.getChannelsHidden) + return JSON.parse(this.$store.getters.getChannelsHidden).map((ch) => { + // Legacy support + if (typeof ch === 'string') { + return { name: ch, preferredName: '', icon: '' } + } + return ch + }) }, shouldBeVisible() { - return !(this.channelsHidden.includes(this.data.authorId) || - this.channelsHidden.includes(this.data.author)) + return !(this.channelsHidden.some(ch => ch.name === this.data.authorId) || + this.channelsHidden.some(ch => ch.name === this.data.author)) } }, created() { diff --git a/src/renderer/helpers/channels.js b/src/renderer/helpers/channels.js new file mode 100644 index 0000000000000..0d3fbb7867aea --- /dev/null +++ b/src/renderer/helpers/channels.js @@ -0,0 +1,72 @@ +import { invidiousGetChannelInfo } from './api/invidious' +import { getLocalChannel } from './api/local' + +/** + * @param {string} id + * @param {{ +* preference: string, +* fallback: boolean, +* invalid: boolean, +* }} backendOptions +*/ +async function findChannelById(id, backendOptions) { + try { + if (backendOptions.preference === 'invidious') { + return await invidiousGetChannelInfo(id) + } else { + return await getLocalChannel(id) + } + } catch (err) { + // don't bother with fallback if channel doesn't exist + if (err.message && err.message === 'This channel does not exist.') { + return { invalid: true } + } + if (backendOptions.fallback && backendOptions.preference === 'invidious') { + return await getLocalChannel(id) + } + if (backendOptions.fallback && backendOptions.preference === 'local') { + return await invidiousGetChannelInfo(id) + } + } +} + +/** + * @param {string} id + * @param {{ +* preference: string, +* fallback: boolean, +* }} backendOptions +* @returns {Promise<{icon: string, iconHref: string, preferredName: string, invalidId: boolean}>} +*/ +export async function findChannelTagInfo(id, backendOptions) { + if (!/UC\S{22}/.test(id)) return { invalidId: true } + try { + const channel = await findChannelById(id, backendOptions) + if (backendOptions.preference === 'invidious') { + if (channel.invalid) return { invalidId: true } + return { + preferredName: channel.author, + icon: channel.authorThumbnails[0].url + } + } else { + if (channel.alert) return { invalidId: true } + return { + preferredName: channel.header.author.name, + icon: channel.header.author.thumbnails.pop().url, + iconHref: `/channel/${id}` + } + } + } catch (err) { + console.error(err) + return { preferredName: '', icon: '', iconHref: '', err } + } +} + +/** + * Check whether Id provided might be a YouTube Id + * @param {string} id + * @returns {boolean} + */ +export function checkYoutubeId(id) { + return /UC\S{22}/.test(id) +} diff --git a/static/locales/en-US.yaml b/static/locales/en-US.yaml index b1b6a720a6e2f..048b6ca209e40 100644 --- a/static/locales/en-US.yaml +++ b/static/locales/en-US.yaml @@ -364,7 +364,11 @@ Settings: Hide Sharing Actions: Hide Sharing Actions Hide Chapters: Hide Chapters Hide Channels: Hide Videos From Channels - Hide Channels Placeholder: Channel Name or ID + Hide Channels Disabled Message: Some channels were blocked using ID and weren't processed. Feature is blocked while those IDs are updating + Hide Channels Placeholder: Channel ID + Hide Channels Invalid: Channel ID provided was invalid + Hide Channels API Error: Error retrieving user with the ID provided. Please check again if the ID is correct. + Hide Channels Already Exists: Channel ID already exists Hide Featured Channels: Hide Featured Channels Hide Channel Playlists: Hide Channel Playlists Hide Channel Community: Hide Channel Community @@ -865,8 +869,8 @@ Tooltips: you want to be passed to the external player. DefaultCustomArgumentsTemplate: "(Default: '{defaultCustomArguments}')" Distraction Free Settings: - Hide Channels: Enter a channel name or channel ID to hide all videos, playlists and the channel itself from appearing in search, trending, most popular and recommended. - The channel name entered must be a complete match and is case sensitive. + Hide Channels: Enter a channel ID to hide all videos, playlists and the channel itself from appearing in search, trending, most popular and recommended. + The channel ID entered must be a complete match and is case sensitive. Hide Subscriptions Live: 'This setting is overridden by the app-wide "{appWideSetting}" setting, in the "{subsection}" section of the "{settingsSection}"' Subscription Settings: Fetch Feeds from RSS: When enabled, FreeTube will use RSS instead of its default