Compare commits

...

48 Commits
main ... nextui

Author SHA1 Message Date
Guus van Meerveld 8a6097302b watch: improved overlay opacity animation
continuous-integration/drone/push Build is passing Details
1 month ago
Guus van Meerveld a2d9a449db watch: added support for timestamp parameter
continuous-integration/drone/push Build is passing Details
1 month ago
Guus van Meerveld 189c651941 restructured youtube paths, added search modal
continuous-integration/drone/push Build is passing Details
1 month ago
Guus van Meerveld d2cef1f072 improved context menu and video component responsiveness
continuous-integration/drone/push Build is passing Details
1 month ago
Guus van Meerveld 0674d6893d watch: added keybinds
continuous-integration/drone/push Build is passing Details
1 month ago
Guus van Meerveld 8632980212 watch: added fullscreen support, added loading bar
continuous-integration/drone/push Build is failing Details
1 month ago
Guus van Meerveld c29052ca61 watch: very basic video player
continuous-integration/drone/push Build is failing Details
1 month ago
Guus van Meerveld 64c8c6bb74 watch: fixed replies for piped api
continuous-integration/drone/push Build is passing Details
1 month ago
Guus van Meerveld 6b5a7d1727 watch: added simple comments section
continuous-integration/drone/push Build is passing Details
1 month ago
Guus van Meerveld 927fca314e results: fixed minor oversights and made optimizations
continuous-integration/drone/push Build is passing Details
1 month ago
Guus van Meerveld 7e13dea3c1 trending: added texts to loading page
1 month ago
Guus van Meerveld cb54d9f991 results: improved folder structure, better fallback
continuous-integration/drone/push Build is passing Details
1 month ago
Guus van Meerveld ff694c15c5 search: improved state handling and removed not need fetches
continuous-integration/drone/push Build is passing Details
1 month ago
Guus van Meerveld 6423bae178 results: exchange author image for avatar
continuous-integration/drone/push Build is passing Details
1 month ago
Guus van Meerveld a5ece4aeb3 nav: fix import name in elements file
1 month ago
Guus van Meerveld bf6a72c6b3 nav: rename main nav file
continuous-integration/drone/push Build is failing Details
1 month ago
Guus van Meerveld 323699cbc7 watch: implemented basic page with info
continuous-integration/drone/push Build is failing Details
1 month ago
Guus van Meerveld 7db6022749 nav: fix suspense not having fallback
continuous-integration/drone/push Build is failing Details
1 month ago
Guus van Meerveld 9f04f1b96a fix padding on context menus
continuous-integration/drone/push Build is passing Details
1 month ago
Guus van Meerveld b77771029e watch: simple backend for video info
continuous-integration/drone/push Build is passing Details
1 month ago
Guus van Meerveld e9d7ea3621 fixed eslint errors
continuous-integration/drone/push Build is failing Details
1 month ago
Guus van Meerveld 9da6733481 added import sorting, added eslint rules
continuous-integration/drone/push Build is failing Details
1 month ago
Guus van Meerveld f24c17d35e watch: started work on stream backend
continuous-integration/drone/push Build is failing Details
1 month ago
Guus van Meerveld 1ffb926631 fixed eslint warnings + made search filter explicit
continuous-integration/drone/push Build is passing Details
2 months ago
Guus van Meerveld 306585ad98 search: added filter button
continuous-integration/drone/push Build is failing Details
2 months ago
Guus van Meerveld ec1b131c3c search: added infinite scrolling support
continuous-integration/drone/push Build is failing Details
2 months ago
Guus van Meerveld c850857768 search: added playlist card
continuous-integration/drone/push Build is failing Details
2 months ago
Guus van Meerveld b1be90d190 added video and channel cards to search page
continuous-integration/drone/push Build is passing Details
2 months ago
Guus van Meerveld 5ac329296e added basic search page
continuous-integration/drone/push Build is passing Details
2 months ago
Guus van Meerveld c7dd2ddd12 basic search backend implemented
continuous-integration/drone/push Build is passing Details
2 months ago
Guus van Meerveld ed53ae1ea1 add context menu, add context menu to video component
continuous-integration/drone/push Build is failing Details
2 months ago
Guus van Meerveld 0efd9ed1f8 moved video card to components and renamed to video
continuous-integration/drone/push Build is failing Details
2 months ago
Guus van Meerveld e1c0a1082c trending: added region switcher
continuous-integration/drone/push Build is passing Details
2 months ago
Guus van Meerveld 6de055dc50 trending: format video duration
continuous-integration/drone/push Build is passing Details
2 months ago
Guus van Meerveld 42c56c6792 move trending page to /trending
continuous-integration/drone/push Build is passing Details
2 months ago
Guus van Meerveld 7438d26fa8 started on search page
continuous-integration/drone/push Build is passing Details
2 months ago
Guus van Meerveld da37c946c4 build: added tailwind config file to build
continuous-integration/drone/push Build is passing Details
2 months ago
Guus van Meerveld 617f947285 build: fix next config name
continuous-integration/drone/push Build is failing Details
2 months ago
Guus van Meerveld c4a97e22c6 build: remove prisma from dockerfile
continuous-integration/drone/push Build is failing Details
2 months ago
Guus van Meerveld ff285f7812 ci: rename drone config file
continuous-integration/drone/push Build is failing Details
2 months ago
Guus van Meerveld ea5f87075f ci: add drone config
2 months ago
Guus van Meerveld ca9cdcea7a fixed missing key on trending page
2 months ago
Guus van Meerveld 2e8785bf23 added basic nav
2 months ago
Guus van Meerveld dc9d17ec74 trending: limited thumbnails to 1 per video
2 months ago
Guus van Meerveld 61b54c4081 basic trending page
2 months ago
Guus van Meerveld b6c45a9049 basic client that can fetch trending page
2 months ago
Guus van Meerveld 098ee2d8ba add next-pwa to turn website into pwa
2 months ago
Guus van Meerveld a944e8cb80 basic next.js app using nextui
2 months ago

@ -1,8 +1,11 @@
**
!/next.config.js
!/tsconfig.json
!/package.json
!/yarn.lock
!/public
!/src
!.env
!next.config.mjs
!tailwind.config.ts
!postcss.config.js
!tsconfig.json
!package.json
!yarn.lock
!public
!src

@ -0,0 +1,73 @@
kind: pipeline
type: docker
name: test
platform:
os: linux
arch: amd64
steps:
- name: Test the newest commit
image: node:lts-alpine
volumes:
- name: cache
path: /drone/src/node_modules
commands:
- yarn install
- yarn lint
- yarn test-build
volumes:
- name: cache
host:
path: /tmp/drone/cache/node_modules
---
kind: pipeline
type: docker
name: build-linux-amd64
platform:
os: linux
arch: amd64
steps:
- name: Build Dockerfile and push to Dockerhub
image: plugins/docker
settings:
repo: guusvanmeerveld/materialtube
tags:
- latest
- latest-amd64
platforms: linux/amd64
username:
from_secret: docker_username
password:
from_secret: docker_password
depends_on:
- test
---
kind: pipeline
type: docker
name: build-linux-arm64
platform:
os: linux
arch: arm64
steps:
- name: Build Dockerfile and push to Dockerhub
image: plugins/docker
settings:
repo: guusvanmeerveld/materialtube
tags: latest-arm64
platforms: linux/arm64
username:
from_secret: docker_username
password:
from_secret: docker_password
depends_on:
- test

@ -0,0 +1,32 @@
{
"extends": [
"plugin:@next/next/recommended",
"next/core-web-vitals",
"plugin:@typescript-eslint/recommended",
"plugin:prettier/recommended",
"plugin:css-modules/recommended",
"plugin:@tanstack/eslint-plugin-query/recommended"
],
"plugins": ["@typescript-eslint", "css-modules", "@tanstack/query"],
"rules": {
"@typescript-eslint/no-non-null-assertion": 0,
"@typescript-eslint/no-unused-vars": ["error"],
"@typescript-eslint/explicit-function-return-type": "warn",
"padding-line-between-statements": [
"error",
{ "blankLine": "always", "prev": "*", "next": "block" },
{ "blankLine": "always", "prev": "block", "next": "*" },
{ "blankLine": "always", "prev": "*", "next": "block-like" },
{ "blankLine": "always", "prev": "block-like", "next": "*" }
]
},
"ignorePatterns": [
"/node_modules",
"/cache",
"/dist",
"/out",
"/public",
"*.js"
]
}

@ -1,11 +0,0 @@
{
"extends": "next/core-web-vitals",
"rules": {
"no-restricted-imports": [
"error",
{
"patterns": ["@mui/*/*/*", "!@mui/material/test-utils/*"]
}
]
}
}

@ -1,38 +0,0 @@
---
name: Bug report
about: Create a report to help us improve
title: ''
labels: ''
assignees: ''
---
**Describe the bug**
A clear and concise description of what the bug is.
**To Reproduce**
Steps to reproduce the behavior:
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
**Expected behavior**
A clear and concise description of what you expected to happen.
**Screenshots**
If applicable, add screenshots to help explain your problem.
**Desktop (please complete the following information):**
- OS: [e.g. iOS]
- Browser [e.g. chrome, safari]
- Version [e.g. 22]
**Smartphone (please complete the following information):**
- Device: [e.g. iPhone6]
- OS: [e.g. iOS8.1]
- Browser [e.g. stock browser, safari]
- Version [e.g. 22]
**Additional context**
Add any other context about the problem here.

@ -1,20 +0,0 @@
---
name: Feature request
about: Suggest an idea for this project
title: ''
labels: ''
assignees: ''
---
**Is your feature request related to a problem? Please describe.**
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
**Describe the solution you'd like**
A clear and concise description of what you want to happen.
**Describe alternatives you've considered**
A clear and concise description of any alternative solutions or features you've considered.
**Additional context**
Add any other context or screenshots about the feature request here.

