diff --git a/src/renderer/App.css b/src/renderer/App.css index 24b765ca8d13e..0364353145afd 100644 --- a/src/renderer/App.css +++ b/src/renderer/App.css @@ -18,10 +18,14 @@ .banner { inline-size: 85%; - margin-block: 20px; + margin-block: 40px 0; margin-inline: auto; } +.banner + .banner { + margin-block: 20px; +} + .banner-wrapper { margin-block: 0; margin-inline: 10px; @@ -53,8 +57,8 @@ } .banner { - inline-size: 80%; - margin-block-start: 20px; + inline-size: 90%; + margin-block: 60px 0; } .flexBox { diff --git a/src/renderer/components/ft-icon-button/ft-icon-button.js b/src/renderer/components/ft-icon-button/ft-icon-button.js index 97062b49538d4..cba8cfb3430c2 100644 --- a/src/renderer/components/ft-icon-button/ft-icon-button.js +++ b/src/renderer/components/ft-icon-button/ft-icon-button.js @@ -16,6 +16,10 @@ export default defineComponent({ type: Array, default: () => ['fas', 'ellipsis-v'] }, + disabled: { + type: Boolean, + default: false + }, theme: { type: String, default: 'base' @@ -88,6 +92,7 @@ export default defineComponent({ }, handleIconClick: function () { + if (this.disabled) { return } if (this.forceDropdown || (this.dropdownOptions.length > 0)) { this.dropdownShown = !this.dropdownShown @@ -104,6 +109,7 @@ export default defineComponent({ }, handleIconMouseDown: function () { + if (this.disabled) { return } if (this.dropdownShown) { this.mouseDownOnIcon = true } diff --git a/src/renderer/components/ft-icon-button/ft-icon-button.scss b/src/renderer/components/ft-icon-button/ft-icon-button.scss index c76e9333fd5b9..6e4f3acb268f6 100644 --- a/src/renderer/components/ft-icon-button/ft-icon-button.scss +++ b/src/renderer/components/ft-icon-button/ft-icon-button.scss @@ -79,6 +79,12 @@ } } +.disabled { + opacity: 0.5; + pointer-events: none; + user-select: none; +} + .iconDropdown { background-color: var(--side-nav-color); box-shadow: 0 1px 2px rgb(0 0 0 / 50%); diff --git a/src/renderer/components/ft-icon-button/ft-icon-button.vue b/src/renderer/components/ft-icon-button/ft-icon-button.vue index afc4c68f4d420..8f4d2bfc7764d 100644 --- a/src/renderer/components/ft-icon-button/ft-icon-button.vue +++ b/src/renderer/components/ft-icon-button/ft-icon-button.vue @@ -7,7 +7,8 @@ :icon="icon" :class="{ [theme]: true, - shadow: useShadow + shadow: useShadow, + disabled }" :style="{ padding: padding + 'px', diff --git a/src/renderer/components/ft-list-video/ft-list-video.js b/src/renderer/components/ft-list-video/ft-list-video.js index becb7185d2f35..8d4d6e3bfaaf5 100644 --- a/src/renderer/components/ft-list-video/ft-list-video.js +++ b/src/renderer/components/ft-list-video/ft-list-video.js @@ -5,6 +5,7 @@ import { copyToClipboard, formatDurationAsTimestamp, formatNumber, + getRelativeTimeFromDate, openExternalLink, showToast, toDistractionFreeTitle, @@ -345,6 +346,10 @@ export default defineComponent({ return this.historyEntryExists && !this.inHistory }, + currentLocale: function () { + return this.$i18n.locale.replace('_', '-') + }, + externalPlayer: function () { return this.$store.getters.getExternalPlayer }, @@ -462,14 +467,6 @@ export default defineComponent({ return query }, - currentLocale: function () { - return this.$i18n.locale.replace('_', '-') - }, - - showAddToPlaylistPrompt: function () { - return this.$store.getters.getShowAddToPlaylistPrompt - }, - useDeArrowTitles: function () { return this.$store.getters.getUseDeArrowTitles }, @@ -668,48 +665,8 @@ export default defineComponent({ if (this.inHistory) { this.uploadedTime = new Date(this.data.published).toLocaleDateString([this.currentLocale, 'en']) } else { - const now = new Date().getTime() - // Convert from ms to second - // For easier code interpretation the value is made to be positive - let timeDiffFromNow = ((now - this.data.published) / 1000) - let timeUnit = 'second' - - if (timeDiffFromNow >= 60) { - timeDiffFromNow /= 60 - timeUnit = 'minute' - } - - if (timeUnit === 'minute' && timeDiffFromNow >= 60) { - timeDiffFromNow /= 60 - timeUnit = 'hour' - } - - if (timeUnit === 'hour' && timeDiffFromNow >= 24) { - timeDiffFromNow /= 24 - timeUnit = 'day' - } - - const timeDiffFromNowDays = timeDiffFromNow - - if (timeUnit === 'day' && timeDiffFromNow >= 7) { - timeDiffFromNow /= 7 - timeUnit = 'week' - } - // Use 30 days per month, just like calculatePublishedDate - if (timeUnit === 'week' && timeDiffFromNowDays >= 30) { - timeDiffFromNow = timeDiffFromNowDays / 30 - timeUnit = 'month' - } - - if (timeUnit === 'month' && timeDiffFromNow >= 12) { - timeDiffFromNow /= 12 - timeUnit = 'year' - } - - // Using `Math.ceil` so that -1.x days ago displayed as 1 day ago - // Notice that the value is turned to negative to be displayed as "ago" - this.uploadedTime = new Intl.RelativeTimeFormat([this.currentLocale, 'en']).format(Math.ceil(-timeDiffFromNow), timeUnit) + this.uploadedTime = getRelativeTimeFromDate(new Date(this.data.published).toDateString(), false) } } diff --git a/src/renderer/components/ft-notification-banner/ft-notification-banner.css b/src/renderer/components/ft-notification-banner/ft-notification-banner.css index 2f8464be3d343..6efe9d4f95bba 100644 --- a/src/renderer/components/ft-notification-banner/ft-notification-banner.css +++ b/src/renderer/components/ft-notification-banner/ft-notification-banner.css @@ -30,3 +30,11 @@ inset-inline-end: 10px; cursor: pointer; } + +@media only screen and (width <= 680px) { + .bannerIcon { + inset-block-start: 27%; + block-size: 25px; + inline-size: 25px; + } +} diff --git a/src/renderer/components/ft-notification-banner/ft-notification-banner.vue b/src/renderer/components/ft-notification-banner/ft-notification-banner.vue index 02feed7602a2c..4b3ff2eb017d1 100644 --- a/src/renderer/components/ft-notification-banner/ft-notification-banner.vue +++ b/src/renderer/components/ft-notification-banner/ft-notification-banner.vue @@ -24,7 +24,7 @@ tabindex="0" :title="$t('Close Banner')" @click.stop="handleClose" - @keydown.enter.stop.prevent="handleClose" + @keydown.enter.space.stop.prevent="handleClose" /> diff --git a/src/renderer/components/ft-refresh-widget/ft-refresh-widget.css b/src/renderer/components/ft-refresh-widget/ft-refresh-widget.css new file mode 100644 index 0000000000000..3c9b499098b31 --- /dev/null +++ b/src/renderer/components/ft-refresh-widget/ft-refresh-widget.css @@ -0,0 +1,36 @@ +.floatingRefreshSection { + position: fixed; + inset-block-start: 60px; + inset-inline-end: 0; + box-sizing: border-box; + inline-size: calc(100% - 80px); + padding-block: 5px; + padding-inline: 10px; + box-shadow: 0 2px 1px 0 var(--primary-shadow-color); + background-color: var(--card-bg-color); + border-inline-start: 2px solid var(--primary-color); + display: flex; + align-items: center; + gap: 5px; + justify-content: flex-end; +} + +.floatingRefreshSection:has(.lastRefreshTimestamp + .refreshButton) { + justify-content: space-between; +} + +.floatingRefreshSection.sideNavOpen { + inline-size: calc(100% - 200px); +} + +.lastRefreshTimestamp { + margin-block: 0; + text-align: center; + font-size: 16px; +} + +@media only screen and (width <= 680px) { + .floatingRefreshSection, .floatingRefreshSection.sideNavOpen { + inline-size: 100%; + } +} diff --git a/src/renderer/components/ft-refresh-widget/ft-refresh-widget.js b/src/renderer/components/ft-refresh-widget/ft-refresh-widget.js new file mode 100644 index 0000000000000..24ca4e1f8495c --- /dev/null +++ b/src/renderer/components/ft-refresh-widget/ft-refresh-widget.js @@ -0,0 +1,29 @@ +import { defineComponent } from 'vue' + +import FtIconButton from '../ft-icon-button/ft-icon-button.vue' + +export default defineComponent({ + name: 'FtRefreshWidget', + components: { + 'ft-icon-button': FtIconButton, + }, + props: { + disableRefresh: { + type: Boolean, + default: false + }, + lastRefreshTimestamp: { + type: String, + default: '' + }, + title: { + type: String, + required: true + } + }, + computed: { + isSideNavOpen: function () { + return this.$store.getters.getIsSideNavOpen + } + } +}) diff --git a/src/renderer/components/ft-refresh-widget/ft-refresh-widget.vue b/src/renderer/components/ft-refresh-widget/ft-refresh-widget.vue new file mode 100644 index 0000000000000..564f1cae8402c --- /dev/null +++ b/src/renderer/components/ft-refresh-widget/ft-refresh-widget.vue @@ -0,0 +1,27 @@ + + + + {{ $t('Feed.Feed Last Updated', { feedName: title, date: lastRefreshTimestamp }) }} + + + + + + + diff --git a/src/renderer/components/subscriptions-community/subscriptions-community.js b/src/renderer/components/subscriptions-community/subscriptions-community.js index 1c17cf6f5af17..e42510c49111f 100644 --- a/src/renderer/components/subscriptions-community/subscriptions-community.js +++ b/src/renderer/components/subscriptions-community/subscriptions-community.js @@ -2,7 +2,7 @@ import { defineComponent } from 'vue' import { mapActions, mapMutations } from 'vuex' import SubscriptionsTabUI from '../subscriptions-tab-ui/subscriptions-tab-ui.vue' -import { calculatePublishedDate, copyToClipboard, showToast } from '../../helpers/utils' +import { calculatePublishedDate, copyToClipboard, getRelativeTimeFromDate, showToast } from '../../helpers/utils' import { getLocalChannelCommunity } from '../../helpers/api/local' import { invidiousGetCommunityPosts } from '../../helpers/api/invidious' @@ -49,6 +49,11 @@ export default defineComponent({ }) return entries }, + + lastCommunityRefreshTimestamp: function () { + return getRelativeTimeFromDate(this.$store.getters.getLastCommunityRefreshTimestampByProfile(this.activeProfileId), true) + }, + postCacheForAllActiveProfileChannelsPresent() { if (this.cacheEntriesForAllActiveProfileChannels.length === 0) { return false } if (this.cacheEntriesForAllActiveProfileChannels.length < this.activeSubscriptionList.length) { return false } @@ -69,22 +74,33 @@ export default defineComponent({ watch: { activeProfile: async function (_) { this.isLoading = true - this.loadpostsFromCacheSometimes() + this.loadPostsFromCacheSometimes() }, }, mounted: async function () { this.isLoading = true - this.loadpostsFromCacheSometimes() + this.loadPostsFromCacheSometimes() }, methods: { - loadpostsFromCacheSometimes() { + loadPostsFromCacheSometimes() { // This method is called on view visible if (this.postCacheForAllActiveProfileChannelsPresent) { this.loadPostsFromCacheForAllActiveProfileChannels() + if (this.cacheEntriesForAllActiveProfileChannels.length > 0) { + let minTimestamp = null + this.cacheEntriesForAllActiveProfileChannels.forEach((cacheEntry) => { + if (!minTimestamp || cacheEntry.timestamp.getTime() < minTimestamp.getTime()) { + minTimestamp = cacheEntry.timestamp + } + }) + this.updateLastCommunityRefreshTimestampByProfile({ profileId: this.activeProfileId, timestamp: minTimestamp }) + } return } + // clear timestamp if not all entries are present in the cache + this.updateLastCommunityRefreshTimestampByProfile({ profileId: this.activeProfileId, timestamp: '' }) this.maybeLoadPostsForSubscriptionsFromRemote() }, @@ -137,7 +153,7 @@ export default defineComponent({ this.updateSubscriptionPostsCacheByChannel({ channelId: channel.id, - posts: posts, + posts: posts }) if (posts.length > 0) { @@ -168,6 +184,7 @@ export default defineComponent({ return posts }))).flatMap((o) => o) postList.push(...postListFromRemote) + this.updateLastCommunityRefreshTimestampByProfile({ profileId: this.activeProfileId, timestamp: new Date() }) postList.sort((a, b) => { return calculatePublishedDate(b.publishedText) - calculatePublishedDate(a.publishedText) }) @@ -243,6 +260,7 @@ export default defineComponent({ 'updateShowProgressBar', 'batchUpdateSubscriptionDetails', 'updateSubscriptionPostsCacheByChannel', + 'updateLastCommunityRefreshTimestampByProfile' ]), ...mapMutations([ diff --git a/src/renderer/components/subscriptions-community/subscriptions-community.vue b/src/renderer/components/subscriptions-community/subscriptions-community.vue index 8c2e504c25ef1..f9c3ede16490d 100644 --- a/src/renderer/components/subscriptions-community/subscriptions-community.vue +++ b/src/renderer/components/subscriptions-community/subscriptions-community.vue @@ -6,6 +6,8 @@ :attempted-fetch="attemptedFetch" :is-community="true" :initial-data-limit="20" + :last-refresh-timestamp="lastCommunityRefreshTimestamp" + :title="$t('Global.Community')" @refresh="loadPostsForSubscriptionsFromRemote" /> diff --git a/src/renderer/components/subscriptions-live/subscriptions-live.js b/src/renderer/components/subscriptions-live/subscriptions-live.js index e13daf3ce4e5e..808270ad8ad0f 100644 --- a/src/renderer/components/subscriptions-live/subscriptions-live.js +++ b/src/renderer/components/subscriptions-live/subscriptions-live.js @@ -2,7 +2,7 @@ import { defineComponent } from 'vue' import { mapActions, mapMutations } from 'vuex' import SubscriptionsTabUI from '../subscriptions-tab-ui/subscriptions-tab-ui.vue' -import { setPublishedTimestampsInvidious, copyToClipboard, showToast } from '../../helpers/utils' +import { setPublishedTimestampsInvidious, copyToClipboard, getRelativeTimeFromDate, showToast } from '../../helpers/utils' import { invidiousAPICall } from '../../helpers/api/invidious' import { getLocalChannelLiveStreams } from '../../helpers/api/local' import { parseYouTubeRSSFeed, updateVideoListAfterProcessing } from '../../helpers/subscriptions' @@ -70,6 +70,10 @@ export default defineComponent({ fetchSubscriptionsAutomatically: function() { return this.$store.getters.getFetchSubscriptionsAutomatically }, + + lastLiveRefreshTimestamp: function () { + return getRelativeTimeFromDate(this.$store.getters.getLastLiveRefreshTimestampByProfile(this.activeProfileId), true) + } }, watch: { activeProfile: async function (_) { @@ -87,9 +91,20 @@ export default defineComponent({ // This method is called on view visible if (this.videoCacheForAllActiveProfileChannelsPresent) { this.loadVideosFromCacheForAllActiveProfileChannels() + if (this.cacheEntriesForAllActiveProfileChannels.length > 0) { + let minTimestamp = null + this.cacheEntriesForAllActiveProfileChannels.forEach((cacheEntry) => { + if (!minTimestamp || cacheEntry.timestamp.getTime() < minTimestamp.getTime()) { + minTimestamp = cacheEntry.timestamp + } + }) + this.updateLastLiveRefreshTimestampByProfile({ profileId: this.activeProfileId, timestamp: minTimestamp }) + } return } + // clear timestamp if not all entries are present in the cache + this.updateLastLiveRefreshTimestampByProfile({ profileId: this.activeProfileId, timestamp: '' }) this.maybeLoadVideosForSubscriptionsFromRemote() }, @@ -154,7 +169,7 @@ export default defineComponent({ this.setProgressBarPercentage(percentageComplete) this.updateSubscriptionLiveCacheByChannel({ channelId: channel.id, - videos: videos, + videos: videos }) if (name || thumbnailUrl) { @@ -168,6 +183,7 @@ export default defineComponent({ return videos }))).flatMap((o) => o) videoList.push(...videoListFromRemote) + this.updateLastLiveRefreshTimestampByProfile({ profileId: this.activeProfileId, timestamp: new Date() }) this.videoList = updateVideoListAfterProcessing(videoList) this.isLoading = false @@ -382,6 +398,7 @@ export default defineComponent({ 'batchUpdateSubscriptionDetails', 'updateShowProgressBar', 'updateSubscriptionLiveCacheByChannel', + 'updateLastLiveRefreshTimestampByProfile' ]), ...mapMutations([ diff --git a/src/renderer/components/subscriptions-live/subscriptions-live.vue b/src/renderer/components/subscriptions-live/subscriptions-live.vue index ec631e695eddb..89be8fbd81c82 100644 --- a/src/renderer/components/subscriptions-live/subscriptions-live.vue +++ b/src/renderer/components/subscriptions-live/subscriptions-live.vue @@ -4,6 +4,8 @@ :video-list="videoList" :error-channels="errorChannels" :attempted-fetch="attemptedFetch" + :last-refresh-timestamp="lastLiveRefreshTimestamp" + :title="$t('Global.Live')" @refresh="loadVideosForSubscriptionsFromRemote" /> diff --git a/src/renderer/components/subscriptions-shorts/subscriptions-shorts.js b/src/renderer/components/subscriptions-shorts/subscriptions-shorts.js index 062da5a21d6be..f24ae77c49ef3 100644 --- a/src/renderer/components/subscriptions-shorts/subscriptions-shorts.js +++ b/src/renderer/components/subscriptions-shorts/subscriptions-shorts.js @@ -3,7 +3,7 @@ import { mapActions, mapMutations } from 'vuex' import SubscriptionsTabUI from '../subscriptions-tab-ui/subscriptions-tab-ui.vue' import { parseYouTubeRSSFeed, updateVideoListAfterProcessing } from '../../helpers/subscriptions' -import { copyToClipboard, showToast } from '../../helpers/utils' +import { copyToClipboard, getRelativeTimeFromDate, showToast } from '../../helpers/utils' export default defineComponent({ name: 'SubscriptionsShorts', @@ -31,6 +31,10 @@ export default defineComponent({ return this.$store.getters.getCurrentInvidiousInstance }, + lastShortRefreshTimestamp: function () { + return getRelativeTimeFromDate(this.$store.getters.getLastShortRefreshTimestampByProfile(this.activeProfileId), true) + }, + activeProfile: function () { return this.$store.getters.getActiveProfile }, @@ -79,11 +83,23 @@ export default defineComponent({ methods: { loadVideosFromCacheSometimes() { // This method is called on view visible + if (this.videoCacheForAllActiveProfileChannelsPresent) { this.loadVideosFromCacheForAllActiveProfileChannels() + if (this.cacheEntriesForAllActiveProfileChannels.length > 0) { + let minTimestamp = null + this.cacheEntriesForAllActiveProfileChannels.forEach((cacheEntry) => { + if (!minTimestamp || cacheEntry.timestamp.getTime() < minTimestamp.getTime()) { + minTimestamp = cacheEntry.timestamp + } + }) + this.updateLastShortRefreshTimestampByProfile({ profileId: this.activeProfileId, timestamp: minTimestamp }) + } return } + // clear timestamp if not all entries are present in the cache + this.updateLastShortRefreshTimestampByProfile({ profileId: this.activeProfileId, timestamp: '' }) this.maybeLoadVideosForSubscriptionsFromRemote() }, @@ -131,7 +147,7 @@ export default defineComponent({ this.setProgressBarPercentage(percentageComplete) this.updateSubscriptionShortsCacheByChannel({ channelId: channel.id, - videos: videos, + videos: videos }) if (name) { @@ -144,6 +160,7 @@ export default defineComponent({ return videos }))).flatMap((o) => o) videoList.push(...videoListFromRemote) + this.updateLastShortRefreshTimestampByProfile({ profileId: this.activeProfileId, timestamp: new Date() }) this.videoList = updateVideoListAfterProcessing(videoList) this.isLoading = false @@ -254,6 +271,7 @@ export default defineComponent({ 'batchUpdateSubscriptionDetails', 'updateShowProgressBar', 'updateSubscriptionShortsCacheByChannel', + 'updateLastShortRefreshTimestampByProfile' ]), ...mapMutations([ diff --git a/src/renderer/components/subscriptions-shorts/subscriptions-shorts.vue b/src/renderer/components/subscriptions-shorts/subscriptions-shorts.vue index d0af84d4238d8..0aa6504539cf4 100644 --- a/src/renderer/components/subscriptions-shorts/subscriptions-shorts.vue +++ b/src/renderer/components/subscriptions-shorts/subscriptions-shorts.vue @@ -4,6 +4,8 @@ :video-list="videoList" :error-channels="errorChannels" :attempted-fetch="attemptedFetch" + :last-refresh-timestamp="lastShortRefreshTimestamp" + :title="$t('Global.Shorts')" @refresh="loadVideosForSubscriptionsFromRemote" /> diff --git a/src/renderer/components/subscriptions-tab-ui/subscriptions-tab-ui.css b/src/renderer/components/subscriptions-tab-ui/subscriptions-tab-ui.css index 07025c77f31bb..a902565bb94b5 100644 --- a/src/renderer/components/subscriptions-tab-ui/subscriptions-tab-ui.css +++ b/src/renderer/components/subscriptions-tab-ui/subscriptions-tab-ui.css @@ -8,18 +8,6 @@ color: var(--tertiary-text-color); } -.floatingTopButton { - position: fixed; - inset-block-start: 70px; - inset-inline-end: 10px; -} - -@media only screen and (width <= 350px) { - .floatingTopButton { - position: absolute - } -} - @media only screen and (width <= 680px) { .card { inline-size: 90%; diff --git a/src/renderer/components/subscriptions-tab-ui/subscriptions-tab-ui.js b/src/renderer/components/subscriptions-tab-ui/subscriptions-tab-ui.js index 73e6e40fcdee5..8a29271d532f4 100644 --- a/src/renderer/components/subscriptions-tab-ui/subscriptions-tab-ui.js +++ b/src/renderer/components/subscriptions-tab-ui/subscriptions-tab-ui.js @@ -3,7 +3,7 @@ import { defineComponent } from 'vue' import FtLoader from '../ft-loader/ft-loader.vue' import FtCard from '../ft-card/ft-card.vue' import FtButton from '../ft-button/ft-button.vue' -import FtIconButton from '../ft-icon-button/ft-icon-button.vue' +import FtRefreshWidget from '../ft-refresh-widget/ft-refresh-widget.vue' import FtFlexBox from '../ft-flex-box/ft-flex-box.vue' import FtElementList from '../ft-element-list/ft-element-list.vue' import FtChannelBubble from '../ft-channel-bubble/ft-channel-bubble.vue' @@ -15,7 +15,7 @@ export default defineComponent({ 'ft-loader': FtLoader, 'ft-card': FtCard, 'ft-button': FtButton, - 'ft-icon-button': FtIconButton, + 'ft-refresh-widget': FtRefreshWidget, 'ft-flex-box': FtFlexBox, 'ft-element-list': FtElementList, 'ft-channel-bubble': FtChannelBubble, @@ -45,6 +45,14 @@ export default defineComponent({ initialDataLimit: { type: Number, default: 100 + }, + lastRefreshTimestamp: { + type: String, + required: true + }, + title: { + type: String, + required: true } }, emits: ['refresh'], @@ -72,7 +80,7 @@ export default defineComponent({ fetchSubscriptionsAutomatically: function() { return this.$store.getters.getFetchSubscriptionsAutomatically - }, + } }, created: function () { const dataLimit = sessionStorage.getItem('subscriptionLimit') diff --git a/src/renderer/components/subscriptions-tab-ui/subscriptions-tab-ui.vue b/src/renderer/components/subscriptions-tab-ui/subscriptions-tab-ui.vue index 73d5f303b191b..b37cf56864fb9 100644 --- a/src/renderer/components/subscriptions-tab-ui/subscriptions-tab-ui.vue +++ b/src/renderer/components/subscriptions-tab-ui/subscriptions-tab-ui.vue @@ -58,13 +58,10 @@ /> - diff --git a/src/renderer/components/subscriptions-videos/subscriptions-videos.js b/src/renderer/components/subscriptions-videos/subscriptions-videos.js index 599fbb5f31d8c..b803f2724166c 100644 --- a/src/renderer/components/subscriptions-videos/subscriptions-videos.js +++ b/src/renderer/components/subscriptions-videos/subscriptions-videos.js @@ -2,7 +2,7 @@ import { defineComponent } from 'vue' import { mapActions, mapMutations } from 'vuex' import SubscriptionsTabUI from '../subscriptions-tab-ui/subscriptions-tab-ui.vue' -import { setPublishedTimestampsInvidious, copyToClipboard, showToast } from '../../helpers/utils' +import { setPublishedTimestampsInvidious, copyToClipboard, getRelativeTimeFromDate, showToast } from '../../helpers/utils' import { invidiousAPICall } from '../../helpers/api/invidious' import { getLocalChannelVideos } from '../../helpers/api/local' import { parseYouTubeRSSFeed, updateVideoListAfterProcessing } from '../../helpers/subscriptions' @@ -33,6 +33,14 @@ export default defineComponent({ return this.$store.getters.getCurrentInvidiousInstance }, + currentLocale: function () { + return this.$i18n.locale.replace('_', '-') + }, + + lastVideoRefreshTimestamp: function () { + return getRelativeTimeFromDate(this.$store.getters.getLastVideoRefreshTimestampByProfile(this.activeProfileId), true) + }, + useRssFeeds: function () { return this.$store.getters.getUseRssFeeds }, @@ -87,9 +95,20 @@ export default defineComponent({ // This method is called on view visible if (this.videoCacheForAllActiveProfileChannelsPresent) { this.loadVideosFromCacheForAllActiveProfileChannels() + if (this.cacheEntriesForAllActiveProfileChannels.length > 0) { + let minTimestamp = null + this.cacheEntriesForAllActiveProfileChannels.forEach((cacheEntry) => { + if (!minTimestamp || cacheEntry.timestamp.getTime() < minTimestamp.getTime()) { + minTimestamp = cacheEntry.timestamp + } + }) + this.updateLastVideoRefreshTimestampByProfile({ profileId: this.activeProfileId, timestamp: minTimestamp }) + } return } + // clear timestamp if not all entries are present in the cache + this.updateLastVideoRefreshTimestampByProfile({ profileId: this.activeProfileId, timestamp: '' }) this.maybeLoadVideosForSubscriptionsFromRemote() }, @@ -154,7 +173,7 @@ export default defineComponent({ this.setProgressBarPercentage(percentageComplete) this.updateSubscriptionVideosCacheByChannel({ channelId: channel.id, - videos: videos, + videos: videos }) if (name || thumbnailUrl) { @@ -168,6 +187,7 @@ export default defineComponent({ return videos }))).flatMap((o) => o) videoList.push(...videoListFromRemote) + this.updateLastVideoRefreshTimestampByProfile({ profileId: this.activeProfileId, timestamp: new Date() }) this.videoList = updateVideoListAfterProcessing(videoList) this.isLoading = false @@ -380,6 +400,7 @@ export default defineComponent({ 'batchUpdateSubscriptionDetails', 'updateShowProgressBar', 'updateSubscriptionVideosCacheByChannel', + 'updateLastVideoRefreshTimestampByProfile' ]), ...mapMutations([ diff --git a/src/renderer/components/subscriptions-videos/subscriptions-videos.vue b/src/renderer/components/subscriptions-videos/subscriptions-videos.vue index 329d0d74e4f21..42aaa5d014122 100644 --- a/src/renderer/components/subscriptions-videos/subscriptions-videos.vue +++ b/src/renderer/components/subscriptions-videos/subscriptions-videos.vue @@ -3,7 +3,9 @@ :is-loading="isLoading" :video-list="videoList" :error-channels="errorChannels" + :last-refresh-timestamp="lastVideoRefreshTimestamp" :attempted-fetch="attemptedFetch" + :title="$t('Global.Videos')" @refresh="loadVideosForSubscriptionsFromRemote" /> diff --git a/src/renderer/helpers/utils.js b/src/renderer/helpers/utils.js index 1a0850e07c0a7..186813446036f 100644 --- a/src/renderer/helpers/utils.js +++ b/src/renderer/helpers/utils.js @@ -10,6 +10,11 @@ import router from '../router/index' export const CHANNEL_HANDLE_REGEX = /^@[\w.-]{3,30}$/ const PUBLISHED_TEXT_REGEX = /(\d+)\s?([a-z]+)/i + +function currentLocale () { + return i18n.locale.replace('_', '-') +} + /** * @param {string} publishedText * @param {boolean} isLive @@ -52,6 +57,7 @@ export function calculatePublishedDate(publishedText, isLive = false, isUpcoming } else if (timeFrame.startsWith('week') || timeFrame === 'w') { timeSpan = timeAmount * 604800000 } else if (timeFrame.startsWith('month') || timeFrame === 'mo') { + // 30 day month being used timeSpan = timeAmount * 2592000000 } else if (timeFrame.startsWith('year') || timeFrame === 'y') { timeSpan = timeAmount * 31556952000 @@ -715,6 +721,57 @@ export function getTodayDateStrLocalTimezone() { return timeNowStr.split('T')[0] } +export function getRelativeTimeFromDate(date, hideSeconds = false, useThirtyDayMonths = true) { + if (!date) { + return '' + } + + const now = new Date().getTime() + // Convert from ms to second + // For easier code interpretation the value is made to be positive + // `comparisonDate` is sometimes a string + const comparisonDate = Date.parse(date) + let timeDiffFromNow = ((now - comparisonDate) / 1000) + let timeUnit = 'second' + + if (timeDiffFromNow < 60 && hideSeconds) { + return i18n.t('Moments Ago') + } + + if (timeDiffFromNow >= 60) { + timeDiffFromNow /= 60 + timeUnit = 'minute' + } + + if (timeUnit === 'minute' && timeDiffFromNow >= 60) { + timeDiffFromNow /= 60 + timeUnit = 'hour' + } + + if (timeUnit === 'hour' && timeDiffFromNow >= 24) { + timeDiffFromNow /= 24 + timeUnit = 'day' + } + + /* Different months might have a different number of days. + In some contexts, to ensure the display is fine, we use 31. + In other contexts, like when working with calculatePublishedDate, we use 30. */ + const daysInMonth = useThirtyDayMonths ? 30 : 31 + if (timeUnit === 'day' && timeDiffFromNow >= daysInMonth) { + timeDiffFromNow /= daysInMonth + timeUnit = 'month' + } + + if (timeUnit === 'month' && timeDiffFromNow >= 12) { + timeDiffFromNow /= 12 + timeUnit = 'year' + } + + // Using `Math.ceil` so that -1.x days ago displayed as 1 day ago + // Notice that the value is turned to negative to be displayed as "ago" + return new Intl.RelativeTimeFormat([currentLocale(), 'en']).format(Math.ceil(-timeDiffFromNow), timeUnit) +} + /** * Escapes HTML tags to avoid XSS * @param {string} untrusted diff --git a/src/renderer/store/modules/subscriptions.js b/src/renderer/store/modules/subscriptions.js index 2ce05c7b62d8e..f98f7881b7978 100644 --- a/src/renderer/store/modules/subscriptions.js +++ b/src/renderer/store/modules/subscriptions.js @@ -69,19 +69,21 @@ const actions = { } const mutations = { - updateVideoCacheByChannel(state, { channelId, videos }) { + updateVideoCacheByChannel(state, { channelId, videos, timestamp = new Date() }) { const existingObject = state.videoCache[channelId] const newObject = existingObject ?? { videos: null } if (videos != null) { newObject.videos = videos } + newObject.timestamp = timestamp state.videoCache[channelId] = newObject }, clearVideoCache(state) { state.videoCache = {} }, - updateShortsCacheByChannel(state, { channelId, videos }) { + updateShortsCacheByChannel(state, { channelId, videos, timestamp = new Date() }) { const existingObject = state.shortsCache[channelId] const newObject = existingObject ?? { videos: null } if (videos != null) { newObject.videos = videos } + newObject.timestamp = timestamp state.shortsCache[channelId] = newObject }, updateShortsCacheWithChannelPageShorts(state, { channelId, videos }) { @@ -112,19 +114,21 @@ const mutations = { clearShortsCache(state) { state.shortsCache = {} }, - updateLiveCacheByChannel(state, { channelId, videos }) { + updateLiveCacheByChannel(state, { channelId, videos, timestamp = new Date() }) { const existingObject = state.liveCache[channelId] const newObject = existingObject ?? { videos: null } if (videos != null) { newObject.videos = videos } + newObject.timestamp = timestamp state.liveCache[channelId] = newObject }, clearLiveCache(state) { state.liveCache = {} }, - updatePostsCacheByChannel(state, { channelId, posts }) { + updatePostsCacheByChannel(state, { channelId, posts, timestamp = new Date() }) { const existingObject = state.postsCache[channelId] const newObject = existingObject ?? { posts: null } if (posts != null) { newObject.posts = posts } + newObject.timestamp = timestamp state.postsCache[channelId] = newObject }, clearPostsCache(state) { diff --git a/src/renderer/store/modules/utils.js b/src/renderer/store/modules/utils.js index c99290f20f0d3..db11f438e4edb 100644 --- a/src/renderer/store/modules/utils.js +++ b/src/renderer/store/modules/utils.js @@ -48,7 +48,13 @@ const state = { }, externalPlayerNames: [], externalPlayerValues: [], - externalPlayerCmdArguments: {} + externalPlayerCmdArguments: {}, + lastVideoRefreshTimestampByProfile: {}, + lastShortRefreshTimestampByProfile: {}, + lastLiveRefreshTimestampByProfile: {}, + lastCommunityRefreshTimestampByProfile: {}, + lastPopularRefreshTimestamp: '', + lastTrendingRefreshTimestamp: '', } const getters = { @@ -138,6 +144,30 @@ const getters = { getExternalPlayerCmdArguments () { return state.externalPlayerCmdArguments + }, + + getLastTrendingRefreshTimestamp() { + return state.lastTrendingRefreshTimestamp + }, + + getLastPopularRefreshTimestamp() { + return state.lastPopularRefreshTimestamp + }, + + getLastCommunityRefreshTimestampByProfile: (state) => (profileId) => { + return state.lastCommunityRefreshTimestampByProfile[profileId] + }, + + getLastShortRefreshTimestampByProfile: (state) => (profileId) => { + return state.lastShortRefreshTimestampByProfile[profileId] + }, + + getLastLiveRefreshTimestampByProfile: (state) => (profileId) => { + return state.lastLiveRefreshTimestampByProfile[profileId] + }, + + getLastVideoRefreshTimestampByProfile: (state) => (profileId) => { + return state.lastVideoRefreshTimestampByProfile[profileId] } } @@ -732,6 +762,22 @@ const actions = { const { ipcRenderer } = require('electron') ipcRenderer.send(IpcChannels.OPEN_IN_EXTERNAL_PLAYER, { executable, args }) } + }, + + updateLastCommunityRefreshTimestampByProfile ({ commit }, payload) { + commit('updateLastCommunityRefreshTimestampByProfile', payload) + }, + + updateLastShortRefreshTimestampByProfile ({ commit }, payload) { + commit('updateLastShortRefreshTimestampByProfile', payload) + }, + + updateLastLiveRefreshTimestampByProfile ({ commit }, payload) { + commit('updateLastLiveRefreshTimestampByProfile', payload) + }, + + updateLastVideoRefreshTimestampByProfile ({ commit }, payload) { + commit('updateLastVideoRefreshTimestampByProfile', payload) } } @@ -824,6 +870,30 @@ const mutations = { state.trendingCache[page] = value }, + setLastTrendingRefreshTimestamp (state, timestamp) { + state.lastTrendingRefreshTimestamp = timestamp + }, + + setLastPopularRefreshTimestamp (state, timestamp) { + state.lastPopularRefreshTimestamp = timestamp + }, + + updateLastCommunityRefreshTimestampByProfile (state, { profileId, timestamp }) { + vueSet(state.lastCommunityRefreshTimestampByProfile, profileId, timestamp) + }, + + updateLastShortRefreshTimestampByProfile (state, { profileId, timestamp }) { + vueSet(state.lastShortRefreshTimestampByProfile, profileId, timestamp) + }, + + updateLastLiveRefreshTimestampByProfile (state, { profileId, timestamp }) { + vueSet(state.lastLiveRefreshTimestampByProfile, profileId, timestamp) + }, + + updateLastVideoRefreshTimestampByProfile (state, { profileId, timestamp }) { + vueSet(state.lastVideoRefreshTimestampByProfile, profileId, timestamp) + }, + clearTrendingCache(state) { state.trendingCache = { default: null, diff --git a/src/renderer/views/Popular/Popular.css b/src/renderer/views/Popular/Popular.css index 05f87ad79961a..b6f8ffc9db440 100644 --- a/src/renderer/views/Popular/Popular.css +++ b/src/renderer/views/Popular/Popular.css @@ -4,18 +4,6 @@ margin-inline: auto; } -.floatingTopButton { - position: fixed; - inset-block-start: 70px; - inset-inline-end: 10px; -} - -@media only screen and (width <= 350px) { - .floatingTopButton { - position: absolute - } -} - @media only screen and (width <= 680px) { .card { inline-size: 90%; diff --git a/src/renderer/views/Popular/Popular.js b/src/renderer/views/Popular/Popular.js index a85e3eb3d39a7..9d71b12d30385 100644 --- a/src/renderer/views/Popular/Popular.js +++ b/src/renderer/views/Popular/Popular.js @@ -1,11 +1,13 @@ import { defineComponent } from 'vue' +import { mapMutations } from 'vuex' import FtLoader from '../../components/ft-loader/ft-loader.vue' import FtCard from '../../components/ft-card/ft-card.vue' import FtElementList from '../../components/ft-element-list/ft-element-list.vue' import FtIconButton from '../../components/ft-icon-button/ft-icon-button.vue' +import FtRefreshWidget from '../../components/ft-refresh-widget/ft-refresh-widget.vue' import { invidiousAPICall } from '../../helpers/api/invidious' -import { copyToClipboard, setPublishedTimestampsInvidious, showToast } from '../../helpers/utils' +import { copyToClipboard, getRelativeTimeFromDate, setPublishedTimestampsInvidious, showToast } from '../../helpers/utils' export default defineComponent({ name: 'Popular', @@ -13,7 +15,8 @@ export default defineComponent({ 'ft-loader': FtLoader, 'ft-card': FtCard, 'ft-element-list': FtElementList, - 'ft-icon-button': FtIconButton + 'ft-icon-button': FtIconButton, + 'ft-refresh-widget': FtRefreshWidget, }, data: function () { return { @@ -22,6 +25,9 @@ export default defineComponent({ } }, computed: { + lastPopularRefreshTimestamp: function () { + return getRelativeTimeFromDate(this.$store.getters.getLastPopularRefreshTimestamp, true) + }, popularCache: function () { return this.$store.getters.getPopularCache } @@ -64,6 +70,7 @@ export default defineComponent({ return item.type === 'video' || item.type === 'shortVideo' || item.type === 'channel' || item.type === 'playlist' }) setPublishedTimestampsInvidious(items.filter(item => item.type === 'video' || item.type === 'shortVideo')) + this.setLastPopularRefreshTimestamp(new Date()) this.shownResults = items @@ -92,6 +99,10 @@ export default defineComponent({ } break } - } + }, + + ...mapMutations([ + 'setLastPopularRefreshTimestamp' + ]) } }) diff --git a/src/renderer/views/Popular/Popular.vue b/src/renderer/views/Popular/Popular.vue index ea86d813e4dd8..c3f001f3415b8 100644 --- a/src/renderer/views/Popular/Popular.vue +++ b/src/renderer/views/Popular/Popular.vue @@ -13,12 +13,10 @@ :data="shownResults" /> - diff --git a/src/renderer/views/Trending/Trending.css b/src/renderer/views/Trending/Trending.css index 5425c5e253094..2a0371e88a7fb 100644 --- a/src/renderer/views/Trending/Trending.css +++ b/src/renderer/views/Trending/Trending.css @@ -4,12 +4,6 @@ margin-inline: auto; } -.floatingTopButton { - position: fixed; - inset-block-start: 70px; - inset-inline-end: 10px; -} - .trendingInfoTabs { inline-size: 100%; display: grid; @@ -38,12 +32,6 @@ font-weight: bold; } -@media only screen and (width <= 350px) { - .floatingTopButton { - position: absolute - } -} - @media only screen and (width <= 680px) { .card { inline-size: 90%; diff --git a/src/renderer/views/Trending/Trending.js b/src/renderer/views/Trending/Trending.js index 355491b93f9db..c4b9e65beb09a 100644 --- a/src/renderer/views/Trending/Trending.js +++ b/src/renderer/views/Trending/Trending.js @@ -1,12 +1,13 @@ import { defineComponent } from 'vue' -import { mapActions } from 'vuex' +import { mapActions, mapMutations } from 'vuex' import FtCard from '../../components/ft-card/ft-card.vue' import FtLoader from '../../components/ft-loader/ft-loader.vue' import FtElementList from '../../components/ft-element-list/ft-element-list.vue' import FtIconButton from '../../components/ft-icon-button/ft-icon-button.vue' import FtFlexBox from '../../components/ft-flex-box/ft-flex-box.vue' +import FtRefreshWidget from '../../components/ft-refresh-widget/ft-refresh-widget.vue' -import { copyToClipboard, setPublishedTimestampsInvidious, showToast } from '../../helpers/utils' +import { copyToClipboard, getRelativeTimeFromDate, setPublishedTimestampsInvidious, showToast } from '../../helpers/utils' import { getLocalTrending } from '../../helpers/api/local' import { invidiousAPICall } from '../../helpers/api/invidious' @@ -17,7 +18,8 @@ export default defineComponent({ 'ft-loader': FtLoader, 'ft-element-list': FtElementList, 'ft-icon-button': FtIconButton, - 'ft-flex-box': FtFlexBox + 'ft-flex-box': FtFlexBox, + 'ft-refresh-widget': FtRefreshWidget, }, data: function () { return { @@ -34,6 +36,9 @@ export default defineComponent({ backendFallback: function () { return this.$store.getters.getBackendFallback }, + lastTrendingRefreshTimestamp: function () { + return getRelativeTimeFromDate(this.$store.getters.getLastTrendingRefreshTimestamp, true) + }, region: function () { return this.$store.getters.getRegion.toUpperCase() }, @@ -90,6 +95,8 @@ export default defineComponent({ } else { this.getTrendingInfoLocal() } + + this.setLastTrendingRefreshTimestamp(new Date()) }, getTrendingInfoLocal: async function () { @@ -195,6 +202,10 @@ export default defineComponent({ ...mapActions([ 'showOutlines' + ]), + + ...mapMutations([ + 'setLastTrendingRefreshTimestamp' ]) } }) diff --git a/src/renderer/views/Trending/Trending.vue b/src/renderer/views/Trending/Trending.vue index 8b244d5482472..5e03565500e18 100644 --- a/src/renderer/views/Trending/Trending.vue +++ b/src/renderer/views/Trending/Trending.vue @@ -85,12 +85,10 @@ :data="shownResults" /> - diff --git a/static/locales/en-US.yaml b/static/locales/en-US.yaml index 7ff54327b04a6..03eded27f8b2a 100644 --- a/static/locales/en-US.yaml +++ b/static/locales/en-US.yaml @@ -109,7 +109,6 @@ Subscriptions: Empty Channels: Your subscribed channels currently does not have any videos. 'Getting Subscriptions. Please wait.': Getting Subscriptions. Please wait. Empty Posts: Your subscribed channels currently do not have any posts. - Refresh Subscriptions: Refresh Subscriptions Load More Videos: Load More Videos Load More Posts: Load More Posts Subscriptions Tabs: Subscriptions Tabs @@ -132,6 +131,9 @@ Trending: Movies: Movies Trending Tabs: Trending Tabs Most Popular: Most Popular +Feed: + Feed Last Updated: '{feedName} feed last updated: {date}' + Refresh Feed: Refresh {subscriptionName} Playlists: Playlists User Playlists: Your Playlists: Your Playlists @@ -1061,6 +1063,7 @@ Hashtag: Hashtag: Hashtag This hashtag does not currently have any videos: This hashtag does not currently have any videos +Moments Ago: moments ago Yes: Yes No: No Ok: Ok
+ {{ $t('Feed.Feed Last Updated', { feedName: title, date: lastRefreshTimestamp }) }} +