Added dearrow
This commit is contained in:
@@ -14,6 +14,7 @@
|
||||
# Features
|
||||
- Sponsorblock built-in.
|
||||
- Return YouTube dislikes built-in.
|
||||
- DeArrow built-in (With local processing fallback).
|
||||
- Video progress tracking & resuming.
|
||||
- No ads.
|
||||
- No tracking.
|
||||
@@ -150,6 +151,12 @@ services:
|
||||
|
||||
# URL to Sponsorblock
|
||||
VITE_DEFAULT_SPONSERBLOCK_INSTANCE: "https://sponsor.ajay.app"
|
||||
|
||||
# URL to DeArrow
|
||||
VITE_DEFAULT_DEARROW_INSTANCE: "https://sponsor.ajay.app"
|
||||
|
||||
# URL to DeArrow thumbnail instance
|
||||
VITE_DEFAULT_DEARROW_THUMBNAIL_INSTANCE: "https://dearrow-thumb.ajay.app"
|
||||
```
|
||||
|
||||
# Previews
|
||||
@@ -175,6 +182,7 @@ services:
|
||||
|
||||
# Special thanks to
|
||||
- [Invidious](https://github.com/iv-org)
|
||||
- [Clipious](https://github.com/lamarios/clipious) for inspiration & a good source for learning more about undocumented Invidious routes.
|
||||
- [Vidstack player](https://github.com/vidstack/player)
|
||||
- [Beer CSS](https://github.com/beercss/beercss) (Especially the [YouTube template](https://github.com/beercss/beercss/tree/main/src/youtube) what was used as the base for Materialious.)
|
||||
- Every dependency in [package.json](/materialious/package.json).
|
||||
|
||||
@@ -10,4 +10,6 @@ services:
|
||||
VITE_DEFAULT_RETURNYTDISLIKES_INSTANCE: "https://returnyoutubedislikeapi.com"
|
||||
VITE_DEFAULT_FRONTEND_URL: "https://materialio.us"
|
||||
VITE_DEFAULT_SPONSERBLOCK_INSTANCE: "https://sponsor.ajay.app"
|
||||
VITE_DEFAULT_DEARROW_INSTANCE: "https://sponsor.ajay.app"
|
||||
VITE_DEFAULT_DEARROW_THUMBNAIL_INSTANCE: "https://dearrow-thumb.ajay.app"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { get } from 'svelte/store';
|
||||
import { auth, returnYTDislikesInstance } from '../../store';
|
||||
import type { Channel, ChannelContentPlaylists, ChannelContentVideos, ChannelPage, Comments, Playlist, PlaylistPage, ReturnYTDislikes, SearchSuggestion, Subscription, Video, VideoPlay } from './model';
|
||||
import { auth, deArrowInstance, deArrowThumbnailInstance, returnYTDislikesInstance } from '../../store';
|
||||
import type { Channel, ChannelContentPlaylists, ChannelContentVideos, ChannelPage, Comments, DeArrow, Playlist, PlaylistPage, ReturnYTDislikes, SearchSuggestion, Subscription, Video, VideoPlay } from './model';
|
||||
|
||||
export function buildPath(path: string): string {
|
||||
return `${import.meta.env.VITE_DEFAULT_INVIDIOUS_INSTANCE}/api/v1/${path}`;
|
||||
@@ -221,4 +221,19 @@ export async function addPlaylistVideo(playlistId: string, videoId: string) {
|
||||
}),
|
||||
...headers
|
||||
}));
|
||||
}
|
||||
|
||||
export async function getDeArrow(videoId: string): Promise<DeArrow> {
|
||||
const resp = await fetchErrorHandle(
|
||||
await fetch(`${get(deArrowInstance)}/api/branding?videoID=${videoId}`
|
||||
)
|
||||
);
|
||||
return await resp.json();
|
||||
}
|
||||
|
||||
export async function getThumbnail(videoId: string, time: number): Promise<string> {
|
||||
const resp = await fetchErrorHandle(
|
||||
await fetch(`${get(deArrowThumbnailInstance)}/api/v1/getThumbnail?videoID=${videoId}&time=${time}`)
|
||||
);
|
||||
return URL.createObjectURL(await resp.blob());
|
||||
}
|
||||
@@ -230,4 +230,23 @@ export interface Feed {
|
||||
export interface Subscription {
|
||||
author: string;
|
||||
authorId: string;
|
||||
}
|
||||
|
||||
export interface DeArrow {
|
||||
titles: {
|
||||
title: string;
|
||||
original: boolean;
|
||||
votes: number;
|
||||
locked: boolean;
|
||||
UUID: string;
|
||||
}[];
|
||||
thumbnails: {
|
||||
timestamp?: number;
|
||||
original: boolean;
|
||||
votes: number;
|
||||
locked: boolean;
|
||||
UUID: string;
|
||||
}[];
|
||||
randomTime: number;
|
||||
videoDuration: number;
|
||||
}
|
||||
@@ -17,7 +17,7 @@
|
||||
sponsorBlockUrl
|
||||
} from '../store';
|
||||
import type { PlaylistPageVideo, VideoPlay } from './Api/model';
|
||||
import { videoLength, type PhasedDescription } from './misc';
|
||||
import { proxyVideoUrl, videoLength, type PhasedDescription } from './misc';
|
||||
import { getDynamicTheme } from './theme';
|
||||
|
||||
export let data: { video: VideoPlay; content: PhasedDescription; playlistId: string | null };
|
||||
@@ -138,13 +138,7 @@
|
||||
let formattedSrc;
|
||||
src = data.video.formatStreams.map((format) => {
|
||||
if (proxyVideos) {
|
||||
const rawSrc = new URL(format.url);
|
||||
rawSrc.host = import.meta.env.VITE_DEFAULT_INVIDIOUS_INSTANCE.replace(
|
||||
'http://',
|
||||
''
|
||||
).replace('https://', '');
|
||||
|
||||
formattedSrc = rawSrc.toString();
|
||||
formattedSrc = proxyVideoUrl(format.url);
|
||||
} else {
|
||||
formattedSrc = format.url;
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { get } from 'svelte/store';
|
||||
import { playerSavePlaybackPosition } from '../store';
|
||||
import { deArrowEnabled, playerSavePlaybackPosition } from '../store';
|
||||
import { getDeArrow, getThumbnail, getVideo } from './Api';
|
||||
import type { Notification, PlaylistPageVideo, Video, VideoBase } from './Api/model';
|
||||
import { cleanNumber, truncate, videoLength } from './misc';
|
||||
import { cleanNumber, proxyVideoUrl, truncate, videoLength } from './misc';
|
||||
|
||||
export let video: VideoBase | Video | Notification | PlaylistPageVideo;
|
||||
export let playlistId: string = '';
|
||||
@@ -27,9 +28,72 @@
|
||||
progress = null;
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
onMount(async () => {
|
||||
let imageSrc = video.videoThumbnails[4].url;
|
||||
|
||||
if (get(deArrowEnabled)) {
|
||||
try {
|
||||
const deArrow = await getDeArrow(video.videoId);
|
||||
for (const title of deArrow.titles) {
|
||||
if (title.locked && !title.original) {
|
||||
video.title = title.title.replace('>', '');
|
||||
console.log(video.title);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let locatedThumbnail = false;
|
||||
for (const thumbnail of deArrow.thumbnails) {
|
||||
if (thumbnail.locked || thumbnail.original) {
|
||||
if (typeof thumbnail.timestamp !== 'undefined') {
|
||||
imageSrc = await getThumbnail(video.videoId, thumbnail.timestamp);
|
||||
}
|
||||
locatedThumbnail = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!locatedThumbnail) {
|
||||
// Process thumbnail locally.
|
||||
const canvas = document.getElementById('canvas') as HTMLCanvasElement | null;
|
||||
function generateThumbnail(): Promise<string> {
|
||||
return new Promise<string>(async (resolve, reject) => {
|
||||
if (canvas) {
|
||||
const context = canvas.getContext('2d');
|
||||
const mockVideo = document.createElement('video');
|
||||
const videoContainer = document.getElementById('video-container');
|
||||
videoContainer?.appendChild(mockVideo);
|
||||
|
||||
mockVideo.preload = 'auto';
|
||||
mockVideo.id = 'video';
|
||||
mockVideo.crossOrigin = import.meta.env.VITE_DEFAULT_FRONTEND_URL;
|
||||
mockVideo.src = proxyVideoUrl((await getVideo(video.videoId)).formatStreams[0].url);
|
||||
|
||||
mockVideo.addEventListener('loadeddata', () => {
|
||||
mockVideo.currentTime = mockVideo.duration / 100;
|
||||
|
||||
mockVideo.addEventListener('seeked', () => {
|
||||
if (context) {
|
||||
context.drawImage(mockVideo, 0, 0, 320, 180);
|
||||
resolve(canvas.toDataURL('image/png'));
|
||||
mockVideo.preload = 'none';
|
||||
mockVideo.pause();
|
||||
videoContainer?.removeChild(mockVideo);
|
||||
}
|
||||
});
|
||||
});
|
||||
mockVideo.load();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
imageSrc = await generateThumbnail();
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
img = new Image();
|
||||
img.src = video.videoThumbnails[4].url;
|
||||
img.src = imageSrc;
|
||||
|
||||
img.onload = () => {
|
||||
loaded = true;
|
||||
@@ -53,7 +117,7 @@
|
||||
{:else if loaded}
|
||||
<img
|
||||
class="responsive"
|
||||
style="max-width: 100%;height: 100%;"
|
||||
style="max-width: 100%;min-height: 160px;"
|
||||
src={img.src}
|
||||
alt="Thumbnail for video"
|
||||
/>
|
||||
@@ -97,3 +161,6 @@
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<canvas id="canvas" style="display: none;"></canvas>
|
||||
<div id="video-container" style="display: none;"></div>
|
||||
|
||||
@@ -61,7 +61,7 @@ export function phaseDescription(content: string): PhasedDescription {
|
||||
const timestamp = timestampMatch[3];
|
||||
const title = timestampMatch[4] || '';
|
||||
timestamps.push({
|
||||
time: parseInt(time),
|
||||
time: convertToSeconds(time),
|
||||
title: title,
|
||||
timePretty: timestamp
|
||||
});
|
||||
@@ -77,8 +77,6 @@ export function phaseDescription(content: string): PhasedDescription {
|
||||
return { description: filteredContent, timestamps: timestamps };
|
||||
}
|
||||
|
||||
|
||||
|
||||
function convertToSeconds(time: string): number {
|
||||
const parts = time.split(':').map(part => parseInt(part));
|
||||
let seconds = 0;
|
||||
@@ -93,4 +91,14 @@ function convertToSeconds(time: string): number {
|
||||
seconds = parts[0];
|
||||
}
|
||||
return seconds;
|
||||
}
|
||||
|
||||
export function proxyVideoUrl(source: string): string {
|
||||
const rawSrc = new URL(source);
|
||||
rawSrc.host = import.meta.env.VITE_DEFAULT_INVIDIOUS_INSTANCE.replace(
|
||||
'http://',
|
||||
''
|
||||
).replace('https://', '');
|
||||
|
||||
return rawSrc.toString();
|
||||
}
|
||||
@@ -13,6 +13,9 @@
|
||||
activePage,
|
||||
auth,
|
||||
darkMode,
|
||||
deArrowEnabled,
|
||||
deArrowInstance,
|
||||
deArrowThumbnailInstance,
|
||||
interfaceSearchSuggestions,
|
||||
playerAlwaysLoop,
|
||||
playerAutoPlay,
|
||||
@@ -31,20 +34,6 @@
|
||||
let currentPage: string | null = '';
|
||||
activePage.subscribe((page) => (currentPage = page));
|
||||
|
||||
let autoplay = false;
|
||||
let alwaysLoop = false;
|
||||
let proxyVideos = false;
|
||||
let savePlayerPackPos = false;
|
||||
let dash = false;
|
||||
let listenByDefault = false;
|
||||
|
||||
playerAutoPlay.subscribe((value) => (autoplay = value));
|
||||
playerAlwaysLoop.subscribe((value) => (alwaysLoop = value));
|
||||
playerProxyVideos.subscribe((value) => (proxyVideos = value));
|
||||
playerSavePlaybackPosition.subscribe((value) => (savePlayerPackPos = value));
|
||||
playerDash.subscribe((value) => (dash = value));
|
||||
playerListenByDefault.subscribe((value) => (listenByDefault = value));
|
||||
|
||||
let searchSuggestions = false;
|
||||
interfaceSearchSuggestions.subscribe((value) => (searchSuggestions = value));
|
||||
|
||||
@@ -60,6 +49,8 @@
|
||||
|
||||
let sponsorBlockInstance = get(sponsorBlockUrl);
|
||||
let returnYTInstance = get(returnYTDislikesInstance);
|
||||
let deArrowUrl = get(deArrowInstance);
|
||||
let deArrowThumbnailUrl = get(deArrowThumbnailInstance);
|
||||
|
||||
const pages = [
|
||||
{
|
||||
@@ -494,6 +485,48 @@
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="settings">
|
||||
<h6>DeArrow</h6>
|
||||
|
||||
<form on:submit|preventDefault={() => deArrowInstance.set(deArrowUrl)}>
|
||||
<nav>
|
||||
<div class="field label border">
|
||||
<input bind:value={deArrowUrl} name="dearrow-instance" type="text" />
|
||||
<label for="dearrow-instance">Instance URL</label>
|
||||
</div>
|
||||
<button class="square round">
|
||||
<i>done</i>
|
||||
</button>
|
||||
</nav>
|
||||
</form>
|
||||
|
||||
<form on:submit|preventDefault={() => deArrowThumbnailInstance.set(deArrowThumbnailUrl)}>
|
||||
<nav>
|
||||
<div class="field label border">
|
||||
<input bind:value={deArrowThumbnailUrl} name="dearrow-thumbnail-instance" type="text" />
|
||||
<label for="dearrow-thumbnail-instance">Thumbnail instance URL</label>
|
||||
</div>
|
||||
<button class="square round">
|
||||
<i>done</i>
|
||||
</button>
|
||||
</nav>
|
||||
</form>
|
||||
|
||||
<nav class="no-padding">
|
||||
<div class="max">
|
||||
<p>Enabled</p>
|
||||
</div>
|
||||
<label class="switch">
|
||||
<input
|
||||
bind:checked={$deArrowEnabled}
|
||||
on:click={() => deArrowEnabled.set(!$deArrowEnabled)}
|
||||
type="checkbox"
|
||||
/>
|
||||
<span></span>
|
||||
</label>
|
||||
</nav>
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
<dialog class="right" id="dialog-notifications">
|
||||
|
||||
@@ -22,4 +22,8 @@ export const auth: Writable<null | { username: string, token: string; }> = persi
|
||||
|
||||
export const sponsorBlock = persisted("sponsorBlock", true);
|
||||
export const sponsorBlockUrl = persisted("sponsorBlockUrl", import.meta.env.VITE_DEFAULT_SPONSERBLOCK_INSTANCE);
|
||||
export const sponsorBlockCategories: Writable<string[]> = persisted("sponsorBlockCategories", []);
|
||||
export const sponsorBlockCategories: Writable<string[]> = persisted("sponsorBlockCategories", []);
|
||||
|
||||
export const deArrowInstance = persisted("deArrowInstance", import.meta.env.VITE_DEFAULT_DEARROW_INSTANCE);
|
||||
export const deArrowEnabled = persisted("deArrowEnabled", false);
|
||||
export const deArrowThumbnailInstance = persisted("deArrowThumbnailInstance", import.meta.env.VITE_DEFAULT_DEARROW_THUMBNAIL_INSTANCE);
|
||||
Reference in New Issue
Block a user