@ -1,49 +0,0 @@
name: "CodeQL"
on:
push:
branches: [master]
paths:
- src/**
- package.json
- tsconfig.json
- .github/workflows/codeql-analysis.yml
pull_request:
# The branches below must be a subset of the branches above
branches: [master]
schedule:
- cron: "27 18 * * 6"
jobs:
analyze:
name: Analyze
runs-on: ubuntu-latest
permissions:
actions: read
contents: read
security-events: write
strategy:
fail-fast: false
matrix:
language: ["typescript"]
steps:
- name: Checkout repository
uses: actions/checkout@v3
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@v2
with:
languages: ${{ matrix.language }}
- name: Autobuild
uses: github/codeql-action/autobuild@v2
#- run: |
# make bootstrap
# make release
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v2

@ -1,64 +0,0 @@
name: Deploy to Github Pages / Docker hub
on:
push:
branches:
- master
paths:
- src/**
- public/**
- Dockerfile
- package.json
- tsconfig.json
- next.config.js
- .github/workflows/deploy.yml
env:
NEXT_TELEMETRY_DISABLED: 1
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Setup
uses: actions/checkout@v2
- name: Setup Node.js
uses: actions/setup-node@v2
with:
node-version: "16.x"
cache: "yarn"
- name: Install npm dependencies
run: yarn install
- name: Lint
run: yarn run lint
- name: Build
run: yarn run build
docker:
runs-on: ubuntu-latest
needs: test
steps:
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v1
# Login
- name: Login to DockerHub
uses: docker/login-action@v1
with:
username: guusvanmeerveld
password: ${{ secrets.DOCKERHUB_TOKEN }}
# Build & Push
- name: Build Dockerfile and push
uses: docker/build-push-action@v2
with:
push: true
tags: guusvanmeerveld/materialtube:latest
# Cache
cache-from: type=gha
cache-to: type=gha,mode=max

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

@ -4,22 +4,26 @@
"semi": true,
"printWidth": 80,
"arrowParens": "always",
"importOrderSeparation": true,
"plugins": ["@ianvs/prettier-plugin-sort-imports"],
"importOrder": [
"^next.*",
"^react$",
"^react.*",
"^axios.*",
"^@emotion/.*",
"^@mui/material/.*",
"^@mui/.*",
"^@src/.*",
"^@models/.*",
"^@interfaces/.*",
"^@utils/.*",
"^@components/.*",
".*sass$",
".*css$",
"^@svg/.*"
".*s?css$",
"",
"<THIRD_PARTY_MODULES>",
"",
"^(next(/.*)?)|(react.*)",
"",
"^@(:?mui|nextui-org)/.*",
"",
"^@/hooks/.*",
"",
"^@/(client)|(utils)/.*",
"",
"^(@/|\\./)components/*",
"",
"^\\.?\\./.*",
"",
"<TYPES>",
"",
"^(@/|\\./)typings/.*"
]
}

@ -0,0 +1,3 @@
{
"prettier.configPath": ".prettierrc.json"
}

@ -1,25 +1,17 @@
FROM node:16-alpine AS deps
FROM node:lts-alpine AS deps
WORKDIR /app
COPY package.json yarn.lock ./
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 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
@ -27,8 +19,9 @@ ENV NODE_ENV production
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/next.config.mjs ./
COPY --from=builder /app/postcss.config.js ./
COPY --from=builder /app/tailwind.config.ts ./
COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next ./.next
COPY --from=builder /app/node_modules ./node_modules
@ -38,4 +31,4 @@ USER nextjs
EXPOSE 3000
CMD ["yarn", "start"]
CMD ["yarn", "run", "start"]

@ -1,157 +1,36 @@
<p align="center">
<img src="https://raw.githubusercontent.com/Guusvanmeerveld/MaterialTube/master/src/svg/logo.svg" height="96"/>
</p>
This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app).
<h1 align="center">MaterialTube</h1>
## Getting Started
<p align="center">
<img src="https://github.com/Guusvanmeerveld/MaterialTube/actions/workflows/deploy.yml/badge.svg" alt="Deploy Site" />
<img src="https://github.com/Guusvanmeerveld/MaterialTube/actions/workflows/codeql-analysis.yml/badge.svg" alt="CodeQL" />
<a href="https://hub.docker.com/r/guusvanmeerveld/materialtube">
<img src="https://shields.io/docker/pulls/guusvanmeerveld/materialtube" alt="Docker pulls" />
</a>
</p>
First, run the development server:
<p align="center">
<a href="https://heroku.com/deploy?template=https://github.com/Guusvanmeerveld/MaterialTube">
<img src="https://www.herokucdn.com/deploy/button.svg" alt="Deploy to Heroku">
</a>
<a href="https://app.netlify.com/start/deploy?repository=https://github.com/Guusvanmeerveld/MaterialTube">
<img src="https://www.netlify.com/img/deploy/button.svg" alt="Deploy to Netlify">
</a>
</p>
<p align="center">
MaterialTube is a simple client-side only web-client for Invidious servers. It supports using an Invidious account, but also allows you to store all of your data locally. It's main goal is to provide an even greater level of privacy and improve on the current Invidious UI.
</p>
<p align="center">Made using</p>
<p align="center">
<img src="https://img.shields.io/badge/TypeScript-007ACC?style=for-the-badge&logo=typescript&logoColor=white" alt="Typescript" />
<img src="https://img.shields.io/badge/React-20232A?style=for-the-badge&logo=react&logoColor=61DAFB" alt="React" />
</p>
## Index
- [Index](#index)
- [(Current) Features](#current-features)
- [Configuration](#configuration)
- [Deploy](#deploy)
- [Using Node.js](#using-nodejs)
- [Using Docker](#using-docker)
- [Locally](#locally)
- [Using Docker Hub](#using-docker-hub)
- [Using Heroku](#using-heroku)
- [Using Netlify](#using-netlify)
## (Current) Features
- Browse trending
- Watch video's
- Custom settings
## Configuration
There are a few environmental variables that are able to be set during build time to further customize the application.
- GIT_URL: Set the url to the git repo. Default: https://github.com/Guusvanmeerveld/MaterialTube
- APP_NAME: Set the app name to show to the users. Default: MaterialTube
- DEFAULT_SERVER: Set the invidious server to use by default. Default: invidious.privacy.gd
## Deploy
### Using Node.js
Requirements:
- Node.js v16.x
- Yarn or NPM
- Git
```sh
git clone https://github.com/Guusvanmeerveld/MaterialTube MaterialTube
cd MaterialTube
# Choose Yarn or NPM
yarn install --frozen-lockfile
# npm install --frozen-lockfile
export NEXT_TELEMETRY_DISABLED=1
```
Now you have to choose between export to static HTML (recommended) or running a custom server (improves speed)
Exporting to static HTML:
```sh
yarn export
# npm export
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
```
The HTML files can be found in the `out` folder. You can now serve them using something like NGINX or Apache
You can also opt to use a custom server, which improves on speed because it will prefetch your request.
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
Building and starting a custom server:
```sh
yarn build
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
# npm build
This project uses [`next/font`](https://nextjs.org/docs/basic-features/font-optimization) to automatically optimize and load Inter, a custom Google Font.
yarn start
# npm start
```
### Using Docker
#### Locally
Requirements:
- Docker
- docker-compose
- Git
```sh
git clone https://github.com/Guusvanmeerveld/MaterialTube MaterialTube
cd MaterialTube
docker build . -t materialtube
```
Now update the `docker-compose.yml` to your needs and start the container:
```sh
docker-compose up -d
```
#### Using Docker Hub
Requirements:
- Docker
- docker-compose
Simply update the following to your needs and put it in a file named `docker-compose.yml`.
```yml
version: "3"
services:
app:
build: guusvanmeerveld/materialtube
container_name: material-tube
ports:
- 3000:80
```
## Learn More
Now run `docker-compose up -d` to start the container.
To learn more about Next.js, take a look at the following resources:
### Using Heroku
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
Deploying to Heroku is a very simple and highly recommended way of deploying. All you have to do is click the button below, create an account (if you don't already have one) and deploy it.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome!
[![Deploy](https://www.herokucdn.com/deploy/button.svg)](https://heroku.com/deploy?template=https://github.com/Guusvanmeerveld/MaterialTube)
## Deploy on Vercel
### Using Netlify
Deploying to Netlify is just as easy as deploying to Heroku. Click the button below connect your Git repo and follow the steps to deploy your application.
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
[![Deploy to Netlify](https://www.netlify.com/img/deploy/button.svg)](https://app.netlify.com/start/deploy?repository=https://github.com/Guusvanmeerveld/MaterialTube)
Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details.

@ -1,6 +0,0 @@
{
"name": "MaterialTube",
"description": "MaterialTube is a beautiful and elegant web client for Invidious servers, built using Next.js and MUI.",
"repository": "https://github.com/Guusvanmeerveld/MaterialTube",
"keywords": ["react", "youtube", "nextjs", "mui", "invidious"]
}

@ -0,0 +1,11 @@
meta {
name: Stream
type: http
seq: 2
}
get {
url: https://invidious.drgns.space/api/v1/videos/CcHevgjAnV0
body: none
auth: none
}

@ -0,0 +1,11 @@
meta {
name: Trending
type: http
seq: 1
}
get {
url: https://invidious.drgns.space/api/v1/trending
body: none
auth: none
}

@ -0,0 +1,11 @@
meta {
name: Stream
type: http
seq: 2
}
get {
url: https://pipedapi.kavin.rocks/streams/CcHevgjAnV0
body: none
auth: none
}

@ -0,0 +1,15 @@
meta {
name: Trending
type: http
seq: 1
}
get {
url: https://pipedapi.kavin.rocks/trending?region=NL
body: none
auth: none
}
query {
region: NL
}

@ -0,0 +1,5 @@
{
"version": "1",
"name": "MaterialTube",
"type": "collection"
}

@ -1,8 +0,0 @@
version: "3"
services:
app:
build: .
container_name: material-tube
ports:
- 3000:3000

@ -0,0 +1,56 @@
{
"nodes": {
"flake-compat": {
"locked": {
"lastModified": 1696426674,
"narHash": "sha256-kvjfFW7WAETZlt09AgDn1MrtKzP7t90Vf7vypd3OL1U=",
"rev": "0f9255e01c2351cc7d116c072cb317785dd33b33",
"revCount": 57,
"type": "tarball",
"url": "https://api.flakehub.com/f/pinned/edolstra/flake-compat/1.0.1/018afb31-abd1-7bff-a5e4-cff7e18efb7a/source.tar.gz"
},
"original": {
"type": "tarball",
"url": "https://flakehub.com/f/edolstra/flake-compat/1.tar.gz"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1710252211,
"narHash": "sha256-hQChQpB4LDBaSrNlD6DPLhU9T+R6oyxMCg2V+S7Y1jg=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "7eeacecff44e05a9fd61b9e03836b66ecde8a525",
"type": "github"
},
"original": {
"id": "nixpkgs",
"type": "indirect"
}
},
"root": {
"inputs": {
"flake-compat": "flake-compat",
"nixpkgs": "nixpkgs",
"systems": "systems"
}
},
"systems": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
}
},
"root": "root",
"version": 7
}

@ -0,0 +1,37 @@
{
description = "Material tube";
inputs = {
systems.url = "github:nix-systems/default";
flake-compat.url = "https://flakehub.com/f/edolstra/flake-compat/1.tar.gz";
};
outputs =
{ systems
, nixpkgs
, ...
}:
let
eachSystem = f:
nixpkgs.lib.genAttrs (import systems) (
system:
f nixpkgs.legacyPackages.${system}
);
in
{
devShells = eachSystem
(pkgs: {
default = pkgs.mkShell
{
buildInputs = with pkgs; [
nodejs_20
yarn
nodePackages.typescript
nodePackages.typescript-language-server
];
};
});
};
}

@ -1,3 +0,0 @@
[build]
publish = ".next"
command = "yarn build"

@ -1,20 +0,0 @@
const packageInfo = require("./package.json");
// @ts-check
/**
* @type {import('next').NextConfig}
**/
module.exports = {
reactStrictMode: true,
eslint: {
ignoreDuringBuilds: true
},
env: {
NEXT_PUBLIC_GITHUB_URL: process.env.GIT_URL ?? packageInfo.repository.url,
NEXT_PUBLIC_APP_NAME: process.env.APP_NAME ?? packageInfo.displayName,
NEXT_PUBLIC_DEFAULT_SERVER:
process.env.DEFAULT_SERVER ?? "invidious.privacy.gd"
},
basePath: process.env.BASE_PATH ?? "",
trailingSlash: !(process.env.CI == "true")
};

@ -0,0 +1,10 @@
import createPWA from "next-pwa";
const withPWA = createPWA({
dest: "public"
});
/** @type {import('next').NextConfig} */
const nextConfig = {};
export default withPWA(nextConfig);

@ -1,51 +1,58 @@
{
"name": "material-tube",
"displayName": "MaterialTube",
"description": "MaterialTube is a beautiful and elegant web client for Invidious servers, built using Next.js and MUI.",
"license": "MIT",
"repository": {
"url": "https://github.com/Guusvanmeerveld/MaterialTube"
},
"cacheDirectories": [".next/cache"],
"name": "materialtube",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"prettify": "prettier . --write",
"test-build": "tsc",
"start": "next start",
"lint": "next lint",
"prettify": "prettier src --write"
"lint": "next lint"
},
"dependencies": {
"@emotion/react": "^11.8.2",
"@emotion/styled": "^11.8.1",
"@mui/icons-material": "^5.5.1",
"@mui/material": "^5.5.1",
"axios": "^0.26.1",
"luxon": "^2.3.1",
"next": "^12.1.0",
"next-seo": "^5.1.0",
"react": "^17.0.2",
"react-dom": "^17.0.2",
"react-intersection-observer": "^8.33.1",
"react-query": "^3.34.16",
"use-local-storage-state": "^16.0.1",
"zustand": "^3.7.2"
"@nextui-org/react": "^2.2.10",
"@tanstack/react-query": "^5.27.5",
"country-region-data": "^3.0.0",
"framer-motion": "^11.0.12",
"ky": "^1.2.2",
"luxon": "^3.4.4",
"next": "14.1.3",
"next-pwa": "^5.6.0",
"react": "^18",
"react-dom": "^18",
"react-hotkeys-hook": "^4.5.0",
"react-icons": "^5.0.1",
"react-player": "^2.15.1",
"reactjs-visibility": "^0.1.4",
"sanitize-html": "^2.13.0",
"screenfull": "^6.0.2",
"use-debounce": "^10.0.0",
"zod": "^3.22.4",
"zustand": "^4.5.2"
},
"devDependencies": {
"@trivago/prettier-plugin-sort-imports": "^3.2.0",
"@types/luxon": "^2.3.1",
"@types/node": "^17.0.21",
"@types/react": "^17.0.40",
"@types/react-dom": "^17.0.13",
"@typescript-eslint/eslint-plugin": "^5.15.0",
"@typescript-eslint/parser": "^5.15.0",
"eslint": "^8.11.0",
"eslint-config-next": "12.1.0",
"eslint-plugin-css-modules": "^2.11.0",
"eslint-plugin-jsx-a11y": "^6.5.1",
"eslint-plugin-prettier": "^4.0.0",
"eslint-plugin-react": "^7.29.4",
"eslint-plugin-react-hooks": "^4.3.0",
"prettier": "^2.6.0",
"typescript": "^4.6.2"
"@ianvs/prettier-plugin-sort-imports": "^4.2.1",
"@tanstack/eslint-plugin-query": "^5.28.6",
"@tanstack/react-query-devtools": "^5.27.8",
"@types/luxon": "^3.4.2",
"@types/next-pwa": "^5.6.9",
"@types/node": "^20",
"@types/react": "^18",
"@types/react-dom": "^18",
"@types/sanitize-html": "^2.11.0",
"@typescript-eslint/eslint-plugin": "^7.3.1",
"@typescript-eslint/parser": "^7.3.1",
"autoprefixer": "^10.0.1",
"eslint": "^8",
"eslint-config-next": "^14.1.4",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-css-modules": "^2.12.0",
"eslint-plugin-import": "^2.29.1",
"eslint-plugin-prettier": "^5.1.3",
"postcss": "^8",
"prettier": "^3.2.5",
"tailwindcss": "^3.3.0",
"typescript": "^5"
}
}

@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {}
}
};

2
public/.gitignore vendored

