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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,35 +1,35 @@
> Task :app:checkKotlinGradlePluginConfigurationErrors
> Task :app:preBuild UP-TO-DATE
> Task :app:preDebugBuild UP-TO-DATE
> Task :app:checkDebugAarMetadata UP-TO-DATE
> Task :app:generateDebugResValues UP-TO-DATE
> Task :app:mapDebugSourceSetPaths UP-TO-DATE
> Task :app:generateDebugResources UP-TO-DATE
> Task :app:mergeDebugResources UP-TO-DATE
> Task :app:packageDebugResources UP-TO-DATE
> Task :app:parseDebugLocalResources UP-TO-DATE
> Task :app:createDebugCompatibleScreenManifests UP-TO-DATE
> Task :app:extractDeepLinksDebug UP-TO-DATE
> Task :app:processDebugMainManifest UP-TO-DATE
> Task :app:processDebugManifest UP-TO-DATE
> Task :app:processDebugManifestForPackage UP-TO-DATE
> Task :app:processDebugResources UP-TO-DATE
> Task :app:compileDebugKotlin FAILED
e: file:///C:/Users/Admin/Documents/Streamflow/android-tv/app/src/main/java/com/streamflow/tv/ui/screens/DetailScreen.kt:120:56 Unresolved reference: accent
FAILURE: Build failed with an exception.
* What went wrong:
Execution failed for task ':app:compileDebugKotlin'.
> A failure occurred while executing org.jetbrains.kotlin.compilerRunner.GradleCompilerRunnerWithWorkers$GradleKotlinCompilerWorkAction
> Compilation error. See log for more details
* Try:
> Run with --stacktrace option to get the stack trace.
> Run with --info or --debug option to get more log output.
> Run with --scan to get full insights.
> Get more help at https://help.gradle.org.
BUILD FAILED in 3s
14 actionable tasks: 2 executed, 12 up-to-date
> Task :app:checkKotlinGradlePluginConfigurationErrors
> Task :app:preBuild UP-TO-DATE
> Task :app:preDebugBuild UP-TO-DATE
> Task :app:checkDebugAarMetadata UP-TO-DATE
> Task :app:generateDebugResValues UP-TO-DATE
> Task :app:mapDebugSourceSetPaths UP-TO-DATE
> Task :app:generateDebugResources UP-TO-DATE
> Task :app:mergeDebugResources UP-TO-DATE
> Task :app:packageDebugResources UP-TO-DATE
> Task :app:parseDebugLocalResources UP-TO-DATE
> Task :app:createDebugCompatibleScreenManifests UP-TO-DATE
> Task :app:extractDeepLinksDebug UP-TO-DATE
> Task :app:processDebugMainManifest UP-TO-DATE
> Task :app:processDebugManifest UP-TO-DATE
> Task :app:processDebugManifestForPackage UP-TO-DATE
> Task :app:processDebugResources UP-TO-DATE
> Task :app:compileDebugKotlin FAILED
e: file:///C:/Users/Admin/Documents/Streamflow/android-tv/app/src/main/java/com/streamflow/tv/ui/screens/DetailScreen.kt:120:56 Unresolved reference: accent
FAILURE: Build failed with an exception.
* What went wrong:
Execution failed for task ':app:compileDebugKotlin'.
> A failure occurred while executing org.jetbrains.kotlin.compilerRunner.GradleCompilerRunnerWithWorkers$GradleKotlinCompilerWorkAction
> Compilation error. See log for more details
* Try:
> Run with --stacktrace option to get the stack trace.
> Run with --info or --debug option to get more log output.
> Run with --scan to get full insights.
> Get more help at https://help.gradle.org.
BUILD FAILED in 3s
14 actionable tasks: 2 executed, 12 up-to-date

View file

