v3.9.2: Fix Android TV OOM crash + backend Content-Type headers
- Backend: Add Content-Type: application/json to all JSON API endpoints - Android TV: Reduce HomeViewModel memory usage (load 4 categories only, limit 15 items each) - Android TV: Prevent OOM kill on TV devices with limited RAM - Updated APK, docker-compose, health endpoint to v3.9.2
This commit is contained in:
parent
fbe89e14fd
commit
69308bf696
95 changed files with 7684 additions and 7709 deletions
98
Dockerfile
98
Dockerfile
|
|
@ -1,49 +1,49 @@
|
|||
# Stage 1: Build Image (Frontend)
|
||||
FROM 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 golang:1.24-alpine AS backend-builder
|
||||
WORKDIR /app/backend
|
||||
# Install build dependencies
|
||||
RUN apk add --no-cache gcc musl-dev
|
||||
|
||||
COPY backend/go.mod backend/go.sum ./
|
||||
RUN go mod download
|
||||
|
||||
COPY backend/ .
|
||||
# Build static binary for Linux amd64
|
||||
RUN CGO_ENABLED=1 GOOS=linux GOARCH=amd64 go build -ldflags="-w -s" -o server cmd/server/main.go
|
||||
|
||||
# Stage 3: Final Image
|
||||
FROM alpine:latest
|
||||
WORKDIR /app
|
||||
|
||||
# Install runtime dependencies (sqlite + yt-dlp for video extraction fallback)
|
||||
RUN apk add --no-cache sqlite ca-certificates tzdata python3 py3-pip && \
|
||||
pip3 install --break-system-packages yt-dlp
|
||||
|
||||
# Copy backend binary
|
||||
COPY --from=backend-builder /app/backend/server .
|
||||
|
||||
# Copy frontend build to the expected static directory
|
||||
# The backend expects ../frontend-react/dist relative to itself, or we configure it.
|
||||
# Let's align with the standard deployment structure: /app/server and /app/dist
|
||||
COPY --from=frontend-builder /app/frontend/dist ./dist
|
||||
|
||||
# Create data directory
|
||||
RUN mkdir -p data
|
||||
|
||||
# Environment variables
|
||||
ENV PORT=8000
|
||||
ENV DATABASE_URL=/app/data/streamflow.db
|
||||
|
||||
# Expose port
|
||||
EXPOSE 8000
|
||||
|
||||
# Start server
|
||||
CMD ["./server"]
|
||||
# Stage 1: Build Image (Frontend)
|
||||
FROM 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
|
||||
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
|
||||
|
||||
# Stage 3: Final Image
|
||||
FROM 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
|
||||
|
||||
# Copy backend binary
|
||||
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
|
||||
|
||||
# Environment variables
|
||||
ENV PORT=8000
|
||||
ENV DATABASE_URL=/app/data/streamflow.db
|
||||
|
||||
# Expose port
|
||||
EXPOSE 8000
|
||||
|
||||
# Start server
|
||||
CMD ["./server"]
|
||||
|
|
|
|||
|
|
@ -1,88 +1,88 @@
|
|||
plugins {
|
||||
id("com.android.application")
|
||||
id("org.jetbrains.kotlin.android")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.streamflow.tv"
|
||||
compileSdk = 34
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "com.streamflow.tv"
|
||||
minSdk = 21
|
||||
targetSdk = 34
|
||||
versionCode = 37
|
||||
versionName = "3.7.0"
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
isMinifyEnabled = false
|
||||
isShrinkResources = false
|
||||
proguardFiles(
|
||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||
"proguard-rules.pro"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
buildFeatures {
|
||||
compose = true
|
||||
}
|
||||
|
||||
composeOptions {
|
||||
kotlinCompilerExtensionVersion = "1.5.8"
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
kotlinOptions {
|
||||
jvmTarget = "17"
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
// Compose for TV
|
||||
implementation("androidx.tv:tv-foundation:1.0.0-alpha11")
|
||||
implementation("androidx.tv:tv-material:1.0.0")
|
||||
|
||||
// Core Compose
|
||||
implementation(platform("androidx.compose:compose-bom:2024.01.00"))
|
||||
implementation("androidx.compose.ui:ui")
|
||||
implementation("androidx.compose.ui:ui-tooling-preview")
|
||||
implementation("androidx.compose.material3:material3")
|
||||
implementation("androidx.compose.material:material-icons-extended")
|
||||
implementation("androidx.activity:activity-compose:1.8.2")
|
||||
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.7.0")
|
||||
implementation("androidx.lifecycle:lifecycle-runtime-compose:2.7.0")
|
||||
implementation("androidx.navigation:navigation-compose:2.7.6")
|
||||
|
||||
// ExoPlayer (Media3)
|
||||
implementation("androidx.media3:media3-exoplayer:1.2.1")
|
||||
implementation("androidx.media3:media3-exoplayer-hls:1.2.1")
|
||||
implementation("androidx.media3:media3-ui:1.2.1")
|
||||
implementation("androidx.media3:media3-session:1.2.1")
|
||||
|
||||
// Networking
|
||||
implementation("com.squareup.retrofit2:retrofit:2.9.0")
|
||||
implementation("com.squareup.retrofit2:converter-moshi:2.9.0")
|
||||
implementation("com.squareup.moshi:moshi-kotlin:1.15.0")
|
||||
implementation("com.squareup.okhttp3:okhttp:4.12.0")
|
||||
implementation("com.squareup.okhttp3:logging-interceptor:4.12.0")
|
||||
|
||||
// Image loading
|
||||
implementation("io.coil-kt:coil-compose:2.5.0")
|
||||
|
||||
// DataStore
|
||||
implementation("androidx.datastore:datastore-preferences:1.0.0")
|
||||
|
||||
// Core Android TV
|
||||
implementation("androidx.core:core-ktx:1.12.0")
|
||||
implementation("androidx.leanback:leanback:1.0.0")
|
||||
|
||||
// Debug
|
||||
debugImplementation("androidx.compose.ui:ui-tooling")
|
||||
}
|
||||
plugins {
|
||||
id("com.android.application")
|
||||
id("org.jetbrains.kotlin.android")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.streamflow.tv"
|
||||
compileSdk = 34
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "com.streamflow.tv"
|
||||
minSdk = 21
|
||||
targetSdk = 34
|
||||
versionCode = 37
|
||||
versionName = "3.7.0"
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
isMinifyEnabled = false
|
||||
isShrinkResources = false
|
||||
proguardFiles(
|
||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||
"proguard-rules.pro"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
buildFeatures {
|
||||
compose = true
|
||||
}
|
||||
|
||||
composeOptions {
|
||||
kotlinCompilerExtensionVersion = "1.5.8"
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
kotlinOptions {
|
||||
jvmTarget = "17"
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
// Compose for TV
|
||||
implementation("androidx.tv:tv-foundation:1.0.0-alpha11")
|
||||
implementation("androidx.tv:tv-material:1.0.0")
|
||||
|
||||
// Core Compose
|
||||
implementation(platform("androidx.compose:compose-bom:2024.01.00"))
|
||||
implementation("androidx.compose.ui:ui")
|
||||
implementation("androidx.compose.ui:ui-tooling-preview")
|
||||
implementation("androidx.compose.material3:material3")
|
||||
implementation("androidx.compose.material:material-icons-extended")
|
||||
implementation("androidx.activity:activity-compose:1.8.2")
|
||||
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.7.0")
|
||||
implementation("androidx.lifecycle:lifecycle-runtime-compose:2.7.0")
|
||||
implementation("androidx.navigation:navigation-compose:2.7.6")
|
||||
|
||||
// ExoPlayer (Media3)
|
||||
implementation("androidx.media3:media3-exoplayer:1.2.1")
|
||||
implementation("androidx.media3:media3-exoplayer-hls:1.2.1")
|
||||
implementation("androidx.media3:media3-ui:1.2.1")
|
||||
implementation("androidx.media3:media3-session:1.2.1")
|
||||
|
||||
// Networking
|
||||
implementation("com.squareup.retrofit2:retrofit:2.9.0")
|
||||
implementation("com.squareup.retrofit2:converter-moshi:2.9.0")
|
||||
implementation("com.squareup.moshi:moshi-kotlin:1.15.0")
|
||||
implementation("com.squareup.okhttp3:okhttp:4.12.0")
|
||||
implementation("com.squareup.okhttp3:logging-interceptor:4.12.0")
|
||||
|
||||
// Image loading
|
||||
implementation("io.coil-kt:coil-compose:2.5.0")
|
||||
|
||||
// DataStore
|
||||
implementation("androidx.datastore:datastore-preferences:1.0.0")
|
||||
|
||||
// Core Android TV
|
||||
implementation("androidx.core:core-ktx:1.12.0")
|
||||
implementation("androidx.leanback:leanback:1.0.0")
|
||||
|
||||
// Debug
|
||||
debugImplementation("androidx.compose.ui:ui-tooling")
|
||||
}
|
||||
|
|
|
|||
108
android-tv/app/proguard-rules.pro
vendored
108
android-tv/app/proguard-rules.pro
vendored
|
|
@ -1,54 +1,54 @@
|
|||
# ProGuard rules for StreamFlow TV
|
||||
|
||||
# Keep all app classes (safety net)
|
||||
-keep class com.streamflow.tv.** { *; }
|
||||
-keepclassmembers class com.streamflow.tv.** { *; }
|
||||
|
||||
# Moshi
|
||||
-keep class com.squareup.moshi.** { *; }
|
||||
-keepclassmembers class * {
|
||||
@com.squareup.moshi.Json <fields>;
|
||||
}
|
||||
-keepclassmembers class * {
|
||||
@com.squareup.moshi.JsonClass <fields>;
|
||||
}
|
||||
|
||||
# Kotlin Metadata (critical for Moshi reflection adapter)
|
||||
-keep class kotlin.Metadata { *; }
|
||||
-keepattributes RuntimeVisibleAnnotations
|
||||
-keepattributes RuntimeInvisibleAnnotations
|
||||
-keepattributes *Annotation*
|
||||
|
||||
# Retrofit
|
||||
-dontwarn retrofit2.**
|
||||
-keep class retrofit2.** { *; }
|
||||
-keepattributes Signature
|
||||
-keepattributes Exceptions
|
||||
-keepclassmembers,allowshrinking,allowobfuscation interface * {
|
||||
@retrofit2.http.* <methods>;
|
||||
}
|
||||
|
||||
# OkHttp
|
||||
-dontwarn okhttp3.**
|
||||
-dontwarn okio.**
|
||||
-keep class okhttp3.** { *; }
|
||||
-keep class okio.** { *; }
|
||||
|
||||
# Kotlin Coroutines
|
||||
-keepnames class kotlinx.coroutines.internal.MainDispatcherFactory {}
|
||||
-keepnames class kotlinx.coroutines.CoroutineExceptionHandler {}
|
||||
-keepclassmembers class kotlinx.** {
|
||||
volatile <fields>;
|
||||
}
|
||||
|
||||
# Coil
|
||||
-dontwarn coil.**
|
||||
-keep class coil.** { *; }
|
||||
|
||||
# AndroidX Compose
|
||||
-keep class androidx.compose.** { *; }
|
||||
-dontwarn androidx.compose.**
|
||||
|
||||
# ExoPlayer / Media3
|
||||
-keep class androidx.media3.** { *; }
|
||||
-dontwarn androidx.media3.**
|
||||
# ProGuard rules for StreamFlow TV
|
||||
|
||||
# Keep all app classes (safety net)
|
||||
-keep class com.streamflow.tv.** { *; }
|
||||
-keepclassmembers class com.streamflow.tv.** { *; }
|
||||
|
||||
# Moshi
|
||||
-keep class com.squareup.moshi.** { *; }
|
||||
-keepclassmembers class * {
|
||||
@com.squareup.moshi.Json <fields>;
|
||||
}
|
||||
-keepclassmembers class * {
|
||||
@com.squareup.moshi.JsonClass <fields>;
|
||||
}
|
||||
|
||||
# Kotlin Metadata (critical for Moshi reflection adapter)
|
||||
-keep class kotlin.Metadata { *; }
|
||||
-keepattributes RuntimeVisibleAnnotations
|
||||
-keepattributes RuntimeInvisibleAnnotations
|
||||
-keepattributes *Annotation*
|
||||
|
||||
# Retrofit
|
||||
-dontwarn retrofit2.**
|
||||
-keep class retrofit2.** { *; }
|
||||
-keepattributes Signature
|
||||
-keepattributes Exceptions
|
||||
-keepclassmembers,allowshrinking,allowobfuscation interface * {
|
||||
@retrofit2.http.* <methods>;
|
||||
}
|
||||
|
||||
# OkHttp
|
||||
-dontwarn okhttp3.**
|
||||
-dontwarn okio.**
|
||||
-keep class okhttp3.** { *; }
|
||||
-keep class okio.** { *; }
|
||||
|
||||
# Kotlin Coroutines
|
||||
-keepnames class kotlinx.coroutines.internal.MainDispatcherFactory {}
|
||||
-keepnames class kotlinx.coroutines.CoroutineExceptionHandler {}
|
||||
-keepclassmembers class kotlinx.** {
|
||||
volatile <fields>;
|
||||
}
|
||||
|
||||
# Coil
|
||||
-dontwarn coil.**
|
||||
-keep class coil.** { *; }
|
||||
|
||||
# AndroidX Compose
|
||||
-keep class androidx.compose.** { *; }
|
||||
-dontwarn androidx.compose.**
|
||||
|
||||
# ExoPlayer / Media3
|
||||
-keep class androidx.media3.** { *; }
|
||||
-dontwarn androidx.media3.**
|
||||
|
|
|
|||
|
|
@ -1,43 +1,43 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
|
||||
<uses-feature
|
||||
android:name="android.software.leanback"
|
||||
android:required="false" />
|
||||
<uses-feature
|
||||
android:name="android.hardware.touchscreen"
|
||||
android:required="false" />
|
||||
|
||||
<application
|
||||
android:name=".StreamFlowApp"
|
||||
android:allowBackup="true"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:banner="@drawable/app_banner"
|
||||
android:theme="@style/Theme.StreamFlowTV"
|
||||
android:supportsRtl="true"
|
||||
android:usesCleartextTraffic="true">
|
||||
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:configChanges="orientation|screenSize|screenLayout|keyboardHidden"
|
||||
android:screenOrientation="landscape">
|
||||
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data android:scheme="streamflow" android:host="player" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
</application>
|
||||
</manifest>
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
|
||||
<uses-feature
|
||||
android:name="android.software.leanback"
|
||||
android:required="false" />
|
||||
<uses-feature
|
||||
android:name="android.hardware.touchscreen"
|
||||
android:required="false" />
|
||||
|
||||
<application
|
||||
android:name=".StreamFlowApp"
|
||||
android:allowBackup="true"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:banner="@drawable/app_banner"
|
||||
android:theme="@style/Theme.StreamFlowTV"
|
||||
android:supportsRtl="true"
|
||||
android:usesCleartextTraffic="true">
|
||||
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:configChanges="orientation|screenSize|screenLayout|keyboardHidden"
|
||||
android:screenOrientation="landscape">
|
||||
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data android:scheme="streamflow" android:host="player" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
</application>
|
||||
</manifest>
|
||||
|
|
|
|||
|
|
@ -1,172 +1,172 @@
|
|||
package com.streamflow.tv
|
||||
|
||||
import android.os.Bundle
|
||||
import android.util.Log
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.navigation.compose.NavHost
|
||||
import androidx.navigation.compose.composable
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import androidx.navigation.compose.currentBackStackEntryAsState
|
||||
import androidx.navigation.NavType
|
||||
import androidx.navigation.navArgument
|
||||
import com.streamflow.tv.data.api.ApiClient
|
||||
import com.streamflow.tv.data.repository.UserDataRepository
|
||||
import com.streamflow.tv.ui.components.SideNavRail
|
||||
import com.streamflow.tv.ui.screens.*
|
||||
import com.streamflow.tv.ui.theme.StreamFlowTheme
|
||||
import com.streamflow.tv.ui.theme.StreamFlowTvTheme
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
Log.d("MainActivity", "onCreate started")
|
||||
setContent {
|
||||
StreamFlowTvApp()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun StreamFlowTvApp() {
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
val userRepo = remember { UserDataRepository(context) }
|
||||
val navController = rememberNavController()
|
||||
|
||||
var currentTheme by remember { mutableStateOf("default") }
|
||||
var selectedNavId by remember { mutableStateOf("home") }
|
||||
|
||||
// Load persisted settings
|
||||
LaunchedEffect(Unit) {
|
||||
try {
|
||||
currentTheme = userRepo.theme.first()
|
||||
package com.streamflow.tv
|
||||
|
||||
import android.os.Bundle
|
||||
import android.util.Log
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.navigation.compose.NavHost
|
||||
import androidx.navigation.compose.composable
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import androidx.navigation.compose.currentBackStackEntryAsState
|
||||
import androidx.navigation.NavType
|
||||
import androidx.navigation.navArgument
|
||||
import com.streamflow.tv.data.api.ApiClient
|
||||
import com.streamflow.tv.data.repository.UserDataRepository
|
||||
import com.streamflow.tv.ui.components.SideNavRail
|
||||
import com.streamflow.tv.ui.screens.*
|
||||
import com.streamflow.tv.ui.theme.StreamFlowTheme
|
||||
import com.streamflow.tv.ui.theme.StreamFlowTvTheme
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
Log.d("MainActivity", "onCreate started")
|
||||
setContent {
|
||||
StreamFlowTvApp()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun StreamFlowTvApp() {
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
val userRepo = remember { UserDataRepository(context) }
|
||||
val navController = rememberNavController()
|
||||
|
||||
var currentTheme by remember { mutableStateOf("default") }
|
||||
var selectedNavId by remember { mutableStateOf("home") }
|
||||
|
||||
// Load persisted settings
|
||||
LaunchedEffect(Unit) {
|
||||
try {
|
||||
currentTheme = userRepo.theme.first()
|
||||
val serverUrl = userRepo.serverUrl.first()
|
||||
/*if (serverUrl.isNotBlank()) {
|
||||
if (serverUrl.isNotBlank()) {
|
||||
ApiClient.baseUrl = serverUrl
|
||||
}*/
|
||||
Log.d("StreamFlowTvApp", "Settings loaded: theme=$currentTheme, url=$serverUrl")
|
||||
} catch (e: Exception) {
|
||||
Log.e("StreamFlowTvApp", "Error loading settings", e)
|
||||
}
|
||||
}
|
||||
|
||||
StreamFlowTvTheme(themeName = currentTheme) {
|
||||
val colors = StreamFlowTheme.colors
|
||||
|
||||
val navBackStackEntry by navController.currentBackStackEntryAsState()
|
||||
val currentRoute = navBackStackEntry?.destination?.route
|
||||
val showSideNav = currentRoute != null && !currentRoute.startsWith("player")
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(colors.background)
|
||||
) {
|
||||
// Side Navigation
|
||||
if (showSideNav) {
|
||||
SideNavRail(
|
||||
selectedId = selectedNavId,
|
||||
onNavigate = { item ->
|
||||
selectedNavId = item.id
|
||||
navController.navigate(item.route) {
|
||||
popUpTo("home") { saveState = true }
|
||||
launchSingleTop = true
|
||||
restoreState = true
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// Main content
|
||||
Box(modifier = Modifier.weight(1f)) {
|
||||
NavHost(
|
||||
navController = navController,
|
||||
startDestination = "home"
|
||||
) {
|
||||
composable("home") {
|
||||
HomeScreen(
|
||||
onMovieClick = { slug ->
|
||||
navController.navigate("detail/$slug")
|
||||
},
|
||||
userDataRepository = userRepo
|
||||
)
|
||||
}
|
||||
|
||||
composable(
|
||||
"home/{category}",
|
||||
arguments = listOf(navArgument("category") { type = NavType.StringType })
|
||||
) { entry ->
|
||||
HomeScreen(
|
||||
onMovieClick = { slug -> navController.navigate("detail/$slug") },
|
||||
category = entry.arguments?.getString("category"),
|
||||
userDataRepository = userRepo
|
||||
)
|
||||
}
|
||||
|
||||
composable(
|
||||
"detail/{slug}",
|
||||
arguments = listOf(navArgument("slug") { type = NavType.StringType })
|
||||
) { entry ->
|
||||
val slug = entry.arguments?.getString("slug") ?: return@composable
|
||||
DetailScreen(
|
||||
slug = slug,
|
||||
onPlayClick = { s, ep -> navController.navigate("player/$s/$ep") },
|
||||
onBack = { navController.popBackStack() }
|
||||
)
|
||||
}
|
||||
|
||||
composable(
|
||||
"player/{slug}/{episode}",
|
||||
arguments = listOf(
|
||||
navArgument("slug") { type = NavType.StringType },
|
||||
navArgument("episode") { type = NavType.IntType; defaultValue = 1 }
|
||||
),
|
||||
deepLinks = listOf(androidx.navigation.navDeepLink { uriPattern = "streamflow://player/{slug}/{episode}" })
|
||||
) { entry ->
|
||||
val slug = entry.arguments?.getString("slug")
|
||||
val episode = entry.arguments?.getInt("episode") ?: 1
|
||||
Log.d("StreamFlowNav", "Navigating to player: slug=$slug, episode=$episode")
|
||||
if (slug == null) {
|
||||
return@composable
|
||||
}
|
||||
PlayerScreen(
|
||||
slug = slug,
|
||||
episode = episode,
|
||||
userDataRepository = userRepo
|
||||
)
|
||||
}
|
||||
|
||||
composable("search") {
|
||||
SearchScreen(
|
||||
onMovieClick = { slug -> navController.navigate("detail/$slug") }
|
||||
)
|
||||
}
|
||||
|
||||
composable("mylist") {
|
||||
MyListScreen(
|
||||
onMovieClick = { slug -> navController.navigate("detail/$slug") }
|
||||
)
|
||||
}
|
||||
|
||||
composable("settings") {
|
||||
SettingsScreen(
|
||||
currentTheme = currentTheme,
|
||||
onThemeChange = { theme ->
|
||||
currentTheme = theme
|
||||
scope.launch { userRepo.setTheme(theme) }
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Log.d("StreamFlowTvApp", "Settings loaded: theme=$currentTheme, url=$serverUrl")
|
||||
} catch (e: Exception) {
|
||||
Log.e("StreamFlowTvApp", "Error loading settings", e)
|
||||
}
|
||||
}
|
||||
|
||||
StreamFlowTvTheme(themeName = currentTheme) {
|
||||
val colors = StreamFlowTheme.colors
|
||||
|
||||
val navBackStackEntry by navController.currentBackStackEntryAsState()
|
||||
val currentRoute = navBackStackEntry?.destination?.route
|
||||
val showSideNav = currentRoute != null && !currentRoute.startsWith("player")
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(colors.background)
|
||||
) {
|
||||
// Side Navigation
|
||||
if (showSideNav) {
|
||||
SideNavRail(
|
||||
selectedId = selectedNavId,
|
||||
onNavigate = { item ->
|
||||
selectedNavId = item.id
|
||||
navController.navigate(item.route) {
|
||||
popUpTo("home") { saveState = true }
|
||||
launchSingleTop = true
|
||||
restoreState = true
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// Main content
|
||||
Box(modifier = Modifier.weight(1f)) {
|
||||
NavHost(
|
||||
navController = navController,
|
||||
startDestination = "home"
|
||||
) {
|
||||
composable("home") {
|
||||
HomeScreen(
|
||||
onMovieClick = { slug ->
|
||||
navController.navigate("detail/$slug")
|
||||
},
|
||||
userDataRepository = userRepo
|
||||
)
|
||||
}
|
||||
|
||||
composable(
|
||||
"home/{category}",
|
||||
arguments = listOf(navArgument("category") { type = NavType.StringType })
|
||||
) { entry ->
|
||||
HomeScreen(
|
||||
onMovieClick = { slug -> navController.navigate("detail/$slug") },
|
||||
category = entry.arguments?.getString("category"),
|
||||
userDataRepository = userRepo
|
||||
)
|
||||
}
|
||||
|
||||
composable(
|
||||
"detail/{slug}",
|
||||
arguments = listOf(navArgument("slug") { type = NavType.StringType })
|
||||
) { entry ->
|
||||
val slug = entry.arguments?.getString("slug") ?: return@composable
|
||||
DetailScreen(
|
||||
slug = slug,
|
||||
onPlayClick = { s, ep -> navController.navigate("player/$s/$ep") },
|
||||
onBack = { navController.popBackStack() }
|
||||
)
|
||||
}
|
||||
|
||||
composable(
|
||||
"player/{slug}/{episode}",
|
||||
arguments = listOf(
|
||||
navArgument("slug") { type = NavType.StringType },
|
||||
navArgument("episode") { type = NavType.IntType; defaultValue = 1 }
|
||||
),
|
||||
deepLinks = listOf(androidx.navigation.navDeepLink { uriPattern = "streamflow://player/{slug}/{episode}" })
|
||||
) { entry ->
|
||||
val slug = entry.arguments?.getString("slug")
|
||||
val episode = entry.arguments?.getInt("episode") ?: 1
|
||||
Log.d("StreamFlowNav", "Navigating to player: slug=$slug, episode=$episode")
|
||||
if (slug == null) {
|
||||
return@composable
|
||||
}
|
||||
PlayerScreen(
|
||||
slug = slug,
|
||||
episode = episode,
|
||||
userDataRepository = userRepo
|
||||
)
|
||||
}
|
||||
|
||||
composable("search") {
|
||||
SearchScreen(
|
||||
onMovieClick = { slug -> navController.navigate("detail/$slug") }
|
||||
)
|
||||
}
|
||||
|
||||
composable("mylist") {
|
||||
MyListScreen(
|
||||
onMovieClick = { slug -> navController.navigate("detail/$slug") }
|
||||
)
|
||||
}
|
||||
|
||||
composable("settings") {
|
||||
SettingsScreen(
|
||||
currentTheme = currentTheme,
|
||||
onThemeChange = { theme ->
|
||||
currentTheme = theme
|
||||
scope.launch { userRepo.setTheme(theme) }
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,30 +1,30 @@
|
|||
package com.streamflow.tv
|
||||
|
||||
import android.app.Application
|
||||
import coil.ImageLoader
|
||||
import coil.ImageLoaderFactory
|
||||
import coil.disk.DiskCache
|
||||
import coil.memory.MemoryCache
|
||||
|
||||
class StreamFlowApp : Application(), ImageLoaderFactory {
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
}
|
||||
|
||||
override fun newImageLoader(): ImageLoader {
|
||||
return ImageLoader.Builder(this)
|
||||
.memoryCache {
|
||||
MemoryCache.Builder(this)
|
||||
.maxSizePercent(0.25)
|
||||
.build()
|
||||
}
|
||||
.diskCache {
|
||||
DiskCache.Builder()
|
||||
.directory(this.cacheDir.resolve("image_cache"))
|
||||
.maxSizePercent(0.02)
|
||||
.build()
|
||||
}
|
||||
.respectCacheHeaders(false) // Often needed for some CDNs
|
||||
.build()
|
||||
}
|
||||
}
|
||||
package com.streamflow.tv
|
||||
|
||||
import android.app.Application
|
||||
import coil.ImageLoader
|
||||
import coil.ImageLoaderFactory
|
||||
import coil.disk.DiskCache
|
||||
import coil.memory.MemoryCache
|
||||
|
||||
class StreamFlowApp : Application(), ImageLoaderFactory {
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
}
|
||||
|
||||
override fun newImageLoader(): ImageLoader {
|
||||
return ImageLoader.Builder(this)
|
||||
.memoryCache {
|
||||
MemoryCache.Builder(this)
|
||||
.maxSizePercent(0.25)
|
||||
.build()
|
||||
}
|
||||
.diskCache {
|
||||
DiskCache.Builder()
|
||||
.directory(this.cacheDir.resolve("image_cache"))
|
||||
.maxSizePercent(0.02)
|
||||
.build()
|
||||
}
|
||||
.respectCacheHeaders(false) // Often needed for some CDNs
|
||||
.build()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,71 +1,71 @@
|
|||
package com.streamflow.tv.data.api
|
||||
|
||||
import com.squareup.moshi.Moshi
|
||||
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.logging.HttpLoggingInterceptor
|
||||
import retrofit2.Retrofit
|
||||
import retrofit2.converter.moshi.MoshiConverterFactory
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
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:3478/"
|
||||
|
||||
var baseUrl: String
|
||||
get() = _baseUrl
|
||||
set(value) {
|
||||
_baseUrl = if (value.endsWith("/")) value else "$value/"
|
||||
synchronized(this) {
|
||||
_api = null // Reset to rebuild
|
||||
}
|
||||
}
|
||||
|
||||
private val moshi: Moshi = Moshi.Builder()
|
||||
.addLast(KotlinJsonAdapterFactory())
|
||||
.build()
|
||||
|
||||
private val userAgentInterceptor = Interceptor { chain ->
|
||||
val request = chain.request().newBuilder()
|
||||
.header("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36")
|
||||
.build()
|
||||
chain.proceed(request)
|
||||
}
|
||||
|
||||
private val okHttpClient: OkHttpClient = OkHttpClient.Builder()
|
||||
.connectTimeout(20, TimeUnit.SECONDS)
|
||||
.readTimeout(60, TimeUnit.SECONDS)
|
||||
.addInterceptor(userAgentInterceptor)
|
||||
.addInterceptor(
|
||||
HttpLoggingInterceptor().apply {
|
||||
level = HttpLoggingInterceptor.Level.HEADERS
|
||||
}
|
||||
)
|
||||
.build()
|
||||
|
||||
private var _api: StreamFlowApi? = null
|
||||
|
||||
val api: StreamFlowApi
|
||||
get() {
|
||||
return synchronized(this) {
|
||||
if (_api == null) {
|
||||
_api = Retrofit.Builder()
|
||||
.baseUrl(_baseUrl)
|
||||
.client(okHttpClient)
|
||||
.addConverterFactory(MoshiConverterFactory.create(moshi))
|
||||
.build()
|
||||
.create(StreamFlowApi::class.java)
|
||||
}
|
||||
_api!!
|
||||
}
|
||||
}
|
||||
|
||||
fun imageProxyUrl(url: String, width: Int = 400): String {
|
||||
val base = _baseUrl.removeSuffix("/")
|
||||
return "$base/api/images/proxy?url=${java.net.URLEncoder.encode(url, "UTF-8")}&width=$width"
|
||||
}
|
||||
}
|
||||
package com.streamflow.tv.data.api
|
||||
|
||||
import com.squareup.moshi.Moshi
|
||||
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.logging.HttpLoggingInterceptor
|
||||
import retrofit2.Retrofit
|
||||
import retrofit2.converter.moshi.MoshiConverterFactory
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
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/"
|
||||
|
||||
var baseUrl: String
|
||||
get() = _baseUrl
|
||||
set(value) {
|
||||
_baseUrl = if (value.endsWith("/")) value else "$value/"
|
||||
synchronized(this) {
|
||||
_api = null // Reset to rebuild
|
||||
}
|
||||
}
|
||||
|
||||
private val moshi: Moshi = Moshi.Builder()
|
||||
.addLast(KotlinJsonAdapterFactory())
|
||||
.build()
|
||||
|
||||
private val userAgentInterceptor = Interceptor { chain ->
|
||||
val request = chain.request().newBuilder()
|
||||
.header("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36")
|
||||
.build()
|
||||
chain.proceed(request)
|
||||
}
|
||||
|
||||
private val okHttpClient: OkHttpClient = OkHttpClient.Builder()
|
||||
.connectTimeout(20, TimeUnit.SECONDS)
|
||||
.readTimeout(60, TimeUnit.SECONDS)
|
||||
.addInterceptor(userAgentInterceptor)
|
||||
.addInterceptor(
|
||||
HttpLoggingInterceptor().apply {
|
||||
level = HttpLoggingInterceptor.Level.HEADERS
|
||||
}
|
||||
)
|
||||
.build()
|
||||
|
||||
private var _api: StreamFlowApi? = null
|
||||
|
||||
val api: StreamFlowApi
|
||||
get() {
|
||||
return synchronized(this) {
|
||||
if (_api == null) {
|
||||
_api = Retrofit.Builder()
|
||||
.baseUrl(_baseUrl)
|
||||
.client(okHttpClient)
|
||||
.addConverterFactory(MoshiConverterFactory.create(moshi))
|
||||
.build()
|
||||
.create(StreamFlowApi::class.java)
|
||||
}
|
||||
_api!!
|
||||
}
|
||||
}
|
||||
|
||||
fun imageProxyUrl(url: String, width: Int = 400): String {
|
||||
val base = _baseUrl.removeSuffix("/")
|
||||
return "$base/api/images/proxy?url=${java.net.URLEncoder.encode(url, "UTF-8")}&width=$width"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,35 +1,35 @@
|
|||
package com.streamflow.tv.data.api
|
||||
|
||||
import com.streamflow.tv.data.model.*
|
||||
import retrofit2.http.*
|
||||
|
||||
interface StreamFlowApi {
|
||||
|
||||
@GET("api/videos/home")
|
||||
suspend fun getHomeVideos(
|
||||
@Query("category") category: String? = null,
|
||||
@Query("page") page: Int = 1
|
||||
): List<Movie>
|
||||
|
||||
@GET("api/videos/search")
|
||||
suspend fun searchVideos(
|
||||
@Query("q") query: String,
|
||||
@Query("page") page: Int = 1
|
||||
): List<Movie>
|
||||
|
||||
@GET("api/videos/{slug}")
|
||||
suspend fun getMovieDetail(
|
||||
@Path("slug") slug: String
|
||||
): MovieDetailResponse
|
||||
|
||||
@POST("api/extract")
|
||||
suspend fun extractVideo(
|
||||
@Body request: ExtractRequest
|
||||
): VideoSource
|
||||
|
||||
@GET("api/categories/genres")
|
||||
suspend fun getGenres(): List<Category>
|
||||
|
||||
@GET("api/categories/countries")
|
||||
suspend fun getCountries(): List<Category>
|
||||
}
|
||||
package com.streamflow.tv.data.api
|
||||
|
||||
import com.streamflow.tv.data.model.*
|
||||
import retrofit2.http.*
|
||||
|
||||
interface StreamFlowApi {
|
||||
|
||||
@GET("api/videos/home")
|
||||
suspend fun getHomeVideos(
|
||||
@Query("category") category: String? = null,
|
||||
@Query("page") page: Int = 1
|
||||
): List<Movie>
|
||||
|
||||
@GET("api/videos/search")
|
||||
suspend fun searchVideos(
|
||||
@Query("q") query: String,
|
||||
@Query("page") page: Int = 1
|
||||
): List<Movie>
|
||||
|
||||
@GET("api/videos/{slug}")
|
||||
suspend fun getMovieDetail(
|
||||
@Path("slug") slug: String
|
||||
): MovieDetailResponse
|
||||
|
||||
@POST("api/extract")
|
||||
suspend fun extractVideo(
|
||||
@Body request: ExtractRequest
|
||||
): VideoSource
|
||||
|
||||
@GET("api/categories/genres")
|
||||
suspend fun getGenres(): List<Category>
|
||||
|
||||
@GET("api/categories/countries")
|
||||
suspend fun getCountries(): List<Category>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,113 +1,113 @@
|
|||
package com.streamflow.tv.data.model
|
||||
|
||||
import com.squareup.moshi.Json
|
||||
import com.squareup.moshi.JsonClass
|
||||
|
||||
@JsonClass(generateAdapter = false)
|
||||
data class Movie(
|
||||
val id: String = "",
|
||||
val title: String = "",
|
||||
@Json(name = "original_title") val originalTitle: String? = null,
|
||||
val slug: String = "",
|
||||
val thumbnail: String = "",
|
||||
val backdrop: String? = null,
|
||||
val quality: String? = null,
|
||||
val year: Int? = null,
|
||||
val category: String = "",
|
||||
val time: String? = null,
|
||||
val lang: String? = null,
|
||||
val director: String? = null,
|
||||
val cast: List<String>? = null,
|
||||
val provider: String? = null
|
||||
)
|
||||
|
||||
@JsonClass(generateAdapter = false)
|
||||
data class MovieDetail(
|
||||
val id: String = "",
|
||||
val title: String = "",
|
||||
@Json(name = "original_title") val originalTitle: String? = null,
|
||||
val slug: String = "",
|
||||
val thumbnail: String = "",
|
||||
val backdrop: String? = null,
|
||||
val quality: String? = null,
|
||||
val year: Int? = null,
|
||||
val category: String = "",
|
||||
val description: String = "",
|
||||
val rating: String? = null,
|
||||
val duration: Int? = null,
|
||||
val genre: String? = null,
|
||||
val director: String? = null,
|
||||
val country: String? = null,
|
||||
val cast: List<String>? = null,
|
||||
val provider: String? = null,
|
||||
val episodes: List<Episode>? = null
|
||||
) {
|
||||
fun toMovie(): Movie = Movie(
|
||||
id = id,
|
||||
title = title,
|
||||
originalTitle = originalTitle,
|
||||
slug = slug,
|
||||
thumbnail = thumbnail,
|
||||
backdrop = backdrop,
|
||||
quality = quality,
|
||||
year = year,
|
||||
category = category,
|
||||
director = director,
|
||||
cast = cast,
|
||||
provider = provider
|
||||
)
|
||||
}
|
||||
|
||||
@JsonClass(generateAdapter = false)
|
||||
data class Episode(
|
||||
val number: Int = 0,
|
||||
val title: String = "",
|
||||
val url: String = ""
|
||||
)
|
||||
|
||||
@JsonClass(generateAdapter = false)
|
||||
data class VideoSource(
|
||||
@Json(name = "stream_url") val streamUrl: String = "",
|
||||
val resolution: String = "",
|
||||
@Json(name = "format_id") val formatId: String = ""
|
||||
)
|
||||
|
||||
@JsonClass(generateAdapter = false)
|
||||
data class Category(
|
||||
val name: String = "",
|
||||
val slug: String = ""
|
||||
)
|
||||
|
||||
@JsonClass(generateAdapter = false)
|
||||
data class HomeResponse(
|
||||
val items: List<Movie> = emptyList(),
|
||||
val totalPages: Int = 1,
|
||||
val currentPage: Int = 1
|
||||
)
|
||||
|
||||
@JsonClass(generateAdapter = false)
|
||||
data class ExtractRequest(
|
||||
val url: String
|
||||
)
|
||||
|
||||
@JsonClass(generateAdapter = false)
|
||||
data class MovieDetailResponse(
|
||||
val id: String = "",
|
||||
val title: String = "",
|
||||
@Json(name = "original_title") val originalTitle: String? = null,
|
||||
val slug: String = "",
|
||||
val thumbnail: String = "",
|
||||
val backdrop: String? = null,
|
||||
val quality: String? = null,
|
||||
val year: Int? = null,
|
||||
val category: String = "",
|
||||
val description: String = "",
|
||||
val rating: String? = null,
|
||||
val duration: Int? = null,
|
||||
val genre: String? = null,
|
||||
val director: String? = null,
|
||||
val country: String? = null,
|
||||
val cast: List<String>? = null,
|
||||
val episodes: List<Episode>? = null
|
||||
)
|
||||
|
||||
package com.streamflow.tv.data.model
|
||||
|
||||
import com.squareup.moshi.Json
|
||||
import com.squareup.moshi.JsonClass
|
||||
|
||||
@JsonClass(generateAdapter = false)
|
||||
data class Movie(
|
||||
val id: String = "",
|
||||
val title: String = "",
|
||||
@Json(name = "original_title") val originalTitle: String? = null,
|
||||
val slug: String = "",
|
||||
val thumbnail: String = "",
|
||||
val backdrop: String? = null,
|
||||
val quality: String? = null,
|
||||
val year: Int? = null,
|
||||
val category: String = "",
|
||||
val time: String? = null,
|
||||
val lang: String? = null,
|
||||
val director: String? = null,
|
||||
val cast: List<String>? = null,
|
||||
val provider: String? = null
|
||||
)
|
||||
|
||||
@JsonClass(generateAdapter = false)
|
||||
data class MovieDetail(
|
||||
val id: String = "",
|
||||
val title: String = "",
|
||||
@Json(name = "original_title") val originalTitle: String? = null,
|
||||
val slug: String = "",
|
||||
val thumbnail: String = "",
|
||||
val backdrop: String? = null,
|
||||
val quality: String? = null,
|
||||
val year: Int? = null,
|
||||
val category: String = "",
|
||||
val description: String = "",
|
||||
val rating: String? = null,
|
||||
val duration: Int? = null,
|
||||
val genre: String? = null,
|
||||
val director: String? = null,
|
||||
val country: String? = null,
|
||||
val cast: List<String>? = null,
|
||||
val provider: String? = null,
|
||||
val episodes: List<Episode>? = null
|
||||
) {
|
||||
fun toMovie(): Movie = Movie(
|
||||
id = id,
|
||||
title = title,
|
||||
originalTitle = originalTitle,
|
||||
slug = slug,
|
||||
thumbnail = thumbnail,
|
||||
backdrop = backdrop,
|
||||
quality = quality,
|
||||
year = year,
|
||||
category = category,
|
||||
director = director,
|
||||
cast = cast,
|
||||
provider = provider
|
||||
)
|
||||
}
|
||||
|
||||
@JsonClass(generateAdapter = false)
|
||||
data class Episode(
|
||||
val number: Int = 0,
|
||||
val title: String = "",
|
||||
val url: String = ""
|
||||
)
|
||||
|
||||
@JsonClass(generateAdapter = false)
|
||||
data class VideoSource(
|
||||
@Json(name = "stream_url") val streamUrl: String = "",
|
||||
val resolution: String = "",
|
||||
@Json(name = "format_id") val formatId: String = ""
|
||||
)
|
||||
|
||||
@JsonClass(generateAdapter = false)
|
||||
data class Category(
|
||||
val name: String = "",
|
||||
val slug: String = ""
|
||||
)
|
||||
|
||||
@JsonClass(generateAdapter = false)
|
||||
data class HomeResponse(
|
||||
val items: List<Movie> = emptyList(),
|
||||
val totalPages: Int = 1,
|
||||
val currentPage: Int = 1
|
||||
)
|
||||
|
||||
@JsonClass(generateAdapter = false)
|
||||
data class ExtractRequest(
|
||||
val url: String
|
||||
)
|
||||
|
||||
@JsonClass(generateAdapter = false)
|
||||
data class MovieDetailResponse(
|
||||
val id: String = "",
|
||||
val title: String = "",
|
||||
@Json(name = "original_title") val originalTitle: String? = null,
|
||||
val slug: String = "",
|
||||
val thumbnail: String = "",
|
||||
val backdrop: String? = null,
|
||||
val quality: String? = null,
|
||||
val year: Int? = null,
|
||||
val category: String = "",
|
||||
val description: String = "",
|
||||
val rating: String? = null,
|
||||
val duration: Int? = null,
|
||||
val genre: String? = null,
|
||||
val director: String? = null,
|
||||
val country: String? = null,
|
||||
val cast: List<String>? = null,
|
||||
val episodes: List<Episode>? = null
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -1,60 +1,60 @@
|
|||
package com.streamflow.tv.data.repository
|
||||
|
||||
import com.streamflow.tv.data.api.ApiClient
|
||||
import com.streamflow.tv.data.model.*
|
||||
|
||||
class MovieRepository {
|
||||
|
||||
private val api get() = ApiClient.api
|
||||
|
||||
suspend fun getHomeVideos(category: String? = null, page: Int = 1): HomeResponse {
|
||||
val list = api.getHomeVideos(category, page)
|
||||
android.util.Log.e("MovieRepo", "getHomeVideos($category): Received ${list.size} items")
|
||||
return HomeResponse(items = list, totalPages = 10, currentPage = page)
|
||||
}
|
||||
|
||||
suspend fun searchVideos(query: String, page: Int = 1): HomeResponse {
|
||||
val list = api.searchVideos(query, page)
|
||||
android.util.Log.e("MovieRepo", "searchVideos($query): Received ${list.size} items")
|
||||
return HomeResponse(items = list, totalPages = 1, currentPage = page)
|
||||
}
|
||||
|
||||
suspend fun getMovieDetail(slug: String): MovieDetail {
|
||||
val response = api.getMovieDetail(slug)
|
||||
|
||||
// API returns a flat list of episodes
|
||||
val episodes = response.episodes ?: emptyList()
|
||||
|
||||
return MovieDetail(
|
||||
id = response.id,
|
||||
title = response.title,
|
||||
originalTitle = response.originalTitle,
|
||||
slug = response.slug,
|
||||
thumbnail = response.thumbnail,
|
||||
backdrop = response.backdrop,
|
||||
quality = response.quality,
|
||||
year = response.year,
|
||||
category = response.category,
|
||||
description = response.description,
|
||||
rating = response.rating,
|
||||
duration = response.duration,
|
||||
genre = response.genre,
|
||||
director = response.director,
|
||||
country = response.country,
|
||||
cast = response.cast,
|
||||
episodes = episodes
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun extractVideo(url: String): VideoSource {
|
||||
return api.extractVideo(ExtractRequest(url))
|
||||
}
|
||||
|
||||
suspend fun getGenres(): List<Category> {
|
||||
return api.getGenres()
|
||||
}
|
||||
|
||||
suspend fun getCountries(): List<Category> {
|
||||
return api.getCountries()
|
||||
}
|
||||
}
|
||||
package com.streamflow.tv.data.repository
|
||||
|
||||
import com.streamflow.tv.data.api.ApiClient
|
||||
import com.streamflow.tv.data.model.*
|
||||
|
||||
class MovieRepository {
|
||||
|
||||
private val api get() = ApiClient.api
|
||||
|
||||
suspend fun getHomeVideos(category: String? = null, page: Int = 1): HomeResponse {
|
||||
val list = api.getHomeVideos(category, page)
|
||||
android.util.Log.e("MovieRepo", "getHomeVideos($category): Received ${list.size} items")
|
||||
return HomeResponse(items = list, totalPages = 10, currentPage = page)
|
||||
}
|
||||
|
||||
suspend fun searchVideos(query: String, page: Int = 1): HomeResponse {
|
||||
val list = api.searchVideos(query, page)
|
||||
android.util.Log.e("MovieRepo", "searchVideos($query): Received ${list.size} items")
|
||||
return HomeResponse(items = list, totalPages = 1, currentPage = page)
|
||||
}
|
||||
|
||||
suspend fun getMovieDetail(slug: String): MovieDetail {
|
||||
val response = api.getMovieDetail(slug)
|
||||
|
||||
// API returns a flat list of episodes
|
||||
val episodes = response.episodes ?: emptyList()
|
||||
|
||||
return MovieDetail(
|
||||
id = response.id,
|
||||
title = response.title,
|
||||
originalTitle = response.originalTitle,
|
||||
slug = response.slug,
|
||||
thumbnail = response.thumbnail,
|
||||
backdrop = response.backdrop,
|
||||
quality = response.quality,
|
||||
year = response.year,
|
||||
category = response.category,
|
||||
description = response.description,
|
||||
rating = response.rating,
|
||||
duration = response.duration,
|
||||
genre = response.genre,
|
||||
director = response.director,
|
||||
country = response.country,
|
||||
cast = response.cast,
|
||||
episodes = episodes
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun extractVideo(url: String): VideoSource {
|
||||
return api.extractVideo(ExtractRequest(url))
|
||||
}
|
||||
|
||||
suspend fun getGenres(): List<Category> {
|
||||
return api.getGenres()
|
||||
}
|
||||
|
||||
suspend fun getCountries(): List<Category> {
|
||||
return api.getCountries()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,103 +1,103 @@
|
|||
package com.streamflow.tv.data.repository
|
||||
|
||||
import android.content.Context
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.*
|
||||
import androidx.datastore.preferences.preferencesDataStore
|
||||
import com.squareup.moshi.Moshi
|
||||
import com.squareup.moshi.Types
|
||||
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
|
||||
import com.streamflow.tv.data.model.Movie
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
|
||||
private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "user_data")
|
||||
|
||||
class UserDataRepository(private val context: Context) {
|
||||
|
||||
companion object {
|
||||
private val MY_LIST_KEY = stringPreferencesKey("my_list")
|
||||
private val WATCH_HISTORY_KEY = stringPreferencesKey("watch_history")
|
||||
private val THEME_KEY = stringPreferencesKey("theme")
|
||||
private val SERVER_URL_KEY = stringPreferencesKey("server_url")
|
||||
|
||||
private const val MAX_HISTORY = 50
|
||||
}
|
||||
|
||||
private val moshi = Moshi.Builder().addLast(KotlinJsonAdapterFactory()).build()
|
||||
private val movieListType = Types.newParameterizedType(List::class.java, Movie::class.java)
|
||||
private val movieListAdapter = moshi.adapter<List<Movie>>(movieListType)
|
||||
|
||||
// --- My List ---
|
||||
|
||||
val myList: Flow<List<Movie>> = context.dataStore.data.map { prefs ->
|
||||
val json = prefs[MY_LIST_KEY] ?: "[]"
|
||||
movieListAdapter.fromJson(json) ?: emptyList()
|
||||
}
|
||||
|
||||
suspend fun addToMyList(movie: Movie) {
|
||||
context.dataStore.edit { prefs ->
|
||||
val current = movieListAdapter.fromJson(prefs[MY_LIST_KEY] ?: "[]") ?: emptyList()
|
||||
if (current.none { it.slug == movie.slug }) {
|
||||
prefs[MY_LIST_KEY] = movieListAdapter.toJson(current + movie)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun removeFromMyList(slug: String) {
|
||||
context.dataStore.edit { prefs ->
|
||||
val current = movieListAdapter.fromJson(prefs[MY_LIST_KEY] ?: "[]") ?: emptyList()
|
||||
prefs[MY_LIST_KEY] = movieListAdapter.toJson(current.filter { it.slug != slug })
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun isInMyList(slug: String): Boolean {
|
||||
var found = false
|
||||
context.dataStore.edit { prefs ->
|
||||
val current = movieListAdapter.fromJson(prefs[MY_LIST_KEY] ?: "[]") ?: emptyList()
|
||||
found = current.any { it.slug == slug }
|
||||
}
|
||||
return found
|
||||
}
|
||||
|
||||
// --- Watch History ---
|
||||
|
||||
val watchHistory: Flow<List<Movie>> = context.dataStore.data.map { prefs ->
|
||||
val json = prefs[WATCH_HISTORY_KEY] ?: "[]"
|
||||
movieListAdapter.fromJson(json) ?: emptyList()
|
||||
}
|
||||
|
||||
suspend fun addToHistory(movie: Movie) {
|
||||
context.dataStore.edit { prefs ->
|
||||
val current = movieListAdapter.fromJson(prefs[WATCH_HISTORY_KEY] ?: "[]")?.toMutableList() ?: mutableListOf()
|
||||
current.removeAll { it.slug == movie.slug }
|
||||
current.add(0, movie) // Most recent first
|
||||
val trimmed = current.take(MAX_HISTORY)
|
||||
prefs[WATCH_HISTORY_KEY] = movieListAdapter.toJson(trimmed)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Theme ---
|
||||
|
||||
val theme: Flow<String> = context.dataStore.data.map { prefs ->
|
||||
prefs[THEME_KEY] ?: "default"
|
||||
}
|
||||
|
||||
suspend fun setTheme(theme: String) {
|
||||
context.dataStore.edit { prefs ->
|
||||
prefs[THEME_KEY] = theme
|
||||
}
|
||||
}
|
||||
|
||||
// --- Server URL ---
|
||||
|
||||
val serverUrl: Flow<String> = context.dataStore.data.map { prefs ->
|
||||
prefs[SERVER_URL_KEY] ?: "https://nf.khoavo.myds.me"
|
||||
}
|
||||
|
||||
suspend fun setServerUrl(url: String) {
|
||||
context.dataStore.edit { prefs ->
|
||||
prefs[SERVER_URL_KEY] = url
|
||||
}
|
||||
}
|
||||
}
|
||||
package com.streamflow.tv.data.repository
|
||||
|
||||
import android.content.Context
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.*
|
||||
import androidx.datastore.preferences.preferencesDataStore
|
||||
import com.squareup.moshi.Moshi
|
||||
import com.squareup.moshi.Types
|
||||
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
|
||||
import com.streamflow.tv.data.model.Movie
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
|
||||
private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "user_data")
|
||||
|
||||
class UserDataRepository(private val context: Context) {
|
||||
|
||||
companion object {
|
||||
private val MY_LIST_KEY = stringPreferencesKey("my_list")
|
||||
private val WATCH_HISTORY_KEY = stringPreferencesKey("watch_history")
|
||||
private val THEME_KEY = stringPreferencesKey("theme")
|
||||
private val SERVER_URL_KEY = stringPreferencesKey("server_url")
|
||||
|
||||
private const val MAX_HISTORY = 50
|
||||
}
|
||||
|
||||
private val moshi = Moshi.Builder().addLast(KotlinJsonAdapterFactory()).build()
|
||||
private val movieListType = Types.newParameterizedType(List::class.java, Movie::class.java)
|
||||
private val movieListAdapter = moshi.adapter<List<Movie>>(movieListType)
|
||||
|
||||
// --- My List ---
|
||||
|
||||
val myList: Flow<List<Movie>> = context.dataStore.data.map { prefs ->
|
||||
val json = prefs[MY_LIST_KEY] ?: "[]"
|
||||
movieListAdapter.fromJson(json) ?: emptyList()
|
||||
}
|
||||
|
||||
suspend fun addToMyList(movie: Movie) {
|
||||
context.dataStore.edit { prefs ->
|
||||
val current = movieListAdapter.fromJson(prefs[MY_LIST_KEY] ?: "[]") ?: emptyList()
|
||||
if (current.none { it.slug == movie.slug }) {
|
||||
prefs[MY_LIST_KEY] = movieListAdapter.toJson(current + movie)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun removeFromMyList(slug: String) {
|
||||
context.dataStore.edit { prefs ->
|
||||
val current = movieListAdapter.fromJson(prefs[MY_LIST_KEY] ?: "[]") ?: emptyList()
|
||||
prefs[MY_LIST_KEY] = movieListAdapter.toJson(current.filter { it.slug != slug })
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun isInMyList(slug: String): Boolean {
|
||||
var found = false
|
||||
context.dataStore.edit { prefs ->
|
||||
val current = movieListAdapter.fromJson(prefs[MY_LIST_KEY] ?: "[]") ?: emptyList()
|
||||
found = current.any { it.slug == slug }
|
||||
}
|
||||
return found
|
||||
}
|
||||
|
||||
// --- Watch History ---
|
||||
|
||||
val watchHistory: Flow<List<Movie>> = context.dataStore.data.map { prefs ->
|
||||
val json = prefs[WATCH_HISTORY_KEY] ?: "[]"
|
||||
movieListAdapter.fromJson(json) ?: emptyList()
|
||||
}
|
||||
|
||||
suspend fun addToHistory(movie: Movie) {
|
||||
context.dataStore.edit { prefs ->
|
||||
val current = movieListAdapter.fromJson(prefs[WATCH_HISTORY_KEY] ?: "[]")?.toMutableList() ?: mutableListOf()
|
||||
current.removeAll { it.slug == movie.slug }
|
||||
current.add(0, movie) // Most recent first
|
||||
val trimmed = current.take(MAX_HISTORY)
|
||||
prefs[WATCH_HISTORY_KEY] = movieListAdapter.toJson(trimmed)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Theme ---
|
||||
|
||||
val theme: Flow<String> = context.dataStore.data.map { prefs ->
|
||||
prefs[THEME_KEY] ?: "default"
|
||||
}
|
||||
|
||||
suspend fun setTheme(theme: String) {
|
||||
context.dataStore.edit { prefs ->
|
||||
prefs[THEME_KEY] = theme
|
||||
}
|
||||
}
|
||||
|
||||
// --- Server URL ---
|
||||
|
||||
val serverUrl: Flow<String> = context.dataStore.data.map { prefs ->
|
||||
prefs[SERVER_URL_KEY] ?: "https://nf.khoavo.myds.me"
|
||||
}
|
||||
|
||||
suspend fun setServerUrl(url: String) {
|
||||
context.dataStore.edit { prefs ->
|
||||
prefs[SERVER_URL_KEY] = url
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,76 +1,76 @@
|
|||
package com.streamflow.tv.ui.components
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.tv.foundation.lazy.grid.TvGridCells
|
||||
import androidx.tv.foundation.lazy.grid.TvLazyVerticalGrid
|
||||
import androidx.tv.foundation.lazy.grid.items
|
||||
import androidx.tv.material3.*
|
||||
import com.streamflow.tv.data.model.Episode
|
||||
import com.streamflow.tv.ui.theme.StreamFlowTheme
|
||||
|
||||
@OptIn(ExperimentalTvMaterial3Api::class)
|
||||
@Composable
|
||||
fun EpisodeSelector(
|
||||
episodes: List<Episode>,
|
||||
currentEpisode: Int,
|
||||
onEpisodeSelect: (Episode) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val colors = StreamFlowTheme.colors
|
||||
|
||||
Column(modifier = modifier) {
|
||||
Text(
|
||||
text = "Episodes",
|
||||
style = StreamFlowTheme.typography.headlineMedium,
|
||||
modifier = Modifier.padding(bottom = 12.dp)
|
||||
)
|
||||
|
||||
android.util.Log.e("EpisodeSelector", "Rendering grid with ${episodes.size} episodes")
|
||||
TvLazyVerticalGrid(
|
||||
columns = TvGridCells.Adaptive(minSize = 120.dp),
|
||||
contentPadding = PaddingValues(4.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
items(episodes) { episode ->
|
||||
val isActive = episode.number == currentEpisode
|
||||
var isFocused by remember { mutableStateOf(false) }
|
||||
|
||||
Surface(
|
||||
onClick = { onEpisodeSelect(episode) },
|
||||
modifier = Modifier
|
||||
.onFocusChanged { isFocused = it.isFocused },
|
||||
shape = ClickableSurfaceDefaults.shape(
|
||||
shape = RoundedCornerShape(8.dp)
|
||||
),
|
||||
colors = ClickableSurfaceDefaults.colors(
|
||||
containerColor = if (isActive) colors.primary.copy(alpha = 0.2f) else colors.surfaceVariant,
|
||||
focusedContainerColor = colors.primary.copy(alpha = 0.3f)
|
||||
),
|
||||
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.05f)
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 12.dp, horizontal = 16.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = if (episode.title.isNotBlank()) episode.title else "Ep ${episode.number}",
|
||||
style = StreamFlowTheme.typography.labelLarge.copy(
|
||||
color = if (isActive) colors.primary else Color.White
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
package com.streamflow.tv.ui.components
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.tv.foundation.lazy.grid.TvGridCells
|
||||
import androidx.tv.foundation.lazy.grid.TvLazyVerticalGrid
|
||||
import androidx.tv.foundation.lazy.grid.items
|
||||
import androidx.tv.material3.*
|
||||
import com.streamflow.tv.data.model.Episode
|
||||
import com.streamflow.tv.ui.theme.StreamFlowTheme
|
||||
|
||||
@OptIn(ExperimentalTvMaterial3Api::class)
|
||||
@Composable
|
||||
fun EpisodeSelector(
|
||||
episodes: List<Episode>,
|
||||
currentEpisode: Int,
|
||||
onEpisodeSelect: (Episode) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val colors = StreamFlowTheme.colors
|
||||
|
||||
Column(modifier = modifier) {
|
||||
Text(
|
||||
text = "Episodes",
|
||||
style = StreamFlowTheme.typography.headlineMedium,
|
||||
modifier = Modifier.padding(bottom = 12.dp)
|
||||
)
|
||||
|
||||
android.util.Log.e("EpisodeSelector", "Rendering grid with ${episodes.size} episodes")
|
||||
TvLazyVerticalGrid(
|
||||
columns = TvGridCells.Adaptive(minSize = 120.dp),
|
||||
contentPadding = PaddingValues(4.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
items(episodes) { episode ->
|
||||
val isActive = episode.number == currentEpisode
|
||||
var isFocused by remember { mutableStateOf(false) }
|
||||
|
||||
Surface(
|
||||
onClick = { onEpisodeSelect(episode) },
|
||||
modifier = Modifier
|
||||
.onFocusChanged { isFocused = it.isFocused },
|
||||
shape = ClickableSurfaceDefaults.shape(
|
||||
shape = RoundedCornerShape(8.dp)
|
||||
),
|
||||
colors = ClickableSurfaceDefaults.colors(
|
||||
containerColor = if (isActive) colors.primary.copy(alpha = 0.2f) else colors.surfaceVariant,
|
||||
focusedContainerColor = colors.primary.copy(alpha = 0.3f)
|
||||
),
|
||||
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.05f)
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 12.dp, horizontal = 16.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = if (episode.title.isNotBlank()) episode.title else "Ep ${episode.number}",
|
||||
style = StreamFlowTheme.typography.labelLarge.copy(
|
||||
color = if (isActive) colors.primary else Color.White
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,159 +1,159 @@
|
|||
package com.streamflow.tv.ui.components
|
||||
|
||||
import androidx.compose.animation.*
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.tv.material3.*
|
||||
import coil.compose.AsyncImage
|
||||
import com.streamflow.tv.data.api.ApiClient
|
||||
import com.streamflow.tv.data.model.Movie
|
||||
import com.streamflow.tv.ui.theme.StreamFlowTheme
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
@OptIn(ExperimentalTvMaterial3Api::class)
|
||||
@Composable
|
||||
fun HeroBanner(
|
||||
movies: List<Movie>,
|
||||
onPlayClick: (Movie) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
if (movies.isEmpty()) return
|
||||
val colors = StreamFlowTheme.colors
|
||||
|
||||
var currentIndex by remember { mutableIntStateOf(0) }
|
||||
val currentMovie = movies[currentIndex]
|
||||
|
||||
LaunchedEffect(currentIndex) {
|
||||
delay(6000)
|
||||
currentIndex = (currentIndex + 1) % movies.size
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.height(480.dp)
|
||||
) {
|
||||
AnimatedContent(
|
||||
targetState = currentMovie,
|
||||
transitionSpec = { fadeIn() togetherWith fadeOut() },
|
||||
label = "hero-crossfade"
|
||||
) { movie ->
|
||||
AsyncImage(
|
||||
model = ApiClient.imageProxyUrl(movie.backdrop ?: movie.thumbnail, 1280),
|
||||
contentDescription = movie.title,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier.fillMaxSize()
|
||||
)
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(
|
||||
Brush.horizontalGradient(
|
||||
colors = listOf(
|
||||
colors.background.copy(alpha = 0.9f),
|
||||
colors.background.copy(alpha = 0.5f),
|
||||
Color.Transparent
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.fillMaxHeight(0.4f)
|
||||
.align(Alignment.BottomCenter)
|
||||
.background(
|
||||
Brush.verticalGradient(
|
||||
colors = listOf(Color.Transparent, colors.background)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.align(Alignment.CenterStart)
|
||||
.padding(start = 48.dp, end = 200.dp)
|
||||
.fillMaxHeight(),
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
currentMovie.quality?.let { quality ->
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.background(colors.primary, RoundedCornerShape(4.dp))
|
||||
.padding(horizontal = 8.dp, vertical = 4.dp)
|
||||
) {
|
||||
Text(
|
||||
text = quality,
|
||||
style = StreamFlowTheme.typography.labelSmall.copy(color = Color.White)
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(12.dp))
|
||||
}
|
||||
|
||||
Text(
|
||||
text = currentMovie.title,
|
||||
style = StreamFlowTheme.typography.displayLarge,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(12.dp))
|
||||
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
currentMovie.year?.let {
|
||||
Text("$it", style = StreamFlowTheme.typography.bodyLarge)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
Surface(
|
||||
onClick = { onPlayClick(currentMovie) },
|
||||
shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(8.dp)),
|
||||
colors = ClickableSurfaceDefaults.colors(
|
||||
containerColor = colors.primary,
|
||||
focusedContainerColor = colors.accent
|
||||
),
|
||||
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.05f)
|
||||
) {
|
||||
Text(
|
||||
text = "▶ Play Now",
|
||||
style = StreamFlowTheme.typography.titleMedium.copy(color = Color.White),
|
||||
modifier = Modifier.padding(horizontal = 24.dp, vertical = 12.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomCenter)
|
||||
.padding(bottom = 16.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
movies.forEachIndexed { index, _ ->
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(if (index == currentIndex) 24.dp else 8.dp, 8.dp)
|
||||
.clip(CircleShape)
|
||||
.background(
|
||||
if (index == currentIndex) colors.primary
|
||||
else Color.White.copy(alpha = 0.3f)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
package com.streamflow.tv.ui.components
|
||||
|
||||
import androidx.compose.animation.*
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.tv.material3.*
|
||||
import coil.compose.AsyncImage
|
||||
import com.streamflow.tv.data.api.ApiClient
|
||||
import com.streamflow.tv.data.model.Movie
|
||||
import com.streamflow.tv.ui.theme.StreamFlowTheme
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
@OptIn(ExperimentalTvMaterial3Api::class)
|
||||
@Composable
|
||||
fun HeroBanner(
|
||||
movies: List<Movie>,
|
||||
onPlayClick: (Movie) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
if (movies.isEmpty()) return
|
||||
val colors = StreamFlowTheme.colors
|
||||
|
||||
var currentIndex by remember { mutableIntStateOf(0) }
|
||||
val currentMovie = movies[currentIndex]
|
||||
|
||||
LaunchedEffect(currentIndex) {
|
||||
delay(6000)
|
||||
currentIndex = (currentIndex + 1) % movies.size
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.height(480.dp)
|
||||
) {
|
||||
AnimatedContent(
|
||||
targetState = currentMovie,
|
||||
transitionSpec = { fadeIn() togetherWith fadeOut() },
|
||||
label = "hero-crossfade"
|
||||
) { movie ->
|
||||
AsyncImage(
|
||||
model = ApiClient.imageProxyUrl(movie.backdrop ?: movie.thumbnail, 1280),
|
||||
contentDescription = movie.title,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier.fillMaxSize()
|
||||
)
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(
|
||||
Brush.horizontalGradient(
|
||||
colors = listOf(
|
||||
colors.background.copy(alpha = 0.9f),
|
||||
colors.background.copy(alpha = 0.5f),
|
||||
Color.Transparent
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.fillMaxHeight(0.4f)
|
||||
.align(Alignment.BottomCenter)
|
||||
.background(
|
||||
Brush.verticalGradient(
|
||||
colors = listOf(Color.Transparent, colors.background)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.align(Alignment.CenterStart)
|
||||
.padding(start = 48.dp, end = 200.dp)
|
||||
.fillMaxHeight(),
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
currentMovie.quality?.let { quality ->
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.background(colors.primary, RoundedCornerShape(4.dp))
|
||||
.padding(horizontal = 8.dp, vertical = 4.dp)
|
||||
) {
|
||||
Text(
|
||||
text = quality,
|
||||
style = StreamFlowTheme.typography.labelSmall.copy(color = Color.White)
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(12.dp))
|
||||
}
|
||||
|
||||
Text(
|
||||
text = currentMovie.title,
|
||||
style = StreamFlowTheme.typography.displayLarge,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(12.dp))
|
||||
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
currentMovie.year?.let {
|
||||
Text("$it", style = StreamFlowTheme.typography.bodyLarge)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
Surface(
|
||||
onClick = { onPlayClick(currentMovie) },
|
||||
shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(8.dp)),
|
||||
colors = ClickableSurfaceDefaults.colors(
|
||||
containerColor = colors.primary,
|
||||
focusedContainerColor = colors.accent
|
||||
),
|
||||
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.05f)
|
||||
) {
|
||||
Text(
|
||||
text = "▶ Play Now",
|
||||
style = StreamFlowTheme.typography.titleMedium.copy(color = Color.White),
|
||||
modifier = Modifier.padding(horizontal = 24.dp, vertical = 12.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomCenter)
|
||||
.padding(bottom = 16.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
movies.forEachIndexed { index, _ ->
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(if (index == currentIndex) 24.dp else 8.dp, 8.dp)
|
||||
.clip(CircleShape)
|
||||
.background(
|
||||
if (index == currentIndex) colors.primary
|
||||
else Color.White.copy(alpha = 0.3f)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,116 +1,116 @@
|
|||
package com.streamflow.tv.ui.components
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.tv.material3.*
|
||||
import coil.compose.AsyncImage
|
||||
import com.streamflow.tv.data.api.ApiClient
|
||||
import com.streamflow.tv.data.model.Movie
|
||||
import com.streamflow.tv.ui.theme.StreamFlowTheme
|
||||
|
||||
@OptIn(ExperimentalTvMaterial3Api::class)
|
||||
@Composable
|
||||
fun MovieCard(
|
||||
movie: Movie,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val colors = StreamFlowTheme.colors
|
||||
|
||||
Surface(
|
||||
onClick = onClick,
|
||||
modifier = modifier
|
||||
.width(200.dp)
|
||||
.height(300.dp),
|
||||
shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(12.dp)),
|
||||
colors = ClickableSurfaceDefaults.colors(
|
||||
containerColor = colors.surfaceVariant,
|
||||
focusedContainerColor = colors.surfaceVariant
|
||||
),
|
||||
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.08f)
|
||||
) {
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
AsyncImage(
|
||||
model = ApiClient.imageProxyUrl(movie.thumbnail, 300),
|
||||
contentDescription = movie.title,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
)
|
||||
|
||||
movie.quality?.let { quality ->
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.padding(8.dp)
|
||||
.align(Alignment.TopEnd)
|
||||
.background(colors.primary, RoundedCornerShape(4.dp))
|
||||
.padding(horizontal = 6.dp, vertical = 2.dp)
|
||||
) {
|
||||
Text(
|
||||
text = quality,
|
||||
style = StreamFlowTheme.typography.labelSmall.copy(color = Color.White)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
movie.provider?.let { provider ->
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.padding(8.dp)
|
||||
.align(Alignment.TopStart)
|
||||
.background(Color.Black.copy(alpha = 0.6f), RoundedCornerShape(4.dp))
|
||||
.padding(horizontal = 6.dp, vertical = 2.dp)
|
||||
) {
|
||||
Text(
|
||||
text = provider,
|
||||
style = StreamFlowTheme.typography.labelSmall.copy(
|
||||
color = Color.White.copy(alpha = 0.8f),
|
||||
fontSize = androidx.compose.ui.unit.TextUnit.Unspecified // Default or small
|
||||
),
|
||||
maxLines = 1
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.align(Alignment.BottomCenter)
|
||||
.background(
|
||||
Brush.verticalGradient(
|
||||
colors = listOf(Color.Transparent, Color.Black.copy(alpha = 0.85f))
|
||||
)
|
||||
)
|
||||
.padding(horizontal = 10.dp, vertical = 10.dp)
|
||||
) {
|
||||
Text(
|
||||
text = movie.title,
|
||||
style = StreamFlowTheme.typography.labelLarge,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
|
||||
movie.year?.let { year ->
|
||||
Text(
|
||||
text = year.toString(),
|
||||
style = StreamFlowTheme.typography.labelSmall.copy(
|
||||
color = Color.White.copy(alpha = 0.6f)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
package com.streamflow.tv.ui.components
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.tv.material3.*
|
||||
import coil.compose.AsyncImage
|
||||
import com.streamflow.tv.data.api.ApiClient
|
||||
import com.streamflow.tv.data.model.Movie
|
||||
import com.streamflow.tv.ui.theme.StreamFlowTheme
|
||||
|
||||
@OptIn(ExperimentalTvMaterial3Api::class)
|
||||
@Composable
|
||||
fun MovieCard(
|
||||
movie: Movie,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val colors = StreamFlowTheme.colors
|
||||
|
||||
Surface(
|
||||
onClick = onClick,
|
||||
modifier = modifier
|
||||
.width(200.dp)
|
||||
.height(300.dp),
|
||||
shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(12.dp)),
|
||||
colors = ClickableSurfaceDefaults.colors(
|
||||
containerColor = colors.surfaceVariant,
|
||||
focusedContainerColor = colors.surfaceVariant
|
||||
),
|
||||
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.08f)
|
||||
) {
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
AsyncImage(
|
||||
model = ApiClient.imageProxyUrl(movie.thumbnail, 300),
|
||||
contentDescription = movie.title,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
)
|
||||
|
||||
movie.quality?.let { quality ->
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.padding(8.dp)
|
||||
.align(Alignment.TopEnd)
|
||||
.background(colors.primary, RoundedCornerShape(4.dp))
|
||||
.padding(horizontal = 6.dp, vertical = 2.dp)
|
||||
) {
|
||||
Text(
|
||||
text = quality,
|
||||
style = StreamFlowTheme.typography.labelSmall.copy(color = Color.White)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
movie.provider?.let { provider ->
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.padding(8.dp)
|
||||
.align(Alignment.TopStart)
|
||||
.background(Color.Black.copy(alpha = 0.6f), RoundedCornerShape(4.dp))
|
||||
.padding(horizontal = 6.dp, vertical = 2.dp)
|
||||
) {
|
||||
Text(
|
||||
text = provider,
|
||||
style = StreamFlowTheme.typography.labelSmall.copy(
|
||||
color = Color.White.copy(alpha = 0.8f),
|
||||
fontSize = androidx.compose.ui.unit.TextUnit.Unspecified // Default or small
|
||||
),
|
||||
maxLines = 1
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.align(Alignment.BottomCenter)
|
||||
.background(
|
||||
Brush.verticalGradient(
|
||||
colors = listOf(Color.Transparent, Color.Black.copy(alpha = 0.85f))
|
||||
)
|
||||
)
|
||||
.padding(horizontal = 10.dp, vertical = 10.dp)
|
||||
) {
|
||||
Text(
|
||||
text = movie.title,
|
||||
style = StreamFlowTheme.typography.labelLarge,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
|
||||
movie.year?.let { year ->
|
||||
Text(
|
||||
text = year.toString(),
|
||||
style = StreamFlowTheme.typography.labelSmall.copy(
|
||||
color = Color.White.copy(alpha = 0.6f)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,43 +1,43 @@
|
|||
package com.streamflow.tv.ui.components
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.tv.foundation.lazy.list.TvLazyRow
|
||||
import androidx.tv.foundation.lazy.list.items
|
||||
import androidx.tv.material3.ExperimentalTvMaterial3Api
|
||||
import androidx.tv.material3.Text
|
||||
import com.streamflow.tv.data.model.Movie
|
||||
import com.streamflow.tv.ui.theme.StreamFlowTheme
|
||||
|
||||
@OptIn(ExperimentalTvMaterial3Api::class)
|
||||
@Composable
|
||||
fun MovieRow(
|
||||
title: String,
|
||||
movies: List<Movie>,
|
||||
onMovieClick: (Movie) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Column(modifier = modifier.padding(vertical = 12.dp)) {
|
||||
// Section title
|
||||
Text(
|
||||
text = title,
|
||||
style = StreamFlowTheme.typography.headlineMedium,
|
||||
modifier = Modifier.padding(start = 48.dp, bottom = 12.dp)
|
||||
)
|
||||
|
||||
// Horizontal scrolling row of cards
|
||||
TvLazyRow(
|
||||
contentPadding = PaddingValues(horizontal = 48.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
items(movies) { movie ->
|
||||
MovieCard(
|
||||
movie = movie,
|
||||
onClick = { onMovieClick(movie) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
package com.streamflow.tv.ui.components
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.tv.foundation.lazy.list.TvLazyRow
|
||||
import androidx.tv.foundation.lazy.list.items
|
||||
import androidx.tv.material3.ExperimentalTvMaterial3Api
|
||||
import androidx.tv.material3.Text
|
||||
import com.streamflow.tv.data.model.Movie
|
||||
import com.streamflow.tv.ui.theme.StreamFlowTheme
|
||||
|
||||
@OptIn(ExperimentalTvMaterial3Api::class)
|
||||
@Composable
|
||||
fun MovieRow(
|
||||
title: String,
|
||||
movies: List<Movie>,
|
||||
onMovieClick: (Movie) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Column(modifier = modifier.padding(vertical = 12.dp)) {
|
||||
// Section title
|
||||
Text(
|
||||
text = title,
|
||||
style = StreamFlowTheme.typography.headlineMedium,
|
||||
modifier = Modifier.padding(start = 48.dp, bottom = 12.dp)
|
||||
)
|
||||
|
||||
// Horizontal scrolling row of cards
|
||||
TvLazyRow(
|
||||
contentPadding = PaddingValues(horizontal = 48.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
items(movies) { movie ->
|
||||
MovieCard(
|
||||
movie = movie,
|
||||
onClick = { onMovieClick(movie) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,127 +1,127 @@
|
|||
package com.streamflow.tv.ui.components
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.tv.material3.*
|
||||
import com.streamflow.tv.ui.theme.StreamFlowTheme
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
|
||||
data class NavItem(
|
||||
val id: String,
|
||||
val route: String,
|
||||
val label: String,
|
||||
val icon: ImageVector
|
||||
)
|
||||
|
||||
val NAV_ITEMS = listOf(
|
||||
NavItem("home", "home", "Home", Icons.Default.Home),
|
||||
NavItem("categories", "home/phim-le", "Categories", Icons.Default.Category),
|
||||
NavItem("search", "search", "Search", Icons.Default.Search),
|
||||
NavItem("mylist", "mylist", "My List", Icons.Default.Favorite),
|
||||
NavItem("settings", "settings", "Settings", Icons.Default.Settings)
|
||||
)
|
||||
|
||||
@OptIn(ExperimentalTvMaterial3Api::class)
|
||||
@Composable
|
||||
fun SideNavRail(
|
||||
selectedId: String,
|
||||
onNavigate: (NavItem) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val colors = StreamFlowTheme.colors
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
try {
|
||||
focusRequester.requestFocus()
|
||||
} catch (e: Exception) {
|
||||
// Ignore
|
||||
}
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxHeight()
|
||||
.width(56.dp)
|
||||
.background(colors.background.copy(alpha = 0.95f))
|
||||
.padding(vertical = 16.dp),
|
||||
verticalArrangement = Arrangement.SpaceBetween,
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(36.dp)
|
||||
.clip(CircleShape)
|
||||
.background(colors.primary),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text("S", style = StreamFlowTheme.typography.titleMedium.copy(color = Color.White))
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(24.dp))
|
||||
|
||||
Column(
|
||||
modifier = Modifier.weight(1f),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
NAV_ITEMS.forEach { item ->
|
||||
NavRailItem(
|
||||
item = item,
|
||||
isSelected = selectedId == item.id,
|
||||
onClick = { onNavigate(item) },
|
||||
accentColor = colors.primary,
|
||||
modifier = if (item.id == "home") Modifier.focusRequester(focusRequester) else Modifier
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalTvMaterial3Api::class)
|
||||
@Composable
|
||||
private fun NavRailItem(
|
||||
item: NavItem,
|
||||
isSelected: Boolean,
|
||||
onClick: () -> Unit,
|
||||
accentColor: Color,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
var isFocused by remember { mutableStateOf(false) }
|
||||
|
||||
Surface(
|
||||
onClick = onClick,
|
||||
modifier = modifier
|
||||
.size(48.dp),
|
||||
shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(12.dp)),
|
||||
colors = ClickableSurfaceDefaults.colors(
|
||||
containerColor = if (isSelected) accentColor.copy(alpha = 0.15f) else Color.Transparent,
|
||||
focusedContainerColor = accentColor.copy(alpha = 0.2f)
|
||||
),
|
||||
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.1f)
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(
|
||||
imageVector = item.icon,
|
||||
contentDescription = item.label,
|
||||
tint = if (isSelected) accentColor else Color.White.copy(alpha = 0.6f),
|
||||
modifier = Modifier.size(22.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
package com.streamflow.tv.ui.components
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.tv.material3.*
|
||||
import com.streamflow.tv.ui.theme.StreamFlowTheme
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
|
||||
data class NavItem(
|
||||
val id: String,
|
||||
val route: String,
|
||||
val label: String,
|
||||
val icon: ImageVector
|
||||
)
|
||||
|
||||
val NAV_ITEMS = listOf(
|
||||
NavItem("home", "home", "Home", Icons.Default.Home),
|
||||
NavItem("categories", "home/phim-le", "Categories", Icons.Default.Category),
|
||||
NavItem("search", "search", "Search", Icons.Default.Search),
|
||||
NavItem("mylist", "mylist", "My List", Icons.Default.Favorite),
|
||||
NavItem("settings", "settings", "Settings", Icons.Default.Settings)
|
||||
)
|
||||
|
||||
@OptIn(ExperimentalTvMaterial3Api::class)
|
||||
@Composable
|
||||
fun SideNavRail(
|
||||
selectedId: String,
|
||||
onNavigate: (NavItem) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val colors = StreamFlowTheme.colors
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
try {
|
||||
focusRequester.requestFocus()
|
||||
} catch (e: Exception) {
|
||||
// Ignore
|
||||
}
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxHeight()
|
||||
.width(56.dp)
|
||||
.background(colors.background.copy(alpha = 0.95f))
|
||||
.padding(vertical = 16.dp),
|
||||
verticalArrangement = Arrangement.SpaceBetween,
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(36.dp)
|
||||
.clip(CircleShape)
|
||||
.background(colors.primary),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text("S", style = StreamFlowTheme.typography.titleMedium.copy(color = Color.White))
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(24.dp))
|
||||
|
||||
Column(
|
||||
modifier = Modifier.weight(1f),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
NAV_ITEMS.forEach { item ->
|
||||
NavRailItem(
|
||||
item = item,
|
||||
isSelected = selectedId == item.id,
|
||||
onClick = { onNavigate(item) },
|
||||
accentColor = colors.primary,
|
||||
modifier = if (item.id == "home") Modifier.focusRequester(focusRequester) else Modifier
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalTvMaterial3Api::class)
|
||||
@Composable
|
||||
private fun NavRailItem(
|
||||
item: NavItem,
|
||||
isSelected: Boolean,
|
||||
onClick: () -> Unit,
|
||||
accentColor: Color,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
var isFocused by remember { mutableStateOf(false) }
|
||||
|
||||
Surface(
|
||||
onClick = onClick,
|
||||
modifier = modifier
|
||||
.size(48.dp),
|
||||
shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(12.dp)),
|
||||
colors = ClickableSurfaceDefaults.colors(
|
||||
containerColor = if (isSelected) accentColor.copy(alpha = 0.15f) else Color.Transparent,
|
||||
focusedContainerColor = accentColor.copy(alpha = 0.2f)
|
||||
),
|
||||
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.1f)
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(
|
||||
imageVector = item.icon,
|
||||
contentDescription = item.label,
|
||||
tint = if (isSelected) accentColor else Color.White.copy(alpha = 0.6f),
|
||||
modifier = Modifier.size(22.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,98 +1,98 @@
|
|||
package com.streamflow.tv.ui.navigation
|
||||
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.navigation.NavType
|
||||
import androidx.navigation.compose.NavHost
|
||||
import androidx.navigation.compose.composable
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import androidx.navigation.navArgument
|
||||
import com.streamflow.tv.ui.screens.*
|
||||
|
||||
@Composable
|
||||
fun AppNavigation(
|
||||
currentTheme: String,
|
||||
onThemeChange: (String) -> Unit
|
||||
) {
|
||||
val navController = rememberNavController()
|
||||
|
||||
NavHost(navController = navController, startDestination = "home") {
|
||||
// Home (all categories)
|
||||
composable("home") {
|
||||
HomeScreen(
|
||||
onMovieClick = { slug -> navController.navigate("detail/$slug") }
|
||||
)
|
||||
}
|
||||
|
||||
// Home filtered by category
|
||||
composable(
|
||||
"home/{category}",
|
||||
arguments = listOf(navArgument("category") { type = NavType.StringType })
|
||||
) { backStackEntry ->
|
||||
val category = backStackEntry.arguments?.getString("category")
|
||||
HomeScreen(
|
||||
onMovieClick = { slug -> navController.navigate("detail/$slug") },
|
||||
category = category
|
||||
)
|
||||
}
|
||||
|
||||
// Movie Detail
|
||||
composable(
|
||||
"detail/{slug}",
|
||||
arguments = listOf(navArgument("slug") { type = NavType.StringType })
|
||||
) { backStackEntry ->
|
||||
val slug = backStackEntry.arguments?.getString("slug") ?: return@composable
|
||||
DetailScreen(
|
||||
slug = slug,
|
||||
onPlayClick = { s, ep -> navController.navigate("player/$s/$ep") },
|
||||
onBack = { navController.popBackStack() }
|
||||
)
|
||||
}
|
||||
|
||||
// Video Player
|
||||
composable(
|
||||
"player/{slug}/{episode}",
|
||||
arguments = listOf(
|
||||
navArgument("slug") { type = NavType.StringType },
|
||||
navArgument("episode") { type = NavType.IntType; defaultValue = 1 }
|
||||
)
|
||||
) { backStackEntry ->
|
||||
val slug = backStackEntry.arguments?.getString("slug") ?: return@composable
|
||||
val episode = backStackEntry.arguments?.getInt("episode") ?: 1
|
||||
PlayerScreen(slug = slug, episode = episode)
|
||||
}
|
||||
|
||||
// Search
|
||||
composable("search") {
|
||||
SearchScreen(
|
||||
onMovieClick = { slug -> navController.navigate("detail/$slug") }
|
||||
)
|
||||
}
|
||||
|
||||
// My List
|
||||
composable("mylist") {
|
||||
MyListScreen(
|
||||
onMovieClick = { slug -> navController.navigate("detail/$slug") }
|
||||
)
|
||||
}
|
||||
|
||||
// Settings
|
||||
composable("settings") {
|
||||
SettingsScreen(
|
||||
currentTheme = currentTheme,
|
||||
onThemeChange = onThemeChange
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Expose navController for SideNavRail
|
||||
LaunchedEffect(navController) {
|
||||
// Store nav controller reference for side nav
|
||||
}
|
||||
|
||||
// Provide nav controller via local
|
||||
CompositionLocalProvider(LocalNavController provides navController) {}
|
||||
}
|
||||
|
||||
val LocalNavController = staticCompositionLocalOf<androidx.navigation.NavHostController> {
|
||||
error("NavController not provided")
|
||||
}
|
||||
package com.streamflow.tv.ui.navigation
|
||||
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.navigation.NavType
|
||||
import androidx.navigation.compose.NavHost
|
||||
import androidx.navigation.compose.composable
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import androidx.navigation.navArgument
|
||||
import com.streamflow.tv.ui.screens.*
|
||||
|
||||
@Composable
|
||||
fun AppNavigation(
|
||||
currentTheme: String,
|
||||
onThemeChange: (String) -> Unit
|
||||
) {
|
||||
val navController = rememberNavController()
|
||||
|
||||
NavHost(navController = navController, startDestination = "home") {
|
||||
// Home (all categories)
|
||||
composable("home") {
|
||||
HomeScreen(
|
||||
onMovieClick = { slug -> navController.navigate("detail/$slug") }
|
||||
)
|
||||
}
|
||||
|
||||
// Home filtered by category
|
||||
composable(
|
||||
"home/{category}",
|
||||
arguments = listOf(navArgument("category") { type = NavType.StringType })
|
||||
) { backStackEntry ->
|
||||
val category = backStackEntry.arguments?.getString("category")
|
||||
HomeScreen(
|
||||
onMovieClick = { slug -> navController.navigate("detail/$slug") },
|
||||
category = category
|
||||
)
|
||||
}
|
||||
|
||||
// Movie Detail
|
||||
composable(
|
||||
"detail/{slug}",
|
||||
arguments = listOf(navArgument("slug") { type = NavType.StringType })
|
||||
) { backStackEntry ->
|
||||
val slug = backStackEntry.arguments?.getString("slug") ?: return@composable
|
||||
DetailScreen(
|
||||
slug = slug,
|
||||
onPlayClick = { s, ep -> navController.navigate("player/$s/$ep") },
|
||||
onBack = { navController.popBackStack() }
|
||||
)
|
||||
}
|
||||
|
||||
// Video Player
|
||||
composable(
|
||||
"player/{slug}/{episode}",
|
||||
arguments = listOf(
|
||||
navArgument("slug") { type = NavType.StringType },
|
||||
navArgument("episode") { type = NavType.IntType; defaultValue = 1 }
|
||||
)
|
||||
) { backStackEntry ->
|
||||
val slug = backStackEntry.arguments?.getString("slug") ?: return@composable
|
||||
val episode = backStackEntry.arguments?.getInt("episode") ?: 1
|
||||
PlayerScreen(slug = slug, episode = episode)
|
||||
}
|
||||
|
||||
// Search
|
||||
composable("search") {
|
||||
SearchScreen(
|
||||
onMovieClick = { slug -> navController.navigate("detail/$slug") }
|
||||
)
|
||||
}
|
||||
|
||||
// My List
|
||||
composable("mylist") {
|
||||
MyListScreen(
|
||||
onMovieClick = { slug -> navController.navigate("detail/$slug") }
|
||||
)
|
||||
}
|
||||
|
||||
// Settings
|
||||
composable("settings") {
|
||||
SettingsScreen(
|
||||
currentTheme = currentTheme,
|
||||
onThemeChange = onThemeChange
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Expose navController for SideNavRail
|
||||
LaunchedEffect(navController) {
|
||||
// Store nav controller reference for side nav
|
||||
}
|
||||
|
||||
// Provide nav controller via local
|
||||
CompositionLocalProvider(LocalNavController provides navController) {}
|
||||
}
|
||||
|
||||
val LocalNavController = staticCompositionLocalOf<androidx.navigation.NavHostController> {
|
||||
error("NavController not provided")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,200 +1,200 @@
|
|||
package com.streamflow.tv.ui.screens
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import android.util.Log
|
||||
import androidx.tv.material3.ClickableSurfaceDefaults
|
||||
import androidx.tv.material3.ExperimentalTvMaterial3Api
|
||||
import androidx.tv.material3.MaterialTheme
|
||||
import androidx.tv.material3.Surface
|
||||
import androidx.tv.material3.Text
|
||||
import coil.compose.AsyncImage
|
||||
import com.streamflow.tv.data.api.ApiClient
|
||||
import com.streamflow.tv.data.model.Episode
|
||||
import com.streamflow.tv.ui.components.EpisodeSelector
|
||||
import com.streamflow.tv.ui.theme.StreamFlowTheme
|
||||
import com.streamflow.tv.viewmodel.DetailViewModel
|
||||
|
||||
@OptIn(ExperimentalTvMaterial3Api::class)
|
||||
@Composable
|
||||
fun DetailScreen(
|
||||
slug: String,
|
||||
onPlayClick: (String, Int) -> Unit,
|
||||
onBack: () -> Unit,
|
||||
viewModel: DetailViewModel = viewModel()
|
||||
) {
|
||||
val uiState by viewModel.uiState.collectAsState()
|
||||
val colors = StreamFlowTheme.colors
|
||||
|
||||
LaunchedEffect(slug) {
|
||||
viewModel.loadMovie(slug)
|
||||
}
|
||||
|
||||
Log.d("DetailScreen", "Composing DetailScreen(slug=$slug, isLoading=${uiState.isLoading})")
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(colors.background),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
if (uiState.isLoading) {
|
||||
CircularLoadingIndicator()
|
||||
} else if (uiState.error != null) {
|
||||
ErrorState(message = uiState.error ?: "Unknown error", onRetry = { viewModel.loadMovie(slug) })
|
||||
} else {
|
||||
val movie = uiState.movie ?: return@Box
|
||||
Log.d("DetailScreen", "Rendering movie details: ${movie.title}")
|
||||
|
||||
// Background Image
|
||||
AsyncImage(
|
||||
model = ApiClient.imageProxyUrl(movie.backdrop ?: movie.thumbnail, 1280),
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier.fillMaxSize()
|
||||
)
|
||||
|
||||
// Gradient Overlays
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(
|
||||
Brush.horizontalGradient(
|
||||
colors = listOf(
|
||||
colors.background.copy(alpha = 0.95f),
|
||||
colors.background.copy(alpha = 0.7f),
|
||||
Color.Transparent
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.fillMaxHeight(0.3f)
|
||||
.align(Alignment.BottomCenter)
|
||||
.background(
|
||||
Brush.verticalGradient(
|
||||
colors = listOf(Color.Transparent, colors.background)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
// Content
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
|
||||
LaunchedEffect(uiState.movie) {
|
||||
if (uiState.movie != null) {
|
||||
focusRequester.requestFocus()
|
||||
}
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(horizontal = 48.dp, vertical = 32.dp),
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
Text(
|
||||
text = movie.title,
|
||||
style = StreamFlowTheme.typography.displayLarge,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
Text(
|
||||
text = movie.description,
|
||||
style = StreamFlowTheme.typography.bodyMedium,
|
||||
maxLines = 3,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.widthIn(max = 600.dp)
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(32.dp))
|
||||
|
||||
Surface(
|
||||
onClick = { onPlayClick(movie.slug, 1) },
|
||||
shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(8.dp)),
|
||||
colors = ClickableSurfaceDefaults.colors(
|
||||
containerColor = colors.primary,
|
||||
focusedContainerColor = colors.accent
|
||||
),
|
||||
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.05f),
|
||||
modifier = Modifier.focusRequester(focusRequester)
|
||||
) {
|
||||
Text(
|
||||
"▶ Play",
|
||||
style = StreamFlowTheme.typography.titleMedium.copy(color = Color.White),
|
||||
modifier = Modifier.padding(horizontal = 24.dp, vertical = 12.dp)
|
||||
)
|
||||
}
|
||||
|
||||
if (!movie.episodes.isNullOrEmpty()) {
|
||||
Spacer(Modifier.height(32.dp))
|
||||
|
||||
EpisodeSelector(
|
||||
episodes = movie.episodes,
|
||||
currentEpisode = 1,
|
||||
onEpisodeSelect = { episode -> onPlayClick(movie.slug, episode.number) },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(200.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun CircularLoadingIndicator() {
|
||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Text(
|
||||
text = "Loading...",
|
||||
style = StreamFlowTheme.typography.headlineMedium.copy(color = StreamFlowTheme.colors.primary)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalTvMaterial3Api::class)
|
||||
@Composable
|
||||
fun ErrorState(message: String, onRetry: () -> Unit) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
val colors = StreamFlowTheme.colors
|
||||
Text(
|
||||
text = message,
|
||||
style = StreamFlowTheme.typography.bodyLarge.copy(color = Color.Red),
|
||||
modifier = Modifier.padding(bottom = 16.dp)
|
||||
)
|
||||
Surface(
|
||||
onClick = onRetry,
|
||||
shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(8.dp)),
|
||||
colors = ClickableSurfaceDefaults.colors(
|
||||
containerColor = colors.surfaceVariant
|
||||
)
|
||||
) {
|
||||
Text(
|
||||
"Retry",
|
||||
modifier = Modifier.padding(horizontal = 24.dp, vertical = 12.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
package com.streamflow.tv.ui.screens
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import android.util.Log
|
||||
import androidx.tv.material3.ClickableSurfaceDefaults
|
||||
import androidx.tv.material3.ExperimentalTvMaterial3Api
|
||||
import androidx.tv.material3.MaterialTheme
|
||||
import androidx.tv.material3.Surface
|
||||
import androidx.tv.material3.Text
|
||||
import coil.compose.AsyncImage
|
||||
import com.streamflow.tv.data.api.ApiClient
|
||||
import com.streamflow.tv.data.model.Episode
|
||||
import com.streamflow.tv.ui.components.EpisodeSelector
|
||||
import com.streamflow.tv.ui.theme.StreamFlowTheme
|
||||
import com.streamflow.tv.viewmodel.DetailViewModel
|
||||
|
||||
@OptIn(ExperimentalTvMaterial3Api::class)
|
||||
@Composable
|
||||
fun DetailScreen(
|
||||
slug: String,
|
||||
onPlayClick: (String, Int) -> Unit,
|
||||
onBack: () -> Unit,
|
||||
viewModel: DetailViewModel = viewModel()
|
||||
) {
|
||||
val uiState by viewModel.uiState.collectAsState()
|
||||
val colors = StreamFlowTheme.colors
|
||||
|
||||
LaunchedEffect(slug) {
|
||||
viewModel.loadMovie(slug)
|
||||
}
|
||||
|
||||
Log.d("DetailScreen", "Composing DetailScreen(slug=$slug, isLoading=${uiState.isLoading})")
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(colors.background),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
if (uiState.isLoading) {
|
||||
CircularLoadingIndicator()
|
||||
} else if (uiState.error != null) {
|
||||
ErrorState(message = uiState.error ?: "Unknown error", onRetry = { viewModel.loadMovie(slug) })
|
||||
} else {
|
||||
val movie = uiState.movie ?: return@Box
|
||||
Log.d("DetailScreen", "Rendering movie details: ${movie.title}")
|
||||
|
||||
// Background Image
|
||||
AsyncImage(
|
||||
model = ApiClient.imageProxyUrl(movie.backdrop ?: movie.thumbnail, 1280),
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier.fillMaxSize()
|
||||
)
|
||||
|
||||
// Gradient Overlays
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(
|
||||
Brush.horizontalGradient(
|
||||
colors = listOf(
|
||||
colors.background.copy(alpha = 0.95f),
|
||||
colors.background.copy(alpha = 0.7f),
|
||||
Color.Transparent
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.fillMaxHeight(0.3f)
|
||||
.align(Alignment.BottomCenter)
|
||||
.background(
|
||||
Brush.verticalGradient(
|
||||
colors = listOf(Color.Transparent, colors.background)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
// Content
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
|
||||
LaunchedEffect(uiState.movie) {
|
||||
if (uiState.movie != null) {
|
||||
focusRequester.requestFocus()
|
||||
}
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(horizontal = 48.dp, vertical = 32.dp),
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
Text(
|
||||
text = movie.title,
|
||||
style = StreamFlowTheme.typography.displayLarge,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
Text(
|
||||
text = movie.description,
|
||||
style = StreamFlowTheme.typography.bodyMedium,
|
||||
maxLines = 3,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.widthIn(max = 600.dp)
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(32.dp))
|
||||
|
||||
Surface(
|
||||
onClick = { onPlayClick(movie.slug, 1) },
|
||||
shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(8.dp)),
|
||||
colors = ClickableSurfaceDefaults.colors(
|
||||
containerColor = colors.primary,
|
||||
focusedContainerColor = colors.accent
|
||||
),
|
||||
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.05f),
|
||||
modifier = Modifier.focusRequester(focusRequester)
|
||||
) {
|
||||
Text(
|
||||
"▶ Play",
|
||||
style = StreamFlowTheme.typography.titleMedium.copy(color = Color.White),
|
||||
modifier = Modifier.padding(horizontal = 24.dp, vertical = 12.dp)
|
||||
)
|
||||
}
|
||||
|
||||
if (!movie.episodes.isNullOrEmpty()) {
|
||||
Spacer(Modifier.height(32.dp))
|
||||
|
||||
EpisodeSelector(
|
||||
episodes = movie.episodes,
|
||||
currentEpisode = 1,
|
||||
onEpisodeSelect = { episode -> onPlayClick(movie.slug, episode.number) },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(200.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun CircularLoadingIndicator() {
|
||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Text(
|
||||
text = "Loading...",
|
||||
style = StreamFlowTheme.typography.headlineMedium.copy(color = StreamFlowTheme.colors.primary)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalTvMaterial3Api::class)
|
||||
@Composable
|
||||
fun ErrorState(message: String, onRetry: () -> Unit) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
val colors = StreamFlowTheme.colors
|
||||
Text(
|
||||
text = message,
|
||||
style = StreamFlowTheme.typography.bodyLarge.copy(color = Color.Red),
|
||||
modifier = Modifier.padding(bottom = 16.dp)
|
||||
)
|
||||
Surface(
|
||||
onClick = onRetry,
|
||||
shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(8.dp)),
|
||||
colors = ClickableSurfaceDefaults.colors(
|
||||
containerColor = colors.surfaceVariant
|
||||
)
|
||||
) {
|
||||
Text(
|
||||
"Retry",
|
||||
modifier = Modifier.padding(horizontal = 24.dp, vertical = 12.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,112 +1,112 @@
|
|||
package com.streamflow.tv.ui.screens
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import androidx.tv.foundation.lazy.list.TvLazyColumn
|
||||
import androidx.tv.foundation.lazy.list.items
|
||||
import androidx.tv.material3.ExperimentalTvMaterial3Api
|
||||
import androidx.tv.material3.Text
|
||||
import com.streamflow.tv.ui.components.HeroBanner
|
||||
import com.streamflow.tv.ui.components.MovieRow
|
||||
import com.streamflow.tv.ui.theme.StreamFlowTheme
|
||||
import com.streamflow.tv.viewmodel.HomeViewModel
|
||||
|
||||
@OptIn(ExperimentalTvMaterial3Api::class)
|
||||
@Composable
|
||||
fun HomeScreen(
|
||||
onMovieClick: (String) -> Unit,
|
||||
category: String? = null,
|
||||
userDataRepository: com.streamflow.tv.data.repository.UserDataRepository? = null,
|
||||
viewModel: HomeViewModel = viewModel()
|
||||
) {
|
||||
val uiState by viewModel.uiState.collectAsState()
|
||||
val colors = StreamFlowTheme.colors
|
||||
|
||||
LaunchedEffect(category) {
|
||||
viewModel.loadHome(category, userDataRepository)
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(colors.background)
|
||||
) {
|
||||
if (uiState.isLoading) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = "Loading...",
|
||||
style = StreamFlowTheme.typography.headlineMedium.copy(color = colors.primary)
|
||||
)
|
||||
}
|
||||
} else if (uiState.error != null) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = uiState.error ?: "Unknown error",
|
||||
style = StreamFlowTheme.typography.bodyLarge.copy(color = Color.Red)
|
||||
)
|
||||
}
|
||||
} else {
|
||||
TvLazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(bottom = 24.dp)
|
||||
) {
|
||||
// Hero Banner
|
||||
if (uiState.heroMovies.isNotEmpty()) {
|
||||
item {
|
||||
HeroBanner(
|
||||
movies = uiState.heroMovies,
|
||||
onPlayClick = { movie -> onMovieClick(movie.slug) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Continue Watching (Watch History)
|
||||
if (uiState.watchedMovies.isNotEmpty()) {
|
||||
item {
|
||||
MovieRow(
|
||||
title = "Continue Watching",
|
||||
movies = uiState.watchedMovies,
|
||||
onMovieClick = { movie -> onMovieClick(movie.slug) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Recommended for You
|
||||
if (uiState.recommendedMovies.isNotEmpty()) {
|
||||
item {
|
||||
MovieRow(
|
||||
title = "Recommended for You",
|
||||
movies = uiState.recommendedMovies,
|
||||
onMovieClick = { movie -> onMovieClick(movie.slug) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Category rows
|
||||
uiState.categoryMovies.forEach { (title, movies) ->
|
||||
if (movies.isNotEmpty()) {
|
||||
item {
|
||||
MovieRow(
|
||||
title = title,
|
||||
movies = movies,
|
||||
onMovieClick = { movie -> onMovieClick(movie.slug) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
package com.streamflow.tv.ui.screens
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import androidx.tv.foundation.lazy.list.TvLazyColumn
|
||||
import androidx.tv.foundation.lazy.list.items
|
||||
import androidx.tv.material3.ExperimentalTvMaterial3Api
|
||||
import androidx.tv.material3.Text
|
||||
import com.streamflow.tv.ui.components.HeroBanner
|
||||
import com.streamflow.tv.ui.components.MovieRow
|
||||
import com.streamflow.tv.ui.theme.StreamFlowTheme
|
||||
import com.streamflow.tv.viewmodel.HomeViewModel
|
||||
|
||||
@OptIn(ExperimentalTvMaterial3Api::class)
|
||||
@Composable
|
||||
fun HomeScreen(
|
||||
onMovieClick: (String) -> Unit,
|
||||
category: String? = null,
|
||||
userDataRepository: com.streamflow.tv.data.repository.UserDataRepository? = null,
|
||||
viewModel: HomeViewModel = viewModel()
|
||||
) {
|
||||
val uiState by viewModel.uiState.collectAsState()
|
||||
val colors = StreamFlowTheme.colors
|
||||
|
||||
LaunchedEffect(category) {
|
||||
viewModel.loadHome(category, userDataRepository)
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(colors.background)
|
||||
) {
|
||||
if (uiState.isLoading) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = "Loading...",
|
||||
style = StreamFlowTheme.typography.headlineMedium.copy(color = colors.primary)
|
||||
)
|
||||
}
|
||||
} else if (uiState.error != null) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = uiState.error ?: "Unknown error",
|
||||
style = StreamFlowTheme.typography.bodyLarge.copy(color = Color.Red)
|
||||
)
|
||||
}
|
||||
} else {
|
||||
TvLazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(bottom = 24.dp)
|
||||
) {
|
||||
// Hero Banner
|
||||
if (uiState.heroMovies.isNotEmpty()) {
|
||||
item {
|
||||
HeroBanner(
|
||||
movies = uiState.heroMovies,
|
||||
onPlayClick = { movie -> onMovieClick(movie.slug) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Continue Watching (Watch History)
|
||||
if (uiState.watchedMovies.isNotEmpty()) {
|
||||
item {
|
||||
MovieRow(
|
||||
title = "Continue Watching",
|
||||
movies = uiState.watchedMovies,
|
||||
onMovieClick = { movie -> onMovieClick(movie.slug) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Recommended for You
|
||||
if (uiState.recommendedMovies.isNotEmpty()) {
|
||||
item {
|
||||
MovieRow(
|
||||
title = "Recommended for You",
|
||||
movies = uiState.recommendedMovies,
|
||||
onMovieClick = { movie -> onMovieClick(movie.slug) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Category rows
|
||||
uiState.categoryMovies.forEach { (title, movies) ->
|
||||
if (movies.isNotEmpty()) {
|
||||
item {
|
||||
MovieRow(
|
||||
title = title,
|
||||
movies = movies,
|
||||
onMovieClick = { movie -> onMovieClick(movie.slug) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,104 +1,104 @@
|
|||
package com.streamflow.tv.ui.screens
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import androidx.tv.foundation.lazy.grid.TvGridCells
|
||||
import androidx.tv.foundation.lazy.grid.TvLazyVerticalGrid
|
||||
import androidx.tv.foundation.lazy.grid.items
|
||||
import androidx.tv.material3.ExperimentalTvMaterial3Api
|
||||
import androidx.tv.material3.Text
|
||||
import com.streamflow.tv.ui.components.MovieCard
|
||||
import com.streamflow.tv.ui.theme.StreamFlowTheme
|
||||
import com.streamflow.tv.viewmodel.MyListViewModel
|
||||
|
||||
@OptIn(ExperimentalTvMaterial3Api::class)
|
||||
@Composable
|
||||
fun MyListScreen(
|
||||
onMovieClick: (String) -> Unit,
|
||||
viewModel: MyListViewModel = viewModel()
|
||||
) {
|
||||
val uiState by viewModel.uiState.collectAsState()
|
||||
val colors = StreamFlowTheme.colors
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(colors.background)
|
||||
.padding(horizontal = 48.dp, vertical = 32.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "My List",
|
||||
style = StreamFlowTheme.typography.displayMedium,
|
||||
modifier = Modifier.padding(bottom = 24.dp)
|
||||
)
|
||||
|
||||
if (uiState.watchHistory.isEmpty() && uiState.savedMovies.isEmpty()) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text("❤️", style = StreamFlowTheme.typography.displayLarge)
|
||||
Text(
|
||||
"Your list is empty.",
|
||||
style = StreamFlowTheme.typography.headlineMedium,
|
||||
modifier = Modifier.padding(top = 12.dp)
|
||||
)
|
||||
Text(
|
||||
"Start watching or add movies to your list.",
|
||||
style = StreamFlowTheme.typography.bodyLarge,
|
||||
modifier = Modifier.padding(top = 4.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Continue Watching
|
||||
if (uiState.watchHistory.isNotEmpty()) {
|
||||
Text(
|
||||
text = "Continue Watching",
|
||||
style = StreamFlowTheme.typography.headlineMedium,
|
||||
modifier = Modifier.padding(bottom = 12.dp)
|
||||
)
|
||||
|
||||
TvLazyVerticalGrid(
|
||||
columns = TvGridCells.Adaptive(180.dp),
|
||||
contentPadding = PaddingValues(4.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
modifier = Modifier.heightIn(max = 320.dp)
|
||||
) {
|
||||
items(uiState.watchHistory, key = { "h_${it.slug}" }) { movie ->
|
||||
MovieCard(movie = movie, onClick = { onMovieClick(movie.slug) })
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(24.dp))
|
||||
}
|
||||
|
||||
// Saved
|
||||
if (uiState.savedMovies.isNotEmpty()) {
|
||||
Text(
|
||||
text = "Saved Movies",
|
||||
style = StreamFlowTheme.typography.headlineMedium,
|
||||
modifier = Modifier.padding(bottom = 12.dp)
|
||||
)
|
||||
|
||||
TvLazyVerticalGrid(
|
||||
columns = TvGridCells.Adaptive(180.dp),
|
||||
contentPadding = PaddingValues(4.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
items(uiState.savedMovies, key = { "s_${it.slug}" }) { movie ->
|
||||
MovieCard(movie = movie, onClick = { onMovieClick(movie.slug) })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
package com.streamflow.tv.ui.screens
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import androidx.tv.foundation.lazy.grid.TvGridCells
|
||||
import androidx.tv.foundation.lazy.grid.TvLazyVerticalGrid
|
||||
import androidx.tv.foundation.lazy.grid.items
|
||||
import androidx.tv.material3.ExperimentalTvMaterial3Api
|
||||
import androidx.tv.material3.Text
|
||||
import com.streamflow.tv.ui.components.MovieCard
|
||||
import com.streamflow.tv.ui.theme.StreamFlowTheme
|
||||
import com.streamflow.tv.viewmodel.MyListViewModel
|
||||
|
||||
@OptIn(ExperimentalTvMaterial3Api::class)
|
||||
@Composable
|
||||
fun MyListScreen(
|
||||
onMovieClick: (String) -> Unit,
|
||||
viewModel: MyListViewModel = viewModel()
|
||||
) {
|
||||
val uiState by viewModel.uiState.collectAsState()
|
||||
val colors = StreamFlowTheme.colors
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(colors.background)
|
||||
.padding(horizontal = 48.dp, vertical = 32.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "My List",
|
||||
style = StreamFlowTheme.typography.displayMedium,
|
||||
modifier = Modifier.padding(bottom = 24.dp)
|
||||
)
|
||||
|
||||
if (uiState.watchHistory.isEmpty() && uiState.savedMovies.isEmpty()) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text("❤️", style = StreamFlowTheme.typography.displayLarge)
|
||||
Text(
|
||||
"Your list is empty.",
|
||||
style = StreamFlowTheme.typography.headlineMedium,
|
||||
modifier = Modifier.padding(top = 12.dp)
|
||||
)
|
||||
Text(
|
||||
"Start watching or add movies to your list.",
|
||||
style = StreamFlowTheme.typography.bodyLarge,
|
||||
modifier = Modifier.padding(top = 4.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Continue Watching
|
||||
if (uiState.watchHistory.isNotEmpty()) {
|
||||
Text(
|
||||
text = "Continue Watching",
|
||||
style = StreamFlowTheme.typography.headlineMedium,
|
||||
modifier = Modifier.padding(bottom = 12.dp)
|
||||
)
|
||||
|
||||
TvLazyVerticalGrid(
|
||||
columns = TvGridCells.Adaptive(180.dp),
|
||||
contentPadding = PaddingValues(4.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
modifier = Modifier.heightIn(max = 320.dp)
|
||||
) {
|
||||
items(uiState.watchHistory, key = { "h_${it.slug}" }) { movie ->
|
||||
MovieCard(movie = movie, onClick = { onMovieClick(movie.slug) })
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(24.dp))
|
||||
}
|
||||
|
||||
// Saved
|
||||
if (uiState.savedMovies.isNotEmpty()) {
|
||||
Text(
|
||||
text = "Saved Movies",
|
||||
style = StreamFlowTheme.typography.headlineMedium,
|
||||
modifier = Modifier.padding(bottom = 12.dp)
|
||||
)
|
||||
|
||||
TvLazyVerticalGrid(
|
||||
columns = TvGridCells.Adaptive(180.dp),
|
||||
contentPadding = PaddingValues(4.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
items(uiState.savedMovies, key = { "s_${it.slug}" }) { movie ->
|
||||
MovieCard(movie = movie, onClick = { onMovieClick(movie.slug) })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,249 +1,249 @@
|
|||
package com.streamflow.tv.ui.screens
|
||||
|
||||
import android.view.ViewGroup
|
||||
import android.widget.FrameLayout
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.input.key.*
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.compose.foundation.focusable
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import androidx.media3.common.MediaItem
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.exoplayer.ExoPlayer
|
||||
import androidx.media3.exoplayer.hls.HlsMediaSource
|
||||
import androidx.media3.datasource.DefaultDataSource
|
||||
import androidx.media3.ui.PlayerView
|
||||
import androidx.tv.material3.ExperimentalTvMaterial3Api
|
||||
import androidx.tv.material3.Text
|
||||
import com.streamflow.tv.ui.theme.StreamFlowTheme
|
||||
import com.streamflow.tv.viewmodel.PlayerViewModel
|
||||
|
||||
@OptIn(UnstableApi::class)
|
||||
@kotlin.OptIn(ExperimentalTvMaterial3Api::class)
|
||||
@Composable
|
||||
fun PlayerScreen(
|
||||
slug: String,
|
||||
episode: Int = 1,
|
||||
userDataRepository: com.streamflow.tv.data.repository.UserDataRepository? = null,
|
||||
viewModel: PlayerViewModel = viewModel()
|
||||
) {
|
||||
val uiState by viewModel.uiState.collectAsState()
|
||||
val context = LocalContext.current
|
||||
val colors = StreamFlowTheme.colors
|
||||
var playerView by remember { mutableStateOf<PlayerView?>(null) }
|
||||
|
||||
LaunchedEffect(slug, episode) {
|
||||
viewModel.loadPlayer(slug, episode)
|
||||
}
|
||||
|
||||
LaunchedEffect(uiState.movie) {
|
||||
if (uiState.movie != null && userDataRepository != null) {
|
||||
viewModel.saveToHistory(userDataRepository)
|
||||
}
|
||||
}
|
||||
|
||||
// ExoPlayer instance
|
||||
val exoPlayer = remember {
|
||||
ExoPlayer.Builder(context).build().apply {
|
||||
playWhenReady = true
|
||||
}
|
||||
}
|
||||
|
||||
// Wrap ExoPlayer to intercept next/previous UI clicks
|
||||
val forwardingPlayer = remember(exoPlayer, uiState.movie, uiState.currentEpisode) {
|
||||
object : androidx.media3.common.ForwardingPlayer(exoPlayer) {
|
||||
override fun getAvailableCommands(): androidx.media3.common.Player.Commands {
|
||||
return super.getAvailableCommands().buildUpon()
|
||||
.add(androidx.media3.common.Player.COMMAND_SEEK_TO_NEXT)
|
||||
.add(androidx.media3.common.Player.COMMAND_SEEK_TO_PREVIOUS)
|
||||
.add(androidx.media3.common.Player.COMMAND_SEEK_TO_NEXT_MEDIA_ITEM)
|
||||
.add(androidx.media3.common.Player.COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM)
|
||||
.build()
|
||||
}
|
||||
|
||||
override fun hasNextMediaItem(): Boolean {
|
||||
val eps = uiState.movie?.episodes ?: return false
|
||||
if (eps.isEmpty()) return false
|
||||
val maxEp = eps.maxOf { it.number }
|
||||
return uiState.currentEpisode < maxEp
|
||||
}
|
||||
override fun hasPreviousMediaItem(): Boolean {
|
||||
val eps = uiState.movie?.episodes ?: return false
|
||||
if (eps.isEmpty()) return false
|
||||
val minEp = eps.minOf { it.number }
|
||||
return uiState.currentEpisode > minEp
|
||||
}
|
||||
override fun seekToNextMediaItem() {
|
||||
if (hasNextMediaItem()) {
|
||||
viewModel.changeEpisode(uiState.currentEpisode + 1)
|
||||
}
|
||||
}
|
||||
override fun seekToNext() {
|
||||
seekToNextMediaItem()
|
||||
}
|
||||
override fun seekToPreviousMediaItem() {
|
||||
if (hasPreviousMediaItem()) {
|
||||
viewModel.changeEpisode(uiState.currentEpisode - 1)
|
||||
}
|
||||
}
|
||||
override fun seekToPrevious() {
|
||||
seekToPreviousMediaItem()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update player when source changes
|
||||
LaunchedEffect(uiState.source) {
|
||||
uiState.source?.let { source ->
|
||||
val dataSourceFactory = DefaultDataSource.Factory(context)
|
||||
val mediaItem = MediaItem.fromUri(source.streamUrl)
|
||||
|
||||
android.util.Log.e("StreamFlowPlayer", "Setting media source: ${source.streamUrl}")
|
||||
|
||||
exoPlayer.addListener(object : androidx.media3.common.Player.Listener {
|
||||
override fun onPlayerError(error: androidx.media3.common.PlaybackException) {
|
||||
android.util.Log.e("StreamFlowPlayer", "Player Error: ${error.message}", error)
|
||||
}
|
||||
override fun onPlaybackStateChanged(playbackState: Int) {
|
||||
android.util.Log.e("StreamFlowPlayer", "Playback State: $playbackState")
|
||||
}
|
||||
})
|
||||
|
||||
if (source.streamUrl.contains(".m3u8")) {
|
||||
val hlsSource = HlsMediaSource.Factory(dataSourceFactory)
|
||||
.createMediaSource(mediaItem)
|
||||
exoPlayer.setMediaSource(hlsSource)
|
||||
} else {
|
||||
exoPlayer.setMediaItem(mediaItem)
|
||||
}
|
||||
exoPlayer.prepare()
|
||||
}
|
||||
}
|
||||
|
||||
// Cleanup
|
||||
DisposableEffect(Unit) {
|
||||
onDispose {
|
||||
exoPlayer.release()
|
||||
}
|
||||
}
|
||||
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(Color.Black)
|
||||
.focusRequester(focusRequester)
|
||||
.focusable()
|
||||
.onPreviewKeyEvent { keyEvent ->
|
||||
if (keyEvent.type == KeyEventType.KeyDown) {
|
||||
when (keyEvent.nativeKeyEvent.keyCode) {
|
||||
android.view.KeyEvent.KEYCODE_DPAD_CENTER,
|
||||
android.view.KeyEvent.KEYCODE_ENTER -> {
|
||||
// Toggle controls visibility
|
||||
if (playerView?.isControllerFullyVisible == true) {
|
||||
playerView?.hideController()
|
||||
} else {
|
||||
playerView?.showController()
|
||||
}
|
||||
true
|
||||
}
|
||||
android.view.KeyEvent.KEYCODE_DPAD_LEFT -> {
|
||||
// Seek backward 10s
|
||||
playerView?.showController()
|
||||
exoPlayer.seekTo(maxOf(0, exoPlayer.currentPosition - 10000))
|
||||
true
|
||||
}
|
||||
android.view.KeyEvent.KEYCODE_DPAD_RIGHT -> {
|
||||
// Seek forward 10s
|
||||
playerView?.showController()
|
||||
exoPlayer.seekTo(minOf(exoPlayer.duration, exoPlayer.currentPosition + 10000))
|
||||
true
|
||||
}
|
||||
android.view.KeyEvent.KEYCODE_DPAD_UP,
|
||||
android.view.KeyEvent.KEYCODE_DPAD_DOWN -> {
|
||||
playerView?.showController()
|
||||
true
|
||||
}
|
||||
android.view.KeyEvent.KEYCODE_MEDIA_NEXT -> {
|
||||
if (forwardingPlayer.hasNextMediaItem()) {
|
||||
forwardingPlayer.seekToNextMediaItem()
|
||||
}
|
||||
true
|
||||
}
|
||||
android.view.KeyEvent.KEYCODE_MEDIA_PREVIOUS -> {
|
||||
if (forwardingPlayer.hasPreviousMediaItem()) {
|
||||
forwardingPlayer.seekToPreviousMediaItem()
|
||||
}
|
||||
true
|
||||
}
|
||||
else -> false
|
||||
}
|
||||
} else false
|
||||
}
|
||||
) {
|
||||
LaunchedEffect(Unit) {
|
||||
focusRequester.requestFocus()
|
||||
}
|
||||
|
||||
if (uiState.isLoading || uiState.source == null) {
|
||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text(
|
||||
"Loading stream...",
|
||||
style = StreamFlowTheme.typography.headlineMedium.copy(color = colors.primary)
|
||||
)
|
||||
uiState.movie?.let { movie ->
|
||||
Text(
|
||||
movie.title,
|
||||
style = StreamFlowTheme.typography.bodyLarge,
|
||||
modifier = Modifier.padding(top = 8.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// ExoPlayer View
|
||||
android.util.Log.e("StreamFlowPlayer", "Drawing AndroidView for Player")
|
||||
AndroidView(
|
||||
factory = { ctx ->
|
||||
android.util.Log.e("StreamFlowPlayer", "Creating PlayerView factory")
|
||||
PlayerView(ctx).apply {
|
||||
player = forwardingPlayer
|
||||
useController = true
|
||||
setShowNextButton(true)
|
||||
setShowPreviousButton(true)
|
||||
controllerAutoShow = true
|
||||
keepScreenOn = true // Prevent screen sleep during playback
|
||||
layoutParams = FrameLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.MATCH_PARENT
|
||||
)
|
||||
playerView = this
|
||||
}
|
||||
},
|
||||
modifier = Modifier.fillMaxSize()
|
||||
)
|
||||
}
|
||||
|
||||
// Error overlay
|
||||
uiState.error?.let { error ->
|
||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Text(
|
||||
error,
|
||||
style = StreamFlowTheme.typography.bodyLarge.copy(color = Color.Red)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
package com.streamflow.tv.ui.screens
|
||||
|
||||
import android.view.ViewGroup
|
||||
import android.widget.FrameLayout
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.input.key.*
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.compose.foundation.focusable
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import androidx.media3.common.MediaItem
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.exoplayer.ExoPlayer
|
||||
import androidx.media3.exoplayer.hls.HlsMediaSource
|
||||
import androidx.media3.datasource.DefaultDataSource
|
||||
import androidx.media3.ui.PlayerView
|
||||
import androidx.tv.material3.ExperimentalTvMaterial3Api
|
||||
import androidx.tv.material3.Text
|
||||
import com.streamflow.tv.ui.theme.StreamFlowTheme
|
||||
import com.streamflow.tv.viewmodel.PlayerViewModel
|
||||
|
||||
@OptIn(UnstableApi::class)
|
||||
@kotlin.OptIn(ExperimentalTvMaterial3Api::class)
|
||||
@Composable
|
||||
fun PlayerScreen(
|
||||
slug: String,
|
||||
episode: Int = 1,
|
||||
userDataRepository: com.streamflow.tv.data.repository.UserDataRepository? = null,
|
||||
viewModel: PlayerViewModel = viewModel()
|
||||
) {
|
||||
val uiState by viewModel.uiState.collectAsState()
|
||||
val context = LocalContext.current
|
||||
val colors = StreamFlowTheme.colors
|
||||
var playerView by remember { mutableStateOf<PlayerView?>(null) }
|
||||
|
||||
LaunchedEffect(slug, episode) {
|
||||
viewModel.loadPlayer(slug, episode)
|
||||
}
|
||||
|
||||
LaunchedEffect(uiState.movie) {
|
||||
if (uiState.movie != null && userDataRepository != null) {
|
||||
viewModel.saveToHistory(userDataRepository)
|
||||
}
|
||||
}
|
||||
|
||||
// ExoPlayer instance
|
||||
val exoPlayer = remember {
|
||||
ExoPlayer.Builder(context).build().apply {
|
||||
playWhenReady = true
|
||||
}
|
||||
}
|
||||
|
||||
// Wrap ExoPlayer to intercept next/previous UI clicks
|
||||
val forwardingPlayer = remember(exoPlayer, uiState.movie, uiState.currentEpisode) {
|
||||
object : androidx.media3.common.ForwardingPlayer(exoPlayer) {
|
||||
override fun getAvailableCommands(): androidx.media3.common.Player.Commands {
|
||||
return super.getAvailableCommands().buildUpon()
|
||||
.add(androidx.media3.common.Player.COMMAND_SEEK_TO_NEXT)
|
||||
.add(androidx.media3.common.Player.COMMAND_SEEK_TO_PREVIOUS)
|
||||
.add(androidx.media3.common.Player.COMMAND_SEEK_TO_NEXT_MEDIA_ITEM)
|
||||
.add(androidx.media3.common.Player.COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM)
|
||||
.build()
|
||||
}
|
||||
|
||||
override fun hasNextMediaItem(): Boolean {
|
||||
val eps = uiState.movie?.episodes ?: return false
|
||||
if (eps.isEmpty()) return false
|
||||
val maxEp = eps.maxOf { it.number }
|
||||
return uiState.currentEpisode < maxEp
|
||||
}
|
||||
override fun hasPreviousMediaItem(): Boolean {
|
||||
val eps = uiState.movie?.episodes ?: return false
|
||||
if (eps.isEmpty()) return false
|
||||
val minEp = eps.minOf { it.number }
|
||||
return uiState.currentEpisode > minEp
|
||||
}
|
||||
override fun seekToNextMediaItem() {
|
||||
if (hasNextMediaItem()) {
|
||||
viewModel.changeEpisode(uiState.currentEpisode + 1)
|
||||
}
|
||||
}
|
||||
override fun seekToNext() {
|
||||
seekToNextMediaItem()
|
||||
}
|
||||
override fun seekToPreviousMediaItem() {
|
||||
if (hasPreviousMediaItem()) {
|
||||
viewModel.changeEpisode(uiState.currentEpisode - 1)
|
||||
}
|
||||
}
|
||||
override fun seekToPrevious() {
|
||||
seekToPreviousMediaItem()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update player when source changes
|
||||
LaunchedEffect(uiState.source) {
|
||||
uiState.source?.let { source ->
|
||||
val dataSourceFactory = DefaultDataSource.Factory(context)
|
||||
val mediaItem = MediaItem.fromUri(source.streamUrl)
|
||||
|
||||
android.util.Log.e("StreamFlowPlayer", "Setting media source: ${source.streamUrl}")
|
||||
|
||||
exoPlayer.addListener(object : androidx.media3.common.Player.Listener {
|
||||
override fun onPlayerError(error: androidx.media3.common.PlaybackException) {
|
||||
android.util.Log.e("StreamFlowPlayer", "Player Error: ${error.message}", error)
|
||||
}
|
||||
override fun onPlaybackStateChanged(playbackState: Int) {
|
||||
android.util.Log.e("StreamFlowPlayer", "Playback State: $playbackState")
|
||||
}
|
||||
})
|
||||
|
||||
if (source.streamUrl.contains(".m3u8")) {
|
||||
val hlsSource = HlsMediaSource.Factory(dataSourceFactory)
|
||||
.createMediaSource(mediaItem)
|
||||
exoPlayer.setMediaSource(hlsSource)
|
||||
} else {
|
||||
exoPlayer.setMediaItem(mediaItem)
|
||||
}
|
||||
exoPlayer.prepare()
|
||||
}
|
||||
}
|
||||
|
||||
// Cleanup
|
||||
DisposableEffect(Unit) {
|
||||
onDispose {
|
||||
exoPlayer.release()
|
||||
}
|
||||
}
|
||||
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(Color.Black)
|
||||
.focusRequester(focusRequester)
|
||||
.focusable()
|
||||
.onPreviewKeyEvent { keyEvent ->
|
||||
if (keyEvent.type == KeyEventType.KeyDown) {
|
||||
when (keyEvent.nativeKeyEvent.keyCode) {
|
||||
android.view.KeyEvent.KEYCODE_DPAD_CENTER,
|
||||
android.view.KeyEvent.KEYCODE_ENTER -> {
|
||||
// Toggle controls visibility
|
||||
if (playerView?.isControllerFullyVisible == true) {
|
||||
playerView?.hideController()
|
||||
} else {
|
||||
playerView?.showController()
|
||||
}
|
||||
true
|
||||
}
|
||||
android.view.KeyEvent.KEYCODE_DPAD_LEFT -> {
|
||||
// Seek backward 10s
|
||||
playerView?.showController()
|
||||
exoPlayer.seekTo(maxOf(0, exoPlayer.currentPosition - 10000))
|
||||
true
|
||||
}
|
||||
android.view.KeyEvent.KEYCODE_DPAD_RIGHT -> {
|
||||
// Seek forward 10s
|
||||
playerView?.showController()
|
||||
exoPlayer.seekTo(minOf(exoPlayer.duration, exoPlayer.currentPosition + 10000))
|
||||
true
|
||||
}
|
||||
android.view.KeyEvent.KEYCODE_DPAD_UP,
|
||||
android.view.KeyEvent.KEYCODE_DPAD_DOWN -> {
|
||||
playerView?.showController()
|
||||
true
|
||||
}
|
||||
android.view.KeyEvent.KEYCODE_MEDIA_NEXT -> {
|
||||
if (forwardingPlayer.hasNextMediaItem()) {
|
||||
forwardingPlayer.seekToNextMediaItem()
|
||||
}
|
||||
true
|
||||
}
|
||||
android.view.KeyEvent.KEYCODE_MEDIA_PREVIOUS -> {
|
||||
if (forwardingPlayer.hasPreviousMediaItem()) {
|
||||
forwardingPlayer.seekToPreviousMediaItem()
|
||||
}
|
||||
true
|
||||
}
|
||||
else -> false
|
||||
}
|
||||
} else false
|
||||
}
|
||||
) {
|
||||
LaunchedEffect(Unit) {
|
||||
focusRequester.requestFocus()
|
||||
}
|
||||
|
||||
if (uiState.isLoading || uiState.source == null) {
|
||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text(
|
||||
"Loading stream...",
|
||||
style = StreamFlowTheme.typography.headlineMedium.copy(color = colors.primary)
|
||||
)
|
||||
uiState.movie?.let { movie ->
|
||||
Text(
|
||||
movie.title,
|
||||
style = StreamFlowTheme.typography.bodyLarge,
|
||||
modifier = Modifier.padding(top = 8.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// ExoPlayer View
|
||||
android.util.Log.e("StreamFlowPlayer", "Drawing AndroidView for Player")
|
||||
AndroidView(
|
||||
factory = { ctx ->
|
||||
android.util.Log.e("StreamFlowPlayer", "Creating PlayerView factory")
|
||||
PlayerView(ctx).apply {
|
||||
player = forwardingPlayer
|
||||
useController = true
|
||||
setShowNextButton(true)
|
||||
setShowPreviousButton(true)
|
||||
controllerAutoShow = true
|
||||
keepScreenOn = true // Prevent screen sleep during playback
|
||||
layoutParams = FrameLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.MATCH_PARENT
|
||||
)
|
||||
playerView = this
|
||||
}
|
||||
},
|
||||
modifier = Modifier.fillMaxSize()
|
||||
)
|
||||
}
|
||||
|
||||
// Error overlay
|
||||
uiState.error?.let { error ->
|
||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Text(
|
||||
error,
|
||||
style = StreamFlowTheme.typography.bodyLarge.copy(color = Color.Red)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,124 +1,124 @@
|
|||
package com.streamflow.tv.ui.screens
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.BasicTextField
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.text.input.TextFieldValue
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import androidx.tv.foundation.lazy.grid.TvGridCells
|
||||
import androidx.tv.foundation.lazy.grid.TvLazyVerticalGrid
|
||||
import androidx.tv.foundation.lazy.grid.items
|
||||
import androidx.tv.material3.*
|
||||
import com.streamflow.tv.ui.components.MovieCard
|
||||
import com.streamflow.tv.ui.theme.StreamFlowTheme
|
||||
import com.streamflow.tv.viewmodel.SearchViewModel
|
||||
|
||||
@OptIn(ExperimentalTvMaterial3Api::class)
|
||||
@Composable
|
||||
fun SearchScreen(
|
||||
onMovieClick: (String) -> Unit,
|
||||
viewModel: SearchViewModel = viewModel()
|
||||
) {
|
||||
val uiState by viewModel.uiState.collectAsState()
|
||||
val colors = StreamFlowTheme.colors
|
||||
var textValue by remember { mutableStateOf(TextFieldValue("")) }
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(colors.background)
|
||||
.padding(horizontal = 48.dp, vertical = 32.dp)
|
||||
) {
|
||||
// Search bar
|
||||
Text(
|
||||
text = "Search",
|
||||
style = StreamFlowTheme.typography.displayMedium,
|
||||
modifier = Modifier.padding(bottom = 16.dp)
|
||||
)
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(colors.surfaceVariant, RoundedCornerShape(12.dp))
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp)
|
||||
) {
|
||||
Text("🔍 ", style = StreamFlowTheme.typography.titleMedium)
|
||||
BasicTextField(
|
||||
value = textValue,
|
||||
onValueChange = {
|
||||
textValue = it
|
||||
if (it.text.length >= 2) {
|
||||
viewModel.search(it.text)
|
||||
}
|
||||
},
|
||||
textStyle = StreamFlowTheme.typography.titleMedium,
|
||||
cursorBrush = SolidColor(colors.primary),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
decorationBox = { innerTextField ->
|
||||
Box {
|
||||
if (textValue.text.isEmpty()) {
|
||||
Text(
|
||||
"Type to search...",
|
||||
style = StreamFlowTheme.typography.titleMedium.copy(
|
||||
color = Color.White.copy(alpha = 0.3f)
|
||||
)
|
||||
)
|
||||
}
|
||||
innerTextField()
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(24.dp))
|
||||
|
||||
// Results
|
||||
when {
|
||||
uiState.isLoading -> {
|
||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Text("Searching...", style = StreamFlowTheme.typography.bodyLarge.copy(color = colors.primary))
|
||||
}
|
||||
}
|
||||
uiState.results.isNotEmpty() -> {
|
||||
TvLazyVerticalGrid(
|
||||
columns = TvGridCells.Adaptive(180.dp),
|
||||
contentPadding = PaddingValues(4.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
items(uiState.results, key = { it.slug }) { movie ->
|
||||
MovieCard(
|
||||
movie = movie,
|
||||
onClick = { onMovieClick(movie.slug) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
uiState.hasSearched -> {
|
||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Text("No results found", style = StreamFlowTheme.typography.bodyLarge)
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text("🎬", style = StreamFlowTheme.typography.displayLarge)
|
||||
Text(
|
||||
"Search for movies and shows",
|
||||
style = StreamFlowTheme.typography.bodyLarge,
|
||||
modifier = Modifier.padding(top = 12.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
package com.streamflow.tv.ui.screens
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.BasicTextField
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.text.input.TextFieldValue
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import androidx.tv.foundation.lazy.grid.TvGridCells
|
||||
import androidx.tv.foundation.lazy.grid.TvLazyVerticalGrid
|
||||
import androidx.tv.foundation.lazy.grid.items
|
||||
import androidx.tv.material3.*
|
||||
import com.streamflow.tv.ui.components.MovieCard
|
||||
import com.streamflow.tv.ui.theme.StreamFlowTheme
|
||||
import com.streamflow.tv.viewmodel.SearchViewModel
|
||||
|
||||
@OptIn(ExperimentalTvMaterial3Api::class)
|
||||
@Composable
|
||||
fun SearchScreen(
|
||||
onMovieClick: (String) -> Unit,
|
||||
viewModel: SearchViewModel = viewModel()
|
||||
) {
|
||||
val uiState by viewModel.uiState.collectAsState()
|
||||
val colors = StreamFlowTheme.colors
|
||||
var textValue by remember { mutableStateOf(TextFieldValue("")) }
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(colors.background)
|
||||
.padding(horizontal = 48.dp, vertical = 32.dp)
|
||||
) {
|
||||
// Search bar
|
||||
Text(
|
||||
text = "Search",
|
||||
style = StreamFlowTheme.typography.displayMedium,
|
||||
modifier = Modifier.padding(bottom = 16.dp)
|
||||
)
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(colors.surfaceVariant, RoundedCornerShape(12.dp))
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp)
|
||||
) {
|
||||
Text("🔍 ", style = StreamFlowTheme.typography.titleMedium)
|
||||
BasicTextField(
|
||||
value = textValue,
|
||||
onValueChange = {
|
||||
textValue = it
|
||||
if (it.text.length >= 2) {
|
||||
viewModel.search(it.text)
|
||||
}
|
||||
},
|
||||
textStyle = StreamFlowTheme.typography.titleMedium,
|
||||
cursorBrush = SolidColor(colors.primary),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
decorationBox = { innerTextField ->
|
||||
Box {
|
||||
if (textValue.text.isEmpty()) {
|
||||
Text(
|
||||
"Type to search...",
|
||||
style = StreamFlowTheme.typography.titleMedium.copy(
|
||||
color = Color.White.copy(alpha = 0.3f)
|
||||
)
|
||||
)
|
||||
}
|
||||
innerTextField()
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(24.dp))
|
||||
|
||||
// Results
|
||||
when {
|
||||
uiState.isLoading -> {
|
||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Text("Searching...", style = StreamFlowTheme.typography.bodyLarge.copy(color = colors.primary))
|
||||
}
|
||||
}
|
||||
uiState.results.isNotEmpty() -> {
|
||||
TvLazyVerticalGrid(
|
||||
columns = TvGridCells.Adaptive(180.dp),
|
||||
contentPadding = PaddingValues(4.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
items(uiState.results, key = { it.slug }) { movie ->
|
||||
MovieCard(
|
||||
movie = movie,
|
||||
onClick = { onMovieClick(movie.slug) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
uiState.hasSearched -> {
|
||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Text("No results found", style = StreamFlowTheme.typography.bodyLarge)
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text("🎬", style = StreamFlowTheme.typography.displayLarge)
|
||||
Text(
|
||||
"Search for movies and shows",
|
||||
style = StreamFlowTheme.typography.bodyLarge,
|
||||
modifier = Modifier.padding(top = 12.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,171 +1,171 @@
|
|||
package com.streamflow.tv.ui.screens
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.BasicTextField
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.input.TextFieldValue
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.tv.material3.*
|
||||
import com.streamflow.tv.data.api.ApiClient
|
||||
import com.streamflow.tv.data.repository.UserDataRepository
|
||||
import com.streamflow.tv.ui.theme.StreamFlowTheme
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@OptIn(ExperimentalTvMaterial3Api::class)
|
||||
@Composable
|
||||
fun SettingsScreen(
|
||||
currentTheme: String,
|
||||
onThemeChange: (String) -> Unit
|
||||
) {
|
||||
val colors = StreamFlowTheme.colors
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
val userRepo = remember { UserDataRepository(context) }
|
||||
|
||||
var serverUrl by remember { mutableStateOf(TextFieldValue(ApiClient.baseUrl.removeSuffix("/"))) }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
val savedUrl = userRepo.serverUrl.first()
|
||||
serverUrl = TextFieldValue(savedUrl)
|
||||
}
|
||||
|
||||
val themes = listOf(
|
||||
Triple("default", "StreamFlow", Color(0xFF06B6D4)),
|
||||
Triple("netflix", "Netflix", Color(0xFFE50914)),
|
||||
Triple("apple", "Apple TV+", Color(0xFFFFFFFF))
|
||||
)
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(colors.background)
|
||||
.padding(horizontal = 48.dp, vertical = 32.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "Settings",
|
||||
style = StreamFlowTheme.typography.displayMedium,
|
||||
modifier = Modifier.padding(bottom = 32.dp)
|
||||
)
|
||||
|
||||
Text(
|
||||
text = "CHOOSE THEME",
|
||||
style = StreamFlowTheme.typography.labelSmall.copy(
|
||||
color = Color.White.copy(alpha = 0.5f)
|
||||
),
|
||||
modifier = Modifier.padding(bottom = 12.dp)
|
||||
)
|
||||
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||
themes.forEach { (id, name, color) ->
|
||||
val isSelected = currentTheme == id
|
||||
|
||||
Surface(
|
||||
onClick = { onThemeChange(id) },
|
||||
modifier = Modifier.width(200.dp),
|
||||
shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(16.dp)),
|
||||
colors = ClickableSurfaceDefaults.colors(
|
||||
containerColor = if (isSelected) Color.White.copy(alpha = 0.1f) else colors.surfaceVariant,
|
||||
focusedContainerColor = Color.White.copy(alpha = 0.15f)
|
||||
),
|
||||
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.05f)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(20.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(48.dp)
|
||||
.background(Color.Black, RoundedCornerShape(12.dp)),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = name.first().toString(),
|
||||
style = StreamFlowTheme.typography.headlineLarge.copy(color = color)
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(12.dp))
|
||||
|
||||
Text(
|
||||
text = name,
|
||||
style = StreamFlowTheme.typography.titleMedium
|
||||
)
|
||||
|
||||
if (isSelected) {
|
||||
Text(
|
||||
text = "✓ Active",
|
||||
style = StreamFlowTheme.typography.labelSmall.copy(
|
||||
color = Color(0xFF22C55E)
|
||||
),
|
||||
modifier = Modifier.padding(top = 4.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(40.dp))
|
||||
|
||||
Text(
|
||||
text = "SERVER URL",
|
||||
style = StreamFlowTheme.typography.labelSmall.copy(
|
||||
color = Color.White.copy(alpha = 0.5f)
|
||||
),
|
||||
modifier = Modifier.padding(bottom = 12.dp)
|
||||
)
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
BasicTextField(
|
||||
value = serverUrl,
|
||||
onValueChange = { serverUrl = it },
|
||||
textStyle = StreamFlowTheme.typography.titleMedium,
|
||||
cursorBrush = SolidColor(colors.primary),
|
||||
modifier = Modifier
|
||||
.width(400.dp)
|
||||
.background(colors.surfaceVariant, RoundedCornerShape(12.dp))
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp)
|
||||
)
|
||||
|
||||
Surface(
|
||||
onClick = {
|
||||
val url = serverUrl.text.trim()
|
||||
ApiClient.baseUrl = url
|
||||
scope.launch { userRepo.setServerUrl(url) }
|
||||
},
|
||||
shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(8.dp)),
|
||||
colors = ClickableSurfaceDefaults.colors(
|
||||
containerColor = colors.primary,
|
||||
focusedContainerColor = colors.accent
|
||||
),
|
||||
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.05f)
|
||||
) {
|
||||
Text(
|
||||
"Save",
|
||||
style = StreamFlowTheme.typography.labelLarge.copy(color = Color.White),
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 10.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
Text(
|
||||
text = "Enter the IP address and port of your StreamFlow backend server.",
|
||||
style = StreamFlowTheme.typography.bodyMedium,
|
||||
modifier = Modifier.widthIn(max = 500.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
package com.streamflow.tv.ui.screens
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.BasicTextField
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.input.TextFieldValue
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.tv.material3.*
|
||||
import com.streamflow.tv.data.api.ApiClient
|
||||
import com.streamflow.tv.data.repository.UserDataRepository
|
||||
import com.streamflow.tv.ui.theme.StreamFlowTheme
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@OptIn(ExperimentalTvMaterial3Api::class)
|
||||
@Composable
|
||||
fun SettingsScreen(
|
||||
currentTheme: String,
|
||||
onThemeChange: (String) -> Unit
|
||||
) {
|
||||
val colors = StreamFlowTheme.colors
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
val userRepo = remember { UserDataRepository(context) }
|
||||
|
||||
var serverUrl by remember { mutableStateOf(TextFieldValue(ApiClient.baseUrl.removeSuffix("/"))) }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
val savedUrl = userRepo.serverUrl.first()
|
||||
serverUrl = TextFieldValue(savedUrl)
|
||||
}
|
||||
|
||||
val themes = listOf(
|
||||
Triple("default", "StreamFlow", Color(0xFF06B6D4)),
|
||||
Triple("netflix", "Netflix", Color(0xFFE50914)),
|
||||
Triple("apple", "Apple TV+", Color(0xFFFFFFFF))
|
||||
)
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(colors.background)
|
||||
.padding(horizontal = 48.dp, vertical = 32.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "Settings",
|
||||
style = StreamFlowTheme.typography.displayMedium,
|
||||
modifier = Modifier.padding(bottom = 32.dp)
|
||||
)
|
||||
|
||||
Text(
|
||||
text = "CHOOSE THEME",
|
||||
style = StreamFlowTheme.typography.labelSmall.copy(
|
||||
color = Color.White.copy(alpha = 0.5f)
|
||||
),
|
||||
modifier = Modifier.padding(bottom = 12.dp)
|
||||
)
|
||||
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||
themes.forEach { (id, name, color) ->
|
||||
val isSelected = currentTheme == id
|
||||
|
||||
Surface(
|
||||
onClick = { onThemeChange(id) },
|
||||
modifier = Modifier.width(200.dp),
|
||||
shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(16.dp)),
|
||||
colors = ClickableSurfaceDefaults.colors(
|
||||
containerColor = if (isSelected) Color.White.copy(alpha = 0.1f) else colors.surfaceVariant,
|
||||
focusedContainerColor = Color.White.copy(alpha = 0.15f)
|
||||
),
|
||||
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.05f)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(20.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(48.dp)
|
||||
.background(Color.Black, RoundedCornerShape(12.dp)),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = name.first().toString(),
|
||||
style = StreamFlowTheme.typography.headlineLarge.copy(color = color)
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(12.dp))
|
||||
|
||||
Text(
|
||||
text = name,
|
||||
style = StreamFlowTheme.typography.titleMedium
|
||||
)
|
||||
|
||||
if (isSelected) {
|
||||
Text(
|
||||
text = "✓ Active",
|
||||
style = StreamFlowTheme.typography.labelSmall.copy(
|
||||
color = Color(0xFF22C55E)
|
||||
),
|
||||
modifier = Modifier.padding(top = 4.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(40.dp))
|
||||
|
||||
Text(
|
||||
text = "SERVER URL",
|
||||
style = StreamFlowTheme.typography.labelSmall.copy(
|
||||
color = Color.White.copy(alpha = 0.5f)
|
||||
),
|
||||
modifier = Modifier.padding(bottom = 12.dp)
|
||||
)
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
BasicTextField(
|
||||
value = serverUrl,
|
||||
onValueChange = { serverUrl = it },
|
||||
textStyle = StreamFlowTheme.typography.titleMedium,
|
||||
cursorBrush = SolidColor(colors.primary),
|
||||
modifier = Modifier
|
||||
.width(400.dp)
|
||||
.background(colors.surfaceVariant, RoundedCornerShape(12.dp))
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp)
|
||||
)
|
||||
|
||||
Surface(
|
||||
onClick = {
|
||||
val url = serverUrl.text.trim()
|
||||
ApiClient.baseUrl = url
|
||||
scope.launch { userRepo.setServerUrl(url) }
|
||||
},
|
||||
shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(8.dp)),
|
||||
colors = ClickableSurfaceDefaults.colors(
|
||||
containerColor = colors.primary,
|
||||
focusedContainerColor = colors.accent
|
||||
),
|
||||
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.05f)
|
||||
) {
|
||||
Text(
|
||||
"Save",
|
||||
style = StreamFlowTheme.typography.labelLarge.copy(color = Color.White),
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 10.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
Text(
|
||||
text = "Enter the IP address and port of your StreamFlow backend server.",
|
||||
style = StreamFlowTheme.typography.bodyMedium,
|
||||
modifier = Modifier.widthIn(max = 500.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,28 +1,28 @@
|
|||
package com.streamflow.tv.ui.theme
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
||||
// StreamFlow Default Theme (Cyan/Blue)
|
||||
val StreamFlowPrimary = Color(0xFF06B6D4)
|
||||
val StreamFlowSecondary = Color(0xFF3B82F6)
|
||||
val StreamFlowAccent = Color(0xFF22D3EE)
|
||||
|
||||
// Netflix Theme (Red)
|
||||
val NetflixPrimary = Color(0xFFE50914)
|
||||
val NetflixSecondary = Color(0xFFB81D24)
|
||||
val NetflixAccent = Color(0xFFFF3D3D)
|
||||
|
||||
// Apple TV+ Theme (White/Silver)
|
||||
val ApplePrimary = Color(0xFFFFFFFF)
|
||||
val AppleSecondary = Color(0xFFA1A1AA)
|
||||
val AppleAccent = Color(0xFFD4D4D8)
|
||||
|
||||
// Common
|
||||
val DarkBackground = Color(0xFF141414)
|
||||
val DarkSurface = Color(0xFF1A1A1A)
|
||||
val DarkSurfaceVariant = Color(0xFF262626)
|
||||
val TextPrimary = Color(0xFFFFFFFF)
|
||||
val TextSecondary = Color(0xFF9CA3AF)
|
||||
val TextMuted = Color(0xFF6B7280)
|
||||
val CardBackground = Color(0xFF1E1E1E)
|
||||
val DividerColor = Color(0x1AFFFFFF)
|
||||
package com.streamflow.tv.ui.theme
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
||||
// StreamFlow Default Theme (Cyan/Blue)
|
||||
val StreamFlowPrimary = Color(0xFF06B6D4)
|
||||
val StreamFlowSecondary = Color(0xFF3B82F6)
|
||||
val StreamFlowAccent = Color(0xFF22D3EE)
|
||||
|
||||
// Netflix Theme (Red)
|
||||
val NetflixPrimary = Color(0xFFE50914)
|
||||
val NetflixSecondary = Color(0xFFB81D24)
|
||||
val NetflixAccent = Color(0xFFFF3D3D)
|
||||
|
||||
// Apple TV+ Theme (White/Silver)
|
||||
val ApplePrimary = Color(0xFFFFFFFF)
|
||||
val AppleSecondary = Color(0xFFA1A1AA)
|
||||
val AppleAccent = Color(0xFFD4D4D8)
|
||||
|
||||
// Common
|
||||
val DarkBackground = Color(0xFF141414)
|
||||
val DarkSurface = Color(0xFF1A1A1A)
|
||||
val DarkSurfaceVariant = Color(0xFF262626)
|
||||
val TextPrimary = Color(0xFFFFFFFF)
|
||||
val TextSecondary = Color(0xFF9CA3AF)
|
||||
val TextMuted = Color(0xFF6B7280)
|
||||
val CardBackground = Color(0xFF1E1E1E)
|
||||
val DividerColor = Color(0x1AFFFFFF)
|
||||
|
|
|
|||
|
|
@ -1,122 +1,122 @@
|
|||
package com.streamflow.tv.ui.theme
|
||||
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.tv.material3.*
|
||||
|
||||
data class StreamFlowColors(
|
||||
val primary: Color,
|
||||
val secondary: Color,
|
||||
val accent: Color,
|
||||
val background: Color = DarkBackground,
|
||||
val surface: Color = DarkSurface,
|
||||
val surfaceVariant: Color = DarkSurfaceVariant,
|
||||
val textPrimary: Color = TextPrimary,
|
||||
val textSecondary: Color = TextSecondary,
|
||||
val card: Color = CardBackground,
|
||||
val divider: Color = DividerColor
|
||||
)
|
||||
|
||||
val LocalStreamFlowColors = staticCompositionLocalOf {
|
||||
StreamFlowColors(
|
||||
primary = StreamFlowPrimary,
|
||||
secondary = StreamFlowSecondary,
|
||||
accent = StreamFlowAccent
|
||||
)
|
||||
}
|
||||
|
||||
object StreamFlowTheme {
|
||||
val colors: StreamFlowColors
|
||||
@Composable
|
||||
@ReadOnlyComposable
|
||||
get() = LocalStreamFlowColors.current
|
||||
|
||||
val typography = AppTypography
|
||||
}
|
||||
|
||||
fun streamFlowColors(themeName: String): StreamFlowColors {
|
||||
return when (themeName) {
|
||||
"netflix" -> StreamFlowColors(
|
||||
primary = NetflixPrimary,
|
||||
secondary = NetflixSecondary,
|
||||
accent = NetflixAccent
|
||||
)
|
||||
"apple" -> StreamFlowColors(
|
||||
primary = ApplePrimary,
|
||||
secondary = AppleSecondary,
|
||||
accent = AppleAccent
|
||||
)
|
||||
else -> StreamFlowColors(
|
||||
primary = StreamFlowPrimary,
|
||||
secondary = StreamFlowSecondary,
|
||||
accent = StreamFlowAccent
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalTvMaterial3Api::class)
|
||||
@Composable
|
||||
fun StreamFlowTvTheme(
|
||||
themeName: String = "default",
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
val colors = streamFlowColors(themeName)
|
||||
|
||||
val colorScheme = ColorScheme(
|
||||
primary = colors.primary,
|
||||
onPrimary = Color.White,
|
||||
primaryContainer = colors.primary.copy(alpha = 0.3f),
|
||||
onPrimaryContainer = Color.White,
|
||||
secondary = colors.secondary,
|
||||
onSecondary = Color.White,
|
||||
secondaryContainer = colors.secondary.copy(alpha = 0.3f),
|
||||
onSecondaryContainer = Color.White,
|
||||
tertiary = colors.accent,
|
||||
onTertiary = Color.Black,
|
||||
tertiaryContainer = colors.accent.copy(alpha = 0.3f),
|
||||
onTertiaryContainer = Color.White,
|
||||
background = colors.background,
|
||||
onBackground = Color.White,
|
||||
surface = colors.surface,
|
||||
onSurface = Color.White,
|
||||
surfaceVariant = colors.surfaceVariant,
|
||||
onSurfaceVariant = Color.White,
|
||||
error = Color.Red,
|
||||
onError = Color.White,
|
||||
errorContainer = Color.Red.copy(alpha = 0.1f),
|
||||
onErrorContainer = Color.Red,
|
||||
border = colors.divider,
|
||||
borderVariant = colors.divider,
|
||||
scrim = Color.Black,
|
||||
inverseSurface = Color.White,
|
||||
inverseOnSurface = Color.Black,
|
||||
inversePrimary = colors.primary,
|
||||
surfaceTint = colors.primary
|
||||
)
|
||||
|
||||
val tvTypography = Typography(
|
||||
displayLarge = AppTypography.displayLarge,
|
||||
displayMedium = AppTypography.displayMedium,
|
||||
displaySmall = AppTypography.displayMedium,
|
||||
headlineLarge = AppTypography.headlineLarge,
|
||||
headlineMedium = AppTypography.headlineMedium,
|
||||
headlineSmall = AppTypography.headlineMedium,
|
||||
titleLarge = AppTypography.titleLarge,
|
||||
titleMedium = AppTypography.titleMedium,
|
||||
titleSmall = AppTypography.titleMedium,
|
||||
bodyLarge = AppTypography.bodyLarge,
|
||||
bodyMedium = AppTypography.bodyMedium,
|
||||
bodySmall = AppTypography.bodyMedium,
|
||||
labelLarge = AppTypography.labelLarge,
|
||||
labelMedium = AppTypography.labelLarge,
|
||||
labelSmall = AppTypography.labelSmall
|
||||
)
|
||||
|
||||
CompositionLocalProvider(LocalStreamFlowColors provides colors) {
|
||||
MaterialTheme(
|
||||
colorScheme = colorScheme,
|
||||
typography = tvTypography,
|
||||
content = content
|
||||
)
|
||||
}
|
||||
}
|
||||
package com.streamflow.tv.ui.theme
|
||||
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.tv.material3.*
|
||||
|
||||
data class StreamFlowColors(
|
||||
val primary: Color,
|
||||
val secondary: Color,
|
||||
val accent: Color,
|
||||
val background: Color = DarkBackground,
|
||||
val surface: Color = DarkSurface,
|
||||
val surfaceVariant: Color = DarkSurfaceVariant,
|
||||
val textPrimary: Color = TextPrimary,
|
||||
val textSecondary: Color = TextSecondary,
|
||||
val card: Color = CardBackground,
|
||||
val divider: Color = DividerColor
|
||||
)
|
||||
|
||||
val LocalStreamFlowColors = staticCompositionLocalOf {
|
||||
StreamFlowColors(
|
||||
primary = StreamFlowPrimary,
|
||||
secondary = StreamFlowSecondary,
|
||||
accent = StreamFlowAccent
|
||||
)
|
||||
}
|
||||
|
||||
object StreamFlowTheme {
|
||||
val colors: StreamFlowColors
|
||||
@Composable
|
||||
@ReadOnlyComposable
|
||||
get() = LocalStreamFlowColors.current
|
||||
|
||||
val typography = AppTypography
|
||||
}
|
||||
|
||||
fun streamFlowColors(themeName: String): StreamFlowColors {
|
||||
return when (themeName) {
|
||||
"netflix" -> StreamFlowColors(
|
||||
primary = NetflixPrimary,
|
||||
secondary = NetflixSecondary,
|
||||
accent = NetflixAccent
|
||||
)
|
||||
"apple" -> StreamFlowColors(
|
||||
primary = ApplePrimary,
|
||||
secondary = AppleSecondary,
|
||||
accent = AppleAccent
|
||||
)
|
||||
else -> StreamFlowColors(
|
||||
primary = StreamFlowPrimary,
|
||||
secondary = StreamFlowSecondary,
|
||||
accent = StreamFlowAccent
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalTvMaterial3Api::class)
|
||||
@Composable
|
||||
fun StreamFlowTvTheme(
|
||||
themeName: String = "default",
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
val colors = streamFlowColors(themeName)
|
||||
|
||||
val colorScheme = ColorScheme(
|
||||
primary = colors.primary,
|
||||
onPrimary = Color.White,
|
||||
primaryContainer = colors.primary.copy(alpha = 0.3f),
|
||||
onPrimaryContainer = Color.White,
|
||||
secondary = colors.secondary,
|
||||
onSecondary = Color.White,
|
||||
secondaryContainer = colors.secondary.copy(alpha = 0.3f),
|
||||
onSecondaryContainer = Color.White,
|
||||
tertiary = colors.accent,
|
||||
onTertiary = Color.Black,
|
||||
tertiaryContainer = colors.accent.copy(alpha = 0.3f),
|
||||
onTertiaryContainer = Color.White,
|
||||
background = colors.background,
|
||||
onBackground = Color.White,
|
||||
surface = colors.surface,
|
||||
onSurface = Color.White,
|
||||
surfaceVariant = colors.surfaceVariant,
|
||||
onSurfaceVariant = Color.White,
|
||||
error = Color.Red,
|
||||
onError = Color.White,
|
||||
errorContainer = Color.Red.copy(alpha = 0.1f),
|
||||
onErrorContainer = Color.Red,
|
||||
border = colors.divider,
|
||||
borderVariant = colors.divider,
|
||||
scrim = Color.Black,
|
||||
inverseSurface = Color.White,
|
||||
inverseOnSurface = Color.Black,
|
||||
inversePrimary = colors.primary,
|
||||
surfaceTint = colors.primary
|
||||
)
|
||||
|
||||
val tvTypography = Typography(
|
||||
displayLarge = AppTypography.displayLarge,
|
||||
displayMedium = AppTypography.displayMedium,
|
||||
displaySmall = AppTypography.displayMedium,
|
||||
headlineLarge = AppTypography.headlineLarge,
|
||||
headlineMedium = AppTypography.headlineMedium,
|
||||
headlineSmall = AppTypography.headlineMedium,
|
||||
titleLarge = AppTypography.titleLarge,
|
||||
titleMedium = AppTypography.titleMedium,
|
||||
titleSmall = AppTypography.titleMedium,
|
||||
bodyLarge = AppTypography.bodyLarge,
|
||||
bodyMedium = AppTypography.bodyMedium,
|
||||
bodySmall = AppTypography.bodyMedium,
|
||||
labelLarge = AppTypography.labelLarge,
|
||||
labelMedium = AppTypography.labelLarge,
|
||||
labelSmall = AppTypography.labelSmall
|
||||
)
|
||||
|
||||
CompositionLocalProvider(LocalStreamFlowColors provides colors) {
|
||||
MaterialTheme(
|
||||
colorScheme = colorScheme,
|
||||
typography = tvTypography,
|
||||
content = content
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,59 +1,59 @@
|
|||
package com.streamflow.tv.ui.theme
|
||||
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.sp
|
||||
|
||||
object AppTypography {
|
||||
val displayLarge = TextStyle(
|
||||
fontSize = 36.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = TextPrimary,
|
||||
letterSpacing = (-0.5).sp
|
||||
)
|
||||
val displayMedium = TextStyle(
|
||||
fontSize = 28.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = TextPrimary
|
||||
)
|
||||
val headlineLarge = TextStyle(
|
||||
fontSize = 24.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = TextPrimary
|
||||
)
|
||||
val headlineMedium = TextStyle(
|
||||
fontSize = 20.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = TextPrimary
|
||||
)
|
||||
val titleLarge = TextStyle(
|
||||
fontSize = 18.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = TextPrimary
|
||||
)
|
||||
val titleMedium = TextStyle(
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = TextPrimary
|
||||
)
|
||||
val bodyLarge = TextStyle(
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.Normal,
|
||||
color = TextSecondary
|
||||
)
|
||||
val bodyMedium = TextStyle(
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Normal,
|
||||
color = TextSecondary
|
||||
)
|
||||
val labelLarge = TextStyle(
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = TextPrimary
|
||||
)
|
||||
val labelSmall = TextStyle(
|
||||
fontSize = 12.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = TextMuted
|
||||
)
|
||||
}
|
||||
package com.streamflow.tv.ui.theme
|
||||
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.sp
|
||||
|
||||
object AppTypography {
|
||||
val displayLarge = TextStyle(
|
||||
fontSize = 36.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = TextPrimary,
|
||||
letterSpacing = (-0.5).sp
|
||||
)
|
||||
val displayMedium = TextStyle(
|
||||
fontSize = 28.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = TextPrimary
|
||||
)
|
||||
val headlineLarge = TextStyle(
|
||||
fontSize = 24.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = TextPrimary
|
||||
)
|
||||
val headlineMedium = TextStyle(
|
||||
fontSize = 20.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = TextPrimary
|
||||
)
|
||||
val titleLarge = TextStyle(
|
||||
fontSize = 18.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = TextPrimary
|
||||
)
|
||||
val titleMedium = TextStyle(
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = TextPrimary
|
||||
)
|
||||
val bodyLarge = TextStyle(
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.Normal,
|
||||
color = TextSecondary
|
||||
)
|
||||
val bodyMedium = TextStyle(
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Normal,
|
||||
color = TextSecondary
|
||||
)
|
||||
val labelLarge = TextStyle(
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = TextPrimary
|
||||
)
|
||||
val labelSmall = TextStyle(
|
||||
fontSize = 12.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = TextMuted
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,45 +1,45 @@
|
|||
package com.streamflow.tv.viewmodel
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.streamflow.tv.data.model.MovieDetail
|
||||
import com.streamflow.tv.data.repository.MovieRepository
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
data class DetailUiState(
|
||||
val movie: MovieDetail? = null,
|
||||
val isLoading: Boolean = true,
|
||||
val error: String? = null,
|
||||
val isInMyList: Boolean = false
|
||||
)
|
||||
|
||||
class DetailViewModel : ViewModel() {
|
||||
|
||||
private val repository = MovieRepository()
|
||||
private val _uiState = MutableStateFlow(DetailUiState())
|
||||
val uiState: StateFlow<DetailUiState> = _uiState
|
||||
|
||||
fun loadMovie(slug: String) {
|
||||
android.util.Log.e("DetailVM", "loadMovie($slug) called")
|
||||
viewModelScope.launch {
|
||||
_uiState.value = DetailUiState(isLoading = true)
|
||||
try {
|
||||
val movie = repository.getMovieDetail(slug)
|
||||
android.util.Log.e("DetailVM", "loadMovie success: ${movie.title}, episodes: ${movie.episodes?.size}")
|
||||
_uiState.value = DetailUiState(movie = movie, isLoading = false)
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.e("DetailVM", "loadMovie failed", e)
|
||||
_uiState.value = DetailUiState(
|
||||
isLoading = false,
|
||||
error = e.message ?: "Failed to load movie details"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun toggleMyList(isInList: Boolean) {
|
||||
_uiState.value = _uiState.value.copy(isInMyList = !isInList)
|
||||
}
|
||||
}
|
||||
package com.streamflow.tv.viewmodel
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.streamflow.tv.data.model.MovieDetail
|
||||
import com.streamflow.tv.data.repository.MovieRepository
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
data class DetailUiState(
|
||||
val movie: MovieDetail? = null,
|
||||
val isLoading: Boolean = true,
|
||||
val error: String? = null,
|
||||
val isInMyList: Boolean = false
|
||||
)
|
||||
|
||||
class DetailViewModel : ViewModel() {
|
||||
|
||||
private val repository = MovieRepository()
|
||||
private val _uiState = MutableStateFlow(DetailUiState())
|
||||
val uiState: StateFlow<DetailUiState> = _uiState
|
||||
|
||||
fun loadMovie(slug: String) {
|
||||
android.util.Log.e("DetailVM", "loadMovie($slug) called")
|
||||
viewModelScope.launch {
|
||||
_uiState.value = DetailUiState(isLoading = true)
|
||||
try {
|
||||
val movie = repository.getMovieDetail(slug)
|
||||
android.util.Log.e("DetailVM", "loadMovie success: ${movie.title}, episodes: ${movie.episodes?.size}")
|
||||
_uiState.value = DetailUiState(movie = movie, isLoading = false)
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.e("DetailVM", "loadMovie failed", e)
|
||||
_uiState.value = DetailUiState(
|
||||
isLoading = false,
|
||||
error = e.message ?: "Failed to load movie details"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun toggleMyList(isInList: Boolean) {
|
||||
_uiState.value = _uiState.value.copy(isInMyList = !isInList)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,144 +1,110 @@
|
|||
package com.streamflow.tv.viewmodel
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.streamflow.tv.data.model.Movie
|
||||
import com.streamflow.tv.data.repository.MovieRepository
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.awaitAll
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
data class HomeUiState(
|
||||
val heroMovies: List<Movie> = emptyList(),
|
||||
val watchedMovies: List<Movie> = emptyList(),
|
||||
val recommendedMovies: List<Movie> = emptyList(),
|
||||
val categoryMovies: Map<String, List<Movie>> = emptyMap(),
|
||||
val isLoading: Boolean = true,
|
||||
val error: String? = null,
|
||||
val currentCategory: String? = null
|
||||
)
|
||||
|
||||
class HomeViewModel : ViewModel() {
|
||||
|
||||
private val repository = MovieRepository()
|
||||
private val _uiState = MutableStateFlow(HomeUiState())
|
||||
val uiState: StateFlow<HomeUiState> = _uiState
|
||||
|
||||
private var userDataRepository: com.streamflow.tv.data.repository.UserDataRepository? = null
|
||||
|
||||
private val categories = listOf(
|
||||
"phim-le" to "Phim Lẻ",
|
||||
"phim-bo" to "Phim Bộ",
|
||||
"hoat-hinh" to "Hoạt Hình",
|
||||
"tv-shows" to "TV Shows"
|
||||
)
|
||||
|
||||
init {
|
||||
loadHome()
|
||||
}
|
||||
|
||||
fun loadHome(
|
||||
category: String? = null,
|
||||
userRepo: com.streamflow.tv.data.repository.UserDataRepository? = null
|
||||
) {
|
||||
if (userRepo != null) {
|
||||
this.userDataRepository = userRepo
|
||||
}
|
||||
|
||||
viewModelScope.launch {
|
||||
_uiState.value = _uiState.value.copy(isLoading = true, error = null, currentCategory = category)
|
||||
try {
|
||||
// Load history if repository is available
|
||||
val history = userRepo?.watchHistory?.first() ?: emptyList()
|
||||
|
||||
if (category != null) {
|
||||
// Load single category
|
||||
val response = repository.getHomeVideos(category)
|
||||
_uiState.value = _uiState.value.copy(
|
||||
heroMovies = response.items.take(5),
|
||||
watchedMovies = history,
|
||||
recommendedMovies = response.items.filter { m -> history.none { it.slug == m.slug } }.shuffled().take(10),
|
||||
categoryMovies = mapOf(
|
||||
categories.find { it.first == category }?.second.orEmpty() to response.items
|
||||
),
|
||||
isLoading = false
|
||||
)
|
||||
} else {
|
||||
// Load all categories for home
|
||||
val allMovies = java.util.Collections.synchronizedMap(mutableMapOf<String, List<Movie>>())
|
||||
val allFlattened = java.util.Collections.synchronizedList(mutableListOf<Movie>())
|
||||
|
||||
kotlinx.coroutines.coroutineScope {
|
||||
// 1. Initial categories
|
||||
val categoryTasks = categories.map { (slug, name) ->
|
||||
async {
|
||||
try {
|
||||
val response = repository.getHomeVideos(slug)
|
||||
allMovies[name] = response.items
|
||||
allFlattened.addAll(response.items)
|
||||
response.items
|
||||
} catch (_: Exception) { emptyList<Movie>() }
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Fetch Genres & Countries metadata in parallel
|
||||
val genresDeferred = async { try { repository.getGenres().take(8) } catch (_: Exception) { emptyList() } }
|
||||
val countriesDeferred = async { try { repository.getCountries().take(5) } catch (_: Exception) { emptyList() } }
|
||||
|
||||
val genres = genresDeferred.await()
|
||||
val countries = countriesDeferred.await()
|
||||
|
||||
// 3. Fetch Genre and Country content in parallel
|
||||
val genreTasks = genres.map { genre ->
|
||||
async {
|
||||
try {
|
||||
val response = repository.getHomeVideos(genre.slug)
|
||||
if (response.items.isNotEmpty()) {
|
||||
allMovies["Genre: ${genre.name}"] = response.items
|
||||
allFlattened.addAll(response.items)
|
||||
}
|
||||
} catch (_: Exception) { }
|
||||
}
|
||||
}
|
||||
|
||||
val countryTasks = countries.map { country ->
|
||||
async {
|
||||
try {
|
||||
val response = repository.getHomeVideos(country.slug)
|
||||
if (response.items.isNotEmpty()) {
|
||||
allMovies["Country: ${country.name}"] = response.items
|
||||
allFlattened.addAll(response.items)
|
||||
}
|
||||
} catch (_: Exception) { }
|
||||
}
|
||||
}
|
||||
|
||||
// Wait for everything
|
||||
categoryTasks.awaitAll()
|
||||
genreTasks.awaitAll()
|
||||
countryTasks.awaitAll()
|
||||
}
|
||||
|
||||
val heroItems = allMovies[categories.first().second]?.take(5) ?: emptyList()
|
||||
|
||||
_uiState.value = _uiState.value.copy(
|
||||
heroMovies = heroItems,
|
||||
watchedMovies = history,
|
||||
recommendedMovies = allFlattened.filter { m -> history.none { it.slug == m.slug } }
|
||||
.distinctBy { it.slug }.shuffled().take(15),
|
||||
categoryMovies = allMovies.toMap(),
|
||||
isLoading = false
|
||||
)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
_uiState.value = _uiState.value.copy(
|
||||
isLoading = false,
|
||||
error = e.message ?: "Failed to load content"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
package com.streamflow.tv.viewmodel
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.streamflow.tv.data.model.Movie
|
||||
import com.streamflow.tv.data.repository.MovieRepository
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.awaitAll
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
data class HomeUiState(
|
||||
val heroMovies: List<Movie> = emptyList(),
|
||||
val watchedMovies: List<Movie> = emptyList(),
|
||||
val recommendedMovies: List<Movie> = emptyList(),
|
||||
val categoryMovies: Map<String, List<Movie>> = emptyMap(),
|
||||
val isLoading: Boolean = true,
|
||||
val error: String? = null,
|
||||
val currentCategory: String? = null
|
||||
)
|
||||
|
||||
class HomeViewModel : ViewModel() {
|
||||
|
||||
private val repository = MovieRepository()
|
||||
private val _uiState = MutableStateFlow(HomeUiState())
|
||||
val uiState: StateFlow<HomeUiState> = _uiState
|
||||
|
||||
private var userDataRepository: com.streamflow.tv.data.repository.UserDataRepository? = null
|
||||
|
||||
private val categories = listOf(
|
||||
"phim-le" to "Phim Lẻ",
|
||||
"phim-bo" to "Phim Bộ",
|
||||
"hoat-hinh" to "Hoạt Hình",
|
||||
"tv-shows" to "TV Shows"
|
||||
)
|
||||
|
||||
init {
|
||||
loadHome()
|
||||
}
|
||||
|
||||
fun loadHome(
|
||||
category: String? = null,
|
||||
userRepo: com.streamflow.tv.data.repository.UserDataRepository? = null
|
||||
) {
|
||||
if (userRepo != null) {
|
||||
this.userDataRepository = userRepo
|
||||
}
|
||||
|
||||
viewModelScope.launch {
|
||||
_uiState.value = _uiState.value.copy(isLoading = true, error = null, currentCategory = category)
|
||||
try {
|
||||
// Load history if repository is available
|
||||
val history = userRepo?.watchHistory?.first() ?: emptyList()
|
||||
|
||||
if (category != null) {
|
||||
// Load single category
|
||||
val response = repository.getHomeVideos(category)
|
||||
_uiState.value = _uiState.value.copy(
|
||||
heroMovies = response.items.take(5),
|
||||
watchedMovies = history,
|
||||
recommendedMovies = response.items.filter { m -> history.none { it.slug == m.slug } }.shuffled().take(10),
|
||||
categoryMovies = mapOf(
|
||||
categories.find { it.first == category }?.second.orEmpty() to response.items
|
||||
),
|
||||
isLoading = false
|
||||
)
|
||||
} else {
|
||||
// Load all categories for home
|
||||
val allMovies = java.util.Collections.synchronizedMap(mutableMapOf<String, List<Movie>>())
|
||||
val allFlattened = java.util.Collections.synchronizedList(mutableListOf<Movie>())
|
||||
|
||||
kotlinx.coroutines.coroutineScope {
|
||||
// Load main categories only (to avoid OOM on TV devices)
|
||||
val categoryTasks = categories.map { (slug, name) ->
|
||||
async {
|
||||
try {
|
||||
val response = repository.getHomeVideos(slug)
|
||||
allMovies[name] = response.items.take(15)
|
||||
allFlattened.addAll(response.items.take(15))
|
||||
response.items
|
||||
} catch (_: Exception) { emptyList<Movie>() }
|
||||
}
|
||||
}
|
||||
|
||||
// Wait for categories
|
||||
categoryTasks.awaitAll()
|
||||
}
|
||||
|
||||
val heroItems = allMovies[categories.first().second]?.take(5) ?: emptyList()
|
||||
|
||||
_uiState.value = _uiState.value.copy(
|
||||
heroMovies = heroItems,
|
||||
watchedMovies = history,
|
||||
recommendedMovies = allFlattened.filter { m -> history.none { it.slug == m.slug } }
|
||||
.distinctBy { it.slug }.shuffled().take(15),
|
||||
categoryMovies = allMovies.toMap(),
|
||||
isLoading = false
|
||||
)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
_uiState.value = _uiState.value.copy(
|
||||
isLoading = false,
|
||||
error = e.message ?: "Failed to load content"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,48 +1,48 @@
|
|||
package com.streamflow.tv.viewmodel
|
||||
|
||||
import android.app.Application
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.streamflow.tv.data.model.Movie
|
||||
import com.streamflow.tv.data.repository.UserDataRepository
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
data class MyListUiState(
|
||||
val savedMovies: List<Movie> = emptyList(),
|
||||
val watchHistory: List<Movie> = emptyList()
|
||||
)
|
||||
|
||||
class MyListViewModel(application: Application) : AndroidViewModel(application) {
|
||||
|
||||
private val userRepo = UserDataRepository(application)
|
||||
private val _uiState = MutableStateFlow(MyListUiState())
|
||||
val uiState: StateFlow<MyListUiState> = _uiState
|
||||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
userRepo.myList.collectLatest { list ->
|
||||
_uiState.value = _uiState.value.copy(savedMovies = list)
|
||||
}
|
||||
}
|
||||
viewModelScope.launch {
|
||||
userRepo.watchHistory.collectLatest { history ->
|
||||
_uiState.value = _uiState.value.copy(watchHistory = history)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun addToMyList(movie: Movie) {
|
||||
viewModelScope.launch { userRepo.addToMyList(movie) }
|
||||
}
|
||||
|
||||
fun removeFromMyList(slug: String) {
|
||||
viewModelScope.launch { userRepo.removeFromMyList(slug) }
|
||||
}
|
||||
|
||||
fun addToHistory(movie: Movie) {
|
||||
viewModelScope.launch { userRepo.addToHistory(movie) }
|
||||
}
|
||||
}
|
||||
package com.streamflow.tv.viewmodel
|
||||
|
||||
import android.app.Application
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.streamflow.tv.data.model.Movie
|
||||
import com.streamflow.tv.data.repository.UserDataRepository
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
data class MyListUiState(
|
||||
val savedMovies: List<Movie> = emptyList(),
|
||||
val watchHistory: List<Movie> = emptyList()
|
||||
)
|
||||
|
||||
class MyListViewModel(application: Application) : AndroidViewModel(application) {
|
||||
|
||||
private val userRepo = UserDataRepository(application)
|
||||
private val _uiState = MutableStateFlow(MyListUiState())
|
||||
val uiState: StateFlow<MyListUiState> = _uiState
|
||||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
userRepo.myList.collectLatest { list ->
|
||||
_uiState.value = _uiState.value.copy(savedMovies = list)
|
||||
}
|
||||
}
|
||||
viewModelScope.launch {
|
||||
userRepo.watchHistory.collectLatest { history ->
|
||||
_uiState.value = _uiState.value.copy(watchHistory = history)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun addToMyList(movie: Movie) {
|
||||
viewModelScope.launch { userRepo.addToMyList(movie) }
|
||||
}
|
||||
|
||||
fun removeFromMyList(slug: String) {
|
||||
viewModelScope.launch { userRepo.removeFromMyList(slug) }
|
||||
}
|
||||
|
||||
fun addToHistory(movie: Movie) {
|
||||
viewModelScope.launch { userRepo.addToHistory(movie) }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,100 +1,100 @@
|
|||
package com.streamflow.tv.viewmodel
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.streamflow.tv.data.model.MovieDetail
|
||||
import com.streamflow.tv.data.model.VideoSource
|
||||
import com.streamflow.tv.data.repository.MovieRepository
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
data class PlayerUiState(
|
||||
val movie: MovieDetail? = null,
|
||||
val source: VideoSource? = null,
|
||||
val currentEpisode: Int = 1,
|
||||
val isLoading: Boolean = true,
|
||||
val error: String? = null
|
||||
)
|
||||
|
||||
class PlayerViewModel : ViewModel() {
|
||||
|
||||
private val repository = MovieRepository()
|
||||
private val _uiState = MutableStateFlow(PlayerUiState())
|
||||
val uiState: StateFlow<PlayerUiState> = _uiState
|
||||
|
||||
fun loadPlayer(slug: String, episode: Int = 1) {
|
||||
viewModelScope.launch {
|
||||
_uiState.value = PlayerUiState(isLoading = true, currentEpisode = episode)
|
||||
try {
|
||||
val movie = repository.getMovieDetail(slug)
|
||||
_uiState.value = _uiState.value.copy(movie = movie)
|
||||
loadStream(movie, episode)
|
||||
} catch (e: Exception) {
|
||||
_uiState.value = _uiState.value.copy(
|
||||
isLoading = false,
|
||||
error = e.message ?: "Failed to load"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun changeEpisode(episode: Int) {
|
||||
val movie = _uiState.value.movie ?: return
|
||||
_uiState.value = _uiState.value.copy(currentEpisode = episode, isLoading = true, source = null)
|
||||
viewModelScope.launch {
|
||||
loadStream(movie, episode)
|
||||
}
|
||||
}
|
||||
|
||||
fun saveToHistory(userDataRepository: com.streamflow.tv.data.repository.UserDataRepository) {
|
||||
val movie = _uiState.value.movie ?: return
|
||||
viewModelScope.launch {
|
||||
userDataRepository.addToHistory(movie.toMovie())
|
||||
android.util.Log.e("PlayerViewModel", "Movie saved to history: ${movie.title}")
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun loadStream(movie: MovieDetail, episode: Int) {
|
||||
try {
|
||||
val ep = movie.episodes?.find { it.number == episode }
|
||||
android.util.Log.e("PlayerViewModel", "Loading stream for slug=${movie.slug} episode=$episode. Episode data: $ep")
|
||||
|
||||
if (ep != null && (ep.url.contains(".m3u8") || ep.url.contains("index.m3u8"))) {
|
||||
// Direct HLS URL
|
||||
android.util.Log.e("PlayerViewModel", "Direct HLS URL found: ${ep.url}")
|
||||
_uiState.value = _uiState.value.copy(
|
||||
source = VideoSource(
|
||||
streamUrl = ep.url,
|
||||
resolution = "HD",
|
||||
formatId = "hls"
|
||||
),
|
||||
isLoading = false
|
||||
)
|
||||
} else if (ep != null && ep.url.isNotEmpty()) {
|
||||
// Non-HLS URL — try to extract via backend
|
||||
android.util.Log.e("PlayerViewModel", "Extracting from URL: ${ep.url}")
|
||||
val source = repository.extractVideo(ep.url)
|
||||
android.util.Log.e("PlayerViewModel", "Extraction successful: $source")
|
||||
|
||||
_uiState.value = _uiState.value.copy(
|
||||
source = source,
|
||||
isLoading = false
|
||||
)
|
||||
} else {
|
||||
// No valid episode URL found
|
||||
android.util.Log.e("PlayerViewModel", "No stream URL found for episode $episode")
|
||||
_uiState.value = _uiState.value.copy(
|
||||
isLoading = false,
|
||||
error = "No stream available for episode $episode"
|
||||
)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.e("PlayerViewModel", "Error loading stream", e)
|
||||
_uiState.value = _uiState.value.copy(
|
||||
isLoading = false,
|
||||
error = e.message ?: "Failed to extract stream"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
package com.streamflow.tv.viewmodel
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.streamflow.tv.data.model.MovieDetail
|
||||
import com.streamflow.tv.data.model.VideoSource
|
||||
import com.streamflow.tv.data.repository.MovieRepository
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
data class PlayerUiState(
|
||||
val movie: MovieDetail? = null,
|
||||
val source: VideoSource? = null,
|
||||
val currentEpisode: Int = 1,
|
||||
val isLoading: Boolean = true,
|
||||
val error: String? = null
|
||||
)
|
||||
|
||||
class PlayerViewModel : ViewModel() {
|
||||
|
||||
private val repository = MovieRepository()
|
||||
private val _uiState = MutableStateFlow(PlayerUiState())
|
||||
val uiState: StateFlow<PlayerUiState> = _uiState
|
||||
|
||||
fun loadPlayer(slug: String, episode: Int = 1) {
|
||||
viewModelScope.launch {
|
||||
_uiState.value = PlayerUiState(isLoading = true, currentEpisode = episode)
|
||||
try {
|
||||
val movie = repository.getMovieDetail(slug)
|
||||
_uiState.value = _uiState.value.copy(movie = movie)
|
||||
loadStream(movie, episode)
|
||||
} catch (e: Exception) {
|
||||
_uiState.value = _uiState.value.copy(
|
||||
isLoading = false,
|
||||
error = e.message ?: "Failed to load"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun changeEpisode(episode: Int) {
|
||||
val movie = _uiState.value.movie ?: return
|
||||
_uiState.value = _uiState.value.copy(currentEpisode = episode, isLoading = true, source = null)
|
||||
viewModelScope.launch {
|
||||
loadStream(movie, episode)
|
||||
}
|
||||
}
|
||||
|
||||
fun saveToHistory(userDataRepository: com.streamflow.tv.data.repository.UserDataRepository) {
|
||||
val movie = _uiState.value.movie ?: return
|
||||
viewModelScope.launch {
|
||||
userDataRepository.addToHistory(movie.toMovie())
|
||||
android.util.Log.e("PlayerViewModel", "Movie saved to history: ${movie.title}")
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun loadStream(movie: MovieDetail, episode: Int) {
|
||||
try {
|
||||
val ep = movie.episodes?.find { it.number == episode }
|
||||
android.util.Log.e("PlayerViewModel", "Loading stream for slug=${movie.slug} episode=$episode. Episode data: $ep")
|
||||
|
||||
if (ep != null && (ep.url.contains(".m3u8") || ep.url.contains("index.m3u8"))) {
|
||||
// Direct HLS URL
|
||||
android.util.Log.e("PlayerViewModel", "Direct HLS URL found: ${ep.url}")
|
||||
_uiState.value = _uiState.value.copy(
|
||||
source = VideoSource(
|
||||
streamUrl = ep.url,
|
||||
resolution = "HD",
|
||||
formatId = "hls"
|
||||
),
|
||||
isLoading = false
|
||||
)
|
||||
} else if (ep != null && ep.url.isNotEmpty()) {
|
||||
// Non-HLS URL — try to extract via backend
|
||||
android.util.Log.e("PlayerViewModel", "Extracting from URL: ${ep.url}")
|
||||
val source = repository.extractVideo(ep.url)
|
||||
android.util.Log.e("PlayerViewModel", "Extraction successful: $source")
|
||||
|
||||
_uiState.value = _uiState.value.copy(
|
||||
source = source,
|
||||
isLoading = false
|
||||
)
|
||||
} else {
|
||||
// No valid episode URL found
|
||||
android.util.Log.e("PlayerViewModel", "No stream URL found for episode $episode")
|
||||
_uiState.value = _uiState.value.copy(
|
||||
isLoading = false,
|
||||
error = "No stream available for episode $episode"
|
||||
)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.e("PlayerViewModel", "Error loading stream", e)
|
||||
_uiState.value = _uiState.value.copy(
|
||||
isLoading = false,
|
||||
error = e.message ?: "Failed to extract stream"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,39 +1,39 @@
|
|||
package com.streamflow.tv.viewmodel
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.streamflow.tv.data.model.Movie
|
||||
import com.streamflow.tv.data.repository.MovieRepository
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
data class SearchUiState(
|
||||
val query: String = "",
|
||||
val results: List<Movie> = emptyList(),
|
||||
val isLoading: Boolean = false,
|
||||
val hasSearched: Boolean = false
|
||||
)
|
||||
|
||||
class SearchViewModel : ViewModel() {
|
||||
|
||||
private val repository = MovieRepository()
|
||||
private val _uiState = MutableStateFlow(SearchUiState())
|
||||
val uiState: StateFlow<SearchUiState> = _uiState
|
||||
|
||||
fun search(query: String) {
|
||||
if (query.isBlank()) return
|
||||
_uiState.value = SearchUiState(query = query, isLoading = true, hasSearched = true)
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
val response = repository.searchVideos(query)
|
||||
_uiState.value = _uiState.value.copy(
|
||||
results = response.items,
|
||||
isLoading = false
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
_uiState.value = _uiState.value.copy(isLoading = false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
package com.streamflow.tv.viewmodel
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.streamflow.tv.data.model.Movie
|
||||
import com.streamflow.tv.data.repository.MovieRepository
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
data class SearchUiState(
|
||||
val query: String = "",
|
||||
val results: List<Movie> = emptyList(),
|
||||
val isLoading: Boolean = false,
|
||||
val hasSearched: Boolean = false
|
||||
)
|
||||
|
||||
class SearchViewModel : ViewModel() {
|
||||
|
||||
private val repository = MovieRepository()
|
||||
private val _uiState = MutableStateFlow(SearchUiState())
|
||||
val uiState: StateFlow<SearchUiState> = _uiState
|
||||
|
||||
fun search(query: String) {
|
||||
if (query.isBlank()) return
|
||||
_uiState.value = SearchUiState(query = query, isLoading = true, hasSearched = true)
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
val response = repository.searchVideos(query)
|
||||
_uiState.value = _uiState.value.copy(
|
||||
results = response.items,
|
||||
isLoading = false
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
_uiState.value = _uiState.value.copy(isLoading = false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,33 +1,33 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="320dp"
|
||||
android:height="180dp"
|
||||
android:viewportWidth="320"
|
||||
android:viewportHeight="180">
|
||||
|
||||
<!-- Background -->
|
||||
<path
|
||||
android:pathData="M0,0h320v180H0z"
|
||||
android:fillColor="#141414"/>
|
||||
|
||||
<!-- Gradient accent bar -->
|
||||
<path
|
||||
android:pathData="M0,160h320v20H0z"
|
||||
android:fillColor="#06B6D4"/>
|
||||
|
||||
<!-- Icon circle -->
|
||||
<path
|
||||
android:pathData="M160,75m-30,0a30,30 0,1 1,60 0a30,30 0,1 1,-60 0"
|
||||
android:fillColor="#06B6D4"/>
|
||||
|
||||
<!-- Play triangle -->
|
||||
<path
|
||||
android:pathData="M152,60L172,75L152,90z"
|
||||
android:fillColor="#FFFFFF"/>
|
||||
|
||||
<!-- Text: StreamFlow -->
|
||||
<path
|
||||
android:pathData="M95,130h130"
|
||||
android:strokeColor="#FFFFFF"
|
||||
android:strokeWidth="0.5"/>
|
||||
</vector>
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="320dp"
|
||||
android:height="180dp"
|
||||
android:viewportWidth="320"
|
||||
android:viewportHeight="180">
|
||||
|
||||
<!-- Background -->
|
||||
<path
|
||||
android:pathData="M0,0h320v180H0z"
|
||||
android:fillColor="#141414"/>
|
||||
|
||||
<!-- Gradient accent bar -->
|
||||
<path
|
||||
android:pathData="M0,160h320v20H0z"
|
||||
android:fillColor="#06B6D4"/>
|
||||
|
||||
<!-- Icon circle -->
|
||||
<path
|
||||
android:pathData="M160,75m-30,0a30,30 0,1 1,60 0a30,30 0,1 1,-60 0"
|
||||
android:fillColor="#06B6D4"/>
|
||||
|
||||
<!-- Play triangle -->
|
||||
<path
|
||||
android:pathData="M152,60L172,75L152,90z"
|
||||
android:fillColor="#FFFFFF"/>
|
||||
|
||||
<!-- Text: StreamFlow -->
|
||||
<path
|
||||
android:pathData="M95,130h130"
|
||||
android:strokeColor="#FFFFFF"
|
||||
android:strokeWidth="0.5"/>
|
||||
</vector>
|
||||
|
|
|
|||
|
|
@ -1,17 +1,17 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="48dp"
|
||||
android:height="48dp"
|
||||
android:viewportWidth="48"
|
||||
android:viewportHeight="48">
|
||||
|
||||
<!-- Background rounded rect -->
|
||||
<path
|
||||
android:pathData="M8,0h32a8,8 0,0 1,8 8v32a8,8 0,0 1,-8 8H8A8,8 0,0 1,0 40V8A8,8 0,0 1,8 0z"
|
||||
android:fillColor="#06B6D4"/>
|
||||
|
||||
<!-- Play triangle -->
|
||||
<path
|
||||
android:pathData="M18,12L36,24L18,36z"
|
||||
android:fillColor="#FFFFFF"/>
|
||||
</vector>
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="48dp"
|
||||
android:height="48dp"
|
||||
android:viewportWidth="48"
|
||||
android:viewportHeight="48">
|
||||
|
||||
<!-- Background rounded rect -->
|
||||
<path
|
||||
android:pathData="M8,0h32a8,8 0,0 1,8 8v32a8,8 0,0 1,-8 8H8A8,8 0,0 1,0 40V8A8,8 0,0 1,8 0z"
|
||||
android:fillColor="#06B6D4"/>
|
||||
|
||||
<!-- Play triangle -->
|
||||
<path
|
||||
android:pathData="M18,12L36,24L18,36z"
|
||||
android:fillColor="#FFFFFF"/>
|
||||
</vector>
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
<resources>
|
||||
<string name="app_name">StreamFlow</string>
|
||||
</resources>
|
||||
<resources>
|
||||
<string name="app_name">StreamFlow</string>
|
||||
</resources>
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<style name="Theme.StreamFlowTV" parent="@style/Theme.Leanback">
|
||||
<item name="android:windowIsTranslucent">true</item>
|
||||
<item name="android:windowBackground">@android:color/transparent</item>
|
||||
<item name="android:backgroundDimEnabled">false</item>
|
||||
</style>
|
||||
</resources>
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<style name="Theme.StreamFlowTV" parent="@style/Theme.Leanback">
|
||||
<item name="android:windowIsTranslucent">true</item>
|
||||
<item name="android:windowBackground">@android:color/transparent</item>
|
||||
<item name="android:backgroundDimEnabled">false</item>
|
||||
</style>
|
||||
</resources>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
plugins {
|
||||
id("com.android.application") version "8.2.2" apply false
|
||||
id("org.jetbrains.kotlin.android") version "1.9.22" apply false
|
||||
}
|
||||
plugins {
|
||||
id("com.android.application") version "8.2.2" apply false
|
||||
id("org.jetbrains.kotlin.android") version "1.9.22" apply false
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,35 +1,35 @@
|
|||
> Task :app:checkKotlinGradlePluginConfigurationErrors
|
||||
> Task :app:preBuild UP-TO-DATE
|
||||
> Task :app:preDebugBuild UP-TO-DATE
|
||||
> Task :app:checkDebugAarMetadata UP-TO-DATE
|
||||
> Task :app:generateDebugResValues UP-TO-DATE
|
||||
> Task :app:mapDebugSourceSetPaths UP-TO-DATE
|
||||
> Task :app:generateDebugResources UP-TO-DATE
|
||||
> Task :app:mergeDebugResources UP-TO-DATE
|
||||
> Task :app:packageDebugResources UP-TO-DATE
|
||||
> Task :app:parseDebugLocalResources UP-TO-DATE
|
||||
> Task :app:createDebugCompatibleScreenManifests UP-TO-DATE
|
||||
> Task :app:extractDeepLinksDebug UP-TO-DATE
|
||||
> Task :app:processDebugMainManifest UP-TO-DATE
|
||||
> Task :app:processDebugManifest UP-TO-DATE
|
||||
> Task :app:processDebugManifestForPackage UP-TO-DATE
|
||||
> Task :app:processDebugResources UP-TO-DATE
|
||||
|
||||
> Task :app:compileDebugKotlin FAILED
|
||||
e: file:///C:/Users/Admin/Documents/Streamflow/android-tv/app/src/main/java/com/streamflow/tv/ui/screens/DetailScreen.kt:120:56 Unresolved reference: accent
|
||||
|
||||
FAILURE: Build failed with an exception.
|
||||
|
||||
* What went wrong:
|
||||
Execution failed for task ':app:compileDebugKotlin'.
|
||||
> A failure occurred while executing org.jetbrains.kotlin.compilerRunner.GradleCompilerRunnerWithWorkers$GradleKotlinCompilerWorkAction
|
||||
> Compilation error. See log for more details
|
||||
|
||||
* Try:
|
||||
> Run with --stacktrace option to get the stack trace.
|
||||
> 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.
|
||||
|
||||
BUILD FAILED in 3s
|
||||
14 actionable tasks: 2 executed, 12 up-to-date
|
||||
> Task :app:checkKotlinGradlePluginConfigurationErrors
|
||||
> Task :app:preBuild UP-TO-DATE
|
||||
> Task :app:preDebugBuild UP-TO-DATE
|
||||
> Task :app:checkDebugAarMetadata UP-TO-DATE
|
||||
> Task :app:generateDebugResValues UP-TO-DATE
|
||||
> Task :app:mapDebugSourceSetPaths UP-TO-DATE
|
||||
> Task :app:generateDebugResources UP-TO-DATE
|
||||
> Task :app:mergeDebugResources UP-TO-DATE
|
||||
> Task :app:packageDebugResources UP-TO-DATE
|
||||
> Task :app:parseDebugLocalResources UP-TO-DATE
|
||||
> Task :app:createDebugCompatibleScreenManifests UP-TO-DATE
|
||||
> Task :app:extractDeepLinksDebug UP-TO-DATE
|
||||
> Task :app:processDebugMainManifest UP-TO-DATE
|
||||
> Task :app:processDebugManifest UP-TO-DATE
|
||||
> Task :app:processDebugManifestForPackage UP-TO-DATE
|
||||
> Task :app:processDebugResources UP-TO-DATE
|
||||
|
||||
> Task :app:compileDebugKotlin FAILED
|
||||
e: file:///C:/Users/Admin/Documents/Streamflow/android-tv/app/src/main/java/com/streamflow/tv/ui/screens/DetailScreen.kt:120:56 Unresolved reference: accent
|
||||
|
||||
FAILURE: Build failed with an exception.
|
||||
|
||||
* What went wrong:
|
||||
Execution failed for task ':app:compileDebugKotlin'.
|
||||
> A failure occurred while executing org.jetbrains.kotlin.compilerRunner.GradleCompilerRunnerWithWorkers$GradleKotlinCompilerWorkAction
|
||||
> Compilation error. See log for more details
|
||||
|
||||
* Try:
|
||||
> Run with --stacktrace option to get the stack trace.
|
||||
> 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.
|
||||
|
||||
BUILD FAILED in 3s
|
||||
14 actionable tasks: 2 executed, 12 up-to-date
|
||||
|
|
|
|||
|
|
@ -1,35 +1,35 @@
|
|||
> Task :app:checkKotlinGradlePluginConfigurationErrors
|
||||
> Task :app:preBuild UP-TO-DATE
|
||||
> Task :app:preDebugBuild UP-TO-DATE
|
||||
> Task :app:checkDebugAarMetadata UP-TO-DATE
|
||||
> Task :app:generateDebugResValues UP-TO-DATE
|
||||
> Task :app:mapDebugSourceSetPaths UP-TO-DATE
|
||||
> Task :app:generateDebugResources UP-TO-DATE
|
||||
> Task :app:mergeDebugResources UP-TO-DATE
|
||||
> Task :app:packageDebugResources UP-TO-DATE
|
||||
> Task :app:parseDebugLocalResources UP-TO-DATE
|
||||
> Task :app:createDebugCompatibleScreenManifests UP-TO-DATE
|
||||
> Task :app:extractDeepLinksDebug UP-TO-DATE
|
||||
> Task :app:processDebugMainManifest UP-TO-DATE
|
||||
> Task :app:processDebugManifest UP-TO-DATE
|
||||
> Task :app:processDebugManifestForPackage UP-TO-DATE
|
||||
> Task :app:processDebugResources UP-TO-DATE
|
||||
|
||||
> Task :app:compileDebugKotlin FAILED
|
||||
e: file:///C:/Users/Admin/Documents/Streamflow/android-tv/app/src/main/java/com/streamflow/tv/ui/screens/DetailScreen.kt:114:56 Unresolved reference: accent
|
||||
|
||||
FAILURE: Build failed with an exception.
|
||||
|
||||
* What went wrong:
|
||||
Execution failed for task ':app:compileDebugKotlin'.
|
||||
> A failure occurred while executing org.jetbrains.kotlin.compilerRunner.GradleCompilerRunnerWithWorkers$GradleKotlinCompilerWorkAction
|
||||
> Compilation error. See log for more details
|
||||
|
||||
* Try:
|
||||
> Run with --stacktrace option to get the stack trace.
|
||||
> 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.
|
||||
|
||||
BUILD FAILED in 1s
|
||||
14 actionable tasks: 2 executed, 12 up-to-date
|
||||
> Task :app:checkKotlinGradlePluginConfigurationErrors
|
||||
> Task :app:preBuild UP-TO-DATE
|
||||
> Task :app:preDebugBuild UP-TO-DATE
|
||||
> Task :app:checkDebugAarMetadata UP-TO-DATE
|
||||
> Task :app:generateDebugResValues UP-TO-DATE
|
||||
> Task :app:mapDebugSourceSetPaths UP-TO-DATE
|
||||
> Task :app:generateDebugResources UP-TO-DATE
|
||||
> Task :app:mergeDebugResources UP-TO-DATE
|
||||
> Task :app:packageDebugResources UP-TO-DATE
|
||||
> Task :app:parseDebugLocalResources UP-TO-DATE
|
||||
> Task :app:createDebugCompatibleScreenManifests UP-TO-DATE
|
||||
> Task :app:extractDeepLinksDebug UP-TO-DATE
|
||||
> Task :app:processDebugMainManifest UP-TO-DATE
|
||||
> Task :app:processDebugManifest UP-TO-DATE
|
||||
> Task :app:processDebugManifestForPackage UP-TO-DATE
|
||||
> Task :app:processDebugResources UP-TO-DATE
|
||||
|
||||
> Task :app:compileDebugKotlin FAILED
|
||||
e: file:///C:/Users/Admin/Documents/Streamflow/android-tv/app/src/main/java/com/streamflow/tv/ui/screens/DetailScreen.kt:114:56 Unresolved reference: accent
|
||||
|
||||
FAILURE: Build failed with an exception.
|
||||
|
||||
* What went wrong:
|
||||
Execution failed for task ':app:compileDebugKotlin'.
|
||||
> A failure occurred while executing org.jetbrains.kotlin.compilerRunner.GradleCompilerRunnerWithWorkers$GradleKotlinCompilerWorkAction
|
||||
> Compilation error. See log for more details
|
||||
|
||||
* Try:
|
||||
> Run with --stacktrace option to get the stack trace.
|
||||
> 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.
|
||||
|
||||
BUILD FAILED in 1s
|
||||
14 actionable tasks: 2 executed, 12 up-to-date
|
||||
|
|
|
|||
|
|
@ -1,37 +1,37 @@
|
|||
> Task :app:checkKotlinGradlePluginConfigurationErrors
|
||||
> Task :app:preBuild UP-TO-DATE
|
||||
> Task :app:preDebugBuild UP-TO-DATE
|
||||
> Task :app:checkDebugAarMetadata UP-TO-DATE
|
||||
> Task :app:generateDebugResValues UP-TO-DATE
|
||||
> Task :app:mapDebugSourceSetPaths UP-TO-DATE
|
||||
> Task :app:generateDebugResources UP-TO-DATE
|
||||
> Task :app:mergeDebugResources UP-TO-DATE
|
||||
> Task :app:packageDebugResources UP-TO-DATE
|
||||
> Task :app:parseDebugLocalResources UP-TO-DATE
|
||||
> Task :app:createDebugCompatibleScreenManifests UP-TO-DATE
|
||||
> Task :app:extractDeepLinksDebug UP-TO-DATE
|
||||
> Task :app:processDebugMainManifest UP-TO-DATE
|
||||
> Task :app:processDebugManifest UP-TO-DATE
|
||||
> Task :app:processDebugManifestForPackage UP-TO-DATE
|
||||
> Task :app:processDebugResources UP-TO-DATE
|
||||
|
||||
> Task :app:compileDebugKotlin FAILED
|
||||
e: file:///C:/Users/Admin/Documents/Streamflow/android-tv/app/src/main/java/com/streamflow/tv/ui/screens/DetailScreen.kt:140:21 Cannot find a parameter with this name: onEpisodeClick
|
||||
e: file:///C:/Users/Admin/Documents/Streamflow/android-tv/app/src/main/java/com/streamflow/tv/ui/screens/DetailScreen.kt:140:40 Cannot infer a type for this parameter. Please specify it explicitly.
|
||||
e: file:///C:/Users/Admin/Documents/Streamflow/android-tv/app/src/main/java/com/streamflow/tv/ui/screens/DetailScreen.kt:141:21 No value passed for parameter 'onEpisodeSelect'
|
||||
|
||||
FAILURE: Build failed with an exception.
|
||||
|
||||
* What went wrong:
|
||||
Execution failed for task ':app:compileDebugKotlin'.
|
||||
> A failure occurred while executing org.jetbrains.kotlin.compilerRunner.GradleCompilerRunnerWithWorkers$GradleKotlinCompilerWorkAction
|
||||
> Compilation error. See log for more details
|
||||
|
||||
* Try:
|
||||
> Run with --stacktrace option to get the stack trace.
|
||||
> 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.
|
||||
|
||||
BUILD FAILED in 1s
|
||||
14 actionable tasks: 2 executed, 12 up-to-date
|
||||
> Task :app:checkKotlinGradlePluginConfigurationErrors
|
||||
> Task :app:preBuild UP-TO-DATE
|
||||
> Task :app:preDebugBuild UP-TO-DATE
|
||||
> Task :app:checkDebugAarMetadata UP-TO-DATE
|
||||
> Task :app:generateDebugResValues UP-TO-DATE
|
||||
> Task :app:mapDebugSourceSetPaths UP-TO-DATE
|
||||
> Task :app:generateDebugResources UP-TO-DATE
|
||||
> Task :app:mergeDebugResources UP-TO-DATE
|
||||
> Task :app:packageDebugResources UP-TO-DATE
|
||||
> Task :app:parseDebugLocalResources UP-TO-DATE
|
||||
> Task :app:createDebugCompatibleScreenManifests UP-TO-DATE
|
||||
> Task :app:extractDeepLinksDebug UP-TO-DATE
|
||||
> Task :app:processDebugMainManifest UP-TO-DATE
|
||||
> Task :app:processDebugManifest UP-TO-DATE
|
||||
> Task :app:processDebugManifestForPackage UP-TO-DATE
|
||||
> Task :app:processDebugResources UP-TO-DATE
|
||||
|
||||
> Task :app:compileDebugKotlin FAILED
|
||||
e: file:///C:/Users/Admin/Documents/Streamflow/android-tv/app/src/main/java/com/streamflow/tv/ui/screens/DetailScreen.kt:140:21 Cannot find a parameter with this name: onEpisodeClick
|
||||
e: file:///C:/Users/Admin/Documents/Streamflow/android-tv/app/src/main/java/com/streamflow/tv/ui/screens/DetailScreen.kt:140:40 Cannot infer a type for this parameter. Please specify it explicitly.
|
||||
e: file:///C:/Users/Admin/Documents/Streamflow/android-tv/app/src/main/java/com/streamflow/tv/ui/screens/DetailScreen.kt:141:21 No value passed for parameter 'onEpisodeSelect'
|
||||
|
||||
FAILURE: Build failed with an exception.
|
||||
|
||||
* What went wrong:
|
||||
Execution failed for task ':app:compileDebugKotlin'.
|
||||
> A failure occurred while executing org.jetbrains.kotlin.compilerRunner.GradleCompilerRunnerWithWorkers$GradleKotlinCompilerWorkAction
|
||||
> Compilation error. See log for more details
|
||||
|
||||
* Try:
|
||||
> Run with --stacktrace option to get the stack trace.
|
||||
> 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.
|
||||
|
||||
BUILD FAILED in 1s
|
||||
14 actionable tasks: 2 executed, 12 up-to-date
|
||||
|
|
|
|||
|
|
@ -1,92 +1,92 @@
|
|||
@rem
|
||||
@rem Copyright 2015 the original author or authors.
|
||||
@rem
|
||||
@rem Licensed under the Apache License, Version 2.0 (the "License");
|
||||
@rem you may not use this file except in compliance with the License.
|
||||
@rem You may obtain a copy of the License at
|
||||
@rem
|
||||
@rem https://www.apache.org/licenses/LICENSE-2.0
|
||||
@rem
|
||||
@rem Unless required by applicable law or agreed to in writing, software
|
||||
@rem distributed under the License is distributed on an "AS IS" BASIS,
|
||||
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
@rem See the License for the specific language governing permissions and
|
||||
@rem limitations under the License.
|
||||
@rem
|
||||
|
||||
@if "%DEBUG%"=="" @echo off
|
||||
@rem ##########################################################################
|
||||
@rem
|
||||
@rem Gradle startup script for Windows
|
||||
@rem
|
||||
@rem ##########################################################################
|
||||
|
||||
@rem Set local scope for the variables with windows NT shell
|
||||
if "%OS%"=="Windows_NT" setlocal
|
||||
|
||||
set DIRNAME=%~dp0
|
||||
if "%DIRNAME%"=="" set DIRNAME=.
|
||||
@rem This is normally unused
|
||||
set APP_BASE_NAME=%~n0
|
||||
set APP_HOME=%DIRNAME%..
|
||||
|
||||
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
|
||||
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
|
||||
|
||||
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" "-javaagent:%APP_HOME%/lib/agents/gradle-instrumentation-agent-8.4.jar"
|
||||
|
||||
@rem Find java.exe
|
||||
if defined JAVA_HOME goto findJavaFromJavaHome
|
||||
|
||||
set JAVA_EXE=java.exe
|
||||
%JAVA_EXE% -version >NUL 2>&1
|
||||
if %ERRORLEVEL% equ 0 goto execute
|
||||
|
||||
echo.
|
||||
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||
echo.
|
||||
echo Please set the JAVA_HOME variable in your environment to match the
|
||||
echo location of your Java installation.
|
||||
|
||||
goto fail
|
||||
|
||||
:findJavaFromJavaHome
|
||||
set JAVA_HOME=%JAVA_HOME:"=%
|
||||
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||
|
||||
if exist "%JAVA_EXE%" goto execute
|
||||
|
||||
echo.
|
||||
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
|
||||
echo.
|
||||
echo Please set the JAVA_HOME variable in your environment to match the
|
||||
echo location of your Java installation.
|
||||
|
||||
goto fail
|
||||
|
||||
:execute
|
||||
@rem Setup the command line
|
||||
|
||||
set CLASSPATH=%APP_HOME%\lib\gradle-launcher-8.4.jar
|
||||
|
||||
|
||||
@rem Execute Gradle
|
||||
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.launcher.GradleMain %*
|
||||
|
||||
:end
|
||||
@rem End local scope for the variables with windows NT shell
|
||||
if %ERRORLEVEL% equ 0 goto mainEnd
|
||||
|
||||
:fail
|
||||
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
|
||||
rem the _cmd.exe /c_ return code!
|
||||
set EXIT_CODE=%ERRORLEVEL%
|
||||
if %EXIT_CODE% equ 0 set EXIT_CODE=1
|
||||
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
|
||||
exit /b %EXIT_CODE%
|
||||
|
||||
:mainEnd
|
||||
if "%OS%"=="Windows_NT" endlocal
|
||||
|
||||
:omega
|
||||
@rem
|
||||
@rem Copyright 2015 the original author or authors.
|
||||
@rem
|
||||
@rem Licensed under the Apache License, Version 2.0 (the "License");
|
||||
@rem you may not use this file except in compliance with the License.
|
||||
@rem You may obtain a copy of the License at
|
||||
@rem
|
||||
@rem https://www.apache.org/licenses/LICENSE-2.0
|
||||
@rem
|
||||
@rem Unless required by applicable law or agreed to in writing, software
|
||||
@rem distributed under the License is distributed on an "AS IS" BASIS,
|
||||
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
@rem See the License for the specific language governing permissions and
|
||||
@rem limitations under the License.
|
||||
@rem
|
||||
|
||||
@if "%DEBUG%"=="" @echo off
|
||||
@rem ##########################################################################
|
||||
@rem
|
||||
@rem Gradle startup script for Windows
|
||||
@rem
|
||||
@rem ##########################################################################
|
||||
|
||||
@rem Set local scope for the variables with windows NT shell
|
||||
if "%OS%"=="Windows_NT" setlocal
|
||||
|
||||
set DIRNAME=%~dp0
|
||||
if "%DIRNAME%"=="" set DIRNAME=.
|
||||
@rem This is normally unused
|
||||
set APP_BASE_NAME=%~n0
|
||||
set APP_HOME=%DIRNAME%..
|
||||
|
||||
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
|
||||
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
|
||||
|
||||
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" "-javaagent:%APP_HOME%/lib/agents/gradle-instrumentation-agent-8.4.jar"
|
||||
|
||||
@rem Find java.exe
|
||||
if defined JAVA_HOME goto findJavaFromJavaHome
|
||||
|
||||
set JAVA_EXE=java.exe
|
||||
%JAVA_EXE% -version >NUL 2>&1
|
||||
if %ERRORLEVEL% equ 0 goto execute
|
||||
|
||||
echo.
|
||||
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||
echo.
|
||||
echo Please set the JAVA_HOME variable in your environment to match the
|
||||
echo location of your Java installation.
|
||||
|
||||
goto fail
|
||||
|
||||
:findJavaFromJavaHome
|
||||
set JAVA_HOME=%JAVA_HOME:"=%
|
||||
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||
|
||||
if exist "%JAVA_EXE%" goto execute
|
||||
|
||||
echo.
|
||||
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
|
||||
echo.
|
||||
echo Please set the JAVA_HOME variable in your environment to match the
|
||||
echo location of your Java installation.
|
||||
|
||||
goto fail
|
||||
|
||||
:execute
|
||||
@rem Setup the command line
|
||||
|
||||
set CLASSPATH=%APP_HOME%\lib\gradle-launcher-8.4.jar
|
||||
|
||||
|
||||
@rem Execute Gradle
|
||||
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.launcher.GradleMain %*
|
||||
|
||||
:end
|
||||
@rem End local scope for the variables with windows NT shell
|
||||
if %ERRORLEVEL% equ 0 goto mainEnd
|
||||
|
||||
:fail
|
||||
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
|
||||
rem the _cmd.exe /c_ return code!
|
||||
set EXIT_CODE=%ERRORLEVEL%
|
||||
if %EXIT_CODE% equ 0 set EXIT_CODE=1
|
||||
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
|
||||
exit /b %EXIT_CODE%
|
||||
|
||||
:mainEnd
|
||||
if "%OS%"=="Windows_NT" endlocal
|
||||
|
||||
:omega
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
|
||||
android.useAndroidX=true
|
||||
kotlin.code.style=official
|
||||
android.nonTransitiveRClass=true
|
||||
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
|
||||
android.useAndroidX=true
|
||||
kotlin.code.style=official
|
||||
android.nonTransitiveRClass=true
|
||||
|
|
|
|||
0
android-tv/gradlew
vendored
Normal file → Executable file
0
android-tv/gradlew
vendored
Normal file → Executable file
188
android-tv/gradlew.bat
vendored
188
android-tv/gradlew.bat
vendored
|
|
@ -1,94 +1,94 @@
|
|||
@rem
|
||||
@rem Copyright 2015 the original author or authors.
|
||||
@rem
|
||||
@rem Licensed under the Apache License, Version 2.0 (the "License");
|
||||
@rem you may not use this file except in compliance with the License.
|
||||
@rem You may obtain a copy of the License at
|
||||
@rem
|
||||
@rem https://www.apache.org/licenses/LICENSE-2.0
|
||||
@rem
|
||||
@rem Unless required by applicable law or agreed to in writing, software
|
||||
@rem distributed under the License is distributed on an "AS IS" BASIS,
|
||||
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
@rem See the License for the specific language governing permissions and
|
||||
@rem limitations under the License.
|
||||
@rem
|
||||
@rem SPDX-License-Identifier: Apache-2.0
|
||||
@rem
|
||||
|
||||
@if "%DEBUG%"=="" @echo off
|
||||
@rem ##########################################################################
|
||||
@rem
|
||||
@rem Gradle startup script for Windows
|
||||
@rem
|
||||
@rem ##########################################################################
|
||||
|
||||
@rem Set local scope for the variables with windows NT shell
|
||||
if "%OS%"=="Windows_NT" setlocal
|
||||
|
||||
set DIRNAME=%~dp0
|
||||
if "%DIRNAME%"=="" set DIRNAME=.
|
||||
@rem This is normally unused
|
||||
set APP_BASE_NAME=%~n0
|
||||
set APP_HOME=%DIRNAME%
|
||||
|
||||
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
|
||||
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
|
||||
|
||||
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
|
||||
|
||||
@rem Find java.exe
|
||||
if defined JAVA_HOME goto findJavaFromJavaHome
|
||||
|
||||
set JAVA_EXE=java.exe
|
||||
%JAVA_EXE% -version >NUL 2>&1
|
||||
if %ERRORLEVEL% equ 0 goto execute
|
||||
|
||||
echo. 1>&2
|
||||
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
|
||||
echo. 1>&2
|
||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||
echo location of your Java installation. 1>&2
|
||||
|
||||
goto fail
|
||||
|
||||
:findJavaFromJavaHome
|
||||
set JAVA_HOME=%JAVA_HOME:"=%
|
||||
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||
|
||||
if exist "%JAVA_EXE%" goto execute
|
||||
|
||||
echo. 1>&2
|
||||
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
|
||||
echo. 1>&2
|
||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||
echo location of your Java installation. 1>&2
|
||||
|
||||
goto fail
|
||||
|
||||
:execute
|
||||
@rem Setup the command line
|
||||
|
||||
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
|
||||
|
||||
|
||||
@rem Execute Gradle
|
||||
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
|
||||
|
||||
:end
|
||||
@rem End local scope for the variables with windows NT shell
|
||||
if %ERRORLEVEL% equ 0 goto mainEnd
|
||||
|
||||
:fail
|
||||
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
|
||||
rem the _cmd.exe /c_ return code!
|
||||
set EXIT_CODE=%ERRORLEVEL%
|
||||
if %EXIT_CODE% equ 0 set EXIT_CODE=1
|
||||
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
|
||||
exit /b %EXIT_CODE%
|
||||
|
||||
:mainEnd
|
||||
if "%OS%"=="Windows_NT" endlocal
|
||||
|
||||
:omega
|
||||
@rem
|
||||
@rem Copyright 2015 the original author or authors.
|
||||
@rem
|
||||
@rem Licensed under the Apache License, Version 2.0 (the "License");
|
||||
@rem you may not use this file except in compliance with the License.
|
||||
@rem You may obtain a copy of the License at
|
||||
@rem
|
||||
@rem https://www.apache.org/licenses/LICENSE-2.0
|
||||
@rem
|
||||
@rem Unless required by applicable law or agreed to in writing, software
|
||||
@rem distributed under the License is distributed on an "AS IS" BASIS,
|
||||
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
@rem See the License for the specific language governing permissions and
|
||||
@rem limitations under the License.
|
||||
@rem
|
||||
@rem SPDX-License-Identifier: Apache-2.0
|
||||
@rem
|
||||
|
||||
@if "%DEBUG%"=="" @echo off
|
||||
@rem ##########################################################################
|
||||
@rem
|
||||
@rem Gradle startup script for Windows
|
||||
@rem
|
||||
@rem ##########################################################################
|
||||
|
||||
@rem Set local scope for the variables with windows NT shell
|
||||
if "%OS%"=="Windows_NT" setlocal
|
||||
|
||||
set DIRNAME=%~dp0
|
||||
if "%DIRNAME%"=="" set DIRNAME=.
|
||||
@rem This is normally unused
|
||||
set APP_BASE_NAME=%~n0
|
||||
set APP_HOME=%DIRNAME%
|
||||
|
||||
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
|
||||
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
|
||||
|
||||
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
|
||||
|
||||
@rem Find java.exe
|
||||
if defined JAVA_HOME goto findJavaFromJavaHome
|
||||
|
||||
set JAVA_EXE=java.exe
|
||||
%JAVA_EXE% -version >NUL 2>&1
|
||||
if %ERRORLEVEL% equ 0 goto execute
|
||||
|
||||
echo. 1>&2
|
||||
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
|
||||
echo. 1>&2
|
||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||
echo location of your Java installation. 1>&2
|
||||
|
||||
goto fail
|
||||
|
||||
:findJavaFromJavaHome
|
||||
set JAVA_HOME=%JAVA_HOME:"=%
|
||||
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||
|
||||
if exist "%JAVA_EXE%" goto execute
|
||||
|
||||
echo. 1>&2
|
||||
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
|
||||
echo. 1>&2
|
||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||
echo location of your Java installation. 1>&2
|
||||
|
||||
goto fail
|
||||
|
||||
:execute
|
||||
@rem Setup the command line
|
||||
|
||||
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
|
||||
|
||||
|
||||
@rem Execute Gradle
|
||||
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
|
||||
|
||||
:end
|
||||
@rem End local scope for the variables with windows NT shell
|
||||
if %ERRORLEVEL% equ 0 goto mainEnd
|
||||
|
||||
:fail
|
||||
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
|
||||
rem the _cmd.exe /c_ return code!
|
||||
set EXIT_CODE=%ERRORLEVEL%
|
||||
if %EXIT_CODE% equ 0 set EXIT_CODE=1
|
||||
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
|
||||
exit /b %EXIT_CODE%
|
||||
|
||||
:mainEnd
|
||||
if "%OS%"=="Windows_NT" endlocal
|
||||
|
||||
:omega
|
||||
|
|
|
|||
|
|
@ -1,18 +1,18 @@
|
|||
pluginManagement {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
gradlePluginPortal()
|
||||
}
|
||||
}
|
||||
|
||||
dependencyResolutionManagement {
|
||||
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
}
|
||||
|
||||
rootProject.name = "StreamFlowTV"
|
||||
include(":app")
|
||||
pluginManagement {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
gradlePluginPortal()
|
||||
}
|
||||
}
|
||||
|
||||
dependencyResolutionManagement {
|
||||
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
}
|
||||
|
||||
rootProject.name = "StreamFlowTV"
|
||||
include(":app")
|
||||
|
|
|
|||
|
|
@ -75,6 +75,7 @@ func (h *Handler) GetHomeVideos(w http.ResponseWriter, r *http.Request) {
|
|||
return p.GetMoviesByCategory(category, page)
|
||||
})
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(movies)
|
||||
}
|
||||
|
||||
|
|
@ -94,6 +95,7 @@ func (h *Handler) SearchVideos(w http.ResponseWriter, r *http.Request) {
|
|||
return p.Search(query, page)
|
||||
})
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(movies)
|
||||
}
|
||||
|
||||
|
|
@ -211,6 +213,7 @@ func (h *Handler) ExtractVideo(w http.ResponseWriter, r *http.Request) {
|
|||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(info)
|
||||
}
|
||||
|
||||
|
|
@ -306,6 +309,7 @@ func (h *Handler) GetMovieDetail(w http.ResponseWriter, r *http.Request) {
|
|||
primaryMovie.Episodes = uniqueEps
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(primaryMovie)
|
||||
}
|
||||
|
||||
|
|
@ -316,6 +320,7 @@ func (h *Handler) GetGenres(w http.ResponseWriter, r *http.Request) {
|
|||
}); ok {
|
||||
genres, err := gp.GetGenres()
|
||||
if err == nil {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(genres)
|
||||
return
|
||||
}
|
||||
|
|
@ -331,6 +336,7 @@ func (h *Handler) GetCountries(w http.ResponseWriter, r *http.Request) {
|
|||
}); ok {
|
||||
countries, err := cp.GetCountries()
|
||||
if err == nil {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(countries)
|
||||
return
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,16 +1,16 @@
|
|||
package api
|
||||
|
||||
import (
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
func RegisterRoutes(r chi.Router, h *Handler) {
|
||||
r.Get("/videos/home", h.GetHomeVideos)
|
||||
r.Get("/videos/search", h.SearchVideos)
|
||||
r.Get("/videos/{slug}", h.GetMovieDetail)
|
||||
r.Post("/extract", h.ExtractVideo)
|
||||
r.Get("/images/proxy", h.ProxyImage)
|
||||
r.Get("/categories/genres", h.GetGenres)
|
||||
r.Get("/categories/countries", h.GetCountries)
|
||||
r.Get("/stream", h.StreamVideo)
|
||||
}
|
||||
package api
|
||||
|
||||
import (
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
func RegisterRoutes(r chi.Router, h *Handler) {
|
||||
r.Get("/videos/home", h.GetHomeVideos)
|
||||
r.Get("/videos/search", h.SearchVideos)
|
||||
r.Get("/videos/{slug}", h.GetMovieDetail)
|
||||
r.Post("/extract", h.ExtractVideo)
|
||||
r.Get("/images/proxy", h.ProxyImage)
|
||||
r.Get("/categories/genres", h.GetGenres)
|
||||
r.Get("/categories/countries", h.GetCountries)
|
||||
r.Get("/stream", h.StreamVideo)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,85 +1,85 @@
|
|||
package database
|
||||
|
||||
import (
|
||||
"log"
|
||||
|
||||
"streamflow-backend/internal/models"
|
||||
|
||||
"github.com/glebarez/sqlite"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
)
|
||||
|
||||
var DB *gorm.DB
|
||||
|
||||
func InitDB(dsn string) {
|
||||
var err error
|
||||
DB, err = gorm.Open(sqlite.Open(dsn), &gorm.Config{
|
||||
Logger: logger.Default.LogMode(logger.Info),
|
||||
})
|
||||
if err != nil {
|
||||
log.Fatal("Failed to connect to database:", err)
|
||||
}
|
||||
|
||||
log.Println("Database connection established")
|
||||
|
||||
// Auto-migrate schema
|
||||
err = DB.AutoMigrate(&models.Video{})
|
||||
if err != nil {
|
||||
log.Fatal("Failed to migrate database:", err)
|
||||
}
|
||||
}
|
||||
|
||||
type VideoRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewVideoRepository(db *gorm.DB) *VideoRepository {
|
||||
return &VideoRepository{db: db}
|
||||
}
|
||||
|
||||
func (r *VideoRepository) Create(video *models.Video) error {
|
||||
return r.db.Create(video).Error
|
||||
}
|
||||
|
||||
func (r *VideoRepository) GetByID(id uint) (*models.Video, error) {
|
||||
var video models.Video
|
||||
err := r.db.First(&video, id).Error
|
||||
return &video, err
|
||||
}
|
||||
|
||||
func (r *VideoRepository) GetBySourceURL(url string) (*models.Video, error) {
|
||||
var video models.Video
|
||||
err := r.db.Where("source_url = ?", url).First(&video).Error
|
||||
return &video, err
|
||||
}
|
||||
|
||||
func (r *VideoRepository) Search(query string, limit int) ([]models.Video, error) {
|
||||
var videos []models.Video
|
||||
err := r.db.Where("title LIKE ?", "%"+query+"%").Limit(limit).Find(&videos).Error
|
||||
return videos, err
|
||||
}
|
||||
|
||||
func (r *VideoRepository) GetAll(skip int, limit int) ([]models.Video, error) {
|
||||
var videos []models.Video
|
||||
err := r.db.Offset(skip).Limit(limit).Find(&videos).Error
|
||||
return videos, err
|
||||
}
|
||||
|
||||
func (r *VideoRepository) Update(id uint, updates map[string]interface{}) (*models.Video, error) {
|
||||
var video models.Video
|
||||
result := r.db.First(&video, id)
|
||||
if result.Error != nil {
|
||||
return nil, result.Error
|
||||
}
|
||||
|
||||
err := r.db.Model(&video).Updates(updates).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &video, nil
|
||||
}
|
||||
|
||||
func (r *VideoRepository) Delete(id uint) error {
|
||||
return r.db.Delete(&models.Video{}, id).Error
|
||||
}
|
||||
package database
|
||||
|
||||
import (
|
||||
"log"
|
||||
|
||||
"streamflow-backend/internal/models"
|
||||
|
||||
"github.com/glebarez/sqlite"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
)
|
||||
|
||||
var DB *gorm.DB
|
||||
|
||||
func InitDB(dsn string) {
|
||||
var err error
|
||||
DB, err = gorm.Open(sqlite.Open(dsn), &gorm.Config{
|
||||
Logger: logger.Default.LogMode(logger.Info),
|
||||
})
|
||||
if err != nil {
|
||||
log.Fatal("Failed to connect to database:", err)
|
||||
}
|
||||
|
||||
log.Println("Database connection established")
|
||||
|
||||
// Auto-migrate schema
|
||||
err = DB.AutoMigrate(&models.Video{})
|
||||
if err != nil {
|
||||
log.Fatal("Failed to migrate database:", err)
|
||||
}
|
||||
}
|
||||
|
||||
type VideoRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewVideoRepository(db *gorm.DB) *VideoRepository {
|
||||
return &VideoRepository{db: db}
|
||||
}
|
||||
|
||||
func (r *VideoRepository) Create(video *models.Video) error {
|
||||
return r.db.Create(video).Error
|
||||
}
|
||||
|
||||
func (r *VideoRepository) GetByID(id uint) (*models.Video, error) {
|
||||
var video models.Video
|
||||
err := r.db.First(&video, id).Error
|
||||
return &video, err
|
||||
}
|
||||
|
||||
func (r *VideoRepository) GetBySourceURL(url string) (*models.Video, error) {
|
||||
var video models.Video
|
||||
err := r.db.Where("source_url = ?", url).First(&video).Error
|
||||
return &video, err
|
||||
}
|
||||
|
||||
func (r *VideoRepository) Search(query string, limit int) ([]models.Video, error) {
|
||||
var videos []models.Video
|
||||
err := r.db.Where("title LIKE ?", "%"+query+"%").Limit(limit).Find(&videos).Error
|
||||
return videos, err
|
||||
}
|
||||
|
||||
func (r *VideoRepository) GetAll(skip int, limit int) ([]models.Video, error) {
|
||||
var videos []models.Video
|
||||
err := r.db.Offset(skip).Limit(limit).Find(&videos).Error
|
||||
return videos, err
|
||||
}
|
||||
|
||||
func (r *VideoRepository) Update(id uint, updates map[string]interface{}) (*models.Video, error) {
|
||||
var video models.Video
|
||||
result := r.db.First(&video, id)
|
||||
if result.Error != nil {
|
||||
return nil, result.Error
|
||||
}
|
||||
|
||||
err := r.db.Model(&video).Updates(updates).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &video, nil
|
||||
}
|
||||
|
||||
func (r *VideoRepository) Delete(id uint) error {
|
||||
return r.db.Delete(&models.Video{}, id).Error
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,56 +1,56 @@
|
|||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// Video metadata model matches SQLAlchemy Video class
|
||||
type Video struct {
|
||||
ID uint `json:"id" gorm:"primaryKey"`
|
||||
Title string `json:"title" gorm:"index;size:500"`
|
||||
Description string `json:"description"`
|
||||
Thumbnail string `json:"thumbnail" gorm:"size:1000"`
|
||||
SourceURL string `json:"source_url" gorm:"uniqueIndex;size:2000"`
|
||||
Duration int `json:"duration" gorm:"default:0"`
|
||||
Resolution string `json:"resolution" gorm:"size:20"`
|
||||
Category string `json:"category" gorm:"index;size:100"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// RophimMovie represents the scraped movie data
|
||||
type RophimMovie struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
OriginalTitle string `json:"original_title,omitempty"`
|
||||
Slug string `json:"slug"`
|
||||
Thumbnail string `json:"thumbnail"`
|
||||
Backdrop string `json:"backdrop,omitempty"`
|
||||
Year int `json:"year,omitempty"`
|
||||
Rating string `json:"rating,omitempty"`
|
||||
Duration int `json:"duration,omitempty"`
|
||||
Time string `json:"time,omitempty"` // Raw time string
|
||||
Quality string `json:"quality,omitempty"`
|
||||
Lang string `json:"lang,omitempty"`
|
||||
Genre string `json:"genre,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Category string `json:"category"`
|
||||
Provider string `json:"provider,omitempty"`
|
||||
Cast []string `json:"cast,omitempty" gorm:"-"`
|
||||
Director string `json:"director,omitempty"`
|
||||
Country string `json:"country,omitempty"`
|
||||
Episodes []Episode `json:"episodes,omitempty" gorm:"-"`
|
||||
TrailerURL string `json:"trailer_url,omitempty"`
|
||||
}
|
||||
|
||||
type Episode struct {
|
||||
Number int `json:"number"`
|
||||
Title string `json:"title"`
|
||||
URL string `json:"url"`
|
||||
ServerName string `json:"server_name"`
|
||||
}
|
||||
|
||||
type Category struct {
|
||||
Name string `json:"name"`
|
||||
Slug string `json:"slug"`
|
||||
}
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// Video metadata model matches SQLAlchemy Video class
|
||||
type Video struct {
|
||||
ID uint `json:"id" gorm:"primaryKey"`
|
||||
Title string `json:"title" gorm:"index;size:500"`
|
||||
Description string `json:"description"`
|
||||
Thumbnail string `json:"thumbnail" gorm:"size:1000"`
|
||||
SourceURL string `json:"source_url" gorm:"uniqueIndex;size:2000"`
|
||||
Duration int `json:"duration" gorm:"default:0"`
|
||||
Resolution string `json:"resolution" gorm:"size:20"`
|
||||
Category string `json:"category" gorm:"index;size:100"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// RophimMovie represents the scraped movie data
|
||||
type RophimMovie struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
OriginalTitle string `json:"original_title,omitempty"`
|
||||
Slug string `json:"slug"`
|
||||
Thumbnail string `json:"thumbnail"`
|
||||
Backdrop string `json:"backdrop,omitempty"`
|
||||
Year int `json:"year,omitempty"`
|
||||
Rating string `json:"rating,omitempty"`
|
||||
Duration int `json:"duration,omitempty"`
|
||||
Time string `json:"time,omitempty"` // Raw time string
|
||||
Quality string `json:"quality,omitempty"`
|
||||
Lang string `json:"lang,omitempty"`
|
||||
Genre string `json:"genre,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Category string `json:"category"`
|
||||
Provider string `json:"provider,omitempty"`
|
||||
Cast []string `json:"cast,omitempty" gorm:"-"`
|
||||
Director string `json:"director,omitempty"`
|
||||
Country string `json:"country,omitempty"`
|
||||
Episodes []Episode `json:"episodes,omitempty" gorm:"-"`
|
||||
TrailerURL string `json:"trailer_url,omitempty"`
|
||||
}
|
||||
|
||||
type Episode struct {
|
||||
Number int `json:"number"`
|
||||
Title string `json:"title"`
|
||||
URL string `json:"url"`
|
||||
ServerName string `json:"server_name"`
|
||||
}
|
||||
|
||||
type Category struct {
|
||||
Name string `json:"name"`
|
||||
Slug string `json:"slug"`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.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, ", ")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,191 +1,191 @@
|
|||
package scraper
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"streamflow-backend/internal/models"
|
||||
|
||||
"github.com/PuerkitoBio/goquery"
|
||||
)
|
||||
|
||||
func parseEpisodeNumber(title string) int {
|
||||
// e.g "Tập 1", "Tập 01", "Full"
|
||||
t := strings.ToLower(strings.TrimSpace(title))
|
||||
if t == "full" {
|
||||
return 1
|
||||
}
|
||||
t = strings.ReplaceAll(t, "tập ", "")
|
||||
t = strings.ReplaceAll(t, "tap ", "")
|
||||
|
||||
// handle multi-spaces
|
||||
parts := strings.Fields(t)
|
||||
if len(parts) > 0 {
|
||||
num, err := strconv.Atoi(parts[0])
|
||||
if err == nil {
|
||||
return num
|
||||
}
|
||||
}
|
||||
return 1
|
||||
}
|
||||
|
||||
const Phim30BaseURL = "https://phim30.me"
|
||||
|
||||
type Phim30Scraper struct {
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
func NewPhim30Scraper() *Phim30Scraper {
|
||||
return &Phim30Scraper{
|
||||
client: &http.Client{
|
||||
Timeout: 30 * time.Second,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Phim30Scraper) Search(query string, page int) ([]models.RophimMovie, error) {
|
||||
searchURL := fmt.Sprintf("%s/tim-kiem?keyword=%s&page=%d", Phim30BaseURL, url.QueryEscape(query), page)
|
||||
return p.scrapeMovieList(searchURL)
|
||||
}
|
||||
|
||||
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)
|
||||
return p.scrapeMovieList(catURL)
|
||||
}
|
||||
|
||||
func (p *Phim30Scraper) scrapeMovieList(targetURL string) ([]models.RophimMovie, error) {
|
||||
req, err := http.NewRequest("GET", targetURL, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36")
|
||||
|
||||
resp, err := p.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, 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, err
|
||||
}
|
||||
|
||||
var movies []models.RophimMovie
|
||||
|
||||
doc.Find("a[href^='https://phim30.me/phim/']").Each(func(i int, s *goquery.Selection) {
|
||||
href, _ := s.Attr("href")
|
||||
title, _ := s.Attr("title")
|
||||
|
||||
// Remove the base url to get the slug
|
||||
slug := strings.TrimPrefix(href, "https://phim30.me/phim/")
|
||||
|
||||
// Try to find an image child (check data-src for lazy-loaded images)
|
||||
thumb := ""
|
||||
s.Find("img").Each(func(j int, img *goquery.Selection) {
|
||||
src, _ := img.Attr("src")
|
||||
dataSrc, _ := img.Attr("data-src")
|
||||
lazySrc, _ := img.Attr("lazy-src")
|
||||
if dataSrc != "" {
|
||||
thumb = dataSrc
|
||||
} else if lazySrc != "" {
|
||||
thumb = lazySrc
|
||||
} else if src != "" && !strings.Contains(src, "data:image") {
|
||||
thumb = src
|
||||
}
|
||||
})
|
||||
|
||||
if title != "" && slug != "" {
|
||||
movies = append(movies, models.RophimMovie{
|
||||
ID: slug,
|
||||
Slug: slug,
|
||||
Title: title,
|
||||
OriginalTitle: title,
|
||||
Thumbnail: thumb,
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// Deduplicate movies because a search page might have multiple links to the same movie
|
||||
var uniqueMovies []models.RophimMovie
|
||||
seen := make(map[string]bool)
|
||||
for _, m := range movies {
|
||||
if !seen[m.Slug] {
|
||||
seen[m.Slug] = true
|
||||
uniqueMovies = append(uniqueMovies, m)
|
||||
}
|
||||
}
|
||||
|
||||
return uniqueMovies, nil
|
||||
}
|
||||
|
||||
func (p *Phim30Scraper) GetMovieDetail(slug string) (*models.RophimMovie, error) {
|
||||
targetURL := fmt.Sprintf("%s/phim/%s", Phim30BaseURL, slug)
|
||||
req, err := http.NewRequest("GET", targetURL, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36")
|
||||
|
||||
resp, err := p.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, 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, err
|
||||
}
|
||||
|
||||
movie := &models.RophimMovie{
|
||||
ID: slug,
|
||||
Slug: slug,
|
||||
}
|
||||
|
||||
title := doc.Find("h1.movie-title").Text()
|
||||
if title == "" {
|
||||
title = doc.Find("title").Text()
|
||||
title = strings.Split(title, "–")[0]
|
||||
title = strings.TrimSpace(title)
|
||||
}
|
||||
movie.Title = title
|
||||
movie.OriginalTitle = title
|
||||
|
||||
var eps []models.Episode
|
||||
doc.Find("a[href*='/xem-phim/']").Each(func(i int, s *goquery.Selection) {
|
||||
href, _ := s.Attr("href")
|
||||
epName := strings.TrimSpace(s.Text())
|
||||
|
||||
if epName != "" && href != "" {
|
||||
if !strings.HasPrefix(href, "http") {
|
||||
href = Phim30BaseURL + href
|
||||
}
|
||||
eps = append(eps, models.Episode{
|
||||
ServerName: "Phim30",
|
||||
Title: epName,
|
||||
Number: parseEpisodeNumber(epName),
|
||||
URL: href,
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
if len(eps) > 0 {
|
||||
movie.Episodes = eps
|
||||
}
|
||||
|
||||
return movie, nil
|
||||
}
|
||||
package scraper
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"streamflow-backend/internal/models"
|
||||
|
||||
"github.com/PuerkitoBio/goquery"
|
||||
)
|
||||
|
||||
func parseEpisodeNumber(title string) int {
|
||||
// e.g "Tập 1", "Tập 01", "Full"
|
||||
t := strings.ToLower(strings.TrimSpace(title))
|
||||
if t == "full" {
|
||||
return 1
|
||||
}
|
||||
t = strings.ReplaceAll(t, "tập ", "")
|
||||
t = strings.ReplaceAll(t, "tap ", "")
|
||||
|
||||
// handle multi-spaces
|
||||
parts := strings.Fields(t)
|
||||
if len(parts) > 0 {
|
||||
num, err := strconv.Atoi(parts[0])
|
||||
if err == nil {
|
||||
return num
|
||||
}
|
||||
}
|
||||
return 1
|
||||
}
|
||||
|
||||
const Phim30BaseURL = "https://phim30.me"
|
||||
|
||||
type Phim30Scraper struct {
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
func NewPhim30Scraper() *Phim30Scraper {
|
||||
return &Phim30Scraper{
|
||||
client: &http.Client{
|
||||
Timeout: 30 * time.Second,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Phim30Scraper) Search(query string, page int) ([]models.RophimMovie, error) {
|
||||
searchURL := fmt.Sprintf("%s/tim-kiem?keyword=%s&page=%d", Phim30BaseURL, url.QueryEscape(query), page)
|
||||
return p.scrapeMovieList(searchURL)
|
||||
}
|
||||
|
||||
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)
|
||||
return p.scrapeMovieList(catURL)
|
||||
}
|
||||
|
||||
func (p *Phim30Scraper) scrapeMovieList(targetURL string) ([]models.RophimMovie, error) {
|
||||
req, err := http.NewRequest("GET", targetURL, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36")
|
||||
|
||||
resp, err := p.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, 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, err
|
||||
}
|
||||
|
||||
var movies []models.RophimMovie
|
||||
|
||||
doc.Find("a[href^='https://phim30.me/phim/']").Each(func(i int, s *goquery.Selection) {
|
||||
href, _ := s.Attr("href")
|
||||
title, _ := s.Attr("title")
|
||||
|
||||
// Remove the base url to get the slug
|
||||
slug := strings.TrimPrefix(href, "https://phim30.me/phim/")
|
||||
|
||||
// Try to find an image child (check data-src for lazy-loaded images)
|
||||
thumb := ""
|
||||
s.Find("img").Each(func(j int, img *goquery.Selection) {
|
||||
src, _ := img.Attr("src")
|
||||
dataSrc, _ := img.Attr("data-src")
|
||||
lazySrc, _ := img.Attr("lazy-src")
|
||||
if dataSrc != "" {
|
||||
thumb = dataSrc
|
||||
} else if lazySrc != "" {
|
||||
thumb = lazySrc
|
||||
} else if src != "" && !strings.Contains(src, "data:image") {
|
||||
thumb = src
|
||||
}
|
||||
})
|
||||
|
||||
if title != "" && slug != "" {
|
||||
movies = append(movies, models.RophimMovie{
|
||||
ID: slug,
|
||||
Slug: slug,
|
||||
Title: title,
|
||||
OriginalTitle: title,
|
||||
Thumbnail: thumb,
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// Deduplicate movies because a search page might have multiple links to the same movie
|
||||
var uniqueMovies []models.RophimMovie
|
||||
seen := make(map[string]bool)
|
||||
for _, m := range movies {
|
||||
if !seen[m.Slug] {
|
||||
seen[m.Slug] = true
|
||||
uniqueMovies = append(uniqueMovies, m)
|
||||
}
|
||||
}
|
||||
|
||||
return uniqueMovies, nil
|
||||
}
|
||||
|
||||
func (p *Phim30Scraper) GetMovieDetail(slug string) (*models.RophimMovie, error) {
|
||||
targetURL := fmt.Sprintf("%s/phim/%s", Phim30BaseURL, slug)
|
||||
req, err := http.NewRequest("GET", targetURL, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36")
|
||||
|
||||
resp, err := p.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, 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, err
|
||||
}
|
||||
|
||||
movie := &models.RophimMovie{
|
||||
ID: slug,
|
||||
Slug: slug,
|
||||
}
|
||||
|
||||
title := doc.Find("h1.movie-title").Text()
|
||||
if title == "" {
|
||||
title = doc.Find("title").Text()
|
||||
title = strings.Split(title, "–")[0]
|
||||
title = strings.TrimSpace(title)
|
||||
}
|
||||
movie.Title = title
|
||||
movie.OriginalTitle = title
|
||||
|
||||
var eps []models.Episode
|
||||
doc.Find("a[href*='/xem-phim/']").Each(func(i int, s *goquery.Selection) {
|
||||
href, _ := s.Attr("href")
|
||||
epName := strings.TrimSpace(s.Text())
|
||||
|
||||
if epName != "" && href != "" {
|
||||
if !strings.HasPrefix(href, "http") {
|
||||
href = Phim30BaseURL + href
|
||||
}
|
||||
eps = append(eps, models.Episode{
|
||||
ServerName: "Phim30",
|
||||
Title: epName,
|
||||
Number: parseEpisodeNumber(epName),
|
||||
URL: href,
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
if len(eps) > 0 {
|
||||
movie.Episodes = eps
|
||||
}
|
||||
|
||||
return movie, nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
package scraper
|
||||
|
||||
import "streamflow-backend/internal/models"
|
||||
|
||||
type MovieProvider interface {
|
||||
GetMoviesByCategory(category string, page int) ([]models.RophimMovie, error)
|
||||
GetMovieDetail(slug string) (*models.RophimMovie, error)
|
||||
Search(query string, page int) ([]models.RophimMovie, error)
|
||||
}
|
||||
package scraper
|
||||
|
||||
import "streamflow-backend/internal/models"
|
||||
|
||||
type MovieProvider interface {
|
||||
GetMoviesByCategory(category string, page int) ([]models.RophimMovie, error)
|
||||
GetMovieDetail(slug string) (*models.RophimMovie, error)
|
||||
Search(query string, page int) ([]models.RophimMovie, error)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,246 +1,246 @@
|
|||
package scraper
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"streamflow-backend/internal/models"
|
||||
|
||||
"github.com/PuerkitoBio/goquery"
|
||||
)
|
||||
|
||||
const BaseURL = "https://phimmoichill.network"
|
||||
|
||||
type RophimScraper struct {
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
func NewRophimScraper() *RophimScraper {
|
||||
// Create custom client to handle SSL constraints if needed, similar to Python's ssl_context
|
||||
tr := &http.Transport{
|
||||
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
|
||||
}
|
||||
client := &http.Client{
|
||||
Transport: tr,
|
||||
Timeout: 30 * time.Second,
|
||||
}
|
||||
return &RophimScraper{client: client}
|
||||
}
|
||||
|
||||
func (s *RophimScraper) fetchDocument(url string) (*goquery.Document, error) {
|
||||
req, err := http.NewRequest("GET", url, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36")
|
||||
req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8")
|
||||
req.Header.Set("Referer", BaseURL)
|
||||
|
||||
resp, err := s.client.Do(req)
|
||||
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)
|
||||
}
|
||||
|
||||
return goquery.NewDocumentFromReader(resp.Body)
|
||||
}
|
||||
|
||||
func (s *RophimScraper) GetHomepageMovies(page int, limit int) ([]models.RophimMovie, error) {
|
||||
url := fmt.Sprintf("%s/danh-sach/phim-le", BaseURL)
|
||||
if page > 1 {
|
||||
url = fmt.Sprintf("%s/danh-sach/phim-le/page/%d", BaseURL, page)
|
||||
}
|
||||
|
||||
doc, err := s.fetchDocument(url)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return s.parseMovieGrid(doc, limit), nil
|
||||
}
|
||||
|
||||
func (s *RophimScraper) Search(query string, limit int) ([]models.RophimMovie, error) {
|
||||
url := fmt.Sprintf("%s/tim-kiem?keyword=%s", BaseURL, query)
|
||||
doc, err := s.fetchDocument(url)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.parseMovieGrid(doc, limit), nil
|
||||
}
|
||||
|
||||
func (s *RophimScraper) parseMovieGrid(doc *goquery.Document, limit int) []models.RophimMovie {
|
||||
var movies []models.RophimMovie
|
||||
|
||||
doc.Find(".myui-vodlist__box").EachWithBreak(func(i int, s *goquery.Selection) bool {
|
||||
if i >= limit {
|
||||
return false
|
||||
}
|
||||
|
||||
link := s.Find("a.myui-vodlist__thumb")
|
||||
if link.Length() == 0 {
|
||||
link = s.Find("a[href*='/phim/']")
|
||||
}
|
||||
if link.Length() == 0 {
|
||||
return true
|
||||
}
|
||||
|
||||
href, _ := link.Attr("href")
|
||||
slug := extractSlug(href)
|
||||
if slug == "" {
|
||||
return true
|
||||
}
|
||||
|
||||
title, _ := link.Attr("title")
|
||||
if title == "" {
|
||||
title = s.Find("h4.title a").Text()
|
||||
}
|
||||
|
||||
style, _ := link.Attr("style")
|
||||
thumbnail := extractThumbnail(style)
|
||||
if thumbnail == "" {
|
||||
thumbnail, _ = s.Find("img").Attr("src")
|
||||
}
|
||||
|
||||
quality := s.Find(".pic-tag").Text()
|
||||
if quality == "" {
|
||||
quality = "HD"
|
||||
}
|
||||
|
||||
engTitle := s.Find(".text-muted").Text()
|
||||
|
||||
movie := models.RophimMovie{
|
||||
ID: slug,
|
||||
Title: strings.TrimSpace(title),
|
||||
OriginalTitle: strings.TrimSpace(engTitle),
|
||||
Slug: slug,
|
||||
Thumbnail: normalizeURL(thumbnail),
|
||||
Quality: strings.TrimSpace(quality),
|
||||
Category: "movies", // Default
|
||||
}
|
||||
movies = append(movies, movie)
|
||||
return true
|
||||
})
|
||||
|
||||
return movies
|
||||
}
|
||||
|
||||
func (s *RophimScraper) GetMovieDetail(slug string) (*models.RophimMovie, error) {
|
||||
url := fmt.Sprintf("%s/phim/%s", BaseURL, slug)
|
||||
doc, err := s.fetchDocument(url)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return s.parseMovieDetail(doc, slug), nil
|
||||
}
|
||||
|
||||
func (s *RophimScraper) parseMovieDetail(doc *goquery.Document, slug string) *models.RophimMovie {
|
||||
title := doc.Find("h1.movie-title").Text()
|
||||
if title == "" {
|
||||
title = doc.Find("h1").Text()
|
||||
}
|
||||
|
||||
description := doc.Find("meta[name='description']").AttrOr("content", "")
|
||||
if description == "" {
|
||||
description = doc.Find(".description, .content, .film-description").Text()
|
||||
}
|
||||
|
||||
poster := doc.Find("meta[property='og:image']").AttrOr("content", "")
|
||||
|
||||
// Parse Info (Year, Country, etc) - simplified for brevity
|
||||
var year int
|
||||
doc.Find(".movie-info li, .film-info li").Each(func(i int, s *goquery.Selection) {
|
||||
text := s.Text()
|
||||
if strings.Contains(text, "Năm") || strings.Contains(text, "Year") {
|
||||
re := regexp.MustCompile(`\d{4}`)
|
||||
if match := re.FindString(text); match != "" {
|
||||
year, _ = strconv.Atoi(match)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Parse Episodes
|
||||
var episodes []models.Episode
|
||||
doc.Find("a[href*='/tap-'], a[href*='episode'], .episode-list a").Each(func(i int, s *goquery.Selection) {
|
||||
href, _ := s.Attr("href")
|
||||
text := strings.TrimSpace(s.Text())
|
||||
|
||||
re := regexp.MustCompile(`tap-(\d+)`)
|
||||
match := re.FindStringSubmatch(href)
|
||||
if len(match) > 1 {
|
||||
epNum, _ := strconv.Atoi(match[1])
|
||||
episodes = append(episodes, models.Episode{
|
||||
Number: epNum,
|
||||
Title: text,
|
||||
URL: normalizeURL(href),
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// De-duplicate episodes
|
||||
seen := make(map[int]bool)
|
||||
var uniqueEpisodes []models.Episode
|
||||
for _, ep := range episodes {
|
||||
if !seen[ep.Number] {
|
||||
seen[ep.Number] = true
|
||||
uniqueEpisodes = append(uniqueEpisodes, ep)
|
||||
}
|
||||
}
|
||||
|
||||
return &models.RophimMovie{
|
||||
ID: slug,
|
||||
Title: strings.TrimSpace(title),
|
||||
Slug: slug,
|
||||
Thumbnail: normalizeURL(poster),
|
||||
Description: strings.TrimSpace(description),
|
||||
Year: year,
|
||||
Episodes: uniqueEpisodes,
|
||||
Category: "movies",
|
||||
}
|
||||
}
|
||||
|
||||
func extractSlug(url string) string {
|
||||
re := regexp.MustCompile(`/phim/([^/?#]+)`)
|
||||
matches := re.FindStringSubmatch(url)
|
||||
if len(matches) > 1 {
|
||||
return matches[1]
|
||||
}
|
||||
// Fallback
|
||||
parts := strings.Split(url, "/")
|
||||
if len(parts) > 0 {
|
||||
return parts[len(parts)-1]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func extractThumbnail(style string) string {
|
||||
re := regexp.MustCompile(`url\(([^)]+)\)`)
|
||||
matches := re.FindStringSubmatch(style)
|
||||
if len(matches) > 1 {
|
||||
return strings.Trim(matches[1], "'\"")
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func normalizeURL(url string) string {
|
||||
if url == "" {
|
||||
return ""
|
||||
}
|
||||
if strings.HasPrefix(url, "//") {
|
||||
return "https:" + url
|
||||
}
|
||||
if strings.HasPrefix(url, "/") {
|
||||
return BaseURL + url
|
||||
}
|
||||
return url
|
||||
}
|
||||
package scraper
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"streamflow-backend/internal/models"
|
||||
|
||||
"github.com/PuerkitoBio/goquery"
|
||||
)
|
||||
|
||||
const BaseURL = "https://phimmoichill.network"
|
||||
|
||||
type RophimScraper struct {
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
func NewRophimScraper() *RophimScraper {
|
||||
// Create custom client to handle SSL constraints if needed, similar to Python's ssl_context
|
||||
tr := &http.Transport{
|
||||
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
|
||||
}
|
||||
client := &http.Client{
|
||||
Transport: tr,
|
||||
Timeout: 30 * time.Second,
|
||||
}
|
||||
return &RophimScraper{client: client}
|
||||
}
|
||||
|
||||
func (s *RophimScraper) fetchDocument(url string) (*goquery.Document, error) {
|
||||
req, err := http.NewRequest("GET", url, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36")
|
||||
req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8")
|
||||
req.Header.Set("Referer", BaseURL)
|
||||
|
||||
resp, err := s.client.Do(req)
|
||||
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)
|
||||
}
|
||||
|
||||
return goquery.NewDocumentFromReader(resp.Body)
|
||||
}
|
||||
|
||||
func (s *RophimScraper) GetHomepageMovies(page int, limit int) ([]models.RophimMovie, error) {
|
||||
url := fmt.Sprintf("%s/danh-sach/phim-le", BaseURL)
|
||||
if page > 1 {
|
||||
url = fmt.Sprintf("%s/danh-sach/phim-le/page/%d", BaseURL, page)
|
||||
}
|
||||
|
||||
doc, err := s.fetchDocument(url)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return s.parseMovieGrid(doc, limit), nil
|
||||
}
|
||||
|
||||
func (s *RophimScraper) Search(query string, limit int) ([]models.RophimMovie, error) {
|
||||
url := fmt.Sprintf("%s/tim-kiem?keyword=%s", BaseURL, query)
|
||||
doc, err := s.fetchDocument(url)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.parseMovieGrid(doc, limit), nil
|
||||
}
|
||||
|
||||
func (s *RophimScraper) parseMovieGrid(doc *goquery.Document, limit int) []models.RophimMovie {
|
||||
var movies []models.RophimMovie
|
||||
|
||||
doc.Find(".myui-vodlist__box").EachWithBreak(func(i int, s *goquery.Selection) bool {
|
||||
if i >= limit {
|
||||
return false
|
||||
}
|
||||
|
||||
link := s.Find("a.myui-vodlist__thumb")
|
||||
if link.Length() == 0 {
|
||||
link = s.Find("a[href*='/phim/']")
|
||||
}
|
||||
if link.Length() == 0 {
|
||||
return true
|
||||
}
|
||||
|
||||
href, _ := link.Attr("href")
|
||||
slug := extractSlug(href)
|
||||
if slug == "" {
|
||||
return true
|
||||
}
|
||||
|
||||
title, _ := link.Attr("title")
|
||||
if title == "" {
|
||||
title = s.Find("h4.title a").Text()
|
||||
}
|
||||
|
||||
style, _ := link.Attr("style")
|
||||
thumbnail := extractThumbnail(style)
|
||||
if thumbnail == "" {
|
||||
thumbnail, _ = s.Find("img").Attr("src")
|
||||
}
|
||||
|
||||
quality := s.Find(".pic-tag").Text()
|
||||
if quality == "" {
|
||||
quality = "HD"
|
||||
}
|
||||
|
||||
engTitle := s.Find(".text-muted").Text()
|
||||
|
||||
movie := models.RophimMovie{
|
||||
ID: slug,
|
||||
Title: strings.TrimSpace(title),
|
||||
OriginalTitle: strings.TrimSpace(engTitle),
|
||||
Slug: slug,
|
||||
Thumbnail: normalizeURL(thumbnail),
|
||||
Quality: strings.TrimSpace(quality),
|
||||
Category: "movies", // Default
|
||||
}
|
||||
movies = append(movies, movie)
|
||||
return true
|
||||
})
|
||||
|
||||
return movies
|
||||
}
|
||||
|
||||
func (s *RophimScraper) GetMovieDetail(slug string) (*models.RophimMovie, error) {
|
||||
url := fmt.Sprintf("%s/phim/%s", BaseURL, slug)
|
||||
doc, err := s.fetchDocument(url)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return s.parseMovieDetail(doc, slug), nil
|
||||
}
|
||||
|
||||
func (s *RophimScraper) parseMovieDetail(doc *goquery.Document, slug string) *models.RophimMovie {
|
||||
title := doc.Find("h1.movie-title").Text()
|
||||
if title == "" {
|
||||
title = doc.Find("h1").Text()
|
||||
}
|
||||
|
||||
description := doc.Find("meta[name='description']").AttrOr("content", "")
|
||||
if description == "" {
|
||||
description = doc.Find(".description, .content, .film-description").Text()
|
||||
}
|
||||
|
||||
poster := doc.Find("meta[property='og:image']").AttrOr("content", "")
|
||||
|
||||
// Parse Info (Year, Country, etc) - simplified for brevity
|
||||
var year int
|
||||
doc.Find(".movie-info li, .film-info li").Each(func(i int, s *goquery.Selection) {
|
||||
text := s.Text()
|
||||
if strings.Contains(text, "Năm") || strings.Contains(text, "Year") {
|
||||
re := regexp.MustCompile(`\d{4}`)
|
||||
if match := re.FindString(text); match != "" {
|
||||
year, _ = strconv.Atoi(match)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Parse Episodes
|
||||
var episodes []models.Episode
|
||||
doc.Find("a[href*='/tap-'], a[href*='episode'], .episode-list a").Each(func(i int, s *goquery.Selection) {
|
||||
href, _ := s.Attr("href")
|
||||
text := strings.TrimSpace(s.Text())
|
||||
|
||||
re := regexp.MustCompile(`tap-(\d+)`)
|
||||
match := re.FindStringSubmatch(href)
|
||||
if len(match) > 1 {
|
||||
epNum, _ := strconv.Atoi(match[1])
|
||||
episodes = append(episodes, models.Episode{
|
||||
Number: epNum,
|
||||
Title: text,
|
||||
URL: normalizeURL(href),
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// De-duplicate episodes
|
||||
seen := make(map[int]bool)
|
||||
var uniqueEpisodes []models.Episode
|
||||
for _, ep := range episodes {
|
||||
if !seen[ep.Number] {
|
||||
seen[ep.Number] = true
|
||||
uniqueEpisodes = append(uniqueEpisodes, ep)
|
||||
}
|
||||
}
|
||||
|
||||
return &models.RophimMovie{
|
||||
ID: slug,
|
||||
Title: strings.TrimSpace(title),
|
||||
Slug: slug,
|
||||
Thumbnail: normalizeURL(poster),
|
||||
Description: strings.TrimSpace(description),
|
||||
Year: year,
|
||||
Episodes: uniqueEpisodes,
|
||||
Category: "movies",
|
||||
}
|
||||
}
|
||||
|
||||
func extractSlug(url string) string {
|
||||
re := regexp.MustCompile(`/phim/([^/?#]+)`)
|
||||
matches := re.FindStringSubmatch(url)
|
||||
if len(matches) > 1 {
|
||||
return matches[1]
|
||||
}
|
||||
// Fallback
|
||||
parts := strings.Split(url, "/")
|
||||
if len(parts) > 0 {
|
||||
return parts[len(parts)-1]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func extractThumbnail(style string) string {
|
||||
re := regexp.MustCompile(`url\(([^)]+)\)`)
|
||||
matches := re.FindStringSubmatch(style)
|
||||
if len(matches) > 1 {
|
||||
return strings.Trim(matches[1], "'\"")
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func normalizeURL(url string) string {
|
||||
if url == "" {
|
||||
return ""
|
||||
}
|
||||
if strings.HasPrefix(url, "//") {
|
||||
return "https:" + url
|
||||
}
|
||||
if strings.HasPrefix(url, "/") {
|
||||
return BaseURL + url
|
||||
}
|
||||
return url
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,96 +1,96 @@
|
|||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type VideoInfo struct {
|
||||
Title string `json:"title"`
|
||||
Thumbnail string `json:"thumbnail"`
|
||||
Duration int `json:"duration"`
|
||||
StreamURL string `json:"url"` // yt-dlp JSON key is 'url'
|
||||
FormatID string `json:"format_id"`
|
||||
Resolution string `json:"resolution"` // Custom field
|
||||
Ext string `json:"ext"`
|
||||
}
|
||||
|
||||
type VideoExtractor struct{}
|
||||
|
||||
func NewVideoExtractor() *VideoExtractor {
|
||||
return &VideoExtractor{}
|
||||
}
|
||||
|
||||
func (e *VideoExtractor) Extract(url string, quality string) (*VideoInfo, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// Build format selector
|
||||
formatSelector := "bestvideo+bestaudio/best"
|
||||
if quality != "" {
|
||||
height := strings.Replace(quality, "p", "", -1)
|
||||
formatSelector = fmt.Sprintf("bestvideo[height<=%s]+bestaudio/best[height<=%s]/best", height, height)
|
||||
}
|
||||
|
||||
args := []string{
|
||||
"--dump-json",
|
||||
"--no-playlist",
|
||||
"--no-warnings",
|
||||
"--format", formatSelector,
|
||||
url,
|
||||
}
|
||||
|
||||
// Check for local yt-dlp.exe
|
||||
ytDlpCmd := "yt-dlp"
|
||||
// Only on windows for simplicity or check OS
|
||||
if _, err := os.Stat("yt-dlp.exe"); err == nil {
|
||||
path, _ := filepath.Abs("yt-dlp.exe")
|
||||
ytDlpCmd = path
|
||||
}
|
||||
|
||||
cmd := exec.CommandContext(ctx, ytDlpCmd, args...)
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("extraction failed: %v", err)
|
||||
}
|
||||
|
||||
var info VideoInfo
|
||||
// yt-dlp dumps JSON. Unmarshal it.
|
||||
// Note: yt-dlp JSON has many fields, we only map the ones in VideoInfo struct
|
||||
if err := json.Unmarshal(output, &info); err != nil {
|
||||
return nil, fmt.Errorf("json parse error: %v", err)
|
||||
}
|
||||
|
||||
// Post-process resolution if not directly available or custom logic needed
|
||||
// In strict parsing, we might need a custom struct to catch 'height' and 'width' to form resolution
|
||||
// allowing dynamic map parsing for simplicity:
|
||||
var rawData map[string]interface{}
|
||||
json.Unmarshal(output, &rawData)
|
||||
|
||||
if h, ok := rawData["height"].(float64); ok {
|
||||
info.Resolution = fmt.Sprintf("%dp", int(h))
|
||||
} else {
|
||||
info.Resolution = "unknown"
|
||||
}
|
||||
|
||||
// Ensure StreamURL is populated (sometimes 'url' is the stream url)
|
||||
if info.StreamURL == "" {
|
||||
if u, ok := rawData["url"].(string); ok {
|
||||
info.StreamURL = u
|
||||
}
|
||||
}
|
||||
|
||||
return &info, nil
|
||||
}
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type VideoInfo struct {
|
||||
Title string `json:"title"`
|
||||
Thumbnail string `json:"thumbnail"`
|
||||
Duration int `json:"duration"`
|
||||
StreamURL string `json:"url"` // yt-dlp JSON key is 'url'
|
||||
FormatID string `json:"format_id"`
|
||||
Resolution string `json:"resolution"` // Custom field
|
||||
Ext string `json:"ext"`
|
||||
}
|
||||
|
||||
type VideoExtractor struct{}
|
||||
|
||||
func NewVideoExtractor() *VideoExtractor {
|
||||
return &VideoExtractor{}
|
||||
}
|
||||
|
||||
func (e *VideoExtractor) Extract(url string, quality string) (*VideoInfo, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// Build format selector
|
||||
formatSelector := "bestvideo+bestaudio/best"
|
||||
if quality != "" {
|
||||
height := strings.Replace(quality, "p", "", -1)
|
||||
formatSelector = fmt.Sprintf("bestvideo[height<=%s]+bestaudio/best[height<=%s]/best", height, height)
|
||||
}
|
||||
|
||||
args := []string{
|
||||
"--dump-json",
|
||||
"--no-playlist",
|
||||
"--no-warnings",
|
||||
"--format", formatSelector,
|
||||
url,
|
||||
}
|
||||
|
||||
// Check for local yt-dlp.exe
|
||||
ytDlpCmd := "yt-dlp"
|
||||
// Only on windows for simplicity or check OS
|
||||
if _, err := os.Stat("yt-dlp.exe"); err == nil {
|
||||
path, _ := filepath.Abs("yt-dlp.exe")
|
||||
ytDlpCmd = path
|
||||
}
|
||||
|
||||
cmd := exec.CommandContext(ctx, ytDlpCmd, args...)
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("extraction failed: %v", err)
|
||||
}
|
||||
|
||||
var info VideoInfo
|
||||
// yt-dlp dumps JSON. Unmarshal it.
|
||||
// Note: yt-dlp JSON has many fields, we only map the ones in VideoInfo struct
|
||||
if err := json.Unmarshal(output, &info); err != nil {
|
||||
return nil, fmt.Errorf("json parse error: %v", err)
|
||||
}
|
||||
|
||||
// Post-process resolution if not directly available or custom logic needed
|
||||
// In strict parsing, we might need a custom struct to catch 'height' and 'width' to form resolution
|
||||
// allowing dynamic map parsing for simplicity:
|
||||
var rawData map[string]interface{}
|
||||
json.Unmarshal(output, &rawData)
|
||||
|
||||
if h, ok := rawData["height"].(float64); ok {
|
||||
info.Resolution = fmt.Sprintf("%dp", int(h))
|
||||
} else {
|
||||
info.Resolution = "unknown"
|
||||
}
|
||||
|
||||
// Ensure StreamURL is populated (sometimes 'url' is the stream url)
|
||||
if info.StreamURL == "" {
|
||||
if u, ok := rawData["url"].(string); ok {
|
||||
info.StreamURL = u
|
||||
}
|
||||
}
|
||||
|
||||
return &info, nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,113 +1,116 @@
|
|||
package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/md5"
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"image"
|
||||
"image/jpeg"
|
||||
"image/png"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"golang.org/x/image/draw"
|
||||
)
|
||||
|
||||
const CacheDir = "cache/images"
|
||||
|
||||
type ImageService struct {
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
func NewImageService() *ImageService {
|
||||
os.MkdirAll(CacheDir, 0755)
|
||||
|
||||
// Use custom transport to skip SSL verification
|
||||
tr := &http.Transport{
|
||||
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
|
||||
}
|
||||
|
||||
return &ImageService{
|
||||
client: &http.Client{
|
||||
Transport: tr,
|
||||
Timeout: 15 * time.Second,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (s *ImageService) GetProxiedImage(url string, width int) ([]byte, string, error) {
|
||||
hash := md5.Sum([]byte(fmt.Sprintf("%s_%d", url, width)))
|
||||
cacheKey := fmt.Sprintf("%x.jpg", hash)
|
||||
cachePath := filepath.Join(CacheDir, cacheKey)
|
||||
|
||||
// Check cache
|
||||
if _, err := os.Stat(cachePath); err == nil {
|
||||
data, err := os.ReadFile(cachePath)
|
||||
if err == nil {
|
||||
return data, "image/jpeg", nil
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch with custom request to set headers
|
||||
req, err := http.NewRequest("GET", url, nil)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36")
|
||||
req.Header.Set("Referer", "https://ophim1.com/")
|
||||
|
||||
resp, err := s.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
return nil, "", fmt.Errorf("image fetch failed: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
// Decode
|
||||
var img image.Image
|
||||
contentType := resp.Header.Get("Content-Type")
|
||||
|
||||
switch contentType {
|
||||
case "image/jpeg":
|
||||
img, err = jpeg.Decode(resp.Body)
|
||||
case "image/png":
|
||||
img, err = png.Decode(resp.Body)
|
||||
default:
|
||||
// Attempt agnostic decode
|
||||
img, _, err = image.Decode(resp.Body)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("decode error: %v", err)
|
||||
}
|
||||
|
||||
// Resize if needed
|
||||
if width > 0 && img.Bounds().Dx() > width {
|
||||
bounds := img.Bounds()
|
||||
ratio := float64(width) / float64(bounds.Dx())
|
||||
height := int(float64(bounds.Dy()) * ratio)
|
||||
|
||||
dst := image.NewRGBA(image.Rect(0, 0, width, height))
|
||||
draw.CatmullRom.Scale(dst, dst.Bounds(), img, bounds, draw.Over, nil)
|
||||
img = dst
|
||||
}
|
||||
|
||||
// Encode to JPEG
|
||||
var buf bytes.Buffer
|
||||
if err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: 80}); err != nil {
|
||||
return nil, "", fmt.Errorf("jpeg encode error: %v", err)
|
||||
}
|
||||
|
||||
jpegData := buf.Bytes()
|
||||
|
||||
// Write cache
|
||||
os.WriteFile(cachePath, jpegData, 0644)
|
||||
|
||||
return jpegData, "image/jpeg", nil
|
||||
}
|
||||
package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/md5"
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"image"
|
||||
"image/jpeg"
|
||||
"image/png"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"golang.org/x/image/draw"
|
||||
)
|
||||
|
||||
const CacheDir = "cache/images"
|
||||
|
||||
type ImageService struct {
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
func NewImageService() *ImageService {
|
||||
os.MkdirAll(CacheDir, 0755)
|
||||
|
||||
// Use custom transport to skip SSL verification
|
||||
tr := &http.Transport{
|
||||
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
|
||||
}
|
||||
|
||||
return &ImageService{
|
||||
client: &http.Client{
|
||||
Transport: tr,
|
||||
Timeout: 15 * time.Second,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (s *ImageService) GetProxiedImage(url string, width int) ([]byte, string, error) {
|
||||
hash := md5.Sum([]byte(fmt.Sprintf("%s_%d", url, width)))
|
||||
cacheKey := fmt.Sprintf("%x.jpg", hash)
|
||||
cachePath := filepath.Join(CacheDir, cacheKey)
|
||||
|
||||
// Check cache
|
||||
if _, err := os.Stat(cachePath); err == nil {
|
||||
data, err := os.ReadFile(cachePath)
|
||||
if err == nil {
|
||||
return data, "image/jpeg", nil
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch with custom request to set headers
|
||||
req, err := http.NewRequest("GET", url, nil)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36")
|
||||
req.Header.Set("Referer", "https://ophim1.com/")
|
||||
|
||||
resp, err := s.client.Do(req)
|
||||
if err != nil {
|
||||
fmt.Printf("GetProxiedImage fetch error: %v\n", err)
|
||||
return nil, "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
fmt.Printf("GetProxiedImage status error: %d for url: %s\n", resp.StatusCode, url)
|
||||
return nil, "", fmt.Errorf("image fetch failed: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
// Decode
|
||||
var img image.Image
|
||||
contentType := resp.Header.Get("Content-Type")
|
||||
|
||||
switch contentType {
|
||||
case "image/jpeg":
|
||||
img, err = jpeg.Decode(resp.Body)
|
||||
case "image/png":
|
||||
img, err = png.Decode(resp.Body)
|
||||
default:
|
||||
// Attempt agnostic decode
|
||||
img, _, err = image.Decode(resp.Body)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
fmt.Printf("GetProxiedImage decode error: %v for content-type: %s and url: %s\n", err, contentType, url)
|
||||
return nil, "", fmt.Errorf("decode error: %v", err)
|
||||
}
|
||||
|
||||
// Resize if needed
|
||||
if width > 0 && img.Bounds().Dx() > width {
|
||||
bounds := img.Bounds()
|
||||
ratio := float64(width) / float64(bounds.Dx())
|
||||
height := int(float64(bounds.Dy()) * ratio)
|
||||
|
||||
dst := image.NewRGBA(image.Rect(0, 0, width, height))
|
||||
draw.CatmullRom.Scale(dst, dst.Bounds(), img, bounds, draw.Over, nil)
|
||||
img = dst
|
||||
}
|
||||
|
||||
// Encode to JPEG
|
||||
var buf bytes.Buffer
|
||||
if err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: 80}); err != nil {
|
||||
return nil, "", fmt.Errorf("jpeg encode error: %v", err)
|
||||
}
|
||||
|
||||
jpegData := buf.Bytes()
|
||||
|
||||
// Write cache
|
||||
os.WriteFile(cachePath, jpegData, 0644)
|
||||
|
||||
return jpegData, "image/jpeg", nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,137 +1,137 @@
|
|||
package service
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
TMDBBaseURL = "https://api.themoviedb.org/3"
|
||||
TMDBImageBaseURL = "https://image.tmdb.org/t/p"
|
||||
)
|
||||
|
||||
type TMDBService struct {
|
||||
client *http.Client
|
||||
apiKey string
|
||||
}
|
||||
|
||||
func NewTMDBService() *TMDBService {
|
||||
return &TMDBService{
|
||||
client: &http.Client{Timeout: 10 * time.Second},
|
||||
apiKey: os.Getenv("TMDB_API_KEY"),
|
||||
}
|
||||
}
|
||||
|
||||
type TMDBMovieResult struct {
|
||||
ID int `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Overview string `json:"overview"`
|
||||
PosterPath string `json:"poster_path"`
|
||||
BackdropPath string `json:"backdrop_path"`
|
||||
ReleaseDate string `json:"release_date"`
|
||||
VoteAverage float64 `json:"vote_average"`
|
||||
}
|
||||
|
||||
type TMDBSearchResponse struct {
|
||||
Results []TMDBMovieResult `json:"results"`
|
||||
}
|
||||
|
||||
type TMDBMovieDetails struct {
|
||||
ID int `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Overview string `json:"overview"`
|
||||
Runtime int `json:"runtime"`
|
||||
Budget int64 `json:"budget"`
|
||||
Revenue int64 `json:"revenue"`
|
||||
Tagline string `json:"tagline"`
|
||||
VoteAverage float64 `json:"vote_average"`
|
||||
PosterPath string `json:"poster_path"`
|
||||
BackdropPath string `json:"backdrop_path"`
|
||||
Credits struct {
|
||||
Cast []struct {
|
||||
Name string `json:"name"`
|
||||
Character string `json:"character"`
|
||||
ProfilePath string `json:"profile_path"`
|
||||
} `json:"cast"`
|
||||
Crew []struct {
|
||||
Name string `json:"name"`
|
||||
Job string `json:"job"`
|
||||
} `json:"crew"`
|
||||
} `json:"credits"`
|
||||
}
|
||||
|
||||
func (s *TMDBService) SearchMovie(title string, year int) (*TMDBMovieResult, error) {
|
||||
if s.apiKey == "" {
|
||||
return nil, fmt.Errorf("TMDB_API_KEY not set")
|
||||
}
|
||||
|
||||
params := url.Values{}
|
||||
params.Add("api_key", s.apiKey)
|
||||
params.Add("query", title)
|
||||
params.Add("language", "en-US")
|
||||
if year > 0 {
|
||||
params.Add("year", fmt.Sprintf("%d", year))
|
||||
}
|
||||
|
||||
resp, err := s.client.Get(fmt.Sprintf("%s/search/movie?%s", TMDBBaseURL, params.Encode()))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
return nil, fmt.Errorf("TMDB API returned status: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var searchResp TMDBSearchResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&searchResp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(searchResp.Results) > 0 {
|
||||
return &searchResp.Results[0], nil
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (s *TMDBService) GetMovieDetails(tmdbID int) (*TMDBMovieDetails, error) {
|
||||
if s.apiKey == "" {
|
||||
return nil, fmt.Errorf("TMDB_API_KEY not set")
|
||||
}
|
||||
|
||||
params := url.Values{}
|
||||
params.Add("api_key", s.apiKey)
|
||||
params.Add("append_to_response", "credits")
|
||||
params.Add("language", "en-US")
|
||||
|
||||
resp, err := s.client.Get(fmt.Sprintf("%s/movie/%d?%s", TMDBBaseURL, tmdbID, params.Encode()))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
return nil, fmt.Errorf("TMDB API returned status: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var details TMDBMovieDetails
|
||||
if err := json.NewDecoder(resp.Body).Decode(&details); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &details, nil
|
||||
}
|
||||
|
||||
func (s *TMDBService) GetPosterURL(path string, size string) string {
|
||||
if path == "" {
|
||||
return ""
|
||||
}
|
||||
if size == "" {
|
||||
size = "w500"
|
||||
}
|
||||
return fmt.Sprintf("%s/%s%s", TMDBImageBaseURL, size, path)
|
||||
}
|
||||
package service
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
TMDBBaseURL = "https://api.themoviedb.org/3"
|
||||
TMDBImageBaseURL = "https://image.tmdb.org/t/p"
|
||||
)
|
||||
|
||||
type TMDBService struct {
|
||||
client *http.Client
|
||||
apiKey string
|
||||
}
|
||||
|
||||
func NewTMDBService() *TMDBService {
|
||||
return &TMDBService{
|
||||
client: &http.Client{Timeout: 10 * time.Second},
|
||||
apiKey: os.Getenv("TMDB_API_KEY"),
|
||||
}
|
||||
}
|
||||
|
||||
type TMDBMovieResult struct {
|
||||
ID int `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Overview string `json:"overview"`
|
||||
PosterPath string `json:"poster_path"`
|
||||
BackdropPath string `json:"backdrop_path"`
|
||||
ReleaseDate string `json:"release_date"`
|
||||
VoteAverage float64 `json:"vote_average"`
|
||||
}
|
||||
|
||||
type TMDBSearchResponse struct {
|
||||
Results []TMDBMovieResult `json:"results"`
|
||||
}
|
||||
|
||||
type TMDBMovieDetails struct {
|
||||
ID int `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Overview string `json:"overview"`
|
||||
Runtime int `json:"runtime"`
|
||||
Budget int64 `json:"budget"`
|
||||
Revenue int64 `json:"revenue"`
|
||||
Tagline string `json:"tagline"`
|
||||
VoteAverage float64 `json:"vote_average"`
|
||||
PosterPath string `json:"poster_path"`
|
||||
BackdropPath string `json:"backdrop_path"`
|
||||
Credits struct {
|
||||
Cast []struct {
|
||||
Name string `json:"name"`
|
||||
Character string `json:"character"`
|
||||
ProfilePath string `json:"profile_path"`
|
||||
} `json:"cast"`
|
||||
Crew []struct {
|
||||
Name string `json:"name"`
|
||||
Job string `json:"job"`
|
||||
} `json:"crew"`
|
||||
} `json:"credits"`
|
||||
}
|
||||
|
||||
func (s *TMDBService) SearchMovie(title string, year int) (*TMDBMovieResult, error) {
|
||||
if s.apiKey == "" {
|
||||
return nil, fmt.Errorf("TMDB_API_KEY not set")
|
||||
}
|
||||
|
||||
params := url.Values{}
|
||||
params.Add("api_key", s.apiKey)
|
||||
params.Add("query", title)
|
||||
params.Add("language", "en-US")
|
||||
if year > 0 {
|
||||
params.Add("year", fmt.Sprintf("%d", year))
|
||||
}
|
||||
|
||||
resp, err := s.client.Get(fmt.Sprintf("%s/search/movie?%s", TMDBBaseURL, params.Encode()))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
return nil, fmt.Errorf("TMDB API returned status: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var searchResp TMDBSearchResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&searchResp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(searchResp.Results) > 0 {
|
||||
return &searchResp.Results[0], nil
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (s *TMDBService) GetMovieDetails(tmdbID int) (*TMDBMovieDetails, error) {
|
||||
if s.apiKey == "" {
|
||||
return nil, fmt.Errorf("TMDB_API_KEY not set")
|
||||
}
|
||||
|
||||
params := url.Values{}
|
||||
params.Add("api_key", s.apiKey)
|
||||
params.Add("append_to_response", "credits")
|
||||
params.Add("language", "en-US")
|
||||
|
||||
resp, err := s.client.Get(fmt.Sprintf("%s/movie/%d?%s", TMDBBaseURL, tmdbID, params.Encode()))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
return nil, fmt.Errorf("TMDB API returned status: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var details TMDBMovieDetails
|
||||
if err := json.NewDecoder(resp.Body).Decode(&details); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &details, nil
|
||||
}
|
||||
|
||||
func (s *TMDBService) GetPosterURL(path string, size string) string {
|
||||
if path == "" {
|
||||
return ""
|
||||
}
|
||||
if size == "" {
|
||||
size = "w500"
|
||||
}
|
||||
return fmt.Sprintf("%s/%s%s", TMDBImageBaseURL, size, path)
|
||||
}
|
||||
|
|
|
|||
62
deploy.ps1
62
deploy.ps1
|
|
@ -1,31 +1,31 @@
|
|||
# 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
|
||||
# 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.1
|
||||
image: git.khoavo.myds.me/vndangkhoa/kv-streamflow:v3.9.2
|
||||
container_name: streamflow
|
||||
platform: linux/amd64
|
||||
ports:
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
export default {
|
||||
plugins: {
|
||||
'@tailwindcss/postcss': {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
export default {
|
||||
plugins: {
|
||||
'@tailwindcss/postcss': {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
|
|
|
|||
Binary file not shown.
|
|
@ -1,284 +1,284 @@
|
|||
import { useState, useEffect } from 'react';
|
||||
import { Play, Plus, Check } from 'lucide-react';
|
||||
import type { Movie } from '../types';
|
||||
import { useMyList } from '../hooks/useMyList';
|
||||
|
||||
interface HeroProps {
|
||||
movies: Movie[];
|
||||
variant?: 'default' | 'netflix' | 'apple';
|
||||
}
|
||||
|
||||
export const Hero = ({ movies, variant = 'default' }: HeroProps) => {
|
||||
const [index, setIndex] = useState(0);
|
||||
const { addToList, removeFromList, isSaved } = useMyList();
|
||||
|
||||
// Auto-rotate carousel
|
||||
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);
|
||||
};
|
||||
|
||||
// Helper to generate robust image URLs
|
||||
const getImageUrl = (url: string | undefined, width: number, blur: number = 0) => {
|
||||
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`;
|
||||
};
|
||||
|
||||
// --- Variant-Specific Styles ---
|
||||
|
||||
// 1. Apple Variant (Glassmorphism, Bottom-Aligned)
|
||||
if (variant === 'apple') {
|
||||
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={getImageUrl(movie.backdrop || movie.thumbnail, 1600)}
|
||||
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);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<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 (Bottom Center) */}
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
// 2. Netflix Variant (Left-Aligned, Sidebar-Aware if needed, Top-10 Badge)
|
||||
if (variant === 'netflix') {
|
||||
return (
|
||||
<div className="relative h-[85vh] w-full overflow-hidden group">
|
||||
<div className="absolute inset-0 transition-opacity duration-1000 ease-in-out">
|
||||
<img
|
||||
key={movie.id}
|
||||
src={getImageUrl(movie.backdrop || movie.thumbnail, 1600)}
|
||||
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);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<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 (Vertical Right Side - Classic Netflix) */}
|
||||
<div className="absolute right-12 bottom-1/3 flex flex-col gap-2 z-20 hidden md:flex">
|
||||
{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-600 hover:bg-gray-400'}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 3. Default (StreamFlow) Variant - Split Poster Design (Solves Quality & Sizing)
|
||||
return (
|
||||
<div className="relative w-full h-[60vh] md:h-[70vh] lg:h-[75vh] min-h-[500px] overflow-hidden group">
|
||||
{/* 1. Ambient Background (Blurred) */}
|
||||
<div className="absolute inset-0 bg-[#0a0a0a]">
|
||||
<img
|
||||
key={`bg-${movie.id}`}
|
||||
// Use thumbnail as safe default since we blur it anyway
|
||||
src={getImageUrl(movie.thumbnail || movie.backdrop, 1000)}
|
||||
alt="Background"
|
||||
referrerPolicy="no-referrer"
|
||||
onError={(e) => {
|
||||
if (movie.thumbnail && e.currentTarget.src !== getImageUrl(movie.thumbnail, 1000)) {
|
||||
e.currentTarget.src = getImageUrl(movie.thumbnail, 1000);
|
||||
}
|
||||
}}
|
||||
className="w-full h-full object-cover opacity-50 scale-110 blur-xl" // CSS Blur instead of API blur
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-[#141414] via-[#141414]/60 to-transparent" />
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-[#141414] via-[#141414]/80 to-transparent" />
|
||||
</div>
|
||||
|
||||
{/* 2. Main Content Layout */}
|
||||
<div className="absolute inset-0 z-10 container mx-auto px-4 sm:px-8 lg:px-12 flex items-center">
|
||||
<div className="flex w-full items-center gap-8 lg:gap-16">
|
||||
|
||||
{/* Left Column: Text Info */}
|
||||
<div className="w-full md:w-3/5 lg:w-1/2 space-y-6 pt-10 md:pt-0">
|
||||
{/* Wrapper for text to ensure readability */}
|
||||
<div className="relative z-10">
|
||||
<div className="flex items-center gap-3 mb-4 animate-slide-up">
|
||||
<div className="flex items-center gap-2 bg-gradient-to-r from-cyan-600 to-blue-600 text-white text-[10px] font-bold px-3 py-1 rounded shadow-lg shadow-cyan-500/20">
|
||||
<span>TOP 10</span>
|
||||
</div>
|
||||
<span className="text-gray-300 text-lg font-bold tracking-wide">#{index + 1} in Movies Today</span>
|
||||
</div>
|
||||
|
||||
<h1 className="mt-4 text-3xl sm:text-4xl md:text-5xl lg:text-6xl font-bold text-white leading-tight tracking-tight drop-shadow-2xl line-clamp-2 animate-slide-up" style={{ animationDelay: '100ms' }}>
|
||||
{movie.title}
|
||||
</h1>
|
||||
|
||||
<div className="mt-4 flex items-center gap-4 text-gray-300 text-sm md:text-base animate-slide-up" style={{ animationDelay: '200ms' }}>
|
||||
<span className="text-green-400 font-bold">98% Match</span>
|
||||
<span className="w-1 h-1 bg-gray-600 rounded-full" />
|
||||
<span>{movie.year || '2024'}</span>
|
||||
<span className="w-1 h-1 bg-gray-600 rounded-full" />
|
||||
<span className="border border-gray-600 px-1.5 py-0.5 rounded text-xs">HD</span>
|
||||
{movie.original_title && (
|
||||
<>
|
||||
<span className="w-1 h-1 bg-gray-600 rounded-full" />
|
||||
<span className="italic opacity-80 truncate max-w-[200px]">{movie.original_title}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-8 flex items-center gap-4 animate-slide-up" style={{ animationDelay: '300ms' }}>
|
||||
<a
|
||||
href={`/watch/${movie.slug}`}
|
||||
className="bg-white text-black px-8 py-3.5 rounded-xl font-bold text-sm md:text-base tracking-wide hover:scale-105 transition-transform duration-200 flex items-center gap-2 shadow-lg shadow-white/10"
|
||||
>
|
||||
<Play className="w-5 h-5 fill-current" />
|
||||
Watch Now
|
||||
</a>
|
||||
<button
|
||||
onClick={toggleList}
|
||||
className="bg-white/10 backdrop-blur-md text-white px-8 py-3.5 rounded-xl font-bold text-sm md:text-base tracking-wide border border-white/10 hover:bg-white/20 transition-colors flex items-center gap-2"
|
||||
>
|
||||
{saved ? <Check className="w-5 h-5" /> : <Plus className="w-5 h-5" />}
|
||||
My List
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right Column: Sharp Poster (Desktop Only) */}
|
||||
<div className="hidden md:flex w-2/5 lg:w-1/2 justify-center lg:justify-end animate-fade-in delay-200">
|
||||
<div className="relative group/poster">
|
||||
{/* Glow Effect */}
|
||||
<div className="absolute -inset-1 bg-gradient-to-tr from-cyan-500 to-blue-600 rounded-2xl blur opacity-20 group-hover/poster:opacity-40 transition duration-500" />
|
||||
|
||||
<img
|
||||
key={`poster-${movie.id}`}
|
||||
src={getImageUrl(movie.thumbnail || movie.backdrop, 600)}
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Indicators */}
|
||||
<div className="absolute bottom-6 right-8 lg:bottom-10 lg:right-12 flex gap-2 z-20">
|
||||
{movies.map((_, i) => (
|
||||
<button
|
||||
key={i}
|
||||
onClick={() => setIndex(i)}
|
||||
className={`transition-all duration-300 rounded-full ${i === index
|
||||
? 'w-6 h-1.5 bg-cyan-500 shadow-lg shadow-cyan-500/50'
|
||||
: 'w-1.5 h-1.5 bg-gray-600 hover:bg-white/50'
|
||||
}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Play, Plus, Check } from 'lucide-react';
|
||||
import type { Movie } from '../types';
|
||||
import { useMyList } from '../hooks/useMyList';
|
||||
|
||||
interface HeroProps {
|
||||
movies: Movie[];
|
||||
variant?: 'default' | 'netflix' | 'apple';
|
||||
}
|
||||
|
||||
export const Hero = ({ movies, variant = 'default' }: HeroProps) => {
|
||||
const [index, setIndex] = useState(0);
|
||||
const { addToList, removeFromList, isSaved } = useMyList();
|
||||
|
||||
// Auto-rotate carousel
|
||||
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);
|
||||
};
|
||||
|
||||
// Helper to generate robust image URLs
|
||||
const getImageUrl = (url: string | undefined, width: number, blur: number = 0) => {
|
||||
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`;
|
||||
};
|
||||
|
||||
// --- Variant-Specific Styles ---
|
||||
|
||||
// 1. Apple Variant (Glassmorphism, Bottom-Aligned)
|
||||
if (variant === 'apple') {
|
||||
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={getImageUrl(movie.backdrop || movie.thumbnail, 1600)}
|
||||
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);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<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 (Bottom Center) */}
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
// 2. Netflix Variant (Left-Aligned, Sidebar-Aware if needed, Top-10 Badge)
|
||||
if (variant === 'netflix') {
|
||||
return (
|
||||
<div className="relative h-[85vh] w-full overflow-hidden group">
|
||||
<div className="absolute inset-0 transition-opacity duration-1000 ease-in-out">
|
||||
<img
|
||||
key={movie.id}
|
||||
src={getImageUrl(movie.backdrop || movie.thumbnail, 1600)}
|
||||
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);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<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 (Vertical Right Side - Classic Netflix) */}
|
||||
<div className="absolute right-12 bottom-1/3 flex flex-col gap-2 z-20 hidden md:flex">
|
||||
{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-600 hover:bg-gray-400'}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 3. Default (StreamFlow) Variant - Split Poster Design (Solves Quality & Sizing)
|
||||
return (
|
||||
<div className="relative w-full h-[60vh] md:h-[70vh] lg:h-[75vh] min-h-[500px] overflow-hidden group">
|
||||
{/* 1. Ambient Background (Blurred) */}
|
||||
<div className="absolute inset-0 bg-[#0a0a0a]">
|
||||
<img
|
||||
key={`bg-${movie.id}`}
|
||||
// Use thumbnail as safe default since we blur it anyway
|
||||
src={getImageUrl(movie.thumbnail || movie.backdrop, 1000)}
|
||||
alt="Background"
|
||||
referrerPolicy="no-referrer"
|
||||
onError={(e) => {
|
||||
if (movie.thumbnail && e.currentTarget.src !== getImageUrl(movie.thumbnail, 1000)) {
|
||||
e.currentTarget.src = getImageUrl(movie.thumbnail, 1000);
|
||||
}
|
||||
}}
|
||||
className="w-full h-full object-cover opacity-50 scale-110 blur-xl" // CSS Blur instead of API blur
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-[#141414] via-[#141414]/60 to-transparent" />
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-[#141414] via-[#141414]/80 to-transparent" />
|
||||
</div>
|
||||
|
||||
{/* 2. Main Content Layout */}
|
||||
<div className="absolute inset-0 z-10 container mx-auto px-4 sm:px-8 lg:px-12 flex items-center">
|
||||
<div className="flex w-full items-center gap-8 lg:gap-16">
|
||||
|
||||
{/* Left Column: Text Info */}
|
||||
<div className="w-full md:w-3/5 lg:w-1/2 space-y-6 pt-10 md:pt-0">
|
||||
{/* Wrapper for text to ensure readability */}
|
||||
<div className="relative z-10">
|
||||
<div className="flex items-center gap-3 mb-4 animate-slide-up">
|
||||
<div className="flex items-center gap-2 bg-gradient-to-r from-cyan-600 to-blue-600 text-white text-[10px] font-bold px-3 py-1 rounded shadow-lg shadow-cyan-500/20">
|
||||
<span>TOP 10</span>
|
||||
</div>
|
||||
<span className="text-gray-300 text-lg font-bold tracking-wide">#{index + 1} in Movies Today</span>
|
||||
</div>
|
||||
|
||||
<h1 className="mt-4 text-3xl sm:text-4xl md:text-5xl lg:text-6xl font-bold text-white leading-tight tracking-tight drop-shadow-2xl line-clamp-2 animate-slide-up" style={{ animationDelay: '100ms' }}>
|
||||
{movie.title}
|
||||
</h1>
|
||||
|
||||
<div className="mt-4 flex items-center gap-4 text-gray-300 text-sm md:text-base animate-slide-up" style={{ animationDelay: '200ms' }}>
|
||||
<span className="text-green-400 font-bold">98% Match</span>
|
||||
<span className="w-1 h-1 bg-gray-600 rounded-full" />
|
||||
<span>{movie.year || '2024'}</span>
|
||||
<span className="w-1 h-1 bg-gray-600 rounded-full" />
|
||||
<span className="border border-gray-600 px-1.5 py-0.5 rounded text-xs">HD</span>
|
||||
{movie.original_title && (
|
||||
<>
|
||||
<span className="w-1 h-1 bg-gray-600 rounded-full" />
|
||||
<span className="italic opacity-80 truncate max-w-[200px]">{movie.original_title}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-8 flex items-center gap-4 animate-slide-up" style={{ animationDelay: '300ms' }}>
|
||||
<a
|
||||
href={`/watch/${movie.slug}`}
|
||||
className="bg-white text-black px-8 py-3.5 rounded-xl font-bold text-sm md:text-base tracking-wide hover:scale-105 transition-transform duration-200 flex items-center gap-2 shadow-lg shadow-white/10"
|
||||
>
|
||||
<Play className="w-5 h-5 fill-current" />
|
||||
Watch Now
|
||||
</a>
|
||||
<button
|
||||
onClick={toggleList}
|
||||
className="bg-white/10 backdrop-blur-md text-white px-8 py-3.5 rounded-xl font-bold text-sm md:text-base tracking-wide border border-white/10 hover:bg-white/20 transition-colors flex items-center gap-2"
|
||||
>
|
||||
{saved ? <Check className="w-5 h-5" /> : <Plus className="w-5 h-5" />}
|
||||
My List
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right Column: Sharp Poster (Desktop Only) */}
|
||||
<div className="hidden md:flex w-2/5 lg:w-1/2 justify-center lg:justify-end animate-fade-in delay-200">
|
||||
<div className="relative group/poster">
|
||||
{/* Glow Effect */}
|
||||
<div className="absolute -inset-1 bg-gradient-to-tr from-cyan-500 to-blue-600 rounded-2xl blur opacity-20 group-hover/poster:opacity-40 transition duration-500" />
|
||||
|
||||
<img
|
||||
key={`poster-${movie.id}`}
|
||||
src={getImageUrl(movie.thumbnail || movie.backdrop, 600)}
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Indicators */}
|
||||
<div className="absolute bottom-6 right-8 lg:bottom-10 lg:right-12 flex gap-2 z-20">
|
||||
{movies.map((_, i) => (
|
||||
<button
|
||||
key={i}
|
||||
onClick={() => setIndex(i)}
|
||||
className={`transition-all duration-300 rounded-full ${i === index
|
||||
? 'w-6 h-1.5 bg-cyan-500 shadow-lg shadow-cyan-500/50'
|
||||
: 'w-1.5 h-1.5 bg-gray-600 hover:bg-white/50'
|
||||
}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,217 +1,217 @@
|
|||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import type { Movie } from '../types';
|
||||
import MovieRow from './MovieRow';
|
||||
import { MovieCard } from './MovieCard';
|
||||
import { CATEGORIES } from '../constants';
|
||||
|
||||
import { useMyList } from '../hooks/useMyList';
|
||||
import { useSmartRecommendations } from '../hooks/useSmartRecommendations';
|
||||
|
||||
interface HomeContentProps {
|
||||
topPadding?: string;
|
||||
}
|
||||
|
||||
export const HomeContent = ({ topPadding = "pt-24" }: HomeContentProps) => {
|
||||
const [movies, setMovies] = useState<Movie[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [fetchingMore, setFetchingMore] = useState(false);
|
||||
const [page, setPage] = useState(1);
|
||||
const [hasMore, setHasMore] = useState(true);
|
||||
|
||||
const { watchHistory, savedMovies } = useMyList(); // Access History and MyList
|
||||
const [searchParams] = useSearchParams();
|
||||
const query = searchParams.get('q');
|
||||
const category = searchParams.get('category');
|
||||
|
||||
// Filtered view if search or specific category is selected
|
||||
const isFiltered = !!(query || (category && category !== 'home'));
|
||||
|
||||
// On main home page, we show rows AND infinite grid at bottom
|
||||
// If filtered, we ONLY show the grid
|
||||
const showRows = !isFiltered;
|
||||
|
||||
const observer = useRef<IntersectionObserver | null>(null);
|
||||
|
||||
// ... (rest of useEffects same as before) ...
|
||||
|
||||
// Reset grid when query/category changes
|
||||
useEffect(() => {
|
||||
setMovies([]);
|
||||
setPage(1);
|
||||
setHasMore(true);
|
||||
setLoading(true);
|
||||
}, [query, category]);
|
||||
|
||||
// Fetch movies for the Infinite Grid
|
||||
useEffect(() => {
|
||||
const fetchMovies = async () => {
|
||||
if (page === 1) setLoading(true);
|
||||
else setFetchingMore(true);
|
||||
|
||||
try {
|
||||
let endpoint = '/api/videos/home';
|
||||
if (query) {
|
||||
endpoint = `/api/videos/search?q=${query}&page=${page}`;
|
||||
} else if (category && category !== 'home') {
|
||||
endpoint = `/api/videos/home?category=${category}&page=${page}`;
|
||||
} else {
|
||||
endpoint = `/api/videos/home?page=${page}`;
|
||||
}
|
||||
|
||||
const res = await fetch(endpoint);
|
||||
if (!res.ok) {
|
||||
throw new Error(`HTTP error! status: ${res.status}`);
|
||||
}
|
||||
const data = await res.json();
|
||||
|
||||
if (!data || data.length === 0) {
|
||||
setHasMore(false);
|
||||
} else {
|
||||
setMovies(prev => {
|
||||
if (page === 1) return data;
|
||||
// Deduplicate arrays when appending to prevent React StrictMode or fast-scroll double fetches
|
||||
const existingIds = new Set(prev.map(m => m.id));
|
||||
const newUniqueMovies = data.filter((m: Movie) => !existingIds.has(m.id));
|
||||
return [...prev, ...newUniqueMovies];
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
console.error("Failed to fetch movies");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setFetchingMore(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchMovies();
|
||||
}, [page, query, category]);
|
||||
|
||||
// Sentinel observer for infinite scroll
|
||||
const lastElementRef = useCallback((node: HTMLDivElement) => {
|
||||
if (loading || fetchingMore) return;
|
||||
if (observer.current) observer.current.disconnect();
|
||||
|
||||
observer.current = new IntersectionObserver(entries => {
|
||||
if (entries[0].isIntersecting && hasMore) {
|
||||
setPage(prevPage => prevPage + 1);
|
||||
}
|
||||
});
|
||||
|
||||
if (node) observer.current.observe(node);
|
||||
}, [loading, fetchingMore, hasMore]);
|
||||
|
||||
const getTitle = () => {
|
||||
if (query) return `Kết quả cho "${query}"`;
|
||||
if (category === 'phim-le') return 'Phim Lẻ';
|
||||
if (category === 'phim-bo') return 'Phim Bộ';
|
||||
if (category === 'hoat-hinh') return 'Hoạt Hình';
|
||||
if (category === 'tv-shows') return 'TV Shows';
|
||||
if (category === 'phim-sap-chieu') return 'Phim Sắp Chiếu';
|
||||
if (category === 'phim-hay') return 'Phim Hay';
|
||||
if (category) return 'Danh Sách Phim';
|
||||
return 'Tất Cả Phim';
|
||||
};
|
||||
|
||||
// Calculate Smart Suggestions based on last watched item
|
||||
const lastWatched = watchHistory.length > 0 ? watchHistory[0] : null;
|
||||
|
||||
// Get Category-based recommendations
|
||||
const recommendations = useSmartRecommendations(watchHistory);
|
||||
|
||||
return (
|
||||
<div className={`w-full px-4 sm:px-6 lg:px-12 pb-12 ${topPadding}`}>
|
||||
{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} />
|
||||
)}
|
||||
|
||||
{/* My List Row */}
|
||||
{savedMovies.length > 0 && (
|
||||
<MovieRow title="Danh sách của tôi" movies={savedMovies} />
|
||||
)}
|
||||
|
||||
{/* Smart Category Recommendations */}
|
||||
{recommendations.map(rec => (
|
||||
<MovieRow
|
||||
key={rec.id}
|
||||
title={rec.title}
|
||||
category={rec.category}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Smart Suggestions using SEARCH API */}
|
||||
{lastWatched && (
|
||||
<>
|
||||
{lastWatched.director && (
|
||||
<MovieRow
|
||||
title={`Đạo diễn ${lastWatched.director.replace(/,$/, '').trim()}`}
|
||||
searchQuery={lastWatched.director.replace(/,$/, '').trim()}
|
||||
key={`dir-${lastWatched.id}`}
|
||||
/>
|
||||
)}
|
||||
{lastWatched.cast && lastWatched.cast.length > 0 && (
|
||||
<MovieRow
|
||||
title={`Diễn viên ${lastWatched.cast[0].replace(/,$/, '').trim()}`}
|
||||
searchQuery={lastWatched.cast[0].replace(/,$/, '').trim()}
|
||||
key={`act-${lastWatched.id}`}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Phim Mới Horizontal Carousel */}
|
||||
<MovieRow title="Phim Mới Cập Nhật" category="home" />
|
||||
|
||||
{/* Top 10 Grids for each Category */}
|
||||
{CATEGORIES.filter(c => c.id !== 'my-list').map((cat) => (
|
||||
<MovieRow
|
||||
key={cat.id}
|
||||
title={`Top 10 ${cat.name}`}
|
||||
category={cat.id}
|
||||
limit={10}
|
||||
// layout="row" is default
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Other Curated Sections */}
|
||||
<MovieRow title="Hành Động & Phiêu Lưu" category="hanh-dong" />
|
||||
<MovieRow title="Tâm Lý & Tình Cảm" category="tinh-cam" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Infinite Scroll Grid */}
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold mb-8 flex items-center gap-3 text-white">
|
||||
<span className="w-1.5 h-8 bg-cyan-500 rounded-full"></span>
|
||||
{getTitle()}
|
||||
</h2>
|
||||
|
||||
<div className="grid grid-cols-3 min-[400px]:grid-cols-4 md:grid-cols-5 lg:grid-cols-6 gap-2 md:gap-4">
|
||||
{movies.map((movie, index) => (
|
||||
<MovieCard key={`${movie.id}-${index}`} movie={movie} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Sentinel element for infinite scroll */}
|
||||
<div ref={lastElementRef} className="h-10 w-full" />
|
||||
|
||||
{loading && (
|
||||
<div className="grid grid-cols-3 min-[400px]:grid-cols-4 md:grid-cols-5 lg:grid-cols-6 gap-2 md:gap-4 mt-4">
|
||||
{[...Array(12)].map((_, i) => (
|
||||
<div key={i} className="aspect-[2/3] bg-white/5 rounded-lg animate-pulse" />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && movies.length === 0 && (
|
||||
<div className="text-center py-12 text-gray-400">
|
||||
Không tìm thấy phim nào.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import type { Movie } from '../types';
|
||||
import MovieRow from './MovieRow';
|
||||
import { MovieCard } from './MovieCard';
|
||||
import { CATEGORIES } from '../constants';
|
||||
|
||||
import { useMyList } from '../hooks/useMyList';
|
||||
import { useSmartRecommendations } from '../hooks/useSmartRecommendations';
|
||||
|
||||
interface HomeContentProps {
|
||||
topPadding?: string;
|
||||
}
|
||||
|
||||
export const HomeContent = ({ topPadding = "pt-24" }: HomeContentProps) => {
|
||||
const [movies, setMovies] = useState<Movie[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [fetchingMore, setFetchingMore] = useState(false);
|
||||
const [page, setPage] = useState(1);
|
||||
const [hasMore, setHasMore] = useState(true);
|
||||
|
||||
const { watchHistory, savedMovies } = useMyList(); // Access History and MyList
|
||||
const [searchParams] = useSearchParams();
|
||||
const query = searchParams.get('q');
|
||||
const category = searchParams.get('category');
|
||||
|
||||
// Filtered view if search or specific category is selected
|
||||
const isFiltered = !!(query || (category && category !== 'home'));
|
||||
|
||||
// On main home page, we show rows AND infinite grid at bottom
|
||||
// If filtered, we ONLY show the grid
|
||||
const showRows = !isFiltered;
|
||||
|
||||
const observer = useRef<IntersectionObserver | null>(null);
|
||||
|
||||
// ... (rest of useEffects same as before) ...
|
||||
|
||||
// Reset grid when query/category changes
|
||||
useEffect(() => {
|
||||
setMovies([]);
|
||||
setPage(1);
|
||||
setHasMore(true);
|
||||
setLoading(true);
|
||||
}, [query, category]);
|
||||
|
||||
// Fetch movies for the Infinite Grid
|
||||
useEffect(() => {
|
||||
const fetchMovies = async () => {
|
||||
if (page === 1) setLoading(true);
|
||||
else setFetchingMore(true);
|
||||
|
||||
try {
|
||||
let endpoint = '/api/videos/home';
|
||||
if (query) {
|
||||
endpoint = `/api/videos/search?q=${query}&page=${page}`;
|
||||
} else if (category && category !== 'home') {
|
||||
endpoint = `/api/videos/home?category=${category}&page=${page}`;
|
||||
} else {
|
||||
endpoint = `/api/videos/home?page=${page}`;
|
||||
}
|
||||
|
||||
const res = await fetch(endpoint);
|
||||
if (!res.ok) {
|
||||
throw new Error(`HTTP error! status: ${res.status}`);
|
||||
}
|
||||
const data = await res.json();
|
||||
|
||||
if (!data || data.length === 0) {
|
||||
setHasMore(false);
|
||||
} else {
|
||||
setMovies(prev => {
|
||||
if (page === 1) return data;
|
||||
// Deduplicate arrays when appending to prevent React StrictMode or fast-scroll double fetches
|
||||
const existingIds = new Set(prev.map(m => m.id));
|
||||
const newUniqueMovies = data.filter((m: Movie) => !existingIds.has(m.id));
|
||||
return [...prev, ...newUniqueMovies];
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
console.error("Failed to fetch movies");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setFetchingMore(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchMovies();
|
||||
}, [page, query, category]);
|
||||
|
||||
// Sentinel observer for infinite scroll
|
||||
const lastElementRef = useCallback((node: HTMLDivElement) => {
|
||||
if (loading || fetchingMore) return;
|
||||
if (observer.current) observer.current.disconnect();
|
||||
|
||||
observer.current = new IntersectionObserver(entries => {
|
||||
if (entries[0].isIntersecting && hasMore) {
|
||||
setPage(prevPage => prevPage + 1);
|
||||
}
|
||||
});
|
||||
|
||||
if (node) observer.current.observe(node);
|
||||
}, [loading, fetchingMore, hasMore]);
|
||||
|
||||
const getTitle = () => {
|
||||
if (query) return `Kết quả cho "${query}"`;
|
||||
if (category === 'phim-le') return 'Phim Lẻ';
|
||||
if (category === 'phim-bo') return 'Phim Bộ';
|
||||
if (category === 'hoat-hinh') return 'Hoạt Hình';
|
||||
if (category === 'tv-shows') return 'TV Shows';
|
||||
if (category === 'phim-sap-chieu') return 'Phim Sắp Chiếu';
|
||||
if (category === 'phim-hay') return 'Phim Hay';
|
||||
if (category) return 'Danh Sách Phim';
|
||||
return 'Tất Cả Phim';
|
||||
};
|
||||
|
||||
// Calculate Smart Suggestions based on last watched item
|
||||
const lastWatched = watchHistory.length > 0 ? watchHistory[0] : null;
|
||||
|
||||
// Get Category-based recommendations
|
||||
const recommendations = useSmartRecommendations(watchHistory);
|
||||
|
||||
return (
|
||||
<div className={`w-full px-4 sm:px-6 lg:px-12 pb-12 ${topPadding}`}>
|
||||
{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} />
|
||||
)}
|
||||
|
||||
{/* My List Row */}
|
||||
{savedMovies.length > 0 && (
|
||||
<MovieRow title="Danh sách của tôi" movies={savedMovies} />
|
||||
)}
|
||||
|
||||
{/* Smart Category Recommendations */}
|
||||
{recommendations.map(rec => (
|
||||
<MovieRow
|
||||
key={rec.id}
|
||||
title={rec.title}
|
||||
category={rec.category}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Smart Suggestions using SEARCH API */}
|
||||
{lastWatched && (
|
||||
<>
|
||||
{lastWatched.director && (
|
||||
<MovieRow
|
||||
title={`Đạo diễn ${lastWatched.director.replace(/,$/, '').trim()}`}
|
||||
searchQuery={lastWatched.director.replace(/,$/, '').trim()}
|
||||
key={`dir-${lastWatched.id}`}
|
||||
/>
|
||||
)}
|
||||
{lastWatched.cast && lastWatched.cast.length > 0 && (
|
||||
<MovieRow
|
||||
title={`Diễn viên ${lastWatched.cast[0].replace(/,$/, '').trim()}`}
|
||||
searchQuery={lastWatched.cast[0].replace(/,$/, '').trim()}
|
||||
key={`act-${lastWatched.id}`}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Phim Mới Horizontal Carousel */}
|
||||
<MovieRow title="Phim Mới Cập Nhật" category="home" />
|
||||
|
||||
{/* Top 10 Grids for each Category */}
|
||||
{CATEGORIES.filter(c => c.id !== 'my-list').map((cat) => (
|
||||
<MovieRow
|
||||
key={cat.id}
|
||||
title={`Top 10 ${cat.name}`}
|
||||
category={cat.id}
|
||||
limit={10}
|
||||
// layout="row" is default
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Other Curated Sections */}
|
||||
<MovieRow title="Hành Động & Phiêu Lưu" category="hanh-dong" />
|
||||
<MovieRow title="Tâm Lý & Tình Cảm" category="tinh-cam" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Infinite Scroll Grid */}
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold mb-8 flex items-center gap-3 text-white">
|
||||
<span className="w-1.5 h-8 bg-cyan-500 rounded-full"></span>
|
||||
{getTitle()}
|
||||
</h2>
|
||||
|
||||
<div className="grid grid-cols-3 min-[400px]:grid-cols-4 md:grid-cols-5 lg:grid-cols-6 gap-2 md:gap-4">
|
||||
{movies.map((movie, index) => (
|
||||
<MovieCard key={`${movie.id}-${index}`} movie={movie} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Sentinel element for infinite scroll */}
|
||||
<div ref={lastElementRef} className="h-10 w-full" />
|
||||
|
||||
{loading && (
|
||||
<div className="grid grid-cols-3 min-[400px]:grid-cols-4 md:grid-cols-5 lg:grid-cols-6 gap-2 md:gap-4 mt-4">
|
||||
{[...Array(12)].map((_, i) => (
|
||||
<div key={i} className="aspect-[2/3] bg-white/5 rounded-lg animate-pulse" />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && movies.length === 0 && (
|
||||
<div className="text-center py-12 text-gray-400">
|
||||
Không tìm thấy phim nào.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,89 +1,89 @@
|
|||
import { Link } from 'react-router-dom';
|
||||
import { Play } from 'lucide-react';
|
||||
import type { Movie } from '../types';
|
||||
|
||||
interface MovieCardProps {
|
||||
movie: Movie;
|
||||
className?: string;
|
||||
isDragging?: boolean;
|
||||
}
|
||||
|
||||
export const MovieCard = ({ movie, className = '', isDragging = false }: MovieCardProps) => {
|
||||
const getImageUrl = (url: string, width: number) => {
|
||||
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`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`group/card relative flex flex-col h-full ${className}`}>
|
||||
{/* Poster Image Container */}
|
||||
<Link
|
||||
to={`/watch/${movie.slug}`}
|
||||
className={`block relative aspect-[2/3] rounded-xl overflow-hidden bg-[#1a1a1a] shadow-lg transition-all duration-500 hover:shadow-cyan-500/10 ${isDragging ? 'pointer-events-none' : ''
|
||||
}`}
|
||||
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}
|
||||
/>
|
||||
|
||||
{/* 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">
|
||||
<div className="bg-white/20 backdrop-blur-md p-4 rounded-full translate-y-8 group-hover/card:translate-y-0 transition-all duration-500 shadow-xl border border-white/10">
|
||||
<Play className="w-6 h-6 text-white fill-current" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Top-Left Tag (Provider) */}
|
||||
{movie.provider && (
|
||||
<div className="absolute top-2 left-2">
|
||||
<div className="bg-black/60 backdrop-blur-md px-1.5 py-0.5 rounded text-[9px] font-bold border border-white/10 text-gray-300 uppercase tracking-tighter">
|
||||
{movie.provider}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Top-Right Tags (Quality & Lang) */}
|
||||
<div className="absolute top-2 right-2 flex flex-col gap-1.5 items-end">
|
||||
{movie.quality && (
|
||||
<div className="bg-cyan-500/90 backdrop-blur-md px-1.5 py-0.5 rounded text-[10px] font-bold text-black uppercase tracking-wider shadow-lg border border-cyan-400/20">
|
||||
{movie.quality}
|
||||
</div>
|
||||
)}
|
||||
{movie.lang && (
|
||||
<div className="bg-black/60 backdrop-blur-md px-1.5 py-0.5 rounded text-[10px] font-bold border border-white/10 text-gray-200">
|
||||
{movie.lang}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Bottom Status (Time / Episode Info) */}
|
||||
{movie.time && (
|
||||
<div className="absolute bottom-2 left-2 right-2 flex items-center gap-2">
|
||||
<div className="bg-black/70 backdrop-blur-xl px-2 py-1 rounded-md text-[10px] font-semibold border border-white/10 text-white flex items-center gap-1.5 shadow-2xl">
|
||||
<span className="w-1.5 h-1.5 bg-red-500 rounded-full animate-pulse shadow-[0_0_5px_rgba(239,68,68,0.8)]"></span>
|
||||
{movie.time}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Link>
|
||||
|
||||
{/* Info Section */}
|
||||
<div className="mt-3 px-1">
|
||||
<h3 className="font-semibold text-white text-sm leading-snug line-clamp-2 group-hover/card:text-cyan-400 transition-colors duration-300">
|
||||
{movie.title}
|
||||
</h3>
|
||||
{movie.year && (
|
||||
<p className="text-[11px] text-gray-500 mt-1 font-medium tracking-wide translate-y-0 opacity-100 transition-all">
|
||||
{movie.year} • 98% Match
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Play } from 'lucide-react';
|
||||
import type { Movie } from '../types';
|
||||
|
||||
interface MovieCardProps {
|
||||
movie: Movie;
|
||||
className?: string;
|
||||
isDragging?: boolean;
|
||||
}
|
||||
|
||||
export const MovieCard = ({ movie, className = '', isDragging = false }: MovieCardProps) => {
|
||||
const getImageUrl = (url: string, width: number) => {
|
||||
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`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`group/card relative flex flex-col h-full ${className}`}>
|
||||
{/* Poster Image Container */}
|
||||
<Link
|
||||
to={`/watch/${movie.slug}`}
|
||||
className={`block relative aspect-[2/3] rounded-xl overflow-hidden bg-[#1a1a1a] shadow-lg transition-all duration-500 hover:shadow-cyan-500/10 ${isDragging ? 'pointer-events-none' : ''
|
||||
}`}
|
||||
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}
|
||||
/>
|
||||
|
||||
{/* 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">
|
||||
<div className="bg-white/20 backdrop-blur-md p-4 rounded-full translate-y-8 group-hover/card:translate-y-0 transition-all duration-500 shadow-xl border border-white/10">
|
||||
<Play className="w-6 h-6 text-white fill-current" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Top-Left Tag (Provider) */}
|
||||
{movie.provider && (
|
||||
<div className="absolute top-2 left-2">
|
||||
<div className="bg-black/60 backdrop-blur-md px-1.5 py-0.5 rounded text-[9px] font-bold border border-white/10 text-gray-300 uppercase tracking-tighter">
|
||||
{movie.provider}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Top-Right Tags (Quality & Lang) */}
|
||||
<div className="absolute top-2 right-2 flex flex-col gap-1.5 items-end">
|
||||
{movie.quality && (
|
||||
<div className="bg-cyan-500/90 backdrop-blur-md px-1.5 py-0.5 rounded text-[10px] font-bold text-black uppercase tracking-wider shadow-lg border border-cyan-400/20">
|
||||
{movie.quality}
|
||||
</div>
|
||||
)}
|
||||
{movie.lang && (
|
||||
<div className="bg-black/60 backdrop-blur-md px-1.5 py-0.5 rounded text-[10px] font-bold border border-white/10 text-gray-200">
|
||||
{movie.lang}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Bottom Status (Time / Episode Info) */}
|
||||
{movie.time && (
|
||||
<div className="absolute bottom-2 left-2 right-2 flex items-center gap-2">
|
||||
<div className="bg-black/70 backdrop-blur-xl px-2 py-1 rounded-md text-[10px] font-semibold border border-white/10 text-white flex items-center gap-1.5 shadow-2xl">
|
||||
<span className="w-1.5 h-1.5 bg-red-500 rounded-full animate-pulse shadow-[0_0_5px_rgba(239,68,68,0.8)]"></span>
|
||||
{movie.time}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Link>
|
||||
|
||||
{/* Info Section */}
|
||||
<div className="mt-3 px-1">
|
||||
<h3 className="font-semibold text-white text-sm leading-snug line-clamp-2 group-hover/card:text-cyan-400 transition-colors duration-300">
|
||||
{movie.title}
|
||||
</h3>
|
||||
{movie.year && (
|
||||
<p className="text-[11px] text-gray-500 mt-1 font-medium tracking-wide translate-y-0 opacity-100 transition-all">
|
||||
{movie.year} • 98% Match
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,201 +1,201 @@
|
|||
import { useEffect, useState, useRef } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { ChevronLeft, ChevronRight } from 'lucide-react';
|
||||
import type { Movie } from '../types';
|
||||
import { MovieCard } from './MovieCard';
|
||||
|
||||
interface MovieRowProps {
|
||||
title: string;
|
||||
category?: string;
|
||||
searchQuery?: string;
|
||||
limit?: number;
|
||||
layout?: 'row' | 'grid';
|
||||
movies?: Movie[];
|
||||
}
|
||||
|
||||
const MovieRow = ({ title, category, searchQuery, limit, layout = 'row', movies: manualMovies }: MovieRowProps) => {
|
||||
const [movies, setMovies] = useState<Movie[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const rowRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Drag to scroll logic state
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
|
||||
// Drag to scroll logic state refs
|
||||
const isDown = useRef(false);
|
||||
const startX = useRef(0);
|
||||
const scrollLeft = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchMovies = async () => {
|
||||
// If manual movies are provided (e.g. History, My List), use them directly
|
||||
if (manualMovies) {
|
||||
let result = manualMovies;
|
||||
if (limit && result.length > 0) {
|
||||
result = result.slice(0, limit);
|
||||
}
|
||||
setMovies(result);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
let endpoint = '';
|
||||
if (searchQuery) { // ... unchanged fetch logic
|
||||
endpoint = `/api/videos/search?q=${encodeURIComponent(searchQuery)}`;
|
||||
} else if (category && category !== 'home') {
|
||||
endpoint = `/api/videos/home?category=${category}`;
|
||||
} else {
|
||||
endpoint = '/api/videos/home';
|
||||
}
|
||||
|
||||
const res = await fetch(endpoint);
|
||||
const data = await res.json();
|
||||
let result = data || [];
|
||||
|
||||
// Search API usually returns unfiltered list, so we might need to be careful.
|
||||
// But generally it returns an array of movies.
|
||||
|
||||
if (limit && result.length > 0) {
|
||||
result = result.slice(0, limit);
|
||||
}
|
||||
setMovies(result);
|
||||
} catch {
|
||||
console.error(`Failed to fetch movies for row ${title}`);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
fetchMovies();
|
||||
}, [category, searchQuery, limit, manualMovies]);
|
||||
|
||||
const scroll = (direction: 'left' | 'right') => {
|
||||
if (rowRef.current) {
|
||||
const { current } = rowRef;
|
||||
const scrollAmount = direction === 'left' ? -current.clientWidth * 0.8 : current.clientWidth * 0.8;
|
||||
current.scrollBy({ left: scrollAmount, behavior: 'smooth' });
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) return (
|
||||
<div className="mb-8 space-y-4">
|
||||
<div className="h-6 w-48 bg-white/5 rounded animate-pulse" />
|
||||
{layout === 'row' ? (
|
||||
<div className="flex gap-4 overflow-hidden">
|
||||
{[...Array(6)].map((_, i) => (
|
||||
<div key={i} className="min-w-[160px] md:min-w-[200px] aspect-[2/3] bg-white/5 rounded-xl animate-pulse" />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-3 min-[480px]:grid-cols-4 md:grid-cols-5 lg:grid-cols-6 gap-2 md:gap-4">
|
||||
{[...Array(12)].map((_, i) => (
|
||||
<div key={i} className="aspect-[2/3] bg-white/5 rounded-lg animate-pulse" />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
// Drag to scroll logic handlers
|
||||
|
||||
// Drag to scroll logic handlers
|
||||
|
||||
const handlePointerDown = (e: React.PointerEvent) => {
|
||||
// Only enable custom drag for mouse. Touch uses native browser scroll.
|
||||
if (e.pointerType !== 'mouse' || !rowRef.current) return;
|
||||
|
||||
isDown.current = true;
|
||||
startX.current = e.pageX - rowRef.current.offsetLeft;
|
||||
scrollLeft.current = rowRef.current.scrollLeft;
|
||||
};
|
||||
|
||||
const handlePointerUp = (e: React.PointerEvent) => {
|
||||
if (!isDown.current) return;
|
||||
|
||||
isDown.current = false;
|
||||
if (isDragging) {
|
||||
setIsDragging(false);
|
||||
e.currentTarget.releasePointerCapture(e.pointerId);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePointerMove = (e: React.PointerEvent) => {
|
||||
if (!isDown.current || !rowRef.current) return;
|
||||
|
||||
e.preventDefault();
|
||||
const x = e.pageX - rowRef.current.offsetLeft;
|
||||
const walk = (x - startX.current) * 2; // Scroll-fast
|
||||
|
||||
// Only trigger dragging state if moved significantly to prevent accidental clicks being blocked
|
||||
if (Math.abs(x - startX.current) > 5) {
|
||||
if (!isDragging) {
|
||||
setIsDragging(true);
|
||||
e.currentTarget.setPointerCapture(e.pointerId);
|
||||
}
|
||||
}
|
||||
|
||||
if (isDragging) {
|
||||
rowRef.current.scrollLeft = scrollLeft.current - walk;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
|
||||
if (movies.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="mb-10 group/row relative">
|
||||
<h2 className="text-lg md:text-xl font-bold mb-4 text-white flex items-center gap-3">
|
||||
<span className="w-1.5 h-6 bg-cyan-500 rounded-full"></span>
|
||||
{title}
|
||||
<Link to={`/?category=${category}`} className="text-[10px] font-normal text-gray-500 hover:text-cyan-400 ml-2 transition-colors uppercase tracking-wider">
|
||||
Xem tất cả
|
||||
</Link>
|
||||
</h2>
|
||||
|
||||
{layout === 'row' ? (
|
||||
<div className="relative group">
|
||||
<button
|
||||
onClick={() => scroll('left')}
|
||||
className="hidden md:flex absolute left-0 top-0 bottom-0 z-20 w-12 items-center justify-center bg-transparent group-hover:bg-gradient-to-r group-hover:from-black/80 group-hover:to-transparent transition-all duration-300 opacity-0 group-hover:opacity-100"
|
||||
>
|
||||
<ChevronLeft size={40} className="text-white drop-shadow-lg" strokeWidth={1} />
|
||||
</button>
|
||||
|
||||
<div
|
||||
ref={rowRef}
|
||||
className={`flex gap-2 md:gap-4 overflow-x-auto px-4 md:px-12 pb-4 scrollbar-hide select-none overscroll-x-contain ${isDragging ? 'cursor-grabbing snap-none' : 'cursor-grab snap-x snap-mandatory'}`}
|
||||
style={{ scrollbarWidth: 'none', msOverflowStyle: 'none' }}
|
||||
onPointerDown={handlePointerDown}
|
||||
onPointerUp={handlePointerUp}
|
||||
onPointerMove={handlePointerMove}
|
||||
>
|
||||
{movies.map((movie) => (
|
||||
<div key={movie.id} className="w-[110px] sm:w-[150px] md:w-[180px] lg:w-[200px] flex-shrink-0 snap-start">
|
||||
<MovieCard
|
||||
movie={movie}
|
||||
isDragging={isDragging}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => scroll('right')}
|
||||
className="hidden md:flex absolute right-0 top-0 bottom-0 z-20 w-12 items-center justify-center bg-transparent group-hover:bg-gradient-to-l group-hover:from-black/80 group-hover:to-transparent transition-all duration-300 opacity-0 group-hover:opacity-100"
|
||||
>
|
||||
<ChevronRight size={40} className="text-white drop-shadow-lg" strokeWidth={1} />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-3 min-[480px]:grid-cols-4 md:grid-cols-5 lg:grid-cols-6 gap-2 md:gap-4">
|
||||
{movies.map((movie) => (
|
||||
<MovieCard key={movie.id} movie={movie} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div >
|
||||
);
|
||||
};
|
||||
|
||||
export default MovieRow;
|
||||
import { useEffect, useState, useRef } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { ChevronLeft, ChevronRight } from 'lucide-react';
|
||||
import type { Movie } from '../types';
|
||||
import { MovieCard } from './MovieCard';
|
||||
|
||||
interface MovieRowProps {
|
||||
title: string;
|
||||
category?: string;
|
||||
searchQuery?: string;
|
||||
limit?: number;
|
||||
layout?: 'row' | 'grid';
|
||||
movies?: Movie[];
|
||||
}
|
||||
|
||||
const MovieRow = ({ title, category, searchQuery, limit, layout = 'row', movies: manualMovies }: MovieRowProps) => {
|
||||
const [movies, setMovies] = useState<Movie[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const rowRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Drag to scroll logic state
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
|
||||
// Drag to scroll logic state refs
|
||||
const isDown = useRef(false);
|
||||
const startX = useRef(0);
|
||||
const scrollLeft = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchMovies = async () => {
|
||||
// If manual movies are provided (e.g. History, My List), use them directly
|
||||
if (manualMovies) {
|
||||
let result = manualMovies;
|
||||
if (limit && result.length > 0) {
|
||||
result = result.slice(0, limit);
|
||||
}
|
||||
setMovies(result);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
let endpoint = '';
|
||||
if (searchQuery) { // ... unchanged fetch logic
|
||||
endpoint = `/api/videos/search?q=${encodeURIComponent(searchQuery)}`;
|
||||
} else if (category && category !== 'home') {
|
||||
endpoint = `/api/videos/home?category=${category}`;
|
||||
} else {
|
||||
endpoint = '/api/videos/home';
|
||||
}
|
||||
|
||||
const res = await fetch(endpoint);
|
||||
const data = await res.json();
|
||||
let result = data || [];
|
||||
|
||||
// Search API usually returns unfiltered list, so we might need to be careful.
|
||||
// But generally it returns an array of movies.
|
||||
|
||||
if (limit && result.length > 0) {
|
||||
result = result.slice(0, limit);
|
||||
}
|
||||
setMovies(result);
|
||||
} catch {
|
||||
console.error(`Failed to fetch movies for row ${title}`);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
fetchMovies();
|
||||
}, [category, searchQuery, limit, manualMovies]);
|
||||
|
||||
const scroll = (direction: 'left' | 'right') => {
|
||||
if (rowRef.current) {
|
||||
const { current } = rowRef;
|
||||
const scrollAmount = direction === 'left' ? -current.clientWidth * 0.8 : current.clientWidth * 0.8;
|
||||
current.scrollBy({ left: scrollAmount, behavior: 'smooth' });
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) return (
|
||||
<div className="mb-8 space-y-4">
|
||||
<div className="h-6 w-48 bg-white/5 rounded animate-pulse" />
|
||||
{layout === 'row' ? (
|
||||
<div className="flex gap-4 overflow-hidden">
|
||||
{[...Array(6)].map((_, i) => (
|
||||
<div key={i} className="min-w-[160px] md:min-w-[200px] aspect-[2/3] bg-white/5 rounded-xl animate-pulse" />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-3 min-[480px]:grid-cols-4 md:grid-cols-5 lg:grid-cols-6 gap-2 md:gap-4">
|
||||
{[...Array(12)].map((_, i) => (
|
||||
<div key={i} className="aspect-[2/3] bg-white/5 rounded-lg animate-pulse" />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
// Drag to scroll logic handlers
|
||||
|
||||
// Drag to scroll logic handlers
|
||||
|
||||
const handlePointerDown = (e: React.PointerEvent) => {
|
||||
// Only enable custom drag for mouse. Touch uses native browser scroll.
|
||||
if (e.pointerType !== 'mouse' || !rowRef.current) return;
|
||||
|
||||
isDown.current = true;
|
||||
startX.current = e.pageX - rowRef.current.offsetLeft;
|
||||
scrollLeft.current = rowRef.current.scrollLeft;
|
||||
};
|
||||
|
||||
const handlePointerUp = (e: React.PointerEvent) => {
|
||||
if (!isDown.current) return;
|
||||
|
||||
isDown.current = false;
|
||||
if (isDragging) {
|
||||
setIsDragging(false);
|
||||
e.currentTarget.releasePointerCapture(e.pointerId);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePointerMove = (e: React.PointerEvent) => {
|
||||
if (!isDown.current || !rowRef.current) return;
|
||||
|
||||
e.preventDefault();
|
||||
const x = e.pageX - rowRef.current.offsetLeft;
|
||||
const walk = (x - startX.current) * 2; // Scroll-fast
|
||||
|
||||
// Only trigger dragging state if moved significantly to prevent accidental clicks being blocked
|
||||
if (Math.abs(x - startX.current) > 5) {
|
||||
if (!isDragging) {
|
||||
setIsDragging(true);
|
||||
e.currentTarget.setPointerCapture(e.pointerId);
|
||||
}
|
||||
}
|
||||
|
||||
if (isDragging) {
|
||||
rowRef.current.scrollLeft = scrollLeft.current - walk;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
|
||||
if (movies.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="mb-10 group/row relative">
|
||||
<h2 className="text-lg md:text-xl font-bold mb-4 text-white flex items-center gap-3">
|
||||
<span className="w-1.5 h-6 bg-cyan-500 rounded-full"></span>
|
||||
{title}
|
||||
<Link to={`/?category=${category}`} className="text-[10px] font-normal text-gray-500 hover:text-cyan-400 ml-2 transition-colors uppercase tracking-wider">
|
||||
Xem tất cả
|
||||
</Link>
|
||||
</h2>
|
||||
|
||||
{layout === 'row' ? (
|
||||
<div className="relative group">
|
||||
<button
|
||||
onClick={() => scroll('left')}
|
||||
className="hidden md:flex absolute left-0 top-0 bottom-0 z-20 w-12 items-center justify-center bg-transparent group-hover:bg-gradient-to-r group-hover:from-black/80 group-hover:to-transparent transition-all duration-300 opacity-0 group-hover:opacity-100"
|
||||
>
|
||||
<ChevronLeft size={40} className="text-white drop-shadow-lg" strokeWidth={1} />
|
||||
</button>
|
||||
|
||||
<div
|
||||
ref={rowRef}
|
||||
className={`flex gap-2 md:gap-4 overflow-x-auto px-4 md:px-12 pb-4 scrollbar-hide select-none overscroll-x-contain ${isDragging ? 'cursor-grabbing snap-none' : 'cursor-grab snap-x snap-mandatory'}`}
|
||||
style={{ scrollbarWidth: 'none', msOverflowStyle: 'none' }}
|
||||
onPointerDown={handlePointerDown}
|
||||
onPointerUp={handlePointerUp}
|
||||
onPointerMove={handlePointerMove}
|
||||
>
|
||||
{movies.map((movie) => (
|
||||
<div key={movie.id} className="w-[110px] sm:w-[150px] md:w-[180px] lg:w-[200px] flex-shrink-0 snap-start">
|
||||
<MovieCard
|
||||
movie={movie}
|
||||
isDragging={isDragging}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => scroll('right')}
|
||||
className="hidden md:flex absolute right-0 top-0 bottom-0 z-20 w-12 items-center justify-center bg-transparent group-hover:bg-gradient-to-l group-hover:from-black/80 group-hover:to-transparent transition-all duration-300 opacity-0 group-hover:opacity-100"
|
||||
>
|
||||
<ChevronRight size={40} className="text-white drop-shadow-lg" strokeWidth={1} />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-3 min-[480px]:grid-cols-4 md:grid-cols-5 lg:grid-cols-6 gap-2 md:gap-4">
|
||||
{movies.map((movie) => (
|
||||
<MovieCard key={movie.id} movie={movie} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div >
|
||||
);
|
||||
};
|
||||
|
||||
export default MovieRow;
|
||||
|
|
|
|||
|
|
@ -1,157 +1,157 @@
|
|||
import React, { useState } from 'react';
|
||||
import { Link, useNavigate, useLocation } from 'react-router-dom';
|
||||
import { Search, Film, Menu, X } from 'lucide-react';
|
||||
import { NAV_ITEMS } from '../constants'; // Unified Categories
|
||||
|
||||
const Navbar = () => {
|
||||
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
|
||||
// Helper to check active state
|
||||
const isActive = (path: string) => {
|
||||
if (path === '/') return location.pathname === '/' && !location.search;
|
||||
return location.pathname + location.search === path;
|
||||
};
|
||||
|
||||
const handleSearch = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (searchQuery.trim()) {
|
||||
navigate(`/?q=${encodeURIComponent(searchQuery)}`);
|
||||
setIsMenuOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<nav className="fixed top-0 w-full z-50 bg-[#141414]/95 backdrop-blur-md border-b border-white/5 font-sans">
|
||||
<div className="w-full px-4 sm:px-6 lg:px-12">
|
||||
<div className="flex items-center justify-between h-16">
|
||||
<div className="flex items-center gap-8">
|
||||
<Link to="/" className="flex items-center gap-2 group">
|
||||
<div className="bg-gradient-to-tr from-red-600 to-red-800 p-2 rounded-lg group-hover:shadow-[0_0_15px_rgba(220,38,38,0.5)] transition-all duration-300">
|
||||
<Film className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
<span className="text-xl font-bold bg-clip-text text-transparent bg-gradient-to-r from-red-600 to-red-400 tracking-tighter uppercase">
|
||||
kv-netflix
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
<div className="hidden lg:flex items-center gap-6">
|
||||
{/* Unified Links */}
|
||||
{NAV_ITEMS.map((item) => (
|
||||
<Link
|
||||
key={item.name}
|
||||
to={item.path}
|
||||
className={`text-sm font-medium transition-colors ${isActive(item.path)
|
||||
? 'text-white'
|
||||
: 'text-gray-300 hover:text-cyan-400'
|
||||
}`}
|
||||
>
|
||||
{item.name}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="hidden md:block flex-1 max-w-xs mx-8">
|
||||
<form onSubmit={handleSearch} className="relative group">
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder="Tìm kiếm..."
|
||||
className="w-full bg-white/5 border border-white/10 rounded-full py-2 pl-10 pr-4 text-sm text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-cyan-500/50 focus:border-cyan-500/50 transition-all duration-300 focus:bg-white/10"
|
||||
/>
|
||||
<Search className="absolute left-3 top-2.5 w-4 h-4 text-gray-500 group-focus-within:text-cyan-400 transition-colors" />
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* Install App Button */}
|
||||
<a
|
||||
href="/streamflow-tv.apk"
|
||||
download="streamflow-tv.apk"
|
||||
className="hidden sm:flex items-center gap-2 px-4 py-2 bg-gradient-to-r from-cyan-600 to-blue-700 hover:from-cyan-500 hover:to-blue-600 text-white text-sm font-bold rounded-full shadow-lg shadow-cyan-900/20 hover:shadow-cyan-500/40 transition-all duration-300 active:scale-95 border border-white/10"
|
||||
>
|
||||
<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>Install App</span>
|
||||
</a>
|
||||
|
||||
<div className="lg:hidden">
|
||||
<button
|
||||
onClick={() => setIsMenuOpen(!isMenuOpen)}
|
||||
className="text-gray-300 hover:text-white p-2"
|
||||
>
|
||||
{isMenuOpen ? <X size={24} /> : <Menu size={24} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isMenuOpen && (
|
||||
<div className="lg:hidden bg-[#1a1a1a] border-b border-white/10 max-h-[80vh] overflow-y-auto">
|
||||
<div className="px-4 pt-2 pb-4 space-y-3">
|
||||
{/* Mobile Install App Button */}
|
||||
<a
|
||||
href="/streamflow-tv.apk"
|
||||
download="streamflow-tv.apk"
|
||||
className="flex items-center justify-center gap-2 w-full py-3 bg-gradient-to-r from-cyan-600 to-blue-700 text-white font-bold rounded-lg mb-4"
|
||||
onClick={() => setIsMenuOpen(false)}
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2.5"
|
||||
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>
|
||||
<span>Download Android TV App</span>
|
||||
</a>
|
||||
|
||||
<form onSubmit={handleSearch} className="relative mb-4">
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder="Tìm kiếm..."
|
||||
className="w-full bg-white/5 border border-white/10 rounded-lg py-2 pl-10 pr-4 text-white"
|
||||
/>
|
||||
<Search className="absolute left-3 top-2.5 w-4 h-4 text-gray-400" />
|
||||
</form>
|
||||
|
||||
{NAV_ITEMS.map((item) => (
|
||||
<Link
|
||||
key={item.name}
|
||||
to={item.path}
|
||||
onClick={() => setIsMenuOpen(false)}
|
||||
className={`block px-3 py-2 rounded-md text-base font-medium hover:bg-white/10 ${isActive(item.path) ? 'text-white bg-white/5' : 'text-gray-300'}`}
|
||||
>
|
||||
{item.name}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</nav>
|
||||
);
|
||||
};
|
||||
|
||||
export default Navbar;
|
||||
import React, { useState } from 'react';
|
||||
import { Link, useNavigate, useLocation } from 'react-router-dom';
|
||||
import { Search, Film, Menu, X } from 'lucide-react';
|
||||
import { NAV_ITEMS } from '../constants'; // Unified Categories
|
||||
|
||||
const Navbar = () => {
|
||||
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
|
||||
// Helper to check active state
|
||||
const isActive = (path: string) => {
|
||||
if (path === '/') return location.pathname === '/' && !location.search;
|
||||
return location.pathname + location.search === path;
|
||||
};
|
||||
|
||||
const handleSearch = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (searchQuery.trim()) {
|
||||
navigate(`/?q=${encodeURIComponent(searchQuery)}`);
|
||||
setIsMenuOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<nav className="fixed top-0 w-full z-50 bg-[#141414]/95 backdrop-blur-md border-b border-white/5 font-sans">
|
||||
<div className="w-full px-4 sm:px-6 lg:px-12">
|
||||
<div className="flex items-center justify-between h-16">
|
||||
<div className="flex items-center gap-8">
|
||||
<Link to="/" className="flex items-center gap-2 group">
|
||||
<div className="bg-gradient-to-tr from-red-600 to-red-800 p-2 rounded-lg group-hover:shadow-[0_0_15px_rgba(220,38,38,0.5)] transition-all duration-300">
|
||||
<Film className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
<span className="text-xl font-bold bg-clip-text text-transparent bg-gradient-to-r from-red-600 to-red-400 tracking-tighter uppercase">
|
||||
kv-netflix
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
<div className="hidden lg:flex items-center gap-6">
|
||||
{/* Unified Links */}
|
||||
{NAV_ITEMS.map((item) => (
|
||||
<Link
|
||||
key={item.name}
|
||||
to={item.path}
|
||||
className={`text-sm font-medium transition-colors ${isActive(item.path)
|
||||
? 'text-white'
|
||||
: 'text-gray-300 hover:text-cyan-400'
|
||||
}`}
|
||||
>
|
||||
{item.name}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="hidden md:block flex-1 max-w-xs mx-8">
|
||||
<form onSubmit={handleSearch} className="relative group">
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder="Tìm kiếm..."
|
||||
className="w-full bg-white/5 border border-white/10 rounded-full py-2 pl-10 pr-4 text-sm text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-cyan-500/50 focus:border-cyan-500/50 transition-all duration-300 focus:bg-white/10"
|
||||
/>
|
||||
<Search className="absolute left-3 top-2.5 w-4 h-4 text-gray-500 group-focus-within:text-cyan-400 transition-colors" />
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* Install App Button */}
|
||||
<a
|
||||
href="/streamflow-tv.apk"
|
||||
download="streamflow-tv.apk"
|
||||
className="hidden sm:flex items-center gap-2 px-4 py-2 bg-gradient-to-r from-cyan-600 to-blue-700 hover:from-cyan-500 hover:to-blue-600 text-white text-sm font-bold rounded-full shadow-lg shadow-cyan-900/20 hover:shadow-cyan-500/40 transition-all duration-300 active:scale-95 border border-white/10"
|
||||
>
|
||||
<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>Install App</span>
|
||||
</a>
|
||||
|
||||
<div className="lg:hidden">
|
||||
<button
|
||||
onClick={() => setIsMenuOpen(!isMenuOpen)}
|
||||
className="text-gray-300 hover:text-white p-2"
|
||||
>
|
||||
{isMenuOpen ? <X size={24} /> : <Menu size={24} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isMenuOpen && (
|
||||
<div className="lg:hidden bg-[#1a1a1a] border-b border-white/10 max-h-[80vh] overflow-y-auto">
|
||||
<div className="px-4 pt-2 pb-4 space-y-3">
|
||||
{/* Mobile Install App Button */}
|
||||
<a
|
||||
href="/streamflow-tv.apk"
|
||||
download="streamflow-tv.apk"
|
||||
className="flex items-center justify-center gap-2 w-full py-3 bg-gradient-to-r from-cyan-600 to-blue-700 text-white font-bold rounded-lg mb-4"
|
||||
onClick={() => setIsMenuOpen(false)}
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2.5"
|
||||
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>
|
||||
<span>Download Android TV App</span>
|
||||
</a>
|
||||
|
||||
<form onSubmit={handleSearch} className="relative mb-4">
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder="Tìm kiếm..."
|
||||
className="w-full bg-white/5 border border-white/10 rounded-lg py-2 pl-10 pr-4 text-white"
|
||||
/>
|
||||
<Search className="absolute left-3 top-2.5 w-4 h-4 text-gray-400" />
|
||||
</form>
|
||||
|
||||
{NAV_ITEMS.map((item) => (
|
||||
<Link
|
||||
key={item.name}
|
||||
to={item.path}
|
||||
onClick={() => setIsMenuOpen(false)}
|
||||
className={`block px-3 py-2 rounded-md text-base font-medium hover:bg-white/10 ${isActive(item.path) ? 'text-white bg-white/5' : 'text-gray-300'}`}
|
||||
>
|
||||
{item.name}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</nav>
|
||||
);
|
||||
};
|
||||
|
||||
export default Navbar;
|
||||
|
|
|
|||
|
|
@ -1,88 +1,88 @@
|
|||
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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
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,14 +1,14 @@
|
|||
import { Home, Film, Tv, PlayCircle, Heart, Folder } from 'lucide-react';
|
||||
|
||||
export const CATEGORIES = [
|
||||
{ id: 'phim-le', name: 'Phim Lẻ', path: '?category=phim-le', icon: Film },
|
||||
{ id: 'phim-bo', name: 'Phim Bộ', path: '?category=phim-bo', icon: Tv },
|
||||
{ id: 'hoat-hinh', name: 'Hoạt Hình', path: '?category=hoat-hinh', icon: PlayCircle },
|
||||
{ id: 'tv-shows', name: 'TV Shows', path: '?category=tv-shows', icon: Folder },
|
||||
{ id: 'my-list', name: 'My List', path: '/my-list', icon: Heart },
|
||||
];
|
||||
|
||||
export const NAV_ITEMS = [
|
||||
{ name: 'Home', path: '/', icon: Home },
|
||||
...CATEGORIES.map(cat => ({ name: cat.name, path: cat.path, icon: cat.icon })),
|
||||
];
|
||||
import { Home, Film, Tv, PlayCircle, Heart, Folder } from 'lucide-react';
|
||||
|
||||
export const CATEGORIES = [
|
||||
{ id: 'phim-le', name: 'Phim Lẻ', path: '?category=phim-le', icon: Film },
|
||||
{ id: 'phim-bo', name: 'Phim Bộ', path: '?category=phim-bo', icon: Tv },
|
||||
{ id: 'hoat-hinh', name: 'Hoạt Hình', path: '?category=hoat-hinh', icon: PlayCircle },
|
||||
{ id: 'tv-shows', name: 'TV Shows', path: '?category=tv-shows', icon: Folder },
|
||||
{ id: 'my-list', name: 'My List', path: '/my-list', icon: Heart },
|
||||
];
|
||||
|
||||
export const NAV_ITEMS = [
|
||||
{ name: 'Home', path: '/', icon: Home },
|
||||
...CATEGORIES.map(cat => ({ name: cat.name, path: cat.path, icon: cat.icon })),
|
||||
];
|
||||
|
|
|
|||
|
|
@ -1,43 +1,43 @@
|
|||
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;
|
||||
};
|
||||
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,49 +1,49 @@
|
|||
import { useState, useEffect } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import type { Movie } from '../types';
|
||||
|
||||
export const useMovies = () => {
|
||||
const [movies, setMovies] = useState<Movie[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [searchParams] = useSearchParams();
|
||||
const query = searchParams.get('q');
|
||||
const category = searchParams.get('category');
|
||||
|
||||
useEffect(() => {
|
||||
const fetchMovies = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
let endpoint = '/api/videos/home';
|
||||
if (query) {
|
||||
endpoint = `/api/videos/search?q=${query}`;
|
||||
} else if (category && category !== 'home') {
|
||||
endpoint = `/api/videos/home?category=${category}`;
|
||||
}
|
||||
|
||||
const res = await fetch(endpoint);
|
||||
if (!res.ok) {
|
||||
throw new Error(`HTTP error! status: ${res.status}`);
|
||||
}
|
||||
const data = await res.json();
|
||||
setMovies(data || []);
|
||||
} catch {
|
||||
console.error("Failed to fetch movies");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchMovies();
|
||||
}, [query, category]);
|
||||
|
||||
const getTitle = () => {
|
||||
if (query) return `Results for "${query}"`;
|
||||
if (category === 'phim-le') return 'Movies';
|
||||
if (category === 'phim-bo') return 'Series';
|
||||
if (category === 'hoat-hinh') return 'Cartoons';
|
||||
if (category === 'tv-shows') return 'TV Shows';
|
||||
return 'Latest Movies';
|
||||
};
|
||||
|
||||
return { movies, loading, title: getTitle() };
|
||||
};
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import type { Movie } from '../types';
|
||||
|
||||
export const useMovies = () => {
|
||||
const [movies, setMovies] = useState<Movie[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [searchParams] = useSearchParams();
|
||||
const query = searchParams.get('q');
|
||||
const category = searchParams.get('category');
|
||||
|
||||
useEffect(() => {
|
||||
const fetchMovies = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
let endpoint = '/api/videos/home';
|
||||
if (query) {
|
||||
endpoint = `/api/videos/search?q=${query}`;
|
||||
} else if (category && category !== 'home') {
|
||||
endpoint = `/api/videos/home?category=${category}`;
|
||||
}
|
||||
|
||||
const res = await fetch(endpoint);
|
||||
if (!res.ok) {
|
||||
throw new Error(`HTTP error! status: ${res.status}`);
|
||||
}
|
||||
const data = await res.json();
|
||||
setMovies(data || []);
|
||||
} catch {
|
||||
console.error("Failed to fetch movies");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchMovies();
|
||||
}, [query, category]);
|
||||
|
||||
const getTitle = () => {
|
||||
if (query) return `Results for "${query}"`;
|
||||
if (category === 'phim-le') return 'Movies';
|
||||
if (category === 'phim-bo') return 'Series';
|
||||
if (category === 'hoat-hinh') return 'Cartoons';
|
||||
if (category === 'tv-shows') return 'TV Shows';
|
||||
return 'Latest Movies';
|
||||
};
|
||||
|
||||
return { movies, loading, title: getTitle() };
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,61 +1,61 @@
|
|||
import { useState, useEffect } from 'react';
|
||||
import type { Movie } from '../types';
|
||||
|
||||
interface MyListState {
|
||||
saved: Movie[];
|
||||
history: Movie[];
|
||||
}
|
||||
|
||||
export const useMyList = () => {
|
||||
const [list, setList] = useState<MyListState>(() => {
|
||||
const saved = localStorage.getItem('streamflow_mylist');
|
||||
return saved ? JSON.parse(saved) : { saved: [], history: [] };
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem('streamflow_mylist', JSON.stringify(list));
|
||||
}, [list]);
|
||||
|
||||
const addToList = (movie: Movie) => {
|
||||
setList(prev => {
|
||||
if (prev.saved.some(m => m.id === movie.id)) return prev;
|
||||
return { ...prev, saved: [movie, ...prev.saved] };
|
||||
});
|
||||
};
|
||||
|
||||
const removeFromList = (movieId: string) => {
|
||||
setList(prev => ({
|
||||
...prev,
|
||||
saved: prev.saved.filter(m => m.id !== movieId)
|
||||
}));
|
||||
};
|
||||
|
||||
const addToHistory = (movie: Movie) => {
|
||||
setList(prev => {
|
||||
const filtered = prev.history.filter(m => m.id !== movie.id);
|
||||
|
||||
// Normalize Category to ensure it works with Recommendations
|
||||
let cat = movie.category?.toLowerCase() || 'phim-le';
|
||||
if (cat === 'movies') cat = 'phim-le';
|
||||
if (cat === 'series') cat = 'phim-bo';
|
||||
if (cat === 'animation') cat = 'hoat-hinh';
|
||||
if (cat === 'cartoon') cat = 'hoat-hinh';
|
||||
if (cat === 'tv') cat = 'tv-shows';
|
||||
|
||||
const normalizedMovie = { ...movie, category: cat };
|
||||
|
||||
return { ...prev, history: [normalizedMovie, ...filtered].slice(0, 50) };
|
||||
});
|
||||
};
|
||||
|
||||
const isSaved = (movieId: string) => list.saved.some(m => m.id === movieId);
|
||||
|
||||
return {
|
||||
savedMovies: list.saved,
|
||||
watchHistory: list.history,
|
||||
addToList,
|
||||
removeFromList,
|
||||
addToHistory,
|
||||
isSaved
|
||||
};
|
||||
};
|
||||
import { useState, useEffect } from 'react';
|
||||
import type { Movie } from '../types';
|
||||
|
||||
interface MyListState {
|
||||
saved: Movie[];
|
||||
history: Movie[];
|
||||
}
|
||||
|
||||
export const useMyList = () => {
|
||||
const [list, setList] = useState<MyListState>(() => {
|
||||
const saved = localStorage.getItem('streamflow_mylist');
|
||||
return saved ? JSON.parse(saved) : { saved: [], history: [] };
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem('streamflow_mylist', JSON.stringify(list));
|
||||
}, [list]);
|
||||
|
||||
const addToList = (movie: Movie) => {
|
||||
setList(prev => {
|
||||
if (prev.saved.some(m => m.id === movie.id)) return prev;
|
||||
return { ...prev, saved: [movie, ...prev.saved] };
|
||||
});
|
||||
};
|
||||
|
||||
const removeFromList = (movieId: string) => {
|
||||
setList(prev => ({
|
||||
...prev,
|
||||
saved: prev.saved.filter(m => m.id !== movieId)
|
||||
}));
|
||||
};
|
||||
|
||||
const addToHistory = (movie: Movie) => {
|
||||
setList(prev => {
|
||||
const filtered = prev.history.filter(m => m.id !== movie.id);
|
||||
|
||||
// Normalize Category to ensure it works with Recommendations
|
||||
let cat = movie.category?.toLowerCase() || 'phim-le';
|
||||
if (cat === 'movies') cat = 'phim-le';
|
||||
if (cat === 'series') cat = 'phim-bo';
|
||||
if (cat === 'animation') cat = 'hoat-hinh';
|
||||
if (cat === 'cartoon') cat = 'hoat-hinh';
|
||||
if (cat === 'tv') cat = 'tv-shows';
|
||||
|
||||
const normalizedMovie = { ...movie, category: cat };
|
||||
|
||||
return { ...prev, history: [normalizedMovie, ...filtered].slice(0, 50) };
|
||||
});
|
||||
};
|
||||
|
||||
const isSaved = (movieId: string) => list.saved.some(m => m.id === movieId);
|
||||
|
||||
return {
|
||||
savedMovies: list.saved,
|
||||
watchHistory: list.history,
|
||||
addToList,
|
||||
removeFromList,
|
||||
addToHistory,
|
||||
isSaved
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,64 +1,64 @@
|
|||
import { useMemo } from 'react';
|
||||
import type { Movie } from '../types';
|
||||
import { CATEGORIES } from '../constants';
|
||||
|
||||
interface Recommendation {
|
||||
id: string;
|
||||
title: string;
|
||||
category: string;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
export const useSmartRecommendations = (history: Movie[]): Recommendation[] => {
|
||||
return useMemo(() => {
|
||||
if (!history || history.length === 0) return [];
|
||||
|
||||
// Pre-defined mapping for data normalization
|
||||
const NORMALIZE_MAP: Record<string, string> = {
|
||||
'movies': 'phim-le',
|
||||
'phim-le': 'phim-le',
|
||||
'series': 'phim-bo',
|
||||
'phim-bo': 'phim-bo',
|
||||
'cartoon': 'hoat-hinh',
|
||||
'animation': 'hoat-hinh',
|
||||
'hoat-hinh': 'hoat-hinh',
|
||||
'tv-shows': 'tv-shows',
|
||||
'tv': 'tv-shows',
|
||||
'shows': 'tv-shows'
|
||||
};
|
||||
|
||||
// 1. Frequency Map of Categories
|
||||
const categoryCounts: Record<string, number> = {};
|
||||
history.forEach(movie => {
|
||||
if (movie.category) {
|
||||
const raw = movie.category.toLowerCase();
|
||||
const normalized = NORMALIZE_MAP[raw] || (CATEGORIES.some(c => c.id === raw) ? raw : 'phim-le');
|
||||
|
||||
if (CATEGORIES.some(c => c.id === normalized)) {
|
||||
categoryCounts[normalized] = (categoryCounts[normalized] || 0) + 1;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 2. Sort by frequency
|
||||
const sortedCategories = Object.entries(categoryCounts)
|
||||
.sort(([, a], [, b]) => b - a)
|
||||
.map(([cat]) => cat);
|
||||
|
||||
// 3. Get Top 2 Categories
|
||||
const topCategories = sortedCategories.slice(0, 2);
|
||||
|
||||
// 4. Map to Recommendation Objects
|
||||
const recommendations: Recommendation[] = topCategories.map(catSlug => {
|
||||
const catName = CATEGORIES.find(c => c.id === catSlug)?.name || 'Phim';
|
||||
return {
|
||||
id: `rec-${catSlug}`,
|
||||
title: `Gợi ý từ ${catName}`,
|
||||
category: catSlug,
|
||||
reason: `Based on your interest in ${catName}`
|
||||
};
|
||||
});
|
||||
|
||||
return recommendations;
|
||||
}, [history]);
|
||||
};
|
||||
import { useMemo } from 'react';
|
||||
import type { Movie } from '../types';
|
||||
import { CATEGORIES } from '../constants';
|
||||
|
||||
interface Recommendation {
|
||||
id: string;
|
||||
title: string;
|
||||
category: string;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
export const useSmartRecommendations = (history: Movie[]): Recommendation[] => {
|
||||
return useMemo(() => {
|
||||
if (!history || history.length === 0) return [];
|
||||
|
||||
// Pre-defined mapping for data normalization
|
||||
const NORMALIZE_MAP: Record<string, string> = {
|
||||
'movies': 'phim-le',
|
||||
'phim-le': 'phim-le',
|
||||
'series': 'phim-bo',
|
||||
'phim-bo': 'phim-bo',
|
||||
'cartoon': 'hoat-hinh',
|
||||
'animation': 'hoat-hinh',
|
||||
'hoat-hinh': 'hoat-hinh',
|
||||
'tv-shows': 'tv-shows',
|
||||
'tv': 'tv-shows',
|
||||
'shows': 'tv-shows'
|
||||
};
|
||||
|
||||
// 1. Frequency Map of Categories
|
||||
const categoryCounts: Record<string, number> = {};
|
||||
history.forEach(movie => {
|
||||
if (movie.category) {
|
||||
const raw = movie.category.toLowerCase();
|
||||
const normalized = NORMALIZE_MAP[raw] || (CATEGORIES.some(c => c.id === raw) ? raw : 'phim-le');
|
||||
|
||||
if (CATEGORIES.some(c => c.id === normalized)) {
|
||||
categoryCounts[normalized] = (categoryCounts[normalized] || 0) + 1;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 2. Sort by frequency
|
||||
const sortedCategories = Object.entries(categoryCounts)
|
||||
.sort(([, a], [, b]) => b - a)
|
||||
.map(([cat]) => cat);
|
||||
|
||||
// 3. Get Top 2 Categories
|
||||
const topCategories = sortedCategories.slice(0, 2);
|
||||
|
||||
// 4. Map to Recommendation Objects
|
||||
const recommendations: Recommendation[] = topCategories.map(catSlug => {
|
||||
const catName = CATEGORIES.find(c => c.id === catSlug)?.name || 'Phim';
|
||||
return {
|
||||
id: `rec-${catSlug}`,
|
||||
title: `Gợi ý từ ${catName}`,
|
||||
category: catSlug,
|
||||
reason: `Based on your interest in ${catName}`
|
||||
};
|
||||
});
|
||||
|
||||
return recommendations;
|
||||
}, [history]);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,174 +1,174 @@
|
|||
import { useState, useEffect, useRef } from 'react';
|
||||
import Hls from 'hls.js';
|
||||
import type { MovieDetail, VideoSource } from '../types';
|
||||
|
||||
export const useWatchMovie = (slug: string | undefined, episode: string | undefined) => {
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
const [movie, setMovie] = useState<MovieDetail | null>(null);
|
||||
const [source, setSource] = useState<VideoSource | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [currentEpisode, setCurrentEpisode] = useState(parseInt(episode || '1'));
|
||||
|
||||
useEffect(() => {
|
||||
if (!slug) return;
|
||||
const fetchDetails = async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/videos/${slug}`);
|
||||
if (!res.ok) throw new Error('Failed to fetch details');
|
||||
const data = await res.json();
|
||||
setMovie(data);
|
||||
} catch {
|
||||
console.error("Failed to fetch details");
|
||||
}
|
||||
};
|
||||
fetchDetails();
|
||||
}, [slug]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!movie) return;
|
||||
|
||||
const fetchStream = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const ep = movie.episodes?.find(e => e.number === currentEpisode);
|
||||
|
||||
// If no episode or no URL, don't try to extract — let WatchPage show "Coming Soon"
|
||||
if (!ep?.url) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (ep.url.includes('.m3u8') || ep.url.includes('index.m3u8')) {
|
||||
const isPhimMoi = ep.url.includes('phimmoichill') || ep.url.includes('sotrim') || ep.url.includes('phmchill');
|
||||
setSource({
|
||||
stream_url: isPhimMoi
|
||||
? `/api/stream?url=${encodeURIComponent(ep.url)}`
|
||||
: ep.url,
|
||||
resolution: 'HD',
|
||||
format_id: 'hls'
|
||||
});
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const targetUrl = ep ? ep.url : `https://phimmoichill.network/xem-phim/${slug}/tap-${currentEpisode}`;
|
||||
|
||||
const res = await fetch(`/api/extract`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ url: targetUrl }) // Changed to JSON payload
|
||||
});
|
||||
|
||||
if (!res.ok) throw new Error('Failed to extract');
|
||||
const data = await res.json();
|
||||
setSource({
|
||||
...data,
|
||||
stream_url: (data.url || data.stream_url).includes('phimmoichill') || (data.url || data.stream_url).includes('sotrim') || (data.url || data.stream_url).includes('phmchill')
|
||||
? `/api/stream?url=${encodeURIComponent(data.url || data.stream_url)}`
|
||||
: (data.url || data.stream_url)
|
||||
});
|
||||
} catch {
|
||||
console.error("Failed to extract stream");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchStream();
|
||||
}, [movie, currentEpisode, slug]);
|
||||
|
||||
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 (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(() => { });
|
||||
}
|
||||
}
|
||||
}, [source]);
|
||||
|
||||
// Wake Lock Logic (Prevent Screen Sleep)
|
||||
useEffect(() => {
|
||||
const video = videoRef.current;
|
||||
let wakeLock: any = null;
|
||||
|
||||
const requestWakeLock = async () => {
|
||||
if (wakeLock !== null) return;
|
||||
try {
|
||||
if ('wakeLock' in navigator) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
wakeLock = await (navigator as any).wakeLock.request('screen');
|
||||
// console.log('Wake Lock active');
|
||||
}
|
||||
} catch {
|
||||
console.warn('Wake Lock failed');
|
||||
}
|
||||
};
|
||||
|
||||
const releaseWakeLock = async () => {
|
||||
if (wakeLock) {
|
||||
try {
|
||||
await wakeLock.release();
|
||||
wakeLock = null;
|
||||
// console.log('Wake Lock released');
|
||||
} catch {
|
||||
// console.warn('Wake Lock release failed');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (video) {
|
||||
const onPlay = () => requestWakeLock();
|
||||
const onPause = () => releaseWakeLock();
|
||||
const onEnded = () => releaseWakeLock();
|
||||
|
||||
video.addEventListener('play', onPlay);
|
||||
video.addEventListener('pause', onPause);
|
||||
video.addEventListener('ended', onEnded);
|
||||
|
||||
// If already playing (HLS might auto-start before this effect)
|
||||
if (!video.paused) {
|
||||
requestWakeLock();
|
||||
}
|
||||
|
||||
// Re-acquire on visibility change if playing
|
||||
const onVisibilityChange = () => {
|
||||
if (document.visibilityState === 'visible' && !video.paused) {
|
||||
requestWakeLock();
|
||||
}
|
||||
};
|
||||
document.addEventListener('visibilitychange', onVisibilityChange);
|
||||
|
||||
return () => {
|
||||
video.removeEventListener('play', onPlay);
|
||||
video.removeEventListener('pause', onPause);
|
||||
video.removeEventListener('ended', onEnded);
|
||||
document.removeEventListener('visibilitychange', onVisibilityChange);
|
||||
releaseWakeLock();
|
||||
};
|
||||
}
|
||||
}, [source]);
|
||||
|
||||
return {
|
||||
movie,
|
||||
source,
|
||||
loading,
|
||||
currentEpisode,
|
||||
setCurrentEpisode,
|
||||
videoRef
|
||||
};
|
||||
};
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import Hls from 'hls.js';
|
||||
import type { MovieDetail, VideoSource } from '../types';
|
||||
|
||||
export const useWatchMovie = (slug: string | undefined, episode: string | undefined) => {
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
const [movie, setMovie] = useState<MovieDetail | null>(null);
|
||||
const [source, setSource] = useState<VideoSource | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [currentEpisode, setCurrentEpisode] = useState(parseInt(episode || '1'));
|
||||
|
||||
useEffect(() => {
|
||||
if (!slug) return;
|
||||
const fetchDetails = async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/videos/${slug}`);
|
||||
if (!res.ok) throw new Error('Failed to fetch details');
|
||||
const data = await res.json();
|
||||
setMovie(data);
|
||||
} catch {
|
||||
console.error("Failed to fetch details");
|
||||
}
|
||||
};
|
||||
fetchDetails();
|
||||
}, [slug]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!movie) return;
|
||||
|
||||
const fetchStream = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const ep = movie.episodes?.find(e => e.number === currentEpisode);
|
||||
|
||||
// If no episode or no URL, don't try to extract — let WatchPage show "Coming Soon"
|
||||
if (!ep?.url) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (ep.url.includes('.m3u8') || ep.url.includes('index.m3u8')) {
|
||||
const isPhimMoi = ep.url.includes('phimmoichill') || ep.url.includes('sotrim') || ep.url.includes('phmchill');
|
||||
setSource({
|
||||
stream_url: isPhimMoi
|
||||
? `/api/stream?url=${encodeURIComponent(ep.url)}`
|
||||
: ep.url,
|
||||
resolution: 'HD',
|
||||
format_id: 'hls'
|
||||
});
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const targetUrl = ep ? ep.url : `https://phimmoichill.network/xem-phim/${slug}/tap-${currentEpisode}`;
|
||||
|
||||
const res = await fetch(`/api/extract`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ url: targetUrl }) // Changed to JSON payload
|
||||
});
|
||||
|
||||
if (!res.ok) throw new Error('Failed to extract');
|
||||
const data = await res.json();
|
||||
setSource({
|
||||
...data,
|
||||
stream_url: (data.url || data.stream_url).includes('phimmoichill') || (data.url || data.stream_url).includes('sotrim') || (data.url || data.stream_url).includes('phmchill')
|
||||
? `/api/stream?url=${encodeURIComponent(data.url || data.stream_url)}`
|
||||
: (data.url || data.stream_url)
|
||||
});
|
||||
} catch {
|
||||
console.error("Failed to extract stream");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchStream();
|
||||
}, [movie, currentEpisode, slug]);
|
||||
|
||||
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 (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(() => { });
|
||||
}
|
||||
}
|
||||
}, [source]);
|
||||
|
||||
// Wake Lock Logic (Prevent Screen Sleep)
|
||||
useEffect(() => {
|
||||
const video = videoRef.current;
|
||||
let wakeLock: any = null;
|
||||
|
||||
const requestWakeLock = async () => {
|
||||
if (wakeLock !== null) return;
|
||||
try {
|
||||
if ('wakeLock' in navigator) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
wakeLock = await (navigator as any).wakeLock.request('screen');
|
||||
// console.log('Wake Lock active');
|
||||
}
|
||||
} catch {
|
||||
console.warn('Wake Lock failed');
|
||||
}
|
||||
};
|
||||
|
||||
const releaseWakeLock = async () => {
|
||||
if (wakeLock) {
|
||||
try {
|
||||
await wakeLock.release();
|
||||
wakeLock = null;
|
||||
// console.log('Wake Lock released');
|
||||
} catch {
|
||||
// console.warn('Wake Lock release failed');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (video) {
|
||||
const onPlay = () => requestWakeLock();
|
||||
const onPause = () => releaseWakeLock();
|
||||
const onEnded = () => releaseWakeLock();
|
||||
|
||||
video.addEventListener('play', onPlay);
|
||||
video.addEventListener('pause', onPause);
|
||||
video.addEventListener('ended', onEnded);
|
||||
|
||||
// If already playing (HLS might auto-start before this effect)
|
||||
if (!video.paused) {
|
||||
requestWakeLock();
|
||||
}
|
||||
|
||||
// Re-acquire on visibility change if playing
|
||||
const onVisibilityChange = () => {
|
||||
if (document.visibilityState === 'visible' && !video.paused) {
|
||||
requestWakeLock();
|
||||
}
|
||||
};
|
||||
document.addEventListener('visibilitychange', onVisibilityChange);
|
||||
|
||||
return () => {
|
||||
video.removeEventListener('play', onPlay);
|
||||
video.removeEventListener('pause', onPause);
|
||||
video.removeEventListener('ended', onEnded);
|
||||
document.removeEventListener('visibilitychange', onVisibilityChange);
|
||||
releaseWakeLock();
|
||||
};
|
||||
}
|
||||
}, [source]);
|
||||
|
||||
return {
|
||||
movie,
|
||||
source,
|
||||
loading,
|
||||
currentEpisode,
|
||||
setCurrentEpisode,
|
||||
videoRef
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,23 +1,23 @@
|
|||
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;
|
||||
|
||||
return <ThemeHome />;
|
||||
};
|
||||
|
||||
export default Home;
|
||||
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;
|
||||
|
||||
return <ThemeHome />;
|
||||
};
|
||||
|
||||
export default Home;
|
||||
|
|
|
|||
|
|
@ -1,46 +1,46 @@
|
|||
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;
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<div className="pt-24 px-4 md:px-12 min-h-screen">
|
||||
{/* Watch History Section */}
|
||||
{watchHistory.length > 0 && (
|
||||
<div className="mb-12">
|
||||
<MovieGrid movies={watchHistory} title="Continue Watching" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Saved List Section */}
|
||||
<MovieGrid movies={savedMovies} title="My List" />
|
||||
|
||||
{savedMovies.length === 0 && watchHistory.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center h-[50vh] text-gray-500">
|
||||
<p className="text-xl">Your list is empty.</p>
|
||||
<p className="text-sm mt-2">Start watching or add movies to your list.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<SettingsPanel />
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
export default MyList;
|
||||
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;
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<div className="pt-24 px-4 md:px-12 min-h-screen">
|
||||
{/* Watch History Section */}
|
||||
{watchHistory.length > 0 && (
|
||||
<div className="mb-12">
|
||||
<MovieGrid movies={watchHistory} title="Continue Watching" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Saved List Section */}
|
||||
<MovieGrid movies={savedMovies} title="My List" />
|
||||
|
||||
{savedMovies.length === 0 && watchHistory.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center h-[50vh] text-gray-500">
|
||||
<p className="text-xl">Your list is empty.</p>
|
||||
<p className="text-sm mt-2">Start watching or add movies to your list.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<SettingsPanel />
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
export default MyList;
|
||||
|
|
|
|||
|
|
@ -1,58 +1,58 @@
|
|||
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
|
||||
useEffect(() => {
|
||||
if (!slug) return;
|
||||
const fetchDetail = async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/videos/${slug}`);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
// Add to history when loaded
|
||||
addToHistory({
|
||||
id: data.id,
|
||||
title: data.title,
|
||||
original_title: data.original_title,
|
||||
slug: data.slug,
|
||||
thumbnail: data.thumbnail,
|
||||
backdrop: data.backdrop,
|
||||
year: data.year,
|
||||
category: data.category || 'movies',
|
||||
quality: data.quality,
|
||||
director: data.director,
|
||||
cast: data.cast
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
console.error("Failed to fetch for history");
|
||||
}
|
||||
};
|
||||
fetchDetail();
|
||||
}, [slug]);
|
||||
|
||||
// Select the current theme components
|
||||
const ActiveTheme = themes[currentTheme];
|
||||
const { WatchPage } = ActiveTheme.components;
|
||||
|
||||
return <WatchPage slug={slug || ''} episode={episode || '1'} />;
|
||||
};
|
||||
|
||||
export default Watch;
|
||||
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
|
||||
useEffect(() => {
|
||||
if (!slug) return;
|
||||
const fetchDetail = async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/videos/${slug}`);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
// Add to history when loaded
|
||||
addToHistory({
|
||||
id: data.id,
|
||||
title: data.title,
|
||||
original_title: data.original_title,
|
||||
slug: data.slug,
|
||||
thumbnail: data.thumbnail,
|
||||
backdrop: data.backdrop,
|
||||
year: data.year,
|
||||
category: data.category || 'movies',
|
||||
quality: data.quality,
|
||||
director: data.director,
|
||||
cast: data.cast
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
console.error("Failed to fetch for history");
|
||||
}
|
||||
};
|
||||
fetchDetail();
|
||||
}, [slug]);
|
||||
|
||||
// Select the current theme components
|
||||
const ActiveTheme = themes[currentTheme];
|
||||
const { WatchPage } = ActiveTheme.components;
|
||||
|
||||
return <WatchPage slug={slug || ''} episode={episode || '1'} />;
|
||||
};
|
||||
|
||||
export default Watch;
|
||||
|
|
|
|||
|
|
@ -1,15 +1,15 @@
|
|||
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>
|
||||
);
|
||||
};
|
||||
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 +1,41 @@
|
|||
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>
|
||||
);
|
||||
};
|
||||
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 +1,86 @@
|
|||
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>
|
||||
);
|
||||
};
|
||||
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 +1,172 @@
|
|||
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>
|
||||
);
|
||||
};
|
||||
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 +1,32 @@
|
|||
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>
|
||||
);
|
||||
};
|
||||
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 +1,201 @@
|
|||
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>
|
||||
);
|
||||
};
|
||||
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 +1,25 @@
|
|||
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
|
||||
},
|
||||
};
|
||||
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,15 +1,15 @@
|
|||
import Navbar from '../../components/Navbar';
|
||||
import { HomeContent } from '../../components/HomeContent';
|
||||
import { SettingsPanel } from '../../components/SettingsPanel';
|
||||
|
||||
export const DefaultHome = () => {
|
||||
return (
|
||||
<div className="min-h-screen bg-black text-white font-sans selection:bg-red-600 selection:text-white">
|
||||
<Navbar />
|
||||
<div className="pt-16">
|
||||
<HomeContent topPadding="pt-8 md:pt-12" />
|
||||
</div>
|
||||
<SettingsPanel />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
import Navbar from '../../components/Navbar';
|
||||
import { HomeContent } from '../../components/HomeContent';
|
||||
import { SettingsPanel } from '../../components/SettingsPanel';
|
||||
|
||||
export const DefaultHome = () => {
|
||||
return (
|
||||
<div className="min-h-screen bg-black text-white font-sans selection:bg-red-600 selection:text-white">
|
||||
<Navbar />
|
||||
<div className="pt-16">
|
||||
<HomeContent topPadding="pt-8 md:pt-12" />
|
||||
</div>
|
||||
<SettingsPanel />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,200 +1,200 @@
|
|||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { ArrowLeft, 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 [selectedServer, setSelectedServer] = useState<string>('');
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
if (!movie) return (
|
||||
<div className="h-screen w-full flex items-center justify-center bg-[#141414] text-white">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<div className="w-10 h-10 border-4 border-cyan-500 border-t-transparent rounded-full animate-spin"></div>
|
||||
<p className="text-gray-400 animate-pulse">Loading StreamFlow...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
// Helper for URL safety (same as Hero)
|
||||
const getImageUrl = (url: string | undefined, width: number) => {
|
||||
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`;
|
||||
};
|
||||
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-[#0a0a0a] text-white font-sans selection:bg-cyan-500/30 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 border border-white/5"
|
||||
>
|
||||
<ArrowLeft className="w-5 h-5 text-gray-200 group-hover:-translate-x-1 transition-transform" />
|
||||
<span className="font-medium text-sm">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-16 w-16 border-4 border-cyan-500 border-t-transparent shadow-[0_0_20px_rgba(6,182,212,0.5)]"></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(${getImageUrl(movie.backdrop || movie.thumbnail, 400)})` }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<video
|
||||
key={activeEpisode.url}
|
||||
ref={videoRef}
|
||||
controls
|
||||
className="w-full h-full max-h-screen object-contain"
|
||||
poster={getImageUrl(movie.backdrop || movie.thumbnail, 1280)}
|
||||
/>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
|
||||
{/* 2. Content Info & Rows */}
|
||||
{/* 2. Content Info & Rows */}
|
||||
<div className="max-w-[1600px] mx-auto px-4 md:px-12 py-8 md:py-12 space-y-12">
|
||||
{/* Glass Info Card */}
|
||||
<div className="bg-[#141414]/90 backdrop-blur-xl rounded-2xl 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="bg-cyan-500/10 text-cyan-400 border border-cyan-500/20 px-2 py-0.5 rounded text-xs font-bold uppercase tracking-wider">
|
||||
{movie.quality || 'HD'}
|
||||
</span>
|
||||
<span className="text-gray-400">{movie.year || '2024'}</span>
|
||||
<span className="text-green-400 font-medium">98% Match</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 font-light"
|
||||
dangerouslySetInnerHTML={{ __html: movie.description }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Episodes Section - Compact Grid */}
|
||||
{currentServerEpisodes.length > 0 && (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
||||
<div className="flex flex-wrap items-center gap-6">
|
||||
<h3 className="text-2xl font-bold border-l-4 border-cyan-500 pl-4 whitespace-nowrap">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-bold rounded-full transition-all border ${selectedServer === server
|
||||
? 'bg-cyan-500 text-black border-cyan-500'
|
||||
: 'bg-white/5 text-gray-400 border-white/10 hover:bg-white/10'
|
||||
}`}
|
||||
>
|
||||
{server}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-gray-400 text-sm font-medium bg-white/5 px-3 py-1 rounded-full w-fit">{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={`${ep.number}-${selectedServer}`}
|
||||
onClick={() => {
|
||||
setCurrentEpisode(ep.number);
|
||||
navigate(`/watch/${slug}/${ep.number}`);
|
||||
}}
|
||||
className={`group relative py-2 rounded-lg border transition-all duration-300 ${currentEpisode === ep.number
|
||||
? 'border-cyan-500 bg-cyan-950/30'
|
||||
: 'border-transparent bg-[#111] hover:bg-[#222] hover:border-white/10'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-center">
|
||||
<span className={`font-bold text-sm ${currentEpisode === ep.number ? 'text-cyan-400' : 'text-gray-400 group-hover:text-white'
|
||||
}`}>
|
||||
{ep.number}
|
||||
</span>
|
||||
</div>
|
||||
{currentEpisode === ep.number && (
|
||||
<div className="absolute top-1 right-1 w-1.5 h-1.5 rounded-full bg-cyan-400 shadow-[0_0_8px_rgba(34,211,238,0.8)]" />
|
||||
)}
|
||||
</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 mx-auto"
|
||||
>
|
||||
{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/5">
|
||||
<MovieRow title="Có thể bạn sẽ thích" category={movie.category || 'phim-le'} limit={10} key={`related-${movie.slug}`} />
|
||||
<MovieRow title="Phim Mới Cập Nhật" category="home" limit={10} key="trending" />
|
||||
<MovieRow title="Top Phim Lẻ" category="phim-le" limit={10} key="top-movies" />
|
||||
<MovieRow title="Top Phim Bộ" category="phim-bo" limit={10} key="top-series" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { ArrowLeft, 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 [selectedServer, setSelectedServer] = useState<string>('');
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
if (!movie) return (
|
||||
<div className="h-screen w-full flex items-center justify-center bg-[#141414] text-white">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<div className="w-10 h-10 border-4 border-cyan-500 border-t-transparent rounded-full animate-spin"></div>
|
||||
<p className="text-gray-400 animate-pulse">Loading StreamFlow...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
// Helper for URL safety (same as Hero)
|
||||
const getImageUrl = (url: string | undefined, width: number) => {
|
||||
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`;
|
||||
};
|
||||
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-[#0a0a0a] text-white font-sans selection:bg-cyan-500/30 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 border border-white/5"
|
||||
>
|
||||
<ArrowLeft className="w-5 h-5 text-gray-200 group-hover:-translate-x-1 transition-transform" />
|
||||
<span className="font-medium text-sm">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-16 w-16 border-4 border-cyan-500 border-t-transparent shadow-[0_0_20px_rgba(6,182,212,0.5)]"></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(${getImageUrl(movie.backdrop || movie.thumbnail, 400)})` }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<video
|
||||
key={activeEpisode.url}
|
||||
ref={videoRef}
|
||||
controls
|
||||
className="w-full h-full max-h-screen object-contain"
|
||||
poster={getImageUrl(movie.backdrop || movie.thumbnail, 1280)}
|
||||
/>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
|
||||
{/* 2. Content Info & Rows */}
|
||||
{/* 2. Content Info & Rows */}
|
||||
<div className="max-w-[1600px] mx-auto px-4 md:px-12 py-8 md:py-12 space-y-12">
|
||||
{/* Glass Info Card */}
|
||||
<div className="bg-[#141414]/90 backdrop-blur-xl rounded-2xl 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="bg-cyan-500/10 text-cyan-400 border border-cyan-500/20 px-2 py-0.5 rounded text-xs font-bold uppercase tracking-wider">
|
||||
{movie.quality || 'HD'}
|
||||
</span>
|
||||
<span className="text-gray-400">{movie.year || '2024'}</span>
|
||||
<span className="text-green-400 font-medium">98% Match</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 font-light"
|
||||
dangerouslySetInnerHTML={{ __html: movie.description }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Episodes Section - Compact Grid */}
|
||||
{currentServerEpisodes.length > 0 && (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
||||
<div className="flex flex-wrap items-center gap-6">
|
||||
<h3 className="text-2xl font-bold border-l-4 border-cyan-500 pl-4 whitespace-nowrap">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-bold rounded-full transition-all border ${selectedServer === server
|
||||
? 'bg-cyan-500 text-black border-cyan-500'
|
||||
: 'bg-white/5 text-gray-400 border-white/10 hover:bg-white/10'
|
||||
}`}
|
||||
>
|
||||
{server}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-gray-400 text-sm font-medium bg-white/5 px-3 py-1 rounded-full w-fit">{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={`${ep.number}-${selectedServer}`}
|
||||
onClick={() => {
|
||||
setCurrentEpisode(ep.number);
|
||||
navigate(`/watch/${slug}/${ep.number}`);
|
||||
}}
|
||||
className={`group relative py-2 rounded-lg border transition-all duration-300 ${currentEpisode === ep.number
|
||||
? 'border-cyan-500 bg-cyan-950/30'
|
||||
: 'border-transparent bg-[#111] hover:bg-[#222] hover:border-white/10'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-center">
|
||||
<span className={`font-bold text-sm ${currentEpisode === ep.number ? 'text-cyan-400' : 'text-gray-400 group-hover:text-white'
|
||||
}`}>
|
||||
{ep.number}
|
||||
</span>
|
||||
</div>
|
||||
{currentEpisode === ep.number && (
|
||||
<div className="absolute top-1 right-1 w-1.5 h-1.5 rounded-full bg-cyan-400 shadow-[0_0_8px_rgba(34,211,238,0.8)]" />
|
||||
)}
|
||||
</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 mx-auto"
|
||||
>
|
||||
{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/5">
|
||||
<MovieRow title="Có thể bạn sẽ thích" category={movie.category || 'phim-le'} limit={10} key={`related-${movie.slug}`} />
|
||||
<MovieRow title="Phim Mới Cập Nhật" category="home" limit={10} key="trending" />
|
||||
<MovieRow title="Top Phim Lẻ" category="phim-le" limit={10} key="top-movies" />
|
||||
<MovieRow title="Top Phim Bộ" category="phim-bo" limit={10} key="top-series" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,25 +1,25 @@
|
|||
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 { WatchPage } from './WatchPage'; // Use local StreamFlow WatchPage
|
||||
import { Layout } from '../netflix/Layout'; // Fallback layout if needed, but Home handles it
|
||||
|
||||
export const defaultTheme: Theme = {
|
||||
name: 'default',
|
||||
label: 'StreamFlow',
|
||||
colors: {
|
||||
background: '#141414',
|
||||
primary: '#E50914',
|
||||
text: '#FFFFFF',
|
||||
},
|
||||
components: {
|
||||
Layout,
|
||||
Hero,
|
||||
MovieGrid,
|
||||
Card,
|
||||
WatchPage,
|
||||
Home: DefaultHome,
|
||||
},
|
||||
};
|
||||
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 { WatchPage } from './WatchPage'; // Use local StreamFlow WatchPage
|
||||
import { Layout } from '../netflix/Layout'; // Fallback layout if needed, but Home handles it
|
||||
|
||||
export const defaultTheme: Theme = {
|
||||
name: 'default',
|
||||
label: 'StreamFlow',
|
||||
colors: {
|
||||
background: '#141414',
|
||||
primary: '#E50914',
|
||||
text: '#FFFFFF',
|
||||
},
|
||||
components: {
|
||||
Layout,
|
||||
Hero,
|
||||
MovieGrid,
|
||||
Card,
|
||||
WatchPage,
|
||||
Home: DefaultHome,
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { MovieCard } from '../../components/MovieCard';
|
||||
import type { Movie } from '../../types';
|
||||
|
||||
export const Card = ({ movie }: { movie: Movie }) => {
|
||||
return <MovieCard movie={movie} />;
|
||||
};
|
||||
import { MovieCard } from '../../components/MovieCard';
|
||||
import type { Movie } from '../../types';
|
||||
|
||||
export const Card = ({ movie }: { movie: Movie }) => {
|
||||
return <MovieCard movie={movie} />;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,87 +1,87 @@
|
|||
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>
|
||||
);
|
||||
};
|
||||
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,139 +1,139 @@
|
|||
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';
|
||||
|
||||
export const Layout = ({ children }: { children: ReactNode }) => {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const [isSearchOpen, setIsSearchOpen] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
|
||||
const isActive = (path: string) => {
|
||||
if (path === '/') return location.pathname === '/' && !location.search;
|
||||
return location.pathname + location.search === path;
|
||||
};
|
||||
|
||||
const handleSearch = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (searchQuery.trim()) {
|
||||
navigate(`/?q=${encodeURIComponent(searchQuery)}`);
|
||||
// Optional: close search or keep it open
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#141414] text-white flex">
|
||||
{/* Sidebar Navigation */}
|
||||
<aside className="hidden md:flex flex-col w-24 lg:w-64 fixed h-full z-50 bg-black border-r border-white/10 pt-8 transition-all duration-300">
|
||||
<div className="px-6 mb-10">
|
||||
<span className="text-red-600 text-3xl font-bold tracking-tighter">NETFLIX</span>
|
||||
</div>
|
||||
|
||||
<nav className="flex-1 space-y-2 px-4">
|
||||
{/* Search Item */}
|
||||
<div className={`flex items-center gap-4 px-4 py-3 rounded-md transition-colors cursor-pointer ${isSearchOpen ? 'bg-white/10' : 'text-gray-400 hover:text-white hover:bg-white/5'}`}
|
||||
onClick={() => !isSearchOpen && setIsSearchOpen(true)}
|
||||
>
|
||||
<Search className={`w-6 h-6 ${isSearchOpen ? 'text-white' : ''}`} />
|
||||
{isSearchOpen ? (
|
||||
<form onSubmit={handleSearch} className="flex-1">
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder="Search..."
|
||||
className="w-full bg-transparent border-none focus:ring-0 text-white text-sm placeholder:text-gray-500"
|
||||
autoFocus
|
||||
onBlur={() => !searchQuery && setIsSearchOpen(false)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
</form>
|
||||
) : (
|
||||
<span className="hidden lg:block text-sm">Search</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{NAV_ITEMS.map((item) => (
|
||||
<Link
|
||||
key={item.name}
|
||||
to={item.path}
|
||||
className={`flex items-center gap-4 px-4 py-3 rounded-md transition-colors ${isActive(item.path)
|
||||
? 'text-white font-bold bg-white/10'
|
||||
: 'text-gray-400 hover:text-white hover:bg-white/5'
|
||||
}`}
|
||||
>
|
||||
<item.icon className="w-6 h-6" />
|
||||
<span className="hidden lg:block text-sm">{item.name}</span>
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div className="p-4 mt-auto space-y-4">
|
||||
{/* PC/Tablet Sidebar Install Link */}
|
||||
<a
|
||||
href="/streamflow-tv.apk"
|
||||
download="streamflow-tv.apk"
|
||||
className="flex items-center gap-4 px-4 py-3 rounded-md transition-all duration-300 text-red-600 border border-red-900/30 hover:bg-red-600/10 group shadow-[0_0_15px_rgba(220,38,38,0.1)] hover:shadow-[0_0_20px_rgba(220,38,38,0.3)] bg-gradient-to-r from-red-600/5 to-transparent"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="w-6 h-6 group-hover:scale-110 transition-transform"
|
||||
>
|
||||
<rect width="20" height="15" x="2" y="7" rx="2" ry="2" />
|
||||
<polyline points="17 2 12 7 7 2" />
|
||||
</svg>
|
||||
<span className="hidden lg:block text-sm font-bold tracking-wide">TV APP</span>
|
||||
</a>
|
||||
|
||||
<div className="text-xs text-gray-500 text-center lg:text-left pt-2 border-t border-white/5 font-medium">
|
||||
© 2026 StreamFlow
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Mobile Bottom Nav (Visible only on small screens) */}
|
||||
<div className="md:hidden fixed bottom-0 left-0 right-0 bg-[#121212] border-t border-white/10 z-50 flex justify-around p-3 items-center">
|
||||
{NAV_ITEMS.slice(0, 4).map((item) => (
|
||||
<Link key={item.name} to={item.path} className={`flex flex-col items-center gap-1 ${isActive(item.path) ? 'text-white' : 'text-gray-500'}`}>
|
||||
<item.icon className="w-5 h-5" />
|
||||
<span className="text-[10px]">{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 text-red-600 animate-pulse font-bold"
|
||||
>
|
||||
<div className="bg-red-600 rounded-lg p-1">
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="white"
|
||||
strokeWidth="3"
|
||||
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>
|
||||
</div>
|
||||
<span className="text-[10px]">TV App</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{/* Main Content Area */}
|
||||
<main className="flex-1 md:ml-24 lg:ml-64 w-full pb-16 md:pb-0">
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
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';
|
||||
|
||||
export const Layout = ({ children }: { children: ReactNode }) => {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const [isSearchOpen, setIsSearchOpen] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
|
||||
const isActive = (path: string) => {
|
||||
if (path === '/') return location.pathname === '/' && !location.search;
|
||||
return location.pathname + location.search === path;
|
||||
};
|
||||
|
||||
const handleSearch = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (searchQuery.trim()) {
|
||||
navigate(`/?q=${encodeURIComponent(searchQuery)}`);
|
||||
// Optional: close search or keep it open
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#141414] text-white flex">
|
||||
{/* Sidebar Navigation */}
|
||||
<aside className="hidden md:flex flex-col w-24 lg:w-64 fixed h-full z-50 bg-black border-r border-white/10 pt-8 transition-all duration-300">
|
||||
<div className="px-6 mb-10">
|
||||
<span className="text-red-600 text-3xl font-bold tracking-tighter">NETFLIX</span>
|
||||
</div>
|
||||
|
||||
<nav className="flex-1 space-y-2 px-4">
|
||||
{/* Search Item */}
|
||||
<div className={`flex items-center gap-4 px-4 py-3 rounded-md transition-colors cursor-pointer ${isSearchOpen ? 'bg-white/10' : 'text-gray-400 hover:text-white hover:bg-white/5'}`}
|
||||
onClick={() => !isSearchOpen && setIsSearchOpen(true)}
|
||||
>
|
||||
<Search className={`w-6 h-6 ${isSearchOpen ? 'text-white' : ''}`} />
|
||||
{isSearchOpen ? (
|
||||
<form onSubmit={handleSearch} className="flex-1">
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder="Search..."
|
||||
className="w-full bg-transparent border-none focus:ring-0 text-white text-sm placeholder:text-gray-500"
|
||||
autoFocus
|
||||
onBlur={() => !searchQuery && setIsSearchOpen(false)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
</form>
|
||||
) : (
|
||||
<span className="hidden lg:block text-sm">Search</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{NAV_ITEMS.map((item) => (
|
||||
<Link
|
||||
key={item.name}
|
||||
to={item.path}
|
||||
className={`flex items-center gap-4 px-4 py-3 rounded-md transition-colors ${isActive(item.path)
|
||||
? 'text-white font-bold bg-white/10'
|
||||
: 'text-gray-400 hover:text-white hover:bg-white/5'
|
||||
}`}
|
||||
>
|
||||
<item.icon className="w-6 h-6" />
|
||||
<span className="hidden lg:block text-sm">{item.name}</span>
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div className="p-4 mt-auto space-y-4">
|
||||
{/* PC/Tablet Sidebar Install Link */}
|
||||
<a
|
||||
href="/streamflow-tv.apk"
|
||||
download="streamflow-tv.apk"
|
||||
className="flex items-center gap-4 px-4 py-3 rounded-md transition-all duration-300 text-red-600 border border-red-900/30 hover:bg-red-600/10 group shadow-[0_0_15px_rgba(220,38,38,0.1)] hover:shadow-[0_0_20px_rgba(220,38,38,0.3)] bg-gradient-to-r from-red-600/5 to-transparent"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="w-6 h-6 group-hover:scale-110 transition-transform"
|
||||
>
|
||||
<rect width="20" height="15" x="2" y="7" rx="2" ry="2" />
|
||||
<polyline points="17 2 12 7 7 2" />
|
||||
</svg>
|
||||
<span className="hidden lg:block text-sm font-bold tracking-wide">TV APP</span>
|
||||
</a>
|
||||
|
||||
<div className="text-xs text-gray-500 text-center lg:text-left pt-2 border-t border-white/5 font-medium">
|
||||
© 2026 StreamFlow
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Mobile Bottom Nav (Visible only on small screens) */}
|
||||
<div className="md:hidden fixed bottom-0 left-0 right-0 bg-[#121212] border-t border-white/10 z-50 flex justify-around p-3 items-center">
|
||||
{NAV_ITEMS.slice(0, 4).map((item) => (
|
||||
<Link key={item.name} to={item.path} className={`flex flex-col items-center gap-1 ${isActive(item.path) ? 'text-white' : 'text-gray-500'}`}>
|
||||
<item.icon className="w-5 h-5" />
|
||||
<span className="text-[10px]">{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 text-red-600 animate-pulse font-bold"
|
||||
>
|
||||
<div className="bg-red-600 rounded-lg p-1">
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="white"
|
||||
strokeWidth="3"
|
||||
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>
|
||||
</div>
|
||||
<span className="text-[10px]">TV App</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{/* Main Content Area */}
|
||||
<main className="flex-1 md:ml-24 lg:ml-64 w-full pb-16 md:pb-0">
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,28 +1,28 @@
|
|||
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-4 md:px-12 pb-10">
|
||||
{title && <h2 className="text-xl font-bold mb-4 text-white">{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-4">
|
||||
{[...Array(12)].map((_, i) => (
|
||||
<div key={i} className="aspect-[2/3] bg-[#222] rounded-md animate-pulse" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="px-4 md:px-12 pb-10">
|
||||
{title && <h2 className="text-xl font-bold mb-4 text-white hover:text-gray-300 cursor-pointer transition-colors inline-block">{title} ></h2>}
|
||||
<div className="grid grid-cols-2 min-[450px]:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-4">
|
||||
{movies.map((movie) => (
|
||||
<Card key={movie.id} movie={movie} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
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-4 md:px-12 pb-10">
|
||||
{title && <h2 className="text-xl font-bold mb-4 text-white">{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-4">
|
||||
{[...Array(12)].map((_, i) => (
|
||||
<div key={i} className="aspect-[2/3] bg-[#222] rounded-md animate-pulse" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="px-4 md:px-12 pb-10">
|
||||
{title && <h2 className="text-xl font-bold mb-4 text-white hover:text-gray-300 cursor-pointer transition-colors inline-block">{title} ></h2>}
|
||||
<div className="grid grid-cols-2 min-[450px]:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-4">
|
||||
{movies.map((movie) => (
|
||||
<Card key={movie.id} movie={movie} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,14 +1,14 @@
|
|||
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>
|
||||
);
|
||||
};
|
||||
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 +1,186 @@
|
|||
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>
|
||||
);
|
||||
};
|
||||
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 +1,25 @@
|
|||
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
|
||||
},
|
||||
};
|
||||
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
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,24 +1,24 @@
|
|||
import type { ReactNode } from 'react';
|
||||
import type { Movie } from './index';
|
||||
|
||||
export interface ThemeComponents {
|
||||
Layout: React.ComponentType<{ children: ReactNode }>;
|
||||
Hero: React.ComponentType<{ movies: Movie[] }>;
|
||||
MovieGrid: React.ComponentType<{ movies: Movie[], loading?: boolean, title?: string }>;
|
||||
Card: React.ComponentType<{ movie: Movie }>;
|
||||
WatchPage: React.ComponentType<{ slug: string, episode: string }>;
|
||||
Home: React.ComponentType; // Refactored to be self-contained
|
||||
}
|
||||
|
||||
export type ThemeName = 'netflix' | 'apple' | 'default';
|
||||
|
||||
export interface Theme {
|
||||
name: ThemeName;
|
||||
label: string;
|
||||
colors: {
|
||||
background: string;
|
||||
primary: string;
|
||||
text: string;
|
||||
};
|
||||
components: ThemeComponents;
|
||||
}
|
||||
import type { ReactNode } from 'react';
|
||||
import type { Movie } from './index';
|
||||
|
||||
export interface ThemeComponents {
|
||||
Layout: React.ComponentType<{ children: ReactNode }>;
|
||||
Hero: React.ComponentType<{ movies: Movie[] }>;
|
||||
MovieGrid: React.ComponentType<{ movies: Movie[], loading?: boolean, title?: string }>;
|
||||
Card: React.ComponentType<{ movie: Movie }>;
|
||||
WatchPage: React.ComponentType<{ slug: string, episode: string }>;
|
||||
Home: React.ComponentType; // Refactored to be self-contained
|
||||
}
|
||||
|
||||
export type ThemeName = 'netflix' | 'apple' | 'default';
|
||||
|
||||
export interface Theme {
|
||||
name: ThemeName;
|
||||
label: string;
|
||||
colors: {
|
||||
background: string;
|
||||
primary: string;
|
||||
text: string;
|
||||
};
|
||||
components: ThemeComponents;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,44 +1,44 @@
|
|||
export interface Movie {
|
||||
id: string;
|
||||
title: string;
|
||||
original_title?: string;
|
||||
slug: string;
|
||||
thumbnail: string;
|
||||
backdrop?: string;
|
||||
quality?: string;
|
||||
year?: number;
|
||||
category: string;
|
||||
time?: string;
|
||||
lang?: string;
|
||||
provider?: string;
|
||||
director?: string;
|
||||
cast?: string[];
|
||||
}
|
||||
|
||||
export interface MovieDetail extends Movie {
|
||||
description: string;
|
||||
rating?: string;
|
||||
duration?: number;
|
||||
genre?: string;
|
||||
director?: string;
|
||||
country?: string;
|
||||
cast?: string[];
|
||||
episodes?: Episode[];
|
||||
}
|
||||
|
||||
export interface Episode {
|
||||
number: number;
|
||||
title: string;
|
||||
url: string;
|
||||
server_name?: string;
|
||||
}
|
||||
|
||||
export interface VideoSource {
|
||||
stream_url: string;
|
||||
resolution: string;
|
||||
format_id: string;
|
||||
}
|
||||
export interface Category {
|
||||
name: string;
|
||||
slug: string;
|
||||
}
|
||||
export interface Movie {
|
||||
id: string;
|
||||
title: string;
|
||||
original_title?: string;
|
||||
slug: string;
|
||||
thumbnail: string;
|
||||
backdrop?: string;
|
||||
quality?: string;
|
||||
year?: number;
|
||||
category: string;
|
||||
time?: string;
|
||||
lang?: string;
|
||||
provider?: string;
|
||||
director?: string;
|
||||
cast?: string[];
|
||||
}
|
||||
|
||||
export interface MovieDetail extends Movie {
|
||||
description: string;
|
||||
rating?: string;
|
||||
duration?: number;
|
||||
genre?: string;
|
||||
director?: string;
|
||||
country?: string;
|
||||
cast?: string[];
|
||||
episodes?: Episode[];
|
||||
}
|
||||
|
||||
export interface Episode {
|
||||
number: number;
|
||||
title: string;
|
||||
url: string;
|
||||
server_name?: string;
|
||||
}
|
||||
|
||||
export interface VideoSource {
|
||||
stream_url: string;
|
||||
resolution: string;
|
||||
format_id: string;
|
||||
}
|
||||
export interface Category {
|
||||
name: string;
|
||||
slug: string;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: [
|
||||
"./index.html",
|
||||
"./src/**/*.{js,ts,jsx,tsx}",
|
||||
],
|
||||
theme: {
|
||||
extend: {},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: [
|
||||
"./index.html",
|
||||
"./src/**/*.{js,ts,jsx,tsx}",
|
||||
],
|
||||
theme: {
|
||||
extend: {},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
|
|
|
|||
102
start-dev.ps1
102
start-dev.ps1
|
|
@ -1,51 +1,51 @@
|
|||
# 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
|
||||
# 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
|
||||
|
|
|
|||
Loading…
Reference in a new issue