Merge pull request #1567 from Materialious/update/1.16.19

Update/1.16.19
This commit is contained in:
Ward
2026-03-14 12:33:26 +13:00
committed by GitHub
30 changed files with 743 additions and 406 deletions
+2 -2
View File
@@ -7,8 +7,8 @@ android {
applicationId "us.materialio.app"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 235
versionName "1.16.18"
versionCode 236
versionName "1.16.19"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
@@ -77,7 +77,11 @@
<release version="1.16.18" date="2026-3-11">
<release version="1.16.19" date="2026-3-13">
<url>https://github.com/Materialious/Materialious/releases/tag/1.16.19</url>
</release>
<release version="1.16.18" date="2026-3-11">
<url>https://github.com/Materialious/Materialious/releases/tag/1.16.18</url>
</release>
<release version="1.16.17" date="2026-3-09">
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "Materialious",
"version": "1.16.18",
"version": "1.16.19",
"description": "Modern material design for YouTube and Invidious.",
"author": {
"name": "Ward Pearce",
+3 -3
View File
@@ -9140,9 +9140,9 @@
}
},
"node_modules/devalue": {
"version": "5.6.3",
"resolved": "https://registry.npmjs.org/devalue/-/devalue-5.6.3.tgz",
"integrity": "sha512-nc7XjUU/2Lb+SvEFVGcWLiKkzfw8+qHI7zn8WYXKkLMgfGSHbgCEaR6bJpev8Cm6Rmrb19Gfd/tZvGqx9is3wg==",
"version": "5.6.4",
"resolved": "https://registry.npmjs.org/devalue/-/devalue-5.6.4.tgz",
"integrity": "sha512-Gp6rDldRsFh/7XuouDbxMH3Mx8GMCcgzIb1pDTvNyn8pZGQ22u+Wa+lGV9dQCltFQ7uVw0MhRyb8XDskNFOReA==",
"license": "MIT"
},
"node_modules/dexie": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "materialious",
"version": "1.16.18",
"version": "1.16.19",
"private": true,
"scripts": {
"dev": "npm run patch:github && vite dev",
+6 -3
View File
@@ -1,7 +1,8 @@
import sodium from 'libsodium-wrappers-sumo';
import { decryptWithMasterKey, encryptWithMasterKey, getRawKey, getSecureHash } from './encryption';
import type { VideoPlay, VideoWatchHistory } from '../model';
import type { VideoWatchHistory } from '../model';
import { getBestThumbnail } from '$lib/images';
import type { ThumbnailVideo } from '$lib/thumbnail';
export async function updateWatchHistoryBackend(videoId: string, progress: number) {
await sodium.ready;
@@ -128,7 +129,7 @@ export async function deleteWatchHistoryItemBackend(videoId: string) {
await fetch(`/api/user/history/${videoHash}`, { method: 'DELETE' });
}
export async function saveWatchHistoryBackend(video: VideoPlay, progress: number = 0) {
export async function saveWatchHistoryBackend(video: ThumbnailVideo, progress: number = 0) {
await sodium.ready;
const rawKey = await getRawKey();
if (!rawKey) return;
@@ -137,7 +138,9 @@ export async function saveWatchHistoryBackend(video: VideoPlay, progress: number
const title = await encryptWithMasterKey(video.title);
const author = await encryptWithMasterKey(video.author);
const thumbnail = await encryptWithMasterKey(getBestThumbnail(video.videoThumbnails));
const thumbnail = await encryptWithMasterKey(
'videoThumbnails' in video ? getBestThumbnail(video.videoThumbnails) : video.thumbnail
);
const videoId = await encryptWithMasterKey(video.videoId);
await fetch('/api/user/history', {
+1 -1
View File
@@ -5,7 +5,7 @@ const videoIds: string[] = [];
const pendingResolves = new Map<string, (result: VideoWatchHistory | undefined) => void>();
let timeout: ReturnType<typeof setTimeout> | null = null;
const DEBOUNCE_MS = 2000;
const DEBOUNCE_MS = 1000;
const BATCH_SIZE = 100;
async function processBatches(): Promise<void> {
+4 -2
View File
@@ -82,6 +82,7 @@ import {
} from './backend/history';
import { localDb } from '$lib/dexie';
import { getBestThumbnail } from '$lib/images';
import type { ThumbnailVideo } from '$lib/thumbnail';
export async function getPopular(fetchOptions?: RequestInit): Promise<Video[]> {
// Doesn't exist in YTjs.
@@ -368,7 +369,7 @@ export async function updateWatchHistory(
await localDb.watchHistory.update({ videoId }, { progress, watched: new Date() });
}
export async function saveWatchHistory(video: VideoPlay, progress: number = 0) {
export async function saveWatchHistory(video: ThumbnailVideo, progress: number = 0) {
if (!get(watchHistoryEnabledStore)) return;
if (isOwnBackend()?.internalAuth && get(rawMasterKeyStore)) {
@@ -384,7 +385,8 @@ export async function saveWatchHistory(video: VideoPlay, progress: number = 0) {
progress,
id: video.videoId,
title: video.title,
thumbnail: getBestThumbnail(video.videoThumbnails),
thumbnail:
'videoThumbnails' in video ? getBestThumbnail(video.videoThumbnails) : video.thumbnail,
videoId: video.videoId,
type: 'historyVideo'
});
@@ -8,13 +8,11 @@ export function buildPath(path: string): URL {
export function setLocale(url: URL): URL {
const region = get(interfaceRegionStore);
if (region) {
url.searchParams.set('region', region);
}
const locale = getUserLocale();
url.searchParams.set('hl', locale);
return url;
@@ -0,0 +1,79 @@
<script lang="ts">
import { _ } from '$lib/i18n';
import { mergeAttrs } from 'melt';
import { Combobox } from 'melt/builders';
type ComboOption = { label: string; value: any };
let {
options,
label = undefined,
defaultValue = undefined,
onChange = undefined
}: {
options: ComboOption[];
label?: string;
defaultValue?: string | null;
onChange?: (value: string) => void;
} = $props();
let initialValueChange = true;
const combobox = new Combobox<string>({
onValueChange: (value) => {
if (initialValueChange && defaultValue) {
initialValueChange = false;
return;
}
if (value) onChange?.(value);
}
});
if (defaultValue) combobox.select(defaultValue);
let valueToLabel: Record<string, string> = {};
setValuesToLabel();
function setValuesToLabel() {
for (const option of options) {
valueToLabel[option.value] = option.label;
}
}
$effect(() => {
setValuesToLabel();
});
const filtered = $derived.by(() => {
if (!combobox.touched) return options;
return options.filter((o) =>
o.label.toLowerCase().includes(combobox.inputValue.trim().toLowerCase())
);
});
</script>
<div class="field suffix surface-container-highest" class:label={typeof label === 'string'}>
<input
{...mergeAttrs(combobox.input, {
value: valueToLabel[combobox.input.value]
})}
/>
{#if label}
<label {...combobox.label}>{label}</label>
{/if}
<i>keyboard_arrow_down</i>
<div {...combobox.content}>
{#each filtered as option (option)}
<div {...combobox.getOption(option.value)}>
{option.label}
{#if combobox.isSelected(option.value)}
<i>check</i>
{/if}
</div>
{:else}
<span>{$_('noResult')}</span>
{/each}
</div>
</div>
+13 -5
View File
@@ -17,11 +17,17 @@
let {
shares,
includePromptText = undefined,
iconOnly = true
iconOnly = true,
classes = 'surface-container-highest',
menuClasses = 'mobile no-wrap',
onShare = undefined
}: {
shares: ShareLink[];
includePromptText?: string;
iconOnly: boolean;
classes?: string;
menuClasses?: string;
onShare?: (value: string) => void;
} = $props();
const shareBase = {
@@ -34,19 +40,21 @@
let shareButtonElement: HTMLElement | undefined = $state();
let includePrompt = $state(false);
async function onShare(share: ShareLink) {
async function onShareValue(share: ShareLink) {
const url = new URL(`${shareBase[share.type]}${share.path}`);
if (share.param && includePrompt)
url.searchParams.append(share.param.key, share.param.value().toString());
onShare?.(url.toString());
await shareURL(url.toString());
shareButtonElement?.click();
}
</script>
<button bind:this={shareButtonElement} class="surface-container-highest">
<button bind:this={shareButtonElement} class={classes}>
<i>share</i>
{#if !iconOnly}
{$_('player.share.title')}
@@ -54,7 +62,7 @@
<div class="tooltip">
{$_('player.share.title')}
</div>
<menu class="no-wrap mobile" data-ui="#share-menu" id="share-menu">
<menu class={menuClasses} data-ui="#share-menu" id="share-menu">
{#if includePromptText}
<li class="row">
<label class="switch">
@@ -73,7 +81,7 @@
class="row"
role="presentation"
onclick={() => {
onShare(share);
onShareValue(share);
}}
>
<div class="min">
@@ -1,5 +1,5 @@
<script lang="ts">
import { feedLastItemId, isAndroidTvStore } from '$lib/store';
import { feedLastItemId, filterContentListStore, isAndroidTvStore } from '$lib/store';
import ContentColumn from '$lib/components/layout/ContentColumn.svelte';
import { onMount } from 'svelte';
import Thumbnail from '$lib/components/thumbnail/VideoThumbnail.svelte';
@@ -68,35 +68,43 @@
<NoResults />
{/if}
<div class="grid" {...spatialMenu.root}>
{#each items as item, index (index)}
{#if !isItemFiltered(item)}
{@const uniqueItemId = extractUniqueId(item)}
{@const spatialItem = spatialMenu.getItem(item, { onSelect: () => goToItem(uniqueItemId) })}
<ContentColumn>
<article
{...mergeAttrs(spatialItem.attrs, {
onclick: () => goToItem(uniqueItemId),
id: uniqueItemId
})}
class="no-padding item-select border"
class:item-select-focused={!isMobile() && spatialItem.highlighted}
style="height: 100%;"
>
{#if item.type === 'video' || item.type === 'shortVideo' || item.type === 'stream' || item.type === 'historyVideo'}
{#key item.videoId}
<Thumbnail video={item} {playlistId} />
{/key}
{:else if item.type === 'channel'}
<ChannelThumbnail channel={item} />
{:else if item.type === 'playlist'}
<PlaylistThumbnail playlist={item} />
{:else if item.type === 'hashtag'}
<HashtagThumbnail hashtag={item} />
{/if}
</article>
</ContentColumn>
{/if}
{/each}
{#key $filterContentListStore?.length}
{#each items as item, index (index)}
{#if !isItemFiltered(item)}
{@const uniqueItemId = extractUniqueId(item)}
{@const spatialItem = spatialMenu.getItem(item, {
onSelect: () => {
if ($isAndroidTvStore) goToItem(uniqueItemId);
}
})}
<ContentColumn>
<article
{...mergeAttrs(spatialItem.attrs, {
onclick: () => {
if ($isAndroidTvStore) goToItem(uniqueItemId);
},
id: uniqueItemId
})}
class="no-padding item-select border"
class:item-select-focused={!isMobile() && spatialItem.highlighted}
style="height: 100%;"
>
{#if item.type === 'video' || item.type === 'shortVideo' || item.type === 'stream' || item.type === 'historyVideo'}
{#key item.videoId}
<Thumbnail video={item} {playlistId} />
{/key}
{:else if item.type === 'channel'}
<ChannelThumbnail channel={item} />
{:else if item.type === 'playlist'}
<PlaylistThumbnail playlist={item} />
{:else if item.type === 'hashtag'}
<HashtagThumbnail hashtag={item} />
{/if}
</article>
</ContentColumn>
{/if}
{/each}
{/key}
</div>
</div>
@@ -12,6 +12,7 @@
import { addToast } from '../Toast.svelte';
import { Clipboard } from '@capacitor/clipboard';
import { downloadStringAsFile } from '$lib/misc';
import ComboBox from '../ComboBox.svelte';
let remoteFilterListUrl: string = $state($filterContentUrlStore ?? '');
let remoteError: string = $state('');
@@ -165,17 +166,21 @@
<article class="no-margin surface-container-high">
<div class="grid">
<div class="s12 m6 l6">
<div class="label field suffix surface-container-highest">
<select name="content-type">
{#each filterTypes as filterType (filterType)}
<option selected={filterType === filter.type} value={filterType}
>{titleCase(filterType)}</option
>
{/each}
</select>
<label for="content-type">{$_('layout.filter.contentType')}</label>
<i>arrow_drop_down</i>
</div>
<ComboBox
label={$_('layout.filter.contentType')}
defaultValue={filter.type}
options={filterTypes.map((filterType) => {
return {
label: titleCase(filterType),
value: filterType
};
})}
onChange={(value) => {
filter.type = value as 'channel' | 'video';
filterContentListStore.set(contentFilters);
filterContentUrlAutoUpdateStore.set(false);
}}
/>
</div>
<div class="s12 m6 l6 right-align">
<button onclick={() => removeFilter(filter)} class="surface-container-highest">
@@ -203,89 +208,84 @@
</button>
</nav>
<div class="label field suffix surface-container-highest">
<select
onchange={(event: Event & { currentTarget: HTMLSelectElement }) => {
condition.field = event.currentTarget.value;
<div class="field label surface-container-highest">
<input
oninput={(event: Event & { currentTarget: HTMLInputElement }) => {
condition.note = event.currentTarget.value;
filterContentListStore.set(contentFilters);
filterContentUrlAutoUpdateStore.set(false);
}}
name="field"
>
{#each Object.keys(schema[filter.type]) as key (key)}
<option value={key} selected={condition.field === key}
>{camelCaseToHuman(key)}</option
>
{/each}
</select>
<label for="field">{$_('layout.filter.field')}</label>
<i>arrow_drop_down</i>
name="note"
type="text"
value={condition.note}
/>
<label for="note">Note</label>
</div>
<div class="field label suffix surface-container-highest">
<select
onchange={(event: Event & { currentTarget: HTMLSelectElement }) => {
condition.operator = event.currentTarget.value as z.infer<
typeof zFilterOperatorEnum
>;
filterContentListStore.set(contentFilters);
filterContentUrlAutoUpdateStore.set(false);
}}
name="operator"
>
{#each zFilterOperatorEnum.options as operator (operator)}
<option selected={operator === condition.operator} value={operator}
>{operator}</option
>
{/each}
</select>
<label for="operator">{$_('layout.filter.operator')}</label>
<i>arrow_drop_down</i>
</div>
<ComboBox
label={$_('layout.filter.field')}
defaultValue={condition.field}
options={Object.keys(schema[filter.type]).map((key) => {
return {
label: camelCaseToHuman(key),
value: key
};
})}
onChange={(value) => {
condition.field = value;
filterContentListStore.set(contentFilters);
filterContentUrlAutoUpdateStore.set(false);
}}
/>
<ComboBox
label={$_('layout.filter.operator')}
defaultValue={condition.operator}
options={zFilterOperatorEnum.options.map((operator) => {
return {
label: operator,
value: operator
};
})}
onChange={(value) => {
condition.operator = value as z.infer<typeof zFilterOperatorEnum>;
filterContentListStore.set(contentFilters);
filterContentUrlAutoUpdateStore.set(false);
}}
/>
{#each condition.values as conditionValue, index (index)}
<nav>
{#if schema[filter.type][condition.field] === 'boolean'}
<div class="field label suffix surface-container-highest max">
<select
onchange={(event: Event & { currentTarget: HTMLSelectElement }) => {
condition.values[index] = event.currentTarget.value;
filterContentListStore.set(contentFilters);
filterContentUrlAutoUpdateStore.set(false);
}}
name="boolean-options"
>
<option value="" disabled selected
>{$_('layout.filter.optionPlaceholder')}</option
>
<option selected={conditionValue === 'true'} value="true">true</option>
<option selected={conditionValue === 'false'} value="false">false</option>
</select>
<label for="boolean-options">Value</label>
<i>arrow_drop_down</i>
</div>
<ComboBox
label="Value"
defaultValue={conditionValue as string}
options={[
{ label: 'True', value: 'true' },
{ label: 'False', value: 'false' }
]}
onChange={(value) => {
condition.values[index] = value;
filterContentListStore.set(contentFilters);
filterContentUrlAutoUpdateStore.set(false);
}}
/>
{:else if Array.isArray(schema[filter.type][condition.field])}
<div class="field label suffix surface-container-highest max">
<select
onchange={(event: Event & { currentTarget: HTMLSelectElement }) => {
condition.values[index] = event.currentTarget.value;
filterContentListStore.set(contentFilters);
filterContentUrlAutoUpdateStore.set(false);
}}
name="array-options"
>
<option value="" disabled selected
>{$_('layout.filter.optionPlaceholder')}</option
>
{#each schema[filter.type][condition.field] as value (value)}
<option selected={conditionValue === value} {value}
>{camelCaseToHuman(value)}</option
>
{/each}
</select>
<label for="array-options">Value</label>
<i>arrow_drop_down</i>
</div>
<ComboBox
label="Value"
defaultValue={conditionValue as string}
options={(schema[filter.type][condition.field] as string[]).map((value) => {
return {
label: camelCaseToHuman(value),
value
};
})}
onChange={(value) => {
condition.values[index] = value;
filterContentListStore.set(contentFilters);
filterContentUrlAutoUpdateStore.set(false);
}}
/>
{:else}
<div class="field label surface-container-highest max">
<input
@@ -1,6 +1,6 @@
<script lang="ts">
import { bookmarkletSaveToUrl } from '$lib/externalSettings/index';
import { letterCase, titleCases } from '$lib/letterCasing';
import { letterCase, titleCases, type TitleCase } from '$lib/letterCasing';
import { setAmoledTheme } from '$lib/theme';
import { Capacitor } from '@capacitor/core';
import ui from 'beercss';
@@ -35,11 +35,9 @@
} from '../../store';
import { tick } from 'svelte';
import { isOwnBackend } from '$lib/shared';
import ComboBox from '../ComboBox.svelte';
let invidiousInstance = $state(get(invidiousInstanceStore));
let region = $state(get(interfaceRegionStore));
let forceCase = $state(get(interfaceForceCase));
let defaultPage = $state(get(interfaceDefaultPage));
let invalidInstance = $state(false);
let colorPickerOpen = $state(false);
@@ -81,10 +79,8 @@
location.reload();
}
async function setBackend(event: Event) {
const select = event.target as HTMLSelectElement;
backendInUseStore.set(select.value as 'ivg' | 'yt');
async function setBackend(backend: string) {
backendInUseStore.set(backend as 'ivg' | 'yt');
await timeout(100);
location.reload();
}
@@ -120,14 +116,15 @@
</script>
{#if isUnrestrictedPlatform()}
<div class="field label suffix surface-container-highest">
<select name="backend-in-use" onchange={setBackend}>
<option selected={$backendInUseStore === 'ivg'} value="ivg">Invidious</option>
<option selected={$backendInUseStore === 'yt'} value="yt">YouTube (Experimental)</option>
</select>
<label for="backend-in-use">{$_('backend')}</label>
<i>arrow_drop_down</i>
</div>
<ComboBox
label={$_('backend')}
defaultValue={$backendInUseStore}
onChange={setBackend}
options={[
{ label: 'Invidious', value: 'ivg' },
{ label: 'YouTube (Experimental)', value: 'yt' }
]}
/>
{#if $backendInUseStore === 'ivg'}
<form onsubmit={setInstance}>
@@ -396,58 +393,43 @@
</div>
{/if}
<div class="field label suffix surface-container-highest">
<select
tabindex="0"
name="region"
bind:value={region}
onchange={() => interfaceRegionStore.set(region)}
>
{#each iso31661 as region (region)}
<option selected={$interfaceRegionStore === region.alpha2} value={region.alpha2}
>{region.alpha2} - {region.name}</option
>
{/each}
</select>
<label tabindex="-1" for="region">{$_('region')}</label>
<i>arrow_drop_down</i>
</div>
<ComboBox
label={$_('region')}
defaultValue={$interfaceRegionStore}
options={iso31661.map((region) => {
return {
label: region.name,
value: region.alpha2
};
})}
onChange={(value) => interfaceRegionStore.set(value)}
/>
<div class="field label suffix surface-container-highest">
<select
tabindex="0"
name="case"
bind:value={forceCase}
onchange={() => interfaceForceCase.set(forceCase)}
>
<option selected={$interfaceForceCase === null} value={null}>Default</option>
{#each titleCases as caseType (caseType)}
<option selected={$interfaceForceCase === caseType} value={caseType}
>{letterCase(`${caseType}`, caseType)}</option
>
{/each}
</select>
<label tabindex="-1" for="case">{$_('letterCase')}</label>
<i>arrow_drop_down</i>
</div>
<ComboBox
label={$_('letterCase')}
defaultValue={$interfaceForceCase}
options={[
...titleCases.map((caseType) => {
return {
label: letterCase(`${caseType}`, caseType),
value: caseType as string
};
})
]}
onChange={(value) => interfaceForceCase.set(value as TitleCase)}
/>
<div class="field label suffix surface-container-highest">
<select
name="defaultPage"
tabindex="0"
bind:value={defaultPage}
onchange={() => interfaceDefaultPage.set(defaultPage)}
>
{#each pages as page (page)}
{#if !page.requiresAuth || get(invidiousAuthStore)}
<option selected={$interfaceDefaultPage === page.href} value={page.href}>{page.name}</option
>
{/if}
{/each}
</select>
<label tabindex="-1" for="defaultPage">{$_('defaultPage')}</label>
<i>arrow_drop_down</i>
</div>
<ComboBox
label={$_('defaultPage')}
defaultValue={$interfaceDefaultPage}
options={pages.map((page) => {
return {
label: page.name,
value: page.href
};
})}
onChange={(value) => interfaceDefaultPage.set(value)}
/>
{#if !Capacitor.isNativePlatform()}
<div class="space"></div>
@@ -3,7 +3,6 @@
import { Capacitor } from '@capacitor/core';
import ISO6391 from 'iso-639-1';
import { _ } from '$lib/i18n';
import { get } from 'svelte/store';
import {
backendInUseStore,
playerAlwaysLoopStore,
@@ -24,8 +23,7 @@
} from '../../store';
import { playbackRates } from '$lib/player/index';
import { isUnrestrictedPlatform } from '$lib/misc';
let defaultLanguage = $state(get(playerDefaultLanguage));
import ComboBox from '../ComboBox.svelte';
let localVideoFallback: 'enabled' | 'disabled' | 'always' = $state('enabled');
if (!$playerYouTubeJsFallback) {
@@ -38,18 +36,10 @@
ISO6391.getName(code).toLocaleLowerCase()
);
function onQualityChange(event: Event) {
const value = (event.target as HTMLSelectElement).value;
playerDefaultQualityStore.set(value);
}
languageNames.unshift('original');
function onSpeedChange(event: Event) {
const value = (event.target as HTMLSelectElement).value;
playerDefaultPlaybackSpeed.set(Number(value));
}
function onLocalVideoFallbackChange() {
switch (localVideoFallback) {
function onLocalVideoFallbackChange(value: string) {
switch (value) {
case 'enabled':
playerYouTubeJsFallback.set(true);
playerYouTubeJsAlways.set(false);
@@ -67,79 +57,62 @@
</script>
<div class="margin"></div>
<div class="field label suffix surface-container-highest">
<select
tabindex="0"
name="case"
bind:value={defaultLanguage}
onchange={() => playerDefaultLanguage.set(defaultLanguage)}
>
<option selected={$playerDefaultLanguage === 'original'} value="original">Original</option>
{#each languageNames as language (language)}
<option selected={$playerDefaultLanguage === language} value={language}
>{titleCase(language)}</option
>
{/each}
</select>
<label tabindex="-1" for="case">{$_('player.defaultLanguage')}</label>
<i>arrow_drop_down</i>
</div>
<ComboBox
label={$_('player.defaultLanguage')}
defaultValue={$playerDefaultLanguage}
options={languageNames.map((language) => {
return {
label: titleCase(language),
value: language
};
})}
onChange={(value) => playerDefaultLanguage.set(value)}
/>
<div class="field label suffix surface-container-highest">
<select
tabindex="0"
name="quality"
id="quality"
bind:value={$playerDefaultQualityStore}
onchange={onQualityChange}
>
<option value="auto">Auto (Recommended)</option>
<option value="144">144p (Ultra low)</option>
<option value="240">240p (Low)</option>
<option value="360">360p (SD)</option>
<option value="480">480p (SD+)</option>
<option value="720">720p (HD)</option>
<option value="1080">1080p (Full HD)</option>
<option value="1440">1440p (2K)</option>
<option value="2160">2160p (4K UHD)</option>
</select>
<label tabindex="-1" for="quality">{$_('player.preferredQuality')}</label>
<i>arrow_drop_down</i>
</div>
<ComboBox
label={$_('player.preferredQuality')}
defaultValue={$playerDefaultQualityStore}
onChange={(value) => playerDefaultQualityStore.set(value)}
options={[
{ label: 'Auto (Recommended)', value: 'auto' },
{ label: '144p (Ultra low)', value: '144' },
{ label: '240p (Low)', value: '240' },
{ label: '360p (SD)', value: '360' },
{ label: '480p (SD+)', value: '480' },
{ label: '720p (HD)', value: '720' },
{ label: '1080p (Full HD)', value: '1080' },
{ label: '1440p (2K)', value: '1440' },
{ label: '2160p (4K UHD)', value: '2160' }
]}
/>
<div class="field label suffix surface-container-highest">
<select
tabindex="0"
name="quality"
id="quality"
bind:value={$playerDefaultPlaybackSpeed}
onchange={onSpeedChange}
>
{#each playbackRates as speed (speed)}
<option value={speed}>x{speed}</option>
{/each}
</select>
<label tabindex="-1" for="quality">{$_('layout.player.defaultPlaybackSpeed')}</label>
<i>arrow_drop_down</i>
</div>
<ComboBox
label={$_('layout.player.defaultPlaybackSpeed')}
defaultValue={$playerDefaultPlaybackSpeed.toString()}
options={playbackRates.map((rate) => {
return {
label: `x${rate}`,
value: rate.toString()
};
})}
onChange={(value) => playerDefaultPlaybackSpeed.set(Number(value))}
/>
{#if isUnrestrictedPlatform() && $backendInUseStore === 'ivg'}
<div class="field suffix surface-container-highest label">
<select
tabindex="0"
name="ytfallback"
bind:value={localVideoFallback}
onchange={onLocalVideoFallbackChange}
>
<option value="enabled">{$_('enabled')}</option>
<option value="always">{$_('layout.player.youtubeJsAlways')}</option>
<option value="disabled">{$_('disabled')}</option>
</select>
<label tabindex="-1" for="ytfallback">{$_('layout.player.localVideoFallback')}</label>
<i>arrow_drop_down</i>
</div>
<ComboBox
label={$_('layout.player.localVideoFallback')}
defaultValue={localVideoFallback}
options={[
{ label: $_('enabled'), value: 'enabled' },
{ label: $_('layout.player.youtubeJsAlways'), value: 'always' },
{ label: $_('disabled'), value: 'disabled' }
]}
onChange={(value) => onLocalVideoFallbackChange(value)}
/>
{/if}
<div class="space"></div>
<div class="field no-margin">
<nav class="no-padding">
<div class="max">
@@ -119,7 +119,7 @@
{#if currentTab}
<button
bind:this={mobileCategoriesButton}
class="large small-round surface-container-highest max"
class="large surface-container-highest max"
data-ui="#tab-menu"
>
<i>{currentTab.icon}</i>
@@ -9,6 +9,7 @@
sponsorBlockTimelineStore,
sponsorBlockUrlStore
} from '../../store';
import ComboBox from '../ComboBox.svelte';
let sponsorBlockInstance = $state(get(sponsorBlockUrlStore));
@@ -22,11 +23,8 @@
{ name: $_('layout.sponsors.tangentJokes'), category: 'filler' }
];
function onSponsorSet(
category: string,
event: Event & { currentTarget: EventTarget & HTMLSelectElement }
) {
const value = event.currentTarget.value as 'automatic' | 'manual' | 'timeline' | 'disabled';
function onSponsorSet(category: string, givenValue: string) {
const value = givenValue as 'automatic' | 'manual' | 'timeline' | 'disabled';
const categories = get(sponsorBlockCategoriesStore);
@@ -112,29 +110,23 @@
<p class="bold">{$_('layout.sponsors.Catagories')}</p>
{#each sponsorCategories as sponsor (sponsor)}
{@const currentCatergoryTrigger = $sponsorBlockCategoriesStore[sponsor.category]}
<div class="field middle-align no-margin">
{@const currentCategoryTrigger = $sponsorBlockCategoriesStore[sponsor.category]}
<div class="field middle-align">
<nav class="no-padding">
<div class="max">
<p>{sponsor.name}</p>
</div>
<div class="field suffix surface-container-highest">
<select onchange={(event) => onSponsorSet(sponsor.category, event)}>
<option selected={currentCatergoryTrigger === undefined} value="disabled"
>{$_('disabled')}</option
>
<option selected={currentCatergoryTrigger === 'automatic'} value="automatic"
>{$_('layout.sponsors.automatic')}</option
>
<option selected={currentCatergoryTrigger === 'manual'} value="manual"
>{$_('layout.sponsors.manual')}</option
>
<option selected={currentCatergoryTrigger === 'timeline'} value="timeline"
>{$_('layout.sponsors.timeline')}</option
>
</select>
<i>arrow_drop_down</i>
</div>
<ComboBox
options={[
{ label: $_('disabled'), value: 'disabled' },
{ label: $_('layout.sponsors.automatic'), value: 'automatic' },
{ label: $_('layout.sponsors.manual'), value: 'manual' },
{ label: $_('layout.sponsors.timeline'), value: 'timeline' }
]}
defaultValue={currentCategoryTrigger ?? 'disabled'}
onChange={(value) => onSponsorSet(sponsor.category, value)}
/>
</nav>
</div>
{/each}
@@ -10,10 +10,9 @@
interface Props {
playlist: Playlist | PlaylistPage;
disabled?: boolean;
}
let { playlist, disabled = false }: Props = $props();
let { playlist }: Props = $props();
const playlistLink = resolve('/playlist/[playlistId]', { playlistId: playlist.playlistId });
@@ -27,12 +26,7 @@
const thumbnail = new Avatar({ src: thumbnailSrc });
</script>
<a
href={playlistLink}
class:link-disabled={disabled}
style="width: 100%; overflow: hidden;min-height:100px;"
class="wave"
>
<a href={playlistLink} style="width: 100%; overflow: hidden;min-height:100px;" class="wave">
<img
class="responsive"
{...mergeAttrs(thumbnail.image, {
@@ -51,9 +45,7 @@
<div class="small-padding">
<nav class="no-margin">
<div class="max">
<a class:link-disabled={disabled} href={playlistLink}
><div class="bold">{letterCase(truncate(playlist.title))}</div></a
>
<a href={playlistLink}><div class="bold">{letterCase(truncate(playlist.title))}</div></a>
<div>
{#if playlist.authorId}
<a href={resolve('/channel/[authorId]', { authorId: playlist.authorId })}
@@ -66,9 +58,3 @@
</div>
</nav>
</div>
<style>
.link-disabled {
pointer-events: none;
}
</style>
@@ -7,22 +7,24 @@
import { _ } from '$lib/i18n';
import { get } from 'svelte/store';
import { Avatar } from 'melt/builders';
import type {
Notification,
PlaylistPageVideo,
Video,
VideoBase,
VideoWatchHistory
} from '$lib/api/model';
import { deArrowEnabledStore, isAndroidTvStore, playerState } from '$lib/store';
import {
deArrowEnabledStore,
filterContentListStore,
filterContentUrlAutoUpdateStore,
isAndroidTvStore,
playerState
} from '$lib/store';
import { relativeTimestamp } from '$lib/time';
import { queueGetWatchHistory } from '$lib/api/historyPool';
import { page } from '$app/state';
import { getDeArrow, getThumbnailDeArrow } from '$lib/api/dearrow';
import AuthorAvatar from '../AuthorAvatar.svelte';
import Share from '../Share.svelte';
import { deleteWatchHistoryItem, saveWatchHistory } from '$lib/api';
import type { ThumbnailVideo } from '$lib/thumbnail';
interface Props {
video: VideoBase | Video | Notification | PlaylistPageVideo | VideoWatchHistory;
video: ThumbnailVideo;
playlistId?: string;
sideways?: boolean;
}
@@ -71,12 +73,13 @@
}
let thumbnailHeight = $state(0);
let thumbnailHTMLElement: HTMLImageElement | undefined = $state();
let thumbnailImageElement: HTMLImageElement | undefined = $state();
let thumbnailElement: HTMLElement | undefined = $state();
const thumbnail = new Avatar({
src: () => thumbnailSrc,
onLoadingStatusChange: () => {
if (thumbnailHTMLElement) thumbnailHeight = thumbnailHTMLElement.naturalHeight;
if (thumbnailImageElement) thumbnailHeight = thumbnailImageElement.naturalHeight;
}
});
@@ -89,22 +92,30 @@
} else sideways = true;
}
let thumbnailActionsVisible = $state(false);
const observer = new IntersectionObserver(([entry]) => {
if (!entry.isIntersecting) thumbnailActionsVisible = false;
});
onMount(async () => {
// Check if sideways should be enabled or disabled.
disableSideways();
addEventListener('resize', disableSideways);
if (!page.url.pathname.endsWith('/history'))
queueGetWatchHistory(video.videoId).then((watchHistory) => {
if (watchHistory) {
progress = watchHistory.progress.toString();
}
});
queueGetWatchHistory(video.videoId).then(async (watchHistory) => {
if (watchHistory) {
progress = watchHistory.progress.toString();
}
});
if (thumbnailElement) observer.observe(thumbnailElement);
});
onDestroy(() => {
removeEventListener('resize', disableSideways);
observer.disconnect();
});
function onVideoSelected() {
@@ -112,7 +123,129 @@
}
</script>
<div class:sideways-root={sideways} class:use-flex-column={!sideways} tabindex="0" role="button">
<div
class:sideways-root={sideways}
class:use-flex-column={!sideways}
bind:this={thumbnailElement}
tabindex="0"
role="button"
onmouseleave={() => (thumbnailActionsVisible = false)}
>
{#if thumbnailActionsVisible}
<menu class="active root-menu" onmouseleave={() => (thumbnailActionsVisible = false)}>
<li>
<Share
shares={[
{
type: 'materialious',
path: resolve('/watch/[videoId]', { videoId: video.videoId })
},
{
type: 'invidious',
path: `/watch?v=${video.videoId}`
},
{
type: 'invidious redirect',
path: `/watch?v=${video.videoId}`
},
{
type: 'youtube',
path: `/watch?v=${video.videoId}`
}
]}
classes="transparent max"
menuClasses="max no-wrap"
iconOnly={false}
onShare={() => (thumbnailActionsVisible = false)}
/>
</li>
<li>
<button
onclick={async () => {
if (progress) {
await deleteWatchHistoryItem(video.videoId);
progress = undefined;
} else {
await saveWatchHistory(video, 1);
progress = '0';
}
thumbnailActionsVisible = false;
}}
class="transparent max"
>
{#if progress}
<i>visibility_off</i>
<span>{$_('markAs.unwatched')}</span>
{:else}
<i>visibility</i>
<span>{$_('markAs.watched')}</span>
{/if}
</button>
</li>
<li>
<button class="transparent max">
<i>disabled_visible</i>
<span>
{$_('layout.filter.addFilter')}
</span>
<menu class="max" data-ui="#hide-menu" id="hide-menu">
{#if 'authorId' in video}
<li
onclick={() => {
$filterContentListStore?.push({
type: 'video',
conditions: [
{
field: 'authorId',
operator: 'equals',
values: [video.authorId],
note: $_('layout.filter.channelFiltered', { authorName: video.author })
}
]
});
filterContentListStore.set($filterContentListStore);
filterContentUrlAutoUpdateStore.set(false);
thumbnailActionsVisible = false;
}}
role="presentation"
data-ui="#hide-menu"
class="row"
>
{$_('hideAuthor')}
</li>
{/if}
<li
onclick={() => {
$filterContentListStore?.push({
type: 'video',
conditions: [
{
field: 'videoId',
operator: 'equals',
values: [video.videoId],
note: $_('layout.filter.videoFiltered', {
videoTitle: video.title,
authorName: video.author
})
}
]
});
filterContentListStore.set($filterContentListStore);
filterContentUrlAutoUpdateStore.set(false);
thumbnailActionsVisible = false;
}}
role="presentation"
data-ui="#hide-menu"
class="row"
>
{$_('hideVideo')}
</li>
</menu>
</button>
</li>
</menu>
{/if}
<div id="thumbnail-container">
<!-- eslint-disable svelte/no-navigation-without-resolve -->
<a
@@ -126,9 +259,9 @@
<div class:crop={thumbnailHeight > 300}>
<img
class="responsive"
class:watched={progress !== undefined}
class:watched={progress}
{...thumbnail.image}
bind:this={thumbnailHTMLElement}
bind:this={thumbnailImageElement}
alt="Thumbnail for video"
/>
</div>
@@ -138,7 +271,7 @@
style="height: 200px;"
></div>
{#if progress !== undefined}
{#if progress}
<div class="chip surface-container-highest">
<i>check</i>
</div>
@@ -184,41 +317,53 @@
</a>
<nav class="align-end">
{#if !sideways && 'authorId' in video}
<AuthorAvatar author={video.author} authorId={video.authorId} />
{#if !sideways}
<AuthorAvatar
author={video.author}
authorId={'authorId' in video ? video.authorId : ''}
/>
{/if}
<div>
{#if 'authorId' in video && video.authorId}
<a
tabindex="-1"
class:author={!sideways}
href={resolve(`/channel/[authorId]`, { authorId: video.authorId })}
data-sveltekit-preload-data="off"
>{video.author}
</a>
{:else}
<p>{video.author}</p>
{/if}
{#if 'promotedBy' in video && video.promotedBy === 'favourited'}
<i>star</i>
{/if}
{#if !('publishedText' in video) && 'viewCountText' in video}
{video.viewCountText ?? cleanNumber(video.viewCount ?? 0)}
{$_('views')}
{/if}
{#if 'published' in video}
<div style="width: 82%;">
<nav style="justify-content: space-between;width: 100%;">
<div>
{video.viewCountText ?? cleanNumber(video.viewCount ?? 0)}
{video.published && video.published !== 0
? relativeTimestamp(video.published * 1000, false)
: video.publishedText}
{#if 'authorId' in video && video.authorId}
<a
tabindex="-1"
class:author={!sideways}
href={resolve(`/channel/[authorId]`, { authorId: video.authorId })}
data-sveltekit-preload-data="off"
>{video.author}
</a>
{:else}
<p>{video.author}</p>
{/if}
{#if 'promotedBy' in video && video.promotedBy === 'favourited'}
<i>star</i>
{/if}
{#if !('publishedText' in video) && 'viewCountText' in video}
{video.viewCountText ?? cleanNumber(video.viewCount ?? 0)}
{$_('views')}
{/if}
{#if 'published' in video}
<div>
{video.viewCountText ?? cleanNumber(video.viewCount ?? 0)}
{video.published && video.published !== 0
? relativeTimestamp(video.published * 1000, false)
: video.publishedText}
</div>
{/if}
</div>
{/if}
{#if !sideways}
<button onclick={() => (thumbnailActionsVisible = true)} class="transparent circle">
<i>more_vert</i>
</button>
{/if}
</nav>
</div>
</nav>
</div>
@@ -291,6 +436,7 @@
word-wrap: break-word;
overflow: hidden;
border-radius: 0px;
width: 100%;
}
.thumbnail-details {
@@ -320,6 +466,10 @@
align-items: end;
}
.root-menu > li:hover {
background-color: transparent;
}
@media screen and (max-width: 1800px) {
.sideways-root .thumbnail {
width: 100%;
+18
View File
@@ -194,6 +194,24 @@ menu.player-settings {
transform: translate(-50%, -50%);
}
[data-melt-combobox-content] {
background-color: var(--surface-container-high);
padding: 1rem;
font-size: 1rem;
border-radius: var(--border-radius);
overflow-y: scroll;
max-height: 200px;
}
[data-melt-combobox-option] {
padding: 0.25rem;
cursor: pointer;
}
[data-highlighted] {
background-color: var(--surface-container-highest);
}
@media screen and (max-width: 1000px) {
menu.mobile {
position: fixed !important;
+8 -1
View File
@@ -38,6 +38,10 @@
"sortBy": "Sort By",
"features": "Features"
},
"markAs": {
"watched": "Mark as watched",
"unwatched": "Mark as unwatched"
},
"initalSetup": {
"useInvidious": "I want to use Invidious",
"useLocalFallback": "I want to use local video fallback",
@@ -73,6 +77,7 @@
"letterCase": "Letter case for titles",
"hideReplies": "Hide replies",
"hideVideo": "Hide video",
"hideAuthor": "Hide author",
"defaultPage": "Default page",
"newest": "Newest",
"oldest": "Oldest",
@@ -269,7 +274,9 @@
"optionPlaceholder": "Select your option",
"value": "Value",
"addConditional": "Add conditional",
"addFilter": "Add filter"
"addFilter": "Add filter",
"videoFiltered": "\"{{videoTitle}}\" from \"{{authorName}}\" filtered",
"channelFiltered": "\"{{authorName}}\" filtered"
},
"export": {
"title": "Export/Import",
+110 -9
View File
@@ -12,15 +12,15 @@
"delete": "Eliminar",
"donate": "Donar",
"deleteAllHistory": "Eliminar todo el historial",
"skipping": "Omitir",
"skipping": "Omitiendo",
"replies": "respuestas",
"region": "Región",
"noResult": "Sin resultados",
"language": "Idioma",
"language": "Lenguaje",
"channels": "canales",
"transcript": "Transcribir",
"noCaptionData": "Los subtítulos no contienen datos.",
"selectLang": "Seleccionar idioma",
"selectLang": "Seleccionar lenguaje",
"letterCase": "Títulos en mayúsculas o minúsculas",
"hideReplies": "Ocultar respuestas",
"hideVideo": "Ocultar video",
@@ -48,7 +48,9 @@
"private": "Privado",
"playVideos": "Reproducir videos",
"shuffleVideos": "Reproducir videos en aleatorio",
"loopPlaylist": "Repetir lista de reproducción"
"loopPlaylist": "Repetir lista de reproducción",
"next": "Siguiente",
"previous": "Anterior"
},
"thumbnail": {
"live": "EN VIVO",
@@ -73,7 +75,10 @@
"title": "Compartir",
"materialiousLink": "Copiar enlace de Materialious",
"invidiousRedirect": "Copiar enlace de redirección de Invidious",
"youtubeLink": "Copiar enlace de Youtube"
"youtubeLink": "Copiar enlace de Youtube",
"copyXLink": "Copiar {{linkType}} link",
"includeTimestamp": "Marca de tiempo",
"copiedSuccess": "Copiado al portapapeles"
},
"download": "Descargar",
"downloadSteps": {
@@ -89,8 +94,25 @@
"noPlaylists": "Sin listas de reproducción",
"unableToLoadComments": "No se pudo cargar los comentarios",
"addToPlaylist": "Agregar a lista de reproducción",
"youtubeJsFallBack": "Se está utilizando la opción de video local.",
"defaultLanguage": "Idioma predeterminado"
"youtubeJsFallBack": "Se está utilizando la opción de video local",
"defaultLanguage": "Idioma predeterminado",
"preferredQuality": "Calidad preferida",
"premiere": "El video estará disponible",
"chapters": "Capítulos",
"youtubeJsLoading": "El video no ha podido cargar, se está intentado usar la opción de video local.",
"retryText": "El video no ha podido cargar, ¿cómo le gustaría proceder?",
"enableYoutubejsTemp": "Usar la opción de video local una vez",
"enableYoutubejsPerm": "Siempre permitir la opción de video local",
"controls": {
"back": "Atrás",
"quality": "Calidad",
"auto": "Automático",
"playbackSpeed": "Velocidad de reproducción",
"loop": "Repetir",
"on": "Encendido",
"off": "Apagado",
"language": "Lenguaje"
}
},
"layout": {
"interface": "Interfaz",
@@ -137,7 +159,7 @@
"dash": "Modo escritorio",
"silenceSkipper": "Omitir silencio (Experimental)",
"youtubeJsFallback": "Procesar video localmente",
"youtubeJsAlways": "Utilizar siempre el procesamiento de vídeo local",
"youtubeJsAlways": "Utilizar siempre",
"lockOrientation": "Bloquear orientación de pantalla"
},
"bookmarklet": "Favoritos",
@@ -158,7 +180,86 @@
"title": "DeArrow",
"thumbnailInstanceUrl": "URL de la instancia de la miniatura",
"titleOnly": "Solo títulos"
},
"engine": "Motor de backend",
"about": "Acerca de",
"materialiousAccount": "Cuenta",
"shareURL": "Compartir URL",
"companionUrl": "URL del Companion",
"deleteAccount": "Borrar cuenta",
"clickXmoreTimesToDelete": "Haga clic {{clicksTillDelete}} veces más para borrar",
"historyEnabled": "Guardar historial de visionado",
"expandChapters": "Expandir capítulos por defecto",
"allowInsecureRequests": "Permitir peticiones inseguras",
"disableAutoUpdate": "Desabilitar actualizaciones automáticas",
"androidNativeShare": "Usar el \"compartir\" nativo",
"backendEngine": {
"warning": "Opciones avanzadas! Solo para usuarios avanzados. Se recomienda no cambiar nada, excepto que entiendas lo que haces.",
"cull": "Elementos máximos en el feed"
}
},
"subscribe": "Suscríbete"
"subscribe": "Suscríbete",
"disabled": "Desactivado",
"popularPageDisabled": "La página popular ha sido desactivado por los Administradores",
"premium": "El contenido de YouTube Premium no puede ser visualizado en Materialious.",
"copy": "Copiar al portapapeles",
"login": "Acceder",
"username": "Nombre de usuario",
"password": "Contraseña",
"captionStyle": "Estilo de los subtítulos",
"invalidPassword": "Contraseña erronea",
"usernameTaken": "El nombre de usuario ya existe",
"linkInvidious": "Conecta una cuenta de Invidious",
"unlinkInvidious": "Desconecta una cuenta de Invidious",
"createAccount": "Crear cuenta",
"needRegister": "Necesito registrarme",
"registrationDisabled": "Registro desabilitado, contacte con el dueño de la instancia.",
"needLogin": "Necesito haber iniciado sesión",
"recommendedVideos": "Videos Recomendados",
"playlistVideos": "Videos en Lista de Reproducción",
"invidiousLogin": "Por favor, accede con tu cuenta de Invidious.",
"materialiousLogin": "Por favor, accede con tu cuenta de Materialious.",
"materialiousCreate": "Por favor, crea una cuenta de Materialious.",
"invalidInstance": "Por favor, verifica la URL. Si es correcta, puede que la instancia esté inactiva o no soporte clientes externos.",
"backend": "Backend",
"filters": {
"filters": "Filtros",
"type": "Tipos",
"uploadDate": "Fecha de subida",
"duration": "Duración",
"sortBy": "Ordernar por",
"features": "Características"
},
"initalSetup": {
"useInvidious": "Quiero usar Invidious",
"useLocalFallback": "Quiero usar la opción de video local",
"yes": "Sí",
"no": "No",
"unsure": "No estoy seguro",
"required": "Configuración requerida",
"invidiousInfo": "Invidious es una interfaz auto-hospedable para YouTube.",
"localFallbackInfo": "Cuando Invidious falla en cargar un video, Materialious lo hace localmente.",
"configureInstance": "Configurar instancia",
"done": "Hecho"
},
"skip": "Omitir",
"upcomingSegment": "Próximo ",
"comments": "comentarios",
"reply": "responder",
"contributors": "Colaboradores",
"version": "Versión",
"newest": "Nuevo",
"oldest": "Antiguo",
"popular": "Popular",
"watchParty": {
"start": "Empezar a ver en grupo",
"header": "Ver en grupo",
"createRoom": "Crear sala",
"joinRoom": "Unirse a una sala",
"roomID": "ID de la sala",
"shareRoom": "Compartir sala",
"leaveRoom": "Abandonar sala",
"userJoin": "Un usuario se ha unido a la sala",
"userLeft": "Un usuario ha abandonado la sala"
}
}
+14 -2
View File
@@ -1,8 +1,20 @@
import { get } from 'svelte/store';
import { interfaceForceCase } from './store';
export type TitleCase = 'uppercase' | 'lowercase' | 'sentence case' | 'title case' | null;
export const titleCases: TitleCase[] = ['lowercase', 'uppercase', 'title case', 'sentence case'];
export type TitleCase =
| 'uppercase'
| 'lowercase'
| 'sentence case'
| 'title case'
| 'original'
| null;
export const titleCases: TitleCase[] = [
'original',
'lowercase',
'uppercase',
'title case',
'sentence case'
];
export function letterCase(text: string, caseTypeOverwrite?: TitleCase): string {
const casing = caseTypeOverwrite ? caseTypeOverwrite : get(interfaceForceCase);
+1 -1
View File
@@ -214,7 +214,7 @@ export const interfaceSearchSuggestionsStore = persist(
'searchSuggestions'
);
export const interfaceForceCase: Writable<TitleCase> = persist(
writable(null),
writable('original'),
createStorage(),
'forceCase'
);
+15 -1
View File
@@ -1,9 +1,23 @@
import type { Image } from './api/model';
import type {
Image,
Notification,
PlaylistPageVideo,
Video,
VideoBase,
VideoWatchHistory
} from './api/model';
import { localDb } from './dexie';
import { getBestThumbnail } from './images';
let isInitial = false;
export type ThumbnailVideo =
| VideoBase
| Video
| Notification
| PlaylistPageVideo
| VideoWatchHistory;
export async function associateAvatar(channelId: string, avatars: Image[]) {
if (avatars.length === 0) return;
@@ -12,7 +12,7 @@
</script>
<article class="border padding">
{#if data.playlist.videos}
{#if data.playlist.videos.length > 0}
<nav>
<a
href={resolve(
@@ -96,6 +96,4 @@
<div class="space"></div>
{#if data.playlist.videos}
<ItemsList items={data.playlist.videos} playlistId={data.playlist.info.playlistId} />
{/if}
<ItemsList items={data.playlist.videos} playlistId={data.playlist.info.playlistId} />
@@ -43,7 +43,7 @@
<ContentColumn>
<article class="no-padding" style="height: 100%;">
{#key playlist.playlistId}
<PlaylistThumbnail disabled={playlist.videoCount === 0} {playlist} />
<PlaylistThumbnail {playlist} />
{/key}
<nav class="right-align padding">
@@ -127,14 +127,16 @@ async function proxyRequest(
...(request.body ? { duplex: 'half' } : {})
};
let body;
if (request.body && request.headers.has('__is_base64_encoded')) {
requestHeaders.delete('__is_base64_encoded');
let body: any = request.body;
if (body) {
if (request.headers.has('__is_base64_encoded')) {
requestHeaders.delete('__is_base64_encoded');
await sodium.ready;
body = Uint8Array.from(sodium.from_base64(await request.text()));
} else {
body = request.body;
await sodium.ready;
body = Uint8Array.from(sodium.from_base64(await request.text()));
} else if (request.method !== 'GET' && request.method !== 'HEAD') {
body = await request.blob();
}
}
let response: Response | undefined;
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.18"
LATEST_VERSION = "1.16.19"
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")