diff --git a/Dockerfile b/Dockerfile index bcff6a8..9f10120 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,49 +1,49 @@ -# Stage 1: Build Image (Frontend) -FROM node:20-alpine AS frontend-builder -WORKDIR /app/frontend -COPY frontend-react/package*.json ./ -RUN npm install -COPY frontend-react/ . -RUN npm run build - -# Stage 2: Build Image (Backend) -FROM golang:1.24-alpine AS backend-builder -WORKDIR /app/backend -# Install build dependencies -RUN apk add --no-cache gcc musl-dev - -COPY backend/go.mod backend/go.sum ./ -RUN go mod download - -COPY backend/ . -# Build static binary for Linux amd64 -RUN CGO_ENABLED=1 GOOS=linux GOARCH=amd64 go build -ldflags="-w -s" -o server cmd/server/main.go - -# Stage 3: Final Image -FROM alpine:latest -WORKDIR /app - -# Install runtime dependencies (sqlite + yt-dlp for video extraction fallback) -RUN apk add --no-cache sqlite ca-certificates tzdata python3 py3-pip && \ - pip3 install --break-system-packages yt-dlp - -# Copy backend binary -COPY --from=backend-builder /app/backend/server . - -# Copy frontend build to the expected static directory -# The backend expects ../frontend-react/dist relative to itself, or we configure it. -# Let's align with the standard deployment structure: /app/server and /app/dist -COPY --from=frontend-builder /app/frontend/dist ./dist - -# Create data directory -RUN mkdir -p data - -# Environment variables -ENV PORT=8000 -ENV DATABASE_URL=/app/data/streamflow.db - -# Expose port -EXPOSE 8000 - -# Start server -CMD ["./server"] +# Stage 1: Build Image (Frontend) +FROM node:20-alpine AS frontend-builder +WORKDIR /app/frontend +COPY frontend-react/package*.json ./ +RUN npm install +COPY frontend-react/ . +RUN npm run build + +# Stage 2: Build Image (Backend) +FROM --platform=$BUILDPLATFORM golang:1.24-alpine AS backend-builder +WORKDIR /app/backend + +ARG TARGETOS TARGETARCH + +COPY backend/go.mod backend/go.sum ./ +RUN go mod download + +COPY backend/ . +# Build static binary for Linux amd64 +RUN CGO_ENABLED=0 GOOS=$TARGETOS GOARCH=$TARGETARCH go build -ldflags="-w -s" -o server cmd/server/main.go + +# Stage 3: Final Image +FROM alpine:latest +WORKDIR /app + +# Install runtime dependencies +RUN apk add --no-cache sqlite ca-certificates tzdata python3 py3-pip +RUN pip3 install --break-system-packages --ignore-installed yt-dlp || true + +# Copy backend binary +COPY --from=backend-builder /app/backend/server . + +# Copy frontend build to the expected static directory +COPY --from=frontend-builder /app/frontend/dist ./dist + + + +# Create data directory +RUN mkdir -p data + +# Environment variables +ENV PORT=8000 +ENV DATABASE_URL=/app/data/streamflow.db + +# Expose port +EXPOSE 8000 + +# Start server +CMD ["./server"] diff --git a/android-tv/app/build.gradle.kts b/android-tv/app/build.gradle.kts index 9645c44..b2a719f 100644 --- a/android-tv/app/build.gradle.kts +++ b/android-tv/app/build.gradle.kts @@ -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") +} diff --git a/android-tv/app/proguard-rules.pro b/android-tv/app/proguard-rules.pro index 65d0b4d..c7c89ee 100644 --- a/android-tv/app/proguard-rules.pro +++ b/android-tv/app/proguard-rules.pro @@ -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 ; -} --keepclassmembers class * { - @com.squareup.moshi.JsonClass ; -} - -# 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.* ; -} - -# 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 ; -} - -# 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 ; +} +-keepclassmembers class * { + @com.squareup.moshi.JsonClass ; +} + +# 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.* ; +} + +# 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 ; +} + +# Coil +-dontwarn coil.** +-keep class coil.** { *; } + +# AndroidX Compose +-keep class androidx.compose.** { *; } +-dontwarn androidx.compose.** + +# ExoPlayer / Media3 +-keep class androidx.media3.** { *; } +-dontwarn androidx.media3.** diff --git a/android-tv/app/src/main/AndroidManifest.xml b/android-tv/app/src/main/AndroidManifest.xml index bd82ea6..476b8f4 100644 --- a/android-tv/app/src/main/AndroidManifest.xml +++ b/android-tv/app/src/main/AndroidManifest.xml @@ -1,43 +1,43 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android-tv/app/src/main/java/com/streamflow/tv/MainActivity.kt b/android-tv/app/src/main/java/com/streamflow/tv/MainActivity.kt index f915bc2..ff36346 100644 --- a/android-tv/app/src/main/java/com/streamflow/tv/MainActivity.kt +++ b/android-tv/app/src/main/java/com/streamflow/tv/MainActivity.kt @@ -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) } + } + ) + } + } + } + } + } +} diff --git a/android-tv/app/src/main/java/com/streamflow/tv/StreamFlowApp.kt b/android-tv/app/src/main/java/com/streamflow/tv/StreamFlowApp.kt index 481d8d6..caf82d8 100644 --- a/android-tv/app/src/main/java/com/streamflow/tv/StreamFlowApp.kt +++ b/android-tv/app/src/main/java/com/streamflow/tv/StreamFlowApp.kt @@ -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() + } +} diff --git a/android-tv/app/src/main/java/com/streamflow/tv/data/api/ApiClient.kt b/android-tv/app/src/main/java/com/streamflow/tv/data/api/ApiClient.kt index 7313100..8599a67 100644 --- a/android-tv/app/src/main/java/com/streamflow/tv/data/api/ApiClient.kt +++ b/android-tv/app/src/main/java/com/streamflow/tv/data/api/ApiClient.kt @@ -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" + } +} diff --git a/android-tv/app/src/main/java/com/streamflow/tv/data/api/StreamFlowApi.kt b/android-tv/app/src/main/java/com/streamflow/tv/data/api/StreamFlowApi.kt index d3f27b0..8828c4c 100644 --- a/android-tv/app/src/main/java/com/streamflow/tv/data/api/StreamFlowApi.kt +++ b/android-tv/app/src/main/java/com/streamflow/tv/data/api/StreamFlowApi.kt @@ -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 - - @GET("api/videos/search") - suspend fun searchVideos( - @Query("q") query: String, - @Query("page") page: Int = 1 - ): List - - @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 - - @GET("api/categories/countries") - suspend fun getCountries(): List -} +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 + + @GET("api/videos/search") + suspend fun searchVideos( + @Query("q") query: String, + @Query("page") page: Int = 1 + ): List + + @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 + + @GET("api/categories/countries") + suspend fun getCountries(): List +} diff --git a/android-tv/app/src/main/java/com/streamflow/tv/data/model/Models.kt b/android-tv/app/src/main/java/com/streamflow/tv/data/model/Models.kt index 0f93641..52bfeab 100644 --- a/android-tv/app/src/main/java/com/streamflow/tv/data/model/Models.kt +++ b/android-tv/app/src/main/java/com/streamflow/tv/data/model/Models.kt @@ -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? = 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? = null, - val provider: String? = null, - val episodes: List? = 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 = 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? = null, - val episodes: List? = 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? = 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? = null, + val provider: String? = null, + val episodes: List? = 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 = 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? = null, + val episodes: List? = null +) + diff --git a/android-tv/app/src/main/java/com/streamflow/tv/data/repository/MovieRepository.kt b/android-tv/app/src/main/java/com/streamflow/tv/data/repository/MovieRepository.kt index 9962934..32d2b72 100644 --- a/android-tv/app/src/main/java/com/streamflow/tv/data/repository/MovieRepository.kt +++ b/android-tv/app/src/main/java/com/streamflow/tv/data/repository/MovieRepository.kt @@ -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 { - return api.getGenres() - } - - suspend fun getCountries(): List { - 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 { + return api.getGenres() + } + + suspend fun getCountries(): List { + return api.getCountries() + } +} diff --git a/android-tv/app/src/main/java/com/streamflow/tv/data/repository/UserDataRepository.kt b/android-tv/app/src/main/java/com/streamflow/tv/data/repository/UserDataRepository.kt index 18632a9..35e0fcf 100644 --- a/android-tv/app/src/main/java/com/streamflow/tv/data/repository/UserDataRepository.kt +++ b/android-tv/app/src/main/java/com/streamflow/tv/data/repository/UserDataRepository.kt @@ -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 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>(movieListType) - - // --- My List --- - - val myList: Flow> = 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> = 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 = 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 = 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 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>(movieListType) + + // --- My List --- + + val myList: Flow> = 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> = 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 = 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 = 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 + } + } +} diff --git a/android-tv/app/src/main/java/com/streamflow/tv/ui/components/EpisodeSelector.kt b/android-tv/app/src/main/java/com/streamflow/tv/ui/components/EpisodeSelector.kt index 4798db7..a8440c3 100644 --- a/android-tv/app/src/main/java/com/streamflow/tv/ui/components/EpisodeSelector.kt +++ b/android-tv/app/src/main/java/com/streamflow/tv/ui/components/EpisodeSelector.kt @@ -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, - 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, + 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 + ) + ) + } + } + } + } + } +} diff --git a/android-tv/app/src/main/java/com/streamflow/tv/ui/components/HeroBanner.kt b/android-tv/app/src/main/java/com/streamflow/tv/ui/components/HeroBanner.kt index 0d09aa3..a1c0406 100644 --- a/android-tv/app/src/main/java/com/streamflow/tv/ui/components/HeroBanner.kt +++ b/android-tv/app/src/main/java/com/streamflow/tv/ui/components/HeroBanner.kt @@ -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, - 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, + 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) + ) + ) + } + } + } +} diff --git a/android-tv/app/src/main/java/com/streamflow/tv/ui/components/MovieCard.kt b/android-tv/app/src/main/java/com/streamflow/tv/ui/components/MovieCard.kt index c5ff8d6..c359dee 100644 --- a/android-tv/app/src/main/java/com/streamflow/tv/ui/components/MovieCard.kt +++ b/android-tv/app/src/main/java/com/streamflow/tv/ui/components/MovieCard.kt @@ -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) + ) + ) + } + } + } + } +} diff --git a/android-tv/app/src/main/java/com/streamflow/tv/ui/components/MovieRow.kt b/android-tv/app/src/main/java/com/streamflow/tv/ui/components/MovieRow.kt index b5c00c8..cb545a3 100644 --- a/android-tv/app/src/main/java/com/streamflow/tv/ui/components/MovieRow.kt +++ b/android-tv/app/src/main/java/com/streamflow/tv/ui/components/MovieRow.kt @@ -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, - 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, + 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) } + ) + } + } + } +} diff --git a/android-tv/app/src/main/java/com/streamflow/tv/ui/components/SideNavRail.kt b/android-tv/app/src/main/java/com/streamflow/tv/ui/components/SideNavRail.kt index 7f6618e..03bf3d3 100644 --- a/android-tv/app/src/main/java/com/streamflow/tv/ui/components/SideNavRail.kt +++ b/android-tv/app/src/main/java/com/streamflow/tv/ui/components/SideNavRail.kt @@ -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) + ) + } + } +} diff --git a/android-tv/app/src/main/java/com/streamflow/tv/ui/navigation/AppNavigation.kt b/android-tv/app/src/main/java/com/streamflow/tv/ui/navigation/AppNavigation.kt index 9e0f1d6..705538b 100644 --- a/android-tv/app/src/main/java/com/streamflow/tv/ui/navigation/AppNavigation.kt +++ b/android-tv/app/src/main/java/com/streamflow/tv/ui/navigation/AppNavigation.kt @@ -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 { - 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 { + error("NavController not provided") +} diff --git a/android-tv/app/src/main/java/com/streamflow/tv/ui/screens/DetailScreen.kt b/android-tv/app/src/main/java/com/streamflow/tv/ui/screens/DetailScreen.kt index 5975b73..f3129c1 100644 --- a/android-tv/app/src/main/java/com/streamflow/tv/ui/screens/DetailScreen.kt +++ b/android-tv/app/src/main/java/com/streamflow/tv/ui/screens/DetailScreen.kt @@ -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) + ) + } + } +} diff --git a/android-tv/app/src/main/java/com/streamflow/tv/ui/screens/HomeScreen.kt b/android-tv/app/src/main/java/com/streamflow/tv/ui/screens/HomeScreen.kt index 79a1bdd..d6cbe6f 100644 --- a/android-tv/app/src/main/java/com/streamflow/tv/ui/screens/HomeScreen.kt +++ b/android-tv/app/src/main/java/com/streamflow/tv/ui/screens/HomeScreen.kt @@ -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) } + ) + } + } + } + } + } + } +} diff --git a/android-tv/app/src/main/java/com/streamflow/tv/ui/screens/MyListScreen.kt b/android-tv/app/src/main/java/com/streamflow/tv/ui/screens/MyListScreen.kt index 8df14c0..f97e03f 100644 --- a/android-tv/app/src/main/java/com/streamflow/tv/ui/screens/MyListScreen.kt +++ b/android-tv/app/src/main/java/com/streamflow/tv/ui/screens/MyListScreen.kt @@ -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) }) + } + } + } + } + } +} diff --git a/android-tv/app/src/main/java/com/streamflow/tv/ui/screens/PlayerScreen.kt b/android-tv/app/src/main/java/com/streamflow/tv/ui/screens/PlayerScreen.kt index e1ed378..971a946 100644 --- a/android-tv/app/src/main/java/com/streamflow/tv/ui/screens/PlayerScreen.kt +++ b/android-tv/app/src/main/java/com/streamflow/tv/ui/screens/PlayerScreen.kt @@ -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(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(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) + ) + } + } + } +} diff --git a/android-tv/app/src/main/java/com/streamflow/tv/ui/screens/SearchScreen.kt b/android-tv/app/src/main/java/com/streamflow/tv/ui/screens/SearchScreen.kt index 8e90a41..d8031e5 100644 --- a/android-tv/app/src/main/java/com/streamflow/tv/ui/screens/SearchScreen.kt +++ b/android-tv/app/src/main/java/com/streamflow/tv/ui/screens/SearchScreen.kt @@ -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) + ) + } + } + } + } + } +} diff --git a/android-tv/app/src/main/java/com/streamflow/tv/ui/screens/SettingsScreen.kt b/android-tv/app/src/main/java/com/streamflow/tv/ui/screens/SettingsScreen.kt index 7750d8f..db531e4 100644 --- a/android-tv/app/src/main/java/com/streamflow/tv/ui/screens/SettingsScreen.kt +++ b/android-tv/app/src/main/java/com/streamflow/tv/ui/screens/SettingsScreen.kt @@ -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) + ) + } +} diff --git a/android-tv/app/src/main/java/com/streamflow/tv/ui/theme/Color.kt b/android-tv/app/src/main/java/com/streamflow/tv/ui/theme/Color.kt index 3b3f8d1..d9389c9 100644 --- a/android-tv/app/src/main/java/com/streamflow/tv/ui/theme/Color.kt +++ b/android-tv/app/src/main/java/com/streamflow/tv/ui/theme/Color.kt @@ -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) diff --git a/android-tv/app/src/main/java/com/streamflow/tv/ui/theme/Theme.kt b/android-tv/app/src/main/java/com/streamflow/tv/ui/theme/Theme.kt index 4701b4e..efda086 100644 --- a/android-tv/app/src/main/java/com/streamflow/tv/ui/theme/Theme.kt +++ b/android-tv/app/src/main/java/com/streamflow/tv/ui/theme/Theme.kt @@ -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 + ) + } +} diff --git a/android-tv/app/src/main/java/com/streamflow/tv/ui/theme/Typography.kt b/android-tv/app/src/main/java/com/streamflow/tv/ui/theme/Typography.kt index db0b897..91b1d39 100644 --- a/android-tv/app/src/main/java/com/streamflow/tv/ui/theme/Typography.kt +++ b/android-tv/app/src/main/java/com/streamflow/tv/ui/theme/Typography.kt @@ -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 + ) +} diff --git a/android-tv/app/src/main/java/com/streamflow/tv/viewmodel/DetailViewModel.kt b/android-tv/app/src/main/java/com/streamflow/tv/viewmodel/DetailViewModel.kt index 5d24019..0e4bbe9 100644 --- a/android-tv/app/src/main/java/com/streamflow/tv/viewmodel/DetailViewModel.kt +++ b/android-tv/app/src/main/java/com/streamflow/tv/viewmodel/DetailViewModel.kt @@ -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 = _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 = _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) + } +} diff --git a/android-tv/app/src/main/java/com/streamflow/tv/viewmodel/HomeViewModel.kt b/android-tv/app/src/main/java/com/streamflow/tv/viewmodel/HomeViewModel.kt index 46e36cf..6a9dd4f 100644 --- a/android-tv/app/src/main/java/com/streamflow/tv/viewmodel/HomeViewModel.kt +++ b/android-tv/app/src/main/java/com/streamflow/tv/viewmodel/HomeViewModel.kt @@ -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 = emptyList(), - val watchedMovies: List = emptyList(), - val recommendedMovies: List = emptyList(), - val categoryMovies: Map> = 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 = _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>()) - val allFlattened = java.util.Collections.synchronizedList(mutableListOf()) - - 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() } - } - } - - // 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 = emptyList(), + val watchedMovies: List = emptyList(), + val recommendedMovies: List = emptyList(), + val categoryMovies: Map> = 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 = _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>()) + val allFlattened = java.util.Collections.synchronizedList(mutableListOf()) + + 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() } + } + } + + // 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" + ) + } + } + } +} diff --git a/android-tv/app/src/main/java/com/streamflow/tv/viewmodel/MyListViewModel.kt b/android-tv/app/src/main/java/com/streamflow/tv/viewmodel/MyListViewModel.kt index a393a49..ad23c27 100644 --- a/android-tv/app/src/main/java/com/streamflow/tv/viewmodel/MyListViewModel.kt +++ b/android-tv/app/src/main/java/com/streamflow/tv/viewmodel/MyListViewModel.kt @@ -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 = emptyList(), - val watchHistory: List = emptyList() -) - -class MyListViewModel(application: Application) : AndroidViewModel(application) { - - private val userRepo = UserDataRepository(application) - private val _uiState = MutableStateFlow(MyListUiState()) - val uiState: StateFlow = _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 = emptyList(), + val watchHistory: List = emptyList() +) + +class MyListViewModel(application: Application) : AndroidViewModel(application) { + + private val userRepo = UserDataRepository(application) + private val _uiState = MutableStateFlow(MyListUiState()) + val uiState: StateFlow = _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) } + } +} diff --git a/android-tv/app/src/main/java/com/streamflow/tv/viewmodel/PlayerViewModel.kt b/android-tv/app/src/main/java/com/streamflow/tv/viewmodel/PlayerViewModel.kt index 6e552de..c5cf7f3 100644 --- a/android-tv/app/src/main/java/com/streamflow/tv/viewmodel/PlayerViewModel.kt +++ b/android-tv/app/src/main/java/com/streamflow/tv/viewmodel/PlayerViewModel.kt @@ -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 = _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 = _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" + ) + } + } +} diff --git a/android-tv/app/src/main/java/com/streamflow/tv/viewmodel/SearchViewModel.kt b/android-tv/app/src/main/java/com/streamflow/tv/viewmodel/SearchViewModel.kt index 7086fa9..be13698 100644 --- a/android-tv/app/src/main/java/com/streamflow/tv/viewmodel/SearchViewModel.kt +++ b/android-tv/app/src/main/java/com/streamflow/tv/viewmodel/SearchViewModel.kt @@ -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 = emptyList(), - val isLoading: Boolean = false, - val hasSearched: Boolean = false -) - -class SearchViewModel : ViewModel() { - - private val repository = MovieRepository() - private val _uiState = MutableStateFlow(SearchUiState()) - val uiState: StateFlow = _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 = emptyList(), + val isLoading: Boolean = false, + val hasSearched: Boolean = false +) + +class SearchViewModel : ViewModel() { + + private val repository = MovieRepository() + private val _uiState = MutableStateFlow(SearchUiState()) + val uiState: StateFlow = _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) + } + } + } +} diff --git a/android-tv/app/src/main/res/drawable/app_banner.xml b/android-tv/app/src/main/res/drawable/app_banner.xml index f222f5f..f05e7a4 100644 --- a/android-tv/app/src/main/res/drawable/app_banner.xml +++ b/android-tv/app/src/main/res/drawable/app_banner.xml @@ -1,33 +1,33 @@ - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + diff --git a/android-tv/app/src/main/res/mipmap/ic_launcher.xml b/android-tv/app/src/main/res/mipmap/ic_launcher.xml index 3506d45..177c19c 100644 --- a/android-tv/app/src/main/res/mipmap/ic_launcher.xml +++ b/android-tv/app/src/main/res/mipmap/ic_launcher.xml @@ -1,17 +1,17 @@ - - - - - - - - - + + + + + + + + + diff --git a/android-tv/app/src/main/res/values/strings.xml b/android-tv/app/src/main/res/values/strings.xml index ac6af5f..351f658 100644 --- a/android-tv/app/src/main/res/values/strings.xml +++ b/android-tv/app/src/main/res/values/strings.xml @@ -1,3 +1,3 @@ - - StreamFlow - + + StreamFlow + diff --git a/android-tv/app/src/main/res/values/themes.xml b/android-tv/app/src/main/res/values/themes.xml index 88a1d5c..2130c42 100644 --- a/android-tv/app/src/main/res/values/themes.xml +++ b/android-tv/app/src/main/res/values/themes.xml @@ -1,8 +1,8 @@ - - - - + + + + diff --git a/android-tv/build.gradle.kts b/android-tv/build.gradle.kts index e1e81fa..68ca19b 100644 --- a/android-tv/build.gradle.kts +++ b/android-tv/build.gradle.kts @@ -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 +} diff --git a/android-tv/build.txt b/android-tv/build.txt index 750c14f..4819840 100644 --- a/android-tv/build.txt +++ b/android-tv/build.txt @@ -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 diff --git a/android-tv/build_async.txt b/android-tv/build_async.txt index c3229cf..b31eff9 100644 --- a/android-tv/build_async.txt +++ b/android-tv/build_async.txt @@ -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 diff --git a/android-tv/build_episodes.txt b/android-tv/build_episodes.txt index 722e400..c63f131 100644 --- a/android-tv/build_episodes.txt +++ b/android-tv/build_episodes.txt @@ -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 diff --git a/android-tv/gradle-8.4/bin/gradle.bat b/android-tv/gradle-8.4/bin/gradle.bat index 9c2613c..671b8e9 100644 --- a/android-tv/gradle-8.4/bin/gradle.bat +++ b/android-tv/gradle-8.4/bin/gradle.bat @@ -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 diff --git a/android-tv/gradle.properties b/android-tv/gradle.properties index f0a2e55..94f93a0 100644 --- a/android-tv/gradle.properties +++ b/android-tv/gradle.properties @@ -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 diff --git a/android-tv/gradlew b/android-tv/gradlew old mode 100644 new mode 100755 diff --git a/android-tv/gradlew.bat b/android-tv/gradlew.bat index 9d21a21..9b42019 100644 --- a/android-tv/gradlew.bat +++ b/android-tv/gradlew.bat @@ -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 diff --git a/android-tv/settings.gradle.kts b/android-tv/settings.gradle.kts index 1332a8e..cd4200d 100644 --- a/android-tv/settings.gradle.kts +++ b/android-tv/settings.gradle.kts @@ -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") diff --git a/backend/internal/api/handlers.go b/backend/internal/api/handlers.go index f2a67d5..0df8580 100644 --- a/backend/internal/api/handlers.go +++ b/backend/internal/api/handlers.go @@ -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 } diff --git a/backend/internal/api/routes.go b/backend/internal/api/routes.go index d74d4ab..4ae3aa8 100644 --- a/backend/internal/api/routes.go +++ b/backend/internal/api/routes.go @@ -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) +} diff --git a/backend/internal/database/database.go b/backend/internal/database/database.go index e89e13c..bc6c66c 100644 --- a/backend/internal/database/database.go +++ b/backend/internal/database/database.go @@ -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 +} diff --git a/backend/internal/models/models.go b/backend/internal/models/models.go index b497590..d1f371f 100644 --- a/backend/internal/models/models.go +++ b/backend/internal/models/models.go @@ -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"` +} diff --git a/backend/internal/scraper/ophim.go b/backend/internal/scraper/ophim.go index 27ac668..fbbebc6 100644 --- a/backend/internal/scraper/ophim.go +++ b/backend/internal/scraper/ophim.go @@ -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, ", ") +} diff --git a/backend/internal/scraper/phim30.go b/backend/internal/scraper/phim30.go index 9ffbf3f..459e4f5 100644 --- a/backend/internal/scraper/phim30.go +++ b/backend/internal/scraper/phim30.go @@ -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 +} diff --git a/backend/internal/scraper/provider.go b/backend/internal/scraper/provider.go index 1217e1d..973e590 100644 --- a/backend/internal/scraper/provider.go +++ b/backend/internal/scraper/provider.go @@ -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) +} diff --git a/backend/internal/scraper/rophim.go b/backend/internal/scraper/rophim.go index f5765aa..ce310ca 100644 --- a/backend/internal/scraper/rophim.go +++ b/backend/internal/scraper/rophim.go @@ -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 +} diff --git a/backend/internal/service/extractor.go b/backend/internal/service/extractor.go index e1f608b..eb38a8b 100644 --- a/backend/internal/service/extractor.go +++ b/backend/internal/service/extractor.go @@ -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 +} diff --git a/backend/internal/service/image.go b/backend/internal/service/image.go index 57e3d39..4f73aea 100644 --- a/backend/internal/service/image.go +++ b/backend/internal/service/image.go @@ -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 +} diff --git a/backend/internal/service/tmdb.go b/backend/internal/service/tmdb.go index f5b8706..162cbd0 100644 --- a/backend/internal/service/tmdb.go +++ b/backend/internal/service/tmdb.go @@ -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) +} diff --git a/deploy.ps1 b/deploy.ps1 index 1f78804..7f08e7c 100644 --- a/deploy.ps1 +++ b/deploy.ps1 @@ -1,31 +1,31 @@ -# Streamflow Deployment Script -# Automates building and pushing Docker images to registries - -$ErrorActionPreference = "Stop" - -Write-Host "=============================" -ForegroundColor Cyan -Write-Host " Streamflow Deployer " -ForegroundColor Cyan -Write-Host "=============================" -ForegroundColor Cyan - -# 1. Build -Write-Host "`n[1/3] Building Docker Image..." -ForegroundColor White -docker build -t streamflow:latest . -if ($LASTEXITCODE -ne 0) { Write-Error "Build failed"; exit 1 } -Write-Host " -> Build successful" -ForegroundColor Green - -# 2. Push to Docker Hub -Write-Host "`n[2/3] Pushing to Docker Hub..." -ForegroundColor White -docker tag streamflow:latest vndangkhoa/streamflow:latest -docker push vndangkhoa/streamflow:latest -if ($LASTEXITCODE -ne 0) { Write-Warning "Docker Hub push failed. Check your login." } -else { Write-Host " -> Pushed to Docker Hub" -ForegroundColor Green } - -# 3. Push to Private Registry -Write-Host "`n[3/3] Pushing to Private Registry..." -ForegroundColor White -docker tag streamflow:latest git.khoavo.myds.me/vndangkhoa/kv-streamflow:latest -docker push git.khoavo.myds.me/vndangkhoa/kv-streamflow:latest -if ($LASTEXITCODE -ne 0) { Write-Warning "Private Registry push failed. Check VPN/Login." } -else { Write-Host " -> Pushed to Private Registry" -ForegroundColor Green } - -Write-Host "`nDeployment Complete!" -ForegroundColor Magenta -Start-Sleep -Seconds 5 +# Streamflow Deployment Script +# Automates building and pushing Docker images to registries + +$ErrorActionPreference = "Stop" + +Write-Host "=============================" -ForegroundColor Cyan +Write-Host " Streamflow Deployer " -ForegroundColor Cyan +Write-Host "=============================" -ForegroundColor Cyan + +# 1. Build +Write-Host "`n[1/3] Building Docker Image..." -ForegroundColor White +docker build -t streamflow:latest . +if ($LASTEXITCODE -ne 0) { Write-Error "Build failed"; exit 1 } +Write-Host " -> Build successful" -ForegroundColor Green + +# 2. Push to Docker Hub +Write-Host "`n[2/3] Pushing to Docker Hub..." -ForegroundColor White +docker tag streamflow:latest vndangkhoa/streamflow:latest +docker push vndangkhoa/streamflow:latest +if ($LASTEXITCODE -ne 0) { Write-Warning "Docker Hub push failed. Check your login." } +else { Write-Host " -> Pushed to Docker Hub" -ForegroundColor Green } + +# 3. Push to Private Registry +Write-Host "`n[3/3] Pushing to Private Registry..." -ForegroundColor White +docker tag streamflow:latest git.khoavo.myds.me/vndangkhoa/kv-streamflow:latest +docker push git.khoavo.myds.me/vndangkhoa/kv-streamflow:latest +if ($LASTEXITCODE -ne 0) { Write-Warning "Private Registry push failed. Check VPN/Login." } +else { Write-Host " -> Pushed to Private Registry" -ForegroundColor Green } + +Write-Host "`nDeployment Complete!" -ForegroundColor Magenta +Start-Sleep -Seconds 5 diff --git a/docker-compose.yml b/docker-compose.yml index 3a56800..c296f34 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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: diff --git a/frontend-react/postcss.config.js b/frontend-react/postcss.config.js index d0ec925..faef8be 100644 --- a/frontend-react/postcss.config.js +++ b/frontend-react/postcss.config.js @@ -1,6 +1,6 @@ -export default { - plugins: { - '@tailwindcss/postcss': {}, - autoprefixer: {}, - }, -} +export default { + plugins: { + '@tailwindcss/postcss': {}, + autoprefixer: {}, + }, +} diff --git a/frontend-react/public/streamflow-tv.apk b/frontend-react/public/streamflow-tv.apk index d32fdc4..2a0ac47 100644 Binary files a/frontend-react/public/streamflow-tv.apk and b/frontend-react/public/streamflow-tv.apk differ diff --git a/frontend-react/src/components/Hero.tsx b/frontend-react/src/components/Hero.tsx index 4da8926..77c1b9a 100644 --- a/frontend-react/src/components/Hero.tsx +++ b/frontend-react/src/components/Hero.tsx @@ -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 ( -
-
- {movie.title} { - if (movie.thumbnail && e.currentTarget.src !== getImageUrl(movie.thumbnail, 1600)) { - e.currentTarget.src = getImageUrl(movie.thumbnail, 1600); - } - }} - /> -
-
-
- -
-
-
- Premiere -
- -

