Merge branch 'development' into piped-support

This commit is contained in:
ChunkyProgrammer 2024-04-17 21:39:45 -04:00
commit ff715e9c88
67 changed files with 544 additions and 230 deletions

View File

@ -112,6 +112,8 @@ module.exports = {
ignoreText: ['-', '•', '/', 'YouTube', 'Invidious', 'FreeTube']
}
],
'vue/require-explicit-emits': 'error',
'vue/no-unused-emit-declarations': 'error',
},
settings: {
'vue-i18n': {

View File

@ -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

View File

@ -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)

View File

@ -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

View File

@ -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

View File

@ -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"
]
}
}
}

View File

@ -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

View File

@ -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

View File

@ -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'

View File

@ -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

View File

@ -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 })

View File

@ -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'

View File

@ -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 {

View File

@ -287,10 +287,7 @@ export default defineComponent({
},
checkExternalPlayer: async function () {
const payload = {
externalPlayer: this.externalPlayer
}
this.getExternalPlayerCmdArgumentsData(payload)
this.getExternalPlayerCmdArgumentsData()
},
handleUpdateBannerClick: function (response) {

View File

@ -2,6 +2,7 @@ import { defineComponent } from 'vue'
export default defineComponent({
name: 'FtAutoLoadNextPageWrapper',
emits: ['load-next-page'],
computed: {
generalAutoLoadMorePaginatedItemsEnabled() {
return this.$store.getters.getGeneralAutoLoadMorePaginatedItemsEnabled

View File

@ -20,6 +20,7 @@ export default defineComponent({
default: false
}
},
emits: ['click'],
data: function () {
return {
selected: false

View File

@ -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
}

View File

@ -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%);

View File

@ -7,7 +7,8 @@
:icon="icon"
:class="{
[theme]: true,
shadow: useShadow
shadow: useShadow,
disabled
}"
:style="{
padding: padding + 'px',

View File

@ -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) {

View File

@ -64,6 +64,7 @@ export default defineComponent({
default: ''
}
},
emits: ['clear', 'click', 'input'],
data: function () {
let actionIcon = ['fas', 'search']
if (this.forceActionButtonIconName !== null) {

View File

@ -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)
}
}

View File

@ -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;
}
}

View File

@ -8,6 +8,7 @@ export default defineComponent({
required: true
}
},
emits: ['click'],
methods: {
handleClick: function (response) {
this.$emit('click', response)

View File

@ -24,7 +24,7 @@
tabindex="0"
:title="$t('Close Banner')"
@click.stop="handleClose"
@keydown.enter.stop.prevent="handleClose"
@keydown.enter.space.stop.prevent="handleClose"
/>
</div>
</template>

View File

@ -25,6 +25,7 @@ export default defineComponent({
required: true,
},
},
emits: ['selected'],
data: function () {
return {
title: '',

View File

@ -32,6 +32,7 @@ export default defineComponent({
required: true
}
},
emits: ['new-profile-created', 'profile-deleted'],
data: function () {
return {
showDeletePrompt: false,

View File

@ -38,6 +38,7 @@ export default defineComponent({
default: false
}
},
emits: ['click'],
data: function () {
return {
promptButtons: [],

View File

@ -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%;
}
}

View File

@ -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
}
}
})

View File

@ -0,0 +1,27 @@
<template>
<div
class="floatingRefreshSection"
:class="{
sideNavOpen: isSideNavOpen
}"
>
<p
v-if="lastRefreshTimestamp"
class="lastRefreshTimestamp"
>
{{ $t('Feed.Feed Last Updated', { feedName: title, date: lastRefreshTimestamp }) }}
</p>
<ft-icon-button
:disabled="disableRefresh"
:icon="['fas', 'sync']"
class="refreshButton"
:title="$t('Feed.Refresh Feed', { subscriptionName: title })"
:size="12"
theme="primary"
@click="$emit('click')"
/>
</div>
</template>
<script src="./ft-refresh-widget.js" />
<style scoped src="./ft-refresh-widget.css" />

View File

@ -8,6 +8,7 @@ export default defineComponent({
'ft-flex-box': FtFlexBox,
'ft-radio-button': FtRadioButton
},
emits: ['filterValueUpdated'],
data: function () {
return {
sortByValues: [

View File

@ -8,6 +8,7 @@ export default defineComponent({
default: ''
}
},
emits: ['timestamp-event'],
methods: {
catchTimestampClick: function (event) {
this.$emit('timestamp-event', event.detail)

View File

@ -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,

View File

@ -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,

View File

@ -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([

View File

@ -6,6 +6,8 @@
:attempted-fetch="attemptedFetch"
:is-community="true"
:initial-data-limit="20"
:last-refresh-timestamp="lastCommunityRefreshTimestamp"
:title="$t('Global.Community')"
@refresh="loadPostsForSubscriptionsFromRemote"
/>
</template>

View File

@ -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([

View File

@ -4,6 +4,8 @@
:video-list="videoList"
:error-channels="errorChannels"
:attempted-fetch="attemptedFetch"
:last-refresh-timestamp="lastLiveRefreshTimestamp"
:title="$t('Global.Live')"
@refresh="loadVideosForSubscriptionsFromRemote"
/>
</template>

View File

@ -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([

View File

@ -4,6 +4,8 @@
:video-list="videoList"
:error-channels="errorChannels"
:attempted-fetch="attemptedFetch"
:last-refresh-timestamp="lastShortRefreshTimestamp"
:title="$t('Global.Shorts')"
@refresh="loadVideosForSubscriptionsFromRemote"
/>
</template>

View File

@ -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%;

View File

@ -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')

View File

@ -58,13 +58,10 @@
/>
</ft-flex-box>
</ft-auto-load-next-page-wrapper>
<ft-icon-button
v-if="!isLoading && activeSubscriptionList.length > 0"
:icon="['fas', 'sync']"
class="floatingTopButton"
:title="$t('Subscriptions.Refresh Subscriptions')"
:size="12"
theme="primary"
<ft-refresh-widget
:disable-refresh="isLoading || activeSubscriptionList.length === 0"
:last-refresh-timestamp="lastRefreshTimestamp"
:title="title"
@click="$emit('refresh')"
/>
</div>

View File

@ -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([

View File

@ -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"
/>
</template>

View File

@ -16,6 +16,7 @@ export default defineComponent({
required: true
}
},
emits: ['timestamp-event'],
data: function () {
return {
showChapters: false,

View File

@ -37,6 +37,7 @@ export default defineComponent({
default: null,
},
},
emits: ['timestamp-event'],
data: function () {
return {
isLoading: false,

View File

@ -19,6 +19,7 @@ export default defineComponent({
default: ''
}
},
emits: ['timestamp-event'],
data: function () {
return {
shownDescription: ''

View File

@ -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

View File

@ -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

View File

@ -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)

View File

@ -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

View File

@ -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) {

View File

@ -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,

View File

@ -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%;

View File

@ -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'
])
}
})

View File

@ -13,12 +13,10 @@
:data="shownResults"
/>
</ft-card>
<ft-icon-button
v-if="!isLoading"
:icon="['fas', 'sync']"
class="floatingTopButton"
:size="12"
theme="primary"
<ft-refresh-widget
:disable-refresh="isLoading"
:last-refresh-timestamp="lastPopularRefreshTimestamp"
:title="$t('Most Popular')"
@click="fetchPopularInfo"
/>
</div>

View File

@ -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%;

View File

@ -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'
])
}
})

View File

@ -86,12 +86,10 @@
:data="shownResults"
/>
</ft-card>
<ft-icon-button
v-if="!isLoading"
:icon="['fas', 'sync']"
class="floatingTopButton"
:size="12"
theme="primary"
<ft-refresh-widget
:disable-refresh="isLoading"
:last-refresh-timestamp="lastTrendingRefreshTimestamp"
:title="$t('Trending.Trending')"
@click="getTrendingInfo(true)"
/>
</div>

View File

@ -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: 'تغيير تنسيقات الفيديو'

View File

@ -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

View File

@ -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

View File

@ -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'

View File

@ -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}

View File

@ -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