@ -0,0 +1,2 @@
/**.js
/**.js.map

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,54 @@
"use client";
import { Button } from "@nextui-org/react";
import { FC, useState } from "react";
import { useHotkeys } from "react-hotkeys-hook";
import {
Modal,
ModalBody,
ModalContent,
ModalFooter,
ModalHeader
} from "@nextui-org/modal";
import { Search } from "@/components/Search";
export const SearchModal: FC = () => {
const [isOpen, setOpen] = useState(false);
useHotkeys(
"ctrl+k",
() => {
setOpen(true);
},
{ preventDefault: true }
);
return (
<Modal
isOpen={isOpen}
onOpenChange={(isOpen) => {
setOpen(isOpen);
}}
size="2xl"
>
<ModalContent>
{(onClose) => (
<>
<ModalHeader className="flex flex-col gap-1">Search</ModalHeader>
<ModalBody>
<Search onSearch={() => onClose()} />
</ModalBody>
<ModalFooter>
<Button color="danger" variant="light" onPress={onClose}>
Close
</Button>
<Button color="primary">Search</Button>
</ModalFooter>
</>
)}
</ModalContent>
</Modal>
);
};

@ -0,0 +1,14 @@
import { SearchModal } from "./SearchModal";
import { Component } from "@/typings/component";
const YoutubeLayout: Component = ({ children }) => {
return (
<>
{children}
<SearchModal />
</>
);
};
export default YoutubeLayout;

@ -0,0 +1,22 @@
import { FC } from "react";
import { Button } from "@nextui-org/button";
import { Spacer } from "@nextui-org/spacer";
export const ErrorPage: FC<{ data: Error; refetch: () => void }> = ({
data: error,
refetch
}) => {
return (
<div className="flex-1 flex items-center justify-center">
<div className="text-center">
<h1 className="text-xl">An error occurred loading the search page</h1>
<h2 className="text-lg">{error.toString()}</h2>
<Spacer y={2} />
<Button color="primary" onClick={() => refetch()}>
Retry
</Button>
</div>
</div>
);
};

@ -0,0 +1,42 @@
"use client";
import { useSearchParams } from "next/navigation";
import { FC, useMemo } from "react";
import { SearchType, SearchTypeModel } from "@/client/typings/search/options";
import { Container } from "@/components/Container";
import { Search } from "@/components/Search";
import { SearchPageBody } from "./SearchPageBody";
export const SearchPage: FC = () => {
const searchParams = useSearchParams();
const query = useMemo(() => {
const param = searchParams.get("search_query");
if (param === null || param.length === 0) return;
return param;
}, [searchParams]);
const filter: SearchType = useMemo(() => {
const param = searchParams.get("filter");
const parsed = SearchTypeModel.safeParse(param);
if (!parsed.success) return "all";
return parsed.data;
}, [searchParams]);
return (
<>
<Container>
<Search query={query} filter={filter} />
{query && <SearchPageBody query={query} filter={filter} />}
</Container>
</>
);
};

@ -0,0 +1,46 @@
"use client";
import Link from "next/link";
import { FC } from "react";
import { Avatar } from "@nextui-org/avatar";
import { Card, CardBody } from "@nextui-org/card";
import { ChannelItem } from "@/client/typings/item";
import formatBigNumber from "@/utils/formatBigNumber";
import { channelUrl } from "@/utils/urls";
export const Channel: FC<{ data: ChannelItem }> = ({ data }) => {
const url = channelUrl(data.id);
return (
<Card>
<CardBody>
<div className="flex flex-row gap-4">
<Link href={url}>
<Avatar
className="w-32 h-32 text-2xl"
src={data.thumbnail}
name={data.name}
isBordered
/>
</Link>
<div className="flex-1 gap-2 flex flex-col justify-center">
<div className="flex flex-col">
<Link href={url}>
<p className="text-lg">{data.name}</p>
</Link>
<div className="flex flex-row gap-4 items-center tracking-tight text-default-400">
<p>{formatBigNumber(data.subscribers)} subscribers</p>
{data.videos !== 0 && (
<p>{formatBigNumber(data.videos)} videos</p>
)}
</div>
</div>
<p className="text-default-600">{data.description}</p>
</div>
</div>
</CardBody>
</Card>
);
};

@ -0,0 +1,20 @@
import { useVisibility } from "reactjs-visibility";
import { CircularProgress } from "@nextui-org/progress";
import { Component } from "@/typings/component";
export const LoadingNextPage: Component<{
isFetching: boolean;
onVisible: (visiblity: boolean) => void;
}> = ({ onVisible, isFetching }) => {
const { ref } = useVisibility({
onChangeVisibility: onVisible
});
return (
<div ref={ref} className="flex items-center justify-center min-h-10">
{isFetching && <CircularProgress aria-label="Loading more items..." />}
</div>
);
};

@ -0,0 +1,81 @@
"use client";
import NextImage from "next/image";
import NextLink from "next/link";
import { FC } from "react";
import { Card, CardBody } from "@nextui-org/card";
import { Image } from "@nextui-org/image";
import { Link } from "@nextui-org/link";
import { Listbox, ListboxItem } from "@nextui-org/listbox";
import { PlaylistItem } from "@/client/typings/item";
import { videoUrl } from "@/utils/urls";
import { videoSize } from "@/utils/videoSize";
import { Author } from "@/components/Author";
import { imageSize } from "./constants";
export const Playlist: FC<{ data: PlaylistItem }> = ({ data }) => {
const url = `/playlist/${data.id}`;
const [width, height] = videoSize(imageSize);
const [playlistItemWidth, playlistItemHeight] = videoSize(5);
return (
<Card>
<CardBody>
<div className="flex flex-col lg:flex-row gap-4">
<div className="relative">
<NextLink href={url}>
<Image
width={width}
height={height}
className="object-contain"
src={data.thumbnail}
alt={data.title}
as={NextImage}
unoptimized
/>
</NextLink>
<p className="text-small rounded-md z-10 absolute bottom-2 right-2 bg-content2 p-1">
{data.numberOfVideos} videos
</p>
</div>
<div className="flex flex-col gap-2">
<Link as={NextLink} href={url}>
<h1 className="text-xl text-default-foreground">{data.title}</h1>
</Link>
<Author data={data.author} />
{data.videos && (
<Listbox>
{data.videos.slice(0, 2).map((video) => (
<ListboxItem
as={NextLink}
startContent={
<Image
alt={video.title}
src={video.thumbnail}
height={playlistItemHeight}
width={playlistItemWidth}
/>
}
key={video.id}
href={videoUrl(video.id)}
>
{video.title}
</ListboxItem>
))}
</Listbox>
)}
</div>
</div>
</CardBody>
</Card>
);
};

@ -0,0 +1,72 @@
"use client";
import NextImage from "next/image";
import NextLink from "next/link";
import { FC } from "react";
import { Card, CardBody } from "@nextui-org/card";
import { Image } from "@nextui-org/image";
import { Link } from "@nextui-org/link";
import { VideoItem } from "@/client/typings/item";
import formatBigNumber from "@/utils/formatBigNumber";
import formatDuration from "@/utils/formatDuration";
import formatUploadedTime from "@/utils/formatUploadedTime";
import { videoUrl } from "@/utils/urls";
import { videoSize } from "@/utils/videoSize";
import { Author } from "@/components/Author";
import { imageSize } from "./constants";
export const Video: FC<{ data: VideoItem }> = ({ data }) => {
const url = videoUrl(data.id);
const [width, height] = videoSize(imageSize);
return (
<Card>
<CardBody>
<div className="flex flex-row gap-4">
<div className="relative">
<NextLink href={url}>
<Image
width={width}
height={height}
src={data.thumbnail}
alt={data.title}
as={NextImage}
unoptimized
/>
</NextLink>
<p className="text-small rounded-md z-10 absolute bottom-2 right-2 bg-content2 p-1">
{formatDuration(data.duration)}
</p>
{data.live && (
<p className="text-small rounded-md z-10 absolute bottom-2 left-2 bg-danger p-1">
LIVE
</p>
)}
</div>
<div className="flex flex-col gap-2 w-full">
<div>
<Link as={NextLink} href={url}>
<h1 className="text-xl text-default-foreground">
{data.title}
</h1>
</Link>
<div className="flex flex-row gap-4 items-center tracking-tight text-default-400">
<h1>{formatBigNumber(data.views)} views</h1>
{data.uploaded && <h1>{formatUploadedTime(data.uploaded)}</h1>}
</div>
</div>
<Author data={data.author} />
<p className="text-default-600">{data.description}</p>
</div>
</div>
</CardBody>
</Card>
);
};

@ -0,0 +1,86 @@
import { useInfiniteQuery } from "@tanstack/react-query";
import { FC, Fragment, useCallback } from "react";
import { useClient } from "@/hooks/useClient";
import { SearchType } from "@/client/typings/search/options";
import { LoadingPage } from "@/components/LoadingPage";
import { ErrorPage } from "../ErrorPage";
import { Channel } from "./Channel";
import { LoadingNextPage } from "./LoadingNextPage";
import { Playlist } from "./Playlist";
import { Video } from "./Video";
export const SearchPageBody: FC<{ query: string; filter: SearchType }> = ({
filter,
query
}) => {
const client = useClient();
const {
data,
error,
fetchNextPage,
refetch,
isPending: isFetchingInitialData,
isFetchingNextPage
} = useInfiniteQuery({
queryKey: ["search", query, filter],
queryFn: async ({ pageParam }) => {
return await client.getSearch(query, {
pageParam: pageParam,
type: filter
});
},
initialPageParam: "",
getNextPageParam: (lastPage) => lastPage.nextCursor
});
const isFetchingNewPage = isFetchingNextPage && !isFetchingInitialData;
const fetchNewData = useCallback(
(visiblity: boolean) => {
if (visiblity && !isFetchingNextPage) fetchNextPage();
},
[isFetchingNextPage, fetchNextPage]
);
return (
<>
{isFetchingInitialData && (
<LoadingPage text={`Fetching search results for query \`${query}\``} />
)}
{data && (
<div className="flex flex-col gap-4 mt-4">
{data.pages.map((page, i) => {
return (
<Fragment key={i}>
{page.items.map((result) => {
switch (result.type) {
case "channel":
return <Channel key={result.id} data={result} />;
case "video":
return <Video key={result.id} data={result} />;
case "playlist":
return <Playlist key={result.id} data={result} />;
}
})}
</Fragment>
);
})}
{error === null && (
<LoadingNextPage
isFetching={isFetchingNewPage}
onVisible={fetchNewData}
/>
)}
</div>
)}
{error !== null && <ErrorPage data={error} refetch={refetch} />}
</>
);
};

@ -0,0 +1,25 @@
import { NextPage } from "next";
import { Suspense } from "react";
import { Container } from "@/components/Container";
import { Search } from "@/components/Search";
import { SearchPage } from "./SearchPage";
const Page: NextPage = () => {
return (
<>
<Suspense
fallback={
<Container>
<Search />
</Container>
}
>
<SearchPage />
</Suspense>
</>
);
};
export default Page;

@ -0,0 +1,35 @@
"use client";
import { useRouter } from "next/navigation";
import { Autocomplete, AutocompleteItem } from "@nextui-org/autocomplete";
import { Region } from "@/utils/getRegionCodes";
import { Component } from "@/typings/component";
export const RegionSwitcher: Component<{
regions: Region[];
currentRegion: Region | null;
}> = ({ currentRegion, regions }) => {
const router = useRouter();
return (
<Autocomplete
defaultItems={regions}
label="Region"
placeholder="Select your region"
isClearable={false}
selectedKey={currentRegion?.code}
onSelectionChange={(key) => {
if (typeof key === "string" && key.length != 0)
return router.push(`/trending?region=${key}`);
}}
className="max-w-xs"
>
{(item) => (
<AutocompleteItem key={item.code}>{item.name}</AutocompleteItem>
)}
</Autocomplete>
);
};

@ -0,0 +1,108 @@
"use client";
import { defaultRegion } from "@/constants";
import { useQuery } from "@tanstack/react-query";
import { useSearchParams } from "next/navigation";
import { useMemo } from "react";
import { Button } from "@nextui-org/button";
import { Spacer } from "@nextui-org/spacer";
import { useClient } from "@/hooks/useClient";
import getRegionCodes from "@/utils/getRegionCodes";
import { Container } from "@/components/Container";
import { LoadingPage } from "@/components/LoadingPage";
import { Video } from "@/components/Video";
import { RegionSwitcher } from "./RegionSwitcher";
import { Component } from "@/typings/component";
export const Trending: Component = ({}) => {
const client = useClient();
const searchParams = useSearchParams();
const validRegions = useMemo(() => getRegionCodes(), []);
const specifiedRegion =
searchParams.get("region")?.toUpperCase() ?? defaultRegion;
const [region, regionError] = useMemo(() => {
const foundRegion = validRegions.find(
(validRegion) => validRegion.code === specifiedRegion
);
if (foundRegion === undefined)
return [null, new Error(`Region \`${specifiedRegion}\` is invalid`)];
return [foundRegion, null];
}, [specifiedRegion, validRegions]);
const {
isLoading,
error: fetchError,
refetch,
data
} = useQuery({
queryKey: ["trending", region],
queryFn: () => {
if (region === null) return;
return client.getTrending(region.code);
},
enabled: regionError === null
});
const noDataError = useMemo(() => {
if (data && data.length === 0)
return new Error(
`Could not find any trending video's in region \`${region?.name}\``
);
return null;
}, [data, region]);
const error: Error | null = regionError ?? fetchError ?? noDataError ?? null;
return (
<>
<Container>
<div className="flex flex-row items-center gap-4">
<RegionSwitcher currentRegion={region} regions={validRegions} />
<h1 className="text-xl">Trending</h1>
</div>
{isLoading && !data && (
<LoadingPage
text={`Loading trending page for region \`${region?.name}\``}
/>
)}
{error !== null && (
<div className="flex-1 flex items-center justify-center">
<div className="text-center">
<h1 className="text-xl">
An error occurred loading the trending page
</h1>
<h2 className="text-lg">{error.toString()}</h2>
<Spacer y={2} />
<Button color="primary" onClick={() => refetch()}>
Retry
</Button>
</div>
</div>
)}
{data && data.length !== 0 && error === null && (
<div className="grid gap-4 py-4 grid-cols-1 md:grid-cols-2 lg:grid-cols-3">
{data.map((video) => (
<Video key={video.id} data={video} />
))}
</div>
)}
</Container>
</>
);
};

@ -0,0 +1,25 @@
import { NextPage } from "next";
import { Suspense } from "react";
import { Container } from "@/components/Container";
import { LoadingPage } from "@/components/LoadingPage";
import { Trending } from "./Trending";
const Page: NextPage = () => {
return (
<>
<Suspense
fallback={
<Container>
<LoadingPage text="Loading trending page" />
</Container>
}
>
<Trending />
</Suspense>
</>
);
};
export default Page;

@ -0,0 +1,227 @@
import { useQuery } from "@tanstack/react-query";
import NextLink from "next/link";
import { FC, useMemo, useState } from "react";
import {
FiHeart as HeartIcon,
FiThumbsUp as LikeIcon,
FiLock as PinnedIcon,
FiCornerDownRight as ShowRepliesIcon,
FiSlash as SlashIcon,
FiCheck as UploaderIcon
} from "react-icons/fi";
import { Avatar } from "@nextui-org/avatar";
import { Button } from "@nextui-org/button";
import { Chip } from "@nextui-org/chip";
import { Divider } from "@nextui-org/divider";
import { Link } from "@nextui-org/link";
import { CircularProgress } from "@nextui-org/progress";
import { Tooltip } from "@nextui-org/tooltip";
import { useClient } from "@/hooks/useClient";
import { Author } from "@/client/typings/author";
import {
Comment as CommentProps,
Comments as CommentsProps
} from "@/client/typings/comment";
import formatBigNumber from "@/utils/formatBigNumber";
import formatUploadedTime from "@/utils/formatUploadedTime";
import { highlight } from "@/utils/highlight";
import { channelUrl } from "@/utils/urls";
import { HighlightRenderer } from "./HighlightRenderer";
const Comment: FC<{
data: CommentProps;
videoUploader: Author;
videoId: string;
}> = ({ data, videoUploader, videoId }) => {
const message = useMemo(() => highlight(data.message), [data.message]);
const client = useClient();
const [showReplies, setShowReplies] = useState(false);
const {
data: replies,
error: repliesError,
refetch: refetchReplies,
isLoading: isLoadingReplies
} = useQuery({
queryKey: ["replies", videoId, data.repliesToken],
queryFn: () => {
return client.getComments(videoId, data.repliesToken);
},
enabled: showReplies && !!data.repliesToken
});
const userUrl = data.author.id ? channelUrl(data.author.id) : "#";
return (
<div className="flex flex-row gap-4">
<div>
<Link as={NextLink} href={userUrl}>
<Avatar
isBordered
size="lg"
showFallback
src={data.author.avatar}
name={data.author.name}
/>
</Link>
</div>
<div className="flex flex-col gap-2 w-full">
<div className="flex flex-row gap-2">
<Link as={NextLink} href={userUrl}>
<p className="font-semibold text-default-foreground">
{data.author.name}
</p>
</Link>
{data.author.id === videoUploader.id && (
<Chip
className="pl-2"
startContent={<UploaderIcon />}
color="primary"
>
Uploader
</Chip>
)}
{data.pinned && (
<Chip
className="pl-2"
startContent={<PinnedIcon />}
color="primary"
>
Pinned
</Chip>
)}
</div>
<p>
<HighlightRenderer highlighted={message} />
</p>
<div className="flex flex-row gap-4 items-center">
<div className="flex flex-row tracking-tight text-default-500 items-center gap-1">
<LikeIcon />
<p>{formatBigNumber(data.likes)} likes</p>
</div>
<div className="tracking-tight text-default-500">
<p>{formatUploadedTime(data.written)}</p>
</div>
{data.videoUploaderLiked && (
<div className="flex items-center">
<Tooltip content="Uploader liked" showArrow>
<p className="text-danger text-xl">
<HeartIcon />
</p>
</Tooltip>
</div>
)}
{data.videoUploaderReplied && (
<div>
<Avatar
size="sm"
src={videoUploader.avatar}
name={videoUploader.name}
showFallback
/>
</div>
)}
{data.edited && (
<p className="tracking-tight text-default-500">(edited)</p>
)}
{data.repliesToken && (
<Button
startContent={<ShowRepliesIcon />}
variant="light"
onClick={() => setShowReplies((state) => !state)}
>
{showReplies ? "Hide replies" : "Show replies"}
</Button>
)}
</div>
{showReplies && (
<div className="flex flex-col gap-4">
<Comments
data={replies}
isLoading={isLoadingReplies}
error={repliesError}
refetch={refetchReplies}
videoUploader={videoUploader}
videoId={videoId}
/>
</div>
)}
</div>
</div>
);
};
export const Comments: FC<{
data?: CommentsProps;
isLoading: boolean;
error: Error | null;
refetch: () => void;
videoUploader: Author;
videoId: string;
}> = ({ data, isLoading, error, refetch, videoUploader, videoId }) => {
return (
<>
{data && (
<>
<p className="text-xl">
{data.count && formatBigNumber(data.count)} Comments
</p>
<Divider orientation="horizontal" />
<div className="flex flex-col gap-4">
{data.enabled && (
<>
{data.data.map((comment) => (
<Comment
key={comment.id}
data={comment}
videoUploader={videoUploader}
videoId={videoId}
/>
))}
</>
)}
{!data.enabled && (
<div className="flex flex-row gap-2 items-center">
<SlashIcon />
<p>Comments on this video are disabled</p>
</div>
)}
</div>
</>
)}
{!data && isLoading && (
<div className="h-24 w-full justify-center items-center flex">
<CircularProgress aria-label="Loading comments..." />
</div>
)}
{error && (
<div className="flex flex-col gap-2">
<p className="text-lg font-semibold">Failed to load comments:</p>
{error.toString()}
<div>
<Button color="primary" onClick={() => refetch()}>
Retry
</Button>
</div>
</div>
)}
</>
);
};

@ -0,0 +1,66 @@
import sanitizeHtml from "sanitize-html";
import { useMemo, useState } from "react";
import {
FiChevronUp as CollapseIcon,
FiChevronDown as ExpandIcon
} from "react-icons/fi";
import { Button } from "@nextui-org/button";
import { highlight } from "@/utils/highlight";
import { HighlightRenderer } from "./HighlightRenderer";
import { Component } from "@/typings/component";
const shortenedDescriptionLength = 200;
export const Description: Component<{ data: string }> = ({ data }) => {
const [expandedDescription, setExpandedDescription] = useState(false);
const sanitizedDescription = useMemo(
() =>
sanitizeHtml(data, {
allowedTags: ["a", "br"],
allowedAttributes: {
a: ["href"]
}
}),
[data]
);
const descriptionAlreadyShort =
sanitizedDescription.length <= shortenedDescriptionLength;
const descriptionCut = useMemo(() => {
if (descriptionAlreadyShort || expandedDescription)
return sanitizedDescription;
else
return (
sanitizedDescription.substring(0, shortenedDescriptionLength) + "..."
);
}, [sanitizedDescription, descriptionAlreadyShort, expandedDescription]);
const description = useMemo(
() => highlight(descriptionCut),
[descriptionCut]
);
return (
<div>
<h2 className="text-ellipsis overflow-y-hidden">
<HighlightRenderer highlighted={description} />
</h2>
{!descriptionAlreadyShort && (
<Button
startContent={expandedDescription ? <CollapseIcon /> : <ExpandIcon />}
variant="light"
onClick={() => setExpandedDescription((state) => !state)}
>
{expandedDescription ? "Show less" : "Show more"}
</Button>
)}
</div>
);
};

@ -0,0 +1,38 @@
import { FC, Fragment } from "react";
import { Link } from "@nextui-org/link";
import formatDuration from "@/utils/formatDuration";
import { Item, ItemType } from "@/utils/highlight";
export const HighlightRenderer: FC<{ highlighted: Item[] }> = ({
highlighted
}) => {
return (
<>
{highlighted.map((item) => {
switch (item.type) {
case ItemType.Tokens:
return <Fragment key={item.id}>{item.content}</Fragment>;
case ItemType.Link:
return (
<Link key={item.id} href={item.href}>
{item.text ?? item.href}
</Link>
);
case ItemType.Timestamp:
return (
<Link key={item.id} href="">
{formatDuration(item.duration * 1000)}
</Link>
);
case ItemType.Linebreak:
return <br key={item.id} />;
}
})}
</>
);
};

@ -0,0 +1,393 @@
"use client";
import screenfull from "screenfull";
import { useDebounce } from "use-debounce";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useHotkeys } from "react-hotkeys-hook";
import {
FiMaximize as MaximizeIcon,
FiMinimize as MinimizeIcon,
FiVolumeX as MutedIcon,
FiPause as PauseIcon,
FiPlay as PlayIcon,
FiSettings as SettingsIcon,
FiVolume as VolumeIcon,
FiVolume1 as VolumeIcon1,
FiVolume2 as VolumeIcon2
} from "react-icons/fi";
import ReactPlayer from "react-player";
import { Button } from "@nextui-org/button";
import {
Dropdown,
DropdownItem,
DropdownMenu,
DropdownTrigger
} from "@nextui-org/dropdown";
import { Slider } from "@nextui-org/slider";
import { HlsStream, Stream, StreamType } from "@/client/typings/stream";
import { Video } from "@/client/typings/video";
import formatDuration from "@/utils/formatDuration";
import { Component } from "@/typings/component";
export const Player: Component<{
streams: Stream[];
video: Video;
initialTimestamp?: number;
}> = ({ streams, initialTimestamp, video }) => {
const stream = streams.find((stream) => stream.type === StreamType.Hls);
const playerRef = useRef<ReactPlayer>(null);
const videoPlayerId = "video-player";
// TODO: Make framerate based on video, not a set number
const framerate = 60;
const volumeIcons = useMemo(
() => [
<VolumeIcon key="vol" />,
<VolumeIcon1 key="vol1" />,
<VolumeIcon2 key="vol2" />
],
[]
);
const [playbackRateMenuItems, playbackRateCategories] = useMemo(() => {
const categories = [0.25, 0.5, 1, 1.25, 1.5, 2];
return [
categories.map((speed) => ({
key: speed,
label: speed.toString()
})),
categories
];
}, []);
const [showOverlay, setShowOverlay] = useState(false);
const [mouseOnOverlay, setMouseOnOverlay] = useState([0, 0]);
useEffect(() => {
setShowOverlay(true);
}, [mouseOnOverlay]);
const [mouseLastMoved] = useDebounce(mouseOnOverlay, 2000);
useEffect(() => {
setShowOverlay(false);
}, [mouseLastMoved]);
const [duration, setDuration] = useState(video.duration / 1000);
const [progress, setProgress] = useState(0);
const [loaded, setLoaded] = useState(0);
const [maximized, setMaximized] = useState(false);
const [volume, setVolume] = useState(40);
const [muted, setMuted] = useState(false);
const [playbackRate, setPlaybackRate] = useState(1.0);
const [playing, setPlaying] = useState(false);
const [userSetProgress, setUserSetProgress] = useState(0);
const seekForward = useCallback(
(seconds: number) => {
if (duration >= seconds) {
let newProgress = progress + seconds / duration;
if (newProgress <= 0) newProgress = 0;
if (newProgress >= duration) newProgress = duration;
setUserSetProgress(newProgress);
setProgress(newProgress);
}
},
[progress, duration]
);
const increaseVolume = useCallback((amount: number) => {
setVolume((state) => {
const newVolume = state + amount;
if (newVolume >= 0 && newVolume <= 100) return newVolume;
else return state;
});
}, []);
const increasePlaybackRate = useCallback(
(amount: number) => {
const indexOfCurrentRate = playbackRateCategories.indexOf(playbackRate);
if (indexOfCurrentRate < 0) return;
const newRateIndex = indexOfCurrentRate + amount;
if (newRateIndex < 0 || newRateIndex > playbackRateCategories.length - 1)
return;
setPlaybackRate(playbackRateCategories[newRateIndex]);
},
[playbackRate, playbackRateCategories]
);
useHotkeys(["k", "space"], () => setPlaying((state) => !state), {
preventDefault: true
});
useHotkeys(["f"], () => setMaximized((state) => !state));
useHotkeys(["m"], () => setMuted((state) => !state));
useHotkeys(["arrowup"], () => increaseVolume(5), { preventDefault: true });
useHotkeys(["arrowdown"], () => increaseVolume(-5), { preventDefault: true });
useHotkeys(["arrowright"], () => seekForward(5));
useHotkeys(["arrowleft"], () => seekForward(-5));
useHotkeys(["shift+."], () => increasePlaybackRate(1));
useHotkeys(["shift+,"], () => increasePlaybackRate(-1));
useHotkeys(["l"], () => seekForward(10));
useHotkeys(["j"], () => seekForward(-10));
useHotkeys(
["."],
() => {
if (!playing) seekForward(1 / framerate);
},
[seekForward, playing, framerate]
);
useHotkeys(
[","],
() => {
if (!playing) seekForward(-(1 / framerate));
},
[seekForward, playing, framerate]
);
// Mute if volume is 0
useEffect(() => {
if (volume === 0) setMuted(true);
else setMuted(false);
}, [volume]);
useEffect(() => {
playerRef.current?.seekTo(userSetProgress);
}, [userSetProgress]);
useEffect(() => {
if (initialTimestamp && initialTimestamp <= duration)
setUserSetProgress(initialTimestamp / duration);
}, [initialTimestamp, duration]);
const updateMaximized = useCallback(() => {
setMaximized(screenfull.isFullscreen);
}, [setMaximized]);
useEffect(() => {
if (screenfull.isEnabled) {
screenfull.on("change", updateMaximized);
}
return () => screenfull.off("change", updateMaximized);
}, [updateMaximized]);
useEffect(() => {
if (screenfull.isEnabled) {
const playerElement = document.getElementById(videoPlayerId) ?? undefined;
if (maximized) screenfull.request(playerElement);
else screenfull.exit();
}
}, [maximized]);
return (
<>
<div className="relative" style={{ paddingTop: `${100 / (16 / 9)}%` }}>
<div id={videoPlayerId}>
<div
className="flex flex-col w-full h-full absolute bottom-0 z-10 transition-opacity ease-in duration-[2000ms]"
style={{
opacity: showOverlay ? 100 : 0,
cursor: showOverlay ? "initial" : "none"
}}
onMouseMove={(e) => setMouseOnOverlay([e.movementX, e.movementY])}
>
<div
style={{
display: maximized ? "flex" : "none",
background:
"linear-gradient(180deg, rgba(0,0,0,0.8) 0%, rgba(0,0,0,0) 100%)"
}}
className="flex-row gap-2 p-4"
>
<p className="text-3xl">{video.title}</p>
</div>
<div
className="flex-1"
onClick={() => setPlaying((state) => !state)}
/>
<div
className="flex flex-col gap-1 pb-2 px-4"
style={{
background:
"linear-gradient(0deg, rgba(0,0,0,0.8) 0%, rgba(0,0,0,0) 100%)"
}}
>
<div
className="relative flex items-center"
style={{ height: "24px" }}
>
<Slider
aria-label="Video progress bar"
className="w-full cursor-pointer absolute bottom-0 z-20"
step={0.1}
onChange={(value) => {
if (typeof value === "number") {
setProgress(value / 100);
}
}}
onChangeEnd={(value) => {
if (typeof value === "number") {
setUserSetProgress(value / 100);
}
}}
value={progress * 100}
/>
<div
className="h-3 bg-default-600/50 z-10 rounded-lg"
style={{ width: `${loaded * 100}%` }}
/>
</div>
<div className="flex flex-row items-center gap-4">
<div className="flex flex-1 flex-row gap-2 items-center">
<Button
variant="light"
isIconOnly
className="text-xl"
onClick={() => setPlaying((state) => !state)}
>
{playing ? <PauseIcon /> : <PlayIcon />}
</Button>
<Dropdown>
<DropdownTrigger>
<Button variant="light" isIconOnly className="text-xl">
{muted ? (
<MutedIcon />
) : (
volumeIcons[
Math.floor((volume / 100) * volumeIcons.length)
]
)}
</Button>
</DropdownTrigger>
<DropdownMenu aria-label="Volume menu">
<DropdownItem>
<Slider
aria-label="Volume slider"
className="h-48"
value={volume}
onChange={(value) => {
if (typeof value === "number") setVolume(value);
}}
orientation="vertical"
/>
</DropdownItem>
</DropdownMenu>
</Dropdown>
<p>
{formatDuration(progress * duration * 1000)} /{" "}
{formatDuration(duration * 1000)}
</p>
</div>
<div className="flex flex-row gap-2 items-center">
<Dropdown>
<DropdownTrigger>
<Button className="text-xl" isIconOnly variant="light">
<SettingsIcon />
</Button>
</DropdownTrigger>
<DropdownMenu
aria-label="Playback rate menu"
onAction={(key) => {
setPlaybackRate(parseFloat(key as string));
}}
items={playbackRateMenuItems}
>
{(item) => (
<DropdownItem key={item.key}>
{item.label}x
</DropdownItem>
)}
</DropdownMenu>
</Dropdown>
<Dropdown>
<DropdownTrigger>
<Button className="text-xl" variant="light">
{playbackRate}x
</Button>
</DropdownTrigger>
<DropdownMenu
aria-label="Playback rate menu"
onAction={(key) => {
setPlaybackRate(parseFloat(key as string));
}}
items={playbackRateMenuItems}
>
{(item) => (
<DropdownItem key={item.key}>
{item.label}x
</DropdownItem>
)}
</DropdownMenu>
</Dropdown>
<Button
variant="light"
isIconOnly
className="text-xl"
onClick={() => setMaximized((state) => !state)}
>
{maximized ? <MinimizeIcon /> : <MaximizeIcon />}
</Button>
</div>
</div>
</div>
</div>
{stream && (
<ReactPlayer
playing={playing}
volume={volume / 100}
muted={muted}
playbackRate={playbackRate}
ref={playerRef}
className="absolute top-0 left-0"
width="100%"
height="100%"
onPause={() => setPlaying(false)}
onPlay={() => setPlaying(true)}
onDuration={(duration) => setDuration(duration)}
// onBuffer={({}) => {}}
onProgress={({ played, loaded }) => {
setProgress(played);
setLoaded(loaded);
}}
// onPlaybackQualityChange={(e: unknown) =>
// console.log("onPlaybackQualityChange", e)
// }
url={(stream as HlsStream).url}
/>
)}
</div>
</div>
</>
);
};

@ -0,0 +1,23 @@
"use client";
import { Item } from "@/client/typings/item";
import { Video } from "@/components/Video";
import { Component } from "@/typings/component";
export const Related: Component<{ data: Item[] }> = ({ data }) => {
return (
<div className="flex flex-col gap-4">
{data.map((item) => {
switch (item.type) {
case "video":
return <Video key={item.id} data={item} size={25} />;
default:
return <></>;
}
})}
</div>
);
};

@ -0,0 +1,150 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { useSearchParams } from "next/navigation";
import { useMemo } from "react";
import {
FiThumbsDown as DislikeIcon,
FiThumbsUp as LikeIcon,
FiEye as ViewIcon
} from "react-icons/fi";
import { Chip } from "@nextui-org/chip";
import { useClient } from "@/hooks/useClient";
import formatBigNumber from "@/utils/formatBigNumber";
import { Author } from "@/components/Author";
import { Container } from "@/components/Container";
import { LoadingPage } from "@/components/LoadingPage";
import { Comments } from "./Comments";
import { Description } from "./Description";
import { Player } from "./Player";
import { Related } from "./Related";
import { Component } from "@/typings/component";
// TODO: Make all keywords visible in some way
const maxKeyWords = 3;
export const Watch: Component = () => {
const client = useClient();
const searchParams = useSearchParams();
const videoId = useMemo(() => {
const param = searchParams.get("v");
if (param === null) return;
return param;
}, [searchParams]);
const timestamp = useMemo(() => {
const param = searchParams.get("t");
if (param === null) return;
const time = parseInt(param);
if (isNaN(time)) return;
return time;
}, [searchParams]);
const { data, isLoading, error } = useQuery({
queryKey: ["watch", videoId],
queryFn: () => {
if (!videoId) return;
return client.getWatchable(videoId);
},
enabled: !!videoId
});
const {
data: comments,
isLoading: isLoadingComments,
refetch: refetchComments,
error: commentsError
} = useQuery({
queryKey: ["comments", videoId],
queryFn: () => {
if (!videoId) return;
return client.getComments(videoId);
},
enabled: !!videoId
});
if (error) console.log(error);
return (
<Container>
{isLoading && <LoadingPage />}
{data && !isLoading && (
<div className="flex flex-col gap-4">
<Player
initialTimestamp={timestamp}
video={data.video}
streams={data.streams}
/>
<div className="flex flex-col xl:flex-row gap-4">
<div className="flex flex-1 flex-col gap-4">
<div className="flex flex-col">
<h1 className="text-2xl">{data.video.title}</h1>
<div className="flex flex-row gap-4 text-lg tracking-tight text-default-500">
<div className="flex flex-row gap-1 items-center">
<ViewIcon />
<p>{formatBigNumber(data.video.views)} views</p>
</div>
<div className="flex flex-row gap-1 items-center">
<LikeIcon />
<p>{formatBigNumber(data.likes)} likes</p>
</div>
<div className="flex flex-row gap-1 items-center">
<DislikeIcon />
<p>{formatBigNumber(data.dislikes)} dislikes</p>
</div>
</div>
</div>
<Author data={data.video.author} />
<Description data={data.video.description ?? ""} />
<div className="flex flex-row gap-2">
<h1>Category:</h1>
<h1 className="font-semibold">{data.category}</h1>
</div>
{data.keywords.length !== 0 && (
<div className="flex flex-row gap-2">
<p>Keywords:</p>
<div className="flex flex-row gap-2 whitespace-nowrap overflow-x-scroll">
{data.keywords.slice(0, maxKeyWords).map((keyword) => (
<Chip key={keyword}>{keyword}</Chip>
))}
</div>
</div>
)}
<Comments
data={comments}
error={commentsError}
refetch={refetchComments}
isLoading={isLoadingComments}
videoId={data.video.id}
videoUploader={data.video.author}
/>
</div>
<div className="flex justify-center">
<Related data={data.related} />
</div>
</div>
</div>
)}
</Container>
);
};

@ -0,0 +1,16 @@
import { NextPage } from "next";
import { Suspense } from "react";
import { Watch } from "./Watch";
const Page: NextPage = () => {
return (
<>
<Suspense>
<Watch />
</Suspense>
</>
);
};
export default Page;

@ -0,0 +1,18 @@
import { Suspense } from "react";
import { Nav } from "@/components/Nav";
import { NavClient } from "@/components/Nav/NavClient";
import { Component } from "@/typings/component";
export const Elements: Component = ({ children }) => {
return (
<>
<Suspense fallback={<Nav pathname="" />}>
<NavClient />
</Suspense>
{children}
</>
);
};

@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;

@ -0,0 +1,29 @@
import type { Metadata } from "next";
import "./globals.css";
import { Elements } from "./elements";
import { Providers } from "./providers";
import { Component } from "@/typings/component";
export const metadata: Metadata = {
title: "MaterialTube client",
description:
"MaterialTube is a beautiful and elegant web client for Invidious servers, built using Next.js and NextUI.",
applicationName: "MaterialTube"
};
const RootLayout: Component = ({ children }) => {
return (
<html lang="en" className="dark">
<body>
<Providers>
<Elements>{children}</Elements>
</Providers>
</body>
</html>
);
};
export default RootLayout;

@ -0,0 +1,119 @@
import { FC, useCallback, useEffect } from "react";
import { Listbox, ListboxItem, ListboxSection } from "@nextui-org/listbox";
import useContextMenuStore from "@/hooks/useContextMenuStore";
import { Component } from "@/typings/component";
import {
ContextMenuAction as ContextMenuActionProps,
ContextMenuItemType
} from "@/typings/contextMenu";
const ContextMenuActionComponent: FC<{
item: ContextMenuActionProps;
hideContextMenu: () => void;
}> = ({ item, hideContextMenu }) => {
return (
<ListboxItem
onClick={() => {
if (item.onClick) {
item.onClick();
hideContextMenu();
}
}}
description={item.description}
startContent={item.icon}
showDivider={item.showDivider}
key={item.key}
href={item.href}
>
{item.title}
</ListboxItem>
);
};
const Menu: FC = () => {
const shouldShow = useContextMenuStore((state) => state.show);
const menu = useContextMenuStore((state) => state.items);
const hide = useContextMenuStore((state) => state.hide);
const location = useContextMenuStore((state) => state.location);
const hideIfShown = useCallback(() => {
if (shouldShow) hide();
}, [hide, shouldShow]);
useEffect(() => {
window.addEventListener("click", hideIfShown);
window.addEventListener("scroll", hideIfShown);
return () => {
window.removeEventListener("click", hideIfShown);
window.removeEventListener("scroll", hideIfShown);
};
}, [hideIfShown]);
return (
<div
style={{
top: location.y,
left: location.x,
display: shouldShow ? "block" : "none"
}}
className="bg-background border-small max-w-xs rounded-small border-default-200 absolute z-10"
>
<Listbox aria-label="Context Menu" items={menu}>
{(item) => {
switch (item.type) {
case ContextMenuItemType.Action:
return (
<ContextMenuActionComponent
item={item}
hideContextMenu={hide}
key={item.key}
/>
);
case ContextMenuItemType.Category:
const category = item;
return (
<ListboxSection
title={category.title}
key={category.key}
showDivider={category.showDivider}
>
{category.items.map((item) => (
<ListboxItem
onClick={() => {
if (item.onClick) {
item.onClick();
hide();
}
}}
description={item.description}
startContent={item.icon}
showDivider={item.showDivider}
key={item.key}
href={item.href}
>
{item.title}
</ListboxItem>
))}
</ListboxSection>
);
}
}}
</Listbox>
</div>
);
};
export const ContextMenuProvider: Component = ({ children }) => {
return (
<>
{children}
<Menu />
</>
);
};

@ -0,0 +1,24 @@
"use client";
import { NextUIProvider } from "@nextui-org/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
import { ContextMenuProvider } from "./ContextMenuProvider";
import { Component } from "@/typings/component";
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } }
});
export const Providers: Component = ({ children }) => {
return (
<QueryClientProvider client={queryClient}>
<ReactQueryDevtools initialIsOpen={false} />
<NextUIProvider>
<ContextMenuProvider>{children}</ContextMenuProvider>
</NextUIProvider>
</QueryClientProvider>
);
};

@ -0,0 +1,28 @@
import { Comments } from "@/client/typings/comment";
import { SearchResults } from "@/client/typings/search";
import { SearchOptions } from "@/client/typings/search/options";
import { Suggestions } from "@/client/typings/search/suggestions";
import { Video } from "@/client/typings/video";
import { Watchable } from "@/client/typings/watchable";
export interface ConnectedAdapter {
getTrending(region: string): Promise<Video[]>;
getSearchSuggestions(query: string): Promise<Suggestions>;
getSearch(query: string, options?: SearchOptions): Promise<SearchResults>;
getWatchable(videoId: string): Promise<Watchable>;
getComments(videoId: string, repliesToken?: string): Promise<Comments>;
}
export default interface Adapter {
apiType: ApiType;
connect(url: string): ConnectedAdapter;
}
export enum ApiType {
Piped,
Invidious
}

@ -0,0 +1,159 @@
import path from "path";
import ky from "ky";
import Adapter, { ApiType } from "@/client/adapters";
import Transformer from "./transformer";
import Comments, { CommentsModel } from "./typings/comments";
import Search, { SearchModel } from "./typings/search";
import Suggestions, { SuggestionsModel } from "./typings/search/suggestions";
import Stream, { StreamModel } from "./typings/stream";
import Video, { VideoModel } from "./typings/video";
const apiPath = (...paths: string[]): string =>
path.join("api", "v1", ...paths);
export type TrendingVideoType = "music" | "gaming" | "news" | "movies";
const getTrending = async (
baseUrl: string,
region?: string,
type?: TrendingVideoType
): Promise<Video[]> => {
const url = new URL(apiPath("trending"), baseUrl);
const searchParams = new URLSearchParams();
if (region !== undefined) searchParams.append("region", region);
if (type !== undefined) searchParams.append("type", type);
const response = await ky.get(url, {
searchParams
});
const json = await response.json();
const data = VideoModel.array().parse(json);
return data;
};
const getSearchSuggestions = async (
baseUrl: string,
query: string
): Promise<Suggestions> => {
const url = new URL(apiPath("search", "suggestions"), baseUrl);
const response = await ky.get(url, {
searchParams: { q: query }
});
const json = await response.json();
const data = SuggestionsModel.parse(json);
return data;
};
export interface SearchOptions {
page?: number;
sort_by?: "relevance" | "rating" | "upload_date" | "view_count";
date?: "hour" | "today" | "week" | "month" | "year";
duration?: "short" | "long" | "medium";
type?: "video" | "playlist" | "channel" | "movie" | "show" | "all";
region?: string;
}
const getSearch = async (
baseUrl: string,
query: string,
options?: SearchOptions
): Promise<Search> => {
const url = new URL(apiPath("search"), baseUrl);
const response = await ky.get(url, {
searchParams: { ...options, q: query }
});
const json = await response.json();
const data = SearchModel.parse(json);
return data;
};
const getVideo = async (baseUrl: string, videoId: string): Promise<Stream> => {
const url = new URL(apiPath("videos", videoId), baseUrl);
const response = await ky.get(url);
const json = await response.json();
const data = StreamModel.parse(json);
return data;
};
const getComments = async (
baseUrl: string,
videoId: string,
continuation?: string
): Promise<Comments> => {
const url = new URL(apiPath("comments", videoId), baseUrl);
const searchParams = new URLSearchParams();
searchParams.append("source", "youtube");
if (continuation) searchParams.append("continuation", continuation);
const response = await ky.get(url, {
searchParams
});
const json = await response.json();
const data = CommentsModel.parse(json);
return data;
};
const adapter: Adapter = {
apiType: ApiType.Invidious,
connect(url) {
return {
async getTrending(region) {
return getTrending(url, region).then(Transformer.videos);
},
async getSearchSuggestions(query) {
return getSearchSuggestions(url, query).then(Transformer.suggestions);
},
async getSearch(query, options) {
const page = options?.pageParam ? parseInt(options.pageParam) : 1;
const items = await getSearch(url, query, {
page: page,
type: options?.type
}).then(Transformer.search);
return { items: items, nextCursor: (page + 1).toString() };
},
async getWatchable(videoId) {
return getVideo(url, videoId).then(Transformer.stream);
},
async getComments(videoId, repliesToken) {
return getComments(url, videoId, repliesToken).then(
Transformer.comments
);
}
};
}
};
export default adapter;

@ -0,0 +1,206 @@
import { Comments } from "@/client/typings/comment";
import {
ChannelItem,
Item,
PlaylistItem,
VideoItem
} from "@/client/typings/item";
import { Suggestions } from "@/client/typings/search/suggestions";
import { Stream, StreamType } from "@/client/typings/stream";
import { Video } from "@/client/typings/video";
import { Watchable } from "@/client/typings/watchable";
import { parseSubscriberCount } from "@/utils/parseSubscriberCount";
import InvidiousComments from "./typings/comments";
import InvidiousSearch from "./typings/search";
import InvidiousSuggestions from "./typings/search/suggestions";
import InvidiousStream, {
RecommendedVideo as InvidiousRecommendedVideo
} from "./typings/stream";
import InvidiousThumbnail from "./typings/thumbnail";
import InvidiousVideo from "./typings/video";
export default class Transformer {
private static findBestThumbnail(
thumbnails: InvidiousThumbnail[]
): string | null {
const thumbnail = thumbnails.find(
(thumbnail) =>
thumbnail.quality == "maxresdefault" ||
thumbnail.quality == "default" ||
thumbnail.quality == "medium" ||
thumbnail.quality == "middle"
);
return thumbnail?.url ?? null;
}
private static recommendedVideo(data: InvidiousRecommendedVideo): VideoItem {
const thumbnail = Transformer.findBestThumbnail(data.videoThumbnails);
if (thumbnail === null)
throw new Error(
`Invidious: Missing thumbnail for video with id ${data.videoId}`
);
return {
type: "video",
author: { id: data.authorId, name: data.author },
duration: data.lengthSeconds * 1000,
live: data.liveNow,
id: data.videoId,
title: data.title,
thumbnail: thumbnail,
views: data.viewCount
};
}
public static video(data: InvidiousVideo): Video {
const thumbnail = Transformer.findBestThumbnail(data.videoThumbnails);
if (thumbnail === null)
throw new Error(
`Invidious: Missing thumbnail for video with id ${data.videoId}`
);
return {
author: { id: data.authorId, name: data.author },
duration: data.lengthSeconds * 1000,
description: data.description,
live: data.liveNow,
id: data.videoId,
title: data.title,
thumbnail: thumbnail,
uploaded: new Date(data.published * 1000 ?? 0),
views: data.viewCount
};
}
public static videos(data: InvidiousVideo[]): Video[] {
return data.map(Transformer.video);
}
public static suggestions(data: InvidiousSuggestions): Suggestions {
return data.suggestions;
}
public static search(data: InvidiousSearch): Item[] {
return data.map((result) => {
switch (result.type) {
case "video":
const video: VideoItem = {
...Transformer.video(result),
type: "video"
};
return video;
case "channel":
const channel: ChannelItem = {
type: "channel",
name: result.author,
id: result.authorId,
thumbnail: result.authorThumbnails[0].url,
subscribers: result.subCount,
videos: result.videoCount,
description: result.description
};
return channel;
case "playlist":
const playlist: PlaylistItem = {
type: "playlist",
title: result.title,
author: {
name: result.author,
id: result.authorId
},
id: result.playlistId,
numberOfVideos: result.videoCount,
thumbnail: result.playlistThumbnail,
videos: result.videos.map((video) => {
const thumbnail = Transformer.findBestThumbnail(
video.videoThumbnails
);
if (thumbnail === null)
throw new Error(
`Invidious: Missing thumbnail for video with id ${video.videoId}`
);
return {
title: video.title,
id: video.videoId,
duration: video.lengthSeconds * 1000,
thumbnail: thumbnail
};
})
};
return playlist;
}
});
}
public static stream(data: InvidiousStream): Watchable {
const thumbnail = Transformer.findBestThumbnail(data.videoThumbnails);
if (thumbnail === null)
throw new Error(
`Invidious: Missing thumbnail for video with id ${data.videoId}`
);
const streams: Stream[] = [];
streams.push({ type: StreamType.Dash, url: data.dashUrl });
return {
category: data.genre,
dislikes: data.dislikeCount,
likes: data.likeCount,
keywords: data.keywords,
related: data.recommendedVideos.map(Transformer.recommendedVideo),
streams,
video: {
author: {
id: data.authorId,
name: data.author,
avatar: data.authorThumbnails[0].url,
subscribers: parseSubscriberCount(data.subCountText)
},
description: data.description,
duration: data.lengthSeconds * 1000,
id: data.videoId,
live: data.liveNow,
thumbnail: thumbnail,
title: data.title,
uploaded: new Date(data.published * 1000),
views: data.viewCount
}
};
}
public static comments(comments: InvidiousComments): Comments {
return {
enabled: true,
count: comments.commentCount,
data: comments.comments.map((comment) => ({
id: comment.commentId,
message: comment.content,
likes: comment.likeCount,
edited: comment.isEdited,
written: new Date(comment.published * 1000),
author: {
name: comment.author,
id: comment.authorId,
handle: comment.authorUrl,
avatar: comment.authorThumbnails[0].url
},
videoUploaderLiked: !!comment.creatorHeart,
videoUploaderReplied: false,
pinned: comment.isPinned,
repliesToken: comment.replies?.continuation
}))
};
}
}

@ -0,0 +1,44 @@
import z from "zod";
import { AuthorThumbnailModel } from "./thumbnail";
export const CommentModel = z.object({
author: z.string(),
authorThumbnails: AuthorThumbnailModel.array(),
authorId: z.string(),
authorUrl: z.string(),
isEdited: z.boolean(),
isPinned: z.boolean(),
content: z.string(),
contentHtml: z.string(),
published: z.number(),
publishedText: z.string(),
likeCount: z.number(),
commentId: z.string(),
authorIsChannelOwner: z.boolean(),
creatorHeart: z
.object({
creatorThumbnail: z.string(),
creatorName: z.string()
})
.optional(),
replies: z
.object({
replyCount: z.number(),
continuation: z.string()
})
.optional()
});
export const CommentsModel = z.object({
commentCount: z.number().optional(),
videoId: z.string(),
comments: CommentModel.array(),
continuation: z.string().optional()
});
type Comments = z.infer<typeof CommentsModel>;
export default Comments;

@ -0,0 +1,51 @@
import z from "zod";
import { AuthorThumbnailModel, ThumbnailModel } from "../thumbnail";
import { VideoModel } from "../video";
export const VideoResultModel = z
.object({
type: z.literal("video")
})
.and(VideoModel);
export const ChannelResultModel = z.object({
type: z.literal("channel"),
author: z.string(),
authorId: z.string(),
authorUrl: z.string(),
authorThumbnails: AuthorThumbnailModel.array(),
autoGenerated: z.boolean(),
subCount: z.number(),
videoCount: z.number(),
description: z.string(),
descriptionHtml: z.string()
});
export const PlaylistResultModel = z.object({
type: z.literal("playlist"),
title: z.string(),
playlistId: z.string(),
playlistThumbnail: z.string().url(),
author: z.string(),
authorId: z.string(),
authorUrl: z.string(),
authorVerified: z.boolean(),
videoCount: z.number(),
videos: z
.object({
title: z.string(),
videoId: z.string(),
lengthSeconds: z.number(),
videoThumbnails: ThumbnailModel.array()
})
.array()
});
export const SearchModel = z
.union([PlaylistResultModel, VideoResultModel, ChannelResultModel])
.array();
type Search = z.infer<typeof SearchModel>;
export default Search;

@ -0,0 +1,10 @@
import z from "zod";
export const SuggestionsModel = z.object({
query: z.string(),
suggestions: z.string().array()
});
type Suggestions = z.infer<typeof SuggestionsModel>;
export default Suggestions;

@ -0,0 +1,15 @@
import z from "zod";
export const StoryboardModel = z.object({
url: z.string(),
templateUrl: z.string().url(),
width: z.number(),
height: z.number(),
count: z.number(),
interval: z.number(),
storyboardWidth: z.number(),
storyboardHeight: z.number(),
storyboardCount: z.number()
});
export type Storyboard = z.infer<typeof StoryboardModel>;

@ -0,0 +1,104 @@
import z from "zod";
import { StoryboardModel } from "./storyboard";
import { AuthorThumbnailModel, ThumbnailModel } from "./thumbnail";
export const AdaptiveFormatModel = z.object({
index: z.string(),
bitrate: z.string(),
init: z.string(),
url: z.string().url(),
itag: z.string(),
type: z.string(),
clen: z.string(),
lmt: z.string(),
projectionType: z.number().or(z.string()),
container: z.string().optional(),
encoding: z.string().optional(),
qualityLabel: z.string().optional(),
resolution: z.string().optional(),
audioQuality: z.string().optional(),
audioSampleRate: z.number().optional(),
audioChannels: z.number().optional()
});
export const FormatStreamModel = z.object({
url: z.string().url(),
itag: z.string(),
type: z.string(),
quality: z.string(),
fps: z.number(),
container: z.string(),
encoding: z.string(),
resolution: z.string(),
qualityLabel: z.string(),
size: z.string()
});
export const CaptionModel = z.object({
label: z.string(),
language_code: z.string(),
url: z.string()
});
export const RecommendedVideoModel = z.object({
title: z.string(),
videoId: z.string(),
videoThumbnails: ThumbnailModel.array(),
lengthSeconds: z.number(),
viewCount: z.number(),
author: z.string(),
authorId: z.string(),
authorUrl: z.string(),
liveNow: z.boolean().optional().default(false),
paid: z.boolean().optional().default(false),
premium: z.boolean().optional().default(false)
});
export type RecommendedVideo = z.infer<typeof RecommendedVideoModel>;
export const StreamModel = z.object({
type: z.string(),
title: z.string(),
videoId: z.string(),
videoThumbnails: ThumbnailModel.array(),
storyboards: StoryboardModel.array(),
description: z.string(),
descriptionHtml: z.string(),
published: z.number(),
publishedText: z.string(),
keywords: z.string().array(),
viewCount: z.number(),
likeCount: z.number(),
dislikeCount: z.number(),
paid: z.boolean().optional().default(false),
premium: z.boolean().optional().default(false),
isFamilyFriendly: z.boolean(),
allowedRegions: z.string().array(),
genre: z.string(),
genreUrl: z.string(),
author: z.string(),
authorId: z.string(),
authorUrl: z.string(),
authorVerified: z.boolean(),
authorThumbnails: AuthorThumbnailModel.array(),
subCountText: z.string(),
lengthSeconds: z.number(),
allowRatings: z.boolean(),
rating: z.number(),
isListed: z.boolean(),
liveNow: z.boolean().optional().default(false),
isUpcoming: z.boolean(),
dashUrl: z.string().url(),
adaptiveFormats: AdaptiveFormatModel.array(),
formatStreams: FormatStreamModel.array(),
captions: CaptionModel.array(),
recommendedVideos: RecommendedVideoModel.array()
});
type Stream = z.infer<typeof StreamModel>;
export default Stream;

@ -0,0 +1,30 @@
import z from "zod";
const qualityTypes = [
"maxres",
"maxresdefault",
"sddefault",
"high",
"medium",
"default",
"start",
"middle",
"end"
] as const;
export const AuthorThumbnailModel = z.object({
url: z.string(),
width: z.number(),
height: z.number()
});
export const ThumbnailModel = z.object({
url: z.string().url(),
width: z.number(),
height: z.number(),
quality: z.enum(qualityTypes)
});
type Thumbnail = z.infer<typeof ThumbnailModel>;
export default Thumbnail;

@ -0,0 +1,29 @@
import z from "zod";
import { ThumbnailModel } from "./thumbnail";
export const VideoModel = z.object({
title: z.string(),
videoId: z.string(),
videoThumbnails: ThumbnailModel.array(),
lengthSeconds: z.number(),
viewCount: z.number(),
author: z.string(),
authorId: z.string(),
authorUrl: z.string(),
published: z.number(),
publishedText: z.string().optional(),
description: z.string(),
descriptionHtml: z.string(),
liveNow: z.boolean().optional().default(false),
paid: z.boolean().optional().default(false),
premium: z.boolean().optional().default(false)
});
type Video = z.infer<typeof VideoModel>;
export default Video;

@ -0,0 +1,185 @@
import path from "path";
import ky from "ky";
import z from "zod";
import Adapter, { ApiType } from "@/client/adapters";
import { Suggestions } from "@/client/typings/search/suggestions";
import Transformer from "./transformer";
import Comments, { CommentsModel } from "./typings/comments";
import Search, { SearchModel } from "./typings/search";
import Stream, { StreamModel } from "./typings/stream";
import Video, { VideoModel } from "./typings/video";
const getTrending = async (
apiBaseUrl: string,
region = "US"
): Promise<Video[]> => {
const url = new URL("/trending", apiBaseUrl);
const response = await ky.get(url, {
searchParams: { region: region.toUpperCase() }
});
const json = await response.json();
const data = VideoModel.array().parse(json);
return data;
};
const getSearchSuggestions = async (
apiBaseUrl: string,
query: string
): Promise<Suggestions> => {
const url = new URL("suggestions", apiBaseUrl);
const response = await ky.get(url, {
searchParams: { query: query }
});
const json = await response.json();
const data = z.string().array().parse(json);
return data;
};
export type FilterType =
| "all"
| "videos"
| "channels"
| "playlists"
| "music_videos"
| "music_songs"
| "music_albums"
| "music_playlists"
| "music_artists";
export interface SearchOptions {
filter?: FilterType;
nextpage?: string;
}
const getSearch = async (
apiBaseUrl: string,
query: string,
options?: SearchOptions
): Promise<Search> => {
let url: URL;
const searchParams = new URLSearchParams();
searchParams.append("q", query);
if (options?.nextpage) {
url = new URL(path.join("nextpage", "search"), apiBaseUrl);
searchParams.append("nextpage", options.nextpage);
} else url = new URL("search", apiBaseUrl);
if (options?.filter) searchParams.append("filter", options.filter);
const response = await ky.get(url, {
searchParams
});
const json = await response.json();
const data = SearchModel.parse(json);
return data;
};
const getStream = async (
apiBaseUrl: string,
videoId: string
): Promise<Stream> => {
const url = new URL(path.join("streams", videoId), apiBaseUrl);
const response = await ky.get(url);
const json = await response.json();
const data = StreamModel.parse(json);
return data;
};
const getComments = async (
apiBaseUrl: string,
videoId: string,
nextpage?: string
): Promise<Comments> => {
const searchParams = new URLSearchParams();
let url;
if (nextpage) {
url = new URL(path.join("nextpage", "comments", videoId), apiBaseUrl);
searchParams.append("nextpage", nextpage);
} else url = new URL(path.join("comments", videoId), apiBaseUrl);
const response = await ky.get(url, { searchParams });
const json = await response.json();
const data = CommentsModel.parse(json);
return data;
};
const adapter: Adapter = {
apiType: ApiType.Piped,
connect(url) {
return {
async getTrending(region) {
return getTrending(url, region).then(Transformer.videos);
},
async getSearchSuggestions(query) {
return getSearchSuggestions(url, query);
},
async getSearch(query, options) {
let filter: FilterType;
switch (options?.type) {
default:
filter = "all";
break;
case "channel":
filter = "channels";
break;
case "playlist":
filter = "playlists";
break;
case "video":
filter = "videos";
break;
}
return getSearch(url, query, {
filter: filter,
nextpage: options?.pageParam
}).then(Transformer.search);
},
async getWatchable(videoId) {
return getStream(url, videoId).then((data) =>
Transformer.stream(data, videoId)
);
},
async getComments(videoId, repliesToken?: string) {
return getComments(url, videoId, repliesToken).then(
Transformer.comments
);
}
};
}
};
export default adapter;

@ -0,0 +1,165 @@
import { Comments } from "@/client/typings/comment";
import {
ChannelItem,
Item,
PlaylistItem,
VideoItem
} from "@/client/typings/item";
import { SearchResults } from "@/client/typings/search";
import { Stream, StreamType } from "@/client/typings/stream";
import { Video } from "@/client/typings/video";
import { Watchable } from "@/client/typings/watchable";
import {
parseChannelIdFromUrl,
parseVideoIdFromUrl
} from "@/utils/parseIdFromUrl";
import { parseRelativeTime } from "@/utils/parseRelativeTime";
import PipedComments from "./typings/comments";
import PipedItem from "./typings/item";
import PipedSearch from "./typings/search";
import PipedStream from "./typings/stream";
import PipedVideo from "./typings/video";
export default class Transformer {
private static item(data: PipedItem): Item {
switch (data.type) {
case "stream":
const video: VideoItem = {
...Transformer.video(data),
type: "video"
};
return video;
case "channel":
const id = parseChannelIdFromUrl(data.url);
if (id === null) throw new Error("Piped: Missing channelId");
const channel: ChannelItem = {
type: "channel",
name: data.name,
id: id,
thumbnail: data.thumbnail,
subscribers: data.subscribers,
videos: data.videos,
description: data.description ?? ""
};
return channel;
case "playlist":
const channelId = data.uploaderUrl
? parseChannelIdFromUrl(data.uploaderUrl)
: null;
const playlist: PlaylistItem = {
type: "playlist",
title: data.name,
author: {
name: data.uploaderName,
id: channelId ?? undefined
},
thumbnail: data.thumbnail,
id: data.url,
numberOfVideos: data.videos
};
return playlist;
}
}
public static video(data: PipedVideo): Video {
const videoId = parseVideoIdFromUrl(data.url);
if (videoId === null) throw new Error("Piped: Missing video id");
const channelId = parseChannelIdFromUrl(data.uploaderUrl) ?? undefined;
return {
duration: data.duration * 1000,
views: data.views,
id: videoId,
uploaded: new Date(data.uploaded),
thumbnail: data.thumbnail,
title: data.title,
live: false,
author: {
id: channelId,
name: data.uploaderName,
avatar: data.uploaderAvatar
}
};
}
public static videos(data: PipedVideo[]): Video[] {
return data.map(Transformer.video);
}
public static search(data: PipedSearch): SearchResults {
const items = data.items.map(Transformer.item);
return { items, nextCursor: data.nextpage };
}
public static stream(data: PipedStream, videoId: string): Watchable {
const streams: Stream[] = [];
if (data.dash) streams.push({ type: StreamType.Dash, url: data.dash });
if (data.hls) streams.push({ type: StreamType.Hls, url: data.hls });
return {
category: data.category,
keywords: data.tags,
dislikes: data.dislikes,
likes: data.likes,
related: data.relatedStreams.map(Transformer.item),
streams,
video: {
author: {
id: parseChannelIdFromUrl(data.uploaderUrl) ?? undefined,
name: data.uploader,
avatar: data.uploaderAvatar,
subscribers: data.uploaderSubscriberCount
},
description: data.description,
duration: data.duration * 1000,
id: videoId,
live: data.livestream,
thumbnail: data.thumbnailUrl,
title: data.title,
uploaded: data.uploadDate,
views: data.views
}
};
}
public static comments(data: PipedComments): Comments {
return {
enabled: !data.disabled,
count: data.commentCount,
data: data.comments.map((comment) => ({
id: comment.commentId,
message: comment.commentText,
likes: comment.likeCount,
edited: false,
written: parseRelativeTime(comment.commentedTime).toJSDate(),
author: {
name: comment.author,
id: parseChannelIdFromUrl(comment.commentorUrl) ?? undefined,
avatar: comment.thumbnail,
verified: comment.verified
},
pinned: comment.pinned,
videoUploaderLiked: comment.hearted,
videoUploaderReplied: comment.creatorReplied,
repliesToken: comment.repliesPage ?? undefined
}))
};
}
}

@ -0,0 +1,45 @@
import z from "zod";
export const CommentModel = z.object({
author: z.string().describe("The name of the author of the comment"),
commentId: z.string().describe("The comment ID"),
commentText: z.string().describe("The text of the comment"),
commentedTime: z.string().describe("The time the comment was made"),
commentorUrl: z.string().describe("The URL of the channel of the comment"),
hearted: z.boolean().describe("Whether or not the comment has been hearted"),
likeCount: z.number().describe("The number of likes the comment has"),
pinned: z.boolean().describe("Whether or not the comment is pinned"),
thumbnail: z.string().url().describe("The thumbnail of the comment"),
verified: z
.boolean()
.describe("Whether or not the author of the comment is verified"),
replyCount: z
.number()
.transform((number) => (number < 0 ? 0 : number))
.optional()
.describe("The amount of replies this comment has"),
repliesPage: z
.string()
.nullable()
.describe("The token needed to fetch the replies"),
creatorReplied: z
.boolean()
.describe("Whether the creator has replied to the comment")
});
export const CommentsModel = z.object({
comments: CommentModel.array(), // A list of comments
commentCount: z
.number()
.transform((number) => (number < 0 ? 0 : number))
.optional(),
disabled: z.boolean(), // Whether or not the comments are disabled
nextpage: z
.string()
.nullable()
.describe("A JSON encoded page, which is used for the nextpage endpoint.")
});
type Comments = z.infer<typeof CommentsModel>;
export default Comments;

@ -0,0 +1,42 @@
import z from "zod";
import { VideoModel } from "./video";
export const VideoItemModel = z
.object({
type: z.literal("stream")
})
.and(VideoModel);
export const ChannelItemModel = z.object({
type: z.literal("channel"),
url: z.string(),
name: z.string(),
thumbnail: z.string().url(),
description: z.string().nullable(),
subscribers: z.number(),
videos: z.number(),
verified: z.boolean()
});
export const PlaylistItemModel = z.object({
type: z.literal("playlist"),
url: z.string(),
name: z.string(),
thumbnail: z.string().url(),
uploaderName: z.string(),
uploaderUrl: z.string().nullable(),
uploaderVerified: z.boolean(),
playlistType: z.string(),
videos: z.number()
});
export const ItemModel = z.union([
VideoItemModel,
ChannelItemModel,
PlaylistItemModel
]);
type Item = z.infer<typeof ItemModel>;
export default Item;

@ -0,0 +1,14 @@
import z from "zod";
import { ItemModel } from "../item";
export const SearchModel = z.object({
items: ItemModel.array(),
nextpage: z.string(),
suggestion: z.string().nullable(),
corrected: z.boolean()
});
type Search = z.infer<typeof SearchModel>;
export default Search;

@ -0,0 +1,101 @@
import z from "zod";
import { ItemModel } from "./item";
export const AudioStreamModel = z.object({
url: z.string().url(),
format: z.string(),
quality: z.string(),
mimeType: z.string(),
codec: z.string().nullable(),
audioTrackId: z.string().nullable(),
audioTrackName: z.string().nullable(),
audioTrackType: z.string().nullable(),
audioTrackLocale: z.string().nullable(),
videoOnly: z.boolean(),
itag: z.number(),
bitrate: z.number(),
initStart: z.number(),
initEnd: z.number(),
indexStart: z.number(),
indexEnd: z.number(),
width: z.number(),
height: z.number(),
fps: z.number(),
contentLength: z.number()
});
export const VideoStreamModel = z.object({
url: z.string(),
format: z.string(),
quality: z.string(),
mimeType: z.string(),
codec: z.string().nullable(),
audioTrackId: z.null(),
audioTrackName: z.null(),
audioTrackType: z.null(),
audioTrackLocale: z.null(),
videoOnly: z.boolean(),
itag: z.number(),
bitrate: z.number(),
initStart: z.number(),
initEnd: z.number(),
indexStart: z.number(),
indexEnd: z.number(),
width: z.number(),
height: z.number(),
fps: z.number(),
contentLength: z.number()
});
export const ChapterModel = z.object({
title: z.string(),
image: z.string(),
start: z.number()
});
export const PreviewFrameModel = z.object({
urls: z.array(z.string()),
frameWidth: z.number(),
frameHeight: z.number(),
totalCount: z.number(),
durationPerFrame: z.number(),
framesPerPageX: z.number(),
framesPerPageY: z.number()
});
export const StreamModel = z.object({
title: z.string(),
description: z.string(),
uploadDate: z.coerce.date(),
uploader: z.string(),
uploaderUrl: z.string(),
uploaderAvatar: z.string().url(),
thumbnailUrl: z.string().url(),
hls: z.string().url(),
dash: z.string().url().nullable(),
lbryId: z.string().nullable(),
category: z.string(),
license: z.string(),
visibility: z.string(),
tags: z.array(z.string()),
metaInfo: z.array(z.unknown()),
uploaderVerified: z.boolean(),
duration: z.number(),
views: z.number(),
likes: z.number(),
dislikes: z.number(),
uploaderSubscriberCount: z.number(),
audioStreams: AudioStreamModel.array(),
videoStreams: VideoStreamModel.array(),
relatedStreams: ItemModel.array(),
subtitles: z.array(z.unknown()),
livestream: z.boolean(),
proxyUrl: z.string().url(),
chapters: ChapterModel.array(),
previewFrames: PreviewFrameModel.array()
});
type Stream = z.infer<typeof StreamModel>;
export default Stream;

@ -0,0 +1,19 @@
import z from "zod";
export const VideoModel = z.object({
duration: z.number(), // The duration of the video in seconds
thumbnail: z.string().url(), // The thumbnail of the video
title: z.string(), // The title of the video
uploaded: z.number(),
uploadedDate: z.string().nullable(), // The date the video was uploaded
uploaderName: z.string(),
uploaderAvatar: z.string().url(), // The avatar of the channel of the video
uploaderUrl: z.string(), // The URL of the channel of the video
uploaderVerified: z.boolean(), // Whether or not the channel of the video is verified
url: z.string(), // The URL of the video
views: z.number() // The number of views the video has
});
type Video = z.infer<typeof VideoModel>;
export default Video;

@ -0,0 +1,94 @@
import Adapter, { ApiType, ConnectedAdapter } from "./adapters";
import InvidiousAdapter from "./adapters/invidious";
import PipedAdapter from "./adapters/piped";
import { Comments } from "./typings/comment";
import { SearchResults } from "./typings/search";
import { SearchOptions } from "./typings/search/options";
import { Suggestions } from "./typings/search/suggestions";
import { Video } from "./typings/video";
import { Watchable } from "./typings/watchable";
export interface RemoteApi {
type: ApiType;
baseUrl: string;
score: number;
}
export default class Client {
private apis: RemoteApi[];
private adapters: Adapter[] = [InvidiousAdapter, PipedAdapter];
constructor(
apis: {
type: ApiType;
baseUrl: string;
}[]
) {
this.apis = apis.map((api) => ({ ...api, score: 0 }));
}
private findAdapterForApiType(apiType: ApiType): Adapter {
const adapter = this.adapters.find((adapter) => adapter.apiType == apiType);
if (adapter === undefined)
throw new Error(`Could not find an adapter with api type ${apiType}`);
return adapter;
}
private getBestApi(): RemoteApi {
const randomIndex = Math.floor(Math.random() * this.apis.length);
return this.apis[randomIndex];
}
private getBestAdapter(): ConnectedAdapter {
const api = this.getBestApi();
const adapter = this.findAdapterForApiType(api.type);
return adapter.connect(api.baseUrl);
}
public async getTrending(region: string): Promise<Video[]> {
const adapter = this.getBestAdapter();
return await adapter.getTrending(region);
}
public async getSearchSuggestions(query: string): Promise<Suggestions> {
const adapter = this.getBestAdapter();
return await adapter.getSearchSuggestions(query);
}
public async getSearch(
query: string,
options?: SearchOptions
): Promise<SearchResults> {
const adapter = this.getBestAdapter();
const pageParam =
options?.pageParam?.length === 0 ? undefined : options?.pageParam;
return await adapter.getSearch(query, {
pageParam: pageParam,
type: options?.type ?? "all"
});
}
public async getWatchable(videoId: string): Promise<Watchable> {
const adapter = this.getBestAdapter();
return await adapter.getWatchable(videoId);
}
public async getComments(
videoId: string,
repliesToken?: string
): Promise<Comments> {
const adapter = this.getBestAdapter();
return await adapter.getComments(videoId, repliesToken);
}
}

@ -0,0 +1,8 @@
export interface Author {
name: string;
id?: string;
handle?: string;
avatar?: string;
subscribers?: number;
verified?: boolean;
}

@ -0,0 +1,24 @@
import { Author } from "./author";
export interface Comment {
id: string;
message: string;
likes: number;
edited: boolean;
written: Date;
author: Author;
pinned: boolean;
videoUploaderLiked: boolean;
videoUploaderReplied: boolean;
repliesToken?: string;
}
export interface Comments {
enabled: boolean;
count?: number;
data: Comment[];
}

@ -0,0 +1,31 @@
import { Author } from "./author";
import { Video } from "./video";
export type VideoItem = Video & { type: "video" };
export interface ChannelItem {
type: "channel";
name: string;
id: string;
thumbnail: string;
subscribers: number;
videos: number;
description: string;
}
export interface PlaylistItem {
type: "playlist";
title: string;
id: string;
author: Author;
numberOfVideos: number;
thumbnail: string;
videos?: {
title: string;
id: string;
duration: number;
thumbnail: string;
}[];
}
export type Item = VideoItem | ChannelItem | PlaylistItem;

@ -0,0 +1,6 @@
import { Item } from "../item";
export interface SearchResults {
items: Item[];
nextCursor: string;
}

@ -0,0 +1,12 @@
import z from "zod";
export interface SearchOptions {
pageParam?: string;
type?: SearchType;
}
export const searchTypes = ["video", "playlist", "channel", "all"] as const;
export const SearchTypeModel = z.enum(searchTypes);
export type SearchType = z.infer<typeof SearchTypeModel>;

@ -0,0 +1 @@
export type Suggestions = string[];

@ -0,0 +1,31 @@
export enum StreamType {
Dash,
Hls,
Standard
}
export interface BaseStream {
type: StreamType;
}
export interface DashStream extends BaseStream {
type: StreamType.Dash;
url: string;
}
export interface HlsStream extends BaseStream {
type: StreamType.Hls;
url: string;
}
export interface StandardStream extends BaseStream {
type: StreamType.Standard;
video: VideoStream[];
audio: AudioStream[];
}
export interface VideoStream {}
export interface AudioStream {}
export type Stream = DashStream | HlsStream | StandardStream;

@ -0,0 +1,16 @@
import { Author } from "./author";
export interface Video {
title: string;
id: string;
author: Author;
thumbnail: string;
description?: string;
/*
Duration in milliseconds.
*/
duration: number;
views: number;
uploaded?: Date;
live: boolean;
}

