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:
vndangkhoa 2026-03-01 11:16:34 +07:00
parent fbe89e14fd
commit 69308bf696
95 changed files with 7684 additions and 7709 deletions

View file

@ -1,49 +1,49 @@
# Stage 1: Build Image (Frontend) # Stage 1: Build Image (Frontend)
FROM node:20-alpine AS frontend-builder FROM node:20-alpine AS frontend-builder
WORKDIR /app/frontend WORKDIR /app/frontend
COPY frontend-react/package*.json ./ COPY frontend-react/package*.json ./
RUN npm install RUN npm install
COPY frontend-react/ . COPY frontend-react/ .
RUN npm run build RUN npm run build
# Stage 2: Build Image (Backend) # Stage 2: Build Image (Backend)
FROM golang:1.24-alpine AS backend-builder FROM --platform=$BUILDPLATFORM golang:1.24-alpine AS backend-builder
WORKDIR /app/backend WORKDIR /app/backend
# Install build dependencies
RUN apk add --no-cache gcc musl-dev ARG TARGETOS TARGETARCH
COPY backend/go.mod backend/go.sum ./ COPY backend/go.mod backend/go.sum ./
RUN go mod download RUN go mod download
COPY backend/ . COPY backend/ .
# Build static binary for Linux amd64 # 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 RUN CGO_ENABLED=0 GOOS=$TARGETOS GOARCH=$TARGETARCH go build -ldflags="-w -s" -o server cmd/server/main.go
# Stage 3: Final Image # Stage 3: Final Image
FROM alpine:latest FROM alpine:latest
WORKDIR /app WORKDIR /app
# Install runtime dependencies (sqlite + yt-dlp for video extraction fallback) # Install runtime dependencies
RUN apk add --no-cache sqlite ca-certificates tzdata python3 py3-pip && \ RUN apk add --no-cache sqlite ca-certificates tzdata python3 py3-pip
pip3 install --break-system-packages yt-dlp RUN pip3 install --break-system-packages --ignore-installed yt-dlp || true
# Copy backend binary # Copy backend binary
COPY --from=backend-builder /app/backend/server . COPY --from=backend-builder /app/backend/server .
# Copy frontend build to the expected static directory # Copy frontend build to the expected static directory
# The backend expects ../frontend-react/dist relative to itself, or we configure it. COPY --from=frontend-builder /app/frontend/dist ./dist
# Let's align with the standard deployment structure: /app/server and /app/dist
COPY --from=frontend-builder /app/frontend/dist ./dist
# Create data directory # Create data directory
RUN mkdir -p data RUN mkdir -p data
# Environment variables # Environment variables
ENV PORT=8000 ENV PORT=8000
ENV DATABASE_URL=/app/data/streamflow.db ENV DATABASE_URL=/app/data/streamflow.db
# Expose port # Expose port
EXPOSE 8000 EXPOSE 8000
# Start server # Start server
CMD ["./server"] CMD ["./server"]

View file

@ -1,88 +1,88 @@
plugins { plugins {
id("com.android.application") id("com.android.application")
id("org.jetbrains.kotlin.android") id("org.jetbrains.kotlin.android")
} }
android { android {
namespace = "com.streamflow.tv" namespace = "com.streamflow.tv"
compileSdk = 34 compileSdk = 34
defaultConfig { defaultConfig {
applicationId = "com.streamflow.tv" applicationId = "com.streamflow.tv"
minSdk = 21 minSdk = 21
targetSdk = 34 targetSdk = 34
versionCode = 37 versionCode = 37
versionName = "3.7.0" versionName = "3.7.0"
} }
buildTypes { buildTypes {
release { release {
isMinifyEnabled = false isMinifyEnabled = false
isShrinkResources = false isShrinkResources = false
proguardFiles( proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"), getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro" "proguard-rules.pro"
) )
} }
} }
buildFeatures { buildFeatures {
compose = true compose = true
} }
composeOptions { composeOptions {
kotlinCompilerExtensionVersion = "1.5.8" kotlinCompilerExtensionVersion = "1.5.8"
} }
compileOptions { compileOptions {
sourceCompatibility = JavaVersion.VERSION_17 sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_17
} }
kotlinOptions { kotlinOptions {
jvmTarget = "17" jvmTarget = "17"
} }
} }
dependencies { dependencies {
// Compose for TV // Compose for TV
implementation("androidx.tv:tv-foundation:1.0.0-alpha11") implementation("androidx.tv:tv-foundation:1.0.0-alpha11")
implementation("androidx.tv:tv-material:1.0.0") implementation("androidx.tv:tv-material:1.0.0")
// Core Compose // Core Compose
implementation(platform("androidx.compose:compose-bom:2024.01.00")) implementation(platform("androidx.compose:compose-bom:2024.01.00"))
implementation("androidx.compose.ui:ui") implementation("androidx.compose.ui:ui")
implementation("androidx.compose.ui:ui-tooling-preview") implementation("androidx.compose.ui:ui-tooling-preview")
implementation("androidx.compose.material3:material3") implementation("androidx.compose.material3:material3")
implementation("androidx.compose.material:material-icons-extended") implementation("androidx.compose.material:material-icons-extended")
implementation("androidx.activity:activity-compose:1.8.2") implementation("androidx.activity:activity-compose:1.8.2")
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.7.0") implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.7.0")
implementation("androidx.lifecycle:lifecycle-runtime-compose:2.7.0") implementation("androidx.lifecycle:lifecycle-runtime-compose:2.7.0")
implementation("androidx.navigation:navigation-compose:2.7.6") implementation("androidx.navigation:navigation-compose:2.7.6")
// ExoPlayer (Media3) // ExoPlayer (Media3)
implementation("androidx.media3:media3-exoplayer:1.2.1") implementation("androidx.media3:media3-exoplayer:1.2.1")
implementation("androidx.media3:media3-exoplayer-hls:1.2.1") implementation("androidx.media3:media3-exoplayer-hls:1.2.1")
implementation("androidx.media3:media3-ui:1.2.1") implementation("androidx.media3:media3-ui:1.2.1")
implementation("androidx.media3:media3-session:1.2.1") implementation("androidx.media3:media3-session:1.2.1")
// Networking // Networking
implementation("com.squareup.retrofit2:retrofit:2.9.0") implementation("com.squareup.retrofit2:retrofit:2.9.0")
implementation("com.squareup.retrofit2:converter-moshi:2.9.0") implementation("com.squareup.retrofit2:converter-moshi:2.9.0")
implementation("com.squareup.moshi:moshi-kotlin:1.15.0") implementation("com.squareup.moshi:moshi-kotlin:1.15.0")
implementation("com.squareup.okhttp3:okhttp:4.12.0") implementation("com.squareup.okhttp3:okhttp:4.12.0")
implementation("com.squareup.okhttp3:logging-interceptor:4.12.0") implementation("com.squareup.okhttp3:logging-interceptor:4.12.0")
// Image loading // Image loading
implementation("io.coil-kt:coil-compose:2.5.0") implementation("io.coil-kt:coil-compose:2.5.0")
// DataStore // DataStore
implementation("androidx.datastore:datastore-preferences:1.0.0") implementation("androidx.datastore:datastore-preferences:1.0.0")
// Core Android TV // Core Android TV
implementation("androidx.core:core-ktx:1.12.0") implementation("androidx.core:core-ktx:1.12.0")
implementation("androidx.leanback:leanback:1.0.0") implementation("androidx.leanback:leanback:1.0.0")
// Debug // Debug
debugImplementation("androidx.compose.ui:ui-tooling") debugImplementation("androidx.compose.ui:ui-tooling")
} }

View file

@ -1,54 +1,54 @@
# ProGuard rules for StreamFlow TV # ProGuard rules for StreamFlow TV
# Keep all app classes (safety net) # Keep all app classes (safety net)
-keep class com.streamflow.tv.** { *; } -keep class com.streamflow.tv.** { *; }
-keepclassmembers class com.streamflow.tv.** { *; } -keepclassmembers class com.streamflow.tv.** { *; }
# Moshi # Moshi
-keep class com.squareup.moshi.** { *; } -keep class com.squareup.moshi.** { *; }
-keepclassmembers class * { -keepclassmembers class * {
@com.squareup.moshi.Json <fields>; @com.squareup.moshi.Json <fields>;
} }
-keepclassmembers class * { -keepclassmembers class * {
@com.squareup.moshi.JsonClass <fields>; @com.squareup.moshi.JsonClass <fields>;
} }
# Kotlin Metadata (critical for Moshi reflection adapter) # Kotlin Metadata (critical for Moshi reflection adapter)
-keep class kotlin.Metadata { *; } -keep class kotlin.Metadata { *; }
-keepattributes RuntimeVisibleAnnotations -keepattributes RuntimeVisibleAnnotations
-keepattributes RuntimeInvisibleAnnotations -keepattributes RuntimeInvisibleAnnotations
-keepattributes *Annotation* -keepattributes *Annotation*
# Retrofit # Retrofit
-dontwarn retrofit2.** -dontwarn retrofit2.**
-keep class retrofit2.** { *; } -keep class retrofit2.** { *; }
-keepattributes Signature -keepattributes Signature
-keepattributes Exceptions -keepattributes Exceptions
-keepclassmembers,allowshrinking,allowobfuscation interface * { -keepclassmembers,allowshrinking,allowobfuscation interface * {
@retrofit2.http.* <methods>; @retrofit2.http.* <methods>;
} }
# OkHttp # OkHttp
-dontwarn okhttp3.** -dontwarn okhttp3.**
-dontwarn okio.** -dontwarn okio.**
-keep class okhttp3.** { *; } -keep class okhttp3.** { *; }
-keep class okio.** { *; } -keep class okio.** { *; }
# Kotlin Coroutines # Kotlin Coroutines
-keepnames class kotlinx.coroutines.internal.MainDispatcherFactory {} -keepnames class kotlinx.coroutines.internal.MainDispatcherFactory {}
-keepnames class kotlinx.coroutines.CoroutineExceptionHandler {} -keepnames class kotlinx.coroutines.CoroutineExceptionHandler {}
-keepclassmembers class kotlinx.** { -keepclassmembers class kotlinx.** {
volatile <fields>; volatile <fields>;
} }
# Coil # Coil
-dontwarn coil.** -dontwarn coil.**
-keep class coil.** { *; } -keep class coil.** { *; }
# AndroidX Compose # AndroidX Compose
-keep class androidx.compose.** { *; } -keep class androidx.compose.** { *; }
-dontwarn androidx.compose.** -dontwarn androidx.compose.**
# ExoPlayer / Media3 # ExoPlayer / Media3
-keep class androidx.media3.** { *; } -keep class androidx.media3.** { *; }
-dontwarn androidx.media3.** -dontwarn androidx.media3.**

View file

@ -1,43 +1,43 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> <manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" /> <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-feature <uses-feature
android:name="android.software.leanback" android:name="android.software.leanback"
android:required="false" /> android:required="false" />
<uses-feature <uses-feature
android:name="android.hardware.touchscreen" android:name="android.hardware.touchscreen"
android:required="false" /> android:required="false" />
<application <application
android:name=".StreamFlowApp" android:name=".StreamFlowApp"
android:allowBackup="true" android:allowBackup="true"
android:icon="@mipmap/ic_launcher" android:icon="@mipmap/ic_launcher"
android:label="@string/app_name" android:label="@string/app_name"
android:banner="@drawable/app_banner" android:banner="@drawable/app_banner"
android:theme="@style/Theme.StreamFlowTV" android:theme="@style/Theme.StreamFlowTV"
android:supportsRtl="true" android:supportsRtl="true"
android:usesCleartextTraffic="true"> android:usesCleartextTraffic="true">
<activity <activity
android:name=".MainActivity" android:name=".MainActivity"
android:exported="true" android:exported="true"
android:configChanges="orientation|screenSize|screenLayout|keyboardHidden" android:configChanges="orientation|screenSize|screenLayout|keyboardHidden"
android:screenOrientation="landscape"> android:screenOrientation="landscape">
<intent-filter> <intent-filter>
<action android:name="android.intent.action.MAIN" /> <action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LEANBACK_LAUNCHER" /> <category android:name="android.intent.category.LEANBACK_LAUNCHER" />
<category android:name="android.intent.category.LAUNCHER" /> <category android:name="android.intent.category.LAUNCHER" />
</intent-filter> </intent-filter>
<intent-filter> <intent-filter>
<action android:name="android.intent.action.VIEW" /> <action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" /> <category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" /> <category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="streamflow" android:host="player" /> <data android:scheme="streamflow" android:host="player" />
</intent-filter> </intent-filter>
</activity> </activity>
</application> </application>
</manifest> </manifest>

View file

@ -1,172 +1,172 @@
package com.streamflow.tv package com.streamflow.tv
import android.os.Bundle import android.os.Bundle
import android.util.Log import android.util.Log
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.navigation.compose.NavHost import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController import androidx.navigation.compose.rememberNavController
import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.NavType import androidx.navigation.NavType
import androidx.navigation.navArgument import androidx.navigation.navArgument
import com.streamflow.tv.data.api.ApiClient import com.streamflow.tv.data.api.ApiClient
import com.streamflow.tv.data.repository.UserDataRepository import com.streamflow.tv.data.repository.UserDataRepository
import com.streamflow.tv.ui.components.SideNavRail import com.streamflow.tv.ui.components.SideNavRail
import com.streamflow.tv.ui.screens.* import com.streamflow.tv.ui.screens.*
import com.streamflow.tv.ui.theme.StreamFlowTheme import com.streamflow.tv.ui.theme.StreamFlowTheme
import com.streamflow.tv.ui.theme.StreamFlowTvTheme import com.streamflow.tv.ui.theme.StreamFlowTvTheme
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
Log.d("MainActivity", "onCreate started") Log.d("MainActivity", "onCreate started")
setContent { setContent {
StreamFlowTvApp() StreamFlowTvApp()
} }
} }
} }
@Composable @Composable
fun StreamFlowTvApp() { fun StreamFlowTvApp() {
val context = LocalContext.current val context = LocalContext.current
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val userRepo = remember { UserDataRepository(context) } val userRepo = remember { UserDataRepository(context) }
val navController = rememberNavController() val navController = rememberNavController()
var currentTheme by remember { mutableStateOf("default") } var currentTheme by remember { mutableStateOf("default") }
var selectedNavId by remember { mutableStateOf("home") } var selectedNavId by remember { mutableStateOf("home") }
// Load persisted settings // Load persisted settings
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
try { try {
currentTheme = userRepo.theme.first() currentTheme = userRepo.theme.first()
val serverUrl = userRepo.serverUrl.first() val serverUrl = userRepo.serverUrl.first()
/*if (serverUrl.isNotBlank()) { if (serverUrl.isNotBlank()) {
ApiClient.baseUrl = serverUrl 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
}
}
)
} }
Log.d("StreamFlowTvApp", "Settings loaded: theme=$currentTheme, url=$serverUrl")
// Main content } catch (e: Exception) {
Box(modifier = Modifier.weight(1f)) { Log.e("StreamFlowTvApp", "Error loading settings", e)
NavHost( }
navController = navController, }
startDestination = "home"
) { StreamFlowTvTheme(themeName = currentTheme) {
composable("home") { val colors = StreamFlowTheme.colors
HomeScreen(
onMovieClick = { slug -> val navBackStackEntry by navController.currentBackStackEntryAsState()
navController.navigate("detail/$slug") val currentRoute = navBackStackEntry?.destination?.route
}, val showSideNav = currentRoute != null && !currentRoute.startsWith("player")
userDataRepository = userRepo
) Row(
} modifier = Modifier
.fillMaxSize()
composable( .background(colors.background)
"home/{category}", ) {
arguments = listOf(navArgument("category") { type = NavType.StringType }) // Side Navigation
) { entry -> if (showSideNav) {
HomeScreen( SideNavRail(
onMovieClick = { slug -> navController.navigate("detail/$slug") }, selectedId = selectedNavId,
category = entry.arguments?.getString("category"), onNavigate = { item ->
userDataRepository = userRepo selectedNavId = item.id
) navController.navigate(item.route) {
} popUpTo("home") { saveState = true }
launchSingleTop = true
composable( restoreState = true
"detail/{slug}", }
arguments = listOf(navArgument("slug") { type = NavType.StringType }) }
) { entry -> )
val slug = entry.arguments?.getString("slug") ?: return@composable }
DetailScreen(
slug = slug, // Main content
onPlayClick = { s, ep -> navController.navigate("player/$s/$ep") }, Box(modifier = Modifier.weight(1f)) {
onBack = { navController.popBackStack() } NavHost(
) navController = navController,
} startDestination = "home"
) {
composable( composable("home") {
"player/{slug}/{episode}", HomeScreen(
arguments = listOf( onMovieClick = { slug ->
navArgument("slug") { type = NavType.StringType }, navController.navigate("detail/$slug")
navArgument("episode") { type = NavType.IntType; defaultValue = 1 } },
), userDataRepository = userRepo
deepLinks = listOf(androidx.navigation.navDeepLink { uriPattern = "streamflow://player/{slug}/{episode}" }) )
) { entry -> }
val slug = entry.arguments?.getString("slug")
val episode = entry.arguments?.getInt("episode") ?: 1 composable(
Log.d("StreamFlowNav", "Navigating to player: slug=$slug, episode=$episode") "home/{category}",
if (slug == null) { arguments = listOf(navArgument("category") { type = NavType.StringType })
return@composable ) { entry ->
} HomeScreen(
PlayerScreen( onMovieClick = { slug -> navController.navigate("detail/$slug") },
slug = slug, category = entry.arguments?.getString("category"),
episode = episode, userDataRepository = userRepo
userDataRepository = userRepo )
) }
}
composable(
composable("search") { "detail/{slug}",
SearchScreen( arguments = listOf(navArgument("slug") { type = NavType.StringType })
onMovieClick = { slug -> navController.navigate("detail/$slug") } ) { entry ->
) val slug = entry.arguments?.getString("slug") ?: return@composable
} DetailScreen(
slug = slug,
composable("mylist") { onPlayClick = { s, ep -> navController.navigate("player/$s/$ep") },
MyListScreen( onBack = { navController.popBackStack() }
onMovieClick = { slug -> navController.navigate("detail/$slug") } )
) }
}
composable(
composable("settings") { "player/{slug}/{episode}",
SettingsScreen( arguments = listOf(
currentTheme = currentTheme, navArgument("slug") { type = NavType.StringType },
onThemeChange = { theme -> navArgument("episode") { type = NavType.IntType; defaultValue = 1 }
currentTheme = theme ),
scope.launch { userRepo.setTheme(theme) } 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) }
}
)
}
}
}
}
}
}

View file

@ -1,30 +1,30 @@
package com.streamflow.tv package com.streamflow.tv
import android.app.Application import android.app.Application
import coil.ImageLoader import coil.ImageLoader
import coil.ImageLoaderFactory import coil.ImageLoaderFactory
import coil.disk.DiskCache import coil.disk.DiskCache
import coil.memory.MemoryCache import coil.memory.MemoryCache
class StreamFlowApp : Application(), ImageLoaderFactory { class StreamFlowApp : Application(), ImageLoaderFactory {
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
} }
override fun newImageLoader(): ImageLoader { override fun newImageLoader(): ImageLoader {
return ImageLoader.Builder(this) return ImageLoader.Builder(this)
.memoryCache { .memoryCache {
MemoryCache.Builder(this) MemoryCache.Builder(this)
.maxSizePercent(0.25) .maxSizePercent(0.25)
.build() .build()
} }
.diskCache { .diskCache {
DiskCache.Builder() DiskCache.Builder()
.directory(this.cacheDir.resolve("image_cache")) .directory(this.cacheDir.resolve("image_cache"))
.maxSizePercent(0.02) .maxSizePercent(0.02)
.build() .build()
} }
.respectCacheHeaders(false) // Often needed for some CDNs .respectCacheHeaders(false) // Often needed for some CDNs
.build() .build()
} }
} }

View file

@ -1,71 +1,71 @@
package com.streamflow.tv.data.api package com.streamflow.tv.data.api
import com.squareup.moshi.Moshi import com.squareup.moshi.Moshi
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
import okhttp3.Interceptor import okhttp3.Interceptor
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor import okhttp3.logging.HttpLoggingInterceptor
import retrofit2.Retrofit import retrofit2.Retrofit
import retrofit2.converter.moshi.MoshiConverterFactory import retrofit2.converter.moshi.MoshiConverterFactory
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
object ApiClient { object ApiClient {
// Default base URL for testing // Default base URL for testing
// Change this to your production API when ready // Change this to your production API when ready
// var baseUrl: String = "https://nf.khoavo.myds.me" // var baseUrl: String = "https://nf.khoavo.myds.me"
private var _baseUrl: String = "http://10.0.2.2:3478/" private var _baseUrl: String = "http://10.0.2.2:8000/"
var baseUrl: String var baseUrl: String
get() = _baseUrl get() = _baseUrl
set(value) { set(value) {
_baseUrl = if (value.endsWith("/")) value else "$value/" _baseUrl = if (value.endsWith("/")) value else "$value/"
synchronized(this) { synchronized(this) {
_api = null // Reset to rebuild _api = null // Reset to rebuild
} }
} }
private val moshi: Moshi = Moshi.Builder() private val moshi: Moshi = Moshi.Builder()
.addLast(KotlinJsonAdapterFactory()) .addLast(KotlinJsonAdapterFactory())
.build() .build()
private val userAgentInterceptor = Interceptor { chain -> private val userAgentInterceptor = Interceptor { chain ->
val request = chain.request().newBuilder() 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") .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() .build()
chain.proceed(request) chain.proceed(request)
} }
private val okHttpClient: OkHttpClient = OkHttpClient.Builder() private val okHttpClient: OkHttpClient = OkHttpClient.Builder()
.connectTimeout(20, TimeUnit.SECONDS) .connectTimeout(20, TimeUnit.SECONDS)
.readTimeout(60, TimeUnit.SECONDS) .readTimeout(60, TimeUnit.SECONDS)
.addInterceptor(userAgentInterceptor) .addInterceptor(userAgentInterceptor)
.addInterceptor( .addInterceptor(
HttpLoggingInterceptor().apply { HttpLoggingInterceptor().apply {
level = HttpLoggingInterceptor.Level.HEADERS level = HttpLoggingInterceptor.Level.HEADERS
} }
) )
.build() .build()
private var _api: StreamFlowApi? = null private var _api: StreamFlowApi? = null
val api: StreamFlowApi val api: StreamFlowApi
get() { get() {
return synchronized(this) { return synchronized(this) {
if (_api == null) { if (_api == null) {
_api = Retrofit.Builder() _api = Retrofit.Builder()
.baseUrl(_baseUrl) .baseUrl(_baseUrl)
.client(okHttpClient) .client(okHttpClient)
.addConverterFactory(MoshiConverterFactory.create(moshi)) .addConverterFactory(MoshiConverterFactory.create(moshi))
.build() .build()
.create(StreamFlowApi::class.java) .create(StreamFlowApi::class.java)
} }
_api!! _api!!
} }
} }
fun imageProxyUrl(url: String, width: Int = 400): String { fun imageProxyUrl(url: String, width: Int = 400): String {
val base = _baseUrl.removeSuffix("/") val base = _baseUrl.removeSuffix("/")
return "$base/api/images/proxy?url=${java.net.URLEncoder.encode(url, "UTF-8")}&width=$width" return "$base/api/images/proxy?url=${java.net.URLEncoder.encode(url, "UTF-8")}&width=$width"
} }
} }

View file

@ -1,35 +1,35 @@
package com.streamflow.tv.data.api package com.streamflow.tv.data.api
import com.streamflow.tv.data.model.* import com.streamflow.tv.data.model.*
import retrofit2.http.* import retrofit2.http.*
interface StreamFlowApi { interface StreamFlowApi {
@GET("api/videos/home") @GET("api/videos/home")
suspend fun getHomeVideos( suspend fun getHomeVideos(
@Query("category") category: String? = null, @Query("category") category: String? = null,
@Query("page") page: Int = 1 @Query("page") page: Int = 1
): List<Movie> ): List<Movie>
@GET("api/videos/search") @GET("api/videos/search")
suspend fun searchVideos( suspend fun searchVideos(
@Query("q") query: String, @Query("q") query: String,
@Query("page") page: Int = 1 @Query("page") page: Int = 1
): List<Movie> ): List<Movie>
@GET("api/videos/{slug}") @GET("api/videos/{slug}")
suspend fun getMovieDetail( suspend fun getMovieDetail(
@Path("slug") slug: String @Path("slug") slug: String
): MovieDetailResponse ): MovieDetailResponse
@POST("api/extract") @POST("api/extract")
suspend fun extractVideo( suspend fun extractVideo(
@Body request: ExtractRequest @Body request: ExtractRequest
): VideoSource ): VideoSource
@GET("api/categories/genres") @GET("api/categories/genres")
suspend fun getGenres(): List<Category> suspend fun getGenres(): List<Category>
@GET("api/categories/countries") @GET("api/categories/countries")
suspend fun getCountries(): List<Category> suspend fun getCountries(): List<Category>
} }

View file

@ -1,113 +1,113 @@
package com.streamflow.tv.data.model package com.streamflow.tv.data.model
import com.squareup.moshi.Json import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = false) @JsonClass(generateAdapter = false)
data class Movie( data class Movie(
val id: String = "", val id: String = "",
val title: String = "", val title: String = "",
@Json(name = "original_title") val originalTitle: String? = null, @Json(name = "original_title") val originalTitle: String? = null,
val slug: String = "", val slug: String = "",
val thumbnail: String = "", val thumbnail: String = "",
val backdrop: String? = null, val backdrop: String? = null,
val quality: String? = null, val quality: String? = null,
val year: Int? = null, val year: Int? = null,
val category: String = "", val category: String = "",
val time: String? = null, val time: String? = null,
val lang: String? = null, val lang: String? = null,
val director: String? = null, val director: String? = null,
val cast: List<String>? = null, val cast: List<String>? = null,
val provider: String? = null val provider: String? = null
) )
@JsonClass(generateAdapter = false) @JsonClass(generateAdapter = false)
data class MovieDetail( data class MovieDetail(
val id: String = "", val id: String = "",
val title: String = "", val title: String = "",
@Json(name = "original_title") val originalTitle: String? = null, @Json(name = "original_title") val originalTitle: String? = null,
val slug: String = "", val slug: String = "",
val thumbnail: String = "", val thumbnail: String = "",
val backdrop: String? = null, val backdrop: String? = null,
val quality: String? = null, val quality: String? = null,
val year: Int? = null, val year: Int? = null,
val category: String = "", val category: String = "",
val description: String = "", val description: String = "",
val rating: String? = null, val rating: String? = null,
val duration: Int? = null, val duration: Int? = null,
val genre: String? = null, val genre: String? = null,
val director: String? = null, val director: String? = null,
val country: String? = null, val country: String? = null,
val cast: List<String>? = null, val cast: List<String>? = null,
val provider: String? = null, val provider: String? = null,
val episodes: List<Episode>? = null val episodes: List<Episode>? = null
) { ) {
fun toMovie(): Movie = Movie( fun toMovie(): Movie = Movie(
id = id, id = id,
title = title, title = title,
originalTitle = originalTitle, originalTitle = originalTitle,
slug = slug, slug = slug,
thumbnail = thumbnail, thumbnail = thumbnail,
backdrop = backdrop, backdrop = backdrop,
quality = quality, quality = quality,
year = year, year = year,
category = category, category = category,
director = director, director = director,
cast = cast, cast = cast,
provider = provider provider = provider
) )
} }
@JsonClass(generateAdapter = false) @JsonClass(generateAdapter = false)
data class Episode( data class Episode(
val number: Int = 0, val number: Int = 0,
val title: String = "", val title: String = "",
val url: String = "" val url: String = ""
) )
@JsonClass(generateAdapter = false) @JsonClass(generateAdapter = false)
data class VideoSource( data class VideoSource(
@Json(name = "stream_url") val streamUrl: String = "", @Json(name = "stream_url") val streamUrl: String = "",
val resolution: String = "", val resolution: String = "",
@Json(name = "format_id") val formatId: String = "" @Json(name = "format_id") val formatId: String = ""
) )
@JsonClass(generateAdapter = false) @JsonClass(generateAdapter = false)
data class Category( data class Category(
val name: String = "", val name: String = "",
val slug: String = "" val slug: String = ""
) )
@JsonClass(generateAdapter = false) @JsonClass(generateAdapter = false)
data class HomeResponse( data class HomeResponse(
val items: List<Movie> = emptyList(), val items: List<Movie> = emptyList(),
val totalPages: Int = 1, val totalPages: Int = 1,
val currentPage: Int = 1 val currentPage: Int = 1
) )
@JsonClass(generateAdapter = false) @JsonClass(generateAdapter = false)
data class ExtractRequest( data class ExtractRequest(
val url: String val url: String
) )
@JsonClass(generateAdapter = false) @JsonClass(generateAdapter = false)
data class MovieDetailResponse( data class MovieDetailResponse(
val id: String = "", val id: String = "",
val title: String = "", val title: String = "",
@Json(name = "original_title") val originalTitle: String? = null, @Json(name = "original_title") val originalTitle: String? = null,
val slug: String = "", val slug: String = "",
val thumbnail: String = "", val thumbnail: String = "",
val backdrop: String? = null, val backdrop: String? = null,
val quality: String? = null, val quality: String? = null,
val year: Int? = null, val year: Int? = null,
val category: String = "", val category: String = "",
val description: String = "", val description: String = "",
val rating: String? = null, val rating: String? = null,
val duration: Int? = null, val duration: Int? = null,
val genre: String? = null, val genre: String? = null,
val director: String? = null, val director: String? = null,
val country: String? = null, val country: String? = null,
val cast: List<String>? = null, val cast: List<String>? = null,
val episodes: List<Episode>? = null val episodes: List<Episode>? = null
) )

View file

@ -1,60 +1,60 @@
package com.streamflow.tv.data.repository package com.streamflow.tv.data.repository
import com.streamflow.tv.data.api.ApiClient import com.streamflow.tv.data.api.ApiClient
import com.streamflow.tv.data.model.* import com.streamflow.tv.data.model.*
class MovieRepository { class MovieRepository {
private val api get() = ApiClient.api private val api get() = ApiClient.api
suspend fun getHomeVideos(category: String? = null, page: Int = 1): HomeResponse { suspend fun getHomeVideos(category: String? = null, page: Int = 1): HomeResponse {
val list = api.getHomeVideos(category, page) val list = api.getHomeVideos(category, page)
android.util.Log.e("MovieRepo", "getHomeVideos($category): Received ${list.size} items") android.util.Log.e("MovieRepo", "getHomeVideos($category): Received ${list.size} items")
return HomeResponse(items = list, totalPages = 10, currentPage = page) return HomeResponse(items = list, totalPages = 10, currentPage = page)
} }
suspend fun searchVideos(query: String, page: Int = 1): HomeResponse { suspend fun searchVideos(query: String, page: Int = 1): HomeResponse {
val list = api.searchVideos(query, page) val list = api.searchVideos(query, page)
android.util.Log.e("MovieRepo", "searchVideos($query): Received ${list.size} items") android.util.Log.e("MovieRepo", "searchVideos($query): Received ${list.size} items")
return HomeResponse(items = list, totalPages = 1, currentPage = page) return HomeResponse(items = list, totalPages = 1, currentPage = page)
} }
suspend fun getMovieDetail(slug: String): MovieDetail { suspend fun getMovieDetail(slug: String): MovieDetail {
val response = api.getMovieDetail(slug) val response = api.getMovieDetail(slug)
// API returns a flat list of episodes // API returns a flat list of episodes
val episodes = response.episodes ?: emptyList() val episodes = response.episodes ?: emptyList()
return MovieDetail( return MovieDetail(
id = response.id, id = response.id,
title = response.title, title = response.title,
originalTitle = response.originalTitle, originalTitle = response.originalTitle,
slug = response.slug, slug = response.slug,
thumbnail = response.thumbnail, thumbnail = response.thumbnail,
backdrop = response.backdrop, backdrop = response.backdrop,
quality = response.quality, quality = response.quality,
year = response.year, year = response.year,
category = response.category, category = response.category,
description = response.description, description = response.description,
rating = response.rating, rating = response.rating,
duration = response.duration, duration = response.duration,
genre = response.genre, genre = response.genre,
director = response.director, director = response.director,
country = response.country, country = response.country,
cast = response.cast, cast = response.cast,
episodes = episodes episodes = episodes
) )
} }
suspend fun extractVideo(url: String): VideoSource { suspend fun extractVideo(url: String): VideoSource {
return api.extractVideo(ExtractRequest(url)) return api.extractVideo(ExtractRequest(url))
} }
suspend fun getGenres(): List<Category> { suspend fun getGenres(): List<Category> {
return api.getGenres() return api.getGenres()
} }
suspend fun getCountries(): List<Category> { suspend fun getCountries(): List<Category> {
return api.getCountries() return api.getCountries()
} }
} }

View file

@ -1,103 +1,103 @@
package com.streamflow.tv.data.repository package com.streamflow.tv.data.repository
import android.content.Context import android.content.Context
import androidx.datastore.core.DataStore import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.* import androidx.datastore.preferences.core.*
import androidx.datastore.preferences.preferencesDataStore import androidx.datastore.preferences.preferencesDataStore
import com.squareup.moshi.Moshi import com.squareup.moshi.Moshi
import com.squareup.moshi.Types import com.squareup.moshi.Types
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
import com.streamflow.tv.data.model.Movie import com.streamflow.tv.data.model.Movie
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "user_data") private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "user_data")
class UserDataRepository(private val context: Context) { class UserDataRepository(private val context: Context) {
companion object { companion object {
private val MY_LIST_KEY = stringPreferencesKey("my_list") private val MY_LIST_KEY = stringPreferencesKey("my_list")
private val WATCH_HISTORY_KEY = stringPreferencesKey("watch_history") private val WATCH_HISTORY_KEY = stringPreferencesKey("watch_history")
private val THEME_KEY = stringPreferencesKey("theme") private val THEME_KEY = stringPreferencesKey("theme")
private val SERVER_URL_KEY = stringPreferencesKey("server_url") private val SERVER_URL_KEY = stringPreferencesKey("server_url")
private const val MAX_HISTORY = 50 private const val MAX_HISTORY = 50
} }
private val moshi = Moshi.Builder().addLast(KotlinJsonAdapterFactory()).build() private val moshi = Moshi.Builder().addLast(KotlinJsonAdapterFactory()).build()
private val movieListType = Types.newParameterizedType(List::class.java, Movie::class.java) private val movieListType = Types.newParameterizedType(List::class.java, Movie::class.java)
private val movieListAdapter = moshi.adapter<List<Movie>>(movieListType) private val movieListAdapter = moshi.adapter<List<Movie>>(movieListType)
// --- My List --- // --- My List ---
val myList: Flow<List<Movie>> = context.dataStore.data.map { prefs -> val myList: Flow<List<Movie>> = context.dataStore.data.map { prefs ->
val json = prefs[MY_LIST_KEY] ?: "[]" val json = prefs[MY_LIST_KEY] ?: "[]"
movieListAdapter.fromJson(json) ?: emptyList() movieListAdapter.fromJson(json) ?: emptyList()
} }
suspend fun addToMyList(movie: Movie) { suspend fun addToMyList(movie: Movie) {
context.dataStore.edit { prefs -> context.dataStore.edit { prefs ->
val current = movieListAdapter.fromJson(prefs[MY_LIST_KEY] ?: "[]") ?: emptyList() val current = movieListAdapter.fromJson(prefs[MY_LIST_KEY] ?: "[]") ?: emptyList()
if (current.none { it.slug == movie.slug }) { if (current.none { it.slug == movie.slug }) {
prefs[MY_LIST_KEY] = movieListAdapter.toJson(current + movie) prefs[MY_LIST_KEY] = movieListAdapter.toJson(current + movie)
} }
} }
} }
suspend fun removeFromMyList(slug: String) { suspend fun removeFromMyList(slug: String) {
context.dataStore.edit { prefs -> context.dataStore.edit { prefs ->
val current = movieListAdapter.fromJson(prefs[MY_LIST_KEY] ?: "[]") ?: emptyList() val current = movieListAdapter.fromJson(prefs[MY_LIST_KEY] ?: "[]") ?: emptyList()
prefs[MY_LIST_KEY] = movieListAdapter.toJson(current.filter { it.slug != slug }) prefs[MY_LIST_KEY] = movieListAdapter.toJson(current.filter { it.slug != slug })
} }
} }
suspend fun isInMyList(slug: String): Boolean { suspend fun isInMyList(slug: String): Boolean {
var found = false var found = false
context.dataStore.edit { prefs -> context.dataStore.edit { prefs ->
val current = movieListAdapter.fromJson(prefs[MY_LIST_KEY] ?: "[]") ?: emptyList() val current = movieListAdapter.fromJson(prefs[MY_LIST_KEY] ?: "[]") ?: emptyList()
found = current.any { it.slug == slug } found = current.any { it.slug == slug }
} }
return found return found
} }
// --- Watch History --- // --- Watch History ---
val watchHistory: Flow<List<Movie>> = context.dataStore.data.map { prefs -> val watchHistory: Flow<List<Movie>> = context.dataStore.data.map { prefs ->
val json = prefs[WATCH_HISTORY_KEY] ?: "[]" val json = prefs[WATCH_HISTORY_KEY] ?: "[]"
movieListAdapter.fromJson(json) ?: emptyList() movieListAdapter.fromJson(json) ?: emptyList()
} }
suspend fun addToHistory(movie: Movie) { suspend fun addToHistory(movie: Movie) {
context.dataStore.edit { prefs -> context.dataStore.edit { prefs ->
val current = movieListAdapter.fromJson(prefs[WATCH_HISTORY_KEY] ?: "[]")?.toMutableList() ?: mutableListOf() val current = movieListAdapter.fromJson(prefs[WATCH_HISTORY_KEY] ?: "[]")?.toMutableList() ?: mutableListOf()
current.removeAll { it.slug == movie.slug } current.removeAll { it.slug == movie.slug }
current.add(0, movie) // Most recent first current.add(0, movie) // Most recent first
val trimmed = current.take(MAX_HISTORY) val trimmed = current.take(MAX_HISTORY)
prefs[WATCH_HISTORY_KEY] = movieListAdapter.toJson(trimmed) prefs[WATCH_HISTORY_KEY] = movieListAdapter.toJson(trimmed)
} }
} }
// --- Theme --- // --- Theme ---
val theme: Flow<String> = context.dataStore.data.map { prefs -> val theme: Flow<String> = context.dataStore.data.map { prefs ->
prefs[THEME_KEY] ?: "default" prefs[THEME_KEY] ?: "default"
} }
suspend fun setTheme(theme: String) { suspend fun setTheme(theme: String) {
context.dataStore.edit { prefs -> context.dataStore.edit { prefs ->
prefs[THEME_KEY] = theme prefs[THEME_KEY] = theme
} }
} }
// --- Server URL --- // --- Server URL ---
val serverUrl: Flow<String> = context.dataStore.data.map { prefs -> val serverUrl: Flow<String> = context.dataStore.data.map { prefs ->
prefs[SERVER_URL_KEY] ?: "https://nf.khoavo.myds.me" prefs[SERVER_URL_KEY] ?: "https://nf.khoavo.myds.me"
} }
suspend fun setServerUrl(url: String) { suspend fun setServerUrl(url: String) {
context.dataStore.edit { prefs -> context.dataStore.edit { prefs ->
prefs[SERVER_URL_KEY] = url prefs[SERVER_URL_KEY] = url
} }
} }
} }

View file

@ -1,76 +1,76 @@
package com.streamflow.tv.ui.components package com.streamflow.tv.ui.components
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.tv.foundation.lazy.grid.TvGridCells import androidx.tv.foundation.lazy.grid.TvGridCells
import androidx.tv.foundation.lazy.grid.TvLazyVerticalGrid import androidx.tv.foundation.lazy.grid.TvLazyVerticalGrid
import androidx.tv.foundation.lazy.grid.items import androidx.tv.foundation.lazy.grid.items
import androidx.tv.material3.* import androidx.tv.material3.*
import com.streamflow.tv.data.model.Episode import com.streamflow.tv.data.model.Episode
import com.streamflow.tv.ui.theme.StreamFlowTheme import com.streamflow.tv.ui.theme.StreamFlowTheme
@OptIn(ExperimentalTvMaterial3Api::class) @OptIn(ExperimentalTvMaterial3Api::class)
@Composable @Composable
fun EpisodeSelector( fun EpisodeSelector(
episodes: List<Episode>, episodes: List<Episode>,
currentEpisode: Int, currentEpisode: Int,
onEpisodeSelect: (Episode) -> Unit, onEpisodeSelect: (Episode) -> Unit,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
val colors = StreamFlowTheme.colors val colors = StreamFlowTheme.colors
Column(modifier = modifier) { Column(modifier = modifier) {
Text( Text(
text = "Episodes", text = "Episodes",
style = StreamFlowTheme.typography.headlineMedium, style = StreamFlowTheme.typography.headlineMedium,
modifier = Modifier.padding(bottom = 12.dp) modifier = Modifier.padding(bottom = 12.dp)
) )
android.util.Log.e("EpisodeSelector", "Rendering grid with ${episodes.size} episodes") android.util.Log.e("EpisodeSelector", "Rendering grid with ${episodes.size} episodes")
TvLazyVerticalGrid( TvLazyVerticalGrid(
columns = TvGridCells.Adaptive(minSize = 120.dp), columns = TvGridCells.Adaptive(minSize = 120.dp),
contentPadding = PaddingValues(4.dp), contentPadding = PaddingValues(4.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp) verticalArrangement = Arrangement.spacedBy(8.dp)
) { ) {
items(episodes) { episode -> items(episodes) { episode ->
val isActive = episode.number == currentEpisode val isActive = episode.number == currentEpisode
var isFocused by remember { mutableStateOf(false) } var isFocused by remember { mutableStateOf(false) }
Surface( Surface(
onClick = { onEpisodeSelect(episode) }, onClick = { onEpisodeSelect(episode) },
modifier = Modifier modifier = Modifier
.onFocusChanged { isFocused = it.isFocused }, .onFocusChanged { isFocused = it.isFocused },
shape = ClickableSurfaceDefaults.shape( shape = ClickableSurfaceDefaults.shape(
shape = RoundedCornerShape(8.dp) shape = RoundedCornerShape(8.dp)
), ),
colors = ClickableSurfaceDefaults.colors( colors = ClickableSurfaceDefaults.colors(
containerColor = if (isActive) colors.primary.copy(alpha = 0.2f) else colors.surfaceVariant, containerColor = if (isActive) colors.primary.copy(alpha = 0.2f) else colors.surfaceVariant,
focusedContainerColor = colors.primary.copy(alpha = 0.3f) focusedContainerColor = colors.primary.copy(alpha = 0.3f)
), ),
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.05f) scale = ClickableSurfaceDefaults.scale(focusedScale = 1.05f)
) { ) {
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(vertical = 12.dp, horizontal = 16.dp), .padding(vertical = 12.dp, horizontal = 16.dp),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
Text( Text(
text = if (episode.title.isNotBlank()) episode.title else "Ep ${episode.number}", text = if (episode.title.isNotBlank()) episode.title else "Ep ${episode.number}",
style = StreamFlowTheme.typography.labelLarge.copy( style = StreamFlowTheme.typography.labelLarge.copy(
color = if (isActive) colors.primary else Color.White color = if (isActive) colors.primary else Color.White
) )
) )
} }
} }
} }
} }
} }
} }

View file

@ -1,159 +1,159 @@
package com.streamflow.tv.ui.components package com.streamflow.tv.ui.components
import androidx.compose.animation.* import androidx.compose.animation.*
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.tv.material3.* import androidx.tv.material3.*
import coil.compose.AsyncImage import coil.compose.AsyncImage
import com.streamflow.tv.data.api.ApiClient import com.streamflow.tv.data.api.ApiClient
import com.streamflow.tv.data.model.Movie import com.streamflow.tv.data.model.Movie
import com.streamflow.tv.ui.theme.StreamFlowTheme import com.streamflow.tv.ui.theme.StreamFlowTheme
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
@OptIn(ExperimentalTvMaterial3Api::class) @OptIn(ExperimentalTvMaterial3Api::class)
@Composable @Composable
fun HeroBanner( fun HeroBanner(
movies: List<Movie>, movies: List<Movie>,
onPlayClick: (Movie) -> Unit, onPlayClick: (Movie) -> Unit,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
if (movies.isEmpty()) return if (movies.isEmpty()) return
val colors = StreamFlowTheme.colors val colors = StreamFlowTheme.colors
var currentIndex by remember { mutableIntStateOf(0) } var currentIndex by remember { mutableIntStateOf(0) }
val currentMovie = movies[currentIndex] val currentMovie = movies[currentIndex]
LaunchedEffect(currentIndex) { LaunchedEffect(currentIndex) {
delay(6000) delay(6000)
currentIndex = (currentIndex + 1) % movies.size currentIndex = (currentIndex + 1) % movies.size
} }
Box( Box(
modifier = modifier modifier = modifier
.fillMaxWidth() .fillMaxWidth()
.height(480.dp) .height(480.dp)
) { ) {
AnimatedContent( AnimatedContent(
targetState = currentMovie, targetState = currentMovie,
transitionSpec = { fadeIn() togetherWith fadeOut() }, transitionSpec = { fadeIn() togetherWith fadeOut() },
label = "hero-crossfade" label = "hero-crossfade"
) { movie -> ) { movie ->
AsyncImage( AsyncImage(
model = ApiClient.imageProxyUrl(movie.backdrop ?: movie.thumbnail, 1280), model = ApiClient.imageProxyUrl(movie.backdrop ?: movie.thumbnail, 1280),
contentDescription = movie.title, contentDescription = movie.title,
contentScale = ContentScale.Crop, contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize() modifier = Modifier.fillMaxSize()
) )
} }
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.background( .background(
Brush.horizontalGradient( Brush.horizontalGradient(
colors = listOf( colors = listOf(
colors.background.copy(alpha = 0.9f), colors.background.copy(alpha = 0.9f),
colors.background.copy(alpha = 0.5f), colors.background.copy(alpha = 0.5f),
Color.Transparent Color.Transparent
) )
) )
) )
) )
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.fillMaxHeight(0.4f) .fillMaxHeight(0.4f)
.align(Alignment.BottomCenter) .align(Alignment.BottomCenter)
.background( .background(
Brush.verticalGradient( Brush.verticalGradient(
colors = listOf(Color.Transparent, colors.background) colors = listOf(Color.Transparent, colors.background)
) )
) )
) )
Column( Column(
modifier = Modifier modifier = Modifier
.align(Alignment.CenterStart) .align(Alignment.CenterStart)
.padding(start = 48.dp, end = 200.dp) .padding(start = 48.dp, end = 200.dp)
.fillMaxHeight(), .fillMaxHeight(),
verticalArrangement = Arrangement.Center verticalArrangement = Arrangement.Center
) { ) {
currentMovie.quality?.let { quality -> currentMovie.quality?.let { quality ->
Box( Box(
modifier = Modifier modifier = Modifier
.background(colors.primary, RoundedCornerShape(4.dp)) .background(colors.primary, RoundedCornerShape(4.dp))
.padding(horizontal = 8.dp, vertical = 4.dp) .padding(horizontal = 8.dp, vertical = 4.dp)
) { ) {
Text( Text(
text = quality, text = quality,
style = StreamFlowTheme.typography.labelSmall.copy(color = Color.White) style = StreamFlowTheme.typography.labelSmall.copy(color = Color.White)
) )
} }
Spacer(Modifier.height(12.dp)) Spacer(Modifier.height(12.dp))
} }
Text( Text(
text = currentMovie.title, text = currentMovie.title,
style = StreamFlowTheme.typography.displayLarge, style = StreamFlowTheme.typography.displayLarge,
maxLines = 2, maxLines = 2,
overflow = TextOverflow.Ellipsis overflow = TextOverflow.Ellipsis
) )
Spacer(Modifier.height(12.dp)) Spacer(Modifier.height(12.dp))
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
currentMovie.year?.let { currentMovie.year?.let {
Text("$it", style = StreamFlowTheme.typography.bodyLarge) Text("$it", style = StreamFlowTheme.typography.bodyLarge)
} }
} }
Spacer(Modifier.height(16.dp)) Spacer(Modifier.height(16.dp))
Surface( Surface(
onClick = { onPlayClick(currentMovie) }, onClick = { onPlayClick(currentMovie) },
shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(8.dp)), shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(8.dp)),
colors = ClickableSurfaceDefaults.colors( colors = ClickableSurfaceDefaults.colors(
containerColor = colors.primary, containerColor = colors.primary,
focusedContainerColor = colors.accent focusedContainerColor = colors.accent
), ),
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.05f) scale = ClickableSurfaceDefaults.scale(focusedScale = 1.05f)
) { ) {
Text( Text(
text = "▶ Play Now", text = "▶ Play Now",
style = StreamFlowTheme.typography.titleMedium.copy(color = Color.White), style = StreamFlowTheme.typography.titleMedium.copy(color = Color.White),
modifier = Modifier.padding(horizontal = 24.dp, vertical = 12.dp) modifier = Modifier.padding(horizontal = 24.dp, vertical = 12.dp)
) )
} }
} }
Row( Row(
modifier = Modifier modifier = Modifier
.align(Alignment.BottomCenter) .align(Alignment.BottomCenter)
.padding(bottom = 16.dp), .padding(bottom = 16.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp) horizontalArrangement = Arrangement.spacedBy(8.dp)
) { ) {
movies.forEachIndexed { index, _ -> movies.forEachIndexed { index, _ ->
Box( Box(
modifier = Modifier modifier = Modifier
.size(if (index == currentIndex) 24.dp else 8.dp, 8.dp) .size(if (index == currentIndex) 24.dp else 8.dp, 8.dp)
.clip(CircleShape) .clip(CircleShape)
.background( .background(
if (index == currentIndex) colors.primary if (index == currentIndex) colors.primary
else Color.White.copy(alpha = 0.3f) else Color.White.copy(alpha = 0.3f)
) )
) )
} }
} }
} }
} }

View file

@ -1,116 +1,116 @@
package com.streamflow.tv.ui.components package com.streamflow.tv.ui.components
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.tv.material3.* import androidx.tv.material3.*
import coil.compose.AsyncImage import coil.compose.AsyncImage
import com.streamflow.tv.data.api.ApiClient import com.streamflow.tv.data.api.ApiClient
import com.streamflow.tv.data.model.Movie import com.streamflow.tv.data.model.Movie
import com.streamflow.tv.ui.theme.StreamFlowTheme import com.streamflow.tv.ui.theme.StreamFlowTheme
@OptIn(ExperimentalTvMaterial3Api::class) @OptIn(ExperimentalTvMaterial3Api::class)
@Composable @Composable
fun MovieCard( fun MovieCard(
movie: Movie, movie: Movie,
onClick: () -> Unit, onClick: () -> Unit,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
val colors = StreamFlowTheme.colors val colors = StreamFlowTheme.colors
Surface( Surface(
onClick = onClick, onClick = onClick,
modifier = modifier modifier = modifier
.width(200.dp) .width(200.dp)
.height(300.dp), .height(300.dp),
shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(12.dp)), shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(12.dp)),
colors = ClickableSurfaceDefaults.colors( colors = ClickableSurfaceDefaults.colors(
containerColor = colors.surfaceVariant, containerColor = colors.surfaceVariant,
focusedContainerColor = colors.surfaceVariant focusedContainerColor = colors.surfaceVariant
), ),
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.08f) scale = ClickableSurfaceDefaults.scale(focusedScale = 1.08f)
) { ) {
Box(modifier = Modifier.fillMaxSize()) { Box(modifier = Modifier.fillMaxSize()) {
AsyncImage( AsyncImage(
model = ApiClient.imageProxyUrl(movie.thumbnail, 300), model = ApiClient.imageProxyUrl(movie.thumbnail, 300),
contentDescription = movie.title, contentDescription = movie.title,
contentScale = ContentScale.Crop, contentScale = ContentScale.Crop,
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.clip(RoundedCornerShape(12.dp)) .clip(RoundedCornerShape(12.dp))
) )
movie.quality?.let { quality -> movie.quality?.let { quality ->
Box( Box(
modifier = Modifier modifier = Modifier
.padding(8.dp) .padding(8.dp)
.align(Alignment.TopEnd) .align(Alignment.TopEnd)
.background(colors.primary, RoundedCornerShape(4.dp)) .background(colors.primary, RoundedCornerShape(4.dp))
.padding(horizontal = 6.dp, vertical = 2.dp) .padding(horizontal = 6.dp, vertical = 2.dp)
) { ) {
Text( Text(
text = quality, text = quality,
style = StreamFlowTheme.typography.labelSmall.copy(color = Color.White) style = StreamFlowTheme.typography.labelSmall.copy(color = Color.White)
) )
} }
} }
movie.provider?.let { provider -> movie.provider?.let { provider ->
Box( Box(
modifier = Modifier modifier = Modifier
.padding(8.dp) .padding(8.dp)
.align(Alignment.TopStart) .align(Alignment.TopStart)
.background(Color.Black.copy(alpha = 0.6f), RoundedCornerShape(4.dp)) .background(Color.Black.copy(alpha = 0.6f), RoundedCornerShape(4.dp))
.padding(horizontal = 6.dp, vertical = 2.dp) .padding(horizontal = 6.dp, vertical = 2.dp)
) { ) {
Text( Text(
text = provider, text = provider,
style = StreamFlowTheme.typography.labelSmall.copy( style = StreamFlowTheme.typography.labelSmall.copy(
color = Color.White.copy(alpha = 0.8f), color = Color.White.copy(alpha = 0.8f),
fontSize = androidx.compose.ui.unit.TextUnit.Unspecified // Default or small fontSize = androidx.compose.ui.unit.TextUnit.Unspecified // Default or small
), ),
maxLines = 1 maxLines = 1
) )
} }
} }
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.align(Alignment.BottomCenter) .align(Alignment.BottomCenter)
.background( .background(
Brush.verticalGradient( Brush.verticalGradient(
colors = listOf(Color.Transparent, Color.Black.copy(alpha = 0.85f)) colors = listOf(Color.Transparent, Color.Black.copy(alpha = 0.85f))
) )
) )
.padding(horizontal = 10.dp, vertical = 10.dp) .padding(horizontal = 10.dp, vertical = 10.dp)
) { ) {
Text( Text(
text = movie.title, text = movie.title,
style = StreamFlowTheme.typography.labelLarge, style = StreamFlowTheme.typography.labelLarge,
maxLines = 2, maxLines = 2,
overflow = TextOverflow.Ellipsis overflow = TextOverflow.Ellipsis
) )
movie.year?.let { year -> movie.year?.let { year ->
Text( Text(
text = year.toString(), text = year.toString(),
style = StreamFlowTheme.typography.labelSmall.copy( style = StreamFlowTheme.typography.labelSmall.copy(
color = Color.White.copy(alpha = 0.6f) color = Color.White.copy(alpha = 0.6f)
) )
) )
} }
} }
} }
} }
} }

View file

@ -1,43 +1,43 @@
package com.streamflow.tv.ui.components package com.streamflow.tv.ui.components
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.tv.foundation.lazy.list.TvLazyRow import androidx.tv.foundation.lazy.list.TvLazyRow
import androidx.tv.foundation.lazy.list.items import androidx.tv.foundation.lazy.list.items
import androidx.tv.material3.ExperimentalTvMaterial3Api import androidx.tv.material3.ExperimentalTvMaterial3Api
import androidx.tv.material3.Text import androidx.tv.material3.Text
import com.streamflow.tv.data.model.Movie import com.streamflow.tv.data.model.Movie
import com.streamflow.tv.ui.theme.StreamFlowTheme import com.streamflow.tv.ui.theme.StreamFlowTheme
@OptIn(ExperimentalTvMaterial3Api::class) @OptIn(ExperimentalTvMaterial3Api::class)
@Composable @Composable
fun MovieRow( fun MovieRow(
title: String, title: String,
movies: List<Movie>, movies: List<Movie>,
onMovieClick: (Movie) -> Unit, onMovieClick: (Movie) -> Unit,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
Column(modifier = modifier.padding(vertical = 12.dp)) { Column(modifier = modifier.padding(vertical = 12.dp)) {
// Section title // Section title
Text( Text(
text = title, text = title,
style = StreamFlowTheme.typography.headlineMedium, style = StreamFlowTheme.typography.headlineMedium,
modifier = Modifier.padding(start = 48.dp, bottom = 12.dp) modifier = Modifier.padding(start = 48.dp, bottom = 12.dp)
) )
// Horizontal scrolling row of cards // Horizontal scrolling row of cards
TvLazyRow( TvLazyRow(
contentPadding = PaddingValues(horizontal = 48.dp), contentPadding = PaddingValues(horizontal = 48.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp) horizontalArrangement = Arrangement.spacedBy(16.dp)
) { ) {
items(movies) { movie -> items(movies) { movie ->
MovieCard( MovieCard(
movie = movie, movie = movie,
onClick = { onMovieClick(movie) } onClick = { onMovieClick(movie) }
) )
} }
} }
} }
} }

View file

@ -1,127 +1,127 @@
package com.streamflow.tv.ui.components package com.streamflow.tv.ui.components
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.* import androidx.compose.material.icons.filled.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.tv.material3.* import androidx.tv.material3.*
import com.streamflow.tv.ui.theme.StreamFlowTheme import com.streamflow.tv.ui.theme.StreamFlowTheme
import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.focusRequester
data class NavItem( data class NavItem(
val id: String, val id: String,
val route: String, val route: String,
val label: String, val label: String,
val icon: ImageVector val icon: ImageVector
) )
val NAV_ITEMS = listOf( val NAV_ITEMS = listOf(
NavItem("home", "home", "Home", Icons.Default.Home), NavItem("home", "home", "Home", Icons.Default.Home),
NavItem("categories", "home/phim-le", "Categories", Icons.Default.Category), NavItem("categories", "home/phim-le", "Categories", Icons.Default.Category),
NavItem("search", "search", "Search", Icons.Default.Search), NavItem("search", "search", "Search", Icons.Default.Search),
NavItem("mylist", "mylist", "My List", Icons.Default.Favorite), NavItem("mylist", "mylist", "My List", Icons.Default.Favorite),
NavItem("settings", "settings", "Settings", Icons.Default.Settings) NavItem("settings", "settings", "Settings", Icons.Default.Settings)
) )
@OptIn(ExperimentalTvMaterial3Api::class) @OptIn(ExperimentalTvMaterial3Api::class)
@Composable @Composable
fun SideNavRail( fun SideNavRail(
selectedId: String, selectedId: String,
onNavigate: (NavItem) -> Unit, onNavigate: (NavItem) -> Unit,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
val colors = StreamFlowTheme.colors val colors = StreamFlowTheme.colors
val focusRequester = remember { FocusRequester() } val focusRequester = remember { FocusRequester() }
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
try { try {
focusRequester.requestFocus() focusRequester.requestFocus()
} catch (e: Exception) { } catch (e: Exception) {
// Ignore // Ignore
} }
} }
Column( Column(
modifier = modifier modifier = modifier
.fillMaxHeight() .fillMaxHeight()
.width(56.dp) .width(56.dp)
.background(colors.background.copy(alpha = 0.95f)) .background(colors.background.copy(alpha = 0.95f))
.padding(vertical = 16.dp), .padding(vertical = 16.dp),
verticalArrangement = Arrangement.SpaceBetween, verticalArrangement = Arrangement.SpaceBetween,
horizontalAlignment = Alignment.CenterHorizontally horizontalAlignment = Alignment.CenterHorizontally
) { ) {
Box( Box(
modifier = Modifier modifier = Modifier
.size(36.dp) .size(36.dp)
.clip(CircleShape) .clip(CircleShape)
.background(colors.primary), .background(colors.primary),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
Text("S", style = StreamFlowTheme.typography.titleMedium.copy(color = Color.White)) Text("S", style = StreamFlowTheme.typography.titleMedium.copy(color = Color.White))
} }
Spacer(Modifier.height(24.dp)) Spacer(Modifier.height(24.dp))
Column( Column(
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(4.dp), verticalArrangement = Arrangement.spacedBy(4.dp),
horizontalAlignment = Alignment.CenterHorizontally horizontalAlignment = Alignment.CenterHorizontally
) { ) {
NAV_ITEMS.forEach { item -> NAV_ITEMS.forEach { item ->
NavRailItem( NavRailItem(
item = item, item = item,
isSelected = selectedId == item.id, isSelected = selectedId == item.id,
onClick = { onNavigate(item) }, onClick = { onNavigate(item) },
accentColor = colors.primary, accentColor = colors.primary,
modifier = if (item.id == "home") Modifier.focusRequester(focusRequester) else Modifier modifier = if (item.id == "home") Modifier.focusRequester(focusRequester) else Modifier
) )
} }
} }
} }
} }
@OptIn(ExperimentalTvMaterial3Api::class) @OptIn(ExperimentalTvMaterial3Api::class)
@Composable @Composable
private fun NavRailItem( private fun NavRailItem(
item: NavItem, item: NavItem,
isSelected: Boolean, isSelected: Boolean,
onClick: () -> Unit, onClick: () -> Unit,
accentColor: Color, accentColor: Color,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
var isFocused by remember { mutableStateOf(false) } var isFocused by remember { mutableStateOf(false) }
Surface( Surface(
onClick = onClick, onClick = onClick,
modifier = modifier modifier = modifier
.size(48.dp), .size(48.dp),
shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(12.dp)), shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(12.dp)),
colors = ClickableSurfaceDefaults.colors( colors = ClickableSurfaceDefaults.colors(
containerColor = if (isSelected) accentColor.copy(alpha = 0.15f) else Color.Transparent, containerColor = if (isSelected) accentColor.copy(alpha = 0.15f) else Color.Transparent,
focusedContainerColor = accentColor.copy(alpha = 0.2f) focusedContainerColor = accentColor.copy(alpha = 0.2f)
), ),
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.1f) scale = ClickableSurfaceDefaults.scale(focusedScale = 1.1f)
) { ) {
Box( Box(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
Icon( Icon(
imageVector = item.icon, imageVector = item.icon,
contentDescription = item.label, contentDescription = item.label,
tint = if (isSelected) accentColor else Color.White.copy(alpha = 0.6f), tint = if (isSelected) accentColor else Color.White.copy(alpha = 0.6f),
modifier = Modifier.size(22.dp) modifier = Modifier.size(22.dp)
) )
} }
} }
} }

View file

@ -1,98 +1,98 @@
package com.streamflow.tv.ui.navigation package com.streamflow.tv.ui.navigation
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.navigation.NavType import androidx.navigation.NavType
import androidx.navigation.compose.NavHost import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController import androidx.navigation.compose.rememberNavController
import androidx.navigation.navArgument import androidx.navigation.navArgument
import com.streamflow.tv.ui.screens.* import com.streamflow.tv.ui.screens.*
@Composable @Composable
fun AppNavigation( fun AppNavigation(
currentTheme: String, currentTheme: String,
onThemeChange: (String) -> Unit onThemeChange: (String) -> Unit
) { ) {
val navController = rememberNavController() val navController = rememberNavController()
NavHost(navController = navController, startDestination = "home") { NavHost(navController = navController, startDestination = "home") {
// Home (all categories) // Home (all categories)
composable("home") { composable("home") {
HomeScreen( HomeScreen(
onMovieClick = { slug -> navController.navigate("detail/$slug") } onMovieClick = { slug -> navController.navigate("detail/$slug") }
) )
} }
// Home filtered by category // Home filtered by category
composable( composable(
"home/{category}", "home/{category}",
arguments = listOf(navArgument("category") { type = NavType.StringType }) arguments = listOf(navArgument("category") { type = NavType.StringType })
) { backStackEntry -> ) { backStackEntry ->
val category = backStackEntry.arguments?.getString("category") val category = backStackEntry.arguments?.getString("category")
HomeScreen( HomeScreen(
onMovieClick = { slug -> navController.navigate("detail/$slug") }, onMovieClick = { slug -> navController.navigate("detail/$slug") },
category = category category = category
) )
} }
// Movie Detail // Movie Detail
composable( composable(
"detail/{slug}", "detail/{slug}",
arguments = listOf(navArgument("slug") { type = NavType.StringType }) arguments = listOf(navArgument("slug") { type = NavType.StringType })
) { backStackEntry -> ) { backStackEntry ->
val slug = backStackEntry.arguments?.getString("slug") ?: return@composable val slug = backStackEntry.arguments?.getString("slug") ?: return@composable
DetailScreen( DetailScreen(
slug = slug, slug = slug,
onPlayClick = { s, ep -> navController.navigate("player/$s/$ep") }, onPlayClick = { s, ep -> navController.navigate("player/$s/$ep") },
onBack = { navController.popBackStack() } onBack = { navController.popBackStack() }
) )
} }
// Video Player // Video Player
composable( composable(
"player/{slug}/{episode}", "player/{slug}/{episode}",
arguments = listOf( arguments = listOf(
navArgument("slug") { type = NavType.StringType }, navArgument("slug") { type = NavType.StringType },
navArgument("episode") { type = NavType.IntType; defaultValue = 1 } navArgument("episode") { type = NavType.IntType; defaultValue = 1 }
) )
) { backStackEntry -> ) { backStackEntry ->
val slug = backStackEntry.arguments?.getString("slug") ?: return@composable val slug = backStackEntry.arguments?.getString("slug") ?: return@composable
val episode = backStackEntry.arguments?.getInt("episode") ?: 1 val episode = backStackEntry.arguments?.getInt("episode") ?: 1
PlayerScreen(slug = slug, episode = episode) PlayerScreen(slug = slug, episode = episode)
} }
// Search // Search
composable("search") { composable("search") {
SearchScreen( SearchScreen(
onMovieClick = { slug -> navController.navigate("detail/$slug") } onMovieClick = { slug -> navController.navigate("detail/$slug") }
) )
} }
// My List // My List
composable("mylist") { composable("mylist") {
MyListScreen( MyListScreen(
onMovieClick = { slug -> navController.navigate("detail/$slug") } onMovieClick = { slug -> navController.navigate("detail/$slug") }
) )
} }
// Settings // Settings
composable("settings") { composable("settings") {
SettingsScreen( SettingsScreen(
currentTheme = currentTheme, currentTheme = currentTheme,
onThemeChange = onThemeChange onThemeChange = onThemeChange
) )
} }
} }
// Expose navController for SideNavRail // Expose navController for SideNavRail
LaunchedEffect(navController) { LaunchedEffect(navController) {
// Store nav controller reference for side nav // Store nav controller reference for side nav
} }
// Provide nav controller via local // Provide nav controller via local
CompositionLocalProvider(LocalNavController provides navController) {} CompositionLocalProvider(LocalNavController provides navController) {}
} }
val LocalNavController = staticCompositionLocalOf<androidx.navigation.NavHostController> { val LocalNavController = staticCompositionLocalOf<androidx.navigation.NavHostController> {
error("NavController not provided") error("NavController not provided")
} }

View file

@ -1,200 +1,200 @@
package com.streamflow.tv.ui.screens package com.streamflow.tv.ui.screens
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import android.util.Log import android.util.Log
import androidx.tv.material3.ClickableSurfaceDefaults import androidx.tv.material3.ClickableSurfaceDefaults
import androidx.tv.material3.ExperimentalTvMaterial3Api import androidx.tv.material3.ExperimentalTvMaterial3Api
import androidx.tv.material3.MaterialTheme import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Surface import androidx.tv.material3.Surface
import androidx.tv.material3.Text import androidx.tv.material3.Text
import coil.compose.AsyncImage import coil.compose.AsyncImage
import com.streamflow.tv.data.api.ApiClient import com.streamflow.tv.data.api.ApiClient
import com.streamflow.tv.data.model.Episode import com.streamflow.tv.data.model.Episode
import com.streamflow.tv.ui.components.EpisodeSelector import com.streamflow.tv.ui.components.EpisodeSelector
import com.streamflow.tv.ui.theme.StreamFlowTheme import com.streamflow.tv.ui.theme.StreamFlowTheme
import com.streamflow.tv.viewmodel.DetailViewModel import com.streamflow.tv.viewmodel.DetailViewModel
@OptIn(ExperimentalTvMaterial3Api::class) @OptIn(ExperimentalTvMaterial3Api::class)
@Composable @Composable
fun DetailScreen( fun DetailScreen(
slug: String, slug: String,
onPlayClick: (String, Int) -> Unit, onPlayClick: (String, Int) -> Unit,
onBack: () -> Unit, onBack: () -> Unit,
viewModel: DetailViewModel = viewModel() viewModel: DetailViewModel = viewModel()
) { ) {
val uiState by viewModel.uiState.collectAsState() val uiState by viewModel.uiState.collectAsState()
val colors = StreamFlowTheme.colors val colors = StreamFlowTheme.colors
LaunchedEffect(slug) { LaunchedEffect(slug) {
viewModel.loadMovie(slug) viewModel.loadMovie(slug)
} }
Log.d("DetailScreen", "Composing DetailScreen(slug=$slug, isLoading=${uiState.isLoading})") Log.d("DetailScreen", "Composing DetailScreen(slug=$slug, isLoading=${uiState.isLoading})")
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.background(colors.background), .background(colors.background),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
if (uiState.isLoading) { if (uiState.isLoading) {
CircularLoadingIndicator() CircularLoadingIndicator()
} else if (uiState.error != null) { } else if (uiState.error != null) {
ErrorState(message = uiState.error ?: "Unknown error", onRetry = { viewModel.loadMovie(slug) }) ErrorState(message = uiState.error ?: "Unknown error", onRetry = { viewModel.loadMovie(slug) })
} else { } else {
val movie = uiState.movie ?: return@Box val movie = uiState.movie ?: return@Box
Log.d("DetailScreen", "Rendering movie details: ${movie.title}") Log.d("DetailScreen", "Rendering movie details: ${movie.title}")
// Background Image // Background Image
AsyncImage( AsyncImage(
model = ApiClient.imageProxyUrl(movie.backdrop ?: movie.thumbnail, 1280), model = ApiClient.imageProxyUrl(movie.backdrop ?: movie.thumbnail, 1280),
contentDescription = null, contentDescription = null,
contentScale = ContentScale.Crop, contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize() modifier = Modifier.fillMaxSize()
) )
// Gradient Overlays // Gradient Overlays
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.background( .background(
Brush.horizontalGradient( Brush.horizontalGradient(
colors = listOf( colors = listOf(
colors.background.copy(alpha = 0.95f), colors.background.copy(alpha = 0.95f),
colors.background.copy(alpha = 0.7f), colors.background.copy(alpha = 0.7f),
Color.Transparent Color.Transparent
) )
) )
) )
) )
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.fillMaxHeight(0.3f) .fillMaxHeight(0.3f)
.align(Alignment.BottomCenter) .align(Alignment.BottomCenter)
.background( .background(
Brush.verticalGradient( Brush.verticalGradient(
colors = listOf(Color.Transparent, colors.background) colors = listOf(Color.Transparent, colors.background)
) )
) )
) )
// Content // Content
val focusRequester = remember { FocusRequester() } val focusRequester = remember { FocusRequester() }
LaunchedEffect(uiState.movie) { LaunchedEffect(uiState.movie) {
if (uiState.movie != null) { if (uiState.movie != null) {
focusRequester.requestFocus() focusRequester.requestFocus()
} }
} }
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.padding(horizontal = 48.dp, vertical = 32.dp), .padding(horizontal = 48.dp, vertical = 32.dp),
verticalArrangement = Arrangement.Center verticalArrangement = Arrangement.Center
) { ) {
Text( Text(
text = movie.title, text = movie.title,
style = StreamFlowTheme.typography.displayLarge, style = StreamFlowTheme.typography.displayLarge,
maxLines = 2, maxLines = 2,
overflow = TextOverflow.Ellipsis overflow = TextOverflow.Ellipsis
) )
Spacer(Modifier.height(16.dp)) Spacer(Modifier.height(16.dp))
Text( Text(
text = movie.description, text = movie.description,
style = StreamFlowTheme.typography.bodyMedium, style = StreamFlowTheme.typography.bodyMedium,
maxLines = 3, maxLines = 3,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
modifier = Modifier.widthIn(max = 600.dp) modifier = Modifier.widthIn(max = 600.dp)
) )
Spacer(Modifier.height(32.dp)) Spacer(Modifier.height(32.dp))
Surface( Surface(
onClick = { onPlayClick(movie.slug, 1) }, onClick = { onPlayClick(movie.slug, 1) },
shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(8.dp)), shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(8.dp)),
colors = ClickableSurfaceDefaults.colors( colors = ClickableSurfaceDefaults.colors(
containerColor = colors.primary, containerColor = colors.primary,
focusedContainerColor = colors.accent focusedContainerColor = colors.accent
), ),
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.05f), scale = ClickableSurfaceDefaults.scale(focusedScale = 1.05f),
modifier = Modifier.focusRequester(focusRequester) modifier = Modifier.focusRequester(focusRequester)
) { ) {
Text( Text(
"▶ Play", "▶ Play",
style = StreamFlowTheme.typography.titleMedium.copy(color = Color.White), style = StreamFlowTheme.typography.titleMedium.copy(color = Color.White),
modifier = Modifier.padding(horizontal = 24.dp, vertical = 12.dp) modifier = Modifier.padding(horizontal = 24.dp, vertical = 12.dp)
) )
} }
if (!movie.episodes.isNullOrEmpty()) { if (!movie.episodes.isNullOrEmpty()) {
Spacer(Modifier.height(32.dp)) Spacer(Modifier.height(32.dp))
EpisodeSelector( EpisodeSelector(
episodes = movie.episodes, episodes = movie.episodes,
currentEpisode = 1, currentEpisode = 1,
onEpisodeSelect = { episode -> onPlayClick(movie.slug, episode.number) }, onEpisodeSelect = { episode -> onPlayClick(movie.slug, episode.number) },
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.height(200.dp) .height(200.dp)
) )
} }
} }
} }
} }
} }
@Composable @Composable
fun CircularLoadingIndicator() { fun CircularLoadingIndicator() {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text( Text(
text = "Loading...", text = "Loading...",
style = StreamFlowTheme.typography.headlineMedium.copy(color = StreamFlowTheme.colors.primary) style = StreamFlowTheme.typography.headlineMedium.copy(color = StreamFlowTheme.colors.primary)
) )
} }
} }
@OptIn(ExperimentalTvMaterial3Api::class) @OptIn(ExperimentalTvMaterial3Api::class)
@Composable @Composable
fun ErrorState(message: String, onRetry: () -> Unit) { fun ErrorState(message: String, onRetry: () -> Unit) {
Column( Column(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.Center, verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally horizontalAlignment = Alignment.CenterHorizontally
) { ) {
val colors = StreamFlowTheme.colors val colors = StreamFlowTheme.colors
Text( Text(
text = message, text = message,
style = StreamFlowTheme.typography.bodyLarge.copy(color = Color.Red), style = StreamFlowTheme.typography.bodyLarge.copy(color = Color.Red),
modifier = Modifier.padding(bottom = 16.dp) modifier = Modifier.padding(bottom = 16.dp)
) )
Surface( Surface(
onClick = onRetry, onClick = onRetry,
shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(8.dp)), shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(8.dp)),
colors = ClickableSurfaceDefaults.colors( colors = ClickableSurfaceDefaults.colors(
containerColor = colors.surfaceVariant containerColor = colors.surfaceVariant
) )
) { ) {
Text( Text(
"Retry", "Retry",
modifier = Modifier.padding(horizontal = 24.dp, vertical = 12.dp) modifier = Modifier.padding(horizontal = 24.dp, vertical = 12.dp)
) )
} }
} }
} }

View file

@ -1,112 +1,112 @@
package com.streamflow.tv.ui.screens package com.streamflow.tv.ui.screens
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.tv.foundation.lazy.list.TvLazyColumn import androidx.tv.foundation.lazy.list.TvLazyColumn
import androidx.tv.foundation.lazy.list.items import androidx.tv.foundation.lazy.list.items
import androidx.tv.material3.ExperimentalTvMaterial3Api import androidx.tv.material3.ExperimentalTvMaterial3Api
import androidx.tv.material3.Text import androidx.tv.material3.Text
import com.streamflow.tv.ui.components.HeroBanner import com.streamflow.tv.ui.components.HeroBanner
import com.streamflow.tv.ui.components.MovieRow import com.streamflow.tv.ui.components.MovieRow
import com.streamflow.tv.ui.theme.StreamFlowTheme import com.streamflow.tv.ui.theme.StreamFlowTheme
import com.streamflow.tv.viewmodel.HomeViewModel import com.streamflow.tv.viewmodel.HomeViewModel
@OptIn(ExperimentalTvMaterial3Api::class) @OptIn(ExperimentalTvMaterial3Api::class)
@Composable @Composable
fun HomeScreen( fun HomeScreen(
onMovieClick: (String) -> Unit, onMovieClick: (String) -> Unit,
category: String? = null, category: String? = null,
userDataRepository: com.streamflow.tv.data.repository.UserDataRepository? = null, userDataRepository: com.streamflow.tv.data.repository.UserDataRepository? = null,
viewModel: HomeViewModel = viewModel() viewModel: HomeViewModel = viewModel()
) { ) {
val uiState by viewModel.uiState.collectAsState() val uiState by viewModel.uiState.collectAsState()
val colors = StreamFlowTheme.colors val colors = StreamFlowTheme.colors
LaunchedEffect(category) { LaunchedEffect(category) {
viewModel.loadHome(category, userDataRepository) viewModel.loadHome(category, userDataRepository)
} }
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.background(colors.background) .background(colors.background)
) { ) {
if (uiState.isLoading) { if (uiState.isLoading) {
Box( Box(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
Text( Text(
text = "Loading...", text = "Loading...",
style = StreamFlowTheme.typography.headlineMedium.copy(color = colors.primary) style = StreamFlowTheme.typography.headlineMedium.copy(color = colors.primary)
) )
} }
} else if (uiState.error != null) { } else if (uiState.error != null) {
Box( Box(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
Text( Text(
text = uiState.error ?: "Unknown error", text = uiState.error ?: "Unknown error",
style = StreamFlowTheme.typography.bodyLarge.copy(color = Color.Red) style = StreamFlowTheme.typography.bodyLarge.copy(color = Color.Red)
) )
} }
} else { } else {
TvLazyColumn( TvLazyColumn(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(bottom = 24.dp) contentPadding = PaddingValues(bottom = 24.dp)
) { ) {
// Hero Banner // Hero Banner
if (uiState.heroMovies.isNotEmpty()) { if (uiState.heroMovies.isNotEmpty()) {
item { item {
HeroBanner( HeroBanner(
movies = uiState.heroMovies, movies = uiState.heroMovies,
onPlayClick = { movie -> onMovieClick(movie.slug) } onPlayClick = { movie -> onMovieClick(movie.slug) }
) )
} }
} }
// Continue Watching (Watch History) // Continue Watching (Watch History)
if (uiState.watchedMovies.isNotEmpty()) { if (uiState.watchedMovies.isNotEmpty()) {
item { item {
MovieRow( MovieRow(
title = "Continue Watching", title = "Continue Watching",
movies = uiState.watchedMovies, movies = uiState.watchedMovies,
onMovieClick = { movie -> onMovieClick(movie.slug) } onMovieClick = { movie -> onMovieClick(movie.slug) }
) )
} }
} }
// Recommended for You // Recommended for You
if (uiState.recommendedMovies.isNotEmpty()) { if (uiState.recommendedMovies.isNotEmpty()) {
item { item {
MovieRow( MovieRow(
title = "Recommended for You", title = "Recommended for You",
movies = uiState.recommendedMovies, movies = uiState.recommendedMovies,
onMovieClick = { movie -> onMovieClick(movie.slug) } onMovieClick = { movie -> onMovieClick(movie.slug) }
) )
} }
} }
// Category rows // Category rows
uiState.categoryMovies.forEach { (title, movies) -> uiState.categoryMovies.forEach { (title, movies) ->
if (movies.isNotEmpty()) { if (movies.isNotEmpty()) {
item { item {
MovieRow( MovieRow(
title = title, title = title,
movies = movies, movies = movies,
onMovieClick = { movie -> onMovieClick(movie.slug) } onMovieClick = { movie -> onMovieClick(movie.slug) }
) )
} }
} }
} }
} }
} }
} }
} }

View file

@ -1,104 +1,104 @@
package com.streamflow.tv.ui.screens package com.streamflow.tv.ui.screens
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.tv.foundation.lazy.grid.TvGridCells import androidx.tv.foundation.lazy.grid.TvGridCells
import androidx.tv.foundation.lazy.grid.TvLazyVerticalGrid import androidx.tv.foundation.lazy.grid.TvLazyVerticalGrid
import androidx.tv.foundation.lazy.grid.items import androidx.tv.foundation.lazy.grid.items
import androidx.tv.material3.ExperimentalTvMaterial3Api import androidx.tv.material3.ExperimentalTvMaterial3Api
import androidx.tv.material3.Text import androidx.tv.material3.Text
import com.streamflow.tv.ui.components.MovieCard import com.streamflow.tv.ui.components.MovieCard
import com.streamflow.tv.ui.theme.StreamFlowTheme import com.streamflow.tv.ui.theme.StreamFlowTheme
import com.streamflow.tv.viewmodel.MyListViewModel import com.streamflow.tv.viewmodel.MyListViewModel
@OptIn(ExperimentalTvMaterial3Api::class) @OptIn(ExperimentalTvMaterial3Api::class)
@Composable @Composable
fun MyListScreen( fun MyListScreen(
onMovieClick: (String) -> Unit, onMovieClick: (String) -> Unit,
viewModel: MyListViewModel = viewModel() viewModel: MyListViewModel = viewModel()
) { ) {
val uiState by viewModel.uiState.collectAsState() val uiState by viewModel.uiState.collectAsState()
val colors = StreamFlowTheme.colors val colors = StreamFlowTheme.colors
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.background(colors.background) .background(colors.background)
.padding(horizontal = 48.dp, vertical = 32.dp) .padding(horizontal = 48.dp, vertical = 32.dp)
) { ) {
Text( Text(
text = "My List", text = "My List",
style = StreamFlowTheme.typography.displayMedium, style = StreamFlowTheme.typography.displayMedium,
modifier = Modifier.padding(bottom = 24.dp) modifier = Modifier.padding(bottom = 24.dp)
) )
if (uiState.watchHistory.isEmpty() && uiState.savedMovies.isEmpty()) { if (uiState.watchHistory.isEmpty() && uiState.savedMovies.isEmpty()) {
Box( Box(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
Column(horizontalAlignment = Alignment.CenterHorizontally) { Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text("❤️", style = StreamFlowTheme.typography.displayLarge) Text("❤️", style = StreamFlowTheme.typography.displayLarge)
Text( Text(
"Your list is empty.", "Your list is empty.",
style = StreamFlowTheme.typography.headlineMedium, style = StreamFlowTheme.typography.headlineMedium,
modifier = Modifier.padding(top = 12.dp) modifier = Modifier.padding(top = 12.dp)
) )
Text( Text(
"Start watching or add movies to your list.", "Start watching or add movies to your list.",
style = StreamFlowTheme.typography.bodyLarge, style = StreamFlowTheme.typography.bodyLarge,
modifier = Modifier.padding(top = 4.dp) modifier = Modifier.padding(top = 4.dp)
) )
} }
} }
} else { } else {
// Continue Watching // Continue Watching
if (uiState.watchHistory.isNotEmpty()) { if (uiState.watchHistory.isNotEmpty()) {
Text( Text(
text = "Continue Watching", text = "Continue Watching",
style = StreamFlowTheme.typography.headlineMedium, style = StreamFlowTheme.typography.headlineMedium,
modifier = Modifier.padding(bottom = 12.dp) modifier = Modifier.padding(bottom = 12.dp)
) )
TvLazyVerticalGrid( TvLazyVerticalGrid(
columns = TvGridCells.Adaptive(180.dp), columns = TvGridCells.Adaptive(180.dp),
contentPadding = PaddingValues(4.dp), contentPadding = PaddingValues(4.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp), horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp),
modifier = Modifier.heightIn(max = 320.dp) modifier = Modifier.heightIn(max = 320.dp)
) { ) {
items(uiState.watchHistory, key = { "h_${it.slug}" }) { movie -> items(uiState.watchHistory, key = { "h_${it.slug}" }) { movie ->
MovieCard(movie = movie, onClick = { onMovieClick(movie.slug) }) MovieCard(movie = movie, onClick = { onMovieClick(movie.slug) })
} }
} }
Spacer(Modifier.height(24.dp)) Spacer(Modifier.height(24.dp))
} }
// Saved // Saved
if (uiState.savedMovies.isNotEmpty()) { if (uiState.savedMovies.isNotEmpty()) {
Text( Text(
text = "Saved Movies", text = "Saved Movies",
style = StreamFlowTheme.typography.headlineMedium, style = StreamFlowTheme.typography.headlineMedium,
modifier = Modifier.padding(bottom = 12.dp) modifier = Modifier.padding(bottom = 12.dp)
) )
TvLazyVerticalGrid( TvLazyVerticalGrid(
columns = TvGridCells.Adaptive(180.dp), columns = TvGridCells.Adaptive(180.dp),
contentPadding = PaddingValues(4.dp), contentPadding = PaddingValues(4.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp), horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp) verticalArrangement = Arrangement.spacedBy(16.dp)
) { ) {
items(uiState.savedMovies, key = { "s_${it.slug}" }) { movie -> items(uiState.savedMovies, key = { "s_${it.slug}" }) { movie ->
MovieCard(movie = movie, onClick = { onMovieClick(movie.slug) }) MovieCard(movie = movie, onClick = { onMovieClick(movie.slug) })
} }
} }
} }
} }
} }
} }

View file

@ -1,249 +1,249 @@
package com.streamflow.tv.ui.screens package com.streamflow.tv.ui.screens
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.FrameLayout import android.widget.FrameLayout
import androidx.annotation.OptIn import androidx.annotation.OptIn
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.key.* import androidx.compose.ui.input.key.*
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView import androidx.compose.ui.viewinterop.AndroidView
import androidx.compose.foundation.focusable import androidx.compose.foundation.focusable
import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.focusRequester
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.media3.common.MediaItem import androidx.media3.common.MediaItem
import androidx.media3.common.util.UnstableApi import androidx.media3.common.util.UnstableApi
import androidx.media3.exoplayer.ExoPlayer import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.exoplayer.hls.HlsMediaSource import androidx.media3.exoplayer.hls.HlsMediaSource
import androidx.media3.datasource.DefaultDataSource import androidx.media3.datasource.DefaultDataSource
import androidx.media3.ui.PlayerView import androidx.media3.ui.PlayerView
import androidx.tv.material3.ExperimentalTvMaterial3Api import androidx.tv.material3.ExperimentalTvMaterial3Api
import androidx.tv.material3.Text import androidx.tv.material3.Text
import com.streamflow.tv.ui.theme.StreamFlowTheme import com.streamflow.tv.ui.theme.StreamFlowTheme
import com.streamflow.tv.viewmodel.PlayerViewModel import com.streamflow.tv.viewmodel.PlayerViewModel
@OptIn(UnstableApi::class) @OptIn(UnstableApi::class)
@kotlin.OptIn(ExperimentalTvMaterial3Api::class) @kotlin.OptIn(ExperimentalTvMaterial3Api::class)
@Composable @Composable
fun PlayerScreen( fun PlayerScreen(
slug: String, slug: String,
episode: Int = 1, episode: Int = 1,
userDataRepository: com.streamflow.tv.data.repository.UserDataRepository? = null, userDataRepository: com.streamflow.tv.data.repository.UserDataRepository? = null,
viewModel: PlayerViewModel = viewModel() viewModel: PlayerViewModel = viewModel()
) { ) {
val uiState by viewModel.uiState.collectAsState() val uiState by viewModel.uiState.collectAsState()
val context = LocalContext.current val context = LocalContext.current
val colors = StreamFlowTheme.colors val colors = StreamFlowTheme.colors
var playerView by remember { mutableStateOf<PlayerView?>(null) } var playerView by remember { mutableStateOf<PlayerView?>(null) }
LaunchedEffect(slug, episode) { LaunchedEffect(slug, episode) {
viewModel.loadPlayer(slug, episode) viewModel.loadPlayer(slug, episode)
} }
LaunchedEffect(uiState.movie) { LaunchedEffect(uiState.movie) {
if (uiState.movie != null && userDataRepository != null) { if (uiState.movie != null && userDataRepository != null) {
viewModel.saveToHistory(userDataRepository) viewModel.saveToHistory(userDataRepository)
} }
} }
// ExoPlayer instance // ExoPlayer instance
val exoPlayer = remember { val exoPlayer = remember {
ExoPlayer.Builder(context).build().apply { ExoPlayer.Builder(context).build().apply {
playWhenReady = true playWhenReady = true
} }
} }
// Wrap ExoPlayer to intercept next/previous UI clicks // Wrap ExoPlayer to intercept next/previous UI clicks
val forwardingPlayer = remember(exoPlayer, uiState.movie, uiState.currentEpisode) { val forwardingPlayer = remember(exoPlayer, uiState.movie, uiState.currentEpisode) {
object : androidx.media3.common.ForwardingPlayer(exoPlayer) { object : androidx.media3.common.ForwardingPlayer(exoPlayer) {
override fun getAvailableCommands(): androidx.media3.common.Player.Commands { override fun getAvailableCommands(): androidx.media3.common.Player.Commands {
return super.getAvailableCommands().buildUpon() return super.getAvailableCommands().buildUpon()
.add(androidx.media3.common.Player.COMMAND_SEEK_TO_NEXT) .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_PREVIOUS)
.add(androidx.media3.common.Player.COMMAND_SEEK_TO_NEXT_MEDIA_ITEM) .add(androidx.media3.common.Player.COMMAND_SEEK_TO_NEXT_MEDIA_ITEM)
.add(androidx.media3.common.Player.COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM) .add(androidx.media3.common.Player.COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM)
.build() .build()
} }
override fun hasNextMediaItem(): Boolean { override fun hasNextMediaItem(): Boolean {
val eps = uiState.movie?.episodes ?: return false val eps = uiState.movie?.episodes ?: return false
if (eps.isEmpty()) return false if (eps.isEmpty()) return false
val maxEp = eps.maxOf { it.number } val maxEp = eps.maxOf { it.number }
return uiState.currentEpisode < maxEp return uiState.currentEpisode < maxEp
} }
override fun hasPreviousMediaItem(): Boolean { override fun hasPreviousMediaItem(): Boolean {
val eps = uiState.movie?.episodes ?: return false val eps = uiState.movie?.episodes ?: return false
if (eps.isEmpty()) return false if (eps.isEmpty()) return false
val minEp = eps.minOf { it.number } val minEp = eps.minOf { it.number }
return uiState.currentEpisode > minEp return uiState.currentEpisode > minEp
} }
override fun seekToNextMediaItem() { override fun seekToNextMediaItem() {
if (hasNextMediaItem()) { if (hasNextMediaItem()) {
viewModel.changeEpisode(uiState.currentEpisode + 1) viewModel.changeEpisode(uiState.currentEpisode + 1)
} }
} }
override fun seekToNext() { override fun seekToNext() {
seekToNextMediaItem() seekToNextMediaItem()
} }
override fun seekToPreviousMediaItem() { override fun seekToPreviousMediaItem() {
if (hasPreviousMediaItem()) { if (hasPreviousMediaItem()) {
viewModel.changeEpisode(uiState.currentEpisode - 1) viewModel.changeEpisode(uiState.currentEpisode - 1)
} }
} }
override fun seekToPrevious() { override fun seekToPrevious() {
seekToPreviousMediaItem() seekToPreviousMediaItem()
} }
} }
} }
// Update player when source changes // Update player when source changes
LaunchedEffect(uiState.source) { LaunchedEffect(uiState.source) {
uiState.source?.let { source -> uiState.source?.let { source ->
val dataSourceFactory = DefaultDataSource.Factory(context) val dataSourceFactory = DefaultDataSource.Factory(context)
val mediaItem = MediaItem.fromUri(source.streamUrl) val mediaItem = MediaItem.fromUri(source.streamUrl)
android.util.Log.e("StreamFlowPlayer", "Setting media source: ${source.streamUrl}") android.util.Log.e("StreamFlowPlayer", "Setting media source: ${source.streamUrl}")
exoPlayer.addListener(object : androidx.media3.common.Player.Listener { exoPlayer.addListener(object : androidx.media3.common.Player.Listener {
override fun onPlayerError(error: androidx.media3.common.PlaybackException) { override fun onPlayerError(error: androidx.media3.common.PlaybackException) {
android.util.Log.e("StreamFlowPlayer", "Player Error: ${error.message}", error) android.util.Log.e("StreamFlowPlayer", "Player Error: ${error.message}", error)
} }
override fun onPlaybackStateChanged(playbackState: Int) { override fun onPlaybackStateChanged(playbackState: Int) {
android.util.Log.e("StreamFlowPlayer", "Playback State: $playbackState") android.util.Log.e("StreamFlowPlayer", "Playback State: $playbackState")
} }
}) })
if (source.streamUrl.contains(".m3u8")) { if (source.streamUrl.contains(".m3u8")) {
val hlsSource = HlsMediaSource.Factory(dataSourceFactory) val hlsSource = HlsMediaSource.Factory(dataSourceFactory)
.createMediaSource(mediaItem) .createMediaSource(mediaItem)
exoPlayer.setMediaSource(hlsSource) exoPlayer.setMediaSource(hlsSource)
} else { } else {
exoPlayer.setMediaItem(mediaItem) exoPlayer.setMediaItem(mediaItem)
} }
exoPlayer.prepare() exoPlayer.prepare()
} }
} }
// Cleanup // Cleanup
DisposableEffect(Unit) { DisposableEffect(Unit) {
onDispose { onDispose {
exoPlayer.release() exoPlayer.release()
} }
} }
val focusRequester = remember { FocusRequester() } val focusRequester = remember { FocusRequester() }
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.background(Color.Black) .background(Color.Black)
.focusRequester(focusRequester) .focusRequester(focusRequester)
.focusable() .focusable()
.onPreviewKeyEvent { keyEvent -> .onPreviewKeyEvent { keyEvent ->
if (keyEvent.type == KeyEventType.KeyDown) { if (keyEvent.type == KeyEventType.KeyDown) {
when (keyEvent.nativeKeyEvent.keyCode) { when (keyEvent.nativeKeyEvent.keyCode) {
android.view.KeyEvent.KEYCODE_DPAD_CENTER, android.view.KeyEvent.KEYCODE_DPAD_CENTER,
android.view.KeyEvent.KEYCODE_ENTER -> { android.view.KeyEvent.KEYCODE_ENTER -> {
// Toggle controls visibility // Toggle controls visibility
if (playerView?.isControllerFullyVisible == true) { if (playerView?.isControllerFullyVisible == true) {
playerView?.hideController() playerView?.hideController()
} else { } else {
playerView?.showController() playerView?.showController()
} }
true true
} }
android.view.KeyEvent.KEYCODE_DPAD_LEFT -> { android.view.KeyEvent.KEYCODE_DPAD_LEFT -> {
// Seek backward 10s // Seek backward 10s
playerView?.showController() playerView?.showController()
exoPlayer.seekTo(maxOf(0, exoPlayer.currentPosition - 10000)) exoPlayer.seekTo(maxOf(0, exoPlayer.currentPosition - 10000))
true true
} }
android.view.KeyEvent.KEYCODE_DPAD_RIGHT -> { android.view.KeyEvent.KEYCODE_DPAD_RIGHT -> {
// Seek forward 10s // Seek forward 10s
playerView?.showController() playerView?.showController()
exoPlayer.seekTo(minOf(exoPlayer.duration, exoPlayer.currentPosition + 10000)) exoPlayer.seekTo(minOf(exoPlayer.duration, exoPlayer.currentPosition + 10000))
true true
} }
android.view.KeyEvent.KEYCODE_DPAD_UP, android.view.KeyEvent.KEYCODE_DPAD_UP,
android.view.KeyEvent.KEYCODE_DPAD_DOWN -> { android.view.KeyEvent.KEYCODE_DPAD_DOWN -> {
playerView?.showController() playerView?.showController()
true true
} }
android.view.KeyEvent.KEYCODE_MEDIA_NEXT -> { android.view.KeyEvent.KEYCODE_MEDIA_NEXT -> {
if (forwardingPlayer.hasNextMediaItem()) { if (forwardingPlayer.hasNextMediaItem()) {
forwardingPlayer.seekToNextMediaItem() forwardingPlayer.seekToNextMediaItem()
} }
true true
} }
android.view.KeyEvent.KEYCODE_MEDIA_PREVIOUS -> { android.view.KeyEvent.KEYCODE_MEDIA_PREVIOUS -> {
if (forwardingPlayer.hasPreviousMediaItem()) { if (forwardingPlayer.hasPreviousMediaItem()) {
forwardingPlayer.seekToPreviousMediaItem() forwardingPlayer.seekToPreviousMediaItem()
} }
true true
} }
else -> false else -> false
} }
} else false } else false
} }
) { ) {
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
focusRequester.requestFocus() focusRequester.requestFocus()
} }
if (uiState.isLoading || uiState.source == null) { if (uiState.isLoading || uiState.source == null) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Column(horizontalAlignment = Alignment.CenterHorizontally) { Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text( Text(
"Loading stream...", "Loading stream...",
style = StreamFlowTheme.typography.headlineMedium.copy(color = colors.primary) style = StreamFlowTheme.typography.headlineMedium.copy(color = colors.primary)
) )
uiState.movie?.let { movie -> uiState.movie?.let { movie ->
Text( Text(
movie.title, movie.title,
style = StreamFlowTheme.typography.bodyLarge, style = StreamFlowTheme.typography.bodyLarge,
modifier = Modifier.padding(top = 8.dp) modifier = Modifier.padding(top = 8.dp)
) )
} }
} }
} }
} else { } else {
// ExoPlayer View // ExoPlayer View
android.util.Log.e("StreamFlowPlayer", "Drawing AndroidView for Player") android.util.Log.e("StreamFlowPlayer", "Drawing AndroidView for Player")
AndroidView( AndroidView(
factory = { ctx -> factory = { ctx ->
android.util.Log.e("StreamFlowPlayer", "Creating PlayerView factory") android.util.Log.e("StreamFlowPlayer", "Creating PlayerView factory")
PlayerView(ctx).apply { PlayerView(ctx).apply {
player = forwardingPlayer player = forwardingPlayer
useController = true useController = true
setShowNextButton(true) setShowNextButton(true)
setShowPreviousButton(true) setShowPreviousButton(true)
controllerAutoShow = true controllerAutoShow = true
keepScreenOn = true // Prevent screen sleep during playback keepScreenOn = true // Prevent screen sleep during playback
layoutParams = FrameLayout.LayoutParams( layoutParams = FrameLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT ViewGroup.LayoutParams.MATCH_PARENT
) )
playerView = this playerView = this
} }
}, },
modifier = Modifier.fillMaxSize() modifier = Modifier.fillMaxSize()
) )
} }
// Error overlay // Error overlay
uiState.error?.let { error -> uiState.error?.let { error ->
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text( Text(
error, error,
style = StreamFlowTheme.typography.bodyLarge.copy(color = Color.Red) style = StreamFlowTheme.typography.bodyLarge.copy(color = Color.Red)
) )
} }
} }
} }
} }

View file

@ -1,124 +1,124 @@
package com.streamflow.tv.ui.screens package com.streamflow.tv.ui.screens
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.text.BasicTextField
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.tv.foundation.lazy.grid.TvGridCells import androidx.tv.foundation.lazy.grid.TvGridCells
import androidx.tv.foundation.lazy.grid.TvLazyVerticalGrid import androidx.tv.foundation.lazy.grid.TvLazyVerticalGrid
import androidx.tv.foundation.lazy.grid.items import androidx.tv.foundation.lazy.grid.items
import androidx.tv.material3.* import androidx.tv.material3.*
import com.streamflow.tv.ui.components.MovieCard import com.streamflow.tv.ui.components.MovieCard
import com.streamflow.tv.ui.theme.StreamFlowTheme import com.streamflow.tv.ui.theme.StreamFlowTheme
import com.streamflow.tv.viewmodel.SearchViewModel import com.streamflow.tv.viewmodel.SearchViewModel
@OptIn(ExperimentalTvMaterial3Api::class) @OptIn(ExperimentalTvMaterial3Api::class)
@Composable @Composable
fun SearchScreen( fun SearchScreen(
onMovieClick: (String) -> Unit, onMovieClick: (String) -> Unit,
viewModel: SearchViewModel = viewModel() viewModel: SearchViewModel = viewModel()
) { ) {
val uiState by viewModel.uiState.collectAsState() val uiState by viewModel.uiState.collectAsState()
val colors = StreamFlowTheme.colors val colors = StreamFlowTheme.colors
var textValue by remember { mutableStateOf(TextFieldValue("")) } var textValue by remember { mutableStateOf(TextFieldValue("")) }
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.background(colors.background) .background(colors.background)
.padding(horizontal = 48.dp, vertical = 32.dp) .padding(horizontal = 48.dp, vertical = 32.dp)
) { ) {
// Search bar // Search bar
Text( Text(
text = "Search", text = "Search",
style = StreamFlowTheme.typography.displayMedium, style = StreamFlowTheme.typography.displayMedium,
modifier = Modifier.padding(bottom = 16.dp) modifier = Modifier.padding(bottom = 16.dp)
) )
Row( Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.background(colors.surfaceVariant, RoundedCornerShape(12.dp)) .background(colors.surfaceVariant, RoundedCornerShape(12.dp))
.padding(horizontal = 16.dp, vertical = 12.dp) .padding(horizontal = 16.dp, vertical = 12.dp)
) { ) {
Text("🔍 ", style = StreamFlowTheme.typography.titleMedium) Text("🔍 ", style = StreamFlowTheme.typography.titleMedium)
BasicTextField( BasicTextField(
value = textValue, value = textValue,
onValueChange = { onValueChange = {
textValue = it textValue = it
if (it.text.length >= 2) { if (it.text.length >= 2) {
viewModel.search(it.text) viewModel.search(it.text)
} }
}, },
textStyle = StreamFlowTheme.typography.titleMedium, textStyle = StreamFlowTheme.typography.titleMedium,
cursorBrush = SolidColor(colors.primary), cursorBrush = SolidColor(colors.primary),
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
decorationBox = { innerTextField -> decorationBox = { innerTextField ->
Box { Box {
if (textValue.text.isEmpty()) { if (textValue.text.isEmpty()) {
Text( Text(
"Type to search...", "Type to search...",
style = StreamFlowTheme.typography.titleMedium.copy( style = StreamFlowTheme.typography.titleMedium.copy(
color = Color.White.copy(alpha = 0.3f) color = Color.White.copy(alpha = 0.3f)
) )
) )
} }
innerTextField() innerTextField()
} }
} }
) )
} }
Spacer(Modifier.height(24.dp)) Spacer(Modifier.height(24.dp))
// Results // Results
when { when {
uiState.isLoading -> { uiState.isLoading -> {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text("Searching...", style = StreamFlowTheme.typography.bodyLarge.copy(color = colors.primary)) Text("Searching...", style = StreamFlowTheme.typography.bodyLarge.copy(color = colors.primary))
} }
} }
uiState.results.isNotEmpty() -> { uiState.results.isNotEmpty() -> {
TvLazyVerticalGrid( TvLazyVerticalGrid(
columns = TvGridCells.Adaptive(180.dp), columns = TvGridCells.Adaptive(180.dp),
contentPadding = PaddingValues(4.dp), contentPadding = PaddingValues(4.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp), horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp) verticalArrangement = Arrangement.spacedBy(16.dp)
) { ) {
items(uiState.results, key = { it.slug }) { movie -> items(uiState.results, key = { it.slug }) { movie ->
MovieCard( MovieCard(
movie = movie, movie = movie,
onClick = { onMovieClick(movie.slug) } onClick = { onMovieClick(movie.slug) }
) )
} }
} }
} }
uiState.hasSearched -> { uiState.hasSearched -> {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text("No results found", style = StreamFlowTheme.typography.bodyLarge) Text("No results found", style = StreamFlowTheme.typography.bodyLarge)
} }
} }
else -> { else -> {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Column(horizontalAlignment = Alignment.CenterHorizontally) { Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text("🎬", style = StreamFlowTheme.typography.displayLarge) Text("🎬", style = StreamFlowTheme.typography.displayLarge)
Text( Text(
"Search for movies and shows", "Search for movies and shows",
style = StreamFlowTheme.typography.bodyLarge, style = StreamFlowTheme.typography.bodyLarge,
modifier = Modifier.padding(top = 12.dp) modifier = Modifier.padding(top = 12.dp)
) )
} }
} }
} }
} }
} }
} }

View file

@ -1,171 +1,171 @@
package com.streamflow.tv.ui.screens package com.streamflow.tv.ui.screens
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.text.BasicTextField
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.tv.material3.* import androidx.tv.material3.*
import com.streamflow.tv.data.api.ApiClient import com.streamflow.tv.data.api.ApiClient
import com.streamflow.tv.data.repository.UserDataRepository import com.streamflow.tv.data.repository.UserDataRepository
import com.streamflow.tv.ui.theme.StreamFlowTheme import com.streamflow.tv.ui.theme.StreamFlowTheme
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@OptIn(ExperimentalTvMaterial3Api::class) @OptIn(ExperimentalTvMaterial3Api::class)
@Composable @Composable
fun SettingsScreen( fun SettingsScreen(
currentTheme: String, currentTheme: String,
onThemeChange: (String) -> Unit onThemeChange: (String) -> Unit
) { ) {
val colors = StreamFlowTheme.colors val colors = StreamFlowTheme.colors
val context = LocalContext.current val context = LocalContext.current
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val userRepo = remember { UserDataRepository(context) } val userRepo = remember { UserDataRepository(context) }
var serverUrl by remember { mutableStateOf(TextFieldValue(ApiClient.baseUrl.removeSuffix("/"))) } var serverUrl by remember { mutableStateOf(TextFieldValue(ApiClient.baseUrl.removeSuffix("/"))) }
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
val savedUrl = userRepo.serverUrl.first() val savedUrl = userRepo.serverUrl.first()
serverUrl = TextFieldValue(savedUrl) serverUrl = TextFieldValue(savedUrl)
} }
val themes = listOf( val themes = listOf(
Triple("default", "StreamFlow", Color(0xFF06B6D4)), Triple("default", "StreamFlow", Color(0xFF06B6D4)),
Triple("netflix", "Netflix", Color(0xFFE50914)), Triple("netflix", "Netflix", Color(0xFFE50914)),
Triple("apple", "Apple TV+", Color(0xFFFFFFFF)) Triple("apple", "Apple TV+", Color(0xFFFFFFFF))
) )
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.background(colors.background) .background(colors.background)
.padding(horizontal = 48.dp, vertical = 32.dp) .padding(horizontal = 48.dp, vertical = 32.dp)
) { ) {
Text( Text(
text = "Settings", text = "Settings",
style = StreamFlowTheme.typography.displayMedium, style = StreamFlowTheme.typography.displayMedium,
modifier = Modifier.padding(bottom = 32.dp) modifier = Modifier.padding(bottom = 32.dp)
) )
Text( Text(
text = "CHOOSE THEME", text = "CHOOSE THEME",
style = StreamFlowTheme.typography.labelSmall.copy( style = StreamFlowTheme.typography.labelSmall.copy(
color = Color.White.copy(alpha = 0.5f) color = Color.White.copy(alpha = 0.5f)
), ),
modifier = Modifier.padding(bottom = 12.dp) modifier = Modifier.padding(bottom = 12.dp)
) )
Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
themes.forEach { (id, name, color) -> themes.forEach { (id, name, color) ->
val isSelected = currentTheme == id val isSelected = currentTheme == id
Surface( Surface(
onClick = { onThemeChange(id) }, onClick = { onThemeChange(id) },
modifier = Modifier.width(200.dp), modifier = Modifier.width(200.dp),
shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(16.dp)), shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(16.dp)),
colors = ClickableSurfaceDefaults.colors( colors = ClickableSurfaceDefaults.colors(
containerColor = if (isSelected) Color.White.copy(alpha = 0.1f) else colors.surfaceVariant, containerColor = if (isSelected) Color.White.copy(alpha = 0.1f) else colors.surfaceVariant,
focusedContainerColor = Color.White.copy(alpha = 0.15f) focusedContainerColor = Color.White.copy(alpha = 0.15f)
), ),
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.05f) scale = ClickableSurfaceDefaults.scale(focusedScale = 1.05f)
) { ) {
Column( Column(
modifier = Modifier.padding(20.dp), modifier = Modifier.padding(20.dp),
horizontalAlignment = Alignment.CenterHorizontally horizontalAlignment = Alignment.CenterHorizontally
) { ) {
Box( Box(
modifier = Modifier modifier = Modifier
.size(48.dp) .size(48.dp)
.background(Color.Black, RoundedCornerShape(12.dp)), .background(Color.Black, RoundedCornerShape(12.dp)),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
Text( Text(
text = name.first().toString(), text = name.first().toString(),
style = StreamFlowTheme.typography.headlineLarge.copy(color = color) style = StreamFlowTheme.typography.headlineLarge.copy(color = color)
) )
} }
Spacer(Modifier.height(12.dp)) Spacer(Modifier.height(12.dp))
Text( Text(
text = name, text = name,
style = StreamFlowTheme.typography.titleMedium style = StreamFlowTheme.typography.titleMedium
) )
if (isSelected) { if (isSelected) {
Text( Text(
text = "✓ Active", text = "✓ Active",
style = StreamFlowTheme.typography.labelSmall.copy( style = StreamFlowTheme.typography.labelSmall.copy(
color = Color(0xFF22C55E) color = Color(0xFF22C55E)
), ),
modifier = Modifier.padding(top = 4.dp) modifier = Modifier.padding(top = 4.dp)
) )
} }
} }
} }
} }
} }
Spacer(Modifier.height(40.dp)) Spacer(Modifier.height(40.dp))
Text( Text(
text = "SERVER URL", text = "SERVER URL",
style = StreamFlowTheme.typography.labelSmall.copy( style = StreamFlowTheme.typography.labelSmall.copy(
color = Color.White.copy(alpha = 0.5f) color = Color.White.copy(alpha = 0.5f)
), ),
modifier = Modifier.padding(bottom = 12.dp) modifier = Modifier.padding(bottom = 12.dp)
) )
Row( Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp) horizontalArrangement = Arrangement.spacedBy(12.dp)
) { ) {
BasicTextField( BasicTextField(
value = serverUrl, value = serverUrl,
onValueChange = { serverUrl = it }, onValueChange = { serverUrl = it },
textStyle = StreamFlowTheme.typography.titleMedium, textStyle = StreamFlowTheme.typography.titleMedium,
cursorBrush = SolidColor(colors.primary), cursorBrush = SolidColor(colors.primary),
modifier = Modifier modifier = Modifier
.width(400.dp) .width(400.dp)
.background(colors.surfaceVariant, RoundedCornerShape(12.dp)) .background(colors.surfaceVariant, RoundedCornerShape(12.dp))
.padding(horizontal = 16.dp, vertical = 12.dp) .padding(horizontal = 16.dp, vertical = 12.dp)
) )
Surface( Surface(
onClick = { onClick = {
val url = serverUrl.text.trim() val url = serverUrl.text.trim()
ApiClient.baseUrl = url ApiClient.baseUrl = url
scope.launch { userRepo.setServerUrl(url) } scope.launch { userRepo.setServerUrl(url) }
}, },
shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(8.dp)), shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(8.dp)),
colors = ClickableSurfaceDefaults.colors( colors = ClickableSurfaceDefaults.colors(
containerColor = colors.primary, containerColor = colors.primary,
focusedContainerColor = colors.accent focusedContainerColor = colors.accent
), ),
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.05f) scale = ClickableSurfaceDefaults.scale(focusedScale = 1.05f)
) { ) {
Text( Text(
"Save", "Save",
style = StreamFlowTheme.typography.labelLarge.copy(color = Color.White), style = StreamFlowTheme.typography.labelLarge.copy(color = Color.White),
modifier = Modifier.padding(horizontal = 16.dp, vertical = 10.dp) modifier = Modifier.padding(horizontal = 16.dp, vertical = 10.dp)
) )
} }
} }
Spacer(Modifier.height(16.dp)) Spacer(Modifier.height(16.dp))
Text( Text(
text = "Enter the IP address and port of your StreamFlow backend server.", text = "Enter the IP address and port of your StreamFlow backend server.",
style = StreamFlowTheme.typography.bodyMedium, style = StreamFlowTheme.typography.bodyMedium,
modifier = Modifier.widthIn(max = 500.dp) modifier = Modifier.widthIn(max = 500.dp)
) )
} }
} }

View file

@ -1,28 +1,28 @@
package com.streamflow.tv.ui.theme package com.streamflow.tv.ui.theme
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
// StreamFlow Default Theme (Cyan/Blue) // StreamFlow Default Theme (Cyan/Blue)
val StreamFlowPrimary = Color(0xFF06B6D4) val StreamFlowPrimary = Color(0xFF06B6D4)
val StreamFlowSecondary = Color(0xFF3B82F6) val StreamFlowSecondary = Color(0xFF3B82F6)
val StreamFlowAccent = Color(0xFF22D3EE) val StreamFlowAccent = Color(0xFF22D3EE)
// Netflix Theme (Red) // Netflix Theme (Red)
val NetflixPrimary = Color(0xFFE50914) val NetflixPrimary = Color(0xFFE50914)
val NetflixSecondary = Color(0xFFB81D24) val NetflixSecondary = Color(0xFFB81D24)
val NetflixAccent = Color(0xFFFF3D3D) val NetflixAccent = Color(0xFFFF3D3D)
// Apple TV+ Theme (White/Silver) // Apple TV+ Theme (White/Silver)
val ApplePrimary = Color(0xFFFFFFFF) val ApplePrimary = Color(0xFFFFFFFF)
val AppleSecondary = Color(0xFFA1A1AA) val AppleSecondary = Color(0xFFA1A1AA)
val AppleAccent = Color(0xFFD4D4D8) val AppleAccent = Color(0xFFD4D4D8)
// Common // Common
val DarkBackground = Color(0xFF141414) val DarkBackground = Color(0xFF141414)
val DarkSurface = Color(0xFF1A1A1A) val DarkSurface = Color(0xFF1A1A1A)
val DarkSurfaceVariant = Color(0xFF262626) val DarkSurfaceVariant = Color(0xFF262626)
val TextPrimary = Color(0xFFFFFFFF) val TextPrimary = Color(0xFFFFFFFF)
val TextSecondary = Color(0xFF9CA3AF) val TextSecondary = Color(0xFF9CA3AF)
val TextMuted = Color(0xFF6B7280) val TextMuted = Color(0xFF6B7280)
val CardBackground = Color(0xFF1E1E1E) val CardBackground = Color(0xFF1E1E1E)
val DividerColor = Color(0x1AFFFFFF) val DividerColor = Color(0x1AFFFFFF)

View file

@ -1,122 +1,122 @@
package com.streamflow.tv.ui.theme package com.streamflow.tv.ui.theme
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.tv.material3.* import androidx.tv.material3.*
data class StreamFlowColors( data class StreamFlowColors(
val primary: Color, val primary: Color,
val secondary: Color, val secondary: Color,
val accent: Color, val accent: Color,
val background: Color = DarkBackground, val background: Color = DarkBackground,
val surface: Color = DarkSurface, val surface: Color = DarkSurface,
val surfaceVariant: Color = DarkSurfaceVariant, val surfaceVariant: Color = DarkSurfaceVariant,
val textPrimary: Color = TextPrimary, val textPrimary: Color = TextPrimary,
val textSecondary: Color = TextSecondary, val textSecondary: Color = TextSecondary,
val card: Color = CardBackground, val card: Color = CardBackground,
val divider: Color = DividerColor val divider: Color = DividerColor
) )
val LocalStreamFlowColors = staticCompositionLocalOf { val LocalStreamFlowColors = staticCompositionLocalOf {
StreamFlowColors( StreamFlowColors(
primary = StreamFlowPrimary, primary = StreamFlowPrimary,
secondary = StreamFlowSecondary, secondary = StreamFlowSecondary,
accent = StreamFlowAccent accent = StreamFlowAccent
) )
} }
object StreamFlowTheme { object StreamFlowTheme {
val colors: StreamFlowColors val colors: StreamFlowColors
@Composable @Composable
@ReadOnlyComposable @ReadOnlyComposable
get() = LocalStreamFlowColors.current get() = LocalStreamFlowColors.current
val typography = AppTypography val typography = AppTypography
} }
fun streamFlowColors(themeName: String): StreamFlowColors { fun streamFlowColors(themeName: String): StreamFlowColors {
return when (themeName) { return when (themeName) {
"netflix" -> StreamFlowColors( "netflix" -> StreamFlowColors(
primary = NetflixPrimary, primary = NetflixPrimary,
secondary = NetflixSecondary, secondary = NetflixSecondary,
accent = NetflixAccent accent = NetflixAccent
) )
"apple" -> StreamFlowColors( "apple" -> StreamFlowColors(
primary = ApplePrimary, primary = ApplePrimary,
secondary = AppleSecondary, secondary = AppleSecondary,
accent = AppleAccent accent = AppleAccent
) )
else -> StreamFlowColors( else -> StreamFlowColors(
primary = StreamFlowPrimary, primary = StreamFlowPrimary,
secondary = StreamFlowSecondary, secondary = StreamFlowSecondary,
accent = StreamFlowAccent accent = StreamFlowAccent
) )
} }
} }
@OptIn(ExperimentalTvMaterial3Api::class) @OptIn(ExperimentalTvMaterial3Api::class)
@Composable @Composable
fun StreamFlowTvTheme( fun StreamFlowTvTheme(
themeName: String = "default", themeName: String = "default",
content: @Composable () -> Unit content: @Composable () -> Unit
) { ) {
val colors = streamFlowColors(themeName) val colors = streamFlowColors(themeName)
val colorScheme = ColorScheme( val colorScheme = ColorScheme(
primary = colors.primary, primary = colors.primary,
onPrimary = Color.White, onPrimary = Color.White,
primaryContainer = colors.primary.copy(alpha = 0.3f), primaryContainer = colors.primary.copy(alpha = 0.3f),
onPrimaryContainer = Color.White, onPrimaryContainer = Color.White,
secondary = colors.secondary, secondary = colors.secondary,
onSecondary = Color.White, onSecondary = Color.White,
secondaryContainer = colors.secondary.copy(alpha = 0.3f), secondaryContainer = colors.secondary.copy(alpha = 0.3f),
onSecondaryContainer = Color.White, onSecondaryContainer = Color.White,
tertiary = colors.accent, tertiary = colors.accent,
onTertiary = Color.Black, onTertiary = Color.Black,
tertiaryContainer = colors.accent.copy(alpha = 0.3f), tertiaryContainer = colors.accent.copy(alpha = 0.3f),
onTertiaryContainer = Color.White, onTertiaryContainer = Color.White,
background = colors.background, background = colors.background,
onBackground = Color.White, onBackground = Color.White,
surface = colors.surface, surface = colors.surface,
onSurface = Color.White, onSurface = Color.White,
surfaceVariant = colors.surfaceVariant, surfaceVariant = colors.surfaceVariant,
onSurfaceVariant = Color.White, onSurfaceVariant = Color.White,
error = Color.Red, error = Color.Red,
onError = Color.White, onError = Color.White,
errorContainer = Color.Red.copy(alpha = 0.1f), errorContainer = Color.Red.copy(alpha = 0.1f),
onErrorContainer = Color.Red, onErrorContainer = Color.Red,
border = colors.divider, border = colors.divider,
borderVariant = colors.divider, borderVariant = colors.divider,
scrim = Color.Black, scrim = Color.Black,
inverseSurface = Color.White, inverseSurface = Color.White,
inverseOnSurface = Color.Black, inverseOnSurface = Color.Black,
inversePrimary = colors.primary, inversePrimary = colors.primary,
surfaceTint = colors.primary surfaceTint = colors.primary
) )
val tvTypography = Typography( val tvTypography = Typography(
displayLarge = AppTypography.displayLarge, displayLarge = AppTypography.displayLarge,
displayMedium = AppTypography.displayMedium, displayMedium = AppTypography.displayMedium,
displaySmall = AppTypography.displayMedium, displaySmall = AppTypography.displayMedium,
headlineLarge = AppTypography.headlineLarge, headlineLarge = AppTypography.headlineLarge,
headlineMedium = AppTypography.headlineMedium, headlineMedium = AppTypography.headlineMedium,
headlineSmall = AppTypography.headlineMedium, headlineSmall = AppTypography.headlineMedium,
titleLarge = AppTypography.titleLarge, titleLarge = AppTypography.titleLarge,
titleMedium = AppTypography.titleMedium, titleMedium = AppTypography.titleMedium,
titleSmall = AppTypography.titleMedium, titleSmall = AppTypography.titleMedium,
bodyLarge = AppTypography.bodyLarge, bodyLarge = AppTypography.bodyLarge,
bodyMedium = AppTypography.bodyMedium, bodyMedium = AppTypography.bodyMedium,
bodySmall = AppTypography.bodyMedium, bodySmall = AppTypography.bodyMedium,
labelLarge = AppTypography.labelLarge, labelLarge = AppTypography.labelLarge,
labelMedium = AppTypography.labelLarge, labelMedium = AppTypography.labelLarge,
labelSmall = AppTypography.labelSmall labelSmall = AppTypography.labelSmall
) )
CompositionLocalProvider(LocalStreamFlowColors provides colors) { CompositionLocalProvider(LocalStreamFlowColors provides colors) {
MaterialTheme( MaterialTheme(
colorScheme = colorScheme, colorScheme = colorScheme,
typography = tvTypography, typography = tvTypography,
content = content content = content
) )
} }
} }

View file

@ -1,59 +1,59 @@
package com.streamflow.tv.ui.theme package com.streamflow.tv.ui.theme
import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
object AppTypography { object AppTypography {
val displayLarge = TextStyle( val displayLarge = TextStyle(
fontSize = 36.sp, fontSize = 36.sp,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
color = TextPrimary, color = TextPrimary,
letterSpacing = (-0.5).sp letterSpacing = (-0.5).sp
) )
val displayMedium = TextStyle( val displayMedium = TextStyle(
fontSize = 28.sp, fontSize = 28.sp,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
color = TextPrimary color = TextPrimary
) )
val headlineLarge = TextStyle( val headlineLarge = TextStyle(
fontSize = 24.sp, fontSize = 24.sp,
fontWeight = FontWeight.SemiBold, fontWeight = FontWeight.SemiBold,
color = TextPrimary color = TextPrimary
) )
val headlineMedium = TextStyle( val headlineMedium = TextStyle(
fontSize = 20.sp, fontSize = 20.sp,
fontWeight = FontWeight.SemiBold, fontWeight = FontWeight.SemiBold,
color = TextPrimary color = TextPrimary
) )
val titleLarge = TextStyle( val titleLarge = TextStyle(
fontSize = 18.sp, fontSize = 18.sp,
fontWeight = FontWeight.Medium, fontWeight = FontWeight.Medium,
color = TextPrimary color = TextPrimary
) )
val titleMedium = TextStyle( val titleMedium = TextStyle(
fontSize = 16.sp, fontSize = 16.sp,
fontWeight = FontWeight.Medium, fontWeight = FontWeight.Medium,
color = TextPrimary color = TextPrimary
) )
val bodyLarge = TextStyle( val bodyLarge = TextStyle(
fontSize = 16.sp, fontSize = 16.sp,
fontWeight = FontWeight.Normal, fontWeight = FontWeight.Normal,
color = TextSecondary color = TextSecondary
) )
val bodyMedium = TextStyle( val bodyMedium = TextStyle(
fontSize = 14.sp, fontSize = 14.sp,
fontWeight = FontWeight.Normal, fontWeight = FontWeight.Normal,
color = TextSecondary color = TextSecondary
) )
val labelLarge = TextStyle( val labelLarge = TextStyle(
fontSize = 14.sp, fontSize = 14.sp,
fontWeight = FontWeight.Medium, fontWeight = FontWeight.Medium,
color = TextPrimary color = TextPrimary
) )
val labelSmall = TextStyle( val labelSmall = TextStyle(
fontSize = 12.sp, fontSize = 12.sp,
fontWeight = FontWeight.Medium, fontWeight = FontWeight.Medium,
color = TextMuted color = TextMuted
) )
} }

View file

@ -1,45 +1,45 @@
package com.streamflow.tv.viewmodel package com.streamflow.tv.viewmodel
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.streamflow.tv.data.model.MovieDetail import com.streamflow.tv.data.model.MovieDetail
import com.streamflow.tv.data.repository.MovieRepository import com.streamflow.tv.data.repository.MovieRepository
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
data class DetailUiState( data class DetailUiState(
val movie: MovieDetail? = null, val movie: MovieDetail? = null,
val isLoading: Boolean = true, val isLoading: Boolean = true,
val error: String? = null, val error: String? = null,
val isInMyList: Boolean = false val isInMyList: Boolean = false
) )
class DetailViewModel : ViewModel() { class DetailViewModel : ViewModel() {
private val repository = MovieRepository() private val repository = MovieRepository()
private val _uiState = MutableStateFlow(DetailUiState()) private val _uiState = MutableStateFlow(DetailUiState())
val uiState: StateFlow<DetailUiState> = _uiState val uiState: StateFlow<DetailUiState> = _uiState
fun loadMovie(slug: String) { fun loadMovie(slug: String) {
android.util.Log.e("DetailVM", "loadMovie($slug) called") android.util.Log.e("DetailVM", "loadMovie($slug) called")
viewModelScope.launch { viewModelScope.launch {
_uiState.value = DetailUiState(isLoading = true) _uiState.value = DetailUiState(isLoading = true)
try { try {
val movie = repository.getMovieDetail(slug) val movie = repository.getMovieDetail(slug)
android.util.Log.e("DetailVM", "loadMovie success: ${movie.title}, episodes: ${movie.episodes?.size}") android.util.Log.e("DetailVM", "loadMovie success: ${movie.title}, episodes: ${movie.episodes?.size}")
_uiState.value = DetailUiState(movie = movie, isLoading = false) _uiState.value = DetailUiState(movie = movie, isLoading = false)
} catch (e: Exception) { } catch (e: Exception) {
android.util.Log.e("DetailVM", "loadMovie failed", e) android.util.Log.e("DetailVM", "loadMovie failed", e)
_uiState.value = DetailUiState( _uiState.value = DetailUiState(
isLoading = false, isLoading = false,
error = e.message ?: "Failed to load movie details" error = e.message ?: "Failed to load movie details"
) )
} }
} }
} }
fun toggleMyList(isInList: Boolean) { fun toggleMyList(isInList: Boolean) {
_uiState.value = _uiState.value.copy(isInMyList = !isInList) _uiState.value = _uiState.value.copy(isInMyList = !isInList)
} }
} }

View file

@ -1,144 +1,110 @@
package com.streamflow.tv.viewmodel package com.streamflow.tv.viewmodel
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.streamflow.tv.data.model.Movie import com.streamflow.tv.data.model.Movie
import com.streamflow.tv.data.repository.MovieRepository import com.streamflow.tv.data.repository.MovieRepository
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.async import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
data class HomeUiState( data class HomeUiState(
val heroMovies: List<Movie> = emptyList(), val heroMovies: List<Movie> = emptyList(),
val watchedMovies: List<Movie> = emptyList(), val watchedMovies: List<Movie> = emptyList(),
val recommendedMovies: List<Movie> = emptyList(), val recommendedMovies: List<Movie> = emptyList(),
val categoryMovies: Map<String, List<Movie>> = emptyMap(), val categoryMovies: Map<String, List<Movie>> = emptyMap(),
val isLoading: Boolean = true, val isLoading: Boolean = true,
val error: String? = null, val error: String? = null,
val currentCategory: String? = null val currentCategory: String? = null
) )
class HomeViewModel : ViewModel() { class HomeViewModel : ViewModel() {
private val repository = MovieRepository() private val repository = MovieRepository()
private val _uiState = MutableStateFlow(HomeUiState()) private val _uiState = MutableStateFlow(HomeUiState())
val uiState: StateFlow<HomeUiState> = _uiState val uiState: StateFlow<HomeUiState> = _uiState
private var userDataRepository: com.streamflow.tv.data.repository.UserDataRepository? = null private var userDataRepository: com.streamflow.tv.data.repository.UserDataRepository? = null
private val categories = listOf( private val categories = listOf(
"phim-le" to "Phim Lẻ", "phim-le" to "Phim Lẻ",
"phim-bo" to "Phim Bộ", "phim-bo" to "Phim Bộ",
"hoat-hinh" to "Hoạt Hình", "hoat-hinh" to "Hoạt Hình",
"tv-shows" to "TV Shows" "tv-shows" to "TV Shows"
) )
init { init {
loadHome() loadHome()
} }
fun loadHome( fun loadHome(
category: String? = null, category: String? = null,
userRepo: com.streamflow.tv.data.repository.UserDataRepository? = null userRepo: com.streamflow.tv.data.repository.UserDataRepository? = null
) { ) {
if (userRepo != null) { if (userRepo != null) {
this.userDataRepository = userRepo this.userDataRepository = userRepo
} }
viewModelScope.launch { viewModelScope.launch {
_uiState.value = _uiState.value.copy(isLoading = true, error = null, currentCategory = category) _uiState.value = _uiState.value.copy(isLoading = true, error = null, currentCategory = category)
try { try {
// Load history if repository is available // Load history if repository is available
val history = userRepo?.watchHistory?.first() ?: emptyList() val history = userRepo?.watchHistory?.first() ?: emptyList()
if (category != null) { if (category != null) {
// Load single category // Load single category
val response = repository.getHomeVideos(category) val response = repository.getHomeVideos(category)
_uiState.value = _uiState.value.copy( _uiState.value = _uiState.value.copy(
heroMovies = response.items.take(5), heroMovies = response.items.take(5),
watchedMovies = history, watchedMovies = history,
recommendedMovies = response.items.filter { m -> history.none { it.slug == m.slug } }.shuffled().take(10), recommendedMovies = response.items.filter { m -> history.none { it.slug == m.slug } }.shuffled().take(10),
categoryMovies = mapOf( categoryMovies = mapOf(
categories.find { it.first == category }?.second.orEmpty() to response.items categories.find { it.first == category }?.second.orEmpty() to response.items
), ),
isLoading = false isLoading = false
) )
} else { } else {
// Load all categories for home // Load all categories for home
val allMovies = java.util.Collections.synchronizedMap(mutableMapOf<String, List<Movie>>()) val allMovies = java.util.Collections.synchronizedMap(mutableMapOf<String, List<Movie>>())
val allFlattened = java.util.Collections.synchronizedList(mutableListOf<Movie>()) val allFlattened = java.util.Collections.synchronizedList(mutableListOf<Movie>())
kotlinx.coroutines.coroutineScope { kotlinx.coroutines.coroutineScope {
// 1. Initial categories // Load main categories only (to avoid OOM on TV devices)
val categoryTasks = categories.map { (slug, name) -> val categoryTasks = categories.map { (slug, name) ->
async { async {
try { try {
val response = repository.getHomeVideos(slug) val response = repository.getHomeVideos(slug)
allMovies[name] = response.items allMovies[name] = response.items.take(15)
allFlattened.addAll(response.items) allFlattened.addAll(response.items.take(15))
response.items response.items
} catch (_: Exception) { emptyList<Movie>() } } catch (_: Exception) { emptyList<Movie>() }
} }
} }
// 2. Fetch Genres & Countries metadata in parallel // Wait for categories
val genresDeferred = async { try { repository.getGenres().take(8) } catch (_: Exception) { emptyList() } } categoryTasks.awaitAll()
val countriesDeferred = async { try { repository.getCountries().take(5) } catch (_: Exception) { emptyList() } } }
val genres = genresDeferred.await() val heroItems = allMovies[categories.first().second]?.take(5) ?: emptyList()
val countries = countriesDeferred.await()
_uiState.value = _uiState.value.copy(
// 3. Fetch Genre and Country content in parallel heroMovies = heroItems,
val genreTasks = genres.map { genre -> watchedMovies = history,
async { recommendedMovies = allFlattened.filter { m -> history.none { it.slug == m.slug } }
try { .distinctBy { it.slug }.shuffled().take(15),
val response = repository.getHomeVideos(genre.slug) categoryMovies = allMovies.toMap(),
if (response.items.isNotEmpty()) { isLoading = false
allMovies["Genre: ${genre.name}"] = response.items )
allFlattened.addAll(response.items) }
} } catch (e: Exception) {
} catch (_: Exception) { } _uiState.value = _uiState.value.copy(
} isLoading = false,
} error = e.message ?: "Failed to load content"
)
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"
)
}
}
}
}

View file

@ -1,48 +1,48 @@
package com.streamflow.tv.viewmodel package com.streamflow.tv.viewmodel
import android.app.Application import android.app.Application
import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.streamflow.tv.data.model.Movie import com.streamflow.tv.data.model.Movie
import com.streamflow.tv.data.repository.UserDataRepository import com.streamflow.tv.data.repository.UserDataRepository
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
data class MyListUiState( data class MyListUiState(
val savedMovies: List<Movie> = emptyList(), val savedMovies: List<Movie> = emptyList(),
val watchHistory: List<Movie> = emptyList() val watchHistory: List<Movie> = emptyList()
) )
class MyListViewModel(application: Application) : AndroidViewModel(application) { class MyListViewModel(application: Application) : AndroidViewModel(application) {
private val userRepo = UserDataRepository(application) private val userRepo = UserDataRepository(application)
private val _uiState = MutableStateFlow(MyListUiState()) private val _uiState = MutableStateFlow(MyListUiState())
val uiState: StateFlow<MyListUiState> = _uiState val uiState: StateFlow<MyListUiState> = _uiState
init { init {
viewModelScope.launch { viewModelScope.launch {
userRepo.myList.collectLatest { list -> userRepo.myList.collectLatest { list ->
_uiState.value = _uiState.value.copy(savedMovies = list) _uiState.value = _uiState.value.copy(savedMovies = list)
} }
} }
viewModelScope.launch { viewModelScope.launch {
userRepo.watchHistory.collectLatest { history -> userRepo.watchHistory.collectLatest { history ->
_uiState.value = _uiState.value.copy(watchHistory = history) _uiState.value = _uiState.value.copy(watchHistory = history)
} }
} }
} }
fun addToMyList(movie: Movie) { fun addToMyList(movie: Movie) {
viewModelScope.launch { userRepo.addToMyList(movie) } viewModelScope.launch { userRepo.addToMyList(movie) }
} }
fun removeFromMyList(slug: String) { fun removeFromMyList(slug: String) {
viewModelScope.launch { userRepo.removeFromMyList(slug) } viewModelScope.launch { userRepo.removeFromMyList(slug) }
} }
fun addToHistory(movie: Movie) { fun addToHistory(movie: Movie) {
viewModelScope.launch { userRepo.addToHistory(movie) } viewModelScope.launch { userRepo.addToHistory(movie) }
} }
} }

View file

@ -1,100 +1,100 @@
package com.streamflow.tv.viewmodel package com.streamflow.tv.viewmodel
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.streamflow.tv.data.model.MovieDetail import com.streamflow.tv.data.model.MovieDetail
import com.streamflow.tv.data.model.VideoSource import com.streamflow.tv.data.model.VideoSource
import com.streamflow.tv.data.repository.MovieRepository import com.streamflow.tv.data.repository.MovieRepository
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
data class PlayerUiState( data class PlayerUiState(
val movie: MovieDetail? = null, val movie: MovieDetail? = null,
val source: VideoSource? = null, val source: VideoSource? = null,
val currentEpisode: Int = 1, val currentEpisode: Int = 1,
val isLoading: Boolean = true, val isLoading: Boolean = true,
val error: String? = null val error: String? = null
) )
class PlayerViewModel : ViewModel() { class PlayerViewModel : ViewModel() {
private val repository = MovieRepository() private val repository = MovieRepository()
private val _uiState = MutableStateFlow(PlayerUiState()) private val _uiState = MutableStateFlow(PlayerUiState())
val uiState: StateFlow<PlayerUiState> = _uiState val uiState: StateFlow<PlayerUiState> = _uiState
fun loadPlayer(slug: String, episode: Int = 1) { fun loadPlayer(slug: String, episode: Int = 1) {
viewModelScope.launch { viewModelScope.launch {
_uiState.value = PlayerUiState(isLoading = true, currentEpisode = episode) _uiState.value = PlayerUiState(isLoading = true, currentEpisode = episode)
try { try {
val movie = repository.getMovieDetail(slug) val movie = repository.getMovieDetail(slug)
_uiState.value = _uiState.value.copy(movie = movie) _uiState.value = _uiState.value.copy(movie = movie)
loadStream(movie, episode) loadStream(movie, episode)
} catch (e: Exception) { } catch (e: Exception) {
_uiState.value = _uiState.value.copy( _uiState.value = _uiState.value.copy(
isLoading = false, isLoading = false,
error = e.message ?: "Failed to load" error = e.message ?: "Failed to load"
) )
} }
} }
} }
fun changeEpisode(episode: Int) { fun changeEpisode(episode: Int) {
val movie = _uiState.value.movie ?: return val movie = _uiState.value.movie ?: return
_uiState.value = _uiState.value.copy(currentEpisode = episode, isLoading = true, source = null) _uiState.value = _uiState.value.copy(currentEpisode = episode, isLoading = true, source = null)
viewModelScope.launch { viewModelScope.launch {
loadStream(movie, episode) loadStream(movie, episode)
} }
} }
fun saveToHistory(userDataRepository: com.streamflow.tv.data.repository.UserDataRepository) { fun saveToHistory(userDataRepository: com.streamflow.tv.data.repository.UserDataRepository) {
val movie = _uiState.value.movie ?: return val movie = _uiState.value.movie ?: return
viewModelScope.launch { viewModelScope.launch {
userDataRepository.addToHistory(movie.toMovie()) userDataRepository.addToHistory(movie.toMovie())
android.util.Log.e("PlayerViewModel", "Movie saved to history: ${movie.title}") android.util.Log.e("PlayerViewModel", "Movie saved to history: ${movie.title}")
} }
} }
private suspend fun loadStream(movie: MovieDetail, episode: Int) { private suspend fun loadStream(movie: MovieDetail, episode: Int) {
try { try {
val ep = movie.episodes?.find { it.number == episode } val ep = movie.episodes?.find { it.number == episode }
android.util.Log.e("PlayerViewModel", "Loading stream for slug=${movie.slug} episode=$episode. Episode data: $ep") 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"))) { if (ep != null && (ep.url.contains(".m3u8") || ep.url.contains("index.m3u8"))) {
// Direct HLS URL // Direct HLS URL
android.util.Log.e("PlayerViewModel", "Direct HLS URL found: ${ep.url}") android.util.Log.e("PlayerViewModel", "Direct HLS URL found: ${ep.url}")
_uiState.value = _uiState.value.copy( _uiState.value = _uiState.value.copy(
source = VideoSource( source = VideoSource(
streamUrl = ep.url, streamUrl = ep.url,
resolution = "HD", resolution = "HD",
formatId = "hls" formatId = "hls"
), ),
isLoading = false isLoading = false
) )
} else if (ep != null && ep.url.isNotEmpty()) { } else if (ep != null && ep.url.isNotEmpty()) {
// Non-HLS URL — try to extract via backend // Non-HLS URL — try to extract via backend
android.util.Log.e("PlayerViewModel", "Extracting from URL: ${ep.url}") android.util.Log.e("PlayerViewModel", "Extracting from URL: ${ep.url}")
val source = repository.extractVideo(ep.url) val source = repository.extractVideo(ep.url)
android.util.Log.e("PlayerViewModel", "Extraction successful: $source") android.util.Log.e("PlayerViewModel", "Extraction successful: $source")
_uiState.value = _uiState.value.copy( _uiState.value = _uiState.value.copy(
source = source, source = source,
isLoading = false isLoading = false
) )
} else { } else {
// No valid episode URL found // No valid episode URL found
android.util.Log.e("PlayerViewModel", "No stream URL found for episode $episode") android.util.Log.e("PlayerViewModel", "No stream URL found for episode $episode")
_uiState.value = _uiState.value.copy( _uiState.value = _uiState.value.copy(
isLoading = false, isLoading = false,
error = "No stream available for episode $episode" error = "No stream available for episode $episode"
) )
} }
} catch (e: Exception) { } catch (e: Exception) {
android.util.Log.e("PlayerViewModel", "Error loading stream", e) android.util.Log.e("PlayerViewModel", "Error loading stream", e)
_uiState.value = _uiState.value.copy( _uiState.value = _uiState.value.copy(
isLoading = false, isLoading = false,
error = e.message ?: "Failed to extract stream" error = e.message ?: "Failed to extract stream"
) )
} }
} }
} }

View file

@ -1,39 +1,39 @@
package com.streamflow.tv.viewmodel package com.streamflow.tv.viewmodel
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.streamflow.tv.data.model.Movie import com.streamflow.tv.data.model.Movie
import com.streamflow.tv.data.repository.MovieRepository import com.streamflow.tv.data.repository.MovieRepository
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
data class SearchUiState( data class SearchUiState(
val query: String = "", val query: String = "",
val results: List<Movie> = emptyList(), val results: List<Movie> = emptyList(),
val isLoading: Boolean = false, val isLoading: Boolean = false,
val hasSearched: Boolean = false val hasSearched: Boolean = false
) )
class SearchViewModel : ViewModel() { class SearchViewModel : ViewModel() {
private val repository = MovieRepository() private val repository = MovieRepository()
private val _uiState = MutableStateFlow(SearchUiState()) private val _uiState = MutableStateFlow(SearchUiState())
val uiState: StateFlow<SearchUiState> = _uiState val uiState: StateFlow<SearchUiState> = _uiState
fun search(query: String) { fun search(query: String) {
if (query.isBlank()) return if (query.isBlank()) return
_uiState.value = SearchUiState(query = query, isLoading = true, hasSearched = true) _uiState.value = SearchUiState(query = query, isLoading = true, hasSearched = true)
viewModelScope.launch { viewModelScope.launch {
try { try {
val response = repository.searchVideos(query) val response = repository.searchVideos(query)
_uiState.value = _uiState.value.copy( _uiState.value = _uiState.value.copy(
results = response.items, results = response.items,
isLoading = false isLoading = false
) )
} catch (e: Exception) { } catch (e: Exception) {
_uiState.value = _uiState.value.copy(isLoading = false) _uiState.value = _uiState.value.copy(isLoading = false)
} }
} }
} }
} }

View file

@ -1,33 +1,33 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android" <vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="320dp" android:width="320dp"
android:height="180dp" android:height="180dp"
android:viewportWidth="320" android:viewportWidth="320"
android:viewportHeight="180"> android:viewportHeight="180">
<!-- Background --> <!-- Background -->
<path <path
android:pathData="M0,0h320v180H0z" android:pathData="M0,0h320v180H0z"
android:fillColor="#141414"/> android:fillColor="#141414"/>
<!-- Gradient accent bar --> <!-- Gradient accent bar -->
<path <path
android:pathData="M0,160h320v20H0z" android:pathData="M0,160h320v20H0z"
android:fillColor="#06B6D4"/> android:fillColor="#06B6D4"/>
<!-- Icon circle --> <!-- Icon circle -->
<path <path
android:pathData="M160,75m-30,0a30,30 0,1 1,60 0a30,30 0,1 1,-60 0" android:pathData="M160,75m-30,0a30,30 0,1 1,60 0a30,30 0,1 1,-60 0"
android:fillColor="#06B6D4"/> android:fillColor="#06B6D4"/>
<!-- Play triangle --> <!-- Play triangle -->
<path <path
android:pathData="M152,60L172,75L152,90z" android:pathData="M152,60L172,75L152,90z"
android:fillColor="#FFFFFF"/> android:fillColor="#FFFFFF"/>
<!-- Text: StreamFlow --> <!-- Text: StreamFlow -->
<path <path
android:pathData="M95,130h130" android:pathData="M95,130h130"
android:strokeColor="#FFFFFF" android:strokeColor="#FFFFFF"
android:strokeWidth="0.5"/> android:strokeWidth="0.5"/>
</vector> </vector>

View file

@ -1,17 +1,17 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android" <vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="48dp" android:width="48dp"
android:height="48dp" android:height="48dp"
android:viewportWidth="48" android:viewportWidth="48"
android:viewportHeight="48"> android:viewportHeight="48">
<!-- Background rounded rect --> <!-- Background rounded rect -->
<path <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: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"/> android:fillColor="#06B6D4"/>
<!-- Play triangle --> <!-- Play triangle -->
<path <path
android:pathData="M18,12L36,24L18,36z" android:pathData="M18,12L36,24L18,36z"
android:fillColor="#FFFFFF"/> android:fillColor="#FFFFFF"/>
</vector> </vector>

View file

@ -1,3 +1,3 @@
<resources> <resources>
<string name="app_name">StreamFlow</string> <string name="app_name">StreamFlow</string>
</resources> </resources>

View file

@ -1,8 +1,8 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources> <resources>
<style name="Theme.StreamFlowTV" parent="@style/Theme.Leanback"> <style name="Theme.StreamFlowTV" parent="@style/Theme.Leanback">
<item name="android:windowIsTranslucent">true</item> <item name="android:windowIsTranslucent">true</item>
<item name="android:windowBackground">@android:color/transparent</item> <item name="android:windowBackground">@android:color/transparent</item>
<item name="android:backgroundDimEnabled">false</item> <item name="android:backgroundDimEnabled">false</item>
</style> </style>
</resources> </resources>

View file

@ -1,4 +1,4 @@
plugins { plugins {
id("com.android.application") version "8.2.2" apply false id("com.android.application") version "8.2.2" apply false
id("org.jetbrains.kotlin.android") version "1.9.22" apply false id("org.jetbrains.kotlin.android") version "1.9.22" apply false
} }

View file

@ -1,35 +1,35 @@
> Task :app:checkKotlinGradlePluginConfigurationErrors > Task :app:checkKotlinGradlePluginConfigurationErrors
> Task :app:preBuild UP-TO-DATE > Task :app:preBuild UP-TO-DATE
> Task :app:preDebugBuild UP-TO-DATE > Task :app:preDebugBuild UP-TO-DATE
> Task :app:checkDebugAarMetadata UP-TO-DATE > Task :app:checkDebugAarMetadata UP-TO-DATE
> Task :app:generateDebugResValues UP-TO-DATE > Task :app:generateDebugResValues UP-TO-DATE
> Task :app:mapDebugSourceSetPaths UP-TO-DATE > Task :app:mapDebugSourceSetPaths UP-TO-DATE
> Task :app:generateDebugResources UP-TO-DATE > Task :app:generateDebugResources UP-TO-DATE
> Task :app:mergeDebugResources UP-TO-DATE > Task :app:mergeDebugResources UP-TO-DATE
> Task :app:packageDebugResources UP-TO-DATE > Task :app:packageDebugResources UP-TO-DATE
> Task :app:parseDebugLocalResources UP-TO-DATE > Task :app:parseDebugLocalResources UP-TO-DATE
> Task :app:createDebugCompatibleScreenManifests UP-TO-DATE > Task :app:createDebugCompatibleScreenManifests UP-TO-DATE
> Task :app:extractDeepLinksDebug UP-TO-DATE > Task :app:extractDeepLinksDebug UP-TO-DATE
> Task :app:processDebugMainManifest UP-TO-DATE > Task :app:processDebugMainManifest UP-TO-DATE
> Task :app:processDebugManifest UP-TO-DATE > Task :app:processDebugManifest UP-TO-DATE
> Task :app:processDebugManifestForPackage UP-TO-DATE > Task :app:processDebugManifestForPackage UP-TO-DATE
> Task :app:processDebugResources UP-TO-DATE > Task :app:processDebugResources UP-TO-DATE
> Task :app:compileDebugKotlin FAILED > 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 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. FAILURE: Build failed with an exception.
* What went wrong: * What went wrong:
Execution failed for task ':app:compileDebugKotlin'. Execution failed for task ':app:compileDebugKotlin'.
> A failure occurred while executing org.jetbrains.kotlin.compilerRunner.GradleCompilerRunnerWithWorkers$GradleKotlinCompilerWorkAction > A failure occurred while executing org.jetbrains.kotlin.compilerRunner.GradleCompilerRunnerWithWorkers$GradleKotlinCompilerWorkAction
> Compilation error. See log for more details > Compilation error. See log for more details
* Try: * Try:
> Run with --stacktrace option to get the stack trace. > Run with --stacktrace option to get the stack trace.
> Run with --info or --debug option to get more log output. > Run with --info or --debug option to get more log output.
> Run with --scan to get full insights. > Run with --scan to get full insights.
> Get more help at https://help.gradle.org. > Get more help at https://help.gradle.org.
BUILD FAILED in 3s BUILD FAILED in 3s
14 actionable tasks: 2 executed, 12 up-to-date 14 actionable tasks: 2 executed, 12 up-to-date

View file

@ -1,35 +1,35 @@
> Task :app:checkKotlinGradlePluginConfigurationErrors > Task :app:checkKotlinGradlePluginConfigurationErrors
> Task :app:preBuild UP-TO-DATE > Task :app:preBuild UP-TO-DATE
> Task :app:preDebugBuild UP-TO-DATE > Task :app:preDebugBuild UP-TO-DATE
> Task :app:checkDebugAarMetadata UP-TO-DATE > Task :app:checkDebugAarMetadata UP-TO-DATE
> Task :app:generateDebugResValues UP-TO-DATE > Task :app:generateDebugResValues UP-TO-DATE
> Task :app:mapDebugSourceSetPaths UP-TO-DATE > Task :app:mapDebugSourceSetPaths UP-TO-DATE
> Task :app:generateDebugResources UP-TO-DATE > Task :app:generateDebugResources UP-TO-DATE
> Task :app:mergeDebugResources UP-TO-DATE > Task :app:mergeDebugResources UP-TO-DATE
> Task :app:packageDebugResources UP-TO-DATE > Task :app:packageDebugResources UP-TO-DATE
> Task :app:parseDebugLocalResources UP-TO-DATE > Task :app:parseDebugLocalResources UP-TO-DATE
> Task :app:createDebugCompatibleScreenManifests UP-TO-DATE > Task :app:createDebugCompatibleScreenManifests UP-TO-DATE
> Task :app:extractDeepLinksDebug UP-TO-DATE > Task :app:extractDeepLinksDebug UP-TO-DATE
> Task :app:processDebugMainManifest UP-TO-DATE > Task :app:processDebugMainManifest UP-TO-DATE
> Task :app:processDebugManifest UP-TO-DATE > Task :app:processDebugManifest UP-TO-DATE
> Task :app:processDebugManifestForPackage UP-TO-DATE > Task :app:processDebugManifestForPackage UP-TO-DATE
> Task :app:processDebugResources UP-TO-DATE > Task :app:processDebugResources UP-TO-DATE
> Task :app:compileDebugKotlin FAILED > 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 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. FAILURE: Build failed with an exception.
* What went wrong: * What went wrong:
Execution failed for task ':app:compileDebugKotlin'. Execution failed for task ':app:compileDebugKotlin'.
> A failure occurred while executing org.jetbrains.kotlin.compilerRunner.GradleCompilerRunnerWithWorkers$GradleKotlinCompilerWorkAction > A failure occurred while executing org.jetbrains.kotlin.compilerRunner.GradleCompilerRunnerWithWorkers$GradleKotlinCompilerWorkAction
> Compilation error. See log for more details > Compilation error. See log for more details
* Try: * Try:
> Run with --stacktrace option to get the stack trace. > Run with --stacktrace option to get the stack trace.
> Run with --info or --debug option to get more log output. > Run with --info or --debug option to get more log output.
> Run with --scan to get full insights. > Run with --scan to get full insights.
> Get more help at https://help.gradle.org. > Get more help at https://help.gradle.org.
BUILD FAILED in 1s BUILD FAILED in 1s
14 actionable tasks: 2 executed, 12 up-to-date 14 actionable tasks: 2 executed, 12 up-to-date

View file

@ -1,37 +1,37 @@
> Task :app:checkKotlinGradlePluginConfigurationErrors > Task :app:checkKotlinGradlePluginConfigurationErrors
> Task :app:preBuild UP-TO-DATE > Task :app:preBuild UP-TO-DATE
> Task :app:preDebugBuild UP-TO-DATE > Task :app:preDebugBuild UP-TO-DATE
> Task :app:checkDebugAarMetadata UP-TO-DATE > Task :app:checkDebugAarMetadata UP-TO-DATE
> Task :app:generateDebugResValues UP-TO-DATE > Task :app:generateDebugResValues UP-TO-DATE
> Task :app:mapDebugSourceSetPaths UP-TO-DATE > Task :app:mapDebugSourceSetPaths UP-TO-DATE
> Task :app:generateDebugResources UP-TO-DATE > Task :app:generateDebugResources UP-TO-DATE
> Task :app:mergeDebugResources UP-TO-DATE > Task :app:mergeDebugResources UP-TO-DATE
> Task :app:packageDebugResources UP-TO-DATE > Task :app:packageDebugResources UP-TO-DATE
> Task :app:parseDebugLocalResources UP-TO-DATE > Task :app:parseDebugLocalResources UP-TO-DATE
> Task :app:createDebugCompatibleScreenManifests UP-TO-DATE > Task :app:createDebugCompatibleScreenManifests UP-TO-DATE
> Task :app:extractDeepLinksDebug UP-TO-DATE > Task :app:extractDeepLinksDebug UP-TO-DATE
> Task :app:processDebugMainManifest UP-TO-DATE > Task :app:processDebugMainManifest UP-TO-DATE
> Task :app:processDebugManifest UP-TO-DATE > Task :app:processDebugManifest UP-TO-DATE
> Task :app:processDebugManifestForPackage UP-TO-DATE > Task :app:processDebugManifestForPackage UP-TO-DATE
> Task :app:processDebugResources UP-TO-DATE > Task :app:processDebugResources UP-TO-DATE
> Task :app:compileDebugKotlin FAILED > 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: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: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' 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. FAILURE: Build failed with an exception.
* What went wrong: * What went wrong:
Execution failed for task ':app:compileDebugKotlin'. Execution failed for task ':app:compileDebugKotlin'.
> A failure occurred while executing org.jetbrains.kotlin.compilerRunner.GradleCompilerRunnerWithWorkers$GradleKotlinCompilerWorkAction > A failure occurred while executing org.jetbrains.kotlin.compilerRunner.GradleCompilerRunnerWithWorkers$GradleKotlinCompilerWorkAction
> Compilation error. See log for more details > Compilation error. See log for more details
* Try: * Try:
> Run with --stacktrace option to get the stack trace. > Run with --stacktrace option to get the stack trace.
> Run with --info or --debug option to get more log output. > Run with --info or --debug option to get more log output.
> Run with --scan to get full insights. > Run with --scan to get full insights.
> Get more help at https://help.gradle.org. > Get more help at https://help.gradle.org.
BUILD FAILED in 1s BUILD FAILED in 1s
14 actionable tasks: 2 executed, 12 up-to-date 14 actionable tasks: 2 executed, 12 up-to-date

View file

@ -1,92 +1,92 @@
@rem @rem
@rem Copyright 2015 the original author or authors. @rem Copyright 2015 the original author or authors.
@rem @rem
@rem Licensed under the Apache License, Version 2.0 (the "License"); @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 not use this file except in compliance with the License.
@rem You may obtain a copy of the License at @rem You may obtain a copy of the License at
@rem @rem
@rem https://www.apache.org/licenses/LICENSE-2.0 @rem https://www.apache.org/licenses/LICENSE-2.0
@rem @rem
@rem Unless required by applicable law or agreed to in writing, software @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 distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and @rem See the License for the specific language governing permissions and
@rem limitations under the License. @rem limitations under the License.
@rem @rem
@if "%DEBUG%"=="" @echo off @if "%DEBUG%"=="" @echo off
@rem ########################################################################## @rem ##########################################################################
@rem @rem
@rem Gradle startup script for Windows @rem Gradle startup script for Windows
@rem @rem
@rem ########################################################################## @rem ##########################################################################
@rem Set local scope for the variables with windows NT shell @rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0 set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=. if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused @rem This is normally unused
set APP_BASE_NAME=%~n0 set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%.. set APP_HOME=%DIRNAME%..
@rem Resolve any "." and ".." in APP_HOME to make it shorter. @rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi 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. @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" set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" "-javaagent:%APP_HOME%/lib/agents/gradle-instrumentation-agent-8.4.jar"
@rem Find java.exe @rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1 %JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute if %ERRORLEVEL% equ 0 goto execute
echo. echo.
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
echo. echo.
echo Please set the JAVA_HOME variable in your environment to match the echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation. echo location of your Java installation.
goto fail goto fail
:findJavaFromJavaHome :findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=% set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute if exist "%JAVA_EXE%" goto execute
echo. echo.
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
echo. echo.
echo Please set the JAVA_HOME variable in your environment to match the echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation. echo location of your Java installation.
goto fail goto fail
:execute :execute
@rem Setup the command line @rem Setup the command line
set CLASSPATH=%APP_HOME%\lib\gradle-launcher-8.4.jar set CLASSPATH=%APP_HOME%\lib\gradle-launcher-8.4.jar
@rem Execute Gradle @rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.launcher.GradleMain %* "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.launcher.GradleMain %*
:end :end
@rem End local scope for the variables with windows NT shell @rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd if %ERRORLEVEL% equ 0 goto mainEnd
:fail :fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code! rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL% set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1 if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE% exit /b %EXIT_CODE%
:mainEnd :mainEnd
if "%OS%"=="Windows_NT" endlocal if "%OS%"=="Windows_NT" endlocal
:omega :omega

View file

@ -1,4 +1,4 @@
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
android.useAndroidX=true android.useAndroidX=true
kotlin.code.style=official kotlin.code.style=official
android.nonTransitiveRClass=true android.nonTransitiveRClass=true

0
android-tv/gradlew vendored Normal file → Executable file
View file

188
android-tv/gradlew.bat vendored
View file

@ -1,94 +1,94 @@
@rem @rem
@rem Copyright 2015 the original author or authors. @rem Copyright 2015 the original author or authors.
@rem @rem
@rem Licensed under the Apache License, Version 2.0 (the "License"); @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 not use this file except in compliance with the License.
@rem You may obtain a copy of the License at @rem You may obtain a copy of the License at
@rem @rem
@rem https://www.apache.org/licenses/LICENSE-2.0 @rem https://www.apache.org/licenses/LICENSE-2.0
@rem @rem
@rem Unless required by applicable law or agreed to in writing, software @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 distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and @rem See the License for the specific language governing permissions and
@rem limitations under the License. @rem limitations under the License.
@rem @rem
@rem SPDX-License-Identifier: Apache-2.0 @rem SPDX-License-Identifier: Apache-2.0
@rem @rem
@if "%DEBUG%"=="" @echo off @if "%DEBUG%"=="" @echo off
@rem ########################################################################## @rem ##########################################################################
@rem @rem
@rem Gradle startup script for Windows @rem Gradle startup script for Windows
@rem @rem
@rem ########################################################################## @rem ##########################################################################
@rem Set local scope for the variables with windows NT shell @rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0 set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=. if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused @rem This is normally unused
set APP_BASE_NAME=%~n0 set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME% set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter. @rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi 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. @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" set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe @rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1 %JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2 echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 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. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 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 echo location of your Java installation. 1>&2
goto fail goto fail
:findJavaFromJavaHome :findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=% set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute if exist "%JAVA_EXE%" goto execute
echo. 1>&2 echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2 echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 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 echo location of your Java installation. 1>&2
goto fail goto fail
:execute :execute
@rem Setup the command line @rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
@rem Execute Gradle @rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
:end :end
@rem End local scope for the variables with windows NT shell @rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd if %ERRORLEVEL% equ 0 goto mainEnd
:fail :fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code! rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL% set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1 if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE% exit /b %EXIT_CODE%
:mainEnd :mainEnd
if "%OS%"=="Windows_NT" endlocal if "%OS%"=="Windows_NT" endlocal
:omega :omega

View file

@ -1,18 +1,18 @@
pluginManagement { pluginManagement {
repositories { repositories {
google() google()
mavenCentral() mavenCentral()
gradlePluginPortal() gradlePluginPortal()
} }
} }
dependencyResolutionManagement { dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories { repositories {
google() google()
mavenCentral() mavenCentral()
} }
} }
rootProject.name = "StreamFlowTV" rootProject.name = "StreamFlowTV"
include(":app") include(":app")

View file

@ -75,6 +75,7 @@ func (h *Handler) GetHomeVideos(w http.ResponseWriter, r *http.Request) {
return p.GetMoviesByCategory(category, page) return p.GetMoviesByCategory(category, page)
}) })
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(movies) json.NewEncoder(w).Encode(movies)
} }
@ -94,6 +95,7 @@ func (h *Handler) SearchVideos(w http.ResponseWriter, r *http.Request) {
return p.Search(query, page) return p.Search(query, page)
}) })
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(movies) json.NewEncoder(w).Encode(movies)
} }
@ -211,6 +213,7 @@ func (h *Handler) ExtractVideo(w http.ResponseWriter, r *http.Request) {
return return
} }
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(info) json.NewEncoder(w).Encode(info)
} }
@ -306,6 +309,7 @@ func (h *Handler) GetMovieDetail(w http.ResponseWriter, r *http.Request) {
primaryMovie.Episodes = uniqueEps primaryMovie.Episodes = uniqueEps
} }
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(primaryMovie) json.NewEncoder(w).Encode(primaryMovie)
} }
@ -316,6 +320,7 @@ func (h *Handler) GetGenres(w http.ResponseWriter, r *http.Request) {
}); ok { }); ok {
genres, err := gp.GetGenres() genres, err := gp.GetGenres()
if err == nil { if err == nil {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(genres) json.NewEncoder(w).Encode(genres)
return return
} }
@ -331,6 +336,7 @@ func (h *Handler) GetCountries(w http.ResponseWriter, r *http.Request) {
}); ok { }); ok {
countries, err := cp.GetCountries() countries, err := cp.GetCountries()
if err == nil { if err == nil {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(countries) json.NewEncoder(w).Encode(countries)
return return
} }

View file

@ -1,16 +1,16 @@
package api package api
import ( import (
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
) )
func RegisterRoutes(r chi.Router, h *Handler) { func RegisterRoutes(r chi.Router, h *Handler) {
r.Get("/videos/home", h.GetHomeVideos) r.Get("/videos/home", h.GetHomeVideos)
r.Get("/videos/search", h.SearchVideos) r.Get("/videos/search", h.SearchVideos)
r.Get("/videos/{slug}", h.GetMovieDetail) r.Get("/videos/{slug}", h.GetMovieDetail)
r.Post("/extract", h.ExtractVideo) r.Post("/extract", h.ExtractVideo)
r.Get("/images/proxy", h.ProxyImage) r.Get("/images/proxy", h.ProxyImage)
r.Get("/categories/genres", h.GetGenres) r.Get("/categories/genres", h.GetGenres)
r.Get("/categories/countries", h.GetCountries) r.Get("/categories/countries", h.GetCountries)
r.Get("/stream", h.StreamVideo) r.Get("/stream", h.StreamVideo)
} }

View file

@ -1,85 +1,85 @@
package database package database
import ( import (
"log" "log"
"streamflow-backend/internal/models" "streamflow-backend/internal/models"
"github.com/glebarez/sqlite" "github.com/glebarez/sqlite"
"gorm.io/gorm" "gorm.io/gorm"
"gorm.io/gorm/logger" "gorm.io/gorm/logger"
) )
var DB *gorm.DB var DB *gorm.DB
func InitDB(dsn string) { func InitDB(dsn string) {
var err error var err error
DB, err = gorm.Open(sqlite.Open(dsn), &gorm.Config{ DB, err = gorm.Open(sqlite.Open(dsn), &gorm.Config{
Logger: logger.Default.LogMode(logger.Info), Logger: logger.Default.LogMode(logger.Info),
}) })
if err != nil { if err != nil {
log.Fatal("Failed to connect to database:", err) log.Fatal("Failed to connect to database:", err)
} }
log.Println("Database connection established") log.Println("Database connection established")
// Auto-migrate schema // Auto-migrate schema
err = DB.AutoMigrate(&models.Video{}) err = DB.AutoMigrate(&models.Video{})
if err != nil { if err != nil {
log.Fatal("Failed to migrate database:", err) log.Fatal("Failed to migrate database:", err)
} }
} }
type VideoRepository struct { type VideoRepository struct {
db *gorm.DB db *gorm.DB
} }
func NewVideoRepository(db *gorm.DB) *VideoRepository { func NewVideoRepository(db *gorm.DB) *VideoRepository {
return &VideoRepository{db: db} return &VideoRepository{db: db}
} }
func (r *VideoRepository) Create(video *models.Video) error { func (r *VideoRepository) Create(video *models.Video) error {
return r.db.Create(video).Error return r.db.Create(video).Error
} }
func (r *VideoRepository) GetByID(id uint) (*models.Video, error) { func (r *VideoRepository) GetByID(id uint) (*models.Video, error) {
var video models.Video var video models.Video
err := r.db.First(&video, id).Error err := r.db.First(&video, id).Error
return &video, err return &video, err
} }
func (r *VideoRepository) GetBySourceURL(url string) (*models.Video, error) { func (r *VideoRepository) GetBySourceURL(url string) (*models.Video, error) {
var video models.Video var video models.Video
err := r.db.Where("source_url = ?", url).First(&video).Error err := r.db.Where("source_url = ?", url).First(&video).Error
return &video, err return &video, err
} }
func (r *VideoRepository) Search(query string, limit int) ([]models.Video, error) { func (r *VideoRepository) Search(query string, limit int) ([]models.Video, error) {
var videos []models.Video var videos []models.Video
err := r.db.Where("title LIKE ?", "%"+query+"%").Limit(limit).Find(&videos).Error err := r.db.Where("title LIKE ?", "%"+query+"%").Limit(limit).Find(&videos).Error
return videos, err return videos, err
} }
func (r *VideoRepository) GetAll(skip int, limit int) ([]models.Video, error) { func (r *VideoRepository) GetAll(skip int, limit int) ([]models.Video, error) {
var videos []models.Video var videos []models.Video
err := r.db.Offset(skip).Limit(limit).Find(&videos).Error err := r.db.Offset(skip).Limit(limit).Find(&videos).Error
return videos, err return videos, err
} }
func (r *VideoRepository) Update(id uint, updates map[string]interface{}) (*models.Video, error) { func (r *VideoRepository) Update(id uint, updates map[string]interface{}) (*models.Video, error) {
var video models.Video var video models.Video
result := r.db.First(&video, id) result := r.db.First(&video, id)
if result.Error != nil { if result.Error != nil {
return nil, result.Error return nil, result.Error
} }
err := r.db.Model(&video).Updates(updates).Error err := r.db.Model(&video).Updates(updates).Error
if err != nil { if err != nil {
return nil, err return nil, err
} }
return &video, nil return &video, nil
} }
func (r *VideoRepository) Delete(id uint) error { func (r *VideoRepository) Delete(id uint) error {
return r.db.Delete(&models.Video{}, id).Error return r.db.Delete(&models.Video{}, id).Error
} }

View file

@ -1,56 +1,56 @@
package models package models
import ( import (
"time" "time"
) )
// Video metadata model matches SQLAlchemy Video class // Video metadata model matches SQLAlchemy Video class
type Video struct { type Video struct {
ID uint `json:"id" gorm:"primaryKey"` ID uint `json:"id" gorm:"primaryKey"`
Title string `json:"title" gorm:"index;size:500"` Title string `json:"title" gorm:"index;size:500"`
Description string `json:"description"` Description string `json:"description"`
Thumbnail string `json:"thumbnail" gorm:"size:1000"` Thumbnail string `json:"thumbnail" gorm:"size:1000"`
SourceURL string `json:"source_url" gorm:"uniqueIndex;size:2000"` SourceURL string `json:"source_url" gorm:"uniqueIndex;size:2000"`
Duration int `json:"duration" gorm:"default:0"` Duration int `json:"duration" gorm:"default:0"`
Resolution string `json:"resolution" gorm:"size:20"` Resolution string `json:"resolution" gorm:"size:20"`
Category string `json:"category" gorm:"index;size:100"` Category string `json:"category" gorm:"index;size:100"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"` UpdatedAt time.Time `json:"updated_at"`
} }
// RophimMovie represents the scraped movie data // RophimMovie represents the scraped movie data
type RophimMovie struct { type RophimMovie struct {
ID string `json:"id"` ID string `json:"id"`
Title string `json:"title"` Title string `json:"title"`
OriginalTitle string `json:"original_title,omitempty"` OriginalTitle string `json:"original_title,omitempty"`
Slug string `json:"slug"` Slug string `json:"slug"`
Thumbnail string `json:"thumbnail"` Thumbnail string `json:"thumbnail"`
Backdrop string `json:"backdrop,omitempty"` Backdrop string `json:"backdrop,omitempty"`
Year int `json:"year,omitempty"` Year int `json:"year,omitempty"`
Rating string `json:"rating,omitempty"` Rating string `json:"rating,omitempty"`
Duration int `json:"duration,omitempty"` Duration int `json:"duration,omitempty"`
Time string `json:"time,omitempty"` // Raw time string Time string `json:"time,omitempty"` // Raw time string
Quality string `json:"quality,omitempty"` Quality string `json:"quality,omitempty"`
Lang string `json:"lang,omitempty"` Lang string `json:"lang,omitempty"`
Genre string `json:"genre,omitempty"` Genre string `json:"genre,omitempty"`
Description string `json:"description,omitempty"` Description string `json:"description,omitempty"`
Category string `json:"category"` Category string `json:"category"`
Provider string `json:"provider,omitempty"` Provider string `json:"provider,omitempty"`
Cast []string `json:"cast,omitempty" gorm:"-"` Cast []string `json:"cast,omitempty" gorm:"-"`
Director string `json:"director,omitempty"` Director string `json:"director,omitempty"`
Country string `json:"country,omitempty"` Country string `json:"country,omitempty"`
Episodes []Episode `json:"episodes,omitempty" gorm:"-"` Episodes []Episode `json:"episodes,omitempty" gorm:"-"`
TrailerURL string `json:"trailer_url,omitempty"` TrailerURL string `json:"trailer_url,omitempty"`
} }
type Episode struct { type Episode struct {
Number int `json:"number"` Number int `json:"number"`
Title string `json:"title"` Title string `json:"title"`
URL string `json:"url"` URL string `json:"url"`
ServerName string `json:"server_name"` ServerName string `json:"server_name"`
} }
type Category struct { type Category struct {
Name string `json:"name"` Name string `json:"name"`
Slug string `json:"slug"` Slug string `json:"slug"`
} }

View file

@ -1,368 +1,368 @@
package scraper package scraper
import ( import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"net/http" "net/http"
"net/url" "net/url"
"strings" "strings"
"time" "time"
"streamflow-backend/internal/models" "streamflow-backend/internal/models"
) )
const OphimBaseURL = "https://ophim1.com" const OphimBaseURL = "https://ophim1.com"
type OphimScraper struct { type OphimScraper struct {
client *http.Client client *http.Client
} }
func NewOphimScraper() *OphimScraper { func NewOphimScraper() *OphimScraper {
return &OphimScraper{ return &OphimScraper{
client: &http.Client{ client: &http.Client{
Timeout: 30 * time.Second, Timeout: 30 * time.Second,
}, },
} }
} }
// Response structs for Ophim API // Response structs for Ophim API
type OphimResponse struct { type OphimResponse struct {
Items []OphimItem `json:"items"` Items []OphimItem `json:"items"`
Data struct { Data struct {
Items []OphimItem `json:"items"` Items []OphimItem `json:"items"`
Item OphimMovie `json:"item"` Item OphimMovie `json:"item"`
Episodes []OphimEpisodeServer `json:"episodes,omitempty"` // Sometimes here? Episodes []OphimEpisodeServer `json:"episodes,omitempty"` // Sometimes here?
} `json:"data"` } `json:"data"`
Movie OphimMovie `json:"movie"` Movie OphimMovie `json:"movie"`
Episodes []OphimEpisodeServer `json:"episodes"` Episodes []OphimEpisodeServer `json:"episodes"`
Pagination struct { Pagination struct {
TotalItems int `json:"totalItems"` TotalItems int `json:"totalItems"`
TotalItemsPerPage int `json:"totalItemsPerPage"` TotalItemsPerPage int `json:"totalItemsPerPage"`
CurrentPage int `json:"currentPage"` CurrentPage int `json:"currentPage"`
TotalPages int `json:"totalPages"` TotalPages int `json:"totalPages"`
} `json:"pagination"` } `json:"pagination"`
} }
type OphimItem struct { type OphimItem struct {
Name string `json:"name"` Name string `json:"name"`
OriginName string `json:"origin_name"` OriginName string `json:"origin_name"`
Slug string `json:"slug"` Slug string `json:"slug"`
ThumbURL string `json:"thumb_url"` ThumbURL string `json:"thumb_url"`
PosterURL string `json:"poster_url"` PosterURL string `json:"poster_url"`
Year int `json:"year"` Year int `json:"year"`
Time string `json:"time"` Time string `json:"time"`
Quality string `json:"quality"` Quality string `json:"quality"`
Lang string `json:"lang"` Lang string `json:"lang"`
} }
type OphimMovie struct { type OphimMovie struct {
ID string `json:"_id"` ID string `json:"_id"`
Name string `json:"name"` Name string `json:"name"`
OriginName string `json:"origin_name"` OriginName string `json:"origin_name"`
Slug string `json:"slug"` Slug string `json:"slug"`
Content string `json:"content"` Content string `json:"content"`
ThumbURL string `json:"thumb_url"` ThumbURL string `json:"thumb_url"`
PosterURL string `json:"poster_url"` PosterURL string `json:"poster_url"`
Year int `json:"year"` Year int `json:"year"`
Time string `json:"time"` Time string `json:"time"`
Quality string `json:"quality"` Quality string `json:"quality"`
Lang string `json:"lang"` Lang string `json:"lang"`
Director []string `json:"director"` Director []string `json:"director"`
Category []struct { Category []struct {
Name string `json:"name"` Name string `json:"name"`
} `json:"category"` } `json:"category"`
Country []struct { Country []struct {
Name string `json:"name"` Name string `json:"name"`
} `json:"country"` } `json:"country"`
Episodes []OphimEpisodeServer `json:"episodes,omitempty"` // Nested episodes? Episodes []OphimEpisodeServer `json:"episodes,omitempty"` // Nested episodes?
TrailerURL string `json:"trailer_url"` TrailerURL string `json:"trailer_url"`
} }
type OphimEpisodeServer struct { type OphimEpisodeServer struct {
ServerName string `json:"server_name"` ServerName string `json:"server_name"`
ServerData []OphimEpisodeData `json:"server_data"` ServerData []OphimEpisodeData `json:"server_data"`
} }
type OphimEpisodeData struct { type OphimEpisodeData struct {
Name string `json:"name"` Name string `json:"name"`
Slug string `json:"slug"` Slug string `json:"slug"`
Filename string `json:"filename"` Filename string `json:"filename"`
LinkEmbed string `json:"link_embed"` LinkEmbed string `json:"link_embed"`
LinkM3U8 string `json:"link_m3u8"` LinkM3U8 string `json:"link_m3u8"`
} }
func (s *OphimScraper) GetMoviesByCategory(category string, page int) ([]models.RophimMovie, error) { func (s *OphimScraper) GetMoviesByCategory(category string, page int) ([]models.RophimMovie, error) {
// Logic to distinguish between "Lists" (danh-sach) and "Genres" (the-loai) // 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 // Known lists: phim-le, phim-bo, hoat-hinh, tv-shows, phim-sap-chieu, phim-dang-chieu
var path string var path string
switch category { switch category {
case "home", "": case "home", "":
path = "danh-sach/phim-moi-cap-nhat" path = "danh-sach/phim-moi-cap-nhat"
case "phim-le", "phim-bo", "hoat-hinh", "tv-shows", "phim-sap-chieu", "phim-dang-chieu": case "phim-le", "phim-bo", "hoat-hinh", "tv-shows", "phim-sap-chieu", "phim-dang-chieu":
path = fmt.Sprintf("danh-sach/%s", category) path = fmt.Sprintf("danh-sach/%s", category)
default: default:
// Assume everything else is a Genre (e.g., hanh-dong, tinh-cam, co-trang) // Assume everything else is a Genre (e.g., hanh-dong, tinh-cam, co-trang)
// Ophim uses "the-loai" for these. // Ophim uses "the-loai" for these.
path = fmt.Sprintf("the-loai/%s", category) path = fmt.Sprintf("the-loai/%s", category)
} }
// Important: The upstream API endpoints are: // Important: The upstream API endpoints are:
// - v1/api/danh-sach/{slug} // - v1/api/danh-sach/{slug}
// - v1/api/the-loai/{slug} // - v1/api/the-loai/{slug}
// The getList function appends prefix if not present? // The getList function appends prefix if not present?
// s.getList adds "v1/api" prefix? No, currently getList takes full path suffix. // 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) // 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. // So we need to include "v1/api/" in our path variable constructed above.
finalPath := fmt.Sprintf("v1/api/%s", path) finalPath := fmt.Sprintf("v1/api/%s", path)
return s.getList(finalPath, page) return s.getList(finalPath, page)
} }
func (s *OphimScraper) GetHomepageMovies(page int) ([]models.RophimMovie, error) { func (s *OphimScraper) GetHomepageMovies(page int) ([]models.RophimMovie, error) {
return s.GetMoviesByCategory("home", page) return s.GetMoviesByCategory("home", page)
} }
func (s *OphimScraper) Search(query string, page int) ([]models.RophimMovie, error) { func (s *OphimScraper) Search(query string, page int) ([]models.RophimMovie, error) {
encodedQuery := url.QueryEscape(query) encodedQuery := url.QueryEscape(query)
url := fmt.Sprintf("%s/v1/api/tim-kiem?keyword=%s&page=%d", OphimBaseURL, encodedQuery, page) url := fmt.Sprintf("%s/v1/api/tim-kiem?keyword=%s&page=%d", OphimBaseURL, encodedQuery, page)
return s.fetchAndParseList(url) return s.fetchAndParseList(url)
} }
func (s *OphimScraper) GetGenres() ([]models.Category, error) { func (s *OphimScraper) GetGenres() ([]models.Category, error) {
return s.fetchCategories("v1/api/the-loai") return s.fetchCategories("v1/api/the-loai")
} }
func (s *OphimScraper) GetCountries() ([]models.Category, error) { func (s *OphimScraper) GetCountries() ([]models.Category, error) {
return s.fetchCategories("v1/api/quoc-gia") return s.fetchCategories("v1/api/quoc-gia")
} }
func (s *OphimScraper) fetchCategories(path string) ([]models.Category, error) { func (s *OphimScraper) fetchCategories(path string) ([]models.Category, error) {
url := fmt.Sprintf("%s/%s", OphimBaseURL, path) url := fmt.Sprintf("%s/%s", OphimBaseURL, path)
resp, err := s.client.Get(url) resp, err := s.client.Get(url)
if err != nil { if err != nil {
return nil, err return nil, err
} }
defer resp.Body.Close() defer resp.Body.Close()
var result struct { var result struct {
Data struct { Data struct {
Items []struct { Items []struct {
Name string `json:"name"` Name string `json:"name"`
Slug string `json:"slug"` Slug string `json:"slug"`
} `json:"items"` } `json:"items"`
} `json:"data"` } `json:"data"`
} }
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, err return nil, err
} }
var categories []models.Category var categories []models.Category
for _, item := range result.Data.Items { for _, item := range result.Data.Items {
categories = append(categories, models.Category{ categories = append(categories, models.Category{
Name: item.Name, Name: item.Name,
Slug: item.Slug, Slug: item.Slug,
}) })
} }
return categories, nil return categories, nil
} }
func (s *OphimScraper) getList(path string, page int) ([]models.RophimMovie, error) { func (s *OphimScraper) getList(path string, page int) ([]models.RophimMovie, error) {
url := fmt.Sprintf("%s/%s?page=%d", OphimBaseURL, path, page) url := fmt.Sprintf("%s/%s?page=%d", OphimBaseURL, path, page)
return s.fetchAndParseList(url) return s.fetchAndParseList(url)
} }
func (s *OphimScraper) fetchAndParseList(url string) ([]models.RophimMovie, error) { func (s *OphimScraper) fetchAndParseList(url string) ([]models.RophimMovie, error) {
resp, err := s.client.Get(url) resp, err := s.client.Get(url)
if err != nil { if err != nil {
return nil, err return nil, err
} }
defer resp.Body.Close() defer resp.Body.Close()
if resp.StatusCode != 200 { if resp.StatusCode != 200 {
return nil, fmt.Errorf("status code error: %d %s", resp.StatusCode, resp.Status) return nil, fmt.Errorf("status code error: %d %s", resp.StatusCode, resp.Status)
} }
var result OphimResponse var result OphimResponse
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, err return nil, err
} }
// API usually returns items in "items" (homepage/list) or "data" sometimes? // API usually returns items in "items" (homepage/list) or "data" sometimes?
// The struct OphimResponse has "items". // The struct OphimResponse has "items".
// Search API structure verification: // Search API structure verification:
// My previous curl showed "data": { "items": [...] } structure for search? // My previous curl showed "data": { "items": [...] } structure for search?
// Wait, checking the curled output from Step 256. // Wait, checking the curled output from Step 256.
// Output: `{"status":true,"msg":"","data":{"seoOnPage":...,"breadCrumb":...,"titlePage":...,"items":[...]` // Output: `{"status":true,"msg":"","data":{"seoOnPage":...,"breadCrumb":...,"titlePage":...,"items":[...]`
// So Search returns data -> items. // So Search returns data -> items.
// My OphimResponse struct has "Items []OphimItem" at top level. // My OphimResponse struct has "Items []OphimItem" at top level.
// I need to adjust struct to handle "data" wrapper if present, or "items" if direct. // I need to adjust struct to handle "data" wrapper if present, or "items" if direct.
// The homepage returns "items" directly? // The homepage returns "items" directly?
// Let's check homepage struct. I previously assumed it was directly status, items. // Let's check homepage struct. I previously assumed it was directly status, items.
// If search has "data", generic parsing might need adjustment. // If search has "data", generic parsing might need adjustment.
// Let's look at the previous successful homepage request. // Let's look at the previous successful homepage request.
// If it worked, then homepage returns "items" at top level. // If it worked, then homepage returns "items" at top level.
// If Search returns "data" -> "items", I need a wrapper struct. // If Search returns "data" -> "items", I need a wrapper struct.
var movies []models.RophimMovie var movies []models.RophimMovie
items := result.Items items := result.Items
// If top level items is empty, try checking if there is a Data field with 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. // I need to update OphimResponse struct first to include Data field.
if len(items) == 0 && len(result.Data.Items) > 0 { if len(items) == 0 && len(result.Data.Items) > 0 {
items = result.Data.Items items = result.Data.Items
} }
for _, item := range items { for _, item := range items {
thumb := item.ThumbURL thumb := item.ThumbURL
if !strings.HasPrefix(thumb, "http") { if !strings.HasPrefix(thumb, "http") {
// Search API might return relative paths too // Search API might return relative paths too
thumb = "https://img.ophim1.com/uploads/movies/" + thumb thumb = "https://img.ophim1.com/uploads/movies/" + thumb
} }
backdrop := item.PosterURL backdrop := item.PosterURL
if !strings.HasPrefix(backdrop, "http") { if !strings.HasPrefix(backdrop, "http") {
backdrop = "https://img.ophim1.com/uploads/movies/" + backdrop backdrop = "https://img.ophim1.com/uploads/movies/" + backdrop
} }
movies = append(movies, models.RophimMovie{ movies = append(movies, models.RophimMovie{
ID: item.Slug, ID: item.Slug,
Title: item.Name, Title: item.Name,
OriginalTitle: item.OriginName, OriginalTitle: item.OriginName,
Slug: item.Slug, Slug: item.Slug,
Thumbnail: thumb, Thumbnail: thumb,
Backdrop: backdrop, Backdrop: backdrop,
Year: item.Year, Year: item.Year,
Category: "movies", Category: "movies",
Provider: "Ophim", Provider: "Ophim",
Time: item.Time, Time: item.Time,
Quality: item.Quality, Quality: item.Quality,
Lang: item.Lang, Lang: item.Lang,
}) })
} }
return movies, nil return movies, nil
} }
func (s *OphimScraper) GetMovieDetail(slug string) (*models.RophimMovie, error) { func (s *OphimScraper) GetMovieDetail(slug string) (*models.RophimMovie, error) {
// Correct API endpoint is v1/api/phim/{slug} // Correct API endpoint is v1/api/phim/{slug}
url := fmt.Sprintf("%s/v1/api/phim/%s", OphimBaseURL, slug) url := fmt.Sprintf("%s/v1/api/phim/%s", OphimBaseURL, slug)
resp, err := s.client.Get(url) resp, err := s.client.Get(url)
if err != nil { if err != nil {
return nil, err return nil, err
} }
defer resp.Body.Close() defer resp.Body.Close()
if resp.StatusCode != 200 { if resp.StatusCode != 200 {
return nil, fmt.Errorf("status code error: %d %s", resp.StatusCode, resp.Status) return nil, fmt.Errorf("status code error: %d %s", resp.StatusCode, resp.Status)
} }
var result OphimResponse var result OphimResponse
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, err return nil, err
} }
// Try to get movie from Top Level or Data.Item // Try to get movie from Top Level or Data.Item
movie := result.Movie movie := result.Movie
if movie.Slug == "" { if movie.Slug == "" {
movie = result.Data.Item movie = result.Data.Item
} }
thumb := movie.ThumbURL thumb := movie.ThumbURL
if !strings.HasPrefix(thumb, "http") { if !strings.HasPrefix(thumb, "http") {
thumb = "https://img.ophim1.com/uploads/movies/" + thumb thumb = "https://img.ophim1.com/uploads/movies/" + thumb
} }
backdrop := movie.PosterURL backdrop := movie.PosterURL
if !strings.HasPrefix(backdrop, "http") { if !strings.HasPrefix(backdrop, "http") {
backdrop = "https://img.ophim1.com/uploads/movies/" + backdrop backdrop = "https://img.ophim1.com/uploads/movies/" + backdrop
} }
var episodes []models.Episode var episodes []models.Episode
// Try Top Level Episodes, then Data.Episodes, then Movie.Episodes? // Try Top Level Episodes, then Data.Episodes, then Movie.Episodes?
rawEpisodes := result.Episodes rawEpisodes := result.Episodes
if len(rawEpisodes) == 0 { if len(rawEpisodes) == 0 {
// New API might put episodes inside "item.episodes" or "data.episodes" // New API might put episodes inside "item.episodes" or "data.episodes"
// Based on typical Ophim structures: // Based on typical Ophim structures:
if len(result.Data.Episodes) > 0 { if len(result.Data.Episodes) > 0 {
rawEpisodes = result.Data.Episodes rawEpisodes = result.Data.Episodes
} else if len(movie.Episodes) > 0 { } else if len(movie.Episodes) > 0 {
rawEpisodes = movie.Episodes rawEpisodes = movie.Episodes
} }
} }
epMap := make(map[string]int) // map[epNum-serverName]sliceIndex epMap := make(map[string]int) // map[epNum-serverName]sliceIndex
for _, server := range rawEpisodes { for _, server := range rawEpisodes {
for _, ep := range server.ServerData { for _, ep := range server.ServerData {
epNum := 0 epNum := 0
fmt.Sscanf(ep.Name, "%d", &epNum) fmt.Sscanf(ep.Name, "%d", &epNum)
if epNum == 0 { if epNum == 0 {
var n int var n int
if _, err := fmt.Sscanf(ep.Name, "Tap %d", &n); err == nil { if _, err := fmt.Sscanf(ep.Name, "Tap %d", &n); err == nil {
epNum = n epNum = n
} }
if strings.EqualFold(ep.Name, "Full") || strings.EqualFold(ep.Name, "Trailer") { if strings.EqualFold(ep.Name, "Full") || strings.EqualFold(ep.Name, "Trailer") {
epNum = 1 // single-movie or trailer as ep 1 epNum = 1 // single-movie or trailer as ep 1
} }
// If still 0, skip // If still 0, skip
if epNum == 0 { if epNum == 0 {
continue continue
} }
} }
serverKey := fmt.Sprintf("%d-%s", epNum, server.ServerName) serverKey := fmt.Sprintf("%d-%s", epNum, server.ServerName)
if idx, exists := epMap[serverKey]; exists { if idx, exists := epMap[serverKey]; exists {
// If existing is empty, replace with this one // If existing is empty, replace with this one
if episodes[idx].URL == "" && ep.LinkM3U8 != "" { if episodes[idx].URL == "" && ep.LinkM3U8 != "" {
episodes[idx].URL = ep.LinkM3U8 episodes[idx].URL = ep.LinkM3U8
episodes[idx].Title = ep.Name episodes[idx].Title = ep.Name
} }
} else { } else {
if ep.LinkM3U8 == "" && ep.LinkEmbed == "" { if ep.LinkM3U8 == "" && ep.LinkEmbed == "" {
continue continue
} }
epMap[serverKey] = len(episodes) epMap[serverKey] = len(episodes)
episodes = append(episodes, models.Episode{ episodes = append(episodes, models.Episode{
Number: epNum, Number: epNum,
Title: ep.Name, Title: ep.Name,
URL: ep.LinkM3U8, URL: ep.LinkM3U8,
ServerName: server.ServerName, ServerName: server.ServerName,
}) })
} }
} }
} }
return &models.RophimMovie{ return &models.RophimMovie{
ID: movie.Slug, ID: movie.Slug,
Title: movie.Name, Title: movie.Name,
OriginalTitle: movie.OriginName, OriginalTitle: movie.OriginName,
Slug: movie.Slug, Slug: movie.Slug,
Thumbnail: thumb, Thumbnail: thumb,
Backdrop: backdrop, Backdrop: backdrop,
Description: movie.Content, Description: movie.Content,
Year: movie.Year, Year: movie.Year,
Quality: movie.Quality, Quality: movie.Quality,
Duration: 0, // String parse needed if we want "90 phut" Duration: 0, // String parse needed if we want "90 phut"
Category: "movies", Category: "movies",
Episodes: episodes, Episodes: episodes,
Country: safeGetName(movie.Country), Country: safeGetName(movie.Country),
Director: strings.Join(movie.Director, ", "), Director: strings.Join(movie.Director, ", "),
Genre: safeGetName(movie.Category), Genre: safeGetName(movie.Category),
TrailerURL: movie.TrailerURL, TrailerURL: movie.TrailerURL,
}, nil }, nil
} }
func safeGetName(items []struct { func safeGetName(items []struct {
Name string `json:"name"` Name string `json:"name"`
}) string { }) string {
var names []string var names []string
for _, i := range items { for _, i := range items {
names = append(names, i.Name) names = append(names, i.Name)
} }
return strings.Join(names, ", ") return strings.Join(names, ", ")
} }

View file

@ -1,191 +1,191 @@
package scraper package scraper
import ( import (
"fmt" "fmt"
"net/http" "net/http"
"net/url" "net/url"
"strconv" "strconv"
"strings" "strings"
"time" "time"
"streamflow-backend/internal/models" "streamflow-backend/internal/models"
"github.com/PuerkitoBio/goquery" "github.com/PuerkitoBio/goquery"
) )
func parseEpisodeNumber(title string) int { func parseEpisodeNumber(title string) int {
// e.g "Tập 1", "Tập 01", "Full" // e.g "Tập 1", "Tập 01", "Full"
t := strings.ToLower(strings.TrimSpace(title)) t := strings.ToLower(strings.TrimSpace(title))
if t == "full" { if t == "full" {
return 1 return 1
} }
t = strings.ReplaceAll(t, "tập ", "") t = strings.ReplaceAll(t, "tập ", "")
t = strings.ReplaceAll(t, "tap ", "") t = strings.ReplaceAll(t, "tap ", "")
// handle multi-spaces // handle multi-spaces
parts := strings.Fields(t) parts := strings.Fields(t)
if len(parts) > 0 { if len(parts) > 0 {
num, err := strconv.Atoi(parts[0]) num, err := strconv.Atoi(parts[0])
if err == nil { if err == nil {
return num return num
} }
} }
return 1 return 1
} }
const Phim30BaseURL = "https://phim30.me" const Phim30BaseURL = "https://phim30.me"
type Phim30Scraper struct { type Phim30Scraper struct {
client *http.Client client *http.Client
} }
func NewPhim30Scraper() *Phim30Scraper { func NewPhim30Scraper() *Phim30Scraper {
return &Phim30Scraper{ return &Phim30Scraper{
client: &http.Client{ client: &http.Client{
Timeout: 30 * time.Second, Timeout: 30 * time.Second,
}, },
} }
} }
func (p *Phim30Scraper) Search(query string, page int) ([]models.RophimMovie, error) { 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) searchURL := fmt.Sprintf("%s/tim-kiem?keyword=%s&page=%d", Phim30BaseURL, url.QueryEscape(query), page)
return p.scrapeMovieList(searchURL) return p.scrapeMovieList(searchURL)
} }
func (p *Phim30Scraper) GetMoviesByCategory(category string, page int) ([]models.RophimMovie, error) { func (p *Phim30Scraper) GetMoviesByCategory(category string, page int) ([]models.RophimMovie, error) {
// e.g. https://phim30.me/the-loai/hanh-dong?page=1 // e.g. https://phim30.me/the-loai/hanh-dong?page=1
catURL := fmt.Sprintf("%s/the-loai/%s?page=%d", Phim30BaseURL, category, page) catURL := fmt.Sprintf("%s/the-loai/%s?page=%d", Phim30BaseURL, category, page)
return p.scrapeMovieList(catURL) return p.scrapeMovieList(catURL)
} }
func (p *Phim30Scraper) scrapeMovieList(targetURL string) ([]models.RophimMovie, error) { func (p *Phim30Scraper) scrapeMovieList(targetURL string) ([]models.RophimMovie, error) {
req, err := http.NewRequest("GET", targetURL, nil) req, err := http.NewRequest("GET", targetURL, nil)
if err != nil { if err != nil {
return nil, err return nil, err
} }
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36") req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36")
resp, err := p.client.Do(req) resp, err := p.client.Do(req)
if err != nil { if err != nil {
return nil, err return nil, err
} }
defer resp.Body.Close() defer resp.Body.Close()
if resp.StatusCode != http.StatusOK { if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("phim30 returned status: %d", resp.StatusCode) return nil, fmt.Errorf("phim30 returned status: %d", resp.StatusCode)
} }
doc, err := goquery.NewDocumentFromReader(resp.Body) doc, err := goquery.NewDocumentFromReader(resp.Body)
if err != nil { if err != nil {
return nil, err return nil, err
} }
var movies []models.RophimMovie var movies []models.RophimMovie
doc.Find("a[href^='https://phim30.me/phim/']").Each(func(i int, s *goquery.Selection) { doc.Find("a[href^='https://phim30.me/phim/']").Each(func(i int, s *goquery.Selection) {
href, _ := s.Attr("href") href, _ := s.Attr("href")
title, _ := s.Attr("title") title, _ := s.Attr("title")
// Remove the base url to get the slug // Remove the base url to get the slug
slug := strings.TrimPrefix(href, "https://phim30.me/phim/") slug := strings.TrimPrefix(href, "https://phim30.me/phim/")
// Try to find an image child (check data-src for lazy-loaded images) // Try to find an image child (check data-src for lazy-loaded images)
thumb := "" thumb := ""
s.Find("img").Each(func(j int, img *goquery.Selection) { s.Find("img").Each(func(j int, img *goquery.Selection) {
src, _ := img.Attr("src") src, _ := img.Attr("src")
dataSrc, _ := img.Attr("data-src") dataSrc, _ := img.Attr("data-src")
lazySrc, _ := img.Attr("lazy-src") lazySrc, _ := img.Attr("lazy-src")
if dataSrc != "" { if dataSrc != "" {
thumb = dataSrc thumb = dataSrc
} else if lazySrc != "" { } else if lazySrc != "" {
thumb = lazySrc thumb = lazySrc
} else if src != "" && !strings.Contains(src, "data:image") { } else if src != "" && !strings.Contains(src, "data:image") {
thumb = src thumb = src
} }
}) })
if title != "" && slug != "" { if title != "" && slug != "" {
movies = append(movies, models.RophimMovie{ movies = append(movies, models.RophimMovie{
ID: slug, ID: slug,
Slug: slug, Slug: slug,
Title: title, Title: title,
OriginalTitle: title, OriginalTitle: title,
Thumbnail: thumb, Thumbnail: thumb,
}) })
} }
}) })
// Deduplicate movies because a search page might have multiple links to the same movie // Deduplicate movies because a search page might have multiple links to the same movie
var uniqueMovies []models.RophimMovie var uniqueMovies []models.RophimMovie
seen := make(map[string]bool) seen := make(map[string]bool)
for _, m := range movies { for _, m := range movies {
if !seen[m.Slug] { if !seen[m.Slug] {
seen[m.Slug] = true seen[m.Slug] = true
uniqueMovies = append(uniqueMovies, m) uniqueMovies = append(uniqueMovies, m)
} }
} }
return uniqueMovies, nil return uniqueMovies, nil
} }
func (p *Phim30Scraper) GetMovieDetail(slug string) (*models.RophimMovie, error) { func (p *Phim30Scraper) GetMovieDetail(slug string) (*models.RophimMovie, error) {
targetURL := fmt.Sprintf("%s/phim/%s", Phim30BaseURL, slug) targetURL := fmt.Sprintf("%s/phim/%s", Phim30BaseURL, slug)
req, err := http.NewRequest("GET", targetURL, nil) req, err := http.NewRequest("GET", targetURL, nil)
if err != nil { if err != nil {
return nil, err return nil, err
} }
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36") req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36")
resp, err := p.client.Do(req) resp, err := p.client.Do(req)
if err != nil { if err != nil {
return nil, err return nil, err
} }
defer resp.Body.Close() defer resp.Body.Close()
if resp.StatusCode != http.StatusOK { if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("phim30 returned status: %d", resp.StatusCode) return nil, fmt.Errorf("phim30 returned status: %d", resp.StatusCode)
} }
doc, err := goquery.NewDocumentFromReader(resp.Body) doc, err := goquery.NewDocumentFromReader(resp.Body)
if err != nil { if err != nil {
return nil, err return nil, err
} }
movie := &models.RophimMovie{ movie := &models.RophimMovie{
ID: slug, ID: slug,
Slug: slug, Slug: slug,
} }
title := doc.Find("h1.movie-title").Text() title := doc.Find("h1.movie-title").Text()
if title == "" { if title == "" {
title = doc.Find("title").Text() title = doc.Find("title").Text()
title = strings.Split(title, "")[0] title = strings.Split(title, "")[0]
title = strings.TrimSpace(title) title = strings.TrimSpace(title)
} }
movie.Title = title movie.Title = title
movie.OriginalTitle = title movie.OriginalTitle = title
var eps []models.Episode var eps []models.Episode
doc.Find("a[href*='/xem-phim/']").Each(func(i int, s *goquery.Selection) { doc.Find("a[href*='/xem-phim/']").Each(func(i int, s *goquery.Selection) {
href, _ := s.Attr("href") href, _ := s.Attr("href")
epName := strings.TrimSpace(s.Text()) epName := strings.TrimSpace(s.Text())
if epName != "" && href != "" { if epName != "" && href != "" {
if !strings.HasPrefix(href, "http") { if !strings.HasPrefix(href, "http") {
href = Phim30BaseURL + href href = Phim30BaseURL + href
} }
eps = append(eps, models.Episode{ eps = append(eps, models.Episode{
ServerName: "Phim30", ServerName: "Phim30",
Title: epName, Title: epName,
Number: parseEpisodeNumber(epName), Number: parseEpisodeNumber(epName),
URL: href, URL: href,
}) })
} }
}) })
if len(eps) > 0 { if len(eps) > 0 {
movie.Episodes = eps movie.Episodes = eps
} }
return movie, nil return movie, nil
} }

View file

@ -1,9 +1,9 @@
package scraper package scraper
import "streamflow-backend/internal/models" import "streamflow-backend/internal/models"
type MovieProvider interface { type MovieProvider interface {
GetMoviesByCategory(category string, page int) ([]models.RophimMovie, error) GetMoviesByCategory(category string, page int) ([]models.RophimMovie, error)
GetMovieDetail(slug string) (*models.RophimMovie, error) GetMovieDetail(slug string) (*models.RophimMovie, error)
Search(query string, page int) ([]models.RophimMovie, error) Search(query string, page int) ([]models.RophimMovie, error)
} }

View file

@ -1,246 +1,246 @@
package scraper package scraper
import ( import (
"crypto/tls" "crypto/tls"
"fmt" "fmt"
"net/http" "net/http"
"regexp" "regexp"
"strconv" "strconv"
"strings" "strings"
"time" "time"
"streamflow-backend/internal/models" "streamflow-backend/internal/models"
"github.com/PuerkitoBio/goquery" "github.com/PuerkitoBio/goquery"
) )
const BaseURL = "https://phimmoichill.network" const BaseURL = "https://phimmoichill.network"
type RophimScraper struct { type RophimScraper struct {
client *http.Client client *http.Client
} }
func NewRophimScraper() *RophimScraper { func NewRophimScraper() *RophimScraper {
// Create custom client to handle SSL constraints if needed, similar to Python's ssl_context // Create custom client to handle SSL constraints if needed, similar to Python's ssl_context
tr := &http.Transport{ tr := &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
} }
client := &http.Client{ client := &http.Client{
Transport: tr, Transport: tr,
Timeout: 30 * time.Second, Timeout: 30 * time.Second,
} }
return &RophimScraper{client: client} return &RophimScraper{client: client}
} }
func (s *RophimScraper) fetchDocument(url string) (*goquery.Document, error) { func (s *RophimScraper) fetchDocument(url string) (*goquery.Document, error) {
req, err := http.NewRequest("GET", url, nil) req, err := http.NewRequest("GET", url, nil)
if err != nil { if err != nil {
return nil, err 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("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("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8")
req.Header.Set("Referer", BaseURL) req.Header.Set("Referer", BaseURL)
resp, err := s.client.Do(req) resp, err := s.client.Do(req)
if err != nil { if err != nil {
return nil, err return nil, err
} }
defer resp.Body.Close() defer resp.Body.Close()
if resp.StatusCode != 200 { if resp.StatusCode != 200 {
return nil, fmt.Errorf("status code error: %d %s", resp.StatusCode, resp.Status) return nil, fmt.Errorf("status code error: %d %s", resp.StatusCode, resp.Status)
} }
return goquery.NewDocumentFromReader(resp.Body) return goquery.NewDocumentFromReader(resp.Body)
} }
func (s *RophimScraper) GetHomepageMovies(page int, limit int) ([]models.RophimMovie, error) { func (s *RophimScraper) GetHomepageMovies(page int, limit int) ([]models.RophimMovie, error) {
url := fmt.Sprintf("%s/danh-sach/phim-le", BaseURL) url := fmt.Sprintf("%s/danh-sach/phim-le", BaseURL)
if page > 1 { if page > 1 {
url = fmt.Sprintf("%s/danh-sach/phim-le/page/%d", BaseURL, page) url = fmt.Sprintf("%s/danh-sach/phim-le/page/%d", BaseURL, page)
} }
doc, err := s.fetchDocument(url) doc, err := s.fetchDocument(url)
if err != nil { if err != nil {
return nil, err return nil, err
} }
return s.parseMovieGrid(doc, limit), nil return s.parseMovieGrid(doc, limit), nil
} }
func (s *RophimScraper) Search(query string, limit int) ([]models.RophimMovie, error) { func (s *RophimScraper) Search(query string, limit int) ([]models.RophimMovie, error) {
url := fmt.Sprintf("%s/tim-kiem?keyword=%s", BaseURL, query) url := fmt.Sprintf("%s/tim-kiem?keyword=%s", BaseURL, query)
doc, err := s.fetchDocument(url) doc, err := s.fetchDocument(url)
if err != nil { if err != nil {
return nil, err return nil, err
} }
return s.parseMovieGrid(doc, limit), nil return s.parseMovieGrid(doc, limit), nil
} }
func (s *RophimScraper) parseMovieGrid(doc *goquery.Document, limit int) []models.RophimMovie { func (s *RophimScraper) parseMovieGrid(doc *goquery.Document, limit int) []models.RophimMovie {
var movies []models.RophimMovie var movies []models.RophimMovie
doc.Find(".myui-vodlist__box").EachWithBreak(func(i int, s *goquery.Selection) bool { doc.Find(".myui-vodlist__box").EachWithBreak(func(i int, s *goquery.Selection) bool {
if i >= limit { if i >= limit {
return false return false
} }
link := s.Find("a.myui-vodlist__thumb") link := s.Find("a.myui-vodlist__thumb")
if link.Length() == 0 { if link.Length() == 0 {
link = s.Find("a[href*='/phim/']") link = s.Find("a[href*='/phim/']")
} }
if link.Length() == 0 { if link.Length() == 0 {
return true return true
} }
href, _ := link.Attr("href") href, _ := link.Attr("href")
slug := extractSlug(href) slug := extractSlug(href)
if slug == "" { if slug == "" {
return true return true
} }
title, _ := link.Attr("title") title, _ := link.Attr("title")
if title == "" { if title == "" {
title = s.Find("h4.title a").Text() title = s.Find("h4.title a").Text()
} }
style, _ := link.Attr("style") style, _ := link.Attr("style")
thumbnail := extractThumbnail(style) thumbnail := extractThumbnail(style)
if thumbnail == "" { if thumbnail == "" {
thumbnail, _ = s.Find("img").Attr("src") thumbnail, _ = s.Find("img").Attr("src")
} }
quality := s.Find(".pic-tag").Text() quality := s.Find(".pic-tag").Text()
if quality == "" { if quality == "" {
quality = "HD" quality = "HD"
} }
engTitle := s.Find(".text-muted").Text() engTitle := s.Find(".text-muted").Text()
movie := models.RophimMovie{ movie := models.RophimMovie{
ID: slug, ID: slug,
Title: strings.TrimSpace(title), Title: strings.TrimSpace(title),
OriginalTitle: strings.TrimSpace(engTitle), OriginalTitle: strings.TrimSpace(engTitle),
Slug: slug, Slug: slug,
Thumbnail: normalizeURL(thumbnail), Thumbnail: normalizeURL(thumbnail),
Quality: strings.TrimSpace(quality), Quality: strings.TrimSpace(quality),
Category: "movies", // Default Category: "movies", // Default
} }
movies = append(movies, movie) movies = append(movies, movie)
return true return true
}) })
return movies return movies
} }
func (s *RophimScraper) GetMovieDetail(slug string) (*models.RophimMovie, error) { func (s *RophimScraper) GetMovieDetail(slug string) (*models.RophimMovie, error) {
url := fmt.Sprintf("%s/phim/%s", BaseURL, slug) url := fmt.Sprintf("%s/phim/%s", BaseURL, slug)
doc, err := s.fetchDocument(url) doc, err := s.fetchDocument(url)
if err != nil { if err != nil {
return nil, err return nil, err
} }
return s.parseMovieDetail(doc, slug), nil return s.parseMovieDetail(doc, slug), nil
} }
func (s *RophimScraper) parseMovieDetail(doc *goquery.Document, slug string) *models.RophimMovie { func (s *RophimScraper) parseMovieDetail(doc *goquery.Document, slug string) *models.RophimMovie {
title := doc.Find("h1.movie-title").Text() title := doc.Find("h1.movie-title").Text()
if title == "" { if title == "" {
title = doc.Find("h1").Text() title = doc.Find("h1").Text()
} }
description := doc.Find("meta[name='description']").AttrOr("content", "") description := doc.Find("meta[name='description']").AttrOr("content", "")
if description == "" { if description == "" {
description = doc.Find(".description, .content, .film-description").Text() description = doc.Find(".description, .content, .film-description").Text()
} }
poster := doc.Find("meta[property='og:image']").AttrOr("content", "") poster := doc.Find("meta[property='og:image']").AttrOr("content", "")
// Parse Info (Year, Country, etc) - simplified for brevity // Parse Info (Year, Country, etc) - simplified for brevity
var year int var year int
doc.Find(".movie-info li, .film-info li").Each(func(i int, s *goquery.Selection) { doc.Find(".movie-info li, .film-info li").Each(func(i int, s *goquery.Selection) {
text := s.Text() text := s.Text()
if strings.Contains(text, "Năm") || strings.Contains(text, "Year") { if strings.Contains(text, "Năm") || strings.Contains(text, "Year") {
re := regexp.MustCompile(`\d{4}`) re := regexp.MustCompile(`\d{4}`)
if match := re.FindString(text); match != "" { if match := re.FindString(text); match != "" {
year, _ = strconv.Atoi(match) year, _ = strconv.Atoi(match)
} }
} }
}) })
// Parse Episodes // Parse Episodes
var episodes []models.Episode var episodes []models.Episode
doc.Find("a[href*='/tap-'], a[href*='episode'], .episode-list a").Each(func(i int, s *goquery.Selection) { doc.Find("a[href*='/tap-'], a[href*='episode'], .episode-list a").Each(func(i int, s *goquery.Selection) {
href, _ := s.Attr("href") href, _ := s.Attr("href")
text := strings.TrimSpace(s.Text()) text := strings.TrimSpace(s.Text())
re := regexp.MustCompile(`tap-(\d+)`) re := regexp.MustCompile(`tap-(\d+)`)
match := re.FindStringSubmatch(href) match := re.FindStringSubmatch(href)
if len(match) > 1 { if len(match) > 1 {
epNum, _ := strconv.Atoi(match[1]) epNum, _ := strconv.Atoi(match[1])
episodes = append(episodes, models.Episode{ episodes = append(episodes, models.Episode{
Number: epNum, Number: epNum,
Title: text, Title: text,
URL: normalizeURL(href), URL: normalizeURL(href),
}) })
} }
}) })
// De-duplicate episodes // De-duplicate episodes
seen := make(map[int]bool) seen := make(map[int]bool)
var uniqueEpisodes []models.Episode var uniqueEpisodes []models.Episode
for _, ep := range episodes { for _, ep := range episodes {
if !seen[ep.Number] { if !seen[ep.Number] {
seen[ep.Number] = true seen[ep.Number] = true
uniqueEpisodes = append(uniqueEpisodes, ep) uniqueEpisodes = append(uniqueEpisodes, ep)
} }
} }
return &models.RophimMovie{ return &models.RophimMovie{
ID: slug, ID: slug,
Title: strings.TrimSpace(title), Title: strings.TrimSpace(title),
Slug: slug, Slug: slug,
Thumbnail: normalizeURL(poster), Thumbnail: normalizeURL(poster),
Description: strings.TrimSpace(description), Description: strings.TrimSpace(description),
Year: year, Year: year,
Episodes: uniqueEpisodes, Episodes: uniqueEpisodes,
Category: "movies", Category: "movies",
} }
} }
func extractSlug(url string) string { func extractSlug(url string) string {
re := regexp.MustCompile(`/phim/([^/?#]+)`) re := regexp.MustCompile(`/phim/([^/?#]+)`)
matches := re.FindStringSubmatch(url) matches := re.FindStringSubmatch(url)
if len(matches) > 1 { if len(matches) > 1 {
return matches[1] return matches[1]
} }
// Fallback // Fallback
parts := strings.Split(url, "/") parts := strings.Split(url, "/")
if len(parts) > 0 { if len(parts) > 0 {
return parts[len(parts)-1] return parts[len(parts)-1]
} }
return "" return ""
} }
func extractThumbnail(style string) string { func extractThumbnail(style string) string {
re := regexp.MustCompile(`url\(([^)]+)\)`) re := regexp.MustCompile(`url\(([^)]+)\)`)
matches := re.FindStringSubmatch(style) matches := re.FindStringSubmatch(style)
if len(matches) > 1 { if len(matches) > 1 {
return strings.Trim(matches[1], "'\"") return strings.Trim(matches[1], "'\"")
} }
return "" return ""
} }
func normalizeURL(url string) string { func normalizeURL(url string) string {
if url == "" { if url == "" {
return "" return ""
} }
if strings.HasPrefix(url, "//") { if strings.HasPrefix(url, "//") {
return "https:" + url return "https:" + url
} }
if strings.HasPrefix(url, "/") { if strings.HasPrefix(url, "/") {
return BaseURL + url return BaseURL + url
} }
return url return url
} }

View file

@ -1,96 +1,96 @@
package service package service
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"fmt" "fmt"
"os" "os"
"os/exec" "os/exec"
"path/filepath" "path/filepath"
"strings" "strings"
"time" "time"
) )
type VideoInfo struct { type VideoInfo struct {
Title string `json:"title"` Title string `json:"title"`
Thumbnail string `json:"thumbnail"` Thumbnail string `json:"thumbnail"`
Duration int `json:"duration"` Duration int `json:"duration"`
StreamURL string `json:"url"` // yt-dlp JSON key is 'url' StreamURL string `json:"url"` // yt-dlp JSON key is 'url'
FormatID string `json:"format_id"` FormatID string `json:"format_id"`
Resolution string `json:"resolution"` // Custom field Resolution string `json:"resolution"` // Custom field
Ext string `json:"ext"` Ext string `json:"ext"`
} }
type VideoExtractor struct{} type VideoExtractor struct{}
func NewVideoExtractor() *VideoExtractor { func NewVideoExtractor() *VideoExtractor {
return &VideoExtractor{} return &VideoExtractor{}
} }
func (e *VideoExtractor) Extract(url string, quality string) (*VideoInfo, error) { func (e *VideoExtractor) Extract(url string, quality string) (*VideoInfo, error) {
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel() defer cancel()
// Check for custom extractors // Check for custom extractors
if strings.Contains(url, "phim30.me") { if strings.Contains(url, "phim30.me") {
// Currently returning the URL as-is, letting yt-dlp attempt extraction // Currently returning the URL as-is, letting yt-dlp attempt extraction
// or allowing the frontend iframe to handle it directly if it's embeddable // or allowing the frontend iframe to handle it directly if it's embeddable
} }
// Build format selector // Build format selector
formatSelector := "bestvideo+bestaudio/best" formatSelector := "bestvideo+bestaudio/best"
if quality != "" { if quality != "" {
height := strings.Replace(quality, "p", "", -1) height := strings.Replace(quality, "p", "", -1)
formatSelector = fmt.Sprintf("bestvideo[height<=%s]+bestaudio/best[height<=%s]/best", height, height) formatSelector = fmt.Sprintf("bestvideo[height<=%s]+bestaudio/best[height<=%s]/best", height, height)
} }
args := []string{ args := []string{
"--dump-json", "--dump-json",
"--no-playlist", "--no-playlist",
"--no-warnings", "--no-warnings",
"--format", formatSelector, "--format", formatSelector,
url, url,
} }
// Check for local yt-dlp.exe // Check for local yt-dlp.exe
ytDlpCmd := "yt-dlp" ytDlpCmd := "yt-dlp"
// Only on windows for simplicity or check OS // Only on windows for simplicity or check OS
if _, err := os.Stat("yt-dlp.exe"); err == nil { if _, err := os.Stat("yt-dlp.exe"); err == nil {
path, _ := filepath.Abs("yt-dlp.exe") path, _ := filepath.Abs("yt-dlp.exe")
ytDlpCmd = path ytDlpCmd = path
} }
cmd := exec.CommandContext(ctx, ytDlpCmd, args...) cmd := exec.CommandContext(ctx, ytDlpCmd, args...)
output, err := cmd.Output() output, err := cmd.Output()
if err != nil { if err != nil {
return nil, fmt.Errorf("extraction failed: %v", err) return nil, fmt.Errorf("extraction failed: %v", err)
} }
var info VideoInfo var info VideoInfo
// yt-dlp dumps JSON. Unmarshal it. // yt-dlp dumps JSON. Unmarshal it.
// Note: yt-dlp JSON has many fields, we only map the ones in VideoInfo struct // Note: yt-dlp JSON has many fields, we only map the ones in VideoInfo struct
if err := json.Unmarshal(output, &info); err != nil { if err := json.Unmarshal(output, &info); err != nil {
return nil, fmt.Errorf("json parse error: %v", err) return nil, fmt.Errorf("json parse error: %v", err)
} }
// Post-process resolution if not directly available or custom logic needed // 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 // In strict parsing, we might need a custom struct to catch 'height' and 'width' to form resolution
// allowing dynamic map parsing for simplicity: // allowing dynamic map parsing for simplicity:
var rawData map[string]interface{} var rawData map[string]interface{}
json.Unmarshal(output, &rawData) json.Unmarshal(output, &rawData)
if h, ok := rawData["height"].(float64); ok { if h, ok := rawData["height"].(float64); ok {
info.Resolution = fmt.Sprintf("%dp", int(h)) info.Resolution = fmt.Sprintf("%dp", int(h))
} else { } else {
info.Resolution = "unknown" info.Resolution = "unknown"
} }
// Ensure StreamURL is populated (sometimes 'url' is the stream url) // Ensure StreamURL is populated (sometimes 'url' is the stream url)
if info.StreamURL == "" { if info.StreamURL == "" {
if u, ok := rawData["url"].(string); ok { if u, ok := rawData["url"].(string); ok {
info.StreamURL = u info.StreamURL = u
} }
} }
return &info, nil return &info, nil
} }

View file

@ -1,113 +1,116 @@
package service package service
import ( import (
"bytes" "bytes"
"crypto/md5" "crypto/md5"
"crypto/tls" "crypto/tls"
"fmt" "fmt"
"image" "image"
"image/jpeg" "image/jpeg"
"image/png" "image/png"
"net/http" "net/http"
"os" "os"
"path/filepath" "path/filepath"
"time" "time"
"golang.org/x/image/draw" "golang.org/x/image/draw"
) )
const CacheDir = "cache/images" const CacheDir = "cache/images"
type ImageService struct { type ImageService struct {
client *http.Client client *http.Client
} }
func NewImageService() *ImageService { func NewImageService() *ImageService {
os.MkdirAll(CacheDir, 0755) os.MkdirAll(CacheDir, 0755)
// Use custom transport to skip SSL verification // Use custom transport to skip SSL verification
tr := &http.Transport{ tr := &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
} }
return &ImageService{ return &ImageService{
client: &http.Client{ client: &http.Client{
Transport: tr, Transport: tr,
Timeout: 15 * time.Second, Timeout: 15 * time.Second,
}, },
} }
} }
func (s *ImageService) GetProxiedImage(url string, width int) ([]byte, string, error) { func (s *ImageService) GetProxiedImage(url string, width int) ([]byte, string, error) {
hash := md5.Sum([]byte(fmt.Sprintf("%s_%d", url, width))) hash := md5.Sum([]byte(fmt.Sprintf("%s_%d", url, width)))
cacheKey := fmt.Sprintf("%x.jpg", hash) cacheKey := fmt.Sprintf("%x.jpg", hash)
cachePath := filepath.Join(CacheDir, cacheKey) cachePath := filepath.Join(CacheDir, cacheKey)
// Check cache // Check cache
if _, err := os.Stat(cachePath); err == nil { if _, err := os.Stat(cachePath); err == nil {
data, err := os.ReadFile(cachePath) data, err := os.ReadFile(cachePath)
if err == nil { if err == nil {
return data, "image/jpeg", nil return data, "image/jpeg", nil
} }
} }
// Fetch with custom request to set headers // Fetch with custom request to set headers
req, err := http.NewRequest("GET", url, nil) req, err := http.NewRequest("GET", url, nil)
if err != nil { if err != nil {
return nil, "", err 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("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/") req.Header.Set("Referer", "https://ophim1.com/")
resp, err := s.client.Do(req) resp, err := s.client.Do(req)
if err != nil { if err != nil {
return nil, "", err fmt.Printf("GetProxiedImage fetch error: %v\n", err)
} return nil, "", err
defer resp.Body.Close() }
defer resp.Body.Close()
if resp.StatusCode != 200 {
return nil, "", fmt.Errorf("image fetch failed: %d", resp.StatusCode) 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") // Decode
var img image.Image
switch contentType { contentType := resp.Header.Get("Content-Type")
case "image/jpeg":
img, err = jpeg.Decode(resp.Body) switch contentType {
case "image/png": case "image/jpeg":
img, err = png.Decode(resp.Body) img, err = jpeg.Decode(resp.Body)
default: case "image/png":
// Attempt agnostic decode img, err = png.Decode(resp.Body)
img, _, err = image.Decode(resp.Body) default:
} // Attempt agnostic decode
img, _, err = image.Decode(resp.Body)
if err != nil { }
return nil, "", fmt.Errorf("decode error: %v", err)
} if err != nil {
fmt.Printf("GetProxiedImage decode error: %v for content-type: %s and url: %s\n", err, contentType, url)
// Resize if needed return nil, "", fmt.Errorf("decode error: %v", err)
if width > 0 && img.Bounds().Dx() > width { }
bounds := img.Bounds()
ratio := float64(width) / float64(bounds.Dx()) // Resize if needed
height := int(float64(bounds.Dy()) * ratio) if width > 0 && img.Bounds().Dx() > width {
bounds := img.Bounds()
dst := image.NewRGBA(image.Rect(0, 0, width, height)) ratio := float64(width) / float64(bounds.Dx())
draw.CatmullRom.Scale(dst, dst.Bounds(), img, bounds, draw.Over, nil) height := int(float64(bounds.Dy()) * ratio)
img = dst
} dst := image.NewRGBA(image.Rect(0, 0, width, height))
draw.CatmullRom.Scale(dst, dst.Bounds(), img, bounds, draw.Over, nil)
// Encode to JPEG img = dst
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) // Encode to JPEG
} var buf bytes.Buffer
if err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: 80}); err != nil {
jpegData := buf.Bytes() return nil, "", fmt.Errorf("jpeg encode error: %v", err)
}
// Write cache
os.WriteFile(cachePath, jpegData, 0644) jpegData := buf.Bytes()
return jpegData, "image/jpeg", nil // Write cache
} os.WriteFile(cachePath, jpegData, 0644)
return jpegData, "image/jpeg", nil
}

View file

@ -1,137 +1,137 @@
package service package service
import ( import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"net/http" "net/http"
"net/url" "net/url"
"os" "os"
"time" "time"
) )
const ( const (
TMDBBaseURL = "https://api.themoviedb.org/3" TMDBBaseURL = "https://api.themoviedb.org/3"
TMDBImageBaseURL = "https://image.tmdb.org/t/p" TMDBImageBaseURL = "https://image.tmdb.org/t/p"
) )
type TMDBService struct { type TMDBService struct {
client *http.Client client *http.Client
apiKey string apiKey string
} }
func NewTMDBService() *TMDBService { func NewTMDBService() *TMDBService {
return &TMDBService{ return &TMDBService{
client: &http.Client{Timeout: 10 * time.Second}, client: &http.Client{Timeout: 10 * time.Second},
apiKey: os.Getenv("TMDB_API_KEY"), apiKey: os.Getenv("TMDB_API_KEY"),
} }
} }
type TMDBMovieResult struct { type TMDBMovieResult struct {
ID int `json:"id"` ID int `json:"id"`
Title string `json:"title"` Title string `json:"title"`
Overview string `json:"overview"` Overview string `json:"overview"`
PosterPath string `json:"poster_path"` PosterPath string `json:"poster_path"`
BackdropPath string `json:"backdrop_path"` BackdropPath string `json:"backdrop_path"`
ReleaseDate string `json:"release_date"` ReleaseDate string `json:"release_date"`
VoteAverage float64 `json:"vote_average"` VoteAverage float64 `json:"vote_average"`
} }
type TMDBSearchResponse struct { type TMDBSearchResponse struct {
Results []TMDBMovieResult `json:"results"` Results []TMDBMovieResult `json:"results"`
} }
type TMDBMovieDetails struct { type TMDBMovieDetails struct {
ID int `json:"id"` ID int `json:"id"`
Title string `json:"title"` Title string `json:"title"`
Overview string `json:"overview"` Overview string `json:"overview"`
Runtime int `json:"runtime"` Runtime int `json:"runtime"`
Budget int64 `json:"budget"` Budget int64 `json:"budget"`
Revenue int64 `json:"revenue"` Revenue int64 `json:"revenue"`
Tagline string `json:"tagline"` Tagline string `json:"tagline"`
VoteAverage float64 `json:"vote_average"` VoteAverage float64 `json:"vote_average"`
PosterPath string `json:"poster_path"` PosterPath string `json:"poster_path"`
BackdropPath string `json:"backdrop_path"` BackdropPath string `json:"backdrop_path"`
Credits struct { Credits struct {
Cast []struct { Cast []struct {
Name string `json:"name"` Name string `json:"name"`
Character string `json:"character"` Character string `json:"character"`
ProfilePath string `json:"profile_path"` ProfilePath string `json:"profile_path"`
} `json:"cast"` } `json:"cast"`
Crew []struct { Crew []struct {
Name string `json:"name"` Name string `json:"name"`
Job string `json:"job"` Job string `json:"job"`
} `json:"crew"` } `json:"crew"`
} `json:"credits"` } `json:"credits"`
} }
func (s *TMDBService) SearchMovie(title string, year int) (*TMDBMovieResult, error) { func (s *TMDBService) SearchMovie(title string, year int) (*TMDBMovieResult, error) {
if s.apiKey == "" { if s.apiKey == "" {
return nil, fmt.Errorf("TMDB_API_KEY not set") return nil, fmt.Errorf("TMDB_API_KEY not set")
} }
params := url.Values{} params := url.Values{}
params.Add("api_key", s.apiKey) params.Add("api_key", s.apiKey)
params.Add("query", title) params.Add("query", title)
params.Add("language", "en-US") params.Add("language", "en-US")
if year > 0 { if year > 0 {
params.Add("year", fmt.Sprintf("%d", year)) params.Add("year", fmt.Sprintf("%d", year))
} }
resp, err := s.client.Get(fmt.Sprintf("%s/search/movie?%s", TMDBBaseURL, params.Encode())) resp, err := s.client.Get(fmt.Sprintf("%s/search/movie?%s", TMDBBaseURL, params.Encode()))
if err != nil { if err != nil {
return nil, err return nil, err
} }
defer resp.Body.Close() defer resp.Body.Close()
if resp.StatusCode != 200 { if resp.StatusCode != 200 {
return nil, fmt.Errorf("TMDB API returned status: %d", resp.StatusCode) return nil, fmt.Errorf("TMDB API returned status: %d", resp.StatusCode)
} }
var searchResp TMDBSearchResponse var searchResp TMDBSearchResponse
if err := json.NewDecoder(resp.Body).Decode(&searchResp); err != nil { if err := json.NewDecoder(resp.Body).Decode(&searchResp); err != nil {
return nil, err return nil, err
} }
if len(searchResp.Results) > 0 { if len(searchResp.Results) > 0 {
return &searchResp.Results[0], nil return &searchResp.Results[0], nil
} }
return nil, nil return nil, nil
} }
func (s *TMDBService) GetMovieDetails(tmdbID int) (*TMDBMovieDetails, error) { func (s *TMDBService) GetMovieDetails(tmdbID int) (*TMDBMovieDetails, error) {
if s.apiKey == "" { if s.apiKey == "" {
return nil, fmt.Errorf("TMDB_API_KEY not set") return nil, fmt.Errorf("TMDB_API_KEY not set")
} }
params := url.Values{} params := url.Values{}
params.Add("api_key", s.apiKey) params.Add("api_key", s.apiKey)
params.Add("append_to_response", "credits") params.Add("append_to_response", "credits")
params.Add("language", "en-US") params.Add("language", "en-US")
resp, err := s.client.Get(fmt.Sprintf("%s/movie/%d?%s", TMDBBaseURL, tmdbID, params.Encode())) resp, err := s.client.Get(fmt.Sprintf("%s/movie/%d?%s", TMDBBaseURL, tmdbID, params.Encode()))
if err != nil { if err != nil {
return nil, err return nil, err
} }
defer resp.Body.Close() defer resp.Body.Close()
if resp.StatusCode != 200 { if resp.StatusCode != 200 {
return nil, fmt.Errorf("TMDB API returned status: %d", resp.StatusCode) return nil, fmt.Errorf("TMDB API returned status: %d", resp.StatusCode)
} }
var details TMDBMovieDetails var details TMDBMovieDetails
if err := json.NewDecoder(resp.Body).Decode(&details); err != nil { if err := json.NewDecoder(resp.Body).Decode(&details); err != nil {
return nil, err return nil, err
} }
return &details, nil return &details, nil
} }
func (s *TMDBService) GetPosterURL(path string, size string) string { func (s *TMDBService) GetPosterURL(path string, size string) string {
if path == "" { if path == "" {
return "" return ""
} }
if size == "" { if size == "" {
size = "w500" size = "w500"
} }
return fmt.Sprintf("%s/%s%s", TMDBImageBaseURL, size, path) return fmt.Sprintf("%s/%s%s", TMDBImageBaseURL, size, path)
} }

View file

@ -1,31 +1,31 @@
# Streamflow Deployment Script # Streamflow Deployment Script
# Automates building and pushing Docker images to registries # Automates building and pushing Docker images to registries
$ErrorActionPreference = "Stop" $ErrorActionPreference = "Stop"
Write-Host "=============================" -ForegroundColor Cyan Write-Host "=============================" -ForegroundColor Cyan
Write-Host " Streamflow Deployer " -ForegroundColor Cyan Write-Host " Streamflow Deployer " -ForegroundColor Cyan
Write-Host "=============================" -ForegroundColor Cyan Write-Host "=============================" -ForegroundColor Cyan
# 1. Build # 1. Build
Write-Host "`n[1/3] Building Docker Image..." -ForegroundColor White Write-Host "`n[1/3] Building Docker Image..." -ForegroundColor White
docker build -t streamflow:latest . docker build -t streamflow:latest .
if ($LASTEXITCODE -ne 0) { Write-Error "Build failed"; exit 1 } if ($LASTEXITCODE -ne 0) { Write-Error "Build failed"; exit 1 }
Write-Host " -> Build successful" -ForegroundColor Green Write-Host " -> Build successful" -ForegroundColor Green
# 2. Push to Docker Hub # 2. Push to Docker Hub
Write-Host "`n[2/3] Pushing to Docker Hub..." -ForegroundColor White Write-Host "`n[2/3] Pushing to Docker Hub..." -ForegroundColor White
docker tag streamflow:latest vndangkhoa/streamflow:latest docker tag streamflow:latest vndangkhoa/streamflow:latest
docker push vndangkhoa/streamflow:latest docker push vndangkhoa/streamflow:latest
if ($LASTEXITCODE -ne 0) { Write-Warning "Docker Hub push failed. Check your login." } if ($LASTEXITCODE -ne 0) { Write-Warning "Docker Hub push failed. Check your login." }
else { Write-Host " -> Pushed to Docker Hub" -ForegroundColor Green } else { Write-Host " -> Pushed to Docker Hub" -ForegroundColor Green }
# 3. Push to Private Registry # 3. Push to Private Registry
Write-Host "`n[3/3] Pushing to Private Registry..." -ForegroundColor White Write-Host "`n[3/3] Pushing to Private Registry..." -ForegroundColor White
docker tag streamflow:latest git.khoavo.myds.me/vndangkhoa/kv-streamflow:latest docker tag streamflow:latest git.khoavo.myds.me/vndangkhoa/kv-streamflow:latest
docker push 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." } if ($LASTEXITCODE -ne 0) { Write-Warning "Private Registry push failed. Check VPN/Login." }
else { Write-Host " -> Pushed to Private Registry" -ForegroundColor Green } else { Write-Host " -> Pushed to Private Registry" -ForegroundColor Green }
Write-Host "`nDeployment Complete!" -ForegroundColor Magenta Write-Host "`nDeployment Complete!" -ForegroundColor Magenta
Start-Sleep -Seconds 5 Start-Sleep -Seconds 5

View file

@ -2,7 +2,7 @@ version: '3.8'
services: services:
streamflow: 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 container_name: streamflow
platform: linux/amd64 platform: linux/amd64
ports: ports:

View file

@ -1,6 +1,6 @@
export default { export default {
plugins: { plugins: {
'@tailwindcss/postcss': {}, '@tailwindcss/postcss': {},
autoprefixer: {}, autoprefixer: {},
}, },
} }

View file

@ -1,284 +1,284 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { Play, Plus, Check } from 'lucide-react'; import { Play, Plus, Check } from 'lucide-react';
import type { Movie } from '../types'; import type { Movie } from '../types';
import { useMyList } from '../hooks/useMyList'; import { useMyList } from '../hooks/useMyList';
interface HeroProps { interface HeroProps {
movies: Movie[]; movies: Movie[];
variant?: 'default' | 'netflix' | 'apple'; variant?: 'default' | 'netflix' | 'apple';
} }
export const Hero = ({ movies, variant = 'default' }: HeroProps) => { export const Hero = ({ movies, variant = 'default' }: HeroProps) => {
const [index, setIndex] = useState(0); const [index, setIndex] = useState(0);
const { addToList, removeFromList, isSaved } = useMyList(); const { addToList, removeFromList, isSaved } = useMyList();
// Auto-rotate carousel // Auto-rotate carousel
useEffect(() => { useEffect(() => {
if (movies.length <= 1) return; if (movies.length <= 1) return;
const interval = setInterval(() => { const interval = setInterval(() => {
setIndex((prev) => (prev + 1) % movies.length); setIndex((prev) => (prev + 1) % movies.length);
}, 8000); }, 8000);
return () => clearInterval(interval); return () => clearInterval(interval);
}, [movies]); }, [movies]);
if (!movies || movies.length === 0) return null; if (!movies || movies.length === 0) return null;
const movie = movies[index]; const movie = movies[index];
const saved = isSaved(movie.id); const saved = isSaved(movie.id);
const toggleList = () => { const toggleList = () => {
if (saved) removeFromList(movie.id); if (saved) removeFromList(movie.id);
else addToList(movie); else addToList(movie);
}; };
// Helper to generate robust image URLs // Helper to generate robust image URLs
const getImageUrl = (url: string | undefined, width: number, blur: number = 0) => { const getImageUrl = (url: string | undefined, width: number, blur: number = 0) => {
if (!url) return ''; if (!url) return '';
// Unified logic: Simple encoding like Card.tsx, relying on wsrv.nl's robust handling // 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`; return `https://wsrv.nl/?url=${encodeURIComponent(url)}&w=${width}&output=webp${blur ? `&blur=${blur}` : ''}&fit=cover`;
}; };
// --- Variant-Specific Styles --- // --- Variant-Specific Styles ---
// 1. Apple Variant (Glassmorphism, Bottom-Aligned) // 1. Apple Variant (Glassmorphism, Bottom-Aligned)
if (variant === 'apple') { if (variant === 'apple') {
return ( return (
<div className="relative h-[85vh] w-full overflow-hidden group"> <div className="relative h-[85vh] w-full overflow-hidden group">
<div className="absolute inset-0 scale-105 transition-transform duration-[10000ms] ease-linear"> <div className="absolute inset-0 scale-105 transition-transform duration-[10000ms] ease-linear">
<img <img
key={movie.id} key={movie.id}
src={getImageUrl(movie.backdrop || movie.thumbnail, 1600)} src={getImageUrl(movie.backdrop || movie.thumbnail, 1600)}
alt={movie.title} alt={movie.title}
className="w-full h-full object-cover animate-fade-in" className="w-full h-full object-cover animate-fade-in"
onError={(e) => { onError={(e) => {
if (movie.thumbnail && e.currentTarget.src !== getImageUrl(movie.thumbnail, 1600)) { if (movie.thumbnail && e.currentTarget.src !== getImageUrl(movie.thumbnail, 1600)) {
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-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 className="absolute inset-0 bg-gradient-to-r from-black/60 via-transparent to-transparent" />
</div> </div>
<div className="absolute bottom-0 left-0 w-full p-8 md:p-16 lg:p-24 pb-20 z-10"> <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="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"> <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> <span className="text-[10px] font-bold tracking-widest uppercase text-white/90">Premiere</span>
</div> </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' }}> <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} {movie.title}
</h1> </h1>
{movie.original_title && ( {movie.original_title && (
<p className="text-xl text-white/70 font-medium animate-slide-up" style={{ animationDelay: '200ms' }}>{movie.original_title}</p> <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' }}> <div className="flex items-center gap-4 pt-4 animate-slide-up" style={{ animationDelay: '300ms' }}>
<a <a
href={`/watch/${movie.slug}`} 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" 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 className="w-4 h-4 fill-current" />
Play Play
</a> </a>
<button <button
onClick={toggleList} 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" 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 ? <Check className="w-4 h-4" /> : <Plus className="w-4 h-4" />}
{saved ? 'In Up Next' : 'Add to Up Next'} {saved ? 'In Up Next' : 'Add to Up Next'}
</button> </button>
</div> </div>
</div> </div>
</div> </div>
{/* Carousel Dots (Bottom Center) */} {/* Carousel Dots (Bottom Center) */}
<div className="absolute bottom-8 left-1/2 -translate-x-1/2 flex gap-3 z-20"> <div className="absolute bottom-8 left-1/2 -translate-x-1/2 flex gap-3 z-20">
{movies.map((_, i) => ( {movies.map((_, i) => (
<button <button
key={i} key={i}
onClick={() => setIndex(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'}`} className={`w-2 h-2 rounded-full transition-all ${i === index ? 'bg-white w-4' : 'bg-white/30 hover:bg-white/50'}`}
/> />
))} ))}
</div> </div>
</div> </div>
); );
} }
// 2. Netflix Variant (Left-Aligned, Sidebar-Aware if needed, Top-10 Badge) // 2. Netflix Variant (Left-Aligned, Sidebar-Aware if needed, Top-10 Badge)
if (variant === 'netflix') { if (variant === 'netflix') {
return ( return (
<div className="relative h-[85vh] w-full overflow-hidden group"> <div className="relative h-[85vh] w-full overflow-hidden group">
<div className="absolute inset-0 transition-opacity duration-1000 ease-in-out"> <div className="absolute inset-0 transition-opacity duration-1000 ease-in-out">
<img <img
key={movie.id} key={movie.id}
src={getImageUrl(movie.backdrop || movie.thumbnail, 1600)} src={getImageUrl(movie.backdrop || movie.thumbnail, 1600)}
alt={movie.title} alt={movie.title}
className="w-full h-full object-cover mask-image-gradient animate-fade-in" className="w-full h-full object-cover mask-image-gradient animate-fade-in"
onError={(e) => { onError={(e) => {
if (movie.thumbnail && e.currentTarget.src !== getImageUrl(movie.thumbnail, 1600)) { if (movie.thumbnail && e.currentTarget.src !== getImageUrl(movie.thumbnail, 1600)) {
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-r from-[#141414] via-[#141414]/40 to-transparent" />
<div className="absolute inset-0 bg-gradient-to-t from-[#141414] via-transparent to-transparent" /> <div className="absolute inset-0 bg-gradient-to-t from-[#141414] via-transparent to-transparent" />
</div> </div>
<div className="absolute inset-0 flex items-center px-4 md:px-12 z-10"> <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="max-w-2xl space-y-6">
<div className="flex items-center gap-2 mb-4 animate-slide-up"> <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="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> <span className="text-gray-300 text-sm font-medium tracking-widest uppercase">#{index + 1} in Movies</span>
</div> </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' }}> <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} {movie.title}
</h1> </h1>
{movie.original_title && ( {movie.original_title && (
<p className="text-xl text-gray-300 italic animate-slide-up" style={{ animationDelay: '200ms' }}>{movie.original_title}</p> <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' }}> <div className="flex items-center gap-3 pt-4 animate-slide-up" style={{ animationDelay: '300ms' }}>
<a <a
href={`/watch/${movie.slug}`} 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" 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 className="w-6 h-6 fill-current" />
Play Play
</a> </a>
<button <button
onClick={toggleList} 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" 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 ? <Check className="w-6 h-6" /> : <Plus className="w-6 h-6" />}
{saved ? 'My List' : 'My List'} {saved ? 'My List' : 'My List'}
</button> </button>
</div> </div>
</div> </div>
</div> </div>
{/* Indicators (Vertical Right Side - Classic Netflix) */} {/* Indicators (Vertical Right Side - Classic Netflix) */}
<div className="absolute right-12 bottom-1/3 flex flex-col gap-2 z-20 hidden md:flex"> <div className="absolute right-12 bottom-1/3 flex flex-col gap-2 z-20 hidden md:flex">
{movies.map((_, i) => ( {movies.map((_, i) => (
<button <button
key={i} key={i}
onClick={() => setIndex(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'}`} className={`w-2 h-2 rounded-full transition-all ${i === index ? 'bg-white scale-125' : 'bg-gray-600 hover:bg-gray-400'}`}
/> />
))} ))}
</div> </div>
</div> </div>
); );
} }
// 3. Default (StreamFlow) Variant - Split Poster Design (Solves Quality & Sizing) // 3. Default (StreamFlow) Variant - Split Poster Design (Solves Quality & Sizing)
return ( return (
<div className="relative w-full h-[60vh] md:h-[70vh] lg:h-[75vh] min-h-[500px] overflow-hidden group"> <div className="relative w-full h-[60vh] md:h-[70vh] lg:h-[75vh] min-h-[500px] overflow-hidden group">
{/* 1. Ambient Background (Blurred) */} {/* 1. Ambient Background (Blurred) */}
<div className="absolute inset-0 bg-[#0a0a0a]"> <div className="absolute inset-0 bg-[#0a0a0a]">
<img <img
key={`bg-${movie.id}`} key={`bg-${movie.id}`}
// Use thumbnail as safe default since we blur it anyway // Use thumbnail as safe default since we blur it anyway
src={getImageUrl(movie.thumbnail || movie.backdrop, 1000)} src={getImageUrl(movie.thumbnail || movie.backdrop, 1000)}
alt="Background" alt="Background"
referrerPolicy="no-referrer" referrerPolicy="no-referrer"
onError={(e) => { onError={(e) => {
if (movie.thumbnail && e.currentTarget.src !== getImageUrl(movie.thumbnail, 1000)) { if (movie.thumbnail && e.currentTarget.src !== getImageUrl(movie.thumbnail, 1000)) {
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 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-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 className="absolute inset-0 bg-gradient-to-r from-[#141414] via-[#141414]/80 to-transparent" />
</div> </div>
{/* 2. Main Content Layout */} {/* 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="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"> <div className="flex w-full items-center gap-8 lg:gap-16">
{/* Left Column: Text Info */} {/* Left Column: Text Info */}
<div className="w-full md:w-3/5 lg:w-1/2 space-y-6 pt-10 md:pt-0"> <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 */} {/* Wrapper for text to ensure readability */}
<div className="relative z-10"> <div className="relative z-10">
<div className="flex items-center gap-3 mb-4 animate-slide-up"> <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"> <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> <span>TOP 10</span>
</div> </div>
<span className="text-gray-300 text-lg font-bold tracking-wide">#{index + 1} in Movies Today</span> <span className="text-gray-300 text-lg font-bold tracking-wide">#{index + 1} in Movies Today</span>
</div> </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' }}> <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} {movie.title}
</h1> </h1>
<div className="mt-4 flex items-center gap-4 text-gray-300 text-sm md:text-base animate-slide-up" style={{ animationDelay: '200ms' }}> <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="text-green-400 font-bold">98% Match</span>
<span className="w-1 h-1 bg-gray-600 rounded-full" /> <span className="w-1 h-1 bg-gray-600 rounded-full" />
<span>{movie.year || '2024'}</span> <span>{movie.year || '2024'}</span>
<span className="w-1 h-1 bg-gray-600 rounded-full" /> <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> <span className="border border-gray-600 px-1.5 py-0.5 rounded text-xs">HD</span>
{movie.original_title && ( {movie.original_title && (
<> <>
<span className="w-1 h-1 bg-gray-600 rounded-full" /> <span className="w-1 h-1 bg-gray-600 rounded-full" />
<span className="italic opacity-80 truncate max-w-[200px]">{movie.original_title}</span> <span className="italic opacity-80 truncate max-w-[200px]">{movie.original_title}</span>
</> </>
)} )}
</div> </div>
<div className="mt-8 flex items-center gap-4 animate-slide-up" style={{ animationDelay: '300ms' }}> <div className="mt-8 flex items-center gap-4 animate-slide-up" style={{ animationDelay: '300ms' }}>
<a <a
href={`/watch/${movie.slug}`} 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" 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" /> <Play className="w-5 h-5 fill-current" />
Watch Now Watch Now
</a> </a>
<button <button
onClick={toggleList} 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" 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" />} {saved ? <Check className="w-5 h-5" /> : <Plus className="w-5 h-5" />}
My List My List
</button> </button>
</div> </div>
</div> </div>
</div> </div>
{/* Right Column: Sharp Poster (Desktop Only) */} {/* 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="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"> <div className="relative group/poster">
{/* Glow Effect */} {/* 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" /> <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 <img
key={`poster-${movie.id}`} key={`poster-${movie.id}`}
src={getImageUrl(movie.thumbnail || movie.backdrop, 600)} src={getImageUrl(movie.thumbnail || movie.backdrop, 600)}
alt={movie.title} 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" 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>
</div> </div>
</div> </div>
{/* Indicators */} {/* Indicators */}
<div className="absolute bottom-6 right-8 lg:bottom-10 lg:right-12 flex gap-2 z-20"> <div className="absolute bottom-6 right-8 lg:bottom-10 lg:right-12 flex gap-2 z-20">
{movies.map((_, i) => ( {movies.map((_, i) => (
<button <button
key={i} key={i}
onClick={() => setIndex(i)} onClick={() => setIndex(i)}
className={`transition-all duration-300 rounded-full ${i === index className={`transition-all duration-300 rounded-full ${i === index
? 'w-6 h-1.5 bg-cyan-500 shadow-lg shadow-cyan-500/50' ? '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' : 'w-1.5 h-1.5 bg-gray-600 hover:bg-white/50'
}`} }`}
/> />
))} ))}
</div> </div>
</div> </div>
); );
}; };

View file

@ -1,217 +1,217 @@
import { useState, useEffect, useRef, useCallback } from 'react'; import { useState, useEffect, useRef, useCallback } from 'react';
import { useSearchParams } from 'react-router-dom'; import { useSearchParams } from 'react-router-dom';
import type { Movie } from '../types'; import type { Movie } from '../types';
import MovieRow from './MovieRow'; import MovieRow from './MovieRow';
import { MovieCard } from './MovieCard'; import { MovieCard } from './MovieCard';
import { CATEGORIES } from '../constants'; import { CATEGORIES } from '../constants';
import { useMyList } from '../hooks/useMyList'; import { useMyList } from '../hooks/useMyList';
import { useSmartRecommendations } from '../hooks/useSmartRecommendations'; import { useSmartRecommendations } from '../hooks/useSmartRecommendations';
interface HomeContentProps { interface HomeContentProps {
topPadding?: string; topPadding?: string;
} }
export const HomeContent = ({ topPadding = "pt-24" }: HomeContentProps) => { export const HomeContent = ({ topPadding = "pt-24" }: HomeContentProps) => {
const [movies, setMovies] = useState<Movie[]>([]); const [movies, setMovies] = useState<Movie[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [fetchingMore, setFetchingMore] = useState(false); const [fetchingMore, setFetchingMore] = useState(false);
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const [hasMore, setHasMore] = useState(true); const [hasMore, setHasMore] = useState(true);
const { watchHistory, savedMovies } = useMyList(); // Access History and MyList const { watchHistory, savedMovies } = useMyList(); // Access History and MyList
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
const query = searchParams.get('q'); const query = searchParams.get('q');
const category = searchParams.get('category'); const category = searchParams.get('category');
// Filtered view if search or specific category is selected // Filtered view if search or specific category is selected
const isFiltered = !!(query || (category && category !== 'home')); const isFiltered = !!(query || (category && category !== 'home'));
// On main home page, we show rows AND infinite grid at bottom // On main home page, we show rows AND infinite grid at bottom
// If filtered, we ONLY show the grid // If filtered, we ONLY show the grid
const showRows = !isFiltered; const showRows = !isFiltered;
const observer = useRef<IntersectionObserver | null>(null); const observer = useRef<IntersectionObserver | null>(null);
// ... (rest of useEffects same as before) ... // ... (rest of useEffects same as before) ...
// Reset grid when query/category changes // Reset grid when query/category changes
useEffect(() => { useEffect(() => {
setMovies([]); setMovies([]);
setPage(1); setPage(1);
setHasMore(true); setHasMore(true);
setLoading(true); setLoading(true);
}, [query, category]); }, [query, category]);
// Fetch movies for the Infinite Grid // Fetch movies for the Infinite Grid
useEffect(() => { useEffect(() => {
const fetchMovies = async () => { const fetchMovies = async () => {
if (page === 1) setLoading(true); if (page === 1) setLoading(true);
else setFetchingMore(true); else setFetchingMore(true);
try { try {
let endpoint = '/api/videos/home'; let endpoint = '/api/videos/home';
if (query) { if (query) {
endpoint = `/api/videos/search?q=${query}&page=${page}`; endpoint = `/api/videos/search?q=${query}&page=${page}`;
} else if (category && category !== 'home') { } else if (category && category !== 'home') {
endpoint = `/api/videos/home?category=${category}&page=${page}`; endpoint = `/api/videos/home?category=${category}&page=${page}`;
} else { } else {
endpoint = `/api/videos/home?page=${page}`; endpoint = `/api/videos/home?page=${page}`;
} }
const res = await fetch(endpoint); const res = await fetch(endpoint);
if (!res.ok) { if (!res.ok) {
throw new Error(`HTTP error! status: ${res.status}`); throw new Error(`HTTP error! status: ${res.status}`);
} }
const data = await res.json(); const data = await res.json();
if (!data || data.length === 0) { if (!data || data.length === 0) {
setHasMore(false); setHasMore(false);
} else { } else {
setMovies(prev => { setMovies(prev => {
if (page === 1) return data; if (page === 1) return data;
// Deduplicate arrays when appending to prevent React StrictMode or fast-scroll double fetches // Deduplicate arrays when appending to prevent React StrictMode or fast-scroll double fetches
const existingIds = new Set(prev.map(m => m.id)); const existingIds = new Set(prev.map(m => m.id));
const newUniqueMovies = data.filter((m: Movie) => !existingIds.has(m.id)); const newUniqueMovies = data.filter((m: Movie) => !existingIds.has(m.id));
return [...prev, ...newUniqueMovies]; return [...prev, ...newUniqueMovies];
}); });
} }
} catch { } catch {
console.error("Failed to fetch movies"); console.error("Failed to fetch movies");
} finally { } finally {
setLoading(false); setLoading(false);
setFetchingMore(false); setFetchingMore(false);
} }
}; };
fetchMovies(); fetchMovies();
}, [page, query, category]); }, [page, query, category]);
// Sentinel observer for infinite scroll // Sentinel observer for infinite scroll
const lastElementRef = useCallback((node: HTMLDivElement) => { const lastElementRef = useCallback((node: HTMLDivElement) => {
if (loading || fetchingMore) return; if (loading || fetchingMore) return;
if (observer.current) observer.current.disconnect(); if (observer.current) observer.current.disconnect();
observer.current = new IntersectionObserver(entries => { observer.current = new IntersectionObserver(entries => {
if (entries[0].isIntersecting && hasMore) { if (entries[0].isIntersecting && hasMore) {
setPage(prevPage => prevPage + 1); setPage(prevPage => prevPage + 1);
} }
}); });
if (node) observer.current.observe(node); if (node) observer.current.observe(node);
}, [loading, fetchingMore, hasMore]); }, [loading, fetchingMore, hasMore]);
const getTitle = () => { const getTitle = () => {
if (query) return `Kết quả cho "${query}"`; if (query) return `Kết quả cho "${query}"`;
if (category === 'phim-le') return 'Phim Lẻ'; if (category === 'phim-le') return 'Phim Lẻ';
if (category === 'phim-bo') return 'Phim Bộ'; if (category === 'phim-bo') return 'Phim Bộ';
if (category === 'hoat-hinh') return 'Hoạt Hình'; if (category === 'hoat-hinh') return 'Hoạt Hình';
if (category === 'tv-shows') return 'TV Shows'; if (category === 'tv-shows') return 'TV Shows';
if (category === 'phim-sap-chieu') return 'Phim Sắp Chiếu'; if (category === 'phim-sap-chieu') return 'Phim Sắp Chiếu';
if (category === 'phim-hay') return 'Phim Hay'; if (category === 'phim-hay') return 'Phim Hay';
if (category) return 'Danh Sách Phim'; if (category) return 'Danh Sách Phim';
return 'Tất Cả Phim'; return 'Tất Cả Phim';
}; };
// Calculate Smart Suggestions based on last watched item // Calculate Smart Suggestions based on last watched item
const lastWatched = watchHistory.length > 0 ? watchHistory[0] : null; const lastWatched = watchHistory.length > 0 ? watchHistory[0] : null;
// Get Category-based recommendations // Get Category-based recommendations
const recommendations = useSmartRecommendations(watchHistory); const recommendations = useSmartRecommendations(watchHistory);
return ( return (
<div className={`w-full px-4 sm:px-6 lg:px-12 pb-12 ${topPadding}`}> <div className={`w-full px-4 sm:px-6 lg:px-12 pb-12 ${topPadding}`}>
{showRows && ( {showRows && (
<div className="space-y-4 relative z-10 mb-12"> <div className="space-y-4 relative z-10 mb-12">
{/* Continue Watching Row */} {/* Continue Watching Row */}
{watchHistory.length > 0 && ( {watchHistory.length > 0 && (
<MovieRow title="Tiếp tục xem" movies={watchHistory} /> <MovieRow title="Tiếp tục xem" movies={watchHistory} />
)} )}
{/* My List Row */} {/* My List Row */}
{savedMovies.length > 0 && ( {savedMovies.length > 0 && (
<MovieRow title="Danh sách của tôi" movies={savedMovies} /> <MovieRow title="Danh sách của tôi" movies={savedMovies} />
)} )}
{/* Smart Category Recommendations */} {/* Smart Category Recommendations */}
{recommendations.map(rec => ( {recommendations.map(rec => (
<MovieRow <MovieRow
key={rec.id} key={rec.id}
title={rec.title} title={rec.title}
category={rec.category} category={rec.category}
/> />
))} ))}
{/* Smart Suggestions using SEARCH API */} {/* Smart Suggestions using SEARCH API */}
{lastWatched && ( {lastWatched && (
<> <>
{lastWatched.director && ( {lastWatched.director && (
<MovieRow <MovieRow
title={`Đạo diễn ${lastWatched.director.replace(/,$/, '').trim()}`} title={`Đạo diễn ${lastWatched.director.replace(/,$/, '').trim()}`}
searchQuery={lastWatched.director.replace(/,$/, '').trim()} searchQuery={lastWatched.director.replace(/,$/, '').trim()}
key={`dir-${lastWatched.id}`} key={`dir-${lastWatched.id}`}
/> />
)} )}
{lastWatched.cast && lastWatched.cast.length > 0 && ( {lastWatched.cast && lastWatched.cast.length > 0 && (
<MovieRow <MovieRow
title={`Diễn viên ${lastWatched.cast[0].replace(/,$/, '').trim()}`} title={`Diễn viên ${lastWatched.cast[0].replace(/,$/, '').trim()}`}
searchQuery={lastWatched.cast[0].replace(/,$/, '').trim()} searchQuery={lastWatched.cast[0].replace(/,$/, '').trim()}
key={`act-${lastWatched.id}`} key={`act-${lastWatched.id}`}
/> />
)} )}
</> </>
)} )}
{/* Phim Mới Horizontal Carousel */} {/* Phim Mới Horizontal Carousel */}
<MovieRow title="Phim Mới Cập Nhật" category="home" /> <MovieRow title="Phim Mới Cập Nhật" category="home" />
{/* Top 10 Grids for each Category */} {/* Top 10 Grids for each Category */}
{CATEGORIES.filter(c => c.id !== 'my-list').map((cat) => ( {CATEGORIES.filter(c => c.id !== 'my-list').map((cat) => (
<MovieRow <MovieRow
key={cat.id} key={cat.id}
title={`Top 10 ${cat.name}`} title={`Top 10 ${cat.name}`}
category={cat.id} category={cat.id}
limit={10} limit={10}
// layout="row" is default // layout="row" is default
/> />
))} ))}
{/* Other Curated Sections */} {/* Other Curated Sections */}
<MovieRow title="Hành Động & Phiêu Lưu" category="hanh-dong" /> <MovieRow title="Hành Động & Phiêu Lưu" category="hanh-dong" />
<MovieRow title="Tâm Lý & Tình Cảm" category="tinh-cam" /> <MovieRow title="Tâm Lý & Tình Cảm" category="tinh-cam" />
</div> </div>
)} )}
{/* Infinite Scroll Grid */} {/* Infinite Scroll Grid */}
<div> <div>
<h2 className="text-2xl font-bold mb-8 flex items-center gap-3 text-white"> <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> <span className="w-1.5 h-8 bg-cyan-500 rounded-full"></span>
{getTitle()} {getTitle()}
</h2> </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"> <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) => ( {movies.map((movie, index) => (
<MovieCard key={`${movie.id}-${index}`} movie={movie} /> <MovieCard key={`${movie.id}-${index}`} movie={movie} />
))} ))}
</div> </div>
{/* Sentinel element for infinite scroll */} {/* Sentinel element for infinite scroll */}
<div ref={lastElementRef} className="h-10 w-full" /> <div ref={lastElementRef} className="h-10 w-full" />
{loading && ( {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"> <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) => ( {[...Array(12)].map((_, i) => (
<div key={i} className="aspect-[2/3] bg-white/5 rounded-lg animate-pulse" /> <div key={i} className="aspect-[2/3] bg-white/5 rounded-lg animate-pulse" />
))} ))}
</div> </div>
)} )}
{!loading && movies.length === 0 && ( {!loading && movies.length === 0 && (
<div className="text-center py-12 text-gray-400"> <div className="text-center py-12 text-gray-400">
Không tìm thấy phim nào. Không tìm thấy phim nào.
</div> </div>
)} )}
</div> </div>
</div> </div>
); );
}; };

View file

@ -1,89 +1,89 @@
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { Play } from 'lucide-react'; import { Play } from 'lucide-react';
import type { Movie } from '../types'; import type { Movie } from '../types';
interface MovieCardProps { interface MovieCardProps {
movie: Movie; movie: Movie;
className?: string; className?: string;
isDragging?: boolean; isDragging?: boolean;
} }
export const MovieCard = ({ movie, className = '', isDragging = false }: MovieCardProps) => { export const MovieCard = ({ movie, className = '', isDragging = false }: MovieCardProps) => {
const getImageUrl = (url: string, width: number) => { const getImageUrl = (url: string, width: number) => {
if (!url) return ''; if (!url) return '';
const cleanUrl = url.replace('img.ophim1.com', 'ssl:img.ophim1.com'); 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 `https://wsrv.nl/?url=${encodeURIComponent(cleanUrl.replace(/^https?:\/\//, ''))}&w=${width}&output=webp`;
}; };
return ( return (
<div className={`group/card relative flex flex-col h-full ${className}`}> <div className={`group/card relative flex flex-col h-full ${className}`}>
{/* Poster Image Container */} {/* Poster Image Container */}
<Link <Link
to={`/watch/${movie.slug}`} 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' : '' 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} draggable={false}
> >
<img <img
src={getImageUrl(movie.thumbnail, 400)} src={getImageUrl(movie.thumbnail, 400)}
alt={movie.title} alt={movie.title}
className="w-full h-full object-cover transition-transform duration-700 group-hover/card:scale-110" className="w-full h-full object-cover transition-transform duration-700 group-hover/card:scale-110"
loading="lazy" loading="lazy"
draggable={false} draggable={false}
/> />
{/* Hover Overlay */} {/* 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="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"> <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" /> <Play className="w-6 h-6 text-white fill-current" />
</div> </div>
</div> </div>
{/* Top-Left Tag (Provider) */} {/* Top-Left Tag (Provider) */}
{movie.provider && ( {movie.provider && (
<div className="absolute top-2 left-2"> <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"> <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} {movie.provider}
</div> </div>
</div> </div>
)} )}
{/* Top-Right Tags (Quality & Lang) */} {/* Top-Right Tags (Quality & Lang) */}
<div className="absolute top-2 right-2 flex flex-col gap-1.5 items-end"> <div className="absolute top-2 right-2 flex flex-col gap-1.5 items-end">
{movie.quality && ( {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"> <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} {movie.quality}
</div> </div>
)} )}
{movie.lang && ( {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"> <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} {movie.lang}
</div> </div>
)} )}
</div> </div>
{/* Bottom Status (Time / Episode Info) */} {/* Bottom Status (Time / Episode Info) */}
{movie.time && ( {movie.time && (
<div className="absolute bottom-2 left-2 right-2 flex items-center gap-2"> <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"> <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> <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} {movie.time}
</div> </div>
</div> </div>
)} )}
</Link> </Link>
{/* Info Section */} {/* Info Section */}
<div className="mt-3 px-1"> <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"> <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} {movie.title}
</h3> </h3>
{movie.year && ( {movie.year && (
<p className="text-[11px] text-gray-500 mt-1 font-medium tracking-wide translate-y-0 opacity-100 transition-all"> <p className="text-[11px] text-gray-500 mt-1 font-medium tracking-wide translate-y-0 opacity-100 transition-all">
{movie.year} 98% Match {movie.year} 98% Match
</p> </p>
)} )}
</div> </div>
</div> </div>
); );
}; };

View file

@ -1,201 +1,201 @@
import { useEffect, useState, useRef } from 'react'; import { useEffect, useState, useRef } from 'react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { ChevronLeft, ChevronRight } from 'lucide-react'; import { ChevronLeft, ChevronRight } from 'lucide-react';
import type { Movie } from '../types'; import type { Movie } from '../types';
import { MovieCard } from './MovieCard'; import { MovieCard } from './MovieCard';
interface MovieRowProps { interface MovieRowProps {
title: string; title: string;
category?: string; category?: string;
searchQuery?: string; searchQuery?: string;
limit?: number; limit?: number;
layout?: 'row' | 'grid'; layout?: 'row' | 'grid';
movies?: Movie[]; movies?: Movie[];
} }
const MovieRow = ({ title, category, searchQuery, limit, layout = 'row', movies: manualMovies }: MovieRowProps) => { const MovieRow = ({ title, category, searchQuery, limit, layout = 'row', movies: manualMovies }: MovieRowProps) => {
const [movies, setMovies] = useState<Movie[]>([]); const [movies, setMovies] = useState<Movie[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const rowRef = useRef<HTMLDivElement>(null); const rowRef = useRef<HTMLDivElement>(null);
// Drag to scroll logic state // Drag to scroll logic state
const [isDragging, setIsDragging] = useState(false); const [isDragging, setIsDragging] = useState(false);
// Drag to scroll logic state refs // Drag to scroll logic state refs
const isDown = useRef(false); const isDown = useRef(false);
const startX = useRef(0); const startX = useRef(0);
const scrollLeft = useRef(0); const scrollLeft = useRef(0);
useEffect(() => { useEffect(() => {
const fetchMovies = async () => { const fetchMovies = async () => {
// If manual movies are provided (e.g. History, My List), use them directly // If manual movies are provided (e.g. History, My List), use them directly
if (manualMovies) { if (manualMovies) {
let result = manualMovies; let result = manualMovies;
if (limit && result.length > 0) { if (limit && result.length > 0) {
result = result.slice(0, limit); result = result.slice(0, limit);
} }
setMovies(result); setMovies(result);
setLoading(false); setLoading(false);
return; return;
} }
try { try {
let endpoint = ''; let endpoint = '';
if (searchQuery) { // ... unchanged fetch logic if (searchQuery) { // ... unchanged fetch logic
endpoint = `/api/videos/search?q=${encodeURIComponent(searchQuery)}`; endpoint = `/api/videos/search?q=${encodeURIComponent(searchQuery)}`;
} else if (category && category !== 'home') { } else if (category && category !== 'home') {
endpoint = `/api/videos/home?category=${category}`; endpoint = `/api/videos/home?category=${category}`;
} else { } else {
endpoint = '/api/videos/home'; endpoint = '/api/videos/home';
} }
const res = await fetch(endpoint); const res = await fetch(endpoint);
const data = await res.json(); const data = await res.json();
let result = data || []; let result = data || [];
// Search API usually returns unfiltered list, so we might need to be careful. // Search API usually returns unfiltered list, so we might need to be careful.
// But generally it returns an array of movies. // But generally it returns an array of movies.
if (limit && result.length > 0) { if (limit && result.length > 0) {
result = result.slice(0, limit); result = result.slice(0, limit);
} }
setMovies(result); setMovies(result);
} catch { } catch {
console.error(`Failed to fetch movies for row ${title}`); console.error(`Failed to fetch movies for row ${title}`);
} finally { } finally {
setLoading(false); setLoading(false);
} }
}; };
fetchMovies(); fetchMovies();
}, [category, searchQuery, limit, manualMovies]); }, [category, searchQuery, limit, manualMovies]);
const scroll = (direction: 'left' | 'right') => { const scroll = (direction: 'left' | 'right') => {
if (rowRef.current) { if (rowRef.current) {
const { current } = rowRef; const { current } = rowRef;
const scrollAmount = direction === 'left' ? -current.clientWidth * 0.8 : current.clientWidth * 0.8; const scrollAmount = direction === 'left' ? -current.clientWidth * 0.8 : current.clientWidth * 0.8;
current.scrollBy({ left: scrollAmount, behavior: 'smooth' }); current.scrollBy({ left: scrollAmount, behavior: 'smooth' });
} }
}; };
if (loading) return ( if (loading) return (
<div className="mb-8 space-y-4"> <div className="mb-8 space-y-4">
<div className="h-6 w-48 bg-white/5 rounded animate-pulse" /> <div className="h-6 w-48 bg-white/5 rounded animate-pulse" />
{layout === 'row' ? ( {layout === 'row' ? (
<div className="flex gap-4 overflow-hidden"> <div className="flex gap-4 overflow-hidden">
{[...Array(6)].map((_, i) => ( {[...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 key={i} className="min-w-[160px] md:min-w-[200px] aspect-[2/3] bg-white/5 rounded-xl animate-pulse" />
))} ))}
</div> </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"> <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) => ( {[...Array(12)].map((_, i) => (
<div key={i} className="aspect-[2/3] bg-white/5 rounded-lg animate-pulse" /> <div key={i} className="aspect-[2/3] bg-white/5 rounded-lg animate-pulse" />
))} ))}
</div> </div>
)} )}
</div> </div>
); );
// Drag to scroll logic handlers // Drag to scroll logic handlers
// Drag to scroll logic handlers // Drag to scroll logic handlers
const handlePointerDown = (e: React.PointerEvent) => { const handlePointerDown = (e: React.PointerEvent) => {
// Only enable custom drag for mouse. Touch uses native browser scroll. // Only enable custom drag for mouse. Touch uses native browser scroll.
if (e.pointerType !== 'mouse' || !rowRef.current) return; if (e.pointerType !== 'mouse' || !rowRef.current) return;
isDown.current = true; isDown.current = true;
startX.current = e.pageX - rowRef.current.offsetLeft; startX.current = e.pageX - rowRef.current.offsetLeft;
scrollLeft.current = rowRef.current.scrollLeft; scrollLeft.current = rowRef.current.scrollLeft;
}; };
const handlePointerUp = (e: React.PointerEvent) => { const handlePointerUp = (e: React.PointerEvent) => {
if (!isDown.current) return; if (!isDown.current) return;
isDown.current = false; isDown.current = false;
if (isDragging) { if (isDragging) {
setIsDragging(false); setIsDragging(false);
e.currentTarget.releasePointerCapture(e.pointerId); e.currentTarget.releasePointerCapture(e.pointerId);
} }
}; };
const handlePointerMove = (e: React.PointerEvent) => { const handlePointerMove = (e: React.PointerEvent) => {
if (!isDown.current || !rowRef.current) return; if (!isDown.current || !rowRef.current) return;
e.preventDefault(); e.preventDefault();
const x = e.pageX - rowRef.current.offsetLeft; const x = e.pageX - rowRef.current.offsetLeft;
const walk = (x - startX.current) * 2; // Scroll-fast const walk = (x - startX.current) * 2; // Scroll-fast
// Only trigger dragging state if moved significantly to prevent accidental clicks being blocked // Only trigger dragging state if moved significantly to prevent accidental clicks being blocked
if (Math.abs(x - startX.current) > 5) { if (Math.abs(x - startX.current) > 5) {
if (!isDragging) { if (!isDragging) {
setIsDragging(true); setIsDragging(true);
e.currentTarget.setPointerCapture(e.pointerId); e.currentTarget.setPointerCapture(e.pointerId);
} }
} }
if (isDragging) { if (isDragging) {
rowRef.current.scrollLeft = scrollLeft.current - walk; rowRef.current.scrollLeft = scrollLeft.current - walk;
} }
}; };
if (movies.length === 0) return null; if (movies.length === 0) return null;
return ( return (
<div className="mb-10 group/row relative"> <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"> <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> <span className="w-1.5 h-6 bg-cyan-500 rounded-full"></span>
{title} {title}
<Link to={`/?category=${category}`} className="text-[10px] font-normal text-gray-500 hover:text-cyan-400 ml-2 transition-colors uppercase tracking-wider"> <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ả Xem tất cả
</Link> </Link>
</h2> </h2>
{layout === 'row' ? ( {layout === 'row' ? (
<div className="relative group"> <div className="relative group">
<button <button
onClick={() => scroll('left')} 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" 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} /> <ChevronLeft size={40} className="text-white drop-shadow-lg" strokeWidth={1} />
</button> </button>
<div <div
ref={rowRef} 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'}`} 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' }} style={{ scrollbarWidth: 'none', msOverflowStyle: 'none' }}
onPointerDown={handlePointerDown} onPointerDown={handlePointerDown}
onPointerUp={handlePointerUp} onPointerUp={handlePointerUp}
onPointerMove={handlePointerMove} onPointerMove={handlePointerMove}
> >
{movies.map((movie) => ( {movies.map((movie) => (
<div key={movie.id} className="w-[110px] sm:w-[150px] md:w-[180px] lg:w-[200px] flex-shrink-0 snap-start"> <div key={movie.id} className="w-[110px] sm:w-[150px] md:w-[180px] lg:w-[200px] flex-shrink-0 snap-start">
<MovieCard <MovieCard
movie={movie} movie={movie}
isDragging={isDragging} isDragging={isDragging}
/> />
</div> </div>
))} ))}
</div> </div>
<button <button
onClick={() => scroll('right')} 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" 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} /> <ChevronRight size={40} className="text-white drop-shadow-lg" strokeWidth={1} />
</button> </button>
</div> </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"> <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) => ( {movies.map((movie) => (
<MovieCard key={movie.id} movie={movie} /> <MovieCard key={movie.id} movie={movie} />
))} ))}
</div> </div>
)} )}
</div > </div >
); );
}; };
export default MovieRow; export default MovieRow;

View file

@ -1,157 +1,157 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { Link, useNavigate, useLocation } from 'react-router-dom'; import { Link, useNavigate, useLocation } from 'react-router-dom';
import { Search, Film, Menu, X } from 'lucide-react'; import { Search, Film, Menu, X } from 'lucide-react';
import { NAV_ITEMS } from '../constants'; // Unified Categories import { NAV_ITEMS } from '../constants'; // Unified Categories
const Navbar = () => { const Navbar = () => {
const [isMenuOpen, setIsMenuOpen] = useState(false); const [isMenuOpen, setIsMenuOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState('');
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation(); const location = useLocation();
// Helper to check active state // Helper to check active state
const isActive = (path: string) => { const isActive = (path: string) => {
if (path === '/') return location.pathname === '/' && !location.search; if (path === '/') return location.pathname === '/' && !location.search;
return location.pathname + location.search === path; return location.pathname + location.search === path;
}; };
const handleSearch = (e: React.FormEvent) => { const handleSearch = (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
if (searchQuery.trim()) { if (searchQuery.trim()) {
navigate(`/?q=${encodeURIComponent(searchQuery)}`); navigate(`/?q=${encodeURIComponent(searchQuery)}`);
setIsMenuOpen(false); setIsMenuOpen(false);
} }
}; };
return ( return (
<nav className="fixed top-0 w-full z-50 bg-[#141414]/95 backdrop-blur-md border-b border-white/5 font-sans"> <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="w-full px-4 sm:px-6 lg:px-12">
<div className="flex items-center justify-between h-16"> <div className="flex items-center justify-between h-16">
<div className="flex items-center gap-8"> <div className="flex items-center gap-8">
<Link to="/" className="flex items-center gap-2 group"> <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"> <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" /> <Film className="w-5 h-5 text-white" />
</div> </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"> <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 kv-netflix
</span> </span>
</Link> </Link>
<div className="hidden lg:flex items-center gap-6"> <div className="hidden lg:flex items-center gap-6">
{/* Unified Links */} {/* Unified Links */}
{NAV_ITEMS.map((item) => ( {NAV_ITEMS.map((item) => (
<Link <Link
key={item.name} key={item.name}
to={item.path} to={item.path}
className={`text-sm font-medium transition-colors ${isActive(item.path) className={`text-sm font-medium transition-colors ${isActive(item.path)
? 'text-white' ? 'text-white'
: 'text-gray-300 hover:text-cyan-400' : 'text-gray-300 hover:text-cyan-400'
}`} }`}
> >
{item.name} {item.name}
</Link> </Link>
))} ))}
</div> </div>
</div> </div>
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<div className="hidden md:block flex-1 max-w-xs mx-8"> <div className="hidden md:block flex-1 max-w-xs mx-8">
<form onSubmit={handleSearch} className="relative group"> <form onSubmit={handleSearch} className="relative group">
<input <input
type="text" type="text"
value={searchQuery} value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)} onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Tìm kiếm..." 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" 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" /> <Search className="absolute left-3 top-2.5 w-4 h-4 text-gray-500 group-focus-within:text-cyan-400 transition-colors" />
</form> </form>
</div> </div>
{/* Install App Button */} {/* Install App Button */}
<a <a
href="/streamflow-tv.apk" href="/streamflow-tv.apk"
download="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" 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 <svg
viewBox="0 0 24 24" viewBox="0 0 24 24"
fill="none" fill="none"
stroke="currentColor" stroke="currentColor"
strokeWidth="2.5" strokeWidth="2.5"
strokeLinecap="round" strokeLinecap="round"
strokeLinejoin="round" strokeLinejoin="round"
className="w-4 h-4" className="w-4 h-4"
> >
<rect width="20" height="15" x="2" y="7" rx="2" ry="2" /> <rect width="20" height="15" x="2" y="7" rx="2" ry="2" />
<polyline points="17 2 12 7 7 2" /> <polyline points="17 2 12 7 7 2" />
</svg> </svg>
<span>Install App</span> <span>Install App</span>
</a> </a>
<div className="lg:hidden"> <div className="lg:hidden">
<button <button
onClick={() => setIsMenuOpen(!isMenuOpen)} onClick={() => setIsMenuOpen(!isMenuOpen)}
className="text-gray-300 hover:text-white p-2" className="text-gray-300 hover:text-white p-2"
> >
{isMenuOpen ? <X size={24} /> : <Menu size={24} />} {isMenuOpen ? <X size={24} /> : <Menu size={24} />}
</button> </button>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
{isMenuOpen && ( {isMenuOpen && (
<div className="lg:hidden bg-[#1a1a1a] border-b border-white/10 max-h-[80vh] overflow-y-auto"> <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"> <div className="px-4 pt-2 pb-4 space-y-3">
{/* Mobile Install App Button */} {/* Mobile Install App Button */}
<a <a
href="/streamflow-tv.apk" href="/streamflow-tv.apk"
download="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" 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)} onClick={() => setIsMenuOpen(false)}
> >
<svg <svg
viewBox="0 0 24 24" viewBox="0 0 24 24"
fill="none" fill="none"
stroke="currentColor" stroke="currentColor"
strokeWidth="2.5" strokeWidth="2.5"
strokeLinecap="round" strokeLinecap="round"
strokeLinejoin="round" strokeLinejoin="round"
className="w-5 h-5" className="w-5 h-5"
> >
<rect width="20" height="15" x="2" y="7" rx="2" ry="2" /> <rect width="20" height="15" x="2" y="7" rx="2" ry="2" />
<polyline points="17 2 12 7 7 2" /> <polyline points="17 2 12 7 7 2" />
</svg> </svg>
<span>Download Android TV App</span> <span>Download Android TV App</span>
</a> </a>
<form onSubmit={handleSearch} className="relative mb-4"> <form onSubmit={handleSearch} className="relative mb-4">
<input <input
type="text" type="text"
value={searchQuery} value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)} onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Tìm kiếm..." 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" 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" /> <Search className="absolute left-3 top-2.5 w-4 h-4 text-gray-400" />
</form> </form>
{NAV_ITEMS.map((item) => ( {NAV_ITEMS.map((item) => (
<Link <Link
key={item.name} key={item.name}
to={item.path} to={item.path}
onClick={() => setIsMenuOpen(false)} 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'}`} 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} {item.name}
</Link> </Link>
))} ))}
</div> </div>
</div> </div>
)} )}
</nav> </nav>
); );
}; };
export default Navbar; export default Navbar;

View file

@ -1,88 +1,88 @@
import { useState } from 'react'; import { useState } from 'react';
import { Settings, X, Check } from 'lucide-react'; import { Settings, X, Check } from 'lucide-react';
import { useTheme } from '../context/ThemeContext'; import { useTheme } from '../context/ThemeContext';
import type { ThemeName } from '../types/Theme'; import type { ThemeName } from '../types/Theme';
export const SettingsPanel = () => { export const SettingsPanel = () => {
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const { currentTheme, setTheme } = useTheme(); const { currentTheme, setTheme } = useTheme();
const themes: { id: ThemeName; name: string; color: string }[] = [ const themes: { id: ThemeName; name: string; color: string }[] = [
{ id: 'default', name: 'StreamFlow', color: '#06b6d4' }, { id: 'default', name: 'StreamFlow', color: '#06b6d4' },
{ id: 'netflix', name: 'Netflix', color: '#E50914' }, { id: 'netflix', name: 'Netflix', color: '#E50914' },
{ id: 'apple', name: 'Apple TV+', color: '#FFFFFF' }, { id: 'apple', name: 'Apple TV+', color: '#FFFFFF' },
]; ];
return ( return (
<> <>
<button <button
onClick={() => setIsOpen(true)} 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" 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" /> <Settings className="w-6 h-6 animate-spin-slow" />
</button> </button>
{isOpen && ( {isOpen && (
<div className="fixed inset-0 z-[101] flex items-center justify-center p-4"> <div className="fixed inset-0 z-[101] flex items-center justify-center p-4">
<div <div
className="absolute inset-0 bg-black/60 backdrop-blur-sm" className="absolute inset-0 bg-black/60 backdrop-blur-sm"
onClick={() => setIsOpen(false)} 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="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"> <div className="flex items-center justify-between p-4 border-b border-white/5">
<h2 className="text-lg font-bold text-white">Appearance</h2> <h2 className="text-lg font-bold text-white">Appearance</h2>
<button <button
onClick={() => setIsOpen(false)} onClick={() => setIsOpen(false)}
className="p-1 hover:bg-white/10 rounded-full transition-colors text-gray-400 hover:text-white" className="p-1 hover:bg-white/10 rounded-full transition-colors text-gray-400 hover:text-white"
> >
<X className="w-5 h-5" /> <X className="w-5 h-5" />
</button> </button>
</div> </div>
<div className="p-4 space-y-4"> <div className="p-4 space-y-4">
<div> <div>
<h3 className="text-sm font-medium text-gray-400 mb-3 uppercase tracking-wider">Choose Theme</h3> <h3 className="text-sm font-medium text-gray-400 mb-3 uppercase tracking-wider">Choose Theme</h3>
<div className="space-y-2"> <div className="space-y-2">
{themes.map((theme) => ( {themes.map((theme) => (
<button <button
key={theme.id} key={theme.id}
onClick={() => setTheme(theme.id)} onClick={() => setTheme(theme.id)}
className={`w-full flex items-center justify-between p-4 rounded-xl border transition-all ${currentTheme === 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-white/10 border-white/20'
: 'bg-transparent border-white/5 hover:bg-white/5' : 'bg-transparent border-white/5 hover:bg-white/5'
}`} }`}
> >
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div <div
className="w-10 h-10 rounded-lg shadow-inner flex items-center justify-center font-bold text-white text-xs" 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' }} style={{ backgroundColor: theme.id === 'netflix' ? '#000' : '#111' }}
> >
<span style={{ color: theme.color }}> <span style={{ color: theme.color }}>
{theme.name.charAt(0)} {theme.name.charAt(0)}
</span> </span>
</div> </div>
<span className="font-medium text-white">{theme.name}</span> <span className="font-medium text-white">{theme.name}</span>
</div> </div>
{currentTheme === theme.id && ( {currentTheme === theme.id && (
<div className="bg-green-500 rounded-full p-1"> <div className="bg-green-500 rounded-full p-1">
<Check className="w-3 h-3 text-white" /> <Check className="w-3 h-3 text-white" />
</div> </div>
)} )}
</button> </button>
))} ))}
</div> </div>
</div> </div>
<div className="bg-blue-500/10 border border-blue-500/20 rounded-lg p-3"> <div className="bg-blue-500/10 border border-blue-500/20 rounded-lg p-3">
<p className="text-xs text-blue-200 text-center"> <p className="text-xs text-blue-200 text-center">
Switching themes completely changes the layout and browsing experience. Switching themes completely changes the layout and browsing experience.
</p> </p>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
)} )}
</> </>
); );
}; };

View file

@ -1,14 +1,14 @@
import { Home, Film, Tv, PlayCircle, Heart, Folder } from 'lucide-react'; import { Home, Film, Tv, PlayCircle, Heart, Folder } from 'lucide-react';
export const CATEGORIES = [ export const CATEGORIES = [
{ id: 'phim-le', name: 'Phim Lẻ', path: '?category=phim-le', icon: Film }, { 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: '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: '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: 'tv-shows', name: 'TV Shows', path: '?category=tv-shows', icon: Folder },
{ id: 'my-list', name: 'My List', path: '/my-list', icon: Heart }, { id: 'my-list', name: 'My List', path: '/my-list', icon: Heart },
]; ];
export const NAV_ITEMS = [ export const NAV_ITEMS = [
{ name: 'Home', path: '/', icon: Home }, { name: 'Home', path: '/', icon: Home },
...CATEGORIES.map(cat => ({ name: cat.name, path: cat.path, icon: cat.icon })), ...CATEGORIES.map(cat => ({ name: cat.name, path: cat.path, icon: cat.icon })),
]; ];

View file

@ -1,43 +1,43 @@
import React, { createContext, useContext, useState, useEffect } from 'react'; import React, { createContext, useContext, useState, useEffect } from 'react';
import type { ThemeName } from '../types/Theme'; import type { ThemeName } from '../types/Theme';
// We will import the actual theme objects here once they are created // We will import the actual theme objects here once they are created
// import { netflixTheme } from '../themes/netflix'; // import { netflixTheme } from '../themes/netflix';
// import { appleTheme } from '../themes/apple'; // import { appleTheme } from '../themes/apple';
interface ThemeContextType { interface ThemeContextType {
currentTheme: ThemeName; currentTheme: ThemeName;
setTheme: (theme: ThemeName) => void; setTheme: (theme: ThemeName) => void;
// For now, we'll just store the ID. Later we will expose the full theme object // For now, we'll just store the ID. Later we will expose the full theme object
// theme: Theme; // theme: Theme;
} }
const ThemeContext = createContext<ThemeContextType | undefined>(undefined); const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
export const ThemeProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { export const ThemeProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [currentTheme, setCurrentTheme] = useState<ThemeName>(() => { const [currentTheme, setCurrentTheme] = useState<ThemeName>(() => {
const saved = localStorage.getItem('app-theme'); const saved = localStorage.getItem('app-theme');
return (saved as ThemeName) || 'netflix'; return (saved as ThemeName) || 'netflix';
}); });
useEffect(() => { useEffect(() => {
localStorage.setItem('app-theme', currentTheme); localStorage.setItem('app-theme', currentTheme);
// We can also set a class on the body if global styles need it // We can also set a class on the body if global styles need it
document.body.className = `theme-${currentTheme}`; document.body.className = `theme-${currentTheme}`;
}, [currentTheme]); }, [currentTheme]);
return ( return (
<ThemeContext.Provider value={{ currentTheme, setTheme: setCurrentTheme }}> <ThemeContext.Provider value={{ currentTheme, setTheme: setCurrentTheme }}>
{children} {children}
</ThemeContext.Provider> </ThemeContext.Provider>
); );
}; };
export const useTheme = () => { export const useTheme = () => {
const context = useContext(ThemeContext); const context = useContext(ThemeContext);
if (context === undefined) { if (context === undefined) {
throw new Error('useTheme must be used within a ThemeProvider'); throw new Error('useTheme must be used within a ThemeProvider');
} }
return context; return context;
}; };

View file

@ -1,49 +1,49 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { useSearchParams } from 'react-router-dom'; import { useSearchParams } from 'react-router-dom';
import type { Movie } from '../types'; import type { Movie } from '../types';
export const useMovies = () => { export const useMovies = () => {
const [movies, setMovies] = useState<Movie[]>([]); const [movies, setMovies] = useState<Movie[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
const query = searchParams.get('q'); const query = searchParams.get('q');
const category = searchParams.get('category'); const category = searchParams.get('category');
useEffect(() => { useEffect(() => {
const fetchMovies = async () => { const fetchMovies = async () => {
setLoading(true); setLoading(true);
try { try {
let endpoint = '/api/videos/home'; let endpoint = '/api/videos/home';
if (query) { if (query) {
endpoint = `/api/videos/search?q=${query}`; endpoint = `/api/videos/search?q=${query}`;
} else if (category && category !== 'home') { } else if (category && category !== 'home') {
endpoint = `/api/videos/home?category=${category}`; endpoint = `/api/videos/home?category=${category}`;
} }
const res = await fetch(endpoint); const res = await fetch(endpoint);
if (!res.ok) { if (!res.ok) {
throw new Error(`HTTP error! status: ${res.status}`); throw new Error(`HTTP error! status: ${res.status}`);
} }
const data = await res.json(); const data = await res.json();
setMovies(data || []); setMovies(data || []);
} catch { } catch {
console.error("Failed to fetch movies"); console.error("Failed to fetch movies");
} finally { } finally {
setLoading(false); setLoading(false);
} }
}; };
fetchMovies(); fetchMovies();
}, [query, category]); }, [query, category]);
const getTitle = () => { const getTitle = () => {
if (query) return `Results for "${query}"`; if (query) return `Results for "${query}"`;
if (category === 'phim-le') return 'Movies'; if (category === 'phim-le') return 'Movies';
if (category === 'phim-bo') return 'Series'; if (category === 'phim-bo') return 'Series';
if (category === 'hoat-hinh') return 'Cartoons'; if (category === 'hoat-hinh') return 'Cartoons';
if (category === 'tv-shows') return 'TV Shows'; if (category === 'tv-shows') return 'TV Shows';
return 'Latest Movies'; return 'Latest Movies';
}; };
return { movies, loading, title: getTitle() }; return { movies, loading, title: getTitle() };
}; };

View file

@ -1,61 +1,61 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import type { Movie } from '../types'; import type { Movie } from '../types';
interface MyListState { interface MyListState {
saved: Movie[]; saved: Movie[];
history: Movie[]; history: Movie[];
} }
export const useMyList = () => { export const useMyList = () => {
const [list, setList] = useState<MyListState>(() => { const [list, setList] = useState<MyListState>(() => {
const saved = localStorage.getItem('streamflow_mylist'); const saved = localStorage.getItem('streamflow_mylist');
return saved ? JSON.parse(saved) : { saved: [], history: [] }; return saved ? JSON.parse(saved) : { saved: [], history: [] };
}); });
useEffect(() => { useEffect(() => {
localStorage.setItem('streamflow_mylist', JSON.stringify(list)); localStorage.setItem('streamflow_mylist', JSON.stringify(list));
}, [list]); }, [list]);
const addToList = (movie: Movie) => { const addToList = (movie: Movie) => {
setList(prev => { setList(prev => {
if (prev.saved.some(m => m.id === movie.id)) return prev; if (prev.saved.some(m => m.id === movie.id)) return prev;
return { ...prev, saved: [movie, ...prev.saved] }; return { ...prev, saved: [movie, ...prev.saved] };
}); });
}; };
const removeFromList = (movieId: string) => { const removeFromList = (movieId: string) => {
setList(prev => ({ setList(prev => ({
...prev, ...prev,
saved: prev.saved.filter(m => m.id !== movieId) saved: prev.saved.filter(m => m.id !== movieId)
})); }));
}; };
const addToHistory = (movie: Movie) => { const addToHistory = (movie: Movie) => {
setList(prev => { setList(prev => {
const filtered = prev.history.filter(m => m.id !== movie.id); const filtered = prev.history.filter(m => m.id !== movie.id);
// Normalize Category to ensure it works with Recommendations // Normalize Category to ensure it works with Recommendations
let cat = movie.category?.toLowerCase() || 'phim-le'; let cat = movie.category?.toLowerCase() || 'phim-le';
if (cat === 'movies') cat = 'phim-le'; if (cat === 'movies') cat = 'phim-le';
if (cat === 'series') cat = 'phim-bo'; if (cat === 'series') cat = 'phim-bo';
if (cat === 'animation') cat = 'hoat-hinh'; if (cat === 'animation') cat = 'hoat-hinh';
if (cat === 'cartoon') cat = 'hoat-hinh'; if (cat === 'cartoon') cat = 'hoat-hinh';
if (cat === 'tv') cat = 'tv-shows'; if (cat === 'tv') cat = 'tv-shows';
const normalizedMovie = { ...movie, category: cat }; const normalizedMovie = { ...movie, category: cat };
return { ...prev, history: [normalizedMovie, ...filtered].slice(0, 50) }; return { ...prev, history: [normalizedMovie, ...filtered].slice(0, 50) };
}); });
}; };
const isSaved = (movieId: string) => list.saved.some(m => m.id === movieId); const isSaved = (movieId: string) => list.saved.some(m => m.id === movieId);
return { return {
savedMovies: list.saved, savedMovies: list.saved,
watchHistory: list.history, watchHistory: list.history,
addToList, addToList,
removeFromList, removeFromList,
addToHistory, addToHistory,
isSaved isSaved
}; };
}; };

View file

@ -1,64 +1,64 @@
import { useMemo } from 'react'; import { useMemo } from 'react';
import type { Movie } from '../types'; import type { Movie } from '../types';
import { CATEGORIES } from '../constants'; import { CATEGORIES } from '../constants';
interface Recommendation { interface Recommendation {
id: string; id: string;
title: string; title: string;
category: string; category: string;
reason: string; reason: string;
} }
export const useSmartRecommendations = (history: Movie[]): Recommendation[] => { export const useSmartRecommendations = (history: Movie[]): Recommendation[] => {
return useMemo(() => { return useMemo(() => {
if (!history || history.length === 0) return []; if (!history || history.length === 0) return [];
// Pre-defined mapping for data normalization // Pre-defined mapping for data normalization
const NORMALIZE_MAP: Record<string, string> = { const NORMALIZE_MAP: Record<string, string> = {
'movies': 'phim-le', 'movies': 'phim-le',
'phim-le': 'phim-le', 'phim-le': 'phim-le',
'series': 'phim-bo', 'series': 'phim-bo',
'phim-bo': 'phim-bo', 'phim-bo': 'phim-bo',
'cartoon': 'hoat-hinh', 'cartoon': 'hoat-hinh',
'animation': 'hoat-hinh', 'animation': 'hoat-hinh',
'hoat-hinh': 'hoat-hinh', 'hoat-hinh': 'hoat-hinh',
'tv-shows': 'tv-shows', 'tv-shows': 'tv-shows',
'tv': 'tv-shows', 'tv': 'tv-shows',
'shows': 'tv-shows' 'shows': 'tv-shows'
}; };
// 1. Frequency Map of Categories // 1. Frequency Map of Categories
const categoryCounts: Record<string, number> = {}; const categoryCounts: Record<string, number> = {};
history.forEach(movie => { history.forEach(movie => {
if (movie.category) { if (movie.category) {
const raw = movie.category.toLowerCase(); const raw = movie.category.toLowerCase();
const normalized = NORMALIZE_MAP[raw] || (CATEGORIES.some(c => c.id === raw) ? raw : 'phim-le'); const normalized = NORMALIZE_MAP[raw] || (CATEGORIES.some(c => c.id === raw) ? raw : 'phim-le');
if (CATEGORIES.some(c => c.id === normalized)) { if (CATEGORIES.some(c => c.id === normalized)) {
categoryCounts[normalized] = (categoryCounts[normalized] || 0) + 1; categoryCounts[normalized] = (categoryCounts[normalized] || 0) + 1;
} }
} }
}); });
// 2. Sort by frequency // 2. Sort by frequency
const sortedCategories = Object.entries(categoryCounts) const sortedCategories = Object.entries(categoryCounts)
.sort(([, a], [, b]) => b - a) .sort(([, a], [, b]) => b - a)
.map(([cat]) => cat); .map(([cat]) => cat);
// 3. Get Top 2 Categories // 3. Get Top 2 Categories
const topCategories = sortedCategories.slice(0, 2); const topCategories = sortedCategories.slice(0, 2);
// 4. Map to Recommendation Objects // 4. Map to Recommendation Objects
const recommendations: Recommendation[] = topCategories.map(catSlug => { const recommendations: Recommendation[] = topCategories.map(catSlug => {
const catName = CATEGORIES.find(c => c.id === catSlug)?.name || 'Phim'; const catName = CATEGORIES.find(c => c.id === catSlug)?.name || 'Phim';
return { return {
id: `rec-${catSlug}`, id: `rec-${catSlug}`,
title: `Gợi ý từ ${catName}`, title: `Gợi ý từ ${catName}`,
category: catSlug, category: catSlug,
reason: `Based on your interest in ${catName}` reason: `Based on your interest in ${catName}`
}; };
}); });
return recommendations; return recommendations;
}, [history]); }, [history]);
}; };

View file

@ -1,174 +1,174 @@
import { useState, useEffect, useRef } from 'react'; import { useState, useEffect, useRef } from 'react';
import Hls from 'hls.js'; import Hls from 'hls.js';
import type { MovieDetail, VideoSource } from '../types'; import type { MovieDetail, VideoSource } from '../types';
export const useWatchMovie = (slug: string | undefined, episode: string | undefined) => { export const useWatchMovie = (slug: string | undefined, episode: string | undefined) => {
const videoRef = useRef<HTMLVideoElement>(null); const videoRef = useRef<HTMLVideoElement>(null);
const [movie, setMovie] = useState<MovieDetail | null>(null); const [movie, setMovie] = useState<MovieDetail | null>(null);
const [source, setSource] = useState<VideoSource | null>(null); const [source, setSource] = useState<VideoSource | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [currentEpisode, setCurrentEpisode] = useState(parseInt(episode || '1')); const [currentEpisode, setCurrentEpisode] = useState(parseInt(episode || '1'));
useEffect(() => { useEffect(() => {
if (!slug) return; if (!slug) return;
const fetchDetails = async () => { const fetchDetails = async () => {
try { try {
const res = await fetch(`/api/videos/${slug}`); const res = await fetch(`/api/videos/${slug}`);
if (!res.ok) throw new Error('Failed to fetch details'); if (!res.ok) throw new Error('Failed to fetch details');
const data = await res.json(); const data = await res.json();
setMovie(data); setMovie(data);
} catch { } catch {
console.error("Failed to fetch details"); console.error("Failed to fetch details");
} }
}; };
fetchDetails(); fetchDetails();
}, [slug]); }, [slug]);
useEffect(() => { useEffect(() => {
if (!movie) return; if (!movie) return;
const fetchStream = async () => { const fetchStream = async () => {
setLoading(true); setLoading(true);
try { try {
const ep = movie.episodes?.find(e => e.number === currentEpisode); 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 no episode or no URL, don't try to extract — let WatchPage show "Coming Soon"
if (!ep?.url) { if (!ep?.url) {
setLoading(false); setLoading(false);
return; return;
} }
if (ep.url.includes('.m3u8') || ep.url.includes('index.m3u8')) { if (ep.url.includes('.m3u8') || ep.url.includes('index.m3u8')) {
const isPhimMoi = ep.url.includes('phimmoichill') || ep.url.includes('sotrim') || ep.url.includes('phmchill'); const isPhimMoi = ep.url.includes('phimmoichill') || ep.url.includes('sotrim') || ep.url.includes('phmchill');
setSource({ setSource({
stream_url: isPhimMoi stream_url: isPhimMoi
? `/api/stream?url=${encodeURIComponent(ep.url)}` ? `/api/stream?url=${encodeURIComponent(ep.url)}`
: ep.url, : ep.url,
resolution: 'HD', resolution: 'HD',
format_id: 'hls' format_id: 'hls'
}); });
setLoading(false); setLoading(false);
return; return;
} }
const targetUrl = ep ? ep.url : `https://phimmoichill.network/xem-phim/${slug}/tap-${currentEpisode}`; const targetUrl = ep ? ep.url : `https://phimmoichill.network/xem-phim/${slug}/tap-${currentEpisode}`;
const res = await fetch(`/api/extract`, { const res = await fetch(`/api/extract`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url: targetUrl }) // Changed to JSON payload body: JSON.stringify({ url: targetUrl }) // Changed to JSON payload
}); });
if (!res.ok) throw new Error('Failed to extract'); if (!res.ok) throw new Error('Failed to extract');
const data = await res.json(); const data = await res.json();
setSource({ setSource({
...data, ...data,
stream_url: (data.url || data.stream_url).includes('phimmoichill') || (data.url || data.stream_url).includes('sotrim') || (data.url || data.stream_url).includes('phmchill') 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)}` ? `/api/stream?url=${encodeURIComponent(data.url || data.stream_url)}`
: (data.url || data.stream_url) : (data.url || data.stream_url)
}); });
} catch { } catch {
console.error("Failed to extract stream"); console.error("Failed to extract stream");
} finally { } finally {
setLoading(false); setLoading(false);
} }
}; };
fetchStream(); fetchStream();
}, [movie, currentEpisode, slug]); }, [movie, currentEpisode, slug]);
useEffect(() => { useEffect(() => {
if (source && videoRef.current) { if (source && videoRef.current) {
console.log("Initializing player with source:", source); console.log("Initializing player with source:", source);
const isHls = source.stream_url.includes('.m3u8') || source.format_id === 'hls'; const isHls = source.stream_url.includes('.m3u8') || source.format_id === 'hls';
console.log("Is HLS:", isHls, "Stream URL:", source.stream_url); console.log("Is HLS:", isHls, "Stream URL:", source.stream_url);
if (isHls && Hls.isSupported()) { if (isHls && Hls.isSupported()) {
const hls = new Hls(); const hls = new Hls();
hls.loadSource(source.stream_url); hls.loadSource(source.stream_url);
hls.attachMedia(videoRef.current); hls.attachMedia(videoRef.current);
hls.on(Hls.Events.MANIFEST_PARSED, () => { hls.on(Hls.Events.MANIFEST_PARSED, () => {
videoRef.current?.play().catch(() => { }); videoRef.current?.play().catch(() => { });
}); });
return () => { return () => {
hls.destroy(); hls.destroy();
}; };
} else { } else {
// MP4 or Native HLS (Safari) // MP4 or Native HLS (Safari)
videoRef.current.src = source.stream_url; videoRef.current.src = source.stream_url;
videoRef.current.play().catch(() => { }); videoRef.current.play().catch(() => { });
} }
} }
}, [source]); }, [source]);
// Wake Lock Logic (Prevent Screen Sleep) // Wake Lock Logic (Prevent Screen Sleep)
useEffect(() => { useEffect(() => {
const video = videoRef.current; const video = videoRef.current;
let wakeLock: any = null; let wakeLock: any = null;
const requestWakeLock = async () => { const requestWakeLock = async () => {
if (wakeLock !== null) return; if (wakeLock !== null) return;
try { try {
if ('wakeLock' in navigator) { if ('wakeLock' in navigator) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
wakeLock = await (navigator as any).wakeLock.request('screen'); wakeLock = await (navigator as any).wakeLock.request('screen');
// console.log('Wake Lock active'); // console.log('Wake Lock active');
} }
} catch { } catch {
console.warn('Wake Lock failed'); console.warn('Wake Lock failed');
} }
}; };
const releaseWakeLock = async () => { const releaseWakeLock = async () => {
if (wakeLock) { if (wakeLock) {
try { try {
await wakeLock.release(); await wakeLock.release();
wakeLock = null; wakeLock = null;
// console.log('Wake Lock released'); // console.log('Wake Lock released');
} catch { } catch {
// console.warn('Wake Lock release failed'); // console.warn('Wake Lock release failed');
} }
} }
}; };
if (video) { if (video) {
const onPlay = () => requestWakeLock(); const onPlay = () => requestWakeLock();
const onPause = () => releaseWakeLock(); const onPause = () => releaseWakeLock();
const onEnded = () => releaseWakeLock(); const onEnded = () => releaseWakeLock();
video.addEventListener('play', onPlay); video.addEventListener('play', onPlay);
video.addEventListener('pause', onPause); video.addEventListener('pause', onPause);
video.addEventListener('ended', onEnded); video.addEventListener('ended', onEnded);
// If already playing (HLS might auto-start before this effect) // If already playing (HLS might auto-start before this effect)
if (!video.paused) { if (!video.paused) {
requestWakeLock(); requestWakeLock();
} }
// Re-acquire on visibility change if playing // Re-acquire on visibility change if playing
const onVisibilityChange = () => { const onVisibilityChange = () => {
if (document.visibilityState === 'visible' && !video.paused) { if (document.visibilityState === 'visible' && !video.paused) {
requestWakeLock(); requestWakeLock();
} }
}; };
document.addEventListener('visibilitychange', onVisibilityChange); document.addEventListener('visibilitychange', onVisibilityChange);
return () => { return () => {
video.removeEventListener('play', onPlay); video.removeEventListener('play', onPlay);
video.removeEventListener('pause', onPause); video.removeEventListener('pause', onPause);
video.removeEventListener('ended', onEnded); video.removeEventListener('ended', onEnded);
document.removeEventListener('visibilitychange', onVisibilityChange); document.removeEventListener('visibilitychange', onVisibilityChange);
releaseWakeLock(); releaseWakeLock();
}; };
} }
}, [source]); }, [source]);
return { return {
movie, movie,
source, source,
loading, loading,
currentEpisode, currentEpisode,
setCurrentEpisode, setCurrentEpisode,
videoRef videoRef
}; };
}; };

View file

@ -1,23 +1,23 @@
import { useTheme } from '../context/ThemeContext'; import { useTheme } from '../context/ThemeContext';
import { netflixTheme } from '../themes/netflix'; import { netflixTheme } from '../themes/netflix';
import { appleTheme } from '../themes/apple'; import { appleTheme } from '../themes/apple';
import { defaultTheme } from '../themes/default'; import { defaultTheme } from '../themes/default';
const themes = { const themes = {
default: defaultTheme, default: defaultTheme,
netflix: netflixTheme, netflix: netflixTheme,
apple: appleTheme, apple: appleTheme,
}; };
const Home = () => { const Home = () => {
const { currentTheme } = useTheme(); const { currentTheme } = useTheme();
// Dynamically select the Home component based on the current theme // Dynamically select the Home component based on the current theme
const ActiveTheme = themes[currentTheme]; const ActiveTheme = themes[currentTheme];
const ThemeHome = ActiveTheme.components.Home; const ThemeHome = ActiveTheme.components.Home;
return <ThemeHome />; return <ThemeHome />;
}; };
export default Home; export default Home;

View file

@ -1,46 +1,46 @@
import { useTheme } from '../context/ThemeContext'; import { useTheme } from '../context/ThemeContext';
import { netflixTheme } from '../themes/netflix'; import { netflixTheme } from '../themes/netflix';
import { appleTheme } from '../themes/apple'; import { appleTheme } from '../themes/apple';
import { useMyList } from '../hooks/useMyList'; import { useMyList } from '../hooks/useMyList';
import { SettingsPanel } from '../components/SettingsPanel'; import { SettingsPanel } from '../components/SettingsPanel';
import { defaultTheme } from '../themes/default'; import { defaultTheme } from '../themes/default';
const themes = { const themes = {
netflix: netflixTheme, netflix: netflixTheme,
apple: appleTheme, apple: appleTheme,
default: defaultTheme, default: defaultTheme,
}; };
const MyList = () => { const MyList = () => {
const { currentTheme } = useTheme(); const { currentTheme } = useTheme();
const { savedMovies, watchHistory } = useMyList(); const { savedMovies, watchHistory } = useMyList();
const ActiveTheme = themes[currentTheme]; const ActiveTheme = themes[currentTheme];
const { Layout, MovieGrid } = ActiveTheme.components; const { Layout, MovieGrid } = ActiveTheme.components;
return ( return (
<Layout> <Layout>
<div className="pt-24 px-4 md:px-12 min-h-screen"> <div className="pt-24 px-4 md:px-12 min-h-screen">
{/* Watch History Section */} {/* Watch History Section */}
{watchHistory.length > 0 && ( {watchHistory.length > 0 && (
<div className="mb-12"> <div className="mb-12">
<MovieGrid movies={watchHistory} title="Continue Watching" /> <MovieGrid movies={watchHistory} title="Continue Watching" />
</div> </div>
)} )}
{/* Saved List Section */} {/* Saved List Section */}
<MovieGrid movies={savedMovies} title="My List" /> <MovieGrid movies={savedMovies} title="My List" />
{savedMovies.length === 0 && watchHistory.length === 0 && ( {savedMovies.length === 0 && watchHistory.length === 0 && (
<div className="flex flex-col items-center justify-center h-[50vh] text-gray-500"> <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-xl">Your list is empty.</p>
<p className="text-sm mt-2">Start watching or add movies to your list.</p> <p className="text-sm mt-2">Start watching or add movies to your list.</p>
</div> </div>
)} )}
</div> </div>
<SettingsPanel /> <SettingsPanel />
</Layout> </Layout>
); );
}; };
export default MyList; export default MyList;

View file

@ -1,58 +1,58 @@
import { useEffect } from 'react'; import { useEffect } from 'react';
import { useParams } from 'react-router-dom'; import { useParams } from 'react-router-dom';
import { useTheme } from '../context/ThemeContext'; import { useTheme } from '../context/ThemeContext';
import { netflixTheme } from '../themes/netflix'; import { netflixTheme } from '../themes/netflix';
import { appleTheme } from '../themes/apple'; import { appleTheme } from '../themes/apple';
import { useMyList } from '../hooks/useMyList'; import { useMyList } from '../hooks/useMyList';
import { defaultTheme } from '../themes/default'; import { defaultTheme } from '../themes/default';
const themes = { const themes = {
netflix: netflixTheme, netflix: netflixTheme,
apple: appleTheme, apple: appleTheme,
default: defaultTheme, default: defaultTheme,
}; };
const Watch = () => { const Watch = () => {
const { slug, episode } = useParams(); const { slug, episode } = useParams();
const { currentTheme } = useTheme(); const { currentTheme } = useTheme();
const { addToHistory } = useMyList(); const { addToHistory } = useMyList();
// Fetch movie detail to get info for history // Fetch movie detail to get info for history
useEffect(() => { useEffect(() => {
if (!slug) return; if (!slug) return;
const fetchDetail = async () => { const fetchDetail = async () => {
try { try {
const res = await fetch(`/api/videos/${slug}`); const res = await fetch(`/api/videos/${slug}`);
if (res.ok) { if (res.ok) {
const data = await res.json(); const data = await res.json();
// Add to history when loaded // Add to history when loaded
addToHistory({ addToHistory({
id: data.id, id: data.id,
title: data.title, title: data.title,
original_title: data.original_title, original_title: data.original_title,
slug: data.slug, slug: data.slug,
thumbnail: data.thumbnail, thumbnail: data.thumbnail,
backdrop: data.backdrop, backdrop: data.backdrop,
year: data.year, year: data.year,
category: data.category || 'movies', category: data.category || 'movies',
quality: data.quality, quality: data.quality,
director: data.director, director: data.director,
cast: data.cast cast: data.cast
}); });
} }
} catch { } catch {
console.error("Failed to fetch for history"); console.error("Failed to fetch for history");
} }
}; };
fetchDetail(); fetchDetail();
}, [slug]); }, [slug]);
// Select the current theme components // Select the current theme components
const ActiveTheme = themes[currentTheme]; const ActiveTheme = themes[currentTheme];
const { WatchPage } = ActiveTheme.components; const { WatchPage } = ActiveTheme.components;
return <WatchPage slug={slug || ''} episode={episode || '1'} />; return <WatchPage slug={slug || ''} episode={episode || '1'} />;
}; };
export default Watch; export default Watch;

View file

@ -1,15 +1,15 @@
import { Layout } from './Layout'; import { Layout } from './Layout';
import { HomeContent } from '../../components/HomeContent'; import { HomeContent } from '../../components/HomeContent';
import { SettingsPanel } from '../../components/SettingsPanel'; import { SettingsPanel } from '../../components/SettingsPanel';
export const AppleHome = () => { export const AppleHome = () => {
return ( return (
<Layout> <Layout>
{/* Apple Theme usually has a dark gradient header, but HomeContent handles general layout */} {/* Apple Theme usually has a dark gradient header, but HomeContent handles general layout */}
<div className="min-h-screen bg-black"> <div className="min-h-screen bg-black">
<HomeContent topPadding="pt-24" /> <HomeContent topPadding="pt-24" />
</div> </div>
<SettingsPanel /> <SettingsPanel />
</Layout> </Layout>
); );
}; };

View file

@ -1,41 +1,41 @@
import type { Movie } from '../../types'; import type { Movie } from '../../types';
import { Play } from 'lucide-react'; import { Play } from 'lucide-react';
export const Card = ({ movie }: { movie: Movie }) => { export const Card = ({ movie }: { movie: Movie }) => {
return ( return (
<div className="group flex flex-col gap-3 cursor-pointer"> <div className="group flex flex-col gap-3 cursor-pointer">
<a href={`/watch/${movie.slug}`} className="relative"> <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"> <div className="aspect-[2/3] relative rounded-xl overflow-hidden shadow-lg group-hover:shadow-2xl transition-all duration-300">
<img <img
src={`https://wsrv.nl/?url=${encodeURIComponent(movie.thumbnail)}&w=500&output=webp`} src={`https://wsrv.nl/?url=${encodeURIComponent(movie.thumbnail)}&w=500&output=webp`}
alt={movie.title} alt={movie.title}
className="w-full h-full object-cover transition-transform duration-500 group-hover:scale-110" className="w-full h-full object-cover transition-transform duration-500 group-hover:scale-110"
loading="lazy" 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="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"> <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" /> <Play className="w-6 h-6 fill-current" />
</div> </div>
</div> </div>
{/* Glass Badge */} {/* Glass Badge */}
{movie.quality && ( {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"> <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} {movie.quality}
</div> </div>
)} )}
</div> </div>
</a> </a>
<div className="px-1 space-y-1"> <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"> <h3 className="font-semibold text-white/90 text-[15px] leading-tight truncate group-hover:text-white transition-colors">
{movie.title} {movie.title}
</h3> </h3>
<p className="text-white/40 text-xs font-medium truncate"> <p className="text-white/40 text-xs font-medium truncate">
{movie.original_title || movie.year || '2024'} {movie.original_title || movie.year || '2024'}
</p> </p>
</div> </div>
</div> </div>
); );
}; };

View file

@ -1,86 +1,86 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { Plus, Check, Play } from 'lucide-react'; import { Plus, Check, Play } from 'lucide-react';
import type { Movie } from '../../types'; import type { Movie } from '../../types';
import { useMyList } from '../../hooks/useMyList'; import { useMyList } from '../../hooks/useMyList';
export const Hero = ({ movies }: { movies: Movie[] }) => { export const Hero = ({ movies }: { movies: Movie[] }) => {
const [index, setIndex] = useState(0); const [index, setIndex] = useState(0);
const { addToList, removeFromList, isSaved } = useMyList(); const { addToList, removeFromList, isSaved } = useMyList();
useEffect(() => { useEffect(() => {
if (movies.length <= 1) return; if (movies.length <= 1) return;
const interval = setInterval(() => { const interval = setInterval(() => {
setIndex((prev) => (prev + 1) % movies.length); setIndex((prev) => (prev + 1) % movies.length);
}, 8000); }, 8000);
return () => clearInterval(interval); return () => clearInterval(interval);
}, [movies]); }, [movies]);
if (!movies || movies.length === 0) return null; if (!movies || movies.length === 0) return null;
const movie = movies[index]; const movie = movies[index];
const saved = isSaved(movie.id); const saved = isSaved(movie.id);
const toggleList = () => { const toggleList = () => {
if (saved) removeFromList(movie.id); if (saved) removeFromList(movie.id);
else addToList(movie); else addToList(movie);
}; };
return ( return (
<div className="relative h-[85vh] w-full overflow-hidden group"> <div className="relative h-[85vh] w-full overflow-hidden group">
<div className="absolute inset-0 scale-105 transition-transform duration-[10000ms] ease-linear"> <div className="absolute inset-0 scale-105 transition-transform duration-[10000ms] ease-linear">
<img <img
key={movie.id} key={movie.id}
src={`https://wsrv.nl/?url=${encodeURIComponent(movie.backdrop || movie.thumbnail)}&w=1600&output=webp`} src={`https://wsrv.nl/?url=${encodeURIComponent(movie.backdrop || movie.thumbnail)}&w=1600&output=webp`}
alt={movie.title} alt={movie.title}
className="w-full h-full object-cover animate-fade-in" 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-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 className="absolute inset-0 bg-gradient-to-r from-black/60 via-transparent to-transparent" />
</div> </div>
<div className="absolute bottom-0 left-0 w-full p-8 md:p-16 lg:p-24 pb-20 z-10"> <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="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"> <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> <span className="text-[10px] font-bold tracking-widest uppercase text-white/90">Premiere</span>
</div> </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' }}> <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} {movie.title}
</h1> </h1>
{movie.original_title && ( {movie.original_title && (
<p className="text-xl text-white/70 font-medium animate-slide-up" style={{ animationDelay: '200ms' }}>{movie.original_title}</p> <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' }}> <div className="flex items-center gap-4 pt-4 animate-slide-up" style={{ animationDelay: '300ms' }}>
<a <a
href={`/watch/${movie.slug}`} 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" 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 className="w-4 h-4 fill-current" />
Play Play
</a> </a>
<button <button
onClick={toggleList} 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" 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 ? <Check className="w-4 h-4" /> : <Plus className="w-4 h-4" />}
{saved ? 'In Up Next' : 'Add to Up Next'} {saved ? 'In Up Next' : 'Add to Up Next'}
</button> </button>
</div> </div>
</div> </div>
</div> </div>
{/* Carousel Dots */} {/* Carousel Dots */}
<div className="absolute bottom-8 left-1/2 -translate-x-1/2 flex gap-3 z-20"> <div className="absolute bottom-8 left-1/2 -translate-x-1/2 flex gap-3 z-20">
{movies.map((_, i) => ( {movies.map((_, i) => (
<button <button
key={i} key={i}
onClick={() => setIndex(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'}`} className={`w-2 h-2 rounded-full transition-all ${i === index ? 'bg-white w-4' : 'bg-white/30 hover:bg-white/50'}`}
/> />
))} ))}
</div> </div>
</div> </div>
); );
}; };

View file

@ -1,172 +1,172 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
import { Link, useNavigate, useLocation } from 'react-router-dom'; import { Link, useNavigate, useLocation } from 'react-router-dom';
import { Search, Apple, Home, Film, Tv, Sparkles, MonitorPlay } from 'lucide-react'; import { Search, Apple, Home, Film, Tv, Sparkles, MonitorPlay } from 'lucide-react';
import { CATEGORIES } from '../../constants'; import { CATEGORIES } from '../../constants';
export const Layout = ({ children }: { children: ReactNode }) => { export const Layout = ({ children }: { children: ReactNode }) => {
const [scrolled, setScrolled] = useState(false); const [scrolled, setScrolled] = useState(false);
const [isSearchOpen, setIsSearchOpen] = useState(false); const [isSearchOpen, setIsSearchOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState('');
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation(); const location = useLocation();
useEffect(() => { useEffect(() => {
const handleScroll = () => { const handleScroll = () => {
setScrolled(window.scrollY > 20); setScrolled(window.scrollY > 20);
}; };
window.addEventListener('scroll', handleScroll); window.addEventListener('scroll', handleScroll);
return () => window.removeEventListener('scroll', handleScroll); return () => window.removeEventListener('scroll', handleScroll);
}, []); }, []);
const handleSearch = (e: React.FormEvent) => { const handleSearch = (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
if (searchQuery.trim()) { if (searchQuery.trim()) {
navigate(`/?q=${encodeURIComponent(searchQuery)}`); navigate(`/?q=${encodeURIComponent(searchQuery)}`);
setIsSearchOpen(false); setIsSearchOpen(false);
} }
}; };
return ( return (
<div className="min-h-screen bg-[#000000] text-white selection:bg-white/20"> <div className="min-h-screen bg-[#000000] text-white selection:bg-white/20">
{/* Glass Navbar */} {/* Glass Navbar */}
<nav className={`fixed top-0 w-full z-50 transition-all duration-500 ${scrolled || isSearchOpen <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-[#1a1a1a]/90 backdrop-blur-xl border-b border-white/5'
: 'bg-gradient-to-b from-black/80 to-transparent' : '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="max-w-[1600px] mx-auto px-6 lg:px-12 h-16 flex items-center justify-between">
<div className="flex items-center gap-12"> <div className="flex items-center gap-12">
<Link to="/" className="text-white hover:opacity-80 transition-opacity"> <Link to="/" className="text-white hover:opacity-80 transition-opacity">
{/* Mock Apple Logo */} {/* Mock Apple Logo */}
<div className="flex items-center gap-1 font-semibold tracking-tight text-xl"> <div className="flex items-center gap-1 font-semibold tracking-tight text-xl">
<Apple className="w-5 h-5 mb-1" /> <Apple className="w-5 h-5 mb-1" />
<span>TV+</span> <span>TV+</span>
</div> </div>
</Link> </Link>
<div className="hidden md:flex items-center gap-8"> <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> <Link to="/" className="text-sm font-medium text-white/90 hover:text-white transition-colors">Home</Link>
{CATEGORIES.map((item) => ( {CATEGORIES.map((item) => (
<Link <Link
key={item.id} key={item.id}
to={item.path} to={item.path}
className="text-sm font-medium text-white/70 hover:text-white transition-colors" className="text-sm font-medium text-white/70 hover:text-white transition-colors"
> >
{item.name} {item.name}
</Link> </Link>
))} ))}
</div> </div>
</div> </div>
<div className="flex items-center gap-6"> <div className="flex items-center gap-6">
{/* Install App Button (PC/Tablet) */} {/* Install App Button (PC/Tablet) */}
<a <a
href="/streamflow-tv.apk" href="/streamflow-tv.apk"
download="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" 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 <svg
viewBox="0 0 24 24" viewBox="0 0 24 24"
fill="none" fill="none"
stroke="currentColor" stroke="currentColor"
strokeWidth="2.5" strokeWidth="2.5"
strokeLinecap="round" strokeLinecap="round"
strokeLinejoin="round" strokeLinejoin="round"
className="w-4 h-4" className="w-4 h-4"
> >
<rect width="20" height="15" x="2" y="7" rx="2" ry="2" /> <rect width="20" height="15" x="2" y="7" rx="2" ry="2" />
<polyline points="17 2 12 7 7 2" /> <polyline points="17 2 12 7 7 2" />
</svg> </svg>
<span>TV APP</span> <span>TV APP</span>
</a> </a>
<div className={`relative group flex items-center transition-all duration-300 ${isSearchOpen ? 'w-64 bg-white/10 rounded-lg px-2' : 'w-8'}`}> <div className={`relative group flex items-center transition-all duration-300 ${isSearchOpen ? 'w-64 bg-white/10 rounded-lg px-2' : 'w-8'}`}>
<Search <Search
className="w-4 h-4 text-white/70 group-hover:text-white transition-colors cursor-pointer" className="w-4 h-4 text-white/70 group-hover:text-white transition-colors cursor-pointer"
onClick={() => setIsSearchOpen(true)} onClick={() => setIsSearchOpen(true)}
/> />
{isSearchOpen && ( {isSearchOpen && (
<form onSubmit={handleSearch} className="flex-1"> <form onSubmit={handleSearch} className="flex-1">
<input <input
type="text" type="text"
value={searchQuery} value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)} onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Search movies..." 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" 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 autoFocus
onBlur={() => !searchQuery && setIsSearchOpen(false)} onBlur={() => !searchQuery && setIsSearchOpen(false)}
/> />
</form> </form>
)} )}
</div> </div>
<div className="w-8 h-8 rounded-full bg-gradient-to-tr from-blue-500 to-purple-500 p-[1px]"> <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"> <div className="w-full h-full rounded-full bg-black flex items-center justify-center text-xs font-bold">
K K
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</nav> </nav>
{/* Mobile Bottom 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"> <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"> <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'}`}> <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} /> <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> <span className="text-[10px] font-medium tracking-wide">Home</span>
</Link> </Link>
{CATEGORIES.slice(0, 3).map((item) => { {CATEGORIES.slice(0, 3).map((item) => {
const getCategoryIcon = (id: string) => { const getCategoryIcon = (id: string) => {
switch (id) { switch (id) {
case 'phim-le': return Film; case 'phim-le': return Film;
case 'phim-bo': return Tv; // Series implies TV case 'phim-bo': return Tv; // Series implies TV
case 'hoat-hinh': return Sparkles; // Animation case 'hoat-hinh': return Sparkles; // Animation
case 'tv-shows': return MonitorPlay; case 'tv-shows': return MonitorPlay;
default: return Film; default: return Film;
} }
}; };
const Icon = getCategoryIcon(item.id); const Icon = getCategoryIcon(item.id);
const isActive = location.pathname === item.path; const isActive = location.pathname === item.path;
return ( return (
<Link <Link
key={item.id} key={item.id}
to={item.path} 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'}`} 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} /> <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> <span className="text-[10px] font-medium tracking-wide">{item.name}</span>
</Link> </Link>
); );
})} })}
{/* APK Download in Mobile Nav */} {/* APK Download in Mobile Nav */}
<a <a
href="/streamflow-tv.apk" href="/streamflow-tv.apk"
download="streamflow-tv.apk" download="streamflow-tv.apk"
className="flex flex-col items-center gap-1.5 p-2 text-white animate-pulse" className="flex flex-col items-center gap-1.5 p-2 text-white animate-pulse"
> >
<div className="p-1 rounded bg-white text-black"> <div className="p-1 rounded bg-white text-black">
<svg <svg
viewBox="0 0 24 24" viewBox="0 0 24 24"
fill="none" fill="none"
stroke="currentColor" stroke="currentColor"
strokeWidth="3" strokeWidth="3"
strokeLinecap="round" strokeLinecap="round"
strokeLinejoin="round" strokeLinejoin="round"
className="w-5 h-5" className="w-5 h-5"
> >
<rect width="20" height="15" x="2" y="7" rx="2" ry="2" /> <rect width="20" height="15" x="2" y="7" rx="2" ry="2" />
<polyline points="17 2 12 7 7 2" /> <polyline points="17 2 12 7 7 2" />
</svg> </svg>
</div> </div>
<span className="text-[10px] font-bold tracking-wide">TV APP</span> <span className="text-[10px] font-bold tracking-wide">TV APP</span>
</a> </a>
</div> </div>
</nav> </nav>
<main className="w-full pb-20 md:pb-0"> <main className="w-full pb-20 md:pb-0">
{children} {children}
</main> </main>
</div> </div>
); );
}; };

View file

@ -1,32 +1,32 @@
import type { Movie } from '../../types'; import type { Movie } from '../../types';
import { Card } from './Card'; import { Card } from './Card';
export const MovieGrid = ({ movies, loading, title }: { movies: Movie[], loading?: boolean, title?: string }) => { export const MovieGrid = ({ movies, loading, title }: { movies: Movie[], loading?: boolean, title?: string }) => {
if (loading) { if (loading) {
return ( return (
<div className="px-6 md:px-16 pt-8 pb-16"> <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>} {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"> <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) => ( {[...Array(10)].map((_, i) => (
<div key={i} className="aspect-[2/3] bg-white/5 rounded-2xl animate-pulse" /> <div key={i} className="aspect-[2/3] bg-white/5 rounded-2xl animate-pulse" />
))} ))}
</div> </div>
</div> </div>
); );
} }
return ( return (
<div className="px-6 md:px-16 pt-8 pb-16"> <div className="px-6 md:px-16 pt-8 pb-16">
<div className="flex items-baseline justify-between mb-6"> <div className="flex items-baseline justify-between mb-6">
{title && <h2 className="text-2xl font-bold text-white/90">{title}</h2>} {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> <button className="text-blue-400 text-sm font-medium hover:text-blue-300 transition-colors">See All</button>
</div> </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"> <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) => ( {movies.map((movie) => (
<Card key={movie.id} movie={movie} /> <Card key={movie.id} movie={movie} />
))} ))}
</div> </div>
</div> </div>
); );
}; };

View file

@ -1,201 +1,201 @@
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { ArrowLeft, ChevronDown, Play, ChevronUp } from 'lucide-react'; import { ArrowLeft, ChevronDown, Play, ChevronUp } from 'lucide-react';
import { useWatchMovie } from '../../hooks/useWatchMovie'; import { useWatchMovie } from '../../hooks/useWatchMovie';
import { useState } from 'react'; import { useState } from 'react';
import MovieRow from '../../components/MovieRow'; import MovieRow from '../../components/MovieRow';
export const WatchPage = ({ slug, episode }: { slug: string, episode: string }) => { export const WatchPage = ({ slug, episode }: { slug: string, episode: string }) => {
const navigate = useNavigate(); const navigate = useNavigate();
const { movie, loading, currentEpisode, setCurrentEpisode, videoRef } = useWatchMovie(slug, episode); const { movie, loading, currentEpisode, setCurrentEpisode, videoRef } = useWatchMovie(slug, episode);
const [expanded, setExpanded] = useState(false); const [expanded, setExpanded] = useState(false);
const [selectedServer, setSelectedServer] = useState<string>(''); const [selectedServer, setSelectedServer] = useState<string>('');
if (!movie) return <div className="text-white p-10">Loading...</div>; if (!movie) return <div className="text-white p-10">Loading...</div>;
// Group episodes by server // Group episodes by server
const episodesByServer = movie?.episodes?.reduce((acc, ep) => { const episodesByServer = movie?.episodes?.reduce((acc, ep) => {
const server = ep.server_name || 'Default'; const server = ep.server_name || 'Default';
if (!acc[server]) acc[server] = []; if (!acc[server]) acc[server] = [];
acc[server].push(ep); acc[server].push(ep);
return acc; return acc;
}, {} as Record<string, typeof movie.episodes>) || {}; }, {} as Record<string, typeof movie.episodes>) || {};
const serverNames = Object.keys(episodesByServer); const serverNames = Object.keys(episodesByServer);
// Initialize selected server // Initialize selected server
if (serverNames.length > 0 && !selectedServer) { if (serverNames.length > 0 && !selectedServer) {
const defaultServer = serverNames.find(s => s.toLowerCase().includes('vietsub #1')) || serverNames[0]; const defaultServer = serverNames.find(s => s.toLowerCase().includes('vietsub #1')) || serverNames[0];
setSelectedServer(defaultServer); setSelectedServer(defaultServer);
} }
const currentServerEpisodes = episodesByServer[selectedServer] || []; const currentServerEpisodes = episodesByServer[selectedServer] || [];
const visibleEpisodes = expanded ? currentServerEpisodes : currentServerEpisodes.slice(0, 20); const visibleEpisodes = expanded ? currentServerEpisodes : currentServerEpisodes.slice(0, 20);
return ( return (
<div className="min-h-screen bg-black text-white selection:bg-white/20 font-sans"> <div className="min-h-screen bg-black text-white selection:bg-white/20 font-sans">
{/* Navigation */} {/* 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'}`}> <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 <button
onClick={() => navigate('/')} 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" 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" /> <ArrowLeft className="w-5 h-5" />
<span className="font-medium text-sm hidden md:inline">Main Menu</span> <span className="font-medium text-sm hidden md:inline">Main Menu</span>
</button> </button>
</div> </div>
<div className="flex flex-col pb-20"> <div className="flex flex-col pb-20">
{/* Player Section - Sticky on larger screens for cinema feel */} {/* 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"> <div className="sticky top-0 z-40 w-full aspect-video md:h-[75vh] bg-black relative shadow-2xl">
{loading && ( {loading && (
<div className="absolute inset-0 flex items-center justify-center z-20"> <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 className="w-10 h-10 border-2 border-white/20 border-t-white rounded-full animate-spin"></div>
</div> </div>
)} )}
{(() => { {(() => {
const activeEpisode = currentServerEpisodes?.find(e => e.number === currentEpisode); const activeEpisode = currentServerEpisodes?.find(e => e.number === currentEpisode);
if (!activeEpisode?.url) { if (!activeEpisode?.url) {
return ( return (
<div className="absolute inset-0 z-10 flex flex-col items-center justify-center bg-zinc-900/50 backdrop-blur-3xl"> <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="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="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 className="w-2 h-2 bg-white rounded-full animate-pulse" />
</div> </div>
<h2 className="text-xl md:text-2xl font-bold tracking-tight">Processing Content</h2> <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"> <p className="text-white/60 text-sm max-w-xs mx-auto">
This title is currently being prepared for streaming. This title is currently being prepared for streaming.
</p> </p>
</div> </div>
{/* Subtle Background */} {/* Subtle Background */}
<div <div
className="absolute inset-0 -z-10 opacity-30 bg-cover bg-center blur-3xl" className="absolute inset-0 -z-10 opacity-30 bg-cover bg-center blur-3xl"
style={{ style={{
backgroundImage: `url(https://wsrv.nl/?url=${encodeURIComponent(movie.thumbnail?.replace(/^https?:\/\//, '').replace('img.ophim1.com', 'ssl:img.ophim1.com') || '')}&w=400&output=webp)` backgroundImage: `url(https://wsrv.nl/?url=${encodeURIComponent(movie.thumbnail?.replace(/^https?:\/\//, '').replace('img.ophim1.com', 'ssl:img.ophim1.com') || '')}&w=400&output=webp)`
}} }}
/> />
</div> </div>
); );
} }
return ( return (
<video <video
key={activeEpisode.url} key={activeEpisode.url}
ref={videoRef} ref={videoRef}
controls controls
className="w-full h-full object-contain bg-black" 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`} poster={`https://wsrv.nl/?url=${encodeURIComponent(movie.thumbnail?.replace(/^https?:\/\//, '').replace('img.ophim1.com', 'ssl:img.ophim1.com') || '')}&w=1600&output=webp`}
/> />
); );
})()} })()}
</div> </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 */}
{/* 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"> <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 */} {/* Movie Info */}
<div className="space-y-4"> <div className="space-y-4">
<div className="flex flex-col md:flex-row md:items-end justify-between gap-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> <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"> <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 className="px-2 py-0.5 border border-white/20 rounded text-xs uppercase">HD</span>
<span>{movie.year || '2024'}</span> <span>{movie.year || '2024'}</span>
<span>{movie.episodes?.length || 0} Episodes</span> <span>{movie.episodes?.length || 0} Episodes</span>
</div> </div>
</div> </div>
<p className="text-gray-400 text-base md:text-lg max-w-4xl leading-relaxed">{movie.description}</p> <p className="text-gray-400 text-base md:text-lg max-w-4xl leading-relaxed">{movie.description}</p>
</div> </div>
{/* Episodes Grid */} {/* Episodes Grid */}
<div className="space-y-6"> <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-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"> <div className="flex flex-wrap items-center gap-6">
<h3 className="text-lg font-bold">Episodes</h3> <h3 className="text-lg font-bold">Episodes</h3>
{/* Server Selector */} {/* Server Selector */}
{serverNames.length > 1 && ( {serverNames.length > 1 && (
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{serverNames.map(server => ( {serverNames.map(server => (
<button <button
key={server} key={server}
onClick={() => setSelectedServer(server)} onClick={() => setSelectedServer(server)}
className={`px-3 py-1 text-xs font-medium rounded-full transition-all ${selectedServer === server className={`px-3 py-1 text-xs font-medium rounded-full transition-all ${selectedServer === server
? 'bg-white text-black' ? 'bg-white text-black'
: 'bg-white/5 text-white/50 hover:bg-white/10 hover:text-white' : 'bg-white/5 text-white/50 hover:bg-white/10 hover:text-white'
}`} }`}
> >
{server} {server}
</button> </button>
))} ))}
</div> </div>
)} )}
</div> </div>
<span className="text-sm text-gray-500">{currentServerEpisodes.length} available</span> <span className="text-sm text-gray-500">{currentServerEpisodes.length} available</span>
</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"> <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) => ( {visibleEpisodes.map((ep) => (
<button <button
key={`${ep.number}-${selectedServer}`} key={`${ep.number}-${selectedServer}`}
onClick={() => { onClick={() => {
setCurrentEpisode(ep.number); setCurrentEpisode(ep.number);
navigate(`/watch/${slug}/${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 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-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' : 'bg-zinc-900/50 hover:bg-zinc-800 text-white border-white/5 hover:border-white/20'
}`} }`}
> >
<span className="font-bold text-sm"> <span className="font-bold text-sm">
{ep.number} {ep.number}
</span> </span>
{currentEpisode === ep.number && ( {currentEpisode === ep.number && (
<div className="absolute top-1 right-1"> <div className="absolute top-1 right-1">
<Play className="w-2.5 h-2.5 fill-current" /> <Play className="w-2.5 h-2.5 fill-current" />
</div> </div>
)} )}
</button> </button>
))} ))}
</div> </div>
{currentServerEpisodes.length > 20 && ( {currentServerEpisodes.length > 20 && (
<button <button
onClick={() => setExpanded(!expanded)} 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" 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 ? ( {expanded ? (
<>Show Less <ChevronUp className="w-4 h-4" /></> <>Show Less <ChevronUp className="w-4 h-4" /></>
) : ( ) : (
<>Show All Episodes <ChevronDown className="w-4 h-4" /></> <>Show All Episodes <ChevronDown className="w-4 h-4" /></>
)} )}
</button> </button>
)} )}
</div> </div>
{/* Related Categories */} {/* Related Categories */}
<div className="space-y-12 pt-12 border-t border-white/10"> <div className="space-y-12 pt-12 border-t border-white/10">
<div className="space-y-4"> <div className="space-y-4">
<h3 className="text-xl font-bold">More Like This</h3> <h3 className="text-xl font-bold">More Like This</h3>
<MovieRow title="" category={movie.category || 'phim-le'} limit={10} key="related" /> <MovieRow title="" category={movie.category || 'phim-le'} limit={10} key="related" />
</div> </div>
<div className="space-y-4"> <div className="space-y-4">
<h3 className="text-xl font-bold">Trending Now</h3> <h3 className="text-xl font-bold">Trending Now</h3>
<MovieRow title="" category="home" limit={10} key="trending" /> <MovieRow title="" category="home" limit={10} key="trending" />
</div> </div>
<div className="space-y-4"> <div className="space-y-4">
<h3 className="text-xl font-bold">Top Movies</h3> <h3 className="text-xl font-bold">Top Movies</h3>
<MovieRow title="" category="phim-le" limit={10} key="top" /> <MovieRow title="" category="phim-le" limit={10} key="top" />
</div> </div>
<div className="space-y-4"> <div className="space-y-4">
<h3 className="text-xl font-bold">Animation</h3> <h3 className="text-xl font-bold">Animation</h3>
<MovieRow title="" category="hoat-hinh" limit={10} key="anim" /> <MovieRow title="" category="hoat-hinh" limit={10} key="anim" />
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
); );
}; };

View file

@ -1,25 +1,25 @@
import type { Theme } from '../../types/Theme'; import type { Theme } from '../../types/Theme';
import { Layout } from './Layout'; import { Layout } from './Layout';
import { Hero } from '../../components/Hero'; import { Hero } from '../../components/Hero';
import { MovieGrid } from './MovieGrid'; import { MovieGrid } from './MovieGrid';
import { Card } from './Card'; import { Card } from './Card';
import { WatchPage } from './WatchPage'; // Added import { WatchPage } from './WatchPage'; // Added
import { AppleHome } from './AppleHome'; // Added import { AppleHome } from './AppleHome'; // Added
export const appleTheme: Theme = { export const appleTheme: Theme = {
name: 'apple', name: 'apple',
label: 'Apple TV+', label: 'Apple TV+',
colors: { colors: {
background: '#000000', background: '#000000',
primary: '#FFFFFF', primary: '#FFFFFF',
text: '#FFFFFF', text: '#FFFFFF',
}, },
components: { components: {
Layout, Layout,
Hero, Hero,
MovieGrid, MovieGrid,
Card, Card,
WatchPage, // Added WatchPage, // Added
Home: AppleHome, // Added as Home Home: AppleHome, // Added as Home
}, },
}; };

View file

@ -1,15 +1,15 @@
import Navbar from '../../components/Navbar'; import Navbar from '../../components/Navbar';
import { HomeContent } from '../../components/HomeContent'; import { HomeContent } from '../../components/HomeContent';
import { SettingsPanel } from '../../components/SettingsPanel'; import { SettingsPanel } from '../../components/SettingsPanel';
export const DefaultHome = () => { export const DefaultHome = () => {
return ( return (
<div className="min-h-screen bg-black text-white font-sans selection:bg-red-600 selection:text-white"> <div className="min-h-screen bg-black text-white font-sans selection:bg-red-600 selection:text-white">
<Navbar /> <Navbar />
<div className="pt-16"> <div className="pt-16">
<HomeContent topPadding="pt-8 md:pt-12" /> <HomeContent topPadding="pt-8 md:pt-12" />
</div> </div>
<SettingsPanel /> <SettingsPanel />
</div> </div>
); );
}; };

View file

@ -1,200 +1,200 @@
import { useState } from 'react'; import { useState } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { ArrowLeft, ChevronDown, ChevronUp } from 'lucide-react'; import { ArrowLeft, ChevronDown, ChevronUp } from 'lucide-react';
import { useWatchMovie } from '../../hooks/useWatchMovie'; import { useWatchMovie } from '../../hooks/useWatchMovie';
import MovieRow from '../../components/MovieRow'; import MovieRow from '../../components/MovieRow';
export const WatchPage = ({ slug, episode }: { slug: string, episode: string }) => { export const WatchPage = ({ slug, episode }: { slug: string, episode: string }) => {
const navigate = useNavigate(); const navigate = useNavigate();
const { movie, loading, currentEpisode, setCurrentEpisode, videoRef } = useWatchMovie(slug, episode); const { movie, loading, currentEpisode, setCurrentEpisode, videoRef } = useWatchMovie(slug, episode);
const [selectedServer, setSelectedServer] = useState<string>(''); const [selectedServer, setSelectedServer] = useState<string>('');
const [expanded, setExpanded] = useState(false); const [expanded, setExpanded] = useState(false);
if (!movie) return ( if (!movie) return (
<div className="h-screen w-full flex items-center justify-center bg-[#141414] text-white"> <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="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> <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> <p className="text-gray-400 animate-pulse">Loading StreamFlow...</p>
</div> </div>
</div> </div>
); );
// Helper for URL safety (same as Hero) // Helper for URL safety (same as Hero)
const getImageUrl = (url: string | undefined, width: number) => { const getImageUrl = (url: string | undefined, width: number) => {
if (!url) return ''; if (!url) return '';
const cleanUrl = url.replace('img.ophim1.com', 'ssl:img.ophim1.com'); const cleanUrl = url.replace('img.ophim1.com', 'ssl:img.ophim1.com');
return `https://wsrv.nl/?url=${encodeURIComponent(cleanUrl)}&w=${width}&output=webp`; return `https://wsrv.nl/?url=${encodeURIComponent(cleanUrl)}&w=${width}&output=webp`;
}; };
const episodesByServer = movie?.episodes?.reduce((acc, ep) => { const episodesByServer = movie?.episodes?.reduce((acc, ep) => {
const server = ep.server_name || 'Default'; const server = ep.server_name || 'Default';
if (!acc[server]) acc[server] = []; if (!acc[server]) acc[server] = [];
acc[server].push(ep); acc[server].push(ep);
return acc; return acc;
}, {} as Record<string, typeof movie.episodes>) || {}; }, {} as Record<string, typeof movie.episodes>) || {};
const serverNames = Object.keys(episodesByServer); const serverNames = Object.keys(episodesByServer);
// Initialize selected server // Initialize selected server
if (serverNames.length > 0 && !selectedServer) { if (serverNames.length > 0 && !selectedServer) {
const defaultServer = serverNames.find(s => s.toLowerCase().includes('vietsub #1')) || serverNames[0]; const defaultServer = serverNames.find(s => s.toLowerCase().includes('vietsub #1')) || serverNames[0];
setSelectedServer(defaultServer); setSelectedServer(defaultServer);
} }
const currentServerEpisodes = episodesByServer[selectedServer] || []; const currentServerEpisodes = episodesByServer[selectedServer] || [];
const visibleEpisodes = expanded ? currentServerEpisodes : currentServerEpisodes.slice(0, 20); const visibleEpisodes = expanded ? currentServerEpisodes : currentServerEpisodes.slice(0, 20);
return ( return (
<div className="min-h-screen bg-[#0a0a0a] text-white font-sans selection:bg-cyan-500/30 pb-20"> <div className="min-h-screen bg-[#0a0a0a] text-white font-sans selection:bg-cyan-500/30 pb-20">
{/* Back Navigation */} {/* 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"> <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 <button
onClick={() => navigate('/')} 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" 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" /> <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> <span className="font-medium text-sm">Back to Home</span>
</button> </button>
</div> </div>
{/* 1. Cinema Player Section */} {/* 1. Cinema Player Section */}
<div className="w-full h-[50vh] md:h-[80vh] bg-black relative shadow-2xl z-40"> <div className="w-full h-[50vh] md:h-[80vh] bg-black relative shadow-2xl z-40">
{loading && ( {loading && (
<div className="absolute inset-0 flex items-center justify-center z-20"> <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 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> </div>
)} )}
{(() => { {(() => {
const activeEpisode = currentServerEpisodes?.find(e => e.number === currentEpisode); const activeEpisode = currentServerEpisodes?.find(e => e.number === currentEpisode);
if (!activeEpisode?.url) { if (!activeEpisode?.url) {
return ( return (
<div className="absolute inset-0 z-10 flex flex-col items-center justify-center bg-black/90"> <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"> <div className="text-center px-6 max-w-lg">
<h2 className="text-3xl font-bold text-white mb-4">Coming Soon</h2> <h2 className="text-3xl font-bold text-white mb-4">Coming Soon</h2>
<p className="text-gray-400 text-lg mb-6"> <p className="text-gray-400 text-lg mb-6">
We're busy uploading the best quality version of this movie. We're busy uploading the best quality version of this movie.
</p> </p>
</div> </div>
<div <div
className="absolute inset-0 -z-10 opacity-30 bg-cover bg-center blur-2xl grayscale" className="absolute inset-0 -z-10 opacity-30 bg-cover bg-center blur-2xl grayscale"
style={{ backgroundImage: `url(${getImageUrl(movie.backdrop || movie.thumbnail, 400)})` }} style={{ backgroundImage: `url(${getImageUrl(movie.backdrop || movie.thumbnail, 400)})` }}
/> />
</div> </div>
); );
} }
return ( return (
<video <video
key={activeEpisode.url} key={activeEpisode.url}
ref={videoRef} ref={videoRef}
controls controls
className="w-full h-full max-h-screen object-contain" className="w-full h-full max-h-screen object-contain"
poster={getImageUrl(movie.backdrop || movie.thumbnail, 1280)} poster={getImageUrl(movie.backdrop || movie.thumbnail, 1280)}
/> />
); );
})()} })()}
</div> </div>
{/* 2. Content Info & Rows */} {/* 2. Content Info & Rows */}
{/* 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"> <div className="max-w-[1600px] mx-auto px-4 md:px-12 py-8 md:py-12 space-y-12">
{/* Glass Info Card */} {/* 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"> <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> <h1 className="text-3xl md:text-5xl font-bold mb-4 tracking-tight">{movie.title}</h1>
{/* Meta Tags */} {/* Meta Tags */}
<div className="flex items-center gap-4 text-sm md:text-base mb-6"> <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"> <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'} {movie.quality || 'HD'}
</span> </span>
<span className="text-gray-400">{movie.year || '2024'}</span> <span className="text-gray-400">{movie.year || '2024'}</span>
<span className="text-green-400 font-medium">98% Match</span> <span className="text-green-400 font-medium">98% Match</span>
<span className="text-gray-400">{movie.original_title}</span> <span className="text-gray-400">{movie.original_title}</span>
</div> </div>
<div <div
className="text-gray-300 leading-relaxed max-w-4xl text-base md:text-lg font-light" className="text-gray-300 leading-relaxed max-w-4xl text-base md:text-lg font-light"
dangerouslySetInnerHTML={{ __html: movie.description }} dangerouslySetInnerHTML={{ __html: movie.description }}
/> />
</div> </div>
{/* Episodes Section - Compact Grid */} {/* Episodes Section - Compact Grid */}
{currentServerEpisodes.length > 0 && ( {currentServerEpisodes.length > 0 && (
<div className="space-y-6"> <div className="space-y-6">
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4"> <div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
<div className="flex flex-wrap items-center gap-6"> <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> <h3 className="text-2xl font-bold border-l-4 border-cyan-500 pl-4 whitespace-nowrap">Episodes</h3>
{/* Server Selector */} {/* Server Selector */}
{serverNames.length > 1 && ( {serverNames.length > 1 && (
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{serverNames.map(server => ( {serverNames.map(server => (
<button <button
key={server} key={server}
onClick={() => setSelectedServer(server)} onClick={() => setSelectedServer(server)}
className={`px-3 py-1 text-xs font-bold rounded-full transition-all border ${selectedServer === 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-cyan-500 text-black border-cyan-500'
: 'bg-white/5 text-gray-400 border-white/10 hover:bg-white/10' : 'bg-white/5 text-gray-400 border-white/10 hover:bg-white/10'
}`} }`}
> >
{server} {server}
</button> </button>
))} ))}
</div> </div>
)} )}
</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 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>
<div className="grid grid-cols-4 sm:grid-cols-6 md:grid-cols-8 lg:grid-cols-10 xl:grid-cols-12 gap-2"> <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) => ( {visibleEpisodes.map((ep) => (
<button <button
key={`${ep.number}-${selectedServer}`} key={`${ep.number}-${selectedServer}`}
onClick={() => { onClick={() => {
setCurrentEpisode(ep.number); setCurrentEpisode(ep.number);
navigate(`/watch/${slug}/${ep.number}`); navigate(`/watch/${slug}/${ep.number}`);
}} }}
className={`group relative py-2 rounded-lg border transition-all duration-300 ${currentEpisode === 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-cyan-500 bg-cyan-950/30'
: 'border-transparent bg-[#111] hover:bg-[#222] hover:border-white/10' : 'border-transparent bg-[#111] hover:bg-[#222] hover:border-white/10'
}`} }`}
> >
<div className="flex items-center justify-center"> <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' <span className={`font-bold text-sm ${currentEpisode === ep.number ? 'text-cyan-400' : 'text-gray-400 group-hover:text-white'
}`}> }`}>
{ep.number} {ep.number}
</span> </span>
</div> </div>
{currentEpisode === ep.number && ( {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)]" /> <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> </button>
))} ))}
</div> </div>
{currentServerEpisodes.length > 20 && ( {currentServerEpisodes.length > 20 && (
<button <button
onClick={() => setExpanded(!expanded)} 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" className="flex items-center gap-2 text-sm font-medium text-gray-400 hover:text-white transition-colors mt-4 mx-auto"
> >
{expanded ? ( {expanded ? (
<>Show Less <ChevronUp className="w-4 h-4" /></> <>Show Less <ChevronUp className="w-4 h-4" /></>
) : ( ) : (
<>Show All Episodes <ChevronDown className="w-4 h-4" /></> <>Show All Episodes <ChevronDown className="w-4 h-4" /></>
)} )}
</button> </button>
)} )}
</div> </div>
)} )}
{/* Related Content Section */} {/* Related Content Section */}
<div className="space-y-12 pt-8 border-t border-white/5"> <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="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="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 Lẻ" category="phim-le" limit={10} key="top-movies" />
<MovieRow title="Top Phim Bộ" category="phim-bo" limit={10} key="top-series" /> <MovieRow title="Top Phim Bộ" category="phim-bo" limit={10} key="top-series" />
</div> </div>
</div> </div>
</div> </div>
); );
}; };

View file

@ -1,25 +1,25 @@
import type { Theme } from '../../types/Theme'; import type { Theme } from '../../types/Theme';
import { DefaultHome } from './DefaultHome'; import { DefaultHome } from './DefaultHome';
import { Hero } from '../../components/Hero'; import { Hero } from '../../components/Hero';
import { MovieGrid } from '../netflix/MovieGrid'; import { MovieGrid } from '../netflix/MovieGrid';
import { Card } from '../netflix/Card'; import { Card } from '../netflix/Card';
import { WatchPage } from './WatchPage'; // Use local StreamFlow WatchPage import { WatchPage } from './WatchPage'; // Use local StreamFlow WatchPage
import { Layout } from '../netflix/Layout'; // Fallback layout if needed, but Home handles it import { Layout } from '../netflix/Layout'; // Fallback layout if needed, but Home handles it
export const defaultTheme: Theme = { export const defaultTheme: Theme = {
name: 'default', name: 'default',
label: 'StreamFlow', label: 'StreamFlow',
colors: { colors: {
background: '#141414', background: '#141414',
primary: '#E50914', primary: '#E50914',
text: '#FFFFFF', text: '#FFFFFF',
}, },
components: { components: {
Layout, Layout,
Hero, Hero,
MovieGrid, MovieGrid,
Card, Card,
WatchPage, WatchPage,
Home: DefaultHome, Home: DefaultHome,
}, },
}; };

View file

@ -1,6 +1,6 @@
import { MovieCard } from '../../components/MovieCard'; import { MovieCard } from '../../components/MovieCard';
import type { Movie } from '../../types'; import type { Movie } from '../../types';
export const Card = ({ movie }: { movie: Movie }) => { export const Card = ({ movie }: { movie: Movie }) => {
return <MovieCard movie={movie} />; return <MovieCard movie={movie} />;
}; };

View file

@ -1,87 +1,87 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { Play, Plus, Check } from 'lucide-react'; import { Play, Plus, Check } from 'lucide-react';
import type { Movie } from '../../types'; import type { Movie } from '../../types';
import { useMyList } from '../../hooks/useMyList'; import { useMyList } from '../../hooks/useMyList';
export const Hero = ({ movies }: { movies: Movie[] }) => { export const Hero = ({ movies }: { movies: Movie[] }) => {
const [index, setIndex] = useState(0); const [index, setIndex] = useState(0);
const { addToList, removeFromList, isSaved } = useMyList(); const { addToList, removeFromList, isSaved } = useMyList();
useEffect(() => { useEffect(() => {
if (movies.length <= 1) return; if (movies.length <= 1) return;
const interval = setInterval(() => { const interval = setInterval(() => {
setIndex((prev) => (prev + 1) % movies.length); setIndex((prev) => (prev + 1) % movies.length);
}, 8000); }, 8000);
return () => clearInterval(interval); return () => clearInterval(interval);
}, [movies]); }, [movies]);
if (!movies || movies.length === 0) return null; if (!movies || movies.length === 0) return null;
const movie = movies[index]; const movie = movies[index];
const saved = isSaved(movie.id); const saved = isSaved(movie.id);
const toggleList = () => { const toggleList = () => {
if (saved) removeFromList(movie.id); if (saved) removeFromList(movie.id);
else addToList(movie); else addToList(movie);
}; };
return ( return (
<div className="relative h-[85vh] w-full mr-4 overflow-hidden group"> <div className="relative h-[85vh] w-full mr-4 overflow-hidden group">
<div className="absolute inset-0 transition-opacity duration-1000 ease-in-out"> <div className="absolute inset-0 transition-opacity duration-1000 ease-in-out">
<img <img
key={movie.id} key={movie.id}
src={`https://wsrv.nl/?url=${encodeURIComponent(movie.backdrop || movie.thumbnail)}&w=1600&output=webp`} src={`https://wsrv.nl/?url=${encodeURIComponent(movie.backdrop || movie.thumbnail)}&w=1600&output=webp`}
alt={movie.title} alt={movie.title}
className="w-full h-full object-cover mask-image-gradient animate-fade-in" 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-r from-[#141414] via-[#141414]/40 to-transparent" />
<div className="absolute inset-0 bg-gradient-to-t from-[#141414] via-transparent to-transparent" /> <div className="absolute inset-0 bg-gradient-to-t from-[#141414] via-transparent to-transparent" />
</div> </div>
<div className="absolute inset-0 flex items-center px-4 md:px-12 z-10"> <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="max-w-2xl space-y-6">
<div className="flex items-center gap-2 mb-4 animate-slide-up"> <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="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> <span className="text-gray-300 text-sm font-medium tracking-widest uppercase">#{index + 1} in Movies</span>
</div> </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' }}> <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} {movie.title}
</h1> </h1>
{movie.original_title && ( {movie.original_title && (
<p className="text-xl text-gray-300 italic animate-slide-up" style={{ animationDelay: '200ms' }}>{movie.original_title}</p> <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' }}> <div className="flex items-center gap-3 pt-4 animate-slide-up" style={{ animationDelay: '300ms' }}>
<a <a
href={`/watch/${movie.slug}`} 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" 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 className="w-6 h-6 fill-current" />
Play Play
</a> </a>
<button <button
onClick={toggleList} 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" 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 ? <Check className="w-6 h-6" /> : <Plus className="w-6 h-6" />}
{saved ? 'My List' : 'My List'} {saved ? 'My List' : 'My List'}
</button> </button>
</div> </div>
</div> </div>
</div> </div>
{/* Indicators */} {/* Indicators */}
<div className="absolute right-12 bottom-1/3 flex flex-col gap-2 z-20"> <div className="absolute right-12 bottom-1/3 flex flex-col gap-2 z-20">
{movies.map((_, i) => ( {movies.map((_, i) => (
<button <button
key={i} key={i}
onClick={() => setIndex(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'}`} className={`w-2 h-2 rounded-full transition-all ${i === index ? 'bg-white scale-125' : 'bg-gray-500 hover:bg-gray-400'}`}
/> />
))} ))}
</div> </div>
</div> </div>
); );
}; };

View file

@ -1,139 +1,139 @@
import { useState } from 'react'; import { useState } from 'react';
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
import { useLocation, Link, useNavigate } from 'react-router-dom'; import { useLocation, Link, useNavigate } from 'react-router-dom';
import { Search } from 'lucide-react'; import { Search } from 'lucide-react';
import { NAV_ITEMS } from '../../constants'; import { NAV_ITEMS } from '../../constants';
export const Layout = ({ children }: { children: ReactNode }) => { export const Layout = ({ children }: { children: ReactNode }) => {
const location = useLocation(); const location = useLocation();
const navigate = useNavigate(); const navigate = useNavigate();
const [isSearchOpen, setIsSearchOpen] = useState(false); const [isSearchOpen, setIsSearchOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState('');
const isActive = (path: string) => { const isActive = (path: string) => {
if (path === '/') return location.pathname === '/' && !location.search; if (path === '/') return location.pathname === '/' && !location.search;
return location.pathname + location.search === path; return location.pathname + location.search === path;
}; };
const handleSearch = (e: React.FormEvent) => { const handleSearch = (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
if (searchQuery.trim()) { if (searchQuery.trim()) {
navigate(`/?q=${encodeURIComponent(searchQuery)}`); navigate(`/?q=${encodeURIComponent(searchQuery)}`);
// Optional: close search or keep it open // Optional: close search or keep it open
} }
}; };
return ( return (
<div className="min-h-screen bg-[#141414] text-white flex"> <div className="min-h-screen bg-[#141414] text-white flex">
{/* Sidebar Navigation */} {/* 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"> <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"> <div className="px-6 mb-10">
<span className="text-red-600 text-3xl font-bold tracking-tighter">NETFLIX</span> <span className="text-red-600 text-3xl font-bold tracking-tighter">NETFLIX</span>
</div> </div>
<nav className="flex-1 space-y-2 px-4"> <nav className="flex-1 space-y-2 px-4">
{/* Search Item */} {/* 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'}`} <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)} onClick={() => !isSearchOpen && setIsSearchOpen(true)}
> >
<Search className={`w-6 h-6 ${isSearchOpen ? 'text-white' : ''}`} /> <Search className={`w-6 h-6 ${isSearchOpen ? 'text-white' : ''}`} />
{isSearchOpen ? ( {isSearchOpen ? (
<form onSubmit={handleSearch} className="flex-1"> <form onSubmit={handleSearch} className="flex-1">
<input <input
type="text" type="text"
value={searchQuery} value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)} onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Search..." placeholder="Search..."
className="w-full bg-transparent border-none focus:ring-0 text-white text-sm placeholder:text-gray-500" className="w-full bg-transparent border-none focus:ring-0 text-white text-sm placeholder:text-gray-500"
autoFocus autoFocus
onBlur={() => !searchQuery && setIsSearchOpen(false)} onBlur={() => !searchQuery && setIsSearchOpen(false)}
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
/> />
</form> </form>
) : ( ) : (
<span className="hidden lg:block text-sm">Search</span> <span className="hidden lg:block text-sm">Search</span>
)} )}
</div> </div>
{NAV_ITEMS.map((item) => ( {NAV_ITEMS.map((item) => (
<Link <Link
key={item.name} key={item.name}
to={item.path} to={item.path}
className={`flex items-center gap-4 px-4 py-3 rounded-md transition-colors ${isActive(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-white font-bold bg-white/10'
: 'text-gray-400 hover:text-white hover:bg-white/5' : 'text-gray-400 hover:text-white hover:bg-white/5'
}`} }`}
> >
<item.icon className="w-6 h-6" /> <item.icon className="w-6 h-6" />
<span className="hidden lg:block text-sm">{item.name}</span> <span className="hidden lg:block text-sm">{item.name}</span>
</Link> </Link>
))} ))}
</nav> </nav>
<div className="p-4 mt-auto space-y-4"> <div className="p-4 mt-auto space-y-4">
{/* PC/Tablet Sidebar Install Link */} {/* PC/Tablet Sidebar Install Link */}
<a <a
href="/streamflow-tv.apk" href="/streamflow-tv.apk"
download="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" 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 <svg
viewBox="0 0 24 24" viewBox="0 0 24 24"
fill="none" fill="none"
stroke="currentColor" stroke="currentColor"
strokeWidth="2.5" strokeWidth="2.5"
strokeLinecap="round" strokeLinecap="round"
strokeLinejoin="round" strokeLinejoin="round"
className="w-6 h-6 group-hover:scale-110 transition-transform" className="w-6 h-6 group-hover:scale-110 transition-transform"
> >
<rect width="20" height="15" x="2" y="7" rx="2" ry="2" /> <rect width="20" height="15" x="2" y="7" rx="2" ry="2" />
<polyline points="17 2 12 7 7 2" /> <polyline points="17 2 12 7 7 2" />
</svg> </svg>
<span className="hidden lg:block text-sm font-bold tracking-wide">TV APP</span> <span className="hidden lg:block text-sm font-bold tracking-wide">TV APP</span>
</a> </a>
<div className="text-xs text-gray-500 text-center lg:text-left pt-2 border-t border-white/5 font-medium"> <div className="text-xs text-gray-500 text-center lg:text-left pt-2 border-t border-white/5 font-medium">
&copy; 2026 StreamFlow &copy; 2026 StreamFlow
</div> </div>
</div> </div>
</aside> </aside>
{/* Mobile Bottom Nav (Visible only on small screens) */} {/* 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"> <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) => ( {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'}`}> <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" /> <item.icon className="w-5 h-5" />
<span className="text-[10px]">{item.name}</span> <span className="text-[10px]">{item.name}</span>
</Link> </Link>
))} ))}
{/* APK Download in Mobile Nav */} {/* APK Download in Mobile Nav */}
<a <a
href="/streamflow-tv.apk" href="/streamflow-tv.apk"
download="streamflow-tv.apk" download="streamflow-tv.apk"
className="flex flex-col items-center gap-1 text-red-600 animate-pulse font-bold" className="flex flex-col items-center gap-1 text-red-600 animate-pulse font-bold"
> >
<div className="bg-red-600 rounded-lg p-1"> <div className="bg-red-600 rounded-lg p-1">
<svg <svg
viewBox="0 0 24 24" viewBox="0 0 24 24"
fill="none" fill="none"
stroke="white" stroke="white"
strokeWidth="3" strokeWidth="3"
strokeLinecap="round" strokeLinecap="round"
strokeLinejoin="round" strokeLinejoin="round"
className="w-4 h-4" className="w-4 h-4"
> >
<rect width="20" height="15" x="2" y="7" rx="2" ry="2" /> <rect width="20" height="15" x="2" y="7" rx="2" ry="2" />
<polyline points="17 2 12 7 7 2" /> <polyline points="17 2 12 7 7 2" />
</svg> </svg>
</div> </div>
<span className="text-[10px]">TV App</span> <span className="text-[10px]">TV App</span>
</a> </a>
</div> </div>
{/* Main Content Area */} {/* Main Content Area */}
<main className="flex-1 md:ml-24 lg:ml-64 w-full pb-16 md:pb-0"> <main className="flex-1 md:ml-24 lg:ml-64 w-full pb-16 md:pb-0">
{children} {children}
</main> </main>
</div> </div>
); );
}; };

View file

@ -1,28 +1,28 @@
import type { Movie } from '../../types'; import type { Movie } from '../../types';
import { Card } from './Card'; import { Card } from './Card';
export const MovieGrid = ({ movies, loading, title }: { movies: Movie[], loading?: boolean, title?: string }) => { export const MovieGrid = ({ movies, loading, title }: { movies: Movie[], loading?: boolean, title?: string }) => {
if (loading) { if (loading) {
return ( return (
<div className="px-4 md:px-12 pb-10"> <div className="px-4 md:px-12 pb-10">
{title && <h2 className="text-xl font-bold mb-4 text-white">{title}</h2>} {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"> <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) => ( {[...Array(12)].map((_, i) => (
<div key={i} className="aspect-[2/3] bg-[#222] rounded-md animate-pulse" /> <div key={i} className="aspect-[2/3] bg-[#222] rounded-md animate-pulse" />
))} ))}
</div> </div>
</div> </div>
); );
} }
return ( return (
<div className="px-4 md:px-12 pb-10"> <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} &gt;</h2>} {title && <h2 className="text-xl font-bold mb-4 text-white hover:text-gray-300 cursor-pointer transition-colors inline-block">{title} &gt;</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"> <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) => ( {movies.map((movie) => (
<Card key={movie.id} movie={movie} /> <Card key={movie.id} movie={movie} />
))} ))}
</div> </div>
</div> </div>
); );
}; };

View file

@ -1,14 +1,14 @@
import { Layout } from './Layout'; import { Layout } from './Layout';
import { HomeContent } from '../../components/HomeContent'; import { HomeContent } from '../../components/HomeContent';
import { SettingsPanel } from '../../components/SettingsPanel'; import { SettingsPanel } from '../../components/SettingsPanel';
export const NetflixHome = () => { export const NetflixHome = () => {
return ( return (
<Layout> <Layout>
<div className="w-full min-h-screen bg-black"> <div className="w-full min-h-screen bg-black">
<HomeContent topPadding="pt-8" /> <HomeContent topPadding="pt-8" />
</div> </div>
<SettingsPanel /> <SettingsPanel />
</Layout> </Layout>
); );
}; };

View file

@ -1,186 +1,186 @@
import { useState } from 'react'; import { useState } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { ArrowLeft, Play, ChevronDown, ChevronUp } from 'lucide-react'; import { ArrowLeft, Play, ChevronDown, ChevronUp } from 'lucide-react';
import { useWatchMovie } from '../../hooks/useWatchMovie'; import { useWatchMovie } from '../../hooks/useWatchMovie';
import MovieRow from '../../components/MovieRow'; import MovieRow from '../../components/MovieRow';
export const WatchPage = ({ slug, episode }: { slug: string, episode: string }) => { export const WatchPage = ({ slug, episode }: { slug: string, episode: string }) => {
const navigate = useNavigate(); const navigate = useNavigate();
const { movie, loading, currentEpisode, setCurrentEpisode, videoRef } = useWatchMovie(slug, episode); const { movie, loading, currentEpisode, setCurrentEpisode, videoRef } = useWatchMovie(slug, episode);
const [expanded, setExpanded] = useState(false); const [expanded, setExpanded] = useState(false);
const [selectedServer, setSelectedServer] = useState<string>(''); const [selectedServer, setSelectedServer] = useState<string>('');
// Group episodes by server // Group episodes by server
const episodesByServer = movie?.episodes?.reduce((acc, ep) => { const episodesByServer = movie?.episodes?.reduce((acc, ep) => {
const server = ep.server_name || 'Default'; const server = ep.server_name || 'Default';
if (!acc[server]) acc[server] = []; if (!acc[server]) acc[server] = [];
acc[server].push(ep); acc[server].push(ep);
return acc; return acc;
}, {} as Record<string, typeof movie.episodes>) || {}; }, {} as Record<string, typeof movie.episodes>) || {};
const serverNames = Object.keys(episodesByServer); const serverNames = Object.keys(episodesByServer);
// Initialize selected server // Initialize selected server
if (serverNames.length > 0 && !selectedServer) { if (serverNames.length > 0 && !selectedServer) {
// Prefer "Ophim" or "Vietsub #1" if available, else first // Prefer "Ophim" or "Vietsub #1" if available, else first
const defaultServer = serverNames.find(s => s.includes('Ophim')) || serverNames[0]; const defaultServer = serverNames.find(s => s.includes('Ophim')) || serverNames[0];
setSelectedServer(defaultServer); setSelectedServer(defaultServer);
} }
const currentServerEpisodes = episodesByServer[selectedServer] || []; const currentServerEpisodes = episodesByServer[selectedServer] || [];
const visibleEpisodes = expanded ? currentServerEpisodes : currentServerEpisodes.slice(0, 20); const visibleEpisodes = expanded ? currentServerEpisodes : currentServerEpisodes.slice(0, 20);
if (!movie) return <div className="text-white p-10">Loading...</div>; if (!movie) return <div className="text-white p-10">Loading...</div>;
return ( return (
<div className="min-h-screen bg-[#141414] text-white font-sans selection:bg-red-600 selection:text-white pb-20"> <div className="min-h-screen bg-[#141414] text-white font-sans selection:bg-red-600 selection:text-white pb-20">
{/* Back Navigation */} {/* 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"> <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 <button
onClick={() => navigate('/')} 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" 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" /> <ArrowLeft className="w-5 h-5 text-white group-hover:-translate-x-1 transition-transform" />
<span className="font-medium">Back to Home</span> <span className="font-medium">Back to Home</span>
</button> </button>
</div> </div>
{/* 1. Cinema Player Section */} {/* 1. Cinema Player Section */}
<div className="w-full h-[50vh] md:h-[80vh] bg-black relative shadow-2xl z-40"> <div className="w-full h-[50vh] md:h-[80vh] bg-black relative shadow-2xl z-40">
{loading && ( {loading && (
<div className="absolute inset-0 flex items-center justify-center z-20"> <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 className="animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-red-600"></div>
</div> </div>
)} )}
{(() => { {(() => {
const activeEpisode = currentServerEpisodes?.find(e => e.number === currentEpisode); const activeEpisode = currentServerEpisodes?.find(e => e.number === currentEpisode);
if (!activeEpisode?.url) { if (!activeEpisode?.url) {
return ( return (
<div className="absolute inset-0 z-10 flex flex-col items-center justify-center bg-black/90"> <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"> <div className="text-center px-6 max-w-lg">
<h2 className="text-3xl font-bold text-white mb-4">Coming Soon</h2> <h2 className="text-3xl font-bold text-white mb-4">Coming Soon</h2>
<p className="text-gray-400 text-lg mb-6"> <p className="text-gray-400 text-lg mb-6">
We're busy uploading the best quality version of this movie. We're busy uploading the best quality version of this movie.
</p> </p>
</div> </div>
<div <div
className="absolute inset-0 -z-10 opacity-30 bg-cover bg-center blur-2xl grayscale" className="absolute inset-0 -z-10 opacity-30 bg-cover bg-center blur-2xl grayscale"
style={{ style={{
backgroundImage: `url(https://wsrv.nl/?url=${encodeURIComponent(movie.thumbnail?.replace(/^https?:\/\//, '').replace('img.ophim1.com', 'ssl:img.ophim1.com') || '')}&w=400&output=webp)` backgroundImage: `url(https://wsrv.nl/?url=${encodeURIComponent(movie.thumbnail?.replace(/^https?:\/\//, '').replace('img.ophim1.com', 'ssl:img.ophim1.com') || '')}&w=400&output=webp)`
}} }}
/> />
</div> </div>
); );
} }
return ( return (
<video <video
ref={videoRef} ref={videoRef}
controls controls
className="w-full h-full max-h-screen object-contain" 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`} poster={`https://wsrv.nl/?url=${encodeURIComponent(movie.thumbnail?.replace(/^https?:\/\//, '').replace('img.ophim1.com', 'ssl:img.ophim1.com') || '')}&w=1280&output=webp`}
/> />
); );
})()} })()}
</div> </div>
{/* 2. Content Info & Rows */} {/* 2. Content Info & Rows */}
<div className="max-w-[1600px] mx-auto px-4 md:px-12 py-8 space-y-12"> <div className="max-w-[1600px] mx-auto px-4 md:px-12 py-8 space-y-12">
{/* Glass Info Card */} {/* 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"> <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> <h1 className="text-3xl md:text-5xl font-bold mb-4 tracking-tight">{movie.title}</h1>
{/* Meta Tags */} {/* Meta Tags */}
<div className="flex items-center gap-4 text-sm md:text-base mb-6"> <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-green-500 font-bold">98% Match</span>
<span className="text-gray-400">{movie.year || '2024'}</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="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> <span className="text-gray-400">{movie.original_title}</span>
</div> </div>
<div <div
className="text-gray-300 leading-relaxed max-w-4xl text-base md:text-lg" className="text-gray-300 leading-relaxed max-w-4xl text-base md:text-lg"
dangerouslySetInnerHTML={{ __html: movie.description }} dangerouslySetInnerHTML={{ __html: movie.description }}
/> />
</div> </div>
{/* Episodes Section - Compact Grid */} {/* Episodes Section - Compact Grid */}
{currentServerEpisodes.length > 0 && ( {currentServerEpisodes.length > 0 && (
<div className="space-y-6"> <div className="space-y-6">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center gap-6"> <div className="flex items-center gap-6">
<h3 className="text-2xl font-bold border-l-4 border-red-600 pl-4">Episodes</h3> <h3 className="text-2xl font-bold border-l-4 border-red-600 pl-4">Episodes</h3>
{/* Server Selector */} {/* Server Selector */}
{serverNames.length > 1 && ( {serverNames.length > 1 && (
<div className="flex gap-2"> <div className="flex gap-2">
{serverNames.map(server => ( {serverNames.map(server => (
<button <button
key={server} key={server}
onClick={() => setSelectedServer(server)} onClick={() => setSelectedServer(server)}
className={`px-3 py-1 text-sm font-medium rounded-md transition-colors ${selectedServer === server className={`px-3 py-1 text-sm font-medium rounded-md transition-colors ${selectedServer === server
? 'bg-red-600 text-white' ? 'bg-red-600 text-white'
: 'bg-[#333] text-gray-400 hover:bg-[#444]' : 'bg-[#333] text-gray-400 hover:bg-[#444]'
}`} }`}
> >
{server} {server}
</button> </button>
))} ))}
</div> </div>
)} )}
</div> </div>
<div className="text-gray-400 text-sm font-medium">{currentServerEpisodes.length} Items</div> <div className="text-gray-400 text-sm font-medium">{currentServerEpisodes.length} Items</div>
</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"> <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) => ( {visibleEpisodes.map((ep) => (
<button <button
key={`${selectedServer}-${ep.number}`} key={`${selectedServer}-${ep.number}`}
onClick={() => { onClick={() => {
setCurrentEpisode(ep.number); setCurrentEpisode(ep.number);
navigate(`/watch/${slug}/${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]'}`} 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"> <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'}`}> <span className={`font-bold text-sm ${currentEpisode === ep.number ? 'text-red-500' : 'text-gray-400 group-hover:text-white'}`}>
{ep.number} {ep.number}
</span> </span>
</div> </div>
{currentEpisode === ep.number && ( {currentEpisode === ep.number && (
<div className="absolute top-1 right-1"> <div className="absolute top-1 right-1">
<Play className="w-2.5 h-2.5 text-red-500 fill-current" /> <Play className="w-2.5 h-2.5 text-red-500 fill-current" />
</div> </div>
)} )}
</button> </button>
))} ))}
</div> </div>
{currentServerEpisodes.length > 20 && ( {currentServerEpisodes.length > 20 && (
<button <button
onClick={() => setExpanded(!expanded)} onClick={() => setExpanded(!expanded)}
className="flex items-center gap-2 text-sm font-medium text-gray-400 hover:text-white transition-colors mt-4" className="flex items-center gap-2 text-sm font-medium text-gray-400 hover:text-white transition-colors mt-4"
> >
{expanded ? ( {expanded ? (
<>Show Less <ChevronUp className="w-4 h-4" /></> <>Show Less <ChevronUp className="w-4 h-4" /></>
) : ( ) : (
<>Show All Episodes <ChevronDown className="w-4 h-4" /></> <>Show All Episodes <ChevronDown className="w-4 h-4" /></>
)} )}
</button> </button>
)} )}
</div> </div>
)} )}
{/* Related Content Section */} {/* Related Content Section */}
<div className="space-y-12 pt-8 border-t border-white/10"> <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="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="New Releases" category="home" limit={10} key="trending" />
<MovieRow title="Top Movies" category="phim-le" limit={10} key="top-movies" /> <MovieRow title="Top Movies" category="phim-le" limit={10} key="top-movies" />
<MovieRow title="Animation" category="hoat-hinh" limit={10} key="animation" /> <MovieRow title="Animation" category="hoat-hinh" limit={10} key="animation" />
</div> </div>
</div> </div>
</div> </div>
); );
}; };

View file

@ -1,25 +1,25 @@
import type { Theme } from '../../types/Theme'; import type { Theme } from '../../types/Theme';
import { Layout } from './Layout'; import { Layout } from './Layout';
import { Hero } from '../../components/Hero'; import { Hero } from '../../components/Hero';
import { MovieGrid } from './MovieGrid'; import { MovieGrid } from './MovieGrid';
import { Card } from './Card'; import { Card } from './Card';
import { WatchPage } from './WatchPage'; import { WatchPage } from './WatchPage';
import { NetflixHome } from './NetflixHome'; // Added import { NetflixHome } from './NetflixHome'; // Added
export const netflixTheme: Theme = { export const netflixTheme: Theme = {
name: 'netflix', name: 'netflix',
label: 'Netflix', label: 'Netflix',
colors: { colors: {
background: '#141414', background: '#141414',
primary: '#E50914', primary: '#E50914',
text: '#FFFFFF', text: '#FFFFFF',
}, },
components: { components: {
Layout, Layout,
Hero, Hero,
MovieGrid, MovieGrid,
Card, Card,
WatchPage, WatchPage,
Home: NetflixHome, // Added as Home Home: NetflixHome, // Added as Home
}, },
}; };

View file

@ -1,24 +1,24 @@
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
import type { Movie } from './index'; import type { Movie } from './index';
export interface ThemeComponents { export interface ThemeComponents {
Layout: React.ComponentType<{ children: ReactNode }>; Layout: React.ComponentType<{ children: ReactNode }>;
Hero: React.ComponentType<{ movies: Movie[] }>; Hero: React.ComponentType<{ movies: Movie[] }>;
MovieGrid: React.ComponentType<{ movies: Movie[], loading?: boolean, title?: string }>; MovieGrid: React.ComponentType<{ movies: Movie[], loading?: boolean, title?: string }>;
Card: React.ComponentType<{ movie: Movie }>; Card: React.ComponentType<{ movie: Movie }>;
WatchPage: React.ComponentType<{ slug: string, episode: string }>; WatchPage: React.ComponentType<{ slug: string, episode: string }>;
Home: React.ComponentType; // Refactored to be self-contained Home: React.ComponentType; // Refactored to be self-contained
} }
export type ThemeName = 'netflix' | 'apple' | 'default'; export type ThemeName = 'netflix' | 'apple' | 'default';
export interface Theme { export interface Theme {
name: ThemeName; name: ThemeName;
label: string; label: string;
colors: { colors: {
background: string; background: string;
primary: string; primary: string;
text: string; text: string;
}; };
components: ThemeComponents; components: ThemeComponents;
} }

View file

@ -1,44 +1,44 @@
export interface Movie { export interface Movie {
id: string; id: string;
title: string; title: string;
original_title?: string; original_title?: string;
slug: string; slug: string;
thumbnail: string; thumbnail: string;
backdrop?: string; backdrop?: string;
quality?: string; quality?: string;
year?: number; year?: number;
category: string; category: string;
time?: string; time?: string;
lang?: string; lang?: string;
provider?: string; provider?: string;
director?: string; director?: string;
cast?: string[]; cast?: string[];
} }
export interface MovieDetail extends Movie { export interface MovieDetail extends Movie {
description: string; description: string;
rating?: string; rating?: string;
duration?: number; duration?: number;
genre?: string; genre?: string;
director?: string; director?: string;
country?: string; country?: string;
cast?: string[]; cast?: string[];
episodes?: Episode[]; episodes?: Episode[];
} }
export interface Episode { export interface Episode {
number: number; number: number;
title: string; title: string;
url: string; url: string;
server_name?: string; server_name?: string;
} }
export interface VideoSource { export interface VideoSource {
stream_url: string; stream_url: string;
resolution: string; resolution: string;
format_id: string; format_id: string;
} }
export interface Category { export interface Category {
name: string; name: string;
slug: string; slug: string;
} }

View file

@ -1,11 +1,11 @@
/** @type {import('tailwindcss').Config} */ /** @type {import('tailwindcss').Config} */
export default { export default {
content: [ content: [
"./index.html", "./index.html",
"./src/**/*.{js,ts,jsx,tsx}", "./src/**/*.{js,ts,jsx,tsx}",
], ],
theme: { theme: {
extend: {}, extend: {},
}, },
plugins: [], plugins: [],
} }

View file

@ -1,51 +1,51 @@
# Streamflow Dev Start Script (Auto-Restart) # Streamflow Dev Start Script (Auto-Restart)
Write-Host "=============================" -ForegroundColor Cyan Write-Host "=============================" -ForegroundColor Cyan
Write-Host " Streamflow Dev Launcher " -ForegroundColor Cyan Write-Host " Streamflow Dev Launcher " -ForegroundColor Cyan
Write-Host "=============================" -ForegroundColor Cyan Write-Host "=============================" -ForegroundColor Cyan
$BackendPort = 8000 $BackendPort = 8000
$FrontendPort = 5173 $FrontendPort = 5173
# Helper function to kill processes on a port # Helper function to kill processes on a port
function Kill-Port($port) { function Kill-Port($port) {
echo "Checking port $port..." echo "Checking port $port..."
$connection = Get-NetTCPConnection -LocalPort $port -ErrorAction SilentlyContinue $connection = Get-NetTCPConnection -LocalPort $port -ErrorAction SilentlyContinue
if ($connection) { if ($connection) {
$pidNum = $connection.OwningProcess $pidNum = $connection.OwningProcess
Write-Host " -> Killing process $pidNum on port $port" -ForegroundColor Yellow Write-Host " -> Killing process $pidNum on port $port" -ForegroundColor Yellow
Stop-Process -Id $pidNum -Force -ErrorAction SilentlyContinue Stop-Process -Id $pidNum -Force -ErrorAction SilentlyContinue
} else { } else {
Write-Host " -> Port $port is free." -ForegroundColor Green Write-Host " -> Port $port is free." -ForegroundColor Green
} }
} }
# 1. Cleanup # 1. Cleanup
Write-Host "`n[1/4] Cleaning up existing processes..." -ForegroundColor White Write-Host "`n[1/4] Cleaning up existing processes..." -ForegroundColor White
Kill-Port $BackendPort Kill-Port $BackendPort
Kill-Port $FrontendPort Kill-Port $FrontendPort
# 2. Start Backend # 2. Start Backend
Write-Host "`n[2/4] Starting Backend (Go)..." -ForegroundColor White 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 $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 Write-Host " -> Backend started (PID: $($backendProcess.Id))" -ForegroundColor Green
# 3. Start Frontend # 3. Start Frontend
Write-Host "`n[3/4] Starting Frontend (Vite)..." -ForegroundColor White Write-Host "`n[3/4] Starting Frontend (Vite)..." -ForegroundColor White
# Use npm.cmd for Windows compatibility # Use npm.cmd for Windows compatibility
$frontendProcess = Start-Process -FilePath "npm.cmd" -ArgumentList "run dev" -WorkingDirectory "$PSScriptRoot\frontend-react" -PassThru -NoNewWindow:$false $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 Write-Host " -> Frontend started (PID: $($frontendProcess.Id))" -ForegroundColor Green
# 4. Launch Browser # 4. Launch Browser
Write-Host "`n[4/4] Waiting for services..." -ForegroundColor White Write-Host "`n[4/4] Waiting for services..." -ForegroundColor White
for ($i = 5; $i -gt 0; $i--) { for ($i = 5; $i -gt 0; $i--) {
Write-Host " -> Launching in $i seconds..." -NoNewline Write-Host " -> Launching in $i seconds..." -NoNewline
Start-Sleep -Seconds 1 Start-Sleep -Seconds 1
Write-Host "`r" -NoNewline Write-Host "`r" -NoNewline
} }
Write-Host "`n -> Opening http://localhost:$FrontendPort" -ForegroundColor Cyan Write-Host "`n -> Opening http://localhost:$FrontendPort" -ForegroundColor Cyan
Start-Process "http://localhost:$FrontendPort" Start-Process "http://localhost:$FrontendPort"
Write-Host "`nAll systems go! Close the pop-up windows to stop the servers." -ForegroundColor Magenta Write-Host "`nAll systems go! Close the pop-up windows to stop the servers." -ForegroundColor Magenta
Start-Sleep -Seconds 3 Start-Sleep -Seconds 3