Merge pull request #1468 from Materialious/update/1.16.2"

Update/1.16.2
This commit is contained in:
Ward
2026-02-18 20:09:23 +13:00
committed by GitHub
19 changed files with 229 additions and 117 deletions
+2 -2
View File
@@ -7,8 +7,8 @@ android {
applicationId "us.materialio.app"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 219
versionName "1.16.1"
versionCode 220
versionName "1.16.2"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
@@ -84,7 +84,11 @@
<release version="1.16.1" date="2026-2-18">
<release version="1.16.2" date="2026-2-18">
<url>https://github.com/Materialious/Materialious/releases/tag/1.16.2</url>
</release>
<release version="1.16.1" date="2026-2-18">
<url>https://github.com/Materialious/Materialious/releases/tag/1.16.1</url>
</release>
<release version="1.16.0" date="2026-2-18">
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "Materialious",
"version": "1.16.1",
"version": "1.16.2",
"description": "Modern material design for YouTube and Invidious.",
"author": {
"name": "Ward Pearce",
@@ -45,4 +45,4 @@
"capacitor",
"electron"
]
}
}
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "materialious",
"version": "1.16.1",
"version": "1.16.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "materialious",
"version": "1.16.1",
"version": "1.16.2",
"hasInstallScript": true,
"dependencies": {
"@capacitor-community/electron": "^5.0.1",
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "materialious",
"version": "1.16.1",
"version": "1.16.2",
"private": true,
"scripts": {
"dev": "npm run patch:github && vite dev",
@@ -96,4 +96,4 @@
"youtubei.js": "^16.0.1",
"zod": "^4.3.6"
}
}
}
+3 -1
View File
@@ -98,6 +98,8 @@ export interface Ytjs {
rawApiResponse: ApiResponse;
}
export type FallbackPatches = 'youtubejs' | 'piped';
export interface VideoPlay extends Video {
keywords: string[];
likeCount: number;
@@ -120,7 +122,7 @@ export interface VideoPlay extends Video {
captions: Captions[];
storyboards?: StoryBoard[];
ytjs?: Ytjs;
fallbackPatch?: 'youtubejs' | 'piped';
fallbackPatch?: FallbackPatches;
}
export interface StoryBoard {
@@ -0,0 +1,129 @@
<script lang="ts" module>
let trackVisible: boolean = $state(false);
let captionsCues: VTTCue[] = $state([]);
const captionTracks: Record<string, string> = {};
export function setTextTrackVisibility(visible: boolean) {
trackVisible = visible;
}
// Fetch caption data for a selected language
export async function selectTextTrack(language: string) {
const resp = await fetch(captionTracks[language], { method: 'GET' });
if (!resp.ok) {
addToast({
data: {
text: 'Unable to fetch captions'
}
});
}
captionsCues = (await parseText(await resp.text(), { strict: true, type: 'vtt' })).cues;
}
</script>
<script lang="ts">
import type { VideoPlay } from '$lib/api/model';
import { findElementForTime } from '$lib/misc';
import { onDestroy, onMount } from 'svelte';
import { addToast } from '../Toast.svelte';
import { parseText, renderVTTCueString, type VTTCue } from 'media-captions';
import { getCaptionUrl } from '$lib/player/captions';
let {
video,
currentTime = $bindable(),
showControls = $bindable()
}: {
video: VideoPlay;
currentTime: number;
showControls: boolean;
} = $props();
let captionElement: HTMLElement | undefined = $state();
let captionContainerHeight: number = $state(0);
let currentCaption: VTTCue | null = $state(null);
function updateCaptionHeight() {
if (captionElement) {
captionContainerHeight = captionElement.offsetHeight;
}
}
$effect(() => {
updateCaptionHeight();
currentCaption = findElementForTime(
captionsCues,
currentTime,
(cue: VTTCue) => cue.startTime,
(cue: VTTCue) => cue.endTime
);
});
onMount(async () => {
window.addEventListener('resize', updateCaptionHeight);
if (video.captions) {
for (const caption of video.captions) {
const captionUrl = getCaptionUrl(caption, video.fallbackPatch);
if (!captionUrl) continue;
captionTracks[caption.language_code] = captionUrl;
}
}
});
onDestroy(() => {
window.removeEventListener('resize', updateCaptionHeight);
});
</script>
{#if trackVisible && captionsCues.length > 0}
{#if currentCaption}
<div
class="caption-container"
bind:this={captionElement}
style:top={`calc(${showControls ? 'var(--video-player-height) * 0.85' : 'var(--video-player-height) * 0.98'} - ${captionContainerHeight}px)`}
>
{#if currentCaption}
<p>
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
{@html renderVTTCueString(currentCaption, currentTime)}
</p>
{/if}
</div>
{/if}
{/if}
<style>
.caption-container {
position: absolute;
z-index: 5;
left: 50%;
transform: translateX(-50%);
width: fit-content;
}
p {
padding: 5px;
border-radius: 0.25rem;
user-select: none;
font-size: 1.5rem;
color: #fff !important;
background-color: rgb(0, 0, 0, 0.7);
}
@media screen and (max-width: 1000px) {
.caption-container {
width: 100%;
}
p {
font-size: 1rem;
}
}
</style>
@@ -17,7 +17,6 @@
import type { VideoPlay } from '$lib/api/model';
import {
invidiousAuthStore,
invidiousInstanceStore,
isAndroidTvStore,
playerAlwaysLoopStore,
playerAndroidLockOrientation,
@@ -65,6 +64,7 @@
import TouchControls from './TouchControls.svelte';
import { Network, type ConnectionStatus } from '@capacitor/network';
import { ScreenOrientation, type ScreenOrientationResult } from '@capacitor/screen-orientation';
import ClosedCaptions from './ClosedCaptions.svelte';
interface Props {
data: { video: VideoPlay; content: ParsedDescription; playlistId: string | null };
@@ -286,28 +286,6 @@
await player.load(dashUrl, await getLastPlayPos());
}
if (data.video.captions) {
for (const caption of data.video.captions) {
let captionUrl: string;
if (!getPublicEnv('DEFAULT_COMPANION_INSTANCE') && $invidiousInstanceStore) {
captionUrl = caption.url.startsWith('http')
? caption.url
: `${new URL($invidiousInstanceStore).origin}${caption.url}`;
} else {
captionUrl = `${getPublicEnv('DEFAULT_COMPANION_INSTANCE')}${caption.url}`;
}
await player.addTextTrackAsync(
captionUrl,
caption.language_code,
'captions',
undefined,
undefined,
caption.label
);
}
}
if (data.content.timestamps) {
try {
player.addChaptersTrack(
@@ -479,31 +457,6 @@
const error = (event as CustomEvent).detail as shaka.util.Error;
console.error('Player error:', error);
});
player.getNetworkingEngine()?.registerResponseFilter((type, response) => {
if (
type !== shaka.net.NetworkingEngine.RequestType.SEGMENT ||
!response.uri.includes('/api/timedtext')
) {
return;
}
const url = new URL(response.uri);
// Fix positioning for auto-generated subtitles
// Credit to Freetube!
if (
url.hostname.endsWith('.youtube.com') &&
url.pathname === '/api/timedtext' &&
url.searchParams.get('caps') === 'asr' &&
url.searchParams.get('kind') === 'asr' &&
url.searchParams.get('fmt') === 'vtt'
) {
const stringBody = new TextDecoder().decode(response.data);
// position:0% for LTR text and position:100% for RTL text
const cleaned = stringBody.replaceAll(/ align:start position:(?:10)?0%$/gm, '');
response.data = new TextEncoder().encode(cleaned).buffer;
}
});
playerElement?.addEventListener('volumechange', saveVolumePreference);
@@ -894,6 +847,8 @@
}}
bind:this={playerContainer}
>
<ClosedCaptions video={data.video} bind:currentTime bind:showControls />
<video
controls={false}
autoplay={$playerAutoPlayStore}
@@ -905,6 +860,7 @@
{data.video.title}
</div>
{/if}
{#if showControls}
<div id="mobile-time" transition:fade>
<p class="chip surface-container-highest s">
@@ -974,7 +930,7 @@
{/if}
</p>
{#if !$isAndroidTvStore}
<CaptionSettings {player} video={data.video} />
<CaptionSettings video={data.video} />
{#if playerElement}
<Settings {player} {playerElement} />
<Airplay {playerElement} />
@@ -1,36 +1,25 @@
<script lang="ts">
import type shaka from 'shaka-player/dist/shaka-player.ui';
import { onMount } from 'svelte';
import { _ } from '$lib/i18n';
import type { VideoPlay } from '$lib/api/model';
import { selectTextTrack, setTextTrackVisibility } from '../ClosedCaptions.svelte';
let { player, video }: { player: shaka.Player; video: VideoPlay } = $props();
let playerTextTracks: shaka.extern.TextTrack[] | undefined = $state(undefined);
onMount(() => {
playerTextTracks = player.getTextTracks();
});
let { video }: { video: VideoPlay } = $props();
</script>
{#if playerTextTracks && playerTextTracks.length > 0 && !video.liveNow}
{#if video.captions.length > 0 && !video.liveNow}
<button class="surface-container-highest">
<i>closed_caption</i>
<menu class="no-wrap mobile player-settings" id="cc-menu" data-ui="#cc-menu">
<li
role="presentation"
data-ui="#cc-menu"
onclick={() => player.setTextTrackVisibility(false)}
>
<li role="presentation" data-ui="#cc-menu" onclick={() => setTextTrackVisibility(false)}>
{$_('player.controls.off')}
</li>
{#each playerTextTracks as track (track)}
{#each video.captions as track (track)}
<li
role="presentation"
data-ui="#cc-menu"
onclick={() => {
player.selectTextTrack(track);
player.setTextTrackVisibility(true);
selectTextTrack(track.language_code);
setTextTrackVisibility(true);
}}
>
{track.label}
@@ -10,7 +10,7 @@
let { player, playerElement }: { player: shaka.Player; playerElement: HTMLMediaElement } =
$props();
let playerSettings: 'quality' | 'speed' | 'language' | 'root' = $state('root');
let playerSettings: 'quality' | 'speed' | 'language' | 'caption' | 'root' = $state('root');
let playerCurrentVideoTrack: shaka.extern.VideoTrack | undefined = $state(undefined);
let playerCurrentAudioTrack: shaka.extern.AudioTrack | undefined = $state(undefined);
let playerLoop = $state($playerAlwaysLoopStore);
@@ -508,7 +508,7 @@
@media screen and (max-width: 640px) {
.color-picker {
--picker-width: 85vw;
--picker-width: 95vw;
}
}
</style>
@@ -1,34 +1,39 @@
<script lang="ts">
import { videoLength } from '$lib/numbers';
import Fuse from 'fuse.js';
import { type VTTCue, parseText, type ParsedCaptionsResult } from 'media-captions';
import {
type VTTCue,
parseText,
type ParsedCaptionsResult,
renderVTTCueString
} from 'media-captions';
import { _ } from '$lib/i18n';
import type { VideoPlay } from '$lib/api/model';
import { decodeHtmlCharCodes } from '$lib/misc';
import { invidiousInstanceStore } from '$lib/store';
import type { Captions, VideoPlay } from '$lib/api/model';
import { getCaptionUrl } from '$lib/player/captions';
interface Props {
video: VideoPlay;
playerElement: HTMLMediaElement;
currentTime: number;
}
let { video, playerElement = $bindable() }: Props = $props();
let { video, currentTime = $bindable() }: Props = $props();
let url: string | null = $state(null);
let selectedCaption: Captions | undefined = $state();
let autoScroll: boolean = $state(true);
let transcript: ParsedCaptionsResult | null = $state(null);
let transcriptCues: VTTCue[] = $state([]);
let isLoading = $state(false);
let currentTime = $state(0);
let search: string = $state('');
playerElement.addEventListener('timeupdate', () => {
currentTime = playerElement.currentTime;
$effect(() => {
// currentTime must be referenced to update effect
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
currentTime;
if (autoScroll) {
const currentTranscriptLine = document.querySelector(
'.transcript-line.secondary-container'
'.transcript-line.current-line'
) as HTMLElement;
const transcriptScrollable = document.getElementById('transcript');
@@ -40,28 +45,21 @@
});
async function loadTranscript() {
if (!url) {
if (!selectedCaption) {
transcript = null;
return;
}
let urlConstructed = '';
if (video.fallbackPatch === 'youtubejs') {
urlConstructed = url;
} else if ($invidiousInstanceStore) {
urlConstructed = new URL($invidiousInstanceStore).origin + url;
} else {
return;
}
isLoading = true;
transcript = null;
const resp = await fetch(urlConstructed);
if (!resp.ok) return;
transcript = await parseText(await resp.text(), { strict: false });
const captionUrl = getCaptionUrl(selectedCaption, video.fallbackPatch);
if (!captionUrl) return;
const resp = await fetch(captionUrl);
if (!resp.ok) return;
transcript = await parseText(await resp.text(), { strict: false });
transcriptCues = transcript.cues;
isLoading = false;
@@ -87,10 +85,10 @@
<article class="no-elevate padding" style="position: sticky; top: 0; z-index: 3;">
<h6>{$_('transcript')}</h6>
<div class="field label suffix surface-container-highest">
<select bind:value={url} onchange={loadTranscript} name="captions">
<select bind:value={selectedCaption} onchange={loadTranscript} name="captions">
<option selected={true} value={null}>{$_('selectLang')}</option>
{#each video.captions as caption (caption)}
<option value={caption.url}>{caption.label}</option>
<option value={caption}>{caption.label}</option>
{/each}
</select>
<label for="captions">{$_('language')}</label>
@@ -124,15 +122,19 @@
{#if transcript.cues.length > 0}
{#if transcriptCues.length > 0}
{#each transcriptCues as cue (cue)}
{@const isCurrent = currentTime >= cue.startTime && currentTime <= cue.endTime}
<div
class="transcript-line"
role="presentation"
onclick={() => (playerElement.currentTime = cue.startTime)}
class:surface-container-highest={currentTime >= cue.startTime &&
currentTime <= cue.endTime}
onclick={() => (currentTime = cue.startTime)}
class:current-line={isCurrent}
class:surface-container-highest={isCurrent}
>
<p class="chip no-margin">{videoLength(cue.startTime)}</p>
<p class="transcript-text">{decodeHtmlCharCodes(cue.text.replace(/<[^>]+>/g, ''))}</p>
<p class="transcript-text">
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
{@html renderVTTCueString(cue, currentTime)}
</p>
</div>
{/each}
{:else}
@@ -9,13 +9,15 @@ import { addOrUpdateKeyValue, getKeyValue } from '$lib/api/backend';
import { rawMasterKeyStore } from '$lib/store';
import { getPublicEnv } from '$lib/misc';
const allowNullOverwrite = ['authToken'];
export async function syncSettingsToBackend() {
if (!isOwnBackend() || !get(rawMasterKeyStore)) return;
await Promise.all(
persistedStores.map(async (store) => {
getKeyValue(store.name).then((currentKeyValue) => {
if (currentKeyValue !== null) {
if (currentKeyValue !== null || allowNullOverwrite.includes(store.name)) {
const currentKeyValueParsed = parseWithSchema(store.schema, currentKeyValue);
if (currentKeyValueParsed !== null && currentKeyValueParsed !== undefined) {
store.store.set(currentKeyValueParsed);
@@ -10,6 +10,7 @@
"login": "Login",
"username": "Username",
"password": "Password",
"captionStyle": "Caption styles",
"invalidPassword": "Invalid password",
"usernameTaken": "Username taken",
"linkInvidious": "Link Invidious account",
+25
View File
@@ -0,0 +1,25 @@
import type { Captions, FallbackPatches } from '$lib/api/model';
import { getPublicEnv } from '$lib/misc';
import { invidiousInstanceStore } from '$lib/store';
import { get } from 'svelte/store';
export function getCaptionUrl(
caption: Captions,
fallbackPath: FallbackPatches | undefined = undefined
) {
let captionUrl: string | undefined;
const invidiousInstance = get(invidiousInstanceStore);
if (getPublicEnv('DEFAULT_COMPANION_INSTANCE')) {
captionUrl = `${getPublicEnv('DEFAULT_COMPANION_INSTANCE')}${caption.url}`;
} else if (fallbackPath === 'youtubejs') {
captionUrl = caption.url;
} else if (invidiousInstance) {
captionUrl = caption.url.startsWith('http')
? caption.url
: `${new URL(invidiousInstance).origin}${caption.url}`;
}
return captionUrl;
}
+3 -1
View File
@@ -148,7 +148,9 @@
onMount(async () => {
if ($invidiousAuthStore && !isYTBackend()) {
loadNotifications().catch(() => invidiousLogout());
loadNotifications().catch(() => {
invidiousLogout();
});
}
if ($rawMasterKeyStore) {
@@ -595,8 +595,8 @@
</div>
{#if !$playertheatreModeIsActive}
<div class="s12 m12 l3 recommended">
{#if showTranscript && playerElement}
<Transcript video={data.video} bind:playerElement />
{#if showTranscript}
<Transcript video={data.video} bind:currentTime={playerCurrentTime} />
{/if}
{#if data.playlistId && data.playlistId in $playlistCacheStore}
<Playlist video={data.video} playlist={$playlistCacheStore[data.playlistId]} />
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.16.1"
LATEST_VERSION = "1.16.2"
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")