@ -0,0 +1,13 @@
import { Item } from "./item";
import { Stream } from "./stream";
import { Video } from "./video";
export interface Watchable {
video: Video;
streams: Stream[];
keywords: string[];
likes: number;
dislikes: number;
category: string;
related: Item[];
}

@ -0,0 +1,45 @@
import NextLink from "next/link";
import { FC } from "react";
import { FiCheckCircle as VerifiedIcon } from "react-icons/fi";
import { Avatar } from "@nextui-org/avatar";
import { Link } from "@nextui-org/link";
import { Author as AuthorProps } from "@/client/typings/author";
import formatBigNumber from "@/utils/formatBigNumber";
import { channelUrl } from "@/utils/urls";
export const Author: FC<{ data: AuthorProps }> = ({ data }) => {
const url = data.id ? channelUrl(data.id) : "#";
return (
<div className="flex flex-row gap-4 items-center">
{data.avatar && (
<Link as={NextLink} href={url}>
<Avatar
isBordered
name={data.name}
showFallback
size="lg"
src={data.avatar}
alt={data.name}
/>
</Link>
)}
<div className="flex flex-col">
<Link as={NextLink} href={url}>
<div className="flex flex-row gap-1 items-center">
<p className="text-lg text-default-600">{data.name}</p>
<VerifiedIcon className="text-success" />
</div>
</Link>
{data.subscribers && (
<p className="text-default-400 tracking-tight">
{formatBigNumber(data.subscribers)} subscribers
</p>
)}
</div>
</div>
);
};

