@@ -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">
|
||||
|
||||
Generated
+2
-2
@@ -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",
|
||||
|
||||
@@ -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,6 +1,6 @@
|
||||
{
|
||||
"name": "materialious",
|
||||
"version": "1.14.0",
|
||||
"version": "1.14.1",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "npm run patch:github && vite dev",
|
||||
|
||||
@@ -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>[] = [];
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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[] = [];
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user