Merge pull request #315 from Materialious/update/1.1.0

Started 1.1.0
This commit is contained in:
Ward
2024-05-19 06:18:10 +12:00
committed by GitHub
21 changed files with 248 additions and 79 deletions
+3
View File
@@ -59,6 +59,9 @@ Due to Google's policies on custom YouTube frontends, Materialious isn't availab
## Player
![Preview of player](./previews/player-preview.png)
## Transcript
![Preview of player transcript](./previews/transcript-preview.png)
## Settings
![Preview of settings](./previews/setting-preview.png)
+7 -1
View File
@@ -159,7 +159,13 @@ Materialious allows you to overwrite the default values using `VITE_DEFAULT_SETT
{
// Set to true to enable dark mode, or false to disable it.
"darkMode": true,
// Force case for Videos/Playlists etc.
"forceCase": "uppercase | lowercase | sentence case | title case",
// Any ISO 3166 country code
"region": "US",
// Specifies the theme color in hexadecimal format (e.g., #ff0000 for red).
"themeColor": "#ff0000",
+2 -2
View File
@@ -7,8 +7,8 @@ android {
applicationId "us.materialio.app"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 11
versionName "1.0.11"
versionCode 12
versionName "1.1.0"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "Materialious",
"version": "1.0.11",
"version": "1.1.0",
"description": "Modern material design for Invidious.",
"author": {
"name": "Ward Pearce",
+11 -3
View File
@@ -1,12 +1,12 @@
{
"name": "materialious",
"version": "1.0.11",
"version": "1.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "materialious",
"version": "1.0.11",
"version": "1.1.0",
"dependencies": {
"@capacitor-community/electron": "^5.0.1",
"@capacitor/android": "^6.0.0",
@@ -16,9 +16,11 @@
"@capacitor/core": "^6.0.0",
"beercss": "^3.5.5",
"fuse.js": "^7.0.0",
"he": "^1.2.0",
"human-number": "^2.0.4",
"iso-3166": "^4.3.0",
"material-dynamic-colors": "^1.1.0",
"media-captions": "^1.0.3",
"media-icons": "^0.10.0",
"mousetrap": "^1.6.5",
"peerjs": "^1.5.3",
@@ -36,6 +38,7 @@
"@sveltejs/vite-plugin-svelte": "^3.1.0",
"@types/eslint": "^8.56.7",
"@types/event-source-polyfill": "^1.0.5",
"@types/he": "^1.2.3",
"@types/human-number": "^1.0.2",
"@types/mousetrap": "^1.6.15",
"@typescript-eslint/eslint-plugin": "^7.8.0",
@@ -3837,6 +3840,12 @@
"@types/node": "*"
}
},
"node_modules/@types/he": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@types/he/-/he-1.2.3.tgz",
"integrity": "sha512-q67/qwlxblDzEDvzHhVkwc1gzVWxaNxeyHUBF4xElrvjL11O+Ytze+1fGpBHlr/H9myiBUaUXNnNPmBHxxfAcA==",
"dev": true
},
"node_modules/@types/human-number": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@types/human-number/-/human-number-1.0.2.tgz",
@@ -7258,7 +7267,6 @@
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz",
"integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==",
"dev": true,
"bin": {
"he": "bin/he"
}
+4 -1
View File
@@ -1,6 +1,6 @@
{
"name": "materialious",
"version": "1.0.11",
"version": "1.1.0",
"private": true,
"scripts": {
"dev": "vite dev",
@@ -19,6 +19,7 @@
"@sveltejs/vite-plugin-svelte": "^3.1.0",
"@types/eslint": "^8.56.7",
"@types/event-source-polyfill": "^1.0.5",
"@types/he": "^1.2.3",
"@types/human-number": "^1.0.2",
"@types/mousetrap": "^1.6.15",
"@typescript-eslint/eslint-plugin": "^7.8.0",
@@ -45,9 +46,11 @@
"@capacitor/core": "^6.0.0",
"beercss": "^3.5.5",
"fuse.js": "^7.0.0",
"he": "^1.2.0",
"human-number": "^2.0.4",
"iso-3166": "^4.3.0",
"material-dynamic-colors": "^1.1.0",
"media-captions": "^1.0.3",
"media-icons": "^0.10.0",
"mousetrap": "^1.6.5",
"peerjs": "^1.5.3",
-6
View File
@@ -53,12 +53,6 @@
page.subscribe((pageUpdate) => loadTimeFromUrl(pageUpdate));
export function seekTo(time: number) {
if (typeof player !== 'undefined') {
player.currentTime = time;
}
}
const proxyVideos = get(playerProxyVideosStore);
onMount(async () => {
@@ -2,7 +2,7 @@
import { onMount } from 'svelte';
import { _ } from 'svelte-i18n';
import type { Playlist } from './Api/model';
import { getBestThumbnail, getLetterCaseClass, truncate } from './misc';
import { getBestThumbnail, letterCase, truncate } from './misc';
export let playlist: Playlist;
export let disabled: boolean = false;
@@ -66,7 +66,7 @@
<nav class="no-margin">
<div class="max">
<a class:link-disabled={disabled} href={playlistLink}
><div class={`bold ${getLetterCaseClass()}`}>{truncate(playlist.title)}</div></a
><div class="bold">{letterCase(truncate(playlist.title))}</div></a
>
<div>
<a class:link-disabled={playlist.authorId === null} href={`/channel/${playlist.authorId}`}
+4 -2
View File
@@ -4,7 +4,7 @@
import { iso31661 } from 'iso-3166';
import { _ } from 'svelte-i18n';
import { get } from 'svelte/store';
import { ensureNoTrailingSlash, titleCases, truncate } from './misc';
import { ensureNoTrailingSlash, titleCase, titleCases, truncate } from './misc';
import {
authStore,
darkModeStore,
@@ -190,7 +190,9 @@
>
<option selected={$interfaceForceCase === null} value={null}>Default</option>
{#each titleCases as caseType}
<option selected={$interfaceForceCase === caseType} value={caseType}>{caseType}</option>
<option selected={$interfaceForceCase === caseType} value={caseType}
>{titleCase(`${caseType}`)}</option
>
{/each}
</select>
<label for="case">{$_('letterCase')}</label>
+4 -14
View File
@@ -5,13 +5,7 @@
import { getDeArrow, getThumbnail, getVideo, getVideoProgress } from './Api';
import type { Notification, PlaylistPageVideo, Video, VideoBase, VideoPlay } from './Api/model';
import ShareVideo from './ShareVideo.svelte';
import {
cleanNumber,
getBestThumbnail,
getLetterCaseClass,
proxyVideoUrl,
videoLength
} from './misc';
import { cleanNumber, getBestThumbnail, letterCase, proxyVideoUrl, videoLength } from './misc';
import type { PlayerEvents } from './player';
import {
authStore,
@@ -298,14 +292,11 @@
href={watchUrl.toString()}
data-sveltekit-preload-data="off"
style="display: flex; justify-content:flex-start; position: absolute; width: 100%;"
><div
class={`${getLetterCaseClass()} bold`}
style="white-space: nowrap; overflow: hidden;text-overflow: ellipsis;"
>
{video.title}
><div class="bold" style="white-space: nowrap; overflow: hidden;text-overflow: ellipsis;">
{letterCase(video.title)}
</div>
<div class={`tooltip bottom small ${getLetterCaseClass()}`}>{video.title}</div>
<div class="tooltip bottom small">{letterCase(video.title)}</div>
</a>
<div style="margin-top: 1.4em;">
<a href={`/channel/${video.authorId}`}>{video.author}</a
@@ -341,7 +332,6 @@
.thumbnail {
width: 100%;
overflow: hidden;
max-height: 160px;
}
.thumbnail img {
+139
View File
@@ -0,0 +1,139 @@
<script lang="ts">
import Fuse from 'fuse.js';
import { VTTCue, parseText, type ParsedCaptionsResult } from 'media-captions';
import { _ } from 'svelte-i18n';
import { get } from 'svelte/store';
import type { MediaTimeUpdateEvent } from 'vidstack';
import type { MediaPlayerElement } from 'vidstack/elements';
import type { VideoPlay } from './Api/model';
import { decodeHtmlCharCodes, videoLength } from './misc';
import { instanceStore } from './store';
export let video: VideoPlay;
export let player: MediaPlayerElement;
let url: string | null = null;
let transcript: ParsedCaptionsResult | null = null;
let transcriptCues: VTTCue[] = [];
let isLoading = false;
let currentTime = 0;
let search: string = '';
player.addEventListener('time-update', (event: MediaTimeUpdateEvent) => {
currentTime = event.detail.currentTime;
const currentTranscriptLine = document.querySelector(
'.transcript-line.secondary-container'
) as HTMLElement;
const transcriptScrollable = document.getElementById('transcript');
if (currentTranscriptLine && transcriptScrollable) {
transcriptScrollable.scrollTop =
currentTranscriptLine.offsetTop - transcriptScrollable.offsetTop - 300;
}
});
async function loadTranscript() {
if (!url) {
transcript = null;
return;
}
isLoading = true;
transcript = null;
const resp = await fetch(`${get(instanceStore)}${url}`);
transcript = await parseText(await resp.text(), { strict: false });
transcriptCues = transcript.cues;
isLoading = false;
}
function searchTranscript() {
if (!transcript) return;
if (search.trim() === '') {
transcriptCues = transcript.cues;
return;
}
const fuse = new Fuse(transcript.cues, {
keys: ['text']
});
transcriptCues = fuse.search(search).map((item) => item.item);
}
</script>
<article class="scroll no-padding" style="height: 75vh;" id="transcript">
<article class="no-elevate" style="position: sticky; top: 0; z-index: 3;">
<h6>{$_('transcript')}</h6>
<div class="field label suffix border">
<select bind:value={url} on:change={loadTranscript} name="captions">
<option selected={true} value={null}>{$_('selectLang')}</option>
{#each video.captions as caption}
<option value={caption.url}>{caption.label}</option>
{/each}
</select>
<label for="captions">{$_('language')}</label>
<i>arrow_drop_down</i>
</div>
<div class="max field round suffix prefix small no-margin surface-variant">
<i class="front">search</i><input
bind:value={search}
on:input={searchTranscript}
type="text"
placeholder={$_('searchPlaceholder')}
/>
</div>
<div class="space"></div>
<div class="divider"></div>
</article>
{#if isLoading}
<nav class="center-align">
<progress class="circle"></progress>
</nav>
{:else if transcript}
{#if transcript.cues.length > 0}
{#if transcriptCues.length > 0}
{#each transcriptCues as cue}
<div
class="transcript-line"
id={`transcript-line-${cue.startTime}`}
on:click={() => (player.currentTime = cue.startTime)}
class:secondary-container={currentTime >= cue.startTime && currentTime <= cue.endTime}
>
<p class="chip no-margin">{videoLength(cue.startTime)}</p>
<p class="transcript-text">{decodeHtmlCharCodes(cue.text)}</p>
</div>
{/each}
{:else}
<nav class="center-align">
<h6>{$_('noResult')}</h6>
</nav>
{/if}
{:else}
<nav class="center-align">
<h6>{$_('noCaptionData')}</h6>
</nav>
{/if}
{/if}
</article>
<style>
.transcript-line {
display: flex;
cursor: pointer;
align-items: center;
padding: 0.5em;
margin: 1em;
}
.transcript-text {
margin-left: 1em;
}
.transcript-line .chip {
padding: 0 1.5em;
}
</style>
@@ -13,6 +13,11 @@
"skipping": "Skipping",
"replies": "replies",
"region": "Region",
"noResult": "No results",
"language": "Language",
"transcript": "Transcript",
"noCaptionData": "Captions don't have any data.",
"selectLang": "Select language",
"letterCase": "Letter case for titles",
"hideReplies": "Hide replies",
"hideVideo": "Hide video",
+42 -7
View File
@@ -1,5 +1,6 @@
import { pushState } from '$app/navigation';
import { page } from '$app/stores';
import he from 'he';
import humanNumber from 'human-number';
import type Peer from 'peerjs';
import { get } from 'svelte/store';
@@ -15,13 +16,48 @@ export const titleCases: TitleCase[] = [
'sentence case'
];
export function getLetterCaseClass(): string {
export function letterCase(text: string): string {
const casing = get(interfaceForceCase);
if (!casing) return '';
if (!casing) return text;
return casing.toLowerCase().replaceAll(' ', '-');
switch (casing) {
case 'lowercase':
return text.toLowerCase();
case 'uppercase':
text.toUpperCase();
case 'sentence case':
return sentenceCase(text);
default:
return titleCase(text);
}
}
export function sentenceCase(text: string): string {
let sentences: string[] = text.match(/[^.!?]*[.!?]*/g) || [];
let casedSentences: string[] = sentences.map(sentence => {
sentence = sentence.trim();
if (sentence.length === 0) return '';
return sentence.charAt(0).toUpperCase() + sentence.slice(1).toLowerCase();
});
return casedSentences.join(' ');
}
export function titleCase(text: string): string {
if (!text) return '';
let words: string[] = text.split(/\s+/);
let titleCasedWords: string[] = words.map(word => {
if (word.length === 0) return '';
return word.charAt(0).toUpperCase() + word.slice(1).toLowerCase();
});
return titleCasedWords.join(' ');
}
export function truncate(value: string, maxLength: number = 50): string {
return value.length > maxLength ? `${value.substring(0, maxLength)}...` : value;
}
@@ -40,7 +76,7 @@ export function cleanNumber(number: number): string {
export function videoLength(lengthSeconds: number): string {
const hours = Math.floor(lengthSeconds / 3600);
let minutes: number | string = Math.floor((lengthSeconds % 3600) / 60);
let seconds: number | string = lengthSeconds % 60;
let seconds: number | string = Math.round(lengthSeconds % 60);
if (minutes < 10) {
minutes = `0${minutes}`;
@@ -63,9 +99,8 @@ export interface PhasedDescription {
}
export function decodeHtmlCharCodes(str: string): string {
return str.replace(/(&#(\d+);)/g, function (match, capture, charCode) {
return String.fromCharCode(charCode);
});
const { decode } = he;
return decode(str);
}
export function phaseDescription(content: string): PhasedDescription {
@@ -36,7 +36,7 @@
<div class="space"></div>
<div class="max field round suffix prefix small no-margin white black-text">
<div class="max field round suffix prefix small no-margin surface-variant">
<i class="front">search</i><input
bind:value={search}
on:input={searchSubs}
@@ -14,10 +14,11 @@
import Player from '$lib/Player.svelte';
import ShareVideo from '$lib/ShareVideo.svelte';
import Thumbnail from '$lib/Thumbnail.svelte';
import Transcript from '$lib/Transcript.svelte';
import {
cleanNumber,
getBestThumbnail,
getLetterCaseClass,
letterCase,
numberWithCommas,
unsafeRandomItem
} from '$lib/misc';
@@ -66,9 +67,10 @@
let theatreMode = get(playerTheatreModeByDefaultStore);
let audioMode = get(playerListenByDefaultStore);
let seekTo: (time: number) => void;
let player: MediaPlayerElement;
let showTranscript = false;
playlistSettingsStore.subscribe((playlistSetting) => {
if (!data.playlistId) return;
if (data.playlistId in playlistSetting) {
@@ -398,23 +400,17 @@
<div class="space"></div>
<div class="grid">
<div class={`s12 m12 l${theatreMode ? '12' : '10'}`}>
<div class={`s12 m12 l${theatreMode ? '12' : '9'}`}>
<div style="display: flex;justify-content: center;">
{#key data.video.videoId}
<div
style="max-height: 80vh;max-width: calc(80vh * 16 / 9);overflow: hidden;position: relative;flex: 1;"
>
<Player
{data}
{audioMode}
isSyncing={$syncPartyPeerStore !== null}
bind:seekTo
bind:player
/>
<Player {data} {audioMode} isSyncing={$syncPartyPeerStore !== null} bind:player />
</div>
{/key}
</div>
<h5 class={getLetterCaseClass()}>{data.video.title}</h5>
<h5>{letterCase(data.video.title)}</h5>
<div class="grid no-padding">
<div class="s12 m12 l5">
@@ -484,6 +480,15 @@
<i>width_wide</i>
<div class="tooltip">{$_('player.theatreMode')}</div>
</button>
<button
on:click={() => ((showTranscript = !showTranscript), (theatreMode = false))}
class:border={!showTranscript}
>
<i>description</i>
<div class="tooltip">
{$_('transcript')}
</div>
</button>
<button class="border"
><i>share</i>
<div class="tooltip">
@@ -568,7 +573,7 @@
{#if data.content.timestamps.length > 0}
<h6 style="margin-bottom: .3em;">Chapters</h6>
{#each data.content.timestamps as timestamp}
<button on:click={() => seekTo(timestamp.time)} class="timestamps"
<button on:click={() => (player.currentTime = timestamp.time)} class="timestamps"
>{timestamp.timePretty}
{#if !timestamp.title.startsWith('-')}
-
@@ -617,7 +622,10 @@
{/if}
</div>
{#if !theatreMode}
<div class="s12 m12 l2">
<div class="s12 m12 l3">
{#if player && showTranscript}
<Transcript bind:player video={data.video} />
{/if}
{#if !playlist}
{#if data.video.recommendedVideos}
{#each data.video.recommendedVideos as recommendedVideo}
@@ -629,7 +637,7 @@
{/each}
{/if}
{:else}
<article style="height: 75vh; position: relative;" id="playlist" class="scroll no-padding">
<article style="height: 85vh; position: relative;" id="playlist" class="scroll no-padding">
<article class="no-elevate" style="position: sticky; top: 0; z-index: 3;">
<h6>{playlist.title}</h6>
<p>
@@ -700,10 +708,6 @@
--plyr-color-main: var(--primary);
}
.grid {
padding: 1em 10em;
}
.timestamps {
margin-left: 0;
margin-bottom: 0.4em;
-20
View File
@@ -39,26 +39,6 @@ main.root {
}
.lowercase {
text-transform: lowercase;
}
.uppercase {
text-transform: uppercase;
}
.title-case {
text-transform: capitalize;
}
.sentence-case {
text-transform: lowercase;
}
.sentence-case::first-letter {
text-transform: uppercase !important;
}
@media screen and (max-width: 650px) {
dialog.right {
width: 100% !important;
Binary file not shown.

Before

Width:  |  Height:  |  Size: 684 KiB

After

Width:  |  Height:  |  Size: 995 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 MiB

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 706 KiB

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

+1 -1
View File
@@ -5,7 +5,7 @@ import json
import os
import re
LATEST_VERSION = "1.0.11"
LATEST_VERSION = "1.1.0"
WORKING_DIR = os.path.join(os.path.dirname(os.path.realpath(__file__)), "materialious")
ROOT_PACKAGE = os.path.join(WORKING_DIR, "package.json")