Compare commits
48 Commits
@ -1,8 +1,11 @@
|
||||
**
|
||||
|
||||
!/next.config.js
|
||||
!/tsconfig.json
|
||||
!/package.json
|
||||
!/yarn.lock
|
||||
!/public
|
||||
!/src
|
||||
!.env
|
||||
!next.config.mjs
|
||||
!tailwind.config.ts
|
||||
!postcss.config.js
|
||||
!tsconfig.json
|
||||
!package.json
|
||||
!yarn.lock
|
||||
!public
|
||||
!src
|
@ -0,0 +1,73 @@
|
||||
kind: pipeline
|
||||
type: docker
|
||||
name: test
|
||||
|
||||
platform:
|
||||
os: linux
|
||||
arch: amd64
|
||||
|
||||
steps:
|
||||
- name: Test the newest commit
|
||||
image: node:lts-alpine
|
||||
volumes:
|
||||
- name: cache
|
||||
path: /drone/src/node_modules
|
||||
commands:
|
||||
- yarn install
|
||||
- yarn lint
|
||||
- yarn test-build
|
||||
|
||||
volumes:
|
||||
- name: cache
|
||||
host:
|
||||
path: /tmp/drone/cache/node_modules
|
||||
|
||||
---
|
||||
kind: pipeline
|
||||
type: docker
|
||||
name: build-linux-amd64
|
||||
|
||||
platform:
|
||||
os: linux
|
||||
arch: amd64
|
||||
|
||||
steps:
|
||||
- name: Build Dockerfile and push to Dockerhub
|
||||
image: plugins/docker
|
||||
settings:
|
||||
repo: guusvanmeerveld/materialtube
|
||||
tags:
|
||||
- latest
|
||||
- latest-amd64
|
||||
platforms: linux/amd64
|
||||
username:
|
||||
from_secret: docker_username
|
||||
password:
|
||||
from_secret: docker_password
|
||||
|
||||
depends_on:
|
||||
- test
|
||||
|
||||
---
|
||||
kind: pipeline
|
||||
type: docker
|
||||
name: build-linux-arm64
|
||||
|
||||
platform:
|
||||
os: linux
|
||||
arch: arm64
|
||||
|
||||
steps:
|
||||
- name: Build Dockerfile and push to Dockerhub
|
||||
image: plugins/docker
|
||||
settings:
|
||||
repo: guusvanmeerveld/materialtube
|
||||
tags: latest-arm64
|
||||
platforms: linux/arm64
|
||||
username:
|
||||
from_secret: docker_username
|
||||
password:
|
||||
from_secret: docker_password
|
||||
|
||||
depends_on:
|
||||
- test
|
@ -0,0 +1,32 @@
|
||||
{
|
||||
"extends": [
|
||||
"plugin:@next/next/recommended",
|
||||
"next/core-web-vitals",
|
||||
"plugin:@typescript-eslint/recommended",
|
||||
"plugin:prettier/recommended",
|
||||
"plugin:css-modules/recommended",
|
||||
"plugin:@tanstack/eslint-plugin-query/recommended"
|
||||
],
|
||||
"plugins": ["@typescript-eslint", "css-modules", "@tanstack/query"],
|
||||
"rules": {
|
||||
"@typescript-eslint/no-non-null-assertion": 0,
|
||||
"@typescript-eslint/no-unused-vars": ["error"],
|
||||
"@typescript-eslint/explicit-function-return-type": "warn",
|
||||
"padding-line-between-statements": [
|
||||
"error",
|
||||
{ "blankLine": "always", "prev": "*", "next": "block" },
|
||||
{ "blankLine": "always", "prev": "block", "next": "*" },
|
||||
{ "blankLine": "always", "prev": "*", "next": "block-like" },
|
||||
{ "blankLine": "always", "prev": "block-like", "next": "*" }
|
||||
]
|
||||
},
|
||||
|
||||
"ignorePatterns": [
|
||||
"/node_modules",
|
||||
"/cache",
|
||||
"/dist",
|
||||
"/out",
|
||||
"/public",
|
||||
"*.js"
|
||||
]
|
||||
}
|
@ -1,11 +0,0 @@
|
||||
{
|
||||
"extends": "next/core-web-vitals",
|
||||
"rules": {
|
||||
"no-restricted-imports": [
|
||||
"error",
|
||||
{
|
||||
"patterns": ["@mui/*/*/*", "!@mui/material/test-utils/*"]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
@ -1,38 +0,0 @@
|
||||
---
|
||||
name: Bug report
|
||||
about: Create a report to help us improve
|
||||
title: ''
|
||||
labels: ''
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**Describe the bug**
|
||||
A clear and concise description of what the bug is.
|
||||
|
||||
**To Reproduce**
|
||||
Steps to reproduce the behavior:
|
||||
1. Go to '...'
|
||||
2. Click on '....'
|
||||
3. Scroll down to '....'
|
||||
4. See error
|
||||
|
||||
**Expected behavior**
|
||||
A clear and concise description of what you expected to happen.
|
||||
|
||||
**Screenshots**
|
||||
If applicable, add screenshots to help explain your problem.
|
||||
|
||||
**Desktop (please complete the following information):**
|
||||
- OS: [e.g. iOS]
|
||||
- Browser [e.g. chrome, safari]
|
||||
- Version [e.g. 22]
|
||||
|
||||
**Smartphone (please complete the following information):**
|
||||
- Device: [e.g. iPhone6]
|
||||
- OS: [e.g. iOS8.1]
|
||||
- Browser [e.g. stock browser, safari]
|
||||
- Version [e.g. 22]
|
||||
|
||||
**Additional context**
|
||||
Add any other context about the problem here.
|
@ -1,20 +0,0 @@
|
||||
---
|
||||
name: Feature request
|
||||
about: Suggest an idea for this project
|
||||
title: ''
|
||||
labels: ''
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**Is your feature request related to a problem? Please describe.**
|
||||
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
|
||||
|
||||
**Describe the solution you'd like**
|
||||
A clear and concise description of what you want to happen.
|
||||
|
||||
**Describe alternatives you've considered**
|
||||
A clear and concise description of any alternative solutions or features you've considered.
|
||||
|
||||
**Additional context**
|
||||
Add any other context or screenshots about the feature request here.
|
@ -1,49 +0,0 @@
|
||||
name: "CodeQL"
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [master]
|
||||
paths:
|
||||
- src/**
|
||||
- package.json
|
||||
- tsconfig.json
|
||||
- .github/workflows/codeql-analysis.yml
|
||||
pull_request:
|
||||
# The branches below must be a subset of the branches above
|
||||
branches: [master]
|
||||
schedule:
|
||||
- cron: "27 18 * * 6"
|
||||
|
||||
jobs:
|
||||
analyze:
|
||||
name: Analyze
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
security-events: write
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
language: ["typescript"]
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v3
|
||||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v2
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
|
||||
- name: Autobuild
|
||||
uses: github/codeql-action/autobuild@v2
|
||||
|
||||
#- run: |
|
||||
# make bootstrap
|
||||
# make release
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v2
|
@ -1,64 +0,0 @@
|
||||
name: Deploy to Github Pages / Docker hub
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
paths:
|
||||
- src/**
|
||||
- public/**
|
||||
- Dockerfile
|
||||
- package.json
|
||||
- tsconfig.json
|
||||
- next.config.js
|
||||
- .github/workflows/deploy.yml
|
||||
|
||||
env:
|
||||
NEXT_TELEMETRY_DISABLED: 1
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Setup
|
||||
uses: actions/checkout@v2
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v2
|
||||
with:
|
||||
node-version: "16.x"
|
||||
cache: "yarn"
|
||||
|
||||
- name: Install npm dependencies
|
||||
run: yarn install
|
||||
|
||||
- name: Lint
|
||||
run: yarn run lint
|
||||
|
||||
- name: Build
|
||||
run: yarn run build
|
||||
|
||||
docker:
|
||||
runs-on: ubuntu-latest
|
||||
needs: test
|
||||
steps:
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v1
|
||||
|
||||
# Login
|
||||
- name: Login to DockerHub
|
||||
uses: docker/login-action@v1
|
||||
with:
|
||||
username: guusvanmeerveld
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
# Build & Push
|
||||
- name: Build Dockerfile and push
|
||||
uses: docker/build-push-action@v2
|
||||
with:
|
||||
push: true
|
||||
tags: guusvanmeerveld/materialtube:latest
|
||||
|
||||
# Cache
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
@ -1,4 +1,36 @@
|
||||
node_modules
|
||||
.env.local
|
||||
.next
|
||||
out
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/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
|
||||
|
@ -0,0 +1,3 @@
|
||||
{
|
||||
"prettier.configPath": ".prettierrc.json"
|
||||
}
|
@ -1,157 +1,36 @@
|
||||
<p align="center">
|
||||
<img src="https://raw.githubusercontent.com/Guusvanmeerveld/MaterialTube/master/src/svg/logo.svg" height="96"/>
|
||||
</p>
|
||||
This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app).
|
||||
|
||||
<h1 align="center">MaterialTube</h1>
|
||||
## Getting Started
|
||||
|
||||
<p align="center">
|
||||
<img src="https://github.com/Guusvanmeerveld/MaterialTube/actions/workflows/deploy.yml/badge.svg" alt="Deploy Site" />
|
||||
<img src="https://github.com/Guusvanmeerveld/MaterialTube/actions/workflows/codeql-analysis.yml/badge.svg" alt="CodeQL" />
|
||||
<a href="https://hub.docker.com/r/guusvanmeerveld/materialtube">
|
||||
<img src="https://shields.io/docker/pulls/guusvanmeerveld/materialtube" alt="Docker pulls" />
|
||||
</a>
|
||||
</p>
|
||||
First, run the development server:
|
||||
|
||||
<p align="center">
|
||||
<a href="https://heroku.com/deploy?template=https://github.com/Guusvanmeerveld/MaterialTube">
|
||||
<img src="https://www.herokucdn.com/deploy/button.svg" alt="Deploy to Heroku">
|
||||
</a>
|
||||
<a href="https://app.netlify.com/start/deploy?repository=https://github.com/Guusvanmeerveld/MaterialTube">
|
||||
<img src="https://www.netlify.com/img/deploy/button.svg" alt="Deploy to Netlify">
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
MaterialTube is a simple client-side only web-client for Invidious servers. It supports using an Invidious account, but also allows you to store all of your data locally. It's main goal is to provide an even greater level of privacy and improve on the current Invidious UI.
|
||||
</p>
|
||||
|
||||
<p align="center">Made using</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="https://img.shields.io/badge/TypeScript-007ACC?style=for-the-badge&logo=typescript&logoColor=white" alt="Typescript" />
|
||||
<img src="https://img.shields.io/badge/React-20232A?style=for-the-badge&logo=react&logoColor=61DAFB" alt="React" />
|
||||
</p>
|
||||
|
||||
## Index
|
||||
- [Index](#index)
|
||||
- [(Current) Features](#current-features)
|
||||
- [Configuration](#configuration)
|
||||
- [Deploy](#deploy)
|
||||
- [Using Node.js](#using-nodejs)
|
||||
- [Using Docker](#using-docker)
|
||||
- [Locally](#locally)
|
||||
- [Using Docker Hub](#using-docker-hub)
|
||||
- [Using Heroku](#using-heroku)
|
||||
- [Using Netlify](#using-netlify)
|
||||
|
||||
## (Current) Features
|
||||
- Browse trending
|
||||
- Watch video's
|
||||
- Custom settings
|
||||
|
||||
## Configuration
|
||||
There are a few environmental variables that are able to be set during build time to further customize the application.
|
||||
|
||||
- GIT_URL: Set the url to the git repo. Default: https://github.com/Guusvanmeerveld/MaterialTube
|
||||
- APP_NAME: Set the app name to show to the users. Default: MaterialTube
|
||||
- DEFAULT_SERVER: Set the invidious server to use by default. Default: invidious.privacy.gd
|
||||
|
||||
## Deploy
|
||||
|
||||
### Using Node.js
|
||||
|
||||
Requirements:
|
||||
- Node.js v16.x
|
||||
- Yarn or NPM
|
||||
- Git
|
||||
|
||||
```sh
|
||||
git clone https://github.com/Guusvanmeerveld/MaterialTube MaterialTube
|
||||
|
||||
cd MaterialTube
|
||||
|
||||
# Choose Yarn or NPM
|
||||
yarn install --frozen-lockfile
|
||||
|
||||
# npm install --frozen-lockfile
|
||||
|
||||
export NEXT_TELEMETRY_DISABLED=1
|
||||
```
|
||||
|
||||
Now you have to choose between export to static HTML (recommended) or running a custom server (improves speed)
|
||||
|
||||
Exporting to static HTML:
|
||||
```sh
|
||||
yarn export
|
||||
|
||||
# npm export
|
||||
```bash
|
||||
npm run dev
|
||||
# or
|
||||
yarn dev
|
||||
# or
|
||||
pnpm dev
|
||||
# or
|
||||
bun dev
|
||||
```
|
||||
The HTML files can be found in the `out` folder. You can now serve them using something like NGINX or Apache
|
||||
|
||||
You can also opt to use a custom server, which improves on speed because it will prefetch your request.
|
||||
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
||||
|
||||
Building and starting a custom server:
|
||||
```sh
|
||||
yarn build
|
||||
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
||||
|
||||
# npm build
|
||||
This project uses [`next/font`](https://nextjs.org/docs/basic-features/font-optimization) to automatically optimize and load Inter, a custom Google Font.
|
||||
|
||||
yarn start
|
||||
|
||||
# npm start
|
||||
```
|
||||
|
||||
### Using Docker
|
||||
|
||||
#### Locally
|
||||
|
||||
Requirements:
|
||||
- Docker
|
||||
- docker-compose
|
||||
- Git
|
||||
|
||||
```sh
|
||||
git clone https://github.com/Guusvanmeerveld/MaterialTube MaterialTube
|
||||
|
||||
cd MaterialTube
|
||||
|
||||
docker build . -t materialtube
|
||||
```
|
||||
|
||||
Now update the `docker-compose.yml` to your needs and start the container:
|
||||
|
||||
```sh
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
#### Using Docker Hub
|
||||
|
||||
Requirements:
|
||||
- Docker
|
||||
- docker-compose
|
||||
|
||||
Simply update the following to your needs and put it in a file named `docker-compose.yml`.
|
||||
|
||||
```yml
|
||||
version: "3"
|
||||
|
||||
services:
|
||||
app:
|
||||
build: guusvanmeerveld/materialtube
|
||||
container_name: material-tube
|
||||
ports:
|
||||
- 3000:80
|
||||
```
|
||||
## Learn More
|
||||
|
||||
Now run `docker-compose up -d` to start the container.
|
||||
To learn more about Next.js, take a look at the following resources:
|
||||
|
||||
### Using Heroku
|
||||
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
||||
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
||||
|
||||
Deploying to Heroku is a very simple and highly recommended way of deploying. All you have to do is click the button below, create an account (if you don't already have one) and deploy it.
|
||||
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome!
|
||||
|
||||
[![Deploy](https://www.herokucdn.com/deploy/button.svg)](https://heroku.com/deploy?template=https://github.com/Guusvanmeerveld/MaterialTube)
|
||||
## Deploy on Vercel
|
||||
|
||||
### Using Netlify
|
||||
Deploying to Netlify is just as easy as deploying to Heroku. Click the button below connect your Git repo and follow the steps to deploy your application.
|
||||
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
||||
|
||||
[![Deploy to Netlify](https://www.netlify.com/img/deploy/button.svg)](https://app.netlify.com/start/deploy?repository=https://github.com/Guusvanmeerveld/MaterialTube)
|
||||
Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details.
|
||||
|
@ -1,6 +0,0 @@
|
||||
{
|
||||
"name": "MaterialTube",
|
||||
"description": "MaterialTube is a beautiful and elegant web client for Invidious servers, built using Next.js and MUI.",
|
||||
"repository": "https://github.com/Guusvanmeerveld/MaterialTube",
|
||||
"keywords": ["react", "youtube", "nextjs", "mui", "invidious"]
|
||||
}
|
@ -0,0 +1,11 @@
|
||||
meta {
|
||||
name: Stream
|
||||
type: http
|
||||
seq: 2
|
||||
}
|
||||
|
||||
get {
|
||||
url: https://invidious.drgns.space/api/v1/videos/CcHevgjAnV0
|
||||
body: none
|
||||
auth: none
|
||||
}
|
@ -0,0 +1,11 @@
|
||||
meta {
|
||||
name: Trending
|
||||
type: http
|
||||
seq: 1
|
||||
}
|
||||
|
||||
get {
|
||||
url: https://invidious.drgns.space/api/v1/trending
|
||||
body: none
|
||||
auth: none
|
||||
}
|
@ -0,0 +1,11 @@
|
||||
meta {
|
||||
name: Stream
|
||||
type: http
|
||||
seq: 2
|
||||
}
|
||||
|
||||
get {
|
||||
url: https://pipedapi.kavin.rocks/streams/CcHevgjAnV0
|
||||
body: none
|
||||
auth: none
|
||||
}
|
@ -0,0 +1,15 @@
|
||||
meta {
|
||||
name: Trending
|
||||
type: http
|
||||
seq: 1
|
||||
}
|
||||
|
||||
get {
|
||||
url: https://pipedapi.kavin.rocks/trending?region=NL
|
||||
body: none
|
||||
auth: none
|
||||
}
|
||||
|
||||
query {
|
||||
region: NL
|
||||
}
|
@ -0,0 +1,5 @@
|
||||
{
|
||||
"version": "1",
|
||||
"name": "MaterialTube",
|
||||
"type": "collection"
|
||||
}
|
@ -1,8 +0,0 @@
|
||||
version: "3"
|
||||
|
||||
services:
|
||||
app:
|
||||
build: .
|
||||
container_name: material-tube
|
||||
ports:
|
||||
- 3000:3000
|
@ -0,0 +1,56 @@
|
||||
{
|
||||
"nodes": {
|
||||
"flake-compat": {
|
||||
"locked": {
|
||||
"lastModified": 1696426674,
|
||||
"narHash": "sha256-kvjfFW7WAETZlt09AgDn1MrtKzP7t90Vf7vypd3OL1U=",
|
||||
"rev": "0f9255e01c2351cc7d116c072cb317785dd33b33",
|
||||
"revCount": 57,
|
||||
"type": "tarball",
|
||||
"url": "https://api.flakehub.com/f/pinned/edolstra/flake-compat/1.0.1/018afb31-abd1-7bff-a5e4-cff7e18efb7a/source.tar.gz"
|
||||
},
|
||||
"original": {
|
||||
"type": "tarball",
|
||||
"url": "https://flakehub.com/f/edolstra/flake-compat/1.tar.gz"
|
||||
}
|
||||
},
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1710252211,
|
||||
"narHash": "sha256-hQChQpB4LDBaSrNlD6DPLhU9T+R6oyxMCg2V+S7Y1jg=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "7eeacecff44e05a9fd61b9e03836b66ecde8a525",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"id": "nixpkgs",
|
||||
"type": "indirect"
|
||||
}
|
||||
},
|
||||
"root": {
|
||||
"inputs": {
|
||||
"flake-compat": "flake-compat",
|
||||
"nixpkgs": "nixpkgs",
|
||||
"systems": "systems"
|
||||
}
|
||||
},
|
||||
"systems": {
|
||||
"locked": {
|
||||
"lastModified": 1681028828,
|
||||
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
|
||||
"owner": "nix-systems",
|
||||
"repo": "default",
|
||||
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nix-systems",
|
||||
"repo": "default",
|
||||
"type": "github"
|
||||
}
|
||||
}
|
||||
},
|
||||
"root": "root",
|
||||
"version": 7
|
||||
}
|
@ -0,0 +1,37 @@
|
||||
{
|
||||
description = "Material tube";
|
||||
|
||||
inputs = {
|
||||
systems.url = "github:nix-systems/default";
|
||||
flake-compat.url = "https://flakehub.com/f/edolstra/flake-compat/1.tar.gz";
|
||||
};
|
||||
|
||||
outputs =
|
||||
{ systems
|
||||
, nixpkgs
|
||||
, ...
|
||||
}:
|
||||
let
|
||||
eachSystem = f:
|
||||
nixpkgs.lib.genAttrs (import systems) (
|
||||
system:
|
||||
f nixpkgs.legacyPackages.${system}
|
||||
);
|
||||
in
|
||||
{
|
||||
devShells = eachSystem
|
||||
(pkgs: {
|
||||
default = pkgs.mkShell
|
||||
{
|
||||
buildInputs = with pkgs; [
|
||||
nodejs_20
|
||||
|
||||
yarn
|
||||
|
||||
nodePackages.typescript
|
||||
nodePackages.typescript-language-server
|
||||
];
|
||||
};
|
||||
});
|
||||
};
|
||||
}
|
@ -1,3 +0,0 @@
|
||||
[build]
|
||||
publish = ".next"
|
||||
command = "yarn build"
|
@ -1,20 +0,0 @@
|
||||
const packageInfo = require("./package.json");
|
||||
|
||||
// @ts-check
|
||||
/**
|
||||
* @type {import('next').NextConfig}
|
||||
**/
|
||||
module.exports = {
|
||||
reactStrictMode: true,
|
||||
eslint: {
|
||||
ignoreDuringBuilds: true
|
||||
},
|
||||
env: {
|
||||
NEXT_PUBLIC_GITHUB_URL: process.env.GIT_URL ?? packageInfo.repository.url,
|
||||
NEXT_PUBLIC_APP_NAME: process.env.APP_NAME ?? packageInfo.displayName,
|
||||
NEXT_PUBLIC_DEFAULT_SERVER:
|
||||
process.env.DEFAULT_SERVER ?? "invidious.privacy.gd"
|
||||
},
|
||||
basePath: process.env.BASE_PATH ?? "",
|
||||
trailingSlash: !(process.env.CI == "true")
|
||||
};
|
@ -0,0 +1,10 @@
|
||||
import createPWA from "next-pwa";
|
||||
|
||||
const withPWA = createPWA({
|
||||
dest: "public"
|
||||
});
|
||||
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {};
|
||||
|
||||
export default withPWA(nextConfig);
|
@ -1,51 +1,58 @@
|
||||
{
|
||||
"name": "material-tube",
|
||||
"displayName": "MaterialTube",
|
||||
"description": "MaterialTube is a beautiful and elegant web client for Invidious servers, built using Next.js and MUI.",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"url": "https://github.com/Guusvanmeerveld/MaterialTube"
|
||||
},
|
||||
"cacheDirectories": [".next/cache"],
|
||||
"name": "materialtube",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"prettify": "prettier . --write",
|
||||
"test-build": "tsc",
|
||||
"start": "next start",
|
||||
"lint": "next lint",
|
||||
"prettify": "prettier src --write"
|
||||
"lint": "next lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@emotion/react": "^11.8.2",
|
||||
"@emotion/styled": "^11.8.1",
|
||||
"@mui/icons-material": "^5.5.1",
|
||||
"@mui/material": "^5.5.1",
|
||||
"axios": "^0.26.1",
|
||||
"luxon": "^2.3.1",
|
||||
"next": "^12.1.0",
|
||||
"next-seo": "^5.1.0",
|
||||
"react": "^17.0.2",
|
||||
"react-dom": "^17.0.2",
|
||||
"react-intersection-observer": "^8.33.1",
|
||||
"react-query": "^3.34.16",
|
||||
"use-local-storage-state": "^16.0.1",
|
||||
"zustand": "^3.7.2"
|
||||
"@nextui-org/react": "^2.2.10",
|
||||
"@tanstack/react-query": "^5.27.5",
|
||||
"country-region-data": "^3.0.0",
|
||||
"framer-motion": "^11.0.12",
|
||||
"ky": "^1.2.2",
|
||||
"luxon": "^3.4.4",
|
||||
"next": "14.1.3",
|
||||
"next-pwa": "^5.6.0",
|
||||
"react": "^18",
|
||||
"react-dom": "^18",
|
||||
"react-hotkeys-hook": "^4.5.0",
|
||||
"react-icons": "^5.0.1",
|
||||
"react-player": "^2.15.1",
|
||||
"reactjs-visibility": "^0.1.4",
|
||||
"sanitize-html": "^2.13.0",
|
||||
"screenfull": "^6.0.2",
|
||||
"use-debounce": "^10.0.0",
|
||||
"zod": "^3.22.4",
|
||||
"zustand": "^4.5.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@trivago/prettier-plugin-sort-imports": "^3.2.0",
|
||||
"@types/luxon": "^2.3.1",
|
||||
"@types/node": "^17.0.21",
|
||||
"@types/react": "^17.0.40",
|
||||
"@types/react-dom": "^17.0.13",
|
||||
"@typescript-eslint/eslint-plugin": "^5.15.0",
|
||||
"@typescript-eslint/parser": "^5.15.0",
|
||||
"eslint": "^8.11.0",
|
||||
"eslint-config-next": "12.1.0",
|
||||
"eslint-plugin-css-modules": "^2.11.0",
|
||||
"eslint-plugin-jsx-a11y": "^6.5.1",
|
||||
"eslint-plugin-prettier": "^4.0.0",
|
||||
"eslint-plugin-react": "^7.29.4",
|
||||
"eslint-plugin-react-hooks": "^4.3.0",
|
||||
"prettier": "^2.6.0",
|
||||
"typescript": "^4.6.2"
|
||||
"@ianvs/prettier-plugin-sort-imports": "^4.2.1",
|
||||
"@tanstack/eslint-plugin-query": "^5.28.6",
|
||||
"@tanstack/react-query-devtools": "^5.27.8",
|
||||
"@types/luxon": "^3.4.2",
|
||||
"@types/next-pwa": "^5.6.9",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^18",
|
||||
"@types/react-dom": "^18",
|
||||
"@types/sanitize-html": "^2.11.0",
|
||||
"@typescript-eslint/eslint-plugin": "^7.3.1",
|
||||
"@typescript-eslint/parser": "^7.3.1",
|
||||
"autoprefixer": "^10.0.1",
|
||||
"eslint": "^8",
|
||||
"eslint-config-next": "^14.1.4",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"eslint-plugin-css-modules": "^2.12.0",
|
||||
"eslint-plugin-import": "^2.29.1",
|
||||
"eslint-plugin-prettier": "^5.1.3",
|
||||
"postcss": "^8",
|
||||
"prettier": "^3.2.5",
|
||||
"tailwindcss": "^3.3.0",
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
|
@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {}
|
||||
}
|
||||
};
|
@ -0,0 +1,2 @@
|
||||
/**.js
|
||||
/**.js.map
|
Before Width: | Height: | Size: 935 B |
@ -0,0 +1,11 @@
|
||||
(import
|
||||
(
|
||||
let lock = builtins.fromJSON (builtins.readFile ./flake.lock); in
|
||||
fetchTarball {
|
||||
url = lock.nodes.flake-compat.locked.url or "https://github.com/edolstra/flake-compat/archive/${lock.nodes.flake-compat.locked.rev}.tar.gz";
|
||||
sha256 = lock.nodes.flake-compat.locked.narHash;
|
||||
}
|
||||
)
|
||||
{ src = ./.; }
|
||||
).shellNix
|
||||
|
@ -0,0 +1,54 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@nextui-org/react";
|
||||
import { FC, useState } from "react";
|
||||
import { useHotkeys } from "react-hotkeys-hook";
|
||||
|
||||
import {
|
||||
Modal,
|
||||
ModalBody,
|
||||
ModalContent,
|
||||
ModalFooter,
|
||||
ModalHeader
|
||||
} from "@nextui-org/modal";
|
||||
|
||||
import { Search } from "@/components/Search";
|
||||
|
||||
export const SearchModal: FC = () => {
|
||||
const [isOpen, setOpen] = useState(false);
|
||||
|
||||
useHotkeys(
|
||||
"ctrl+k",
|
||||
() => {
|
||||
setOpen(true);
|
||||
},
|
||||
{ preventDefault: true }
|
||||
);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onOpenChange={(isOpen) => {
|
||||
setOpen(isOpen);
|
||||
}}
|
||||
size="2xl"
|
||||
>
|
||||
<ModalContent>
|
||||
{(onClose) => (
|
||||
<>
|
||||
<ModalHeader className="flex flex-col gap-1">Search</ModalHeader>
|
||||
<ModalBody>
|
||||
<Search onSearch={() => onClose()} />
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button color="danger" variant="light" onPress={onClose}>
|
||||
Close
|
||||
</Button>
|
||||
<Button color="primary">Search</Button>
|
||||
</ModalFooter>
|
||||
</>
|
||||
)}
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
};
|
@ -0,0 +1,14 @@
|
||||
import { SearchModal } from "./SearchModal";
|
||||
|
||||
import { Component } from "@/typings/component";
|
||||
|
||||
const YoutubeLayout: Component = ({ children }) => {
|
||||
return (
|
||||
<>
|
||||
{children}
|
||||
<SearchModal />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default YoutubeLayout;
|
@ -0,0 +1,22 @@
|
||||
import { FC } from "react";
|
||||
|
||||
import { Button } from "@nextui-org/button";
|
||||
import { Spacer } from "@nextui-org/spacer";
|
||||
|
||||
export const ErrorPage: FC<{ data: Error; refetch: () => void }> = ({
|
||||
data: error,
|
||||
refetch
|
||||
}) => {
|
||||
return (
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<h1 className="text-xl">An error occurred loading the search page</h1>
|
||||
<h2 className="text-lg">{error.toString()}</h2>
|
||||
<Spacer y={2} />
|
||||
<Button color="primary" onClick={() => refetch()}>
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
@ -0,0 +1,42 @@
|
||||
"use client";
|
||||
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { FC, useMemo } from "react";
|
||||
|
||||
import { SearchType, SearchTypeModel } from "@/client/typings/search/options";
|
||||
|
||||
import { Container } from "@/components/Container";
|
||||
import { Search } from "@/components/Search";
|
||||
|
||||
import { SearchPageBody } from "./SearchPageBody";
|
||||
|
||||
export const SearchPage: FC = () => {
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const query = useMemo(() => {
|
||||
const param = searchParams.get("search_query");
|
||||
|
||||
if (param === null || param.length === 0) return;
|
||||
|
||||
return param;
|
||||
}, [searchParams]);
|
||||
|
||||
const filter: SearchType = useMemo(() => {
|
||||
const param = searchParams.get("filter");
|
||||
|
||||
const parsed = SearchTypeModel.safeParse(param);
|
||||
|
||||
if (!parsed.success) return "all";
|
||||
|
||||
return parsed.data;
|
||||
}, [searchParams]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Container>
|
||||
<Search query={query} filter={filter} />
|
||||
{query && <SearchPageBody query={query} filter={filter} />}
|
||||
</Container>
|
||||
</>
|
||||
);
|
||||
};
|
@ -0,0 +1,46 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { FC } from "react";
|
||||
|
||||
import { Avatar } from "@nextui-org/avatar";
|
||||
import { Card, CardBody } from "@nextui-org/card";
|
||||
|
||||
import { ChannelItem } from "@/client/typings/item";
|
||||
import formatBigNumber from "@/utils/formatBigNumber";
|
||||
import { channelUrl } from "@/utils/urls";
|
||||
|
||||
export const Channel: FC<{ data: ChannelItem }> = ({ data }) => {
|
||||
const url = channelUrl(data.id);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardBody>
|
||||
<div className="flex flex-row gap-4">
|
||||
<Link href={url}>
|
||||
<Avatar
|
||||
className="w-32 h-32 text-2xl"
|
||||
src={data.thumbnail}
|
||||
name={data.name}
|
||||
isBordered
|
||||
/>
|
||||
</Link>
|
||||
<div className="flex-1 gap-2 flex flex-col justify-center">
|
||||
<div className="flex flex-col">
|
||||
<Link href={url}>
|
||||
<p className="text-lg">{data.name}</p>
|
||||
</Link>
|
||||
<div className="flex flex-row gap-4 items-center tracking-tight text-default-400">
|
||||
<p>{formatBigNumber(data.subscribers)} subscribers</p>
|
||||
{data.videos !== 0 && (
|
||||
<p>{formatBigNumber(data.videos)} videos</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-default-600">{data.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
};
|
@ -0,0 +1,20 @@
|
||||
import { useVisibility } from "reactjs-visibility";
|
||||
|
||||
import { CircularProgress } from "@nextui-org/progress";
|
||||
|
||||
import { Component } from "@/typings/component";
|
||||
|
||||
export const LoadingNextPage: Component<{
|
||||
isFetching: boolean;
|
||||
onVisible: (visiblity: boolean) => void;
|
||||
}> = ({ onVisible, isFetching }) => {
|
||||
const { ref } = useVisibility({
|
||||
onChangeVisibility: onVisible
|
||||
});
|
||||
|
||||
return (
|
||||
<div ref={ref} className="flex items-center justify-center min-h-10">
|
||||
{isFetching && <CircularProgress aria-label="Loading more items..." />}
|
||||
</div>
|
||||
);
|
||||
};
|
@ -0,0 +1,81 @@
|
||||
"use client";
|
||||
|
||||
import NextImage from "next/image";
|
||||
import NextLink from "next/link";
|
||||
import { FC } from "react";
|
||||
|
||||
import { Card, CardBody } from "@nextui-org/card";
|
||||
import { Image } from "@nextui-org/image";
|
||||
import { Link } from "@nextui-org/link";
|
||||
import { Listbox, ListboxItem } from "@nextui-org/listbox";
|
||||
|
||||
import { PlaylistItem } from "@/client/typings/item";
|
||||
import { videoUrl } from "@/utils/urls";
|
||||
import { videoSize } from "@/utils/videoSize";
|
||||
|
||||
import { Author } from "@/components/Author";
|
||||
|
||||
import { imageSize } from "./constants";
|
||||
|
||||
export const Playlist: FC<{ data: PlaylistItem }> = ({ data }) => {
|
||||
const url = `/playlist/${data.id}`;
|
||||
|
||||
const [width, height] = videoSize(imageSize);
|
||||
|
||||
const [playlistItemWidth, playlistItemHeight] = videoSize(5);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardBody>
|
||||
<div className="flex flex-col lg:flex-row gap-4">
|
||||
<div className="relative">
|
||||
<NextLink href={url}>
|
||||
<Image
|
||||
width={width}
|
||||
height={height}
|
||||
className="object-contain"
|
||||
src={data.thumbnail}
|
||||
alt={data.title}
|
||||
as={NextImage}
|
||||
unoptimized
|
||||
/>
|
||||
</NextLink>
|
||||
<p className="text-small rounded-md z-10 absolute bottom-2 right-2 bg-content2 p-1">
|
||||
{data.numberOfVideos} videos
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<Link as={NextLink} href={url}>
|
||||
<h1 className="text-xl text-default-foreground">{data.title}</h1>
|
||||
</Link>
|
||||
|
||||
<Author data={data.author} />
|
||||
|
||||
{data.videos && (
|
||||
<Listbox>
|
||||
{data.videos.slice(0, 2).map((video) => (
|
||||
<ListboxItem
|
||||
as={NextLink}
|
||||
startContent={
|
||||
<Image
|
||||
alt={video.title}
|
||||
src={video.thumbnail}
|
||||
height={playlistItemHeight}
|
||||
width={playlistItemWidth}
|
||||
/>
|
||||
}
|
||||
key={video.id}
|
||||
href={videoUrl(video.id)}
|
||||
>
|
||||
{video.title}
|
||||
</ListboxItem>
|
||||
))}
|
||||
</Listbox>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
};
|
@ -0,0 +1,72 @@
|
||||
"use client";
|
||||
|
||||
import NextImage from "next/image";
|
||||
import NextLink from "next/link";
|
||||
import { FC } from "react";
|
||||
|
||||
import { Card, CardBody } from "@nextui-org/card";
|
||||
import { Image } from "@nextui-org/image";
|
||||
import { Link } from "@nextui-org/link";
|
||||
|
||||
import { VideoItem } from "@/client/typings/item";
|
||||
import formatBigNumber from "@/utils/formatBigNumber";
|
||||
import formatDuration from "@/utils/formatDuration";
|
||||
import formatUploadedTime from "@/utils/formatUploadedTime";
|
||||
import { videoUrl } from "@/utils/urls";
|
||||
import { videoSize } from "@/utils/videoSize";
|
||||
|
||||
import { Author } from "@/components/Author";
|
||||
|
||||
import { imageSize } from "./constants";
|
||||
|
||||
export const Video: FC<{ data: VideoItem }> = ({ data }) => {
|
||||
const url = videoUrl(data.id);
|
||||
|
||||
const [width, height] = videoSize(imageSize);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardBody>
|
||||
<div className="flex flex-row gap-4">
|
||||
<div className="relative">
|
||||
<NextLink href={url}>
|
||||
<Image
|
||||
width={width}
|
||||
height={height}
|
||||
src={data.thumbnail}
|
||||
alt={data.title}
|
||||
as={NextImage}
|
||||
unoptimized
|
||||
/>
|
||||
</NextLink>
|
||||
|
||||
<p className="text-small rounded-md z-10 absolute bottom-2 right-2 bg-content2 p-1">
|
||||
{formatDuration(data.duration)}
|
||||
</p>
|
||||
{data.live && (
|
||||
<p className="text-small rounded-md z-10 absolute bottom-2 left-2 bg-danger p-1">
|
||||
LIVE
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2 w-full">
|
||||
<div>
|
||||
<Link as={NextLink} href={url}>
|
||||
<h1 className="text-xl text-default-foreground">
|
||||
{data.title}
|
||||
</h1>
|
||||
</Link>
|
||||
<div className="flex flex-row gap-4 items-center tracking-tight text-default-400">
|
||||
<h1>{formatBigNumber(data.views)} views</h1>
|
||||
{data.uploaded && <h1>{formatUploadedTime(data.uploaded)}</h1>}
|
||||
</div>
|
||||
</div>
|
||||
<Author data={data.author} />
|
||||
<p className="text-default-600">{data.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
};
|
@ -0,0 +1 @@
|
||||
export const imageSize = 30;
|
@ -0,0 +1,86 @@
|
||||
import { useInfiniteQuery } from "@tanstack/react-query";
|
||||
import { FC, Fragment, useCallback } from "react";
|
||||
|
||||
import { useClient } from "@/hooks/useClient";
|
||||
|
||||
import { SearchType } from "@/client/typings/search/options";
|
||||
|
||||
import { LoadingPage } from "@/components/LoadingPage";
|
||||
|
||||
import { ErrorPage } from "../ErrorPage";
|
||||
import { Channel } from "./Channel";
|
||||
import { LoadingNextPage } from "./LoadingNextPage";
|
||||
import { Playlist } from "./Playlist";
|
||||
import { Video } from "./Video";
|
||||
|
||||
export const SearchPageBody: FC<{ query: string; filter: SearchType }> = ({
|
||||
filter,
|
||||
query
|
||||
}) => {
|
||||
const client = useClient();
|
||||
|
||||
const {
|
||||
data,
|
||||
error,
|
||||
fetchNextPage,
|
||||
refetch,
|
||||
isPending: isFetchingInitialData,
|
||||
isFetchingNextPage
|
||||
} = useInfiniteQuery({
|
||||
queryKey: ["search", query, filter],
|
||||
queryFn: async ({ pageParam }) => {
|
||||
return await client.getSearch(query, {
|
||||
pageParam: pageParam,
|
||||
type: filter
|
||||
});
|
||||
},
|
||||
initialPageParam: "",
|
||||
getNextPageParam: (lastPage) => lastPage.nextCursor
|
||||
});
|
||||
|
||||
const isFetchingNewPage = isFetchingNextPage && !isFetchingInitialData;
|
||||
|
||||
const fetchNewData = useCallback(
|
||||
(visiblity: boolean) => {
|
||||
if (visiblity && !isFetchingNextPage) fetchNextPage();
|
||||
},
|
||||
[isFetchingNextPage, fetchNextPage]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{isFetchingInitialData && (
|
||||
<LoadingPage text={`Fetching search results for query \`${query}\``} />
|
||||
)}
|
||||
{data && (
|
||||
<div className="flex flex-col gap-4 mt-4">
|
||||
{data.pages.map((page, i) => {
|
||||
return (
|
||||
<Fragment key={i}>
|
||||
{page.items.map((result) => {
|
||||
switch (result.type) {
|
||||
case "channel":
|
||||
return <Channel key={result.id} data={result} />;
|
||||
|
||||
case "video":
|
||||
return <Video key={result.id} data={result} />;
|
||||
|
||||
case "playlist":
|
||||
return <Playlist key={result.id} data={result} />;
|
||||
}
|
||||
})}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
{error === null && (
|
||||
<LoadingNextPage
|
||||
isFetching={isFetchingNewPage}
|
||||
onVisible={fetchNewData}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{error !== null && <ErrorPage data={error} refetch={refetch} />}
|
||||
</>
|
||||
);
|
||||
};
|
@ -0,0 +1,25 @@
|
||||
import { NextPage } from "next";
|
||||
import { Suspense } from "react";
|
||||
|
||||
import { Container } from "@/components/Container";
|
||||
import { Search } from "@/components/Search";
|
||||
|
||||
import { SearchPage } from "./SearchPage";
|
||||
|
||||
const Page: NextPage = () => {
|
||||
return (
|
||||
<>
|
||||
<Suspense
|
||||
fallback={
|
||||
<Container>
|
||||
<Search />
|
||||
</Container>
|
||||
}
|
||||
>
|
||||
<SearchPage />
|
||||
</Suspense>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Page;
|
@ -0,0 +1,35 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
import { Autocomplete, AutocompleteItem } from "@nextui-org/autocomplete";
|
||||
|
||||
import { Region } from "@/utils/getRegionCodes";
|
||||
|
||||
import { Component } from "@/typings/component";
|
||||
|
||||
export const RegionSwitcher: Component<{
|
||||
regions: Region[];
|
||||
currentRegion: Region | null;
|
||||
}> = ({ currentRegion, regions }) => {
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<Autocomplete
|
||||
defaultItems={regions}
|
||||
label="Region"
|
||||
placeholder="Select your region"
|
||||
isClearable={false}
|
||||
selectedKey={currentRegion?.code}
|
||||
onSelectionChange={(key) => {
|
||||
if (typeof key === "string" && key.length != 0)
|
||||
return router.push(`/trending?region=${key}`);
|
||||
}}
|
||||
className="max-w-xs"
|
||||
>
|
||||
{(item) => (
|
||||
<AutocompleteItem key={item.code}>{item.name}</AutocompleteItem>
|
||||
)}
|
||||
</Autocomplete>
|
||||
);
|
||||
};
|
@ -0,0 +1,108 @@
|
||||
"use client";
|
||||
|
||||
import { defaultRegion } from "@/constants";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useMemo } from "react";
|
||||
|
||||
import { Button } from "@nextui-org/button";
|
||||
import { Spacer } from "@nextui-org/spacer";
|
||||
|
||||
import { useClient } from "@/hooks/useClient";
|
||||
|
||||
import getRegionCodes from "@/utils/getRegionCodes";
|
||||
|
||||
import { Container } from "@/components/Container";
|
||||
import { LoadingPage } from "@/components/LoadingPage";
|
||||
import { Video } from "@/components/Video";
|
||||
|
||||
import { RegionSwitcher } from "./RegionSwitcher";
|
||||
|
||||
import { Component } from "@/typings/component";
|
||||
|
||||
export const Trending: Component = ({}) => {
|
||||
const client = useClient();
|
||||
|
||||
const searchParams = useSearchParams();
|
||||
const validRegions = useMemo(() => getRegionCodes(), []);
|
||||
|
||||
const specifiedRegion =
|
||||
searchParams.get("region")?.toUpperCase() ?? defaultRegion;
|
||||
|
||||
const [region, regionError] = useMemo(() => {
|
||||
const foundRegion = validRegions.find(
|
||||
(validRegion) => validRegion.code === specifiedRegion
|
||||
);
|
||||
|
||||
if (foundRegion === undefined)
|
||||
return [null, new Error(`Region \`${specifiedRegion}\` is invalid`)];
|
||||
|
||||
return [foundRegion, null];
|
||||
}, [specifiedRegion, validRegions]);
|
||||
|
||||
const {
|
||||
isLoading,
|
||||
error: fetchError,
|
||||
refetch,
|
||||
data
|
||||
} = useQuery({
|
||||
queryKey: ["trending", region],
|
||||
queryFn: () => {
|
||||
if (region === null) return;
|
||||
|
||||
return client.getTrending(region.code);
|
||||
},
|
||||
enabled: regionError === null
|
||||
});
|
||||
|
||||
const noDataError = useMemo(() => {
|
||||
if (data && data.length === 0)
|
||||
return new Error(
|
||||
`Could not find any trending video's in region \`${region?.name}\``
|
||||
);
|
||||
|
||||
return null;
|
||||
}, [data, region]);
|
||||
|
||||
const error: Error | null = regionError ?? fetchError ?? noDataError ?? null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Container>
|
||||
<div className="flex flex-row items-center gap-4">
|
||||
<RegionSwitcher currentRegion={region} regions={validRegions} />
|
||||
<h1 className="text-xl">Trending</h1>
|
||||
</div>
|
||||
|
||||
{isLoading && !data && (
|
||||
<LoadingPage
|
||||
text={`Loading trending page for region \`${region?.name}\``}
|
||||
/>
|
||||
)}
|
||||
|
||||
{error !== null && (
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<h1 className="text-xl">
|
||||
An error occurred loading the trending page
|
||||
</h1>
|
||||
<h2 className="text-lg">{error.toString()}</h2>
|
||||
<Spacer y={2} />
|
||||
<Button color="primary" onClick={() => refetch()}>
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{data && data.length !== 0 && error === null && (
|
||||
<div className="grid gap-4 py-4 grid-cols-1 md:grid-cols-2 lg:grid-cols-3">
|
||||
{data.map((video) => (
|
||||
<Video key={video.id} data={video} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Container>
|
||||
</>
|
||||
);
|
||||
};
|
@ -0,0 +1,25 @@
|
||||
import { NextPage } from "next";
|
||||
import { Suspense } from "react";
|
||||
|
||||
import { Container } from "@/components/Container";
|
||||
import { LoadingPage } from "@/components/LoadingPage";
|
||||
|
||||
import { Trending } from "./Trending";
|
||||
|
||||
const Page: NextPage = () => {
|
||||
return (
|
||||
<>
|
||||
<Suspense
|
||||
fallback={
|
||||
<Container>
|
||||
<LoadingPage text="Loading trending page" />
|
||||
</Container>
|
||||
}
|
||||
>
|
||||
<Trending />
|
||||
</Suspense>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Page;
|
@ -0,0 +1,227 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import NextLink from "next/link";
|
||||
import { FC, useMemo, useState } from "react";
|
||||
import {
|
||||
FiHeart as HeartIcon,
|
||||
FiThumbsUp as LikeIcon,
|
||||
FiLock as PinnedIcon,
|
||||
FiCornerDownRight as ShowRepliesIcon,
|
||||
FiSlash as SlashIcon,
|
||||
FiCheck as UploaderIcon
|
||||
} from "react-icons/fi";
|
||||
|
||||
import { Avatar } from "@nextui-org/avatar";
|
||||
import { Button } from "@nextui-org/button";
|
||||
import { Chip } from "@nextui-org/chip";
|
||||
import { Divider } from "@nextui-org/divider";
|
||||
import { Link } from "@nextui-org/link";
|
||||
import { CircularProgress } from "@nextui-org/progress";
|
||||
import { Tooltip } from "@nextui-org/tooltip";
|
||||
|
||||
import { useClient } from "@/hooks/useClient";
|
||||
|
||||
import { Author } from "@/client/typings/author";
|
||||
import {
|
||||
Comment as CommentProps,
|
||||
Comments as CommentsProps
|
||||
} from "@/client/typings/comment";
|
||||
import formatBigNumber from "@/utils/formatBigNumber";
|
||||
import formatUploadedTime from "@/utils/formatUploadedTime";
|
||||
import { highlight } from "@/utils/highlight";
|
||||
import { channelUrl } from "@/utils/urls";
|
||||
|
||||
import { HighlightRenderer } from "./HighlightRenderer";
|
||||
|
||||
const Comment: FC<{
|
||||
data: CommentProps;
|
||||
videoUploader: Author;
|
||||
videoId: string;
|
||||
}> = ({ data, videoUploader, videoId }) => {
|
||||
const message = useMemo(() => highlight(data.message), [data.message]);
|
||||
|
||||
const client = useClient();
|
||||
|
||||
const [showReplies, setShowReplies] = useState(false);
|
||||
|
||||
const {
|
||||
data: replies,
|
||||
error: repliesError,
|
||||
refetch: refetchReplies,
|
||||
isLoading: isLoadingReplies
|
||||
} = useQuery({
|
||||
queryKey: ["replies", videoId, data.repliesToken],
|
||||
queryFn: () => {
|
||||
return client.getComments(videoId, data.repliesToken);
|
||||
},
|
||||
enabled: showReplies && !!data.repliesToken
|
||||
});
|
||||
|
||||
const userUrl = data.author.id ? channelUrl(data.author.id) : "#";
|
||||
|
||||
return (
|
||||
<div className="flex flex-row gap-4">
|
||||
<div>
|
||||
<Link as={NextLink} href={userUrl}>
|
||||
<Avatar
|
||||
isBordered
|
||||
size="lg"
|
||||
showFallback
|
||||
src={data.author.avatar}
|
||||
name={data.author.name}
|
||||
/>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2 w-full">
|
||||
<div className="flex flex-row gap-2">
|
||||
<Link as={NextLink} href={userUrl}>
|
||||
<p className="font-semibold text-default-foreground">
|
||||
{data.author.name}
|
||||
</p>
|
||||
</Link>
|
||||
{data.author.id === videoUploader.id && (
|
||||
<Chip
|
||||
className="pl-2"
|
||||
startContent={<UploaderIcon />}
|
||||
color="primary"
|
||||
>
|
||||
Uploader
|
||||
</Chip>
|
||||
)}
|
||||
{data.pinned && (
|
||||
<Chip
|
||||
className="pl-2"
|
||||
startContent={<PinnedIcon />}
|
||||
color="primary"
|
||||
>
|
||||
Pinned
|
||||
</Chip>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p>
|
||||
<HighlightRenderer highlighted={message} />
|
||||
</p>
|
||||
|
||||
<div className="flex flex-row gap-4 items-center">
|
||||
<div className="flex flex-row tracking-tight text-default-500 items-center gap-1">
|
||||
<LikeIcon />
|
||||
<p>{formatBigNumber(data.likes)} likes</p>
|
||||
</div>
|
||||
|
||||
<div className="tracking-tight text-default-500">
|
||||
<p>{formatUploadedTime(data.written)}</p>
|
||||
</div>
|
||||
|
||||
{data.videoUploaderLiked && (
|
||||
<div className="flex items-center">
|
||||
<Tooltip content="Uploader liked" showArrow>
|
||||
<p className="text-danger text-xl">
|
||||
<HeartIcon />
|
||||
</p>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{data.videoUploaderReplied && (
|
||||
<div>
|
||||
<Avatar
|
||||
size="sm"
|
||||
src={videoUploader.avatar}
|
||||
name={videoUploader.name}
|
||||
showFallback
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{data.edited && (
|
||||
<p className="tracking-tight text-default-500">(edited)</p>
|
||||
)}
|
||||
|
||||
{data.repliesToken && (
|
||||
<Button
|
||||
startContent={<ShowRepliesIcon />}
|
||||
variant="light"
|
||||
onClick={() => setShowReplies((state) => !state)}
|
||||
>
|
||||
{showReplies ? "Hide replies" : "Show replies"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showReplies && (
|
||||
<div className="flex flex-col gap-4">
|
||||
<Comments
|
||||
data={replies}
|
||||
isLoading={isLoadingReplies}
|
||||
error={repliesError}
|
||||
refetch={refetchReplies}
|
||||
videoUploader={videoUploader}
|
||||
videoId={videoId}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const Comments: FC<{
|
||||
data?: CommentsProps;
|
||||
isLoading: boolean;
|
||||
error: Error | null;
|
||||
refetch: () => void;
|
||||
videoUploader: Author;
|
||||
videoId: string;
|
||||
}> = ({ data, isLoading, error, refetch, videoUploader, videoId }) => {
|
||||
return (
|
||||
<>
|
||||
{data && (
|
||||
<>
|
||||
<p className="text-xl">
|
||||
{data.count && formatBigNumber(data.count)} Comments
|
||||
</p>
|
||||
|
||||
<Divider orientation="horizontal" />
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
{data.enabled && (
|
||||
<>
|
||||
{data.data.map((comment) => (
|
||||
<Comment
|
||||
key={comment.id}
|
||||
data={comment}
|
||||
videoUploader={videoUploader}
|
||||
videoId={videoId}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
{!data.enabled && (
|
||||
<div className="flex flex-row gap-2 items-center">
|
||||
<SlashIcon />
|
||||
<p>Comments on this video are disabled</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{!data && isLoading && (
|
||||
<div className="h-24 w-full justify-center items-center flex">
|
||||
<CircularProgress aria-label="Loading comments..." />
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<p className="text-lg font-semibold">Failed to load comments:</p>
|
||||
{error.toString()}
|
||||
<div>
|
||||
<Button color="primary" onClick={() => refetch()}>
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
@ -0,0 +1,66 @@
|
||||
import sanitizeHtml from "sanitize-html";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import {
|
||||
FiChevronUp as CollapseIcon,
|
||||
FiChevronDown as ExpandIcon
|
||||
} from "react-icons/fi";
|
||||
|
||||
import { Button } from "@nextui-org/button";
|
||||
|
||||
import { highlight } from "@/utils/highlight";
|
||||
|
||||
import { HighlightRenderer } from "./HighlightRenderer";
|
||||
|
||||
import { Component } from "@/typings/component";
|
||||
|
||||
const shortenedDescriptionLength = 200;
|
||||
|
||||
export const Description: Component<{ data: string }> = ({ data }) => {
|
||||
const [expandedDescription, setExpandedDescription] = useState(false);
|
||||
|
||||
const sanitizedDescription = useMemo(
|
||||
() =>
|
||||
sanitizeHtml(data, {
|
||||
allowedTags: ["a", "br"],
|
||||
allowedAttributes: {
|
||||
a: ["href"]
|
||||
}
|
||||
}),
|
||||
[data]
|
||||
);
|
||||
|
||||
const descriptionAlreadyShort =
|
||||
sanitizedDescription.length <= shortenedDescriptionLength;
|
||||
|
||||
const descriptionCut = useMemo(() => {
|
||||
if (descriptionAlreadyShort || expandedDescription)
|
||||
return sanitizedDescription;
|
||||
else
|
||||
return (
|
||||
sanitizedDescription.substring(0, shortenedDescriptionLength) + "..."
|
||||
);
|
||||
}, [sanitizedDescription, descriptionAlreadyShort, expandedDescription]);
|
||||
|
||||
const description = useMemo(
|
||||
() => highlight(descriptionCut),
|
||||
[descriptionCut]
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2 className="text-ellipsis overflow-y-hidden">
|
||||
<HighlightRenderer highlighted={description} />
|
||||
</h2>
|
||||
{!descriptionAlreadyShort && (
|
||||
<Button
|
||||
startContent={expandedDescription ? <CollapseIcon /> : <ExpandIcon />}
|
||||
variant="light"
|
||||
onClick={() => setExpandedDescription((state) => !state)}
|
||||
>
|
||||
{expandedDescription ? "Show less" : "Show more"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
@ -0,0 +1,38 @@
|
||||
import { FC, Fragment } from "react";
|
||||
|
||||
import { Link } from "@nextui-org/link";
|
||||
|
||||
import formatDuration from "@/utils/formatDuration";
|
||||
import { Item, ItemType } from "@/utils/highlight";
|
||||
|
||||
export const HighlightRenderer: FC<{ highlighted: Item[] }> = ({
|
||||
highlighted
|
||||
}) => {
|
||||
return (
|
||||
<>
|
||||
{highlighted.map((item) => {
|
||||
switch (item.type) {
|
||||
case ItemType.Tokens:
|
||||
return <Fragment key={item.id}>{item.content}</Fragment>;
|
||||
|
||||
case ItemType.Link:
|
||||
return (
|
||||
<Link key={item.id} href={item.href}>
|
||||
{item.text ?? item.href}
|
||||
</Link>
|
||||
);
|
||||
|
||||
case ItemType.Timestamp:
|
||||
return (
|
||||
<Link key={item.id} href="">
|
||||
{formatDuration(item.duration * 1000)}
|
||||
</Link>
|
||||
);
|
||||
|
||||
case ItemType.Linebreak:
|
||||
return <br key={item.id} />;
|
||||
}
|
||||
})}
|
||||
</>
|
||||
);
|
||||
};
|
@ -0,0 +1,393 @@
|
||||
"use client";
|
||||
|
||||
import screenfull from "screenfull";
|
||||
import { useDebounce } from "use-debounce";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useHotkeys } from "react-hotkeys-hook";
|
||||
import {
|
||||
FiMaximize as MaximizeIcon,
|
||||
FiMinimize as MinimizeIcon,
|
||||
FiVolumeX as MutedIcon,
|
||||
FiPause as PauseIcon,
|
||||
FiPlay as PlayIcon,
|
||||
FiSettings as SettingsIcon,
|
||||
FiVolume as VolumeIcon,
|
||||
FiVolume1 as VolumeIcon1,
|
||||
FiVolume2 as VolumeIcon2
|
||||
} from "react-icons/fi";
|
||||
import ReactPlayer from "react-player";
|
||||
|
||||
import { Button } from "@nextui-org/button";
|
||||
import {
|
||||
Dropdown,
|
||||
DropdownItem,
|
||||
DropdownMenu,
|
||||
DropdownTrigger
|
||||
} from "@nextui-org/dropdown";
|
||||
import { Slider } from "@nextui-org/slider";
|
||||
|
||||
import { HlsStream, Stream, StreamType } from "@/client/typings/stream";
|
||||
import { Video } from "@/client/typings/video";
|
||||
import formatDuration from "@/utils/formatDuration";
|
||||
|
||||
import { Component } from "@/typings/component";
|
||||
|
||||
export const Player: Component<{
|
||||
streams: Stream[];
|
||||
video: Video;
|
||||
initialTimestamp?: number;
|
||||
}> = ({ streams, initialTimestamp, video }) => {
|
||||
const stream = streams.find((stream) => stream.type === StreamType.Hls);
|
||||
|
||||
const playerRef = useRef<ReactPlayer>(null);
|
||||
|
||||
const videoPlayerId = "video-player";
|
||||
|
||||
// TODO: Make framerate based on video, not a set number
|
||||
const framerate = 60;
|
||||
|
||||
const volumeIcons = useMemo(
|
||||
() => [
|
||||
<VolumeIcon key="vol" />,
|
||||
<VolumeIcon1 key="vol1" />,
|
||||
<VolumeIcon2 key="vol2" />
|
||||
],
|
||||
[]
|
||||
);
|
||||
|
||||
const [playbackRateMenuItems, playbackRateCategories] = useMemo(() => {
|
||||
const categories = [0.25, 0.5, 1, 1.25, 1.5, 2];
|
||||
|
||||
return [
|
||||
categories.map((speed) => ({
|
||||
key: speed,
|
||||
label: speed.toString()
|
||||
})),
|
||||
categories
|
||||
];
|
||||
}, []);
|
||||
|
||||
const [showOverlay, setShowOverlay] = useState(false);
|
||||
const [mouseOnOverlay, setMouseOnOverlay] = useState([0, 0]);
|
||||
|
||||
useEffect(() => {
|
||||
setShowOverlay(true);
|
||||
}, [mouseOnOverlay]);
|
||||
|
||||
const [mouseLastMoved] = useDebounce(mouseOnOverlay, 2000);
|
||||
|
||||
useEffect(() => {
|
||||
setShowOverlay(false);
|
||||
}, [mouseLastMoved]);
|
||||
|
||||
const [duration, setDuration] = useState(video.duration / 1000);
|
||||
const [progress, setProgress] = useState(0);
|
||||
const [loaded, setLoaded] = useState(0);
|
||||
const [maximized, setMaximized] = useState(false);
|
||||
const [volume, setVolume] = useState(40);
|
||||
const [muted, setMuted] = useState(false);
|
||||
const [playbackRate, setPlaybackRate] = useState(1.0);
|
||||
const [playing, setPlaying] = useState(false);
|
||||
|
||||
const [userSetProgress, setUserSetProgress] = useState(0);
|
||||
|
||||
const seekForward = useCallback(
|
||||
(seconds: number) => {
|
||||
if (duration >= seconds) {
|
||||
let newProgress = progress + seconds / duration;
|
||||
|
||||
if (newProgress <= 0) newProgress = 0;
|
||||
|
||||
if (newProgress >= duration) newProgress = duration;
|
||||
|
||||
setUserSetProgress(newProgress);
|
||||
setProgress(newProgress);
|
||||
}
|
||||
},
|
||||
[progress, duration]
|
||||
);
|
||||
|
||||
const increaseVolume = useCallback((amount: number) => {
|
||||
setVolume((state) => {
|
||||
const newVolume = state + amount;
|
||||
|
||||
if (newVolume >= 0 && newVolume <= 100) return newVolume;
|
||||
else return state;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const increasePlaybackRate = useCallback(
|
||||
(amount: number) => {
|
||||
const indexOfCurrentRate = playbackRateCategories.indexOf(playbackRate);
|
||||
|
||||
if (indexOfCurrentRate < 0) return;
|
||||
|
||||
const newRateIndex = indexOfCurrentRate + amount;
|
||||
|
||||
if (newRateIndex < 0 || newRateIndex > playbackRateCategories.length - 1)
|
||||
return;
|
||||
|
||||
setPlaybackRate(playbackRateCategories[newRateIndex]);
|
||||
},
|
||||
[playbackRate, playbackRateCategories]
|
||||
);
|
||||
|
||||
useHotkeys(["k", "space"], () => setPlaying((state) => !state), {
|
||||
preventDefault: true
|
||||
});
|
||||
|
||||
useHotkeys(["f"], () => setMaximized((state) => !state));
|
||||
|
||||
useHotkeys(["m"], () => setMuted((state) => !state));
|
||||
|
||||
useHotkeys(["arrowup"], () => increaseVolume(5), { preventDefault: true });
|
||||
useHotkeys(["arrowdown"], () => increaseVolume(-5), { preventDefault: true });
|
||||
|
||||
useHotkeys(["arrowright"], () => seekForward(5));
|
||||
useHotkeys(["arrowleft"], () => seekForward(-5));
|
||||
|
||||
useHotkeys(["shift+."], () => increasePlaybackRate(1));
|
||||
useHotkeys(["shift+,"], () => increasePlaybackRate(-1));
|
||||
|
||||
useHotkeys(["l"], () => seekForward(10));
|
||||
useHotkeys(["j"], () => seekForward(-10));
|
||||
|
||||
useHotkeys(
|
||||
["."],
|
||||
() => {
|
||||
if (!playing) seekForward(1 / framerate);
|
||||
},
|
||||
[seekForward, playing, framerate]
|
||||
);
|
||||
useHotkeys(
|
||||
[","],
|
||||
() => {
|
||||
if (!playing) seekForward(-(1 / framerate));
|
||||
},
|
||||
[seekForward, playing, framerate]
|
||||
);
|
||||
|
||||
// Mute if volume is 0
|
||||
useEffect(() => {
|
||||
if (volume === 0) setMuted(true);
|
||||
else setMuted(false);
|
||||
}, [volume]);
|
||||
|
||||
useEffect(() => {
|
||||
playerRef.current?.seekTo(userSetProgress);
|
||||
}, [userSetProgress]);
|
||||
|
||||
useEffect(() => {
|
||||
if (initialTimestamp && initialTimestamp <= duration)
|
||||
setUserSetProgress(initialTimestamp / duration);
|
||||
}, [initialTimestamp, duration]);
|
||||
|
||||
const updateMaximized = useCallback(() => {
|
||||
setMaximized(screenfull.isFullscreen);
|
||||
}, [setMaximized]);
|
||||
|
||||
useEffect(() => {
|
||||
if (screenfull.isEnabled) {
|
||||
screenfull.on("change", updateMaximized);
|
||||
}
|
||||
|
||||
return () => screenfull.off("change", updateMaximized);
|
||||
}, [updateMaximized]);
|
||||
|
||||
useEffect(() => {
|
||||
if (screenfull.isEnabled) {
|
||||
const playerElement = document.getElementById(videoPlayerId) ?? undefined;
|
||||
|
||||
if (maximized) screenfull.request(playerElement);
|
||||
else screenfull.exit();
|
||||
}
|
||||
}, [maximized]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="relative" style={{ paddingTop: `${100 / (16 / 9)}%` }}>
|
||||
<div id={videoPlayerId}>
|
||||
<div
|
||||
className="flex flex-col w-full h-full absolute bottom-0 z-10 transition-opacity ease-in duration-[2000ms]"
|
||||
style={{
|
||||
opacity: showOverlay ? 100 : 0,
|
||||
cursor: showOverlay ? "initial" : "none"
|
||||
}}
|
||||
onMouseMove={(e) => setMouseOnOverlay([e.movementX, e.movementY])}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: maximized ? "flex" : "none",
|
||||
background:
|
||||
"linear-gradient(180deg, rgba(0,0,0,0.8) 0%, rgba(0,0,0,0) 100%)"
|
||||
}}
|
||||
className="flex-row gap-2 p-4"
|
||||
>
|
||||
<p className="text-3xl">{video.title}</p>
|
||||
</div>
|
||||
<div
|
||||
className="flex-1"
|
||||
onClick={() => setPlaying((state) => !state)}
|
||||
/>
|
||||
<div
|
||||
className="flex flex-col gap-1 pb-2 px-4"
|
||||
style={{
|
||||
background:
|
||||
"linear-gradient(0deg, rgba(0,0,0,0.8) 0%, rgba(0,0,0,0) 100%)"
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="relative flex items-center"
|
||||
style={{ height: "24px" }}
|
||||
>
|
||||
<Slider
|
||||
aria-label="Video progress bar"
|
||||
className="w-full cursor-pointer absolute bottom-0 z-20"
|
||||
step={0.1}
|
||||
onChange={(value) => {
|
||||
if (typeof value === "number") {
|
||||
setProgress(value / 100);
|
||||
}
|
||||
}}
|
||||
onChangeEnd={(value) => {
|
||||
if (typeof value === "number") {
|
||||
setUserSetProgress(value / 100);
|
||||
}
|
||||
}}
|
||||
value={progress * 100}
|
||||
/>
|
||||
<div
|
||||
className="h-3 bg-default-600/50 z-10 rounded-lg"
|
||||
style={{ width: `${loaded * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-row items-center gap-4">
|
||||
<div className="flex flex-1 flex-row gap-2 items-center">
|
||||
<Button
|
||||
variant="light"
|
||||
isIconOnly
|
||||
className="text-xl"
|
||||
onClick={() => setPlaying((state) => !state)}
|
||||
>
|
||||
{playing ? <PauseIcon /> : <PlayIcon />}
|
||||
</Button>
|
||||
|
||||
<Dropdown>
|
||||
<DropdownTrigger>
|
||||
<Button variant="light" isIconOnly className="text-xl">
|
||||
{muted ? (
|
||||
<MutedIcon />
|
||||
) : (
|
||||
volumeIcons[
|
||||
Math.floor((volume / 100) * volumeIcons.length)
|
||||
]
|
||||
)}
|
||||
</Button>
|
||||
</DropdownTrigger>
|
||||
<DropdownMenu aria-label="Volume menu">
|
||||
<DropdownItem>
|
||||
<Slider
|
||||
aria-label="Volume slider"
|
||||
className="h-48"
|
||||
value={volume}
|
||||
onChange={(value) => {
|
||||
if (typeof value === "number") setVolume(value);
|
||||
}}
|
||||
orientation="vertical"
|
||||
/>
|
||||
</DropdownItem>
|
||||
</DropdownMenu>
|
||||
</Dropdown>
|
||||
|
||||
<p>
|
||||
{formatDuration(progress * duration * 1000)} /{" "}
|
||||
{formatDuration(duration * 1000)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-row gap-2 items-center">
|
||||
<Dropdown>
|
||||
<DropdownTrigger>
|
||||
<Button className="text-xl" isIconOnly variant="light">
|
||||
<SettingsIcon />
|
||||
</Button>
|
||||
</DropdownTrigger>
|
||||
<DropdownMenu
|
||||
aria-label="Playback rate menu"
|
||||
onAction={(key) => {
|
||||
setPlaybackRate(parseFloat(key as string));
|
||||
}}
|
||||
items={playbackRateMenuItems}
|
||||
>
|
||||
{(item) => (
|
||||
<DropdownItem key={item.key}>
|
||||
{item.label}x
|
||||
</DropdownItem>
|
||||
)}
|
||||
</DropdownMenu>
|
||||
</Dropdown>
|
||||
|
||||
<Dropdown>
|
||||
<DropdownTrigger>
|
||||
<Button className="text-xl" variant="light">
|
||||
{playbackRate}x
|
||||
</Button>
|
||||
</DropdownTrigger>
|
||||
<DropdownMenu
|
||||
aria-label="Playback rate menu"
|
||||
onAction={(key) => {
|
||||
setPlaybackRate(parseFloat(key as string));
|
||||
}}
|
||||
items={playbackRateMenuItems}
|
||||
>
|
||||
{(item) => (
|
||||
<DropdownItem key={item.key}>
|
||||
{item.label}x
|
||||
</DropdownItem>
|
||||
)}
|
||||
</DropdownMenu>
|
||||
</Dropdown>
|
||||
|
||||
<Button
|
||||
variant="light"
|
||||
isIconOnly
|
||||
className="text-xl"
|
||||
onClick={() => setMaximized((state) => !state)}
|
||||
>
|
||||
{maximized ? <MinimizeIcon /> : <MaximizeIcon />}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{stream && (
|
||||
<ReactPlayer
|
||||
playing={playing}
|
||||
volume={volume / 100}
|
||||
muted={muted}
|
||||
playbackRate={playbackRate}
|
||||
ref={playerRef}
|
||||
className="absolute top-0 left-0"
|
||||
width="100%"
|
||||
height="100%"
|
||||
onPause={() => setPlaying(false)}
|
||||
onPlay={() => setPlaying(true)}
|
||||
onDuration={(duration) => setDuration(duration)}
|
||||
// onBuffer={({}) => {}}
|
||||
onProgress={({ played, loaded }) => {
|
||||
setProgress(played);
|
||||
setLoaded(loaded);
|
||||
}}
|
||||
// onPlaybackQualityChange={(e: unknown) =>
|
||||
// console.log("onPlaybackQualityChange", e)
|
||||
// }
|
||||
url={(stream as HlsStream).url}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
@ -0,0 +1,23 @@
|
||||
"use client";
|
||||
|
||||
import { Item } from "@/client/typings/item";
|
||||
|
||||
import { Video } from "@/components/Video";
|
||||
|
||||
import { Component } from "@/typings/component";
|
||||
|
||||
export const Related: Component<{ data: Item[] }> = ({ data }) => {
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
{data.map((item) => {
|
||||
switch (item.type) {
|
||||
case "video":
|
||||
return <Video key={item.id} data={item} size={25} />;
|
||||
|
||||
default:
|
||||
return <></>;
|
||||
}
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
@ -0,0 +1,150 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useMemo } from "react";
|
||||
import {
|
||||
FiThumbsDown as DislikeIcon,
|
||||
FiThumbsUp as LikeIcon,
|
||||
FiEye as ViewIcon
|
||||
} from "react-icons/fi";
|
||||
|
||||
import { Chip } from "@nextui-org/chip";
|
||||
|
||||
import { useClient } from "@/hooks/useClient";
|
||||
|
||||
import formatBigNumber from "@/utils/formatBigNumber";
|
||||
|
||||
import { Author } from "@/components/Author";
|
||||
import { Container } from "@/components/Container";
|
||||
import { LoadingPage } from "@/components/LoadingPage";
|
||||
|
||||
import { Comments } from "./Comments";
|
||||
import { Description } from "./Description";
|
||||
import { Player } from "./Player";
|
||||
import { Related } from "./Related";
|
||||
|
||||
import { Component } from "@/typings/component";
|
||||
|
||||
// TODO: Make all keywords visible in some way
|
||||
const maxKeyWords = 3;
|
||||
|
||||
export const Watch: Component = () => {
|
||||
const client = useClient();
|
||||
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const videoId = useMemo(() => {
|
||||
const param = searchParams.get("v");
|
||||
|
||||
if (param === null) return;
|
||||
|
||||
return param;
|
||||
}, [searchParams]);
|
||||
|
||||
const timestamp = useMemo(() => {
|
||||
const param = searchParams.get("t");
|
||||
|
||||
if (param === null) return;
|
||||
|
||||
const time = parseInt(param);
|
||||
|
||||
if (isNaN(time)) return;
|
||||
|
||||
return time;
|
||||
}, [searchParams]);
|
||||
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: ["watch", videoId],
|
||||
queryFn: () => {
|
||||
if (!videoId) return;
|
||||
return client.getWatchable(videoId);
|
||||
},
|
||||
enabled: !!videoId
|
||||
});
|
||||
|
||||
const {
|
||||
data: comments,
|
||||
isLoading: isLoadingComments,
|
||||
refetch: refetchComments,
|
||||
error: commentsError
|
||||
} = useQuery({
|
||||
queryKey: ["comments", videoId],
|
||||
queryFn: () => {
|
||||
if (!videoId) return;
|
||||
return client.getComments(videoId);
|
||||
},
|
||||
enabled: !!videoId
|
||||
});
|
||||
|
||||
if (error) console.log(error);
|
||||
|
||||
return (
|
||||
<Container>
|
||||
{isLoading && <LoadingPage />}
|
||||
{data && !isLoading && (
|
||||
<div className="flex flex-col gap-4">
|
||||
<Player
|
||||
initialTimestamp={timestamp}
|
||||
video={data.video}
|
||||
streams={data.streams}
|
||||
/>
|
||||
<div className="flex flex-col xl:flex-row gap-4">
|
||||
<div className="flex flex-1 flex-col gap-4">
|
||||
<div className="flex flex-col">
|
||||
<h1 className="text-2xl">{data.video.title}</h1>
|
||||
<div className="flex flex-row gap-4 text-lg tracking-tight text-default-500">
|
||||
<div className="flex flex-row gap-1 items-center">
|
||||
<ViewIcon />
|
||||
<p>{formatBigNumber(data.video.views)} views</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-row gap-1 items-center">
|
||||
<LikeIcon />
|
||||
<p>{formatBigNumber(data.likes)} likes</p>
|
||||
</div>
|
||||
<div className="flex flex-row gap-1 items-center">
|
||||
<DislikeIcon />
|
||||
<p>{formatBigNumber(data.dislikes)} dislikes</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Author data={data.video.author} />
|
||||
|
||||
<Description data={data.video.description ?? ""} />
|
||||
|
||||
<div className="flex flex-row gap-2">
|
||||
<h1>Category:</h1>
|
||||
<h1 className="font-semibold">{data.category}</h1>
|
||||
</div>
|
||||
|
||||
{data.keywords.length !== 0 && (
|
||||
<div className="flex flex-row gap-2">
|
||||
<p>Keywords:</p>
|
||||
<div className="flex flex-row gap-2 whitespace-nowrap overflow-x-scroll">
|
||||
{data.keywords.slice(0, maxKeyWords).map((keyword) => (
|
||||
<Chip key={keyword}>{keyword}</Chip>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Comments
|
||||
data={comments}
|
||||
error={commentsError}
|
||||
refetch={refetchComments}
|
||||
isLoading={isLoadingComments}
|
||||
videoId={data.video.id}
|
||||
videoUploader={data.video.author}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-center">
|
||||
<Related data={data.related} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Container>
|
||||
);
|
||||
};
|
@ -0,0 +1,16 @@
|
||||
import { NextPage } from "next";
|
||||
import { Suspense } from "react";
|
||||
|
||||
import { Watch } from "./Watch";
|
||||
|
||||
const Page: NextPage = () => {
|
||||
return (
|
||||
<>
|
||||
<Suspense>
|
||||
<Watch />
|
||||
</Suspense>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Page;
|
@ -0,0 +1,18 @@
|
||||
import { Suspense } from "react";
|
||||
|
||||
import { Nav } from "@/components/Nav";
|
||||
import { NavClient } from "@/components/Nav/NavClient";
|
||||
|
||||
import { Component } from "@/typings/component";
|
||||
|
||||
export const Elements: Component = ({ children }) => {
|
||||
return (
|
||||
<>
|
||||
<Suspense fallback={<Nav pathname="" />}>
|
||||
<NavClient />
|
||||
</Suspense>
|
||||
|
||||
{children}
|
||||
</>
|
||||
);
|
||||
};
|
@ -0,0 +1,3 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
@ -0,0 +1,29 @@
|
||||
import type { Metadata } from "next";
|
||||
|
||||
import "./globals.css";
|
||||
|
||||
import { Elements } from "./elements";
|
||||
import { Providers } from "./providers";
|
||||
|
||||
import { Component } from "@/typings/component";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "MaterialTube client",
|
||||
description:
|
||||
"MaterialTube is a beautiful and elegant web client for Invidious servers, built using Next.js and NextUI.",
|
||||
applicationName: "MaterialTube"
|
||||
};
|
||||
|
||||
const RootLayout: Component = ({ children }) => {
|
||||
return (
|
||||
<html lang="en" className="dark">
|
||||
<body>
|
||||
<Providers>
|
||||
<Elements>{children}</Elements>
|
||||
</Providers>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
};
|
||||
|
||||
export default RootLayout;
|
@ -0,0 +1,119 @@
|
||||
import { FC, useCallback, useEffect } from "react";
|
||||
|
||||
import { Listbox, ListboxItem, ListboxSection } from "@nextui-org/listbox";
|
||||
|
||||
import useContextMenuStore from "@/hooks/useContextMenuStore";
|
||||
|
||||
import { Component } from "@/typings/component";
|
||||
import {
|
||||
ContextMenuAction as ContextMenuActionProps,
|
||||
ContextMenuItemType
|
||||
} from "@/typings/contextMenu";
|
||||
|
||||
const ContextMenuActionComponent: FC<{
|
||||
item: ContextMenuActionProps;
|
||||
hideContextMenu: () => void;
|
||||
}> = ({ item, hideContextMenu }) => {
|
||||
return (
|
||||
<ListboxItem
|
||||
onClick={() => {
|
||||
if (item.onClick) {
|
||||
item.onClick();
|
||||
hideContextMenu();
|
||||
}
|
||||
}}
|
||||
description={item.description}
|
||||
startContent={item.icon}
|
||||
showDivider={item.showDivider}
|
||||
key={item.key}
|
||||
href={item.href}
|
||||
>
|
||||
{item.title}
|
||||
</ListboxItem>
|
||||
);
|
||||
};
|
||||
|
||||
const Menu: FC = () => {
|
||||
const shouldShow = useContextMenuStore((state) => state.show);
|
||||
const menu = useContextMenuStore((state) => state.items);
|
||||
const hide = useContextMenuStore((state) => state.hide);
|
||||
|
||||
const location = useContextMenuStore((state) => state.location);
|
||||
|
||||
const hideIfShown = useCallback(() => {
|
||||
if (shouldShow) hide();
|
||||
}, [hide, shouldShow]);
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener("click", hideIfShown);
|
||||
window.addEventListener("scroll", hideIfShown);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("click", hideIfShown);
|
||||
window.removeEventListener("scroll", hideIfShown);
|
||||
};
|
||||
}, [hideIfShown]);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
top: location.y,
|
||||
left: location.x,
|
||||
display: shouldShow ? "block" : "none"
|
||||
}}
|
||||
className="bg-background border-small max-w-xs rounded-small border-default-200 absolute z-10"
|
||||
>
|
||||
<Listbox aria-label="Context Menu" items={menu}>
|
||||
{(item) => {
|
||||
switch (item.type) {
|
||||
case ContextMenuItemType.Action:
|
||||
return (
|
||||
<ContextMenuActionComponent
|
||||
item={item}
|
||||
hideContextMenu={hide}
|
||||
key={item.key}
|
||||
/>
|
||||
);
|
||||
|
||||
case ContextMenuItemType.Category:
|
||||
const category = item;
|
||||
return (
|
||||
<ListboxSection
|
||||
title={category.title}
|
||||
key={category.key}
|
||||
showDivider={category.showDivider}
|
||||
>
|
||||
{category.items.map((item) => (
|
||||
<ListboxItem
|
||||
onClick={() => {
|
||||
if (item.onClick) {
|
||||
item.onClick();
|
||||
hide();
|
||||
}
|
||||
}}
|
||||
description={item.description}
|
||||
startContent={item.icon}
|
||||
showDivider={item.showDivider}
|
||||
key={item.key}
|
||||
href={item.href}
|
||||
>
|
||||
{item.title}
|
||||
</ListboxItem>
|
||||
))}
|
||||
</ListboxSection>
|
||||
);
|
||||
}
|
||||
}}
|
||||
</Listbox>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const ContextMenuProvider: Component = ({ children }) => {
|
||||
return (
|
||||
<>
|
||||
{children}
|
||||
<Menu />
|
||||
</>
|
||||
);
|
||||
};
|
@ -0,0 +1,24 @@
|
||||
"use client";
|
||||
|
||||
import { NextUIProvider } from "@nextui-org/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
|
||||
|
||||
import { ContextMenuProvider } from "./ContextMenuProvider";
|
||||
|
||||
import { Component } from "@/typings/component";
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } }
|
||||
});
|
||||
|
||||
export const Providers: Component = ({ children }) => {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ReactQueryDevtools initialIsOpen={false} />
|
||||
<NextUIProvider>
|
||||
<ContextMenuProvider>{children}</ContextMenuProvider>
|
||||
</NextUIProvider>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
};
|
@ -0,0 +1,28 @@
|
||||
import { Comments } from "@/client/typings/comment";
|
||||
import { SearchResults } from "@/client/typings/search";
|
||||
import { SearchOptions } from "@/client/typings/search/options";
|
||||
import { Suggestions } from "@/client/typings/search/suggestions";
|
||||
import { Video } from "@/client/typings/video";
|
||||
import { Watchable } from "@/client/typings/watchable";
|
||||
|
||||
export interface ConnectedAdapter {
|
||||
getTrending(region: string): Promise<Video[]>;
|
||||
|
||||
getSearchSuggestions(query: string): Promise<Suggestions>;
|
||||
getSearch(query: string, options?: SearchOptions): Promise<SearchResults>;
|
||||
|
||||
getWatchable(videoId: string): Promise<Watchable>;
|
||||
|
||||
getComments(videoId: string, repliesToken?: string): Promise<Comments>;
|
||||
}
|
||||
|
||||
export default interface Adapter {
|
||||
apiType: ApiType;
|
||||
|
||||
connect(url: string): ConnectedAdapter;
|
||||
}
|
||||
|
||||
export enum ApiType {
|
||||
Piped,
|
||||
Invidious
|
||||
}
|
@ -0,0 +1,159 @@
|
||||
import path from "path";
|
||||
|
||||
import ky from "ky";
|
||||
|
||||
import Adapter, { ApiType } from "@/client/adapters";
|
||||
|
||||
import Transformer from "./transformer";
|
||||
import Comments, { CommentsModel } from "./typings/comments";
|
||||
import Search, { SearchModel } from "./typings/search";
|
||||
import Suggestions, { SuggestionsModel } from "./typings/search/suggestions";
|
||||
import Stream, { StreamModel } from "./typings/stream";
|
||||
import Video, { VideoModel } from "./typings/video";
|
||||
|
||||
const apiPath = (...paths: string[]): string =>
|
||||
path.join("api", "v1", ...paths);
|
||||
|
||||
export type TrendingVideoType = "music" | "gaming" | "news" | "movies";
|
||||
|
||||
const getTrending = async (
|
||||
baseUrl: string,
|
||||
region?: string,
|
||||
type?: TrendingVideoType
|
||||
): Promise<Video[]> => {
|
||||
const url = new URL(apiPath("trending"), baseUrl);
|
||||
|
||||
const searchParams = new URLSearchParams();
|
||||
|
||||
if (region !== undefined) searchParams.append("region", region);
|
||||
|
||||
if (type !== undefined) searchParams.append("type", type);
|
||||
|
||||
const response = await ky.get(url, {
|
||||
searchParams
|
||||
});
|
||||
|
||||
const json = await response.json();
|
||||
|
||||
const data = VideoModel.array().parse(json);
|
||||
|
||||
return data;
|
||||
};
|
||||
|
||||
const getSearchSuggestions = async (
|
||||
baseUrl: string,
|
||||
query: string
|
||||
): Promise<Suggestions> => {
|
||||
const url = new URL(apiPath("search", "suggestions"), baseUrl);
|
||||
|
||||
const response = await ky.get(url, {
|
||||
searchParams: { q: query }
|
||||
});
|
||||
|
||||
const json = await response.json();
|
||||
|
||||
const data = SuggestionsModel.parse(json);
|
||||
|
||||
return data;
|
||||
};
|
||||
|
||||
export interface SearchOptions {
|
||||
page?: number;
|
||||
sort_by?: "relevance" | "rating" | "upload_date" | "view_count";
|
||||
date?: "hour" | "today" | "week" | "month" | "year";
|
||||
duration?: "short" | "long" | "medium";
|
||||
type?: "video" | "playlist" | "channel" | "movie" | "show" | "all";
|
||||
region?: string;
|
||||
}
|
||||
|
||||
const getSearch = async (
|
||||
baseUrl: string,
|
||||
query: string,
|
||||
options?: SearchOptions
|
||||
): Promise<Search> => {
|
||||
const url = new URL(apiPath("search"), baseUrl);
|
||||
|
||||
const response = await ky.get(url, {
|
||||
searchParams: { ...options, q: query }
|
||||
});
|
||||
|
||||
const json = await response.json();
|
||||
|
||||
const data = SearchModel.parse(json);
|
||||
|
||||
return data;
|
||||
};
|
||||
|
||||
const getVideo = async (baseUrl: string, videoId: string): Promise<Stream> => {
|
||||
const url = new URL(apiPath("videos", videoId), baseUrl);
|
||||
|
||||
const response = await ky.get(url);
|
||||
|
||||
const json = await response.json();
|
||||
|
||||
const data = StreamModel.parse(json);
|
||||
|
||||
return data;
|
||||
};
|
||||
|
||||
const getComments = async (
|
||||
baseUrl: string,
|
||||
videoId: string,
|
||||
continuation?: string
|
||||
): Promise<Comments> => {
|
||||
const url = new URL(apiPath("comments", videoId), baseUrl);
|
||||
|
||||
const searchParams = new URLSearchParams();
|
||||
|
||||
searchParams.append("source", "youtube");
|
||||
|
||||
if (continuation) searchParams.append("continuation", continuation);
|
||||
|
||||
const response = await ky.get(url, {
|
||||
searchParams
|
||||
});
|
||||
|
||||
const json = await response.json();
|
||||
|
||||
const data = CommentsModel.parse(json);
|
||||
|
||||
return data;
|
||||
};
|
||||
|
||||
const adapter: Adapter = {
|
||||
apiType: ApiType.Invidious,
|
||||
|
||||
connect(url) {
|
||||
return {
|
||||
async getTrending(region) {
|
||||
return getTrending(url, region).then(Transformer.videos);
|
||||
},
|
||||
|
||||
async getSearchSuggestions(query) {
|
||||
return getSearchSuggestions(url, query).then(Transformer.suggestions);
|
||||
},
|
||||
async getSearch(query, options) {
|
||||
const page = options?.pageParam ? parseInt(options.pageParam) : 1;
|
||||
|
||||
const items = await getSearch(url, query, {
|
||||
page: page,
|
||||
type: options?.type
|
||||
}).then(Transformer.search);
|
||||
|
||||
return { items: items, nextCursor: (page + 1).toString() };
|
||||
},
|
||||
|
||||
async getWatchable(videoId) {
|
||||
return getVideo(url, videoId).then(Transformer.stream);
|
||||
},
|
||||
|
||||
async getComments(videoId, repliesToken) {
|
||||
return getComments(url, videoId, repliesToken).then(
|
||||
Transformer.comments
|
||||
);
|
||||
}
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
export default adapter;
|
@ -0,0 +1,206 @@
|
||||
import { Comments } from "@/client/typings/comment";
|
||||
import {
|
||||
ChannelItem,
|
||||
Item,
|
||||
PlaylistItem,
|
||||
VideoItem
|
||||
} from "@/client/typings/item";
|
||||
import { Suggestions } from "@/client/typings/search/suggestions";
|
||||
import { Stream, StreamType } from "@/client/typings/stream";
|
||||
import { Video } from "@/client/typings/video";
|
||||
import { Watchable } from "@/client/typings/watchable";
|
||||
import { parseSubscriberCount } from "@/utils/parseSubscriberCount";
|
||||
|
||||
import InvidiousComments from "./typings/comments";
|
||||
import InvidiousSearch from "./typings/search";
|
||||
import InvidiousSuggestions from "./typings/search/suggestions";
|
||||
import InvidiousStream, {
|
||||
RecommendedVideo as InvidiousRecommendedVideo
|
||||
} from "./typings/stream";
|
||||
import InvidiousThumbnail from "./typings/thumbnail";
|
||||
import InvidiousVideo from "./typings/video";
|
||||
|
||||
export default class Transformer {
|
||||
private static findBestThumbnail(
|
||||
thumbnails: InvidiousThumbnail[]
|
||||
): string | null {
|
||||
const thumbnail = thumbnails.find(
|
||||
(thumbnail) =>
|
||||
thumbnail.quality == "maxresdefault" ||
|
||||
thumbnail.quality == "default" ||
|
||||
thumbnail.quality == "medium" ||
|
||||
thumbnail.quality == "middle"
|
||||
);
|
||||
|
||||
return thumbnail?.url ?? null;
|
||||
}
|
||||
|
||||
private static recommendedVideo(data: InvidiousRecommendedVideo): VideoItem {
|
||||
const thumbnail = Transformer.findBestThumbnail(data.videoThumbnails);
|
||||
|
||||
if (thumbnail === null)
|
||||
throw new Error(
|
||||
`Invidious: Missing thumbnail for video with id ${data.videoId}`
|
||||
);
|
||||
|
||||
return {
|
||||
type: "video",
|
||||
author: { id: data.authorId, name: data.author },
|
||||
duration: data.lengthSeconds * 1000,
|
||||
live: data.liveNow,
|
||||
id: data.videoId,
|
||||
title: data.title,
|
||||
thumbnail: thumbnail,
|
||||
views: data.viewCount
|
||||
};
|
||||
}
|
||||
|
||||
public static video(data: InvidiousVideo): Video {
|
||||
const thumbnail = Transformer.findBestThumbnail(data.videoThumbnails);
|
||||
|
||||
if (thumbnail === null)
|
||||
throw new Error(
|
||||
`Invidious: Missing thumbnail for video with id ${data.videoId}`
|
||||
);
|
||||
|
||||
return {
|
||||
author: { id: data.authorId, name: data.author },
|
||||
duration: data.lengthSeconds * 1000,
|
||||
description: data.description,
|
||||
live: data.liveNow,
|
||||
id: data.videoId,
|
||||
title: data.title,
|
||||
thumbnail: thumbnail,
|
||||
uploaded: new Date(data.published * 1000 ?? 0),
|
||||
views: data.viewCount
|
||||
};
|
||||
}
|
||||
|
||||
public static videos(data: InvidiousVideo[]): Video[] {
|
||||
return data.map(Transformer.video);
|
||||
}
|
||||
|
||||
public static suggestions(data: InvidiousSuggestions): Suggestions {
|
||||
return data.suggestions;
|
||||
}
|
||||
|
||||
public static search(data: InvidiousSearch): Item[] {
|
||||
return data.map((result) => {
|
||||
switch (result.type) {
|
||||
case "video":
|
||||
const video: VideoItem = {
|
||||
...Transformer.video(result),
|
||||
type: "video"
|
||||
};
|
||||
|
||||
return video;
|
||||
|
||||
case "channel":
|
||||
const channel: ChannelItem = {
|
||||
type: "channel",
|
||||
name: result.author,
|
||||
id: result.authorId,
|
||||
thumbnail: result.authorThumbnails[0].url,
|
||||
subscribers: result.subCount,
|
||||
videos: result.videoCount,
|
||||
description: result.description
|
||||
};
|
||||
|
||||
return channel;
|
||||
|
||||
case "playlist":
|
||||
const playlist: PlaylistItem = {
|
||||
type: "playlist",
|
||||
title: result.title,
|
||||
author: {
|
||||
name: result.author,
|
||||
id: result.authorId
|
||||
},
|
||||
id: result.playlistId,
|
||||
numberOfVideos: result.videoCount,
|
||||
thumbnail: result.playlistThumbnail,
|
||||
videos: result.videos.map((video) => {
|
||||
const thumbnail = Transformer.findBestThumbnail(
|
||||
video.videoThumbnails
|
||||
);
|
||||
if (thumbnail === null)
|
||||
throw new Error(
|
||||
`Invidious: Missing thumbnail for video with id ${video.videoId}`
|
||||
);
|
||||
|
||||
return {
|
||||
title: video.title,
|
||||
id: video.videoId,
|
||||
duration: video.lengthSeconds * 1000,
|
||||
thumbnail: thumbnail
|
||||
};
|
||||
})
|
||||
};
|
||||
|
||||
return playlist;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public static stream(data: InvidiousStream): Watchable {
|
||||
const thumbnail = Transformer.findBestThumbnail(data.videoThumbnails);
|
||||
|
||||
if (thumbnail === null)
|
||||
throw new Error(
|
||||
`Invidious: Missing thumbnail for video with id ${data.videoId}`
|
||||
);
|
||||
|
||||
const streams: Stream[] = [];
|
||||
|
||||
streams.push({ type: StreamType.Dash, url: data.dashUrl });
|
||||
|
||||
return {
|
||||
category: data.genre,
|
||||
dislikes: data.dislikeCount,
|
||||
likes: data.likeCount,
|
||||
keywords: data.keywords,
|
||||
related: data.recommendedVideos.map(Transformer.recommendedVideo),
|
||||
streams,
|
||||
video: {
|
||||
author: {
|
||||
id: data.authorId,
|
||||
name: data.author,
|
||||
avatar: data.authorThumbnails[0].url,
|
||||
subscribers: parseSubscriberCount(data.subCountText)
|
||||
},
|
||||
description: data.description,
|
||||
duration: data.lengthSeconds * 1000,
|
||||
id: data.videoId,
|
||||
live: data.liveNow,
|
||||
thumbnail: thumbnail,
|
||||
title: data.title,
|
||||
uploaded: new Date(data.published * 1000),
|
||||
views: data.viewCount
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
public static comments(comments: InvidiousComments): Comments {
|
||||
return {
|
||||
enabled: true,
|
||||
count: comments.commentCount,
|
||||
data: comments.comments.map((comment) => ({
|
||||
id: comment.commentId,
|
||||
message: comment.content,
|
||||
likes: comment.likeCount,
|
||||
edited: comment.isEdited,
|
||||
written: new Date(comment.published * 1000),
|
||||
author: {
|
||||
name: comment.author,
|
||||
id: comment.authorId,
|
||||
handle: comment.authorUrl,
|
||||
avatar: comment.authorThumbnails[0].url
|
||||
},
|
||||
videoUploaderLiked: !!comment.creatorHeart,
|
||||
videoUploaderReplied: false,
|
||||
pinned: comment.isPinned,
|
||||
repliesToken: comment.replies?.continuation
|
||||
}))
|
||||
};
|
||||
}
|
||||
}
|
@ -0,0 +1,44 @@
|
||||
import z from "zod";
|
||||
|
||||
import { AuthorThumbnailModel } from "./thumbnail";
|
||||
|
||||
export const CommentModel = z.object({
|
||||
author: z.string(),
|
||||
authorThumbnails: AuthorThumbnailModel.array(),
|
||||
authorId: z.string(),
|
||||
authorUrl: z.string(),
|
||||
|
||||
isEdited: z.boolean(),
|
||||
isPinned: z.boolean(),
|
||||
|
||||
content: z.string(),
|
||||
contentHtml: z.string(),
|
||||
published: z.number(),
|
||||
publishedText: z.string(),
|
||||
likeCount: z.number(),
|
||||
commentId: z.string(),
|
||||
authorIsChannelOwner: z.boolean(),
|
||||
creatorHeart: z
|
||||
.object({
|
||||
creatorThumbnail: z.string(),
|
||||
creatorName: z.string()
|
||||
})
|
||||
.optional(),
|
||||
replies: z
|
||||
.object({
|
||||
replyCount: z.number(),
|
||||
continuation: z.string()
|
||||
})
|
||||
.optional()
|
||||
});
|
||||
|
||||
export const CommentsModel = z.object({
|
||||
commentCount: z.number().optional(),
|
||||
videoId: z.string(),
|
||||
comments: CommentModel.array(),
|
||||
continuation: z.string().optional()
|
||||
});
|
||||
|
||||
type Comments = z.infer<typeof CommentsModel>;
|
||||
|
||||
export default Comments;
|
@ -0,0 +1,51 @@
|
||||
import z from "zod";
|
||||
|
||||
import { AuthorThumbnailModel, ThumbnailModel } from "../thumbnail";
|
||||
import { VideoModel } from "../video";
|
||||
|
||||
export const VideoResultModel = z
|
||||
.object({
|
||||
type: z.literal("video")
|
||||
})
|
||||
.and(VideoModel);
|
||||
|
||||
export const ChannelResultModel = z.object({
|
||||
type: z.literal("channel"),
|
||||
author: z.string(),
|
||||
authorId: z.string(),
|
||||
authorUrl: z.string(),
|
||||
authorThumbnails: AuthorThumbnailModel.array(),
|
||||
autoGenerated: z.boolean(),
|
||||
subCount: z.number(),
|
||||
videoCount: z.number(),
|
||||
description: z.string(),
|
||||
descriptionHtml: z.string()
|
||||
});
|
||||
|
||||
export const PlaylistResultModel = z.object({
|
||||
type: z.literal("playlist"),
|
||||
title: z.string(),
|
||||
playlistId: z.string(),
|
||||
playlistThumbnail: z.string().url(),
|
||||
author: z.string(),
|
||||
authorId: z.string(),
|
||||
authorUrl: z.string(),
|
||||
authorVerified: z.boolean(),
|
||||
videoCount: z.number(),
|
||||
videos: z
|
||||
.object({
|
||||
title: z.string(),
|
||||
videoId: z.string(),
|
||||
lengthSeconds: z.number(),
|
||||
videoThumbnails: ThumbnailModel.array()
|
||||
})
|
||||
.array()
|
||||
});
|
||||
|
||||
export const SearchModel = z
|
||||
.union([PlaylistResultModel, VideoResultModel, ChannelResultModel])
|
||||
.array();
|
||||
|
||||
type Search = z.infer<typeof SearchModel>;
|
||||
|
||||
export default Search;
|
@ -0,0 +1,10 @@
|
||||
import z from "zod";
|
||||
|
||||
export const SuggestionsModel = z.object({
|
||||
query: z.string(),
|
||||
suggestions: z.string().array()
|
||||
});
|
||||
|
||||
type Suggestions = z.infer<typeof SuggestionsModel>;
|
||||
|
||||
export default Suggestions;
|
@ -0,0 +1,15 @@
|
||||
import z from "zod";
|
||||
|
||||
export const StoryboardModel = z.object({
|
||||
url: z.string(),
|
||||
templateUrl: z.string().url(),
|
||||
width: z.number(),
|
||||
height: z.number(),
|
||||
count: z.number(),
|
||||
interval: z.number(),
|
||||
storyboardWidth: z.number(),
|
||||
storyboardHeight: z.number(),
|
||||
storyboardCount: z.number()
|
||||
});
|
||||
|
||||
export type Storyboard = z.infer<typeof StoryboardModel>;
|
@ -0,0 +1,104 @@
|
||||
import z from "zod";
|
||||
|
||||
import { StoryboardModel } from "./storyboard";
|
||||
import { AuthorThumbnailModel, ThumbnailModel } from "./thumbnail";
|
||||
|
||||
export const AdaptiveFormatModel = z.object({
|
||||
index: z.string(),
|
||||
bitrate: z.string(),
|
||||
init: z.string(),
|
||||
url: z.string().url(),
|
||||
itag: z.string(),
|
||||
type: z.string(),
|
||||
clen: z.string(),
|
||||
lmt: z.string(),
|
||||
projectionType: z.number().or(z.string()),
|
||||
container: z.string().optional(),
|
||||
encoding: z.string().optional(),
|
||||
qualityLabel: z.string().optional(),
|
||||
resolution: z.string().optional(),
|
||||
audioQuality: z.string().optional(),
|
||||
audioSampleRate: z.number().optional(),
|
||||
audioChannels: z.number().optional()
|
||||
});
|
||||
|
||||
export const FormatStreamModel = z.object({
|
||||
url: z.string().url(),
|
||||
itag: z.string(),
|
||||
type: z.string(),
|
||||
quality: z.string(),
|
||||
fps: z.number(),
|
||||
container: z.string(),
|
||||
encoding: z.string(),
|
||||
resolution: z.string(),
|
||||
qualityLabel: z.string(),
|
||||
size: z.string()
|
||||
});
|
||||
|
||||
export const CaptionModel = z.object({
|
||||
label: z.string(),
|
||||
language_code: z.string(),
|
||||
url: z.string()
|
||||
});
|
||||
|
||||
export const RecommendedVideoModel = z.object({
|
||||
title: z.string(),
|
||||
videoId: z.string(),
|
||||
videoThumbnails: ThumbnailModel.array(),
|
||||
|
||||
lengthSeconds: z.number(),
|
||||
viewCount: z.number(),
|
||||
|
||||
author: z.string(),
|
||||
authorId: z.string(),
|
||||
authorUrl: z.string(),
|
||||
|
||||
liveNow: z.boolean().optional().default(false),
|
||||
paid: z.boolean().optional().default(false),
|
||||
premium: z.boolean().optional().default(false)
|
||||
});
|
||||
|
||||
export type RecommendedVideo = z.infer<typeof RecommendedVideoModel>;
|
||||
|
||||
export const StreamModel = z.object({
|
||||
type: z.string(),
|
||||
title: z.string(),
|
||||
videoId: z.string(),
|
||||
videoThumbnails: ThumbnailModel.array(),
|
||||
storyboards: StoryboardModel.array(),
|
||||
description: z.string(),
|
||||
descriptionHtml: z.string(),
|
||||
published: z.number(),
|
||||
publishedText: z.string(),
|
||||
keywords: z.string().array(),
|
||||
viewCount: z.number(),
|
||||
likeCount: z.number(),
|
||||
dislikeCount: z.number(),
|
||||
paid: z.boolean().optional().default(false),
|
||||
premium: z.boolean().optional().default(false),
|
||||
isFamilyFriendly: z.boolean(),
|
||||
allowedRegions: z.string().array(),
|
||||
genre: z.string(),
|
||||
genreUrl: z.string(),
|
||||
author: z.string(),
|
||||
authorId: z.string(),
|
||||
authorUrl: z.string(),
|
||||
authorVerified: z.boolean(),
|
||||
authorThumbnails: AuthorThumbnailModel.array(),
|
||||
subCountText: z.string(),
|
||||
lengthSeconds: z.number(),
|
||||
allowRatings: z.boolean(),
|
||||
rating: z.number(),
|
||||
isListed: z.boolean(),
|
||||
liveNow: z.boolean().optional().default(false),
|
||||
isUpcoming: z.boolean(),
|
||||
dashUrl: z.string().url(),
|
||||
adaptiveFormats: AdaptiveFormatModel.array(),
|
||||
formatStreams: FormatStreamModel.array(),
|
||||
captions: CaptionModel.array(),
|
||||
recommendedVideos: RecommendedVideoModel.array()
|
||||
});
|
||||
|
||||
type Stream = z.infer<typeof StreamModel>;
|
||||
|
||||
export default Stream;
|
@ -0,0 +1,30 @@
|
||||
import z from "zod";
|
||||
|
||||
const qualityTypes = [
|
||||
"maxres",
|
||||
"maxresdefault",
|
||||
"sddefault",
|
||||
"high",
|
||||
"medium",
|
||||
"default",
|
||||
"start",
|
||||
"middle",
|
||||
"end"
|
||||
] as const;
|
||||
|
||||
export const AuthorThumbnailModel = z.object({
|
||||
url: z.string(),
|
||||
width: z.number(),
|
||||
height: z.number()
|
||||
});
|
||||
|
||||
export const ThumbnailModel = z.object({
|
||||
url: z.string().url(),
|
||||
width: z.number(),
|
||||
height: z.number(),
|
||||
quality: z.enum(qualityTypes)
|
||||
});
|
||||
|
||||
type Thumbnail = z.infer<typeof ThumbnailModel>;
|
||||
|
||||
export default Thumbnail;
|
@ -0,0 +1,29 @@
|
||||
import z from "zod";
|
||||
|
||||
import { ThumbnailModel } from "./thumbnail";
|
||||
|
||||
export const VideoModel = z.object({
|
||||
title: z.string(),
|
||||
videoId: z.string(),
|
||||
videoThumbnails: ThumbnailModel.array(),
|
||||
|
||||
lengthSeconds: z.number(),
|
||||
viewCount: z.number(),
|
||||
|
||||
author: z.string(),
|
||||
authorId: z.string(),
|
||||
authorUrl: z.string(),
|
||||
|
||||
published: z.number(),
|
||||
publishedText: z.string().optional(),
|
||||
description: z.string(),
|
||||
descriptionHtml: z.string(),
|
||||
|
||||
liveNow: z.boolean().optional().default(false),
|
||||
paid: z.boolean().optional().default(false),
|
||||
premium: z.boolean().optional().default(false)
|
||||
});
|
||||
|
||||
type Video = z.infer<typeof VideoModel>;
|
||||
|
||||
export default Video;
|
@ -0,0 +1,185 @@
|
||||
import path from "path";
|
||||
|
||||
import ky from "ky";
|
||||
import z from "zod";
|
||||
|
||||
import Adapter, { ApiType } from "@/client/adapters";
|
||||
import { Suggestions } from "@/client/typings/search/suggestions";
|
||||
|
||||
import Transformer from "./transformer";
|
||||
import Comments, { CommentsModel } from "./typings/comments";
|
||||
import Search, { SearchModel } from "./typings/search";
|
||||
import Stream, { StreamModel } from "./typings/stream";
|
||||
import Video, { VideoModel } from "./typings/video";
|
||||
|
||||
const getTrending = async (
|
||||
apiBaseUrl: string,
|
||||
region = "US"
|
||||
): Promise<Video[]> => {
|
||||
const url = new URL("/trending", apiBaseUrl);
|
||||
|
||||
const response = await ky.get(url, {
|
||||
searchParams: { region: region.toUpperCase() }
|
||||
});
|
||||
|
||||
const json = await response.json();
|
||||
|
||||
const data = VideoModel.array().parse(json);
|
||||
|
||||
return data;
|
||||
};
|
||||
|
||||
const getSearchSuggestions = async (
|
||||
apiBaseUrl: string,
|
||||
query: string
|
||||
): Promise<Suggestions> => {
|
||||
const url = new URL("suggestions", apiBaseUrl);
|
||||
|
||||
const response = await ky.get(url, {
|
||||
searchParams: { query: query }
|
||||
});
|
||||
|
||||
const json = await response.json();
|
||||
|
||||
const data = z.string().array().parse(json);
|
||||
|
||||
return data;
|
||||
};
|
||||
|
||||
export type FilterType =
|
||||
| "all"
|
||||
| "videos"
|
||||
| "channels"
|
||||
| "playlists"
|
||||
| "music_videos"
|
||||
| "music_songs"
|
||||
| "music_albums"
|
||||
| "music_playlists"
|
||||
| "music_artists";
|
||||
|
||||
export interface SearchOptions {
|
||||
filter?: FilterType;
|
||||
nextpage?: string;
|
||||
}
|
||||
|
||||
const getSearch = async (
|
||||
apiBaseUrl: string,
|
||||
query: string,
|
||||
options?: SearchOptions
|
||||
): Promise<Search> => {
|
||||
let url: URL;
|
||||
|
||||
const searchParams = new URLSearchParams();
|
||||
|
||||
searchParams.append("q", query);
|
||||
|
||||
if (options?.nextpage) {
|
||||
url = new URL(path.join("nextpage", "search"), apiBaseUrl);
|
||||
searchParams.append("nextpage", options.nextpage);
|
||||
} else url = new URL("search", apiBaseUrl);
|
||||
|
||||
if (options?.filter) searchParams.append("filter", options.filter);
|
||||
|
||||
const response = await ky.get(url, {
|
||||
searchParams
|
||||
});
|
||||
|
||||
const json = await response.json();
|
||||
|
||||
const data = SearchModel.parse(json);
|
||||
|
||||
return data;
|
||||
};
|
||||
|
||||
const getStream = async (
|
||||
apiBaseUrl: string,
|
||||
videoId: string
|
||||
): Promise<Stream> => {
|
||||
const url = new URL(path.join("streams", videoId), apiBaseUrl);
|
||||
|
||||
const response = await ky.get(url);
|
||||
|
||||
const json = await response.json();
|
||||
|
||||
const data = StreamModel.parse(json);
|
||||
|
||||
return data;
|
||||
};
|
||||
|
||||
const getComments = async (
|
||||
apiBaseUrl: string,
|
||||
videoId: string,
|
||||
nextpage?: string
|
||||
): Promise<Comments> => {
|
||||
const searchParams = new URLSearchParams();
|
||||
|
||||
let url;
|
||||
if (nextpage) {
|
||||
url = new URL(path.join("nextpage", "comments", videoId), apiBaseUrl);
|
||||
searchParams.append("nextpage", nextpage);
|
||||
} else url = new URL(path.join("comments", videoId), apiBaseUrl);
|
||||
|
||||
const response = await ky.get(url, { searchParams });
|
||||
|
||||
const json = await response.json();
|
||||
|
||||
const data = CommentsModel.parse(json);
|
||||
|
||||
return data;
|
||||
};
|
||||
|
||||
const adapter: Adapter = {
|
||||
apiType: ApiType.Piped,
|
||||
|
||||
connect(url) {
|
||||
return {
|
||||
async getTrending(region) {
|
||||
return getTrending(url, region).then(Transformer.videos);
|
||||
},
|
||||
|
||||
async getSearchSuggestions(query) {
|
||||
return getSearchSuggestions(url, query);
|
||||
},
|
||||
async getSearch(query, options) {
|
||||
let filter: FilterType;
|
||||
|
||||
switch (options?.type) {
|
||||
default:
|
||||
filter = "all";
|
||||
break;
|
||||
|
||||
case "channel":
|
||||
filter = "channels";
|
||||
break;
|
||||
|
||||
case "playlist":
|
||||
filter = "playlists";
|
||||
break;
|
||||
|
||||
case "video":
|
||||
filter = "videos";
|
||||
break;
|
||||
}
|
||||
|
||||
return getSearch(url, query, {
|
||||
filter: filter,
|
||||
nextpage: options?.pageParam
|
||||
}).then(Transformer.search);
|
||||
},
|
||||
|
||||
async getWatchable(videoId) {
|
||||
return getStream(url, videoId).then((data) =>
|
||||
Transformer.stream(data, videoId)
|
||||
);
|
||||
},
|
||||
|
||||
async getComments(videoId, repliesToken?: string) {
|
||||
return getComments(url, videoId, repliesToken).then(
|
||||
Transformer.comments
|
||||
);
|
||||
}
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
export default adapter;
|
@ -0,0 +1,165 @@
|
||||
import { Comments } from "@/client/typings/comment";
|
||||
import {
|
||||
ChannelItem,
|
||||
Item,
|
||||
PlaylistItem,
|
||||
VideoItem
|
||||
} from "@/client/typings/item";
|
||||
import { SearchResults } from "@/client/typings/search";
|
||||
import { Stream, StreamType } from "@/client/typings/stream";
|
||||
import { Video } from "@/client/typings/video";
|
||||
import { Watchable } from "@/client/typings/watchable";
|
||||
import {
|
||||
parseChannelIdFromUrl,
|
||||
parseVideoIdFromUrl
|
||||
} from "@/utils/parseIdFromUrl";
|
||||
import { parseRelativeTime } from "@/utils/parseRelativeTime";
|
||||
|
||||
import PipedComments from "./typings/comments";
|
||||
import PipedItem from "./typings/item";
|
||||
import PipedSearch from "./typings/search";
|
||||
import PipedStream from "./typings/stream";
|
||||
import PipedVideo from "./typings/video";
|
||||
|
||||
export default class Transformer {
|
||||
private static item(data: PipedItem): Item {
|
||||
switch (data.type) {
|
||||
case "stream":
|
||||
const video: VideoItem = {
|
||||
...Transformer.video(data),
|
||||
type: "video"
|
||||
};
|
||||
|
||||
return video;
|
||||
|
||||
case "channel":
|
||||
const id = parseChannelIdFromUrl(data.url);
|
||||
|
||||
if (id === null) throw new Error("Piped: Missing channelId");
|
||||
|
||||
const channel: ChannelItem = {
|
||||
type: "channel",
|
||||
name: data.name,
|
||||
id: id,
|
||||
thumbnail: data.thumbnail,
|
||||
subscribers: data.subscribers,
|
||||
videos: data.videos,
|
||||
description: data.description ?? ""
|
||||
};
|
||||
|
||||
return channel;
|
||||
|
||||
case "playlist":
|
||||
const channelId = data.uploaderUrl
|
||||
? parseChannelIdFromUrl(data.uploaderUrl)
|
||||
: null;
|
||||
|
||||
const playlist: PlaylistItem = {
|
||||
type: "playlist",
|
||||
title: data.name,
|
||||
author: {
|
||||
name: data.uploaderName,
|
||||
id: channelId ?? undefined
|
||||
},
|
||||
thumbnail: data.thumbnail,
|
||||
id: data.url,
|
||||
numberOfVideos: data.videos
|
||||
};
|
||||
|
||||
return playlist;
|
||||
}
|
||||
}
|
||||
|
||||
public static video(data: PipedVideo): Video {
|
||||
const videoId = parseVideoIdFromUrl(data.url);
|
||||
|
||||
if (videoId === null) throw new Error("Piped: Missing video id");
|
||||
|
||||
const channelId = parseChannelIdFromUrl(data.uploaderUrl) ?? undefined;
|
||||
|
||||
return {
|
||||
duration: data.duration * 1000,
|
||||
views: data.views,
|
||||
id: videoId,
|
||||
uploaded: new Date(data.uploaded),
|
||||
thumbnail: data.thumbnail,
|
||||
title: data.title,
|
||||
live: false,
|
||||
author: {
|
||||
id: channelId,
|
||||
name: data.uploaderName,
|
||||
avatar: data.uploaderAvatar
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
public static videos(data: PipedVideo[]): Video[] {
|
||||
return data.map(Transformer.video);
|
||||
}
|
||||
|
||||
public static search(data: PipedSearch): SearchResults {
|
||||
const items = data.items.map(Transformer.item);
|
||||
|
||||
return { items, nextCursor: data.nextpage };
|
||||
}
|
||||
|
||||
public static stream(data: PipedStream, videoId: string): Watchable {
|
||||
const streams: Stream[] = [];
|
||||
|
||||
if (data.dash) streams.push({ type: StreamType.Dash, url: data.dash });
|
||||
if (data.hls) streams.push({ type: StreamType.Hls, url: data.hls });
|
||||
|
||||
return {
|
||||
category: data.category,
|
||||
keywords: data.tags,
|
||||
dislikes: data.dislikes,
|
||||
likes: data.likes,
|
||||
related: data.relatedStreams.map(Transformer.item),
|
||||
streams,
|
||||
video: {
|
||||
author: {
|
||||
id: parseChannelIdFromUrl(data.uploaderUrl) ?? undefined,
|
||||
name: data.uploader,
|
||||
avatar: data.uploaderAvatar,
|
||||
subscribers: data.uploaderSubscriberCount
|
||||
},
|
||||
description: data.description,
|
||||
duration: data.duration * 1000,
|
||||
id: videoId,
|
||||
live: data.livestream,
|
||||
thumbnail: data.thumbnailUrl,
|
||||
title: data.title,
|
||||
uploaded: data.uploadDate,
|
||||
views: data.views
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
public static comments(data: PipedComments): Comments {
|
||||
return {
|
||||
enabled: !data.disabled,
|
||||
count: data.commentCount,
|
||||
data: data.comments.map((comment) => ({
|
||||
id: comment.commentId,
|
||||
message: comment.commentText,
|
||||
likes: comment.likeCount,
|
||||
edited: false,
|
||||
|
||||
written: parseRelativeTime(comment.commentedTime).toJSDate(),
|
||||
|
||||
author: {
|
||||
name: comment.author,
|
||||
id: parseChannelIdFromUrl(comment.commentorUrl) ?? undefined,
|
||||
avatar: comment.thumbnail,
|
||||
verified: comment.verified
|
||||
},
|
||||
|
||||
pinned: comment.pinned,
|
||||
videoUploaderLiked: comment.hearted,
|
||||
videoUploaderReplied: comment.creatorReplied,
|
||||
|
||||
repliesToken: comment.repliesPage ?? undefined
|
||||
}))
|
||||
};
|
||||
}
|
||||
}
|
@ -0,0 +1,45 @@
|
||||
import z from "zod";
|
||||
|
||||
export const CommentModel = z.object({
|
||||
author: z.string().describe("The name of the author of the comment"),
|
||||
commentId: z.string().describe("The comment ID"),
|
||||
commentText: z.string().describe("The text of the comment"),
|
||||
commentedTime: z.string().describe("The time the comment was made"),
|
||||
commentorUrl: z.string().describe("The URL of the channel of the comment"),
|
||||
hearted: z.boolean().describe("Whether or not the comment has been hearted"),
|
||||
likeCount: z.number().describe("The number of likes the comment has"),
|
||||
pinned: z.boolean().describe("Whether or not the comment is pinned"),
|
||||
thumbnail: z.string().url().describe("The thumbnail of the comment"),
|
||||
verified: z
|
||||
.boolean()
|
||||
.describe("Whether or not the author of the comment is verified"),
|
||||
replyCount: z
|
||||
.number()
|
||||
.transform((number) => (number < 0 ? 0 : number))
|
||||
.optional()
|
||||
.describe("The amount of replies this comment has"),
|
||||
repliesPage: z
|
||||
.string()
|
||||
.nullable()
|
||||
.describe("The token needed to fetch the replies"),
|
||||
creatorReplied: z
|
||||
.boolean()
|
||||
.describe("Whether the creator has replied to the comment")
|
||||
});
|
||||
|
||||
export const CommentsModel = z.object({
|
||||
comments: CommentModel.array(), // A list of comments
|
||||
commentCount: z
|
||||
.number()
|
||||
.transform((number) => (number < 0 ? 0 : number))
|
||||
.optional(),
|
||||
disabled: z.boolean(), // Whether or not the comments are disabled
|
||||
nextpage: z
|
||||
.string()
|
||||
.nullable()
|
||||
.describe("A JSON encoded page, which is used for the nextpage endpoint.")
|
||||
});
|
||||
|
||||
type Comments = z.infer<typeof CommentsModel>;
|
||||
|
||||
export default Comments;
|
@ -0,0 +1,42 @@
|
||||
import z from "zod";
|
||||
|
||||
import { VideoModel } from "./video";
|
||||
|
||||
export const VideoItemModel = z
|
||||
.object({
|
||||
type: z.literal("stream")
|
||||
})
|
||||
.and(VideoModel);
|
||||
|
||||
export const ChannelItemModel = z.object({
|
||||
type: z.literal("channel"),
|
||||
url: z.string(),
|
||||
name: z.string(),
|
||||
thumbnail: z.string().url(),
|
||||
description: z.string().nullable(),
|
||||
subscribers: z.number(),
|
||||
videos: z.number(),
|
||||
verified: z.boolean()
|
||||
});
|
||||
|
||||
export const PlaylistItemModel = z.object({
|
||||
type: z.literal("playlist"),
|
||||
url: z.string(),
|
||||
name: z.string(),
|
||||
thumbnail: z.string().url(),
|
||||
uploaderName: z.string(),
|
||||
uploaderUrl: z.string().nullable(),
|
||||
uploaderVerified: z.boolean(),
|
||||
playlistType: z.string(),
|
||||
videos: z.number()
|
||||
});
|
||||
|
||||
export const ItemModel = z.union([
|
||||
VideoItemModel,
|
||||
ChannelItemModel,
|
||||
PlaylistItemModel
|
||||
]);
|
||||
|
||||
type Item = z.infer<typeof ItemModel>;
|
||||
|
||||
export default Item;
|
@ -0,0 +1,14 @@
|
||||
import z from "zod";
|
||||
|
||||
import { ItemModel } from "../item";
|
||||
|
||||
export const SearchModel = z.object({
|
||||
items: ItemModel.array(),
|
||||
nextpage: z.string(),
|
||||
suggestion: z.string().nullable(),
|
||||
corrected: z.boolean()
|
||||
});
|
||||
|
||||
type Search = z.infer<typeof SearchModel>;
|
||||
|
||||
export default Search;
|
@ -0,0 +1,101 @@
|
||||
import z from "zod";
|
||||
|
||||
import { ItemModel } from "./item";
|
||||
|
||||
export const AudioStreamModel = z.object({
|
||||
url: z.string().url(),
|
||||
format: z.string(),
|
||||
quality: z.string(),
|
||||
mimeType: z.string(),
|
||||
codec: z.string().nullable(),
|
||||
audioTrackId: z.string().nullable(),
|
||||
audioTrackName: z.string().nullable(),
|
||||
audioTrackType: z.string().nullable(),
|
||||
audioTrackLocale: z.string().nullable(),
|
||||
videoOnly: z.boolean(),
|
||||
itag: z.number(),
|
||||
bitrate: z.number(),
|
||||
initStart: z.number(),
|
||||
initEnd: z.number(),
|
||||
indexStart: z.number(),
|
||||
indexEnd: z.number(),
|
||||
width: z.number(),
|
||||
height: z.number(),
|
||||
fps: z.number(),
|
||||
contentLength: z.number()
|
||||
});
|
||||
|
||||
export const VideoStreamModel = z.object({
|
||||
url: z.string(),
|
||||
format: z.string(),
|
||||
quality: z.string(),
|
||||
mimeType: z.string(),
|
||||
codec: z.string().nullable(),
|
||||
audioTrackId: z.null(),
|
||||
audioTrackName: z.null(),
|
||||
audioTrackType: z.null(),
|
||||
audioTrackLocale: z.null(),
|
||||
videoOnly: z.boolean(),
|
||||
itag: z.number(),
|
||||
bitrate: z.number(),
|
||||
initStart: z.number(),
|
||||
initEnd: z.number(),
|
||||
indexStart: z.number(),
|
||||
indexEnd: z.number(),
|
||||
width: z.number(),
|
||||
height: z.number(),
|
||||
fps: z.number(),
|
||||
contentLength: z.number()
|
||||
});
|
||||
|
||||
export const ChapterModel = z.object({
|
||||
title: z.string(),
|
||||
image: z.string(),
|
||||
start: z.number()
|
||||
});
|
||||
|
||||
export const PreviewFrameModel = z.object({
|
||||
urls: z.array(z.string()),
|
||||
frameWidth: z.number(),
|
||||
frameHeight: z.number(),
|
||||
totalCount: z.number(),
|
||||
durationPerFrame: z.number(),
|
||||
framesPerPageX: z.number(),
|
||||
framesPerPageY: z.number()
|
||||
});
|
||||
|
||||
export const StreamModel = z.object({
|
||||
title: z.string(),
|
||||
description: z.string(),
|
||||
uploadDate: z.coerce.date(),
|
||||
uploader: z.string(),
|
||||
uploaderUrl: z.string(),
|
||||
uploaderAvatar: z.string().url(),
|
||||
thumbnailUrl: z.string().url(),
|
||||
hls: z.string().url(),
|
||||
dash: z.string().url().nullable(),
|
||||
lbryId: z.string().nullable(),
|
||||
category: z.string(),
|
||||
license: z.string(),
|
||||
visibility: z.string(),
|
||||
tags: z.array(z.string()),
|
||||
metaInfo: z.array(z.unknown()),
|
||||
uploaderVerified: z.boolean(),
|
||||
duration: z.number(),
|
||||
views: z.number(),
|
||||
likes: z.number(),
|
||||
dislikes: z.number(),
|
||||
uploaderSubscriberCount: z.number(),
|
||||
audioStreams: AudioStreamModel.array(),
|
||||
videoStreams: VideoStreamModel.array(),
|
||||
relatedStreams: ItemModel.array(),
|
||||
subtitles: z.array(z.unknown()),
|
||||
livestream: z.boolean(),
|
||||
proxyUrl: z.string().url(),
|
||||
chapters: ChapterModel.array(),
|
||||
previewFrames: PreviewFrameModel.array()
|
||||
});
|
||||
|
||||
type Stream = z.infer<typeof StreamModel>;
|
||||
|
||||
export default Stream;
|
@ -0,0 +1,19 @@
|
||||
import z from "zod";
|
||||
|
||||
export const VideoModel = z.object({
|
||||
duration: z.number(), // The duration of the video in seconds
|
||||
thumbnail: z.string().url(), // The thumbnail of the video
|
||||
title: z.string(), // The title of the video
|
||||
uploaded: z.number(),
|
||||
uploadedDate: z.string().nullable(), // The date the video was uploaded
|
||||
uploaderName: z.string(),
|
||||
uploaderAvatar: z.string().url(), // The avatar of the channel of the video
|
||||
uploaderUrl: z.string(), // The URL of the channel of the video
|
||||
uploaderVerified: z.boolean(), // Whether or not the channel of the video is verified
|
||||
url: z.string(), // The URL of the video
|
||||
views: z.number() // The number of views the video has
|
||||
});
|
||||
|
||||
type Video = z.infer<typeof VideoModel>;
|
||||
|
||||
export default Video;
|
@ -0,0 +1,94 @@
|
||||
import Adapter, { ApiType, ConnectedAdapter } from "./adapters";
|
||||
import InvidiousAdapter from "./adapters/invidious";
|
||||
import PipedAdapter from "./adapters/piped";
|
||||
import { Comments } from "./typings/comment";
|
||||
import { SearchResults } from "./typings/search";
|
||||
import { SearchOptions } from "./typings/search/options";
|
||||
import { Suggestions } from "./typings/search/suggestions";
|
||||
import { Video } from "./typings/video";
|
||||
import { Watchable } from "./typings/watchable";
|
||||
|
||||
export interface RemoteApi {
|
||||
type: ApiType;
|
||||
baseUrl: string;
|
||||
score: number;
|
||||
}
|
||||
|
||||
export default class Client {
|
||||
private apis: RemoteApi[];
|
||||
private adapters: Adapter[] = [InvidiousAdapter, PipedAdapter];
|
||||
|
||||
constructor(
|
||||
apis: {
|
||||
type: ApiType;
|
||||
baseUrl: string;
|
||||
}[]
|
||||
) {
|
||||
this.apis = apis.map((api) => ({ ...api, score: 0 }));
|
||||
}
|
||||
|
||||
private findAdapterForApiType(apiType: ApiType): Adapter {
|
||||
const adapter = this.adapters.find((adapter) => adapter.apiType == apiType);
|
||||
|
||||
if (adapter === undefined)
|
||||
throw new Error(`Could not find an adapter with api type ${apiType}`);
|
||||
|
||||
return adapter;
|
||||
}
|
||||
|
||||
private getBestApi(): RemoteApi {
|
||||
const randomIndex = Math.floor(Math.random() * this.apis.length);
|
||||
|
||||
return this.apis[randomIndex];
|
||||
}
|
||||
|
||||
private getBestAdapter(): ConnectedAdapter {
|
||||
const api = this.getBestApi();
|
||||
|
||||
const adapter = this.findAdapterForApiType(api.type);
|
||||
|
||||
return adapter.connect(api.baseUrl);
|
||||
}
|
||||
|
||||
public async getTrending(region: string): Promise<Video[]> {
|
||||
const adapter = this.getBestAdapter();
|
||||
|
||||
return await adapter.getTrending(region);
|
||||
}
|
||||
|
||||
public async getSearchSuggestions(query: string): Promise<Suggestions> {
|
||||
const adapter = this.getBestAdapter();
|
||||
|
||||
return await adapter.getSearchSuggestions(query);
|
||||
}
|
||||
|
||||
public async getSearch(
|
||||
query: string,
|
||||
options?: SearchOptions
|
||||
): Promise<SearchResults> {
|
||||
const adapter = this.getBestAdapter();
|
||||
|
||||
const pageParam =
|
||||
options?.pageParam?.length === 0 ? undefined : options?.pageParam;
|
||||
|
||||
return await adapter.getSearch(query, {
|
||||
pageParam: pageParam,
|
||||
type: options?.type ?? "all"
|
||||
});
|
||||
}
|
||||
|
||||
public async getWatchable(videoId: string): Promise<Watchable> {
|
||||
const adapter = this.getBestAdapter();
|
||||
|
||||
return await adapter.getWatchable(videoId);
|
||||
}
|
||||
|
||||
public async getComments(
|
||||
videoId: string,
|
||||
repliesToken?: string
|
||||
): Promise<Comments> {
|
||||
const adapter = this.getBestAdapter();
|
||||
|
||||
return await adapter.getComments(videoId, repliesToken);
|
||||
}
|
||||
}
|
@ -0,0 +1,8 @@
|
||||
export interface Author {
|
||||
name: string;
|
||||
id?: string;
|
||||
handle?: string;
|
||||
avatar?: string;
|
||||
subscribers?: number;
|
||||
verified?: boolean;
|
||||
}
|
@ -0,0 +1,24 @@
|
||||
import { Author } from "./author";
|
||||
|
||||
export interface Comment {
|
||||
id: string;
|
||||
message: string;
|
||||
likes: number;
|
||||
edited: boolean;
|
||||
|
||||
written: Date;
|
||||
|
||||
author: Author;
|
||||
|
||||
pinned: boolean;
|
||||
videoUploaderLiked: boolean;
|
||||
videoUploaderReplied: boolean;
|
||||
|
||||
repliesToken?: string;
|
||||
}
|
||||
|
||||
export interface Comments {
|
||||
enabled: boolean;
|
||||
count?: number;
|
||||
data: Comment[];
|
||||
}
|
@ -0,0 +1,31 @@
|
||||
import { Author } from "./author";
|
||||
import { Video } from "./video";
|
||||
|
||||
export type VideoItem = Video & { type: "video" };
|
||||
|
||||
export interface ChannelItem {
|
||||
type: "channel";
|
||||
name: string;
|
||||
id: string;
|
||||
thumbnail: string;
|
||||
subscribers: number;
|
||||
videos: number;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface PlaylistItem {
|
||||
type: "playlist";
|
||||
title: string;
|
||||
id: string;
|
||||
author: Author;
|
||||
numberOfVideos: number;
|
||||
thumbnail: string;
|
||||
videos?: {
|
||||
title: string;
|
||||
id: string;
|
||||
duration: number;
|
||||
thumbnail: string;
|
||||
}[];
|
||||
}
|
||||
|
||||
export type Item = VideoItem | ChannelItem | PlaylistItem;
|
@ -0,0 +1,6 @@
|
||||
import { Item } from "../item";
|
||||
|
||||
export interface SearchResults {
|
||||
items: Item[];
|
||||
nextCursor: string;
|
||||
}
|
@ -0,0 +1,12 @@
|
||||
import z from "zod";
|
||||
|
||||
export interface SearchOptions {
|
||||
pageParam?: string;
|
||||
type?: SearchType;
|
||||
}
|
||||
|
||||
export const searchTypes = ["video", "playlist", "channel", "all"] as const;
|
||||
|
||||
export const SearchTypeModel = z.enum(searchTypes);
|
||||
|
||||
export type SearchType = z.infer<typeof SearchTypeModel>;
|
@ -0,0 +1 @@
|
||||
export type Suggestions = string[];
|
@ -0,0 +1,31 @@
|
||||
export enum StreamType {
|
||||
Dash,
|
||||
Hls,
|
||||
Standard
|
||||
}
|
||||
|
||||
export interface BaseStream {
|
||||
type: StreamType;
|
||||
}
|
||||
|
||||
export interface DashStream extends BaseStream {
|
||||
type: StreamType.Dash;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface HlsStream extends BaseStream {
|
||||
type: StreamType.Hls;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface StandardStream extends BaseStream {
|
||||
type: StreamType.Standard;
|
||||
video: VideoStream[];
|
||||
audio: AudioStream[];
|
||||
}
|
||||
|
||||
export interface VideoStream {}
|
||||
|
||||
export interface AudioStream {}
|
||||
|
||||
export type Stream = DashStream | HlsStream | StandardStream;
|
@ -0,0 +1,16 @@
|
||||
import { Author } from "./author";
|
||||
|
||||
export interface Video {
|
||||
title: string;
|
||||
id: string;
|
||||
author: Author;
|
||||
thumbnail: string;
|
||||
description?: string;
|
||||
/*
|
||||
Duration in milliseconds.
|
||||
*/
|
||||
duration: number;
|
||||
views: number;
|
||||
uploaded?: Date;
|
||||
live: boolean;
|
||||
}
|
@ -0,0 +1,13 @@
|
||||
import { Item } from "./item";
|
||||
import { Stream } from "./stream";
|
||||
import { Video } from "./video";
|
||||
|
||||
export interface Watchable {
|
||||
video: Video;
|
||||
streams: Stream[];
|
||||
keywords: string[];
|
||||
likes: number;
|
||||
dislikes: number;
|
||||
category: string;
|
||||
related: Item[];
|
||||
}
|
@ -0,0 +1,45 @@
|
||||
import NextLink from "next/link";
|
||||
import { FC } from "react";
|
||||
import { FiCheckCircle as VerifiedIcon } from "react-icons/fi";
|
||||
|
||||
import { Avatar } from "@nextui-org/avatar";
|
||||
import { Link } from "@nextui-org/link";
|
||||
|
||||
import { Author as AuthorProps } from "@/client/typings/author";
|
||||
import formatBigNumber from "@/utils/formatBigNumber";
|
||||
import { channelUrl } from "@/utils/urls";
|
||||
|
||||
export const Author: FC<{ data: AuthorProps }> = ({ data }) => {
|
||||
const url = data.id ? channelUrl(data.id) : "#";
|
||||
|
||||
return (
|
||||
<div className="flex flex-row gap-4 items-center">
|
||||
{data.avatar && (
|
||||
<Link as={NextLink} href={url}>
|
||||
<Avatar
|
||||
isBordered
|
||||
name={data.name}
|
||||
showFallback
|
||||
size="lg"
|
||||
src={data.avatar}
|
||||
alt={data.name}
|
||||
/>
|
||||
</Link>
|
||||
)}
|
||||
<div className="flex flex-col">
|
||||
<Link as={NextLink} href={url}>
|
||||
<div className="flex flex-row gap-1 items-center">
|
||||
<p className="text-lg text-default-600">{data.name}</p>
|
||||
<VerifiedIcon className="text-success" />
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
{data.subscribers && (
|
||||
<p className="text-default-400 tracking-tight">
|
||||
{formatBigNumber(data.subscribers)} subscribers
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
@ -1,71 +0,0 @@
|
||||
import Link from "next/link";
|
||||
|
||||
import { FC } from "react";
|
||||
|
||||
import Avatar from "@mui/material/Avatar";
|
||||
import Box from "@mui/material/Box";
|
||||
import Paper from "@mui/material/Paper";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
|
||||
import { abbreviateNumber, formatNumber } from "@src/utils/";
|
||||
|
||||
import { ChannelResult } from "@interfaces/api/search";
|
||||
|
||||
const Channel: FC<{ channel: ChannelResult }> = ({ channel }) => {
|
||||
const theme = useTheme();
|
||||
|
||||
const thumbnail = channel.authorThumbnails.find(
|
||||
(thumbnail) => thumbnail.height == 512
|
||||
)?.url as string;
|
||||
|
||||
return (
|
||||
<Link
|
||||
passHref
|
||||
href={{ pathname: "/channel", query: { c: channel.authorId } }}
|
||||
>
|
||||
<a>
|
||||
<Paper sx={{ my: 2 }}>
|
||||
<Box
|
||||
sx={{
|
||||
p: 3,
|
||||
display: { md: "flex", xs: "block" },
|
||||
alignItems: "center"
|
||||
}}
|
||||
>
|
||||
<Avatar
|
||||
sx={{
|
||||
width: 96,
|
||||
height: 96,
|
||||
mx: { md: 3, xs: "auto" },
|
||||
mb: { md: 0, xs: 2 }
|
||||
}}
|
||||
src={thumbnail}
|
||||
alt={channel.author}
|
||||
/>
|
||||
|
||||
<Box sx={{ textAlign: { md: "left", xs: "center" } }}>
|
||||
<Typography variant="h5">{channel.author}</Typography>
|
||||
<Typography
|
||||
variant="subtitle1"
|
||||
color={theme.palette.text.secondary}
|
||||
>
|
||||
{abbreviateNumber(channel.subCount)} subscribers •{" "}
|
||||
{formatNumber(channel.videoCount)} videos
|
||||
</Typography>
|
||||
|
||||
<Typography
|
||||
variant="subtitle1"
|
||||
color={theme.palette.text.secondary}
|
||||
>
|
||||
{channel.description}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
</Paper>
|
||||
</a>
|
||||
</Link>
|
||||
);
|
||||
};
|
||||
|
||||
export default Channel;
|
@ -0,0 +1,22 @@
|
||||
import { navHeight } from "./Nav";
|
||||
|
||||
import { Component } from "@/typings/component";
|
||||
|
||||
export const Container: Component<{ navbarOffset?: boolean }> = ({
|
||||
children,
|
||||
navbarOffset = true
|
||||
}) => {
|
||||
let height;
|
||||
|
||||
if (navbarOffset) height = `calc(100vh - ${navHeight}px)`;
|
||||
else height = "100vh";
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{ minHeight: height }}
|
||||
className="container mx-auto py-4 px-2 flex flex-col"
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
@ -0,0 +1,23 @@
|
||||
import useContextMenuStore from "@/hooks/useContextMenuStore";
|
||||
|
||||
import { Component } from "@/typings/component";
|
||||
import { ContextMenuItem } from "@/typings/contextMenu";
|
||||
|
||||
export const ContextMenu: Component<{ menu: ContextMenuItem[] }> = ({
|
||||
menu,
|
||||
children
|
||||
}) => {
|
||||
const showContextMenu = useContextMenuStore((state) => state.showContextMenu);
|
||||
|
||||
return (
|
||||
<div
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
|
||||
showContextMenu(e.pageX, e.pageY, menu);
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
@ -1,17 +0,0 @@
|
||||
import { FC } from "react";
|
||||
|
||||
import Box from "@mui/material/Box";
|
||||
|
||||
import Navbar from "@components/Navbar";
|
||||
|
||||
const Layout: FC = ({ children }) => (
|
||||
<>
|
||||
<Navbar />
|
||||
<Box
|
||||
sx={{ height: { sm: 64, xs: 56 }, display: "block", width: "100%" }}
|
||||
></Box>
|
||||
{children}
|
||||
</>
|
||||
);
|
||||
|
||||
export default Layout;
|
@ -1,19 +0,0 @@
|
||||
import { FC } from "react";
|
||||
|
||||
import Box from "@mui/material/Box";
|
||||
import CircularProgress from "@mui/material/CircularProgress";
|
||||
|
||||
const Loading: FC = () => (
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
mt: 5
|
||||
}}
|
||||
>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
);
|
||||
|
||||
export default Loading;
|
@ -0,0 +1,16 @@
|
||||
"use client";
|
||||
|
||||
import { FC } from "react";
|
||||
|
||||
import { CircularProgress } from "@nextui-org/progress";
|
||||
|
||||
export const LoadingPage: FC<{ text?: string }> = ({ text }) => {
|
||||
return (
|
||||
<div className="flex flex-1 justify-center items-center">
|
||||
<div className="flex flex-col gap-2 items-center">
|
||||
<CircularProgress aria-label="Loading page..." />
|
||||
{text && <p className="text-xl">{text}</p>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
@ -1,20 +0,0 @@
|
||||
import Box from "@mui/material/Box";
|
||||
import { styled } from "@mui/material/styles";
|
||||
|
||||
interface ColorBoxProps {
|
||||
color: string;
|
||||
}
|
||||
|
||||
const ColorBox = styled(Box, {
|
||||
shouldForwardProp: (prop) => prop !== "color"
|
||||
})<ColorBoxProps>(({ theme, color }) => ({
|
||||
width: 24,
|
||||
height: 24,
|
||||
backgroundColor: color,
|
||||
borderRadius: "50%",
|
||||
borderColor: theme.palette.text.primary,
|
||||
borderWidth: 2,
|
||||
borderStyle: "solid"
|
||||
}));
|
||||
|
||||
export default ColorBox;
|
@ -1,103 +0,0 @@
|
||||
import { FC } from "react";
|
||||
|
||||
import Box from "@mui/material/Box";
|
||||
import Grid from "@mui/material/Grid";
|
||||
import Modal from "@mui/material/Modal";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import {
|
||||
blue,
|
||||
green,
|
||||
amber,
|
||||
red,
|
||||
cyan,
|
||||
teal,
|
||||
deepOrange,
|
||||
indigo,
|
||||
yellow,
|
||||
lightBlue,
|
||||
orange,
|
||||
lime,
|
||||
deepPurple,
|
||||
lightGreen,
|
||||
pink,
|
||||
purple
|
||||
} from "@mui/material/colors";
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
|
||||
import ModalBox from "@components/ModalBox";
|
||||
|
||||
const colors = [
|
||||
red,
|
||||
deepOrange,
|
||||
orange,
|
||||
amber,
|
||||
yellow,
|
||||
lime,
|
||||
lightGreen,
|
||||
green,
|
||||
teal,
|
||||
cyan,
|
||||
lightBlue,
|
||||
blue,
|
||||
indigo,
|
||||
deepPurple,
|
||||
purple,
|
||||
pink
|
||||
];
|
||||
|
||||
const MaterialColorPicker: FC<{
|
||||
isOpen: boolean;
|
||||
setState: (isOpen: boolean) => void;
|
||||
selectedColor: string;
|
||||
setColor: (color: string) => void;
|
||||
}> = ({ setState, isOpen, selectedColor, setColor }) => {
|
||||
const theme = useTheme();
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={isOpen}
|
||||
onClose={() => setState(false)}
|
||||
aria-labelledby="color-picker"
|
||||
aria-describedby="Pick a material color"
|
||||
component="div"
|
||||
>
|
||||
<ModalBox>
|
||||
<Typography gutterBottom variant="h4">
|
||||
Pick a color
|
||||
</Typography>
|
||||
<Grid container spacing={1} columns={12}>
|
||||
{colors.map((color, i) => (
|
||||
<Grid item key={i}>
|
||||
{Object.values(color)
|
||||
.slice(0, 10)
|
||||
.map((shade, i) => (
|
||||
<Box
|
||||
onClick={() => setColor(shade)}
|
||||
key={i}
|
||||
sx={{
|
||||
width: 24,
|
||||
height: 24,
|
||||
backgroundColor: shade,
|
||||
borderRadius: "50%",
|
||||
border:
|
||||
shade == selectedColor
|
||||
? {
|
||||
borderColor: theme.palette.text.primary,
|
||||
borderWidth: 2,
|
||||
borderStyle: "solid"
|
||||
}
|
||||
: null,
|
||||
cursor: "pointer",
|
||||
mb: 1
|
||||
}}
|
||||
></Box>
|
||||
))}
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
</ModalBox>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default MaterialColorPicker;
|
@ -1,17 +0,0 @@
|
||||
import Box from "@mui/material/Box";
|
||||
|
||||
import styled from "@mui/system/styled";
|
||||
|
||||
const ModalBox = styled(Box)(({ theme }) => ({
|
||||
padding: "2rem",
|
||||
position: "absolute",
|
||||
left: "50%",
|
||||
top: "50%",
|
||||
transform: "translate(-50%, -50%)",
|
||||
minWidth: "20rem",
|
||||
backgroundColor: theme.palette.background.paper,
|
||||
borderRadius: 5,
|
||||
outline: "none"
|
||||
}));
|
||||
|
||||
export default ModalBox;
|
@ -0,0 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { Nav } from ".";
|
||||
|
||||
import { usePathname } from "next/navigation";
|
||||
import { FC } from "react";
|
||||
|
||||
export const NavClient: FC = () => {
|
||||
const pathname = usePathname();
|
||||
|
||||
return <Nav pathname={pathname} />;
|
||||
};
|
@ -0,0 +1,67 @@
|
||||
import NextLink from "next/link";
|
||||
import { FC } from "react";
|
||||
|
||||
import { Button } from "@nextui-org/button";
|
||||
import { Link } from "@nextui-org/link";
|
||||
import {
|
||||
Navbar,
|
||||
NavbarBrand,
|
||||
NavbarContent,
|
||||
NavbarItem
|
||||
} from "@nextui-org/navbar";
|
||||
|
||||
export const navHeight = 64;
|
||||
|
||||
export const Nav: FC<{ pathname: string }> = ({ pathname }) => {
|
||||
const navItems = [
|
||||
{
|
||||
title: "Trending",
|
||||
link: "/trending"
|
||||
},
|
||||
{
|
||||
title: "Search",
|
||||
link: "/results"
|
||||
},
|
||||
{
|
||||
title: "Subscriptions",
|
||||
link: "/subscriptions"
|
||||
},
|
||||
{
|
||||
title: "History",
|
||||
link: "/history"
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<Navbar>
|
||||
<NavbarBrand>
|
||||
{/* <AcmeLogo /> */}
|
||||
<p className="font-bold text-inherit">MaterialTube</p>
|
||||
</NavbarBrand>
|
||||
<NavbarContent className="hidden sm:flex gap-4" justify="center">
|
||||
{navItems.map((item) => {
|
||||
const isActive: boolean = pathname === item.link;
|
||||
|
||||
return (
|
||||
<NavbarItem key={item.title.toLowerCase()} isActive={isActive}>
|
||||
<Link
|
||||
as={NextLink}
|
||||
color={isActive ? "primary" : "foreground"}
|
||||
href={item.link}
|
||||
>
|
||||
{item.title}
|
||||
</Link>
|
||||
</NavbarItem>
|
||||
);
|
||||
})}
|
||||
</NavbarContent>
|
||||
<NavbarContent justify="end">
|
||||
<NavbarItem>
|
||||
<Button as={NextLink} color="primary" href="/settings" variant="flat">
|
||||
Settings
|
||||
</Button>
|
||||
</NavbarItem>
|
||||
</NavbarContent>
|
||||
</Navbar>
|
||||
);
|
||||
};
|
@ -1,68 +0,0 @@
|
||||
import packageInfo from "../../../package.json";
|
||||
|
||||
import Link from "next/link";
|
||||
|
||||
import { FC } from "react";
|
||||
|
||||
import Box from "@mui/material/Box";
|
||||
import Divider from "@mui/material/Divider";
|
||||
import Drawer from "@mui/material/Drawer";
|
||||
import List from "@mui/material/List";
|
||||
import ListItem from "@mui/material/ListItem";
|
||||
import ListItemIcon from "@mui/material/ListItemIcon";
|
||||
import ListItemText from "@mui/material/ListItemText";
|
||||
import Typography from "@mui/material/Typography";
|
||||
|
||||
import Settings from "@mui/icons-material/Settings";
|
||||
|
||||
const AppDrawer: FC<{
|
||||
drawerIsOpen: boolean;
|
||||
toggleDrawer: (
|
||||
isOpen: boolean
|
||||
) => (event: React.KeyboardEvent | React.MouseEvent) => void;
|
||||
width: number;
|
||||
pages: {
|
||||
name: string;
|
||||
icon: JSX.Element;
|
||||
link: string;
|
||||
}[];
|
||||
}> = ({ drawerIsOpen, toggleDrawer, pages, width }) => {
|
||||
return (
|
||||
<Drawer anchor="left" open={drawerIsOpen} onClose={toggleDrawer(false)}>
|
||||
<Box
|
||||
sx={{ width }}
|
||||
role="presentation"
|
||||
onClick={toggleDrawer(false)}
|
||||
onKeyDown={toggleDrawer(false)}
|
||||
>
|
||||
<Box padding={2}>
|
||||
<Typography variant="h4">
|
||||
{process.env.NEXT_PUBLIC_APP_NAME}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Divider />
|
||||
<List>
|
||||
{pages.map((page, index) => (
|
||||
<Link key={index} href={page.link} passHref>
|
||||
<ListItem button>
|
||||
<ListItemIcon>{page.icon}</ListItemIcon>
|
||||
<ListItemText primary={page.name} />
|
||||
</ListItem>
|
||||
</Link>
|
||||
))}
|
||||
<Divider />
|
||||
<Link href="/settings" passHref>
|
||||
<ListItem button>
|
||||
<ListItemIcon>
|
||||
<Settings />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary="Settings" />
|
||||
</ListItem>
|
||||
</Link>
|
||||
</List>
|
||||
</Box>
|
||||
</Drawer>
|
||||
);
|
||||
};
|
||||
|
||||
export default AppDrawer;
|
@ -1,43 +0,0 @@
|
||||
import InputBase from "@mui/material/InputBase";
|
||||
import { alpha, styled } from "@mui/material/styles";
|
||||
|
||||
const Search = styled("div")(({ theme }) => ({
|
||||
position: "relative",
|
||||
borderRadius: theme.shape.borderRadius,
|
||||
backgroundColor: alpha(theme.palette.common.white, 0.15),
|
||||
"&:hover": {
|
||||
backgroundColor: alpha(theme.palette.common.white, 0.25)
|
||||
},
|
||||
marginRight: theme.spacing(2),
|
||||
marginLeft: 0,
|
||||
width: "100%",
|
||||
[theme.breakpoints.up("sm")]: {
|
||||
marginLeft: theme.spacing(3),
|
||||
width: "auto"
|
||||
}
|
||||
}));
|
||||
|
||||
export const SearchIconWrapper = styled("div")(({ theme }) => ({
|
||||
padding: theme.spacing(0, 2),
|
||||
height: "100%",
|
||||
position: "absolute",
|
||||
pointerEvents: "none",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center"
|
||||
}));
|
||||
|
||||
export const StyledInputBase = styled(InputBase)(({ theme }) => ({
|
||||
color: "inherit",
|
||||
"& .MuiInputBase-input": {
|
||||
padding: theme.spacing(1, 1, 1, 0),
|
||||
paddingLeft: `calc(1em + ${theme.spacing(4)})`,
|
||||
transition: theme.transitions.create("width"),
|
||||
width: "100%",
|
||||
[theme.breakpoints.up("md")]: {
|
||||
width: "20ch"
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
export default Search;
|
@ -1,194 +0,0 @@
|
||||
import packageInfo from "../../../package.json";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
import { FC, useState } from "react";
|
||||
|
||||
import AppBar from "@mui/material/AppBar";
|
||||
import Box from "@mui/material/Box";
|
||||
import Button from "@mui/material/Button";
|
||||
import IconButton from "@mui/material/IconButton";
|
||||
import Toolbar from "@mui/material/Toolbar";
|
||||
import Tooltip from "@mui/material/Tooltip";
|
||||
import Typography from "@mui/material/Typography";
|
||||
|
||||
import History from "@mui/icons-material/History";
|
||||
import Menu from "@mui/icons-material/Menu";
|
||||
import PlayCircleOutline from "@mui/icons-material/PlayCircleOutline";
|
||||
import PlaylistAddCheck from "@mui/icons-material/PlaylistAddCheck";
|
||||
import SearchIcon from "@mui/icons-material/Search";
|
||||
import Settings from "@mui/icons-material/Settings";
|
||||
import Subscriptions from "@mui/icons-material/Subscriptions";
|
||||
import Whatshot from "@mui/icons-material/Whatshot";
|
||||
|
||||
import Drawer from "@components/Navbar/Drawer";
|
||||
import Search, {
|
||||
SearchIconWrapper,
|
||||
StyledInputBase
|
||||
} from "@components/Navbar/Search";
|
||||
|
||||
export const drawerWidth = 240;
|
||||
|
||||
const Navbar: FC = () => {
|
||||
const [drawerIsOpen, setDrawerState] = useState(false);
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const [search, setSearch] = useState<string | undefined>();
|
||||
|
||||
const toggleDrawer =
|
||||
(open: boolean) => (event: React.KeyboardEvent | React.MouseEvent) => {
|
||||
if (
|
||||
event.type === "keydown" &&
|
||||
((event as React.KeyboardEvent).key === "Tab" ||
|
||||
(event as React.KeyboardEvent).key === "Shift")
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
setDrawerState(open);
|
||||
};
|
||||
|
||||
const pages = [
|
||||
{ name: "Trending", icon: <Whatshot />, link: "/trending" },
|
||||
{
|
||||
name: "Subscriptions",
|
||||
icon: <Subscriptions />,
|
||||
link: "/subscriptions"
|
||||
},
|
||||
{
|
||||
name: "Watch History",
|
||||
icon: <History />,
|
||||
link: "/history"
|
||||
},
|
||||
{
|
||||
name: "Playlists",
|
||||
icon: <PlaylistAddCheck />,
|
||||
link: "/playlists"
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<Drawer
|
||||
width={drawerWidth}
|
||||
drawerIsOpen={drawerIsOpen}
|
||||
toggleDrawer={toggleDrawer}
|
||||
pages={pages}
|
||||
/>
|
||||
<Box sx={{ flexGrow: 1 }}>
|
||||
<AppBar position="fixed" enableColorOnDark>
|
||||
<Toolbar>
|
||||
<IconButton
|
||||
size="large"
|
||||
edge="start"
|
||||
color="inherit"
|
||||
aria-label="menu"
|
||||
sx={{
|
||||
mr: { md: 2, xs: 0 },
|
||||
display: { lg: "none", xs: "flex" }
|
||||
}}
|
||||
onClick={() => setDrawerState(!drawerIsOpen)}
|
||||
>
|
||||
<Menu />
|
||||
</IconButton>
|
||||
|
||||
<Link href="/" passHref>
|
||||
<a>
|
||||
<Typography
|
||||
variant="h6"
|
||||
component="div"
|
||||
sx={{
|
||||
mr: 2,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
cursor: "pointer"
|
||||
}}
|
||||
>
|
||||
<PlayCircleOutline sx={{ mr: 1 }} />
|
||||
{process.env.NEXT_PUBLIC_APP_NAME}
|
||||
</Typography>
|
||||
</a>
|
||||
</Link>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
flexGrow: 1,
|
||||
display: "flex",
|
||||
alignItems: "center"
|
||||
}}
|
||||
>
|
||||
{pages.map((page, i) => (
|
||||
<Link key={i} href={page.link} passHref>
|
||||
<Tooltip title={`Go to ${page.name}`}>
|
||||
<Button
|
||||
sx={{
|
||||
color: "white",
|
||||
display: { lg: "flex", xs: "none" },
|
||||
alignItems: "center"
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{ mr: 1, display: "flex", alignItems: "center" }}
|
||||
>
|
||||
{page.icon}
|
||||
</Box>
|
||||
{page.name}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</Link>
|
||||
))}
|
||||
|
||||
<Search>
|
||||
<SearchIconWrapper>
|
||||
<SearchIcon />
|
||||
</SearchIconWrapper>
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
|
||||
router.push({
|
||||
pathname: "/results",
|
||||
query: { search_query: search }
|
||||
});
|
||||
}}
|
||||
method="get"
|
||||
>
|
||||
<StyledInputBase
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
value={search}
|
||||
name="search_query"
|
||||
placeholder="Search…"
|
||||
inputProps={{ "aria-label": "search" }}
|
||||
/>
|
||||
</form>
|
||||
</Search>
|
||||
</Box>
|
||||
|
||||
<Link href="/settings" passHref>
|
||||
<a
|
||||
onClick={(e) => {
|
||||
if (router.pathname == "/settings") {
|
||||
e.preventDefault();
|
||||
|
||||
router.back();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<IconButton
|
||||
sx={{ display: { md: "flex", xs: "none" } }}
|
||||
size="large"
|
||||
>
|
||||
<Settings />
|
||||
</IconButton>
|
||||
</a>
|
||||
</Link>
|
||||
</Toolbar>
|
||||
</AppBar>
|
||||
</Box>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Navbar;
|
@ -1,89 +0,0 @@
|
||||
import { FC, MutableRefObject } from "react";
|
||||
|
||||
import Box from "@mui/material/Box";
|
||||
import Tooltip from "@mui/material/Tooltip";
|
||||
|
||||
import Fullscreen from "@mui/icons-material/Fullscreen";
|
||||
import Pause from "@mui/icons-material/Pause";
|
||||
import PlayArrow from "@mui/icons-material/PlayArrow";
|
||||
import Settings from "@mui/icons-material/Settings";
|
||||
import SkipNext from "@mui/icons-material/SkipNext";
|
||||
import Subtitles from "@mui/icons-material/Subtitles";
|
||||
import VolumeUp from "@mui/icons-material/VolumeUp";
|
||||
|
||||
import { VideoStatus } from "@interfaces/videoPlayer";
|
||||
|
||||
import useVideoState from "@utils/hooks/useVideoState";
|
||||
|
||||
import Time from "@components/Player/Time";
|
||||
|
||||
const iconStyles = {
|
||||
mr: 1.5,
|
||||
cursor: "pointer"
|
||||
};
|
||||
|
||||
const Actions: FC<{
|
||||
duration: number;
|
||||
videoRef: MutableRefObject<HTMLVideoElement | null>;
|
||||
}> = ({ duration, videoRef }) => {
|
||||
const togglePlaying = useVideoState((state) => state.togglePlaying);
|
||||
const playing = useVideoState((state) => state.playing);
|
||||
|
||||
const muted = useVideoState((state) => state.muted);
|
||||
const toggleMuted = useVideoState((state) => state.toggleMuted);
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
position: "absolute",
|
||||
display: "flex",
|
||||
width: "100%",
|
||||
height: 40,
|
||||
px: 1.5,
|
||||
bottom: 5,
|
||||
left: 0
|
||||
}}
|
||||
>
|
||||
<Box sx={{ flexGrow: 1, display: "flex", alignItems: "center" }}>
|
||||
<Tooltip title={playing == VideoStatus.Playing ? "Pause" : "Play"}>
|
||||
<Box
|
||||
sx={{
|
||||
...iconStyles,
|
||||
display: "flex",
|
||||
alignItems: "center"
|
||||
}}
|
||||
onClick={() => togglePlaying()}
|
||||
>
|
||||
{playing == VideoStatus.Playing ? <Pause /> : <PlayArrow />}
|
||||
</Box>
|
||||
</Tooltip>
|
||||
<Tooltip title="Play next video">
|
||||
<SkipNext sx={iconStyles} />
|
||||
</Tooltip>
|
||||
<Tooltip title={muted ? "Unmute" : "Mute"}>
|
||||
<VolumeUp onClick={() => toggleMuted()} sx={iconStyles} />
|
||||
</Tooltip>
|
||||
<Time duration={duration} videoRef={videoRef} />
|
||||
</Box>
|
||||
<Box sx={{ display: "flex", alignItems: "center" }}>
|
||||
<Tooltip title="Turn on captions">
|
||||
<Subtitles sx={iconStyles} />
|
||||
</Tooltip>
|
||||
<Tooltip title="Change quality">
|
||||
<Settings sx={iconStyles} />
|
||||
</Tooltip>
|
||||
<Tooltip title="Fullscreen">
|
||||
<Fullscreen
|
||||
sx={{
|
||||
...iconStyles,
|
||||
transition: "font-size .2s",
|
||||
"&:hover": { fontSize: "2rem" }
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default Actions;
|
@ -1,74 +0,0 @@
|
||||
import { FC, MutableRefObject, useEffect, useState } from "react";
|
||||
|
||||
import Box from "@mui/material/Box";
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
|
||||
import useVideoState from "@utils/hooks/useVideoState";
|
||||
|
||||
const ProgressBar: FC<{
|
||||
duration: number;
|
||||
videoRef: MutableRefObject<HTMLVideoElement | null>;
|
||||
}> = ({ videoRef, duration }) => {
|
||||
const theme = useTheme();
|
||||
|
||||
const [buffer, setBuffer] = useState<number>(1);
|
||||
|
||||
const height = 5;
|
||||
|
||||
const bufferColor = "rgba(200, 200, 200, 0.5)";
|
||||
const backgroundColor = "rgba(132, 132, 132, 0.5)";
|
||||
|
||||
const progress = (useVideoState((state) => state.progress) / duration) * 100;
|
||||
|
||||
useEffect(() => {
|
||||
if (!videoRef.current) return;
|
||||
|
||||
const buffered = videoRef.current.buffered;
|
||||
|
||||
if (buffered.length != 0) {
|
||||
const newBuffer =
|
||||
((buffered.end(0) - buffered.start(0)) / duration) * 100;
|
||||
|
||||
if (newBuffer != buffer) {
|
||||
setBuffer(newBuffer);
|
||||
}
|
||||
}
|
||||
}, [buffer, duration, videoRef, videoRef.current?.buffered]);
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
position: "absolute",
|
||||
cursor: "pointer",
|
||||
width: "98%",
|
||||
backgroundColor,
|
||||
height,
|
||||
left: "1%",
|
||||
bottom: 45,
|
||||
"&:hover": {}
|
||||
}}
|
||||
>
|
||||
<Box sx={{ position: "relative" }}>
|
||||
<Box
|
||||
sx={{
|
||||
backgroundColor: theme.palette.primary.main,
|
||||
position: "absolute",
|
||||
height,
|
||||
zIndex: 10
|
||||
}}
|
||||
style={{ width: `${progress}%` }}
|
||||
></Box>
|
||||
<Box
|
||||
sx={{
|
||||
backgroundColor: bufferColor,
|
||||
position: "absolute",
|
||||
height
|
||||
}}
|
||||
style={{ width: `${buffer}%` }}
|
||||
></Box>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProgressBar;
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in new issue