@ -1,71 +0,0 @@
import Link from "next/link";
import { FC } from "react";
import Avatar from "@mui/material/Avatar";
import Box from "@mui/material/Box";
import Paper from "@mui/material/Paper";
import Typography from "@mui/material/Typography";
import { useTheme } from "@mui/material/styles";
import { abbreviateNumber, formatNumber } from "@src/utils/";
import { ChannelResult } from "@interfaces/api/search";
const Channel: FC<{ channel: ChannelResult }> = ({ channel }) => {
const theme = useTheme();
const thumbnail = channel.authorThumbnails.find(
(thumbnail) => thumbnail.height == 512
)?.url as string;
return (
<Link
passHref
href={{ pathname: "/channel", query: { c: channel.authorId } }}
>
<a>
<Paper sx={{ my: 2 }}>
<Box
sx={{
p: 3,
display: { md: "flex", xs: "block" },
alignItems: "center"
}}
>
<Avatar
sx={{
width: 96,
height: 96,
mx: { md: 3, xs: "auto" },
mb: { md: 0, xs: 2 }
}}
src={thumbnail}
alt={channel.author}
/>
<Box sx={{ textAlign: { md: "left", xs: "center" } }}>
<Typography variant="h5">{channel.author}</Typography>
<Typography
variant="subtitle1"
color={theme.palette.text.secondary}
>
{abbreviateNumber(channel.subCount)} subscribers {" "}
{formatNumber(channel.videoCount)} videos
</Typography>
<Typography
variant="subtitle1"
color={theme.palette.text.secondary}
>
{channel.description}
</Typography>
</Box>
</Box>
</Paper>
</a>
</Link>
);
};
export default Channel;

