Compare commits
6 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
064377d7dd | ||
|
|
0819a1beca | ||
|
|
3009f94fe9 | ||
|
|
9b2339b85d | ||
|
|
22229153b9 | ||
|
|
f8be75bd81 |
26
Dockerfile
|
|
@ -1,31 +1,28 @@
|
|||
# Stage 1: Build Image (Frontend)
|
||||
FROM node:20-alpine AS frontend-builder
|
||||
# Stage 1: Build Frontend
|
||||
FROM --platform=linux/amd64 node:20-alpine AS frontend-builder
|
||||
WORKDIR /app/frontend
|
||||
COPY frontend-react/package*.json ./
|
||||
RUN npm install
|
||||
COPY frontend-react/ .
|
||||
RUN npm run build
|
||||
|
||||
# Stage 2: Build Image (Backend)
|
||||
FROM --platform=$BUILDPLATFORM golang:1.24-alpine AS backend-builder
|
||||
# Stage 2: Build Backend for linux/amd64
|
||||
FROM --platform=linux/amd64 golang:1.24-alpine AS backend-builder
|
||||
WORKDIR /app/backend
|
||||
|
||||
ARG TARGETOS TARGETARCH
|
||||
|
||||
COPY backend/go.mod backend/go.sum ./
|
||||
RUN go mod download
|
||||
|
||||
COPY backend/ .
|
||||
# Build static binary for Linux amd64
|
||||
RUN CGO_ENABLED=0 GOOS=$TARGETOS GOARCH=$TARGETARCH go build -ldflags="-w -s" -o server cmd/server/main.go
|
||||
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="-w -s" -o server cmd/server/main.go
|
||||
|
||||
# Stage 3: Final Image
|
||||
FROM alpine:latest
|
||||
# Stage 3: Final Image (linux/amd64 only for Synology NAS)
|
||||
FROM --platform=linux/amd64 alpine:latest
|
||||
WORKDIR /app
|
||||
|
||||
# Install runtime dependencies
|
||||
RUN apk add --no-cache sqlite ca-certificates tzdata python3 py3-pip
|
||||
RUN pip3 install --break-system-packages --ignore-installed yt-dlp || true
|
||||
RUN apk add --no-cache sqlite ca-certificates tzdata
|
||||
|
||||
# Copy backend binary
|
||||
COPY --from=backend-builder /app/backend/server .
|
||||
|
|
@ -33,14 +30,13 @@ COPY --from=backend-builder /app/backend/server .
|
|||
# Copy frontend build to the expected static directory
|
||||
COPY --from=frontend-builder /app/frontend/dist ./dist
|
||||
|
||||
|
||||
|
||||
# Create data directory
|
||||
RUN mkdir -p data
|
||||
# Create data directory for SQLite database
|
||||
RUN mkdir -p /app/data
|
||||
|
||||
# Environment variables
|
||||
ENV PORT=8000
|
||||
ENV DATABASE_URL=/app/data/streamflow.db
|
||||
ENV TZ=Asia/Ho_Chi_Minh
|
||||
|
||||
# Expose port
|
||||
EXPOSE 8000
|
||||
|
|
|
|||
61
README.md
|
|
@ -1,4 +1,4 @@
|
|||
# StreamFlow V3.9
|
||||
# kv-netflix V6
|
||||
|
||||
A high-performance video streaming web application with a pure Go backend and modern React + Tailwind frontend.
|
||||
|
||||
|
|
@ -10,6 +10,7 @@ A high-performance video streaming web application with a pure Go backend and mo
|
|||
- **HLS Streaming** - Native HLS playback with proxy support
|
||||
- **Android TV** - Native TV app with D-pad controls and 10s skip
|
||||
- **PWA Support** - Install as a progressive web app
|
||||
- **Episode Progress Tracking** - Auto-save progress, continue watching with seek
|
||||
- **Docker Ready** - Multi-stage build for Synology NAS (linux/amd64)
|
||||
|
||||
## Tech Stack
|
||||
|
|
@ -23,21 +24,45 @@ A high-performance video streaming web application with a pure Go backend and mo
|
|||
|
||||
## Quick Start
|
||||
|
||||
### Docker (Recommended)
|
||||
### Docker (Recommended for Synology NAS)
|
||||
|
||||
**Prerequisites:**
|
||||
- Synology NAS with Container Manager (Docker) installed
|
||||
- SSH access enabled (optional, for CLI) or use Container Manager GUI
|
||||
|
||||
**Option 1: Container Manager GUI (Recommended for Synology)**
|
||||
|
||||
1. Open **Container Manager** on your Synology NAS
|
||||
2. Go to **Registry** tab and add your Forgejo registry:
|
||||
- Registry URL: `git.khoavo.myds.me`
|
||||
- Username: `vndangkhoa`
|
||||
- Password: `Thieugia19`
|
||||
3. Search for `vndangkhoa/kv-netflix` and download `v6` tag
|
||||
4. Create a new container:
|
||||
- **Image**: `git.khoavo.myds.me/vndangkhoa/kv-netflix:v6`
|
||||
- **Container name**: `streamflow`
|
||||
- **Network**: Bridge mode, map port `3478` (local) → `8000` (container)
|
||||
- **Environment**: Add `TZ=Asia/Ho_Chi_Minh`
|
||||
- **Volume**: Create folder `docker/streamflow/data` on NAS, map to `/app/data`
|
||||
- **Restart policy**: `Unless stopped`
|
||||
5. Start the container
|
||||
|
||||
**Option 2: Docker Compose (SSH/CLI)**
|
||||
|
||||
Create `docker-compose.yml` on your NAS:
|
||||
```yaml
|
||||
# docker-compose.yml
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
streamflow:
|
||||
image: git.khoavo.myds.me/vndangkhoa/kv-streamflow:v3.9
|
||||
image: git.khoavo.myds.me/vndangkhoa/kv-netflix:v6
|
||||
container_name: streamflow
|
||||
platform: linux/amd64
|
||||
ports:
|
||||
- "3478:8000"
|
||||
environment:
|
||||
- DATABASE_URL=/app/data/streamflow.db
|
||||
- PORT=8000
|
||||
- TZ=Asia/Ho_Chi_Minh
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
|
|
@ -51,7 +76,14 @@ services:
|
|||
```
|
||||
|
||||
```bash
|
||||
# Login to registry first
|
||||
docker login git.khoavo.myds.me -u vndangkhoa -p Thieugia19
|
||||
|
||||
# Start container
|
||||
docker-compose up -d
|
||||
|
||||
# Check logs
|
||||
docker-compose logs -f
|
||||
```
|
||||
|
||||
Access at: `http://YOUR_NAS_IP:3478`
|
||||
|
|
@ -119,7 +151,26 @@ Streamflow/
|
|||
|
||||
## Changelog
|
||||
|
||||
### v3.9 (Current)
|
||||
### v6 (Current)
|
||||
- Episode progress tracking with auto-save (every 5s + on pause)
|
||||
- Continue Watching section with progress bars
|
||||
- Seek to saved position minus 20 seconds on return
|
||||
- Fixed ophim image URLs (migrated to img.ophim.live)
|
||||
- Removed broken wsrv.nl proxy dependency
|
||||
- Episode badge and progress bar in MovieCard
|
||||
- Pushed to Forgejo: `git.khoavo.myds.me/vndangkhoa/kv-netflix:v6`
|
||||
- Docker multi-stage build optimized for Synology NAS (linux/amd64)
|
||||
|
||||
### v4
|
||||
- Deployed v4 to Forgejo and Docker Registry
|
||||
- Refactored frontend and cleaned up repository
|
||||
|
||||
### v3.9.2
|
||||
- Fixed Android TV local IP issue by replacing it with production backend URL
|
||||
- Rebuilt Android TV APK and updated the frontend static bundle
|
||||
|
||||
### v3.9.1
|
||||
- Fix Android TV OOM crash + backend Content-Type headers
|
||||
- Bundled Android TV APK with the webapp for direct download
|
||||
- Verified D-pad navigation on Android TV app
|
||||
|
||||
|
|
|
|||
BIN
android-tv/adb_logs.txt
Normal file
|
|
@ -13,8 +13,8 @@ object ApiClient {
|
|||
|
||||
// Default base URL for testing
|
||||
// Change this to your production API when ready
|
||||
// var baseUrl: String = "https://nf.khoavo.myds.me"
|
||||
private var _baseUrl: String = "http://10.0.2.2:8000/"
|
||||
// private var _baseUrl: String = "http://10.0.2.2:8000/"
|
||||
private var _baseUrl: String = "https://nf.khoavo.myds.me/"
|
||||
|
||||
var baseUrl: String
|
||||
get() = _baseUrl
|
||||
|
|
|
|||
299
android-tv/build_error.txt
Normal file
|
|
@ -0,0 +1,299 @@
|
|||
WARNING: A restricted method in java.lang.System has been called
|
||||
WARNING: java.lang.System::load has been called by net.rubygrapefruit.platform.internal.NativeLibraryLoader in an unnamed module (file:/Users/khoa.vo/.gradle/wrapper/dists/gradle-8.9-bin/90cnw93cvbtalezasaz0blq0a/gradle-8.9/lib/native-platform-0.22-milestone-26.jar)
|
||||
WARNING: Use --enable-native-access=ALL-UNNAMED to avoid a warning for callers in this module
|
||||
WARNING: Restricted methods will be blocked in a future release unless native access is enabled
|
||||
|
||||
|
||||
FAILURE: Build failed with an exception.
|
||||
|
||||
* What went wrong:
|
||||
25.0.1
|
||||
|
||||
* Try:
|
||||
> Run with --info or --debug option to get more log output.
|
||||
> Run with --scan to get full insights.
|
||||
> Get more help at https://help.gradle.org.
|
||||
|
||||
* Exception is:
|
||||
java.lang.IllegalArgumentException: 25.0.1
|
||||
at org.jetbrains.kotlin.com.intellij.util.lang.JavaVersion.parse(JavaVersion.java:305)
|
||||
at org.jetbrains.kotlin.com.intellij.util.lang.JavaVersion.current(JavaVersion.java:174)
|
||||
at org.jetbrains.kotlin.cli.jvm.modules.JavaVersionUtilsKt.isAtLeastJava9(javaVersionUtils.kt:11)
|
||||
at org.jetbrains.kotlin.cli.jvm.modules.CoreJrtFileSystem$Companion$globalJrtFsCache$1.invoke(CoreJrtFileSystem.kt:83)
|
||||
at org.jetbrains.kotlin.cli.jvm.modules.CoreJrtFileSystem$Companion$globalJrtFsCache$1.invoke(CoreJrtFileSystem.kt:74)
|
||||
at org.jetbrains.kotlin.cli.jvm.modules.CoreJrtFileSystem.globalJrtFsCache$lambda$1(CoreJrtFileSystem.kt:74)
|
||||
at org.jetbrains.kotlin.com.intellij.util.containers.ConcurrentFactoryMap$2.create(ConcurrentFactoryMap.java:174)
|
||||
at org.jetbrains.kotlin.com.intellij.util.containers.ConcurrentFactoryMap.get(ConcurrentFactoryMap.java:40)
|
||||
at org.jetbrains.kotlin.cli.jvm.modules.CoreJrtFileSystem$roots$1.invoke(CoreJrtFileSystem.kt:34)
|
||||
at org.jetbrains.kotlin.cli.jvm.modules.CoreJrtFileSystem$roots$1.invoke(CoreJrtFileSystem.kt:33)
|
||||
at org.jetbrains.kotlin.cli.jvm.modules.CoreJrtFileSystem.roots$lambda$0(CoreJrtFileSystem.kt:33)
|
||||
at org.jetbrains.kotlin.com.intellij.util.containers.ConcurrentFactoryMap$2.create(ConcurrentFactoryMap.java:174)
|
||||
at org.jetbrains.kotlin.com.intellij.util.containers.ConcurrentFactoryMap.get(ConcurrentFactoryMap.java:40)
|
||||
at org.jetbrains.kotlin.cli.jvm.modules.CoreJrtFileSystem.findFileByPath(CoreJrtFileSystem.kt:42)
|
||||
at org.jetbrains.kotlin.cli.jvm.modules.CliJavaModuleFinder.<init>(CliJavaModuleFinder.kt:44)
|
||||
at org.jetbrains.kotlin.cli.jvm.compiler.KotlinCoreEnvironment.<init>(KotlinCoreEnvironment.kt:210)
|
||||
at org.jetbrains.kotlin.cli.jvm.compiler.KotlinCoreEnvironment.<init>(KotlinCoreEnvironment.kt)
|
||||
at org.jetbrains.kotlin.cli.jvm.compiler.KotlinCoreEnvironment$Companion.createForProduction(KotlinCoreEnvironment.kt:446)
|
||||
at org.gradle.kotlin.dsl.support.KotlinCompilerKt$kotlinCoreEnvironmentFor$1.create(KotlinCompiler.kt:429)
|
||||
at org.gradle.kotlin.dsl.support.KotlinCompilerKt$kotlinCoreEnvironmentFor$1.create(KotlinCompiler.kt:425)
|
||||
at org.gradle.internal.SystemProperties.withSystemProperty(SystemProperties.java:123)
|
||||
at org.gradle.kotlin.dsl.support.KotlinCompilerKt.kotlinCoreEnvironmentFor(KotlinCompiler.kt:425)
|
||||
at org.gradle.kotlin.dsl.support.KotlinCompilerKt.compileKotlinScriptModuleTo(KotlinCompiler.kt:184)
|
||||
at org.gradle.kotlin.dsl.support.KotlinCompilerKt.compileKotlinScriptToDirectory(KotlinCompiler.kt:148)
|
||||
at org.gradle.kotlin.dsl.execution.ResidualProgramCompiler$compileScript$1.invoke(ResidualProgramCompiler.kt:712)
|
||||
at org.gradle.kotlin.dsl.execution.ResidualProgramCompiler$compileScript$1.invoke(ResidualProgramCompiler.kt:711)
|
||||
at org.gradle.kotlin.dsl.provider.StandardKotlinScriptEvaluator$InterpreterHost$runCompileBuildOperation$1.call(KotlinScriptEvaluator.kt:186)
|
||||
at org.gradle.kotlin.dsl.provider.StandardKotlinScriptEvaluator$InterpreterHost$runCompileBuildOperation$1.call(KotlinScriptEvaluator.kt:183)
|
||||
at org.gradle.internal.operations.DefaultBuildOperationRunner$CallableBuildOperationWorker.execute(DefaultBuildOperationRunner.java:209)
|
||||
at org.gradle.internal.operations.DefaultBuildOperationRunner$CallableBuildOperationWorker.execute(DefaultBuildOperationRunner.java:204)
|
||||
at org.gradle.internal.operations.DefaultBuildOperationRunner$2.execute(DefaultBuildOperationRunner.java:66)
|
||||
at org.gradle.internal.operations.DefaultBuildOperationRunner$2.execute(DefaultBuildOperationRunner.java:59)
|
||||
at org.gradle.internal.operations.DefaultBuildOperationRunner.execute(DefaultBuildOperationRunner.java:166)
|
||||
at org.gradle.internal.operations.DefaultBuildOperationRunner.execute(DefaultBuildOperationRunner.java:59)
|
||||
at org.gradle.internal.operations.DefaultBuildOperationRunner.call(DefaultBuildOperationRunner.java:53)
|
||||
at org.gradle.kotlin.dsl.provider.StandardKotlinScriptEvaluator$InterpreterHost.runCompileBuildOperation(KotlinScriptEvaluator.kt:183)
|
||||
at org.gradle.kotlin.dsl.execution.Interpreter$compile$1$1$1$1.invoke(Interpreter.kt:332)
|
||||
at org.gradle.kotlin.dsl.execution.Interpreter$compile$1$1$1$1.invoke(Interpreter.kt:332)
|
||||
at org.gradle.kotlin.dsl.execution.ResidualProgramCompiler.compileScript-C5AE47M(ResidualProgramCompiler.kt:711)
|
||||
at org.gradle.kotlin.dsl.execution.ResidualProgramCompiler.compileStage1-EfyMToc(ResidualProgramCompiler.kt:694)
|
||||
at org.gradle.kotlin.dsl.execution.ResidualProgramCompiler.emitStage1Sequence(ResidualProgramCompiler.kt:246)
|
||||
at org.gradle.kotlin.dsl.execution.ResidualProgramCompiler.emitStage1Sequence(ResidualProgramCompiler.kt:237)
|
||||
at org.gradle.kotlin.dsl.execution.ResidualProgramCompiler.emit(ResidualProgramCompiler.kt:197)
|
||||
at org.gradle.kotlin.dsl.execution.ResidualProgramCompiler.emit(ResidualProgramCompiler.kt:181)
|
||||
at org.gradle.kotlin.dsl.execution.ResidualProgramCompiler.access$emit(ResidualProgramCompiler.kt:83)
|
||||
at org.gradle.kotlin.dsl.execution.ResidualProgramCompiler$emitDynamicProgram$1$1.invoke(ResidualProgramCompiler.kt:122)
|
||||
at org.gradle.kotlin.dsl.execution.ResidualProgramCompiler$emitDynamicProgram$1$1.invoke(ResidualProgramCompiler.kt:120)
|
||||
at org.gradle.kotlin.dsl.execution.ResidualProgramCompiler$overrideExecute$1.invoke(ResidualProgramCompiler.kt:547)
|
||||
at org.gradle.kotlin.dsl.execution.ResidualProgramCompiler$overrideExecute$1.invoke(ResidualProgramCompiler.kt:546)
|
||||
at org.gradle.kotlin.dsl.support.bytecode.AsmExtensionsKt.method(AsmExtensions.kt:131)
|
||||
at org.gradle.kotlin.dsl.support.bytecode.AsmExtensionsKt.publicMethod(AsmExtensions.kt:114)
|
||||
at org.gradle.kotlin.dsl.support.bytecode.AsmExtensionsKt.publicMethod$default(AsmExtensions.kt:106)
|
||||
at org.gradle.kotlin.dsl.execution.ResidualProgramCompiler.overrideExecute(ResidualProgramCompiler.kt:546)
|
||||
at org.gradle.kotlin.dsl.execution.ResidualProgramCompiler.access$overrideExecute(ResidualProgramCompiler.kt:83)
|
||||
at org.gradle.kotlin.dsl.execution.ResidualProgramCompiler$emitDynamicProgram$1.invoke(ResidualProgramCompiler.kt:120)
|
||||
at org.gradle.kotlin.dsl.execution.ResidualProgramCompiler$emitDynamicProgram$1.invoke(ResidualProgramCompiler.kt:118)
|
||||
at org.gradle.kotlin.dsl.execution.ResidualProgramCompiler$program$3.invoke(ResidualProgramCompiler.kt:672)
|
||||
at org.gradle.kotlin.dsl.execution.ResidualProgramCompiler$program$3.invoke(ResidualProgramCompiler.kt:670)
|
||||
at org.gradle.kotlin.dsl.support.bytecode.AsmExtensionsKt.publicClass-7y5yvvE(AsmExtensions.kt:39)
|
||||
at org.gradle.kotlin.dsl.execution.ResidualProgramCompiler.program-5oOsWEo(ResidualProgramCompiler.kt:670)
|
||||
at org.gradle.kotlin.dsl.execution.ResidualProgramCompiler.access$program-5oOsWEo(ResidualProgramCompiler.kt:83)
|
||||
at org.gradle.kotlin.dsl.execution.ResidualProgramCompiler.emitDynamicProgram(ResidualProgramCompiler.kt:797)
|
||||
at org.gradle.kotlin.dsl.execution.ResidualProgramCompiler.compile(ResidualProgramCompiler.kt:101)
|
||||
at org.gradle.kotlin.dsl.execution.Interpreter$compile$1.invoke(Interpreter.kt:335)
|
||||
at org.gradle.kotlin.dsl.execution.Interpreter$compile$1.invoke(Interpreter.kt:299)
|
||||
at org.gradle.kotlin.dsl.provider.StandardKotlinScriptEvaluator$KotlinScriptCompilationAndInstrumentation.compile(KotlinScriptEvaluator.kt:408)
|
||||
at org.gradle.internal.scripts.BuildScriptCompilationAndInstrumentation.execute(BuildScriptCompilationAndInstrumentation.java:95)
|
||||
at org.gradle.internal.execution.steps.ExecuteStep.executeInternal(ExecuteStep.java:105)
|
||||
at org.gradle.internal.execution.steps.ExecuteStep.access$000(ExecuteStep.java:44)
|
||||
at org.gradle.internal.execution.steps.ExecuteStep$1.call(ExecuteStep.java:59)
|
||||
at org.gradle.internal.execution.steps.ExecuteStep$1.call(ExecuteStep.java:56)
|
||||
at org.gradle.internal.operations.DefaultBuildOperationRunner$CallableBuildOperationWorker.execute(DefaultBuildOperationRunner.java:209)
|
||||
at org.gradle.internal.operations.DefaultBuildOperationRunner$CallableBuildOperationWorker.execute(DefaultBuildOperationRunner.java:204)
|
||||
at org.gradle.internal.operations.DefaultBuildOperationRunner$2.execute(DefaultBuildOperationRunner.java:66)
|
||||
at org.gradle.internal.operations.DefaultBuildOperationRunner$2.execute(DefaultBuildOperationRunner.java:59)
|
||||
at org.gradle.internal.operations.DefaultBuildOperationRunner.execute(DefaultBuildOperationRunner.java:166)
|
||||
at org.gradle.internal.operations.DefaultBuildOperationRunner.execute(DefaultBuildOperationRunner.java:59)
|
||||
at org.gradle.internal.operations.DefaultBuildOperationRunner.call(DefaultBuildOperationRunner.java:53)
|
||||
at org.gradle.internal.execution.steps.ExecuteStep.execute(ExecuteStep.java:56)
|
||||
at org.gradle.internal.execution.steps.ExecuteStep.execute(ExecuteStep.java:44)
|
||||
at org.gradle.internal.execution.steps.CancelExecutionStep.execute(CancelExecutionStep.java:42)
|
||||
at org.gradle.internal.execution.steps.TimeoutStep.executeWithoutTimeout(TimeoutStep.java:75)
|
||||
at org.gradle.internal.execution.steps.TimeoutStep.execute(TimeoutStep.java:55)
|
||||
at org.gradle.internal.execution.steps.PreCreateOutputParentsStep.execute(PreCreateOutputParentsStep.java:50)
|
||||
at org.gradle.internal.execution.steps.PreCreateOutputParentsStep.execute(PreCreateOutputParentsStep.java:28)
|
||||
at org.gradle.internal.execution.steps.BroadcastChangingOutputsStep.execute(BroadcastChangingOutputsStep.java:61)
|
||||
at org.gradle.internal.execution.steps.BroadcastChangingOutputsStep.execute(BroadcastChangingOutputsStep.java:26)
|
||||
at org.gradle.internal.execution.steps.NoInputChangesStep.execute(NoInputChangesStep.java:30)
|
||||
at org.gradle.internal.execution.steps.NoInputChangesStep.execute(NoInputChangesStep.java:21)
|
||||
at org.gradle.internal.execution.steps.CaptureOutputsAfterExecutionStep.execute(CaptureOutputsAfterExecutionStep.java:69)
|
||||
at org.gradle.internal.execution.steps.CaptureOutputsAfterExecutionStep.execute(CaptureOutputsAfterExecutionStep.java:46)
|
||||
at org.gradle.internal.execution.steps.BuildCacheStep.executeWithoutCache(BuildCacheStep.java:189)
|
||||
at org.gradle.internal.execution.steps.BuildCacheStep.lambda$execute$1(BuildCacheStep.java:75)
|
||||
at org.gradle.internal.Either$Right.fold(Either.java:175)
|
||||
at org.gradle.internal.execution.caching.CachingState.fold(CachingState.java:62)
|
||||
at org.gradle.internal.execution.steps.BuildCacheStep.execute(BuildCacheStep.java:73)
|
||||
at org.gradle.internal.execution.steps.BuildCacheStep.execute(BuildCacheStep.java:48)
|
||||
at org.gradle.internal.execution.steps.NeverUpToDateStep.execute(NeverUpToDateStep.java:34)
|
||||
at org.gradle.internal.execution.steps.NeverUpToDateStep.execute(NeverUpToDateStep.java:22)
|
||||
at org.gradle.internal.execution.steps.legacy.MarkSnapshottingInputsFinishedStep.execute(MarkSnapshottingInputsFinishedStep.java:37)
|
||||
at org.gradle.internal.execution.steps.legacy.MarkSnapshottingInputsFinishedStep.execute(MarkSnapshottingInputsFinishedStep.java:27)
|
||||
at org.gradle.internal.execution.steps.ResolveNonIncrementalCachingStateStep.executeDelegate(ResolveNonIncrementalCachingStateStep.java:50)
|
||||
at org.gradle.internal.execution.steps.AbstractResolveCachingStateStep.execute(AbstractResolveCachingStateStep.java:71)
|
||||
at org.gradle.internal.execution.steps.AbstractResolveCachingStateStep.execute(AbstractResolveCachingStateStep.java:39)
|
||||
at org.gradle.internal.execution.steps.ValidateStep.execute(ValidateStep.java:105)
|
||||
at org.gradle.internal.execution.steps.ValidateStep.execute(ValidateStep.java:54)
|
||||
at org.gradle.internal.execution.steps.AbstractCaptureStateBeforeExecutionStep.execute(AbstractCaptureStateBeforeExecutionStep.java:64)
|
||||
at org.gradle.internal.execution.steps.AbstractCaptureStateBeforeExecutionStep.execute(AbstractCaptureStateBeforeExecutionStep.java:43)
|
||||
at org.gradle.internal.execution.steps.legacy.MarkSnapshottingInputsStartedStep.execute(MarkSnapshottingInputsStartedStep.java:38)
|
||||
at org.gradle.internal.execution.steps.AssignImmutableWorkspaceStep.lambda$executeInTemporaryWorkspace$3(AssignImmutableWorkspaceStep.java:209)
|
||||
at org.gradle.internal.execution.workspace.impl.CacheBasedImmutableWorkspaceProvider$1.withTemporaryWorkspace(CacheBasedImmutableWorkspaceProvider.java:119)
|
||||
at org.gradle.internal.execution.steps.AssignImmutableWorkspaceStep.executeInTemporaryWorkspace(AssignImmutableWorkspaceStep.java:199)
|
||||
at org.gradle.internal.execution.steps.AssignImmutableWorkspaceStep.lambda$execute$0(AssignImmutableWorkspaceStep.java:121)
|
||||
at org.gradle.internal.execution.steps.AssignImmutableWorkspaceStep.execute(AssignImmutableWorkspaceStep.java:121)
|
||||
at org.gradle.internal.execution.steps.AssignImmutableWorkspaceStep.execute(AssignImmutableWorkspaceStep.java:90)
|
||||
at org.gradle.internal.execution.steps.ChoosePipelineStep.execute(ChoosePipelineStep.java:38)
|
||||
at org.gradle.internal.execution.steps.ChoosePipelineStep.execute(ChoosePipelineStep.java:23)
|
||||
at org.gradle.internal.execution.steps.ExecuteWorkBuildOperationFiringStep.lambda$execute$2(ExecuteWorkBuildOperationFiringStep.java:67)
|
||||
at org.gradle.internal.execution.steps.ExecuteWorkBuildOperationFiringStep.execute(ExecuteWorkBuildOperationFiringStep.java:67)
|
||||
at org.gradle.internal.execution.steps.ExecuteWorkBuildOperationFiringStep.execute(ExecuteWorkBuildOperationFiringStep.java:39)
|
||||
at org.gradle.internal.execution.steps.IdentityCacheStep.execute(IdentityCacheStep.java:46)
|
||||
at org.gradle.internal.execution.steps.IdentityCacheStep.execute(IdentityCacheStep.java:34)
|
||||
at org.gradle.internal.execution.steps.IdentifyStep.execute(IdentifyStep.java:48)
|
||||
at org.gradle.internal.execution.steps.IdentifyStep.execute(IdentifyStep.java:35)
|
||||
at org.gradle.internal.execution.impl.DefaultExecutionEngine$1.execute(DefaultExecutionEngine.java:61)
|
||||
at org.gradle.kotlin.dsl.provider.StandardKotlinScriptEvaluator$InterpreterHost.cachedDirFor(KotlinScriptEvaluator.kt:278)
|
||||
at org.gradle.kotlin.dsl.execution.Interpreter.compile(Interpreter.kt:299)
|
||||
at org.gradle.kotlin.dsl.execution.Interpreter.emitSpecializedProgramFor(Interpreter.kt:266)
|
||||
at org.gradle.kotlin.dsl.execution.Interpreter.eval(Interpreter.kt:198)
|
||||
at org.gradle.kotlin.dsl.provider.StandardKotlinScriptEvaluator.evaluate(KotlinScriptEvaluator.kt:124)
|
||||
at org.gradle.kotlin.dsl.provider.KotlinScriptPluginFactory$create$1.invoke(KotlinScriptPluginFactory.kt:51)
|
||||
at org.gradle.kotlin.dsl.provider.KotlinScriptPluginFactory$create$1.invoke(KotlinScriptPluginFactory.kt:48)
|
||||
at org.gradle.kotlin.dsl.provider.KotlinScriptPlugin.apply(KotlinScriptPlugin.kt:35)
|
||||
at org.gradle.configuration.BuildOperationScriptPlugin$1.run(BuildOperationScriptPlugin.java:68)
|
||||
at org.gradle.internal.operations.DefaultBuildOperationRunner$1.execute(DefaultBuildOperationRunner.java:29)
|
||||
at org.gradle.internal.operations.DefaultBuildOperationRunner$1.execute(DefaultBuildOperationRunner.java:26)
|
||||
at org.gradle.internal.operations.DefaultBuildOperationRunner$2.execute(DefaultBuildOperationRunner.java:66)
|
||||
at org.gradle.internal.operations.DefaultBuildOperationRunner$2.execute(DefaultBuildOperationRunner.java:59)
|
||||
at org.gradle.internal.operations.DefaultBuildOperationRunner.execute(DefaultBuildOperationRunner.java:166)
|
||||
at org.gradle.internal.operations.DefaultBuildOperationRunner.execute(DefaultBuildOperationRunner.java:59)
|
||||
at org.gradle.internal.operations.DefaultBuildOperationRunner.run(DefaultBuildOperationRunner.java:47)
|
||||
at org.gradle.configuration.BuildOperationScriptPlugin.lambda$apply$0(BuildOperationScriptPlugin.java:65)
|
||||
at org.gradle.internal.code.DefaultUserCodeApplicationContext.apply(DefaultUserCodeApplicationContext.java:44)
|
||||
at org.gradle.configuration.BuildOperationScriptPlugin.apply(BuildOperationScriptPlugin.java:65)
|
||||
at org.gradle.initialization.ScriptEvaluatingSettingsProcessor.applySettingsScript(ScriptEvaluatingSettingsProcessor.java:75)
|
||||
at org.gradle.initialization.ScriptEvaluatingSettingsProcessor.process(ScriptEvaluatingSettingsProcessor.java:68)
|
||||
at org.gradle.initialization.SettingsEvaluatedCallbackFiringSettingsProcessor.process(SettingsEvaluatedCallbackFiringSettingsProcessor.java:34)
|
||||
at org.gradle.initialization.RootBuildCacheControllerSettingsProcessor.process(RootBuildCacheControllerSettingsProcessor.java:47)
|
||||
at org.gradle.initialization.BuildOperationSettingsProcessor$2.call(BuildOperationSettingsProcessor.java:49)
|
||||
at org.gradle.initialization.BuildOperationSettingsProcessor$2.call(BuildOperationSettingsProcessor.java:46)
|
||||
at org.gradle.internal.operations.DefaultBuildOperationRunner$CallableBuildOperationWorker.execute(DefaultBuildOperationRunner.java:209)
|
||||
at org.gradle.internal.operations.DefaultBuildOperationRunner$CallableBuildOperationWorker.execute(DefaultBuildOperationRunner.java:204)
|
||||
at org.gradle.internal.operations.DefaultBuildOperationRunner$2.execute(DefaultBuildOperationRunner.java:66)
|
||||
at org.gradle.internal.operations.DefaultBuildOperationRunner$2.execute(DefaultBuildOperationRunner.java:59)
|
||||
at org.gradle.internal.operations.DefaultBuildOperationRunner.execute(DefaultBuildOperationRunner.java:166)
|
||||
at org.gradle.internal.operations.DefaultBuildOperationRunner.execute(DefaultBuildOperationRunner.java:59)
|
||||
at org.gradle.internal.operations.DefaultBuildOperationRunner.call(DefaultBuildOperationRunner.java:53)
|
||||
at org.gradle.initialization.BuildOperationSettingsProcessor.process(BuildOperationSettingsProcessor.java:46)
|
||||
at org.gradle.initialization.DefaultSettingsLoader.findSettingsAndLoadIfAppropriate(DefaultSettingsLoader.java:143)
|
||||
at org.gradle.initialization.DefaultSettingsLoader.findAndLoadSettings(DefaultSettingsLoader.java:63)
|
||||
at org.gradle.initialization.SettingsAttachingSettingsLoader.findAndLoadSettings(SettingsAttachingSettingsLoader.java:33)
|
||||
at org.gradle.internal.composite.CommandLineIncludedBuildSettingsLoader.findAndLoadSettings(CommandLineIncludedBuildSettingsLoader.java:35)
|
||||
at org.gradle.internal.composite.ChildBuildRegisteringSettingsLoader.findAndLoadSettings(ChildBuildRegisteringSettingsLoader.java:44)
|
||||
at org.gradle.internal.composite.CompositeBuildSettingsLoader.findAndLoadSettings(CompositeBuildSettingsLoader.java:35)
|
||||
at org.gradle.initialization.InitScriptHandlingSettingsLoader.findAndLoadSettings(InitScriptHandlingSettingsLoader.java:33)
|
||||
at org.gradle.api.internal.initialization.CacheConfigurationsHandlingSettingsLoader.findAndLoadSettings(CacheConfigurationsHandlingSettingsLoader.java:36)
|
||||
at org.gradle.initialization.GradlePropertiesHandlingSettingsLoader.findAndLoadSettings(GradlePropertiesHandlingSettingsLoader.java:38)
|
||||
at org.gradle.initialization.DefaultSettingsPreparer.prepareSettings(DefaultSettingsPreparer.java:31)
|
||||
at org.gradle.initialization.BuildOperationFiringSettingsPreparer$LoadBuild.doLoadBuild(BuildOperationFiringSettingsPreparer.java:71)
|
||||
at org.gradle.initialization.BuildOperationFiringSettingsPreparer$LoadBuild.run(BuildOperationFiringSettingsPreparer.java:66)
|
||||
at org.gradle.internal.operations.DefaultBuildOperationRunner$1.execute(DefaultBuildOperationRunner.java:29)
|
||||
at org.gradle.internal.operations.DefaultBuildOperationRunner$1.execute(DefaultBuildOperationRunner.java:26)
|
||||
at org.gradle.internal.operations.DefaultBuildOperationRunner$2.execute(DefaultBuildOperationRunner.java:66)
|
||||
at org.gradle.internal.operations.DefaultBuildOperationRunner$2.execute(DefaultBuildOperationRunner.java:59)
|
||||
at org.gradle.internal.operations.DefaultBuildOperationRunner.execute(DefaultBuildOperationRunner.java:166)
|
||||
at org.gradle.internal.operations.DefaultBuildOperationRunner.execute(DefaultBuildOperationRunner.java:59)
|
||||
at org.gradle.internal.operations.DefaultBuildOperationRunner.run(DefaultBuildOperationRunner.java:47)
|
||||
at org.gradle.initialization.BuildOperationFiringSettingsPreparer.prepareSettings(BuildOperationFiringSettingsPreparer.java:54)
|
||||
at org.gradle.initialization.VintageBuildModelController.lambda$prepareSettings$1(VintageBuildModelController.java:80)
|
||||
at org.gradle.internal.model.StateTransitionController.lambda$doTransition$14(StateTransitionController.java:255)
|
||||
at org.gradle.internal.model.StateTransitionController.doTransition(StateTransitionController.java:266)
|
||||
at org.gradle.internal.model.StateTransitionController.doTransition(StateTransitionController.java:254)
|
||||
at org.gradle.internal.model.StateTransitionController.lambda$transitionIfNotPreviously$11(StateTransitionController.java:213)
|
||||
at org.gradle.internal.work.DefaultSynchronizer.withLock(DefaultSynchronizer.java:34)
|
||||
at org.gradle.internal.model.StateTransitionController.transitionIfNotPreviously(StateTransitionController.java:209)
|
||||
at org.gradle.initialization.VintageBuildModelController.prepareSettings(VintageBuildModelController.java:80)
|
||||
at org.gradle.initialization.VintageBuildModelController.prepareToScheduleTasks(VintageBuildModelController.java:70)
|
||||
at org.gradle.internal.build.DefaultBuildLifecycleController.lambda$prepareToScheduleTasks$6(DefaultBuildLifecycleController.java:175)
|
||||
at org.gradle.internal.model.StateTransitionController.lambda$doTransition$14(StateTransitionController.java:255)
|
||||
at org.gradle.internal.model.StateTransitionController.doTransition(StateTransitionController.java:266)
|
||||
at org.gradle.internal.model.StateTransitionController.doTransition(StateTransitionController.java:254)
|
||||
at org.gradle.internal.model.StateTransitionController.lambda$maybeTransition$9(StateTransitionController.java:190)
|
||||
at org.gradle.internal.work.DefaultSynchronizer.withLock(DefaultSynchronizer.java:34)
|
||||
at org.gradle.internal.model.StateTransitionController.maybeTransition(StateTransitionController.java:186)
|
||||
at org.gradle.internal.build.DefaultBuildLifecycleController.prepareToScheduleTasks(DefaultBuildLifecycleController.java:173)
|
||||
at org.gradle.internal.buildtree.DefaultBuildTreeWorkPreparer.scheduleRequestedTasks(DefaultBuildTreeWorkPreparer.java:36)
|
||||
at org.gradle.internal.cc.impl.VintageBuildTreeWorkController$scheduleAndRunRequestedTasks$1.apply(VintageBuildTreeWorkController.kt:36)
|
||||
at org.gradle.internal.cc.impl.VintageBuildTreeWorkController$scheduleAndRunRequestedTasks$1.apply(VintageBuildTreeWorkController.kt:35)
|
||||
at org.gradle.composite.internal.DefaultIncludedBuildTaskGraph.withNewWorkGraph(DefaultIncludedBuildTaskGraph.java:112)
|
||||
at org.gradle.internal.cc.impl.VintageBuildTreeWorkController.scheduleAndRunRequestedTasks(VintageBuildTreeWorkController.kt:35)
|
||||
at org.gradle.internal.buildtree.DefaultBuildTreeLifecycleController.lambda$scheduleAndRunTasks$1(DefaultBuildTreeLifecycleController.java:77)
|
||||
at org.gradle.internal.buildtree.DefaultBuildTreeLifecycleController.lambda$runBuild$4(DefaultBuildTreeLifecycleController.java:120)
|
||||
at org.gradle.internal.model.StateTransitionController.lambda$transition$6(StateTransitionController.java:169)
|
||||
at org.gradle.internal.model.StateTransitionController.doTransition(StateTransitionController.java:266)
|
||||
at org.gradle.internal.model.StateTransitionController.lambda$transition$7(StateTransitionController.java:169)
|
||||
at org.gradle.internal.work.DefaultSynchronizer.withLock(DefaultSynchronizer.java:44)
|
||||
at org.gradle.internal.model.StateTransitionController.transition(StateTransitionController.java:169)
|
||||
at org.gradle.internal.buildtree.DefaultBuildTreeLifecycleController.runBuild(DefaultBuildTreeLifecycleController.java:117)
|
||||
at org.gradle.internal.buildtree.DefaultBuildTreeLifecycleController.scheduleAndRunTasks(DefaultBuildTreeLifecycleController.java:77)
|
||||
at org.gradle.internal.buildtree.DefaultBuildTreeLifecycleController.scheduleAndRunTasks(DefaultBuildTreeLifecycleController.java:72)
|
||||
at org.gradle.tooling.internal.provider.ExecuteBuildActionRunner.run(ExecuteBuildActionRunner.java:31)
|
||||
at org.gradle.launcher.exec.ChainingBuildActionRunner.run(ChainingBuildActionRunner.java:35)
|
||||
at org.gradle.internal.buildtree.ProblemReportingBuildActionRunner.run(ProblemReportingBuildActionRunner.java:49)
|
||||
at org.gradle.launcher.exec.BuildOutcomeReportingBuildActionRunner.run(BuildOutcomeReportingBuildActionRunner.java:65)
|
||||
at org.gradle.tooling.internal.provider.FileSystemWatchingBuildActionRunner.run(FileSystemWatchingBuildActionRunner.java:140)
|
||||
at org.gradle.launcher.exec.BuildCompletionNotifyingBuildActionRunner.run(BuildCompletionNotifyingBuildActionRunner.java:41)
|
||||
at org.gradle.launcher.exec.RootBuildLifecycleBuildActionExecutor.lambda$execute$0(RootBuildLifecycleBuildActionExecutor.java:40)
|
||||
at org.gradle.composite.internal.DefaultRootBuildState.run(DefaultRootBuildState.java:130)
|
||||
at org.gradle.launcher.exec.RootBuildLifecycleBuildActionExecutor.execute(RootBuildLifecycleBuildActionExecutor.java:40)
|
||||
at org.gradle.internal.buildtree.InitDeprecationLoggingActionExecutor.execute(InitDeprecationLoggingActionExecutor.java:62)
|
||||
at org.gradle.internal.buildtree.InitProblems.execute(InitProblems.java:36)
|
||||
at org.gradle.internal.buildtree.DefaultBuildTreeContext.execute(DefaultBuildTreeContext.java:40)
|
||||
at org.gradle.launcher.exec.BuildTreeLifecycleBuildActionExecutor.lambda$execute$0(BuildTreeLifecycleBuildActionExecutor.java:71)
|
||||
at org.gradle.internal.buildtree.BuildTreeState.run(BuildTreeState.java:60)
|
||||
at org.gradle.launcher.exec.BuildTreeLifecycleBuildActionExecutor.execute(BuildTreeLifecycleBuildActionExecutor.java:71)
|
||||
at org.gradle.launcher.exec.RunAsBuildOperationBuildActionExecutor$3.call(RunAsBuildOperationBuildActionExecutor.java:61)
|
||||
at org.gradle.launcher.exec.RunAsBuildOperationBuildActionExecutor$3.call(RunAsBuildOperationBuildActionExecutor.java:57)
|
||||
at org.gradle.internal.operations.DefaultBuildOperationRunner$CallableBuildOperationWorker.execute(DefaultBuildOperationRunner.java:209)
|
||||
at org.gradle.internal.operations.DefaultBuildOperationRunner$CallableBuildOperationWorker.execute(DefaultBuildOperationRunner.java:204)
|
||||
at org.gradle.internal.operations.DefaultBuildOperationRunner$2.execute(DefaultBuildOperationRunner.java:66)
|
||||
at org.gradle.internal.operations.DefaultBuildOperationRunner$2.execute(DefaultBuildOperationRunner.java:59)
|
||||
at org.gradle.internal.operations.DefaultBuildOperationRunner.execute(DefaultBuildOperationRunner.java:166)
|
||||
at org.gradle.internal.operations.DefaultBuildOperationRunner.execute(DefaultBuildOperationRunner.java:59)
|
||||
at org.gradle.internal.operations.DefaultBuildOperationRunner.call(DefaultBuildOperationRunner.java:53)
|
||||
at org.gradle.launcher.exec.RunAsBuildOperationBuildActionExecutor.execute(RunAsBuildOperationBuildActionExecutor.java:57)
|
||||
at org.gradle.launcher.exec.RunAsWorkerThreadBuildActionExecutor.lambda$execute$0(RunAsWorkerThreadBuildActionExecutor.java:36)
|
||||
at org.gradle.internal.work.DefaultWorkerLeaseService.withLocks(DefaultWorkerLeaseService.java:267)
|
||||
at org.gradle.internal.work.DefaultWorkerLeaseService.runAsWorkerThread(DefaultWorkerLeaseService.java:131)
|
||||
at org.gradle.launcher.exec.RunAsWorkerThreadBuildActionExecutor.execute(RunAsWorkerThreadBuildActionExecutor.java:36)
|
||||
at org.gradle.tooling.internal.provider.continuous.ContinuousBuildActionExecutor.execute(ContinuousBuildActionExecutor.java:110)
|
||||
at org.gradle.tooling.internal.provider.SubscribableBuildActionExecutor.execute(SubscribableBuildActionExecutor.java:64)
|
||||
at org.gradle.internal.session.DefaultBuildSessionContext.execute(DefaultBuildSessionContext.java:46)
|
||||
at org.gradle.internal.buildprocess.execution.BuildSessionLifecycleBuildActionExecutor$ActionImpl.apply(BuildSessionLifecycleBuildActionExecutor.java:92)
|
||||
at org.gradle.internal.buildprocess.execution.BuildSessionLifecycleBuildActionExecutor$ActionImpl.apply(BuildSessionLifecycleBuildActionExecutor.java:80)
|
||||
at org.gradle.internal.session.BuildSessionState.run(BuildSessionState.java:71)
|
||||
at org.gradle.internal.buildprocess.execution.BuildSessionLifecycleBuildActionExecutor.execute(BuildSessionLifecycleBuildActionExecutor.java:62)
|
||||
at org.gradle.internal.buildprocess.execution.BuildSessionLifecycleBuildActionExecutor.execute(BuildSessionLifecycleBuildActionExecutor.java:41)
|
||||
at org.gradle.internal.buildprocess.execution.StartParamsValidatingActionExecutor.execute(StartParamsValidatingActionExecutor.java:64)
|
||||
at org.gradle.internal.buildprocess.execution.StartParamsValidatingActionExecutor.execute(StartParamsValidatingActionExecutor.java:32)
|
||||
at org.gradle.internal.buildprocess.execution.SessionFailureReportingActionExecutor.execute(SessionFailureReportingActionExecutor.java:51)
|
||||
at org.gradle.internal.buildprocess.execution.SessionFailureReportingActionExecutor.execute(SessionFailureReportingActionExecutor.java:39)
|
||||
at org.gradle.internal.buildprocess.execution.SetupLoggingActionExecutor.execute(SetupLoggingActionExecutor.java:47)
|
||||
at org.gradle.internal.buildprocess.execution.SetupLoggingActionExecutor.execute(SetupLoggingActionExecutor.java:31)
|
||||
at org.gradle.launcher.daemon.server.exec.ExecuteBuild.doBuild(ExecuteBuild.java:70)
|
||||
at org.gradle.launcher.daemon.server.exec.BuildCommandOnly.execute(BuildCommandOnly.java:37)
|
||||
at org.gradle.launcher.daemon.server.api.DaemonCommandExecution.proceed(DaemonCommandExecution.java:104)
|
||||
at org.gradle.launcher.daemon.server.exec.WatchForDisconnection.execute(WatchForDisconnection.java:39)
|
||||
at org.gradle.launcher.daemon.server.api.DaemonCommandExecution.proceed(DaemonCommandExecution.java:104)
|
||||
at org.gradle.launcher.daemon.server.exec.ResetDeprecationLogger.execute(ResetDeprecationLogger.java:29)
|
||||
at org.gradle.launcher.daemon.server.api.DaemonCommandExecution.proceed(DaemonCommandExecution.java:104)
|
||||
at org.gradle.launcher.daemon.server.exec.RequestStopIfSingleUsedDaemon.execute(RequestStopIfSingleUsedDaemon.java:35)
|
||||
at org.gradle.launcher.daemon.server.api.DaemonCommandExecution.proceed(DaemonCommandExecution.java:104)
|
||||
at org.gradle.launcher.daemon.server.exec.ForwardClientInput.lambda$execute$0(ForwardClientInput.java:40)
|
||||
at org.gradle.internal.daemon.clientinput.ClientInputForwarder.forwardInput(ClientInputForwarder.java:80)
|
||||
at org.gradle.launcher.daemon.server.exec.ForwardClientInput.execute(ForwardClientInput.java:37)
|
||||
at org.gradle.launcher.daemon.server.api.DaemonCommandExecution.proceed(DaemonCommandExecution.java:104)
|
||||
at org.gradle.launcher.daemon.server.exec.LogAndCheckHealth.execute(LogAndCheckHealth.java:64)
|
||||
at org.gradle.launcher.daemon.server.api.DaemonCommandExecution.proceed(DaemonCommandExecution.java:104)
|
||||
at org.gradle.launcher.daemon.server.exec.LogToClient.doBuild(LogToClient.java:63)
|
||||
at org.gradle.launcher.daemon.server.exec.BuildCommandOnly.execute(BuildCommandOnly.java:37)
|
||||
at org.gradle.launcher.daemon.server.api.DaemonCommandExecution.proceed(DaemonCommandExecution.java:104)
|
||||
at org.gradle.launcher.daemon.server.exec.EstablishBuildEnvironment.doBuild(EstablishBuildEnvironment.java:84)
|
||||
at org.gradle.launcher.daemon.server.exec.BuildCommandOnly.execute(BuildCommandOnly.java:37)
|
||||
at org.gradle.launcher.daemon.server.api.DaemonCommandExecution.proceed(DaemonCommandExecution.java:104)
|
||||
at org.gradle.launcher.daemon.server.exec.StartBuildOrRespondWithBusy$1.run(StartBuildOrRespondWithBusy.java:52)
|
||||
at org.gradle.launcher.daemon.server.DaemonStateCoordinator.lambda$runCommand$0(DaemonStateCoordinator.java:320)
|
||||
at org.gradle.internal.concurrent.ExecutorPolicy$CatchAndRecordFailures.onExecute(ExecutorPolicy.java:64)
|
||||
at org.gradle.internal.concurrent.AbstractManagedExecutor$1.run(AbstractManagedExecutor.java:48)
|
||||
|
||||
|
||||
BUILD FAILED in 4s
|
||||
46385
android-tv/logcat.txt
Normal file
2151
android-tv/logcat2.txt
Normal file
BIN
backend/cache/images/009a4648cfbc3528f6708a6c41a8c0df.jpg
vendored
Normal file
|
After Width: | Height: | Size: 46 KiB |
BIN
backend/cache/images/0224c715cd96ade6ed1b45408791878e.jpg
vendored
Normal file
|
After Width: | Height: | Size: 94 KiB |
BIN
backend/cache/images/11e8ffb2a3d869beef0e03ca0ffcdded.jpg
vendored
Normal file
|
After Width: | Height: | Size: 64 KiB |
BIN
backend/cache/images/154415651d73422adc4fac6b636ad35c.jpg
vendored
Normal file
|
After Width: | Height: | Size: 24 KiB |
BIN
backend/cache/images/25ead2464d25d3aebea55dc12a486409.jpg
vendored
Normal file
|
After Width: | Height: | Size: 26 KiB |
BIN
backend/cache/images/2909ef6d7ea6665614cbafa7031afe6b.jpg
vendored
Normal file
|
After Width: | Height: | Size: 26 KiB |
BIN
backend/cache/images/415af139057262a5d7de7c0221798754.jpg
vendored
Normal file
|
After Width: | Height: | Size: 29 KiB |
BIN
backend/cache/images/42f7927393e3e167fd7436d11ca45cc4.jpg
vendored
Normal file
|
After Width: | Height: | Size: 34 KiB |
BIN
backend/cache/images/4913254e3cd3b6f449b7626cfb00abbd.jpg
vendored
Normal file
|
After Width: | Height: | Size: 20 KiB |
BIN
backend/cache/images/495a29ba9081df8481098a2ca0796726.jpg
vendored
Normal file
|
After Width: | Height: | Size: 28 KiB |
BIN
backend/cache/images/563ab85daf0361b446a6a58f690e1fb5.jpg
vendored
Normal file
|
After Width: | Height: | Size: 110 KiB |
BIN
backend/cache/images/5bb569278362af207d22a2a13784f0b0.jpg
vendored
Normal file
|
After Width: | Height: | Size: 28 KiB |
BIN
backend/cache/images/64db92774b883117c531ff1b7dc8b830.jpg
vendored
Normal file
|
After Width: | Height: | Size: 45 KiB |
BIN
backend/cache/images/66debc10258e9b71054ed7d52d7a8b1f.jpg
vendored
Normal file
|
After Width: | Height: | Size: 35 KiB |
BIN
backend/cache/images/6c7531274e55385f37edbd47bbae6d5f.jpg
vendored
Normal file
|
After Width: | Height: | Size: 23 KiB |
BIN
backend/cache/images/72257fdd325da9f90124996dbd7a03f7.jpg
vendored
Normal file
|
After Width: | Height: | Size: 35 KiB |
BIN
backend/cache/images/8adcb8fdb317f3ceb665e387d504c7c5.jpg
vendored
Normal file
|
After Width: | Height: | Size: 23 KiB |
BIN
backend/cache/images/977f099768c8d5d8c597460f3ecbbe78.jpg
vendored
Normal file
|
After Width: | Height: | Size: 36 KiB |
BIN
backend/cache/images/a94aee6527e4a8949cd23f5a15d3a9dd.jpg
vendored
Normal file
|
After Width: | Height: | Size: 44 KiB |
BIN
backend/cache/images/add0acdf732315b6d8faf1340da1bffa.jpg
vendored
Normal file
|
After Width: | Height: | Size: 31 KiB |
BIN
backend/cache/images/af974f055b1ffb40d1b1dbfaa142d837.jpg
vendored
Normal file
|
After Width: | Height: | Size: 22 KiB |
BIN
backend/cache/images/bed842ab15f277935c30824818641942.jpg
vendored
Normal file
|
After Width: | Height: | Size: 37 KiB |
BIN
backend/cache/images/c23b2cb67ea0078953aa9437b5a01d9b.jpg
vendored
Normal file
|
After Width: | Height: | Size: 24 KiB |
BIN
backend/cache/images/cba6e90d7955f81e277ea987e7af1d27.jpg
vendored
Normal file
|
After Width: | Height: | Size: 74 KiB |
BIN
backend/cache/images/cf2f4f9cbffd4eda60316fc031fa6336.jpg
vendored
Normal file
|
After Width: | Height: | Size: 22 KiB |
BIN
backend/cache/images/d9dfb0eb46f632fa74a35701dbf2c644.jpg
vendored
Normal file
|
After Width: | Height: | Size: 19 KiB |
BIN
backend/cache/images/da25e479917b419ea8710368b29892d6.jpg
vendored
Normal file
|
After Width: | Height: | Size: 22 KiB |
BIN
backend/cache/images/de55d41cdb1be82063846c590588550b.jpg
vendored
Normal file
|
After Width: | Height: | Size: 46 KiB |
BIN
backend/cache/images/e1c57185978a94e87d16c83b971f874e.jpg
vendored
Normal file
|
After Width: | Height: | Size: 30 KiB |
BIN
backend/cache/images/e482cbee52cde885e88519f2ec680142.jpg
vendored
Normal file
|
After Width: | Height: | Size: 26 KiB |
BIN
backend/cache/images/ec8ab4998a26430d13349e62f7453a1f.jpg
vendored
Normal file
|
After Width: | Height: | Size: 36 KiB |
BIN
backend/cache/images/f191da9141b464a956ca73b32252afb3.jpg
vendored
Normal file
|
After Width: | Height: | Size: 36 KiB |
BIN
backend/cache/images/f27a350429c57e23b363016424e5389b.jpg
vendored
Normal file
|
After Width: | Height: | Size: 29 KiB |
|
|
@ -299,10 +299,11 @@ func (h *Handler) GetMovieDetail(w http.ResponseWriter, r *http.Request) {
|
|||
|
||||
if len(primaryMovie.Episodes) > 0 {
|
||||
uniqueEps := make([]models.Episode, 0)
|
||||
seenEpNums := make(map[int]bool)
|
||||
seenEpNums := make(map[string]bool)
|
||||
for _, ep := range primaryMovie.Episodes {
|
||||
if !seenEpNums[ep.Number] {
|
||||
seenEpNums[ep.Number] = true
|
||||
key := fmt.Sprintf("%d-%s", ep.Number, ep.ServerName)
|
||||
if !seenEpNums[key] {
|
||||
seenEpNums[key] = true
|
||||
uniqueEps = append(uniqueEps, ep)
|
||||
}
|
||||
}
|
||||
|
|
@ -431,21 +432,23 @@ func (h *Handler) mergeMovieMetadata(existing, new *models.RophimMovie) {
|
|||
existing.Quality = new.Quality
|
||||
}
|
||||
|
||||
epMap := make(map[int]int)
|
||||
for i := range existing.Episodes {
|
||||
epMap[existing.Episodes[i].Number] = i
|
||||
epMap := make(map[string]int)
|
||||
for i, ep := range existing.Episodes {
|
||||
key := fmt.Sprintf("%d-%s", ep.Number, ep.ServerName)
|
||||
epMap[key] = i
|
||||
}
|
||||
|
||||
for i := range new.Episodes {
|
||||
newEp := &new.Episodes[i]
|
||||
if idx, exists := epMap[newEp.Number]; exists {
|
||||
key := fmt.Sprintf("%d-%s", newEp.Number, newEp.ServerName)
|
||||
if idx, exists := epMap[key]; exists {
|
||||
if existing.Episodes[idx].URL == "" && newEp.URL != "" {
|
||||
existing.Episodes[idx].URL = newEp.URL
|
||||
existing.Episodes[idx].Title = newEp.Title
|
||||
existing.Episodes[idx].ServerName = newEp.ServerName
|
||||
}
|
||||
} else {
|
||||
epMap[newEp.Number] = len(existing.Episodes)
|
||||
epMap[key] = len(existing.Episodes)
|
||||
existing.Episodes = append(existing.Episodes, *newEp)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,368 +1,368 @@
|
|||
package scraper
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"streamflow-backend/internal/models"
|
||||
)
|
||||
|
||||
const OphimBaseURL = "https://ophim1.com"
|
||||
|
||||
type OphimScraper struct {
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
func NewOphimScraper() *OphimScraper {
|
||||
return &OphimScraper{
|
||||
client: &http.Client{
|
||||
Timeout: 30 * time.Second,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Response structs for Ophim API
|
||||
|
||||
type OphimResponse struct {
|
||||
Items []OphimItem `json:"items"`
|
||||
Data struct {
|
||||
Items []OphimItem `json:"items"`
|
||||
Item OphimMovie `json:"item"`
|
||||
Episodes []OphimEpisodeServer `json:"episodes,omitempty"` // Sometimes here?
|
||||
} `json:"data"`
|
||||
Movie OphimMovie `json:"movie"`
|
||||
Episodes []OphimEpisodeServer `json:"episodes"`
|
||||
Pagination struct {
|
||||
TotalItems int `json:"totalItems"`
|
||||
TotalItemsPerPage int `json:"totalItemsPerPage"`
|
||||
CurrentPage int `json:"currentPage"`
|
||||
TotalPages int `json:"totalPages"`
|
||||
} `json:"pagination"`
|
||||
}
|
||||
|
||||
type OphimItem struct {
|
||||
Name string `json:"name"`
|
||||
OriginName string `json:"origin_name"`
|
||||
Slug string `json:"slug"`
|
||||
ThumbURL string `json:"thumb_url"`
|
||||
PosterURL string `json:"poster_url"`
|
||||
Year int `json:"year"`
|
||||
Time string `json:"time"`
|
||||
Quality string `json:"quality"`
|
||||
Lang string `json:"lang"`
|
||||
}
|
||||
|
||||
type OphimMovie struct {
|
||||
ID string `json:"_id"`
|
||||
Name string `json:"name"`
|
||||
OriginName string `json:"origin_name"`
|
||||
Slug string `json:"slug"`
|
||||
Content string `json:"content"`
|
||||
ThumbURL string `json:"thumb_url"`
|
||||
PosterURL string `json:"poster_url"`
|
||||
Year int `json:"year"`
|
||||
Time string `json:"time"`
|
||||
Quality string `json:"quality"`
|
||||
Lang string `json:"lang"`
|
||||
Director []string `json:"director"`
|
||||
Category []struct {
|
||||
Name string `json:"name"`
|
||||
} `json:"category"`
|
||||
Country []struct {
|
||||
Name string `json:"name"`
|
||||
} `json:"country"`
|
||||
Episodes []OphimEpisodeServer `json:"episodes,omitempty"` // Nested episodes?
|
||||
TrailerURL string `json:"trailer_url"`
|
||||
}
|
||||
|
||||
type OphimEpisodeServer struct {
|
||||
ServerName string `json:"server_name"`
|
||||
ServerData []OphimEpisodeData `json:"server_data"`
|
||||
}
|
||||
|
||||
type OphimEpisodeData struct {
|
||||
Name string `json:"name"`
|
||||
Slug string `json:"slug"`
|
||||
Filename string `json:"filename"`
|
||||
LinkEmbed string `json:"link_embed"`
|
||||
LinkM3U8 string `json:"link_m3u8"`
|
||||
}
|
||||
|
||||
func (s *OphimScraper) GetMoviesByCategory(category string, page int) ([]models.RophimMovie, error) {
|
||||
// Logic to distinguish between "Lists" (danh-sach) and "Genres" (the-loai)
|
||||
// Known lists: phim-le, phim-bo, hoat-hinh, tv-shows, phim-sap-chieu, phim-dang-chieu
|
||||
var path string
|
||||
switch category {
|
||||
case "home", "":
|
||||
path = "danh-sach/phim-moi-cap-nhat"
|
||||
case "phim-le", "phim-bo", "hoat-hinh", "tv-shows", "phim-sap-chieu", "phim-dang-chieu":
|
||||
path = fmt.Sprintf("danh-sach/%s", category)
|
||||
default:
|
||||
// Assume everything else is a Genre (e.g., hanh-dong, tinh-cam, co-trang)
|
||||
// Ophim uses "the-loai" for these.
|
||||
path = fmt.Sprintf("the-loai/%s", category)
|
||||
}
|
||||
|
||||
// Important: The upstream API endpoints are:
|
||||
// - v1/api/danh-sach/{slug}
|
||||
// - v1/api/the-loai/{slug}
|
||||
// The getList function appends prefix if not present?
|
||||
// s.getList adds "v1/api" prefix? No, currently getList takes full path suffix.
|
||||
// Wait, loop at getList: url := fmt.Sprintf("%s/%s?page=%d", OphimBaseURL, path, page)
|
||||
// So we need to include "v1/api/" in our path variable constructed above.
|
||||
|
||||
finalPath := fmt.Sprintf("v1/api/%s", path)
|
||||
return s.getList(finalPath, page)
|
||||
}
|
||||
|
||||
func (s *OphimScraper) GetHomepageMovies(page int) ([]models.RophimMovie, error) {
|
||||
return s.GetMoviesByCategory("home", page)
|
||||
}
|
||||
|
||||
func (s *OphimScraper) Search(query string, page int) ([]models.RophimMovie, error) {
|
||||
encodedQuery := url.QueryEscape(query)
|
||||
url := fmt.Sprintf("%s/v1/api/tim-kiem?keyword=%s&page=%d", OphimBaseURL, encodedQuery, page)
|
||||
return s.fetchAndParseList(url)
|
||||
}
|
||||
|
||||
func (s *OphimScraper) GetGenres() ([]models.Category, error) {
|
||||
return s.fetchCategories("v1/api/the-loai")
|
||||
}
|
||||
|
||||
func (s *OphimScraper) GetCountries() ([]models.Category, error) {
|
||||
return s.fetchCategories("v1/api/quoc-gia")
|
||||
}
|
||||
|
||||
func (s *OphimScraper) fetchCategories(path string) ([]models.Category, error) {
|
||||
url := fmt.Sprintf("%s/%s", OphimBaseURL, path)
|
||||
resp, err := s.client.Get(url)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var result struct {
|
||||
Data struct {
|
||||
Items []struct {
|
||||
Name string `json:"name"`
|
||||
Slug string `json:"slug"`
|
||||
} `json:"items"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var categories []models.Category
|
||||
for _, item := range result.Data.Items {
|
||||
categories = append(categories, models.Category{
|
||||
Name: item.Name,
|
||||
Slug: item.Slug,
|
||||
})
|
||||
}
|
||||
return categories, nil
|
||||
}
|
||||
|
||||
func (s *OphimScraper) getList(path string, page int) ([]models.RophimMovie, error) {
|
||||
url := fmt.Sprintf("%s/%s?page=%d", OphimBaseURL, path, page)
|
||||
return s.fetchAndParseList(url)
|
||||
}
|
||||
|
||||
func (s *OphimScraper) fetchAndParseList(url string) ([]models.RophimMovie, error) {
|
||||
resp, err := s.client.Get(url)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
return nil, fmt.Errorf("status code error: %d %s", resp.StatusCode, resp.Status)
|
||||
}
|
||||
|
||||
var result OphimResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// API usually returns items in "items" (homepage/list) or "data" sometimes?
|
||||
// The struct OphimResponse has "items".
|
||||
// Search API structure verification:
|
||||
// My previous curl showed "data": { "items": [...] } structure for search?
|
||||
// Wait, checking the curled output from Step 256.
|
||||
// Output: `{"status":true,"msg":"","data":{"seoOnPage":...,"breadCrumb":...,"titlePage":...,"items":[...]`
|
||||
// So Search returns data -> items.
|
||||
// My OphimResponse struct has "Items []OphimItem" at top level.
|
||||
// I need to adjust struct to handle "data" wrapper if present, or "items" if direct.
|
||||
// The homepage returns "items" directly?
|
||||
// Let's check homepage struct. I previously assumed it was directly status, items.
|
||||
// If search has "data", generic parsing might need adjustment.
|
||||
|
||||
// Let's look at the previous successful homepage request.
|
||||
// If it worked, then homepage returns "items" at top level.
|
||||
// If Search returns "data" -> "items", I need a wrapper struct.
|
||||
|
||||
var movies []models.RophimMovie
|
||||
items := result.Items
|
||||
|
||||
// If top level items is empty, try checking if there is a Data field with items
|
||||
// I need to update OphimResponse struct first to include Data field.
|
||||
|
||||
if len(items) == 0 && len(result.Data.Items) > 0 {
|
||||
items = result.Data.Items
|
||||
}
|
||||
|
||||
for _, item := range items {
|
||||
thumb := item.ThumbURL
|
||||
if !strings.HasPrefix(thumb, "http") {
|
||||
// Search API might return relative paths too
|
||||
thumb = "https://img.ophim1.com/uploads/movies/" + thumb
|
||||
}
|
||||
|
||||
backdrop := item.PosterURL
|
||||
if !strings.HasPrefix(backdrop, "http") {
|
||||
backdrop = "https://img.ophim1.com/uploads/movies/" + backdrop
|
||||
}
|
||||
|
||||
movies = append(movies, models.RophimMovie{
|
||||
ID: item.Slug,
|
||||
Title: item.Name,
|
||||
OriginalTitle: item.OriginName,
|
||||
Slug: item.Slug,
|
||||
Thumbnail: thumb,
|
||||
Backdrop: backdrop,
|
||||
Year: item.Year,
|
||||
Category: "movies",
|
||||
Provider: "Ophim",
|
||||
Time: item.Time,
|
||||
Quality: item.Quality,
|
||||
Lang: item.Lang,
|
||||
})
|
||||
}
|
||||
|
||||
return movies, nil
|
||||
}
|
||||
|
||||
func (s *OphimScraper) GetMovieDetail(slug string) (*models.RophimMovie, error) {
|
||||
// Correct API endpoint is v1/api/phim/{slug}
|
||||
url := fmt.Sprintf("%s/v1/api/phim/%s", OphimBaseURL, slug)
|
||||
resp, err := s.client.Get(url)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
return nil, fmt.Errorf("status code error: %d %s", resp.StatusCode, resp.Status)
|
||||
}
|
||||
|
||||
var result OphimResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Try to get movie from Top Level or Data.Item
|
||||
movie := result.Movie
|
||||
if movie.Slug == "" {
|
||||
movie = result.Data.Item
|
||||
}
|
||||
|
||||
thumb := movie.ThumbURL
|
||||
if !strings.HasPrefix(thumb, "http") {
|
||||
thumb = "https://img.ophim1.com/uploads/movies/" + thumb
|
||||
}
|
||||
|
||||
backdrop := movie.PosterURL
|
||||
if !strings.HasPrefix(backdrop, "http") {
|
||||
backdrop = "https://img.ophim1.com/uploads/movies/" + backdrop
|
||||
}
|
||||
|
||||
var episodes []models.Episode
|
||||
// Try Top Level Episodes, then Data.Episodes, then Movie.Episodes?
|
||||
rawEpisodes := result.Episodes
|
||||
if len(rawEpisodes) == 0 {
|
||||
// New API might put episodes inside "item.episodes" or "data.episodes"
|
||||
// Based on typical Ophim structures:
|
||||
if len(result.Data.Episodes) > 0 {
|
||||
rawEpisodes = result.Data.Episodes
|
||||
} else if len(movie.Episodes) > 0 {
|
||||
rawEpisodes = movie.Episodes
|
||||
}
|
||||
}
|
||||
|
||||
epMap := make(map[string]int) // map[epNum-serverName]sliceIndex
|
||||
for _, server := range rawEpisodes {
|
||||
for _, ep := range server.ServerData {
|
||||
epNum := 0
|
||||
fmt.Sscanf(ep.Name, "%d", &epNum)
|
||||
if epNum == 0 {
|
||||
var n int
|
||||
if _, err := fmt.Sscanf(ep.Name, "Tap %d", &n); err == nil {
|
||||
epNum = n
|
||||
}
|
||||
if strings.EqualFold(ep.Name, "Full") || strings.EqualFold(ep.Name, "Trailer") {
|
||||
epNum = 1 // single-movie or trailer as ep 1
|
||||
}
|
||||
|
||||
// If still 0, skip
|
||||
if epNum == 0 {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
serverKey := fmt.Sprintf("%d-%s", epNum, server.ServerName)
|
||||
if idx, exists := epMap[serverKey]; exists {
|
||||
// If existing is empty, replace with this one
|
||||
if episodes[idx].URL == "" && ep.LinkM3U8 != "" {
|
||||
episodes[idx].URL = ep.LinkM3U8
|
||||
episodes[idx].Title = ep.Name
|
||||
}
|
||||
} else {
|
||||
if ep.LinkM3U8 == "" && ep.LinkEmbed == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
epMap[serverKey] = len(episodes)
|
||||
episodes = append(episodes, models.Episode{
|
||||
Number: epNum,
|
||||
Title: ep.Name,
|
||||
URL: ep.LinkM3U8,
|
||||
ServerName: server.ServerName,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return &models.RophimMovie{
|
||||
ID: movie.Slug,
|
||||
Title: movie.Name,
|
||||
OriginalTitle: movie.OriginName,
|
||||
Slug: movie.Slug,
|
||||
Thumbnail: thumb,
|
||||
Backdrop: backdrop,
|
||||
Description: movie.Content,
|
||||
Year: movie.Year,
|
||||
Quality: movie.Quality,
|
||||
Duration: 0, // String parse needed if we want "90 phut"
|
||||
Category: "movies",
|
||||
Episodes: episodes,
|
||||
Country: safeGetName(movie.Country),
|
||||
Director: strings.Join(movie.Director, ", "),
|
||||
Genre: safeGetName(movie.Category),
|
||||
TrailerURL: movie.TrailerURL,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func safeGetName(items []struct {
|
||||
Name string `json:"name"`
|
||||
}) string {
|
||||
var names []string
|
||||
for _, i := range items {
|
||||
names = append(names, i.Name)
|
||||
}
|
||||
return strings.Join(names, ", ")
|
||||
}
|
||||
package scraper
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"streamflow-backend/internal/models"
|
||||
)
|
||||
|
||||
const OphimBaseURL = "https://ophim1.com"
|
||||
|
||||
type OphimScraper struct {
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
func NewOphimScraper() *OphimScraper {
|
||||
return &OphimScraper{
|
||||
client: &http.Client{
|
||||
Timeout: 30 * time.Second,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Response structs for Ophim API
|
||||
|
||||
type OphimResponse struct {
|
||||
Items []OphimItem `json:"items"`
|
||||
Data struct {
|
||||
Items []OphimItem `json:"items"`
|
||||
Item OphimMovie `json:"item"`
|
||||
Episodes []OphimEpisodeServer `json:"episodes,omitempty"` // Sometimes here?
|
||||
} `json:"data"`
|
||||
Movie OphimMovie `json:"movie"`
|
||||
Episodes []OphimEpisodeServer `json:"episodes"`
|
||||
Pagination struct {
|
||||
TotalItems int `json:"totalItems"`
|
||||
TotalItemsPerPage int `json:"totalItemsPerPage"`
|
||||
CurrentPage int `json:"currentPage"`
|
||||
TotalPages int `json:"totalPages"`
|
||||
} `json:"pagination"`
|
||||
}
|
||||
|
||||
type OphimItem struct {
|
||||
Name string `json:"name"`
|
||||
OriginName string `json:"origin_name"`
|
||||
Slug string `json:"slug"`
|
||||
ThumbURL string `json:"thumb_url"`
|
||||
PosterURL string `json:"poster_url"`
|
||||
Year int `json:"year"`
|
||||
Time string `json:"time"`
|
||||
Quality string `json:"quality"`
|
||||
Lang string `json:"lang"`
|
||||
}
|
||||
|
||||
type OphimMovie struct {
|
||||
ID string `json:"_id"`
|
||||
Name string `json:"name"`
|
||||
OriginName string `json:"origin_name"`
|
||||
Slug string `json:"slug"`
|
||||
Content string `json:"content"`
|
||||
ThumbURL string `json:"thumb_url"`
|
||||
PosterURL string `json:"poster_url"`
|
||||
Year int `json:"year"`
|
||||
Time string `json:"time"`
|
||||
Quality string `json:"quality"`
|
||||
Lang string `json:"lang"`
|
||||
Director []string `json:"director"`
|
||||
Category []struct {
|
||||
Name string `json:"name"`
|
||||
} `json:"category"`
|
||||
Country []struct {
|
||||
Name string `json:"name"`
|
||||
} `json:"country"`
|
||||
Episodes []OphimEpisodeServer `json:"episodes,omitempty"` // Nested episodes?
|
||||
TrailerURL string `json:"trailer_url"`
|
||||
}
|
||||
|
||||
type OphimEpisodeServer struct {
|
||||
ServerName string `json:"server_name"`
|
||||
ServerData []OphimEpisodeData `json:"server_data"`
|
||||
}
|
||||
|
||||
type OphimEpisodeData struct {
|
||||
Name string `json:"name"`
|
||||
Slug string `json:"slug"`
|
||||
Filename string `json:"filename"`
|
||||
LinkEmbed string `json:"link_embed"`
|
||||
LinkM3U8 string `json:"link_m3u8"`
|
||||
}
|
||||
|
||||
func (s *OphimScraper) GetMoviesByCategory(category string, page int) ([]models.RophimMovie, error) {
|
||||
// Logic to distinguish between "Lists" (danh-sach) and "Genres" (the-loai)
|
||||
// Known lists: phim-le, phim-bo, hoat-hinh, tv-shows, phim-sap-chieu, phim-dang-chieu
|
||||
var path string
|
||||
switch category {
|
||||
case "home", "":
|
||||
path = "danh-sach/phim-moi-cap-nhat"
|
||||
case "phim-le", "phim-bo", "hoat-hinh", "tv-shows", "phim-sap-chieu", "phim-dang-chieu":
|
||||
path = fmt.Sprintf("danh-sach/%s", category)
|
||||
default:
|
||||
// Assume everything else is a Genre (e.g., hanh-dong, tinh-cam, co-trang)
|
||||
// Ophim uses "the-loai" for these.
|
||||
path = fmt.Sprintf("the-loai/%s", category)
|
||||
}
|
||||
|
||||
// Important: The upstream API endpoints are:
|
||||
// - v1/api/danh-sach/{slug}
|
||||
// - v1/api/the-loai/{slug}
|
||||
// The getList function appends prefix if not present?
|
||||
// s.getList adds "v1/api" prefix? No, currently getList takes full path suffix.
|
||||
// Wait, loop at getList: url := fmt.Sprintf("%s/%s?page=%d", OphimBaseURL, path, page)
|
||||
// So we need to include "v1/api/" in our path variable constructed above.
|
||||
|
||||
finalPath := fmt.Sprintf("v1/api/%s", path)
|
||||
return s.getList(finalPath, page)
|
||||
}
|
||||
|
||||
func (s *OphimScraper) GetHomepageMovies(page int) ([]models.RophimMovie, error) {
|
||||
return s.GetMoviesByCategory("home", page)
|
||||
}
|
||||
|
||||
func (s *OphimScraper) Search(query string, page int) ([]models.RophimMovie, error) {
|
||||
encodedQuery := url.QueryEscape(query)
|
||||
url := fmt.Sprintf("%s/v1/api/tim-kiem?keyword=%s&page=%d", OphimBaseURL, encodedQuery, page)
|
||||
return s.fetchAndParseList(url)
|
||||
}
|
||||
|
||||
func (s *OphimScraper) GetGenres() ([]models.Category, error) {
|
||||
return s.fetchCategories("v1/api/the-loai")
|
||||
}
|
||||
|
||||
func (s *OphimScraper) GetCountries() ([]models.Category, error) {
|
||||
return s.fetchCategories("v1/api/quoc-gia")
|
||||
}
|
||||
|
||||
func (s *OphimScraper) fetchCategories(path string) ([]models.Category, error) {
|
||||
url := fmt.Sprintf("%s/%s", OphimBaseURL, path)
|
||||
resp, err := s.client.Get(url)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var result struct {
|
||||
Data struct {
|
||||
Items []struct {
|
||||
Name string `json:"name"`
|
||||
Slug string `json:"slug"`
|
||||
} `json:"items"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var categories []models.Category
|
||||
for _, item := range result.Data.Items {
|
||||
categories = append(categories, models.Category{
|
||||
Name: item.Name,
|
||||
Slug: item.Slug,
|
||||
})
|
||||
}
|
||||
return categories, nil
|
||||
}
|
||||
|
||||
func (s *OphimScraper) getList(path string, page int) ([]models.RophimMovie, error) {
|
||||
url := fmt.Sprintf("%s/%s?page=%d", OphimBaseURL, path, page)
|
||||
return s.fetchAndParseList(url)
|
||||
}
|
||||
|
||||
func (s *OphimScraper) fetchAndParseList(url string) ([]models.RophimMovie, error) {
|
||||
resp, err := s.client.Get(url)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
return nil, fmt.Errorf("status code error: %d %s", resp.StatusCode, resp.Status)
|
||||
}
|
||||
|
||||
var result OphimResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// API usually returns items in "items" (homepage/list) or "data" sometimes?
|
||||
// The struct OphimResponse has "items".
|
||||
// Search API structure verification:
|
||||
// My previous curl showed "data": { "items": [...] } structure for search?
|
||||
// Wait, checking the curled output from Step 256.
|
||||
// Output: `{"status":true,"msg":"","data":{"seoOnPage":...,"breadCrumb":...,"titlePage":...,"items":[...]`
|
||||
// So Search returns data -> items.
|
||||
// My OphimResponse struct has "Items []OphimItem" at top level.
|
||||
// I need to adjust struct to handle "data" wrapper if present, or "items" if direct.
|
||||
// The homepage returns "items" directly?
|
||||
// Let's check homepage struct. I previously assumed it was directly status, items.
|
||||
// If search has "data", generic parsing might need adjustment.
|
||||
|
||||
// Let's look at the previous successful homepage request.
|
||||
// If it worked, then homepage returns "items" at top level.
|
||||
// If Search returns "data" -> "items", I need a wrapper struct.
|
||||
|
||||
var movies []models.RophimMovie
|
||||
items := result.Items
|
||||
|
||||
// If top level items is empty, try checking if there is a Data field with items
|
||||
// I need to update OphimResponse struct first to include Data field.
|
||||
|
||||
if len(items) == 0 && len(result.Data.Items) > 0 {
|
||||
items = result.Data.Items
|
||||
}
|
||||
|
||||
for _, item := range items {
|
||||
thumb := item.ThumbURL
|
||||
if !strings.HasPrefix(thumb, "http") {
|
||||
// Search API might return relative paths too
|
||||
thumb = "https://img.ophim.live/uploads/movies/" + thumb
|
||||
}
|
||||
|
||||
backdrop := item.PosterURL
|
||||
if !strings.HasPrefix(backdrop, "http") {
|
||||
backdrop = "https://img.ophim.live/uploads/movies/" + backdrop
|
||||
}
|
||||
|
||||
movies = append(movies, models.RophimMovie{
|
||||
ID: item.Slug,
|
||||
Title: item.Name,
|
||||
OriginalTitle: item.OriginName,
|
||||
Slug: item.Slug,
|
||||
Thumbnail: thumb,
|
||||
Backdrop: backdrop,
|
||||
Year: item.Year,
|
||||
Category: "movies",
|
||||
Provider: "Ophim",
|
||||
Time: item.Time,
|
||||
Quality: item.Quality,
|
||||
Lang: item.Lang,
|
||||
})
|
||||
}
|
||||
|
||||
return movies, nil
|
||||
}
|
||||
|
||||
func (s *OphimScraper) GetMovieDetail(slug string) (*models.RophimMovie, error) {
|
||||
// Correct API endpoint is v1/api/phim/{slug}
|
||||
url := fmt.Sprintf("%s/v1/api/phim/%s", OphimBaseURL, slug)
|
||||
resp, err := s.client.Get(url)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
return nil, fmt.Errorf("status code error: %d %s", resp.StatusCode, resp.Status)
|
||||
}
|
||||
|
||||
var result OphimResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Try to get movie from Top Level or Data.Item
|
||||
movie := result.Movie
|
||||
if movie.Slug == "" {
|
||||
movie = result.Data.Item
|
||||
}
|
||||
|
||||
thumb := movie.ThumbURL
|
||||
if !strings.HasPrefix(thumb, "http") {
|
||||
thumb = "https://img.ophim.live/uploads/movies/" + thumb
|
||||
}
|
||||
|
||||
backdrop := movie.PosterURL
|
||||
if !strings.HasPrefix(backdrop, "http") {
|
||||
backdrop = "https://img.ophim.live/uploads/movies/" + backdrop
|
||||
}
|
||||
|
||||
var episodes []models.Episode
|
||||
// Try Top Level Episodes, then Data.Episodes, then Movie.Episodes?
|
||||
rawEpisodes := result.Episodes
|
||||
if len(rawEpisodes) == 0 {
|
||||
// New API might put episodes inside "item.episodes" or "data.episodes"
|
||||
// Based on typical Ophim structures:
|
||||
if len(result.Data.Episodes) > 0 {
|
||||
rawEpisodes = result.Data.Episodes
|
||||
} else if len(movie.Episodes) > 0 {
|
||||
rawEpisodes = movie.Episodes
|
||||
}
|
||||
}
|
||||
|
||||
epMap := make(map[string]int) // map[epNum-serverName]sliceIndex
|
||||
for _, server := range rawEpisodes {
|
||||
for _, ep := range server.ServerData {
|
||||
epNum := 0
|
||||
fmt.Sscanf(ep.Name, "%d", &epNum)
|
||||
if epNum == 0 {
|
||||
var n int
|
||||
if _, err := fmt.Sscanf(ep.Name, "Tap %d", &n); err == nil {
|
||||
epNum = n
|
||||
}
|
||||
if strings.EqualFold(ep.Name, "Full") || strings.EqualFold(ep.Name, "Trailer") {
|
||||
epNum = 1 // single-movie or trailer as ep 1
|
||||
}
|
||||
|
||||
// If still 0, skip
|
||||
if epNum == 0 {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
serverKey := fmt.Sprintf("%d-%s", epNum, server.ServerName)
|
||||
if idx, exists := epMap[serverKey]; exists {
|
||||
// If existing is empty, replace with this one
|
||||
if episodes[idx].URL == "" && ep.LinkM3U8 != "" {
|
||||
episodes[idx].URL = ep.LinkM3U8
|
||||
episodes[idx].Title = ep.Name
|
||||
}
|
||||
} else {
|
||||
if ep.LinkM3U8 == "" && ep.LinkEmbed == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
epMap[serverKey] = len(episodes)
|
||||
episodes = append(episodes, models.Episode{
|
||||
Number: epNum,
|
||||
Title: ep.Name,
|
||||
URL: ep.LinkM3U8,
|
||||
ServerName: server.ServerName,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return &models.RophimMovie{
|
||||
ID: movie.Slug,
|
||||
Title: movie.Name,
|
||||
OriginalTitle: movie.OriginName,
|
||||
Slug: movie.Slug,
|
||||
Thumbnail: thumb,
|
||||
Backdrop: backdrop,
|
||||
Description: movie.Content,
|
||||
Year: movie.Year,
|
||||
Quality: movie.Quality,
|
||||
Duration: 0, // String parse needed if we want "90 phut"
|
||||
Category: "movies",
|
||||
Episodes: episodes,
|
||||
Country: safeGetName(movie.Country),
|
||||
Director: strings.Join(movie.Director, ", "),
|
||||
Genre: safeGetName(movie.Category),
|
||||
TrailerURL: movie.TrailerURL,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func safeGetName(items []struct {
|
||||
Name string `json:"name"`
|
||||
}) string {
|
||||
var names []string
|
||||
for _, i := range items {
|
||||
names = append(names, i.Name)
|
||||
}
|
||||
return strings.Join(names, ", ")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -53,11 +53,38 @@ func (p *Phim30Scraper) Search(query string, page int) ([]models.RophimMovie, er
|
|||
}
|
||||
|
||||
func (p *Phim30Scraper) GetMoviesByCategory(category string, page int) ([]models.RophimMovie, error) {
|
||||
// e.g. https://phim30.me/the-loai/hanh-dong?page=1
|
||||
catURL := fmt.Sprintf("%s/the-loai/%s?page=%d", Phim30BaseURL, category, page)
|
||||
if category == "" || category == "home" {
|
||||
homeURL := fmt.Sprintf("%s/?page=%d", Phim30BaseURL, page)
|
||||
return p.scrapeMovieList(homeURL)
|
||||
}
|
||||
|
||||
var path string
|
||||
switch category {
|
||||
case "phim-le", "phim-bo", "phim-sap-chieu":
|
||||
path = fmt.Sprintf("danh-sach/%s", category)
|
||||
default:
|
||||
// Assume everything else is a Genre (e.g., hanh-dong, hoat-hinh, tv-shows)
|
||||
path = fmt.Sprintf("the-loai/%s", category)
|
||||
}
|
||||
|
||||
catURL := fmt.Sprintf("%s/%s?page=%d", Phim30BaseURL, path, page)
|
||||
return p.scrapeMovieList(catURL)
|
||||
}
|
||||
|
||||
func cleanImageUrl(rawURL string) string {
|
||||
if strings.Contains(rawURL, "cdn-image-tf.phim30.me") {
|
||||
// Example: https://cdn-image-tf.phim30.me/unsafe/360x0/filters:quality(90)/https%3A%2F%2Fphimimg.com%2Fupload%2Fvod%2F...
|
||||
parts := strings.SplitN(rawURL, "/https", 2)
|
||||
if len(parts) == 2 {
|
||||
decoded, err := url.QueryUnescape("https" + parts[1])
|
||||
if err == nil {
|
||||
return decoded
|
||||
}
|
||||
}
|
||||
}
|
||||
return rawURL
|
||||
}
|
||||
|
||||
func (p *Phim30Scraper) scrapeMovieList(targetURL string) ([]models.RophimMovie, error) {
|
||||
req, err := http.NewRequest("GET", targetURL, nil)
|
||||
if err != nil {
|
||||
|
|
@ -86,6 +113,10 @@ func (p *Phim30Scraper) scrapeMovieList(targetURL string) ([]models.RophimMovie,
|
|||
href, _ := s.Attr("href")
|
||||
title, _ := s.Attr("title")
|
||||
|
||||
if title == "" {
|
||||
title = strings.TrimSpace(s.Text())
|
||||
}
|
||||
|
||||
// Remove the base url to get the slug
|
||||
slug := strings.TrimPrefix(href, "https://phim30.me/phim/")
|
||||
|
||||
|
|
@ -104,13 +135,15 @@ func (p *Phim30Scraper) scrapeMovieList(targetURL string) ([]models.RophimMovie,
|
|||
}
|
||||
})
|
||||
|
||||
if title != "" && slug != "" {
|
||||
if title != "" && slug != "" && !strings.Contains(slug, "the-loai") && !strings.Contains(slug, "quoc-gia") && !strings.Contains(slug, "nam-phat-hanh") {
|
||||
movies = append(movies, models.RophimMovie{
|
||||
ID: slug,
|
||||
Slug: slug,
|
||||
Title: title,
|
||||
OriginalTitle: title,
|
||||
Thumbnail: thumb,
|
||||
Thumbnail: cleanImageUrl(thumb),
|
||||
Backdrop: cleanImageUrl(thumb),
|
||||
Provider: "Phim30.me",
|
||||
})
|
||||
}
|
||||
})
|
||||
|
|
@ -165,6 +198,19 @@ func (p *Phim30Scraper) GetMovieDetail(slug string) (*models.RophimMovie, error)
|
|||
movie.Title = title
|
||||
movie.OriginalTitle = title
|
||||
|
||||
thumb := ""
|
||||
doc.Find("div.movie-l-img img").Each(func(i int, img *goquery.Selection) {
|
||||
if src, ok := img.Attr("src"); ok {
|
||||
thumb = src
|
||||
}
|
||||
})
|
||||
if thumb != "" {
|
||||
movie.Thumbnail = cleanImageUrl(thumb)
|
||||
movie.Backdrop = cleanImageUrl(thumb)
|
||||
}
|
||||
|
||||
movie.Provider = "Phim30.me"
|
||||
|
||||
var eps []models.Episode
|
||||
doc.Find("a[href*='/xem-phim/']").Each(func(i int, s *goquery.Selection) {
|
||||
href, _ := s.Attr("href")
|
||||
|
|
|
|||
|
|
@ -4,11 +4,14 @@ import (
|
|||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/PuerkitoBio/goquery"
|
||||
)
|
||||
|
||||
type VideoInfo struct {
|
||||
|
|
@ -33,8 +36,36 @@ func (e *VideoExtractor) Extract(url string, quality string) (*VideoInfo, error)
|
|||
|
||||
// Check for custom extractors
|
||||
if strings.Contains(url, "phim30.me") {
|
||||
// Currently returning the URL as-is, letting yt-dlp attempt extraction
|
||||
// or allowing the frontend iframe to handle it directly if it's embeddable
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create phim30 request: %v", err)
|
||||
}
|
||||
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36")
|
||||
|
||||
client := &http.Client{Timeout: 30 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to fetch phim30 page: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("phim30 returned status: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
doc, err := goquery.NewDocumentFromReader(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse phim30 page: %v", err)
|
||||
}
|
||||
|
||||
streamURL, _ := doc.Find("[data-movie-player-src-value]").Attr("data-movie-player-src-value")
|
||||
if streamURL != "" {
|
||||
return &VideoInfo{
|
||||
StreamURL: streamURL,
|
||||
Resolution: "unknown",
|
||||
}, nil
|
||||
}
|
||||
return nil, fmt.Errorf("could not find stream URL on phim30 page")
|
||||
}
|
||||
|
||||
// Build format selector
|
||||
|
|
|
|||
31
deploy.ps1
|
|
@ -1,31 +0,0 @@
|
|||
# Streamflow Deployment Script
|
||||
# Automates building and pushing Docker images to registries
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
Write-Host "=============================" -ForegroundColor Cyan
|
||||
Write-Host " Streamflow Deployer " -ForegroundColor Cyan
|
||||
Write-Host "=============================" -ForegroundColor Cyan
|
||||
|
||||
# 1. Build
|
||||
Write-Host "`n[1/3] Building Docker Image..." -ForegroundColor White
|
||||
docker build -t streamflow:latest .
|
||||
if ($LASTEXITCODE -ne 0) { Write-Error "Build failed"; exit 1 }
|
||||
Write-Host " -> Build successful" -ForegroundColor Green
|
||||
|
||||
# 2. Push to Docker Hub
|
||||
Write-Host "`n[2/3] Pushing to Docker Hub..." -ForegroundColor White
|
||||
docker tag streamflow:latest vndangkhoa/streamflow:latest
|
||||
docker push vndangkhoa/streamflow:latest
|
||||
if ($LASTEXITCODE -ne 0) { Write-Warning "Docker Hub push failed. Check your login." }
|
||||
else { Write-Host " -> Pushed to Docker Hub" -ForegroundColor Green }
|
||||
|
||||
# 3. Push to Private Registry
|
||||
Write-Host "`n[3/3] Pushing to Private Registry..." -ForegroundColor White
|
||||
docker tag streamflow:latest git.khoavo.myds.me/vndangkhoa/kv-streamflow:latest
|
||||
docker push git.khoavo.myds.me/vndangkhoa/kv-streamflow:latest
|
||||
if ($LASTEXITCODE -ne 0) { Write-Warning "Private Registry push failed. Check VPN/Login." }
|
||||
else { Write-Host " -> Pushed to Private Registry" -ForegroundColor Green }
|
||||
|
||||
Write-Host "`nDeployment Complete!" -ForegroundColor Magenta
|
||||
Start-Sleep -Seconds 5
|
||||
|
|
@ -2,7 +2,7 @@ version: '3.8'
|
|||
|
||||
services:
|
||||
streamflow:
|
||||
image: git.khoavo.myds.me/vndangkhoa/kv-streamflow:v3.9.2
|
||||
image: git.khoavo.myds.me/vndangkhoa/kv-netflix:v6
|
||||
container_name: streamflow
|
||||
platform: linux/amd64
|
||||
ports:
|
||||
|
|
@ -12,10 +12,11 @@ services:
|
|||
- PORT=8000
|
||||
- TZ=Asia/Ho_Chi_Minh
|
||||
volumes:
|
||||
# Synology: Use relative path for data persistence
|
||||
- ./data:/app/data
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: [ "CMD", "wget", "-q", "--spider", "http://localhost:8000/api/health" ]
|
||||
test: ["CMD", "wget", "-q", "--spider", "http://localhost:8000/api/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
|
|
|
|||
1271
frontend-react/package-lock.json
generated
|
|
@ -1,7 +1,6 @@
|
|||
import { Suspense, lazy } from 'react';
|
||||
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom';
|
||||
import ErrorBoundary from './components/ErrorBoundary';
|
||||
import { ThemeProvider } from './context/ThemeContext';
|
||||
|
||||
const Home = lazy(() => import('./pages/Home'));
|
||||
const Watch = lazy(() => import('./pages/Watch'));
|
||||
|
|
@ -17,20 +16,18 @@ function LoadingSpinner() {
|
|||
|
||||
function App() {
|
||||
return (
|
||||
<ThemeProvider>
|
||||
<ErrorBoundary>
|
||||
<Router>
|
||||
<Suspense fallback={<LoadingSpinner />}>
|
||||
<Routes>
|
||||
<Route path="/" element={<Home />} />
|
||||
<Route path="/my-list" element={<MyList />} />
|
||||
<Route path="/watch/:slug/:episode" element={<Watch />} />
|
||||
<Route path="/watch/:slug" element={<Watch />} />
|
||||
</Routes>
|
||||
</Suspense>
|
||||
</Router>
|
||||
</ErrorBoundary>
|
||||
</ThemeProvider>
|
||||
<ErrorBoundary>
|
||||
<Router>
|
||||
<Suspense fallback={<LoadingSpinner />}>
|
||||
<Routes>
|
||||
<Route path="/" element={<Home />} />
|
||||
<Route path="/my-list" element={<MyList />} />
|
||||
<Route path="/watch/:slug/:episode" element={<Watch />} />
|
||||
<Route path="/watch/:slug" element={<Watch />} />
|
||||
</Routes>
|
||||
</Suspense>
|
||||
</Router>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
6
frontend-react/src/components/Card.tsx
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
import { MovieCard } from './MovieCard';
|
||||
import type { Movie } from '../types';
|
||||
|
||||
export const Card = ({ movie }: { movie: Movie }) => {
|
||||
return <MovieCard movie={movie} />;
|
||||
};
|
||||
|
|
@ -32,10 +32,15 @@ export const Hero = ({ movies, variant = 'default' }: HeroProps) => {
|
|||
};
|
||||
|
||||
// Helper to generate robust image URLs
|
||||
const getImageUrl = (url: string | undefined, width: number, blur: number = 0) => {
|
||||
const getImageUrl = (url: string | undefined) => {
|
||||
if (!url) return '';
|
||||
// Unified logic: Simple encoding like Card.tsx, relying on wsrv.nl's robust handling
|
||||
return `https://wsrv.nl/?url=${encodeURIComponent(url)}&w=${width}&output=webp${blur ? `&blur=${blur}` : ''}&fit=cover`;
|
||||
let cleanUrl = url;
|
||||
if (url.startsWith('//')) {
|
||||
cleanUrl = `https:${url}`;
|
||||
} else if (!url.startsWith('http')) {
|
||||
cleanUrl = `https://${url}`;
|
||||
}
|
||||
return cleanUrl;
|
||||
};
|
||||
|
||||
// --- Variant-Specific Styles ---
|
||||
|
|
@ -47,12 +52,12 @@ export const Hero = ({ movies, variant = 'default' }: HeroProps) => {
|
|||
<div className="absolute inset-0 scale-105 transition-transform duration-[10000ms] ease-linear">
|
||||
<img
|
||||
key={movie.id}
|
||||
src={getImageUrl(movie.backdrop || movie.thumbnail, 1600)}
|
||||
src={getImageUrl(movie.backdrop || movie.thumbnail)}
|
||||
alt={movie.title}
|
||||
className="w-full h-full object-cover animate-fade-in"
|
||||
onError={(e) => {
|
||||
if (movie.thumbnail && e.currentTarget.src !== getImageUrl(movie.thumbnail, 1600)) {
|
||||
e.currentTarget.src = getImageUrl(movie.thumbnail, 1600);
|
||||
if (movie.thumbnail && e.currentTarget.src !== getImageUrl(movie.thumbnail)) {
|
||||
e.currentTarget.src = getImageUrl(movie.thumbnail);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
|
@ -114,12 +119,12 @@ export const Hero = ({ movies, variant = 'default' }: HeroProps) => {
|
|||
<div className="absolute inset-0 transition-opacity duration-1000 ease-in-out">
|
||||
<img
|
||||
key={movie.id}
|
||||
src={getImageUrl(movie.backdrop || movie.thumbnail, 1600)}
|
||||
src={getImageUrl(movie.backdrop || movie.thumbnail)}
|
||||
alt={movie.title}
|
||||
className="w-full h-full object-cover mask-image-gradient animate-fade-in"
|
||||
onError={(e) => {
|
||||
if (movie.thumbnail && e.currentTarget.src !== getImageUrl(movie.thumbnail, 1600)) {
|
||||
e.currentTarget.src = getImageUrl(movie.thumbnail, 1600);
|
||||
if (movie.thumbnail && e.currentTarget.src !== getImageUrl(movie.thumbnail)) {
|
||||
e.currentTarget.src = getImageUrl(movie.thumbnail);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
|
@ -183,12 +188,12 @@ export const Hero = ({ movies, variant = 'default' }: HeroProps) => {
|
|||
<img
|
||||
key={`bg-${movie.id}`}
|
||||
// Use thumbnail as safe default since we blur it anyway
|
||||
src={getImageUrl(movie.thumbnail || movie.backdrop, 1000)}
|
||||
src={getImageUrl(movie.thumbnail || movie.backdrop)}
|
||||
alt="Background"
|
||||
referrerPolicy="no-referrer"
|
||||
onError={(e) => {
|
||||
if (movie.thumbnail && e.currentTarget.src !== getImageUrl(movie.thumbnail, 1000)) {
|
||||
e.currentTarget.src = getImageUrl(movie.thumbnail, 1000);
|
||||
if (movie.thumbnail && e.currentTarget.src !== getImageUrl(movie.thumbnail)) {
|
||||
e.currentTarget.src = getImageUrl(movie.thumbnail);
|
||||
}
|
||||
}}
|
||||
className="w-full h-full object-cover opacity-50 scale-110 blur-xl" // CSS Blur instead of API blur
|
||||
|
|
@ -257,7 +262,7 @@ export const Hero = ({ movies, variant = 'default' }: HeroProps) => {
|
|||
|
||||
<img
|
||||
key={`poster-${movie.id}`}
|
||||
src={getImageUrl(movie.thumbnail || movie.backdrop, 600)}
|
||||
src={getImageUrl(movie.thumbnail || movie.backdrop)}
|
||||
alt={movie.title}
|
||||
className="relative w-[280px] lg:w-[350px] aspect-[2/3] object-cover rounded-xl shadow-2xl shadow-black/50 ring-1 ring-white/10 transform transition-all duration-500 group-hover/poster:scale-[1.02] group-hover/poster:-rotate-1"
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import { CATEGORIES } from '../constants';
|
|||
|
||||
import { useMyList } from '../hooks/useMyList';
|
||||
import { useSmartRecommendations } from '../hooks/useSmartRecommendations';
|
||||
import { useWatchProgress } from '../hooks/useWatchProgress';
|
||||
|
||||
interface HomeContentProps {
|
||||
topPadding?: string;
|
||||
|
|
@ -20,6 +21,8 @@ export const HomeContent = ({ topPadding = "pt-24" }: HomeContentProps) => {
|
|||
const [hasMore, setHasMore] = useState(true);
|
||||
|
||||
const { watchHistory, savedMovies } = useMyList(); // Access History and MyList
|
||||
const { getContinueWatchingMovies } = useWatchProgress();
|
||||
const continueWatching = getContinueWatchingMovies();
|
||||
const [searchParams] = useSearchParams();
|
||||
const query = searchParams.get('q');
|
||||
const category = searchParams.get('category');
|
||||
|
|
@ -124,8 +127,8 @@ export const HomeContent = ({ topPadding = "pt-24" }: HomeContentProps) => {
|
|||
{showRows && (
|
||||
<div className="space-y-4 relative z-10 mb-12">
|
||||
{/* Continue Watching Row */}
|
||||
{watchHistory.length > 0 && (
|
||||
<MovieRow title="Tiếp tục xem" movies={watchHistory} />
|
||||
{continueWatching.length > 0 && (
|
||||
<MovieRow title="Tiếp tục xem" movies={continueWatching} />
|
||||
)}
|
||||
|
||||
{/* My List Row */}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { useState } from 'react';
|
|||
import type { ReactNode } from 'react';
|
||||
import { useLocation, Link, useNavigate } from 'react-router-dom';
|
||||
import { Search } from 'lucide-react';
|
||||
import { NAV_ITEMS } from '../../constants';
|
||||
import { NAV_ITEMS } from '../constants';
|
||||
|
||||
export const Layout = ({ children }: { children: ReactNode }) => {
|
||||
const location = useLocation();
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
import { useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Play } from 'lucide-react';
|
||||
import { Play, Image as ImageIcon } from 'lucide-react';
|
||||
import type { Movie } from '../types';
|
||||
|
||||
interface MovieCardProps {
|
||||
|
|
@ -9,10 +10,22 @@ interface MovieCardProps {
|
|||
}
|
||||
|
||||
export const MovieCard = ({ movie, className = '', isDragging = false }: MovieCardProps) => {
|
||||
const getImageUrl = (url: string, width: number) => {
|
||||
const [imgError, setImgError] = useState(false);
|
||||
|
||||
// Calculate progress percentage
|
||||
const progressPercent = movie.watchedTimestamp && movie.duration
|
||||
? (movie.watchedTimestamp / movie.duration) * 100
|
||||
: 0;
|
||||
|
||||
const getImageUrl = (url: string) => {
|
||||
if (!url) return '';
|
||||
const cleanUrl = url.replace('img.ophim1.com', 'ssl:img.ophim1.com');
|
||||
return `https://wsrv.nl/?url=${encodeURIComponent(cleanUrl.replace(/^https?:\/\//, ''))}&w=${width}&output=webp`;
|
||||
let cleanUrl = url;
|
||||
if (url.startsWith('//')) {
|
||||
cleanUrl = `https:${url}`;
|
||||
} else if (!url.startsWith('http')) {
|
||||
cleanUrl = `https://${url}`;
|
||||
}
|
||||
return cleanUrl;
|
||||
};
|
||||
|
||||
return (
|
||||
|
|
@ -24,13 +37,21 @@ export const MovieCard = ({ movie, className = '', isDragging = false }: MovieCa
|
|||
}`}
|
||||
draggable={false}
|
||||
>
|
||||
<img
|
||||
src={getImageUrl(movie.thumbnail, 400)}
|
||||
alt={movie.title}
|
||||
className="w-full h-full object-cover transition-transform duration-700 group-hover/card:scale-110"
|
||||
loading="lazy"
|
||||
draggable={false}
|
||||
/>
|
||||
{!imgError ? (
|
||||
<img
|
||||
src={getImageUrl(movie.thumbnail)}
|
||||
alt={movie.title}
|
||||
onError={() => setImgError(true)}
|
||||
className="w-full h-full object-cover transition-transform duration-700 group-hover/card:scale-110"
|
||||
loading="lazy"
|
||||
draggable={false}
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex flex-col items-center justify-center bg-[#222] text-gray-500 p-4 text-center">
|
||||
<ImageIcon className="w-8 h-8 mb-2 opacity-50" />
|
||||
<span className="text-xs font-medium leading-tight">{movie.title}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Hover Overlay */}
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/90 via-black/20 to-transparent opacity-0 group-hover/card:opacity-100 transition-all duration-500 flex items-center justify-center">
|
||||
|
|
@ -48,6 +69,15 @@ export const MovieCard = ({ movie, className = '', isDragging = false }: MovieCa
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* Episode Badge for Continue Watching */}
|
||||
{movie.currentEpisode && (
|
||||
<div className="absolute top-2 left-2 mt-7">
|
||||
<div className="bg-cyan-500/90 backdrop-blur-md px-1.5 py-0.5 rounded text-[9px] font-bold text-black border border-cyan-400/20">
|
||||
Tập {movie.currentEpisode}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Top-Right Tags (Quality & Lang) */}
|
||||
<div className="absolute top-2 right-2 flex flex-col gap-1.5 items-end">
|
||||
{movie.quality && (
|
||||
|
|
@ -71,6 +101,16 @@ export const MovieCard = ({ movie, className = '', isDragging = false }: MovieCa
|
|||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Progress Bar for Continue Watching */}
|
||||
{progressPercent > 0 && (
|
||||
<div className="absolute bottom-0 left-0 right-0 h-1 bg-gray-600/50">
|
||||
<div
|
||||
className="h-full bg-cyan-500 transition-all duration-300"
|
||||
style={{ width: `${Math.min(progressPercent, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Link>
|
||||
|
||||
{/* Info Section */}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import type { Movie } from '../../types';
|
||||
import type { Movie } from '../types';
|
||||
import { Card } from './Card';
|
||||
|
||||
export const MovieGrid = ({ movies, loading, title }: { movies: Movie[], loading?: boolean, title?: string }) => {
|
||||
|
|
@ -1,88 +0,0 @@
|
|||
import { useState } from 'react';
|
||||
import { Settings, X, Check } from 'lucide-react';
|
||||
import { useTheme } from '../context/ThemeContext';
|
||||
import type { ThemeName } from '../types/Theme';
|
||||
|
||||
export const SettingsPanel = () => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const { currentTheme, setTheme } = useTheme();
|
||||
|
||||
const themes: { id: ThemeName; name: string; color: string }[] = [
|
||||
{ id: 'default', name: 'StreamFlow', color: '#06b6d4' },
|
||||
{ id: 'netflix', name: 'Netflix', color: '#E50914' },
|
||||
{ id: 'apple', name: 'Apple TV+', color: '#FFFFFF' },
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
onClick={() => setIsOpen(true)}
|
||||
className="fixed bottom-24 right-4 md:bottom-6 md:right-6 z-[9999] bg-white/10 hover:bg-white/20 backdrop-blur-md p-3 rounded-full shadow-lg border border-white/10 transition-all text-white"
|
||||
>
|
||||
<Settings className="w-6 h-6 animate-spin-slow" />
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<div className="fixed inset-0 z-[101] flex items-center justify-center p-4">
|
||||
<div
|
||||
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
|
||||
onClick={() => setIsOpen(false)}
|
||||
/>
|
||||
|
||||
<div className="relative bg-[#1a1a1a] border border-white/10 rounded-2xl w-full max-w-sm overflow-hidden shadow-2xl animate-in fade-in zoom-in duration-200">
|
||||
<div className="flex items-center justify-between p-4 border-b border-white/5">
|
||||
<h2 className="text-lg font-bold text-white">Appearance</h2>
|
||||
<button
|
||||
onClick={() => setIsOpen(false)}
|
||||
className="p-1 hover:bg-white/10 rounded-full transition-colors text-gray-400 hover:text-white"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="p-4 space-y-4">
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-gray-400 mb-3 uppercase tracking-wider">Choose Theme</h3>
|
||||
<div className="space-y-2">
|
||||
{themes.map((theme) => (
|
||||
<button
|
||||
key={theme.id}
|
||||
onClick={() => setTheme(theme.id)}
|
||||
className={`w-full flex items-center justify-between p-4 rounded-xl border transition-all ${currentTheme === theme.id
|
||||
? 'bg-white/10 border-white/20'
|
||||
: 'bg-transparent border-white/5 hover:bg-white/5'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className="w-10 h-10 rounded-lg shadow-inner flex items-center justify-center font-bold text-white text-xs"
|
||||
style={{ backgroundColor: theme.id === 'netflix' ? '#000' : '#111' }}
|
||||
>
|
||||
<span style={{ color: theme.color }}>
|
||||
{theme.name.charAt(0)}
|
||||
</span>
|
||||
</div>
|
||||
<span className="font-medium text-white">{theme.name}</span>
|
||||
</div>
|
||||
{currentTheme === theme.id && (
|
||||
<div className="bg-green-500 rounded-full p-1">
|
||||
<Check className="w-3 h-3 text-white" />
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-blue-500/10 border border-blue-500/20 rounded-lg p-3">
|
||||
<p className="text-xs text-blue-200 text-center">
|
||||
Switching themes completely changes the layout and browsing experience.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,43 +0,0 @@
|
|||
import React, { createContext, useContext, useState, useEffect } from 'react';
|
||||
import type { ThemeName } from '../types/Theme';
|
||||
|
||||
// We will import the actual theme objects here once they are created
|
||||
// import { netflixTheme } from '../themes/netflix';
|
||||
// import { appleTheme } from '../themes/apple';
|
||||
|
||||
interface ThemeContextType {
|
||||
currentTheme: ThemeName;
|
||||
setTheme: (theme: ThemeName) => void;
|
||||
|
||||
// For now, we'll just store the ID. Later we will expose the full theme object
|
||||
// theme: Theme;
|
||||
}
|
||||
|
||||
const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
|
||||
|
||||
export const ThemeProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const [currentTheme, setCurrentTheme] = useState<ThemeName>(() => {
|
||||
const saved = localStorage.getItem('app-theme');
|
||||
return (saved as ThemeName) || 'netflix';
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem('app-theme', currentTheme);
|
||||
// We can also set a class on the body if global styles need it
|
||||
document.body.className = `theme-${currentTheme}`;
|
||||
}, [currentTheme]);
|
||||
|
||||
return (
|
||||
<ThemeContext.Provider value={{ currentTheme, setTheme: setCurrentTheme }}>
|
||||
{children}
|
||||
</ThemeContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useTheme = () => {
|
||||
const context = useContext(ThemeContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('useTheme must be used within a ThemeProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
import { useState, useEffect, useRef } from 'react';
|
||||
import Hls from 'hls.js';
|
||||
import type { MovieDetail, VideoSource } from '../types';
|
||||
import { useWatchProgress } from './useWatchProgress';
|
||||
|
||||
export const useWatchMovie = (slug: string | undefined, episode: string | undefined) => {
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
|
|
@ -8,6 +9,40 @@ export const useWatchMovie = (slug: string | undefined, episode: string | undefi
|
|||
const [source, setSource] = useState<VideoSource | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [currentEpisode, setCurrentEpisode] = useState(parseInt(episode || '1'));
|
||||
const { getProgress, saveProgress, clearProgress } = useWatchProgress();
|
||||
const saveIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
// Refs to avoid effect re-running when these functions change
|
||||
const getProgressRef = useRef(getProgress);
|
||||
const saveProgressRef = useRef(saveProgress);
|
||||
const clearProgressRef = useRef(clearProgress);
|
||||
const movieRef = useRef(movie);
|
||||
|
||||
// Update refs when values change
|
||||
useEffect(() => {
|
||||
getProgressRef.current = getProgress;
|
||||
}, [getProgress]);
|
||||
|
||||
useEffect(() => {
|
||||
saveProgressRef.current = saveProgress;
|
||||
}, [saveProgress]);
|
||||
|
||||
useEffect(() => {
|
||||
clearProgressRef.current = clearProgress;
|
||||
}, [clearProgress]);
|
||||
|
||||
useEffect(() => {
|
||||
movieRef.current = movie;
|
||||
}, [movie]);
|
||||
|
||||
// Load saved progress on mount
|
||||
useEffect(() => {
|
||||
if (!slug) return;
|
||||
const progress = getProgress(slug);
|
||||
if (progress) {
|
||||
setCurrentEpisode(progress.episode);
|
||||
}
|
||||
}, [slug, getProgress]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!slug) return;
|
||||
|
|
@ -24,6 +59,16 @@ export const useWatchMovie = (slug: string | undefined, episode: string | undefi
|
|||
fetchDetails();
|
||||
}, [slug]);
|
||||
|
||||
// Save progress when episode changes
|
||||
useEffect(() => {
|
||||
if (!slug) return;
|
||||
const progress = getProgress(slug);
|
||||
if (progress && progress.episode !== currentEpisode) {
|
||||
// Clear old progress when switching episodes
|
||||
clearProgress(slug);
|
||||
}
|
||||
}, [currentEpisode, slug, getProgress, clearProgress]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!movie) return;
|
||||
|
||||
|
|
@ -56,7 +101,7 @@ export const useWatchMovie = (slug: string | undefined, episode: string | undefi
|
|||
const res = await fetch(`/api/extract`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ url: targetUrl }) // Changed to JSON payload
|
||||
body: JSON.stringify({ url: targetUrl })
|
||||
});
|
||||
|
||||
if (!res.ok) throw new Error('Failed to extract');
|
||||
|
|
@ -75,31 +120,83 @@ export const useWatchMovie = (slug: string | undefined, episode: string | undefi
|
|||
};
|
||||
|
||||
fetchStream();
|
||||
}, [movie, currentEpisode, slug]);
|
||||
}, [movie, currentEpisode, slug]);
|
||||
|
||||
// Save progress periodically and seek to saved position
|
||||
useEffect(() => {
|
||||
if (source && videoRef.current) {
|
||||
console.log("Initializing player with source:", source);
|
||||
const isHls = source.stream_url.includes('.m3u8') || source.format_id === 'hls';
|
||||
console.log("Is HLS:", isHls, "Stream URL:", source.stream_url);
|
||||
if (!source || !videoRef.current || !slug) return;
|
||||
|
||||
if (isHls && Hls.isSupported()) {
|
||||
const hls = new Hls();
|
||||
hls.loadSource(source.stream_url);
|
||||
hls.attachMedia(videoRef.current);
|
||||
hls.on(Hls.Events.MANIFEST_PARSED, () => {
|
||||
videoRef.current?.play().catch(() => { });
|
||||
});
|
||||
return () => {
|
||||
hls.destroy();
|
||||
};
|
||||
} else {
|
||||
// MP4 or Native HLS (Safari)
|
||||
videoRef.current.src = source.stream_url;
|
||||
videoRef.current.play().catch(() => { });
|
||||
const video = videoRef.current;
|
||||
let hls: Hls | null = null;
|
||||
|
||||
const saveCurrentProgress = () => {
|
||||
if (video && slug && movieRef.current) {
|
||||
const currentTime = video.currentTime;
|
||||
const duration = video.duration;
|
||||
if (duration > 0) {
|
||||
saveProgressRef.current(slug, currentEpisode, currentTime, duration, {
|
||||
title: movieRef.current.title,
|
||||
thumbnail: movieRef.current.thumbnail,
|
||||
backdrop: movieRef.current.backdrop,
|
||||
year: movieRef.current.year,
|
||||
category: movieRef.current.category,
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const onLoadedMetadata = () => {
|
||||
// Seek to saved position (minus 20s) if available
|
||||
const progress = getProgressRef.current(slug);
|
||||
if (progress && progress.episode === currentEpisode && progress.timestamp > 0) {
|
||||
// Rewind 20 seconds so user doesn't miss the exact moment
|
||||
video.currentTime = Math.max(0, progress.timestamp - 20);
|
||||
}
|
||||
};
|
||||
|
||||
const onPause = () => {
|
||||
saveCurrentProgress();
|
||||
};
|
||||
|
||||
const onEnded = () => {
|
||||
// Clear progress when video ends
|
||||
clearProgressRef.current(slug);
|
||||
};
|
||||
|
||||
const isHls = source.stream_url.includes('.m3u8') || source.format_id === 'hls';
|
||||
|
||||
if (isHls && Hls.isSupported()) {
|
||||
hls = new Hls();
|
||||
hls.loadSource(source.stream_url);
|
||||
hls.attachMedia(video);
|
||||
hls.on(Hls.Events.MANIFEST_PARSED, () => {
|
||||
video.play().catch(() => { });
|
||||
});
|
||||
} else {
|
||||
video.src = source.stream_url;
|
||||
video.play().catch(() => { });
|
||||
}
|
||||
}, [source]);
|
||||
|
||||
video.addEventListener('loadedmetadata', onLoadedMetadata);
|
||||
video.addEventListener('pause', onPause);
|
||||
video.addEventListener('ended', onEnded);
|
||||
|
||||
// Save progress every 5 seconds
|
||||
saveIntervalRef.current = setInterval(saveCurrentProgress, 5000);
|
||||
|
||||
return () => {
|
||||
if (hls) hls.destroy();
|
||||
video.removeEventListener('loadedmetadata', onLoadedMetadata);
|
||||
video.removeEventListener('pause', onPause);
|
||||
video.removeEventListener('ended', onEnded);
|
||||
if (saveIntervalRef.current) {
|
||||
clearInterval(saveIntervalRef.current);
|
||||
saveIntervalRef.current = null;
|
||||
}
|
||||
// Save final progress on unmount
|
||||
saveCurrentProgress();
|
||||
};
|
||||
}, [source, slug, currentEpisode]);
|
||||
|
||||
// Wake Lock Logic (Prevent Screen Sleep)
|
||||
useEffect(() => {
|
||||
|
|
|
|||
123
frontend-react/src/hooks/useWatchProgress.ts
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
import { useState, useEffect, useCallback } from 'react';
|
||||
import type { Movie } from '../types';
|
||||
|
||||
const STORAGE_KEY = 'streamflow_watch_progress';
|
||||
|
||||
interface ProgressData {
|
||||
episode: number;
|
||||
timestamp: number;
|
||||
duration: number;
|
||||
updatedAt: string;
|
||||
movieTitle?: string;
|
||||
movieThumbnail?: string;
|
||||
movieBackdrop?: string;
|
||||
movieYear?: number;
|
||||
movieCategory?: string;
|
||||
}
|
||||
|
||||
interface StoredProgress {
|
||||
[slug: string]: ProgressData;
|
||||
}
|
||||
|
||||
export interface WatchProgress extends ProgressData {
|
||||
slug: string;
|
||||
movieTitle?: string;
|
||||
movieThumbnail?: string;
|
||||
movieBackdrop?: string;
|
||||
movieYear?: number;
|
||||
movieCategory?: string;
|
||||
}
|
||||
|
||||
export const useWatchProgress = () => {
|
||||
const [progressMap, setProgressMap] = useState<StoredProgress>(() => {
|
||||
try {
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
return stored ? JSON.parse(stored) : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(progressMap));
|
||||
} catch (e) {
|
||||
console.error('Failed to save watch progress:', e);
|
||||
}
|
||||
}, [progressMap]);
|
||||
|
||||
const getProgress = useCallback((slug: string): ProgressData | null => {
|
||||
return progressMap[slug] || null;
|
||||
}, [progressMap]);
|
||||
|
||||
const saveProgress = useCallback((slug: string, episode: number, timestamp: number, duration: number, movieInfo?: {
|
||||
title?: string;
|
||||
thumbnail?: string;
|
||||
backdrop?: string;
|
||||
year?: number;
|
||||
category?: string;
|
||||
}) => {
|
||||
setProgressMap(prev => ({
|
||||
...prev,
|
||||
[slug]: {
|
||||
episode,
|
||||
timestamp,
|
||||
duration,
|
||||
updatedAt: new Date().toISOString(),
|
||||
movieTitle: movieInfo?.title,
|
||||
movieThumbnail: movieInfo?.thumbnail,
|
||||
movieBackdrop: movieInfo?.backdrop,
|
||||
movieYear: movieInfo?.year,
|
||||
movieCategory: movieInfo?.category,
|
||||
}
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const getAllProgress = useCallback((): WatchProgress[] => {
|
||||
return Object.entries(progressMap)
|
||||
.map(([slug, data]) => ({
|
||||
slug,
|
||||
...data,
|
||||
}))
|
||||
.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime());
|
||||
}, [progressMap]);
|
||||
|
||||
const getContinueWatchingMovies = useCallback(() => {
|
||||
return Object.entries(progressMap)
|
||||
.map(([slug, data]) => ({
|
||||
id: slug,
|
||||
title: data.movieTitle || slug,
|
||||
slug: slug,
|
||||
thumbnail: data.movieThumbnail || '',
|
||||
backdrop: data.movieBackdrop || undefined,
|
||||
year: data.movieYear || undefined,
|
||||
category: data.movieCategory || 'movies',
|
||||
// Add progress info for display
|
||||
currentEpisode: data.episode,
|
||||
watchedTimestamp: data.timestamp,
|
||||
duration: data.duration,
|
||||
} as Movie))
|
||||
.sort((a, b) => new Date(progressMap[b.slug!].updatedAt).getTime() - new Date(progressMap[a.slug!].updatedAt).getTime());
|
||||
}, [progressMap]);
|
||||
|
||||
const clearProgress = useCallback((slug: string) => {
|
||||
setProgressMap(prev => {
|
||||
const newMap = { ...prev };
|
||||
delete newMap[slug];
|
||||
return newMap;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const clearAllProgress = useCallback(() => {
|
||||
setProgressMap({});
|
||||
}, []);
|
||||
|
||||
return {
|
||||
getProgress,
|
||||
saveProgress,
|
||||
getAllProgress,
|
||||
getContinueWatchingMovies,
|
||||
clearProgress,
|
||||
clearAllProgress,
|
||||
};
|
||||
};
|
||||
|
|
@ -1,21 +1,7 @@
|
|||
import { useTheme } from '../context/ThemeContext';
|
||||
import { netflixTheme } from '../themes/netflix';
|
||||
import { appleTheme } from '../themes/apple';
|
||||
|
||||
import { defaultTheme } from '../themes/default';
|
||||
|
||||
const themes = {
|
||||
default: defaultTheme,
|
||||
netflix: netflixTheme,
|
||||
apple: appleTheme,
|
||||
};
|
||||
|
||||
const Home = () => {
|
||||
const { currentTheme } = useTheme();
|
||||
|
||||
// Dynamically select the Home component based on the current theme
|
||||
const ActiveTheme = themes[currentTheme];
|
||||
const ThemeHome = ActiveTheme.components.Home;
|
||||
const ThemeHome = defaultTheme.components.Home;
|
||||
|
||||
return <ThemeHome />;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,22 +1,10 @@
|
|||
import { useTheme } from '../context/ThemeContext';
|
||||
import { netflixTheme } from '../themes/netflix';
|
||||
import { appleTheme } from '../themes/apple';
|
||||
import { useMyList } from '../hooks/useMyList';
|
||||
import { SettingsPanel } from '../components/SettingsPanel';
|
||||
|
||||
import { defaultTheme } from '../themes/default';
|
||||
|
||||
const themes = {
|
||||
netflix: netflixTheme,
|
||||
apple: appleTheme,
|
||||
default: defaultTheme,
|
||||
};
|
||||
|
||||
const MyList = () => {
|
||||
const { currentTheme } = useTheme();
|
||||
const { savedMovies, watchHistory } = useMyList();
|
||||
const ActiveTheme = themes[currentTheme];
|
||||
const { Layout, MovieGrid } = ActiveTheme.components;
|
||||
const { Layout, MovieGrid } = defaultTheme.components;
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
|
|
@ -38,7 +26,6 @@ const MyList = () => {
|
|||
</div>
|
||||
)}
|
||||
</div>
|
||||
<SettingsPanel />
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,21 +1,11 @@
|
|||
import { useEffect } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { useTheme } from '../context/ThemeContext';
|
||||
import { netflixTheme } from '../themes/netflix';
|
||||
import { appleTheme } from '../themes/apple';
|
||||
import { useMyList } from '../hooks/useMyList';
|
||||
|
||||
import { defaultTheme } from '../themes/default';
|
||||
|
||||
const themes = {
|
||||
netflix: netflixTheme,
|
||||
apple: appleTheme,
|
||||
default: defaultTheme,
|
||||
};
|
||||
|
||||
const Watch = () => {
|
||||
const { slug, episode } = useParams();
|
||||
const { currentTheme } = useTheme();
|
||||
const { addToHistory } = useMyList();
|
||||
|
||||
// Fetch movie detail to get info for history
|
||||
|
|
@ -48,9 +38,7 @@ const Watch = () => {
|
|||
fetchDetail();
|
||||
}, [slug]);
|
||||
|
||||
// Select the current theme components
|
||||
const ActiveTheme = themes[currentTheme];
|
||||
const { WatchPage } = ActiveTheme.components;
|
||||
const { WatchPage } = defaultTheme.components;
|
||||
|
||||
return <WatchPage slug={slug || ''} episode={episode || '1'} />;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,15 +0,0 @@
|
|||
import { Layout } from './Layout';
|
||||
import { HomeContent } from '../../components/HomeContent';
|
||||
import { SettingsPanel } from '../../components/SettingsPanel';
|
||||
|
||||
export const AppleHome = () => {
|
||||
return (
|
||||
<Layout>
|
||||
{/* Apple Theme usually has a dark gradient header, but HomeContent handles general layout */}
|
||||
<div className="min-h-screen bg-black">
|
||||
<HomeContent topPadding="pt-24" />
|
||||
</div>
|
||||
<SettingsPanel />
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,41 +0,0 @@
|
|||
import type { Movie } from '../../types';
|
||||
import { Play } from 'lucide-react';
|
||||
|
||||
export const Card = ({ movie }: { movie: Movie }) => {
|
||||
return (
|
||||
<div className="group flex flex-col gap-3 cursor-pointer">
|
||||
<a href={`/watch/${movie.slug}`} className="relative">
|
||||
<div className="aspect-[2/3] relative rounded-xl overflow-hidden shadow-lg group-hover:shadow-2xl transition-all duration-300">
|
||||
<img
|
||||
src={`https://wsrv.nl/?url=${encodeURIComponent(movie.thumbnail)}&w=500&output=webp`}
|
||||
alt={movie.title}
|
||||
className="w-full h-full object-cover transition-transform duration-500 group-hover:scale-110"
|
||||
loading="lazy"
|
||||
/>
|
||||
|
||||
<div className="absolute inset-0 bg-black/20 opacity-0 group-hover:opacity-100 transition-opacity duration-300 flex items-center justify-center">
|
||||
<div className="bg-white/90 text-black rounded-full p-4 transform scale-50 group-hover:scale-100 transition-all duration-300 shadow-xl">
|
||||
<Play className="w-6 h-6 fill-current" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Glass Badge */}
|
||||
{movie.quality && (
|
||||
<div className="absolute bottom-3 right-3 bg-white/10 backdrop-blur-md px-2 py-1 rounded-md text-[10px] text-white/90 font-medium border border-white/10">
|
||||
{movie.quality}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<div className="px-1 space-y-1">
|
||||
<h3 className="font-semibold text-white/90 text-[15px] leading-tight truncate group-hover:text-white transition-colors">
|
||||
{movie.title}
|
||||
</h3>
|
||||
<p className="text-white/40 text-xs font-medium truncate">
|
||||
{movie.original_title || movie.year || '2024'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,86 +0,0 @@
|
|||
import { useState, useEffect } from 'react';
|
||||
import { Plus, Check, Play } from 'lucide-react';
|
||||
import type { Movie } from '../../types';
|
||||
import { useMyList } from '../../hooks/useMyList';
|
||||
|
||||
export const Hero = ({ movies }: { movies: Movie[] }) => {
|
||||
const [index, setIndex] = useState(0);
|
||||
const { addToList, removeFromList, isSaved } = useMyList();
|
||||
|
||||
useEffect(() => {
|
||||
if (movies.length <= 1) return;
|
||||
const interval = setInterval(() => {
|
||||
setIndex((prev) => (prev + 1) % movies.length);
|
||||
}, 8000);
|
||||
return () => clearInterval(interval);
|
||||
}, [movies]);
|
||||
|
||||
if (!movies || movies.length === 0) return null;
|
||||
|
||||
const movie = movies[index];
|
||||
const saved = isSaved(movie.id);
|
||||
|
||||
const toggleList = () => {
|
||||
if (saved) removeFromList(movie.id);
|
||||
else addToList(movie);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative h-[85vh] w-full overflow-hidden group">
|
||||
<div className="absolute inset-0 scale-105 transition-transform duration-[10000ms] ease-linear">
|
||||
<img
|
||||
key={movie.id}
|
||||
src={`https://wsrv.nl/?url=${encodeURIComponent(movie.backdrop || movie.thumbnail)}&w=1600&output=webp`}
|
||||
alt={movie.title}
|
||||
className="w-full h-full object-cover animate-fade-in"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black via-transparent to-black/30" />
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-black/60 via-transparent to-transparent" />
|
||||
</div>
|
||||
|
||||
<div className="absolute bottom-0 left-0 w-full p-8 md:p-16 lg:p-24 pb-20 z-10">
|
||||
<div className="max-w-3xl space-y-6">
|
||||
<div className="inline-flex items-center gap-2 bg-white/10 backdrop-blur-md border border-white/10 px-3 py-1 rounded-full animate-slide-up">
|
||||
<span className="text-[10px] font-bold tracking-widest uppercase text-white/90">Premiere</span>
|
||||
</div>
|
||||
|
||||
<h1 className="text-5xl md:text-7xl font-bold text-white tracking-tight drop-shadow-[0_2px_10px_rgba(0,0,0,0.5)] line-clamp-2 animate-slide-up" style={{ animationDelay: '100ms' }}>
|
||||
{movie.title}
|
||||
</h1>
|
||||
|
||||
{movie.original_title && (
|
||||
<p className="text-xl text-white/70 font-medium animate-slide-up" style={{ animationDelay: '200ms' }}>{movie.original_title}</p>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-4 pt-4 animate-slide-up" style={{ animationDelay: '300ms' }}>
|
||||
<a
|
||||
href={`/watch/${movie.slug}`}
|
||||
className="bg-white text-black px-8 py-3.5 rounded-full font-bold text-sm tracking-wide hover:scale-105 transition-transform duration-200 flex items-center gap-2"
|
||||
>
|
||||
<Play className="w-4 h-4 fill-current" />
|
||||
Play
|
||||
</a>
|
||||
<button
|
||||
onClick={toggleList}
|
||||
className="bg-white/10 backdrop-blur-md text-white px-8 py-3.5 rounded-full font-bold text-sm tracking-wide border border-white/20 hover:bg-white/20 transition-colors flex items-center gap-2"
|
||||
>
|
||||
{saved ? <Check className="w-4 h-4" /> : <Plus className="w-4 h-4" />}
|
||||
{saved ? 'In Up Next' : 'Add to Up Next'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Carousel Dots */}
|
||||
<div className="absolute bottom-8 left-1/2 -translate-x-1/2 flex gap-3 z-20">
|
||||
{movies.map((_, i) => (
|
||||
<button
|
||||
key={i}
|
||||
onClick={() => setIndex(i)}
|
||||
className={`w-2 h-2 rounded-full transition-all ${i === index ? 'bg-white w-4' : 'bg-white/30 hover:bg-white/50'}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,172 +0,0 @@
|
|||
import { useState, useEffect } from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { Link, useNavigate, useLocation } from 'react-router-dom';
|
||||
import { Search, Apple, Home, Film, Tv, Sparkles, MonitorPlay } from 'lucide-react';
|
||||
import { CATEGORIES } from '../../constants';
|
||||
|
||||
export const Layout = ({ children }: { children: ReactNode }) => {
|
||||
const [scrolled, setScrolled] = useState(false);
|
||||
const [isSearchOpen, setIsSearchOpen] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
|
||||
useEffect(() => {
|
||||
const handleScroll = () => {
|
||||
setScrolled(window.scrollY > 20);
|
||||
};
|
||||
window.addEventListener('scroll', handleScroll);
|
||||
return () => window.removeEventListener('scroll', handleScroll);
|
||||
}, []);
|
||||
|
||||
const handleSearch = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (searchQuery.trim()) {
|
||||
navigate(`/?q=${encodeURIComponent(searchQuery)}`);
|
||||
setIsSearchOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#000000] text-white selection:bg-white/20">
|
||||
{/* Glass Navbar */}
|
||||
<nav className={`fixed top-0 w-full z-50 transition-all duration-500 ${scrolled || isSearchOpen
|
||||
? 'bg-[#1a1a1a]/90 backdrop-blur-xl border-b border-white/5'
|
||||
: 'bg-gradient-to-b from-black/80 to-transparent'
|
||||
}`}>
|
||||
<div className="max-w-[1600px] mx-auto px-6 lg:px-12 h-16 flex items-center justify-between">
|
||||
<div className="flex items-center gap-12">
|
||||
<Link to="/" className="text-white hover:opacity-80 transition-opacity">
|
||||
{/* Mock Apple Logo */}
|
||||
<div className="flex items-center gap-1 font-semibold tracking-tight text-xl">
|
||||
<Apple className="w-5 h-5 mb-1" />
|
||||
<span>TV+</span>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
<div className="hidden md:flex items-center gap-8">
|
||||
<Link to="/" className="text-sm font-medium text-white/90 hover:text-white transition-colors">Home</Link>
|
||||
{CATEGORIES.map((item) => (
|
||||
<Link
|
||||
key={item.id}
|
||||
to={item.path}
|
||||
className="text-sm font-medium text-white/70 hover:text-white transition-colors"
|
||||
>
|
||||
{item.name}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-6">
|
||||
{/* Install App Button (PC/Tablet) */}
|
||||
<a
|
||||
href="/streamflow-tv.apk"
|
||||
download="streamflow-tv.apk"
|
||||
className="hidden lg:flex items-center gap-2 px-5 py-2.5 bg-white text-black hover:bg-white/90 text-sm font-bold rounded-full transition-all duration-300 shadow-xl shadow-white/5 active:scale-95"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="w-4 h-4"
|
||||
>
|
||||
<rect width="20" height="15" x="2" y="7" rx="2" ry="2" />
|
||||
<polyline points="17 2 12 7 7 2" />
|
||||
</svg>
|
||||
<span>TV APP</span>
|
||||
</a>
|
||||
|
||||
<div className={`relative group flex items-center transition-all duration-300 ${isSearchOpen ? 'w-64 bg-white/10 rounded-lg px-2' : 'w-8'}`}>
|
||||
<Search
|
||||
className="w-4 h-4 text-white/70 group-hover:text-white transition-colors cursor-pointer"
|
||||
onClick={() => setIsSearchOpen(true)}
|
||||
/>
|
||||
{isSearchOpen && (
|
||||
<form onSubmit={handleSearch} className="flex-1">
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder="Search movies..."
|
||||
className="w-full bg-transparent border-none outline-none focus:outline-none focus:ring-0 text-white text-sm placeholder:text-gray-400 ml-2 h-8"
|
||||
autoFocus
|
||||
onBlur={() => !searchQuery && setIsSearchOpen(false)}
|
||||
/>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
<div className="w-8 h-8 rounded-full bg-gradient-to-tr from-blue-500 to-purple-500 p-[1px]">
|
||||
<div className="w-full h-full rounded-full bg-black flex items-center justify-center text-xs font-bold">
|
||||
K
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{/* Mobile Bottom Nav */}
|
||||
<nav className="md:hidden fixed bottom-0 left-0 right-0 z-50 bg-[#161616]/80 backdrop-blur-2xl border-t border-white/5 pb-safe">
|
||||
<div className="flex items-center justify-around h-20 px-2 pb-2">
|
||||
<Link to="/" className={`flex flex-col items-center gap-1.5 p-2 transition-colors ${location.pathname === '/' ? 'text-white' : 'text-white/40 hover:text-white/70'}`}>
|
||||
<Home className={`w-6 h-6 ${location.pathname === '/' ? 'fill-current' : ''}`} strokeWidth={location.pathname === '/' ? 2.5 : 2} />
|
||||
<span className="text-[10px] font-medium tracking-wide">Home</span>
|
||||
</Link>
|
||||
{CATEGORIES.slice(0, 3).map((item) => {
|
||||
const getCategoryIcon = (id: string) => {
|
||||
switch (id) {
|
||||
case 'phim-le': return Film;
|
||||
case 'phim-bo': return Tv; // Series implies TV
|
||||
case 'hoat-hinh': return Sparkles; // Animation
|
||||
case 'tv-shows': return MonitorPlay;
|
||||
default: return Film;
|
||||
}
|
||||
};
|
||||
const Icon = getCategoryIcon(item.id);
|
||||
const isActive = location.pathname === item.path;
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={item.id}
|
||||
to={item.path}
|
||||
className={`flex flex-col items-center gap-1.5 p-2 transition-colors ${isActive ? 'text-white' : 'text-white/40 hover:text-white/70'}`}
|
||||
>
|
||||
<Icon className={`w-6 h-6 ${isActive ? 'fill-current' : ''}`} strokeWidth={isActive ? 2.5 : 2} />
|
||||
<span className="text-[10px] font-medium tracking-wide">{item.name}</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
{/* APK Download in Mobile Nav */}
|
||||
<a
|
||||
href="/streamflow-tv.apk"
|
||||
download="streamflow-tv.apk"
|
||||
className="flex flex-col items-center gap-1.5 p-2 text-white animate-pulse"
|
||||
>
|
||||
<div className="p-1 rounded bg-white text-black">
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="3"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="w-5 h-5"
|
||||
>
|
||||
<rect width="20" height="15" x="2" y="7" rx="2" ry="2" />
|
||||
<polyline points="17 2 12 7 7 2" />
|
||||
</svg>
|
||||
</div>
|
||||
<span className="text-[10px] font-bold tracking-wide">TV APP</span>
|
||||
</a>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<main className="w-full pb-20 md:pb-0">
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,32 +0,0 @@
|
|||
import type { Movie } from '../../types';
|
||||
import { Card } from './Card';
|
||||
|
||||
export const MovieGrid = ({ movies, loading, title }: { movies: Movie[], loading?: boolean, title?: string }) => {
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="px-6 md:px-16 pt-8 pb-16">
|
||||
{title && <h2 className="text-2xl font-bold mb-6 text-white/90">{title}</h2>}
|
||||
<div className="grid grid-cols-2 min-[450px]:grid-cols-3 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-x-6 gap-y-10">
|
||||
{[...Array(10)].map((_, i) => (
|
||||
<div key={i} className="aspect-[2/3] bg-white/5 rounded-2xl animate-pulse" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="px-6 md:px-16 pt-8 pb-16">
|
||||
<div className="flex items-baseline justify-between mb-6">
|
||||
{title && <h2 className="text-2xl font-bold text-white/90">{title}</h2>}
|
||||
<button className="text-blue-400 text-sm font-medium hover:text-blue-300 transition-colors">See All</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 min-[450px]:grid-cols-3 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-x-8 gap-y-12">
|
||||
{movies.map((movie) => (
|
||||
<Card key={movie.id} movie={movie} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,201 +0,0 @@
|
|||
import { useNavigate } from 'react-router-dom';
|
||||
import { ArrowLeft, ChevronDown, Play, ChevronUp } from 'lucide-react';
|
||||
import { useWatchMovie } from '../../hooks/useWatchMovie';
|
||||
import { useState } from 'react';
|
||||
import MovieRow from '../../components/MovieRow';
|
||||
|
||||
export const WatchPage = ({ slug, episode }: { slug: string, episode: string }) => {
|
||||
const navigate = useNavigate();
|
||||
const { movie, loading, currentEpisode, setCurrentEpisode, videoRef } = useWatchMovie(slug, episode);
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [selectedServer, setSelectedServer] = useState<string>('');
|
||||
|
||||
if (!movie) return <div className="text-white p-10">Loading...</div>;
|
||||
|
||||
// Group episodes by server
|
||||
const episodesByServer = movie?.episodes?.reduce((acc, ep) => {
|
||||
const server = ep.server_name || 'Default';
|
||||
if (!acc[server]) acc[server] = [];
|
||||
acc[server].push(ep);
|
||||
return acc;
|
||||
}, {} as Record<string, typeof movie.episodes>) || {};
|
||||
|
||||
const serverNames = Object.keys(episodesByServer);
|
||||
|
||||
// Initialize selected server
|
||||
if (serverNames.length > 0 && !selectedServer) {
|
||||
const defaultServer = serverNames.find(s => s.toLowerCase().includes('vietsub #1')) || serverNames[0];
|
||||
setSelectedServer(defaultServer);
|
||||
}
|
||||
|
||||
const currentServerEpisodes = episodesByServer[selectedServer] || [];
|
||||
const visibleEpisodes = expanded ? currentServerEpisodes : currentServerEpisodes.slice(0, 20);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-black text-white selection:bg-white/20 font-sans">
|
||||
{/* Navigation */}
|
||||
<div className={`fixed top-0 left-0 z-50 p-4 md:p-6 transition-opacity duration-300 ${loading ? 'opacity-0' : 'opacity-100 hover:opacity-100'}`}>
|
||||
<button
|
||||
onClick={() => navigate('/')}
|
||||
className="flex items-center gap-2 text-white/70 hover:text-white transition-colors bg-black/40 backdrop-blur-xl border border-white/10 px-4 py-2 rounded-full"
|
||||
>
|
||||
<ArrowLeft className="w-5 h-5" />
|
||||
<span className="font-medium text-sm hidden md:inline">Main Menu</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col pb-20">
|
||||
{/* Player Section - Sticky on larger screens for cinema feel */}
|
||||
<div className="sticky top-0 z-40 w-full aspect-video md:h-[75vh] bg-black relative shadow-2xl">
|
||||
{loading && (
|
||||
<div className="absolute inset-0 flex items-center justify-center z-20">
|
||||
<div className="w-10 h-10 border-2 border-white/20 border-t-white rounded-full animate-spin"></div>
|
||||
</div>
|
||||
)}
|
||||
{(() => {
|
||||
const activeEpisode = currentServerEpisodes?.find(e => e.number === currentEpisode);
|
||||
if (!activeEpisode?.url) {
|
||||
return (
|
||||
<div className="absolute inset-0 z-10 flex flex-col items-center justify-center bg-zinc-900/50 backdrop-blur-3xl">
|
||||
<div className="text-center space-y-4 px-4">
|
||||
<div className="inline-flex items-center justify-center w-16 h-16 rounded-full bg-white/5 backdrop-blur-md mb-2 border border-white/10">
|
||||
<div className="w-2 h-2 bg-white rounded-full animate-pulse" />
|
||||
</div>
|
||||
<h2 className="text-xl md:text-2xl font-bold tracking-tight">Processing Content</h2>
|
||||
<p className="text-white/60 text-sm max-w-xs mx-auto">
|
||||
This title is currently being prepared for streaming.
|
||||
</p>
|
||||
</div>
|
||||
{/* Subtle Background */}
|
||||
<div
|
||||
className="absolute inset-0 -z-10 opacity-30 bg-cover bg-center blur-3xl"
|
||||
style={{
|
||||
backgroundImage: `url(https://wsrv.nl/?url=${encodeURIComponent(movie.thumbnail?.replace(/^https?:\/\//, '').replace('img.ophim1.com', 'ssl:img.ophim1.com') || '')}&w=400&output=webp)`
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<video
|
||||
key={activeEpisode.url}
|
||||
ref={videoRef}
|
||||
controls
|
||||
className="w-full h-full object-contain bg-black"
|
||||
poster={`https://wsrv.nl/?url=${encodeURIComponent(movie.thumbnail?.replace(/^https?:\/\//, '').replace('img.ophim1.com', 'ssl:img.ophim1.com') || '')}&w=1600&output=webp`}
|
||||
/>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
|
||||
{/* Content Section - Scrolls over the bottom of the player if sticky, or just below */}
|
||||
{/* Content Section - Scrolls over the bottom of the player if sticky, or just below */}
|
||||
<div className="relative z-50 bg-black rounded-t-3xl border-t border-white/10 shadow-[0_-10px_40px_rgba(0,0,0,0.8)] px-4 md:px-12 py-8 md:py-12 max-w-[1800px] mx-auto w-full space-y-12 min-h-screen">
|
||||
|
||||
{/* Movie Info */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-col md:flex-row md:items-end justify-between gap-4">
|
||||
<h1 className="text-3xl md:text-5xl font-bold tracking-tight text-white">{movie.title}</h1>
|
||||
<div className="flex items-center gap-3 text-sm text-gray-400 font-medium">
|
||||
<span className="px-2 py-0.5 border border-white/20 rounded text-xs uppercase">HD</span>
|
||||
<span>{movie.year || '2024'}</span>
|
||||
<span>{movie.episodes?.length || 0} Episodes</span>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-gray-400 text-base md:text-lg max-w-4xl leading-relaxed">{movie.description}</p>
|
||||
</div>
|
||||
|
||||
{/* Episodes Grid */}
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4 border-b border-white/10 pb-4">
|
||||
<div className="flex flex-wrap items-center gap-6">
|
||||
<h3 className="text-lg font-bold">Episodes</h3>
|
||||
|
||||
{/* Server Selector */}
|
||||
{serverNames.length > 1 && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{serverNames.map(server => (
|
||||
<button
|
||||
key={server}
|
||||
onClick={() => setSelectedServer(server)}
|
||||
className={`px-3 py-1 text-xs font-medium rounded-full transition-all ${selectedServer === server
|
||||
? 'bg-white text-black'
|
||||
: 'bg-white/5 text-white/50 hover:bg-white/10 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
{server}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-sm text-gray-500">{currentServerEpisodes.length} available</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-4 sm:grid-cols-6 md:grid-cols-8 lg:grid-cols-10 xl:grid-cols-12 gap-2">
|
||||
{visibleEpisodes.map((ep) => (
|
||||
<button
|
||||
key={`${ep.number}-${selectedServer}`}
|
||||
onClick={() => {
|
||||
setCurrentEpisode(ep.number);
|
||||
navigate(`/watch/${slug}/${ep.number}`);
|
||||
}}
|
||||
className={`group relative py-2 rounded-lg flex items-center justify-center transition-all duration-300 border ${currentEpisode === ep.number
|
||||
? 'bg-white text-black border-white shadow-[0_0_15px_rgba(255,255,255,0.2)]'
|
||||
: 'bg-zinc-900/50 hover:bg-zinc-800 text-white border-white/5 hover:border-white/20'
|
||||
}`}
|
||||
>
|
||||
<span className="font-bold text-sm">
|
||||
{ep.number}
|
||||
</span>
|
||||
{currentEpisode === ep.number && (
|
||||
<div className="absolute top-1 right-1">
|
||||
<Play className="w-2.5 h-2.5 fill-current" />
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{currentServerEpisodes.length > 20 && (
|
||||
<button
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
className="w-full py-4 flex items-center justify-center gap-2 text-sm font-medium text-gray-400 hover:text-white transition-colors bg-zinc-900/50 hover:bg-zinc-900 rounded-xl"
|
||||
>
|
||||
{expanded ? (
|
||||
<>Show Less <ChevronUp className="w-4 h-4" /></>
|
||||
) : (
|
||||
<>Show All Episodes <ChevronDown className="w-4 h-4" /></>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Related Categories */}
|
||||
<div className="space-y-12 pt-12 border-t border-white/10">
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-xl font-bold">More Like This</h3>
|
||||
<MovieRow title="" category={movie.category || 'phim-le'} limit={10} key="related" />
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-xl font-bold">Trending Now</h3>
|
||||
<MovieRow title="" category="home" limit={10} key="trending" />
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-xl font-bold">Top Movies</h3>
|
||||
<MovieRow title="" category="phim-le" limit={10} key="top" />
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-xl font-bold">Animation</h3>
|
||||
<MovieRow title="" category="hoat-hinh" limit={10} key="anim" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,25 +0,0 @@
|
|||
import type { Theme } from '../../types/Theme';
|
||||
import { Layout } from './Layout';
|
||||
import { Hero } from '../../components/Hero';
|
||||
import { MovieGrid } from './MovieGrid';
|
||||
import { Card } from './Card';
|
||||
import { WatchPage } from './WatchPage'; // Added
|
||||
import { AppleHome } from './AppleHome'; // Added
|
||||
|
||||
export const appleTheme: Theme = {
|
||||
name: 'apple',
|
||||
label: 'Apple TV+',
|
||||
colors: {
|
||||
background: '#000000',
|
||||
primary: '#FFFFFF',
|
||||
text: '#FFFFFF',
|
||||
},
|
||||
components: {
|
||||
Layout,
|
||||
Hero,
|
||||
MovieGrid,
|
||||
Card,
|
||||
WatchPage, // Added
|
||||
Home: AppleHome, // Added as Home
|
||||
},
|
||||
};
|
||||
|
|
@ -1,6 +1,5 @@
|
|||
import Navbar from '../../components/Navbar';
|
||||
import { HomeContent } from '../../components/HomeContent';
|
||||
import { SettingsPanel } from '../../components/SettingsPanel';
|
||||
|
||||
export const DefaultHome = () => {
|
||||
return (
|
||||
|
|
@ -9,7 +8,6 @@ export const DefaultHome = () => {
|
|||
<div className="pt-16">
|
||||
<HomeContent topPadding="pt-8 md:pt-12" />
|
||||
</div>
|
||||
<SettingsPanel />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -20,10 +20,15 @@ export const WatchPage = ({ slug, episode }: { slug: string, episode: string })
|
|||
);
|
||||
|
||||
// Helper for URL safety (same as Hero)
|
||||
const getImageUrl = (url: string | undefined, width: number) => {
|
||||
const getImageUrl = (url: string | undefined) => {
|
||||
if (!url) return '';
|
||||
const cleanUrl = url.replace('img.ophim1.com', 'ssl:img.ophim1.com');
|
||||
return `https://wsrv.nl/?url=${encodeURIComponent(cleanUrl)}&w=${width}&output=webp`;
|
||||
let cleanUrl = url;
|
||||
if (url.startsWith('//')) {
|
||||
cleanUrl = `https:${url}`;
|
||||
} else if (!url.startsWith('http')) {
|
||||
cleanUrl = `https://${url}`;
|
||||
}
|
||||
return cleanUrl;
|
||||
};
|
||||
const episodesByServer = movie?.episodes?.reduce((acc, ep) => {
|
||||
const server = ep.server_name || 'Default';
|
||||
|
|
@ -77,7 +82,7 @@ export const WatchPage = ({ slug, episode }: { slug: string, episode: string })
|
|||
</div>
|
||||
<div
|
||||
className="absolute inset-0 -z-10 opacity-30 bg-cover bg-center blur-2xl grayscale"
|
||||
style={{ backgroundImage: `url(${getImageUrl(movie.backdrop || movie.thumbnail, 400)})` }}
|
||||
style={{ backgroundImage: `url(${getImageUrl(movie.backdrop || movie.thumbnail)})` }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -89,7 +94,7 @@ export const WatchPage = ({ slug, episode }: { slug: string, episode: string })
|
|||
ref={videoRef}
|
||||
controls
|
||||
className="w-full h-full max-h-screen object-contain"
|
||||
poster={getImageUrl(movie.backdrop || movie.thumbnail, 1280)}
|
||||
poster={getImageUrl(movie.backdrop || movie.thumbnail)}
|
||||
/>
|
||||
);
|
||||
})()}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
import type { Theme } from '../../types/Theme';
|
||||
import { DefaultHome } from './DefaultHome';
|
||||
import { Hero } from '../../components/Hero';
|
||||
import { MovieGrid } from '../netflix/MovieGrid';
|
||||
import { Card } from '../netflix/Card';
|
||||
import { MovieGrid } from '../../components/MovieGrid';
|
||||
import { Card } from '../../components/Card';
|
||||
import { WatchPage } from './WatchPage'; // Use local StreamFlow WatchPage
|
||||
import { Layout } from '../netflix/Layout'; // Fallback layout if needed, but Home handles it
|
||||
import { Layout } from '../../components/Layout'; // Fallback layout if needed, but Home handles it
|
||||
|
||||
export const defaultTheme: Theme = {
|
||||
name: 'default',
|
||||
|
|
|
|||
|
|
@ -1,6 +0,0 @@
|
|||
import { MovieCard } from '../../components/MovieCard';
|
||||
import type { Movie } from '../../types';
|
||||
|
||||
export const Card = ({ movie }: { movie: Movie }) => {
|
||||
return <MovieCard movie={movie} />;
|
||||
};
|
||||
|
|
@ -1,87 +0,0 @@
|
|||
import { useState, useEffect } from 'react';
|
||||
import { Play, Plus, Check } from 'lucide-react';
|
||||
import type { Movie } from '../../types';
|
||||
import { useMyList } from '../../hooks/useMyList';
|
||||
|
||||
export const Hero = ({ movies }: { movies: Movie[] }) => {
|
||||
const [index, setIndex] = useState(0);
|
||||
const { addToList, removeFromList, isSaved } = useMyList();
|
||||
|
||||
useEffect(() => {
|
||||
if (movies.length <= 1) return;
|
||||
const interval = setInterval(() => {
|
||||
setIndex((prev) => (prev + 1) % movies.length);
|
||||
}, 8000);
|
||||
return () => clearInterval(interval);
|
||||
}, [movies]);
|
||||
|
||||
if (!movies || movies.length === 0) return null;
|
||||
|
||||
const movie = movies[index];
|
||||
const saved = isSaved(movie.id);
|
||||
|
||||
const toggleList = () => {
|
||||
if (saved) removeFromList(movie.id);
|
||||
else addToList(movie);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative h-[85vh] w-full mr-4 overflow-hidden group">
|
||||
<div className="absolute inset-0 transition-opacity duration-1000 ease-in-out">
|
||||
<img
|
||||
key={movie.id}
|
||||
src={`https://wsrv.nl/?url=${encodeURIComponent(movie.backdrop || movie.thumbnail)}&w=1600&output=webp`}
|
||||
alt={movie.title}
|
||||
className="w-full h-full object-cover mask-image-gradient animate-fade-in"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-[#141414] via-[#141414]/40 to-transparent" />
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-[#141414] via-transparent to-transparent" />
|
||||
</div>
|
||||
|
||||
<div className="absolute inset-0 flex items-center px-4 md:px-12 z-10">
|
||||
<div className="max-w-2xl space-y-6">
|
||||
<div className="flex items-center gap-2 mb-4 animate-slide-up">
|
||||
<span className="bg-red-600 text-white text-xs font-bold px-2 py-1 rounded-sm">TOP 10 TODAY</span>
|
||||
<span className="text-gray-300 text-sm font-medium tracking-widest uppercase">#{index + 1} in Movies</span>
|
||||
</div>
|
||||
|
||||
<h1 className="text-4xl md:text-6xl font-bold text-white leading-tight drop-shadow-xl line-clamp-2 animate-slide-up" style={{ animationDelay: '100ms' }}>
|
||||
{movie.title}
|
||||
</h1>
|
||||
|
||||
{movie.original_title && (
|
||||
<p className="text-xl text-gray-300 italic animate-slide-up" style={{ animationDelay: '200ms' }}>{movie.original_title}</p>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-3 pt-4 animate-slide-up" style={{ animationDelay: '300ms' }}>
|
||||
<a
|
||||
href={`/watch/${movie.slug}`}
|
||||
className="flex items-center gap-2 bg-white text-black px-8 py-3 rounded font-bold hover:bg-opacity-90 transition-colors"
|
||||
>
|
||||
<Play className="w-6 h-6 fill-current" />
|
||||
Play
|
||||
</a>
|
||||
<button
|
||||
onClick={toggleList}
|
||||
className="flex items-center gap-2 bg-gray-500/70 text-white px-8 py-3 rounded font-bold backdrop-blur-sm hover:bg-gray-500/50 transition-colors"
|
||||
>
|
||||
{saved ? <Check className="w-6 h-6" /> : <Plus className="w-6 h-6" />}
|
||||
{saved ? 'My List' : 'My List'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Indicators */}
|
||||
<div className="absolute right-12 bottom-1/3 flex flex-col gap-2 z-20">
|
||||
{movies.map((_, i) => (
|
||||
<button
|
||||
key={i}
|
||||
onClick={() => setIndex(i)}
|
||||
className={`w-2 h-2 rounded-full transition-all ${i === index ? 'bg-white scale-125' : 'bg-gray-500 hover:bg-gray-400'}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,14 +0,0 @@
|
|||
import { Layout } from './Layout';
|
||||
import { HomeContent } from '../../components/HomeContent';
|
||||
import { SettingsPanel } from '../../components/SettingsPanel';
|
||||
|
||||
export const NetflixHome = () => {
|
||||
return (
|
||||
<Layout>
|
||||
<div className="w-full min-h-screen bg-black">
|
||||
<HomeContent topPadding="pt-8" />
|
||||
</div>
|
||||
<SettingsPanel />
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,186 +0,0 @@
|
|||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { ArrowLeft, Play, ChevronDown, ChevronUp } from 'lucide-react';
|
||||
|
||||
import { useWatchMovie } from '../../hooks/useWatchMovie';
|
||||
import MovieRow from '../../components/MovieRow';
|
||||
|
||||
export const WatchPage = ({ slug, episode }: { slug: string, episode: string }) => {
|
||||
const navigate = useNavigate();
|
||||
const { movie, loading, currentEpisode, setCurrentEpisode, videoRef } = useWatchMovie(slug, episode);
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [selectedServer, setSelectedServer] = useState<string>('');
|
||||
|
||||
// Group episodes by server
|
||||
const episodesByServer = movie?.episodes?.reduce((acc, ep) => {
|
||||
const server = ep.server_name || 'Default';
|
||||
if (!acc[server]) acc[server] = [];
|
||||
acc[server].push(ep);
|
||||
return acc;
|
||||
}, {} as Record<string, typeof movie.episodes>) || {};
|
||||
|
||||
const serverNames = Object.keys(episodesByServer);
|
||||
|
||||
// Initialize selected server
|
||||
if (serverNames.length > 0 && !selectedServer) {
|
||||
// Prefer "Ophim" or "Vietsub #1" if available, else first
|
||||
const defaultServer = serverNames.find(s => s.includes('Ophim')) || serverNames[0];
|
||||
setSelectedServer(defaultServer);
|
||||
}
|
||||
|
||||
const currentServerEpisodes = episodesByServer[selectedServer] || [];
|
||||
const visibleEpisodes = expanded ? currentServerEpisodes : currentServerEpisodes.slice(0, 20);
|
||||
|
||||
if (!movie) return <div className="text-white p-10">Loading...</div>;
|
||||
|
||||
return (
|
||||
|
||||
<div className="min-h-screen bg-[#141414] text-white font-sans selection:bg-red-600 selection:text-white pb-20">
|
||||
{/* Back Navigation */}
|
||||
<div className="fixed top-0 left-0 right-0 z-50 p-4 bg-gradient-to-b from-black/80 to-transparent pointer-events-none">
|
||||
<button
|
||||
onClick={() => navigate('/')}
|
||||
className="pointer-events-auto flex items-center gap-2 px-4 py-2 bg-black/50 hover:bg-white/20 backdrop-blur-md rounded-full transition-all group"
|
||||
>
|
||||
<ArrowLeft className="w-5 h-5 text-white group-hover:-translate-x-1 transition-transform" />
|
||||
<span className="font-medium">Back to Home</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 1. Cinema Player Section */}
|
||||
<div className="w-full h-[50vh] md:h-[80vh] bg-black relative shadow-2xl z-40">
|
||||
{loading && (
|
||||
<div className="absolute inset-0 flex items-center justify-center z-20">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-red-600"></div>
|
||||
</div>
|
||||
)}
|
||||
{(() => {
|
||||
const activeEpisode = currentServerEpisodes?.find(e => e.number === currentEpisode);
|
||||
if (!activeEpisode?.url) {
|
||||
return (
|
||||
<div className="absolute inset-0 z-10 flex flex-col items-center justify-center bg-black/90">
|
||||
<div className="text-center px-6 max-w-lg">
|
||||
<h2 className="text-3xl font-bold text-white mb-4">Coming Soon</h2>
|
||||
<p className="text-gray-400 text-lg mb-6">
|
||||
We're busy uploading the best quality version of this movie.
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
className="absolute inset-0 -z-10 opacity-30 bg-cover bg-center blur-2xl grayscale"
|
||||
style={{
|
||||
backgroundImage: `url(https://wsrv.nl/?url=${encodeURIComponent(movie.thumbnail?.replace(/^https?:\/\//, '').replace('img.ophim1.com', 'ssl:img.ophim1.com') || '')}&w=400&output=webp)`
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<video
|
||||
ref={videoRef}
|
||||
controls
|
||||
className="w-full h-full max-h-screen object-contain"
|
||||
poster={`https://wsrv.nl/?url=${encodeURIComponent(movie.thumbnail?.replace(/^https?:\/\//, '').replace('img.ophim1.com', 'ssl:img.ophim1.com') || '')}&w=1280&output=webp`}
|
||||
/>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
|
||||
{/* 2. Content Info & Rows */}
|
||||
<div className="max-w-[1600px] mx-auto px-4 md:px-12 py-8 space-y-12">
|
||||
{/* Glass Info Card */}
|
||||
<div className="bg-[#181818]/90 backdrop-blur-xl rounded-xl p-6 md:p-10 shadow-2xl border border-white/5 mx-2 md:mx-0">
|
||||
<h1 className="text-3xl md:text-5xl font-bold mb-4 tracking-tight">{movie.title}</h1>
|
||||
|
||||
{/* Meta Tags */}
|
||||
<div className="flex items-center gap-4 text-sm md:text-base mb-6">
|
||||
<span className="text-green-500 font-bold">98% Match</span>
|
||||
<span className="text-gray-400">{movie.year || '2024'}</span>
|
||||
<span className="border border-gray-600 px-2 py-0.5 rounded text-xs bg-black/40">HD</span>
|
||||
<span className="text-gray-400">{movie.original_title}</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="text-gray-300 leading-relaxed max-w-4xl text-base md:text-lg"
|
||||
dangerouslySetInnerHTML={{ __html: movie.description }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Episodes Section - Compact Grid */}
|
||||
{currentServerEpisodes.length > 0 && (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-6">
|
||||
<h3 className="text-2xl font-bold border-l-4 border-red-600 pl-4">Episodes</h3>
|
||||
|
||||
{/* Server Selector */}
|
||||
{serverNames.length > 1 && (
|
||||
<div className="flex gap-2">
|
||||
{serverNames.map(server => (
|
||||
<button
|
||||
key={server}
|
||||
onClick={() => setSelectedServer(server)}
|
||||
className={`px-3 py-1 text-sm font-medium rounded-md transition-colors ${selectedServer === server
|
||||
? 'bg-red-600 text-white'
|
||||
: 'bg-[#333] text-gray-400 hover:bg-[#444]'
|
||||
}`}
|
||||
>
|
||||
{server}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-gray-400 text-sm font-medium">{currentServerEpisodes.length} Items</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-4 sm:grid-cols-6 md:grid-cols-8 lg:grid-cols-10 xl:grid-cols-12 gap-2">
|
||||
{visibleEpisodes.map((ep) => (
|
||||
<button
|
||||
key={`${selectedServer}-${ep.number}`}
|
||||
onClick={() => {
|
||||
setCurrentEpisode(ep.number);
|
||||
navigate(`/watch/${slug}/${ep.number}`);
|
||||
}}
|
||||
className={`group relative py-2 rounded-md overflow-hidden border-2 transition-all ${currentEpisode === ep.number ? 'border-red-600 bg-red-900/10' : 'border-transparent hover:border-white/40 bg-[#222]'}`}
|
||||
>
|
||||
<div className="flex items-center justify-center">
|
||||
<span className={`font-bold text-sm ${currentEpisode === ep.number ? 'text-red-500' : 'text-gray-400 group-hover:text-white'}`}>
|
||||
{ep.number}
|
||||
</span>
|
||||
</div>
|
||||
{currentEpisode === ep.number && (
|
||||
<div className="absolute top-1 right-1">
|
||||
<Play className="w-2.5 h-2.5 text-red-500 fill-current" />
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{currentServerEpisodes.length > 20 && (
|
||||
<button
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
className="flex items-center gap-2 text-sm font-medium text-gray-400 hover:text-white transition-colors mt-4"
|
||||
>
|
||||
{expanded ? (
|
||||
<>Show Less <ChevronUp className="w-4 h-4" /></>
|
||||
) : (
|
||||
<>Show All Episodes <ChevronDown className="w-4 h-4" /></>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Related Content Section */}
|
||||
<div className="space-y-12 pt-8 border-t border-white/10">
|
||||
<MovieRow title="More Like This" category={movie.category || 'phim-le'} limit={10} key={`related-${movie.slug}`} />
|
||||
<MovieRow title="New Releases" category="home" limit={10} key="trending" />
|
||||
<MovieRow title="Top Movies" category="phim-le" limit={10} key="top-movies" />
|
||||
<MovieRow title="Animation" category="hoat-hinh" limit={10} key="animation" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,25 +0,0 @@
|
|||
import type { Theme } from '../../types/Theme';
|
||||
import { Layout } from './Layout';
|
||||
import { Hero } from '../../components/Hero';
|
||||
import { MovieGrid } from './MovieGrid';
|
||||
import { Card } from './Card';
|
||||
import { WatchPage } from './WatchPage';
|
||||
import { NetflixHome } from './NetflixHome'; // Added
|
||||
|
||||
export const netflixTheme: Theme = {
|
||||
name: 'netflix',
|
||||
label: 'Netflix',
|
||||
colors: {
|
||||
background: '#141414',
|
||||
primary: '#E50914',
|
||||
text: '#FFFFFF',
|
||||
},
|
||||
components: {
|
||||
Layout,
|
||||
Hero,
|
||||
MovieGrid,
|
||||
Card,
|
||||
WatchPage,
|
||||
Home: NetflixHome, // Added as Home
|
||||
},
|
||||
};
|
||||
|
|
@ -13,6 +13,10 @@ export interface Movie {
|
|||
provider?: string;
|
||||
director?: string;
|
||||
cast?: string[];
|
||||
// Progress tracking
|
||||
currentEpisode?: number;
|
||||
watchedTimestamp?: number;
|
||||
duration?: number;
|
||||
}
|
||||
|
||||
export interface MovieDetail extends Movie {
|
||||
|
|
|
|||
|
|
@ -1,51 +0,0 @@
|
|||
# Streamflow Dev Start Script (Auto-Restart)
|
||||
|
||||
Write-Host "=============================" -ForegroundColor Cyan
|
||||
Write-Host " Streamflow Dev Launcher " -ForegroundColor Cyan
|
||||
Write-Host "=============================" -ForegroundColor Cyan
|
||||
|
||||
$BackendPort = 8000
|
||||
$FrontendPort = 5173
|
||||
|
||||
# Helper function to kill processes on a port
|
||||
function Kill-Port($port) {
|
||||
echo "Checking port $port..."
|
||||
$connection = Get-NetTCPConnection -LocalPort $port -ErrorAction SilentlyContinue
|
||||
if ($connection) {
|
||||
$pidNum = $connection.OwningProcess
|
||||
Write-Host " -> Killing process $pidNum on port $port" -ForegroundColor Yellow
|
||||
Stop-Process -Id $pidNum -Force -ErrorAction SilentlyContinue
|
||||
} else {
|
||||
Write-Host " -> Port $port is free." -ForegroundColor Green
|
||||
}
|
||||
}
|
||||
|
||||
# 1. Cleanup
|
||||
Write-Host "`n[1/4] Cleaning up existing processes..." -ForegroundColor White
|
||||
Kill-Port $BackendPort
|
||||
Kill-Port $FrontendPort
|
||||
|
||||
# 2. Start Backend
|
||||
Write-Host "`n[2/4] Starting Backend (Go)..." -ForegroundColor White
|
||||
$backendProcess = Start-Process -FilePath "go" -ArgumentList "run cmd/server/main.go" -WorkingDirectory "$PSScriptRoot\backend" -PassThru -NoNewWindow:$false
|
||||
Write-Host " -> Backend started (PID: $($backendProcess.Id))" -ForegroundColor Green
|
||||
|
||||
# 3. Start Frontend
|
||||
Write-Host "`n[3/4] Starting Frontend (Vite)..." -ForegroundColor White
|
||||
# Use npm.cmd for Windows compatibility
|
||||
$frontendProcess = Start-Process -FilePath "npm.cmd" -ArgumentList "run dev" -WorkingDirectory "$PSScriptRoot\frontend-react" -PassThru -NoNewWindow:$false
|
||||
Write-Host " -> Frontend started (PID: $($frontendProcess.Id))" -ForegroundColor Green
|
||||
|
||||
# 4. Launch Browser
|
||||
Write-Host "`n[4/4] Waiting for services..." -ForegroundColor White
|
||||
for ($i = 5; $i -gt 0; $i--) {
|
||||
Write-Host " -> Launching in $i seconds..." -NoNewline
|
||||
Start-Sleep -Seconds 1
|
||||
Write-Host "`r" -NoNewline
|
||||
}
|
||||
|
||||
Write-Host "`n -> Opening http://localhost:$FrontendPort" -ForegroundColor Cyan
|
||||
Start-Process "http://localhost:$FrontendPort"
|
||||
|
||||
Write-Host "`nAll systems go! Close the pop-up windows to stop the servers." -ForegroundColor Magenta
|
||||
Start-Sleep -Seconds 3
|
||||