diff --git a/.eslintrc.js b/.eslintrc.js index 1846e7791..f342b62dd 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -112,6 +112,8 @@ module.exports = { ignoreText: ['-', '•', '/', 'YouTube', 'Invidious', 'FreeTube'] } ], + 'vue/require-explicit-emits': 'error', + 'vue/no-unused-emit-declarations': 'error', }, settings: { 'vue-i18n': { diff --git a/.github/workflows/linter.yml b/.github/workflows/linter.yml index a10f2f3e2..962a5f55d 100644 --- a/.github/workflows/linter.yml +++ b/.github/workflows/linter.yml @@ -27,3 +27,5 @@ jobs: - run: yarn run lint # let's verify that webpack is able to package the project - run: yarn run pack + # verify that webpack is able to package the project using the web config + - run: yarn run pack:web diff --git a/_scripts/dev-runner.js b/_scripts/dev-runner.js index 0a4678040..fe25a836f 100644 --- a/_scripts/dev-runner.js +++ b/_scripts/dev-runner.js @@ -111,13 +111,14 @@ function startRenderer(callback) { const server = new WebpackDevServer({ static: { - directory: path.join(process.cwd(), 'static'), + directory: path.resolve(__dirname, '..', 'static'), watch: { ignored: [ /(dashFiles|storyboards)\/*/, '/**/.DS_Store', ] - } + }, + publicPath: '/static' }, port }, compiler) diff --git a/_scripts/webpack.renderer.config.js b/_scripts/webpack.renderer.config.js index 18d778c89..fcd9b0e58 100644 --- a/_scripts/webpack.renderer.config.js +++ b/_scripts/webpack.renderer.config.js @@ -111,8 +111,8 @@ const config = { ] }, node: { - __dirname: isDevMode, - __filename: isDevMode + __dirname: false, + __filename: false }, plugins: [ processLocalesPlugin, @@ -151,6 +151,8 @@ const config = { alias: { vue$: 'vue/dist/vue.runtime.esm.js', + 'DB_HANDLERS_ELECTRON_RENDERER_OR_WEB$': path.resolve(__dirname, '../src/datastores/handlers/electron.js'), + 'youtubei.js$': 'youtubei.js/web', // video.js's mpd-parser uses @xmldom/xmldom so that it can support both node and web browsers diff --git a/_scripts/webpack.web.config.js b/_scripts/webpack.web.config.js index c06679910..04f4b9803 100644 --- a/_scripts/webpack.web.config.js +++ b/_scripts/webpack.web.config.js @@ -108,8 +108,8 @@ const config = { ] }, node: { - __dirname: true, - __filename: isDevMode, + __dirname: false, + __filename: false }, plugins: [ new webpack.DefinePlugin({ @@ -160,6 +160,8 @@ const config = { alias: { vue$: 'vue/dist/vue.runtime.esm.js', + 'DB_HANDLERS_ELECTRON_RENDERER_OR_WEB$': path.resolve(__dirname, '../src/datastores/handlers/web.js'), + // video.js's mpd-parser uses @xmldom/xmldom so that it can support both node and web browsers // As FreeTube only runs in electron and web browsers, we can use the native DOMParser class, instead of the "polyfill" // https://caniuse.com/mdn-api_domparser diff --git a/jsconfig.json b/jsconfig.json index 6efa53c23..3dd46670d 100644 --- a/jsconfig.json +++ b/jsconfig.json @@ -3,6 +3,13 @@ "target": 2.7 }, "compilerOptions": { - "strictNullChecks": true + "strictNullChecks": true, + "baseUrl": "./", + "paths": { + "DB_HANDLERS_ELECTRON_RENDERER_OR_WEB": [ + "src/datastores/handlers/electron", + "src/datastores/handlers/web" + ] + } } } diff --git a/src/datastores/handlers/base.js b/src/datastores/handlers/base.js index e2bff72ef..7c2eb4ec0 100644 --- a/src/datastores/handlers/base.js +++ b/src/datastores/handlers/base.js @@ -1,4 +1,4 @@ -import db from '../index' +import * as db from '../index' class Settings { static find() { @@ -192,13 +192,11 @@ function compactAllDatastores() { ]) } -const baseHandlers = { - settings: Settings, - history: History, - profiles: Profiles, - playlists: Playlists, +export { + Settings as settings, + History as history, + Profiles as profiles, + Playlists as playlists, compactAllDatastores, } - -export default baseHandlers diff --git a/src/datastores/handlers/electron.js b/src/datastores/handlers/electron.js index 9bf2526cf..31e8c9630 100644 --- a/src/datastores/handlers/electron.js +++ b/src/datastores/handlers/electron.js @@ -205,11 +205,9 @@ class Playlists { } } -const handlers = { - settings: Settings, - history: History, - profiles: Profiles, - playlists: Playlists +export { + Settings as settings, + History as history, + Profiles as profiles, + Playlists as playlists } - -export default handlers diff --git a/src/datastores/handlers/index.js b/src/datastores/handlers/index.js index e96565d1f..df6ebffd9 100644 --- a/src/datastores/handlers/index.js +++ b/src/datastores/handlers/index.js @@ -1,18 +1,6 @@ -let handlers -if (process.env.IS_ELECTRON) { - handlers = require('./electron').default -} else { - handlers = require('./web').default -} - -const DBSettingHandlers = handlers.settings -const DBHistoryHandlers = handlers.history -const DBProfileHandlers = handlers.profiles -const DBPlaylistHandlers = handlers.playlists - export { - DBSettingHandlers, - DBHistoryHandlers, - DBProfileHandlers, - DBPlaylistHandlers -} + settings as DBSettingHandlers, + history as DBHistoryHandlers, + profiles as DBProfileHandlers, + playlists as DBPlaylistHandlers +} from 'DB_HANDLERS_ELECTRON_RENDERER_OR_WEB' diff --git a/src/datastores/handlers/web.js b/src/datastores/handlers/web.js index e8618beab..d5feccc99 100644 --- a/src/datastores/handlers/web.js +++ b/src/datastores/handlers/web.js @@ -1,4 +1,4 @@ -import baseHandlers from './base' +import * as baseHandlers from './base' // TODO: Syncing // Syncing on the web would involve a different implementation @@ -118,11 +118,9 @@ class Playlists { } } -const handlers = { - settings: Settings, - history: History, - profiles: Profiles, - playlists: Playlists +export { + Settings as settings, + History as history, + Profiles as profiles, + Playlists as playlists } - -export default handlers diff --git a/src/datastores/index.js b/src/datastores/index.js index a7038fec9..442fed649 100644 --- a/src/datastores/index.js +++ b/src/datastores/index.js @@ -22,10 +22,7 @@ if (process.env.IS_ELECTRON_MAIN) { dbPath = (dbName) => `${dbName}.db` } -const db = {} -db.settings = new Datastore({ filename: dbPath('settings'), autoload: true }) -db.profiles = new Datastore({ filename: dbPath('profiles'), autoload: true }) -db.playlists = new Datastore({ filename: dbPath('playlists'), autoload: true }) -db.history = new Datastore({ filename: dbPath('history'), autoload: true }) - -export default db +export const settings = new Datastore({ filename: dbPath('settings'), autoload: true }) +export const profiles = new Datastore({ filename: dbPath('profiles'), autoload: true }) +export const playlists = new Datastore({ filename: dbPath('playlists'), autoload: true }) +export const history = new Datastore({ filename: dbPath('history'), autoload: true }) diff --git a/src/main/index.js b/src/main/index.js index aa3b241a3..928dfd7b6 100644 --- a/src/main/index.js +++ b/src/main/index.js @@ -7,7 +7,7 @@ import path from 'path' import cp from 'child_process' import { IpcChannels, DBActions, SyncEvents } from '../constants' -import baseHandlers from '../datastores/handlers/base' +import * as baseHandlers from '../datastores/handlers/base' import { extractExpiryTimestamp, ImageCache } from './ImageCache' import { existsSync } from 'fs' import asyncFs from 'fs/promises' diff --git a/src/renderer/App.css b/src/renderer/App.css index 24b765ca8..036435314 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/App.js b/src/renderer/App.js index a80cebcf1..b208fe8e4 100644 --- a/src/renderer/App.js +++ b/src/renderer/App.js @@ -287,10 +287,7 @@ export default defineComponent({ }, checkExternalPlayer: async function () { - const payload = { - externalPlayer: this.externalPlayer - } - this.getExternalPlayerCmdArgumentsData(payload) + this.getExternalPlayerCmdArgumentsData() }, handleUpdateBannerClick: function (response) { diff --git a/src/renderer/components/ft-auto-load-next-page-wrapper/ft-auto-load-next-page-wrapper.js b/src/renderer/components/ft-auto-load-next-page-wrapper/ft-auto-load-next-page-wrapper.js index aca042948..4b26e8866 100644 --- a/src/renderer/components/ft-auto-load-next-page-wrapper/ft-auto-load-next-page-wrapper.js +++ b/src/renderer/components/ft-auto-load-next-page-wrapper/ft-auto-load-next-page-wrapper.js @@ -2,6 +2,7 @@ import { defineComponent } from 'vue' export default defineComponent({ name: 'FtAutoLoadNextPageWrapper', + emits: ['load-next-page'], computed: { generalAutoLoadMorePaginatedItemsEnabled() { return this.$store.getters.getGeneralAutoLoadMorePaginatedItemsEnabled diff --git a/src/renderer/components/ft-channel-bubble/ft-channel-bubble.js b/src/renderer/components/ft-channel-bubble/ft-channel-bubble.js index c9854b34d..dbff93d28 100644 --- a/src/renderer/components/ft-channel-bubble/ft-channel-bubble.js +++ b/src/renderer/components/ft-channel-bubble/ft-channel-bubble.js @@ -20,6 +20,7 @@ export default defineComponent({ default: false } }, + emits: ['click'], data: function () { return { selected: false 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 fb6b4a3a9..cba8cfb34 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' @@ -61,6 +65,7 @@ export default defineComponent({ default: false } }, + emits: ['click'], data: function () { return { dropdownShown: false, @@ -87,6 +92,7 @@ export default defineComponent({ }, handleIconClick: function () { + if (this.disabled) { return } if (this.forceDropdown || (this.dropdownOptions.length > 0)) { this.dropdownShown = !this.dropdownShown @@ -103,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 c76e9333f..6e4f3acb2 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 afc4c68f4..8f4d2bfc7 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-input-tags/ft-input-tags.js b/src/renderer/components/ft-input-tags/ft-input-tags.js index e139db3b7..281b35171 100644 --- a/src/renderer/components/ft-input-tags/ft-input-tags.js +++ b/src/renderer/components/ft-input-tags/ft-input-tags.js @@ -53,6 +53,7 @@ export default defineComponent({ default: (_) => ({ preferredName: '', icon: '' }), } }, + emits: ['already-exists', 'change', 'error-find-tag-info', 'invalid-name'], methods: { updateTags: async function (text, _e) { if (this.areChannelTags) { diff --git a/src/renderer/components/ft-input/ft-input.js b/src/renderer/components/ft-input/ft-input.js index dc4c945c2..9c3bfc197 100644 --- a/src/renderer/components/ft-input/ft-input.js +++ b/src/renderer/components/ft-input/ft-input.js @@ -64,6 +64,7 @@ export default defineComponent({ default: '' } }, + emits: ['clear', 'click', 'input'], data: function () { let actionIcon = ['fas', 'search'] if (this.forceActionButtonIconName !== null) { 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 b602727b6..8e34e17f2 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, @@ -84,6 +85,7 @@ export default defineComponent({ default: false, }, }, + emits: ['pause-player'], data: function () { return { id: '', @@ -365,6 +367,10 @@ export default defineComponent({ return this.historyEntryExists && !this.inHistory }, + currentLocale: function () { + return this.$i18n.locale.replace('_', '-') + }, + externalPlayer: function () { return this.$store.getters.getExternalPlayer }, @@ -482,14 +488,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 }, @@ -688,48 +686,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 2f8464be3..6efe9d4f9 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.js b/src/renderer/components/ft-notification-banner/ft-notification-banner.js index 9973993cb..75c7c4c87 100644 --- a/src/renderer/components/ft-notification-banner/ft-notification-banner.js +++ b/src/renderer/components/ft-notification-banner/ft-notification-banner.js @@ -8,6 +8,7 @@ export default defineComponent({ required: true } }, + emits: ['click'], methods: { handleClick: function (response) { this.$emit('click', response) 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 02feed760..4b3ff2eb0 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-playlist-selector/ft-playlist-selector.js b/src/renderer/components/ft-playlist-selector/ft-playlist-selector.js index 61c2551f2..dbb64c271 100644 --- a/src/renderer/components/ft-playlist-selector/ft-playlist-selector.js +++ b/src/renderer/components/ft-playlist-selector/ft-playlist-selector.js @@ -25,6 +25,7 @@ export default defineComponent({ required: true, }, }, + emits: ['selected'], data: function () { return { title: '', diff --git a/src/renderer/components/ft-profile-edit/ft-profile-edit.js b/src/renderer/components/ft-profile-edit/ft-profile-edit.js index 4cf9029c9..f89b449f1 100644 --- a/src/renderer/components/ft-profile-edit/ft-profile-edit.js +++ b/src/renderer/components/ft-profile-edit/ft-profile-edit.js @@ -32,6 +32,7 @@ export default defineComponent({ required: true } }, + emits: ['new-profile-created', 'profile-deleted'], data: function () { return { showDeletePrompt: false, diff --git a/src/renderer/components/ft-prompt/ft-prompt.js b/src/renderer/components/ft-prompt/ft-prompt.js index 060a4a006..45296bbfd 100644 --- a/src/renderer/components/ft-prompt/ft-prompt.js +++ b/src/renderer/components/ft-prompt/ft-prompt.js @@ -38,6 +38,7 @@ export default defineComponent({ default: false } }, + emits: ['click'], data: function () { return { promptButtons: [], 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 000000000..3c9b49909 --- /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 000000000..24ca4e1f8 --- /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 000000000..564f1cae8 --- /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/ft-search-filters/ft-search-filters.js b/src/renderer/components/ft-search-filters/ft-search-filters.js index 3fe6da742..bebeafcdc 100644 --- a/src/renderer/components/ft-search-filters/ft-search-filters.js +++ b/src/renderer/components/ft-search-filters/ft-search-filters.js @@ -8,6 +8,7 @@ export default defineComponent({ 'ft-flex-box': FtFlexBox, 'ft-radio-button': FtRadioButton }, + emits: ['filterValueUpdated'], data: function () { return { sortByValues: [ diff --git a/src/renderer/components/ft-timestamp-catcher/ft-timestamp-catcher.js b/src/renderer/components/ft-timestamp-catcher/ft-timestamp-catcher.js index 1006ea594..fb60e08a2 100644 --- a/src/renderer/components/ft-timestamp-catcher/ft-timestamp-catcher.js +++ b/src/renderer/components/ft-timestamp-catcher/ft-timestamp-catcher.js @@ -8,6 +8,7 @@ export default defineComponent({ default: '' } }, + emits: ['timestamp-event'], methods: { catchTimestampClick: function (event) { this.$emit('timestamp-event', event.detail) diff --git a/src/renderer/components/ft-video-player/ft-video-player.js b/src/renderer/components/ft-video-player/ft-video-player.js index afe83aff3..4cacc6a19 100644 --- a/src/renderer/components/ft-video-player/ft-video-player.js +++ b/src/renderer/components/ft-video-player/ft-video-player.js @@ -117,6 +117,7 @@ export default defineComponent({ default: false } }, + emits: ['ended', 'error', 'ready', 'store-caption-list', 'timeupdate', 'toggle-theatre-mode'], data: function () { return { powerSaveBlocker: null, diff --git a/src/renderer/components/playlist-info/playlist-info.js b/src/renderer/components/playlist-info/playlist-info.js index e7948882c..a4b1f0a4a 100644 --- a/src/renderer/components/playlist-info/playlist-info.js +++ b/src/renderer/components/playlist-info/playlist-info.js @@ -97,6 +97,7 @@ export default defineComponent({ required: true, }, }, + emits: ['enter-edit-mode', 'exit-edit-mode', 'search-video-query-change'], data: function () { return { searchVideoMode: false, diff --git a/src/renderer/components/subscriptions-community/subscriptions-community.js b/src/renderer/components/subscriptions-community/subscriptions-community.js index 5d15505f5..90b7c6444 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' @@ -53,6 +53,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 } @@ -73,22 +78,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() }, @@ -141,7 +157,7 @@ export default defineComponent({ this.updateSubscriptionPostsCacheByChannel({ channelId: channel.id, - posts: posts, + posts: posts }) if (posts.length > 0) { @@ -172,6 +188,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) }) @@ -247,6 +264,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 8c2e504c2..f9c3ede16 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 dc3674274..cd2fae3cb 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' @@ -74,6 +74,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 (_) { @@ -91,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.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() }, @@ -158,7 +173,7 @@ export default defineComponent({ this.setProgressBarPercentage(percentageComplete) this.updateSubscriptionLiveCacheByChannel({ channelId: channel.id, - videos: videos, + videos: videos }) if (name || thumbnailUrl) { @@ -172,6 +187,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 @@ -386,6 +402,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 ec631e695..89be8fbd8 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 fa54ad9b9..f59436cdf 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', @@ -35,6 +35,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 }, @@ -83,11 +87,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() }, @@ -135,7 +151,7 @@ export default defineComponent({ this.setProgressBarPercentage(percentageComplete) this.updateSubscriptionShortsCacheByChannel({ channelId: channel.id, - videos: videos, + videos: videos }) if (name) { @@ -148,6 +164,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 @@ -258,6 +275,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 d0af84d42..0aa650453 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 07025c77f..a902565bb 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 1c72b620b..8a29271d5 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,8 +45,17 @@ export default defineComponent({ initialDataLimit: { type: Number, default: 100 + }, + lastRefreshTimestamp: { + type: String, + required: true + }, + title: { + type: String, + required: true } }, + emits: ['refresh'], data: function () { return { dataLimit: 100, @@ -71,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 73d5f303b..b37cf5686 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 31aad4b3e..074865e09 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' @@ -37,6 +37,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 }, @@ -91,9 +99,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() }, @@ -158,7 +177,7 @@ export default defineComponent({ this.setProgressBarPercentage(percentageComplete) this.updateSubscriptionVideosCacheByChannel({ channelId: channel.id, - videos: videos, + videos: videos }) if (name || thumbnailUrl) { @@ -172,6 +191,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 @@ -384,6 +404,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 329d0d74e..42aaa5d01 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/components/watch-video-chapters/watch-video-chapters.js b/src/renderer/components/watch-video-chapters/watch-video-chapters.js index 74885cbdf..dd49f2e95 100644 --- a/src/renderer/components/watch-video-chapters/watch-video-chapters.js +++ b/src/renderer/components/watch-video-chapters/watch-video-chapters.js @@ -16,6 +16,7 @@ export default defineComponent({ required: true } }, + emits: ['timestamp-event'], data: function () { return { showChapters: false, diff --git a/src/renderer/components/watch-video-comments/watch-video-comments.js b/src/renderer/components/watch-video-comments/watch-video-comments.js index e9b19d9c2..e632cdd26 100644 --- a/src/renderer/components/watch-video-comments/watch-video-comments.js +++ b/src/renderer/components/watch-video-comments/watch-video-comments.js @@ -37,6 +37,7 @@ export default defineComponent({ default: null, }, }, + emits: ['timestamp-event'], data: function () { return { isLoading: false, diff --git a/src/renderer/components/watch-video-description/watch-video-description.js b/src/renderer/components/watch-video-description/watch-video-description.js index 7bab5214a..7d96f86e5 100644 --- a/src/renderer/components/watch-video-description/watch-video-description.js +++ b/src/renderer/components/watch-video-description/watch-video-description.js @@ -19,6 +19,7 @@ export default defineComponent({ default: '' } }, + emits: ['timestamp-event'], data: function () { return { shownDescription: '' diff --git a/src/renderer/components/watch-video-info/watch-video-info.js b/src/renderer/components/watch-video-info/watch-video-info.js index 7bc33dc09..506998459 100644 --- a/src/renderer/components/watch-video-info/watch-video-info.js +++ b/src/renderer/components/watch-video-info/watch-video-info.js @@ -104,6 +104,7 @@ export default defineComponent({ required: true } }, + emits: ['set-info-area-sticky', 'scroll-to-info-area', 'pause-player'], computed: { hideSharingActions: function() { return this.$store.getters.getHideSharingActions diff --git a/src/renderer/helpers/utils.js b/src/renderer/helpers/utils.js index 1a0850e07..186813446 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/i18n/index.js b/src/renderer/i18n/index.js index 454c439aa..fc05ceed7 100644 --- a/src/renderer/i18n/index.js +++ b/src/renderer/i18n/index.js @@ -36,14 +36,14 @@ export async function loadLocale(locale) { // locales are only compressed in our production Electron builds if (process.env.IS_ELECTRON && process.env.NODE_ENV !== 'development') { - const { readFile } = require('fs/promises') const { promisify } = require('util') const { brotliDecompress } = require('zlib') const brotliDecompressAsync = promisify(brotliDecompress) try { // decompress brotli compressed json file and then load it - // eslint-disable-next-line n/no-path-concat - const compressed = await readFile(`${__dirname}/static/locales/${locale}.json.br`) + const url = createWebURL(`/static/locales/${locale}.json.br`) + const compressed = await (await fetch(url)).arrayBuffer() + const decompressed = await brotliDecompressAsync(compressed) const data = JSON.parse(decompressed.toString()) i18n.setLocaleMessage(locale, data) diff --git a/src/renderer/store/modules/invidious.js b/src/renderer/store/modules/invidious.js index 90cdc1f90..619ced606 100644 --- a/src/renderer/store/modules/invidious.js +++ b/src/renderer/store/modules/invidious.js @@ -1,4 +1,3 @@ -import fs from 'fs/promises' import { createWebURL, fetchWithTimeout } from '../../helpers/utils' const state = { @@ -42,14 +41,11 @@ const actions = { // If the invidious instance fetch isn't returning anything interpretable if (instances.length === 0) { - // Fallback: read from static file - const fileName = 'invidious-instances.json' - /* eslint-disable-next-line n/no-path-concat */ - const fileLocation = process.env.NODE_ENV === 'development' ? './static/' : `${__dirname}/static/` - const filePath = `${fileLocation}${fileName}` console.warn('reading static file for invidious instances') - const fileData = process.env.IS_ELECTRON ? await fs.readFile(filePath, 'utf8') : await (await fetch(createWebURL(filePath))).text() - instances = JSON.parse(fileData).filter(e => { + const url = createWebURL('/static/invidious-instances.json') + + const fileData = await (await fetch(url)).json() + instances = fileData.filter(e => { return process.env.SUPPORTS_LOCAL_API || e.cors }).map(e => { return e.url diff --git a/src/renderer/store/modules/subscriptions.js b/src/renderer/store/modules/subscriptions.js index 2ce05c7b6..f98f7881b 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 c99290f20..818439586 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] } } @@ -354,11 +384,10 @@ const actions = { async getRegionData ({ commit }, { locale }) { const localePathExists = process.env.GEOLOCATION_NAMES.includes(locale) - // Exclude __dirname from path if not in electron - const fileLocation = `${process.env.IS_ELECTRON ? process.env.NODE_ENV === 'development' ? '.' : __dirname : ''}/static/geolocations/` - const pathName = `${fileLocation}${localePathExists ? locale : 'en-US'}.json` - const countries = process.env.IS_ELECTRON ? JSON.parse(await fs.readFile(pathName)) : await (await fetch(createWebURL(pathName))).json() + const url = createWebURL(`/static/geolocations/${localePathExists ? locale : 'en-US'}.json`) + + const countries = await (await fetch(url)).json() const regionNames = countries.map((entry) => { return entry.name }) const regionValues = countries.map((entry) => { return entry.code }) @@ -590,16 +619,9 @@ const actions = { commit('setSessionSearchHistory', []) }, - async getExternalPlayerCmdArgumentsData ({ commit }, payload) { - const fileName = 'external-player-map.json' - /* eslint-disable-next-line n/no-path-concat */ - const fileLocation = process.env.NODE_ENV === 'development' ? './static/' : `${__dirname}/static/` - - const fileData = await fs.readFile(`${fileLocation}${fileName}`) - - const externalPlayerMap = JSON.parse(fileData).map((entry) => { - return { name: entry.name, value: entry.value, cmdArguments: entry.cmdArguments } - }) + async getExternalPlayerCmdArgumentsData ({ commit }) { + const url = createWebURL('/static/external-player-map.json') + const externalPlayerMap = await (await fetch(url)).json() // Sort external players alphabetically & case-insensitive, keep default entry at the top const playerNone = externalPlayerMap.shift() externalPlayerMap.sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: 'base' })) @@ -732,6 +754,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 +862,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 05f87ad79..b6f8ffc9d 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 a85e3eb3d..9d71b12d3 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 ea86d813e..c3f001f34 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 5425c5e25..2a0371e88 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 754700a49..99eb85f20 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' import { getPipedTrending } from '../../helpers/api/piped' @@ -18,7 +19,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 { @@ -39,6 +41,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() }, @@ -99,6 +104,8 @@ export default defineComponent({ } else { this.getTrendingInfoLocal() } + + this.setLastTrendingRefreshTimestamp(new Date()) }, getTrendingInfoLocal: async function () { @@ -252,6 +259,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 98db507ec..f5a18ab6a 100644 --- a/src/renderer/views/Trending/Trending.vue +++ b/src/renderer/views/Trending/Trending.vue @@ -86,12 +86,10 @@ :data="shownResults" /> - diff --git a/static/locales/ar.yaml b/static/locales/ar.yaml index 8dc39cee0..7d3f4dff9 100644 --- a/static/locales/ar.yaml +++ b/static/locales/ar.yaml @@ -265,6 +265,9 @@ Settings: Ask Before Opening Link: اسأل قبل فتح الرابط Open Link: افتح الرابط External Link Handling: معالجة الارتباط الخارجي + Auto Load Next Page: + Label: تحميل تلقائي للصفحة التالية + Tooltip: قم بتحميل الصفحات والتعليقات الإضافية تلقائيًا. Theme Settings: Theme Settings: 'إعدادات السِمة' Match Top Bar with Main Color: 'طابق الشريط العلوي مع اللون الأساسي' @@ -950,6 +953,15 @@ Playlist: #* Published #& Views Playlist: قائمة التشغيل + Sort By: + Sort By: ترتيب حسب + DateAddedNewest: آخر مضاف أولا + DateAddedOldest: الأقدم إضافتا أولا + AuthorAscending: الكاتب (A-Z) + AuthorDescending: الكاتب (Z-A) + VideoTitleAscending: العنوان (A-Z) + VideoTitleDescending: العنوان (Z-A) + Custom: مُخصّص Toggle Theatre Mode: 'تمكين وضع المسرح' Change Format: Change Media Formats: 'تغيير تنسيقات الفيديو' diff --git a/static/locales/en-US.yaml b/static/locales/en-US.yaml index 28558180c..22dff17ea 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 @@ -1071,6 +1073,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 diff --git a/static/locales/es.yaml b/static/locales/es.yaml index d8edaf1da..4ddd2884d 100644 --- a/static/locales/es.yaml +++ b/static/locales/es.yaml @@ -122,7 +122,7 @@ User Playlists: a la lista de reproducción «Favoritos». Search bar placeholder: Buscar listas de reproducción Empty Search Message: No hay vídeos en esta lista de reproducción que coincidan - con tu búsqueda + con su búsqueda Are you sure you want to remove all watched videos from this playlist? This cannot be undone: ¿Estás seguro de que quieres eliminar todos los vídeos vistos de esta lista de reproducción? Esto no se puede deshacer. @@ -976,6 +976,15 @@ Playlist: #* Published #& Views Playlist: Lista de reproducción + Sort By: + Custom: Personalizado + DateAddedNewest: Último añadido primero + DateAddedOldest: Primeros añadidos primero + AuthorAscending: Autor (A-Z) + AuthorDescending: Autor (Z-A) + VideoTitleAscending: Título (A-Z) + Sort By: Ordenar por + VideoTitleDescending: Título (Z-A) Toggle Theatre Mode: 'Activar modo cine' Change Format: Change Media Formats: 'Cambiar formato de vídeo' @@ -1215,3 +1224,7 @@ Age Restricted: This video is age restricted: Este vídeo está restringido por edad checkmark: ✓ Display Label: '{label}: {value}' +Feed: + Feed Last Updated: '{feedName} feed actualizado por última vez: {date}' + Refresh Feed: Actualizar {subscriptionName} +Moments Ago: hace unos instantes diff --git a/static/locales/hr.yaml b/static/locales/hr.yaml index efc568729..a3557c353 100644 --- a/static/locales/hr.yaml +++ b/static/locales/hr.yaml @@ -266,6 +266,9 @@ Settings: Ask Before Opening Link: Pitaj prije otvaranja poveznice Open Link: Otvori poveznicu External Link Handling: Rukovanje vanjskim poveznicama + Auto Load Next Page: + Label: Automatski učitaj sljedeću stranicu + Tooltip: Automatski učitaj dodatne stranice i komentare. Theme Settings: Theme Settings: 'Postavke teme' Match Top Bar with Main Color: 'Koristi glavnu boju u gornjoj traci' @@ -961,6 +964,8 @@ Playlist: #* Published #& Views Playlist: Zbirka + Sort By: + Sort By: Redoslijed Toggle Theatre Mode: 'Uključi/isključi kazališni modus' Change Format: Change Media Formats: 'Promijeni videoformate' diff --git a/static/locales/it.yaml b/static/locales/it.yaml index 75c4b305e..749b89bcd 100644 --- a/static/locales/it.yaml +++ b/static/locales/it.yaml @@ -1227,3 +1227,7 @@ Age Restricted: Close Banner: Chiudi banner checkmark: ✓ Display Label: '{label}: {value}' +Moments Ago: pochi istanti fa +Feed: + Feed Last Updated: 'Ultimo aggiornamento del feed {feedName}: {date}' + Refresh Feed: Aggiorna {subscriptionName} diff --git a/static/locales/pt-BR.yaml b/static/locales/pt-BR.yaml index 415e076b5..a656f9168 100644 --- a/static/locales/pt-BR.yaml +++ b/static/locales/pt-BR.yaml @@ -218,7 +218,7 @@ User Playlists: History: # On History Page History: 'Histórico' - Watch History: 'Histórico de visualizações' + Watch History: 'Histórico de exibição' Your history list is currently empty.: 'Seu histórico está vazio no momento.' Search bar placeholder: Pesquisar no histórico Empty Search Message: Não há vídeos em seu histórico que correspondam à sua pesquisa @@ -436,15 +436,15 @@ Settings: #& No Privacy Settings: - Watch history has been cleared: Histórico de visualizações foi apagado + Watch history has been cleared: O histórico de exibição foi apagado Are you sure you want to remove your entire watch history?: Tem certeza de que - deseja remover todo o seu histórico de visualizações? - Remove Watch History: Remover histórico + deseja remover todo o seu histórico de exibição? + Remove Watch History: Remover histórico de exibição Search cache has been cleared: Cache de pesquisas foi apagado Are you sure you want to clear out your search cache?: Tem certeza de que deseja limpar o cache de pesquisa? Clear Search Cache: Limpar cache de buscas - Save Watched Progress: Salvar progresso de visualização + Save Watched Progress: Habilitar o histórico de exibição Remember History: Lembrar histórico Privacy Settings: Configurações de privacidade Are you sure you want to remove all subscriptions and profiles? This cannot be undone.: Tem @@ -483,13 +483,13 @@ Settings: Unknown data key: Chave de dados desconhecida Unable to write file: O arquivo não pôde ser salvo Unable to read file: O arquivo não pôde ser lido - All watched history has been successfully exported: Todo o histórico de visualizações - foi exportado com sucesso - All watched history has been successfully imported: Todo o histórico de visualizações - foi importado com sucesso + All watched history has been successfully exported: O histórico de exibição foi + exportado com sucesso + All watched history has been successfully imported: O histórico de exibição foi + importado com sucesso History object has insufficient data, skipping item: O histórico tem dados insuficientes, pulando item - This might take a while, please wait: Este processo pode demorar, por favor espere + This might take a while, please wait: Este processo pode demorar, por favor aguarde Invalid subscriptions file: Arquivo de inscrições inválido All subscriptions and profiles have been successfully imported: Todas as inscrições e perfis foram importados com sucesso @@ -1221,3 +1221,7 @@ Age Restricted: This video is age restricted: Este vídeo tem restrição de idade checkmark: ✓ Display Label: '{label}: {value}' +Feed: + Feed Last Updated: '{feedName} última atualização do feed: {date}' + Refresh Feed: Atualizar {subscriptionName} +Moments Ago: momentos atrás
+ {{ $t('Feed.Feed Last Updated', { feedName: title, date: lastRefreshTimestamp }) }} +