@ -0,0 +1,22 @@
import { navHeight } from "./Nav";
import { Component } from "@/typings/component";
export const Container: Component<{ navbarOffset?: boolean }> = ({
children,
navbarOffset = true
}) => {
let height;
if (navbarOffset) height = `calc(100vh - ${navHeight}px)`;
else height = "100vh";
return (
<div
style={{ minHeight: height }}
className="container mx-auto py-4 px-2 flex flex-col"
>
{children}
</div>
);
};

@ -0,0 +1,23 @@
import useContextMenuStore from "@/hooks/useContextMenuStore";
import { Component } from "@/typings/component";
import { ContextMenuItem } from "@/typings/contextMenu";
export const ContextMenu: Component<{ menu: ContextMenuItem[] }> = ({
menu,
children
}) => {
const showContextMenu = useContextMenuStore((state) => state.showContextMenu);
return (
<div
onContextMenu={(e) => {
e.preventDefault();
showContextMenu(e.pageX, e.pageY, menu);
}}
>
{children}
</div>
);
};

@ -1,17 +0,0 @@
import { FC } from "react";
import Box from "@mui/material/Box";
import Navbar from "@components/Navbar";
const Layout: FC = ({ children }) => (
<>
<Navbar />
<Box
sx={{ height: { sm: 64, xs: 56 }, display: "block", width: "100%" }}
></Box>
{children}
</>
);
export default Layout;

