V3 Release: Integrated Android TV App App across all themes, updated Docker for Synology NAS
This commit is contained in:
parent
c2dd326855
commit
05b320e823
54 changed files with 3086 additions and 38 deletions
40
README.md
40
README.md
|
|
@ -1,4 +1,4 @@
|
|||
# StreamFlow V2
|
||||
# StreamFlow V3
|
||||
|
||||
StreamFlow is a high-performance video streaming web application featuring a pure Go backend and a modern React + Tailwind frontend.
|
||||
|
||||
|
|
@ -8,7 +8,8 @@ StreamFlow is a high-performance video streaming web application featuring a pur
|
|||
- **High Performance**: Backend written in Go (Golang) for speed and concurrency.
|
||||
- **Smart Scraping**: Integrated scraping engine (Rophim) with automated episode extraction.
|
||||
- **HLS Streaming**: Native HLS playback support.
|
||||
- **Docker Ready**: Multi-stage Docker build for optimized deployment.
|
||||
- **Android TV App**: Native TV app support with dedicated APK available for download.
|
||||
- **Docker Ready**: Multi-stage Docker build optimized for NAS Synology (linux/amd64).
|
||||
|
||||
## 🛠️ Tech Stack
|
||||
|
||||
|
|
@ -43,12 +44,37 @@ StreamFlow is a high-performance video streaming web application featuring a pur
|
|||
```
|
||||
Frontend runs at `http://localhost:5173` (proxying to backend).
|
||||
|
||||
### Docker Deployment
|
||||
### Docker Deployment (Recommended for NAS Synology)
|
||||
|
||||
```bash
|
||||
docker-compose up -d --build
|
||||
```
|
||||
Access the application at `http://localhost:8000`.
|
||||
1. **Environmental Variables**: Create a `.env` file or set them in your NAS:
|
||||
```env
|
||||
TMDB_API_KEY=your_api_key_here
|
||||
```
|
||||
|
||||
2. **Run with Docker Compose**:
|
||||
```yaml
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
streamflow:
|
||||
image: git.khoavo.myds.me/vndangkhoa/kv-streamflow:v3
|
||||
container_name: streamflow
|
||||
platform: linux/amd64
|
||||
ports:
|
||||
- "3478:8000"
|
||||
environment:
|
||||
- DATABASE_URL=/app/data/streamflow.db
|
||||
- TMDB_API_KEY=${TMDB_API_KEY}
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
restart: always
|
||||
```
|
||||
|
||||
```bash
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
Access the application at `http://YOUR_NAS_IP:3478`. You can download the **Android TV App** directly from the navigation bar once the webapp is running.
|
||||
|
||||
## 📂 Project Structure
|
||||
|
||||
|
|
|
|||
87
android-tv/app/build.gradle.kts
Normal file
87
android-tv/app/build.gradle.kts
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
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 = 1
|
||||
versionName = "1.0.0"
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
isMinifyEnabled = true
|
||||
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")
|
||||
}
|
||||
15
android-tv/app/proguard-rules.pro
vendored
Normal file
15
android-tv/app/proguard-rules.pro
vendored
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
# ProGuard rules for StreamFlow TV
|
||||
|
||||
# Moshi
|
||||
-keep class com.streamflow.tv.data.model.** { *; }
|
||||
-keepclassmembers class com.streamflow.tv.data.model.** { *; }
|
||||
|
||||
# Retrofit
|
||||
-dontwarn retrofit2.**
|
||||
-keep class retrofit2.** { *; }
|
||||
-keepattributes Signature
|
||||
-keepattributes Exceptions
|
||||
|
||||
# OkHttp
|
||||
-dontwarn okhttp3.**
|
||||
-dontwarn okio.**
|
||||
36
android-tv/app/src/main/AndroidManifest.xml
Normal file
36
android-tv/app/src/main/AndroidManifest.xml
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
|
||||
<uses-feature
|
||||
android:name="android.software.leanback"
|
||||
android:required="true" />
|
||||
<uses-feature
|
||||
android:name="android.hardware.touchscreen"
|
||||
android:required="false" />
|
||||
|
||||
<application
|
||||
android:name=".StreamFlowApp"
|
||||
android:allowBackup="true"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:banner="@drawable/app_banner"
|
||||
android:theme="@style/Theme.StreamFlowTV"
|
||||
android:supportsRtl="true"
|
||||
android:usesCleartextTraffic="true">
|
||||
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:configChanges="orientation|screenSize|screenLayout|keyboardHidden"
|
||||
android:screenOrientation="landscape">
|
||||
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
</application>
|
||||
</manifest>
|
||||
165
android-tv/app/src/main/java/com/streamflow/tv/MainActivity.kt
Normal file
165
android-tv/app/src/main/java/com/streamflow/tv/MainActivity.kt
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
package com.streamflow.tv
|
||||
|
||||
import android.os.Bundle
|
||||
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)
|
||||
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) {
|
||||
currentTheme = userRepo.theme.first()
|
||||
val serverUrl = userRepo.serverUrl.first()
|
||||
if (serverUrl.isNotBlank()) {
|
||||
ApiClient.baseUrl = serverUrl
|
||||
}
|
||||
}
|
||||
|
||||
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 }
|
||||
)
|
||||
) { entry ->
|
||||
val slug = entry.arguments?.getString("slug")
|
||||
val episode = entry.arguments?.getInt("episode") ?: 1
|
||||
android.util.Log.e("StreamFlowNav", "Navigating to player: slug=$slug, episode=$episode")
|
||||
if (slug == null) {
|
||||
android.util.Log.e("StreamFlowNav", "Slug is null - not rendering PlayerScreen")
|
||||
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) }
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
package com.streamflow.tv
|
||||
|
||||
import android.app.Application
|
||||
|
||||
class StreamFlowApp : Application() {
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,52 @@
|
|||
package com.streamflow.tv.data.api
|
||||
|
||||
import com.squareup.moshi.Moshi
|
||||
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.logging.HttpLoggingInterceptor
|
||||
import retrofit2.Retrofit
|
||||
import retrofit2.converter.moshi.MoshiConverterFactory
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
object ApiClient {
|
||||
|
||||
// Production server on Synology NAS
|
||||
var baseUrl: String = "https://nf.khoavo.myds.me/"
|
||||
set(value) {
|
||||
field = if (value.endsWith("/")) value else "$value/"
|
||||
_api = null // Reset to rebuild
|
||||
}
|
||||
|
||||
private val moshi: Moshi = Moshi.Builder()
|
||||
.addLast(KotlinJsonAdapterFactory())
|
||||
.build()
|
||||
|
||||
private val okHttpClient: OkHttpClient = OkHttpClient.Builder()
|
||||
.connectTimeout(15, TimeUnit.SECONDS)
|
||||
.readTimeout(30, TimeUnit.SECONDS)
|
||||
.addInterceptor(
|
||||
HttpLoggingInterceptor().apply {
|
||||
level = HttpLoggingInterceptor.Level.BASIC
|
||||
}
|
||||
)
|
||||
.build()
|
||||
|
||||
private var _api: StreamFlowApi? = null
|
||||
|
||||
val api: StreamFlowApi
|
||||
get() {
|
||||
if (_api == null) {
|
||||
_api = Retrofit.Builder()
|
||||
.baseUrl(baseUrl)
|
||||
.client(okHttpClient)
|
||||
.addConverterFactory(MoshiConverterFactory.create(moshi))
|
||||
.build()
|
||||
.create(StreamFlowApi::class.java)
|
||||
}
|
||||
return _api!!
|
||||
}
|
||||
|
||||
fun imageProxyUrl(url: String, width: Int = 400): String {
|
||||
return "${baseUrl}api/images/proxy?url=${java.net.URLEncoder.encode(url, "UTF-8")}&width=$width"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
package com.streamflow.tv.data.api
|
||||
|
||||
import com.streamflow.tv.data.model.*
|
||||
import retrofit2.http.*
|
||||
|
||||
interface StreamFlowApi {
|
||||
|
||||
@GET("api/videos/home")
|
||||
suspend fun getHomeVideos(
|
||||
@Query("category") category: String? = null,
|
||||
@Query("page") page: Int = 1
|
||||
): List<Movie>
|
||||
|
||||
@GET("api/videos/search")
|
||||
suspend fun searchVideos(
|
||||
@Query("q") query: String,
|
||||
@Query("page") page: Int = 1
|
||||
): List<Movie>
|
||||
|
||||
@GET("api/videos/{slug}")
|
||||
suspend fun getMovieDetail(
|
||||
@Path("slug") slug: String
|
||||
): MovieDetailResponse
|
||||
|
||||
@POST("api/extract")
|
||||
suspend fun extractVideo(
|
||||
@Body request: ExtractRequest
|
||||
): VideoSource
|
||||
|
||||
@GET("api/categories/genres")
|
||||
suspend fun getGenres(): List<Category>
|
||||
|
||||
@GET("api/categories/countries")
|
||||
suspend fun getCountries(): List<Category>
|
||||
}
|
||||
|
|
@ -0,0 +1,110 @@
|
|||
package com.streamflow.tv.data.model
|
||||
|
||||
import com.squareup.moshi.Json
|
||||
import com.squareup.moshi.JsonClass
|
||||
|
||||
@JsonClass(generateAdapter = false)
|
||||
data class Movie(
|
||||
val id: String = "",
|
||||
val title: String = "",
|
||||
@Json(name = "original_title") val originalTitle: String? = null,
|
||||
val slug: String = "",
|
||||
val thumbnail: String = "",
|
||||
val backdrop: String? = null,
|
||||
val quality: String? = null,
|
||||
val year: Int? = null,
|
||||
val category: String = "",
|
||||
val time: String? = null,
|
||||
val lang: String? = null,
|
||||
val director: String? = null,
|
||||
val cast: List<String>? = null
|
||||
)
|
||||
|
||||
@JsonClass(generateAdapter = false)
|
||||
data class MovieDetail(
|
||||
val id: String = "",
|
||||
val title: String = "",
|
||||
@Json(name = "original_title") val originalTitle: String? = null,
|
||||
val slug: String = "",
|
||||
val thumbnail: String = "",
|
||||
val backdrop: String? = null,
|
||||
val quality: String? = null,
|
||||
val year: Int? = null,
|
||||
val category: String = "",
|
||||
val description: String = "",
|
||||
val rating: String? = null,
|
||||
val duration: Int? = null,
|
||||
val genre: String? = null,
|
||||
val director: String? = null,
|
||||
val country: String? = null,
|
||||
val cast: List<String>? = null,
|
||||
val episodes: List<Episode>? = null
|
||||
) {
|
||||
fun toMovie(): Movie = Movie(
|
||||
id = id,
|
||||
title = title,
|
||||
originalTitle = originalTitle,
|
||||
slug = slug,
|
||||
thumbnail = thumbnail,
|
||||
backdrop = backdrop,
|
||||
quality = quality,
|
||||
year = year,
|
||||
category = category,
|
||||
director = director,
|
||||
cast = cast
|
||||
)
|
||||
}
|
||||
|
||||
@JsonClass(generateAdapter = false)
|
||||
data class Episode(
|
||||
val number: Int = 0,
|
||||
val title: String = "",
|
||||
val url: String = ""
|
||||
)
|
||||
|
||||
@JsonClass(generateAdapter = false)
|
||||
data class VideoSource(
|
||||
@Json(name = "stream_url") val streamUrl: String = "",
|
||||
val resolution: String = "",
|
||||
@Json(name = "format_id") val formatId: String = ""
|
||||
)
|
||||
|
||||
@JsonClass(generateAdapter = false)
|
||||
data class Category(
|
||||
val name: String = "",
|
||||
val slug: String = ""
|
||||
)
|
||||
|
||||
@JsonClass(generateAdapter = false)
|
||||
data class HomeResponse(
|
||||
val items: List<Movie> = emptyList(),
|
||||
val totalPages: Int = 1,
|
||||
val currentPage: Int = 1
|
||||
)
|
||||
|
||||
@JsonClass(generateAdapter = false)
|
||||
data class ExtractRequest(
|
||||
val url: String
|
||||
)
|
||||
|
||||
@JsonClass(generateAdapter = false)
|
||||
data class MovieDetailResponse(
|
||||
val id: String = "",
|
||||
val title: String = "",
|
||||
@Json(name = "original_title") val originalTitle: String? = null,
|
||||
val slug: String = "",
|
||||
val thumbnail: String = "",
|
||||
val backdrop: String? = null,
|
||||
val quality: String? = null,
|
||||
val year: Int? = null,
|
||||
val category: String = "",
|
||||
val description: String = "",
|
||||
val rating: String? = null,
|
||||
val duration: Int? = null,
|
||||
val genre: String? = null,
|
||||
val director: String? = null,
|
||||
val country: String? = null,
|
||||
val cast: List<String>? = null,
|
||||
val episodes: List<Episode>? = null
|
||||
)
|
||||
|
||||
|
|
@ -0,0 +1,60 @@
|
|||
package com.streamflow.tv.data.repository
|
||||
|
||||
import com.streamflow.tv.data.api.ApiClient
|
||||
import com.streamflow.tv.data.model.*
|
||||
|
||||
class MovieRepository {
|
||||
|
||||
private val api get() = ApiClient.api
|
||||
|
||||
suspend fun getHomeVideos(category: String? = null, page: Int = 1): HomeResponse {
|
||||
val list = api.getHomeVideos(category, page)
|
||||
android.util.Log.e("MovieRepo", "getHomeVideos($category): Received ${list.size} items")
|
||||
return HomeResponse(items = list, totalPages = 10, currentPage = page)
|
||||
}
|
||||
|
||||
suspend fun searchVideos(query: String, page: Int = 1): HomeResponse {
|
||||
val list = api.searchVideos(query, page)
|
||||
android.util.Log.e("MovieRepo", "searchVideos($query): Received ${list.size} items")
|
||||
return HomeResponse(items = list, totalPages = 1, currentPage = page)
|
||||
}
|
||||
|
||||
suspend fun getMovieDetail(slug: String): MovieDetail {
|
||||
val response = api.getMovieDetail(slug)
|
||||
|
||||
// API returns a flat list of episodes
|
||||
val episodes = response.episodes ?: emptyList()
|
||||
|
||||
return MovieDetail(
|
||||
id = response.id,
|
||||
title = response.title,
|
||||
originalTitle = response.originalTitle,
|
||||
slug = response.slug,
|
||||
thumbnail = response.thumbnail,
|
||||
backdrop = response.backdrop,
|
||||
quality = response.quality,
|
||||
year = response.year,
|
||||
category = response.category,
|
||||
description = response.description,
|
||||
rating = response.rating,
|
||||
duration = response.duration,
|
||||
genre = response.genre,
|
||||
director = response.director,
|
||||
country = response.country,
|
||||
cast = response.cast,
|
||||
episodes = episodes
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun extractVideo(url: String): VideoSource {
|
||||
return api.extractVideo(ExtractRequest(url))
|
||||
}
|
||||
|
||||
suspend fun getGenres(): List<Category> {
|
||||
return api.getGenres()
|
||||
}
|
||||
|
||||
suspend fun getCountries(): List<Category> {
|
||||
return api.getCountries()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,103 @@
|
|||
package com.streamflow.tv.data.repository
|
||||
|
||||
import android.content.Context
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.*
|
||||
import androidx.datastore.preferences.preferencesDataStore
|
||||
import com.squareup.moshi.Moshi
|
||||
import com.squareup.moshi.Types
|
||||
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
|
||||
import com.streamflow.tv.data.model.Movie
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
|
||||
private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "user_data")
|
||||
|
||||
class UserDataRepository(private val context: Context) {
|
||||
|
||||
companion object {
|
||||
private val MY_LIST_KEY = stringPreferencesKey("my_list")
|
||||
private val WATCH_HISTORY_KEY = stringPreferencesKey("watch_history")
|
||||
private val THEME_KEY = stringPreferencesKey("theme")
|
||||
private val SERVER_URL_KEY = stringPreferencesKey("server_url")
|
||||
|
||||
private const val MAX_HISTORY = 50
|
||||
}
|
||||
|
||||
private val moshi = Moshi.Builder().addLast(KotlinJsonAdapterFactory()).build()
|
||||
private val movieListType = Types.newParameterizedType(List::class.java, Movie::class.java)
|
||||
private val movieListAdapter = moshi.adapter<List<Movie>>(movieListType)
|
||||
|
||||
// --- My List ---
|
||||
|
||||
val myList: Flow<List<Movie>> = context.dataStore.data.map { prefs ->
|
||||
val json = prefs[MY_LIST_KEY] ?: "[]"
|
||||
movieListAdapter.fromJson(json) ?: emptyList()
|
||||
}
|
||||
|
||||
suspend fun addToMyList(movie: Movie) {
|
||||
context.dataStore.edit { prefs ->
|
||||
val current = movieListAdapter.fromJson(prefs[MY_LIST_KEY] ?: "[]") ?: emptyList()
|
||||
if (current.none { it.slug == movie.slug }) {
|
||||
prefs[MY_LIST_KEY] = movieListAdapter.toJson(current + movie)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun removeFromMyList(slug: String) {
|
||||
context.dataStore.edit { prefs ->
|
||||
val current = movieListAdapter.fromJson(prefs[MY_LIST_KEY] ?: "[]") ?: emptyList()
|
||||
prefs[MY_LIST_KEY] = movieListAdapter.toJson(current.filter { it.slug != slug })
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun isInMyList(slug: String): Boolean {
|
||||
var found = false
|
||||
context.dataStore.edit { prefs ->
|
||||
val current = movieListAdapter.fromJson(prefs[MY_LIST_KEY] ?: "[]") ?: emptyList()
|
||||
found = current.any { it.slug == slug }
|
||||
}
|
||||
return found
|
||||
}
|
||||
|
||||
// --- Watch History ---
|
||||
|
||||
val watchHistory: Flow<List<Movie>> = context.dataStore.data.map { prefs ->
|
||||
val json = prefs[WATCH_HISTORY_KEY] ?: "[]"
|
||||
movieListAdapter.fromJson(json) ?: emptyList()
|
||||
}
|
||||
|
||||
suspend fun addToHistory(movie: Movie) {
|
||||
context.dataStore.edit { prefs ->
|
||||
val current = movieListAdapter.fromJson(prefs[WATCH_HISTORY_KEY] ?: "[]")?.toMutableList() ?: mutableListOf()
|
||||
current.removeAll { it.slug == movie.slug }
|
||||
current.add(0, movie) // Most recent first
|
||||
val trimmed = current.take(MAX_HISTORY)
|
||||
prefs[WATCH_HISTORY_KEY] = movieListAdapter.toJson(trimmed)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Theme ---
|
||||
|
||||
val theme: Flow<String> = context.dataStore.data.map { prefs ->
|
||||
prefs[THEME_KEY] ?: "default"
|
||||
}
|
||||
|
||||
suspend fun setTheme(theme: String) {
|
||||
context.dataStore.edit { prefs ->
|
||||
prefs[THEME_KEY] = theme
|
||||
}
|
||||
}
|
||||
|
||||
// --- Server URL ---
|
||||
|
||||
val serverUrl: Flow<String> = context.dataStore.data.map { prefs ->
|
||||
prefs[SERVER_URL_KEY] ?: "https://nf.khoavo.myds.me"
|
||||
}
|
||||
|
||||
suspend fun setServerUrl(url: String) {
|
||||
context.dataStore.edit { prefs ->
|
||||
prefs[SERVER_URL_KEY] = url
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,76 @@
|
|||
package com.streamflow.tv.ui.components
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.tv.foundation.lazy.grid.TvGridCells
|
||||
import androidx.tv.foundation.lazy.grid.TvLazyVerticalGrid
|
||||
import androidx.tv.foundation.lazy.grid.items
|
||||
import androidx.tv.material3.*
|
||||
import com.streamflow.tv.data.model.Episode
|
||||
import com.streamflow.tv.ui.theme.StreamFlowTheme
|
||||
|
||||
@OptIn(ExperimentalTvMaterial3Api::class)
|
||||
@Composable
|
||||
fun EpisodeSelector(
|
||||
episodes: List<Episode>,
|
||||
currentEpisode: Int,
|
||||
onEpisodeSelect: (Episode) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val colors = StreamFlowTheme.colors
|
||||
|
||||
Column(modifier = modifier) {
|
||||
Text(
|
||||
text = "Episodes",
|
||||
style = StreamFlowTheme.typography.headlineMedium,
|
||||
modifier = Modifier.padding(bottom = 12.dp)
|
||||
)
|
||||
|
||||
android.util.Log.e("EpisodeSelector", "Rendering grid with ${episodes.size} episodes")
|
||||
TvLazyVerticalGrid(
|
||||
columns = TvGridCells.Adaptive(minSize = 120.dp),
|
||||
contentPadding = PaddingValues(4.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
items(episodes) { episode ->
|
||||
val isActive = episode.number == currentEpisode
|
||||
var isFocused by remember { mutableStateOf(false) }
|
||||
|
||||
Surface(
|
||||
onClick = { onEpisodeSelect(episode) },
|
||||
modifier = Modifier
|
||||
.onFocusChanged { isFocused = it.isFocused },
|
||||
shape = ClickableSurfaceDefaults.shape(
|
||||
shape = RoundedCornerShape(8.dp)
|
||||
),
|
||||
colors = ClickableSurfaceDefaults.colors(
|
||||
containerColor = if (isActive) colors.primary.copy(alpha = 0.2f) else colors.surfaceVariant,
|
||||
focusedContainerColor = colors.primary.copy(alpha = 0.3f)
|
||||
),
|
||||
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.05f)
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 12.dp, horizontal = 16.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = if (episode.title.isNotBlank()) episode.title else "Ep ${episode.number}",
|
||||
style = StreamFlowTheme.typography.labelLarge.copy(
|
||||
color = if (isActive) colors.primary else Color.White
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,159 @@
|
|||
package com.streamflow.tv.ui.components
|
||||
|
||||
import androidx.compose.animation.*
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.tv.material3.*
|
||||
import coil.compose.AsyncImage
|
||||
import com.streamflow.tv.data.api.ApiClient
|
||||
import com.streamflow.tv.data.model.Movie
|
||||
import com.streamflow.tv.ui.theme.StreamFlowTheme
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
@OptIn(ExperimentalTvMaterial3Api::class)
|
||||
@Composable
|
||||
fun HeroBanner(
|
||||
movies: List<Movie>,
|
||||
onPlayClick: (Movie) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
if (movies.isEmpty()) return
|
||||
val colors = StreamFlowTheme.colors
|
||||
|
||||
var currentIndex by remember { mutableIntStateOf(0) }
|
||||
val currentMovie = movies[currentIndex]
|
||||
|
||||
LaunchedEffect(currentIndex) {
|
||||
delay(6000)
|
||||
currentIndex = (currentIndex + 1) % movies.size
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.height(480.dp)
|
||||
) {
|
||||
AnimatedContent(
|
||||
targetState = currentMovie,
|
||||
transitionSpec = { fadeIn() togetherWith fadeOut() },
|
||||
label = "hero-crossfade"
|
||||
) { movie ->
|
||||
AsyncImage(
|
||||
model = ApiClient.imageProxyUrl(movie.backdrop ?: movie.thumbnail, 1280),
|
||||
contentDescription = movie.title,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier.fillMaxSize()
|
||||
)
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(
|
||||
Brush.horizontalGradient(
|
||||
colors = listOf(
|
||||
colors.background.copy(alpha = 0.9f),
|
||||
colors.background.copy(alpha = 0.5f),
|
||||
Color.Transparent
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.fillMaxHeight(0.4f)
|
||||
.align(Alignment.BottomCenter)
|
||||
.background(
|
||||
Brush.verticalGradient(
|
||||
colors = listOf(Color.Transparent, colors.background)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.align(Alignment.CenterStart)
|
||||
.padding(start = 48.dp, end = 200.dp)
|
||||
.fillMaxHeight(),
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
currentMovie.quality?.let { quality ->
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.background(colors.primary, RoundedCornerShape(4.dp))
|
||||
.padding(horizontal = 8.dp, vertical = 4.dp)
|
||||
) {
|
||||
Text(
|
||||
text = quality,
|
||||
style = StreamFlowTheme.typography.labelSmall.copy(color = Color.White)
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(12.dp))
|
||||
}
|
||||
|
||||
Text(
|
||||
text = currentMovie.title,
|
||||
style = StreamFlowTheme.typography.displayLarge,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(12.dp))
|
||||
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
currentMovie.year?.let {
|
||||
Text("$it", style = StreamFlowTheme.typography.bodyLarge)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
Surface(
|
||||
onClick = { onPlayClick(currentMovie) },
|
||||
shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(8.dp)),
|
||||
colors = ClickableSurfaceDefaults.colors(
|
||||
containerColor = colors.primary,
|
||||
focusedContainerColor = colors.accent
|
||||
),
|
||||
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.05f)
|
||||
) {
|
||||
Text(
|
||||
text = "▶ Play Now",
|
||||
style = StreamFlowTheme.typography.titleMedium.copy(color = Color.White),
|
||||
modifier = Modifier.padding(horizontal = 24.dp, vertical = 12.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomCenter)
|
||||
.padding(bottom = 16.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
movies.forEachIndexed { index, _ ->
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(if (index == currentIndex) 24.dp else 8.dp, 8.dp)
|
||||
.clip(CircleShape)
|
||||
.background(
|
||||
if (index == currentIndex) colors.primary
|
||||
else Color.White.copy(alpha = 0.3f)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,97 @@
|
|||
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)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
package com.streamflow.tv.ui.components
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.tv.foundation.lazy.list.TvLazyRow
|
||||
import androidx.tv.foundation.lazy.list.items
|
||||
import androidx.tv.material3.ExperimentalTvMaterial3Api
|
||||
import androidx.tv.material3.Text
|
||||
import com.streamflow.tv.data.model.Movie
|
||||
import com.streamflow.tv.ui.theme.StreamFlowTheme
|
||||
|
||||
@OptIn(ExperimentalTvMaterial3Api::class)
|
||||
@Composable
|
||||
fun MovieRow(
|
||||
title: String,
|
||||
movies: List<Movie>,
|
||||
onMovieClick: (Movie) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Column(modifier = modifier.padding(vertical = 12.dp)) {
|
||||
// Section title
|
||||
Text(
|
||||
text = title,
|
||||
style = StreamFlowTheme.typography.headlineMedium,
|
||||
modifier = Modifier.padding(start = 48.dp, bottom = 12.dp)
|
||||
)
|
||||
|
||||
// Horizontal scrolling row of cards
|
||||
TvLazyRow(
|
||||
contentPadding = PaddingValues(horizontal = 48.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
items(movies) { movie ->
|
||||
MovieCard(
|
||||
movie = movie,
|
||||
onClick = { onMovieClick(movie) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,114 @@
|
|||
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
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalTvMaterial3Api::class)
|
||||
@Composable
|
||||
private fun NavRailItem(
|
||||
item: NavItem,
|
||||
isSelected: Boolean,
|
||||
onClick: () -> Unit,
|
||||
accentColor: Color
|
||||
) {
|
||||
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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,98 @@
|
|||
package com.streamflow.tv.ui.navigation
|
||||
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.navigation.NavType
|
||||
import androidx.navigation.compose.NavHost
|
||||
import androidx.navigation.compose.composable
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import androidx.navigation.navArgument
|
||||
import com.streamflow.tv.ui.screens.*
|
||||
|
||||
@Composable
|
||||
fun AppNavigation(
|
||||
currentTheme: String,
|
||||
onThemeChange: (String) -> Unit
|
||||
) {
|
||||
val navController = rememberNavController()
|
||||
|
||||
NavHost(navController = navController, startDestination = "home") {
|
||||
// Home (all categories)
|
||||
composable("home") {
|
||||
HomeScreen(
|
||||
onMovieClick = { slug -> navController.navigate("detail/$slug") }
|
||||
)
|
||||
}
|
||||
|
||||
// Home filtered by category
|
||||
composable(
|
||||
"home/{category}",
|
||||
arguments = listOf(navArgument("category") { type = NavType.StringType })
|
||||
) { backStackEntry ->
|
||||
val category = backStackEntry.arguments?.getString("category")
|
||||
HomeScreen(
|
||||
onMovieClick = { slug -> navController.navigate("detail/$slug") },
|
||||
category = category
|
||||
)
|
||||
}
|
||||
|
||||
// Movie Detail
|
||||
composable(
|
||||
"detail/{slug}",
|
||||
arguments = listOf(navArgument("slug") { type = NavType.StringType })
|
||||
) { backStackEntry ->
|
||||
val slug = backStackEntry.arguments?.getString("slug") ?: return@composable
|
||||
DetailScreen(
|
||||
slug = slug,
|
||||
onPlayClick = { s, ep -> navController.navigate("player/$s/$ep") },
|
||||
onBack = { navController.popBackStack() }
|
||||
)
|
||||
}
|
||||
|
||||
// Video Player
|
||||
composable(
|
||||
"player/{slug}/{episode}",
|
||||
arguments = listOf(
|
||||
navArgument("slug") { type = NavType.StringType },
|
||||
navArgument("episode") { type = NavType.IntType; defaultValue = 1 }
|
||||
)
|
||||
) { backStackEntry ->
|
||||
val slug = backStackEntry.arguments?.getString("slug") ?: return@composable
|
||||
val episode = backStackEntry.arguments?.getInt("episode") ?: 1
|
||||
PlayerScreen(slug = slug, episode = episode)
|
||||
}
|
||||
|
||||
// Search
|
||||
composable("search") {
|
||||
SearchScreen(
|
||||
onMovieClick = { slug -> navController.navigate("detail/$slug") }
|
||||
)
|
||||
}
|
||||
|
||||
// My List
|
||||
composable("mylist") {
|
||||
MyListScreen(
|
||||
onMovieClick = { slug -> navController.navigate("detail/$slug") }
|
||||
)
|
||||
}
|
||||
|
||||
// Settings
|
||||
composable("settings") {
|
||||
SettingsScreen(
|
||||
currentTheme = currentTheme,
|
||||
onThemeChange = onThemeChange
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Expose navController for SideNavRail
|
||||
LaunchedEffect(navController) {
|
||||
// Store nav controller reference for side nav
|
||||
}
|
||||
|
||||
// Provide nav controller via local
|
||||
CompositionLocalProvider(LocalNavController provides navController) {}
|
||||
}
|
||||
|
||||
val LocalNavController = staticCompositionLocalOf<androidx.navigation.NavHostController> {
|
||||
error("NavController not provided")
|
||||
}
|
||||
|
|
@ -0,0 +1,155 @@
|
|||
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()
|
||||
|
||||
LaunchedEffect(slug) {
|
||||
viewModel.loadMovie(slug)
|
||||
}
|
||||
|
||||
Log.e("DetailScreen", "Composing DetailScreen(slug=$slug, isLoading=${uiState.isLoading})")
|
||||
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
val movie = uiState.movie ?: return@Box
|
||||
Log.e("DetailScreen", "Rendering movie details: ${movie.title}")
|
||||
|
||||
val colors = StreamFlowTheme.colors
|
||||
|
||||
// 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()
|
||||
android.util.Log.e("DetailScreen", "Focus requested on Play button")
|
||||
}
|
||||
}
|
||||
|
||||
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, // Default to 1 for initial detail load
|
||||
onEpisodeSelect = { episode -> onPlayClick(movie.slug, episode.number) },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(200.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +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) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +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) })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,140 @@
|
|||
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.platform.LocalContext
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
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
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
// 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()
|
||||
}
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(Color.Black)
|
||||
) {
|
||||
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
|
||||
AndroidView(
|
||||
factory = { ctx ->
|
||||
PlayerView(ctx).apply {
|
||||
player = exoPlayer
|
||||
useController = true
|
||||
layoutParams = FrameLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.MATCH_PARENT
|
||||
)
|
||||
}
|
||||
},
|
||||
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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +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)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +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)
|
||||
|
|
@ -0,0 +1,65 @@
|
|||
package com.streamflow.tv.ui.theme
|
||||
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
||||
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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun StreamFlowTvTheme(
|
||||
themeName: String = "default",
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
val colors = streamFlowColors(themeName)
|
||||
CompositionLocalProvider(LocalStreamFlowColors provides colors) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +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
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
package com.streamflow.tv.viewmodel
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.streamflow.tv.data.model.MovieDetail
|
||||
import com.streamflow.tv.data.repository.MovieRepository
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
data class DetailUiState(
|
||||
val movie: MovieDetail? = null,
|
||||
val isLoading: Boolean = true,
|
||||
val error: String? = null,
|
||||
val isInMyList: Boolean = false
|
||||
)
|
||||
|
||||
class DetailViewModel : ViewModel() {
|
||||
|
||||
private val repository = MovieRepository()
|
||||
private val _uiState = MutableStateFlow(DetailUiState())
|
||||
val uiState: StateFlow<DetailUiState> = _uiState
|
||||
|
||||
fun loadMovie(slug: String) {
|
||||
android.util.Log.e("DetailVM", "loadMovie($slug) called")
|
||||
viewModelScope.launch {
|
||||
_uiState.value = DetailUiState(isLoading = true)
|
||||
try {
|
||||
val movie = repository.getMovieDetail(slug)
|
||||
android.util.Log.e("DetailVM", "loadMovie success: ${movie.title}, episodes: ${movie.episodes?.size}")
|
||||
_uiState.value = DetailUiState(movie = movie, isLoading = false)
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.e("DetailVM", "loadMovie failed", e)
|
||||
_uiState.value = DetailUiState(
|
||||
isLoading = false,
|
||||
error = e.message ?: "Failed to load movie details"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun toggleMyList(isInList: Boolean) {
|
||||
_uiState.value = _uiState.value.copy(isInMyList = !isInList)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,129 @@
|
|||
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.launch
|
||||
|
||||
data class HomeUiState(
|
||||
val heroMovies: List<Movie> = emptyList(),
|
||||
val watchedMovies: List<Movie> = emptyList(),
|
||||
val recommendedMovies: List<Movie> = emptyList(),
|
||||
val categoryMovies: Map<String, List<Movie>> = emptyMap(),
|
||||
val isLoading: Boolean = true,
|
||||
val error: String? = null,
|
||||
val currentCategory: String? = null
|
||||
)
|
||||
|
||||
class HomeViewModel : ViewModel() {
|
||||
|
||||
private val repository = MovieRepository()
|
||||
private val _uiState = MutableStateFlow(HomeUiState())
|
||||
val uiState: StateFlow<HomeUiState> = _uiState
|
||||
|
||||
private var userDataRepository: com.streamflow.tv.data.repository.UserDataRepository? = null
|
||||
|
||||
private val categories = listOf(
|
||||
"phim-le" to "Phim Lẻ",
|
||||
"phim-bo" to "Phim Bộ",
|
||||
"hoat-hinh" to "Hoạt Hình",
|
||||
"tv-shows" to "TV Shows"
|
||||
)
|
||||
|
||||
init {
|
||||
loadHome()
|
||||
}
|
||||
|
||||
fun loadHome(
|
||||
category: String? = null,
|
||||
userRepo: com.streamflow.tv.data.repository.UserDataRepository? = null
|
||||
) {
|
||||
if (userRepo != null) {
|
||||
this.userDataRepository = userRepo
|
||||
}
|
||||
|
||||
viewModelScope.launch {
|
||||
_uiState.value = _uiState.value.copy(isLoading = true, error = null, currentCategory = category)
|
||||
try {
|
||||
// Load history if repository is available
|
||||
val history = userRepo?.watchHistory?.first() ?: emptyList()
|
||||
|
||||
if (category != null) {
|
||||
// Load single category
|
||||
val response = repository.getHomeVideos(category)
|
||||
_uiState.value = _uiState.value.copy(
|
||||
heroMovies = response.items.take(5),
|
||||
watchedMovies = history,
|
||||
recommendedMovies = response.items.filter { m -> history.none { it.slug == m.slug } }.shuffled().take(10),
|
||||
categoryMovies = mapOf(
|
||||
categories.find { it.first == category }?.second.orEmpty() to response.items
|
||||
),
|
||||
isLoading = false
|
||||
)
|
||||
} else {
|
||||
// Load all categories for home
|
||||
val allMovies = mutableMapOf<String, List<Movie>>()
|
||||
var heroItems = listOf<Movie>()
|
||||
val allFlattened = mutableListOf<Movie>()
|
||||
|
||||
// 1. Initial categories
|
||||
categories.forEach { (slug, name) ->
|
||||
try {
|
||||
val response = repository.getHomeVideos(slug)
|
||||
allMovies[name] = response.items
|
||||
allFlattened.addAll(response.items)
|
||||
if (heroItems.isEmpty()) {
|
||||
heroItems = response.items.take(5)
|
||||
}
|
||||
} catch (_: Exception) { }
|
||||
}
|
||||
|
||||
// 2. Fetch Genres
|
||||
try {
|
||||
val genres = repository.getGenres()
|
||||
genres.take(8).forEach { genre ->
|
||||
try {
|
||||
val response = repository.getHomeVideos(genre.slug)
|
||||
if (response.items.isNotEmpty()) {
|
||||
allMovies["Genre: ${genre.name}"] = response.items
|
||||
allFlattened.addAll(response.items)
|
||||
}
|
||||
} catch (_: Exception) { }
|
||||
}
|
||||
} catch (_: Exception) { }
|
||||
|
||||
// 3. Fetch Countries
|
||||
try {
|
||||
val countries = repository.getCountries()
|
||||
countries.take(5).forEach { country ->
|
||||
try {
|
||||
val response = repository.getHomeVideos(country.slug)
|
||||
if (response.items.isNotEmpty()) {
|
||||
allMovies["Country: ${country.name}"] = response.items
|
||||
allFlattened.addAll(response.items)
|
||||
}
|
||||
} catch (_: Exception) { }
|
||||
}
|
||||
} catch (_: Exception) { }
|
||||
|
||||
_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,
|
||||
isLoading = false
|
||||
)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
_uiState.value = _uiState.value.copy(
|
||||
isLoading = false,
|
||||
error = e.message ?: "Failed to load content"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
package com.streamflow.tv.viewmodel
|
||||
|
||||
import android.app.Application
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.streamflow.tv.data.model.Movie
|
||||
import com.streamflow.tv.data.repository.UserDataRepository
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
data class MyListUiState(
|
||||
val savedMovies: List<Movie> = emptyList(),
|
||||
val watchHistory: List<Movie> = emptyList()
|
||||
)
|
||||
|
||||
class MyListViewModel(application: Application) : AndroidViewModel(application) {
|
||||
|
||||
private val userRepo = UserDataRepository(application)
|
||||
private val _uiState = MutableStateFlow(MyListUiState())
|
||||
val uiState: StateFlow<MyListUiState> = _uiState
|
||||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
userRepo.myList.collectLatest { list ->
|
||||
_uiState.value = _uiState.value.copy(savedMovies = list)
|
||||
}
|
||||
}
|
||||
viewModelScope.launch {
|
||||
userRepo.watchHistory.collectLatest { history ->
|
||||
_uiState.value = _uiState.value.copy(watchHistory = history)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun addToMyList(movie: Movie) {
|
||||
viewModelScope.launch { userRepo.addToMyList(movie) }
|
||||
}
|
||||
|
||||
fun removeFromMyList(slug: String) {
|
||||
viewModelScope.launch { userRepo.removeFromMyList(slug) }
|
||||
}
|
||||
|
||||
fun addToHistory(movie: Movie) {
|
||||
viewModelScope.launch { userRepo.addToHistory(movie) }
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,96 @@
|
|||
package com.streamflow.tv.viewmodel
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.streamflow.tv.data.model.MovieDetail
|
||||
import com.streamflow.tv.data.model.VideoSource
|
||||
import com.streamflow.tv.data.repository.MovieRepository
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
data class PlayerUiState(
|
||||
val movie: MovieDetail? = null,
|
||||
val source: VideoSource? = null,
|
||||
val currentEpisode: Int = 1,
|
||||
val isLoading: Boolean = true,
|
||||
val error: String? = null
|
||||
)
|
||||
|
||||
class PlayerViewModel : ViewModel() {
|
||||
|
||||
private val repository = MovieRepository()
|
||||
private val _uiState = MutableStateFlow(PlayerUiState())
|
||||
val uiState: StateFlow<PlayerUiState> = _uiState
|
||||
|
||||
fun loadPlayer(slug: String, episode: Int = 1) {
|
||||
viewModelScope.launch {
|
||||
_uiState.value = PlayerUiState(isLoading = true, currentEpisode = episode)
|
||||
try {
|
||||
val movie = repository.getMovieDetail(slug)
|
||||
_uiState.value = _uiState.value.copy(movie = movie)
|
||||
loadStream(movie, episode)
|
||||
} catch (e: Exception) {
|
||||
_uiState.value = _uiState.value.copy(
|
||||
isLoading = false,
|
||||
error = e.message ?: "Failed to load"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun changeEpisode(episode: Int) {
|
||||
val movie = _uiState.value.movie ?: return
|
||||
_uiState.value = _uiState.value.copy(currentEpisode = episode, isLoading = true, source = null)
|
||||
viewModelScope.launch {
|
||||
loadStream(movie, episode)
|
||||
}
|
||||
}
|
||||
|
||||
fun saveToHistory(userDataRepository: com.streamflow.tv.data.repository.UserDataRepository) {
|
||||
val movie = _uiState.value.movie ?: return
|
||||
viewModelScope.launch {
|
||||
userDataRepository.addToHistory(movie.toMovie())
|
||||
android.util.Log.e("PlayerViewModel", "Movie saved to history: ${movie.title}")
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun loadStream(movie: MovieDetail, episode: Int) {
|
||||
try {
|
||||
val ep = movie.episodes?.find { it.number == episode }
|
||||
android.util.Log.e("PlayerViewModel", "Loading stream for slug=${movie.slug} episode=$episode. Episode data: $ep")
|
||||
|
||||
if (ep != null && (ep.url.contains(".m3u8") || ep.url.contains("index.m3u8"))) {
|
||||
// Direct HLS URL
|
||||
android.util.Log.e("PlayerViewModel", "Direct HLS URL found: ${ep.url}")
|
||||
_uiState.value = _uiState.value.copy(
|
||||
source = VideoSource(
|
||||
streamUrl = ep.url,
|
||||
resolution = "HD",
|
||||
formatId = "hls"
|
||||
),
|
||||
isLoading = false
|
||||
)
|
||||
} else {
|
||||
// Need to extract
|
||||
val targetUrl = ep?.url
|
||||
?: "https://phimmoichill.network/xem-phim/${movie.slug}/tap-$episode"
|
||||
|
||||
android.util.Log.e("PlayerViewModel", "Extracting from URL: $targetUrl")
|
||||
val source = repository.extractVideo(targetUrl)
|
||||
android.util.Log.e("PlayerViewModel", "Extraction successful: $source")
|
||||
|
||||
_uiState.value = _uiState.value.copy(
|
||||
source = source,
|
||||
isLoading = false
|
||||
)
|
||||
}
|
||||
} 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"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
package com.streamflow.tv.viewmodel
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.streamflow.tv.data.model.Movie
|
||||
import com.streamflow.tv.data.repository.MovieRepository
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
data class SearchUiState(
|
||||
val query: String = "",
|
||||
val results: List<Movie> = emptyList(),
|
||||
val isLoading: Boolean = false,
|
||||
val hasSearched: Boolean = false
|
||||
)
|
||||
|
||||
class SearchViewModel : ViewModel() {
|
||||
|
||||
private val repository = MovieRepository()
|
||||
private val _uiState = MutableStateFlow(SearchUiState())
|
||||
val uiState: StateFlow<SearchUiState> = _uiState
|
||||
|
||||
fun search(query: String) {
|
||||
if (query.isBlank()) return
|
||||
_uiState.value = SearchUiState(query = query, isLoading = true, hasSearched = true)
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
val response = repository.searchVideos(query)
|
||||
_uiState.value = _uiState.value.copy(
|
||||
results = response.items,
|
||||
isLoading = false
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
_uiState.value = _uiState.value.copy(isLoading = false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
33
android-tv/app/src/main/res/drawable/app_banner.xml
Normal file
33
android-tv/app/src/main/res/drawable/app_banner.xml
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="320dp"
|
||||
android:height="180dp"
|
||||
android:viewportWidth="320"
|
||||
android:viewportHeight="180">
|
||||
|
||||
<!-- Background -->
|
||||
<path
|
||||
android:pathData="M0,0h320v180H0z"
|
||||
android:fillColor="#141414"/>
|
||||
|
||||
<!-- Gradient accent bar -->
|
||||
<path
|
||||
android:pathData="M0,160h320v20H0z"
|
||||
android:fillColor="#06B6D4"/>
|
||||
|
||||
<!-- Icon circle -->
|
||||
<path
|
||||
android:pathData="M160,75m-30,0a30,30 0,1 1,60 0a30,30 0,1 1,-60 0"
|
||||
android:fillColor="#06B6D4"/>
|
||||
|
||||
<!-- Play triangle -->
|
||||
<path
|
||||
android:pathData="M152,60L172,75L152,90z"
|
||||
android:fillColor="#FFFFFF"/>
|
||||
|
||||
<!-- Text: StreamFlow -->
|
||||
<path
|
||||
android:pathData="M95,130h130"
|
||||
android:strokeColor="#FFFFFF"
|
||||
android:strokeWidth="0.5"/>
|
||||
</vector>
|
||||
17
android-tv/app/src/main/res/mipmap/ic_launcher.xml
Normal file
17
android-tv/app/src/main/res/mipmap/ic_launcher.xml
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="48dp"
|
||||
android:height="48dp"
|
||||
android:viewportWidth="48"
|
||||
android:viewportHeight="48">
|
||||
|
||||
<!-- Background rounded rect -->
|
||||
<path
|
||||
android:pathData="M8,0h32a8,8 0,0 1,8 8v32a8,8 0,0 1,-8 8H8A8,8 0,0 1,0 40V8A8,8 0,0 1,8 0z"
|
||||
android:fillColor="#06B6D4"/>
|
||||
|
||||
<!-- Play triangle -->
|
||||
<path
|
||||
android:pathData="M18,12L36,24L18,36z"
|
||||
android:fillColor="#FFFFFF"/>
|
||||
</vector>
|
||||
3
android-tv/app/src/main/res/values/strings.xml
Normal file
3
android-tv/app/src/main/res/values/strings.xml
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
<resources>
|
||||
<string name="app_name">StreamFlow</string>
|
||||
</resources>
|
||||
8
android-tv/app/src/main/res/values/themes.xml
Normal file
8
android-tv/app/src/main/res/values/themes.xml
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<style name="Theme.StreamFlowTV" parent="@style/Theme.Leanback">
|
||||
<item name="android:windowIsTranslucent">true</item>
|
||||
<item name="android:windowBackground">@android:color/transparent</item>
|
||||
<item name="android:backgroundDimEnabled">false</item>
|
||||
</style>
|
||||
</resources>
|
||||
4
android-tv/build.gradle.kts
Normal file
4
android-tv/build.gradle.kts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
plugins {
|
||||
id("com.android.application") version "8.2.2" apply false
|
||||
id("org.jetbrains.kotlin.android") version "1.9.22" apply false
|
||||
}
|
||||
35
android-tv/build.txt
Normal file
35
android-tv/build.txt
Normal file
|
|
@ -0,0 +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
|
||||
35
android-tv/build_async.txt
Normal file
35
android-tv/build_async.txt
Normal file
|
|
@ -0,0 +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
|
||||
37
android-tv/build_episodes.txt
Normal file
37
android-tv/build_episodes.txt
Normal file
|
|
@ -0,0 +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
|
||||
1
android-tv/cross.json
Normal file
1
android-tv/cross.json
Normal file
|
|
@ -0,0 +1 @@
|
|||
{"id":"cross-phan-2","title":"Cross (Phần 2)","original_title":"Cross (Season 2)","slug":"cross-phan-2","thumbnail":"https://img.ophim1.com/uploads/movies/cross-phan-2-thumb.jpg","backdrop":"https://img.ophim1.com/uploads/movies/cross-phan-2-poster.jpg","year":2026,"quality":"HD","genre":"Hành Động, Hình Sự, Chính kịch, Bí ẩn","description":"\u003cp\u003eSau những sự kiện chấn động ở phần 1, Alex Cross (do Aldis Hodge thủ vai) giờ đây đã vượt qua được nỗi đau mất vợ nhờ trị liệu và quay trở lại công việc với một tinh thần sắc bén hơn. Lần này, Alex phải đối mặt với một nữ sát thủ kiêm dân phòng (vigilante) cực kỳ thông minh và tàn nhẫn tên là Rebecca (hay còn gọi là Luz), nhắm mục tiêu vào những nhà tài phiệt tham nhũng, những kẻ đứng sau các đường dây buôn người và bóc lột lao động trẻ em. Alex phối hợp cùng cộng sự John Sampson và đặc vụ FBI Kayla Craig để điều tra các mối đe dọa nhắm vào Lance Durand (Matthew Lillard) - một tỷ phú công nghệ tin rằng mình là mục tiêu tiếp theo của kẻ sát nhân. Cross Phần 2 là mùa phim nối từ thành công của mùa 1, khai thác một vụ án mới đầy gay cấn với tâm điểm là một kẻ truy đuổi công lý theo cách riêng, buộc Alex Cross phải đối mặt với những ranh giới đạo đức sắc bén hơn bao giờ hết.\u003c/p\u003e","category":"movies","director":"Stacey Muhammad, Craig Siebels, Nzingha Stewart","country":"Âu Mỹ","episodes":[{"number":1,"title":"1","url":"https://vip.opstream10.com/20260213/32782_f321d182/index.m3u8"},{"number":2,"title":"2","url":"https://vip.opstream10.com/20260213/32785_3ef7738c/index.m3u8"},{"number":3,"title":"3","url":"https://vip.opstream10.com/20260215/32799_37d7465c/index.m3u8"}]}
|
||||
4
android-tv/gradle.properties
Normal file
4
android-tv/gradle.properties
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
|
||||
android.useAndroidX=true
|
||||
kotlin.code.style=official
|
||||
android.nonTransitiveRClass=true
|
||||
BIN
android-tv/gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
BIN
android-tv/gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
Binary file not shown.
5
android-tv/gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
5
android-tv/gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.5-bin.zip
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
85
android-tv/gradlew.bat
vendored
Normal file
85
android-tv/gradlew.bat
vendored
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
@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"
|
||||
|
||||
@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%\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 "%OS%"=="Windows_NT" endlocal
|
||||
|
||||
:omega
|
||||
@exit /b %ERRORLEVEL%
|
||||
|
||||
:fail
|
||||
@exit /b 1
|
||||
1
android-tv/response.json
Normal file
1
android-tv/response.json
Normal file
File diff suppressed because one or more lines are too long
18
android-tv/settings.gradle.kts
Normal file
18
android-tv/settings.gradle.kts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
pluginManagement {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
gradlePluginPortal()
|
||||
}
|
||||
}
|
||||
|
||||
dependencyResolutionManagement {
|
||||
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
}
|
||||
|
||||
rootProject.name = "StreamFlowTV"
|
||||
include(":app")
|
||||
|
|
@ -4,18 +4,27 @@ go 1.25.4
|
|||
|
||||
require (
|
||||
github.com/PuerkitoBio/goquery v1.11.0
|
||||
github.com/glebarez/sqlite v1.11.0
|
||||
github.com/go-chi/chi/v5 v5.2.4
|
||||
github.com/go-chi/cors v1.2.2
|
||||
golang.org/x/image v0.35.0
|
||||
gorm.io/driver/sqlite v1.6.0
|
||||
gorm.io/gorm v1.31.1
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/andybalholm/cascadia v1.3.3 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/glebarez/go-sqlite v1.21.2 // indirect
|
||||
github.com/google/uuid v1.3.0 // indirect
|
||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||
github.com/jinzhu/now v1.1.5 // indirect
|
||||
github.com/mattn/go-sqlite3 v1.14.22 // indirect
|
||||
github.com/mattn/go-isatty v0.0.17 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
golang.org/x/net v0.49.0 // indirect
|
||||
golang.org/x/sys v0.40.0 // indirect
|
||||
golang.org/x/text v0.33.0 // indirect
|
||||
modernc.org/libc v1.22.5 // indirect
|
||||
modernc.org/mathutil v1.5.0 // indirect
|
||||
modernc.org/memory v1.5.0 // indirect
|
||||
modernc.org/sqlite v1.23.1 // indirect
|
||||
)
|
||||
|
|
|
|||
|
|
@ -2,17 +2,30 @@ github.com/PuerkitoBio/goquery v1.11.0 h1:jZ7pwMQXIITcUXNH83LLk+txlaEy6NVOfTuP43
|
|||
github.com/PuerkitoBio/goquery v1.11.0/go.mod h1:wQHgxUOU3JGuj3oD/QFfxUdlzW6xPHfqyHre6VMY4DQ=
|
||||
github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM=
|
||||
github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/glebarez/go-sqlite v1.21.2 h1:3a6LFC4sKahUunAmynQKLZceZCOzUthkRkEAl9gAXWo=
|
||||
github.com/glebarez/go-sqlite v1.21.2/go.mod h1:sfxdZyhQjTM2Wry3gVYWaW072Ri1WMdWJi0k6+3382k=
|
||||
github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw=
|
||||
github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ=
|
||||
github.com/go-chi/chi/v5 v5.2.4 h1:WtFKPHwlywe8Srng8j2BhOD9312j9cGUxG1SP4V2cR4=
|
||||
github.com/go-chi/chi/v5 v5.2.4/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
|
||||
github.com/go-chi/cors v1.2.2 h1:Jmey33TE+b+rB7fT8MUy1u0I4L+NARQlK6LhzKPSyQE=
|
||||
github.com/go-chi/cors v1.2.2/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ=
|
||||
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo=
|
||||
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
|
||||
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
||||
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
||||
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
||||
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
||||
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
|
||||
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
github.com/mattn/go-isatty v0.0.17 h1:BTarxUcIeDqL27Mc+vyvdWYSL28zpIhv3RoTdsLMPng=
|
||||
github.com/mattn/go-isatty v0.0.17/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
|
|
@ -50,12 +63,15 @@ golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7w
|
|||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
|
||||
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
|
|
@ -83,7 +99,13 @@ golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
|||
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
|
||||
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ=
|
||||
gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8=
|
||||
gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg=
|
||||
gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
|
||||
modernc.org/libc v1.22.5 h1:91BNch/e5B0uPbJFgqbxXuOnxBQjlS//icfQEGmvyjE=
|
||||
modernc.org/libc v1.22.5/go.mod h1:jj+Z7dTNX8fBScMVNRAYZ/jF91K8fdT2hYMThc3YjBY=
|
||||
modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ=
|
||||
modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E=
|
||||
modernc.org/memory v1.5.0 h1:N+/8c5rE6EqugZwHii4IFsaJ7MUhoWX07J5tC/iI5Ds=
|
||||
modernc.org/memory v1.5.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU=
|
||||
modernc.org/sqlite v1.23.1 h1:nrSBg4aRQQwq59JpvGEQ15tNxoO5pX/kUjcRNwSAGQM=
|
||||
modernc.org/sqlite v1.23.1/go.mod h1:OrDj17Mggn6MhE+iPbBNf7RGKODDE9NFT0f3EwDzJqk=
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import (
|
|||
|
||||
"streamflow-backend/internal/models"
|
||||
|
||||
"gorm.io/driver/sqlite"
|
||||
"github.com/glebarez/sqlite"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
)
|
||||
|
|
|
|||
BIN
backend/yt-dlp.exe
Normal file
BIN
backend/yt-dlp.exe
Normal file
Binary file not shown.
|
|
@ -3,7 +3,7 @@ version: '3.8'
|
|||
services:
|
||||
streamflow:
|
||||
# build: .
|
||||
image: git.khoavo.myds.me/vndangkhoa/kv-streamflow:latest
|
||||
image: git.khoavo.myds.me/vndangkhoa/kv-streamflow:v3
|
||||
container_name: streamflow
|
||||
platform: linux/amd64
|
||||
ports:
|
||||
|
|
|
|||
|
|
@ -54,26 +54,49 @@ const Navbar = () => {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div className="hidden md:block flex-1 max-w-xs mx-8">
|
||||
<form onSubmit={handleSearch} className="relative group">
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder="Tìm kiếm..."
|
||||
className="w-full bg-white/5 border border-white/10 rounded-full py-2 pl-10 pr-4 text-sm text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-cyan-500/50 focus:border-cyan-500/50 transition-all duration-300 focus:bg-white/10"
|
||||
/>
|
||||
<Search className="absolute left-3 top-2.5 w-4 h-4 text-gray-500 group-focus-within:text-cyan-400 transition-colors" />
|
||||
</form>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="hidden md:block flex-1 max-w-xs mx-8">
|
||||
<form onSubmit={handleSearch} className="relative group">
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder="Tìm kiếm..."
|
||||
className="w-full bg-white/5 border border-white/10 rounded-full py-2 pl-10 pr-4 text-sm text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-cyan-500/50 focus:border-cyan-500/50 transition-all duration-300 focus:bg-white/10"
|
||||
/>
|
||||
<Search className="absolute left-3 top-2.5 w-4 h-4 text-gray-500 group-focus-within:text-cyan-400 transition-colors" />
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div className="lg:hidden">
|
||||
<button
|
||||
onClick={() => setIsMenuOpen(!isMenuOpen)}
|
||||
className="text-gray-300 hover:text-white p-2"
|
||||
{/* Install App Button */}
|
||||
<a
|
||||
href="/streamflow-tv.apk"
|
||||
download="streamflow-tv.apk"
|
||||
className="hidden sm:flex items-center gap-2 px-4 py-2 bg-gradient-to-r from-cyan-600 to-blue-700 hover:from-cyan-500 hover:to-blue-600 text-white text-sm font-bold rounded-full shadow-lg shadow-cyan-900/20 hover:shadow-cyan-500/40 transition-all duration-300 active:scale-95 border border-white/10"
|
||||
>
|
||||
{isMenuOpen ? <X size={24} /> : <Menu size={24} />}
|
||||
</button>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="w-4 h-4"
|
||||
>
|
||||
<rect width="20" height="15" x="2" y="7" rx="2" ry="2"/>
|
||||
<polyline points="17 2 12 7 7 2"/>
|
||||
</svg>
|
||||
<span>Install App</span>
|
||||
</a>
|
||||
|
||||
<div className="lg:hidden">
|
||||
<button
|
||||
onClick={() => setIsMenuOpen(!isMenuOpen)}
|
||||
className="text-gray-300 hover:text-white p-2"
|
||||
>
|
||||
{isMenuOpen ? <X size={24} /> : <Menu size={24} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -81,6 +104,28 @@ const Navbar = () => {
|
|||
{isMenuOpen && (
|
||||
<div className="lg:hidden bg-[#1a1a1a] border-b border-white/10 max-h-[80vh] overflow-y-auto">
|
||||
<div className="px-4 pt-2 pb-4 space-y-3">
|
||||
{/* Mobile Install App Button */}
|
||||
<a
|
||||
href="/streamflow-tv.apk"
|
||||
download="streamflow-tv.apk"
|
||||
className="flex items-center justify-center gap-2 w-full py-3 bg-gradient-to-r from-cyan-600 to-blue-700 text-white font-bold rounded-lg mb-4"
|
||||
onClick={() => setIsMenuOpen(false)}
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="w-5 h-5"
|
||||
>
|
||||
<rect width="20" height="15" x="2" y="7" rx="2" ry="2"/>
|
||||
<polyline points="17 2 12 7 7 2"/>
|
||||
</svg>
|
||||
<span>Download Android TV App</span>
|
||||
</a>
|
||||
|
||||
<form onSubmit={handleSearch} className="relative mb-4">
|
||||
<input
|
||||
type="text"
|
||||
|
|
|
|||
|
|
@ -59,6 +59,27 @@ export const Layout = ({ children }: { children: ReactNode }) => {
|
|||
</div>
|
||||
|
||||
<div className="flex items-center gap-6">
|
||||
{/* Install App Button (PC/Tablet) */}
|
||||
<a
|
||||
href="/streamflow-tv.apk"
|
||||
download="streamflow-tv.apk"
|
||||
className="hidden lg:flex items-center gap-2 px-5 py-2.5 bg-white text-black hover:bg-white/90 text-sm font-bold rounded-full transition-all duration-300 shadow-xl shadow-white/5 active:scale-95"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="w-4 h-4"
|
||||
>
|
||||
<rect width="20" height="15" x="2" y="7" rx="2" ry="2" />
|
||||
<polyline points="17 2 12 7 7 2" />
|
||||
</svg>
|
||||
<span>TV APP</span>
|
||||
</a>
|
||||
|
||||
<div className={`relative group flex items-center transition-all duration-300 ${isSearchOpen ? 'w-64 bg-white/10 rounded-lg px-2' : 'w-8'}`}>
|
||||
<Search
|
||||
className="w-4 h-4 text-white/70 group-hover:text-white transition-colors cursor-pointer"
|
||||
|
|
@ -94,7 +115,7 @@ export const Layout = ({ children }: { children: ReactNode }) => {
|
|||
<Home className={`w-6 h-6 ${location.pathname === '/' ? 'fill-current' : ''}`} strokeWidth={location.pathname === '/' ? 2.5 : 2} />
|
||||
<span className="text-[10px] font-medium tracking-wide">Home</span>
|
||||
</Link>
|
||||
{CATEGORIES.slice(0, 4).map((item) => {
|
||||
{CATEGORIES.slice(0, 3).map((item) => {
|
||||
const getCategoryIcon = (id: string) => {
|
||||
switch (id) {
|
||||
case 'phim-le': return Film;
|
||||
|
|
@ -118,6 +139,28 @@ export const Layout = ({ children }: { children: ReactNode }) => {
|
|||
</Link>
|
||||
);
|
||||
})}
|
||||
{/* APK Download in Mobile Nav */}
|
||||
<a
|
||||
href="/streamflow-tv.apk"
|
||||
download="streamflow-tv.apk"
|
||||
className="flex flex-col items-center gap-1.5 p-2 text-white animate-pulse"
|
||||
>
|
||||
<div className="p-1 rounded bg-white text-black">
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="3"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="w-5 h-5"
|
||||
>
|
||||
<rect width="20" height="15" x="2" y="7" rx="2" ry="2" />
|
||||
<polyline points="17 2 12 7 7 2" />
|
||||
</svg>
|
||||
</div>
|
||||
<span className="text-[10px] font-bold tracking-wide">TV APP</span>
|
||||
</a>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
|
|
|
|||
|
|
@ -70,21 +70,64 @@ export const Layout = ({ children }: { children: ReactNode }) => {
|
|||
))}
|
||||
</nav>
|
||||
|
||||
<div className="p-4 mt-auto">
|
||||
<div className="text-xs text-gray-500 text-center lg:text-left">
|
||||
<div className="p-4 mt-auto space-y-4">
|
||||
{/* PC/Tablet Sidebar Install Link */}
|
||||
<a
|
||||
href="/streamflow-tv.apk"
|
||||
download="streamflow-tv.apk"
|
||||
className="flex items-center gap-4 px-4 py-3 rounded-md transition-all duration-300 text-red-600 border border-red-900/30 hover:bg-red-600/10 group shadow-[0_0_15px_rgba(220,38,38,0.1)] hover:shadow-[0_0_20px_rgba(220,38,38,0.3)] bg-gradient-to-r from-red-600/5 to-transparent"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="w-6 h-6 group-hover:scale-110 transition-transform"
|
||||
>
|
||||
<rect width="20" height="15" x="2" y="7" rx="2" ry="2" />
|
||||
<polyline points="17 2 12 7 7 2" />
|
||||
</svg>
|
||||
<span className="hidden lg:block text-sm font-bold tracking-wide">TV APP</span>
|
||||
</a>
|
||||
|
||||
<div className="text-xs text-gray-500 text-center lg:text-left pt-2 border-t border-white/5 font-medium">
|
||||
© 2026 StreamFlow
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Mobile Bottom Nav (Visible only on small screens) */}
|
||||
<div className="md:hidden fixed bottom-0 left-0 right-0 bg-[#121212] border-t border-white/10 z-50 flex justify-around p-3">
|
||||
{NAV_ITEMS.slice(0, 5).map((item) => (
|
||||
<div className="md:hidden fixed bottom-0 left-0 right-0 bg-[#121212] border-t border-white/10 z-50 flex justify-around p-3 items-center">
|
||||
{NAV_ITEMS.slice(0, 4).map((item) => (
|
||||
<Link key={item.name} to={item.path} className={`flex flex-col items-center gap-1 ${isActive(item.path) ? 'text-white' : 'text-gray-500'}`}>
|
||||
<item.icon className="w-5 h-5" />
|
||||
<span className="text-[10px]">{item.name}</span>
|
||||
</Link>
|
||||
))}
|
||||
{/* APK Download in Mobile Nav */}
|
||||
<a
|
||||
href="/streamflow-tv.apk"
|
||||
download="streamflow-tv.apk"
|
||||
className="flex flex-col items-center gap-1 text-red-600 animate-pulse font-bold"
|
||||
>
|
||||
<div className="bg-red-600 rounded-lg p-1">
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="white"
|
||||
strokeWidth="3"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="w-4 h-4"
|
||||
>
|
||||
<rect width="20" height="15" x="2" y="7" rx="2" ry="2" />
|
||||
<polyline points="17 2 12 7 7 2" />
|
||||
</svg>
|
||||
</div>
|
||||
<span className="text-[10px]">TV App</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{/* Main Content Area */}
|
||||
|
|
|
|||
Loading…
Reference in a new issue