diff --git a/package.json b/package.json
index ad90e73bb483f..05404b6115c76 100644
--- a/package.json
+++ b/package.json
@@ -2,7 +2,7 @@
"name": "freetube",
"productName": "FreeTube",
"description": "A private YouTube client",
- "version": "0.25.2",
+ "version": "0.25.3",
"license": "AGPL-3.0-or-later",
"main": "./dist/main.js",
"private": true,
@@ -71,7 +71,7 @@
"vue-observe-visibility": "^2.0.0-alpha.1",
"vue-router": "^5.1.0",
"vuex": "^4.1.0",
- "youtubei.js": "^17.2.0"
+ "youtubei.js": "^18.0.0"
},
"devDependencies": {
"@babel/core": "^8.0.1",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 2dc05053efcbe..6f297dcf35c8f 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -75,8 +75,8 @@ importers:
specifier: ^4.1.0
version: 4.1.0(vue@3.5.39(typescript@6.0.3))
youtubei.js:
- specifier: ^17.2.0
- version: 17.2.0
+ specifier: ^18.0.0
+ version: 18.0.0
devDependencies:
'@babel/core':
specifier: ^8.0.1
@@ -715,8 +715,8 @@ packages:
resolution: {integrity: sha512-K8ponJDxBwDHigkeFqaqT5wLGl4bTlwMafR8k7b5CPxr6Ww+UG9ls8Yx6Tcpboxu97eeGVEEyKcHmEyOwN1vSw==}
engines: {node: ^22.18.0 || >=24.11.0}
- '@bufbuild/protobuf@2.1.0':
- resolution: {integrity: sha512-+2Mx67Y3skJ4NCD/qNSdBJNWtu6x6Qr53jeNg+QcwiL6mt0wK+3jwHH2x1p7xaYH6Ve2JKOVn0OxU35WsmqI9A==}
+ '@bufbuild/protobuf@2.12.1':
+ resolution: {integrity: sha512-BvAMfS6LrgZiryOAZ4pBYucu4wG/Ei/9o9DZ9akbREnMLbPJiom2i8b9C8IsKErQoiKqVhrerzt3kOT/RrzLHg==}
'@cacheable/memory@2.2.0':
resolution: {integrity: sha512-CTLKqLItRCEixEAewD3/j9DB3/o96gpTPD4eJ1v+DGOlxZRZncRQkGYqqnAGCscYd6RNeXfGeiuCphsPtqyIfQ==}
@@ -3585,9 +3585,9 @@ packages:
resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==}
engines: {node: '>= 8'}
- meriyah@6.1.4:
- resolution: {integrity: sha512-Sz8FzjzI0kN13GK/6MVEsVzMZEPvOhnmmI1lU5+/1cGOiK3QUahntrNNtdVeihrO7t9JpoH75iMNXg6R6uWflQ==}
- engines: {node: '>=18.0.0'}
+ meriyah@7.3.1:
+ resolution: {integrity: sha512-642iQ3T0ZBXw+qrFo9m50CsdzSz9Tn0HtSKN3WxWlTXfcHUZwunxU4gMoMDIQzzOhwtkLOLeBs9GXs7NwDI2nA==}
+ engines: {node: '>=20.0.0'}
micromatch@4.0.8:
resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==}
@@ -5188,8 +5188,8 @@ packages:
resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
engines: {node: '>=10'}
- youtubei.js@17.2.0:
- resolution: {integrity: sha512-XLNsgRKO1h7t4i9tIMWSQSeWdD7Ujkk5v1m5YCaumaHMhu/xuLqtO3M0Hq7CXNup9HlJ1NGrT1Y+HLIHnL6Ujg==}
+ youtubei.js@18.0.0:
+ resolution: {integrity: sha512-7Tztl5QzNL4nIhiAxa4P/0C8cDDsUe0j4iaUeNiaA+ZfSe/l+HkHxXyoWlQXNW7IPi2n6AW23ysVMJLDh+AwWQ==}
snapshots:
@@ -5784,7 +5784,7 @@ snapshots:
'@babel/helper-string-parser': 8.0.0
'@babel/helper-validator-identifier': 8.0.2
- '@bufbuild/protobuf@2.1.0': {}
+ '@bufbuild/protobuf@2.12.1': {}
'@cacheable/memory@2.2.0':
dependencies:
@@ -8353,7 +8353,7 @@ snapshots:
googlevideo@4.0.4:
dependencies:
- '@bufbuild/protobuf': 2.1.0
+ '@bufbuild/protobuf': 2.12.1
gopd@1.2.0: {}
@@ -8900,7 +8900,7 @@ snapshots:
merge2@1.4.1: {}
- meriyah@6.1.4: {}
+ meriyah@7.3.1: {}
micromatch@4.0.8:
dependencies:
@@ -10533,8 +10533,8 @@ snapshots:
yocto-queue@0.1.0: {}
- youtubei.js@17.2.0:
+ youtubei.js@18.0.0:
dependencies:
- '@bufbuild/protobuf': 2.1.0
+ '@bufbuild/protobuf': 2.12.1
fflate: 0.8.3
- meriyah: 6.1.4
+ meriyah: 7.3.1
diff --git a/src/main/index.js b/src/main/index.js
index 716c281fdb06a..38b708bc486b9 100644
--- a/src/main/index.js
+++ b/src/main/index.js
@@ -590,7 +590,10 @@ function runApp() {
requestHeaders['Sec-Fetch-Site'] = 'same-origin'
requestHeaders['Sec-Fetch-Mode'] = 'same-origin'
requestHeaders['X-Youtube-Bootstrap-Logged-In'] = 'false'
- } else if (url.startsWith('https://www.youtube.com/watch')) {
+ } else if (
+ url.startsWith('https://www.youtube.com/watch') ||
+ (urlObj.origin === 'www.youtube.com' && urlObj.pathname === '/')
+ ) {
delete requestHeaders.Referer
delete requestHeaders.Origin
requestHeaders['Sec-Fetch-Dest'] = 'document'
diff --git a/src/renderer/components/CommentSection/CommentSection.css b/src/renderer/components/CommentSection/CommentSection.css
index 8dca3e6acd4f8..673add62bb423 100644
--- a/src/renderer/components/CommentSection/CommentSection.css
+++ b/src/renderer/components/CommentSection/CommentSection.css
@@ -34,14 +34,10 @@
@media only screen and (width <= 1000px) {
.commentSort {
float: none;
+ margin-block: 15px;
}
}
-
-.comment {
- padding: 15px;
-}
-
.hideComments {
font-size: 13px;
text-decoration: underline;
@@ -49,151 +45,6 @@
color: var(--title-color);
}
-.commentThumbnail {
- float: inline-start;
- inline-size: 60px;
- block-size: 60px;
- border-radius: 200px;
-}
-
-.commentThumbnailHidden {
- float: inline-start;
- display: flex;
- justify-content: center;
- align-items: center;
- inline-size: 60px;
- block-size: 60px;
- font-size: 20px;
- line-height: 1em;
- color: rgb(0 0 0);
- background-color: rgb(235 160 172);
- border-radius: 50%;
-}
-
-.commentAuthorWrapper {
- font-weight: bold;
- font-size: 14px;
- margin-inline-start: 68px;
- margin-block-start: 0;
- overflow-wrap: break-word;
-}
-
-.commentOwner {
- background-color: var(--secondary-card-bg-color);
- border-radius: 10px;
- padding-inline: 10px;
-}
-
-
-.commentAuthor {
- color: inherit;
- text-decoration: none;
-}
-
-.commentText {
- white-space: pre-wrap;
- font-size: 14px;
- /* stylelint-disable-next-line liberty/use-logical-spec */
- margin-left: -10px;
- margin-inline-start: 70px;
- overflow-wrap: break-word;
-}
-
-/* stylelint-disable liberty/use-logical-spec */
-:global(body[dir='rtl'] .commentText) {
- margin-left: initial;
- margin-right: -10px;
-}
-/* stylelint-enable liberty/use-logical-spec */
-
-.commentPinned {
- font-weight: normal;
- font-size: 12px;
- margin-block: 0 5px;
- margin-inline-start: 68px;
-}
-
-.commentDate {
- font-weight: normal;
- margin-inline-start: 5px;
- font-size: 12px;
-}
-
-.commentMemberIcon {
- margin-inline-start: 5px;
- inline-size: 14px;
- block-size: 14px;
-}
-
-.commentSubscribedIcon {
- content: var(--logo-icon);
- block-size: 14px;
- inline-size: 14px;
- vertical-align: middle;
- margin-inline: 0.25em;
-}
-
-.commentLikeCount {
- font-size: 11px;
- margin-inline-start: 70px;
- margin-block-start: 0;
-}
-
-.commentHeartBadge {
- display: inline-block;
- position: relative;
- inline-size: 25px;
- block-size: 20px;
- margin-inline-start: 10px;
- margin-block-end: -7px;
-}
-
-.commentHeartBadgeImg {
- position: absolute;
- inset-inline-start: 0;
- inline-size: 15px;
- block-size: 15px;
- border-radius: 50%;
-}
-
-.commentHeartBadgeWhite {
- position: absolute;
- inset-inline-start: 9px;
- inset-block-end: 1px;
- inline-size: 11px;
- block-size: 11px;
- z-index: 1;
-}
-
-.commentHeartBadgeRed {
- position: absolute;
- color: var(--red-500);
- inset-inline-start: 10px;
- inset-block-end: 2px;
- inline-size: 9px;
- block-size: 9px;
- z-index: 2;
-}
-
-.commentMoreReplies {
- font-size: 11px;
- margin-inline-start: 5px;
- text-decoration: underline;
- cursor: pointer;
- color: var(--title-color);
-}
-
-.commentReplies {
- margin-inline-start: 30px;
-}
-
-.showMoreReplies {
- margin-inline-start: 30px;
- font-size: 15px;
- cursor: pointer;
- text-decoration: underline;
-}
-
.getMoreComments {
padding-block-end: 1em;
text-align: center;
diff --git a/src/renderer/components/CommentSection/CommentSection.vue b/src/renderer/components/CommentSection/CommentSection.vue
index 51a3bf1d83db2..55444796c6b16 100644
--- a/src/renderer/components/CommentSection/CommentSection.vue
+++ b/src/renderer/components/CommentSection/CommentSection.vue
@@ -53,237 +53,18 @@
-
+ :comment="comment"
+ :channel-name="channelName"
+ :channel-thumbnail="channelThumbnail"
+ :autoload-this-reply-level="false"
+ :can-fallback-to-invidious="canFallbackToInvidious"
+ :get-invidious-comment-replies="getInvidiousCommentReplies"
+ @timestamp-event="onTimestamp"
+ />
+
+
diff --git a/src/renderer/components/ft-shaka-video-player/ft-shaka-video-player.js b/src/renderer/components/ft-shaka-video-player/ft-shaka-video-player.js
index 799ef9600eb00..f7bb8929f26c6 100644
--- a/src/renderer/components/ft-shaka-video-player/ft-shaka-video-player.js
+++ b/src/renderer/components/ft-shaka-video-player/ft-shaka-video-player.js
@@ -1518,6 +1518,7 @@ export default defineComponent({
/** @type {object[]} */
const legacyFormats = props.legacyFormats
+ if (legacyFormats.length === 0) { return }
const isPortrait = legacyFormats[0].height > legacyFormats[0].width
@@ -2830,7 +2831,7 @@ export default defineComponent({
forceAspectRatio.value = firstVariant.width / firstVariant.height < 1.5
}
})
- } else {
+ } else if (props.legacyFormats.length > 0) {
// force the player aspect ratio to 16:9 to avoid overflowing the layout, when the video is too tall
const firstFormat = props.legacyFormats[0]
diff --git a/src/renderer/components/ft-shaka-video-player/player-components/LegacyQualitySelection.js b/src/renderer/components/ft-shaka-video-player/player-components/LegacyQualitySelection.js
index b5a286c8a129b..70179771be2ae 100644
--- a/src/renderer/components/ft-shaka-video-player/player-components/LegacyQualitySelection.js
+++ b/src/renderer/components/ft-shaka-video-player/player-components/LegacyQualitySelection.js
@@ -31,8 +31,10 @@ export class LegacyQualitySelection extends shaka.ui.SettingsMenu {
const sortedLegacyFormats = [...legacyFormats]
- const isPortrait = legacyFormats[0].height > legacyFormats[0].width
- sortedLegacyFormats.sort((a, b) => isPortrait ? b.width - a.width : b.height - a.height)
+ if (legacyFormats.length > 0) {
+ const isPortrait = legacyFormats[0].height > legacyFormats[0].width
+ sortedLegacyFormats.sort((a, b) => isPortrait ? b.width - a.width : b.height - a.height)
+ }
/** @private */
this.legacyFormats_ = sortedLegacyFormats
diff --git a/src/renderer/helpers/api/invidious.js b/src/renderer/helpers/api/invidious.js
index b4c6a0b79567f..8d57c3062a96f 100644
--- a/src/renderer/helpers/api/invidious.js
+++ b/src/renderer/helpers/api/invidious.js
@@ -429,26 +429,47 @@ export async function invidiousGetVideoInformation(videoId) {
* The complete Triforce, or one or more components of the Triforce.
* @typedef {object} InvidiousComment
* @property {string} id
- * @property {string} authorLink
- * @property {string} authorThumb
+ * @property {'invidious'} dataType
* @property {string} author
+ * @property {string} authorId
+ * @property {string} authorThumb
* @property {number} likes
* @property {string} text
- * @property {string} dataType
+ * @property {string} time
+ * @property {boolean} isHearted
+ * @property {boolean} isMember
* @property {boolean} isOwner
* @property {boolean} isPinned
- * @property {number} numReplies
+ * @property {false} hasOwnerReplied
* @property {boolean} hasReplyToken
* @property {string} replyToken
- * @property {boolean} showReplies
- * @property {InvidiousComment[]} replies
- * @property {boolean} isHearted
- * @property {boolean} isMember
+ * @property {0} replyLevel
* @property {string} memberIconUrl
- * @property {string} time
+ * @property {number} numReplies
+ */
+/**
+ * See: https://docs.invidious.io/api/#get-apiv1commentsid
+ * @typedef {{
+ * commentCount: number,
+ * videoId: string,
+ * continuation: string?,
+ * comments: {
+ * authorId: string,
+ * author: string,
+ * authorThumbnail: string,
+ * authorIsChannelOwner: boolean,
+ * isSponsor: boolean,
+ * sponsorIconUrl?: string,
+ * likeCount: number,
+ * creatorHeart?: object,
+ * isPinned: boolean,
+ * commentId: string,
+ * contentHtml: string,
+ * published: number,
+ * replies?: {replyCount: number, continuation: string}
+ * }[]
+ * }} InvidiousCommentResponse
*/
-/** @typedef {{commentCount: number, videoId: string, continuation: string?, comments: InvidiousComment[]}} InvidiousCommentResponse */
-
export async function invidiousGetComments({ id, nextPageToken = '', sortNewest = true }) {
const payload = {
resource: 'comments',
@@ -577,14 +598,12 @@ export async function getInvidiousSearchResults(query, page, searchSettings) {
}
/**
- * @param {string} url
+ * @param {string | undefined | null} url
* @param {string?} currentInstance
- * @returns {string?}
+ * @returns {string}
*/
export function youtubeImageUrlToInvidious(url, currentInstance = null) {
- if (url == null) {
- return null
- }
+ if (!url) return ''
if (currentInstance === null) {
currentInstance = getCurrentInstanceUrl()
@@ -600,34 +619,35 @@ export function youtubeImageUrlToInvidious(url, currentInstance = null) {
}
/**
- * @param {string} url
+ * @param {string | undefined | null} url
* @param {string?} currentInstance
* @returns {string}
*/
export function invidiousImageUrlToInvidious(url, currentInstance = null) {
- return url.replaceAll('/ggpht/', `${currentInstance}/ggpht/`)
+ return url?.replaceAll('/ggpht/', `${currentInstance}/ggpht/`) ?? ''
}
/**
* @param {InvidiousCommentResponse} response
+ * @returns {InvidiousComment[]}
*/
function parseInvidiousCommentData(response) {
return response.comments.map((comment) => {
return {
id: comment.commentId,
- authorLink: comment.authorId,
- authorThumb: youtubeImageUrlToInvidious(comment.authorThumbnails.at(-1).url),
+ dataType: 'invidious',
+ authorId: comment.authorId,
+ authorThumb: youtubeImageUrlToInvidious(comment.authorThumbnail),
author: comment.author,
likes: comment.likeCount,
text: autolinker.link(invidiousImageUrlToInvidious(comment.contentHtml, getCurrentInstanceUrl())),
- dataType: 'invidious',
isOwner: comment.authorIsChannelOwner,
isPinned: comment.isPinned,
numReplies: comment.replies?.replyCount ?? 0,
+ hasOwnerReplied: false,
hasReplyToken: !!comment.replies?.continuation,
replyToken: comment.replies?.continuation ?? '',
- showReplies: false,
- replies: [],
+ replyLevel: 0,
isHearted: comment.creatorHeart !== undefined,
isMember: comment.isSponsor,
memberIconUrl: youtubeImageUrlToInvidious(comment.sponsorIconUrl),
diff --git a/src/renderer/helpers/api/local.js b/src/renderer/helpers/api/local.js
index 4aba2028d7cd6..e5d0fe77e49f9 100644
--- a/src/renderer/helpers/api/local.js
+++ b/src/renderer/helpers/api/local.js
@@ -379,12 +379,12 @@ export async function getLocalSearchContinuation(continuationData) {
}
/**
- * @param {string} videoId
- * @param {(input, init) => Promise} fetchFunc
+ * @param {string} url
+ * @param {string} kind
*/
-async function getWatchHTMLWatchPage(videoId, fetchFunc) {
+async function getHTMLPage(url, kind) {
// This returns session/tracking cookies but they get removed in onHeadersReceived in the main process before they are saved by Electron
- const htmlResponse = await fetch(`https://www.youtube.com/watch?v=${videoId}&bpctr=9999999999&has_verified=1`,
+ const htmlResponse = await fetch(url,
{
headers: {
// We need to be able to parse the localised strings in the /next response data (e.g. view counts and published dates)
@@ -398,7 +398,7 @@ async function getWatchHTMLWatchPage(videoId, fetchFunc) {
const ytConfigStr = htmlPage.match(/ytcfg\.set\(({.+?})\);/s)?.[1]
if (!ytConfigStr) {
// required for botguard
- throw new Error('Could not find ytcfg in the HTML page')
+ throw new Error(`Could not find ytcfg in the ${kind} HTML page`)
}
const ytConfig = JSON.parse(ytConfigStr)
@@ -407,7 +407,7 @@ async function getWatchHTMLWatchPage(videoId, fetchFunc) {
if (!initialAttestationDataMatch) {
// required for botguard
- throw new Error('Could not find challenge in the HTML page')
+ throw new Error(`Could not find challenge in the ${kind} HTML page`)
}
let initialAttestationData
@@ -415,42 +415,74 @@ async function getWatchHTMLWatchPage(videoId, fetchFunc) {
try {
initialAttestationData = parseLooseJSON(initialAttestationDataMatch[1])
} catch (e) {
- const error = new Error('Failed to parse the initial attestation data', { cause: e })
+ const error = new Error(`Failed to parse the ${kind} home page initial attestation data`, { cause: e })
console.error(error, initialAttestationDataMatch[1])
throw error
}
- /** @type {string | undefined} */
let playerId = ytConfig.PLAYER_JS_URL?.match(/player\/([^/]+)\//)?.[1]
playerId ??= htmlPage.match(/
-
- {{ $t("Comments.Pinned by") }} {{ channelName }}
-
--
- {{ comment.author }}
-
-
-
-
- {{ comment.time }}
-
-
-- -
- {{ comment.likes }}
-
-
-
-
-
-
-
-
- {{ toggleCommentRepliesLinkText(comment) }}
-
-
-
--
- {{ reply.author }}
-
-
-
-
- {{ reply.time }}
-
-
-- -
- {{ reply.likes }}
-
-
-
-
-
-
-
-- {{ $t('Comments.View {replyCount} replies', { replyCount: reply.numReplies }, reply.numReplies) }} -
-