Merge pull request #1405 from Materialious/update/1.14.1

Update/1.14.1
This commit is contained in:
Ward
2026-02-12 19:27:43 +13:00
committed by GitHub
20 changed files with 103 additions and 113 deletions
+2 -2
View File
@@ -7,8 +7,8 @@ android {
applicationId "us.materialio.app"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 204
versionName "1.14.0"
versionCode 205
versionName "1.14.1"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
@@ -3,7 +3,7 @@
<id type="desktop-application">us.materialio.Materialious</id>
<metadata_license>CC0-1.0</metadata_license>
<name>Materialious</name>
<summary>Materialious is a privacy respecting frontend for YouTube built ontop of Invidious.</summary>
<summary>Materialious is a modern material design frontend for YouTube & Invidious, focused on a clean, privacy-friendly YouTube experience.</summary>
<description>
<p>Materialious is a client for Invidious using material design. It lets you browse and watch videos, manage playlists and visit channels.</p>
<p>Features:</p>
@@ -48,7 +48,7 @@
</screenshot>
<screenshot>
<caption>Watching a video</caption>
<image>https://raw.githubusercontent.com/Materialious/Materialious/4baa7b897a46d4e71aaca7d322e4f7dafc870a33/previews/player-preview.png</image>
<image>https://raw.githubusercontent.com/Materialious/Materialious/43e6eb78edae030ffd372af5b9a8dcf92564be46/previews/player-preview.png</image>
</screenshot>
<screenshot>
<caption>Modifying settings</caption>
@@ -69,41 +69,10 @@
<content_attribute id="social-contacts">intense</content_attribute>
</content_rating>
<releases>
<release version="1.14.0" date="2026-2-11">
<release version="1.14.1" date="2026-2-12">
<url>https://github.com/Materialious/Materialious/releases/tag/1.14.1</url>
</release>
<release version="1.14.0" date="2026-2-11">
<url>https://github.com/Materialious/Materialious/releases/tag/1.14.0</url>
</release>
<release version="1.13.19" date="2026-2-11">
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "Materialious",
"version": "1.14.0",
"version": "1.14.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "Materialious",
"version": "1.14.0",
"version": "1.14.1",
"license": "MIT",
"dependencies": {
"@capacitor-community/electron": "^5.0.0",
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "Materialious",
"version": "1.14.0",
"version": "1.14.1",
"description": "Modern material design for YouTube and Invidious.",
"author": {
"name": "Ward Pearce",
@@ -45,4 +45,4 @@
"capacitor",
"electron"
]
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "materialious",
"version": "1.14.0",
"version": "1.14.1",
"private": true,
"scripts": {
"dev": "npm run patch:github && vite dev",
+1 -1
View File
@@ -252,7 +252,7 @@ export async function getFeed(
fetchOptions: RequestInit = {}
): Promise<Feed> {
if (isYTBackend()) {
return getFeedYTjs(maxResults, page);
return getFeedYTjs();
}
const path = buildPath('auth/feed');
@@ -128,9 +128,7 @@ export async function parseChannelRSS(channelId: string): Promise<void> {
}
}
// Ignored to match non ytjs version of getfeed.
// eslint-disable-next-line @typescript-eslint/no-unused-vars
export async function getFeedYTjs(maxResults: number, page: number): Promise<Feed> {
export async function getFeedYTjs(): Promise<Feed> {
const channelSubscriptions = await localDb.channelSubscriptions.toArray();
const toUpdatePromises: Promise<void>[] = [];
+20 -23
View File
@@ -15,7 +15,6 @@ import { get } from 'svelte/store';
import type { Types } from 'youtubei.js';
import { Utils, YT, YTNodes, Platform } from 'youtubei.js';
import { getInnertube } from '.';
import { cleanNumber, extractNumber } from '$lib/numbers';
Platform.shim.eval = async (
data: Types.BuildScriptResult,
@@ -174,33 +173,31 @@ export async function getVideoYTjs(videoId: string): Promise<VideoPlay> {
)
return;
const durationOverlay = recommended.content_image.overlays
?.find(
(overlay) =>
overlay.is(YTNodes.ThumbnailOverlayBadgeView) &&
overlay.position === 'THUMBNAIL_OVERLAY_BADGE_POSITION_BOTTOM_END'
)
?.as(YTNodes.ThumbnailOverlayBadgeView);
let lengthSeconds: number = 0;
recommended.content_image.overlays.forEach((overlay) => {
if (overlay.is(YTNodes.ThumbnailBottomOverlayView)) {
overlay.badges.forEach((badge) => {
if (
badge.is(YTNodes.ThumbnailBadgeView) &&
badge.badge_style === 'THUMBNAIL_OVERLAY_BADGE_STYLE_DEFAULT'
) {
lengthSeconds = convertToSeconds(badge.text);
}
});
}
});
const viewCountText = (
recommended.metadata.metadata.metadata_rows[1]?.metadata_parts?.[0]?.text?.text ?? ''
).split(' ')[0];
recommendedVideos.push({
videoThumbnails: (recommended?.content_image.image as Thumbnail[]) || [],
videoId: recommended.content_id,
title: recommended.metadata.title.toString(),
viewCountText: cleanNumber(
extractNumber(
(
recommended.metadata.metadata.metadata_rows[1]?.metadata_parts?.[0]?.text ?? ''
).toString()
)
),
author:
(
recommended.metadata.metadata.metadata_rows[0]?.metadata_parts?.[0]?.text ?? ''
).toString() || '',
lengthSeconds: durationOverlay?.badges[0].text
? convertToSeconds(durationOverlay?.badges[0].text)
: 0,
viewCountText,
author: recommended.metadata.metadata.metadata_rows[0]?.metadata_parts?.[0]?.text?.text ?? '',
lengthSeconds,
authorId:
recommended.metadata?.image?.renderer_context?.command_context?.on_tap?.payload?.browseId ||
'',
@@ -2,28 +2,17 @@
import Thumbnail from '$lib/components/Thumbnail.svelte';
import { _ } from '$lib/i18n';
import { removePlaylistVideo } from '../api';
import type {
Channel,
HashTag,
Playlist,
PlaylistPage,
PlaylistPageVideo,
Video,
VideoBase
} from '../api/model';
import { authStore, feedLastItemId, isAndroidTvStore } from '../store';
import ContentColumn from './ContentColumn.svelte';
import { onMount, onDestroy, tick } from 'svelte';
import Mousetrap from 'mousetrap';
import { extractUniqueId } from '$lib/misc';
import { extractUniqueId, type feedItems } from '$lib/misc';
import ChannelThumbnail from './ChannelThumbnail.svelte';
import PlaylistThumbnail from './PlaylistThumbnail.svelte';
import HashtagThumbnail from './HashtagThumbnail.svelte';
interface Props {
items?:
| (VideoBase | Video | PlaylistPageVideo | Channel | Playlist | HashTag)[]
| PlaylistPage[];
items?: feedItems;
playlistId?: string;
playlistAuthor?: string;
classes?: string;
@@ -2,7 +2,7 @@
import { page } from '$app/stores';
import { getBestThumbnail, ImageCache } from '$lib/images';
import { videoLength } from '$lib/numbers';
import { generateChapterWebVTT, type PhasedDescription, type Timestamp } from '$lib/description';
import { generateChapterWebVTT, type ParsedDescription, type Timestamp } from '$lib/description';
import { SystemBars, SystemBarsStyle, SystemBarType } from '@capacitor/core';
import { Capacitor } from '@capacitor/core';
import { ScreenOrientation, type ScreenOrientationResult } from '@capacitor/screen-orientation';
@@ -67,7 +67,7 @@
} from '$lib/timelineThumbnails';
interface Props {
data: { video: VideoPlay; content: PhasedDescription; playlistId: string | null };
data: { video: VideoPlay; content: ParsedDescription; playlistId: string | null };
currentTime?: number;
userManualSeeking?: boolean;
isEmbed?: boolean;
@@ -8,7 +8,7 @@
import { get } from 'svelte/store';
import { getDeArrow, getThumbnail } from '../api';
import type { Notification, PlaylistPageVideo, Video, VideoBase } from '../api/model';
import { createVideoUrl, insecureRequestImageHandler } from '../misc';
import { createVideoUrl, insecureRequestImageHandler, isYTBackend } from '../misc';
import type { PlayerEvents } from '../player';
import {
authStore,
@@ -248,10 +248,9 @@
{#if 'published' in video}
<div class="max">
{video.viewCountText ?? cleanNumber(video.viewCount ?? 0)}{relativeTimestamp(
video.published,
false
)}
{video.viewCountText ?? cleanNumber(video.viewCount ?? 0)}
{isYTBackend() ? relativeTimestamp(video.published, false) : video.publishedText}
</div>
{/if}
</div>
@@ -270,10 +269,10 @@
width: 100%;
height: 100%;
object-fit: cover;
clip-path: inset(30px 0 30px 0);
clip-path: inset(10% 0 10% 0);
display: block;
transform: translateY(-30px);
margin-bottom: -60px;
transform: translateY(-15%);
margin-bottom: -20%;
}
.thumbnail {
@@ -133,8 +133,10 @@
{#each tabs as tab, index (tab)}
<a
class:active={isActive(tab.id)}
class:surface-container-lowest={isActive(tab.id)}
class:surface-container-highest={!isActive(tab.id)}
aria-selected={isActive(tab.id)}
class="button surface-container-highest"
class="button"
id={`tab-${tab.id}`}
aria-controls={`panel-${tab.id}`}
tabindex={isActive(tab.id) ? 0 : -1}
@@ -9,6 +9,7 @@
import CommentSelf from './Comment.svelte';
import { insecureRequestImageHandler, truncate } from '$lib/misc';
import { _ } from '$lib/i18n';
import { extractActualLink } from '$lib/description';
interface Props {
comment: Comment;
@@ -36,15 +37,38 @@
}
}
}
function parseComment(html: string): string {
const parser = new DOMParser();
const doc = parser.parseFromString(html, 'text/html');
function commentTimestamps(html: string): string {
const regex =
/<a href="([^"]+)" data-onclick="jump_to_time" data-jump-time="(\d+)">([^<]+)<\/a>\s*(.+)/g;
const replacement = `<a href=${resolve(`/watch/[videoId]?time=$2`, { videoId: videoId })} data-sveltekit-preload-data="off" class="link">$3 $4</a>`;
const links = doc.querySelectorAll('a');
const processedHtml = html.replace(regex, replacement);
links.forEach((link) => {
const href = link.getAttribute('href');
if (!href) return;
return processedHtml;
const realHref = extractActualLink(href);
const dataOnClick = link.getAttribute('data-onclick');
link.classList.add('link');
if (dataOnClick === 'jump_to_time' || (realHref && realHref.includes('t='))) {
const match = realHref?.match(/t=(\d+)/);
const timestamp = match ? match[1] : '';
const newHref = resolve(`/watch/[videoId]?time=${timestamp}`, { videoId });
link.setAttribute('href', newHref);
link.removeAttribute('data-onclick');
link.setAttribute('data-sveltekit-preload-data', 'off');
} else {
link.setAttribute('href', realHref);
link.setAttribute('target', '_blank');
link.setAttribute('referrerpolicy', 'no-referrer');
}
});
return doc.documentElement.outerHTML;
}
let userPfp = $state('');
@@ -77,7 +101,7 @@
</a>
<p class="no-margin">
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
{@html commentTimestamps(comment.contentHtml)}
{@html parseComment(comment.contentHtml)}
<!-- Comment comes directly from YT so is already sanitized -->
</p>
<div class="comment-actions">
+3 -3
View File
@@ -5,7 +5,7 @@ import { convertToSeconds, padTime } from './time';
export type Timestamp = { title: string; time: number; timePretty: string; endTime: number };
export type Timestamps = Timestamp[];
export interface PhasedDescription {
export interface ParsedDescription {
description: string;
timestamps: Timestamps;
}
@@ -41,11 +41,11 @@ function cleanTimestampTitle(title: string): string {
return title.replace(/^[\s\-•|:/\\*#>~]+/, '').trim();
}
export function phaseDescription(
export function parseDescription(
videoId: string,
content: string,
fallbackPatch?: 'youtubejs' | 'piped'
): PhasedDescription {
): ParsedDescription {
const timestamps: Timestamps = [];
const lines = content.split('\n');
const filteredLines: string[] = [];
+11 -1
View File
@@ -6,7 +6,17 @@ import { isVideoID } from './misc';
function extractVideoId(url: string): string | null {
const urlObj = new URL(url, 'http://example.com'); // Using a base URL in case searchValue is just a query parameter
const videoId = urlObj.searchParams.get('v');
let videoId: string | null = null;
if (urlObj.hostname === 'youtu.be') {
videoId = urlObj.pathname.replace('/', '');
if (videoId === '') {
videoId = null;
}
} else {
videoId = urlObj.searchParams.get('v');
}
return videoId;
}
+2 -2
View File
@@ -21,7 +21,7 @@ import type {
VideoBase,
VideoPlay
} from './api/model';
import type { PhasedDescription } from './description';
import type { ParsedDescription } from './description';
import { ensureNoTrailingSlash } from './misc';
import type { EngineFallback } from './api/misc';
@@ -182,7 +182,7 @@ export const playerAndroidPauseOnNetworkChange = persist(
export const playerPlaylistHistory: Writable<string[]> = writable([]);
export interface PlayerState {
data: { video: VideoPlay; content: PhasedDescription; playlistId: string | null };
data: { video: VideoPlay; content: ParsedDescription; playlistId: string | null };
playerElement?: HTMLMediaElement | undefined;
}
+2 -2
View File
@@ -13,7 +13,7 @@ import {
returnYTDislikesInstanceStore,
returnYtDislikesStore
} from '$lib/store';
import { phaseDescription } from '$lib/description';
import { parseDescription } from '$lib/description';
import { error } from '@sveltejs/kit';
import { get } from 'svelte/store';
import { _ } from './i18n';
@@ -71,7 +71,7 @@ export async function getWatchDetails(videoId: string, url: URL) {
return {
video: video,
content: phaseDescription(video.videoId, video.descriptionHtml, video.fallbackPatch),
content: parseDescription(video.videoId, video.descriptionHtml, video.fallbackPatch),
playlistId: playlistId,
streamed: {
personalPlaylists: personalPlaylists,
@@ -449,7 +449,9 @@
{$_('transcript')}
</div>
</button>
<button class="surface-container-highest"
<button
class="surface-container-highest"
onclick={(event: Event) => event.stopPropagation()}
><i>share</i>
<div class="tooltip">
{$_('player.share.title')}
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -3,7 +3,7 @@ import os
import re
from datetime import datetime
LATEST_VERSION = "1.14.0"
LATEST_VERSION = "1.14.1"
RELEASE_DATE = datetime.now().strftime("%Y-%-m-%d") # Format: YYYY-M-D
WORKING_DIR = os.path.join(os.path.dirname(os.path.realpath(__file__)), "materialious")