V3 Release: Integrated Android TV App App across all themes, updated Docker for Synology NAS

This commit is contained in:
vndangkhoa 2026-02-15 18:04:25 +07:00
parent c2dd326855
commit 05b320e823
54 changed files with 3086 additions and 38 deletions

View file

@ -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

View 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
View 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.**

View 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>

View 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) }
}
)
}
}
}
}
}
}

View file

@ -0,0 +1,9 @@
package com.streamflow.tv
import android.app.Application
class StreamFlowApp : Application() {
override fun onCreate() {
super.onCreate()
}
}

View file

@ -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"
}
}

View file

@ -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>
}

View file

@ -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
)

View file

@ -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()
}
}

View file

@ -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
}
}
}

View file

@ -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
)
)
}
}
}
}
}
}

View file

@ -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)
)
)
}
}
}
}

View file

@ -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)
)
)
}
}
}
}
}

View file

@ -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) }
)
}
}
}
}

View file

@ -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)
)
}
}
}

View file

@ -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")
}

View file

@ -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)
)
}
}
}
}

View file

@ -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) }
)
}
}
}
}
}
}
}

View file

@ -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) })
}
}
}
}
}
}

View file

@ -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)
)
}
}
}
}

View file

@ -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)
)
}
}
}
}
}
}

View file

@ -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)
)
}
}

View file

@ -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)

View file

@ -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()
}
}

View file

@ -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
)
}

View file

@ -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)
}
}

View file

@ -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"
)
}
}
}
}

View file

@ -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) }
}
}

View file

@ -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"
)
}
}
}

View file

@ -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)
}
}
}
}

View 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>

View 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>

View file

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

View 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>

View 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
View 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

View 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

View 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
View 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"}]}

View file

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

Binary file not shown.

View 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
View 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

File diff suppressed because one or more lines are too long

View 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")

View file

@ -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
)

View file

@ -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=

View file

@ -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

Binary file not shown.

View file

@ -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:

View file

@ -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"

View file

@ -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>

View file

@ -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">
&copy; 2026 StreamFlow
</div>
</div>
</aside>
{/* Mobile Bottom Nav (Visible only on small screens) */}
<div className="md:hidden fixed bottom-0 left-0 right-0 bg-[#121212] border-t border-white/10 z-50 flex justify-around p-3">
{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 */}