@ -1,35 +1,35 @@
> Task :app:checkKotlinGradlePluginConfigurationErrors
> Task :app:preBuild UP-TO-DATE
> Task :app:preDebugBuild UP-TO-DATE
> Task :app:checkDebugAarMetadata UP-TO-DATE
> Task :app:generateDebugResValues UP-TO-DATE
> Task :app:mapDebugSourceSetPaths UP-TO-DATE
> Task :app:generateDebugResources UP-TO-DATE
> Task :app:mergeDebugResources UP-TO-DATE
> Task :app:packageDebugResources UP-TO-DATE
> Task :app:parseDebugLocalResources UP-TO-DATE
> Task :app:createDebugCompatibleScreenManifests UP-TO-DATE
> Task :app:extractDeepLinksDebug UP-TO-DATE
> Task :app:processDebugMainManifest UP-TO-DATE
> Task :app:processDebugManifest UP-TO-DATE
> Task :app:processDebugManifestForPackage UP-TO-DATE
> Task :app:processDebugResources UP-TO-DATE
> Task :app:compileDebugKotlin FAILED
e: file:///C:/Users/Admin/Documents/Streamflow/android-tv/app/src/main/java/com/streamflow/tv/ui/screens/DetailScreen.kt:114:56 Unresolved reference: accent
FAILURE: Build failed with an exception.
* What went wrong:
Execution failed for task ':app:compileDebugKotlin'.
> A failure occurred while executing org.jetbrains.kotlin.compilerRunner.GradleCompilerRunnerWithWorkers$GradleKotlinCompilerWorkAction
> Compilation error. See log for more details
* Try:
> Run with --stacktrace option to get the stack trace.
> Run with --info or --debug option to get more log output.
> Run with --scan to get full insights.
> Get more help at https://help.gradle.org.
BUILD FAILED in 1s
14 actionable tasks: 2 executed, 12 up-to-date
> Task :app:checkKotlinGradlePluginConfigurationErrors
> Task :app:preBuild UP-TO-DATE
> Task :app:preDebugBuild UP-TO-DATE
> Task :app:checkDebugAarMetadata UP-TO-DATE
> Task :app:generateDebugResValues UP-TO-DATE
> Task :app:mapDebugSourceSetPaths UP-TO-DATE
> Task :app:generateDebugResources UP-TO-DATE
> Task :app:mergeDebugResources UP-TO-DATE
> Task :app:packageDebugResources UP-TO-DATE
> Task :app:parseDebugLocalResources UP-TO-DATE
> Task :app:createDebugCompatibleScreenManifests UP-TO-DATE
> Task :app:extractDeepLinksDebug UP-TO-DATE
> Task :app:processDebugMainManifest UP-TO-DATE
> Task :app:processDebugManifest UP-TO-DATE
> Task :app:processDebugManifestForPackage UP-TO-DATE
> Task :app:processDebugResources UP-TO-DATE
> Task :app:compileDebugKotlin FAILED
e: file:///C:/Users/Admin/Documents/Streamflow/android-tv/app/src/main/java/com/streamflow/tv/ui/screens/DetailScreen.kt:114:56 Unresolved reference: accent
FAILURE: Build failed with an exception.
* What went wrong:
Execution failed for task ':app:compileDebugKotlin'.
> A failure occurred while executing org.jetbrains.kotlin.compilerRunner.GradleCompilerRunnerWithWorkers$GradleKotlinCompilerWorkAction
> Compilation error. See log for more details
* Try:
> Run with --stacktrace option to get the stack trace.
> Run with --info or --debug option to get more log output.
> Run with --scan to get full insights.
> Get more help at https://help.gradle.org.
BUILD FAILED in 1s
14 actionable tasks: 2 executed, 12 up-to-date

View file

