Merge pull request #4 from Radiquum/feat__customPlayer
Some checks are pending
V3 Preview Deployment / Deploy-Preview (push) Waiting to run

Feat  custom player
This commit is contained in:
Kentai Radiquum 2025-03-16 13:33:51 +05:00 committed by GitHub
commit b0ba6c9efc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
23 changed files with 971 additions and 132 deletions

View file

@ -1,3 +1,7 @@
{
"extends": "next/core-web-vitals"
"extends": ["next/core-web-vitals", "prettier"],
"rules": {
"prettier/prettier": "error"
},
"plugins": ["prettier"]
}

7
.gitignore vendored
View file

@ -52,3 +52,10 @@ traefik/traefik
old/
#Trigger Vercel Prod Build
# next-video
videos/*
!videos/*.json
!videos/*.js
!videos/*.ts
public/_next-video

View file

@ -6,10 +6,10 @@ AniX is an unofficial web client for the Android application Anixart. It allows
## Changelog [RU]
- [3.3.0](./public/changelog/3.3.0.md)
- [3.2.3](./public/changelog/3.2.3.md)
- [3.2.2](./public/changelog/3.2.2.md)
- [3.2.1](./public/changelog/3.2.1.md)
- [3.2.0](./public/changelog/3.2.0.md)
[other versions](./public/changelog)

View file

@ -1,4 +1,4 @@
export const CURRENT_APP_VERSION = "3.2.3";
export const CURRENT_APP_VERSION = "3.3.0";
export const API_URL = "https://api.anixart.tv";
export const API_PREFIX = "/api/proxy";
@ -10,6 +10,7 @@ export const ENDPOINTS = {
info: `${API_PREFIX}/release`,
episode: `${API_PREFIX}/episode`,
related: `${API_PREFIX}/related`,
licensed: `${API_PREFIX}/release/streaming/platform`,
},
user: {
profile: `${API_PREFIX}/profile`,

View file

@ -144,15 +144,6 @@ export function unixToDate(
);
}
export const getSeasonFromUnix = (unix: number) => {
const date = new Date(unix * 1000);
const month = date.getMonth();
if (month >= 3 && month <= 5) return "весна";
if (month >= 6 && month <= 8) return "лето";
if (month >= 9 && month <= 11) return "осень";
return "зима";
};
export function sinceUnixDate(unixInSeconds: number) {
const unix = Math.floor(unixInSeconds * 1000);
const date = new Date(unix);

View file

@ -1,8 +1,10 @@
import { Card, Button } from "flowbite-react";
import { useState } from "react";
import Image from "next/image";
import { ReleaseInfoStreaming } from "./ReleaseInfo.LicensedPlatforms";
export const ReleaseInfoBasics = (props: {
release_id: number;
image: string;
title: { ru: string; original: string };
note: string | null;
@ -52,6 +54,7 @@ export const ReleaseInfoBasics = (props: {
>
{isFullDescription ? "Скрыть" : "Показать полностью"}
</Button>
<ReleaseInfoStreaming release_id={props.release_id} />
</div>
</div>
</Card>

View file

@ -1,6 +1,6 @@
import { Card, Table } from "flowbite-react";
import { ReleaseInfoSearchLink } from "#/components/ReleaseInfo/ReleaseInfo.SearchLink";
import { unixToDate, getSeasonFromUnix, minutesToTime } from "#/api/utils";
import { unixToDate, minutesToTime } from "#/api/utils";
const weekDay = [
"_",
"каждый понедельник",
@ -33,21 +33,20 @@ export const ReleaseInfoInfo = (props: {
<Table.Body>
<Table.Row>
<Table.Cell className="py-0">
{props.country ? (
props.country.toLowerCase() == "япония" ? (
{props.country ?
props.country.toLowerCase() == "япония" ?
<span className="w-8 h-8 iconify-color twemoji--flag-for-japan"></span>
) : (
<span className="w-8 h-8 iconify-color twemoji--flag-for-china"></span>
)
) : (
<span className="w-8 h-8 iconify-color twemoji--flag-for-united-nations "></span>
)}
: <span className="w-8 h-8 iconify-color twemoji--flag-for-china"></span>
: <span className="w-8 h-8 iconify-color twemoji--flag-for-united-nations "></span>
}
</Table.Cell>
<Table.Cell className="font-medium text-gray-900 whitespace-nowrap dark:text-white">
{props.country && props.country}
{(props.aired_on_date != 0 || props.year) && ", "}
{props.aired_on_date != 0 &&
`${getSeasonFromUnix(props.aired_on_date)} `}
{props.season && props.season != 0 ?
`${YearSeason[props.season]} `
: ""}
{props.year && `${props.year} г.`}
</Table.Cell>
</Table.Row>
@ -59,7 +58,8 @@ export const ReleaseInfoInfo = (props: {
{props.episodes.released ? props.episodes.released : "?"}
{"/"}
{props.episodes.total ? props.episodes.total + " эп. " : "? эп. "}
{props.duration != 0 && `по ${minutesToTime(props.duration, "daysHours")}`}
{props.duration != 0 &&
`по ${minutesToTime(props.duration, "daysHours")}`}
</Table.Cell>
</Table.Row>
<Table.Row>
@ -69,8 +69,8 @@ export const ReleaseInfoInfo = (props: {
<Table.Cell className="font-medium text-gray-900 dark:text-white">
{props.category}
{", "}
{props.broadcast == 0
? props.status.toLowerCase()
{props.broadcast == 0 ?
props.status.toLowerCase()
: `выходит ${weekDay[props.broadcast]}`}
</Table.Cell>
</Table.Row>
@ -88,7 +88,10 @@ export const ReleaseInfoInfo = (props: {
return (
<div key={index} className="inline">
{index > 0 && ", "}
<ReleaseInfoSearchLink title={studio} searchBy={"studio"} />
<ReleaseInfoSearchLink
title={studio}
searchBy={"studio"}
/>
</div>
);
})}
@ -98,14 +101,20 @@ export const ReleaseInfoInfo = (props: {
{props.author && (
<>
{"Автор: "}
<ReleaseInfoSearchLink title={props.author} searchBy={"author"} />
<ReleaseInfoSearchLink
title={props.author}
searchBy={"author"}
/>
{props.director && ", "}
</>
)}
{props.director && (
<>
{"Режиссёр: "}
<ReleaseInfoSearchLink title={props.director} searchBy={"director"} />
<ReleaseInfoSearchLink
title={props.director}
searchBy={"director"}
/>
</>
)}
</Table.Cell>
@ -132,18 +141,16 @@ export const ReleaseInfoInfo = (props: {
<span className="w-8 h-8 iconify-color mdi--clock-outline dark:invert"></span>
</Table.Cell>
<Table.Cell className="font-medium text-gray-900 whitespace-nowrap dark:text-white">
{props.aired_on_date != 0 ? (
{props.aired_on_date != 0 ?
unixToDate(props.aired_on_date, "full")
) : props.year ? (
: props.year ?
<>
{props.season && props.season != 0
? `${YearSeason[props.season]} `
{props.season && props.season != 0 ?
`${YearSeason[props.season]} `
: ""}
{props.year && `${props.year} г.`}
</>
) : (
"Скоро"
)}
: "Скоро"}
</Table.Cell>
</Table.Row>
)}

View file

@ -0,0 +1,45 @@
"use client";
import { ENDPOINTS } from "#/api/config";
import { useEffect, useState } from "react";
export const ReleaseInfoStreaming = (props: { release_id: number }) => {
const [data, setData] = useState(null);
useEffect(() => {
const _getData = async () => {
const response = await fetch(
`${ENDPOINTS.release.licensed}/${props.release_id}`
);
setData(await response.json());
};
_getData();
}, []);
return (
<>
{!data ?
""
: !(data.content.length > 0) ?
""
: <div>
<p className="mt-4 mb-1 text-lg">Официальные источники: </p>
<div className="grid grid-cols-2 gap-2 md:grid-cols-4">
{data.content.map((item: any) => {
return (
<a
href={item.url}
key={`platform_${item.id}`}
className="flex items-center gap-2 px-2 py-1 transition-colors bg-gray-100 rounded-lg hover:bg-gray-300 dark:bg-gray-700 dark:hover:bg-gray-600 "
>
<img src={item.icon} className="w-6 h-6 rounded-full" />
<p className="text-lg">{item.name}</p>
</a>
);
})}
</div>
</div>
}
</>
);
};

View file

@ -0,0 +1,131 @@
"use client";
import { ENDPOINTS } from "#/api/config";
import { useState, useEffect } from "react";
import { Swiper, SwiperSlide } from "swiper/react";
import "swiper/css";
import "swiper/css/navigation";
import "swiper/css/mousewheel";
import "swiper/css/scrollbar";
import { Navigation, Mousewheel, Scrollbar } from "swiper/modules";
import { Button } from "flowbite-react";
import {
getAnonEpisodesWatched,
saveAnonEpisodeWatched,
} from "./ReleasePlayer";
interface Episode {
id: number;
position: number;
name: string;
is_watched: boolean;
}
interface Source {
id: number;
name: string;
episodes_count: number;
}
export const EpisodeSelector = (props: {
availableEpisodes: Episode[];
episode: Episode;
setEpisode: any;
source: Source;
release_id: any;
voiceover: any;
token: string | null;
}) => {
let anonEpisodesWatched = getAnonEpisodesWatched(
props.release_id,
props.source.id,
props.voiceover.id
);
anonEpisodesWatched =
anonEpisodesWatched[props.release_id][props.source.id][props.voiceover.id];
async function saveEpisodeToHistory(episode: Episode) {
if (episode && props.token) {
fetch(
`${ENDPOINTS.statistic.addHistory}/${props.release_id}/${props.source.id}/${episode.position}?token=${props.token}`
);
fetch(
`${ENDPOINTS.statistic.markWatched}/${props.release_id}/${props.source.id}/${episode.position}?token=${props.token}`
);
}
}
return (
<div>
<Swiper
modules={[Navigation, Mousewheel, Scrollbar]}
spaceBetween={8}
slidesPerView={"auto"}
direction={"horizontal"}
mousewheel={{
enabled: true,
sensitivity: 4,
}}
scrollbar={true}
allowTouchMove={true}
style={
{
"--swiper-scrollbar-bottom": "0",
} as React.CSSProperties
}
>
{props.availableEpisodes.map((episode: Episode) => (
<SwiperSlide
key={`episode_${episode.position}`}
style={{ maxWidth: "fit-content" }}
>
<Button
color={
props.episode.position === episode.position ? "blue" : "light"
}
theme={{ base: "w-full disabled:opacity-100" }}
onClick={() => {
if (["Sibnet"].includes(props.source.name)) {
props.availableEpisodes[episode.position].is_watched = true;
} else {
props.availableEpisodes[episode.position - 1].is_watched =
true;
}
saveAnonEpisodeWatched(
props.release_id,
props.source.id,
props.voiceover.id,
episode.position
);
saveEpisodeToHistory(episode);
props.setEpisode({
selected: { ...episode, is_watched: true },
available: props.availableEpisodes,
});
}}
disabled={props.episode.position === episode.position}
>
<div className="flex items-center">
{episode.name ?
episode.name
: ["Sibnet"].includes(props.source.name) ?
`${episode.position + 1} Серия`
: `${episode.position} Серия`}
{(
episode.is_watched ||
Object.keys(anonEpisodesWatched).includes(
episode.position.toString()
)
) ?
<span className="w-4 h-4 ml-2 iconify material-symbols--check-circle"></span>
: <span className="w-4 h-4 ml-2 iconify material-symbols--check-circle-outline"></span>
}
</div>
</Button>
</SwiperSlide>
))}
</Swiper>
</div>
);
};

View file

@ -32,7 +32,7 @@ async function _fetch(url: string) {
return data;
}
const getAnonEpisodesWatched = (
export const getAnonEpisodesWatched = (
Release: number,
Source: number,
Voiceover: number
@ -80,7 +80,7 @@ const getAnonCurrentEpisodeWatched = (
return anonEpisodesWatched[Release][Source][Voiceover][Episode];
};
const saveAnonEpisodeWatched = (
export const saveAnonEpisodeWatched = (
Release: number,
Source: number,
Voiceover: number,
@ -162,32 +162,25 @@ export const ReleasePlayer = (props: { id: number }) => {
return;
});
if (data && Object.keys(data).length == 0) {
if (!data || (data && Object.keys(data).length == 0)) {
_setError("Ошибка получение данных с сервера");
return;
}
if (type == "voiceover") {
if (data.types.length > 0) {
setVoiceoverInfo(data.types);
const preferredVoiceover =
data.types.find(
(voiceover: any) => voiceover.name === storedPreferredVoiceover
) || data.types[0];
setSelectedVoiceover(preferredVoiceover);
} else {
_setError("Ошибка получения озвучек");
}
} else if (type == "sources") {
if (data.sources.length > 0) {
setSourcesInfo(data.sources);
const preferredSource =
data.sources.find(
(source: any) => source.name === storedPreferredPlayer
) || data.sources[0];
setSelectedSource(preferredSource);
} else {
_setError("Ошибка получения источников");
}
} else if (type == "episodes") {
if (data.episodes.length === 0) {
const remSources = sourcesInfo.filter(
@ -196,7 +189,7 @@ export const ReleasePlayer = (props: { id: number }) => {
setSourcesInfo(remSources);
setSelectedSource(remSources[0]);
return;
} else if (data.episodes.length > 0) {
} else {
setEpisodeInfo(data.episodes);
setSelectedEpisode(data.episodes[0]);
@ -220,8 +213,6 @@ export const ReleasePlayer = (props: { id: number }) => {
}
setSelectedEpisode(data.episodes[lastWatchedEpisode]);
}
} else {
_setError("Ошибка получения эпизодов");
}
} else {
_setError("Неизвестный тип запроса");

View file

@ -0,0 +1,298 @@
"use client";
import { Card } from "flowbite-react";
import { useEffect, useState } from "react";
import { ENDPOINTS } from "#/api/config";
import { VoiceoverSelector } from "./VoiceoverSelector";
import { SourceSelector } from "./SourceSelector";
import { EpisodeSelector } from "./EpisodeSelector";
import { Spinner } from "../Spinner/Spinner";
import { useUserPlayerPreferencesStore } from "#/store/player";
import HlsVideo from "hls-video-element/react";
import MediaThemeSutro from "player.style/sutro/react";
import { getAnonEpisodesWatched } from "./ReleasePlayer";
export const ReleasePlayerCustom = (props: {
id: number;
token: string | null;
}) => {
const [voiceover, setVoiceover] = useState({
selected: null,
available: null,
});
const [source, setSource] = useState({
selected: null,
available: null,
});
const [episode, setEpisode] = useState({
selected: null,
available: null,
});
const [playerProps, SetPlayerProps] = useState({
src: null,
poster: null,
type: null,
useCustom: false,
});
const playerPreferenceStore = useUserPlayerPreferencesStore();
const preferredVO = playerPreferenceStore.getPreferredVoiceover(props.id);
const preferredSource = playerPreferenceStore.getPreferredPlayer(props.id);
const _fetchVoiceover = async (release_id: number) => {
let url = `${ENDPOINTS.release.episode}/${release_id}`;
if (props.token) {
url += `?token=${props.token}`;
}
const response = await fetch(url);
const data = await response.json();
return data;
};
const _fetchSource = async (release_id: number, voiceover_id: number) => {
const response = await fetch(
`${ENDPOINTS.release.episode}/${release_id}/${voiceover_id}`
);
const data = await response.json();
return data;
};
const _fetchEpisode = async (
release_id: number,
voiceover_id: number,
source_id: number
) => {
let url = `${ENDPOINTS.release.episode}/${release_id}/${voiceover_id}/${source_id}`;
if (props.token) {
url += `?token=${props.token}`;
}
const response = await fetch(url);
const data = await response.json();
return data;
};
const _fetchKodikManifest = async (url: string) => {
const response = await fetch(
`https://anix-player.wah.su/?url=${url}&player=kodik`
);
const data = await response.json();
let manifest = `https:${data.links["360"][0].src.replace("360.mp4:hls:", "")}`;
let poster = `https:${data.links["360"][0].src.replace("360.mp4:hls:manifest.m3u8", "thumb001.jpg")}`;
return { manifest, poster };
};
const _fetchAnilibriaManifest = async (url: string) => {
const id = url.split("?id=")[1].split("&ep=")[0];
const response = await fetch(`https://api.anilibria.tv/v3/title?id=${id}`);
const data = await response.json();
const host = `https://${data.player.host}`;
const ep = data.player.list[episode.selected.position];
const blobTxt = `#EXTM3U\n${ep.hls.sd && `#EXT-X-STREAM-INF:RESOLUTION=854x480,BANDWIDTH=596000\n${host}${ep.hls.sd}\n`}${ep.hls.hd && `#EXT-X-STREAM-INF:RESOLUTION=1280x720,BANDWIDTH=1280000\n${host}${ep.hls.hd}\n`}${ep.hls.fhd && `#EXT-X-STREAM-INF:RESOLUTION=1920x1080,BANDWIDTH=2560000\n${host}${ep.hls.fhd}\n`}`;
const blob = new Blob([blobTxt], { type: "application/x-mpegURL" });
let file = new File([blobTxt], "manifest.m3u8", {
type: "application/x-mpegURL",
});
let manifest = URL.createObjectURL(file);
let poster = `https://anixart.libria.fun${ep.preview}`;
return { manifest, poster };
};
const _fetchSibnetManifest = async (url: string) => {
const response = await fetch(
`https://sibnet.anix-player.wah.su/?url=${url}`
);
const data = await response.json();
let manifest = data.video;
let poster = data.poster;
return { manifest, poster };
};
useEffect(() => {
const __getInfo = async () => {
const vo = await _fetchVoiceover(props.id);
const selectedVO =
vo.types.find((voiceover: any) => voiceover.name === preferredVO) ||
vo.types[0];
setVoiceover({
selected: selectedVO,
available: vo.types,
});
};
__getInfo();
}, []);
useEffect(() => {
const __getInfo = async () => {
const src = await _fetchSource(props.id, voiceover.selected.id);
const selectedSrc =
src.sources.find((source: any) => source.name === preferredSource) ||
src.sources[0];
if (selectedSrc.episodes_count == 0) {
const remSources = src.sources.filter(
(source: any) => source.id !== selectedSrc.id
);
setSource({
selected: remSources[0],
available: remSources,
});
return;
}
setSource({
selected: selectedSrc,
available: src.sources,
});
};
if (voiceover.selected) {
__getInfo();
}
}, [voiceover.selected]);
useEffect(() => {
const __getInfo = async () => {
const episodes = await _fetchEpisode(
props.id,
voiceover.selected.id,
source.selected.id
);
let anonEpisodesWatched = getAnonEpisodesWatched(
props.id,
source.selected.id,
voiceover.selected.id
);
let lastEpisodeWatched = Math.max.apply(
0,
Object.keys(
anonEpisodesWatched[props.id][source.selected.id][
voiceover.selected.id
]
)
);
let selectedEpisode =
episodes.episodes.find(
(episode: any) => episode.position == lastEpisodeWatched
) || episodes.episodes[0];
setEpisode({
selected: selectedEpisode,
available: episodes.episodes,
});
};
if (source.selected) {
__getInfo();
}
}, [source.selected]);
useEffect(() => {
const __getInfo = async () => {
if (source.selected.name == "Kodik") {
const { manifest, poster } = await _fetchKodikManifest(
episode.selected.url
);
SetPlayerProps({
src: manifest,
poster: poster,
useCustom: true,
type: "hls",
});
return;
}
if (source.selected.name == "Libria") {
const { manifest, poster } = await _fetchAnilibriaManifest(
episode.selected.url
);
SetPlayerProps({
src: manifest,
poster: poster,
useCustom: true,
type: "hls",
});
return;
}
if (source.selected.name == "Sibnet") {
const { manifest, poster } = await _fetchSibnetManifest(
episode.selected.url
);
SetPlayerProps({
src: manifest,
poster: poster,
useCustom: true,
type: "mp4",
});
return;
}
SetPlayerProps({
src: episode.selected.url,
poster: null,
useCustom: false,
type: null,
});
};
if (episode.selected) {
__getInfo();
}
}, [episode.selected]);
return (
<Card className="h-full">
{(
!voiceover.selected ||
!source.selected ||
!episode.selected ||
!playerProps.src
) ?
<div className="flex items-center justify-center w-full aspect-video">
<Spinner />
</div>
: <div className="flex flex-col gap-4">
<div className="flex flex-wrap gap-4">
<VoiceoverSelector
availableVoiceover={voiceover.available}
voiceover={voiceover.selected}
setVoiceover={setVoiceover}
release_id={props.id}
/>
<SourceSelector
availableSource={source.available}
source={source.selected}
setSource={setSource}
release_id={props.id}
/>
</div>
{playerProps.useCustom ?
<MediaThemeSutro className="w-full aspect-video">
{playerProps.type == "hls" ?
<HlsVideo
slot="media"
src={playerProps.src}
poster={playerProps.poster}
/>
: <video
slot="media"
src={playerProps.src}
poster={playerProps.poster}
></video>
}
</MediaThemeSutro>
: <iframe src={playerProps.src} className="w-full aspect-video" />}
<EpisodeSelector
availableEpisodes={episode.available}
episode={episode.selected}
setEpisode={setEpisode}
release_id={props.id}
source={source.selected}
voiceover={voiceover.selected}
token={props.token}
/>
</div>
}
</Card>
);
};

View file

@ -0,0 +1,76 @@
"use client";
import { Dropdown } from "flowbite-react";
import { numberDeclension } from "#/api/utils";
import { useUserPlayerPreferencesStore } from "#/store/player";
interface Source {
id: number;
name: string;
episodes_count: number;
}
const DropdownTrigger = ({ name }: Source) => {
return (
<div className="flex items-center gap-1 cursor-pointer">
<span className="w-6 h-6 iconify material-symbols--motion-play"></span>
<p>{name}</p>
<span className="w-6 h-6 iconify material-symbols--arrow-drop-down"></span>
</div>
);
};
const DropdownItem = ({ name, episodes_count }: Source) => {
return (
<div className="flex flex-col gap-2 cursor-pointer">
<div className="flex items-center gap-2">
<p>{name}</p>
</div>
<div className="flex items-center gap-2">
<p>
{episodes_count}{" "}
{numberDeclension(episodes_count, "серия", "серии", "серий")}
</p>
</div>
</div>
);
};
export const SourceSelector = (props: {
availableSource: Source[];
source: Source;
setSource: any;
release_id: any;
}) => {
const playerPreferenceStore = useUserPlayerPreferencesStore();
return (
<Dropdown
label=""
dismissOnClick={true}
renderTrigger={() => (
<span>
<DropdownTrigger {...props.source} />
</span>
)}
>
{props.availableSource.map((source: Source) => (
<Dropdown.Item
key={`source_${source.id}`}
onClick={() => {
playerPreferenceStore.setPreferredPlayer(
props.release_id,
source.name
);
props.setSource({
selected: source,
available: props.availableSource,
});
}}
>
<DropdownItem {...source} />
</Dropdown.Item>
))}
</Dropdown>
);
};

View file

@ -0,0 +1,102 @@
"use client";
import { Dropdown } from "flowbite-react";
import { numberDeclension } from "#/api/utils";
import { useUserPlayerPreferencesStore } from "#/store/player";
interface Voiceover {
id: number;
name: string;
icon: string;
episodes_count: number;
view_count: number;
pinned: boolean;
}
const DropdownTrigger = ({ icon, name, pinned }: Voiceover) => {
return (
<div className="flex items-center gap-2 cursor-pointer">
{icon && <img className="w-6 h-6 rounded-full" src={icon}></img>}
<p>{name}</p>
{pinned && (
<span className="h-6 bg-gray-700 dark:bg-gray-300 iconify material-symbols--push-pin"></span>
)}
<span className="w-6 h-6 -ml-2 iconify material-symbols--arrow-drop-down"></span>
</div>
);
};
const DropdownItem = ({
icon,
name,
pinned,
episodes_count,
view_count,
}: Voiceover) => {
return (
<div className="flex flex-col gap-2 cursor-pointer">
<div className="flex items-center gap-2">
{icon && <img className="w-6 h-6 rounded-full" src={icon}></img>}
<p>{name}</p>
{pinned && (
<span className="h-6 iconify material-symbols--push-pin"></span>
)}
</div>
<div className="flex items-center gap-2">
<p>
{episodes_count}{" "}
{numberDeclension(episodes_count, "серия", "серии", "серий")}
</p>
<p>
{view_count}{" "}
{numberDeclension(view_count, "просмотр", "просмотра", "просмотров")}
</p>
</div>
</div>
);
};
const DropdownTheme = {
content: "md:grid md:grid-cols-2 xl:grid-cols-4 gap-2 w-full container",
};
export const VoiceoverSelector = (props: {
availableVoiceover: Voiceover[];
voiceover: Voiceover;
setVoiceover: any;
release_id: number;
}) => {
const playerPreferenceStore = useUserPlayerPreferencesStore();
return (
<Dropdown
theme={DropdownTheme}
label=""
dismissOnClick={true}
renderTrigger={() => (
<span>
<DropdownTrigger {...props.voiceover} />
</span>
)}
>
{props.availableVoiceover.map((voiceover: Voiceover) => (
<Dropdown.Item
className="w-fit"
key={`voiceover_${voiceover.id}`}
onClick={() => {
playerPreferenceStore.setPreferredVoiceover(
props.release_id,
voiceover.name
);
props.setVoiceover({
selected: voiceover,
available: props.availableVoiceover,
});
}}
>
<DropdownItem {...voiceover} />
</Dropdown.Item>
))}
</Dropdown>
);
};

View file

@ -41,8 +41,12 @@ export const SettingsModal = (props: { isOpen: boolean; setIsOpen: any }) => {
<Modal.Header>Настройки</Modal.Header>
<Modal.Body>
<div className="space-y-6">
<div className="flex items-center gap-2">
<span className="w-6 h-6 iconify material-symbols--palette-outline"></span>
<p className="text-lg font-bold dark:text-white">Интерфейс</p>
</div>
<div className="flex items-center justify-between">
<p className="font-bold dark:text-white">Тема</p>
<p className=" dark:text-white">Тема</p>
<Button.Group>
<Button
color={computedMode == "light" ? "blue" : "gray"}
@ -59,30 +63,7 @@ export const SettingsModal = (props: { isOpen: boolean; setIsOpen: any }) => {
</Button.Group>
</div>
<div className="flex items-center justify-between">
<p className="font-bold dark:text-white">
Показывать список изменений
</p>
<ToggleSwitch
color="blue"
theme={{
toggle: {
checked: {
color: {
blue: "border-blue-700 bg-blue-700",
},
},
},
}}
onChange={() =>
preferenceStore.setFlags({
showChangelog: !preferenceStore.flags.showChangelog,
})
}
checked={preferenceStore.flags.showChangelog}
/>
</div>
<div className="flex items-center justify-between">
<p className="font-bold dark:text-white max-w-96">
<p className=" dark:text-white max-w-96">
Пропускать страницу выбора категорий на страницах Домашняя и
Закладки
</p>
@ -111,10 +92,17 @@ export const SettingsModal = (props: { isOpen: boolean; setIsOpen: any }) => {
{preferenceStore.params.skipToCategory.enabled ? (
<>
<div className="flex items-center justify-between">
<p className="font-bold dark:text-white max-w-96">
<p className=" dark:text-white max-w-96">
Категория домашней страницы
</p>
<Dropdown color="blue" label={HomeCategory[preferenceStore.params.skipToCategory.homeCategory]}>
<Dropdown
color="blue"
label={
HomeCategory[
preferenceStore.params.skipToCategory.homeCategory
]
}
>
{Object.keys(HomeCategory).map((key) => {
return (
<Dropdown.Item
@ -135,10 +123,17 @@ export const SettingsModal = (props: { isOpen: boolean; setIsOpen: any }) => {
</Dropdown>
</div>
<div className="flex items-center justify-between">
<p className="font-bold dark:text-white max-w-96">
<p className=" dark:text-white max-w-96">
Категория страницы закладок
</p>
<Dropdown color="blue" label={BookmarksCategory[preferenceStore.params.skipToCategory.bookmarksCategory]}>
<Dropdown
color="blue"
label={
BookmarksCategory[
preferenceStore.params.skipToCategory.bookmarksCategory
]
}
>
{Object.keys(BookmarksCategory).map((key) => {
return (
<Dropdown.Item
@ -162,9 +157,35 @@ export const SettingsModal = (props: { isOpen: boolean; setIsOpen: any }) => {
) : (
""
)}
<HR className="my-4 dark:bg-slate-400" />
<div className="flex items-center gap-2">
<span className="w-6 h-6 iconify material-symbols--settings-outline"></span>
<p className="text-lg font-bold dark:text-white">Приложение</p>
</div>
<div className="flex items-center justify-between">
<p className=" dark:text-white">Показывать список изменений</p>
<ToggleSwitch
color="blue"
theme={{
toggle: {
checked: {
color: {
blue: "border-blue-700 bg-blue-700",
},
},
},
}}
onChange={() =>
preferenceStore.setFlags({
showChangelog: !preferenceStore.flags.showChangelog,
})
}
checked={preferenceStore.flags.showChangelog}
/>
</div>
<div className="flex items-center justify-between">
<div>
<p className="font-bold dark:text-white">Отправка аналитики</p>
<p className=" dark:text-white">Отправка аналитики</p>
<p className="text-gray-500 dark:text-gray-300">
Требуется перезагрузка для применения
</p>
@ -188,6 +209,40 @@ export const SettingsModal = (props: { isOpen: boolean; setIsOpen: any }) => {
checked={preferenceStore.flags.enableAnalytics}
/>
</div>
<HR className="my-4 dark:bg-slate-400" />
<div className="flex items-center gap-2">
<span className="w-6 h-6 iconify material-symbols--experiment-outline"></span>
<p className="text-lg font-bold dark:text-white">Эксперименты</p>
</div>
<div className="flex items-center justify-between">
<div>
<p className=" dark:text-white">Новый плеер</p>
<p className="text-gray-500 dark:text-gray-300">
Поддерживаемые источники: Kodik, Sibnet, Libria
</p>
</div>
<ToggleSwitch
color="blue"
theme={{
toggle: {
checked: {
color: {
blue: "border-blue-700 bg-blue-700",
},
},
},
}}
onChange={() =>
preferenceStore.setParams({
experimental: {
...preferenceStore.params.experimental,
newPlayer: !preferenceStore.params.experimental.newPlayer,
},
})
}
checked={preferenceStore.params.experimental.newPlayer}
/>
</div>
</div>
<HR className="my-4 dark:bg-slate-400" />
<div>

View file

@ -10,6 +10,7 @@ import { useEffect, useState } from "react";
import { ReleaseInfoBasics } from "#/components/ReleaseInfo/ReleaseInfo.Basics";
import { ReleaseInfoInfo } from "#/components/ReleaseInfo/ReleaseInfo.Info";
import { ReleasePlayer } from "#/components/ReleasePlayer/ReleasePlayer";
import { ReleasePlayerCustom } from "#/components/ReleasePlayer/ReleasePlayerCustom";
import { ReleaseInfoUserList } from "#/components/ReleaseInfo/ReleaseInfo.UserList";
import { ReleaseInfoRating } from "#/components/ReleaseInfo/ReleaseInfo.Rating";
import { ReleaseInfoRelated } from "#/components/ReleaseInfo/ReleaseInfo.Related";
@ -17,9 +18,11 @@ import { ReleaseInfoScreenshots } from "#/components/ReleaseInfo/ReleaseInfo.Scr
import { CommentsMain } from "#/components/Comments/Comments.Main";
import { InfoLists } from "#/components/InfoLists/InfoLists";
import { ENDPOINTS } from "#/api/config";
import { usePreferencesStore } from "#/store/preferences";
export const ReleasePage = (props: any) => {
const userStore = useUserStore();
const preferenceStore = usePreferencesStore();
const [userList, setUserList] = useState(0);
const [userFavorite, setUserFavorite] = useState(false);
@ -58,6 +61,7 @@ export const ReleasePage = (props: any) => {
}}
description={data.release.description}
note={data.release.note}
release_id={data.release.id}
/>
</div>
<div className="[grid-column:2]">
@ -92,12 +96,18 @@ export const ReleasePage = (props: any) => {
collection_count={data.release.collection_count}
/>
</div>
{data.release.status && data.release.status.name.toLowerCase() != "анонс" && (
{data.release.status &&
data.release.status.name.toLowerCase() != "анонс" && (
<div className="[grid-column:1] [grid-row:span_12]">
{preferenceStore.params.experimental.newPlayer ? (
<ReleasePlayerCustom id={props.id} token={userStore.token} />
) : (
<ReleasePlayer id={props.id} />
)}
</div>
)}
{data.release.status && data.release.status.name.toLowerCase() != "анонс" && (
{data.release.status &&
data.release.status.name.toLowerCase() != "анонс" && (
<div className="[grid-column:2]">
<ReleaseInfoRating
release_id={props.id}

View file

@ -18,6 +18,9 @@ interface preferencesState {
enabled: boolean;
homeCategory: string;
bookmarksCategory: string;
};
experimental?: {
newPlayer: boolean;
}
// color: {
// primary: string;
@ -47,6 +50,9 @@ export const usePreferencesStore = create<preferencesState>()(
enabled: false,
homeCategory: "last",
bookmarksCategory: "watching",
},
experimental: {
newPlayer: false
}
},
setHasHydrated: (state) => {

View file

@ -6,10 +6,10 @@ AniX - это неофициальный веб-клиент для Android-пр
## Список изменений
- [3.3.0](/public/changelog/3.3.0.md)
- [3.2.3](/public/changelog/3.2.3.md)
- [3.2.2](/public/changelog/3.2.2.md)
- [3.2.1](/public/changelog/3.2.1.md)
- [3.2.0](/public/changelog/3.2.0.md)
[другие версии](/public/changelog)

View file

@ -1,19 +1,22 @@
import type { NextFetchEvent } from 'next/server';
import { fetchDataViaGet, fetchDataViaPost } from '#/api/utils';
import { API_URL } from '#/api/config';
import type { NextFetchEvent } from "next/server";
import { fetchDataViaGet, fetchDataViaPost } from "#/api/utils";
import { API_URL } from "#/api/config";
export const config = {
matcher: '/api/proxy/:path*',
matcher: "/api/proxy/:path*",
};
export default async function middleware(request: Request, context: NextFetchEvent) {
export default async function middleware(
request: Request,
context: NextFetchEvent
) {
if (request.method == "GET") {
const url = new URL(request.url);
const isApiV2 = url.searchParams.get("API-Version") == "v2" || false;
if (isApiV2) {
url.searchParams.delete("API-Version");
}
const path = url.pathname.match(/\/api\/proxy\/(.*)/)?.[1] + url.search
let path = url.pathname.match(/\/api\/proxy\/(.*)/)?.[1] + url.search;
const data = await fetchDataViaGet(`${API_URL}/${path}`, isApiV2);
@ -21,7 +24,7 @@ export default async function middleware(request: Request, context: NextFetchEve
return new Response(JSON.stringify({ message: "Error Fetching Data" }), {
status: 500,
headers: {
'Content-Type': 'application/json',
"Content-Type": "application/json",
},
});
}
@ -29,7 +32,7 @@ export default async function middleware(request: Request, context: NextFetchEve
return new Response(JSON.stringify(data), {
status: 200,
headers: {
'Content-Type': 'application/json',
"Content-Type": "application/json",
},
});
}
@ -40,7 +43,7 @@ export default async function middleware(request: Request, context: NextFetchEve
if (isApiV2) {
url.searchParams.delete("API-Version");
}
const path = url.pathname.match(/\/api\/proxy\/(.*)/)?.[1] + url.search
const path = url.pathname.match(/\/api\/proxy\/(.*)/)?.[1] + url.search;
const ReqContentTypeHeader = request.headers.get("Content-Type") || "";
let ResContentTypeHeader = "";
@ -54,13 +57,18 @@ export default async function middleware(request: Request, context: NextFetchEve
body = JSON.stringify(await request.json());
}
const data = await fetchDataViaPost(`${API_URL}/${path}`, body, isApiV2, ResContentTypeHeader);
const data = await fetchDataViaPost(
`${API_URL}/${path}`,
body,
isApiV2,
ResContentTypeHeader
);
if (!data) {
return new Response(JSON.stringify({ message: "Error Fetching Data" }), {
status: 500,
headers: {
'Content-Type': 'application/json',
"Content-Type": "application/json",
},
});
}
@ -68,7 +76,7 @@ export default async function middleware(request: Request, context: NextFetchEve
return new Response(JSON.stringify(data), {
status: 200,
headers: {
'Content-Type': 'application/json',
"Content-Type": "application/json",
},
});
}

83
package-lock.json generated
View file

@ -12,9 +12,12 @@
"deepmerge-ts": "^7.1.0",
"flowbite": "^2.4.1",
"flowbite-react": "^0.10.1",
"hls-video-element": "^1.5.0",
"markdown-to-jsx": "^7.4.7",
"media-chrome": "^4.8.0",
"next": "^14.2.13",
"next-plausible": "^3.12.1",
"player.style": "^0.1.6",
"react": "^18",
"react-cropper": "^2.3.3",
"react-dom": "^18",
@ -34,6 +37,7 @@
"eslint": "^8",
"eslint-config-next": "14.2.5",
"postcss": "^8",
"prettier": "^3.5.3",
"tailwind-scrollbar": "^3.1.0",
"tailwindcss": "^3.4.1"
}
@ -1588,6 +1592,15 @@
}
]
},
"node_modules/ce-la-react": {
"version": "0.1.3",
"resolved": "https://registry.npmjs.org/ce-la-react/-/ce-la-react-0.1.3.tgz",
"integrity": "sha512-zZwEEJv9XukeEGbswQXObaDJjYAufOIilSnDg4BWCpKNEYN84H9fpaB+wl+rYKWOIH4wBBPbLnOxKvDIwsL/JQ==",
"license": "BSD-3-Clause",
"peerDependencies": {
"react": ">=17.0.0"
}
},
"node_modules/chalk": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
@ -1747,6 +1760,12 @@
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
"devOptional": true
},
"node_modules/custom-media-element": {
"version": "1.4.2",
"resolved": "https://registry.npmjs.org/custom-media-element/-/custom-media-element-1.4.2.tgz",
"integrity": "sha512-AM6FRWqJyW7pWTvXb4uJj6yvHE7C6UutdhJ5o3XO5NEl5aWFcfnpz8/TuW8qr1+/wfbj50wRvdArnSNjTmjmVw==",
"license": "MIT"
},
"node_modules/damerau-levenshtein": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz",
@ -3171,6 +3190,23 @@
"node": ">= 0.4"
}
},
"node_modules/hls-video-element": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/hls-video-element/-/hls-video-element-1.5.0.tgz",
"integrity": "sha512-ghFzkmd9kmeUjmHuZ/aw55V/n1OQCTy05U7yrs8oN5vmmd/pue+MxbQBXl2oXV7VupLB+LnxzZHycireLUy0VQ==",
"license": "MIT",
"dependencies": {
"custom-media-element": "^1.4.2",
"hls.js": "^1.5.11",
"media-tracks": "^0.3.3"
}
},
"node_modules/hls.js": {
"version": "1.5.20",
"resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.5.20.tgz",
"integrity": "sha512-uu0VXUK52JhihhnN/MVVo1lvqNNuhoxkonqgO3IpjvQiGpJBdIXMGkofjQb/j9zvV7a1SW8U9g1FslWx/1HOiQ==",
"license": "Apache-2.0"
},
"node_modules/ignore": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
@ -3904,6 +3940,21 @@
"node": ">= 0.4"
}
},
"node_modules/media-chrome": {
"version": "4.8.0",
"resolved": "https://registry.npmjs.org/media-chrome/-/media-chrome-4.8.0.tgz",
"integrity": "sha512-oioEGlluW+1RqknqsszrKHDs3NZ9AaatEaE2kYYOSWxnwvVmhRTfDWT4JeMgtUr5r3i2dAI3e/qbeb1j+a0MhA==",
"license": "MIT",
"dependencies": {
"ce-la-react": "^0.1.3"
}
},
"node_modules/media-tracks": {
"version": "0.3.3",
"resolved": "https://registry.npmjs.org/media-tracks/-/media-tracks-0.3.3.tgz",
"integrity": "sha512-9P2FuUHnZZ3iji+2RQk7Zkh5AmZTnOG5fODACnjhCVveX1McY3jmCRHofIEI+yTBqplz7LXy48c7fQ3Uigp88w==",
"license": "MIT"
},
"node_modules/merge-stream": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz",
@ -4448,6 +4499,22 @@
"node": ">= 6"
}
},
"node_modules/player.style": {
"version": "0.1.6",
"resolved": "https://registry.npmjs.org/player.style/-/player.style-0.1.6.tgz",
"integrity": "sha512-QuWz8vaXo+g476TSK4dU+fN5Y+t5ok9UhPrX9DkxG7y9SvA2GbltiWemoz6k9JiCzdiHwS4TKX4HFtVFFzJUnQ==",
"license": "MIT",
"workspaces": [
".",
"site",
"examples/*",
"scripts/*",
"themes/*"
],
"dependencies": {
"media-chrome": "~4.8.0"
}
},
"node_modules/possible-typed-array-names": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",
@ -4602,6 +4669,22 @@
"node": ">= 0.8.0"
}
},
"node_modules/prettier": {
"version": "3.5.3",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.5.3.tgz",
"integrity": "sha512-QQtaxnoDJeAkDvDKWCLiwIXkTgRhwYDEQCghU9Z6q03iyek/rxRh/2lC3HB7P8sWT2xC/y5JDctPLBIGzHKbhw==",
"dev": true,
"license": "MIT",
"bin": {
"prettier": "bin/prettier.cjs"
},
"engines": {
"node": ">=14"
},
"funding": {
"url": "https://github.com/prettier/prettier?sponsor=1"
}
},
"node_modules/prop-types": {
"version": "15.8.1",
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",

View file

@ -13,9 +13,12 @@
"deepmerge-ts": "^7.1.0",
"flowbite": "^2.4.1",
"flowbite-react": "^0.10.1",
"hls-video-element": "^1.5.0",
"markdown-to-jsx": "^7.4.7",
"media-chrome": "^4.8.0",
"next": "^14.2.13",
"next-plausible": "^3.12.1",
"player.style": "^0.1.6",
"react": "^18",
"react-cropper": "^2.3.3",
"react-dom": "^18",
@ -35,6 +38,7 @@
"eslint": "^8",
"eslint-config-next": "14.2.5",
"postcss": "^8",
"prettier": "^3.5.3",
"tailwind-scrollbar": "^3.1.0",
"tailwindcss": "^3.4.1"
}

15
public/changelog/3.3.0.md Normal file
View file

@ -0,0 +1,15 @@
# 3.3.0
## Добавлено
- Добавлены ссылки на официальные источники для релизов где они доступны
- Добавлен собственный плеер для источников Kodik, Anilibria, Sibnet (эксперементально)
## Изменено
- Изменён вид окна настроек
## Исправлено
- Исправлено отображение времени года в информации о релизе
- Исправлена ошибка когда плеер не может загрузиться

View file

@ -8,7 +8,7 @@
"incremental": true,
"module": "esnext",
"esModuleInterop": true,
"moduleResolution": "node",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
@ -27,6 +27,7 @@
}
},
"include": [
"video.d.ts",
"next-env.d.ts",
".next/types/**/*.ts",
"**/*.ts",

1
video.d.ts vendored Normal file
View file

@ -0,0 +1 @@
/// <reference types="next-video/video-types/global" />