Merge pull request #781 from Materialious/update/1.6.30

Update/1.6.30
This commit is contained in:
Ward
2025-01-24 17:40:33 +13:00
committed by GitHub
61 changed files with 1423 additions and 1703 deletions
+5 -6
View File
@@ -27,7 +27,6 @@
- Watch sync parties!
- Mini player.
- Silence skipper (Experimental.)
- ffmpeg integration for downloading videos with audio at any quality ([Configuration required](./docs/DOCKER.md#step-7-optional-enabling-downloads)).
- [YouTube.js](https://github.com/LuanRT/YouTube.js) fallback if Invidious fails loading videos for Desktop & Android.
- Preview video on hover.
- Sponsorblock built-in.
@@ -48,11 +47,11 @@
- YT path redirects (So your redirect plugins should still work!)
# Support table
| | Dash | HLS | Local video fallback | API-Extended | ffmpeg download merging | Dearrow | RYD |
|---------|------|-----|----------------------|--------------|-------------------------|---------|-----|
| Web | ✅ | ✅ | ❌ | ✅ | ✅ | ✅ | ✅ |
| Desktop | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ | ✅ |
| Android | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ | ✅ |
| | Dash | HLS | Local video fallback | API-Extended | Dearrow | RYD |
|---------|------|-----|----------------------|--------------|---------|-----|
| Web | ✅ | ✅ | ❌ | ✅ | ✅ | ✅ |
| Desktop | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| Android | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
# Public instances
[Public instances are listed here](./docs/INSTANCES.md).
+2 -2
View File
@@ -7,8 +7,8 @@ android {
applicationId "us.materialio.app"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 80
versionName "1.6.29"
versionCode 81
versionName "1.6.30"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "Materialious",
"version": "1.6.29",
"version": "1.6.30",
"description": "Modern material design for Invidious.",
"author": {
"name": "Ward Pearce",
+2 -32
View File
@@ -1,12 +1,12 @@
{
"name": "materialious",
"version": "1.6.28",
"version": "1.6.30",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "materialious",
"version": "1.6.28",
"version": "1.6.30",
"dependencies": {
"@capacitor-community/electron": "^5.0.1",
"@capacitor/android": "^6.1.2",
@@ -18,7 +18,6 @@
"@capacitor/screen-orientation": "^6.0.2",
"@capacitor/status-bar": "^6.0.1",
"@capgo/inappbrowser": "^6.6.8",
"@ffmpeg/ffmpeg": "^0.12.10",
"@hugotomazi/capacitor-navigation-bar": "^4.0.1",
"@mediagrid/capacitor-native-audio": "^1.0.0",
"beercss": "^3.8.0",
@@ -45,7 +44,6 @@
},
"devDependencies": {
"@capacitor/assets": "^3.0.5",
"@ffmpeg/util": "^0.12.1",
"@sveltejs/adapter-auto": "^3.2.5",
"@sveltejs/adapter-static": "^3.0.5",
"@sveltejs/kit": "^2.9.1",
@@ -2084,34 +2082,6 @@
"node": ">=14"
}
},
"node_modules/@ffmpeg/ffmpeg": {
"version": "0.12.10",
"resolved": "https://registry.npmjs.org/@ffmpeg/ffmpeg/-/ffmpeg-0.12.10.tgz",
"integrity": "sha512-lVtk8PW8e+NUzGZhPTWj2P1J4/NyuCrbDD3O9IGpSeLYtUZKBqZO8CNj1WYGghep/MXoM8e1qVY1GztTkf8YYQ==",
"dependencies": {
"@ffmpeg/types": "^0.12.2"
},
"engines": {
"node": ">=18.x"
}
},
"node_modules/@ffmpeg/types": {
"version": "0.12.2",
"resolved": "https://registry.npmjs.org/@ffmpeg/types/-/types-0.12.2.tgz",
"integrity": "sha512-NJtxwPoLb60/z1Klv0ueshguWQ/7mNm106qdHkB4HL49LXszjhjCCiL+ldHJGQ9ai2Igx0s4F24ghigy//ERdA==",
"engines": {
"node": ">=16.x"
}
},
"node_modules/@ffmpeg/util": {
"version": "0.12.1",
"resolved": "https://registry.npmjs.org/@ffmpeg/util/-/util-0.12.1.tgz",
"integrity": "sha512-10jjfAKWaDyb8+nAkijcsi9wgz/y26LOc1NKJradNMyCIl6usQcBbhkjX5qhALrSBcOy6TOeksunTYa+a03qNQ==",
"dev": true,
"engines": {
"node": ">=18.x"
}
},
"node_modules/@floating-ui/core": {
"version": "1.6.7",
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.6.7.tgz",
+2 -4
View File
@@ -1,6 +1,6 @@
{
"name": "materialious",
"version": "1.6.29",
"version": "1.6.30",
"private": true,
"scripts": {
"dev": "vite dev",
@@ -13,7 +13,6 @@
},
"devDependencies": {
"@capacitor/assets": "^3.0.5",
"@ffmpeg/util": "^0.12.1",
"@sveltejs/adapter-auto": "^3.2.5",
"@sveltejs/adapter-static": "^3.0.5",
"@sveltejs/kit": "^2.9.1",
@@ -52,7 +51,6 @@
"@capacitor/screen-orientation": "^6.0.2",
"@capacitor/status-bar": "^6.0.1",
"@capgo/inappbrowser": "^6.6.8",
"@ffmpeg/ffmpeg": "^0.12.10",
"@hugotomazi/capacitor-navigation-bar": "^4.0.1",
"@mediagrid/capacitor-native-audio": "^1.0.0",
"beercss": "^3.8.0",
@@ -77,4 +75,4 @@
"vidstack": "^1.12.9",
"youtubei.js": "^12.2.0"
}
}
}
-904
View File
@@ -1,904 +0,0 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { bookmarkletSaveToUrl } from '$lib/externalSettings';
import { Clipboard } from '@capacitor/clipboard';
import { Capacitor } from '@capacitor/core';
import ui from 'beercss';
import { iso31661 } from 'iso-3166';
import ISO6391 from 'iso-639-1';
import { onMount } from 'svelte';
import { _ } from 'svelte-i18n';
import { get } from 'svelte/store';
import { ensureNoTrailingSlash, letterCase, titleCase, titleCases } from './misc';
import { getPages } from './navPages';
import {
authStore,
darkModeStore,
deArrowEnabledStore,
deArrowInstanceStore,
deArrowThumbnailInstanceStore,
deArrowTitlesOnly,
instanceStore,
interfaceAmoledTheme,
interfaceAutoExpandComments,
interfaceAutoExpandDesc,
interfaceDefaultPage,
interfaceDisplayThumbnailAvatars,
interfaceForceCase,
interfaceLowBandwidthMode,
interfacePreviewVideoOnHoverStore,
interfaceRegionStore,
interfaceSearchSuggestionsStore,
playerAlwaysLoopStore,
playerAndroidBgPlayer,
playerAndroidLockOrientation,
playerAutoPlayStore,
playerAutoplayNextByDefaultStore,
playerDefaultLanguage,
playerListenByDefaultStore,
playerMiniPlayerStore,
playerProxyVideosStore,
playerSavePlaybackPositionStore,
playerTheatreModeByDefaultStore,
playerYouTubeJsAlways,
playerYouTubeJsFallback,
returnYTDislikesInstanceStore,
returnYtDislikesStore,
silenceSkipperStore,
sponsorBlockCategoriesStore,
sponsorBlockDisplayToastStore,
sponsorBlockStore,
sponsorBlockTimelineStore,
sponsorBlockUrlStore,
synciousInstanceStore,
synciousStore,
themeColorStore
} from './store';
import { setAmoledTheme } from './theme';
let sponsorCategoriesList: string[] = [];
sponsorBlockCategoriesStore.subscribe((value) => (sponsorCategoriesList = value));
let sponsorBlockInstance = get(sponsorBlockUrlStore);
let synciousInstance = get(synciousInstanceStore);
let returnYTInstance = get(returnYTDislikesInstanceStore);
let invidiousInstance = get(instanceStore);
let deArrowUrl = get(deArrowInstanceStore);
let deArrowThumbnailUrl = get(deArrowThumbnailInstanceStore);
let region = get(interfaceRegionStore);
let forceCase = get(interfaceForceCase);
let defaultLanguage = get(playerDefaultLanguage);
let defaultPage = get(interfaceDefaultPage);
const languageNames = ISO6391.getAllCodes().map((code) =>
ISO6391.getName(code).toLocaleLowerCase()
);
const sponsorCategories = [
{ name: $_('layout.sponsors.sponsor'), category: 'sponsor' },
{ name: $_('layout.sponsors.unpaidSelfPromotion'), category: 'selfpromo' },
{ name: $_('layout.sponsors.interactionReminder'), category: 'interaction' },
{ name: $_('layout.sponsors.intermissionIntroAnimation'), category: 'intro' },
{ name: $_('layout.sponsors.credits'), category: 'outro' },
{ name: $_('layout.sponsors.preViewRecapHook'), category: 'preview' },
{ name: $_('layout.sponsors.tangentJokes'), category: 'filler' }
];
function toggleSponsor(category: string) {
if (sponsorCategoriesList.includes(category)) {
sponsorBlockCategoriesStore.set(sponsorCategoriesList.filter((value) => value !== category));
} else {
sponsorCategoriesList.push(category);
sponsorBlockCategoriesStore.set(sponsorCategoriesList);
}
}
async function setColor(color: any) {
const target = color.target;
const hex = (target as { value: string }).value;
await ui('theme', hex);
setAmoledTheme();
themeColorStore.set(hex);
}
function toggleDarkMode() {
const isDark = get(darkModeStore);
if (isDark) {
ui('mode', 'light');
darkModeStore.set(false);
} else {
ui('mode', 'dark');
darkModeStore.set(true);
}
}
let activeTab:
| 'interface'
| 'player'
| 'data'
| 'ryd'
| 'api extended'
| 'sponsorblock'
| 'dearrow' = 'interface';
let dialogType = '';
function checkWidth() {
if (innerWidth <= 1320) {
dialogType = 'right';
} else {
dialogType = '';
}
}
onMount(() => {
checkWidth();
addEventListener('resize', () => checkWidth());
// Set default language if not set already based off browser.
if (!get(playerDefaultLanguage)) {
const languageCode = navigator.language || navigator.languages[0];
const languageName = ISO6391.getName(languageCode.split('-')[0]);
if (languageName) {
playerDefaultLanguage.set(languageName.toLowerCase());
}
}
});
</script>
<dialog id="dialog-settings" class={dialogType}>
<nav style="margin-bottom: 1em;">
<h4 class="max">{$_('layout.settings')}</h4>
<button class="circle transparent" data-ui="#dialog-settings"><i>close</i></button>
</nav>
<div>
<nav class="tabbed small">
<a class:active={activeTab === 'interface'} on:click={() => (activeTab = 'interface')}>
<i>grid_view</i>
<span>{$_('layout.interface')}</span>
</a>
<a class:active={activeTab === 'player'} on:click={() => (activeTab = 'player')}>
<i>smart_display</i>
<span>{$_('layout.player.title')}</span>
</a>
<a class:active={activeTab === 'data'} on:click={() => (activeTab = 'data')}>
<i>save</i>
<span>{$_('layout.dataPreferences.dataPreferences')}</span>
</a>
<a class:active={activeTab === 'ryd'} on:click={() => (activeTab = 'ryd')}>
<i>thumb_down</i>
<span>RYD</span>
</a>
<a class:active={activeTab === 'api extended'} on:click={() => (activeTab = 'api extended')}>
<i>sync</i>
<span>API Extended</span>
</a>
<a class:active={activeTab === 'sponsorblock'} on:click={() => (activeTab = 'sponsorblock')}>
<i>block</i>
<span>Sponsorblock</span>
</a>
<a class:active={activeTab === 'dearrow'} on:click={() => (activeTab = 'dearrow')}>
<i>keyboard_double_arrow_down</i>
<span>{$_('layout.deArrow.title')}</span>
</a>
<a href="https://github.com/sponsors/WardPearce" target="_blank" referrerpolicy="no-referrer">
<i>favorite</i>
<span>{$_('donate')}</span>
</a>
<a
href="https://github.com/Materialious/Materialious"
target="_blank"
referrerpolicy="no-referrer"
>
<i>code</i>
<span>GitHub</span>
</a>
</nav>
<div class="page padding" class:active={activeTab === 'interface'}>
{#if Capacitor.isNativePlatform()}
<form
on:submit|preventDefault={() => {
instanceStore.set(ensureNoTrailingSlash(invidiousInstance));
authStore.set(null);
goto('/', { replaceState: true });
ui('#dialog-settings');
}}
>
<nav>
<div class="field label border max">
<input bind:value={invidiousInstance} name="invidious-instance" type="text" />
<label for="invidious-instance">{$_('layout.instanceUrl')}</label>
</div>
<button class="square round">
<i>done</i>
</button>
</nav>
</form>
{/if}
<div class="margin"></div>
<button on:click={toggleDarkMode} class="no-margin">
{#if !$darkModeStore}
<i>dark_mode</i>
<span>{$_('layout.theme.darkMode')}</span>
{:else}
<i>light_mode</i>
<span>{$_('layout.theme.lightMode')}</span>
{/if}
</button>
<button>
<i>palette</i>
<span>{$_('layout.theme.color')}</span>
<input on:change={setColor} type="color" />
</button>
<div class="margin"></div>
{#if $darkModeStore}
<div class="field no-margin">
<nav class="no-padding">
<div class="max">
<div>{$_('layout.theme.AmoledTheme')}</div>
</div>
<label class="switch">
<input
type="checkbox"
bind:checked={$interfaceAmoledTheme}
on:click={() => interfaceAmoledTheme.set(!$interfaceAmoledTheme)}
/>
<span></span>
</label>
</nav>
</div>
{/if}
<div class="field no-margin">
<nav class="no-padding">
<div class="max">
<div>{$_('layout.displayThumbnailAvatars')}</div>
</div>
<label class="switch">
<input
type="checkbox"
bind:checked={$interfaceDisplayThumbnailAvatars}
on:click={() =>
interfaceDisplayThumbnailAvatars.set(!$interfaceDisplayThumbnailAvatars)}
/>
<span></span>
</label>
</nav>
</div>
<div class="field no-margin">
<nav class="no-padding">
<div class="max">
<div>{$_('layout.lowBandwidthMode')}</div>
</div>
<label class="switch">
<input
type="checkbox"
bind:checked={$interfaceLowBandwidthMode}
on:click={() => interfaceLowBandwidthMode.set(!$interfaceLowBandwidthMode)}
/>
<span></span>
</label>
</nav>
</div>
<div class="field no-margin">
<nav class="no-padding">
<div class="max">
<div>{$_('layout.searchSuggestions')}</div>
</div>
<label class="switch">
<input
type="checkbox"
bind:checked={$interfaceSearchSuggestionsStore}
on:click={() =>
interfaceSearchSuggestionsStore.set(!$interfaceSearchSuggestionsStore)}
/>
<span></span>
</label>
</nav>
</div>
<div class="field no-margin">
<nav class="no-padding">
<div class="max">
<div>{$_('layout.previewVideoOnHover')}</div>
</div>
<label class="switch">
<input
type="checkbox"
bind:checked={$interfacePreviewVideoOnHoverStore}
on:click={() =>
interfacePreviewVideoOnHoverStore.set(!$interfacePreviewVideoOnHoverStore)}
/>
<span></span>
</label>
</nav>
</div>
<div class="field no-margin">
<nav class="no-padding">
<div class="max">
<div>{$_('layout.expandDescription')}</div>
</div>
<label class="switch">
<input
type="checkbox"
bind:checked={$interfaceAutoExpandDesc}
on:click={() => interfaceAutoExpandDesc.set(!$interfaceAutoExpandDesc)}
/>
<span></span>
</label>
</nav>
</div>
<div class="field no-margin">
<nav class="no-padding">
<div class="max">
<div>{$_('layout.expandComments')}</div>
</div>
<label class="switch">
<input
type="checkbox"
bind:checked={$interfaceAutoExpandComments}
on:click={() => interfaceAutoExpandComments.set(!$interfaceAutoExpandComments)}
/>
<span></span>
</label>
</nav>
</div>
<div class="field label suffix border">
<select
name="region"
bind:value={region}
on:change={() => interfaceRegionStore.set(region)}
>
{#each iso31661 as region}
<option selected={$interfaceRegionStore === region.alpha2} value={region.alpha2}
>{region.alpha2} - {region.name}</option
>
{/each}
</select>
<label for="region">{$_('region')}</label>
<i>arrow_drop_down</i>
</div>
<div class="field label suffix border">
<select
name="case"
bind:value={forceCase}
on:change={() => interfaceForceCase.set(forceCase)}
>
<option selected={$interfaceForceCase === null} value={null}>Default</option>
{#each titleCases as caseType}
<option selected={$interfaceForceCase === caseType} value={caseType}
>{letterCase(`${caseType}`, caseType)}</option
>
{/each}
</select>
<label for="case">{$_('letterCase')}</label>
<i>arrow_drop_down</i>
</div>
<div class="field label suffix border">
<select
name="defaultPage"
bind:value={defaultPage}
on:change={() => interfaceDefaultPage.set(defaultPage)}
>
{#each getPages() as page}
{#if !page.requiresAuth || get(authStore)}
<option selected={$interfaceDefaultPage === page.href} value={page.href}
>{page.name}</option
>
{/if}
{/each}
</select>
<label for="defaultPage">{$_('defaultPage')}</label>
<i>arrow_drop_down</i>
</div>
{#if !Capacitor.isNativePlatform()}
<div class="settings">
<h6>{$_('layout.bookmarklet')}</h6>
<button
class="no-margin"
on:click={async () => await Clipboard.write({ string: bookmarkletSaveToUrl() })}
>{$_('copyUrl')}</button
>
</div>
{/if}
</div>
<div class="page padding" class:active={activeTab === 'player'}>
<div class="margin"></div>
<div class="field label suffix border">
<select
name="case"
bind:value={defaultLanguage}
on:change={() => playerDefaultLanguage.set(defaultLanguage)}
>
{#each languageNames as language}
<option selected={$playerDefaultLanguage === language} value={language}
>{titleCase(language)}</option
>
{/each}
</select>
<label for="case">{$_('player.defaultLanguage')}</label>
<i>arrow_drop_down</i>
</div>
<div class="field no-margin">
<nav class="no-padding">
<div class="max">
<div>{$_('layout.player.miniPlayer')}</div>
</div>
<label class="switch">
<input
type="checkbox"
bind:checked={$playerMiniPlayerStore}
on:click={() => playerMiniPlayerStore.set(!$playerMiniPlayerStore)}
/>
<span></span>
</label>
</nav>
</div>
<div class="field no-margin">
<nav class="no-padding">
<div class="max">
<div>{$_('layout.player.autoPlay')}</div>
</div>
<label class="switch">
<input
type="checkbox"
bind:checked={$playerAutoPlayStore}
on:click={() => playerAutoPlayStore.set(!$playerAutoPlayStore)}
/>
<span></span>
</label>
</nav>
</div>
<div class="field no-margin">
<nav class="no-padding">
<div class="max">
<div>{$_('layout.player.alwaysLoopVideo')}</div>
</div>
<label class="switch">
<input
type="checkbox"
bind:checked={$playerAlwaysLoopStore}
on:click={() => playerAlwaysLoopStore.set(!$playerAlwaysLoopStore)}
/>
<span></span>
</label>
</nav>
</div>
{#if Capacitor.getPlatform() === 'android'}
<div class="field no-margin">
<nav class="no-padding">
<div class="max">
<div>{$_('layout.player.backgroundPlay')}</div>
</div>
<label class="switch">
<input
type="checkbox"
bind:checked={$playerAndroidBgPlayer}
on:click={() => playerAndroidBgPlayer.set(!$playerAndroidBgPlayer)}
/>
<span></span>
</label>
</nav>
</div>
<div class="field no-margin">
<nav class="no-padding">
<div class="max">
<div>{$_('layout.player.lockOrientation')}</div>
</div>
<label class="switch">
<input
type="checkbox"
bind:checked={$playerAndroidLockOrientation}
on:click={() => playerAndroidLockOrientation.set(!$playerAndroidLockOrientation)}
/>
<span></span>
</label>
</nav>
</div>
{/if}
{#if Capacitor.isNativePlatform()}
<div class="field no-margin">
<nav class="no-padding">
<div class="max">
<div>{$_('layout.player.proxyVideos')}</div>
</div>
<label class="switch">
<input
type="checkbox"
bind:checked={$playerProxyVideosStore}
on:click={() => playerProxyVideosStore.set(!$playerProxyVideosStore)}
/>
<span></span>
</label>
</nav>
</div>
{/if}
<div class="field no-margin">
<nav class="no-padding">
<div class="max">
<div>{$_('layout.player.savePlaybackPosition')}</div>
</div>
<label class="switch">
<input
type="checkbox"
bind:checked={$playerSavePlaybackPositionStore}
on:click={() =>
playerSavePlaybackPositionStore.set(!$playerSavePlaybackPositionStore)}
/>
<span></span>
</label>
</nav>
</div>
<div class="field no-margin">
<nav class="no-padding">
<div class="max">
<div>{$_('layout.player.listenByDefault')}</div>
</div>
<label class="switch">
<input
type="checkbox"
bind:checked={$playerListenByDefaultStore}
on:click={() => playerListenByDefaultStore.set(!$playerListenByDefaultStore)}
/>
<span></span>
</label>
</nav>
</div>
<div class="field no-margin m l">
<nav class="no-padding">
<div class="max">
<div>{$_('layout.player.theatreModeByDefault')}</div>
</div>
<label class="switch">
<input
type="checkbox"
bind:checked={$playerTheatreModeByDefaultStore}
on:click={() =>
playerTheatreModeByDefaultStore.set(!$playerTheatreModeByDefaultStore)}
/>
<span></span>
</label>
</nav>
</div>
<div class="field no-margin">
<nav class="no-padding">
<div class="max">
<div>{$_('layout.player.autoPlayNextByDefault')}</div>
</div>
<label class="switch">
<input
type="checkbox"
bind:checked={$playerAutoplayNextByDefaultStore}
on:click={() =>
playerAutoplayNextByDefaultStore.set(!$playerAutoplayNextByDefaultStore)}
/>
<span></span>
</label>
</nav>
</div>
<div class="field no-margin">
<nav class="no-padding">
<div class="max">
<div>{$_('layout.player.silenceSkipper')}</div>
</div>
<label class="switch">
<input
type="checkbox"
bind:checked={$silenceSkipperStore}
on:click={() => silenceSkipperStore.set(!$silenceSkipperStore)}
/>
<span></span>
</label>
</nav>
</div>
{#if Capacitor.isNativePlatform()}
<div class="field no-margin">
<nav class="no-padding">
<div class="max">
<div>{$_('layout.player.youtubeJsAlways')}</div>
</div>
<label class="switch">
<input
type="checkbox"
bind:checked={$playerYouTubeJsAlways}
on:click={() => playerYouTubeJsAlways.set(!$playerYouTubeJsAlways)}
/>
<span></span>
</label>
</nav>
</div>
<div class="field no-margin">
<nav class="no-padding">
<div class="max">
<div>{$_('layout.player.youtubeJsFallback')}</div>
</div>
<label class="switch">
<input
type="checkbox"
bind:checked={$playerYouTubeJsFallback}
on:click={() => playerYouTubeJsFallback.set(!$playerYouTubeJsFallback)}
/>
<span></span>
</label>
</nav>
</div>
{/if}
</div>
<div class="page padding" class:active={activeTab === 'data'}>
<p style="width: 240px;">
<a
href={`${get(instanceStore)}/preferences`}
target="_blank"
rel="noopener noreferrer"
class="link"
>
{$_('layout.dataPreferences.content')}
</a>
</p>
</div>
<div class="page padding" class:active={activeTab === 'ryd'}>
<form
on:submit|preventDefault={() =>
returnYTDislikesInstanceStore.set(ensureNoTrailingSlash(returnYTInstance))}
>
<nav>
<div class="field label border max">
<input bind:value={returnYTInstance} name="returnyt-instance" type="text" />
<label for="returnyt-instance">{$_('layout.instanceUrl')}</label>
</div>
<button class="square round">
<i>done</i>
</button>
</nav>
</form>
<nav class="no-padding">
<div class="max">
<p>{$_('enabled')}</p>
</div>
<label class="switch">
<input
bind:checked={$returnYtDislikesStore}
on:click={() => returnYtDislikesStore.set(!$returnYtDislikesStore)}
type="checkbox"
/>
<span></span>
</label>
</nav>
</div>
<div class="page padding" class:active={activeTab === 'api extended'}>
<form
on:submit|preventDefault={() =>
synciousInstanceStore.set(ensureNoTrailingSlash(synciousInstance))}
>
<nav>
<div class="field label border max">
<input bind:value={synciousInstance} name="syncious-instance" type="text" />
<label for="syncious-instance">{$_('layout.instanceUrl')}</label>
</div>
<button class="square round">
<i>done</i>
</button>
</nav>
</form>
<nav class="no-padding">
<div class="max">
<p>{$_('enabled')}</p>
</div>
<label class="switch">
<input
bind:checked={$synciousStore}
on:click={() => synciousStore.set(!$synciousStore)}
type="checkbox"
/>
<span></span>
</label>
</nav>
</div>
<div class="page padding" class:active={activeTab === 'sponsorblock'}>
<form
on:submit|preventDefault={() =>
sponsorBlockUrlStore.set(ensureNoTrailingSlash(sponsorBlockInstance))}
>
<nav>
<div class="field label border max">
<input bind:value={sponsorBlockInstance} name="sponsorblock-instance" type="text" />
<label for="sponsorblock-instance">{$_('layout.instanceUrl')}</label>
</div>
<button class="square round">
<i>done</i>
</button>
</nav>
</form>
<nav class="no-padding">
<div class="max">
<p>{$_('enabled')}</p>
</div>
<label class="switch">
<input
bind:checked={$sponsorBlockStore}
on:click={() => sponsorBlockStore.set(!$sponsorBlockStore)}
type="checkbox"
/>
<span></span>
</label>
</nav>
<nav class="no-padding">
<div class="max">
<p>{$_('layout.sponsors.disableToast')}</p>
</div>
<label class="switch">
<input
bind:checked={$sponsorBlockDisplayToastStore}
on:click={() => sponsorBlockDisplayToastStore.set(!$sponsorBlockDisplayToastStore)}
type="checkbox"
/>
<span></span>
</label>
</nav>
<nav class="no-padding">
<div class="max">
<p>{$_('layout.sponsors.disableTimeline')}</p>
</div>
<label class="switch">
<input
bind:checked={$sponsorBlockTimelineStore}
on:click={() => sponsorBlockTimelineStore.set(!$sponsorBlockTimelineStore)}
type="checkbox"
/>
<span></span>
</label>
</nav>
<hr style="margin: 1em 0;" />
<p class="bold">{$_('layout.sponsors.Catagories')}</p>
{#each sponsorCategories as sponsor}
<div class="field middle-align no-margin">
<nav class="no-padding">
<div class="max">
<p>{sponsor.name}</p>
</div>
<label class="switch">
<input
type="checkbox"
checked={sponsorCategoriesList.includes(sponsor.category)}
on:click={() => toggleSponsor(sponsor.category)}
/>
<span></span>
</label>
</nav>
</div>
{/each}
</div>
<div class="page padding" class:active={activeTab === 'dearrow'}>
<form
on:submit|preventDefault={() => deArrowInstanceStore.set(ensureNoTrailingSlash(deArrowUrl))}
>
<nav>
<div class="field label border max">
<input bind:value={deArrowUrl} name="dearrow-instance" type="text" />
<label for="dearrow-instance">{$_('layout.instanceUrl')}</label>
</div>
<button class="square round">
<i>done</i>
</button>
</nav>
</form>
<form on:submit|preventDefault={() => deArrowThumbnailInstanceStore.set(deArrowThumbnailUrl)}>
<nav>
<div class="field label border max">
<input bind:value={deArrowThumbnailUrl} name="dearrow-thumbnail-instance" type="text" />
<label for="dearrow-thumbnail-instance"
>{$_('layout.deArrow.thumbnailInstanceUrl')}</label
>
</div>
<button class="square round">
<i>done</i>
</button>
</nav>
</form>
<nav class="no-padding">
<div class="max">
<p>{$_('layout.deArrow.titleOnly')}</p>
</div>
<label class="switch">
<input
bind:checked={$deArrowTitlesOnly}
on:click={() => deArrowTitlesOnly.set(!$deArrowTitlesOnly)}
type="checkbox"
/>
<span></span>
</label>
</nav>
<nav class="no-padding">
<div class="max">
<p>{$_('enabled')}</p>
</div>
<label class="switch">
<input
bind:checked={$deArrowEnabledStore}
on:click={() => deArrowEnabledStore.set(!$deArrowEnabledStore)}
type="checkbox"
/>
<span></span>
</label>
</nav>
</div>
</div>
</dialog>
<style>
form {
margin: 1em 0;
}
dialog {
width: 1320px;
}
.tabbed > a {
flex: none;
}
@media screen and (max-width: 1320px) {
.tabbed {
display: flex !important;
flex-direction: column !important;
gap: 0 !important;
flex: 0 !important;
margin-bottom: 1em !important;
align-items: start !important;
padding: 1em 0 !important;
background-color: transparent;
border-bottom: solid 1px var(--outline-variant);
}
.tabbed > a {
font-size: 1.5rem !important;
padding: 0.5em 0.3em !important;
border-radius: 3em !important;
}
.tabbed.small {
block-size: 100% !important;
border-radius: 0 !important;
}
}
</style>
@@ -1,10 +1,12 @@
<script lang="ts">
import { getBestThumbnail, proxyGoogleImage } from '$lib/images';
import { cleanNumber } from '$lib/time';
import { onMount } from 'svelte';
import { _ } from 'svelte-i18n';
import { get } from 'svelte/store';
import type { Channel } from './api/model';
import { cleanNumber, getBestThumbnail, proxyGoogleImage, truncate } from './misc';
import { interfaceLowBandwidthMode } from './store';
import type { Channel } from '../api/model';
import { truncate } from '../misc';
import { interfaceLowBandwidthMode } from '../store';
export let channel: Channel;
@@ -1,9 +1,10 @@
<script lang="ts">
import { getComments } from '$lib/api';
import { type Comment, type Comments } from '$lib/api/model';
import { getBestThumbnail, proxyGoogleImage } from '$lib/images';
import { interfaceLowBandwidthMode } from '$lib/store';
import { numberWithCommas } from '$lib/time';
import { _ } from 'svelte-i18n';
import { getComments } from './api';
import { type Comment, type Comments } from './api/model';
import { getBestThumbnail, numberWithCommas, proxyGoogleImage } from './misc';
import { interfaceLowBandwidthMode } from './store';
export let comment: Comment;
export let videoId: string;
@@ -1,7 +1,8 @@
<script lang="ts">
import { cleanNumber } from '$lib/time';
import { _ } from 'svelte-i18n';
import type { HashTag } from './api/model';
import { cleanNumber, truncate } from './misc';
import type { HashTag } from '../api/model';
import { truncate } from '../misc';
export let hashtag: HashTag;
@@ -1,8 +1,8 @@
<script lang="ts">
import ui from 'beercss';
import { onMount } from 'svelte';
import { darkModeStore, themeColorStore } from './store';
import { getDynamicTheme } from './theme';
import { darkModeStore, themeColorStore } from '../store';
import { getDynamicTheme } from '../theme';
export let classes = '';
@@ -1,8 +1,8 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { get } from 'svelte/store';
import { proxyVideoUrl } from './misc';
import { miniPlayerSrcStore, playerProxyVideosStore } from './store';
import { proxyVideoUrl } from '../misc';
import { miniPlayerSrcStore, playerProxyVideosStore } from '../store';
let currentTime: number = 0;
@@ -2,9 +2,12 @@
import 'vidstack/bundle';
import { page } from '$app/stores';
import { getBestThumbnail, proxyGoogleImage } from '$lib/images';
import { padTime, videoLength } from '$lib/time';
import { Capacitor } from '@capacitor/core';
import { ScreenOrientation, type ScreenOrientationResult } from '@capacitor/screen-orientation';
import { StatusBar } from '@capacitor/status-bar';
import { StatusBar, Style } from '@capacitor/status-bar';
import { NavigationBar } from '@hugotomazi/capacitor-navigation-bar';
import { AudioPlayer } from '@mediagrid/capacitor-native-audio';
import type { Page } from '@sveltejs/kit';
import { SponsorBlock, type Category, type Segment } from 'sponsorblock-api';
@@ -13,15 +16,9 @@
import { get } from 'svelte/store';
import type { FullscreenChangeEvent, MediaTimeUpdateEvent, PlayerSrc } from 'vidstack';
import type { MediaPlayerElement } from 'vidstack/elements';
import { deleteVideoProgress, getVideoProgress, saveVideoProgress } from './api';
import type { VideoPlay } from './api/model';
import {
getBestThumbnail,
proxyVideoUrl,
pullBitratePreference,
videoLength,
type PhasedDescription
} from './misc';
import { deleteVideoProgress, getVideoProgress, saveVideoProgress } from '../api';
import type { VideoPlay } from '../api/model';
import { proxyVideoUrl, pullBitratePreference, type PhasedDescription } from '../misc';
import {
authStore,
instanceStore,
@@ -41,8 +38,8 @@
sponsorBlockUrlStore,
synciousInstanceStore,
synciousStore
} from './store';
import { getDynamicTheme, setStatusBarColor } from './theme';
} from '../store';
import { getDynamicTheme, setStatusBarColor } from '../theme';
export let data: { video: VideoPlay; content: PhasedDescription; playlistId: string | null };
export let audioMode = false;
@@ -222,7 +219,7 @@
endTime = data.content.timestamps[timestampIndex + 1].timePretty;
}
chapterWebVTT += `${timestamp.timePretty} --> ${endTime}\n${timestamp.title.replaceAll('-', '').trim()}\n\n`;
chapterWebVTT += `${padTime(timestamp.timePretty)} --> ${padTime(endTime)}\n${timestamp.title.replaceAll('-', '').trim()}\n\n`;
timestampIndex += 1;
});
@@ -434,6 +431,10 @@
if (event.detail) {
// Ensure bar color is black while in fullscreen
await StatusBar.setBackgroundColor({ color: '#000000' });
await NavigationBar.setColor({
color: '#000000'
});
await StatusBar.setStyle({ style: Style.Dark });
} else {
await setStatusBarColor();
}
@@ -604,7 +605,9 @@
>
<media-provider>
{#if !audioMode}
<media-poster class="vds-poster" src={getBestThumbnail(data.video.videoThumbnails, 1251, 781)}
<media-poster
class="vds-poster"
src={proxyGoogleImage(getBestThumbnail(data.video.videoThumbnails, 1251, 781))}
></media-poster>
{/if}
</media-provider>
@@ -1,16 +1,17 @@
<script lang="ts">
import { getBestThumbnail } from '$lib/images';
import { letterCase } from '$lib/letterCasing';
import { onMount } from 'svelte';
import { _ } from 'svelte-i18n';
import { get } from 'svelte/store';
import type { Playlist } from './api/model';
import { getBestThumbnail, letterCase, truncate } from './misc';
import { interfaceLowBandwidthMode } from './store';
import type { Playlist } from '../api/model';
import { truncate } from '../misc';
import { interfaceLowBandwidthMode } from '../store';
export let playlist: Playlist;
export let disabled: boolean = false;
let loading = true;
let failed = false;
let img: HTMLImageElement;
@@ -35,7 +36,6 @@
};
img.onerror = () => {
loading = false;
failed = true;
};
});
</script>
@@ -3,8 +3,12 @@
import Mousetrap from 'mousetrap';
import { createEventDispatcher, onMount } from 'svelte';
import { _ } from 'svelte-i18n';
import { getSearchSuggestions } from './api';
import { interfaceSearchSuggestionsStore } from './store';
import { getSearchSuggestions } from '../api';
import {
interfaceSearchHistoryEnabled,
interfaceSearchSuggestionsStore,
searchHistoryStore
} from '../store';
const dispatch = createEventDispatcher();
@@ -37,6 +41,17 @@
selectedSuggestionIndex = -1;
goto(`/search/${encodeURIComponent(search)}`);
dispatch('searchSubmitted');
suggestionsForSearch = [];
showSearchBox = false;
if ($interfaceSearchHistoryEnabled && !$searchHistoryStore.includes(search)) {
let pastHistory = $searchHistoryStore;
if (pastHistory.length > 15) {
pastHistory.pop();
}
searchHistoryStore.set([search, ...pastHistory]);
}
}
function handleKeyDown(event: KeyboardEvent) {
@@ -129,6 +144,13 @@
</a>
{/each}
{/if}
{#if !suggestionsForSearch.length && $interfaceSearchHistoryEnabled}
{#each $searchHistoryStore as history}
<a class="row" href={`/search/${encodeURIComponent(history)}`}>
<div>{history}</div>
</a>
{/each}
{/if}
</menu>
{/if}
</div>
@@ -0,0 +1,37 @@
<script lang="ts">
import { _ } from 'svelte-i18n';
import { get } from 'svelte/store';
import { ensureNoTrailingSlash } from '../../misc';
import { synciousInstanceStore, synciousStore } from '../../store';
let synciousInstance = get(synciousInstanceStore);
</script>
<form
on:submit|preventDefault={() =>
synciousInstanceStore.set(ensureNoTrailingSlash(synciousInstance))}
>
<nav>
<div class="field label border max">
<input bind:value={synciousInstance} name="syncious-instance" type="text" />
<label for="syncious-instance">{$_('layout.instanceUrl')}</label>
</div>
<button class="square round">
<i>done</i>
</button>
</nav>
</form>
<nav class="no-padding">
<div class="max">
<p>{$_('enabled')}</p>
</div>
<label class="switch">
<input
bind:checked={$synciousStore}
on:click={() => synciousStore.set(!$synciousStore)}
type="checkbox"
/>
<span></span>
</label>
</nav>
@@ -0,0 +1,16 @@
<script lang="ts">
import { instanceStore } from '$lib/store';
import { _ } from 'svelte-i18n';
import { get } from 'svelte/store';
</script>
<p style="width: 240px;">
<a
href={`${get(instanceStore)}/preferences`}
target="_blank"
rel="noopener noreferrer"
class="link"
>
{$_('layout.dataPreferences.content')}
</a>
</p>
@@ -0,0 +1,66 @@
<script lang="ts">
import { _ } from 'svelte-i18n';
import { get } from 'svelte/store';
import { ensureNoTrailingSlash } from '../../misc';
import {
deArrowEnabledStore,
deArrowInstanceStore,
deArrowThumbnailInstanceStore,
deArrowTitlesOnly
} from '../../store';
let deArrowUrl = get(deArrowInstanceStore);
let deArrowThumbnailUrl = get(deArrowThumbnailInstanceStore);
</script>
<form on:submit|preventDefault={() => deArrowInstanceStore.set(ensureNoTrailingSlash(deArrowUrl))}>
<nav>
<div class="field label border max">
<input bind:value={deArrowUrl} name="dearrow-instance" type="text" />
<label for="dearrow-instance">{$_('layout.instanceUrl')}</label>
</div>
<button class="square round">
<i>done</i>
</button>
</nav>
</form>
<form on:submit|preventDefault={() => deArrowThumbnailInstanceStore.set(deArrowThumbnailUrl)}>
<nav>
<div class="field label border max">
<input bind:value={deArrowThumbnailUrl} name="dearrow-thumbnail-instance" type="text" />
<label for="dearrow-thumbnail-instance">{$_('layout.deArrow.thumbnailInstanceUrl')}</label>
</div>
<button class="square round">
<i>done</i>
</button>
</nav>
</form>
<nav class="no-padding">
<div class="max">
<p>{$_('layout.deArrow.titleOnly')}</p>
</div>
<label class="switch">
<input
bind:checked={$deArrowTitlesOnly}
on:click={() => deArrowTitlesOnly.set(!$deArrowTitlesOnly)}
type="checkbox"
/>
<span></span>
</label>
</nav>
<nav class="no-padding">
<div class="max">
<p>{$_('enabled')}</p>
</div>
<label class="switch">
<input
bind:checked={$deArrowEnabledStore}
on:click={() => deArrowEnabledStore.set(!$deArrowEnabledStore)}
type="checkbox"
/>
<span></span>
</label>
</nav>
@@ -0,0 +1,283 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { bookmarkletSaveToUrl } from '$lib/externalSettings';
import { letterCase, titleCases } from '$lib/letterCasing';
import { setAmoledTheme } from '$lib/theme';
import { Clipboard } from '@capacitor/clipboard';
import { Capacitor } from '@capacitor/core';
import ui from 'beercss';
import { iso31661 } from 'iso-3166';
import { _ } from 'svelte-i18n';
import { get } from 'svelte/store';
import { ensureNoTrailingSlash } from '../../misc';
import { getPages } from '../../navPages';
import {
authStore,
darkModeStore,
instanceStore,
interfaceAmoledTheme,
interfaceAutoExpandComments,
interfaceAutoExpandDesc,
interfaceDefaultPage,
interfaceDisplayThumbnailAvatars,
interfaceForceCase,
interfaceLowBandwidthMode,
interfacePreviewVideoOnHoverStore,
interfaceRegionStore,
interfaceSearchHistoryEnabled,
interfaceSearchSuggestionsStore,
searchHistoryStore,
themeColorStore
} from '../../store';
let invidiousInstance = get(instanceStore);
let region = get(interfaceRegionStore);
let forceCase = get(interfaceForceCase);
let defaultPage = get(interfaceDefaultPage);
async function setColor(color: any) {
const target = color.target;
const hex = (target as { value: string }).value;
await ui('theme', hex);
setAmoledTheme();
themeColorStore.set(hex);
}
function toggleDarkMode() {
const isDark = get(darkModeStore);
if (isDark) {
ui('mode', 'light');
darkModeStore.set(false);
} else {
ui('mode', 'dark');
darkModeStore.set(true);
}
}
</script>
{#if Capacitor.isNativePlatform()}
<form
on:submit|preventDefault={() => {
instanceStore.set(ensureNoTrailingSlash(invidiousInstance));
authStore.set(null);
goto('/', { replaceState: true });
ui('#dialog-settings');
}}
>
<nav>
<div class="field label border max">
<input bind:value={invidiousInstance} name="invidious-instance" type="text" />
<label for="invidious-instance">{$_('layout.instanceUrl')}</label>
</div>
<button class="square round">
<i>done</i>
</button>
</nav>
</form>
{/if}
<div class="margin"></div>
<button on:click={toggleDarkMode} class="no-margin">
{#if !$darkModeStore}
<i>dark_mode</i>
<span>{$_('layout.theme.darkMode')}</span>
{:else}
<i>light_mode</i>
<span>{$_('layout.theme.lightMode')}</span>
{/if}
</button>
<button>
<i>palette</i>
<span>{$_('layout.theme.color')}</span>
<input on:change={setColor} type="color" />
</button>
<div class="margin"></div>
{#if $darkModeStore}
<div class="field no-margin">
<nav class="no-padding">
<div class="max">
<div>{$_('layout.theme.AmoledTheme')}</div>
</div>
<label class="switch">
<input
type="checkbox"
bind:checked={$interfaceAmoledTheme}
on:click={() => interfaceAmoledTheme.set(!$interfaceAmoledTheme)}
/>
<span></span>
</label>
</nav>
</div>
{/if}
<div class="field no-margin">
<nav class="no-padding">
<div class="max">
<div>{$_('layout.displayThumbnailAvatars')}</div>
</div>
<label class="switch">
<input
type="checkbox"
bind:checked={$interfaceDisplayThumbnailAvatars}
on:click={() => interfaceDisplayThumbnailAvatars.set(!$interfaceDisplayThumbnailAvatars)}
/>
<span></span>
</label>
</nav>
</div>
<div class="field no-margin">
<nav class="no-padding">
<div class="max">
<div>{$_('layout.searchHistory')}</div>
</div>
<label class="switch">
<input
type="checkbox"
bind:checked={$interfaceSearchHistoryEnabled}
on:click={() => {
interfaceSearchHistoryEnabled.set(!$interfaceSearchHistoryEnabled);
searchHistoryStore.set([]);
}}
/>
<span></span>
</label>
</nav>
</div>
<div class="field no-margin">
<nav class="no-padding">
<div class="max">
<div>{$_('layout.lowBandwidthMode')}</div>
</div>
<label class="switch">
<input
type="checkbox"
bind:checked={$interfaceLowBandwidthMode}
on:click={() => interfaceLowBandwidthMode.set(!$interfaceLowBandwidthMode)}
/>
<span></span>
</label>
</nav>
</div>
<div class="field no-margin">
<nav class="no-padding">
<div class="max">
<div>{$_('layout.searchSuggestions')}</div>
</div>
<label class="switch">
<input
type="checkbox"
bind:checked={$interfaceSearchSuggestionsStore}
on:click={() => interfaceSearchSuggestionsStore.set(!$interfaceSearchSuggestionsStore)}
/>
<span></span>
</label>
</nav>
</div>
<div class="field no-margin">
<nav class="no-padding">
<div class="max">
<div>{$_('layout.previewVideoOnHover')}</div>
</div>
<label class="switch">
<input
type="checkbox"
bind:checked={$interfacePreviewVideoOnHoverStore}
on:click={() => interfacePreviewVideoOnHoverStore.set(!$interfacePreviewVideoOnHoverStore)}
/>
<span></span>
</label>
</nav>
</div>
<div class="field no-margin">
<nav class="no-padding">
<div class="max">
<div>{$_('layout.expandDescription')}</div>
</div>
<label class="switch">
<input
type="checkbox"
bind:checked={$interfaceAutoExpandDesc}
on:click={() => interfaceAutoExpandDesc.set(!$interfaceAutoExpandDesc)}
/>
<span></span>
</label>
</nav>
</div>
<div class="field no-margin">
<nav class="no-padding">
<div class="max">
<div>{$_('layout.expandComments')}</div>
</div>
<label class="switch">
<input
type="checkbox"
bind:checked={$interfaceAutoExpandComments}
on:click={() => interfaceAutoExpandComments.set(!$interfaceAutoExpandComments)}
/>
<span></span>
</label>
</nav>
</div>
<div class="field label suffix border">
<select name="region" bind:value={region} on:change={() => interfaceRegionStore.set(region)}>
{#each iso31661 as region}
<option selected={$interfaceRegionStore === region.alpha2} value={region.alpha2}
>{region.alpha2} - {region.name}</option
>
{/each}
</select>
<label for="region">{$_('region')}</label>
<i>arrow_drop_down</i>
</div>
<div class="field label suffix border">
<select name="case" bind:value={forceCase} on:change={() => interfaceForceCase.set(forceCase)}>
<option selected={$interfaceForceCase === null} value={null}>Default</option>
{#each titleCases as caseType}
<option selected={$interfaceForceCase === caseType} value={caseType}
>{letterCase(`${caseType}`, caseType)}</option
>
{/each}
</select>
<label for="case">{$_('letterCase')}</label>
<i>arrow_drop_down</i>
</div>
<div class="field label suffix border">
<select
name="defaultPage"
bind:value={defaultPage}
on:change={() => interfaceDefaultPage.set(defaultPage)}
>
{#each getPages() as page}
{#if !page.requiresAuth || get(authStore)}
<option selected={$interfaceDefaultPage === page.href} value={page.href}>{page.name}</option
>
{/if}
{/each}
</select>
<label for="defaultPage">{$_('defaultPage')}</label>
<i>arrow_drop_down</i>
</div>
{#if !Capacitor.isNativePlatform()}
<div class="settings">
<h6>{$_('layout.bookmarklet')}</h6>
<button
class="no-margin"
on:click={async () => await Clipboard.write({ string: bookmarkletSaveToUrl() })}
>{$_('copyUrl')}</button
>
</div>
{/if}
@@ -0,0 +1,260 @@
<script lang="ts">
import { titleCase } from '$lib/letterCasing';
import { Capacitor } from '@capacitor/core';
import ISO6391 from 'iso-639-1';
import { _ } from 'svelte-i18n';
import { get } from 'svelte/store';
import {
playerAlwaysLoopStore,
playerAndroidBgPlayer,
playerAndroidLockOrientation,
playerAutoPlayStore,
playerAutoplayNextByDefaultStore,
playerDefaultLanguage,
playerListenByDefaultStore,
playerMiniPlayerStore,
playerProxyVideosStore,
playerSavePlaybackPositionStore,
playerTheatreModeByDefaultStore,
playerYouTubeJsAlways,
playerYouTubeJsFallback,
silenceSkipperStore
} from '../../store';
let defaultLanguage = get(playerDefaultLanguage);
const languageNames = ISO6391.getAllCodes().map((code) =>
ISO6391.getName(code).toLocaleLowerCase()
);
</script>
<div class="margin"></div>
<div class="field label suffix border">
<select
name="case"
bind:value={defaultLanguage}
on:change={() => playerDefaultLanguage.set(defaultLanguage)}
>
{#each languageNames as language}
<option selected={$playerDefaultLanguage === language} value={language}
>{titleCase(language)}</option
>
{/each}
</select>
<label for="case">{$_('player.defaultLanguage')}</label>
<i>arrow_drop_down</i>
</div>
<div class="field no-margin">
<nav class="no-padding">
<div class="max">
<div>{$_('layout.player.miniPlayer')}</div>
</div>
<label class="switch">
<input
type="checkbox"
bind:checked={$playerMiniPlayerStore}
on:click={() => playerMiniPlayerStore.set(!$playerMiniPlayerStore)}
/>
<span></span>
</label>
</nav>
</div>
<div class="field no-margin">
<nav class="no-padding">
<div class="max">
<div>{$_('layout.player.autoPlay')}</div>
</div>
<label class="switch">
<input
type="checkbox"
bind:checked={$playerAutoPlayStore}
on:click={() => playerAutoPlayStore.set(!$playerAutoPlayStore)}
/>
<span></span>
</label>
</nav>
</div>
<div class="field no-margin">
<nav class="no-padding">
<div class="max">
<div>{$_('layout.player.alwaysLoopVideo')}</div>
</div>
<label class="switch">
<input
type="checkbox"
bind:checked={$playerAlwaysLoopStore}
on:click={() => playerAlwaysLoopStore.set(!$playerAlwaysLoopStore)}
/>
<span></span>
</label>
</nav>
</div>
{#if Capacitor.getPlatform() === 'android'}
<div class="field no-margin">
<nav class="no-padding">
<div class="max">
<div>{$_('layout.player.backgroundPlay')}</div>
</div>
<label class="switch">
<input
type="checkbox"
bind:checked={$playerAndroidBgPlayer}
on:click={() => playerAndroidBgPlayer.set(!$playerAndroidBgPlayer)}
/>
<span></span>
</label>
</nav>
</div>
<div class="field no-margin">
<nav class="no-padding">
<div class="max">
<div>{$_('layout.player.lockOrientation')}</div>
</div>
<label class="switch">
<input
type="checkbox"
bind:checked={$playerAndroidLockOrientation}
on:click={() => playerAndroidLockOrientation.set(!$playerAndroidLockOrientation)}
/>
<span></span>
</label>
</nav>
</div>
{/if}
{#if Capacitor.isNativePlatform()}
<div class="field no-margin">
<nav class="no-padding">
<div class="max">
<div>{$_('layout.player.proxyVideos')}</div>
</div>
<label class="switch">
<input
type="checkbox"
bind:checked={$playerProxyVideosStore}
on:click={() => playerProxyVideosStore.set(!$playerProxyVideosStore)}
/>
<span></span>
</label>
</nav>
</div>
{/if}
<div class="field no-margin">
<nav class="no-padding">
<div class="max">
<div>{$_('layout.player.savePlaybackPosition')}</div>
</div>
<label class="switch">
<input
type="checkbox"
bind:checked={$playerSavePlaybackPositionStore}
on:click={() => playerSavePlaybackPositionStore.set(!$playerSavePlaybackPositionStore)}
/>
<span></span>
</label>
</nav>
</div>
<div class="field no-margin">
<nav class="no-padding">
<div class="max">
<div>{$_('layout.player.listenByDefault')}</div>
</div>
<label class="switch">
<input
type="checkbox"
bind:checked={$playerListenByDefaultStore}
on:click={() => playerListenByDefaultStore.set(!$playerListenByDefaultStore)}
/>
<span></span>
</label>
</nav>
</div>
<div class="field no-margin m l">
<nav class="no-padding">
<div class="max">
<div>{$_('layout.player.theatreModeByDefault')}</div>
</div>
<label class="switch">
<input
type="checkbox"
bind:checked={$playerTheatreModeByDefaultStore}
on:click={() => playerTheatreModeByDefaultStore.set(!$playerTheatreModeByDefaultStore)}
/>
<span></span>
</label>
</nav>
</div>
<div class="field no-margin">
<nav class="no-padding">
<div class="max">
<div>{$_('layout.player.autoPlayNextByDefault')}</div>
</div>
<label class="switch">
<input
type="checkbox"
bind:checked={$playerAutoplayNextByDefaultStore}
on:click={() => playerAutoplayNextByDefaultStore.set(!$playerAutoplayNextByDefaultStore)}
/>
<span></span>
</label>
</nav>
</div>
<div class="field no-margin">
<nav class="no-padding">
<div class="max">
<div>{$_('layout.player.silenceSkipper')}</div>
</div>
<label class="switch">
<input
type="checkbox"
bind:checked={$silenceSkipperStore}
on:click={() => silenceSkipperStore.set(!$silenceSkipperStore)}
/>
<span></span>
</label>
</nav>
</div>
{#if Capacitor.isNativePlatform()}
<div class="field no-margin">
<nav class="no-padding">
<div class="max">
<div>{$_('layout.player.youtubeJsAlways')}</div>
</div>
<label class="switch">
<input
type="checkbox"
bind:checked={$playerYouTubeJsAlways}
on:click={() => playerYouTubeJsAlways.set(!$playerYouTubeJsAlways)}
/>
<span></span>
</label>
</nav>
</div>
<div class="field no-margin">
<nav class="no-padding">
<div class="max">
<div>{$_('layout.player.youtubeJsFallback')}</div>
</div>
<label class="switch">
<input
type="checkbox"
bind:checked={$playerYouTubeJsFallback}
on:click={() => playerYouTubeJsFallback.set(!$playerYouTubeJsFallback)}
/>
<span></span>
</label>
</nav>
</div>
{/if}
@@ -0,0 +1,37 @@
<script lang="ts">
import { _ } from 'svelte-i18n';
import { get } from 'svelte/store';
import { ensureNoTrailingSlash } from '../../misc';
import { returnYTDislikesInstanceStore, returnYtDislikesStore } from '../../store';
let returnYTInstance = get(returnYTDislikesInstanceStore);
</script>
<form
on:submit|preventDefault={() =>
returnYTDislikesInstanceStore.set(ensureNoTrailingSlash(returnYTInstance))}
>
<nav>
<div class="field label border max">
<input bind:value={returnYTInstance} name="returnyt-instance" type="text" />
<label for="returnyt-instance">{$_('layout.instanceUrl')}</label>
</div>
<button class="square round">
<i>done</i>
</button>
</nav>
</form>
<nav class="no-padding">
<div class="max">
<p>{$_('enabled')}</p>
</div>
<label class="switch">
<input
bind:checked={$returnYtDislikesStore}
on:click={() => returnYtDislikesStore.set(!$returnYtDislikesStore)}
type="checkbox"
/>
<span></span>
</label>
</nav>
@@ -0,0 +1,205 @@
<script lang="ts">
import ISO6391 from 'iso-639-1';
import { onMount } from 'svelte';
import { _ } from 'svelte-i18n';
import { get } from 'svelte/store';
import {
deArrowInstanceStore,
deArrowThumbnailInstanceStore,
instanceStore,
interfaceDefaultPage,
interfaceForceCase,
interfaceRegionStore,
playerDefaultLanguage,
returnYTDislikesInstanceStore,
sponsorBlockCategoriesStore,
sponsorBlockUrlStore,
synciousInstanceStore
} from '../../store';
import ApiExtended from './ApiExtended.svelte';
import DataPreferences from './DataPreferences.svelte';
import DeArrow from './DeArrow.svelte';
import Interface from './Interface.svelte';
import Player from './Player.svelte';
import Ryd from './RYD.svelte';
import SponsorBlock from './SponsorBlock.svelte';
let sponsorCategoriesList: string[] = [];
sponsorBlockCategoriesStore.subscribe((value) => (sponsorCategoriesList = value));
let sponsorBlockInstance = get(sponsorBlockUrlStore);
let synciousInstance = get(synciousInstanceStore);
let returnYTInstance = get(returnYTDislikesInstanceStore);
let invidiousInstance = get(instanceStore);
let deArrowUrl = get(deArrowInstanceStore);
let deArrowThumbnailUrl = get(deArrowThumbnailInstanceStore);
let region = get(interfaceRegionStore);
let forceCase = get(interfaceForceCase);
let defaultLanguage = get(playerDefaultLanguage);
let defaultPage = get(interfaceDefaultPage);
const languageNames = ISO6391.getAllCodes().map((code) =>
ISO6391.getName(code).toLocaleLowerCase()
);
const sponsorCategories = [
{ name: $_('layout.sponsors.sponsor'), category: 'sponsor' },
{ name: $_('layout.sponsors.unpaidSelfPromotion'), category: 'selfpromo' },
{ name: $_('layout.sponsors.interactionReminder'), category: 'interaction' },
{ name: $_('layout.sponsors.intermissionIntroAnimation'), category: 'intro' },
{ name: $_('layout.sponsors.credits'), category: 'outro' },
{ name: $_('layout.sponsors.preViewRecapHook'), category: 'preview' },
{ name: $_('layout.sponsors.tangentJokes'), category: 'filler' }
];
function toggleSponsor(category: string) {
if (sponsorCategoriesList.includes(category)) {
sponsorBlockCategoriesStore.set(sponsorCategoriesList.filter((value) => value !== category));
} else {
sponsorCategoriesList.push(category);
sponsorBlockCategoriesStore.set(sponsorCategoriesList);
}
}
let activeTab:
| 'interface'
| 'player'
| 'data'
| 'ryd'
| 'api extended'
| 'sponsorblock'
| 'dearrow' = 'interface';
let dialogType = '';
function checkWidth() {
if (innerWidth <= 1320) {
dialogType = 'right';
} else {
dialogType = '';
}
}
onMount(() => {
checkWidth();
addEventListener('resize', () => checkWidth());
// Set default language if not set already based off browser.
if (!get(playerDefaultLanguage)) {
const languageCode = navigator.language || navigator.languages[0];
const languageName = ISO6391.getName(languageCode.split('-')[0]);
if (languageName) {
playerDefaultLanguage.set(languageName.toLowerCase());
}
}
});
</script>
<dialog id="dialog-settings" class={dialogType}>
<nav style="margin-bottom: 1em;">
<h4 class="max">{$_('layout.settings')}</h4>
<button class="circle transparent" data-ui="#dialog-settings"><i>close</i></button>
</nav>
<div>
<nav class="tabbed small">
<a class:active={activeTab === 'interface'} on:click={() => (activeTab = 'interface')}>
<i>grid_view</i>
<span>{$_('layout.interface')}</span>
</a>
<a class:active={activeTab === 'player'} on:click={() => (activeTab = 'player')}>
<i>smart_display</i>
<span>{$_('layout.player.title')}</span>
</a>
<a class:active={activeTab === 'data'} on:click={() => (activeTab = 'data')}>
<i>save</i>
<span>{$_('layout.dataPreferences.dataPreferences')}</span>
</a>
<a class:active={activeTab === 'ryd'} on:click={() => (activeTab = 'ryd')}>
<i>thumb_down</i>
<span>RYD</span>
</a>
<a class:active={activeTab === 'api extended'} on:click={() => (activeTab = 'api extended')}>
<i>sync</i>
<span>API Extended</span>
</a>
<a class:active={activeTab === 'sponsorblock'} on:click={() => (activeTab = 'sponsorblock')}>
<i>block</i>
<span>Sponsorblock</span>
</a>
<a class:active={activeTab === 'dearrow'} on:click={() => (activeTab = 'dearrow')}>
<i>keyboard_double_arrow_down</i>
<span>{$_('layout.deArrow.title')}</span>
</a>
<a href="https://github.com/sponsors/WardPearce" target="_blank" referrerpolicy="no-referrer">
<i>favorite</i>
<span>{$_('donate')}</span>
</a>
<a
href="https://github.com/Materialious/Materialious"
target="_blank"
referrerpolicy="no-referrer"
>
<i>code</i>
<span>GitHub</span>
</a>
</nav>
<div class="page padding" class:active={activeTab === 'interface'}>
<Interface />
</div>
<div class="page padding" class:active={activeTab === 'player'}>
<Player />
</div>
<div class="page padding" class:active={activeTab === 'data'}>
<DataPreferences />
</div>
<div class="page padding" class:active={activeTab === 'ryd'}>
<Ryd />
</div>
<div class="page padding" class:active={activeTab === 'api extended'}>
<ApiExtended />
</div>
<div class="page padding" class:active={activeTab === 'sponsorblock'}>
<SponsorBlock />
</div>
<div class="page padding" class:active={activeTab === 'dearrow'}>
<DeArrow />
</div>
</div>
</dialog>
<style>
dialog {
width: 1320px;
}
.tabbed > a {
flex: none;
}
@media screen and (max-width: 1320px) {
.tabbed {
display: flex !important;
flex-direction: column !important;
gap: 0 !important;
flex: 0 !important;
margin-bottom: 1em !important;
align-items: start !important;
padding: 1em 0 !important;
background-color: transparent;
border-bottom: solid 1px var(--outline-variant);
}
.tabbed > a {
font-size: 1.5rem !important;
padding: 0.5em 0.3em !important;
border-radius: 3em !important;
}
.tabbed.small {
block-size: 100% !important;
border-radius: 0 !important;
}
}
</style>
@@ -0,0 +1,115 @@
<script lang="ts">
import { _ } from 'svelte-i18n';
import { get } from 'svelte/store';
import { ensureNoTrailingSlash } from '../../misc';
import {
sponsorBlockCategoriesStore,
sponsorBlockDisplayToastStore,
sponsorBlockStore,
sponsorBlockTimelineStore,
sponsorBlockUrlStore
} from '../../store';
let sponsorCategoriesList: string[] = [];
sponsorBlockCategoriesStore.subscribe((value) => (sponsorCategoriesList = value));
let sponsorBlockInstance = get(sponsorBlockUrlStore);
const sponsorCategories = [
{ name: $_('layout.sponsors.sponsor'), category: 'sponsor' },
{ name: $_('layout.sponsors.unpaidSelfPromotion'), category: 'selfpromo' },
{ name: $_('layout.sponsors.interactionReminder'), category: 'interaction' },
{ name: $_('layout.sponsors.intermissionIntroAnimation'), category: 'intro' },
{ name: $_('layout.sponsors.credits'), category: 'outro' },
{ name: $_('layout.sponsors.preViewRecapHook'), category: 'preview' },
{ name: $_('layout.sponsors.tangentJokes'), category: 'filler' }
];
function toggleSponsor(category: string) {
if (sponsorCategoriesList.includes(category)) {
sponsorBlockCategoriesStore.set(sponsorCategoriesList.filter((value) => value !== category));
} else {
sponsorCategoriesList.push(category);
sponsorBlockCategoriesStore.set(sponsorCategoriesList);
}
}
</script>
<form
on:submit|preventDefault={() =>
sponsorBlockUrlStore.set(ensureNoTrailingSlash(sponsorBlockInstance))}
>
<nav>
<div class="field label border max">
<input bind:value={sponsorBlockInstance} name="sponsorblock-instance" type="text" />
<label for="sponsorblock-instance">{$_('layout.instanceUrl')}</label>
</div>
<button class="square round">
<i>done</i>
</button>
</nav>
</form>
<nav class="no-padding">
<div class="max">
<p>{$_('enabled')}</p>
</div>
<label class="switch">
<input
bind:checked={$sponsorBlockStore}
on:click={() => sponsorBlockStore.set(!$sponsorBlockStore)}
type="checkbox"
/>
<span></span>
</label>
</nav>
<nav class="no-padding">
<div class="max">
<p>{$_('layout.sponsors.disableToast')}</p>
</div>
<label class="switch">
<input
bind:checked={$sponsorBlockDisplayToastStore}
on:click={() => sponsorBlockDisplayToastStore.set(!$sponsorBlockDisplayToastStore)}
type="checkbox"
/>
<span></span>
</label>
</nav>
<nav class="no-padding">
<div class="max">
<p>{$_('layout.sponsors.disableTimeline')}</p>
</div>
<label class="switch">
<input
bind:checked={$sponsorBlockTimelineStore}
on:click={() => sponsorBlockTimelineStore.set(!$sponsorBlockTimelineStore)}
type="checkbox"
/>
<span></span>
</label>
</nav>
<hr style="margin: 1em 0;" />
<p class="bold">{$_('layout.sponsors.Catagories')}</p>
{#each sponsorCategories as sponsor}
<div class="field middle-align no-margin">
<nav class="no-padding">
<div class="max">
<p>{sponsor.name}</p>
</div>
<label class="switch">
<input
type="checkbox"
checked={sponsorCategoriesList.includes(sponsor.category)}
on:click={() => toggleSponsor(sponsor.category)}
/>
<span></span>
</label>
</nav>
</div>
{/each}
@@ -2,7 +2,7 @@
import { Clipboard } from '@capacitor/clipboard';
import { Capacitor } from '@capacitor/core';
import { _ } from 'svelte-i18n';
import type { Notification, PlaylistPageVideo, Video, VideoBase } from './api/model';
import type { Notification, PlaylistPageVideo, Video, VideoBase } from '../api/model';
export let video: VideoBase | Video | Notification | PlaylistPageVideo;
</script>
@@ -9,7 +9,7 @@
import { onDestroy, onMount } from 'svelte';
import { _ } from 'svelte-i18n';
import { get } from 'svelte/store';
import { syncPartyConnectionsStore, syncPartyPeerStore } from './store';
import { syncPartyConnectionsStore, syncPartyPeerStore } from '../store';
function changeVideoEvent(conn: DataConnection) {
conn.on('data', (data) => {
@@ -1,19 +1,14 @@
<script lang="ts">
import { getBestThumbnail, proxyGoogleImage } from '$lib/images';
import { letterCase } from '$lib/letterCasing';
import { cleanNumber, videoLength } from '$lib/time';
import { createEventDispatcher, onDestroy, onMount } from 'svelte';
import { _ } from 'svelte-i18n';
import { get } from 'svelte/store';
import { getChannel, getDeArrow, getThumbnail, getVideo, getVideoProgress } from './api';
import type { Notification, PlaylistPageVideo, Video, VideoBase, VideoPlay } from './api/model';
import {
cleanNumber,
getBestThumbnail,
letterCase,
proxyGoogleImage,
proxyVideoUrl,
truncate,
videoLength
} from './misc';
import type { PlayerEvents } from './player';
import { getChannel, getDeArrow, getThumbnail, getVideo, getVideoProgress } from '../api';
import type { Notification, PlaylistPageVideo, Video, VideoBase, VideoPlay } from '../api/model';
import { proxyVideoUrl, truncate } from '../misc';
import type { PlayerEvents } from '../player';
import {
authStore,
deArrowEnabledStore,
@@ -27,7 +22,7 @@
syncPartyPeerStore,
synciousInstanceStore,
synciousStore
} from './store';
} from '../store';
const dispatch = createEventDispatcher();
@@ -1,13 +1,14 @@
<script lang="ts">
import { videoLength } from '$lib/time';
import Fuse from 'fuse.js';
import { VTTCue, parseText, type ParsedCaptionsResult } from 'media-captions';
import { _ } from 'svelte-i18n';
import { get } from 'svelte/store';
import type { MediaTimeUpdateEvent } from 'vidstack';
import type { MediaPlayerElement } from 'vidstack/elements';
import type { VideoPlay } from './api/model';
import { decodeHtmlCharCodes, videoLength } from './misc';
import { instanceStore } from './store';
import type { VideoPlay } from '../api/model';
import { decodeHtmlCharCodes } from '../misc';
import { instanceStore } from '../store';
export let video: VideoPlay;
export let player: MediaPlayerElement;
@@ -1,11 +1,11 @@
<script lang="ts">
import Thumbnail from '$lib/components/Thumbnail.svelte';
import { _ } from 'svelte-i18n';
import { get } from 'svelte/store';
import { removePlaylistVideo } from './api';
import type { Notification, PlaylistPageVideo, Video, VideoBase } from './api/model';
import { removePlaylistVideo } from '../api';
import type { Notification, PlaylistPageVideo, Video, VideoBase } from '../api/model';
import { authStore } from '../store';
import ContentColumn from './ContentColumn.svelte';
import { authStore } from './store';
import Thumbnail from './Thumbnail.svelte';
export let videos: VideoBase[] | Video[] | Notification[] | PlaylistPageVideo[] = [];
export let playlistId: string = '';
@@ -13,7 +13,6 @@
let hiddenVideos: string[] = [];
let auth = get(authStore);
let largeCol = '2';
async function removePlaylistItem(indexId: string, videoId: string) {
if (!playlistId) return;
+2 -1
View File
@@ -106,7 +106,8 @@
"logout": "Logout",
"customize": "Customize",
"lowBandwidthMode": "Low bandwidth mode",
"displayThumbnailAvatars": "Display channel avatars on thumbnail (Slow)",
"displayThumbnailAvatars": "Thumbnail avatars (Slow)",
"searchHistory": "Search history",
"theme": {
"theme": "Theme",
"darkMode": "Dark mode",
+41
View File
@@ -0,0 +1,41 @@
import { get } from "svelte/store";
import type { Image } from "./api/model";
import { instanceStore } from "./store";
export function getBestThumbnail(
images: Image[] | null,
maxWidthDimension: number = 480,
maxHeightDimension = 360
): string {
if (images && images.length > 0) {
const imagesFiltered = images.filter(
(image) => image.width < maxWidthDimension && image.height < maxHeightDimension
);
if (imagesFiltered.length === 0) {
return images[0].url;
}
imagesFiltered.sort((a, b) => {
return b.width * b.height - a.width * a.height;
});
return imagesFiltered[0].url;
} else {
return '';
}
}
export function proxyGoogleImage(source: string): string {
if (source.startsWith('//')) source = `https:${source}`;
let path: string | undefined;
try {
path = new URL(source).pathname;
} catch { }
if (typeof path === 'undefined') return '';
return `${get(instanceStore)}/ggpht${path}`;
}
+61
View File
@@ -0,0 +1,61 @@
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 function letterCase(text: string, caseTypeOverwrite?: TitleCase): string {
const casing = caseTypeOverwrite ? caseTypeOverwrite : get(interfaceForceCase);
if (!casing) return text;
switch (casing) {
case 'lowercase':
return text.toLowerCase();
case 'uppercase':
return text.toUpperCase();
case 'sentence case':
return sentenceCase(text);
default:
return titleCase(text);
}
}
export function sentenceCase(text: string): string {
let sentences: string[] = text.match(/[^.!?]*[.!?]*/g) || [];
let casedSentences: string[] = sentences.map(sentence => {
sentence = sentence.trim();
if (sentence.length === 0) return '';
return sentence.charAt(0).toUpperCase() + sentence.slice(1).toLowerCase();
});
return casedSentences.join(' ');
}
export function titleCase(text: string): string {
if (!text) return '';
let words: string[] = text.split(/(\s+)/).filter(word => word.trim().length > 0);
let titleCasedWords: string[] = words.map(word => {
if (word.length === 0) return '';
const firstChar = word.charAt(0);
const restOfWord = word.slice(1).toLowerCase();
// Capitalize if the first character is a letter
if (/[a-zA-Z]/.test(firstChar)) {
return firstChar.toUpperCase() + restOfWord;
}
return word;
});
return titleCasedWords.join(' ');
}
+1 -209
View File
@@ -1,107 +1,14 @@
import { pushState } from '$app/navigation';
import { page } from '$app/stores';
import he from 'he';
import humanNumber from 'human-number';
import type Peer from 'peerjs';
import { get } from 'svelte/store';
import type { Image } from './api/model';
import { instanceStore, interfaceForceCase } from './store';
export type TitleCase = 'uppercase' | 'lowercase' | 'sentence case' | 'title case' | null;
export const titleCases: TitleCase[] = [
'lowercase',
'uppercase',
'title case',
'sentence case'
];
export function letterCase(text: string, caseTypeOverwrite?: TitleCase): string {
const casing = caseTypeOverwrite ? caseTypeOverwrite : get(interfaceForceCase);
if (!casing) return text;
switch (casing) {
case 'lowercase':
return text.toLowerCase();
case 'uppercase':
return text.toUpperCase();
case 'sentence case':
return sentenceCase(text);
default:
return titleCase(text);
}
}
export function sentenceCase(text: string): string {
let sentences: string[] = text.match(/[^.!?]*[.!?]*/g) || [];
let casedSentences: string[] = sentences.map(sentence => {
sentence = sentence.trim();
if (sentence.length === 0) return '';
return sentence.charAt(0).toUpperCase() + sentence.slice(1).toLowerCase();
});
return casedSentences.join(' ');
}
export function titleCase(text: string): string {
if (!text) return '';
let words: string[] = text.split(/(\s+)/).filter(word => word.trim().length > 0);
let titleCasedWords: string[] = words.map(word => {
if (word.length === 0) return '';
const firstChar = word.charAt(0);
const restOfWord = word.slice(1).toLowerCase();
// Capitalize if the first character is a letter
if (/[a-zA-Z]/.test(firstChar)) {
return firstChar.toUpperCase() + restOfWord;
}
return word;
});
return titleCasedWords.join(' ');
}
import { instanceStore } from './store';
export function truncate(value: string, maxLength: number = 50): string {
return value.length > maxLength ? `${value.substring(0, maxLength)}...` : value;
}
export function numberWithCommas(number: number) {
if (typeof number === 'undefined') return;
return number.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',');
}
export function cleanNumber(number: number): string {
return humanNumber(number, (number: number) =>
Number.parseFloat(number.toString()).toFixed(1)
).replace('.0', '');
}
export function videoLength(lengthSeconds: number): string {
const hours = Math.floor(lengthSeconds / 3600);
let minutes: number | string = Math.floor((lengthSeconds % 3600) / 60);
let seconds: number | string = Math.round(lengthSeconds % 60);
if (minutes < 10) {
minutes = `0${minutes}`;
}
if (seconds < 10) {
seconds = `0${seconds}`;
}
if (hours !== 0) {
return `${hours}:${minutes}:${seconds}`;
} else {
return `${minutes}:${seconds}`;
}
}
export interface PhasedDescription {
description: string;
timestamps: { title: string; time: number; timePretty: string; }[];
@@ -112,85 +19,6 @@ export function decodeHtmlCharCodes(str: string): string {
return decode(str);
}
export function phaseDescription(content: string, usingYoutubeJs: boolean = false): PhasedDescription {
const timestamps: { title: string; time: number; timePretty: string; }[] = [];
const lines = content.split('\n');
// Regular expressions for different timestamp formats
const urlRegex = /<a href="([^"]+)"/;
const timestampRegexInvidious = /<a href="([^"]+)" data-onclick="jump_to_time" data-jump-time="(\d+)">(\d+:\d+(?::\d+)?)<\/a>\s*(.+)/;
const timestampRegexYtJs = /&(?:\S*?&)?t=(\d+)\s*s.*?<span[^>]*>([^<]*)<\/span>.*?>(.*?)<\/span>/;
let filteredLines: string[] = [];
lines.forEach((line) => {
const urlMatch = urlRegex.exec(line);
// Use appropriate regex based on the `usingYoutubeJs` flag
const timestampMatch = (usingYoutubeJs ? timestampRegexYtJs : timestampRegexInvidious).exec(usingYoutubeJs ? line + '</span>' : line);
if (urlMatch !== null && timestampMatch === null) {
// If line contains a URL but not a timestamp, modify the URL
const modifiedLine = line.replace(
/<a href="([^"]+)"/,
'<a href="$1" target="_blank" rel="noopener noreferrer" class="link"'
);
filteredLines.push(modifiedLine);
} else if (timestampMatch !== null) {
// If line contains a timestamp, extract details and push into timestamps array
const time = usingYoutubeJs ? timestampMatch[1] : timestampMatch[2];
const timestamp = usingYoutubeJs ? timestampMatch[2] : timestampMatch[3];
const title = usingYoutubeJs ? timestampMatch[3] || '' : timestampMatch[4] || '';
timestamps.push({
time: convertToSeconds(time),
// Remove any HTML in the timestamp title.
title: decodeHtmlCharCodes(title.replace(/<[^>]+>/g, '').replace(/\n/g, '').trim()),
timePretty: timestamp
});
} else {
filteredLines.push(line);
}
});
const filteredContent = filteredLines.join('\n');
return { description: filteredContent, timestamps: timestamps };
}
function convertToSeconds(time: string): number {
const parts = time.split(':').map((part) => parseInt(part));
let seconds = 0;
if (parts.length === 3) {
// hh:mm:ss
seconds = parts[0] * 3600 + parts[1] * 60 + parts[2];
} else if (parts.length === 2) {
// hh:ss or m:ss
seconds = parts[0] * 60 + parts[1];
} else if (parts.length === 1) {
// s
seconds = parts[0];
}
return seconds;
}
export function humanizeSeconds(totalSeconds: number): string {
const secondsInMinute = 60;
const secondsInHour = 3600;
const hours = Math.floor(totalSeconds / secondsInHour);
const minutes = Math.floor((totalSeconds % secondsInHour) / secondsInMinute);
const parts: string[] = [];
if (hours > 0) {
parts.push(`${hours} hour${hours > 1 ? 's' : ''}`);
}
if (minutes > 0) {
parts.push(`${minutes} minute${minutes > 1 ? 's' : ''}`);
}
return parts.join(', ');
}
export function proxyVideoUrl(source: string): string {
const rawSrc = new URL(source);
rawSrc.host = get(instanceStore).replace('http://', '').replace(
@@ -214,18 +42,6 @@ export function pullBitratePreference(): number {
return -1;
}
export function proxyGoogleImage(source: string): string {
if (source.startsWith('//')) source = `https:${source}`;
let path: string | undefined;
try {
path = new URL(source).pathname;
} catch { }
if (typeof path === 'undefined') return '';
return `${get(instanceStore)}/ggpht${path}`;
}
export function unsafeRandomItem(array: any[]): any {
return array[Math.floor(Math.random() * array.length)];
@@ -259,30 +75,6 @@ export async function peerJs(peerId: string): Promise<Peer> {
);
}
export function getBestThumbnail(
images: Image[] | null,
maxWidthDimension: number = 480,
maxHeightDimension = 360
): string {
if (images && images.length > 0) {
const imagesFiltered = images.filter(
(image) => image.width < maxWidthDimension && image.height < maxHeightDimension
);
if (imagesFiltered.length === 0) {
return images[0].url;
}
imagesFiltered.sort((a, b) => {
return b.width * b.height - a.width * a.height;
});
return imagesFiltered[0].url;
} else {
return '';
}
}
export function ensureNoTrailingSlash(url: any): string {
if (typeof url !== 'string') return '';
+1 -1
View File
@@ -1,6 +1,6 @@
import type { AdaptiveFormats, Captions, Image, StoryBoard, Thumbnail, VideoBase, VideoPlay } from '$lib/api/model';
import { numberWithCommas } from '$lib/misc';
import { interfaceRegionStore, poTokenCacheStore } from '$lib/store';
import { numberWithCommas } from '$lib/time';
import { Capacitor } from '@capacitor/core';
import { BG } from 'bgutils-js';
import { get } from 'svelte/store';
+4 -1
View File
@@ -4,7 +4,7 @@ import type Peer from 'peerjs';
import type { DataConnection } from 'peerjs';
import { persisted } from 'svelte-persisted-store';
import { writable, type Writable } from 'svelte/store';
import type { TitleCase } from './misc';
import type { TitleCase } from './letterCasing';
import type { PoTokens } from './patches/youtubejs';
function platformDependentDefault(givenValue: any, defaultValue: any): any {
@@ -68,6 +68,7 @@ export const interfaceAmoledTheme = persisted('amoledTheme', false);
export const interfaceLowBandwidthMode = persisted('lowBandwidthMode', false);
export const interfaceDisplayThumbnailAvatars = persisted('disableThumbnailAvatars', false);
export const interfaceDefaultPage = persisted('defaultPage', '/');
export const interfaceSearchHistoryEnabled = persisted('searchHistoryEnabled', false);
export const sponsorBlockStore = persisted('sponsorBlock', true);
export const sponsorBlockUrlStore: Writable<string | null | undefined> = persisted(
@@ -110,3 +111,5 @@ export const miniPlayerSrcStore: Writable<{ video: VideoPlay; time: number; } |
export const silenceSkipperStore: Writable<boolean> = persisted('silenceSkipper', false);
export const poTokenCacheStore: Writable<PoTokens> = writable();
export const searchHistoryStore: Writable<string[]> = persisted('searchHistory', []);
+3
View File
@@ -2,6 +2,7 @@ import { Capacitor } from '@capacitor/core';
import { StatusBar, Style } from '@capacitor/status-bar';
import { NavigationBar } from '@hugotomazi/capacitor-navigation-bar';
import ui from 'beercss';
import { tick } from 'svelte';
import { get } from 'svelte/store';
import { darkModeStore, interfaceAmoledTheme } from './store';
@@ -20,6 +21,8 @@ export async function getDynamicTheme(mode?: string): Promise<Record<string, str
export async function setStatusBarColor() {
if (Capacitor.getPlatform() === 'android') {
await tick();
const surfaceColor = get(interfaceAmoledTheme) ? '#000000' : (await getDynamicTheme())['--surface-container'];
await StatusBar.setBackgroundColor({
+92
View File
@@ -0,0 +1,92 @@
import humanNumber from 'human-number';
export function numberWithCommas(number: number) {
if (typeof number === 'undefined') return;
return number.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',');
}
export function cleanNumber(number: number): string {
return humanNumber(number, (number: number) =>
Number.parseFloat(number.toString()).toFixed(1)
).replace('.0', '');
}
export function videoLength(lengthSeconds: number): string {
const hours = Math.floor(lengthSeconds / 3600);
let minutes: number | string = Math.floor((lengthSeconds % 3600) / 60);
let seconds: number | string = Math.round(lengthSeconds % 60);
if (minutes < 10) {
minutes = `0${minutes}`;
}
if (seconds < 10) {
seconds = `0${seconds}`;
}
if (hours !== 0) {
return `${hours}:${minutes}:${seconds}`;
} else {
return `${minutes}:${seconds}`;
}
}
export function padTime(time: string): string {
let timeParts = time.split(':');
if (timeParts.length < 3) {
timeParts = ('00:' + time).split(':');
}
const hours = (timeParts[0] || '0').padStart(2, '0');
const minutes = (timeParts[1] || '0').padStart(2, '0');
let seconds = timeParts[2] || '0';
let milliseconds = '';
if (seconds.includes('.')) {
const [sec, ms] = seconds.split('.');
seconds = sec.padStart(2, '0');
milliseconds = `.${ms.padStart(3, '0')}`;
} else {
seconds = seconds.padStart(2, '0');
}
return `${hours}:${minutes}:${seconds}${milliseconds}`;
}
export function convertToSeconds(time: string): number {
const parts = time.split(':').map((part) => parseInt(part));
let seconds = 0;
if (parts.length === 3) {
// hh:mm:ss
seconds = parts[0] * 3600 + parts[1] * 60 + parts[2];
} else if (parts.length === 2) {
// hh:ss or m:ss
seconds = parts[0] * 60 + parts[1];
} else if (parts.length === 1) {
// s
seconds = parts[0];
}
return seconds;
}
export function humanizeSeconds(totalSeconds: number): string {
const secondsInMinute = 60;
const secondsInHour = 3600;
const hours = Math.floor(totalSeconds / secondsInHour);
const minutes = Math.floor((totalSeconds % secondsInHour) / secondsInMinute);
const parts: string[] = [];
if (hours > 0) {
parts.push(`${hours} hour${hours > 1 ? 's' : ''}`);
}
if (minutes > 0) {
parts.push(`${minutes} minute${minutes > 1 ? 's' : ''}`);
}
return parts.join(', ');
}
+45
View File
@@ -0,0 +1,45 @@
import { type PhasedDescription, decodeHtmlCharCodes } from "./misc";
import { convertToSeconds } from "./time";
export function phaseDescription(content: string, usingYoutubeJs: boolean = false): PhasedDescription {
const timestamps: { title: string; time: number; timePretty: string; }[] = [];
const lines = content.split('\n');
// Regular expressions for different timestamp formats
const urlRegex = /<a href="([^"]+)"/;
const timestampRegexInvidious = /<a href="([^"]+)" data-onclick="jump_to_time" data-jump-time="(\d+)">(\d+:\d+(?::\d+)?)<\/a>\s*(.+)/;
const timestampRegexYtJs = /&(?:\S*?&)?t=(\d+)\s*s.*?<span[^>]*>([^<]*)<\/span>.*?>(.*?)<\/span>/;
let filteredLines: string[] = [];
lines.forEach((line) => {
const urlMatch = urlRegex.exec(line);
// Use appropriate regex based on the `usingYoutubeJs` flag
const timestampMatch = (usingYoutubeJs ? timestampRegexYtJs : timestampRegexInvidious).exec(usingYoutubeJs ? line + '</span>' : line);
if (urlMatch !== null && timestampMatch === null) {
// If line contains a URL but not a timestamp, modify the URL
const modifiedLine = line.replace(
/<a href="([^"]+)"/,
'<a href="$1" target="_blank" rel="noopener noreferrer" class="link"'
);
filteredLines.push(modifiedLine);
} else if (timestampMatch !== null) {
// If line contains a timestamp, extract details and push into timestamps array
const time = usingYoutubeJs ? timestampMatch[1] : timestampMatch[2];
const timestamp = usingYoutubeJs ? timestampMatch[2] : timestampMatch[3];
const title = usingYoutubeJs ? timestampMatch[3] || '' : timestampMatch[4] || '';
timestamps.push({
time: convertToSeconds(time),
// Remove any HTML in the timestamp title.
title: decodeHtmlCharCodes(title.replace(/<[^>]+>/g, '').replace(/\n/g, '').trim()),
timePretty: timestamp
});
} else {
filteredLines.push(line);
}
});
const filteredContent = filteredLines.join('\n');
return { description: filteredContent, timestamps: timestamps };
}
-178
View File
@@ -1,178 +0,0 @@
interface MediaQuality {
bandwidth: string;
videoUrl: string;
audioUrl: string;
resolution?: string;
frameRate?: string;
audioChannels?: string;
audioBandwidth?: string;
}
// https://github.com/ffmpegwasm/ffmpeg.wasm/issues/603#issuecomment-1982289892
// Ripped from https://github.com/ffmpegwasm/ffmpeg.wasm/blob/ae1cdac7db79c5315f9a1b716fcbd9bcfe27c902/packages/util/src/index.ts#L49
export async function fetchFile(
file: string,
onProgress?: (progress: number) => void
): Promise<Uint8Array> {
const response = await fetch(file);
const contentLength = response.headers.get('content-length');
// If content-length is missing, download without progress tracking
if (!contentLength) {
const data = await response.arrayBuffer();
return new Uint8Array(data);
}
const totalBytes = parseInt(contentLength, 10);
const data = new Uint8Array(totalBytes);
let position = 0;
let downloadBytes = 0;
const reader = response.body!.getReader();
while (true) {
const { done, value } = await reader.read();
if (done) break;
data.set(value, position);
position += value.length;
downloadBytes += value.byteLength;
if (onProgress) {
onProgress((downloadBytes / totalBytes) * 100);
}
}
return data;
}
export async function toBlobURL(
url: string,
mimeType: string,
onProgress?: (progress: number) => void
): Promise<string> {
return URL.createObjectURL(new Blob([await fetchFile(url, onProgress)], { type: mimeType }));
}
// Fetch and parse the DASH manifest
async function fetchDASHManifest(manifestUrl: string) {
const response = await fetch(manifestUrl);
const manifestText = await response.text();
const parser = new DOMParser();
const manifest = parser.parseFromString(manifestText, "text/xml");
return manifest;
}
export async function listCombinedQualities(manifestUrl: string): Promise<MediaQuality[]> {
const manifest = await fetchDASHManifest(`${manifestUrl}?local=true`);
const qualities: MediaQuality[] = [];
// Fetch all AdaptationSets (both video and audio)
const adaptationSets = manifest.getElementsByTagName('AdaptationSet');
let videoRepresentations: Element[] = [];
let audioRepresentations: Element[] = [];
// Separate video and audio AdaptationSets based on mimeType
for (let i = 0; i < adaptationSets.length; i++) {
const mimeType = adaptationSets[i].getAttribute('mimeType');
const representations = adaptationSets[i].getElementsByTagName('Representation');
if (mimeType && mimeType === 'video/mp4') {
videoRepresentations = [...videoRepresentations, ...Array.from(representations)];
} else if (mimeType && mimeType === 'audio/mp4') {
audioRepresentations = [...audioRepresentations, ...Array.from(representations)];
}
}
// Combine video and audio representations
videoRepresentations.forEach((videoRep) => {
const videoBandwidth = videoRep.getAttribute('bandwidth');
const videoUrl = videoRep.getElementsByTagName('BaseURL')[0].textContent as string;
const width = videoRep.getAttribute('width');
const height = videoRep.getAttribute('height');
const resolution = width && height ? `${width}x${height}` : undefined;
const frameRate = videoRep.getAttribute('frameRate') || '';
// Pair this video representation with each audio representation
audioRepresentations.forEach((audioRep) => {
const audioUrl = audioRep.getElementsByTagName('BaseURL')[0].textContent as string;
const audioChannels = audioRep.getElementsByTagName('AudioChannelConfiguration')[0]?.getAttribute('value') || '';
const audioBandwidth = audioRep.getAttribute('bandwidth') || '';
qualities.push({
bandwidth: videoBandwidth || '',
videoUrl,
audioUrl,
resolution,
frameRate,
audioChannels,
audioBandwidth
});
});
});
return qualities;
}
export async function mergeMediaFromDASH(
selectedQuality: MediaQuality,
fileName: string,
progressCallbacks?: {
video?: (progress: number) => void,
audio?: (progress: number) => void,
merging?: (progress: number) => void,
loadingFfmpeg?: (completed: boolean) => void;
classWorker?: (progress: number) => void,
core?: (progress: number) => void;
wasm?: (progress: number) => void;
worker?: (progress: number) => void;
}
) {
if (progressCallbacks?.loadingFfmpeg) {
progressCallbacks.loadingFfmpeg(false);
}
const FFmpeg = await import('@ffmpeg/ffmpeg');
const ffmpeg = new FFmpeg.FFmpeg();
ffmpeg.on('progress', ({ progress }) => {
if (progressCallbacks?.merging) {
progressCallbacks.merging(progress * 100);
}
});
await ffmpeg.load({
classWorkerURL: await toBlobURL('/ffmpeg/ffmpeg-worker.js', 'text/javascript', progressCallbacks?.classWorker),
coreURL: await toBlobURL('/ffmpeg/ffmpeg-core.js', 'text/javascript', progressCallbacks?.core),
wasmURL: await toBlobURL('/ffmpeg/ffmpeg-core.wasm', 'application/wasm', progressCallbacks?.wasm),
workerURL: await toBlobURL('/ffmpeg/ffmpeg-core.worker.js', 'text/javascript', progressCallbacks?.worker)
});
if (progressCallbacks?.loadingFfmpeg) {
progressCallbacks.loadingFfmpeg(true);
}
const { videoUrl, audioUrl } = selectedQuality;
ffmpeg.writeFile('input.mp4', await fetchFile(videoUrl, progressCallbacks?.video));
ffmpeg.writeFile('input.mp3', await fetchFile(audioUrl, progressCallbacks?.audio));
await ffmpeg.exec(['-i', 'input.mp4', '-i', 'input.mp3', '-c:v', 'copy', '-c:a', 'aac', 'output.mp4']);
const data = await ffmpeg.readFile('output.mp4');
const mergedFile = new Blob([data], { type: 'video/mp4' });
const url = URL.createObjectURL(mergedFile);
const a = document.createElement('a');
a.href = url;
a.download = `${fileName} - ${selectedQuality.resolution}.mp4`;
document.body.appendChild(a);
a.click();
URL.revokeObjectURL(url);
document.body.removeChild(a);
}
+14 -11
View File
@@ -6,13 +6,15 @@
import colorTheme, { convertToHexColorCode } from '$lib/android/plugins/colorTheme';
import { getFeed } from '$lib/api/index';
import type { Notification } from '$lib/api/model';
import Logo from '$lib/components/Logo.svelte';
import MiniPlayer from '$lib/components/MiniPlayer.svelte';
import PageLoading from '$lib/components/PageLoading.svelte';
import Search from '$lib/components/Search.svelte';
import Settings from '$lib/components/Settings/Settings.svelte';
import SyncParty from '$lib/components/SyncParty.svelte';
import Thumbnail from '$lib/components/Thumbnail.svelte';
import { bookmarkletLoadFromUrl, loadSettingsFromEnv } from '$lib/externalSettings';
import Logo from '$lib/Logo.svelte';
import MiniPlayer from '$lib/MiniPlayer.svelte';
import { getPages } from '$lib/navPages';
import PageLoading from '$lib/PageLoading.svelte';
import Search from '$lib/Search.svelte';
import Settings from '$lib/Settings.svelte';
import {
activePageStore,
authStore,
@@ -24,9 +26,7 @@
syncPartyPeerStore,
themeColorStore
} from '$lib/store';
import SyncParty from '$lib/SyncParty.svelte';
import { setAmoledTheme, setStatusBarColor, setTheme } from '$lib/theme';
import Thumbnail from '$lib/Thumbnail.svelte';
import { App } from '@capacitor/app';
import { Browser } from '@capacitor/browser';
import { Capacitor } from '@capacitor/core';
@@ -50,8 +50,10 @@
let notifications: Notification[] = [];
interfaceAmoledTheme.subscribe(() => {
interfaceAmoledTheme.subscribe(async () => {
setAmoledTheme();
await setStatusBarColor();
});
darkModeStore.subscribe(async () => {
@@ -176,11 +178,12 @@
const colorPalette = await colorTheme.getColorPalette();
themeHex = convertToHexColorCode(colorPalette.primary);
await ui('theme', themeHex);
await setStatusBarColor();
} catch {}
}
}
await setStatusBarColor();
setTheme();
setAmoledTheme();
@@ -318,9 +321,9 @@
{@html $_('invidiousBlockWarning', {
values: {
android:
'<a href="https://github.com/Materialious/Materialious/releases/latest" target="_blank" class="link" rel="noopener noreferrer">Android</a>',
'<a href="https://github.com/Materialious/Materialious?tab=readme-ov-file#android" target="_blank" class="link" rel="noopener noreferrer">Android</a>',
desktop:
'<a href="https://github.com/Materialious/Materialious/releases/latest" target="_blank" class="link" rel="noopener noreferrer">Desktop</a>'
'<a href="https://github.com/Materialious/Materialious?tab=readme-ov-file#desktop-windowsmacoslinux" target="_blank" class="link" rel="noopener noreferrer">Desktop</a>'
}
})}
</p>
+1 -1
View File
@@ -1,5 +1,5 @@
<script lang="ts">
import VideoList from '$lib/VideoList.svelte';
import VideoList from '$lib/components/VideoList.svelte';
import { activePageStore } from '$lib/store';
export let data;
@@ -1,7 +1,7 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import PageLoading from '$lib/PageLoading.svelte';
import PageLoading from '$lib/components/PageLoading.svelte';
import { authStore } from '$lib/store';
import { onMount } from 'svelte';
@@ -1,12 +1,13 @@
<script lang="ts">
import { amSubscribed, deleteUnsubscribe, getChannelContent, postSubscribe } from '$lib/api';
import type { ChannelContentPlaylists, ChannelContentVideos } from '$lib/api/model';
import ContentColumn from '$lib/ContentColumn.svelte';
import PageLoading from '$lib/PageLoading.svelte';
import PlaylistThumbnail from '$lib/PlaylistThumbnail.svelte';
import VideoList from '$lib/VideoList.svelte';
import { cleanNumber, getBestThumbnail, proxyGoogleImage } from '$lib/misc';
import ContentColumn from '$lib/components/ContentColumn.svelte';
import PageLoading from '$lib/components/PageLoading.svelte';
import PlaylistThumbnail from '$lib/components/PlaylistThumbnail.svelte';
import VideoList from '$lib/components/VideoList.svelte';
import { getBestThumbnail, proxyGoogleImage } from '$lib/images';
import { activePageStore, authStore, interfaceLowBandwidthMode } from '$lib/store';
import { cleanNumber } from '$lib/time';
import { Clipboard } from '@capacitor/clipboard';
import { Capacitor } from '@capacitor/core';
import { onMount } from 'svelte';
@@ -1,7 +1,7 @@
<script lang="ts">
import { getHashtag } from '$lib/api/index.js';
import { activePageStore } from '$lib/store.js';
import VideoList from '$lib/VideoList.svelte';
import { getHashtag } from '$lib/api';
import VideoList from '$lib/components/VideoList.svelte';
import { activePageStore } from '$lib/store';
import InfiniteLoading, { type InfiniteEvent } from 'svelte-infinite-loading';
export let data;
@@ -1,8 +1,8 @@
<script lang="ts">
import { deleteAllVideoProgress, deleteHistory, getHistory, getVideo } from '$lib/api';
import type { VideoPlay } from '$lib/api/model';
import PageLoading from '$lib/PageLoading.svelte';
import VideoList from '$lib/VideoList.svelte';
import PageLoading from '$lib/components/PageLoading.svelte';
import VideoList from '$lib/components/VideoList.svelte';
import { activePageStore, synciousStore } from '$lib/store';
import { error } from '@sveltejs/kit';
import { onMount } from 'svelte';
@@ -1,9 +1,10 @@
<script lang="ts">
import { getPlaylist } from '$lib/api/index';
import type { PlaylistPageVideo } from '$lib/api/model.js';
import { cleanNumber, unsafeRandomItem } from '$lib/misc.js';
import type { PlaylistPageVideo } from '$lib/api/model';
import VideoList from '$lib/components/VideoList.svelte';
import { unsafeRandomItem } from '$lib/misc';
import { activePageStore, playlistSettingsStore } from '$lib/store';
import VideoList from '$lib/VideoList.svelte';
import { cleanNumber } from '$lib/time';
import { Clipboard } from '@capacitor/clipboard';
import { Capacitor } from '@capacitor/core';
import { onMount } from 'svelte';
@@ -1,8 +1,9 @@
<script lang="ts">
import { deletePersonalPlaylist, getPersonalPlaylists, postPersonalPlaylist } from '$lib/api';
import ContentColumn from '$lib/ContentColumn.svelte';
import PlaylistThumbnail from '$lib/PlaylistThumbnail.svelte';
import ContentColumn from '$lib/components/ContentColumn.svelte';
import PlaylistThumbnail from '$lib/components/PlaylistThumbnail.svelte';
import { activePageStore } from '$lib/store';
import { ui } from 'beercss';
import { _ } from 'svelte-i18n';
export let data;
@@ -1,12 +1,12 @@
<script lang="ts">
import { getSearch } from '$lib/api';
import ChannelThumbnail from '$lib/ChannelThumbnail.svelte';
import ContentColumn from '$lib/ContentColumn.svelte';
import HashtagThumbnail from '$lib/HashtagThumbnail.svelte';
import PageLoading from '$lib/PageLoading.svelte';
import PlaylistThumbnail from '$lib/PlaylistThumbnail.svelte';
import ChannelThumbnail from '$lib/components/ChannelThumbnail.svelte';
import ContentColumn from '$lib/components/ContentColumn.svelte';
import HashtagThumbnail from '$lib/components/HashtagThumbnail.svelte';
import PageLoading from '$lib/components/PageLoading.svelte';
import PlaylistThumbnail from '$lib/components/PlaylistThumbnail.svelte';
import Thumbnail from '$lib/components/Thumbnail.svelte';
import { activePageStore } from '$lib/store';
import Thumbnail from '$lib/Thumbnail.svelte';
import { _ } from 'svelte-i18n';
import InfiniteLoading, { type InfiniteEvent } from 'svelte-infinite-loading';
@@ -1,7 +1,7 @@
<script lang="ts">
import { getFeed } from '$lib/api/index';
import VideoList from '$lib/components/VideoList.svelte';
import { activePageStore } from '$lib/store';
import VideoList from '$lib/VideoList.svelte';
import { _ } from 'svelte-i18n';
import InfiniteLoading, { type InfiniteEvent } from 'svelte-infinite-loading';
@@ -1,5 +1,5 @@
<script lang="ts">
import VideoList from '$lib/VideoList.svelte';
import VideoList from '$lib/components/VideoList.svelte';
import { activePageStore } from '$lib/store';
export let data;
@@ -9,21 +9,16 @@
postSubscribe,
removePlaylistVideo
} from '$lib/api/index';
import type { Comments, PlaylistPage, PlaylistPageVideo } from '$lib/api/model.js';
import Comment from '$lib/Comment.svelte';
import {
cleanNumber,
getBestThumbnail,
humanizeSeconds,
letterCase,
numberWithCommas,
proxyGoogleImage,
truncate,
unsafeRandomItem
} from '$lib/misc';
import type { Comments, PlaylistPage, PlaylistPageVideo } from '$lib/api/model';
import Comment from '$lib/components/Comment.svelte';
import Player from '$lib/components/Player.svelte';
import ShareVideo from '$lib/components/ShareVideo.svelte';
import Thumbnail from '$lib/components/Thumbnail.svelte';
import Transcript from '$lib/components/Transcript.svelte';
import { getBestThumbnail, proxyGoogleImage } from '$lib/images';
import { letterCase } from '$lib/letterCasing';
import { truncate, unsafeRandomItem } from '$lib/misc';
import type { PlayerEvents } from '$lib/player';
import Player from '$lib/Player.svelte';
import ShareVideo from '$lib/ShareVideo.svelte';
import {
activePageStore,
authStore,
@@ -39,9 +34,7 @@
syncPartyConnectionsStore,
syncPartyPeerStore
} from '$lib/store';
import Thumbnail from '$lib/Thumbnail.svelte';
import Transcript from '$lib/Transcript.svelte';
import { mergeMediaFromDASH } from '$lib/videoDownload';
import { cleanNumber, humanizeSeconds, numberWithCommas } from '$lib/time';
import ui from 'beercss';
import type { DataConnection } from 'peerjs';
import { type Segment } from 'sponsorblock-api';
@@ -434,50 +427,6 @@
let downloadStage: string | undefined;
let downloadProgress: number = 0;
function onVideoDownloadProgress(progress: number) {
downloadStage = $_('player.downloadSteps.video');
downloadProgress = progress;
}
function onAudioDownloadProgress(progress: number) {
downloadStage = $_('player.downloadSteps.audio');
downloadProgress = progress;
}
function onMergingProgress(progress: number) {
downloadStage = $_('player.downloadSteps.merging');
downloadProgress = progress;
if (progress >= 100) {
downloadStage = undefined;
}
}
function onLoadingFFmpeg(completed: boolean) {
downloadProgress = 0;
downloadStage = $_('player.downloadSteps.ffmpeg');
}
function onClassWorkerProgress(progress: number) {
downloadStage = $_('player.downloadSteps.classWorker');
downloadProgress = progress;
}
function onCoreProgress(progress: number) {
downloadStage = $_('player.downloadSteps.core');
downloadProgress = progress;
}
function onWasmProgress(progress: number) {
downloadStage = $_('player.downloadSteps.wasm');
downloadProgress = progress;
}
function onWorkerProgress(progress: number) {
downloadStage = $_('player.downloadSteps.worker');
downloadProgress = progress;
}
</script>
<svelte:head>
@@ -621,33 +570,6 @@
<ShareVideo video={data.video} />
</menu></button
>
{#await data.streamed.downloadQualitiesDash then downloadQualitiesDash}
{#if downloadQualitiesDash}
<button class="border"
><i>download</i>
<div class="tooltip">{$_('player.download')}</div>
<menu class="no-wrap">
{#each downloadQualitiesDash as quality}
<a
class="row"
href="#download"
on:click={async () =>
await mergeMediaFromDASH(quality, data.video.title, {
video: onVideoDownloadProgress,
audio: onAudioDownloadProgress,
merging: onMergingProgress,
loadingFfmpeg: onLoadingFFmpeg,
classWorker: onClassWorkerProgress,
core: onCoreProgress,
wasm: onWasmProgress,
worker: onWorkerProgress
})}>{quality.resolution}</a
>
{/each}
</menu></button
>
{/if}
{/await}
{#if personalPlaylists}
<button class="border">
<i>add</i>
@@ -6,14 +6,13 @@ import {
getVideo,
postHistory
} from '$lib/api/index';
import { phaseDescription } from '$lib/misc';
import {
authStore,
playerProxyVideosStore,
returnYTDislikesInstanceStore,
returnYtDislikesStore
} from '$lib/store';
import { listCombinedQualities } from '$lib/videoDownload';
import { phaseDescription } from '$lib/timestamps';
import { error } from '@sveltejs/kit';
import { get } from 'svelte/store';
@@ -51,15 +50,6 @@ export async function load({ params, url }) {
}
let downloadQualitiesDash;
if (
import.meta.env.VITE_DEFAULT_DOWNLOAD_ENABLED &&
import.meta.env.VITE_DEFAULT_DOWNLOAD_ENABLED.toString().toLowerCase() === 'true' &&
!video.liveNow
) {
downloadQualitiesDash = listCombinedQualities(video.dashUrl);
}
return {
video: video,
content: phaseDescription(video.descriptionHtml, video.fallbackPatch === 'youtubejs'),
@@ -69,7 +59,6 @@ export async function load({ params, url }) {
returnYTDislikes: returnYTDislikes,
comments: comments,
subscribed: amSubscribed(video.authorId),
downloadQualitiesDash: downloadQualitiesDash
}
};
}
@@ -1,8 +1,8 @@
import { getVideo } from '$lib/api/index';
import type { PhasedDescription } from '$lib/misc';
import { playerProxyVideosStore } from '$lib/store';
import { error } from '@sveltejs/kit';
import { get } from 'svelte/store';
import type { PhasedDescription } from '../../../../lib/misc.js';
import { playerProxyVideosStore } from '../../../../lib/store.js';
export async function load({ params }) {
let video;
@@ -1,9 +1,8 @@
<script lang="ts">
import Player from '$lib/Player.svelte';
import Player from '$lib/components/Player.svelte';
import type { MediaPlayerElement } from 'vidstack/elements';
export let data;
export let layout = false;
let player: MediaPlayerElement;
</script>
File diff suppressed because one or more lines are too long
Binary file not shown.
@@ -1 +0,0 @@
"use strict";var Module={};var ENVIRONMENT_IS_NODE=typeof process=="object"&&typeof process.versions=="object"&&typeof process.versions.node=="string";if(ENVIRONMENT_IS_NODE){var nodeWorkerThreads=require("worker_threads");var parentPort=nodeWorkerThreads.parentPort;parentPort.on("message",data=>onmessage({data:data}));var fs=require("fs");Object.assign(global,{self:global,require:require,Module:Module,location:{href:__filename},Worker:nodeWorkerThreads.Worker,importScripts:function(f){(0,eval)(fs.readFileSync(f,"utf8")+"//# sourceURL="+f)},postMessage:function(msg){parentPort.postMessage(msg)},performance:global.performance||{now:function(){return Date.now()}}})}var initializedJS=false;function threadPrintErr(){var text=Array.prototype.slice.call(arguments).join(" ");if(ENVIRONMENT_IS_NODE){fs.writeSync(2,text+"\n");return}console.error(text)}function threadAlert(){var text=Array.prototype.slice.call(arguments).join(" ");postMessage({cmd:"alert",text:text,threadId:Module["_pthread_self"]()})}var err=threadPrintErr;self.alert=threadAlert;Module["instantiateWasm"]=(info,receiveInstance)=>{var module=Module["wasmModule"];Module["wasmModule"]=null;var instance=new WebAssembly.Instance(module,info);return receiveInstance(instance)};self.onunhandledrejection=e=>{throw e.reason??e};function handleMessage(e){try{if(e.data.cmd==="load"){let messageQueue=[];self.onmessage=e=>messageQueue.push(e);self.startWorker=instance=>{Module=instance;postMessage({"cmd":"loaded"});for(let msg of messageQueue){handleMessage(msg)}self.onmessage=handleMessage};Module["wasmModule"]=e.data.wasmModule;for(const handler of e.data.handlers){Module[handler]=function(){postMessage({cmd:"callHandler",handler:handler,args:[...arguments]})}}Module["wasmMemory"]=e.data.wasmMemory;Module["buffer"]=Module["wasmMemory"].buffer;Module["ENVIRONMENT_IS_PTHREAD"]=true;(e.data.urlOrBlob?import(e.data.urlOrBlob):import("./ffmpeg-core.js")).then(exports=>exports.default(Module))}else if(e.data.cmd==="run"){Module["__emscripten_thread_init"](e.data.pthread_ptr,0,0,1);Module["__emscripten_thread_mailbox_await"](e.data.pthread_ptr);Module["establishStackSpace"]();Module["PThread"].receiveObjectTransfer(e.data);Module["PThread"].threadInitTLS();if(!initializedJS){initializedJS=true}try{Module["invokeEntryPoint"](e.data.start_routine,e.data.arg)}catch(ex){if(ex!="unwind"){throw ex}}}else if(e.data.cmd==="cancel"){if(Module["_pthread_self"]()){Module["__emscripten_thread_exit"](-1)}}else if(e.data.target==="setimmediate"){}else if(e.data.cmd==="checkMailbox"){if(initializedJS){Module["checkMailbox"]()}}else if(e.data.cmd){err("worker.js received unknown command "+e.data.cmd);err(e.data)}}catch(ex){if(Module["__emscripten_thread_crashed"]){Module["__emscripten_thread_crashed"]()}throw ex}}self.onmessage=handleMessage;
-146
View File
@@ -1,146 +0,0 @@
(function () {
"use strict";
const R = "./ffmpeg-core.js";
var E;
(function (t) {
t.LOAD = "LOAD", t.EXEC = "EXEC", t.WRITE_FILE = "WRITE_FILE", t.READ_FILE = "READ_FILE", t.DELETE_FILE = "DELETE_FILE", t.RENAME = "RENAME", t.CREATE_DIR = "CREATE_DIR", t.LIST_DIR = "LIST_DIR", t.DELETE_DIR = "DELETE_DIR", t.ERROR = "ERROR", t.DOWNLOAD = "DOWNLOAD", t.PROGRESS = "PROGRESS", t.LOG = "LOG", t.MOUNT = "MOUNT", t.UNMOUNT = "UNMOUNT"
})(E || (E = {}));
const a = new Error("unknown message type"),
f = new Error("ffmpeg is not loaded, call `await ffmpeg.load()` first"),
u = new Error("failed to import ffmpeg-core.js");
let r;
const O = async ({
coreURL: t,
wasmURL: n,
workerURL: e
}) => {
const o = !r;
try {
t || (t = R), importScripts(t)
} catch {
if (t || (t = R.replace("/umd/", "/esm/")), self.createFFmpegCore = (await import(t)).default, !self.createFFmpegCore) throw u
}
const s = t,
c = n || t.replace(/.js$/g, ".wasm"),
b = e || t.replace(/.js$/g, ".worker.js");
return r = await self.createFFmpegCore({
mainScriptUrlOrBlob: `${s}#${btoa(JSON.stringify({ wasmURL: c, workerURL: b }))}`
}), r.setLogger(i => self.postMessage({
type: E.LOG,
data: i
})), r.setProgress(i => self.postMessage({
type: E.PROGRESS,
data: i
})), o
}, l = ({
args: t,
timeout: n = -1
}) => {
r.setTimeout(n), r.exec(...t);
const e = r.ret;
return r.reset(), e
}, m = ({
path: t,
data: n
}) => (r.FS.writeFile(t, n), !0), D = ({
path: t,
encoding: n
}) => r.FS.readFile(t, {
encoding: n
}), S = ({
path: t
}) => (r.FS.unlink(t), !0), I = ({
oldPath: t,
newPath: n
}) => (r.FS.rename(t, n), !0), L = ({
path: t
}) => (r.FS.mkdir(t), !0), N = ({
path: t
}) => {
const n = r.FS.readdir(t),
e = [];
for (const o of n) {
const s = r.FS.stat(`${t}/${o}`),
c = r.FS.isDir(s.mode);
e.push({
name: o,
isDir: c
})
}
return e
}, A = ({
path: t
}) => (r.FS.rmdir(t), !0), w = ({
fsType: t,
options: n,
mountPoint: e
}) => {
const o = t,
s = r.FS.filesystems[o];
return s ? (r.FS.mount(s, n, e), !0) : !1
}, k = ({
mountPoint: t
}) => (r.FS.unmount(t), !0);
self.onmessage = async ({
data: {
id: t,
type: n,
data: e
}
}) => {
const o = [];
let s;
try {
if (n !== E.LOAD && !r) throw f;
switch (n) {
case E.LOAD:
s = await O(e);
break;
case E.EXEC:
s = l(e);
break;
case E.WRITE_FILE:
s = m(e);
break;
case E.READ_FILE:
s = D(e);
break;
case E.DELETE_FILE:
s = S(e);
break;
case E.RENAME:
s = I(e);
break;
case E.CREATE_DIR:
s = L(e);
break;
case E.LIST_DIR:
s = N(e);
break;
case E.DELETE_DIR:
s = A(e);
break;
case E.MOUNT:
s = w(e);
break;
case E.UNMOUNT:
s = k(e);
break;
default:
throw a
}
} catch (c) {
self.postMessage({
id: t,
type: E.ERROR,
data: c.toString()
});
return
}
s instanceof Uint8Array && o.push(s.buffer), self.postMessage({
id: t,
type: n,
data: s
}, o)
}
})();
+4
View File
@@ -48,6 +48,10 @@
display: none !important;
}
#dialog-settings form {
margin: 1em 0;
}
main.root {
padding-left: 7em !important;
padding-top: 5em !important;
-3
View File
@@ -37,9 +37,6 @@ export default defineConfig({
vidstack(),
sveltekit()
],
optimizeDeps: {
exclude: ['@ffmpeg/ffmpeg', '@ffmpeg/util'],
},
ssr: {
noExternal: ['beercss']
}
+1 -1
View File
@@ -5,7 +5,7 @@ import json
import os
import re
LATEST_VERSION = "1.6.29"
LATEST_VERSION = "1.6.30"
WORKING_DIR = os.path.join(os.path.dirname(os.path.realpath(__file__)), "materialious")
ROOT_PACKAGE = os.path.join(WORKING_DIR, "package.json")