@@ -16,7 +16,7 @@
|
||||
|
||||
|
||||
# Features
|
||||
- Support for using without a Invidious instance (With experimental Youtubejs backend).
|
||||
- Use in local mode without relying on a Invidious instance.
|
||||
- [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.
|
||||
|
||||
@@ -7,8 +7,8 @@ android {
|
||||
applicationId "us.materialio.app"
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 206
|
||||
versionName "1.14.2"
|
||||
versionCode 207
|
||||
versionName "1.14.3"
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
aaptOptions {
|
||||
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
<p>Materialious is a client for Invidious and YouTube using material design. It lets you browse and watch videos, manage playlists and visit channels.</p>
|
||||
<p>Features:</p>
|
||||
<ul>
|
||||
<li>Use in local mode without relying on a Invidious instance</li>
|
||||
<li>Invidious companion support</li>
|
||||
<li>Invidious API extended integration</li>
|
||||
<li>YouTube.js fallback if Invidious fails loading videos</li>
|
||||
@@ -52,7 +53,7 @@
|
||||
</screenshot>
|
||||
<screenshot>
|
||||
<caption>Modifying settings</caption>
|
||||
<image>https://raw.githubusercontent.com/Materialious/Materialious/4baa7b897a46d4e71aaca7d322e4f7dafc870a33/previews/setting-preview.png</image>
|
||||
<image>https://raw.githubusercontent.com/Materialious/Materialious/43e6eb78edae030ffd372af5b9a8dcf92564be46/previews/setting-preview.png</image>
|
||||
</screenshot>
|
||||
<screenshot>
|
||||
<caption>Browsing a channel</caption>
|
||||
@@ -69,8 +70,12 @@
|
||||
<content_attribute id="social-contacts">intense</content_attribute>
|
||||
</content_rating>
|
||||
<releases>
|
||||
|
||||
<release version="1.14.2" date="2026-2-12">
|
||||
|
||||
|
||||
<release version="1.14.3" date="2026-2-13">
|
||||
<url>https://github.com/Materialious/Materialious/releases/tag/1.14.3</url>
|
||||
</release>
|
||||
<release version="1.14.2" date="2026-2-12">
|
||||
<url>https://github.com/Materialious/Materialious/releases/tag/1.14.2</url>
|
||||
</release>
|
||||
<release version="1.14.1" date="2026-2-12">
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "Materialious",
|
||||
"version": "1.14.2",
|
||||
"version": "1.14.3",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "Materialious",
|
||||
"version": "1.14.2",
|
||||
"version": "1.14.3",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@capacitor-community/electron": "^5.0.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "Materialious",
|
||||
"version": "1.14.2",
|
||||
"version": "1.14.3",
|
||||
"description": "Modern material design for YouTube and Invidious.",
|
||||
"author": {
|
||||
"name": "Ward Pearce",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "materialious",
|
||||
"version": "1.14.2",
|
||||
"version": "1.14.3",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "npm run patch:github && vite dev",
|
||||
|
||||
@@ -274,8 +274,11 @@ export async function notificationsMarkAsRead(fetchOptions: RequestInit = {}) {
|
||||
await fetchErrorHandle(await fetch(path, { ...buildAuthHeaders(), ...fetchOptions }));
|
||||
}
|
||||
|
||||
export async function getSubscriptions(fetchOptions: RequestInit = {}): Promise<Subscription[]> {
|
||||
if (isYTBackend()) {
|
||||
export async function getSubscriptions(
|
||||
fetchOptions: RequestInit = {},
|
||||
bypassYTBackend: boolean = false
|
||||
): Promise<Subscription[]> {
|
||||
if (isYTBackend() && !bypassYTBackend) {
|
||||
return getSubscriptionsYTjs();
|
||||
}
|
||||
const resp = await fetchErrorHandle(
|
||||
@@ -304,8 +307,12 @@ export async function amSubscribed(
|
||||
}
|
||||
}
|
||||
|
||||
export async function postSubscribe(authorId: string, fetchOptions: RequestInit = {}) {
|
||||
if (isYTBackend()) {
|
||||
export async function postSubscribe(
|
||||
authorId: string,
|
||||
fetchOptions: RequestInit = {},
|
||||
bypassYTBackend: boolean = false
|
||||
) {
|
||||
if (isYTBackend() && !bypassYTBackend) {
|
||||
return postSubscribeYTjs(authorId);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { localDb } from '$lib/dexie';
|
||||
import { logoutStores } from '$lib/misc';
|
||||
import { clearCaches } from '$lib/misc';
|
||||
import { cleanNumber } from '$lib/numbers';
|
||||
import { relativeTimestamp } from '$lib/time';
|
||||
import { get } from 'svelte/store';
|
||||
@@ -24,13 +24,19 @@ export async function amSubscribedYTjs(authorId: string): Promise<boolean> {
|
||||
return (await localDb.channelSubscriptions.where('channelId').equals(authorId).count()) > 0;
|
||||
}
|
||||
|
||||
export async function postSubscribeYTjs(authorId: string) {
|
||||
const channel = await getChannelYTjs(authorId);
|
||||
export async function postSubscribeYTjs(
|
||||
authorId: string,
|
||||
authorName: string | undefined = undefined
|
||||
) {
|
||||
if (!authorName) {
|
||||
const channel = await getChannelYTjs(authorId);
|
||||
authorName = channel.author;
|
||||
}
|
||||
|
||||
try {
|
||||
await localDb.channelSubscriptions.add({
|
||||
channelId: authorId,
|
||||
channelName: channel.author,
|
||||
channelName: authorName,
|
||||
lastRSSFetch: new Date(0)
|
||||
});
|
||||
} catch {
|
||||
@@ -44,7 +50,7 @@ export async function postSubscribeYTjs(authorId: string) {
|
||||
export async function deleteUnsubscribeYTjs(authorId: string) {
|
||||
await localDb.channelSubscriptions.where('channelId').equals(authorId).delete();
|
||||
await localDb.subscriptionFeed.where('authorId').equals(authorId).delete();
|
||||
logoutStores();
|
||||
clearCaches();
|
||||
}
|
||||
|
||||
export async function parseChannelRSS(channelId: string): Promise<void> {
|
||||
@@ -147,16 +153,17 @@ export async function getFeedYTjs(): Promise<Feed> {
|
||||
await Promise.all(toUpdatePromises);
|
||||
}
|
||||
|
||||
const videos = await localDb.subscriptionFeed.toArray();
|
||||
let videos = await localDb.subscriptionFeed.toArray();
|
||||
videos.sort((a, b) => b.published - a.published);
|
||||
|
||||
const cullAfter = get(engineCullYTStore);
|
||||
|
||||
if (videos.length > cullAfter) {
|
||||
const videosToDelete = videos.slice(cullAfter);
|
||||
const videoIdsToDelete = videosToDelete.map((video) => video.videoId);
|
||||
await localDb.subscriptionFeed.where('videoId').anyOf(videoIdsToDelete).delete();
|
||||
|
||||
await localDb.subscriptionFeed.where('id').anyOf(videoIdsToDelete).delete();
|
||||
// Don't display culled videos.
|
||||
videos = videos.slice(0, cullAfter);
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,9 +1,18 @@
|
||||
<script lang="ts">
|
||||
import { isYTBackend } from '$lib/misc';
|
||||
import { _ } from '$lib/i18n';
|
||||
import { engineCooldownYTStore, engineCullYTStore, engineFallbacksStore } from '$lib/store';
|
||||
import {
|
||||
authStore,
|
||||
engineCooldownYTStore,
|
||||
engineCullYTStore,
|
||||
engineFallbacksStore,
|
||||
instanceStore
|
||||
} from '$lib/store';
|
||||
import { useEngineFallback, type EngineFallback } from '$lib/api/misc';
|
||||
import { get } from 'svelte/store';
|
||||
import { getSubscriptions, postSubscribe } from '$lib/api';
|
||||
import { postSubscribeYTjs } from '$lib/api/youtubejs/subscriptions';
|
||||
import { addToast } from '../Toast.svelte';
|
||||
|
||||
const engineFallbacks: EngineFallback[] = [
|
||||
'Channel',
|
||||
@@ -33,6 +42,52 @@
|
||||
|
||||
engineFallbacksStore.set(enabledFallbacks);
|
||||
}
|
||||
|
||||
async function importInvidiousSubs() {
|
||||
const importedSubs = await getSubscriptions({}, true);
|
||||
|
||||
addToast({
|
||||
data: {
|
||||
text: $_('layout.backendEngine.importingToMaterialious')
|
||||
}
|
||||
});
|
||||
|
||||
const subPromises: Promise<void>[] = [];
|
||||
importedSubs.forEach((sub) => {
|
||||
subPromises.push(postSubscribeYTjs(sub.authorId, sub.author));
|
||||
});
|
||||
|
||||
await Promise.all(subPromises);
|
||||
|
||||
addToast({
|
||||
data: {
|
||||
text: $_('layout.backendEngine.importingToMaterialiousFinished')
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function exportMaterialiousSubs() {
|
||||
const subs = await getSubscriptions();
|
||||
|
||||
addToast({
|
||||
data: {
|
||||
text: $_('layout.backendEngine.exportingToInvidious')
|
||||
}
|
||||
});
|
||||
|
||||
const subPromises: Promise<void>[] = [];
|
||||
subs.forEach((sub) => {
|
||||
subPromises.push(postSubscribe(sub.authorId, {}, true));
|
||||
});
|
||||
|
||||
await Promise.all(subPromises);
|
||||
|
||||
addToast({
|
||||
data: {
|
||||
text: $_('layout.backendEngine.exportingToInvidiousFinished')
|
||||
}
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<article class="error-container">
|
||||
@@ -40,7 +95,7 @@
|
||||
</article>
|
||||
|
||||
{#if isYTBackend()}
|
||||
<h4>Feed</h4>
|
||||
<h6>Feed</h6>
|
||||
<div class="field label prefix border">
|
||||
<i>view_stream</i>
|
||||
<input
|
||||
@@ -65,8 +120,25 @@
|
||||
/>
|
||||
<label for="cull">{$_('layout.backendEngine.cooldown')}</label>
|
||||
</div>
|
||||
|
||||
{#if $authStore && $instanceStore}
|
||||
<h6>{$_('layout.backendEngine.importExport')}</h6>
|
||||
<div class="space"></div>
|
||||
|
||||
<button onclick={exportMaterialiousSubs} class="surface-container-highest">
|
||||
<i>upload</i>
|
||||
<div>{$_('layout.backendEngine.exportToInvidious')}</div>
|
||||
</button>
|
||||
|
||||
<div class="space"></div>
|
||||
|
||||
<button onclick={importInvidiousSubs} class="surface-container-highest">
|
||||
<i>download</i>
|
||||
<div>{$_('layout.backendEngine.importToMaterialious')}</div>
|
||||
</button>
|
||||
{/if}
|
||||
{:else}
|
||||
<h4>{$_('layout.backendEngine.fallbacks')}</h4>
|
||||
<h6>{$_('layout.backendEngine.fallbacks')}</h6>
|
||||
{#each engineFallbacks as fallback (fallback)}
|
||||
<nav class="no-padding">
|
||||
<div class="max">
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
import type { RgbaColor, HsvaColor, Colord } from 'colord';
|
||||
import { _ } from '$lib/i18n';
|
||||
import { get } from 'svelte/store';
|
||||
import { ensureNoTrailingSlash, isMobile, logoutStores } from '../../misc';
|
||||
import { ensureNoTrailingSlash, isMobile, clearCaches } from '../../misc';
|
||||
import { getPages, type Pages } from '../../navPages';
|
||||
import ColorPicker from 'svelte-awesome-color-picker';
|
||||
import {
|
||||
@@ -75,8 +75,8 @@
|
||||
}
|
||||
}
|
||||
|
||||
function clearPreviousInstance() {
|
||||
logoutStores();
|
||||
function reloadState() {
|
||||
clearCaches();
|
||||
ui('#dialog-settings');
|
||||
goto(resolve('/', {}), { replaceState: true });
|
||||
location.reload();
|
||||
@@ -113,13 +113,14 @@
|
||||
|
||||
instanceStore.set(instance);
|
||||
|
||||
clearPreviousInstance();
|
||||
reloadState();
|
||||
authStore.set(null);
|
||||
}
|
||||
|
||||
async function setBackend(event: Event) {
|
||||
const select = event.target as HTMLSelectElement;
|
||||
backendInUseStore.set(select.value as 'ivg' | 'yt');
|
||||
clearPreviousInstance();
|
||||
reloadState();
|
||||
}
|
||||
|
||||
function allowInsecureRequests() {
|
||||
|
||||
@@ -129,14 +129,13 @@
|
||||
|
||||
<div class="grid">
|
||||
<div class="s12 m4 l4 m l">
|
||||
<div class="padding">
|
||||
<div class="categories padding">
|
||||
{#each tabs as tab, index (tab)}
|
||||
<a
|
||||
<button
|
||||
class:active={isActive(tab.id)}
|
||||
class:surface-container-lowest={isActive(tab.id)}
|
||||
class:surface-container-highest={!isActive(tab.id)}
|
||||
aria-selected={isActive(tab.id)}
|
||||
class="button"
|
||||
id={`tab-${tab.id}`}
|
||||
aria-controls={`panel-${tab.id}`}
|
||||
tabindex={isActive(tab.id) ? 0 : -1}
|
||||
@@ -145,25 +144,26 @@
|
||||
>
|
||||
<i>{tab.icon}</i>
|
||||
<span>{tab.label}</span>
|
||||
</a>
|
||||
</button>
|
||||
<div class="space"></div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
<div class="s12 m8 l8">
|
||||
{#each tabs as tab (tab)}
|
||||
<div
|
||||
class="page padding"
|
||||
id={`panel-${tab.id}`}
|
||||
aria-labelledby={`tab-${tab.id}`}
|
||||
hidden={!isActive(tab.id)}
|
||||
inert={!$isAndroidTvStore && !isActive(tab.id)}
|
||||
aria-hidden={!isActive(tab.id)}
|
||||
class:active={isActive(tab.id)}
|
||||
>
|
||||
<tab.component />
|
||||
</div>
|
||||
{/each}
|
||||
<div class="settings padding">
|
||||
{#each tabs as tab (tab)}
|
||||
<div
|
||||
id={`panel-${tab.id}`}
|
||||
aria-labelledby={`tab-${tab.id}`}
|
||||
hidden={!isActive(tab.id)}
|
||||
inert={!$isAndroidTvStore && !isActive(tab.id)}
|
||||
aria-hidden={!isActive(tab.id)}
|
||||
class:active={isActive(tab.id)}
|
||||
>
|
||||
<tab.component />
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -172,12 +172,20 @@
|
||||
<style>
|
||||
#dialog-settings {
|
||||
width: 800px;
|
||||
height: 800px;
|
||||
}
|
||||
|
||||
.page {
|
||||
max-height: 800px;
|
||||
overflow: scroll;
|
||||
.categories {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
height: fit-content;
|
||||
}
|
||||
|
||||
.settings {
|
||||
height: 800px;
|
||||
overflow-y: auto;
|
||||
padding-right: 10px;
|
||||
}
|
||||
|
||||
.grid {
|
||||
@@ -195,7 +203,7 @@
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
a.button {
|
||||
.categories button {
|
||||
width: 100%;
|
||||
box-sizing: border-box !important;
|
||||
}
|
||||
@@ -206,8 +214,8 @@
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.page {
|
||||
max-height: 100%;
|
||||
.settings {
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -179,7 +179,14 @@
|
||||
"warning": "Advanced settings! For experienced users only. Do not change unless you know what you’re doing.",
|
||||
"cull": "Max feed items",
|
||||
"cooldown": "Re-fetch channel hour cooldown",
|
||||
"fallbacks": "Fallbacks"
|
||||
"fallbacks": "Fallbacks",
|
||||
"importExport": "Import/Export subscriptions",
|
||||
"exportToInvidious": "Export to Invidious from Materialious",
|
||||
"importToMaterialious": "Import from Invidious to Materialious",
|
||||
"importingToMaterialious": "Importing to Materialious began",
|
||||
"importingToMaterialiousFinished": "Importing to Materialious finished",
|
||||
"exportingToInvidious": "Exporting to Invidious began",
|
||||
"exportingToInvidiousFinished": "Exporting to Invidious finished"
|
||||
},
|
||||
"player": {
|
||||
"title": "Player",
|
||||
|
||||
@@ -233,8 +233,7 @@ export function isYTBackend(): boolean {
|
||||
return get(backendInUseStore) === 'yt' && Capacitor.isNativePlatform();
|
||||
}
|
||||
|
||||
export function logoutStores() {
|
||||
authStore.set(null);
|
||||
export function clearCaches() {
|
||||
feedCacheStore.set({});
|
||||
searchCacheStore.set({});
|
||||
playlistCacheStore.set({});
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { _ } from '$lib/i18n';
|
||||
import { get } from 'svelte/store';
|
||||
import { isYTBackend } from './misc';
|
||||
import { authStore } from './store';
|
||||
|
||||
export type Pages = { icon: string; href: string; name: string; requiresAuth: boolean }[];
|
||||
|
||||
// Must be a func do to how i18n is loaded
|
||||
export function getPages(): Pages {
|
||||
return [
|
||||
let pages: Pages = [
|
||||
{
|
||||
icon: 'home',
|
||||
href: !isYTBackend() ? '/' : '/subscriptions',
|
||||
@@ -26,4 +27,10 @@ export function getPages(): Pages {
|
||||
requiresAuth: true
|
||||
}
|
||||
];
|
||||
|
||||
pages = pages.filter((page) => {
|
||||
return !page.requiresAuth || (get(authStore) && !isYTBackend());
|
||||
});
|
||||
|
||||
return pages;
|
||||
}
|
||||
|
||||
@@ -315,7 +315,7 @@ export const deArrowThumbnailInstanceStore = persist(
|
||||
);
|
||||
|
||||
export const engineCullYTStore: Writable<number> = persist(
|
||||
writable(1000),
|
||||
writable(500),
|
||||
createStorage(),
|
||||
'engineCullYT'
|
||||
);
|
||||
|
||||
@@ -39,7 +39,7 @@
|
||||
import { _ } from '$lib/i18n';
|
||||
import { get } from 'svelte/store';
|
||||
import { pwaInfo } from 'virtual:pwa-info';
|
||||
import { isYTBackend, logoutStores, truncate } from '$lib/misc';
|
||||
import { isYTBackend, clearCaches, truncate } from '$lib/misc';
|
||||
import Author from '$lib/components/Author.svelte';
|
||||
import Toast from '$lib/components/Toast.svelte';
|
||||
|
||||
@@ -179,7 +179,8 @@
|
||||
}
|
||||
|
||||
function logout() {
|
||||
logoutStores();
|
||||
authStore.set(null);
|
||||
clearCaches();
|
||||
goto(resolve('/', {}));
|
||||
}
|
||||
|
||||
@@ -236,7 +237,7 @@
|
||||
setTheme();
|
||||
setAmoledTheme();
|
||||
|
||||
if (isLoggedIn) {
|
||||
if (isLoggedIn && !isYTBackend()) {
|
||||
loadNotifications().catch(() => authStore.set(null));
|
||||
}
|
||||
|
||||
@@ -269,12 +270,10 @@
|
||||
</a>
|
||||
{/if}
|
||||
{#each getPages() as navPage (navPage)}
|
||||
{#if !navPage.requiresAuth || isLoggedIn}
|
||||
<a href={resolve(navPage.href, {})} class:active={$page.url.href.endsWith(navPage.href)}
|
||||
><i>{navPage.icon}</i>
|
||||
<div>{navPage.name}</div>
|
||||
</a>
|
||||
{/if}
|
||||
<a href={resolve(navPage.href, {})} class:active={$page.url.href.endsWith(navPage.href)}
|
||||
><i>{navPage.icon}</i>
|
||||
<div>{navPage.name}</div>
|
||||
</a>
|
||||
{/each}
|
||||
{#if $isAndroidTvStore}
|
||||
<div class="divider"></div>
|
||||
@@ -345,7 +344,7 @@
|
||||
<i class:primary-text={$syncPartyPeerStore}>group</i>
|
||||
<div class="tooltip bottom">{$_('layout.syncParty')}</div>
|
||||
</button>
|
||||
{#if isLoggedIn}
|
||||
{#if isLoggedIn && !isYTBackend()}
|
||||
<button
|
||||
class="circle large transparent"
|
||||
onclick={() => {
|
||||
@@ -387,16 +386,14 @@
|
||||
|
||||
<nav class="bottom s">
|
||||
{#each getPages() as navPage (navPage)}
|
||||
{#if !navPage.requiresAuth || isLoggedIn}
|
||||
<a
|
||||
class="round"
|
||||
href={resolve(navPage.href, {})}
|
||||
class:active={$page.url.href.endsWith(navPage.href)}
|
||||
data-sveltekit-preload-data="off"
|
||||
><i>{navPage.icon}</i>
|
||||
<span style="font-size: .8em;">{navPage.name}</span>
|
||||
</a>
|
||||
{/if}
|
||||
<a
|
||||
class="round"
|
||||
href={resolve(navPage.href, {})}
|
||||
class:active={$page.url.href.endsWith(navPage.href)}
|
||||
data-sveltekit-preload-data="off"
|
||||
><i>{navPage.icon}</i>
|
||||
<span style="font-size: .8em;">{navPage.name}</span>
|
||||
</a>
|
||||
{/each}
|
||||
</nav>
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
+1
-1
@@ -3,7 +3,7 @@ import os
|
||||
import re
|
||||
from datetime import datetime
|
||||
|
||||
LATEST_VERSION = "1.14.2"
|
||||
LATEST_VERSION = "1.14.3"
|
||||
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