Compare commits
No commits in common. "V3" and "3.6.0" have entirely different histories.
|
@ -1,68 +0,0 @@
|
||||||
# Python
|
|
||||||
__pycache__
|
|
||||||
venv
|
|
||||||
.mypy_cache
|
|
||||||
|
|
||||||
# VSCode
|
|
||||||
.VSCode
|
|
||||||
*.code-workspace
|
|
||||||
|
|
||||||
# DetaSpace
|
|
||||||
.space
|
|
||||||
|
|
||||||
# NextJS
|
|
||||||
## dependencies
|
|
||||||
standalone
|
|
||||||
node_modules
|
|
||||||
.pnp
|
|
||||||
.pnp.js
|
|
||||||
.yarn/install-state.gz
|
|
||||||
|
|
||||||
## testing
|
|
||||||
coverage
|
|
||||||
|
|
||||||
## next.js
|
|
||||||
.next
|
|
||||||
out
|
|
||||||
|
|
||||||
## production
|
|
||||||
build
|
|
||||||
|
|
||||||
## misc
|
|
||||||
.DS_Store
|
|
||||||
*.pem
|
|
||||||
|
|
||||||
## debug
|
|
||||||
npm-debug.log*
|
|
||||||
yarn-debug.log*
|
|
||||||
yarn-error.log*
|
|
||||||
|
|
||||||
## local env files
|
|
||||||
.env*.local
|
|
||||||
|
|
||||||
## vercel
|
|
||||||
.vercel
|
|
||||||
|
|
||||||
## typescript
|
|
||||||
*.tsbuildinfo
|
|
||||||
next-env.d.ts
|
|
||||||
|
|
||||||
# traefik
|
|
||||||
traefik/traefik
|
|
||||||
|
|
||||||
old/
|
|
||||||
#Trigger Vercel Prod Build
|
|
||||||
|
|
||||||
# next-video
|
|
||||||
videos/*
|
|
||||||
!videos/*.json
|
|
||||||
!videos/*.js
|
|
||||||
!videos/*.ts
|
|
||||||
public/_next-video
|
|
||||||
|
|
||||||
API-Trace/*
|
|
||||||
.env
|
|
||||||
|
|
||||||
player-parsers
|
|
||||||
docs
|
|
||||||
.git
|
|
|
@ -1,5 +0,0 @@
|
||||||
# пример заполнения: https://example.com, http://0.0.0.0:80
|
|
||||||
NEXT_PUBLIC_KODIK_PARSER_URL= # Домен парсера кодика, требуется для просмотра с данного источника
|
|
||||||
NEXT_PUBLIC_ANILIBRIA_PARSER_URL= # Домен парсера анилибрии, если не заполнено, используется официальное апи
|
|
||||||
NEXT_PUBLIC_SIBNET_PARSER_URL= # Домен парсера сибнет, требуется для просмотра с данного источника
|
|
||||||
# ---
|
|
28
.github/workflows/DeployPreviewToVercel.yml
vendored
Normal file
|
@ -0,0 +1,28 @@
|
||||||
|
name: V3 Preview Deployment
|
||||||
|
env:
|
||||||
|
VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }}
|
||||||
|
VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }}
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
# Pattern matched against refs/tags
|
||||||
|
branches:
|
||||||
|
- 'V3'
|
||||||
|
paths-ignore:
|
||||||
|
- '**/README.md'
|
||||||
|
- '**/LICENSE'
|
||||||
|
- '**/TODO.md'
|
||||||
|
- '**/docs/**'
|
||||||
|
- '**/extension/**'
|
||||||
|
jobs:
|
||||||
|
Deploy-Preview:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v3
|
||||||
|
- name: Install Vercel CLI
|
||||||
|
run: npm install --global vercel@latest
|
||||||
|
- name: Pull Vercel Environment Information
|
||||||
|
run: vercel pull --yes --environment=preview --token=${{ secrets.VERCEL_TOKEN }}
|
||||||
|
- name: Build Project Artifacts
|
||||||
|
run: vercel build --token=${{ secrets.VERCEL_TOKEN }}
|
||||||
|
- name: Deploy Project Artifacts to Vercel
|
||||||
|
run: vercel deploy --prebuilt --token=${{ secrets.VERCEL_TOKEN }}
|
28
.github/workflows/DeployTagToVercel.yml
vendored
Normal file
|
@ -0,0 +1,28 @@
|
||||||
|
name: Production Tag Deployment
|
||||||
|
env:
|
||||||
|
VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }}
|
||||||
|
VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }}
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
# Pattern matched against refs/tags
|
||||||
|
tags:
|
||||||
|
- '*' # Push events to every tag not containing /
|
||||||
|
paths-ignore:
|
||||||
|
- '**/README.md'
|
||||||
|
- '**/LICENSE'
|
||||||
|
- '**/TODO.md'
|
||||||
|
- '**/docs/**'
|
||||||
|
- '**/extension/**'
|
||||||
|
jobs:
|
||||||
|
Deploy-Production:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v3
|
||||||
|
- name: Install Vercel CLI
|
||||||
|
run: npm install --global vercel@latest
|
||||||
|
- name: Pull Vercel Environment Information
|
||||||
|
run: vercel pull --yes --environment=production --token=${{ secrets.VERCEL_TOKEN }}
|
||||||
|
- name: Build Project Artifacts
|
||||||
|
run: vercel build --prod --token=${{ secrets.VERCEL_TOKEN }}
|
||||||
|
- name: Deploy Project Artifacts to Vercel
|
||||||
|
run: vercel deploy --prebuilt --prod --token=${{ secrets.VERCEL_TOKEN }}
|
1
.gitignore
vendored
|
@ -61,4 +61,3 @@ videos/*
|
||||||
public/_next-video
|
public/_next-video
|
||||||
|
|
||||||
API-Trace/*
|
API-Trace/*
|
||||||
.env
|
|
180
DEPLOYMENT.RU.md
|
@ -1,180 +0,0 @@
|
||||||
# Развёртывание приложения AniX
|
|
||||||
|
|
||||||
## Vercel
|
|
||||||
|
|
||||||
Требования:
|
|
||||||
|
|
||||||
- аккаунт GitHub
|
|
||||||
- аккаунт Vercel
|
|
||||||
|
|
||||||
1. Создайте форк репозитория
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
2. Войдите в аккаунт Vercel
|
|
||||||
|
|
||||||
> [!IMPORTANT]
|
|
||||||
> Аккаунт Vercel должен быть связан с аккаунтом GitHub.
|
|
||||||
>
|
|
||||||
> Если у вас нет аккаунта Vercel, то создайте его через вход с помощью GitHub.
|
|
||||||
|
|
||||||
3. Нажмите кнопку создать новый проект
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
4. Нажмите кнопку импортировать напротив названия репозитория
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
5. (опционально) добавьте переменные для использования своего плеера:
|
|
||||||
|
|
||||||
- NEXT_PUBLIC_KODIK_PARSER_URL
|
|
||||||
- NEXT_PUBLIC_ANILIBRIA_PARSER_URL
|
|
||||||
- NEXT_PUBLIC_SIBNET_PARSER_URL
|
|
||||||
|
|
||||||
на те которые вы получили, если развёртывали [anix-player-parsers](./player-parsers/README.RU.md)
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
6. нажмите кнопку "Deploy" и ожидайте пока не появится подтверждение
|
|
||||||
7. нажмите кнопку "Continue to Dashboard"
|
|
||||||
8. клиент будет доступен по ссылке такого вида, нажмите на неё чтобы его открыть
|
|
||||||

|
|
||||||
|
|
||||||
## Netlify
|
|
||||||
|
|
||||||
Требования:
|
|
||||||
|
|
||||||
- аккаунт GitHub
|
|
||||||
- аккаунт Netlify
|
|
||||||
|
|
||||||
1. Создайте форк репозитория
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
2. Войдите в аккаунт Netlify
|
|
||||||
|
|
||||||
> [!IMPORTANT]
|
|
||||||
> Аккаунт Netlify должен быть связан с аккаунтом GitHub.
|
|
||||||
>
|
|
||||||
> Если у вас нет аккаунта Netlify, то создайте его через вход с помощью GitHub.
|
|
||||||
|
|
||||||
3. Нажмите кнопку создать новый проект
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
4. Нажмите кнопку GitHub
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
5. Нажмите на название репозитория
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
6. (опционально) заполните название проекта
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
7. (опционально) добавьте переменные для использования своего плеера:
|
|
||||||
|
|
||||||
- NEXT_PUBLIC_KODIK_PARSER_URL
|
|
||||||
- NEXT_PUBLIC_ANILIBRIA_PARSER_URL
|
|
||||||
- NEXT_PUBLIC_SIBNET_PARSER_URL
|
|
||||||
|
|
||||||
на те которые вы получили, если развёртывали [anix-player-parsers](./player-parsers/README.RU.md)
|
|
||||||
|
|
||||||
1. 
|
|
||||||
|
|
||||||
2. 
|
|
||||||
|
|
||||||
8. нажмите кнопку "Deploy" и ожидайте пока не появится подтверждение
|
|
||||||
|
|
||||||
9. клиент будет доступен по ссылке такого вида, нажмите на неё чтобы его открыть
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
## Docker
|
|
||||||
|
|
||||||
Требования:
|
|
||||||
|
|
||||||
- [docker](https://docs.docker.com/engine/install/)
|
|
||||||
|
|
||||||
### Пре-билд
|
|
||||||
|
|
||||||
1. выполните команду:
|
|
||||||
|
|
||||||
`docker run -d --name anix -p 3000:3000 radiquum/anix:latest`
|
|
||||||
|
|
||||||
### Ручной билд
|
|
||||||
|
|
||||||
Доп. Требования:
|
|
||||||
|
|
||||||
- [git](https://git-scm.com/)
|
|
||||||
|
|
||||||
1. Клонируйте репозиторий `git clone https://github.com/Radiquum/AniX`
|
|
||||||
2. Переместитесь в директорию репозитория `cd AniX`
|
|
||||||
3. Выполните команду `docker build -t anix .`
|
|
||||||
4. После окончания, выполните команду: `docker run -d --restart always --name anix -p 3000:3000 anix`
|
|
||||||
|
|
||||||
### docker/Обозначения
|
|
||||||
|
|
||||||
- -d - запустить контейнер в фоне
|
|
||||||
- --restart always - всегда запускать после перезагрузки сервера
|
|
||||||
- --name - название контейнера
|
|
||||||
- -p - порт контейнера который будет доступен извне. ПОРТ:3000
|
|
||||||
|
|
||||||
> [!NOTE]
|
|
||||||
> для переменных которые вы получили, если развёртывали [anix-player-parsers](./player-parsers/README.RU.md), необходимо использовать `-e ПЕРЕМЕННАЯ=ЗНАЧЕНИЕ` до слова anix
|
|
||||||
|
|
||||||
[команда docker run](https://docs.docker.com/reference/cli/docker/container/run/)
|
|
||||||
|
|
||||||
### docker/После развёртывания
|
|
||||||
|
|
||||||
Сервис будет доступен по адресу: `http://<ВАШ IP><:ВАШ ПОРТ>/`
|
|
||||||
|
|
||||||
### docker/Примечание
|
|
||||||
|
|
||||||
Для использования своего домена и поддержки протокола HTTPS, вы можете использовать Traefik или другой reverse-proxy, с сертификатом SSL.
|
|
||||||
|
|
||||||
Полезные ссылки:
|
|
||||||
|
|
||||||
- [Конвертер из команды docker run в синтакс для docker compose](https://it-tools.tech/docker-run-to-docker-compose-converter)
|
|
||||||
- [Как настроить Traefik + свой домен + SSL](https://letmegooglethat.com/?q=how+to+setup+traefik+with+custom+domain+and+ssl+certificate+from+lets+encrypt%3F)
|
|
||||||
|
|
||||||
## pm2
|
|
||||||
|
|
||||||
Требования:
|
|
||||||
|
|
||||||
- [git](https://git-scm.com/)
|
|
||||||
- [nodejs 23+ с npm](http://nodejs.org/)
|
|
||||||
- [pm2](https://pm2.keymetrics.io/)
|
|
||||||
|
|
||||||
Инструкция:
|
|
||||||
|
|
||||||
1. Клонируйте репозиторий `git clone https://github.com/Radiquum/AniX`
|
|
||||||
2. Переместитесь в директорию репозитория `cd AniX`
|
|
||||||
3. Выполните команду `npm install`
|
|
||||||
4. (опционально) скопируйте .env.sample как .env и заполните его переменными которые вы получили, если развёртывали [anix-player-parsers](./player-parsers/README.RU.md)
|
|
||||||
5. Выполните команду `npm run build`
|
|
||||||
6. создайте новую директорию (далее будем использовать `<имя_новой_директории>` как её имя)
|
|
||||||
7. переместите в созданную директорию (`<имя_новой_директории>`)
|
|
||||||
- директорию `public` в `<имя_новой_директории>/public`
|
|
||||||
- директорию `.next/static` в `<имя_новой_директории>/.next/static`
|
|
||||||
- файлы из `.next/standalone` в `<имя_новой_директории>`
|
|
||||||
8. Переместитесь в созданную директорию и выполните команду `pm2 start server.js -n anix`
|
|
||||||
|
|
||||||
### pm2/Обозначения
|
|
||||||
|
|
||||||
- -n - название сервиса в pm2
|
|
||||||
|
|
||||||
### pm2/После развёртывания
|
|
||||||
|
|
||||||
Сервис будет доступен по адресу: `http://<ВАШ IP>:3000/`
|
|
||||||
|
|
||||||
### pm2/Примечание
|
|
||||||
|
|
||||||
Для автоматического запуска приложения, рекомендуется настроить pm2 на автозапуск, с помощью команды: `pm2 startup`
|
|
||||||
|
|
||||||
Полезные ссылки:
|
|
||||||
|
|
||||||
- [PM2: подходим к вопросу процесс-менеджмента с умом @ Habr](https://habr.com/ru/articles/480670/)
|
|
180
DEPLOYMENT.md
|
@ -1,180 +0,0 @@
|
||||||
# AniX Application Deployment
|
|
||||||
|
|
||||||
## Vercel
|
|
||||||
|
|
||||||
Requirements:
|
|
||||||
|
|
||||||
- GitHub account
|
|
||||||
- Vercel account
|
|
||||||
|
|
||||||
1. Fork the repository
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
2. Log in to your Vercel account
|
|
||||||
|
|
||||||
> [!IMPORTANT]
|
|
||||||
> Your Vercel account must be linked with your GitHub account.
|
|
||||||
>
|
|
||||||
> If you don't have a Vercel account, create one by signing in with GitHub.
|
|
||||||
|
|
||||||
3. Click the button to create a new project
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
4. Click the import button next to the repository name
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
5. (optional) Add variables to use your own player:
|
|
||||||
|
|
||||||
- NEXT_PUBLIC_KODIK_PARSER_URL
|
|
||||||
- NEXT_PUBLIC_ANILIBRIA_PARSER_URL
|
|
||||||
- NEXT_PUBLIC_SIBNET_PARSER_URL
|
|
||||||
|
|
||||||
Use the ones you received if you deployed [anix-player-parsers](./player-parsers/README.md)
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
6. Click the "Deploy" button and wait until you see a confirmation
|
|
||||||
7. Click the "Continue to Dashboard" button
|
|
||||||
8. The client will be available at a link of this form, click it to open
|
|
||||||

|
|
||||||
|
|
||||||
## Netlify
|
|
||||||
|
|
||||||
Requirements:
|
|
||||||
|
|
||||||
- GitHub account
|
|
||||||
- Netlify account
|
|
||||||
|
|
||||||
1. Fork the repository
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
2. Log in to your Netlify account
|
|
||||||
|
|
||||||
> [!IMPORTANT]
|
|
||||||
> Your Netlify account must be linked with your GitHub account.
|
|
||||||
>
|
|
||||||
> If you don't have a Netlify account, create one by signing in with GitHub.
|
|
||||||
|
|
||||||
3. Click the button to create a new project
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
4. Click the GitHub button
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
5. Click the repository name
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
6. (optional) Fill in the project name
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
7. (optional) Add variables to use your own player:
|
|
||||||
|
|
||||||
- NEXT_PUBLIC_KODIK_PARSER_URL
|
|
||||||
- NEXT_PUBLIC_ANILIBRIA_PARSER_URL
|
|
||||||
- NEXT_PUBLIC_SIBNET_PARSER_URL
|
|
||||||
|
|
||||||
Use the ones you received if you deployed [anix-player-parsers](./player-parsers/README.md)
|
|
||||||
|
|
||||||
1. 
|
|
||||||
|
|
||||||
2. 
|
|
||||||
|
|
||||||
8. Click the "Deploy" button and wait until you see a confirmation
|
|
||||||
|
|
||||||
9. The client will be available at a link of this form, click it to open
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
## Docker
|
|
||||||
|
|
||||||
Requirements:
|
|
||||||
|
|
||||||
- [docker](https://docs.docker.com/engine/install/)
|
|
||||||
|
|
||||||
### Pre-built
|
|
||||||
|
|
||||||
1. Run the command:
|
|
||||||
|
|
||||||
`docker run -d --name anix -p 3000:3000 radiquum/anix:latest`
|
|
||||||
|
|
||||||
### Manual build
|
|
||||||
|
|
||||||
Additional Requirements:
|
|
||||||
|
|
||||||
- [git](https://git-scm.com/)
|
|
||||||
|
|
||||||
1. Clone the repository `git clone https://github.com/Radiquum/AniX`
|
|
||||||
2. Navigate to the repository directory `cd AniX`
|
|
||||||
3. Run the command `docker build -t anix .`
|
|
||||||
4. Once finished, run the command: `docker run -d --restart always --name anix -p 3000:3000 anix`
|
|
||||||
|
|
||||||
### docker/Flags
|
|
||||||
|
|
||||||
- -d - run container in the background
|
|
||||||
- --restart always - always restart after server reboot
|
|
||||||
- --name - container name
|
|
||||||
- -p - container port to be exposed externally. PORT:3000
|
|
||||||
|
|
||||||
> [!NOTE]
|
|
||||||
> For variables you received if you deployed [anix-player-parsers](./player-parsers/README.md), you need to use `-e VARIABLE=VALUE` before the word anix
|
|
||||||
|
|
||||||
[docker run command](https://docs.docker.com/reference/cli/docker/container/run/)
|
|
||||||
|
|
||||||
### docker/After deployment
|
|
||||||
|
|
||||||
The service will be available at: `http://<YOUR IP><:YOUR PORT>/`
|
|
||||||
|
|
||||||
### docker/Note
|
|
||||||
|
|
||||||
To use your own domain and support HTTPS protocol, you can use Traefik or another reverse proxy with SSL certificate.
|
|
||||||
|
|
||||||
Useful links:
|
|
||||||
|
|
||||||
- [Converter from docker run command to docker compose syntax](https://it-tools.tech/docker-run-to-docker-compose-converter)
|
|
||||||
- [How to setup Traefik + custom domain + SSL](https://letmegooglethat.com/?q=how+to+setup+traefik+with+custom+domain+and+ssl+certificate+from+lets+encrypt%3F)
|
|
||||||
|
|
||||||
## pm2
|
|
||||||
|
|
||||||
Requirements:
|
|
||||||
|
|
||||||
- [git](https://git-scm.com/)
|
|
||||||
- [nodejs 23+ with npm](http://nodejs.org/)
|
|
||||||
- [pm2](https://pm2.keymetrics.io/)
|
|
||||||
|
|
||||||
Instructions:
|
|
||||||
|
|
||||||
1. Clone the repository `git clone https://github.com/Radiquum/AniX`
|
|
||||||
2. Navigate to the repository directory `cd AniX`
|
|
||||||
3. Run the command `npm install`
|
|
||||||
4. (optional) copy `.env.sample` as `.env` and fill it with variables you received if you deployed [anix-player-parsers](./player-parsers/README.md)
|
|
||||||
5. Run the command `npm run build`
|
|
||||||
6. Create a new directory (next we will be refer to its name as `<new_dir>`)
|
|
||||||
7. Move the following files into the new directory (`<new_dir>`):
|
|
||||||
- move `public` directory to `<new_dir>/public`
|
|
||||||
- move `.next/static` directory to `<new_dir>/.next/static`
|
|
||||||
- move files from `.next/standalone` to `<new_dir>`
|
|
||||||
8. Move into the created directory (<new_dir>) and run the command `pm2 start server.js -n anix`
|
|
||||||
|
|
||||||
### pm2/Flags
|
|
||||||
|
|
||||||
- -n - service name in pm2
|
|
||||||
|
|
||||||
### pm2/After deployment
|
|
||||||
|
|
||||||
The service will be available at: `http://<YOUR IP>:3000/`
|
|
||||||
|
|
||||||
### pm2/Note
|
|
||||||
|
|
||||||
To enable automatic application startup, it is recommended to configure pm2 to start on boot using the command: `pm2 startup`
|
|
||||||
|
|
||||||
Useful links:
|
|
||||||
|
|
||||||
- [PM2: managing processes smartly @ Habr](https://habr.com/ru/articles/480670/)
|
|
32
Dockerfile
|
@ -1,32 +0,0 @@
|
||||||
FROM node:23-alpine AS base
|
|
||||||
|
|
||||||
|
|
||||||
FROM base AS deps
|
|
||||||
RUN apk add --no-cache libc6-compat
|
|
||||||
WORKDIR /app
|
|
||||||
COPY package.json package-lock.json ./
|
|
||||||
RUN npm ci
|
|
||||||
|
|
||||||
|
|
||||||
FROM base AS builder
|
|
||||||
WORKDIR /app
|
|
||||||
COPY --from=deps /app/node_modules ./node_modules
|
|
||||||
COPY . .
|
|
||||||
RUN npm run build
|
|
||||||
|
|
||||||
|
|
||||||
FROM base AS runner
|
|
||||||
LABEL org.opencontainers.image.source=https://github.com/radiquum/anix
|
|
||||||
WORKDIR /app
|
|
||||||
ENV NODE_ENV=production
|
|
||||||
RUN addgroup --system --gid 1001 nodejs
|
|
||||||
RUN adduser --system --uid 1001 nextjs
|
|
||||||
COPY --from=builder --chown=nextjs:nodejs /app/public ./public
|
|
||||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
|
||||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
|
||||||
USER nextjs
|
|
||||||
|
|
||||||
EXPOSE 3000
|
|
||||||
ENV PORT=3000
|
|
||||||
ENV HOSTNAME="0.0.0.0"
|
|
||||||
CMD ["node", "server.js"]
|
|
27
README.md
|
@ -2,20 +2,21 @@
|
||||||
|
|
||||||
AniX is an unofficial web client for the Android application Anixart. It allows you to access and manage your Anixart account from a web browser on your desktop or laptop computer.
|
AniX is an unofficial web client for the Android application Anixart. It allows you to access and manage your Anixart account from a web browser on your desktop or laptop computer.
|
||||||
|
|
||||||
|
[Readme [RU]](./docs/REAME.RU.md) | [Browser Extension [RU]](./extension/README.md)
|
||||||
|
|
||||||
|
## Changelog [RU]
|
||||||
|
|
||||||
|
- [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)
|
||||||
|
|
||||||
## Disclaimer
|
## Disclaimer
|
||||||
|
|
||||||
Please note that AniX is an unofficial project and is not affiliated with the developers of Anixart. It is recommended to use the official Anixart app for the most up-to-date features and functionality.
|
Please note that AniX is an unofficial project and is not affiliated with the developers of Anixart. It is recommended to use the official Anixart app for the most up-to-date features and functionality.
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
[[RU] ПРОЧТИ МЕНЯ](./README.RU.md) | [[EN] README](./README.md)
|
|
||||||
|
|
||||||
[[RU] РАЗВЁРТЫВАНИЕ](./DEPLOYMENT.RU.md) | [[EN] DEPLOY](./DEPLOYMENT.md)
|
|
||||||
|
|
||||||
[[RU] Changelogs](./public/changelog)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Screenshots
|
## Screenshots
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
|
@ -53,6 +54,12 @@ Please note that AniX is an unofficial project and is not affiliated with the de
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
1. Use your existing Anixart account
|
||||||
|
2. sync lists, watch history, collections and more
|
||||||
|
3. use almost all features of an android app
|
||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
|
|
||||||
We welcome contributions to this project! If you have any bug fixes, improvements, or new features, please feel free to create a pull request.
|
We welcome contributions to this project! If you have any bug fixes, improvements, or new features, please feel free to create a pull request.
|
17
app/App.tsx
|
@ -4,15 +4,10 @@ import { usePreferencesStore } from "./store/preferences";
|
||||||
import { Navbar } from "./components/Navbar/NavbarUpdate";
|
import { Navbar } from "./components/Navbar/NavbarUpdate";
|
||||||
import { Inter } from "next/font/google";
|
import { Inter } from "next/font/google";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import {
|
import { Button, Modal, ModalBody, ModalFooter, ModalHeader } from "flowbite-react";
|
||||||
Button,
|
|
||||||
Modal,
|
|
||||||
ModalBody,
|
|
||||||
ModalFooter,
|
|
||||||
ModalHeader,
|
|
||||||
} from "flowbite-react";
|
|
||||||
import { Spinner } from "./components/Spinner/Spinner";
|
import { Spinner } from "./components/Spinner/Spinner";
|
||||||
import { ChangelogModal } from "#/components/ChangelogModal/ChangelogModal";
|
import { ChangelogModal } from "#/components/ChangelogModal/ChangelogModal";
|
||||||
|
import PlausibleProvider from "next-plausible";
|
||||||
import { Bounce, ToastContainer } from "react-toastify";
|
import { Bounce, ToastContainer } from "react-toastify";
|
||||||
|
|
||||||
const inter = Inter({ subsets: ["latin"] });
|
const inter = Inter({ subsets: ["latin"] });
|
||||||
|
@ -109,6 +104,14 @@ export const App = (props) => {
|
||||||
</Button>
|
</Button>
|
||||||
</ModalFooter>
|
</ModalFooter>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
{preferencesStore.flags.enableAnalytics && (
|
||||||
|
<PlausibleProvider
|
||||||
|
domain="anix.wah.su"
|
||||||
|
trackLocalhost={false}
|
||||||
|
selfHosted={true}
|
||||||
|
enabled={true}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<ToastContainer
|
<ToastContainer
|
||||||
className={"mx-2 mb-20 sm:mb-0"}
|
className={"mx-2 mb-20 sm:mb-0"}
|
||||||
position="bottom-center"
|
position="bottom-center"
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
export const CURRENT_APP_VERSION = "3.7.0";
|
export const CURRENT_APP_VERSION = "3.6.0";
|
||||||
|
|
||||||
export const API_URL = "https://api.anixart.tv";
|
export const API_URL = "https://api.anixart.tv";
|
||||||
export const API_PREFIX = "/api/proxy";
|
export const API_PREFIX = "/api/proxy";
|
||||||
|
|
|
@ -84,17 +84,9 @@ export const CommentsComment = (props: {
|
||||||
url += `&token=${props.token}`;
|
url += `&token=${props.token}`;
|
||||||
}
|
}
|
||||||
await fetch(url)
|
await fetch(url)
|
||||||
.then((res) => {
|
.then((res) => res.json())
|
||||||
if (res.ok) {
|
|
||||||
return res.json();
|
|
||||||
} else {
|
|
||||||
return { content: [] };
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.then((data) => {
|
.then((data) => {
|
||||||
if (data && data.content) {
|
|
||||||
setReplies(data.content);
|
setReplies(data.content);
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
|
@ -202,8 +194,8 @@ export const CommentsComment = (props: {
|
||||||
</footer>
|
</footer>
|
||||||
<div className="relative flex items-center py-2">
|
<div className="relative flex items-center py-2">
|
||||||
<p className="text-gray-800 whitespace-pre-wrap dark:text-gray-400">
|
<p className="text-gray-800 whitespace-pre-wrap dark:text-gray-400">
|
||||||
{!props.comment.isDeleted ?
|
{!props.comment.isDeleted
|
||||||
props.comment.message
|
? props.comment.message
|
||||||
: "Комментарий был удалён."}
|
: "Комментарий был удалён."}
|
||||||
</p>
|
</p>
|
||||||
{isHidden && (
|
{isHidden && (
|
||||||
|
@ -213,8 +205,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">
|
<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>
|
<p>
|
||||||
{props.comment.likes_count < -5 ?
|
{props.comment.likes_count < -5
|
||||||
"У комментария слишком низкий рейтинг."
|
? "У комментария слишком низкий рейтинг."
|
||||||
: "Данный комментарий может содержать спойлер."}
|
: "Данный комментарий может содержать спойлер."}
|
||||||
</p>
|
</p>
|
||||||
<p className="font-bold">Нажмите, чтобы прочитать</p>
|
<p className="font-bold">Нажмите, чтобы прочитать</p>
|
||||||
|
@ -228,7 +220,7 @@ export const CommentsComment = (props: {
|
||||||
isHidden ? "mt-4" : ""
|
isHidden ? "mt-4" : ""
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{props.token ?
|
{props.token ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="flex items-center text-sm font-medium text-gray-500 hover:underline dark:text-gray-400"
|
className="flex items-center text-sm font-medium text-gray-500 hover:underline dark:text-gray-400"
|
||||||
|
@ -251,7 +243,9 @@ export const CommentsComment = (props: {
|
||||||
</svg>
|
</svg>
|
||||||
Ответить
|
Ответить
|
||||||
</button>
|
</button>
|
||||||
: <span></span>}
|
) : (
|
||||||
|
<span></span>
|
||||||
|
)}
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<Button
|
<Button
|
||||||
color="inline"
|
color="inline"
|
||||||
|
@ -262,16 +256,18 @@ export const CommentsComment = (props: {
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
className={`w-6 h-6 iconify mdi--dislike ${
|
className={`w-6 h-6 iconify mdi--dislike ${
|
||||||
vote == 1 ?
|
vote == 1
|
||||||
"text-red-500 dark:text-red-400"
|
? "text-red-500 dark:text-red-400"
|
||||||
: "text-gray-500 dark:text-gray-400"
|
: "text-gray-500 dark:text-gray-400"
|
||||||
}`}
|
}`}
|
||||||
></span>
|
></span>
|
||||||
</Button>
|
</Button>
|
||||||
<p
|
<p
|
||||||
className={`text-sm font-medium ${
|
className={`text-sm font-medium ${
|
||||||
likes > 0 ? "text-green-500 dark:text-green-400"
|
likes > 0
|
||||||
: likes < 0 ? "text-red-500 dark:text-red-400"
|
? "text-green-500 dark:text-green-400"
|
||||||
|
: likes < 0
|
||||||
|
? "text-red-500 dark:text-red-400"
|
||||||
: "text-gray-500 dark:text-gray-400"
|
: "text-gray-500 dark:text-gray-400"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
|
@ -286,8 +282,8 @@ export const CommentsComment = (props: {
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
className={`w-6 h-6 iconify mdi--like ${
|
className={`w-6 h-6 iconify mdi--like ${
|
||||||
vote == 2 ?
|
vote == 2
|
||||||
"text-green-500 dark:text-green-400"
|
? "text-green-500 dark:text-green-400"
|
||||||
: "text-gray-500 dark:text-gray-400"
|
: "text-gray-500 dark:text-gray-400"
|
||||||
}`}
|
}`}
|
||||||
></span>
|
></span>
|
||||||
|
|
|
@ -41,7 +41,7 @@ export const CommentsMain = (props: {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
{props.comments && props.comments.map((comment: any) => (
|
{props.comments.map((comment: any) => (
|
||||||
<CommentsComment
|
<CommentsComment
|
||||||
key={comment.id}
|
key={comment.id}
|
||||||
release_id={props.release_id}
|
release_id={props.release_id}
|
||||||
|
|
135
app/components/ReleasePlayer/EpisodeSelector.tsx
Normal file
|
@ -0,0 +1,135 @@
|
||||||
|
"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>
|
||||||
|
);
|
||||||
|
};
|
|
@ -1,133 +0,0 @@
|
||||||
"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>
|
|
||||||
);
|
|
||||||
};
|
|
|
@ -1,630 +0,0 @@
|
||||||
.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;
|
|
||||||
}
|
|
760
app/components/ReleasePlayer/MediaThemeSutro.tsx
Normal file
|
@ -0,0 +1,760 @@
|
||||||
|
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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
|
@ -1,267 +0,0 @@
|
||||||
import { tryCatchPlayer, tryCatchAPI } from "#/api/utils";
|
|
||||||
import { env } from 'next-runtime-env';
|
|
||||||
|
|
||||||
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 NEXT_PUBLIC_KODIK_PARSER_URL = env("NEXT_PUBLIC_KODIK_PARSER_URL")
|
|
||||||
if (!NEXT_PUBLIC_KODIK_PARSER_URL) {
|
|
||||||
setPlayerError({
|
|
||||||
message: "Источник не настроен",
|
|
||||||
detail: "переменная 'NEXT_PUBLIC_KODIK_PARSER_URL' не обнаружена",
|
|
||||||
});
|
|
||||||
return { manifest: null, poster: null };
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await _fetchPlayer(
|
|
||||||
`${NEXT_PUBLIC_KODIK_PARSER_URL}/?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}`;
|
|
||||||
let data = null;
|
|
||||||
const NEXT_PUBLIC_ANILIBRIA_PARSER_URL = env("NEXT_PUBLIC_ANILIBRIA_PARSER_URL")
|
|
||||||
if (NEXT_PUBLIC_ANILIBRIA_PARSER_URL) {
|
|
||||||
data = await _fetchPlayer(
|
|
||||||
`${NEXT_PUBLIC_ANILIBRIA_PARSER_URL}/?url=${_url}&player=libria`,
|
|
||||||
setPlayerError
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
data = await _fetchPlayer(_url, 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 NEXT_PUBLIC_SIBNET_PARSER_URL = env("NEXT_PUBLIC_SIBNET_PARSER_URL")
|
|
||||||
if (!NEXT_PUBLIC_SIBNET_PARSER_URL) {
|
|
||||||
setPlayerError({
|
|
||||||
message: "Источник не настроен",
|
|
||||||
detail: "переменная 'NEXT_PUBLIC_SIBNET_PARSER_URL' не обнаружена",
|
|
||||||
});
|
|
||||||
return { manifest: null, poster: null };
|
|
||||||
}
|
|
||||||
const data = await _fetchPlayer(
|
|
||||||
`${NEXT_PUBLIC_SIBNET_PARSER_URL}/?url=${url}&player=sibnet`,
|
|
||||||
setPlayerError
|
|
||||||
);
|
|
||||||
if (data) {
|
|
||||||
let manifest = data.video;
|
|
||||||
let poster = data.poster;
|
|
||||||
return { manifest, poster };
|
|
||||||
}
|
|
||||||
return { manifest: null, poster: null };
|
|
||||||
};
|
|
|
@ -12,7 +12,6 @@ import "swiper/css/navigation";
|
||||||
import "swiper/css/mousewheel";
|
import "swiper/css/mousewheel";
|
||||||
import "swiper/css/scrollbar";
|
import "swiper/css/scrollbar";
|
||||||
import { Navigation, Mousewheel, Scrollbar } from "swiper/modules";
|
import { Navigation, Mousewheel, Scrollbar } from "swiper/modules";
|
||||||
import { usePreferencesStore } from "#/store/preferences";
|
|
||||||
|
|
||||||
const DropdownTheme = {
|
const DropdownTheme = {
|
||||||
floating: {
|
floating: {
|
||||||
|
@ -133,7 +132,6 @@ export const ReleasePlayer = (props: { id: number }) => {
|
||||||
setSelectedSource(player);
|
setSelectedSource(player);
|
||||||
preferredVoiceoverStore.setPreferredPlayer(props.id, player.name);
|
preferredVoiceoverStore.setPreferredPlayer(props.id, player.name);
|
||||||
};
|
};
|
||||||
const preferenceStore = usePreferencesStore();
|
|
||||||
|
|
||||||
function _setError(error: string) {
|
function _setError(error: string) {
|
||||||
setVoiceoverInfo(null);
|
setVoiceoverInfo(null);
|
||||||
|
@ -246,48 +244,25 @@ export const ReleasePlayer = (props: { id: number }) => {
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [props.id, selectedSource, userStore.token]);
|
}, [props.id, selectedSource, userStore.token]);
|
||||||
|
|
||||||
function _addToHistory(episode: any) {
|
async function _addToHistory(episode: any) {
|
||||||
if (props.id && selectedSource && selectedVoiceover && episode) {
|
if (episode && userStore.token) {
|
||||||
const anonEpisodesWatched = getAnonEpisodesWatched(
|
_fetch(
|
||||||
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}`
|
`${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}`
|
`${ENDPOINTS.statistic.markWatched}/${props.id}/${selectedSource.id}/${episode.position}?token=${userStore.token}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
{!voiceoverInfo || !sourcesInfo || !episodeInfo ?
|
{!voiceoverInfo || !sourcesInfo || !episodeInfo ? (
|
||||||
<div className="flex items-center justify-center w-full aspect-video">
|
<div className="flex items-center justify-center w-full aspect-video">
|
||||||
{!error ?
|
{!error ? <Spinner /> : <p>{error}</p>}
|
||||||
<Spinner />
|
|
||||||
: <p>{error}</p>}
|
|
||||||
</div>
|
</div>
|
||||||
: <>
|
) : (
|
||||||
|
<>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
<Dropdown
|
<Dropdown
|
||||||
label={`Озвучка: ${selectedVoiceover.name}`}
|
label={`Озвучка: ${selectedVoiceover.name}`}
|
||||||
|
@ -321,13 +296,15 @@ export const ReleasePlayer = (props: { id: number }) => {
|
||||||
</Dropdown>
|
</Dropdown>
|
||||||
</div>
|
</div>
|
||||||
<div className="aspect-video">
|
<div className="aspect-video">
|
||||||
{selectedEpisode ?
|
{selectedEpisode ? (
|
||||||
<iframe
|
<iframe
|
||||||
allowFullScreen={true}
|
allowFullScreen={true}
|
||||||
src={selectedEpisode.url}
|
src={selectedEpisode.url}
|
||||||
className="w-full h-full rounded-md"
|
className="w-full h-full rounded-md"
|
||||||
></iframe>
|
></iframe>
|
||||||
: <p>Ошибка загрузки плеера</p>}
|
) : (
|
||||||
|
<p>Ошибка загрузки плеера</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<Swiper
|
<Swiper
|
||||||
|
@ -358,48 +335,51 @@ export const ReleasePlayer = (props: { id: number }) => {
|
||||||
>
|
>
|
||||||
<Button
|
<Button
|
||||||
color={
|
color={
|
||||||
selectedEpisode.position === episode.position ?
|
selectedEpisode.position === episode.position
|
||||||
"blue"
|
? "blue"
|
||||||
: "light"
|
: "light"
|
||||||
}
|
}
|
||||||
theme={{ base: "w-full disabled:opacity-100" }}
|
theme={{ base: "w-full disabled:opacity-100" }}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setSelectedEpisode(episode);
|
setSelectedEpisode(episode);
|
||||||
|
episode.is_watched = true;
|
||||||
_addToHistory(episode);
|
_addToHistory(episode);
|
||||||
|
saveAnonEpisodeWatched(
|
||||||
|
props.id,
|
||||||
|
selectedSource.id,
|
||||||
|
selectedVoiceover.id,
|
||||||
|
episode.position
|
||||||
|
);
|
||||||
}}
|
}}
|
||||||
disabled={selectedEpisode.position === episode.position}
|
disabled={selectedEpisode.position === episode.position}
|
||||||
>
|
>
|
||||||
{episode.name ?
|
{episode.name
|
||||||
episode.name
|
? episode.name
|
||||||
: `${
|
: `${
|
||||||
(
|
|
||||||
!["Sibnet", "Sibnet (не работает)"].includes(
|
!["Sibnet", "Sibnet (не работает)"].includes(
|
||||||
selectedSource.name
|
selectedSource.name
|
||||||
)
|
)
|
||||||
) ?
|
? episode.position
|
||||||
episode.position
|
|
||||||
: episode.position + 1
|
: episode.position + 1
|
||||||
} серия`
|
} серия`}
|
||||||
}
|
{episode.is_watched ||
|
||||||
{(
|
|
||||||
episode.is_watched ||
|
|
||||||
getAnonCurrentEpisodeWatched(
|
getAnonCurrentEpisodeWatched(
|
||||||
props.id,
|
props.id,
|
||||||
selectedSource.id,
|
selectedSource.id,
|
||||||
selectedVoiceover.id,
|
selectedVoiceover.id,
|
||||||
episode.position
|
episode.position
|
||||||
)
|
) ? (
|
||||||
) ?
|
|
||||||
<span className="w-5 h-5 ml-2 iconify material-symbols--check-circle"></span>
|
<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>
|
</Button>
|
||||||
</SwiperSlide>
|
</SwiperSlide>
|
||||||
))}
|
))}
|
||||||
</Swiper>
|
</Swiper>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
76
app/components/ReleasePlayer/SourceSelector.tsx
Normal file
|
@ -0,0 +1,76 @@
|
||||||
|
"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>
|
||||||
|
);
|
||||||
|
};
|
|
@ -1,117 +0,0 @@
|
||||||
"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>
|
|
||||||
);
|
|
||||||
};
|
|
104
app/components/ReleasePlayer/VoiceoverSelector.tsx
Normal file
|
@ -0,0 +1,104 @@
|
||||||
|
"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>
|
||||||
|
);
|
||||||
|
};
|
|
@ -1,109 +0,0 @@
|
||||||
"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>
|
|
||||||
);
|
|
||||||
};
|
|
|
@ -251,20 +251,19 @@ export const SettingsModal = (props: { isOpen: boolean; setIsOpen: any }) => {
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p className=" dark:text-white">Сохранять историю просмотра</p>
|
<p className=" dark:text-white">Отправка аналитики</p>
|
||||||
<p className="max-w-sm text-gray-500 dark:text-gray-300">
|
<p className="text-gray-500 dark:text-gray-300">
|
||||||
При отключении, история не будет сохранятся как локально, так и
|
Требуется перезагрузка для применения
|
||||||
на аккаунте
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<ToggleSwitch
|
<ToggleSwitch
|
||||||
color="blue"
|
color="blue"
|
||||||
onChange={() =>
|
onChange={() =>
|
||||||
preferenceStore.setFlags({
|
preferenceStore.setFlags({
|
||||||
saveWatchHistory: !preferenceStore.flags.saveWatchHistory,
|
enableAnalytics: !preferenceStore.flags.enableAnalytics,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
checked={preferenceStore.flags.saveWatchHistory}
|
checked={preferenceStore.flags.enableAnalytics}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<HR className="my-4 dark:bg-slate-400" />
|
<HR className="my-4 dark:bg-slate-400" />
|
||||||
|
|
|
@ -1,17 +1,10 @@
|
||||||
export const Spinner = (props: { size?: "base" | "md" | "lg" }) => {
|
export const Spinner = () => {
|
||||||
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 (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div role="status">
|
<div role="status">
|
||||||
<svg
|
<svg
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
className={`inline ${size} text-gray-200 animate-spin dark:text-gray-600 fill-blue-600`}
|
className="inline w-8 h-8 text-gray-200 animate-spin dark:text-gray-600 fill-blue-600"
|
||||||
viewBox="0 0 100 101"
|
viewBox="0 0 100 101"
|
||||||
fill="none"
|
fill="none"
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
|
|
@ -1,7 +1,6 @@
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
import { App } from "./App";
|
import { App } from "./App";
|
||||||
import { ThemeModeScript } from "flowbite-react";
|
import { ThemeModeScript } from "flowbite-react";
|
||||||
import { PublicEnvScript } from 'next-runtime-env';
|
|
||||||
|
|
||||||
export const metadata = {
|
export const metadata = {
|
||||||
metadataBase: new URL("https://anix.wah.su"),
|
metadataBase: new URL("https://anix.wah.su"),
|
||||||
|
@ -34,7 +33,6 @@ export default function RootLayout({ children }) {
|
||||||
return (
|
return (
|
||||||
<html lang="en" suppressHydrationWarning>
|
<html lang="en" suppressHydrationWarning>
|
||||||
<head>
|
<head>
|
||||||
<PublicEnvScript />
|
|
||||||
<ThemeModeScript />
|
<ThemeModeScript />
|
||||||
</head>
|
</head>
|
||||||
<App>{children}</App>
|
<App>{children}</App>
|
||||||
|
|
|
@ -45,8 +45,8 @@ export const AboutPage = () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid grid-cols-1 gap-2 mb-4 md:grid-cols-2">
|
<div className="grid grid-cols-1 gap-2 md:grid-cols-2 lg:grid-cols-3">
|
||||||
<Card className="md:col-span-2">
|
<Card className="md:col-span-2 lg:col-span-3">
|
||||||
<div className="flex flex-col items-center gap-4 md:flex-row">
|
<div className="flex flex-col items-center gap-4 md:flex-row">
|
||||||
<Image
|
<Image
|
||||||
src="/images/icons/icon-512x512.png"
|
src="/images/icons/icon-512x512.png"
|
||||||
|
@ -62,13 +62,32 @@ export const AboutPage = () => {
|
||||||
<p className="max-w-[900px]">
|
<p className="max-w-[900px]">
|
||||||
AniX - это неофициальный веб-клиент для Android-приложения
|
AniX - это неофициальный веб-клиент для Android-приложения
|
||||||
Anixart. Он позволяет вам получать доступ к своей учетной записи
|
Anixart. Он позволяет вам получать доступ к своей учетной записи
|
||||||
Anixart и управлять ею из веб-браузера компьютера или телефона.
|
Anixart и управлять ею из веб-браузера. Так-же можно
|
||||||
В клиенте доступна синхронизация с аккаунтом и управление его списками и избранным.
|
синхронизировать и управлять списками и избранным. И самое главное
|
||||||
А самое главное - это возможность смотреть все доступные аниме из базы Anixart даже недоступные на территории РФ.
|
смотреть все доступные аниме из базы Anixart.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
<Link href={"https://wah.su/radiquum"} target="_blank">
|
||||||
|
<Card>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Image
|
||||||
|
src="https://radiquum.wah.su/static/avatar_512.jpg"
|
||||||
|
className="flex-shrink-0 w-16 h-16 rounded-full"
|
||||||
|
alt="developer image"
|
||||||
|
width={128}
|
||||||
|
height={128}
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-bold">Radiquum</h1>
|
||||||
|
<p className="text-sm text-gray-500 dark:text-gray-200">
|
||||||
|
Разработчик
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</Link>
|
||||||
<Link href={"https://t.me/anix_web"} target="_blank">
|
<Link href={"https://t.me/anix_web"} target="_blank">
|
||||||
<Card>
|
<Card>
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
|
@ -95,7 +114,7 @@ export const AboutPage = () => {
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
</Link>
|
</Link>
|
||||||
<Card className="md:col-span-2">
|
<Card className="md:col-span-2 lg:col-span-3">
|
||||||
<h1 className="text-2xl font-bold">Список изменений</h1>
|
<h1 className="text-2xl font-bold">Список изменений</h1>
|
||||||
<Markdown className={Styles.markdown}>{current.changelog}</Markdown>
|
<Markdown className={Styles.markdown}>{current.changelog}</Markdown>
|
||||||
<Accordion collapseAll={true}>
|
<Accordion collapseAll={true}>
|
||||||
|
|
|
@ -119,7 +119,7 @@ export const ReleasePage = (props: any) => {
|
||||||
data.release.status.name.toLowerCase() != "анонс" && (
|
data.release.status.name.toLowerCase() != "анонс" && (
|
||||||
<>
|
<>
|
||||||
{preferenceStore.params.experimental.newPlayer ?
|
{preferenceStore.params.experimental.newPlayer ?
|
||||||
<ReleasePlayerCustom id={props.id} token={userStore.token} title={data.release.title_ru || data.release.title_original || ""} />
|
<ReleasePlayerCustom id={props.id} token={userStore.token} />
|
||||||
: <ReleasePlayer id={props.id} />}
|
: <ReleasePlayer id={props.id} />}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
|
@ -9,6 +9,7 @@ interface preferencesState {
|
||||||
// saveSearchHistory: boolean;
|
// saveSearchHistory: boolean;
|
||||||
saveWatchHistory?: boolean;
|
saveWatchHistory?: boolean;
|
||||||
showChangelog?: boolean;
|
showChangelog?: boolean;
|
||||||
|
enableAnalytics?: boolean;
|
||||||
showNavbarTitles?: "always" | "links" | "selected" | "never";
|
showNavbarTitles?: "always" | "links" | "selected" | "never";
|
||||||
showFifthButton?: null | 3 | 4 | 5;
|
showFifthButton?: null | 3 | 4 | 5;
|
||||||
};
|
};
|
||||||
|
@ -42,6 +43,7 @@ export const usePreferencesStore = create<preferencesState>()(
|
||||||
// saveSearchHistory: true,
|
// saveSearchHistory: true,
|
||||||
saveWatchHistory: true,
|
saveWatchHistory: true,
|
||||||
showChangelog: true,
|
showChangelog: true,
|
||||||
|
enableAnalytics: true,
|
||||||
showNavbarTitles: "always",
|
showNavbarTitles: "always",
|
||||||
showFifthButton: null,
|
showFifthButton: null,
|
||||||
},
|
},
|
||||||
|
|
|
@ -2,20 +2,21 @@
|
||||||
|
|
||||||
AniX - это неофициальный веб-клиент для Android-приложения Anixart. Он позволяет вам получать доступ к своей учетной записи Anixart и управлять ею из веб-браузера на вашем настольном компьютере или ноутбуке.
|
AniX - это неофициальный веб-клиент для Android-приложения Anixart. Он позволяет вам получать доступ к своей учетной записи Anixart и управлять ею из веб-браузера на вашем настольном компьютере или ноутбуке.
|
||||||
|
|
||||||
|
[Расширение для браузера](./extension/README.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)
|
||||||
|
|
||||||
## Отказ от ответственности
|
## Отказ от ответственности
|
||||||
|
|
||||||
Пожалуйста, обратите внимание, что Anix является неофициальным проектом и не связан с разработчиками Anixart. Рекомендуется использовать официальное приложение Anixart для получения наиболее актуальных функций.
|
Пожалуйста, обратите внимание, что Anix является неофициальным проектом и не связан с разработчиками Anixart. Рекомендуется использовать официальное приложение Anixart для получения наиболее актуальных функций.
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
[[RU] ПРОЧТИ МЕНЯ](./README.RU.md) | [[EN] README](./README.md)
|
|
||||||
|
|
||||||
[[RU] РАЗВЁРТЫВАНИЕ](./DEPLOYMENT.RU.md) | [[EN] DEPLOY](./DEPLOYMENT.md)
|
|
||||||
|
|
||||||
[[RU] Списки изменений](./public/changelog)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Скриншоты
|
## Скриншоты
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
|
@ -53,6 +54,12 @@ AniX - это неофициальный веб-клиент для Android-пр
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
|
## Возможности
|
||||||
|
|
||||||
|
1. Используйте свой существующий аккаунт на Anixart
|
||||||
|
2. Синхронизируйте списки, историю просмотров, коллекции и многое другое
|
||||||
|
3. используйте практически все функции приложения для Android
|
||||||
|
|
||||||
## Внесение вклада
|
## Внесение вклада
|
||||||
|
|
||||||
Мы приветствуем вклад в этот проект! Если у вас есть какие-либо исправления ошибок, улучшения или новые функции, пожалуйста, не стесняйтесь отправлять запрос на обновление.
|
Мы приветствуем вклад в этот проект! Если у вас есть какие-либо исправления ошибок, улучшения или новые функции, пожалуйста, не стесняйтесь отправлять запрос на обновление.
|
Before ![]() (image error) Size: 1.5 KiB |
Before ![]() (image error) Size: 11 KiB |
Before ![]() (image error) Size: 19 KiB |
Before ![]() (image error) Size: 5.8 KiB |
Before ![]() (image error) Size: 7 KiB |
Before ![]() (image error) Size: 7 KiB |
Before ![]() (image error) Size: 19 KiB |
Before ![]() (image error) Size: 39 KiB |
Before ![]() (image error) Size: 13 KiB |
Before ![]() (image error) Size: 9.2 KiB |
Before ![]() (image error) Size: 49 KiB |
Before ![]() (image error) Size: 70 KiB |
20
extension/README.md
Normal file
|
@ -0,0 +1,20 @@
|
||||||
|
Это расширение для firefox и chrome для добавления кнопки Смотреть в Anix на сайт anixart.tv, а так-же найти в Anix на сайт кинопоиск, если обнаружен жанр аниме
|
||||||
|
|
||||||
|
## Скачать
|
||||||
|
|
||||||
|
Firefox: https://addons.mozilla.org/en-US/firefox/addon/watch-on-anix/
|
||||||
|
|
||||||
|
Chrome: https://github.com/Radiquum/anix/raw/V3/extension/chrome/watch-on-anix-chrome.zip
|
||||||
|
|
||||||
|
## Установка
|
||||||
|
|
||||||
|
Firefox:
|
||||||
|
|
||||||
|
- Загрузите расширение из AMO
|
||||||
|
|
||||||
|
Chrome:
|
||||||
|
|
||||||
|
1. скачайте и распакуйте архив
|
||||||
|
2. зайдите в расширения браузера chrome://extensions/
|
||||||
|
3. включите режим разработчика
|
||||||
|
4. нажмите "загрузить распакованное расширение" и выберите директорию куда вы распаковали архив
|
BIN
extension/chrome/icon-16x16.png
Normal file
After ![]() (image error) Size: 875 B |
BIN
extension/chrome/icon-32x32.png
Normal file
After ![]() (image error) Size: 2.6 KiB |
BIN
extension/chrome/icon-48x48.png
Normal file
After ![]() (image error) Size: 5.2 KiB |
BIN
extension/chrome/icon-72x72.png
Normal file
After ![]() (image error) Size: 10 KiB |
BIN
extension/chrome/icon-96x96.png
Normal file
After ![]() (image error) Size: 16 KiB |
116
extension/chrome/main.js
Normal file
|
@ -0,0 +1,116 @@
|
||||||
|
function determineHost() {
|
||||||
|
const url = new URL(window.location.href);
|
||||||
|
return {
|
||||||
|
host: url.host,
|
||||||
|
pathname: url.pathname,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function addButtonToAnixart(pathname) {
|
||||||
|
// find a container and an open in app link with button
|
||||||
|
const container = document.querySelector('div[style="text-align: center;"]');
|
||||||
|
const openInAppLink = document.querySelector('a[href^="anixart"');
|
||||||
|
const openInAppLinkButton = openInAppLink.querySelector("button");
|
||||||
|
openInAppLinkButton.style = "margin-top: 0px !important;"; // disable default button margin
|
||||||
|
openInAppLinkButton.classList = "btn btn-secondary"; // change default button from primary to secondary
|
||||||
|
|
||||||
|
// create a custom footer
|
||||||
|
const footer = document.createElement("div");
|
||||||
|
footer.style =
|
||||||
|
"display: flex; flex-wrap: wrap; gap: 8px; justify-content: center; align-items: center; margin-top: 20px;";
|
||||||
|
|
||||||
|
// create and set custom link
|
||||||
|
const link = document.createElement("a");
|
||||||
|
const button = document.createElement("button");
|
||||||
|
button.style = "margin-top: 0px !important;";
|
||||||
|
button.classList = "btn btn-primary";
|
||||||
|
button.textContent = "Открыть в Anix";
|
||||||
|
|
||||||
|
const url = new URL(window.location.href);
|
||||||
|
link.href = `https://anix.wah.su${pathname}?ref=anixart.tv&source=extension`;
|
||||||
|
link.appendChild(button);
|
||||||
|
|
||||||
|
// append link and open in app link to footer
|
||||||
|
footer.appendChild(link);
|
||||||
|
footer.appendChild(openInAppLink);
|
||||||
|
|
||||||
|
// append footer to container
|
||||||
|
container.appendChild(footer);
|
||||||
|
}
|
||||||
|
|
||||||
|
function kinopoiskIsAnimeGenrePresent() {
|
||||||
|
const genre = document.querySelector('a[href^="/lists/movies/genre--anime"]');
|
||||||
|
|
||||||
|
if (genre) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function addButtonToKinopoisk() {
|
||||||
|
let isAnime = kinopoiskIsAnimeGenrePresent();
|
||||||
|
if (!isAnime) {
|
||||||
|
console.log("genre not found");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let title = document.querySelector('h1[itemprop="name"]');
|
||||||
|
if (!title) {
|
||||||
|
console.log("title not found");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
title = title.textContent.split(" (")[0];
|
||||||
|
|
||||||
|
const buttonStyle = `
|
||||||
|
display: inline-block;
|
||||||
|
font-weight: 400;
|
||||||
|
text-align: center;
|
||||||
|
vertical-align: middle;
|
||||||
|
cursor: pointer;
|
||||||
|
-webkit-user-select: none;
|
||||||
|
-moz-user-select: none;
|
||||||
|
-ms-user-select: none;
|
||||||
|
user-select: none;
|
||||||
|
background-color: transparent;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
border-radius: .25rem;
|
||||||
|
transition: color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out;
|
||||||
|
padding: 1rem 2rem;
|
||||||
|
color: #fff;
|
||||||
|
background-color: #F04E4E;
|
||||||
|
border-color: #F04E4E;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const buttonHoverStyle = `
|
||||||
|
color: #fff !important;
|
||||||
|
background-color: #E23D3D !important;
|
||||||
|
border-color: #E23D3D !important;
|
||||||
|
`
|
||||||
|
|
||||||
|
const link = document.createElement("a");
|
||||||
|
const button = document.createElement("button");
|
||||||
|
|
||||||
|
link.style =
|
||||||
|
"text-decoration: none; position: fixed; bottom: 0; right: 0; margin: 1.5rem; z-index: 1000;";
|
||||||
|
link.href = "https://anix.wah.su/search?q=" + title + "&ref=kinopoisk.ru&source=extension";
|
||||||
|
link.appendChild(button);
|
||||||
|
button.style = buttonStyle;
|
||||||
|
button.onmouseover = function () {
|
||||||
|
button.style = buttonStyle + buttonHoverStyle
|
||||||
|
}
|
||||||
|
button.onmouseout = function () {
|
||||||
|
button.style = buttonStyle;
|
||||||
|
}
|
||||||
|
button.textContent = "Найти в Anix";
|
||||||
|
|
||||||
|
document.body.appendChild(link);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { host, pathname } = determineHost();
|
||||||
|
|
||||||
|
if (host == "anixart.tv") {
|
||||||
|
addButtonToAnixart(pathname);
|
||||||
|
} else if (host == "www.kinopoisk.ru") {
|
||||||
|
addButtonToKinopoisk();
|
||||||
|
}
|
27
extension/chrome/manifest.json
Normal file
|
@ -0,0 +1,27 @@
|
||||||
|
{
|
||||||
|
"manifest_version": 3,
|
||||||
|
"version": "1.2",
|
||||||
|
"name": "Watch on Anix",
|
||||||
|
"description": "Adds a button to watch on Anix.",
|
||||||
|
"content_scripts": [
|
||||||
|
{
|
||||||
|
"matches": [
|
||||||
|
"https://anixart.tv/release/*",
|
||||||
|
"https://anixart.tv/collection/*",
|
||||||
|
"https://anixart.tv/profile/*",
|
||||||
|
"https://www.kinopoisk.ru/film/*",
|
||||||
|
"https://www.kinopoisk.ru/series/*"
|
||||||
|
],
|
||||||
|
"js": [
|
||||||
|
"main.js"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"icons": {
|
||||||
|
"16": "icon-16x16.png",
|
||||||
|
"32": "icon-32x32.png",
|
||||||
|
"48": "icon-48x48.png",
|
||||||
|
"72": "icon-72x72.png",
|
||||||
|
"96": "icon-96x96.png"
|
||||||
|
}
|
||||||
|
}
|
BIN
extension/chrome/watch-on-anix-chrome.zip
Normal file
BIN
extension/firefox/icon-16x16.png
Normal file
After ![]() (image error) Size: 875 B |
BIN
extension/firefox/icon-32x32.png
Normal file
After ![]() (image error) Size: 2.6 KiB |
BIN
extension/firefox/icon-48x48.png
Normal file
After ![]() (image error) Size: 5.2 KiB |
BIN
extension/firefox/icon-72x72.png
Normal file
After ![]() (image error) Size: 10 KiB |
BIN
extension/firefox/icon-96x96.png
Normal file
After ![]() (image error) Size: 16 KiB |
116
extension/firefox/main.js
Normal file
|
@ -0,0 +1,116 @@
|
||||||
|
function determineHost() {
|
||||||
|
const url = new URL(window.location.href);
|
||||||
|
return {
|
||||||
|
host: url.host,
|
||||||
|
pathname: url.pathname,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function addButtonToAnixart(pathname) {
|
||||||
|
// find a container and an open in app link with button
|
||||||
|
const container = document.querySelector('div[style="text-align: center;"]');
|
||||||
|
const openInAppLink = document.querySelector('a[href^="anixart"');
|
||||||
|
const openInAppLinkButton = openInAppLink.querySelector("button");
|
||||||
|
openInAppLinkButton.style = "margin-top: 0px !important;"; // disable default button margin
|
||||||
|
openInAppLinkButton.classList = "btn btn-secondary"; // change default button from primary to secondary
|
||||||
|
|
||||||
|
// create a custom footer
|
||||||
|
const footer = document.createElement("div");
|
||||||
|
footer.style =
|
||||||
|
"display: flex; flex-wrap: wrap; gap: 8px; justify-content: center; align-items: center; margin-top: 20px;";
|
||||||
|
|
||||||
|
// create and set custom link
|
||||||
|
const link = document.createElement("a");
|
||||||
|
const button = document.createElement("button");
|
||||||
|
button.style = "margin-top: 0px !important;";
|
||||||
|
button.classList = "btn btn-primary";
|
||||||
|
button.textContent = "Открыть в Anix";
|
||||||
|
|
||||||
|
const url = new URL(window.location.href);
|
||||||
|
link.href = `https://anix.wah.su${pathname}?ref=anixart.tv&source=extension`;
|
||||||
|
link.appendChild(button);
|
||||||
|
|
||||||
|
// append link and open in app link to footer
|
||||||
|
footer.appendChild(link);
|
||||||
|
footer.appendChild(openInAppLink);
|
||||||
|
|
||||||
|
// append footer to container
|
||||||
|
container.appendChild(footer);
|
||||||
|
}
|
||||||
|
|
||||||
|
function kinopoiskIsAnimeGenrePresent() {
|
||||||
|
const genre = document.querySelector('a[href^="/lists/movies/genre--anime"]');
|
||||||
|
|
||||||
|
if (genre) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function addButtonToKinopoisk() {
|
||||||
|
let isAnime = kinopoiskIsAnimeGenrePresent();
|
||||||
|
if (!isAnime) {
|
||||||
|
console.log("genre not found");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let title = document.querySelector('h1[itemprop="name"]');
|
||||||
|
if (!title) {
|
||||||
|
console.log("title not found");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
title = title.textContent.split(" (")[0];
|
||||||
|
|
||||||
|
const buttonStyle = `
|
||||||
|
display: inline-block;
|
||||||
|
font-weight: 400;
|
||||||
|
text-align: center;
|
||||||
|
vertical-align: middle;
|
||||||
|
cursor: pointer;
|
||||||
|
-webkit-user-select: none;
|
||||||
|
-moz-user-select: none;
|
||||||
|
-ms-user-select: none;
|
||||||
|
user-select: none;
|
||||||
|
background-color: transparent;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
border-radius: .25rem;
|
||||||
|
transition: color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out;
|
||||||
|
padding: 1rem 2rem;
|
||||||
|
color: #fff;
|
||||||
|
background-color: #F04E4E;
|
||||||
|
border-color: #F04E4E;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const buttonHoverStyle = `
|
||||||
|
color: #fff !important;
|
||||||
|
background-color: #E23D3D !important;
|
||||||
|
border-color: #E23D3D !important;
|
||||||
|
`
|
||||||
|
|
||||||
|
const link = document.createElement("a");
|
||||||
|
const button = document.createElement("button");
|
||||||
|
|
||||||
|
link.style =
|
||||||
|
"text-decoration: none; position: fixed; bottom: 0; right: 0; margin: 1.5rem; z-index: 1000;";
|
||||||
|
link.href = "https://anix.wah.su/search?q=" + title + "&ref=kinopoisk.ru&source=extension";
|
||||||
|
link.appendChild(button);
|
||||||
|
button.style = buttonStyle;
|
||||||
|
button.onmouseover = function () {
|
||||||
|
button.style = buttonStyle + buttonHoverStyle
|
||||||
|
}
|
||||||
|
button.onmouseout = function () {
|
||||||
|
button.style = buttonStyle;
|
||||||
|
}
|
||||||
|
button.textContent = "Найти в Anix";
|
||||||
|
|
||||||
|
document.body.appendChild(link);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { host, pathname } = determineHost();
|
||||||
|
|
||||||
|
if (host == "anixart.tv") {
|
||||||
|
addButtonToAnixart(pathname);
|
||||||
|
} else if (host == "www.kinopoisk.ru") {
|
||||||
|
addButtonToKinopoisk();
|
||||||
|
}
|
32
extension/firefox/manifest.json
Normal file
|
@ -0,0 +1,32 @@
|
||||||
|
{
|
||||||
|
"manifest_version": 2,
|
||||||
|
"version": "1.2",
|
||||||
|
"name": "Watch on Anix",
|
||||||
|
"description": "Adds a button to watch on Anix.",
|
||||||
|
"browser_specific_settings": {
|
||||||
|
"gecko": {
|
||||||
|
"id": "{8c53d0c2-43ad-4498-b700-290bd2e1030f}"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"content_scripts": [
|
||||||
|
{
|
||||||
|
"matches": [
|
||||||
|
"https://anixart.tv/release/*",
|
||||||
|
"https://anixart.tv/collection/*",
|
||||||
|
"https://anixart.tv/profile/*",
|
||||||
|
"https://www.kinopoisk.ru/film/*",
|
||||||
|
"https://www.kinopoisk.ru/series/*"
|
||||||
|
],
|
||||||
|
"js": [
|
||||||
|
"main.js"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"icons": {
|
||||||
|
"16": "icon-16x16.png",
|
||||||
|
"32": "icon-32x32.png",
|
||||||
|
"48": "icon-48x48.png",
|
||||||
|
"72": "icon-72x72.png",
|
||||||
|
"96": "icon-96x96.png"
|
||||||
|
}
|
||||||
|
}
|
BIN
extension/firefox/watch-on-anix-firefox.zip
Normal file
|
@ -1,16 +1,11 @@
|
||||||
|
const { withPlausibleProxy } = require("next-plausible");
|
||||||
const withFlowbiteReact = require("flowbite-react/plugin/nextjs");
|
const withFlowbiteReact = require("flowbite-react/plugin/nextjs");
|
||||||
/** @type {import('next').NextConfig} */
|
/** @type {import('next').NextConfig} */
|
||||||
const NextConfig = {
|
const NextConfig = {
|
||||||
output: "standalone",
|
|
||||||
reactStrictMode: false,
|
reactStrictMode: false,
|
||||||
images: {
|
images: {
|
||||||
unoptimized: true,
|
unoptimized: true,
|
||||||
},
|
},
|
||||||
env: {
|
|
||||||
NEXT_PUBLIC_KODIK_PARSER_URL: process.env.NEXT_PUBLIC_KODIK_PARSER_URL,
|
|
||||||
NEXT_PUBLIC_ANILIBRIA_PARSER_URL: process.env.NEXT_PUBLIC_ANILIBRIA_PARSER_URL,
|
|
||||||
NEXT_PUBLIC_SIBNET_PARSER_URL: process.env.NEXT_PUBLIC_SIBNET_PARSER_URL,
|
|
||||||
},
|
|
||||||
async headers() {
|
async headers() {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
|
@ -80,6 +75,8 @@ const NextConfig = {
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const config = withFlowbiteReact(NextConfig);
|
const config = withPlausibleProxy({
|
||||||
|
customDomain: "https://analytics.wah.su",
|
||||||
|
})(withFlowbiteReact(NextConfig));
|
||||||
|
|
||||||
module.exports = config;
|
module.exports = config;
|
||||||
|
|
47
package-lock.json
generated
|
@ -15,9 +15,9 @@
|
||||||
"flowbite-react": "^0.11.7",
|
"flowbite-react": "^0.11.7",
|
||||||
"hls-video-element": "^1.5.0",
|
"hls-video-element": "^1.5.0",
|
||||||
"markdown-to-jsx": "^7.4.7",
|
"markdown-to-jsx": "^7.4.7",
|
||||||
"media-chrome": "^4.9.0",
|
"media-chrome": "^4.8.0",
|
||||||
"next": "^14.2.26",
|
"next": "^14.2.26",
|
||||||
"next-runtime-env": "^3.3.0",
|
"next-plausible": "^3.12.1",
|
||||||
"prettier": "^3.5.3",
|
"prettier": "^3.5.3",
|
||||||
"react": "^18",
|
"react": "^18",
|
||||||
"react-cropper": "^2.3.3",
|
"react-cropper": "^2.3.3",
|
||||||
|
@ -32,7 +32,6 @@
|
||||||
"@iconify-json/fa6-brands": "^1.1.21",
|
"@iconify-json/fa6-brands": "^1.1.21",
|
||||||
"@iconify-json/material-symbols": "^1.1.83",
|
"@iconify-json/material-symbols": "^1.1.83",
|
||||||
"@iconify-json/mdi": "^1.1.67",
|
"@iconify-json/mdi": "^1.1.67",
|
||||||
"@iconify-json/solar": "^1.2.2",
|
|
||||||
"@iconify-json/twemoji": "^1.1.15",
|
"@iconify-json/twemoji": "^1.1.15",
|
||||||
"@iconify/tailwind": "^1.1.1",
|
"@iconify/tailwind": "^1.1.1",
|
||||||
"@types/node": "20.14.12",
|
"@types/node": "20.14.12",
|
||||||
|
@ -42,7 +41,6 @@
|
||||||
"eslint-config-next": "14.2.5",
|
"eslint-config-next": "14.2.5",
|
||||||
"eslint-plugin-react-refresh": "^0.4.19",
|
"eslint-plugin-react-refresh": "^0.4.19",
|
||||||
"postcss": "^8",
|
"postcss": "^8",
|
||||||
"tailwind-scrollbar": "^3.1.0",
|
|
||||||
"tailwindcss": "^3.4.1"
|
"tailwindcss": "^3.4.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
@ -282,16 +280,6 @@
|
||||||
"@iconify/types": "*"
|
"@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": {
|
"node_modules/@iconify-json/twemoji": {
|
||||||
"version": "1.2.2",
|
"version": "1.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/@iconify-json/twemoji/-/twemoji-1.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/@iconify-json/twemoji/-/twemoji-1.2.2.tgz",
|
||||||
|
@ -4956,18 +4944,18 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/next-runtime-env": {
|
"node_modules/next-plausible": {
|
||||||
"version": "3.3.0",
|
"version": "3.12.4",
|
||||||
"resolved": "https://registry.npmjs.org/next-runtime-env/-/next-runtime-env-3.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/next-plausible/-/next-plausible-3.12.4.tgz",
|
||||||
"integrity": "sha512-JgKVnog9mNbjbjH9csVpMnz2tB2cT5sLF+7O47i6Ze/s/GoiKdV7dHhJHk1gwXpo6h5qPj5PTzryldtSjvrHuQ==",
|
"integrity": "sha512-cD3+ixJxf8yBYvsideTxqli3fvrB7R4BXcvsNJz8Sm2X1QN039WfiXjCyNWkub4h5++rRs6fHhchUMnOuJokcg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"funding": {
|
||||||
"next": "^14",
|
"url": "https://github.com/4lejandrito/next-plausible?sponsor=1"
|
||||||
"react": "^18"
|
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"next": "^14",
|
"next": "^11.1.0 || ^12.0.0 || ^13.0.0 || ^14.0.0 || ^15.0.0 ",
|
||||||
"react": "^18"
|
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||||
|
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/next/node_modules/postcss": {
|
"node_modules/next/node_modules/postcss": {
|
||||||
|
@ -6676,19 +6664,6 @@
|
||||||
"url": "https://github.com/sponsors/dcastil"
|
"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": {
|
"node_modules/tailwindcss": {
|
||||||
"version": "3.4.17",
|
"version": "3.4.17",
|
||||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.17.tgz",
|
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.17.tgz",
|
||||||
|
|
|
@ -16,9 +16,9 @@
|
||||||
"flowbite-react": "^0.11.7",
|
"flowbite-react": "^0.11.7",
|
||||||
"hls-video-element": "^1.5.0",
|
"hls-video-element": "^1.5.0",
|
||||||
"markdown-to-jsx": "^7.4.7",
|
"markdown-to-jsx": "^7.4.7",
|
||||||
"media-chrome": "^4.9.0",
|
"media-chrome": "^4.8.0",
|
||||||
"next": "^14.2.26",
|
"next": "^14.2.26",
|
||||||
"next-runtime-env": "^3.3.0",
|
"next-plausible": "^3.12.1",
|
||||||
"prettier": "^3.5.3",
|
"prettier": "^3.5.3",
|
||||||
"react": "^18",
|
"react": "^18",
|
||||||
"react-cropper": "^2.3.3",
|
"react-cropper": "^2.3.3",
|
||||||
|
@ -33,7 +33,6 @@
|
||||||
"@iconify-json/fa6-brands": "^1.1.21",
|
"@iconify-json/fa6-brands": "^1.1.21",
|
||||||
"@iconify-json/material-symbols": "^1.1.83",
|
"@iconify-json/material-symbols": "^1.1.83",
|
||||||
"@iconify-json/mdi": "^1.1.67",
|
"@iconify-json/mdi": "^1.1.67",
|
||||||
"@iconify-json/solar": "^1.2.2",
|
|
||||||
"@iconify-json/twemoji": "^1.1.15",
|
"@iconify-json/twemoji": "^1.1.15",
|
||||||
"@iconify/tailwind": "^1.1.1",
|
"@iconify/tailwind": "^1.1.1",
|
||||||
"@types/node": "20.14.12",
|
"@types/node": "20.14.12",
|
||||||
|
@ -43,7 +42,6 @@
|
||||||
"eslint-config-next": "14.2.5",
|
"eslint-config-next": "14.2.5",
|
||||||
"eslint-plugin-react-refresh": "^0.4.19",
|
"eslint-plugin-react-refresh": "^0.4.19",
|
||||||
"postcss": "^8",
|
"postcss": "^8",
|
||||||
"tailwind-scrollbar": "^3.1.0",
|
|
||||||
"tailwindcss": "^3.4.1"
|
"tailwindcss": "^3.4.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,2 +0,0 @@
|
||||||
node_modules
|
|
||||||
README.md
|
|
|
@ -1,15 +0,0 @@
|
||||||
FROM node:23-alpine
|
|
||||||
|
|
||||||
LABEL org.opencontainers.image.source=https://github.com/radiquum/anix
|
|
||||||
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
COPY package.json package-lock.json ./
|
|
||||||
RUN npm ci
|
|
||||||
|
|
||||||
COPY *.ts ./
|
|
||||||
|
|
||||||
EXPOSE 7000
|
|
||||||
ENV PORT=7000
|
|
||||||
ENV HOSTNAME="0.0.0.0"
|
|
||||||
CMD ["npm", "run", "serve"]
|
|
|
@ -1,114 +0,0 @@
|
||||||
# AniX - Player Parsers
|
|
||||||
|
|
||||||
Данный под-проект позволяет получить прямые ссылки на видеофайлы с источников Sibnet, Kodik, Anilibria (источник: libria)
|
|
||||||
|
|
||||||
Он может использоваться как для основного проекта AniX, так и как отдельный сервис.
|
|
||||||
|
|
||||||
В основном проекте, парсеры используются для работы своего плеера, если вам не важна данная функция, вы можете не развёртывать данный суб-сервис.
|
|
||||||
|
|
||||||
Лицензия: [MIT](../LICENSE)
|
|
||||||
|
|
||||||
## Использование
|
|
||||||
|
|
||||||
В строке веб-браузера необходимо ввести:
|
|
||||||
|
|
||||||
`<http|https>://<ip|domain><:port>/?url=<VIDEO_URL>&player=<PLAYER_SOURCE>`
|
|
||||||
|
|
||||||
где:
|
|
||||||
|
|
||||||
- http|https - схема по которой будет осуществляться подключение к сервису
|
|
||||||
- ip|domain - IP адрес или домен на котором находится сервис
|
|
||||||
- :port - порт сервиса, опционально
|
|
||||||
- VIDEO_URL - ссылка на видео от источника
|
|
||||||
- PLAYER_SOURCE - источник, один из: kodik, sibnet, libria
|
|
||||||
|
|
||||||
> [!NOTE]
|
|
||||||
> Если используется источник libria, ссылка должна быть ссылкой на API anilibria, а не на плеер
|
|
||||||
|
|
||||||
Ответ:
|
|
||||||
|
|
||||||
- 500|400: произошла ошибка, подробнее в строке `message` в теле ответа
|
|
||||||
- 200: запрос прошёл успешно
|
|
||||||
|
|
||||||
## Развёртывание
|
|
||||||
|
|
||||||
> [!IMPORTANT]
|
|
||||||
> В связи с спецификой источников, рекомендуется использовать виртуальный сервер в россии, т.к. они могут быть недоступны из других стран.
|
|
||||||
>
|
|
||||||
> Из-за данной специфики, парсеры невозможно развернуть на edge сервисах, таких как Cloudflare Workers или Deno, а только на отдельном сервере.
|
|
||||||
|
|
||||||
### Docker
|
|
||||||
|
|
||||||
Требования:
|
|
||||||
|
|
||||||
- [docker](https://docs.docker.com/engine/install/)
|
|
||||||
|
|
||||||
### Пре-билд
|
|
||||||
|
|
||||||
1. выполните команду:
|
|
||||||
|
|
||||||
`docker run -d --name anix-player -p 7000:7000 radiquum/anix-player-parser:latest`
|
|
||||||
|
|
||||||
### Ручной билд
|
|
||||||
|
|
||||||
Доп. Требования:
|
|
||||||
|
|
||||||
- [git](https://git-scm.com/)
|
|
||||||
|
|
||||||
1. Клонируйте репозиторий `git clone https://github.com/Radiquum/AniX`
|
|
||||||
2. Переместитесь в директорию репозитория `cd AniX`
|
|
||||||
3. Переместитесь в директорию парсеров `cd player-parsers`
|
|
||||||
4. Выполните команду `docker build -t anix-player-parser .`
|
|
||||||
5. После окончания, выполните команду: `docker run -d --restart always --name anix-player -p 7000:7000 anix-player-parser`
|
|
||||||
|
|
||||||
### docker/Обозначения
|
|
||||||
|
|
||||||
- -d - запустить контейнер в фоне
|
|
||||||
- --restart always - всегда запускать после перезагрузки сервера
|
|
||||||
- --name - название контейнера
|
|
||||||
- -p - порт контейнера который будет доступен извне. ПОРТ:7000
|
|
||||||
|
|
||||||
### docker/После развёртывания
|
|
||||||
|
|
||||||
Сервис будет доступен по адресу: `http://<ВАШ IP><:ВАШ ПОРТ>/`
|
|
||||||
|
|
||||||
### docker/Примечание
|
|
||||||
|
|
||||||
Для использования своего домена и поддержки протокола HTTPS, вы можете использовать Traefik или другой reverse-proxy, с сертификатом SSL.
|
|
||||||
|
|
||||||
Полезные ссылки:
|
|
||||||
|
|
||||||
- [Конвертер из команды docker run в синтакс для docker compose](https://it-tools.tech/docker-run-to-docker-compose-converter)
|
|
||||||
- [Как настроить Traefik + свой домен + SSL](https://letmegooglethat.com/?q=how+to+setup+traefik+with+custom+domain+and+ssl+certificate+from+lets+encrypt%3F)
|
|
||||||
|
|
||||||
### pm2
|
|
||||||
|
|
||||||
Требования:
|
|
||||||
|
|
||||||
- [git](https://git-scm.com/)
|
|
||||||
- [nodejs 23+ с npm](http://nodejs.org/)
|
|
||||||
- [pm2](https://pm2.keymetrics.io/)
|
|
||||||
|
|
||||||
Инструкция:
|
|
||||||
|
|
||||||
1. Клонируйте репозиторий `git clone https://github.com/Radiquum/AniX`
|
|
||||||
2. Переместитесь в директорию репозитория `cd AniX`
|
|
||||||
3. Переместитесь в директорию парсеров `cd player-parsers`
|
|
||||||
4. Выполните команду `npm install`
|
|
||||||
5. После окончания и выполните команду `pm2 start index.ts -n anix-player-parser`
|
|
||||||
|
|
||||||
### pm2/Обозначения
|
|
||||||
|
|
||||||
- -n - название сервиса в pm2
|
|
||||||
|
|
||||||
### pm2/После развёртывания
|
|
||||||
|
|
||||||
Сервис будет доступен по адресу: `http://<ВАШ IP>:7000/`
|
|
||||||
|
|
||||||
### pm2/Примечание
|
|
||||||
|
|
||||||
Для автоматического запуска приложения, рекомендуется настроить pm2 на автозапуск, с помощью команды: `pm2 startup`
|
|
||||||
|
|
||||||
Полезные ссылки:
|
|
||||||
|
|
||||||
- [PM2: подходим к вопросу процесс-менеджмента с умом @ Habr](https://habr.com/ru/articles/480670/)
|
|
|
@ -1,114 +0,0 @@
|
||||||
# AniX - Player Parsers
|
|
||||||
|
|
||||||
This sub-project allows obtaining direct video file links from sources Sibnet, Kodik, Anilibria (source: libria)
|
|
||||||
|
|
||||||
It can be used both for the main AniX project and as a standalone service.
|
|
||||||
|
|
||||||
In the main project, the parsers are used to operate the internal player. If this function is not important to you, you may choose not to deploy this sub-service.
|
|
||||||
|
|
||||||
License: [MIT](../LICENSE)
|
|
||||||
|
|
||||||
## Usage
|
|
||||||
|
|
||||||
In the web browser address bar, enter:
|
|
||||||
|
|
||||||
`<http|https>://<ip|domain><:port>/?url=<VIDEO_URL>&player=<PLAYER_SOURCE>`
|
|
||||||
|
|
||||||
where:
|
|
||||||
|
|
||||||
- http|https - the scheme used to connect to the service
|
|
||||||
- ip|domain - IP address or domain where the service is hosted
|
|
||||||
- :port - service port, optional
|
|
||||||
- VIDEO_URL - the link to the video from the source
|
|
||||||
- PLAYER_SOURCE - the source, one of: kodik, sibnet, libria
|
|
||||||
|
|
||||||
> [!NOTE]
|
|
||||||
> When using libria source, url should be the url to the anilibria api, not player directly
|
|
||||||
|
|
||||||
Response:
|
|
||||||
|
|
||||||
- 500|400: an error occurred, see the `message` field in the response body for details
|
|
||||||
- 200: request was successful
|
|
||||||
|
|
||||||
## Deployment
|
|
||||||
|
|
||||||
> [!IMPORTANT]
|
|
||||||
> Due to the nature of the sources, it is recommended to use a virtual server in Russia, as they may be inaccessible from other countries.
|
|
||||||
>
|
|
||||||
> Because of this specificity, the parsers cannot be deployed on edge services like Cloudflare Workers or Deno, only on a dedicated server.
|
|
||||||
|
|
||||||
### Docker
|
|
||||||
|
|
||||||
Requirements:
|
|
||||||
|
|
||||||
- [docker](https://docs.docker.com/engine/install/)
|
|
||||||
|
|
||||||
### Pre-built
|
|
||||||
|
|
||||||
1. Run the command:
|
|
||||||
|
|
||||||
`docker run -d --name anix-player -p 7000:7000 radiquum/anix-player-parser:latest`
|
|
||||||
|
|
||||||
### Manual build
|
|
||||||
|
|
||||||
Additional Requirements:
|
|
||||||
|
|
||||||
- [git](https://git-scm.com/)
|
|
||||||
|
|
||||||
1. Clone the repository `git clone https://github.com/Radiquum/AniX`
|
|
||||||
2. Navigate to the repository directory `cd AniX`
|
|
||||||
3. Navigate to the parsers directory `cd player-parsers`
|
|
||||||
4. Run the command `docker build -t anix-player-parser .`
|
|
||||||
5. Once finished, run the command: `docker run -d --restart always --name anix-player -p 7000:7000 anix-player-parser`
|
|
||||||
|
|
||||||
### docker/Legend
|
|
||||||
|
|
||||||
- -d - run container in the background
|
|
||||||
- --restart always - always restart after server reboot
|
|
||||||
- --name - container name
|
|
||||||
- -p - container port accessible externally. PORT:7000
|
|
||||||
|
|
||||||
### docker/After deployment
|
|
||||||
|
|
||||||
The service will be available at: `http://<YOUR IP><:YOUR PORT>/`
|
|
||||||
|
|
||||||
### docker/Note
|
|
||||||
|
|
||||||
To use your own domain and support the HTTPS protocol, you can use Traefik or another reverse-proxy with an SSL certificate.
|
|
||||||
|
|
||||||
Useful links:
|
|
||||||
|
|
||||||
- [Docker run to docker compose syntax converter](https://it-tools.tech/docker-run-to-docker-compose-converter)
|
|
||||||
- [How to setup Traefik + custom domain + SSL](https://letmegooglethat.com/?q=how+to+setup+traefik+with+custom+domain+and+ssl+certificate+from+lets+encrypt%3F)
|
|
||||||
|
|
||||||
### pm2
|
|
||||||
|
|
||||||
Requirements:
|
|
||||||
|
|
||||||
- [git](https://git-scm.com/)
|
|
||||||
- [nodejs 23+ with npm](http://nodejs.org/)
|
|
||||||
- [pm2](https://pm2.keymetrics.io/)
|
|
||||||
|
|
||||||
Instructions:
|
|
||||||
|
|
||||||
1. Clone the repository `git clone https://github.com/Radiquum/AniX`
|
|
||||||
2. Navigate to the repository directory `cd AniX`
|
|
||||||
3. Navigate to the parsers directory `cd player-parsers`
|
|
||||||
4. Run the command `npm install`
|
|
||||||
5. Once finished, run the command `pm2 start index.ts -n anix-player-parser`
|
|
||||||
|
|
||||||
### pm2/Legend
|
|
||||||
|
|
||||||
- -n - service name in pm2
|
|
||||||
|
|
||||||
### pm2/After deployment
|
|
||||||
|
|
||||||
The service will be available at: `http://<YOUR IP>:7000/`
|
|
||||||
|
|
||||||
### pm2/Note
|
|
||||||
|
|
||||||
To enable automatic application start, it is recommended to configure pm2 to start on boot with the command: `pm2 startup`
|
|
||||||
|
|
||||||
Useful links:
|
|
||||||
|
|
||||||
- [PM2: smart approach to process management @ Habr](https://habr.com/ru/articles/480670/)
|
|
|
@ -1,46 +0,0 @@
|
||||||
import { asJSON } from "./shared";
|
|
||||||
import { getAnilibriaURL } from "./libria";
|
|
||||||
import { getSibnetURL } from "./sibnet";
|
|
||||||
import { getKodikURL } from "./kodik";
|
|
||||||
|
|
||||||
import express from "express";
|
|
||||||
const app = express();
|
|
||||||
|
|
||||||
const host = "0.0.0.0";
|
|
||||||
const port = 7000;
|
|
||||||
const allowedPlayers = ["kodik", "libria", "sibnet"];
|
|
||||||
|
|
||||||
app.get("/", (req, res) => {
|
|
||||||
const urlParams = new URLSearchParams(req.query)
|
|
||||||
const url = urlParams.get("url");
|
|
||||||
const player = urlParams.get("player");
|
|
||||||
|
|
||||||
if (!url) {
|
|
||||||
asJSON(res, { message: "no 'url' query provided" }, 400)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!player) {
|
|
||||||
asJSON(res, { message: "no 'player' query provided" }, 400)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
switch (player) {
|
|
||||||
case "libria":
|
|
||||||
getAnilibriaURL(res, url)
|
|
||||||
return
|
|
||||||
case "sibnet":
|
|
||||||
getSibnetURL(res, url)
|
|
||||||
return
|
|
||||||
case "kodik":
|
|
||||||
getKodikURL(res, url)
|
|
||||||
return
|
|
||||||
default:
|
|
||||||
asJSON(res, { message: `player '${player}' is not supported. choose one of: ${allowedPlayers.join(", ")}` }, 400)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
app.listen(port, host, function () {
|
|
||||||
console.log(`Server listens http://${host}:${port}`);
|
|
||||||
});
|
|
|
@ -1,95 +0,0 @@
|
||||||
import { asJSON, randomUA } from "./shared";
|
|
||||||
const altDomains = ["kodik.info", "aniqit.com", "kodik.cc", "kodik.biz"];
|
|
||||||
|
|
||||||
export async function getKodikURL(res, url: string) {
|
|
||||||
const origDomain = url.replace("https://", "").split("/")[0];
|
|
||||||
let domain = url.replace("https://", "").split("/")[0];
|
|
||||||
|
|
||||||
if (!altDomains.includes(domain)) {
|
|
||||||
asJSON(res, { message: "Wrong url provided for player kodik" }, 400);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let user_agent = randomUA();
|
|
||||||
|
|
||||||
let pageRes = await fetch(url, {
|
|
||||||
headers: {
|
|
||||||
"User-Agent": user_agent,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!pageRes.ok) {
|
|
||||||
for (let i = 0; i < altDomains.length; i++) {
|
|
||||||
if (url.includes(altDomains[i])) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
user_agent = randomUA();
|
|
||||||
const altDomain = altDomains[i];
|
|
||||||
const altUrl = url.replace(
|
|
||||||
`https://${origDomain}/`,
|
|
||||||
`https://${altDomain}/`
|
|
||||||
);
|
|
||||||
|
|
||||||
domain = altDomain;
|
|
||||||
pageRes = await fetch(altUrl, {
|
|
||||||
headers: {
|
|
||||||
"User-Agent": user_agent,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (pageRes.ok) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!pageRes.ok) {
|
|
||||||
asJSON(res, { message: "KODIK: failed to load page" }, 500);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const pageData = await pageRes.text();
|
|
||||||
const urlParamsRe = /var urlParams = .*;$/m;
|
|
||||||
const urlParamsMatch = urlParamsRe.exec(pageData);
|
|
||||||
|
|
||||||
if (!urlParamsMatch || urlParamsMatch.length == 0) {
|
|
||||||
asJSON(res, { message: `KODIK: failed to find data to parse` }, 500);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const urlParamsStr = urlParamsMatch[0]
|
|
||||||
.replace("var urlParams = '", "")
|
|
||||||
.replace("';", "");
|
|
||||||
|
|
||||||
const urlStr = url.replace(`https://${origDomain}/`, "");
|
|
||||||
const type = urlStr.split("/")[0];
|
|
||||||
const id = urlStr.split("/")[1];
|
|
||||||
const hash = urlStr.split("/")[2];
|
|
||||||
|
|
||||||
const urlParams = JSON.parse(urlParamsStr);
|
|
||||||
urlParams["type"] = type;
|
|
||||||
urlParams["id"] = id;
|
|
||||||
urlParams["hash"] = hash;
|
|
||||||
|
|
||||||
const formData = new FormData();
|
|
||||||
for (const [key, value] of Object.entries(urlParams)) {
|
|
||||||
formData.append(key, value as any);
|
|
||||||
}
|
|
||||||
|
|
||||||
const linksRes = await fetch(`https://${domain}/ftor`, {
|
|
||||||
method: "POST",
|
|
||||||
body: formData,
|
|
||||||
headers: {
|
|
||||||
"User-Agent": user_agent,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!linksRes.ok) {
|
|
||||||
asJSON(res, { message: `KODIK: failed to get links` }, 500);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
asJSON(res, await linksRes.json(), 200);
|
|
||||||
return;
|
|
||||||
}
|
|
|
@ -1,17 +0,0 @@
|
||||||
import { asJSON } from "./shared";
|
|
||||||
|
|
||||||
export async function getAnilibriaURL(res, url: string) {
|
|
||||||
|
|
||||||
if (!url.includes("anilibria")) {
|
|
||||||
asJSON(res, { message: "Wrong url provided for player libria" }, 400);
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let apiRes = await fetch(url);
|
|
||||||
if (!apiRes.ok) {
|
|
||||||
asJSON(res, { message: "LIBRIA: failed to get api response" }, 500);
|
|
||||||
return
|
|
||||||
}
|
|
||||||
asJSON(res, await apiRes.json(), 200);
|
|
||||||
return
|
|
||||||
}
|
|
1311
player-parsers/package-lock.json
generated
|
@ -1,14 +0,0 @@
|
||||||
{
|
|
||||||
"name": "player-parsers",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"description": "Player Parsing for AniX",
|
|
||||||
"scripts": {
|
|
||||||
"serve": "npx tsx ./index.ts"
|
|
||||||
},
|
|
||||||
"author": "",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"express": "^5.1.0",
|
|
||||||
"tsx": "^4.19.4"
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,48 +0,0 @@
|
||||||
export const corsHeaders = {
|
|
||||||
"Access-Control-Allow-Origin": "*",
|
|
||||||
"Access-Control-Allow-Methods": "GET,HEAD,POST,OPTIONS",
|
|
||||||
"Cache-Control": "no-cache",
|
|
||||||
};
|
|
||||||
|
|
||||||
export const resHeaders = {
|
|
||||||
...corsHeaders,
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
};
|
|
||||||
|
|
||||||
export const USERAGENTS = [
|
|
||||||
"Mozilla/5.0 (Linux; Android 12.0; LG G8) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.2.7124.71 Mobile Safari/537.36",
|
|
||||||
"Mozilla/5.0 (Windows NT 11.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.5.1269.13 Safari/537.36",
|
|
||||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:123.0) Gecko/123.0 Firefox/123.0",
|
|
||||||
"Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:94.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.8.4576.73 Safari/537.36",
|
|
||||||
"Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:121.0) Gecko/121.0 Firefox/121.0",
|
|
||||||
"Mozilla/5.0 (Linux; Android 11.0; OnePlus 10T Pro) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.8.1484.76 Mobile Safari/537.36",
|
|
||||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.9.9841.32 Safari/537.36",
|
|
||||||
"Mozilla/5.0 (Windows NT 11.0; Win64; x64; rv:124.0) Gecko/124.0 Firefox/124.0",
|
|
||||||
"Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:94.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.3457.25 Safari/537.36",
|
|
||||||
"Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:124.0) Gecko/124.0 Firefox/124.0",
|
|
||||||
"Mozilla/5.0 (Linux; Android 13.0; Pixel 3) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.3.1166.27 Mobile Safari/537.36",
|
|
||||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.6.4126.27 Safari/537.36",
|
|
||||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:130.0) Gecko/130.0 Firefox/130.0",
|
|
||||||
"Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:94.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.3.4677.74 Safari/537.36",
|
|
||||||
"Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:122.0) Gecko/122.0 Firefox/122.0",
|
|
||||||
"Mozilla/5.0 (Linux; Android 12.0; Xiaomi Redmi Note 9) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.6.3806.92 Mobile Safari/537.36",
|
|
||||||
"Mozilla/5.0 (Windows NT 11.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.3.9963.85 Safari/537.36",
|
|
||||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:122.0) Gecko/122.0 Firefox/122.0",
|
|
||||||
"Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:94.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.8.5618.48 Safari/537.36",
|
|
||||||
"Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:126.0) Gecko/126.0 Firefox/126.0",
|
|
||||||
"Mozilla/5.0 (Linux; Android 12.0; Huawei Mate 40) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.6740.69 Mobile Safari/537.36",
|
|
||||||
"Mozilla/5.0 (Windows NT 11.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.9.2666.21 Safari/537.36",
|
|
||||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:132.0) Gecko/132.0 Firefox/132.0",
|
|
||||||
"Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:94.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.4804.4 Safari/537.36",
|
|
||||||
"Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:128.0) Gecko/128.0 Firefox/128.0",
|
|
||||||
];
|
|
||||||
|
|
||||||
export function asJSON(res, object: any, status: number) {
|
|
||||||
res.status(status).type("application/json");
|
|
||||||
res.set(corsHeaders);
|
|
||||||
res.send(JSON.stringify(object));
|
|
||||||
}
|
|
||||||
|
|
||||||
export function randomUA() {
|
|
||||||
return USERAGENTS[Math.floor(Math.random() * USERAGENTS.length)];
|
|
||||||
}
|
|
|
@ -1,59 +0,0 @@
|
||||||
import { asJSON, randomUA } from "./shared";
|
|
||||||
|
|
||||||
export async function getSibnetURL(res, url: string) {
|
|
||||||
|
|
||||||
if (!url.includes("sibnet")) {
|
|
||||||
asJSON(res, { message: "Wrong url provided for player sibnet" }, 400);
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const user_agent = randomUA();
|
|
||||||
|
|
||||||
let pageRes = await fetch(url, {
|
|
||||||
headers: {
|
|
||||||
"User-Agent": user_agent,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
if (!pageRes.ok) {
|
|
||||||
asJSON(res, { message: `SIBNET:${pageRes.status}: failed to load page` }, 500)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const pageData = await pageRes.text();
|
|
||||||
const videoRe = /\/v\/.*?\.mp4/;
|
|
||||||
const videoMatch = videoRe.exec(pageData);
|
|
||||||
|
|
||||||
if (!videoMatch || videoMatch.length == 0) {
|
|
||||||
asJSON(res, { message: `SIBNET: failed to find data to parse` }, 500)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const posterRe = /\/upload\/cover\/.*?\.jpg/;
|
|
||||||
const posterMatch = posterRe.exec(pageData);
|
|
||||||
|
|
||||||
const actualVideoRes = await fetch(
|
|
||||||
`https://video.sibnet.ru${videoMatch[0]}`,
|
|
||||||
{
|
|
||||||
headers: {
|
|
||||||
"User-Agent": user_agent,
|
|
||||||
Referer: url,
|
|
||||||
},
|
|
||||||
redirect: "manual",
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!actualVideoRes.headers.get("location")) {
|
|
||||||
asJSON(res, { message: `SIBNET: failed to get video link` }, 500)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const video = actualVideoRes.headers.get("location");
|
|
||||||
const poster =
|
|
||||||
posterMatch ?
|
|
||||||
posterMatch.length > 0 ?
|
|
||||||
`https://st.sibnet.ru${posterMatch[0]}`
|
|
||||||
: null
|
|
||||||
: null;
|
|
||||||
|
|
||||||
asJSON(res, { video, poster }, 200)
|
|
||||||
return
|
|
||||||
}
|
|
|
@ -1,12 +0,0 @@
|
||||||
# 3.7.0
|
|
||||||
|
|
||||||
## Добавлено
|
|
||||||
|
|
||||||
- Настройка сохранения истории просмотра
|
|
||||||
- Кнопки след./пред. серия в плеере
|
|
||||||
- Название, озвучка и серия написаны в плеере
|
|
||||||
|
|
||||||
## Изменено
|
|
||||||
|
|
||||||
- Выбор озвучки, источника и серии теперь находится в плеере
|
|
||||||
- Серия теперь засчитывается просмотренной только после нажатия кнопки воспроизведения
|
|
|
@ -14,9 +14,8 @@ module.exports = {
|
||||||
".flowbite-react\\class-list.json"
|
".flowbite-react\\class-list.json"
|
||||||
],
|
],
|
||||||
plugins: [
|
plugins: [
|
||||||
addIconSelectors(["mdi", "material-symbols", "twemoji", "fa6-brands", "solar"]),
|
addIconSelectors(["mdi", "material-symbols", "twemoji", "fa6-brands"]),
|
||||||
flowbiteReact,
|
flowbiteReact
|
||||||
require("tailwind-scrollbar")
|
|
||||||
],
|
],
|
||||||
darkMode: "selector",
|
darkMode: "selector",
|
||||||
theme: {
|
theme: {
|
||||||
|
|
|
@ -33,5 +33,5 @@
|
||||||
"**/*.tsx",
|
"**/*.tsx",
|
||||||
"next.config.js"
|
"next.config.js"
|
||||||
],
|
],
|
||||||
"exclude": ["node_modules", "player-parsers"]
|
"exclude": ["node_modules"]
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,4 +1,10 @@
|
||||||
{
|
{
|
||||||
|
"git": {
|
||||||
|
"deploymentEnabled": {
|
||||||
|
"V3": false,
|
||||||
|
"V2": false
|
||||||
|
}
|
||||||
|
},
|
||||||
"headers": [
|
"headers": [
|
||||||
{
|
{
|
||||||
"source": "/bookmarks/:slug*",
|
"source": "/bookmarks/:slug*",
|
||||||
|
|