fixes for extension sources & tweaks
This commit is contained in:
@@ -9,6 +9,7 @@ const viewsRoutes = require('./src/views/views.routes');
|
|||||||
const animeRoutes = require('./src/anime/anime.routes');
|
const animeRoutes = require('./src/anime/anime.routes');
|
||||||
const booksRoutes = require('./src/books/books.routes');
|
const booksRoutes = require('./src/books/books.routes');
|
||||||
const proxyRoutes = require('./src/shared/proxy/proxy.routes');
|
const proxyRoutes = require('./src/shared/proxy/proxy.routes');
|
||||||
|
const extensionsRoutes = require('./src/extensions/extensions.routes');
|
||||||
|
|
||||||
fastify.register(require('@fastify/static'), {
|
fastify.register(require('@fastify/static'), {
|
||||||
root: path.join(__dirname, 'public'),
|
root: path.join(__dirname, 'public'),
|
||||||
@@ -32,6 +33,7 @@ fastify.register(viewsRoutes);
|
|||||||
fastify.register(animeRoutes, { prefix: '/api' });
|
fastify.register(animeRoutes, { prefix: '/api' });
|
||||||
fastify.register(booksRoutes, { prefix: '/api' });
|
fastify.register(booksRoutes, { prefix: '/api' });
|
||||||
fastify.register(proxyRoutes, { prefix: '/api' });
|
fastify.register(proxyRoutes, { prefix: '/api' });
|
||||||
|
fastify.register(extensionsRoutes, { prefix: '/api' });
|
||||||
|
|
||||||
const start = async () => {
|
const start = async () => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import {FastifyReply, FastifyRequest} from 'fastify';
|
import {FastifyReply, FastifyRequest} from 'fastify';
|
||||||
import * as animeService from './anime.service';
|
import * as animeService from './anime.service';
|
||||||
import { getExtension, getExtensionsList } from '../shared/extensions';
|
import {getExtension} from '../shared/extensions';
|
||||||
import {AnimeRequest, SearchRequest, ExtensionNameRequest, WatchStreamRequest, Anime} from '../types';
|
import {Anime, AnimeRequest, SearchRequest, WatchStreamRequest} from '../types';
|
||||||
|
|
||||||
export async function getAnime(req: AnimeRequest, reply: FastifyReply) {
|
export async function getAnime(req: AnimeRequest, reply: FastifyReply) {
|
||||||
try {
|
try {
|
||||||
@@ -18,7 +18,7 @@ export async function getAnime(req: AnimeRequest, reply: FastifyReply) {
|
|||||||
const results = await animeService.searchAnimeInExtension(
|
const results = await animeService.searchAnimeInExtension(
|
||||||
ext,
|
ext,
|
||||||
extensionName,
|
extensionName,
|
||||||
id.replaceAll("-", " ")
|
id.replace(/--/g, '\u0000').replace(/-/g, ' ').replace(new RegExp('\u0000', 'g'), '-').trim()
|
||||||
);
|
);
|
||||||
anime = results[0] || null;
|
anime = results[0] || null;
|
||||||
}
|
}
|
||||||
@@ -29,6 +29,22 @@ export async function getAnime(req: AnimeRequest, reply: FastifyReply) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getAnimeEpisodes(req: AnimeRequest, reply: FastifyReply) {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const extensionName = req.query.ext || 'anilist';
|
||||||
|
const ext = getExtension(extensionName);
|
||||||
|
|
||||||
|
return await animeService.searchChaptersInExtension(
|
||||||
|
ext,
|
||||||
|
extensionName,
|
||||||
|
id.replace(/--/g, '\u0000').replace(/-/g, ' ').replace(new RegExp('\u0000', 'g'), '-').trim()
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
return { error: "Database error" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function getTrending(req: FastifyRequest, reply: FastifyReply) {
|
export async function getTrending(req: FastifyRequest, reply: FastifyReply) {
|
||||||
try {
|
try {
|
||||||
const results = await animeService.getTrendingAnime();
|
const results = await animeService.getTrendingAnime();
|
||||||
@@ -64,25 +80,6 @@ export async function search(req: SearchRequest, reply: FastifyReply) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getExtensions(req: FastifyRequest, reply: FastifyReply) {
|
|
||||||
return { extensions: getExtensionsList() };
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getExtensionSettings(req: ExtensionNameRequest, reply: FastifyReply) {
|
|
||||||
const { name } = req.params;
|
|
||||||
const ext = getExtension(name);
|
|
||||||
|
|
||||||
if (!ext) {
|
|
||||||
return { error: "Extension not found" };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!ext.getSettings) {
|
|
||||||
return { episodeServers: ["default"], supportsDub: false };
|
|
||||||
}
|
|
||||||
|
|
||||||
return ext.getSettings();
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getWatchStream(req: WatchStreamRequest, reply: FastifyReply) {
|
export async function getWatchStream(req: WatchStreamRequest, reply: FastifyReply) {
|
||||||
try {
|
try {
|
||||||
const { animeId, episode, server, category, ext } = req.query;
|
const { animeId, episode, server, category, ext } = req.query;
|
||||||
@@ -98,7 +95,7 @@ export async function getWatchStream(req: WatchStreamRequest, reply: FastifyRepl
|
|||||||
const results = await animeService.searchAnimeInExtension(
|
const results = await animeService.searchAnimeInExtension(
|
||||||
extension,
|
extension,
|
||||||
ext,
|
ext,
|
||||||
animeId.replaceAll("-", " ")
|
animeId.replace(/--/g, '\u0000').replace(/-/g, ' ').replace(new RegExp('\u0000', 'g'), '-').trim()
|
||||||
);
|
);
|
||||||
anime = results[0];
|
anime = results[0];
|
||||||
if (!anime) return { error: "Anime not found in extension search" };
|
if (!anime) return { error: "Anime not found in extension search" };
|
||||||
|
|||||||
@@ -3,11 +3,10 @@ import * as controller from './anime.controller';
|
|||||||
|
|
||||||
async function animeRoutes(fastify: FastifyInstance) {
|
async function animeRoutes(fastify: FastifyInstance) {
|
||||||
fastify.get('/anime/:id', controller.getAnime);
|
fastify.get('/anime/:id', controller.getAnime);
|
||||||
|
fastify.get('/anime/:id/:episodes', controller.getAnimeEpisodes);
|
||||||
fastify.get('/trending', controller.getTrending);
|
fastify.get('/trending', controller.getTrending);
|
||||||
fastify.get('/top-airing', controller.getTopAiring);
|
fastify.get('/top-airing', controller.getTopAiring);
|
||||||
fastify.get('/search', controller.search);
|
fastify.get('/search', controller.search);
|
||||||
fastify.get('/extensions', controller.getExtensions);
|
|
||||||
fastify.get('/extension/:name/settings', controller.getExtensionSettings);
|
|
||||||
fastify.get('/watch/stream', controller.getWatchStream);
|
fastify.get('/watch/stream', controller.getWatchStream);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { queryOne, queryAll } from '../shared/database';
|
import { queryOne, queryAll } from '../shared/database';
|
||||||
import { getAllExtensions } from '../shared/extensions';
|
import { getAnimeExtensionsMap } from '../shared/extensions';
|
||||||
import { Anime, Extension, StreamData } from '../types';
|
import {Anime, Episode, Extension, StreamData} from '../types';
|
||||||
|
|
||||||
export async function getAnimeById(id: string | number): Promise<Anime | { error: string }> {
|
export async function getAnimeById(id: string | number): Promise<Anime | { error: string }> {
|
||||||
const row = await queryOne("SELECT full_data FROM anime WHERE id = ?", [id]);
|
const row = await queryOne("SELECT full_data FROM anime WHERE id = ?", [id]);
|
||||||
@@ -47,16 +47,12 @@ export async function searchAnimeLocal(query: string): Promise<Anime[]> {
|
|||||||
return cleanResults.slice(0, 10);
|
return cleanResults.slice(0, 10);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function searchAnimeInExtension(
|
export async function searchAnimeInExtension(ext: Extension | null, name: string, query: string): Promise<Anime[]> {
|
||||||
ext: Extension | null,
|
|
||||||
name: string,
|
|
||||||
query: string
|
|
||||||
): Promise<Anime[]> {
|
|
||||||
if (!ext) return [];
|
if (!ext) return [];
|
||||||
|
|
||||||
if ((ext.type === 'anime-board') && ext.search) {
|
if (ext.type === 'anime-board' && ext.search) {
|
||||||
try {
|
try {
|
||||||
console.log(`[${name}] Searching for book: ${query}`);
|
console.log(`[${name}] Searching for anime: ${query}`);
|
||||||
const matches = await ext.search({
|
const matches = await ext.search({
|
||||||
query: query,
|
query: query,
|
||||||
media: {
|
media: {
|
||||||
@@ -86,10 +82,47 @@ export async function searchAnimeInExtension(
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function searchAnimeExtensions(query: string): Promise<Anime[]> {
|
export async function searchChaptersInExtension(ext: Extension | null, name: string, query: string): Promise<Episode[]> {
|
||||||
const extensions = getAllExtensions();
|
if (!ext) return [];
|
||||||
|
|
||||||
for (const [name, ext] of extensions) {
|
if (ext.type === "anime-board" && ext.search && typeof ext.findEpisodes === "function") {
|
||||||
|
try {
|
||||||
|
|
||||||
|
const matches = await ext.search({
|
||||||
|
query,
|
||||||
|
media: {
|
||||||
|
romajiTitle: query,
|
||||||
|
englishTitle: query,
|
||||||
|
startDate: { year: 0, month: 0, day: 0 }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!matches || matches.length === 0) return [];
|
||||||
|
|
||||||
|
const res = matches[0];
|
||||||
|
if (!res?.id) return [];
|
||||||
|
|
||||||
|
const chapterList = await ext.findEpisodes(res.id);
|
||||||
|
if (!Array.isArray(chapterList)) return [];
|
||||||
|
|
||||||
|
return chapterList.map(ep => ({
|
||||||
|
id: ep.id,
|
||||||
|
number: ep.number,
|
||||||
|
url: ep.url,
|
||||||
|
title: ep.title
|
||||||
|
}));
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`Extension search failed for ${name}:`, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function searchAnimeExtensions(query: string): Promise<Anime[]> {
|
||||||
|
const animeExtensions = getAnimeExtensionsMap();
|
||||||
|
|
||||||
|
for (const [name, ext] of animeExtensions) {
|
||||||
const results = await searchAnimeInExtension(ext, name, query);
|
const results = await searchAnimeInExtension(ext, name, query);
|
||||||
if (results.length > 0) return results;
|
if (results.length > 0) return results;
|
||||||
}
|
}
|
||||||
@@ -97,13 +130,7 @@ export async function searchAnimeExtensions(query: string): Promise<Anime[]> {
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getStreamData(
|
export async function getStreamData(extension: Extension, animeData: Anime, episode: string, server?: string, category?: string): Promise<StreamData> {
|
||||||
extension: Extension,
|
|
||||||
animeData: Anime,
|
|
||||||
episode: string,
|
|
||||||
server?: string,
|
|
||||||
category?: string
|
|
||||||
): Promise<StreamData> {
|
|
||||||
const searchOptions = {
|
const searchOptions = {
|
||||||
query: animeData.title.english || animeData.title.romaji,
|
query: animeData.title.english || animeData.title.romaji,
|
||||||
dub: category === 'dub',
|
dub: category === 'dub',
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ export async function getBook(req: BookRequest, reply: FastifyReply) {
|
|||||||
const extensionName = source;
|
const extensionName = source;
|
||||||
const ext = getExtension(extensionName);
|
const ext = getExtension(extensionName);
|
||||||
|
|
||||||
const results = await booksService.searchBooksInExtension(ext, extensionName, id.replaceAll("-", " "));
|
const results = await booksService.searchBooksInExtension(ext, extensionName, id.replace(/--/g, '\u0000').replace(/-/g, ' ').replace(new RegExp('\u0000', 'g'), '-').trim());
|
||||||
book = results[0] || null;
|
book = results[0] || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { queryOne, queryAll } from '../shared/database';
|
import { queryOne, queryAll } from '../shared/database';
|
||||||
import { getAllExtensions } from '../shared/extensions';
|
import { getAllExtensions, getBookExtensionsMap } from '../shared/extensions';
|
||||||
import { Book, Extension, ChapterWithProvider, ChapterContent } from '../types';
|
import { Book, Extension, ChapterWithProvider, ChapterContent } from '../types';
|
||||||
|
|
||||||
export async function getBookById(id: string | number): Promise<Book | { error: string }> {
|
export async function getBookById(id: string | number): Promise<Book | { error: string }> {
|
||||||
@@ -144,9 +144,9 @@ export async function searchBooksInExtension(ext: Extension | null, name: string
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function searchBooksExtensions(query: string): Promise<Book[]> {
|
export async function searchBooksExtensions(query: string): Promise<Book[]> {
|
||||||
const extensions = getAllExtensions();
|
const bookExtensions = getBookExtensionsMap();
|
||||||
|
|
||||||
for (const [name, ext] of extensions) {
|
for (const [name, ext] of bookExtensions) {
|
||||||
const results = await searchBooksInExtension(ext, name, query);
|
const results = await searchBooksInExtension(ext, name, query);
|
||||||
if (results.length > 0) return results;
|
if (results.length > 0) return results;
|
||||||
}
|
}
|
||||||
@@ -154,19 +154,7 @@ export async function searchBooksExtensions(query: string): Promise<Book[]> {
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getChaptersForBook(id: string): Promise<{ chapters: ChapterWithProvider[] }> {
|
async function fetchBookMetadata(id: string): Promise<Book | null> {
|
||||||
let bookData: Book | null = null;
|
|
||||||
let searchTitle: string | null = null;
|
|
||||||
|
|
||||||
if (typeof id === "string" && isNaN(Number(id))) {
|
|
||||||
searchTitle = id.replaceAll("-", " ");
|
|
||||||
} else {
|
|
||||||
const result = await getBookById(id);
|
|
||||||
if (!('error' in result)) {
|
|
||||||
bookData = result;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!bookData) {
|
|
||||||
try {
|
try {
|
||||||
const query = `query ($id: Int) {
|
const query = `query ($id: Int) {
|
||||||
Media(id: $id, type: MANGA) {
|
Media(id: $id, type: MANGA) {
|
||||||
@@ -182,65 +170,97 @@ export async function getChaptersForBook(id: string): Promise<{ chapters: Chapte
|
|||||||
});
|
});
|
||||||
|
|
||||||
const d = await res.json();
|
const d = await res.json();
|
||||||
if (d.data?.Media) bookData = d.data.Media;
|
return d.data?.Media || null;
|
||||||
} catch (e) { }
|
} catch (e) {
|
||||||
|
console.error("Failed to fetch book metadata:", e);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!bookData) return { chapters: [] };
|
async function searchChaptersInExtension(ext: Extension, name: string, searchTitle: string, bookData: Book | null): Promise<ChapterWithProvider[]> {
|
||||||
|
try {
|
||||||
|
console.log(`[${name}] Searching chapters for: ${searchTitle}`);
|
||||||
|
|
||||||
|
const matches = await ext.search!({
|
||||||
|
query: searchTitle,
|
||||||
|
media: bookData ? {
|
||||||
|
romajiTitle: bookData.title.romaji,
|
||||||
|
englishTitle: bookData.title.english || "",
|
||||||
|
startDate: bookData.startDate || { year: 0, month: 0, day: 0 }
|
||||||
|
} : {
|
||||||
|
romajiTitle: searchTitle,
|
||||||
|
englishTitle: searchTitle,
|
||||||
|
startDate: { year: 0, month: 0, day: 0 }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!matches?.length) {
|
||||||
|
console.log(`[${name}] No matches found for book.`);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const best = matches[0];
|
||||||
|
const chaps = await ext.findChapters!(best.id);
|
||||||
|
|
||||||
|
if (!chaps?.length) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[${name}] Found ${chaps.length} chapters.`);
|
||||||
|
|
||||||
|
return chaps.map((ch) => ({
|
||||||
|
id: ch.id,
|
||||||
|
number: parseFloat(ch.number.toString()),
|
||||||
|
title: ch.title,
|
||||||
|
date: ch.releaseDate,
|
||||||
|
provider: name
|
||||||
|
}));
|
||||||
|
} catch (e) {
|
||||||
|
const error = e as Error;
|
||||||
|
console.error(`Failed to fetch chapters from ${name}:`, error.message);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getChaptersForBook(id: string): Promise<{ chapters: ChapterWithProvider[] }> {
|
||||||
|
let bookData: Book | null = null;
|
||||||
|
let searchTitle: string | null = null;
|
||||||
|
|
||||||
|
if (isNaN(Number(id))) {
|
||||||
|
searchTitle = id.replace(/--/g, '\u0000').replace(/-/g, ' ').replace(new RegExp('\u0000', 'g'), '-').trim();
|
||||||
|
} else {
|
||||||
|
const result = await getBookById(id);
|
||||||
|
if (!('error' in result)) {
|
||||||
|
bookData = result;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!bookData) {
|
||||||
|
bookData = await fetchBookMetadata(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!bookData) {
|
||||||
|
return { chapters: [] };
|
||||||
|
}
|
||||||
|
|
||||||
const titles = [bookData.title.english, bookData.title.romaji].filter(Boolean) as string[];
|
const titles = [bookData.title.english, bookData.title.romaji].filter(Boolean) as string[];
|
||||||
searchTitle = titles[0];
|
searchTitle = titles[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
const allChapters: ChapterWithProvider[] = [];
|
const allChapters: ChapterWithProvider[] = [];
|
||||||
const extensions = getAllExtensions();
|
const bookExtensions = getBookExtensionsMap();
|
||||||
|
|
||||||
const searchPromises = Array.from(extensions.entries())
|
const searchPromises = Array.from(bookExtensions.entries())
|
||||||
.filter(([_, ext]) =>
|
.filter(([_, ext]) => ext.search && ext.findChapters)
|
||||||
(ext.type === 'book-board' || ext.type === 'manga-board') &&
|
|
||||||
ext.search && ext.findChapters
|
|
||||||
)
|
|
||||||
.map(async ([name, ext]) => {
|
.map(async ([name, ext]) => {
|
||||||
try {
|
const chapters = await searchChaptersInExtension(ext, name, searchTitle!, bookData);
|
||||||
console.log(`[${name}] Searching chapters for: ${searchTitle}`);
|
allChapters.push(...chapters);
|
||||||
|
|
||||||
const matches = await ext.search!({
|
|
||||||
query: searchTitle!,
|
|
||||||
media: bookData ? {
|
|
||||||
romajiTitle: bookData.title.romaji,
|
|
||||||
englishTitle: bookData.title.english || "",
|
|
||||||
startDate: bookData.startDate || { year: 0, month: 0, day: 0 }
|
|
||||||
} : { romajiTitle: searchTitle!, englishTitle: searchTitle!, startDate: { year: 0, month: 0, day: 0 } }
|
|
||||||
});
|
|
||||||
|
|
||||||
if (matches?.length) {
|
|
||||||
const best = matches[0];
|
|
||||||
const chaps = await ext.findChapters!(best.id);
|
|
||||||
|
|
||||||
if (chaps?.length) {
|
|
||||||
console.log(`[${name}] Found ${chaps.length} chapters.`);
|
|
||||||
chaps.forEach((ch: { id: any; number: { toString: () => string; }; title: any; releaseDate: any; }) => {
|
|
||||||
allChapters.push({
|
|
||||||
id: ch.id,
|
|
||||||
number: parseFloat(ch.number.toString()),
|
|
||||||
title: ch.title,
|
|
||||||
date: ch.releaseDate,
|
|
||||||
provider: name
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
console.log(`[${name}] No matches found for book.`);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
const error = e as Error;
|
|
||||||
console.error(`Failed to fetch chapters from ${name}:`, error.message);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
await Promise.all(searchPromises);
|
await Promise.all(searchPromises);
|
||||||
|
|
||||||
return { chapters: allChapters.sort((a, b) => Number(a.number) - Number(b.number)) };
|
return {
|
||||||
|
chapters: allChapters.sort((a, b) => Number(a.number) - Number(b.number))
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getChapterContent(bookId: string, chapterIndex: string, providerName: string): Promise<ChapterContent> {
|
export async function getChapterContent(bookId: string, chapterIndex: string, providerName: string): Promise<ChapterContent> {
|
||||||
|
|||||||
32
src/extensions/extensions.controller.ts
Normal file
32
src/extensions/extensions.controller.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { FastifyReply, FastifyRequest } from 'fastify';
|
||||||
|
import { getExtension, getExtensionsList, getAllExtensions, getBookExtensionsMap, getAnimeExtensionsMap } from '../shared/extensions';
|
||||||
|
import { ExtensionNameRequest } from '../types';
|
||||||
|
|
||||||
|
export async function getExtensions(req: FastifyRequest, reply: FastifyReply) {
|
||||||
|
return { extensions: getExtensionsList() };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAnimeExtensions(req: FastifyRequest, reply: FastifyReply) {
|
||||||
|
const animeExtensions = getAnimeExtensionsMap();
|
||||||
|
return { extensions: Array.from(animeExtensions.keys()) };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getBookExtensions(req: FastifyRequest, reply: FastifyReply) {
|
||||||
|
const bookExtensions = getBookExtensionsMap();
|
||||||
|
return { extensions: Array.from(bookExtensions.keys()) };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getExtensionSettings(req: ExtensionNameRequest, reply: FastifyReply) {
|
||||||
|
const { name } = req.params;
|
||||||
|
const ext = getExtension(name);
|
||||||
|
|
||||||
|
if (!ext) {
|
||||||
|
return { error: "Extension not found" };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!ext.getSettings) {
|
||||||
|
return { episodeServers: ["default"], supportsDub: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
return ext.getSettings();
|
||||||
|
}
|
||||||
11
src/extensions/extensions.routes.ts
Normal file
11
src/extensions/extensions.routes.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { FastifyInstance } from 'fastify';
|
||||||
|
import * as controller from './extensions.controller';
|
||||||
|
|
||||||
|
async function extensionsRoutes(fastify: FastifyInstance) {
|
||||||
|
fastify.get('/extensions', controller.getExtensions);
|
||||||
|
fastify.get('/extensions/anime', controller.getAnimeExtensions);
|
||||||
|
fastify.get('/extensions/book', controller.getBookExtensions);
|
||||||
|
fastify.get('/extensions/:name/settings', controller.getExtensionSettings);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default extensionsRoutes;
|
||||||
@@ -134,6 +134,18 @@ async function loadAnime() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let extensionEpisodes = [];
|
||||||
|
|
||||||
|
if (extensionName) {
|
||||||
|
extensionEpisodes = await loadExtensionEpisodes(animeId, extensionName);
|
||||||
|
|
||||||
|
if (extensionEpisodes.length > 0) {
|
||||||
|
totalEpisodes = extensionEpisodes.length;
|
||||||
|
} else {
|
||||||
|
totalEpisodes = 1;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// MODO NORMAL (AniList)
|
||||||
if (data.nextAiringEpisode?.episode) {
|
if (data.nextAiringEpisode?.episode) {
|
||||||
totalEpisodes = data.nextAiringEpisode.episode - 1;
|
totalEpisodes = data.nextAiringEpisode.episode - 1;
|
||||||
} else if (data.episodes) {
|
} else if (data.episodes) {
|
||||||
@@ -141,6 +153,7 @@ async function loadAnime() {
|
|||||||
} else {
|
} else {
|
||||||
totalEpisodes = 12;
|
totalEpisodes = 12;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
totalEpisodes = Math.min(Math.max(totalEpisodes, 1), 5000);
|
totalEpisodes = Math.min(Math.max(totalEpisodes, 1), 5000);
|
||||||
document.getElementById('episodes').innerText = totalEpisodes;
|
document.getElementById('episodes').innerText = totalEpisodes;
|
||||||
@@ -153,6 +166,27 @@ async function loadAnime() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadExtensionEpisodes(animeId, extName) {
|
||||||
|
try {
|
||||||
|
const url = `/api/anime/${animeId}/episodes?ext=${extName}`;
|
||||||
|
const res = await fetch(url);
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
if (!Array.isArray(data)) return [];
|
||||||
|
|
||||||
|
return data.map(ep => ({
|
||||||
|
id: ep.id,
|
||||||
|
number: ep.number,
|
||||||
|
title: ep.title || `Episode ${ep.number}`,
|
||||||
|
url: ep.url
|
||||||
|
}));
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to fetch extension episodes:", err);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
function handleDescription(text) {
|
function handleDescription(text) {
|
||||||
const tmp = document.createElement("DIV");
|
const tmp = document.createElement("DIV");
|
||||||
tmp.innerHTML = text;
|
tmp.innerHTML = text;
|
||||||
|
|||||||
@@ -34,12 +34,11 @@ async function fetchSearh(query) {
|
|||||||
function createSlug(text) {
|
function createSlug(text) {
|
||||||
if (!text) return '';
|
if (!text) return '';
|
||||||
return text
|
return text
|
||||||
.replace(/([a-z])([A-Z])/g, '$1 $2') // separa CamelCase
|
|
||||||
.replace(/([a-z])(\d)/g, '$1 $2') // separa letras de números
|
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
.trim()
|
.trim()
|
||||||
.replace(/[^a-z0-9\s-]/g, '')
|
.replace(/-/g, '--')
|
||||||
.replace(/[\s-]+/g, '-');
|
.replace(/\s+/g, '-')
|
||||||
|
.replace(/[^a-z0-9\-]/g, '');
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderSearchResults(results) {
|
function renderSearchResults(results) {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ let audioMode = 'sub';
|
|||||||
let currentExtension = '';
|
let currentExtension = '';
|
||||||
let plyrInstance;
|
let plyrInstance;
|
||||||
let hlsInstance;
|
let hlsInstance;
|
||||||
|
let totalEpisodes = 0;
|
||||||
|
|
||||||
const params = new URLSearchParams(window.location.search);
|
const params = new URLSearchParams(window.location.search);
|
||||||
const firstKey = params.keys().next().value;
|
const firstKey = params.keys().next().value;
|
||||||
@@ -21,42 +22,130 @@ document.getElementById('episode-label').innerText = `Episode ${currentEpisode}`
|
|||||||
|
|
||||||
async function loadMetadata() {
|
async function loadMetadata() {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/anime/${animeId}`);
|
const extQuery = extName ? `?ext=${extName}` : "";
|
||||||
|
|
||||||
|
const res = await fetch(`/api/anime/${animeId}${extQuery}`);
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
|
|
||||||
if (!data.error) {
|
if (!data.error) {
|
||||||
const title = data.title.english || data.title.romaji;
|
const romajiTitle = data.title.romaji || data.title.english || 'Anime Title';
|
||||||
document.getElementById('anime-title').innerText = title;
|
|
||||||
document.title = `Watching ${title} - Ep ${currentEpisode}`;
|
document.getElementById('anime-title').innerText = romajiTitle;
|
||||||
|
document.title = `Watching ${romajiTitle} - Ep ${currentEpisode}`;
|
||||||
|
|
||||||
|
document.getElementById('detail-anime-title').innerText = romajiTitle;
|
||||||
|
|
||||||
|
const tempDiv = document.createElement('div');
|
||||||
|
tempDiv.innerHTML = data.description || 'No description available.';
|
||||||
|
document.getElementById('detail-description').innerText =
|
||||||
|
tempDiv.textContent || tempDiv.innerText;
|
||||||
|
|
||||||
|
document.getElementById('detail-format').innerText = data.format || '--';
|
||||||
|
document.getElementById('detail-score').innerText =
|
||||||
|
data.averageScore ? `${data.averageScore}%` : '--';
|
||||||
|
|
||||||
|
const season = data.season
|
||||||
|
? data.season.charAt(0) + data.season.slice(1).toLowerCase()
|
||||||
|
: '';
|
||||||
|
document.getElementById('detail-season').innerText =
|
||||||
|
data.seasonYear ? `${season} ${data.seasonYear}` : '--';
|
||||||
|
|
||||||
|
document.getElementById('detail-cover-image').src =
|
||||||
|
data.coverImage.large || data.coverImage.medium || '';
|
||||||
|
|
||||||
|
if (!extName) {
|
||||||
|
|
||||||
|
totalEpisodes = data.episodes || 0;
|
||||||
|
} else {
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res2 = await fetch(`/api/anime/${animeId}/episodes${extQuery}`);
|
||||||
|
const data2 = await res2.json();
|
||||||
|
totalEpisodes = Array.isArray(data2) ? data2.length : 0;
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Error cargando episodios por extensión:", e);
|
||||||
|
totalEpisodes = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
populateEpisodeSelectors(totalEpisodes);
|
||||||
|
|
||||||
|
if (currentEpisode >= totalEpisodes && totalEpisodes > 0) {
|
||||||
|
document.getElementById('next-btn').disabled = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading metadata:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function populateEpisodeSelectors(count) {
|
||||||
|
const list = document.getElementById('episode-list');
|
||||||
|
list.innerHTML = '';
|
||||||
|
|
||||||
|
for (let i = 1; i <= count; i++) {
|
||||||
|
const extParam = extName ? `?${extName}` : "";
|
||||||
|
|
||||||
|
const btn = document.createElement('a');
|
||||||
|
btn.href = `/watch/${animeId}/${i}${extParam}`;
|
||||||
|
btn.classList.add('episode-btn');
|
||||||
|
btn.dataset.episode = i;
|
||||||
|
btn.innerText = i;
|
||||||
|
|
||||||
|
if (i === currentEpisode) {
|
||||||
|
btn.classList.add('active-ep');
|
||||||
|
}
|
||||||
|
|
||||||
|
list.appendChild(btn);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function filterEpisodes() {
|
||||||
|
const searchInput = document.getElementById('episode-search');
|
||||||
|
const filter = searchInput.value.toUpperCase().trim();
|
||||||
|
const episodeList = document.getElementById('episode-list');
|
||||||
|
const buttons = episodeList.getElementsByClassName('episode-btn');
|
||||||
|
|
||||||
|
for (let i = 0; i < buttons.length; i++) {
|
||||||
|
const episodeNumber = buttons[i].dataset.episode;
|
||||||
|
|
||||||
|
if (episodeNumber.startsWith(filter) || filter === "") {
|
||||||
|
buttons[i].style.display = "";
|
||||||
|
} else {
|
||||||
|
buttons[i].style.display = "none";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch(e) { console.error(e); }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadExtensions() {
|
async function loadExtensions() {
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/extensions');
|
const res = await fetch('/api/extensions/anime');
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
const select = document.getElementById('extension-select');
|
const select = document.getElementById('extension-select');
|
||||||
|
|
||||||
if (data.extensions && data.extensions.length > 0) {
|
if (data.extensions && data.extensions.length > 0) {
|
||||||
|
select.innerHTML = '';
|
||||||
data.extensions.forEach(ext => {
|
data.extensions.forEach(ext => {
|
||||||
const opt = document.createElement('option');
|
const opt = document.createElement('option');
|
||||||
opt.value = opt.innerText = ext;
|
opt.value = opt.innerText = ext;
|
||||||
select.appendChild(opt);
|
select.appendChild(opt);
|
||||||
});
|
});
|
||||||
|
|
||||||
if (data.extensions.includes(extName ?? "")) {
|
if (typeof extName === 'string' && data.extensions.includes(extName)) {
|
||||||
select.value = extName;
|
select.value = extName;
|
||||||
currentExtension = extName;
|
} else {
|
||||||
onExtensionChange();
|
select.selectedIndex = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
currentExtension = select.value;
|
||||||
|
onExtensionChange();
|
||||||
} else {
|
} else {
|
||||||
select.innerHTML = '<option>No Extensions</option>';
|
select.innerHTML = '<option>No Extensions</option>';
|
||||||
select.disabled = true;
|
select.disabled = true;
|
||||||
setLoading("No extensions found in WaifuBoards folder.");
|
setLoading("No anime extensions found.");
|
||||||
}
|
}
|
||||||
|
} catch (error) {
|
||||||
} catch(e) {
|
console.error("Extension Error:", error);
|
||||||
console.error("Extension Error:", e);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,7 +155,7 @@ async function onExtensionChange() {
|
|||||||
setLoading("Fetching extension settings...");
|
setLoading("Fetching extension settings...");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/extension/${currentExtension}/settings`);
|
const res = await fetch(`/api/extensions/${currentExtension}/settings`);
|
||||||
const settings = await res.json();
|
const settings = await res.json();
|
||||||
|
|
||||||
const toggle = document.getElementById('sd-toggle');
|
const toggle = document.getElementById('sd-toggle');
|
||||||
@@ -93,9 +182,8 @@ async function onExtensionChange() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
loadStream();
|
loadStream();
|
||||||
|
} catch (error) {
|
||||||
} catch (err) {
|
console.error(error);
|
||||||
console.error(err);
|
|
||||||
setLoading("Failed to load extension settings.");
|
setLoading("Failed to load extension settings.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -113,6 +201,7 @@ function setAudioMode(mode) {
|
|||||||
const dubOpt = document.getElementById('opt-dub');
|
const dubOpt = document.getElementById('opt-dub');
|
||||||
|
|
||||||
toggle.setAttribute('data-state', mode);
|
toggle.setAttribute('data-state', mode);
|
||||||
|
|
||||||
if (mode === 'sub') {
|
if (mode === 'sub') {
|
||||||
subOpt.classList.add('active');
|
subOpt.classList.add('active');
|
||||||
dubOpt.classList.remove('active');
|
dubOpt.classList.remove('active');
|
||||||
@@ -128,7 +217,7 @@ async function loadStream() {
|
|||||||
const serverSelect = document.getElementById('server-select');
|
const serverSelect = document.getElementById('server-select');
|
||||||
const server = serverSelect.value || "default";
|
const server = serverSelect.value || "default";
|
||||||
|
|
||||||
setLoading(`Searching & Resolving Stream (${audioMode})...`);
|
setLoading(`Loading stream (${audioMode})...`);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const url = `/api/watch/stream?animeId=${animeId.slice(0, 30)}&episode=${currentEpisode}&server=${server}&category=${audioMode}&ext=${currentExtension}`;
|
const url = `/api/watch/stream?animeId=${animeId.slice(0, 30)}&episode=${currentEpisode}&server=${server}&category=${audioMode}&ext=${currentExtension}`;
|
||||||
@@ -146,21 +235,18 @@ async function loadStream() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const source = data.videoSources.find(s => s.type === 'm3u8') || data.videoSources[0];
|
const source = data.videoSources.find(s => s.type === 'm3u8') || data.videoSources[0];
|
||||||
|
|
||||||
const headers = data.headers || {};
|
const headers = data.headers || {};
|
||||||
let proxyUrl = `/api/proxy?url=${encodeURIComponent(source.url)}`;
|
|
||||||
|
|
||||||
|
let proxyUrl = `/api/proxy?url=${encodeURIComponent(source.url)}`;
|
||||||
if (headers['Referer']) proxyUrl += `&referer=${encodeURIComponent(headers['Referer'])}`;
|
if (headers['Referer']) proxyUrl += `&referer=${encodeURIComponent(headers['Referer'])}`;
|
||||||
if (headers['Origin']) proxyUrl += `&origin=${encodeURIComponent(headers['Origin'])}`;
|
if (headers['Origin']) proxyUrl += `&origin=${encodeURIComponent(headers['Origin'])}`;
|
||||||
if (headers['User-Agent']) proxyUrl += `&userAgent=${encodeURIComponent(headers['User-Agent'])}`;
|
if (headers['User-Agent']) proxyUrl += `&userAgent=${encodeURIComponent(headers['User-Agent'])}`;
|
||||||
|
|
||||||
playVideo(proxyUrl, data.videoSources[0].subtitles);
|
playVideo(proxyUrl, data.videoSources[0].subtitles);
|
||||||
|
|
||||||
document.getElementById('loading-overlay').style.display = 'none';
|
document.getElementById('loading-overlay').style.display = 'none';
|
||||||
|
} catch (error) {
|
||||||
} catch (err) {
|
setLoading("Stream error. Check console.");
|
||||||
setLoading("Stream Error. Check console.");
|
console.error(error);
|
||||||
console.error(err);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -169,6 +255,7 @@ function playVideo(url, subtitles) {
|
|||||||
|
|
||||||
if (Hls.isSupported()) {
|
if (Hls.isSupported()) {
|
||||||
if (hlsInstance) hlsInstance.destroy();
|
if (hlsInstance) hlsInstance.destroy();
|
||||||
|
|
||||||
hlsInstance = new Hls({
|
hlsInstance = new Hls({
|
||||||
xhrSetup: (xhr, url) => {
|
xhrSetup: (xhr, url) => {
|
||||||
xhr.withCredentials = false;
|
xhr.withCredentials = false;
|
||||||
@@ -193,30 +280,52 @@ function playVideo(url, subtitles) {
|
|||||||
track.label = sub.language;
|
track.label = sub.language;
|
||||||
track.srclang = sub.language.slice(0, 2).toLowerCase();
|
track.srclang = sub.language.slice(0, 2).toLowerCase();
|
||||||
track.src = sub.url;
|
track.src = sub.url;
|
||||||
|
|
||||||
if (sub.default || sub.language.toLowerCase().includes('english')) {
|
if (sub.default || sub.language.toLowerCase().includes('english')) {
|
||||||
track.default = true;
|
track.default = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
video.appendChild(track);
|
video.appendChild(track);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
plyrInstance = new Plyr(video, {
|
plyrInstance = new Plyr(video, {
|
||||||
captions: { active: true, update: true, language: 'en' },
|
captions: {
|
||||||
controls: ['play-large', 'play', 'progress', 'current-time', 'duration', 'mute', 'volume', 'captions', 'settings', 'pip', 'airplay', 'fullscreen'],
|
active: true,
|
||||||
|
update: true,
|
||||||
|
language: 'en'
|
||||||
|
},
|
||||||
|
controls: [
|
||||||
|
'play-large',
|
||||||
|
'play',
|
||||||
|
'progress',
|
||||||
|
'current-time',
|
||||||
|
'duration',
|
||||||
|
'mute',
|
||||||
|
'volume',
|
||||||
|
'captions',
|
||||||
|
'settings',
|
||||||
|
'pip',
|
||||||
|
'airplay',
|
||||||
|
'fullscreen'
|
||||||
|
],
|
||||||
settings: ['captions', 'quality', 'speed']
|
settings: ['captions', 'quality', 'speed']
|
||||||
});
|
});
|
||||||
|
|
||||||
video.play().catch(e => console.log("Auto-play blocked"));
|
video.play().catch(error => {
|
||||||
|
console.log("Autoplay blocked:", error);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function setLoading(msg) {
|
function setLoading(message) {
|
||||||
const overlay = document.getElementById('loading-overlay');
|
const overlay = document.getElementById('loading-overlay');
|
||||||
const text = document.getElementById('loading-text');
|
const text = document.getElementById('loading-text');
|
||||||
overlay.style.display = 'flex';
|
overlay.style.display = 'flex';
|
||||||
text.innerText = msg;
|
text.innerText = message;
|
||||||
}
|
}
|
||||||
|
|
||||||
const extParam = extName ? `?${extName}` : "";
|
const extParam = extName ? `?${extName}` : "";
|
||||||
|
|
||||||
document.getElementById('prev-btn').onclick = () => {
|
document.getElementById('prev-btn').onclick = () => {
|
||||||
if (currentEpisode > 1) {
|
if (currentEpisode > 1) {
|
||||||
window.location.href = `/watch/${animeId}/${currentEpisode - 1}${extParam}`;
|
window.location.href = `/watch/${animeId}/${currentEpisode - 1}${extParam}`;
|
||||||
@@ -226,7 +335,10 @@ document.getElementById('prev-btn').onclick = () => {
|
|||||||
document.getElementById('next-btn').onclick = () => {
|
document.getElementById('next-btn').onclick = () => {
|
||||||
window.location.href = `/watch/${animeId}/${currentEpisode + 1}${extParam}`;
|
window.location.href = `/watch/${animeId}/${currentEpisode + 1}${extParam}`;
|
||||||
};
|
};
|
||||||
if(currentEpisode <= 1) document.getElementById('prev-btn').disabled = true;
|
|
||||||
|
if (currentEpisode <= 1) {
|
||||||
|
document.getElementById('prev-btn').disabled = true;
|
||||||
|
}
|
||||||
|
|
||||||
loadMetadata();
|
loadMetadata();
|
||||||
loadExtensions();
|
loadExtensions();
|
||||||
@@ -104,7 +104,6 @@ async function loadChapters() {
|
|||||||
? `/api/book/${bookId.slice(0, 40)}/chapters`
|
? `/api/book/${bookId.slice(0, 40)}/chapters`
|
||||||
: `/api/book/${bookId}/chapters`;
|
: `/api/book/${bookId}/chapters`;
|
||||||
|
|
||||||
console.log(fetchUrl)
|
|
||||||
const res = await fetch(fetchUrl);
|
const res = await fetch(fetchUrl);
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
|
|
||||||
|
|||||||
@@ -49,11 +49,11 @@ async function fetchBookSearch(query) {
|
|||||||
function createSlug(text) {
|
function createSlug(text) {
|
||||||
if (!text) return '';
|
if (!text) return '';
|
||||||
return text
|
return text
|
||||||
.toString()
|
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
.trim()
|
.trim()
|
||||||
.replace(/[^a-z0-9\s-]/g, '')
|
.replace(/-/g, '--')
|
||||||
.replace(/[\s-]+/g, '-');
|
.replace(/\s+/g, '-')
|
||||||
|
.replace(/[^a-z0-9\-]/g, '');
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderSearchResults(results) {
|
function renderSearchResults(results) {
|
||||||
|
|||||||
@@ -57,9 +57,31 @@ function getExtensionsList() {
|
|||||||
return Array.from(extensions.keys());
|
return Array.from(extensions.keys());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getAnimeExtensionsMap() {
|
||||||
|
const animeExts = new Map();
|
||||||
|
for (const [name, ext] of extensions) {
|
||||||
|
if (ext.type === 'anime-board') {
|
||||||
|
animeExts.set(name, ext);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return animeExts;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getBookExtensionsMap() {
|
||||||
|
const bookExts = new Map();
|
||||||
|
for (const [name, ext] of extensions) {
|
||||||
|
if (ext.type === 'book-board' || ext.type === 'manga-board') {
|
||||||
|
bookExts.set(name, ext);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return bookExts;
|
||||||
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
loadExtensions,
|
loadExtensions,
|
||||||
getExtension,
|
getExtension,
|
||||||
getAllExtensions,
|
getAllExtensions,
|
||||||
getExtensionsList
|
getExtensionsList,
|
||||||
|
getAnimeExtensionsMap,
|
||||||
|
getBookExtensionsMap
|
||||||
};
|
};
|
||||||
@@ -69,6 +69,7 @@ export interface ExtensionSearchResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface Episode {
|
export interface Episode {
|
||||||
|
url: string;
|
||||||
id: string;
|
id: string;
|
||||||
number: number;
|
number: number;
|
||||||
title?: string;
|
title?: string;
|
||||||
|
|||||||
@@ -1,152 +1,175 @@
|
|||||||
:root {
|
:root {
|
||||||
--bg-base: #000000;
|
|
||||||
--bg-overlay: #101012;
|
--color-bg-base: #000000;
|
||||||
--accent: #8b5cf6;
|
--color-bg-elevated: #0a0a0b;
|
||||||
--accent-dark: #7c3aed;
|
--color-bg-card: #121214;
|
||||||
--text-primary: #ffffff;
|
--color-primary: #8b5cf6;
|
||||||
--text-secondary: #a1a1aa;
|
--color-primary-hover: #7c3aed;
|
||||||
|
--color-primary-glow: rgba(139, 92, 246, 0.4);
|
||||||
|
--color-text-primary: #ffffff;
|
||||||
|
--color-text-secondary: #a1a1aa;
|
||||||
|
--color-text-muted: #71717a;
|
||||||
|
|
||||||
|
--border-subtle: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
--border-medium: 1px solid rgba(255, 255, 255, 0.12);
|
||||||
|
--glass-bg: rgba(18, 18, 20, 0.8);
|
||||||
|
--glass-border: rgba(255, 255, 255, 0.1);
|
||||||
|
|
||||||
|
--spacing-xs: 0.5rem;
|
||||||
|
--spacing-sm: 0.75rem;
|
||||||
|
--spacing-md: 1rem;
|
||||||
|
--spacing-lg: 1.5rem;
|
||||||
|
--spacing-xl: 2rem;
|
||||||
|
--spacing-2xl: 3rem;
|
||||||
|
|
||||||
|
--radius-sm: 0.5rem;
|
||||||
|
--radius-md: 1rem;
|
||||||
|
--radius-lg: 1.25rem;
|
||||||
|
--radius-xl: 1.5rem;
|
||||||
--radius-full: 9999px;
|
--radius-full: 9999px;
|
||||||
--radius-md: 16px;
|
|
||||||
--glass-border: 1px solid rgba(255, 255, 255, 0.1);
|
|
||||||
--glass-bg: rgba(20, 20, 23, 0.7);
|
|
||||||
|
|
||||||
|
--shadow-sm: 0 2px 8px rgba(0, 0, 0, 0.3);
|
||||||
|
--shadow-md: 0 8px 24px rgba(0, 0, 0, 0.4);
|
||||||
|
--shadow-lg: 0 16px 48px rgba(0, 0, 0, 0.5);
|
||||||
|
--shadow-glow: 0 8px 32px var(--color-primary-glow);
|
||||||
|
|
||||||
--plyr-color-main: #8b5cf6;
|
--transition-fast: 150ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
--plyr-video-control-color: #ffffff;
|
--transition-base: 250ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
--plyr-video-control-background-hover: rgba(255, 255, 255, 0.1);
|
--transition-smooth: 350ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
--plyr-menu-background: rgba(28, 28, 30, 0.95);
|
|
||||||
--plyr-menu-color: #ffffff;
|
|
||||||
--plyr-menu-border-color: rgba(255, 255, 255, 0.1);
|
|
||||||
--plyr-font-family: 'Inter', sans-serif;
|
|
||||||
|
|
||||||
|
--plyr-color-main: var(--color-primary);
|
||||||
|
--plyr-video-control-color: var(--color-text-primary);
|
||||||
|
--plyr-video-control-background-hover: rgba(255, 255, 255, 0.12);
|
||||||
|
--plyr-menu-background: rgba(18, 18, 20, 0.95);
|
||||||
|
--plyr-menu-color: var(--color-text-primary);
|
||||||
|
--plyr-menu-border-color: var(--glass-border);
|
||||||
|
}
|
||||||
|
|
||||||
--plyr-control-icon-size: 18px;
|
*,
|
||||||
--plyr-control-spacing: 10px;
|
*::before,
|
||||||
|
*::after {
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
background-color: var(--bg-base);
|
background-color: var(--color-bg-base);
|
||||||
color: var(--text-primary);
|
color: var(--color-text-primary);
|
||||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
font-family: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||||
height: 100vh;
|
line-height: 1.6;
|
||||||
overflow: hidden;
|
overflow-x: hidden;
|
||||||
display: flex;
|
-webkit-font-smoothing: antialiased;
|
||||||
flex-direction: column;
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-scale {
|
||||||
|
transform: scale(0.90);
|
||||||
|
transform-origin: top center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.top-bar {
|
.top-bar {
|
||||||
padding: 1.5rem 2rem;
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: 0; left: 0; right: 0;
|
top: 0;
|
||||||
z-index: 100;
|
left: 0;
|
||||||
background: linear-gradient(to bottom, rgba(0,0,0,0.8) 0%, transparent 100%);
|
right: 0;
|
||||||
|
padding: var(--spacing-lg) var(--spacing-xl);
|
||||||
|
background: linear-gradient(180deg, rgba(0, 0, 0, 0.8) 0%, transparent 100%);
|
||||||
|
z-index: 1000;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.back-btn {
|
.back-btn {
|
||||||
pointer-events: auto;
|
pointer-events: auto;
|
||||||
display: flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.6rem;
|
gap: var(--spacing-sm);
|
||||||
padding: 0.8rem 1.8rem;
|
padding: 0.7rem 1.5rem;
|
||||||
background: var(--glass-bg);
|
background: var(--glass-bg);
|
||||||
backdrop-filter: blur(12px);
|
backdrop-filter: blur(16px);
|
||||||
border: var(--glass-border);
|
-webkit-backdrop-filter: blur(16px);
|
||||||
|
border: 1px solid var(--glass-border);
|
||||||
border-radius: var(--radius-full);
|
border-radius: var(--radius-full);
|
||||||
color: white;
|
color: var(--color-text-primary);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
font-size: 0.95rem;
|
font-size: 0.9rem;
|
||||||
transition: all 0.3s cubic-bezier(0.2, 0.8, 0.2, 1);
|
transition: all var(--transition-smooth);
|
||||||
box-shadow: 0 4px 20px rgba(0,0,0,0.2);
|
box-shadow: var(--shadow-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
.back-btn:hover {
|
.back-btn:hover {
|
||||||
background: rgba(255, 255, 255, 0.15);
|
background: rgba(255, 255, 255, 0.12);
|
||||||
transform: scale(1.02);
|
border-color: var(--color-primary);
|
||||||
box-shadow: 0 0 15px rgba(139, 92, 246, 0.3);
|
transform: translateY(-2px);
|
||||||
border-color: rgba(139, 92, 246, 0.3);
|
box-shadow: var(--shadow-glow);
|
||||||
}
|
}
|
||||||
|
|
||||||
.theater-container {
|
.watch-container {
|
||||||
flex: 1;
|
max-width: 1800px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 1rem var(--spacing-xl) var(--spacing-xl);
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 420px;
|
||||||
|
gap: var(--spacing-xl);
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.watch-container.sidebar-hidden {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-section {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
justify-content: center;
|
gap: var(--spacing-lg);
|
||||||
align-items: center;
|
|
||||||
width: 100%;
|
|
||||||
max-width: 1300px;
|
|
||||||
margin: 0 auto;
|
|
||||||
padding: 0 2rem;
|
|
||||||
position: relative;
|
|
||||||
z-index: 10;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.player-toolbar {
|
.player-toolbar {
|
||||||
width: 100%;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: flex-end;
|
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 1rem;
|
gap: var(--spacing-md);
|
||||||
margin-bottom: 1rem;
|
flex-wrap: wrap;
|
||||||
position: relative;
|
background: var(--glass-bg);
|
||||||
z-index: 50;
|
backdrop-filter: blur(16px);
|
||||||
|
-webkit-backdrop-filter: blur(16px);
|
||||||
|
border: 1px solid var(--glass-border);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
padding: var(--spacing-md);
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
.extension-select {
|
.control-group {
|
||||||
appearance: none;
|
display: flex;
|
||||||
-webkit-appearance: none;
|
align-items: center;
|
||||||
background-color: var(--glass-bg);
|
gap: var(--spacing-md);
|
||||||
backdrop-filter: blur(12px);
|
|
||||||
border: var(--glass-border);
|
|
||||||
color: var(--text-primary);
|
|
||||||
padding: 0.7rem 2.5rem 0.7rem 1.5rem;
|
|
||||||
border-radius: var(--radius-full);
|
|
||||||
font-size: 0.9rem;
|
|
||||||
font-weight: 500;
|
|
||||||
cursor: pointer;
|
|
||||||
outline: none;
|
|
||||||
min-width: 180px;
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
background-image: url("data:image/svg+xml,%3Csvg width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='white' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E");
|
|
||||||
background-repeat: no-repeat;
|
|
||||||
background-position: right 1rem center;
|
|
||||||
box-shadow: 0 4px 10px rgba(0,0,0,0.3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.extension-select:hover {
|
|
||||||
background-color: rgba(255,255,255,0.1);
|
|
||||||
border-color: var(--accent);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.sd-toggle {
|
.sd-toggle {
|
||||||
display: flex;
|
display: flex;
|
||||||
background: var(--glass-bg);
|
background: var(--color-bg-elevated);
|
||||||
border: var(--glass-border);
|
border: var(--border-subtle);
|
||||||
border-radius: var(--radius-full);
|
border-radius: var(--radius-full);
|
||||||
padding: 4px;
|
padding: 4px;
|
||||||
position: relative;
|
position: relative;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
backdrop-filter: blur(12px);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.sd-option {
|
.sd-option {
|
||||||
padding: 0.5rem 1.2rem;
|
padding: 0.6rem 1.5rem;
|
||||||
font-size: 0.85rem;
|
font-size: 0.875rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: var(--text-secondary);
|
color: var(--color-text-muted);
|
||||||
z-index: 2;
|
z-index: 2;
|
||||||
transition: color 0.3s;
|
transition: color var(--transition-base);
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.5px;
|
letter-spacing: 0.05em;
|
||||||
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sd-option.active {
|
.sd-option.active {
|
||||||
color: white;
|
color: var(--color-text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.sd-bg {
|
.sd-bg {
|
||||||
@@ -155,10 +178,10 @@ body {
|
|||||||
left: 4px;
|
left: 4px;
|
||||||
bottom: 4px;
|
bottom: 4px;
|
||||||
width: calc(50% - 4px);
|
width: calc(50% - 4px);
|
||||||
background: var(--accent);
|
background: var(--color-primary);
|
||||||
border-radius: var(--radius-full);
|
border-radius: var(--radius-full);
|
||||||
transition: transform 0.3s cubic-bezier(0.2, 0.8, 0.2, 1);
|
transition: transform var(--transition-smooth);
|
||||||
box-shadow: 0 2px 10px rgba(139, 92, 246, 0.4);
|
box-shadow: 0 4px 12px var(--color-primary-glow);
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -166,22 +189,74 @@ body {
|
|||||||
transform: translateX(100%);
|
transform: translateX(100%);
|
||||||
}
|
}
|
||||||
|
|
||||||
.video-wrapper {
|
.source-select {
|
||||||
width: 100%;
|
appearance: none;
|
||||||
|
background-color: var(--color-bg-elevated);
|
||||||
|
background-image: url("data:image/svg+xml,%3Csvg width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='white' stroke-width='2'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E");
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-position: right 1.2rem center;
|
||||||
|
border: var(--border-subtle);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
padding: 0.7rem 2.8rem 0.7rem 1.2rem;
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
min-width: 160px;
|
||||||
|
transition: all var(--transition-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
.source-select:hover {
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
background-color: var(--color-bg-card);
|
||||||
|
}
|
||||||
|
|
||||||
|
.source-select:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
box-shadow: 0 0 0 3px var(--color-primary-glow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-episodes-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--spacing-sm);
|
||||||
|
background: var(--color-bg-elevated);
|
||||||
|
border: var(--border-subtle);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
padding: 0.7rem 1.2rem;
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all var(--transition-base);
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-episodes-btn:hover {
|
||||||
|
background: var(--color-primary);
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-episodes-btn.active {
|
||||||
|
background: var(--color-primary);
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-container {
|
||||||
aspect-ratio: 16/9;
|
aspect-ratio: 16/9;
|
||||||
background: #000;
|
background: var(--color-bg-base);
|
||||||
border-radius: 20px;
|
border-radius: var(--radius-xl);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
box-shadow: 0 20px 60px rgba(0,0,0,0.5), 0 0 0 1px rgba(255,255,255,0.08);
|
box-shadow: var(--shadow-lg), 0 0 0 1px var(--glass-border);
|
||||||
position: relative;
|
position: relative;
|
||||||
transition: box-shadow 0.3s ease;
|
transition: box-shadow var(--transition-smooth);
|
||||||
}
|
}
|
||||||
|
|
||||||
.video-wrapper:hover {
|
.video-container:hover {
|
||||||
box-shadow: 0 25px 70px rgba(139, 92, 246, 0.15), 0 0 0 1px rgba(139, 92, 246, 0.3);
|
box-shadow: var(--shadow-lg), 0 0 0 1px var(--color-primary), var(--shadow-glow);
|
||||||
}
|
}
|
||||||
|
|
||||||
video {
|
#player {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
object-fit: contain;
|
object-fit: contain;
|
||||||
@@ -190,79 +265,91 @@ video {
|
|||||||
.loading-overlay {
|
.loading-overlay {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
background: #000;
|
background: var(--color-bg-base);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
z-index: 20;
|
z-index: 20;
|
||||||
color: var(--text-secondary);
|
gap: var(--spacing-lg);
|
||||||
gap: 1rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.spinner {
|
.spinner {
|
||||||
width: 40px;
|
width: 48px;
|
||||||
height: 40px;
|
height: 48px;
|
||||||
border: 3px solid rgba(255, 255, 255, 0.1);
|
border: 3px solid rgba(255, 255, 255, 0.1);
|
||||||
|
border-top-color: var(--color-primary);
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
border-top-color: var(--accent);
|
animation: spin 0.8s linear infinite;
|
||||||
animation: spin 1s linear infinite;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes spin { 100% { transform: rotate(360deg); } }
|
@keyframes spin {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
.controls-area {
|
.loading-overlay p {
|
||||||
width: 100%;
|
color: var(--color-text-secondary);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.episode-controls {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: flex-start;
|
||||||
margin-top: 1.5rem;
|
gap: var(--spacing-lg);
|
||||||
padding: 0 0.5rem;
|
background: var(--glass-bg);
|
||||||
|
backdrop-filter: blur(16px);
|
||||||
|
-webkit-backdrop-filter: blur(16px);
|
||||||
|
border: 1px solid var(--glass-border);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
padding: var(--spacing-lg);
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
.episode-info h2 {
|
.episode-info h1 {
|
||||||
margin: 0;
|
font-size: 1.75rem;
|
||||||
font-size: 1.4rem;
|
font-weight: 800;
|
||||||
font-weight: 700;
|
line-height: 1.2;
|
||||||
letter-spacing: -0.5px;
|
margin: 0 0 var(--spacing-xs);
|
||||||
text-shadow: 0 2px 10px rgba(0,0,0,0.5);
|
color: var(--color-text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.episode-info span {
|
.episode-info p {
|
||||||
color: var(--accent);
|
color: var(--color-primary);
|
||||||
font-size: 0.95rem;
|
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
font-size: 1rem;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 1px;
|
letter-spacing: 0.05em;
|
||||||
margin-top: 4px;
|
margin: 0;
|
||||||
display: block;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-controls {
|
.navigation-buttons {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 1rem;
|
gap: var(--spacing-md);
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-btn {
|
.nav-btn {
|
||||||
background: rgba(255,255,255,0.05);
|
|
||||||
border: 1px solid rgba(255,255,255,0.1);
|
|
||||||
color: white;
|
|
||||||
padding: 0.8rem 1.6rem;
|
|
||||||
border-radius: var(--radius-full);
|
|
||||||
font-weight: 600;
|
|
||||||
cursor: pointer;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.6rem;
|
gap: var(--spacing-sm);
|
||||||
transition: all 0.2s ease;
|
background: var(--color-bg-elevated);
|
||||||
backdrop-filter: blur(10px);
|
border: var(--border-subtle);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
padding: 0.75rem 1.5rem;
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all var(--transition-base);
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-btn:hover:not(:disabled) {
|
.nav-btn:hover:not(:disabled) {
|
||||||
background: var(--accent);
|
background: var(--color-primary);
|
||||||
border-color: var(--accent);
|
border-color: var(--color-primary);
|
||||||
transform: translateY(-2px);
|
transform: translateY(-2px);
|
||||||
box-shadow: 0 5px 15px rgba(139, 92, 246, 0.3);
|
box-shadow: var(--shadow-glow);
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-btn:disabled {
|
.nav-btn:disabled {
|
||||||
@@ -270,144 +357,335 @@ video {
|
|||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.episodes-sidebar {
|
||||||
|
position: sticky;
|
||||||
|
top: 6rem;
|
||||||
|
height: calc(100vh - 7rem);
|
||||||
|
background: var(--glass-bg);
|
||||||
|
backdrop-filter: blur(16px);
|
||||||
|
-webkit-backdrop-filter: blur(16px);
|
||||||
|
border: 1px solid var(--glass-border);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
padding: var(--spacing-lg);
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.watch-container.sidebar-hidden > .episodes-sidebar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: var(--spacing-lg);
|
||||||
|
padding-bottom: var(--spacing-md);
|
||||||
|
border-bottom: 1px solid var(--glass-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-header h3 {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-primary);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-sidebar-btn {
|
||||||
|
display: none;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
border-radius: 50%;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all var(--transition-base);
|
||||||
|
padding: 0;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-sidebar-btn:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-search {
|
||||||
|
margin-bottom: var(--spacing-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.episode-search-input {
|
||||||
|
width: 100%;
|
||||||
|
appearance: none;
|
||||||
|
background-color: var(--color-bg-elevated);
|
||||||
|
border: var(--border-subtle);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
padding: 0.75rem 1.2rem;
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: all var(--transition-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
.episode-search-input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
box-shadow: 0 0 0 3px var(--color-primary-glow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.episode-search-input::placeholder {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.episode-list {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(70px, 1fr));
|
||||||
|
gap: var(--spacing-sm);
|
||||||
|
padding: var(--spacing-xs);
|
||||||
|
align-content: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.episode-list::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.episode-list::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.episode-list::-webkit-scrollbar-thumb {
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
}
|
||||||
|
|
||||||
|
.episode-list::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.episode-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: var(--color-bg-elevated);
|
||||||
|
border: var(--border-subtle);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
padding: 0.85rem;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 1rem;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: all var(--transition-base);
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
aspect-ratio: 1;
|
||||||
|
min-height: 52px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.episode-btn:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
transform: scale(1.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.episode-btn.active-ep {
|
||||||
|
background: var(--color-primary);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
box-shadow: var(--shadow-glow);
|
||||||
|
font-weight: 800;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.episode-btn.active-ep::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: -2px;
|
||||||
|
border: 2px solid var(--color-primary);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.anime-details {
|
||||||
|
max-width: 1800px;
|
||||||
|
margin: var(--spacing-2xl) auto;
|
||||||
|
padding: 0 var(--spacing-xl) 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.details-container {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--spacing-xl);
|
||||||
|
background: var(--glass-bg);
|
||||||
|
backdrop-filter: blur(16px);
|
||||||
|
-webkit-backdrop-filter: blur(16px);
|
||||||
|
border: 1px solid var(--glass-border);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
padding: var(--spacing-xl);
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.details-cover {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover-image {
|
||||||
|
width: 220px;
|
||||||
|
height: auto;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.details-content h2 {
|
||||||
|
font-size: 2rem;
|
||||||
|
font-weight: 800;
|
||||||
|
margin: 0 0 var(--spacing-md);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.details-meta {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--spacing-md);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-bottom: var(--spacing-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-badge {
|
||||||
|
background: rgba(139, 92, 246, 0.12);
|
||||||
|
color: var(--color-primary);
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
border: 1px solid rgba(139, 92, 246, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-badge.meta-score {
|
||||||
|
background: var(--color-primary);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.details-description {
|
||||||
|
font-size: 1rem;
|
||||||
|
line-height: 1.7;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.plyr--video {
|
.plyr--video {
|
||||||
border-radius: 20px;
|
border-radius: var(--radius-xl);
|
||||||
font-family: 'Inter', sans-serif;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.plyr__controls {
|
.plyr__controls {
|
||||||
background: linear-gradient(to top, rgba(0,0,0,0.8) 0%, rgba(0,0,0,0.4) 50%, transparent 100%) !important;
|
background: linear-gradient(to top, rgba(0, 0, 0, 0.9) 0%, rgba(0, 0, 0, 0.5) 50%, transparent 100%) !important;
|
||||||
padding: 10px 20px 20px 20px !important;
|
padding: 1rem 1.5rem 1.5rem !important;
|
||||||
margin: 0 !important;
|
border-radius: 0 0 var(--radius-xl) var(--radius-xl) !important;
|
||||||
border-radius: 0 0 20px 20px !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.plyr__progress input[type=range] {
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.plyr--full-ui input[type=range] {
|
.plyr--full-ui input[type=range] {
|
||||||
color: var(--accent);
|
color: var(--color-primary);
|
||||||
height: 4px;
|
|
||||||
transition: height 0.1s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.plyr__progress__container:hover input[type=range] {
|
|
||||||
height: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.plyr__control {
|
|
||||||
background: transparent !important;
|
|
||||||
border-radius: 4px;
|
|
||||||
transition: transform 0.1s, opacity 0.2s;
|
|
||||||
padding: 7px !important;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.plyr__control:hover {
|
.plyr__control:hover {
|
||||||
background: rgba(255,255,255,0.1) !important;
|
background: rgba(255, 255, 255, 0.12) !important;
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.plyr__control svg {
|
|
||||||
width: 24px;
|
|
||||||
height: 24px;
|
|
||||||
filter: drop-shadow(0 1px 2px rgba(0,0,0,0.5));
|
|
||||||
}
|
|
||||||
|
|
||||||
.plyr__control--overlaid {
|
|
||||||
background: rgba(0, 0, 0, 0.6) !important;
|
|
||||||
border: 2px solid white;
|
|
||||||
border-radius: 50%;
|
|
||||||
padding: 1.5rem !important;
|
|
||||||
opacity: 0.9;
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.plyr__control--overlaid svg {
|
|
||||||
width: 32px;
|
|
||||||
height: 32px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.plyr__control--overlaid:hover {
|
|
||||||
background: var(--accent) !important;
|
|
||||||
border-color: var(--accent);
|
|
||||||
transform: scale(1.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.plyr__time {
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 500;
|
|
||||||
text-shadow: 0 1px 2px rgba(0,0,0,0.8);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.plyr__menu__container {
|
.plyr__menu__container {
|
||||||
background: rgba(28, 28, 30, 0.95) !important;
|
background: var(--glass-bg) !important;
|
||||||
backdrop-filter: blur(12px);
|
backdrop-filter: blur(16px);
|
||||||
border: 1px solid rgba(255,255,255,0.1);
|
border: 1px solid var(--glass-border);
|
||||||
border-radius: 12px !important;
|
border-radius: var(--radius-md) !important;
|
||||||
padding: 8px;
|
box-shadow: var(--shadow-lg) !important;
|
||||||
box-shadow: 0 10px 40px rgba(0,0,0,0.5) !important;
|
|
||||||
bottom: 60px !important;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.plyr__menu__container .plyr__control {
|
@media (max-width: 1100px) {
|
||||||
font-size: 13px;
|
.watch-container {
|
||||||
font-weight: 500;
|
grid-template-columns: 1fr;
|
||||||
padding: 8px 12px !important;
|
padding-top: 5rem;
|
||||||
border-radius: 6px;
|
|
||||||
justify-content: flex-start;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.plyr__menu__container .plyr__control:hover {
|
.episodes-sidebar {
|
||||||
background: rgba(255,255,255,0.1) !important;
|
position: fixed;
|
||||||
|
top: 5rem;
|
||||||
|
right: 0;
|
||||||
|
left: 0;
|
||||||
|
width: auto;
|
||||||
|
height: auto;
|
||||||
|
max-height: 80vh;
|
||||||
|
margin: 0 1rem;
|
||||||
|
z-index: 999;
|
||||||
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.plyr__menu__container .plyr__control[aria-checked="true"] {
|
.episodes-sidebar.sidebar-open {
|
||||||
color: var(--accent);
|
display: flex;
|
||||||
}
|
|
||||||
.plyr__menu__container .plyr__control[aria-checked="true"]::after {
|
|
||||||
background: var(--accent);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.plyr__tooltip {
|
.close-sidebar-btn {
|
||||||
background: rgba(28, 28, 30, 0.9);
|
display: flex;
|
||||||
border: 1px solid rgba(255,255,255,0.1);
|
|
||||||
border-radius: 4px;
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 600;
|
|
||||||
padding: 4px 8px;
|
|
||||||
box-shadow: 0 2px 10px rgba(0,0,0,0.5);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.plyr__cues {
|
.episode-list {
|
||||||
margin-bottom: 50px !important;
|
grid-template-columns: repeat(auto-fill, minmax(60px, 1fr));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.plyr__cues span {
|
@media (max-width: 768px) {
|
||||||
background-color: rgba(0, 0, 0, 0.75) !important;
|
.watch-container {
|
||||||
font-family: 'Inter', sans-serif;
|
padding: 4.5rem 1rem 1rem;
|
||||||
font-weight: 600;
|
|
||||||
font-size: 18px;
|
|
||||||
padding: 4px 12px;
|
|
||||||
border-radius: 4px;
|
|
||||||
text-shadow: 0 2px 4px rgba(0,0,0,0.8);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.extension-select option {
|
.anime-details {
|
||||||
background: var(--bg-overlay);
|
padding: 0 1rem var(--spacing-xl);
|
||||||
color: var(--text-primary);
|
|
||||||
padding: 0.8rem 1rem;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
font-weight: 500;
|
|
||||||
border: none;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.extension-select option:hover {
|
.details-container {
|
||||||
background: rgba(139, 92, 246, 0.2);
|
flex-direction: column;
|
||||||
color: white;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.extension-select option:checked {
|
.cover-image {
|
||||||
background: var(--accent);
|
width: 180px;
|
||||||
color: white;
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-toolbar {
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.episode-controls {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--spacing-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.navigation-buttons {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-btn {
|
||||||
|
flex: 1;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.episodes-sidebar {
|
||||||
|
margin: 0 0.5rem;
|
||||||
|
padding: var(--spacing-md);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.episode-info h1 {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.details-content h2 {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-btn span {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
161
views/watch.html
161
views/watch.html
@@ -6,69 +6,176 @@
|
|||||||
<base href="/">
|
<base href="/">
|
||||||
<title>WaifuBoard Watch</title>
|
<title>WaifuBoard Watch</title>
|
||||||
<link rel="stylesheet" href="/views/css/anime/watch.css">
|
<link rel="stylesheet" href="/views/css/anime/watch.css">
|
||||||
|
|
||||||
<script src="https://cdn.jsdelivr.net/npm/hls.js@latest"></script>
|
<script src="https://cdn.jsdelivr.net/npm/hls.js@latest"></script>
|
||||||
<link rel="stylesheet" href="https://cdn.plyr.io/3.7.8/plyr.css" />
|
<link rel="stylesheet" href="https://cdn.plyr.io/3.7.8/plyr.css" />
|
||||||
<script src="https://cdn.plyr.io/3.7.8/plyr.js"></script>
|
<script src="https://cdn.plyr.io/3.7.8/plyr.js"></script>
|
||||||
<link rel="icon" href="/public/assets/waifuboards.ico" type="image/x-icon">
|
<link rel="icon" href="/public/assets/waifuboards.ico">
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
||||||
<div class="top-bar">
|
<header class="top-bar">
|
||||||
<a href="#" id="back-link" class="back-btn">
|
<a href="#" id="back-link" class="back-btn">
|
||||||
<svg width="20" height="20" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24"><path d="M15 19l-7-7 7-7"/></svg>
|
<svg width="18" height="18" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24">
|
||||||
Back to Series
|
<path d="M15 19l-7-7 7-7"/>
|
||||||
|
</svg>
|
||||||
|
<span>Back to Series</span>
|
||||||
</a>
|
</a>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="ui-scale">
|
||||||
|
|
||||||
|
<section class="anime-details">
|
||||||
|
<div class="details-container">
|
||||||
|
<div class="details-cover">
|
||||||
|
<img id="detail-cover-image" src="" alt="Anime Cover" class="cover-image">
|
||||||
</div>
|
</div>
|
||||||
|
<div class="details-content">
|
||||||
|
<h1 id="detail-anime-title"></h1>
|
||||||
|
<div class="details-meta">
|
||||||
|
<span id="detail-format" class="meta-badge">--</span>
|
||||||
|
<span id="detail-season" class="meta-badge">--</span>
|
||||||
|
<span id="detail-score" class="meta-badge meta-score">--</span>
|
||||||
|
</div>
|
||||||
|
<p id="detail-description" class="details-description">Loading description...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<main class="watch-container">
|
||||||
|
|
||||||
<div class="theater-container">
|
<section class="player-section">
|
||||||
|
|
||||||
|
|
||||||
<div class="player-toolbar">
|
<div class="player-toolbar">
|
||||||
<div class="sd-toggle" id="sd-toggle" data-state="sub" style="display: none;" onclick="toggleAudioMode()">
|
|
||||||
|
<div class="control-group">
|
||||||
|
<div class="sd-toggle" id="sd-toggle" data-state="sub" onclick="toggleAudioMode()">
|
||||||
<div class="sd-bg"></div>
|
<div class="sd-bg"></div>
|
||||||
<div class="sd-option active" id="opt-sub">Sub</div>
|
<div class="sd-option active" id="opt-sub">Sub</div>
|
||||||
<div class="sd-option" id="opt-dub">Dub</div>
|
<div class="sd-option" id="opt-dub">Dub</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<select id="server-select" class="extension-select" style="display:none;" onchange="loadStream()">
|
<div class="control-group">
|
||||||
|
<select id="server-select" class="source-select" onchange="loadStream()" style="display:none;">
|
||||||
|
<option value="">Server...</option>
|
||||||
</select>
|
</select>
|
||||||
|
<select id="extension-select" class="source-select" onchange="onExtensionChange()">
|
||||||
<select id="extension-select" class="extension-select" onchange="onExtensionChange()">
|
|
||||||
<option value="" disabled selected>Source...</option>
|
<option value="" disabled selected>Source...</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<button id="toggle-episodes" class="toggle-episodes-btn">
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<rect x="3" y="4" width="18" height="3" rx="1"/>
|
||||||
|
<rect x="3" y="10.5" width="18" height="3" rx="1"/>
|
||||||
|
<rect x="3" y="17" width="18" height="3" rx="1"/>
|
||||||
|
</svg>
|
||||||
|
<span>Episodes</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="video-wrapper">
|
<div class="video-container">
|
||||||
<video id="player" controls crossorigin playsinline poster=""></video>
|
<video id="player" controls crossorigin playsinline></video>
|
||||||
<div id="loading-overlay" class="loading-overlay">
|
<div id="loading-overlay" class="loading-overlay">
|
||||||
<div class="spinner"></div>
|
<div class="spinner"></div>
|
||||||
<span id="loading-text">Select a source...</span>
|
<p id="loading-text">Select a source...</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="episode-controls">
|
||||||
<div class="controls-area">
|
|
||||||
<div class="episode-info">
|
<div class="episode-info">
|
||||||
<h2 id="anime-title">Loading...</h2>
|
<h1 id="anime-title">Loading...</h1>
|
||||||
<span id="episode-label">Episode --</span>
|
<p id="episode-label">Episode --</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="navigation-buttons">
|
||||||
|
<button class="nav-btn prev-btn" id="prev-btn">
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<path d="M15 19l-7-7 7-7"/>
|
||||||
|
</svg>
|
||||||
|
<span>Previous</span>
|
||||||
|
</button>
|
||||||
|
<button class="nav-btn next-btn" id="next-btn">
|
||||||
|
<span>Next</span>
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<path d="M9 5l7 7-7 7"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<div class="nav-controls">
|
<aside id="episodes-sidebar" class="episodes-sidebar">
|
||||||
<button class="nav-btn" id="prev-btn">
|
<div class="sidebar-header">
|
||||||
<svg width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M15 19l-7-7 7-7"/></svg>
|
<h3>Episodes</h3>
|
||||||
Prev
|
<button id="close-sidebar" class="close-sidebar-btn" aria-label="Close episodes">
|
||||||
</button>
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
<button class="nav-btn" id="next-btn">
|
<path d="M18 6L6 18M6 6l12 12"/>
|
||||||
Next
|
</svg>
|
||||||
<svg width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M9 5l7 7-7 7"/></svg>
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="sidebar-search">
|
||||||
|
<input type="text" id="episode-search" class="episode-search-input" placeholder="Search episode..." oninput="filterEpisodes()">
|
||||||
</div>
|
</div>
|
||||||
|
<div id="episode-list" class="episode-list"></div>
|
||||||
|
</aside>
|
||||||
|
</main>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="../src/scripts/anime/player.js"></script>
|
<script src="../src/scripts/anime/player.js"></script>
|
||||||
|
<script>
|
||||||
|
const watchContainer = document.querySelector('.watch-container');
|
||||||
|
const sidebar = document.getElementById('episodes-sidebar');
|
||||||
|
const toggleBtn = document.getElementById('toggle-episodes');
|
||||||
|
const closeBtn = document.getElementById('close-sidebar');
|
||||||
|
const DESKTOP_BREAKPOINT = 1100;
|
||||||
|
|
||||||
|
function isDesktop() {
|
||||||
|
return window.innerWidth > DESKTOP_BREAKPOINT;
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleSidebar = () => {
|
||||||
|
const isOpen = sidebar.classList.contains('sidebar-open') ||
|
||||||
|
!watchContainer.classList.contains('sidebar-hidden');
|
||||||
|
|
||||||
|
if (isDesktop()) {
|
||||||
|
watchContainer.classList.toggle('sidebar-hidden');
|
||||||
|
toggleBtn.classList.toggle('active', !watchContainer.classList.contains('sidebar-hidden'));
|
||||||
|
} else {
|
||||||
|
sidebar.classList.toggle('sidebar-open');
|
||||||
|
toggleBtn.classList.toggle('active', sidebar.classList.contains('sidebar-open'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
toggleBtn.addEventListener('click', toggleSidebar);
|
||||||
|
|
||||||
|
closeBtn.addEventListener('click', () => {
|
||||||
|
if (isDesktop()) {
|
||||||
|
watchContainer.classList.add('sidebar-hidden');
|
||||||
|
toggleBtn.classList.remove('active');
|
||||||
|
} else {
|
||||||
|
sidebar.classList.remove('sidebar-open');
|
||||||
|
toggleBtn.classList.remove('active');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function handleResize() {
|
||||||
|
if (isDesktop()) {
|
||||||
|
sidebar.classList.remove('sidebar-open');
|
||||||
|
if (!watchContainer.classList.contains('sidebar-hidden')) {
|
||||||
|
toggleBtn.classList.add('active');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
watchContainer.classList.remove('sidebar-hidden');
|
||||||
|
sidebar.classList.remove('sidebar-open');
|
||||||
|
toggleBtn.classList.remove('active');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('resize', handleResize);
|
||||||
|
handleResize();
|
||||||
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
Reference in New Issue
Block a user