Merge pull request #1567 from Materialious/update/1.16.19
Update/1.16.19
This commit is contained in:
@@ -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,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",
|
||||
|
||||
Generated
+3
-3
@@ -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,6 +1,6 @@
|
||||
{
|
||||
"name": "materialious",
|
||||
"version": "1.16.18",
|
||||
"version": "1.16.19",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "npm run patch:github && vite dev",
|
||||
|
||||
@@ -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', {
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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>
|
||||
@@ -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%;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -214,7 +214,7 @@ export const interfaceSearchSuggestionsStore = persist(
|
||||
'searchSuggestions'
|
||||
);
|
||||
export const interfaceForceCase: Writable<TitleCase> = persist(
|
||||
writable(null),
|
||||
writable('original'),
|
||||
createStorage(),
|
||||
'forceCase'
|
||||
);
|
||||
|
||||
@@ -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
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user