diff --git a/README.md b/README.md index bee3f745..5891f842 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,7 @@ - [Invidious companion support.](./docs/DOCKER.md#invidious-companion-support) - [Invidious API extended integration.](https://github.com/Materialious/api-extended) - [YouTube.js](https://github.com/LuanRT/YouTube.js) fallback if Invidious fails loading videos for Desktop & Android. +- Android TV support - Support for disabling certificate validation for homelab users. - Sync your watch progress between Invidious sessions. - Watch sync parties. @@ -203,6 +204,9 @@ Help [translate Materialious via Weblate](https://toolate.othing.xyz/projects/ma ## Mobile +## Android TV +![Preview of subscriptions page on Android TV](./previews/android-tv.png) + ## Player ![Preview of player](./previews/player-preview.png) diff --git a/branding/tv-banner.png b/branding/tv-banner.png new file mode 100644 index 00000000..de293639 Binary files /dev/null and b/branding/tv-banner.png differ diff --git a/materialious/android/app/build.gradle b/materialious/android/app/build.gradle index 3fcd7468..e1080ac3 100644 --- a/materialious/android/app/build.gradle +++ b/materialious/android/app/build.gradle @@ -7,14 +7,17 @@ android { applicationId "us.materialio.app" minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion - versionCode 125 - versionName "1.9.8" + versionCode 126 + versionName "1.9.9" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" aaptOptions { // Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps. // 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 } } diff --git a/materialious/android/app/src/main/AndroidManifest.xml b/materialious/android/app/src/main/AndroidManifest.xml index da6049ea..927d681e 100644 --- a/materialious/android/app/src/main/AndroidManifest.xml +++ b/materialious/android/app/src/main/AndroidManifest.xml @@ -3,10 +3,12 @@ + + diff --git a/materialious/android/app/src/main/java/us/materialio/app/AndroidTv.java b/materialious/android/app/src/main/java/us/materialio/app/AndroidTv.java new file mode 100644 index 00000000..c300de80 --- /dev/null +++ b/materialious/android/app/src/main/java/us/materialio/app/AndroidTv.java @@ -0,0 +1,24 @@ +package us.materialio.app; + +import android.app.UiModeManager; +import android.content.Context; +import android.content.res.Configuration; + +import com.getcapacitor.annotation.CapacitorPlugin; +import com.getcapacitor.PluginMethod; +import com.getcapacitor.PluginCall; +import com.getcapacitor.Plugin; +import com.getcapacitor.JSObject; + +@CapacitorPlugin(name = "AndroidTv") +public class AndroidTv extends Plugin { + @PluginMethod() + public void isAndroidTv(PluginCall call) { + UiModeManager uiModeManager = (UiModeManager) getContext().getSystemService(Context.UI_MODE_SERVICE); + boolean isTelevision = uiModeManager.getCurrentModeType() == Configuration.UI_MODE_TYPE_TELEVISION; + + JSObject ret = new JSObject(); + ret.put("value", isTelevision); + call.resolve(ret); + } +} diff --git a/materialious/android/app/src/main/java/us/materialio/app/MainActivity.java b/materialious/android/app/src/main/java/us/materialio/app/MainActivity.java index 133f8b82..5d099b95 100644 --- a/materialious/android/app/src/main/java/us/materialio/app/MainActivity.java +++ b/materialious/android/app/src/main/java/us/materialio/app/MainActivity.java @@ -9,6 +9,7 @@ public class MainActivity extends BridgeActivity { @Override protected void onCreate(Bundle savedInstanceState) { registerPlugin(ColorTheme.class); + registerPlugin(AndroidTv.class); super.onCreate(savedInstanceState); } diff --git a/materialious/android/app/src/main/res/drawable/banner.png b/materialious/android/app/src/main/res/drawable/banner.png new file mode 100644 index 00000000..de293639 Binary files /dev/null and b/materialious/android/app/src/main/res/drawable/banner.png differ diff --git a/materialious/capacitor.config.ts b/materialious/capacitor.config.ts index 0847dd5c..cfabd961 100644 --- a/materialious/capacitor.config.ts +++ b/materialious/capacitor.config.ts @@ -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; diff --git a/materialious/electron/capacitor.config.ts b/materialious/electron/capacitor.config.ts index 0847dd5c..cfabd961 100644 --- a/materialious/electron/capacitor.config.ts +++ b/materialious/electron/capacitor.config.ts @@ -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; diff --git a/materialious/electron/materialious.metainfo.xml b/materialious/electron/materialious.metainfo.xml index bdd466cb..4a43a84a 100644 --- a/materialious/electron/materialious.metainfo.xml +++ b/materialious/electron/materialious.metainfo.xml @@ -60,7 +60,11 @@ - + + + https://github.com/Materialious/Materialious/releases/tag/1.9.9 + + https://github.com/Materialious/Materialious/releases/tag/1.9.8 diff --git a/materialious/electron/package-lock.json b/materialious/electron/package-lock.json index 7a2ca20f..ffd10f47 100644 --- a/materialious/electron/package-lock.json +++ b/materialious/electron/package-lock.json @@ -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", diff --git a/materialious/electron/package.json b/materialious/electron/package.json index 6d17148a..9312f0a5 100644 --- a/materialious/electron/package.json +++ b/materialious/electron/package.json @@ -1,6 +1,6 @@ { "name": "Materialious", - "version": "1.9.8", + "version": "1.9.9", "description": "Modern material design for Invidious.", "author": { "name": "Ward Pearce", diff --git a/materialious/package-lock.json b/materialious/package-lock.json index c630bb9d..961d6011 100644 --- a/materialious/package-lock.json +++ b/materialious/package-lock.json @@ -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", diff --git a/materialious/package.json b/materialious/package.json index 966d8901..e378977a 100644 --- a/materialious/package.json +++ b/materialious/package.json @@ -1,6 +1,6 @@ { "name": "materialious", - "version": "1.9.8", + "version": "1.9.9", "private": true, "scripts": { "dev": "vite dev", diff --git a/materialious/src/lib/android/http/androidRequests.ts b/materialious/src/lib/android/http/androidRequests.ts index a3fe52ed..48c474d2 100644 --- a/materialious/src/lib/android/http/androidRequests.ts +++ b/materialious/src/lib/android/http/androidRequests.ts @@ -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 }); + }); } diff --git a/materialious/src/lib/android/plugins/androidTv.ts b/materialious/src/lib/android/plugins/androidTv.ts new file mode 100644 index 00000000..e2eac05d --- /dev/null +++ b/materialious/src/lib/android/plugins/androidTv.ts @@ -0,0 +1,18 @@ +import { registerPlugin } from '@capacitor/core'; +import { WebPlugin } from '@capacitor/core'; + +interface AndroidTV { + isAndroidTv: () => Promise; +} + +export class AndroidTvWeb extends WebPlugin implements AndroidTV { + async isAndroidTv(): Promise { + return false; + } +} + +const androidTv = registerPlugin('AndroidTv', { + web: new AndroidTvWeb() +}); + +export default androidTv; diff --git a/materialious/src/lib/android/plugins/colorTheme.ts b/materialious/src/lib/android/plugins/colorTheme.ts index 5c4778f6..b51b3e54 100644 --- a/materialious/src/lib/android/plugins/colorTheme.ts +++ b/materialious/src/lib/android/plugins/colorTheme.ts @@ -1,28 +1,27 @@ -import { registerPlugin } from "@capacitor/core"; +import { registerPlugin } from '@capacitor/core'; export interface ColorTheme { - getColorPalette: () => Promise<{ - primary: number; - onPrimary: number; - secondary: number; - accent: number; - }>; + getColorPalette: () => Promise<{ + primary: number; + onPrimary: number; + secondary: number; + accent: number; + }>; } export function convertToHexColorCode(color: number): string { - // Convert the negative number to a positive unsigned integer - const unsignedColor = color < 0 ? color + 0x100000000 : color; + // Convert the negative number to a positive unsigned integer + const unsignedColor = color < 0 ? color + 0x100000000 : color; - // Extract the RGB components - const r = (unsignedColor >> 16) & 0xff; - const g = (unsignedColor >> 8) & 0xff; - const b = unsignedColor & 0xff; + // Extract the RGB components + const r = (unsignedColor >> 16) & 0xff; + const g = (unsignedColor >> 8) & 0xff; + const b = unsignedColor & 0xff; - // Convert to hex format and pad with zeros if necessary - return `#${((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1).toUpperCase()}`; + // Convert to hex format and pad with zeros if necessary + return `#${((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1).toUpperCase()}`; } - const colorTheme = registerPlugin('ColorTheme'); -export default colorTheme; \ No newline at end of file +export default colorTheme; diff --git a/materialious/src/lib/api/index.ts b/materialious/src/lib/api/index.ts index f1b9911c..39440178 100644 --- a/materialious/src/lib/api/index.ts +++ b/materialious/src/lib/api/index.ts @@ -59,8 +59,13 @@ export async function fetchErrorHandle(response: Response): Promise { return response; } -export function buildAuthHeaders(): { headers: { Authorization: string } } { - return { headers: { Authorization: `Bearer ${get(authStore)?.token}` } }; +export function buildAuthHeaders(): { headers: Record } { + 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 { diff --git a/materialious/src/lib/components/ContentColumn.svelte b/materialious/src/lib/components/ContentColumn.svelte index 9b5de488..929626b8 100644 --- a/materialious/src/lib/components/ContentColumn.svelte +++ b/materialious/src/lib/components/ContentColumn.svelte @@ -1,25 +1,30 @@ -
+
{@render children?.()}
diff --git a/materialious/src/lib/components/Player.svelte b/materialious/src/lib/components/Player.svelte index 4c5b3561..5d4f21db 100644 --- a/materialious/src/lib/components/Player.svelte +++ b/materialious/src/lib/components/Player.svelte @@ -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 '$lib/android/plugins/androidTv'; 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) { diff --git a/materialious/src/lib/components/Search.svelte b/materialious/src/lib/components/Search.svelte index f65690db..8b3e74f3 100644 --- a/materialious/src/lib/components/Search.svelte +++ b/materialious/src/lib/components/Search.svelte @@ -9,6 +9,7 @@ interfaceSearchSuggestionsStore, searchHistoryStore } from '../store'; + import androidTv from '$lib/android/plugins/androidTv'; 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" > -