@ -1,37 +1,37 @@
> Task :app:checkKotlinGradlePluginConfigurationErrors
> Task :app:preBuild UP-TO-DATE
> Task :app:preDebugBuild UP-TO-DATE
> Task :app:checkDebugAarMetadata UP-TO-DATE
> Task :app:generateDebugResValues UP-TO-DATE
> Task :app:mapDebugSourceSetPaths UP-TO-DATE
> Task :app:generateDebugResources UP-TO-DATE
> Task :app:mergeDebugResources UP-TO-DATE
> Task :app:packageDebugResources UP-TO-DATE
> Task :app:parseDebugLocalResources UP-TO-DATE
> Task :app:createDebugCompatibleScreenManifests UP-TO-DATE
> Task :app:extractDeepLinksDebug UP-TO-DATE
> Task :app:processDebugMainManifest UP-TO-DATE
> Task :app:processDebugManifest UP-TO-DATE
> Task :app:processDebugManifestForPackage UP-TO-DATE
> Task :app:processDebugResources UP-TO-DATE
> Task :app:compileDebugKotlin FAILED
e: file:///C:/Users/Admin/Documents/Streamflow/android-tv/app/src/main/java/com/streamflow/tv/ui/screens/DetailScreen.kt:140:21 Cannot find a parameter with this name: onEpisodeClick
e: file:///C:/Users/Admin/Documents/Streamflow/android-tv/app/src/main/java/com/streamflow/tv/ui/screens/DetailScreen.kt:140:40 Cannot infer a type for this parameter. Please specify it explicitly.
e: file:///C:/Users/Admin/Documents/Streamflow/android-tv/app/src/main/java/com/streamflow/tv/ui/screens/DetailScreen.kt:141:21 No value passed for parameter 'onEpisodeSelect'
FAILURE: Build failed with an exception.
* What went wrong:
Execution failed for task ':app:compileDebugKotlin'.
> A failure occurred while executing org.jetbrains.kotlin.compilerRunner.GradleCompilerRunnerWithWorkers$GradleKotlinCompilerWorkAction
> Compilation error. See log for more details
* Try:
> Run with --stacktrace option to get the stack trace.
> Run with --info or --debug option to get more log output.
> Run with --scan to get full insights.
> Get more help at https://help.gradle.org.
BUILD FAILED in 1s
14 actionable tasks: 2 executed, 12 up-to-date
> Task :app:checkKotlinGradlePluginConfigurationErrors
> Task :app:preBuild UP-TO-DATE
> Task :app:preDebugBuild UP-TO-DATE
> Task :app:checkDebugAarMetadata UP-TO-DATE
> Task :app:generateDebugResValues UP-TO-DATE
> Task :app:mapDebugSourceSetPaths UP-TO-DATE
> Task :app:generateDebugResources UP-TO-DATE
> Task :app:mergeDebugResources UP-TO-DATE
> Task :app:packageDebugResources UP-TO-DATE
> Task :app:parseDebugLocalResources UP-TO-DATE
> Task :app:createDebugCompatibleScreenManifests UP-TO-DATE
> Task :app:extractDeepLinksDebug UP-TO-DATE
> Task :app:processDebugMainManifest UP-TO-DATE
> Task :app:processDebugManifest UP-TO-DATE
> Task :app:processDebugManifestForPackage UP-TO-DATE
> Task :app:processDebugResources UP-TO-DATE
> Task :app:compileDebugKotlin FAILED
e: file:///C:/Users/Admin/Documents/Streamflow/android-tv/app/src/main/java/com/streamflow/tv/ui/screens/DetailScreen.kt:140:21 Cannot find a parameter with this name: onEpisodeClick
e: file:///C:/Users/Admin/Documents/Streamflow/android-tv/app/src/main/java/com/streamflow/tv/ui/screens/DetailScreen.kt:140:40 Cannot infer a type for this parameter. Please specify it explicitly.
e: file:///C:/Users/Admin/Documents/Streamflow/android-tv/app/src/main/java/com/streamflow/tv/ui/screens/DetailScreen.kt:141:21 No value passed for parameter 'onEpisodeSelect'
FAILURE: Build failed with an exception.
* What went wrong:
Execution failed for task ':app:compileDebugKotlin'.
> A failure occurred while executing org.jetbrains.kotlin.compilerRunner.GradleCompilerRunnerWithWorkers$GradleKotlinCompilerWorkAction
> Compilation error. See log for more details
* Try:
> Run with --stacktrace option to get the stack trace.
> Run with --info or --debug option to get more log output.
> Run with --scan to get full insights.
> Get more help at https://help.gradle.org.
BUILD FAILED in 1s
14 actionable tasks: 2 executed, 12 up-to-date

View file

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

View file

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

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

188
android-tv/gradlew.bat vendored
View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -2,7 +2,7 @@ version: '3.8'
services:
streamflow:
image: git.khoavo.myds.me/vndangkhoa/kv-streamflow:v3.9.1
image: git.khoavo.myds.me/vndangkhoa/kv-streamflow:v3.9.2
container_name: streamflow
platform: linux/amd64
ports:

View file

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

View file

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

View file

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

View file

