@@ -7,8 +7,8 @@ android {
|
||||
applicationId "us.materialio.app"
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 118
|
||||
versionName "1.9.1"
|
||||
versionCode 119
|
||||
versionName "1.9.2"
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
aaptOptions {
|
||||
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
||||
|
||||
@@ -63,7 +63,11 @@
|
||||
|
||||
|
||||
|
||||
<release version="1.9.1" date="2025-6-05">
|
||||
|
||||
<release version="1.9.2" date="2025-6-05">
|
||||
<url>https://github.com/Materialious/Materialious/releases/tag/1.9.2</url>
|
||||
</release>
|
||||
<release version="1.9.1" date="2025-6-05">
|
||||
<url>https://github.com/Materialious/Materialious/releases/tag/1.9.1</url>
|
||||
</release>
|
||||
<release version="1.9.0" date="2025-6-04">
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "Materialious",
|
||||
"version": "1.9.1",
|
||||
"version": "1.9.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "Materialious",
|
||||
"version": "1.9.1",
|
||||
"version": "1.9.2",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@capacitor-community/electron": "^5.0.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "Materialious",
|
||||
"version": "1.9.1",
|
||||
"version": "1.9.2",
|
||||
"description": "Modern material design for Invidious.",
|
||||
"author": {
|
||||
"name": "Ward Pearce",
|
||||
@@ -44,4 +44,4 @@
|
||||
"capacitor",
|
||||
"electron"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "materialious",
|
||||
"version": "1.9.1",
|
||||
"version": "1.9.2",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
@@ -70,4 +70,4 @@
|
||||
"svelte-persisted-store": "^0.12.0",
|
||||
"youtubei.js": "^13.4.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,7 @@
|
||||
instanceStore,
|
||||
interfaceAllowInsecureRequests,
|
||||
interfaceAmoledTheme,
|
||||
interfaceAutoExpandChapters,
|
||||
interfaceAutoExpandComments,
|
||||
interfaceAutoExpandDesc,
|
||||
interfaceDefaultPage,
|
||||
@@ -250,6 +251,22 @@
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<div class="field no-margin">
|
||||
<nav class="no-padding">
|
||||
<div class="max">
|
||||
<div>{$_('layout.expandChapters')}</div>
|
||||
</div>
|
||||
<label class="switch">
|
||||
<input
|
||||
type="checkbox"
|
||||
bind:checked={$interfaceAutoExpandChapters}
|
||||
onclick={() => interfaceAutoExpandChapters.set(!$interfaceAutoExpandChapters)}
|
||||
/>
|
||||
<span></span>
|
||||
</label>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<div class="field no-margin">
|
||||
<nav class="no-padding">
|
||||
<div class="max">
|
||||
|
||||
@@ -40,118 +40,6 @@
|
||||
}
|
||||
});
|
||||
|
||||
export function dedupeAndMergeOverlappingCues(cues: VTTCue[], timeOffset = -1): VTTCue[] {
|
||||
const merged: VTTCue[] = [];
|
||||
|
||||
for (const cue of cues) {
|
||||
const trimmedText = cue.text.trim();
|
||||
if (!trimmedText) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let wasMerged = false;
|
||||
|
||||
for (let i = 0; i < merged.length; i++) {
|
||||
const existing = merged[i];
|
||||
|
||||
const timeGap = Math.abs(cue.startTime - existing.endTime);
|
||||
const isTimeAdjacent = timeGap <= 1.5;
|
||||
|
||||
if (isTimeAdjacent && areCuesTextSimilar(existing.text, cue.text)) {
|
||||
const mergedText = mergeCueText(existing.text, cue.text);
|
||||
|
||||
const newStart = Math.max(0, Math.min(existing.startTime, cue.startTime) + timeOffset);
|
||||
const newEnd = Math.max(existing.endTime, cue.endTime) + timeOffset;
|
||||
|
||||
const mergedCue = new VTTCue(newStart, newEnd, mergedText);
|
||||
copyCueMeta(mergedCue, cue);
|
||||
|
||||
merged[i] = mergedCue;
|
||||
wasMerged = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!wasMerged) {
|
||||
const offsetCue = new VTTCue(
|
||||
Math.max(0, cue.startTime + timeOffset),
|
||||
cue.endTime + timeOffset,
|
||||
cue.text
|
||||
);
|
||||
copyCueMeta(offsetCue, cue);
|
||||
merged.push(offsetCue);
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure no overlapping cues (each cue ends before the next starts)
|
||||
for (let i = 0; i < merged.length - 1; i++) {
|
||||
const current = merged[i];
|
||||
const next = merged[i + 1];
|
||||
if (current.endTime > next.startTime) {
|
||||
current.endTime = Math.max(current.startTime, next.startTime - 0.01); // 10ms gap
|
||||
}
|
||||
}
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
||||
function areCuesTextSimilar(a: string, b: string): boolean {
|
||||
if (a === b) return true;
|
||||
|
||||
// Normalize and compare overlap ratio
|
||||
const cleanA = normalizeText(a);
|
||||
const cleanB = normalizeText(b);
|
||||
|
||||
const overlap = getTextOverlap(cleanA, cleanB);
|
||||
const minLength = Math.min(cleanA.length, cleanB.length);
|
||||
|
||||
// Consider similar if 50% or more overlap
|
||||
return overlap / minLength >= 0.5;
|
||||
}
|
||||
|
||||
function getTextOverlap(a: string, b: string): number {
|
||||
const maxOverlap = Math.min(a.length, b.length);
|
||||
for (let i = maxOverlap; i > 0; i--) {
|
||||
if (a.endsWith(b.slice(0, i)) || b.endsWith(a.slice(0, i))) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
function normalizeText(text: string): string {
|
||||
return text
|
||||
.trim()
|
||||
.replace(/\s+/g, ' ')
|
||||
.replace(/[.,?!"'”“]/g, '')
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
function mergeCueText(a: string, b: string): string {
|
||||
// Avoid duplicating overlap
|
||||
const overlapLen = getTextOverlap(a, b);
|
||||
if (a.endsWith(b.slice(0, overlapLen))) {
|
||||
return a + b.slice(overlapLen);
|
||||
}
|
||||
if (b.endsWith(a.slice(0, overlapLen))) {
|
||||
return b + a.slice(overlapLen);
|
||||
}
|
||||
return `${a} ${b}`;
|
||||
}
|
||||
|
||||
function copyCueMeta(target: VTTCue, source: VTTCue): void {
|
||||
target.region = source.region;
|
||||
target.vertical = source.vertical;
|
||||
target.snapToLines = source.snapToLines;
|
||||
target.line = source.line;
|
||||
target.lineAlign = source.lineAlign;
|
||||
target.position = source.position;
|
||||
target.positionAlign = source.positionAlign;
|
||||
target.size = source.size;
|
||||
target.align = source.align;
|
||||
target.style = source.style;
|
||||
}
|
||||
|
||||
async function loadTranscript() {
|
||||
if (!url) {
|
||||
transcript = null;
|
||||
@@ -160,11 +48,31 @@
|
||||
|
||||
isLoading = true;
|
||||
transcript = null;
|
||||
|
||||
const resp = await fetch(`${!video.fallbackPatch ? get(instanceStore) : ''}${url}`);
|
||||
transcript = await parseText(await resp.text(), { strict: false });
|
||||
|
||||
transcriptCues = dedupeAndMergeOverlappingCues(transcript.cues);
|
||||
// Group cues by Math.ceil(startTime)
|
||||
const startTimeMap = new Map<number, VTTCue[]>();
|
||||
for (const cue of transcript.cues) {
|
||||
const roundedTime = Math.ceil(cue.startTime);
|
||||
if (!startTimeMap.has(roundedTime)) {
|
||||
startTimeMap.set(roundedTime, []);
|
||||
}
|
||||
startTimeMap.get(roundedTime)!.push(cue);
|
||||
}
|
||||
|
||||
// Keep only the second cue if multiple exist for the same rounded time
|
||||
transcriptCues = [];
|
||||
for (const [, cues] of startTimeMap.entries()) {
|
||||
if (cues.length === 1) {
|
||||
transcriptCues.push(cues[0]);
|
||||
} else if (cues.length >= 2) {
|
||||
transcriptCues.push(cues[1]); // Keep the second one
|
||||
}
|
||||
}
|
||||
|
||||
transcript.cues = transcriptCues;
|
||||
|
||||
isLoading = false;
|
||||
}
|
||||
|
||||
@@ -172,7 +80,7 @@
|
||||
if (!transcript) return;
|
||||
|
||||
if (search.trim() === '') {
|
||||
transcriptCues = dedupeAndMergeOverlappingCues(transcript.cues);
|
||||
transcriptCues = transcript.cues;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -180,8 +88,7 @@
|
||||
keys: ['text']
|
||||
});
|
||||
|
||||
const results = fuse.search(search).map((item) => item.item);
|
||||
transcriptCues = dedupeAndMergeOverlappingCues(results);
|
||||
transcriptCues = fuse.search(search).map((item) => item.item);
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -260,4 +167,8 @@
|
||||
.transcript-text {
|
||||
margin-left: 1em;
|
||||
}
|
||||
|
||||
.transcript-line .chip {
|
||||
padding: 0 1.5em;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
main.root {
|
||||
padding-left: 7em !important;
|
||||
padding-top: 5em !important;
|
||||
padding-bottom: 5em !important;
|
||||
max-height: 100vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
@@ -31,7 +31,8 @@ import {
|
||||
sponsorBlockUrlStore,
|
||||
synciousInstanceStore,
|
||||
synciousStore,
|
||||
themeColorStore
|
||||
themeColorStore,
|
||||
interfaceAutoExpandChapters
|
||||
} from '$lib/store';
|
||||
import { get, type Writable } from 'svelte/store';
|
||||
|
||||
@@ -199,6 +200,11 @@ const persistedStores: {
|
||||
name: 'defaultQuality',
|
||||
store: playerDefaultQualityStore,
|
||||
type: 'string'
|
||||
},
|
||||
{
|
||||
name: 'autoExpandChapters',
|
||||
store: interfaceAutoExpandChapters,
|
||||
type: 'boolean'
|
||||
}
|
||||
];
|
||||
|
||||
|
||||
@@ -72,6 +72,7 @@
|
||||
"pauseTimer": "Pause timer",
|
||||
"pauseVideoIn": "Pause video in ",
|
||||
"preferredQuality": "Preferred quality",
|
||||
"chapters": "Chapters",
|
||||
"share": {
|
||||
"title": "Share",
|
||||
"materialiousLink": "Copy Materialious link",
|
||||
@@ -126,6 +127,7 @@
|
||||
"searchSuggestions": "Search suggestions",
|
||||
"previewVideoOnHover": "Preview video on hover",
|
||||
"expandDescription": "Expand description by default",
|
||||
"expandChapters": "Expand chapters by default",
|
||||
"expandComments": "Expand comments by default",
|
||||
"allowInsecureRequests": "Allow insecure requests",
|
||||
"dataPreferences": {
|
||||
|
||||
@@ -71,6 +71,10 @@ export const interfaceSearchSuggestionsStore = persisted('searchSuggestions', tr
|
||||
export const interfaceForceCase: Writable<TitleCase> = persisted('forceCase', null);
|
||||
export const interfaceAutoExpandComments: Writable<boolean> = persisted('autoExpandComments', true);
|
||||
export const interfaceAutoExpandDesc: Writable<boolean> = persisted('autoExpandDesc', false);
|
||||
export const interfaceAutoExpandChapters: Writable<boolean> = persisted(
|
||||
'autoExpandChapters',
|
||||
false
|
||||
);
|
||||
export const interfaceAmoledTheme = persisted('amoledTheme', false);
|
||||
export const interfaceLowBandwidthMode = persisted('lowBandwidthMode', false);
|
||||
export const interfaceDisplayThumbnailAvatars = persisted('disableThumbnailAvatars', false);
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
import type { PlayerEvents } from '$lib/player.js';
|
||||
import {
|
||||
authStore,
|
||||
interfaceAutoExpandChapters,
|
||||
interfaceAutoExpandComments,
|
||||
interfaceAutoExpandDesc,
|
||||
interfaceLowBandwidthMode,
|
||||
@@ -71,12 +72,16 @@
|
||||
|
||||
let playerCurrentTime: number = $state(0);
|
||||
|
||||
function expandSummery(id: string) {
|
||||
const element = document.getElementById(id);
|
||||
if (element) {
|
||||
element.click();
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if ($interfaceAutoExpandComments && comments) {
|
||||
const commentSectionElement = document.getElementById('comment-section');
|
||||
if (commentSectionElement) {
|
||||
commentSectionElement.click();
|
||||
}
|
||||
expandSummery('comment-section');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -221,10 +226,11 @@
|
||||
|
||||
onMount(async () => {
|
||||
if ($interfaceAutoExpandDesc) {
|
||||
const descriptionElement = document.getElementById('description');
|
||||
if (descriptionElement) {
|
||||
descriptionElement.click();
|
||||
}
|
||||
expandSummery('description');
|
||||
}
|
||||
|
||||
if ($interfaceAutoExpandChapters) {
|
||||
expandSummery('chapter-section');
|
||||
}
|
||||
|
||||
if ($syncPartyConnectionsStore) {
|
||||
@@ -403,9 +409,6 @@
|
||||
|
||||
ui('#pause-timer');
|
||||
}
|
||||
|
||||
let downloadStage: string | undefined;
|
||||
let downloadProgress: number = 0;
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
@@ -422,18 +425,6 @@
|
||||
{/key}
|
||||
</div>
|
||||
|
||||
{#if downloadStage}
|
||||
<article>
|
||||
<h6>
|
||||
{downloadStage}
|
||||
{#if downloadProgress > 0}
|
||||
({Math.round(downloadProgress)}%)
|
||||
{/if}
|
||||
</h6>
|
||||
<progress class="max" value={downloadProgress} max="100"></progress>
|
||||
</article>
|
||||
{/if}
|
||||
|
||||
<h5>{letterCase(data.video.title)}</h5>
|
||||
|
||||
<div class="grid no-padding">
|
||||
@@ -580,7 +571,8 @@
|
||||
<summary id="description" class="bold none">
|
||||
<nav>
|
||||
<div class="max">
|
||||
{numberWithCommas(data.video.viewCount)} views • {data.video.publishedText}
|
||||
{numberWithCommas(data.video.viewCount)}
|
||||
{$_('views')} • {data.video.publishedText}
|
||||
</div>
|
||||
<i>expand_more</i>
|
||||
</nav>
|
||||
@@ -590,24 +582,6 @@
|
||||
<div style="white-space: pre-line; overflow-wrap: break-word;">
|
||||
{@html data.content.description}
|
||||
</div>
|
||||
{#if data.content}
|
||||
{#if data.content.timestamps.length > 0}
|
||||
<h6 style="margin-bottom: .3em;">Chapters</h6>
|
||||
{#each data.content.timestamps as timestamp}
|
||||
<button
|
||||
onclick={() => {
|
||||
if (playerElement) playerElement.currentTime = timestamp.time;
|
||||
}}
|
||||
class="timestamps"
|
||||
>{timestamp.timePretty}
|
||||
{#if !timestamp.title.startsWith('-')}
|
||||
-
|
||||
{/if}
|
||||
{timestamp.title}</button
|
||||
>
|
||||
{/each}
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<nav class="scroll">
|
||||
@@ -620,6 +594,43 @@
|
||||
</details>
|
||||
</article>
|
||||
|
||||
{#if data.content.timestamps.length > 0}
|
||||
<article>
|
||||
<details>
|
||||
<summary id="chapter-section" class="bold none">
|
||||
<nav>
|
||||
<div class="max">
|
||||
<p>{$_('player.chapters')}</p>
|
||||
</div>
|
||||
<i>expand_more</i>
|
||||
</nav>
|
||||
</summary>
|
||||
<div class="space"></div>
|
||||
<div class="chapter-list">
|
||||
<ul class="list">
|
||||
{#each data.content.timestamps as timestamp}
|
||||
<li
|
||||
onclick={() => {
|
||||
if (playerElement) playerElement.currentTime = timestamp.time;
|
||||
}}
|
||||
>
|
||||
<img
|
||||
class="round large"
|
||||
src={getBestThumbnail(data.video.videoThumbnails) as string}
|
||||
alt="Thumbnail for current video"
|
||||
/>
|
||||
<div class="max" style="white-space: pre-line; overflow-wrap: break-word;">
|
||||
<p>{timestamp.title}</p>
|
||||
<span class="chip fill no-margin">{timestamp.timePretty}</span>
|
||||
</div>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
</details>
|
||||
</article>
|
||||
{/if}
|
||||
|
||||
{#if comments && comments.comments.length > 0}
|
||||
<article>
|
||||
<details>
|
||||
@@ -767,22 +778,9 @@
|
||||
</dialog>
|
||||
|
||||
<style>
|
||||
:root {
|
||||
--plyr-color-main: var(--primary);
|
||||
}
|
||||
|
||||
.timestamps {
|
||||
margin-left: 0;
|
||||
margin-bottom: 0.4em;
|
||||
display: block;
|
||||
padding: 0;
|
||||
text-align: left;
|
||||
background-color: transparent;
|
||||
color: var(--on-secondary-container);
|
||||
}
|
||||
|
||||
.timestamps:hover {
|
||||
padding: 0 0.5em;
|
||||
.chapter-list {
|
||||
max-height: 300px;
|
||||
overflow-x: scroll;
|
||||
}
|
||||
|
||||
.video-actions {
|
||||
|
||||
|
Before Width: | Height: | Size: 1.3 MiB After Width: | Height: | Size: 1.4 MiB |
|
Before Width: | Height: | Size: 3.8 MiB |
|
Before Width: | Height: | Size: 1.2 MiB After Width: | Height: | Size: 2.1 MiB |
|
Before Width: | Height: | Size: 2.4 MiB After Width: | Height: | Size: 1.9 MiB |
@@ -3,7 +3,7 @@ import os
|
||||
import re
|
||||
from datetime import datetime
|
||||
|
||||
LATEST_VERSION = "1.9.1"
|
||||
LATEST_VERSION = "1.9.2"
|
||||
RELEASE_DATE = datetime.now().strftime("%Y-%-m-%d") # Format: YYYY-M-D
|
||||
|
||||
WORKING_DIR = os.path.join(
|
||||
|
||||