+2
-2
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
Generated
+2
-2
@@ -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,6 +1,6 @@
|
||||
{
|
||||
"name": "Materialious",
|
||||
"version": "1.12.11",
|
||||
"version": "1.13.0",
|
||||
"description": "Modern material design for Invidious.",
|
||||
"author": {
|
||||
"name": "Ward Pearce",
|
||||
|
||||
Generated
+12
-2
@@ -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",
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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()}
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
});
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user