@ -1,89 +1,89 @@
import { Link } from 'react-router-dom';
import { Play } from 'lucide-react';
import type { Movie } from '../types';
interface MovieCardProps {
movie: Movie;
className?: string;
isDragging?: boolean;
}
export const MovieCard = ({ movie, className = '', isDragging = false }: MovieCardProps) => {
const getImageUrl = (url: string, width: number) => {
if (!url) return '';
const cleanUrl = url.replace('img.ophim1.com', 'ssl:img.ophim1.com');
return `https://wsrv.nl/?url=${encodeURIComponent(cleanUrl.replace(/^https?:\/\//, ''))}&w=${width}&output=webp`;
};
return (
<div className={`group/card relative flex flex-col h-full ${className}`}>
{/* Poster Image Container */}
<Link
to={`/watch/${movie.slug}`}
className={`block relative aspect-[2/3] rounded-xl overflow-hidden bg-[#1a1a1a] shadow-lg transition-all duration-500 hover:shadow-cyan-500/10 ${isDragging ? 'pointer-events-none' : ''
}`}
draggable={false}
>
<img
src={getImageUrl(movie.thumbnail, 400)}
alt={movie.title}
className="w-full h-full object-cover transition-transform duration-700 group-hover/card:scale-110"
loading="lazy"
draggable={false}
/>
{/* Hover Overlay */}
<div className="absolute inset-0 bg-gradient-to-t from-black/90 via-black/20 to-transparent opacity-0 group-hover/card:opacity-100 transition-all duration-500 flex items-center justify-center">
<div className="bg-white/20 backdrop-blur-md p-4 rounded-full translate-y-8 group-hover/card:translate-y-0 transition-all duration-500 shadow-xl border border-white/10">
<Play className="w-6 h-6 text-white fill-current" />
</div>
</div>
{/* Top-Left Tag (Provider) */}
{movie.provider && (
<div className="absolute top-2 left-2">
<div className="bg-black/60 backdrop-blur-md px-1.5 py-0.5 rounded text-[9px] font-bold border border-white/10 text-gray-300 uppercase tracking-tighter">
{movie.provider}
</div>
</div>
)}
{/* Top-Right Tags (Quality & Lang) */}
<div className="absolute top-2 right-2 flex flex-col gap-1.5 items-end">
{movie.quality && (
<div className="bg-cyan-500/90 backdrop-blur-md px-1.5 py-0.5 rounded text-[10px] font-bold text-black uppercase tracking-wider shadow-lg border border-cyan-400/20">
{movie.quality}
</div>
)}
{movie.lang && (
<div className="bg-black/60 backdrop-blur-md px-1.5 py-0.5 rounded text-[10px] font-bold border border-white/10 text-gray-200">
{movie.lang}
</div>
)}
</div>
{/* Bottom Status (Time / Episode Info) */}
{movie.time && (
<div className="absolute bottom-2 left-2 right-2 flex items-center gap-2">
<div className="bg-black/70 backdrop-blur-xl px-2 py-1 rounded-md text-[10px] font-semibold border border-white/10 text-white flex items-center gap-1.5 shadow-2xl">
<span className="w-1.5 h-1.5 bg-red-500 rounded-full animate-pulse shadow-[0_0_5px_rgba(239,68,68,0.8)]"></span>
{movie.time}
</div>
</div>
)}
</Link>
{/* Info Section */}
<div className="mt-3 px-1">
<h3 className="font-semibold text-white text-sm leading-snug line-clamp-2 group-hover/card:text-cyan-400 transition-colors duration-300">
{movie.title}
</h3>
{movie.year && (
<p className="text-[11px] text-gray-500 mt-1 font-medium tracking-wide translate-y-0 opacity-100 transition-all">
{movie.year} 98% Match
</p>
)}
</div>
</div>
);
};
import { Link } from 'react-router-dom';
import { Play } from 'lucide-react';
import type { Movie } from '../types';
interface MovieCardProps {
movie: Movie;
className?: string;
isDragging?: boolean;
}
export const MovieCard = ({ movie, className = '', isDragging = false }: MovieCardProps) => {
const getImageUrl = (url: string, width: number) => {
if (!url) return '';
const cleanUrl = url.replace('img.ophim1.com', 'ssl:img.ophim1.com');
return `https://wsrv.nl/?url=${encodeURIComponent(cleanUrl.replace(/^https?:\/\//, ''))}&w=${width}&output=webp`;
};
return (
<div className={`group/card relative flex flex-col h-full ${className}`}>
{/* Poster Image Container */}
<Link
to={`/watch/${movie.slug}`}
className={`block relative aspect-[2/3] rounded-xl overflow-hidden bg-[#1a1a1a] shadow-lg transition-all duration-500 hover:shadow-cyan-500/10 ${isDragging ? 'pointer-events-none' : ''
}`}
draggable={false}
>
<img
src={getImageUrl(movie.thumbnail, 400)}
alt={movie.title}
className="w-full h-full object-cover transition-transform duration-700 group-hover/card:scale-110"
loading="lazy"
draggable={false}
/>
{/* Hover Overlay */}
<div className="absolute inset-0 bg-gradient-to-t from-black/90 via-black/20 to-transparent opacity-0 group-hover/card:opacity-100 transition-all duration-500 flex items-center justify-center">
<div className="bg-white/20 backdrop-blur-md p-4 rounded-full translate-y-8 group-hover/card:translate-y-0 transition-all duration-500 shadow-xl border border-white/10">
<Play className="w-6 h-6 text-white fill-current" />
</div>
</div>
{/* Top-Left Tag (Provider) */}
{movie.provider && (
<div className="absolute top-2 left-2">
<div className="bg-black/60 backdrop-blur-md px-1.5 py-0.5 rounded text-[9px] font-bold border border-white/10 text-gray-300 uppercase tracking-tighter">
{movie.provider}
</div>
</div>
)}
{/* Top-Right Tags (Quality & Lang) */}
<div className="absolute top-2 right-2 flex flex-col gap-1.5 items-end">
{movie.quality && (
<div className="bg-cyan-500/90 backdrop-blur-md px-1.5 py-0.5 rounded text-[10px] font-bold text-black uppercase tracking-wider shadow-lg border border-cyan-400/20">
{movie.quality}
</div>
)}
{movie.lang && (
<div className="bg-black/60 backdrop-blur-md px-1.5 py-0.5 rounded text-[10px] font-bold border border-white/10 text-gray-200">
{movie.lang}
</div>
)}
</div>
{/* Bottom Status (Time / Episode Info) */}
{movie.time && (
<div className="absolute bottom-2 left-2 right-2 flex items-center gap-2">
<div className="bg-black/70 backdrop-blur-xl px-2 py-1 rounded-md text-[10px] font-semibold border border-white/10 text-white flex items-center gap-1.5 shadow-2xl">
<span className="w-1.5 h-1.5 bg-red-500 rounded-full animate-pulse shadow-[0_0_5px_rgba(239,68,68,0.8)]"></span>
{movie.time}
</div>
</div>
)}
</Link>
{/* Info Section */}
<div className="mt-3 px-1">
<h3 className="font-semibold text-white text-sm leading-snug line-clamp-2 group-hover/card:text-cyan-400 transition-colors duration-300">
{movie.title}
</h3>
{movie.year && (
<p className="text-[11px] text-gray-500 mt-1 font-medium tracking-wide translate-y-0 opacity-100 transition-all">
{movie.year} 98% Match
</p>
)}
</div>
</div>
);
};

