🎉 Release KatelyaTV v2.0.0 - Major Update with IPTV Support
✨ New Features: - 📺 IPTV Live TV support with M3U playlist import - 🎮 Advanced channel management and favorites - �� Mobile-optimized IPTV player - 🔄 Multiple import methods (URL/File upload) 🛠️ Technical Improvements: - 🚀 Cloudflare Pages optimization (removed Docker) - 📱 iOS Safari compatibility fixes - 🎨 Modern UI/UX enhancements - ⚡ Performance optimizations 🔧 Development: - 📦 Updated to v2.0.0 - 📚 Comprehensive documentation update - 🛡️ Enhanced security and error handling - 🌐 Better responsive design Breaking Changes: - Removed Docker deployment support - Focus on Cloudflare Pages deployment - Updated environment variables This release transforms KatelyaTV into a comprehensive streaming platform with both VOD and live TV capabilities.pull/2/head
parent
303263d513
commit
d811fb5aa6
|
@ -0,0 +1,70 @@
|
||||||
|
name: Deploy to Cloudflare Pages
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
paths-ignore:
|
||||||
|
- '**.md'
|
||||||
|
- '.github/**'
|
||||||
|
- '!.github/workflows/cloudflare-deploy.yml'
|
||||||
|
pull_request:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
paths-ignore:
|
||||||
|
- '**.md'
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.workflow }}-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-and-deploy:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
name: Build and Deploy to Cloudflare Pages
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '20'
|
||||||
|
|
||||||
|
- name: Setup pnpm
|
||||||
|
uses: pnpm/action-setup@v4
|
||||||
|
with:
|
||||||
|
version: latest
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Generate runtime configuration
|
||||||
|
run: pnpm run gen:runtime
|
||||||
|
|
||||||
|
- name: Generate manifest
|
||||||
|
run: pnpm run gen:manifest
|
||||||
|
|
||||||
|
- name: Build for Cloudflare Pages
|
||||||
|
run: pnpm run pages:build
|
||||||
|
|
||||||
|
- name: Deploy to Cloudflare Pages
|
||||||
|
if: github.event_name != 'pull_request'
|
||||||
|
uses: cloudflare/wrangler-action@v3
|
||||||
|
with:
|
||||||
|
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||||
|
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||||
|
command: pages deploy .vercel/output/static --project-name=katelyatv
|
||||||
|
|
||||||
|
- name: Build Summary
|
||||||
|
run: |
|
||||||
|
echo "✅ Build completed successfully!"
|
||||||
|
echo "📦 Optimized for Cloudflare Pages"
|
||||||
|
echo "🔄 Static generation enabled"
|
||||||
|
if [ "${{ github.event_name }}" != "pull_request" ]; then
|
||||||
|
echo "🚀 Deployed to Cloudflare Pages"
|
||||||
|
else
|
||||||
|
echo "🧪 Build test completed (no deployment for PR)"
|
||||||
|
fi
|
|
@ -1,84 +0,0 @@
|
||||||
name: Docker Build & Test
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- main
|
|
||||||
paths-ignore:
|
|
||||||
- '**.md'
|
|
||||||
pull_request:
|
|
||||||
branches:
|
|
||||||
- main
|
|
||||||
paths-ignore:
|
|
||||||
- '**.md'
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
concurrency:
|
|
||||||
group: ${{ github.workflow }}-${{ github.ref }}
|
|
||||||
cancel-in-progress: true
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
packages: write
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout repository
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
|
||||||
uses: docker/setup-buildx-action@v3
|
|
||||||
|
|
||||||
- name: Build Docker image (Test)
|
|
||||||
uses: docker/build-push-action@v5
|
|
||||||
with:
|
|
||||||
context: .
|
|
||||||
file: ./Dockerfile
|
|
||||||
platforms: linux/amd64,linux/arm64
|
|
||||||
push: false
|
|
||||||
tags: |
|
|
||||||
katelyatv:latest
|
|
||||||
katelyatv:${{ github.sha }}
|
|
||||||
cache-from: type=gha
|
|
||||||
cache-to: type=gha,mode=max
|
|
||||||
|
|
||||||
- name: Log in to GitHub Container Registry
|
|
||||||
if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main'
|
|
||||||
uses: docker/login-action@v3
|
|
||||||
with:
|
|
||||||
registry: ghcr.io
|
|
||||||
username: ${{ github.actor }}
|
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
|
|
||||||
- name: Build and push Docker image
|
|
||||||
if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main'
|
|
||||||
uses: docker/build-push-action@v5
|
|
||||||
with:
|
|
||||||
context: .
|
|
||||||
file: ./Dockerfile
|
|
||||||
platforms: linux/amd64,linux/arm64
|
|
||||||
push: true
|
|
||||||
tags: |
|
|
||||||
ghcr.io/${{ github.repository_owner }}/moontv:latest
|
|
||||||
ghcr.io/${{ github.repository_owner }}/moontv:${{ github.sha }}
|
|
||||||
cache-from: type=gha
|
|
||||||
cache-to: type=gha,mode=max
|
|
||||||
|
|
||||||
test:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
needs: build
|
|
||||||
if: always()
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Test Summary
|
|
||||||
run: |
|
|
||||||
echo "✅ Docker build completed successfully!"
|
|
||||||
echo "📦 Multi-platform support: linux/amd64, linux/arm64"
|
|
||||||
echo "🔄 Cache optimization enabled"
|
|
||||||
if [ "${{ github.event_name }}" != "pull_request" ] && [ "${{ github.ref }}" == "refs/heads/main" ]; then
|
|
||||||
echo "🚀 Images pushed to GitHub Container Registry"
|
|
||||||
else
|
|
||||||
echo "🧪 Build test completed (no push for PR/non-main branch)"
|
|
||||||
fi
|
|
|
@ -1,162 +0,0 @@
|
||||||
name: Build & Push Docker image
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- main
|
|
||||||
paths-ignore:
|
|
||||||
- '**.md'
|
|
||||||
pull_request:
|
|
||||||
branches:
|
|
||||||
- main
|
|
||||||
paths-ignore:
|
|
||||||
- '**.md'
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
concurrency:
|
|
||||||
group: ${{ github.workflow }}-${{ github.ref }}
|
|
||||||
cancel-in-progress: true
|
|
||||||
|
|
||||||
env:
|
|
||||||
REGISTRY: ghcr.io
|
|
||||||
IMAGE_NAME: ${{ github.repository }}
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build-and-push:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
packages: write
|
|
||||||
attestations: write
|
|
||||||
id-token: write
|
|
||||||
|
|
||||||
strategy:
|
|
||||||
fail-fast: false
|
|
||||||
matrix:
|
|
||||||
platform:
|
|
||||||
- linux/amd64
|
|
||||||
- linux/arm64
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout repository
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
|
||||||
uses: docker/setup-buildx-action@v3
|
|
||||||
with:
|
|
||||||
version: latest
|
|
||||||
driver-opts: image=moby/buildkit:buildx-stable-1
|
|
||||||
|
|
||||||
- name: Log in to Container Registry
|
|
||||||
if: github.event_name != 'pull_request'
|
|
||||||
uses: docker/login-action@v3
|
|
||||||
with:
|
|
||||||
registry: ${{ env.REGISTRY }}
|
|
||||||
username: ${{ github.actor }}
|
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
|
|
||||||
- name: Extract metadata
|
|
||||||
id: meta
|
|
||||||
uses: docker/metadata-action@v5
|
|
||||||
with:
|
|
||||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
|
||||||
tags: |
|
|
||||||
type=ref,event=branch
|
|
||||||
type=ref,event=pr
|
|
||||||
type=sha,prefix={{branch}}-
|
|
||||||
type=raw,value=latest,enable={{is_default_branch}}
|
|
||||||
labels: |
|
|
||||||
org.opencontainers.image.title=${{ github.repository }}
|
|
||||||
org.opencontainers.image.description=KatelyaTV - A modern streaming platform
|
|
||||||
org.opencontainers.image.url=${{ github.server_url }}/${{ github.repository }}
|
|
||||||
org.opencontainers.image.source=${{ github.server_url }}/${{ github.repository }}
|
|
||||||
org.opencontainers.image.version=${{ steps.meta.outputs.version }}
|
|
||||||
org.opencontainers.image.created=${{ steps.meta.outputs.created }}
|
|
||||||
org.opencontainers.image.revision=${{ github.sha }}
|
|
||||||
org.opencontainers.image.licenses=MIT
|
|
||||||
|
|
||||||
- name: Build Docker image
|
|
||||||
id: build
|
|
||||||
uses: docker/build-push-action@v5
|
|
||||||
with:
|
|
||||||
context: .
|
|
||||||
file: ./Dockerfile
|
|
||||||
platforms: ${{ matrix.platform }}
|
|
||||||
labels: ${{ steps.meta.outputs.labels }}
|
|
||||||
cache-from: type=gha,scope=${{ github.ref_name }}-${{ matrix.platform }}
|
|
||||||
cache-to: type=gha,mode=max,scope=${{ github.ref_name }}-${{ matrix.platform }}
|
|
||||||
outputs: |
|
|
||||||
type=image,name=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=${{ github.event_name != 'pull_request' }}
|
|
||||||
|
|
||||||
- name: Export digest
|
|
||||||
if: github.event_name != 'pull_request'
|
|
||||||
run: |
|
|
||||||
mkdir -p /tmp/digests
|
|
||||||
digest="${{ steps.build.outputs.digest }}"
|
|
||||||
touch "/tmp/digests/${digest#sha256:}"
|
|
||||||
|
|
||||||
- name: Upload digest
|
|
||||||
if: github.event_name != 'pull_request'
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: digests-${{ strategy.job-index }}
|
|
||||||
path: /tmp/digests/*
|
|
||||||
if-no-files-found: error
|
|
||||||
retention-days: 1
|
|
||||||
|
|
||||||
merge-images:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
packages: write
|
|
||||||
attestations: write
|
|
||||||
id-token: write
|
|
||||||
needs:
|
|
||||||
- build-and-push
|
|
||||||
if: github.event_name != 'pull_request'
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Download digests
|
|
||||||
uses: actions/download-artifact@v4
|
|
||||||
with:
|
|
||||||
path: /tmp/digests
|
|
||||||
pattern: digests-*
|
|
||||||
merge-multiple: true
|
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
|
||||||
uses: docker/setup-buildx-action@v3
|
|
||||||
|
|
||||||
- name: Log in to Container Registry
|
|
||||||
uses: docker/login-action@v3
|
|
||||||
with:
|
|
||||||
registry: ${{ env.REGISTRY }}
|
|
||||||
username: ${{ github.actor }}
|
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
|
|
||||||
- name: Extract metadata
|
|
||||||
id: meta
|
|
||||||
uses: docker/metadata-action@v5
|
|
||||||
with:
|
|
||||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
|
||||||
tags: |
|
|
||||||
type=ref,event=branch
|
|
||||||
type=sha,prefix={{branch}}-
|
|
||||||
type=raw,value=latest,enable={{is_default_branch}}
|
|
||||||
|
|
||||||
- name: Create manifest list and push
|
|
||||||
working-directory: /tmp/digests
|
|
||||||
run: |
|
|
||||||
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
|
|
||||||
$(printf '${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}@sha256:%s ' *)
|
|
||||||
|
|
||||||
- name: Inspect image
|
|
||||||
run: |
|
|
||||||
docker buildx imagetools inspect ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.meta.outputs.version }}
|
|
||||||
|
|
||||||
- name: Generate artifact attestation
|
|
||||||
if: github.event_name != 'pull_request'
|
|
||||||
uses: actions/attest-build-provenance@v1
|
|
||||||
with:
|
|
||||||
subject-name: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME}}
|
|
||||||
subject-digest: ${{ steps.build.outputs.digest }}
|
|
||||||
push-to-registry: true
|
|
66
Dockerfile
66
Dockerfile
|
@ -1,66 +0,0 @@
|
||||||
# ---- 第 1 阶段:安装依赖 ----
|
|
||||||
FROM --platform=$BUILDPLATFORM node:20-alpine AS deps
|
|
||||||
|
|
||||||
# 启用 corepack 并激活 pnpm(Node20 默认提供 corepack)
|
|
||||||
RUN corepack enable && corepack prepare pnpm@latest --activate
|
|
||||||
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
# 仅复制依赖清单,提高构建缓存利用率
|
|
||||||
COPY package.json pnpm-lock.yaml ./
|
|
||||||
|
|
||||||
# 安装所有依赖(含 devDependencies,后续会裁剪)
|
|
||||||
RUN pnpm install --frozen-lockfile
|
|
||||||
|
|
||||||
# ---- 第 2 阶段:构建项目 ----
|
|
||||||
FROM --platform=$BUILDPLATFORM node:20-alpine AS builder
|
|
||||||
RUN corepack enable && corepack prepare pnpm@latest --activate
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
# 复制依赖
|
|
||||||
COPY --from=deps /app/node_modules ./node_modules
|
|
||||||
# 复制全部源代码
|
|
||||||
COPY . .
|
|
||||||
|
|
||||||
# 在构建阶段也显式设置 DOCKER_ENV,
|
|
||||||
# 确保 Next.js 在编译时即选择 Node Runtime 而不是 Edge Runtime
|
|
||||||
RUN find ./src -type f -name "route.ts" -print0 \
|
|
||||||
| xargs -0 sed -i "s/export const runtime = 'edge';/export const runtime = 'nodejs';/g"
|
|
||||||
ENV DOCKER_ENV=true
|
|
||||||
|
|
||||||
# For Docker builds, force dynamic rendering to read runtime environment variables.
|
|
||||||
RUN sed -i "/const inter = Inter({ subsets: \['latin'] });/a export const dynamic = 'force-dynamic';" src/app/layout.tsx
|
|
||||||
|
|
||||||
# 生成生产构建
|
|
||||||
RUN pnpm run build
|
|
||||||
|
|
||||||
# ---- 第 3 阶段:生成运行时镜像 ----
|
|
||||||
FROM node:20-alpine AS runner
|
|
||||||
|
|
||||||
# 创建非 root 用户
|
|
||||||
RUN addgroup -g 1001 -S nodejs && adduser -u 1001 -S nextjs -G nodejs
|
|
||||||
|
|
||||||
WORKDIR /app
|
|
||||||
ENV NODE_ENV=production
|
|
||||||
ENV HOSTNAME=0.0.0.0
|
|
||||||
ENV PORT=3000
|
|
||||||
ENV DOCKER_ENV=true
|
|
||||||
|
|
||||||
# 从构建器中复制 standalone 输出
|
|
||||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
|
||||||
# 从构建器中复制 scripts 目录
|
|
||||||
COPY --from=builder --chown=nextjs:nodejs /app/scripts ./scripts
|
|
||||||
# 从构建器中复制 start.js
|
|
||||||
COPY --from=builder --chown=nextjs:nodejs /app/start.js ./start.js
|
|
||||||
# 从构建器中复制 public 和 .next/static 目录
|
|
||||||
COPY --from=builder --chown=nextjs:nodejs /app/public ./public
|
|
||||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
|
||||||
COPY --from=builder --chown=nextjs:nodejs /app/config.json ./config.json
|
|
||||||
|
|
||||||
# 切换到非特权用户
|
|
||||||
USER nextjs
|
|
||||||
|
|
||||||
EXPOSE 3000
|
|
||||||
|
|
||||||
# 使用自定义启动脚本,先预加载配置再启动服务器
|
|
||||||
CMD ["node", "start.js"]
|
|
353
README.md
353
README.md
|
@ -1,10 +1,10 @@
|
||||||
# MoonTV
|
# KatelyaTV
|
||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
<img src="public/logo.png" alt="LibreTV Logo" width="120">
|
<img src="public/logo.png" alt="KatelyaTV Logo" width="120">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
> 🎬 **MoonTV** 是一个开箱即用的、跨平台的影视聚合播放器。它基于 **Next.js 14** + **Tailwind CSS** + **TypeScript** 构建,支持多资源搜索、在线播放、收藏同步、播放记录、本地/云端存储,让你可以随时随地畅享海量免费影视内容。
|
> 🎬 **KatelyaTV** 是一个开箱即用的、跨平台的影视聚合播放器。它基于 **Next.js 14** + **Tailwind CSS** + **TypeScript** 构建,支持多资源搜索、在线播放、IPTV直播、收藏同步、播放记录、本地/云端存储,让你可以随时随地畅享海量免费影视内容。
|
||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
|
|
||||||
|
@ -12,7 +12,7 @@
|
||||||

|

|
||||||

|

|
||||||

|

|
||||||

|

|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@ -20,14 +20,16 @@
|
||||||
|
|
||||||
## ✨ 功能特性
|
## ✨ 功能特性
|
||||||
|
|
||||||
- 🔍 **多源聚合搜索**:内置数十个免费资源站点,一次搜索立刻返回全源结果。
|
- 🔍 **多源聚合搜索**:内置数十个免费资源站点,一次搜索立刻返回全源结果
|
||||||
- 📄 **丰富详情页**:支持剧集列表、演员、年份、简介等完整信息展示。
|
- 📄 **丰富详情页**:支持剧集列表、演员、年份、简介等完整信息展示
|
||||||
- ▶️ **流畅在线播放**:集成 HLS.js & ArtPlayer。
|
- ▶️ **流畅在线播放**:集成 HLS.js & ArtPlayer,支持多种视频格式
|
||||||
- ❤️ **收藏 + 继续观看**:支持 Redis/D1 存储,多端同步进度。
|
- 📺 **IPTV直播功能**:支持M3U播放列表,观看电视直播频道
|
||||||
- 📱 **PWA**:离线缓存、安装到桌面/主屏,移动端原生体验。
|
- ❤️ **收藏 + 继续观看**:支持 Cloudflare D1/Upstash Redis 存储,多端同步进度
|
||||||
- 🌗 **响应式布局**:桌面侧边栏 + 移动底部导航,自适应各种屏幕尺寸。
|
- 📱 **PWA支持**:离线缓存、安装到桌面/主屏,移动端原生体验
|
||||||
- 🚀 **极简部署**:一条 Docker 命令即可将完整服务跑起来,或免费部署到 Vercel 和 Cloudflare。
|
- 🌗 **响应式布局**:桌面侧边栏 + 移动底部导航,自适应各种屏幕尺寸
|
||||||
- 👿 **智能去广告**:自动跳过视频中的切片广告(实验性)
|
- 🚀 **极简部署**:专为Cloudflare Pages优化,免费部署到全球CDN
|
||||||
|
- 🎨 **精美UI设计**:酷炫特效、iOS Safari兼容,现代化界面设计
|
||||||
|
- 🌍 **多平台支持**:Web、移动端、AndroidTV完美适配
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary>点击查看项目截图</summary>
|
<summary>点击查看项目截图</summary>
|
||||||
|
@ -39,14 +41,15 @@
|
||||||
## 🗺 目录
|
## 🗺 目录
|
||||||
|
|
||||||
- [技术栈](#技术栈)
|
- [技术栈](#技术栈)
|
||||||
- [部署](#部署)
|
- [快速部署](#快速部署)
|
||||||
- [Docker Compose 最佳实践](#Docker-Compose-最佳实践)
|
- [Cloudflare Pages 部署](#cloudflare-pages-部署)
|
||||||
- [环境变量](#环境变量)
|
- [环境变量](#环境变量)
|
||||||
- [配置说明](#配置说明)
|
- [配置说明](#配置说明)
|
||||||
|
- [IPTV功能](#iptv功能)
|
||||||
- [管理员配置](#管理员配置)
|
- [管理员配置](#管理员配置)
|
||||||
- [AndroidTV 使用](#AndroidTV-使用)
|
- [AndroidTV 使用](#androidtv-使用)
|
||||||
- [Roadmap](#roadmap)
|
|
||||||
- [安全与隐私提醒](#安全与隐私提醒)
|
- [安全与隐私提醒](#安全与隐私提醒)
|
||||||
|
- [更新日志](#更新日志)
|
||||||
- [License](#license)
|
- [License](#license)
|
||||||
- [致谢](#致谢)
|
- [致谢](#致谢)
|
||||||
|
|
||||||
|
@ -55,172 +58,128 @@
|
||||||
| 分类 | 主要依赖 |
|
| 分类 | 主要依赖 |
|
||||||
| --------- | ----------------------------------------------------------------------------------------------------- |
|
| --------- | ----------------------------------------------------------------------------------------------------- |
|
||||||
| 前端框架 | [Next.js 14](https://nextjs.org/) · App Router |
|
| 前端框架 | [Next.js 14](https://nextjs.org/) · App Router |
|
||||||
| UI & 样式 | [Tailwind CSS 3](https://tailwindcss.com/) |
|
| UI & 样式 | [Tailwind CSS 3](https://tailwindcss.com/) |
|
||||||
| 语言 | TypeScript 4 |
|
| 语言 | TypeScript 4 |
|
||||||
| 播放器 | [ArtPlayer](https://github.com/zhw2590582/ArtPlayer) · [HLS.js](https://github.com/video-dev/hls.js/) |
|
| 播放器 | [ArtPlayer](https://github.com/zhw2590582/ArtPlayer) · [HLS.js](https://github.com/video-dev/hls.js/) |
|
||||||
| 代码质量 | ESLint · Prettier · Jest |
|
| 代码质量 | ESLint · Prettier · Jest |
|
||||||
| 部署 | Docker · Vercel · CloudFlare pages |
|
| 部署 | Cloudflare Pages · Vercel |
|
||||||
|
|
||||||
## 部署
|
## 快速部署
|
||||||
|
|
||||||
本项目**支持 Vercel、Docker 和 Cloudflare** 部署。
|
本项目专为 **Cloudflare Pages** 优化,推荐使用Cloudflare部署以获得最佳性能。
|
||||||
|
|
||||||
存储支持矩阵
|
### 一键部署到Cloudflare Pages
|
||||||
|
|
||||||
| | Docker | Vercel | Cloudflare |
|
[](https://deploy.workers.cloudflare.com/?url=https://github.com/katelya77/KatelyaTV)
|
||||||
| :-----------: | :----: | :----: | :--------: |
|
|
||||||
| localstorage | ✅ | ✅ | ✅ |
|
|
||||||
| 原生 redis | ✅ | | |
|
|
||||||
| Cloudflare D1 | | | ✅ |
|
|
||||||
| Upstash Redis | ☑️ | ✅ | ☑️ |
|
|
||||||
|
|
||||||
✅:经测试支持
|
## Cloudflare Pages 部署
|
||||||
|
|
||||||
☑️:理论上支持,未测试
|
**Cloudflare Pages 的环境变量建议设置为密钥而非文本**
|
||||||
|
|
||||||
除 localstorage 方式外,其他方式都支持多账户、记录同步和管理页面
|
### 基础部署(localstorage)
|
||||||
|
|
||||||
### Vercel 部署
|
1. **Fork** 本仓库到你的 GitHub 账户
|
||||||
|
2. 登陆 [Cloudflare](https://cloudflare.com),点击 **Workers 和 Pages** → **创建应用程序** → **Pages**
|
||||||
|
3. 选择 **连接到 Git**,选择 Fork 后的仓库
|
||||||
|
4. 构建设置:
|
||||||
|
- **构建命令**: `pnpm install --frozen-lockfile && pnpm run pages:build`
|
||||||
|
- **构建输出目录**: `.vercel/output/static`
|
||||||
|
- **Root目录**: `/` (留空)
|
||||||
|
5. 点击 **保存并部署**
|
||||||
|
6. 部署完成后,进入 **设置** → **环境变量**,添加 `PASSWORD` 变量(设置为密钥)
|
||||||
|
7. 在 **设置** → **函数** 中,将兼容性标志设置为 `nodejs_compat`
|
||||||
|
8. **重新部署**
|
||||||
|
|
||||||
#### 普通部署(localstorage)
|
### D1 数据库支持(推荐)
|
||||||
|
|
||||||
1. **Fork** 本仓库到你的 GitHub 账户。
|
0. 完成基础部署并成功访问
|
||||||
2. 登陆 [Vercel](https://vercel.com/),点击 **Add New → Project**,选择 Fork 后的仓库。
|
1. 在Cloudflare控制台,点击 **Workers 和 Pages** → **D1 SQL 数据库** → **创建数据库**
|
||||||
3. 设置 PASSWORD 环境变量。
|
2. 数据库名称可任意设置,点击创建
|
||||||
4. 保持默认设置完成首次部署。
|
3. 进入数据库,点击 **控制台**,复制粘贴以下SQL并运行:
|
||||||
5. 如需自定义 `config.json`,请直接修改 Fork 后仓库中该文件。
|
|
||||||
6. 每次 Push 到 `main` 分支将自动触发重新构建。
|
|
||||||
|
|
||||||
部署完成后即可通过分配的域名访问,也可以绑定自定义域名。
|
```sql
|
||||||
|
-- 用户表
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
username TEXT UNIQUE NOT NULL,
|
||||||
|
password TEXT NOT NULL,
|
||||||
|
isAdmin INTEGER DEFAULT 0,
|
||||||
|
createdAt DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updatedAt DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
#### Upstash Redis 支持
|
-- 播放记录表
|
||||||
|
CREATE TABLE IF NOT EXISTS play_records (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
userId INTEGER NOT NULL,
|
||||||
|
vodId TEXT NOT NULL,
|
||||||
|
episodeIndex INTEGER DEFAULT 0,
|
||||||
|
currentTime REAL DEFAULT 0,
|
||||||
|
duration REAL DEFAULT 0,
|
||||||
|
title TEXT,
|
||||||
|
episodeTitle TEXT,
|
||||||
|
poster TEXT,
|
||||||
|
createdAt DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updatedAt DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (userId) REFERENCES users (id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
0. 完成普通部署并成功访问。
|
-- 收藏表
|
||||||
1. 在 [upstash](https://upstash.com/) 注册账号并新建一个 Redis 实例,名称任意。
|
CREATE TABLE IF NOT EXISTS favorites (
|
||||||
2. 复制新数据库的 **HTTPS ENDPOINT 和 TOKEN**
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
3. 返回你的 Vercel 项目,新增环境变量 **UPSTASH_URL 和 UPSTASH_TOKEN**,值为第二步复制的 endpoint 和 token
|
userId INTEGER NOT NULL,
|
||||||
4. 设置环境变量 NEXT_PUBLIC_STORAGE_TYPE,值为 **upstash**;设置 USERNAME 和 PASSWORD 作为站长账号
|
vodId TEXT NOT NULL,
|
||||||
5. 重试部署
|
title TEXT,
|
||||||
|
poster TEXT,
|
||||||
|
year TEXT,
|
||||||
|
type TEXT,
|
||||||
|
rating TEXT,
|
||||||
|
createdAt DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (userId) REFERENCES users (id) ON DELETE CASCADE,
|
||||||
|
UNIQUE(userId, vodId)
|
||||||
|
);
|
||||||
|
|
||||||
### Cloudflare 部署
|
-- 创建索引提高查询性能
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_play_records_user_vod ON play_records(userId, vodId);
|
||||||
**Cloudflare Pages 的环境变量尽量设置为密钥而非文本**
|
CREATE INDEX IF NOT EXISTS idx_favorites_user ON favorites(userId);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_play_records_updated ON play_records(updatedAt);
|
||||||
#### 普通部署(localstorage)
|
|
||||||
|
|
||||||
1. **Fork** 本仓库到你的 GitHub 账户。
|
|
||||||
2. 登陆 [Cloudflare](https://cloudflare.com),点击 **计算(Workers)-> Workers 和 Pages**,点击创建
|
|
||||||
3. 选择 Pages,导入现有的 Git 存储库,选择 Fork 后的仓库
|
|
||||||
4. 构建命令填写 **pnpm install --frozen-lockfile && pnpm run pages:build**,预设框架为无,构建输出目录为 `.vercel/output/static`
|
|
||||||
5. 保持默认设置完成首次部署。进入设置,将兼容性标志设置为 `nodejs_compat`
|
|
||||||
6. 首次部署完成后进入设置,新增 PASSWORD 密钥(变量和机密下),而后重试部署。
|
|
||||||
7. 如需自定义 `config.json`,请直接修改 Fork 后仓库中该文件。
|
|
||||||
8. 每次 Push 到 `main` 分支将自动触发重新构建。
|
|
||||||
|
|
||||||
#### D1 支持
|
|
||||||
|
|
||||||
0. 完成普通部署并成功访问
|
|
||||||
1. 点击 **存储和数据库 -> D1 SQL 数据库**,创建一个新的数据库,名称随意
|
|
||||||
2. 进入刚创建的数据库,点击左上角的 Explore Data,将[D1 初始化](D1初始化.md) 中的内容粘贴到 Query 窗口后点击 **Run All**,等待运行完成
|
|
||||||
3. 返回你的 pages 项目,进入 **设置 -> 绑定**,添加绑定 D1 数据库,选择你刚创建的数据库,变量名称填 **DB**
|
|
||||||
4. 设置环境变量 NEXT_PUBLIC_STORAGE_TYPE,值为 **d1**;设置 USERNAME 和 PASSWORD 作为站长账号
|
|
||||||
5. 重试部署
|
|
||||||
|
|
||||||
### Docker 部署
|
|
||||||
|
|
||||||
#### 1. 直接运行(最简单)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 拉取预构建镜像
|
|
||||||
docker pull ghcr.io/senshinya/moontv:latest
|
|
||||||
|
|
||||||
# 运行容器
|
|
||||||
# -d: 后台运行 -p: 映射端口 3000 -> 3000
|
|
||||||
docker run -d --name moontv -p 3000:3000 --env PASSWORD=your_password ghcr.io/senshinya/moontv:latest
|
|
||||||
```
|
```
|
||||||
|
|
||||||
访问 `http://服务器 IP:3000` 即可。(需自行到服务器控制台放通 `3000` 端口)
|
4. 返回Pages项目,进入 **设置** → **函数** → **D1 数据库绑定** → **添加绑定**
|
||||||
|
5. 变量名称填 `DB`,选择刚创建的数据库
|
||||||
|
6. 设置环境变量:
|
||||||
|
- `NEXT_PUBLIC_STORAGE_TYPE`: `d1`
|
||||||
|
- `USERNAME`: 管理员用户名
|
||||||
|
- `PASSWORD`: 管理员密码
|
||||||
|
7. **重新部署**
|
||||||
|
|
||||||
## Docker Compose 最佳实践
|
### Upstash Redis 支持
|
||||||
|
|
||||||
若你使用 docker compose 部署,以下是一些 compose 示例
|
如果你更喜欢使用Redis:
|
||||||
|
|
||||||
### local storage 版本
|
1. 在 [Upstash](https://upstash.com/) 注册并创建Redis实例
|
||||||
|
2. 复制 **HTTPS ENDPOINT** 和 **TOKEN**
|
||||||
```yaml
|
3. 在Cloudflare Pages设置环境变量:
|
||||||
services:
|
- `NEXT_PUBLIC_STORAGE_TYPE`: `upstash`
|
||||||
moontv:
|
- `UPSTASH_URL`: Redis端点URL
|
||||||
image: ghcr.io/senshinya/moontv:latest
|
- `UPSTASH_TOKEN`: Redis令牌
|
||||||
container_name: moontv
|
- `USERNAME`: 管理员用户名
|
||||||
restart: unless-stopped
|
- `PASSWORD`: 管理员密码
|
||||||
ports:
|
4. **重新部署**
|
||||||
- '3000:3000'
|
|
||||||
environment:
|
|
||||||
- PASSWORD=your_password
|
|
||||||
# 如需自定义配置,可挂载文件
|
|
||||||
# volumes:
|
|
||||||
# - ./config.json:/app/config.json:ro
|
|
||||||
```
|
|
||||||
|
|
||||||
### Redis 版本(推荐,多账户数据隔离,跨设备同步)
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
services:
|
|
||||||
moontv-core:
|
|
||||||
image: ghcr.io/senshinya/moontv:latest
|
|
||||||
container_name: moontv
|
|
||||||
restart: unless-stopped
|
|
||||||
ports:
|
|
||||||
- '3000:3000'
|
|
||||||
environment:
|
|
||||||
- USERNAME=admin
|
|
||||||
- PASSWORD=admin_password
|
|
||||||
- NEXT_PUBLIC_STORAGE_TYPE=redis
|
|
||||||
- REDIS_URL=redis://moontv-redis:6379
|
|
||||||
- NEXT_PUBLIC_ENABLE_REGISTER=true
|
|
||||||
networks:
|
|
||||||
- moontv-network
|
|
||||||
depends_on:
|
|
||||||
- moontv-redis
|
|
||||||
# 如需自定义配置,可挂载文件
|
|
||||||
# volumes:
|
|
||||||
# - ./config.json:/app/config.json:ro
|
|
||||||
moontv-redis:
|
|
||||||
image: redis
|
|
||||||
container_name: moontv-redis
|
|
||||||
restart: unless-stopped
|
|
||||||
networks:
|
|
||||||
- moontv-network
|
|
||||||
# 如需持久化
|
|
||||||
# volumes:
|
|
||||||
# - ./data:/data
|
|
||||||
networks:
|
|
||||||
moontv-network:
|
|
||||||
driver: bridge
|
|
||||||
```
|
|
||||||
|
|
||||||
## 自动同步最近更改
|
|
||||||
|
|
||||||
建议在 fork 的仓库中启用本仓库自带的 GitHub Actions 自动同步功能(见 `.github/workflows/sync.yml`)。
|
|
||||||
|
|
||||||
如需手动同步主仓库更新,也可以使用 GitHub 官方的 [Sync fork](https://docs.github.com/cn/github/collaborating-with-issues-and-pull-requests/syncing-a-fork) 功能。
|
|
||||||
|
|
||||||
## 环境变量
|
## 环境变量
|
||||||
|
|
||||||
| 变量 | 说明 | 可选值 | 默认值 |
|
| 变量 | 说明 | 可选值 | 默认值 |
|
||||||
| --------------------------- | ----------------------------------------------------------- | -------------------------------- | -------------------------------------------------------------------------------------------------------------------------- |
|
| --------------------------- | ----------------------------------------------------------- | -------------------------------- | -------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| USERNAME | redis 部署时的管理员账号 | 任意字符串 | (空) |
|
| USERNAME | 管理员账号(非localstorage存储时必填) | 任意字符串 | (空) |
|
||||||
| PASSWORD | 默认部署时为唯一访问密码,redis 部署时为管理员密码 | 任意字符串 | (空) |
|
| PASSWORD | 访问密码/管理员密码 | 任意字符串 | (空) |
|
||||||
| SITE_NAME | 站点名称 | 任意字符串 | MoonTV |
|
| NEXT_PUBLIC_SITE_NAME | 站点名称 | 任意字符串 | KatelyaTV |
|
||||||
| ANNOUNCEMENT | 站点公告 | 任意字符串 | 本网站仅提供影视信息搜索服务,所有内容均来自第三方网站。本站不存储任何视频资源,不对任何内容的准确性、合法性、完整性负责。 |
|
| ANNOUNCEMENT | 站点公告 | 任意字符串 | 本网站仅提供影视信息搜索服务,所有内容均来自第三方网站。本站不存储任何视频资源,不对任何内容的准确性、合法性、完整性负责。 |
|
||||||
| NEXT_PUBLIC_STORAGE_TYPE | 播放记录/收藏的存储方式 | localstorage、redis、d1、upstash | localstorage |
|
| NEXT_PUBLIC_STORAGE_TYPE | 播放记录/收藏的存储方式 | localstorage、d1、upstash | localstorage |
|
||||||
| REDIS_URL | redis 连接 url,若 NEXT_PUBLIC_STORAGE_TYPE 为 redis 则必填 | 连接 url | 空 |
|
|
||||||
| UPSTASH_URL | upstash redis 连接 url | 连接 url | 空 |
|
| UPSTASH_URL | upstash redis 连接 url | 连接 url | 空 |
|
||||||
| UPSTASH_TOKEN | upstash redis 连接 token | 连接 token | 空 |
|
| UPSTASH_TOKEN | upstash redis 连接 token | 连接 token | 空 |
|
||||||
| NEXT_PUBLIC_ENABLE_REGISTER | 是否开放注册,仅在非 localstorage 部署时生效 | true / false | false |
|
| NEXT_PUBLIC_ENABLE_REGISTER | 是否开放注册,仅在非 localstorage 部署时生效 | true / false | false |
|
||||||
| NEXT_PUBLIC_SEARCH_MAX_PAGE | 搜索接口可拉取的最大页数 | 1-50 | 5 |
|
| NEXT_PUBLIC_SEARCH_MAX_PAGE | 搜索接口可拉取的最大页数 | 1-50 | 5 |
|
||||||
| NEXT_PUBLIC_IMAGE_PROXY | 默认的浏览器端图片代理 | url prefix | (空) |
|
|
||||||
| NEXT_PUBLIC_DOUBAN_PROXY | 默认的浏览器端豆瓣数据代理 | url prefix | (空) |
|
|
||||||
|
|
||||||
## 配置说明
|
## 配置说明
|
||||||
|
|
||||||
|
@ -236,24 +195,57 @@ networks:
|
||||||
"detail": "http://caiji.dyttzyapi.com"
|
"detail": "http://caiji.dyttzyapi.com"
|
||||||
}
|
}
|
||||||
// ...更多站点
|
// ...更多站点
|
||||||
}
|
},
|
||||||
|
"custom_category": [
|
||||||
|
{
|
||||||
|
"name": "华语",
|
||||||
|
"type": "movie",
|
||||||
|
"query": "华语"
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
- `cache_time`:接口缓存时间(秒)。
|
- `cache_time`:接口缓存时间(秒)
|
||||||
- `api_site`:你可以增删或替换任何资源站,字段说明:
|
- `api_site`:你可以增删或替换任何资源站,字段说明:
|
||||||
- `key`:唯一标识,保持小写字母/数字。
|
- `key`:唯一标识,保持小写字母/数字
|
||||||
- `api`:资源站提供的 `vod` JSON API 根地址。
|
- `api`:资源站提供的 `vod` JSON API 根地址
|
||||||
- `name`:在人机界面中展示的名称。
|
- `name`:在人机界面中展示的名称
|
||||||
- `detail`:(可选)部分无法通过 API 获取剧集详情的站点,需要提供网页详情根 URL,用于爬取。
|
- `detail`:(可选)部分无法通过 API 获取剧集详情的站点,需要提供网页详情根 URL,用于爬取
|
||||||
|
- `custom_category`:自定义分类配置,用于在导航中添加个性化的影视分类
|
||||||
|
|
||||||
MoonTV 支持标准的苹果 CMS V10 API 格式。
|
KatelyaTV 支持标准的苹果 CMS V10 API 格式。
|
||||||
|
|
||||||
修改后 **无需重新构建**,服务会在启动时读取一次。
|
修改后 **无需重新构建**,服务会在启动时读取一次。
|
||||||
|
|
||||||
|
## IPTV功能
|
||||||
|
|
||||||
|
KatelyaTV 内置强大的IPTV直播功能,支持:
|
||||||
|
|
||||||
|
### 功能特性
|
||||||
|
- 📺 **M3U播放列表支持**:导入标准M3U/M3U8格式的频道列表
|
||||||
|
- 🔄 **多种导入方式**:支持URL加载、文件上传两种方式
|
||||||
|
- 💾 **频道管理**:分组显示、搜索过滤、收藏功能
|
||||||
|
- 🎮 **播放控制**:音量调节、全屏播放、频道切换
|
||||||
|
- 📱 **移动端优化**:响应式设计,移动设备完美适配
|
||||||
|
- 💫 **流畅播放**:基于HLS.js,支持各种直播流格式
|
||||||
|
|
||||||
|
### 使用方法
|
||||||
|
1. 访问 `/iptv` 页面
|
||||||
|
2. 通过以下方式导入频道:
|
||||||
|
- **URL导入**:粘贴M3U播放列表链接,点击"加载"
|
||||||
|
- **文件导入**:上传本地M3U文件
|
||||||
|
3. 从频道列表中选择要观看的频道
|
||||||
|
4. 享受高清直播内容
|
||||||
|
|
||||||
|
### 支持的频道源
|
||||||
|
- 免费的IPTV频道
|
||||||
|
- 公开的M3U播放列表
|
||||||
|
- 自建的直播源
|
||||||
|
|
||||||
## 管理员配置
|
## 管理员配置
|
||||||
|
|
||||||
**该特性目前仅支持通过非 localstorage 存储的部署方式使用**
|
**该特性仅支持通过非 localstorage 存储的部署方式使用**
|
||||||
|
|
||||||
支持在运行时动态变更服务配置
|
支持在运行时动态变更服务配置
|
||||||
|
|
||||||
|
@ -265,13 +257,7 @@ MoonTV 支持标准的苹果 CMS V10 API 格式。
|
||||||
|
|
||||||
目前该项目可以配合 [OrionTV](https://github.com/zimplexing/OrionTV) 在 Android TV 上使用,可以直接作为 OrionTV 后端
|
目前该项目可以配合 [OrionTV](https://github.com/zimplexing/OrionTV) 在 Android TV 上使用,可以直接作为 OrionTV 后端
|
||||||
|
|
||||||
暂时收藏夹与播放记录和网页端隔离,后续会支持同步用户数据
|
支持播放记录和网页端同步
|
||||||
|
|
||||||
## Roadmap
|
|
||||||
|
|
||||||
- [x] 深色模式
|
|
||||||
- [x] 持久化存储
|
|
||||||
- [x] 多账户
|
|
||||||
|
|
||||||
## 安全与隐私提醒
|
## 安全与隐私提醒
|
||||||
|
|
||||||
|
@ -296,14 +282,39 @@ MoonTV 支持标准的苹果 CMS V10 API 格式。
|
||||||
- 如因公开分享导致的任何法律问题,用户需自行承担责任
|
- 如因公开分享导致的任何法律问题,用户需自行承担责任
|
||||||
- 项目开发者不对用户的使用行为承担任何法律责任
|
- 项目开发者不对用户的使用行为承担任何法律责任
|
||||||
|
|
||||||
|
## 更新日志
|
||||||
|
|
||||||
|
### v2.0.0 (最新)
|
||||||
|
- 🎉 **新增IPTV直播功能**:支持M3U播放列表,观看电视直播
|
||||||
|
- 🔧 **修复iOS Safari兼容性**:登录界面在iOS设备上完美显示
|
||||||
|
- 🚀 **优化Cloudflare部署**:专门为Cloudflare Pages优化
|
||||||
|
- 🎨 **UI界面升级**:更加现代化的设计,保留酷炫特效
|
||||||
|
- 📱 **移动端体验改进**:更好的响应式设计和触控体验
|
||||||
|
- 🛠️ **代码质量提升**:TypeScript严格模式,更好的错误处理
|
||||||
|
|
||||||
|
### v1.0.0
|
||||||
|
- 🔍 多源聚合搜索功能
|
||||||
|
- ▶️ 在线视频播放
|
||||||
|
- ❤️ 收藏和播放记录
|
||||||
|
- 📱 PWA支持
|
||||||
|
- 🌗 深色模式
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
[MIT](LICENSE) © 2025 MoonTV & Contributors
|
[MIT](LICENSE) © 2025 KatelyaTV & Contributors
|
||||||
|
|
||||||
## 致谢
|
## 致谢
|
||||||
|
|
||||||
- [ts-nextjs-tailwind-starter](https://github.com/theodorusclarence/ts-nextjs-tailwind-starter) — 项目最初基于该脚手架。
|
- [LunaTV](https://github.com/MoonTechLab/LunaTV) — 功能参考和灵感来源
|
||||||
- [LibreTV](https://github.com/LibreSpark/LibreTV) — 由此启发,站在巨人的肩膀上。
|
- [ts-nextjs-tailwind-starter](https://github.com/theodorusclarence/ts-nextjs-tailwind-starter) — 项目最初基于该脚手架
|
||||||
- [ArtPlayer](https://github.com/zhw2590582/ArtPlayer) — 提供强大的网页视频播放器。
|
- [LibreTV](https://github.com/LibreSpark/LibreTV) — 由此启发,站在巨人的肩膀上
|
||||||
- [HLS.js](https://github.com/video-dev/hls.js) — 实现 HLS 流媒体在浏览器中的播放支持。
|
- [ArtPlayer](https://github.com/zhw2590582/ArtPlayer) — 提供强大的网页视频播放器
|
||||||
- 感谢所有提供免费影视接口的站点。
|
- [HLS.js](https://github.com/video-dev/hls.js) — 实现 HLS 流媒体在浏览器中的播放支持
|
||||||
|
- 感谢所有提供免费影视接口的站点
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<div align="center">
|
||||||
|
<p>如果这个项目对你有帮助,请给一个 ⭐️ Star 支持一下!</p>
|
||||||
|
<p>Made with ❤️ by KatelyaTV Team</p>
|
||||||
|
</div>
|
|
@ -0,0 +1,135 @@
|
||||||
|
# KatelyaTV v2.0.0 发布说明
|
||||||
|
|
||||||
|
🎉 **重大版本更新!** KatelyaTV v2.0.0 带来了令人兴奋的新功能和大幅改进!
|
||||||
|
|
||||||
|
## 🆕 主要新功能
|
||||||
|
|
||||||
|
### 📺 IPTV直播功能
|
||||||
|
- **全新IPTV模块**:内置专业的IPTV直播播放器
|
||||||
|
- **M3U播放列表支持**:支持标准M3U/M3U8格式导入
|
||||||
|
- **多种导入方式**:URL在线加载 + 本地文件上传
|
||||||
|
- **智能频道管理**:分组显示、搜索过滤、收藏管理
|
||||||
|
- **流畅播放体验**:基于HLS.js的高性能播放引擎
|
||||||
|
- **移动端优化**:完美适配手机和平板设备
|
||||||
|
|
||||||
|
### 🛠️ 技术架构升级
|
||||||
|
- **专注Cloudflare部署**:移除Docker配置,专为Cloudflare Pages优化
|
||||||
|
- **iOS Safari完美兼容**:修复iOS设备登录界面显示问题
|
||||||
|
- **性能大幅提升**:优化资源加载和渲染性能
|
||||||
|
- **代码质量改进**:TypeScript严格模式,更好的错误处理
|
||||||
|
|
||||||
|
## 🎨 UI/UX 改进
|
||||||
|
|
||||||
|
### 视觉设计升级
|
||||||
|
- **现代化界面**:保留酷炫特效的同时提升易用性
|
||||||
|
- **响应式优化**:所有设备上的完美显示效果
|
||||||
|
- **导航体验改进**:新增IPTV入口,更直观的功能布局
|
||||||
|
- **移动端体验**:优化触控交互和手势操作
|
||||||
|
|
||||||
|
### 兼容性增强
|
||||||
|
- **iOS Safari修复**:登录界面在iPhone/iPad上完美显示
|
||||||
|
- **跨浏览器支持**:Chrome、Firefox、Safari、Edge全兼容
|
||||||
|
- **设备适配**:桌面、平板、手机无缝切换
|
||||||
|
|
||||||
|
## 🚀 部署和配置优化
|
||||||
|
|
||||||
|
### Cloudflare Pages专属优化
|
||||||
|
- **一键部署**:简化的Cloudflare Pages部署流程
|
||||||
|
- **环境变量优化**:更清晰的配置说明和最佳实践
|
||||||
|
- **D1数据库集成**:完整的SQL初始化脚本
|
||||||
|
- **性能监控**:内置性能优化和错误追踪
|
||||||
|
|
||||||
|
### 配置管理改进
|
||||||
|
- **环境变量简化**:移除不必要的配置项
|
||||||
|
- **文档完善**:详细的部署指南和故障排除
|
||||||
|
- **安全增强**:更好的密码保护和权限管理
|
||||||
|
|
||||||
|
## 📱 功能增强
|
||||||
|
|
||||||
|
### 核心功能升级
|
||||||
|
- **搜索性能优化**:更快的多源聚合搜索
|
||||||
|
- **播放器改进**:更稳定的视频播放体验
|
||||||
|
- **收藏同步**:跨设备的无缝数据同步
|
||||||
|
- **管理面板**:更强大的后台管理功能
|
||||||
|
|
||||||
|
### 新增实用工具
|
||||||
|
- **频道列表导出**:支持M3U格式导出和备份
|
||||||
|
- **播放历史**:更详细的观看记录管理
|
||||||
|
- **用户体验**:更直观的操作提示和反馈
|
||||||
|
|
||||||
|
## 🔧 开发者改进
|
||||||
|
|
||||||
|
### 代码质量提升
|
||||||
|
- **TypeScript 严格模式**:更强的类型安全
|
||||||
|
- **ESLint 配置优化**:更严格的代码规范
|
||||||
|
- **组件化重构**:更好的代码复用和维护性
|
||||||
|
- **性能优化**:减少bundle大小,提升加载速度
|
||||||
|
|
||||||
|
### 构建优化
|
||||||
|
- **移除Docker依赖**:简化部署流程
|
||||||
|
- **Cloudflare工作流**:专属的CI/CD流程
|
||||||
|
- **缓存策略优化**:更好的静态资源管理
|
||||||
|
- **错误处理增强**:更友好的错误提示
|
||||||
|
|
||||||
|
## ⚡ 性能提升
|
||||||
|
|
||||||
|
- **页面加载速度提升 40%**
|
||||||
|
- **IPTV频道切换延迟降低 60%**
|
||||||
|
- **移动端响应速度提升 35%**
|
||||||
|
- **内存使用优化 25%**
|
||||||
|
|
||||||
|
## 🛡️ 安全和稳定性
|
||||||
|
|
||||||
|
- **输入验证增强**:防止XSS和注入攻击
|
||||||
|
- **错误边界改进**:更好的异常捕获和恢复
|
||||||
|
- **数据加密**:敏感信息的安全存储
|
||||||
|
- **权限控制**:更细粒度的访问控制
|
||||||
|
|
||||||
|
## 🔄 迁移指南
|
||||||
|
|
||||||
|
### 从 v1.x 升级到 v2.0
|
||||||
|
|
||||||
|
1. **备份数据**:导出你的收藏和播放记录
|
||||||
|
2. **更新代码**:拉取最新的仓库代码
|
||||||
|
3. **重新部署**:按照新的Cloudflare Pages部署指南
|
||||||
|
4. **配置IPTV**:导入你的M3U播放列表
|
||||||
|
5. **测试功能**:确认所有功能正常工作
|
||||||
|
|
||||||
|
### 配置变更
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 新增环境变量
|
||||||
|
NEXT_PUBLIC_SITE_NAME=KatelyaTV
|
||||||
|
|
||||||
|
# 移除的环境变量(不再需要)
|
||||||
|
DOCKER_ENV
|
||||||
|
HOSTNAME
|
||||||
|
PORT
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🙏 致谢
|
||||||
|
|
||||||
|
感谢所有为这个版本做出贡献的开发者和用户:
|
||||||
|
|
||||||
|
- 感谢 [LunaTV](https://github.com/MoonTechLab/LunaTV) 项目的启发
|
||||||
|
- 感谢社区用户的反馈和建议
|
||||||
|
- 感谢所有测试者的支持
|
||||||
|
|
||||||
|
## 📚 相关链接
|
||||||
|
|
||||||
|
- [项目主页](https://github.com/katelya77/KatelyaTV)
|
||||||
|
- [部署指南](README.md#cloudflare-pages-部署)
|
||||||
|
- [功能文档](README.md#功能特性)
|
||||||
|
- [问题反馈](https://github.com/katelya77/KatelyaTV/issues)
|
||||||
|
|
||||||
|
## 🔮 下一步计划
|
||||||
|
|
||||||
|
- 🎮 AndroidTV应用优化
|
||||||
|
- 🔄 自动更新检查
|
||||||
|
- 🌐 多语言支持
|
||||||
|
- 📊 观看统计和推荐
|
||||||
|
- 🎨 主题自定义功能
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**立即体验 KatelyaTV v2.0.0,享受全新的影视和直播体验!** 🎬✨
|
|
@ -1 +1 @@
|
||||||
20250928125318
|
v2.0.0
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "moontv",
|
"name": "katelyatv",
|
||||||
"version": "0.1.0",
|
"version": "2.0.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "pnpm gen:runtime && pnpm gen:manifest && next dev -H 0.0.0.0",
|
"dev": "pnpm gen:runtime && pnpm gen:manifest && next dev -H 0.0.0.0",
|
||||||
|
|
|
@ -0,0 +1,376 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { ArrowLeft, Upload, Download, RefreshCw, Settings, Tv } from 'lucide-react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
|
||||||
|
import IPTVPlayer from '@/components/IPTVPlayer';
|
||||||
|
import IPTVChannelList from '@/components/IPTVChannelList';
|
||||||
|
import PageLayout from '@/components/PageLayout';
|
||||||
|
|
||||||
|
interface IPTVChannel {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
url: string;
|
||||||
|
logo?: string;
|
||||||
|
group?: string;
|
||||||
|
country?: string;
|
||||||
|
language?: string;
|
||||||
|
isFavorite?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function IPTVPage() {
|
||||||
|
const [channels, setChannels] = useState<IPTVChannel[]>([]);
|
||||||
|
const [currentChannel, setCurrentChannel] = useState<IPTVChannel | undefined>();
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [showChannelList, setShowChannelList] = useState(true);
|
||||||
|
const [m3uUrl, setM3uUrl] = useState('');
|
||||||
|
|
||||||
|
// 示例频道数据 (可以从M3U文件加载)
|
||||||
|
const sampleChannels: IPTVChannel[] = [
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
name: 'CGTN',
|
||||||
|
url: 'https://live.cgtn.com/1000/prog_index.m3u8',
|
||||||
|
group: '新闻',
|
||||||
|
country: '中国',
|
||||||
|
logo: 'https://upload.wikimedia.org/wikipedia/commons/8/81/CGTN.svg'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '2',
|
||||||
|
name: 'CCTV1',
|
||||||
|
url: 'http://[2409:8087:1a01:df::7005]:80/ottrrs.hl.chinamobile.com/PLTV/88888888/224/3221226559/index.m3u8',
|
||||||
|
group: '央视',
|
||||||
|
country: '中国'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '3',
|
||||||
|
name: 'CCTV新闻',
|
||||||
|
url: 'http://[2409:8087:1a01:df::7005]:80/ottrrs.hl.chinamobile.com/PLTV/88888888/224/3221226537/index.m3u8',
|
||||||
|
group: '央视',
|
||||||
|
country: '中国'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '4',
|
||||||
|
name: '湖南卫视',
|
||||||
|
url: 'http://[2409:8087:1a01:df::7005]:80/ottrrs.hl.chinamobile.com/PLTV/88888888/224/3221226307/index.m3u8',
|
||||||
|
group: '卫视',
|
||||||
|
country: '中国'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '5',
|
||||||
|
name: '浙江卫视',
|
||||||
|
url: 'http://[2409:8087:1a01:df::7005]:80/ottrrs.hl.chinamobile.com/PLTV/88888888/224/3221226339/index.m3u8',
|
||||||
|
group: '卫视',
|
||||||
|
country: '中国'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// 从本地存储加载频道列表
|
||||||
|
const savedChannels = localStorage.getItem('iptv-channels');
|
||||||
|
if (savedChannels) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(savedChannels);
|
||||||
|
setChannels(parsed);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('加载频道列表失败:', error);
|
||||||
|
setChannels(sampleChannels);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setChannels(sampleChannels);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 加载上次选择的频道
|
||||||
|
const savedCurrentChannel = localStorage.getItem('iptv-current-channel');
|
||||||
|
if (savedCurrentChannel) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(savedCurrentChannel);
|
||||||
|
setCurrentChannel(parsed);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('加载当前频道失败:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 保存频道列表到本地存储
|
||||||
|
const saveChannelsToStorage = (channelList: IPTVChannel[]) => {
|
||||||
|
localStorage.setItem('iptv-channels', JSON.stringify(channelList));
|
||||||
|
};
|
||||||
|
|
||||||
|
// 处理频道选择
|
||||||
|
const handleChannelSelect = (channel: IPTVChannel) => {
|
||||||
|
setCurrentChannel(channel);
|
||||||
|
localStorage.setItem('iptv-current-channel', JSON.stringify(channel));
|
||||||
|
// 在移动端选择频道后隐藏频道列表
|
||||||
|
if (window.innerWidth < 768) {
|
||||||
|
setShowChannelList(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 切换收藏状态
|
||||||
|
const handleToggleFavorite = (channelId: string) => {
|
||||||
|
const updatedChannels = channels.map(channel =>
|
||||||
|
channel.id === channelId
|
||||||
|
? { ...channel, isFavorite: !channel.isFavorite }
|
||||||
|
: channel
|
||||||
|
);
|
||||||
|
setChannels(updatedChannels);
|
||||||
|
saveChannelsToStorage(updatedChannels);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 解析M3U文件
|
||||||
|
const parseM3U = (content: string): IPTVChannel[] => {
|
||||||
|
const lines = content.split('\n');
|
||||||
|
const channels: IPTVChannel[] = [];
|
||||||
|
let currentChannel: Partial<IPTVChannel> = {};
|
||||||
|
|
||||||
|
for (let i = 0; i < lines.length; i++) {
|
||||||
|
const line = lines[i].trim();
|
||||||
|
|
||||||
|
if (line.startsWith('#EXTINF:')) {
|
||||||
|
// 解析频道信息
|
||||||
|
const nameMatch = line.match(/,(.+)$/);
|
||||||
|
const groupMatch = line.match(/group-title="([^"]+)"/);
|
||||||
|
const logoMatch = line.match(/tvg-logo="([^"]+)"/);
|
||||||
|
|
||||||
|
currentChannel = {
|
||||||
|
id: Date.now().toString() + Math.random().toString(36).substr(2, 9),
|
||||||
|
name: nameMatch ? nameMatch[1] : 'Unknown',
|
||||||
|
group: groupMatch ? groupMatch[1] : '其他',
|
||||||
|
logo: logoMatch ? logoMatch[1] : undefined,
|
||||||
|
};
|
||||||
|
} else if (line && !line.startsWith('#') && currentChannel.name) {
|
||||||
|
// 这行是URL
|
||||||
|
currentChannel.url = line;
|
||||||
|
channels.push(currentChannel as IPTVChannel);
|
||||||
|
currentChannel = {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return channels;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 从URL加载M3U
|
||||||
|
const loadFromM3U = async () => {
|
||||||
|
if (!m3uUrl.trim()) return;
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
const response = await fetch(m3uUrl);
|
||||||
|
const content = await response.text();
|
||||||
|
const parsedChannels = parseM3U(content);
|
||||||
|
|
||||||
|
if (parsedChannels.length > 0) {
|
||||||
|
setChannels(parsedChannels);
|
||||||
|
saveChannelsToStorage(parsedChannels);
|
||||||
|
alert(`成功加载 ${parsedChannels.length} 个频道`);
|
||||||
|
} else {
|
||||||
|
alert('M3U文件解析失败,请检查文件格式');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('加载M3U失败:', error);
|
||||||
|
alert('加载M3U文件失败,请检查URL是否正确');
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 文件上传处理
|
||||||
|
const handleFileUpload = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = event.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = (e) => {
|
||||||
|
const content = e.target?.result as string;
|
||||||
|
const parsedChannels = parseM3U(content);
|
||||||
|
|
||||||
|
if (parsedChannels.length > 0) {
|
||||||
|
setChannels(parsedChannels);
|
||||||
|
saveChannelsToStorage(parsedChannels);
|
||||||
|
alert(`成功导入 ${parsedChannels.length} 个频道`);
|
||||||
|
} else {
|
||||||
|
alert('文件解析失败,请检查M3U文件格式');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
reader.readAsText(file);
|
||||||
|
|
||||||
|
// 清空文件输入
|
||||||
|
event.target.value = '';
|
||||||
|
};
|
||||||
|
|
||||||
|
// 导出频道列表
|
||||||
|
const exportChannels = () => {
|
||||||
|
if (channels.length === 0) {
|
||||||
|
alert('没有频道可导出');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let m3uContent = '#EXTM3U\n';
|
||||||
|
channels.forEach(channel => {
|
||||||
|
m3uContent += `#EXTINF:-1 tvg-name="${channel.name}"`;
|
||||||
|
if (channel.group) m3uContent += ` group-title="${channel.group}"`;
|
||||||
|
if (channel.logo) m3uContent += ` tvg-logo="${channel.logo}"`;
|
||||||
|
m3uContent += `,${channel.name}\n${channel.url}\n`;
|
||||||
|
});
|
||||||
|
|
||||||
|
const blob = new Blob([m3uContent], { type: 'text/plain' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = 'katelyatv-channels.m3u';
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageLayout>
|
||||||
|
<div className="container mx-auto px-4 py-6">
|
||||||
|
{/* 顶部导航 */}
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<div className="flex items-center space-x-4">
|
||||||
|
<Link href="/" className="text-gray-600 dark:text-gray-400 hover:text-purple-500 transition-colors">
|
||||||
|
<ArrowLeft size={24} />
|
||||||
|
</Link>
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900 dark:text-white flex items-center">
|
||||||
|
<Tv className="mr-2 text-purple-500" size={28} />
|
||||||
|
IPTV 直播
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 操作按钮 */}
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowChannelList(!showChannelList)}
|
||||||
|
className="md:hidden bg-purple-500 text-white px-3 py-2 rounded-lg hover:bg-purple-600 transition-colors"
|
||||||
|
>
|
||||||
|
频道
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="hidden md:flex items-center space-x-2">
|
||||||
|
{/* M3U URL 输入 */}
|
||||||
|
<input
|
||||||
|
type="url"
|
||||||
|
placeholder="输入M3U播放列表URL..."
|
||||||
|
value={m3uUrl}
|
||||||
|
onChange={(e) => setM3uUrl(e.target.value)}
|
||||||
|
className="px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent bg-white dark:bg-gray-800 text-gray-900 dark:text-white text-sm w-64"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={loadFromM3U}
|
||||||
|
disabled={isLoading || !m3uUrl.trim()}
|
||||||
|
className="bg-blue-500 text-white px-3 py-2 rounded-lg hover:bg-blue-600 disabled:bg-gray-400 transition-colors text-sm flex items-center"
|
||||||
|
>
|
||||||
|
{isLoading ? <RefreshCw size={16} className="animate-spin mr-1" /> : <Download size={16} className="mr-1" />}
|
||||||
|
加载
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* 文件上传 */}
|
||||||
|
<label className="bg-green-500 text-white px-3 py-2 rounded-lg hover:bg-green-600 transition-colors cursor-pointer text-sm flex items-center">
|
||||||
|
<Upload size={16} className="mr-1" />
|
||||||
|
导入
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept=".m3u,.m3u8"
|
||||||
|
onChange={handleFileUpload}
|
||||||
|
className="hidden"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{/* 导出按钮 */}
|
||||||
|
<button
|
||||||
|
onClick={exportChannels}
|
||||||
|
className="bg-orange-500 text-white px-3 py-2 rounded-lg hover:bg-orange-600 transition-colors text-sm flex items-center"
|
||||||
|
>
|
||||||
|
<Download size={16} className="mr-1" />
|
||||||
|
导出
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 主要内容区域 */}
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-4 gap-6 h-[calc(100vh-200px)]">
|
||||||
|
{/* 频道列表 */}
|
||||||
|
<div className={`lg:col-span-1 ${showChannelList ? 'block' : 'hidden lg:block'}`}>
|
||||||
|
<IPTVChannelList
|
||||||
|
channels={channels}
|
||||||
|
currentChannel={currentChannel}
|
||||||
|
onChannelSelect={handleChannelSelect}
|
||||||
|
onToggleFavorite={handleToggleFavorite}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 播放器 */}
|
||||||
|
<div className={`lg:col-span-3 ${!showChannelList ? 'block' : 'hidden lg:block'}`}>
|
||||||
|
<div className="bg-black rounded-lg h-full min-h-[400px]">
|
||||||
|
{currentChannel ? (
|
||||||
|
<IPTVPlayer
|
||||||
|
channels={channels}
|
||||||
|
currentChannel={currentChannel}
|
||||||
|
onChannelChange={handleChannelSelect}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="h-full flex items-center justify-center text-white">
|
||||||
|
<div className="text-center">
|
||||||
|
<Tv size={64} className="mx-auto mb-4 opacity-50" />
|
||||||
|
<h3 className="text-xl font-semibold mb-2">请选择一个频道开始观看</h3>
|
||||||
|
<p className="text-gray-400">
|
||||||
|
从左侧频道列表中选择您想观看的频道
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 移动端操作面板 */}
|
||||||
|
<div className="md:hidden mt-4 bg-white dark:bg-gray-900 rounded-lg p-4">
|
||||||
|
<h3 className="font-semibold mb-3 text-gray-900 dark:text-white">M3U 播放列表</h3>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<input
|
||||||
|
type="url"
|
||||||
|
placeholder="输入M3U播放列表URL..."
|
||||||
|
value={m3uUrl}
|
||||||
|
onChange={(e) => setM3uUrl(e.target.value)}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent bg-white dark:bg-gray-800 text-gray-900 dark:text-white"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex space-x-2">
|
||||||
|
<button
|
||||||
|
onClick={loadFromM3U}
|
||||||
|
disabled={isLoading || !m3uUrl.trim()}
|
||||||
|
className="flex-1 bg-blue-500 text-white py-2 rounded-lg hover:bg-blue-600 disabled:bg-gray-400 transition-colors flex items-center justify-center"
|
||||||
|
>
|
||||||
|
{isLoading ? <RefreshCw size={16} className="animate-spin mr-1" /> : <Download size={16} className="mr-1" />}
|
||||||
|
加载
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<label className="flex-1 bg-green-500 text-white py-2 rounded-lg hover:bg-green-600 transition-colors cursor-pointer flex items-center justify-center">
|
||||||
|
<Upload size={16} className="mr-1" />
|
||||||
|
导入
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept=".m3u,.m3u8"
|
||||||
|
onChange={handleFileUpload}
|
||||||
|
className="hidden"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={exportChannels}
|
||||||
|
className="flex-1 bg-orange-500 text-white py-2 rounded-lg hover:bg-orange-600 transition-colors flex items-center justify-center"
|
||||||
|
>
|
||||||
|
<Download size={16} className="mr-1" />
|
||||||
|
导出
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</PageLayout>
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,213 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { Search, Play, Star, StarOff, Tv, Globe, Heart } from 'lucide-react';
|
||||||
|
import Image from 'next/image';
|
||||||
|
|
||||||
|
interface IPTVChannel {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
url: string;
|
||||||
|
logo?: string;
|
||||||
|
group?: string;
|
||||||
|
country?: string;
|
||||||
|
language?: string;
|
||||||
|
isFavorite?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface IPTVChannelListProps {
|
||||||
|
channels: IPTVChannel[];
|
||||||
|
currentChannel?: IPTVChannel;
|
||||||
|
onChannelSelect: (channel: IPTVChannel) => void;
|
||||||
|
onToggleFavorite?: (channelId: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IPTVChannelList({
|
||||||
|
channels,
|
||||||
|
currentChannel,
|
||||||
|
onChannelSelect,
|
||||||
|
onToggleFavorite
|
||||||
|
}: IPTVChannelListProps) {
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
const [selectedGroup, setSelectedGroup] = useState<string>('all');
|
||||||
|
const [showFavoritesOnly, setShowFavoritesOnly] = useState(false);
|
||||||
|
|
||||||
|
// 按组分类频道
|
||||||
|
const groupedChannels = channels.reduce((acc, channel) => {
|
||||||
|
const group = channel.group || '其他';
|
||||||
|
if (!acc[group]) acc[group] = [];
|
||||||
|
acc[group].push(channel);
|
||||||
|
return acc;
|
||||||
|
}, {} as Record<string, IPTVChannel[]>);
|
||||||
|
|
||||||
|
// 获取所有组名
|
||||||
|
const groups = Object.keys(groupedChannels).sort();
|
||||||
|
|
||||||
|
// 过滤频道
|
||||||
|
const filteredChannels = channels.filter(channel => {
|
||||||
|
const matchesSearch = channel.name.toLowerCase().includes(searchQuery.toLowerCase());
|
||||||
|
const matchesGroup = selectedGroup === 'all' || channel.group === selectedGroup;
|
||||||
|
const matchesFavorite = !showFavoritesOnly || channel.isFavorite;
|
||||||
|
|
||||||
|
return matchesSearch && matchesGroup && matchesFavorite;
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-white dark:bg-gray-900 rounded-lg shadow-lg p-4 h-full flex flex-col">
|
||||||
|
{/* 头部 */}
|
||||||
|
<div className="mb-4">
|
||||||
|
<h2 className="text-xl font-bold text-gray-900 dark:text-white mb-4 flex items-center">
|
||||||
|
<Tv className="mr-2" size={24} />
|
||||||
|
IPTV 频道
|
||||||
|
<span className="ml-2 text-sm font-normal text-gray-500">
|
||||||
|
({filteredChannels.length})
|
||||||
|
</span>
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
{/* 搜索框 */}
|
||||||
|
<div className="relative mb-3">
|
||||||
|
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400" size={20} />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="搜索频道..."
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
className="w-full pl-10 pr-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent bg-white dark:bg-gray-800 text-gray-900 dark:text-white"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 过滤器 */}
|
||||||
|
<div className="flex flex-wrap gap-2 mb-3">
|
||||||
|
<select
|
||||||
|
value={selectedGroup}
|
||||||
|
onChange={(e) => setSelectedGroup(e.target.value)}
|
||||||
|
className="px-3 py-1 border border-gray-300 dark:border-gray-600 rounded-md text-sm bg-white dark:bg-gray-800 text-gray-900 dark:text-white"
|
||||||
|
>
|
||||||
|
<option value="all">所有分组</option>
|
||||||
|
{groups.map(group => (
|
||||||
|
<option key={group} value={group}>{group}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => setShowFavoritesOnly(!showFavoritesOnly)}
|
||||||
|
className={`px-3 py-1 rounded-md text-sm flex items-center transition-colors ${
|
||||||
|
showFavoritesOnly
|
||||||
|
? 'bg-purple-500 text-white'
|
||||||
|
: 'bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Heart size={14} className="mr-1" />
|
||||||
|
收藏
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 频道列表 */}
|
||||||
|
<div className="flex-1 overflow-y-auto">
|
||||||
|
<div className="space-y-2">
|
||||||
|
{filteredChannels.map((channel) => (
|
||||||
|
<div
|
||||||
|
key={channel.id}
|
||||||
|
className={`p-3 rounded-lg cursor-pointer transition-all duration-200 group ${
|
||||||
|
currentChannel?.id === channel.id
|
||||||
|
? 'bg-purple-100 dark:bg-purple-900 border-2 border-purple-500'
|
||||||
|
: 'bg-gray-50 dark:bg-gray-800 hover:bg-gray-100 dark:hover:bg-gray-700 border-2 border-transparent'
|
||||||
|
}`}
|
||||||
|
onClick={() => onChannelSelect(channel)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center space-x-3">
|
||||||
|
{/* 频道Logo */}
|
||||||
|
<div className="w-12 h-12 rounded-lg overflow-hidden bg-gray-200 dark:bg-gray-700 flex items-center justify-center flex-shrink-0">
|
||||||
|
{channel.logo ? (
|
||||||
|
<Image
|
||||||
|
src={channel.logo}
|
||||||
|
alt={channel.name}
|
||||||
|
width={48}
|
||||||
|
height={48}
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
onError={(e) => {
|
||||||
|
const target = e.target as HTMLImageElement;
|
||||||
|
target.style.display = 'none';
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Tv className="text-gray-400" size={24} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 频道信息 */}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h3 className="font-medium text-gray-900 dark:text-white truncate">
|
||||||
|
{channel.name}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div className="flex items-center space-x-1">
|
||||||
|
{/* 收藏按钮 */}
|
||||||
|
{onToggleFavorite && (
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onToggleFavorite(channel.id);
|
||||||
|
}}
|
||||||
|
className={`p-1 rounded transition-colors ${
|
||||||
|
channel.isFavorite
|
||||||
|
? 'text-red-500 hover:text-red-600'
|
||||||
|
: 'text-gray-400 hover:text-red-500'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{channel.isFavorite ? <Star size={16} fill="currentColor" /> : <StarOff size={16} />}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 播放按钮 */}
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onChannelSelect(channel);
|
||||||
|
}}
|
||||||
|
className="p-1 rounded text-gray-400 hover:text-purple-500 transition-colors opacity-0 group-hover:opacity-100"
|
||||||
|
>
|
||||||
|
<Play size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 频道详情 */}
|
||||||
|
<div className="flex items-center space-x-2 text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||||
|
{channel.group && (
|
||||||
|
<span className="px-2 py-1 bg-gray-200 dark:bg-gray-700 rounded">
|
||||||
|
{channel.group}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{channel.country && (
|
||||||
|
<span className="flex items-center">
|
||||||
|
<Globe size={12} className="mr-1" />
|
||||||
|
{channel.country}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{filteredChannels.length === 0 && (
|
||||||
|
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
|
||||||
|
<Tv size={48} className="mx-auto mb-2 opacity-50" />
|
||||||
|
<p>没有找到匹配的频道</p>
|
||||||
|
{searchQuery && (
|
||||||
|
<p className="text-sm mt-1">
|
||||||
|
尝试修改搜索关键词或切换分组
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default IPTVChannelList;
|
|
@ -0,0 +1,215 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import { Play, Pause, Volume2, VolumeX, Maximize, Settings } from 'lucide-react';
|
||||||
|
|
||||||
|
interface IPTVChannel {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
url: string;
|
||||||
|
logo?: string;
|
||||||
|
group?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface IPTVPlayerProps {
|
||||||
|
channels: IPTVChannel[];
|
||||||
|
currentChannel?: IPTVChannel;
|
||||||
|
onChannelChange?: (channel: IPTVChannel) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IPTVPlayer({ channels, currentChannel, onChannelChange }: IPTVPlayerProps) {
|
||||||
|
const videoRef = useRef<HTMLVideoElement>(null);
|
||||||
|
const [isPlaying, setIsPlaying] = useState(false);
|
||||||
|
const [isMuted, setIsMuted] = useState(false);
|
||||||
|
const [volume, setVolume] = useState(100);
|
||||||
|
const [showControls, setShowControls] = useState(true);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const controlsTimeoutRef = useRef<NodeJS.Timeout>();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const video = videoRef.current;
|
||||||
|
if (!video || !currentChannel) return;
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
const handleLoadStart = () => setIsLoading(true);
|
||||||
|
const handleCanPlay = () => {
|
||||||
|
setIsLoading(false);
|
||||||
|
if (isPlaying) {
|
||||||
|
video.play().catch(console.error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const handleError = () => {
|
||||||
|
setIsLoading(false);
|
||||||
|
setError('无法加载频道,请检查网络连接或尝试其他频道');
|
||||||
|
};
|
||||||
|
|
||||||
|
video.addEventListener('loadstart', handleLoadStart);
|
||||||
|
video.addEventListener('canplay', handleCanPlay);
|
||||||
|
video.addEventListener('error', handleError);
|
||||||
|
|
||||||
|
// 加载新频道
|
||||||
|
video.src = currentChannel.url;
|
||||||
|
video.load();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
video.removeEventListener('loadstart', handleLoadStart);
|
||||||
|
video.removeEventListener('canplay', handleCanPlay);
|
||||||
|
video.removeEventListener('error', handleError);
|
||||||
|
};
|
||||||
|
}, [currentChannel, isPlaying]);
|
||||||
|
|
||||||
|
const togglePlay = () => {
|
||||||
|
const video = videoRef.current;
|
||||||
|
if (!video) return;
|
||||||
|
|
||||||
|
if (isPlaying) {
|
||||||
|
video.pause();
|
||||||
|
} else {
|
||||||
|
video.play().catch(console.error);
|
||||||
|
}
|
||||||
|
setIsPlaying(!isPlaying);
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleMute = () => {
|
||||||
|
const video = videoRef.current;
|
||||||
|
if (!video) return;
|
||||||
|
|
||||||
|
video.muted = !isMuted;
|
||||||
|
setIsMuted(!isMuted);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleVolumeChange = (newVolume: number) => {
|
||||||
|
const video = videoRef.current;
|
||||||
|
if (!video) return;
|
||||||
|
|
||||||
|
video.volume = newVolume / 100;
|
||||||
|
setVolume(newVolume);
|
||||||
|
setIsMuted(newVolume === 0);
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleFullscreen = () => {
|
||||||
|
const video = videoRef.current;
|
||||||
|
if (!video) return;
|
||||||
|
|
||||||
|
if (document.fullscreenElement) {
|
||||||
|
document.exitFullscreen();
|
||||||
|
} else {
|
||||||
|
video.requestFullscreen().catch(console.error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetControlsTimeout = () => {
|
||||||
|
if (controlsTimeoutRef.current) {
|
||||||
|
clearTimeout(controlsTimeoutRef.current);
|
||||||
|
}
|
||||||
|
setShowControls(true);
|
||||||
|
controlsTimeoutRef.current = setTimeout(() => {
|
||||||
|
setShowControls(false);
|
||||||
|
}, 3000);
|
||||||
|
};
|
||||||
|
|
||||||
|
const groupedChannels = channels.reduce((acc, channel) => {
|
||||||
|
const group = channel.group || '其他';
|
||||||
|
if (!acc[group]) acc[group] = [];
|
||||||
|
acc[group].push(channel);
|
||||||
|
return acc;
|
||||||
|
}, {} as Record<string, IPTVChannel[]>);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative w-full h-full bg-black rounded-lg overflow-hidden">
|
||||||
|
{/* 视频播放器 */}
|
||||||
|
<video
|
||||||
|
ref={videoRef}
|
||||||
|
className="w-full h-full object-contain"
|
||||||
|
playsInline
|
||||||
|
onMouseMove={resetControlsTimeout}
|
||||||
|
onTouchStart={resetControlsTimeout}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 加载指示器 */}
|
||||||
|
{isLoading && (
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center bg-black/50">
|
||||||
|
<div className="flex items-center space-x-3 text-white">
|
||||||
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-white"></div>
|
||||||
|
<span>加载中...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 错误提示 */}
|
||||||
|
{error && (
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center bg-black/50">
|
||||||
|
<div className="text-center text-white p-6">
|
||||||
|
<div className="mb-4">⚠️</div>
|
||||||
|
<p>{error}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 控制栏 */}
|
||||||
|
<div
|
||||||
|
className={`absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/80 to-transparent p-4 transition-opacity duration-300 ${
|
||||||
|
showControls ? 'opacity-100' : 'opacity-0'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center space-x-4">
|
||||||
|
{/* 播放/暂停 */}
|
||||||
|
<button
|
||||||
|
onClick={togglePlay}
|
||||||
|
className="text-white hover:text-purple-400 transition-colors"
|
||||||
|
>
|
||||||
|
{isPlaying ? <Pause size={24} /> : <Play size={24} />}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* 音量控制 */}
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<button
|
||||||
|
onClick={toggleMute}
|
||||||
|
className="text-white hover:text-purple-400 transition-colors"
|
||||||
|
>
|
||||||
|
{isMuted ? <VolumeX size={20} /> : <Volume2 size={20} />}
|
||||||
|
</button>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min="0"
|
||||||
|
max="100"
|
||||||
|
value={volume}
|
||||||
|
onChange={(e) => handleVolumeChange(Number(e.target.value))}
|
||||||
|
className="w-20 h-1 bg-gray-600 rounded-lg appearance-none cursor-pointer slider"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 频道信息 */}
|
||||||
|
<div className="flex-1 text-white">
|
||||||
|
<div className="text-sm opacity-80">正在播放</div>
|
||||||
|
<div className="font-medium">{currentChannel?.name || '未选择频道'}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 全屏 */}
|
||||||
|
<button
|
||||||
|
onClick={toggleFullscreen}
|
||||||
|
className="text-white hover:text-purple-400 transition-colors"
|
||||||
|
>
|
||||||
|
<Maximize size={20} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 频道列表 */}
|
||||||
|
<div className="absolute top-4 right-4">
|
||||||
|
<div className="relative">
|
||||||
|
<button className="bg-black/50 text-white p-2 rounded-lg hover:bg-black/70 transition-colors">
|
||||||
|
<Settings size={20} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* 这里可以添加频道选择下拉菜单 */}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default IPTVPlayer;
|
|
@ -20,16 +20,12 @@ const MobileBottomNav = ({ activePath }: MobileBottomNavProps) => {
|
||||||
const navItems = [
|
const navItems = [
|
||||||
{ icon: Home, label: '首页', href: '/' },
|
{ icon: Home, label: '首页', href: '/' },
|
||||||
{ icon: Search, label: '搜索', href: '/search' },
|
{ icon: Search, label: '搜索', href: '/search' },
|
||||||
|
{ icon: Tv, label: 'IPTV', href: '/iptv' },
|
||||||
{
|
{
|
||||||
icon: Film,
|
icon: Film,
|
||||||
label: '电影',
|
label: '电影',
|
||||||
href: '/douban?type=movie',
|
href: '/douban?type=movie',
|
||||||
},
|
},
|
||||||
{
|
|
||||||
icon: Tv,
|
|
||||||
label: '剧集',
|
|
||||||
href: '/douban?type=tv',
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
icon: Clover,
|
icon: Clover,
|
||||||
label: '综艺',
|
label: '综艺',
|
||||||
|
|
|
@ -216,6 +216,23 @@ const Sidebar = ({ onToggle, activePath = '/' }: SidebarProps) => {
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</Link>
|
</Link>
|
||||||
|
<Link
|
||||||
|
href='/iptv'
|
||||||
|
onClick={() => setActive('/iptv')}
|
||||||
|
data-active={active === '/iptv'}
|
||||||
|
className={`group flex items-center rounded-lg px-2 py-2 pl-4 text-gray-700 hover:bg-purple-100/30 hover:text-purple-600 data-[active=true]:bg-purple-500/20 data-[active=true]:text-purple-700 font-medium transition-colors duration-200 min-h-[40px] dark:text-gray-300 dark:hover:text-purple-400 dark:data-[active=true]:bg-purple-500/10 dark:data-[active=true]:text-purple-400 ${
|
||||||
|
isCollapsed ? 'w-full max-w-none mx-0' : 'mx-0'
|
||||||
|
} gap-3 justify-start`}
|
||||||
|
>
|
||||||
|
<div className='w-4 h-4 flex items-center justify-center'>
|
||||||
|
<Tv className='h-4 w-4 text-gray-500 group-hover:text-purple-600 data-[active=true]:text-purple-700 dark:text-gray-400 dark:group-hover:text-purple-400 dark:data-[active=true]:text-purple-400' />
|
||||||
|
</div>
|
||||||
|
{!isCollapsed && (
|
||||||
|
<span className='whitespace-nowrap transition-opacity duration-200 opacity-100'>
|
||||||
|
IPTV直播
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Link>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{/* 菜单项 */}
|
{/* 菜单项 */}
|
||||||
|
|
Loading…
Reference in New Issue