basic next.js app using nextui
parent
fcf9237da0
commit
a944e8cb80
@ -1,8 +1,11 @@
|
||||
**
|
||||
|
||||
!/next.config.js
|
||||
!/tsconfig.json
|
||||
!/package.json
|
||||
!/yarn.lock
|
||||
!/public
|
||||
!/src
|
||||
!.env
|
||||
!next.config.js
|
||||
!tailwind.config.js
|
||||
!postcss.config.js
|
||||
!tsconfig.json
|
||||
!package.json
|
||||
!yarn.lock
|
||||
!public
|
||||
!src
|
@ -1,11 +1,3 @@
|
||||
{
|
||||
"extends": "next/core-web-vitals",
|
||||
"rules": {
|
||||
"no-restricted-imports": [
|
||||
"error",
|
||||
{
|
||||
"patterns": ["@mui/*/*/*", "!@mui/material/test-utils/*"]
|
||||
}
|
||||
]
|
||||
}
|
||||
"extends": "next/core-web-vitals"
|
||||
}
|
||||
|
@ -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
|
||||
|
@ -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"]
|
||||
}
|
@ -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,4 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {};
|
||||
|
||||
export default nextConfig;
|
@ -1,51 +1,29 @@
|
||||
{
|
||||
"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"],
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint",
|
||||
"prettify": "prettier src --write"
|
||||
},
|
||||
"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"
|
||||
},
|
||||
"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"
|
||||
}
|
||||
"name": "materialtube",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@nextui-org/react": "^2.2.10",
|
||||
"framer-motion": "^11.0.12",
|
||||
"next": "14.1.3",
|
||||
"react": "^18",
|
||||
"react-dom": "^18"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^18",
|
||||
"@types/react-dom": "^18",
|
||||
"autoprefixer": "^10.0.1",
|
||||
"eslint": "^8",
|
||||
"eslint-config-next": "14.1.3",
|
||||
"postcss": "^8",
|
||||
"tailwindcss": "^3.3.0",
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
|
@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
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,3 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
@ -0,0 +1,22 @@
|
||||
import type { Metadata } from "next";
|
||||
import "./globals.css";
|
||||
import { Providers } from "./providers";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Create Next App",
|
||||
description: "Generated by create next app"
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html lang="en" className="dark">
|
||||
<body>
|
||||
<Providers>{children}</Providers>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
@ -0,0 +1,9 @@
|
||||
import { Button } from "@nextui-org/button";
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<>
|
||||
<Button>Click me</Button>
|
||||
</>
|
||||
);
|
||||
}
|
@ -0,0 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { NextUIProvider } from "@nextui-org/react";
|
||||
|
||||
export function Providers({ children }: { children: React.ReactNode }) {
|
||||
return <NextUIProvider>{children}</NextUIProvider>;
|
||||
}
|
@ -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;
|
@ -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;
|
@ -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;
|
@ -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;
|
@ -1,30 +0,0 @@
|
||||
import { FC, MutableRefObject } from "react";
|
||||
|
||||
import Typography from "@mui/material/Typography";
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
|
||||
import { formatTime } from "@src/utils/";
|
||||
|
||||
import useVideoState from "@utils/hooks/useVideoState";
|
||||
|
||||
const Time: FC<{
|
||||
videoRef: MutableRefObject<HTMLVideoElement | null>;
|
||||
duration: number;
|
||||
}> = ({ videoRef, duration }) => {
|
||||
const theme = useTheme();
|
||||
|
||||
const progress = useVideoState((state) => state.progress);
|
||||
|
||||
return (
|
||||
<Typography
|
||||
variant="subtitle1"
|
||||
color={theme.palette.text.secondary}
|
||||
>
|
||||
{formatTime(Math.round(progress))}
|
||||
<> / </>
|
||||
{formatTime(duration)}
|
||||
</Typography>
|
||||
);
|
||||
};
|
||||
|
||||
export default Time;
|
@ -1,212 +0,0 @@
|
||||
import { FC, MutableRefObject, useEffect, useRef, useState } from "react";
|
||||
|
||||
import Box from "@mui/material/Box";
|
||||
import { SxProps } from "@mui/material/styles";
|
||||
|
||||
import { AdaptiveFormat, Caption, FormatStream } from "@interfaces/video";
|
||||
import { PausedBy, VideoStatus } from "@interfaces/videoPlayer";
|
||||
|
||||
import useSettings from "@utils/hooks/useSettings";
|
||||
import useVideoState from "@utils/hooks/useVideoState";
|
||||
|
||||
import Actions from "@components/Player/Actions";
|
||||
import ProgressBar from "@components/Player/ProgressBar";
|
||||
|
||||
const Player: FC<{
|
||||
formats: AdaptiveFormat[];
|
||||
streams: FormatStream[];
|
||||
captions: Caption[];
|
||||
length: number;
|
||||
videoId: string;
|
||||
sx?: SxProps;
|
||||
}> = ({ formats, length: duration, sx }) => {
|
||||
const [settings] = useSettings();
|
||||
|
||||
const playing = useVideoState((state) => state.playing);
|
||||
const togglePlaying = useVideoState((state) => state.togglePlaying);
|
||||
const setPlaying = useVideoState((state) => state.setPlaying);
|
||||
|
||||
const speed = useVideoState((state) => state.speed);
|
||||
const muted = useVideoState((state) => state.muted);
|
||||
|
||||
const error = useVideoState((state) => state.error);
|
||||
const setError = useVideoState((state) => state.setError);
|
||||
|
||||
const waiting = useVideoState((state) => state.waiting);
|
||||
const setWaiting = useVideoState((state) => state.setWaiting);
|
||||
|
||||
const setProgress = useVideoState((state) => state.setProgress);
|
||||
|
||||
const pausedBy = useVideoState((state) => state.pausedBy);
|
||||
|
||||
const videoStream = formats.find(
|
||||
(format) =>
|
||||
format.qualityLabel == "2160p" ||
|
||||
format.qualityLabel == "1080p"
|
||||
)?.url;
|
||||
|
||||
const audioStream = formats.find((format) =>
|
||||
format.type.includes("audio/mp4")
|
||||
)?.url as string;
|
||||
|
||||
const videoRef = useRef<HTMLVideoElement | null>(null);
|
||||
const audioRef = useRef<HTMLAudioElement>(new Audio(audioStream));
|
||||
|
||||
useEffect(() => {
|
||||
const audio = audioRef.current;
|
||||
|
||||
audio.volume = 0.25;
|
||||
|
||||
const video = videoRef.current;
|
||||
|
||||
if (!video) return;
|
||||
|
||||
video.playbackRate = speed;
|
||||
|
||||
video.currentTime = 0;
|
||||
|
||||
const handleError = (e: ErrorEvent) => {
|
||||
setError(e.message || "An unknown error occurred");
|
||||
setPlaying(VideoStatus.Paused, PausedBy.Player);
|
||||
};
|
||||
|
||||
const handleWaiting = (e: Event) => {
|
||||
setWaiting(true);
|
||||
|
||||
if (playing == VideoStatus.Playing)
|
||||
setPlaying(VideoStatus.Paused, PausedBy.Player);
|
||||
};
|
||||
|
||||
const handleFinishedWaiting = (e: Event) => {
|
||||
setWaiting(false);
|
||||
|
||||
if (pausedBy == PausedBy.Player)
|
||||
setPlaying(VideoStatus.Playing);
|
||||
};
|
||||
|
||||
const onTimeUpdate = () => {
|
||||
setProgress(video.currentTime ?? 0);
|
||||
};
|
||||
|
||||
const handlePause = () => {
|
||||
setPlaying(VideoStatus.Paused);
|
||||
};
|
||||
|
||||
if (!videoStream) setError("Could not find video stream");
|
||||
|
||||
video.addEventListener("waiting", handleWaiting);
|
||||
video.addEventListener("canplaythrough", handleFinishedWaiting);
|
||||
video.addEventListener("error", handleError);
|
||||
video.addEventListener("pause", handlePause);
|
||||
video.addEventListener("timeupdate", onTimeUpdate);
|
||||
|
||||
audio.addEventListener("waiting", handleWaiting);
|
||||
audio.addEventListener("canplaythrough", handleFinishedWaiting);
|
||||
audio.addEventListener("pause", handlePause);
|
||||
|
||||
return () => {
|
||||
audio.srcObject = null;
|
||||
|
||||
video.removeEventListener("waiting", handleWaiting);
|
||||
video.removeEventListener(
|
||||
"canplaythrough",
|
||||
handleFinishedWaiting
|
||||
);
|
||||
video.removeEventListener("error", handleError);
|
||||
video.removeEventListener("pause", handlePause);
|
||||
video.removeEventListener("timeupdate", onTimeUpdate);
|
||||
|
||||
audio.removeEventListener("waiting", handleWaiting);
|
||||
audio.removeEventListener(
|
||||
"canplaythrough",
|
||||
handleFinishedWaiting
|
||||
);
|
||||
audio.removeEventListener("pause", handlePause);
|
||||
};
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setPlaying(
|
||||
settings.autoPlay
|
||||
? VideoStatus.Playing
|
||||
: VideoStatus.Paused
|
||||
);
|
||||
}, [setPlaying, settings.autoPlay]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!videoRef.current || !audioRef.current) return;
|
||||
|
||||
if (playing == VideoStatus.Playing && !error && !waiting) {
|
||||
videoRef.current.play();
|
||||
audioRef.current.play();
|
||||
} else {
|
||||
videoRef.current.pause();
|
||||
audioRef.current.pause();
|
||||
}
|
||||
}, [error, playing, waiting]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!videoRef.current) return;
|
||||
|
||||
videoRef.current.playbackRate = speed;
|
||||
}, [speed]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!audioRef.current) return;
|
||||
|
||||
audioRef.current.muted = muted;
|
||||
}, [muted, audioRef]);
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
...sx,
|
||||
maxWidth: "fit-content",
|
||||
position: "relative"
|
||||
}}
|
||||
>
|
||||
{error && (
|
||||
<Box
|
||||
sx={{
|
||||
position: "absolute",
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center"
|
||||
}}
|
||||
>
|
||||
{error}
|
||||
</Box>
|
||||
)}
|
||||
<video
|
||||
src={videoStream}
|
||||
ref={videoRef}
|
||||
style={{
|
||||
height: "100%",
|
||||
width: "100%"
|
||||
}}
|
||||
autoPlay={playing == VideoStatus.Playing}
|
||||
>
|
||||
Your browser does not support video playback.
|
||||
</video>
|
||||
<Box
|
||||
onClick={() => togglePlaying(PausedBy.User)}
|
||||
sx={{
|
||||
boxShadow: "0px -15px 30px 0px rgba(0,0,0,0.75) inset",
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: "100%",
|
||||
height: "100%"
|
||||
}}
|
||||
></Box>
|
||||
<ProgressBar videoRef={videoRef} duration={duration} />
|
||||
<Actions videoRef={videoRef} duration={duration} />
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default Player;
|
@ -1,25 +0,0 @@
|
||||
import { FC } from "react";
|
||||
|
||||
import Grid from "@mui/material/Grid";
|
||||
|
||||
import VideoModel from "@interfaces/video";
|
||||
|
||||
import Video from "@components/Video";
|
||||
|
||||
const VideoGrid: FC<{ videos: VideoModel[] }> = ({ videos }) => {
|
||||
return (
|
||||
<Grid
|
||||
container
|
||||
spacing={{ xs: 2, md: 3 }}
|
||||
columns={{ xs: 3, sm: 6, md: 9, lg: 12 }}
|
||||
>
|
||||
{videos.map((video) => (
|
||||
<Grid item key={video.id} xs={3}>
|
||||
<Video {...video} />
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
);
|
||||
};
|
||||
|
||||
export default VideoGrid;
|
@ -1,144 +0,0 @@
|
||||
import Link from "next/link";
|
||||
|
||||
import { FC } from "react";
|
||||
|
||||
import Avatar from "@mui/material/Avatar";
|
||||
import Box from "@mui/material/Box";
|
||||
import CircularProgress from "@mui/material/CircularProgress";
|
||||
import Grid from "@mui/material/Grid";
|
||||
import Paper from "@mui/material/Paper";
|
||||
import Tooltip from "@mui/material/Tooltip";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import { red } from "@mui/material/colors";
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
|
||||
import { abbreviateNumber } from "@src/utils/";
|
||||
|
||||
import VideoModel from "@interfaces/video";
|
||||
|
||||
import { useAuthorThumbnail } from "@utils/requests";
|
||||
|
||||
const Video: FC<{ video: VideoModel }> = ({ video }) => {
|
||||
const theme = useTheme();
|
||||
|
||||
const {
|
||||
ref,
|
||||
isLoading,
|
||||
thumbnail: authorThumbnail
|
||||
} = useAuthorThumbnail(video.author.id, 176);
|
||||
|
||||
return (
|
||||
<Paper sx={{ my: 2 }}>
|
||||
<Grid container spacing={0}>
|
||||
<Grid item md={4} sx={{ position: "relative" }}>
|
||||
{video.live && (
|
||||
<Box
|
||||
sx={{
|
||||
backgroundColor: red[600],
|
||||
position: "absolute",
|
||||
right: 5,
|
||||
top: 5,
|
||||
p: "3px",
|
||||
borderRadius: "2px",
|
||||
textTransform: "uppercase"
|
||||
}}
|
||||
>
|
||||
Live
|
||||
</Box>
|
||||
)}
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
borderRadius: "4px"
|
||||
}}
|
||||
src={video.thumbnail}
|
||||
alt="thumbnail"
|
||||
loading="lazy"
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
<Grid item md={8} sx={{ padding: 3, width: "100%" }}>
|
||||
<Link
|
||||
href={{
|
||||
pathname: "/watch",
|
||||
query: { v: video.id }
|
||||
}}
|
||||
>
|
||||
<a>
|
||||
<Tooltip title={video.title}>
|
||||
<Typography gutterBottom noWrap variant="h5">
|
||||
{video.title}
|
||||
</Typography>
|
||||
</Tooltip>
|
||||
</a>
|
||||
</Link>
|
||||
<Typography
|
||||
gutterBottom
|
||||
variant="subtitle1"
|
||||
color={theme.palette.text.secondary}
|
||||
>
|
||||
{!(video.live || video.upcoming) && (
|
||||
<>
|
||||
{abbreviateNumber(video.views)} Views • Published{" "}
|
||||
{video.published.text}
|
||||
</>
|
||||
)}
|
||||
{video.live && <>🔴 Live now</>}
|
||||
{video.upcoming && video.premiereTimestamp && (
|
||||
<>
|
||||
Premiering on{" "}
|
||||
{new Date(video.premiereTimestamp * 1000).toLocaleDateString()}{" "}
|
||||
at{" "}
|
||||
{new Date(video.premiereTimestamp * 1000).toLocaleTimeString()}
|
||||
</>
|
||||
)}
|
||||
</Typography>
|
||||
<Typography
|
||||
gutterBottom
|
||||
variant="subtitle1"
|
||||
color={theme.palette.text.secondary}
|
||||
>
|
||||
{video.description.text}
|
||||
</Typography>
|
||||
<Link
|
||||
passHref
|
||||
href={{
|
||||
pathname: "/channel",
|
||||
query: {
|
||||
c: video.author.id
|
||||
}
|
||||
}}
|
||||
>
|
||||
<a>
|
||||
<Box
|
||||
ref={ref}
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center"
|
||||
}}
|
||||
>
|
||||
{isLoading && <CircularProgress />}
|
||||
{!isLoading && (
|
||||
<Avatar src={authorThumbnail} alt={video.author.name} />
|
||||
)}
|
||||
<Typography
|
||||
sx={{
|
||||
ml: 2
|
||||
}}
|
||||
variant="subtitle1"
|
||||
color={theme.palette.text.secondary}
|
||||
>
|
||||
{video.author.name}
|
||||
</Typography>
|
||||
</Box>
|
||||
</a>
|
||||
</Link>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
|
||||
export default Video;
|
@ -1,104 +0,0 @@
|
||||
import { DateTime } from "luxon";
|
||||
|
||||
import Link from "next/link";
|
||||
|
||||
import { FC } from "react";
|
||||
|
||||
import Avatar from "@mui/material/Avatar";
|
||||
import Box from "@mui/material/Box";
|
||||
import Card from "@mui/material/Card";
|
||||
import CardActionArea from "@mui/material/CardActionArea";
|
||||
import CardContent from "@mui/material/CardContent";
|
||||
import CardMedia from "@mui/material/CardMedia";
|
||||
import CircularProgress from "@mui/material/CircularProgress";
|
||||
import Tooltip from "@mui/material/Tooltip";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
|
||||
import { abbreviateNumber, formatTime } from "@src/utils";
|
||||
|
||||
import VideoModel from "@interfaces/video";
|
||||
|
||||
import { useAuthorThumbnail } from "@utils/requests";
|
||||
|
||||
const Video: FC<VideoModel> = (video) => {
|
||||
const theme = useTheme();
|
||||
|
||||
const {
|
||||
ref,
|
||||
isLoading,
|
||||
thumbnail: authorThumbnail
|
||||
} = useAuthorThumbnail(video.author.id, 100);
|
||||
|
||||
return (
|
||||
<Card sx={{ width: "100%" }}>
|
||||
<Link href={{ pathname: "/watch", query: { v: video.id } }}>
|
||||
<a>
|
||||
<CardActionArea>
|
||||
<Box sx={{ position: "relative" }}>
|
||||
<CardMedia
|
||||
height="270"
|
||||
component="img"
|
||||
image={video.thumbnail}
|
||||
alt="video thumbnail"
|
||||
/>
|
||||
<Box
|
||||
sx={{
|
||||
p: 0.5,
|
||||
borderRadius: "3px",
|
||||
backgroundColor: "#000",
|
||||
position: "absolute",
|
||||
bottom: 10,
|
||||
right: 10
|
||||
}}
|
||||
>
|
||||
{formatTime(video.length)}
|
||||
</Box>
|
||||
</Box>
|
||||
<CardContent>
|
||||
<Tooltip title={video.title}>
|
||||
<Typography noWrap gutterBottom variant="h6" component="div">
|
||||
{video.title}
|
||||
</Typography>
|
||||
</Tooltip>
|
||||
<Link passHref href={`/channel/${video.author.id}`}>
|
||||
<a>
|
||||
<Box ref={ref} sx={{ display: "flex", alignItems: "center" }}>
|
||||
{isLoading && <CircularProgress sx={{ mr: 2 }} />}
|
||||
{!isLoading && (
|
||||
<Avatar
|
||||
sx={{ mr: 2 }}
|
||||
alt={video.author.name}
|
||||
src={authorThumbnail}
|
||||
/>
|
||||
)}
|
||||
<Typography
|
||||
color={theme.palette.text.secondary}
|
||||
variant="subtitle1"
|
||||
>
|
||||
{video.author.name}
|
||||
</Typography>
|
||||
</Box>
|
||||
</a>
|
||||
</Link>
|
||||
<Typography
|
||||
sx={{ mt: 2 }}
|
||||
color={theme.palette.text.secondary}
|
||||
variant="body2"
|
||||
>
|
||||
{!(video.live || video.upcoming) && (
|
||||
<>
|
||||
{abbreviateNumber(video.views)} Views • Published{" "}
|
||||
{video.published.text}
|
||||
</>
|
||||
)}
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</CardActionArea>
|
||||
</a>
|
||||
</Link>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default Video;
|
@ -1,4 +0,0 @@
|
||||
a {
|
||||
text-decoration: none;
|
||||
color: unset;
|
||||
}
|
@ -1,36 +0,0 @@
|
||||
import { Quality } from "@interfaces/api";
|
||||
import VideoTrending from "@interfaces/api/trending";
|
||||
|
||||
interface Channel {
|
||||
author: string;
|
||||
authorId: string;
|
||||
authorUrl: string;
|
||||
authorBanners: AuthorBanner[];
|
||||
authorThumbnails: AuthorBanner[];
|
||||
subCount: number;
|
||||
totalViews: number;
|
||||
joined: number;
|
||||
autoGenerated: boolean;
|
||||
isFamilyFriendly: boolean;
|
||||
description: string;
|
||||
descriptionHtml: string;
|
||||
allowedRegions: string[];
|
||||
latestVideos: VideoTrending[];
|
||||
relatedChannels: RelatedChannel[];
|
||||
}
|
||||
|
||||
interface AuthorBanner {
|
||||
url: string;
|
||||
width: number;
|
||||
height: number;
|
||||
quality?: Quality;
|
||||
}
|
||||
|
||||
interface RelatedChannel {
|
||||
author: string;
|
||||
authorId: string;
|
||||
authorUrl: string;
|
||||
authorThumbnails: AuthorBanner[];
|
||||
}
|
||||
|
||||
export default Channel;
|
@ -1,22 +0,0 @@
|
||||
export interface Error {
|
||||
error: string;
|
||||
}
|
||||
|
||||
export interface Thumbnail {
|
||||
url: string;
|
||||
width: number;
|
||||
height: number;
|
||||
quality?: Quality;
|
||||
}
|
||||
|
||||
export enum Quality {
|
||||
Default = "default",
|
||||
End = "end",
|
||||
High = "high",
|
||||
Maxres = "maxres",
|
||||
Maxresdefault = "maxresdefault",
|
||||
Medium = "medium",
|
||||
Middle = "middle",
|
||||
Sddefault = "sddefault",
|
||||
Start = "start"
|
||||
}
|
@ -1,71 +0,0 @@
|
||||
export interface ServerInstance {
|
||||
flag?: string;
|
||||
region?: string;
|
||||
stats?: Stats;
|
||||
cors?: boolean;
|
||||
api?: boolean;
|
||||
type: ServerInstanceType;
|
||||
uri: string;
|
||||
monitor?: Monitor;
|
||||
}
|
||||
|
||||
interface Monitor {
|
||||
monitorId: number;
|
||||
createdAt: number;
|
||||
statusClass: StatusClass;
|
||||
name: string;
|
||||
url: null;
|
||||
type: MonitorType;
|
||||
dailyRatios: Ratio[];
|
||||
"90dRatio": Ratio;
|
||||
"30dRatio": Ratio;
|
||||
}
|
||||
|
||||
interface Ratio {
|
||||
ratio: string;
|
||||
label: StatusClass;
|
||||
}
|
||||
|
||||
export enum StatusClass {
|
||||
Black = "black",
|
||||
Success = "success",
|
||||
Warning = "warning"
|
||||
}
|
||||
|
||||
export enum MonitorType {
|
||||
HTTPS = "HTTP(s)"
|
||||
}
|
||||
|
||||
export interface Stats {
|
||||
version: string;
|
||||
software: Software;
|
||||
openRegistrations: boolean;
|
||||
usage: Usage;
|
||||
metadata: Metadata;
|
||||
}
|
||||
|
||||
interface Metadata {
|
||||
updatedAt: number;
|
||||
lastChannelRefreshedAt: number;
|
||||
}
|
||||
|
||||
interface Software {
|
||||
name: string;
|
||||
version: string;
|
||||
branch: string;
|
||||
}
|
||||
|
||||
interface Usage {
|
||||
users: Users;
|
||||
}
|
||||
|
||||
interface Users {
|
||||
total: number;
|
||||
activeHalfyear: number;
|
||||
activeMonth: number;
|
||||
}
|
||||
|
||||
export enum ServerInstanceType {
|
||||
HTTPS = "https",
|
||||
Onion = "onion"
|
||||
}
|
@ -1,85 +0,0 @@
|
||||
import { Thumbnail } from "@interfaces/api";
|
||||
import VideoTrending from "@interfaces/api/trending";
|
||||
|
||||
interface Results {
|
||||
type: Type;
|
||||
}
|
||||
|
||||
export interface ChannelResult extends Results {
|
||||
type: "channel";
|
||||
author: string;
|
||||
authorId: string;
|
||||
authorUrl: string;
|
||||
authorThumbnails: Thumbnail[];
|
||||
subCount: number;
|
||||
videoCount: number;
|
||||
description: string;
|
||||
descriptionHtml: string;
|
||||
}
|
||||
|
||||
export interface VideoResult extends Results {
|
||||
type: "video";
|
||||
title: string;
|
||||
author: string;
|
||||
authorId: string;
|
||||
authorUrl: string;
|
||||
description: string;
|
||||
descriptionHtml: string;
|
||||
videoId: string;
|
||||
videoThumbnails: Thumbnail[];
|
||||
viewCount: number;
|
||||
published: number;
|
||||
publishedText: string;
|
||||
lengthSeconds: number;
|
||||
isUpcoming: boolean;
|
||||
premiereTimestamp?: number;
|
||||
liveNow: boolean;
|
||||
premium: boolean;
|
||||
}
|
||||
|
||||
export interface PlaylistResult extends Results {
|
||||
type: "playlist";
|
||||
title: string;
|
||||
playlistId: string;
|
||||
author: string;
|
||||
authorId: string;
|
||||
authorUrl: string;
|
||||
videoCount: number;
|
||||
videos: Video[];
|
||||
}
|
||||
|
||||
export interface CategoryResult extends Results {
|
||||
type: "category";
|
||||
title: string;
|
||||
contents: VideoTrending[];
|
||||
}
|
||||
|
||||
export interface Content {
|
||||
type: Type;
|
||||
title: string;
|
||||
videoId: string;
|
||||
author: string;
|
||||
authorId: string;
|
||||
authorUrl: string;
|
||||
videoThumbnails: Thumbnail[];
|
||||
description: string;
|
||||
descriptionHtml: string;
|
||||
viewCount: number;
|
||||
published: number;
|
||||
publishedText: string;
|
||||
lengthSeconds: number;
|
||||
liveNow: boolean;
|
||||
premium: boolean;
|
||||
isUpcoming: boolean;
|
||||
}
|
||||
|
||||
export type Type = "category" | "channel" | "playlist" | "video";
|
||||
|
||||
export interface Video {
|
||||
title: string;
|
||||
videoId: string;
|
||||
lengthSeconds: number;
|
||||
videoThumbnails: Thumbnail[];
|
||||
}
|
||||
|
||||
export default Results;
|
@ -1,23 +0,0 @@
|
||||
import { Thumbnail } from "@interfaces/api";
|
||||
|
||||
interface Trending {
|
||||
type: string;
|
||||
title: string;
|
||||
videoId: string;
|
||||
author: string;
|
||||
authorId: string;
|
||||
authorUrl: string;
|
||||
videoThumbnails: Thumbnail[];
|
||||
description: string;
|
||||
descriptionHtml: string;
|
||||
viewCount: number;
|
||||
published: number;
|
||||
publishedText: string;
|
||||
lengthSeconds: number;
|
||||
liveNow: boolean;
|
||||
premium: boolean;
|
||||
isUpcoming: boolean;
|
||||
premiereTimestamp?: number;
|
||||
}
|
||||
|
||||
export default Trending;
|
@ -1,60 +0,0 @@
|
||||
import { Thumbnail } from "@interfaces/api";
|
||||
import {
|
||||
AdaptiveFormat,
|
||||
Caption,
|
||||
FormatStream,
|
||||
RecommendedVideo
|
||||
} from "@interfaces/video";
|
||||
|
||||
export interface Video {
|
||||
type: string;
|
||||
title: string;
|
||||
videoId: string;
|
||||
videoThumbnails: Thumbnail[];
|
||||
storyboards: Storyboard[];
|
||||
description: string;
|
||||
descriptionHtml: string;
|
||||
published: number;
|
||||
publishedText: string;
|
||||
keywords: string[];
|
||||
viewCount: number;
|
||||
likeCount: number;
|
||||
dislikeCount: number;
|
||||
paid: boolean;
|
||||
premium: boolean;
|
||||
isFamilyFriendly: boolean;
|
||||
allowedRegions: string[];
|
||||
premiereTimestamp?: number;
|
||||
genre: string;
|
||||
genreUrl: string;
|
||||
author: string;
|
||||
authorId: string;
|
||||
authorUrl: string;
|
||||
authorThumbnails: Thumbnail[];
|
||||
subCountText: string;
|
||||
lengthSeconds: number;
|
||||
allowRatings: boolean;
|
||||
rating: number;
|
||||
isListed: boolean;
|
||||
liveNow: boolean;
|
||||
isUpcoming: boolean;
|
||||
dashUrl: string;
|
||||
adaptiveFormats: AdaptiveFormat[];
|
||||
formatStreams: FormatStream[];
|
||||
captions: Caption[];
|
||||
recommendedVideos: RecommendedVideo[];
|
||||
}
|
||||
|
||||
interface Storyboard {
|
||||
url: string;
|
||||
templateUrl: string;
|
||||
width: number;
|
||||
height: number;
|
||||
count: number;
|
||||
interval: number;
|
||||
storyboardWidth: number;
|
||||
storyboardHeight: number;
|
||||
storyboardCount: number;
|
||||
}
|
||||
|
||||
export default Video;
|
@ -1,19 +0,0 @@
|
||||
interface Settings {
|
||||
theme?: "light" | "dark";
|
||||
primaryColor: string;
|
||||
accentColor: string;
|
||||
invidiousServer: string;
|
||||
invidiousUsername?: string;
|
||||
storageType: StorageType;
|
||||
customServer?: string;
|
||||
password?: string;
|
||||
autoPlay: boolean;
|
||||
}
|
||||
|
||||
export enum StorageType {
|
||||
Local = "local",
|
||||
Invidious = "invidious",
|
||||
RemoteServer = "remoteserver"
|
||||
}
|
||||
|
||||
export default Settings;
|
@ -1,102 +0,0 @@
|
||||
import { Thumbnail } from "@interfaces/api";
|
||||
|
||||
interface Video {
|
||||
thumbnail: string;
|
||||
title: string;
|
||||
description: {
|
||||
text: string;
|
||||
html: string;
|
||||
};
|
||||
id: string;
|
||||
author: {
|
||||
name: string;
|
||||
id: string;
|
||||
url: string;
|
||||
thumbnail?: string;
|
||||
};
|
||||
views: number;
|
||||
published: {
|
||||
time: Date;
|
||||
text: string;
|
||||
};
|
||||
length: number;
|
||||
live: boolean;
|
||||
premium: boolean;
|
||||
keywords?: string[];
|
||||
likes?: number;
|
||||
dislikes?: number;
|
||||
familyFriendly?: boolean;
|
||||
genre?: {
|
||||
type: string;
|
||||
url: string;
|
||||
};
|
||||
subscriptions?: string;
|
||||
rating?: number;
|
||||
upcoming?: boolean;
|
||||
premiereTimestamp?: number;
|
||||
premiered?: Date;
|
||||
recommendedVideos?: RecommendedVideo[];
|
||||
adaptiveFormats?: AdaptiveFormat[];
|
||||
formatStreams?: FormatStream[];
|
||||
captions?: Caption[];
|
||||
}
|
||||
|
||||
export interface RecommendedVideo {
|
||||
videoId: string;
|
||||
title: string;
|
||||
videoThumbnails: Thumbnail[];
|
||||
author: string;
|
||||
authorUrl: string;
|
||||
authorId: string;
|
||||
lengthSeconds: number;
|
||||
viewCountText: string;
|
||||
viewCount: number;
|
||||
}
|
||||
|
||||
export interface Caption {
|
||||
label: string;
|
||||
language_code: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
enum ProjectionType {
|
||||
Rectangular = "RECTANGULAR"
|
||||
}
|
||||
|
||||
export interface AdaptiveFormat {
|
||||
index: string;
|
||||
bitrate: string;
|
||||
init: string;
|
||||
url: string;
|
||||
itag: string;
|
||||
type: string;
|
||||
clen: string;
|
||||
lmt: string;
|
||||
projectionType: ProjectionType;
|
||||
fps?: number;
|
||||
container?: Container;
|
||||
encoding?: string;
|
||||
resolution?: string;
|
||||
qualityLabel?: string;
|
||||
}
|
||||
|
||||
export interface FormatStream {
|
||||
url: string;
|
||||
itag: string;
|
||||
type: string;
|
||||
quality: string;
|
||||
fps: number;
|
||||
container: string;
|
||||
encoding: string;
|
||||
resolution: string;
|
||||
qualityLabel: string;
|
||||
size: string;
|
||||
}
|
||||
|
||||
enum Container {
|
||||
M4A = "m4a",
|
||||
Mp4 = "mp4",
|
||||
Webm = "webm"
|
||||
}
|
||||
|
||||
export default Video;
|
@ -1,11 +0,0 @@
|
||||
export type VideoSpeed = 0.25 | 0.5 | 1.0 | 1.25 | 1.5 | 1.75 | 2.0;
|
||||
|
||||
export enum VideoStatus {
|
||||
Playing = "playing",
|
||||
Paused = "paused"
|
||||
}
|
||||
|
||||
export enum PausedBy {
|
||||
User = "user",
|
||||
Player = "player"
|
||||
}
|
@ -1,17 +0,0 @@
|
||||
import packageInfo from "../package.json";
|
||||
|
||||
import type { DefaultSeoProps } from "next-seo";
|
||||
|
||||
const name = process.env.NEXT_PUBLIC_APP_NAME;
|
||||
|
||||
const SEO: DefaultSeoProps = {
|
||||
titleTemplate: `%s - ${name}`,
|
||||
defaultTitle: name,
|
||||
description: packageInfo.description,
|
||||
openGraph: {
|
||||
description: name,
|
||||
site_name: name
|
||||
}
|
||||
};
|
||||
|
||||
export default SEO;
|
@ -1,31 +0,0 @@
|
||||
import { NextPage } from "next";
|
||||
import { NextSeo } from "next-seo";
|
||||
|
||||
import Box from "@mui/material/Box";
|
||||
import Typography from "@mui/material/Typography";
|
||||
|
||||
import Layout from "@components/Layout";
|
||||
|
||||
const NotFound: NextPage = () => {
|
||||
return (
|
||||
<>
|
||||
<NextSeo
|
||||
title="Not Found"
|
||||
description="The page may have been moved or deleted"
|
||||
/>
|
||||
<Layout>
|
||||
<Box sx={{ mt: 5, textAlign: "center" }}>
|
||||
<Typography variant="h3">
|
||||
Page not found
|
||||
</Typography>
|
||||
<Typography variant="h4">
|
||||
The page may have been moved or
|
||||
deleted.
|
||||
</Typography>
|
||||
</Box>
|
||||
</Layout>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default NotFound;
|
@ -1,55 +0,0 @@
|
||||
import { DefaultSeo } from "next-seo";
|
||||
import type { AppProps } from "next/app";
|
||||
|
||||
import { useMemo } from "react";
|
||||
|
||||
import { QueryClient, QueryClientProvider } from "react-query";
|
||||
import { ReactQueryDevtools } from "react-query/devtools";
|
||||
|
||||
import CssBaseline from "@mui/material/CssBaseline";
|
||||
import { ThemeProvider } from "@mui/material/styles";
|
||||
import useMediaQuery from "@mui/material/useMediaQuery";
|
||||
|
||||
import "@src/globals.css";
|
||||
import SEO from "@src/next-seo.config";
|
||||
import createTheme from "@src/theme";
|
||||
|
||||
import useSettings from "@utils/hooks/useSettings";
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { refetchOnWindowFocus: false, retry: 5, retryDelay: 5000 }
|
||||
}
|
||||
});
|
||||
|
||||
const App = ({ Component, pageProps }: AppProps) => {
|
||||
const [settings] = useSettings();
|
||||
|
||||
const prefersDarkMode = useMediaQuery("(prefers-color-scheme: dark)");
|
||||
|
||||
let dark: boolean;
|
||||
|
||||
if (settings.theme) {
|
||||
if (settings.theme == "dark") dark = true;
|
||||
else dark = false;
|
||||
} else {
|
||||
dark = prefersDarkMode;
|
||||
}
|
||||
|
||||
const theme = useMemo(() => createTheme(settings, dark), [settings, dark]);
|
||||
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
{process.env.NODE_ENV != "production" && (
|
||||
<ReactQueryDevtools initialIsOpen={false} />
|
||||
)}
|
||||
<ThemeProvider theme={theme}>
|
||||
<CssBaseline />
|
||||
<DefaultSeo {...SEO} />
|
||||
<Component {...pageProps} />
|
||||
</ThemeProvider>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export default App;
|
@ -1,22 +0,0 @@
|
||||
import { Html, Head, Main, NextScript } from "next/document";
|
||||
|
||||
export default function Document() {
|
||||
return (
|
||||
<Html lang="en">
|
||||
<Head>
|
||||
<link
|
||||
href="https://fonts.googleapis.com/icon?family=Material+Icons"
|
||||
rel="stylesheet"
|
||||
></link>
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css?family=Roboto:300,400,500,700&display=swap"
|
||||
/>
|
||||
</Head>
|
||||
<body>
|
||||
<Main />
|
||||
<NextScript />
|
||||
</body>
|
||||
</Html>
|
||||
);
|
||||
}
|
@ -1,46 +0,0 @@
|
||||
import packageInfo from "../../package.json";
|
||||
|
||||
import { NextPage } from "next";
|
||||
import { NextSeo } from "next-seo";
|
||||
import Link from "next/link";
|
||||
|
||||
import Box from "@mui/material/Box";
|
||||
import Button from "@mui/material/Button";
|
||||
import Typography from "@mui/material/Typography";
|
||||
|
||||
import PlayCircleOutline from "@mui/icons-material/PlayCircleOutline";
|
||||
|
||||
import Layout from "@components/Layout";
|
||||
|
||||
const Index: NextPage = () => (
|
||||
<>
|
||||
<NextSeo title="Home" description={packageInfo.description} />
|
||||
<Layout>
|
||||
<Box
|
||||
sx={{
|
||||
textAlign: "center",
|
||||
width: { md: "50%", xs: "100%" },
|
||||
m: "auto"
|
||||
}}
|
||||
>
|
||||
<PlayCircleOutline sx={{ mt: 2, fontSize: 100 }} />
|
||||
<Typography variant="h2" sx={{ my: 1 }}>
|
||||
{process.env.NEXT_PUBLIC_APP_NAME}
|
||||
</Typography>
|
||||
<Typography variant="h4" sx={{ my: 1 }}>
|
||||
{packageInfo.description}
|
||||
</Typography>
|
||||
<Box sx={{ mt: 5, display: "flex", justifyContent: "space-evenly" }}>
|
||||
<Link passHref href={process.env.NEXT_PUBLIC_GITHUB_URL as string}>
|
||||
<Button variant="contained">Check out the docs</Button>
|
||||
</Link>
|
||||
<Link passHref href="/trending">
|
||||
<Button variant="contained">Start watching</Button>
|
||||
</Link>
|
||||
</Box>
|
||||
</Box>
|
||||
</Layout>
|
||||
</>
|
||||
);
|
||||
|
||||
export default Index;
|
@ -1,163 +0,0 @@
|
||||
import NotFound from "./404";
|
||||
|
||||
import { NextPage } from "next";
|
||||
import { NextSeo } from "next-seo";
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
import { FC, useState } from "react";
|
||||
|
||||
import { useQuery } from "react-query";
|
||||
|
||||
import axios from "axios";
|
||||
|
||||
import Box from "@mui/material/Box";
|
||||
import Button from "@mui/material/Button";
|
||||
import Container from "@mui/material/Container";
|
||||
import Divider from "@mui/material/Divider";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
|
||||
import Add from "@mui/icons-material/Add";
|
||||
|
||||
import Result, {
|
||||
CategoryResult,
|
||||
ChannelResult,
|
||||
PlaylistResult,
|
||||
VideoResult
|
||||
} from "@interfaces/api/search";
|
||||
|
||||
import { apiToVideo } from "@utils/conversions";
|
||||
import useSettings from "@utils/hooks/useSettings";
|
||||
|
||||
import Channel from "@components/Channel/Inline";
|
||||
import Layout from "@components/Layout";
|
||||
import Loading from "@components/Loading";
|
||||
import Video from "@components/Video/Inline";
|
||||
|
||||
const Results: NextPage = () => {
|
||||
const router = useRouter();
|
||||
|
||||
const [settings] = useSettings();
|
||||
|
||||
const query = router.query["search_query"];
|
||||
|
||||
const { data, isLoading } = useQuery<Result[] | undefined>(
|
||||
["searchResultsFor", query],
|
||||
() =>
|
||||
query
|
||||
? axios
|
||||
.get(`https://${settings.invidiousServer}/api/v1/search`, {
|
||||
params: {
|
||||
q: query,
|
||||
type: "all"
|
||||
}
|
||||
})
|
||||
.then((res) => res.data)
|
||||
: undefined
|
||||
);
|
||||
|
||||
const Category: FC<{ category: CategoryResult }> = ({ category }) => {
|
||||
const initialCount = 3;
|
||||
|
||||
const [count, setCount] = useState(initialCount);
|
||||
|
||||
const shownVideos = category.contents.slice(0, count);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Typography variant="h5">{category.title}</Typography>
|
||||
|
||||
{shownVideos.map((video, i) => (
|
||||
<Video video={apiToVideo(video)} key={i} />
|
||||
))}
|
||||
|
||||
{category.contents.length > initialCount && (
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
mt: 4
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="text"
|
||||
onClick={() =>
|
||||
setCount(
|
||||
count > initialCount ? initialCount : category.contents.length
|
||||
)
|
||||
}
|
||||
>
|
||||
<Add />
|
||||
Show {count > initialCount ? "less" : "more"} (
|
||||
{category.contents.length - initialCount})
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Divider sx={{ my: 4 }} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
if (!router.isReady || isLoading)
|
||||
return (
|
||||
<>
|
||||
<NextSeo title="Searching..." />
|
||||
<Layout>
|
||||
<Loading />
|
||||
</Layout>
|
||||
</>
|
||||
);
|
||||
|
||||
if (!query) return <NotFound />;
|
||||
|
||||
const channels = data?.filter((result) => result.type == "channel") as
|
||||
| ChannelResult[]
|
||||
| undefined;
|
||||
|
||||
const videos = data?.filter((result) => result.type == "video") as
|
||||
| VideoResult[]
|
||||
| undefined;
|
||||
|
||||
const categories = data?.filter((result) => result.type == "category") as
|
||||
| CategoryResult[]
|
||||
| undefined;
|
||||
|
||||
return (
|
||||
<>
|
||||
<NextSeo title={query as string} />
|
||||
<Layout>
|
||||
<Container sx={{ py: 2 }}>
|
||||
{channels && channels.length != 0 && (
|
||||
<>
|
||||
<Typography variant="h5">Channels</Typography>
|
||||
{channels.map((channel, i) => (
|
||||
<Channel key={i} channel={channel} />
|
||||
))}
|
||||
<Divider sx={{ my: 4 }} />
|
||||
</>
|
||||
)}
|
||||
{categories && categories.length != 0 && (
|
||||
<>
|
||||
{categories.map((category, i) => (
|
||||
<Category key={i} category={category} />
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
{videos && videos.length != 0 && (
|
||||
<>
|
||||
<Typography variant="h5">Videos</Typography>
|
||||
{videos.map((video, i) => (
|
||||
<Video key={i} video={apiToVideo(video)} />
|
||||
))}
|
||||
<Divider sx={{ my: 4 }} />
|
||||
</>
|
||||
)}
|
||||
</Container>
|
||||
</Layout>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Results;
|
@ -1,580 +0,0 @@
|
||||
import { NextPage } from "next";
|
||||
import { NextSeo } from "next-seo";
|
||||
|
||||
import { FC, useState } from "react";
|
||||
|
||||
import { useMutation, useQuery } from "react-query";
|
||||
|
||||
import axios from "axios";
|
||||
|
||||
import Box from "@mui/material/Box";
|
||||
import Button from "@mui/material/Button";
|
||||
import ButtonGroup from "@mui/material/ButtonGroup";
|
||||
import CircularProgress from "@mui/material/CircularProgress";
|
||||
import Container from "@mui/material/Container";
|
||||
import Divider from "@mui/material/Divider";
|
||||
import FormControl from "@mui/material/FormControl";
|
||||
import InputLabel from "@mui/material/InputLabel";
|
||||
import MenuItem from "@mui/material/MenuItem";
|
||||
import Modal from "@mui/material/Modal";
|
||||
import Select from "@mui/material/Select";
|
||||
import TextField from "@mui/material/TextField";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import { red, green } from "@mui/material/colors/";
|
||||
import { useTheme } from "@mui/material/styles/";
|
||||
|
||||
import Done from "@mui/icons-material/Done";
|
||||
import Error from "@mui/icons-material/Error";
|
||||
import Refresh from "@mui/icons-material/Refresh";
|
||||
|
||||
import {
|
||||
ServerInstance,
|
||||
ServerInstanceType,
|
||||
Stats
|
||||
} from "@interfaces/api/instances";
|
||||
import { StorageType } from "@interfaces/settings";
|
||||
|
||||
import useSettings from "@utils/hooks/useSettings";
|
||||
|
||||
import Layout from "@components/Layout";
|
||||
import MaterialColorPicker from "@components/MaterialColorPicker";
|
||||
import ColorBox from "@components/MaterialColorPicker/ColorBox";
|
||||
import ModalBox from "@components/ModalBox";
|
||||
|
||||
const InfoModal: FC<{
|
||||
modalIsOpen: boolean;
|
||||
setModalState: (isOpen: boolean) => void;
|
||||
data: Stats;
|
||||
}> = ({ modalIsOpen, setModalState, data }) => {
|
||||
const [settings] = useSettings();
|
||||
|
||||
const lastUpdated = new Date(data.metadata.updatedAt * 1000);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={modalIsOpen}
|
||||
onClose={() => setModalState(false)}
|
||||
aria-labelledby="stats-modal"
|
||||
aria-describedby="Shows server stats"
|
||||
>
|
||||
<ModalBox>
|
||||
<Typography id="modal-modal-title" variant="h4">
|
||||
Stats for {settings.invidiousServer}
|
||||
</Typography>
|
||||
<Typography
|
||||
id="modal-modal-description"
|
||||
sx={{ mt: 2 }}
|
||||
>
|
||||
Version: {data.version} <br /> <br />
|
||||
Software name: {data.software.name}{" "}
|
||||
<br />
|
||||
Software version:{" "}
|
||||
{data.software.version} <br />
|
||||
Software branch: {
|
||||
data.software.branch
|
||||
}{" "}
|
||||
<br /> <br />
|
||||
Is accepting registrations:{" "}
|
||||
{data.openRegistrations ? "Yes" : "No"}
|
||||
<br /> <br />
|
||||
Total users: {
|
||||
data.usage.users.total
|
||||
}{" "}
|
||||
<br />
|
||||
Active in the past half year:{" "}
|
||||
{data.usage.users.activeHalfyear}
|
||||
<br />
|
||||
Active in the past month:{" "}
|
||||
{
|
||||
data.usage.users.activeMonth
|
||||
} <br /> <br />
|
||||
Stats updated at:{" "}
|
||||
{lastUpdated.toLocaleDateString()} -{" "}
|
||||
{lastUpdated.toLocaleTimeString()}
|
||||
</Typography>
|
||||
</ModalBox>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
const Setting: FC<{ title: string; description?: string }> = ({
|
||||
title,
|
||||
children,
|
||||
description
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
|
||||
return (
|
||||
<Box sx={{ my: 3, display: "flex", alignItems: "center" }}>
|
||||
<Box sx={{ flexGrow: 1 }}>
|
||||
<Typography variant="h5">{title}</Typography>
|
||||
{description && (
|
||||
<Typography
|
||||
variant="subtitle1"
|
||||
color={
|
||||
theme.palette.text
|
||||
.secondary
|
||||
}
|
||||
>
|
||||
{description}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
{children}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
const Settings: NextPage = () => {
|
||||
const [settings, setSettings] = useSettings();
|
||||
|
||||
const setSetting = (key: string, value?: string): void =>
|
||||
setSettings({ ...settings, [key]: value });
|
||||
|
||||
const theme = useTheme();
|
||||
|
||||
const [primaryColorModalIsOpen, setPrimaryColorModal] = useState(false);
|
||||
const [accentColorModalIsOpen, setAccentColorModal] = useState(false);
|
||||
|
||||
const [modalIsOpen, setModalState] = useState(false);
|
||||
|
||||
const instances = useQuery<[string, ServerInstance][]>(
|
||||
"invidiousInstances",
|
||||
() =>
|
||||
axios
|
||||
.get("https://api.invidious.io/instances.json")
|
||||
.then((res) => res.data),
|
||||
{ retry: false }
|
||||
);
|
||||
|
||||
const invidiousServerResponse = useMutation<Stats, unknown, string>(
|
||||
"invidiousInstance",
|
||||
(server) =>
|
||||
axios
|
||||
.get(`https://${server}/api/v1/stats`)
|
||||
.then((res) => res.data)
|
||||
);
|
||||
|
||||
const allowsRegistrations =
|
||||
invidiousServerResponse.data?.openRegistrations;
|
||||
|
||||
return (
|
||||
<>
|
||||
<NextSeo
|
||||
title="Settings"
|
||||
description={`Update your ${process.env.NEXT_PUBLIC_APP_NAME} settings`}
|
||||
/>
|
||||
<Layout>
|
||||
<Container>
|
||||
<Typography sx={{ my: 2 }} variant="h2">
|
||||
Settings
|
||||
</Typography>
|
||||
|
||||
<Divider sx={{ mb: 4 }} />
|
||||
|
||||
<Typography variant="h4">
|
||||
Theme
|
||||
</Typography>
|
||||
|
||||
<Setting
|
||||
title="General Theme"
|
||||
description="Sets the background color"
|
||||
>
|
||||
<ButtonGroup
|
||||
variant="contained"
|
||||
color="primary"
|
||||
aria-label="outlined default button group"
|
||||
>
|
||||
<Button
|
||||
onClick={() =>
|
||||
setSetting(
|
||||
"theme",
|
||||
"light"
|
||||
)
|
||||
}
|
||||
>
|
||||
Light
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() =>
|
||||
setSetting(
|
||||
"theme"
|
||||
)
|
||||
}
|
||||
>
|
||||
System
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() =>
|
||||
setSetting(
|
||||
"theme",
|
||||
"dark"
|
||||
)
|
||||
}
|
||||
>
|
||||
Dark
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
</Setting>
|
||||
|
||||
<Setting title="Primary Color">
|
||||
<ColorBox
|
||||
marginRight={1}
|
||||
color={
|
||||
settings.primaryColor
|
||||
}
|
||||
/>
|
||||
<Button
|
||||
onClick={() =>
|
||||
setPrimaryColorModal(
|
||||
true
|
||||
)
|
||||
}
|
||||
variant="contained"
|
||||
>
|
||||
Pick Color
|
||||
</Button>
|
||||
<MaterialColorPicker
|
||||
setState={
|
||||
setPrimaryColorModal
|
||||
}
|
||||
isOpen={
|
||||
primaryColorModalIsOpen
|
||||
}
|
||||
setColor={(color) =>
|
||||
setSetting(
|
||||
"primaryColor",
|
||||
color
|
||||
)
|
||||
}
|
||||
selectedColor={
|
||||
settings.primaryColor
|
||||
}
|
||||
/>
|
||||
</Setting>
|
||||
|
||||
<Setting title="Accent Color">
|
||||
<ColorBox
|
||||
marginRight={1}
|
||||
color={
|
||||
settings.accentColor
|
||||
}
|
||||
/>
|
||||
<Button
|
||||
onClick={() =>
|
||||
setAccentColorModal(
|
||||
true
|
||||
)
|
||||
}
|
||||
variant="contained"
|
||||
>
|
||||
Pick Color
|
||||
</Button>
|
||||
<MaterialColorPicker
|
||||
setState={
|
||||
setAccentColorModal
|
||||
}
|
||||
isOpen={
|
||||
accentColorModalIsOpen
|
||||
}
|
||||
setColor={(color) =>
|
||||
setSetting(
|
||||
"accentColor",
|
||||
color
|
||||
)
|
||||
}
|
||||
selectedColor={
|
||||
settings.accentColor
|
||||
}
|
||||
/>
|
||||
</Setting>
|
||||
|
||||
<Divider sx={{ my: 4 }} />
|
||||
|
||||
<Typography variant="h4">
|
||||
Player
|
||||
</Typography>
|
||||
|
||||
<Divider sx={{ my: 4 }} />
|
||||
|
||||
<Typography variant="h4">
|
||||
Data
|
||||
</Typography>
|
||||
|
||||
<Setting
|
||||
title="Invidious Server"
|
||||
description={`Where to fetch data from ${
|
||||
settings.storageType ==
|
||||
"invidious"
|
||||
? "and login into"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
<Box sx={{ mr: 2 }}>
|
||||
{!invidiousServerResponse.data &&
|
||||
!invidiousServerResponse.error &&
|
||||
!invidiousServerResponse.isLoading && (
|
||||
<Refresh
|
||||
sx={{
|
||||
cursor: "pointer",
|
||||
color: theme
|
||||
.palette
|
||||
.text
|
||||
.secondary
|
||||
}}
|
||||
onClick={() =>
|
||||
invidiousServerResponse.mutate(
|
||||
settings.invidiousServer
|
||||
)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{invidiousServerResponse.data &&
|
||||
!invidiousServerResponse.isLoading && (
|
||||
<>
|
||||
<Done
|
||||
onClick={() =>
|
||||
setModalState(
|
||||
true
|
||||
)
|
||||
}
|
||||
sx={{
|
||||
color: green[800],
|
||||
cursor: "pointer"
|
||||
}}
|
||||
/>
|
||||
<InfoModal
|
||||
modalIsOpen={
|
||||
modalIsOpen
|
||||
}
|
||||
setModalState={
|
||||
setModalState
|
||||
}
|
||||
data={
|
||||
invidiousServerResponse.data!
|
||||
}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{invidiousServerResponse.error && (
|
||||
<Error
|
||||
sx={{
|
||||
color: red[800]
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{invidiousServerResponse.isLoading && (
|
||||
<CircularProgress />
|
||||
)}
|
||||
</Box>
|
||||
<FormControl
|
||||
sx={{ minWidth: 200 }}
|
||||
>
|
||||
<InputLabel id="server-select-label">
|
||||
Server
|
||||
</InputLabel>
|
||||
<Select
|
||||
labelId="server-select-label"
|
||||
id="server-select"
|
||||
value={
|
||||
settings.invidiousServer
|
||||
}
|
||||
label="Server"
|
||||
onChange={(
|
||||
e
|
||||
) => {
|
||||
const server =
|
||||
e
|
||||
.target
|
||||
.value;
|
||||
|
||||
invidiousServerResponse.mutate(
|
||||
server
|
||||
);
|
||||
|
||||
setSetting(
|
||||
"invidiousServer",
|
||||
server
|
||||
);
|
||||
}}
|
||||
MenuProps={{
|
||||
sx: {
|
||||
maxHeight: 300
|
||||
}
|
||||
}}
|
||||
>
|
||||
{instances.data &&
|
||||
instances.data
|
||||
.filter(
|
||||
([
|
||||
,
|
||||
server
|
||||
]) =>
|
||||
server.type !=
|
||||
ServerInstanceType.Onion &&
|
||||
server.api ==
|
||||
true
|
||||
)
|
||||
.map(
|
||||
([
|
||||
uri,
|
||||
server
|
||||
]) => (
|
||||
<MenuItem
|
||||
key={
|
||||
uri
|
||||
}
|
||||
value={
|
||||
uri
|
||||
}
|
||||
>
|
||||
{
|
||||
server.flag
|
||||
}{" "}
|
||||
{
|
||||
uri
|
||||
}
|
||||
</MenuItem>
|
||||
)
|
||||
)}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Setting>
|
||||
|
||||
<Setting
|
||||
title="Data Storage Location"
|
||||
description="Where your personal data will be stored"
|
||||
>
|
||||
<FormControl
|
||||
sx={{ minWidth: 200 }}
|
||||
>
|
||||
<InputLabel id="location-select-label">
|
||||
Location
|
||||
</InputLabel>
|
||||
<Select
|
||||
labelId="location-select-label"
|
||||
id="location-select"
|
||||
value={
|
||||
settings.storageType
|
||||
}
|
||||
label="Location"
|
||||
onChange={(
|
||||
e
|
||||
) => {
|
||||
const location =
|
||||
e
|
||||
.target
|
||||
.value;
|
||||
|
||||
setSetting(
|
||||
"storageType",
|
||||
location
|
||||
);
|
||||
}}
|
||||
MenuProps={{
|
||||
sx: {
|
||||
maxHeight: 300
|
||||
}
|
||||
}}
|
||||
>
|
||||
{[
|
||||
{
|
||||
name: "Locally",
|
||||
value: StorageType.Local
|
||||
},
|
||||
{
|
||||
name: "Invidious server using auth",
|
||||
value: StorageType.Invidious
|
||||
},
|
||||
{
|
||||
name: `A custom ${process.env.NEXT_PUBLIC_APP_NAME} auth server`,
|
||||
value: StorageType.RemoteServer
|
||||
}
|
||||
].map(
|
||||
(
|
||||
location,
|
||||
i
|
||||
) => (
|
||||
<MenuItem
|
||||
key={
|
||||
i
|
||||
}
|
||||
value={
|
||||
location.value
|
||||
}
|
||||
>
|
||||
{
|
||||
location.name
|
||||
}
|
||||
</MenuItem>
|
||||
)
|
||||
)}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Setting>
|
||||
|
||||
{settings.storageType !=
|
||||
StorageType.Local && (
|
||||
<>
|
||||
{settings.storageType ==
|
||||
"invidious" &&
|
||||
allowsRegistrations && (
|
||||
<Setting
|
||||
title="Username"
|
||||
description="The username for your Invidious account"
|
||||
>
|
||||
<TextField
|
||||
label="Username"
|
||||
color="primary"
|
||||
/>
|
||||
</Setting>
|
||||
)}
|
||||
|
||||
{settings.storageType ==
|
||||
StorageType.RemoteServer && (
|
||||
<Setting
|
||||
title="Server address"
|
||||
description={`The address for your ${process.env.NEXT_PUBLIC_APP_NAME} auth server`}
|
||||
>
|
||||
<TextField
|
||||
label="Server adress"
|
||||
color="primary"
|
||||
/>
|
||||
</Setting>
|
||||
)}
|
||||
|
||||
<Setting
|
||||
title={
|
||||
settings.storageType ==
|
||||
StorageType.Invidious
|
||||
? "Password"
|
||||
: "Passphrase"
|
||||
}
|
||||
description={
|
||||
settings.storageType ==
|
||||
StorageType.Invidious
|
||||
? "The password for your invidious account"
|
||||
: "The passphrase for your account"
|
||||
}
|
||||
>
|
||||
<TextField
|
||||
type="password"
|
||||
label={
|
||||
settings.storageType ==
|
||||
StorageType.Invidious
|
||||
? "Password"
|
||||
: "Passphrase"
|
||||
}
|
||||
color="primary"
|
||||
/>
|
||||
</Setting>
|
||||
</>
|
||||
)}
|
||||
</Container>
|
||||
</Layout>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Settings;
|
@ -1,120 +0,0 @@
|
||||
import { GetStaticProps, NextPage } from "next";
|
||||
import { NextSeo } from "next-seo";
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
import { useQuery } from "react-query";
|
||||
|
||||
import axios, { AxiosError } from "axios";
|
||||
|
||||
import Box from "@mui/material/Box";
|
||||
import Chip from "@mui/material/Chip";
|
||||
import CircularProgress from "@mui/material/CircularProgress";
|
||||
import Typography from "@mui/material/Typography";
|
||||
|
||||
import { Error } from "@interfaces/api";
|
||||
import VideoTrending from "@interfaces/api/trending";
|
||||
|
||||
import { apiToVideo } from "@utils/conversions";
|
||||
import useSettings from "@utils/hooks/useSettings";
|
||||
|
||||
import Layout from "@components/Layout";
|
||||
import Loading from "@components/Loading";
|
||||
import Grid from "@components/Video/Grid";
|
||||
|
||||
const fetchTrending = (server: string, category: string) =>
|
||||
axios
|
||||
.get(`https://${server}/api/v1/trending`, {
|
||||
params: {
|
||||
fields: [
|
||||
"title",
|
||||
"description",
|
||||
"descriptionHtml",
|
||||
"videoId",
|
||||
"author",
|
||||
"authorId",
|
||||
"authorUrl",
|
||||
"lengthSeconds",
|
||||
"published",
|
||||
"publishedText",
|
||||
"liveNow",
|
||||
"premium",
|
||||
"isUpcoming",
|
||||
"viewCount",
|
||||
"videoThumbnails"
|
||||
].join(","),
|
||||
type: category
|
||||
}
|
||||
})
|
||||
.then((res) => res.data);
|
||||
|
||||
const Trending: NextPage<{ trending: VideoTrending[] }> = (props) => {
|
||||
const [selectedCategory, setCategory] = useState("all");
|
||||
|
||||
const [settings] = useSettings();
|
||||
|
||||
const { isLoading, error, data, isFetching } = useQuery<
|
||||
VideoTrending[],
|
||||
AxiosError<Error>
|
||||
>(
|
||||
["trendingData", selectedCategory],
|
||||
() => fetchTrending(settings.invidiousServer, selectedCategory),
|
||||
{
|
||||
initialData: props.trending
|
||||
}
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<NextSeo
|
||||
title="Trending"
|
||||
description="Look at new and trending video's"
|
||||
/>
|
||||
<Layout>
|
||||
<Box sx={{ px: { xs: 1, sm: 2, md: 5 } }}>
|
||||
{isLoading && <Loading />}
|
||||
{error && <Box>{error.response?.data.error}</Box>}
|
||||
{!isLoading && !error && data && (
|
||||
<>
|
||||
<Box sx={{ my: 2, display: "flex", alignItems: "center" }}>
|
||||
<Typography sx={{ mr: 1 }}>Categories:</Typography>
|
||||
{["Music", "Gaming", "News", "Movies"].map((category) => {
|
||||
const name = category.toLowerCase();
|
||||
const isSelected = name == selectedCategory;
|
||||
|
||||
return (
|
||||
<Chip
|
||||
sx={{ mr: 1 }}
|
||||
key={category}
|
||||
color={isSelected ? "primary" : "default"}
|
||||
label={category}
|
||||
onClick={() => {
|
||||
setCategory(isSelected ? "all" : name);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{isFetching && <CircularProgress size={25} />}
|
||||
</Box>
|
||||
<Grid videos={data.map(apiToVideo)} />
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
</Layout>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const getStaticProps: GetStaticProps = async ({}) => {
|
||||
const trending = await fetchTrending(
|
||||
process.env.NEXT_PUBLIC_DEFAULT_SERVER as string,
|
||||
"all"
|
||||
);
|
||||
|
||||
return {
|
||||
props: { trending: trending.slice(0, 10) },
|
||||
revalidate: 30
|
||||
};
|
||||
};
|
||||
|
||||
export default Trending;
|
@ -1,3 +0,0 @@
|
||||
.description a {
|
||||
color: #566fff;
|
||||
}
|
@ -1,164 +0,0 @@
|
||||
import NotFound from "./404";
|
||||
|
||||
import { GetStaticProps, NextPage } from "next";
|
||||
import { NextSeo } from "next-seo";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
import { useQuery } from "react-query";
|
||||
|
||||
import axios, { AxiosError } from "axios";
|
||||
|
||||
import Avatar from "@mui/material/Avatar";
|
||||
import Box from "@mui/material/Box";
|
||||
import Container from "@mui/material/Container";
|
||||
import Divider from "@mui/material/Divider";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
|
||||
import Share from "@mui/icons-material/Share";
|
||||
|
||||
import { abbreviateNumber } from "@src/utils";
|
||||
|
||||
import { Error } from "@interfaces/api";
|
||||
import VideoAPI from "@interfaces/api/video";
|
||||
|
||||
import useSettings from "@utils/hooks/useSettings";
|
||||
|
||||
import Layout from "@components/Layout";
|
||||
import Loading from "@components/Loading";
|
||||
import Player from "@components/Player";
|
||||
|
||||
import styles from "./watch.module.css";
|
||||
|
||||
const Watch: NextPage = () => {
|
||||
const { query, isReady } = useRouter();
|
||||
|
||||
const theme = useTheme();
|
||||
|
||||
const videoId = query["v"];
|
||||
|
||||
const [settings] = useSettings();
|
||||
|
||||
const { isLoading, error, data } = useQuery<
|
||||
VideoAPI | null,
|
||||
AxiosError<Error>
|
||||
>(["videoData", videoId], () =>
|
||||
videoId
|
||||
? axios
|
||||
.get(`https://${settings.invidiousServer}/api/v1/videos/${videoId}`, {
|
||||
params: {}
|
||||
})
|
||||
.then((res) => res.data)
|
||||
: null
|
||||
);
|
||||
|
||||
if (!isReady || isLoading) {
|
||||
return (
|
||||
<>
|
||||
<NextSeo title="Loading video..." />
|
||||
<Layout>
|
||||
<Loading />
|
||||
</Layout>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (!videoId) {
|
||||
return <NotFound />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<NextSeo title={data ? data.title : "Not Found"} />
|
||||
<Layout>
|
||||
{data && (
|
||||
<>
|
||||
<Player
|
||||
streams={data.formatStreams}
|
||||
formats={data.adaptiveFormats}
|
||||
captions={data.captions}
|
||||
length={data.lengthSeconds}
|
||||
videoId={data.videoId}
|
||||
sx={{
|
||||
height: "75vh",
|
||||
margin: "auto",
|
||||
mt: 2
|
||||
}}
|
||||
/>
|
||||
<Container sx={{ mt: 2 }}>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center"
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
flex: 1
|
||||
}}
|
||||
>
|
||||
<Typography variant="h4">{data.title}</Typography>
|
||||
<Typography
|
||||
variant="subtitle1"
|
||||
color={theme.palette.text.secondary}
|
||||
sx={{
|
||||
mt: 1
|
||||
}}
|
||||
>
|
||||
{abbreviateNumber(data.viewCount)} Views •{" "}
|
||||
{new Date(data.published * 1000).toLocaleDateString()}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Share />
|
||||
</Box>
|
||||
|
||||
<Divider sx={{ my: 2 }} />
|
||||
|
||||
<Link
|
||||
href={{
|
||||
pathname: `/channel`,
|
||||
query: {
|
||||
c: data.authorId
|
||||
}
|
||||
}}
|
||||
>
|
||||
<a>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
mb: 2
|
||||
}}
|
||||
>
|
||||
<Avatar
|
||||
src={
|
||||
data?.authorThumbnails.find(
|
||||
(thumbnail) => thumbnail.width == 100
|
||||
)?.url
|
||||
}
|
||||
alt={data.author}
|
||||
sx={{
|
||||
mr: 2
|
||||
}}
|
||||
/>
|
||||
<Typography variant="h6">{data.author}</Typography>
|
||||
</Box>
|
||||
</a>
|
||||
</Link>
|
||||
|
||||
<Typography
|
||||
className={styles.description}
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: data.descriptionHtml.replaceAll("\n", "<br>")
|
||||
}}
|
||||
></Typography>
|
||||
</Container>
|
||||
</>
|
||||
)}
|
||||
</Layout>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Watch;
|
Before Width: | Height: | Size: 304 B |
@ -1,19 +0,0 @@
|
||||
import { createTheme as createMUITheme } from "@mui/material/styles";
|
||||
|
||||
import Settings from "@interfaces/settings";
|
||||
|
||||
const createTheme = (settings: Settings, prefersDarkMode: boolean) => {
|
||||
return createMUITheme({
|
||||
palette: {
|
||||
mode: prefersDarkMode ? "dark" : "light",
|
||||
primary: {
|
||||
main: settings.primaryColor
|
||||
},
|
||||
secondary: {
|
||||
main: settings.accentColor
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export default createTheme;
|
@ -1,78 +0,0 @@
|
||||
import { Quality } from "@interfaces/api";
|
||||
import { VideoResult } from "@interfaces/api/search";
|
||||
import VideoTrending from "@interfaces/api/trending";
|
||||
import VideoAPI from "@interfaces/api/video";
|
||||
import Video from "@interfaces/video";
|
||||
|
||||
export const apiToVideo = (item: VideoTrending | VideoResult): Video => {
|
||||
return {
|
||||
title: item.title,
|
||||
description: {
|
||||
text: item.description,
|
||||
html: item.descriptionHtml
|
||||
},
|
||||
id: item.videoId,
|
||||
author: {
|
||||
name: item.author,
|
||||
id: item.authorId,
|
||||
url: item.authorUrl
|
||||
},
|
||||
length: item.lengthSeconds,
|
||||
published: {
|
||||
time: new Date(item.published),
|
||||
text: item.publishedText
|
||||
},
|
||||
views: item.viewCount,
|
||||
live: item.liveNow,
|
||||
premium: item.premium,
|
||||
upcoming: item.isUpcoming,
|
||||
premiereTimestamp: item.premiereTimestamp,
|
||||
thumbnail: item.videoThumbnails.find(
|
||||
(thumbnail) => thumbnail.quality == Quality.Maxresdefault
|
||||
)?.url as string
|
||||
};
|
||||
};
|
||||
|
||||
export const videoToVideo = (item: VideoAPI): Video => {
|
||||
return {
|
||||
title: item.title,
|
||||
views: item.viewCount,
|
||||
likes: item.likeCount,
|
||||
dislikes: item.dislikeCount,
|
||||
id: item.videoId,
|
||||
description: { html: item.descriptionHtml, text: item.description },
|
||||
length: item.lengthSeconds,
|
||||
live: item.liveNow,
|
||||
premiered: item.premiereTimestamp
|
||||
? new Date(item.premiereTimestamp)
|
||||
: undefined,
|
||||
premium: item.premium,
|
||||
published: {
|
||||
time: new Date(item.published),
|
||||
text: item.publishedText
|
||||
},
|
||||
rating: item.rating,
|
||||
genre: {
|
||||
type: item.genre,
|
||||
url: item.genreUrl
|
||||
},
|
||||
keywords: item.keywords,
|
||||
familyFriendly: item.isFamilyFriendly,
|
||||
subscriptions: item.subCountText,
|
||||
thumbnail: item.videoThumbnails.find(
|
||||
(thumbnail) => thumbnail.quality == "maxresdefault"
|
||||
)?.url as string,
|
||||
author: {
|
||||
id: item.authorId,
|
||||
name: item.author,
|
||||
url: item.authorUrl,
|
||||
thumbnail: item.authorThumbnails.find(
|
||||
(thumbnail) => thumbnail.width == 100
|
||||
)?.url as string
|
||||
},
|
||||
adaptiveFormats: item.adaptiveFormats,
|
||||
recommendedVideos: item.recommendedVideos,
|
||||
formatStreams: item.formatStreams,
|
||||
captions: item.captions
|
||||
};
|
||||
};
|
@ -1,29 +0,0 @@
|
||||
import useLocalStorageState from "use-local-storage-state";
|
||||
|
||||
import { Dispatch, SetStateAction } from "react";
|
||||
|
||||
import { red } from "@mui/material/colors";
|
||||
|
||||
import Settings, { StorageType } from "@interfaces/settings";
|
||||
|
||||
const defaultSettings: Settings = {
|
||||
primaryColor: red[800],
|
||||
accentColor: red[800],
|
||||
invidiousServer: process.env.NEXT_PUBLIC_DEFAULT_SERVER as string,
|
||||
storageType: StorageType.Local,
|
||||
autoPlay: true
|
||||
};
|
||||
|
||||
const useSettings = (): [
|
||||
settings: Settings,
|
||||
setSetting: Dispatch<SetStateAction<Settings>>
|
||||
] => {
|
||||
const [settings, setSettings] = useLocalStorageState<Settings>("settings", {
|
||||
defaultValue: defaultSettings,
|
||||
ssr: false
|
||||
});
|
||||
|
||||
return [settings, setSettings];
|
||||
};
|
||||
|
||||
export default useSettings;
|
@ -1,50 +0,0 @@
|
||||
import create from "zustand";
|
||||
|
||||
import { PausedBy, VideoSpeed, VideoStatus } from "@interfaces/videoPlayer";
|
||||
|
||||
interface VideoState {
|
||||
progress: number;
|
||||
setProgress: (progress: number) => void;
|
||||
speed: VideoSpeed;
|
||||
setSpeed: (speed: VideoSpeed) => void;
|
||||
error?: string;
|
||||
setError: (error: string) => void;
|
||||
waiting: boolean;
|
||||
setWaiting: (waiting: boolean) => void;
|
||||
muted: boolean;
|
||||
toggleMuted: () => void;
|
||||
pausedBy?: PausedBy;
|
||||
playing: VideoStatus;
|
||||
togglePlaying: (pausedBy?: PausedBy) => void;
|
||||
setPlaying: (playing: VideoStatus, pausedBy?: PausedBy) => void;
|
||||
}
|
||||
|
||||
const useVideoState = create<VideoState>((set) => ({
|
||||
progress: 0,
|
||||
setProgress: (progress) => set(() => ({ progress })),
|
||||
speed: 1,
|
||||
setSpeed: (speed) => set(() => ({ speed })),
|
||||
error: undefined,
|
||||
setError: (error) => set(() => ({ error })),
|
||||
waiting: true,
|
||||
setWaiting: (waiting) => set(() => ({ waiting })),
|
||||
muted: false,
|
||||
toggleMuted: () => set((state) => ({ muted: !state.muted })),
|
||||
pausedBy: undefined,
|
||||
playing: VideoStatus.Paused,
|
||||
togglePlaying: (pausedBy?: PausedBy) =>
|
||||
set((state) => ({
|
||||
playing:
|
||||
state.playing == VideoStatus.Playing
|
||||
? VideoStatus.Paused
|
||||
: VideoStatus.Playing,
|
||||
pausedBy: state.playing == VideoStatus.Playing ? pausedBy : undefined
|
||||
})),
|
||||
setPlaying: (playing, pausedBy) =>
|
||||
set(() => ({
|
||||
playing,
|
||||
pausedBy: playing == VideoStatus.Paused ? pausedBy : undefined
|
||||
}))
|
||||
}));
|
||||
|
||||
export default useVideoState;
|
@ -1,30 +0,0 @@
|
||||
import { DateTime } from "luxon";
|
||||
|
||||
export const abbreviateNumber = (value: number): string => {
|
||||
const suffixes = ["", "K", "M", "B", "T"];
|
||||
|
||||
let suffixNum = 0;
|
||||
|
||||
while (value >= 1000) {
|
||||
value /= 1000;
|
||||
suffixNum++;
|
||||
}
|
||||
|
||||
return `${value.toPrecision(4)}${suffixes[suffixNum]}`;
|
||||
};
|
||||
|
||||
export const formatNumber = (number: number) =>
|
||||
number.toString().replace(/(\d)(?=(\d{3})+(?!\d))/g, "$1,");
|
||||
|
||||
export const formatTime = (timestamp: number) =>
|
||||
DateTime.fromSeconds(timestamp)
|
||||
.toUTC()
|
||||
.toFormat("H:mm:ss")
|
||||
.replace(/^(0:)/g, "");
|
||||
|
||||
export const toCamelCase = (string: string): string =>
|
||||
string
|
||||
.replace(/(?:^\w|[A-Z]|\b\w)/g, (leftTrim: string, index: number) =>
|
||||
index === 0 ? leftTrim.toLowerCase() : leftTrim.toUpperCase()
|
||||
)
|
||||
.replace(/\s+/g, "");
|
@ -1,57 +0,0 @@
|
||||
import { useEffect } from "react";
|
||||
|
||||
import { useInView } from "react-intersection-observer";
|
||||
import { useQuery } from "react-query";
|
||||
|
||||
import axios, { AxiosError } from "axios";
|
||||
|
||||
import useSettings from "@utils/hooks/useSettings";
|
||||
|
||||
interface Channel {
|
||||
authorThumbnails: { url: string; width: number; height: number }[];
|
||||
}
|
||||
|
||||
export const useAuthorThumbnail = (
|
||||
authorId: string,
|
||||
quality: 32 | 48 | 76 | 100 | 176 | 512
|
||||
): {
|
||||
isLoading: boolean;
|
||||
error: AxiosError<Error> | null;
|
||||
ref: (node?: Element) => void;
|
||||
thumbnail?: string;
|
||||
} => {
|
||||
const [settings] = useSettings();
|
||||
|
||||
const { ref, inView } = useInView({
|
||||
threshold: 0
|
||||
});
|
||||
|
||||
const { isLoading, error, data, isFetched, refetch } = useQuery<
|
||||
Channel,
|
||||
AxiosError<Error>
|
||||
>(
|
||||
["channelData", authorId],
|
||||
() =>
|
||||
axios
|
||||
.get(
|
||||
`https://${settings.invidiousServer}/api/v1/channels/${authorId}`,
|
||||
{
|
||||
params: {
|
||||
fields: ["authorThumbnails"].join(",")
|
||||
}
|
||||
}
|
||||
)
|
||||
.then((res) => res.data),
|
||||
{ enabled: false }
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isFetched && inView) refetch();
|
||||
}, [refetch, isFetched, inView]);
|
||||
|
||||
const thumbnail = data?.authorThumbnails.find(
|
||||
(thumbnail) => thumbnail.width == quality
|
||||
)?.url;
|
||||
|
||||
return { isLoading, error, ref, thumbnail };
|
||||
};
|
@ -0,0 +1,19 @@
|
||||
import type { Config } from "tailwindcss";
|
||||
|
||||
import { nextui } from "@nextui-org/react";
|
||||
|
||||
const config: Config = {
|
||||
content: [
|
||||
"./src/pages/**/*.{js,ts,jsx,tsx,mdx}",
|
||||
"./src/components/**/*.{js,ts,jsx,tsx,mdx}",
|
||||
"./src/app/**/*.{js,ts,jsx,tsx,mdx}",
|
||||
"./node_modules/@nextui-org/theme/dist/**/*.{js,ts,jsx,tsx}"
|
||||
],
|
||||
theme: {
|
||||
extend: {}
|
||||
},
|
||||
darkMode: "class",
|
||||
plugins: [nextui()]
|
||||
};
|
||||
|
||||
export default config;
|
@ -1,30 +1,26 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "es5",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "node",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve",
|
||||
"incremental": true,
|
||||
"tsBuildInfoFile": ".next/tsbuildinfo.json",
|
||||
"baseUrl": "src",
|
||||
"paths": {
|
||||
"@styles/*": ["styles/*"],
|
||||
"@components/*": ["components/*"],
|
||||
"@interfaces/*": ["interfaces/*"],
|
||||
"@utils/*": ["utils/*"],
|
||||
|
||||
"@src/*": ["./*"]
|
||||
}
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"],
|
||||
"exclude": ["node_modules"]
|
||||
"compilerOptions": {
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve",
|
||||
"incremental": true,
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
|
Loading…
Reference in new issue