@ -1,19 +0,0 @@
import { FC } from "react";
import Box from "@mui/material/Box";
import CircularProgress from "@mui/material/CircularProgress";
const Loading: FC = () => (
<Box
sx={{
display: "flex",
justifyContent: "center",
alignItems: "center",
mt: 5
}}
>
<CircularProgress />
</Box>
);
export default Loading;

@ -0,0 +1,16 @@
"use client";
import { FC } from "react";
import { CircularProgress } from "@nextui-org/progress";
export const LoadingPage: FC<{ text?: string }> = ({ text }) => {
return (
<div className="flex flex-1 justify-center items-center">
<div className="flex flex-col gap-2 items-center">
<CircularProgress aria-label="Loading page..." />
{text && <p className="text-xl">{text}</p>}
</div>
</div>
);
};

@ -1,20 +0,0 @@
import Box from "@mui/material/Box";
import { styled } from "@mui/material/styles";
interface ColorBoxProps {
color: string;
}
const ColorBox = styled(Box, {
shouldForwardProp: (prop) => prop !== "color"
})<ColorBoxProps>(({ theme, color }) => ({
width: 24,
height: 24,
backgroundColor: color,
borderRadius: "50%",
borderColor: theme.palette.text.primary,
borderWidth: 2,
borderStyle: "solid"
}));
export default ColorBox;

