Compare commits

...

33 commits
3.6.0 ... V3

Author SHA1 Message Date
42d838a497
fix: manual kodik manifest parsing
Some checks failed
V3 Preview Deployment / Deploy-Preview (push) Has been cancelled
2025-04-13 02:43:32 +05:00
73fb51d914
fix: parsing of kodik manifest 2025-04-13 02:18:52 +05:00
da6639d799
fix: episode changing via buttons
Some checks are pending
V3 Preview Deployment / Deploy-Preview (push) Waiting to run
2025-04-12 21:28:46 +05:00
bc7c799f3c
fix: episode history save 2025-04-12 21:03:10 +05:00
0b66614ccf
chore: update readme 2025-04-12 20:36:07 +05:00
cc19e2a057
Merge branch 'refactor__newplayertheme' into V3 2025-04-12 20:24:41 +05:00
aae958624f
chore: bump version [3.7.0] 2025-04-12 20:23:50 +05:00
fd47aebca7
render comments if comments property exists 2025-04-12 16:58:25 +05:00
9d92c884e8
fix?: error if failed to get comment replies 2025-04-12 16:55:24 +05:00
ed96dd301c
feat: make anilibria api requests via proxy 2025-04-12 15:32:26 +05:00
601b5b10e2
feat: add retry button if error of player link parsing 2025-04-12 15:25:29 +05:00
874b646bc9
remove unused component 2025-04-12 15:19:11 +05:00
b537d9584a
fix: error text overlay of source selection dialog 2025-04-12 15:18:20 +05:00
59419d4ad4
feat: add disable save watch history setting 2025-04-11 21:15:49 +05:00
fd7638121c
remove unused component 2025-04-11 20:56:02 +05:00
99055ef917
add next/prev episode button to player 2025-04-11 20:54:47 +05:00
6ea87a190f
fix svg props 2025-04-11 20:12:21 +05:00
9bc320d39a
add rounding for not fullscreen player 2025-04-11 20:05:25 +05:00
e27029608c
feat: add episode as watched only on first play 2025-04-11 17:53:30 +05:00
3b99b9a173
move buttons to main control bar in player 2025-04-11 17:18:04 +05:00
6c3ce44c76
feat: add anime title to player 2025-04-11 16:56:40 +05:00
50b9f5b918
feat: add skip bar 2025-04-10 16:46:44 +05:00
ad6aba06d3
force to use landscale mode for selection 2025-04-10 16:15:31 +05:00
ac70db8de6
feat: add error dialog 2025-04-10 15:43:08 +05:00
e25bcb2415
add loading indicator 2025-04-10 14:55:26 +05:00
5264534693
feat: add a menu for selecting voiceover, source and episode inside of a player 2025-04-09 17:11:08 +05:00
0a5b8a59e6
return source selection 2025-04-09 14:48:16 +05:00
0168daa6cc
add label 2025-04-09 06:24:08 +05:00
6a27977576
reimplement voiceover selection 2025-04-09 06:05:58 +05:00
0afcc84a94
restore buttons 2025-04-09 04:19:23 +05:00
2b43348b20
fix: player settings open/close animation and media playback rates 2025-04-09 03:46:14 +05:00
5ddf44ac3d
refactor: almost fully reimplement MediaThemeSutro to react 2025-04-08 05:25:04 +05:00
4c0345ffab
move selectors into player 2025-04-08 01:04:21 +05:00
23 changed files with 2112 additions and 1543 deletions

View file

@ -6,10 +6,9 @@ AniX is an unofficial web client for the Android application Anixart. It allows
## Changelog [RU]
- [3.7.0](./public/changelog/3.7.0.md)
- [3.6.0](./public/changelog/3.6.0.md)
- [3.5.0](./public/changelog/3.5.0.md)
- [3.4.0](./public/changelog/3.4.0.md)
- [3.3.0](./public/changelog/3.3.0.md)
[other versions](./public/changelog)

View file

@ -1,4 +1,4 @@
export const CURRENT_APP_VERSION = "3.6.0";
export const CURRENT_APP_VERSION = "3.7.0";
export const API_URL = "https://api.anixart.tv";
export const API_PREFIX = "/api/proxy";

View file

