Merge pull request #1389 from Materialious/update/1.13.16
Update/1.13.16
This commit is contained in:
@@ -7,8 +7,8 @@ android {
|
||||
applicationId "us.materialio.app"
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 199
|
||||
versionName "1.13.15"
|
||||
versionCode 200
|
||||
versionName "1.13.16"
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
aaptOptions {
|
||||
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
||||
|
||||
@@ -98,7 +98,11 @@
|
||||
|
||||
|
||||
|
||||
<release version="1.13.15" date="2026-2-10">
|
||||
|
||||
<release version="1.13.16" date="2026-2-10">
|
||||
<url>https://github.com/Materialious/Materialious/releases/tag/1.13.16</url>
|
||||
</release>
|
||||
<release version="1.13.15" date="2026-2-10">
|
||||
<url>https://github.com/Materialious/Materialious/releases/tag/1.13.15</url>
|
||||
</release>
|
||||
<release version="1.13.14" date="2026-2-09">
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "Materialious",
|
||||
"version": "1.13.15",
|
||||
"version": "1.13.16",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "Materialious",
|
||||
"version": "1.13.15",
|
||||
"version": "1.13.16",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@capacitor-community/electron": "^5.0.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "Materialious",
|
||||
"version": "1.13.15",
|
||||
"version": "1.13.16",
|
||||
"description": "Modern material design for Invidious.",
|
||||
"author": {
|
||||
"name": "Ward Pearce",
|
||||
|
||||
@@ -190,7 +190,6 @@ export class ElectronCapacitorApp {
|
||||
globalShortcut.register('Control+Shift+I', () => {
|
||||
this.MainWindow.webContents.toggleDevTools();
|
||||
});
|
||||
this.MainWindow.webContents.toggleDevTools();
|
||||
|
||||
// Security
|
||||
this.MainWindow.webContents.setWindowOpenHandler((details) => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "materialious",
|
||||
"version": "1.13.15",
|
||||
"version": "1.13.16",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "npm run patch:github && vite dev",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/stores';
|
||||
import { getBestThumbnail } from '$lib/images';
|
||||
import { getBestThumbnail, ImageCache } from '$lib/images';
|
||||
import { padTime, videoLength } from '$lib/numbers';
|
||||
import { type PhasedDescription, type Timestamp } from '$lib/timestamps';
|
||||
import { SystemBars, SystemBarsStyle, SystemBarType } from '@capacitor/core';
|
||||
@@ -58,7 +58,12 @@
|
||||
import { Network, type ConnectionStatus } from '@capacitor/network';
|
||||
import { fade } from 'svelte/transition';
|
||||
import { addToast } from './Toast.svelte';
|
||||
import { isMobile } from '$lib/misc';
|
||||
import { isMobile, truncate } from '$lib/misc';
|
||||
import {
|
||||
drawTimelineThumbnail,
|
||||
storyboardThumbnails,
|
||||
type TimelineThumbnail
|
||||
} from '$lib/timelineThumbnails';
|
||||
|
||||
interface Props {
|
||||
data: { video: VideoPlay; content: PhasedDescription; playlistId: string | null };
|
||||
@@ -121,6 +126,9 @@
|
||||
let playerVolumeElement: HTMLElement | undefined = $state();
|
||||
let playerIsFullscreen: boolean = $state(false);
|
||||
let playerTimelineTooltip: HTMLDivElement | undefined = $state();
|
||||
let playerTimelineThumbnails: TimelineThumbnail[] = $state([]);
|
||||
let playerTimelineThumbnailsCache = new ImageCache();
|
||||
let playerTimelineThumbnailCanvas: HTMLCanvasElement | undefined = $state();
|
||||
let playerInitalInteract = true;
|
||||
|
||||
const playerTimelineSlider = new Slider({
|
||||
@@ -507,6 +515,15 @@
|
||||
dashUrl += '?local=true';
|
||||
}
|
||||
|
||||
if (data.video.storyboards && data.video.storyboards.length > 1) {
|
||||
// Use best possible thumbnails
|
||||
playerTimelineThumbnails = await storyboardThumbnails(
|
||||
data.video.storyboards[data.video.storyboards.length - 1],
|
||||
playerTimelineThumbnailsCache,
|
||||
playerMaxKnownTime
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
Capacitor.getPlatform() === 'android' &&
|
||||
$playerProxyVideosStore &&
|
||||
@@ -1110,7 +1127,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
function updateTooltip() {
|
||||
async function updateTooltip() {
|
||||
if (!playerSliderElement || latestMouseX === undefined) {
|
||||
requestAnimationTooltip = undefined;
|
||||
return;
|
||||
@@ -1122,6 +1139,19 @@
|
||||
playerTimelineTimeHover = percent * (data.video.lengthSeconds ?? 0);
|
||||
setPlayerTimelineChapters(playerTimelineTimeHover);
|
||||
|
||||
if (playerTimelineThumbnailCanvas) {
|
||||
const canvasContext = playerTimelineThumbnailCanvas.getContext('2d');
|
||||
|
||||
if (canvasContext) {
|
||||
await drawTimelineThumbnail(
|
||||
canvasContext,
|
||||
playerTimelineThumbnailsCache,
|
||||
playerTimelineThumbnails,
|
||||
playerTimelineTimeHover
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (playerTimelineTooltip) {
|
||||
const tooltipWidth = playerTimelineTooltip.offsetWidth;
|
||||
const tooltipHeight = playerTimelineTooltip.offsetHeight;
|
||||
@@ -1218,6 +1248,8 @@
|
||||
// Continue regardless of error
|
||||
}
|
||||
|
||||
playerTimelineThumbnailsCache.clear();
|
||||
|
||||
window.removeEventListener('resize', updateVideoPlayerHeight);
|
||||
|
||||
Mousetrap.unbind(['left', 'right', 'space', 'c', 'f', 'shift+left', 'shift+right', 'enter']);
|
||||
@@ -1323,13 +1355,22 @@
|
||||
{sponsorSegments[playerCloestSponsor.category]}
|
||||
<br />
|
||||
{:else if playerCloestTimestamp}
|
||||
{playerCloestTimestamp.title}
|
||||
{truncate(playerCloestTimestamp.title, 22)}
|
||||
<br />
|
||||
{/if}
|
||||
{/snippet}
|
||||
<div class="track">
|
||||
{#if !userManualSeeking}
|
||||
<div bind:this={playerTimelineTooltip} class="timeline tooltip">
|
||||
{#if playerTimelineThumbnails.length > 0}
|
||||
<canvas
|
||||
bind:this={playerTimelineThumbnailCanvas}
|
||||
width={playerTimelineThumbnails[0].width}
|
||||
height={playerTimelineThumbnails[0].height}
|
||||
>
|
||||
></canvas
|
||||
>
|
||||
{/if}
|
||||
{@render timelineTooltip()}
|
||||
{videoLength(playerTimelineTimeHover)}
|
||||
</div>
|
||||
@@ -1614,6 +1655,10 @@
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
:root {
|
||||
--player-timeline-height: 1.1rem;
|
||||
}
|
||||
|
||||
#player-container {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
@@ -1661,7 +1706,7 @@
|
||||
}
|
||||
|
||||
.player-slider {
|
||||
height: 1rem;
|
||||
height: var(--player-timeline-height);
|
||||
margin: 0 auto;
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
@@ -1732,7 +1777,7 @@
|
||||
|
||||
.buffered-bar {
|
||||
position: absolute;
|
||||
height: 1rem;
|
||||
height: var(--player-timeline-height);
|
||||
background: var(--secondary);
|
||||
top: 50%;
|
||||
left: 0;
|
||||
@@ -1749,7 +1794,7 @@
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
left: 0;
|
||||
height: 1rem;
|
||||
height: var(--player-timeline-height);
|
||||
background-color: var(--secondary);
|
||||
border-radius: 0.25rem;
|
||||
z-index: 2;
|
||||
@@ -1827,5 +1872,14 @@
|
||||
transition: none;
|
||||
pointer-events: none;
|
||||
will-change: transform;
|
||||
padding: 0;
|
||||
border-radius: 0.25rem;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.timeline.tooltip canvas {
|
||||
display: block;
|
||||
margin-bottom: 0.1rem;
|
||||
height: 100px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -2,6 +2,31 @@ import { get } from 'svelte/store';
|
||||
import type { Image } from './api/model';
|
||||
import { instanceStore } from './store';
|
||||
|
||||
export class ImageCache {
|
||||
private cache = new Map<string, HTMLImageElement>();
|
||||
|
||||
load(src: string): Promise<HTMLImageElement> {
|
||||
if (this.cache.has(src)) {
|
||||
return Promise.resolve(this.cache.get(src)!);
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new Image();
|
||||
img.crossOrigin = 'anonymous';
|
||||
img.onload = () => {
|
||||
this.cache.set(src, img);
|
||||
resolve(img);
|
||||
};
|
||||
img.onerror = reject;
|
||||
img.src = src;
|
||||
});
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.cache.clear();
|
||||
}
|
||||
}
|
||||
|
||||
export function getBestThumbnail(
|
||||
images: Image[] | null,
|
||||
maxWidthDimension: number = 480,
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import type { StoryBoard } from './api/model';
|
||||
import { ImageCache } from './images';
|
||||
|
||||
export interface TimelineThumbnail {
|
||||
url: string;
|
||||
time: number;
|
||||
width: number;
|
||||
height: number;
|
||||
index: number;
|
||||
}
|
||||
|
||||
export async function storyboardThumbnails(
|
||||
storyboard: StoryBoard,
|
||||
imageCache: ImageCache,
|
||||
videoLength: number
|
||||
): Promise<TimelineThumbnail[]> {
|
||||
const { count, storyboardCount, width, height, templateUrl } = storyboard;
|
||||
|
||||
// Our ytjs implementation doesn't do this right,
|
||||
// so just assume its incorrect and fetch manually.
|
||||
// Doesn't actually add any extra overhead.
|
||||
const firstSheetUrl = templateUrl.replace('M$M', `M0`);
|
||||
const firstSheetImage = await imageCache.load(firstSheetUrl);
|
||||
if (!firstSheetImage) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const storyboardWidth = firstSheetImage.width;
|
||||
const storyboardHeight = firstSheetImage.height;
|
||||
|
||||
const cols = Math.floor(storyboardWidth / width);
|
||||
const rows = Math.floor(storyboardHeight / height);
|
||||
const thumbnails: TimelineThumbnail[] = [];
|
||||
|
||||
const totalThumbnails = storyboardCount * count;
|
||||
const videoDurationMs = videoLength * 1000;
|
||||
|
||||
let globalIndex = 0;
|
||||
|
||||
for (let sheetIndex = 0; sheetIndex < storyboardCount; sheetIndex++) {
|
||||
const url = templateUrl.replace('M$M', `M${sheetIndex}`);
|
||||
|
||||
// Preload all images.
|
||||
imageCache.load(url);
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const localIndex = i % (cols * rows);
|
||||
const row = Math.floor(localIndex / cols);
|
||||
|
||||
if (row >= rows) break;
|
||||
|
||||
const time = Math.floor((globalIndex / totalThumbnails) * videoDurationMs);
|
||||
|
||||
thumbnails.push({
|
||||
url,
|
||||
time,
|
||||
width,
|
||||
height,
|
||||
index: globalIndex
|
||||
});
|
||||
|
||||
globalIndex++;
|
||||
}
|
||||
}
|
||||
|
||||
return thumbnails;
|
||||
}
|
||||
|
||||
export async function drawTimelineThumbnail(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
imageCache: ImageCache,
|
||||
thumbnails: TimelineThumbnail[],
|
||||
time: number
|
||||
): Promise<void> {
|
||||
if (!thumbnails.length) return;
|
||||
|
||||
const timeInMs = time * 1000;
|
||||
|
||||
const closest = thumbnails.reduce((prev, curr) =>
|
||||
Math.abs(curr.time - timeInMs) < Math.abs(prev.time - timeInMs) ? curr : prev
|
||||
);
|
||||
|
||||
if (!closest.url) return;
|
||||
|
||||
const img = await imageCache.load(closest.url);
|
||||
if (!img) {
|
||||
return;
|
||||
}
|
||||
|
||||
const sheetWidth = img.width;
|
||||
const sheetHeight = img.height;
|
||||
|
||||
const cols = Math.floor(sheetWidth / closest.width);
|
||||
const rows = Math.floor(sheetHeight / closest.height);
|
||||
const thumbnailsPerSheet = cols * rows;
|
||||
|
||||
const indexInSheet = closest.index % thumbnailsPerSheet;
|
||||
const thumbX = (indexInSheet % cols) * closest.width;
|
||||
const thumbY = Math.floor(indexInSheet / cols) * closest.height;
|
||||
|
||||
ctx.clearRect(0, 0, closest.width, closest.height);
|
||||
ctx.drawImage(
|
||||
img,
|
||||
thumbX,
|
||||
thumbY,
|
||||
closest.width,
|
||||
closest.height,
|
||||
0,
|
||||
0,
|
||||
closest.width,
|
||||
closest.height
|
||||
);
|
||||
}
|
||||
@@ -66,7 +66,7 @@
|
||||
|
||||
playerState.subscribe(() => {
|
||||
requestAnimationFrame(() => resetScroll());
|
||||
})
|
||||
});
|
||||
|
||||
interfaceAmoledTheme.subscribe(async () => {
|
||||
setAmoledTheme();
|
||||
@@ -245,6 +245,7 @@
|
||||
if (isLoggedIn) {
|
||||
loadNotifications().catch(() => authStore.set(null));
|
||||
}
|
||||
|
||||
resetScroll();
|
||||
});
|
||||
|
||||
@@ -351,6 +352,13 @@
|
||||
{#if isLoggedIn}
|
||||
<button class="circle large transparent" onclick={() => ui('#dialog-notifications')}
|
||||
><i>notifications</i>
|
||||
<div class="badge">
|
||||
{#if notifications.length > 99}
|
||||
99+
|
||||
{:else}
|
||||
{notifications.length}
|
||||
{/if}
|
||||
</div>
|
||||
<div class="tooltip bottom">{$_('layout.notifications')}</div>
|
||||
</button>
|
||||
{/if}
|
||||
@@ -401,7 +409,7 @@
|
||||
{:else}
|
||||
{#each notifications as notification (notification)}
|
||||
<article class="no-padding border">
|
||||
<Thumbnail video={notification}></Thumbnail>
|
||||
<Thumbnail video={notification} sideways={true}></Thumbnail>
|
||||
</article>
|
||||
{/each}
|
||||
{/if}
|
||||
@@ -522,6 +530,10 @@
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
#dialog-notifications {
|
||||
width: 500px;
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 993px) {
|
||||
.pip {
|
||||
width: 100%;
|
||||
@@ -529,6 +541,10 @@
|
||||
right: 0px;
|
||||
}
|
||||
|
||||
#dialog-notifications {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.pip > .pip-info > .player {
|
||||
height: 80px;
|
||||
width: 150px;
|
||||
|
||||
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.13.15"
|
||||
LATEST_VERSION = "1.13.16"
|
||||
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