- {movie.title} -

- - {movie.original_title && ( -

{movie.original_title}

- )} - -
- - - Play - - -
-
-
- - {/* Carousel Dots (Bottom Center) */} -
- {movies.map((_, i) => ( -
-
- ); - } - - // 2. Netflix Variant (Left-Aligned, Sidebar-Aware if needed, Top-10 Badge) - if (variant === 'netflix') { - return ( -
-
- {movie.title} { - if (movie.thumbnail && e.currentTarget.src !== getImageUrl(movie.thumbnail, 1600)) { - e.currentTarget.src = getImageUrl(movie.thumbnail, 1600); - } - }} - /> -
-
-
- -
-
-
- TOP 10 TODAY - #{index + 1} in Movies -
- -

- {movie.title} -

- - {movie.original_title && ( -

{movie.original_title}

- )} - -
- - - Play - - -
-
-
- - {/* Indicators (Vertical Right Side - Classic Netflix) */} -
- {movies.map((_, i) => ( -
-
- ); - } - - // 3. Default (StreamFlow) Variant - Split Poster Design (Solves Quality & Sizing) - return ( -
- {/* 1. Ambient Background (Blurred) */} -
- Background { - 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 - /> -
-
-
- - {/* 2. Main Content Layout */} -
-
- - {/* Left Column: Text Info */} -
- {/* Wrapper for text to ensure readability */} -
-
-
- TOP 10 -
- #{index + 1} in Movies Today -
- -

- {movie.title} -

- -
- 98% Match - - {movie.year || '2024'} - - HD - {movie.original_title && ( - <> - - {movie.original_title} - - )} -
- -
- - - Watch Now - - -
-
-
- - {/* Right Column: Sharp Poster (Desktop Only) */} -
-
- {/* Glow Effect */} -
- - {movie.title} -
-
-
-
- - {/* Indicators */} -
- {movies.map((_, i) => ( -
-
- ); -}; +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 ( +
+
+ {movie.title} { + if (movie.thumbnail && e.currentTarget.src !== getImageUrl(movie.thumbnail, 1600)) { + e.currentTarget.src = getImageUrl(movie.thumbnail, 1600); + } + }} + /> +
+
+
+ +
+
+
+ Premiere +
+ +

+ {movie.title} +

+ + {movie.original_title && ( +

{movie.original_title}

+ )} + +
+ + + Play + + +
+
+
+ + {/* Carousel Dots (Bottom Center) */} +
+ {movies.map((_, i) => ( +
+
+ ); + } + + // 2. Netflix Variant (Left-Aligned, Sidebar-Aware if needed, Top-10 Badge) + if (variant === 'netflix') { + return ( +
+
+ {movie.title} { + if (movie.thumbnail && e.currentTarget.src !== getImageUrl(movie.thumbnail, 1600)) { + e.currentTarget.src = getImageUrl(movie.thumbnail, 1600); + } + }} + /> +
+
+
+ +
+
+
+ TOP 10 TODAY + #{index + 1} in Movies +
+ +

+ {movie.title} +

+ + {movie.original_title && ( +

{movie.original_title}

+ )} + +
+ + + Play + + +
+
+
+ + {/* Indicators (Vertical Right Side - Classic Netflix) */} +
+ {movies.map((_, i) => ( +
+
+ ); + } + + // 3. Default (StreamFlow) Variant - Split Poster Design (Solves Quality & Sizing) + return ( +
+ {/* 1. Ambient Background (Blurred) */} +
+ Background { + 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 + /> +
+
+
+ + {/* 2. Main Content Layout */} +
+
+ + {/* Left Column: Text Info */} +
+ {/* Wrapper for text to ensure readability */} +
+
+
+ TOP 10 +
+ #{index + 1} in Movies Today +
+ +

+ {movie.title} +

+ +
+ 98% Match + + {movie.year || '2024'} + + HD + {movie.original_title && ( + <> + + {movie.original_title} + + )} +
+ +
+ + + Watch Now + + +
+
+
+ + {/* Right Column: Sharp Poster (Desktop Only) */} +
+
+ {/* Glow Effect */} +
+ + {movie.title} +
+
+
+
+ + {/* Indicators */} +
+ {movies.map((_, i) => ( +
+
+ ); +}; diff --git a/frontend-react/src/components/HomeContent.tsx b/frontend-react/src/components/HomeContent.tsx index 232077b..0c40179 100644 --- a/frontend-react/src/components/HomeContent.tsx +++ b/frontend-react/src/components/HomeContent.tsx @@ -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([]); - 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(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 ( -
- {showRows && ( -
- {/* Continue Watching Row */} - {watchHistory.length > 0 && ( - - )} - - {/* My List Row */} - {savedMovies.length > 0 && ( - - )} - - {/* Smart Category Recommendations */} - {recommendations.map(rec => ( - - ))} - - {/* Smart Suggestions using SEARCH API */} - {lastWatched && ( - <> - {lastWatched.director && ( - - )} - {lastWatched.cast && lastWatched.cast.length > 0 && ( - - )} - - )} - - {/* Phim Mới Horizontal Carousel */} - - - {/* Top 10 Grids for each Category */} - {CATEGORIES.filter(c => c.id !== 'my-list').map((cat) => ( - - ))} - - {/* Other Curated Sections */} - - -
- )} - - {/* Infinite Scroll Grid */} -
-

- - {getTitle()} -

- -
- {movies.map((movie, index) => ( - - ))} -
- - {/* Sentinel element for infinite scroll */} -
- - {loading && ( -
- {[...Array(12)].map((_, i) => ( -
- ))} -
- )} - - {!loading && movies.length === 0 && ( -
- Không tìm thấy phim nào. -
- )} -
-
- ); -}; +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([]); + 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(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 ( +
+ {showRows && ( +
+ {/* Continue Watching Row */} + {watchHistory.length > 0 && ( + + )} + + {/* My List Row */} + {savedMovies.length > 0 && ( + + )} + + {/* Smart Category Recommendations */} + {recommendations.map(rec => ( + + ))} + + {/* Smart Suggestions using SEARCH API */} + {lastWatched && ( + <> + {lastWatched.director && ( + + )} + {lastWatched.cast && lastWatched.cast.length > 0 && ( + + )} + + )} + + {/* Phim Mới Horizontal Carousel */} + + + {/* Top 10 Grids for each Category */} + {CATEGORIES.filter(c => c.id !== 'my-list').map((cat) => ( + + ))} + + {/* Other Curated Sections */} + + +
+ )} + + {/* Infinite Scroll Grid */} +
+

+ + {getTitle()} +

+ +
+ {movies.map((movie, index) => ( + + ))} +
+ + {/* Sentinel element for infinite scroll */} +
+ + {loading && ( +
+ {[...Array(12)].map((_, i) => ( +
+ ))} +
+ )} + + {!loading && movies.length === 0 && ( +
+ Không tìm thấy phim nào. +
+ )} +
+
+ ); +}; diff --git a/frontend-react/src/components/MovieCard.tsx b/frontend-react/src/components/MovieCard.tsx index 67b791d..a538281 100644 --- a/frontend-react/src/components/MovieCard.tsx +++ b/frontend-react/src/components/MovieCard.tsx @@ -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 ( -
- {/* Poster Image Container */} - - {movie.title} - - {/* Hover Overlay */} -
-
- -
-
- - {/* Top-Left Tag (Provider) */} - {movie.provider && ( -
-
- {movie.provider} -
-
- )} - - {/* Top-Right Tags (Quality & Lang) */} -
- {movie.quality && ( -
- {movie.quality} -
- )} - {movie.lang && ( -
- {movie.lang} -
- )} -
- - {/* Bottom Status (Time / Episode Info) */} - {movie.time && ( -
-
- - {movie.time} -
-
- )} - - - {/* Info Section */} -
-

- {movie.title} -

- {movie.year && ( -

- {movie.year} • 98% Match -

- )} -
-
- ); -}; +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 ( +
+ {/* Poster Image Container */} + + {movie.title} + + {/* Hover Overlay */} +
+
+ +
+
+ + {/* Top-Left Tag (Provider) */} + {movie.provider && ( +
+
+ {movie.provider} +
+
+ )} + + {/* Top-Right Tags (Quality & Lang) */} +
+ {movie.quality && ( +
+ {movie.quality} +
+ )} + {movie.lang && ( +
+ {movie.lang} +
+ )} +
+ + {/* Bottom Status (Time / Episode Info) */} + {movie.time && ( +
+
+ + {movie.time} +
+
+ )} + + + {/* Info Section */} +
+

+ {movie.title} +

+ {movie.year && ( +

+ {movie.year} • 98% Match +

+ )} +
+
+ ); +}; diff --git a/frontend-react/src/components/MovieRow.tsx b/frontend-react/src/components/MovieRow.tsx index 5147f67..1dd6f90 100644 --- a/frontend-react/src/components/MovieRow.tsx +++ b/frontend-react/src/components/MovieRow.tsx @@ -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([]); - const [loading, setLoading] = useState(true); - const rowRef = useRef(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 ( -
-
- {layout === 'row' ? ( -
- {[...Array(6)].map((_, i) => ( -
- ))} -
- ) : ( -
- {[...Array(12)].map((_, i) => ( -
- ))} -
- )} -
- ); - - // 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 ( -
-

- - {title} - - Xem tất cả - -

- - {layout === 'row' ? ( -
- - -
- {movies.map((movie) => ( -
- -
- ))} -
- - -
- ) : ( -
- {movies.map((movie) => ( - - ))} -
- )} -
- ); -}; - -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([]); + const [loading, setLoading] = useState(true); + const rowRef = useRef(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 ( +
+
+ {layout === 'row' ? ( +
+ {[...Array(6)].map((_, i) => ( +
+ ))} +
+ ) : ( +
+ {[...Array(12)].map((_, i) => ( +
+ ))} +
+ )} +
+ ); + + // 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 ( +
+

+ + {title} + + Xem tất cả + +

+ + {layout === 'row' ? ( +
+ + +
+ {movies.map((movie) => ( +
+ +
+ ))} +
+ + +
+ ) : ( +
+ {movies.map((movie) => ( + + ))} +
+ )} +
+ ); +}; + +export default MovieRow; diff --git a/frontend-react/src/components/Navbar.tsx b/frontend-react/src/components/Navbar.tsx index 47c8927..b6602a1 100644 --- a/frontend-react/src/components/Navbar.tsx +++ b/frontend-react/src/components/Navbar.tsx @@ -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 ( - - ); -}; - -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 ( + + ); +}; + +export default Navbar; diff --git a/frontend-react/src/components/SettingsPanel.tsx b/frontend-react/src/components/SettingsPanel.tsx index a2e7979..c31c12f 100644 --- a/frontend-react/src/components/SettingsPanel.tsx +++ b/frontend-react/src/components/SettingsPanel.tsx @@ -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 ( - <> - - - {isOpen && ( -
-
setIsOpen(false)} - /> - -
-
-

Appearance

- -
- -
-
-

Choose Theme

-
- {themes.map((theme) => ( - - ))} -
-
- -
-

- Switching themes completely changes the layout and browsing experience. -

-
-
-
-
- )} - - ); -}; +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 ( + <> + + + {isOpen && ( +
+
setIsOpen(false)} + /> + +
+
+

Appearance

+ +
+ +
+
+

Choose Theme

+
+ {themes.map((theme) => ( + + ))} +
+
+ +
+

+ Switching themes completely changes the layout and browsing experience. +

+
+
+
+
+ )} + + ); +}; diff --git a/frontend-react/src/constants.ts b/frontend-react/src/constants.ts index 30180f9..ebd5b58 100644 --- a/frontend-react/src/constants.ts +++ b/frontend-react/src/constants.ts @@ -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 })), +]; diff --git a/frontend-react/src/context/ThemeContext.tsx b/frontend-react/src/context/ThemeContext.tsx index 9fe6e4e..1a46ce1 100644 --- a/frontend-react/src/context/ThemeContext.tsx +++ b/frontend-react/src/context/ThemeContext.tsx @@ -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(undefined); - -export const ThemeProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { - const [currentTheme, setCurrentTheme] = useState(() => { - 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 ( - - {children} - - ); -}; - -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(undefined); + +export const ThemeProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { + const [currentTheme, setCurrentTheme] = useState(() => { + 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 ( + + {children} + + ); +}; + +export const useTheme = () => { + const context = useContext(ThemeContext); + if (context === undefined) { + throw new Error('useTheme must be used within a ThemeProvider'); + } + return context; +}; diff --git a/frontend-react/src/hooks/useMovies.ts b/frontend-react/src/hooks/useMovies.ts index 96dc7da..73e6412 100644 --- a/frontend-react/src/hooks/useMovies.ts +++ b/frontend-react/src/hooks/useMovies.ts @@ -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([]); - 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([]); + 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() }; +}; diff --git a/frontend-react/src/hooks/useMyList.ts b/frontend-react/src/hooks/useMyList.ts index 2cb2f49..ea28407 100644 --- a/frontend-react/src/hooks/useMyList.ts +++ b/frontend-react/src/hooks/useMyList.ts @@ -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(() => { - 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(() => { + 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 + }; +}; diff --git a/frontend-react/src/hooks/useSmartRecommendations.ts b/frontend-react/src/hooks/useSmartRecommendations.ts index 10ce297..3fec882 100644 --- a/frontend-react/src/hooks/useSmartRecommendations.ts +++ b/frontend-react/src/hooks/useSmartRecommendations.ts @@ -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 = { - '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 = {}; - 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 = { + '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 = {}; + 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]); +}; diff --git a/frontend-react/src/hooks/useWatchMovie.ts b/frontend-react/src/hooks/useWatchMovie.ts index 02dcfc1..0cff125 100644 --- a/frontend-react/src/hooks/useWatchMovie.ts +++ b/frontend-react/src/hooks/useWatchMovie.ts @@ -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(null); - const [movie, setMovie] = useState(null); - const [source, setSource] = useState(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(null); + const [movie, setMovie] = useState(null); + const [source, setSource] = useState(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 + }; +}; diff --git a/frontend-react/src/pages/Home.tsx b/frontend-react/src/pages/Home.tsx index 43b0b23..3790ce5 100644 --- a/frontend-react/src/pages/Home.tsx +++ b/frontend-react/src/pages/Home.tsx @@ -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 ; -}; - -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 ; +}; + +export default Home; diff --git a/frontend-react/src/pages/MyList.tsx b/frontend-react/src/pages/MyList.tsx index 317f821..97be302 100644 --- a/frontend-react/src/pages/MyList.tsx +++ b/frontend-react/src/pages/MyList.tsx @@ -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 ( - -
- {/* Watch History Section */} - {watchHistory.length > 0 && ( -
- -
- )} - - {/* Saved List Section */} - - - {savedMovies.length === 0 && watchHistory.length === 0 && ( -
-

Your list is empty.

-

Start watching or add movies to your list.

-
- )} -
- -
- ); -}; - -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 ( + +
+ {/* Watch History Section */} + {watchHistory.length > 0 && ( +
+ +
+ )} + + {/* Saved List Section */} + + + {savedMovies.length === 0 && watchHistory.length === 0 && ( +
+

Your list is empty.

+

Start watching or add movies to your list.

+
+ )} +
+ +
+ ); +}; + +export default MyList; diff --git a/frontend-react/src/pages/Watch.tsx b/frontend-react/src/pages/Watch.tsx index a6b9eed..5e34722 100644 --- a/frontend-react/src/pages/Watch.tsx +++ b/frontend-react/src/pages/Watch.tsx @@ -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 ; -}; - -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 ; +}; + +export default Watch; diff --git a/frontend-react/src/themes/apple/AppleHome.tsx b/frontend-react/src/themes/apple/AppleHome.tsx index 35a9a01..aa5d936 100644 --- a/frontend-react/src/themes/apple/AppleHome.tsx +++ b/frontend-react/src/themes/apple/AppleHome.tsx @@ -1,15 +1,15 @@ -import { Layout } from './Layout'; -import { HomeContent } from '../../components/HomeContent'; -import { SettingsPanel } from '../../components/SettingsPanel'; - -export const AppleHome = () => { - return ( - - {/* Apple Theme usually has a dark gradient header, but HomeContent handles general layout */} -
- -
- -
- ); -}; +import { Layout } from './Layout'; +import { HomeContent } from '../../components/HomeContent'; +import { SettingsPanel } from '../../components/SettingsPanel'; + +export const AppleHome = () => { + return ( + + {/* Apple Theme usually has a dark gradient header, but HomeContent handles general layout */} +
+ +
+ +
+ ); +}; diff --git a/frontend-react/src/themes/apple/Card.tsx b/frontend-react/src/themes/apple/Card.tsx index 64a5053..dde3a89 100644 --- a/frontend-react/src/themes/apple/Card.tsx +++ b/frontend-react/src/themes/apple/Card.tsx @@ -1,41 +1,41 @@ -import type { Movie } from '../../types'; -import { Play } from 'lucide-react'; - -export const Card = ({ movie }: { movie: Movie }) => { - return ( -
- -
- {movie.title} - -
-
- -
-
- - {/* Glass Badge */} - {movie.quality && ( -
- {movie.quality} -
- )} -
-
- -
-

- {movie.title} -

-

- {movie.original_title || movie.year || '2024'} -

-
-
- ); -}; +import type { Movie } from '../../types'; +import { Play } from 'lucide-react'; + +export const Card = ({ movie }: { movie: Movie }) => { + return ( +
+ +
+ {movie.title} + +
+
+ +
+
+ + {/* Glass Badge */} + {movie.quality && ( +
+ {movie.quality} +
+ )} +
+
+ +
+

+ {movie.title} +

+

+ {movie.original_title || movie.year || '2024'} +

+
+
+ ); +}; diff --git a/frontend-react/src/themes/apple/Hero.tsx b/frontend-react/src/themes/apple/Hero.tsx index bb6aa7a..3fafbdf 100644 --- a/frontend-react/src/themes/apple/Hero.tsx +++ b/frontend-react/src/themes/apple/Hero.tsx @@ -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 ( -
-
- {movie.title} -
-
-
- -
-
-
- Premiere -
- -

- {movie.title} -

- - {movie.original_title && ( -

{movie.original_title}

- )} - -
- - - Play - - -
-
-
- - {/* Carousel Dots */} -
- {movies.map((_, i) => ( -
-
- ); -}; +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 ( +
+
+ {movie.title} +
+
+
+ +
+
+
+ Premiere +
+ +

+ {movie.title} +

+ + {movie.original_title && ( +

{movie.original_title}

+ )} + +
+ + + Play + + +
+
+
+ + {/* Carousel Dots */} +
+ {movies.map((_, i) => ( +
+
+ ); +}; diff --git a/frontend-react/src/themes/apple/Layout.tsx b/frontend-react/src/themes/apple/Layout.tsx index f8aed84..cdc1090 100644 --- a/frontend-react/src/themes/apple/Layout.tsx +++ b/frontend-react/src/themes/apple/Layout.tsx @@ -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 ( -
- {/* Glass Navbar */} - - - {/* Mobile Bottom Nav */} - - -
- {children} -
-
- ); -}; +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 ( +
+ {/* Glass Navbar */} + + + {/* Mobile Bottom Nav */} + + +
+ {children} +
+
+ ); +}; diff --git a/frontend-react/src/themes/apple/MovieGrid.tsx b/frontend-react/src/themes/apple/MovieGrid.tsx index a6632d5..5695eef 100644 --- a/frontend-react/src/themes/apple/MovieGrid.tsx +++ b/frontend-react/src/themes/apple/MovieGrid.tsx @@ -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 ( -
- {title &&

{title}

} -
- {[...Array(10)].map((_, i) => ( -
- ))} -
-
- ); - } - - return ( -
-
- {title &&

{title}

} - -
- -
- {movies.map((movie) => ( - - ))} -
-
- ); -}; +import type { Movie } from '../../types'; +import { Card } from './Card'; + +export const MovieGrid = ({ movies, loading, title }: { movies: Movie[], loading?: boolean, title?: string }) => { + if (loading) { + return ( +
+ {title &&

{title}

} +
+ {[...Array(10)].map((_, i) => ( +
+ ))} +
+
+ ); + } + + return ( +
+
+ {title &&

{title}

} + +
+ +
+ {movies.map((movie) => ( + + ))} +
+
+ ); +}; diff --git a/frontend-react/src/themes/apple/WatchPage.tsx b/frontend-react/src/themes/apple/WatchPage.tsx index f0099f9..b3f24f2 100644 --- a/frontend-react/src/themes/apple/WatchPage.tsx +++ b/frontend-react/src/themes/apple/WatchPage.tsx @@ -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(''); - - if (!movie) return
Loading...
; - - // 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) || {}; - - 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 ( -
- {/* Navigation */} -
- -
- -
- {/* Player Section - Sticky on larger screens for cinema feel */} -
- {loading && ( -
-
-
- )} - {(() => { - const activeEpisode = currentServerEpisodes?.find(e => e.number === currentEpisode); - if (!activeEpisode?.url) { - return ( -
-
-
-
-
-

Processing Content

-

- This title is currently being prepared for streaming. -

-
- {/* Subtle Background */} -
-
- ); - } - - return ( -
- - {/* 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 */} -
- - {/* Movie Info */} -
-
-

{movie.title}

-
- HD - {movie.year || '2024'} - {movie.episodes?.length || 0} Episodes -
-
-

{movie.description}

-
- - {/* Episodes Grid */} -
-
-
-

Episodes

- - {/* Server Selector */} - {serverNames.length > 1 && ( -
- {serverNames.map(server => ( - - ))} -
- )} -
- {currentServerEpisodes.length} available -
- -
- {visibleEpisodes.map((ep) => ( - - ))} -
- - {currentServerEpisodes.length > 20 && ( - - )} -
- - {/* Related Categories */} -
-
-

More Like This

- -
- -
-

Trending Now

- -
- -
-

Top Movies

- -
- -
-

Animation

- -
-
-
-
-
- ); -}; +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(''); + + if (!movie) return
Loading...
; + + // 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) || {}; + + 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 ( +
+ {/* Navigation */} +
+ +
+ +
+ {/* Player Section - Sticky on larger screens for cinema feel */} +
+ {loading && ( +
+
+
+ )} + {(() => { + const activeEpisode = currentServerEpisodes?.find(e => e.number === currentEpisode); + if (!activeEpisode?.url) { + return ( +
+
+
+
+
+

Processing Content

+

+ This title is currently being prepared for streaming. +

+
+ {/* Subtle Background */} +
+
+ ); + } + + return ( +
+ + {/* 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 */} +
+ + {/* Movie Info */} +
+
+

{movie.title}

+
+ HD + {movie.year || '2024'} + {movie.episodes?.length || 0} Episodes +
+
+

{movie.description}

+
+ + {/* Episodes Grid */} +
+
+
+

Episodes

+ + {/* Server Selector */} + {serverNames.length > 1 && ( +
+ {serverNames.map(server => ( + + ))} +
+ )} +
+ {currentServerEpisodes.length} available +
+ +
+ {visibleEpisodes.map((ep) => ( + + ))} +
+ + {currentServerEpisodes.length > 20 && ( + + )} +
+ + {/* Related Categories */} +
+
+

More Like This

+ +
+ +
+

Trending Now

+ +
+ +
+

Top Movies

+ +
+ +
+

Animation

+ +
+
+
+
+
+ ); +}; diff --git a/frontend-react/src/themes/apple/index.ts b/frontend-react/src/themes/apple/index.ts index 5d49f9e..f279dfd 100644 --- a/frontend-react/src/themes/apple/index.ts +++ b/frontend-react/src/themes/apple/index.ts @@ -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 + }, +}; diff --git a/frontend-react/src/themes/default/DefaultHome.tsx b/frontend-react/src/themes/default/DefaultHome.tsx index d0ddcf8..34dcc7e 100644 --- a/frontend-react/src/themes/default/DefaultHome.tsx +++ b/frontend-react/src/themes/default/DefaultHome.tsx @@ -1,15 +1,15 @@ -import Navbar from '../../components/Navbar'; -import { HomeContent } from '../../components/HomeContent'; -import { SettingsPanel } from '../../components/SettingsPanel'; - -export const DefaultHome = () => { - return ( -
- -
- -
- -
- ); -}; +import Navbar from '../../components/Navbar'; +import { HomeContent } from '../../components/HomeContent'; +import { SettingsPanel } from '../../components/SettingsPanel'; + +export const DefaultHome = () => { + return ( +
+ +
+ +
+ +
+ ); +}; diff --git a/frontend-react/src/themes/default/WatchPage.tsx b/frontend-react/src/themes/default/WatchPage.tsx index 481feb4..2461364 100644 --- a/frontend-react/src/themes/default/WatchPage.tsx +++ b/frontend-react/src/themes/default/WatchPage.tsx @@ -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(''); - const [expanded, setExpanded] = useState(false); - - if (!movie) return ( -
-
-
-

Loading StreamFlow...

-
-
- ); - - // 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) || {}; - - 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 ( - -
- {/* Back Navigation */} -
- -
- - {/* 1. Cinema Player Section */} -
- {loading && ( -
-
-
- )} - {(() => { - const activeEpisode = currentServerEpisodes?.find(e => e.number === currentEpisode); - if (!activeEpisode?.url) { - return ( -
-
-

Coming Soon

-

- We're busy uploading the best quality version of this movie. -

-
-
-
- ); - } - - return ( -
- - {/* 2. Content Info & Rows */} - {/* 2. Content Info & Rows */} -
- {/* Glass Info Card */} -
-

{movie.title}

- - {/* Meta Tags */} -
- - {movie.quality || 'HD'} - - {movie.year || '2024'} - 98% Match - {movie.original_title} -
- -
-
- - {/* Episodes Section - Compact Grid */} - {currentServerEpisodes.length > 0 && ( -
-
-
-

Episodes

- - {/* Server Selector */} - {serverNames.length > 1 && ( -
- {serverNames.map(server => ( - - ))} -
- )} -
-
{currentServerEpisodes.length} Items
-
- -
- {visibleEpisodes.map((ep) => ( - - ))} -
- - {currentServerEpisodes.length > 20 && ( - - )} -
- )} - - {/* Related Content Section */} -
- - - - -
-
-
- ); -}; +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(''); + const [expanded, setExpanded] = useState(false); + + if (!movie) return ( +
+
+
+

Loading StreamFlow...

+
+
+ ); + + // 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) || {}; + + 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 ( + +
+ {/* Back Navigation */} +
+ +
+ + {/* 1. Cinema Player Section */} +
+ {loading && ( +
+
+
+ )} + {(() => { + const activeEpisode = currentServerEpisodes?.find(e => e.number === currentEpisode); + if (!activeEpisode?.url) { + return ( +
+
+

Coming Soon

+

+ We're busy uploading the best quality version of this movie. +

+
+
+
+ ); + } + + return ( +
+ + {/* 2. Content Info & Rows */} + {/* 2. Content Info & Rows */} +
+ {/* Glass Info Card */} +
+

{movie.title}

+ + {/* Meta Tags */} +
+ + {movie.quality || 'HD'} + + {movie.year || '2024'} + 98% Match + {movie.original_title} +
+ +
+
+ + {/* Episodes Section - Compact Grid */} + {currentServerEpisodes.length > 0 && ( +
+
+
+

Episodes

+ + {/* Server Selector */} + {serverNames.length > 1 && ( +
+ {serverNames.map(server => ( + + ))} +
+ )} +
+
{currentServerEpisodes.length} Items
+
+ +
+ {visibleEpisodes.map((ep) => ( + + ))} +
+ + {currentServerEpisodes.length > 20 && ( + + )} +
+ )} + + {/* Related Content Section */} +
+ + + + +
+
+
+ ); +}; diff --git a/frontend-react/src/themes/default/index.ts b/frontend-react/src/themes/default/index.ts index 2b1773b..e05323a 100644 --- a/frontend-react/src/themes/default/index.ts +++ b/frontend-react/src/themes/default/index.ts @@ -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, + }, +}; diff --git a/frontend-react/src/themes/netflix/Card.tsx b/frontend-react/src/themes/netflix/Card.tsx index 0308b5c..e768cc8 100644 --- a/frontend-react/src/themes/netflix/Card.tsx +++ b/frontend-react/src/themes/netflix/Card.tsx @@ -1,6 +1,6 @@ -import { MovieCard } from '../../components/MovieCard'; -import type { Movie } from '../../types'; - -export const Card = ({ movie }: { movie: Movie }) => { - return ; -}; +import { MovieCard } from '../../components/MovieCard'; +import type { Movie } from '../../types'; + +export const Card = ({ movie }: { movie: Movie }) => { + return ; +}; diff --git a/frontend-react/src/themes/netflix/Hero.tsx b/frontend-react/src/themes/netflix/Hero.tsx index c56b4d2..db979a3 100644 --- a/frontend-react/src/themes/netflix/Hero.tsx +++ b/frontend-react/src/themes/netflix/Hero.tsx @@ -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 ( -
-
- {movie.title} -
-
-
- -
-
-
- TOP 10 TODAY - #{index + 1} in Movies -
- -

- {movie.title} -

- - {movie.original_title && ( -

{movie.original_title}

- )} - -
- - - Play - - -
-
-
- - {/* Indicators */} -
- {movies.map((_, i) => ( -
-
- ); -}; +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 ( +
+
+ {movie.title} +
+
+
+ +
+
+
+ TOP 10 TODAY + #{index + 1} in Movies +
+ +

+ {movie.title} +

+ + {movie.original_title && ( +

{movie.original_title}

+ )} + +
+ + + Play + + +
+
+
+ + {/* Indicators */} +
+ {movies.map((_, i) => ( +
+
+ ); +}; diff --git a/frontend-react/src/themes/netflix/Layout.tsx b/frontend-react/src/themes/netflix/Layout.tsx index 895d9a2..60402f8 100644 --- a/frontend-react/src/themes/netflix/Layout.tsx +++ b/frontend-react/src/themes/netflix/Layout.tsx @@ -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 ( -
- {/* Sidebar Navigation */} - - - {/* Mobile Bottom Nav (Visible only on small screens) */} -
- {NAV_ITEMS.slice(0, 4).map((item) => ( - - - {item.name} - - ))} - {/* APK Download in Mobile Nav */} - -
- - - - -
- TV App -
-
- - {/* Main Content Area */} -
- {children} -
-
- ); -}; +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 ( +
+ {/* Sidebar Navigation */} + + + {/* Mobile Bottom Nav (Visible only on small screens) */} +
+ {NAV_ITEMS.slice(0, 4).map((item) => ( + + + {item.name} + + ))} + {/* APK Download in Mobile Nav */} + +
+ + + + +
+ TV App +
+
+ + {/* Main Content Area */} +
+ {children} +
+
+ ); +}; diff --git a/frontend-react/src/themes/netflix/MovieGrid.tsx b/frontend-react/src/themes/netflix/MovieGrid.tsx index bde1cda..4a131e0 100644 --- a/frontend-react/src/themes/netflix/MovieGrid.tsx +++ b/frontend-react/src/themes/netflix/MovieGrid.tsx @@ -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 ( -
- {title &&

{title}

} -
- {[...Array(12)].map((_, i) => ( -
- ))} -
-
- ); - } - - return ( -
- {title &&

{title} >

} -
- {movies.map((movie) => ( - - ))} -
-
- ); -}; +import type { Movie } from '../../types'; +import { Card } from './Card'; + +export const MovieGrid = ({ movies, loading, title }: { movies: Movie[], loading?: boolean, title?: string }) => { + if (loading) { + return ( +
+ {title &&

{title}

} +
+ {[...Array(12)].map((_, i) => ( +
+ ))} +
+
+ ); + } + + return ( +
+ {title &&

{title} >

} +
+ {movies.map((movie) => ( + + ))} +
+
+ ); +}; diff --git a/frontend-react/src/themes/netflix/NetflixHome.tsx b/frontend-react/src/themes/netflix/NetflixHome.tsx index 5bf03f2..d9e6726 100644 --- a/frontend-react/src/themes/netflix/NetflixHome.tsx +++ b/frontend-react/src/themes/netflix/NetflixHome.tsx @@ -1,14 +1,14 @@ -import { Layout } from './Layout'; -import { HomeContent } from '../../components/HomeContent'; -import { SettingsPanel } from '../../components/SettingsPanel'; - -export const NetflixHome = () => { - return ( - -
- -
- -
- ); -}; +import { Layout } from './Layout'; +import { HomeContent } from '../../components/HomeContent'; +import { SettingsPanel } from '../../components/SettingsPanel'; + +export const NetflixHome = () => { + return ( + +
+ +
+ +
+ ); +}; diff --git a/frontend-react/src/themes/netflix/WatchPage.tsx b/frontend-react/src/themes/netflix/WatchPage.tsx index 20f4926..5e73079 100644 --- a/frontend-react/src/themes/netflix/WatchPage.tsx +++ b/frontend-react/src/themes/netflix/WatchPage.tsx @@ -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(''); - - // 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) || {}; - - 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
Loading...
; - - return ( - -
- {/* Back Navigation */} -
- -
- - {/* 1. Cinema Player Section */} -
- {loading && ( -
-
-
- )} - {(() => { - const activeEpisode = currentServerEpisodes?.find(e => e.number === currentEpisode); - if (!activeEpisode?.url) { - return ( -
-
-

Coming Soon

-

- We're busy uploading the best quality version of this movie. -

-
-
-
- ); - } - - return ( -
- - {/* 2. Content Info & Rows */} -
- {/* Glass Info Card */} -
-

{movie.title}

- - {/* Meta Tags */} -
- 98% Match - {movie.year || '2024'} - HD - {movie.original_title} -
- -
-
- - {/* Episodes Section - Compact Grid */} - {currentServerEpisodes.length > 0 && ( -
-
-
-

Episodes

- - {/* Server Selector */} - {serverNames.length > 1 && ( -
- {serverNames.map(server => ( - - ))} -
- )} -
-
{currentServerEpisodes.length} Items
-
- -
- {visibleEpisodes.map((ep) => ( - - ))} -
- - {currentServerEpisodes.length > 20 && ( - - )} -
- )} - - {/* Related Content Section */} -
- - - - -
-
-
- ); -}; +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(''); + + // 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) || {}; + + 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
Loading...
; + + return ( + +
+ {/* Back Navigation */} +
+ +
+ + {/* 1. Cinema Player Section */} +
+ {loading && ( +
+
+
+ )} + {(() => { + const activeEpisode = currentServerEpisodes?.find(e => e.number === currentEpisode); + if (!activeEpisode?.url) { + return ( +
+
+

Coming Soon

+

+ We're busy uploading the best quality version of this movie. +

+
+
+
+ ); + } + + return ( +
+ + {/* 2. Content Info & Rows */} +
+ {/* Glass Info Card */} +
+

{movie.title}

+ + {/* Meta Tags */} +
+ 98% Match + {movie.year || '2024'} + HD + {movie.original_title} +
+ +
+
+ + {/* Episodes Section - Compact Grid */} + {currentServerEpisodes.length > 0 && ( +
+
+
+

Episodes

+ + {/* Server Selector */} + {serverNames.length > 1 && ( +
+ {serverNames.map(server => ( + + ))} +
+ )} +
+
{currentServerEpisodes.length} Items
+
+ +
+ {visibleEpisodes.map((ep) => ( + + ))} +
+ + {currentServerEpisodes.length > 20 && ( + + )} +
+ )} + + {/* Related Content Section */} +
+ + + + +
+
+
+ ); +}; diff --git a/frontend-react/src/themes/netflix/index.ts b/frontend-react/src/themes/netflix/index.ts index 835138f..6ca1bfa 100644 --- a/frontend-react/src/themes/netflix/index.ts +++ b/frontend-react/src/themes/netflix/index.ts @@ -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 + }, +}; diff --git a/frontend-react/src/types/Theme.ts b/frontend-react/src/types/Theme.ts index 1b03a24..1423ff4 100644 --- a/frontend-react/src/types/Theme.ts +++ b/frontend-react/src/types/Theme.ts @@ -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; +} diff --git a/frontend-react/src/types/index.ts b/frontend-react/src/types/index.ts index a8e5636..c44f697 100644 --- a/frontend-react/src/types/index.ts +++ b/frontend-react/src/types/index.ts @@ -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; +} diff --git a/frontend-react/tailwind.config.js b/frontend-react/tailwind.config.js index ceb0351..e5619ae 100644 --- a/frontend-react/tailwind.config.js +++ b/frontend-react/tailwind.config.js @@ -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: [], +} diff --git a/start-dev.ps1 b/start-dev.ps1 index 1dca52d..3ee0fc6 100644 --- a/start-dev.ps1 +++ b/start-dev.ps1 @@ -1,51 +1,51 @@ -# Streamflow Dev Start Script (Auto-Restart) - -Write-Host "=============================" -ForegroundColor Cyan -Write-Host " Streamflow Dev Launcher " -ForegroundColor Cyan -Write-Host "=============================" -ForegroundColor Cyan - -$BackendPort = 8000 -$FrontendPort = 5173 - -# Helper function to kill processes on a port -function Kill-Port($port) { - echo "Checking port $port..." - $connection = Get-NetTCPConnection -LocalPort $port -ErrorAction SilentlyContinue - if ($connection) { - $pidNum = $connection.OwningProcess - Write-Host " -> Killing process $pidNum on port $port" -ForegroundColor Yellow - Stop-Process -Id $pidNum -Force -ErrorAction SilentlyContinue - } else { - Write-Host " -> Port $port is free." -ForegroundColor Green - } -} - -# 1. Cleanup -Write-Host "`n[1/4] Cleaning up existing processes..." -ForegroundColor White -Kill-Port $BackendPort -Kill-Port $FrontendPort - -# 2. Start Backend -Write-Host "`n[2/4] Starting Backend (Go)..." -ForegroundColor White -$backendProcess = Start-Process -FilePath "go" -ArgumentList "run cmd/server/main.go" -WorkingDirectory "$PSScriptRoot\backend" -PassThru -NoNewWindow:$false -Write-Host " -> Backend started (PID: $($backendProcess.Id))" -ForegroundColor Green - -# 3. Start Frontend -Write-Host "`n[3/4] Starting Frontend (Vite)..." -ForegroundColor White -# Use npm.cmd for Windows compatibility -$frontendProcess = Start-Process -FilePath "npm.cmd" -ArgumentList "run dev" -WorkingDirectory "$PSScriptRoot\frontend-react" -PassThru -NoNewWindow:$false -Write-Host " -> Frontend started (PID: $($frontendProcess.Id))" -ForegroundColor Green - -# 4. Launch Browser -Write-Host "`n[4/4] Waiting for services..." -ForegroundColor White -for ($i = 5; $i -gt 0; $i--) { - Write-Host " -> Launching in $i seconds..." -NoNewline - Start-Sleep -Seconds 1 - Write-Host "`r" -NoNewline -} - -Write-Host "`n -> Opening http://localhost:$FrontendPort" -ForegroundColor Cyan -Start-Process "http://localhost:$FrontendPort" - -Write-Host "`nAll systems go! Close the pop-up windows to stop the servers." -ForegroundColor Magenta -Start-Sleep -Seconds 3 +# Streamflow Dev Start Script (Auto-Restart) + +Write-Host "=============================" -ForegroundColor Cyan +Write-Host " Streamflow Dev Launcher " -ForegroundColor Cyan +Write-Host "=============================" -ForegroundColor Cyan + +$BackendPort = 8000 +$FrontendPort = 5173 + +# Helper function to kill processes on a port +function Kill-Port($port) { + echo "Checking port $port..." + $connection = Get-NetTCPConnection -LocalPort $port -ErrorAction SilentlyContinue + if ($connection) { + $pidNum = $connection.OwningProcess + Write-Host " -> Killing process $pidNum on port $port" -ForegroundColor Yellow + Stop-Process -Id $pidNum -Force -ErrorAction SilentlyContinue + } else { + Write-Host " -> Port $port is free." -ForegroundColor Green + } +} + +# 1. Cleanup +Write-Host "`n[1/4] Cleaning up existing processes..." -ForegroundColor White +Kill-Port $BackendPort +Kill-Port $FrontendPort + +# 2. Start Backend +Write-Host "`n[2/4] Starting Backend (Go)..." -ForegroundColor White +$backendProcess = Start-Process -FilePath "go" -ArgumentList "run cmd/server/main.go" -WorkingDirectory "$PSScriptRoot\backend" -PassThru -NoNewWindow:$false +Write-Host " -> Backend started (PID: $($backendProcess.Id))" -ForegroundColor Green + +# 3. Start Frontend +Write-Host "`n[3/4] Starting Frontend (Vite)..." -ForegroundColor White +# Use npm.cmd for Windows compatibility +$frontendProcess = Start-Process -FilePath "npm.cmd" -ArgumentList "run dev" -WorkingDirectory "$PSScriptRoot\frontend-react" -PassThru -NoNewWindow:$false +Write-Host " -> Frontend started (PID: $($frontendProcess.Id))" -ForegroundColor Green + +# 4. Launch Browser +Write-Host "`n[4/4] Waiting for services..." -ForegroundColor White +for ($i = 5; $i -gt 0; $i--) { + Write-Host " -> Launching in $i seconds..." -NoNewline + Start-Sleep -Seconds 1 + Write-Host "`r" -NoNewline +} + +Write-Host "`n -> Opening http://localhost:$FrontendPort" -ForegroundColor Cyan +Start-Process "http://localhost:$FrontendPort" + +Write-Host "`nAll systems go! Close the pop-up windows to stop the servers." -ForegroundColor Magenta +Start-Sleep -Seconds 3