@ -84,9 +84,17 @@ export const CommentsComment = (props: {
url += `&token=${props.token}`;
}
await fetch(url)
.then((res) => res.json())
.then((res) => {
if (res.ok) {
return res.json();
} else {
return { content: [] };
}
})
.then((data) => {
if (data && data.content) {
setReplies(data.content);
}
});
}
if (
@ -194,8 +202,8 @@ export const CommentsComment = (props: {
</footer>
<div className="relative flex items-center py-2">
<p className="text-gray-800 whitespace-pre-wrap dark:text-gray-400">
{!props.comment.isDeleted
? props.comment.message
{!props.comment.isDeleted ?
props.comment.message
: "Комментарий был удалён."}
</p>
{isHidden && (
@ -205,8 +213,8 @@ export const CommentsComment = (props: {
>
<div className="min-w-full min-h-full px-2 py-1.5 rounded-md bg-black text-white bg-opacity-50 backdrop-blur-[8px] flex flex-col justify-center items-center">
<p>
{props.comment.likes_count < -5
? "У комментария слишком низкий рейтинг."
{props.comment.likes_count < -5 ?
"У комментария слишком низкий рейтинг."
: "Данный комментарий может содержать спойлер."}
</p>
<p className="font-bold">Нажмите, чтобы прочитать</p>
@ -220,7 +228,7 @@ export const CommentsComment = (props: {
isHidden ? "mt-4" : ""
}`}
>
{props.token ? (
{props.token ?
<button
type="button"
className="flex items-center text-sm font-medium text-gray-500 hover:underline dark:text-gray-400"
@ -243,9 +251,7 @@ export const CommentsComment = (props: {
</svg>
Ответить
</button>
) : (
<span></span>
)}
: <span></span>}
<div className="flex items-center">
<Button
color="inline"
@ -256,18 +262,16 @@ export const CommentsComment = (props: {
>
<span
className={`w-6 h-6 iconify mdi--dislike ${
vote == 1
? "text-red-500 dark:text-red-400"
vote == 1 ?
"text-red-500 dark:text-red-400"
: "text-gray-500 dark:text-gray-400"
}`}
></span>
</Button>
<p
className={`text-sm font-medium ${
likes > 0
? "text-green-500 dark:text-green-400"
: likes < 0
? "text-red-500 dark:text-red-400"
likes > 0 ? "text-green-500 dark:text-green-400"
: likes < 0 ? "text-red-500 dark:text-red-400"
: "text-gray-500 dark:text-gray-400"
}`}
>
@ -282,8 +286,8 @@ export const CommentsComment = (props: {
>
<span
className={`w-6 h-6 iconify mdi--like ${
vote == 2
? "text-green-500 dark:text-green-400"
vote == 2 ?
"text-green-500 dark:text-green-400"
: "text-gray-500 dark:text-gray-400"
}`}
></span>

View file

@ -41,7 +41,7 @@ export const CommentsMain = (props: {
</div>
</div>
<div className="flex flex-col gap-2">
{props.comments.map((comment: any) => (
{props.comments && props.comments.map((comment: any) => (
<CommentsComment
key={comment.id}
release_id={props.release_id}

View file

@ -1,135 +0,0 @@
"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={{
enabled: true,
draggable: 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" }}
className="pb-2"
>
<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

@ -0,0 +1,133 @@
"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]);
if (!voiceover || !source || !episode) return <></>
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-300 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

@ -0,0 +1,630 @@
.media-controller {
--_primary-color: var(--media-primary-color, #fff);
--_secondary-color: var(--media-secondary-color, transparent);
--_accent-color: var(--media-accent-color, #fff);
--base: 18px;
font-size: calc(0.75 * var(--base));
font-family: Roboto, Arial, sans-serif;
--media-font-family: Roboto, helvetica neue, segoe ui, arial, sans-serif;
-webkit-font-smoothing: antialiased;
--media-primary-color: #fff;
--media-secondary-color: transparent;
--media-menu-background: rgba(28, 28, 28, 0.8);
--media-text-color: var(--_primary-color);
--media-control-hover-background: var(--media-secondary-color);
--media-range-track-height: calc(0.125 * var(--base));
--media-range-thumb-height: var(--base);
--media-range-thumb-width: var(--base);
--media-range-thumb-border-radius: var(--base);
--media-control-height: calc(2 * var(--base));
}
.media-controller[breakpointmd] {
--base: 20px;
}
/* The biggest size controller is tied to going fullscreen
instead of a player width */
.media-controller[mediaisfullscreen] {
--base: 24px;
}
.media-controller:not([mediaisfullscreen]) {
border-radius: 8px;
}
.media-control-bar {
position: absolute;
height: calc(2 * var(--base));
line-height: calc(2 * var(--base));
bottom: calc(1 * var(--base));
left: var(--base);
right: var(--base);
}
.media-button {
--media-control-hover-background: var(--_secondary-color);
--media-tooltip-background: rgb(28 28 28 / 0.24);
--media-text-content-height: 1.2;
--media-tooltip-padding: 0.7em 1em;
--media-tooltip-distance: 8px;
--media-tooltip-container-margin: 18px;
position: relative;
padding: 0;
opacity: 0.9;
transition: opacity 0.1s cubic-bezier(0.4, 0, 1, 1);
}
.svg {
fill: none;
stroke: var(--_primary-color, #fff);
stroke-width: 1;
stroke-linecap: "round";
stroke-linejoin: "round";
}
.svg .svg-shadow {
stroke: #000;
stroke-opacity: 0.15;
stroke-width: 2px;
fill: none;
}
.media-gradient-bottom {
position: absolute;
bottom: 0;
width: 100%;
height: calc(12 * var(--base));
pointer-events: none;
}
.media-gradient-bottom::before {
content: "";
--gradient-steps:
hsl(0 0% 0% / 0) 0%, hsl(0 0% 0% / 0.013) 8.1%, hsl(0 0% 0% / 0.049) 15.5%,
hsl(0 0% 0% / 0.104) 22.5%, hsl(0 0% 0% / 0.175) 29%,
hsl(0 0% 0% / 0.259) 35.3%, hsl(0 0% 0% / 0.352) 41.2%,
hsl(0 0% 0% / 0.45) 47.1%, hsl(0 0% 0% / 0.55) 52.9%,
hsl(0 0% 0% / 0.648) 58.8%, hsl(0 0% 0% / 0.741) 64.7%,
hsl(0 0% 0% / 0.825) 71%, hsl(0 0% 0% / 0.896) 77.5%,
hsl(0 0% 0% / 0.951) 84.5%, hsl(0 0% 0% / 0.987) 91.9%, hsl(0 0% 0%) 100%;
position: absolute;
inset: 0;
opacity: 0.7;
background: linear-gradient(to bottom, var(--gradient-steps));
}
.media-gradient-top {
position: absolute;
top: 0;
width: 100%;
height: calc(8 * var(--base));
pointer-events: none;
}
.media-gradient-top::before {
content: "";
--gradient-steps:
hsl(0 0% 0% / 0) 0%, hsl(0 0% 0% / 0.013) 8.1%, hsl(0 0% 0% / 0.049) 15.5%,
hsl(0 0% 0% / 0.104) 22.5%, hsl(0 0% 0% / 0.175) 29%,
hsl(0 0% 0% / 0.259) 35.3%, hsl(0 0% 0% / 0.352) 41.2%,
hsl(0 0% 0% / 0.45) 47.1%, hsl(0 0% 0% / 0.55) 52.9%,
hsl(0 0% 0% / 0.648) 58.8%, hsl(0 0% 0% / 0.741) 64.7%,
hsl(0 0% 0% / 0.825) 71%, hsl(0 0% 0% / 0.896) 77.5%,
hsl(0 0% 0% / 0.951) 84.5%, hsl(0 0% 0% / 0.987) 91.9%, hsl(0 0% 0%) 100%;
position: absolute;
inset: 0;
opacity: 0.7;
background: linear-gradient(to top, var(--gradient-steps));
}
.anime-title {
position: absolute;
height: calc(2 * var(--base));
top: calc(0.5 * var(--base));
left: var(--base);
right: var(--base);
}
.media-settings-menu {
--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);
padding-block: calc(0.15 * var(--base));
margin-right: 10px;
margin-bottom: 17px;
border-radius: 8px;
z-index: 2;
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-settings-menu-min-height: calc(2 * var(--base));
--media-menu-transform-in: translateY(0) scale(1);
--media-menu-transform-out: translateY(20px) rotate(3deg) scale(1);
background: rgba(28, 28, 28, 0.8);
min-width: var(--media-settings-menu-min-width, 170px);
min-height: var(--media-settings-menu-min-height, 170px);
position: absolute;
right: 10px;
bottom: calc(3 * var(--base));
padding: 0;
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: fit-content;
z-index: 5;
}
@media (min-width: 640px) {
.media-source-dialog {
max-height: 50%;
}
}
@media (min-width: 1280px) {
.media-controller[mediaisfullscreen] .media-source-dialog {
max-height: 30%;
}
}
.media-controller media-chrome-dialog > div {
word-wrap: normal !important;
}
.media-settings-menu[hidden] {
display: block;
visibility: visible;
opacity: 0;
}
.media-settings-menu-item,
.media-controller [role="menu"]::part(menu-item) {
--media-icon-color: var(--_primary-color);
margin-inline: calc(0.45 * var(--base));
height: calc(1.6 * var(--base));
font-size: calc(0.7 * var(--base));
font-weight: 400;
padding: 0;
padding-left: calc(0.4 * var(--base));
padding-right: calc(0.1 * var(--base));
border-radius: 6px;
text-shadow: none;
}
.media-controller [slot="submenu"]::part(back button) {
font-size: calc(0.7 * var(--base));
}
.media-settings-menu-item:hover {
--media-icon-color: #000;
color: #000;
background-color: #fff;
}
.media-settings-menu-item:hover [slot="submenu"]::part(menu-item),
.media-controller [slot="submenu"]::part(back indicator) {
--media-icon-color: var(--_primary-color);
}
.media-settings-menu-item:hover [slot="submenu"]::part(menu-item):hover {
--media-icon-color: #000;
color: #000;
background-color: #fff;
}
.media-settings-menu-item[submenusize="0"] {
display: none;
}
.quality-settings[submenusize="1"] {
display: none;
}
@keyframes bounce-scale-play {
0% {
transform: scale(0.75, 0.75);
}
50% {
transform: scale(115%, 115%);
}
100% {
transform: scale(1, 1);
}
}
.media-button {
border-radius: 25%;
backdrop-filter: blur(10px) invert(15%) brightness(80%) opacity(0);
-webkit-backdrop-filter: blur(10px) invert(15%) brightness(80%) opacity(0);
transition:
backdrop-filter 0.3s,
-webkit-backdrop-filter 0.3s,
box-shadow 0.3s;
}
.media-button:hover {
/* background-color: rgba(0, 0, 0, 0.05); */
box-shadow: rgba(0, 0, 0, 0.3) 0px 0px 5px;
/* hue-rotate(120deg) */
backdrop-filter: blur(10px) invert(15%) brightness(80%) opacity(1);
-webkit-backdrop-filter: blur(10px) invert(15%) brightness(80%) opacity(1);
transition:
backdrop-filter 0.3s,
-webkit-backdrop-filter 0.3s;
}
.media-play-button .icon-play {
opacity: 0;
transform-box: view-box;
transform-origin: center center;
transform: scale(0.5, 0.5);
transition: all 0.5s;
}
.media-play-button[mediapaused] .icon-play {
opacity: 1;
transform: scale(1, 1);
animation: 0.35s bounce-scale-play ease-in-out;
}
@keyframes bounce-pause-left {
0% {
font-size: 10px;
}
50% {
font-size: 3px;
}
100% {
font-size: 4px;
}
}
@keyframes bounce-pause-right {
0% {
font-size: 10px;
transform: translateX(-8px);
}
50% {
font-size: 3px;
transform: translateX(1px);
}
100% {
font-size: 4px;
transform: translateX(0);
}
}
.media-play-button .pause-left,
.media-play-button .pause-right {
font-size: 4px;
opacity: 1;
transform: translateX(0);
transform-box: view-box;
}
.media-play-button:not([mediapaused]) .pause-left {
animation: 0.3s bounce-pause-left ease-out;
}
.media-play-button:not([mediapaused]) .pause-right {
animation: 0.3s bounce-pause-right ease-out;
}
.media-play-button[mediapaused] .pause-left,
.media-play-button[mediapaused] .pause-right {
opacity: 0;
font-size: 10px;
}
.media-play-button[mediapaused] .pause-right {
transform-origin: right center;
transform: translateX(-8px);
}
.media-settings-menu-button svg {
transition: transform 0.1s cubic-bezier(0.4, 0, 1, 1);
transform: rotateZ(0deg);
}
.media-settings-menu-button[aria-expanded="true"] svg {
transform: rotateZ(30deg);
}
.media-time-display {
position: relative;
padding: calc(0.5 * var(--base));
font-size: calc(0.7 * var(--base));
border-radius: calc(0.5 * var(--base));
}
.media-controller[breakpointmd] .media-time-display:not([showduration]) {
display: none;
}
.media-controller:not([breakpointmd]) .media-time-display[showduration] {
display: none;
}
.media-time-range {
height: calc(2 * var(--base));
border-radius: calc(0.25 * var(--base));
--media-range-track-backdrop-filter: invert(10%) blur(5px) brightness(110%);
--media-range-track-background: rgba(255, 255, 255, 0.2);
--media-range-track-pointer-background: rgba(255, 255, 255, 0.5);
--media-range-track-border-radius: calc(0.25 * var(--base));
--media-time-range-buffered-color: rgba(255, 255, 255, 0.4);
--media-range-bar-color: var(--media-accent-color);
--media-range-thumb-background: var(--media-accent-color);
--media-range-thumb-transition: opacity 0.1s linear;
--media-range-thumb-opacity: 0;
--media-preview-thumbnail-border: calc(0.125 * var(--base)) solid #fff;
--media-preview-thumbnail-border-radius: calc(0.5 * var(--base));
--media-preview-thumbnail-min-width: calc(8 * var(--base));
--media-preview-thumbnail-max-width: calc(10 * var(--base));
--media-preview-thumbnail-min-height: calc(5 * var(--base));
--media-preview-thumbnail-max-height: calc(7 * var(--base));
--media-preview-box-margin: 0 0 -10px;
}
.media-time-range:hover {
--media-range-thumb-opacity: 1;
--media-range-track-height: calc(0.25 * var(--base));
}
.media-preview-time-display {
font-size: calc(0.65 * var(--base));
padding-top: 0;
}
.media-mute-button {
position: relative;
opacity: 1;
}
.media-mute-button .muted-path {
transition: clip-path 0.2s ease-out;
}
.media-mute-button .muted-path-2 {
transition-delay: 0.2s;
}
.media-mute-button .muted-path {
clip-path: inset(0);
}
.media-mute-button:not([mediavolumelevel="off"]) .muted-path-1 {
clip-path: inset(0 0 100% 0);
}
.media-mute-button:not([mediavolumelevel="off"]) .muted-path-2 {
clip-path: inset(0 0 100% 0);
}
.media-mute-button .muted-path {
opacity: 0;
}
.media-mute-button[mediavolumelevel="off"] .muted-path {
opacity: 1;
}
.media-mute-button .vol-path {
opacity: 1;
transition: opacity 0.4s;
}
.media-mute-button[mediavolumelevel="off"] .vol-path {
opacity: 0;
}
.media-mute-button[mediavolumelevel="low"] .vol-high-path,
.media-mute-button[mediavolumelevel="medium"] .vol-high-path {
opacity: 0;
}
.media-volume-range {
--media-range-track-background: rgba(255, 255, 255, 0.2);
--media-range-thumb-opacity: 0;
}
@keyframes volume-in {
0% {
visibility: hidden;
opacity: 0;
transform: translateY(50%) rotate(1deg);
}
50% {
visibility: visible;
opacity: 1;
transform: rotate(-2deg);
}
100% {
visibility: visible;
opacity: 1;
transform: translateY(0) rotate(0deg);
}
}
@keyframes volume-out {
0% {
visibility: visible;
opacity: 1;
transform: translateY(0) rotate(0deg);
}
50% {
opacity: 1;
transform: rotate(0deg);
}
100% {
visibility: hidden;
opacity: 0;
transform: translateY(50%) rotate(1deg);
}
}
.media-volume-range-wrapper {
opacity: 0;
visibility: hidden;
position: absolute;
top: -100%;
left: calc(4 * var(--base));
width: calc(10 * var(--base));
height: calc(2.5 * var(--base));
transform-origin: center left;
}
.media-volume-range {
/*
Hide range and animation until mediavolume attribute is set.
visibility didn't work, hovering over media-volume-range-wrapper
caused it to show. Should require mute-button:hover.
*/
opacity: 0;
transition: opacity 0s 1s;
width: calc(10 * var(--base));
height: var(--base);
padding: 0;
border-radius: calc(0.25 * var(--base));
overflow: hidden;
background: rgba(0, 0, 0, 0.2);
--media-range-bar-color: var(--media-accent-color);
--media-range-padding-left: 0;
--media-range-padding-right: 0;
--media-range-track-width: calc(10 * var(--base));
--media-range-track-height: var(--base);
--media-range-track-border-radius: calc(0.25 * var(--base));
--media-range-track-backdrop-filter: blur(10px) brightness(80%);
/* This makes zero volume still show some of the bar.
I can't make the bar have curved corners otherwise though. */
--media-range-thumb-width: var(--base);
--media-range-thumb-border-radius: calc(0.25 * var(--base));
/* The Sutro design has a gradient like this, but not sure I like it */
/* --media-range-thumb-box-shadow: 10px 0px 20px rgba(255, 255, 255, 0.5); */
}
.media-volume-range[mediavolume] {
opacity: 1;
}
.media-controller[keyboardcontrol] .media-volume-range:focus {
/* TODO: This appears to be creating a think outline */
outline: 1px solid rgba(27, 127, 204, 0.9);
}
.media-mute-button:hover + .media-volume-range-wrapper,
.media-mute-button:focus + .media-volume-range-wrapper,
.media-mute-button:focus-within + .media-volume-range-wrapper,
.media-volume-range-wrapper:hover,
.media-volume-range-wrapper:focus,
.media-volume-range-wrapper:focus-within {
animation: 0.3s volume-in forwards ease-out;
}
.media-volume-range-wrapper:not(:hover, :focus-within) {
animation: 0.3s volume-out ease-out;
}
/* When keyboard navigating the volume range and wrapper need to always be visible
otherwise focus state can't land on it. This is ok when keyboard navigating because
the hovering issues aren't a concern, unless you happen to be keyboard AND mouse navigating.
*/
.media-controller[keyboardcontrol] .media-volume-range-wrapper,
.media-controller[keyboardcontrol] .media-volume-range-wrapper:focus-within,
.media-controller[keyboardcontrol]
.media-volume-range-wrapper:focus-within
.media-volume-range {
visibility: visible;
}
/* Having trouble getting @property to work in the shadow dom
to clean this up. Like https://codepen.io/luwes/pen/oNRyZyx */
.media-fullscreen-button .fs-arrow {
translate: 0% 0%;
}
.media-fullscreen-button:hover .fs-arrow {
animation: 0.35s up-left-bounce cubic-bezier(0.34, 1.56, 0.64, 1);
}
.media-fullscreen-button:hover .fs-enter-top,
.media-fullscreen-button:hover .fs-exit-bottom {
animation-name: up-right-bounce;
}
.media-fullscreen-button:hover .fs-enter-bottom,
.media-fullscreen-button:hover .fs-exit-top {
animation-name: down-left-bounce;
}
@keyframes up-left-bounce {
0% {
translate: 0 0;
}
50% {
translate: -4% -4%;
}
}
@keyframes up-right-bounce {
0% {
translate: 0 0;
}
50% {
translate: 4% -4%;
}
}
@keyframes down-left-bounce {
0% {
translate: 0 0;
}
50% {
translate: -4% 4%;
}
}
@keyframes down-right-bounce {
0% {
translate: 0 0;
}
50% {
translate: 4% 4%;
}
}
.media-controller:not([breakpointmd]) .media-pip-button {
display: none;
}
.media-controller media-rendition-menu[mediarenditionunavailable],
.media-controller media-volume-range[mediavolumeunavailable],
.media-controller media-airplay-button[mediaairplayunavailable],
.media-controller media-fullscreen-button[mediafullscreenunavailable],
.media-controller media-cast-button[mediacastunavailable],
.media-controller media-pip-button[mediapipunavailable] {
display: none;
}

View file

@ -1,760 +0,0 @@
import "media-chrome/react";
import "media-chrome/react/menu";
import { MediaTheme } from "media-chrome/react/media-theme";
export default function Page(props: { children: any, className?: string }) {
return (
<>
<template
id="media-theme-sutro-player-template"
dangerouslySetInnerHTML={{
__html: `
<!-- Sutro -->
<style>
:host {
--_primary-color: var(--media-primary-color, #fff);
--_secondary-color: var(--media-secondary-color, transparent);
--_accent-color: var(--media-accent-color, #fff);
}
media-controller {
--base: 18px;
font-size: calc(0.75 * var(--base));
font-family: Roboto, Arial, sans-serif;
--media-font-family: Roboto, helvetica neue, segoe ui, arial, sans-serif;
-webkit-font-smoothing: antialiased;
--media-primary-color: #fff;
--media-secondary-color: transparent;
--media-menu-background: rgba(28, 28, 28, 0.6);
--media-text-color: var(--_primary-color);
--media-control-hover-background: var(--media-secondary-color);
--media-range-track-height: calc(0.125 * var(--base));
--media-range-thumb-height: var(--base);
--media-range-thumb-width: var(--base);
--media-range-thumb-border-radius: var(--base);
--media-control-height: calc(2 * var(--base));
}
media-controller[breakpointmd] {
--base: 20px;
}
/* The biggest size controller is tied to going fullscreen
instead of a player width */
media-controller[mediaisfullscreen] {
--base: 24px;
}
.media-button {
--media-control-hover-background: var(--_secondary-color);
--media-tooltip-background: rgb(28 28 28 / .24);
--media-text-content-height: 1.2;
--media-tooltip-padding: .7em 1em;
--media-tooltip-distance: 8px;
--media-tooltip-container-margin: 18px;
position: relative;
padding: 0;
opacity: 0.9;
transition: opacity 0.1s cubic-bezier(0.4, 0, 1, 1);
}
.media-button svg {
fill: none;
stroke: var(--_primary-color);
stroke-width: 1;
stroke-linecap: 'round';
stroke-linejoin: 'round';
}
svg .svg-shadow {
stroke: #000;
stroke-opacity: 0.15;
stroke-width: 2px;
fill: none;
}
</style>
<media-controller
breakpoints="md:480"
defaultstreamtype="on-demand"
>
<slot name="media" slot="media"></slot>
<slot name="poster" slot="poster"></slot>
<media-error-dialog slot="dialog"></media-error-dialog>
<!-- Controls Gradient -->
<style>
.media-gradient-bottom {
position: absolute;
bottom: 0;
width: 100%;
height: calc(8 * var(--base));
pointer-events: none;
}
.media-gradient-bottom::before {
content: '';
--gradient-steps: hsl(0 0% 0% / 0) 0%, hsl(0 0% 0% / 0.013) 8.1%, hsl(0 0% 0% / 0.049) 15.5%,
hsl(0 0% 0% / 0.104) 22.5%, hsl(0 0% 0% / 0.175) 29%, hsl(0 0% 0% / 0.259) 35.3%, hsl(0 0% 0% / 0.352) 41.2%,
hsl(0 0% 0% / 0.45) 47.1%, hsl(0 0% 0% / 0.55) 52.9%, hsl(0 0% 0% / 0.648) 58.8%, hsl(0 0% 0% / 0.741) 64.7%,
hsl(0 0% 0% / 0.825) 71%, hsl(0 0% 0% / 0.896) 77.5%, hsl(0 0% 0% / 0.951) 84.5%, hsl(0 0% 0% / 0.987) 91.9%,
hsl(0 0% 0%) 100%;
position: absolute;
inset: 0;
opacity: 0.7;
background: linear-gradient(to bottom, var(--gradient-steps));
}
</style>
<div class="media-gradient-bottom"></div>
<!-- Settings Menu -->
<style>
media-settings-menu {
--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);
padding-block: calc(0.15 * var(--base));
margin-right: 10px;
margin-bottom: 17px;
border-radius: 8px;
z-index: 2;
user-select: none;
}
media-settings-menu-item,
[role='menu']::part(menu-item) {
--media-icon-color: var(--_primary-color);
margin-inline: calc(0.45 * var(--base));
height: calc(1.6 * var(--base));
font-size: calc(0.7 * var(--base));
font-weight: 400;
padding: 0;
padding-left: calc(0.4 * var(--base));
padding-right: calc(0.1 * var(--base));
border-radius: 6px;
text-shadow: none;
}
[slot='submenu']::part(back button) {
font-size: calc(0.7 * var(--base));
}
media-settings-menu-item:hover {
--media-icon-color: #000;
color: #000;
background-color: #fff;
}
media-settings-menu-item:hover [slot='submenu']::part(menu-item),
[slot='submenu']::part(back indicator) {
--media-icon-color: var(--_primary-color);
}
media-settings-menu-item:hover [slot='submenu']::part(menu-item):hover {
--media-icon-color: #000;
color: #000;
background-color: #fff;
}
media-settings-menu-item[submenusize='0'] {
display: none;
}
/* Also hide if only Auto is added. */
.quality-settings[submenusize='1'] {
display: none;
}
</style>
<media-settings-menu hidden anchor="auto">
<media-settings-menu-item>
Playback Speed
<media-playback-rate-menu slot="submenu" hidden rates="0.5 0.75 1 1.25 1.5 1.75 2">
<div slot="title">Playback Speed</div>
</media-playback-rate-menu>
</media-settings-menu-item>
<media-settings-menu-item class="quality-settings">
Quality
<media-rendition-menu slot="submenu" hidden>
<div slot="title">Quality</div>
</media-rendition-menu>
</media-settings-menu-item>
<media-settings-menu-item>
Subtitles/CC
<media-captions-menu slot="submenu" hidden>
<div slot="title">Subtitles/CC</div>
</media-captions-menu>
</media-settings-menu-item>
</media-settings-menu>
<!-- Control Bar -->
<style>
media-control-bar {
position: absolute;
height: calc(2 * var(--base));
line-height: calc(2 * var(--base));
bottom: var(--base);
left: var(--base);
right: var(--base);
}
</style>
<media-control-bar>
<!-- Play/Pause -->
<style>
@keyframes bounce-scale-play {
0% {
transform: scale(0.75, 0.75);
}
50% {
transform: scale(115%, 115%);
}
100% {
transform: scale(1, 1);
}
}
.media-button {
border-radius: 25%;
backdrop-filter: blur(10px) invert(15%) brightness(80%) opacity(0);
-webkit-backdrop-filter: blur(10px) invert(15%) brightness(80%) opacity(0);
transition: backdrop-filter 0.3s, -webkit-backdrop-filter 0.3s, box-shadow 0.3s;
}
.media-button:hover {
/* background-color: rgba(0, 0, 0, 0.05); */
box-shadow: rgba(0, 0, 0, 0.3) 0px 0px 5px;
/* hue-rotate(120deg) */
backdrop-filter: blur(10px) invert(15%) brightness(80%) opacity(1);
-webkit-backdrop-filter: blur(10px) invert(15%) brightness(80%) opacity(1);
transition: backdrop-filter 0.3s, -webkit-backdrop-filter 0.3s;
}
media-play-button #icon-play {
opacity: 0;
transform-box: view-box;
transform-origin: center center;
transform: scale(0.5, 0.5);
transition: all 0.5s;
}
media-play-button[mediapaused] #icon-play {
opacity: 1;
transform: scale(1, 1);
animation: 0.35s bounce-scale-play ease-in-out;
}
@keyframes bounce-pause-left {
0% {
font-size: 10px;
}
50% {
font-size: 3px;
}
100% {
font-size: 4px;
}
}
@keyframes bounce-pause-right {
0% {
font-size: 10px;
transform: translateX(-8px);
}
50% {
font-size: 3px;
transform: translateX(1px);
}
100% {
font-size: 4px;
transform: translateX(0);
}
}
media-play-button #pause-left,
media-play-button #pause-right {
/* Using font-size to animate height because using scale was resulting in unexpected positioning */
font-size: 4px;
opacity: 1;
transform: translateX(0);
transform-box: view-box;
}
media-play-button:not([mediapaused]) #pause-left {
animation: 0.3s bounce-pause-left ease-out;
}
media-play-button:not([mediapaused]) #pause-right {
animation: 0.3s bounce-pause-right ease-out;
}
media-play-button[mediapaused] #pause-left,
media-play-button[mediapaused] #pause-right {
opacity: 0;
font-size: 10px;
}
media-play-button[mediapaused] #pause-right {
transform-origin: right center;
transform: translateX(-8px);
}
</style>
<media-play-button mediapaused class="media-button">
<svg slot="icon" viewBox="0 0 32 32">
<!-- <use class="svg-shadow" xlink:href="#icon-play"></use> -->
<g>
<path
id="icon-play"
d="M20.7131 14.6976C21.7208 15.2735 21.7208 16.7265 20.7131 17.3024L12.7442 21.856C11.7442 22.4274 10.5 21.7054 10.5 20.5536L10.5 11.4464C10.5 10.2946 11.7442 9.57257 12.7442 10.144L20.7131 14.6976Z"
/>
</g>
<!-- <use class="svg-shadow" xlink:href="#icon-pause"></use> -->
<g id="icon-pause">
<rect id="pause-left" x="10.5" width="1em" y="10.5" height="11" rx="0.5" />
<rect id="pause-right" x="17.5" width="1em" y="10.5" height="11" rx="0.5" />
</g>
</svg>
</media-play-button>
<!-- Volume/Mute -->
<style>
media-mute-button {
position: relative;
}
media-mute-button .muted-path {
transition: clip-path 0.2s ease-out;
}
media-mute-button #muted-path-2 {
transition-delay: 0.2s;
}
media-mute-button .muted-path {
clip-path: inset(0);
}
media-mute-button:not([mediavolumelevel='off']) #muted-path-1 {
clip-path: inset(0 0 100% 0);
}
media-mute-button:not([mediavolumelevel='off']) #muted-path-2 {
clip-path: inset(0 0 100% 0);
}
media-mute-button .muted-path {
opacity: 0;
}
media-mute-button[mediavolumelevel='off'] .muted-path {
opacity: 1;
}
media-mute-button .vol-path {
opacity: 1;
transition: opacity 0.4s;
}
media-mute-button[mediavolumelevel='off'] .vol-path {
opacity: 0;
}
media-mute-button[mediavolumelevel='low'] #vol-high-path,
media-mute-button[mediavolumelevel='medium'] #vol-high-path {
opacity: 0;
}
media-volume-range {
--media-range-track-background: rgba(255, 255, 255, 0.2);
--media-range-thumb-opacity: 0;
}
@keyframes volume-in {
0% {
visibility: hidden;
opacity: 0;
transform: translateY(50%) rotate(1deg);
}
50% {
visibility: visible;
opacity: 1;
transform: rotate(-2deg);
}
100% {
visibility: visible;
opacity: 1;
transform: translateY(0) rotate(0deg);
}
}
@keyframes volume-out {
0% {
visibility: visible;
opacity: 1;
transform: translateY(0) rotate(0deg);
}
50% {
opacity: 1;
transform: rotate(0deg);
}
100% {
visibility: hidden;
opacity: 0;
transform: translateY(50%) rotate(1deg);
}
}
.media-volume-range-wrapper {
opacity: 0;
visibility: hidden;
position: absolute;
top: -100%;
left: calc(2 * var(--base));
width: calc(10 * var(--base));
height: calc(2.5 * var(--base));
transform-origin: center left;
}
media-volume-range {
/*
Hide range and animation until mediavolume attribute is set.
visibility didn't work, hovering over media-volume-range-wrapper
caused it to show. Should require mute-button:hover.
*/
opacity: 0;
transition: opacity 0s 1s;
width: calc(10 * var(--base));
height: var(--base);
padding: 0;
border-radius: calc(0.25 * var(--base));
overflow: hidden;
background: rgba(0, 0, 0, 0.2);
--media-range-bar-color: var(--media-accent-color);
--media-range-padding-left: 0;
--media-range-padding-right: 0;
--media-range-track-width: calc(10 * var(--base));
--media-range-track-height: var(--base);
--media-range-track-border-radius: calc(0.25 * var(--base));
--media-range-track-backdrop-filter: blur(10px) brightness(80%);
/* This makes zero volume still show some of the bar.
I can't make the bar have curved corners otherwise though. */
--media-range-thumb-width: var(--base);
--media-range-thumb-border-radius: calc(0.25 * var(--base));
/* The Sutro design has a gradient like this, but not sure I like it */
/* --media-range-thumb-box-shadow: 10px 0px 20px rgba(255, 255, 255, 0.5); */
}
media-volume-range[mediavolume] {
opacity: 1;
}
[keyboardcontrol] media-volume-range:focus {
/* TODO: This appears to be creating a think outline */
outline: 1px solid rgba(27, 127, 204, 0.9);
}
media-mute-button:hover + .media-volume-range-wrapper,
media-mute-button:focus + .media-volume-range-wrapper,
media-mute-button:focus-within + .media-volume-range-wrapper,
.media-volume-range-wrapper:hover,
.media-volume-range-wrapper:focus,
.media-volume-range-wrapper:focus-within {
animation: 0.3s volume-in forwards ease-out;
}
.media-volume-range-wrapper:not(:hover, :focus-within) {
animation: 0.3s volume-out ease-out;
}
/* When keyboard navigating the volume range and wrapper need to always be visible
otherwise focus state can't land on it. This is ok when keyboard navigating because
the hovering issues aren't a concern, unless you happen to be keyboard AND mouse navigating.
*/
[keyboardcontrol] .media-volume-range-wrapper,
[keyboardcontrol] .media-volume-range-wrapper:focus-within,
[keyboardcontrol] .media-volume-range-wrapper:focus-within media-volume-range {
visibility: visible;
}
</style>
<media-mute-button class="media-button" notooltip>
<use class="svg-shadow" xlink:href="#vol-paths"></use>
<svg slot="icon" viewBox="0 0 32 32">
<g id="vol-paths">
<path
id="speaker-path"
d="M16.5 20.486v-8.972c0-1.537-2.037-2.08-2.802-.745l-1.026 1.79a2.5 2.5 0 0 1-.8.85l-1.194.78A1.5 1.5 0 0 0 10 15.446v1.11c0 .506.255.978.678 1.255l1.194.782a2.5 2.5 0 0 1 .8.849l1.026 1.79c.765 1.334 2.802.792 2.802-.745Z"
/>
<path
id="vol-low-path"
class="vol-path"
d="M18.5 18C19.6046 18 20.5 17.1046 20.5 16C20.5 14.8954 19.6046 14 18.5 14"
/>
<path
id="vol-high-path"
class="vol-path"
d="M18 21C20.7614 21 23 18.7614 23 16C23 13.2386 20.7614 11 18 11"
/>
<path id="muted-path-1" class="muted-path" d="M23 18L19 14" />
<path id="muted-path-2" class="muted-path" d="M23 14L19 18" />
</g>
</svg>
</media-mute-button>
<div class="media-volume-range-wrapper">
<media-volume-range></media-volume-range>
</div>
<!-- Time Display -->
<style>
media-time-display {
position: relative;
padding: calc(0.5 * var(--base));
font-size: calc(0.7 * var(--base));
border-radius: calc(0.5 * var(--base));
}
media-controller[breakpointmd] media-time-display:not([showduration]) {
display: none;
}
media-controller:not([breakpointmd]) media-time-display[showduration] {
display: none;
}
</style>
<media-time-display></media-time-display>
<media-time-display showduration></media-time-display>
<!-- Time Range / Progress Bar -->
<style>
media-time-range {
height: calc(2 * var(--base));
border-radius: calc(0.25 * var(--base));
--media-range-track-backdrop-filter: invert(10%) blur(5px) brightness(110%);
--media-range-track-background: rgba(255, 255, 255, 0.2);
--media-range-track-pointer-background: rgba(255, 255, 255, 0.5);
--media-range-track-border-radius: calc(0.25 * var(--base));
--media-time-range-buffered-color: rgba(255, 255, 255, 0.4);
--media-range-bar-color: var(--media-accent-color);
--media-range-thumb-background: var(--media-accent-color);
--media-range-thumb-transition: opacity 0.1s linear;
--media-range-thumb-opacity: 0;
--media-preview-thumbnail-border: calc(0.125 * var(--base)) solid #fff;
--media-preview-thumbnail-border-radius: calc(0.5 * var(--base));
--media-preview-thumbnail-min-width: calc(8 * var(--base));
--media-preview-thumbnail-max-width: calc(10 * var(--base));
--media-preview-thumbnail-min-height: calc(5 * var(--base));
--media-preview-thumbnail-max-height: calc(7 * var(--base));
--media-preview-box-margin: 0 0 -10px;
}
media-time-range:hover {
--media-range-thumb-opacity: 1;
--media-range-track-height: calc(0.25 * var(--base));
}
media-preview-thumbnail {
margin-bottom: 5px;
}
media-preview-chapter-display {
font-size: calc(0.6 * var(--base));
padding-block: 0;
}
media-preview-time-display {
font-size: calc(0.65 * var(--base));
padding-top: 0;
}
</style>
<media-time-range>
<media-preview-thumbnail slot="preview"></media-preview-thumbnail>
<media-preview-chapter-display slot="preview"></media-preview-chapter-display>
<media-preview-time-display slot="preview"></media-preview-time-display>
</media-time-range>
<!-- Skip opening Button -->
<media-seek-forward-button class="media-button" seekoffset="90">
<svg slot="icon" width="256" height="256" viewBox="-65 -75 400 400">
<path fill="#fff" d="m246.52 118l-88.19-56.13a12 12 0 0 0-12.18-.39A11.66 11.66 0 0 0 140 71.84v44.59L54.33 61.87a12 12 0 0 0-12.18-.39A11.66 11.66 0 0 0 36 71.84v112.32a11.66 11.66 0 0 0 6.15 10.36a12 12 0 0 0 12.18-.39L140 139.57v44.59a11.66 11.66 0 0 0 6.15 10.36a12 12 0 0 0 12.18-.39L246.52 138a11.81 11.81 0 0 0 0-19.94Zm-108.3 13.19L50 187.38a3.91 3.91 0 0 1-4 .13a3.76 3.76 0 0 1-2-3.35V71.84a3.76 3.76 0 0 1 2-3.35a4 4 0 0 1 1.91-.5a3.94 3.94 0 0 1 2.13.63l88.18 56.16a3.8 3.8 0 0 1 0 6.44Zm104 0L154 187.38a3.91 3.91 0 0 1-4 .13a3.76 3.76 0 0 1-2-3.35V71.84a3.76 3.76 0 0 1 2-3.35a4 4 0 0 1 1.91-.5a3.94 3.94 0 0 1 2.13.63l88.18 56.16a3.8 3.8 0 0 1 0 6.44Z" />
</svg>
</media-seek-forward-button>
<!-- Settings Menu Button -->
<style>
media-settings-menu-button svg {
transition: transform 0.1s cubic-bezier(0.4, 0, 1, 1);
transform: rotateZ(0deg);
}
media-settings-menu-button[aria-expanded='true'] svg {
transform: rotateZ(30deg);
}
</style>
<media-settings-menu-button class="media-button">
<svg slot="icon" viewBox="0 0 32 32">
<use class="svg-shadow" xlink:href="#settings-icon"></use>
<g id="settings-icon">
<path
d="M16 18C17.1046 18 18 17.1046 18 16C18 14.8954 17.1046 14 16 14C14.8954 14 14 14.8954 14 16C14 17.1046 14.8954 18 16 18Z"
/>
<path
d="M21.0176 13.0362L20.9715 12.9531C20.8445 12.7239 20.7797 12.4629 20.784 12.1982L20.8049 10.8997C20.8092 10.6343 20.675 10.3874 20.4545 10.2549L18.5385 9.10362C18.3186 8.97143 18.0472 8.9738 17.8293 9.10981L16.7658 9.77382C16.5485 9.90953 16.2999 9.98121 16.0465 9.98121H15.9543C15.7004 9.98121 15.4513 9.90922 15.2336 9.77295L14.1652 9.10413C13.9467 8.96728 13.674 8.96518 13.4535 9.09864L11.5436 10.2545C11.3242 10.3873 11.1908 10.6336 11.1951 10.8981L11.216 12.1982C11.2203 12.4629 11.1555 12.7239 11.0285 12.9531L10.9831 13.0351C10.856 13.2645 10.6715 13.4535 10.4493 13.5819L9.36075 14.2109C9.13763 14.3398 8.99942 14.5851 9 14.8511L9.00501 17.152C9.00559 17.4163 9.1432 17.6597 9.36476 17.7883L10.4481 18.4167C10.671 18.546 10.8559 18.7364 10.9826 18.9673L11.0313 19.0559C11.1565 19.284 11.2203 19.5431 11.2161 19.8059L11.1951 21.1003C11.1908 21.3657 11.325 21.6126 11.5456 21.7452L13.4615 22.8964C13.6814 23.0286 13.9528 23.0262 14.1707 22.8902L15.2342 22.2262C15.4515 22.0905 15.7001 22.0188 15.9535 22.0188H16.0457C16.2996 22.0188 16.5487 22.0908 16.7664 22.227L17.8348 22.8959C18.0534 23.0327 18.326 23.0348 18.5465 22.9014L20.4564 21.7455C20.6758 21.6127 20.8092 21.3664 20.8049 21.1019L20.784 19.8018C20.7797 19.5371 20.8445 19.2761 20.9715 19.0469L21.0169 18.9649C21.144 18.7355 21.3285 18.5465 21.5507 18.4181L22.6393 17.7891C22.8624 17.6602 23.0006 17.4149 23 17.1489L22.995 14.848C22.9944 14.5837 22.8568 14.3403 22.6352 14.2117L21.5493 13.5818C21.328 13.4534 21.1442 13.2649 21.0176 13.0362Z"
/>
</g>
</svg>
</media-settings-menu-button>
<!-- PIP/Mini Player Button -->
<style>
media-controller:not([breakpointmd]) media-pip-button {
display: none;
}
</style>
<media-pip-button class="media-button">
<svg slot="icon" viewBox="0 0 32 32">
<use class="svg-shadow" xlink:href="#pip-icon"></use>
<g id="pip-icon">
<path
d="M12 22H9.77778C9.34822 22 9 21.6162 9 21.1429V10.8571C9 10.3838 9.34822 10 9.77778 10L22.2222 10C22.6518 10 23 10.3838 23 10.8571V12.5714"
/>
<path
d="M15 21.5714V16.4286C15 16.1919 15.199 16 15.4444 16H22.5556C22.801 16 23 16.1919 23 16.4286V17V21.5714C23 21.8081 22.801 22 22.5556 22H20.3333H17.6667H15.4444C15.199 22 15 21.8081 15 21.5714Z"
/>
</g>
</svg>
</media-pip-button>
<!-- Airplay Button -->
<media-airplay-button class="media-button">
<svg viewBox="0 0 32 32" aria-hidden="true" slot="icon">
<path stroke-linecap="round" stroke-linejoin="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" 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" d="m16 19 3.464 3.75h-6.928L16 19Z"/>
</svg>
</media-airplay-button>
<!-- Cast Button -->
<media-cast-button class="media-button">
<svg slot="icon" viewBox="0 0 32 32">
<use class="svg-shadow" xlink:href="#cast-icon"></use>
<g id="cast-icon">
<path
d="M18.5 21.833h4.167c.46 0 .833-.373.833-.833V11a.833.833 0 0 0-.833-.833H9.333A.833.833 0 0 0 8.5 11v1.111m0 8.056c.92 0 1.667.746 1.667 1.666M8.5 17.667a4.167 4.167 0 0 1 4.167 4.166"
/>
<path d="M8.5 15.167a6.667 6.667 0 0 1 6.667 6.666" />
</g>
</svg>
</media-cast-button>
<!-- Fullscreen Button -->
<style>
/* Having trouble getting @property to work in the shadow dom
to clean this up. Like https://codepen.io/luwes/pen/oNRyZyx */
media-fullscreen-button .fs-arrow {
translate: 0% 0%;
}
media-fullscreen-button:hover .fs-arrow {
animation: 0.35s up-left-bounce cubic-bezier(0.34, 1.56, 0.64, 1);
}
media-fullscreen-button:hover #fs-enter-top,
media-fullscreen-button:hover #fs-exit-bottom {
animation-name: up-right-bounce;
}
media-fullscreen-button:hover #fs-enter-bottom,
media-fullscreen-button:hover #fs-exit-top {
animation-name: down-left-bounce;
}
@keyframes up-left-bounce {
0% {
translate: 0 0;
}
50% {
translate: -4% -4%;
}
}
@keyframes up-right-bounce {
0% {
translate: 0 0;
}
50% {
translate: 4% -4%;
}
}
@keyframes down-left-bounce {
0% {
translate: 0 0;
}
50% {
translate: -4% 4%;
}
}
@keyframes down-right-bounce {
0% {
translate: 0 0;
}
50% {
translate: 4% 4%;
}
}
</style>
<media-fullscreen-button class="media-button">
<svg slot="enter" viewBox="0 0 32 32">
<use class="svg-shadow" xlink:href="#fs-enter-paths"></use>
<g id="fs-enter-paths">
<g id="fs-enter-top" class="fs-arrow">
<path d="M18 10H22V14" />
<path d="M22 10L18 14" />
</g>
<g id="fs-enter-bottom" class="fs-arrow">
<path d="M14 22L10 22V18" />
<path d="M10 22L14 18" />
</g>
</g>
</svg>
<svg slot="exit" viewBox="0 0 32 32">
<use class="svg-shadow" xlink:href="#fs-exit-paths"></use>
<g id="fs-exit-paths">
<g id="fs-exit-top" class="fs-arrow">
<path d="M22 14H18V10" />
<path d="M22 10L18 14" />
</g>
<g id="fs-exit-bottom" class="fs-arrow">
<path d="M10 18L14 18V22" />
<path d="M14 18L10 22" />
</g>
</g>
</svg>
</media-fullscreen-button>
</media-control-bar>
</media-controller>`,
}}
/>
<MediaTheme
className={props.className}
// @ts-ignore
template="media-theme-sutro-player-template"
>
{props.children}
</MediaTheme>
</>
);
}

View file

@ -0,0 +1,242 @@
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;
}
function decryptKodikLink(enc: string) {
const decryptedBase64 = enc.replace(/[a-zA-Z]/g, (e: any) => {
return String.fromCharCode(
(e <= "Z" ? 90 : 122) >= (e = e.charCodeAt(0) + 18) ? e : e - 26
);
});
return atob(decryptedBase64);
}
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
lowQualityLink = decryptKodikLink(lowQualityLink);
}
if (lowQualityLink.includes("https://")) {
// strip 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") ||
lowQualityLink.includes("tvseries")
) {
// if link includes "animetvseries" or "tvseries" 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";
let link = data.links["240"][0].src;
let dec = null;
link.includes("//") ?
link.startsWith("https:") ?
(blobTxt += `${link}\n`)
: (blobTxt += `https:${link}\n`)
: (dec = decryptKodikLink(link));
dec ?
dec.startsWith("https:") ?
(blobTxt += `${dec}\n`)
: (blobTxt += `https:${dec}\n`)
: null;
}
if (data.links.hasOwnProperty("360")) {
blobTxt += "#EXT-X-STREAM-INF:RESOLUTION=578x360,BANDWIDTH=400000\n";
let link = data.links["360"][0].src;
let dec = null;
link.includes("//") ?
link.startsWith("https:") ?
(blobTxt += `${link}\n`)
: (blobTxt += `https:${link}\n`)
: (dec = decryptKodikLink(link));
dec ?
dec.startsWith("https:") ?
(blobTxt += `${dec}\n`)
: (blobTxt += `https:${dec}\n`)
: null;
}
if (data.links.hasOwnProperty("480")) {
blobTxt += "#EXT-X-STREAM-INF:RESOLUTION=854x480,BANDWIDTH=596000\n";
let link = data.links["480"][0].src;
let dec = null;
link.includes("//") ?
link.startsWith("https:") ?
(blobTxt += `${link}\n`)
: (blobTxt += `https:${link}\n`)
: (dec = decryptKodikLink(link));
dec ?
dec.startsWith("https:") ?
(blobTxt += `${dec}\n`)
: (blobTxt += `https:${dec}\n`)
: null;
}
if (data.links.hasOwnProperty("720")) {
blobTxt += "#EXT-X-STREAM-INF:RESOLUTION=1280x720,BANDWIDTH=1280000\n";
let link = data.links["720"][0].src;
let dec = null;
link.includes("//") ?
link.startsWith("https:") ?
(blobTxt += `${link}\n`)
: (blobTxt += `https:${link}\n`)
: (dec = decryptKodikLink(link));
dec ?
dec.startsWith("https:") ?
(blobTxt += `${dec}\n`)
: (blobTxt += `https:${dec}\n`)
: null;
}
if (data.links.hasOwnProperty("1080")) {
blobTxt += "#EXT-X-STREAM-INF:RESOLUTION=1920x1080,BANDWIDTH=2560000\n";
let link = data.links["1080"][0].src;
let dec = null;
link.includes("//") ?
link.startsWith("https:") ?
(blobTxt += `${link}\n`)
: (blobTxt += `https:${link}\n`)
: (dec = decryptKodikLink(link));
dec ?
dec.startsWith("https:") ?
(blobTxt += `${dec}\n`)
: (blobTxt += `https:${dec}\n`)
: null;
}
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 _url = `https://api.anilibria.tv/v3/title?id=${id}`;
const data = await _fetchPlayer(
`https://anix-player.wah.su/?url=${_url}&player=libria`,
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 };
};

