Merge pull request #1288 from Materialious/update/1.13.0

Update/1.13.0
This commit is contained in:
Ward
2026-01-05 05:35:17 +00:00
committed by GitHub
27 changed files with 621 additions and 408 deletions
+2 -2
View File
@@ -4,10 +4,10 @@
<selectionStates>
<SelectionState runConfigName="app">
<option name="selectionMode" value="DROPDOWN" />
<DropdownSelection timestamp="2025-10-14T13:17:54.830930475Z">
<DropdownSelection timestamp="2026-01-05T05:19:59.214925354Z">
<Target type="DEFAULT_BOOT">
<handle>
<DeviceId pluginId="LocalEmulator" identifier="path=/home/ward/.android/avd/Pixel_9.avd" />
<DeviceId pluginId="LocalEmulator" identifier="path=/home/ward/.android/avd/Television_4K.avd" />
</handle>
</Target>
</DropdownSelection>
+2 -2
View File
@@ -7,8 +7,8 @@ android {
applicationId "us.materialio.app"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 183
versionName "1.12.11"
versionCode 184
versionName "1.13.0"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
@@ -13,6 +13,7 @@ dependencies {
implementation project(':capacitor-app')
implementation project(':capacitor-browser')
implementation project(':capacitor-clipboard')
implementation project(':capacitor-network')
implementation project(':capacitor-preferences')
implementation project(':capacitor-screen-orientation')
implementation project(':capacitor-share')
@@ -94,6 +94,7 @@
<!-- Permissions -->
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
@@ -14,6 +14,9 @@ project(':capacitor-browser').projectDir = new File('../node_modules/@capacitor/
include ':capacitor-clipboard'
project(':capacitor-clipboard').projectDir = new File('../node_modules/@capacitor/clipboard/android')
include ':capacitor-network'
project(':capacitor-network').projectDir = new File('../node_modules/@capacitor/network/android')
include ':capacitor-preferences'
project(':capacitor-preferences').projectDir = new File('../node_modules/@capacitor/preferences/android')
@@ -80,7 +80,11 @@
<release version="1.12.11" date="2026-1-02">
<release version="1.13.0" date="2026-1-03">
<url>https://github.com/Materialious/Materialious/releases/tag/1.13.0</url>
</release>
<release version="1.12.11" date="2026-1-02">
<url>https://github.com/Materialious/Materialious/releases/tag/1.12.11</url>
</release>
<release version="1.12.10" date="2025-12-28">
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "Materialious",
"version": "1.12.11",
"version": "1.13.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "Materialious",
"version": "1.12.11",
"version": "1.13.0",
"license": "MIT",
"dependencies": {
"@capacitor-community/electron": "^5.0.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "Materialious",
"version": "1.12.11",
"version": "1.13.0",
"description": "Modern material design for Invidious.",
"author": {
"name": "Ward Pearce",
+12 -2
View File
@@ -1,12 +1,12 @@
{
"name": "materialious",
"version": "1.12.11",
"version": "1.13.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "materialious",
"version": "1.12.11",
"version": "1.13.0",
"hasInstallScript": true,
"dependencies": {
"@capacitor-community/electron": "^5.0.1",
@@ -16,6 +16,7 @@
"@capacitor/browser": "^8.0.0",
"@capacitor/clipboard": "^8.0.0",
"@capacitor/core": "^8.0.0",
"@capacitor/network": "^8.0.0",
"@capacitor/preferences": "^8.0.0",
"@capacitor/screen-orientation": "^8.0.0",
"@capacitor/share": "^8.0.0",
@@ -2137,6 +2138,15 @@
"tslib": "^2.1.0"
}
},
"node_modules/@capacitor/network": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/@capacitor/network/-/network-8.0.0.tgz",
"integrity": "sha512-fgvB7pNKn8pKavuzys218j4YuA5euNfavp7nS3NuwWKWNupZAlbucfnl75lazxCyVF/ZRjzYVTb4vtTEfFrK1A==",
"license": "MIT",
"peerDependencies": {
"@capacitor/core": ">=8.0.0"
}
},
"node_modules/@capacitor/preferences": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/@capacitor/preferences/-/preferences-8.0.0.tgz",
+3 -2
View File
@@ -1,6 +1,6 @@
{
"name": "materialious",
"version": "1.12.11",
"version": "1.13.0",
"private": true,
"scripts": {
"dev": "npm run patch:github && vite dev",
@@ -49,6 +49,7 @@
"@capacitor/browser": "^8.0.0",
"@capacitor/clipboard": "^8.0.0",
"@capacitor/core": "^8.0.0",
"@capacitor/network": "^8.0.0",
"@capacitor/preferences": "^8.0.0",
"@capacitor/screen-orientation": "^8.0.0",
"@capacitor/share": "^8.0.0",
@@ -76,4 +77,4 @@
"svelte-infinite-loading": "^1.4.0",
"youtubei.js": "^16.0.1"
}
}
}
@@ -1,7 +1,9 @@
import { timeout } from '$lib/misc';
import { Capacitor } from '@capacitor/core';
const originalFetch = window.fetch;
const corsProxyUrl: string = 'http://localhost:3000/';
let mobileNodeLoaded = false;
function needsProxying(target: string): boolean {
if (!target.startsWith('http')) return false;
@@ -12,6 +14,21 @@ export const androidFetch = async (
requestInput: string | URL | Request,
requestOptions?: RequestInit
): Promise<Response> => {
if (!mobileNodeLoaded) {
let mobileNodeResp: Response | undefined;
while (!mobileNodeResp || !mobileNodeResp.ok) {
try {
mobileNodeResp = await originalFetch(corsProxyUrl + 'check-nodejs');
} catch {
// Continue regardless of error
}
await timeout(100);
}
mobileNodeLoaded = true;
}
const uri = requestInput instanceof Request ? requestInput.url : requestInput.toString();
if (needsProxying(uri)) {
+230 -237
View File
@@ -24,6 +24,7 @@
isAndroidTvStore,
playerAlwaysLoopStore,
playerAndroidLockOrientation,
playerAndroidPauseOnNetworkChange,
playerAutoPlayStore,
playerCCByDefault,
playerDefaultLanguage,
@@ -53,19 +54,21 @@
import { injectSabr } from '$lib/sabr';
import type { SabrStreamingAdapter } from 'googlevideo/sabr-streaming-adapter';
import { SvelteSet } from 'svelte/reactivity';
import { Network, type ConnectionStatus } from '@capacitor/network';
import { fade } from 'svelte/transition';
interface Props {
data: { video: VideoPlay; content: PhasedDescription; playlistId: string | null };
isEmbed?: boolean;
playerElement?: HTMLMediaElement | undefined;
hideControls?: boolean;
showControls?: boolean;
}
let {
data,
isEmbed = false,
playerElement = $bindable(undefined),
hideControls = false
showControls = false
}: Props = $props();
let segments: Segment[] = [];
@@ -76,6 +79,8 @@
let watchProgressInterval: NodeJS.Timeout;
let showVideoRetry = $state(false);
let androidInitialNetworkStatus: ConnectionStatus | undefined;
let player: shaka.Player;
let sabrAdapter: SabrStreamingAdapter | null;
@@ -98,11 +103,9 @@
let playerVideoEndTimePretty: string = $state('');
let playerBufferedTo: number = $state(0);
let playerCloestTimestamp: Timestamp | undefined = $state();
let playerControls: HTMLElement | undefined = $state();
// eslint-disable-next-line no-undef
let playerAndroidUITimeout: NodeJS.Timeout;
let playerAndroidforceHideControls = $state(false);
let clickCount = $state(0);
// eslint-disable-next-line no-undef
@@ -597,6 +600,17 @@
await androidHandleRotate();
if (Capacitor.getPlatform() === 'android') {
androidInitialNetworkStatus = await Network.getStatus();
Network.addListener('networkStatusChange', (networkStatus) => {
if (
androidInitialNetworkStatus?.connectionType !== networkStatus.connectionType &&
$playerAndroidPauseOnNetworkChange
) {
playerElement?.pause();
}
});
await CapacitorMusicControls.create({
track: data.video.title,
artist: data.video.author,
@@ -962,14 +976,18 @@
}
) {
if (Capacitor.getPlatform() === 'android') {
const initalControlsState = showControls.valueOf();
if (playerAndroidUITimeout) {
clearTimeout(playerAndroidUITimeout);
playerAndroidforceHideControls = false;
showControls = true;
}
playerAndroidUITimeout = setTimeout(() => {
playerAndroidforceHideControls = true;
showControls = false;
}, 3000);
if (!initalControlsState) return;
}
if (
@@ -1024,6 +1042,8 @@
}
}
await Network.removeAllListeners();
await setStatusBarColor();
await CapacitorMusicControls.destroy();
@@ -1059,9 +1079,10 @@
class:contain-video={!$isAndroidTvStore}
class:tv-contain-video={$isAndroidTvStore}
class:hide={showVideoRetry}
class:force-hide-controls={playerAndroidforceHideControls}
role="presentation"
onclick={onVideoClick}
onmouseenter={() => (showControls = true)}
onmouseleave={() => (showControls = false)}
bind:this={playerContainer}
>
<video
@@ -1075,8 +1096,8 @@
{data.video.title}
</div>
{/if}
{#if !hideControls}
<div id="mobile-time">
{#if showControls}
<div id="mobile-time" transition:fade>
<p class="chip inverse-primary s">
{#if data.video.liveNow}
{$_('thumbnail.live')}
@@ -1122,22 +1143,21 @@
</div>
</div>
</div>
{#if !hideControls}
<div id="player-controls" bind:this={playerControls}>
{#if showControls}
<div id="player-controls" transition:fade>
<label class="slider" id="progress-slider">
{#key playerCurrentTime}
<input
style="width: 100%;"
type="range"
oninput={handleTimeChange}
min={0}
step={0.1}
bind:value={playerCurrentTime}
max={playerMaxKnownTime}
onmousemove={handleMouseMove}
onmouseleave={handleMouseLeave}
/>
{/key}
<input
style="width: 100%;"
type="range"
oninput={handleTimeChange}
min={0}
step={0.1}
bind:value={playerCurrentTime}
max={playerMaxKnownTime}
onmousemove={handleMouseMove}
onmouseleave={handleMouseLeave}
disabled={$isAndroidTvStore}
/>
<span></span>
<div bind:this={playerBufferBar} class="buffered-bar"></div>
{#each data.content.timestamps as chapter, index (chapter)}
@@ -1164,19 +1184,19 @@
{/if}
<nav>
<nav class="no-wrap">
<button class="inverse-primary" onclick={toggleVideoPlaybackStatus}>
<i>
{#if playerCurrentPlaybackState}
pause
{:else}
play_arrow
{/if}
</i>
</button>
{#if Capacitor.getPlatform() !== 'android'}
<label class="slider round m l" id="volume-slider">
{#key playerVolume}
{#if !$isAndroidTvStore}
<nav class="no-wrap">
<button class="inverse-primary" onclick={toggleVideoPlaybackStatus}>
<i>
{#if playerCurrentPlaybackState}
pause
{:else}
play_arrow
{/if}
</i>
</button>
{#if Capacitor.getPlatform() !== 'android'}
<label class="slider round m l" id="volume-slider">
<input
oninput={() => {
if (!playerElement) return;
@@ -1188,11 +1208,11 @@
step="0.1"
max="1"
/>
{/key}
<span></span>
</label>
{/if}
</nav>
<span></span>
</label>
{/if}
</nav>
{/if}
<div class="max"></div>
@@ -1204,194 +1224,196 @@
{videoLength(playerCurrentTime)} / {videoLength(data.video.lengthSeconds)}
{/if}
</p>
{#if playerTextTracks && playerTextTracks.length > 0 && !data.video.liveNow}
<button class="inverse-primary">
<i>closed_caption</i>
<menu class="no-wrap mobile" id="cc-menu" data-ui="#cc-menu">
<li
role="presentation"
data-ui="#cc-menu"
onclick={() => player.setTextTrackVisibility(false)}
>
{$_('player.controls.off')}
</li>
{#each playerTextTracks as track (track)}
{#if !$isAndroidTvStore}
{#if playerTextTracks && playerTextTracks.length > 0 && !data.video.liveNow}
<button class="inverse-primary">
<i>closed_caption</i>
<menu class="no-wrap mobile" id="cc-menu" data-ui="#cc-menu">
<li
role="presentation"
data-ui="#cc-menu"
onclick={() => {
player.selectTextTrack(track);
player.setTextTrackVisibility(true);
}}
onclick={() => player.setTextTrackVisibility(false)}
>
{track.label}
{$_('player.controls.off')}
</li>
{/each}
</menu>
</button>
{/if}
<button class="inverse-primary">
<i>settings</i>
<menu class="no-wrap mobile" id="settings-menu">
{#if playerSettings !== 'root'}
<li role="presentation" onclick={() => (playerSettings = 'root')}>
<i>arrow_back</i>
{$_('player.controls.back')}
</li>
{/if}
{#if playerSettings === 'root'}
<li role="presentation" onclick={() => (playerSettings = 'quality')}>
<nav class="no-wrap" style="width: 100%;">
<i>high_quality</i>
{$_('player.controls.quality')}
<div class="max"></div>
<span class="chip">
{#if playerCurrentVideoTrack}
{playerCurrentVideoTrack.height}p
{:else}
{$_('player.controls.auto')}
{/if}
</span>
</nav>
</li>
<li role="presentation" onclick={() => (playerSettings = 'speed')}>
<nav class="no-wrap" style="width: 100%;">
<i>speed</i>
{$_('player.controls.playbackSpeed')}
<div class="max"></div>
<span class="chip">
{playerElement?.playbackRate}x
</span>
</nav>
</li>
{#if playerCurrentAudioTrack && playerCurrentAudioTrack.label !== null}
<li role="presentation" onclick={() => (playerSettings = 'language')}>
{#each playerTextTracks as track (track)}
<li
role="presentation"
data-ui="#cc-menu"
onclick={() => {
player.selectTextTrack(track);
player.setTextTrackVisibility(true);
}}
>
{track.label}
</li>
{/each}
</menu>
</button>
{/if}
<button class="inverse-primary">
<i>settings</i>
<menu class="no-wrap mobile" id="settings-menu">
{#if playerSettings !== 'root'}
<li role="presentation" onclick={() => (playerSettings = 'root')}>
<i>arrow_back</i>
{$_('player.controls.back')}
</li>
{/if}
{#if playerSettings === 'root'}
<li role="presentation" onclick={() => (playerSettings = 'quality')}>
<nav class="no-wrap" style="width: 100%;">
<i>language</i>
{$_('player.controls.language')}
<i>high_quality</i>
{$_('player.controls.quality')}
<div class="max"></div>
<span class="chip">
{#if playerCurrentAudioTrack}
{playerCurrentAudioTrack.language !== 'und'
? ISO6391.getName(playerCurrentAudioTrack.language)
: playerCurrentAudioTrack.label}
{#if playerCurrentVideoTrack}
{playerCurrentVideoTrack.height}p
{:else}
{$_('player.controls.auto')}
{/if}
</span>
</nav>
</li>
<li role="presentation" onclick={() => (playerSettings = 'speed')}>
<nav class="no-wrap" style="width: 100%;">
<i>speed</i>
{$_('player.controls.playbackSpeed')}
<div class="max"></div>
<span class="chip">
{playerElement?.playbackRate}x
</span>
</nav>
</li>
{#if playerCurrentAudioTrack && playerCurrentAudioTrack.label !== null}
<li role="presentation" onclick={() => (playerSettings = 'language')}>
<nav class="no-wrap" style="width: 100%;">
<i>language</i>
{$_('player.controls.language')}
<div class="max"></div>
<span class="chip">
{#if playerCurrentAudioTrack}
{playerCurrentAudioTrack.language !== 'und'
? ISO6391.getName(playerCurrentAudioTrack.language)
: playerCurrentAudioTrack.label}
{/if}
</span>
</nav>
</li>
{/if}
<li
role="presentation"
onclick={() => {
if (playerElement) playerElement.loop = !playerLoop;
playerLoop = !playerLoop;
}}
>
<nav class="no-wrap" style="width: 100%;">
<i>all_inclusive</i>
{$_('player.controls.loop')}
<div class="max"></div>
<span class="chip">
{playerLoop ? $_('player.controls.on') : $_('player.controls.off')}
</span>
</nav>
</li>
{:else if playerSettings === 'quality'}
<li
role="presentation"
onclick={() => {
playerSettings = 'root';
player.configure({ abr: true });
playerCurrentVideoTrack = undefined;
}}
>
{$_('player.controls.auto')}
</li>
{#each player.getVideoTracks().sort((a, b) => {
const heightA = a.height || 0;
const heightB = b.height || 0;
const widthA = a.width || 0;
const widthB = b.width || 0;
return heightB - heightA || widthB - widthA;
}) as track (track)}
<li
role="presentation"
onclick={() => {
playerSettings = 'root';
player.selectVideoTrack(track, true);
setActiveVideoTrack();
}}
>
{track.height}p
</li>
{/each}
{:else if playerSettings === 'speed'}
{#each playbackRates as playbackRate (playbackRate)}
<li
role="presentation"
onclick={() => {
playerSettings = 'root';
if (playerElement) playerElement.playbackRate = playbackRate;
}}
>
{playbackRate}
</li>
{/each}
{:else if playerSettings === 'language'}
{#each filterUniqueAudioTracks(player.getAudioTracks()) as track (track)}
<li
role="presentation"
onclick={() => {
playerSettings = 'root';
player.selectAudioTrack(track);
setActiveAudioTrack();
}}
>
{#if track.language !== 'und'}
{ISO6391.getName(track.language)} -
{/if}
{track.label}
</li>
{/each}
{/if}
<li
role="presentation"
onclick={() => {
if (playerElement) playerElement.loop = !playerLoop;
playerLoop = !playerLoop;
}}
>
<nav class="no-wrap" style="width: 100%;">
<i>all_inclusive</i>
{$_('player.controls.loop')}
<div class="max"></div>
<span class="chip">
{playerLoop ? $_('player.controls.on') : $_('player.controls.off')}
</span>
</nav>
</li>
{:else if playerSettings === 'quality'}
<li
role="presentation"
onclick={() => {
playerSettings = 'root';
player.configure({ abr: true });
playerCurrentVideoTrack = undefined;
}}
>
{$_('player.controls.auto')}
</li>
{#each player.getVideoTracks().sort((a, b) => {
const heightA = a.height || 0;
const heightB = b.height || 0;
const widthA = a.width || 0;
const widthB = b.width || 0;
return heightB - heightA || widthB - widthA;
}) as track (track)}
<li
role="presentation"
onclick={() => {
playerSettings = 'root';
player.selectVideoTrack(track, true);
setActiveVideoTrack();
}}
>
{track.height}p
</li>
{/each}
{:else if playerSettings === 'speed'}
{#each playbackRates as playbackRate (playbackRate)}
<li
role="presentation"
onclick={() => {
playerSettings = 'root';
if (playerElement) playerElement.playbackRate = playbackRate;
}}
>
{playbackRate}
</li>
{/each}
{:else if playerSettings === 'language'}
{#each filterUniqueAudioTracks(player.getAudioTracks()) as track (track)}
<li
role="presentation"
onclick={() => {
playerSettings = 'root';
player.selectAudioTrack(track);
setActiveAudioTrack();
}}
>
{#if track.language !== 'und'}
{ISO6391.getName(track.language)} -
{/if}
{track.label}
</li>
{/each}
{/if}
</menu>
</button>
{#if document.pictureInPictureEnabled}
</menu>
</button>
{#if document.pictureInPictureEnabled}
<button
class="inverse-primary"
onclick={() => {
(playerElement as HTMLVideoElement).requestPictureInPicture();
}}
>
<i>pip</i>
</button>
{/if}
<button
class="inverse-primary"
onclick={() => {
(playerElement as HTMLVideoElement).requestPictureInPicture();
if (document.fullscreenElement) {
document.exitFullscreen();
} else {
playerContainer.requestFullscreen();
}
}}
>
<i>pip</i>
<i>
{#if document.fullscreenElement}
fullscreen_exit
{:else}
fullscreen
{/if}
</i>
</button>
{/if}
<button
class="inverse-primary"
onclick={() => {
if (document.fullscreenElement) {
document.exitFullscreen();
} else {
playerContainer.requestFullscreen();
}
}}
>
<i>
{#if document.fullscreenElement}
fullscreen_exit
{:else}
fullscreen
{/if}
</i>
</button>
</nav>
</nav>
</div>
@@ -1435,10 +1457,7 @@
bottom: 0;
left: 0;
right: 0;
opacity: 0;
transition: opacity 2s ease;
padding: 10px;
user-select: none;
}
#player-controls span {
@@ -1463,8 +1482,6 @@
top: 0;
right: 0;
padding: 10px;
opacity: 0;
transition: opacity 2s ease;
}
#mobile-time .chip {
@@ -1481,30 +1498,6 @@
margin: 0;
}
#player-container:focus-within #player-controls,
#player-container:active #player-controls,
#player-container:hover #player-controls {
opacity: 1;
transition: opacity 0.3s ease;
user-select: all;
}
#player-container:focus-within #mobile-time,
#player-container:active #mobile-time,
#player-container:hover #mobile-time {
opacity: 1;
transition: opacity 0.3s ease;
}
#player-container.force-hide-controls #player-controls {
opacity: 0 !important;
pointer-events: none !important;
}
#player-container.force-hide-controls #mobile-time {
display: none !important;
}
.seek-double-click {
background-color: var(--secondary-container);
height: var(--video-player-height);
+24 -43
View File
@@ -1,6 +1,5 @@
<script lang="ts">
import { resolve } from '$app/paths';
import { goto } from '$app/navigation';
import Mousetrap from 'mousetrap';
import { createEventDispatcher, onMount, tick } from 'svelte';
import { _ } from '$lib/i18n';
@@ -8,18 +7,20 @@
import {
interfaceSearchHistoryEnabled,
interfaceSearchSuggestionsStore,
isAndroidTvStore,
searchHistoryStore
} from '../store';
import { isVideoID } from '$lib/misc';
import { goToSearch } from '$lib/search';
let {
autoFocus = false,
suggestionsForSearch = $bindable([])
}: {
autoFocus?: boolean;
suggestionsForSearch?: string[];
} = $props();
const dispatch = createEventDispatcher();
let searchSuggestions = $state(false);
interfaceSearchSuggestionsStore.subscribe((value) => (searchSuggestions = value));
let search: string = $state('');
let suggestionsForSearch: string[] = $state([]);
let selectedSuggestionIndex: number = $state(-1);
let showSearchBox = $state(false);
@@ -27,7 +28,7 @@
// eslint-disable-next-line no-undef
let debounceTimer: NodeJS.Timeout;
function debouncedSearch(event: any) {
if (!searchSuggestions) return;
if (!$interfaceSearchSuggestionsStore) return;
if (debounceTimer) clearTimeout(debounceTimer);
debounceTimer = setTimeout(async () => {
@@ -39,32 +40,14 @@
}, 250);
}
function handleSubmit(event: Event | undefined = undefined) {
function onSubmit(event: Event | undefined = undefined) {
event?.preventDefault();
const searchTrimed = search.trim();
if (!searchTrimed) return;
if (isVideoID(searchTrimed)) {
// Go directly to video if Video ID provided
goto(resolve('/watch/[videoId]', { videoId: searchTrimed }));
return;
}
goToSearch(search);
selectedSuggestionIndex = -1;
goto(resolve(`/search/[search]`, { search: encodeURIComponent(searchTrimed) }));
suggestionsForSearch = [];
showSearchBox = false;
if ($interfaceSearchHistoryEnabled && !$searchHistoryStore.includes(searchTrimed)) {
let pastHistory = $searchHistoryStore;
if (pastHistory.length > 15) {
pastHistory.pop();
}
searchHistoryStore.set([searchTrimed, ...pastHistory]);
}
}
function handleKeyDown(event: KeyboardEvent) {
@@ -93,7 +76,7 @@
}
} else if (event.key === 'Enter' && selectedSuggestionIndex !== -1) {
search = suggestionsForSearch[selectedSuggestionIndex];
handleSubmit();
onSubmit();
} else if ((event.ctrlKey || event.metaKey) && event.key === 'k') {
resetSearch();
event.preventDefault();
@@ -116,11 +99,17 @@
if (!showSearchBox) resetSearch();
return false;
});
if (autoFocus) {
showSearchBox = true;
await tick();
document.getElementById('search')?.focus();
}
});
</script>
<form
onsubmit={handleSubmit}
onsubmit={onSubmit}
onclick={async () => {
showSearchBox = true;
await tick();
@@ -128,11 +117,7 @@
}}
role="presentation"
>
<div
class="field prefix fill no-margin rounded"
class:search={!$isAndroidTvStore}
class:search-tv={$isAndroidTvStore}
>
<div class="field prefix fill no-margin rounded search">
<i class="front" tabindex="-1">search</i>
<input
tabindex="0"
@@ -157,7 +142,7 @@
onkeydown={handleKeyDown}
onkeyup={(event) => {
if (event.key === 'Enter') {
handleSubmit();
onSubmit();
} else {
debouncedSearch(event);
}
@@ -165,13 +150,13 @@
/>
<i class="front" role="presentation" onclick={resetSearch}>close</i>
</div>
{#if searchSuggestions}
{#if $interfaceSearchSuggestionsStore}
{#each suggestionsForSearch as suggestion, index (index)}
<li>
<a
onclick={() => {
search = suggestion;
handleSubmit();
onSubmit();
}}
class:selected={index === selectedSuggestionIndex}
href={resolve(`/search/[search]`, { search: encodeURIComponent(suggestion) })}
@@ -205,10 +190,6 @@
width: 500px;
}
.search-tv {
width: 600px;
}
.selected {
background-color: var(--surface-variant);
}
@@ -7,6 +7,7 @@
import {
playerAlwaysLoopStore,
playerAndroidLockOrientation,
playerAndroidPauseOnNetworkChange,
playerAutoPlayStore,
playerAutoplayNextByDefaultStore,
playerCCByDefault,
@@ -222,6 +223,23 @@
</label>
</nav>
</div>
<div class="field no-margin">
<nav class="no-padding">
<div class="max">
<div>{$_('layout.player.networkChangePause')}</div>
</div>
<label class="switch" tabindex="0">
<input
type="checkbox"
bind:checked={$playerAndroidPauseOnNetworkChange}
onclick={() => playerAndroidPauseOnNetworkChange.set(!$playerAndroidPauseOnNetworkChange)}
role="switch"
/>
<span></span>
</label>
</nav>
</div>
{/if}
{#if Capacitor.isNativePlatform()}
+1 -1
View File
@@ -41,7 +41,7 @@ button,
}
nav.top {
padding-bottom: 1em;
padding: 1em 0;
}
main.root {
@@ -187,6 +187,7 @@
"youtubeJsFallback": "Local video processing fallback",
"youtubeJsAlways": "Always use",
"lockOrientation": "Orientation locked to aspect ratio",
"networkChangePause": "Pause on network change",
"defaultPlaybackSpeed": "Default playback speed",
"playerStatistics": "Show player statistics by default"
},
+27
View File
@@ -0,0 +1,27 @@
import { goto } from '$app/navigation';
import { resolve } from '$app/paths';
import { get } from 'svelte/store';
import { isVideoID } from './misc';
import { interfaceSearchHistoryEnabled, searchHistoryStore } from './store';
export function goToSearch(searchValue: string) {
const searchTrimed = searchValue.trim();
if (!searchTrimed) return;
if (isVideoID(searchTrimed)) {
// Go directly to video if Video ID provided
goto(resolve('/watch/[videoId]', { videoId: searchTrimed }));
return;
}
goto(resolve(`/search/[search]`, { search: encodeURIComponent(searchTrimed) }));
if (get(interfaceSearchHistoryEnabled) && !get(searchHistoryStore).includes(searchTrimed)) {
const pastHistory = get(searchHistoryStore);
if (pastHistory.length > 15) {
pastHistory.pop();
}
searchHistoryStore.set([searchTrimed, ...pastHistory]);
}
}
+5
View File
@@ -170,6 +170,11 @@ export const playerMiniplayerEnabled = persist(
createStorage(),
'miniplayerEnabled'
);
export const playerAndroidPauseOnNetworkChange = persist(
writable(true),
createStorage(),
'pauseOnNetworkChange'
);
export const playerPlaylistHistory: Writable<string[]> = writable([]);
export interface PlayerState {
+7 -2
View File
@@ -35,6 +35,11 @@ export function processYoutubeLink(line: string): string {
}
}
function cleanTimestampTitle(title: string): string {
// Remove leading separators like -, •, |, :, /, \, *, #, >, ~ and whitespace
return title.replace(/^[\s\-•|:/\\*#>~]+/, '').trim();
}
export function phaseDescription(
videoId: string,
content: string,
@@ -67,7 +72,7 @@ export function phaseDescription(
timestamps.push({
time: convertToSeconds(timeParam.replace('s', '')),
title: decodeHtmlCharCodes(title),
title: cleanTimestampTitle(decodeHtmlCharCodes(title)),
timePretty,
endTime: -1
});
@@ -86,7 +91,7 @@ export function phaseDescription(
timestamps.push({
time: convertToSeconds(time),
title: decodeHtmlCharCodes(title),
title: cleanTimestampTitle(decodeHtmlCharCodes(title)),
timePretty,
endTime: -1
});
+85 -90
View File
@@ -38,11 +38,10 @@
import 'beercss';
import ui from 'beercss';
import 'material-dynamic-colors';
import { onDestroy, onMount } from 'svelte';
import { onMount } from 'svelte';
import { _ } from '$lib/i18n';
import { get } from 'svelte/store';
import { pwaInfo } from 'virtual:pwa-info';
import Mousetrap from 'mousetrap';
import { truncate } from '$lib/misc';
import Author from '$lib/components/Author.svelte';
@@ -76,14 +75,6 @@
await setStatusBarColor();
});
App.addListener('backButton', async (data) => {
if (data.canGoBack) {
window.history.back();
} else {
await App.exitApp();
}
});
App.addListener('appUrlOpen', (data) => {
const url = new URL(data.url);
@@ -216,20 +207,6 @@
}
}
if ($isAndroidTvStore) {
const topContent = document.getElementById('top-content') as HTMLElement;
Mousetrap.bind(
'down',
() => {
if (topContent.contains(document.activeElement)) {
document.getElementById('main-content')?.focus();
return true;
}
},
'keyup'
);
}
if (Capacitor.getPlatform() === 'android') {
document.addEventListener('click', async (event: MouseEvent) => {
// Handles opening links in browser for android.
@@ -257,12 +234,6 @@
}
});
onDestroy(() => {
if ($isAndroidTvStore) {
Mousetrap.unbind('down', 'keyup');
}
});
let webManifestLink = $derived(pwaInfo ? pwaInfo.webManifest.linkTag : '');
</script>
@@ -282,6 +253,10 @@
>
<Logo />
</header>
<a href={resolve('/search', {})} class:active={$page.url.href.endsWith('/search')}>
<i>search</i>
<div>{$_('searchPlaceholder')}</div>
</a>
{#each getPages() as navPage (navPage)}
{#if !navPage.requiresAuth || isLoggedIn}
<a href={resolve(navPage.href, {})} class:active={$page.url.href.endsWith(navPage.href)}
@@ -290,73 +265,93 @@
</a>
{/if}
{/each}
</nav>
<nav class="top" id="top-content" class:tv-nav={$isAndroidTvStore}>
{#if !mobileSearchShow}
<button
onclick={() => (mobileSearchShow = !mobileSearchShow)}
class="transparent s circle large"
>
<i>search</i>
</button>
{/if}
{#if Capacitor.getPlatform() === 'electron'}
<nav class="no-space">
<button onclick={() => window.history.back()} class="border">
<i>arrow_back</i>
</button>
<button onclick={() => window.history.forward()} class="border">
<i>arrow_forward</i>
</button>
</nav>
{/if}
<div class="max m l"></div>
<div class="m l">
<Search />
</div>
{#if !mobileSearchShow}
<div class="max"></div>
{/if}
{#if mobileSearchShow}
<div style="width: 100%;">
<Search on:searchCancelled={() => (mobileSearchShow = false)} />
</div>
{:else}
{#if !Capacitor.isNativePlatform()}
<button data-ui="#sync-party" class="circle large transparent">
<i class:primary-text={$syncPartyPeerStore}>group</i>
<div class="tooltip bottom">{$_('layout.syncParty')}</div>
</button>
{/if}
{#if isLoggedIn}
<button class="circle large transparent" onclick={() => ui('#dialog-notifications')}
><i>notifications</i>
<div class="tooltip bottom">{$_('layout.notifications')}</div>
</button>
{/if}
<button class="circle large transparent" onclick={() => ui('#dialog-settings')}>
{#if $isAndroidTvStore}
<div class="divider"></div>
<a href="#settings" onclick={() => ui('#dialog-settings')}>
<i>settings</i>
<div class="tooltip bottom">{$_('layout.settings')}</div>
</button>
<div>{$_('layout.settings')}</div>
</a>
{#if !isLoggedIn}
<button onclick={login} class="circle large transparent">
<a onclick={login} href="#login">
<i>login</i>
<div class="tooltip bottom">{$_('layout.login')}</div>
</button>
<div>{$_('layout.login')}</div>
</a>
{:else}
<button onclick={logout} class="circle large transparent">
<a onclick={logout} href="#logout">
<i>logout</i>
<div class="tooltip bottom">{$_('layout.logout')}</div>
</button>
<div>{$_('layout.logout')}</div>
</a>
{/if}
{/if}
</nav>
{#if !$isAndroidTvStore}
<nav class="top" id="top-content" class:tv-nav={$isAndroidTvStore}>
{#if !mobileSearchShow}
<button
onclick={() => (mobileSearchShow = !mobileSearchShow)}
class="transparent s circle large"
>
<i>search</i>
</button>
{/if}
{#if Capacitor.getPlatform() === 'electron'}
<nav class="no-space">
<button onclick={() => window.history.back()} class="border">
<i>arrow_back</i>
</button>
<button onclick={() => window.history.forward()} class="border">
<i>arrow_forward</i>
</button>
</nav>
{/if}
<div class="max m l"></div>
<div class="m l">
<Search />
</div>
{#if !mobileSearchShow}
<div class="max"></div>
{/if}
{#if mobileSearchShow}
<div style="width: 100%;">
<Search on:searchCancelled={() => (mobileSearchShow = false)} />
</div>
{:else}
{#if !Capacitor.isNativePlatform()}
<button data-ui="#sync-party" class="circle large transparent">
<i class:primary-text={$syncPartyPeerStore}>group</i>
<div class="tooltip bottom">{$_('layout.syncParty')}</div>
</button>
{/if}
{#if isLoggedIn}
<button class="circle large transparent" onclick={() => ui('#dialog-notifications')}
><i>notifications</i>
<div class="tooltip bottom">{$_('layout.notifications')}</div>
</button>
{/if}
<button class="circle large transparent" onclick={() => ui('#dialog-settings')}>
<i>settings</i>
<div class="tooltip bottom">{$_('layout.settings')}</div>
</button>
{#if !isLoggedIn}
<button onclick={login} class="circle large transparent">
<i>login</i>
<div class="tooltip bottom">{$_('layout.login')}</div>
</button>
{:else}
<button onclick={logout} class="circle large transparent">
<i>logout</i>
<div class="tooltip bottom">{$_('layout.logout')}</div>
</button>
{/if}
{/if}
</nav>
{/if}
<nav class="bottom s">
{#each getPages() as navPage (navPage)}
@@ -425,7 +420,7 @@
{/if}
<div class="player">
{#key $playerState.data.video.videoId}
<Player data={$playerState.data} hideControls={playerIsPip} />
<Player data={$playerState.data} />
{/key}
</div>
</div>
@@ -0,0 +1,94 @@
<script lang="ts">
import { getSearchSuggestions } from '$lib/api';
import { _ } from '$lib/i18n';
import { goToSearch } from '$lib/search';
import {
interfaceSearchHistoryEnabled,
interfaceSearchSuggestionsStore,
searchHistoryStore
} from '$lib/store';
let suggestionsForSearch: string[] = $state([]);
let search = $state('');
// eslint-disable-next-line no-undef
let debounceTimer: NodeJS.Timeout;
function debouncedSearch(event: any) {
if (!$interfaceSearchSuggestionsStore) return;
if (debounceTimer) clearTimeout(debounceTimer);
debounceTimer = setTimeout(async () => {
if (event.target.value === '') {
suggestionsForSearch = [];
return;
}
suggestionsForSearch = (await getSearchSuggestions(event.target.value)).suggestions;
}, 250);
}
</script>
<div class="space"></div>
<div class="search">
<div class="field large prefix" style="width: 600px;">
<i class="front" tabindex="-1">search</i>
<input
tabindex="0"
placeholder={$_('searchPlaceholder')}
type="text"
id="search"
autofocus
required
bind:value={search}
onkeyup={(event) => {
if (event.key === 'Enter') {
goToSearch(search);
} else {
debouncedSearch(event);
}
}}
/>
</div>
<ul class="list border no-space" style="width: 600px;">
{#if $interfaceSearchSuggestionsStore}
{#each suggestionsForSearch as suggestion, index (index)}
<li>
<button
tabindex="0"
onclick={() => {
goToSearch(suggestion);
}}
class="border"
>
<div>{suggestion}</div>
</button>
</li>
{/each}
{/if}
{#if !suggestionsForSearch.length && $interfaceSearchHistoryEnabled}
{#each $searchHistoryStore as history (history)}
<li>
<button
tabindex="0"
onclick={() => {
goToSearch(history);
}}
class="border"
>
<div>{history}</div>
</button>
</li>
{/each}
{/if}
</ul>
</div>
<style>
.search {
display: flex;
justify-content: center;
align-items: center;
flex-direction: column;
}
</style>
@@ -17,6 +17,60 @@
let playerElement: HTMLMediaElement | undefined = $state();
let showInfo = $state(false);
let playerCurrentTime: number = $state(0);
let showControls = $state(false);
// eslint-disable-next-line no-undef
let seekInterval: NodeJS.Timeout | undefined;
let seekStartTime: number = 0;
let isSeeking = false;
function startSeeking(direction: 'left' | 'right') {
if (!playerElement || showInfo || isSeeking) return true;
isSeeking = true;
showControls = true;
seekStartTime = Date.now();
const videoDuration = data.video.lengthSeconds;
const baseSeekAmount = videoDuration * 0.005; // 0.5% of video duration as base
// Initial seek
if (direction === 'right') {
playerElement.currentTime = Math.min(
playerElement.currentTime + baseSeekAmount,
videoDuration
);
} else {
playerElement.currentTime = Math.max(playerElement.currentTime - baseSeekAmount, 0);
}
// Progressive seeking
seekInterval = setInterval(() => {
if (!playerElement || !isSeeking) return;
const holdDuration = (Date.now() - seekStartTime) / 1000; // in seconds
const acceleration = Math.min(holdDuration * 0.5, 5); // Cap at 5x acceleration
const seekAmount = baseSeekAmount * (1 + acceleration);
if (direction === 'right') {
playerElement.currentTime = Math.min(playerElement.currentTime + seekAmount, videoDuration);
} else {
playerElement.currentTime = Math.max(playerElement.currentTime - seekAmount, 0);
}
}, 100);
return false;
}
function stopSeeking() {
isSeeking = false;
showControls = false;
if (seekInterval) {
clearInterval(seekInterval);
seekInterval = undefined;
}
return false;
}
onMount(() => {
if (playerElement) {
@@ -67,34 +121,22 @@
'keyup'
);
Mousetrap.bind(
'right',
() => {
if (!playerElement || showInfo) return true;
playerElement.currentTime = playerElement.currentTime + 10;
return false;
},
'keyup'
);
Mousetrap.bind(
'left',
() => {
if (!playerElement || showInfo) return true;
playerElement.currentTime = playerElement.currentTime - 10;
return false;
},
'keyup'
);
// Arrow key seeking bindings
Mousetrap.bind('right', () => startSeeking('right'), 'keydown');
Mousetrap.bind('right', stopSeeking, 'keyup');
Mousetrap.bind('left', () => startSeeking('left'), 'keydown');
Mousetrap.bind('left', stopSeeking, 'keyup');
Mousetrap.bind(
'enter',
() => {
if (!showInfo) {
if (playerElement?.paused) {
showControls = false;
playerElement?.play();
} else {
showControls = true;
playerElement?.pause();
}
return false;
@@ -108,11 +150,14 @@
onDestroy(() => {
Mousetrap.unbind(['up', 'down', 'left', 'right', 'enter']);
if (seekInterval) {
clearInterval(seekInterval);
}
});
</script>
{#key data.video.videoId}
<Player bind:playerElement isEmbed={true} {data} hideControls={true} />
<Player bind:playerElement isEmbed={true} {data} {showControls} />
{/key}
{#if showInfo}
+9
View File
@@ -1,7 +1,16 @@
<script lang="ts">
import { isAndroidTvStore } from '$lib/store';
import { App } from '@capacitor/app';
let { children } = $props();
App.addListener('backButton', async (data) => {
if (data.canGoBack) {
window.history.back();
} else {
await App.exitApp();
}
});
</script>
<svelte:head>
File diff suppressed because one or more lines are too long
@@ -177,6 +177,9 @@ const server = http.createServer((req, res) => {
rejectUnauthorized = true;
res.writeHead(200);
return res.end();
} else if (targetUrl === 'check-nodejs') {
res.writeHead(200);
return res.end();
}
if (!targetUrl.startsWith('http')) {
Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.7 MiB

After

Width:  |  Height:  |  Size: 6.0 MiB

+1 -1
View File
@@ -3,7 +3,7 @@ import os
import re
from datetime import datetime
LATEST_VERSION = "1.12.11"
LATEST_VERSION = "1.13.0"
RELEASE_DATE = datetime.now().strftime("%Y-%-m-%d") # Format: YYYY-M-D
WORKING_DIR = os.path.join(os.path.dirname(os.path.realpath(__file__)), "materialious")