Android TV search improvements

This commit is contained in:
WardPearce
2026-01-05 16:28:11 +13:00
parent 1c21aa352c
commit 5ea4d7390a
6 changed files with 227 additions and 143 deletions
+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",
+54 -60
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';
@@ -11,15 +10,20 @@
isAndroidTvStore,
searchHistoryStore
} from '../store';
import { isVideoID } from '$lib/misc';
import { goToSearch } from '$lib/search';
let {
hideSearchSuggestionsAndHistory = false,
autoFocus = false,
suggestionsForSearch = $bindable([])
}: {
hideSearchSuggestionsAndHistory?: boolean;
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 +31,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 +43,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 +79,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 +102,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();
@@ -157,7 +149,7 @@
onkeydown={handleKeyDown}
onkeyup={(event) => {
if (event.key === 'Enter') {
handleSubmit();
onSubmit();
} else {
debouncedSearch(event);
}
@@ -165,35 +157,37 @@
/>
<i class="front" role="presentation" onclick={resetSearch}>close</i>
</div>
{#if searchSuggestions}
{#each suggestionsForSearch as suggestion, index (index)}
<li>
<a
onclick={() => {
search = suggestion;
handleSubmit();
}}
class:selected={index === selectedSuggestionIndex}
href={resolve(`/search/[search]`, { search: encodeURIComponent(suggestion) })}
>
<div>{suggestion}</div>
</a>
</li>
{/each}
{/if}
{#if !suggestionsForSearch.length && $interfaceSearchHistoryEnabled}
{#each $searchHistoryStore as history (history)}
<li>
<a
onclick={() => {
search = history;
}}
href={resolve(`/search/[search]`, { search: encodeURIComponent(history) })}
>
<div>{history}</div>
</a>
</li>
{/each}
{#if !hideSearchSuggestionsAndHistory}
{#if $interfaceSearchSuggestionsStore}
{#each suggestionsForSearch as suggestion, index (index)}
<li>
<a
onclick={() => {
search = suggestion;
onSubmit();
}}
class:selected={index === selectedSuggestionIndex}
href={resolve(`/search/[search]`, { search: encodeURIComponent(suggestion) })}
>
<div>{suggestion}</div>
</a>
</li>
{/each}
{/if}
{#if !suggestionsForSearch.length && $interfaceSearchHistoryEnabled}
{#each $searchHistoryStore as history (history)}
<li>
<a
onclick={() => {
search = history;
}}
href={resolve(`/search/[search]`, { search: encodeURIComponent(history) })}
>
<div>{history}</div>
</a>
</li>
{/each}
{/if}
{/if}
</menu>
{/if}
+1 -1
View File
@@ -41,7 +41,7 @@ button,
}
nav.top {
padding-bottom: 1em;
padding: 1em 0;
}
main.root {
+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]);
}
}
+84 -80
View File
@@ -38,7 +38,7 @@
import 'beercss';
import ui from 'beercss';
import 'material-dynamic-colors';
import { onDestroy, onMount } from 'svelte';
import { onDestroy, onMount, tick } from 'svelte';
import { _ } from '$lib/i18n';
import { get } from 'svelte/store';
import { pwaInfo } from 'virtual:pwa-info';
@@ -216,20 +216,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 +243,6 @@
}
});
onDestroy(() => {
if ($isAndroidTvStore) {
Mousetrap.unbind('down', 'keyup');
}
});
let webManifestLink = $derived(pwaInfo ? pwaInfo.webManifest.linkTag : '');
</script>
@@ -282,6 +262,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 +274,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)}
@@ -0,0 +1,59 @@
<script lang="ts">
import { resolve } from '$app/paths';
import Search from '$lib/components/Search.svelte';
import { goToSearch } from '$lib/search';
import {
interfaceSearchHistoryEnabled,
interfaceSearchSuggestionsStore,
searchHistoryStore
} from '$lib/store';
let suggestionsForSearch = $state([]);
</script>
<div class="space"></div>
<div class="search">
<Search hideSearchSuggestionsAndHistory={true} autoFocus={true} bind:suggestionsForSearch />
<ul class="list border no-space" style="width: 600px;">
{#if $interfaceSearchSuggestionsStore}
{#each suggestionsForSearch as suggestion, index (index)}
<li>
<a
onclick={() => {
goToSearch(suggestion);
}}
href={resolve(`/search/[search]`, { search: encodeURIComponent(suggestion) })}
>
<div>{suggestion}</div>
</a>
</li>
{/each}
{/if}
{#if !suggestionsForSearch.length && $interfaceSearchHistoryEnabled}
{#each $searchHistoryStore as history (history)}
<li>
<a
onclick={() => {
goToSearch(history);
}}
href={resolve(`/search/[search]`, { search: encodeURIComponent(history) })}
>
<div>{history}</div>
</a>
</li>
{/each}
{/if}
</ul>
</div>
<style>
.search {
display: flex;
justify-content: center;
align-items: center;
flex-direction: column;
}
</style>