View file

@ -12,6 +12,7 @@ import "swiper/css/navigation";
import "swiper/css/mousewheel";
import "swiper/css/scrollbar";
import { Navigation, Mousewheel, Scrollbar } from "swiper/modules";
import { usePreferencesStore } from "#/store/preferences";
const DropdownTheme = {
floating: {
@ -132,6 +133,7 @@ export const ReleasePlayer = (props: { id: number }) => {
setSelectedSource(player);
preferredVoiceoverStore.setPreferredPlayer(props.id, player.name);
};
const preferenceStore = usePreferencesStore();
function _setError(error: string) {
setVoiceoverInfo(null);
@ -244,25 +246,48 @@ export const ReleasePlayer = (props: { id: number }) => {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [props.id, selectedSource, userStore.token]);
async function _addToHistory(episode: any) {
if (episode && userStore.token) {
_fetch(
function _addToHistory(episode: any) {
if (props.id && selectedSource && selectedVoiceover && episode) {
const anonEpisodesWatched = getAnonEpisodesWatched(
props.id,
selectedSource.id,
selectedVoiceover.id
);
if (
preferenceStore.flags.saveWatchHistory &&
!episode.is_watched &&
!Object.keys(
anonEpisodesWatched[props.id][selectedSource.id][selectedVoiceover.id]
).includes(episode.position.toString())
) {
episode.is_watched = true;
saveAnonEpisodeWatched(
props.id,
selectedSource.id,
selectedVoiceover.id,
episode.position
);
if (userStore.token) {
fetch(
`${ENDPOINTS.statistic.addHistory}/${props.id}/${selectedSource.id}/${episode.position}?token=${userStore.token}`
);
_fetch(
fetch(
`${ENDPOINTS.statistic.markWatched}/${props.id}/${selectedSource.id}/${episode.position}?token=${userStore.token}`
);
}
}
}
}
return (
<Card>
{!voiceoverInfo || !sourcesInfo || !episodeInfo ? (
{!voiceoverInfo || !sourcesInfo || !episodeInfo ?
<div className="flex items-center justify-center w-full aspect-video">
{!error ? <Spinner /> : <p>{error}</p>}
{!error ?
<Spinner />
: <p>{error}</p>}
</div>
) : (
<>
: <>
<div className="flex flex-wrap gap-2">
<Dropdown
label={`Озвучка: ${selectedVoiceover.name}`}
@ -296,15 +321,13 @@ export const ReleasePlayer = (props: { id: number }) => {
</Dropdown>
</div>
<div className="aspect-video">
{selectedEpisode ? (
{selectedEpisode ?
<iframe
allowFullScreen={true}
src={selectedEpisode.url}
className="w-full h-full rounded-md"
></iframe>
) : (
<p>Ошибка загрузки плеера</p>
)}
: <p>Ошибка загрузки плеера</p>}
</div>
<div>
<Swiper
@ -335,51 +358,48 @@ export const ReleasePlayer = (props: { id: number }) => {
>
<Button
color={
selectedEpisode.position === episode.position
? "blue"
selectedEpisode.position === episode.position ?
"blue"
: "light"
}
theme={{ base: "w-full disabled:opacity-100" }}
onClick={() => {
setSelectedEpisode(episode);
episode.is_watched = true;
_addToHistory(episode);
saveAnonEpisodeWatched(
props.id,
selectedSource.id,
selectedVoiceover.id,
episode.position
);
}}
disabled={selectedEpisode.position === episode.position}
>
{episode.name
? episode.name
{episode.name ?
episode.name
: `${
(
!["Sibnet", "Sibnet (не работает)"].includes(
selectedSource.name
)
? episode.position
) ?
episode.position
: episode.position + 1
} серия`}
{episode.is_watched ||
} серия`
}
{(
episode.is_watched ||
getAnonCurrentEpisodeWatched(
props.id,
selectedSource.id,
selectedVoiceover.id,
episode.position
) ? (
)
) ?
<span className="w-5 h-5 ml-2 iconify material-symbols--check-circle"></span>
) : (
<span className="w-5 h-5 ml-2 opacity-10 iconify material-symbols--check-circle"></span>
)}
: <span className="w-5 h-5 ml-2 opacity-10 iconify material-symbols--check-circle"></span>
}
</Button>
</SwiperSlide>
))}
</Swiper>
</div>
</>
)}
}
</Card>
);
};

File diff suppressed because it is too large Load diff

View file

@ -1,76 +0,0 @@
"use client";
import { Dropdown, DropdownItem } 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 DropdownItemInternal = ({ 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) => (
<DropdownItem
key={`source_${source.id}`}
onClick={() => {
playerPreferenceStore.setPreferredPlayer(
props.release_id,
source.name
);
props.setSource({
selected: source,
available: props.availableSource,
});
}}
>
<DropdownItemInternal {...source} />
</DropdownItem>
))}
</Dropdown>
);
};

View file

@ -0,0 +1,117 @@
"use client";
import { ENDPOINTS } from "#/api/config";
import { useEffect } from "react";
import { _fetchAPI } from "./PlayerParsing";
import { useUserPlayerPreferencesStore } from "#/store/player";
import { numberDeclension } from "#/api/utils";
import { Voiceover } from "./VoiceoverSelectorMenu";
export interface Source {
id: number;
name: string;
episodes_count: number;
}
interface SourceSelectorMenuProps {
release_id: number;
setSource: (state) => void;
voiceover: Voiceover;
source: Source;
sourceList: Source[];
setPlayerError: (state) => void;
}
export const SourceSelectorMenu = ({
release_id,
setSource,
voiceover,
source,
sourceList,
setPlayerError,
}: SourceSelectorMenuProps) => {
const playerPreferenceStore = useUserPlayerPreferencesStore();
const preferredSource = playerPreferenceStore.getPreferredPlayer(release_id);
useEffect(() => {
const __getInfo = async () => {
let url = `${ENDPOINTS.release.episode}/${release_id}/${voiceover.id}`;
const src = await _fetchAPI(
url,
"Не удалось получить информацию о источниках",
setPlayerError
);
if (src) {
const selectedSrc =
src.sources.find(
(source: Source) => 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) {
__getInfo();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [voiceover]);
if (!voiceover || !source || !sourceList || sourceList.length <= 1) return <></>
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)]">
{sourceList && sourceList.length > 0 ?
sourceList.map((src: Source) => {
return (
<button
key={`release-${release_id}-voiceover-${voiceover.id}-source-${src.id}`}
className={`h-fit ${source.id == src.id ? "text-white" : "text-gray-300 hover:text-gray-100"} transition-colors`}
onClick={() => {
setSource({
selected: src,
available: sourceList,
});
playerPreferenceStore.setPreferredPlayer(
release_id,
src.name
);
}}
>
<div className="flex flex-col w-full gap-1">
<div className="flex items-center gap-2">
<span className="text-[16px] leading-none">{src.name}</span>
</div>
<div className="flex gap-2">
<span>
{src.episodes_count || 0}{" "}
{numberDeclension(
src.episodes_count || 0,
"серия",
"серии",
"серий"
)}
</span>
</div>
</div>
</button>
);
})
: ""}
</div>
</div>
);
};

View file

@ -1,104 +0,0 @@
"use client";
import { Dropdown, DropdownItem } 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">
{/* eslint-disable-next-line @next/next/no-img-element */}
{icon && <img alt="" 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 DropdownItemInternal = ({
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">
{/* eslint-disable-next-line @next/next/no-img-element */}
{icon && <img alt="" 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) => (
<DropdownItem
className="w-fit"
key={`voiceover_${voiceover.id}`}
onClick={() => {
playerPreferenceStore.setPreferredVoiceover(
props.release_id,
voiceover.name
);
props.setVoiceover({
selected: voiceover,
available: props.availableVoiceover,
});
}}
>
<DropdownItemInternal {...voiceover} />
</DropdownItem>
))}
</Dropdown>
);
};

View file

@ -0,0 +1,109 @@
"use client";
import { ENDPOINTS } from "#/api/config";
import { useEffect } from "react";
import { _fetchAPI } from "./PlayerParsing";
import { useUserPlayerPreferencesStore } from "#/store/player";
import { numberDeclension } from "#/api/utils";
export 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: Voiceover) => voiceover.name === preferredVO) ||
vo.types[0];
setVoiceover({
selected: selectedVO,
available: vo.types,
});
}
};
__getInfo();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [release_id, token]);
if (!voiceover) return <></>
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)]">
{voiceoverList && voiceoverList.length > 0 ?
voiceoverList.map((vo: Voiceover) => {
return (
<button key={`release-${release_id}-voiceover-${vo.id}`}
className={`h-fit px-2 ${voiceover.id == vo.id ? "text-white" : "text-gray-300 hover:text-gray-100"} transition-colors`}
onClick={() => {
setVoiceover({
selected: vo,
available: voiceoverList
});
playerPreferenceStore.setPreferredVoiceover(
release_id,
vo.name
);
}
}
>
<div className="flex flex-col w-full gap-1">
<div className="flex items-center 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 className="text-[16px] leading-none whitespace-nowrap">{vo.name}</span>
{vo.pinned && (
<span className={`h-4 iconify material-symbols--push-pin ${voiceover.id == vo.id ? "bg-white" : "bg-gray-300 hover:bg-gray-100"} 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>
</div>
);
};

View file

@ -249,6 +249,24 @@ export const SettingsModal = (props: { isOpen: boolean; setIsOpen: any }) => {
checked={preferenceStore.flags.showChangelog}
/>
</div>
<div className="flex items-center justify-between">
<div>
<p className=" dark:text-white">Сохранять историю просмотра</p>
<p className="max-w-sm text-gray-500 dark:text-gray-300">
При отключении, история не будет сохранятся как локально, так и
на аккаунте
</p>
</div>
<ToggleSwitch
color="blue"
onChange={() =>
preferenceStore.setFlags({
saveWatchHistory: !preferenceStore.flags.saveWatchHistory,
})
}
checked={preferenceStore.flags.saveWatchHistory}
/>
</div>
<div className="flex items-center justify-between">
<div>
<p className=" dark:text-white">Отправка аналитики</p>

View file

@ -1,10 +1,17 @@
export const Spinner = () => {
export const Spinner = (props: { size?: "base" | "md" | "lg" }) => {
let size = "w-8 h-8";
if (props.size == "md") {
size = "w-12 h-12";
}
if (props.size == "lg") {
size = "w-16 h-16";
}
return (
<div>
<div role="status">
<svg
aria-hidden="true"
className="inline w-8 h-8 text-gray-200 animate-spin dark:text-gray-600 fill-blue-600"
className={`inline ${size} text-gray-200 animate-spin dark:text-gray-600 fill-blue-600`}
viewBox="0 0 100 101"
fill="none"
xmlns="http://www.w3.org/2000/svg"

View file

@ -119,7 +119,7 @@ export const ReleasePage = (props: any) => {
data.release.status.name.toLowerCase() != "анонс" && (
<>
{preferenceStore.params.experimental.newPlayer ?
<ReleasePlayerCustom id={props.id} token={userStore.token} />
<ReleasePlayerCustom id={props.id} token={userStore.token} title={data.release.title_ru || data.release.title_original || ""} />
: <ReleasePlayer id={props.id} />}
</>
)}

View file

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

27
package-lock.json generated
View file

@ -15,7 +15,7 @@
"flowbite-react": "^0.11.7",
"hls-video-element": "^1.5.0",
"markdown-to-jsx": "^7.4.7",
"media-chrome": "^4.8.0",
"media-chrome": "^4.9.0",
"next": "^14.2.26",
"next-plausible": "^3.12.1",
"prettier": "^3.5.3",
@ -32,6 +32,7 @@
"@iconify-json/fa6-brands": "^1.1.21",
"@iconify-json/material-symbols": "^1.1.83",
"@iconify-json/mdi": "^1.1.67",
"@iconify-json/solar": "^1.2.2",
"@iconify-json/twemoji": "^1.1.15",
"@iconify/tailwind": "^1.1.1",
"@types/node": "20.14.12",
@ -41,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"
}
},
@ -280,6 +282,16 @@
"@iconify/types": "*"
}
},
"node_modules/@iconify-json/solar": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/@iconify-json/solar/-/solar-1.2.2.tgz",
"integrity": "sha512-lcTb6DWL4HZObiY1W3fHfuxxuQHUc6CFHFeywKEx7Ry0k+dU6POZCMC7oVLr0F8vuf+KgaQ3oOoGO/yFzOwrNg==",
"dev": true,
"license": "CC-BY-4.0",
"dependencies": {
"@iconify/types": "*"
}
},
"node_modules/@iconify-json/twemoji": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/@iconify-json/twemoji/-/twemoji-1.2.2.tgz",
@ -6664,6 +6676,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",

View file

@ -16,7 +16,7 @@
"flowbite-react": "^0.11.7",
"hls-video-element": "^1.5.0",
"markdown-to-jsx": "^7.4.7",
"media-chrome": "^4.8.0",
"media-chrome": "^4.9.0",
"next": "^14.2.26",
"next-plausible": "^3.12.1",
"prettier": "^3.5.3",
@ -33,6 +33,7 @@
"@iconify-json/fa6-brands": "^1.1.21",
"@iconify-json/material-symbols": "^1.1.83",
"@iconify-json/mdi": "^1.1.67",
"@iconify-json/solar": "^1.2.2",
"@iconify-json/twemoji": "^1.1.15",
"@iconify/tailwind": "^1.1.1",
"@types/node": "20.14.12",
@ -42,6 +43,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"
}
}

12
public/changelog/3.7.0.md Normal file
View file

@ -0,0 +1,12 @@
# 3.7.0
## Добавлено
- Настройка сохранения истории просмотра
- Кнопки след./пред. серия в плеере
- Название, озвучка и серия написаны в плеере
## Изменено
- Выбор озвучки, источника и серии теперь находится в плеере
- Серия теперь засчитывается просмотренной только после нажатия кнопки воспроизведения

View file

@ -14,8 +14,9 @@ module.exports = {
".flowbite-react\\class-list.json"
],
plugins: [
addIconSelectors(["mdi", "material-symbols", "twemoji", "fa6-brands"]),
flowbiteReact
addIconSelectors(["mdi", "material-symbols", "twemoji", "fa6-brands", "solar"]),
flowbiteReact,
require("tailwind-scrollbar")
],
darkMode: "selector",
theme: {