diff --git a/app/components/ReleasePlayer/EpisodeSelectorMenu.tsx b/app/components/ReleasePlayer/EpisodeSelectorMenu.tsx new file mode 100644 index 0000000..e69de29 diff --git a/app/components/ReleasePlayer/MediaPlayer.module.css b/app/components/ReleasePlayer/MediaPlayer.module.css index 2e62f67..e772ab0 100644 --- a/app/components/ReleasePlayer/MediaPlayer.module.css +++ b/app/components/ReleasePlayer/MediaPlayer.module.css @@ -38,7 +38,7 @@ position: absolute; height: calc(2 * var(--base)); line-height: calc(2 * var(--base)); - bottom: calc(3 * var(--base)); + bottom: calc(1 * var(--base)); left: var(--base); right: var(--base); } @@ -110,6 +110,27 @@ user-select: none; } +.media-source-dialog { + --media-menu-icon-height: 20px; + --media-menu-item-icon-height: 20px; + --media-settings-menu-min-width: calc(10 * var(--base)); + --media-menu-transform-in: translateY(0) scale(1); + --media-menu-transform-out: translateY(20px) rotate(3deg) scale(1); + background: rgb(30 30 30 / .8); + min-width: var(--media-settings-menu-min-width, 170px); + position: absolute; + right: 10px; + bottom: calc(3 * var(--base)); + padding-block: calc(0.15 * var(--base)); + padding-inline: calc(0.6 * var(--base)); + margin-right: 10px; + margin-bottom: 17px; + border-radius: 8px; + user-select: none; + width: fit-content; + max-height: 50%; +} + .media-settings-menu[hidden] { display: block; visibility: visible; diff --git a/app/components/ReleasePlayer/PlayerParsing.ts b/app/components/ReleasePlayer/PlayerParsing.ts new file mode 100644 index 0000000..9c45221 --- /dev/null +++ b/app/components/ReleasePlayer/PlayerParsing.ts @@ -0,0 +1,177 @@ +import { tryCatchPlayer, tryCatchAPI } from "#/api/utils"; + +export async function _fetchAPI( + url: string, + onErrorMsg: string, + setPlayerError: (state) => void, + onErrorCodes?: Record<number, string> +) { + const { data, error } = await tryCatchAPI(fetch(url)); + if (error) { + let errorDetail = "Мы правда не знаем что произошло..."; + + if (error.name) { + if (error.name == "TypeError") { + errorDetail = "Не удалось подключиться к серверу"; + } else { + errorDetail = `Неизвестная ошибка ${error.name}: ${error.message}`; + } + } + if (error.code) { + if (Object.keys(onErrorCodes).includes(error.code.toString())) { + errorDetail = onErrorCodes[error.code.toString()]; + } else { + errorDetail = `API вернуло ошибку: ${error.code}`; + } + } + + setPlayerError({ + message: onErrorMsg, + detail: errorDetail, + }); + return null; + } + return data; +} + +export async function _fetchPlayer( + url: string, + setPlayerError: (state) => void +) { + const { data, error } = (await tryCatchPlayer(fetch(url))) as any; + if (error) { + let errorDetail = "Мы правда не знаем что произошло..."; + + if (error.name) { + if (error.name == "TypeError") { + errorDetail = "Не удалось подключиться к серверу"; + } else { + errorDetail = `Неизвестная ошибка ${error.name}: ${error.message}`; + } + } else if (error.message) { + errorDetail = error.message; + } + + setPlayerError({ + message: "Не удалось получить ссылку на видео", + detail: errorDetail, + }); + return null; + } + return data; +} + +export const _fetchKodikManifest = async ( + url: string, + setPlayerError: (state) => void +) => { + // Fetch episode links via edge function + const data = await _fetchPlayer( + `https://anix-player.wah.su/?url=${url}&player=kodik`, + setPlayerError + ); + if (data) { + let lowQualityLink = data.links["360"][0].src; // we assume that 360p is always present + + if (!lowQualityLink.includes("//")) { + // check if link is encrypted, else do nothing + const decryptedBase64 = lowQualityLink.replace(/[a-zA-Z]/g, (e) => { + return String.fromCharCode( + (e <= "Z" ? 90 : 122) >= (e = e.charCodeAt(0) + 18) ? e : e - 26 + ); + }); + lowQualityLink = atob(decryptedBase64); + } + + if (lowQualityLink.includes("https://")) { + // string the https prefix, since we add it manually + lowQualityLink = lowQualityLink.replace("https://", "//"); + } + + let manifest = `https:${lowQualityLink.replace("360.mp4:hls:", "")}`; + let poster = `https:${lowQualityLink.replace("360.mp4:hls:manifest.m3u8", "thumb001.jpg")}`; + + if (lowQualityLink.includes("animetvseries")) { + // if link includes "animetvseries" we need to construct manifest ourselves + let blobTxt = "#EXTM3U\n"; + + if (data.links.hasOwnProperty("240")) { + blobTxt += "#EXT-X-STREAM-INF:RESOLUTION=427x240,BANDWIDTH=200000\n"; + !data.links["240"][0].src.startsWith("https:") ? + (blobTxt += `https:${data.links["240"][0].src}\n`) + : (blobTxt += `${data.links["240"][0].src}\n`); + } + + if (data.links.hasOwnProperty("360")) { + blobTxt += "#EXT-X-STREAM-INF:RESOLUTION=578x360,BANDWIDTH=400000\n"; + !data.links["360"][0].src.startsWith("https:") ? + (blobTxt += `https:${data.links["360"][0].src}\n`) + : (blobTxt += `${data.links["360"][0].src}\n`); + } + + if (data.links.hasOwnProperty("480")) { + blobTxt += "#EXT-X-STREAM-INF:RESOLUTION=854x480,BANDWIDTH=596000\n"; + !data.links["480"][0].src.startsWith("https:") ? + (blobTxt += `https:${data.links["480"][0].src}\n`) + : (blobTxt += `${data.links["480"][0].src}\n`); + } + + if (data.links.hasOwnProperty("720")) { + blobTxt += "#EXT-X-STREAM-INF:RESOLUTION=1280x720,BANDWIDTH=1280000\n"; + !data.links["720"][0].src.startsWith("https:") ? + (blobTxt += `https:${data.links["720"][0].src}\n`) + : (blobTxt += `${data.links["720"][0].src}\n`); + } + + let file = new File([blobTxt], "manifest.m3u8", { + type: "application/x-mpegURL", + }); + manifest = URL.createObjectURL(file); + } + return { manifest, poster }; + } + return { manifest: null, poster: null }; +}; + +export const _fetchAnilibriaManifest = async ( + url: string, + setPlayerError: (state) => void +) => { + const id = url.split("?id=")[1].split("&ep=")[0]; + const epid = url.split("?id=")[1].split("&ep=")[1]; + const data = await _fetchPlayer( + `https://api.anilibria.tv/v3/title?id=${id}`, + setPlayerError + ); + if (data) { + const host = `https://${data.player.host}`; + const ep = data.player.list[epid]; + + // we need to manually construct a manifest file for a hls player + 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`}`; + 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 }; + } + return { manifest: null, poster: null }; +}; + +export const _fetchSibnetManifest = async ( + url: string, + setPlayerError: (state) => void +) => { + // Fetch data via cloud endpoint + const data = await _fetchPlayer( + `https://sibnet.anix-player.wah.su/?url=${url}`, + setPlayerError + ); + if (data) { + let manifest = data.video; + let poster = data.poster; + return { manifest, poster }; + } + return { manifest: null, poster: null }; +}; diff --git a/app/components/ReleasePlayer/ReleasePlayerCustom.tsx b/app/components/ReleasePlayer/ReleasePlayerCustom.tsx index e9d5ac7..5b04cbf 100644 --- a/app/components/ReleasePlayer/ReleasePlayerCustom.tsx +++ b/app/components/ReleasePlayer/ReleasePlayerCustom.tsx @@ -4,17 +4,17 @@ import { Button, 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 { 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 VideoJS from "videojs-video-element/react"; // import MediaThemeSutro from "./MediaThemeSutro"; -import { getAnonEpisodesWatched } from "./ReleasePlayer"; -import { tryCatchPlayer, tryCatchAPI } from "#/api/utils"; +// import { getAnonEpisodesWatched } from "./ReleasePlayer"; +// import { tryCatchPlayer, tryCatchAPI } from "#/api/utils"; import Styles from "./MediaPlayer.module.css"; @@ -32,6 +32,7 @@ import { MediaPreviewTimeDisplay, MediaPipButton, MediaAirplayButton, + MediaChromeDialog, } from "media-chrome/react"; import { MediaPlaybackRateMenu, @@ -40,6 +41,7 @@ import { MediaSettingsMenuButton, MediaSettingsMenuItem, } from "media-chrome/react/menu"; +import { VoiceoverSelectorMenu } from "./VoiceoverSelectorMenu"; export const ReleasePlayerCustom = (props: { id: number; @@ -63,334 +65,153 @@ export const ReleasePlayerCustom = (props: { type: null, useCustom: false, }); - const [playbackRate, setPlaybackRate] = useState(1); const [playerError, setPlayerError] = useState(null); - const [isErrorDetailsOpen, setIsErrorDetailsOpen] = useState(false); - const [isLoading, setIsLoading] = useState(true); + // const [playbackRate, setPlaybackRate] = useState(1); + // const [isErrorDetailsOpen, setIsErrorDetailsOpen] = useState(false); + // const [isLoading, setIsLoading] = useState(true); - const playerPreferenceStore = useUserPlayerPreferencesStore(); - const preferredVO = playerPreferenceStore.getPreferredVoiceover(props.id); - const preferredSource = playerPreferenceStore.getPreferredPlayer(props.id); + // const playerPreferenceStore = useUserPlayerPreferencesStore(); + // const preferredVO = playerPreferenceStore.getPreferredVoiceover(props.id); + // const preferredSource = playerPreferenceStore.getPreferredPlayer(props.id); - async function _fetchAPI( - url: string, - onErrorMsg: string, - onErrorCodes?: Record<number, string> - ) { - const { data, error } = await tryCatchAPI(fetch(url)); - if (error) { - let errorDetail = "Мы правда не знаем что произошло..."; + // old info fetching - if (error.name) { - if (error.name == "TypeError") { - errorDetail = "Не удалось подключиться к серверу"; - } else { - errorDetail = `Неизвестная ошибка ${error.name}: ${error.message}`; - } - } - if (error.code) { - if (Object.keys(onErrorCodes).includes(error.code.toString())) { - errorDetail = onErrorCodes[error.code.toString()]; - } else { - errorDetail = `API вернуло ошибку: ${error.code}`; - } - } + // useEffect(() => { + // const __getInfo = async () => { + // let url = `${ENDPOINTS.release.episode}/${props.id}/${voiceover.selected.id}`; + // const src = await _fetchAPI( + // url, + // "Не удалось получить информацию о источниках" + // ); + // if (src) { + // 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(); + // } + // // eslint-disable-next-line react-hooks/exhaustive-deps + // }, [voiceover.selected]); - setPlayerError({ - message: onErrorMsg, - detail: errorDetail, - }); - return null; - } - return data; - } + // useEffect(() => { + // const __getInfo = async () => { + // let url = `${ENDPOINTS.release.episode}/${props.id}/${voiceover.selected.id}/${source.selected.id}`; + // if (props.token) { + // url += `?token=${props.token}`; + // } + // const episodes = await _fetchAPI( + // url, + // "Не удалось получить информацию о эпизодах" + // ); + // if (episodes) { + // 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]; - async function _fetchPlayer(url: string) { - const { data, error } = (await tryCatchPlayer(fetch(url))) as any; - if (error) { - let errorDetail = "Мы правда не знаем что произошло..."; + // setEpisode({ + // selected: selectedEpisode, + // available: episodes.episodes, + // }); + // } + // }; + // if (source.selected) { + // __getInfo(); + // } + // // eslint-disable-next-line react-hooks/exhaustive-deps + // }, [source.selected]); - if (error.name) { - if (error.name == "TypeError") { - errorDetail = "Не удалось подключиться к серверу"; - } else { - errorDetail = `Неизвестная ошибка ${error.name}: ${error.message}`; - } - } else if (error.message) { - errorDetail = error.message; - } - - setPlayerError({ - message: "Не удалось получить ссылку на видео", - detail: errorDetail, - }); - return null; - } - return data; - } - - const _fetchKodikManifest = async (url: string) => { - // Fetch data through a proxy - const data = await _fetchPlayer( - `https://anix-player.wah.su/?url=${url}&player=kodik` - ); - if (data) { - let lowQualityLink = data.links["360"][0].src; // we assume that 360p is always present - - if (!lowQualityLink.includes("//")) { - // check if link is encrypted, else do nothing - const decryptedBase64 = lowQualityLink.replace(/[a-zA-Z]/g, (e) => { - return String.fromCharCode( - (e <= "Z" ? 90 : 122) >= (e = e.charCodeAt(0) + 18) ? e : e - 26 - ); - }); - lowQualityLink = atob(decryptedBase64); - } - - if (lowQualityLink.includes("https://")) { - // string the https prefix, since we add it manually - lowQualityLink = lowQualityLink.replace("https://", "//"); - } - - let manifest = `https:${lowQualityLink.replace("360.mp4:hls:", "")}`; - let poster = `https:${lowQualityLink.replace("360.mp4:hls:manifest.m3u8", "thumb001.jpg")}`; - - if (lowQualityLink.includes("animetvseries")) { - // if link includes "animetvseries" we need to construct manifest ourselves - let blobTxt = "#EXTM3U\n"; - - if (data.links.hasOwnProperty("240")) { - blobTxt += "#EXT-X-STREAM-INF:RESOLUTION=427x240,BANDWIDTH=200000\n"; - !data.links["240"][0].src.startsWith("https:") ? - (blobTxt += `https:${data.links["240"][0].src}\n`) - : (blobTxt += `${data.links["240"][0].src}\n`); - } - - if (data.links.hasOwnProperty("360")) { - blobTxt += "#EXT-X-STREAM-INF:RESOLUTION=578x360,BANDWIDTH=400000\n"; - !data.links["360"][0].src.startsWith("https:") ? - (blobTxt += `https:${data.links["360"][0].src}\n`) - : (blobTxt += `${data.links["360"][0].src}\n`); - } - - if (data.links.hasOwnProperty("480")) { - blobTxt += "#EXT-X-STREAM-INF:RESOLUTION=854x480,BANDWIDTH=596000\n"; - !data.links["480"][0].src.startsWith("https:") ? - (blobTxt += `https:${data.links["480"][0].src}\n`) - : (blobTxt += `${data.links["480"][0].src}\n`); - } - - if (data.links.hasOwnProperty("720")) { - blobTxt += - "#EXT-X-STREAM-INF:RESOLUTION=1280x720,BANDWIDTH=1280000\n"; - !data.links["720"][0].src.startsWith("https:") ? - (blobTxt += `https:${data.links["720"][0].src}\n`) - : (blobTxt += `${data.links["720"][0].src}\n`); - } - - let file = new File([blobTxt], "manifest.m3u8", { - type: "application/x-mpegURL", - }); - manifest = URL.createObjectURL(file); - } - return { manifest, poster }; - } - return { manifest: null, poster: null }; - }; - - const _fetchAnilibriaManifest = async (url: string) => { - const id = url.split("?id=")[1].split("&ep=")[0]; - const data = await _fetchPlayer( - `https://api.anilibria.tv/v3/title?id=${id}` - ); - if (data) { - 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`}`; - 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 }; - } - return { manifest: null, poster: null }; - }; - - const _fetchSibnetManifest = async (url: string) => { - const data = await _fetchPlayer( - `https://sibnet.anix-player.wah.su/?url=${url}` - ); - if (data) { - let manifest = data.video; - let poster = data.poster; - return { manifest, poster }; - } - return { manifest: null, poster: null }; - }; - - useEffect(() => { - const __getInfo = async () => { - let url = `${ENDPOINTS.release.episode}/${props.id}`; - if (props.token) { - url += `?token=${props.token}`; - } - const vo = await _fetchAPI( - url, - "Не удалось получить информацию о озвучках", - { 1: "Просмотр запрещён" } - ); - if (vo) { - const selectedVO = - vo.types.find((voiceover: any) => voiceover.name === preferredVO) || - vo.types[0]; - setVoiceover({ - selected: selectedVO, - available: vo.types, - }); - } - }; - __getInfo(); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [props.id, props.token]); - - useEffect(() => { - const __getInfo = async () => { - let url = `${ENDPOINTS.release.episode}/${props.id}/${voiceover.selected.id}`; - const src = await _fetchAPI( - url, - "Не удалось получить информацию о источниках" - ); - if (src) { - 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(); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [voiceover.selected]); - - useEffect(() => { - const __getInfo = async () => { - let url = `${ENDPOINTS.release.episode}/${props.id}/${voiceover.selected.id}/${source.selected.id}`; - if (props.token) { - url += `?token=${props.token}`; - } - const episodes = await _fetchAPI( - url, - "Не удалось получить информацию о эпизодах" - ); - if (episodes) { - 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(); - } - // 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]); + // 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 ( <Card className=""> @@ -472,7 +293,7 @@ export const ReleasePlayerCustom = (props: { defaultStreamType="on-demand" className={`relative w-full overflow-hidden ${Styles["media-controller"]}`} > - <div className="absolute flex flex-wrap w-full gap-2 top-2 left-2"> + {/* <div className="absolute flex flex-wrap w-full gap-2 top-2 left-2"> {voiceover.selected && ( <VoiceoverSelector availableVoiceover={voiceover.available} @@ -489,21 +310,21 @@ export const ReleasePlayerCustom = (props: { release_id={props.id} /> )} - </div> - + </div> */} <HlsVideo className="object-contain h-full aspect-video" slot="media" src={playerProps.src} poster={playerProps.poster} - defaultPlaybackRate={playbackRate} - onRateChange={(e) => { - // @ts-ignore - setPlaybackRate(e.target.playbackRate || 1); - }} + // defaultPlaybackRate={playbackRate} + // onRateChange={(e) => { + // // @ts-ignore + // setPlaybackRate(e.target.playbackRate || 1); + // }} /> <div className={`${Styles["media-gradient-bottom"]}`}></div> <MediaSettingsMenu + id="settings" hidden anchor="auto" className={`${Styles["media-settings-menu"]}`} @@ -530,6 +351,21 @@ export const ReleasePlayerCustom = (props: { </MediaRenditionMenu> </MediaSettingsMenuItem> </MediaSettingsMenu> + <MediaChromeDialog + id="source" + className={`${Styles["media-source-dialog"]}`} + > + <div className="flex gap-2 overflow-hidden"> + <VoiceoverSelectorMenu + release_id={props.id} + token={props.token} + voiceover={voiceover.selected} + voiceoverList={voiceover.available} + setVoiceover={setVoiceover} + setPlayerError={setPlayerError} + /> + </div> + </MediaChromeDialog> <MediaControlBar className={`${Styles["media-control-bar"]}`}> <MediaPlayButton mediaPaused={true} @@ -644,6 +480,9 @@ export const ReleasePlayerCustom = (props: { /> </svg> </MediaSeekForwardButton> + <MediaSettingsMenuButton + {...({ invoketarget: "source" } as any)} + ></MediaSettingsMenuButton> <MediaSettingsMenuButton className={`${Styles["media-button"]} ${Styles["media-settings-menu-button"]}`} > @@ -690,17 +529,17 @@ export const ReleasePlayerCustom = (props: { className={`${Styles["svg"]}`} > <path - stroke-linecap="round" - stroke-linejoin="round" + strokeLinecap="round" + strokeLinejoin="round" d="M20.5 20h1.722c.43 0 .778-.32.778-.714v-8.572c0-.394-.348-.714-.778-.714H9.778c-.43 0-.778.32-.778.714v1.429" /> <path - stroke-linecap="round" - stroke-linejoin="round" + strokeLinecap="round" + strokeLinejoin="round" d="M11.5 20H9.778c-.43 0-.778-.32-.778-.714v-8.572c0-.394.348-.714.778-.714h12.444c.43 0 .778.32.778.714v1.429" /> <path - stroke-linejoin="round" + strokeLinejoin="round" d="m16 19 3.464 3.75h-6.928L16 19Z" /> </svg> @@ -780,7 +619,7 @@ export const ReleasePlayerCustom = (props: { </svg> </MediaFullscreenButton> </MediaControlBar> - {episode.selected && source.selected && voiceover.selected && ( + {/* {episode.selected && source.selected && voiceover.selected && ( <div className="w-full"> <EpisodeSelector availableEpisodes={episode.available} @@ -792,11 +631,9 @@ export const ReleasePlayerCustom = (props: { token={props.token} /> </div> - )} + )} */} </MediaController> </div> - - <div></div> </Card> ); }; diff --git a/app/components/ReleasePlayer/VoiceoverSelectorMenu.tsx b/app/components/ReleasePlayer/VoiceoverSelectorMenu.tsx new file mode 100644 index 0000000..de208e7 --- /dev/null +++ b/app/components/ReleasePlayer/VoiceoverSelectorMenu.tsx @@ -0,0 +1,105 @@ +"use client"; + +import { ENDPOINTS } from "#/api/config"; +import { useEffect } from "react"; +import { _fetchAPI } from "./PlayerParsing"; +import { useUserPlayerPreferencesStore } from "#/store/player"; +import { Button } from "flowbite-react"; +import { numberDeclension } from "#/api/utils"; + +interface Voiceover { + id: number; + name: string; + icon: string; + episodes_count: number; + view_count: number; + pinned: boolean; +} + +interface VoiceoverSelectorMenuProps { + release_id: number; + token: string | null; + setVoiceover: (state) => void; + voiceover: Voiceover; + voiceoverList: Voiceover[]; + setPlayerError: (state) => void; +} + +export const VoiceoverSelectorMenu = ({ + release_id, + token, + setVoiceover, + voiceover, + voiceoverList, + setPlayerError, +}: VoiceoverSelectorMenuProps) => { + const playerPreferenceStore = useUserPlayerPreferencesStore(); + const preferredVO = playerPreferenceStore.getPreferredVoiceover(release_id); + + useEffect(() => { + const __getInfo = async () => { + let url = `${ENDPOINTS.release.episode}/${release_id}`; + if (token) { + url += `?token=${token}`; + } + const vo = await _fetchAPI( + url, + "Не удалось получить информацию о озвучках", + setPlayerError, + { 1: "Просмотр запрещён" } + ); + if (vo) { + const selectedVO = + vo.types.find((voiceover: any) => voiceover.name === preferredVO) || + vo.types[0]; + setVoiceover({ + selected: selectedVO, + available: vo.types, + }); + } + }; + __getInfo(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [release_id, token]); + + return ( + <div className="max-h-full flex flex-col gap-4 items-start justify-start overflow-x-hidden overflow-y-auto px-2 py-2 scrollbar-thin scrollbar-thumb-[rgb(60_60_60_/_.8)] scrollbar-track-[rgb(30_30_30_/_.8)]"> + {voiceoverList && voiceoverList.length > 0 ? + voiceoverList.map((vo: Voiceover) => { + return ( + <Button key={`release-${release_id}-voiceover-${vo.id}`} + className={`h-fit ${voiceover.id == vo.id ? "text-white" : "text-gray-500 hover:text-gray-300"} transition-colors`} + onClick={() => { + setVoiceover({ + selected: vo, + available: voiceoverList + }); + playerPreferenceStore.setPreferredVoiceover( + release_id, + vo.name + ); + } + } + > + <div className="flex flex-col gap-1"> + <div className="flex gap-2"> + {/* eslint-disable-next-line @next/next/no-img-element */} + {vo.icon ? <img alt="" className="w-6 h-6 rounded-full" src={vo.icon}></img> : ""} + <span>{vo.name}</span> + {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> + )} + </div> + <div className="flex gap-2"> + {/* eslint-disable-next-line @next/next/no-img-element */} + <span>{vo.episodes_count} {numberDeclension(vo.episodes_count, "серия", "серии", "серий")}</span> + <span>{vo.view_count} {numberDeclension(vo.view_count, "просмотр", "просмотра", "просмотров")}</span> + </div> + </div> + </Button> + ); + }) + : ""} + </div> + ); +}; diff --git a/package-lock.json b/package-lock.json index 0af315f..0758888 100644 --- a/package-lock.json +++ b/package-lock.json @@ -41,6 +41,7 @@ "eslint-config-next": "14.2.5", "eslint-plugin-react-refresh": "^0.4.19", "postcss": "^8", + "tailwind-scrollbar": "^3.1.0", "tailwindcss": "^3.4.1" } }, @@ -6664,6 +6665,19 @@ "url": "https://github.com/sponsors/dcastil" } }, + "node_modules/tailwind-scrollbar": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tailwind-scrollbar/-/tailwind-scrollbar-3.1.0.tgz", + "integrity": "sha512-pmrtDIZeHyu2idTejfV59SbaJyvp1VRjYxAjZBH0jnyrPRo6HL1kD5Glz8VPagasqr6oAx6M05+Tuw429Z8jxg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.13.0" + }, + "peerDependencies": { + "tailwindcss": "3.x" + } + }, "node_modules/tailwindcss": { "version": "3.4.17", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.17.tgz", diff --git a/package.json b/package.json index 35d5b6e..3b6982f 100644 --- a/package.json +++ b/package.json @@ -42,6 +42,7 @@ "eslint-config-next": "14.2.5", "eslint-plugin-react-refresh": "^0.4.19", "postcss": "^8", + "tailwind-scrollbar": "^3.1.0", "tailwindcss": "^3.4.1" } } diff --git a/tailwind.config.js b/tailwind.config.js index 1864db8..d3ac4d9 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -15,7 +15,8 @@ module.exports = { ], plugins: [ addIconSelectors(["mdi", "material-symbols", "twemoji", "fa6-brands"]), - flowbiteReact + flowbiteReact, + require("tailwind-scrollbar") ], darkMode: "selector", theme: {