Started android tv support

This commit is contained in:
ward
2025-06-19 14:49:59 +12:00
parent 99a21e7564
commit be8e3711ce
20 changed files with 295 additions and 95 deletions
+4 -1
View File
@@ -15,6 +15,9 @@ android {
// Default: https://android.googlesource.com/platform/frameworks/base/+/282e181b58cf72b6ca770dc7ca5f91f135444502/tools/aapt/AaptAssets.cpp#61
ignoreAssetsPattern '!.svn:!.git:!.ds_store:!*.scc:.*:!CVS:!thumbs.db:!picasa.ini:!*~'
}
ndk {
abiFilters 'armeabi-v7a', 'arm64-v8a', 'x86_64'
}
}
buildTypes {
release {
@@ -26,7 +29,7 @@ android {
abi {
enable true
reset()
include 'armeabi-v7a', 'arm64-v8a', 'x86', 'x86_64'
include 'armeabi-v7a', 'arm64-v8a', 'x86_64'
universalApk true
}
}
@@ -15,6 +15,7 @@ dependencies {
implementation project(':capacitor-screen-orientation')
implementation project(':capacitor-status-bar')
implementation project(':hugotomazi-capacitor-navigation-bar')
implementation project(':capacitor-android-tv-plugin')
implementation project(':capacitor-nodejs')
}
@@ -7,6 +7,7 @@
android:supportsRtl="true"
android:usesCleartextTraffic="true"
android:theme="@style/AppTheme">
<activity
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode"
android:name=".MainActivity"
@@ -17,6 +18,7 @@
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
</intent-filter>
<intent-filter>
@@ -20,5 +20,8 @@ project(':capacitor-status-bar').projectDir = new File('../node_modules/@capacit
include ':hugotomazi-capacitor-navigation-bar'
project(':hugotomazi-capacitor-navigation-bar').projectDir = new File('../node_modules/@hugotomazi/capacitor-navigation-bar/android')
include ':capacitor-android-tv-plugin'
project(':capacitor-android-tv-plugin').projectDir = new File('../node_modules/capacitor-android-tv-plugin/android')
include ':capacitor-nodejs'
project(':capacitor-nodejs').projectDir = new File('../node_modules/capacitor-nodejs/android')
+12 -12
View File
@@ -1,18 +1,18 @@
import type { CapacitorConfig } from '@capacitor/cli';
const config: CapacitorConfig = {
appId: 'us.materialio.app',
appName: 'Materialious',
webDir: 'build',
plugins: {
CapacitorNodeJS: {
nodeDir: 'nodejs-android',
}
},
server: {
hostname: 'www.youtube.com',
androidScheme: 'https'
}
appId: 'us.materialio.app',
appName: 'Materialious',
webDir: 'build',
plugins: {
CapacitorNodeJS: {
nodeDir: 'nodejs-android'
}
},
server: {
hostname: 'www.youtube.com',
androidScheme: 'https'
}
};
export default config;
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "Materialious",
"version": "1.9.8",
"version": "1.9.9",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "Materialious",
"version": "1.9.8",
"version": "1.9.9",
"license": "MIT",
"dependencies": {
"@capacitor-community/electron": "^5.0.0",
+12 -2
View File
@@ -1,12 +1,12 @@
{
"name": "materialious",
"version": "1.9.8",
"version": "1.9.9",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "materialious",
"version": "1.9.8",
"version": "1.9.9",
"hasInstallScript": true,
"dependencies": {
"@capacitor-community/electron": "^5.0.1",
@@ -21,6 +21,7 @@
"@hugotomazi/capacitor-navigation-bar": "^4.0.1",
"beercss": "^3.8.0",
"bgutils-js": "^3.2.0",
"capacitor-android-tv-plugin": "^0.0.2",
"capacitor-nodejs": "https://github.com/hampoelz/capacitor-nodejs/releases/download/v1.0.0-beta.8/capacitor-nodejs.tgz",
"fuse.js": "^7.0.0",
"googlevideo": "^3.0.0",
@@ -4320,6 +4321,15 @@
"license": "CC-BY-4.0",
"peer": true
},
"node_modules/capacitor-android-tv-plugin": {
"version": "0.0.2",
"resolved": "https://registry.npmjs.org/capacitor-android-tv-plugin/-/capacitor-android-tv-plugin-0.0.2.tgz",
"integrity": "sha512-Ww+Nx7lg7cmXk+KX8zTk3G1IMvTO4pa06m6fU9rmuw54T5wOwgz39e9BNjF9kt5Vtio7blSfwPVLHCc4CdCTTg==",
"license": "MIT",
"peerDependencies": {
"@capacitor/core": "^6.0.0"
}
},
"node_modules/capacitor-nodejs": {
"version": "1.0.0-beta.8",
"resolved": "https://github.com/hampoelz/capacitor-nodejs/releases/download/v1.0.0-beta.8/capacitor-nodejs.tgz",
+2 -1
View File
@@ -52,6 +52,7 @@
"@hugotomazi/capacitor-navigation-bar": "^4.0.1",
"beercss": "^3.8.0",
"bgutils-js": "^3.2.0",
"capacitor-android-tv-plugin": "^0.0.2",
"capacitor-nodejs": "https://github.com/hampoelz/capacitor-nodejs/releases/download/v1.0.0-beta.8/capacitor-nodejs.tgz",
"fuse.js": "^7.0.0",
"googlevideo": "^3.0.0",
@@ -70,4 +71,4 @@
"svelte-persisted-store": "^0.12.0",
"youtubei.js": "^14.0.0"
}
}
}
@@ -1,5 +1,6 @@
import { goto } from '$app/navigation';
import { Capacitor } from '@capacitor/core';
import { NodeJS } from 'capacitor-nodejs';
const originalFetch = window.fetch;
const corsProxyUrl: string = 'http://localhost:3000/';
@@ -52,5 +53,7 @@ if (Capacitor.getPlatform() === 'android') {
return originalXhrOpen.apply(this, args);
};
setTimeout(() => goto('/', { replaceState: true }), 100);
NodeJS.whenReady().then(() => {
goto('/', { replaceState: true });
});
}
+7 -2
View File
@@ -59,8 +59,13 @@ export async function fetchErrorHandle(response: Response): Promise<Response> {
return response;
}
export function buildAuthHeaders(): { headers: { Authorization: string } } {
return { headers: { Authorization: `Bearer ${get(authStore)?.token}` } };
export function buildAuthHeaders(): { headers: Record<string, string> } {
const authToken = get(authStore)?.token ?? '';
if (authToken.startsWith('SID=')) {
return { headers: { __sid_auth: authToken } };
} else {
return { headers: { Authorization: `Bearer ${get(authStore)?.token}` } };
}
}
export async function getTrending(fetchOptions?: RequestInit): Promise<Video[]> {
@@ -1,25 +1,30 @@
<script lang="ts">
import { onMount } from 'svelte';
import { AndroidTv } from 'capacitor-android-tv-plugin';
let { children } = $props();
let largeCol = $state('2');
let smallCol = $state('12');
let mediumCol = $state('6');
function checkWidth() {
if (innerWidth <= 1750) {
async function checkWidth() {
if (await AndroidTv.isAndroidTv()) {
mediumCol = '3';
} else if (innerWidth <= 1750) {
largeCol = '4';
} else {
largeCol = '2';
}
}
onMount(() => {
checkWidth();
onMount(async () => {
await checkWidth();
addEventListener('resize', () => checkWidth());
});
</script>
<div class="s12 m6 l{largeCol}">
<div class="s{smallCol} m{mediumCol} l{largeCol}">
{@render children?.()}
</div>
+29 -9
View File
@@ -47,6 +47,7 @@
import { patchYoutubeJs } from '$lib/patches/youtubejs';
import { playbackRates } from '$lib/const';
import { EndTimeElement } from '$lib/shaka-elements/endTime';
import { AndroidTv } from 'capacitor-android-tv-plugin';
interface Props {
data: { video: VideoPlay; content: PhasedDescription; playlistId: string | null };
@@ -67,7 +68,6 @@
let playerPosSet = false;
let originalOrigination: ScreenOrientationResult | undefined;
let watchProgressTimeout: NodeJS.Timeout;
let userWantsFullscreen = false;
let playerElementResizeObserver: ResizeObserver | undefined;
let showVideoRetry = $state(false);
@@ -188,7 +188,11 @@
}
async function androidHandleRotate() {
if (Capacitor.getPlatform() === 'android' && data.video.adaptiveFormats.length > 0) {
if (
Capacitor.getPlatform() === 'android' &&
data.video.adaptiveFormats.length > 0 &&
!(await AndroidTv.isAndroidTv())
) {
const videoFormats = data.video.adaptiveFormats.filter((format) =>
format.type.startsWith('video/')
);
@@ -479,7 +483,6 @@
// Required to stop buttons from being still selected when fullscreening
document.addEventListener('fullscreenchange', async () => {
userWantsFullscreen = document.fullscreenElement !== null;
const buttons = document.querySelectorAll('.shaka-controls-button-panel button');
buttons.forEach((button) => {
// Reset the button's focus and active states
@@ -513,7 +516,7 @@
playerElement.pause();
}
if (!document.fullscreenElement && userWantsFullscreen) {
if (!document.fullscreenElement) {
shakaUi.getControls()?.toggleFullScreen();
}
return false;
@@ -523,7 +526,7 @@
if (!playerElement) return;
playerElement.currentTime = playerElement.currentTime + 10;
if (!document.fullscreenElement && userWantsFullscreen) {
if (!document.fullscreenElement) {
shakaUi.getControls()?.toggleFullScreen();
}
return false;
@@ -534,7 +537,7 @@
playerElement.currentTime = playerElement.currentTime - 10;
if (!document.fullscreenElement && userWantsFullscreen) {
if (!document.fullscreenElement) {
shakaUi.getControls()?.toggleFullScreen();
}
return false;
@@ -557,7 +560,7 @@
}
}
if (!document.fullscreenElement && userWantsFullscreen) {
if (!document.fullscreenElement) {
shakaUi.getControls()?.toggleFullScreen();
}
return false;
@@ -577,9 +580,11 @@
playerElement.playbackRate = playerElement.playbackRate - 0.25;
if (!document.fullscreenElement && userWantsFullscreen) {
if (!document.fullscreenElement) {
shakaUi.getControls()?.toggleFullScreen();
}
return false;
});
Mousetrap.bind('shift+right', () => {
@@ -587,13 +592,28 @@
playerElement.playbackRate = playerElement.playbackRate + 0.25;
if (!document.fullscreenElement && userWantsFullscreen) {
if (!document.fullscreenElement) {
shakaUi.getControls()?.toggleFullScreen();
}
return false;
});
setChapterMarkers();
if (await AndroidTv.isAndroidTv()) {
shakaUi.getControls()?.toggleFullScreen();
Mousetrap.bind('enter', () => {
if (playerElement?.paused) {
playerElement?.play();
} else {
playerElement?.pause();
}
return false;
});
}
try {
await loadVideo();
} catch (error: unknown) {
+17 -3
View File
@@ -9,6 +9,7 @@
interfaceSearchSuggestionsStore,
searchHistoryStore
} from '../store';
import { AndroidTv } from 'capacitor-android-tv-plugin';
const dispatch = createEventDispatcher();
@@ -21,6 +22,8 @@
let showSearchBox = $state(false);
let isAndroidTv = $state(false);
let debounceTimer: NodeJS.Timeout;
function debouncedSearch(event: any) {
if (!searchSuggestions) return;
@@ -88,7 +91,8 @@
showSearchBox = false;
}
onMount(() => {
onMount(async () => {
isAndroidTv = await AndroidTv.isAndroidTv();
Mousetrap.bind(['ctrl+k', 'command+k'], () => {
document.getElementById('search-box')?.focus();
showSearchBox = !showSearchBox;
@@ -105,10 +109,15 @@
await tick();
document.getElementById('search')?.focus();
}}
tabindex="0"
role="presentation"
>
<div class="field prefix fill no-margin search rounded">
<i class="front">search</i>
<div
class="field prefix fill no-margin rounded"
class:search={!isAndroidTv}
class:search-tv={isAndroidTv}
>
<i class="front" tabindex="-1">search</i>
<input
id="search-box"
placeholder={$_('searchPlaceholder')}
@@ -175,6 +184,11 @@
.search {
width: 500px;
}
.search-tv {
width: 600px;
}
.selected {
background-color: var(--surface-variant);
}
@@ -42,7 +42,7 @@
</nav>
<div>
<nav class="tabbed small">
<nav class="tabbed small" tabindex="0" role="region">
<a class:active={activeTab === 'interface'} onclick={() => (activeTab = 'interface')}>
<i>grid_view</i>
<span>{$_('layout.interface')}</span>
@@ -19,6 +19,8 @@
synciousInstanceStore,
synciousStore
} from '../store';
import { goto } from '$app/navigation';
import { AndroidTv } from 'capacitor-android-tv-plugin';
interface Props {
video: VideoBase | Video | Notification | PlaylistPageVideo;
@@ -151,9 +153,19 @@
}
</script>
<div class:sideways-root={sideways}>
<div onfocus={() => {}} id="thumbnail-container" role="region">
<div
class:sideways-root={sideways}
tabindex="0"
role="button"
onclick={async () => {
if (await AndroidTv.isAndroidTv()) {
goto(watchUrl.toString());
}
}}
>
<div id="thumbnail-container">
<a
tabindex="-1"
class="wave thumbnail"
href={watchUrl.toString()}
data-sveltekit-preload-data="off"
@@ -199,6 +211,7 @@
<div class="thumbnail-details video-title">
<div class="video-title">
<a
tabindex="-1"
style="padding-left: 1px;"
class="video-title"
data-sveltekit-preload-data="off"
@@ -208,7 +221,9 @@
</a>
<div>
<a class:author={!sideways} href={`/channel/${video.authorId}`}>{video.author}</a>
<a tabindex="-1" class:author={!sideways} href={`/channel/${video.authorId}`}
>{video.author}</a
>
{#if !('publishedText' in video) && 'viewCountText' in video}
<span style="margin-top: 1em;">
{video.viewCountText}
+5 -2
View File
@@ -11,13 +11,16 @@
}
main.root {
padding-left: 7em !important;
padding-top: 5em !important;
padding-top: 6em !important;
padding-bottom: 5em !important;
max-height: 100vh;
overflow-y: auto;
}
main.root-not-tv {
padding-left: 7em !important;
}
.row {
padding: 1em !important;
}
@@ -4,6 +4,8 @@
"copyUrl": "Copy URL",
"loadMore": "Load more",
"views": "views",
"login": "Login",
"invidiousLogin": "Please log in with your Invidious account",
"invalidInstance": "Please verify the URL. If it's correct, the instance may be down or may not support third-party clients.",
"invidiousBlockWarning": "Invidious is currently being blocked by Google. If videos aren't loading for this instance, please use this instance on Materialious on {android} or {desktop} to get around this with local video fallback.",
"videos": "videos",
+1
View File
@@ -5,6 +5,7 @@ import type Peer from 'peerjs';
import { get } from 'svelte/store';
import { instanceStore, interfaceAllowInsecureRequests } from './store';
import type { Channel, HashTag, Playlist, PlaylistPageVideo, Video, VideoBase } from './api/model';
import { Capacitor } from '@capacitor/core';
export function truncate(value: string, maxLength: number = 50): string {
return value.length > maxLength ? `${value.substring(0, maxLength)}...` : value;
+123 -48
View File
@@ -20,7 +20,6 @@
instanceStore,
interfaceAmoledTheme,
interfaceDefaultPage,
showWarningStore,
syncPartyPeerStore,
themeColorStore
} from '$lib/store';
@@ -35,11 +34,15 @@
import { _ } from '$lib/i18n';
import { get } from 'svelte/store';
import { pwaInfo } from 'virtual:pwa-info';
import { AndroidTv } from 'capacitor-android-tv-plugin';
import Mousetrap from 'mousetrap';
let { children } = $props();
let mobileSearchShow = $state(false);
let isAndroidTv = $state(false);
let isLoggedIn = $state(false);
authStore.subscribe((value) => {
isLoggedIn = value !== null;
@@ -94,21 +97,67 @@
});
async function login() {
const path = new URL(`${get(instanceStore)}/authorize_token`);
const searchParams = new URLSearchParams({
scopes: ':feed,:subscriptions*,:playlists*,:history*,:notifications*'
});
if (Capacitor.getPlatform() === 'android') {
searchParams.set('callback_url', 'materialious-auth://');
path.search = searchParams.toString();
await Browser.open({ url: path.toString() });
if (!isAndroidTv) {
const path = new URL(`${get(instanceStore)}/authorize_token`);
const searchParams = new URLSearchParams({
scopes: ':feed,:subscriptions*,:playlists*,:history*,:notifications*'
});
if (Capacitor.getPlatform() === 'android') {
searchParams.set('callback_url', 'materialious-auth://');
path.search = searchParams.toString();
await Browser.open({ url: path.toString() });
} else {
searchParams.set('callback_url', `${location.origin}/auth`);
path.search = searchParams.toString();
document.location.href = path.toString();
}
} else {
searchParams.set('callback_url', `${location.origin}/auth`);
path.search = searchParams.toString();
document.location.href = path.toString();
await ui('#tv-login');
document.getElementById('username')?.focus();
}
}
let loginError: boolean = $state(false);
let rawUsername: string = $state('');
let rawPassword: string = $state('');
async function usernamePasswordLogin(event: Event) {
event.preventDefault();
loginError = false;
const body = new FormData();
body.append('email', rawUsername);
body.append('password', rawPassword);
body.append('action', 'signin');
const response = await fetch(`${$instanceStore}/login?type=invidious`, {
method: 'POST',
body: body,
headers: {
__redirect: 'manual',
__custom_return: 'json-headers'
}
});
if (response.ok) {
const headers = await response.json();
if ('set-cookie' in headers) {
const sid = (headers['set-cookie'][0].split(';') as string[]).find((cookie) =>
cookie.startsWith('SID=')
);
if (sid) {
console.log(sid);
authStore.set({ username: rawUsername, token: sid });
await ui('#tv-login');
goto('/', { replaceState: true });
return;
}
}
}
loginError = true;
}
function logout() {
authStore.set(null);
goto('/');
@@ -122,6 +171,8 @@
onMount(async () => {
ui();
isAndroidTv = await AndroidTv.isAndroidTv();
document.addEventListener('click', linkClickOverwrite);
loadSettingsFromEnv();
@@ -172,20 +223,22 @@
{@html webManifestLink}
</svelte:head>
<nav class="left m l small">
<header></header>
{#each getPages() as navPage}
{#if !navPage.requiresAuth || isLoggedIn}
<a
href={navPage.href}
class:active={$page.url.href.endsWith(navPage.href)}
data-sveltekit-preload-data="off"
><i>{navPage.icon}</i>
<div>{navPage.name}</div>
</a>
{/if}
{/each}
</nav>
{#if !isAndroidTv}
<nav class="left m l small">
<header></header>
{#each getPages() as navPage}
{#if !navPage.requiresAuth || isLoggedIn}
<a
href={navPage.href}
class:active={$page.url.href.endsWith(navPage.href)}
data-sveltekit-preload-data="off"
><i>{navPage.icon}</i>
<div>{navPage.name}</div>
</a>
{/if}
{/each}
</nav>
{/if}
<nav class="top">
{#if !mobileSearchShow}
@@ -299,28 +352,23 @@
{/if}
</dialog>
<main class="responsive max root">
{#if Capacitor.getPlatform() === 'web' && $showWarningStore}
<dialog class="active small" style="height: fit-content;">
<h5>Warning</h5>
<div>
<p>
{@html $_('invidiousBlockWarning', {
values: {
android:
'<a href="https://github.com/Materialious/Materialious?tab=readme-ov-file#android" target="_blank" class="link" rel="noopener noreferrer">Android</a>',
desktop:
'<a href="https://github.com/Materialious/Materialious?tab=readme-ov-file#desktop-windowsmacoslinux" target="_blank" class="link" rel="noopener noreferrer">Desktop</a>'
}
})}
</p>
</div>
<nav class="right-align no-space">
<button class="transparent link" onclick={() => showWarningStore.set(false)}
>Don't show again</button
>
</nav>
</dialog>
<main class="responsive max root" tabindex="0" role="region" class:main.root-not-tv={!isAndroidTv}>
{#if isAndroidTv}
<div class="tabs">
{#each getPages() as navPage}
{#if !navPage.requiresAuth || isLoggedIn}
<a
href={navPage.href}
class:active={$page.url.href.endsWith(navPage.href)}
class="active"
data-sveltekit-preload-data="off"
>
<i>{navPage.icon}</i>
<span>{navPage.name}</span>
</a>
{/if}
{/each}
</div>
{/if}
{#if $navigating}
<PageLoading />
@@ -331,6 +379,33 @@
<SyncParty />
</main>
<dialog class="modal" id="tv-login">
<h5>{$_('loginRequired')}</h5>
<div>{$_('invidiousLogin')}</div>
<form onsubmit={usernamePasswordLogin}>
<div class="field label border" class:invalid={loginError}>
<input id="username" bind:value={rawUsername} name="username" type="text" />
<label for="username">Username</label>
</div>
<div class="field label border" class:invalid={loginError}>
<input bind:value={rawPassword} name="password" type="password" />
<label for="password">Password</label>
</div>
<nav class="right-align no-space">
<button
class="transparent link"
type="button"
onclick={async () => {
await ui('#tv-login');
}}>{$_('cancel')}</button
>
<button class="transparent link" type="submit">{$_('login')}</button>
</nav>
</form>
</dialog>
<style>
nav.left a {
font-size: 0.8em;
+40 -3
View File
@@ -26,7 +26,10 @@ const CORS_HEADERS = [
'X-Goog-FieldMask',
'Range',
'Referer',
'Cookie'
'Cookie',
'__redirect',
'__custom_return',
'__sid_auth'
].join(', ');
const CORS_ORIGIN = 'https://www.youtube.com';
@@ -47,6 +50,11 @@ function proxyRequest(clientReq, clientRes, parsedUrl, bodyChunks = [], redirect
return clientRes.end('Too many redirects');
}
const redirectAllowed = clientReq.headers.__redirect !== 'manual';
delete clientReq.headers.__redirect;
const returnHeadersAsJson = clientReq.headers.__custom_return === 'json-headers';
delete clientReq.headers.__custom_return;
const isHttps = parsedUrl.protocol === 'https:';
const httpClient = isHttps ? https : http;
@@ -62,6 +70,11 @@ function proxyRequest(clientReq, clientRes, parsedUrl, bodyChunks = [], redirect
rejectUnauthorized: rejectUnauthorized
};
if (clientReq.headers.__sid_auth) {
proxyOptions.headers.cookie = clientReq.headers.__sid_auth;
delete proxyOptions.headers.__sid_auth;
}
// Remove headers that may cause issues or be auto-managed
for (const key of [
'referer',
@@ -75,13 +88,37 @@ function proxyRequest(clientReq, clientRes, parsedUrl, bodyChunks = [], redirect
}
const proxyReq = httpClient.request(parsedUrl, proxyOptions, (proxyRes) => {
// Handle redirects
if (proxyRes.statusCode >= 300 && proxyRes.statusCode < 400 && proxyRes.headers.location) {
// Handle redirects normally if redirectAllowed
if (
proxyRes.statusCode >= 300 &&
proxyRes.statusCode < 400 &&
proxyRes.headers.location &&
redirectAllowed
) {
proxyRes.resume(); // discard response data
const newUrl = new URL(proxyRes.headers.location, parsedUrl);
return proxyRequest(clientReq, clientRes, newUrl, bodyChunks, redirectCount + 1);
}
if (returnHeadersAsJson) {
const headersDict = {};
for (const [key, value] of Object.entries(proxyRes.headers)) {
headersDict[key] = value;
}
const body = JSON.stringify(headersDict);
clientRes.writeHead(200, {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': CORS_ORIGIN,
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
'Access-Control-Allow-Headers': CORS_HEADERS,
'Access-Control-Allow-Credentials': 'true',
'Content-Length': Buffer.byteLength(body)
});
return clientRes.end(body);
}
clientRes.writeHead(proxyRes.statusCode, {
...proxyRes.headers,
'Access-Control-Allow-Origin': CORS_ORIGIN,