View file

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

View file

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

View file

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

View file

@ -1,14 +1,14 @@
import { Home, Film, Tv, PlayCircle, Heart, Folder } from 'lucide-react';
export const CATEGORIES = [
{ id: 'phim-le', name: 'Phim Lẻ', path: '?category=phim-le', icon: Film },
{ id: 'phim-bo', name: 'Phim Bộ', path: '?category=phim-bo', icon: Tv },
{ id: 'hoat-hinh', name: 'Hoạt Hình', path: '?category=hoat-hinh', icon: PlayCircle },
{ id: 'tv-shows', name: 'TV Shows', path: '?category=tv-shows', icon: Folder },
{ id: 'my-list', name: 'My List', path: '/my-list', icon: Heart },
];
export const NAV_ITEMS = [
{ name: 'Home', path: '/', icon: Home },
...CATEGORIES.map(cat => ({ name: cat.name, path: cat.path, icon: cat.icon })),
];
import { Home, Film, Tv, PlayCircle, Heart, Folder } from 'lucide-react';
export const CATEGORIES = [
{ id: 'phim-le', name: 'Phim Lẻ', path: '?category=phim-le', icon: Film },
{ id: 'phim-bo', name: 'Phim Bộ', path: '?category=phim-bo', icon: Tv },
{ id: 'hoat-hinh', name: 'Hoạt Hình', path: '?category=hoat-hinh', icon: PlayCircle },
{ id: 'tv-shows', name: 'TV Shows', path: '?category=tv-shows', icon: Folder },
{ id: 'my-list', name: 'My List', path: '/my-list', icon: Heart },
];
export const NAV_ITEMS = [
{ name: 'Home', path: '/', icon: Home },
...CATEGORIES.map(cat => ({ name: cat.name, path: cat.path, icon: cat.icon })),
];

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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