basic next.js app using nextui

nextui
Guus van Meerveld 2 months ago
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

40
.gitignore vendored

@ -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,25 +1,18 @@
FROM node:16-alpine AS deps
FROM node:lts-alpine AS deps
WORKDIR /app
COPY package.json yarn.lock ./
COPY package.json yarn.lock prisma ./
RUN yarn install --frozen-lockfile
FROM node:16-alpine AS builder
FROM node:lts-alpine AS builder
WORKDIR /app
COPY . .
COPY --from=deps /app/node_modules ./node_modules
ENV NEXT_TELEMETRY_DISABLED 1;
RUN yarn run prisma:generate
RUN yarn build && yarn install --production --ignore-scripts --prefer-offline
FROM node:16-alpine AS runner
FROM node:lts-alpine AS runner
WORKDIR /app
ENV NODE_ENV production
@ -28,14 +21,16 @@ RUN addgroup -g 1001 -S nodejs
RUN adduser -S nextjs -u 1001
COPY --from=builder /app/next.config.js ./
COPY --from=builder /app/next-i18next.config.js ./
COPY --from=builder /app/postcss.config.js ./
COPY --from=builder /app/tailwind.config.js ./
COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next ./.next
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/package.json ./package.json
COPY --from=builder /app/prisma ./prisma
USER nextjs
EXPOSE 3000
CMD ["yarn", "start"]
CMD ["yarn", "run", "start:migrate"]

@ -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: {},
},
};

Binary file not shown.

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;

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 0 24 24" width="24px" fill="#FFFFFF"><path d="M0 0h24v24H0z" fill="none"/><path d="M10 16.5l6-4.5-6-4.5v9zM12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8z"/></svg>

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"]
}

File diff suppressed because it is too large Load Diff
Loading…
Cancel
Save