diff --git a/.dockerignore b/.dockerignore
deleted file mode 100644
index d4d96b32..00000000
--- a/.dockerignore
+++ /dev/null
@@ -1,5 +0,0 @@
-.git
-node_modules
-apps/web/node_modules
-apps/web/dist
-**/*.md
diff --git a/.env.example b/.env.example
index 18aefa1e..ca997ced 100644
--- a/.env.example
+++ b/.env.example
@@ -27,7 +27,7 @@ POSTGRES_DB=typetype
POSTGRES_USER=typetype
POSTGRES_PASSWORD=typetype
DRAGONFLY_URL=redis://dragonfly:6379
-GITHUB_REPO=Priveetee/TypeType-Server
+GITHUB_REPO=TypeType-Video/TypeType
GITHUB_ISSUE_TEMPLATE=bug_report_backend.md
DOWNLOADER_S3_ACCESS_KEY=SET_ME_ACCESS_KEY
DOWNLOADER_S3_SECRET_KEY=SET_ME_SECRET_KEY
diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml
index 5da944f7..3eb3b635 100644
--- a/.github/ISSUE_TEMPLATE/config.yml
+++ b/.github/ISSUE_TEMPLATE/config.yml
@@ -1,5 +1,5 @@
blank_issues_enabled: false
contact_links:
- name: Question or help
- url: https://github.com/Priveetee/TypeType/discussions
+ url: https://github.com/TypeType-Video/TypeType/discussions
about: "Ask questions and get help in Discussions ;)"
diff --git a/.github/actionlint.yaml b/.github/actionlint.yaml
new file mode 100644
index 00000000..306a00e8
--- /dev/null
+++ b/.github/actionlint.yaml
@@ -0,0 +1,4 @@
+self-hosted-runner:
+ labels:
+ - arko
+ - typetype
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 778298e5..3fa0b754 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -1,81 +1,34 @@
name: CI
on:
+ push:
+ branches: ["dev", "main"]
pull_request:
branches: ["dev", "main"]
workflow_call:
workflow_dispatch:
+permissions:
+ contents: read
+
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
jobs:
- quality:
- runs-on: [self-hosted, Linux, X64, arko, typetype]
+ validate-pr:
+ if: github.event_name == 'pull_request'
+ runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v6.0.2
-
- - uses: oven-sh/setup-bun@v2.2.0
with:
- bun-version: "1.3.14"
-
- - name: Install dependencies
- run: bun install --frozen-lockfile
-
- - name: Validate release version
- run: |
- version="$(bun -e 'console.log(require("./package.json").version)')"
- web_version="$(bun -e 'console.log(require("./apps/web/package.json").version)')"
- if [[ ! "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
- echo "package.json must contain a stable SemVer version"
- exit 1
- fi
- if [[ "$web_version" != "$version" ]]; then
- echo "package.json and apps/web/package.json versions must match"
- exit 1
- fi
-
- tag="v$version"
- tag_sha="$(git ls-remote --tags origin "refs/tags/$tag" | cut -f1)"
- if [[ -z "$tag_sha" ]]; then
- exit 0
- fi
-
- if [[ "$GITHUB_REF" == "refs/heads/main" && "$tag_sha" == "$GITHUB_SHA" ]]; then
- exit 0
- fi
-
- if [[ "$GITHUB_REF" == "refs/tags/$tag" && "$tag_sha" == "$GITHUB_SHA" ]]; then
- exit 0
- fi
+ submodules: true
+ - run: ./scripts/validate-stack.sh
- echo "$tag already exists; bump package.json and apps/web/package.json before continuing"
- exit 1
-
- - name: Biome check
- run: bun run check
-
- - name: Test
- run: bun run test
-
- - name: Knip
- run: bun run knip
-
- - name: Sherif
- run: bun run sherif
-
- build:
- needs: quality
+ validate-trusted:
+ if: github.event_name != 'pull_request'
runs-on: [self-hosted, Linux, X64, arko, typetype]
steps:
- uses: actions/checkout@v6.0.2
-
- - uses: oven-sh/setup-bun@v2.2.0
with:
- bun-version: "1.3.14"
-
- - name: Install dependencies
- run: bun install --frozen-lockfile
-
- - name: Build
- run: bun run build
+ submodules: true
+ - run: ./scripts/validate-stack.sh
diff --git a/.github/workflows/component-image.yml b/.github/workflows/component-image.yml
new file mode 100644
index 00000000..972b9796
--- /dev/null
+++ b/.github/workflows/component-image.yml
@@ -0,0 +1,98 @@
+name: Component image
+
+on:
+ repository_dispatch:
+ types: [component-image]
+ workflow_dispatch:
+
+permissions:
+ contents: read
+
+env:
+ FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
+
+concurrency:
+ group: typetype-beta-coordination
+ cancel-in-progress: false
+
+jobs:
+ validate:
+ runs-on: [self-hosted, Linux, X64, arko, typetype]
+ outputs:
+ channel: ${{ steps.payload.outputs.channel }}
+ component: ${{ steps.payload.outputs.component }}
+ digest: ${{ steps.payload.outputs.digest }}
+ image: ${{ steps.payload.outputs.image }}
+ revision: ${{ steps.payload.outputs.revision }}
+ version: ${{ steps.payload.outputs.version }}
+ steps:
+ - name: Validate payload
+ id: payload
+ env:
+ EVENT_NAME: ${{ github.event_name }}
+ PAYLOAD: ${{ toJSON(github.event.client_payload) }}
+ run: |
+ if [[ "$EVENT_NAME" == "workflow_dispatch" ]]; then
+ {
+ echo "channel=beta"
+ echo "component=all"
+ echo "digest=manual"
+ echo "image=all beta images"
+ echo "revision=$GITHUB_SHA"
+ echo "version=manual"
+ } >> "$GITHUB_OUTPUT"
+ exit 0
+ fi
+
+ channel="$(jq -er '.channel' <<< "$PAYLOAD")"
+ component="$(jq -er '.component' <<< "$PAYLOAD")"
+ digest="$(jq -er '.digest' <<< "$PAYLOAD")"
+ image="$(jq -er '.image' <<< "$PAYLOAD")"
+ revision="$(jq -er '.revision' <<< "$PAYLOAD")"
+ version="$(jq -er '.version' <<< "$PAYLOAD")"
+
+ case "$component" in
+ frontend) image_name="typetype" ;;
+ server) image_name="typetype-server" ;;
+ downloader) image_name="typetype-downloader" ;;
+ token) image_name="typetype-token" ;;
+ *) exit 64 ;;
+ esac
+ case "$channel" in
+ beta) expected_image="ghcr.io/typetype-video/${image_name}-beta" ;;
+ stable) expected_image="ghcr.io/typetype-video/${image_name}" ;;
+ *) exit 64 ;;
+ esac
+
+ [[ "$image" == "$expected_image" ]]
+ [[ "$digest" =~ ^sha256:[0-9a-f]{64}$ ]]
+ [[ "$revision" =~ ^[0-9a-f]{40}$ ]]
+ [[ "$version" =~ ^[0-9A-Za-z][0-9A-Za-z._+-]{0,127}$ ]]
+
+ {
+ echo "channel=$channel"
+ echo "component=$component"
+ echo "digest=$digest"
+ echo "image=$image"
+ echo "revision=$revision"
+ echo "version=$version"
+ } >> "$GITHUB_OUTPUT"
+
+ - name: Record component version
+ env:
+ CHANNEL: ${{ steps.payload.outputs.channel }}
+ COMPONENT: ${{ steps.payload.outputs.component }}
+ DIGEST: ${{ steps.payload.outputs.digest }}
+ IMAGE: ${{ steps.payload.outputs.image }}
+ REVISION: ${{ steps.payload.outputs.revision }}
+ VERSION: ${{ steps.payload.outputs.version }}
+ run: |
+ {
+ echo "## Component image"
+ echo
+ echo "- Channel: \`$CHANNEL\`"
+ echo "- Component: \`$COMPONENT\`"
+ echo "- Version: \`$VERSION\`"
+ echo "- Revision: \`$REVISION\`"
+ echo "- Image: \`$IMAGE@$DIGEST\`"
+ } >> "$GITHUB_STEP_SUMMARY"
diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml
deleted file mode 100644
index cf498419..00000000
--- a/.github/workflows/coverage.yml
+++ /dev/null
@@ -1,33 +0,0 @@
-name: Coverage
-
-on:
- push:
- branches: ["dev"]
- pull_request:
- branches: ["dev", "main"]
-
-env:
- FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
-
-jobs:
- coverage:
- runs-on: [self-hosted, Linux, X64, arko, typetype]
- steps:
- - uses: actions/checkout@v6.0.2
-
- - uses: oven-sh/setup-bun@v2.2.0
- with:
- bun-version: "1.3.14"
-
- - name: Install dependencies
- run: bun install --frozen-lockfile
-
- - name: Test coverage
- run: bun run test:coverage
-
- - name: Upload coverage report
- if: always()
- uses: actions/upload-artifact@v7
- with:
- name: frontend-lcov-report
- path: apps/web/coverage
diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml
deleted file mode 100644
index e46b30ba..00000000
--- a/.github/workflows/docker.yml
+++ /dev/null
@@ -1,476 +0,0 @@
-name: Docker
-
-on:
- push:
- branches:
- - main
- - dev
- tags:
- - "v*"
- workflow_dispatch:
-
-env:
- REGISTRY: ghcr.io
- IMAGE_NAME: ${{ github.repository }}
- FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
-
-concurrency:
- group: docker-${{ github.ref }}
- cancel-in-progress: true
-
-jobs:
- verify:
- uses: ./.github/workflows/ci.yml
-
- prepare-image:
- needs: verify
- runs-on: [self-hosted, Linux, X64, arko, typetype]
- permissions:
- contents: read
- outputs:
- image: ${{ steps.build-info.outputs.image }}
- labels: ${{ steps.meta.outputs.labels }}
- metadata-json: ${{ steps.meta.outputs.json }}
- release-tag: ${{ steps.build-info.outputs.release-tag }}
- version: ${{ steps.build-info.outputs.version }}
- build-time: ${{ steps.build-info.outputs.build-time }}
-
- steps:
- - name: Checkout
- uses: actions/checkout@v6.0.2
-
- - uses: oven-sh/setup-bun@v2.2.0
- with:
- bun-version: 1.3.14
-
- - name: Resolve build metadata
- id: build-info
- run: |
- base_version="$(bun -e 'console.log(require("./package.json").version)')"
- if [[ "$GITHUB_REF" == refs/tags/v* ]]; then
- version="${GITHUB_REF_NAME#v}"
- release_tag="$GITHUB_REF_NAME"
- image="${REGISTRY}/${GITHUB_REPOSITORY,,}"
- elif [[ "$GITHUB_REF" == "refs/heads/main" ]]; then
- version="$base_version"
- release_tag="v$base_version"
- image="${REGISTRY}/${GITHUB_REPOSITORY,,}"
- else
- version="$base_version-beta.$GITHUB_RUN_NUMBER"
- release_tag="v$version"
- image="${REGISTRY}/${GITHUB_REPOSITORY,,}-beta"
- fi
- IFS=. read -r major minor _ <<< "$base_version"
- echo "image=$image" >> "$GITHUB_OUTPUT"
- echo "major=$major" >> "$GITHUB_OUTPUT"
- echo "major-minor=$major.$minor" >> "$GITHUB_OUTPUT"
- echo "release-tag=$release_tag" >> "$GITHUB_OUTPUT"
- echo "version=$version" >> "$GITHUB_OUTPUT"
- echo "build-time=$(date -u +'%Y-%m-%dT%H:%M:%SZ')" >> "$GITHUB_OUTPUT"
-
- - name: Validate stable release notes
- if: github.ref_name == 'main' || startsWith(github.ref, 'refs/tags/v')
- env:
- RELEASE_VERSION: ${{ steps.build-info.outputs.version }}
- run: |
- expected_heading="# TypeType $RELEASE_VERSION"
- actual_heading="$(sed -n '1p' RELEASE_NOTES.md)"
- if [[ "$actual_heading" != "$expected_heading" ]]; then
- echo "RELEASE_NOTES.md must start with: $expected_heading"
- exit 1
- fi
-
- - name: Extract metadata
- id: meta
- uses: docker/metadata-action@v6.0.0
- with:
- images: |
- name=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }},enable=${{ github.ref_name == 'main' || startsWith(github.ref, 'refs/tags/v') }}
- name=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-beta,enable=${{ github.ref_name == 'dev' }}
- tags: |
- type=sha,prefix=sha-,format=short
- type=ref,event=branch
- type=ref,event=tag
- type=semver,pattern={{version}}
- type=semver,pattern={{major}}.{{minor}}
- type=raw,value=${{ steps.build-info.outputs.version }}
- type=raw,value=${{ steps.build-info.outputs.major-minor }},enable=${{ github.ref_name == 'main' }}
- type=raw,value=${{ steps.build-info.outputs.major }},enable=${{ github.ref_name == 'main' }}
- type=raw,value=latest,enable={{is_default_branch}}
- type=raw,value=latest,enable=${{ github.ref_name == 'dev' }}
- type=raw,value=beta,enable=${{ github.ref_name == 'dev' }}
-
- build-platform:
- needs: prepare-image
- runs-on: [self-hosted, Linux, X64, arko, typetype]
- timeout-minutes: 20
- permissions:
- contents: read
- packages: write
- strategy:
- fail-fast: false
- matrix:
- include:
- - platform: linux/amd64
- arch: amd64
- - platform: linux/arm64
- arch: arm64
-
- steps:
- - name: Checkout
- uses: actions/checkout@v6.0.2
-
- - name: Log in to GitHub Container Registry
- uses: docker/login-action@v4.1.0
- with:
- registry: ${{ env.REGISTRY }}
- username: ${{ github.actor }}
- password: ${{ secrets.GITHUB_TOKEN }}
-
- - name: Set up QEMU
- if: matrix.arch == 'arm64'
- uses: docker/setup-qemu-action@v4.1.0
- with:
- platforms: arm64
- cache-image: false
-
- - name: Set up Docker Buildx
- uses: docker/setup-buildx-action@v4.0.0
-
- - name: Build and push
- id: build
- uses: docker/build-push-action@v7.1.0
- with:
- context: .
- build-args: |
- BUILD_VERSION=${{ needs.prepare-image.outputs.version }}
- BUILD_REVISION=${{ github.sha }}
- BUILD_TIME=${{ needs.prepare-image.outputs.build-time }}
- platforms: ${{ matrix.platform }}
- pull: true
- labels: ${{ needs.prepare-image.outputs.labels }}
- outputs: type=image,name=${{ needs.prepare-image.outputs.image }},push-by-digest=true,name-canonical=true,push=true
- provenance: false
- cache-from: type=gha,scope=typetype-${{ matrix.arch }}
- cache-to: type=gha,mode=max,scope=typetype-${{ matrix.arch }}
-
- - name: Export digest
- env:
- DIGEST: ${{ steps.build.outputs.digest }}
- run: |
- digest_dir="$RUNNER_TEMP/typetype-digests"
- rm -rf "$digest_dir"
- mkdir -p "$digest_dir"
- touch "$digest_dir/${DIGEST#sha256:}"
-
- - name: Upload digest
- uses: actions/upload-artifact@v7
- with:
- name: digests-${{ matrix.arch }}
- path: ${{ runner.temp }}/typetype-digests/*
- if-no-files-found: error
- retention-days: 1
-
- build-and-push:
- needs: [prepare-image, build-platform]
- runs-on: [self-hosted, Linux, X64, arko, typetype]
- timeout-minutes: 10
- permissions:
- contents: read
- packages: write
- outputs:
- digest: ${{ steps.manifest.outputs.digest }}
- image: ${{ needs.prepare-image.outputs.image }}
- release-tag: ${{ needs.prepare-image.outputs.release-tag }}
- revision: ${{ github.sha }}
- version: ${{ needs.prepare-image.outputs.version }}
-
- steps:
- - name: Prepare digest directory
- run: rm -rf "$RUNNER_TEMP/typetype-digests"
-
- - name: Download digests
- uses: actions/download-artifact@v8.0.1
- with:
- path: ${{ runner.temp }}/typetype-digests
- pattern: digests-*
- merge-multiple: true
-
- - name: Log in to GitHub Container Registry
- uses: docker/login-action@v4.1.0
- with:
- registry: ${{ env.REGISTRY }}
- username: ${{ github.actor }}
- password: ${{ secrets.GITHUB_TOKEN }}
-
- - name: Set up Docker Buildx
- uses: docker/setup-buildx-action@v4.0.0
-
- - name: Create manifest list and push
- id: manifest
- env:
- DIGEST_DIR: ${{ runner.temp }}/typetype-digests
- IMAGE: ${{ needs.prepare-image.outputs.image }}
- METADATA_JSON: ${{ needs.prepare-image.outputs.metadata-json }}
- run: |
- cd "$DIGEST_DIR"
- mapfile -t digests < <(find . -maxdepth 1 -type f -printf '%f\n' | sort)
- if [[ "${#digests[@]}" -ne 2 ]]; then
- echo "Expected two platform digests, found ${#digests[@]}"
- exit 1
- fi
-
- mapfile -t tags < <(jq -r '.tags[]' <<< "$METADATA_JSON")
- if [[ "${#tags[@]}" -eq 0 ]]; then
- echo "No image tags were generated"
- exit 1
- fi
-
- tag_args=()
- for tag in "${tags[@]}"; do
- tag_args+=(--tag "$tag")
- done
-
- source_args=()
- for digest in "${digests[@]}"; do
- source_args+=("$IMAGE@sha256:$digest")
- done
-
- docker buildx imagetools create "${tag_args[@]}" "${source_args[@]}"
- manifest_json="$(docker buildx imagetools inspect "${tags[0]}" --format '{{json .Manifest}}')"
- digest="$(jq -r '.digest' <<< "$manifest_json")"
- if [[ "$digest" != sha256:* ]]; then
- echo "Published manifest has no valid digest"
- exit 1
- fi
- echo "digest=$digest" >> "$GITHUB_OUTPUT"
-
- - name: Verify manifest platforms
- env:
- IMAGE: ${{ needs.prepare-image.outputs.image }}
- VERSION: ${{ needs.prepare-image.outputs.version }}
- run: |
- platforms="$(docker buildx imagetools inspect "$IMAGE:$VERSION" --format '{{json .Manifest}}' | jq -r '.manifests[].platform | "\(.os)/\(.architecture)"' | sort -u)"
- if [[ "$platforms" != $'linux/amd64\nlinux/arm64' ]]; then
- printf 'Unexpected manifest platforms:\n%s\n' "$platforms"
- exit 1
- fi
-
- deploy-stable:
- needs: build-and-push
- if: github.ref_name == 'main'
- runs-on: [self-hosted, Linux, X64, arko, typetype]
- environment: stable
- outputs:
- deployed: ${{ steps.deploy-result.outputs.deployed }}
- steps:
- - name: Check deployment configuration
- id: deploy-config
- env:
- TYPE_TYPE_DEPLOY_HOST: ${{ secrets.TYPE_TYPE_DEPLOY_HOST }}
- TYPE_TYPE_DEPLOY_PORT: ${{ secrets.TYPE_TYPE_DEPLOY_PORT }}
- TYPE_TYPE_DEPLOY_USER: ${{ secrets.TYPE_TYPE_DEPLOY_USER }}
- TYPE_TYPE_DEPLOY_SSH_KEY: ${{ secrets.TYPE_TYPE_DEPLOY_SSH_KEY }}
- run: |
- if [ -z "$TYPE_TYPE_DEPLOY_HOST" ] || [ -z "$TYPE_TYPE_DEPLOY_PORT" ] || [ -z "$TYPE_TYPE_DEPLOY_USER" ] || [ -z "$TYPE_TYPE_DEPLOY_SSH_KEY" ]; then
- echo "Deployment secrets are not configured; skipping deployment."
- echo "configured=false" >> "$GITHUB_OUTPUT"
- exit 0
- fi
- echo "configured=true" >> "$GITHUB_OUTPUT"
-
- - name: Configure SSH
- if: steps.deploy-config.outputs.configured == 'true'
- env:
- TYPE_TYPE_DEPLOY_HOST: ${{ secrets.TYPE_TYPE_DEPLOY_HOST }}
- TYPE_TYPE_DEPLOY_PORT: ${{ secrets.TYPE_TYPE_DEPLOY_PORT }}
- TYPE_TYPE_DEPLOY_SSH_KEY: ${{ secrets.TYPE_TYPE_DEPLOY_SSH_KEY }}
- run: |
- install -m 600 /dev/null "$RUNNER_TEMP/typetype_deploy_key"
- printf '%s\n' "$TYPE_TYPE_DEPLOY_SSH_KEY" > "$RUNNER_TEMP/typetype_deploy_key"
- ssh-keyscan -p "$TYPE_TYPE_DEPLOY_PORT" "$TYPE_TYPE_DEPLOY_HOST" > "$RUNNER_TEMP/known_hosts" 2>/dev/null
-
- - name: Deploy stable stack
- if: steps.deploy-config.outputs.configured == 'true'
- env:
- TYPE_TYPE_DEPLOY_HOST: ${{ secrets.TYPE_TYPE_DEPLOY_HOST }}
- TYPE_TYPE_DEPLOY_PORT: ${{ secrets.TYPE_TYPE_DEPLOY_PORT }}
- TYPE_TYPE_DEPLOY_USER: ${{ secrets.TYPE_TYPE_DEPLOY_USER }}
- run: |
- printf '%s\n' "$GITHUB_SHA" \
- | ssh -i "$RUNNER_TEMP/typetype_deploy_key" -p "$TYPE_TYPE_DEPLOY_PORT" -o BatchMode=yes -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes -o UserKnownHostsFile="$RUNNER_TEMP/known_hosts" "$TYPE_TYPE_DEPLOY_USER@$TYPE_TYPE_DEPLOY_HOST" /usr/local/sbin/typetype-update
- curl --fail --retry 12 --retry-delay 2 --retry-all-errors --silent --show-error https://watch.typetype.video/api/health
- curl --fail --retry 12 --retry-delay 2 --retry-all-errors --silent --show-error https://watch.typetype.video/api/downloader/health
-
- - name: Record stable deployment
- id: deploy-result
- if: steps.deploy-config.outputs.configured == 'true'
- run: echo "deployed=true" >> "$GITHUB_OUTPUT"
-
- deploy-beta:
- needs: build-and-push
- if: github.ref_name == 'dev'
- runs-on: [self-hosted, Linux, X64, arko, typetype]
- environment: beta
- outputs:
- deployed: ${{ steps.deploy-result.outputs.deployed }}
- steps:
- - name: Check deployment configuration
- id: deploy-config
- env:
- TYPE_TYPE_DEPLOY_HOST: ${{ secrets.TYPE_TYPE_DEPLOY_HOST }}
- TYPE_TYPE_DEPLOY_PORT: ${{ secrets.TYPE_TYPE_DEPLOY_PORT }}
- TYPE_TYPE_DEPLOY_USER: ${{ secrets.TYPE_TYPE_DEPLOY_USER }}
- TYPE_TYPE_DEPLOY_SSH_KEY: ${{ secrets.TYPE_TYPE_DEPLOY_SSH_KEY }}
- run: |
- if [ -z "$TYPE_TYPE_DEPLOY_HOST" ] || [ -z "$TYPE_TYPE_DEPLOY_PORT" ] || [ -z "$TYPE_TYPE_DEPLOY_USER" ] || [ -z "$TYPE_TYPE_DEPLOY_SSH_KEY" ]; then
- echo "Deployment secrets are not configured; skipping deployment."
- echo "configured=false" >> "$GITHUB_OUTPUT"
- exit 0
- fi
- echo "configured=true" >> "$GITHUB_OUTPUT"
-
- - name: Configure SSH
- if: steps.deploy-config.outputs.configured == 'true'
- env:
- TYPE_TYPE_DEPLOY_HOST: ${{ secrets.TYPE_TYPE_DEPLOY_HOST }}
- TYPE_TYPE_DEPLOY_PORT: ${{ secrets.TYPE_TYPE_DEPLOY_PORT }}
- TYPE_TYPE_DEPLOY_SSH_KEY: ${{ secrets.TYPE_TYPE_DEPLOY_SSH_KEY }}
- run: |
- install -m 600 /dev/null "$RUNNER_TEMP/typetype_deploy_key"
- printf '%s\n' "$TYPE_TYPE_DEPLOY_SSH_KEY" > "$RUNNER_TEMP/typetype_deploy_key"
- ssh-keyscan -p "$TYPE_TYPE_DEPLOY_PORT" "$TYPE_TYPE_DEPLOY_HOST" > "$RUNNER_TEMP/known_hosts" 2>/dev/null
-
- - name: Deploy beta stack
- if: steps.deploy-config.outputs.configured == 'true'
- env:
- TYPE_TYPE_DEPLOY_HOST: ${{ secrets.TYPE_TYPE_DEPLOY_HOST }}
- TYPE_TYPE_DEPLOY_PORT: ${{ secrets.TYPE_TYPE_DEPLOY_PORT }}
- TYPE_TYPE_DEPLOY_USER: ${{ secrets.TYPE_TYPE_DEPLOY_USER }}
- run: |
- ssh -i "$RUNNER_TEMP/typetype_deploy_key" -p "$TYPE_TYPE_DEPLOY_PORT" -o BatchMode=yes -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes -o UserKnownHostsFile="$RUNNER_TEMP/known_hosts" "$TYPE_TYPE_DEPLOY_USER@$TYPE_TYPE_DEPLOY_HOST" "/usr/local/sbin/typetype-beta-update"
- curl --fail --retry 12 --retry-delay 2 --retry-all-errors --silent --show-error https://beta.typetype.video/api/health
- curl --fail --retry 12 --retry-delay 2 --retry-all-errors --silent --show-error https://beta.typetype.video/api/downloader/health
-
- - name: Record beta deployment
- id: deploy-result
- if: steps.deploy-config.outputs.configured == 'true'
- run: echo "deployed=true" >> "$GITHUB_OUTPUT"
-
- prerelease:
- needs: [build-and-push, deploy-beta]
- if: github.ref_name == 'dev' && needs.deploy-beta.outputs.deployed == 'true'
- runs-on: [self-hosted, Linux, X64, arko, typetype]
- permissions:
- contents: write
- steps:
- - name: Publish GitHub prerelease
- env:
- GH_TOKEN: ${{ github.token }}
- IMAGE_DIGEST: ${{ needs.build-and-push.outputs.digest }}
- IMAGE_NAME: ${{ needs.build-and-push.outputs.image }}
- RELEASE_TAG: ${{ needs.build-and-push.outputs.release-tag }}
- RELEASE_VERSION: ${{ needs.build-and-push.outputs.version }}
- REVISION: ${{ needs.build-and-push.outputs.revision }}
- run: |
- existing_tag=""
- if resolved_tag="$(gh api "repos/$GITHUB_REPOSITORY/git/ref/tags/$RELEASE_TAG" --jq '.object.sha' 2>/dev/null)"; then
- existing_tag="$resolved_tag"
- fi
- if [[ -n "$existing_tag" && "$existing_tag" != "$REVISION" ]]; then
- echo "$RELEASE_TAG already targets another commit"
- exit 1
- fi
- if gh release view "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then
- exit 0
- fi
- previous_tag="$(
- gh release list \
- --repo "$GITHUB_REPOSITORY" \
- --limit 100 \
- --json tagName,isPrerelease,publishedAt \
- --jq '[.[] | select(.isPrerelease)] | sort_by(.publishedAt) | reverse | .[0].tagName // ""'
- )"
- notes_file="$RUNNER_TEMP/prerelease-notes.md"
- {
- echo "## Beta build"
- echo
- echo "- Version: \`$RELEASE_VERSION\`"
- echo "- Commit: [\`${REVISION:0:7}\`]($GITHUB_SERVER_URL/$GITHUB_REPOSITORY/commit/$REVISION)"
- echo "- Container image: \`$IMAGE_NAME@$IMAGE_DIGEST\`"
- echo
- if [[ -n "$previous_tag" ]]; then
- echo "## Changes since \`$previous_tag\`"
- echo
- gh api "repos/$GITHUB_REPOSITORY/compare/$previous_tag...$REVISION" \
- --jq '.commits[] | [.sha, (.commit.message | split("\n")[0])] | @tsv' |
- while IFS=$'\t' read -r commit_sha subject; do
- printf -- '- [`%.7s`](%s/%s/commit/%s) %s\n' \
- "$commit_sha" "$GITHUB_SERVER_URL" "$GITHUB_REPOSITORY" "$commit_sha" "$subject"
- done
- else
- echo "## Changes"
- echo
- gh api "repos/$GITHUB_REPOSITORY/commits/$REVISION" \
- --jq '[.sha, (.commit.message | split("\n")[0])] | @tsv' |
- while IFS=$'\t' read -r commit_sha subject; do
- printf -- '- [`%.7s`](%s/%s/commit/%s) %s\n' \
- "$commit_sha" "$GITHUB_SERVER_URL" "$GITHUB_REPOSITORY" "$commit_sha" "$subject"
- done
- fi
- } > "$notes_file"
- gh release create "$RELEASE_TAG" \
- --repo "$GITHUB_REPOSITORY" \
- --target "$REVISION" \
- --title "TypeType $RELEASE_VERSION" \
- --prerelease \
- --latest=false \
- --notes-file "$notes_file"
-
- release:
- needs: [build-and-push, deploy-stable]
- if: github.ref_name == 'main' && needs.deploy-stable.outputs.deployed == 'true'
- runs-on: [self-hosted, Linux, X64, arko, typetype]
- permissions:
- contents: write
- steps:
- - name: Checkout
- uses: actions/checkout@v6.0.2
-
- - name: Publish GitHub release
- env:
- GH_TOKEN: ${{ github.token }}
- IMAGE_DIGEST: ${{ needs.build-and-push.outputs.digest }}
- IMAGE_NAME: ${{ needs.build-and-push.outputs.image }}
- RELEASE_TAG: ${{ needs.build-and-push.outputs.release-tag }}
- RELEASE_VERSION: ${{ needs.build-and-push.outputs.version }}
- REVISION: ${{ needs.build-and-push.outputs.revision }}
- run: |
- existing_tag=""
- if resolved_tag="$(gh api "repos/$GITHUB_REPOSITORY/git/ref/tags/$RELEASE_TAG" --jq '.object.sha' 2>/dev/null)"; then
- existing_tag="$resolved_tag"
- fi
- if [[ -n "$existing_tag" && "$existing_tag" != "$REVISION" ]]; then
- echo "$RELEASE_TAG already targets another commit; bump the product version"
- exit 1
- fi
- if gh release view "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then
- exit 0
- fi
- notes_file="$RUNNER_TEMP/release-notes.md"
- cp RELEASE_NOTES.md "$notes_file"
- {
- echo "## Technical details"
- echo
- echo "- Version: \`$RELEASE_VERSION\`"
- echo "- Commit: [\`${REVISION:0:7}\`]($GITHUB_SERVER_URL/$GITHUB_REPOSITORY/commit/$REVISION)"
- echo "- Container image: \`$IMAGE_NAME@$IMAGE_DIGEST\`"
- echo
- echo "For the complete release history, see [typetype.video/releases](https://typetype.video/releases)."
- } >> "$notes_file"
- gh release create "$RELEASE_TAG" \
- --repo "$GITHUB_REPOSITORY" \
- --target "$REVISION" \
- --title "TypeType $RELEASE_VERSION" \
- --latest \
- --notes-file "$notes_file"
diff --git a/.gitignore b/.gitignore
index 06339030..19a93c6c 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,10 +1,3 @@
-AGENTS.md
-CLAUDE.md
-Vision.md
-response.md
-attente.md
-Backend.md
-question.md
node_modules/
dist/
build/
diff --git a/.gitmodules b/.gitmodules
new file mode 100644
index 00000000..4eba14d0
--- /dev/null
+++ b/.gitmodules
@@ -0,0 +1,24 @@
+[submodule "TypeType-Frontend"]
+ path = TypeType-Frontend
+ url = https://github.com/TypeType-Video/TypeType-Frontend.git
+ branch = dev
+[submodule "TypeType-Server"]
+ path = TypeType-Server
+ url = https://github.com/TypeType-Video/TypeType-Server.git
+ branch = dev
+[submodule "TypeType-Player"]
+ path = TypeType-Player
+ url = https://github.com/TypeType-Video/TypeType-Player.git
+ branch = dev
+[submodule "TypeType-Token"]
+ path = TypeType-Token
+ url = https://github.com/TypeType-Video/TypeType-Token.git
+ branch = dev
+[submodule "TypeType-Downloader"]
+ path = TypeType-Downloader
+ url = https://github.com/TypeType-Video/TypeType-Downloader.git
+ branch = dev
+[submodule "Docs-TypeType"]
+ path = Docs-TypeType
+ url = https://github.com/TypeType-Video/Docs-TypeType.git
+ branch = dev
diff --git a/Architecture.md b/Architecture.md
deleted file mode 100644
index 8a659c33..00000000
--- a/Architecture.md
+++ /dev/null
@@ -1,150 +0,0 @@
-# Architecture
-
-## Overview
-
-TypeType is a frontend SPA. It renders client-side and talks to backend services over HTTP.
-
-Runtime responsibilities:
-
-- TypeType frontend (this repo): UI, routing, state, playback UX
-- TypeType-Server: extraction, auth, user data
-- TypeType-Token: PO token and subtitle helper for YouTube flows
-
-## System Boundary
-
-```
-PipePipeExtractor (Java, GPL v3)
- |
- v
-TypeType-Server (Kotlin/Ktor, GPL v3)
- |
- v
-TypeType frontend (TypeScript/React, MIT)
-```
-
-The frontend and backend are separate programs. The REST API is the boundary.
-
-## Frontend Stack
-
-| Role | Tool |
-|---|---|
-| Language | TypeScript (strict) |
-| Runtime / Package manager | Bun |
-| Build | Vite |
-| Framework | React |
-| Routing | TanStack Router |
-| Server state | TanStack Query |
-| Client state | Zustand |
-| Video player | Vidstack |
-| Styling | Tailwind CSS |
-| Components | shadcn/ui + Radix UI |
-| Lint / Format | Biome |
-
-## Repository Structure
-
-```
-TypeType/
-├── apps/
-│ └── web/
-│ ├── src/
-│ │ ├── components/
-│ │ ├── hooks/
-│ │ ├── lib/
-│ │ ├── routes/
-│ │ └── types/
-│ └── public/
-├── Dockerfile
-├── docker-compose.yml
-└── nginx.conf
-```
-
-## Runtime Data Flow
-
-```
-User action
- |
- v
-Route / Hook
- |
- v
-TanStack Query / fetch
- |
- v
-TypeType-Server HTTP API
- |
- v
-JSON response
- |
- v
-UI render update
-```
-
-## Authentication Model
-
-TypeType uses JWT auth from TypeType-Server.
-
-Auth routes:
-
-- `POST /auth/register`
-- `POST /auth/login`
-- `POST /auth/refresh`
-- `GET /auth/me`
-- `POST /auth/guest`
-- `POST /auth/reset-password`
-
-Protected routes use:
-
-- `Authorization: Bearer `
-
-## API Surface (frontend usage)
-
-Base URL is `VITE_API_URL` (defaults to `/api` in app runtime).
-
-Public data routes:
-
-- `/streams`, `/streams/manifest`, `/streams/native-manifest`
-- `/search`, `/suggestions`, `/trending`
-- `/comments`, `/comments/replies`, `/bullet-comments`, `/channel`
-- `/proxy`, `/proxy/storyboard`, `/proxy/nicovideo`
-
-Protected user routes:
-
-- `/history`, `/subscriptions`, `/subscriptions/feed`, `/subscriptions/shorts`
-- `/playlists`, `/watch-later`, `/progress`, `/favorites`, `/settings`
-- `/search-history`, `/blocked/channels`, `/blocked/videos`
-- `/recommendations/home`, `/recommendations/shorts`
-- `/recommendations/home/metrics`, `/restore/pipepipe`, `/imports/youtube-takeout`, `/bug-reports`
-
-Protected admin routes:
-
-- `/admin/users`, `/admin/settings`, `/admin/bug-reports`
-
-## Notable Behavior Contracts
-
-- `GET /progress/{videoUrl}` can return `404` when no position exists; frontend treats this as position `0`
-- YouTube uses `/streams/native-manifest` first, then fallback to `/streams/manifest` on `422`
-- NicoNico can return `422` on `/streams/manifest`; expected for non-DASH cases
-- `GET /search-history` supports backend pagination: `page` and `limit`, total from `X-Total-Count`
-- Home and Shorts recommendations are fetched without client event reporting
-
-## Recommendation and Privacy Flow
-
-- Home feed requests call `/recommendations/home` with `intent` (currently default `auto`).
-- Shorts feed requests call `/recommendations/shorts` with `intent` (currently default `auto`).
-- Optional offline quality metrics are available via `/recommendations/home/metrics`.
-
-## Import and Restore Flow
-
-- YouTube import (`/imports/youtube-takeout`) is job-based:
- - create upload job,
- - poll status,
- - fetch preview,
- - commit import,
- - read final report.
-- PipePipe restore uses `POST /restore/pipepipe?timeMode=raw|normalized` and returns restore counts plus watchedAt range details.
-
-## License Boundary
-
-TypeType-Server is GPL v3. This frontend is MIT.
-
-No backend source code, classes, or shared modules are imported into the frontend. Integration is HTTP-only.
diff --git a/Dockerfile b/Dockerfile
deleted file mode 100644
index 436035f0..00000000
--- a/Dockerfile
+++ /dev/null
@@ -1,35 +0,0 @@
-FROM --platform=$BUILDPLATFORM oven/bun:1.3.14-alpine AS builder
-
-WORKDIR /app
-
-RUN apk upgrade --no-cache libcrypto3 libssl3
-
-COPY bun.lock package.json ./
-COPY apps/web/package.json ./apps/web/
-
-RUN bun install --frozen-lockfile
-
-COPY apps/web ./apps/web
-
-RUN bun run --cwd apps/web build
-
-FROM nginx:1.31.0-alpine AS runner
-ARG BUILD_VERSION=0.1.0
-ARG BUILD_REVISION=development
-ARG BUILD_TIME=unknown
-
-RUN apk upgrade --no-cache libxml2 libcrypto3 libssl3 libexpat
-
-COPY --from=builder /app/apps/web/dist /usr/share/nginx/html
-COPY nginx.conf /etc/nginx/conf.d/default.conf
-RUN short_revision="$(printf '%s' "$BUILD_REVISION" | cut -c1-12)" \
- && printf '{"service":"typetype","version":"%s","revision":"%s","shortRevision":"%s","buildTime":"%s"}\n' \
- "$BUILD_VERSION" "$BUILD_REVISION" "$short_revision" "$BUILD_TIME" \
- > /usr/share/nginx/html/version.json \
- && printf '{"service":"web","version":"%s","revision":"%s","shortRevision":"%s","buildTime":"%s"}\n' \
- "$BUILD_VERSION" "$BUILD_REVISION" "$short_revision" "$BUILD_TIME" \
- > /usr/share/nginx/html/version-web.json
-
-EXPOSE 80
-
-CMD ["nginx", "-g", "daemon off;"]
diff --git a/Docs-TypeType b/Docs-TypeType
new file mode 160000
index 00000000..a69d6f7d
--- /dev/null
+++ b/Docs-TypeType
@@ -0,0 +1 @@
+Subproject commit a69d6f7d6c16a4d59d0693075457592a0040eecc
diff --git a/README.md b/README.md
index c251b37b..e5465247 100644
--- a/README.md
+++ b/README.md
@@ -1,384 +1,64 @@
-
+
-
+
-
+# TypeType
-[
](LICENSE)
-[
](https://github.com/Priveetee/TypeType)
-[
](https://github.com/InfinityLoop1308/PipePipeExtractor)
-[
](https://react.dev)
+TypeType is a self-hosted web client for YouTube, with support for NicoNico and BiliBili. This repository is the central project repository: it contains the Docker Compose stack, installer, update and rollback tooling, release notes, and issue tracker.
-
+> [!IMPORTANT]
+> The TypeType repositories moved from `Priveetee` to the [`TypeType-Video`](https://github.com/TypeType-Video) organization. GitHub redirects existing repository links. New installations use the organization container image paths; the previous image paths remain available during the transition.
-TypeType is a self-hostable video app for YouTube, NicoNico and BiliBili.
+## Install
-It is not only a web UI. This repository contains the TypeType web client and the deployment files for running the full stack: frontend, Kotlin API backend, PostgreSQL, Dragonfly, media proxying, token service, downloader service and Garage-backed download storage.
+Docker Engine and Docker Compose v2 are required.
-## Documentation
-
-Full documentation lives at **[priveetee.github.io/Docs-TypeType](https://priveetee.github.io/Docs-TypeType/)**:
-
-- [Self-hosting guide](https://priveetee.github.io/Docs-TypeType/self-hosting/introduction), set up and operate the stack, including a fully script-free Docker Compose setup.
-- [User guide](https://priveetee.github.io/Docs-TypeType/guide/), everything the app can do.
-
-## Start Here
-
-Install and start the stack with one command:
-
-```sh
-curl -fsSL https://raw.githubusercontent.com/Priveetee/TypeType/main/scripts/install-stack.sh | bash
-```
-
-The installer creates `~/typetype-stack`, generates local downloader and YouTube remote login secrets, selects available ports when defaults are busy, starts the services, and bootstraps Garage. On `arm64`/`aarch64` hosts, including Raspberry Pi 4, it automatically adds the ARM64 Compose override that uses `redis:7-alpine` as the Redis-compatible cache service instead of Dragonfly.
-
-After install:
-
-```sh
-cd ~/typetype-stack
-docker compose ps
-curl -fsS http://localhost:8080/health
-```
-
-Open the web app:
-
-```text
-http://localhost:8082
-```
-
-Default local endpoints:
-
-| Service | URL |
-|---|---|
-| Web app | `http://localhost:8082` |
-| API backend | `http://localhost:8080` |
-| Token service | `http://localhost:8081` |
-| Garage S3 | `http://localhost:3900` |
-
-Download the stack files without starting Docker:
-
-```sh
-curl -fsSL https://raw.githubusercontent.com/Priveetee/TypeType/main/scripts/install-stack.sh | bash -s -- --download-only
-```
-
-Install and start only the beta stack automatically:
-
-```sh
-curl -fsSL https://raw.githubusercontent.com/Priveetee/TypeType/dev/scripts/install-stack.sh | bash -s -- --beta --yes
-```
-
-The beta installer creates `~/typetype-beta-stack`, downloads `docker-compose.dev.yml`, pulls only the beta images, starts only that Compose stack, and bootstraps Garage.
-
-Beta endpoints:
-
-| Service | URL |
-|---|---|
-| Web app | `http://localhost:18082` |
-| API backend | `http://localhost:18080` |
-| Token service | `http://localhost:18081` |
-| Garage S3 | `http://localhost:3900` |
-
-Update only the beta stack:
-
-```sh
-cd ~/typetype-beta-stack
-docker compose -f docker-compose.dev.yml --env-file .env pull
-docker compose -f docker-compose.dev.yml --env-file .env up -d --wait --wait-timeout 180
-docker compose -f docker-compose.dev.yml --env-file .env ps
-```
-
-On ARM64 hosts, include the override when running Compose manually:
-
-```sh
-docker compose -f docker-compose.yml -f docker-compose.arm64.yml --env-file .env up -d
-docker compose -f docker-compose.dev.yml -f docker-compose.arm64.yml --env-file .env up -d
-```
-
-## Screenshots
-
-
-
-### Multi-Service Search
-
-
-
-| YouTube | NicoNico | BiliBili |
-|---|---|---|
-|  |  |  |
-
-### Playback
-
-
-
-| YouTube | NicoNico | BiliBili |
-|---|---|---|
-|  |  |  |
-
-| Save to playlist | Download formats | NicoNico danmaku |
-|---|---|---|
-|  |  |  |
-
-### Library Flow
-
-
-
-| Playlists | History | Import |
-|---|---|---|
-|  |  |  |
-
-| Channel | Settings |
-|---|---|
-|  |  |
-
-Subscriptions:
-
-
-
-Import and settings flow:
-
-
-
-Mobile layout:
-
-
-
-| Home | Search | Watch |
-|---|---|---|
-|  |  |  |
-
-## What It Does
-
-- Plays YouTube, NicoNico and BiliBili videos from a self-hosted web app.
-- Stores history, subscriptions, playlists, favorites, watch later, progress and settings on your own instance.
-- Searches, loads trending feeds, shows comments and opens channel pages through the backend API.
-- Proxies media when direct browser playback is unreliable or provider-specific headers are needed.
-- Generates DASH manifests when separate audio/video streams are available.
-- Imports YouTube Takeout data and PipePipe backup data.
-- Runs download jobs through a separate downloader service.
-
-## What This Is Not
-
-- Not a hosted SaaS.
-- Not a YouTube-only frontend clone.
-- Not a fork of Piped, FreeTube, LibreTube, Invidious or NewPipe.
-- Not a browser-side extractor. Extraction stays behind the HTTP API boundary.
-- Not affiliated with YouTube, NicoNico, BiliBili or any upstream video platform.
-
-## Stack
-
-| Role | Project |
-|---|---|
-| Web client | React, TypeScript, Vite, TanStack Router, TanStack Query, Tailwind CSS |
-| API backend | Kotlin, Ktor, PipePipeExtractor |
-| User data | PostgreSQL |
-| Extraction cache | Dragonfly |
-| Media proxy | TypeType-Server |
-| Token service | TypeType-Token |
-| Downloader | TypeType-Downloader |
-| Download storage | Garage S3 |
-| Deployment | Docker Compose |
-
-## API Smoke Tests
-
-Run these after the stack is up.
-
-Health:
-
-```sh
-curl -fsS http://localhost:8080/health
+```bash
+curl -fsSL https://raw.githubusercontent.com/TypeType-Video/TypeType/main/scripts/install-stack.sh | bash
```
-YouTube search:
+The installer creates `~/typetype-stack`, prompts before starting the stack, generates installation-specific secrets, and preserves the existing `.env` file during updates.
-```sh
-curl -fsS "http://localhost:8080/search?q=lofi&service=0"
-```
-
-NicoNico suggestions:
-
-```sh
-curl -fsS "http://localhost:8080/suggestions?query=miku&service=6"
-```
-
-BiliBili trending:
-
-```sh
-curl -fsS "http://localhost:8080/trending?service=5"
-```
-
-YouTube stream extraction:
-
-```sh
-curl -fsS "http://localhost:8080/streams?url=https%3A%2F%2Fwww.youtube.com%2Fwatch%3Fv%3DdQw4w9WgXcQ"
-```
-
-NicoNico stream extraction:
-
-```sh
-curl -fsS "http://localhost:8080/streams?url=https%3A%2F%2Fwww.nicovideo.jp%2Fwatch%2Fsm9"
-```
-
-Create a guest session for protected user-data endpoints:
-
-```sh
-curl -fsS -X POST http://localhost:8080/auth/guest
-```
-
-Use the returned token as a bearer token, or let Python extract it:
-
-```sh
-TOKEN="$(curl -fsS -X POST http://localhost:8080/auth/guest | python3 -c 'import json,sys; print(json.load(sys.stdin)["token"])')"
-curl -fsS http://localhost:8080/settings -H "Authorization: Bearer ${TOKEN}"
-```
-
-Service IDs:
-
-| Service | ID |
-|---|---:|
-| YouTube | `0` |
-| BiliBili | `5` |
-| NicoNico | `6` |
-
-## Manual Install
-
-The installer is recommended. For a fully **script-free** Docker Compose setup (no bootstrap scripts), follow the [manual setup guide](https://priveetee.github.io/Docs-TypeType/self-hosting/docker-compose). If you want to run from a cloned repository with the helper scripts instead:
-
-```sh
-git clone https://github.com/Priveetee/TypeType.git
-cd TypeType
-cp .env.example .env
-./scripts/bootstrap-env.sh
-./scripts/setup-stack.sh
-```
-
-Manual Docker Compose flow:
-
-```sh
-cp .env.example .env
-./scripts/bootstrap-env.sh
-docker compose config -q
-docker compose pull
-./scripts/bootstrap-garage.sh
-docker compose up -d --wait --wait-timeout 180
-docker compose ps
-```
-
-The script-free guide gives the equivalent one-time secret generation and Garage provisioning commands. Once Garage is provisioned, updates and verification only require Docker Compose and curl:
-
-```sh
-docker compose config -q
-docker compose pull
-docker compose up -d --wait --wait-timeout 180
-docker compose ps
-curl -fsS http://localhost:8080/health
-curl -fsS http://localhost:8081/health
-curl -fsS "http://localhost:8081/potoken?videoId=dQw4w9WgXcQ"
-curl -fsS http://localhost:8082/api/downloader/health/deep
-```
-
-If you do the manual flow, edit `.env` before exposing the stack outside localhost. In particular, keep the generated downloader S3 access key, downloader S3 secret key, Garage RPC secret, YouTube remote login token, and YouTube session encryption key private.
-
-For a controlled update, set `TYPETYPE_WEB_IMAGE`, `TYPETYPE_SERVER_IMAGE`, `TYPETYPE_DOWNLOADER_IMAGE`, and `TYPETYPE_TOKEN_IMAGE` in `.env` to immutable `sha-` image tags. Keep the previous four values before changing them. Restoring those values and running `docker compose up -d --wait --wait-timeout 180` rolls the application services back without changing the data volumes. Beta uses the corresponding variables ending in `_BETA_IMAGE`.
-
-## Updating
-
-Update the whole stack:
-
-```sh
-curl -fsSL https://raw.githubusercontent.com/Priveetee/TypeType/main/scripts/install-stack.sh | bash -s -- --yes
-cd ~/typetype-stack
-docker compose ps
-```
-
-Running the installer again updates the Compose and configuration files, keeps the
-existing `.env` and data volumes, pulls the new images, recreates changed services,
-and provisions any newly added Garage resources. Existing configured ports are kept.
-
-For a script-free update, first replace `docker-compose.yml`, `nginx.conf`, and
-`garage.toml` with their current release versions while keeping `.env`, then run the
-manual update and Garage provisioning steps from the self-hosting guide.
-
-Update only the web client:
-
-```sh
-cd ~/typetype-stack
-docker compose pull typetype
-docker compose up -d --force-recreate --no-deps typetype
-```
-
-## Local Development
-
-Install dependencies:
-
-```sh
-bun install
-```
-
-Create the frontend environment file:
-
-```sh
-cp apps/web/.env.example apps/web/.env
-```
+For manual installation and operating instructions, use the documentation:
-Set the API URL:
+- [Self-hosting guide](https://typetype-video.github.io/Docs-TypeType/self-hosting/introduction)
+- [Manual Docker Compose setup](https://typetype-video.github.io/Docs-TypeType/self-hosting/docker-compose#manual-setup)
+- [Update guide](https://typetype-video.github.io/Docs-TypeType/self-hosting/maintenance)
+- [Rollback guide](https://typetype-video.github.io/Docs-TypeType/self-hosting/rollback)
+- [User guide](https://typetype-video.github.io/Docs-TypeType/guide/)
-```env
-VITE_API_URL=http://localhost:8080
-```
+## Repositories
-Run the dev server:
+| Repository | Responsibility | License |
+| --- | --- | --- |
+| [TypeType](https://github.com/TypeType-Video/TypeType) | Stack, releases, coordination, and issues | MIT |
+| [TypeType-Frontend](https://github.com/TypeType-Video/TypeType-Frontend) | React web client | MIT |
+| [TypeType-Server](https://github.com/TypeType-Video/TypeType-Server) | Kotlin API and extraction backend | GPL-3.0 |
+| [TypeType-Player](https://github.com/TypeType-Video/TypeType-Player) | MSE and SABR playback package | MIT |
+| [TypeType-Token](https://github.com/TypeType-Video/TypeType-Token) | YouTube token and decoder service | MIT |
+| [TypeType-Downloader](https://github.com/TypeType-Video/TypeType-Downloader) | Download jobs and artifacts | GPL-3.0-or-later |
+| [Docs-TypeType](https://github.com/TypeType-Video/Docs-TypeType) | User and self-hosting documentation | MIT |
-```sh
-bun run dev
-```
+Development changes land on each component's `dev` branch. Component images notify this repository with their exact digest. Component CI does not deploy beta or production instances.
-Open:
+The public component repositories are also available here as Git submodules. Clone the complete source tree with:
-```text
-http://localhost:5173
+```bash
+git clone --recurse-submodules https://github.com/TypeType-Video/TypeType.git
```
-Checks:
+## Privacy And Disclaimer
-```sh
-bun run check
-bun run build
-bun run knip
-bun run sherif
-```
+TypeType is designed to provide a private, self-hosted way to use supported media services. The project does not add telemetry or collect usage data. Instance operators control their own deployment, accounts, logs, storage, and network configuration.
-## Related Repositories
+TypeType and its contents are not affiliated with, funded, authorized, endorsed by, or associated with YouTube, Google LLC, NicoNico, BiliBili, or their affiliates. Trademarks, service marks, trade names, and other intellectual property belong to their respective owners.
-- [TypeType](https://github.com/Priveetee/TypeType) contains the web client and deployment stack.
-- [TypeType-Server](https://github.com/Priveetee/TypeType-Server) is the Kotlin/Ktor API backend.
-- [TypeType-Downloader](https://github.com/Priveetee/TypeType-Downloader) handles download jobs and artifacts.
-- [TypeType-Token](https://github.com/Priveetee/TypeType-Token) provides YouTube PO tokens.
+TypeType is open source software built for learning and research purposes.
-Clone the source repositories directly:
+## Contributing
-```sh
-git clone https://github.com/Priveetee/TypeType.git
-git clone https://github.com/Priveetee/TypeType-Server.git
-git clone https://github.com/Priveetee/TypeType-Downloader.git
-git clone https://github.com/Priveetee/TypeType-Token.git
-```
-
-## Notes
-
-TypeType is usable, but it is still young. Video providers change frequently, and provider-specific extraction, signed URLs, manifests, headers, range requests and cache TTLs can break over time.
-
-The clean boundary is HTTP. The web client talks to the API; extraction stays in TypeType-Server.
-
-## Acknowledgments
-
-TypeType is a clean rewrite, but its direction was shaped by existing open-source video clients and extractor projects.
-
-- [PipePipe](https://github.com/InfinityLoop1308/PipePipe) and [PipePipeExtractor](https://github.com/InfinityLoop1308/PipePipeExtractor) for multi-service extraction behavior.
-- [Piped](https://github.com/TeamPiped/Piped-Frontend) for UX and API pattern references.
-- [FreeTube](https://github.com/FreeTubeApp/FreeTube) for video player behavior references.
+Use the [central issue tracker](https://github.com/TypeType-Video/TypeType/issues) for bug reports and feature requests. Pull requests belong in the repository that owns the affected component.
## License
-This repository is MIT licensed. See [LICENSE](LICENSE).
-
-TypeType-Server is GPLv3 because it uses PipePipeExtractor.
+The files in this repository are licensed under the [MIT License](LICENSE). Components keep their own licenses; in particular, TypeType-Server and other GPL components remain under GPL-3.0.
diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md
index 6ca01fd4..5b128d25 100644
--- a/RELEASE_NOTES.md
+++ b/RELEASE_NOTES.md
@@ -13,6 +13,6 @@ TypeType runtime behavior, API contracts and self-hosting configuration are unch
## Updating
-Follow the [update guide](https://priveetee.github.io/Docs-TypeType/self-hosting/maintenance).
+Follow the [update guide](https://typetype-video.github.io/Docs-TypeType/self-hosting/maintenance).
-If necessary, follow the [rollback guide](https://priveetee.github.io/Docs-TypeType/self-hosting/rollback).
+If necessary, follow the [rollback guide](https://typetype-video.github.io/Docs-TypeType/self-hosting/rollback).
diff --git a/TypeType-Downloader b/TypeType-Downloader
new file mode 160000
index 00000000..f82abf39
--- /dev/null
+++ b/TypeType-Downloader
@@ -0,0 +1 @@
+Subproject commit f82abf3947911aad67f57e0a0bd52e114c2c8375
diff --git a/TypeType-Frontend b/TypeType-Frontend
new file mode 160000
index 00000000..3e9f3cb7
--- /dev/null
+++ b/TypeType-Frontend
@@ -0,0 +1 @@
+Subproject commit 3e9f3cb70ea376c2f4eafffb210678964e37f75a
diff --git a/TypeType-Player b/TypeType-Player
new file mode 160000
index 00000000..5b8fdc84
--- /dev/null
+++ b/TypeType-Player
@@ -0,0 +1 @@
+Subproject commit 5b8fdc84c552bc85b5c2804bf58b32f46b333437
diff --git a/TypeType-Server b/TypeType-Server
new file mode 160000
index 00000000..232fa28d
--- /dev/null
+++ b/TypeType-Server
@@ -0,0 +1 @@
+Subproject commit 232fa28d2f88d069328c299e5074c4142bb8c165
diff --git a/TypeType-Token b/TypeType-Token
new file mode 160000
index 00000000..6795a612
--- /dev/null
+++ b/TypeType-Token
@@ -0,0 +1 @@
+Subproject commit 6795a612822f9b18a8f11e789126382636cfea20
diff --git a/apps/web/.env.example b/apps/web/.env.example
deleted file mode 100644
index d2911dca..00000000
--- a/apps/web/.env.example
+++ /dev/null
@@ -1,2 +0,0 @@
-VITE_API_URL=http://localhost:8080
-VITE_DEV_PROXY_TARGET=http://localhost:8080
diff --git a/apps/web/.gitignore b/apps/web/.gitignore
deleted file mode 100644
index 2bd18039..00000000
--- a/apps/web/.gitignore
+++ /dev/null
@@ -1,26 +0,0 @@
-.env
-
-# Logs
-logs
-*.log
-npm-debug.log*
-yarn-debug.log*
-yarn-error.log*
-pnpm-debug.log*
-lerna-debug.log*
-
-node_modules
-dist
-dist-ssr
-*.local
-
-# Editor directories and files
-.vscode/*
-!.vscode/extensions.json
-.idea
-.DS_Store
-*.suo
-*.ntvs*
-*.njsproj
-*.sln
-*.sw?
diff --git a/apps/web/index.html b/apps/web/index.html
deleted file mode 100644
index c0fcd1c3..00000000
--- a/apps/web/index.html
+++ /dev/null
@@ -1,81 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- TypeType
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/apps/web/package.json b/apps/web/package.json
deleted file mode 100644
index 03a72cb1..00000000
--- a/apps/web/package.json
+++ /dev/null
@@ -1,38 +0,0 @@
-{
- "name": "@typetype/web",
- "private": true,
- "version": "1.0.3",
- "type": "module",
- "scripts": {
- "dev": "vite",
- "build": "tsc -b && vite build",
- "preview": "vite preview",
- "test": "bun test",
- "test:coverage": "bun test --coverage --coverage-reporter=lcov"
- },
- "dependencies": {
- "@tanstack/react-query": "^5.101.2",
- "@tanstack/react-router": "^1.170.17",
- "@typetype/mse": "0.1.31",
- "@vidstack/react": "1.12.13",
- "dashjs": "^5.2.0",
- "hls.js": "1.6.16",
- "lucide-react": "^1.24.0",
- "media-icons": "1.1.5",
- "react": "^19.2.7",
- "react-dom": "^19.2.7",
- "simple-icons": "^16.25.0",
- "zustand": "^5.0.14"
- },
- "devDependencies": {
- "@tailwindcss/vite": "^4.3.2",
- "@tanstack/router-plugin": "^1.168.19",
- "@types/node": "^26.1.1",
- "@types/react": "^19.2.17",
- "@types/react-dom": "^19.2.3",
- "@vitejs/plugin-react": "^6.0.3",
- "tailwindcss": "^4.3.2",
- "typescript": "~6.0.3",
- "vite": "^8.1.4"
- }
-}
diff --git a/apps/web/public/app.webmanifest b/apps/web/public/app.webmanifest
deleted file mode 100644
index a5982ae9..00000000
--- a/apps/web/public/app.webmanifest
+++ /dev/null
@@ -1,22 +0,0 @@
-{
- "id": "/",
- "name": "TypeType",
- "short_name": "TypeType",
- "start_url": "/",
- "scope": "/",
- "display": "standalone",
- "background_color": "#09090b",
- "theme_color": "#09090b",
- "icons": [
- {
- "src": "/icon-192.png",
- "sizes": "192x192",
- "type": "image/png"
- },
- {
- "src": "/icon-512.png",
- "sizes": "512x512",
- "type": "image/png"
- }
- ]
-}
diff --git a/apps/web/public/apple-touch-icon.png b/apps/web/public/apple-touch-icon.png
deleted file mode 100644
index b3002740..00000000
Binary files a/apps/web/public/apple-touch-icon.png and /dev/null differ
diff --git a/apps/web/public/downloader-waiting.gif b/apps/web/public/downloader-waiting.gif
deleted file mode 100644
index 46062571..00000000
Binary files a/apps/web/public/downloader-waiting.gif and /dev/null differ
diff --git a/apps/web/public/error-cat.gif b/apps/web/public/error-cat.gif
deleted file mode 100644
index b9f31ce5..00000000
Binary files a/apps/web/public/error-cat.gif and /dev/null differ
diff --git a/apps/web/public/family-list-blocked.gif b/apps/web/public/family-list-blocked.gif
deleted file mode 100644
index fdc0fe91..00000000
Binary files a/apps/web/public/family-list-blocked.gif and /dev/null differ
diff --git a/apps/web/public/guest-disabled-bird.gif b/apps/web/public/guest-disabled-bird.gif
deleted file mode 100644
index ef1e69ca..00000000
Binary files a/apps/web/public/guest-disabled-bird.gif and /dev/null differ
diff --git a/apps/web/public/icon-192.png b/apps/web/public/icon-192.png
deleted file mode 100644
index 570e0186..00000000
Binary files a/apps/web/public/icon-192.png and /dev/null differ
diff --git a/apps/web/public/icon-512.png b/apps/web/public/icon-512.png
deleted file mode 100644
index bb04b250..00000000
Binary files a/apps/web/public/icon-512.png and /dev/null differ
diff --git a/apps/web/public/import-cooking-chef.gif b/apps/web/public/import-cooking-chef.gif
deleted file mode 100644
index 8df21e25..00000000
Binary files a/apps/web/public/import-cooking-chef.gif and /dev/null differ
diff --git a/apps/web/public/import-cooking-chef.webm b/apps/web/public/import-cooking-chef.webm
deleted file mode 100644
index dc206595..00000000
Binary files a/apps/web/public/import-cooking-chef.webm and /dev/null differ
diff --git a/apps/web/public/import-dudu-cooking.gif b/apps/web/public/import-dudu-cooking.gif
deleted file mode 100644
index 7d9d873e..00000000
Binary files a/apps/web/public/import-dudu-cooking.gif and /dev/null differ
diff --git a/apps/web/public/import-dudu-cooking.webm b/apps/web/public/import-dudu-cooking.webm
deleted file mode 100644
index c8b6dcde..00000000
Binary files a/apps/web/public/import-dudu-cooking.webm and /dev/null differ
diff --git a/apps/web/public/loader.gif b/apps/web/public/loader.gif
deleted file mode 100644
index 23873b25..00000000
Binary files a/apps/web/public/loader.gif and /dev/null differ
diff --git a/apps/web/public/logo.svg b/apps/web/public/logo.svg
deleted file mode 100644
index 2d412a28..00000000
--- a/apps/web/public/logo.svg
+++ /dev/null
@@ -1 +0,0 @@
-
diff --git a/apps/web/public/member-only-source.gif b/apps/web/public/member-only-source.gif
deleted file mode 100644
index 7bee0d9f..00000000
Binary files a/apps/web/public/member-only-source.gif and /dev/null differ
diff --git a/apps/web/public/member-only.gif b/apps/web/public/member-only.gif
deleted file mode 100644
index 7bee0d9f..00000000
Binary files a/apps/web/public/member-only.gif and /dev/null differ
diff --git a/apps/web/public/pipepipe-logo.png b/apps/web/public/pipepipe-logo.png
deleted file mode 100644
index a783bfec..00000000
Binary files a/apps/web/public/pipepipe-logo.png and /dev/null differ
diff --git a/apps/web/public/pixel/bird-0.png b/apps/web/public/pixel/bird-0.png
deleted file mode 100644
index a6e7ae56..00000000
Binary files a/apps/web/public/pixel/bird-0.png and /dev/null differ
diff --git a/apps/web/public/pixel/bird-1.png b/apps/web/public/pixel/bird-1.png
deleted file mode 100644
index 2684a827..00000000
Binary files a/apps/web/public/pixel/bird-1.png and /dev/null differ
diff --git a/apps/web/public/pixel/bird-2.png b/apps/web/public/pixel/bird-2.png
deleted file mode 100644
index 0c8c935f..00000000
Binary files a/apps/web/public/pixel/bird-2.png and /dev/null differ
diff --git a/apps/web/public/pixel/bird-3.png b/apps/web/public/pixel/bird-3.png
deleted file mode 100644
index bd0e4890..00000000
Binary files a/apps/web/public/pixel/bird-3.png and /dev/null differ
diff --git a/apps/web/public/pixel/cat-0.png b/apps/web/public/pixel/cat-0.png
deleted file mode 100644
index eeb2ffd5..00000000
Binary files a/apps/web/public/pixel/cat-0.png and /dev/null differ
diff --git a/apps/web/public/pixel/cat-1.png b/apps/web/public/pixel/cat-1.png
deleted file mode 100644
index f2e190aa..00000000
Binary files a/apps/web/public/pixel/cat-1.png and /dev/null differ
diff --git a/apps/web/public/pixel/cat-2.png b/apps/web/public/pixel/cat-2.png
deleted file mode 100644
index cbf28699..00000000
Binary files a/apps/web/public/pixel/cat-2.png and /dev/null differ
diff --git a/apps/web/public/pixel/cat-3.png b/apps/web/public/pixel/cat-3.png
deleted file mode 100644
index 9b9be398..00000000
Binary files a/apps/web/public/pixel/cat-3.png and /dev/null differ
diff --git a/apps/web/public/pixel/cat-4.png b/apps/web/public/pixel/cat-4.png
deleted file mode 100644
index f725fd53..00000000
Binary files a/apps/web/public/pixel/cat-4.png and /dev/null differ
diff --git a/apps/web/public/pixel/cloud-0.png b/apps/web/public/pixel/cloud-0.png
deleted file mode 100644
index 0361a50a..00000000
Binary files a/apps/web/public/pixel/cloud-0.png and /dev/null differ
diff --git a/apps/web/public/pixel/cloud-1.png b/apps/web/public/pixel/cloud-1.png
deleted file mode 100644
index 143b5450..00000000
Binary files a/apps/web/public/pixel/cloud-1.png and /dev/null differ
diff --git a/apps/web/public/pixel/cloud-3.png b/apps/web/public/pixel/cloud-3.png
deleted file mode 100644
index 24cb4b57..00000000
Binary files a/apps/web/public/pixel/cloud-3.png and /dev/null differ
diff --git a/apps/web/public/pixel/dog-carry-0.png b/apps/web/public/pixel/dog-carry-0.png
deleted file mode 100644
index 0f9393f6..00000000
Binary files a/apps/web/public/pixel/dog-carry-0.png and /dev/null differ
diff --git a/apps/web/public/pixel/dog-carry-1.png b/apps/web/public/pixel/dog-carry-1.png
deleted file mode 100644
index ccea0f12..00000000
Binary files a/apps/web/public/pixel/dog-carry-1.png and /dev/null differ
diff --git a/apps/web/public/pixel/dog-carry-2.png b/apps/web/public/pixel/dog-carry-2.png
deleted file mode 100644
index f0522021..00000000
Binary files a/apps/web/public/pixel/dog-carry-2.png and /dev/null differ
diff --git a/apps/web/public/pixel/dog-carry-3.png b/apps/web/public/pixel/dog-carry-3.png
deleted file mode 100644
index dd72d437..00000000
Binary files a/apps/web/public/pixel/dog-carry-3.png and /dev/null differ
diff --git a/apps/web/public/pixel/dog-carry-4.png b/apps/web/public/pixel/dog-carry-4.png
deleted file mode 100644
index 4fa445c1..00000000
Binary files a/apps/web/public/pixel/dog-carry-4.png and /dev/null differ
diff --git a/apps/web/public/pixel/dog-carry-5.png b/apps/web/public/pixel/dog-carry-5.png
deleted file mode 100644
index 3b0b3250..00000000
Binary files a/apps/web/public/pixel/dog-carry-5.png and /dev/null differ
diff --git a/apps/web/public/pixel/dog-carry-6.png b/apps/web/public/pixel/dog-carry-6.png
deleted file mode 100644
index 8197eb83..00000000
Binary files a/apps/web/public/pixel/dog-carry-6.png and /dev/null differ
diff --git a/apps/web/public/pixel/dog-carry-7.png b/apps/web/public/pixel/dog-carry-7.png
deleted file mode 100644
index 23da0154..00000000
Binary files a/apps/web/public/pixel/dog-carry-7.png and /dev/null differ
diff --git a/apps/web/public/pixel/dog-carry-8.png b/apps/web/public/pixel/dog-carry-8.png
deleted file mode 100644
index c798ecad..00000000
Binary files a/apps/web/public/pixel/dog-carry-8.png and /dev/null differ
diff --git a/apps/web/public/pixel/dog-carry-9.png b/apps/web/public/pixel/dog-carry-9.png
deleted file mode 100644
index de2c20c6..00000000
Binary files a/apps/web/public/pixel/dog-carry-9.png and /dev/null differ
diff --git a/apps/web/public/pixel/dog-idle.png b/apps/web/public/pixel/dog-idle.png
deleted file mode 100644
index 772ae360..00000000
Binary files a/apps/web/public/pixel/dog-idle.png and /dev/null differ
diff --git a/apps/web/public/pixel/dog-rest-0.png b/apps/web/public/pixel/dog-rest-0.png
deleted file mode 100644
index be187ed5..00000000
Binary files a/apps/web/public/pixel/dog-rest-0.png and /dev/null differ
diff --git a/apps/web/public/pixel/dog-rest-1.png b/apps/web/public/pixel/dog-rest-1.png
deleted file mode 100644
index f4e043fe..00000000
Binary files a/apps/web/public/pixel/dog-rest-1.png and /dev/null differ
diff --git a/apps/web/public/pixel/dog-rest-2.png b/apps/web/public/pixel/dog-rest-2.png
deleted file mode 100644
index 4b06267e..00000000
Binary files a/apps/web/public/pixel/dog-rest-2.png and /dev/null differ
diff --git a/apps/web/public/pixel/dog-run-0.png b/apps/web/public/pixel/dog-run-0.png
deleted file mode 100644
index cdb07178..00000000
Binary files a/apps/web/public/pixel/dog-run-0.png and /dev/null differ
diff --git a/apps/web/public/pixel/dog-run-1.png b/apps/web/public/pixel/dog-run-1.png
deleted file mode 100644
index 5d0e213b..00000000
Binary files a/apps/web/public/pixel/dog-run-1.png and /dev/null differ
diff --git a/apps/web/public/pixel/dog-run-2.png b/apps/web/public/pixel/dog-run-2.png
deleted file mode 100644
index f7844b2a..00000000
Binary files a/apps/web/public/pixel/dog-run-2.png and /dev/null differ
diff --git a/apps/web/public/pixel/dog-run-3.png b/apps/web/public/pixel/dog-run-3.png
deleted file mode 100644
index 7b520f7a..00000000
Binary files a/apps/web/public/pixel/dog-run-3.png and /dev/null differ
diff --git a/apps/web/public/pixel/dog-run-4.png b/apps/web/public/pixel/dog-run-4.png
deleted file mode 100644
index 9e5df25e..00000000
Binary files a/apps/web/public/pixel/dog-run-4.png and /dev/null differ
diff --git a/apps/web/public/pixel/table.svg b/apps/web/public/pixel/table.svg
deleted file mode 100644
index 5250015c..00000000
--- a/apps/web/public/pixel/table.svg
+++ /dev/null
@@ -1,31 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/apps/web/public/pixel/tv-0.png b/apps/web/public/pixel/tv-0.png
deleted file mode 100644
index a0b6ff7c..00000000
Binary files a/apps/web/public/pixel/tv-0.png and /dev/null differ
diff --git a/apps/web/public/pixel/tv-1.png b/apps/web/public/pixel/tv-1.png
deleted file mode 100644
index dbe7d890..00000000
Binary files a/apps/web/public/pixel/tv-1.png and /dev/null differ
diff --git a/apps/web/public/pixel/tv-2.png b/apps/web/public/pixel/tv-2.png
deleted file mode 100644
index 09aa407e..00000000
Binary files a/apps/web/public/pixel/tv-2.png and /dev/null differ
diff --git a/apps/web/public/pixel/tv-3.png b/apps/web/public/pixel/tv-3.png
deleted file mode 100644
index 2d6a176a..00000000
Binary files a/apps/web/public/pixel/tv-3.png and /dev/null differ
diff --git a/apps/web/public/pixel/tv-4.png b/apps/web/public/pixel/tv-4.png
deleted file mode 100644
index 50f76a44..00000000
Binary files a/apps/web/public/pixel/tv-4.png and /dev/null differ
diff --git a/apps/web/public/pixel/waterfall-0.png b/apps/web/public/pixel/waterfall-0.png
deleted file mode 100644
index d49f5f3e..00000000
Binary files a/apps/web/public/pixel/waterfall-0.png and /dev/null differ
diff --git a/apps/web/public/pixel/waterfall-1.png b/apps/web/public/pixel/waterfall-1.png
deleted file mode 100644
index c0c2d261..00000000
Binary files a/apps/web/public/pixel/waterfall-1.png and /dev/null differ
diff --git a/apps/web/public/pixel/waterfall-2.png b/apps/web/public/pixel/waterfall-2.png
deleted file mode 100644
index 1f1ae661..00000000
Binary files a/apps/web/public/pixel/waterfall-2.png and /dev/null differ
diff --git a/apps/web/public/pixel/waterfall-3.png b/apps/web/public/pixel/waterfall-3.png
deleted file mode 100644
index c7a4a14b..00000000
Binary files a/apps/web/public/pixel/waterfall-3.png and /dev/null differ
diff --git a/apps/web/public/sad-sigh.gif b/apps/web/public/sad-sigh.gif
deleted file mode 100644
index b919076f..00000000
Binary files a/apps/web/public/sad-sigh.gif and /dev/null differ
diff --git a/apps/web/src/components/account-identity-settings.tsx b/apps/web/src/components/account-identity-settings.tsx
deleted file mode 100644
index d52005c9..00000000
--- a/apps/web/src/components/account-identity-settings.tsx
+++ /dev/null
@@ -1,90 +0,0 @@
-import { useEffect, useState } from "react";
-import { useAccountIdentity } from "../hooks/use-account-identity";
-
-type Props = {
- enabled: boolean;
- onMessage: (message: string) => void;
-};
-
-export function AccountIdentitySettings({ enabled, onMessage }: Props) {
- const { query, update } = useAccountIdentity(enabled);
- const [email, setEmail] = useState("");
- const [name, setName] = useState("");
- const [password, setPassword] = useState("");
-
- useEffect(() => {
- if (!query.data) return;
- setEmail(query.data.email);
- setName(query.data.name);
- }, [query.data]);
-
- if (!enabled || query.isPending) return null;
- const managed = query.data?.managedByOidc ?? false;
- const dirty =
- email.trim().toLowerCase() !== query.data?.email || name.trim() !== query.data?.name;
-
- return (
-
- );
-}
diff --git a/apps/web/src/components/admin-allow-list-channel-list.tsx b/apps/web/src/components/admin-allow-list-channel-list.tsx
deleted file mode 100644
index 627385d7..00000000
--- a/apps/web/src/components/admin-allow-list-channel-list.tsx
+++ /dev/null
@@ -1,98 +0,0 @@
-import { channelRoutePath } from "../lib/channel-route-url";
-import type { AllowedChannelItem } from "../types/user";
-import { ChannelAvatar } from "./channel-avatar";
-import { ChannelRouteLink } from "./channel-route-link";
-
-function XIcon() {
- return (
-
-
-
-
- );
-}
-
-type Props = {
- title?: string;
- channels: AllowedChannelItem[];
- onRemove?: (url: string) => void;
-};
-
-export function AdminAllowListChannelList({
- title = "Allowed channels",
- channels,
- onRemove,
-}: Props) {
- return (
-
-
-
-
{title}
-
- Channels available when allow-list mode is enabled.
-
-
-
- {channels.length} {channels.length === 1 ? "channel" : "channels"}
-
-
- {channels.length === 0 ? (
- No channels added.
- ) : (
-
- {channels.map((item) => {
- const label = item.name ?? item.url;
- const typeTypeUrl = channelRoutePath(item.url);
- return (
-
-
-
-
- {label}
-
-
- {typeTypeUrl}
-
-
- {onRemove && (
-
onRemove(item.url)}
- aria-label={`Remove ${label}`}
- className="flex h-7 w-7 shrink-0 items-center justify-center text-fg-soft transition-colors hover:text-fg"
- >
-
-
- )}
-
- );
- })}
-
- )}
-
- );
-}
diff --git a/apps/web/src/components/admin-allow-list-form.tsx b/apps/web/src/components/admin-allow-list-form.tsx
deleted file mode 100644
index 3937ce2d..00000000
--- a/apps/web/src/components/admin-allow-list-form.tsx
+++ /dev/null
@@ -1,101 +0,0 @@
-import { useQuery } from "@tanstack/react-query";
-import { useState } from "react";
-import { useDebouncedValue } from "../hooks/use-debounced-value";
-import { fetchSearch } from "../lib/api-discovery";
-import { normalizeChannelUrl } from "../lib/channel-url";
-import { formatSubscribers } from "../lib/format";
-import { proxyImage } from "../lib/proxy";
-import type { ChannelResultItem } from "../types/api";
-import { ChannelAvatar } from "./channel-avatar";
-import { ChannelRouteLink } from "./channel-route-link";
-
-function isTrusted(channel: ChannelResultItem, trustedUrls: Set): boolean {
- return trustedUrls.has(normalizeChannelUrl(channel.url));
-}
-
-type Props = {
- title: string;
- description: string;
- trustedUrls: string[];
- pending: boolean;
- onAdd: (channel: ChannelResultItem) => void;
-};
-
-export function AdminAllowListForm({ title, description, trustedUrls, pending, onAdd }: Props) {
- const [term, setTerm] = useState("");
- const debounced = useDebouncedValue(term.trim(), 300);
- const trusted = new Set(trustedUrls.map(normalizeChannelUrl));
- const search = useQuery({
- queryKey: ["admin-allow-list-channel-search", debounced],
- queryFn: () => fetchSearch(debounced, 0),
- enabled: debounced.length >= 2,
- staleTime: 60 * 1000,
- });
- const channels = search.data?.channels ?? [];
-
- return (
-
-
-
{title}
-
{description}
-
- setTerm(event.target.value)}
- placeholder="Channel name or @handle"
- className="h-10 w-full border border-border bg-app px-3 text-sm text-fg outline-none transition-colors placeholder:text-fg-muted focus:border-border-strong"
- />
-
- {debounced.length < 2 ? (
-
- Type at least two characters to search channels.
-
- ) : search.isLoading ? (
-
Searching channels...
- ) : channels.length === 0 ? (
-
- No channels found. Try the exact channel name or handle.
-
- ) : (
-
- {channels.slice(0, 8).map((channel) => {
- const alreadyAdded = isTrusted(channel, trusted);
- return (
-
-
-
-
- {channel.name}
-
-
- {formatSubscribers(channel.subscriberCount)}
-
-
-
onAdd(channel)}
- className={`h-8 shrink-0 border px-3 text-xs font-medium transition-colors disabled:cursor-not-allowed disabled:opacity-60 ${
- alreadyAdded
- ? "border-border text-fg-soft"
- : "border-fg bg-fg text-app hover:bg-fg-strong"
- }`}
- >
- {alreadyAdded ? "Added" : "Add"}
-
-
- );
- })}
-
- )}
-
-
- );
-}
diff --git a/apps/web/src/components/admin-allow-list-playlist-list.tsx b/apps/web/src/components/admin-allow-list-playlist-list.tsx
deleted file mode 100644
index 8f0abd46..00000000
--- a/apps/web/src/components/admin-allow-list-playlist-list.tsx
+++ /dev/null
@@ -1,96 +0,0 @@
-import { Link } from "@tanstack/react-router";
-import { proxyImage } from "../lib/proxy";
-import type { AllowedPlaylistItem } from "../types/allow-list";
-
-type Props = {
- title: string;
- playlists: AllowedPlaylistItem[];
- onRemove?: (url: string) => void;
-};
-
-function playlistPath(url: string): string {
- const params = new URLSearchParams({ url });
- return `/playlist?${params.toString()}`;
-}
-
-function XIcon() {
- return (
-
-
-
-
- );
-}
-
-export function AdminAllowListPlaylistList({ title, playlists, onRemove }: Props) {
- return (
-
-
-
{title}
-
- {playlists.length} {playlists.length === 1 ? "playlist" : "playlists"}
-
-
- {playlists.length === 0 ? (
- No playlists added.
- ) : (
-
- {playlists.map((playlist) => {
- const label = playlist.title ?? playlist.url;
- return (
-
-
-
-
- {label}
-
-
- {playlistPath(playlist.url)}
-
-
- {onRemove && (
-
onRemove(playlist.url)}
- aria-label={`Remove ${label}`}
- className="flex h-7 w-7 shrink-0 items-center justify-center text-fg-soft transition-colors hover:text-fg"
- >
-
-
- )}
-
- );
- })}
-
- )}
-
- );
-}
diff --git a/apps/web/src/components/admin-allow-list-playlist-search.tsx b/apps/web/src/components/admin-allow-list-playlist-search.tsx
deleted file mode 100644
index cc76f26f..00000000
--- a/apps/web/src/components/admin-allow-list-playlist-search.tsx
+++ /dev/null
@@ -1,149 +0,0 @@
-import { useQuery } from "@tanstack/react-query";
-import { Link } from "@tanstack/react-router";
-import { useState } from "react";
-import { useDebouncedValue } from "../hooks/use-debounced-value";
-import { fetchSearch } from "../lib/api-discovery";
-import { proxyImage } from "../lib/proxy";
-import type { AllowPlaylistInput } from "../types/allow-list";
-import type { PublicPlaylistInfo } from "../types/playlist";
-
-type Props = {
- title: string;
- description: string;
- addedUrls: string[];
- pending: boolean;
- onAdd: (playlist: AllowPlaylistInput) => void;
-};
-
-function playlistPath(url: string): string {
- const params = new URLSearchParams({ url });
- return `/playlist?${params.toString()}`;
-}
-
-function playlistSourceUrl(input: string): string {
- const trimmed = input.trim();
- if (trimmed.length === 0) return "";
- try {
- const parsed = new URL(trimmed, "http://typetype.local");
- const sourceUrl = parsed.pathname === "/playlist" ? parsed.searchParams.get("url") : null;
- return sourceUrl?.trim() || trimmed;
- } catch {
- return trimmed;
- }
-}
-
-export function AdminAllowListPlaylistSearch({
- title,
- description,
- addedUrls,
- pending,
- onAdd,
-}: Props) {
- const [term, setTerm] = useState("");
- const [url, setUrl] = useState("");
- const debounced = useDebouncedValue(term.trim(), 300);
- const added = new Set(addedUrls);
- const search = useQuery({
- queryKey: ["admin-allow-list-playlist-search", debounced],
- queryFn: () => fetchSearch(debounced, 0),
- enabled: debounced.length >= 2,
- staleTime: 60 * 1000,
- });
- const playlists = search.data?.playlists ?? [];
- const playlistUrl = playlistSourceUrl(url);
- const urlAlreadyAdded = added.has(playlistUrl);
-
- function addUrl() {
- if (playlistUrl.length === 0 || urlAlreadyAdded || pending) return;
- onAdd({ url: playlistUrl, title: null, thumbnailUrl: null, uploaderName: null });
- setUrl("");
- }
-
- function addPlaylist(playlist: PublicPlaylistInfo) {
- onAdd({
- url: playlist.url,
- title: playlist.title,
- thumbnailUrl: playlist.thumbnailUrl,
- uploaderName: playlist.uploaderName,
- });
- }
-
- return (
-
-
-
{title}
-
{description}
-
-
- setUrl(event.target.value)}
- placeholder="Playlist URL"
- className="h-10 flex-1 border border-border bg-app px-3 text-sm text-fg outline-none transition-colors placeholder:text-fg-muted focus:border-border-strong"
- />
-
- {urlAlreadyAdded ? "Added" : "Add URL"}
-
-
- setTerm(event.target.value)}
- placeholder="Playlist name"
- className="h-10 w-full border border-border bg-app px-3 text-sm text-fg outline-none transition-colors placeholder:text-fg-muted focus:border-border-strong"
- />
-
- {debounced.length < 2 ? (
-
Type at least two characters.
- ) : search.isLoading ? (
-
Searching playlists...
- ) : playlists.length === 0 ? (
-
No playlists found.
- ) : (
-
- {playlists.slice(0, 8).map((playlist) => {
- const alreadyAdded = added.has(playlist.url);
- return (
-
-
-
-
- {playlist.title}
-
-
{playlistPath(playlist.url)}
-
-
addPlaylist(playlist)}
- className={`h-8 shrink-0 border px-3 text-xs font-medium transition-colors disabled:cursor-not-allowed disabled:opacity-60 ${
- alreadyAdded
- ? "border-border text-fg-soft"
- : "border-fg bg-fg text-app hover:bg-fg-strong"
- }`}
- >
- {alreadyAdded ? "Added" : "Add"}
-
-
- );
- })}
-
- )}
-
-
- );
-}
diff --git a/apps/web/src/components/admin-allow-list-section.tsx b/apps/web/src/components/admin-allow-list-section.tsx
deleted file mode 100644
index ed056829..00000000
--- a/apps/web/src/components/admin-allow-list-section.tsx
+++ /dev/null
@@ -1,123 +0,0 @@
-import {
- useAdminAllowedPlaylists,
- useAdminAllowListMutations,
-} from "../hooks/use-admin-granular-allow-list";
-import { useAdminSettings } from "../hooks/use-admin-settings";
-import { useAllowedChannels } from "../hooks/use-allowed-channels";
-import { AdminAllowListChannelList } from "./admin-allow-list-channel-list";
-import { AdminAllowListForm } from "./admin-allow-list-form";
-import { AdminAllowListPlaylistList } from "./admin-allow-list-playlist-list";
-import { AdminAllowListPlaylistSearch } from "./admin-allow-list-playlist-search";
-import { AdminAllowListUsers } from "./admin-allow-list-users";
-
-const MODE_BUTTON =
- "h-8 border border-border px-3 text-xs transition-colors disabled:cursor-not-allowed disabled:opacity-60";
-
-type Props = {
- enabled: boolean;
- onToast: (message: string) => void;
-};
-
-export function AdminAllowListSection({ enabled, onToast }: Props) {
- const adminSettings = useAdminSettings(enabled);
- const { query, add, remove } = useAllowedChannels();
- const globalPlaylists = useAdminAllowedPlaylists(enabled);
- const mutations = useAdminAllowListMutations(null);
- const settings = adminSettings.query.data;
- const channels = (query.data ?? []).filter((item) => item.global === true);
- const playlists = globalPlaylists.data ?? [];
- const instanceRestricted = settings?.accessMode === "allow_list";
-
- function setMode(accessMode: "unrestricted" | "allow_list") {
- if (!settings) return;
- adminSettings.update.mutate(
- { ...settings, accessMode },
- {
- onSuccess: () => onToast("Allow list mode updated"),
- onError: (error) =>
- onToast(error instanceof Error ? error.message : "Unable to update mode"),
- },
- );
- }
-
- if (adminSettings.query.isPending) {
- return (
-
- Loading allow list...
-
- );
- }
-
- if (!settings || adminSettings.query.isError) {
- return (
-
- Unable to load allow list settings.
-
- );
- }
-
- return (
-
-
-
-
-
Entire instance
-
- Restrict everyone to the admin allow list, including guests.
-
-
-
- setMode("unrestricted")}
- className={`${MODE_BUTTON} ${!instanceRestricted ? "bg-fg text-app" : "text-fg-soft hover:text-fg"}`}
- >
- Unrestricted
-
- setMode("allow_list")}
- className={`${MODE_BUTTON} ${instanceRestricted ? "bg-fg text-app" : "text-fg-soft hover:text-fg"}`}
- >
- Restrict entire instance
-
-
-
-
-
-
item.url)}
- pending={add.isPending}
- onAdd={(channel) =>
- add.mutate({
- url: channel.url,
- name: channel.name,
- thumbnailUrl: channel.thumbnailUrl,
- global: true,
- })
- }
- />
-
- item.url)}
- pending={mutations.addGlobalPlaylist.isPending}
- onAdd={(playlist) => mutations.addGlobalPlaylist.mutate(playlist)}
- />
-
-
- );
-}
diff --git a/apps/web/src/components/admin-allow-list-user-detail.tsx b/apps/web/src/components/admin-allow-list-user-detail.tsx
deleted file mode 100644
index 65928e28..00000000
--- a/apps/web/src/components/admin-allow-list-user-detail.tsx
+++ /dev/null
@@ -1,164 +0,0 @@
-import {
- useAdminAllowListMutations,
- useAdminUserAllowList,
-} from "../hooks/use-admin-granular-allow-list";
-import type { AdminAllowListUser } from "../types/allow-list";
-import { AdminAllowListChannelList } from "./admin-allow-list-channel-list";
-import { AdminAllowListForm } from "./admin-allow-list-form";
-import { AdminAllowListPlaylistList } from "./admin-allow-list-playlist-list";
-import { AdminAllowListPlaylistSearch } from "./admin-allow-list-playlist-search";
-import { AdminUserAvatar } from "./admin-user-avatar";
-
-type Props = {
- user: AdminAllowListUser;
- instanceRestricted: boolean;
- onToast: (message: string) => void;
-};
-
-function accessState(user: AdminAllowListUser, instanceRestricted: boolean) {
- if (user.accessMode === "allow_list") {
- return {
- label: "User-specific allow-list",
- action: instanceRestricted ? "Set unrestricted override" : "Unrestrict user",
- nextMode: "unrestricted" as const,
- toast: instanceRestricted ? "Unrestricted override set" : "User unrestricted",
- active: true,
- };
- }
- if (instanceRestricted && user.adminManagedAccessMode) {
- return {
- label: "Admin unrestricted override",
- action: "Restrict user",
- nextMode: "allow_list" as const,
- toast: "User restricted",
- active: true,
- };
- }
- if (instanceRestricted) {
- return {
- label: "Restricted by entire instance",
- action: "Set unrestricted override",
- nextMode: "unrestricted" as const,
- toast: "Unrestricted override set",
- active: false,
- };
- }
- return {
- label: "Unrestricted",
- action: "Restrict user",
- nextMode: "allow_list" as const,
- toast: "User restricted",
- active: false,
- };
-}
-
-export function AdminAllowListUserDetail({ user, instanceRestricted, onToast }: Props) {
- const detail = useAdminUserAllowList(user.id);
- const mutations = useAdminAllowListMutations(user.id);
- const data = detail.data;
- const selected = data?.user
- ? {
- ...user,
- ...data.user,
- adminManagedAccessMode: data.user.adminManagedAccessMode ?? user.adminManagedAccessMode,
- avatarUrl: data.user.avatarUrl ?? user.avatarUrl,
- avatarType: data.user.avatarType ?? user.avatarType,
- avatarCode: data.user.avatarCode ?? user.avatarCode,
- }
- : user;
- const state = accessState(selected, instanceRestricted);
-
- function toggleMode() {
- mutations.userMode.mutate(
- { id: selected.id, accessMode: state.nextMode },
- {
- onSuccess: () => onToast(state.toast),
- onError: (error) =>
- onToast(error instanceof Error ? error.message : "Unable to update user"),
- },
- );
- }
-
- if (detail.isLoading || !data) {
- return (
-
- Loading user allow list...
-
- );
- }
-
- return (
-
-
-
-
-
-
-
- {selected.name || selected.email}
-
-
{selected.email}
-
{state.label}
-
-
-
- {state.action}
-
-
-
-
-
-
-
-
item.url)}
- pending={mutations.addUserChannel.isPending}
- onAdd={(channel) => mutations.addUserChannel.mutate({ id: selected.id, channel })}
- />
- mutations.removeUserChannel.mutate({ id: selected.id, url })}
- />
-
- item.url)}
- pending={mutations.addUserPlaylist.isPending}
- onAdd={(playlist) => mutations.addUserPlaylist.mutate({ id: selected.id, playlist })}
- />
- mutations.removeUserPlaylist.mutate({ id: selected.id, url })}
- />
-
- );
-}
diff --git a/apps/web/src/components/admin-allow-list-users.tsx b/apps/web/src/components/admin-allow-list-users.tsx
deleted file mode 100644
index c57d52b1..00000000
--- a/apps/web/src/components/admin-allow-list-users.tsx
+++ /dev/null
@@ -1,112 +0,0 @@
-import { useState } from "react";
-import { useAdminAllowListUsers, useAdminUserSearch } from "../hooks/use-admin-granular-allow-list";
-import type { AdminAllowListUser } from "../types/allow-list";
-import { AdminAllowListUserDetail } from "./admin-allow-list-user-detail";
-import { AdminUserAvatar } from "./admin-user-avatar";
-
-type Props = {
- enabled: boolean;
- instanceRestricted: boolean;
- onToast: (message: string) => void;
-};
-
-function avatarUser(user: AdminAllowListUser) {
- return {
- ...user,
- role: "user" as const,
- publicUsername: null,
- bio: null,
- avatarUrl: user.avatarUrl ?? null,
- avatarType: user.avatarType ?? null,
- avatarCode: user.avatarCode ?? null,
- suspended: false,
- verified: false,
- createdAt: 0,
- };
-}
-
-function accessLabel(user: AdminAllowListUser, instanceRestricted: boolean): string {
- if (user.accessMode === "allow_list") return "User restricted";
- if (!instanceRestricted) return "Unrestricted";
- return user.adminManagedAccessMode ? "Admin override" : "Instance restricted";
-}
-
-export function AdminAllowListUsers({ enabled, instanceRestricted, onToast }: Props) {
- const [search, setSearch] = useState("");
- const [selected, setSelected] = useState(null);
- const searching = search.trim().length >= 2;
- const managedQuery = useAdminAllowListUsers(enabled && !searching);
- const searchQuery = useAdminUserSearch(search, enabled && searching);
- const managedUsers = managedQuery.data?.pages.flatMap((page) => page.items) ?? [];
- const visibleUsers = searching ? (searchQuery.data ?? []) : managedUsers;
- const loading = searching ? searchQuery.isLoading : managedQuery.isLoading;
-
- return (
-
-
-
Specific users
-
- Users with admin-managed access appear here. Search to configure another account.
-
-
- setSearch(event.target.value)}
- placeholder="Search by email or name"
- className="h-10 w-full border border-border bg-app px-3 text-sm text-fg outline-none transition-colors placeholder:text-fg-muted focus:border-border-strong"
- />
-
- {loading ? (
-
Searching users...
- ) : visibleUsers.length === 0 ? (
-
- {searching ? "No users found." : "No users are managed by allow-list rules yet."}
-
- ) : (
-
- {visibleUsers.map((user) => (
-
setSelected(user)}
- className={`flex w-full min-w-0 items-center gap-3 border-l-2 py-2.5 pl-2 pr-0 text-left transition-colors hover:border-border-strong ${
- selected?.id === user.id ? "border-fg" : "border-transparent"
- }`}
- >
-
-
-
{user.name || user.email}
-
{user.email}
-
-
- {accessLabel(user, instanceRestricted)}
-
-
- ))}
-
- )}
-
- {!searching && managedQuery.hasNextPage && (
- {
- void managedQuery.fetchNextPage();
- }}
- className="mt-3 h-8 border border-border px-3 text-xs text-fg-soft transition-colors hover:border-border-strong hover:text-fg disabled:cursor-not-allowed disabled:opacity-60"
- >
- {managedQuery.isFetchingNextPage ? "Loading..." : "Load more users"}
-
- )}
- {selected && (
-
- )}
-
- );
-}
diff --git a/apps/web/src/components/admin-bug-report-detail.tsx b/apps/web/src/components/admin-bug-report-detail.tsx
deleted file mode 100644
index 3ad8aaa8..00000000
--- a/apps/web/src/components/admin-bug-report-detail.tsx
+++ /dev/null
@@ -1,35 +0,0 @@
-import type { BugReportDetail, BugReportStatus } from "../types/bug-report";
-import { AdminBugReportDiagnostics } from "./admin-bug-report-diagnostics";
-import { AdminBugReportGitHubAction } from "./admin-bug-report-github-action";
-import { AdminBugReportOverview } from "./admin-bug-report-overview";
-import { AdminBugReportStatusControl } from "./admin-bug-report-status-control";
-
-type Props = {
- report: BugReportDetail;
- busy: boolean;
- isAdmin: boolean;
- onStatusChange: (status: BugReportStatus) => void;
- onCreateIssue: () => void;
-};
-
-export function AdminBugReportDetailPanel({
- report,
- busy,
- isAdmin,
- onStatusChange,
- onCreateIssue,
-}: Props) {
- return (
-
- );
-}
diff --git a/apps/web/src/components/admin-bug-report-diagnostics-details.tsx b/apps/web/src/components/admin-bug-report-diagnostics-details.tsx
deleted file mode 100644
index df7d9c04..00000000
--- a/apps/web/src/components/admin-bug-report-diagnostics-details.tsx
+++ /dev/null
@@ -1,61 +0,0 @@
-import { formatTimestamp } from "../lib/bug-report-utils";
-import type { BugReportDetail } from "../types/bug-report";
-
-type Props = {
- report: BugReportDetail;
-};
-
-export function AdminBugReportDiagnosticsDetails({ report }: Props) {
- const crashLogs = report.context.crashLogs;
- const apiErrors = report.context.apiErrors;
-
- return (
- <>
- {report.context.playerState && (
-
- Player state
-
- {JSON.stringify(report.context.playerState, null, 2)}
-
-
- )}
-
- {apiErrors.length > 0 && (
-
- API errors
-
- {apiErrors.slice(0, 8).map((error) => (
-
-
{error.endpoint}
-
- {error.status} · {error.code ?? "unknown"} · {formatTimestamp(error.timestamp)}
-
-
{error.message}
-
- ))}
-
-
- )}
-
- {crashLogs.length > 0 && (
-
- Crash logs
-
- {crashLogs.slice(0, 5).map((entry) => (
-
-
{entry.message}
-
{formatTimestamp(entry.timestamp)}
-
- ))}
-
-
- )}
- >
- );
-}
diff --git a/apps/web/src/components/admin-bug-report-diagnostics-meta.tsx b/apps/web/src/components/admin-bug-report-diagnostics-meta.tsx
deleted file mode 100644
index 1fe62525..00000000
--- a/apps/web/src/components/admin-bug-report-diagnostics-meta.tsx
+++ /dev/null
@@ -1,28 +0,0 @@
-import { formatTimestamp } from "../lib/bug-report-utils";
-import type { BugReportDetail } from "../types/bug-report";
-
-type Props = {
- report: BugReportDetail;
-};
-
-function shorten(value: string, max: number): string {
- if (value.length <= max) return value;
- return `${value.slice(0, max)}...`;
-}
-
-export function AdminBugReportDiagnosticsMeta({ report }: Props) {
- return (
-
-
Route: {report.context.route || "unknown"}
-
Language: {report.context.browserLanguage || "unknown"}
-
- User agent: {shorten(report.context.userAgent || "unknown", 110)}
-
-
- Crash logs: {report.context.crashLogs.length} · API errors:{" "}
- {report.context.apiErrors.length}
-
-
Captured at: {formatTimestamp(report.context.timestamp)}
-
- );
-}
diff --git a/apps/web/src/components/admin-bug-report-diagnostics.tsx b/apps/web/src/components/admin-bug-report-diagnostics.tsx
deleted file mode 100644
index 743b5f73..00000000
--- a/apps/web/src/components/admin-bug-report-diagnostics.tsx
+++ /dev/null
@@ -1,41 +0,0 @@
-import { useMemo, useState } from "react";
-import type { BugReportDetail } from "../types/bug-report";
-import { AdminBugReportDiagnosticsDetails } from "./admin-bug-report-diagnostics-details";
-import { AdminBugReportDiagnosticsMeta } from "./admin-bug-report-diagnostics-meta";
-
-type Props = {
- report: BugReportDetail;
-};
-
-export function AdminBugReportDiagnostics({ report }: Props) {
- const [copied, setCopied] = useState(false);
- const diagnosticsJson = useMemo(() => JSON.stringify(report.context, null, 2), [report.context]);
-
- async function copyDiagnostics() {
- if (typeof navigator === "undefined" || !navigator.clipboard) return;
- try {
- await navigator.clipboard.writeText(diagnosticsJson);
- setCopied(true);
- window.setTimeout(() => setCopied(false), 1600);
- } catch {
- setCopied(false);
- }
- }
-
- return (
-
-
-
Diagnostics
-
- {copied ? "Copied" : "Copy JSON"}
-
-
-
-
-
- );
-}
diff --git a/apps/web/src/components/admin-bug-report-filters.tsx b/apps/web/src/components/admin-bug-report-filters.tsx
deleted file mode 100644
index d36ebb6e..00000000
--- a/apps/web/src/components/admin-bug-report-filters.tsx
+++ /dev/null
@@ -1,63 +0,0 @@
-import { CATEGORY_OPTIONS, STATUS_OPTIONS } from "../lib/bug-report-utils";
-import type { BugReportCategory, BugReportStatus } from "../types/bug-report";
-
-type Props = {
- statusFilter: BugReportStatus | undefined;
- categoryFilter: BugReportCategory | undefined;
- searchText: string;
- onStatusChange: (value: BugReportStatus | undefined) => void;
- onCategoryChange: (value: BugReportCategory | undefined) => void;
- onSearchChange: (value: string) => void;
-};
-
-export function AdminBugReportFilters({
- statusFilter,
- categoryFilter,
- searchText,
- onStatusChange,
- onCategoryChange,
- onSearchChange,
-}: Props) {
- return (
-
- onSearchChange(e.target.value)}
- placeholder="Search id, email, text..."
- className="rounded border border-border-strong bg-transparent px-2 py-1.5 text-sm text-fg placeholder:text-fg-soft"
- />
-
- onStatusChange(e.target.value ? (e.target.value as BugReportStatus) : undefined)
- }
- className="rounded border border-border-strong bg-transparent px-2 py-1.5 text-sm text-fg"
- >
-
- All Statuses
-
- {STATUS_OPTIONS.map((opt) => (
-
- {opt.label}
-
- ))}
-
-
- onCategoryChange(e.target.value ? (e.target.value as BugReportCategory) : undefined)
- }
- className="rounded border border-border-strong bg-transparent px-2 py-1.5 text-sm text-fg"
- >
-
- All Categories
-
- {CATEGORY_OPTIONS.map((opt) => (
-
- {opt.label}
-
- ))}
-
-
- );
-}
diff --git a/apps/web/src/components/admin-bug-report-github-action.tsx b/apps/web/src/components/admin-bug-report-github-action.tsx
deleted file mode 100644
index 639c4ece..00000000
--- a/apps/web/src/components/admin-bug-report-github-action.tsx
+++ /dev/null
@@ -1,37 +0,0 @@
-import { ExternalLink } from "lucide-react";
-import type { BugReportDetail } from "../types/bug-report";
-
-type Props = {
- report: BugReportDetail;
- busy: boolean;
- isAdmin: boolean;
- onCreateIssue: () => void;
-};
-
-export function AdminBugReportGitHubAction({ report, busy, isAdmin, onCreateIssue }: Props) {
- if (!isAdmin) return null;
- if (report.githubIssueUrl) {
- return (
-
-
- View GitHub Issue
-
- );
- }
-
- return (
-
- Create GitHub Issue
-
- );
-}
diff --git a/apps/web/src/components/admin-bug-report-list.tsx b/apps/web/src/components/admin-bug-report-list.tsx
deleted file mode 100644
index 4e0a5e46..00000000
--- a/apps/web/src/components/admin-bug-report-list.tsx
+++ /dev/null
@@ -1,39 +0,0 @@
-import { formatTimestamp } from "../lib/bug-report-utils";
-import type { BugReportListItem } from "../types/bug-report";
-
-type Props = {
- reports: BugReportListItem[];
- selectedId: string | null;
- onSelect: (id: string) => void;
-};
-
-function trimDescription(value: string): string {
- if (value.length <= 90) return value;
- return `${value.slice(0, 90)}...`;
-}
-
-export function AdminBugReportList({ reports, selectedId, onSelect }: Props) {
- if (reports.length === 0) return No bug reports found.
;
-
- return (
-
- {reports.map((report) => {
- const selected = selectedId === report.id;
- return (
-
onSelect(report.id)}
- className={`w-full border-l-2 px-3 py-2 text-left ${selected ? "border-border bg-surface/70" : "border-border-strong hover:border-border-strong"}`}
- >
- {trimDescription(report.description)}
-
- {report.category.replace("_", " ")} · {report.userEmail}
-
- {formatTimestamp(report.createdAt)}
-
- );
- })}
-
- );
-}
diff --git a/apps/web/src/components/admin-bug-report-overview.tsx b/apps/web/src/components/admin-bug-report-overview.tsx
deleted file mode 100644
index 65f3fd00..00000000
--- a/apps/web/src/components/admin-bug-report-overview.tsx
+++ /dev/null
@@ -1,25 +0,0 @@
-import { formatTimestamp } from "../lib/bug-report-utils";
-import type { BugReportDetail } from "../types/bug-report";
-
-type Props = {
- report: BugReportDetail;
-};
-
-export function AdminBugReportOverview({ report }: Props) {
- return (
-
-
-
Issue
-
{formatTimestamp(report.createdAt)}
-
- {report.status.replace("_", " ")}
- {report.description}
-
- {report.category.replace("_", " ")} · {report.userEmail}
-
- {report.context.videoUrl && (
- {report.context.videoUrl}
- )}
-
- );
-}
diff --git a/apps/web/src/components/admin-bug-report-pager.tsx b/apps/web/src/components/admin-bug-report-pager.tsx
deleted file mode 100644
index 81e3ddb2..00000000
--- a/apps/web/src/components/admin-bug-report-pager.tsx
+++ /dev/null
@@ -1,38 +0,0 @@
-type Props = {
- page: number;
- totalPages: number;
- total: number;
- onPrev: () => void;
- onNext: () => void;
-};
-
-export function AdminBugReportPager({ page, totalPages, total, onPrev, onNext }: Props) {
- return (
-
-
- {total} report{total !== 1 ? "s" : ""}
-
-
-
- Prev
-
-
- Page {page} of {totalPages}
-
- = totalPages}
- onClick={onNext}
- className="rounded border border-border-strong px-2 py-1 disabled:opacity-50"
- >
- Next
-
-
-
- );
-}
diff --git a/apps/web/src/components/admin-bug-report-status-control.tsx b/apps/web/src/components/admin-bug-report-status-control.tsx
deleted file mode 100644
index a0272359..00000000
--- a/apps/web/src/components/admin-bug-report-status-control.tsx
+++ /dev/null
@@ -1,28 +0,0 @@
-import { STATUS_OPTIONS } from "../lib/bug-report-utils";
-import type { BugReportDetail, BugReportStatus } from "../types/bug-report";
-
-type Props = {
- report: BugReportDetail;
- busy: boolean;
- onStatusChange: (status: BugReportStatus) => void;
-};
-
-export function AdminBugReportStatusControl({ report, busy, onStatusChange }: Props) {
- return (
-
- Status
- onStatusChange(e.target.value as BugReportStatus)}
- className="w-full rounded border border-border-strong bg-transparent px-2 py-1.5 text-sm text-fg disabled:opacity-50"
- >
- {STATUS_OPTIONS.map((opt) => (
-
- {opt.label}
-
- ))}
-
-
- );
-}
diff --git a/apps/web/src/components/admin-bug-reports-section.tsx b/apps/web/src/components/admin-bug-reports-section.tsx
deleted file mode 100644
index 61906ed7..00000000
--- a/apps/web/src/components/admin-bug-reports-section.tsx
+++ /dev/null
@@ -1,105 +0,0 @@
-import { useEffect, useState } from "react";
-import { useAdminBugReportDetail, useAdminBugReports } from "../hooks/use-admin-bug-reports";
-import { filterBugReports } from "../lib/admin-bug-report-filter";
-import type { BugReportCategory, BugReportStatus } from "../types/bug-report";
-import { AdminBugReportDetailPanel } from "./admin-bug-report-detail";
-import { AdminBugReportFilters } from "./admin-bug-report-filters";
-import { AdminBugReportList } from "./admin-bug-report-list";
-import { AdminBugReportPager } from "./admin-bug-report-pager";
-
-const PAGE_SIZE = 20;
-
-type Props = {
- enabled: boolean;
- isAdmin: boolean;
- onToast: (message: string) => void;
-};
-
-export function AdminBugReportsSection({ enabled, isAdmin, onToast }: Props) {
- const [page, setPage] = useState(1);
- const [statusFilter, setStatusFilter] = useState();
- const [categoryFilter, setCategoryFilter] = useState();
- const [searchText, setSearchText] = useState("");
- const [selectedId, setSelectedId] = useState(null);
- const { query, updateStatus, createIssue } = useAdminBugReports(enabled, {
- page,
- limit: PAGE_SIZE,
- });
- const reports = filterBugReports(query.data?.items ?? [], {
- status: statusFilter,
- category: categoryFilter,
- query: searchText,
- });
- const total = query.data?.total ?? 0;
- const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
- const { query: detailQuery } = useAdminBugReportDetail(enabled && !!selectedId, selectedId ?? "");
-
- useEffect(() => {
- if (reports.length > 0 && !selectedId) setSelectedId(reports[0].id);
- }, [reports, selectedId]);
-
- const busy = updateStatus.isPending || createIssue.isPending;
-
- return (
-
-
{
- setStatusFilter(v);
- setPage(1);
- }}
- onCategoryChange={(v) => {
- setCategoryFilter(v);
- setPage(1);
- }}
- onSearchChange={(value) => {
- setSearchText(value);
- setSelectedId(null);
- }}
- />
- setPage((p) => p - 1)}
- onNext={() => setPage((p) => p + 1)}
- />
-
- {query.isPending && Loading bug reports...
}
- {query.isError && Unable to load bug reports.
}
-
- {!query.isPending && !query.isError && (
-
-
- {detailQuery.data ? (
-
{
- updateStatus.mutate(
- { id: detailQuery.data.id, status },
- {
- onSuccess: () => onToast("Status updated"),
- onError: (e) => onToast(e instanceof Error ? e.message : "Failed to update"),
- },
- );
- }}
- onCreateIssue={() => {
- createIssue.mutate(detailQuery.data.id, {
- onSuccess: () => onToast("GitHub issue created"),
- onError: (e) =>
- onToast(e instanceof Error ? e.message : "Failed to create issue"),
- });
- }}
- />
- ) : (
- Select a report to inspect details.
- )}
-
- )}
-
- );
-}
diff --git a/apps/web/src/components/admin-console-header.tsx b/apps/web/src/components/admin-console-header.tsx
deleted file mode 100644
index 5e95cc0b..00000000
--- a/apps/web/src/components/admin-console-header.tsx
+++ /dev/null
@@ -1,33 +0,0 @@
-import type { AdminSection } from "../lib/admin-console-section";
-
-type Props = {
- section: AdminSection;
-};
-
-const TITLES: Record = {
- settings: "Admin Settings",
- "allow-list": "Allow List",
- users: "User Management",
- sessions: "Active Sessions",
- issues: "Issue Triage",
-};
-
-const DESCRIPTIONS: Record = {
- settings: "Global moderation and platform switches.",
- "allow-list": "Control which channels are available in allow-list mode.",
- users: "Roles, suspension, and account recovery tools.",
- sessions: "Connected clients, playback state, and recent activity.",
- issues: "Bug reports, diagnostics, status updates, and GitHub sync.",
-};
-
-export function AdminConsoleHeader({ section }: Props) {
- return (
-
- Admin Console
-
- {TITLES[section]}
-
- {DESCRIPTIONS[section]}
-
- );
-}
diff --git a/apps/web/src/components/admin-console-nav.tsx b/apps/web/src/components/admin-console-nav.tsx
deleted file mode 100644
index 1c98bebd..00000000
--- a/apps/web/src/components/admin-console-nav.tsx
+++ /dev/null
@@ -1,39 +0,0 @@
-import type { AdminSection } from "../lib/admin-console-section";
-
-type Item = {
- key: AdminSection;
- label: string;
-};
-
-type Props = {
- items: Item[];
- active: AdminSection;
- onSelect: (section: AdminSection) => void;
-};
-
-export function AdminConsoleNav({ items, active, onSelect }: Props) {
- return (
-
-
- {items.map((item) => {
- const isActive = item.key === active;
- return (
- onSelect(item.key)}
- className={`shrink-0 border-b px-1 py-2 text-left font-mono text-xs uppercase tracking-[0.16em] transition-colors ${
- isActive
- ? "border-border text-fg"
- : "border-border text-fg-soft hover:border-border-strong hover:text-fg-muted"
- }`}
- >
- {item.label}
-
- );
- })}
-
-
- );
-}
diff --git a/apps/web/src/components/admin-session-card.tsx b/apps/web/src/components/admin-session-card.tsx
deleted file mode 100644
index ddce41c9..00000000
--- a/apps/web/src/components/admin-session-card.tsx
+++ /dev/null
@@ -1,125 +0,0 @@
-import { toApiUrl } from "../lib/env";
-import { getOpenMojiUrl, pickOpenMojiCode } from "../lib/openmoji";
-import type { AdminSession } from "../types/admin";
-import type { AuthUser } from "../types/auth";
-
-type Props = {
- session: AdminSession;
- user?: AuthUser;
-};
-
-function formatDuration(value: number | null | undefined): string {
- if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) return "0:00";
- const totalSeconds = Math.floor(value / 1000);
- const minutes = Math.floor(totalSeconds / 60);
- const seconds = totalSeconds % 60;
- return `${minutes}:${seconds.toString().padStart(2, "0")}`;
-}
-
-function initials(value: string): string {
- return value
- .split(" ")
- .filter(Boolean)
- .slice(0, 2)
- .map((part) => part[0]?.toUpperCase() ?? "")
- .join("");
-}
-
-function avatarUrl(user: AuthUser | undefined, session: AdminSession): string | null {
- if (user?.avatarType === "emoji" && user.avatarCode) return getOpenMojiUrl(user.avatarCode);
- if (user?.avatarUrl) return toApiUrl(user.avatarUrl);
- if (user) return getOpenMojiUrl(pickOpenMojiCode(`${user.id}:${user.email}`));
- if (session.userId) return getOpenMojiUrl(pickOpenMojiCode(session.userId));
- return null;
-}
-
-export function AdminSessionCard({ session, user }: Props) {
- const name =
- user?.publicUsername ?? user?.name ?? session.username ?? session.userId ?? "Unknown user";
- const device = [session.deviceName, session.deviceType].filter(Boolean).join(" - ") || "Browser";
- const nowPlaying = session.nowPlaying;
- const stateLabel = nowPlaying ? (nowPlaying.paused ? "Paused" : "Playing") : "Online";
- const imageUrl = avatarUrl(user, session);
- const progress = nowPlaying?.durationMs
- ? Math.min(100, Math.max(0, (nowPlaying.positionMs / nowPlaying.durationMs) * 100))
- : 0;
-
- return (
-
-
- {nowPlaying?.thumbnail ? (
-
- ) : (
-
- )}
-
-
-
- {stateLabel}
-
-
-
-
- {imageUrl ? (
-
- ) : (
- initials(name) || "U"
- )}
-
-
-
-
-
-
-
- {nowPlaying ? (
-
-
-
-
{nowPlaying.title}
-
- {nowPlaying.channelName ?? "Video"}
-
-
-
- {formatDuration(nowPlaying.positionMs)}
-
-
-
-
- {formatDuration(nowPlaying.positionMs)}
- {formatDuration(nowPlaying.durationMs)}
-
-
- ) : (
-
-
No active playback
-
The client is connected and ready.
-
- )}
-
-
- );
-}
diff --git a/apps/web/src/components/admin-sessions-section.tsx b/apps/web/src/components/admin-sessions-section.tsx
deleted file mode 100644
index 8302d313..00000000
--- a/apps/web/src/components/admin-sessions-section.tsx
+++ /dev/null
@@ -1,50 +0,0 @@
-import { useAdminSessions } from "../hooks/use-admin-sessions";
-import { useAdminUsers } from "../hooks/use-admin-users";
-import { AdminSessionCard } from "./admin-session-card";
-
-type Props = {
- enabled: boolean;
-};
-
-export function AdminSessionsSection({ enabled }: Props) {
- const query = useAdminSessions(enabled);
- const usersQuery = useAdminUsers(enabled, 1, 200).query;
- const sessions = query.data ?? [];
- const users = usersQuery.data?.items ?? [];
-
- if (query.isPending) {
- return (
-
- Loading active sessions...
-
- );
- }
-
- if (query.isError) {
- return (
-
- Unable to load active sessions.
-
- );
- }
-
- if (sessions.length === 0) {
- return (
-
- No active sessions are currently reported.
-
- );
- }
-
- return (
-
- {sessions.map((session) => (
- item.id === session.userId)}
- />
- ))}
-
- );
-}
diff --git a/apps/web/src/components/admin-settings-panel.tsx b/apps/web/src/components/admin-settings-panel.tsx
deleted file mode 100644
index 46b43f55..00000000
--- a/apps/web/src/components/admin-settings-panel.tsx
+++ /dev/null
@@ -1,114 +0,0 @@
-import type { AdminSettings } from "../types/admin";
-
-type Props = {
- settings: AdminSettings;
- pending: boolean;
- onToggle: (key: BooleanAdminSetting) => void;
-};
-
-type BooleanAdminSetting = {
- [Key in keyof AdminSettings]: AdminSettings[Key] extends boolean ? Key : never;
-}[keyof AdminSettings];
-
-const ROW =
- "flex items-center justify-between rounded-lg border border-border bg-surface px-4 py-3";
-
-export function AdminSettingsPanel({ settings, pending, onToggle }: Props) {
- return (
-
-
-
-
Instance access
-
Control who can register or browse as guest.
-
-
- onToggle("allowRegistration")}
- />
- onToggle("allowGuest")}
- />
- onToggle("activeSessionsEnabled")}
- />
-
-
-
-
-
Authentication
-
- OIDC behavior. Disable local login only when an OIDC provider is configured.
-
-
-
- onToggle("localLoginEnabled")}
- />
- onToggle("oidcAutoRedirect")}
- />
-
-
-
-
-
YouTube
-
Allow remote browser sign-in for YouTube sessions.
-
-
- onToggle("youtubeRemoteLoginEnabled")}
- />
-
-
-
- );
-}
-
-type ToggleProps = {
- label: string;
- value: boolean;
- pending: boolean;
- onClick: () => void;
-};
-
-function SettingToggle({ label, value, pending, onClick }: ToggleProps) {
- return (
-
- {label}
-
-
-
-
- );
-}
diff --git a/apps/web/src/components/admin-settings-section.tsx b/apps/web/src/components/admin-settings-section.tsx
deleted file mode 100644
index 7c3b2aa7..00000000
--- a/apps/web/src/components/admin-settings-section.tsx
+++ /dev/null
@@ -1,60 +0,0 @@
-import { useAdminSettings } from "../hooks/use-admin-settings";
-import type { AdminSettings } from "../types/admin";
-import { AdminSettingsPanel } from "./admin-settings-panel";
-
-type Props = {
- enabled: boolean;
- onToast: (message: string) => void;
-};
-
-type BooleanAdminSetting = {
- [Key in keyof AdminSettings]: AdminSettings[Key] extends boolean ? Key : never;
-}[keyof AdminSettings];
-
-export function AdminSettingsSection({ enabled, onToast }: Props) {
- const adminSettings = useAdminSettings(enabled);
-
- function updateSettings(next: AdminSettings) {
- adminSettings.update.mutate(next, {
- onSuccess: () => onToast("Admin settings updated"),
- onError: (error) =>
- onToast(error instanceof Error ? error.message : "Unable to update admin settings"),
- });
- }
-
- function toggleSetting(key: BooleanAdminSetting) {
- const current = adminSettings.query.data;
- if (!current) return;
- updateSettings({ ...current, [key]: !current[key] });
- }
-
- if (adminSettings.query.isPending) {
- return (
-
- Loading admin settings...
-
- );
- }
-
- if (adminSettings.query.isError) {
- const message =
- adminSettings.query.error instanceof Error
- ? adminSettings.query.error.message
- : "Unable to load admin settings.";
- return (
-
- Unable to load admin settings: {message}
-
- );
- }
-
- if (!adminSettings.query.data) return null;
-
- return (
-
- );
-}
diff --git a/apps/web/src/components/admin-user-avatar.tsx b/apps/web/src/components/admin-user-avatar.tsx
deleted file mode 100644
index cd9787df..00000000
--- a/apps/web/src/components/admin-user-avatar.tsx
+++ /dev/null
@@ -1,31 +0,0 @@
-import { toApiUrl } from "../lib/env";
-import { getOpenMojiUrl, pickOpenMojiCode } from "../lib/openmoji";
-import type { AuthUser } from "../types/auth";
-
-type AdminUserAvatarProps = {
- user: AuthUser;
- className: string;
-};
-
-export function AdminUserAvatar({ user, className }: AdminUserAvatarProps) {
- const seed = `${user.id}:${user.email}`;
- const avatarUrl =
- user.avatarType === "emoji" && typeof user.avatarCode === "string" && user.avatarCode.length > 0
- ? getOpenMojiUrl(user.avatarCode)
- : user.avatarUrl
- ? toApiUrl(user.avatarUrl)
- : getOpenMojiUrl(pickOpenMojiCode(seed));
-
- return (
-
-
-
- );
-}
diff --git a/apps/web/src/components/admin-user-detail-panel.tsx b/apps/web/src/components/admin-user-detail-panel.tsx
deleted file mode 100644
index 1d1dc961..00000000
--- a/apps/web/src/components/admin-user-detail-panel.tsx
+++ /dev/null
@@ -1,102 +0,0 @@
-import { useState } from "react";
-import type { AuthRole, AuthUser } from "../types/auth";
-import { AdminUserAvatar } from "./admin-user-avatar";
-import { AdminUserIdentityForm } from "./admin-user-identity-form";
-
-type AdminUserDetailPanelProps = {
- user: AuthUser;
- busy: boolean;
- onRole: (id: string, role: AuthRole) => void;
- onSuspend: (id: string, suspended: boolean) => void;
- onReset: (id: string, email: string) => void;
- onMessage: (message: string) => void;
-};
-
-const ROLE_OPTIONS: AuthRole[] = ["user", "moderator", "admin"];
-
-function roleClass(active: boolean): string {
- if (active) return "border-fg bg-fg text-app";
- return "border-border-strong bg-surface text-fg-muted hover:border-border-strong";
-}
-
-export function AdminUserDetailPanel({
- user,
- busy,
- onRole,
- onSuspend,
- onReset,
- onMessage,
-}: AdminUserDetailPanelProps) {
- const [actionsOpen, setActionsOpen] = useState(false);
- const suspendClass = user.suspended
- ? "border-emerald-800/60 bg-emerald-950/30 text-emerald-200 hover:border-emerald-700"
- : "border-danger/60 bg-danger/30 text-danger-strong hover:border-danger";
-
- return (
-
-
-
-
-
{user.name || user.email}
-
{user.email}
-
-
- {user.id}
-
-
-
- {ROLE_OPTIONS.map((role) => (
- onRole(user.id, role)}
- className={`h-8 rounded-md border text-[11px] uppercase tracking-wide transition-colors disabled:opacity-50 ${roleClass(
- user.role === role,
- )}`}
- >
- {role}
-
- ))}
-
-
-
-
setActionsOpen((open) => !open)}
- className="ml-auto block h-8 rounded-md border border-border-strong bg-surface px-2.5 text-xs font-medium text-fg transition-all duration-150 hover:-translate-y-0.5 hover:border-border-strong hover:bg-surface-strong"
- >
- Actions
-
-
- {actionsOpen && (
-
- {
- onSuspend(user.id, user.suspended);
- setActionsOpen(false);
- }}
- className={`mb-1 h-8 w-full rounded-md border px-2.5 text-left text-xs font-medium transition-colors disabled:opacity-50 ${suspendClass}`}
- >
- {user.suspended ? "Unsuspend" : "Suspend"}
-
- {
- onReset(user.id, user.email);
- setActionsOpen(false);
- }}
- className="h-8 w-full rounded-md border border-border-strong bg-surface px-2.5 text-left text-xs font-medium text-fg transition-colors hover:border-border-strong hover:bg-surface-strong disabled:opacity-50"
- >
- Reset token
-
-
- )}
-
-
- );
-}
diff --git a/apps/web/src/components/admin-user-grid.tsx b/apps/web/src/components/admin-user-grid.tsx
deleted file mode 100644
index 4b344d49..00000000
--- a/apps/web/src/components/admin-user-grid.tsx
+++ /dev/null
@@ -1,30 +0,0 @@
-import { formatCreatedAt } from "../lib/admin-console";
-import type { AuthUser } from "../types/auth";
-import { AdminUserRow } from "./admin-user-row";
-
-type AdminUserGridProps = {
- users: AuthUser[];
- selectedUserId: string | null;
- onSelectUser: (id: string) => void;
-};
-
-export function AdminUserGrid({ users, selectedUserId, onSelectUser }: AdminUserGridProps) {
- return (
-
- {users.map((user, index) => (
-
-
onSelectUser(id)}
- />
-
- ))}
-
- );
-}
diff --git a/apps/web/src/components/admin-user-identity-form.tsx b/apps/web/src/components/admin-user-identity-form.tsx
deleted file mode 100644
index ceab2bf7..00000000
--- a/apps/web/src/components/admin-user-identity-form.tsx
+++ /dev/null
@@ -1,58 +0,0 @@
-import { useMutation, useQueryClient } from "@tanstack/react-query";
-import { useEffect, useState } from "react";
-import { updateAdminIdentity } from "../lib/api-account-identity";
-import type { AuthUser } from "../types/auth";
-
-type Props = {
- user: AuthUser;
- disabled: boolean;
- onMessage: (message: string) => void;
-};
-
-export function AdminUserIdentityForm({ user, disabled, onMessage }: Props) {
- const queryClient = useQueryClient();
- const [email, setEmail] = useState(user.email);
- const [name, setName] = useState(user.name);
- const update = useMutation({
- mutationFn: () => updateAdminIdentity(user.id, { email: email.trim(), name: name.trim() }),
- onSuccess: () => queryClient.invalidateQueries({ queryKey: ["admin-users"] }),
- });
-
- useEffect(() => {
- setEmail(user.email);
- setName(user.name);
- }, [user]);
-
- const dirty =
- email.trim().toLowerCase() !== user.email.toLowerCase() || name.trim() !== user.name;
- return (
-
- setName(event.target.value)}
- className="h-8 rounded-md border border-border-strong bg-app px-2.5 text-xs text-fg"
- />
- setEmail(event.target.value)}
- className="h-8 rounded-md border border-border-strong bg-app px-2.5 text-xs text-fg"
- />
-
- update.mutate(undefined, {
- onSuccess: () => onMessage("User identity updated"),
- onError: (error) => onMessage(error instanceof Error ? error.message : "Update failed"),
- })
- }
- className="h-8 rounded-md border border-border-strong bg-surface text-xs text-fg disabled:opacity-50"
- >
- Save identity
-
-
- );
-}
diff --git a/apps/web/src/components/admin-user-row.tsx b/apps/web/src/components/admin-user-row.tsx
deleted file mode 100644
index 748d9cef..00000000
--- a/apps/web/src/components/admin-user-row.tsx
+++ /dev/null
@@ -1,54 +0,0 @@
-import type { AuthUser } from "../types/auth";
-import { AdminUserAvatar } from "./admin-user-avatar";
-
-type AdminUserRowProps = {
- user: AuthUser;
- selected: boolean;
- createdAtLabel: string;
- onSelect: (id: string) => void;
-};
-
-function roleClass(role: AuthUser["role"]): string {
- if (role === "admin") return "border-sky-800/70 bg-sky-950/40 text-sky-200";
- if (role === "moderator") return "border-amber-800/70 bg-amber-950/40 text-amber-200";
- return "border-border-strong bg-surface text-fg-muted";
-}
-
-export function AdminUserRow({ user, selected, createdAtLabel, onSelect }: AdminUserRowProps) {
- const displayName = user.name.trim().length > 0 ? user.name : user.email;
-
- return (
- onSelect(user.id)}
- className={`w-full rounded-2xl border bg-surface p-3 text-left transition-colors ${
- selected
- ? "border-border-strong ring-1 ring-border-strong/60"
- : "border-border hover:border-border-strong"
- }`}
- >
-
-
-
-
{displayName}
-
{user.email}
-
-
- {user.role}
-
-
-
-
- {createdAtLabel}
-
- {user.suspended && (
-
- suspended
-
- )}
-
-
- );
-}
diff --git a/apps/web/src/components/admin-user-toolbar.tsx b/apps/web/src/components/admin-user-toolbar.tsx
deleted file mode 100644
index 9d6cd87a..00000000
--- a/apps/web/src/components/admin-user-toolbar.tsx
+++ /dev/null
@@ -1,46 +0,0 @@
-import type { ChangeEvent } from "react";
-import { ADMIN_FILTERS, type AdminFilter, isAdminFilter } from "../lib/admin-console";
-
-type AdminUserToolbarProps = {
- search: string;
- filter: AdminFilter;
- onSearchChange: (value: string) => void;
- onFilterChange: (value: AdminFilter) => void;
-};
-
-export function AdminUserToolbar({
- search,
- filter,
- onSearchChange,
- onFilterChange,
-}: AdminUserToolbarProps) {
- function handleFilter(event: ChangeEvent) {
- const next = event.target.value;
- if (!isAdminFilter(next)) return;
- onFilterChange(next);
- }
-
- return (
-
- );
-}
diff --git a/apps/web/src/components/admin-users-pagination.tsx b/apps/web/src/components/admin-users-pagination.tsx
deleted file mode 100644
index f0c92c29..00000000
--- a/apps/web/src/components/admin-users-pagination.tsx
+++ /dev/null
@@ -1,50 +0,0 @@
-type AdminUsersPaginationProps = {
- page: number;
- totalPages: number;
- total: number;
- pageStart: number;
- pageEnd: number;
- pending: boolean;
- onPrev: () => void;
- onNext: () => void;
-};
-
-export function AdminUsersPagination({
- page,
- totalPages,
- total,
- pageStart,
- pageEnd,
- pending,
- onPrev,
- onNext,
-}: AdminUsersPaginationProps) {
- return (
-
-
- {pageStart}-{pageEnd} of {total}
-
-
-
- Prev
-
-
- Page {page} / {totalPages}
-
- = totalPages}
- onClick={onNext}
- className="h-8 rounded-md border border-border-strong bg-surface px-2.5 text-fg transition-colors hover:border-border-strong disabled:opacity-50"
- >
- Next
-
-
-
- );
-}
diff --git a/apps/web/src/components/admin-users-section.tsx b/apps/web/src/components/admin-users-section.tsx
deleted file mode 100644
index 39dcd887..00000000
--- a/apps/web/src/components/admin-users-section.tsx
+++ /dev/null
@@ -1,164 +0,0 @@
-import { useEffect, useMemo, useState } from "react";
-import { useAdminUsers } from "../hooks/use-admin-users";
-import { type AdminFilter, matchesAdminFilter } from "../lib/admin-console";
-import { AdminUserDetailPanel } from "./admin-user-detail-panel";
-import { AdminUserGrid } from "./admin-user-grid";
-import { AdminUserToolbar } from "./admin-user-toolbar";
-import { AdminUsersPagination } from "./admin-users-pagination";
-import { ResetTokenModal } from "./reset-token-modal";
-
-const PAGE_SIZE = 50;
-
-type Props = {
- enabled: boolean;
- currentUserId: string | null;
- onToast: (message: string) => void;
-};
-
-export function AdminUsersSection({ enabled, currentUserId, onToast }: Props) {
- const [page, setPage] = useState(1);
- const [search, setSearch] = useState("");
- const [filter, setFilter] = useState("all");
- const [selectedUserId, setSelectedUserId] = useState(null);
- const [resetTokenData, setResetTokenData] = useState<{ email: string; token: string } | null>(
- null,
- );
- const { query, role, suspend, resetToken } = useAdminUsers(enabled, page, PAGE_SIZE);
-
- const users = query.data?.items ?? [];
- const total = query.data?.total ?? 0;
- const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
- const currentPage = query.data?.page ?? page;
- const pageStart = total === 0 ? 0 : (currentPage - 1) * PAGE_SIZE + 1;
- const pageEnd = total === 0 ? 0 : Math.min(currentPage * PAGE_SIZE, total);
- const searchTerm = search.trim().toLowerCase();
- const busy = role.isPending || suspend.isPending || resetToken.isPending;
-
- const filtered = useMemo(
- () =>
- users
- .filter((user) => {
- if (!matchesAdminFilter(user, filter)) return false;
- if (!searchTerm) return true;
- const haystack = `${user.name} ${user.email} ${user.id}`.toLowerCase();
- return haystack.includes(searchTerm);
- })
- .sort((a, b) => Number(new Date(b.createdAt)) - Number(new Date(a.createdAt))),
- [users, filter, searchTerm],
- );
-
- const selectedUser = filtered.find((user) => user.id === selectedUserId) ?? null;
-
- useEffect(() => {
- if (filtered.length === 0) {
- setSelectedUserId(null);
- return;
- }
- if (selectedUserId && filtered.some((user) => user.id === selectedUserId)) return;
- if (currentUserId && filtered.some((user) => user.id === currentUserId)) {
- setSelectedUserId(currentUserId);
- return;
- }
- setSelectedUserId(filtered[0].id);
- }, [selectedUserId, filtered, currentUserId]);
-
- return (
- <>
- {
- setSearch(value);
- setPage(1);
- }}
- onFilterChange={(value) => {
- setFilter(value);
- setPage(1);
- }}
- />
- setPage((value) => Math.max(1, value - 1))}
- onNext={() => setPage((value) => Math.min(totalPages, value + 1))}
- />
- {query.isPending && (
-
- )}
- {query.isError && (
-
- Unable to load users right now.
-
- )}
- {!query.isPending && !query.isError && filtered.length === 0 && (
-
- No user matches this view.
-
- )}
- {!query.isPending && !query.isError && filtered.length > 0 && (
-
-
- {selectedUser ? (
- {
- role.mutate(
- { id, role: nextRole },
- {
- onSuccess: () => onToast(`Role set to ${nextRole}`),
- onError: (error) =>
- onToast(error instanceof Error ? error.message : "Unable to update role"),
- },
- );
- }}
- onSuspend={(id, suspendedFlag) => {
- suspend.mutate(
- { id, suspended: !suspendedFlag },
- {
- onSuccess: () =>
- onToast(!suspendedFlag ? "User suspended" : "User unsuspended"),
- onError: (error) =>
- onToast(
- error instanceof Error ? error.message : "Unable to update suspension",
- ),
- },
- );
- }}
- onReset={(id, email) => {
- resetToken.mutate(id, {
- onSuccess: (result) => setResetTokenData({ email, token: result.resetToken }),
- onError: (error) =>
- onToast(
- error instanceof Error ? error.message : "Unable to generate reset token",
- ),
- });
- }}
- />
- ) : (
-
- )}
-
- )}
- {resetTokenData && (
- setResetTokenData(null)}
- onCopied={() => onToast("Token copied to clipboard")}
- />
- )}
- >
- );
-}
diff --git a/apps/web/src/components/allow-channel-button.tsx b/apps/web/src/components/allow-channel-button.tsx
deleted file mode 100644
index c2374e17..00000000
--- a/apps/web/src/components/allow-channel-button.tsx
+++ /dev/null
@@ -1,35 +0,0 @@
-import { useAllowedChannels } from "../hooks/use-allowed-channels";
-import { useAuth } from "../hooks/use-auth";
-import { normalizeChannelUrl } from "../lib/channel-url";
-
-type Props = {
- url: string;
- name?: string | null;
- thumbnailUrl?: string | null;
- compact?: boolean;
-};
-
-export function AllowChannelButton({ url, name, thumbnailUrl, compact = false }: Props) {
- const { authReady, isAuthed } = useAuth();
- const { canGlobalBlock } = useAuth();
- const { query, add } = useAllowedChannels();
- if (!authReady || !isAuthed || !canGlobalBlock) return null;
- const normalizedUrl = normalizeChannelUrl(url);
- const allowed = (query.data ?? []).some(
- (item) => normalizeChannelUrl(item.url) === normalizedUrl || item.name === name,
- );
- return (
- add.mutate({ url, name, thumbnailUrl, global: true })}
- className={
- compact
- ? "rounded-md border border-border px-3 py-1 text-xs text-fg-soft transition-colors hover:text-fg disabled:cursor-not-allowed disabled:opacity-60"
- : "rounded-md bg-surface-strong px-4 py-1.5 text-sm font-medium text-fg transition-colors hover:bg-surface-soft disabled:cursor-not-allowed disabled:opacity-60"
- }
- >
- {allowed ? "Allowed" : add.isPending ? "Adding" : "Allow channel"}
-
- );
-}
diff --git a/apps/web/src/components/app-footer.tsx b/apps/web/src/components/app-footer.tsx
deleted file mode 100644
index cab6c194..00000000
--- a/apps/web/src/components/app-footer.tsx
+++ /dev/null
@@ -1,34 +0,0 @@
-import { Heart } from "lucide-react";
-import { siGithub } from "simple-icons";
-import { ServiceIcon } from "./service-icon";
-
-const PROFILE_URL = "https://github.com/Priveetee";
-const SPONSOR_URL = "https://github.com/sponsors/Priveetee";
-
-export function AppFooter() {
- return (
-
- );
-}
diff --git a/apps/web/src/components/audio-center-toggle.tsx b/apps/web/src/components/audio-center-toggle.tsx
deleted file mode 100644
index 4c031897..00000000
--- a/apps/web/src/components/audio-center-toggle.tsx
+++ /dev/null
@@ -1,26 +0,0 @@
-import { requestSabrVidstackPlayback } from "../lib/sabr-vidstack-bridge";
-import { useMediaRemote, useMediaState } from "../lib/vidstack";
-
-export function AudioCenterToggle({ video = null }: { video?: HTMLVideoElement | null }) {
- const remote = useMediaRemote();
- const paused = useMediaState("paused");
- const label = paused ? "Play audio" : "Pause audio";
-
- const togglePlayback = async () => {
- if (video) return requestSabrVidstackPlayback(video, paused, true);
- if (paused) await remote.play();
- else await remote.pause();
- };
-
- return (
- {
- event.stopPropagation();
- void togglePlayback().catch(() => {});
- }}
- />
- );
-}
diff --git a/apps/web/src/components/audio-control-icons.tsx b/apps/web/src/components/audio-control-icons.tsx
deleted file mode 100644
index 88314ada..00000000
--- a/apps/web/src/components/audio-control-icons.tsx
+++ /dev/null
@@ -1,67 +0,0 @@
-type Props = {
- size?: number;
-};
-
-export function AudioSeekBackward10Icon({ size = 32 }: Props) {
- return (
-
-
-
-
-
- );
-}
-
-export function AudioSeekForward10Icon({ size = 32 }: Props) {
- return (
-
-
-
-
-
- );
-}
-
-export function AudioPlayIcon({ size = 32 }: Props) {
- return (
-
-
-
- );
-}
-
-export function AudioPauseIcon({ size = 32 }: Props) {
- return (
-
-
-
-
- );
-}
diff --git a/apps/web/src/components/audio-only-poster.tsx b/apps/web/src/components/audio-only-poster.tsx
deleted file mode 100644
index 4c845d2a..00000000
--- a/apps/web/src/components/audio-only-poster.tsx
+++ /dev/null
@@ -1,96 +0,0 @@
-import { type CSSProperties, useEffect, useRef, useState } from "react";
-import { useAudioPalette } from "../hooks/use-audio-palette";
-import { proxyImage } from "../lib/proxy";
-import { AudioOnlyVisualizer } from "./audio-only-visualizer";
-
-type Props = {
- poster?: string;
- title?: string;
- media?: HTMLMediaElement | null;
-};
-
-type PaletteStyle = CSSProperties & {
- "--typetype-audio-primary": string;
- "--typetype-audio-secondary": string;
- "--typetype-audio-ambient": string;
- "--typetype-audio-wave-top": string;
- "--typetype-audio-wave-mid": string;
- "--typetype-audio-wave-bottom": string;
-};
-
-export function AudioOnlyPoster({ poster, title, media = null }: Props) {
- const image = poster ? proxyImage(poster) : "";
- const palette = useAudioPalette(image);
- const titleRef = useRef(null);
- const titleTextRef = useRef(null);
- const [shouldMarquee, setShouldMarquee] = useState(false);
-
- useEffect(() => {
- const titleElement = titleRef.current;
- const textElement = titleTextRef.current;
- if (!titleElement || !textElement) return;
-
- const measureTitle = () => {
- setShouldMarquee(textElement.scrollWidth > titleElement.clientWidth + 8);
- };
-
- measureTitle();
-
- const observer = new ResizeObserver(measureTitle);
- observer.observe(titleElement);
- observer.observe(textElement);
- return () => observer.disconnect();
- }, []);
-
- const titleClassName = [
- "typetype-audio-poster-title line-clamp-2 max-w-lg text-balance font-semibold text-base leading-tight text-white drop-shadow-2xl sm:text-2xl lg:text-3xl",
- shouldMarquee ? "typetype-audio-poster-title-marquee" : null,
- ]
- .filter(Boolean)
- .join(" ");
- const style: PaletteStyle = {
- "--typetype-audio-primary": palette.primary,
- "--typetype-audio-secondary": palette.secondary,
- "--typetype-audio-ambient": palette.ambient,
- "--typetype-audio-wave-top": palette.waveTop,
- "--typetype-audio-wave-mid": palette.waveMid,
- "--typetype-audio-wave-bottom": palette.waveBottom,
- };
-
- return (
-
- {image ? (
- <>
-
-
-
-
-
-
-
-
- {title ?? "Audio only playback"}
-
-
-
-
- >
- ) : (
-
- {title ?? "Audio only playback"}
-
- )}
-
- );
-}
diff --git a/apps/web/src/components/audio-only-visualizer.tsx b/apps/web/src/components/audio-only-visualizer.tsx
deleted file mode 100644
index 49d7cb5a..00000000
--- a/apps/web/src/components/audio-only-visualizer.tsx
+++ /dev/null
@@ -1,165 +0,0 @@
-import { useEffect, useRef } from "react";
-import { audioSpectrum, waveformEnergy, waveformLevel } from "../lib/audio-spectrum";
-
-type VisualizerColors = {
- top: string;
- mid: string;
- bottom: string;
-};
-
-type VisualizerMotion = {
- values: Float32Array;
- targets: Float32Array;
- phases: Float32Array;
- speeds: Float32Array;
- intervals: Uint8Array;
- offsets: Uint8Array;
-};
-
-function createMotion(bars: number): VisualizerMotion {
- const motion: VisualizerMotion = {
- values: new Float32Array(bars),
- targets: new Float32Array(bars),
- phases: new Float32Array(bars),
- speeds: new Float32Array(bars),
- intervals: new Uint8Array(bars),
- offsets: new Uint8Array(bars),
- };
- for (let index = 0; index < bars; index += 1) {
- motion.values[index] = Math.random();
- motion.targets[index] = Math.random();
- motion.phases[index] = Math.random() * Math.PI * 2;
- motion.speeds[index] = 0.018 + Math.random() * 0.055;
- motion.intervals[index] = 4 + Math.floor(Math.random() * 10);
- motion.offsets[index] = Math.floor(Math.random() * motion.intervals[index]);
- }
- return motion;
-}
-
-function resizeCanvas(canvas: HTMLCanvasElement) {
- const rect = canvas.getBoundingClientRect();
- const scale = window.devicePixelRatio || 1;
- canvas.width = Math.max(1, Math.floor(rect.width * scale));
- canvas.height = Math.max(1, Math.floor(rect.height * scale));
-}
-
-function drawBars(
- context: CanvasRenderingContext2D,
- levels: Float32Array,
- motion: VisualizerMotion,
- spectrum: Uint8Array | null,
- active: boolean,
- colors: VisualizerColors,
- frame: number,
-) {
- const width = context.canvas.width;
- const height = context.canvas.height;
- const bars = levels.length;
- const gap = width / bars / 3.4;
- const barWidth = width / bars - gap;
- context.clearRect(0, 0, width, height);
- const gradient = context.createLinearGradient(0, height * 0.15, 0, height * 0.85);
- gradient.addColorStop(0, colors.top);
- gradient.addColorStop(0.48, colors.mid);
- gradient.addColorStop(1, colors.bottom);
- context.fillStyle = gradient;
- const energy = spectrum ? waveformEnergy(spectrum) : 0;
-
- for (let index = 0; index < bars; index += 1) {
- const measured = spectrum ? waveformLevel(spectrum, index, bars, energy) : 0;
- const interval = motion.intervals[index] ?? 10;
- if ((frame + (motion.offsets[index] ?? 0)) % interval === 0) {
- motion.targets[index] = Math.random();
- }
- motion.values[index] += ((motion.targets[index] ?? 0) - (motion.values[index] ?? 0)) * 0.11;
- const pulse =
- 0.5 + Math.sin(frame * (motion.speeds[index] ?? 0.03) + (motion.phases[index] ?? 0)) * 0.5;
- const signal = active ? Math.max(energy, 0.34) : 0;
- const movement = signal * (0.08 + (motion.values[index] ?? 0) * 0.52 + pulse * 0.26);
- const target = active ? Math.min(1, 0.035 + measured * 0.44 + movement) : 0.025;
- levels[index] += (target - levels[index]) * (active ? 0.22 : 0.08);
- const edgeFade = 0.65 + Math.sin((index / bars) * Math.PI) * 0.35;
- const barHeight = Math.max(height * 0.025, levels[index] * edgeFade * height * 0.58);
- const x = index * (barWidth + gap);
- const y = height / 2 - barHeight / 2;
- context.fillRect(x, y, barWidth, barHeight);
- }
-}
-
-function colorVar(element: Element, name: string, fallback: string, alpha: number) {
- const channels = getComputedStyle(element).getPropertyValue(name).trim() || fallback;
- const values = channels
- .split(/\s+/)
- .map((value) => Number(value))
- .filter(Number.isFinite);
- if (values.length !== 3) return `rgba(${fallback.replaceAll(" ", ", ")}, ${alpha})`;
- return `rgba(${values[0]}, ${values[1]}, ${values[2]}, ${alpha})`;
-}
-
-function visualizerColors(element: Element): VisualizerColors {
- return {
- top: colorVar(element, "--typetype-audio-wave-top", "255 255 255", 0.25),
- mid: colorVar(element, "--typetype-audio-wave-mid", "248 113 113", 0.66),
- bottom: colorVar(element, "--typetype-audio-wave-bottom", "127 29 29", 0.16),
- };
-}
-
-export function AudioOnlyVisualizer({ media }: { media: HTMLMediaElement | null }) {
- const canvasRef = useRef(null);
-
- useEffect(() => {
- const canvas = canvasRef.current;
- const context = canvas?.getContext("2d");
- if (!canvas || !context) return;
- let activeMedia = media;
-
- resizeCanvas(canvas);
- const observer = new ResizeObserver(() => resizeCanvas(canvas));
- observer.observe(canvas);
- const levels = new Float32Array(112);
- const motion = createMotion(levels.length);
- let spectrum = activeMedia && !activeMedia.paused ? audioSpectrum(activeMedia) : null;
- let colors = visualizerColors(canvas);
- let animation = 0;
- let frame = 0;
- const activate = (event?: Event) => {
- if (event?.target instanceof HTMLMediaElement) activeMedia = event.target;
- activeMedia ??=
- [...document.querySelectorAll("video,audio")].find(
- (element) => element.currentSrc && !element.paused,
- ) ?? null;
- if (!activeMedia) return;
- spectrum ??= audioSpectrum(activeMedia);
- if (spectrum?.context.state === "suspended") {
- void spectrum.context.resume().catch(() => undefined);
- }
- };
- document.addEventListener("playing", activate, true);
- activate();
- const render = () => {
- if (frame % 30 === 0) colors = visualizerColors(canvas);
- const active = Boolean(
- activeMedia &&
- !activeMedia.paused &&
- !activeMedia.ended &&
- !activeMedia.error &&
- activeMedia.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA,
- );
- if (active && spectrum?.context.state === "running") {
- spectrum.analyser.getByteFrequencyData(spectrum.data);
- }
- drawBars(context, levels, motion, spectrum?.data ?? null, active, colors, frame);
- frame += 1;
- animation = requestAnimationFrame(render);
- };
- animation = requestAnimationFrame(render);
-
- return () => {
- cancelAnimationFrame(animation);
- observer.disconnect();
- document.removeEventListener("playing", activate, true);
- };
- }, [media]);
-
- return ;
-}
diff --git a/apps/web/src/components/audio-play-button.tsx b/apps/web/src/components/audio-play-button.tsx
deleted file mode 100644
index 3d2857a0..00000000
--- a/apps/web/src/components/audio-play-button.tsx
+++ /dev/null
@@ -1,31 +0,0 @@
-import { requestSabrVidstackPlayback } from "../lib/sabr-vidstack-bridge";
-import { useMediaRemote, useMediaState } from "../lib/vidstack";
-import { AudioPauseIcon, AudioPlayIcon } from "./audio-control-icons";
-
-export function AudioPlayButton({ video = null }: { video?: HTMLVideoElement | null }) {
- const remote = useMediaRemote();
- const paused = useMediaState("paused");
- const Icon = paused ? AudioPlayIcon : AudioPauseIcon;
- const label = paused ? "Play" : "Pause";
-
- const togglePlayback = async () => {
- if (video) return requestSabrVidstackPlayback(video, paused, true);
- if (paused) await remote.play();
- else await remote.pause();
- };
-
- return (
- {
- event.stopPropagation();
- void togglePlayback().catch(() => {});
- }}
- >
-
-
- );
-}
diff --git a/apps/web/src/components/audio-seek-button.tsx b/apps/web/src/components/audio-seek-button.tsx
deleted file mode 100644
index eece3f7a..00000000
--- a/apps/web/src/components/audio-seek-button.tsx
+++ /dev/null
@@ -1,38 +0,0 @@
-import { requestSabrSeek } from "../lib/sabr-vidstack-bridge";
-import { useMediaRemote, useMediaState } from "../lib/vidstack";
-import { AudioSeekBackward10Icon, AudioSeekForward10Icon } from "./audio-control-icons";
-
-type Props = {
- direction: "backward" | "forward";
- disabled?: boolean;
- video?: HTMLVideoElement | null;
-};
-
-export function AudioSeekButton({ direction, disabled = false, video = null }: Props) {
- const remote = useMediaRemote();
- const currentTime = useMediaState("currentTime");
- const duration = useMediaState("duration");
- const seconds = direction === "backward" ? -10 : 10;
- const label = direction === "backward" ? "Seek backward 10 seconds" : "Seek forward 10 seconds";
- const Icon = direction === "backward" ? AudioSeekBackward10Icon : AudioSeekForward10Icon;
-
- const seek = () => {
- const target = currentTime + seconds;
- const bounded = Math.min(Math.max(target, 0), duration > 0 ? duration : target);
- if (video && requestSabrSeek(video, bounded)) return;
- remote.seek(bounded);
- };
-
- return (
-
-
-
- );
-}
diff --git a/apps/web/src/components/audio-time-slider.tsx b/apps/web/src/components/audio-time-slider.tsx
deleted file mode 100644
index 0672e95e..00000000
--- a/apps/web/src/components/audio-time-slider.tsx
+++ /dev/null
@@ -1,42 +0,0 @@
-import { useEffect, useState } from "react";
-import { secondsFromSliderPercent } from "../lib/sabr-player-seek";
-import { requestSabrSeek } from "../lib/sabr-vidstack-bridge";
-import { TimeSlider, useMediaRemote, useMediaState } from "../lib/vidstack";
-
-type Props = {
- disabled?: boolean;
- video?: HTMLVideoElement | null;
-};
-
-export function AudioTimeSlider({ disabled = false, video = null }: Props) {
- const [seekTarget, setSeekTarget] = useState(null);
- const remote = useMediaRemote();
- const mediaDuration = useMediaState("duration");
- useEffect(() => {
- if (!disabled) setSeekTarget(null);
- }, [disabled]);
- const style = seekTarget === null ? undefined : { "--typetype-seek-target": `${seekTarget}%` };
-
- return (
- {
- setSeekTarget(percent);
- const seconds = secondsFromSliderPercent(video?.duration ?? mediaDuration, percent);
- if (disabled || seconds === null) return;
- if (video && requestSabrSeek(video, seconds)) return;
- remote.seek(seconds);
- }}
- >
-
-
-
-
-
-
- );
-}
diff --git a/apps/web/src/components/audio-track-selector.tsx b/apps/web/src/components/audio-track-selector.tsx
deleted file mode 100644
index 77543f8e..00000000
--- a/apps/web/src/components/audio-track-selector.tsx
+++ /dev/null
@@ -1,84 +0,0 @@
-import { useRef } from "react";
-import type { DefaultLayoutIcon, MenuInstance } from "../lib/vidstack";
-import {
- DefaultMenuButton,
- DefaultMenuRadioGroup,
- LanguageIcon,
- Menu,
- useAudioOptions,
-} from "../lib/vidstack";
-import { useSabrAudioStore } from "../stores/sabr-audio-store";
-import { includesOriginal, normalizeLanguageTag } from "./player-language";
-
-const languageIcon: DefaultLayoutIcon = (props) => ;
-const MENU_ITEMS_CLASS =
- "vds-menu-items overflow-y-auto overscroll-y-contain pr-0.5 [scrollbar-width:thin] [scrollbar-color:var(--color-zinc-500)_transparent] [&::-webkit-scrollbar]:w-1.5 [&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-thumb]:bg-surface-soft/80 [&::-webkit-scrollbar-thumb:hover]:bg-surface-soft [&::-webkit-scrollbar-track]:bg-transparent";
-
-type Props = {
- originalLocale?: string | null;
- sabr?: boolean;
-};
-
-export function AudioTrackSelector({ originalLocale, sabr = false }: Props) {
- const menuRef = useRef(null);
- const nativeOptions = useAudioOptions();
- const sabrStreamId = useSabrAudioStore((state) => state.streamId);
- const sabrOptions = useSabrAudioStore((state) => state.options);
- const selectedTrackId = useSabrAudioStore((state) => state.selectedTrackId);
- const selectSabrTrack = useSabrAudioStore((state) => state.selectTrack);
- const options = sabr
- ? sabrOptions.map((option) => ({
- label: option.label,
- selected: option.id === selectedTrackId,
- track: { language: option.language },
- select: () => {
- if (sabrStreamId) selectSabrTrack(sabrStreamId, option.id);
- },
- }))
- : nativeOptions;
-
- if (options.length <= 1) return null;
-
- const selectedOption = options.find((o) => o.selected) ?? options[0];
- const selectedTrackLooksOriginal = includesOriginal(selectedOption?.label);
- const selectedMatchesOriginalLocale =
- originalLocale != null &&
- normalizeLanguageTag(selectedOption?.track.language) === normalizeLanguageTag(originalLocale);
- const selectedIsOriginal = selectedTrackLooksOriginal || selectedMatchesOriginalLocale;
- const currentHint = selectedIsOriginal
- ? includesOriginal(selectedOption?.label)
- ? selectedOption?.label
- : `${selectedOption?.label} (original)`
- : selectedOption?.label;
-
- const radioOptions = options.map((o, index) => {
- const isOriginal =
- includesOriginal(o.label) ||
- (originalLocale != null &&
- normalizeLanguageTag(o.track.language) === normalizeLanguageTag(originalLocale));
- return {
- label: isOriginal && !includesOriginal(o.label) ? `${o.label} (original)` : o.label,
- value: `${o.label}-${o.track.language ?? "und"}-${index}`,
- };
- });
-
- const selectedValue =
- radioOptions.find((_, index) => options[index]?.selected)?.value ??
- radioOptions[0]?.value ??
- "";
-
- function onChange(value: string) {
- const selectedIndex = radioOptions.findIndex((option) => option.value === value);
- options[selectedIndex]?.select();
- menuRef.current?.close();
- }
-
- return (
-
-
-
-
-
-
- );
-}
diff --git a/apps/web/src/components/auth-backdrop.tsx b/apps/web/src/components/auth-backdrop.tsx
deleted file mode 100644
index 00289dfc..00000000
--- a/apps/web/src/components/auth-backdrop.tsx
+++ /dev/null
@@ -1,38 +0,0 @@
-import type { ReactNode } from "react";
-import { ThemeToggleButton } from "./theme-toggle-button";
-
-type Props = {
- children: ReactNode;
- fixed?: boolean;
- contentClassName: string;
-};
-
-export function AuthBackdrop({ children, fixed = false, contentClassName }: Props) {
- const shellClass = fixed
- ? "fixed inset-0 z-50 overflow-hidden bg-app text-fg"
- : "relative min-h-screen overflow-hidden bg-app text-fg";
-
- return (
-
- );
-}
diff --git a/apps/web/src/components/auth-card.tsx b/apps/web/src/components/auth-card.tsx
deleted file mode 100644
index 03e0e85e..00000000
--- a/apps/web/src/components/auth-card.tsx
+++ /dev/null
@@ -1,17 +0,0 @@
-type Props = {
- title: string;
- subtitle: string;
- children: React.ReactNode;
-};
-
-export function AuthCard({ title, subtitle, children }: Props) {
- return (
-
- );
-}
diff --git a/apps/web/src/components/auth-error-banner.tsx b/apps/web/src/components/auth-error-banner.tsx
deleted file mode 100644
index 804d56d2..00000000
--- a/apps/web/src/components/auth-error-banner.tsx
+++ /dev/null
@@ -1,12 +0,0 @@
-type Props = {
- message: string | null;
-};
-
-export function AuthErrorBanner({ message }: Props) {
- if (!message) return null;
- return (
-
- {message}
-
- );
-}
diff --git a/apps/web/src/components/autoplay-countdown-overlay.tsx b/apps/web/src/components/autoplay-countdown-overlay.tsx
deleted file mode 100644
index 78ca66a9..00000000
--- a/apps/web/src/components/autoplay-countdown-overlay.tsx
+++ /dev/null
@@ -1,123 +0,0 @@
-import { Pause, Play, X } from "lucide-react";
-import type { MouseEvent, PointerEvent } from "react";
-import type { AutoplayTarget } from "../hooks/use-watch-ended-navigation";
-import { proxyImage } from "../lib/proxy";
-
-type Props = {
- target: AutoplayTarget;
- totalSeconds: number;
- paused: boolean;
- onPlayNow: () => void;
- onCancel: () => void;
- onPauseToggle: () => void;
-};
-
-export function AutoplayCountdownOverlay({
- target,
- totalSeconds,
- paused,
- onPlayNow,
- onCancel,
- onPauseToggle,
-}: Props) {
- const thumbnail = proxyImage(target.thumbnail);
- const progressStyle = {
- animationDuration: `${totalSeconds}s`,
- animationPlayState: paused ? "paused" : "running",
- };
- const pauseButtonClass = paused
- ? "inline-flex h-9 w-9 items-center justify-center rounded-full bg-sky-500/20 text-sky-100 ring-1 ring-sky-300/45 transition hover:bg-sky-500/30 hover:ring-sky-200/60"
- : "inline-flex h-9 w-9 items-center justify-center rounded-full bg-black/45 text-white ring-1 ring-white/20 transition hover:bg-black/60 hover:ring-white/35";
-
- function stopOverlayEvent(event: MouseEvent | PointerEvent) {
- event.preventDefault();
- event.stopPropagation();
- }
-
- function stopPointerEvent(event: PointerEvent) {
- event.stopPropagation();
- }
-
- function handlePlayNow(event: MouseEvent) {
- stopOverlayEvent(event);
- onPlayNow();
- }
-
- function handleCancel(event: MouseEvent) {
- stopOverlayEvent(event);
- onCancel();
- }
-
- function handlePauseToggle(event: MouseEvent) {
- stopOverlayEvent(event);
- onPauseToggle();
- }
-
- return (
-
- {thumbnail ? (
-
- ) : (
-
- )}
-
-
-
-
-
-
-
- Up next
-
-
- {target.title}
-
- {target.channelName && (
-
{target.channelName}
- )}
-
-
-
- Play now
-
-
- {paused ? (
-
- ) : (
-
- )}
-
-
-
-
-
-
- );
-}
diff --git a/apps/web/src/components/autoplay-toggle.tsx b/apps/web/src/components/autoplay-toggle.tsx
deleted file mode 100644
index 493f632b..00000000
--- a/apps/web/src/components/autoplay-toggle.tsx
+++ /dev/null
@@ -1,26 +0,0 @@
-import { useSettings } from "../hooks/use-settings";
-
-export function AutoplayToggle() {
- const { settings, update } = useSettings();
-
- return (
-
- Autoplay
- update.mutate({ autoplay: !settings.autoplay })}
- className={`relative h-5 w-10 rounded-full border transition-colors duration-200 flex-shrink-0 ${
- settings.autoplay ? "border-fg bg-fg" : "border-border-strong bg-surface-strong"
- }`}
- >
-
-
-
- );
-}
diff --git a/apps/web/src/components/caption-style-restorer.tsx b/apps/web/src/components/caption-style-restorer.tsx
deleted file mode 100644
index 0204ffa4..00000000
--- a/apps/web/src/components/caption-style-restorer.tsx
+++ /dev/null
@@ -1,55 +0,0 @@
-import { useEffect, useRef } from "react";
-import {
- applyCaptionStyles,
- readCaptionStylesFromStorage,
- writeCaptionStylesToStorage,
-} from "../lib/caption-styles";
-import { useMediaPlayer, useMediaState } from "../lib/vidstack";
-import type { CaptionStyles } from "../types/user";
-
-type Props = {
- captionStyles: CaptionStyles;
- settingsReady: boolean;
- onChange: (styles: CaptionStyles) => void;
-};
-
-export function CaptionStyleRestorer({ captionStyles, settingsReady, onChange }: Props) {
- const player = useMediaPlayer();
- const canPlay = useMediaState("canPlay");
- const onChangeRef = useRef(onChange);
- onChangeRef.current = onChange;
- const applyingRef = useRef(false);
- const restoredRef = useRef(false);
-
- useEffect(() => {
- const el = player?.el;
- if (!el || !settingsReady || !canPlay || restoredRef.current) return;
- restoredRef.current = true;
- applyingRef.current = true;
- writeCaptionStylesToStorage(captionStyles);
- applyCaptionStyles(el, captionStyles);
- requestAnimationFrame(() => {
- applyingRef.current = false;
- });
- }, [player, settingsReady, canPlay, captionStyles]);
-
- useEffect(() => {
- const el = player?.el;
- if (!el) return;
- let timer: ReturnType | null = null;
- const observer = new MutationObserver(() => {
- if (applyingRef.current) return;
- if (timer) clearTimeout(timer);
- timer = setTimeout(() => {
- onChangeRef.current(readCaptionStylesFromStorage());
- }, 600);
- });
- observer.observe(el, { attributes: true, attributeFilter: ["style"] });
- return () => {
- observer.disconnect();
- if (timer) clearTimeout(timer);
- };
- }, [player]);
-
- return null;
-}
diff --git a/apps/web/src/components/card-liquid-fill.tsx b/apps/web/src/components/card-liquid-fill.tsx
deleted file mode 100644
index cefe4c32..00000000
--- a/apps/web/src/components/card-liquid-fill.tsx
+++ /dev/null
@@ -1,103 +0,0 @@
-import { useEffect, useRef } from "react";
-
-type Props = {
- progress: number;
-};
-
-function getColor(p: number): { h: number; s: number; l: number } {
- if (p < 40) return { h: 4, s: 72, l: 45 };
- if (p < 75) return { h: 38, s: 85, l: 50 };
- return { h: 145, s: 65, l: 42 };
-}
-
-export function CardLiquidFill({ progress }: Props) {
- const canvasRef = useRef(null);
- const animRef = useRef({ level: 0, time: 0 });
- const frameRef = useRef(0);
-
- useEffect(() => {
- const canvas = canvasRef.current;
- if (!canvas) return;
- const ctx = canvas.getContext("2d");
- if (!ctx) return;
-
- const resize = () => {
- const rect = canvas.getBoundingClientRect();
- const dpr = window.devicePixelRatio || 1;
- canvas.width = rect.width * dpr;
- canvas.height = rect.height * dpr;
- ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
- };
- resize();
- window.addEventListener("resize", resize);
-
- let running = true;
- const anim = animRef.current;
-
- const draw = () => {
- if (!running) return;
- const rect = canvas.getBoundingClientRect();
- const w = rect.width;
- const h = rect.height;
-
- ctx.clearRect(0, 0, w, h);
- anim.time += 0.02;
- anim.level += (progress - anim.level) * 0.03;
-
- const fillHeight = (anim.level / 100) * h;
- const surfaceY = h - fillHeight;
- const color = getColor(anim.level);
-
- const grad = ctx.createLinearGradient(0, surfaceY, 0, h);
- grad.addColorStop(0, `hsla(${color.h}, ${color.s}%, ${color.l + 15}%, 0.35)`);
- grad.addColorStop(0.5, `hsla(${color.h}, ${color.s}%, ${color.l}%, 0.45)`);
- grad.addColorStop(1, `hsla(${color.h}, ${color.s - 10}%, ${color.l - 8}%, 0.55)`);
-
- ctx.fillStyle = grad;
- ctx.beginPath();
- ctx.moveTo(0, surfaceY);
-
- for (let x = 0; x <= w; x += 4) {
- const wave =
- Math.sin(x * 0.02 + anim.time * 1.5) * 3 +
- Math.sin(x * 0.035 + anim.time * 2.2) * 2 +
- Math.sin(x * 0.05 + anim.time * 0.8) * 1.5;
- ctx.lineTo(x, surfaceY + wave);
- }
-
- ctx.lineTo(w, h);
- ctx.lineTo(0, h);
- ctx.closePath();
- ctx.fill();
-
- ctx.fillStyle = `hsla(${color.h + 20}, ${color.s - 20}%, ${color.l + 25}%, 0.08)`;
- ctx.beginPath();
- ctx.ellipse(
- w * 0.7,
- surfaceY + fillHeight * 0.4,
- w * 0.25,
- fillHeight * 0.2,
- -0.1,
- 0,
- Math.PI * 2,
- );
- ctx.fill();
-
- frameRef.current = requestAnimationFrame(draw);
- };
-
- frameRef.current = requestAnimationFrame(draw);
- return () => {
- running = false;
- cancelAnimationFrame(frameRef.current);
- window.removeEventListener("resize", resize);
- };
- }, [progress]);
-
- return (
-
- );
-}
diff --git a/apps/web/src/components/channel-avatar.tsx b/apps/web/src/components/channel-avatar.tsx
deleted file mode 100644
index 32072b28..00000000
--- a/apps/web/src/components/channel-avatar.tsx
+++ /dev/null
@@ -1,50 +0,0 @@
-import { useState } from "react";
-
-type Props = {
- src: string;
- name: string;
- className?: string;
-};
-
-function getInitial(name: string): string {
- if (!name) return "?";
- if (name.startsWith("http")) {
- try {
- const segments = new URL(name).pathname.split("/").filter(Boolean);
- const last = segments.pop() ?? "";
- return (last.replace("@", "")[0] ?? "?").toUpperCase();
- } catch {
- return "?";
- }
- }
- return name[0].toUpperCase();
-}
-
-export function ChannelAvatar({ src, name, className = "w-8 h-8" }: Props) {
- const [failedSrc, setFailedSrc] = useState(null);
- const failed = failedSrc === src;
-
- if (!src || failed) {
- return (
-
- {getInitial(name)}
-
- );
- }
-
- return (
- setFailedSrc(src)}
- title={name}
- />
- );
-}
diff --git a/apps/web/src/components/channel-filter-bar.tsx b/apps/web/src/components/channel-filter-bar.tsx
deleted file mode 100644
index 55f235ce..00000000
--- a/apps/web/src/components/channel-filter-bar.tsx
+++ /dev/null
@@ -1,139 +0,0 @@
-import type { FormEvent } from "react";
-import { useEffect, useState } from "react";
-import type { ChannelSort } from "../lib/api-discovery";
-import type { ChannelTab } from "../lib/channel-route-url";
-import { CHANNEL_SORT_OPTIONS, channelSortOrDefault } from "../lib/channel-sort";
-
-const CHANNEL_TABS: { tab: ChannelTab; label: string }[] = [
- { tab: "videos", label: "Videos" },
- { tab: "live", label: "Live" },
- { tab: "playlists", label: "Playlists" },
-];
-
-type Props = {
- sort: ChannelSort;
- query: string;
- tab: ChannelTab;
- searchAvailable: boolean;
- onSearch: (query: string) => void;
- onTabChange: (tab: ChannelTab) => void;
- onSortChange: (sort: ChannelSort) => void;
-};
-
-export function ChannelFilterBar({
- sort,
- query,
- tab,
- searchAvailable,
- onSearch,
- onTabChange,
- onSortChange,
-}: Props) {
- const [input, setInput] = useState(query);
- const trimmedInput = input.trim();
- const isSearching = query.length > 0;
-
- useEffect(() => {
- setInput(query);
- }, [query]);
-
- function submitSearch(event: FormEvent) {
- event.preventDefault();
- if (searchAvailable) onSearch(trimmedInput);
- }
-
- function clearSearch() {
- setInput("");
- onSearch("");
- }
-
- return (
-
-
- {searchAvailable && (
-
- {CHANNEL_TABS.map((item) => (
- onTabChange(item.tab)}
- className={`border-border-strong border-b py-1 font-medium transition-colors ${
- tab === item.tab
- ? "border-fg text-fg"
- : "border-transparent text-fg-soft hover:text-fg"
- }`}
- >
- {item.label}
-
- ))}
-
- )}
- {searchAvailable && tab === "videos" && (
-
-
-
- )}
- {!isSearching && tab === "videos" && (
-
- {CHANNEL_SORT_OPTIONS.map((option) => {
- const selected = option.value === sort;
- return (
- onSortChange(channelSortOrDefault(option.value))}
- className={`border-border-strong border-b py-1 font-medium transition-colors ${
- selected ? "border-fg text-fg" : "border-transparent text-fg-soft hover:text-fg"
- }`}
- >
- {option.label}
-
- );
- })}
-
- )}
-
- {isSearching && (
-
-
- Search results for {query} , ranked by YouTube.
-
-
- Back to all videos
-
-
- )}
-
- );
-}
diff --git a/apps/web/src/components/channel-page-content.tsx b/apps/web/src/components/channel-page-content.tsx
deleted file mode 100644
index cf57e7bc..00000000
--- a/apps/web/src/components/channel-page-content.tsx
+++ /dev/null
@@ -1,153 +0,0 @@
-import { useMemo } from "react";
-import { useBlockedFilter } from "../hooks/use-blocked-filter";
-import { useChannel } from "../hooks/use-channel";
-import { useDocumentTitle } from "../hooks/use-document-title";
-import { useSubscriptions } from "../hooks/use-subscriptions";
-import { isChannelNotAllowedError } from "../lib/allow-list-error";
-import { ApiError } from "../lib/api";
-import type { ChannelSort } from "../lib/api-discovery";
-import type { ChannelTab } from "../lib/channel-route-url";
-import { detectProvider } from "../lib/provider";
-import { ChannelFilterBar } from "./channel-filter-bar";
-import { ChannelPageHeader } from "./channel-page-header";
-import { ChannelPlaylistsSection } from "./channel-playlists-section";
-import { ChannelPodcastsSection } from "./channel-podcasts-section";
-import { FamilyListEmptyState } from "./family-list-empty-state";
-import { PageSpinner } from "./page-spinner";
-import { ScrollSentinel } from "./scroll-sentinel";
-import { VideoCard } from "./video-card";
-import { VideoGridSkeleton } from "./video-grid-skeleton";
-
-type Props = {
- sourceUrl: string;
- sort: ChannelSort;
- searchQuery: string;
- tab: ChannelTab;
- onNavigate: (sort: ChannelSort, query: string, tab: ChannelTab) => void;
-};
-
-export function ChannelPageContent({ sourceUrl, sort, searchQuery, tab, onNavigate }: Props) {
- const live = tab === "live";
- const {
- meta,
- videos,
- isLoading,
- isError,
- error,
- refetch,
- hasNextPage,
- isFetching,
- isFetchingNextPage,
- fetchNextPage,
- } = useChannel(sourceUrl, sort, searchQuery, live);
- const { add, remove, isSubscribed } = useSubscriptions();
- const { filter } = useBlockedFilter();
- useDocumentTitle(meta?.name);
-
- const subscribed = isSubscribed(sourceUrl);
- const searchAvailable = detectProvider(sourceUrl) === "youtube";
- const visibleVideos = useMemo(() => filter(videos), [filter, videos]);
- const isInitialLoading = isLoading && !meta;
- const isReplacingVideos = isFetching && !isFetchingNextPage && visibleVideos.length === 0;
-
- function handleSubscribe() {
- if (!meta) return;
- if (subscribed) {
- remove.mutate(sourceUrl);
- } else {
- add.mutate({ channelUrl: sourceUrl, name: meta.name, avatarUrl: meta.avatarUrl });
- }
- }
-
- function selectSort(nextSort: ChannelSort) {
- onNavigate(nextSort, searchQuery, tab);
- }
-
- function searchChannel(nextQuery: string) {
- onNavigate(sort, searchAvailable && tab === "videos" ? nextQuery : "", tab);
- }
-
- function selectTab(nextTab: ChannelTab) {
- onNavigate(sort, "", nextTab);
- }
-
- if (isInitialLoading) return ;
- if (isError) {
- if (isChannelNotAllowedError(error)) {
- return (
-
- );
- }
- const message = error instanceof ApiError ? error.message : "Unable to load channel right now.";
- return (
-
-
{message}
-
refetch()}
- className="h-9 w-fit rounded-md bg-fg px-3 text-xs font-medium text-app hover:bg-fg-strong"
- >
- Retry
-
-
- );
- }
-
- return (
-
- {meta && (
-
- )}
- {tab === "videos" && (
-
- )}
-
- {tab === "playlists" ? (
-
- ) : (
- <>
- {isReplacingVideos ? (
-
- ) : (
-
- {visibleVideos.map((v, index) => (
-
-
-
- ))}
-
- )}
- {isFetchingNextPage &&
}
-
- >
- )}
-
- );
-}
diff --git a/apps/web/src/components/channel-page-header.tsx b/apps/web/src/components/channel-page-header.tsx
deleted file mode 100644
index b367cd37..00000000
--- a/apps/web/src/components/channel-page-header.tsx
+++ /dev/null
@@ -1,59 +0,0 @@
-import { formatViews } from "../lib/format";
-import { AllowChannelButton } from "./allow-channel-button";
-import { ChannelAvatar } from "./channel-avatar";
-import { VerifiedBadgeIcon } from "./watch-icons";
-
-type Props = {
- sourceUrl: string;
- name: string;
- avatarUrl: string;
- bannerUrl: string;
- subscriberCount: number;
- isVerified: boolean;
- subscribed: boolean;
- onSubscribe: () => void;
-};
-
-export function ChannelPageHeader({
- sourceUrl,
- name,
- avatarUrl,
- bannerUrl,
- subscriberCount,
- isVerified,
- subscribed,
- onSubscribe,
-}: Props) {
- return (
-
- {bannerUrl &&
}
-
-
-
-
-
- {name}
- {isVerified && }
-
-
{formatViews(subscriberCount)} subscribers
-
-
-
-
-
- {subscribed ? "Subscribed" : "Subscribe"}
-
-
-
-
- );
-}
diff --git a/apps/web/src/components/channel-playlists-section.tsx b/apps/web/src/components/channel-playlists-section.tsx
deleted file mode 100644
index 321eab66..00000000
--- a/apps/web/src/components/channel-playlists-section.tsx
+++ /dev/null
@@ -1,32 +0,0 @@
-import { useChannelPlaylists } from "../hooks/use-channel-playlists";
-import { PageSpinner } from "./page-spinner";
-import { PublicPlaylistCard } from "./public-playlist-card";
-import { ScrollSentinel } from "./scroll-sentinel";
-
-type Props = {
- channelUrl: string;
-};
-
-export function ChannelPlaylistsSection({ channelUrl }: Props) {
- const { data, isLoading, hasNextPage, isFetchingNextPage, fetchNextPage } =
- useChannelPlaylists(channelUrl);
- const playlists = (data?.pages.flatMap((p) => p.playlists) ?? []).filter(
- (pl, i, arr) => arr.findIndex((x) => x.url === pl.url) === i,
- );
-
- if (isLoading) return ;
- if (playlists.length === 0) {
- return No playlists on this channel.
;
- }
-
- return (
-
-
- {playlists.map((pl) => (
-
- ))}
-
-
-
- );
-}
diff --git a/apps/web/src/components/channel-podcasts-section.tsx b/apps/web/src/components/channel-podcasts-section.tsx
deleted file mode 100644
index 785f893a..00000000
--- a/apps/web/src/components/channel-podcasts-section.tsx
+++ /dev/null
@@ -1,57 +0,0 @@
-import { usePodcasts } from "../hooks/use-podcasts";
-import { detectProvider } from "../lib/provider";
-import { PodcastCard } from "./podcast-card";
-
-const SKELETON_KEYS = [
- "podcast-skeleton-1",
- "podcast-skeleton-2",
- "podcast-skeleton-3",
- "podcast-skeleton-4",
-];
-
-type Props = {
- channelUrl: string;
- channelAvatar?: string;
-};
-
-export function ChannelPodcastsSection({ channelUrl, channelAvatar }: Props) {
- const isYoutube = detectProvider(channelUrl) === "youtube";
- const query = usePodcasts(channelUrl, isYoutube);
- const podcasts = query.data?.pages.flatMap((page) => page.podcasts) ?? [];
-
- if (!isYoutube || query.isError || (query.isFetched && podcasts.length === 0)) return null;
-
- return (
-
-
-
-
Podcasts
-
Podcast playlists from this channel
-
- {query.hasNextPage && (
-
query.fetchNextPage()}
- disabled={query.isFetchingNextPage}
- className="rounded-full border border-border px-3 py-1.5 text-xs font-medium text-fg-muted hover:border-border-strong hover:text-fg disabled:opacity-60"
- >
- {query.isFetchingNextPage ? "Loading..." : "Load more"}
-
- )}
-
- {query.isLoading ? (
-
- {SKELETON_KEYS.map((key) => (
-
- ))}
-
- ) : (
-
- {podcasts.map((podcast) => (
-
- ))}
-
- )}
-
- );
-}
diff --git a/apps/web/src/components/channel-route-link.tsx b/apps/web/src/components/channel-route-link.tsx
deleted file mode 100644
index e490ba70..00000000
--- a/apps/web/src/components/channel-route-link.tsx
+++ /dev/null
@@ -1,39 +0,0 @@
-import { Link } from "@tanstack/react-router";
-import type { ReactNode } from "react";
-import type { ChannelSort } from "../lib/api-discovery";
-import {
- channelLegacySearch,
- channelPathSearch,
- toChannelPathParam,
-} from "../lib/channel-route-url";
-
-type Props = {
- url: string;
- children: ReactNode;
- className?: string;
- sort?: ChannelSort;
- query?: string;
-};
-
-export function ChannelRouteLink({ url, children, className, sort = "latest", query = "" }: Props) {
- const channelId = toChannelPathParam(url);
-
- if (channelId) {
- return (
-
- {children}
-
- );
- }
-
- return (
-
- {children}
-
- );
-}
diff --git a/apps/web/src/components/cinema-mode-control.tsx b/apps/web/src/components/cinema-mode-control.tsx
deleted file mode 100644
index ff45f47a..00000000
--- a/apps/web/src/components/cinema-mode-control.tsx
+++ /dev/null
@@ -1,38 +0,0 @@
-import { useWatchLayoutStore } from "../stores/watch-layout-store";
-
-function CinemaModeIcon() {
- return (
-
- Cinema mode
-
-
-
-
-
- );
-}
-
-export function CinemaModeControl() {
- const cinemaMode = useWatchLayoutStore((state) => state.cinemaMode);
- const toggleCinemaMode = useWatchLayoutStore((state) => state.toggleCinemaMode);
-
- return (
-
-
-
- );
-}
diff --git a/apps/web/src/components/collection-page-header.tsx b/apps/web/src/components/collection-page-header.tsx
deleted file mode 100644
index 65df886c..00000000
--- a/apps/web/src/components/collection-page-header.tsx
+++ /dev/null
@@ -1,31 +0,0 @@
-import { PlaylistActions } from "./playlist-actions";
-
-type Props = {
- title: string;
- count: number;
- loading: boolean;
- canPlay: boolean;
- onPlayAll: () => void;
- onShuffle: () => void;
-};
-
-export function CollectionPageHeader({
- title,
- count,
- loading,
- canPlay,
- onPlayAll,
- onShuffle,
-}: Props) {
- return (
-
- );
-}
diff --git a/apps/web/src/components/confirm-modal.tsx b/apps/web/src/components/confirm-modal.tsx
deleted file mode 100644
index 51383e4f..00000000
--- a/apps/web/src/components/confirm-modal.tsx
+++ /dev/null
@@ -1,71 +0,0 @@
-import { useEffect } from "react";
-import { createPortal } from "react-dom";
-
-type Props = {
- title: string;
- description?: string;
- confirmLabel?: string;
- onConfirm: () => void;
- onCancel: () => void;
-};
-
-export function ConfirmModal({
- title,
- description,
- confirmLabel = "Delete",
- onConfirm,
- onCancel,
-}: Props) {
- useEffect(() => {
- const prev = document.body.style.overflow;
- document.body.style.overflow = "hidden";
- function onKeyDown(e: KeyboardEvent) {
- if (e.key === "Escape") onCancel();
- }
- document.addEventListener("keydown", onKeyDown);
- return () => {
- document.body.style.overflow = prev;
- document.removeEventListener("keydown", onKeyDown);
- };
- }, [onCancel]);
-
- return createPortal(
- <>
-
-
-
-
- {title}
-
- {description &&
{description}
}
-
-
-
- Cancel
-
-
- {confirmLabel}
-
-
-
- >,
- document.body,
- );
-}
diff --git a/apps/web/src/components/continue-card.tsx b/apps/web/src/components/continue-card.tsx
deleted file mode 100644
index e39aa4c8..00000000
--- a/apps/web/src/components/continue-card.tsx
+++ /dev/null
@@ -1,106 +0,0 @@
-import { Link } from "@tanstack/react-router";
-import { useClientLocale } from "../hooks/use-client-locale";
-import { useDeArrowBranding } from "../hooks/use-dearrow";
-import { formatDuration, formatPublishedDate, formatViews } from "../lib/format";
-import { proxyImage } from "../lib/proxy";
-import { watchRouteSearch } from "../lib/watch-url";
-import { useWatchNavigationStore } from "../stores/watch-navigation-store";
-import type { VideoStream } from "../types/stream";
-import type { HistoryItem } from "../types/user";
-import { ChannelRouteLink } from "./channel-route-link";
-import { HistoryChannelAvatar } from "./history-channel-avatar";
-import { VideoCardFeedbackMenu } from "./video-card-feedback-menu";
-import { VideoProgressBar } from "./video-progress-bar";
-import { VerifiedBadgeIcon } from "./watch-icons";
-
-type ContinueCardProps = {
- item: HistoryItem;
-};
-
-export function ContinueCard({ item }: ContinueCardProps) {
- const locale = useClientLocale();
- const setNavigation = useWatchNavigationStore((state) => state.setNavigation);
- const uploaderVerified = item.uploaderVerified ?? false;
- const branding = useDeArrowBranding(
- item.url,
- item.title,
- proxyImage(item.thumbnail),
- item.duration,
- );
- const thumbnail = branding.thumbnail;
- const publishedText = formatPublishedDate(item.publishedAt, undefined, locale);
- const viewsText = item.viewCount === undefined ? "" : formatViews(item.viewCount);
- const metaText = [viewsText, publishedText].filter(Boolean).join(" · ");
- const menuStream: VideoStream = {
- id: item.url,
- title: item.title,
- thumbnail,
- rawThumbnail: item.thumbnail,
- rawChannelAvatar: item.channelAvatar ?? "",
- channelName: item.channelName,
- channelUrl: item.channelUrl || undefined,
- channelAvatar: proxyImage(item.channelAvatar ?? ""),
- uploaderVerified,
- views: item.viewCount ?? 0,
- duration: item.duration,
- publishedAt: item.publishedAt,
- };
-
- return (
-
-
setNavigation(menuStream)}
- >
-
-
-
- {formatDuration(item.duration)}
-
-
-
-
- {branding.title}
-
-
-
-
- {item.channelUrl ? (
-
-
-
- ) : (
-
- )}
-
- {item.channelUrl ? (
-
- {item.channelName}
- {uploaderVerified && }
-
- ) : (
-
- {item.channelName}
- {uploaderVerified && }
-
- )}
- {metaText && {metaText} }
-
-
-
-
-
- );
-}
diff --git a/apps/web/src/components/continue-watching.tsx b/apps/web/src/components/continue-watching.tsx
deleted file mode 100644
index 0d715b9d..00000000
--- a/apps/web/src/components/continue-watching.tsx
+++ /dev/null
@@ -1,33 +0,0 @@
-import { useHistory } from "../hooks/use-history";
-import { isVideoInProgress } from "../lib/watch-progress";
-import { ContinueCard } from "./continue-card";
-
-const MAX_ITEMS = 12;
-
-export function ContinueWatching() {
- const { items } = useHistory();
- const displayed = items
- .filter((h) => isVideoInProgress(h.progress, h.duration))
- .sort((a, b) => b.watchedAt - a.watchedAt)
- .slice(0, MAX_ITEMS);
- if (displayed.length === 0) return null;
-
- return (
-
-
- Continue watching
-
-
- {displayed.map((item, index) => (
-
-
-
- ))}
-
-
- );
-}
diff --git a/apps/web/src/components/custom-avatar-upload.tsx b/apps/web/src/components/custom-avatar-upload.tsx
deleted file mode 100644
index cba99add..00000000
--- a/apps/web/src/components/custom-avatar-upload.tsx
+++ /dev/null
@@ -1,59 +0,0 @@
-import { ImageUp } from "lucide-react";
-import { useRef } from "react";
-import { useAvatar } from "../hooks/use-avatar";
-
-const MAX_BYTES = 10 * 1024 * 1024;
-const ACCEPTED_TYPES = new Set(["image/png", "image/jpeg", "image/webp", "image/gif"]);
-
-type Props = {
- onMessage: (message: string) => void;
-};
-
-export function CustomAvatarUpload({ onMessage }: Props) {
- const inputRef = useRef(null);
- const { custom } = useAvatar();
-
- function upload(file: File | undefined) {
- if (!file) return;
- if (!ACCEPTED_TYPES.has(file.type)) {
- onMessage("Use a PNG, JPEG, WebP, or GIF image");
- return;
- }
- if (file.size > MAX_BYTES) {
- onMessage("Avatar must be 10 MB or smaller");
- return;
- }
- custom.mutate(file, {
- onSuccess: () => onMessage("Avatar updated"),
- onError: () => onMessage("Unable to upload avatar"),
- });
- }
-
- return (
-
-
-
Custom image
-
PNG, JPEG, WebP, or animated GIF up to 10 MB
-
-
{
- upload(event.target.files?.[0]);
- event.target.value = "";
- }}
- />
-
inputRef.current?.click()}
- className="inline-flex h-9 items-center gap-2 rounded-md border border-border-strong bg-surface px-3 text-xs text-fg disabled:opacity-50"
- >
-
- Upload
-
-
- );
-}
diff --git a/apps/web/src/components/danmaku-controls.tsx b/apps/web/src/components/danmaku-controls.tsx
deleted file mode 100644
index 40c5e727..00000000
--- a/apps/web/src/components/danmaku-controls.tsx
+++ /dev/null
@@ -1,52 +0,0 @@
-import { useDanmakuStore } from "../stores/danmaku-store";
-import { DanmakuIcon } from "./watch-icons";
-
-const BTN = "flex items-center gap-2 px-3 py-1.5 rounded-lg text-sm transition-colors";
-const BTN_IDLE = "text-fg-muted hover:text-fg hover:bg-surface-strong";
-const BTN_ON = "text-fg bg-surface-strong";
-const SLIDER = "w-20 accent-zinc-400 cursor-pointer";
-
-export function DanmakuControls() {
- const { on, speed, size, toggle, setSpeed, setSize } = useDanmakuStore();
- return (
-
- );
-}
diff --git a/apps/web/src/components/danmaku-item.tsx b/apps/web/src/components/danmaku-item.tsx
deleted file mode 100644
index 1d92bdef..00000000
--- a/apps/web/src/components/danmaku-item.tsx
+++ /dev/null
@@ -1,77 +0,0 @@
-import { argbToColor, CHAR_WIDTH_PX, LANE_HEIGHT_PX, REGULAR_DISPLAY_MS } from "../lib/danmaku";
-import type { BulletCommentItem } from "../types/api";
-
-type Props = {
- comment: BulletCommentItem;
- lane: number;
- containerWidth: number;
- elapsedMs: number;
- speedMultiplier: number;
- sizeMultiplier: number;
-};
-
-const BASE: React.CSSProperties = {
- position: "absolute",
- whiteSpace: "nowrap",
- fontWeight: "bold",
- textShadow: "1px 1px 2px rgba(0,0,0,0.8)",
- pointerEvents: "none",
- userSelect: "none",
-};
-
-export function DanmakuItem({
- comment,
- lane,
- containerWidth,
- elapsedMs,
- speedMultiplier,
- sizeMultiplier,
-}: Props) {
- const color = argbToColor(comment.argbColor);
- const fontSize = Math.round(20 * comment.relativeFontSize * sizeMultiplier);
-
- if (comment.position === "REGULAR") {
- const estimatedWidth = comment.text.length * CHAR_WIDTH_PX;
- return (
-
- {comment.text}
-
- );
- }
-
- if (comment.position === "TOP" || comment.position === "SUPERCHAT") {
- return (
-
- {comment.text}
-
- );
- }
-
- return null;
-}
diff --git a/apps/web/src/components/danmaku-overlay.tsx b/apps/web/src/components/danmaku-overlay.tsx
deleted file mode 100644
index de243a41..00000000
--- a/apps/web/src/components/danmaku-overlay.tsx
+++ /dev/null
@@ -1,91 +0,0 @@
-import { useEffect, useMemo, useRef, useState } from "react";
-import { displayDuration, N_LANES, REGULAR_DISPLAY_MS } from "../lib/danmaku";
-import { useDanmakuStore } from "../stores/danmaku-store";
-import type { BulletCommentItem } from "../types/api";
-import { DanmakuItem } from "./danmaku-item";
-
-type Props = {
- comments: BulletCommentItem[];
- positionRef: React.RefObject;
-};
-
-type IndexedComment = BulletCommentItem & { lane: number; id: number };
-
-export function DanmakuOverlay({ comments, positionRef }: Props) {
- const overlayRef = useRef(null);
- const [visible, setVisible] = useState([]);
- const startMsMap = useRef(new Map());
- const { speed, size } = useDanmakuStore();
-
- const indexed = useMemo(
- () =>
- comments
- .filter((c) => c.position !== "BOTTOM")
- .map((c, i) => ({ ...c, lane: i % N_LANES, id: i })),
- [comments],
- );
-
- useEffect(() => {
- let rafId: number;
- let prevKey = "";
-
- function tick() {
- const ms = positionRef.current;
- const currentSpeed = useDanmakuStore.getState().speed;
- const vis = indexed.filter((c) => {
- const elapsed = ms - c.durationMs;
- const dur =
- c.position === "REGULAR"
- ? REGULAR_DISPLAY_MS / currentSpeed
- : displayDuration(c.position);
- return elapsed >= 0 && elapsed < dur;
- });
- const key = vis.map((c) => c.id).join(",");
- if (key !== prevKey) {
- prevKey = key;
- for (const c of vis) {
- if (!startMsMap.current.has(c.id)) {
- startMsMap.current.set(c.id, ms);
- }
- }
- for (const id of startMsMap.current.keys()) {
- if (!vis.some((c) => c.id === id)) {
- startMsMap.current.delete(id);
- }
- }
- setVisible(vis);
- }
- rafId = requestAnimationFrame(tick);
- }
-
- rafId = requestAnimationFrame(tick);
- return () => cancelAnimationFrame(rafId);
- }, [indexed, positionRef]);
-
- const width = overlayRef.current?.offsetWidth ?? 0;
-
- return (
-
- {visible.map((c) => (
-
- ))}
-
- );
-}
diff --git a/apps/web/src/components/download-mode-button.tsx b/apps/web/src/components/download-mode-button.tsx
deleted file mode 100644
index c16d4ec5..00000000
--- a/apps/web/src/components/download-mode-button.tsx
+++ /dev/null
@@ -1,19 +0,0 @@
-type Props = {
- active: boolean;
- onClick: () => void;
- label: string;
-};
-
-export function DownloadModeButton({ active, onClick, label }: Props) {
- return (
-
- {label}
-
- );
-}
diff --git a/apps/web/src/components/download-option-button.tsx b/apps/web/src/components/download-option-button.tsx
deleted file mode 100644
index b17dc732..00000000
--- a/apps/web/src/components/download-option-button.tsx
+++ /dev/null
@@ -1,45 +0,0 @@
-import type { DownloadOption } from "./download-options";
-
-type Props = {
- option: DownloadOption;
- selected: boolean;
- onSelect: () => void;
- index: number;
- compact?: boolean;
-};
-
-export function DownloadOptionButton({
- option,
- selected,
- onSelect,
- index,
- compact = false,
-}: Props) {
- return (
-
-
-
{option.label}
- {option.recommended && (
-
- Recommended
-
- )}
-
- {option.size}
- {(!compact || selected) && (
- {option.detail}
- )}
-
- );
-}
diff --git a/apps/web/src/components/download-options.ts b/apps/web/src/components/download-options.ts
deleted file mode 100644
index 1b9147db..00000000
--- a/apps/web/src/components/download-options.ts
+++ /dev/null
@@ -1,173 +0,0 @@
-import type { DownloaderCreateJobRequest, DownloaderMode } from "../types/downloader";
-import type { VideoStream } from "../types/stream";
-
-export type DownloadMode = "video" | "audio";
-
-export type DownloadOption = {
- id: string;
- mode: DownloadMode;
- label: string;
- detail: string;
- size: string;
- recommended: boolean;
- videoItag?: string;
- audioItag?: string;
- width?: number;
- height?: number;
- fps?: number;
- videoCodec?: string;
- audioCodec?: string;
- bitrate?: number;
- format: string;
- qualityHint: string;
-};
-
-function formatBytes(bytes: number): string {
- if (!Number.isFinite(bytes) || bytes <= 0) return "Unknown size";
- const units = ["B", "KB", "MB", "GB"];
- let value = bytes;
- let unitIndex = 0;
- while (value >= 1024 && unitIndex < units.length - 1) {
- value /= 1024;
- unitIndex += 1;
- }
- const decimals = value >= 100 ? 0 : value >= 10 ? 1 : 2;
- return `${value.toFixed(decimals)} ${units[unitIndex]}`;
-}
-
-function parseContainer(mimeType: string): string {
- const [type] = mimeType.split(";");
- const [, container = "bin"] = type.split("/");
- return container.toUpperCase();
-}
-
-function effectiveVideoHeight(height?: number, width?: number): number {
- if (typeof height !== "number" || height <= 0) return 0;
- if (typeof width !== "number" || width <= 0) return height;
- return Math.min(height, width);
-}
-
-function pickVideoQuality(height: number, width?: number): string {
- const effective = effectiveVideoHeight(height, width);
- if (effective >= 1080) return "best";
- if (effective >= 720) return "balanced";
- return "small";
-}
-
-function pickAudioQuality(bitrate: number): string {
- if (bitrate >= 192) return "best";
- if (bitrate >= 128) return "balanced";
- return "small";
-}
-
-function pickRecommendedVideoIndex(videos: DownloadOption[]): number {
- const fullHd = videos.findIndex(
- (video) => effectiveVideoHeight(video.height, video.width) === 1080,
- );
- if (fullHd >= 0) return fullHd;
- const hd = videos.findIndex((video) => effectiveVideoHeight(video.height, video.width) === 720);
- if (hd >= 0) return hd;
- const belowFullHd = videos.findIndex(
- (video) => effectiveVideoHeight(video.height, video.width) < 1080,
- );
- if (belowFullHd >= 0) return belowFullHd;
- return videos.length - 1;
-}
-
-export function buildDownloadOptions(stream: VideoStream): DownloadOption[] {
- const videos = [...(stream.videoOnlyStreams ?? [])]
- .sort((left, right) => right.height - left.height || right.fps - left.fps)
- .map((item, index) => {
- const resolution = item.resolution || (item.height > 0 ? `${item.height}p` : "Video");
- const fps = item.fps > 0 ? ` ${item.fps}fps` : "";
- const codec = item.codec ?? "video";
- const container = parseContainer(item.mimeType);
- return {
- id: `video-${item.itag}-${index}`,
- mode: "video" as const,
- label: `${resolution}${fps} ${container}`,
- detail: `${codec} · itag ${item.itag}`,
- size: formatBytes(item.contentLength),
- recommended: false,
- videoItag: String(item.itag),
- width: item.width,
- height: item.height,
- fps: item.fps,
- videoCodec: item.codec ?? undefined,
- format: container.toLowerCase(),
- qualityHint: pickVideoQuality(item.height, item.width),
- };
- });
-
- const audios = [...(stream.audioStreams ?? [])]
- .sort((left, right) => (right.bitrate ?? 0) - (left.bitrate ?? 0))
- .map((item, index) => {
- const locale = item.audioLocale || item.quality || "default";
- const codec = item.codec ?? "audio";
- const container = parseContainer(item.mimeType);
- const bitrate = item.bitrate ?? 0;
- return {
- id: `audio-${item.itag}-${index}`,
- mode: "audio" as const,
- label: `${item.bitrate ? `${Math.round(item.bitrate)} kbps` : "Audio"} ${container}`,
- detail: `${locale} · ${codec} · itag ${item.itag}`,
- size: formatBytes(item.contentLength),
- recommended: false,
- audioItag: String(item.itag),
- bitrate,
- audioCodec: item.codec ?? undefined,
- format: container.toLowerCase(),
- qualityHint: pickAudioQuality(bitrate),
- };
- });
-
- if (videos.length > 0) {
- const recommendedIndex = pickRecommendedVideoIndex(videos);
- videos[recommendedIndex] = { ...videos[recommendedIndex], recommended: true };
- }
-
- if (audios.length > 0) {
- const recommendedAudio = audios.findIndex(
- (audio) => (audio.bitrate ?? 0) >= 160 && (audio.bitrate ?? 0) < 256,
- );
- const index = recommendedAudio >= 0 ? recommendedAudio : 0;
- audios[index] = { ...audios[index], recommended: true };
- }
-
- const merged = [...videos, ...audios];
- return merged;
-}
-
-export function buildDownloaderCreatePayload(
- videoUrl: string,
- option: DownloadOption,
-): DownloaderCreateJobRequest {
- const mode = option.mode as DownloaderMode;
- const format = option.format.length > 0 ? option.format : mode === "video" ? "mp4" : "mp3";
- return {
- url: videoUrl,
- options: {
- mode,
- quality: option.qualityHint,
- format,
- videoItag: option.videoItag,
- audioItag: option.audioItag,
- height: option.height,
- fps: option.fps,
- videoCodec: option.videoCodec,
- audioCodec: option.audioCodec,
- bitrate: option.bitrate,
- allowQualityFallback: false,
- sponsorBlock: false,
- sponsorBlockCategories: ["sponsor", "intro"],
- thumbnailOnly: false,
- subtitles: {
- enabled: false,
- auto: false,
- embed: false,
- languages: ["en"],
- format: "srt",
- },
- },
- };
-}
diff --git a/apps/web/src/components/download-sheet-picker.tsx b/apps/web/src/components/download-sheet-picker.tsx
deleted file mode 100644
index 33244ec8..00000000
--- a/apps/web/src/components/download-sheet-picker.tsx
+++ /dev/null
@@ -1,83 +0,0 @@
-import { useMemo, useState } from "react";
-import { DownloadModeButton } from "./download-mode-button";
-import { DownloadOptionButton } from "./download-option-button";
-import type { DownloadMode, DownloadOption } from "./download-options";
-import { buildSimpleChoices } from "./download-simple-choices";
-
-type Props = {
- mode: DownloadMode;
- options: DownloadOption[];
- selectedId: string;
- onSelect: (id: string) => void;
- onMode: (next: DownloadMode) => void;
-};
-
-export function DownloadSheetPicker({ mode, options, selectedId, onSelect, onMode }: Props) {
- const [showAllFormats, setShowAllFormats] = useState(false);
- const modeOptions = options.filter((option) => option.mode === mode);
- const simpleChoices = useMemo(() => buildSimpleChoices(modeOptions, mode), [modeOptions, mode]);
- const selected = modeOptions.find((option) => option.id === selectedId) ?? modeOptions[0];
-
- return (
- <>
-
- onMode("video")}
- label="Video"
- />
- onMode("audio")}
- label="Audio"
- />
-
- {!showAllFormats && (
-
- {simpleChoices.map((choice) => (
-
onSelect(choice.id)}
- className={`w-full rounded-lg border px-3 py-2 text-left transition-colors ${
- selected?.id === choice.id
- ? "border-fg bg-surface-strong text-fg"
- : "border-border-strong bg-surface text-fg-muted hover:border-border-strong"
- }`}
- >
- {choice.title}
- {choice.option.label}
- {choice.option.size}
-
- ))}
-
- )}
- {showAllFormats && (
-
- {modeOptions.map((option, index) => (
- onSelect(option.id)}
- index={index}
- compact
- />
- ))}
-
- )}
- {modeOptions.length > simpleChoices.length && (
- setShowAllFormats((open) => !open)}
- className="mt-2 w-full rounded-md border border-border-strong px-2.5 py-1.5 text-xs text-fg-muted transition-colors hover:border-border-strong hover:bg-surface-strong"
- >
- {showAllFormats ? "Simple view" : `All formats (${modeOptions.length})`}
-
- )}
- >
- );
-}
diff --git a/apps/web/src/components/download-sheet.tsx b/apps/web/src/components/download-sheet.tsx
deleted file mode 100644
index 5d5e59a6..00000000
--- a/apps/web/src/components/download-sheet.tsx
+++ /dev/null
@@ -1,169 +0,0 @@
-import { useMemo, useState } from "react";
-import { useArtifactDownloadOnDone } from "../hooks/use-artifact-download-on-done";
-import { useDownloaderJob } from "../hooks/use-downloader-job";
-import { useOverlayLock } from "../hooks/use-overlay-lock";
-import { useSmoothDismiss } from "../hooks/use-smooth-dismiss";
-import { deleteDownloaderJob } from "../lib/api-downloader";
-import type { VideoStream } from "../types/stream";
-import {
- buildDownloaderCreatePayload,
- buildDownloadOptions,
- type DownloadMode,
-} from "./download-options";
-import { DownloadSheetPicker } from "./download-sheet-picker";
-import { DownloaderJobFeedback } from "./downloader-job-feedback";
-
-type Props = {
- stream: VideoStream;
- onClose: () => void;
- onDone: (message: string) => void;
-};
-
-export function DownloadSheet({ stream, onClose, onDone }: Props) {
- useOverlayLock(true);
- const { isClosing, dismiss } = useSmoothDismiss({ onClose });
- const downloader = useDownloaderJob();
- const {
- isDone,
- jobId,
- isQueued,
- isRunning,
- errorText,
- isFailed,
- openArtifact,
- reset,
- start,
- canUseIosShareFlow,
- cancelJob,
- isCancelling,
- } = downloader;
- const isBusy = isQueued || isRunning;
- const [artifactError, setArtifactError] = useState(null);
- const [clearPending, setClearPending] = useState(false);
- const options = useMemo(() => buildDownloadOptions(stream), [stream]);
- const [mode, setMode] = useState("video");
- const [selectedId, setSelectedId] = useState(
- options.find((option) => option.recommended)?.id ?? options[0]?.id ?? "",
- );
- const selected = options.find((option) => option.id === selectedId) ?? options[0];
-
- const completion = useArtifactDownloadOnDone({
- isDone,
- jobId,
- selectedLabel: selected?.label ?? "file",
- openArtifact,
- autoStart: !canUseIosShareFlow,
- preferShare: canUseIosShareFlow,
- onDone,
- onDismiss: dismiss,
- reset,
- onArtifactError: setArtifactError,
- });
- const requiresManualArtifactTap = isDone && canUseIosShareFlow;
- const showWorkingState = isBusy || completion.isCompleting;
-
- function selectMode(next: DownloadMode) {
- setMode(next);
- const modeOptions = options.filter((option) => option.mode === next);
- setSelectedId(modeOptions.find((option) => option.recommended)?.id ?? modeOptions[0]?.id ?? "");
- }
-
- function startDownload() {
- if (!selected) return;
- setArtifactError(null);
- start(buildDownloaderCreatePayload(stream.id, selected));
- }
-
- async function clearJob() {
- if (!jobId) return reset();
- setClearPending(true);
- try {
- await deleteDownloaderJob(jobId);
- reset();
- setArtifactError(null);
- } catch (error) {
- setArtifactError(error instanceof Error ? error.message : "Failed to clear download job");
- }
- setClearPending(false);
- }
-
- return (
-
-
-
-
-
-
-
Download
-
{stream.title}
-
-
- Close
-
-
- {!showWorkingState && !requiresManualArtifactTap && (
- <>
-
-
- Start download
-
- >
- )}
- {requiresManualArtifactTap && (
-
-
- File is ready. Tap below to open it in iOS and save to Files.
-
-
void completion.completeNow()}
- disabled={completion.isCompleting}
- className="w-full rounded-lg bg-fg px-3 py-2 text-sm font-medium text-app transition-colors hover:bg-fg-strong disabled:cursor-not-allowed disabled:bg-surface-soft disabled:text-fg-muted"
- >
- {completion.isCompleting ? "Opening..." : "Open file"}
-
-
- )}
-
void clearJob()}
- />
-
-
-
- );
-}
diff --git a/apps/web/src/components/download-simple-choices.ts b/apps/web/src/components/download-simple-choices.ts
deleted file mode 100644
index d49f19f4..00000000
--- a/apps/web/src/components/download-simple-choices.ts
+++ /dev/null
@@ -1,45 +0,0 @@
-import type { DownloadMode, DownloadOption } from "./download-options";
-
-type SimpleChoice = {
- id: string;
- title: string;
- option: DownloadOption;
-};
-
-function effectiveVideoHeight(option: DownloadOption): number {
- if (typeof option.height !== "number" || option.height <= 0) return 0;
- if (typeof option.width !== "number" || option.width <= 0) return option.height;
- return Math.min(option.height, option.width);
-}
-
-export function buildSimpleChoices(options: DownloadOption[], mode: DownloadMode): SimpleChoice[] {
- if (options.length === 0) return [];
- const best = options[0];
- const small =
- mode === "video"
- ? (options.find((option) => effectiveVideoHeight(option) === 360) ??
- [...options].reverse().find((option) => effectiveVideoHeight(option) >= 360) ??
- options[options.length - 1])
- : options[options.length - 1];
- const balanced =
- options.find((option) => option.recommended) ?? options[Math.floor(options.length / 2)];
- const balancedHeight = effectiveVideoHeight(balanced);
- const balancedTitle =
- mode === "video" && balancedHeight === 1080
- ? "Recommended (1080p)"
- : mode === "video" && balancedHeight === 720
- ? "Recommended (720p)"
- : mode === "audio"
- ? "Recommended"
- : "Balanced";
- const raw = [
- { id: best.id, title: mode === "video" ? "Best quality" : "Best sound", option: best },
- { id: balanced.id, title: balancedTitle, option: balanced },
- { id: small.id, title: mode === "video" ? "Small size (360p)" : "Small size", option: small },
- ];
- const unique = new Map();
- raw.forEach((choice) => {
- if (!unique.has(choice.id)) unique.set(choice.id, choice);
- });
- return Array.from(unique.values());
-}
diff --git a/apps/web/src/components/downloader-job-feedback.tsx b/apps/web/src/components/downloader-job-feedback.tsx
deleted file mode 100644
index 99c61ccb..00000000
--- a/apps/web/src/components/downloader-job-feedback.tsx
+++ /dev/null
@@ -1,159 +0,0 @@
-import {
- DOWNLOADER_STEPS,
- downloaderProgressValue,
- downloaderStageIndex,
- downloaderStatusLabel,
- downloaderStatusMessage,
- isCancelledDownloaderJob,
- isFailedDownloaderJob,
- shouldShowDownloaderProgress,
-} from "../lib/downloader-display";
-import { DOWNLOADER_INSUFFICIENT_STORAGE_CODE } from "../lib/downloader-errors";
-import type {
- DownloaderJobStage,
- DownloaderJobStatus,
- DownloaderResolvedSelection,
-} from "../types/downloader";
-import { DownloaderStorageError } from "./downloader-storage-error";
-
-type Props = {
- status: DownloaderJobStatus | null;
- stage: DownloaderJobStage | null;
- progressPercent: number | null;
- resolved: DownloaderResolvedSelection | null;
- errorCode: string | null;
- errorText: string | null;
- immersive?: boolean;
- forceWaiting?: boolean;
- canCancel?: boolean;
- cancelPending?: boolean;
- onCancel?: () => void;
- canClear?: boolean;
- clearPending?: boolean;
- onClear?: () => void;
-};
-
-function formatResolved(resolved: DownloaderResolvedSelection | null): string | null {
- if (!resolved) return null;
- const quality =
- typeof resolved.height === "number"
- ? `${resolved.height}p`
- : typeof resolved.bitrate === "number"
- ? `${Math.round(resolved.bitrate)} kbps`
- : null;
- const container =
- typeof resolved.container === "string" && resolved.container.length > 0
- ? resolved.container.toUpperCase()
- : null;
- if (!quality && !container) return null;
- return [quality, container].filter((item) => item !== null).join(" ");
-}
-
-function exactUnavailableMessage(resolved: DownloaderResolvedSelection | null): string {
- if (typeof resolved?.height === "number") {
- return `Requested ${resolved.height}p is unavailable. Pick another format.`;
- }
- return "Selected format is unavailable. Pick another format.";
-}
-
-export function DownloaderJobFeedback({
- status,
- stage,
- progressPercent,
- resolved,
- errorCode,
- errorText,
- immersive = false,
- forceWaiting = false,
- canCancel = false,
- cancelPending = false,
- onCancel,
- canClear = false,
- clearPending = false,
- onClear,
-}: Props) {
- const resolvedLabel = formatResolved(resolved);
- const visibleError =
- errorCode === "exact_selection_unavailable"
- ? exactUnavailableMessage(resolved)
- : (errorText ?? null);
- const cancelled = isCancelledDownloaderJob(status, stage, errorCode);
- const failed = isFailedDownloaderJob(status, stage, errorCode);
- const label = downloaderStatusLabel(status, stage, errorCode, forceWaiting);
- const message = downloaderStatusMessage(status, stage, errorCode, visibleError, forceWaiting);
- const insufficientStorage = errorCode === DOWNLOADER_INSUFFICIENT_STORAGE_CODE;
- const progress = downloaderProgressValue(status, stage, progressPercent, forceWaiting);
- const activeStep = downloaderStageIndex(status, stage);
- const showCancel = canCancel && !cancelled && !failed && typeof onCancel === "function";
- const showClear = canClear && typeof onClear === "function";
- const showProgress = shouldShowDownloaderProgress(status, forceWaiting) && !failed && !cancelled;
- const showPercent = typeof progressPercent === "number" && status === "running";
-
- if (!status && !forceWaiting && !resolvedLabel && !visibleError) return null;
-
- return (
-
-
-
-
{label}
-
- {message}
-
-
-
- {showPercent && (
- {Math.round(progress)}%
- )}
- {showCancel && (
-
- {cancelPending ? "Cancelling..." : "Cancel"}
-
- )}
- {showClear && (
-
- {clearPending ? "Clearing..." : "Clear"}
-
- )}
-
-
- {insufficientStorage && }
- {showProgress && (
-
- )}
- {status === "running" && (
-
- {DOWNLOADER_STEPS.map((step, index) => (
-
-
- {step}
-
- ))}
-
- )}
- {resolvedLabel && !failed && (
- Selected: {resolvedLabel}
- )}
-
- );
-}
diff --git a/apps/web/src/components/downloader-storage-error.tsx b/apps/web/src/components/downloader-storage-error.tsx
deleted file mode 100644
index a3f251c3..00000000
--- a/apps/web/src/components/downloader-storage-error.tsx
+++ /dev/null
@@ -1,16 +0,0 @@
-import { DOWNLOADER_INSUFFICIENT_STORAGE_HELP } from "../lib/downloader-errors";
-
-export function DownloaderStorageError() {
- return (
-
-
-
- {DOWNLOADER_INSUFFICIENT_STORAGE_HELP}
-
-
- );
-}
diff --git a/apps/web/src/components/external-link-modal.tsx b/apps/web/src/components/external-link-modal.tsx
deleted file mode 100644
index 282ffb3e..00000000
--- a/apps/web/src/components/external-link-modal.tsx
+++ /dev/null
@@ -1,66 +0,0 @@
-import { useEffect } from "react";
-import { createPortal } from "react-dom";
-
-type Props = {
- url: string;
- onConfirm: () => void;
- onCancel: () => void;
-};
-
-export function ExternalLinkModal({ url, onConfirm, onCancel }: Props) {
- useEffect(() => {
- const prev = document.body.style.overflow;
- document.body.style.overflow = "hidden";
- function onKeyDown(e: KeyboardEvent) {
- if (e.key === "Escape") onCancel();
- }
- document.addEventListener("keydown", onKeyDown);
- return () => {
- document.body.style.overflow = prev;
- document.removeEventListener("keydown", onKeyDown);
- };
- }, [onCancel]);
-
- return createPortal(
- <>
-
-
-
-
- You are leaving TypeType
-
-
This link will open in a new tab:
-
- {url}
-
-
-
-
- Cancel
-
-
- Continue
-
-
-
- >,
- document.body,
- );
-}
diff --git a/apps/web/src/components/family-list-empty-state.tsx b/apps/web/src/components/family-list-empty-state.tsx
deleted file mode 100644
index a5e4bb85..00000000
--- a/apps/web/src/components/family-list-empty-state.tsx
+++ /dev/null
@@ -1,39 +0,0 @@
-import { Link } from "@tanstack/react-router";
-import { useAuth } from "../hooks/use-auth";
-
-type Props = {
- title?: string;
- description?: string;
- showSettingsAction?: boolean;
-};
-
-export function FamilyListEmptyState({
- title = "Nothing from the family list yet",
- description = "Add trusted channels so this page can stay focused on videos you picked for your family.",
- showSettingsAction = true,
-}: Props) {
- const { canGlobalBlock } = useAuth();
- const showAdminAction = showSettingsAction && canGlobalBlock;
- return (
-
-
-
- Family list
-
-
-
{title}
-
{description}
-
- {showAdminAction && (
-
- Open allow list
-
- )}
-
-
- );
-}
diff --git a/apps/web/src/components/flag-icon.tsx b/apps/web/src/components/flag-icon.tsx
deleted file mode 100644
index 0db4f97c..00000000
--- a/apps/web/src/components/flag-icon.tsx
+++ /dev/null
@@ -1,24 +0,0 @@
-function toFlagEmoji(code: string): string | null {
- const normalized = code.trim().toUpperCase();
- if (!/^[A-Z]{2}$/.test(normalized)) return null;
- const first = normalized.codePointAt(0);
- const second = normalized.codePointAt(1);
- if (first === undefined || second === undefined) return null;
- const base = 127397;
- return String.fromCodePoint(first + base, second + base);
-}
-
-type FlagIconProps = {
- code: string;
- className?: string;
-};
-
-export function FlagIcon({ code, className }: FlagIconProps) {
- const flag = toFlagEmoji(code);
- if (!flag) return null;
- return (
-
- {flag}
-
- );
-}
diff --git a/apps/web/src/components/format-selector.tsx b/apps/web/src/components/format-selector.tsx
deleted file mode 100644
index 33c0553b..00000000
--- a/apps/web/src/components/format-selector.tsx
+++ /dev/null
@@ -1,136 +0,0 @@
-import type * as dashjs from "dashjs";
-import { useRef } from "react";
-import { useDashPlayerSnapshot } from "../lib/dash-player-store";
-import { dashTrackGroups, maxTrackHeight, selectDashTrack } from "../lib/dash-video";
-import { activeFamily, type CodecFamily, codecFamily, groupByFamily } from "../lib/quality-utils";
-import {
- maxSabrCodecHeight,
- sabrCodecOptions,
- selectSabrCodec,
-} from "../lib/sabr-quality-selection";
-import type { MenuInstance } from "../lib/vidstack";
-import {
- DefaultMenuButton,
- DefaultMenuRadioGroup,
- Menu,
- useVideoQualityOptions,
-} from "../lib/vidstack";
-import { useSabrQualityStore } from "../stores/sabr-quality-store";
-
-const FORMAT_ORDER: CodecFamily[] = ["H.264", "VP9", "AV1"];
-const MENU_ITEMS_CLASS =
- "vds-menu-items overflow-y-auto overscroll-y-contain pr-0.5 [scrollbar-width:thin] [scrollbar-color:var(--color-zinc-500)_transparent] [&::-webkit-scrollbar]:w-1.5 [&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-thumb]:bg-surface-soft/80 [&::-webkit-scrollbar-thumb:hover]:bg-surface-soft [&::-webkit-scrollbar-track]:bg-transparent";
-const QUALITY_OPTIONS = { sort: "descending" } as const;
-
-function isCodecFamily(value: string): value is CodecFamily {
- return value === "H.264" || value === "VP9" || value === "AV1";
-}
-
-function formatLabel(family: CodecFamily, track: dashjs.MediaInfo): string {
- const maxHeight = maxTrackHeight(track);
- return maxHeight > 0 ? `${family} ${maxHeight}p` : family;
-}
-
-export function FormatSelector() {
- const menuRef = useRef(null);
- const { player, selectedVideoTrack } = useDashPlayerSnapshot();
- const options = useVideoQualityOptions(QUALITY_OPTIONS);
- const sabrStreamId = useSabrQualityStore((state) => state.streamId);
- const sabrOptions = useSabrQualityStore((state) => state.options);
- const sabrSelectedItag = useSabrQualityStore((state) => state.selectedItag);
- const selectSabrQuality = useSabrQualityStore((state) => state.selectQuality);
-
- if (sabrStreamId && sabrOptions.length > 0) {
- const streamId = sabrStreamId;
- const selected =
- sabrOptions.find((option) => option.itag === sabrSelectedItag) ?? sabrOptions[0];
- const codecs = sabrCodecOptions(sabrOptions);
- if (codecs.length <= 1) return null;
- function onSabrChange(value: string) {
- if (!isCodecFamily(value)) return;
- const next = selectSabrCodec(sabrOptions, selected, value);
- if (!next) return;
- selectSabrQuality(streamId, next.itag);
- menuRef.current?.close();
- }
- return (
-
-
-
- ({
- label: `${codec} ${maxSabrCodecHeight(sabrOptions, codec)}p`,
- value: codec,
- }))}
- onChange={onSabrChange}
- />
-
-
- );
- }
-
- const videoOptions = options.filter((o) => o.quality !== null);
- const dashGroups = player ? dashTrackGroups(player) : null;
-
- if (player && dashGroups && dashGroups.size > 1) {
- const dashPlayer = player;
- const current = codecFamily(
- selectedVideoTrack?.codec ?? player.getCurrentTrackFor("video")?.codec ?? null,
- );
- const selected = current ?? FORMAT_ORDER.find((family) => dashGroups.has(family));
- const availableOptions = FORMAT_ORDER.flatMap((family) => {
- const track = dashGroups.get(family);
- if (!track) return [];
- return [{ label: formatLabel(family, track), value: family }];
- });
-
- if (!selected) return null;
-
- function onDashChange(value: string) {
- if (!isCodecFamily(value)) return;
- const track = dashGroups?.get(value);
- if (!track) return;
- selectDashTrack(dashPlayer, track);
- menuRef.current?.close();
- }
-
- return (
-
-
-
-
-
-
- );
- }
-
- const groups = groupByFamily(videoOptions);
- const selected = activeFamily(videoOptions) ?? FORMAT_ORDER.find((family) => groups.has(family));
- const availableOptions = FORMAT_ORDER.filter((family) => groups.has(family)).map((family) => ({
- label: family,
- value: family,
- }));
-
- if (groups.size <= 1) return null;
- if (!selected) return null;
-
- function onChange(value: string) {
- if (!isCodecFamily(value)) return;
- groups.get(value)?.select();
- menuRef.current?.close();
- }
-
- return (
-
-
-
-
-
-
- );
-}
diff --git a/apps/web/src/components/fragment-boundary-seeker.tsx b/apps/web/src/components/fragment-boundary-seeker.tsx
deleted file mode 100644
index a5b96b80..00000000
--- a/apps/web/src/components/fragment-boundary-seeker.tsx
+++ /dev/null
@@ -1,33 +0,0 @@
-import { useEffect } from "react";
-import { snapToFragmentBoundary } from "../lib/fragment-boundary-seek";
-import { isWebKitBrowser } from "../lib/ios-device";
-import { useMediaPlayer } from "../lib/vidstack";
-
-export function FragmentBoundarySeeker({ intervalSeconds }: { intervalSeconds?: number }) {
- const player = useMediaPlayer();
-
- useEffect(() => {
- if (!intervalSeconds || !isWebKitBrowser()) return;
- const root = player?.el;
- if (!root) return;
- const media = root.querySelector("video,audio");
- if (!media) return;
- let adjusting = false;
-
- const handleSeeking = () => {
- if (adjusting) return;
- const target = snapToFragmentBoundary(media.currentTime, intervalSeconds);
- if (Math.abs(target - media.currentTime) < 0.05) return;
- adjusting = true;
- media.currentTime = target;
- queueMicrotask(() => {
- adjusting = false;
- });
- };
-
- media.addEventListener("seeking", handleSeeking);
- return () => media.removeEventListener("seeking", handleSeeking);
- }, [intervalSeconds, player]);
-
- return null;
-}
diff --git a/apps/web/src/components/guest-disabled-screen.tsx b/apps/web/src/components/guest-disabled-screen.tsx
deleted file mode 100644
index 2a4c5907..00000000
--- a/apps/web/src/components/guest-disabled-screen.tsx
+++ /dev/null
@@ -1,46 +0,0 @@
-import { Link } from "@tanstack/react-router";
-import { AuthBackdrop } from "./auth-backdrop";
-
-export function GuestDisabledScreen() {
- return (
-
-
-
-
- Guest mode disabled
-
-
- The admin has disabled guest access.
-
-
- To use this TypeType instance, create an account or sign in with an existing one.
-
-
-
-
- Create account
-
-
- Sign in
-
-
-
- );
-}
diff --git a/apps/web/src/components/hide-everything-intro.tsx b/apps/web/src/components/hide-everything-intro.tsx
deleted file mode 100644
index 1d5ebbcc..00000000
--- a/apps/web/src/components/hide-everything-intro.tsx
+++ /dev/null
@@ -1,64 +0,0 @@
-import { useEffect, useState } from "react";
-
-const HOLDS = [3000, 2700, 2100];
-const FADE = 430;
-
-type Props = {
- onDone: () => void;
- onRelax: () => void;
-};
-
-export function HideEverythingIntro({ onDone, onRelax }: Props) {
- const [phase, setPhase] = useState(0);
- const [shown, setShown] = useState(true);
-
- useEffect(() => {
- if (phase === HOLDS.length - 1) {
- onRelax();
- }
- const hold = HOLDS[phase];
- const hide = window.setTimeout(() => setShown(false), hold);
- const next = window.setTimeout(() => {
- if (phase + 1 < HOLDS.length) {
- setShown(true);
- setPhase(phase + 1);
- } else {
- onDone();
- }
- }, hold + FADE);
- return () => {
- window.clearTimeout(hide);
- window.clearTimeout(next);
- };
- }, [phase, onDone, onRelax]);
-
- return (
-
-
- {phase === 0 && (
-
-
- Fine!
-
-
- You want to hide everything?
-
-
- )}
- {phase === 1 && (
-
- Do you know how much time I spent building all of this?!
-
- )}
- {phase === 2 && (
-
- Go and relax
-
- )}
-
-
- );
-}
diff --git a/apps/web/src/components/hide-everything-scene.tsx b/apps/web/src/components/hide-everything-scene.tsx
deleted file mode 100644
index 8a233398..00000000
--- a/apps/web/src/components/hide-everything-scene.tsx
+++ /dev/null
@@ -1,126 +0,0 @@
-import { PixelBird } from "./pixel-bird";
-import { PixelCloud } from "./pixel-cloud";
-import { PixelCouchSeat } from "./pixel-couch-seat";
-import { PixelGround } from "./pixel-ground";
-import { PixelMountain } from "./pixel-mountain";
-import { PixelTvStand } from "./pixel-tv-stand";
-import { PixelWaterfall } from "./pixel-waterfall";
-
-const FAR_PALETTE = {
- snow: "#f5fbff",
- light: "#6faec0",
- mid: "#3f8798",
- dark: "#22535d",
- accent: "#aab3ae",
-};
-
-const MAIN_PALETTE = {
- snow: "#ffffff",
- light: "#3f8da0",
- mid: "#2c7180",
- dark: "#1d4648",
- accent: "#a8b1ac",
-};
-
-const FRONT_PALETTE = {
- snow: "#ffffff",
- light: "#2f7788",
- mid: "#1f5c68",
- dark: "#153e42",
- accent: "#0f3334",
-};
-
-function CloudBand() {
- return (
-
- );
-}
-
-export function HideEverythingScene() {
- return (
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- );
-}
diff --git a/apps/web/src/components/history-calendar-header.tsx b/apps/web/src/components/history-calendar-header.tsx
deleted file mode 100644
index 67ce22f0..00000000
--- a/apps/web/src/components/history-calendar-header.tsx
+++ /dev/null
@@ -1,148 +0,0 @@
-import { useState } from "react";
-import { ChevronLeft, ChevronRight } from "./history-calendar-icons";
-
-const MONTH_NAMES = [
- "January",
- "February",
- "March",
- "April",
- "May",
- "June",
- "July",
- "August",
- "September",
- "October",
- "November",
- "December",
-];
-
-const CURRENT_YEAR = new Date().getFullYear();
-const YEARS = Array.from({ length: CURRENT_YEAR - 1999 }, (_, i) => CURRENT_YEAR - i);
-
-type Props = {
- month: number;
- year: number;
- canGoNext: boolean;
- onPrevMonth: () => void;
- onNextMonth: () => void;
- onMonthChange: (month: number) => void;
- onYearChange: (year: number) => void;
-};
-
-export function CalendarHeader({
- month,
- year,
- canGoNext,
- onPrevMonth,
- onNextMonth,
- onMonthChange,
- onYearChange,
-}: Props) {
- const [dropdown, setDropdown] = useState<"month" | "year" | null>(null);
-
- const toggle = (which: "month" | "year") => setDropdown(dropdown === which ? null : which);
-
- const close = () => setDropdown(null);
-
- const handleMonthSelect = (m: number) => {
- onMonthChange(m);
- close();
- };
- const handleYearSelect = (y: number) => {
- onYearChange(y);
- close();
- };
-
- return (
-
- {dropdown && (
-
- )}
-
-
-
-
-
-
-
-
toggle("month")}
- className="text-xs font-medium text-fg-muted hover:text-fg px-1 py-0.5 rounded transition-colors"
- >
- {MONTH_NAMES[month]}
-
- {dropdown === "month" && (
-
- {MONTH_NAMES.map((name, m) => (
- handleMonthSelect(m)}
- className={`text-[11px] px-1 py-1.5 rounded transition-colors ${
- m === month
- ? "bg-fg text-app font-medium"
- : "text-fg-muted hover:text-fg hover:bg-surface-strong"
- }`}
- >
- {name.slice(0, 3)}
-
- ))}
-
- )}
-
-
-
-
toggle("year")}
- className="text-xs font-medium text-fg-muted hover:text-fg px-1 py-0.5 rounded transition-colors"
- >
- {year}
-
- {dropdown === "year" && (
-
- {YEARS.map((y) => (
- handleYearSelect(y)}
- className={`w-full text-[11px] px-2 py-1 rounded transition-colors text-left ${
- y === year
- ? "bg-fg text-app font-medium"
- : "text-fg-muted hover:text-fg hover:bg-surface-strong"
- }`}
- >
- {y}
-
- ))}
-
- )}
-
-
-
-
-
-
-
- );
-}
diff --git a/apps/web/src/components/history-calendar-icons.tsx b/apps/web/src/components/history-calendar-icons.tsx
deleted file mode 100644
index c5f2fe44..00000000
--- a/apps/web/src/components/history-calendar-icons.tsx
+++ /dev/null
@@ -1,37 +0,0 @@
-export function ChevronLeft() {
- return (
-
-
-
- );
-}
-
-export function ChevronRight() {
- return (
-
-
-
- );
-}
diff --git a/apps/web/src/components/history-calendar.tsx b/apps/web/src/components/history-calendar.tsx
deleted file mode 100644
index 5355b752..00000000
--- a/apps/web/src/components/history-calendar.tsx
+++ /dev/null
@@ -1,111 +0,0 @@
-import { useState } from "react";
-import { CalendarHeader } from "./history-calendar-header";
-
-type Props = {
- selected: Date | null;
- onSelect: (date: Date) => void;
-};
-
-const DAY_LABELS = ["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"];
-
-function startOfDay(date: Date): Date {
- return new Date(date.getFullYear(), date.getMonth(), date.getDate());
-}
-
-function isSameDay(a: Date, b: Date): boolean {
- return (
- a.getFullYear() === b.getFullYear() &&
- a.getMonth() === b.getMonth() &&
- a.getDate() === b.getDate()
- );
-}
-
-export function HistoryCalendar({ selected, onSelect }: Props) {
- const today = startOfDay(new Date());
-
- const initialMonth = selected
- ? new Date(selected.getFullYear(), selected.getMonth(), 1)
- : new Date(today.getFullYear(), today.getMonth(), 1);
-
- const [viewMonth, setViewMonth] = useState(initialMonth);
-
- const year = viewMonth.getFullYear();
- const month = viewMonth.getMonth();
-
- const firstDayOffset = new Date(year, month, 1).getDay();
- const daysInMonth = new Date(year, month + 1, 0).getDate();
-
- const canGoNext =
- new Date(year, month + 1, 1) <= new Date(today.getFullYear(), today.getMonth(), 1);
-
- const prevMonth = () => setViewMonth(new Date(year, month - 1, 1));
- const nextMonth = () => {
- if (canGoNext) setViewMonth(new Date(year, month + 1, 1));
- };
-
- const handleMonthChange = (m: number) => {
- setViewMonth(new Date(year, m, 1));
- };
-
- const handleYearChange = (y: number) => {
- const clampedMonth =
- y === today.getFullYear() && month > today.getMonth() ? today.getMonth() : month;
- setViewMonth(new Date(y, clampedMonth, 1));
- };
-
- const emptySlots = Array.from({ length: firstDayOffset }, (_, i) => `slot-${i}`);
- const days = Array.from({ length: daysInMonth }, (_, i) => i + 1);
-
- return (
-
-
-
-
- {DAY_LABELS.map((d) => (
-
- {d}
-
- ))}
-
- {emptySlots.map((key) => (
-
- ))}
-
- {days.map((day) => {
- const cellDate = new Date(year, month, day);
- const isFuture = cellDate > today;
- const isSelected = selected !== null && isSameDay(cellDate, selected);
- const isToday = isSameDay(cellDate, today);
-
- return (
-
onSelect(cellDate)}
- className={`h-7 w-full rounded text-xs transition-colors ${
- isSelected
- ? "bg-fg text-app font-medium"
- : isToday
- ? "text-fg ring-1 ring-border-strong hover:bg-surface-strong"
- : isFuture
- ? "text-fg-soft cursor-not-allowed"
- : "text-fg-muted hover:text-fg hover:bg-surface-strong"
- }`}
- >
- {day}
-
- );
- })}
-
-
- );
-}
diff --git a/apps/web/src/components/history-card.tsx b/apps/web/src/components/history-card.tsx
deleted file mode 100644
index ee085149..00000000
--- a/apps/web/src/components/history-card.tsx
+++ /dev/null
@@ -1,128 +0,0 @@
-import { Link } from "@tanstack/react-router";
-import { useDeArrowBranding } from "../hooks/use-dearrow";
-import { formatDuration } from "../lib/format";
-import { proxyImage } from "../lib/proxy";
-import { isVideoWatched } from "../lib/watch-progress";
-import { watchRouteSearch } from "../lib/watch-url";
-import type { HistoryItem } from "../types/user";
-import { ChannelRouteLink } from "./channel-route-link";
-import { HistoryChannelAvatar } from "./history-channel-avatar";
-import { VideoProgressBar } from "./video-progress-bar";
-import { WatchedBadge } from "./watched-badge";
-
-function XIcon() {
- return (
-
-
-
-
- );
-}
-
-type HistoryCardProps = { item: HistoryItem; onRemove: () => void; index: number };
-
-function formatWatchedAt(timestamp: number): string {
- return new Date(timestamp).toLocaleString(undefined, {
- year: "numeric",
- month: "short",
- day: "numeric",
- hour: "2-digit",
- minute: "2-digit",
- });
-}
-
-export function HistoryCard({ item, onRemove, index }: HistoryCardProps) {
- const delay = Math.min(index * 45, 270);
- const watched = isVideoWatched(item.progress, item.duration);
- const branding = useDeArrowBranding(
- item.url,
- item.title,
- proxyImage(item.thumbnail),
- item.duration,
- );
-
- return (
-
-
-
-
- {item.duration > 0 && (
-
- {formatDuration(item.duration)}
-
- )}
- {watched && (
-
-
-
- )}
-
-
{
- e.preventDefault();
- e.stopPropagation();
- onRemove();
- }}
- aria-label="Remove from history"
- className="absolute top-1.5 right-1.5 rounded-full bg-black/70 p-1.5 text-white opacity-100 transition-opacity hover:bg-black/90 sm:p-1 sm:opacity-0 sm:group-hover:opacity-100 sm:focus-visible:opacity-100"
- >
-
-
-
-
-
- {item.channelUrl ? (
-
-
-
- ) : (
-
-
-
- )}
-
-
-
- {branding.title}
-
-
- {item.channelUrl ? (
-
- {item.channelName}
-
- ) : (
-
{item.channelName}
- )}
-
- Watched {formatWatchedAt(item.watchedAt)}
-
-
-
-
- );
-}
diff --git a/apps/web/src/components/history-channel-avatar.tsx b/apps/web/src/components/history-channel-avatar.tsx
deleted file mode 100644
index 4ac35dab..00000000
--- a/apps/web/src/components/history-channel-avatar.tsx
+++ /dev/null
@@ -1,18 +0,0 @@
-import { proxyImage } from "../lib/proxy";
-import type { HistoryItem } from "../types/user";
-import { ChannelAvatar } from "./channel-avatar";
-
-type HistoryChannelAvatarProps = {
- item: HistoryItem;
- className: string;
-};
-
-export function HistoryChannelAvatar({ item, className }: HistoryChannelAvatarProps) {
- return (
-
- );
-}
diff --git a/apps/web/src/components/history-filter.tsx b/apps/web/src/components/history-filter.tsx
deleted file mode 100644
index 6109c590..00000000
--- a/apps/web/src/components/history-filter.tsx
+++ /dev/null
@@ -1,169 +0,0 @@
-import { useState } from "react";
-import { HistoryCalendar } from "./history-calendar";
-
-export type FilterState =
- | { kind: "preset"; value: "today" | "week" | "month" }
- | { kind: "date"; date: Date };
-
-type Props = {
- searchQuery: string;
- onSearchChange: (value: string) => void;
- filter: FilterState | null;
- onFilterChange: (value: FilterState | null) => void;
- resultCount: number;
- canClearHistory: boolean;
- onClearHistory: () => void;
-};
-
-const PRESET_OPTIONS = [
- { label: "Today", value: "today" as const },
- { label: "This week", value: "week" as const },
- { label: "This month", value: "month" as const },
-];
-
-function SearchIcon() {
- return (
-
-
-
-
- );
-}
-
-function formatDate(date: Date): string {
- return date.toLocaleDateString(undefined, { month: "short", day: "numeric" });
-}
-
-export function HistoryFilter({
- searchQuery,
- onSearchChange,
- filter,
- onFilterChange,
- resultCount,
- canClearHistory,
- onClearHistory,
-}: Props) {
- const [calendarOpen, setCalendarOpen] = useState(false);
-
- const hasActiveFilter = searchQuery.length > 0 || filter !== null;
-
- const isPresetActive = (value: string) => filter?.kind === "preset" && filter.value === value;
-
- const selectedDate = filter?.kind === "date" ? filter.date : null;
-
- const olderActive = filter?.kind === "date" || calendarOpen;
-
- const handlePreset = (value: "today" | "week" | "month") => {
- setCalendarOpen(false);
- onFilterChange(isPresetActive(value) ? null : { kind: "preset", value });
- };
-
- const handleOlderToggle = () => {
- if (calendarOpen) {
- setCalendarOpen(false);
- if (filter?.kind === "date") onFilterChange(null);
- } else {
- setCalendarOpen(true);
- onFilterChange(null);
- }
- };
-
- const handleDateSelect = (date: Date) => {
- onFilterChange({ kind: "date", date });
- };
-
- const handleClear = () => {
- onSearchChange("");
- onFilterChange(null);
- setCalendarOpen(false);
- };
-
- return (
-
-
-
-
- {resultCount} {resultCount === 1 ? "video" : "videos"}
-
- {canClearHistory && (
-
- Clear all
-
- )}
-
-
-
-
-
- onSearchChange(e.target.value)}
- placeholder="Search history..."
- className="w-full h-9 bg-surface border border-border rounded-lg pl-8 pr-3 text-xs text-fg placeholder-zinc-600 focus:outline-none focus:border-border-strong transition-colors"
- />
-
-
-
-
-
Date
-
- {PRESET_OPTIONS.map((opt) => (
- handlePreset(opt.value)}
- className={`h-8 rounded-lg px-2.5 text-left text-xs transition-colors sm:text-center lg:text-left ${
- isPresetActive(opt.value)
- ? "bg-fg text-app font-medium"
- : "text-fg-muted hover:text-fg hover:bg-surface-strong"
- }`}
- >
- {opt.label}
-
- ))}
-
-
- {selectedDate ? formatDate(selectedDate) : "Older"}
-
-
- {calendarOpen && }
-
-
-
- {hasActiveFilter && (
-
- Clear filters
-
- )}
-
- );
-}
diff --git a/apps/web/src/components/home-fallback-section.tsx b/apps/web/src/components/home-fallback-section.tsx
deleted file mode 100644
index cbc153bc..00000000
--- a/apps/web/src/components/home-fallback-section.tsx
+++ /dev/null
@@ -1,37 +0,0 @@
-import { useMemo } from "react";
-import { useBlockedFilter } from "../hooks/use-blocked-filter";
-import { useSubscriptionFeed } from "../hooks/use-subscription-feed";
-import { useSubscriptions } from "../hooks/use-subscriptions";
-import { ScrollSentinel } from "./scroll-sentinel";
-import { VideoGrid } from "./video-grid";
-import { VideoGridSkeleton } from "./video-grid-skeleton";
-
-function FeedSection() {
- const { streams, isLoading, hasNextPage, isFetchingNextPage, fetchNextPage } =
- useSubscriptionFeed();
- const { filter } = useBlockedFilter();
- const filtered = useMemo(() => filter(streams), [filter, streams]);
- if (isLoading) return ;
- return (
- <>
-
- {isFetchingNextPage && }
-
- >
- );
-}
-
-export function HomeFallbackSection() {
- const { query } = useSubscriptions();
- const hasSubs = (query.data ?? []).length > 0;
- if (query.isLoading) return ;
- if (hasSubs) return ;
- return (
-
- No subscriptions yet
-
- Subscribe to channels to unlock a personalized home feed.
-
-
- );
-}
diff --git a/apps/web/src/components/home-recommendations-section.tsx b/apps/web/src/components/home-recommendations-section.tsx
deleted file mode 100644
index 7554c90d..00000000
--- a/apps/web/src/components/home-recommendations-section.tsx
+++ /dev/null
@@ -1,32 +0,0 @@
-import { useMemo } from "react";
-import { useBlockedFilter } from "../hooks/use-blocked-filter";
-import { useHomeRecommendations } from "../hooks/use-home-recommendations";
-import { useSettings } from "../hooks/use-settings";
-import { FamilyListEmptyState } from "./family-list-empty-state";
-import { HomeFallbackSection } from "./home-fallback-section";
-import { ScrollSentinel } from "./scroll-sentinel";
-import { VideoGrid } from "./video-grid";
-import { VideoGridSkeleton } from "./video-grid-skeleton";
-
-export function HomeRecommendationsSection() {
- const { streams, isLoading, isError, hasNextPage, isFetchingNextPage, fetchNextPage } =
- useHomeRecommendations();
- const { settings } = useSettings();
- const { filter } = useBlockedFilter();
- const filtered = useMemo(() => filter(streams), [filter, streams]);
-
- if (isLoading) return ;
- if (isError || filtered.length === 0) {
- if (settings.accessMode === "allow_list") {
- return ;
- }
- return ;
- }
- return (
- <>
-
- {isFetchingNextPage && }
-
- >
- );
-}
diff --git a/apps/web/src/components/import-mascot-loop.tsx b/apps/web/src/components/import-mascot-loop.tsx
deleted file mode 100644
index 1154b107..00000000
--- a/apps/web/src/components/import-mascot-loop.tsx
+++ /dev/null
@@ -1,26 +0,0 @@
-import { useEffect, useState } from "react";
-
-type Props = {
- primarySrc: string;
- secondarySrc: string;
- className: string;
- intervalMs?: number;
-};
-
-export function ImportMascotLoop({
- primarySrc,
- secondarySrc,
- className,
- intervalMs = 3600,
-}: Props) {
- const [showPrimary, setShowPrimary] = useState(true);
-
- useEffect(() => {
- const timer = window.setInterval(() => setShowPrimary((v) => !v), intervalMs);
- return () => window.clearInterval(timer);
- }, [intervalMs]);
-
- const src = showPrimary ? primarySrc : secondarySrc;
-
- return ;
-}
diff --git a/apps/web/src/components/import-route-heading.tsx b/apps/web/src/components/import-route-heading.tsx
deleted file mode 100644
index c295fe81..00000000
--- a/apps/web/src/components/import-route-heading.tsx
+++ /dev/null
@@ -1,20 +0,0 @@
-type Props = {
- label: string;
- title: string;
- description: string;
- labelClassName?: string;
-};
-
-export function ImportRouteHeading({ label, title, description, labelClassName }: Props) {
- return (
-
-
- {label}
-
-
{title}
-
{description}
-
- );
-}
diff --git a/apps/web/src/components/library-collection-card.tsx b/apps/web/src/components/library-collection-card.tsx
deleted file mode 100644
index 33bcf87d..00000000
--- a/apps/web/src/components/library-collection-card.tsx
+++ /dev/null
@@ -1,71 +0,0 @@
-import { Link } from "@tanstack/react-router";
-import { useState } from "react";
-
-type Props = {
- kind: "favorites" | "watch-later";
- title: string;
- count: number;
- thumbnail?: string;
-};
-
-function EmptyLibraryIcon() {
- return (
-
-
-
- );
-}
-
-export function LibraryCollectionCard({ kind, title, count, thumbnail }: Props) {
- const [failedThumbnail, setFailedThumbnail] = useState(null);
- const showThumbnail = Boolean(thumbnail) && failedThumbnail !== thumbnail;
- const label = `${count} video${count !== 1 ? "s" : ""}`;
- const body = (
-
-
- {showThumbnail ? (
-
setFailedThumbnail(thumbnail ?? null)}
- />
- ) : (
-
-
-
- )}
-
- {label}
-
-
-
-
- {title}
-
-
{label}
-
-
- );
-
- return kind === "favorites" ? (
- {body}
- ) : (
- {body}
- );
-}
diff --git a/apps/web/src/components/media-progress-events.tsx b/apps/web/src/components/media-progress-events.tsx
deleted file mode 100644
index a96d7ffb..00000000
--- a/apps/web/src/components/media-progress-events.tsx
+++ /dev/null
@@ -1,143 +0,0 @@
-import { useEffect, useRef } from "react";
-import { recordClientEvent } from "../lib/client-debug-log";
-import { useMediaPlayer } from "../lib/vidstack";
-
-type Props = {
- suppressPlaybackEvents?: boolean;
- onTimeUpdate?: (positionMs: number) => void;
- onPlay?: () => void;
- onPause?: () => void;
- onSeeking?: (positionMs: number) => void;
- onSeeked?: () => void;
- onEnded?: () => void;
- onPositionReaderChange?: (reader: (() => number | null) | null) => void;
-};
-
-function toPositionMs(media: HTMLMediaElement): number {
- return Math.max(0, Math.round(media.currentTime * 1000));
-}
-
-export function MediaProgressEvents({
- suppressPlaybackEvents = false,
- onTimeUpdate,
- onPlay,
- onPause,
- onSeeking,
- onSeeked,
- onEnded,
- onPositionReaderChange,
-}: Props) {
- const player = useMediaPlayer();
- const suppressPlaybackEventsRef = useRef(suppressPlaybackEvents);
- const onTimeUpdateRef = useRef(onTimeUpdate);
- const onPlayRef = useRef(onPlay);
- const onPauseRef = useRef(onPause);
- const onSeekingRef = useRef(onSeeking);
- const onSeekedRef = useRef(onSeeked);
- const onEndedRef = useRef(onEnded);
- const onPositionReaderChangeRef = useRef(onPositionReaderChange);
-
- suppressPlaybackEventsRef.current = suppressPlaybackEvents;
- onTimeUpdateRef.current = onTimeUpdate;
- onPlayRef.current = onPlay;
- onPauseRef.current = onPause;
- onSeekingRef.current = onSeeking;
- onSeekedRef.current = onSeeked;
- onEndedRef.current = onEnded;
- onPositionReaderChangeRef.current = onPositionReaderChange;
-
- useEffect(() => {
- const root = player?.el;
- if (!root) return;
- const rootElement = root;
- let cleanup: (() => void) | null = null;
-
- function attach() {
- if (cleanup) return true;
- const media = rootElement.querySelector("video,audio");
- if (!media) return false;
-
- const update = () => onTimeUpdateRef.current?.(toPositionMs(media));
- const readPosition = () => toPositionMs(media);
- const play = () => {
- update();
- if (suppressPlaybackEventsRef.current) return;
- onPlayRef.current?.();
- };
- const pause = () => {
- update();
- if (suppressPlaybackEventsRef.current) return;
- onPauseRef.current?.();
- };
- const seeked = () => {
- update();
- onSeekedRef.current?.();
- };
- const seeking = () => {
- update();
- onSeekingRef.current?.(toPositionMs(media));
- };
- const ended = () => {
- update();
- recordClientEvent("media.ended", {
- currentMs: toPositionMs(media),
- durationMs: Number.isFinite(media.duration) ? Math.round(media.duration * 1000) : null,
- tag: media.tagName,
- });
- if (media.loop) return;
- onEndedRef.current?.();
- };
- const stalled = () => {
- recordClientEvent("media.stalled", {
- currentMs: toPositionMs(media),
- readyState: media.readyState,
- networkState: media.networkState,
- tag: media.tagName,
- });
- };
- const error = () => {
- recordClientEvent("media.error", {
- currentMs: toPositionMs(media),
- code: media.error?.code ?? null,
- readyState: media.readyState,
- networkState: media.networkState,
- tag: media.tagName,
- });
- };
-
- media.addEventListener("timeupdate", update);
- media.addEventListener("play", play);
- media.addEventListener("pause", pause);
- media.addEventListener("seeking", seeking);
- media.addEventListener("seeked", seeked);
- media.addEventListener("ended", ended);
- media.addEventListener("stalled", stalled);
- media.addEventListener("error", error);
- onPositionReaderChangeRef.current?.(readPosition);
- cleanup = () => {
- onPositionReaderChangeRef.current?.(null);
- media.removeEventListener("timeupdate", update);
- media.removeEventListener("play", play);
- media.removeEventListener("pause", pause);
- media.removeEventListener("seeking", seeking);
- media.removeEventListener("seeked", seeked);
- media.removeEventListener("ended", ended);
- media.removeEventListener("stalled", stalled);
- media.removeEventListener("error", error);
- };
- return true;
- }
-
- if (attach()) return () => cleanup?.();
- const observer = new MutationObserver(() => {
- if (attach()) observer.disconnect();
- });
- observer.observe(rootElement, { childList: true, subtree: true });
- return () => {
- observer.disconnect();
- cleanup?.();
- };
- }, [player]);
-
- return null;
-}
diff --git a/apps/web/src/components/media-session-position-sync.tsx b/apps/web/src/components/media-session-position-sync.tsx
deleted file mode 100644
index 0b967632..00000000
--- a/apps/web/src/components/media-session-position-sync.tsx
+++ /dev/null
@@ -1,66 +0,0 @@
-import { useEffect } from "react";
-import { useMediaPlayer } from "../lib/vidstack";
-
-type Props = {
- currentTimeRef: { current: number };
- durationRef: { current: number };
-};
-
-export function MediaSessionPositionSync({ currentTimeRef, durationRef }: Props) {
- const player = useMediaPlayer();
-
- useEffect(() => {
- const rootElement = player?.el;
- if (rootElement == null) return;
- const mediaRoot = rootElement;
- let cleanup: (() => void) | null = null;
-
- function syncPosition(media: HTMLMediaElement) {
- const duration = Number.isFinite(media.duration) ? media.duration : 0;
- const currentTime = Number.isFinite(media.currentTime) ? media.currentTime : 0;
- const playbackRate =
- Number.isFinite(media.playbackRate) && media.playbackRate > 0 ? media.playbackRate : 1;
- currentTimeRef.current = currentTime;
- durationRef.current = duration;
- if (typeof navigator === "undefined" || !("mediaSession" in navigator)) return;
- const setPositionState = navigator.mediaSession.setPositionState?.bind(
- navigator.mediaSession,
- );
- if (!setPositionState || duration <= 0 || currentTime < 0) return;
- try {
- setPositionState({ duration, playbackRate, position: Math.min(duration, currentTime) });
- } catch {}
- }
-
- function attach() {
- if (cleanup) return true;
- const media = mediaRoot.querySelector("video,audio");
- if (!media) return false;
- const sync = () => syncPosition(media);
- media.addEventListener("timeupdate", sync);
- media.addEventListener("durationchange", sync);
- media.addEventListener("ratechange", sync);
- media.addEventListener("loadedmetadata", sync);
- sync();
- cleanup = () => {
- media.removeEventListener("timeupdate", sync);
- media.removeEventListener("durationchange", sync);
- media.removeEventListener("ratechange", sync);
- media.removeEventListener("loadedmetadata", sync);
- };
- return true;
- }
-
- if (attach()) return () => cleanup?.();
- const observer = new MutationObserver(() => {
- if (attach()) observer.disconnect();
- });
- observer.observe(mediaRoot, { childList: true, subtree: true });
- return () => {
- observer.disconnect();
- cleanup?.();
- };
- }, [currentTimeRef, durationRef, player]);
-
- return null;
-}
diff --git a/apps/web/src/components/media-session-sync.tsx b/apps/web/src/components/media-session-sync.tsx
deleted file mode 100644
index e3489d4d..00000000
--- a/apps/web/src/components/media-session-sync.tsx
+++ /dev/null
@@ -1,105 +0,0 @@
-import { useEffect, useRef } from "react";
-import { useMediaRemote, useMediaState } from "../lib/vidstack";
-import { MediaSessionPositionSync } from "./media-session-position-sync";
-
-type Props = {
- title?: string;
- artist?: string;
- artwork?: string;
- canSeek?: boolean;
- isLive?: boolean;
- onPreviousTrack?: () => void;
- onNextTrack?: () => void;
-};
-
-function safeSetActionHandler(
- session: MediaSession,
- action: MediaSessionAction,
- handler: MediaSessionActionHandler | null,
-) {
- try {
- session.setActionHandler(action, handler);
- } catch {}
-}
-
-export function MediaSessionSync({
- title,
- artist,
- artwork,
- canSeek = true,
- isLive = false,
- onPreviousTrack,
- onNextTrack,
-}: Props) {
- const remote = useMediaRemote();
- const paused = useMediaState("paused");
- const currentTimeRef = useRef(0);
- const durationRef = useRef(0);
-
- useEffect(() => {
- if (typeof navigator === "undefined" || !("mediaSession" in navigator)) return;
- if (typeof MediaMetadata === "undefined") return;
- const safeTitle = title?.trim();
- if (!safeTitle) return;
- const safeArtist = artist?.trim();
- const safeArtwork = artwork?.trim();
- navigator.mediaSession.metadata = new MediaMetadata({
- title: safeTitle,
- artist: safeArtist,
- artwork: safeArtwork
- ? [{ src: safeArtwork, sizes: "512x512", type: "image/png" }]
- : undefined,
- });
- }, [title, artist, artwork]);
-
- useEffect(() => {
- if (typeof navigator === "undefined" || !("mediaSession" in navigator)) return;
- const session = navigator.mediaSession;
- safeSetActionHandler(session, "play", () => {
- void Promise.resolve(remote.play()).catch(() => {});
- });
- safeSetActionHandler(session, "pause", () => {
- void Promise.resolve(remote.pause()).catch(() => {});
- });
- if (canSeek) {
- safeSetActionHandler(session, "seekbackward", (details) => {
- const step = details.seekOffset ?? 10;
- const target = Math.max(0, currentTimeRef.current - step);
- remote.seek(target);
- });
- safeSetActionHandler(session, "seekforward", (details) => {
- const step = details.seekOffset ?? 10;
- const max = durationRef.current > 0 ? durationRef.current : Number.POSITIVE_INFINITY;
- const target = Math.min(max, currentTimeRef.current + step);
- remote.seek(target);
- });
- safeSetActionHandler(session, "seekto", (details) => {
- const next = details.seekTime;
- if (typeof next !== "number" || Number.isNaN(next)) return;
- remote.seek(next);
- });
- }
- safeSetActionHandler(session, "stop", () => {
- void Promise.resolve(remote.pause()).catch(() => {});
- });
- safeSetActionHandler(session, "previoustrack", isLive ? null : (onPreviousTrack ?? null));
- safeSetActionHandler(session, "nexttrack", isLive ? null : (onNextTrack ?? null));
- return () => {
- safeSetActionHandler(session, "play", null);
- safeSetActionHandler(session, "pause", null);
- safeSetActionHandler(session, "seekbackward", null);
- safeSetActionHandler(session, "seekforward", null);
- safeSetActionHandler(session, "seekto", null);
- safeSetActionHandler(session, "stop", null);
- safeSetActionHandler(session, "previoustrack", null);
- safeSetActionHandler(session, "nexttrack", null);
- };
- }, [canSeek, isLive, onPreviousTrack, onNextTrack, remote]);
-
- useEffect(() => {
- if (typeof navigator === "undefined" || !("mediaSession" in navigator)) return;
- navigator.mediaSession.playbackState = paused ? "paused" : "playing";
- }, [paused]);
-
- return ;
-}
diff --git a/apps/web/src/components/mobile-tab-bar.tsx b/apps/web/src/components/mobile-tab-bar.tsx
deleted file mode 100644
index 6e544b3d..00000000
--- a/apps/web/src/components/mobile-tab-bar.tsx
+++ /dev/null
@@ -1,68 +0,0 @@
-import { Link } from "@tanstack/react-router";
-import { Menu } from "lucide-react";
-import { useUiStore } from "../stores/ui-store";
-import { NAV_ITEMS } from "./nav-items";
-
-const BOTTOM_NAV_PATHS = ["/", "/subscriptions", "/history", "/playlists"];
-const ITEM =
- "flex h-14 flex-1 flex-col items-center justify-center gap-0.5 text-[10px] font-medium transition-colors";
-const ACTIVE = "text-fg";
-const INACTIVE = "text-fg-muted";
-
-function TabIcon({ children, label }: { children: React.ReactNode; label: string }) {
- return (
-
- {children}
-
- );
-}
-
-export function MobileTabBar() {
- const openMobileSidebar = useUiStore((s) => s.openMobileSidebar);
- const mobileOpen = useUiStore((s) => s.mobileSidebarOpen);
- const items = NAV_ITEMS.filter((item) => BOTTOM_NAV_PATHS.includes(item.to));
-
- return (
-
- {items.map((item) => (
-
- {item.icon}
- {item.label}
-
- ))}
-
-
- More
-
-
- );
-}
diff --git a/apps/web/src/components/nav-items.tsx b/apps/web/src/components/nav-items.tsx
deleted file mode 100644
index e7634fba..00000000
--- a/apps/web/src/components/nav-items.tsx
+++ /dev/null
@@ -1,112 +0,0 @@
-type NavItem = {
- label: string;
- to: string;
- icon: React.ReactNode;
- adminOnly?: boolean;
-};
-
-export const NAV_ITEMS: NavItem[] = [
- {
- label: "Home",
- to: "/",
- icon: ,
- },
- {
- label: "Shorts",
- to: "/shorts",
- icon: (
- <>
-
-
-
-
-
- >
- ),
- },
- {
- label: "Subscriptions",
- to: "/subscriptions",
- icon: (
- <>
-
-
- >
- ),
- },
- {
- label: "History",
- to: "/history",
- icon: (
- <>
-
-
- >
- ),
- },
- {
- label: "Playlists",
- to: "/playlists",
- icon: (
- <>
-
-
-
-
- >
- ),
- },
- {
- label: "Import",
- to: "/import",
- icon: (
- <>
-
-
-
- >
- ),
- },
- {
- label: "Settings",
- to: "/settings",
- icon: (
- <>
-
-
- >
- ),
- },
- {
- label: "Login",
- to: "/youtube-session",
- icon: (
- <>
-
-
-
- >
- ),
- },
- {
- label: "Admin",
- to: "/admin-console",
- adminOnly: true,
- icon: (
- <>
-
-
- >
- ),
- },
- {
- label: "Privacy",
- to: "/privacy",
- icon: (
- <>
-
-
- >
- ),
- },
-];
diff --git a/apps/web/src/components/navbar-account-controls.tsx b/apps/web/src/components/navbar-account-controls.tsx
deleted file mode 100644
index e705ac05..00000000
--- a/apps/web/src/components/navbar-account-controls.tsx
+++ /dev/null
@@ -1,171 +0,0 @@
-import { Link } from "@tanstack/react-router";
-import { getStoredAdminSection } from "../lib/admin-console-section";
-import { logoutSession } from "../lib/auth-session";
-import { goto } from "../lib/route-redirect";
-import { getStoredSettingsSection } from "../lib/settings-section";
-import type { AuthMe, AuthStatus } from "../types/auth";
-import { ProfileAvatar } from "./profile-avatar";
-import { ThemeToggleButton } from "./theme-toggle-button";
-
-type Props = {
- status: AuthStatus;
- isAuthed: boolean;
- isGuest: boolean;
- isAdmin: boolean;
- me: AuthMe | null;
- isMobile: boolean;
- signOut: () => void;
-};
-
-function loginHref() {
- const path = window.location.pathname;
- const search = window.location.search;
- const redirect = `${path}${search}`;
- return `/login?redirect=${encodeURIComponent(redirect)}`;
-}
-
-function statusLabel(status: AuthStatus): string {
- if (status === "guest") return "Guest";
- if (status === "authenticated") return "Connected";
- if (status === "loading") return "Loading";
- return "Signed out";
-}
-
-export function NavbarAccountControls({
- status,
- isAuthed,
- isGuest,
- isAdmin,
- me,
- isMobile,
- signOut,
-}: Props) {
- const profileName = me?.publicUsername?.trim() ? me.publicUsername : null;
-
- async function handleSignOut() {
- await logoutSession();
- signOut();
- }
-
- if (isMobile) {
- if (!isAuthed || isGuest || !me) {
- return (
-
- );
- }
- return (
-
- );
- }
-
- return (
- <>
- {!isAuthed || isGuest || !me ? (
-
- {statusLabel(status)}
-
- ) : (
-
-
-
-
-
- {profileName && (
-
- {profileName}
-
- )}
-
- )}
- {!isAuthed && (
-
-
-
- Sign in
-
-
goto("/")}
- className="hidden sm:inline-flex h-8 items-center justify-center px-3 text-xs rounded-full bg-surface hover:bg-surface-strong text-fg-muted"
- >
- Browse
-
-
- )}
- {isAuthed && (
-
- {(isGuest || !me) &&
}
- {!isGuest && me && (
-
-
-
- )}
- {isGuest && (
- <>
-
- Login
-
-
- Register
-
- >
- )}
- {!isGuest && !isAdmin && (
-
- Account
-
- )}
- {isAdmin && (
-
- Admin
-
- )}
-
void handleSignOut()}
- className="h-8 px-3 text-xs rounded-full bg-surface-strong hover:bg-surface-soft text-fg"
- >
- Sign out
-
-
- )}
- >
- );
-}
diff --git a/apps/web/src/components/navbar-leading-control.tsx b/apps/web/src/components/navbar-leading-control.tsx
deleted file mode 100644
index 659ae77b..00000000
--- a/apps/web/src/components/navbar-leading-control.tsx
+++ /dev/null
@@ -1,36 +0,0 @@
-import { ChevronLeft, Menu } from "lucide-react";
-
-type Props = {
- authPage: boolean;
- showBackButton: boolean;
- onBack: () => void;
- onToggleSidebar: () => void;
-};
-
-export function NavbarLeadingControl({ authPage, showBackButton, onBack, onToggleSidebar }: Props) {
- if (authPage) return null;
-
- if (showBackButton) {
- return (
-
-
-
- );
- }
-
- return (
-
-
-
- );
-}
diff --git a/apps/web/src/components/navbar-notifications.tsx b/apps/web/src/components/navbar-notifications.tsx
deleted file mode 100644
index 41dde425..00000000
--- a/apps/web/src/components/navbar-notifications.tsx
+++ /dev/null
@@ -1,15 +0,0 @@
-import { lazy, Suspense } from "react";
-
-const NotificationsDropdown = lazy(() =>
- import("./notifications-dropdown").then((module) => ({
- default: module.NotificationsDropdown,
- })),
-);
-
-export function NavbarNotifications() {
- return (
-
-
-
- );
-}
diff --git a/apps/web/src/components/navbar-playback-mode.tsx b/apps/web/src/components/navbar-playback-mode.tsx
deleted file mode 100644
index 9516748d..00000000
--- a/apps/web/src/components/navbar-playback-mode.tsx
+++ /dev/null
@@ -1,26 +0,0 @@
-import { AudioWaveform } from "lucide-react";
-import { usePlaybackMode } from "../hooks/use-playback-mode";
-
-export function NavbarPlaybackMode() {
- const { playbackMode, setMode } = usePlaybackMode();
- const sabrEnabled = playbackMode === "sabr";
- const label = sabrEnabled ? "Use classic playback" : "Use SABR playback";
-
- return (
- setMode(sabrEnabled ? "legacy" : "sabr")}
- className={`inline-flex h-9 items-center justify-center gap-1.5 rounded-md border px-2 text-xs font-medium transition-colors ${
- sabrEnabled
- ? "border-accent/40 bg-accent/10 text-accent hover:bg-accent/15"
- : "border-border-strong bg-surface-strong text-fg-muted hover:bg-surface-soft hover:text-fg"
- }`}
- >
-
- {sabrEnabled ? "SABR" : "Classic"}
-
- );
-}
diff --git a/apps/web/src/components/navbar-search.tsx b/apps/web/src/components/navbar-search.tsx
deleted file mode 100644
index 75363b38..00000000
--- a/apps/web/src/components/navbar-search.tsx
+++ /dev/null
@@ -1,167 +0,0 @@
-import { useRouterState } from "@tanstack/react-router";
-import { Search } from "lucide-react";
-import { useEffect, useRef, useState } from "react";
-import { useDebouncedValue } from "../hooks/use-debounced-value";
-import { useSearchHistory } from "../hooks/use-search-history";
-import { useSearchOverlayNavigation } from "../hooks/use-search-overlay-navigation";
-import { fetchSuggestions } from "../lib/api-suggestions";
-import { buildSearchOverlayItems } from "../lib/search-overlay-items";
-import { ConfirmModal } from "./confirm-modal";
-import { SearchOverlayList } from "./search-overlay-list";
-
-export function NavbarSearch() {
- const location = useRouterState({ select: (state) => state.location });
- const currentSearch =
- location.pathname === "/search" ? (new URLSearchParams(location.searchStr).get("q") ?? "") : "";
- const [query, setQuery] = useState(currentSearch);
- const [suggestions, setSuggestions] = useState([]);
- const [selectedIndex, setSelectedIndex] = useState(-1);
- const [open, setOpen] = useState(false);
- const [confirmClearOpen, setConfirmClearOpen] = useState(false);
- const rootRef = useRef(null);
- const listRef = useRef(null);
- const { service, navigateAndClose } = useSearchOverlayNavigation({
- onClose: () => setOpen(false),
- });
- const { visibleItems, canLoadMore, loadMore, clear } = useSearchHistory();
- const debouncedQuery = useDebouncedValue(query, 300);
- const items = buildSearchOverlayItems(query, visibleItems, suggestions);
- const showHistory = query.trim().length === 0 && visibleItems.length > 0;
-
- useEffect(() => {
- setQuery(currentSearch);
- }, [currentSearch]);
-
- useEffect(() => {
- function onMouseDown(e: MouseEvent) {
- if (rootRef.current && !rootRef.current.contains(e.target as Node)) setOpen(false);
- }
- document.addEventListener("mousedown", onMouseDown);
- return () => document.removeEventListener("mousedown", onMouseDown);
- }, []);
-
- useEffect(() => {
- if (!debouncedQuery.trim()) {
- setSuggestions([]);
- return;
- }
- let cancelled = false;
- fetchSuggestions(debouncedQuery.trim(), service)
- .then((next) => {
- if (!cancelled) setSuggestions(next);
- })
- .catch(() => {});
- return () => {
- cancelled = true;
- };
- }, [debouncedQuery, service]);
-
- useEffect(() => {
- if (selectedIndex < 0) return;
- const element = listRef.current?.querySelector(
- `button[data-item-index="${selectedIndex}"]`,
- );
- element?.scrollIntoView({ block: "nearest", behavior: "smooth" });
- }, [selectedIndex]);
-
- function submit(e: React.FormEvent) {
- e.preventDefault();
- const selected = selectedIndex >= 0 ? items[selectedIndex]?.label : undefined;
- navigateAndClose(selected ?? query);
- }
-
- function selectTerm(term: string) {
- setQuery(term);
- navigateAndClose(term);
- }
-
- function handleKeyDown(e: React.KeyboardEvent) {
- if (e.key === "Escape") {
- setOpen(false);
- return;
- }
- if (items.length === 0) return;
- if (e.key === "ArrowDown") {
- e.preventDefault();
- setOpen(true);
- setSelectedIndex((index) => (index >= items.length - 1 ? 0 : index + 1));
- return;
- }
- if (e.key === "ArrowUp") {
- e.preventDefault();
- setOpen(true);
- setSelectedIndex((index) => (index <= 0 ? items.length - 1 : index - 1));
- return;
- }
- if (e.key === "Tab") {
- const selected = selectedIndex >= 0 ? items[selectedIndex] : items[0];
- if (!selected) return;
- e.preventDefault();
- setQuery(selected.label);
- setSelectedIndex(-1);
- }
- }
-
- function handleScroll(e: React.UIEvent) {
- if (!showHistory || !canLoadMore) return;
- const target = e.currentTarget;
- const threshold = target.scrollHeight - target.clientHeight - 24;
- if (target.scrollTop >= threshold) loadMore();
- }
-
- function handleConfirmClear() {
- clear.mutate();
- setConfirmClearOpen(false);
- }
-
- return (
-
-
-
- {open && items.length > 0 && (
-
- setConfirmClearOpen(true)}
- onSelect={selectTerm}
- />
-
- )}
- {confirmClearOpen && (
-
setConfirmClearOpen(false)}
- />
- )}
-
-
- );
-}
diff --git a/apps/web/src/components/navbar.tsx b/apps/web/src/components/navbar.tsx
deleted file mode 100644
index 97c45422..00000000
--- a/apps/web/src/components/navbar.tsx
+++ /dev/null
@@ -1,114 +0,0 @@
-import { Link, useRouterState } from "@tanstack/react-router";
-import { Heart, Search } from "lucide-react";
-import { lazy, Suspense, useState } from "react";
-import { useAuth } from "../hooks/use-auth";
-import { useAuthToasts } from "../hooks/use-auth-toasts";
-import { useMobile } from "../hooks/use-mobile";
-import { useSearchShortcut } from "../hooks/use-search-shortcut";
-import { isAuthPage } from "../lib/auth-routes";
-import { useUiStore } from "../stores/ui-store";
-import { NavbarAccountControls } from "./navbar-account-controls";
-import { NavbarLeadingControl } from "./navbar-leading-control";
-import { NavbarNotifications } from "./navbar-notifications";
-import { NavbarPlaybackMode } from "./navbar-playback-mode";
-import { NavbarSearch } from "./navbar-search";
-import { Toast } from "./toast";
-
-const SearchOverlay = lazy(() =>
- import("./search-overlay").then((module) => ({
- default: module.SearchOverlay,
- })),
-);
-
-const SPONSOR_URL = "https://github.com/sponsors/Priveetee";
-
-export function Navbar() {
- const [searchOpen, setSearchOpen] = useState(false);
- const toast = useAuthToasts();
- const isMobile = useMobile();
- const toggleSidebar = useUiStore((s) => s.toggleSidebar);
- const toggleMobileSidebar = useUiStore((s) => s.toggleMobileSidebar);
- const { status, isAuthed, isGuest, isAdmin, me, signOut } = useAuth();
- const pathname = useRouterState({ select: (state) => state.location.pathname });
- const authPage = isAuthPage(pathname);
- const canOpenSearch = !authPage;
- const showBackButton = isMobile && canOpenSearch && pathname !== "/";
- const navClass = isMobile
- ? "fixed top-0 left-0 right-0 z-50 h-14 bg-app/95 backdrop-blur border-b border-border flex items-center px-2 gap-2"
- : "fixed top-0 left-0 right-0 z-50 h-14 bg-app/95 backdrop-blur border-b border-border flex items-center px-4 gap-4";
-
- function handleBack() {
- if (window.history.length > 1) {
- window.history.back();
- return;
- }
- window.location.assign("/");
- }
-
- useSearchShortcut({ enabled: canOpenSearch && isMobile, onOpen: () => setSearchOpen(true) });
-
- return (
- <>
-
-
-
- {canOpenSearch && isMobile && (
- setSearchOpen(true)}
- className="ml-auto inline-flex h-9 w-9 items-center justify-center rounded-full border border-border-strong bg-surface-strong text-fg hover:bg-surface-soft"
- aria-label="Search"
- >
-
-
- )}
-
- {canOpenSearch && !isMobile && }
-
-
-
-
-
-
-
- {searchOpen && canOpenSearch && (
-
- setSearchOpen(false)} />
-
- )}
-
- >
- );
-}
diff --git a/apps/web/src/components/notification-bell-icon.tsx b/apps/web/src/components/notification-bell-icon.tsx
deleted file mode 100644
index dd57e4a4..00000000
--- a/apps/web/src/components/notification-bell-icon.tsx
+++ /dev/null
@@ -1,18 +0,0 @@
-export function NotificationBellIcon() {
- return (
-
- Notifications
-
-
-
- );
-}
diff --git a/apps/web/src/components/notification-row.tsx b/apps/web/src/components/notification-row.tsx
deleted file mode 100644
index 8dd1ed8a..00000000
--- a/apps/web/src/components/notification-row.tsx
+++ /dev/null
@@ -1,56 +0,0 @@
-import { Link } from "@tanstack/react-router";
-import { useClientLocale } from "../hooks/use-client-locale";
-import { useDeArrowBranding } from "../hooks/use-dearrow";
-import { formatPublishedDate } from "../lib/format";
-import { proxyImage } from "../lib/proxy";
-import { watchRouteSearch } from "../lib/watch-url";
-import type { NotificationItem } from "../types/notifications";
-
-type Props = {
- item: NotificationItem;
- onOpen: () => void;
-};
-
-export function NotificationRow({ item, onOpen }: Props) {
- const locale = useClientLocale();
- const videoId = item.video.url.trim().length > 0 ? item.video.url : item.video.id;
- const publishedAt =
- typeof item.publishedAt === "number" && item.publishedAt > 0
- ? item.publishedAt
- : item.video.publishedAt;
- const createdText = formatPublishedDate(publishedAt ?? undefined, undefined, locale) || "recent";
- const branding = useDeArrowBranding(
- videoId,
- item.video.title,
- proxyImage(item.video.thumbnailUrl),
- );
-
- return (
-
-
-
-
{branding.title}
-
-
-
{item.channelName}
-
{createdText}
-
-
-
- );
-}
diff --git a/apps/web/src/components/notifications-dropdown.tsx b/apps/web/src/components/notifications-dropdown.tsx
deleted file mode 100644
index 33f565b1..00000000
--- a/apps/web/src/components/notifications-dropdown.tsx
+++ /dev/null
@@ -1,127 +0,0 @@
-import { useEffect, useRef, useState } from "react";
-import { useMobile } from "../hooks/use-mobile";
-import { useNotifications } from "../hooks/use-notifications";
-import { NotificationBellIcon } from "./notification-bell-icon";
-import { NotificationRow } from "./notification-row";
-import { ScrollSentinel } from "./scroll-sentinel";
-
-export function NotificationsDropdown() {
- const [open, setOpen] = useState(false);
- const isMobile = useMobile();
- const rootRef = useRef(null);
- const [scrollRoot, setScrollRoot] = useState(null);
- const [hasLoaded, setHasLoaded] = useState(false);
- const {
- enabled,
- query,
- unreadQuery,
- items,
- unreadCount,
- markAllRead,
- hasNextPage,
- fetchNextPage,
- isFetchingNextPage,
- isFetchNextPageError,
- } = useNotifications(open);
-
- useEffect(() => {
- if (!open || hasLoaded) return;
- setHasLoaded(true);
- }, [open, hasLoaded]);
-
- useEffect(() => {
- function onMouseDown(event: MouseEvent) {
- if (!rootRef.current?.contains(event.target as Node)) setOpen(false);
- }
- window.addEventListener("mousedown", onMouseDown);
- return () => window.removeEventListener("mousedown", onMouseDown);
- }, []);
-
- if (!enabled) return null;
-
- return (
-
-
setOpen((value) => !value)}
- className="relative inline-flex h-9 w-9 items-center justify-center text-fg-muted hover:text-fg"
- aria-label="Notifications"
- >
-
- {unreadCount > 0 && (
-
- {unreadCount > 99 ? "99+" : unreadCount}
-
- )}
-
-
- {open && (
-
-
-
- Notifications
-
-
markAllRead.mutate()}
- disabled={unreadCount === 0 || markAllRead.isPending}
- className="text-xs text-fg-muted hover:text-fg-strong disabled:cursor-not-allowed disabled:text-fg-soft"
- >
- Mark all read
-
-
-
-
- {!hasLoaded && (
-
Open notifications to load items.
- )}
- {hasLoaded && query.isFetching && items.length === 0 && (
-
Loading notifications...
- )}
- {hasLoaded && query.isError && (
-
- Failed to load notifications. Retry in a few seconds.
-
- )}
- {hasLoaded && !query.isFetching && !query.isError && items.length === 0 && (
-
No notifications yet.
- )}
- {items.map((item) => (
-
setOpen(false)}
- />
- ))}
- {hasLoaded && (
- {
- if (hasNextPage && !isFetchingNextPage) {
- void fetchNextPage();
- }
- }}
- enabled={open && hasNextPage && !isFetchingNextPage}
- />
- )}
- {isFetchingNextPage && (
- Loading more...
- )}
- {isFetchNextPageError && (
- Rate limited while loading more.
- )}
- {unreadQuery.isError && (
- Badge temporarily unavailable.
- )}
-
-
- )}
-
- );
-}
diff --git a/apps/web/src/components/oidc-sign-in-button.tsx b/apps/web/src/components/oidc-sign-in-button.tsx
deleted file mode 100644
index 830744d6..00000000
--- a/apps/web/src/components/oidc-sign-in-button.tsx
+++ /dev/null
@@ -1,47 +0,0 @@
-import { useState } from "react";
-import { startOidc } from "../lib/api-oidc";
-import { oidcCallbackUrl } from "../lib/oidc-redirect";
-import { ProviderBrandIcon } from "./provider-brand-icon";
-
-type Props = {
- providerName: string | null;
- returnTo?: string;
-};
-
-export function OidcSignInButton({ providerName, returnTo }: Props) {
- const [pending, setPending] = useState(false);
- const [failed, setFailed] = useState(false);
-
- async function start() {
- setPending(true);
- setFailed(false);
- try {
- const { authorizationUrl } = await startOidc(oidcCallbackUrl(), returnTo);
- window.location.assign(authorizationUrl);
- } catch {
- setFailed(true);
- setPending(false);
- }
- }
-
- return (
-
-
- {pending ? (
- "Redirecting..."
- ) : (
- <>
-
- Continue with {providerName ?? "SSO"}
- >
- )}
-
- {failed &&
Could not start sign-in. Try again.
}
-
- );
-}
diff --git a/apps/web/src/components/page-spinner.tsx b/apps/web/src/components/page-spinner.tsx
deleted file mode 100644
index fe38814f..00000000
--- a/apps/web/src/components/page-spinner.tsx
+++ /dev/null
@@ -1,16 +0,0 @@
-type Props = {
- fullScreen?: boolean;
-};
-
-export function PageSpinner({ fullScreen = true }: Props) {
- const size = fullScreen ? 120 : 88;
- const wrapperClass = fullScreen
- ? "fixed inset-0 z-50 flex items-center justify-center bg-black"
- : "flex h-full w-full items-center justify-center bg-black";
-
- return (
-
-
-
- );
-}
diff --git a/apps/web/src/components/pipepipe-import-cooking-state.tsx b/apps/web/src/components/pipepipe-import-cooking-state.tsx
deleted file mode 100644
index 6f3692c6..00000000
--- a/apps/web/src/components/pipepipe-import-cooking-state.tsx
+++ /dev/null
@@ -1,19 +0,0 @@
-import { ImportMascotLoop } from "./import-mascot-loop";
-
-export function PipePipeImportCookingState() {
- return (
-
-
-
-
-
Restoring your backup
-
We are plating your data now.
-
-
-
- );
-}
diff --git a/apps/web/src/components/pipepipe-import-drop-zone.tsx b/apps/web/src/components/pipepipe-import-drop-zone.tsx
deleted file mode 100644
index eb0006f6..00000000
--- a/apps/web/src/components/pipepipe-import-drop-zone.tsx
+++ /dev/null
@@ -1,51 +0,0 @@
-import type { RefObject } from "react";
-
-type Props = {
- zoneTone: string;
- disabled: boolean;
- fileRef: RefObject;
- onOver: (v: boolean) => void;
- onFile: (file: File) => void;
-};
-
-export function PipePipeImportDropZone({ zoneTone, disabled, fileRef, onOver, onFile }: Props) {
- return (
- {
- e.preventDefault();
- if (!disabled) onOver(true);
- }}
- onDragOver={(e) => {
- e.preventDefault();
- if (!disabled) onOver(true);
- }}
- onDragLeave={(e) => {
- e.preventDefault();
- onOver(false);
- }}
- onDrop={(e) => {
- e.preventDefault();
- onOver(false);
- if (disabled) return;
- const file = e.dataTransfer.files?.[0];
- if (file?.name.toLowerCase().endsWith(".zip")) onFile(file);
- }}
- >
- Drop your PipePipe backup ZIP here
- or click to select
- {
- const file = event.target.files?.[0];
- event.target.value = "";
- if (file) onFile(file);
- }}
- />
-
- );
-}
diff --git a/apps/web/src/components/pipepipe-import-summary.tsx b/apps/web/src/components/pipepipe-import-summary.tsx
deleted file mode 100644
index 568d8dcd..00000000
--- a/apps/web/src/components/pipepipe-import-summary.tsx
+++ /dev/null
@@ -1,59 +0,0 @@
-import { Link } from "@tanstack/react-router";
-import type { PipePipeRestoreSummary } from "../lib/api-restore";
-import { formatRestoreTimeRange } from "../lib/restore-time";
-import { ImportMascotLoop } from "./import-mascot-loop";
-
-type Props = {
- summary: PipePipeRestoreSummary;
-};
-
-export function PipePipeImportSummary({ summary }: Props) {
- return (
-
-
-
-
-
-
Import complete
-
Your backup has been restored.
-
- View subscriptions
-
-
-
-
-
-
- History: {" "}
- {summary.history}
-
-
- Subscriptions: {" "}
- {summary.subscriptions}
-
-
- Playlists: {" "}
- {summary.playlists}
-
-
- Playlist videos: {" "}
- {summary.playlistVideos}
-
-
- Watch dates: {" "}
-
- {formatRestoreTimeRange(summary.historyMinWatchedAt, summary.historyMaxWatchedAt)}
-
-
-
-
- );
-}
diff --git a/apps/web/src/components/pixel-bird.tsx b/apps/web/src/components/pixel-bird.tsx
deleted file mode 100644
index 31297846..00000000
--- a/apps/web/src/components/pixel-bird.tsx
+++ /dev/null
@@ -1,32 +0,0 @@
-import type { CSSProperties } from "react";
-
-const FRAMES = [0, 1, 2, 3];
-const FLAP = 0.5;
-const PIXEL: CSSProperties = { imageRendering: "pixelated" };
-
-type Props = {
- className?: string;
-};
-
-export function PixelBird({ className }: Props) {
- return (
-
- {FRAMES.map((i) => (
-
- ))}
-
- );
-}
diff --git a/apps/web/src/components/pixel-cloud.tsx b/apps/web/src/components/pixel-cloud.tsx
deleted file mode 100644
index b1283118..00000000
--- a/apps/web/src/components/pixel-cloud.tsx
+++ /dev/null
@@ -1,16 +0,0 @@
-type Props = {
- src: string;
- className?: string;
-};
-
-export function PixelCloud({ src, className }: Props) {
- return (
-
- );
-}
diff --git a/apps/web/src/components/pixel-couch-seat.tsx b/apps/web/src/components/pixel-couch-seat.tsx
deleted file mode 100644
index ed7d7737..00000000
--- a/apps/web/src/components/pixel-couch-seat.tsx
+++ /dev/null
@@ -1,17 +0,0 @@
-import { PixelFlipbook } from "./pixel-flipbook";
-
-type Props = {
- className?: string;
-};
-
-export function PixelCouchSeat({ className }: Props) {
- return (
-
- );
-}
diff --git a/apps/web/src/components/pixel-dog-chase.tsx b/apps/web/src/components/pixel-dog-chase.tsx
deleted file mode 100644
index abbc64d6..00000000
--- a/apps/web/src/components/pixel-dog-chase.tsx
+++ /dev/null
@@ -1,169 +0,0 @@
-import { useEffect, useRef, useState } from "react";
-import {
- APPEAR_MS,
- advanceDog,
- DOG_DELAY,
- type DogPhase,
- EDGE,
- MOVE_OFF,
- MOVE_ON,
-} from "./pixel-dog-motion";
-import { PixelFlipbook } from "./pixel-flipbook";
-
-export function PixelDogChase() {
- const [promptVisible, setPromptVisible] = useState(false);
- const [visible, setVisible] = useState(false);
- const [phase, setPhase] = useState("intro");
- const [moving, setMoving] = useState(false);
- const phaseRef = useRef("intro");
- const movingRef = useRef(false);
- const wrapRef = useRef(null);
- const spriteRef = useRef(null);
- const logoRef = useRef(null);
- const pos = useRef({ x: 0, y: 0 });
- const vel = useRef({ x: 0, y: 0 });
- const cursor = useRef({ x: -9999, y: -9999 });
- const target = useRef({ x: 0, y: 0 });
- const exitDir = useRef({ x: 0, y: 0 });
- const facing = useRef(1);
-
- useEffect(() => {
- const promptTimer = window.setTimeout(() => setPromptVisible(true), APPEAR_MS);
- const dogTimer = window.setTimeout(() => {
- pos.current = { x: -EDGE, y: window.innerHeight * 0.5 };
- setVisible(true);
- }, APPEAR_MS + DOG_DELAY);
- return () => {
- window.clearTimeout(promptTimer);
- window.clearTimeout(dogTimer);
- };
- }, []);
-
- useEffect(() => {
- if (!visible) {
- return;
- }
- const onMove = (event: PointerEvent) => {
- cursor.current = { x: event.clientX, y: event.clientY };
- };
- window.addEventListener("pointermove", onMove);
- const go = (next: DogPhase) => {
- phaseRef.current = next;
- setPhase(next);
- };
- let raf = 0;
- const tick = () => {
- const v = vel.current;
- advanceDog({
- w: window.innerWidth,
- h: window.innerHeight,
- p: pos.current,
- v,
- c: cursor.current,
- ph: phaseRef.current,
- logo: logoRef.current?.getBoundingClientRect(),
- target,
- exitDir,
- go,
- });
- const speed = Math.hypot(v.x, v.y);
- if (!movingRef.current && speed > MOVE_ON) {
- movingRef.current = true;
- setMoving(true);
- } else if (movingRef.current && speed < MOVE_OFF) {
- movingRef.current = false;
- setMoving(false);
- }
- if (Math.abs(v.x) > 1) {
- facing.current = v.x > 0 ? 1 : -1;
- }
- if (wrapRef.current) {
- wrapRef.current.style.transform = `translate3d(${pos.current.x}px, ${pos.current.y}px, 0)`;
- }
- if (spriteRef.current) {
- spriteRef.current.style.transform = `translate(-50%, -50%) scaleX(${facing.current})`;
- }
- raf = requestAnimationFrame(tick);
- };
- raf = requestAnimationFrame(tick);
- return () => {
- window.removeEventListener("pointermove", onMove);
- cancelAnimationFrame(raf);
- };
- }, [visible]);
-
- if (!promptVisible) {
- return null;
- }
-
- const carrying = phase === "carry" || phase === "exit" || phase === "enter";
-
- let sprite = (
-
- );
- if (moving && carrying) {
- sprite = (
-
- );
- } else if (moving) {
- sprite = (
-
- );
- } else if (carrying) {
- sprite = (
-
- );
- }
-
- return (
- <>
-
-
- Go back to
-
- {!carrying && (
-
- )}
-
- {visible && (
-
- )}
- >
- );
-}
diff --git a/apps/web/src/components/pixel-dog-motion.ts b/apps/web/src/components/pixel-dog-motion.ts
deleted file mode 100644
index 56a43940..00000000
--- a/apps/web/src/components/pixel-dog-motion.ts
+++ /dev/null
@@ -1,161 +0,0 @@
-export type DogPhase = "intro" | "idle" | "dash" | "carry" | "exit" | "enter";
-
-export const APPEAR_MS = 10000;
-export const DOG_DELAY = 5200;
-export const EDGE = 150;
-export const MOVE_ON = 1.3;
-export const MOVE_OFF = 0.4;
-
-const GRAB_DIST = 72;
-const TRIGGER = 200;
-const RUN_SPEED = 9;
-const ACCEL = 0.16;
-const MARGIN = 70;
-const COMFORT = 165;
-const SLACK = 95;
-const APPROACH_K = 0.06;
-const DART_MIN = 2.2;
-const DRIFT_SPEED = 5;
-const EXIT_PUSH = 130;
-const EXIT_NEAR = 120;
-
-type Vec = { x: number; y: number };
-type VecRef = { current: Vec };
-
-export type DogContext = {
- w: number;
- h: number;
- p: Vec;
- v: Vec;
- c: Vec;
- ph: DogPhase;
- logo: DOMRect | undefined;
- target: VecRef;
- exitDir: VecRef;
- go: (next: DogPhase) => void;
-};
-
-export function advanceDog(ctx: DogContext): void {
- const { w, h, p, v, c, ph, logo, target, exitDir, go } = ctx;
- let dvx = 0;
- let dvy = 0;
- let clamp = false;
- if (ph === "intro") {
- const dx = w * 0.32 - p.x;
- const dy = h * 0.62 - p.y;
- const d = Math.hypot(dx, dy) || 1;
- dvx = (dx / d) * RUN_SPEED;
- dvy = (dy / d) * RUN_SPEED;
- if (d < 34) {
- go("idle");
- }
- } else if (ph === "idle") {
- clamp = true;
- if (logo) {
- const lx = logo.left + logo.width / 2;
- const ly = logo.top + logo.height / 2;
- if (Math.hypot(c.x - lx, c.y - ly) < TRIGGER) {
- go("dash");
- }
- }
- } else if (ph === "dash") {
- if (logo) {
- const dx = logo.left + logo.width / 2 - p.x;
- const dy = logo.top + logo.height / 2 - p.y;
- const d = Math.hypot(dx, dy) || 1;
- dvx = (dx / d) * RUN_SPEED;
- dvy = (dy / d) * RUN_SPEED;
- if (d < GRAB_DIST) {
- go("carry");
- }
- }
- } else if (ph === "carry") {
- const dx = p.x - c.x;
- const dy = p.y - c.y;
- const d = Math.hypot(dx, dy) || 1;
- if (d < COMFORT) {
- const s = Math.min(RUN_SPEED, (COMFORT - d) * APPROACH_K + DART_MIN);
- dvx = (dx / d) * s;
- dvy = (dy / d) * s;
- const near = p.x < EXIT_NEAR || p.x > w - EXIT_NEAR || p.y < EXIT_NEAR || p.y > h - EXIT_NEAR;
- if (d < EXIT_PUSH && near) {
- const dist = [p.x, w - p.x, p.y, h - p.y];
- const m = Math.min(dist[0], dist[1], dist[2], dist[3]);
- if (m === dist[0]) {
- exitDir.current = { x: -1, y: 0 };
- } else if (m === dist[1]) {
- exitDir.current = { x: 1, y: 0 };
- } else if (m === dist[2]) {
- exitDir.current = { x: 0, y: -1 };
- } else {
- exitDir.current = { x: 0, y: 1 };
- }
- go("exit");
- }
- } else if (d > COMFORT + SLACK) {
- const s = Math.min(DRIFT_SPEED, (d - COMFORT - SLACK) * APPROACH_K);
- dvx = -(dx / d) * s;
- dvy = -(dy / d) * s;
- clamp = true;
- } else {
- clamp = true;
- }
- } else if (ph === "exit") {
- dvx = exitDir.current.x * RUN_SPEED;
- dvy = exitDir.current.y * RUN_SPEED;
- } else if (ph === "enter") {
- const dx = target.current.x - p.x;
- const dy = target.current.y - p.y;
- const d = Math.hypot(dx, dy) || 1;
- dvx = (dx / d) * RUN_SPEED;
- dvy = (dy / d) * RUN_SPEED;
- if (d < 44) {
- go("carry");
- }
- }
- v.x += (dvx - v.x) * ACCEL;
- v.y += (dvy - v.y) * ACCEL;
- p.x += v.x;
- p.y += v.y;
- if (clamp) {
- if (p.x < MARGIN) {
- p.x = MARGIN;
- v.x = 0;
- } else if (p.x > w - MARGIN) {
- p.x = w - MARGIN;
- v.x = 0;
- }
- if (p.y < MARGIN) {
- p.y = MARGIN;
- v.y = 0;
- } else if (p.y > h - MARGIN) {
- p.y = h - MARGIN;
- v.y = 0;
- }
- return;
- }
- if (ph === "exit" && (p.x < -EDGE || p.x > w + EDGE || p.y < -EDGE || p.y > h + EDGE)) {
- const side = Math.floor(Math.random() * 4);
- const rx = MARGIN + Math.random() * (w - 2 * MARGIN);
- const ry = MARGIN + Math.random() * (h - 2 * MARGIN);
- const into = 130 + Math.random() * 180;
- if (side === 0) {
- p.x = rx;
- p.y = -EDGE;
- target.current = { x: rx, y: into };
- } else if (side === 1) {
- p.x = rx;
- p.y = h + EDGE;
- target.current = { x: rx, y: h - into };
- } else if (side === 2) {
- p.x = -EDGE;
- p.y = ry;
- target.current = { x: into, y: ry };
- } else {
- p.x = w + EDGE;
- p.y = ry;
- target.current = { x: w - into, y: ry };
- }
- go("enter");
- }
-}
diff --git a/apps/web/src/components/pixel-flipbook.tsx b/apps/web/src/components/pixel-flipbook.tsx
deleted file mode 100644
index 02bb69f4..00000000
--- a/apps/web/src/components/pixel-flipbook.tsx
+++ /dev/null
@@ -1,34 +0,0 @@
-import type { CSSProperties } from "react";
-
-const PIXEL: CSSProperties = { imageRendering: "pixelated" };
-
-type Props = {
- prefix: string;
- count: number;
- frameClass: string;
- cycle: number;
- className?: string;
-};
-
-export function PixelFlipbook({ prefix, count, frameClass, cycle, className }: Props) {
- return (
-
- {Array.from({ length: count }, (_, i) => i).map((i) => (
-
- ))}
-
- );
-}
diff --git a/apps/web/src/components/pixel-ground.tsx b/apps/web/src/components/pixel-ground.tsx
deleted file mode 100644
index 60fd8467..00000000
--- a/apps/web/src/components/pixel-ground.tsx
+++ /dev/null
@@ -1,123 +0,0 @@
-const WIDTH = 256;
-const HEIGHT = 112;
-const DIRT_TOP = 66;
-
-const GRASS = "#a2c255";
-const GRASS_DARK = "#8ba949";
-const DIRT = "#7f4c30";
-const DIRT_LIGHT = "#955734";
-const DIRT_DARK = "#653c27";
-
-type Run = {
- x: number;
- y: number;
- width: number;
- fill: string;
-};
-
-type Hump = {
- cx: number;
- r: number;
- top: number;
-};
-
-function rand(seed: number) {
- const v = Math.sin(seed * 12.9898) * 43758.5453;
- return v - Math.floor(v);
-}
-
-function cell(x: number, y: number, size: number, salt: number) {
- return rand(Math.floor(x / size) * 131.7 + Math.floor(y / size) * 57.3 + salt);
-}
-
-function buildHumps() {
- const humps: Hump[] = [];
- let x = -4;
- let i = 1;
- while (x < WIDTH + 8) {
- humps.push({
- cx: x,
- r: 7 + Math.floor(rand(i) * 7),
- top: 6 + Math.floor(rand(i * 2 + 3) * 16),
- });
- x += 5 + Math.floor(rand(i * 3 + 1) * 6);
- i += 1;
- }
- return humps;
-}
-
-function canopyAt(x: number, humps: Hump[]) {
- let top = 28;
- for (const hump of humps) {
- const dx = Math.abs(x - hump.cx);
- if (dx > hump.r) continue;
- const y = hump.top + Math.round((dx / hump.r) * (28 - hump.top));
- if (y < top) top = y;
- }
- return top;
-}
-
-function dirtTopAt(x: number) {
- const step = Math.floor(cell(x, 0, 6, 9) * 3);
- return DIRT_TOP - 3 + step * 3;
-}
-
-function fillAt(x: number, y: number, humps: Hump[]): string | null {
- const canopy = canopyAt(x, humps);
- if (y < canopy) return null;
- const dirtTop = dirtTopAt(x);
- if (y < dirtTop) {
- if (y <= canopy + 1) return GRASS;
- if (cell(x, y, 4, 1) > 0.76) return GRASS_DARK;
- if (y > dirtTop - 8 && cell(x, y, 4, 4) > 0.52) return GRASS_DARK;
- return GRASS;
- }
- if (y <= dirtTop + 2) return DIRT_DARK;
- if (cell(x, y, 5, 2) > 0.8) return DIRT_LIGHT;
- if (cell(x, y, 6, 5) < 0.15) return DIRT_DARK;
- return DIRT;
-}
-
-function buildRuns() {
- const humps = buildHumps();
- const runs: Run[] = [];
- for (let y = 0; y < HEIGHT; y += 1) {
- let active: Run | null = null;
- for (let x = 0; x < WIDTH; x += 1) {
- const fill = fillAt(x, y, humps);
- if (active && fill === active.fill) {
- active.width += 1;
- continue;
- }
- if (active) runs.push(active);
- active = fill ? { x, y, width: 1, fill } : null;
- }
- if (active) runs.push(active);
- }
- return runs;
-}
-
-export function PixelGround() {
- const runs = buildRuns();
-
- return (
-
- {runs.map((run) => (
-
- ))}
-
- );
-}
diff --git a/apps/web/src/components/pixel-mountain.tsx b/apps/web/src/components/pixel-mountain.tsx
deleted file mode 100644
index ede40020..00000000
--- a/apps/web/src/components/pixel-mountain.tsx
+++ /dev/null
@@ -1,132 +0,0 @@
-type Point = {
- x: number;
- y: number;
-};
-
-type Palette = {
- snow: string;
- light: string;
- mid: string;
- dark: string;
- accent: string;
-};
-
-type Props = {
- className?: string;
- width: number;
- height: number;
- ridge: Point[];
- palette: Palette;
- snowDepth?: number;
- snowLine?: number;
-};
-
-type Run = {
- x: number;
- y: number;
- width: number;
- fill: string;
-};
-
-function interpolateRidge(width: number, points: Point[]) {
- return Array.from({ length: width }, (_, x) => {
- const nextIndex = points.findIndex((point) => point.x >= x);
- const right = points[nextIndex] ?? points[points.length - 1];
- const left = points[Math.max(0, nextIndex - 1)] ?? right;
- if (left.x === right.x) return left.y;
- const progress = (x - left.x) / (right.x - left.x);
- return Math.round(left.y + (right.y - left.y) * progress);
- });
-}
-
-function pixelNoise(x: number, y: number) {
- return (x * 37 + y * 19 + x * y * 7) % 17;
-}
-
-function pixelFill(
- x: number,
- y: number,
- ridge: number[],
- palette: Palette,
- snowDepth: number,
- snowLine: number,
-) {
- const top = ridge[x] ?? y;
- const left = ridge[Math.max(0, x - 1)] ?? top;
- const right = ridge[Math.min(ridge.length - 1, x + 1)] ?? top;
- const depth = y - top;
- const slope = right - left;
- const noise = pixelNoise(x, y);
- const snowCap = top <= snowLine ? snowDepth + Math.floor((snowLine - top) / 2) : 0;
-
- if (depth <= snowCap && noise > 1) return palette.snow;
- if (snowCap > 0 && depth <= snowCap + 3 && noise > 10) return palette.accent;
- if (slope > 1) return noise > 13 ? palette.mid : palette.light;
- if (slope < -1) return noise > 12 ? palette.mid : palette.dark;
- if (noise === 0 || noise === 7) return palette.accent;
- return palette.mid;
-}
-
-function buildRuns(
- width: number,
- height: number,
- ridge: number[],
- palette: Palette,
- snowDepth: number,
- snowLine: number,
-) {
- const runs: Run[] = [];
- for (let y = 0; y < height; y += 1) {
- let active: Run | null = null;
- for (let x = 0; x < width; x += 1) {
- if (y < ridge[x]) {
- if (active) runs.push(active);
- active = null;
- continue;
- }
- const fill = pixelFill(x, y, ridge, palette, snowDepth, snowLine);
- if (active && active.fill === fill) {
- active.width += 1;
- continue;
- }
- if (active) runs.push(active);
- active = { x, y, width: 1, fill };
- }
- if (active) runs.push(active);
- }
- return runs;
-}
-
-export function PixelMountain({
- className,
- width,
- height,
- ridge,
- palette,
- snowDepth = 3,
- snowLine = Math.floor(height * 0.38),
-}: Props) {
- const ridgeLine = interpolateRidge(width, ridge);
- const runs = buildRuns(width, height, ridgeLine, palette, snowDepth, snowLine);
-
- return (
-
- {runs.map((run) => (
-
- ))}
-
- );
-}
diff --git a/apps/web/src/components/pixel-table.tsx b/apps/web/src/components/pixel-table.tsx
deleted file mode 100644
index 8b03fcec..00000000
--- a/apps/web/src/components/pixel-table.tsx
+++ /dev/null
@@ -1,15 +0,0 @@
-type Props = {
- className?: string;
-};
-
-export function PixelTable({ className }: Props) {
- return (
-
- );
-}
diff --git a/apps/web/src/components/pixel-tv-stand.tsx b/apps/web/src/components/pixel-tv-stand.tsx
deleted file mode 100644
index effabba3..00000000
--- a/apps/web/src/components/pixel-tv-stand.tsx
+++ /dev/null
@@ -1,11 +0,0 @@
-import { PixelTable } from "./pixel-table";
-import { PixelTv } from "./pixel-tv";
-
-export function PixelTvStand() {
- return (
-
- );
-}
diff --git a/apps/web/src/components/pixel-tv.tsx b/apps/web/src/components/pixel-tv.tsx
deleted file mode 100644
index bfff2279..00000000
--- a/apps/web/src/components/pixel-tv.tsx
+++ /dev/null
@@ -1,17 +0,0 @@
-import { PixelFlipbook } from "./pixel-flipbook";
-
-type Props = {
- className?: string;
-};
-
-export function PixelTv({ className }: Props) {
- return (
-
- );
-}
diff --git a/apps/web/src/components/pixel-waterfall.tsx b/apps/web/src/components/pixel-waterfall.tsx
deleted file mode 100644
index 5c4c4762..00000000
--- a/apps/web/src/components/pixel-waterfall.tsx
+++ /dev/null
@@ -1,31 +0,0 @@
-import type { CSSProperties } from "react";
-
-const FRAMES = [0, 1, 2, 3];
-const CYCLE = 0.46;
-const DROP_END = 2.5;
-const PIXEL: CSSProperties = { imageRendering: "pixelated" };
-
-type Props = {
- className?: string;
-};
-
-export function PixelWaterfall({ className }: Props) {
- return (
-
- {FRAMES.map((i) => (
-
- ))}
-
- );
-}
diff --git a/apps/web/src/components/playback-transition-notice.tsx b/apps/web/src/components/playback-transition-notice.tsx
deleted file mode 100644
index 0e0905db..00000000
--- a/apps/web/src/components/playback-transition-notice.tsx
+++ /dev/null
@@ -1,165 +0,0 @@
-import { ArrowUpRight, AudioWaveform, Bug, CircleAlert, ShieldAlert, X } from "lucide-react";
-import { useEffect, useState } from "react";
-import { createPortal } from "react-dom";
-import {
- readPlaybackMode,
- setPlaybackMode,
- subscribeClassicPlaybackRequest,
-} from "../lib/playback-mode";
-
-const PAPER_URL = "https://priveetee.github.io/Docs-PipePipe/developer-guide/introduction.html";
-const ISSUES_URL = "https://github.com/Priveetee/TypeType/issues/new/choose";
-
-const CLASSIC_FAILURES = [
- "Sign in to confirm you're not a bot",
- "Video unavailable",
- "Endless loading",
- "Seeks that never recover",
-];
-
-export function PlaybackTransitionNotice() {
- const [open, setOpen] = useState(false);
-
- useEffect(() => {
- if (readPlaybackMode() === "legacy") setOpen(true);
- return subscribeClassicPlaybackRequest(() => setOpen(true));
- }, []);
-
- useEffect(() => {
- if (!open) return;
- const previousOverflow = document.body.style.overflow;
- document.body.style.overflow = "hidden";
- function onKeyDown(event: KeyboardEvent) {
- if (event.key === "Escape") setOpen(false);
- }
- document.addEventListener("keydown", onKeyDown);
- return () => {
- document.body.style.overflow = previousOverflow;
- document.removeEventListener("keydown", onKeyDown);
- };
- }, [open]);
-
- if (!open) return null;
-
- function useSabr() {
- setPlaybackMode("sabr");
- setOpen(false);
- }
-
- function continueWithClassic() {
- setPlaybackMode("legacy");
- setOpen(false);
- }
-
- return createPortal(
- <>
- setOpen(false)}
- />
-
-
-
-
-
- Alternative clients have relied on classic DASH and HLS extraction for years. YouTube is
- restricting that path more often, so a healthy self-hosted instance can still fail in
- ways that look like a server problem.
-
-
-
- {CLASSIC_FAILURES.map((failure) => (
-
-
- {failure}
-
- ))}
-
-
-
-
- SABR is TypeType's supported and recommended mode.
-
-
- It follows YouTube's current delivery path and is the playback path actively
- maintained and optimized by TypeType. Classic remains available while it still works,
- but it may stop working for individual videos or entire instances.
-
-
-
-
- SABR should work across supported YouTube videos. If you find an unexpected playback
- issue, report the video URL and what happened on GitHub so it can be fixed.
-
-
-
-
-
-
- Switch to SABR
-
-
- Continue with Classic
-
-
-
-
- >,
- document.body,
- );
-}
diff --git a/apps/web/src/components/player-defaults.tsx b/apps/web/src/components/player-defaults.tsx
deleted file mode 100644
index d94b2355..00000000
--- a/apps/web/src/components/player-defaults.tsx
+++ /dev/null
@@ -1,154 +0,0 @@
-import { useEffect, useRef } from "react";
-import {
- useAudioOptions,
- useMediaPlayer,
- useMediaState,
- useVideoQualityOptions,
-} from "../lib/vidstack";
-import { includesOriginal, normalizeLanguageTag } from "./player-language";
-
-const QUALITY_OPTIONS = { sort: "descending" } as const;
-
-type PlayerDefaultsProps = {
- defaultQuality?: string;
- defaultAudioLanguage?: string;
- preferOriginalLanguage?: boolean;
- requireOriginalLanguage?: boolean;
- onOriginalLanguageUnavailable?: () => void;
- originalAudioTrackId?: string | null;
- preferredDefaultAudioTrackId?: string | null;
- originalAudioLocale?: string | null;
- subtitlesEnabled?: boolean;
- defaultSubtitleLanguage?: string;
-};
-
-function qualityLabelHeight(label: string): number | null {
- const match = label.match(/(\d+)/);
- if (!match) return null;
- const height = Number(match[1]);
- return Number.isFinite(height) ? height : null;
-}
-
-export function PlayerDefaults({
- defaultQuality,
- defaultAudioLanguage,
- preferOriginalLanguage,
- requireOriginalLanguage,
- onOriginalLanguageUnavailable,
- originalAudioTrackId,
- preferredDefaultAudioTrackId,
- originalAudioLocale,
- subtitlesEnabled,
- defaultSubtitleLanguage,
-}: PlayerDefaultsProps) {
- const canPlay = useMediaState("canPlay");
- const player = useMediaPlayer();
- const qualityOptions = useVideoQualityOptions(QUALITY_OPTIONS);
- const audioOptions = useAudioOptions();
- const textTracks = useMediaState("textTracks");
- const qualityApplied = useRef(false);
- const appliedAudioOptionsCount = useRef(0);
- const subtitleApplied = useRef(false);
- const originalMissingNotified = useRef(false);
-
- const preferredTag = normalizeLanguageTag(defaultAudioLanguage);
- const originalTag = normalizeLanguageTag(originalAudioLocale);
-
- useEffect(() => {
- if (!canPlay || qualityApplied.current || !defaultQuality) return;
- const media = player?.el?.querySelector("video,audio");
- const currentTime = media && Number.isFinite(media.currentTime) ? media.currentTime : 0;
- if (currentTime > 1.5) {
- qualityApplied.current = true;
- return;
- }
- const defaultHeight = qualityLabelHeight(defaultQuality);
- const exactMatch = qualityOptions.find((o) => o.label === defaultQuality);
- const heightMatch = qualityOptions.find(
- (o) => defaultHeight !== null && o.quality?.height === defaultHeight,
- );
- const match = exactMatch ?? heightMatch;
- if (!match) return;
- match.select();
- qualityApplied.current = true;
- }, [canPlay, qualityOptions, defaultQuality, player]);
-
- useEffect(() => {
- const forceOriginal = requireOriginalLanguage || preferOriginalLanguage;
- const canReapplyOriginalSelection =
- forceOriginal &&
- appliedAudioOptionsCount.current > 0 &&
- audioOptions.length > appliedAudioOptionsCount.current;
- const selectedTrackId = audioOptions.find((option) => option.selected)?.track.id;
- const expectedTrackId = forceOriginal
- ? (originalAudioTrackId ?? preferredDefaultAudioTrackId ?? undefined)
- : (preferredDefaultAudioTrackId ?? undefined);
- const shouldFixMismatchedSelection =
- expectedTrackId !== undefined &&
- expectedTrackId !== null &&
- selectedTrackId !== undefined &&
- selectedTrackId !== expectedTrackId;
- if (
- audioOptions.length === 0 ||
- (appliedAudioOptionsCount.current > 0 &&
- !canReapplyOriginalSelection &&
- !shouldFixMismatchedSelection)
- ) {
- return;
- }
-
- let match = forceOriginal
- ? (audioOptions.find((option) => option.track.id === originalAudioTrackId) ??
- audioOptions.find((option) => option.track.id === preferredDefaultAudioTrackId) ??
- audioOptions.find((option) => includesOriginal(option.label)) ??
- audioOptions.find((option) => normalizeLanguageTag(option.track.language) === originalTag))
- : (audioOptions.find((option) => option.track.id === preferredDefaultAudioTrackId) ??
- audioOptions.find((option) => option.track.id === originalAudioTrackId));
-
- const missingOriginalByContract = forceOriginal && originalAudioTrackId === null;
- const missingOriginalByHeuristic =
- forceOriginal && originalAudioTrackId === undefined && !match;
- if (
- (missingOriginalByContract || missingOriginalByHeuristic) &&
- !originalMissingNotified.current
- ) {
- originalMissingNotified.current = true;
- onOriginalLanguageUnavailable?.();
- }
-
- if (!match) {
- match = audioOptions.find(
- (option) => normalizeLanguageTag(option.track.language) === preferredTag,
- );
- }
- if (!match) {
- match = audioOptions[0];
- }
- if (!match || selectedTrackId === match.track.id) return;
-
- match.select();
- appliedAudioOptionsCount.current = audioOptions.length;
- }, [
- audioOptions,
- originalAudioTrackId,
- preferredDefaultAudioTrackId,
- preferOriginalLanguage,
- requireOriginalLanguage,
- originalTag,
- preferredTag,
- onOriginalLanguageUnavailable,
- ]);
-
- useEffect(() => {
- if (!canPlay || subtitleApplied.current || !subtitlesEnabled) return;
- for (const track of textTracks) {
- if (track.kind !== "subtitles" && track.kind !== "captions") continue;
- if (defaultSubtitleLanguage && track.language !== defaultSubtitleLanguage) continue;
- track.setMode("showing");
- subtitleApplied.current = true;
- break;
- }
- }, [canPlay, textTracks, subtitlesEnabled, defaultSubtitleLanguage]);
-
- return null;
-}
diff --git a/apps/web/src/components/player-error.tsx b/apps/web/src/components/player-error.tsx
deleted file mode 100644
index 21840fac..00000000
--- a/apps/web/src/components/player-error.tsx
+++ /dev/null
@@ -1,47 +0,0 @@
-import { useRouter } from "@tanstack/react-router";
-import { useEffect } from "react";
-
-type Props = {
- onRetry: () => void;
-};
-
-export function PlayerError({ onRetry }: Props) {
- const router = useRouter();
-
- useEffect(() => {
- document.body.style.overflow = "hidden";
- document.documentElement.style.overflow = "hidden";
- return () => {
- document.body.style.overflow = "";
- document.documentElement.style.overflow = "";
- };
- }, []);
-
- return (
-
-
-
-
Playback failed
-
- This video could not be played. The stream may be unavailable or unsupported.
-
-
-
-
- Retry
-
- router.history.back()}
- className="px-5 py-2 rounded-full bg-surface-strong hover:bg-surface-soft text-fg text-sm font-medium transition-colors cursor-pointer"
- >
- Go back
-
-
-
- );
-}
diff --git a/apps/web/src/components/player-fast-forward-indicator.tsx b/apps/web/src/components/player-fast-forward-indicator.tsx
deleted file mode 100644
index 976f912d..00000000
--- a/apps/web/src/components/player-fast-forward-indicator.tsx
+++ /dev/null
@@ -1,10 +0,0 @@
-import { Rabbit } from "lucide-react";
-
-export function PlayerFastForwardIndicator() {
- return (
-
-
- 2x
-
- );
-}
diff --git a/apps/web/src/components/player-hotkeys-utils.ts b/apps/web/src/components/player-hotkeys-utils.ts
deleted file mode 100644
index 111de1d1..00000000
--- a/apps/web/src/components/player-hotkeys-utils.ts
+++ /dev/null
@@ -1,81 +0,0 @@
-export function isInteractiveTarget(target: EventTarget | null): boolean {
- if (!(target instanceof HTMLElement)) return false;
- if (target.isContentEditable) return true;
- return Boolean(
- target.closest(
- "a, button, input, textarea, select, summary, [contenteditable='true'], [role='button'], [role='menuitem'], [role='slider']",
- ),
- );
-}
-
-export function isPlayerSeekShortcutTarget(
- target: EventTarget | null,
- player: HTMLElement | null,
-): boolean {
- if (!isInteractiveTarget(target)) return true;
- if (!(target instanceof HTMLElement) || !player?.contains(target)) return false;
- const slider = target.closest("[role='slider']");
- if (slider && !slider.classList.contains("vds-time-slider")) return false;
- return !target.closest(
- "input, textarea, select, [contenteditable='true'], [role='menu'], [role='menuitem'], [role='listbox'], [role='option']",
- );
-}
-
-export function clampTime(value: number, duration: number): number {
- const max = duration > 0 ? duration : Number.POSITIVE_INFINITY;
- return Math.min(max, Math.max(0, value));
-}
-
-export function keyboardSeekOffset(code: string): number | null {
- if (code === "ArrowLeft") return -10;
- if (code === "ArrowRight") return 10;
- return null;
-}
-
-export type KeyboardSeekTarget = {
- position: number;
- updatedAt: number;
-};
-
-export function nextKeyboardSeekTarget(
- currentTime: number,
- duration: number,
- offset: number,
- previous: KeyboardSeekTarget,
- now: number,
-): KeyboardSeekTarget {
- const base = now - previous.updatedAt <= 1_000 ? previous.position : currentTime;
- return { position: clampTime(base + offset, duration), updatedAt: now };
-}
-
-export function consumeEvent(event: KeyboardEvent) {
- event.preventDefault();
- event.stopImmediatePropagation();
-}
-
-export function consumePointerEvent(event: PointerEvent) {
- event.preventDefault();
- event.stopImmediatePropagation();
-}
-
-export function isFastForwardPointer(event: PointerEvent): boolean {
- if (event.pointerType === "mouse") return event.button === 0;
- return event.pointerType === "touch" || event.pointerType === "pen";
-}
-
-const DRAG_THRESHOLD = 12;
-
-export function exceededDragThreshold(dx: number, dy: number): boolean {
- return Math.abs(dx) >= DRAG_THRESHOLD && Math.abs(dx) > Math.abs(dy);
-}
-
-export function computeScrubTarget(
- startTime: number,
- dx: number,
- width: number,
- range: number,
- duration: number,
-): number {
- if (width <= 0) return clampTime(startTime, duration);
- return clampTime(startTime + (dx / width) * range, duration);
-}
diff --git a/apps/web/src/components/player-hotkeys.tsx b/apps/web/src/components/player-hotkeys.tsx
deleted file mode 100644
index 173dce19..00000000
--- a/apps/web/src/components/player-hotkeys.tsx
+++ /dev/null
@@ -1,15 +0,0 @@
-import { usePlayerGestures } from "../hooks/use-player-gestures";
-import { usePlayerKeyboard } from "../hooks/use-player-keyboard";
-import { PlayerFastForwardIndicator } from "./player-fast-forward-indicator";
-
-export function PlayerHotkeys({
- canSeek,
- sabrVideo,
-}: {
- canSeek: boolean;
- sabrVideo: HTMLVideoElement | null;
-}) {
- const touchHolding = usePlayerGestures(canSeek);
- const keyboardHolding = usePlayerKeyboard(canSeek, sabrVideo);
- return touchHolding || keyboardHolding ? : null;
-}
diff --git a/apps/web/src/components/player-internals.tsx b/apps/web/src/components/player-internals.tsx
deleted file mode 100644
index fbef721e..00000000
--- a/apps/web/src/components/player-internals.tsx
+++ /dev/null
@@ -1,157 +0,0 @@
-import { useEffect, useRef } from "react";
-import { isIosDevice } from "../lib/ios-device";
-import { seekSponsorBlockSegment } from "../lib/sponsorblock-seek";
-import { getSponsorBlockEndTime, getSponsorBlockStartTime } from "../lib/sponsorblock-settings";
-import {
- crossedSponsorBlockStart,
- emitSponsorBlockSkip,
- isSponsorBlockEndSkip,
- sponsorBlockSkipTarget,
-} from "../lib/sponsorblock-skip";
-import { useMediaPlayer, useMediaRemote, useMediaState } from "../lib/vidstack";
-import type { SponsorBlockSegmentItem } from "../types/api";
-
-export function SeekBridge({
- onSeekReady,
-}: {
- onSeekReady: (seek: (seconds: number) => void) => void;
-}) {
- const remote = useMediaRemote();
- const onSeekReadyRef = useRef(onSeekReady);
- onSeekReadyRef.current = onSeekReady;
- useEffect(() => {
- onSeekReadyRef.current((seconds: number) => remote.seek(seconds));
- }, [remote]);
- return null;
-}
-
-export function PlayerFocuser() {
- const ios = isIosDevice();
- const player = useMediaPlayer();
- const canPlay = useMediaState("canPlay");
- const focused = useRef(false);
- useEffect(() => {
- if (ios) return;
- if (!canPlay || focused.current || !player?.el) return;
- focused.current = true;
- player.el.focus({ preventScroll: true });
- }, [ios, canPlay, player]);
- return null;
-}
-
-export function SponsorBlockSkipper({
- segments,
- muteInsteadOfSkip,
-}: {
- segments: SponsorBlockSegmentItem[];
- muteInsteadOfSkip: boolean;
-}) {
- const player = useMediaPlayer();
- const remote = useMediaRemote();
- const canPlay = useMediaState("canPlay");
- const activeMuteRef = useRef(null);
- const pendingSkipRef = useRef<{ key: string; startTime: number; endTime: number } | null>(null);
- const restoreMutedRef = useRef(false);
- const previousTimeRef = useRef(null);
- useEffect(() => {
- if (!canPlay) return;
- const root = player?.el;
- if (!root) return;
- const rootElement = root;
- let cleanup: (() => void) | null = null;
-
- function setMuted(media: HTMLMediaElement, value: boolean) {
- media.muted = value;
- media.dispatchEvent(new Event("volumechange", { bubbles: true }));
- }
-
- function process(media: HTMLMediaElement) {
- const duration = Number.isFinite(media.duration) ? media.duration : 0;
- const currentTime = Number.isFinite(media.currentTime) ? media.currentTime : 0;
- const previousTime = previousTimeRef.current;
- previousTimeRef.current = currentTime;
- const pendingSkip = pendingSkipRef.current;
- if (
- pendingSkip &&
- (currentTime >= pendingSkip.endTime - 0.1 ||
- (currentTime < pendingSkip.startTime &&
- media.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA))
- ) {
- pendingSkipRef.current = null;
- }
- let activeMute: string | null = null;
- for (const seg of segments) {
- if (seg.action !== "skip") continue;
- const startTime = getSponsorBlockStartTime(seg, duration);
- const endTime = getSponsorBlockEndTime(seg, duration);
- if (currentTime >= startTime && currentTime < endTime) {
- if (!muteInsteadOfSkip) {
- const key = `${seg.category}:${seg.startTime}:${seg.endTime}`;
- if (pendingSkipRef.current?.key === key) break;
- const crossedStart = crossedSponsorBlockStart(previousTime, currentTime, startTime);
- if (!crossedStart) break;
- emitSponsorBlockSkip({
- category: seg.category,
- automatic: true,
- toEnd: isSponsorBlockEndSkip(endTime, duration),
- });
- pendingSkipRef.current = { key, startTime, endTime };
- seekSponsorBlockSegment(
- media instanceof HTMLVideoElement ? media : null,
- (seconds) => remote.seek(seconds),
- sponsorBlockSkipTarget(endTime, duration),
- );
- break;
- }
- activeMute = `${seg.category}:${seg.startTime}`;
- if (activeMuteRef.current !== activeMute) {
- activeMuteRef.current = activeMute;
- restoreMutedRef.current = !media.muted;
- }
- setMuted(media, true);
- break;
- }
- }
- if (muteInsteadOfSkip && !activeMute && activeMuteRef.current) {
- if (restoreMutedRef.current) setMuted(media, false);
- activeMuteRef.current = null;
- restoreMutedRef.current = false;
- }
- }
-
- function attach() {
- if (cleanup) return true;
- const media = rootElement.querySelector("video,audio");
- if (!media) return false;
- previousTimeRef.current = null;
- const update = () => process(media);
- const seek = () => {
- previousTimeRef.current = Number.isFinite(media.currentTime) ? media.currentTime : 0;
- process(media);
- };
- media.addEventListener("timeupdate", update);
- media.addEventListener("seeking", seek);
- media.addEventListener("durationchange", update);
- media.addEventListener("loadedmetadata", update);
- update();
- cleanup = () => {
- media.removeEventListener("timeupdate", update);
- media.removeEventListener("seeking", seek);
- media.removeEventListener("durationchange", update);
- media.removeEventListener("loadedmetadata", update);
- };
- return true;
- }
-
- if (attach()) return () => cleanup?.();
- const observer = new MutationObserver(() => {
- if (attach()) observer.disconnect();
- });
- observer.observe(rootElement, { childList: true, subtree: true });
- return () => {
- observer.disconnect();
- cleanup?.();
- };
- }, [canPlay, muteInsteadOfSkip, player, segments, remote]);
- return null;
-}
diff --git a/apps/web/src/components/player-language.ts b/apps/web/src/components/player-language.ts
deleted file mode 100644
index 08fc13af..00000000
--- a/apps/web/src/components/player-language.ts
+++ /dev/null
@@ -1,10 +0,0 @@
-export function normalizeLanguageTag(value: string | null | undefined): string {
- if (!value) return "";
- const [base] = value.toLowerCase().split("-");
- return base ?? "";
-}
-
-export function includesOriginal(value: string | undefined): boolean {
- if (!value) return false;
- return value.toLowerCase().includes("original");
-}
diff --git a/apps/web/src/components/player-play-pause-indicator.tsx b/apps/web/src/components/player-play-pause-indicator.tsx
deleted file mode 100644
index 37b60454..00000000
--- a/apps/web/src/components/player-play-pause-indicator.tsx
+++ /dev/null
@@ -1,42 +0,0 @@
-import { Pause, Play } from "lucide-react";
-import { useEffect, useRef, useState } from "react";
-import { useMediaState } from "../lib/vidstack";
-
-export function PlayerPlayPauseIndicator() {
- const paused = useMediaState("paused");
- const canPlay = useMediaState("canPlay");
- const [pulse, setPulse] = useState(0);
- const prevPausedRef = useRef(paused);
- const readyRef = useRef(false);
-
- useEffect(() => {
- if (!canPlay) return;
- if (!readyRef.current) {
- readyRef.current = true;
- prevPausedRef.current = paused;
- return;
- }
- if (paused === prevPausedRef.current) return;
- prevPausedRef.current = paused;
- setPulse((n) => n + 1);
- }, [paused, canPlay]);
-
- useEffect(() => {
- if (pulse === 0) return;
- const timer = window.setTimeout(() => setPulse(0), 500);
- return () => window.clearTimeout(timer);
- }, [pulse]);
-
- if (pulse === 0) return null;
- const Icon = paused ? Pause : Play;
- return (
-
- );
-}
diff --git a/apps/web/src/components/player-seeker.tsx b/apps/web/src/components/player-seeker.tsx
deleted file mode 100644
index 2bfeaffc..00000000
--- a/apps/web/src/components/player-seeker.tsx
+++ /dev/null
@@ -1,103 +0,0 @@
-import { useEffect, useRef } from "react";
-import { recordClientEvent } from "../lib/client-debug-log";
-import { useMediaPlayer, useMediaRemote, useMediaState } from "../lib/vidstack";
-
-function seekable(media: HTMLMediaElement, target: number) {
- if (media.readyState === 0 && !Number.isFinite(media.duration)) return false;
- if (media.seekable.length === 0) return true;
- for (let index = 0; index < media.seekable.length; index += 1) {
- if (target >= media.seekable.start(index) && target <= media.seekable.end(index)) return true;
- }
- return false;
-}
-
-export function PlayerSeeker({ startTime }: { startTime: number }) {
- const player = useMediaPlayer();
- const remote = useMediaRemote();
- const canPlay = useMediaState("canPlay");
- const seeked = useRef(false);
- const startTimeRef = useRef(startTime);
- if (startTimeRef.current !== startTime) {
- startTimeRef.current = startTime;
- seeked.current = false;
- }
-
- useEffect(() => {
- if (startTime <= 0 || seeked.current) return;
- const target = startTime / 1000;
- const root = player?.el;
- let timeout = 0;
- let applying = false;
-
- function seekMedia(media: HTMLMediaElement) {
- if (seeked.current || applying) return;
- if (!seekable(media, target)) {
- recordClientEvent("player.seek_wait", {
- targetMs: Math.round(target * 1000),
- readyState: media.readyState,
- seekableRanges: media.seekable.length,
- });
- return;
- }
- applying = true;
- try {
- media.currentTime = target;
- } catch {}
- remote.seek(target);
- recordClientEvent("player.seek_apply", {
- targetMs: Math.round(target * 1000),
- currentMs: Math.round(media.currentTime * 1000),
- tag: media.tagName,
- });
- timeout = window.setTimeout(() => {
- applying = false;
- if (Math.abs(media.currentTime - target) <= 1.5 || media.currentTime > target) {
- recordClientEvent("player.seek_settled", {
- targetMs: Math.round(target * 1000),
- currentMs: Math.round(media.currentTime * 1000),
- tag: media.tagName,
- });
- seeked.current = true;
- return;
- }
- seekMedia(media);
- }, 250);
- }
-
- if (canPlay) remote.seek(target);
- if (!root) return;
- const rootElement = root;
- let cleanup: (() => void) | null = null;
-
- function attach() {
- if (cleanup) return true;
- const media = rootElement.querySelector("video,audio");
- if (!media) return false;
- const seek = () => seekMedia(media);
- media.addEventListener("loadedmetadata", seek);
- media.addEventListener("durationchange", seek);
- media.addEventListener("canplay", seek);
- media.addEventListener("progress", seek);
- seek();
- cleanup = () => {
- window.clearTimeout(timeout);
- media.removeEventListener("loadedmetadata", seek);
- media.removeEventListener("durationchange", seek);
- media.removeEventListener("canplay", seek);
- media.removeEventListener("progress", seek);
- };
- return true;
- }
-
- if (attach()) return () => cleanup?.();
- const observer = new MutationObserver(() => {
- if (attach()) observer.disconnect();
- });
- observer.observe(rootElement, { childList: true, subtree: true });
- return () => {
- observer.disconnect();
- cleanup?.();
- };
- }, [canPlay, player, remote, startTime]);
- return null;
-}
diff --git a/apps/web/src/components/player-track-button.tsx b/apps/web/src/components/player-track-button.tsx
deleted file mode 100644
index b98c45c4..00000000
--- a/apps/web/src/components/player-track-button.tsx
+++ /dev/null
@@ -1,27 +0,0 @@
-type Props = {
- direction: "previous" | "next";
- onClick?: () => void;
-};
-
-export function PlayerTrackButton({ direction, onClick }: Props) {
- if (!onClick) return null;
- const label = direction === "previous" ? "Previous video" : "Next video";
-
- return (
-
-
- {direction === "previous" ? (
-
- ) : (
-
- )}
-
-
- );
-}
diff --git a/apps/web/src/components/player-volume-control.tsx b/apps/web/src/components/player-volume-control.tsx
deleted file mode 100644
index 2f512023..00000000
--- a/apps/web/src/components/player-volume-control.tsx
+++ /dev/null
@@ -1,29 +0,0 @@
-import { defaultLayoutIcons, MuteButton, useMediaState, VolumeSlider } from "../lib/vidstack";
-
-export function PlayerVolumeControl() {
- const muted = useMediaState("muted");
- const volume = useMediaState("volume");
- const canSetVolume = useMediaState("canSetVolume");
- const Icon =
- muted || volume === 0
- ? defaultLayoutIcons.MuteButton.Mute
- : volume < 0.5
- ? defaultLayoutIcons.MuteButton.VolumeLow
- : defaultLayoutIcons.MuteButton.VolumeHigh;
-
- return (
-
-
-
-
- {canSetVolume ? (
-
-
-
-
-
-
- ) : null}
-
- );
-}
diff --git a/apps/web/src/components/playlist-actions.tsx b/apps/web/src/components/playlist-actions.tsx
deleted file mode 100644
index 9f12d6d9..00000000
--- a/apps/web/src/components/playlist-actions.tsx
+++ /dev/null
@@ -1,29 +0,0 @@
-import { Play, Shuffle } from "lucide-react";
-
-type Props = {
- onPlayAll: () => void;
- onShuffle: () => void;
-};
-
-export function PlaylistActions({ onPlayAll, onShuffle }: Props) {
- return (
-
-
-
- Play all
-
-
-
- Shuffle
-
-
- );
-}
diff --git a/apps/web/src/components/playlist-add-dropdown.tsx b/apps/web/src/components/playlist-add-dropdown.tsx
deleted file mode 100644
index 4a4030c5..00000000
--- a/apps/web/src/components/playlist-add-dropdown.tsx
+++ /dev/null
@@ -1,143 +0,0 @@
-import { useEffect, useLayoutEffect, useRef, useState } from "react";
-import { createPortal } from "react-dom";
-import { usePlaylists } from "../hooks/use-playlists";
-import type { VideoStream } from "../types/stream";
-import { PlaylistRow } from "./playlist-row";
-
-const MARGIN = 8;
-
-type Props = {
- stream: VideoStream;
- anchorEl: HTMLElement | null;
- onClose: () => void;
- onSaved: (label: string) => void;
-};
-
-export function PlaylistAddDropdown({ stream, anchorEl, onClose, onSaved }: Props) {
- const { query, create, addVideo, removeVideo, isInPlaylist } = usePlaylists();
- const playlists = query.data ?? [];
- const [newName, setNewName] = useState("");
- const panelRef = useRef(null);
- const [panelStyle, setPanelStyle] = useState({ visibility: "hidden" });
- const onCloseRef = useRef(onClose);
- onCloseRef.current = onClose;
- const anchorElRef = useRef(anchorEl);
- anchorElRef.current = anchorEl;
-
- useLayoutEffect(() => {
- if (!anchorEl || !panelRef.current) return;
- const anchor = anchorEl.getBoundingClientRect();
- const panel = panelRef.current.getBoundingClientRect();
- const vw = document.documentElement.clientWidth;
- const vh = document.documentElement.clientHeight;
-
- let left = anchor.right - panel.width;
- left = Math.min(left, vw - panel.width - MARGIN);
- left = Math.max(MARGIN, left);
-
- const spaceBelow = vh - anchor.bottom - MARGIN;
- const spaceAbove = anchor.top - MARGIN;
- let top: number;
- if (spaceBelow >= panel.height || spaceBelow >= spaceAbove) {
- top = anchor.bottom + MARGIN;
- } else {
- top = anchor.top - panel.height - MARGIN;
- }
- top = Math.max(MARGIN, Math.min(top, vh - panel.height - MARGIN));
-
- setPanelStyle({ position: "fixed", top, left, visibility: "visible" });
- }, [anchorEl]);
-
- useEffect(() => {
- function onMouseDown(e: MouseEvent) {
- const target = e.target as Node;
- const outsidePanel = panelRef.current && !panelRef.current.contains(target);
- const outsideAnchor = !anchorElRef.current?.contains(target);
- if (outsidePanel && outsideAnchor) onCloseRef.current();
- }
- function onScroll() {
- onCloseRef.current();
- }
- window.addEventListener("mousedown", onMouseDown);
- window.addEventListener("scroll", onScroll, { passive: true });
- return () => {
- window.removeEventListener("mousedown", onMouseDown);
- window.removeEventListener("scroll", onScroll);
- };
- }, []);
-
- function handleToggle(playlistId: string) {
- const playlist = playlists.find((p) => p.id === playlistId);
- if (!playlist) return;
- if (isInPlaylist(playlistId, stream.id)) {
- removeVideo.mutate({ playlistId, videoUrl: stream.id });
- onSaved(`Removed from ${playlist.name}`);
- } else {
- addVideo.mutate({
- playlistId,
- video: {
- url: stream.id,
- title: stream.title,
- thumbnail: stream.thumbnail,
- channelName: stream.channelName,
- channelUrl: stream.channelUrl ?? "",
- channelAvatar: stream.channelAvatar,
- viewCount: stream.views,
- duration: stream.duration,
- },
- });
- onSaved(`Saved to ${playlist.name}`);
- }
- }
-
- function handleCreate() {
- const trimmed = newName.trim();
- if (!trimmed) return;
- create.mutate(trimmed);
- setNewName("");
- onSaved(`Playlist "${trimmed}" created`);
- }
-
- return createPortal(
-
-
- Save to playlist
-
-
- {playlists.length === 0 && (
-
No playlists yet.
- )}
- {playlists.map((playlist) => (
-
handleToggle(playlist.id)}
- />
- ))}
-
-
- setNewName(e.target.value)}
- onKeyDown={(e) => e.key === "Enter" && handleCreate()}
- placeholder="New playlist..."
- className="min-w-0 flex-1 text-xs bg-surface-strong text-fg placeholder-zinc-500 rounded-lg px-2.5 py-1.5 outline-none focus:ring-1 focus:ring-border-strong"
- />
-
- Create
-
-
-
,
- document.body,
- );
-}
diff --git a/apps/web/src/components/playlist-card.tsx b/apps/web/src/components/playlist-card.tsx
deleted file mode 100644
index 8404ae14..00000000
--- a/apps/web/src/components/playlist-card.tsx
+++ /dev/null
@@ -1,160 +0,0 @@
-import { Link } from "@tanstack/react-router";
-import type { PlaylistItem } from "../types/playlist";
-
-type Props = {
- playlist: PlaylistItem;
- selectionMode?: boolean;
- selected?: boolean;
- onToggleSelect?: () => void;
- onDeleteRequest: () => void;
-};
-
-function TrashIcon() {
- return (
-
-
-
-
- );
-}
-
-function EmptyIcon() {
- return (
-
-
-
-
-
-
- );
-}
-
-function ThumbnailContent({ playlist }: { playlist: PlaylistItem }) {
- const thumbnail = playlist.videos?.[0]?.thumbnail;
- const count = playlist.videoCount ?? playlist.videos?.length ?? 0;
- const label = `${count} video${count !== 1 ? "s" : ""}`;
- return (
-
- {thumbnail ? (
-
- ) : (
-
-
-
- )}
-
- {label}
-
-
- );
-}
-
-export function PlaylistCard({
- playlist,
- selectionMode,
- selected,
- onToggleSelect,
- onDeleteRequest,
-}: Props) {
- const count = playlist.videoCount ?? playlist.videos?.length ?? 0;
- const label = `${count} video${count !== 1 ? "s" : ""}`;
-
- return (
-
-
- {selectionMode ? (
-
-
-
-
- {selected && (
-
-
-
- )}
-
-
- ) : (
-
-
-
- )}
-
-
-
selectionMode && e.preventDefault()}
- >
-
- {playlist.name}
-
-
{label}
-
- {!selectionMode && (
-
-
-
- )}
-
-
- );
-}
diff --git a/apps/web/src/components/playlist-create-modal.tsx b/apps/web/src/components/playlist-create-modal.tsx
deleted file mode 100644
index c09388f6..00000000
--- a/apps/web/src/components/playlist-create-modal.tsx
+++ /dev/null
@@ -1,80 +0,0 @@
-import { useEffect, useRef, useState } from "react";
-import { createPortal } from "react-dom";
-
-type Props = {
- onConfirm: (name: string) => void;
- onCancel: () => void;
-};
-
-export function PlaylistCreateModal({ onConfirm, onCancel }: Props) {
- const [name, setName] = useState("");
- const inputRef = useRef(null);
-
- useEffect(() => {
- const prev = document.body.style.overflow;
- document.body.style.overflow = "hidden";
- inputRef.current?.focus();
- function onKeyDown(e: KeyboardEvent) {
- if (e.key === "Escape") onCancel();
- }
- document.addEventListener("keydown", onKeyDown);
- return () => {
- document.body.style.overflow = prev;
- document.removeEventListener("keydown", onKeyDown);
- };
- }, [onCancel]);
-
- function handleSubmit(e: React.FormEvent) {
- e.preventDefault();
- const trimmed = name.trim();
- if (!trimmed) return;
- onConfirm(trimmed);
- }
-
- return createPortal(
- <>
-
-
- >,
- document.body,
- );
-}
diff --git a/apps/web/src/components/playlist-grid.tsx b/apps/web/src/components/playlist-grid.tsx
deleted file mode 100644
index 0d548aa2..00000000
--- a/apps/web/src/components/playlist-grid.tsx
+++ /dev/null
@@ -1,74 +0,0 @@
-import { type DragEvent, useState } from "react";
-import type { PlaylistVideoItem } from "../types/user";
-import { PlaylistVideoRow } from "./playlist-video-row";
-
-type Props = {
- videos: PlaylistVideoItem[];
- reorderable: boolean;
- listId: string;
- onRemove: (video: PlaylistVideoItem) => void;
- onReorder: (order: string[]) => void;
-};
-
-export function PlaylistGrid({ videos, reorderable, listId, onRemove, onReorder }: Props) {
- const [dragIndex, setDragIndex] = useState(null);
- const [overIndex, setOverIndex] = useState(null);
-
- function handleDragStart(event: DragEvent, index: number) {
- setDragIndex(index);
- event.dataTransfer.effectAllowed = "move";
- const card = (event.currentTarget as HTMLElement).closest("[data-pl-card]");
- if (card instanceof HTMLElement) event.dataTransfer.setDragImage(card, 20, 20);
- }
-
- function handleDrop(targetIndex: number) {
- if (dragIndex !== null && dragIndex !== targetIndex) {
- const next = [...videos];
- const [moved] = next.splice(dragIndex, 1);
- next.splice(targetIndex, 0, moved);
- onReorder(next.map((video) => video.url));
- }
- setDragIndex(null);
- setOverIndex(null);
- }
-
- return (
-
- {videos.map((video, index) => (
- {
- event.preventDefault();
- setOverIndex(index);
- }
- : undefined
- }
- onDrop={reorderable ? () => handleDrop(index) : undefined}
- onDragEnd={
- reorderable
- ? () => {
- setDragIndex(null);
- setOverIndex(null);
- }
- : undefined
- }
- >
- onRemove(video)}
- reorderable={reorderable}
- listId={listId}
- onDragStart={(event) => handleDragStart(event, index)}
- />
-
- ))}
-
- );
-}
diff --git a/apps/web/src/components/playlist-rename-modal.tsx b/apps/web/src/components/playlist-rename-modal.tsx
deleted file mode 100644
index a5ced64d..00000000
--- a/apps/web/src/components/playlist-rename-modal.tsx
+++ /dev/null
@@ -1,82 +0,0 @@
-import { useEffect, useRef, useState } from "react";
-import { createPortal } from "react-dom";
-
-type Props = {
- currentName: string;
- onConfirm: (name: string) => void;
- onCancel: () => void;
-};
-
-export function PlaylistRenameModal({ currentName, onConfirm, onCancel }: Props) {
- const [name, setName] = useState(currentName);
- const inputRef = useRef(null);
-
- useEffect(() => {
- const prev = document.body.style.overflow;
- document.body.style.overflow = "hidden";
- inputRef.current?.focus();
- inputRef.current?.select();
- function onKeyDown(e: KeyboardEvent) {
- if (e.key === "Escape") onCancel();
- }
- document.addEventListener("keydown", onKeyDown);
- return () => {
- document.body.style.overflow = prev;
- document.removeEventListener("keydown", onKeyDown);
- };
- }, [onCancel]);
-
- function handleSubmit(e: React.FormEvent) {
- e.preventDefault();
- const trimmed = name.trim();
- if (!trimmed || trimmed === currentName) return;
- onConfirm(trimmed);
- }
-
- return createPortal(
- <>
-
-
-
- Rename playlist
-
-
-
- >,
- document.body,
- );
-}
diff --git a/apps/web/src/components/playlist-row.tsx b/apps/web/src/components/playlist-row.tsx
deleted file mode 100644
index 00a96cce..00000000
--- a/apps/web/src/components/playlist-row.tsx
+++ /dev/null
@@ -1,44 +0,0 @@
-function CheckIcon() {
- return (
-
-
-
- );
-}
-
-type RowProps = {
- label: string;
- checked: boolean;
- onToggle: () => void;
-};
-
-export function PlaylistRow({ label, checked, onToggle }: RowProps) {
- return (
-
-
- {checked && }
-
- {label}
-
- );
-}
diff --git a/apps/web/src/components/playlist-sort-menu.tsx b/apps/web/src/components/playlist-sort-menu.tsx
deleted file mode 100644
index 1e3e22f5..00000000
--- a/apps/web/src/components/playlist-sort-menu.tsx
+++ /dev/null
@@ -1,88 +0,0 @@
-import { ChevronDown } from "lucide-react";
-import { useEffect, useLayoutEffect, useRef, useState } from "react";
-import { createPortal } from "react-dom";
-import { PLAYLIST_SORT_OPTIONS, type PlaylistSortMode } from "../lib/playlist-sort";
-
-const MARGIN = 8;
-
-type Props = {
- value: PlaylistSortMode;
- onChange: (value: PlaylistSortMode) => void;
-};
-
-export function PlaylistSortMenu({ value, onChange }: Props) {
- const [open, setOpen] = useState(false);
- const anchorRef = useRef(null);
- const panelRef = useRef(null);
- const [panelStyle, setPanelStyle] = useState({ visibility: "hidden" });
- const current = PLAYLIST_SORT_OPTIONS.find((option) => option.value === value);
-
- useLayoutEffect(() => {
- if (!open || !anchorRef.current || !panelRef.current) return;
- const anchor = anchorRef.current.getBoundingClientRect();
- const panel = panelRef.current.getBoundingClientRect();
- const vw = document.documentElement.clientWidth;
- const vh = document.documentElement.clientHeight;
- let left = Math.min(anchor.right - panel.width, vw - panel.width - MARGIN);
- left = Math.max(MARGIN, left);
- const spaceBelow = vh - anchor.bottom - MARGIN;
- let top =
- spaceBelow >= panel.height ? anchor.bottom + MARGIN : anchor.top - panel.height - MARGIN;
- top = Math.max(MARGIN, Math.min(top, vh - panel.height - MARGIN));
- setPanelStyle({ position: "fixed", top, left, visibility: "visible" });
- }, [open]);
-
- useEffect(() => {
- if (!open) return;
- function onMouseDown(event: MouseEvent) {
- const target = event.target as Node;
- if (panelRef.current?.contains(target) || anchorRef.current?.contains(target)) return;
- setOpen(false);
- }
- window.addEventListener("mousedown", onMouseDown);
- return () => window.removeEventListener("mousedown", onMouseDown);
- }, [open]);
-
- return (
- <>
- setOpen((previous) => !previous)}
- className="inline-flex items-center gap-1.5 rounded-lg border border-border-strong bg-surface px-3 py-1.5 text-xs font-medium text-fg transition-colors hover:bg-surface-strong"
- >
- Sort
- {current?.label ?? "Manual"}
-
-
- {open &&
- createPortal(
-
- {PLAYLIST_SORT_OPTIONS.map((option) => (
- {
- onChange(option.value);
- setOpen(false);
- }}
- className={`block w-full px-3 py-2 text-left text-sm transition-colors hover:bg-surface-strong ${
- option.value === value ? "text-fg" : "text-fg-muted"
- }`}
- >
- {option.label}
-
- ))}
-
,
- document.body,
- )}
- >
- );
-}
diff --git a/apps/web/src/components/playlist-video-row.tsx b/apps/web/src/components/playlist-video-row.tsx
deleted file mode 100644
index f07d7518..00000000
--- a/apps/web/src/components/playlist-video-row.tsx
+++ /dev/null
@@ -1,148 +0,0 @@
-import { Link } from "@tanstack/react-router";
-import { GripVertical } from "lucide-react";
-import type { DragEvent } from "react";
-import { useDeArrowBranding } from "../hooks/use-dearrow";
-import { formatDuration, formatViews } from "../lib/format";
-import { proxyImage } from "../lib/proxy";
-import { isVideoWatched } from "../lib/watch-progress";
-import { watchRouteSearch } from "../lib/watch-url";
-import type { VideoStream } from "../types/stream";
-import type { PlaylistVideoItem } from "../types/user";
-import { ChannelRouteLink } from "./channel-route-link";
-import { VideoCardFeedbackMenu } from "./video-card-feedback-menu";
-import { VideoProgressBar } from "./video-progress-bar";
-import { WatchedBadge } from "./watched-badge";
-
-function XIcon() {
- return (
-
-
-
-
- );
-}
-
-type Props = {
- video: PlaylistVideoItem;
- onRemove: () => void;
- reorderable?: boolean;
- listId?: string;
- onDragStart?: (event: DragEvent) => void;
-};
-
-export function PlaylistVideoRow({ video, onRemove, reorderable, listId, onDragStart }: Props) {
- const rawThumbnail = video.thumbnail.trim();
- const fallbackThumbnail = rawThumbnail.length > 0 ? proxyImage(rawThumbnail) : "";
- const branding = useDeArrowBranding(video.url, video.title, fallbackThumbnail, video.duration);
- const thumbnail = branding.thumbnail || null;
- const watched = video.watched || isVideoWatched(video.watchPosition, video.duration);
- const rawChannelName = video.channelName?.trim() ?? "";
- const rawChannelUrl = video.channelUrl?.trim() ?? "";
- const rawChannelAvatar = video.channelAvatar?.trim() ?? "";
- const rawViews = video.viewCount ?? 0;
- const channelName = rawChannelName;
- const channelUrl = rawChannelUrl;
- const channelAvatar = rawChannelAvatar;
- const views = rawViews;
- const menuStream: VideoStream = {
- id: video.url,
- title: video.title,
- thumbnail: thumbnail ?? "",
- rawThumbnail,
- rawChannelAvatar: rawChannelAvatar,
- channelName,
- channelUrl: channelUrl || undefined,
- channelAvatar,
- views,
- duration: video.duration,
- };
- const watchSearch = listId
- ? { ...watchRouteSearch(video.url), list: listId }
- : watchRouteSearch(video.url);
-
- return (
-
-
-
- {thumbnail && (
-
- )}
- {watched && (
-
-
-
- )}
- {video.duration > 0 && (
-
- {formatDuration(video.duration)}
-
- )}
-
-
{
- e.preventDefault();
- onRemove();
- }}
- aria-label="Remove from playlist"
- className="absolute top-1.5 right-1.5 bg-black/70 hover:bg-black/90 text-white rounded-full p-1 opacity-0 group-hover:opacity-100 transition-opacity"
- >
-
-
- {reorderable && (
-
e.preventDefault()}
- aria-label="Drag to reorder"
- className="absolute top-1.5 left-1.5 cursor-grab bg-black/70 p-1 text-white opacity-0 transition-opacity hover:bg-black/90 active:cursor-grabbing group-hover:opacity-100"
- >
-
-
- )}
-
-
-
-
- {branding.title}
-
-
-
-
- {channelName.length > 0 &&
- (channelUrl.length > 0 ? (
-
- {channelName}
-
- ) : (
-
{channelName}
- ))}
- {views > 0 &&
{formatViews(views)}
}
-
-
-
-
- );
-}
diff --git a/apps/web/src/components/playlists-empty-state.tsx b/apps/web/src/components/playlists-empty-state.tsx
deleted file mode 100644
index a7c7e8aa..00000000
--- a/apps/web/src/components/playlists-empty-state.tsx
+++ /dev/null
@@ -1,8 +0,0 @@
-export function PlaylistsEmptyState() {
- return (
-
-
No playlists yet.
-
Use the New playlist button to get started.
-
- );
-}
diff --git a/apps/web/src/components/playlists-page-header.tsx b/apps/web/src/components/playlists-page-header.tsx
deleted file mode 100644
index 91cd20c2..00000000
--- a/apps/web/src/components/playlists-page-header.tsx
+++ /dev/null
@@ -1,63 +0,0 @@
-type Props = {
- selectionMode: boolean;
- selectedCount: number;
- canSelect: boolean;
- onSelect: () => void;
- onCancel: () => void;
- onDelete: () => void;
- onCreate: () => void;
-};
-
-export function PlaylistsPageHeader({
- selectionMode,
- selectedCount,
- canSelect,
- onSelect,
- onCancel,
- onDelete,
- onCreate,
-}: Props) {
- const base = "rounded-lg px-3 py-2 text-sm transition-colors";
- return (
-
-
Playlists
-
- {selectionMode ? (
- <>
- {selectedCount} selected
-
- Cancel
-
-
- Delete ({selectedCount})
-
- >
- ) : (
- <>
- {canSelect && (
-
- Select
-
- )}
-
- New playlist
-
- >
- )}
-
-
- );
-}
diff --git a/apps/web/src/components/podcast-card.tsx b/apps/web/src/components/podcast-card.tsx
deleted file mode 100644
index ec1716f4..00000000
--- a/apps/web/src/components/podcast-card.tsx
+++ /dev/null
@@ -1,47 +0,0 @@
-import { Link } from "@tanstack/react-router";
-import { proxyImage } from "../lib/proxy";
-import type { PodcastItem } from "../types/api";
-
-type Props = {
- podcast: PodcastItem;
- channelAvatar?: string;
-};
-
-export function PodcastCard({ podcast, channelAvatar }: Props) {
- const thumbnail = proxyImage(podcast.thumbnailUrl);
- const count = podcast.streamCount === 1 ? "1 episode" : `${podcast.streamCount} episodes`;
-
- return (
-
-
-
- {channelAvatar && (
-
- )}
-
-
-
- {podcast.title}
-
-
{podcast.uploaderName}
-
{count}
-
-
- );
-}
diff --git a/apps/web/src/components/profile-avatar-settings.tsx b/apps/web/src/components/profile-avatar-settings.tsx
deleted file mode 100644
index 2b746cf5..00000000
--- a/apps/web/src/components/profile-avatar-settings.tsx
+++ /dev/null
@@ -1,145 +0,0 @@
-import { useMemo, useRef, useState } from "react";
-import { useAuth } from "../hooks/use-auth";
-import { useAvatar } from "../hooks/use-avatar";
-import { getOpenMojiUrl } from "../lib/openmoji";
-import { OPENMOJI_CATALOG } from "../lib/openmoji-catalog";
-import { CustomAvatarUpload } from "./custom-avatar-upload";
-import { Toast } from "./toast";
-
-const CARD = "bg-surface rounded-xl border border-border overflow-hidden divide-y divide-border";
-const SCROLL_STEP = 220;
-
-function normalizeTerm(value: string): string {
- return value.trim().toLowerCase();
-}
-
-function ArrowIcon({ right }: { right: boolean }) {
- const d = right ? "m9 6 6 6-6 6" : "m15 6-6 6 6 6";
- return (
-
- {right ? "Right arrow" : "Left arrow"}
-
-
- );
-}
-
-export function ProfileAvatarSettings() {
- const { me } = useAuth();
- const { emoji, clear } = useAvatar();
- const listRef = useRef(null);
- const [search, setSearch] = useState("");
- const [toast, setToast] = useState(null);
- const busy = emoji.isPending || clear.isPending;
- const selectedCode = me?.avatarType === "emoji" ? me.avatarCode : null;
-
- const filtered = useMemo(() => {
- const term = normalizeTerm(search);
- if (term.length === 0) return OPENMOJI_CATALOG;
- return OPENMOJI_CATALOG.filter((item) => item.label.includes(term) || item.code.includes(term));
- }, [search]);
-
- function scroll(direction: "left" | "right") {
- const next = direction === "right" ? SCROLL_STEP : -SCROLL_STEP;
- listRef.current?.scrollBy({ left: next, behavior: "smooth" });
- }
-
- if (!me || me.id.startsWith("guest:")) return null;
-
- return (
-
- Avatar
-
-
-
-
-
- Emojis from{" "}
-
- OpenMoji
-
-
-
setSearch(event.target.value)}
- placeholder="Search emojis"
- className="h-8 w-40 rounded-md border border-border-strong bg-app px-2 text-xs text-fg"
- />
-
-
-
scroll("left")}
- className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-md border border-border-strong bg-surface text-fg-muted transition-colors hover:border-border-strong hover:text-fg disabled:opacity-50"
- >
-
-
-
-
- {filtered.map((item) => {
- const selected = selectedCode === item.code;
- return (
-
{
- emoji.mutate(item.code, {
- onSuccess: () => setToast("Avatar updated"),
- onError: () => setToast("Unable to update avatar"),
- });
- }}
- className={`flex h-11 w-11 flex-shrink-0 items-center justify-center rounded-xl border p-1.5 transition-transform hover:scale-105 active:scale-95 disabled:opacity-50 ${
- selected
- ? "border-border bg-fg/20 ring-1 ring-border"
- : "border-border-strong bg-surface hover:border-border-strong"
- }`}
- >
-
-
- );
- })}
-
-
-
scroll("right")}
- className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-md border border-border-strong bg-surface text-fg-muted transition-colors hover:border-border-strong hover:text-fg disabled:opacity-50"
- >
-
-
-
-
-
- {
- clear.mutate(undefined, {
- onSuccess: () => setToast("Avatar cleared"),
- onError: () => setToast("Unable to clear avatar"),
- });
- }}
- className="h-9 rounded-md border border-border-strong bg-surface px-3 text-xs text-fg disabled:opacity-50"
- >
- Clear
-
-
-
-
-
- );
-}
diff --git a/apps/web/src/components/profile-avatar.tsx b/apps/web/src/components/profile-avatar.tsx
deleted file mode 100644
index 4e286368..00000000
--- a/apps/web/src/components/profile-avatar.tsx
+++ /dev/null
@@ -1,36 +0,0 @@
-import { toApiUrl } from "../lib/env";
-import { getOpenMojiUrl, pickOpenMojiCode } from "../lib/openmoji";
-import type { AuthMe } from "../types/auth";
-
-type ProfileAvatarProps = {
- me: AuthMe;
- className: string;
- plain?: boolean;
-};
-
-export function ProfileAvatar({ me, className, plain = false }: ProfileAvatarProps) {
- const seed = `${me.id}:${me.publicUsername ?? "profile"}`;
- const avatarUrl =
- me.avatarType === "emoji" && typeof me.avatarCode === "string" && me.avatarCode.length > 0
- ? getOpenMojiUrl(me.avatarCode)
- : me.avatarUrl
- ? toApiUrl(me.avatarUrl)
- : getOpenMojiUrl(pickOpenMojiCode(seed));
-
- return (
-
-
-
- );
-}
diff --git a/apps/web/src/components/provider-brand-icon.tsx b/apps/web/src/components/provider-brand-icon.tsx
deleted file mode 100644
index 1eef07e4..00000000
--- a/apps/web/src/components/provider-brand-icon.tsx
+++ /dev/null
@@ -1,38 +0,0 @@
-import { brandIconColor, oidcProviderIcon } from "../lib/oidc-provider-icon";
-import { ServiceIcon } from "./service-icon";
-
-function GoogleGlyph() {
- return (
-
-
-
-
-
-
- );
-}
-
-export function ProviderBrandIcon({ providerName }: { providerName: string | null }) {
- if ((providerName ?? "").toLowerCase().includes("google")) return ;
- const icon = oidcProviderIcon(providerName);
- return ;
-}
diff --git a/apps/web/src/components/public-playlist-card.tsx b/apps/web/src/components/public-playlist-card.tsx
deleted file mode 100644
index fbb0dddd..00000000
--- a/apps/web/src/components/public-playlist-card.tsx
+++ /dev/null
@@ -1,38 +0,0 @@
-import { Link } from "@tanstack/react-router";
-import { proxyImage } from "../lib/proxy";
-import type { PublicPlaylistInfo } from "../types/playlist";
-
-type Props = {
- playlist: PublicPlaylistInfo;
-};
-
-export function PublicPlaylistCard({ playlist }: Props) {
- const count = playlist.streamCount === 1 ? "1 video" : `${playlist.streamCount} videos`;
-
- return (
-
-
-
-
- {count}
-
-
-
-
- {playlist.title}
-
-
{playlist.uploaderName}
-
-
- );
-}
diff --git a/apps/web/src/components/public-playlist-header.tsx b/apps/web/src/components/public-playlist-header.tsx
deleted file mode 100644
index 92bcda93..00000000
--- a/apps/web/src/components/public-playlist-header.tsx
+++ /dev/null
@@ -1,29 +0,0 @@
-import { proxyImage } from "../lib/proxy";
-import type { PublicPlaylistInfo } from "../types/playlist";
-
-type Props = {
- info: PublicPlaylistInfo;
-};
-
-export function PublicPlaylistHeader({ info }: Props) {
- const count = info.streamCount;
- return (
-
- {info.thumbnailUrl && (
-
- )}
-
-
{info.title}
- {info.uploaderName &&
{info.uploaderName}
}
-
- {count} video{count !== 1 ? "s" : ""}
-
-
-
- );
-}
diff --git a/apps/web/src/components/quality-selector.tsx b/apps/web/src/components/quality-selector.tsx
deleted file mode 100644
index 5aae9b10..00000000
--- a/apps/web/src/components/quality-selector.tsx
+++ /dev/null
@@ -1,145 +0,0 @@
-import type * as dashjs from "dashjs";
-import { useRef } from "react";
-import { useDashPlayerSnapshot } from "../lib/dash-player-store";
-import { dashQualityOptions, selectDashTrack, selectedDashHeight } from "../lib/dash-video";
-import { sabrResolutionOptions } from "../lib/sabr-quality-selection";
-import type { DefaultLayoutIcon, MenuInstance } from "../lib/vidstack";
-import {
- ClipIcon,
- DefaultMenuButton,
- DefaultMenuRadioGroup,
- Menu,
- useVideoQualityOptions,
-} from "../lib/vidstack";
-import { useSabrQualityStore } from "../stores/sabr-quality-store";
-
-const qualityIcon: DefaultLayoutIcon = (props) => ;
-const MENU_ITEMS_CLASS =
- "vds-menu-items max-h-[44svh] overflow-y-auto overscroll-y-contain pr-0.5 md:max-h-72 [scrollbar-width:thin] [scrollbar-color:var(--color-zinc-500)_transparent] [&::-webkit-scrollbar]:w-1.5 [&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-thumb]:bg-surface-soft/80 [&::-webkit-scrollbar-thumb:hover]:bg-surface-soft [&::-webkit-scrollbar-track]:bg-transparent";
-const QUALITY_OPTIONS = { sort: "descending" } as const;
-
-type QualityOption = ReturnType[number];
-
-function qualityValue(option: QualityOption): string {
- return String(option.quality?.height ?? option.label);
-}
-
-function collectResolutionOptions(options: QualityOption[]): QualityOption[] {
- const grouped = new Map();
- for (const option of options) {
- if (option.quality === null) continue;
- const value = qualityValue(option);
- const current = grouped.get(value);
- if (!current || option.selected) grouped.set(value, option);
- }
- return [...grouped.values()];
-}
-
-function activeDashTrack(
- player: dashjs.MediaPlayerClass,
- selectedVideoTrack: dashjs.MediaInfo | null,
-): dashjs.MediaInfo | null {
- return selectedVideoTrack ?? player.getCurrentTrackFor("video");
-}
-
-export function QualitySelector() {
- const menuRef = useRef(null);
- const { player, selectedVideoTrack } = useDashPlayerSnapshot();
- const options = useVideoQualityOptions(QUALITY_OPTIONS);
- const sabrStreamId = useSabrQualityStore((state) => state.streamId);
- const sabrOptions = useSabrQualityStore((state) => state.options);
- const sabrSelectedItag = useSabrQualityStore((state) => state.selectedItag);
- const selectSabrQuality = useSabrQualityStore((state) => state.selectQuality);
-
- if (sabrStreamId && sabrOptions.length > 0) {
- const streamId = sabrStreamId;
- const selected =
- sabrOptions.find((option) => option.itag === sabrSelectedItag) ?? sabrOptions[0];
- const resolutionOptions = sabrResolutionOptions(sabrOptions, selected);
- if (resolutionOptions.length <= 1) return null;
- function onSabrChange(value: string) {
- const itag = Number(value);
- if (!Number.isInteger(itag)) return;
- selectSabrQuality(streamId, itag);
- menuRef.current?.close();
- }
- return (
-
-
-
- ({
- label: option.label,
- value: String(option.itag),
- }))}
- onChange={onSabrChange}
- />
-
-
- );
- }
-
- const dashTrack = player ? activeDashTrack(player, selectedVideoTrack) : null;
- if (player && dashTrack) {
- const dashPlayer = player;
- const activeTrack = dashTrack;
- const selectedHeight = selectedDashHeight(dashPlayer, activeTrack);
- const dashOptions = dashQualityOptions(activeTrack, selectedHeight);
- const selected = dashOptions.find((option) => option.selected) ?? dashOptions[0];
-
- if (dashOptions.length > 1 && selected) {
- function onDashChange(value: string) {
- const height = Number(value);
- if (!Number.isFinite(height)) return;
- selectDashTrack(dashPlayer, activeTrack, height);
- menuRef.current?.close();
- }
-
- return (
-
-
-
- ({
- label: option.label,
- value: option.value,
- }))}
- onChange={onDashChange}
- />
-
-
- );
- }
- }
-
- const videoOptions = options.filter((o) => o.quality !== null);
- const filteredOptions = collectResolutionOptions(videoOptions);
- const selected = filteredOptions.find((o) => o.selected) ?? filteredOptions[0];
- const radioOptions = filteredOptions.map((o) => ({ label: o.label, value: qualityValue(o) }));
-
- if (filteredOptions.length <= 1) return null;
- if (filteredOptions.every((o) => (o.quality?.height ?? 0) === 0)) return null;
-
- if (!selected) return null;
- const current = selected.label;
-
- function onChange(value: string) {
- filteredOptions.find((o) => qualityValue(o) === value)?.select();
- menuRef.current?.close();
- }
-
- return (
-
-
-
-
-
-
- );
-}
diff --git a/apps/web/src/components/related-card-skeleton.tsx b/apps/web/src/components/related-card-skeleton.tsx
deleted file mode 100644
index 592c9770..00000000
--- a/apps/web/src/components/related-card-skeleton.tsx
+++ /dev/null
@@ -1,12 +0,0 @@
-export function RelatedCardSkeleton() {
- return (
-
- );
-}
diff --git a/apps/web/src/components/related-card.tsx b/apps/web/src/components/related-card.tsx
deleted file mode 100644
index 313fb35d..00000000
--- a/apps/web/src/components/related-card.tsx
+++ /dev/null
@@ -1,109 +0,0 @@
-import { Link } from "@tanstack/react-router";
-import { memo } from "react";
-import { useClientLocale } from "../hooks/use-client-locale";
-import { useDeArrowBranding } from "../hooks/use-dearrow";
-import { formatDuration, formatPublishedDate, formatViews } from "../lib/format";
-import { watchRouteSearch } from "../lib/watch-url";
-import { useWatchNavigationStore } from "../stores/watch-navigation-store";
-import type { VideoStream } from "../types/stream";
-import { ChannelAvatar } from "./channel-avatar";
-import { ChannelRouteLink } from "./channel-route-link";
-import { VideoCardFeedbackMenu } from "./video-card-feedback-menu";
-import { VideoStatusBadge } from "./video-status-badge";
-import { VerifiedBadgeIcon } from "./watch-icons";
-
-type Props = {
- stream: VideoStream;
- relatedStreams?: VideoStream[];
-};
-
-function RelatedCardComponent({ stream, relatedStreams }: Props) {
- const locale = useClientLocale();
- const setNavigation = useWatchNavigationStore((state) => state.setNavigation);
- const { title, thumbnail } = useDeArrowBranding(
- stream.id,
- stream.title,
- stream.thumbnail,
- stream.duration,
- );
- const publishedText = formatPublishedDate(stream.publishedAt, undefined, locale);
- const metadata = [formatViews(stream.views), publishedText].filter(Boolean).join(" · ");
-
- return (
-
- setNavigation(stream, relatedStreams)}
- >
-
- {stream.requiresMembership && (
-
- Members only
-
- )}
- {(stream.isLive || stream.isPostLive) && (
-
-
-
- )}
- {!stream.isLive && stream.duration > 0 && (
-
- {formatDuration(stream.duration)}
-
- )}
-
-
-
setNavigation(stream, relatedStreams)}
- >
- {title}
-
- {stream.channelUrl ? (
-
-
-
- {stream.channelName}
- {stream.uploaderVerified && }
-
-
- ) : (
-
-
-
- {stream.channelName}
- {stream.uploaderVerified && }
-
-
- )}
-
{metadata}
-
-
-
- );
-}
-
-export const RelatedCard = memo(RelatedCardComponent);
diff --git a/apps/web/src/components/related-videos.tsx b/apps/web/src/components/related-videos.tsx
deleted file mode 100644
index 07fb4d09..00000000
--- a/apps/web/src/components/related-videos.tsx
+++ /dev/null
@@ -1,45 +0,0 @@
-import { useMemo } from "react";
-import { useBlockedFilter } from "../hooks/use-blocked-filter";
-import type { VideoStream } from "../types/stream";
-import { AutoplayToggle } from "./autoplay-toggle";
-import { RelatedCard } from "./related-card";
-import { RelatedCardSkeleton } from "./related-card-skeleton";
-
-const SKELETON_KEYS = Array.from({ length: 4 }, (_, i) => `rs-${i}`);
-
-type Props = {
- streams: VideoStream[];
- isLoading?: boolean;
-};
-
-function uniqueStreams(streams: VideoStream[]): VideoStream[] {
- const seen = new Set();
- const unique: VideoStream[] = [];
- for (const stream of streams) {
- if (seen.has(stream.id)) continue;
- seen.add(stream.id);
- unique.push(stream);
- }
- return unique;
-}
-
-export function RelatedVideos({ streams, isLoading = false }: Props) {
- const { filter } = useBlockedFilter();
- const visible = useMemo(() => uniqueStreams(filter(streams)), [filter, streams]);
- return (
-
-
- {isLoading
- ? SKELETON_KEYS.map((k) =>
)
- : visible.map((stream, index) => (
-
-
-
- ))}
-
- );
-}
diff --git a/apps/web/src/components/report-bug-modal.tsx b/apps/web/src/components/report-bug-modal.tsx
deleted file mode 100644
index 2c361e45..00000000
--- a/apps/web/src/components/report-bug-modal.tsx
+++ /dev/null
@@ -1,126 +0,0 @@
-import { useEffect, useState } from "react";
-import { createPortal } from "react-dom";
-import { useBugReport } from "../hooks/use-bug-report";
-import type { BugReportCategory, PlayerStateContext } from "../types/bug-report";
-
-type Props = {
- videoUrl?: string | null;
- playerState?: PlayerStateContext | null;
- onClose: () => void;
-};
-
-const CATEGORIES: { value: BugReportCategory; label: string }[] = [
- { value: "player", label: "Player" },
- { value: "audio_language", label: "Audio Language" },
- { value: "subtitles", label: "Subtitles" },
- { value: "ui", label: "Interface" },
- { value: "functionality", label: "Functionality" },
-];
-
-export function ReportBugModal({ videoUrl, playerState, onClose }: Props) {
- const [category, setCategory] = useState("player");
- const [description, setDescription] = useState("");
- const mutation = useBugReport();
-
- useEffect(() => {
- const prev = document.body.style.overflow;
- document.body.style.overflow = "hidden";
- function onKeyDown(e: KeyboardEvent) {
- if (e.key === "Escape") onClose();
- }
- document.addEventListener("keydown", onKeyDown);
- return () => {
- document.body.style.overflow = prev;
- document.removeEventListener("keydown", onKeyDown);
- };
- }, [onClose]);
-
- function handleSubmit(e: React.FormEvent) {
- e.preventDefault();
- if (description.trim().length === 0) return;
- mutation.mutate(
- { category, description: description.trim(), videoUrl, playerState },
- { onSuccess: onClose },
- );
- }
-
- const isSubmitting = mutation.isPending;
- const hasError = mutation.isError;
-
- return createPortal(
- <>
-
-
- >,
- document.body,
- );
-}
diff --git a/apps/web/src/components/reset-token-modal.tsx b/apps/web/src/components/reset-token-modal.tsx
deleted file mode 100644
index ca67284d..00000000
--- a/apps/web/src/components/reset-token-modal.tsx
+++ /dev/null
@@ -1,82 +0,0 @@
-import { useEffect } from "react";
-
-type ResetTokenModalProps = {
- email: string;
- token: string;
- onClose: () => void;
- onCopied: () => void;
-};
-
-export function ResetTokenModal({ email, token, onClose, onCopied }: ResetTokenModalProps) {
- useEffect(() => {
- navigator.clipboard
- .writeText(token)
- .then(() => {
- onCopied();
- })
- .catch(() => {});
- }, [token, onCopied]);
-
- useEffect(() => {
- const handleEscape = (e: KeyboardEvent) => {
- if (e.key === "Escape") onClose();
- };
- window.addEventListener("keydown", handleEscape);
- return () => window.removeEventListener("keydown", handleEscape);
- }, [onClose]);
-
- const handleCopy = () => {
- navigator.clipboard.writeText(token).then(() => {
- onCopied();
- });
- };
-
- return (
- {
- if (e.key === "Escape") onClose();
- }}
- >
-
e.stopPropagation()}
- onKeyDown={() => {}}
- >
-
-
Password Reset Token
-
{email}
-
-
-
-
-
- Token copied to clipboard automatically
-
-
-
-
- Copy token
-
-
- Close
-
-
-
-
- );
-}
diff --git a/apps/web/src/components/rich-text.tsx b/apps/web/src/components/rich-text.tsx
deleted file mode 100644
index 1246721b..00000000
--- a/apps/web/src/components/rich-text.tsx
+++ /dev/null
@@ -1,108 +0,0 @@
-import { useState } from "react";
-import { ExternalLinkModal } from "./external-link-modal";
-
-type Segment =
- | { id: string; type: "text"; value: string }
- | { id: string; type: "url"; value: string }
- | { id: string; type: "timecode"; value: string; seconds: number };
-
-function parseTimestampToSeconds(value: string): number | null {
- const parts = value.split(":").map((part) => Number(part));
- if (parts.some((part) => !Number.isFinite(part))) return null;
- if (parts.length === 2) {
- const [minutes, seconds] = parts;
- return minutes * 60 + seconds;
- }
- if (parts.length === 3) {
- const [hours, minutes, seconds] = parts;
- return hours * 3600 + minutes * 60 + seconds;
- }
- return null;
-}
-
-function parseSegments(text: string): Segment[] {
- const regex = /https?:\/\/[^\s\])"',;:!>]+|\b(?:\d{1,2}:)?[0-5]?\d:[0-5]\d\b/g;
- const segments: Segment[] = [];
- let lastIndex = 0;
- let counter = 0;
- let match = regex.exec(text);
- while (match !== null) {
- if (match.index > lastIndex) {
- segments.push({
- id: `t${counter++}`,
- type: "text",
- value: text.slice(lastIndex, match.index),
- });
- }
- const value = match[0];
- if (value.startsWith("http://") || value.startsWith("https://")) {
- segments.push({ id: `u${counter++}`, type: "url", value });
- } else {
- const seconds = parseTimestampToSeconds(value);
- if (seconds === null) {
- segments.push({ id: `t${counter++}`, type: "text", value });
- } else {
- segments.push({ id: `c${counter++}`, type: "timecode", value, seconds });
- }
- }
- lastIndex = match.index + match[0].length;
- match = regex.exec(text);
- }
- if (lastIndex < text.length) {
- segments.push({ id: `t${counter}`, type: "text", value: text.slice(lastIndex) });
- }
- return segments;
-}
-
-type RichTextProps = {
- text: string;
- onSeekTimestamp?: (seconds: number) => void;
-};
-
-export function RichText({ text, onSeekTimestamp }: RichTextProps) {
- const [pendingUrl, setPendingUrl] = useState(null);
- const segments = parseSegments(text);
-
- return (
-
- {segments.map((seg) =>
- seg.type === "text" ? (
- {seg.value}
- ) : seg.type === "url" ? (
- {
- event.preventDefault();
- setPendingUrl(seg.value);
- }}
- className="text-accent hover:text-accent-strong underline underline-offset-2 transition-colors break-all text-left align-baseline"
- >
- {seg.value}
-
- ) : onSeekTimestamp ? (
- onSeekTimestamp(seg.seconds)}
- className="text-accent hover:text-accent-strong underline underline-offset-2 transition-colors"
- >
- {seg.value}
-
- ) : (
- {seg.value}
- ),
- )}
- {pendingUrl && (
- {
- window.open(pendingUrl, "_blank", "noopener,noreferrer");
- setPendingUrl(null);
- }}
- onCancel={() => setPendingUrl(null)}
- />
- )}
-
- );
-}
diff --git a/apps/web/src/components/sabr-mse-player-types.ts b/apps/web/src/components/sabr-mse-player-types.ts
deleted file mode 100644
index 50a4546a..00000000
--- a/apps/web/src/components/sabr-mse-player-types.ts
+++ /dev/null
@@ -1,16 +0,0 @@
-import type { SabrPlaybackConfig } from "../lib/sabr-source";
-
-export type SabrMsePlayerProps = {
- config: SabrPlaybackConfig;
- video: HTMLVideoElement | null;
- startTime: number;
- autoplay: boolean;
- initialVolume: number;
- initialMuted: boolean;
- settingsReady: boolean;
- onVolumeChange?: (volume: number, muted: boolean) => void;
- onError: (positionMs?: number) => void;
- onSeekStateChange: (seeking: boolean) => void;
- onSeekReady: (seek: (seconds: number) => void) => void;
- onPositionReaderChange: (reader: (() => number | null) | null) => void;
-};
diff --git a/apps/web/src/components/sabr-mse-player.tsx b/apps/web/src/components/sabr-mse-player.tsx
deleted file mode 100644
index 2a8ce85d..00000000
--- a/apps/web/src/components/sabr-mse-player.tsx
+++ /dev/null
@@ -1,176 +0,0 @@
-import { TypeTypeMsePlayer, type TypeTypeMseQuality } from "@typetype/mse";
-import { useCallback, useEffect, useRef, useState } from "react";
-import { useLatestValue } from "../hooks/use-latest-value";
-import { useSabrModeSwitch } from "../hooks/use-sabr-mode-switch";
-import { useSabrQualitySwitch } from "../hooks/use-sabr-quality-switch";
-import { recordClientEvent } from "../lib/client-debug-log";
-import { toAbsoluteApiUrl } from "../lib/env";
-import { isAbortError } from "../lib/sabr-playback-retry";
-import { cancelPendingSabrSeek, positionMs, runSabrSeek } from "../lib/sabr-player-seek";
-import { registerSabrVidstackControls } from "../lib/sabr-vidstack-bridge";
-import { useAuthStore } from "../stores/auth-store";
-import type { SabrMsePlayerProps } from "./sabr-mse-player-types";
-
-export function SabrMsePlayer({
- config,
- video,
- startTime,
- autoplay,
- initialVolume,
- initialMuted,
- settingsReady,
- onVolumeChange,
- onError,
- onSeekStateChange,
- onSeekReady,
- onPositionReaderChange,
-}: SabrMsePlayerProps) {
- const token = useAuthStore((state) => state.token);
- const headersRef = useRef(new Headers());
- if (token) headersRef.current.set("authorization", `Bearer ${token}`);
- else headersRef.current.delete("authorization");
- const engineRef = useRef(null);
- const qualityRef = useRef(null);
- const pendingPlayRef = useRef(false);
- const autoplayStartedRef = useRef(false);
- const autoplayConfirmedRef = useRef(false);
- const seekingRef = useRef(false);
- const errorReportedRef = useRef(false);
- const [engineReady, setEngineReady] = useState(false);
- const latestConfig = useLatestValue(config);
- const latestStartTime = useLatestValue(startTime);
- const latestHandlers = useLatestValue({
- autoplay,
- onError,
- onSeekStateChange,
- onSeekReady,
- onPositionReaderChange,
- onVolumeChange,
- });
- const reportError = useCallback(
- (error: unknown, recoveryPositionMs?: number) => {
- if (errorReportedRef.current) return;
- errorReportedRef.current = true;
- const message = error instanceof Error ? error.message : String(error);
- recordClientEvent("player.sabr_engine_error", {
- error: message,
- recoveryPositionMs,
- });
- latestHandlers().onError(recoveryPositionMs);
- },
- [latestHandlers],
- );
- const latestEngineHandlers = useCallback(() => {
- const handlers = latestHandlers();
- return {
- onError: reportError,
- onSeekStateChange: handlers.onSeekStateChange,
- };
- }, [latestHandlers, reportError]);
- useSabrQualitySwitch(config, engineReady, engineRef, qualityRef, seekingRef);
- useSabrModeSwitch(config.audioOnly === true, engineRef, seekingRef, latestEngineHandlers);
- useEffect(() => {
- if (!video || !settingsReady) return;
- video.volume = Math.min(1, Math.max(0, initialVolume));
- video.muted = initialMuted;
- }, [initialMuted, initialVolume, settingsReady, video]);
- useEffect(() => {
- if (!video) return;
- errorReportedRef.current = false;
- const initialConfig = latestConfig();
- const engine = new TypeTypeMsePlayer(video, {
- endpoint: toAbsoluteApiUrl(""),
- videoId: config.videoId,
- videoItag: initialConfig.videoItag,
- audioItag: initialConfig.audioItag,
- audioTrackId: initialConfig.audioTrackId,
- audioOnly: initialConfig.audioOnly,
- startTimeMs: Math.max(0, Math.round(latestStartTime())),
- headers: headersRef.current,
- });
- engineRef.current = engine;
- qualityRef.current = {
- videoItag: initialConfig.videoItag,
- audioItag: initialConfig.audioItag,
- audioTrackId: initialConfig.audioTrackId,
- };
- const offError = engine.on("error", (event) => {
- if (event.type === "error") reportError(event.error, event.recoveryPositionMs);
- });
- const volumeChange = () => latestHandlers().onVolumeChange?.(video.volume, video.muted);
- video.addEventListener("volumechange", volumeChange);
- let autoplayStartTime = 0;
- let engineLoaded = false;
- const startAutoplay = () => {
- if (!engineLoaded || autoplayConfirmedRef.current || video.readyState < 3) return;
- if (autoplayStartedRef.current) {
- if (!video.paused && video.currentTime >= autoplayStartTime + 0.25) {
- autoplayConfirmedRef.current = true;
- } else if (video.paused) {
- autoplayStartedRef.current = false;
- }
- return;
- }
- if (!latestHandlers().autoplay && !pendingPlayRef.current) return;
- autoplayStartTime = video.currentTime;
- autoplayStartedRef.current = true;
- void engine.play().catch(() => {
- autoplayStartedRef.current = false;
- });
- };
- video.addEventListener("canplay", startAutoplay);
- const autoplayTimer = window.setInterval(startAutoplay, 250);
- const unregisterControls = registerSabrVidstackControls(video, {
- play: () => {
- pendingPlayRef.current = true;
- video.autoplay = true;
- return engine.play();
- },
- pause: (userInitiated = false) => {
- if (!userInitiated && pendingPlayRef.current && !autoplayConfirmedRef.current) return;
- pendingPlayRef.current = false;
- autoplayConfirmedRef.current = true;
- video.autoplay = false;
- return engine.pause();
- },
- seek: (seconds) => {
- const targetMs = Math.max(0, Math.round(seconds * 1000));
- runSabrSeek(engine, targetMs, seekingRef, reportError, latestHandlers().onSeekStateChange);
- },
- });
- void engine
- .load()
- .then(() => {
- engineLoaded = true;
- setEngineReady(true);
- startAutoplay();
- })
- .catch((error: unknown) => {
- if (!isAbortError(error)) reportError(error);
- });
- latestHandlers().onSeekReady((seconds) => {
- const targetMs = Math.max(0, Math.round(seconds * 1000));
- runSabrSeek(engine, targetMs, seekingRef, reportError, latestHandlers().onSeekStateChange);
- });
- latestHandlers().onPositionReaderChange(() => positionMs(video));
- return () => {
- offError();
- unregisterControls();
- video.removeEventListener("volumechange", volumeChange);
- video.removeEventListener("canplay", startAutoplay);
- window.clearInterval(autoplayTimer);
- engine.destroy();
- engineRef.current = null;
- setEngineReady(false);
- pendingPlayRef.current = false;
- autoplayStartedRef.current = false;
- autoplayConfirmedRef.current = false;
- cancelPendingSabrSeek(seekingRef);
- seekingRef.current = false;
- latestHandlers().onSeekStateChange(false);
- video.autoplay = false;
- latestHandlers().onPositionReaderChange(null);
- };
- }, [config.videoId, latestConfig, latestHandlers, latestStartTime, reportError, video]);
- return null;
-}
diff --git a/apps/web/src/components/sabr-time-slider.tsx b/apps/web/src/components/sabr-time-slider.tsx
deleted file mode 100644
index cdb1cf2f..00000000
--- a/apps/web/src/components/sabr-time-slider.tsx
+++ /dev/null
@@ -1,50 +0,0 @@
-import { useEffect, useState } from "react";
-import { secondsFromSliderPercent } from "../lib/sabr-player-seek";
-import { requestSabrSeek } from "../lib/sabr-vidstack-bridge";
-import { TimeSlider } from "../lib/vidstack";
-
-type Props = {
- disabled?: boolean;
- thumbnails?: string;
- video: HTMLVideoElement | null;
-};
-
-export function SabrTimeSlider({ disabled = false, thumbnails, video }: Props) {
- const [seekTarget, setSeekTarget] = useState(null);
- useEffect(() => {
- if (!disabled) setSeekTarget(null);
- }, [disabled]);
- const style = seekTarget === null ? undefined : { "--typetype-seek-target": `${seekTarget}%` };
-
- return (
- {
- setSeekTarget(percent);
- const seconds = video ? secondsFromSliderPercent(video.duration, percent) : null;
- if (video && !disabled && seconds !== null) requestSabrSeek(video, seconds);
- }}
- >
-
-
-
-
-
- {thumbnails && (
-
-
-
- )}
-
-
-
- );
-}
diff --git a/apps/web/src/components/saved-playlist-card.tsx b/apps/web/src/components/saved-playlist-card.tsx
deleted file mode 100644
index 98911b83..00000000
--- a/apps/web/src/components/saved-playlist-card.tsx
+++ /dev/null
@@ -1,50 +0,0 @@
-import { Link } from "@tanstack/react-router";
-import { Trash2 } from "lucide-react";
-import { proxyImage } from "../lib/proxy";
-import type { SavedPlaylistItem } from "../types/playlist";
-
-type Props = {
- playlist: SavedPlaylistItem;
- onDelete: () => void;
-};
-
-export function SavedPlaylistCard({ playlist, onDelete }: Props) {
- const count = playlist.streamCount === 1 ? "1 video" : `${playlist.streamCount} videos`;
-
- return (
-
-
-
- {playlist.thumbnailUrl && (
-
- )}
-
- {count}
-
-
-
-
-
-
- {playlist.title}
-
-
{playlist.uploaderName}
-
-
-
-
-
-
- );
-}
diff --git a/apps/web/src/components/saved-playlists-section.tsx b/apps/web/src/components/saved-playlists-section.tsx
deleted file mode 100644
index d30a3cda..00000000
--- a/apps/web/src/components/saved-playlists-section.tsx
+++ /dev/null
@@ -1,31 +0,0 @@
-import type { SavedPlaylistItem } from "../types/playlist";
-import { SavedPlaylistCard } from "./saved-playlist-card";
-
-type Props = {
- playlists: SavedPlaylistItem[];
- onDelete: (playlist: SavedPlaylistItem) => void;
-};
-
-export function SavedPlaylistsSection({ playlists, onDelete }: Props) {
- if (playlists.length === 0) return null;
-
- return (
-
-
-
Saved public playlists
-
Live public playlists saved to your library.
-
-
- {playlists.map((playlist, index) => (
-
- onDelete(playlist)} />
-
- ))}
-
-
- );
-}
diff --git a/apps/web/src/components/scroll-sentinel.tsx b/apps/web/src/components/scroll-sentinel.tsx
deleted file mode 100644
index 4bc2bb23..00000000
--- a/apps/web/src/components/scroll-sentinel.tsx
+++ /dev/null
@@ -1,30 +0,0 @@
-import { useEffect, useRef } from "react";
-
-type Props = {
- onIntersect: () => void;
- enabled: boolean;
- root?: Element | null;
-};
-
-export function ScrollSentinel({ onIntersect, enabled, root }: Props) {
- const ref = useRef(null);
- const onIntersectRef = useRef(onIntersect);
- onIntersectRef.current = onIntersect;
-
- useEffect(() => {
- const el = ref.current;
- if (!el || !enabled) return;
-
- const observer = new IntersectionObserver(
- ([entry]) => {
- if (entry.isIntersecting) onIntersectRef.current();
- },
- { root: root ?? null, rootMargin: "300px" },
- );
-
- observer.observe(el);
- return () => observer.disconnect();
- }, [enabled, root]);
-
- return
;
-}
diff --git a/apps/web/src/components/search-channel-card.tsx b/apps/web/src/components/search-channel-card.tsx
deleted file mode 100644
index c76dcd91..00000000
--- a/apps/web/src/components/search-channel-card.tsx
+++ /dev/null
@@ -1,44 +0,0 @@
-import { BadgeCheck } from "lucide-react";
-import { formatSubscribers } from "../lib/format";
-import { proxyImage } from "../lib/proxy";
-import type { ChannelResultItem } from "../types/api";
-import { AllowChannelButton } from "./allow-channel-button";
-import { ChannelAvatar } from "./channel-avatar";
-import { ChannelRouteLink } from "./channel-route-link";
-
-type Props = {
- channel: ChannelResultItem;
-};
-
-export function SearchChannelCard({ channel }: Props) {
- return (
-
-
-
-
-
-
-
- {channel.name}
- {channel.isVerified && (
-
- )}
-
-
{formatSubscribers(channel.subscriberCount)}
-
-
-
-
- );
-}
diff --git a/apps/web/src/components/search-filter-bar.tsx b/apps/web/src/components/search-filter-bar.tsx
deleted file mode 100644
index 0cd6d18c..00000000
--- a/apps/web/src/components/search-filter-bar.tsx
+++ /dev/null
@@ -1,86 +0,0 @@
-import type { SearchFiltersResponse } from "../types/api";
-
-function prettifyLabel(raw: string): string {
- const afterColon = raw.includes(":") ? raw.slice(raw.indexOf(":") + 1) : raw;
- const base = afterColon.trim();
- const stripped = base.startsWith("sort_") ? base.slice(5) : base;
- return stripped
- .split(/[_\s]+/)
- .filter(Boolean)
- .map((word) => word.charAt(0).toUpperCase() + word.slice(1))
- .join(" ");
-}
-
-function chipClass(active: boolean): string {
- const base =
- "shrink-0 whitespace-nowrap rounded-lg px-3 py-1.5 text-sm font-medium transition-colors";
- return active
- ? `${base} bg-fg text-app`
- : `${base} bg-surface-strong text-fg hover:bg-surface-soft`;
-}
-
-type Props = {
- filters: SearchFiltersResponse;
- contentFilter: string | undefined;
- sortFilter: string | undefined;
- onContentChange: (value: string | undefined) => void;
- onSortChange: (value: string | undefined) => void;
-};
-
-export function SearchFilterBar({
- filters,
- contentFilter,
- sortFilter,
- onContentChange,
- onSortChange,
-}: Props) {
- const contentOptions = filters.contentFilters.filter((option) => option.label !== "all");
- const { sortFilters } = filters;
-
- if (contentOptions.length === 0 && sortFilters.length === 0) return null;
-
- return (
-
-
- onContentChange(undefined)}
- className={chipClass(!contentFilter)}
- >
- All
-
- {contentOptions.map((option) => (
- onContentChange(option.value)}
- className={chipClass(contentFilter === option.value)}
- >
- {prettifyLabel(option.label)}
-
- ))}
-
- {sortFilters.length > 0 && (
-
- onSortChange(undefined)}
- className={chipClass(!sortFilter)}
- >
- Relevance
-
- {sortFilters.map((option) => (
- onSortChange(option.value)}
- className={chipClass(sortFilter === option.value)}
- >
- {prettifyLabel(option.label)}
-
- ))}
-
- )}
-
- );
-}
diff --git a/apps/web/src/components/search-overlay-list.tsx b/apps/web/src/components/search-overlay-list.tsx
deleted file mode 100644
index 39e9b5e2..00000000
--- a/apps/web/src/components/search-overlay-list.tsx
+++ /dev/null
@@ -1,76 +0,0 @@
-import type { RefObject } from "react";
-import type { SearchOverlayItem } from "../lib/search-overlay-items";
-
-type Props = {
- items: SearchOverlayItem[];
- showHistory: boolean;
- selectedIndex: number;
- listRef: RefObject;
- onScroll: (e: React.UIEvent) => void;
- onClearAll?: () => void;
- onSelect: (term: string) => void;
- className?: string;
-};
-
-export function SearchOverlayList({
- items,
- showHistory,
- selectedIndex,
- listRef,
- onScroll,
- onClearAll,
- onSelect,
- className,
-}: Props) {
- if (items.length === 0) return null;
-
- const listClass =
- className ??
- "mt-1 max-h-[22rem] overflow-y-auto scroll-smooth bg-surface border border-border-strong rounded-lg";
-
- return (
-
- {showHistory && (
-
- Recent searches
- {onClearAll && (
-
- Clear all
-
- )}
-
- )}
- {items.map((item, index) => (
-
- onSelect(item.label)}
- >
-
- {item.label}
- {item.source === "history" && !showHistory && (
-
- History
-
- )}
-
-
-
- ))}
-
- );
-}
diff --git a/apps/web/src/components/search-overlay.tsx b/apps/web/src/components/search-overlay.tsx
deleted file mode 100644
index 2bfbb0cd..00000000
--- a/apps/web/src/components/search-overlay.tsx
+++ /dev/null
@@ -1,174 +0,0 @@
-import { useRouterState } from "@tanstack/react-router";
-import { ArrowLeft } from "lucide-react";
-import { useEffect, useRef, useState } from "react";
-import { useDebouncedValue } from "../hooks/use-debounced-value";
-import { useSearchHistory } from "../hooks/use-search-history";
-import { useSearchOverlayNavigation } from "../hooks/use-search-overlay-navigation";
-import { fetchSuggestions } from "../lib/api-suggestions";
-import { buildSearchOverlayItems } from "../lib/search-overlay-items";
-import {
- resolveInitialSearchOverlayQuery,
- writeSearchOverlayQuery,
-} from "../lib/search-overlay-query";
-import { ConfirmModal } from "./confirm-modal";
-import { SearchOverlayList } from "./search-overlay-list";
-
-type Props = {
- onClose: () => void;
-};
-
-export function SearchOverlay({ onClose }: Props) {
- const location = useRouterState({ select: (state) => state.location });
- const [query, setQuery] = useState(() =>
- resolveInitialSearchOverlayQuery(location.pathname, location.searchStr),
- );
- const [suggestions, setSuggestions] = useState([]);
- const [selectedIndex, setSelectedIndex] = useState(-1);
- const [confirmClearOpen, setConfirmClearOpen] = useState(false);
- const inputRef = useRef(null);
- const listRef = useRef(null);
- const { service, navigateAndClose } = useSearchOverlayNavigation({ onClose });
- const { visibleItems, canLoadMore, loadMore, clear } = useSearchHistory();
- const debouncedQuery = useDebouncedValue(query, 300);
- const items = buildSearchOverlayItems(query, visibleItems, suggestions);
-
- useEffect(() => {
- const frame = requestAnimationFrame(() => inputRef.current?.focus());
- return () => cancelAnimationFrame(frame);
- }, []);
-
- useEffect(() => {
- const handleKey = (e: KeyboardEvent) => {
- if (e.key === "Escape") onClose();
- };
- window.addEventListener("keydown", handleKey);
- return () => window.removeEventListener("keydown", handleKey);
- }, [onClose]);
-
- useEffect(() => {
- if (!debouncedQuery.trim()) {
- setSuggestions([]);
- return;
- }
- let cancelled = false;
- fetchSuggestions(debouncedQuery.trim(), service)
- .then((s) => {
- if (!cancelled) setSuggestions(s);
- })
- .catch(() => {});
- return () => {
- cancelled = true;
- };
- }, [debouncedQuery, service]);
-
- useEffect(() => {
- if (selectedIndex < 0) return;
- const element = listRef.current?.querySelector(
- `button[data-item-index="${selectedIndex}"]`,
- );
- element?.scrollIntoView({ block: "nearest", behavior: "smooth" });
- }, [selectedIndex]);
-
- const showHistory = query.trim().length === 0 && visibleItems.length > 0;
-
- function submitTerm(term: string) {
- const trimmed = term.trim();
- if (!trimmed) return;
- writeSearchOverlayQuery(trimmed);
- navigateAndClose(trimmed);
- }
-
- function handleSubmit(e: React.FormEvent) {
- e.preventDefault();
- if (selectedIndex >= 0 && items[selectedIndex]) {
- submitTerm(items[selectedIndex].label);
- return;
- }
- submitTerm(query);
- }
-
- function handleKeyDown(e: React.KeyboardEvent) {
- if (items.length === 0) return;
- if (e.key === "ArrowDown") {
- e.preventDefault();
- setSelectedIndex((index) => (index >= items.length - 1 ? 0 : index + 1));
- } else if (e.key === "ArrowUp") {
- e.preventDefault();
- setSelectedIndex((index) => (index <= 0 ? items.length - 1 : index - 1));
- } else if (e.key === "Tab") {
- const selected = selectedIndex >= 0 ? items[selectedIndex] : items[0];
- if (!selected) return;
- e.preventDefault();
- setQuery(selected.label);
- setSelectedIndex(-1);
- }
- }
-
- function handleHistoryScroll(e: React.UIEvent) {
- if (!showHistory || !canLoadMore) return;
- const target = e.currentTarget;
- const threshold = target.scrollHeight - target.clientHeight - 24;
- if (target.scrollTop >= threshold) {
- loadMore();
- }
- }
-
- function handleConfirmClear() {
- clear.mutate();
- setConfirmClearOpen(false);
- }
-
- return (
-
-
-
-
-
-
- {
- setQuery(e.target.value);
- writeSearchOverlayQuery(e.target.value);
- setSelectedIndex(-1);
- }}
- onKeyDown={handleKeyDown}
- placeholder="Search videos, channels..."
- className="h-10 min-w-0 flex-1 rounded-full bg-surface-strong px-4 text-base text-fg placeholder:text-fg-soft focus:outline-none"
- />
-
-
- setConfirmClearOpen(true)}
- onSelect={submitTerm}
- className="max-h-full overflow-y-auto scroll-smooth rounded-xl border border-border bg-surface"
- />
-
-
- {confirmClearOpen && (
-
setConfirmClearOpen(false)}
- />
- )}
-
- );
-}
diff --git a/apps/web/src/components/search-results-grid.tsx b/apps/web/src/components/search-results-grid.tsx
deleted file mode 100644
index ecc17c8f..00000000
--- a/apps/web/src/components/search-results-grid.tsx
+++ /dev/null
@@ -1,51 +0,0 @@
-import type { ChannelResultItem } from "../types/api";
-import type { PublicPlaylistInfo } from "../types/playlist";
-import type { VideoStream } from "../types/stream";
-import { PublicPlaylistCard } from "./public-playlist-card";
-import { SearchChannelCard } from "./search-channel-card";
-import { VideoCard } from "./video-card";
-
-export type SearchResultItem =
- | { kind: "video"; stream: VideoStream }
- | { kind: "channel"; channel: ChannelResultItem }
- | { kind: "playlist"; playlist: PublicPlaylistInfo };
-
-function itemKey(item: SearchResultItem): string {
- if (item.kind === "video") return item.stream.id;
- if (item.kind === "channel") return item.channel.url;
- return item.playlist.url;
-}
-
-function ItemCard({
- item,
- relatedStreams,
-}: {
- item: SearchResultItem;
- relatedStreams: VideoStream[];
-}) {
- if (item.kind === "video")
- return ;
- if (item.kind === "channel") return ;
- return ;
-}
-
-type Props = {
- items: SearchResultItem[];
-};
-
-export function SearchResultsGrid({ items }: Props) {
- const relatedStreams = items.flatMap((item) => (item.kind === "video" ? [item.stream] : []));
- return (
-
- {items.map((item, index) => (
-
-
-
- ))}
-
- );
-}
diff --git a/apps/web/src/components/service-icon.tsx b/apps/web/src/components/service-icon.tsx
deleted file mode 100644
index e2c11eff..00000000
--- a/apps/web/src/components/service-icon.tsx
+++ /dev/null
@@ -1,21 +0,0 @@
-type Props = {
- path: string;
- color: string;
- label: string;
-};
-
-export function ServiceIcon({ path, color, label }: Props) {
- return (
-
-
-
- );
-}
diff --git a/apps/web/src/components/shorts-action-button.tsx b/apps/web/src/components/shorts-action-button.tsx
deleted file mode 100644
index 92da127f..00000000
--- a/apps/web/src/components/shorts-action-button.tsx
+++ /dev/null
@@ -1,49 +0,0 @@
-type Props = {
- icon: React.ComponentType<{ className?: string }>;
- label: string;
- stateLabel?: string;
- active?: boolean;
- disabled?: boolean;
- compact?: boolean;
- onClick?: () => void;
-};
-
-export function ShortsActionButton({
- icon: Icon,
- label,
- stateLabel,
- active,
- disabled,
- compact,
- onClick,
-}: Props) {
- const sizeClass = compact ? "h-9 w-9" : "h-12 w-12";
- const iconClass = compact ? "h-4 w-4" : "h-6 w-6";
- const rootClass = compact
- ? "flex flex-col items-center gap-0.5 text-white/90 transition-colors hover:text-white disabled:cursor-not-allowed disabled:opacity-50"
- : "flex flex-col items-center gap-1 text-white/90 transition-colors hover:text-white disabled:cursor-not-allowed disabled:opacity-50";
- return (
-
-
-
-
-
- {stateLabel ?? label}
-
-
- );
-}
diff --git a/apps/web/src/components/shorts-actions.tsx b/apps/web/src/components/shorts-actions.tsx
deleted file mode 100644
index 3e7e2992..00000000
--- a/apps/web/src/components/shorts-actions.tsx
+++ /dev/null
@@ -1,113 +0,0 @@
-import { Clock3, MessageCircle, Share2, Star } from "lucide-react";
-import { useAuth } from "../hooks/use-auth";
-import { useFavoriteStatus } from "../hooks/use-favorite-status";
-import { useShareUrl } from "../hooks/use-share-url";
-import { useWatchLaterPlaylist } from "../hooks/use-watch-later-playlist";
-import { toPublicWatchUrl } from "../lib/watch-url";
-import type { VideoStream } from "../types/stream";
-import { ShortsActionButton } from "./shorts-action-button";
-
-type Props = {
- stream: VideoStream;
- onOpenComments: () => void;
- className?: string;
- compact?: boolean;
- showComments?: boolean;
-};
-
-export function ShortsActions({
- stream,
- onOpenComments,
- className,
- compact,
- showComments = true,
-}: Props) {
- const { isAuthed } = useAuth();
- const { copied, share } = useShareUrl();
- const {
- add: addFavorite,
- remove: removeFavorite,
- isFavorite: favorited,
- isPending: favoritesPending,
- } = useFavoriteStatus(stream.id);
- const {
- add: addWatchLater,
- remove: removeWatchLater,
- isInWatchLater,
- isPending: watchLaterPending,
- } = useWatchLaterPlaylist();
-
- const watchLater = isInWatchLater(stream.id);
-
- function requireAuth(): boolean {
- if (isAuthed) return true;
- const redirect = `/shorts?v=${encodeURIComponent(stream.id)}`;
- window.location.assign(`/login?redirect=${encodeURIComponent(redirect)}`);
- return false;
- }
-
- async function toggleFavorite() {
- if (!requireAuth()) return;
- if (favorited) {
- await removeFavorite();
- return;
- }
- await addFavorite();
- }
-
- async function toggleWatchLater() {
- if (!requireAuth()) return;
- if (watchLater) {
- await removeWatchLater(stream.id);
- return;
- }
- await addWatchLater({
- url: stream.id,
- title: stream.title,
- thumbnail: stream.rawThumbnail || stream.thumbnail,
- duration: stream.duration,
- });
- }
-
- function handleShare() {
- void share(toPublicWatchUrl(stream.id, window.location.origin));
- }
-
- return (
-
- void toggleFavorite()}
- />
- void toggleWatchLater()}
- />
- {showComments && (
-
- )}
-
-
- );
-}
diff --git a/apps/web/src/components/shorts-comments-sheet-slot.tsx b/apps/web/src/components/shorts-comments-sheet-slot.tsx
deleted file mode 100644
index 18634ede..00000000
--- a/apps/web/src/components/shorts-comments-sheet-slot.tsx
+++ /dev/null
@@ -1,22 +0,0 @@
-import { lazy, Suspense } from "react";
-
-const ShortsCommentsSheet = lazy(() =>
- import("../components/shorts-comments-sheet").then((module) => ({
- default: module.ShortsCommentsSheet,
- })),
-);
-
-type Props = {
- videoUrl: string;
- anchorEl: HTMLDivElement | null;
- open: boolean;
- onClose: () => void;
-};
-
-export function ShortsCommentsSheetSlot({ videoUrl, anchorEl, open, onClose }: Props) {
- return (
-
-
-
- );
-}
diff --git a/apps/web/src/components/shorts-comments-sheet.tsx b/apps/web/src/components/shorts-comments-sheet.tsx
deleted file mode 100644
index 2e5f1396..00000000
--- a/apps/web/src/components/shorts-comments-sheet.tsx
+++ /dev/null
@@ -1,154 +0,0 @@
-import { useCallback, useEffect, useMemo, useRef, useState } from "react";
-import { createPortal } from "react-dom";
-import { useInfiniteComments } from "../hooks/use-infinite-comments";
-import { ScrollSentinel } from "./scroll-sentinel";
-import { WatchComment } from "./watch-comment";
-import { WatchCommentSkeleton } from "./watch-comment-skeleton";
-
-const SKELETON_KEYS = Array.from({ length: 4 }, (_, i) => `scs-${i}`);
-const INITIAL_RENDER_COUNT = 20;
-const RENDER_STEP = 14;
-
-type Props = {
- videoUrl: string;
- anchorEl?: HTMLElement | null;
- open: boolean;
- onClose: () => void;
-};
-
-export function ShortsCommentsSheet({ videoUrl, anchorEl, open, onClose }: Props) {
- const scrollRef = useRef(null);
- const [isMounted, setIsMounted] = useState(false);
- const [renderCount, setRenderCount] = useState(INITIAL_RENDER_COUNT);
- const { data, isFetchingNextPage, hasNextPage, fetchNextPage, isLoading } = useInfiniteComments(
- videoUrl,
- open,
- );
-
- const loadMore = useCallback(() => {
- if (hasNextPage && !isFetchingNextPage) fetchNextPage();
- }, [hasNextPage, isFetchingNextPage, fetchNextPage]);
-
- useEffect(() => {
- if (!open) {
- const timer = setTimeout(() => setIsMounted(false), 260);
- return () => clearTimeout(timer);
- }
- setIsMounted(true);
- const prev = document.body.style.overflow;
- document.body.style.overflow = "hidden";
- function onKeyDown(e: KeyboardEvent) {
- if (e.key === "Escape") onClose();
- }
- document.addEventListener("keydown", onKeyDown);
- return () => {
- document.body.style.overflow = prev;
- document.removeEventListener("keydown", onKeyDown);
- };
- }, [open, onClose]);
-
- useEffect(() => {
- if (!open) return;
- scrollRef.current?.scrollTo({ top: 0, behavior: "auto" });
- setRenderCount(INITIAL_RENDER_COUNT);
- }, [open]);
-
- const desktopStyle = useMemo(() => {
- if (!anchorEl || typeof window === "undefined") {
- return {
- top: "5rem",
- left: "1rem",
- height: "calc(100svh - 7rem)",
- width: "min(26rem, calc(100vw - 2rem))",
- };
- }
- const rect = anchorEl.getBoundingClientRect();
- const gap = 14;
- const leftBoundary = 12;
- const available = rect.left - leftBoundary - gap;
- const width = Math.min(Math.max(available, 320), 460);
- return {
- top: `${rect.top}px`,
- left: `${Math.max(rect.left - width - gap, leftBoundary)}px`,
- height: `${rect.height}px`,
- width: `${width}px`,
- };
- }, [anchorEl]);
-
- const commentsDisabled = data?.pages[0]?.commentsDisabled ?? false;
- const comments =
- data?.pages.flatMap((p) => p.comments).filter((comment) => comment.text && comment.author) ??
- [];
- const visibleComments = useMemo(() => comments.slice(0, renderCount), [comments, renderCount]);
- const hasHiddenComments = visibleComments.length < comments.length;
- const revealMore = useCallback(() => {
- if (!hasHiddenComments) return;
- setRenderCount((count) => Math.min(count + RENDER_STEP, comments.length));
- }, [hasHiddenComments, comments.length]);
-
- if (!isMounted) return null;
-
- return createPortal(
- <>
-
- = 768 ? desktopStyle.top : undefined,
- left: window.innerWidth >= 768 ? desktopStyle.left : undefined,
- height: window.innerWidth >= 768 ? desktopStyle.height : undefined,
- width: window.innerWidth >= 768 ? desktopStyle.width : undefined,
- transform: open ? "translateY(0) scale(1)" : "translateY(20px) scale(0.98)",
- opacity: open ? 1 : 0,
- }}
- >
-
-
Comments
-
- Close
-
-
-
-
- {commentsDisabled ? (
-
Comments are disabled for this video.
- ) : (
- <>
- {visibleComments.map((comment, i) => (
-
-
-
- ))}
-
- {(isLoading || isFetchingNextPage) &&
- SKELETON_KEYS.map((key) =>
)}
-
- >
- )}
-
-
- >,
- document.body,
- );
-}
diff --git a/apps/web/src/components/shorts-error.tsx b/apps/web/src/components/shorts-error.tsx
deleted file mode 100644
index bdf36062..00000000
--- a/apps/web/src/components/shorts-error.tsx
+++ /dev/null
@@ -1,31 +0,0 @@
-type Props = {
- message: string;
- onRetry: () => void;
- onNext: () => void;
-};
-
-export function ShortsError({ message, onRetry, onNext }: Props) {
- return (
-
-
-
{message}
-
-
- Retry
-
-
- Next
-
-
-
-
- );
-}
diff --git a/apps/web/src/components/shorts-info-overlay.tsx b/apps/web/src/components/shorts-info-overlay.tsx
deleted file mode 100644
index 219b923f..00000000
--- a/apps/web/src/components/shorts-info-overlay.tsx
+++ /dev/null
@@ -1,152 +0,0 @@
-import { useEffect, useState } from "react";
-import { useAuth } from "../hooks/use-auth";
-import { useSubscriptions } from "../hooks/use-subscriptions";
-import type { VideoStream } from "../types/stream";
-import { ChannelAvatar } from "./channel-avatar";
-import { ChannelRouteLink } from "./channel-route-link";
-import { Toast } from "./toast";
-
-type Props = {
- stream: VideoStream;
- variant?: "overlay" | "panel";
- className?: string;
-};
-
-export function ShortsInfoOverlay({ stream, variant = "overlay", className }: Props) {
- const { isAuthed } = useAuth();
- const { add, remove, isSubscribed } = useSubscriptions();
- const [toastMsg, setToastMsg] = useState(null);
- const subscribed = stream.channelUrl ? isSubscribed(stream.channelUrl) : false;
-
- useEffect(() => {
- if (!toastMsg) return;
- const timer = setTimeout(() => setToastMsg(null), 2000);
- return () => clearTimeout(timer);
- }, [toastMsg]);
-
- async function handleSubscribe() {
- if (!stream.channelUrl) return;
- if (!isAuthed) {
- const redirect = `${window.location.pathname}${window.location.search}`;
- window.location.assign(`/login?redirect=${encodeURIComponent(redirect)}`);
- return;
- }
- try {
- if (subscribed) {
- await remove.mutateAsync(stream.channelUrl);
- setToastMsg(`Unsubscribed from ${stream.channelName}`);
- return;
- }
- await add.mutateAsync({
- channelUrl: stream.channelUrl,
- name: stream.channelName,
- avatarUrl: stream.channelAvatar,
- });
- setToastMsg(`Subscribed to ${stream.channelName}`);
- } catch {
- setToastMsg("Subscription update failed");
- }
- }
-
- const panelButtonClass = `rounded-full px-4 py-1.5 text-sm font-semibold transition-colors disabled:cursor-not-allowed disabled:opacity-60 ${
- subscribed
- ? "border border-border-strong bg-surface-strong text-fg hover:bg-surface-soft"
- : "bg-fg text-app hover:bg-white"
- }`;
-
- const overlayButtonClass = `ml-1 rounded-full px-3 py-1 text-xs font-semibold transition-colors disabled:cursor-not-allowed disabled:opacity-60 ${
- subscribed
- ? "border border-border-strong bg-surface-strong text-fg hover:bg-surface-soft"
- : "bg-fg text-app hover:bg-white"
- }`;
-
- if (variant === "panel") {
- return (
-
-
- {stream.title}
-
-
-
-
-
-
-
-
-
- {stream.channelName}
-
-
- {stream.channelUrl && (
-
- {subscribed ? "Subscribed" : "Subscribe"}
-
- )}
-
-
-
-
-
- );
- }
-
- return (
-
-
-
-
-
-
-
-
- {stream.channelName}
-
-
- {stream.channelUrl && (
-
- {subscribed ? "Subscribed" : "Subscribe"}
-
- )}
-
-
{stream.title}
-
-
-
- );
-}
-
-type ChannelLinkProps = {
- url?: string;
- children: React.ReactNode;
-};
-
-function ChannelLink({ url, children }: ChannelLinkProps) {
- if (!url) return <>{children}>;
- return (
-
- {children}
-
- );
-}
diff --git a/apps/web/src/components/shorts-navigation.tsx b/apps/web/src/components/shorts-navigation.tsx
deleted file mode 100644
index 2c1783d9..00000000
--- a/apps/web/src/components/shorts-navigation.tsx
+++ /dev/null
@@ -1,38 +0,0 @@
-import { ChevronDown, ChevronUp } from "lucide-react";
-
-type Props = {
- onPrev: () => void;
- onNext: () => void;
- hasPrev: boolean;
- hasNext: boolean;
-};
-
-export function ShortsNavigation({ onPrev, onNext, hasPrev, hasNext }: Props) {
- return (
-
-
-
-
- );
-}
-
-type NavButtonProps = {
- icon: React.ComponentType<{ className?: string }>;
- onClick: () => void;
- disabled: boolean;
- label: string;
-};
-
-function NavButton({ icon: Icon, onClick, disabled, label }: NavButtonProps) {
- return (
-
-
-
- );
-}
diff --git a/apps/web/src/components/shorts-player-shell.tsx b/apps/web/src/components/shorts-player-shell.tsx
deleted file mode 100644
index 9c2fa8a0..00000000
--- a/apps/web/src/components/shorts-player-shell.tsx
+++ /dev/null
@@ -1,131 +0,0 @@
-import { useRef, useState } from "react";
-import { ShortsPlayerStage } from "../components/shorts-player-stage";
-import { ShortsShellLoader } from "../components/shorts-shell-loader";
-import { useMobile } from "../hooks/use-mobile";
-import { useSettings } from "../hooks/use-settings";
-import { useShortsActiveStream } from "../hooks/use-shorts-active-stream";
-import { useShortsFeed } from "../hooks/use-shorts-feed";
-import { useShortsPrefetch } from "../hooks/use-shorts-prefetch";
-import { useShortsRouteSync } from "../hooks/use-shorts-route-sync";
-import { useVolumeSync } from "../hooks/use-volume-sync";
-import {
- getOriginalAudioLocale,
- getOriginalAudioTrackId,
- getPreferredDefaultAudioTrackId,
-} from "../lib/audio-track";
-import { useShortsNavigation } from "../lib/shorts-navigation";
-import { useUiStore } from "../stores/ui-store";
-
-type Props = {
- targetUrl?: string;
-};
-
-export function ShortsPlayerShell({ targetUrl }: Props) {
- const isMobile = useMobile();
- const { shorts, isLoading, hasNextPage, isFetchingNextPage, fetchNextPage } = useShortsFeed();
- const { settings, update, query: settingsQuery } = useSettings();
- const sidebarCollapsed = useUiStore((s) => s.sidebarCollapsed);
- const playerRef = useRef(null);
- const [commentsOpen, setCommentsOpen] = useState(false);
- const settingsReady =
- (settingsQuery.isSuccess && !settingsQuery.isPlaceholderData) || settingsQuery.isError;
-
- const handleAutoNext = () => {
- moveBy(1, "auto");
- };
- const { index, moveBy, moveTo, onWheel, onTouchStart, onTouchEnd } = useShortsNavigation(
- shorts.length,
- hasNextPage,
- isFetchingNextPage,
- fetchNextPage,
- );
- const { active, activeId, stream, streamQuery, current, errorMessage, isMemberOnlyShort } =
- useShortsActiveStream({ shorts, index });
- const originalAudioTrackId = getOriginalAudioTrackId(stream);
- const preferredDefaultAudioTrackId = getPreferredDefaultAudioTrackId(stream);
- const originalAudioLocale = getOriginalAudioLocale(stream);
- const onVolumeChange = useVolumeSync(update.mutate);
- useShortsPrefetch(
- shorts.map((item) => item.id),
- index,
- );
-
- useShortsRouteSync({
- targetUrl,
- shorts,
- index,
- hasNextPage,
- isFetchingNextPage,
- fetchNextPage: () => void fetchNextPage(),
- moveTo,
- activeId,
- onActiveChange: () => setCommentsOpen(false),
- });
-
- const sectionClass = `h-[calc(100svh-4.5rem)] overflow-hidden px-2 pb-2 pt-1 sm:px-4 sm:pb-4 sm:pt-3 ${
- isMobile ? "pl-2" : sidebarCollapsed ? "md:pl-16" : "md:pl-52"
- }`;
- if (isLoading) return ;
- if (!active) {
- return (
-
-
No shorts available right now.
-
- );
- }
- const hasPrev = index > 0;
- const hasNext = index < shorts.length - 1 || hasNextPage;
-
- const handleWheel = (e: React.WheelEvent) => {
- const target = e.target as HTMLElement;
- const isMenu = target.closest("[role='menu'], .vds-menu-items") !== null;
- if (!isMenu) onWheel(e.deltaY);
- };
-
- const handleTouchStart = (clientY: number | null, target: EventTarget | null) => {
- onTouchStart(clientY, target);
- };
-
- const handleTouchEnd = (clientY: number | null, target: EventTarget | null) => {
- onTouchEnd(clientY, target);
- };
-
- return (
- setCommentsOpen(true)}
- onCloseComments={() => setCommentsOpen(false)}
- onRetry={() => streamQuery.refetch()}
- onNext={() => moveBy(1, "user")}
- onAutoNext={handleAutoNext}
- onPrev={() => moveBy(-1, "user")}
- onWheel={handleWheel}
- onTouchStart={handleTouchStart}
- onTouchEnd={handleTouchEnd}
- onVolumeChange={onVolumeChange}
- />
- );
-}
diff --git a/apps/web/src/components/shorts-player-stage.tsx b/apps/web/src/components/shorts-player-stage.tsx
deleted file mode 100644
index f4d77a8e..00000000
--- a/apps/web/src/components/shorts-player-stage.tsx
+++ /dev/null
@@ -1,178 +0,0 @@
-import { PageSpinner } from "../components/page-spinner";
-import { ShortsActions } from "../components/shorts-actions";
-import { ShortsCommentsSheetSlot } from "../components/shorts-comments-sheet-slot";
-import { ShortsError } from "../components/shorts-error";
-import { ShortsInfoOverlay } from "../components/shorts-info-overlay";
-import { ShortsNavigation } from "../components/shorts-navigation";
-import { ShortsVideoPlayer } from "../components/shorts-video-player";
-import { resolveManifestSrc } from "../lib/stream-src";
-import type { VideoStream } from "../types/stream";
-
-type Props = {
- sectionClass: string;
- playerRef: React.RefObject;
- commentsOpen: boolean;
- active: VideoStream;
- current: VideoStream;
- stream: VideoStream | undefined;
- streamLoading: boolean;
- streamError: boolean;
- errorMessage: string;
- isMemberOnlyShort: boolean;
- hasPrev: boolean;
- hasNext: boolean;
- settingsReady: boolean;
- autoplay: boolean;
- initialVolume: number;
- initialMuted: boolean;
- defaultAudioLanguage?: string;
- preferOriginalLanguage?: boolean;
- originalAudioTrackId?: string | null;
- preferredDefaultAudioTrackId?: string | null;
- originalAudioLocale?: string | null;
- defaultSubtitleLanguage?: string;
- subtitlesEnabled?: boolean;
- showComments: boolean;
- onOpenComments: () => void;
- onCloseComments: () => void;
- onRetry: () => void;
- onNext: () => void;
- onAutoNext: () => void;
- onPrev: () => void;
- onWheel: (event: React.WheelEvent) => void;
- onTouchStart: (clientY: number | null, target: EventTarget | null) => void;
- onTouchEnd: (clientY: number | null, target: EventTarget | null) => void;
- onVolumeChange: (volume: number, muted: boolean) => void;
-};
-export function ShortsPlayerStage({
- sectionClass,
- playerRef,
- commentsOpen,
- active,
- current,
- stream,
- streamLoading,
- streamError,
- errorMessage,
- isMemberOnlyShort,
- hasPrev,
- hasNext,
- settingsReady,
- autoplay,
- initialVolume,
- initialMuted,
- defaultAudioLanguage,
- preferOriginalLanguage,
- originalAudioTrackId,
- preferredDefaultAudioTrackId,
- originalAudioLocale,
- defaultSubtitleLanguage,
- subtitlesEnabled,
- showComments,
- onOpenComments,
- onCloseComments,
- onRetry,
- onNext,
- onAutoNext,
- onPrev,
- onWheel,
- onTouchStart,
- onTouchEnd,
- onVolumeChange,
-}: Props) {
- const shouldAutoplay = autoplay && !streamError;
- const playerSrc = stream
- ? resolveManifestSrc(stream, false, false, {
- compactAudioTracks: true,
- preferredAudioLanguage: preferOriginalLanguage ? undefined : defaultAudioLanguage,
- preferOriginalLanguage,
- maxCompactAudioTracks: 3,
- })
- : undefined;
- return (
-
-
-
-
-
-
-
!commentsOpen && onWheel(event)}
- onTouchStart={(e) =>
- !commentsOpen && onTouchStart(e.touches[0]?.clientY ?? null, e.target)
- }
- onTouchEnd={(e) =>
- !commentsOpen && onTouchEnd(e.changedTouches[0]?.clientY ?? null, e.target)
- }
- >
- {!streamError && streamLoading && (
-
- )}
- {streamError && (
-
- )}
- {stream && !streamError && playerSrc && (
-
- )}
-
-
-
-
-
-
-
-
-
-
-
- {showComments && (
-
- )}
-
- );
-}
diff --git a/apps/web/src/components/shorts-shell-loader.tsx b/apps/web/src/components/shorts-shell-loader.tsx
deleted file mode 100644
index 932bf240..00000000
--- a/apps/web/src/components/shorts-shell-loader.tsx
+++ /dev/null
@@ -1,17 +0,0 @@
-import { PageSpinner } from "./page-spinner";
-
-type Props = {
- sectionClass: string;
-};
-
-export function ShortsShellLoader({ sectionClass }: Props) {
- return (
-
- );
-}
diff --git a/apps/web/src/components/shorts-video-player.tsx b/apps/web/src/components/shorts-video-player.tsx
deleted file mode 100644
index e6c2fc55..00000000
--- a/apps/web/src/components/shorts-video-player.tsx
+++ /dev/null
@@ -1,157 +0,0 @@
-import { useEffect, useState } from "react";
-import { isIosDevice } from "../lib/ios-device";
-import type { MediaSrc } from "../lib/vidstack";
-import {
- DefaultVideoLayout,
- defaultLayoutIcons,
- MediaPlayer,
- MediaProvider,
- Track,
-} from "../lib/vidstack";
-import type { SubtitleItem } from "../types/api";
-import { AudioTrackSelector } from "./audio-track-selector";
-import { MediaSessionSync } from "./media-session-sync";
-import { PlayerDefaults } from "./player-defaults";
-import { buildSafeSubtitleTracks } from "./subtitle-track-utils";
-import { Toast } from "./toast";
-import { onProviderChange } from "./video-player-core";
-import { VolumeRestorer } from "./volume-restorer";
-
-type Props = {
- src: MediaSrc;
- title?: string;
- poster?: string;
- subtitles?: SubtitleItem[];
- initialVolume?: number;
- initialMuted?: boolean;
- settingsReady?: boolean;
- autoplay?: boolean;
- defaultAudioLanguage?: string;
- preferOriginalLanguage?: boolean;
- originalAudioTrackId?: string | null;
- preferredDefaultAudioTrackId?: string | null;
- originalAudioLocale?: string | null;
- defaultSubtitleLanguage?: string;
- subtitlesEnabled?: boolean;
- onVolumeChange?: (volume: number, muted: boolean) => void;
- onError?: () => void;
- onEnded?: () => void;
-};
-
-export function ShortsVideoPlayer({
- src,
- title,
- poster,
- subtitles,
- initialVolume = 1,
- initialMuted = false,
- settingsReady = false,
- autoplay = true,
- defaultAudioLanguage,
- preferOriginalLanguage,
- originalAudioTrackId,
- preferredDefaultAudioTrackId,
- originalAudioLocale,
- defaultSubtitleLanguage,
- subtitlesEnabled,
- onVolumeChange,
- onError,
- onEnded,
-}: Props) {
- const ios = isIosDevice();
- const srcKey = typeof src === "string" ? src : String(src.src);
- const subtitleTracks = buildSafeSubtitleTracks(subtitles);
- const shouldPreferOriginalLanguage = preferOriginalLanguage ?? true;
- const [toast, setToast] = useState(null);
-
- useEffect(() => {
- if (!toast) return;
- const timer = setTimeout(() => setToast(null), 2600);
- return () => clearTimeout(timer);
- }, [toast]);
-
- return (
-
-
onError?.()}
- onEnded={() => onEnded?.()}
- style={{
- position: "absolute",
- inset: 0,
- width: "100%",
- height: "100%",
- "--media-object-fit": "cover",
- }}
- >
-
- {subtitleTracks.map((s) => (
-
- ))}
-
- ,
- }}
- />
- {
- setToast("Original audio unavailable");
- }}
- originalAudioTrackId={originalAudioTrackId}
- preferredDefaultAudioTrackId={preferredDefaultAudioTrackId}
- originalAudioLocale={originalAudioLocale}
- defaultSubtitleLanguage={defaultSubtitleLanguage}
- subtitlesEnabled={subtitlesEnabled}
- />
-
-
-
-
-
- );
-}
diff --git a/apps/web/src/components/sidebar.tsx b/apps/web/src/components/sidebar.tsx
deleted file mode 100644
index 3ce64d66..00000000
--- a/apps/web/src/components/sidebar.tsx
+++ /dev/null
@@ -1,177 +0,0 @@
-import { Link, useNavigate, useRouterState } from "@tanstack/react-router";
-import { siBilibili, siNiconico, siYoutube } from "simple-icons";
-import { useAuth } from "../hooks/use-auth";
-import { useMobile } from "../hooks/use-mobile";
-import { useSettings } from "../hooks/use-settings";
-import { getStoredAdminSection } from "../lib/admin-console-section";
-import { logoutSession } from "../lib/auth-session";
-import { useUiStore } from "../stores/ui-store";
-import type { ServiceId } from "../types/user";
-import { NAV_ITEMS } from "./nav-items";
-import { ServiceIcon } from "./service-icon";
-
-type Service = {
- id: ServiceId;
- label: string;
- path: string;
- color: string;
-};
-
-const SERVICES: Service[] = [
- { id: 0, label: "YouTube", path: siYoutube.path, color: "#FF0000" },
- { id: 6, label: "NicoNico", path: siNiconico.path, color: "#aaaaaa" },
- { id: 5, label: "BiliBili", path: siBilibili.path, color: "#00A1D6" },
-];
-
-const BTN_BASE = "flex items-center h-10 rounded-lg transition-colors w-full";
-const BTN_ACTIVE = "text-fg bg-surface-strong";
-const BTN_INACTIVE = "text-fg-muted hover:text-fg hover:bg-surface-strong";
-
-type Props = {
- overlay?: boolean;
-};
-
-function NavIcon({ children, label }: { children: React.ReactNode; label: string }) {
- return (
-
- {children}
-
- );
-}
-
-export function Sidebar({ overlay = false }: Props) {
- const isMobile = useMobile();
- const collapsed = useUiStore((s) => s.sidebarCollapsed);
- const mobileOpen = useUiStore((s) => s.mobileSidebarOpen);
- const closeMobileSidebar = useUiStore((s) => s.closeMobileSidebar);
- const visualCollapsed = overlay ? false : collapsed;
- const { isAdmin, isAuthed, signOut } = useAuth();
- const { settings, update } = useSettings();
- const service = settings.defaultService;
- const navigate = useNavigate();
- const loc = useRouterState({ select: (s) => s.location });
-
- function handleServiceClick(id: ServiceId) {
- update.mutate({ defaultService: id });
- if (isMobile) closeMobileSidebar();
- if (loc.pathname !== "/search") return;
- const q = new URLSearchParams(loc.searchStr).get("q") ?? "";
- navigate({ to: "/search", search: { q, service: id } });
- }
-
- const adminSearch = { section: getStoredAdminSection() };
- const navItems = NAV_ITEMS.filter((item) => {
- if (item.adminOnly && !isAdmin) return false;
- if (item.to === "/shorts" && settings.hideShorts) return false;
- return true;
- });
-
- const desktopShell = overlay
- ? "z-50 border-border border-r bg-app/95 shadow-2xl backdrop-blur"
- : "z-40 border-r border-border bg-app";
- const desktopMotion = overlay
- ? collapsed
- ? "pointer-events-none -translate-x-full"
- : "translate-x-0"
- : "";
- const baseClasses = isMobile
- ? `fixed top-14 left-0 bottom-0 z-50 w-72 max-w-[85vw] border-r border-border bg-app flex flex-col py-4 pb-[calc(env(safe-area-inset-bottom)+1rem)] transition-transform duration-200 ${
- mobileOpen ? "translate-x-0" : "-translate-x-full"
- }`
- : `fixed top-14 left-0 bottom-0 ${desktopShell} ${desktopMotion} flex flex-col py-4 transition-all duration-200 ${
- visualCollapsed ? "w-14" : "w-48"
- }`;
- const sectionPadding = isMobile ? "px-3" : "px-2";
- const itemLayout = isMobile
- ? "justify-start gap-3 px-2"
- : visualCollapsed
- ? "justify-center px-0"
- : "gap-3 px-2";
-
- if (isMobile && !mobileOpen) return null;
-
- return (
- <>
- {isMobile && (
-
- )}
-
-
- {navItems.map((item) => (
-
- {item.icon}
- {(!visualCollapsed || isMobile) && (
- {item.label}
- )}
-
- ))}
-
-
-
- {(!visualCollapsed || isMobile) && (
-
Services
- )}
- {SERVICES.map((svc) => (
-
handleServiceClick(svc.id)}
- className={`${BTN_BASE} ${
- isMobile
- ? "justify-start gap-3 px-2 text-left"
- : visualCollapsed
- ? "justify-center px-0"
- : "gap-3 px-2 text-left"
- } ${service === svc.id ? BTN_ACTIVE : BTN_INACTIVE}`}
- >
-
- {(!visualCollapsed || isMobile) && {svc.label} }
-
- ))}
-
- {isMobile && isAuthed && (
-
- {
- void logoutSession();
- signOut();
- closeMobileSidebar();
- }}
- className="inline-flex h-10 w-full items-center justify-center rounded-lg border border-border-strong bg-surface text-sm font-medium text-fg hover:bg-surface-strong"
- >
- Sign out
-
-
- )}
-
- >
- );
-}
diff --git a/apps/web/src/components/sponsorblock-bar.tsx b/apps/web/src/components/sponsorblock-bar.tsx
deleted file mode 100644
index 10321118..00000000
--- a/apps/web/src/components/sponsorblock-bar.tsx
+++ /dev/null
@@ -1,96 +0,0 @@
-import { useEffect, useRef } from "react";
-import {
- getSponsorBlockCategoryColor,
- getSponsorBlockEndTime,
- getSponsorBlockStartTime,
-} from "../lib/sponsorblock-settings";
-import { useMediaState } from "../lib/vidstack";
-import type { SponsorBlockSegmentItem } from "../types/api";
-
-const TRACK_HEIGHT = 3;
-const THUMB_MARGIN = 7.5;
-
-type SegmentBarProps = {
- segment: SponsorBlockSegmentItem;
- duration: number;
-};
-
-function SegmentBar({ segment, duration }: SegmentBarProps) {
- const color = getSponsorBlockCategoryColor(segment.category);
- if (!color) return null;
- const startTime = getSponsorBlockStartTime(segment, duration);
- const endTime = getSponsorBlockEndTime(segment, duration);
- const left = (startTime / duration) * 100;
- const width = ((endTime - startTime) / duration) * 100;
- return (
-
- );
-}
-
-type Props = { segments: SponsorBlockSegmentItem[] };
-
-export function SponsorBlockBar({ segments }: Props) {
- const duration = useMediaState("duration");
- const controlsVisible = useMediaState("controlsVisible");
- const anchorRef = useRef(null);
- const overlayRef = useRef(null);
-
- useEffect(() => {
- const anchor = anchorRef.current;
- const overlay = overlayRef.current;
- if (!anchor || !overlay || !duration) return;
-
- const player = anchor.closest("[data-media-player]");
- const slider = player?.querySelector(".vds-time-slider");
- if (!player || !slider) return;
-
- const update = () => {
- const pRect = player.getBoundingClientRect();
- const sRect = slider.getBoundingClientRect();
- const trackCenterY = sRect.top - pRect.top + sRect.height / 2;
- overlay.style.top = `${trackCenterY - TRACK_HEIGHT / 2}px`;
- overlay.style.left = `${sRect.left - pRect.left + THUMB_MARGIN}px`;
- overlay.style.width = `${sRect.width - THUMB_MARGIN * 2}px`;
- };
-
- const ro = new ResizeObserver(update);
- ro.observe(player);
- update();
- return () => ro.disconnect();
- }, [duration]);
-
- if (!duration || segments.length === 0) return null;
-
- return (
- <>
-
-
- {segments.map((seg) => (
-
- ))}
-
- >
- );
-}
diff --git a/apps/web/src/components/sponsorblock-current-segment.tsx b/apps/web/src/components/sponsorblock-current-segment.tsx
deleted file mode 100644
index 4ec97146..00000000
--- a/apps/web/src/components/sponsorblock-current-segment.tsx
+++ /dev/null
@@ -1,142 +0,0 @@
-import { BadgeInfo, SkipForward } from "lucide-react";
-import { useEffect, useRef, useState } from "react";
-import { seekSponsorBlockSegment } from "../lib/sponsorblock-seek";
-import {
- getSponsorBlockCategoryLabel,
- getSponsorBlockEndTime,
- getSponsorBlockStartTime,
-} from "../lib/sponsorblock-settings";
-import {
- emitSponsorBlockSkip,
- isSponsorBlockEndSkip,
- sponsorBlockSkipTarget,
-} from "../lib/sponsorblock-skip";
-import { useMediaPlayer, useMediaRemote } from "../lib/vidstack";
-import type { SponsorBlockSegmentItem } from "../types/api";
-
-type Props = {
- segments: SponsorBlockSegmentItem[];
- autoSkipSegments?: SponsorBlockSegmentItem[];
- manualSkipSegments?: SponsorBlockSegmentItem[];
- muteInsteadOfSkip: boolean;
-};
-
-type ActiveSegment = {
- segment: SponsorBlockSegmentItem;
- duration: number;
- media: HTMLMediaElement;
-};
-
-function includesSegment(
- segments: SponsorBlockSegmentItem[] | undefined,
- segment: SponsorBlockSegmentItem,
-) {
- return segments?.some(
- (item) => item.category === segment.category && item.startTime === segment.startTime,
- );
-}
-
-export function SponsorBlockCurrentSegment({
- segments,
- autoSkipSegments,
- manualSkipSegments,
- muteInsteadOfSkip,
-}: Props) {
- const remote = useMediaRemote();
- const player = useMediaPlayer();
- const [active, setActive] = useState(null);
- const activeKeyRef = useRef("");
-
- useEffect(() => {
- const root = player?.el;
- if (!root) return;
- const rootElement = root;
- let cleanup: (() => void) | null = null;
-
- function updateActive(media: HTMLMediaElement) {
- const duration = Number.isFinite(media.duration) ? media.duration : 0;
- const currentTime = Number.isFinite(media.currentTime) ? media.currentTime : 0;
- const segment = segments.find(
- (item) =>
- currentTime >= getSponsorBlockStartTime(item, duration) &&
- currentTime < getSponsorBlockEndTime(item, duration),
- );
- const nextKey = segment ? `${segment.category}:${segment.startTime}:${duration}` : "";
- if (nextKey === activeKeyRef.current) return;
- activeKeyRef.current = nextKey;
- setActive(segment ? { segment, duration, media } : null);
- }
-
- function attach() {
- if (cleanup) return true;
- const media = rootElement.querySelector("video,audio");
- if (!media) return false;
- const update = () => updateActive(media);
- media.addEventListener("timeupdate", update);
- media.addEventListener("seeking", update);
- media.addEventListener("durationchange", update);
- media.addEventListener("loadedmetadata", update);
- update();
- cleanup = () => {
- media.removeEventListener("timeupdate", update);
- media.removeEventListener("seeking", update);
- media.removeEventListener("durationchange", update);
- media.removeEventListener("loadedmetadata", update);
- };
- return true;
- }
-
- if (attach()) return () => cleanup?.();
- const observer = new MutationObserver(() => {
- if (attach()) observer.disconnect();
- });
- observer.observe(rootElement, { childList: true, subtree: true });
- return () => {
- observer.disconnect();
- cleanup?.();
- };
- }, [player, segments]);
-
- if (!active) return null;
- const { segment: current, duration, media } = active;
- const autoSkip = includesSegment(autoSkipSegments, current);
- const canSkip = includesSegment(manualSkipSegments, current) || !autoSkip || muteInsteadOfSkip;
-
- const label = getSponsorBlockCategoryLabel(current.category);
-
- function skipCurrentSegment() {
- const endTime = getSponsorBlockEndTime(current, duration);
- emitSponsorBlockSkip({
- category: current.category,
- automatic: false,
- toEnd: isSponsorBlockEndSkip(endTime, duration),
- });
- seekSponsorBlockSegment(
- media instanceof HTMLVideoElement ? media : null,
- (seconds) => remote.seek(seconds),
- sponsorBlockSkipTarget(endTime, duration),
- );
- }
-
- return (
-
-
-
-
{label}
-
- SponsorBlock
-
-
- {canSkip && (
-
-
- Skip
-
- )}
-
- );
-}
diff --git a/apps/web/src/components/sponsorblock-skip-notice.tsx b/apps/web/src/components/sponsorblock-skip-notice.tsx
deleted file mode 100644
index 182bcc6b..00000000
--- a/apps/web/src/components/sponsorblock-skip-notice.tsx
+++ /dev/null
@@ -1,48 +0,0 @@
-import { useEffect, useRef, useState } from "react";
-import { getSponsorBlockCategoryLabel } from "../lib/sponsorblock-settings";
-import {
- SPONSORBLOCK_SKIP_EVENT,
- type SponsorBlockSkipNoticeDetail,
-} from "../lib/sponsorblock-skip";
-
-type Notice = SponsorBlockSkipNoticeDetail & {
- id: number;
-};
-
-export function SponsorBlockSkipNotice() {
- const [notice, setNotice] = useState(null);
- const timerRef = useRef | null>(null);
-
- useEffect(() => {
- const clearTimer = () => {
- if (!timerRef.current) return;
- clearTimeout(timerRef.current);
- timerRef.current = null;
- };
- const show = (event: WindowEventMap[typeof SPONSORBLOCK_SKIP_EVENT]) => {
- clearTimer();
- setNotice({ ...event.detail, id: Date.now() });
- timerRef.current = setTimeout(() => setNotice(null), 2600);
- };
- window.addEventListener(SPONSORBLOCK_SKIP_EVENT, show);
- return () => {
- clearTimer();
- window.removeEventListener(SPONSORBLOCK_SKIP_EVENT, show);
- };
- }, []);
-
- if (!notice) return null;
-
- const label = getSponsorBlockCategoryLabel(notice.category);
- const action = notice.automatic ? "Skipped automatically" : "Skipped";
-
- return (
-
-
{action}
-
- {label}
- {notice.toEnd ? " · ending video" : ""}
-
-
- );
-}
diff --git a/apps/web/src/components/stream-error.tsx b/apps/web/src/components/stream-error.tsx
deleted file mode 100644
index f7b58514..00000000
--- a/apps/web/src/components/stream-error.tsx
+++ /dev/null
@@ -1,84 +0,0 @@
-import { Link, useRouter } from "@tanstack/react-router";
-import { useAuth } from "../hooks/use-auth";
-import { FAMILY_LIST_BLOCKED_MESSAGE } from "../lib/allow-list-error";
-import { parseGeoRestriction } from "../lib/geo-restriction";
-import { isMemberOnlyMessage } from "../lib/member-only";
-import { FlagIcon } from "./flag-icon";
-import { YoutubeIcon } from "./youtube-icon";
-
-type Props = {
- message: string;
- onRetry?: () => void;
- youtubeSessionReturnTo?: string;
-};
-
-export function StreamError({ message, onRetry, youtubeSessionReturnTo }: Props) {
- const router = useRouter();
- const { canGlobalBlock } = useAuth();
- const countryCode = parseGeoRestriction(message);
- const isMemberOnly = isMemberOnlyMessage(message);
- const familyListBlocked = message === FAMILY_LIST_BLOCKED_MESSAGE;
- const imageSrc = familyListBlocked
- ? "/family-list-blocked.gif"
- : isMemberOnly
- ? "/member-only-source.gif"
- : "/error-cat.gif";
-
- return (
-
-
-
-
- Couldn't load this video
-
-
- {countryCode &&
}
-
{message}
-
-
-
- {onRetry && (
-
- Retry
-
- )}
- {youtubeSessionReturnTo && (
-
-
- Connect with YouTube
-
- )}
- {familyListBlocked && canGlobalBlock && (
-
- Open allow list
-
- )}
- router.history.back()}
- className="px-5 py-2 rounded-full bg-surface-strong hover:bg-surface-soft text-fg text-sm font-medium transition-colors cursor-pointer"
- >
- Go back
-
-
-
- );
-}
diff --git a/apps/web/src/components/subscription-channel-list.tsx b/apps/web/src/components/subscription-channel-list.tsx
deleted file mode 100644
index 7b5f8110..00000000
--- a/apps/web/src/components/subscription-channel-list.tsx
+++ /dev/null
@@ -1,64 +0,0 @@
-import { startTransition, useMemo, useState } from "react";
-import { proxyImage } from "../lib/proxy";
-import type { SubscriptionItem } from "../types/user";
-import { ChannelAvatar } from "./channel-avatar";
-import { ChannelRouteLink } from "./channel-route-link";
-import { ScrollSentinel } from "./scroll-sentinel";
-
-type Props = { subscriptions: SubscriptionItem[] };
-
-const CHANNEL_BATCH_SIZE = 25;
-
-export function SubscriptionChannelList({ subscriptions }: Props) {
- const [visibleCount, setVisibleCount] = useState(CHANNEL_BATCH_SIZE);
- const sorted = useMemo(
- () => [...subscriptions].sort((a, b) => a.name.localeCompare(b.name)),
- [subscriptions],
- );
- const visible = sorted.slice(0, Math.min(visibleCount, sorted.length));
- const hasMore = visibleCount < sorted.length;
-
- function loadMore() {
- startTransition(() => {
- setVisibleCount((count) => Math.min(count + CHANNEL_BATCH_SIZE, sorted.length));
- });
- }
-
- return (
- <>
-
- {visible.map((item, index) => (
-
-
-
-
-
-
-
- {item.name}
-
-
-
-
- ))}
-
- {hasMore && (
-
- Showing {visible.length} of {sorted.length} channels
-
- )}
-
- >
- );
-}
diff --git a/apps/web/src/components/subscriptions-header.tsx b/apps/web/src/components/subscriptions-header.tsx
deleted file mode 100644
index ae1fb008..00000000
--- a/apps/web/src/components/subscriptions-header.tsx
+++ /dev/null
@@ -1,50 +0,0 @@
-import { Link } from "@tanstack/react-router";
-
-type Props = {
- active: "videos" | "channels";
- count: number;
- onVideosIntent?: () => void;
- onChannelsIntent?: () => void;
-};
-
-function linkClass(active: boolean): string {
- return active
- ? "border-fg text-fg"
- : "border-transparent text-fg-muted hover:border-border-strong hover:text-fg";
-}
-
-export function SubscriptionsHeader({ active, count, onVideosIntent, onChannelsIntent }: Props) {
- return (
-
- );
-}
diff --git a/apps/web/src/components/subtitle-track-utils.ts b/apps/web/src/components/subtitle-track-utils.ts
deleted file mode 100644
index 1f38c0e5..00000000
--- a/apps/web/src/components/subtitle-track-utils.ts
+++ /dev/null
@@ -1,64 +0,0 @@
-import { toProxiedVttUrl } from "../lib/proxy";
-import type { SubtitleItem } from "../types/api";
-
-type SafeSubtitleTrack = {
- key: string;
- id: string;
- src: string;
- label: string;
- lang: string;
-};
-
-function normalizeText(value: unknown): string {
- return typeof value === "string" ? value.trim() : "";
-}
-
-function uniqueLabel(base: string, isAuto: boolean, used: Map): string {
- const label = isAuto ? `${base} (auto)` : base;
- const seen = used.get(label) ?? 0;
- used.set(label, seen + 1);
- return seen > 0 ? `${label} (${seen + 1})` : label;
-}
-
-function subtitleVariant(url: string): string {
- try {
- const name = new URL(url).searchParams.get("name")?.trim();
- return name && /^[a-zA-Z0-9._-]{1,24}$/.test(name) ? name : "";
- } catch {
- return "";
- }
-}
-
-export function buildSafeSubtitleTracks(
- subtitles: SubtitleItem[] | undefined,
-): SafeSubtitleTrack[] {
- if (!subtitles || subtitles.length === 0) return [];
-
- const tracks: SafeSubtitleTrack[] = [];
- const usedLabels = new Map();
-
- for (let i = 0; i < subtitles.length; i++) {
- const item = subtitles[i];
- if (!item) continue;
-
- const rawUrl = normalizeText(item.url);
- if (!rawUrl) continue;
-
- let src = "";
- try {
- src = toProxiedVttUrl(rawUrl);
- } catch {
- continue;
- }
-
- const lang = normalizeText(item.languageTag).toLowerCase() || `und-${i}`;
- const languageLabel = normalizeText(item.displayLanguageName) || lang;
- const variant = subtitleVariant(rawUrl);
- const baseLabel = variant ? `${languageLabel} (${variant})` : languageLabel;
- const label = uniqueLabel(baseLabel, item.isAutoGenerated, usedLabels);
-
- tracks.push({ key: `${lang}-${rawUrl}-${i}`, id: `sub-${lang}-${i}`, src, label, lang });
- }
-
- return tracks;
-}
diff --git a/apps/web/src/components/theme-toggle-button.tsx b/apps/web/src/components/theme-toggle-button.tsx
deleted file mode 100644
index 1d417d28..00000000
--- a/apps/web/src/components/theme-toggle-button.tsx
+++ /dev/null
@@ -1,35 +0,0 @@
-import { Moon, Sun } from "lucide-react";
-import { useThemeStore } from "../stores/theme-store";
-
-type Props = {
- className?: string;
-};
-
-export function ThemeToggleButton({ className }: Props) {
- const theme = useThemeStore((s) => s.theme);
- const setTheme = useThemeStore((s) => s.setTheme);
-
- const toggleTheme = () => {
- const nextTheme = theme === "dark" ? "light" : "dark";
- setTheme(nextTheme);
- };
-
- return (
-
-
-
-
-
-
- );
-}
diff --git a/apps/web/src/components/toast.tsx b/apps/web/src/components/toast.tsx
deleted file mode 100644
index 55e69832..00000000
--- a/apps/web/src/components/toast.tsx
+++ /dev/null
@@ -1,19 +0,0 @@
-type Props = { message: string | null };
-
-export function Toast({ message }: Props) {
- if (!message) return null;
- const success = /completed|success|restored|finished/i.test(message);
- const shell = success
- ? "border-emerald-500/40 bg-gradient-to-r from-emerald-600/90 to-emerald-500/80 text-white"
- : "border-border-strong bg-surface-strong/95 text-fg";
- return (
-
- );
-}
diff --git a/apps/web/src/components/video-block-actions-dropdown.tsx b/apps/web/src/components/video-block-actions-dropdown.tsx
deleted file mode 100644
index 7b630f91..00000000
--- a/apps/web/src/components/video-block-actions-dropdown.tsx
+++ /dev/null
@@ -1,107 +0,0 @@
-import { useEffect, useLayoutEffect, useRef, useState } from "react";
-import { createPortal } from "react-dom";
-
-const MARGIN = 8;
-
-type Props = {
- anchorEl: HTMLElement | null;
- onClose: () => void;
- onSaveToPlaylist?: () => void;
- onToggleVideoBlock?: () => void;
- onToggleChannelBlock?: () => void;
- videoBlocked?: boolean;
- channelBlocked?: boolean;
-};
-
-export function VideoBlockActionsDropdown({
- anchorEl,
- onClose,
- onSaveToPlaylist,
- onToggleVideoBlock,
- onToggleChannelBlock,
- videoBlocked,
- channelBlocked,
-}: Props) {
- const panelRef = useRef(null);
- const [panelStyle, setPanelStyle] = useState({ visibility: "hidden" });
- const onCloseRef = useRef(onClose);
- onCloseRef.current = onClose;
- const anchorElRef = useRef(anchorEl);
- anchorElRef.current = anchorEl;
-
- useLayoutEffect(() => {
- if (!anchorEl || !panelRef.current) return;
- const anchor = anchorEl.getBoundingClientRect();
- const panel = panelRef.current.getBoundingClientRect();
- const vw = document.documentElement.clientWidth;
- const vh = document.documentElement.clientHeight;
-
- let left = anchor.right - panel.width;
- left = Math.min(left, vw - panel.width - MARGIN);
- left = Math.max(MARGIN, left);
-
- const spaceBelow = vh - anchor.bottom - MARGIN;
- const spaceAbove = anchor.top - MARGIN;
- let top =
- spaceBelow >= panel.height || spaceBelow >= spaceAbove
- ? anchor.bottom + MARGIN
- : anchor.top - panel.height - MARGIN;
- top = Math.max(MARGIN, Math.min(top, vh - panel.height - MARGIN));
-
- setPanelStyle({ position: "fixed", top, left, visibility: "visible" });
- }, [anchorEl]);
-
- useEffect(() => {
- function onMouseDown(e: MouseEvent) {
- const target = e.target as Node;
- const outsidePanel = panelRef.current && !panelRef.current.contains(target);
- const outsideAnchor = !anchorElRef.current?.contains(target);
- if (outsidePanel && outsideAnchor) onCloseRef.current();
- }
- window.addEventListener("mousedown", onMouseDown);
- return () => window.removeEventListener("mousedown", onMouseDown);
- }, []);
-
- return createPortal(
-
- {onSaveToPlaylist && (
-
- Save to playlist
-
- )}
- {onToggleVideoBlock && (
- {
- onToggleVideoBlock();
- onClose();
- }}
- className="w-full px-3 py-2 text-left text-sm text-fg transition-colors hover:bg-surface-strong"
- >
- {videoBlocked ? "Unblock video" : "Block video"}
-
- )}
- {onToggleChannelBlock && (
- {
- onToggleChannelBlock();
- onClose();
- }}
- className="w-full px-3 py-2 text-left text-sm text-fg transition-colors hover:bg-surface-strong"
- >
- {channelBlocked ? "Unblock channel" : "Block channel"}
-
- )}
-
,
- document.body,
- );
-}
diff --git a/apps/web/src/components/video-card-feedback-menu.tsx b/apps/web/src/components/video-card-feedback-menu.tsx
deleted file mode 100644
index 34b1494f..00000000
--- a/apps/web/src/components/video-card-feedback-menu.tsx
+++ /dev/null
@@ -1,41 +0,0 @@
-import { lazy, Suspense, useRef, useState } from "react";
-import type { VideoStream } from "../types/stream";
-import { MoreIcon } from "./watch-icons";
-
-const VideoCardFeedbackPanel = lazy(() =>
- import("./video-card-feedback-panel").then((module) => ({
- default: module.VideoCardFeedbackPanel,
- })),
-);
-
-type Props = {
- stream: VideoStream;
-};
-
-export function VideoCardFeedbackMenu({ stream }: Props) {
- const menuRef = useRef(null);
- const [menuOpen, setMenuOpen] = useState(false);
-
- return (
- <>
- setMenuOpen((open) => !open)}
- className="rounded-md p-1 text-fg-muted transition-colors hover:bg-surface-strong hover:text-fg"
- aria-label="Video options"
- >
-
-
- {menuOpen && (
-
- setMenuOpen(false)}
- />
-
- )}
- >
- );
-}
diff --git a/apps/web/src/components/video-card-feedback-panel.tsx b/apps/web/src/components/video-card-feedback-panel.tsx
deleted file mode 100644
index 4a33e9c5..00000000
--- a/apps/web/src/components/video-card-feedback-panel.tsx
+++ /dev/null
@@ -1,88 +0,0 @@
-import { useState } from "react";
-import { useAuth } from "../hooks/use-auth";
-import { useBlocked } from "../hooks/use-blocked";
-import { goto } from "../lib/route-redirect";
-import type { VideoStream } from "../types/stream";
-import { PlaylistAddDropdown } from "./playlist-add-dropdown";
-import { Toast } from "./toast";
-import { VideoBlockActionsDropdown } from "./video-block-actions-dropdown";
-
-type Props = {
- stream: VideoStream;
- anchorEl: HTMLElement | null;
- onClose: () => void;
-};
-
-export function VideoCardFeedbackPanel({ stream, anchorEl, onClose }: Props) {
- const { isAuthed } = useAuth();
- const [playlistOpen, setPlaylistOpen] = useState(false);
- const [toast, setToast] = useState(null);
- const { channels, videos, addChannel, removeChannel, addVideo, removeVideo } = useBlocked();
- const channelBlocked =
- !!stream.channelUrl &&
- (channels.data ?? []).some((blocked) => blocked.url === stream.channelUrl);
- const videoBlocked = (videos.data ?? []).some((blocked) => blocked.url === stream.id);
-
- function requireAuth(): boolean {
- if (isAuthed) return false;
- goto("/");
- return true;
- }
-
- function toggleVideoBlock() {
- if (requireAuth()) return;
- if (videoBlocked) {
- removeVideo.mutate(stream.id);
- return;
- }
- addVideo.mutate({ url: stream.id, global: false });
- }
-
- function toggleChannelBlock() {
- if (!stream.channelUrl || requireAuth()) return;
- if (channelBlocked) {
- removeChannel.mutate(stream.channelUrl);
- return;
- }
- addChannel.mutate({
- url: stream.channelUrl,
- name: stream.channelName,
- thumbnailUrl: stream.channelAvatar,
- global: false,
- });
- }
-
- function openPlaylist() {
- if (requireAuth()) return;
- setPlaylistOpen(true);
- }
-
- function handleSaved(label: string) {
- setToast(label);
- setTimeout(() => setToast(null), 2000);
- }
-
- return (
- <>
- {playlistOpen ? (
-
- ) : (
-
- )}
-
- >
- );
-}
diff --git a/apps/web/src/components/video-card-skeleton.tsx b/apps/web/src/components/video-card-skeleton.tsx
deleted file mode 100644
index 852a6830..00000000
--- a/apps/web/src/components/video-card-skeleton.tsx
+++ /dev/null
@@ -1,15 +0,0 @@
-export function VideoCardSkeleton() {
- return (
-
- );
-}
diff --git a/apps/web/src/components/video-card.tsx b/apps/web/src/components/video-card.tsx
deleted file mode 100644
index 218276de..00000000
--- a/apps/web/src/components/video-card.tsx
+++ /dev/null
@@ -1,154 +0,0 @@
-import { Link } from "@tanstack/react-router";
-import { memo, useCallback, useEffect, useRef } from "react";
-import { useClientLocale } from "../hooks/use-client-locale";
-import { useDeArrowBranding } from "../hooks/use-dearrow";
-import { useVideoCardPreview } from "../hooks/use-video-card-preview";
-import { formatDuration, formatPublishedDate, formatViews } from "../lib/format";
-import { watchListSearch } from "../lib/watch-url";
-import { useWatchNavigationStore } from "../stores/watch-navigation-store";
-import type { VideoStream } from "../types/stream";
-import { ChannelAvatar } from "./channel-avatar";
-import { ChannelRouteLink } from "./channel-route-link";
-import { VideoCardFeedbackMenu } from "./video-card-feedback-menu";
-import { VideoPreview } from "./video-preview";
-import { VideoStatusBadge } from "./video-status-badge";
-import { VerifiedBadgeIcon } from "./watch-icons";
-
-type Props = {
- stream: VideoStream;
- onOpen?: () => void;
- onImpression?: () => void;
- listId?: string;
- relatedStreams?: VideoStream[];
-};
-
-function VideoCardComponent({ stream, onOpen, onImpression, listId, relatedStreams }: Props) {
- const locale = useClientLocale();
- const rootRef = useRef(null);
- const setNavigation = useWatchNavigationStore((state) => state.setNavigation);
- const preview = useVideoCardPreview(stream);
- const { title, thumbnail } = useDeArrowBranding(
- stream.id,
- stream.title,
- stream.thumbnail,
- stream.duration,
- );
- const publishedText = formatPublishedDate(stream.publishedAt, undefined, locale);
- const watchSearch = watchListSearch(stream.id, listId);
- const handleOpen = useCallback(() => {
- setNavigation(stream, relatedStreams);
- onOpen?.();
- }, [onOpen, relatedStreams, setNavigation, stream]);
-
- useEffect(() => {
- if (!onImpression || typeof IntersectionObserver === "undefined") return;
- const element = rootRef.current;
- if (!element) return;
- let seen = false;
- const observer = new IntersectionObserver(
- (entries) => {
- for (const entry of entries) {
- if (seen || !entry.isIntersecting || entry.intersectionRatio < 0.6) continue;
- seen = true;
- onImpression();
- observer.disconnect();
- }
- },
- { threshold: [0.6] },
- );
- observer.observe(element);
- return () => observer.disconnect();
- }, [onImpression]);
-
- return (
-
-
-
-
-
- {preview.memberOnly && (
-
- Members only
-
- )}
- {(stream.isLive || stream.isPostLive) && (
-
-
-
- )}
- {!stream.isLive && stream.duration > 0 && (
-
- {formatDuration(stream.duration)}
-
- )}
-
-
-
- {stream.channelUrl ? (
-
-
-
- ) : (
-
- )}
-
-
- {title}
-
- {stream.channelUrl ? (
-
- {stream.channelName}
- {stream.uploaderVerified && }
-
- ) : (
-
- {stream.channelName}
- {stream.uploaderVerified && }
-
- )}
-
- {formatViews(stream.views)}
- {publishedText && ` · ${publishedText}`}
-
-
-
-
-
- );
-}
-
-export const VideoCard = memo(VideoCardComponent);
diff --git a/apps/web/src/components/video-grid-skeleton.tsx b/apps/web/src/components/video-grid-skeleton.tsx
deleted file mode 100644
index 659adf60..00000000
--- a/apps/web/src/components/video-grid-skeleton.tsx
+++ /dev/null
@@ -1,19 +0,0 @@
-import { VideoCardSkeleton } from "./video-card-skeleton";
-
-const DEFAULT_COUNT = 12;
-
-type Props = {
- count?: number;
- idPrefix?: string;
-};
-
-export function VideoGridSkeleton({ count = DEFAULT_COUNT, idPrefix = "video-grid" }: Props) {
- const keys = Array.from({ length: count }, (_, index) => `${idPrefix}-${index}`);
- return (
-
- {keys.map((key) => (
-
- ))}
-
- );
-}
diff --git a/apps/web/src/components/video-grid.tsx b/apps/web/src/components/video-grid.tsx
deleted file mode 100644
index 4bfd80a6..00000000
--- a/apps/web/src/components/video-grid.tsx
+++ /dev/null
@@ -1,42 +0,0 @@
-import { useMemo } from "react";
-import type { VideoStream } from "../types/stream";
-import { VideoCard } from "./video-card";
-
-type VideoGridProps = {
- streams: VideoStream[];
- onCardOpen?: (stream: VideoStream) => void;
- onCardImpression?: (stream: VideoStream) => void;
- listId?: string;
-};
-
-export function VideoGrid({ streams, onCardOpen, onCardImpression, listId }: VideoGridProps) {
- const unique = useMemo(() => {
- const seen = new Set();
- const result: VideoStream[] = [];
- for (const stream of streams) {
- if (seen.has(stream.id)) continue;
- seen.add(stream.id);
- result.push(stream);
- }
- return result;
- }, [streams]);
- return (
-
- {unique.map((stream, index) => (
-
- onCardOpen(stream) : undefined}
- onImpression={onCardImpression ? () => onCardImpression(stream) : undefined}
- listId={listId}
- relatedStreams={unique}
- />
-
- ))}
-
- );
-}
diff --git a/apps/web/src/components/video-player-class.ts b/apps/web/src/components/video-player-class.ts
deleted file mode 100644
index 8dd90753..00000000
--- a/apps/web/src/components/video-player-class.ts
+++ /dev/null
@@ -1,9 +0,0 @@
-export function videoPlayerClassName(audioOnly: boolean, className?: string): string {
- return [
- "w-full h-full dark typetype-player-surface",
- audioOnly && "typetype-audio-only-player",
- className,
- ]
- .filter(Boolean)
- .join(" ");
-}
diff --git a/apps/web/src/components/video-player-core.tsx b/apps/web/src/components/video-player-core.tsx
deleted file mode 100644
index 2dad91cc..00000000
--- a/apps/web/src/components/video-player-core.tsx
+++ /dev/null
@@ -1,106 +0,0 @@
-import type * as dashjs from "dashjs";
-import type Hls from "hls.js";
-import { notifyDashPlayer, setDashPlayer } from "../lib/dash-player-store";
-import type { MediaProviderAdapter } from "../lib/vidstack";
-import { isDASHProvider, isHLSProvider, Track, useMediaState } from "../lib/vidstack";
-import { useAuthStore } from "../stores/auth-store";
-
-type DashRequestInterceptor = Parameters[0];
-
-const DASH_TOP_QUALITY_BUFFER_SECONDS = 24;
-const DASH_BACK_BUFFER_SECONDS = 30;
-const HLS_FORWARD_BUFFER_SECONDS = 30;
-const HLS_BACK_BUFFER_SECONDS = 30;
-type DashLibraryModule = { default: typeof dashjs };
-type DashRuntimeModule = typeof dashjs & { default?: typeof dashjs };
-type HlsLibraryModule = { default: typeof Hls };
-type HlsRuntimeModule = { default?: typeof Hls };
-let dashLibrary: typeof dashjs | null = null;
-let dashLibraryPromise: Promise | null = null;
-let hlsLibraryPromise: Promise | null = null;
-
-const loadDashLibrary = (): Promise => {
- dashLibraryPromise ??= import("dashjs").then((module) => {
- const library = (module as DashRuntimeModule).default ?? module;
- dashLibrary = library;
- return { default: library };
- });
- return dashLibraryPromise;
-};
-
-const loadHlsLibrary = (): Promise => {
- hlsLibraryPromise ??= import("hls.js").then((module) => ({
- default: (module as HlsRuntimeModule).default ?? (module as unknown as typeof Hls),
- }));
- return hlsLibraryPromise;
-};
-
-function configureDashPlayer(player: dashjs.MediaPlayerClass, library: typeof dashjs): void {
- const onDashUpdate = () => notifyDashPlayer();
- player.on(library.MediaPlayer.events.STREAM_INITIALIZED, onDashUpdate);
- player.on(library.MediaPlayer.events.TRACK_CHANGE_RENDERED, onDashUpdate);
- player.on(library.MediaPlayer.events.QUALITY_CHANGE_RENDERED, onDashUpdate);
- player.updateSettings({
- streaming: {
- buffer: {
- bufferTimeAtTopQuality: DASH_TOP_QUALITY_BUFFER_SECONDS,
- bufferTimeAtTopQualityLongForm: DASH_TOP_QUALITY_BUFFER_SECONDS,
- bufferToKeep: DASH_BACK_BUFFER_SECONDS,
- },
- cmcd: { enabled: false },
- retryAttempts: {
- MPD: 5,
- MediaSegment: 3,
- InitializationSegment: 3,
- IndexSegment: 3,
- },
- retryIntervals: {
- MPD: 500,
- MediaSegment: 500,
- InitializationSegment: 500,
- IndexSegment: 500,
- },
- },
- });
- notifyDashPlayer();
-}
-
-export function ChaptersTrack({ src }: { src: string }) {
- const duration = useMediaState("duration");
- if (!Number.isFinite(duration) || duration <= 0) return null;
- return ;
-}
-
-export function onProviderChange(provider: MediaProviderAdapter | null) {
- if (isHLSProvider(provider)) {
- provider.library = loadHlsLibrary;
- provider.config = {
- backBufferLength: HLS_BACK_BUFFER_SECONDS,
- maxBufferLength: HLS_FORWARD_BUFFER_SECONDS,
- maxMaxBufferLength: HLS_FORWARD_BUFFER_SECONDS * 2,
- };
- return;
- }
- const dashProvider = isDASHProvider(provider);
- if (!dashProvider) {
- if (provider === null) setDashPlayer(null);
- return;
- }
- provider.library = loadDashLibrary;
- provider.onInstance((player) => {
- const addAuthHeader: DashRequestInterceptor = (request) => {
- const token = useAuthStore.getState().token;
- if (!token) return request;
- request.headers = {
- ...request.headers,
- Authorization: `Bearer ${token}`,
- };
- return request;
- };
- setDashPlayer(player);
- player.addRequestInterceptor(addAuthHeader);
- if (dashLibrary) configureDashPlayer(player, dashLibrary);
- else
- void loadDashLibrary().then(({ default: library }) => configureDashPlayer(player, library));
- });
-}
diff --git a/apps/web/src/components/video-player-events.ts b/apps/web/src/components/video-player-events.ts
deleted file mode 100644
index dadee150..00000000
--- a/apps/web/src/components/video-player-events.ts
+++ /dev/null
@@ -1,34 +0,0 @@
-import { useEffect } from "react";
-import { recordClientEvent } from "../lib/client-debug-log";
-import { mediaSrcDetails } from "../lib/player-src-debug";
-import type { MediaSrc } from "../lib/vidstack";
-import { onProviderChange } from "./video-player-core";
-
-type Args = {
- src: MediaSrc;
- onError?: (positionMs?: number) => void;
- onEnded?: () => void;
-};
-
-export function useVideoPlayerEvents({ src, onError, onEnded }: Args) {
- useEffect(() => {
- recordClientEvent("player.src", mediaSrcDetails(src));
- }, [src]);
-
- function handleProviderChange(provider: Parameters[0]) {
- recordClientEvent("player.provider_change", { present: provider !== null });
- onProviderChange(provider);
- }
-
- function handleError() {
- recordClientEvent("player.error", mediaSrcDetails(src));
- onError?.();
- }
-
- function handleEnded() {
- recordClientEvent("player.ended");
- onEnded?.();
- }
-
- return { handleProviderChange, handleError, handleEnded };
-}
diff --git a/apps/web/src/components/video-player-layout.tsx b/apps/web/src/components/video-player-layout.tsx
deleted file mode 100644
index f95beedc..00000000
--- a/apps/web/src/components/video-player-layout.tsx
+++ /dev/null
@@ -1,128 +0,0 @@
-import { DefaultAudioLayout, DefaultVideoLayout, defaultLayoutIcons, Time } from "../lib/vidstack";
-import { AudioPlayButton } from "./audio-play-button";
-import { AudioSeekButton } from "./audio-seek-button";
-import { AudioTimeSlider } from "./audio-time-slider";
-import { AudioTrackSelector } from "./audio-track-selector";
-import { CinemaModeControl } from "./cinema-mode-control";
-import { FormatSelector } from "./format-selector";
-import { PlayerTrackButton } from "./player-track-button";
-import { PlayerVolumeControl } from "./player-volume-control";
-import { QualitySelector } from "./quality-selector";
-import { SabrTimeSlider } from "./sabr-time-slider";
-
-type Props = {
- audioOnly?: boolean;
- audioUsesVideoProvider?: boolean;
- sabr?: boolean;
- sabrVideo?: HTMLVideoElement | null;
- seeking?: boolean;
- thumbnailVtt?: string;
- originalAudioLocale?: string | null;
- onPreviousVideo?: () => void;
- onNextVideo?: () => void;
-};
-
-export function VideoPlayerLayout({
- audioOnly = false,
- audioUsesVideoProvider = false,
- sabr = false,
- sabrVideo = null,
- seeking = false,
- thumbnailVtt,
- originalAudioLocale,
- onPreviousVideo,
- onNextVideo,
-}: Props) {
- if (audioOnly) {
- const timePair = (
-
-
- /
-
-
- );
- const backwardButton = (
-
- );
- const forwardButton = (
-
- );
- const timeSlider = ;
- if (audioUsesVideoProvider) {
- return (
- ,
- afterPlayButton: forwardButton,
- beforeCaptionButton: (
-
- ),
- afterCaptionButton: ,
- beforeSettingsMenu: ,
- fullscreenButton: null,
- pipButton: null,
- title: null,
- chapterTitle: null,
- }}
- />
- );
- }
- return (
- ,
- seekForwardButton: forwardButton,
- beforeCaptionButton: ,
- afterCaptionButton: ,
- beforeSettingsMenu: ,
- }}
- />
- );
- }
-
- return (
-
- ) : undefined,
- settingsMenuItemsStart: (
- <>
-
-
-
- >
- ),
- beforePlayButton: ,
- afterPlayButton: ,
- beforeFullscreenButton: (
- <>
-
-
- >
- ),
- }}
- />
- );
-}
diff --git a/apps/web/src/components/video-player-playback-tools.tsx b/apps/web/src/components/video-player-playback-tools.tsx
deleted file mode 100644
index 1f431dcd..00000000
--- a/apps/web/src/components/video-player-playback-tools.tsx
+++ /dev/null
@@ -1,44 +0,0 @@
-import type { SponsorBlockSegmentItem } from "../types/api";
-import { PlayerHotkeys } from "./player-hotkeys";
-import { SponsorBlockSkipper } from "./player-internals";
-import { PlayerPlayPauseIndicator } from "./player-play-pause-indicator";
-import { SponsorBlockBar } from "./sponsorblock-bar";
-import { SponsorBlockCurrentSegment } from "./sponsorblock-current-segment";
-import { SponsorBlockSkipNotice } from "./sponsorblock-skip-notice";
-
-type Props = {
- canSeek: boolean;
- audioOnly: boolean;
- sabrVideo: HTMLVideoElement | null;
- segments?: SponsorBlockSegmentItem[];
- autoSkipSegments?: SponsorBlockSegmentItem[];
- manualSkipSegments?: SponsorBlockSegmentItem[];
- autoSkip: boolean;
- mutedSkip: boolean;
- showCurrent: boolean;
-};
-
-export function VideoPlayerPlaybackTools(props: Props) {
- return (
- <>
-
- {!props.audioOnly && }
- {!props.audioOnly && props.autoSkip && props.autoSkipSegments && (
-
- )}
- {props.segments && }
- {props.segments && }
- {props.showCurrent && props.segments && (
-
- )}
- >
- );
-}
diff --git a/apps/web/src/components/video-player-tracks.tsx b/apps/web/src/components/video-player-tracks.tsx
deleted file mode 100644
index ec7d3675..00000000
--- a/apps/web/src/components/video-player-tracks.tsx
+++ /dev/null
@@ -1,29 +0,0 @@
-import { Track } from "../lib/vidstack";
-import type { SubtitleItem } from "../types/api";
-import { buildSafeSubtitleTracks } from "./subtitle-track-utils";
-import { ChaptersTrack } from "./video-player-core";
-
-type Props = {
- subtitles?: SubtitleItem[];
- chaptersVtt?: string;
-};
-
-export function VideoPlayerTracks({ subtitles, chaptersVtt }: Props) {
- const subtitleTracks = buildSafeSubtitleTracks(subtitles);
- return (
- <>
- {subtitleTracks.map((track) => (
-
- ))}
- {chaptersVtt && }
- >
- );
-}
diff --git a/apps/web/src/components/video-player-types.ts b/apps/web/src/components/video-player-types.ts
deleted file mode 100644
index 441b9f84..00000000
--- a/apps/web/src/components/video-player-types.ts
+++ /dev/null
@@ -1,47 +0,0 @@
-import type { ReactNode } from "react";
-import type { SabrPlaybackConfig } from "../lib/sabr-source";
-import type { MediaSrc } from "../lib/vidstack";
-import type { SponsorBlockSegmentItem, SubtitleItem } from "../types/api";
-import type { CaptionStyles } from "../types/user";
-
-export type VideoPlayerProps = {
- src: MediaSrc;
- sabrConfig?: SabrPlaybackConfig | null;
- title?: string;
- poster?: string;
- streamType?: "on-demand" | "live";
- startTime?: number;
- seekIntervalSeconds?: number;
- subtitles?: SubtitleItem[];
- sponsorBlockSegments?: SponsorBlockSegmentItem[];
- autoSkipSponsorBlockSegments?: SponsorBlockSegmentItem[];
- manualSkipSponsorBlockSegments?: SponsorBlockSegmentItem[];
- autoSkipSponsorBlock?: boolean;
- muteSponsorBlockInsteadOfSkip?: boolean;
- showCurrentSponsorBlockSegment?: boolean;
- thumbnailVtt?: string;
- chaptersVtt?: string;
- initialVolume?: number;
- initialMuted?: boolean;
- settingsReady?: boolean;
- autoplay?: boolean;
- audioOnly?: boolean;
- originalAudioLocale?: string | null;
- overlay?: ReactNode;
- captionStyles?: CaptionStyles;
- onCaptionStylesChange?: (styles: CaptionStyles) => void;
- onVolumeChange?: (volume: number, muted: boolean) => void;
- onTimeUpdate?: (positionMs: number) => void;
- onPlay?: () => void;
- onPause?: () => void;
- onSeeking?: (positionMs: number) => void;
- onSeeked?: () => void;
- onError?: (positionMs?: number) => void;
- onSeekReady?: (seek: (seconds: number) => void) => void;
- onPositionReaderChange?: (reader: (() => number | null) | null) => void;
- onEnded?: () => void;
- onPreviousVideo?: () => void;
- onNextVideo?: () => void;
- className?: string;
- mediaClassName?: string;
-};
diff --git a/apps/web/src/components/video-player.tsx b/apps/web/src/components/video-player.tsx
deleted file mode 100644
index 28c11cea..00000000
--- a/apps/web/src/components/video-player.tsx
+++ /dev/null
@@ -1,186 +0,0 @@
-import { useMemo } from "react";
-import { useSabrPlayerState } from "../hooks/use-sabr-player-state";
-import { isIosDevice } from "../lib/ios-device";
-import { mediaSourceViewType } from "../lib/media-source-view-type";
-import { SABR_VIDEO_PROVIDER_LOADERS, sabrMediaSrc } from "../lib/sabr-vidstack-loader";
-import { MediaPlayer, MediaProvider } from "../lib/vidstack";
-import { patchVidstackProviderLoaders } from "../lib/vidstack-provider-loader-patch";
-import { AudioCenterToggle } from "./audio-center-toggle";
-import { AudioOnlyPoster } from "./audio-only-poster";
-import { CaptionStyleRestorer } from "./caption-style-restorer";
-import { FragmentBoundarySeeker } from "./fragment-boundary-seeker";
-import { MediaProgressEvents } from "./media-progress-events";
-import { MediaSessionSync } from "./media-session-sync";
-import { SeekBridge } from "./player-internals";
-import { PlayerSeeker } from "./player-seeker";
-import { SabrMsePlayer } from "./sabr-mse-player";
-import { videoPlayerClassName } from "./video-player-class";
-import { useVideoPlayerEvents } from "./video-player-events";
-import { VideoPlayerLayout } from "./video-player-layout";
-import { VideoPlayerPlaybackTools } from "./video-player-playback-tools";
-import { VideoPlayerTracks } from "./video-player-tracks";
-import type { VideoPlayerProps } from "./video-player-types";
-import { VolumeRestorer } from "./volume-restorer";
-
-patchVidstackProviderLoaders();
-
-export function VideoPlayer({
- src,
- sabrConfig,
- title,
- poster,
- streamType = "on-demand",
- startTime = 0,
- seekIntervalSeconds,
- subtitles,
- sponsorBlockSegments,
- autoSkipSponsorBlockSegments,
- manualSkipSponsorBlockSegments,
- autoSkipSponsorBlock = true,
- muteSponsorBlockInsteadOfSkip = false,
- showCurrentSponsorBlockSegment = false,
- thumbnailVtt,
- chaptersVtt,
- initialVolume = 1,
- initialMuted = false,
- settingsReady = false,
- autoplay = false,
- audioOnly = false,
- originalAudioLocale,
- overlay,
- captionStyles,
- onCaptionStylesChange,
- onVolumeChange,
- onTimeUpdate,
- onPlay,
- onPause,
- onSeeking,
- onSeeked,
- onError,
- onSeekReady,
- onPositionReaderChange,
- onEnded,
- onPreviousVideo,
- onNextVideo,
- className,
- mediaClassName,
-}: VideoPlayerProps) {
- const ios = isIosDevice();
- const playerClassName = videoPlayerClassName(audioOnly, className);
- const sabrVideoId = sabrConfig?.videoId;
- const sabrSrc = useMemo(() => (sabrVideoId ? sabrMediaSrc(sabrVideoId) : null), [sabrVideoId]);
- const activeSrc = sabrSrc ?? src;
- const viewType = mediaSourceViewType(audioOnly, Boolean(sabrConfig), activeSrc);
- const { handleProviderChange, handleError, handleEnded } = useVideoPlayerEvents({
- src: activeSrc,
- onError,
- onEnded,
- });
-
- const sabrState = useSabrPlayerState(Boolean(sabrConfig), handleProviderChange);
-
- return (
-
-
- {!audioOnly && }
-
- {sabrConfig && (
- undefined)}
- onSeekStateChange={sabrState.setSeeking}
- onSeekReady={onSeekReady ?? (() => undefined)}
- onPositionReaderChange={onPositionReaderChange ?? (() => undefined)}
- />
- )}
- {audioOnly && }
- {audioOnly && }
-
- {overlay}
-
- {!sabrConfig && }
- {!sabrConfig && }
-
- {captionStyles && onCaptionStylesChange && (
-
- )}
-
-
- {onSeekReady && }
-
- );
-}
diff --git a/apps/web/src/components/video-preview.tsx b/apps/web/src/components/video-preview.tsx
deleted file mode 100644
index 89185f73..00000000
--- a/apps/web/src/components/video-preview.tsx
+++ /dev/null
@@ -1,105 +0,0 @@
-import { useEffect, useRef } from "react";
-import { proxyDashManifest } from "../lib/proxy";
-import { resolveHlsManifestUrl } from "../lib/stream-src";
-import type { VideoStream } from "../types/stream";
-
-type Props = {
- stream: VideoStream | undefined;
- show: boolean;
-};
-
-export function VideoPreview({ stream, show }: Props) {
- const videoRef = useRef(null);
-
- useEffect(() => {
- if (!show || !stream || !videoRef.current) return;
-
- const video = videoRef.current;
- const src = resolvePreviewSrc(stream);
- if (!src) return;
- let disposed = false;
- let hls: Hls | null = null;
-
- if (src.type === "application/x-mpegurl") {
- void loadHls(video, src.url).then((nextHls) => {
- if (disposed) {
- nextHls?.destroy();
- return;
- }
- hls = nextHls;
- });
- } else {
- video.src = src.url;
- }
-
- return () => {
- disposed = true;
- hls?.destroy();
- video.pause();
- video.removeAttribute("src");
- video.load();
- };
- }, [show, stream]);
-
- useEffect(() => {
- if (!videoRef.current) return;
- if (show) {
- void videoRef.current.play().catch(() => {});
- } else {
- videoRef.current.pause();
- videoRef.current.currentTime = 0;
- }
- }, [show]);
-
- if (!show || !stream) return null;
-
- return (
-
- );
-}
-
-type PreviewSrc = { url: string; type: "application/x-mpegurl" | "video/mp4" } | null;
-
-function resolvePreviewSrc(stream: VideoStream): PreviewSrc {
- if (typeof window !== "undefined" && window.matchMedia("(hover: none)").matches) {
- return null;
- }
- if (stream.hlsUrl) {
- return {
- url: resolveHlsManifestUrl(stream),
- type: "application/x-mpegurl",
- };
- }
-
- const progressive = [...(stream.videoStreams ?? [])]
- .filter((candidate) => candidate.mimeType.includes("video/mp4"))
- .sort((left, right) => (right.bitrate ?? 0) - (left.bitrate ?? 0))[0];
- if (!progressive) return null;
- return { url: proxyDashManifest(progressive.url), type: "video/mp4" };
-}
-
-type Hls = { destroy: () => void };
-
-async function loadHls(video: HTMLVideoElement, url: string): Promise {
- if (!supportsNativeHls(video)) return null;
- video.src = url;
- return {
- destroy: () => {
- video.pause();
- video.removeAttribute("src");
- video.load();
- },
- };
-}
-
-function supportsNativeHls(video: HTMLVideoElement): boolean {
- const appleType = video.canPlayType("application/vnd.apple.mpegurl");
- const legacyType = video.canPlayType("application/x-mpegURL");
- return appleType !== "" || legacyType !== "";
-}
diff --git a/apps/web/src/components/video-progress-bar.tsx b/apps/web/src/components/video-progress-bar.tsx
deleted file mode 100644
index c4d25af7..00000000
--- a/apps/web/src/components/video-progress-bar.tsx
+++ /dev/null
@@ -1,22 +0,0 @@
-type Props = {
- progress: number;
- duration: number;
- className?: string;
- alwaysVisible?: boolean;
-};
-
-function progressPercent(progress: number, duration: number): number | null {
- if (!Number.isFinite(progress) || !Number.isFinite(duration) || duration <= 0) return null;
- return Math.min(100, Math.max(0, (progress / duration) * 100));
-}
-
-export function VideoProgressBar({ progress, duration, className, alwaysVisible = false }: Props) {
- const pct = progressPercent(progress, duration);
- if (pct === null || (!alwaysVisible && pct <= 0)) return null;
-
- return (
-
- );
-}
diff --git a/apps/web/src/components/video-status-badge.tsx b/apps/web/src/components/video-status-badge.tsx
deleted file mode 100644
index 00f96dd5..00000000
--- a/apps/web/src/components/video-status-badge.tsx
+++ /dev/null
@@ -1,20 +0,0 @@
-import type { VideoStream } from "../types/stream";
-
-type Props = {
- stream: VideoStream;
- compact?: boolean;
-};
-
-export function VideoStatusBadge({ stream, compact = false }: Props) {
- const label = stream.isLive ? "LIVE" : stream.isPostLive ? "REPLAY" : null;
- if (!label) return null;
- return (
-
- {label}
-
- );
-}
diff --git a/apps/web/src/components/volume-restorer.tsx b/apps/web/src/components/volume-restorer.tsx
deleted file mode 100644
index 2ee07ab4..00000000
--- a/apps/web/src/components/volume-restorer.tsx
+++ /dev/null
@@ -1,43 +0,0 @@
-import { useEffect, useRef } from "react";
-import { useMediaPlayer, useMediaRemote, useMediaState } from "../lib/vidstack";
-
-type Props = {
- initialVolume: number;
- initialMuted: boolean;
- settingsReady: boolean;
- onVolumeChange?: (volume: number, muted: boolean) => void;
-};
-
-export function VolumeRestorer({
- initialVolume,
- initialMuted,
- settingsReady,
- onVolumeChange,
-}: Props) {
- const remote = useMediaRemote();
- const player = useMediaPlayer();
- const volume = useMediaState("volume");
- const muted = useMediaState("muted");
- const canPlay = useMediaState("canPlay");
- const restoredRef = useRef(false);
-
- useEffect(() => {
- if (!settingsReady || !canPlay || restoredRef.current) return;
- const root = player?.el;
- if (!root?.isConnected) return;
- restoredRef.current = true;
- try {
- remote.changeVolume(initialVolume);
- if (initialMuted) remote.mute();
- } catch {
- restoredRef.current = false;
- }
- }, [settingsReady, canPlay, remote, initialVolume, initialMuted, player]);
-
- useEffect(() => {
- if (!restoredRef.current) return;
- onVolumeChange?.(volume, muted);
- }, [volume, muted, onVolumeChange]);
-
- return null;
-}
diff --git a/apps/web/src/components/watch-action-button.tsx b/apps/web/src/components/watch-action-button.tsx
deleted file mode 100644
index 97ca17a0..00000000
--- a/apps/web/src/components/watch-action-button.tsx
+++ /dev/null
@@ -1,34 +0,0 @@
-type Props = {
- buttonRef?: React.Ref;
- onClick: () => void;
- disabled?: boolean;
- pressed?: boolean;
- active?: boolean;
- children: React.ReactNode;
-};
-
-const BTN = "flex items-center gap-2 px-3 py-1.5 rounded-lg text-sm transition-colors";
-const BTN_IDLE = "text-fg-muted hover:text-fg hover:bg-surface-strong";
-const BTN_ON = "text-fg bg-surface-strong";
-
-export function WatchActionButton({
- buttonRef,
- onClick,
- disabled,
- pressed,
- active,
- children,
-}: Props) {
- return (
-
- {children}
-
- );
-}
diff --git a/apps/web/src/components/watch-actions.tsx b/apps/web/src/components/watch-actions.tsx
deleted file mode 100644
index 78d306cd..00000000
--- a/apps/web/src/components/watch-actions.tsx
+++ /dev/null
@@ -1,159 +0,0 @@
-import { useRef, useState } from "react";
-import { useAuth } from "../hooks/use-auth";
-import { useFavoriteStatus } from "../hooks/use-favorite-status";
-import { useShareUrl } from "../hooks/use-share-url";
-import type { WatchAudioOnlyControls } from "../hooks/use-watch-audio-only-playback";
-import { prepareAudioSpectrum } from "../lib/audio-spectrum";
-import { detectProvider } from "../lib/provider";
-import { goto } from "../lib/route-redirect";
-import { toPublicWatchUrl } from "../lib/watch-url";
-import type { VideoStream } from "../types/stream";
-import { DanmakuControls } from "./danmaku-controls";
-import { DownloadSheet } from "./download-sheet";
-import { PlaylistAddDropdown } from "./playlist-add-dropdown";
-import { ReportBugModal } from "./report-bug-modal";
-import { Toast } from "./toast";
-import { WatchActionButton } from "./watch-action-button";
-import {
- BugIcon,
- DownloadIcon,
- HeadphonesIcon,
- ListPlusIcon,
- ShareIcon,
- StarIcon,
-} from "./watch-icons";
-import { WatchMoreActions } from "./watch-more-actions";
-
-type Props = {
- stream: VideoStream;
- audioOnly: WatchAudioOnlyControls;
-};
-export function WatchActions({ stream, audioOnly }: Props) {
- const { copied, share } = useShareUrl();
- const [playlistOpen, setPlaylistOpen] = useState(false);
- const [downloadOpen, setDownloadOpen] = useState(false);
- const [reportOpen, setReportOpen] = useState(false);
- const [toastLabel, setToastLabel] = useState(null);
- const saveAnchorRef = useRef(null);
- const { authReady, isAuthed } = useAuth();
- const {
- add: addFavorite,
- remove: removeFavorite,
- isFavorite: favorited,
- isPending: favPending,
- } = useFavoriteStatus(stream.id);
- const isNicoNico = detectProvider(stream.id) === "nicovideo";
- const isLive = stream.streamType === "live_stream" || stream.streamType === "audio_live_stream";
- const audioOnlyAvailable = !isLive;
- const audioOnlyDisabled = !authReady || audioOnly.loading;
-
- function handleSaved(label: string) {
- setToastLabel(label);
- setTimeout(() => setToastLabel(null), 2000);
- }
- async function handleFavorite() {
- if (!isAuthed) {
- goto("/");
- return;
- }
- if (favorited) {
- await removeFavorite();
- handleSaved("Removed from Favorites");
- } else {
- await addFavorite();
- handleSaved("Saved to Favorites");
- }
- }
-
- function handleDownloadMock() {
- setDownloadOpen(true);
- }
-
- function handleAudioOnly() {
- if (!audioOnly.active) prepareAudioSpectrum();
- audioOnly.onToggle();
- }
-
- const showSave = true;
- const showReport = true;
- const showDanmaku = isNicoNico;
-
- return (
-
-
-
- {favPending ? "Saving..." : favorited ? "Favorited" : "Favorite"}
-
-
-
- Download
-
- {audioOnlyAvailable && (
-
-
- {audioOnly.loading ? "Loading audio..." : "Audio only"}
-
- )}
-
share(toPublicWatchUrl(stream.id, window.location.origin))}>
-
- Share
-
-
- {showSave && (
-
setPlaylistOpen((o) => !o)}
- disabled={!isAuthed}
- className={`flex items-center gap-2 px-3 py-1.5 rounded-lg text-sm transition-colors disabled:opacity-50 disabled:cursor-not-allowed ${
- playlistOpen
- ? "text-fg bg-surface-strong"
- : "text-fg-muted hover:text-fg hover:bg-surface-strong"
- }`}
- >
-
- Save
-
- )}
-
- {showDanmaku &&
}
- {showReport && isAuthed && (
-
setReportOpen(true)}>
-
- Report
-
- )}
- {playlistOpen && (
-
setPlaylistOpen(false)}
- onSaved={handleSaved}
- />
- )}
- {downloadOpen && (
- setDownloadOpen(false)}
- onDone={(message) => handleSaved(message)}
- />
- )}
- {reportOpen && setReportOpen(false)} />}
-
- );
-}
diff --git a/apps/web/src/components/watch-comment-replies.tsx b/apps/web/src/components/watch-comment-replies.tsx
deleted file mode 100644
index c8b34039..00000000
--- a/apps/web/src/components/watch-comment-replies.tsx
+++ /dev/null
@@ -1,51 +0,0 @@
-import { useCallback } from "react";
-import { useCommentReplies } from "../hooks/use-comment-replies";
-import { WatchCommentSkeleton } from "./watch-comment-skeleton";
-import { WatchReply } from "./watch-reply";
-
-const SKELETON_KEYS = Array.from({ length: 3 }, (_, i) => `rs-${i}`);
-
-type Props = {
- videoUrl: string;
- repliesPage: string;
- locale?: string;
- onSeekTimestamp?: (seconds: number) => void;
-};
-
-export function WatchCommentReplies({ videoUrl, repliesPage, locale, onSeekTimestamp }: Props) {
- const { data, isFetchingNextPage, hasNextPage, fetchNextPage, isLoading } = useCommentReplies(
- videoUrl,
- repliesPage,
- );
-
- const loadMore = useCallback(() => {
- if (hasNextPage && !isFetchingNextPage) fetchNextPage();
- }, [hasNextPage, isFetchingNextPage, fetchNextPage]);
-
- const replies = data?.pages.flatMap((p) => p.comments) ?? [];
-
- return (
-
- {replies.map((reply, i) => (
-
-
-
- ))}
- {(isLoading || isFetchingNextPage) &&
- SKELETON_KEYS.map((k) =>
)}
- {hasNextPage && !isFetchingNextPage && (
-
- Load more replies
-
- )}
-
- );
-}
diff --git a/apps/web/src/components/watch-comment-row.tsx b/apps/web/src/components/watch-comment-row.tsx
deleted file mode 100644
index 3125a80f..00000000
--- a/apps/web/src/components/watch-comment-row.tsx
+++ /dev/null
@@ -1,24 +0,0 @@
-import type { Comment } from "../types/comment";
-import { WatchComment } from "./watch-comment";
-
-type Props = {
- comment: Comment;
- videoUrl: string;
- index: number;
- onSeekTimestamp?: (seconds: number) => void;
-};
-
-export function WatchCommentRow({ comment, videoUrl, index, onSeekTimestamp }: Props) {
- return (
-
-
-
- );
-}
diff --git a/apps/web/src/components/watch-comment-skeleton.tsx b/apps/web/src/components/watch-comment-skeleton.tsx
deleted file mode 100644
index 6d247f70..00000000
--- a/apps/web/src/components/watch-comment-skeleton.tsx
+++ /dev/null
@@ -1,16 +0,0 @@
-export function WatchCommentSkeleton() {
- return (
-
- );
-}
diff --git a/apps/web/src/components/watch-comment.tsx b/apps/web/src/components/watch-comment.tsx
deleted file mode 100644
index 77d8c5cd..00000000
--- a/apps/web/src/components/watch-comment.tsx
+++ /dev/null
@@ -1,104 +0,0 @@
-import { useLayoutEffect, useRef, useState } from "react";
-import { useClientLocale } from "../hooks/use-client-locale";
-import { formatCommentPublishedTime } from "../lib/comment-time";
-import { formatLikes } from "../lib/format";
-import type { Comment } from "../types/comment";
-import { RichText } from "./rich-text";
-import { WatchCommentReplies } from "./watch-comment-replies";
-
-type Props = {
- comment: Comment;
- videoUrl: string;
- onSeekTimestamp?: (seconds: number) => void;
-};
-
-export function WatchComment({ comment, videoUrl, onSeekTimestamp }: Props) {
- const locale = useClientLocale();
- const [showReplies, setShowReplies] = useState(false);
- const [expanded, setExpanded] = useState(false);
- const [overflows, setOverflows] = useState(false);
- const textRef = useRef(null);
- const publishedTime = formatCommentPublishedTime(
- comment.publishedAt,
- comment.publishedTime,
- locale,
- );
-
- useLayoutEffect(() => {
- const el = textRef.current;
- if (!el) return;
- setOverflows(el.scrollHeight > el.clientHeight);
- }, []);
-
- const repliesLabel =
- comment.replyCount > 0 ? `${formatLikes(comment.replyCount)} replies` : "Show replies";
-
- const likeDisplay = comment.textualLikeCount || formatLikes(comment.likeCount);
-
- return (
-
- {comment.authorAvatarUrl ? (
-
- ) : (
-
- )}
-
-
- {comment.author}
- {comment.uploaderVerified && (
-
- verified
-
- )}
- {comment.isPinned && (
-
- pinned
-
- )}
- {publishedTime && {publishedTime} }
-
-
-
-
- {(overflows || expanded) && (
-
setExpanded((v) => !v)}
- className="text-xs text-fg-muted hover:text-fg text-left w-fit"
- >
- {expanded ? "Show less" : "Show more"}
-
- )}
- {comment.likeCount >= 0 && (
-
{likeDisplay} likes
- )}
- {comment.repliesPage !== null && (
-
setShowReplies((v) => !v)}
- className="text-xs text-accent hover:text-accent-strong text-left w-fit mt-1"
- >
- {showReplies ? "Hide replies" : repliesLabel}
-
- )}
- {showReplies && comment.repliesPage !== null && (
-
- )}
-
-
- );
-}
diff --git a/apps/web/src/components/watch-comments-lazy-list.tsx b/apps/web/src/components/watch-comments-lazy-list.tsx
deleted file mode 100644
index 719d94b3..00000000
--- a/apps/web/src/components/watch-comments-lazy-list.tsx
+++ /dev/null
@@ -1,27 +0,0 @@
-import { lazy, Suspense } from "react";
-import type { Comment } from "../types/comment";
-import { WatchCommentSkeleton } from "./watch-comment-skeleton";
-
-const WatchCommentsList = lazy(() =>
- import("./watch-comments-list").then((module) => ({ default: module.WatchCommentsList })),
-);
-
-const FALLBACK_KEYS = Array.from({ length: 3 }, (_, i) => `wcl-${i}`);
-
-type Props = {
- comments: Comment[];
- videoUrl: string;
- onSeekTimestamp?: (seconds: number) => void;
-};
-
-export function WatchCommentsLazyList({ comments, videoUrl, onSeekTimestamp }: Props) {
- return (
- )}>
-
-
- );
-}
diff --git a/apps/web/src/components/watch-comments-list.tsx b/apps/web/src/components/watch-comments-list.tsx
deleted file mode 100644
index 31e4e578..00000000
--- a/apps/web/src/components/watch-comments-list.tsx
+++ /dev/null
@@ -1,20 +0,0 @@
-import type { Comment } from "../types/comment";
-import { WatchCommentRow } from "./watch-comment-row";
-
-type Props = {
- comments: Comment[];
- videoUrl: string;
- onSeekTimestamp?: (seconds: number) => void;
-};
-
-export function WatchCommentsList({ comments, videoUrl, onSeekTimestamp }: Props) {
- return comments.map((comment, i) => (
-
- ));
-}
diff --git a/apps/web/src/components/watch-comments.tsx b/apps/web/src/components/watch-comments.tsx
deleted file mode 100644
index 88e71827..00000000
--- a/apps/web/src/components/watch-comments.tsx
+++ /dev/null
@@ -1,66 +0,0 @@
-import { useCallback, useMemo, useState } from "react";
-import { useInfiniteComments } from "../hooks/use-infinite-comments";
-import { WatchCommentSkeleton } from "./watch-comment-skeleton";
-import { WatchCommentsLazyList } from "./watch-comments-lazy-list";
-
-const SKELETON_KEYS = Array.from({ length: 5 }, (_, i) => `cs-${i}`);
-const INITIAL_RENDER_COUNT = 4;
-const RENDER_STEP = 4;
-
-type Props = {
- videoUrl: string;
- onSeekTimestamp?: (seconds: number) => void;
-};
-
-export function WatchComments({ videoUrl, onSeekTimestamp }: Props) {
- const { data, isFetchingNextPage, hasNextPage, fetchNextPage, isLoading } =
- useInfiniteComments(videoUrl);
- const [renderCount, setRenderCount] = useState(INITIAL_RENDER_COUNT);
-
- const commentsDisabled = data?.pages[0]?.commentsDisabled ?? false;
- const allComments = data?.pages.flatMap((p) => p.comments) ?? [];
- const comments = allComments.filter(
- (c) => (c.text as string | null) && (c.author as string | null),
- );
- const visibleComments = useMemo(() => comments.slice(0, renderCount), [comments, renderCount]);
- const hasHiddenComments = visibleComments.length < comments.length;
- const loadMore = useCallback(() => {
- if (hasHiddenComments) {
- setRenderCount((count) => Math.min(count + RENDER_STEP, comments.length));
- return;
- }
- if (hasNextPage && !isFetchingNextPage) {
- fetchNextPage();
- }
- }, [hasHiddenComments, comments.length, hasNextPage, isFetchingNextPage, fetchNextPage]);
- const showSkeletons = isLoading || isFetchingNextPage;
- const canLoadMore = hasHiddenComments || !!hasNextPage;
-
- return (
-
-
Comments
- {commentsDisabled ? (
-
Comments are disabled for this video.
- ) : (
-
-
- {showSkeletons && SKELETON_KEYS.map((k) => )}
- {canLoadMore && !isLoading && (
-
- {isFetchingNextPage ? "Loading..." : "Load more comments"}
-
- )}
-
- )}
-
- );
-}
diff --git a/apps/web/src/components/watch-description.tsx b/apps/web/src/components/watch-description.tsx
deleted file mode 100644
index e343a4da..00000000
--- a/apps/web/src/components/watch-description.tsx
+++ /dev/null
@@ -1,49 +0,0 @@
-import { useState } from "react";
-import { useClientLocale } from "../hooks/use-client-locale";
-import { formatExactDate } from "../lib/format";
-import { RichText } from "./rich-text";
-
-type Props = {
- description: string;
- uploadedAt?: number;
- onSeekTimestamp?: (seconds: number) => void;
-};
-
-export function WatchDescription({ description, uploadedAt, onSeekTimestamp }: Props) {
- const [expanded, setExpanded] = useState(false);
- const locale = useClientLocale();
- const exactDate = formatExactDate(uploadedAt, locale);
-
- if (!expanded) {
- return (
-
-
-
-
-
setExpanded(true)}
- >
- Show more
-
-
- );
- }
-
- return (
-
- {exactDate &&
{exactDate}
}
-
-
-
-
setExpanded(false)}
- >
- Show less
-
-
- );
-}
diff --git a/apps/web/src/components/watch-icons.tsx b/apps/web/src/components/watch-icons.tsx
deleted file mode 100644
index 9b250211..00000000
--- a/apps/web/src/components/watch-icons.tsx
+++ /dev/null
@@ -1,151 +0,0 @@
-function SvgIcon({ children, label }: { children: React.ReactNode; label: string }) {
- return (
-
- {children}
-
- );
-}
-
-export function ShareIcon() {
- return (
-
-
-
-
-
-
-
- );
-}
-
-export function ListPlusIcon() {
- return (
-
-
-
-
-
-
-
- );
-}
-
-export function StarIcon({ filled }: { filled?: boolean }) {
- return (
-
-
-
- );
-}
-
-export function DanmakuIcon() {
- return (
-
-
-
-
-
- );
-}
-
-export function MoreIcon() {
- return (
-
-
-
-
-
- );
-}
-
-export function DownloadIcon() {
- return (
-
-
-
-
-
- );
-}
-
-export function HeadphonesIcon() {
- return (
-
-
-
-
-
- );
-}
-
-export function VerifiedBadgeIcon() {
- return (
-
-
-
-
- );
-}
-
-export function BugIcon() {
- return (
-
-
-
-
-
-
-
-
-
-
-
-
-
- );
-}
-
-export function ThumbsUpIcon() {
- return (
-
-
-
-
- );
-}
-
-export function ThumbsDownIcon() {
- return (
-
-
-
-
- );
-}
diff --git a/apps/web/src/components/watch-info.tsx b/apps/web/src/components/watch-info.tsx
deleted file mode 100644
index 68e3db1d..00000000
--- a/apps/web/src/components/watch-info.tsx
+++ /dev/null
@@ -1,135 +0,0 @@
-import { useEffect, useState } from "react";
-import { useClientLocale } from "../hooks/use-client-locale";
-import { useSubscriptions } from "../hooks/use-subscriptions";
-import { formatPublishedDate, formatSubscribers, formatViews } from "../lib/format";
-import type { VideoStream } from "../types/stream";
-import { AllowChannelButton } from "./allow-channel-button";
-import { ChannelAvatar } from "./channel-avatar";
-import { ChannelRouteLink } from "./channel-route-link";
-import { Toast } from "./toast";
-import { VerifiedBadgeIcon } from "./watch-icons";
-import { WatchLikeDislike } from "./watch-like-dislike";
-
-type Props = {
- stream: VideoStream;
-};
-
-export function WatchInfo({ stream }: Props) {
- const locale = useClientLocale();
- const { add, remove, isSubscribed } = useSubscriptions();
- const subscribed = stream.channelUrl ? isSubscribed(stream.channelUrl) : false;
- const [toastMsg, setToastMsg] = useState(null);
- const publishedText = formatPublishedDate(stream.publishedAt, undefined, locale);
-
- useEffect(() => {
- if (!toastMsg) return;
- const t = setTimeout(() => setToastMsg(null), 2000);
- return () => clearTimeout(t);
- }, [toastMsg]);
-
- async function handleSubscribe() {
- if (!stream.channelUrl) return;
- try {
- if (subscribed) {
- await remove.mutateAsync(stream.channelUrl);
- setToastMsg(`Unsubscribed from ${stream.channelName}`);
- } else {
- await add.mutateAsync({
- channelUrl: stream.channelUrl,
- name: stream.channelName,
- avatarUrl: stream.channelAvatar,
- });
- setToastMsg(`Subscribed to ${stream.channelName}`);
- }
- } catch {
- setToastMsg("Subscription update failed");
- }
- }
-
- const channelMeta = (
-
-
- {stream.channelName}
- {stream.uploaderVerified && }
-
-
- {formatSubscribers(stream.uploaderSubscriberCount)}
- {publishedText && ` · ${publishedText}`}
-
-
- );
-
- return (
-
-
-
{stream.title}
-
- {formatViews(stream.views)}
-
-
-
-
- {stream.channelUrl ? (
-
-
-
-
- {stream.channelName}
- {stream.uploaderVerified && }
-
-
- {formatSubscribers(stream.uploaderSubscriberCount)}
- {publishedText && ` · ${publishedText}`}
-
-
-
- ) : (
-
-
- {channelMeta}
-
- )}
-
-
-
- {stream.channelUrl && (
- <>
-
-
- {subscribed ? "Subscribed" : "Subscribe"}
-
- >
- )}
-
-
-
-
-
- );
-}
diff --git a/apps/web/src/components/watch-layout-classes.ts b/apps/web/src/components/watch-layout-classes.ts
deleted file mode 100644
index bae6e4cd..00000000
--- a/apps/web/src/components/watch-layout-classes.ts
+++ /dev/null
@@ -1,21 +0,0 @@
-export type WatchLayoutClasses = ReturnType;
-
-export function getWatchLayoutClasses(cinemaMode: boolean, hasSecondaryContent: boolean) {
- const anim = "[animation:page-fade-in_0.2s_ease-out]";
- const standardLayout = hasSecondaryContent
- ? "pt-2 sm:pt-3 lg:flex-row lg:items-start"
- : "pt-2 sm:pt-3 lg:items-center";
- return {
- containerClass: `flex flex-col gap-6 ${cinemaMode ? "" : standardLayout} ${anim}`,
- playerWrapClass: cinemaMode
- ? "overflow-hidden bg-black"
- : `min-w-0 flex flex-col gap-4 ${
- hasSecondaryContent ? "flex-[2] max-w-[133.333vh]" : "mx-auto w-full max-w-[1600px]"
- }`,
- playerBoxClass: cinemaMode
- ? "relative mx-auto aspect-video w-[min(100%,calc((100svh-4.5rem)*16/9))]"
- : "relative overflow-hidden rounded-lg",
- playerClassName: cinemaMode ? "w-full h-full dark [--video-aspect-ratio:16/9]" : undefined,
- mediaClassName: cinemaMode ? "object-cover" : undefined,
- };
-}
diff --git a/apps/web/src/components/watch-layout-player-overlay.tsx b/apps/web/src/components/watch-layout-player-overlay.tsx
deleted file mode 100644
index 63a78245..00000000
--- a/apps/web/src/components/watch-layout-player-overlay.tsx
+++ /dev/null
@@ -1,35 +0,0 @@
-import type { RefObject } from "react";
-import { getOriginalAudioLocale } from "../lib/audio-track";
-import type { BulletCommentItem } from "../types/api";
-import type { VideoStream } from "../types/stream";
-import type { SettingsItem } from "../types/user";
-import { WatchPlayerOverlay } from "./watch-player-overlay";
-
-type Props = {
- isNicoNico: boolean;
- hideComments: boolean;
- bulletCommentsOn: boolean;
- bulletComments: BulletCommentItem[] | undefined;
- positionRef: RefObject;
- stream: VideoStream;
- settings: SettingsItem;
- qualityFailed: boolean;
- onOriginalLanguageUnavailable: () => void;
-};
-
-export function WatchLayoutPlayerOverlay(props: Props) {
- return (
-
- );
-}
diff --git a/apps/web/src/components/watch-layout-types.ts b/apps/web/src/components/watch-layout-types.ts
deleted file mode 100644
index 5e88ddf3..00000000
--- a/apps/web/src/components/watch-layout-types.ts
+++ /dev/null
@@ -1,10 +0,0 @@
-import type { VideoStream } from "../types/stream";
-
-export type WatchLayoutProps = {
- stream: VideoStream;
- startTime: number;
- currentParam: string;
- navigating: boolean;
- list?: string;
- shuffle?: string;
-};
diff --git a/apps/web/src/components/watch-layout.tsx b/apps/web/src/components/watch-layout.tsx
deleted file mode 100644
index f81a11ea..00000000
--- a/apps/web/src/components/watch-layout.tsx
+++ /dev/null
@@ -1,187 +0,0 @@
-import { useRef } from "react";
-import { useDeArrowBranding } from "../hooks/use-dearrow";
-import { useMobile } from "../hooks/use-mobile";
-import { usePlayerError } from "../hooks/use-player-error";
-import { usePlayerErrorResume } from "../hooks/use-player-error-resume";
-import { useSaveProgress } from "../hooks/use-progress";
-import { useSabrPlaybackConfig } from "../hooks/use-sabr-playback-config";
-import { useSettings } from "../hooks/use-settings";
-import { useVolumeSync } from "../hooks/use-volume-sync";
-import { useWatchAudioOnlyPlayback } from "../hooks/use-watch-audio-only-playback";
-import { useWatchBulletComments } from "../hooks/use-watch-bullet-comments";
-import { useWatchVttAssets } from "../hooks/use-watch-layout-assets";
-import { useWatchPlaybackFlow } from "../hooks/use-watch-playback-flow";
-import { useWatchPlayerSourceState } from "../hooks/use-watch-player-source-state";
-import { useWatchPlaylist } from "../hooks/use-watch-playlist";
-import { useWatchSponsorBlock } from "../hooks/use-watch-sponsorblock";
-import { useWatchToast } from "../hooks/use-watch-toast";
-import { getOriginalAudioLocale } from "../lib/audio-track";
-import { useDanmakuStore } from "../stores/danmaku-store";
-import { useWatchLayoutStore } from "../stores/watch-layout-store";
-import { Toast } from "./toast";
-import { getWatchLayoutClasses } from "./watch-layout-classes";
-import { WatchLayoutPlayerOverlay } from "./watch-layout-player-overlay";
-import type { WatchLayoutProps } from "./watch-layout-types";
-import { WatchSecondaryContent } from "./watch-secondary-content";
-import { WatchStage } from "./watch-stage";
-export function WatchLayout({
- stream,
- startTime,
- currentParam,
- navigating,
- list,
- shuffle,
-}: WatchLayoutProps) {
- const isMobile = useMobile();
- const save = useSaveProgress(stream.id);
- const { settings, update, settingsReady } = useSettings();
- const branding = useDeArrowBranding(stream.id, stream.title, stream.thumbnail, stream.duration);
- const displayStream = { ...stream, ...branding };
- const isLive = stream.streamType === "live_stream" || stream.streamType === "audio_live_stream";
- const player = usePlayerError(stream, isLive);
- const { on: bulletCommentsOn } = useDanmakuStore();
- const { isNicoNico, bulletComments } = useWatchBulletComments(stream.id, settings.hideComments);
- const sponsor = useWatchSponsorBlock(stream, settings);
- const relatedStreams = settings.hideRelatedVideos ? [] : (stream.related ?? []);
- const playlist = useWatchPlaylist(list, shuffle, currentParam);
- const cinemaMode = useWatchLayoutStore((state) => state.cinemaMode);
- const seekRef = useRef<((seconds: number) => void) | null>(null);
- const positionReaderRef = useRef<(() => number | null) | null>(null);
- const handleVolumeChange = useVolumeSync(update.mutate);
- const { thumbnailVtt, chaptersVtt } = useWatchVttAssets(
- stream,
- sponsor.segments,
- settings.sponsorBlockShowChapters,
- );
- const { autoplay, playerEvents } = useWatchPlaybackFlow({
- stream,
- settings,
- settingsReady,
- isLive,
- nextParam: playlist.nextParam,
- nextVideo: playlist.nextVideo,
- list,
- shuffle,
- mutate: (position, keepalive) => save.mutate({ position, keepalive }),
- onPlay: player.clearFailed,
- });
- const audioOnly = useWatchAudioOnlyPlayback({
- currentParam,
- settings,
- settingsReady,
- isLive,
- positionRef: playerEvents.positionRef,
- readPositionMs: () => positionReaderRef.current?.() ?? null,
- clearFailed: player.clearFailed,
- sabrEnabled: player.sabrEnabled,
- });
- const sabrConfig = useSabrPlaybackConfig(
- stream,
- player.sabrEnabled,
- settings.defaultQuality,
- settings.defaultAudioLanguage,
- audioOnly.active,
- );
- const { toast, setToast } = useWatchToast(audioOnly.unavailable);
- const { retryStartTime, handlePlayerError } = usePlayerErrorResume(
- stream.id,
- stream.duration,
- playerEvents.positionRef,
- player.handleError,
- );
- const sourceState = useWatchPlayerSourceState({
- streamId: stream.id,
- retryKey: player.retryKey,
- startTime: retryStartTime > 0 ? retryStartTime : (audioOnly.switchPositionMs ?? startTime),
- manifestSrc: audioOnly.src ?? player.manifestSrc,
- positionRef: playerEvents.positionRef,
- highQuality: true,
- hasThumbnails: Boolean(thumbnailVtt),
- hasChapters: Boolean(chaptersVtt),
- audioOnlyEnabled: audioOnly.enabled,
- audioOnlyLoading: audioOnly.loading,
- hasAudioOnlySource: Boolean(audioOnly.src),
- sabrEnabled: player.sabrEnabled,
- settingsReady,
- autoplayEnabled: settings.autoplay,
- navigating,
- playbackIntent: playerEvents.playbackIntent,
- });
- const classes = getWatchLayoutClasses(
- cinemaMode,
- Boolean(!isMobile && (playlist.panel || relatedStreams.length > 0)),
- );
- return (
-
- setToast("Original audio unavailable")}
- />
- }
- autoplayState={autoplay.autoplayState}
- sponsorBlockSegments={sponsor.segments}
- autoSkipSegments={sponsor.autoSkipSegments}
- manualSkipSegments={sponsor.manualSkipSegments}
- thumbnailVtt={thumbnailVtt}
- chaptersVtt={chaptersVtt}
- playerFailed={player.playerFailed}
- cinemaMode={cinemaMode}
- hideComments={settings.hideComments}
- mobilePanel={isMobile ? playlist.panel : null}
- seekRef={seekRef}
- audioOnlyControls={audioOnly.controls}
- onCaptionStylesChange={(captionStyles) => update.mutate({ captionStyles })}
- onVolumeChange={handleVolumeChange}
- onTimeUpdate={playerEvents.handleTimeUpdate}
- onPlay={playerEvents.handlePlay}
- onPause={playerEvents.handlePause}
- onSeeking={audioOnly.active ? () => undefined : player.handleSeeking}
- onSeeked={playerEvents.handleSeeked}
- onEnded={playerEvents.handleEnded}
- onAutoplayPlayNow={autoplay.playNow}
- onAutoplayCancel={autoplay.cancel}
- onAutoplayPauseToggle={autoplay.togglePause}
- onPositionReaderChange={(reader) => (positionReaderRef.current = reader)}
- onPreviousVideo={playlist.playPrevious}
- onNextVideo={playlist.playNext}
- onError={(positionMs) =>
- audioOnly.fail() ? setToast("Audio only unavailable") : handlePlayerError(positionMs)
- }
- onReset={player.reset}
- />
- seekRef.current?.(seconds)}
- audioOnly={audioOnly.controls}
- />
-
-
- );
-}
diff --git a/apps/web/src/components/watch-like-dislike.tsx b/apps/web/src/components/watch-like-dislike.tsx
deleted file mode 100644
index 468e194f..00000000
--- a/apps/web/src/components/watch-like-dislike.tsx
+++ /dev/null
@@ -1,30 +0,0 @@
-import { formatLikes } from "../lib/format";
-import type { VideoStream } from "../types/stream";
-import { ThumbsDownIcon, ThumbsUpIcon } from "./watch-icons";
-
-type Props = {
- stream: VideoStream;
-};
-
-export function WatchLikeDislike({ stream }: Props) {
- const hasLikes = typeof stream.likes === "number" && stream.likes >= 0;
- const hasDislikes = typeof stream.dislikes === "number" && stream.dislikes >= 0;
- if (!hasLikes && !hasDislikes) return null;
-
- return (
-
- {hasLikes && (
-
-
- {formatLikes(stream.likes ?? 0)}
-
- )}
- {hasDislikes && (
-
-
- {formatLikes(stream.dislikes ?? 0)}
-
- )}
-
- );
-}
diff --git a/apps/web/src/components/watch-meta.tsx b/apps/web/src/components/watch-meta.tsx
deleted file mode 100644
index ff9ee080..00000000
--- a/apps/web/src/components/watch-meta.tsx
+++ /dev/null
@@ -1,32 +0,0 @@
-import type { WatchAudioOnlyControls } from "../hooks/use-watch-audio-only-playback";
-import type { VideoStream } from "../types/stream";
-import { WatchActions } from "./watch-actions";
-import { WatchComments } from "./watch-comments";
-import { WatchDescription } from "./watch-description";
-import { WatchInfo } from "./watch-info";
-
-type Props = {
- stream: VideoStream;
- showComments?: boolean;
- onSeekTimestamp?: (seconds: number) => void;
- audioOnly: WatchAudioOnlyControls;
-};
-
-export function WatchMeta({ stream, showComments = true, onSeekTimestamp, audioOnly }: Props) {
- return (
- <>
-
-
- {stream.description && (
-
- )}
- {showComments && (
-
- )}
- >
- );
-}
diff --git a/apps/web/src/components/watch-more-actions.tsx b/apps/web/src/components/watch-more-actions.tsx
deleted file mode 100644
index 31ef7276..00000000
--- a/apps/web/src/components/watch-more-actions.tsx
+++ /dev/null
@@ -1,80 +0,0 @@
-import { useRef, useState } from "react";
-import { useBlocked } from "../hooks/use-blocked";
-import { goto } from "../lib/route-redirect";
-import type { VideoStream } from "../types/stream";
-import { VideoBlockActionsDropdown } from "./video-block-actions-dropdown";
-import { MoreIcon } from "./watch-icons";
-
-type Props = {
- stream: VideoStream;
- isAuthed: boolean;
- onSaved: (label: string) => void;
- className: string;
-};
-
-export function WatchMoreActions({ stream, isAuthed, onSaved, className }: Props) {
- const [menuOpen, setMenuOpen] = useState(false);
- const menuAnchorRef = useRef(null);
- const { channels, videos, addChannel, removeChannel, addVideo, removeVideo } = useBlocked();
- const channelBlocked =
- !!stream.channelUrl &&
- (channels.data ?? []).some((blocked) => blocked.url === stream.channelUrl);
- const videoBlocked = (videos.data ?? []).some((blocked) => blocked.url === stream.id);
-
- function ensureAuth(): boolean {
- if (isAuthed) return true;
- goto("/");
- return false;
- }
-
- function toggleVideoBlock() {
- if (!ensureAuth()) return;
- if (videoBlocked) {
- removeVideo.mutate(stream.id);
- onSaved("Video unblocked");
- return;
- }
- addVideo.mutate({ url: stream.id, global: false });
- onSaved("Video blocked");
- }
-
- function toggleChannelBlock() {
- if (!stream.channelUrl || !ensureAuth()) return;
- if (channelBlocked) {
- removeChannel.mutate(stream.channelUrl);
- onSaved("Channel unblocked");
- return;
- }
- addChannel.mutate({
- url: stream.channelUrl,
- name: stream.channelName,
- thumbnailUrl: stream.channelAvatar,
- global: false,
- });
- onSaved(`Channel blocked: ${stream.channelName}`);
- }
-
- return (
- <>
- setMenuOpen((open) => !open)}
- className={className}
- >
-
- More
-
- {menuOpen && (
- setMenuOpen(false)}
- onToggleVideoBlock={toggleVideoBlock}
- onToggleChannelBlock={stream.channelUrl ? toggleChannelBlock : undefined}
- videoBlocked={videoBlocked}
- channelBlocked={channelBlocked}
- />
- )}
- >
- );
-}
diff --git a/apps/web/src/components/watch-page-skeleton.tsx b/apps/web/src/components/watch-page-skeleton.tsx
deleted file mode 100644
index e3e4e987..00000000
--- a/apps/web/src/components/watch-page-skeleton.tsx
+++ /dev/null
@@ -1,67 +0,0 @@
-import type { VideoStream } from "../types/stream";
-import { PageSpinner } from "./page-spinner";
-import { RelatedCardSkeleton } from "./related-card-skeleton";
-import { RelatedVideos } from "./related-videos";
-import { WatchCommentSkeleton } from "./watch-comment-skeleton";
-import { WatchComments } from "./watch-comments";
-import { WatchInfo } from "./watch-info";
-
-const RELATED_KEYS = ["related-1", "related-2", "related-3", "related-4", "related-5"];
-const COMMENT_KEYS = ["comment-1", "comment-2", "comment-3"];
-
-type Props = {
- stream?: VideoStream;
- relatedStreams?: VideoStream[];
- videoUrl?: string;
- showComments?: boolean;
-};
-
-export function WatchPageSkeleton({
- stream,
- relatedStreams = [],
- videoUrl,
- showComments = true,
-}: Props) {
- return (
-
-
-
- {stream ? (
-
- ) : (
-
- )}
- {showComments &&
- (videoUrl ? (
-
- ) : (
-
- {COMMENT_KEYS.map((key) => (
-
- ))}
-
- ))}
-
-
- {relatedStreams.length > 0 ? (
-
- ) : (
- RELATED_KEYS.map((key) => )
- )}
-
-
- );
-}
diff --git a/apps/web/src/components/watch-player-crossfade.tsx b/apps/web/src/components/watch-player-crossfade.tsx
deleted file mode 100644
index c7580c6c..00000000
--- a/apps/web/src/components/watch-player-crossfade.tsx
+++ /dev/null
@@ -1,69 +0,0 @@
-import { type ReactNode, useEffect, useRef, useState } from "react";
-import { AudioOnlyPoster } from "./audio-only-poster";
-
-type Snapshot = {
- id: number;
- poster?: string;
- title?: string;
-};
-
-type Props = {
- audioOnly: boolean;
- poster?: string;
- title?: string;
- children: ReactNode;
-};
-
-export function WatchPlayerCrossfade({ audioOnly, poster, title, children }: Props) {
- const previousAudioOnly = useRef(audioOnly);
- const timeoutRef = useRef(null);
- const [snapshot, setSnapshot] = useState(null);
-
- useEffect(() => {
- const leavingAudio = previousAudioOnly.current && !audioOnly;
- previousAudioOnly.current = audioOnly;
-
- if (leavingAudio) {
- if (timeoutRef.current !== null) window.clearTimeout(timeoutRef.current);
- setSnapshot({ id: Date.now(), poster, title });
- timeoutRef.current = window.setTimeout(() => {
- setSnapshot(null);
- timeoutRef.current = null;
- }, 560);
- return;
- }
-
- if (audioOnly) {
- if (timeoutRef.current !== null) window.clearTimeout(timeoutRef.current);
- timeoutRef.current = null;
- setSnapshot(null);
- }
- }, [audioOnly, poster, title]);
-
- useEffect(
- () => () => {
- if (timeoutRef.current !== null) window.clearTimeout(timeoutRef.current);
- },
- [],
- );
-
- return (
- <>
- {children}
- {snapshot ? (
-
- ) : null}
- >
- );
-}
diff --git a/apps/web/src/components/watch-player-defaults.tsx b/apps/web/src/components/watch-player-defaults.tsx
deleted file mode 100644
index a328c617..00000000
--- a/apps/web/src/components/watch-player-defaults.tsx
+++ /dev/null
@@ -1,39 +0,0 @@
-import type { SettingsItem } from "../types/user";
-import { PlayerDefaults } from "./player-defaults";
-import { PlayerFocuser } from "./player-internals";
-
-type Props = {
- settings: SettingsItem;
- qualityFailed: boolean;
- originalAudioTrackId?: string | null;
- preferredDefaultAudioTrackId?: string | null;
- originalAudioLocale?: string | null;
- onOriginalLanguageUnavailable: () => void;
-};
-
-export function WatchPlayerDefaults({
- settings,
- qualityFailed,
- originalAudioTrackId,
- preferredDefaultAudioTrackId,
- originalAudioLocale,
- onOriginalLanguageUnavailable,
-}: Props) {
- return (
- <>
-
-
- >
- );
-}
diff --git a/apps/web/src/components/watch-player-overlay.tsx b/apps/web/src/components/watch-player-overlay.tsx
deleted file mode 100644
index e506d4c7..00000000
--- a/apps/web/src/components/watch-player-overlay.tsx
+++ /dev/null
@@ -1,49 +0,0 @@
-import type { RefObject } from "react";
-import { getOriginalAudioTrackId, getPreferredDefaultAudioTrackId } from "../lib/audio-track";
-import type { BulletCommentItem } from "../types/api";
-import type { VideoStream } from "../types/stream";
-import type { SettingsItem } from "../types/user";
-import { DanmakuOverlay } from "./danmaku-overlay";
-import { WatchPlayerDefaults } from "./watch-player-defaults";
-
-type Props = {
- isNicoNico: boolean;
- hideComments: boolean;
- bulletCommentsOn: boolean;
- bulletComments: BulletCommentItem[] | undefined;
- positionRef: RefObject;
- stream: VideoStream;
- settings: SettingsItem;
- qualityFailed: boolean;
- originalAudioLocale: string | null;
- onOriginalLanguageUnavailable: () => void;
-};
-
-export function WatchPlayerOverlay({
- isNicoNico,
- hideComments,
- bulletCommentsOn,
- bulletComments,
- positionRef,
- stream,
- settings,
- qualityFailed,
- originalAudioLocale,
- onOriginalLanguageUnavailable,
-}: Props) {
- return (
- <>
- {isNicoNico && !hideComments && bulletCommentsOn && bulletComments && (
-
- )}
-
- >
- );
-}
diff --git a/apps/web/src/components/watch-playlist-handle.tsx b/apps/web/src/components/watch-playlist-handle.tsx
deleted file mode 100644
index 5b48088d..00000000
--- a/apps/web/src/components/watch-playlist-handle.tsx
+++ /dev/null
@@ -1,60 +0,0 @@
-import { ChevronDown, ChevronUp, GripVertical } from "lucide-react";
-import type { DragEvent } from "react";
-
-type Props = {
- reorderable: boolean;
- isMobile: boolean;
- index: number;
- total: number;
- onDragStart: (event: DragEvent) => void;
- onMove: (direction: number) => void;
-};
-
-export function WatchPlaylistHandle({
- reorderable,
- isMobile,
- index,
- total,
- onDragStart,
- onMove,
-}: Props) {
- if (!reorderable) {
- return {index + 1} ;
- }
- if (isMobile) {
- return (
-
- onMove(-1)}
- disabled={index === 0}
- aria-label="Move up"
- className="transition-colors hover:text-fg disabled:opacity-30"
- >
-
-
- onMove(1)}
- disabled={index === total - 1}
- aria-label="Move down"
- className="transition-colors hover:text-fg disabled:opacity-30"
- >
-
-
-
- );
- }
- return (
- event.preventDefault()}
- aria-label="Drag to reorder"
- className="flex w-5 shrink-0 cursor-grab justify-center text-fg-soft transition-colors hover:text-fg active:cursor-grabbing"
- >
-
-
- );
-}
diff --git a/apps/web/src/components/watch-playlist-panel.tsx b/apps/web/src/components/watch-playlist-panel.tsx
deleted file mode 100644
index b0096d67..00000000
--- a/apps/web/src/components/watch-playlist-panel.tsx
+++ /dev/null
@@ -1,164 +0,0 @@
-import { ChevronDown, Shuffle } from "lucide-react";
-import { type DragEvent, type UIEvent, useEffect, useRef, useState } from "react";
-import { useFlipList } from "../hooks/use-flip-list";
-import { useMobile } from "../hooks/use-mobile";
-import { toPublicWatchParam } from "../lib/watch-url";
-import type { WatchPlaylistItem } from "../types/playlist";
-import { WatchPlaylistRow } from "./watch-playlist-row";
-
-type Props = {
- name: string;
- videos: WatchPlaylistItem[];
- listId: string;
- currentParam: string;
- shuffle: string | undefined;
- isLoadingMore?: boolean;
- onLoadMore?: () => void;
- onToggleShuffle: () => void;
- onReorder?: (videos: WatchPlaylistItem[]) => void;
-};
-
-export function WatchPlaylistPanel({
- name,
- videos,
- listId,
- currentParam,
- shuffle,
- isLoadingMore = false,
- onLoadMore,
- onToggleShuffle,
- onReorder,
-}: Props) {
- const isMobile = useMobile();
- const [collapsed, setCollapsed] = useState(isMobile);
- const [dragIndex, setDragIndex] = useState(null);
- const [overIndex, setOverIndex] = useState(null);
- const currentElement = useRef(null);
- const currentIndex = videos.findIndex((video) => toPublicWatchParam(video.url) === currentParam);
- const reorderable = Boolean(onReorder);
- const register = useFlipList(videos.map((video) => video.key).join("|"));
-
- useEffect(() => {
- if (!collapsed && currentIndex >= 0) {
- currentElement.current?.scrollIntoView({ block: "nearest" });
- }
- }, [collapsed, currentIndex]);
-
- function commit(next: WatchPlaylistItem[]) {
- onReorder?.(next);
- }
- function handleDragStart(event: DragEvent, index: number) {
- setDragIndex(index);
- event.dataTransfer.effectAllowed = "move";
- const row = (event.currentTarget as HTMLElement).closest("[data-pl-row]");
- if (row instanceof HTMLElement) event.dataTransfer.setDragImage(row, 20, 20);
- }
- function handleDrop(targetIndex: number) {
- if (onReorder && dragIndex !== null && dragIndex !== targetIndex) {
- const next = [...videos];
- const [moved] = next.splice(dragIndex, 1);
- next.splice(targetIndex, 0, moved);
- commit(next);
- }
- setDragIndex(null);
- setOverIndex(null);
- }
- function moveItem(index: number, direction: number) {
- const target = index + direction;
- if (!onReorder || target < 0 || target >= videos.length) return;
- const next = [...videos];
- const [moved] = next.splice(index, 1);
- next.splice(target, 0, moved);
- commit(next);
- }
- function handleScroll(event: UIEvent) {
- if (!onLoadMore || isLoadingMore) return;
- const list = event.currentTarget;
- const remaining = list.scrollHeight - list.scrollTop - list.clientHeight;
- if (remaining < 480) onLoadMore();
- }
-
- return (
-
-
- setCollapsed((value) => !value)}
- className="flex min-w-0 flex-1 flex-col items-start text-left"
- >
- {name}
-
- {currentIndex >= 0 ? currentIndex + 1 : "-"} / {videos.length}
-
-
-
-
- Shuffle
-
- setCollapsed((value) => !value)}
- aria-label={collapsed ? "Expand playlist" : "Collapse playlist"}
- className="shrink-0 text-fg-muted transition-colors hover:text-fg"
- >
-
-
-
- {!collapsed && (
-
- {videos.map((video, index) => {
- const isCurrent = index === currentIndex;
-
- return (
- {
- register(video.key, element);
- if (isCurrent) currentElement.current = element;
- }}
- data-pl-row="true"
- className={`group flex items-center gap-1 px-1 transition-colors hover:bg-surface-strong/60 ${
- overIndex === index && dragIndex !== null ? "ring-1 ring-accent ring-inset" : ""
- } ${dragIndex === index ? "opacity-40" : ""} ${isCurrent ? "bg-surface-strong" : ""}`}
- onDragOver={reorderable ? (event) => event.preventDefault() : undefined}
- onDragEnter={reorderable ? () => setOverIndex(index) : undefined}
- onDrop={reorderable ? () => handleDrop(index) : undefined}
- onDragEnd={
- reorderable
- ? () => {
- setDragIndex(null);
- setOverIndex(null);
- }
- : undefined
- }
- >
- handleDragStart(event, index)}
- onMove={(direction) => moveItem(index, direction)}
- />
-
- );
- })}
-
- )}
-
- );
-}
diff --git a/apps/web/src/components/watch-playlist-row.tsx b/apps/web/src/components/watch-playlist-row.tsx
deleted file mode 100644
index 337126dd..00000000
--- a/apps/web/src/components/watch-playlist-row.tsx
+++ /dev/null
@@ -1,86 +0,0 @@
-import { Link } from "@tanstack/react-router";
-import { Play } from "lucide-react";
-import type { DragEvent } from "react";
-import { useDeArrowBranding } from "../hooks/use-dearrow";
-import { proxyImage } from "../lib/proxy";
-import { toPublicWatchParam } from "../lib/watch-url";
-import type { WatchPlaylistItem } from "../types/playlist";
-import { WatchPlaylistHandle } from "./watch-playlist-handle";
-
-type Props = {
- video: WatchPlaylistItem;
- index: number;
- total: number;
- isCurrent: boolean;
- reorderable: boolean;
- isMobile: boolean;
- listId: string;
- shuffle: string | undefined;
- onDragStart: (event: DragEvent) => void;
- onMove: (direction: number) => void;
-};
-
-export function WatchPlaylistRow({
- video,
- index,
- total,
- isCurrent,
- reorderable,
- isMobile,
- listId,
- shuffle,
- onDragStart,
- onMove,
-}: Props) {
- const branding = useDeArrowBranding(video.url, video.title, proxyImage(video.thumbnail));
- return (
- <>
-
-
-
- {branding.thumbnail && (
-
- )}
- {isCurrent && (
-
- )}
-
-
-
- {branding.title}
-
- {video.channelName && (
-
{video.channelName}
- )}
-
-
- >
- );
-}
diff --git a/apps/web/src/components/watch-reply.tsx b/apps/web/src/components/watch-reply.tsx
deleted file mode 100644
index 6ab41900..00000000
--- a/apps/web/src/components/watch-reply.tsx
+++ /dev/null
@@ -1,38 +0,0 @@
-import { formatCommentPublishedTime } from "../lib/comment-time";
-import { formatLikes } from "../lib/format";
-import type { Comment } from "../types/comment";
-import { RichText } from "./rich-text";
-
-type Props = {
- reply: Comment;
- locale?: string;
- onSeekTimestamp?: (seconds: number) => void;
-};
-
-export function WatchReply({ reply, locale, onSeekTimestamp }: Props) {
- const publishedTime = formatCommentPublishedTime(reply.publishedAt, reply.publishedTime, locale);
-
- return (
-
-
-
-
- {reply.author}
- {publishedTime && {publishedTime} }
-
-
-
-
- {reply.likeCount >= 0 && (
-
{formatLikes(reply.likeCount)} likes
- )}
-
-
- );
-}
diff --git a/apps/web/src/components/watch-secondary-content.tsx b/apps/web/src/components/watch-secondary-content.tsx
deleted file mode 100644
index d8d85d57..00000000
--- a/apps/web/src/components/watch-secondary-content.tsx
+++ /dev/null
@@ -1,58 +0,0 @@
-import type { ReactNode } from "react";
-import type { WatchAudioOnlyControls } from "../hooks/use-watch-audio-only-playback";
-import type { VideoStream } from "../types/stream";
-import { RelatedVideos } from "./related-videos";
-import { WatchMeta } from "./watch-meta";
-
-type Props = {
- cinemaMode: boolean;
- stream: VideoStream;
- relatedStreams: VideoStream[];
- showComments: boolean;
- playlistPanel?: ReactNode;
- onSeekTimestamp: (seconds: number) => void;
- audioOnly: WatchAudioOnlyControls;
-};
-
-export function WatchSecondaryContent({
- cinemaMode,
- stream,
- relatedStreams,
- showComments,
- playlistPanel,
- onSeekTimestamp,
- audioOnly,
-}: Props) {
- const hasPlaylistPanel = Boolean(playlistPanel);
- const hasRelatedStreams = relatedStreams.length > 0;
-
- if (!cinemaMode) {
- if (!(hasPlaylistPanel || hasRelatedStreams)) return null;
-
- return (
-
- {playlistPanel}
- {hasRelatedStreams && }
-
- );
- }
-
- return (
-
-
-
-
- {(hasPlaylistPanel || hasRelatedStreams) && (
-
- {playlistPanel}
- {hasRelatedStreams && }
-
- )}
-
- );
-}
diff --git a/apps/web/src/components/watch-stage-player.tsx b/apps/web/src/components/watch-stage-player.tsx
deleted file mode 100644
index 3e35df0a..00000000
--- a/apps/web/src/components/watch-stage-player.tsx
+++ /dev/null
@@ -1,99 +0,0 @@
-import type { MutableRefObject, ReactNode } from "react";
-import type { SabrPlaybackConfig } from "../lib/sabr-source";
-import type { MediaSrc } from "../lib/vidstack";
-import type { SponsorBlockSegmentItem, SubtitleItem } from "../types/api";
-import type { CaptionStyles, SettingsItem } from "../types/user";
-import { VideoPlayer } from "./video-player";
-import { WatchPlayerCrossfade } from "./watch-player-crossfade";
-
-type Props = {
- audioOnly: boolean;
- streamTitle: string;
- poster?: string;
- playerKey: string;
- manifestSrc: MediaSrc;
- sabrConfig: SabrPlaybackConfig | null;
- isLive: boolean;
- startTime: number;
- seekIntervalSeconds?: number;
- subtitles?: SubtitleItem[];
- sponsorBlockSegments?: SponsorBlockSegmentItem[];
- autoSkipSegments?: SponsorBlockSegmentItem[];
- manualSkipSegments?: SponsorBlockSegmentItem[];
- settings: SettingsItem;
- settingsReady: boolean;
- autoplay: boolean;
- originalLocale: string | null;
- overlay: ReactNode;
- seekRef: MutableRefObject<((seconds: number) => void) | null>;
- thumbnailVtt?: string;
- chaptersVtt?: string;
- playerClassName?: string;
- mediaClassName?: string;
- onCaptionStylesChange: (styles: CaptionStyles) => void;
- onVolumeChange: (volume: number, muted: boolean) => void;
- onTimeUpdate: (positionMs: number) => void;
- onPlay: () => void;
- onPause: () => void;
- onSeeking: (positionMs: number) => void;
- onSeeked: () => void;
- onError: (positionMs?: number) => void;
- onPositionReaderChange: (reader: (() => number | null) | null) => void;
- onEnded: () => void;
- onPreviousVideo?: () => void;
- onNextVideo?: () => void;
-};
-
-export function WatchStagePlayer(props: Props) {
- const settings = props.settings;
- return (
-
- (props.seekRef.current = seek)}
- className={props.playerClassName}
- mediaClassName={props.mediaClassName}
- />
-
- );
-}
diff --git a/apps/web/src/components/watch-stage.tsx b/apps/web/src/components/watch-stage.tsx
deleted file mode 100644
index f6b04d3b..00000000
--- a/apps/web/src/components/watch-stage.tsx
+++ /dev/null
@@ -1,185 +0,0 @@
-import type { MutableRefObject, ReactNode } from "react";
-import type { WatchAudioOnlyControls } from "../hooks/use-watch-audio-only-playback";
-import type { AutoplayState } from "../hooks/use-watch-ended-navigation";
-import type { SabrPlaybackConfig } from "../lib/sabr-source";
-import type { MediaSrc } from "../lib/vidstack";
-import type { SponsorBlockSegmentItem } from "../types/api";
-import type { VideoStream } from "../types/stream";
-import type { CaptionStyles, SettingsItem } from "../types/user";
-import { AutoplayCountdownOverlay } from "./autoplay-countdown-overlay";
-import { PageSpinner } from "./page-spinner";
-import { PlayerError } from "./player-error";
-import type { WatchLayoutClasses } from "./watch-layout-classes";
-import { WatchMeta } from "./watch-meta";
-import { WatchStagePlayer } from "./watch-stage-player";
-
-type Props = {
- classes: WatchLayoutClasses;
- stream: VideoStream;
- settings: SettingsItem;
- manifestSrc: MediaSrc;
- sabrConfig: SabrPlaybackConfig | null;
- audioOnly: boolean;
- playerKey: string;
- startTime: number;
- seekIntervalSeconds?: number;
- isLive: boolean;
- settingsReady: boolean;
- autoplay: boolean;
- navigating: boolean;
- originalLocale: string | null;
- overlay: ReactNode;
- autoplayState: AutoplayState | null;
- sponsorBlockSegments?: SponsorBlockSegmentItem[];
- autoSkipSegments?: SponsorBlockSegmentItem[];
- manualSkipSegments?: SponsorBlockSegmentItem[];
- thumbnailVtt?: string;
- chaptersVtt?: string;
- playerFailed: boolean;
- cinemaMode: boolean;
- hideComments: boolean;
- mobilePanel: ReactNode;
- seekRef: MutableRefObject<((seconds: number) => void) | null>;
- audioOnlyControls: WatchAudioOnlyControls;
- onCaptionStylesChange: (styles: CaptionStyles) => void;
- onVolumeChange: (volume: number, muted: boolean) => void;
- onTimeUpdate: (positionMs: number) => void;
- onPlay: () => void;
- onPause: () => void;
- onSeeking: (positionMs: number) => void;
- onSeeked: () => void;
- onEnded: () => void;
- onAutoplayPlayNow: () => void;
- onAutoplayCancel: () => void;
- onAutoplayPauseToggle: () => void;
- onPositionReaderChange: (reader: (() => number | null) | null) => void;
- onPreviousVideo?: () => void;
- onNextVideo?: () => void;
- onError: (positionMs?: number) => void;
- onReset: () => void;
-};
-
-export function WatchStage({
- classes,
- stream,
- settings,
- manifestSrc,
- sabrConfig,
- audioOnly,
- playerKey,
- startTime,
- seekIntervalSeconds,
- isLive,
- settingsReady,
- autoplay,
- navigating,
- originalLocale,
- overlay,
- autoplayState,
- sponsorBlockSegments,
- autoSkipSegments,
- manualSkipSegments,
- thumbnailVtt,
- chaptersVtt,
- playerFailed,
- cinemaMode,
- hideComments,
- mobilePanel,
- seekRef,
- audioOnlyControls,
- onCaptionStylesChange,
- onVolumeChange,
- onTimeUpdate,
- onPlay,
- onPause,
- onSeeking,
- onSeeked,
- onEnded,
- onAutoplayPlayNow,
- onAutoplayCancel,
- onAutoplayPauseToggle,
- onPositionReaderChange,
- onPreviousVideo,
- onNextVideo,
- onError,
- onReset,
-}: Props) {
- const playerOverlay = (
- <>
- {overlay}
- {autoplayState && (
-
- )}
- >
- );
-
- return (
-
-
- {navigating ? (
-
- ) : playerFailed ? (
-
- ) : (
-
- )}
-
- {mobilePanel ?
{mobilePanel}
: null}
- {!cinemaMode && (
-
seekRef.current?.(seconds)}
- audioOnly={audioOnlyControls}
- />
- )}
-
- );
-}
diff --git a/apps/web/src/components/watched-badge.tsx b/apps/web/src/components/watched-badge.tsx
deleted file mode 100644
index 481df450..00000000
--- a/apps/web/src/components/watched-badge.tsx
+++ /dev/null
@@ -1,30 +0,0 @@
-function CheckIcon() {
- return (
-
-
-
- );
-}
-
-export function WatchedBadge() {
- return (
-
-
-
- );
-}
diff --git a/apps/web/src/components/youtube-icon.tsx b/apps/web/src/components/youtube-icon.tsx
deleted file mode 100644
index 3c70ab3a..00000000
--- a/apps/web/src/components/youtube-icon.tsx
+++ /dev/null
@@ -1,22 +0,0 @@
-import type { SVGProps } from "react";
-import { siYoutube } from "simple-icons";
-
-type Props = SVGProps & {
- title?: string;
-};
-
-export function YoutubeIcon({ title, ...props }: Props) {
- return (
-
-
-
- );
-}
diff --git a/apps/web/src/components/youtube-import-cooking.tsx b/apps/web/src/components/youtube-import-cooking.tsx
deleted file mode 100644
index 58a6978c..00000000
--- a/apps/web/src/components/youtube-import-cooking.tsx
+++ /dev/null
@@ -1,55 +0,0 @@
-import type {
- YoutubeTakeoutImportJob,
- YoutubeTakeoutPreview,
- YoutubeTakeoutReport,
-} from "../lib/api-youtube-import";
-import { YoutubeImportJobSummary } from "../settings/youtube-import-job-summary";
-import { CardLiquidFill } from "./card-liquid-fill";
-import { ImportMascotLoop } from "./import-mascot-loop";
-
-type Props = {
- job: YoutubeTakeoutImportJob | null;
- preview: YoutubeTakeoutPreview | null;
- report: YoutubeTakeoutReport | null;
- queueLength: number;
- currentIndex: number;
-};
-
-function computeProgress(
- job: YoutubeTakeoutImportJob | null,
- queueLength: number,
- currentIndex: number,
-): number {
- const phaseWeight = 100 / Math.max(queueLength, 1);
- const baseProgress = currentIndex * phaseWeight;
- const jobProgress = typeof job?.progress === "number" ? job.progress : 0;
- const phaseProgress = (jobProgress / 100) * phaseWeight;
- return Math.min(baseProgress + phaseProgress, 100);
-}
-
-export function YoutubeImportCooking({ job, preview, report, queueLength, currentIndex }: Props) {
- const progress = computeProgress(job, queueLength, currentIndex);
-
- return (
-
-
-
-
-
-
-
Cooking your import
-
- Unpacking and syncing your data. This may take a moment.
-
-
estimated sync in progress...
-
-
-
-
-
- );
-}
diff --git a/apps/web/src/components/youtube-import-done.tsx b/apps/web/src/components/youtube-import-done.tsx
deleted file mode 100644
index 081b7663..00000000
--- a/apps/web/src/components/youtube-import-done.tsx
+++ /dev/null
@@ -1,44 +0,0 @@
-import { Link } from "@tanstack/react-router";
-import type {
- YoutubeTakeoutImportJob,
- YoutubeTakeoutPreview,
- YoutubeTakeoutReport,
-} from "../lib/api-youtube-import";
-import { YoutubeImportJobSummary } from "../settings/youtube-import-job-summary";
-import { ImportMascotLoop } from "./import-mascot-loop";
-
-type Props = {
- job: YoutubeTakeoutImportJob | null;
- preview: YoutubeTakeoutPreview | null;
- report: YoutubeTakeoutReport | null;
-};
-
-export function YoutubeImportDone({ job, preview, report }: Props) {
- return (
-
-
-
-
-
-
Import complete
-
- Your data has been plated. Check your subscriptions and playlists.
-
-
- View subscriptions
-
-
-
-
-
-
- );
-}
diff --git a/apps/web/src/components/youtube-import-drop.tsx b/apps/web/src/components/youtube-import-drop.tsx
deleted file mode 100644
index f710c389..00000000
--- a/apps/web/src/components/youtube-import-drop.tsx
+++ /dev/null
@@ -1,114 +0,0 @@
-import { YoutubeImportDropzone } from "../settings/youtube-import-dropzone";
-import { YoutubeImportQueueList } from "./youtube-import-queue-list";
-
-const TAKEOUT_URL =
- "https://takeout.google.com/settings/takeout/custom/youtube,my_activity?dest=mail&frequency=once";
-
-type Props = {
- queue: File[];
- currentIndex: number;
- queueStarted: boolean;
- busy: boolean;
- inlineError: string | null;
- guideOpen: boolean;
- onToggleGuide: () => void;
- onOpenTakeout: () => void;
- onQueueFiles: (files: File[]) => void;
- onRemoveFile: (index: number) => void;
- onClearQueue: () => void;
- onStart: () => void;
-};
-
-export function YoutubeImportDrop(props: Props) {
- const {
- queue,
- currentIndex,
- queueStarted,
- busy,
- inlineError,
- guideOpen,
- onToggleGuide,
- onOpenTakeout,
- onQueueFiles,
- onRemoveFile,
- onClearQueue,
- onStart,
- } = props;
-
- return (
-
-
-
-
-
- {inlineError && (
-
- {inlineError}
-
- )}
-
-
- {queue.length > 0 && (
-
- Start import
-
- )}
-
- {guideOpen ? "Hide guide" : "Need help?"}
-
-
- Open Google Takeout
-
-
-
- {guideOpen &&
}
-
- );
-}
-
-function TakeoutGuide() {
- return (
-
-
How to export from YouTube
-
-
- Go to{" "}
-
- takeout.google.com
-
-
- Click "Deselect all", then select only YouTube and My Activity
- Choose ZIP format, any size
- Create export and wait for the email
- Download the ZIP and drop it here
-
-
- We import subscriptions, playlists, and watch history (if available).
-
-
- );
-}
diff --git a/apps/web/src/components/youtube-import-queue-list.tsx b/apps/web/src/components/youtube-import-queue-list.tsx
deleted file mode 100644
index de01ce55..00000000
--- a/apps/web/src/components/youtube-import-queue-list.tsx
+++ /dev/null
@@ -1,66 +0,0 @@
-type Props = {
- files: File[];
- currentIndex: number;
- locked: boolean;
- onRemove: (index: number) => void;
- onClear: () => void;
-};
-
-function formatBytes(size: number): string {
- if (size >= 1024 * 1024) return `${(size / (1024 * 1024)).toFixed(1)} MB`;
- if (size >= 1024) return `${Math.round(size / 1024)} KB`;
- return `${size} B`;
-}
-
-export function YoutubeImportQueueList({ files, currentIndex, locked, onRemove, onClear }: Props) {
- if (files.length === 0) return null;
-
- return (
-
-
-
Queued archives ({files.length})
-
- Clear
-
-
-
- {files.map((file, index) => {
- const state = locked
- ? index < currentIndex
- ? "done"
- : index === currentIndex
- ? "current"
- : "queued"
- : "queued";
- return (
-
-
-
{file.name}
-
{formatBytes(file.size)}
-
-
- {state}
- onRemove(index)}
- disabled={locked}
- className="h-6 rounded-md border border-border-strong bg-surface px-2 text-[11px] text-fg-muted disabled:cursor-not-allowed disabled:text-fg-soft"
- >
- Remove
-
-
-
- );
- })}
-
-
- );
-}
diff --git a/apps/web/src/components/youtube-remote-browser.tsx b/apps/web/src/components/youtube-remote-browser.tsx
deleted file mode 100644
index 0f539cd8..00000000
--- a/apps/web/src/components/youtube-remote-browser.tsx
+++ /dev/null
@@ -1,162 +0,0 @@
-import type { KeyboardEvent, PointerEvent } from "react";
-import { useEffect, useRef, useState } from "react";
-import type { YoutubeRemoteInput, YoutubeRemotePhase } from "../hooks/use-youtube-remote-browser";
-
-type Props = {
- frameUrl: string | null;
- phase: YoutubeRemotePhase;
- error: string | null;
- onInput: (message: YoutubeRemoteInput) => void;
-};
-
-type FrameSize = {
- width: number;
- height: number;
-};
-
-function modifiers(event: KeyboardEvent): string[] {
- const next: string[] = [];
- if (event.altKey) next.push("Alt");
- if (event.ctrlKey) next.push("Control");
- if (event.metaKey) next.push("Meta");
- if (event.shiftKey) next.push("Shift");
- return next;
-}
-
-function isTextKey(event: KeyboardEvent): boolean {
- return event.key.length === 1 && !event.altKey && !event.ctrlKey && !event.metaKey;
-}
-
-function isPasteShortcut(event: KeyboardEvent): boolean {
- return event.key.toLowerCase() === "v" && (event.ctrlKey || event.metaKey) && !event.altKey;
-}
-
-export function YoutubeRemoteBrowser({ frameUrl, phase, error, onInput }: Props) {
- const rootRef = useRef(null);
- const inputRef = useRef(null);
- const [frameSize, setFrameSize] = useState(null);
-
- useEffect(() => {
- const root = rootRef.current;
- if (!root) return;
- const observer = new ResizeObserver(([entry]) => {
- const width = Math.round(entry.contentRect.width);
- const height = Math.round(entry.contentRect.height);
- if (width > 0 && height > 0) onInput({ type: "resize", width, height });
- });
- observer.observe(root);
- return () => observer.disconnect();
- }, [onInput]);
-
- useEffect(() => {
- const input = inputRef.current;
- if (!input) return;
- const handleWheel = (event: WheelEvent) => {
- event.preventDefault();
- onInput({ type: "wheel", deltaX: event.deltaX, deltaY: event.deltaY });
- };
- input.addEventListener("wheel", handleWheel, { passive: false });
- return () => input.removeEventListener("wheel", handleWheel);
- }, [onInput]);
-
- function point(event: PointerEvent) {
- const rect =
- rootRef.current?.getBoundingClientRect() ?? event.currentTarget.getBoundingClientRect();
- const rawX = event.clientX - rect.left;
- const rawY = event.clientY - rect.top;
- if (!frameSize) {
- return { x: Math.round(rawX), y: Math.round(rawY) };
- }
- const scale = Math.min(rect.width / frameSize.width, rect.height / frameSize.height);
- if (!Number.isFinite(scale) || scale <= 0) {
- return { x: Math.round(rawX), y: Math.round(rawY) };
- }
- const offsetX = (rect.width - frameSize.width * scale) / 2;
- const offsetY = (rect.height - frameSize.height * scale) / 2;
- const x = Math.round((rawX - offsetX) / scale);
- const y = Math.round((rawY - offsetY) / scale);
- return {
- x: Math.max(0, Math.min(frameSize.width - 1, x)),
- y: Math.max(0, Math.min(frameSize.height - 1, y)),
- };
- }
-
- return (
-
- {frameUrl ? (
-
{
- setFrameSize({
- width: event.currentTarget.naturalWidth,
- height: event.currentTarget.naturalHeight,
- });
- }}
- />
- ) : null}
- {!frameUrl && (
-
- Remote browser {phase.replace(/_/g, " ")}
- The YouTube sign-in window will appear here.
- {error && {error} }
-
- )}
-
undefined}
- className="absolute inset-0 h-full w-full touch-none resize-none cursor-default border-0 bg-transparent p-0 text-base text-transparent caret-transparent outline-none"
- onPointerDown={(event) => {
- event.currentTarget.focus();
- event.currentTarget.setPointerCapture(event.pointerId);
- onInput({ type: "pointer", event: "down", ...point(event), button: "left" });
- }}
- onPointerMove={(event) =>
- onInput({ type: "pointer", event: "move", ...point(event), button: "left" })
- }
- onPointerUp={(event) =>
- onInput({ type: "pointer", event: "up", ...point(event), button: "left" })
- }
- onKeyDown={(event) => {
- if (isPasteShortcut(event)) return;
- event.preventDefault();
- if (isTextKey(event)) {
- onInput({ type: "text", value: event.key });
- return;
- }
- onInput({
- type: "key",
- event: "down",
- key: event.key,
- code: event.code,
- modifiers: modifiers(event),
- });
- }}
- onKeyUp={(event) => {
- if (isPasteShortcut(event)) return;
- event.preventDefault();
- if (!isTextKey(event)) {
- onInput({
- type: "key",
- event: "up",
- key: event.key,
- code: event.code,
- modifiers: modifiers(event),
- });
- }
- }}
- onPaste={(event) => {
- event.preventDefault();
- const value = event.clipboardData.getData("text");
- if (value) onInput({ type: "text", value });
- }}
- />
-
- );
-}
diff --git a/apps/web/src/components/youtube-session-browser-panel.tsx b/apps/web/src/components/youtube-session-browser-panel.tsx
deleted file mode 100644
index cfec289b..00000000
--- a/apps/web/src/components/youtube-session-browser-panel.tsx
+++ /dev/null
@@ -1,90 +0,0 @@
-import type { YoutubeRemoteInput, YoutubeRemotePhase } from "../hooks/use-youtube-remote-browser";
-import { YoutubeIcon } from "./youtube-icon";
-import { YoutubeRemoteBrowser } from "./youtube-remote-browser";
-
-type Props = {
- browserOpen: boolean;
- authReady: boolean;
- isAuthed: boolean;
- enabled: boolean;
- loaded: boolean;
- pending: boolean;
- connected: boolean;
- returnTo?: string;
- frameUrl: string | null;
- phase: YoutubeRemotePhase;
- error: string | null;
- onStart: () => void;
- onCancel: () => void;
- onInput: (input: YoutubeRemoteInput) => void;
-};
-
-export function YoutubeSessionBrowserPanel({
- browserOpen,
- authReady,
- isAuthed,
- enabled,
- loaded,
- pending,
- connected,
- returnTo,
- frameUrl,
- phase,
- error,
- onStart,
- onCancel,
- onInput,
-}: Props) {
- if (browserOpen) {
- return (
-
-
-
-
- Phase: {phase.replace(/_/g, " ")}. Click the browser area before typing.
-
-
- Cancel sign-in
-
-
-
- );
- }
-
- return (
-
-
- Use a secondary YouTube account. The remote browser is temporary and closes after
- connection, timeout, or cancellation.
-
-
-
-
- {!loaded ? "Checking availability..." : pending ? "Opening..." : "Connect with YouTube"}
-
-
- {loaded && !enabled && (
-
- Remote YouTube login is disabled on this instance.
-
- )}
- {connected && returnTo && (
-
- Retry video
-
- )}
-
- );
-}
diff --git a/apps/web/src/components/youtube-session-info-section.tsx b/apps/web/src/components/youtube-session-info-section.tsx
deleted file mode 100644
index 3780b799..00000000
--- a/apps/web/src/components/youtube-session-info-section.tsx
+++ /dev/null
@@ -1,22 +0,0 @@
-type Props = {
- labelClassName: string;
-};
-
-export function YoutubeSessionInfoSection({ labelClassName }: Props) {
- return (
-
-
-
What happens
-
- The login page is rendered by a disposable Chromium session running in TypeType-Token.
-
-
-
- You sign in inside the remote browser shown above.
- Cookies and playback token are captured server-side only.
- The browser context is destroyed after success, timeout, or cancel.
- After connection, retry the video and /streams uses your YouTube session.
-
-
- );
-}
diff --git a/apps/web/src/components/youtube-session-status-panel.tsx b/apps/web/src/components/youtube-session-status-panel.tsx
deleted file mode 100644
index 3a42ed26..00000000
--- a/apps/web/src/components/youtube-session-status-panel.tsx
+++ /dev/null
@@ -1,51 +0,0 @@
-import type { YoutubeSessionState } from "../lib/api-youtube-session";
-import {
- formatSessionTime,
- youtubeSessionStatusDescription,
- youtubeSessionStatusLabel,
-} from "../lib/youtube-session-format";
-
-type Props = {
- state: YoutubeSessionState | undefined;
- loading: boolean;
- canDisconnect: boolean;
- onDisconnect: () => void;
-};
-
-const SIDE_LABEL = "font-mono text-fg-soft text-[11px] uppercase tracking-[0.22em]";
-
-export function YoutubeSessionStatusPanel({ state, loading, canDisconnect, onDisconnect }: Props) {
- return (
-
-
-
Status
-
- {loading ? "Loading" : youtubeSessionStatusLabel(state?.status)}
-
-
- {youtubeSessionStatusDescription(state?.status)}
-
-
-
-
-
-
Last used
- {formatSessionTime(state?.lastUsedAt)}
-
-
-
Updated
- {formatSessionTime(state?.updatedAt)}
-
-
-
-
- Disconnect
-
-
- );
-}
diff --git a/apps/web/src/components/zen-sound-toggle.tsx b/apps/web/src/components/zen-sound-toggle.tsx
deleted file mode 100644
index 3ae41841..00000000
--- a/apps/web/src/components/zen-sound-toggle.tsx
+++ /dev/null
@@ -1,39 +0,0 @@
-type Props = {
- playing: boolean;
- onToggle: () => void;
-};
-
-export function ZenSoundToggle({ playing, onToggle }: Props) {
- return (
-
-
-
- {playing ? (
- <>
-
-
- >
- ) : (
- <>
-
-
- >
- )}
-
-
- );
-}
diff --git a/apps/web/src/hooks/shorts-feed-utils.ts b/apps/web/src/hooks/shorts-feed-utils.ts
deleted file mode 100644
index 3eddd024..00000000
--- a/apps/web/src/hooks/shorts-feed-utils.ts
+++ /dev/null
@@ -1,105 +0,0 @@
-import { mapVideoItem } from "../lib/mappers";
-import type {
- HomeRecommendationsResponse,
- SearchPageResponse,
- SubscriptionFeedPage,
-} from "../types/api";
-import type { VideoStream } from "../types/stream";
-
-function isLikelyShort(stream: VideoStream): boolean {
- if (stream.id.includes("/shorts/")) return true;
- if (stream.isShortFormContent) return true;
- const normalizedType = stream.streamType?.toLowerCase() ?? "";
- if (normalizedType.includes("short")) return true;
- return stream.duration === 0 || (stream.duration > 0 && stream.duration <= 180);
-}
-
-export function parseNextPage(nextpage: string | null): number | undefined {
- if (nextpage === null) return undefined;
- const parsed = Number(nextpage);
- return Number.isFinite(parsed) ? parsed : undefined;
-}
-
-export function fromRecommendations(
- pages: HomeRecommendationsResponse[] | undefined,
-): VideoStream[] {
- return (pages ?? [])
- .flatMap((page) => page.items)
- .map(mapVideoItem)
- .filter(isLikelyShort);
-}
-
-export function fromSubscriptions(pages: SubscriptionFeedPage[] | undefined): VideoStream[] {
- return (pages ?? [])
- .flatMap((page) => page.videos)
- .map(mapVideoItem)
- .filter(isLikelyShort);
-}
-
-export function fromDiscovery(pages: SearchPageResponse[] | undefined): VideoStream[] {
- return (pages ?? [])
- .flatMap((page) => page.items)
- .map(mapVideoItem)
- .filter(isLikelyShort);
-}
-
-export function dedupeShorts(streams: VideoStream[]): VideoStream[] {
- const seen = new Set();
- return streams.filter((stream) => {
- if (seen.has(stream.id)) return false;
- seen.add(stream.id);
- return true;
- });
-}
-
-export function interleaveByChannel(streams: VideoStream[]): VideoStream[] {
- const groups = new Map();
- const order: string[] = [];
- for (const stream of streams) {
- const key = stream.channelUrl ?? `__${stream.id}`;
- const bucket = groups.get(key);
- if (bucket) {
- bucket.push(stream);
- continue;
- }
- groups.set(key, [stream]);
- order.push(key);
- }
-
- const out: VideoStream[] = [];
- let progress = true;
- while (progress) {
- progress = false;
- for (const key of order) {
- const bucket = groups.get(key);
- if (!bucket || bucket.length === 0) continue;
- const next = bucket.shift();
- if (!next) continue;
- out.push(next);
- progress = true;
- }
- }
- return out;
-}
-
-export function blendRecommendationsWithSubscriptions(
- recommendations: VideoStream[],
- subscriptions: VideoStream[],
-): VideoStream[] {
- if (recommendations.length === 0) return subscriptions;
-
- const recommendationIds = new Set(recommendations.map((stream) => stream.id));
- const subscriptionCandidates = subscriptions.filter(
- (stream) => !recommendationIds.has(stream.id),
- );
- if (subscriptionCandidates.length === 0) return recommendations;
-
- const insertCount = Math.min(2, subscriptionCandidates.length);
- const blended = [...recommendations];
- for (let i = 0; i < insertCount; i++) {
- const segment = Math.floor(recommendations.length / (insertCount + 1));
- const position = Math.min(blended.length, segment * (i + 1) + i);
- blended.splice(position, 0, subscriptionCandidates[i]);
- }
- return blended;
-}
diff --git a/apps/web/src/hooks/use-account-identity.ts b/apps/web/src/hooks/use-account-identity.ts
deleted file mode 100644
index 9d538802..00000000
--- a/apps/web/src/hooks/use-account-identity.ts
+++ /dev/null
@@ -1,18 +0,0 @@
-import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
-import {
- type AccountIdentityUpdate,
- fetchAccountIdentity,
- updateAccountIdentity,
-} from "../lib/api-account-identity";
-
-const KEY = ["account-identity"];
-
-export function useAccountIdentity(enabled: boolean) {
- const queryClient = useQueryClient();
- const query = useQuery({ queryKey: KEY, queryFn: fetchAccountIdentity, enabled });
- const update = useMutation({
- mutationFn: (payload: AccountIdentityUpdate) => updateAccountIdentity(payload),
- onSuccess: () => queryClient.invalidateQueries({ queryKey: KEY }),
- });
- return { query, update };
-}
diff --git a/apps/web/src/hooks/use-admin-bug-reports.ts b/apps/web/src/hooks/use-admin-bug-reports.ts
deleted file mode 100644
index fa4ca8a5..00000000
--- a/apps/web/src/hooks/use-admin-bug-reports.ts
+++ /dev/null
@@ -1,74 +0,0 @@
-import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
-import {
- createGitHubIssue,
- fetchBugReportDetail,
- fetchBugReports,
- updateBugReportStatus,
-} from "../lib/api-bug-reports";
-import type { BugReportCategory, BugReportStatus } from "../types/bug-report";
-
-const KEY = ["admin-bug-reports"];
-
-type ListParams = {
- page: number;
- limit: number;
- status?: BugReportStatus;
- category?: BugReportCategory;
-};
-
-function listKey(params: ListParams) {
- return [...KEY, "list", params.page, params.limit, params.status, params.category];
-}
-
-function detailKey(id: string) {
- return [...KEY, "detail", id];
-}
-
-export function useAdminBugReports(enabled: boolean, params: ListParams) {
- const qc = useQueryClient();
-
- const query = useQuery({
- queryKey: listKey(params),
- queryFn: () => fetchBugReports(params),
- enabled,
- });
-
- const updateStatus = useMutation({
- mutationFn: ({ id, status }: { id: string; status: BugReportStatus }) =>
- updateBugReportStatus(id, status),
- onSuccess: () => qc.invalidateQueries({ queryKey: KEY }),
- });
-
- const createIssue = useMutation({
- mutationFn: (id: string) => createGitHubIssue(id),
- onSuccess: () => qc.invalidateQueries({ queryKey: KEY }),
- });
-
- return { query, updateStatus, createIssue };
-}
-
-export function useAdminBugReportDetail(enabled: boolean, id: string) {
- const qc = useQueryClient();
-
- const query = useQuery({
- queryKey: detailKey(id),
- queryFn: () => fetchBugReportDetail(id),
- enabled: enabled && id.length > 0,
- });
-
- const updateStatus = useMutation({
- mutationFn: (status: BugReportStatus) => updateBugReportStatus(id, status),
- onSuccess: () => {
- qc.invalidateQueries({ queryKey: KEY });
- },
- });
-
- const createIssue = useMutation({
- mutationFn: () => createGitHubIssue(id),
- onSuccess: () => {
- qc.invalidateQueries({ queryKey: KEY });
- },
- });
-
- return { query, updateStatus, createIssue };
-}
diff --git a/apps/web/src/hooks/use-admin-granular-allow-list.ts b/apps/web/src/hooks/use-admin-granular-allow-list.ts
deleted file mode 100644
index d3c20dad..00000000
--- a/apps/web/src/hooks/use-admin-granular-allow-list.ts
+++ /dev/null
@@ -1,140 +0,0 @@
-import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
-import {
- addAdminAllowedPlaylist,
- addAdminUserAllowedChannel,
- addAdminUserAllowedPlaylist,
- fetchAdminAllowedPlaylists,
- fetchAdminManagedAccessUsers,
- fetchAdminUserAllowList,
- removeAdminAllowedPlaylist,
- removeAdminUserAllowedChannel,
- removeAdminUserAllowedPlaylist,
- searchAdminUsers,
- updateAdminUserAccessMode,
-} from "../lib/api-admin-allow-list";
-import type { AdminUserAllowList, AllowPlaylistInput } from "../types/allow-list";
-import type { ChannelResultItem } from "../types/api";
-import type { AccessMode } from "../types/user";
-
-const ADMIN_ALLOWED_PLAYLISTS_KEY = ["admin-allowed-playlists"];
-const ADMIN_MANAGED_ACCESS_USERS_KEY = ["admin-managed-access-users"];
-const ADMIN_USERS_SEARCH_KEY = ["admin-users-search"];
-const adminUserAllowListKey = (id: string) => ["admin-user-allow-list", id];
-
-export function useAdminAllowedPlaylists(enabled: boolean) {
- return useQuery({
- queryKey: ADMIN_ALLOWED_PLAYLISTS_KEY,
- queryFn: fetchAdminAllowedPlaylists,
- enabled,
- });
-}
-
-export function useAdminUserSearch(query: string, enabled: boolean) {
- const q = query.trim();
- return useQuery({
- queryKey: [...ADMIN_USERS_SEARCH_KEY, q],
- queryFn: () => searchAdminUsers(q, 20),
- enabled: enabled && q.length >= 2,
- staleTime: 30 * 1000,
- });
-}
-
-export function useAdminAllowListUsers(enabled: boolean) {
- return useInfiniteQuery({
- queryKey: ADMIN_MANAGED_ACCESS_USERS_KEY,
- queryFn: ({ pageParam }: { pageParam: string | undefined }) =>
- fetchAdminManagedAccessUsers(100, pageParam),
- initialPageParam: undefined as string | undefined,
- getNextPageParam: (lastPage) => lastPage.nextpage ?? undefined,
- enabled,
- staleTime: 30 * 1000,
- });
-}
-
-export function useAdminUserAllowList(userId: string | null) {
- return useQuery({
- queryKey: userId ? adminUserAllowListKey(userId) : ["admin-user-allow-list", "none"],
- queryFn: () => fetchAdminUserAllowList(userId ?? ""),
- enabled: Boolean(userId),
- });
-}
-
-export function useAdminAllowListMutations(selectedUserId: string | null) {
- const qc = useQueryClient();
- const refreshUser = () => {
- if (selectedUserId) qc.invalidateQueries({ queryKey: adminUserAllowListKey(selectedUserId) });
- };
- const setUserMode = (id: string, accessMode: AccessMode) => {
- qc.setQueryData(adminUserAllowListKey(id), (current) =>
- current
- ? {
- ...current,
- user: { ...current.user, accessMode, adminManagedAccessMode: true },
- }
- : current,
- );
- };
- const refreshManagedUsers = () => {
- qc.invalidateQueries({ queryKey: ADMIN_MANAGED_ACCESS_USERS_KEY });
- qc.invalidateQueries({ queryKey: ADMIN_USERS_SEARCH_KEY });
- };
- const refreshGlobalPlaylists = () =>
- qc.invalidateQueries({ queryKey: ADMIN_ALLOWED_PLAYLISTS_KEY });
- const userMode = useMutation({
- mutationFn: ({ id, accessMode }: { id: string; accessMode: AccessMode }) =>
- updateAdminUserAccessMode(id, accessMode),
- onSuccess: (accessMode, { id }) => {
- setUserMode(id, accessMode);
- refreshManagedUsers();
- },
- });
- const addUserChannel = useMutation({
- mutationFn: ({ id, channel }: { id: string; channel: ChannelResultItem }) =>
- addAdminUserAllowedChannel(id, channel.url, channel.name, channel.thumbnailUrl),
- onSuccess: () => {
- refreshUser();
- refreshManagedUsers();
- },
- });
- const removeUserChannel = useMutation({
- mutationFn: ({ id, url }: { id: string; url: string }) =>
- removeAdminUserAllowedChannel(id, url),
- onSuccess: () => {
- refreshUser();
- refreshManagedUsers();
- },
- });
- const addGlobalPlaylist = useMutation({
- mutationFn: addAdminAllowedPlaylist,
- onSuccess: refreshGlobalPlaylists,
- });
- const removeGlobalPlaylist = useMutation({
- mutationFn: removeAdminAllowedPlaylist,
- onSuccess: refreshGlobalPlaylists,
- });
- const addUserPlaylist = useMutation({
- mutationFn: ({ id, playlist }: { id: string; playlist: AllowPlaylistInput }) =>
- addAdminUserAllowedPlaylist(id, playlist),
- onSuccess: () => {
- refreshUser();
- refreshManagedUsers();
- },
- });
- const removeUserPlaylist = useMutation({
- mutationFn: ({ id, url }: { id: string; url: string }) =>
- removeAdminUserAllowedPlaylist(id, url),
- onSuccess: () => {
- refreshUser();
- refreshManagedUsers();
- },
- });
- return {
- userMode,
- addUserChannel,
- removeUserChannel,
- addGlobalPlaylist,
- removeGlobalPlaylist,
- addUserPlaylist,
- removeUserPlaylist,
- };
-}
diff --git a/apps/web/src/hooks/use-admin-sessions.ts b/apps/web/src/hooks/use-admin-sessions.ts
deleted file mode 100644
index 51ab66d1..00000000
--- a/apps/web/src/hooks/use-admin-sessions.ts
+++ /dev/null
@@ -1,11 +0,0 @@
-import { useQuery } from "@tanstack/react-query";
-import { fetchAdminSessions } from "../lib/api-admin-sessions";
-
-export function useAdminSessions(enabled: boolean) {
- return useQuery({
- queryKey: ["admin-sessions"],
- queryFn: fetchAdminSessions,
- enabled,
- refetchInterval: enabled ? 5000 : false,
- });
-}
diff --git a/apps/web/src/hooks/use-admin-settings.ts b/apps/web/src/hooks/use-admin-settings.ts
deleted file mode 100644
index 143709b4..00000000
--- a/apps/web/src/hooks/use-admin-settings.ts
+++ /dev/null
@@ -1,26 +0,0 @@
-import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
-import { fetchAdminSettings, updateAdminSettings } from "../lib/api-admin";
-import type { AdminSettings } from "../types/admin";
-import { INSTANCE_KEY } from "./use-instance";
-
-const KEY = ["admin-settings"];
-
-export function useAdminSettings(enabled: boolean) {
- const qc = useQueryClient();
-
- const query = useQuery({
- queryKey: KEY,
- queryFn: fetchAdminSettings,
- enabled,
- });
-
- const update = useMutation({
- mutationFn: (settings: AdminSettings) => updateAdminSettings(settings),
- onSuccess: (settings) => {
- qc.setQueryData(KEY, settings);
- qc.invalidateQueries({ queryKey: INSTANCE_KEY });
- },
- });
-
- return { query, update };
-}
diff --git a/apps/web/src/hooks/use-admin-users.ts b/apps/web/src/hooks/use-admin-users.ts
deleted file mode 100644
index 23ed71c6..00000000
--- a/apps/web/src/hooks/use-admin-users.ts
+++ /dev/null
@@ -1,39 +0,0 @@
-import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
-import {
- createPasswordResetToken,
- fetchAdminUsers,
- suspendUser,
- unsuspendUser,
- updateUserRole,
-} from "../lib/api-admin";
-import type { AuthRole } from "../types/auth";
-
-const KEY = ["admin-users"];
-const key = (page: number, limit: number) => [...KEY, page, limit];
-
-export function useAdminUsers(enabled: boolean, page: number, limit: number) {
- const qc = useQueryClient();
-
- const query = useQuery({
- queryKey: key(page, limit),
- queryFn: () => fetchAdminUsers(page, limit),
- enabled,
- });
-
- const role = useMutation({
- mutationFn: ({ id, role }: { id: string; role: AuthRole }) => updateUserRole(id, role),
- onSuccess: () => qc.invalidateQueries({ queryKey: KEY }),
- });
-
- const suspend = useMutation({
- mutationFn: ({ id, suspended }: { id: string; suspended: boolean }) =>
- suspended ? unsuspendUser(id) : suspendUser(id),
- onSuccess: () => qc.invalidateQueries({ queryKey: KEY }),
- });
-
- const resetToken = useMutation({
- mutationFn: (id: string) => createPasswordResetToken(id),
- });
-
- return { query, role, suspend, resetToken };
-}
diff --git a/apps/web/src/hooks/use-allowed-channels.ts b/apps/web/src/hooks/use-allowed-channels.ts
deleted file mode 100644
index 5fad7b8e..00000000
--- a/apps/web/src/hooks/use-allowed-channels.ts
+++ /dev/null
@@ -1,33 +0,0 @@
-import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
-import { allowChannel, disallowChannel, fetchAllowedChannels } from "../lib/api-collections";
-import { useAuth } from "./use-auth";
-
-const ALLOWED_CHANNELS_KEY = ["allowed-channels"];
-
-type AllowChannelArgs = {
- url: string;
- name?: string | null;
- thumbnailUrl?: string | null;
- global?: boolean;
-};
-
-export function useAllowedChannels() {
- const qc = useQueryClient();
- const { authReady, isAuthed } = useAuth();
- const query = useQuery({
- queryKey: ALLOWED_CHANNELS_KEY,
- queryFn: fetchAllowedChannels,
- enabled: authReady && isAuthed,
- staleTime: 5 * 60 * 1000,
- });
- const add = useMutation({
- mutationFn: ({ url, name, thumbnailUrl, global }: AllowChannelArgs) =>
- isAuthed ? allowChannel(url, name, thumbnailUrl, global) : Promise.resolve(null),
- onSuccess: () => qc.invalidateQueries({ queryKey: ALLOWED_CHANNELS_KEY }),
- });
- const remove = useMutation({
- mutationFn: (url: string) => (isAuthed ? disallowChannel(url) : Promise.resolve()),
- onSuccess: () => qc.invalidateQueries({ queryKey: ALLOWED_CHANNELS_KEY }),
- });
- return { query, add, remove };
-}
diff --git a/apps/web/src/hooks/use-artifact-download-on-done.ts b/apps/web/src/hooks/use-artifact-download-on-done.ts
deleted file mode 100644
index c15d9da1..00000000
--- a/apps/web/src/hooks/use-artifact-download-on-done.ts
+++ /dev/null
@@ -1,92 +0,0 @@
-import { useCallback, useEffect, useRef, useState } from "react";
-
-type Params = {
- isDone: boolean;
- jobId: string | undefined;
- selectedLabel: string;
- openArtifact: (options?: { preferShare?: boolean }) => Promise | undefined;
- autoStart?: boolean;
- preferShare?: boolean;
- onDone: (message: string) => void;
- onDismiss: () => void;
- reset: () => void;
- onArtifactError: (message: string | null) => void;
-};
-
-export function useArtifactDownloadOnDone({
- isDone,
- jobId,
- selectedLabel,
- openArtifact,
- autoStart = true,
- preferShare = false,
- onDone,
- onDismiss,
- reset,
- onArtifactError,
-}: Params) {
- const [isCompleting, setIsCompleting] = useState(false);
- const handledJobIdRef = useRef(null);
-
- const completeDownload = useCallback(
- async (selected: string, options?: { preferShare?: boolean }) => {
- const run = openArtifact(options);
- if (!run) throw new Error("Download is not ready");
- await run;
- onDone(`Download started: ${selected}`);
- reset();
- onDismiss();
- },
- [onDismiss, onDone, openArtifact, reset],
- );
-
- const completeNow = useCallback(async () => {
- if (!isDone || !jobId) return;
- if (handledJobIdRef.current === jobId) return;
- handledJobIdRef.current = jobId;
- setIsCompleting(true);
- try {
- await completeDownload(selectedLabel, { preferShare });
- } catch (error) {
- handledJobIdRef.current = null;
- setIsCompleting(false);
- onArtifactError(error instanceof Error ? error.message : "Download failed");
- }
- }, [completeDownload, isDone, jobId, onArtifactError, preferShare, selectedLabel]);
-
- useEffect(() => {
- if (isDone) return;
- setIsCompleting(false);
- }, [isDone]);
-
- useEffect(() => {
- if (jobId) return;
- handledJobIdRef.current = null;
- }, [jobId]);
-
- useEffect(() => {
- if (!autoStart) return;
- if (!isDone || !jobId) return;
- if (handledJobIdRef.current === jobId) return;
- handledJobIdRef.current = jobId;
- setIsCompleting(true);
- let cancelled = false;
- const run = async () => {
- try {
- await completeDownload(selectedLabel, { preferShare });
- if (cancelled) return;
- } catch (error) {
- if (cancelled) return;
- handledJobIdRef.current = null;
- setIsCompleting(false);
- onArtifactError(error instanceof Error ? error.message : "Download failed");
- }
- };
- void run();
- return () => {
- cancelled = true;
- };
- }, [autoStart, completeDownload, isDone, jobId, onArtifactError, preferShare, selectedLabel]);
-
- return { isCompleting, completeNow };
-}
diff --git a/apps/web/src/hooks/use-audio-only-stream.ts b/apps/web/src/hooks/use-audio-only-stream.ts
deleted file mode 100644
index 2c4b1368..00000000
--- a/apps/web/src/hooks/use-audio-only-stream.ts
+++ /dev/null
@@ -1,19 +0,0 @@
-import { useQuery } from "@tanstack/react-query";
-import { fetchAudioOnlyStream } from "../lib/api-audio-only";
-import { useAuthStore } from "../stores/auth-store";
-
-export function useAudioOnlyStream(
- url: string,
- preferOriginal: boolean,
- preferredLocale: string,
- enabled: boolean,
-) {
- const authScope = useAuthStore((state) => (state.token ? (state.me?.id ?? "auth") : "guest"));
- return useQuery({
- queryKey: ["audio-only", url, preferOriginal, preferredLocale, authScope],
- queryFn: () => fetchAudioOnlyStream(url, preferOriginal, preferredLocale),
- enabled: enabled && url.trim().length > 0,
- staleTime: 3 * 60 * 1000,
- retry: false,
- });
-}
diff --git a/apps/web/src/hooks/use-audio-palette.ts b/apps/web/src/hooks/use-audio-palette.ts
deleted file mode 100644
index cf133ac8..00000000
--- a/apps/web/src/hooks/use-audio-palette.ts
+++ /dev/null
@@ -1,163 +0,0 @@
-import { useEffect, useState } from "react";
-
-type Rgb = {
- r: number;
- g: number;
- b: number;
-};
-
-type Swatch = Rgb & {
- weight: number;
-};
-
-export type AudioPalette = {
- primary: string;
- secondary: string;
- ambient: string;
- waveTop: string;
- waveMid: string;
- waveBottom: string;
-};
-
-const RED = { r: 239, g: 68, b: 68 };
-const DARK = { r: 9, g: 9, b: 11 };
-const WHITE = { r: 255, g: 255, b: 255 };
-const DEFAULT_PALETTE = toPalette(RED, { r: 127, g: 29, b: 29 });
-
-function clamp(value: number): number {
- return Math.max(0, Math.min(255, Math.round(value)));
-}
-
-function mix(color: Rgb, target: Rgb, amount: number): Rgb {
- return {
- r: clamp(color.r + (target.r - color.r) * amount),
- g: clamp(color.g + (target.g - color.g) * amount),
- b: clamp(color.b + (target.b - color.b) * amount),
- };
-}
-
-function channels(color: Rgb): string {
- return `${clamp(color.r)} ${clamp(color.g)} ${clamp(color.b)}`;
-}
-
-function distance(a: Rgb, b: Rgb): number {
- return Math.hypot(a.r - b.r, a.g - b.g, a.b - b.b);
-}
-
-function saturation(color: Rgb): number {
- const r = color.r / 255;
- const g = color.g / 255;
- const b = color.b / 255;
- const max = Math.max(r, g, b);
- const min = Math.min(r, g, b);
- const lightness = (max + min) / 2;
- if (max === min) return 0;
- const delta = max - min;
- return delta / (1 - Math.abs(2 * lightness - 1));
-}
-
-function hue(color: Rgb): number {
- const r = color.r / 255;
- const g = color.g / 255;
- const b = color.b / 255;
- const max = Math.max(r, g, b);
- const min = Math.min(r, g, b);
- const delta = max - min;
- if (delta === 0) return 0;
- const value =
- max === r
- ? (g - b) / delta + (g < b ? 6 : 0)
- : max === g
- ? (b - r) / delta + 2
- : (r - g) / delta + 4;
- return value * 60;
-}
-
-function lightness(color: Rgb): number {
- const max = Math.max(color.r, color.g, color.b) / 255;
- const min = Math.min(color.r, color.g, color.b) / 255;
- return (max + min) / 2;
-}
-
-function toPalette(primary: Rgb, secondary: Rgb): AudioPalette {
- const warmPrimary = mix(primary, WHITE, 0.18);
- const warmSecondary = mix(secondary, WHITE, 0.12);
- return {
- primary: channels(warmPrimary),
- secondary: channels(warmSecondary),
- ambient: channels(mix(primary, DARK, 0.58)),
- waveTop: channels(mix(warmPrimary, WHITE, 0.48)),
- waveMid: channels(mix(warmPrimary, warmSecondary, 0.38)),
- waveBottom: channels(mix(warmSecondary, DARK, 0.54)),
- };
-}
-
-function readPalette(image: HTMLImageElement): AudioPalette {
- const canvas = document.createElement("canvas");
- canvas.width = 48;
- canvas.height = 32;
- const context = canvas.getContext("2d", { willReadFrequently: true });
- if (!context) return DEFAULT_PALETTE;
- context.drawImage(image, 0, 0, canvas.width, canvas.height);
- const pixels = context.getImageData(0, 0, canvas.width, canvas.height).data;
- const swatches = new Map();
- for (let index = 0; index < pixels.length; index += 16) {
- const color = { r: pixels[index] ?? 0, g: pixels[index + 1] ?? 0, b: pixels[index + 2] ?? 0 };
- const alpha = pixels[index + 3] ?? 0;
- const colorSaturation = saturation(color);
- const colorLightness = lightness(color);
- if (alpha < 160 || colorSaturation < 0.18 || colorLightness < 0.08 || colorLightness > 0.9)
- continue;
- const bucket = Math.round(hue(color) / 18);
- const weight = (0.35 + colorSaturation) * (1 - Math.abs(colorLightness - 0.52));
- const current = swatches.get(bucket);
- if (current) {
- const total = current.weight + weight;
- swatches.set(bucket, {
- r: (current.r * current.weight + color.r * weight) / total,
- g: (current.g * current.weight + color.g * weight) / total,
- b: (current.b * current.weight + color.b * weight) / total,
- weight: total,
- });
- } else {
- swatches.set(bucket, { ...color, weight });
- }
- }
- const ranked = [...swatches.values()].sort((a, b) => b.weight - a.weight);
- const primary = ranked[0] ?? RED;
- const secondary =
- ranked.find((color) => distance(color, primary) > 72) ?? mix(primary, WHITE, 0.28);
- return toPalette(primary, secondary);
-}
-
-export function useAudioPalette(image: string): AudioPalette {
- const [palette, setPalette] = useState(DEFAULT_PALETTE);
-
- useEffect(() => {
- if (!image) {
- setPalette(DEFAULT_PALETTE);
- return;
- }
- let cancelled = false;
- const element = new Image();
- element.crossOrigin = "anonymous";
- element.decoding = "async";
- element.onload = () => {
- if (cancelled) return;
- try {
- setPalette(readPalette(element));
- } catch {
- setPalette(DEFAULT_PALETTE);
- }
- };
- element.onerror = () => {
- if (!cancelled) setPalette(DEFAULT_PALETTE);
- };
- element.src = image;
- return () => {
- cancelled = true;
- };
- }, [image]);
-
- return palette;
-}
diff --git a/apps/web/src/hooks/use-auth-toasts.ts b/apps/web/src/hooks/use-auth-toasts.ts
deleted file mode 100644
index bf2484fe..00000000
--- a/apps/web/src/hooks/use-auth-toasts.ts
+++ /dev/null
@@ -1,29 +0,0 @@
-import { useEffect, useRef, useState } from "react";
-import { useAuth } from "./use-auth";
-
-export function useAuthToasts() {
- const [toast, setToast] = useState(null);
- const { status } = useAuth();
- const previousStatus = useRef(status);
-
- useEffect(() => {
- if (previousStatus.current === status) return;
- const from = previousStatus.current;
- previousStatus.current = status;
- if (status === "authenticated" && from !== "loading") {
- setToast("Signed in");
- return;
- }
- if (status === "signed_out" && from !== "loading") {
- setToast("Signed out");
- }
- }, [status]);
-
- useEffect(() => {
- if (!toast) return;
- const id = window.setTimeout(() => setToast(null), 1800);
- return () => window.clearTimeout(id);
- }, [toast]);
-
- return toast;
-}
diff --git a/apps/web/src/hooks/use-auth.ts b/apps/web/src/hooks/use-auth.ts
deleted file mode 100644
index bbab6432..00000000
--- a/apps/web/src/hooks/use-auth.ts
+++ /dev/null
@@ -1,41 +0,0 @@
-import { useMemo } from "react";
-import { useAuthStore } from "../stores/auth-store";
-
-export function useAuth() {
- const token = useAuthStore((s) => s.token);
- const me = useAuthStore((s) => s.me);
- const status = useAuthStore((s) => s.status);
- const signOut = useAuthStore((s) => s.setSignedOut);
-
- const role = me?.role ?? null;
- const isAuthed = status === "authenticated" || status === "guest";
- const authReady = status !== "loading";
- const isGuest = status === "guest";
- const publicUsername = me?.publicUsername ?? null;
- const bio = me?.bio ?? null;
- const avatarUrl = me?.avatarUrl ?? null;
- const avatarType = me?.avatarType ?? null;
- const avatarCode = me?.avatarCode ?? null;
- const isAdmin = role === "admin";
- const isModerator = role === "moderator";
- const canGlobalBlock = useMemo(() => isAdmin || isModerator, [isAdmin, isModerator]);
-
- return {
- token,
- me,
- role,
- authReady,
- publicUsername,
- bio,
- avatarUrl,
- avatarType,
- avatarCode,
- status,
- isAuthed,
- isGuest,
- isAdmin,
- isModerator,
- canGlobalBlock,
- signOut,
- };
-}
diff --git a/apps/web/src/hooks/use-avatar.ts b/apps/web/src/hooks/use-avatar.ts
deleted file mode 100644
index a7b88894..00000000
--- a/apps/web/src/hooks/use-avatar.ts
+++ /dev/null
@@ -1,30 +0,0 @@
-import { useMutation } from "@tanstack/react-query";
-import { fetchMe } from "../lib/api-auth";
-import { clearAvatar, setEmojiAvatar, uploadCustomAvatar } from "../lib/api-profile-avatar";
-import { useAuthStore } from "../stores/auth-store";
-
-async function refreshMeAfterAvatarChange(): Promise {
- const { token, me, setSession } = useAuthStore.getState();
- if (!token || !me) return;
- const updated = await fetchMe(token);
- setSession(token, updated);
-}
-
-export function useAvatar() {
- const emoji = useMutation({
- mutationFn: (code: string) => setEmojiAvatar({ code }),
- onSuccess: refreshMeAfterAvatarChange,
- });
-
- const clear = useMutation({
- mutationFn: () => clearAvatar(),
- onSuccess: refreshMeAfterAvatarChange,
- });
-
- const custom = useMutation({
- mutationFn: (file: File) => uploadCustomAvatar(file),
- onSuccess: refreshMeAfterAvatarChange,
- });
-
- return { emoji, custom, clear };
-}
diff --git a/apps/web/src/hooks/use-blocked-filter.ts b/apps/web/src/hooks/use-blocked-filter.ts
deleted file mode 100644
index dcd8ef17..00000000
--- a/apps/web/src/hooks/use-blocked-filter.ts
+++ /dev/null
@@ -1,63 +0,0 @@
-import { useCallback, useMemo } from "react";
-import type { ChannelResultItem } from "../types/api";
-import type { PublicPlaylistInfo } from "../types/playlist";
-import type { VideoStream } from "../types/stream";
-import { useAuth } from "./use-auth";
-import { useBlocked } from "./use-blocked";
-
-export function useBlockedFilter() {
- const { isAuthed } = useAuth();
- const { channels, videos } = useBlocked();
-
- const blockedChannelUrls = useMemo(
- () => new Set((channels.data ?? []).map((item) => item.url)),
- [channels.data],
- );
- const blockedVideoUrls = useMemo(
- () => new Set((videos.data ?? []).map((item) => item.url)),
- [videos.data],
- );
- const blockedChannelNames = useMemo(
- () => new Set((channels.data ?? []).map((item) => item.name?.toLowerCase()).filter(Boolean)),
- [channels.data],
- );
-
- const isBlocked = useCallback(
- (stream: VideoStream): boolean => {
- if (blockedVideoUrls.has(stream.id)) return true;
- if (stream.channelUrl && blockedChannelUrls.has(stream.channelUrl)) return true;
- return false;
- },
- [blockedChannelUrls, blockedVideoUrls],
- );
-
- const filter = useCallback(
- (streams: VideoStream[]): VideoStream[] => {
- if (!isAuthed) return streams;
- return streams.filter((s) => !isBlocked(s));
- },
- [isAuthed, isBlocked],
- );
-
- const isChannelBlocked = useCallback(
- (channel: ChannelResultItem): boolean => blockedChannelUrls.has(channel.url),
- [blockedChannelUrls],
- );
-
- const isPlaylistBlocked = useCallback(
- (playlist: PublicPlaylistInfo): boolean => {
- const uploader = playlist.uploaderName.trim().toLowerCase();
- return uploader.length > 0 && blockedChannelNames.has(uploader);
- },
- [blockedChannelNames],
- );
-
- return {
- filter,
- isBlocked,
- isChannelBlocked,
- isPlaylistBlocked,
- blockedChannelUrls,
- blockedVideoUrls,
- };
-}
diff --git a/apps/web/src/hooks/use-blocked.ts b/apps/web/src/hooks/use-blocked.ts
deleted file mode 100644
index 1c41f978..00000000
--- a/apps/web/src/hooks/use-blocked.ts
+++ /dev/null
@@ -1,67 +0,0 @@
-import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
-import {
- blockChannel,
- blockVideo,
- fetchBlockedChannels,
- fetchBlockedVideos,
- unblockChannel,
- unblockVideo,
-} from "../lib/api-collections";
-import { useAuth } from "./use-auth";
-
-const CHANNELS_KEY = ["blocked-channels"];
-const VIDEOS_KEY = ["blocked-videos"];
-
-type BlockChannelArgs = {
- url: string;
- name?: string;
- thumbnailUrl?: string;
- global?: boolean;
-};
-
-type BlockVideoArgs = {
- url: string;
- global?: boolean;
-};
-
-export function useBlocked() {
- const qc = useQueryClient();
- const { authReady, isAuthed } = useAuth();
-
- const channels = useQuery({
- queryKey: CHANNELS_KEY,
- queryFn: fetchBlockedChannels,
- enabled: authReady && isAuthed,
- staleTime: 5 * 60 * 1000,
- });
- const videos = useQuery({
- queryKey: VIDEOS_KEY,
- queryFn: fetchBlockedVideos,
- enabled: authReady && isAuthed,
- staleTime: 5 * 60 * 1000,
- });
-
- const addChannel = useMutation({
- mutationFn: ({ url, name, thumbnailUrl, global }: BlockChannelArgs) =>
- isAuthed ? blockChannel(url, name, thumbnailUrl, global) : Promise.resolve(),
- onSuccess: () => qc.invalidateQueries({ queryKey: CHANNELS_KEY }),
- });
-
- const removeChannel = useMutation({
- mutationFn: (url: string) => (isAuthed ? unblockChannel(url) : Promise.resolve()),
- onSuccess: () => qc.invalidateQueries({ queryKey: CHANNELS_KEY }),
- });
-
- const addVideo = useMutation({
- mutationFn: ({ url, global }: BlockVideoArgs) =>
- isAuthed ? blockVideo(url, global) : Promise.resolve(),
- onSuccess: () => qc.invalidateQueries({ queryKey: VIDEOS_KEY }),
- });
-
- const removeVideo = useMutation({
- mutationFn: (url: string) => (isAuthed ? unblockVideo(url) : Promise.resolve()),
- onSuccess: () => qc.invalidateQueries({ queryKey: VIDEOS_KEY }),
- });
-
- return { channels, videos, addChannel, removeChannel, addVideo, removeVideo };
-}
diff --git a/apps/web/src/hooks/use-bug-report.ts b/apps/web/src/hooks/use-bug-report.ts
deleted file mode 100644
index f75e0625..00000000
--- a/apps/web/src/hooks/use-bug-report.ts
+++ /dev/null
@@ -1,71 +0,0 @@
-import { useMutation } from "@tanstack/react-query";
-import { useLocation } from "@tanstack/react-router";
-import { submitBugReport } from "../lib/api-bug-reports";
-import { clearApiErrors, getApiErrors } from "../lib/api-error-log";
-import { recordClientEvent } from "../lib/client-debug-log";
-import {
- sanitizeDebugText,
- sanitizeRequestPath,
- sanitizeVideoContext,
-} from "../lib/debug-sanitize";
-import { clearCrashLogs, getCrashLogs } from "../lib/error-capture";
-import type {
- BugReportCategory,
- BugReportContext,
- CreateBugReportRequest,
- PlayerStateContext,
-} from "../types/bug-report";
-
-type SubmitParams = {
- category: BugReportCategory;
- description: string;
- videoUrl?: string | null;
- playerState?: PlayerStateContext | null;
-};
-
-function buildContext(
- route: string,
- videoUrl: string | null,
- playerState: PlayerStateContext | null,
-): BugReportContext {
- return {
- videoUrl: sanitizeVideoContext(videoUrl),
- route: sanitizeRequestPath(route),
- timestamp: Date.now(),
- userAgent: sanitizeDebugText(navigator.userAgent),
- browserLanguage: navigator.language,
- playerState,
- crashLogs: getCrashLogs(),
- apiErrors: getApiErrors(),
- };
-}
-
-export function useBugReport() {
- const location = useLocation();
-
- const mutation = useMutation({
- mutationFn: (params: SubmitParams) => {
- const context = buildContext(
- location.pathname,
- params.videoUrl ?? null,
- params.playerState ?? null,
- );
- const request: CreateBugReportRequest = {
- category: params.category,
- description: params.description,
- context,
- };
- recordClientEvent("bug.report_submit_attempt", {
- route: context.route,
- category: params.category,
- });
- return submitBugReport(request);
- },
- onSuccess: () => {
- clearCrashLogs();
- clearApiErrors();
- },
- });
-
- return mutation;
-}
diff --git a/apps/web/src/hooks/use-bullet-comments.ts b/apps/web/src/hooks/use-bullet-comments.ts
deleted file mode 100644
index 2ae814b5..00000000
--- a/apps/web/src/hooks/use-bullet-comments.ts
+++ /dev/null
@@ -1,12 +0,0 @@
-import { useQuery } from "@tanstack/react-query";
-import { fetchBulletComments } from "../lib/api-bullet-comments";
-
-export function useBulletComments(videoUrl: string, enabled: boolean) {
- return useQuery({
- queryKey: ["bullet-comments", videoUrl],
- queryFn: () => fetchBulletComments(videoUrl),
- enabled: enabled && videoUrl.length > 0,
- staleTime: Number.POSITIVE_INFINITY,
- select: (data) => data.comments,
- });
-}
diff --git a/apps/web/src/hooks/use-channel-playlists.ts b/apps/web/src/hooks/use-channel-playlists.ts
deleted file mode 100644
index a7770f12..00000000
--- a/apps/web/src/hooks/use-channel-playlists.ts
+++ /dev/null
@@ -1,24 +0,0 @@
-import { useInfiniteQuery } from "@tanstack/react-query";
-import { fetchChannelPlaylists } from "../lib/api-channel-playlists";
-import type { PublicPlaylistInfo } from "../types/playlist";
-
-type ChannelPlaylistsPage = {
- playlists: PublicPlaylistInfo[];
- nextpage: string | null;
-};
-
-export function useChannelPlaylists(channelUrl: string) {
- return useInfiniteQuery({
- queryKey: ["channel-playlists", channelUrl],
- queryFn: async ({ pageParam }: { pageParam: string | undefined }) => {
- const res = await fetchChannelPlaylists(channelUrl, pageParam);
- return {
- playlists: res.playlists ?? [],
- nextpage: res.nextpage,
- } satisfies ChannelPlaylistsPage;
- },
- initialPageParam: undefined as string | undefined,
- getNextPageParam: (last: ChannelPlaylistsPage) => last.nextpage ?? undefined,
- enabled: channelUrl.length > 0,
- });
-}
diff --git a/apps/web/src/hooks/use-channel.ts b/apps/web/src/hooks/use-channel.ts
deleted file mode 100644
index 8dc1e18c..00000000
--- a/apps/web/src/hooks/use-channel.ts
+++ /dev/null
@@ -1,79 +0,0 @@
-import { useInfiniteQuery } from "@tanstack/react-query";
-import { useEffect, useRef } from "react";
-import { type ChannelSort, fetchChannel } from "../lib/api-discovery";
-import { buildChannelRequestUrl } from "../lib/channel-search-url";
-import { mapVideoItem } from "../lib/mappers";
-import { proxyImage } from "../lib/proxy";
-import type { VideoStream } from "../types/stream";
-
-type ChannelMeta = {
- name: string;
- description: string;
- avatarUrl: string;
- bannerUrl: string;
- subscriberCount: number;
- isVerified: boolean;
-};
-
-type ChannelPage = {
- meta: ChannelMeta | null;
- videos: VideoStream[];
- nextpage: string | null;
-};
-
-type CachedChannelMeta = {
- channelUrl: string;
- meta: ChannelMeta;
-};
-
-export function useChannel(
- channelUrl: string,
- sort: ChannelSort,
- searchQuery: string,
- live: boolean,
-) {
- const lastMeta = useRef(null);
- const requestUrl = buildChannelRequestUrl(channelUrl, searchQuery, live);
- const channelQuery = useInfiniteQuery({
- queryKey: ["channel", channelUrl, sort, searchQuery, live],
- queryFn: async ({ pageParam }): Promise => {
- const res = await fetchChannel(requestUrl, pageParam as string | undefined, sort);
- const isFirstPage = pageParam === undefined;
- return {
- meta: isFirstPage
- ? {
- name: res.name,
- description: res.description,
- avatarUrl: proxyImage(res.avatarUrl),
- bannerUrl: proxyImage(res.bannerUrl),
- subscriberCount: res.subscriberCount,
- isVerified: res.isVerified,
- }
- : null,
- videos: res.videos.map(mapVideoItem),
- nextpage: res.nextpage,
- };
- },
- initialPageParam: undefined as string | undefined,
- getNextPageParam: (last: ChannelPage | undefined) => last?.nextpage ?? undefined,
- enabled: channelUrl.length > 0,
- staleTime: 2 * 60 * 1000,
- gcTime: 15 * 60 * 1000,
- refetchOnWindowFocus: false,
- });
-
- const pages = channelQuery.data?.pages ?? [];
- const currentMeta = pages.find((p) => p.meta !== null)?.meta ?? null;
- const cachedMeta = lastMeta.current?.channelUrl === channelUrl ? lastMeta.current.meta : null;
- const meta = currentMeta ?? cachedMeta;
- const avatarUrl = meta?.avatarUrl ?? "";
- const videos = pages.flatMap((p) =>
- p.videos.map((v) => (v.channelAvatar || !avatarUrl ? v : { ...v, channelAvatar: avatarUrl })),
- );
-
- useEffect(() => {
- if (currentMeta) lastMeta.current = { channelUrl, meta: currentMeta };
- }, [channelUrl, currentMeta]);
-
- return { ...channelQuery, meta, videos };
-}
diff --git a/apps/web/src/hooks/use-client-locale.ts b/apps/web/src/hooks/use-client-locale.ts
deleted file mode 100644
index a9ceb076..00000000
--- a/apps/web/src/hooks/use-client-locale.ts
+++ /dev/null
@@ -1,13 +0,0 @@
-import { useEffect, useState } from "react";
-import { normalizeClientLocale } from "../lib/client-locale";
-
-export function useClientLocale() {
- const [locale, setLocale] = useState(undefined);
-
- useEffect(() => {
- if (typeof navigator === "undefined") return;
- setLocale(normalizeClientLocale(navigator.language));
- }, []);
-
- return locale;
-}
diff --git a/apps/web/src/hooks/use-comment-replies.ts b/apps/web/src/hooks/use-comment-replies.ts
deleted file mode 100644
index 1859df1e..00000000
--- a/apps/web/src/hooks/use-comment-replies.ts
+++ /dev/null
@@ -1,25 +0,0 @@
-import { useInfiniteQuery } from "@tanstack/react-query";
-import { fetchCommentReplies } from "../lib/api";
-import { mapCommentItem } from "../lib/mappers";
-import type { Comment } from "../types/comment";
-
-type ReplyPage = {
- comments: Comment[];
- nextpage: string | null;
-};
-
-export function useCommentReplies(videoUrl: string, repliesPage: string) {
- return useInfiniteQuery({
- queryKey: ["replies", videoUrl, repliesPage],
- queryFn: async ({ pageParam }: { pageParam: string }) => {
- const res = await fetchCommentReplies(videoUrl, pageParam);
- return {
- comments: res.comments.map(mapCommentItem),
- nextpage: res.nextpage,
- } satisfies ReplyPage;
- },
- initialPageParam: repliesPage,
- getNextPageParam: (last: ReplyPage) => last.nextpage ?? undefined,
- enabled: videoUrl.length > 0,
- });
-}
diff --git a/apps/web/src/hooks/use-dearrow.ts b/apps/web/src/hooks/use-dearrow.ts
deleted file mode 100644
index 95fd798a..00000000
--- a/apps/web/src/hooks/use-dearrow.ts
+++ /dev/null
@@ -1,36 +0,0 @@
-import { useQuery } from "@tanstack/react-query";
-import { fetchDeArrow, resolveDeArrowBranding } from "../lib/api-dearrow";
-import { youtubeVideoId } from "../lib/watch-url";
-import { useSettings } from "./use-settings";
-
-function useDeArrow(sourceUrl: string, enabled: boolean) {
- const videoId = youtubeVideoId(sourceUrl);
- return useQuery({
- queryKey: ["dearrow", videoId],
- queryFn: () => fetchDeArrow(videoId ?? ""),
- enabled: enabled && videoId !== null,
- staleTime: 24 * 60 * 60 * 1000,
- gcTime: 24 * 60 * 60 * 1000,
- retry: false,
- });
-}
-
-export function useDeArrowBranding(
- sourceUrl: string,
- title: string,
- thumbnail: string,
- duration?: number,
-) {
- const { settings } = useSettings();
- const item = useDeArrow(sourceUrl, settings.deArrowEnabled).data;
- return resolveDeArrowBranding(
- item,
- { title, thumbnail },
- {
- titleMode: settings.deArrowTitleMode,
- thumbnailMode: settings.deArrowThumbnailMode,
- trustMode: settings.deArrowTrustMode,
- duration,
- },
- );
-}
diff --git a/apps/web/src/hooks/use-debounced-value.ts b/apps/web/src/hooks/use-debounced-value.ts
deleted file mode 100644
index 579d8be3..00000000
--- a/apps/web/src/hooks/use-debounced-value.ts
+++ /dev/null
@@ -1,12 +0,0 @@
-import { useEffect, useState } from "react";
-
-export function useDebouncedValue(value: string, delay: number): string {
- const [debounced, setDebounced] = useState(value);
-
- useEffect(() => {
- const timer = setTimeout(() => setDebounced(value), delay);
- return () => clearTimeout(timer);
- }, [value, delay]);
-
- return debounced;
-}
diff --git a/apps/web/src/hooks/use-document-title.ts b/apps/web/src/hooks/use-document-title.ts
deleted file mode 100644
index ba2b0e2b..00000000
--- a/apps/web/src/hooks/use-document-title.ts
+++ /dev/null
@@ -1,17 +0,0 @@
-import { useEffect } from "react";
-
-const DEFAULT_DOCUMENT_TITLE = "TypeType";
-
-function toDocumentTitle(title: string | null | undefined): string {
- const trimmed = title?.trim();
- return trimmed ? `${trimmed} - ${DEFAULT_DOCUMENT_TITLE}` : DEFAULT_DOCUMENT_TITLE;
-}
-
-export function useDocumentTitle(title: string | null | undefined): void {
- useEffect(() => {
- document.title = toDocumentTitle(title);
- return () => {
- document.title = DEFAULT_DOCUMENT_TITLE;
- };
- }, [title]);
-}
diff --git a/apps/web/src/hooks/use-downloader-job.ts b/apps/web/src/hooks/use-downloader-job.ts
deleted file mode 100644
index 19112c55..00000000
--- a/apps/web/src/hooks/use-downloader-job.ts
+++ /dev/null
@@ -1,155 +0,0 @@
-import { useMutation, useQuery } from "@tanstack/react-query";
-import { useEffect, useMemo, useState } from "react";
-import {
- cancelDownloaderJob,
- canUseIosShareFlow,
- createDownloaderJob,
- downloadDownloaderArtifact,
- fetchDownloaderJob,
-} from "../lib/api-downloader";
-import { downloaderErrorCode } from "../lib/downloader-errors";
-import { subscribeDownloaderEvents } from "../lib/downloader-events";
-import type {
- DownloaderCreateJobRequest,
- DownloaderJobResponse,
- DownloaderJobStage,
- DownloaderJobStatus,
-} from "../types/downloader";
-
-const POLL_MS = 1_500;
-
-export function useDownloaderJob() {
- const [eventJob, setEventJob] = useState(null);
-
- const create = useMutation({
- mutationFn: (payload: DownloaderCreateJobRequest) => createDownloaderJob(payload),
- retry: false,
- });
- const jobId = create.data?.id;
- const cancel = useMutation({
- mutationFn: (id: string) => cancelDownloaderJob(id),
- onSuccess: (next) =>
- setEventJob((current) => (current?.id === next.id ? { ...current, ...next } : next)),
- });
-
- useEffect(() => {
- if (!jobId) return;
- return subscribeDownloaderEvents(jobId, {
- onMessage: (next) =>
- setEventJob((current) => (current?.id === next.id ? { ...current, ...next } : next)),
- onError: () => undefined,
- });
- }, [jobId]);
-
- const query = useQuery({
- queryKey: ["downloader-job", jobId],
- enabled: typeof jobId === "string" && jobId.length > 0,
- queryFn: () => fetchDownloaderJob(jobId ?? ""),
- refetchInterval: (query) => {
- const current = query.state.data?.status;
- if (current === "done" || current === "failed") return false;
- const eventStatus = eventJob?.status;
- const active =
- current === "queued" ||
- current === "running" ||
- eventStatus === "queued" ||
- eventStatus === "running";
- return active ? POLL_MS : false;
- },
- });
- const job = useMemo(() => {
- const queryJob = query.data ?? create.data;
- if (!eventJob) return queryJob;
- if (!queryJob || queryJob.id !== eventJob.id) return eventJob;
- if (queryJob.status === "done" || queryJob.status === "failed") {
- return {
- ...eventJob,
- ...queryJob,
- resolved: queryJob.resolved ?? eventJob.resolved,
- error: queryJob.error ?? eventJob.error,
- errorCode: queryJob.errorCode ?? eventJob.errorCode,
- tokenFetchMs: queryJob.tokenFetchMs ?? eventJob.tokenFetchMs,
- ytdlpMs: queryJob.ytdlpMs ?? eventJob.ytdlpMs,
- uploadMs: queryJob.uploadMs ?? eventJob.uploadMs,
- totalMs: queryJob.totalMs ?? eventJob.totalMs,
- };
- }
- return {
- ...queryJob,
- ...eventJob,
- resolved: eventJob.resolved ?? queryJob.resolved,
- error: eventJob.error ?? queryJob.error,
- errorCode: eventJob.errorCode ?? queryJob.errorCode,
- };
- }, [create.data, eventJob, query.data]);
-
- const createError = create.error instanceof Error ? create.error : null;
- const status: DownloaderJobStatus | null = create.isPending
- ? "queued"
- : createError
- ? "failed"
- : (job?.status ?? null);
- const isQueued = status === "queued";
- const isRunning = status === "running";
- const isDone = status === "done";
- const isFailed = status === "failed";
- const stage: DownloaderJobStage | null = createError ? "failed" : (job?.stage ?? null);
- const progressPercent = typeof job?.progressPercent === "number" ? job.progressPercent : null;
- const resolved = job?.resolved ?? null;
- const errorCode = downloaderErrorCode(createError) ?? job?.errorCode ?? null;
- const tokenFetchMs = typeof job?.tokenFetchMs === "number" ? job.tokenFetchMs : null;
- const ytdlpMs = typeof job?.ytdlpMs === "number" ? job.ytdlpMs : null;
- const uploadMs = typeof job?.uploadMs === "number" ? job.uploadMs : null;
- const totalMs = typeof job?.totalMs === "number" ? job.totalMs : null;
- const errorText = createError
- ? createError.message
- : cancel.error instanceof Error
- ? cancel.error.message
- : job?.error || (query.error instanceof Error ? query.error.message : null);
-
- function start(payload: DownloaderCreateJobRequest) {
- setEventJob(null);
- create.reset();
- create.mutate(payload);
- }
-
- function openArtifact(options?: { preferShare?: boolean }) {
- if (!jobId) return;
- return downloadDownloaderArtifact(jobId, options);
- }
-
- function cancelJob() {
- if (!jobId || (status !== "queued" && status !== "running")) return;
- cancel.mutate(jobId);
- }
-
- function reset() {
- setEventJob(null);
- cancel.reset();
- create.reset();
- }
-
- return {
- start,
- openArtifact,
- cancelJob,
- reset,
- jobId,
- status,
- stage,
- progressPercent,
- resolved,
- tokenFetchMs,
- ytdlpMs,
- uploadMs,
- totalMs,
- errorCode,
- canUseIosShareFlow: canUseIosShareFlow(),
- isCancelling: cancel.isPending,
- isQueued,
- isRunning,
- isDone,
- isFailed,
- errorText,
- };
-}
diff --git a/apps/web/src/hooks/use-favorite-status.ts b/apps/web/src/hooks/use-favorite-status.ts
deleted file mode 100644
index 4e1afc1d..00000000
--- a/apps/web/src/hooks/use-favorite-status.ts
+++ /dev/null
@@ -1,62 +0,0 @@
-import { useQuery, useQueryClient } from "@tanstack/react-query";
-import { useState } from "react";
-import { addFavorite, fetchFavorite, removeFavorite } from "../lib/api-collections";
-import type { FavoriteItem } from "../types/user";
-import { useAuth } from "./use-auth";
-
-const FAVORITES_KEY = ["favorites"] as const;
-
-export function useFavoriteStatus(videoUrl: string) {
- const { authReady, isAuthed } = useAuth();
- const queryClient = useQueryClient();
- const [intent, setIntent] = useState(null);
- const queryKey = [...FAVORITES_KEY, videoUrl] as const;
- const query = useQuery({
- queryKey,
- queryFn: () => fetchFavorite(videoUrl),
- enabled: authReady && isAuthed,
- });
- const isFavorite = intent ?? (query.data !== null && query.data !== undefined);
-
- async function add(): Promise {
- if (isFavorite) return;
- setIntent(true);
- try {
- const favorite = await addFavorite(videoUrl);
- queryClient.setQueryData(queryKey, favorite);
- await queryClient.invalidateQueries({
- queryKey: FAVORITES_KEY,
- exact: true,
- refetchType: "none",
- });
- } catch (error) {
- setIntent(null);
- throw error;
- }
- setIntent(null);
- }
-
- async function remove(): Promise {
- setIntent(false);
- try {
- await removeFavorite(videoUrl);
- queryClient.setQueryData(queryKey, null);
- await queryClient.invalidateQueries({
- queryKey: FAVORITES_KEY,
- exact: true,
- refetchType: "none",
- });
- } catch (error) {
- setIntent(null);
- throw error;
- }
- setIntent(null);
- }
-
- return {
- isFavorite,
- add,
- remove,
- isPending: (authReady && isAuthed && query.isPending) || intent !== null,
- };
-}
diff --git a/apps/web/src/hooks/use-favorite-streams.ts b/apps/web/src/hooks/use-favorite-streams.ts
deleted file mode 100644
index 0db5f1a0..00000000
--- a/apps/web/src/hooks/use-favorite-streams.ts
+++ /dev/null
@@ -1,69 +0,0 @@
-import { useQuery } from "@tanstack/react-query";
-import { fetchFavorites } from "../lib/api-collections";
-import { proxyImage } from "../lib/proxy";
-import type { VideoStream } from "../types/stream";
-import type { FavoriteItem, PlaylistVideoItem } from "../types/user";
-import { useAuth } from "./use-auth";
-
-type UseFavoriteStreamsOptions = {
- limit?: number;
-};
-
-function mapFavoriteItem(item: FavoriteItem): VideoStream {
- const rawThumbnail = item.thumbnail ?? "";
- const rawChannelAvatar = item.channelAvatar ?? "";
- return {
- id: item.videoUrl,
- title: item.title ?? "",
- thumbnail: proxyImage(rawThumbnail),
- rawThumbnail,
- rawChannelAvatar,
- channelName: item.channelName ?? "",
- channelUrl: item.channelUrl || undefined,
- channelAvatar: proxyImage(rawChannelAvatar),
- views: item.viewCount ?? 0,
- duration: item.duration ?? 0,
- publishedAt: item.publishedAt && item.publishedAt > 0 ? item.publishedAt : undefined,
- };
-}
-
-function mapFavoritePlaylistItem(item: FavoriteItem, position: number): PlaylistVideoItem {
- return {
- id: item.videoUrl,
- url: item.videoUrl,
- title: item.title ?? "Unavailable video",
- thumbnail: item.thumbnail ?? "",
- channelName: item.channelName ?? "",
- channelUrl: item.channelUrl ?? "",
- channelAvatar: item.channelAvatar ?? "",
- viewCount: item.viewCount ?? 0,
- duration: item.duration ?? 0,
- position,
- addedAt: item.favoritedAt,
- publishedAt: item.publishedAt,
- watchPosition: 0,
- watched: false,
- progressUpdatedAt: 0,
- };
-}
-
-export function useFavoriteStreams(options: UseFavoriteStreamsOptions = {}) {
- const { authReady, isAuthed } = useAuth();
- const favorites = useQuery({
- queryKey: ["favorites"],
- queryFn: fetchFavorites,
- enabled: authReady && isAuthed,
- });
- const items =
- options.limit === undefined
- ? (favorites.data ?? [])
- : (favorites.data ?? []).slice(0, options.limit);
-
- return {
- count: favorites.data?.length ?? 0,
- requestedCount: items.length,
- videos: items.map(mapFavoriteItem),
- playlistVideos: items.map(mapFavoritePlaylistItem),
- isLoading: favorites.isLoading,
- };
-}
diff --git a/apps/web/src/hooks/use-favorites-playlist.ts b/apps/web/src/hooks/use-favorites-playlist.ts
deleted file mode 100644
index 201b81d3..00000000
--- a/apps/web/src/hooks/use-favorites-playlist.ts
+++ /dev/null
@@ -1,63 +0,0 @@
-import { useQuery } from "@tanstack/react-query";
-import { useRef, useState } from "react";
-import { addFavorite, fetchFavorites, removeFavorite } from "../lib/api-collections";
-import { useAuth } from "./use-auth";
-
-const KEY = ["favorites"];
-
-type AddPayload = {
- url: string;
- title: string;
- thumbnail: string;
- duration: number;
-};
-
-type Intent = { url: string; adding: boolean };
-
-export function useFavoritesPlaylist() {
- const { authReady, isAuthed } = useAuth();
- const intentRef = useRef(null);
- const [intent, setIntent] = useState(null);
- const query = useQuery({
- queryKey: KEY,
- queryFn: fetchFavorites,
- enabled: authReady && isAuthed,
- });
-
- function isInFavorites(videoUrl: string): boolean {
- if (intentRef.current?.url === videoUrl) return intentRef.current.adding;
- return query.data?.some((item) => item.videoUrl === videoUrl) ?? false;
- }
-
- function applyIntent(value: Intent | null) {
- intentRef.current = value;
- setIntent(value);
- }
-
- async function add(payload: AddPayload): Promise {
- if (isInFavorites(payload.url)) return;
- applyIntent({ url: payload.url, adding: true });
- try {
- await addFavorite(payload.url);
- await query.refetch();
- } catch (e) {
- applyIntent(null);
- throw e;
- }
- applyIntent(null);
- }
-
- async function remove(videoUrl: string): Promise {
- applyIntent({ url: videoUrl, adding: false });
- try {
- await removeFavorite(videoUrl);
- await query.refetch();
- } catch (e) {
- applyIntent(null);
- throw e;
- }
- applyIntent(null);
- }
-
- return { isInFavorites, add, remove, isPending: intent !== null };
-}
diff --git a/apps/web/src/hooks/use-flip-list.ts b/apps/web/src/hooks/use-flip-list.ts
deleted file mode 100644
index 5c415023..00000000
--- a/apps/web/src/hooks/use-flip-list.ts
+++ /dev/null
@@ -1,38 +0,0 @@
-import { useCallback, useLayoutEffect, useRef } from "react";
-
-export function useFlipList(orderKey: string) {
- const elements = useRef>(new Map());
- const previous = useRef>(new Map());
- const lastKey = useRef(orderKey);
-
- const register = useCallback((key: string, element: HTMLElement | null) => {
- if (element) elements.current.set(key, element);
- else elements.current.delete(key);
- }, []);
-
- useLayoutEffect(() => {
- const current = new Map();
- for (const [key, element] of elements.current) {
- current.set(key, element.getBoundingClientRect());
- }
- if (lastKey.current !== orderKey) {
- for (const [key, rect] of current) {
- const before = previous.current.get(key);
- const element = elements.current.get(key);
- if (!before || !element) continue;
- const deltaY = before.top - rect.top;
- if (Math.abs(deltaY) < 1) continue;
- element.style.transition = "none";
- element.style.transform = `translateY(${deltaY}px)`;
- requestAnimationFrame(() => {
- element.style.transition = "transform 0.4s cubic-bezier(0.2, 0, 0, 1)";
- element.style.transform = "";
- });
- }
- lastKey.current = orderKey;
- }
- previous.current = current;
- }, [orderKey]);
-
- return register;
-}
diff --git a/apps/web/src/hooks/use-history.ts b/apps/web/src/hooks/use-history.ts
deleted file mode 100644
index 030f9a84..00000000
--- a/apps/web/src/hooks/use-history.ts
+++ /dev/null
@@ -1,118 +0,0 @@
-import type { InfiniteData } from "@tanstack/react-query";
-import { useInfiniteQuery, useMutation, useQueryClient } from "@tanstack/react-query";
-import { updateProgress } from "../lib/api-collections";
-import { addHistory, clearHistory, fetchHistory, removeHistory } from "../lib/api-user";
-import type { HistoryItem, ProgressItem } from "../types/user";
-import { useAuth } from "./use-auth";
-import { useDebouncedValue } from "./use-debounced-value";
-
-const PAGE_SIZE = 40;
-
-const historyKey = (q: string) => ["history", q];
-
-type HistoryPage = {
- items: HistoryItem[];
- total: number;
-};
-
-type HistoryInfiniteData = InfiniteData;
-
-type RemoveHistoryPayload = {
- id: string;
- url: string;
-};
-
-function clearedProgress(url: string): ProgressItem {
- return { videoUrl: url, position: 0, updatedAt: Date.now() };
-}
-
-function emptyHistoryData(data: HistoryInfiniteData | undefined): HistoryInfiniteData | undefined {
- if (!data) return data;
- return {
- ...data,
- pages: data.pages.map((page) => ({ ...page, items: [], total: 0 })),
- };
-}
-
-export function useHistory(searchQuery = "") {
- const qc = useQueryClient();
- const { authReady, isAuthed } = useAuth();
- const debouncedQuery = useDebouncedValue(searchQuery, 300);
-
- const query = useInfiniteQuery({
- queryKey: historyKey(debouncedQuery),
- queryFn: ({ pageParam = 0 }) =>
- fetchHistory({
- q: debouncedQuery || undefined,
- limit: PAGE_SIZE,
- offset: pageParam,
- }),
- getNextPageParam: (lastPage, pages) => {
- const fetched = pages.reduce((sum, p) => sum + p.items.length, 0);
- return fetched < lastPage.total ? fetched : undefined;
- },
- initialPageParam: 0,
- enabled: authReady && isAuthed,
- staleTime: 30 * 1000,
- gcTime: 10 * 60 * 1000,
- refetchOnWindowFocus: false,
- });
-
- const add = useMutation({
- mutationFn: async (item: Omit) => {
- if (!isAuthed) return;
- const cached = qc.getQueryData<{ pages: { items: HistoryItem[] }[] }>(historyKey(""));
- const existing = cached?.pages.flatMap((p) => p.items).find((h) => h.url === item.url);
- if (existing) await removeHistory(existing.id);
- await addHistory(item);
- },
- onSuccess: () =>
- qc
- .invalidateQueries({ queryKey: ["history"] })
- .then(() => qc.invalidateQueries({ queryKey: ["history-all"] })),
- });
-
- const remove = useMutation({
- mutationFn: async ({ id, url }: RemoveHistoryPayload) => {
- if (!isAuthed) return;
- await updateProgress(url, 0);
- await removeHistory(id);
- },
- onSuccess: (_, payload) => {
- qc.setQueryData(["progress", payload.url], clearedProgress(payload.url));
- return qc
- .invalidateQueries({ queryKey: ["history"] })
- .then(() => qc.invalidateQueries({ queryKey: ["history-all"] }))
- .then(() => qc.invalidateQueries({ queryKey: ["progress", payload.url] }));
- },
- });
-
- const clear = useMutation({
- mutationFn: async () => {
- if (!isAuthed) return;
- await clearHistory();
- },
- onSuccess: () => {
- qc.setQueriesData({ queryKey: ["history"] }, emptyHistoryData);
- qc.setQueriesData({ queryKey: ["history-filtered"] }, (data) =>
- data ? { ...data, items: [], total: 0 } : data,
- );
- qc.removeQueries({ queryKey: ["progress"] });
- return qc
- .invalidateQueries({ queryKey: ["history"] })
- .then(() => qc.invalidateQueries({ queryKey: ["history-filtered"] }))
- .then(() => qc.invalidateQueries({ queryKey: ["history-all"] }));
- },
- });
-
- const total = query.data?.pages[0]?.total ?? 0;
- const rawItems = query.data?.pages.flatMap((p) => p.items) ?? [];
- const seen = new Set();
- const items = rawItems.filter((item) => {
- if (seen.has(item.url)) return false;
- seen.add(item.url);
- return true;
- });
-
- return { query, items, total, add, remove, clear };
-}
diff --git a/apps/web/src/hooks/use-hold-fast-forward.ts b/apps/web/src/hooks/use-hold-fast-forward.ts
deleted file mode 100644
index 9d2799d0..00000000
--- a/apps/web/src/hooks/use-hold-fast-forward.ts
+++ /dev/null
@@ -1,66 +0,0 @@
-import { useCallback, useRef, useState } from "react";
-import { useMediaRemote, useMediaState } from "../lib/vidstack";
-
-const HOLD_SPEED = 2;
-const HOLD_DELAY_MS = 180;
-
-type HoldFastForward = {
- holding: boolean;
- isActive: () => boolean;
- start: () => void;
- restore: (updateIndicator?: boolean) => void;
-};
-
-export function useHoldFastForward(): HoldFastForward {
- const remote = useMediaRemote();
- const paused = useMediaState("paused");
- const playbackRate = useMediaState("playbackRate");
- const [holding, setHolding] = useState(false);
- const remoteRef = useRef(remote);
- const pausedRef = useRef(true);
- const rateRef = useRef(1);
- const timerRef = useRef(null);
- const activeRef = useRef(false);
- const startedPausedRef = useRef(false);
- const restoreRateRef = useRef(1);
-
- remoteRef.current = remote;
- pausedRef.current = paused;
- rateRef.current = Number.isFinite(playbackRate) && playbackRate > 0 ? playbackRate : 1;
-
- const clear = useCallback(() => {
- if (timerRef.current === null) return;
- window.clearTimeout(timerRef.current);
- timerRef.current = null;
- }, []);
-
- const start = useCallback(() => {
- if (timerRef.current !== null || activeRef.current) return;
- timerRef.current = window.setTimeout(() => {
- timerRef.current = null;
- activeRef.current = true;
- setHolding(true);
- startedPausedRef.current = pausedRef.current;
- restoreRateRef.current = rateRef.current;
- remoteRef.current.changePlaybackRate(HOLD_SPEED);
- if (pausedRef.current) void Promise.resolve(remoteRef.current.play()).catch(() => {});
- }, HOLD_DELAY_MS);
- }, []);
-
- const restore = useCallback(
- (updateIndicator = true) => {
- clear();
- if (!activeRef.current) return;
- activeRef.current = false;
- if (updateIndicator) setHolding(false);
- remoteRef.current.changePlaybackRate(restoreRateRef.current);
- if (startedPausedRef.current) void Promise.resolve(remoteRef.current.pause()).catch(() => {});
- startedPausedRef.current = false;
- },
- [clear],
- );
-
- const isActive = useCallback(() => activeRef.current, []);
-
- return { holding, isActive, start, restore };
-}
diff --git a/apps/web/src/hooks/use-home-recommendations.ts b/apps/web/src/hooks/use-home-recommendations.ts
deleted file mode 100644
index 425b9e6d..00000000
--- a/apps/web/src/hooks/use-home-recommendations.ts
+++ /dev/null
@@ -1,56 +0,0 @@
-import { useInfiniteQuery } from "@tanstack/react-query";
-import { useMemo } from "react";
-import { fetchHomeRecommendations, type RecommendationIntent } from "../lib/api-recommendations";
-import { mapVideoItem } from "../lib/mappers";
-import type { VideoStream } from "../types/stream";
-import { useAuth } from "./use-auth";
-import { useSettings } from "./use-settings";
-
-const PAGE_SIZE = 30;
-
-type Result = {
- streams: VideoStream[];
- serviceId: number;
- intent: RecommendationIntent;
- isLoading: boolean;
- isError: boolean;
- isFetchingNextPage: boolean;
- hasNextPage: boolean;
- fetchNextPage: () => void;
-};
-
-export function useHomeRecommendations(): Result {
- const { authReady, isAuthed } = useAuth();
- const { settings } = useSettings();
- const intent: RecommendationIntent = "auto";
- const query = useInfiniteQuery({
- queryKey: ["home-recommendations", "v2", settings.defaultService, intent],
- queryFn: ({ pageParam }) =>
- fetchHomeRecommendations(
- settings.defaultService,
- PAGE_SIZE,
- pageParam as string | undefined,
- intent,
- ),
- initialPageParam: undefined as string | undefined,
- getNextPageParam: (last) => (last.hasMore ? (last.nextCursor ?? undefined) : undefined),
- enabled: authReady && isAuthed && !settings.hideHomeRecommendations,
- staleTime: 90 * 1000,
- });
-
- const streams = useMemo(
- () => (query.data?.pages ?? []).flatMap((page) => page.items).map(mapVideoItem),
- [query.data],
- );
-
- return {
- streams,
- serviceId: settings.defaultService,
- intent,
- isLoading: query.isLoading,
- isError: query.isError,
- isFetchingNextPage: query.isFetchingNextPage,
- hasNextPage: query.hasNextPage,
- fetchNextPage: query.fetchNextPage,
- };
-}
diff --git a/apps/web/src/hooks/use-infinite-comments.ts b/apps/web/src/hooks/use-infinite-comments.ts
deleted file mode 100644
index ca31e0c7..00000000
--- a/apps/web/src/hooks/use-infinite-comments.ts
+++ /dev/null
@@ -1,29 +0,0 @@
-import { useInfiniteQuery } from "@tanstack/react-query";
-import { fetchComments } from "../lib/api";
-import { mapCommentItem } from "../lib/mappers";
-import type { Comment } from "../types/comment";
-
-type CommentPage = {
- comments: Comment[];
- nextpage: string | null;
- commentsDisabled: boolean;
-};
-
-export function useInfiniteComments(videoUrl: string, enabled = true) {
- return useInfiniteQuery({
- queryKey: ["comments", videoUrl],
- queryFn: async ({ pageParam }: { pageParam: string | undefined }) => {
- const res = await fetchComments(videoUrl, pageParam);
- return {
- comments: res.comments.map(mapCommentItem),
- nextpage: res.nextpage,
- commentsDisabled: res.commentsDisabled,
- } satisfies CommentPage;
- },
- initialPageParam: undefined as string | undefined,
- getNextPageParam: (last: CommentPage) => last.nextpage ?? undefined,
- enabled: enabled && videoUrl.length > 0,
- staleTime: 30 * 1000,
- gcTime: 10 * 60 * 1000,
- });
-}
diff --git a/apps/web/src/hooks/use-instance.ts b/apps/web/src/hooks/use-instance.ts
deleted file mode 100644
index 4c933494..00000000
--- a/apps/web/src/hooks/use-instance.ts
+++ /dev/null
@@ -1,12 +0,0 @@
-import { useQuery } from "@tanstack/react-query";
-import { fetchInstanceCapabilities } from "../lib/api-instance";
-
-export const INSTANCE_KEY = ["instance"];
-
-export function useInstance() {
- return useQuery({
- queryKey: INSTANCE_KEY,
- queryFn: fetchInstanceCapabilities,
- staleTime: Number.POSITIVE_INFINITY,
- });
-}
diff --git a/apps/web/src/hooks/use-latest-value.ts b/apps/web/src/hooks/use-latest-value.ts
deleted file mode 100644
index f1f12c0c..00000000
--- a/apps/web/src/hooks/use-latest-value.ts
+++ /dev/null
@@ -1,7 +0,0 @@
-import { useCallback, useRef } from "react";
-
-export function useLatestValue(value: T): () => T {
- const ref = useRef(value);
- ref.current = value;
- return useCallback(() => ref.current, []);
-}
diff --git a/apps/web/src/hooks/use-mobile.ts b/apps/web/src/hooks/use-mobile.ts
deleted file mode 100644
index 0194e1ef..00000000
--- a/apps/web/src/hooks/use-mobile.ts
+++ /dev/null
@@ -1,25 +0,0 @@
-import { useEffect, useState } from "react";
-
-const MOBILE_MEDIA_QUERY = "(max-width: 1023px)";
-const COARSE_POINTER_QUERY = "(hover: none) and (pointer: coarse)";
-
-function readIsMobile(): boolean {
- if (typeof window === "undefined") return false;
- return (
- window.matchMedia(MOBILE_MEDIA_QUERY).matches || window.matchMedia(COARSE_POINTER_QUERY).matches
- );
-}
-
-export function useMobile() {
- const [isMobile, setIsMobile] = useState(readIsMobile);
-
- useEffect(() => {
- function onResize() {
- setIsMobile(readIsMobile());
- }
- window.addEventListener("resize", onResize);
- return () => window.removeEventListener("resize", onResize);
- }, []);
-
- return isMobile;
-}
diff --git a/apps/web/src/hooks/use-notifications.ts b/apps/web/src/hooks/use-notifications.ts
deleted file mode 100644
index 9d6530e9..00000000
--- a/apps/web/src/hooks/use-notifications.ts
+++ /dev/null
@@ -1,60 +0,0 @@
-import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
-import {
- fetchNotifications,
- fetchUnreadNotificationsCount,
- markAllNotificationsRead,
-} from "../lib/api-notifications";
-import { useAuth } from "./use-auth";
-
-const KEY = ["notifications"];
-const UNREAD_KEY = ["notifications-unread-count"];
-const PAGE_SIZE = 20;
-
-export function useNotifications(open: boolean) {
- const qc = useQueryClient();
- const { authReady, isAuthed, isGuest } = useAuth();
- const enabled = authReady && isAuthed && !isGuest;
-
- const unreadQuery = useQuery({
- queryKey: UNREAD_KEY,
- queryFn: () => fetchUnreadNotificationsCount(),
- enabled,
- refetchInterval: enabled ? 90_000 : false,
- retry: false,
- refetchOnWindowFocus: true,
- refetchOnReconnect: true,
- });
-
- const query = useInfiniteQuery({
- queryKey: KEY,
- queryFn: ({ pageParam = 0 }) => fetchNotifications(pageParam, PAGE_SIZE),
- getNextPageParam: (lastPage) => lastPage.nextpage ?? undefined,
- initialPageParam: 0,
- enabled: enabled && open,
- staleTime: 30_000,
- retry: false,
- refetchOnWindowFocus: true,
- refetchOnReconnect: true,
- });
-
- const markAllRead = useMutation({
- mutationFn: () => markAllNotificationsRead(),
- onSuccess: () => {
- qc.invalidateQueries({ queryKey: KEY });
- qc.invalidateQueries({ queryKey: UNREAD_KEY });
- },
- });
-
- return {
- query,
- unreadQuery,
- markAllRead,
- unreadCount: unreadQuery.data?.unreadCount ?? 0,
- items: query.data?.pages.flatMap((page) => page.items) ?? [],
- hasNextPage: query.hasNextPage,
- fetchNextPage: query.fetchNextPage,
- isFetchingNextPage: query.isFetchingNextPage,
- isFetchNextPageError: query.isFetchNextPageError,
- enabled,
- };
-}
diff --git a/apps/web/src/hooks/use-oidc-status.ts b/apps/web/src/hooks/use-oidc-status.ts
deleted file mode 100644
index 1dd1fbce..00000000
--- a/apps/web/src/hooks/use-oidc-status.ts
+++ /dev/null
@@ -1,10 +0,0 @@
-import { useQuery } from "@tanstack/react-query";
-import { fetchOidcStatus } from "../lib/api-oidc";
-
-export function useOidcStatus() {
- return useQuery({
- queryKey: ["oidc-status"],
- queryFn: fetchOidcStatus,
- staleTime: 5 * 60 * 1000,
- });
-}
diff --git a/apps/web/src/hooks/use-overlay-lock.ts b/apps/web/src/hooks/use-overlay-lock.ts
deleted file mode 100644
index 73609fbf..00000000
--- a/apps/web/src/hooks/use-overlay-lock.ts
+++ /dev/null
@@ -1,15 +0,0 @@
-import { useEffect } from "react";
-
-export function useOverlayLock(active: boolean) {
- useEffect(() => {
- if (!active) return;
- const htmlOverflow = document.documentElement.style.overflow;
- const bodyOverflow = document.body.style.overflow;
- document.documentElement.style.overflow = "hidden";
- document.body.style.overflow = "hidden";
- return () => {
- document.documentElement.style.overflow = htmlOverflow;
- document.body.style.overflow = bodyOverflow;
- };
- }, [active]);
-}
diff --git a/apps/web/src/hooks/use-pipepipe-restore.ts b/apps/web/src/hooks/use-pipepipe-restore.ts
deleted file mode 100644
index 0d8189e8..00000000
--- a/apps/web/src/hooks/use-pipepipe-restore.ts
+++ /dev/null
@@ -1,29 +0,0 @@
-import { useMutation, useQueryClient } from "@tanstack/react-query";
-import { type PipePipeTimeMode, restorePipePipe } from "../lib/api-restore";
-
-const INVALIDATE_KEYS = [
- ["history"],
- ["history-all"],
- ["subscriptions"],
- ["subscription-feed"],
- ["playlists"],
- ["search-history"],
- ["progress"],
-] as const;
-
-export function usePipePipeRestore() {
- const qc = useQueryClient();
-
- const restore = useMutation({
- mutationFn: ({ file, timeMode }: { file: File; timeMode: PipePipeTimeMode }) =>
- restorePipePipe(file, timeMode),
- onSuccess: async () => {
- await Promise.all(
- INVALIDATE_KEYS.map((queryKey) => qc.invalidateQueries({ queryKey: [...queryKey] })),
- );
- await qc.invalidateQueries({ queryKey: ["history"] });
- },
- });
-
- return { restore };
-}
diff --git a/apps/web/src/hooks/use-playback-mode.ts b/apps/web/src/hooks/use-playback-mode.ts
deleted file mode 100644
index 0ee074ee..00000000
--- a/apps/web/src/hooks/use-playback-mode.ts
+++ /dev/null
@@ -1,23 +0,0 @@
-import { useSyncExternalStore } from "react";
-import {
- type PlaybackMode,
- readPlaybackMode,
- requestPlaybackMode,
- subscribePlaybackMode,
-} from "../lib/playback-mode";
-
-export function usePlaybackMode(): {
- playbackMode: PlaybackMode;
- setMode: (next: PlaybackMode) => void;
-} {
- const playbackMode = useSyncExternalStore(
- subscribePlaybackMode,
- readPlaybackMode,
- readPlaybackMode,
- );
-
- return {
- playbackMode,
- setMode: requestPlaybackMode,
- };
-}
diff --git a/apps/web/src/hooks/use-player-error-resume.ts b/apps/web/src/hooks/use-player-error-resume.ts
deleted file mode 100644
index 1e793b5d..00000000
--- a/apps/web/src/hooks/use-player-error-resume.ts
+++ /dev/null
@@ -1,46 +0,0 @@
-import { useCallback, useEffect, useState } from "react";
-
-type PositionRef = {
- current: number;
-};
-
-export function playerErrorResumePosition(
- positionMs: number,
- recoveryPositionMs: number | undefined,
- durationMs: number,
-): number {
- const candidate =
- recoveryPositionMs !== undefined && Number.isFinite(recoveryPositionMs)
- ? recoveryPositionMs
- : positionMs;
- if (candidate < 5000 || candidate >= durationMs * 0.95) return 0;
- return Math.round(candidate);
-}
-
-export function usePlayerErrorResume(
- streamId: string,
- duration: number,
- positionRef: PositionRef,
- onError: () => void,
-) {
- const [retryStartTime, setRetryStartTime] = useState(0);
-
- useEffect(() => {
- if (streamId.length > 0) setRetryStartTime(0);
- }, [streamId]);
-
- const handlePlayerError = useCallback(
- (recoveryPositionMs?: number) => {
- const position = playerErrorResumePosition(
- positionRef.current,
- recoveryPositionMs,
- duration * 1000,
- );
- if (position > 0) setRetryStartTime(position);
- onError();
- },
- [duration, onError, positionRef],
- );
-
- return { retryStartTime, handlePlayerError };
-}
diff --git a/apps/web/src/hooks/use-player-error.ts b/apps/web/src/hooks/use-player-error.ts
deleted file mode 100644
index cc1b2a73..00000000
--- a/apps/web/src/hooks/use-player-error.ts
+++ /dev/null
@@ -1,166 +0,0 @@
-import { useCallback, useEffect, useRef, useState } from "react";
-import { bilibiliVariantCount } from "../lib/bilibili-manifest";
-import { recordClientEvent } from "../lib/client-debug-log";
-import { sanitizeVideoContext } from "../lib/debug-sanitize";
-import { isIosDevice } from "../lib/ios-device";
-import { detectProvider } from "../lib/provider";
-import { claimAutomaticSabrRecovery, resetAutomaticSabrRecovery } from "../lib/sabr-error-recovery";
-import {
- hasLegacyDashPair,
- hasSabrPlayback,
- legacyProgressiveStreams,
-} from "../lib/stream-delivery";
-import { resolveManifestSrc, shouldUseClassicHls } from "../lib/stream-src";
-import type { MediaSrc } from "../lib/vidstack";
-import type { VideoStream } from "../types/stream";
-import { useInstance } from "./use-instance";
-import { usePlaybackMode } from "./use-playback-mode";
-
-type UsePlayerErrorReturn = {
- manifestSrc: MediaSrc;
- manifestLoading: boolean;
- sabrEnabled: boolean;
- playerFailed: boolean;
- qualityFailed: boolean;
- clearFailed: () => void;
- handleError: () => void;
- handleSeeking: (positionMs: number) => void;
- reset: () => void;
- retryKey: number;
- seekStartTime: number | null;
-};
-
-export function usePlayerError(stream: VideoStream, isLive: boolean): UsePlayerErrorReturn {
- const debugVideo = sanitizeVideoContext(stream.id) ?? "unknown";
- const provider = detectProvider(stream.id);
- const iosDevice = isIosDevice();
- const { data: instance } = useInstance();
- const { playbackMode } = usePlaybackMode();
- const playbackSourceId = stream.id.length === 0 ? "" : `${stream.id}:${playbackMode}`;
- const preferServerManifests = instance?.guestAllowed !== false;
- const legacyDashPair = hasLegacyDashPair(stream);
- const hasLegacyPlaybackFallback = legacyDashPair || legacyProgressiveStreams(stream).length > 0;
- const highQualityEnabled =
- !isLive &&
- !iosDevice &&
- preferServerManifests &&
- !stream.hlsUrl &&
- legacyDashPair &&
- provider === "youtube";
- const hlsEnabled = shouldUseClassicHls(stream.hlsUrl, isLive, false, legacyDashPair);
- const [hlsFailed, setHlsFailed] = useState(false);
- const [highQualityFailed, setHighQualityFailed] = useState(false);
- const [qualityFailed, setQualityFailed] = useState(false);
- const [compatibilityFallback, setCompatibilityFallback] = useState(false);
- const [bilibiliVariant, setBilibiliVariant] = useState(0);
- const [playerFailed, setPlayerFailed] = useState(false);
- const [retryKey, setRetryKey] = useState(0);
- const sabrRecoveryRef = useRef(false);
- const bilibiliVariants =
- provider === "bilibili"
- ? bilibiliVariantCount(stream.videoOnlyStreams ?? [], stream.audioStreams ?? [])
- : 0;
- const sabrSelected = provider === "youtube" && !isLive && playbackMode === "sabr";
- const sabrEnabled = sabrSelected && hasSabrPlayback(stream);
-
- const fallbackSrc = resolveManifestSrc(stream, isLive, qualityFailed, {
- compatibilityMode: compatibilityFallback,
- enableHighQualityPlayback: highQualityEnabled,
- highQualityFailed,
- hlsFailed,
- allowServerManifests: preferServerManifests,
- bilibiliVariant,
- });
- const manifestSrc: MediaSrc = sabrSelected ? { src: "", type: "video/mp4" } : fallbackSrc;
- const handleError = useCallback(() => {
- if (sabrSelected) {
- if (claimAutomaticSabrRecovery(sabrRecoveryRef)) {
- recordClientEvent("player.sabr_recovering", { video: debugVideo });
- setRetryKey((k) => k + 1);
- return;
- }
- recordClientEvent("player.sabr_failed", { video: debugVideo });
- setPlayerFailed(true);
- } else if (hlsEnabled && !hlsFailed) {
- recordClientEvent("player.hls_failed", { video: debugVideo });
- if (!hasLegacyPlaybackFallback) {
- setPlayerFailed(true);
- return;
- }
- setHlsFailed(true);
- setRetryKey((k) => k + 1);
- } else if (provider === "bilibili" && bilibiliVariant < bilibiliVariants - 1) {
- recordClientEvent("player.bilibili_variant_failed", { video: debugVideo });
- setBilibiliVariant((variant) => variant + 1);
- setRetryKey((k) => k + 1);
- } else if (highQualityEnabled && !highQualityFailed) {
- recordClientEvent("player.high_quality_failed", { video: debugVideo });
- setHighQualityFailed(true);
- setRetryKey((k) => k + 1);
- } else if (legacyDashPair && !qualityFailed) {
- recordClientEvent("player.quality_failed", { video: debugVideo });
- setQualityFailed(true);
- setRetryKey((k) => k + 1);
- } else if (!isLive && hasLegacyPlaybackFallback && !compatibilityFallback) {
- recordClientEvent("player.compatibility_fallback", { video: debugVideo });
- setCompatibilityFallback(true);
- setRetryKey((k) => k + 1);
- } else {
- recordClientEvent("player.failed", { video: debugVideo });
- setPlayerFailed(true);
- }
- }, [
- debugVideo,
- hlsEnabled,
- hlsFailed,
- sabrSelected,
- hasLegacyPlaybackFallback,
- provider,
- bilibiliVariant,
- bilibiliVariants,
- highQualityEnabled,
- highQualityFailed,
- legacyDashPair,
- qualityFailed,
- compatibilityFallback,
- isLive,
- ]);
-
- const reset = useCallback(() => {
- setHlsFailed(false);
- setHighQualityFailed(false);
- setQualityFailed(false);
- setCompatibilityFallback(false);
- setBilibiliVariant(0);
- setPlayerFailed(false);
- resetAutomaticSabrRecovery(sabrRecoveryRef);
- setRetryKey((k) => k + 1);
- }, []);
-
- const clearFailed = useCallback(() => setPlayerFailed(false), []);
- useEffect(() => {
- if (playbackSourceId.length === 0) return;
- setHlsFailed(false);
- setHighQualityFailed(false);
- setQualityFailed(false);
- setCompatibilityFallback(false);
- setBilibiliVariant(0);
- setPlayerFailed(false);
- resetAutomaticSabrRecovery(sabrRecoveryRef);
- setRetryKey(0);
- }, [playbackSourceId]);
-
- return {
- manifestSrc,
- manifestLoading: false,
- sabrEnabled,
- playerFailed,
- qualityFailed,
- clearFailed,
- handleError,
- handleSeeking: () => undefined,
- reset,
- retryKey,
- seekStartTime: null,
- };
-}
diff --git a/apps/web/src/hooks/use-player-gestures.ts b/apps/web/src/hooks/use-player-gestures.ts
deleted file mode 100644
index 0befd595..00000000
--- a/apps/web/src/hooks/use-player-gestures.ts
+++ /dev/null
@@ -1,126 +0,0 @@
-import { useEffect, useRef } from "react";
-import {
- computeScrubTarget,
- consumePointerEvent,
- exceededDragThreshold,
- isFastForwardPointer,
- isInteractiveTarget,
-} from "../components/player-hotkeys-utils";
-import { useMediaPlayer, useMediaRemote, useMediaState } from "../lib/vidstack";
-import { useHoldFastForward } from "./use-hold-fast-forward";
-
-const SEEK_THROTTLE_MS = 80;
-const SCRUB_RANGE_SECONDS = 90;
-
-export function usePlayerGestures(canSeek: boolean) {
- const player = useMediaPlayer();
- const remote = useMediaRemote();
- const currentTime = useMediaState("currentTime");
- const duration = useMediaState("duration");
- const { holding, isActive, start, restore } = useHoldFastForward();
- const currentTimeRef = useRef(0);
- const durationRef = useRef(0);
- const pointerIdRef = useRef(null);
- const startXRef = useRef(0);
- const startYRef = useRef(0);
- const startTimeRef = useRef(0);
- const scrubbingRef = useRef(false);
- const targetRef = useRef(0);
- const lastSeekRef = useRef(0);
- const suppressTapRef = useRef(false);
-
- currentTimeRef.current = Number.isFinite(currentTime) ? currentTime : 0;
- durationRef.current = Number.isFinite(duration) ? duration : 0;
-
- useEffect(() => {
- function endScrub(commit: boolean) {
- if (!scrubbingRef.current) return;
- scrubbingRef.current = false;
- if (commit) remote.seek(targetRef.current);
- }
-
- function onPointerDown(event: PointerEvent) {
- if (!isFastForwardPointer(event)) return;
- if (event.defaultPrevented || isInteractiveTarget(event.target)) return;
- if (pointerIdRef.current !== null) return;
- pointerIdRef.current = event.pointerId;
- startXRef.current = event.clientX;
- startYRef.current = event.clientY;
- startTimeRef.current = currentTimeRef.current;
- suppressTapRef.current = false;
- start();
- }
-
- function onPointerMove(event: PointerEvent) {
- if (pointerIdRef.current !== event.pointerId || !canSeek) return;
- if (event.pointerType === "mouse") return;
- const dx = event.clientX - startXRef.current;
- if (!scrubbingRef.current) {
- if (!exceededDragThreshold(dx, event.clientY - startYRef.current)) return;
- scrubbingRef.current = true;
- suppressTapRef.current = true;
- lastSeekRef.current = 0;
- restore();
- }
- targetRef.current = computeScrubTarget(
- startTimeRef.current,
- dx,
- player?.el?.clientWidth ?? 0,
- SCRUB_RANGE_SECONDS,
- durationRef.current,
- );
- const now = performance.now();
- if (now - lastSeekRef.current < SEEK_THROTTLE_MS) return;
- lastSeekRef.current = now;
- remote.seek(targetRef.current);
- }
-
- function onPointerEnd(event: PointerEvent) {
- if (pointerIdRef.current !== event.pointerId) return;
- pointerIdRef.current = null;
- const wasScrubbing = scrubbingRef.current;
- const wasHolding = isActive();
- endScrub(event.type !== "pointercancel");
- restore();
- if (wasScrubbing || wasHolding) {
- suppressTapRef.current = true;
- if (event.pointerType === "mouse") consumePointerEvent(event);
- }
- }
-
- function onTouchEnd(event: TouchEvent) {
- if (suppressTapRef.current || scrubbingRef.current || isActive()) {
- event.stopImmediatePropagation();
- event.preventDefault();
- }
- }
-
- function onContextMenu(event: MouseEvent) {
- if (!isActive() || isInteractiveTarget(event.target)) return;
- event.preventDefault();
- event.stopImmediatePropagation();
- }
-
- const options = { capture: true };
- const touchOptions = { capture: true, passive: false };
- const el = player?.el;
- el?.addEventListener("pointerdown", onPointerDown, options);
- window.addEventListener("pointermove", onPointerMove, options);
- window.addEventListener("pointerup", onPointerEnd, options);
- window.addEventListener("pointercancel", onPointerEnd, options);
- el?.addEventListener("touchend", onTouchEnd, touchOptions);
- window.addEventListener("contextmenu", onContextMenu, options);
- return () => {
- el?.removeEventListener("pointerdown", onPointerDown, options);
- window.removeEventListener("pointermove", onPointerMove, options);
- window.removeEventListener("pointerup", onPointerEnd, options);
- window.removeEventListener("pointercancel", onPointerEnd, options);
- el?.removeEventListener("touchend", onTouchEnd, touchOptions);
- window.removeEventListener("contextmenu", onContextMenu, options);
- endScrub(false);
- restore(false);
- };
- }, [canSeek, player, remote, start, restore, isActive]);
-
- return holding;
-}
diff --git a/apps/web/src/hooks/use-player-keyboard.ts b/apps/web/src/hooks/use-player-keyboard.ts
deleted file mode 100644
index a349fc10..00000000
--- a/apps/web/src/hooks/use-player-keyboard.ts
+++ /dev/null
@@ -1,120 +0,0 @@
-import { useEffect, useRef } from "react";
-import {
- clampTime,
- consumeEvent,
- isInteractiveTarget,
- isPlayerSeekShortcutTarget,
- type KeyboardSeekTarget,
- keyboardSeekOffset,
- nextKeyboardSeekTarget,
-} from "../components/player-hotkeys-utils";
-import { requestSabrSeek } from "../lib/sabr-vidstack-bridge";
-import { useMediaPlayer, useMediaRemote, useMediaState } from "../lib/vidstack";
-import { useHoldFastForward } from "./use-hold-fast-forward";
-
-const FRAME_STEP_SECONDS = 1 / 30;
-
-export function usePlayerKeyboard(canSeek: boolean, sabrVideo: HTMLVideoElement | null = null) {
- const player = useMediaPlayer();
- const remote = useMediaRemote();
- const currentTime = useMediaState("currentTime");
- const duration = useMediaState("duration");
- const paused = useMediaState("paused");
- const { holding, isActive, start, restore } = useHoldFastForward();
- const currentTimeRef = useRef(0);
- const durationRef = useRef(0);
- const pausedRef = useRef(true);
- const spaceStartedRef = useRef(false);
- const seekTargetRef = useRef({
- position: 0,
- updatedAt: Number.NEGATIVE_INFINITY,
- });
-
- currentTimeRef.current = Number.isFinite(currentTime) ? currentTime : 0;
- durationRef.current = Number.isFinite(duration) ? duration : 0;
- pausedRef.current = paused;
-
- useEffect(() => {
- function togglePaused() {
- if (pausedRef.current) void Promise.resolve(remote.play()).catch(() => {});
- else void Promise.resolve(remote.pause()).catch(() => {});
- }
-
- function stepFrame(direction: -1 | 1) {
- if (!canSeek || !pausedRef.current) return;
- remote.seek(
- clampTime(currentTimeRef.current + FRAME_STEP_SECONDS * direction, durationRef.current),
- );
- }
-
- function seekBy(seconds: number) {
- if (!canSeek) return;
- seekTargetRef.current = nextKeyboardSeekTarget(
- currentTimeRef.current,
- durationRef.current,
- seconds,
- seekTargetRef.current,
- performance.now(),
- );
- currentTimeRef.current = seekTargetRef.current.position;
- if (sabrVideo && requestSabrSeek(sabrVideo, seekTargetRef.current.position)) return;
- remote.seek(seekTargetRef.current.position);
- }
-
- function onKeyDown(event: KeyboardEvent) {
- if (event.defaultPrevented || event.altKey || event.ctrlKey || event.metaKey) return;
- const seekOffset = keyboardSeekOffset(event.code);
- if (seekOffset !== null && isPlayerSeekShortcutTarget(event.target, player?.el ?? null)) {
- consumeEvent(event);
- seekBy(seekOffset);
- return;
- }
- if (isInteractiveTarget(event.target)) return;
- if (event.code === "Space") {
- consumeEvent(event);
- if (!event.repeat) {
- spaceStartedRef.current = true;
- start();
- }
- return;
- }
- if (event.key === "," || event.code === "Comma") {
- consumeEvent(event);
- stepFrame(-1);
- return;
- }
- if (event.key === "." || event.code === "Period") {
- consumeEvent(event);
- stepFrame(1);
- }
- }
-
- function onKeyUp(event: KeyboardEvent) {
- if (event.code !== "Space" || !spaceStartedRef.current) return;
- spaceStartedRef.current = false;
- consumeEvent(event);
- const wasHolding = isActive();
- restore();
- if (!wasHolding) togglePaused();
- }
-
- function onBlur() {
- spaceStartedRef.current = false;
- seekTargetRef.current.updatedAt = Number.NEGATIVE_INFINITY;
- restore();
- }
-
- const options = { capture: true };
- window.addEventListener("keydown", onKeyDown, options);
- window.addEventListener("keyup", onKeyUp, options);
- window.addEventListener("blur", onBlur);
- return () => {
- window.removeEventListener("keydown", onKeyDown, options);
- window.removeEventListener("keyup", onKeyUp, options);
- window.removeEventListener("blur", onBlur);
- restore(false);
- };
- }, [canSeek, player, remote, sabrVideo, start, restore, isActive]);
-
- return holding;
-}
diff --git a/apps/web/src/hooks/use-playlist.ts b/apps/web/src/hooks/use-playlist.ts
deleted file mode 100644
index 7271cdfe..00000000
--- a/apps/web/src/hooks/use-playlist.ts
+++ /dev/null
@@ -1,12 +0,0 @@
-import { useQuery } from "@tanstack/react-query";
-import { fetchPlaylist } from "../lib/api-playlists";
-
-const KEY = ["playlists"];
-
-export function usePlaylist(id: string) {
- return useQuery({
- queryKey: [...KEY, id],
- queryFn: () => fetchPlaylist(id),
- enabled: id.length > 0,
- });
-}
diff --git a/apps/web/src/hooks/use-playlists.ts b/apps/web/src/hooks/use-playlists.ts
deleted file mode 100644
index ce1ff22b..00000000
--- a/apps/web/src/hooks/use-playlists.ts
+++ /dev/null
@@ -1,169 +0,0 @@
-import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
-import { ApiError } from "../lib/api";
-import {
- addVideoToPlaylist,
- createPlaylist,
- deletePlaylist,
- fetchPlaylists,
- removeVideoFromPlaylist,
- reorderPlaylist,
- updatePlaylist,
-} from "../lib/api-playlists";
-import type { PlaylistItem, PlaylistVideoItem } from "../types/user";
-import { useAuth } from "./use-auth";
-
-const KEY = ["playlists"];
-
-type RenamePayload = { id: string; name: string; description?: string };
-
-type AddVideoPayload = {
- playlistId: string;
- video: Pick<
- PlaylistVideoItem,
- | "url"
- | "title"
- | "thumbnail"
- | "channelName"
- | "channelUrl"
- | "channelAvatar"
- | "viewCount"
- | "duration"
- >;
-};
-
-type RemoveVideoPayload = {
- playlistId: string;
- videoUrl: string;
-};
-
-function reorderByUrl(videos: PlaylistVideoItem[], order: string[]): PlaylistVideoItem[] {
- const byUrl = new Map(videos.map((video) => [video.url, video]));
- return order
- .map((url) => byUrl.get(url))
- .filter((video): video is PlaylistVideoItem => video !== undefined)
- .map((video, index) => ({ ...video, position: index }));
-}
-
-export function usePlaylists() {
- const qc = useQueryClient();
- const { authReady, isAuthed } = useAuth();
-
- const query = useQuery({
- queryKey: KEY,
- queryFn: fetchPlaylists,
- enabled: authReady && isAuthed,
- });
-
- const create = useMutation({
- mutationFn: (name: string) =>
- isAuthed
- ? createPlaylist(name)
- : Promise.reject(new ApiError("Authentication required", 401)),
- onSuccess: () => qc.invalidateQueries({ queryKey: KEY }),
- });
-
- const remove = useMutation({
- mutationFn: (id: string) => (isAuthed ? deletePlaylist(id) : Promise.resolve()),
- onSuccess: () => qc.invalidateQueries({ queryKey: KEY }),
- });
-
- const rename = useMutation({
- mutationFn: ({ id, name, description }: RenamePayload) =>
- isAuthed ? updatePlaylist(id, { name, description }) : Promise.resolve(),
- onSuccess: () => qc.invalidateQueries({ queryKey: KEY }),
- });
-
- const addVideo = useMutation({
- mutationFn: ({ playlistId, video }: AddVideoPayload) =>
- isAuthed
- ? addVideoToPlaylist(playlistId, video)
- : Promise.reject(new ApiError("Authentication required", 401)),
- onMutate: async ({ playlistId, video }) => {
- await qc.cancelQueries({ queryKey: KEY });
- const snapshot = qc.getQueryData(KEY);
- qc.setQueryData(KEY, (old) =>
- (old ?? []).map((p) => {
- if (p.id !== playlistId) return p;
- const videos = p.videos ?? [];
- return {
- ...p,
- videoCount: (p.videoCount ?? videos.length) + 1,
- videos: p.videos
- ? [
- ...videos,
- {
- id: video.url,
- position: videos.length,
- channelName: "",
- channelUrl: "",
- channelAvatar: "",
- viewCount: 0,
- watchPosition: 0,
- watched: false,
- progressUpdatedAt: 0,
- ...video,
- },
- ]
- : undefined,
- };
- }),
- );
- return { snapshot };
- },
- onError: (_e, _v, ctx) => {
- if (ctx?.snapshot !== undefined) qc.setQueryData(KEY, ctx.snapshot);
- },
- onSuccess: () => qc.invalidateQueries({ queryKey: KEY }),
- });
-
- const removeVideo = useMutation({
- mutationFn: ({ playlistId, videoUrl }: RemoveVideoPayload) =>
- isAuthed ? removeVideoFromPlaylist(playlistId, videoUrl) : Promise.resolve(),
- onMutate: async ({ playlistId, videoUrl }) => {
- await qc.cancelQueries({ queryKey: KEY });
- const snapshot = qc.getQueryData(KEY);
- qc.setQueryData(KEY, (old) =>
- (old ?? []).map((p) => {
- if (p.id !== playlistId) return p;
- const videos = p.videos?.filter((v) => v.url !== videoUrl);
- return {
- ...p,
- videoCount: Math.max(0, (p.videoCount ?? p.videos?.length ?? 1) - 1),
- videos,
- };
- }),
- );
- return { snapshot };
- },
- onError: (_e, _v, ctx) => {
- if (ctx?.snapshot !== undefined) qc.setQueryData(KEY, ctx.snapshot);
- },
- onSuccess: () => qc.invalidateQueries({ queryKey: KEY }),
- });
-
- const reorder = useMutation({
- mutationFn: ({ id, order }: { id: string; order: string[] }) =>
- isAuthed ? reorderPlaylist(id, order) : Promise.resolve(),
- onMutate: async ({ id, order }) => {
- const detailKey = [...KEY, id];
- await qc.cancelQueries({ queryKey: detailKey });
- const snapshot = qc.getQueryData(detailKey);
- qc.setQueryData(detailKey, (old) =>
- old ? { ...old, videos: reorderByUrl(old.videos ?? [], order) } : old,
- );
- return { snapshot, detailKey };
- },
- onError: (_e, _v, ctx) => {
- if (ctx?.snapshot) qc.setQueryData(ctx.detailKey, ctx.snapshot);
- },
- onSuccess: (_d, { id }) => qc.invalidateQueries({ queryKey: [...KEY, id] }),
- });
-
- function isInPlaylist(playlistId: string, videoUrl: string): boolean {
- if (!isAuthed) return false;
- const pl = (query.data ?? []).find((p) => p.id === playlistId);
- return pl?.videos?.some((v) => v.url === videoUrl) ?? false;
- }
-
- return { query, create, remove, rename, addVideo, removeVideo, reorder, isInPlaylist };
-}
diff --git a/apps/web/src/hooks/use-podcast-episodes.ts b/apps/web/src/hooks/use-podcast-episodes.ts
deleted file mode 100644
index 5b026ad7..00000000
--- a/apps/web/src/hooks/use-podcast-episodes.ts
+++ /dev/null
@@ -1,28 +0,0 @@
-import { useInfiniteQuery } from "@tanstack/react-query";
-import { fetchPodcastEpisodes } from "../lib/api";
-import { mapVideoItem } from "../lib/mappers";
-import type { PodcastItem } from "../types/api";
-import type { VideoStream } from "../types/stream";
-
-type PodcastEpisodesPage = {
- podcast: PodcastItem;
- episodes: VideoStream[];
- nextpage: string | null;
-};
-
-export function usePodcastEpisodes(podcastUrl: string) {
- return useInfiniteQuery({
- queryKey: ["podcast-episodes", podcastUrl],
- enabled: podcastUrl.trim().length > 0,
- queryFn: async ({ pageParam }): Promise => {
- const res = await fetchPodcastEpisodes(podcastUrl, pageParam as string | undefined);
- return {
- podcast: res.podcast,
- episodes: res.episodes.map(mapVideoItem),
- nextpage: res.nextpage,
- };
- },
- initialPageParam: undefined as string | undefined,
- getNextPageParam: (lastPage) => lastPage.nextpage ?? undefined,
- });
-}
diff --git a/apps/web/src/hooks/use-podcasts.ts b/apps/web/src/hooks/use-podcasts.ts
deleted file mode 100644
index a9fbff55..00000000
--- a/apps/web/src/hooks/use-podcasts.ts
+++ /dev/null
@@ -1,32 +0,0 @@
-import { useInfiniteQuery } from "@tanstack/react-query";
-import { fetchPodcasts } from "../lib/api";
-import { mapVideoItem } from "../lib/mappers";
-import type { PodcastItem } from "../types/api";
-import type { VideoStream } from "../types/stream";
-
-type PodcastPage = {
- channelName: string;
- channelUrl: string;
- podcasts: PodcastItem[];
- episodes: VideoStream[];
- nextpage: string | null;
-};
-
-export function usePodcasts(channelUrl: string, enabled = true) {
- return useInfiniteQuery({
- queryKey: ["podcasts", channelUrl],
- enabled: enabled && channelUrl.trim().length > 0,
- queryFn: async ({ pageParam }): Promise => {
- const res = await fetchPodcasts(channelUrl, pageParam as string | undefined);
- return {
- channelName: res.channelName,
- channelUrl: res.channelUrl,
- podcasts: res.podcasts,
- episodes: res.episodes.map(mapVideoItem),
- nextpage: res.nextpage,
- };
- },
- initialPageParam: undefined as string | undefined,
- getNextPageParam: (lastPage) => lastPage.nextpage ?? undefined,
- });
-}
diff --git a/apps/web/src/hooks/use-profile.ts b/apps/web/src/hooks/use-profile.ts
deleted file mode 100644
index 35bc7f33..00000000
--- a/apps/web/src/hooks/use-profile.ts
+++ /dev/null
@@ -1,20 +0,0 @@
-import { useMutation } from "@tanstack/react-query";
-import { fetchMe } from "../lib/api-auth";
-import { type ProfilePatch, updateProfile } from "../lib/api-profile";
-import { useAuthStore } from "../stores/auth-store";
-
-async function refreshMe(): Promise {
- const { token, me, setSession } = useAuthStore.getState();
- if (!token || !me) return;
- const updated = await fetchMe(token);
- setSession(token, updated);
-}
-
-export function useProfile() {
- const save = useMutation({
- mutationFn: (patch: ProfilePatch) => updateProfile(patch),
- onSuccess: refreshMe,
- });
-
- return { save };
-}
diff --git a/apps/web/src/hooks/use-progress.ts b/apps/web/src/hooks/use-progress.ts
deleted file mode 100644
index 1030c2be..00000000
--- a/apps/web/src/hooks/use-progress.ts
+++ /dev/null
@@ -1,38 +0,0 @@
-import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
-import { fetchProgress, updateProgress } from "../lib/api-collections";
-import { useAuthStore } from "../stores/auth-store";
-import type { ProgressItem } from "../types/user";
-import { useAuth } from "./use-auth";
-
-export function useProgress(videoUrl: string) {
- const { authReady, isAuthed } = useAuth();
- return useQuery({
- queryKey: ["progress", videoUrl],
- queryFn: () =>
- isAuthed ? fetchProgress(videoUrl) : Promise.resolve({ videoUrl, position: 0, updatedAt: 0 }),
- retry: false,
- staleTime: Infinity,
- enabled: authReady && videoUrl.length > 0,
- });
-}
-
-export function useSaveProgress(videoUrl: string) {
- const { authReady, isAuthed } = useAuth();
- const qc = useQueryClient();
- return useMutation({
- mutationFn: ({ position, keepalive }: { position: number; keepalive: boolean }) => {
- const token = useAuthStore.getState().token;
- return token ? updateProgress(videoUrl, position, keepalive) : Promise.resolve();
- },
- onSuccess: (_, { position }) => {
- if (!authReady || !isAuthed || !useAuthStore.getState().token) return;
- const next: ProgressItem = {
- videoUrl,
- position: Math.round(position),
- updatedAt: Date.now(),
- };
- qc.setQueryData(["progress", videoUrl], next);
- void qc.invalidateQueries({ queryKey: ["history"] });
- },
- });
-}
diff --git a/apps/web/src/hooks/use-public-playlist.ts b/apps/web/src/hooks/use-public-playlist.ts
deleted file mode 100644
index d858dbbe..00000000
--- a/apps/web/src/hooks/use-public-playlist.ts
+++ /dev/null
@@ -1,28 +0,0 @@
-import { useInfiniteQuery } from "@tanstack/react-query";
-import { fetchPublicPlaylist } from "../lib/api-public-playlist";
-import { mapVideoItem } from "../lib/mappers";
-import type { PublicPlaylistInfo } from "../types/playlist";
-import type { VideoStream } from "../types/stream";
-
-type PublicPlaylistPage = {
- playlist: PublicPlaylistInfo;
- streams: VideoStream[];
- nextpage: string | null;
-};
-
-export function usePublicPlaylist(url: string) {
- return useInfiniteQuery({
- queryKey: ["public-playlist", url],
- queryFn: async ({ pageParam }: { pageParam: string | undefined }) => {
- const response = await fetchPublicPlaylist(url, pageParam);
- return {
- playlist: response.playlist,
- streams: response.videos.map(mapVideoItem),
- nextpage: response.nextpage,
- } satisfies PublicPlaylistPage;
- },
- initialPageParam: undefined as string | undefined,
- getNextPageParam: (last: PublicPlaylistPage) => last.nextpage ?? undefined,
- enabled: url.length > 0,
- });
-}
diff --git a/apps/web/src/hooks/use-register-status.ts b/apps/web/src/hooks/use-register-status.ts
deleted file mode 100644
index b9e405ce..00000000
--- a/apps/web/src/hooks/use-register-status.ts
+++ /dev/null
@@ -1,14 +0,0 @@
-import { useQuery } from "@tanstack/react-query";
-import { fetchRegisterStatus } from "../lib/api-auth-status";
-
-export const REGISTER_STATUS_KEY = ["register-status"];
-
-export function useRegisterStatus(enabled = true) {
- return useQuery({
- queryKey: REGISTER_STATUS_KEY,
- queryFn: fetchRegisterStatus,
- retry: false,
- staleTime: 30_000,
- enabled,
- });
-}
diff --git a/apps/web/src/hooks/use-sabr-mode-switch.ts b/apps/web/src/hooks/use-sabr-mode-switch.ts
deleted file mode 100644
index 16febc8d..00000000
--- a/apps/web/src/hooks/use-sabr-mode-switch.ts
+++ /dev/null
@@ -1,47 +0,0 @@
-import type { TypeTypeMsePlayer } from "@typetype/mse";
-import { type RefObject, useEffect, useRef } from "react";
-import { recordClientEvent } from "../lib/client-debug-log";
-import { isAbortError } from "../lib/sabr-playback-retry";
-
-type ModeSwitchHandlers = {
- onError: (error: unknown) => void;
- onSeekStateChange: (seeking: boolean) => void;
-};
-
-export function useSabrModeSwitch(
- audioOnly: boolean,
- engineRef: RefObject,
- seekingRef: RefObject,
- handlers: () => ModeSwitchHandlers,
-): void {
- const appliedRef = useRef(audioOnly);
- const revisionRef = useRef(0);
- useEffect(() => {
- const engine = engineRef.current;
- if (!engine) {
- appliedRef.current = audioOnly;
- return;
- }
- if (appliedRef.current === audioOnly) return;
- const revision = ++revisionRef.current;
- seekingRef.current = true;
- handlers().onSeekStateChange(true);
- void engine
- .setAudioOnly(audioOnly)
- .then(() => {
- if (revision === revisionRef.current) appliedRef.current = audioOnly;
- })
- .catch((error: unknown) => {
- if (revision !== revisionRef.current || isAbortError(error)) return;
- recordClientEvent("player.sabr_mode_switch_failed", {
- error: error instanceof Error ? error.message : String(error),
- });
- handlers().onError(error);
- })
- .finally(() => {
- if (revision !== revisionRef.current) return;
- seekingRef.current = false;
- handlers().onSeekStateChange(false);
- });
- }, [audioOnly, engineRef, handlers, seekingRef]);
-}
diff --git a/apps/web/src/hooks/use-sabr-playback-config.ts b/apps/web/src/hooks/use-sabr-playback-config.ts
deleted file mode 100644
index bd3a0fed..00000000
--- a/apps/web/src/hooks/use-sabr-playback-config.ts
+++ /dev/null
@@ -1,75 +0,0 @@
-import { useEffect, useMemo } from "react";
-import { defaultSabrAudioTrackId, sabrAudioOptions } from "../lib/sabr-audio";
-import {
- automaticSabrQuality,
- defaultSabrItag,
- resolveSabrPlaybackConfig,
- type SabrPlaybackConfig,
- sabrQualityOptions,
-} from "../lib/sabr-source";
-import { useAuthStore } from "../stores/auth-store";
-import { useSabrAudioStore } from "../stores/sabr-audio-store";
-import { useSabrQualityStore } from "../stores/sabr-quality-store";
-import type { VideoStream } from "../types/stream";
-
-export function useSabrPlaybackConfig(
- stream: VideoStream,
- enabled: boolean,
- defaultQuality?: string,
- defaultAudioLanguage?: string,
- audioOnly = false,
-): SabrPlaybackConfig | null {
- const authScope = useAuthStore((state) => (state.token ? (state.me?.id ?? "auth") : "guest"));
- const selectedItag = useSabrQualityStore((state) =>
- state.streamId === stream.id && state.manuallySelected ? state.selectedItag : null,
- );
- const setOptions = useSabrQualityStore((state) => state.setOptions);
- const selectedTrackId = useSabrAudioStore((state) =>
- state.streamId === stream.id ? state.selectedTrackId : null,
- );
- const setAudioOptions = useSabrAudioStore((state) => state.setOptions);
- const options = useMemo(() => sabrQualityOptions(stream), [stream]);
- const audioOptions = useMemo(() => sabrAudioOptions(stream), [stream]);
- const fallbackTrackId = useMemo(
- () => defaultSabrAudioTrackId(stream, defaultAudioLanguage),
- [defaultAudioLanguage, stream],
- );
- const effectiveTrackId = selectedTrackId ?? fallbackTrackId;
- const preferredQuality = resolvePreferredQuality(defaultQuality);
- const defaultItag = useMemo(
- () => defaultSabrItag(options, preferredQuality),
- [options, preferredQuality],
- );
- const effectiveItag = selectedItag ?? defaultItag;
- useEffect(() => {
- if (!enabled || defaultItag === null) return;
- setOptions(stream.id, options, defaultItag);
- }, [defaultItag, enabled, options, setOptions, stream.id]);
- useEffect(() => {
- if (!enabled) return;
- setAudioOptions(stream.id, audioOptions, fallbackTrackId);
- }, [audioOptions, enabled, fallbackTrackId, setAudioOptions, stream.id]);
- const config = useMemo(() => {
- const config = resolveSabrPlaybackConfig(stream, effectiveItag, effectiveTrackId, audioOnly);
- return config ? { ...config, key: `${config.key}:${authScope}` } : null;
- }, [audioOnly, authScope, effectiveItag, effectiveTrackId, stream]);
- return enabled ? config : null;
-}
-
-type NetworkInformation = {
- effectiveType?: string;
- saveData?: boolean;
-};
-
-function resolvePreferredQuality(defaultQuality: string | undefined): string | undefined {
- if (defaultQuality?.toLowerCase() !== "auto" || typeof window === "undefined") {
- return defaultQuality;
- }
- const connection = (navigator as Navigator & { connection?: NetworkInformation }).connection;
- return automaticSabrQuality(
- window.screen.height,
- window.devicePixelRatio,
- connection?.saveData,
- connection?.effectiveType,
- );
-}
diff --git a/apps/web/src/hooks/use-sabr-player-state.ts b/apps/web/src/hooks/use-sabr-player-state.ts
deleted file mode 100644
index 56c91a79..00000000
--- a/apps/web/src/hooks/use-sabr-player-state.ts
+++ /dev/null
@@ -1,25 +0,0 @@
-import { useState } from "react";
-import type { MediaProviderAdapter } from "../lib/vidstack";
-import { isAudioProvider, isVideoProvider } from "../lib/vidstack";
-
-export function useSabrPlayerState(
- enabled: boolean,
- onProviderChange: (provider: MediaProviderAdapter | null) => void,
-) {
- const [provider, setProvider] = useState(null);
- const [seeking, setSeeking] = useState(false);
-
- const handleProviderChange = (nextProvider: MediaProviderAdapter | null) => {
- onProviderChange(nextProvider);
- setProvider(nextProvider);
- };
-
- const video = enabled && isVideoProvider(provider) ? provider.video : null;
- const media = isVideoProvider(provider)
- ? provider.video
- : isAudioProvider(provider)
- ? provider.audio
- : null;
-
- return { video, media, seeking, setSeeking, handleProviderChange };
-}
diff --git a/apps/web/src/hooks/use-sabr-quality-switch.ts b/apps/web/src/hooks/use-sabr-quality-switch.ts
deleted file mode 100644
index a6444089..00000000
--- a/apps/web/src/hooks/use-sabr-quality-switch.ts
+++ /dev/null
@@ -1,48 +0,0 @@
-import type { TypeTypeMsePlayer, TypeTypeMseQuality } from "@typetype/mse";
-import { type RefObject, useEffect, useRef } from "react";
-import { isAbortError } from "../lib/sabr-playback-retry";
-import type { SabrPlaybackConfig } from "../lib/sabr-source";
-import { useSabrQualityStore } from "../stores/sabr-quality-store";
-
-export function useSabrQualitySwitch(
- config: SabrPlaybackConfig,
- engineReady: boolean,
- engineRef: RefObject,
- qualityRef: RefObject,
- seekingRef: RefObject,
-): void {
- const revisionRef = useRef(0);
- useEffect(() => {
- const quality = {
- videoItag: config.videoItag,
- audioItag: config.audioItag,
- audioTrackId: config.audioTrackId,
- };
- const previous = qualityRef.current;
- const engine = engineRef.current;
- if (
- !engineReady ||
- !engine ||
- !previous ||
- (previous.videoItag === quality.videoItag &&
- previous.audioItag === quality.audioItag &&
- previous.audioTrackId === quality.audioTrackId)
- )
- return;
- const revision = ++revisionRef.current;
- seekingRef.current = true;
- void engine
- .setQuality(quality)
- .then(() => {
- if (revision === revisionRef.current) qualityRef.current = quality;
- })
- .catch((error: unknown) => {
- if (revision !== revisionRef.current || isAbortError(error)) return;
- const store = useSabrQualityStore.getState();
- if (store.streamId) store.restoreQuality(store.streamId, previous.videoItag);
- })
- .finally(() => {
- if (revision === revisionRef.current) seekingRef.current = false;
- });
- }, [config, engineReady, engineRef, qualityRef, seekingRef]);
-}
diff --git a/apps/web/src/hooks/use-saved-playlists.ts b/apps/web/src/hooks/use-saved-playlists.ts
deleted file mode 100644
index 5f2d5c7e..00000000
--- a/apps/web/src/hooks/use-saved-playlists.ts
+++ /dev/null
@@ -1,45 +0,0 @@
-import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
-import {
- deleteSavedPlaylist,
- fetchSavedPlaylists,
- savePublicPlaylist,
-} from "../lib/api-saved-playlists";
-import { playlistListId } from "../lib/playlist-url";
-import type { SavedPlaylistItem } from "../types/playlist";
-import { useAuth } from "./use-auth";
-
-const KEY = ["saved-playlists"];
-
-function samePlaylist(item: SavedPlaylistItem, url: string): boolean {
- const listId = playlistListId(url) ?? url;
- return (
- item.url === url || item.publicPlaylistId === listId || playlistListId(item.url) === listId
- );
-}
-
-export function useSavedPlaylists() {
- const qc = useQueryClient();
- const { authReady, isAuthed } = useAuth();
- const query = useQuery({
- queryKey: KEY,
- queryFn: fetchSavedPlaylists,
- enabled: authReady && isAuthed,
- });
- const save = useMutation({
- mutationFn: savePublicPlaylist,
- onSuccess: () => qc.invalidateQueries({ queryKey: KEY }),
- });
- const remove = useMutation({
- mutationFn: deleteSavedPlaylist,
- onSuccess: () => qc.invalidateQueries({ queryKey: KEY }),
- });
- const items = query.data ?? [];
-
- return {
- query,
- items,
- save,
- remove,
- findSaved: (url: string) => items.find((item) => samePlaylist(item, url)),
- };
-}
diff --git a/apps/web/src/hooks/use-search-filters.ts b/apps/web/src/hooks/use-search-filters.ts
deleted file mode 100644
index 5d1acc49..00000000
--- a/apps/web/src/hooks/use-search-filters.ts
+++ /dev/null
@@ -1,10 +0,0 @@
-import { useQuery } from "@tanstack/react-query";
-import { fetchSearchFilters } from "../lib/api-discovery";
-
-export function useSearchFilters(service: number) {
- return useQuery({
- queryKey: ["search-filters", service],
- queryFn: () => fetchSearchFilters(service),
- staleTime: 60 * 60 * 1000,
- });
-}
diff --git a/apps/web/src/hooks/use-search-history.ts b/apps/web/src/hooks/use-search-history.ts
deleted file mode 100644
index c77474b9..00000000
--- a/apps/web/src/hooks/use-search-history.ts
+++ /dev/null
@@ -1,81 +0,0 @@
-import type { InfiniteData } from "@tanstack/react-query";
-import { useInfiniteQuery, useMutation, useQueryClient } from "@tanstack/react-query";
-import { addSearchHistory, clearSearchHistory, fetchSearchHistoryPage } from "../lib/api-user";
-import type { SearchHistoryItem } from "../types/user";
-import { useAuth } from "./use-auth";
-
-const KEY = ["search-history"];
-const PAGE_SIZE = 8;
-
-type SearchHistoryPage = {
- items: SearchHistoryItem[];
- total: number;
- page: number;
- limit: number;
-};
-
-type SearchHistoryData = InfiniteData;
-
-function emptySearchHistoryData(
- data: SearchHistoryData | undefined,
-): SearchHistoryData | undefined {
- if (!data) return data;
- return {
- ...data,
- pages: data.pages.map((page) => ({ ...page, items: [], total: 0 })),
- };
-}
-
-export function useSearchHistory() {
- const qc = useQueryClient();
- const { authReady, isAuthed } = useAuth();
- const query = useInfiniteQuery({
- queryKey: KEY,
- queryFn: ({ pageParam = 1 }) => fetchSearchHistoryPage(pageParam, PAGE_SIZE),
- getNextPageParam: (lastPage) => {
- const loaded = lastPage.page * lastPage.limit;
- return loaded < lastPage.total ? lastPage.page + 1 : undefined;
- },
- initialPageParam: 1,
- enabled: authReady && isAuthed,
- gcTime: 0,
- });
-
- const visibleItems = query.data?.pages.flatMap((page) => page.items) ?? [];
- const total = query.data?.pages[0]?.total ?? 0;
- const canLoadMore = visibleItems.length < total;
-
- const add = useMutation({
- mutationFn: async (term: string) => {
- if (!isAuthed) return;
- await addSearchHistory(term);
- },
- onSuccess: () => {
- qc.invalidateQueries({ queryKey: KEY });
- },
- });
-
- const clear = useMutation({
- mutationFn: () => (isAuthed ? clearSearchHistory() : Promise.resolve()),
- onSuccess: () => {
- qc.setQueryData(KEY, emptySearchHistoryData);
- qc.invalidateQueries({ queryKey: KEY });
- },
- });
-
- function loadMore() {
- if (query.hasNextPage && !query.isFetchingNextPage) {
- query.fetchNextPage();
- }
- }
-
- return {
- query,
- total,
- visibleItems,
- canLoadMore,
- loadMore,
- add,
- clear,
- };
-}
diff --git a/apps/web/src/hooks/use-search-overlay-navigation.ts b/apps/web/src/hooks/use-search-overlay-navigation.ts
deleted file mode 100644
index 9e65d195..00000000
--- a/apps/web/src/hooks/use-search-overlay-navigation.ts
+++ /dev/null
@@ -1,33 +0,0 @@
-import { useNavigate } from "@tanstack/react-router";
-import { toDirectWatchUrl } from "../lib/direct-watch-url";
-import { markWatchAutoplayIntent } from "../lib/watch-autoplay-intent";
-import { useSearchHistory } from "./use-search-history";
-import { useSettings } from "./use-settings";
-
-type Params = {
- onClose: () => void;
-};
-
-export function useSearchOverlayNavigation({ onClose }: Params) {
- const navigate = useNavigate();
- const { settings } = useSettings();
- const service = settings.defaultService;
- const { add } = useSearchHistory();
-
- function navigateAndClose(term: string) {
- const trimmed = term.trim();
- if (!trimmed) return;
- const directWatchUrl = toDirectWatchUrl(trimmed);
- if (directWatchUrl) {
- markWatchAutoplayIntent();
- navigate({ to: "/watch", search: { v: directWatchUrl } });
- onClose();
- return;
- }
- add.mutate(trimmed);
- navigate({ to: "/search", search: { q: trimmed, service } });
- onClose();
- }
-
- return { service, navigateAndClose };
-}
diff --git a/apps/web/src/hooks/use-search-shortcut.ts b/apps/web/src/hooks/use-search-shortcut.ts
deleted file mode 100644
index 14cd994c..00000000
--- a/apps/web/src/hooks/use-search-shortcut.ts
+++ /dev/null
@@ -1,25 +0,0 @@
-import { useEffect } from "react";
-
-type Params = {
- enabled: boolean;
- onOpen: () => void;
-};
-
-export function useSearchShortcut({ enabled, onOpen }: Params) {
- useEffect(() => {
- const handleKey = (e: KeyboardEvent) => {
- if (!enabled) return;
- if (
- e.key === "/" &&
- !(e.target instanceof HTMLInputElement) &&
- !(e.target instanceof HTMLTextAreaElement)
- ) {
- e.preventDefault();
- onOpen();
- }
- };
-
- window.addEventListener("keydown", handleKey);
- return () => window.removeEventListener("keydown", handleKey);
- }, [enabled, onOpen]);
-}
diff --git a/apps/web/src/hooks/use-search.ts b/apps/web/src/hooks/use-search.ts
deleted file mode 100644
index a8c3d99d..00000000
--- a/apps/web/src/hooks/use-search.ts
+++ /dev/null
@@ -1,39 +0,0 @@
-import { useInfiniteQuery } from "@tanstack/react-query";
-import { fetchSearch } from "../lib/api-discovery";
-import { mapVideoItem } from "../lib/mappers";
-import type { ChannelResultItem } from "../types/api";
-import type { PublicPlaylistInfo } from "../types/playlist";
-import type { VideoStream } from "../types/stream";
-
-type SearchPage = {
- streams: VideoStream[];
- channels: ChannelResultItem[];
- playlists: PublicPlaylistInfo[];
- nextpage: string | null;
- searchSuggestion: string | null;
- isCorrectedSearch: boolean;
-};
-
-export function useSearch(q: string, service: number, contentFilter?: string, sortFilter?: string) {
- return useInfiniteQuery({
- queryKey: ["search", q, service, contentFilter ?? "", sortFilter ?? ""],
- queryFn: async ({ pageParam }: { pageParam: string | undefined }) => {
- const response = await fetchSearch(q, service, pageParam, contentFilter, sortFilter);
- return {
- streams: response.items.map(mapVideoItem),
- channels: response.channels ?? [],
- playlists: response.playlists ?? [],
- nextpage: response.nextpage,
- searchSuggestion: response.searchSuggestion,
- isCorrectedSearch: response.isCorrectedSearch,
- } satisfies SearchPage;
- },
- initialPageParam: undefined as string | undefined,
- getNextPageParam: (last: SearchPage) => {
- const isEmpty =
- last.streams.length === 0 && last.channels.length === 0 && last.playlists.length === 0;
- return isEmpty ? undefined : (last.nextpage ?? undefined);
- },
- enabled: q.length > 0,
- });
-}
diff --git a/apps/web/src/hooks/use-session-activity-reporting.ts b/apps/web/src/hooks/use-session-activity-reporting.ts
deleted file mode 100644
index 8ccb1bd8..00000000
--- a/apps/web/src/hooks/use-session-activity-reporting.ts
+++ /dev/null
@@ -1,30 +0,0 @@
-import { useEffect } from "react";
-import { reportSessionActivity } from "../lib/api-admin-sessions";
-import { getSessionDevicePayload } from "../lib/session-device";
-import { useAuth } from "./use-auth";
-
-const ACTIVITY_INTERVAL_MS = 60_000;
-
-export function useSessionActivityReporting() {
- const { status } = useAuth();
- const enabled = status === "authenticated";
-
- useEffect(() => {
- if (!enabled) return;
-
- const report = () => {
- void reportSessionActivity(getSessionDevicePayload()).catch(() => {});
- };
- const onVisibilityChange = () => {
- if (document.visibilityState === "visible") report();
- };
-
- report();
- const interval = setInterval(report, ACTIVITY_INTERVAL_MS);
- document.addEventListener("visibilitychange", onVisibilityChange);
- return () => {
- clearInterval(interval);
- document.removeEventListener("visibilitychange", onVisibilityChange);
- };
- }, [enabled]);
-}
diff --git a/apps/web/src/hooks/use-settings.ts b/apps/web/src/hooks/use-settings.ts
deleted file mode 100644
index bd5457bb..00000000
--- a/apps/web/src/hooks/use-settings.ts
+++ /dev/null
@@ -1,108 +0,0 @@
-import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
-import { fetchSettings, updateSettings } from "../lib/api-user";
-import { EMPTY_CAPTION_STYLES } from "../lib/caption-styles";
-import { DEFAULT_SPONSORBLOCK_CATEGORY_ACTIONS } from "../lib/sponsorblock-settings";
-import type { SettingsItem } from "../types/user";
-import { useAuth } from "./use-auth";
-
-const KEY = ["settings"];
-const AUDIO_ONLY_STORAGE_KEY = "typetype-audio-only-playback";
-
-const DEFAULTS: SettingsItem = {
- defaultService: 0,
- defaultLandingPage: "home",
- defaultQuality: "1080p",
- autoplay: true,
- autoplayCountdownSeconds: 10,
- skipPlaylistAutoplayScreen: false,
- audioOnlyPlayback: false,
- volume: 1,
- muted: false,
- subtitlesEnabled: false,
- defaultSubtitleLanguage: "",
- defaultAudioLanguage: "",
- preferOriginalLanguage: true,
- enableHighQualityPlayback: true,
- sponsorBlockMode: "auto_skip",
- sponsorBlockCategoryActions: DEFAULT_SPONSORBLOCK_CATEGORY_ACTIONS,
- sponsorBlockMinimumDuration: 0,
- sponsorBlockShowCurrentSegment: true,
- sponsorBlockShowChapters: false,
- sponsorBlockShowFullVideoLabels: true,
- sponsorBlockManualSkipOnFullVideo: true,
- sponsorBlockSkipNonMusicOnlyOnMusicVideos: false,
- sponsorBlockMuteInsteadOfSkip: false,
- disableWatchHistory: false,
- deArrowEnabled: false,
- deArrowTitleMode: "dearrow",
- deArrowThumbnailMode: "dearrow_or_random",
- deArrowTrustMode: "accepted",
- hideContinueWatching: false,
- hideHomeRecommendations: false,
- hideRelatedVideos: false,
- hideComments: false,
- hideShorts: false,
- accessMode: "unrestricted",
- captionStyles: EMPTY_CAPTION_STYLES,
-};
-
-function readAudioOnlyPlayback(): boolean | null {
- const stored = localStorage.getItem(AUDIO_ONLY_STORAGE_KEY);
- if (stored === "true") return true;
- if (stored === "false") return false;
- return null;
-}
-
-function writeAudioOnlyPlayback(value: boolean): void {
- localStorage.setItem(AUDIO_ONLY_STORAGE_KEY, String(value));
-}
-
-function withLocalAudioOnly(settings: SettingsItem): SettingsItem {
- const audioOnlyPlayback = readAudioOnlyPlayback();
- return audioOnlyPlayback === null ? settings : { ...settings, audioOnlyPlayback };
-}
-
-export function useSettings() {
- const qc = useQueryClient();
- const { authReady, isAuthed } = useAuth();
-
- const query = useQuery({
- queryKey: KEY,
- queryFn: () => fetchSettings(),
- enabled: authReady && isAuthed,
- placeholderData: DEFAULTS,
- staleTime: 5 * 60 * 1000,
- });
- const settingsReady =
- (authReady && !isAuthed) || (query.isSuccess && !query.isPlaceholderData) || query.isError;
-
- const update = useMutation({
- mutationFn: (patch: Partial) => {
- const stored = qc.getQueryData(KEY);
- const current = stored ? { ...DEFAULTS, ...stored } : DEFAULTS;
- const next = { ...current, ...patch };
- if (!isAuthed) return Promise.resolve(next);
- return updateSettings(next);
- },
- onMutate: async (patch) => {
- await qc.cancelQueries({ queryKey: KEY });
- if (typeof patch.audioOnlyPlayback === "boolean")
- writeAudioOnlyPlayback(patch.audioOnlyPlayback);
- const previous = qc.getQueryData(KEY);
- qc.setQueryData(KEY, { ...DEFAULTS, ...previous, ...patch });
- return { previous, patch };
- },
- onSuccess: (data, _patch, context) => {
- const current = qc.getQueryData(KEY);
- qc.setQueryData(KEY, { ...DEFAULTS, ...current, ...data, ...context?.patch });
- },
- onError: (err, _patch, context) => {
- if (context?.previous) qc.setQueryData(KEY, context.previous);
- console.error("[settings] PUT failed", err);
- },
- });
-
- const settings = withLocalAudioOnly(query.data ? { ...DEFAULTS, ...query.data } : DEFAULTS);
-
- return { query, update, settings, settingsReady };
-}
diff --git a/apps/web/src/hooks/use-share-url.ts b/apps/web/src/hooks/use-share-url.ts
deleted file mode 100644
index c164ff8a..00000000
--- a/apps/web/src/hooks/use-share-url.ts
+++ /dev/null
@@ -1,26 +0,0 @@
-import { useState } from "react";
-
-export function useShareUrl() {
- const [copied, setCopied] = useState(false);
-
- async function share(url: string) {
- try {
- if (navigator.clipboard) {
- await navigator.clipboard.writeText(url);
- } else {
- const el = document.createElement("textarea");
- el.value = url;
- el.style.cssText = "position:fixed;opacity:0;pointer-events:none";
- document.body.appendChild(el);
- el.focus();
- el.select();
- document.execCommand("copy");
- document.body.removeChild(el);
- }
- setCopied(true);
- setTimeout(() => setCopied(false), 2000);
- } catch {}
- }
-
- return { copied, share };
-}
diff --git a/apps/web/src/hooks/use-shorts-active-stream.ts b/apps/web/src/hooks/use-shorts-active-stream.ts
deleted file mode 100644
index a14820df..00000000
--- a/apps/web/src/hooks/use-shorts-active-stream.ts
+++ /dev/null
@@ -1,31 +0,0 @@
-import { ApiError } from "../lib/api";
-import type { VideoStream } from "../types/stream";
-import { isMemberOnlyApiError, useStream } from "./use-stream";
-
-type Params = {
- shorts: VideoStream[];
- index: number;
-};
-
-export function useShortsActiveStream({ shorts, index }: Params) {
- const active = shorts[index];
- const activeId = active?.id ?? "";
- const streamQuery = useStream(active?.id ?? "");
- const stream = streamQuery.data;
- const current = stream ?? active;
- const errorMessage =
- streamQuery.isError && streamQuery.error instanceof ApiError
- ? streamQuery.error.message
- : "Couldn't load this short.";
- const isMemberOnlyShort = isMemberOnlyApiError(streamQuery.error);
-
- return {
- active,
- activeId,
- stream,
- streamQuery,
- current,
- errorMessage,
- isMemberOnlyShort,
- };
-}
diff --git a/apps/web/src/hooks/use-shorts-feed.ts b/apps/web/src/hooks/use-shorts-feed.ts
deleted file mode 100644
index 47415c75..00000000
--- a/apps/web/src/hooks/use-shorts-feed.ts
+++ /dev/null
@@ -1,131 +0,0 @@
-import { useInfiniteQuery } from "@tanstack/react-query";
-import { useMemo } from "react";
-import { fetchSearch } from "../lib/api-discovery";
-import { fetchShortsRecommendations, type RecommendationIntent } from "../lib/api-recommendations";
-import { fetchSubscriptionShorts } from "../lib/api-user";
-import type { VideoStream } from "../types/stream";
-import {
- blendRecommendationsWithSubscriptions,
- dedupeShorts,
- fromDiscovery,
- fromRecommendations,
- fromSubscriptions,
- interleaveByChannel,
- parseNextPage,
-} from "./shorts-feed-utils";
-import { useAuth } from "./use-auth";
-import { useBlockedFilter } from "./use-blocked-filter";
-import { useSettings } from "./use-settings";
-
-type ShortsFeed = {
- shorts: VideoStream[];
- isLoading: boolean;
- isFetchingNextPage: boolean;
- hasNextPage: boolean;
- fetchNextPage: () => void;
-};
-const SHORTS_QUERY = "shorts";
-
-export function useShortsFeed(): ShortsFeed {
- const { authReady, isAuthed } = useAuth();
- const { settings } = useSettings();
- const { filter } = useBlockedFilter();
- const intent: RecommendationIntent = "auto";
-
- const recommendations = useInfiniteQuery({
- queryKey: ["shorts-recommendations", settings.defaultService, intent],
- queryFn: ({ pageParam }) =>
- fetchShortsRecommendations(
- settings.defaultService,
- 30,
- pageParam as string | undefined,
- intent,
- ),
- initialPageParam: undefined as string | undefined,
- getNextPageParam: (last) => (last.hasMore ? (last.nextCursor ?? undefined) : undefined),
- enabled: authReady && isAuthed && !settings.hideShorts,
- staleTime: 5 * 60 * 1000,
- });
-
- const recommendationShorts = useMemo(
- () => fromRecommendations(recommendations.data?.pages),
- [recommendations.data],
- );
- const hasRecommendationShorts = recommendationShorts.length > 0;
-
- const fallbackSubscriptions = useInfiniteQuery({
- queryKey: ["shorts-subscriptions-fallback", settings.defaultService],
- queryFn: ({ pageParam }) =>
- fetchSubscriptionShorts(pageParam as number, 30, settings.defaultService, true),
- initialPageParam: 0,
- getNextPageParam: (last) => parseNextPage(last.nextpage),
- enabled:
- authReady &&
- isAuthed &&
- !settings.hideShorts &&
- recommendations.isSuccess &&
- !hasRecommendationShorts,
- staleTime: 5 * 60 * 1000,
- });
-
- const discovery = useInfiniteQuery({
- queryKey: ["shorts-discovery", settings.defaultService],
- queryFn: ({ pageParam }) =>
- fetchSearch(SHORTS_QUERY, settings.defaultService, pageParam as string | undefined),
- initialPageParam: undefined as string | undefined,
- getNextPageParam: (last) => last.nextpage ?? undefined,
- enabled: authReady && !isAuthed && !settings.hideShorts,
- staleTime: 90 * 1000,
- });
-
- const shorts = useMemo(() => {
- const fallbackShorts = fromSubscriptions(fallbackSubscriptions.data?.pages);
- const merged = isAuthed
- ? hasRecommendationShorts
- ? blendRecommendationsWithSubscriptions(recommendationShorts, fallbackShorts)
- : fallbackShorts
- : fromDiscovery(discovery.data?.pages);
- return filter(interleaveByChannel(dedupeShorts(merged)));
- }, [
- isAuthed,
- hasRecommendationShorts,
- recommendationShorts,
- fallbackSubscriptions.data,
- discovery.data,
- filter,
- ]);
-
- const useRecommendations = authReady && isAuthed && hasRecommendationShorts;
- return {
- shorts,
- isLoading: settings.hideShorts
- ? false
- : shorts.length > 0
- ? false
- : isAuthed
- ? recommendations.isLoading
- : discovery.isLoading,
- isFetchingNextPage: isAuthed
- ? useRecommendations
- ? recommendations.isFetchingNextPage
- : fallbackSubscriptions.isFetchingNextPage
- : discovery.isFetchingNextPage,
- hasNextPage: isAuthed
- ? useRecommendations
- ? recommendations.hasNextPage
- : fallbackSubscriptions.hasNextPage
- : discovery.hasNextPage,
- fetchNextPage: () => {
- if (isAuthed) {
- if (useRecommendations && recommendations.hasNextPage) {
- void recommendations.fetchNextPage();
- return;
- }
- if (!useRecommendations && fallbackSubscriptions.hasNextPage)
- void fallbackSubscriptions.fetchNextPage();
- return;
- }
- if (discovery.hasNextPage) void discovery.fetchNextPage();
- },
- };
-}
diff --git a/apps/web/src/hooks/use-shorts-prefetch.ts b/apps/web/src/hooks/use-shorts-prefetch.ts
deleted file mode 100644
index 113e9ff5..00000000
--- a/apps/web/src/hooks/use-shorts-prefetch.ts
+++ /dev/null
@@ -1,23 +0,0 @@
-import { useQueryClient } from "@tanstack/react-query";
-import { useEffect } from "react";
-import { streamQueryOptions } from "./use-stream";
-
-export function useShortsPrefetch(shortIds: string[], index: number) {
- const queryClient = useQueryClient();
-
- useEffect(() => {
- if (typeof window !== "undefined") {
- const slowNetwork =
- "connection" in navigator &&
- typeof navigator.connection === "object" &&
- navigator.connection !== null &&
- "saveData" in navigator.connection &&
- navigator.connection.saveData === true;
- if (slowNetwork) return;
- }
- const nextIds = shortIds.slice(index + 1, index + 3);
- for (const id of nextIds) {
- void queryClient.prefetchQuery(streamQueryOptions(id)).catch(() => undefined);
- }
- }, [index, queryClient, shortIds]);
-}
diff --git a/apps/web/src/hooks/use-shorts-route-sync.ts b/apps/web/src/hooks/use-shorts-route-sync.ts
deleted file mode 100644
index aaed69a5..00000000
--- a/apps/web/src/hooks/use-shorts-route-sync.ts
+++ /dev/null
@@ -1,57 +0,0 @@
-import { useNavigate } from "@tanstack/react-router";
-import { useEffect, useRef } from "react";
-import type { VideoStream } from "../types/stream";
-
-type Params = {
- targetUrl: string | undefined;
- shorts: VideoStream[];
- index: number;
- hasNextPage: boolean;
- isFetchingNextPage: boolean;
- fetchNextPage: () => void;
- moveTo: (target: number) => void;
- activeId: string;
- onActiveChange: () => void;
-};
-
-export function useShortsRouteSync({
- targetUrl,
- shorts,
- index,
- hasNextPage,
- isFetchingNextPage,
- fetchNextPage,
- moveTo,
- activeId,
- onActiveChange,
-}: Params) {
- const navigate = useNavigate({ from: "/shorts" });
- const syncedTargetRef = useRef(null);
- const onActiveChangeRef = useRef(onActiveChange);
- onActiveChangeRef.current = onActiveChange;
-
- useEffect(() => {
- if (!targetUrl) return;
- if (syncedTargetRef.current === targetUrl) return;
- syncedTargetRef.current = targetUrl;
- const targetIndex = shorts.findIndex((short) => short.id === targetUrl);
- if (targetIndex >= 0 && targetIndex !== index) moveTo(targetIndex);
- if (targetIndex < 0 && hasNextPage && !isFetchingNextPage) fetchNextPage();
- }, [targetUrl, shorts, index, moveTo, hasNextPage, isFetchingNextPage, fetchNextPage]);
-
- useEffect(() => {
- if (targetUrl) return;
- syncedTargetRef.current = null;
- }, [targetUrl]);
-
- useEffect(() => {
- const active = shorts[index];
- if (!active) return;
- void navigate({ search: { v: active.id }, replace: true });
- }, [shorts, index, navigate]);
-
- useEffect(() => {
- if (!activeId) return;
- onActiveChangeRef.current();
- }, [activeId]);
-}
diff --git a/apps/web/src/hooks/use-smooth-dismiss.ts b/apps/web/src/hooks/use-smooth-dismiss.ts
deleted file mode 100644
index d8bab16c..00000000
--- a/apps/web/src/hooks/use-smooth-dismiss.ts
+++ /dev/null
@@ -1,31 +0,0 @@
-import { useEffect, useRef, useState } from "react";
-
-const CLOSE_MS = 340;
-
-type Params = {
- onClose: () => void;
-};
-
-export function useSmoothDismiss({ onClose }: Params) {
- const [isClosing, setIsClosing] = useState(false);
- const timeoutRef = useRef(null);
-
- useEffect(
- () => () => {
- if (timeoutRef.current !== null) {
- window.clearTimeout(timeoutRef.current);
- }
- },
- [],
- );
-
- function dismiss() {
- if (isClosing) return;
- setIsClosing(true);
- timeoutRef.current = window.setTimeout(() => {
- onClose();
- }, CLOSE_MS);
- }
-
- return { isClosing, dismiss };
-}
diff --git a/apps/web/src/hooks/use-stream.ts b/apps/web/src/hooks/use-stream.ts
deleted file mode 100644
index de256593..00000000
--- a/apps/web/src/hooks/use-stream.ts
+++ /dev/null
@@ -1,103 +0,0 @@
-import { keepPreviousData, queryOptions, useQuery } from "@tanstack/react-query";
-import { ApiError } from "../lib/api";
-import { fetchSabrBootstrap, fetchStream } from "../lib/api-stream";
-import { mapStreamResponse } from "../lib/mappers";
-import {
- isMemberOnlyApiError as isMemberOnlyApiResponse,
- MEMBER_ONLY_MESSAGE,
-} from "../lib/member-only";
-import { type PlaybackMode, readPlaybackMode } from "../lib/playback-mode";
-import {
- sabrBootstrapEndpoint,
- sabrBootstrapQueryKey,
- streamQueryKey,
-} from "../lib/stream-request";
-
-export { MEMBER_ONLY_MESSAGE };
-
-export function streamQueryOptions(
- url: string,
- useAuthenticatedStream = false,
- enabled = true,
- playbackMode: PlaybackMode = readPlaybackMode(),
-) {
- return queryOptions({
- queryKey: streamQueryKey(url, useAuthenticatedStream, playbackMode),
- queryFn: ({ signal }) =>
- fetchStream(
- url,
- useAuthenticatedStream ? "authenticated_first" : "anonymous_first",
- signal,
- playbackMode,
- ).then((r) => mapStreamResponse(r, url)),
- enabled: enabled && url.startsWith("http"),
- staleTime: 3 * 60 * 1000,
- gcTime: 30 * 60 * 1000,
- retry: (count, error) => {
- if (
- error instanceof ApiError &&
- (error.status === 400 ||
- error.status === 403 ||
- error.status === 404 ||
- error.status === 422)
- ) {
- return false;
- }
- return count < 2;
- },
- retryDelay: (attempt) => Math.min(250 * 2 ** attempt, 1500),
- refetchOnWindowFocus: false,
- });
-}
-
-export function isStreamUnavailableError(error: unknown): boolean {
- if (!(error instanceof Error)) return false;
- return (
- error.message.includes("Error occurs when fetching the page") ||
- error.message.includes(MEMBER_ONLY_MESSAGE) ||
- error.message.includes("No suitable stream")
- );
-}
-
-export function isMemberOnlyApiError(error: unknown): boolean {
- return isMemberOnlyApiResponse(error);
-}
-
-export function useStream(
- url: string,
- useAuthenticatedStream = false,
- enabled = true,
- playbackMode: PlaybackMode = readPlaybackMode(),
-) {
- return useQuery({
- ...streamQueryOptions(url, useAuthenticatedStream, enabled, playbackMode),
- placeholderData: keepPreviousData,
- });
-}
-
-export function useSabrBootstrap(
- url: string,
- useAuthenticatedStream = false,
- enabled = true,
- playbackMode: PlaybackMode = readPlaybackMode(),
-) {
- return useQuery({
- queryKey: sabrBootstrapQueryKey(url, useAuthenticatedStream),
- queryFn: ({ signal }) =>
- fetchSabrBootstrap(
- url,
- useAuthenticatedStream ? "authenticated_first" : "anonymous_first",
- signal,
- ).then((response) => mapStreamResponse(response, url)),
- enabled:
- enabled && playbackMode === "sabr" && url.startsWith("http") && !!sabrBootstrapEndpoint(url),
- staleTime: 3 * 60 * 1000,
- gcTime: 30 * 60 * 1000,
- retry: (count, error) => {
- if (error instanceof ApiError && [400, 403, 404, 422].includes(error.status)) return false;
- return count < 2;
- },
- retryDelay: (attempt) => Math.min(250 * 2 ** attempt, 1500),
- refetchOnWindowFocus: false,
- });
-}
diff --git a/apps/web/src/hooks/use-subscription-feed.ts b/apps/web/src/hooks/use-subscription-feed.ts
deleted file mode 100644
index 9c7d288e..00000000
--- a/apps/web/src/hooks/use-subscription-feed.ts
+++ /dev/null
@@ -1,59 +0,0 @@
-import { useInfiniteQuery } from "@tanstack/react-query";
-import { useMemo } from "react";
-import { fetchSubscriptionFeed } from "../lib/api-user";
-import { mapVideoItem } from "../lib/mappers";
-import { proxyImage } from "../lib/proxy";
-import type { VideoStream } from "../types/stream";
-import { useAuth } from "./use-auth";
-import { useSubscriptions } from "./use-subscriptions";
-
-export const SUBSCRIPTION_FEED_KEY = ["subscription-feed"];
-
-type Result = {
- streams: VideoStream[];
- isLoading: boolean;
- isFetchingNextPage: boolean;
- hasNextPage: boolean;
- fetchNextPage: () => void;
-};
-
-export function useSubscriptionFeed(): Result {
- const { authReady, isAuthed } = useAuth();
- const { query: subsQuery } = useSubscriptions();
- const avatarMap = useMemo(
- () => new Map((subsQuery.data ?? []).map((s) => [s.channelUrl, proxyImage(s.avatarUrl)])),
- [subsQuery.data],
- );
-
- const query = useInfiniteQuery({
- queryKey: SUBSCRIPTION_FEED_KEY,
- queryFn: ({ pageParam }) => fetchSubscriptionFeed(pageParam as number),
- initialPageParam: 0,
- getNextPageParam: (last, pages) => (last.nextpage !== null ? pages.length : undefined),
- staleTime: 5 * 60 * 1000,
- enabled: authReady && isAuthed,
- });
-
- const streams = useMemo(
- () =>
- (query.data?.pages ?? [])
- .flatMap((page) => page.videos)
- .map((video) => {
- const mapped = mapVideoItem(video);
- if (!mapped.channelAvatar && mapped.channelUrl) {
- const avatar = avatarMap.get(mapped.channelUrl);
- if (avatar) return { ...mapped, channelAvatar: avatar };
- }
- return mapped;
- }),
- [query.data, avatarMap],
- );
-
- return {
- streams,
- isLoading: query.isLoading,
- isFetchingNextPage: query.isFetchingNextPage,
- hasNextPage: query.hasNextPage,
- fetchNextPage: query.fetchNextPage,
- };
-}
diff --git a/apps/web/src/hooks/use-subscriptions.ts b/apps/web/src/hooks/use-subscriptions.ts
deleted file mode 100644
index 08a9b5c7..00000000
--- a/apps/web/src/hooks/use-subscriptions.ts
+++ /dev/null
@@ -1,60 +0,0 @@
-import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
-import { fetchSubscriptions, subscribe, unsubscribe } from "../lib/api-user";
-import { normalizeChannelUrl } from "../lib/channel-url";
-import type { SubscriptionItem } from "../types/user";
-import { useAuth } from "./use-auth";
-
-export const SUBSCRIPTIONS_KEY = ["subscriptions"];
-
-function hasSubscription(data: SubscriptionItem[] | undefined, channelUrl: string): boolean {
- const target = normalizeChannelUrl(channelUrl);
- return (data ?? []).some((item) => normalizeChannelUrl(item.channelUrl) === target);
-}
-
-function dedupeSubscriptions(data: SubscriptionItem[]): SubscriptionItem[] {
- const kept = new Set();
- const output: SubscriptionItem[] = [];
- for (const item of data) {
- const normalized = normalizeChannelUrl(item.channelUrl);
- if (kept.has(normalized)) continue;
- kept.add(normalized);
- output.push(item);
- }
- return output;
-}
-
-export function useSubscriptions() {
- const qc = useQueryClient();
- const { authReady, isAuthed } = useAuth();
-
- const query = useQuery({
- queryKey: SUBSCRIPTIONS_KEY,
- queryFn: fetchSubscriptions,
- enabled: authReady && isAuthed,
- select: dedupeSubscriptions,
- staleTime: 5 * 60 * 1000,
- });
-
- const add = useMutation({
- mutationFn: (item: Omit) => {
- if (!isAuthed) return Promise.resolve();
- if (hasSubscription(query.data, item.channelUrl)) return Promise.resolve();
- return subscribe({
- ...item,
- channelUrl: normalizeChannelUrl(item.channelUrl),
- });
- },
- onSuccess: () => qc.invalidateQueries({ queryKey: SUBSCRIPTIONS_KEY }),
- });
-
- const remove = useMutation({
- mutationFn: (channelUrl: string) => (isAuthed ? unsubscribe(channelUrl) : Promise.resolve()),
- onSuccess: () => qc.invalidateQueries({ queryKey: SUBSCRIPTIONS_KEY }),
- });
-
- function isSubscribed(channelUrl: string): boolean {
- return hasSubscription(query.data, channelUrl);
- }
-
- return { query, add, remove, isSubscribed };
-}
diff --git a/apps/web/src/hooks/use-video-card-preview.ts b/apps/web/src/hooks/use-video-card-preview.ts
deleted file mode 100644
index c0cc0aa2..00000000
--- a/apps/web/src/hooks/use-video-card-preview.ts
+++ /dev/null
@@ -1,53 +0,0 @@
-import { useQueryClient } from "@tanstack/react-query";
-import { useCallback, useEffect, useRef, useState } from "react";
-import type { VideoStream } from "../types/stream";
-import { isMemberOnlyApiError, streamQueryOptions } from "./use-stream";
-
-const PREVIEW_DELAY_MS = 5000;
-
-export function useVideoCardPreview(stream: VideoStream) {
- const queryClient = useQueryClient();
- const timer = useRef | null>(null);
- const [previewStream, setPreviewStream] = useState();
- const [showPreview, setShowPreview] = useState(false);
- const [memberOnly, setMemberOnly] = useState(false);
-
- const fetchStreamData = useCallback(async () => {
- const options = streamQueryOptions(stream.id);
- const cached = queryClient.getQueryData(options.queryKey);
- if (cached?.videoOnlyStreams?.length) {
- setMemberOnly(false);
- setPreviewStream(cached);
- return;
- }
- try {
- const data = await queryClient.fetchQuery(options);
- setMemberOnly(Boolean(data.requiresMembership));
- if (data.videoOnlyStreams?.length) setPreviewStream(data);
- } catch (error) {
- if (isMemberOnlyApiError(error)) setMemberOnly(true);
- }
- }, [queryClient, stream.id]);
-
- const onMouseEnter = useCallback(() => {
- timer.current = setTimeout(() => {
- void fetchStreamData().then(() => setShowPreview(true));
- }, PREVIEW_DELAY_MS);
- }, [fetchStreamData]);
-
- const onMouseLeave = useCallback(() => {
- if (timer.current) clearTimeout(timer.current);
- timer.current = null;
- setShowPreview(false);
- }, []);
-
- useEffect(() => onMouseLeave, [onMouseLeave]);
-
- return {
- memberOnly: memberOnly || stream.requiresMembership === true,
- onMouseEnter,
- onMouseLeave,
- previewStream,
- showPreview,
- };
-}
diff --git a/apps/web/src/hooks/use-volume-sync.ts b/apps/web/src/hooks/use-volume-sync.ts
deleted file mode 100644
index 7f57c58f..00000000
--- a/apps/web/src/hooks/use-volume-sync.ts
+++ /dev/null
@@ -1,17 +0,0 @@
-import { useCallback, useRef } from "react";
-import type { SettingsItem } from "../types/user";
-
-type MutateFn = (patch: Partial) => void;
-
-export function useVolumeSync(mutate: MutateFn): (volume: number, muted: boolean) => void {
- const mutateRef = useRef(mutate);
- mutateRef.current = mutate;
- const timerRef = useRef | null>(null);
-
- return useCallback((volume: number, muted: boolean) => {
- if (timerRef.current) clearTimeout(timerRef.current);
- timerRef.current = setTimeout(() => {
- mutateRef.current({ volume, muted });
- }, 1000);
- }, []);
-}
diff --git a/apps/web/src/hooks/use-watch-audio-only-mode.ts b/apps/web/src/hooks/use-watch-audio-only-mode.ts
deleted file mode 100644
index 18986ff1..00000000
--- a/apps/web/src/hooks/use-watch-audio-only-mode.ts
+++ /dev/null
@@ -1,58 +0,0 @@
-import { type MutableRefObject, useEffect, useRef, useState } from "react";
-
-type Args = {
- currentParam: string;
- defaultEnabled: boolean;
- settingsReady: boolean;
- positionRef: MutableRefObject;
- readPositionMs: () => number | null;
-};
-
-export function useWatchAudioOnlyMode({
- currentParam,
- defaultEnabled,
- settingsReady,
- positionRef,
- readPositionMs,
-}: Args) {
- const initializedParamRef = useRef(null);
- const [active, setActive] = useState(false);
- const [switchPositionMs, setSwitchPositionMs] = useState(null);
- const initialized = initializedParamRef.current === currentParam;
-
- useEffect(() => {
- if (!settingsReady || initializedParamRef.current === currentParam) return;
- initializedParamRef.current = currentParam;
- setActive(defaultEnabled);
- setSwitchPositionMs(null);
- }, [currentParam, defaultEnabled, settingsReady]);
-
- function syncPosition() {
- const positionMs = readPositionMs();
- if (positionMs !== null && Number.isFinite(positionMs)) {
- const nextPosition = Math.max(0, positionMs);
- positionRef.current = nextPosition;
- setSwitchPositionMs(nextPosition);
- return nextPosition;
- }
- setSwitchPositionMs(positionRef.current);
- return positionRef.current;
- }
-
- function toggle() {
- syncPosition();
- setActive((value) => !value);
- }
-
- function disable() {
- syncPosition();
- setActive(false);
- }
-
- return {
- active: settingsReady && initialized ? active : defaultEnabled,
- switchPositionMs,
- toggle,
- disable,
- };
-}
diff --git a/apps/web/src/hooks/use-watch-audio-only-playback.ts b/apps/web/src/hooks/use-watch-audio-only-playback.ts
deleted file mode 100644
index 413c57fc..00000000
--- a/apps/web/src/hooks/use-watch-audio-only-playback.ts
+++ /dev/null
@@ -1,93 +0,0 @@
-import { type MutableRefObject, useEffect, useState } from "react";
-import type { MediaSrc } from "../lib/vidstack";
-import { toWatchSourceUrl } from "../lib/watch-url";
-import type { SettingsItem } from "../types/user";
-import { useWatchAudioOnlyMode } from "./use-watch-audio-only-mode";
-import { useWatchAudioOnlySource } from "./use-watch-audio-only-source";
-
-type Args = {
- currentParam: string;
- settings: SettingsItem;
- settingsReady: boolean;
- isLive: boolean;
- positionRef: MutableRefObject;
- readPositionMs: () => number | null;
- clearFailed: () => void;
- sabrEnabled: boolean;
-};
-
-type WatchAudioOnlyPlayback = {
- src: MediaSrc | null;
- controls: WatchAudioOnlyControls;
- switchPositionMs: number | null;
- active: boolean;
- loading: boolean;
- enabled: boolean;
- unavailable: boolean;
- toggle: () => void;
- fail: () => boolean;
-};
-
-export type WatchAudioOnlyControls = {
- active: boolean;
- loading: boolean;
- onToggle: () => void;
-};
-
-export function useWatchAudioOnlyPlayback({
- currentParam,
- settings,
- settingsReady,
- isLive,
- positionRef,
- readPositionMs,
- clearFailed,
- sabrEnabled,
-}: Args): WatchAudioOnlyPlayback {
- const mode = useWatchAudioOnlyMode({
- currentParam,
- defaultEnabled: settings.audioOnlyPlayback,
- settingsReady,
- positionRef,
- readPositionMs,
- });
- const failureKey = `${currentParam}:${mode.active}`;
- const [failedKey, setFailedKey] = useState(null);
- const source = useWatchAudioOnlySource(
- toWatchSourceUrl(currentParam),
- settings,
- isLive,
- mode.active && !sabrEnabled,
- );
- const runtimeFailed = failedKey === failureKey;
- const src = runtimeFailed || sabrEnabled ? null : source.src;
-
- useEffect(() => {
- if (!source.unavailable || !mode.active) return;
- mode.disable();
- }, [mode, source.unavailable]);
-
- function fail() {
- if (!src) return false;
- clearFailed();
- setFailedKey(failureKey);
- mode.disable();
- return true;
- }
-
- return {
- src,
- controls: {
- active: mode.active,
- loading: !sabrEnabled && source.loading,
- onToggle: mode.toggle,
- },
- switchPositionMs: mode.switchPositionMs,
- active: mode.active,
- loading: !sabrEnabled && source.loading,
- enabled: sabrEnabled ? mode.active : source.enabled,
- unavailable: source.unavailable,
- toggle: mode.toggle,
- fail,
- };
-}
diff --git a/apps/web/src/hooks/use-watch-audio-only-source.ts b/apps/web/src/hooks/use-watch-audio-only-source.ts
deleted file mode 100644
index dcb804d8..00000000
--- a/apps/web/src/hooks/use-watch-audio-only-source.ts
+++ /dev/null
@@ -1,37 +0,0 @@
-import { useMemo } from "react";
-import { isAudioOnlyUnavailable, toAudioOnlyMediaSrc } from "../lib/api-audio-only";
-import type { MediaSrc } from "../lib/vidstack";
-import type { SettingsItem } from "../types/user";
-import { useAudioOnlyStream } from "./use-audio-only-stream";
-
-type WatchAudioOnlySource = {
- enabled: boolean;
- loading: boolean;
- src: MediaSrc | null;
- unavailable: boolean;
-};
-
-export function useWatchAudioOnlySource(
- sourceUrl: string,
- settings: SettingsItem,
- isLive: boolean,
- active = settings.audioOnlyPlayback,
-): WatchAudioOnlySource {
- const enabled = active && !isLive;
- const query = useAudioOnlyStream(
- sourceUrl,
- settings.preferOriginalLanguage,
- settings.defaultAudioLanguage,
- enabled,
- );
- const src = useMemo(
- () => (enabled && query.data ? toAudioOnlyMediaSrc(query.data) : null),
- [enabled, query.data],
- );
- return {
- enabled,
- loading: enabled && query.isLoading,
- src,
- unavailable: enabled && isAudioOnlyUnavailable(query.error),
- };
-}
diff --git a/apps/web/src/hooks/use-watch-autoplay-preload.ts b/apps/web/src/hooks/use-watch-autoplay-preload.ts
deleted file mode 100644
index 105a83b4..00000000
--- a/apps/web/src/hooks/use-watch-autoplay-preload.ts
+++ /dev/null
@@ -1,78 +0,0 @@
-import { useQueryClient } from "@tanstack/react-query";
-import { useCallback, useEffect, useRef } from "react";
-import { prewarmSabrPlayback } from "../lib/api-sabr-prewarm";
-import { shouldPreloadAutoplayTarget } from "../lib/autoplay-preload";
-import { defaultSabrItag, resolveSabrPlaybackConfig, sabrQualityOptions } from "../lib/sabr-source";
-import { toWatchSourceUrl } from "../lib/watch-url";
-import { useAuthStore } from "../stores/auth-store";
-import { useAuth } from "./use-auth";
-import { useInstance } from "./use-instance";
-import { usePlaybackMode } from "./use-playback-mode";
-import { useSettings } from "./use-settings";
-import { streamQueryOptions } from "./use-stream";
-import type { AutoplayTarget } from "./use-watch-ended-navigation";
-
-type Args = {
- durationMs: number;
- enabled: boolean;
- target: AutoplayTarget | null;
-};
-
-export function useWatchAutoplayPreload({ durationMs, enabled, target }: Args) {
- const queryClient = useQueryClient();
- const token = useAuthStore((state) => state.token);
- const { authReady, isAuthed } = useAuth();
- const { data: instance, isPending: instancePending } = useInstance();
- const { playbackMode } = usePlaybackMode();
- const { settings, settingsReady } = useSettings();
- const preloadedRef = useRef(new Set());
- const activeRef = useRef(null);
- const activeTargetIdRef = useRef("");
- const useAuthenticatedStream =
- isAuthed && (settings.accessMode === "allow_list" || instance?.guestAllowed === false);
- const ready = authReady && !instancePending && (!isAuthed || settingsReady);
- const targetId = target?.id ?? "";
-
- useEffect(() => {
- if (activeTargetIdRef.current === targetId) return;
- activeTargetIdRef.current = targetId;
- activeRef.current?.abort();
- activeRef.current = null;
- }, [targetId]);
-
- useEffect(
- () => () => {
- activeRef.current?.abort();
- },
- [],
- );
-
- return useCallback(
- (positionMs: number) => {
- if (!shouldPreloadAutoplayTarget(positionMs, durationMs, enabled, Boolean(target))) return;
- if (!target || !ready) return;
- const sourceUrl = toWatchSourceUrl(target.search.v);
- const key = `${sourceUrl}:${useAuthenticatedStream ? "auth" : "anon"}:${playbackMode}`;
- if (preloadedRef.current.has(key)) return;
- preloadedRef.current.add(key);
- const controller = new AbortController();
- activeRef.current?.abort();
- activeRef.current = controller;
- void queryClient
- .fetchQuery(streamQueryOptions(sourceUrl, useAuthenticatedStream, true, playbackMode))
- .then((stream) => {
- if (controller.signal.aborted || playbackMode !== "sabr") return;
- const itag = defaultSabrItag(sabrQualityOptions(stream), "720p");
- const config = resolveSabrPlaybackConfig(stream, itag);
- if (config) return prewarmSabrPlayback(config, token, controller.signal);
- })
- .catch(() => {
- if (!controller.signal.aborted) preloadedRef.current.delete(key);
- })
- .finally(() => {
- if (activeRef.current === controller) activeRef.current = null;
- });
- },
- [durationMs, enabled, playbackMode, queryClient, ready, target, token, useAuthenticatedStream],
- );
-}
diff --git a/apps/web/src/hooks/use-watch-bullet-comments.ts b/apps/web/src/hooks/use-watch-bullet-comments.ts
deleted file mode 100644
index b13baebd..00000000
--- a/apps/web/src/hooks/use-watch-bullet-comments.ts
+++ /dev/null
@@ -1,9 +0,0 @@
-import { detectProvider } from "../lib/provider";
-import { useBulletComments } from "./use-bullet-comments";
-
-export function useWatchBulletComments(videoUrl: string, hideComments: boolean) {
- const isNicoNico = detectProvider(videoUrl) === "nicovideo";
- const { data: bulletComments } = useBulletComments(videoUrl, isNicoNico && !hideComments);
-
- return { isNicoNico, bulletComments };
-}
diff --git a/apps/web/src/hooks/use-watch-ended-navigation.ts b/apps/web/src/hooks/use-watch-ended-navigation.ts
deleted file mode 100644
index c6efa23c..00000000
--- a/apps/web/src/hooks/use-watch-ended-navigation.ts
+++ /dev/null
@@ -1,174 +0,0 @@
-import { useNavigate } from "@tanstack/react-router";
-import { useCallback, useEffect, useMemo, useRef, useState } from "react";
-import { markWatchAutoplayIntent } from "../lib/watch-autoplay-intent";
-import type { WatchPlaylistItem } from "../types/playlist";
-import type { VideoStream } from "../types/stream";
-
-const DEFAULT_AUTOPLAY_DELAY_SECONDS = 10;
-
-type WatchSearch = {
- v: string;
- list?: string;
- shuffle?: string;
-};
-
-export type AutoplayTarget = {
- id: string;
- title: string;
- thumbnail: string;
- channelName: string;
- source: "playlist" | "related";
- duration?: number;
- search: WatchSearch;
-};
-
-export type AutoplayState = {
- target: AutoplayTarget;
- totalSeconds: number;
- paused: boolean;
-};
-
-type Params = {
- settingsReady: boolean;
- autoplay: boolean;
- countdownSeconds: number;
- skipPlaylistAutoplayScreen: boolean;
- hideRelatedVideos: boolean;
- nextParam: string | null;
- nextVideo?: WatchPlaylistItem | null;
- list?: string;
- shuffle?: string;
- related?: VideoStream[];
-};
-
-export function useWatchEndedNavigation({
- settingsReady,
- autoplay,
- countdownSeconds,
- skipPlaylistAutoplayScreen,
- hideRelatedVideos,
- nextParam,
- nextVideo,
- list,
- shuffle,
- related,
-}: Params) {
- const navigate = useNavigate();
- const delaySeconds = Math.min(60, Math.max(0, Math.round(countdownSeconds)));
- const delayMs = delaySeconds * 1000;
- const [target, setTarget] = useState(null);
- const [paused, setPaused] = useState(false);
- const [remainingMs, setRemainingMs] = useState(DEFAULT_AUTOPLAY_DELAY_SECONDS * 1000);
- const startedAtRef = useRef(0);
- const dismissedTargetIdRef = useRef("");
-
- const nextTarget = useMemo(() => {
- if (nextParam) {
- return {
- id: nextParam,
- title: nextVideo?.title ?? "Next video",
- thumbnail: nextVideo?.thumbnail ?? "",
- channelName: nextVideo?.channelName ?? "",
- source: "playlist",
- search: { v: nextParam, list, ...(shuffle ? { shuffle } : {}) },
- };
- }
- if (hideRelatedVideos) return null;
- const relatedVideo = related?.[0];
- if (!relatedVideo) return null;
- return {
- id: relatedVideo.id,
- title: relatedVideo.title,
- thumbnail: relatedVideo.thumbnail,
- channelName: relatedVideo.channelName,
- source: "related",
- duration: relatedVideo.duration,
- search: { v: relatedVideo.id },
- };
- }, [nextParam, nextVideo, list, shuffle, hideRelatedVideos, related]);
- const nextTargetRef = useRef(nextTarget);
- nextTargetRef.current = nextTarget;
-
- const navigateToTarget = useCallback(
- (next: AutoplayTarget) => {
- markWatchAutoplayIntent();
- navigate({ to: "/watch", search: next.search });
- },
- [navigate],
- );
-
- const playNow = useCallback(() => {
- if (!target) return;
- navigateToTarget(target);
- }, [target, navigateToTarget]);
-
- const cancel = useCallback(() => {
- if (target) dismissedTargetIdRef.current = target.id;
- setTarget(null);
- setPaused(false);
- setRemainingMs(delayMs);
- }, [target, delayMs]);
-
- const togglePause = useCallback(() => {
- if (!target) return;
- if (paused) {
- setPaused(false);
- return;
- }
- const elapsedMs = Date.now() - startedAtRef.current;
- setRemainingMs((current) => Math.max(0, current - elapsedMs));
- setPaused(true);
- }, [target, paused]);
-
- useEffect(() => {
- if (nextTarget?.id !== dismissedTargetIdRef.current) dismissedTargetIdRef.current = "";
- setTarget((current) => {
- if (!current) return null;
- if (!nextTarget || current.id !== nextTarget.id) return null;
- return nextTarget;
- });
- }, [nextTarget]);
-
- useEffect(() => {
- if (!target || paused) return;
- if (remainingMs <= 0) {
- playNow();
- return;
- }
- startedAtRef.current = Date.now();
- const timer = window.setTimeout(playNow, remainingMs);
- return () => window.clearTimeout(timer);
- }, [target, paused, remainingMs, playNow]);
-
- const handleEnded = useCallback(() => {
- if (!settingsReady || !autoplay) return;
- const next = nextTargetRef.current;
- if (!next || dismissedTargetIdRef.current === next.id) return;
- if (skipPlaylistAutoplayScreen && next.source === "playlist") {
- navigateToTarget(next);
- return;
- }
- if (delayMs <= 0) {
- navigateToTarget(next);
- return;
- }
- setRemainingMs(delayMs);
- setPaused(false);
- setTarget(next);
- }, [settingsReady, autoplay, skipPlaylistAutoplayScreen, delayMs, navigateToTarget]);
-
- return {
- nextTarget,
- handleEnded,
- autoplayState: target
- ? {
- target,
- totalSeconds: delaySeconds,
- paused,
- }
- : null,
- playNow,
- cancel,
- togglePause,
- };
-}
diff --git a/apps/web/src/hooks/use-watch-initial-audio-source.ts b/apps/web/src/hooks/use-watch-initial-audio-source.ts
deleted file mode 100644
index 774b638b..00000000
--- a/apps/web/src/hooks/use-watch-initial-audio-source.ts
+++ /dev/null
@@ -1,35 +0,0 @@
-import { useEffect, useRef } from "react";
-
-type Args = {
- streamId: string;
- settingsReady: boolean;
- navigating: boolean;
- audioOnlyEnabled: boolean;
- audioOnlyLoading: boolean;
- hasAudioOnlySource: boolean;
-};
-
-export function useWatchInitialAudioSource({
- streamId,
- settingsReady,
- navigating,
- audioOnlyEnabled,
- audioOnlyLoading,
- hasAudioOnlySource,
-}: Args) {
- const renderedPlayerRef = useRef(false);
- const renderedStreamRef = useRef(streamId);
-
- if (renderedStreamRef.current !== streamId) {
- renderedStreamRef.current = streamId;
- renderedPlayerRef.current = false;
- }
-
- useEffect(() => {
- if (!settingsReady || navigating) return;
- if (audioOnlyEnabled && !hasAudioOnlySource) return;
- renderedPlayerRef.current = true;
- }, [audioOnlyEnabled, hasAudioOnlySource, navigating, settingsReady]);
-
- return audioOnlyLoading && !renderedPlayerRef.current;
-}
diff --git a/apps/web/src/hooks/use-watch-later-playlist.ts b/apps/web/src/hooks/use-watch-later-playlist.ts
deleted file mode 100644
index 4898ebf1..00000000
--- a/apps/web/src/hooks/use-watch-later-playlist.ts
+++ /dev/null
@@ -1,63 +0,0 @@
-import { useQuery } from "@tanstack/react-query";
-import { useRef, useState } from "react";
-import { addWatchLater, fetchWatchLater, removeWatchLater } from "../lib/api-collections";
-import { useAuth } from "./use-auth";
-
-const KEY = ["watch-later"];
-
-type AddPayload = {
- url: string;
- title: string;
- thumbnail: string;
- duration: number;
-};
-
-type Intent = { url: string; adding: boolean };
-
-export function useWatchLaterPlaylist() {
- const { authReady, isAuthed } = useAuth();
- const intentRef = useRef(null);
- const [intent, setIntent] = useState(null);
- const query = useQuery({
- queryKey: KEY,
- queryFn: fetchWatchLater,
- enabled: authReady && isAuthed,
- });
-
- function isInWatchLater(videoUrl: string): boolean {
- if (intentRef.current?.url === videoUrl) return intentRef.current.adding;
- return query.data?.some((item) => item.url === videoUrl) ?? false;
- }
-
- function applyIntent(value: Intent | null) {
- intentRef.current = value;
- setIntent(value);
- }
-
- async function add(payload: AddPayload): Promise {
- if (isInWatchLater(payload.url)) return;
- applyIntent({ url: payload.url, adding: true });
- try {
- await addWatchLater(payload);
- await query.refetch();
- } catch (e) {
- applyIntent(null);
- throw e;
- }
- applyIntent(null);
- }
-
- async function remove(videoUrl: string): Promise {
- applyIntent({ url: videoUrl, adding: false });
- try {
- await removeWatchLater(videoUrl);
- await query.refetch();
- } catch (e) {
- applyIntent(null);
- throw e;
- }
- applyIntent(null);
- }
-
- return { isInWatchLater, add, remove, isPending: intent !== null };
-}
diff --git a/apps/web/src/hooks/use-watch-later-streams.ts b/apps/web/src/hooks/use-watch-later-streams.ts
deleted file mode 100644
index b4f5152a..00000000
--- a/apps/web/src/hooks/use-watch-later-streams.ts
+++ /dev/null
@@ -1,42 +0,0 @@
-import { useQuery } from "@tanstack/react-query";
-import { fetchWatchLater } from "../lib/api-collections";
-import { mapWatchLaterItem } from "../lib/watch-later-mappers";
-import type { PlaylistVideoItem, WatchLaterItem } from "../types/user";
-import { useAuth } from "./use-auth";
-
-function mapWatchLaterPlaylistItem(item: WatchLaterItem, position: number): PlaylistVideoItem {
- return {
- id: item.url,
- url: item.url,
- title: item.title,
- thumbnail: item.thumbnail,
- channelName: item.channelName ?? "",
- channelUrl: item.channelUrl ?? "",
- channelAvatar: item.channelAvatar ?? "",
- viewCount: item.viewCount ?? 0,
- duration: item.duration,
- position,
- addedAt: item.addedAt,
- publishedAt: item.publishedAt,
- watchPosition: 0,
- watched: false,
- progressUpdatedAt: 0,
- };
-}
-
-export function useWatchLaterStreams() {
- const { authReady, isAuthed } = useAuth();
- const query = useQuery({
- queryKey: ["watch-later"],
- queryFn: fetchWatchLater,
- enabled: authReady && isAuthed,
- });
- const items = query.data ?? [];
-
- return {
- count: items.length,
- videos: items.map(mapWatchLaterItem),
- playlistVideos: items.map(mapWatchLaterPlaylistItem),
- isLoading: query.isLoading,
- };
-}
diff --git a/apps/web/src/hooks/use-watch-layout-assets.ts b/apps/web/src/hooks/use-watch-layout-assets.ts
deleted file mode 100644
index 71966bb1..00000000
--- a/apps/web/src/hooks/use-watch-layout-assets.ts
+++ /dev/null
@@ -1,95 +0,0 @@
-import { useEffect, useRef, useState } from "react";
-import { buildChaptersVtt, buildSponsorBlockChaptersVtt } from "../lib/chapters-vtt";
-import { proxyUrl } from "../lib/proxy";
-import { buildThumbnailVtt } from "../lib/thumbnail-vtt";
-import type { SponsorBlockSegmentItem } from "../types/api";
-import type { VideoStream } from "../types/stream";
-
-function previewFramesKey(stream: VideoStream): string {
- return (stream.previewFrames ?? [])
- .map(
- (frame) =>
- `${frame.frameWidth}:${frame.frameHeight}:${frame.durationPerFrame}:${frame.urls.join(",")}`,
- )
- .join("|");
-}
-
-function streamSegmentsKey(stream: VideoStream): string {
- return (stream.streamSegments ?? [])
- .map((segment) => `${segment.startTimeSeconds}:${segment.title}`)
- .join("|");
-}
-
-function sponsorBlockSegmentsKey(segments: SponsorBlockSegmentItem[] | undefined): string {
- return (segments ?? [])
- .map(
- (segment) => `${segment.startTime}:${segment.endTime}:${segment.category}:${segment.action}`,
- )
- .join("|");
-}
-
-export function useWatchVttAssets(
- stream: VideoStream,
- sponsorBlockSegments: SponsorBlockSegmentItem[] | undefined,
- showSponsorBlockChapters: boolean,
-) {
- const [thumbnailVtt, setThumbnailVtt] = useState(null);
- const [chaptersVtt, setChaptersVtt] = useState(null);
- const thumbnailAssetRef = useRef<{ key: string; vtt: string | null }>({ key: "", vtt: null });
- const chaptersAssetRef = useRef<{ key: string; vtt: string | null }>({ key: "", vtt: null });
- const thumbnailKey = previewFramesKey(stream);
- const chaptersKey = streamSegmentsKey(stream);
- const sponsorChaptersKey = sponsorBlockSegmentsKey(sponsorBlockSegments);
-
- useEffect(() => {
- if (thumbnailAssetRef.current.key === thumbnailKey) return;
- const previousVtt = thumbnailAssetRef.current.vtt;
- if (!stream.previewFrames) {
- thumbnailAssetRef.current = { key: thumbnailKey, vtt: null };
- setThumbnailVtt(null);
- if (previousVtt) URL.revokeObjectURL(previousVtt);
- return;
- }
- const proxied = stream.previewFrames.map((frame) => ({
- ...frame,
- urls: frame.urls.map(proxyUrl),
- }));
- const vtt = buildThumbnailVtt(proxied);
- thumbnailAssetRef.current = { key: thumbnailKey, vtt };
- setThumbnailVtt(vtt);
- if (previousVtt) URL.revokeObjectURL(previousVtt);
- }, [stream.previewFrames, thumbnailKey]);
-
- useEffect(() => {
- const key = `${showSponsorBlockChapters}:${stream.duration}:${chaptersKey}:${sponsorChaptersKey}`;
- if (chaptersAssetRef.current.key === key) return;
- const vtt = stream.streamSegments
- ? buildChaptersVtt(stream.streamSegments, stream.duration)
- : showSponsorBlockChapters
- ? buildSponsorBlockChaptersVtt(sponsorBlockSegments ?? [], stream.duration)
- : null;
- const previousVtt = chaptersAssetRef.current.vtt;
- chaptersAssetRef.current = { key, vtt };
- setChaptersVtt(vtt);
- if (previousVtt) URL.revokeObjectURL(previousVtt);
- }, [
- chaptersKey,
- stream.duration,
- stream.streamSegments,
- sponsorBlockSegments,
- sponsorChaptersKey,
- showSponsorBlockChapters,
- ]);
-
- useEffect(() => {
- return () => {
- if (thumbnailAssetRef.current.vtt) URL.revokeObjectURL(thumbnailAssetRef.current.vtt);
- if (chaptersAssetRef.current.vtt) URL.revokeObjectURL(chaptersAssetRef.current.vtt);
- };
- }, []);
-
- return {
- thumbnailVtt: thumbnailVtt ?? undefined,
- chaptersVtt: chaptersVtt ?? undefined,
- };
-}
diff --git a/apps/web/src/hooks/use-watch-playback-flow.ts b/apps/web/src/hooks/use-watch-playback-flow.ts
deleted file mode 100644
index c44256a8..00000000
--- a/apps/web/src/hooks/use-watch-playback-flow.ts
+++ /dev/null
@@ -1,48 +0,0 @@
-import type { WatchPlaylistItem } from "../types/playlist";
-import type { VideoStream } from "../types/stream";
-import type { SettingsItem } from "../types/user";
-import { useWatchAutoplayPreload } from "./use-watch-autoplay-preload";
-import { useWatchEndedNavigation } from "./use-watch-ended-navigation";
-import { useWatchPlayerEvents } from "./use-watch-player-events";
-
-type Args = {
- stream: VideoStream;
- settings: SettingsItem;
- settingsReady: boolean;
- isLive: boolean;
- nextParam: string | null;
- nextVideo: WatchPlaylistItem | null;
- list?: string;
- shuffle?: string;
- mutate: (positionMs: number, keepalive: boolean) => void;
- onPlay: () => void;
-};
-
-export function useWatchPlaybackFlow(args: Args) {
- const autoplay = useWatchEndedNavigation({
- settingsReady: args.settingsReady,
- autoplay: args.settings.autoplay,
- countdownSeconds: args.settings.autoplayCountdownSeconds,
- skipPlaylistAutoplayScreen: args.settings.skipPlaylistAutoplayScreen,
- hideRelatedVideos: args.settings.hideRelatedVideos,
- nextParam: args.nextParam,
- nextVideo: args.nextVideo,
- list: args.list,
- shuffle: args.shuffle,
- related: args.stream.related,
- });
- const preloadAutoplay = useWatchAutoplayPreload({
- durationMs: args.stream.duration * 1000,
- enabled: args.settingsReady && args.settings.autoplay && !args.isLive,
- target: autoplay.nextTarget,
- });
- const playerEvents = useWatchPlayerEvents({
- stream: args.stream,
- isLive: args.isLive,
- mutate: args.mutate,
- onPlay: args.onPlay,
- onEnded: autoplay.handleEnded,
- onTimeUpdate: preloadAutoplay,
- });
- return { autoplay, playerEvents };
-}
diff --git a/apps/web/src/hooks/use-watch-player-events.ts b/apps/web/src/hooks/use-watch-player-events.ts
deleted file mode 100644
index 313ae904..00000000
--- a/apps/web/src/hooks/use-watch-player-events.ts
+++ /dev/null
@@ -1,62 +0,0 @@
-import { useCallback, useRef } from "react";
-import type { VideoStream } from "../types/stream";
-import { useWatchProgressPersistence } from "./use-watch-progress-persistence";
-import { useWatchSessionReporting } from "./use-watch-session-reporting";
-
-type Args = {
- stream: VideoStream;
- isLive: boolean;
- mutate: (positionMs: number, keepalive: boolean) => void;
- onPlay?: () => void;
- onEnded: () => void;
- onTimeUpdate?: (positionMs: number) => void;
-};
-
-export function useWatchPlayerEvents({
- stream,
- isLive,
- mutate,
- onPlay,
- onEnded,
- onTimeUpdate,
-}: Args) {
- const playbackIntentRef = useRef(null);
- const streamIdRef = useRef(stream.id);
- if (streamIdRef.current !== stream.id) {
- streamIdRef.current = stream.id;
- playbackIntentRef.current = null;
- }
- const { positionRef, handleTimeUpdate, handlePause, handleSeeked } = useWatchProgressPersistence({
- durationSec: stream.duration,
- isLive,
- mutate,
- });
- const sessionReporting = useWatchSessionReporting({
- stream,
- isLive,
- onTimeUpdate: handleTimeUpdate,
- onPause: handlePause,
- onSeeked: handleSeeked,
- onEnded,
- });
- const handlePlay = useCallback(() => {
- playbackIntentRef.current = true;
- onPlay?.();
- }, [onPlay]);
- const handleTrackedPause = useCallback(() => {
- playbackIntentRef.current = false;
- sessionReporting.handlePause();
- }, [sessionReporting.handlePause]);
- return {
- positionRef,
- playbackIntent: () => playbackIntentRef.current,
- handleTimeUpdate: (positionMs: number) => {
- sessionReporting.handleTimeUpdate(positionMs);
- onTimeUpdate?.(positionMs);
- },
- handlePlay,
- handlePause: handleTrackedPause,
- handleSeeked: sessionReporting.handleSeeked,
- handleEnded: sessionReporting.handleEnded,
- };
-}
diff --git a/apps/web/src/hooks/use-watch-player-source-state.ts b/apps/web/src/hooks/use-watch-player-source-state.ts
deleted file mode 100644
index 98d849f5..00000000
--- a/apps/web/src/hooks/use-watch-player-source-state.ts
+++ /dev/null
@@ -1,109 +0,0 @@
-import { type MutableRefObject, useRef } from "react";
-import type { MediaSrc } from "../lib/vidstack";
-import { consumeWatchAutoplayIntent } from "../lib/watch-autoplay-intent";
-import { buildWatchPlayerKey } from "../lib/watch-player-key";
-import { useWatchInitialAudioSource } from "./use-watch-initial-audio-source";
-import { useWatchSourceStartTime } from "./use-watch-source-start-time";
-
-type Args = {
- streamId: string;
- retryKey: number;
- startTime: number;
- manifestSrc: MediaSrc;
- positionRef: MutableRefObject;
- highQuality: boolean;
- hasThumbnails: boolean;
- hasChapters: boolean;
- audioOnlyEnabled: boolean;
- audioOnlyLoading: boolean;
- hasAudioOnlySource: boolean;
- sabrEnabled: boolean;
- settingsReady: boolean;
- autoplayEnabled: boolean;
- navigating: boolean;
- playbackIntent: () => boolean | null;
-};
-
-function sourceIdentity(src: MediaSrc): string {
- if (typeof src === "string") return src;
- if (Array.isArray(src)) return String(src.length);
- if (src && typeof src === "object" && "src" in src && typeof src.src === "string") return src.src;
- return "unknown";
-}
-
-type AutoplayState = {
- playerKey: string;
- streamId: string;
- settingsReady: boolean;
- autoplay: boolean;
-};
-
-type AutoplayDecision = {
- previous: AutoplayState | null;
- streamId: string;
- retryKey: number;
- settingsReady: boolean;
- autoplayEnabled: boolean;
- playbackIntent: boolean | null;
- autoplayIntent: boolean;
-};
-
-export function decideWatchSourceAutoplay(args: AutoplayDecision): boolean {
- if (!args.settingsReady) return false;
- if (args.autoplayIntent) return true;
- if (args.playbackIntent !== null) return args.playbackIntent;
- if (args.retryKey !== 0) return false;
- const initialSource =
- args.previous === null ||
- args.previous.streamId !== args.streamId ||
- !args.previous.settingsReady;
- return args.previous?.autoplay === true || (initialSource && args.autoplayEnabled);
-}
-
-export function useWatchPlayerSourceState(args: Args) {
- const sourceStart = useWatchSourceStartTime({
- streamId: args.streamId,
- sourceKey: args.audioOnlyEnabled ? "audio" : "video",
- retryKey: args.retryKey,
- startTime: args.startTime,
- positionRef: args.positionRef,
- });
- const playerKey = buildWatchPlayerKey({
- streamId: args.streamId,
- retryKey: args.retryKey,
- sourceKey: `${args.sabrEnabled ? "sabr" : sourceStart.keyPart}:${sourceIdentity(args.manifestSrc)}`,
- highQuality: args.highQuality,
- hasThumbnails: args.hasThumbnails,
- hasChapters: args.hasChapters,
- });
- const autoplayRef = useRef(null);
- const autoplayState = autoplayRef.current;
- if (
- !autoplayState ||
- autoplayState.playerKey !== playerKey ||
- (!autoplayState.settingsReady && args.settingsReady)
- ) {
- const autoplayIntent = consumeWatchAutoplayIntent();
- autoplayRef.current = {
- playerKey,
- streamId: args.streamId,
- settingsReady: args.settingsReady,
- autoplay: decideWatchSourceAutoplay({
- previous: autoplayState,
- streamId: args.streamId,
- retryKey: args.retryKey,
- settingsReady: args.settingsReady,
- autoplayEnabled: args.autoplayEnabled,
- playbackIntent: args.playbackIntent(),
- autoplayIntent,
- }),
- };
- }
- const waitForInitialAudioSource = useWatchInitialAudioSource(args);
- return {
- playerKey,
- startTime: sourceStart.startTime,
- waitForInitialAudioSource,
- autoplay: autoplayRef.current?.autoplay ?? false,
- };
-}
diff --git a/apps/web/src/hooks/use-watch-playlist.tsx b/apps/web/src/hooks/use-watch-playlist.tsx
deleted file mode 100644
index 96b43d65..00000000
--- a/apps/web/src/hooks/use-watch-playlist.tsx
+++ /dev/null
@@ -1,144 +0,0 @@
-import { useNavigate } from "@tanstack/react-router";
-import { type ReactNode, useCallback, useEffect } from "react";
-import { WatchPlaylistPanel } from "../components/watch-playlist-panel";
-import { applyCustomOrder, randomShuffleSeed, shuffleByKey } from "../lib/playlist-shuffle";
-import { isManagedPlaylistId } from "../lib/playlist-url";
-import { markWatchAutoplayIntent } from "../lib/watch-autoplay-intent";
-import { toPublicWatchParam } from "../lib/watch-url";
-import { usePlaylistOrderStore } from "../stores/playlist-order-store";
-import type { WatchPlaylistItem } from "../types/playlist";
-import { usePlaylist } from "./use-playlist";
-import { usePlaylists } from "./use-playlists";
-import { usePublicPlaylist } from "./use-public-playlist";
-
-const MISSING_CURRENT_PREFETCH_LIMIT = 5;
-const PREFETCH_REMAINING_ITEMS = 10;
-
-type WatchPlaylist = {
- nextParam: string | null;
- nextVideo: WatchPlaylistItem | null;
- playPrevious?: () => void;
- playNext?: () => void;
- panel: ReactNode;
-};
-
-export function useWatchPlaylist(
- list: string | undefined,
- shuffle: string | undefined,
- currentParam: string,
-): WatchPlaylist {
- const navigate = useNavigate();
- const managedList = list && isManagedPlaylistId(list) ? list : "";
- const publicListUrl =
- list && !isManagedPlaylistId(list) ? `https://www.youtube.com/playlist?list=${list}` : "";
- const managedPlaylist = usePlaylist(managedList);
- const publicPlaylist = usePublicPlaylist(publicListUrl);
- const { reorder } = usePlaylists();
- const setOrder = usePlaylistOrderStore((state) => state.setOrder);
- const customOrder = usePlaylistOrderStore((state) => (list ? state.orders[list] : undefined));
- const isManaged = managedList.length > 0;
- const name = isManaged
- ? (managedPlaylist.data?.name ?? "")
- : (publicPlaylist.data?.pages[0]?.playlist.title ?? "");
- const base: WatchPlaylistItem[] = isManaged
- ? (managedPlaylist.data?.videos ?? []).map((item) => ({
- key: item.id,
- url: item.url,
- title: item.title,
- thumbnail: item.thumbnail,
- channelName: item.channelName,
- }))
- : (publicPlaylist.data?.pages.flatMap((page) => page.streams) ?? []).map((item, index) => ({
- key: `${index}-${item.id}`,
- url: item.id,
- title: item.title,
- thumbnail: item.thumbnail,
- channelName: item.channelName,
- }));
- const arranged = !isManaged && customOrder ? applyCustomOrder(base, customOrder) : base;
- const videos = shuffle ? shuffleByKey(arranged, shuffle) : arranged;
- const inPlaylist = Boolean(list) && videos.length > 0;
- const currentIdx = inPlaylist
- ? videos.findIndex((video) => toPublicWatchParam(video.url) === currentParam)
- : -1;
- const loadedPublicPageCount = publicPlaylist.data?.pages.length ?? 0;
- const canLoadPublicPage =
- !isManaged && Boolean(publicPlaylist.hasNextPage) && !publicPlaylist.isFetchingNextPage;
- const loadMorePublic = useCallback(() => {
- if (canLoadPublicPage) void publicPlaylist.fetchNextPage();
- }, [canLoadPublicPage, publicPlaylist.fetchNextPage]);
- const previousVideo = currentIdx > 0 ? videos[currentIdx - 1] : undefined;
- const nextVideo = currentIdx >= 0 ? videos[currentIdx + 1] : undefined;
- const previousParam = previousVideo ? toPublicWatchParam(previousVideo.url) : null;
- const nextParam = nextVideo ? toPublicWatchParam(nextVideo.url) : null;
- const playPrevious = useCallback(() => {
- if (!previousParam) return;
- markWatchAutoplayIntent();
- navigate({
- to: "/watch",
- search: { v: previousParam, list, ...(shuffle ? { shuffle } : {}) },
- resetScroll: false,
- });
- }, [previousParam, list, shuffle, navigate]);
- const playNext = useCallback(() => {
- if (!nextParam) return;
- markWatchAutoplayIntent();
- navigate({
- to: "/watch",
- search: { v: nextParam, list, ...(shuffle ? { shuffle } : {}) },
- resetScroll: false,
- });
- }, [nextParam, list, shuffle, navigate]);
-
- useEffect(() => {
- if (!canLoadPublicPage || videos.length === 0) return;
- const missingCurrent = currentIdx < 0 && loadedPublicPageCount < MISSING_CURRENT_PREFETCH_LIMIT;
- const nearEnd = currentIdx >= 0 && currentIdx >= videos.length - PREFETCH_REMAINING_ITEMS;
- if (missingCurrent || nearEnd) void publicPlaylist.fetchNextPage();
- }, [
- canLoadPublicPage,
- currentIdx,
- loadedPublicPageCount,
- videos.length,
- publicPlaylist.fetchNextPage,
- ]);
-
- const panel =
- inPlaylist && list ? (
-
- navigate({
- to: "/watch",
- search: { v: currentParam, list, ...(shuffle ? {} : { shuffle: randomShuffleSeed() }) },
- resetScroll: false,
- })
- }
- onReorder={(items) => {
- if (isManaged && list) reorder.mutate({ id: list, order: items.map((v) => v.url) });
- else if (list)
- setOrder(
- list,
- items.map((v) => v.key),
- );
- if (shuffle) {
- navigate({ to: "/watch", search: { v: currentParam, list }, resetScroll: false });
- }
- }}
- />
- ) : null;
-
- return {
- nextParam,
- nextVideo: nextVideo ?? null,
- playPrevious: previousParam ? playPrevious : undefined,
- playNext: nextParam ? playNext : undefined,
- panel,
- };
-}
diff --git a/apps/web/src/hooks/use-watch-progress-persistence.ts b/apps/web/src/hooks/use-watch-progress-persistence.ts
deleted file mode 100644
index 9d06160a..00000000
--- a/apps/web/src/hooks/use-watch-progress-persistence.ts
+++ /dev/null
@@ -1,96 +0,0 @@
-import { useCallback, useEffect, useRef } from "react";
-import { recordClientEvent } from "../lib/client-debug-log";
-
-type MutateFn = (positionMs: number, keepalive: boolean) => void;
-type SaveReason =
- | "interval"
- | "pagehide"
- | "pause"
- | "seeked"
- | "threshold"
- | "unmount"
- | "visibility";
-
-type Args = {
- durationSec: number;
- isLive: boolean;
- mutate: MutateFn;
-};
-
-export function useWatchProgressPersistence({ durationSec, isLive, mutate }: Args) {
- const positionRef = useRef(0);
- const lastSavedPositionRef = useRef(0);
- const lastSeekedSaveRef = useRef(0);
- const maxPositionSeenRef = useRef(0);
- const mutateRef = useRef(mutate);
- mutateRef.current = mutate;
-
- const saveRef = useRef<(reason: SaveReason) => void>(() => {});
- saveRef.current = (reason: SaveReason) => {
- const positionMs = Math.max(0, Math.round(positionRef.current));
- const durationMs = durationSec * 1000;
- if (!Number.isFinite(positionMs)) return;
- if (reason === "seeked" && positionMs < 5000 && maxPositionSeenRef.current >= 5000) {
- lastSavedPositionRef.current = 0;
- recordClientEvent("progress.save", { positionMs: 0, reason });
- mutateRef.current(0, false);
- return;
- }
- if (positionMs <= 0) return;
- if (positionMs >= durationMs * 0.95) return;
- if (positionMs < 5000) return;
- if (reason !== "seeked" && positionMs < lastSavedPositionRef.current) return;
- lastSavedPositionRef.current = positionMs;
- recordClientEvent("progress.save", { positionMs, reason });
- const keepalive = reason === "pagehide" || reason === "unmount" || reason === "visibility";
- mutateRef.current(positionMs, keepalive);
- };
-
- const handleTimeUpdate = useCallback(
- (positionMs: number) => {
- positionRef.current = positionMs;
- maxPositionSeenRef.current = Math.max(maxPositionSeenRef.current, positionMs);
- if (!isLive && positionMs >= 5000 && lastSavedPositionRef.current < 5000) {
- saveRef.current("threshold");
- }
- },
- [isLive],
- );
-
- const handlePause = useCallback(() => {
- saveRef.current("pause");
- }, []);
-
- const handleSeeked = useCallback(() => {
- const positionMs = Math.max(0, Math.round(positionRef.current));
- if (positionMs === lastSeekedSaveRef.current) return;
- lastSeekedSaveRef.current = positionMs;
- saveRef.current("seeked");
- }, []);
-
- useEffect(() => {
- if (isLive) return;
- const onVisibilityChange = () => {
- if (document.visibilityState === "hidden") saveRef.current("visibility");
- };
- const onPageHide = () => {
- saveRef.current("pagehide");
- };
- const interval = setInterval(() => saveRef.current("interval"), 5_000);
- document.addEventListener("visibilitychange", onVisibilityChange);
- window.addEventListener("pagehide", onPageHide);
- return () => {
- saveRef.current("unmount");
- clearInterval(interval);
- document.removeEventListener("visibilitychange", onVisibilityChange);
- window.removeEventListener("pagehide", onPageHide);
- };
- }, [isLive]);
-
- return {
- positionRef,
- handleTimeUpdate,
- handlePause,
- handleSeeked,
- };
-}
diff --git a/apps/web/src/hooks/use-watch-session-reporting.ts b/apps/web/src/hooks/use-watch-session-reporting.ts
deleted file mode 100644
index 22d47afd..00000000
--- a/apps/web/src/hooks/use-watch-session-reporting.ts
+++ /dev/null
@@ -1,126 +0,0 @@
-import { useCallback, useEffect, useRef } from "react";
-import {
- reportPlaybackProgress,
- reportPlaybackStart,
- reportPlaybackStop,
- type SessionPlaybackPayload,
- type SessionPlaybackStartPayload,
-} from "../lib/api-admin-sessions";
-import { getSessionDevicePayload } from "../lib/session-device";
-import type { VideoStream } from "../types/stream";
-import { useAuth } from "./use-auth";
-
-const PROGRESS_INTERVAL_MS = 15_000;
-
-type Args = {
- stream: VideoStream;
- isLive: boolean;
- onTimeUpdate: (positionMs: number) => void;
- onPause: () => void;
- onSeeked: () => void;
- onEnded: () => void;
-};
-
-function durationMs(durationSec: number): number | null {
- if (!Number.isFinite(durationSec) || durationSec <= 0) return null;
- return Math.round(durationSec * 1000);
-}
-
-export function useWatchSessionReporting({
- stream,
- isLive,
- onTimeUpdate,
- onPause,
- onSeeked,
- onEnded,
-}: Args) {
- const { status } = useAuth();
- const enabled = status === "authenticated" && !isLive;
- const startedRef = useRef(false);
- const lastReportRef = useRef(0);
- const positionRef = useRef(0);
-
- const buildPayload = useCallback(
- (positionMs: number, paused: boolean): SessionPlaybackStartPayload => ({
- ...getSessionDevicePayload(),
- videoUrl: stream.id,
- title: stream.title,
- thumbnail: stream.thumbnail,
- channelName: stream.channelName,
- positionMs: Math.max(0, Math.round(positionMs)),
- durationMs: durationMs(stream.duration),
- paused,
- }),
- [stream.channelName, stream.duration, stream.id, stream.thumbnail, stream.title],
- );
-
- const start = useCallback(
- (positionMs: number) => {
- if (!enabled || startedRef.current) return;
- startedRef.current = true;
- lastReportRef.current = Date.now();
- void reportPlaybackStart(buildPayload(positionMs, false)).catch(() => {});
- },
- [buildPayload, enabled],
- );
-
- const progress = useCallback(
- (positionMs: number, paused: boolean, force: boolean) => {
- if (!enabled) return;
- if (!startedRef.current) start(positionMs);
- const now = Date.now();
- if (!force && now - lastReportRef.current < PROGRESS_INTERVAL_MS) return;
- lastReportRef.current = now;
- const payload: SessionPlaybackPayload = buildPayload(positionMs, paused);
- void reportPlaybackProgress(payload).catch(() => {});
- },
- [buildPayload, enabled, start],
- );
-
- const handleTimeUpdate = useCallback(
- (positionMs: number) => {
- positionRef.current = positionMs;
- onTimeUpdate(positionMs);
- progress(positionMs, false, false);
- },
- [onTimeUpdate, progress],
- );
-
- const handlePause = useCallback(() => {
- onPause();
- progress(positionRef.current, true, true);
- }, [onPause, progress]);
-
- const handleSeeked = useCallback(() => {
- onSeeked();
- progress(positionRef.current, false, true);
- }, [onSeeked, progress]);
-
- const handleEnded = useCallback(() => {
- if (enabled && startedRef.current) {
- startedRef.current = false;
- void reportPlaybackStop(getSessionDevicePayload()).catch(() => {});
- }
- onEnded();
- }, [enabled, onEnded]);
-
- useEffect(() => {
- const videoUrl = stream.id;
- startedRef.current = false;
- lastReportRef.current = 0;
- positionRef.current = 0;
- if (!enabled) return;
- const stop = () => {
- if (!startedRef.current || !videoUrl) return;
- startedRef.current = false;
- void reportPlaybackStop(getSessionDevicePayload()).catch(() => {});
- };
- window.addEventListener("pagehide", stop);
- return () => {
- window.removeEventListener("pagehide", stop);
- stop();
- };
- }, [enabled, stream.id]);
-
- return { handleTimeUpdate, handlePause, handleSeeked, handleEnded };
-}
diff --git a/apps/web/src/hooks/use-watch-source-start-time.ts b/apps/web/src/hooks/use-watch-source-start-time.ts
deleted file mode 100644
index 8772a3e6..00000000
--- a/apps/web/src/hooks/use-watch-source-start-time.ts
+++ /dev/null
@@ -1,61 +0,0 @@
-import type { MutableRefObject } from "react";
-import { useRef } from "react";
-
-type Args = {
- streamId: string;
- sourceKey: string;
- retryKey: number;
- startTime: number;
- positionRef: MutableRefObject;
-};
-
-type SourceState = {
- streamId: string;
- sourceKey: string;
- retryKey: number;
- revision: number;
- startTime: number;
-};
-
-function validPosition(position: number) {
- return Number.isFinite(position) ? Math.max(0, position) : 0;
-}
-
-export function useWatchSourceStartTime({
- streamId,
- sourceKey,
- retryKey,
- startTime,
- positionRef,
-}: Args) {
- const stateRef = useRef(null);
- const requestedStartTime = validPosition(startTime);
- const currentPosition = validPosition(positionRef.current);
- const state = stateRef.current;
-
- if (!state || state.streamId !== streamId || state.retryKey !== retryKey) {
- stateRef.current = {
- streamId,
- sourceKey,
- retryKey,
- revision: 0,
- startTime: requestedStartTime,
- };
- } else if (state.sourceKey !== sourceKey) {
- stateRef.current = {
- streamId,
- sourceKey,
- retryKey,
- revision: state.revision + 1,
- startTime: currentPosition > 0 ? currentPosition : requestedStartTime,
- };
- }
-
- const current = stateRef.current;
- if (!current) return { keyPart: `${sourceKey}:0`, startTime: requestedStartTime };
-
- return {
- keyPart: `${current.sourceKey}:${current.revision}`,
- startTime: current.startTime,
- };
-}
diff --git a/apps/web/src/hooks/use-watch-sponsorblock.ts b/apps/web/src/hooks/use-watch-sponsorblock.ts
deleted file mode 100644
index 5ca5904c..00000000
--- a/apps/web/src/hooks/use-watch-sponsorblock.ts
+++ /dev/null
@@ -1,29 +0,0 @@
-import { useMemo } from "react";
-import {
- filterAutoSkipSponsorBlockSegments,
- filterManualSponsorBlockSegments,
- filterSponsorBlockSegments,
-} from "../lib/sponsorblock-settings";
-import type { VideoStream } from "../types/stream";
-import type { SettingsItem } from "../types/user";
-
-export function useWatchSponsorBlock(stream: VideoStream, settings: SettingsItem) {
- const segments = useMemo(
- () => filterSponsorBlockSegments(stream.sponsorBlockSegments, settings, stream.duration),
- [stream.sponsorBlockSegments, stream.duration, settings],
- );
- const autoSkipSegments = useMemo(
- () => filterAutoSkipSponsorBlockSegments(segments, settings, stream.duration, stream.category),
- [segments, settings, stream.duration, stream.category],
- );
- const manualSkipSegments = useMemo(
- () => filterManualSponsorBlockSegments(segments, settings, stream.duration),
- [segments, settings, stream.duration],
- );
-
- return {
- segments,
- autoSkipSegments,
- manualSkipSegments,
- };
-}
diff --git a/apps/web/src/hooks/use-watch-toast.ts b/apps/web/src/hooks/use-watch-toast.ts
deleted file mode 100644
index 40336cff..00000000
--- a/apps/web/src/hooks/use-watch-toast.ts
+++ /dev/null
@@ -1,18 +0,0 @@
-import { useEffect, useState } from "react";
-
-export function useWatchToast(audioOnlyUnavailable: boolean) {
- const [toast, setToast] = useState(null);
-
- useEffect(() => {
- if (!toast) return;
- const timer = setTimeout(() => setToast(null), 2600);
- return () => clearTimeout(timer);
- }, [toast]);
-
- useEffect(() => {
- if (!audioOnlyUnavailable) return;
- setToast("Audio-only is unavailable for this video");
- }, [audioOnlyUnavailable]);
-
- return { toast, setToast };
-}
diff --git a/apps/web/src/hooks/use-youtube-remote-browser.ts b/apps/web/src/hooks/use-youtube-remote-browser.ts
deleted file mode 100644
index 141a1b60..00000000
--- a/apps/web/src/hooks/use-youtube-remote-browser.ts
+++ /dev/null
@@ -1,168 +0,0 @@
-import { useCallback, useEffect, useRef, useState } from "react";
-import { recordClientEvent } from "../lib/client-debug-log";
-
-export type YoutubeRemotePhase =
- | "idle"
- | "connecting"
- | "opening"
- | "awaiting_login"
- | "capturing_session"
- | "connected"
- | "closed"
- | "error";
-
-export type YoutubeRemoteInput =
- | { type: "resize"; width: number; height: number }
- | { type: "pointer"; event: "down" | "up" | "move"; x: number; y: number; button: "left" }
- | { type: "wheel"; deltaX: number; deltaY: number }
- | { type: "key"; event: "down" | "up"; key: string; code: string; modifiers: string[] }
- | { type: "text"; value: string }
- | { type: "cancel" };
-
-type RemoteStatus = {
- type: "status";
- phase: YoutubeRemotePhase;
-};
-
-type RemoteError = {
- type: "error";
- message: string;
-};
-
-function isYoutubeRemotePhase(value: string): value is YoutubeRemotePhase {
- return (
- value === "idle" ||
- value === "connecting" ||
- value === "opening" ||
- value === "awaiting_login" ||
- value === "capturing_session" ||
- value === "connected" ||
- value === "closed" ||
- value === "error"
- );
-}
-
-function parseRemoteMessage(value: string): RemoteStatus | RemoteError | null {
- let parsed: unknown;
- try {
- parsed = JSON.parse(value);
- } catch {
- return null;
- }
- if (!parsed || typeof parsed !== "object" || !("type" in parsed)) return null;
- if (
- parsed.type === "status" &&
- "phase" in parsed &&
- typeof parsed.phase === "string" &&
- isYoutubeRemotePhase(parsed.phase)
- ) {
- return { type: "status", phase: parsed.phase };
- }
- if (parsed.type === "error" && "message" in parsed && typeof parsed.message === "string") {
- return { type: "error", message: parsed.message };
- }
- return null;
-}
-
-export function useYoutubeRemoteBrowser(wsUrl: string | null) {
- const wsRef = useRef(null);
- const frameRef = useRef(null);
- const inputCountRef = useRef(0);
- const [phase, setPhase] = useState(wsUrl ? "connecting" : "idle");
- const [frameUrl, setFrameUrl] = useState(null);
- const [error, setError] = useState(null);
-
- useEffect(() => {
- if (!wsUrl) {
- setPhase("idle");
- setError(null);
- return;
- }
-
- let active = true;
- let finished = false;
- let frameCount = 0;
- setPhase("connecting");
- setError(null);
- const ws = new WebSocket(wsUrl);
- ws.binaryType = "blob";
- wsRef.current = ws;
-
- recordClientEvent("youtube_remote.ws_connecting", { hasUrl: true });
-
- ws.onopen = () => {
- recordClientEvent("youtube_remote.ws_open");
- };
-
- ws.onmessage = (event) => {
- if (typeof event.data === "string") {
- const message = parseRemoteMessage(event.data);
- if (message?.type === "status") {
- if (message.phase === "connected") finished = true;
- setPhase(message.phase);
- recordClientEvent("youtube_remote.status", { phase: message.phase });
- }
- if (message?.type === "error") {
- setPhase("error");
- setError(message.message);
- recordClientEvent("youtube_remote.backend_error", { message: message.message });
- }
- return;
- }
- const blob = event.data instanceof Blob ? event.data : new Blob([event.data]);
- const nextUrl = URL.createObjectURL(blob);
- if (frameRef.current) URL.revokeObjectURL(frameRef.current);
- frameRef.current = nextUrl;
- setFrameUrl(nextUrl);
- frameCount += 1;
- if (frameCount === 1 || frameCount % 50 === 0) {
- recordClientEvent("youtube_remote.frame", { count: frameCount, bytes: blob.size });
- }
- };
-
- ws.onerror = () => {
- finished = true;
- setPhase("error");
- setError("Remote browser connection failed");
- recordClientEvent("youtube_remote.ws_error");
- };
-
- ws.onclose = () => {
- recordClientEvent("youtube_remote.ws_close", { finished });
- if (active && !finished) setPhase("closed");
- };
-
- return () => {
- active = false;
- ws.close();
- wsRef.current = null;
- if (frameRef.current) URL.revokeObjectURL(frameRef.current);
- frameRef.current = null;
- setFrameUrl(null);
- };
- }, [wsUrl]);
-
- const send = useCallback((message: YoutubeRemoteInput) => {
- const ws = wsRef.current;
- if (!ws || ws.readyState !== WebSocket.OPEN) {
- recordClientEvent("youtube_remote.input_dropped", { type: message.type });
- return false;
- }
- ws.send(JSON.stringify(message));
- inputCountRef.current += 1;
- if (
- message.type !== "pointer" ||
- message.event !== "move" ||
- inputCountRef.current % 25 === 0
- ) {
- recordClientEvent("youtube_remote.input_sent", {
- type: message.type,
- event: "event" in message ? message.event : null,
- length: message.type === "text" ? message.value.length : null,
- });
- }
- return true;
- }, []);
-
- return { phase, frameUrl, error, send };
-}
diff --git a/apps/web/src/hooks/use-youtube-session.ts b/apps/web/src/hooks/use-youtube-session.ts
deleted file mode 100644
index 20c125cc..00000000
--- a/apps/web/src/hooks/use-youtube-session.ts
+++ /dev/null
@@ -1,36 +0,0 @@
-import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
-import {
- cancelYoutubeSessionBrowser,
- disconnectYoutubeSession,
- fetchYoutubeSessionStatus,
- startYoutubeSessionBrowser,
-} from "../lib/api-youtube-session";
-import { useAuth } from "./use-auth";
-
-const YOUTUBE_SESSION_KEY = ["youtube-session"];
-
-export function useYoutubeSession() {
- const qc = useQueryClient();
- const { authReady, isAuthed } = useAuth();
-
- const status = useQuery({
- queryKey: YOUTUBE_SESSION_KEY,
- queryFn: fetchYoutubeSessionStatus,
- enabled: authReady && isAuthed,
- });
-
- const startBrowser = useMutation({
- mutationFn: startYoutubeSessionBrowser,
- });
-
- const cancelBrowser = useMutation({
- mutationFn: cancelYoutubeSessionBrowser,
- });
-
- const disconnect = useMutation({
- mutationFn: disconnectYoutubeSession,
- onSuccess: () => qc.invalidateQueries({ queryKey: YOUTUBE_SESSION_KEY }),
- });
-
- return { status, startBrowser, cancelBrowser, disconnect };
-}
diff --git a/apps/web/src/hooks/use-youtube-takeout-import.ts b/apps/web/src/hooks/use-youtube-takeout-import.ts
deleted file mode 100644
index c0e078d1..00000000
--- a/apps/web/src/hooks/use-youtube-takeout-import.ts
+++ /dev/null
@@ -1,90 +0,0 @@
-import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
-import {
- commitYoutubeTakeoutImport,
- createYoutubeTakeoutImport,
- fetchYoutubeTakeoutImportJob,
- fetchYoutubeTakeoutPreview,
- fetchYoutubeTakeoutReport,
- type YoutubeTakeoutImportJob,
- type YoutubeTakeoutPreview,
- type YoutubeTakeoutReport,
-} from "../lib/api-youtube-import";
-
-const INVALIDATE_KEYS = [
- ["subscriptions"],
- ["subscription-feed"],
- ["playlists"],
- ["history"],
- ["history-all"],
-] as const;
-
-type FlowState = {
- jobId: string | null;
- preview: YoutubeTakeoutPreview | null;
- report: YoutubeTakeoutReport | null;
-};
-
-export function useYoutubeTakeoutImport() {
- const qc = useQueryClient();
- const flow = useQuery({
- queryKey: ["youtube-import-flow"],
- queryFn: async () => ({ jobId: null, preview: null, report: null }) satisfies FlowState,
- staleTime: Infinity,
- });
-
- function setFlow(patch: Partial) {
- const current = qc.getQueryData(["youtube-import-flow"]) ?? {
- jobId: null,
- preview: null,
- report: null,
- };
- qc.setQueryData(["youtube-import-flow"], { ...current, ...patch });
- }
-
- const create = useMutation({
- mutationFn: (file: File) => createYoutubeTakeoutImport(file),
- onSuccess: (job) => setFlow({ jobId: job.jobId, preview: null, report: null }),
- });
-
- const status = useQuery({
- queryKey: ["youtube-import-status", flow.data?.jobId],
- queryFn: () => {
- const id = flow.data?.jobId;
- if (!id) throw new Error("Missing YouTube import job id");
- return fetchYoutubeTakeoutImportJob(id);
- },
- enabled: Boolean(flow.data?.jobId),
- refetchInterval: (query) => {
- const data = query.state.data;
- if (!data) return 2500;
- return data.status === "pending" || data.status === "running" ? 2500 : false;
- },
- });
-
- const preview = useMutation({
- mutationFn: (jobId: string) => fetchYoutubeTakeoutPreview(jobId),
- onSuccess: (data) => setFlow({ preview: data }),
- });
-
- const commit = useMutation({ mutationFn: (jobId: string) => commitYoutubeTakeoutImport(jobId) });
-
- const report = useMutation({
- mutationFn: (jobId: string) => fetchYoutubeTakeoutReport(jobId),
- onSuccess: async (data) => {
- setFlow({ report: data });
- await Promise.all(
- INVALIDATE_KEYS.map((queryKey) => qc.invalidateQueries({ queryKey: [...queryKey] })),
- );
- },
- });
-
- return {
- flow: flow.data ?? { jobId: null, preview: null, report: null },
- create,
- status,
- preview,
- commit,
- report,
- clear: () => setFlow({ jobId: null, preview: null, report: null }),
- };
-}
diff --git a/apps/web/src/hooks/use-zen-ambient.ts b/apps/web/src/hooks/use-zen-ambient.ts
deleted file mode 100644
index ee617c3f..00000000
--- a/apps/web/src/hooks/use-zen-ambient.ts
+++ /dev/null
@@ -1,48 +0,0 @@
-import { useCallback, useEffect, useRef, useState } from "react";
-
-const ZEN_URL: string =
- "https://7145jo0azv.ufs.sh/f/pyEcI1anZvJ0Sth0RGo0LGMqC9F4HEaiwJNkhyRBfXxPZjIu";
-
-export function useZenAmbient() {
- const audioRef = useRef(null);
- const [playing, setPlaying] = useState(false);
-
- useEffect(() => {
- if (!ZEN_URL) {
- return;
- }
- const audio = new Audio(ZEN_URL);
- audio.loop = true;
- audio.volume = 0.5;
- audio.preload = "auto";
- audioRef.current = audio;
- const onPlay = () => setPlaying(true);
- const onPause = () => setPlaying(false);
- audio.addEventListener("play", onPlay);
- audio.addEventListener("pause", onPause);
- return () => {
- audio.removeEventListener("play", onPlay);
- audio.removeEventListener("pause", onPause);
- audio.pause();
- audioRef.current = null;
- };
- }, []);
-
- const start = useCallback(() => {
- audioRef.current?.play().catch(() => undefined);
- }, []);
-
- const toggle = useCallback(() => {
- const audio = audioRef.current;
- if (!audio) {
- return;
- }
- if (audio.paused) {
- audio.play().catch(() => undefined);
- } else {
- audio.pause();
- }
- }, []);
-
- return { playing, start, toggle };
-}
diff --git a/apps/web/src/index.css b/apps/web/src/index.css
deleted file mode 100644
index 84d715d6..00000000
--- a/apps/web/src/index.css
+++ /dev/null
@@ -1,208 +0,0 @@
-@import "tailwindcss";
-@import "@vidstack/react/player/styles/default/theme.css";
-@import "@vidstack/react/player/styles/default/layouts/audio.css";
-@import "@vidstack/react/player/styles/default/layouts/video.css";
-@import "./styles/theme.css";
-@import "./styles/shorts-overrides.css";
-@import "./styles/player-menu-overrides.css";
-@import "./styles/audio-only-player.css";
-@import "./styles/audio-only-controls.css";
-@import "./styles/adaptive-audio-layout.css";
-@import "./styles/audio-only-player-mobile.css";
-@import "./styles/player-mode-transition.css";
-@import "./styles/player-volume-controls.css";
-@import "./styles/sabr-seek-state.css";
-@import "./styles/video-player-mobile.css";
-@import "./styles/player-hotkeys.css";
-@import "./styles/autoplay-overlay.css";
-@import "./styles/pixel-scene-enter.css";
-@import "./styles/pixel-scene-loop.css";
-@import "./styles/pixel-intro.css";
-@import "./styles/pixel-dog.css";
-
-@keyframes card-pop-in {
- from {
- opacity: 0;
- transform: scale(0.94) translateY(10px);
- }
- to {
- opacity: 1;
- transform: scale(1) translateY(0);
- }
-}
-
-@theme {
- --animate-card-pop-in: card-pop-in 0.4s cubic-bezier(0.34, 1.56, 0.64, 1) both;
-}
-
-html,
-body,
-#root {
- min-height: 100%;
- background: var(--color-zinc-950);
-}
-
-html,
-body {
- overscroll-behavior-y: none;
-}
-
-@keyframes danmaku-scroll {
- from {
- transform: translateX(var(--d-from));
- }
- to {
- transform: translateX(var(--d-to));
- }
-}
-
-@keyframes page-fade-in {
- from {
- opacity: 0;
- transform: translateY(6px);
- }
- to {
- opacity: 1;
- transform: translateY(0);
- }
-}
-
-@keyframes player-source-enter {
- from {
- opacity: 0.72;
- transform: scale(1.006);
- filter: blur(5px);
- }
- to {
- opacity: 1;
- transform: scale(1);
- filter: blur(0);
- }
-}
-
-.typetype-player-surface {
- animation: player-source-enter 340ms cubic-bezier(0.22, 1, 0.36, 1);
-}
-
-@keyframes toast-fade-in {
- from {
- opacity: 0;
- transform: translateY(8px);
- }
- to {
- opacity: 1;
- transform: translateY(0);
- }
-}
-
-@keyframes dropdown-fade-in {
- from {
- opacity: 0;
- }
- to {
- opacity: 1;
- }
-}
-
-@keyframes admin-panel-slide-in {
- from {
- opacity: 0;
- transform: translateX(22px) scale(0.98);
- }
- to {
- opacity: 1;
- transform: translateX(0) scale(1);
- }
-}
-
-@keyframes admin-actions-pop {
- from {
- opacity: 0;
- transform: translateY(-4px) scale(0.97);
- }
- to {
- opacity: 1;
- transform: translateY(0) scale(1);
- }
-}
-
-@keyframes onboarding-step-enter {
- from {
- opacity: 0;
- transform: translateX(16px) scale(0.985);
- }
- to {
- opacity: 1;
- transform: translateX(0) scale(1);
- }
-}
-
-@keyframes download-pop-in {
- from {
- opacity: 0;
- transform: translateY(6px) scale(0.98);
- }
- to {
- opacity: 1;
- transform: translateY(0) scale(1);
- }
-}
-
-@keyframes download-pop-out {
- from {
- opacity: 1;
- transform: translateY(0) scale(1);
- }
- to {
- opacity: 0;
- transform: translateY(10px) scale(0.975);
- }
-}
-
-@keyframes download-item-pop {
- from {
- opacity: 0;
- transform: translateY(5px) scale(0.99);
- }
- to {
- opacity: 1;
- transform: translateY(0) scale(1);
- }
-}
-
-* {
- scrollbar-width: thin;
- scrollbar-color: var(--color-zinc-600) var(--color-zinc-950);
-}
-
-*::-webkit-scrollbar {
- width: 8px;
- height: 8px;
-}
-
-*::-webkit-scrollbar-track {
- background: var(--color-zinc-950);
-}
-
-*::-webkit-scrollbar-thumb {
- background: var(--color-zinc-600);
- border-radius: 3px;
-}
-
-*::-webkit-scrollbar-thumb:hover {
- background: var(--color-zinc-500);
-}
-
-*::-webkit-scrollbar-corner {
- background: var(--color-zinc-950);
-}
-
-.vds-playback-menu .vds-menu-section:has(.vds-quality-slider) {
- display: none;
-}
-
-@media (max-width: 639px) {
- .vds-video-layout .typetype-track-button {
- display: none;
- }
-}
diff --git a/apps/web/src/lib/admin-bug-report-filter.ts b/apps/web/src/lib/admin-bug-report-filter.ts
deleted file mode 100644
index 1646a380..00000000
--- a/apps/web/src/lib/admin-bug-report-filter.ts
+++ /dev/null
@@ -1,31 +0,0 @@
-import type { BugReportCategory, BugReportListItem, BugReportStatus } from "../types/bug-report";
-
-type Params = {
- status?: BugReportStatus;
- category?: BugReportCategory;
- query: string;
-};
-
-function normalize(value: string): string {
- return value.trim().toLowerCase();
-}
-
-export function filterBugReports(items: BugReportListItem[], params: Params): BugReportListItem[] {
- const q = normalize(params.query);
- return items.filter((report) => {
- if (params.status && report.status !== params.status) return false;
- if (params.category && report.category !== params.category) return false;
- if (!q) return true;
- const haystack = [
- report.id,
- report.userEmail,
- report.description,
- report.category,
- report.status,
- report.githubIssueUrl ?? "",
- ]
- .join(" ")
- .toLowerCase();
- return haystack.includes(q);
- });
-}
diff --git a/apps/web/src/lib/admin-console-section.ts b/apps/web/src/lib/admin-console-section.ts
deleted file mode 100644
index 73d4fa2d..00000000
--- a/apps/web/src/lib/admin-console-section.ts
+++ /dev/null
@@ -1,24 +0,0 @@
-export type AdminSection = "settings" | "allow-list" | "users" | "sessions" | "issues";
-
-const ADMIN_SECTION_KEY = "typetype-admin-section";
-
-export function isAdminSection(value: unknown): value is AdminSection {
- return (
- value === "settings" ||
- value === "allow-list" ||
- value === "users" ||
- value === "sessions" ||
- value === "issues"
- );
-}
-
-export function getStoredAdminSection(): AdminSection {
- if (typeof window === "undefined") return "issues";
- const stored = window.localStorage.getItem(ADMIN_SECTION_KEY);
- return isAdminSection(stored) ? stored : "issues";
-}
-
-export function rememberAdminSection(section: AdminSection) {
- if (typeof window === "undefined") return;
- window.localStorage.setItem(ADMIN_SECTION_KEY, section);
-}
diff --git a/apps/web/src/lib/admin-console.ts b/apps/web/src/lib/admin-console.ts
deleted file mode 100644
index 0bbeb31c..00000000
--- a/apps/web/src/lib/admin-console.ts
+++ /dev/null
@@ -1,37 +0,0 @@
-import type { AuthRole, AuthUser } from "../types/auth";
-
-export type AdminFilter = "all" | AuthRole | "suspended";
-
-export const ADMIN_FILTERS: { value: AdminFilter; label: string }[] = [
- { value: "all", label: "All" },
- { value: "admin", label: "Admins" },
- { value: "moderator", label: "Mods" },
- { value: "user", label: "Users" },
- { value: "suspended", label: "Suspended" },
-];
-
-export function isAdminFilter(value: string): value is AdminFilter {
- return (
- value === "all" ||
- value === "admin" ||
- value === "moderator" ||
- value === "user" ||
- value === "suspended"
- );
-}
-
-export function matchesAdminFilter(user: AuthUser, filter: AdminFilter): boolean {
- if (filter === "all") return true;
- if (filter === "suspended") return user.suspended;
- return user.role === filter;
-}
-
-export function formatCreatedAt(value: number | string): string {
- const date = new Date(value);
- if (Number.isNaN(date.getTime())) return "Unknown";
- return new Intl.DateTimeFormat("en-US", {
- month: "short",
- day: "numeric",
- year: "numeric",
- }).format(date);
-}
diff --git a/apps/web/src/lib/allow-list-error.ts b/apps/web/src/lib/allow-list-error.ts
deleted file mode 100644
index 69e81153..00000000
--- a/apps/web/src/lib/allow-list-error.ts
+++ /dev/null
@@ -1,12 +0,0 @@
-import { ApiError } from "./api";
-
-export const FAMILY_LIST_BLOCKED_MESSAGE =
- "This channel is not on your family list. A parent can add it from the allow list.";
-
-export function isChannelNotAllowedError(error: unknown): boolean {
- return (
- error instanceof ApiError &&
- error.status === 403 &&
- error.message.toLowerCase().includes("channel is not allowed")
- );
-}
diff --git a/apps/web/src/lib/api-account-identity.ts b/apps/web/src/lib/api-account-identity.ts
deleted file mode 100644
index 6ddd806f..00000000
--- a/apps/web/src/lib/api-account-identity.ts
+++ /dev/null
@@ -1,47 +0,0 @@
-import { ApiError } from "./api";
-import { authed, authedJson } from "./authed";
-import { API_BASE as BASE } from "./env";
-
-export type AccountIdentity = {
- email: string;
- name: string;
- managedByOidc: boolean;
-};
-
-export type AccountIdentityUpdate = {
- email: string;
- name: string;
- currentPassword: string;
-};
-
-export function fetchAccountIdentity(): Promise {
- return authedJson(`${BASE}/profile/account`);
-}
-
-export async function updateAccountIdentity(payload: AccountIdentityUpdate): Promise {
- const response = await authed(`${BASE}/profile/account`, {
- method: "PUT",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify(payload),
- });
- if (response.status === 204) return;
- const body = await response.json().catch(() => null);
- const message =
- body && typeof body === "object" && "error" in body && typeof body.error === "string"
- ? body.error
- : "Unable to update account";
- throw new ApiError(message, response.status);
-}
-
-export async function updateAdminIdentity(
- userId: string,
- payload: Pick,
-): Promise {
- const response = await authed(`${BASE}/admin/users/${encodeURIComponent(userId)}/identity`, {
- method: "PUT",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify(payload),
- });
- if (response.status === 204) return;
- throw new ApiError("Unable to update user identity", response.status);
-}
diff --git a/apps/web/src/lib/api-admin-allow-list.ts b/apps/web/src/lib/api-admin-allow-list.ts
deleted file mode 100644
index 7526c7bb..00000000
--- a/apps/web/src/lib/api-admin-allow-list.ts
+++ /dev/null
@@ -1,101 +0,0 @@
-import type {
- AdminAllowListUser,
- AdminManagedAccessUsersPage,
- AdminUserAllowList,
- AllowedPlaylistItem,
- AllowPlaylistInput,
-} from "../types/allow-list";
-import type { AccessMode } from "../types/user";
-import { ApiError } from "./api";
-import { authed, authedJson } from "./authed";
-import { API_BASE as BASE } from "./env";
-
-export function searchAdminUsers(q: string, limit = 20): Promise {
- const params = new URLSearchParams({ q, limit: String(limit) });
- return authedJson(`${BASE}/admin/users/search?${params}`);
-}
-
-export function fetchAdminManagedAccessUsers(
- limit = 100,
- page?: string,
-): Promise {
- const params = new URLSearchParams({ limit: String(limit) });
- if (page) params.set("page", page);
- return authedJson(`${BASE}/admin/users/managed-access?${params}`);
-}
-
-export function fetchAdminUserAllowList(id: string): Promise {
- return authedJson(`${BASE}/admin/users/${encodeURIComponent(id)}/allow-list`);
-}
-
-export async function updateAdminUserAccessMode(
- id: string,
- accessMode: AccessMode,
-): Promise {
- const res = await authed(`${BASE}/admin/users/${encodeURIComponent(id)}/access-mode`, {
- method: "PUT",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ accessMode }),
- });
- const body = (await res.json().catch(() => ({ accessMode }))) as Partial<{
- accessMode: AccessMode;
- }>;
- if (!res.ok) throw new ApiError("Failed to update access mode", res.status);
- return body.accessMode === "allow_list" ? "allow_list" : "unrestricted";
-}
-
-export function addAdminUserAllowedChannel(
- id: string,
- url: string,
- name?: string | null,
- thumbnailUrl?: string | null,
-) {
- return authedJson(`${BASE}/admin/users/${encodeURIComponent(id)}/allowed/channels`, {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ url, name, thumbnailUrl }),
- });
-}
-
-export async function removeAdminUserAllowedChannel(id: string, channelUrl: string): Promise {
- const res = await authed(
- `${BASE}/admin/users/${encodeURIComponent(id)}/allowed/channels/${encodeURIComponent(channelUrl)}`,
- { method: "DELETE" },
- );
- if (!res.ok && res.status !== 404) throw new ApiError("Failed to remove channel", res.status);
-}
-
-export function fetchAdminAllowedPlaylists(): Promise {
- return authedJson(`${BASE}/admin/allowed/playlists`);
-}
-
-export function addAdminAllowedPlaylist(input: AllowPlaylistInput): Promise {
- return authedJson(`${BASE}/admin/allowed/playlists`, {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify(input),
- });
-}
-
-export async function removeAdminAllowedPlaylist(url: string): Promise {
- const res = await authed(`${BASE}/admin/allowed/playlists/${encodeURIComponent(url)}`, {
- method: "DELETE",
- });
- if (!res.ok && res.status !== 404) throw new ApiError("Failed to remove playlist", res.status);
-}
-
-export function addAdminUserAllowedPlaylist(id: string, input: AllowPlaylistInput) {
- return authedJson(`${BASE}/admin/users/${encodeURIComponent(id)}/allowed/playlists`, {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify(input),
- });
-}
-
-export async function removeAdminUserAllowedPlaylist(id: string, url: string): Promise {
- const res = await authed(
- `${BASE}/admin/users/${encodeURIComponent(id)}/allowed/playlists/${encodeURIComponent(url)}`,
- { method: "DELETE" },
- );
- if (!res.ok && res.status !== 404) throw new ApiError("Failed to remove playlist", res.status);
-}
diff --git a/apps/web/src/lib/api-admin-sessions.ts b/apps/web/src/lib/api-admin-sessions.ts
deleted file mode 100644
index b80ae109..00000000
--- a/apps/web/src/lib/api-admin-sessions.ts
+++ /dev/null
@@ -1,111 +0,0 @@
-import type { AdminSession } from "../types/admin";
-import { ApiError } from "./api";
-import { authed, authedJson } from "./authed";
-import { API_BASE as BASE } from "./env";
-
-export type SessionDevicePayload = {
- clientName?: string;
- clientVersion?: string;
- deviceId?: string;
- deviceName?: string;
- deviceType?: string;
-};
-
-export type SessionPlaybackPayload = SessionDevicePayload & {
- videoUrl?: string;
- title?: string;
- thumbnail?: string | null;
- channelName?: string | null;
- positionMs?: number;
- durationMs?: number | null;
- paused?: boolean;
-};
-
-export type SessionPlaybackStartPayload = SessionDevicePayload & {
- videoUrl: string;
- title: string;
- thumbnail?: string | null;
- channelName?: string | null;
- positionMs?: number;
- durationMs?: number | null;
- paused?: boolean;
-};
-
-function isRecord(value: unknown): value is Record {
- return !!value && typeof value === "object";
-}
-
-function isNullableString(value: unknown): value is string | null | undefined {
- return value === null || value === undefined || typeof value === "string";
-}
-
-function isNullableNumber(value: unknown): value is number | null | undefined {
- return value === null || value === undefined || typeof value === "number";
-}
-
-function isNowPlaying(value: unknown): value is NonNullable {
- if (!isRecord(value)) return false;
- return (
- typeof value.videoUrl === "string" &&
- typeof value.title === "string" &&
- isNullableString(value.thumbnail) &&
- isNullableString(value.channelName) &&
- typeof value.positionMs === "number" &&
- isNullableNumber(value.durationMs) &&
- typeof value.paused === "boolean" &&
- typeof value.updatedAt === "number"
- );
-}
-
-function isAdminSession(value: unknown): value is AdminSession {
- if (!isRecord(value)) return false;
- const nowPlaying = value.nowPlaying;
- return (
- typeof value.id === "string" &&
- isNullableString(value.userId) &&
- isNullableString(value.username) &&
- isNullableString(value.clientName) &&
- isNullableString(value.clientVersion) &&
- isNullableString(value.deviceId) &&
- isNullableString(value.deviceName) &&
- isNullableString(value.deviceType) &&
- isNullableString(value.userAgent) &&
- isNullableString(value.remoteAddress) &&
- typeof value.lastActivityAt === "number" &&
- isNullableNumber(value.lastPlaybackAt) &&
- (nowPlaying === null || nowPlaying === undefined || isNowPlaying(nowPlaying))
- );
-}
-
-export async function fetchAdminSessions(): Promise