@@ -19,20 +19,11 @@ jobs:
|
||||
matrix:
|
||||
node-version: [latest]
|
||||
runtime:
|
||||
- linux-x64
|
||||
- linux-arm64
|
||||
- win-x64
|
||||
- osx-x64
|
||||
|
||||
include:
|
||||
- runtime: linux-x64
|
||||
os: ubuntu-latest
|
||||
- runtime: linux-arm64
|
||||
os: ubuntu-latest
|
||||
- runtime: osx-x64
|
||||
os: macOS-latest
|
||||
- runtime: win-x64
|
||||
os: windows-latest
|
||||
|
||||
runs-on: ${{ matrix.os }}
|
||||
|
||||
|
||||
@@ -1,59 +1,50 @@
|
||||
name: Check desktop
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: ["main"]
|
||||
pull_request:
|
||||
branches: ["main"]
|
||||
|
||||
workflow_dispatch:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
desktop-check:
|
||||
strategy:
|
||||
matrix:
|
||||
node-version: [latest]
|
||||
runtime:
|
||||
- linux-x64
|
||||
- linux-arm64
|
||||
- win-x64
|
||||
- osx-x64
|
||||
desktop-check:
|
||||
strategy:
|
||||
matrix:
|
||||
node-version: [latest]
|
||||
runtime:
|
||||
- osx-x64
|
||||
|
||||
include:
|
||||
- runtime: linux-x64
|
||||
os: ubuntu-latest
|
||||
- runtime: linux-arm64
|
||||
os: ubuntu-latest
|
||||
- runtime: osx-x64
|
||||
os: macOS-latest
|
||||
- runtime: win-x64
|
||||
os: windows-latest
|
||||
include:
|
||||
- runtime: osx-x64
|
||||
os: macOS-latest
|
||||
|
||||
runs-on: ${{ matrix.os }}
|
||||
runs-on: ${{ matrix.os }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
- name: Use Node.js ${{ matrix.node-version }}
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: ${{ matrix.node-version }}
|
||||
- name: Use Node.js ${{ matrix.node-version }}
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: ${{ matrix.node-version }}
|
||||
|
||||
- name: Install modules
|
||||
run: cd ./materialious && npm install
|
||||
- name: Install modules
|
||||
run: cd ./materialious && npm install
|
||||
|
||||
- name: Build package
|
||||
run: cd ./materialious && npm run build
|
||||
- name: Build package
|
||||
run: cd ./materialious && npm run build
|
||||
|
||||
- name: Sync electron package
|
||||
run: cd ./materialious && npx cap sync @capacitor-community/electron
|
||||
- name: Sync electron package
|
||||
run: cd ./materialious && npx cap sync @capacitor-community/electron
|
||||
|
||||
- name: Patch capacitor plugin
|
||||
run: cd ./materialious/electron && python patch_capacitor_plugin.py
|
||||
- name: Patch capacitor plugin
|
||||
run: cd ./materialious/electron && python patch_capacitor_plugin.py
|
||||
|
||||
- name: Install electron modules
|
||||
run: cd ./materialious/electron && npm install
|
||||
- name: Install electron modules
|
||||
run: cd ./materialious/electron && npm install
|
||||
|
||||
- name: Build desktop packages
|
||||
run: cd ./materialious/electron && npm run electron:make-no-publish
|
||||
- name: Build desktop packages
|
||||
run: cd ./materialious/electron && npm run electron:make-no-publish
|
||||
|
||||
@@ -7,8 +7,8 @@ android {
|
||||
applicationId "us.materialio.app"
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 159
|
||||
versionName "1.10.18"
|
||||
versionCode 160
|
||||
versionName "1.11.0"
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
aaptOptions {
|
||||
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
||||
|
||||
@@ -69,7 +69,11 @@
|
||||
<releases>
|
||||
|
||||
|
||||
<release version="1.10.18" date="2025-11-02">
|
||||
|
||||
<release version="1.11.0" date="2025-11-04">
|
||||
<url>https://github.com/Materialious/Materialious/releases/tag/1.11.0</url>
|
||||
</release>
|
||||
<release version="1.10.18" date="2025-11-02">
|
||||
<url>https://github.com/Materialious/Materialious/releases/tag/1.10.18</url>
|
||||
</release>
|
||||
<release version="1.10.17" date="2025-10-16">
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "Materialious",
|
||||
"version": "1.10.18",
|
||||
"version": "1.11.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "Materialious",
|
||||
"version": "1.10.18",
|
||||
"version": "1.11.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@capacitor-community/electron": "^5.0.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "Materialious",
|
||||
"version": "1.10.18",
|
||||
"version": "1.11.0",
|
||||
"description": "Modern material design for Invidious.",
|
||||
"author": {
|
||||
"name": "Ward Pearce",
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
setupCapacitorElectronPlugins
|
||||
} from '@capacitor-community/electron';
|
||||
import { USER_AGENT } from 'bgutils-js';
|
||||
import path from 'node:path';
|
||||
import chokidar from 'chokidar';
|
||||
import type { MenuItemConstructorOptions } from 'electron';
|
||||
import {
|
||||
@@ -219,6 +220,34 @@ export class ElectronCapacitorApp {
|
||||
CapElectronEventEmitter.emit('CAPELECTRON_DeeplinkListenerInitialized', '');
|
||||
}, 400);
|
||||
});
|
||||
|
||||
// remove so we can register each time as we run the app.
|
||||
app.removeAsDefaultProtocolClient('materialious');
|
||||
|
||||
// If we are running a non-packaged version of the app && on windows
|
||||
if (process.env.NODE_ENV === 'development' && process.platform === 'win32') {
|
||||
// Set the path of electron.exe and your app.
|
||||
// These two additional parameters are only available on windows.
|
||||
app.setAsDefaultProtocolClient('materialious', process.execPath, [
|
||||
path.resolve(process.argv[1])
|
||||
]);
|
||||
} else {
|
||||
app.setAsDefaultProtocolClient('materialious');
|
||||
}
|
||||
|
||||
const gotTheLock = app.requestSingleInstanceLock();
|
||||
|
||||
if (!gotTheLock) {
|
||||
app.quit();
|
||||
} else {
|
||||
app.on('second-instance', (event, commandLine, workingDirectory) => {
|
||||
// Someone tried to run a second instance, we should focus our window.
|
||||
if (this.MainWindow) {
|
||||
if (this.MainWindow.isMinimized()) this.MainWindow.restore();
|
||||
this.MainWindow.focus();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Generated
+9
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "materialious",
|
||||
"version": "1.10.18",
|
||||
"version": "1.11.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "materialious",
|
||||
"version": "1.10.18",
|
||||
"version": "1.11.0",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"@capacitor-community/electron": "^5.0.1",
|
||||
@@ -20,6 +20,7 @@
|
||||
"beercss": "^3.12.11",
|
||||
"bgutils-js": "^3.2.0",
|
||||
"capacitor-nodejs": "https://github.com/hampoelz/capacitor-nodejs/releases/download/v1.0.0-beta.9/capacitor-nodejs.tgz",
|
||||
"dayjs": "^1.11.19",
|
||||
"fuse.js": "^7.0.0",
|
||||
"googlevideo": "^4.0.4",
|
||||
"he": "^1.2.0",
|
||||
@@ -5235,6 +5236,12 @@
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/dayjs": {
|
||||
"version": "1.11.19",
|
||||
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.19.tgz",
|
||||
"integrity": "sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/debug": {
|
||||
"version": "4.3.4",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "materialious",
|
||||
"version": "1.10.18",
|
||||
"version": "1.11.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
@@ -53,6 +53,7 @@
|
||||
"beercss": "^3.12.11",
|
||||
"bgutils-js": "^3.2.0",
|
||||
"capacitor-nodejs": "https://github.com/hampoelz/capacitor-nodejs/releases/download/v1.0.0-beta.9/capacitor-nodejs.tgz",
|
||||
"dayjs": "^1.11.19",
|
||||
"fuse.js": "^7.0.0",
|
||||
"googlevideo": "^4.0.4",
|
||||
"he": "^1.2.0",
|
||||
|
||||
@@ -179,13 +179,18 @@ export async function getHashtag(tag: string, page: number = 0): Promise<{ resul
|
||||
return await resp.json();
|
||||
}
|
||||
|
||||
export interface SearchOptions {
|
||||
sort_by?: 'relevance' | 'rating' | 'upload_date' | 'view_count';
|
||||
type?: 'video' | 'playlist' | 'channel' | 'all';
|
||||
duration?: 'short' | 'medium' | 'long';
|
||||
date?: 'hour' | 'today' | 'week' | 'month' | 'year';
|
||||
features?: string;
|
||||
page?: string;
|
||||
}
|
||||
|
||||
export async function getSearch(
|
||||
search: string,
|
||||
options: {
|
||||
sort_by?: 'relevance' | 'rating' | 'upload_date' | 'view_count';
|
||||
type?: 'video' | 'playlist' | 'channel' | 'all';
|
||||
page?: string;
|
||||
},
|
||||
options: SearchOptions,
|
||||
fetchOptions?: RequestInit
|
||||
): Promise<(Channel | Video | Playlist | HashTag)[]> {
|
||||
if (typeof options.sort_by === 'undefined') {
|
||||
@@ -193,7 +198,7 @@ export async function getSearch(
|
||||
}
|
||||
|
||||
if (typeof options.type === 'undefined') {
|
||||
options.type = 'video';
|
||||
options.type = 'all';
|
||||
}
|
||||
|
||||
if (typeof options.page === 'undefined') {
|
||||
|
||||
@@ -38,7 +38,7 @@ export interface Video extends VideoBase {
|
||||
descriptionHtml: string;
|
||||
published: number;
|
||||
publishedText: string;
|
||||
premiereTimestamp: number;
|
||||
premiereTimestamp?: number;
|
||||
liveNow: boolean;
|
||||
premium: boolean;
|
||||
isUpcoming: boolean;
|
||||
|
||||
@@ -221,6 +221,18 @@
|
||||
</script>
|
||||
|
||||
<div class={classes} class:android-container={$isAndroidTvStore}>
|
||||
{#if items.length === 0}
|
||||
<div class="space"></div>
|
||||
<div class="center-align">
|
||||
<svg width="200" height="200" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"
|
||||
><path
|
||||
style="fill: var(--secondary);"
|
||||
d="M11 0h2v4h-2zM5.001.586L3.587 2L6.5 4.914L7.915 3.5zM1 13.734L5.42 6h13.16L23 13.734V22H1zM6.58 8l-2.857 5h5.178l.193.75a3.002 3.002 0 0 0 5.812 0l.193-.75h5.178L17.42 8zM18.999.586L16.085 3.5l1.414 1.414L20.413 2z"
|
||||
/></svg
|
||||
>
|
||||
<h1>{$_('noResult')}</h1>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="grid" bind:this={gridElement}>
|
||||
{#each items as item, index}
|
||||
<ContentColumn>
|
||||
@@ -251,7 +263,9 @@
|
||||
}}
|
||||
>
|
||||
{#if item.type === 'video' || item.type === 'shortVideo' || item.type === 'stream'}
|
||||
<Thumbnail video={item} {playlistId} />
|
||||
{#key item.videoId}
|
||||
<Thumbnail video={item} {playlistId} />
|
||||
{/key}
|
||||
{#if $authStore && decodeURIComponent($authStore.username) === playlistAuthor && 'indexId' in item}
|
||||
<div class="right-align" style="margin: 1em .5em;">
|
||||
<button
|
||||
|
||||
@@ -47,7 +47,7 @@
|
||||
} from '../store';
|
||||
import { getDynamicTheme, setStatusBarColor } from '../theme';
|
||||
import { patchYoutubeJs } from '$lib/patches/youtubejs';
|
||||
import { playbackRates } from '$lib/player';
|
||||
import { goToNextVideo, goToPreviousVideo, playbackRates } from '$lib/player';
|
||||
import { EndTimeElement } from '$lib/shaka-elements/endTime';
|
||||
import { loadEntirePlaylist } from '$lib/playlist';
|
||||
import { goto } from '$app/navigation';
|
||||
@@ -602,6 +602,16 @@
|
||||
playerElement.pause();
|
||||
playerElement.currentTime = 0;
|
||||
});
|
||||
|
||||
if (data.playlistId) {
|
||||
navigator.mediaSession.setActionHandler('previoustrack', () => {
|
||||
goToPreviousVideo(data.playlistId);
|
||||
});
|
||||
|
||||
navigator.mediaSession.setActionHandler('nexttrack', async () => {
|
||||
await goToNextVideo(data.video, data.playlistId);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Mousetrap.bind('space', () => {
|
||||
@@ -690,59 +700,7 @@
|
||||
});
|
||||
|
||||
playerElement?.addEventListener('ended', async () => {
|
||||
if (!data.playlistId) {
|
||||
if ($playerAutoplayNextByDefaultStore) {
|
||||
goto(
|
||||
`/${$isAndroidTvStore ? 'tv' : 'watch'}/${data.video.recommendedVideos[0].videoId}`,
|
||||
{ replaceState: $isAndroidTvStore }
|
||||
);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const playlist = await loadEntirePlaylist(data.playlistId);
|
||||
const playlistVideoIds = playlist.videos.map((value) => {
|
||||
return value.videoId;
|
||||
});
|
||||
|
||||
let goToVideo: PlaylistPageVideo | undefined;
|
||||
|
||||
const shufflePlaylist = $playlistSettingsStore[data.playlistId]?.shuffle ?? false;
|
||||
const loopPlaylist = $playlistSettingsStore[data.playlistId]?.loop ?? false;
|
||||
|
||||
if (shufflePlaylist) {
|
||||
goToVideo = unsafeRandomItem(playlist.videos);
|
||||
} else {
|
||||
const currentVideoIndex = playlistVideoIds.indexOf(data.video.videoId);
|
||||
const newIndex = currentVideoIndex + 1;
|
||||
if (currentVideoIndex !== -1 && newIndex < playlistVideoIds.length) {
|
||||
goToVideo = playlist.videos[newIndex];
|
||||
} else if (loopPlaylist) {
|
||||
// Loop playlist on end
|
||||
goToVideo = playlist.videos[0];
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof goToVideo !== 'undefined') {
|
||||
if ($syncPartyConnectionsStore) {
|
||||
$syncPartyConnectionsStore.forEach((conn) => {
|
||||
if (typeof goToVideo === 'undefined') return;
|
||||
|
||||
conn.send({
|
||||
events: [
|
||||
{ type: 'change-video', videoId: goToVideo.videoId },
|
||||
{ type: 'playlist', playlistId: data.playlistId }
|
||||
]
|
||||
} as PlayerEvents);
|
||||
});
|
||||
}
|
||||
|
||||
goto(
|
||||
`/${$isAndroidTvStore ? 'tv' : 'watch'}/${goToVideo.videoId}?playlist=${data.playlistId}`,
|
||||
{ replaceState: $isAndroidTvStore }
|
||||
);
|
||||
}
|
||||
await goToNextVideo(data.video, data.playlistId);
|
||||
});
|
||||
|
||||
try {
|
||||
@@ -863,7 +821,7 @@
|
||||
</div>
|
||||
|
||||
{#if showVideoRetry}
|
||||
<article class="fallback">
|
||||
<article class="video-placeholder">
|
||||
{#if $playerYouTubeJsFallback}
|
||||
<p>{$_('player.youtubeJsLoading')}</p>
|
||||
<progress class="circle large"></progress>
|
||||
@@ -930,13 +888,4 @@
|
||||
.hide {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.fallback {
|
||||
height: 30vh;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -10,6 +10,8 @@
|
||||
isAndroidTvStore,
|
||||
searchHistoryStore
|
||||
} from '../store';
|
||||
import { isVideoID } from '$lib/misc';
|
||||
import { resolve } from '$app/paths';
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
@@ -37,22 +39,30 @@
|
||||
}
|
||||
|
||||
function handleSubmit(event: Event | undefined = undefined) {
|
||||
if (event) event.preventDefault();
|
||||
event?.preventDefault();
|
||||
|
||||
if (search.trim() === '') return;
|
||||
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;
|
||||
}
|
||||
|
||||
selectedSuggestionIndex = -1;
|
||||
goto(`/search/${encodeURIComponent(search)}`);
|
||||
goto(`/search/${encodeURIComponent(searchTrimed)}`);
|
||||
|
||||
suggestionsForSearch = [];
|
||||
showSearchBox = false;
|
||||
|
||||
if ($interfaceSearchHistoryEnabled && !$searchHistoryStore.includes(search)) {
|
||||
if ($interfaceSearchHistoryEnabled && !$searchHistoryStore.includes(searchTrimed)) {
|
||||
let pastHistory = $searchHistoryStore;
|
||||
if (pastHistory.length > 15) {
|
||||
pastHistory.pop();
|
||||
}
|
||||
searchHistoryStore.set([search, ...pastHistory]);
|
||||
searchHistoryStore.set([searchTrimed, ...pastHistory]);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -20,9 +20,7 @@
|
||||
synciousInstanceStore,
|
||||
synciousStore
|
||||
} from '../store';
|
||||
import { goto } from '$app/navigation';
|
||||
import { queueSyncious } from '$lib/api/apiExtended';
|
||||
import { page } from '$app/state';
|
||||
|
||||
interface Props {
|
||||
video: VideoBase | Video | Notification | PlaylistPageVideo;
|
||||
|
||||
@@ -51,6 +51,15 @@ dialog {
|
||||
padding: 1em !important;
|
||||
}
|
||||
|
||||
.video-placeholder {
|
||||
height: 30vh;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 1000px) {
|
||||
menu.mobile {
|
||||
position: fixed !important;
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import i18next, { type InitOptions } from 'i18next';
|
||||
import { writable, type Writable } from 'svelte/store';
|
||||
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
const defaultLocale = 'en';
|
||||
|
||||
export const locale: Writable<string> = writable(defaultLocale);
|
||||
@@ -40,6 +42,13 @@ function getUserLocale(): string {
|
||||
return defaultLocale;
|
||||
}
|
||||
|
||||
async function loadDayjsLocale(localeName: string) {
|
||||
try {
|
||||
const { default: locale } = await import(`dayjs/locale/${localeName}`);
|
||||
dayjs.locale(locale);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
export async function initI18n(selectedLocale: string = getUserLocale()): Promise<void> {
|
||||
const langToLoad = resources[selectedLocale] ? selectedLocale : defaultLocale;
|
||||
const translations = await resources[langToLoad]();
|
||||
@@ -63,8 +72,11 @@ export async function initI18n(selectedLocale: string = getUserLocale()): Promis
|
||||
locale.set(langToLoad);
|
||||
_.set(i18next.t.bind(i18next));
|
||||
|
||||
window.addEventListener('languagechange', () => {
|
||||
await loadDayjsLocale(selectedLocale);
|
||||
|
||||
window.addEventListener('languagechange', async () => {
|
||||
const newLang = getUserLocale();
|
||||
initI18n(newLang);
|
||||
await loadDayjsLocale(newLang);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"enabled": "Enabled",
|
||||
"disabled": "Disabled",
|
||||
"popularPageDisabled": "Popular page has been disabled by Admins",
|
||||
"premium": "Premium YouTube content can't be watched on Materialious.",
|
||||
"isUpcoming": "Video is not in a watchable state yet.",
|
||||
"copyUrl": "Copy URL",
|
||||
"loadMore": "Load more",
|
||||
"views": "views",
|
||||
@@ -15,6 +15,14 @@
|
||||
"videos": "videos",
|
||||
"cancel": "Cancel",
|
||||
"create": "Create",
|
||||
"filters": {
|
||||
"filters": "Filters",
|
||||
"type": "Type",
|
||||
"uploadDate": "Upload date",
|
||||
"duration": "Duration",
|
||||
"sortBy": "Short By",
|
||||
"features": "Features"
|
||||
},
|
||||
"title": "Title",
|
||||
"searchPlaceholder": "Search",
|
||||
"delete": "Delete",
|
||||
@@ -60,7 +68,9 @@
|
||||
"private": "Private",
|
||||
"playVideos": "Play videos",
|
||||
"shuffleVideos": "Play videos shuffled",
|
||||
"loopPlaylist": "Loop playlist"
|
||||
"loopPlaylist": "Loop playlist",
|
||||
"next": "Next",
|
||||
"previous": "Previous"
|
||||
},
|
||||
"thumbnail": {
|
||||
"live": "LIVE",
|
||||
@@ -82,6 +92,7 @@
|
||||
"pauseTimer": "Pause timer",
|
||||
"pauseVideoIn": "Pause video in ",
|
||||
"preferredQuality": "Preferred quality",
|
||||
"premiere": "Video will be available",
|
||||
"chapters": "Chapters",
|
||||
"share": {
|
||||
"title": "Share",
|
||||
|
||||
@@ -14,6 +14,11 @@ import type {
|
||||
VideoBase
|
||||
} from './api/model';
|
||||
|
||||
export function isVideoID(videoId: string): boolean {
|
||||
var regExp = /^[a-zA-Z0-9_-]{11}$/;
|
||||
return regExp.test(videoId);
|
||||
}
|
||||
|
||||
export function truncate(value: string, maxLength: number = 50): string {
|
||||
return value.length > maxLength ? `${value.substring(0, maxLength)}...` : value;
|
||||
}
|
||||
@@ -23,13 +28,6 @@ export function decodeHtmlCharCodes(str: string): string {
|
||||
return decode(str);
|
||||
}
|
||||
|
||||
export function proxyVideoUrl(source: string): string {
|
||||
const rawSrc = new URL(source);
|
||||
rawSrc.host = get(instanceStore).replace('http://', '').replace('https://', '');
|
||||
|
||||
return rawSrc.toString();
|
||||
}
|
||||
|
||||
export function unsafeRandomItem(array: any[]): any {
|
||||
return array[Math.floor(Math.random() * array.length)];
|
||||
}
|
||||
|
||||
@@ -153,8 +153,6 @@ export async function patchYoutubeJs(videoId: string): Promise<VideoPlay> {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(recommended);
|
||||
|
||||
recommendedVideos.push({
|
||||
videoThumbnails: (recommended?.content_image?.image as Thumbnail[]) || [],
|
||||
videoId: recommended?.content_id || '',
|
||||
@@ -233,7 +231,9 @@ export async function patchYoutubeJs(videoId: string): Promise<VideoPlay> {
|
||||
descriptionHtml: video.secondary_info.description?.toHTML() || descString,
|
||||
published: 0,
|
||||
publishedText: video.primary_info.published?.toString() || '',
|
||||
premiereTimestamp: 0,
|
||||
premiereTimestamp: video.basic_info.is_upcoming
|
||||
? Math.floor(video.basic_info.start_timestamp?.getTime() || 0 / 1000)
|
||||
: undefined,
|
||||
hlsUrl: video.streaming_data?.hls_manifest_url || undefined,
|
||||
liveNow: video.basic_info.is_live || false,
|
||||
// @ts-expect-error Type does have offer_id
|
||||
|
||||
@@ -1,3 +1,17 @@
|
||||
import { get } from 'svelte/store';
|
||||
import type { PlaylistPageVideo, VideoPlay } from './api/model';
|
||||
import {
|
||||
isAndroidTvStore,
|
||||
playerAutoplayNextByDefaultStore,
|
||||
playerPlaylistHistory,
|
||||
playlistSettingsStore,
|
||||
syncPartyConnectionsStore
|
||||
} from './store';
|
||||
import { goto } from '$app/navigation';
|
||||
import { loadEntirePlaylist } from './playlist';
|
||||
import { unsafeRandomItem } from './misc';
|
||||
import { resolve } from '$app/paths';
|
||||
|
||||
export interface PlayerEvent {
|
||||
type: 'pause' | 'seek' | 'change-video' | 'play' | 'playlist' | 'goto';
|
||||
path?: string;
|
||||
@@ -11,3 +25,69 @@ export interface PlayerEvents {
|
||||
}
|
||||
|
||||
export const playbackRates = [0.25, 0.5, 0.75, 1, 1.25, 1.5, 1.75, 2, 2.25, 2.5, 2.75, 3];
|
||||
|
||||
export function goToPreviousVideo(playlistId: string | null) {
|
||||
const previousVideos = get(playerPlaylistHistory);
|
||||
if (previousVideos.length > 1) {
|
||||
goto(
|
||||
resolve('/watch/[videoId]?playlist=[playlistId]', {
|
||||
videoId: previousVideos[1],
|
||||
playlistId: playlistId ?? ''
|
||||
})
|
||||
);
|
||||
delete previousVideos[1];
|
||||
}
|
||||
}
|
||||
|
||||
export async function goToNextVideo(video: VideoPlay, playlistId: string | null) {
|
||||
const isAndroidTv = get(isAndroidTvStore);
|
||||
|
||||
if (!playlistId) {
|
||||
if (get(playerAutoplayNextByDefaultStore)) {
|
||||
goto(`/${isAndroidTv ? 'tv' : 'watch'}/${video.recommendedVideos[0].videoId}`, {
|
||||
replaceState: isAndroidTv
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const playlist = await loadEntirePlaylist(playlistId);
|
||||
const playlistVideoIds = playlist.videos.map((value) => {
|
||||
return value.videoId;
|
||||
});
|
||||
|
||||
let goToVideo: PlaylistPageVideo | undefined;
|
||||
|
||||
const shufflePlaylist = get(playlistSettingsStore)[playlistId]?.shuffle ?? false;
|
||||
const loopPlaylist = get(playlistSettingsStore)[playlistId]?.loop ?? false;
|
||||
|
||||
if (shufflePlaylist) {
|
||||
goToVideo = unsafeRandomItem(playlist.videos);
|
||||
} else {
|
||||
const currentVideoIndex = playlistVideoIds.indexOf(video.videoId);
|
||||
const newIndex = currentVideoIndex + 1;
|
||||
if (currentVideoIndex !== -1 && newIndex < playlistVideoIds.length) {
|
||||
goToVideo = playlist.videos[newIndex];
|
||||
} else if (loopPlaylist) {
|
||||
// Loop playlist on end
|
||||
goToVideo = playlist.videos[0];
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof goToVideo !== 'undefined') {
|
||||
get(syncPartyConnectionsStore)?.forEach((conn) => {
|
||||
if (typeof goToVideo === 'undefined') return;
|
||||
|
||||
conn.send({
|
||||
events: [
|
||||
{ type: 'change-video', videoId: goToVideo.videoId },
|
||||
{ type: 'playlist', playlistId: playlistId }
|
||||
]
|
||||
} as PlayerEvents);
|
||||
});
|
||||
|
||||
goto(`/${isAndroidTv ? 'tv' : 'watch'}/${goToVideo.videoId}?playlist=${playlistId}`, {
|
||||
replaceState: isAndroidTv
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,6 +58,8 @@ export const playerCCByDefault = persisted('CCByDefault', false);
|
||||
export const playerDefaultPlaybackSpeed: Writable<number> = persisted('defaultPlaybackSpeed', 1);
|
||||
export const playerStatisticsByDefault = persisted('playerStatistics', false);
|
||||
|
||||
export const playerPlaylistHistory: Writable<string[]> = writable([]);
|
||||
|
||||
export const returnYtDislikesStore = persisted('returnYtDislikes', false);
|
||||
export const returnYTDislikesInstanceStore: Writable<string | null | undefined> = persisted(
|
||||
'returnYTDislikesInstance',
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import dayjs from 'dayjs';
|
||||
import utc from 'dayjs/plugin/utc';
|
||||
import duration from 'dayjs/plugin/duration';
|
||||
import relativeTime from 'dayjs/plugin/relativeTime';
|
||||
import advancedFormat from 'dayjs/plugin/advancedFormat';
|
||||
|
||||
dayjs.extend(utc);
|
||||
dayjs.extend(duration);
|
||||
dayjs.extend(relativeTime);
|
||||
dayjs.extend(advancedFormat);
|
||||
|
||||
export function humanFriendlyTimestamp(utcTimestamp: number): string {
|
||||
const now = dayjs();
|
||||
const timestamp = dayjs.utc(utcTimestamp).local();
|
||||
|
||||
const isSameDay = now.isSame(timestamp, 'day');
|
||||
const isSameMonth = now.isSame(timestamp, 'month');
|
||||
const isThisYear = now.isSame(timestamp, 'year');
|
||||
|
||||
const diffMilliseconds = timestamp.diff(now);
|
||||
const diffDuration = dayjs.duration(diffMilliseconds);
|
||||
|
||||
if (isSameDay) {
|
||||
return diffDuration.humanize(true);
|
||||
} else if (isSameMonth) {
|
||||
return timestamp.format('Do @ h:mm A');
|
||||
} else if (isThisYear) {
|
||||
return timestamp.format('MMMM Do @ h:mm A');
|
||||
} else {
|
||||
return timestamp.format('MMMM Do YYYY h:mm A');
|
||||
}
|
||||
}
|
||||
@@ -55,7 +55,7 @@ export function phaseDescription(
|
||||
// Handle youtubejs timestamps
|
||||
if (
|
||||
fallbackPatch === 'youtubejs' &&
|
||||
href.includes(`https://www.youtube.com/watch?v=${videoId}&t=`)
|
||||
href.includes(`https://www.youtube.com/watch?v=${videoId}`)
|
||||
) {
|
||||
const url = new URL(href);
|
||||
const timeParam = url.searchParams.get('t') || '0';
|
||||
|
||||
@@ -30,10 +30,6 @@ export async function getWatchDetails(videoId: string, url: URL) {
|
||||
error(400, get(_)('premium'));
|
||||
}
|
||||
|
||||
if (video.isUpcoming) {
|
||||
error(400, get(_)('isUpcoming'));
|
||||
}
|
||||
|
||||
let personalPlaylists;
|
||||
if (get(authStore)) {
|
||||
postHistory(video.videoId);
|
||||
|
||||
@@ -1,14 +1,23 @@
|
||||
<script lang="ts">
|
||||
import ItemsList from '$lib/components/ItemsList.svelte';
|
||||
import { _ } from '$lib/i18n/index';
|
||||
import { feedCacheStore } from '$lib/store';
|
||||
|
||||
let { data } = $props();
|
||||
</script>
|
||||
|
||||
{#if data.popularDisabled}
|
||||
<nav class="center-align">
|
||||
<p>Popular page has been disabled</p>
|
||||
</nav>
|
||||
<div class="center-align">
|
||||
<div class="space"></div>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="200" height="200" viewBox="0 0 24 24"
|
||||
><path
|
||||
style="fill: var(--secondary);"
|
||||
d="M11.25 21.975Q9.3 21.825 7.612 21t-2.937-2.162q-1.25-1.338-1.963-3.1T2 12q0-2.075.788-3.9t2.137-3.175q1.35-1.35 3.175-2.137T12 2q2.075 0 3.9.788t3.175 2.137q1.35 1.35 2.138 3.175T22 12v.475q-.425-.275-.988-.537t-1.037-.438q-.2-3.15-2.488-5.325T12 4q-1.4 0-2.638.45T7.1 5.7l6.125 6.125q-.475.2-.912.463t-.863.562L5.7 7.1q-.8 1.025-1.25 2.263T4 12q0 2.475 1.338 4.438T8.8 19.325q.45.7 1.125 1.425t1.325 1.225ZM17 20q1.475 0 2.738-.675T21.75 17.5q-.75-1.15-2.013-1.825T17 15q-1.475 0-2.738.675T12.25 17.5q.75 1.15 2.013 1.825T17 20Zm0 2q-2.4 0-4.288-1.263T10 17.5q.825-1.975 2.713-3.238T17 13q2.4 0 4.288 1.263T24 17.5q-.825 1.975-2.713 3.238T17 22Zm0-3q-.625 0-1.063-.438T15.5 17.5q0-.625.438-1.063T17 16q.625 0 1.063.438T18.5 17.5q0 .625-.438 1.063T17 19Z"
|
||||
/></svg
|
||||
>
|
||||
<h1>{$_('disabled')}</h1>
|
||||
<p>{$_('popularPageDisabled')}</p>
|
||||
</div>
|
||||
{:else}
|
||||
<ItemsList items={$feedCacheStore.popular ?? []} />
|
||||
{/if}
|
||||
|
||||
@@ -1,12 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { getSearch } from '$lib/api';
|
||||
import ChannelThumbnail from '$lib/components/ChannelThumbnail.svelte';
|
||||
import ContentColumn from '$lib/components/ContentColumn.svelte';
|
||||
import HashtagThumbnail from '$lib/components/HashtagThumbnail.svelte';
|
||||
import { getSearch, type SearchOptions } from '$lib/api';
|
||||
import PageLoading from '$lib/components/PageLoading.svelte';
|
||||
import PlaylistThumbnail from '$lib/components/PlaylistThumbnail.svelte';
|
||||
import Thumbnail from '$lib/components/Thumbnail.svelte';
|
||||
import { extractUniqueId } from '$lib/misc';
|
||||
import { feedLastItemId, searchCacheStore } from '$lib/store';
|
||||
import { onMount } from 'svelte';
|
||||
import { _ } from '$lib/i18n';
|
||||
@@ -15,9 +9,60 @@
|
||||
|
||||
let { data } = $props();
|
||||
|
||||
let currentType: 'playlist' | 'all' | 'video' | 'channel' = $state('all');
|
||||
let currentPage = 1;
|
||||
|
||||
let filtersOpen = $state(false);
|
||||
|
||||
let searchOptions: SearchOptions = $state({
|
||||
type: 'all',
|
||||
page: currentPage.toString(),
|
||||
sort_by: 'relevance'
|
||||
});
|
||||
|
||||
const filters: {
|
||||
options: string[];
|
||||
title: string;
|
||||
key: 'type' | 'date' | 'duration' | 'sort_by' | 'features';
|
||||
}[] = $state([
|
||||
{
|
||||
options: ['all', 'video', 'playlist', 'channel'],
|
||||
title: $_('filters.type'),
|
||||
key: 'type'
|
||||
},
|
||||
{
|
||||
options: ['hour', 'today', 'week', 'month', 'year'],
|
||||
title: $_('filters.uploadDate'),
|
||||
key: 'date'
|
||||
},
|
||||
{
|
||||
options: ['short', 'medium', 'long'],
|
||||
title: $_('filters.duration'),
|
||||
key: 'duration'
|
||||
},
|
||||
{
|
||||
options: ['relevance', 'rating', 'upload_date', 'view_count'],
|
||||
title: $_('filters.sortBy'),
|
||||
key: 'sort_by'
|
||||
},
|
||||
{
|
||||
options: [
|
||||
'hd',
|
||||
'subtitles',
|
||||
'creative_commons',
|
||||
'3d',
|
||||
'live',
|
||||
'purchased',
|
||||
'4k',
|
||||
'360',
|
||||
'location',
|
||||
'hdr',
|
||||
'vr180'
|
||||
],
|
||||
title: $_('filters.features'),
|
||||
key: 'features'
|
||||
}
|
||||
]);
|
||||
|
||||
onMount(() => {
|
||||
if ($feedLastItemId) {
|
||||
document
|
||||
@@ -28,19 +73,18 @@
|
||||
}
|
||||
});
|
||||
|
||||
async function changeType(type: 'playlist' | 'all' | 'video' | 'channel') {
|
||||
currentType = type;
|
||||
currentPage = 1;
|
||||
data.searchStoreId = type + data.slug;
|
||||
searchCacheStore.set({ [data.searchStoreId]: await getSearch(data.slug, { type: type }) });
|
||||
async function updateSearch() {
|
||||
data.searchStoreId = JSON.stringify(searchOptions) + data.slug;
|
||||
searchCacheStore.set({ [data.searchStoreId]: await getSearch(data.slug, searchOptions) });
|
||||
}
|
||||
|
||||
async function loadMore(event: InfiniteEvent) {
|
||||
currentPage++;
|
||||
const newSearch = await getSearch(data.slug, {
|
||||
page: currentPage.toString(),
|
||||
type: currentType
|
||||
});
|
||||
searchOptions = {
|
||||
...searchOptions,
|
||||
page: currentPage.toString()
|
||||
};
|
||||
const newSearch = await getSearch(data.slug, searchOptions);
|
||||
|
||||
if (newSearch.length === 0) {
|
||||
event.detail.complete();
|
||||
@@ -53,38 +97,58 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<div style="margin-bottom: 1em;">
|
||||
<div class="tabs left-align min scroll">
|
||||
<a class:active={currentType === 'all'} href="#all" onclick={async () => changeType('all')}>
|
||||
<i>home</i>
|
||||
<span>{$_('videoTabs.all')}</span>
|
||||
</a>
|
||||
<a
|
||||
class:active={currentType === 'video'}
|
||||
href="#videos"
|
||||
onclick={async () => changeType('video')}
|
||||
>
|
||||
<i>movie</i>
|
||||
<span>{$_('videoTabs.videos')}</span>
|
||||
</a>
|
||||
<a
|
||||
class:active={currentType === 'playlist'}
|
||||
href="#playlists"
|
||||
onclick={async () => changeType('playlist')}
|
||||
>
|
||||
<i>playlist_add_check</i>
|
||||
<span>{$_('videoTabs.playlists')}</span>
|
||||
</a>
|
||||
<a
|
||||
class:active={currentType === 'channel'}
|
||||
href="#channels"
|
||||
onclick={async () => changeType('channel')}
|
||||
>
|
||||
<i>person</i>
|
||||
<span>{$_('videoTabs.channels')}</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<details open={filtersOpen}>
|
||||
<summary>
|
||||
<nav>
|
||||
<button
|
||||
class="secondary"
|
||||
onclick={() => {
|
||||
filtersOpen = !filtersOpen;
|
||||
}}
|
||||
>
|
||||
<i>filter_alt</i>
|
||||
<span>{$_('filters.filters')}</span>
|
||||
</button>
|
||||
</nav>
|
||||
</summary>
|
||||
<article class="scroll medium">
|
||||
<div class="grid">
|
||||
{#each filters as filter}
|
||||
<div class="s12 m2 l2">
|
||||
<h6>{filter.title}</h6>
|
||||
<ul class="list no-margin no-padding">
|
||||
{#each filter.options as filterOption}
|
||||
<li>
|
||||
<button
|
||||
class="small border"
|
||||
style="text-transform: capitalize;"
|
||||
disabled={filter.key !== 'type' &&
|
||||
!['all', 'video', undefined].includes(searchOptions.type)}
|
||||
class:active={filterOption === searchOptions[filter.key]}
|
||||
onclick={async () => {
|
||||
if (filterOption === searchOptions[filter.key]) {
|
||||
delete searchOptions[filter.key];
|
||||
} else {
|
||||
searchOptions = {
|
||||
...searchOptions,
|
||||
[filter.key]: filterOption
|
||||
};
|
||||
}
|
||||
await updateSearch();
|
||||
}}
|
||||
>
|
||||
<span>{filterOption.replaceAll('_', ' ')}</span>
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</article>
|
||||
</details>
|
||||
|
||||
<div class="space"></div>
|
||||
|
||||
{#if $searchCacheStore[data.searchStoreId]}
|
||||
<ItemsList items={$searchCacheStore[data.searchStoreId]} />
|
||||
|
||||
@@ -13,11 +13,12 @@
|
||||
import { getBestThumbnail } from '$lib/images';
|
||||
import { letterCase } from '$lib/letterCasing';
|
||||
import { cleanNumber, humanizeSeconds, numberWithCommas } from '$lib/numbers';
|
||||
import type { PlayerEvents } from '$lib/player';
|
||||
import { goToNextVideo, goToPreviousVideo, type PlayerEvents } from '$lib/player';
|
||||
import {
|
||||
authStore,
|
||||
interfaceAutoExpandChapters,
|
||||
interfaceAutoExpandComments,
|
||||
playerPlaylistHistory,
|
||||
playerTheatreModeByDefaultStore,
|
||||
playlistCacheStore,
|
||||
playlistSettingsStore,
|
||||
@@ -36,6 +37,9 @@
|
||||
import LikesDislikes from '$lib/components/watch/LikesDislikes.svelte';
|
||||
import Comment from '$lib/components/watch/Comment.svelte';
|
||||
import { expandSummery } from '$lib/misc';
|
||||
import { humanFriendlyTimestamp } from '$lib/time.js';
|
||||
import { getWatchDetails } from '$lib/watch.js';
|
||||
import { page } from '$app/state';
|
||||
|
||||
let { data = $bindable() } = $props();
|
||||
|
||||
@@ -65,6 +69,9 @@
|
||||
|
||||
let playerCurrentTime: number = $state(0);
|
||||
|
||||
let premiereTime = $state('');
|
||||
let premiereUpdateInterval: NodeJS.Timeout;
|
||||
|
||||
$effect(() => {
|
||||
if ($interfaceAutoExpandComments && comments) {
|
||||
expandSummery('comment-section');
|
||||
@@ -213,6 +220,7 @@
|
||||
onMount(async () => {
|
||||
if (data.playlistId) {
|
||||
await goToCurrentPlaylistItem();
|
||||
playerPlaylistHistory.set([data.video.videoId, ...$playerPlaylistHistory]);
|
||||
}
|
||||
|
||||
if ($interfaceAutoExpandChapters) {
|
||||
@@ -231,6 +239,19 @@
|
||||
playerCurrentTime = playerElement.currentTime;
|
||||
});
|
||||
}
|
||||
|
||||
if (data.video.premiereTimestamp) {
|
||||
premiereTime = humanFriendlyTimestamp(data.video.premiereTimestamp);
|
||||
premiereUpdateInterval = setInterval(async () => {
|
||||
data = await getWatchDetails(data.video.videoId, page.url);
|
||||
|
||||
if (data.video.premiereTimestamp) {
|
||||
premiereTime = humanFriendlyTimestamp(data.video.premiereTimestamp);
|
||||
} else {
|
||||
clearInterval(premiereUpdateInterval);
|
||||
}
|
||||
}, 60000);
|
||||
}
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
@@ -240,6 +261,10 @@
|
||||
if (pauseTimeout) {
|
||||
clearTimeout(pauseTimeout);
|
||||
}
|
||||
|
||||
if (premiereUpdateInterval) {
|
||||
clearInterval(premiereUpdateInterval);
|
||||
}
|
||||
});
|
||||
|
||||
async function goToCurrentPlaylistItem() {
|
||||
@@ -319,9 +344,23 @@
|
||||
<div class="grid">
|
||||
<div class={`s12 m12 l${theatreMode ? '12' : '9'}`}>
|
||||
<div style="display: flex;justify-content: center;">
|
||||
{#key data.video.videoId}
|
||||
<Player bind:playerElement bind:segments {data} isSyncing={$syncPartyPeerStore !== null} />
|
||||
{/key}
|
||||
{#if !data.video.premiereTimestamp}
|
||||
{#key data.video.videoId}
|
||||
<Player
|
||||
bind:playerElement
|
||||
bind:segments
|
||||
{data}
|
||||
isSyncing={$syncPartyPeerStore !== null}
|
||||
/>
|
||||
{/key}
|
||||
{:else}
|
||||
<article class="video-placeholder">
|
||||
<p>{$_('player.premiere')}</p>
|
||||
<h6 class="no-margin no-padding">
|
||||
{premiereTime}
|
||||
</h6>
|
||||
</article>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<h5>{letterCase(data.video.title)}</h5>
|
||||
@@ -536,6 +575,21 @@
|
||||
{$_('playlist.shuffleVideos')}
|
||||
</div>
|
||||
</button>
|
||||
<button class="circle fill" onclick={() => goToPreviousVideo(data.playlistId)}>
|
||||
<i>skip_previous</i>
|
||||
<div class="tooltip bottom">
|
||||
{$_('playlist.previous')}
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
class="circle fill"
|
||||
onclick={async () => await goToNextVideo(data.video, data.playlistId)}
|
||||
>
|
||||
<i>skip_next</i>
|
||||
<div class="tooltip bottom">
|
||||
{$_('playlist.next')}
|
||||
</div>
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<div class="space"></div>
|
||||
|
||||
+1
-1
@@ -3,7 +3,7 @@ import os
|
||||
import re
|
||||
from datetime import datetime
|
||||
|
||||
LATEST_VERSION = "1.10.18"
|
||||
LATEST_VERSION = "1.11.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