@ -1,103 +0,0 @@
import { FC } from "react";
import Box from "@mui/material/Box";
import Grid from "@mui/material/Grid";
import Modal from "@mui/material/Modal";
import Typography from "@mui/material/Typography";
import {
blue,
green,
amber,
red,
cyan,
teal,
deepOrange,
indigo,
yellow,
lightBlue,
orange,
lime,
deepPurple,
lightGreen,
pink,
purple
} from "@mui/material/colors";
import { useTheme } from "@mui/material/styles";
import ModalBox from "@components/ModalBox";
const colors = [
red,
deepOrange,
orange,
amber,
yellow,
lime,
lightGreen,
green,
teal,
cyan,
lightBlue,
blue,
indigo,
deepPurple,
purple,
pink
];
const MaterialColorPicker: FC<{
isOpen: boolean;
setState: (isOpen: boolean) => void;
selectedColor: string;
setColor: (color: string) => void;
}> = ({ setState, isOpen, selectedColor, setColor }) => {
const theme = useTheme();
return (
<Modal
open={isOpen}
onClose={() => setState(false)}
aria-labelledby="color-picker"
aria-describedby="Pick a material color"
component="div"
>
<ModalBox>
<Typography gutterBottom variant="h4">
Pick a color
</Typography>
<Grid container spacing={1} columns={12}>
{colors.map((color, i) => (
<Grid item key={i}>
{Object.values(color)
.slice(0, 10)
.map((shade, i) => (
<Box
onClick={() => setColor(shade)}
key={i}
sx={{
width: 24,
height: 24,
backgroundColor: shade,
borderRadius: "50%",
border:
shade == selectedColor
? {
borderColor: theme.palette.text.primary,
borderWidth: 2,
borderStyle: "solid"
}
: null,
cursor: "pointer",
mb: 1
}}
></Box>
))}
</Grid>
))}
</Grid>
</ModalBox>
</Modal>
);
};
export default MaterialColorPicker;

@ -1,17 +0,0 @@
import Box from "@mui/material/Box";
import styled from "@mui/system/styled";
const ModalBox = styled(Box)(({ theme }) => ({
padding: "2rem",
position: "absolute",
left: "50%",
top: "50%",
transform: "translate(-50%, -50%)",
minWidth: "20rem",
backgroundColor: theme.palette.background.paper,
borderRadius: 5,
outline: "none"
}));
export default ModalBox;

@ -0,0 +1,12 @@
"use client";
import { Nav } from ".";
import { usePathname } from "next/navigation";
import { FC } from "react";
export const NavClient: FC = () => {
const pathname = usePathname();
return <Nav pathname={pathname} />;
};

@ -0,0 +1,67 @@
import NextLink from "next/link";
import { FC } from "react";
import { Button } from "@nextui-org/button";
import { Link } from "@nextui-org/link";
import {
Navbar,
NavbarBrand,
NavbarContent,
NavbarItem
} from "@nextui-org/navbar";
export const navHeight = 64;
export const Nav: FC<{ pathname: string }> = ({ pathname }) => {
const navItems = [
{
title: "Trending",
link: "/trending"
},
{
title: "Search",
link: "/results"
},
{
title: "Subscriptions",
link: "/subscriptions"
},
{
title: "History",
link: "/history"
}
];
return (
<Navbar>
<NavbarBrand>
{/* <AcmeLogo /> */}
<p className="font-bold text-inherit">MaterialTube</p>
</NavbarBrand>
<NavbarContent className="hidden sm:flex gap-4" justify="center">
{navItems.map((item) => {
const isActive: boolean = pathname === item.link;
return (
<NavbarItem key={item.title.toLowerCase()} isActive={isActive}>
<Link
as={NextLink}
color={isActive ? "primary" : "foreground"}
href={item.link}
>
{item.title}
</Link>
</NavbarItem>
);
})}
</NavbarContent>
<NavbarContent justify="end">
<NavbarItem>
<Button as={NextLink} color="primary" href="/settings" variant="flat">
Settings
</Button>
</NavbarItem>
</NavbarContent>
</Navbar>
);
};

@ -1,68 +0,0 @@
import packageInfo from "../../../package.json";
import Link from "next/link";
import { FC } from "react";
import Box from "@mui/material/Box";
import Divider from "@mui/material/Divider";
import Drawer from "@mui/material/Drawer";
import List from "@mui/material/List";
import ListItem from "@mui/material/ListItem";
import ListItemIcon from "@mui/material/ListItemIcon";
import ListItemText from "@mui/material/ListItemText";
import Typography from "@mui/material/Typography";
import Settings from "@mui/icons-material/Settings";
const AppDrawer: FC<{
drawerIsOpen: boolean;
toggleDrawer: (
isOpen: boolean
) => (event: React.KeyboardEvent | React.MouseEvent) => void;
width: number;
pages: {
name: string;
icon: JSX.Element;
link: string;
}[];
}> = ({ drawerIsOpen, toggleDrawer, pages, width }) => {
return (
<Drawer anchor="left" open={drawerIsOpen} onClose={toggleDrawer(false)}>
<Box
sx={{ width }}
role="presentation"
onClick={toggleDrawer(false)}
onKeyDown={toggleDrawer(false)}
>
<Box padding={2}>
<Typography variant="h4">
{process.env.NEXT_PUBLIC_APP_NAME}
</Typography>
</Box>
<Divider />
<List>
{pages.map((page, index) => (
<Link key={index} href={page.link} passHref>
<ListItem button>
<ListItemIcon>{page.icon}</ListItemIcon>
<ListItemText primary={page.name} />
</ListItem>
</Link>
))}
<Divider />
<Link href="/settings" passHref>
<ListItem button>
<ListItemIcon>
<Settings />
</ListItemIcon>
<ListItemText primary="Settings" />
</ListItem>
</Link>
</List>
</Box>
</Drawer>
);
};
export default AppDrawer;

@ -1,43 +0,0 @@
import InputBase from "@mui/material/InputBase";
import { alpha, styled } from "@mui/material/styles";
const Search = styled("div")(({ theme }) => ({
position: "relative",
borderRadius: theme.shape.borderRadius,
backgroundColor: alpha(theme.palette.common.white, 0.15),
"&:hover": {
backgroundColor: alpha(theme.palette.common.white, 0.25)
},
marginRight: theme.spacing(2),
marginLeft: 0,
width: "100%",
[theme.breakpoints.up("sm")]: {
marginLeft: theme.spacing(3),
width: "auto"
}
}));
export const SearchIconWrapper = styled("div")(({ theme }) => ({
padding: theme.spacing(0, 2),
height: "100%",
position: "absolute",
pointerEvents: "none",
display: "flex",
alignItems: "center",
justifyContent: "center"
}));
export const StyledInputBase = styled(InputBase)(({ theme }) => ({
color: "inherit",
"& .MuiInputBase-input": {
padding: theme.spacing(1, 1, 1, 0),
paddingLeft: `calc(1em + ${theme.spacing(4)})`,
transition: theme.transitions.create("width"),
width: "100%",
[theme.breakpoints.up("md")]: {
width: "20ch"
}
}
}));
export default Search;

@ -1,194 +0,0 @@
import packageInfo from "../../../package.json";
import Link from "next/link";
import { useRouter } from "next/router";
import { FC, useState } from "react";
import AppBar from "@mui/material/AppBar";
import Box from "@mui/material/Box";
import Button from "@mui/material/Button";
import IconButton from "@mui/material/IconButton";
import Toolbar from "@mui/material/Toolbar";
import Tooltip from "@mui/material/Tooltip";
import Typography from "@mui/material/Typography";
import History from "@mui/icons-material/History";
import Menu from "@mui/icons-material/Menu";
import PlayCircleOutline from "@mui/icons-material/PlayCircleOutline";
import PlaylistAddCheck from "@mui/icons-material/PlaylistAddCheck";
import SearchIcon from "@mui/icons-material/Search";
import Settings from "@mui/icons-material/Settings";
import Subscriptions from "@mui/icons-material/Subscriptions";
import Whatshot from "@mui/icons-material/Whatshot";
import Drawer from "@components/Navbar/Drawer";
import Search, {
SearchIconWrapper,
StyledInputBase
} from "@components/Navbar/Search";
export const drawerWidth = 240;
const Navbar: FC = () => {
const [drawerIsOpen, setDrawerState] = useState(false);
const router = useRouter();
const [search, setSearch] = useState<string | undefined>();
const toggleDrawer =
(open: boolean) => (event: React.KeyboardEvent | React.MouseEvent) => {
if (
event.type === "keydown" &&
((event as React.KeyboardEvent).key === "Tab" ||
(event as React.KeyboardEvent).key === "Shift")
) {
return;
}
setDrawerState(open);
};
const pages = [
{ name: "Trending", icon: <Whatshot />, link: "/trending" },
{
name: "Subscriptions",
icon: <Subscriptions />,
link: "/subscriptions"
},
{
name: "Watch History",
icon: <History />,
link: "/history"
},
{
name: "Playlists",
icon: <PlaylistAddCheck />,
link: "/playlists"
}
];
return (
<>
<Drawer
width={drawerWidth}
drawerIsOpen={drawerIsOpen}
toggleDrawer={toggleDrawer}
pages={pages}
/>
<Box sx={{ flexGrow: 1 }}>
<AppBar position="fixed" enableColorOnDark>
<Toolbar>
<IconButton
size="large"
edge="start"
color="inherit"
aria-label="menu"
sx={{
mr: { md: 2, xs: 0 },
display: { lg: "none", xs: "flex" }
}}
onClick={() => setDrawerState(!drawerIsOpen)}
>
<Menu />
</IconButton>
<Link href="/" passHref>
<a>
<Typography
variant="h6"
component="div"
sx={{
mr: 2,
display: "flex",
alignItems: "center",
cursor: "pointer"
}}
>
<PlayCircleOutline sx={{ mr: 1 }} />
{process.env.NEXT_PUBLIC_APP_NAME}
</Typography>
</a>
</Link>
<Box
sx={{
flexGrow: 1,
display: "flex",
alignItems: "center"
}}
>
{pages.map((page, i) => (
<Link key={i} href={page.link} passHref>
<Tooltip title={`Go to ${page.name}`}>
<Button
sx={{
color: "white",
display: { lg: "flex", xs: "none" },
alignItems: "center"
}}
>
<Box
sx={{ mr: 1, display: "flex", alignItems: "center" }}
>
{page.icon}
</Box>
{page.name}
</Button>
</Tooltip>
</Link>
))}
<Search>
<SearchIconWrapper>
<SearchIcon />
</SearchIconWrapper>
<form
onSubmit={(e) => {
e.preventDefault();
router.push({
pathname: "/results",
query: { search_query: search }
});
}}
method="get"
>
<StyledInputBase
onChange={(e) => setSearch(e.target.value)}
value={search}
name="search_query"
placeholder="Search…"
inputProps={{ "aria-label": "search" }}
/>
</form>
</Search>
</Box>
<Link href="/settings" passHref>
<a
onClick={(e) => {
if (router.pathname == "/settings") {
e.preventDefault();
router.back();
}
}}
>
<IconButton
sx={{ display: { md: "flex", xs: "none" } }}
size="large"
>
<Settings />
</IconButton>
</a>
</Link>
</Toolbar>
</AppBar>
</Box>
</>
);
};
export default Navbar;

@ -1,89 +0,0 @@
import { FC, MutableRefObject } from "react";
import Box from "@mui/material/Box";
import Tooltip from "@mui/material/Tooltip";
import Fullscreen from "@mui/icons-material/Fullscreen";
import Pause from "@mui/icons-material/Pause";
import PlayArrow from "@mui/icons-material/PlayArrow";
import Settings from "@mui/icons-material/Settings";
import SkipNext from "@mui/icons-material/SkipNext";
import Subtitles from "@mui/icons-material/Subtitles";
import VolumeUp from "@mui/icons-material/VolumeUp";
import { VideoStatus } from "@interfaces/videoPlayer";
import useVideoState from "@utils/hooks/useVideoState";
import Time from "@components/Player/Time";
const iconStyles = {
mr: 1.5,
cursor: "pointer"
};
const Actions: FC<{
duration: number;
videoRef: MutableRefObject<HTMLVideoElement | null>;
}> = ({ duration, videoRef }) => {
const togglePlaying = useVideoState((state) => state.togglePlaying);
const playing = useVideoState((state) => state.playing);
const muted = useVideoState((state) => state.muted);
const toggleMuted = useVideoState((state) => state.toggleMuted);
return (
<Box
sx={{
position: "absolute",
display: "flex",
width: "100%",
height: 40,
px: 1.5,
bottom: 5,
left: 0
}}
>
<Box sx={{ flexGrow: 1, display: "flex", alignItems: "center" }}>
<Tooltip title={playing == VideoStatus.Playing ? "Pause" : "Play"}>
<Box
sx={{
...iconStyles,
display: "flex",
alignItems: "center"
}}
onClick={() => togglePlaying()}
>
{playing == VideoStatus.Playing ? <Pause /> : <PlayArrow />}
</Box>
</Tooltip>
<Tooltip title="Play next video">
<SkipNext sx={iconStyles} />
</Tooltip>
<Tooltip title={muted ? "Unmute" : "Mute"}>
<VolumeUp onClick={() => toggleMuted()} sx={iconStyles} />
</Tooltip>
<Time duration={duration} videoRef={videoRef} />
</Box>
<Box sx={{ display: "flex", alignItems: "center" }}>
<Tooltip title="Turn on captions">
<Subtitles sx={iconStyles} />
</Tooltip>
<Tooltip title="Change quality">
<Settings sx={iconStyles} />
</Tooltip>
<Tooltip title="Fullscreen">
<Fullscreen
sx={{
...iconStyles,
transition: "font-size .2s",
"&:hover": { fontSize: "2rem" }
}}
/>
</Tooltip>
</Box>
</Box>
);
};
export default Actions;

@ -1,74 +0,0 @@
import { FC, MutableRefObject, useEffect, useState } from "react";
import Box from "@mui/material/Box";
import { useTheme } from "@mui/material/styles";
import useVideoState from "@utils/hooks/useVideoState";
const ProgressBar: FC<{
duration: number;
videoRef: MutableRefObject<HTMLVideoElement | null>;
}> = ({ videoRef, duration }) => {
const theme = useTheme();
const [buffer, setBuffer] = useState<number>(1);
const height = 5;
const bufferColor = "rgba(200, 200, 200, 0.5)";
const backgroundColor = "rgba(132, 132, 132, 0.5)";
const progress = (useVideoState((state) => state.progress) / duration) * 100;
useEffect(() => {
if (!videoRef.current) return;
const buffered = videoRef.current.buffered;
if (buffered.length != 0) {
const newBuffer =
((buffered.end(0) - buffered.start(0)) / duration) * 100;
if (newBuffer != buffer) {
setBuffer(newBuffer);
}
}
}, [buffer, duration, videoRef, videoRef.current?.buffered]);
return (
<Box
sx={{
position: "absolute",
cursor: "pointer",
width: "98%",
backgroundColor,
height,
left: "1%",
bottom: 45,
"&:hover": {}
}}
>
<Box sx={{ position: "relative" }}>
<Box
sx={{
backgroundColor: theme.palette.primary.main,
position: "absolute",
height,
zIndex: 10
}}
style={{ width: `${progress}%` }}
></Box>
<Box
sx={{
backgroundColor: bufferColor,
position: "absolute",
height
}}
style={{ width: `${buffer}%` }}
></Box>
</Box>
</Box>
);
};
export default ProgressBar;

Some files were not shown because too many files have changed in this diff Show More

Loading…
Cancel
Save