feat: add a menu for selecting voiceover, source and episode inside of a player

This commit is contained in:
Kentai Radiquum 2025-04-09 17:11:08 +05:00
parent 0a5b8a59e6
commit 5264534693
Signed by: Radiquum
GPG key ID: 858E8EE696525EED
5 changed files with 266 additions and 142 deletions

View file

@ -0,0 +1,131 @@
"use client";
import { ENDPOINTS } from "#/api/config";
import { useEffect, useState } from "react";
import { _fetchAPI } from "./PlayerParsing";
import { Voiceover } from "./VoiceoverSelectorMenu";
import { Source } from "./SourceSelectorMenu";
import { getAnonEpisodesWatched } from "./ReleasePlayer";
export interface Episode {
position: number;
name: string;
is_watched: boolean;
}
interface EpisodeSelectorMenuProps {
release_id: number;
voiceover: Voiceover;
source: Source;
token: string | null;
setEpisode: (state) => void;
episode: Episode;
episodeList: Episode[];
setPlayerError: (state) => void;
}
export const EpisodeSelectorMenu = ({
release_id,
token,
voiceover,
source,
setEpisode,
episode,
episodeList,
setPlayerError,
}: EpisodeSelectorMenuProps) => {
const [watchedEpisodes, setWatchedEpisodes] = useState([]);
useEffect(() => {
const __getInfo = async () => {
let url = `${ENDPOINTS.release.episode}/${release_id}/${voiceover.id}/${source.id}`;
if (token) {
url += `?token=${token}`;
}
const episodes = await _fetchAPI(
url,
"Не удалось получить информацию о эпизодах",
setPlayerError
);
if (episodes) {
let anonEpisodesWatched = getAnonEpisodesWatched(
release_id,
source.id,
voiceover.id
);
let lastEpisodeWatched = Math.max.apply(
0,
Object.keys(anonEpisodesWatched[release_id][source.id][voiceover.id])
);
let selectedEpisode =
episodes.episodes.find(
(episode: Episode) => episode.position == lastEpisodeWatched
) || episodes.episodes[0];
setEpisode({
selected: selectedEpisode,
available: episodes.episodes,
});
}
};
if (source) {
__getInfo();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [source]);
useEffect(() => {
if (release_id && source && voiceover) {
const anonEpisodesWatched = getAnonEpisodesWatched(
release_id,
source.id,
voiceover.id
);
setWatchedEpisodes(
anonEpisodesWatched[release_id][source.id][voiceover.id]
);
}
}, [release_id, source, voiceover]);
return (
<div className="flex flex-col items-start justify-start gap-4">
<p className="text-[20px] px-2 pt-2 pb-1 font-bold">Эпизод</p>
<div className="max-h-full flex flex-col gap-4 items-start justify-start overflow-x-hidden overflow-y-auto px-2 pb-2 scrollbar-thin scrollbar-thumb-[rgb(60_60_60_/_.8)] scrollbar-track-[rgb(30_30_30_/_.8)]">
{episodeList && episodeList.length > 0 ?
episodeList.map((epis: Episode) => {
return (
<button
key={`release-${release_id}-voiceover-${voiceover.id}-source-${source.id}-episode-${epis.position}`}
className={`h-fit px-2 justify-start items-start ${episode.position == epis.position ? "text-white" : "text-gray-400 hover:text-gray-100"} transition-colors`}
onClick={() => {
setEpisode({
selected: epis,
available: episodeList,
});
}}
>
<div className="flex items-center justify-between gap-2 min-w-32">
<p className="text-[16px] leading-none whitespace-nowrap">
{epis.name ?
epis.name
: ["Sibnet"].includes(source.name) ?
`${epis.position + 1} Серия`
: `${epis.position} Серия`}
</p>
{(
epis.is_watched ||
Object.keys(watchedEpisodes).includes(
epis.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>
);
})
: ""}
</div>
</div>
);
};

View file

@ -12,7 +12,7 @@
--media-primary-color: #fff; --media-primary-color: #fff;
--media-secondary-color: transparent; --media-secondary-color: transparent;
--media-menu-background: rgba(28, 28, 28, 0.6); --media-menu-background: rgba(28, 28, 28, 0.8);
--media-text-color: var(--_primary-color); --media-text-color: var(--_primary-color);
--media-control-hover-background: var(--media-secondary-color); --media-control-hover-background: var(--media-secondary-color);
@ -116,11 +116,12 @@
--media-settings-menu-min-width: calc(10 * var(--base)); --media-settings-menu-min-width: calc(10 * var(--base));
--media-menu-transform-in: translateY(0) scale(1); --media-menu-transform-in: translateY(0) scale(1);
--media-menu-transform-out: translateY(20px) rotate(3deg) scale(1); --media-menu-transform-out: translateY(20px) rotate(3deg) scale(1);
background: rgb(30 30 30 / .6); background: rgba(28, 28, 28, 0.8);
min-width: var(--media-settings-menu-min-width, 170px); min-width: var(--media-settings-menu-min-width, 170px);
position: absolute; position: absolute;
right: 10px; right: 10px;
bottom: calc(3 * var(--base)); bottom: calc(3 * var(--base));
padding: 0;
padding-block: calc(0.15 * var(--base)); padding-block: calc(0.15 * var(--base));
padding-inline: calc(0.6 * var(--base)); padding-inline: calc(0.6 * var(--base));
margin-right: 10px; margin-right: 10px;
@ -131,6 +132,10 @@
max-height: 50%; max-height: 50%;
} }
.media-controller media-chrome-dialog > div {
word-wrap: normal !important;
}
.media-settings-menu[hidden] { .media-settings-menu[hidden] {
display: block; display: block;
visibility: visible; visibility: visible;

View file

@ -43,6 +43,12 @@ import {
} from "media-chrome/react/menu"; } from "media-chrome/react/menu";
import { VoiceoverSelectorMenu } from "./VoiceoverSelectorMenu"; import { VoiceoverSelectorMenu } from "./VoiceoverSelectorMenu";
import { SourceSelectorMenu } from "./SourceSelectorMenu"; import { SourceSelectorMenu } from "./SourceSelectorMenu";
import {
_fetchAnilibriaManifest,
_fetchKodikManifest,
_fetchSibnetManifest,
} from "./PlayerParsing";
import { EpisodeSelectorMenu } from "./EpisodeSelectorMenu";
export const ReleasePlayerCustom = (props: { export const ReleasePlayerCustom = (props: {
id: number; id: number;
@ -60,120 +66,87 @@ export const ReleasePlayerCustom = (props: {
selected: null, selected: null,
available: null, available: null,
}); });
const [playerProps, SetPlayerProps] = useState({ const [playerProps, SetPlayerProps] = useState<{
src: string | null;
poster: string | null;
type: "hls" | "mp4" | null;
}>({
src: null, src: null,
poster: null, poster: null,
type: null, type: null,
useCustom: false,
}); });
const [playerError, setPlayerError] = useState(null); const [playerError, setPlayerError] = useState(null);
// const [playbackRate, setPlaybackRate] = useState(1); const [playbackRate, setPlaybackRate] = useState(1);
// const [isErrorDetailsOpen, setIsErrorDetailsOpen] = useState(false); // const [isErrorDetailsOpen, setIsErrorDetailsOpen] = useState(false);
// const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
// useEffect(() => { useEffect(() => {
// const __getInfo = async () => { const __getInfo = async () => {
// let url = `${ENDPOINTS.release.episode}/${props.id}/${voiceover.selected.id}/${source.selected.id}`; if (source.selected.name == "Kodik") {
// if (props.token) { const { manifest, poster } = await _fetchKodikManifest(
// url += `?token=${props.token}`; episode.selected.url,
// } setPlayerError
// const episodes = await _fetchAPI( );
// url, if (manifest) {
// "Не удалось получить информацию о эпизодах" SetPlayerProps({
// ); src: manifest,
// if (episodes) { poster: poster,
// let anonEpisodesWatched = getAnonEpisodesWatched( type: "hls",
// props.id, });
// source.selected.id, setIsLoading(false);
// voiceover.selected.id }
// ); return;
// let lastEpisodeWatched = Math.max.apply( }
// 0, if (source.selected.name == "Libria") {
// Object.keys( const { manifest, poster } = await _fetchAnilibriaManifest(
// anonEpisodesWatched[props.id][source.selected.id][ episode.selected.url,
// voiceover.selected.id setPlayerError
// ] );
// ) if (manifest) {
// ); SetPlayerProps({
// let selectedEpisode = src: manifest,
// episodes.episodes.find( poster: poster,
// (episode: any) => episode.position == lastEpisodeWatched type: "hls",
// ) || episodes.episodes[0]; });
setIsLoading(false);
}
return;
}
if (source.selected.name == "Sibnet") {
const { manifest, poster } = await _fetchSibnetManifest(
episode.selected.url,
setPlayerError
);
if (manifest) {
SetPlayerProps({
src: manifest,
poster: poster,
type: "mp4",
});
setIsLoading(false);
}
return;
}
SetPlayerProps({
src: episode.selected.url,
poster: null,
type: null,
});
setIsLoading(false);
};
if (episode.selected) {
setIsLoading(true);
setPlayerError(null);
__getInfo();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [episode.selected]);
// setEpisode({ useEffect(() => {
// selected: selectedEpisode, if (document && document.querySelector("media-chrome-dialog")) {
// available: episodes.episodes, document.querySelector("media-chrome-dialog").shadowRoot.querySelector("slot").style.width = "100%";
// }); }
// } }, [])
// };
// if (source.selected) {
// __getInfo();
// }
// // eslint-disable-next-line react-hooks/exhaustive-deps
// }, [source.selected]);
// useEffect(() => {
// const __getInfo = async () => {
// if (source.selected.name == "Kodik") {
// const { manifest, poster } = await _fetchKodikManifest(
// episode.selected.url
// );
// if (manifest) {
// SetPlayerProps({
// src: manifest,
// poster: poster,
// useCustom: true,
// type: "hls",
// });
// setIsLoading(false);
// }
// return;
// }
// if (source.selected.name == "Libria") {
// const { manifest, poster } = await _fetchAnilibriaManifest(
// episode.selected.url
// );
// if (manifest) {
// SetPlayerProps({
// src: manifest,
// poster: poster,
// useCustom: true,
// type: "hls",
// });
// setIsLoading(false);
// }
// return;
// }
// if (source.selected.name == "Sibnet") {
// const { manifest, poster } = await _fetchSibnetManifest(
// episode.selected.url
// );
// if (manifest) {
// SetPlayerProps({
// src: manifest,
// poster: poster,
// useCustom: true,
// type: "mp4",
// });
// setIsLoading(false);
// }
// return;
// }
// SetPlayerProps({
// src: episode.selected.url,
// poster: null,
// useCustom: false,
// type: null,
// });
// setIsLoading(false);
// };
// if (episode.selected) {
// setIsLoading(true);
// setPlayerError(null);
// __getInfo();
// }
// // eslint-disable-next-line react-hooks/exhaustive-deps
// }, [episode.selected]);
return ( return (
<Card className=""> <Card className="">
@ -255,35 +228,40 @@ export const ReleasePlayerCustom = (props: {
defaultStreamType="on-demand" defaultStreamType="on-demand"
className={`relative w-full overflow-hidden ${Styles["media-controller"]}`} className={`relative w-full overflow-hidden ${Styles["media-controller"]}`}
> >
{/* <div className="absolute flex flex-wrap w-full gap-2 top-2 left-2"> {playerProps.type == "hls" && (
{voiceover.selected && ( <HlsVideo
<VoiceoverSelector className="object-contain h-full aspect-video"
availableVoiceover={voiceover.available} slot="media"
voiceover={voiceover.selected} src={playerProps.src}
setVoiceover={setVoiceover} poster={playerProps.poster}
release_id={props.id} defaultPlaybackRate={playbackRate}
/> onRateChange={(e) => {
)} // @ts-ignore
{source.selected && ( setPlaybackRate(e.target.playbackRate || 1);
<SourceSelector }}
availableSource={source.available} />
source={source.selected} )}
setSource={setSource} {playerProps.type == "mp4" && (
release_id={props.id} <VideoJS
/> className="object-contain h-full aspect-video"
)} slot="media"
</div> */} src={playerProps.src}
<HlsVideo poster={playerProps.poster}
className="object-contain h-full aspect-video" defaultPlaybackRate={playbackRate}
slot="media" onRateChange={(e) => {
src={playerProps.src} // @ts-ignore
poster={playerProps.poster} setPlaybackRate(e.target.playbackRate || 1);
// defaultPlaybackRate={playbackRate} }}
// onRateChange={(e) => { ></VideoJS>
// // @ts-ignore )}
// setPlaybackRate(e.target.playbackRate || 1); {(playerProps.type == null || playerProps.src == null) && (
// }} <VideoJS
/> src={null}
slot="media"
poster="https://wallpapers.com/images/featured/cute-red-panda-pictures-sererbq0fdjum7rn.jpg"
className="object-contain h-full aspect-video"
></VideoJS>
)}
<div className={`${Styles["media-gradient-bottom"]}`}></div> <div className={`${Styles["media-gradient-bottom"]}`}></div>
<MediaSettingsMenu <MediaSettingsMenu
id="settings" id="settings"
@ -333,7 +311,17 @@ export const ReleasePlayerCustom = (props: {
sourceList={source.available} sourceList={source.available}
setSource={setSource} setSource={setSource}
setPlayerError={setPlayerError} setPlayerError={setPlayerError}
/> />
<EpisodeSelectorMenu
release_id={props.id}
token={props.token}
voiceover={voiceover.selected}
source={source.selected}
episode={episode.selected}
episodeList={episode.available}
setEpisode={setEpisode}
setPlayerError={setPlayerError}
/>
</div> </div>
</MediaChromeDialog> </MediaChromeDialog>
<MediaControlBar className={`${Styles["media-control-bar"]}`}> <MediaControlBar className={`${Styles["media-control-bar"]}`}>

View file

@ -77,7 +77,7 @@ export const SourceSelectorMenu = ({
return ( return (
<button <button
key={`release-${release_id}-voiceover-${voiceover.id}-source-${src.id}`} key={`release-${release_id}-voiceover-${voiceover.id}-source-${src.id}`}
className={`h-fit justify-start items-start ${source.id == src.id ? "text-white" : "text-gray-500 hover:text-gray-300"} transition-colors`} className={`h-fit ${source.id == src.id ? "text-white" : "text-gray-400 hover:text-gray-100"} transition-colors`}
onClick={() => { onClick={() => {
setSource({ setSource({
selected: src, selected: src,

View file

@ -69,7 +69,7 @@ export const VoiceoverSelectorMenu = ({
voiceoverList.map((vo: Voiceover) => { voiceoverList.map((vo: Voiceover) => {
return ( return (
<button key={`release-${release_id}-voiceover-${vo.id}`} <button key={`release-${release_id}-voiceover-${vo.id}`}
className={`h-fit justify-start items-start ${voiceover.id == vo.id ? "text-white" : "text-gray-500 hover:text-gray-300"} transition-colors`} className={`h-fit px-2 ${voiceover.id == vo.id ? "text-white" : "text-gray-400 hover:text-gray-100"} transition-colors`}
onClick={() => { onClick={() => {
setVoiceover({ setVoiceover({
selected: vo, selected: vo,
@ -86,9 +86,9 @@ export const VoiceoverSelectorMenu = ({
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{/* eslint-disable-next-line @next/next/no-img-element */} {/* eslint-disable-next-line @next/next/no-img-element */}
{vo.icon ? <img alt="" className="w-6 h-6 rounded-full" src={vo.icon}></img> : ""} {vo.icon ? <img alt="" className="w-6 h-6 rounded-full" src={vo.icon}></img> : ""}
<span className="text-[16px] leading-none">{vo.name}</span> <span className="text-[16px] leading-none whitespace-nowrap">{vo.name}</span>
{vo.pinned && ( {vo.pinned && (
<span className={`h-4 iconify material-symbols--push-pin ${voiceover.id == vo.id ? "bg-white" : "bg-gray-500 hover:bg-gray-300"} transition-colors`}></span> <span className={`h-4 iconify material-symbols--push-pin ${voiceover.id == vo.id ? "bg-white" : "bg-gray-400 hover:bg-gray-100"} transition-colors`}></span>
)} )}
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">