diff --git a/README.md b/README.md index 132c041..6878996 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# StreamFlow V2 +# StreamFlow V3 StreamFlow is a high-performance video streaming web application featuring a pure Go backend and a modern React + Tailwind frontend. @@ -8,7 +8,8 @@ StreamFlow is a high-performance video streaming web application featuring a pur - **High Performance**: Backend written in Go (Golang) for speed and concurrency. - **Smart Scraping**: Integrated scraping engine (Rophim) with automated episode extraction. - **HLS Streaming**: Native HLS playback support. -- **Docker Ready**: Multi-stage Docker build for optimized deployment. +- **Android TV App**: Native TV app support with dedicated APK available for download. +- **Docker Ready**: Multi-stage Docker build optimized for NAS Synology (linux/amd64). ## 🛠️ Tech Stack @@ -43,12 +44,37 @@ StreamFlow is a high-performance video streaming web application featuring a pur ``` Frontend runs at `http://localhost:5173` (proxying to backend). -### Docker Deployment +### Docker Deployment (Recommended for NAS Synology) -```bash -docker-compose up -d --build -``` -Access the application at `http://localhost:8000`. +1. **Environmental Variables**: Create a `.env` file or set them in your NAS: + ```env + TMDB_API_KEY=your_api_key_here + ``` + +2. **Run with Docker Compose**: + ```yaml + version: '3.8' + + services: + streamflow: + image: git.khoavo.myds.me/vndangkhoa/kv-streamflow:v3 + container_name: streamflow + platform: linux/amd64 + ports: + - "3478:8000" + environment: + - DATABASE_URL=/app/data/streamflow.db + - TMDB_API_KEY=${TMDB_API_KEY} + volumes: + - ./data:/app/data + restart: always + ``` + + ```bash + docker-compose up -d + ``` + +Access the application at `http://YOUR_NAS_IP:3478`. You can download the **Android TV App** directly from the navigation bar once the webapp is running. ## 📂 Project Structure diff --git a/android-tv/app/build.gradle.kts b/android-tv/app/build.gradle.kts new file mode 100644 index 0000000..adf100c --- /dev/null +++ b/android-tv/app/build.gradle.kts @@ -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") +} diff --git a/android-tv/app/proguard-rules.pro b/android-tv/app/proguard-rules.pro new file mode 100644 index 0000000..8595bb1 --- /dev/null +++ b/android-tv/app/proguard-rules.pro @@ -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.** diff --git a/android-tv/app/src/main/AndroidManifest.xml b/android-tv/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..810c5bd --- /dev/null +++ b/android-tv/app/src/main/AndroidManifest.xml @@ -0,0 +1,36 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/android-tv/app/src/main/java/com/streamflow/tv/MainActivity.kt b/android-tv/app/src/main/java/com/streamflow/tv/MainActivity.kt new file mode 100644 index 0000000..300f640 --- /dev/null +++ b/android-tv/app/src/main/java/com/streamflow/tv/MainActivity.kt @@ -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) } + } + ) + } + } + } + } + } +} diff --git a/android-tv/app/src/main/java/com/streamflow/tv/StreamFlowApp.kt b/android-tv/app/src/main/java/com/streamflow/tv/StreamFlowApp.kt new file mode 100644 index 0000000..7752c4d --- /dev/null +++ b/android-tv/app/src/main/java/com/streamflow/tv/StreamFlowApp.kt @@ -0,0 +1,9 @@ +package com.streamflow.tv + +import android.app.Application + +class StreamFlowApp : Application() { + override fun onCreate() { + super.onCreate() + } +} diff --git a/android-tv/app/src/main/java/com/streamflow/tv/data/api/ApiClient.kt b/android-tv/app/src/main/java/com/streamflow/tv/data/api/ApiClient.kt new file mode 100644 index 0000000..c14fc61 --- /dev/null +++ b/android-tv/app/src/main/java/com/streamflow/tv/data/api/ApiClient.kt @@ -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" + } +} diff --git a/android-tv/app/src/main/java/com/streamflow/tv/data/api/StreamFlowApi.kt b/android-tv/app/src/main/java/com/streamflow/tv/data/api/StreamFlowApi.kt new file mode 100644 index 0000000..d3f27b0 --- /dev/null +++ b/android-tv/app/src/main/java/com/streamflow/tv/data/api/StreamFlowApi.kt @@ -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 + + @GET("api/videos/search") + suspend fun searchVideos( + @Query("q") query: String, + @Query("page") page: Int = 1 + ): List + + @GET("api/videos/{slug}") + suspend fun getMovieDetail( + @Path("slug") slug: String + ): MovieDetailResponse + + @POST("api/extract") + suspend fun extractVideo( + @Body request: ExtractRequest + ): VideoSource + + @GET("api/categories/genres") + suspend fun getGenres(): List + + @GET("api/categories/countries") + suspend fun getCountries(): List +} diff --git a/android-tv/app/src/main/java/com/streamflow/tv/data/model/Models.kt b/android-tv/app/src/main/java/com/streamflow/tv/data/model/Models.kt new file mode 100644 index 0000000..43b7b3a --- /dev/null +++ b/android-tv/app/src/main/java/com/streamflow/tv/data/model/Models.kt @@ -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? = null +) + +@JsonClass(generateAdapter = false) +data class MovieDetail( + val id: String = "", + val title: String = "", + @Json(name = "original_title") val originalTitle: String? = null, + val slug: String = "", + val thumbnail: String = "", + val backdrop: String? = null, + val quality: String? = null, + val year: Int? = null, + val category: String = "", + val description: String = "", + val rating: String? = null, + val duration: Int? = null, + val genre: String? = null, + val director: String? = null, + val country: String? = null, + val cast: List? = null, + val episodes: List? = null +) { + fun toMovie(): Movie = Movie( + id = id, + title = title, + originalTitle = originalTitle, + slug = slug, + thumbnail = thumbnail, + backdrop = backdrop, + quality = quality, + year = year, + category = category, + director = director, + cast = cast + ) +} + +@JsonClass(generateAdapter = false) +data class Episode( + val number: Int = 0, + val title: String = "", + val url: String = "" +) + +@JsonClass(generateAdapter = false) +data class VideoSource( + @Json(name = "stream_url") val streamUrl: String = "", + val resolution: String = "", + @Json(name = "format_id") val formatId: String = "" +) + +@JsonClass(generateAdapter = false) +data class Category( + val name: String = "", + val slug: String = "" +) + +@JsonClass(generateAdapter = false) +data class HomeResponse( + val items: List = emptyList(), + val totalPages: Int = 1, + val currentPage: Int = 1 +) + +@JsonClass(generateAdapter = false) +data class ExtractRequest( + val url: String +) + +@JsonClass(generateAdapter = false) +data class MovieDetailResponse( + val id: String = "", + val title: String = "", + @Json(name = "original_title") val originalTitle: String? = null, + val slug: String = "", + val thumbnail: String = "", + val backdrop: String? = null, + val quality: String? = null, + val year: Int? = null, + val category: String = "", + val description: String = "", + val rating: String? = null, + val duration: Int? = null, + val genre: String? = null, + val director: String? = null, + val country: String? = null, + val cast: List? = null, + val episodes: List? = null +) + diff --git a/android-tv/app/src/main/java/com/streamflow/tv/data/repository/MovieRepository.kt b/android-tv/app/src/main/java/com/streamflow/tv/data/repository/MovieRepository.kt new file mode 100644 index 0000000..9962934 --- /dev/null +++ b/android-tv/app/src/main/java/com/streamflow/tv/data/repository/MovieRepository.kt @@ -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 { + return api.getGenres() + } + + suspend fun getCountries(): List { + return api.getCountries() + } +} diff --git a/android-tv/app/src/main/java/com/streamflow/tv/data/repository/UserDataRepository.kt b/android-tv/app/src/main/java/com/streamflow/tv/data/repository/UserDataRepository.kt new file mode 100644 index 0000000..18632a9 --- /dev/null +++ b/android-tv/app/src/main/java/com/streamflow/tv/data/repository/UserDataRepository.kt @@ -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 by preferencesDataStore(name = "user_data") + +class UserDataRepository(private val context: Context) { + + companion object { + private val MY_LIST_KEY = stringPreferencesKey("my_list") + private val WATCH_HISTORY_KEY = stringPreferencesKey("watch_history") + private val THEME_KEY = stringPreferencesKey("theme") + private val SERVER_URL_KEY = stringPreferencesKey("server_url") + + private const val MAX_HISTORY = 50 + } + + private val moshi = Moshi.Builder().addLast(KotlinJsonAdapterFactory()).build() + private val movieListType = Types.newParameterizedType(List::class.java, Movie::class.java) + private val movieListAdapter = moshi.adapter>(movieListType) + + // --- My List --- + + val myList: Flow> = context.dataStore.data.map { prefs -> + val json = prefs[MY_LIST_KEY] ?: "[]" + movieListAdapter.fromJson(json) ?: emptyList() + } + + suspend fun addToMyList(movie: Movie) { + context.dataStore.edit { prefs -> + val current = movieListAdapter.fromJson(prefs[MY_LIST_KEY] ?: "[]") ?: emptyList() + if (current.none { it.slug == movie.slug }) { + prefs[MY_LIST_KEY] = movieListAdapter.toJson(current + movie) + } + } + } + + suspend fun removeFromMyList(slug: String) { + context.dataStore.edit { prefs -> + val current = movieListAdapter.fromJson(prefs[MY_LIST_KEY] ?: "[]") ?: emptyList() + prefs[MY_LIST_KEY] = movieListAdapter.toJson(current.filter { it.slug != slug }) + } + } + + suspend fun isInMyList(slug: String): Boolean { + var found = false + context.dataStore.edit { prefs -> + val current = movieListAdapter.fromJson(prefs[MY_LIST_KEY] ?: "[]") ?: emptyList() + found = current.any { it.slug == slug } + } + return found + } + + // --- Watch History --- + + val watchHistory: Flow> = context.dataStore.data.map { prefs -> + val json = prefs[WATCH_HISTORY_KEY] ?: "[]" + movieListAdapter.fromJson(json) ?: emptyList() + } + + suspend fun addToHistory(movie: Movie) { + context.dataStore.edit { prefs -> + val current = movieListAdapter.fromJson(prefs[WATCH_HISTORY_KEY] ?: "[]")?.toMutableList() ?: mutableListOf() + current.removeAll { it.slug == movie.slug } + current.add(0, movie) // Most recent first + val trimmed = current.take(MAX_HISTORY) + prefs[WATCH_HISTORY_KEY] = movieListAdapter.toJson(trimmed) + } + } + + // --- Theme --- + + val theme: Flow = context.dataStore.data.map { prefs -> + prefs[THEME_KEY] ?: "default" + } + + suspend fun setTheme(theme: String) { + context.dataStore.edit { prefs -> + prefs[THEME_KEY] = theme + } + } + + // --- Server URL --- + + val serverUrl: Flow = context.dataStore.data.map { prefs -> + prefs[SERVER_URL_KEY] ?: "https://nf.khoavo.myds.me" + } + + suspend fun setServerUrl(url: String) { + context.dataStore.edit { prefs -> + prefs[SERVER_URL_KEY] = url + } + } +} diff --git a/android-tv/app/src/main/java/com/streamflow/tv/ui/components/EpisodeSelector.kt b/android-tv/app/src/main/java/com/streamflow/tv/ui/components/EpisodeSelector.kt new file mode 100644 index 0000000..4798db7 --- /dev/null +++ b/android-tv/app/src/main/java/com/streamflow/tv/ui/components/EpisodeSelector.kt @@ -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, + currentEpisode: Int, + onEpisodeSelect: (Episode) -> Unit, + modifier: Modifier = Modifier +) { + val colors = StreamFlowTheme.colors + + Column(modifier = modifier) { + Text( + text = "Episodes", + style = StreamFlowTheme.typography.headlineMedium, + modifier = Modifier.padding(bottom = 12.dp) + ) + + android.util.Log.e("EpisodeSelector", "Rendering grid with ${episodes.size} episodes") + TvLazyVerticalGrid( + columns = TvGridCells.Adaptive(minSize = 120.dp), + contentPadding = PaddingValues(4.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + items(episodes) { episode -> + val isActive = episode.number == currentEpisode + var isFocused by remember { mutableStateOf(false) } + + Surface( + onClick = { onEpisodeSelect(episode) }, + modifier = Modifier + .onFocusChanged { isFocused = it.isFocused }, + shape = ClickableSurfaceDefaults.shape( + shape = RoundedCornerShape(8.dp) + ), + colors = ClickableSurfaceDefaults.colors( + containerColor = if (isActive) colors.primary.copy(alpha = 0.2f) else colors.surfaceVariant, + focusedContainerColor = colors.primary.copy(alpha = 0.3f) + ), + scale = ClickableSurfaceDefaults.scale(focusedScale = 1.05f) + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 12.dp, horizontal = 16.dp), + contentAlignment = Alignment.Center + ) { + Text( + text = if (episode.title.isNotBlank()) episode.title else "Ep ${episode.number}", + style = StreamFlowTheme.typography.labelLarge.copy( + color = if (isActive) colors.primary else Color.White + ) + ) + } + } + } + } + } +} diff --git a/android-tv/app/src/main/java/com/streamflow/tv/ui/components/HeroBanner.kt b/android-tv/app/src/main/java/com/streamflow/tv/ui/components/HeroBanner.kt new file mode 100644 index 0000000..0d09aa3 --- /dev/null +++ b/android-tv/app/src/main/java/com/streamflow/tv/ui/components/HeroBanner.kt @@ -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, + onPlayClick: (Movie) -> Unit, + modifier: Modifier = Modifier +) { + if (movies.isEmpty()) return + val colors = StreamFlowTheme.colors + + var currentIndex by remember { mutableIntStateOf(0) } + val currentMovie = movies[currentIndex] + + LaunchedEffect(currentIndex) { + delay(6000) + currentIndex = (currentIndex + 1) % movies.size + } + + Box( + modifier = modifier + .fillMaxWidth() + .height(480.dp) + ) { + AnimatedContent( + targetState = currentMovie, + transitionSpec = { fadeIn() togetherWith fadeOut() }, + label = "hero-crossfade" + ) { movie -> + AsyncImage( + model = ApiClient.imageProxyUrl(movie.backdrop ?: movie.thumbnail, 1280), + contentDescription = movie.title, + contentScale = ContentScale.Crop, + modifier = Modifier.fillMaxSize() + ) + } + + Box( + modifier = Modifier + .fillMaxSize() + .background( + Brush.horizontalGradient( + colors = listOf( + colors.background.copy(alpha = 0.9f), + colors.background.copy(alpha = 0.5f), + Color.Transparent + ) + ) + ) + ) + Box( + modifier = Modifier + .fillMaxWidth() + .fillMaxHeight(0.4f) + .align(Alignment.BottomCenter) + .background( + Brush.verticalGradient( + colors = listOf(Color.Transparent, colors.background) + ) + ) + ) + + Column( + modifier = Modifier + .align(Alignment.CenterStart) + .padding(start = 48.dp, end = 200.dp) + .fillMaxHeight(), + verticalArrangement = Arrangement.Center + ) { + currentMovie.quality?.let { quality -> + Box( + modifier = Modifier + .background(colors.primary, RoundedCornerShape(4.dp)) + .padding(horizontal = 8.dp, vertical = 4.dp) + ) { + Text( + text = quality, + style = StreamFlowTheme.typography.labelSmall.copy(color = Color.White) + ) + } + Spacer(Modifier.height(12.dp)) + } + + Text( + text = currentMovie.title, + style = StreamFlowTheme.typography.displayLarge, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + + Spacer(Modifier.height(12.dp)) + + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + currentMovie.year?.let { + Text("$it", style = StreamFlowTheme.typography.bodyLarge) + } + } + + Spacer(Modifier.height(16.dp)) + + Surface( + onClick = { onPlayClick(currentMovie) }, + shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(8.dp)), + colors = ClickableSurfaceDefaults.colors( + containerColor = colors.primary, + focusedContainerColor = colors.accent + ), + scale = ClickableSurfaceDefaults.scale(focusedScale = 1.05f) + ) { + Text( + text = "▶ Play Now", + style = StreamFlowTheme.typography.titleMedium.copy(color = Color.White), + modifier = Modifier.padding(horizontal = 24.dp, vertical = 12.dp) + ) + } + } + + Row( + modifier = Modifier + .align(Alignment.BottomCenter) + .padding(bottom = 16.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + movies.forEachIndexed { index, _ -> + Box( + modifier = Modifier + .size(if (index == currentIndex) 24.dp else 8.dp, 8.dp) + .clip(CircleShape) + .background( + if (index == currentIndex) colors.primary + else Color.White.copy(alpha = 0.3f) + ) + ) + } + } + } +} diff --git a/android-tv/app/src/main/java/com/streamflow/tv/ui/components/MovieCard.kt b/android-tv/app/src/main/java/com/streamflow/tv/ui/components/MovieCard.kt new file mode 100644 index 0000000..a76d8b9 --- /dev/null +++ b/android-tv/app/src/main/java/com/streamflow/tv/ui/components/MovieCard.kt @@ -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) + ) + ) + } + } + } + } +} diff --git a/android-tv/app/src/main/java/com/streamflow/tv/ui/components/MovieRow.kt b/android-tv/app/src/main/java/com/streamflow/tv/ui/components/MovieRow.kt new file mode 100644 index 0000000..b5c00c8 --- /dev/null +++ b/android-tv/app/src/main/java/com/streamflow/tv/ui/components/MovieRow.kt @@ -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, + onMovieClick: (Movie) -> Unit, + modifier: Modifier = Modifier +) { + Column(modifier = modifier.padding(vertical = 12.dp)) { + // Section title + Text( + text = title, + style = StreamFlowTheme.typography.headlineMedium, + modifier = Modifier.padding(start = 48.dp, bottom = 12.dp) + ) + + // Horizontal scrolling row of cards + TvLazyRow( + contentPadding = PaddingValues(horizontal = 48.dp), + horizontalArrangement = Arrangement.spacedBy(16.dp) + ) { + items(movies) { movie -> + MovieCard( + movie = movie, + onClick = { onMovieClick(movie) } + ) + } + } + } +} diff --git a/android-tv/app/src/main/java/com/streamflow/tv/ui/components/SideNavRail.kt b/android-tv/app/src/main/java/com/streamflow/tv/ui/components/SideNavRail.kt new file mode 100644 index 0000000..d9ebb89 --- /dev/null +++ b/android-tv/app/src/main/java/com/streamflow/tv/ui/components/SideNavRail.kt @@ -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) + ) + } + } +} diff --git a/android-tv/app/src/main/java/com/streamflow/tv/ui/navigation/AppNavigation.kt b/android-tv/app/src/main/java/com/streamflow/tv/ui/navigation/AppNavigation.kt new file mode 100644 index 0000000..9e0f1d6 --- /dev/null +++ b/android-tv/app/src/main/java/com/streamflow/tv/ui/navigation/AppNavigation.kt @@ -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 { + error("NavController not provided") +} diff --git a/android-tv/app/src/main/java/com/streamflow/tv/ui/screens/DetailScreen.kt b/android-tv/app/src/main/java/com/streamflow/tv/ui/screens/DetailScreen.kt new file mode 100644 index 0000000..d7999ea --- /dev/null +++ b/android-tv/app/src/main/java/com/streamflow/tv/ui/screens/DetailScreen.kt @@ -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) + ) + } + } + } +} diff --git a/android-tv/app/src/main/java/com/streamflow/tv/ui/screens/HomeScreen.kt b/android-tv/app/src/main/java/com/streamflow/tv/ui/screens/HomeScreen.kt new file mode 100644 index 0000000..79a1bdd --- /dev/null +++ b/android-tv/app/src/main/java/com/streamflow/tv/ui/screens/HomeScreen.kt @@ -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) } + ) + } + } + } + } + } + } +} diff --git a/android-tv/app/src/main/java/com/streamflow/tv/ui/screens/MyListScreen.kt b/android-tv/app/src/main/java/com/streamflow/tv/ui/screens/MyListScreen.kt new file mode 100644 index 0000000..8df14c0 --- /dev/null +++ b/android-tv/app/src/main/java/com/streamflow/tv/ui/screens/MyListScreen.kt @@ -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) }) + } + } + } + } + } +} diff --git a/android-tv/app/src/main/java/com/streamflow/tv/ui/screens/PlayerScreen.kt b/android-tv/app/src/main/java/com/streamflow/tv/ui/screens/PlayerScreen.kt new file mode 100644 index 0000000..fecc120 --- /dev/null +++ b/android-tv/app/src/main/java/com/streamflow/tv/ui/screens/PlayerScreen.kt @@ -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) + ) + } + } + } +} diff --git a/android-tv/app/src/main/java/com/streamflow/tv/ui/screens/SearchScreen.kt b/android-tv/app/src/main/java/com/streamflow/tv/ui/screens/SearchScreen.kt new file mode 100644 index 0000000..8e90a41 --- /dev/null +++ b/android-tv/app/src/main/java/com/streamflow/tv/ui/screens/SearchScreen.kt @@ -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) + ) + } + } + } + } + } +} diff --git a/android-tv/app/src/main/java/com/streamflow/tv/ui/screens/SettingsScreen.kt b/android-tv/app/src/main/java/com/streamflow/tv/ui/screens/SettingsScreen.kt new file mode 100644 index 0000000..7750d8f --- /dev/null +++ b/android-tv/app/src/main/java/com/streamflow/tv/ui/screens/SettingsScreen.kt @@ -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) + ) + } +} diff --git a/android-tv/app/src/main/java/com/streamflow/tv/ui/theme/Color.kt b/android-tv/app/src/main/java/com/streamflow/tv/ui/theme/Color.kt new file mode 100644 index 0000000..3b3f8d1 --- /dev/null +++ b/android-tv/app/src/main/java/com/streamflow/tv/ui/theme/Color.kt @@ -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) diff --git a/android-tv/app/src/main/java/com/streamflow/tv/ui/theme/Theme.kt b/android-tv/app/src/main/java/com/streamflow/tv/ui/theme/Theme.kt new file mode 100644 index 0000000..be14900 --- /dev/null +++ b/android-tv/app/src/main/java/com/streamflow/tv/ui/theme/Theme.kt @@ -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() + } +} diff --git a/android-tv/app/src/main/java/com/streamflow/tv/ui/theme/Typography.kt b/android-tv/app/src/main/java/com/streamflow/tv/ui/theme/Typography.kt new file mode 100644 index 0000000..db0b897 --- /dev/null +++ b/android-tv/app/src/main/java/com/streamflow/tv/ui/theme/Typography.kt @@ -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 + ) +} diff --git a/android-tv/app/src/main/java/com/streamflow/tv/viewmodel/DetailViewModel.kt b/android-tv/app/src/main/java/com/streamflow/tv/viewmodel/DetailViewModel.kt new file mode 100644 index 0000000..5d24019 --- /dev/null +++ b/android-tv/app/src/main/java/com/streamflow/tv/viewmodel/DetailViewModel.kt @@ -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 = _uiState + + fun loadMovie(slug: String) { + android.util.Log.e("DetailVM", "loadMovie($slug) called") + viewModelScope.launch { + _uiState.value = DetailUiState(isLoading = true) + try { + val movie = repository.getMovieDetail(slug) + android.util.Log.e("DetailVM", "loadMovie success: ${movie.title}, episodes: ${movie.episodes?.size}") + _uiState.value = DetailUiState(movie = movie, isLoading = false) + } catch (e: Exception) { + android.util.Log.e("DetailVM", "loadMovie failed", e) + _uiState.value = DetailUiState( + isLoading = false, + error = e.message ?: "Failed to load movie details" + ) + } + } + } + + fun toggleMyList(isInList: Boolean) { + _uiState.value = _uiState.value.copy(isInMyList = !isInList) + } +} diff --git a/android-tv/app/src/main/java/com/streamflow/tv/viewmodel/HomeViewModel.kt b/android-tv/app/src/main/java/com/streamflow/tv/viewmodel/HomeViewModel.kt new file mode 100644 index 0000000..d30e80e --- /dev/null +++ b/android-tv/app/src/main/java/com/streamflow/tv/viewmodel/HomeViewModel.kt @@ -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 = emptyList(), + val watchedMovies: List = emptyList(), + val recommendedMovies: List = emptyList(), + val categoryMovies: Map> = emptyMap(), + val isLoading: Boolean = true, + val error: String? = null, + val currentCategory: String? = null +) + +class HomeViewModel : ViewModel() { + + private val repository = MovieRepository() + private val _uiState = MutableStateFlow(HomeUiState()) + val uiState: StateFlow = _uiState + + private var userDataRepository: com.streamflow.tv.data.repository.UserDataRepository? = null + + private val categories = listOf( + "phim-le" to "Phim Lẻ", + "phim-bo" to "Phim Bộ", + "hoat-hinh" to "Hoạt Hình", + "tv-shows" to "TV Shows" + ) + + init { + loadHome() + } + + fun loadHome( + category: String? = null, + userRepo: com.streamflow.tv.data.repository.UserDataRepository? = null + ) { + if (userRepo != null) { + this.userDataRepository = userRepo + } + + viewModelScope.launch { + _uiState.value = _uiState.value.copy(isLoading = true, error = null, currentCategory = category) + try { + // Load history if repository is available + val history = userRepo?.watchHistory?.first() ?: emptyList() + + if (category != null) { + // Load single category + val response = repository.getHomeVideos(category) + _uiState.value = _uiState.value.copy( + heroMovies = response.items.take(5), + watchedMovies = history, + recommendedMovies = response.items.filter { m -> history.none { it.slug == m.slug } }.shuffled().take(10), + categoryMovies = mapOf( + categories.find { it.first == category }?.second.orEmpty() to response.items + ), + isLoading = false + ) + } else { + // Load all categories for home + val allMovies = mutableMapOf>() + var heroItems = listOf() + val allFlattened = mutableListOf() + + // 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" + ) + } + } + } +} diff --git a/android-tv/app/src/main/java/com/streamflow/tv/viewmodel/MyListViewModel.kt b/android-tv/app/src/main/java/com/streamflow/tv/viewmodel/MyListViewModel.kt new file mode 100644 index 0000000..a393a49 --- /dev/null +++ b/android-tv/app/src/main/java/com/streamflow/tv/viewmodel/MyListViewModel.kt @@ -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 = emptyList(), + val watchHistory: List = emptyList() +) + +class MyListViewModel(application: Application) : AndroidViewModel(application) { + + private val userRepo = UserDataRepository(application) + private val _uiState = MutableStateFlow(MyListUiState()) + val uiState: StateFlow = _uiState + + init { + viewModelScope.launch { + userRepo.myList.collectLatest { list -> + _uiState.value = _uiState.value.copy(savedMovies = list) + } + } + viewModelScope.launch { + userRepo.watchHistory.collectLatest { history -> + _uiState.value = _uiState.value.copy(watchHistory = history) + } + } + } + + fun addToMyList(movie: Movie) { + viewModelScope.launch { userRepo.addToMyList(movie) } + } + + fun removeFromMyList(slug: String) { + viewModelScope.launch { userRepo.removeFromMyList(slug) } + } + + fun addToHistory(movie: Movie) { + viewModelScope.launch { userRepo.addToHistory(movie) } + } +} diff --git a/android-tv/app/src/main/java/com/streamflow/tv/viewmodel/PlayerViewModel.kt b/android-tv/app/src/main/java/com/streamflow/tv/viewmodel/PlayerViewModel.kt new file mode 100644 index 0000000..d7a3d0a --- /dev/null +++ b/android-tv/app/src/main/java/com/streamflow/tv/viewmodel/PlayerViewModel.kt @@ -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 = _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" + ) + } + } +} diff --git a/android-tv/app/src/main/java/com/streamflow/tv/viewmodel/SearchViewModel.kt b/android-tv/app/src/main/java/com/streamflow/tv/viewmodel/SearchViewModel.kt new file mode 100644 index 0000000..7086fa9 --- /dev/null +++ b/android-tv/app/src/main/java/com/streamflow/tv/viewmodel/SearchViewModel.kt @@ -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 = emptyList(), + val isLoading: Boolean = false, + val hasSearched: Boolean = false +) + +class SearchViewModel : ViewModel() { + + private val repository = MovieRepository() + private val _uiState = MutableStateFlow(SearchUiState()) + val uiState: StateFlow = _uiState + + fun search(query: String) { + if (query.isBlank()) return + _uiState.value = SearchUiState(query = query, isLoading = true, hasSearched = true) + viewModelScope.launch { + try { + val response = repository.searchVideos(query) + _uiState.value = _uiState.value.copy( + results = response.items, + isLoading = false + ) + } catch (e: Exception) { + _uiState.value = _uiState.value.copy(isLoading = false) + } + } + } +} diff --git a/android-tv/app/src/main/res/drawable/app_banner.xml b/android-tv/app/src/main/res/drawable/app_banner.xml new file mode 100644 index 0000000..f222f5f --- /dev/null +++ b/android-tv/app/src/main/res/drawable/app_banner.xml @@ -0,0 +1,33 @@ + + + + + + + + + + + + + + + + + + diff --git a/android-tv/app/src/main/res/mipmap/ic_launcher.xml b/android-tv/app/src/main/res/mipmap/ic_launcher.xml new file mode 100644 index 0000000..3506d45 --- /dev/null +++ b/android-tv/app/src/main/res/mipmap/ic_launcher.xml @@ -0,0 +1,17 @@ + + + + + + + + + diff --git a/android-tv/app/src/main/res/values/strings.xml b/android-tv/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..ac6af5f --- /dev/null +++ b/android-tv/app/src/main/res/values/strings.xml @@ -0,0 +1,3 @@ + + StreamFlow + diff --git a/android-tv/app/src/main/res/values/themes.xml b/android-tv/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..88a1d5c --- /dev/null +++ b/android-tv/app/src/main/res/values/themes.xml @@ -0,0 +1,8 @@ + + + + diff --git a/android-tv/build.gradle.kts b/android-tv/build.gradle.kts new file mode 100644 index 0000000..e1e81fa --- /dev/null +++ b/android-tv/build.gradle.kts @@ -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 +} diff --git a/android-tv/build.txt b/android-tv/build.txt new file mode 100644 index 0000000..750c14f --- /dev/null +++ b/android-tv/build.txt @@ -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 diff --git a/android-tv/build_async.txt b/android-tv/build_async.txt new file mode 100644 index 0000000..c3229cf --- /dev/null +++ b/android-tv/build_async.txt @@ -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 diff --git a/android-tv/build_episodes.txt b/android-tv/build_episodes.txt new file mode 100644 index 0000000..722e400 --- /dev/null +++ b/android-tv/build_episodes.txt @@ -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 diff --git a/android-tv/cross.json b/android-tv/cross.json new file mode 100644 index 0000000..fa98eb9 --- /dev/null +++ b/android-tv/cross.json @@ -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"}]} diff --git a/android-tv/gradle.properties b/android-tv/gradle.properties new file mode 100644 index 0000000..f0a2e55 --- /dev/null +++ b/android-tv/gradle.properties @@ -0,0 +1,4 @@ +org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 +android.useAndroidX=true +kotlin.code.style=official +android.nonTransitiveRClass=true diff --git a/android-tv/gradle/wrapper/gradle-wrapper.jar b/android-tv/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..d64cd49 Binary files /dev/null and b/android-tv/gradle/wrapper/gradle-wrapper.jar differ diff --git a/android-tv/gradle/wrapper/gradle-wrapper.properties b/android-tv/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..a595206 --- /dev/null +++ b/android-tv/gradle/wrapper/gradle-wrapper.properties @@ -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 diff --git a/android-tv/gradlew.bat b/android-tv/gradlew.bat new file mode 100644 index 0000000..4f64ffb --- /dev/null +++ b/android-tv/gradlew.bat @@ -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 diff --git a/android-tv/response.json b/android-tv/response.json new file mode 100644 index 0000000..1ab08e5 --- /dev/null +++ b/android-tv/response.json @@ -0,0 +1 @@ +{"id":"thu-thach-than-tuong","title":"Thử Thách Thần Tượng","original_title":"RUNNING MAN","slug":"thu-thach-than-tuong","thumbnail":"https://img.ophim1.com/uploads/movies/thu-thach-than-tuong-thumb.jpg","backdrop":"https://img.ophim1.com/uploads/movies/thu-thach-than-tuong-poster.jpg","year":2010,"quality":"HD","genre":"Hài Hước, Phiêu Lưu","description":"\u003cp\u003eShow nằm trong chương trình New Sunday của đài SBS được VIETSUB, cùng với Heroes (Kara Nicole, IU, Narsha, Lee Jin…) được phát sóng tiếp theo sau đó. Đây là show “hành động đô thị” mới lạ chưa từng có, đánh dấu sự trở lại của MC quốc dân Yu Jae-suk sau khi anh rời chương trình Good Sunday’s Family Outing vào tháng 2/2010. Xem Running Man, đảm bảo các bạn sẽ phải cười lăn cười bò vì sự hài hước của các thành viên, cũng như những nhiệm vụ oái ăm mà họ phải chịu đựng trong suốt chương trình.\u003cbr\u003e\u003cbr\u003eMC cùng các thành viên và khách mời là những ngôi sao nổi tiếng của Hàn Quốc khám phá những địa điểm thú vị trong thành phố. Đúng như tên gọi của chương trình, các thành viên Running Man bị nhốt trong một địa điểm được chọn sẵn, và rượt đuổi nhau trong đêm để giành lấy Heo vàng hoặc quả bóng Running Ball. Đội bị thua sẽ phải chịu hình phạt khi trời sáng. Một trong những hình phạt phổ biến và khủng khiếp nhất là mặc quần xà lỏn giữa nơi công cộng…\u003c/p\u003e","category":"movies","director":"Kim Ju Hyung, Jo Hyo Jin","country":"Hàn Quốc","episodes":[{"number":410,"title":"410","url":"https://vip.opstream11.com/20220630/33627_1a827e4b/index.m3u8"},{"number":411,"title":"411","url":"https://vip.opstream11.com/20220630/33628_ade1768d/index.m3u8"},{"number":412,"title":"412","url":"https://vip.opstream11.com/20220630/33629_c9b1c637/index.m3u8"},{"number":413,"title":"413","url":"https://vip.opstream11.com/20220630/33630_5f3bbcda/index.m3u8"},{"number":414,"title":"414","url":"https://vip.opstream11.com/20220630/33631_14517e2b/index.m3u8"},{"number":415,"title":"415","url":"https://vip.opstream11.com/20220630/33632_8da7c312/index.m3u8"},{"number":416,"title":"416","url":"https://vip.opstream11.com/20220630/33633_bd69c15b/index.m3u8"},{"number":417,"title":"417","url":"https://vip.opstream11.com/20220630/33634_8720e816/index.m3u8"},{"number":418,"title":"418","url":"https://vip.opstream11.com/20220630/33635_d9eacd63/index.m3u8"},{"number":419,"title":"419","url":"https://vip.opstream11.com/20220630/33636_9be79c67/index.m3u8"},{"number":420,"title":"420","url":"https://vip.opstream11.com/20220630/33637_8c2fe5d3/index.m3u8"},{"number":421,"title":"421","url":"https://vip.opstream11.com/20220630/33638_4fd68c69/index.m3u8"},{"number":422,"title":"422","url":"https://vip.opstream11.com/20220630/33639_46f7754f/index.m3u8"},{"number":423,"title":"423","url":"https://vip.opstream11.com/20220630/33640_3f7553cd/index.m3u8"},{"number":424,"title":"424","url":"https://vip.opstream11.com/20220630/33641_5a3397b0/index.m3u8"},{"number":425,"title":"425","url":"https://vip.opstream11.com/20220630/33642_708cf5bc/index.m3u8"},{"number":426,"title":"426","url":"https://vip.opstream11.com/20220630/33643_6d6ca6d9/index.m3u8"},{"number":427,"title":"427","url":"https://vip.opstream11.com/20220630/33644_95f81df8/index.m3u8"},{"number":428,"title":"428","url":"https://vip.opstream11.com/20220630/33645_1e8ad82f/index.m3u8"},{"number":429,"title":"429","url":"https://vip.opstream11.com/20220630/33646_d789ab86/index.m3u8"},{"number":430,"title":"430","url":"https://vip.opstream11.com/20220630/33647_cbe38fc8/index.m3u8"},{"number":431,"title":"431","url":"https://vip.opstream11.com/20220630/33648_8eae9636/index.m3u8"},{"number":432,"title":"432","url":"https://vip.opstream11.com/20220630/33649_785c1ba4/index.m3u8"},{"number":433,"title":"433","url":"https://vip.opstream11.com/20220630/33650_c26fa47b/index.m3u8"},{"number":434,"title":"434","url":"https://vip.opstream11.com/20220630/33651_24d75242/index.m3u8"},{"number":435,"title":"435","url":"https://vip.opstream11.com/20220630/33652_a7a23a04/index.m3u8"},{"number":436,"title":"436","url":"https://vip.opstream11.com/20220630/33653_339d11f9/index.m3u8"},{"number":437,"title":"437","url":"https://vip.opstream11.com/20220630/33654_d82096f4/index.m3u8"},{"number":438,"title":"438","url":"https://vip.opstream11.com/20220630/33655_3787542a/index.m3u8"},{"number":439,"title":"439","url":"https://vip.opstream11.com/20220630/33656_3e3e4e48/index.m3u8"},{"number":440,"title":"440","url":"https://vip.opstream11.com/20220630/33657_8cd7078e/index.m3u8"},{"number":441,"title":"441","url":"https://vip.opstream11.com/20220630/33658_fd67dd51/index.m3u8"},{"number":442,"title":"442","url":"https://vip.opstream11.com/20220630/33659_3c171f3c/index.m3u8"},{"number":443,"title":"443","url":"https://vip.opstream11.com/20220630/33660_8672067a/index.m3u8"},{"number":444,"title":"444","url":"https://vip.opstream11.com/20220630/33661_22c7cc85/index.m3u8"},{"number":445,"title":"445","url":"https://vip.opstream11.com/20220630/33662_8563ccf1/index.m3u8"},{"number":446,"title":"446","url":"https://vip.opstream11.com/20220630/33663_84edde83/index.m3u8"},{"number":447,"title":"447","url":"https://vip.opstream11.com/20220630/33664_8417b1e0/index.m3u8"},{"number":448,"title":"448","url":"https://vip.opstream11.com/20220630/33665_d8e92b70/index.m3u8"},{"number":449,"title":"449","url":"https://vip.opstream11.com/20220630/33666_6c16aa02/index.m3u8"},{"number":450,"title":"450","url":"https://vip.opstream11.com/20220630/33667_fdca259d/index.m3u8"},{"number":451,"title":"451","url":"https://vip.opstream11.com/20220630/33668_87693b64/index.m3u8"},{"number":452,"title":"452","url":"https://vip.opstream11.com/20220630/33669_8771134a/index.m3u8"},{"number":453,"title":"453","url":"https://vip.opstream11.com/20220630/33670_dc047d7c/index.m3u8"},{"number":454,"title":"454","url":"https://vip.opstream11.com/20220630/33671_07e32cf3/index.m3u8"},{"number":455,"title":"455","url":"https://vip.opstream11.com/20220630/33672_b52f67c1/index.m3u8"},{"number":456,"title":"456","url":"https://vip.opstream11.com/20220630/33673_b3f3c990/index.m3u8"},{"number":457,"title":"457","url":"https://vip.opstream11.com/20220630/33674_840101af/index.m3u8"},{"number":458,"title":"458","url":"https://vip.opstream11.com/20220630/33675_e7232101/index.m3u8"},{"number":459,"title":"459","url":"https://vip.opstream11.com/20220630/33676_b7812cfd/index.m3u8"},{"number":460,"title":"460","url":"https://vip.opstream11.com/20220630/33677_50355b60/index.m3u8"},{"number":461,"title":"461","url":"https://vip.opstream11.com/20220630/33678_4f901e45/index.m3u8"},{"number":462,"title":"462","url":"https://vip.opstream11.com/20220630/33679_78176c3f/index.m3u8"},{"number":463,"title":"463","url":"https://vip.opstream11.com/20220630/33680_15a8c9e1/index.m3u8"},{"number":464,"title":"464","url":"https://vip.opstream11.com/20220630/33681_e1260090/index.m3u8"},{"number":465,"title":"465","url":"https://vip.opstream11.com/20220630/33682_1a8124bc/index.m3u8"},{"number":466,"title":"466","url":"https://vip.opstream11.com/20220630/33683_84ff6b5f/index.m3u8"},{"number":467,"title":"467","url":"https://vip.opstream11.com/20220630/33684_44b12853/index.m3u8"},{"number":468,"title":"468","url":"https://vip.opstream11.com/20220630/33685_46b2ff2d/index.m3u8"},{"number":469,"title":"469","url":"https://vip.opstream11.com/20220630/33686_4e4f8075/index.m3u8"},{"number":470,"title":"470","url":"https://vip.opstream11.com/20220630/33687_fed6c664/index.m3u8"},{"number":471,"title":"471","url":"https://vip.opstream11.com/20220630/33688_e050027a/index.m3u8"},{"number":472,"title":"472","url":"https://vip.opstream11.com/20220630/33689_3124bac7/index.m3u8"},{"number":473,"title":"473","url":"https://vip.opstream11.com/20220630/33690_93c52d3a/index.m3u8"},{"number":474,"title":"474","url":"https://vip.opstream11.com/20220630/33691_8d7208ef/index.m3u8"},{"number":475,"title":"475","url":"https://vip.opstream11.com/20220630/33692_88687796/index.m3u8"},{"number":476,"title":"476","url":"https://vip.opstream11.com/20220630/33693_e3c67ec2/index.m3u8"},{"number":477,"title":"477","url":"https://vip.opstream11.com/20220630/33694_99521b8e/index.m3u8"},{"number":478,"title":"478","url":"https://vip.opstream11.com/20220630/33695_5326d5a5/index.m3u8"},{"number":479,"title":"479","url":"https://vip.opstream11.com/20220630/33696_aece4261/index.m3u8"},{"number":480,"title":"480","url":"https://vip.opstream11.com/20220630/33697_67730747/index.m3u8"},{"number":481,"title":"481","url":"https://vip.opstream11.com/20220630/33698_6aa21dd5/index.m3u8"},{"number":482,"title":"482","url":"https://vip.opstream11.com/20220630/33699_104f17b0/index.m3u8"},{"number":483,"title":"483","url":"https://vip.opstream11.com/20220630/33700_6f8cd2e3/index.m3u8"},{"number":484,"title":"484","url":"https://vip.opstream11.com/20220630/33701_7b99cd57/index.m3u8"},{"number":485,"title":"485","url":"https://vip.opstream11.com/20220630/33702_1d2051b3/index.m3u8"},{"number":486,"title":"486","url":"https://vip.opstream11.com/20220630/33703_c7f6e3a3/index.m3u8"},{"number":487,"title":"487","url":"https://vip.opstream11.com/20220630/33704_702bafa5/index.m3u8"},{"number":488,"title":"488","url":"https://vip.opstream11.com/20220630/33705_c3d9182f/index.m3u8"},{"number":489,"title":"489","url":"https://vip.opstream11.com/20220630/33706_5b826138/index.m3u8"},{"number":490,"title":"490","url":"https://vip.opstream11.com/20220630/33707_3975d575/index.m3u8"},{"number":491,"title":"491","url":"https://vip.opstream11.com/20220630/33708_f3e72f99/index.m3u8"},{"number":492,"title":"492","url":"https://vip.opstream11.com/20220630/33709_0b0cf3b8/index.m3u8"},{"number":493,"title":"493","url":"https://vip.opstream11.com/20220630/33710_3bcf16b1/index.m3u8"},{"number":494,"title":"494","url":"https://vip.opstream11.com/20220630/33711_4eafcfa0/index.m3u8"},{"number":495,"title":"495","url":"https://vip.opstream11.com/20220630/33712_5e05de54/index.m3u8"},{"number":496,"title":"496","url":"https://vip.opstream11.com/20220630/33713_6a670f6a/index.m3u8"},{"number":497,"title":"497","url":"https://vip.opstream11.com/20220630/33714_817444a3/index.m3u8"},{"number":498,"title":"498","url":"https://vip.opstream11.com/20220630/33715_8d20d99b/index.m3u8"},{"number":499,"title":"499","url":"https://vip.opstream11.com/20220630/33716_e1af59e5/index.m3u8"},{"number":500,"title":"500","url":"https://vip.opstream11.com/20220630/33717_339c359d/index.m3u8"},{"number":501,"title":"501","url":"https://vip.opstream11.com/20220630/33736_b2665cbd/index.m3u8"},{"number":502,"title":"502","url":"https://vip.opstream11.com/20220630/33718_2ce1d368/index.m3u8"},{"number":503,"title":"503","url":"https://vip.opstream11.com/20220630/33719_b8996562/index.m3u8"},{"number":504,"title":"504","url":"https://vip.opstream11.com/20220630/33737_39fbfc2d/index.m3u8"},{"number":505,"title":"505","url":"https://vip.opstream11.com/20220630/33720_9962aae3/index.m3u8"},{"number":506,"title":"506","url":"https://vip.opstream11.com/20220630/33721_f9f89ddc/index.m3u8"},{"number":507,"title":"507","url":"https://vip.opstream11.com/20220630/33722_223d27fc/index.m3u8"},{"number":508,"title":"508","url":"https://vip.opstream11.com/20220630/33723_b545b9d3/index.m3u8"},{"number":509,"title":"509","url":"https://vip.opstream11.com/20220630/33724_00d112b9/index.m3u8"},{"number":510,"title":"510","url":"https://vip.opstream11.com/20220630/33725_2e4c72c4/index.m3u8"},{"number":511,"title":"511","url":"https://vip.opstream11.com/20220630/33726_c533264a/index.m3u8"},{"number":512,"title":"512","url":"https://vip.opstream11.com/20220630/33727_54c0add2/index.m3u8"},{"number":513,"title":"513","url":"https://vip.opstream11.com/20220630/33728_e69ec839/index.m3u8"},{"number":514,"title":"514","url":"https://vip.opstream11.com/20220630/33729_1196413e/index.m3u8"},{"number":515,"title":"515","url":"https://vip.opstream11.com/20220630/33730_fec9c3cf/index.m3u8"},{"number":516,"title":"516","url":"https://vip.opstream11.com/20220630/33731_56314e36/index.m3u8"},{"number":517,"title":"517","url":"https://vip.opstream11.com/20220630/33732_4b1c51b3/index.m3u8"},{"number":518,"title":"518","url":"https://vip.opstream11.com/20220630/33733_7d727518/index.m3u8"},{"number":519,"title":"519","url":"https://vip.opstream11.com/20220630/33734_21d0a47c/index.m3u8"},{"number":520,"title":"520","url":"https://vip.opstream11.com/20220630/33735_670f537b/index.m3u8"},{"number":521,"title":"521","url":"https://vip.opstream11.com/20220702/33962_22e58856/index.m3u8"},{"number":522,"title":"522","url":"https://vip.opstream11.com/20220702/33963_936f02c6/index.m3u8"},{"number":523,"title":"523","url":"https://vip.opstream11.com/20220702/33964_2483d046/index.m3u8"},{"number":524,"title":"524","url":"https://vip.opstream11.com/20220702/33965_6c9fcfcc/index.m3u8"},{"number":525,"title":"525","url":"https://vip.opstream11.com/20220702/33966_a5183aad/index.m3u8"},{"number":526,"title":"526","url":"https://vip.opstream11.com/20220702/33967_f9608c0e/index.m3u8"},{"number":527,"title":"527","url":"https://vip.opstream11.com/20220702/33968_6333d4c7/index.m3u8"},{"number":528,"title":"528","url":"https://vip.opstream11.com/20220702/33969_9fc66f30/index.m3u8"},{"number":529,"title":"529","url":"https://vip.opstream11.com/20220702/33970_93e6e420/index.m3u8"},{"number":530,"title":"530","url":"https://vip.opstream11.com/20220702/33971_171728bb/index.m3u8"},{"number":531,"title":"531","url":"https://vip.opstream11.com/20220702/33972_105278e8/index.m3u8"},{"number":532,"title":"532","url":"https://vip.opstream11.com/20220702/33973_91a3dff4/index.m3u8"},{"number":533,"title":"533","url":"https://vip.opstream11.com/20220702/33974_505ffd8a/index.m3u8"},{"number":534,"title":"534","url":"https://vip.opstream11.com/20220702/33975_3bd6b2d8/index.m3u8"},{"number":535,"title":"535","url":"https://vip.opstream11.com/20220702/33976_c03c9d15/index.m3u8"},{"number":536,"title":"536","url":"https://vip.opstream11.com/20220702/33977_f0c76c8e/index.m3u8"},{"number":537,"title":"537","url":"https://vip.opstream11.com/20220702/33978_8fb53765/index.m3u8"},{"number":538,"title":"538","url":"https://vip.opstream11.com/20220702/33979_c692ed4a/index.m3u8"},{"number":539,"title":"539","url":"https://vip.opstream11.com/20220702/33980_848e2117/index.m3u8"},{"number":540,"title":"540","url":"https://vip.opstream11.com/20220702/33981_ff4de4ee/index.m3u8"},{"number":541,"title":"541","url":"https://vip.opstream11.com/20220702/33982_85bacb8b/index.m3u8"},{"number":542,"title":"542","url":"https://vip.opstream11.com/20220702/33983_3438a799/index.m3u8"},{"number":543,"title":"543","url":"https://vip.opstream11.com/20220702/33984_9fb8bef6/index.m3u8"},{"number":544,"title":"544","url":"https://vip.opstream11.com/20220702/33985_402f5702/index.m3u8"},{"number":545,"title":"545","url":"https://vip.opstream11.com/20220702/33986_b7541741/index.m3u8"},{"number":546,"title":"546","url":"https://vip.opstream11.com/20220702/33987_28bd7ef4/index.m3u8"},{"number":547,"title":"547","url":"https://vip.opstream11.com/20220702/33988_240c499f/index.m3u8"},{"number":548,"title":"548","url":"https://vip.opstream11.com/20220702/33989_e6a2fdbd/index.m3u8"},{"number":549,"title":"549","url":"https://vip.opstream11.com/20220702/33990_e544436a/index.m3u8"},{"number":550,"title":"550","url":"https://vip.opstream11.com/20220702/33991_e153cb5c/index.m3u8"},{"number":551,"title":"551","url":"https://vip.opstream11.com/20220702/33992_38c33225/index.m3u8"},{"number":552,"title":"552","url":"https://vip.opstream11.com/20220702/33993_197a3ed4/index.m3u8"},{"number":553,"title":"553","url":"https://vip.opstream11.com/20220702/33994_515cc4b5/index.m3u8"},{"number":554,"title":"554","url":"https://vip.opstream11.com/20220702/33995_1f4d7d51/index.m3u8"},{"number":555,"title":"555","url":"https://vip.opstream11.com/20220702/33996_90faa819/index.m3u8"},{"number":556,"title":"556","url":"https://vip.opstream11.com/20220702/33997_a7625210/index.m3u8"},{"number":557,"title":"557","url":"https://vip.opstream11.com/20220702/33998_9bb7e4e1/index.m3u8"},{"number":558,"title":"558","url":"https://vip.opstream11.com/20220702/33999_366813a5/index.m3u8"},{"number":559,"title":"559","url":"https://vip.opstream11.com/20220702/34000_9dbf22b4/index.m3u8"},{"number":560,"title":"560","url":"https://vip.opstream11.com/20220702/34001_ad162221/index.m3u8"},{"number":561,"title":"561","url":"https://vip.opstream11.com/20220702/34002_8ad667e6/index.m3u8"},{"number":562,"title":"562","url":"https://vip.opstream11.com/20220702/34003_0f673dfc/index.m3u8"},{"number":563,"title":"563","url":"https://vip.opstream11.com/20220702/34004_f53d0a79/index.m3u8"},{"number":564,"title":"564","url":"https://vip.opstream11.com/20220702/34005_c693352f/index.m3u8"},{"number":565,"title":"565","url":"https://vip.opstream11.com/20220702/34006_6d7dfa75/index.m3u8"},{"number":566,"title":"566","url":"https://vip.opstream11.com/20220702/34007_0b3de887/index.m3u8"},{"number":567,"title":"567","url":"https://vip.opstream11.com/20220702/34008_71fdddde/index.m3u8"},{"number":568,"title":"568","url":"https://vip.opstream11.com/20220702/34009_570184b1/index.m3u8"},{"number":569,"title":"569","url":"https://vip.opstream11.com/20220702/34010_367d52f6/index.m3u8"},{"number":570,"title":"570","url":"https://vip.opstream11.com/20220702/34011_6f55619f/index.m3u8"},{"number":571,"title":"571","url":"https://vip.opstream11.com/20220702/34012_8126fe81/index.m3u8"},{"number":572,"title":"572","url":"https://vip.opstream11.com/20220702/34013_c9dbf501/index.m3u8"},{"number":573,"title":"573","url":"https://vip.opstream11.com/20220702/34014_e4ab6baa/index.m3u8"},{"number":574,"title":"574","url":"https://vip.opstream11.com/20220702/34015_6886018b/index.m3u8"},{"number":575,"title":"575","url":"https://vip.opstream11.com/20220702/34016_54db88d4/index.m3u8"},{"number":576,"title":"576","url":"https://vip.opstream11.com/20220702/34017_de42880f/index.m3u8"},{"number":577,"title":"577","url":"https://vip.opstream11.com/20220702/34018_f1cdb63a/index.m3u8"},{"number":578,"title":"578","url":"https://vip.opstream11.com/20220702/34019_0cb6291e/index.m3u8"},{"number":579,"title":"579","url":"https://vip.opstream11.com/20220702/34020_001a572b/index.m3u8"},{"number":580,"title":"580","url":"https://vip.opstream11.com/20220702/34021_5eb0959e/index.m3u8"},{"number":581,"title":"581","url":"https://vip.opstream14.com/20220706/17493_8f05ee74/index.m3u8"},{"number":582,"title":"582","url":"https://vip.opstream14.com/20220706/17494_06b86ac3/index.m3u8"},{"number":583,"title":"583","url":"https://vip.opstream14.com/20220706/17495_c129c4de/index.m3u8"},{"number":584,"title":"584","url":"https://vip.opstream14.com/20220706/17496_230b5e27/index.m3u8"},{"number":585,"title":"585","url":"https://vip.opstream14.com/20220706/17497_f23f9d63/index.m3u8"},{"number":586,"title":"586","url":"https://vip.opstream14.com/20220706/17498_5d0bad8b/index.m3u8"},{"number":587,"title":"587","url":"https://vip.opstream14.com/20220706/17499_69243e2b/index.m3u8"},{"number":588,"title":"588","url":"https://vip.opstream14.com/20220706/17500_8cb900d6/index.m3u8"},{"number":589,"title":"589","url":"https://vip.opstream14.com/20220706/17501_a953683c/index.m3u8"},{"number":590,"title":"590","url":"https://vip.opstream14.com/20220706/17502_c98460ee/index.m3u8"},{"number":591,"title":"591","url":"https://vip.opstream14.com/20220706/17503_90a330a7/index.m3u8"},{"number":592,"title":"592","url":"https://vip.opstream14.com/20220706/17504_7260c782/index.m3u8"},{"number":593,"title":"593","url":"https://vip.opstream14.com/20220706/17505_1198589e/index.m3u8"},{"number":594,"title":"594","url":"https://vip.opstream14.com/20220706/17506_d8b13687/index.m3u8"},{"number":595,"title":"595","url":"https://vip.opstream14.com/20220706/17507_49c69869/index.m3u8"},{"number":596,"title":"596 SP","url":"https://vip.opstream14.com/20220706/17509_a0c5852c/index.m3u8"},{"number":596,"title":"596","url":"https://vip.opstream14.com/20220706/17508_bd8fd519/index.m3u8"},{"number":597,"title":"597","url":"https://vip.opstream14.com/20220706/17510_3d39698b/index.m3u8"},{"number":598,"title":"598","url":"https://vip.opstream14.com/20220706/17511_e644c972/index.m3u8"},{"number":599,"title":"599","url":"https://vip.opstream14.com/20220706/17512_da49c199/index.m3u8"},{"number":600,"title":"600","url":"https://vip.opstream14.com/20220706/17513_90a25cac/index.m3u8"},{"number":601,"title":"601","url":"https://vip.opstream14.com/20220706/17514_ba1c7acf/index.m3u8"},{"number":602,"title":"602","url":"https://vip.opstream14.com/20220706/17515_1ff0f377/index.m3u8"},{"number":603,"title":"603","url":"https://vip.opstream14.com/20220706/17516_d1d84daf/index.m3u8"},{"number":604,"title":"604","url":"https://vip.opstream14.com/20220706/17517_6ac34e32/index.m3u8"},{"number":605,"title":"605","url":"https://vip.opstream14.com/20220706/17518_0db17b6f/index.m3u8"},{"number":606,"title":"606","url":"https://vip.opstream14.com/20220706/17519_b222759e/index.m3u8"},{"number":607,"title":"607","url":"https://vip.opstream14.com/20220706/17520_2b2a6f61/index.m3u8"},{"number":608,"title":"608","url":"https://vip.opstream14.com/20220706/17521_79704ba4/index.m3u8"},{"number":609,"title":"609","url":"https://vip.opstream14.com/20220706/17522_836f8167/index.m3u8"},{"number":610,"title":"610","url":"https://vip.opstream14.com/20220706/17523_1baf867b/index.m3u8"},{"number":611,"title":"611","url":"https://vip.opstream14.com/20220718/18419_868f3770/index.m3u8"},{"number":612,"title":"612","url":"https://vip.opstream14.com/20220720/18593_d43fe3fa/index.m3u8"},{"number":613,"title":"613","url":"https://vip.opstream14.com/20220725/18936_2da6a9c6/index.m3u8"},{"number":614,"title":"614","url":"https://vip.opstream14.com/20220804/19552_22148739/index.m3u8"},{"number":615,"title":"615","url":"https://vip.opstream14.com/20220808/19777_e4af942d/index.m3u8"},{"number":616,"title":"616","url":"https://vip.opstream15.com/20220814/26493_b662dfc4/index.m3u8"},{"number":617,"title":"617","url":"https://vip.opstream14.com/20220822/20510_bffb900a/index.m3u8"},{"number":618,"title":"618","url":"https://vip.opstream14.com/20220829/20795_43470b4a/index.m3u8"},{"number":619,"title":"619","url":"https://vip.opstream14.com/20220920/21696_5cc46f79/index.m3u8"},{"number":620,"title":"620","url":"https://vip.opstream14.com/20220920/21697_da9f122e/index.m3u8"},{"number":621,"title":"621","url":"https://vip.opstream14.com/20220920/21698_511492da/index.m3u8"},{"number":622,"title":"622","url":"https://vip.opstream14.com/20220926/22041_b3ccf0e4/index.m3u8"},{"number":623,"title":"623","url":"https://vip.opstream15.com/20221003/26948_59ef2751/index.m3u8"},{"number":624,"title":"624","url":"https://vip.opstream15.com/20221010/27204_5ed7c9dd/index.m3u8"},{"number":625,"title":"625","url":"https://vip.opstream15.com/20221017/27438_93c55814/index.m3u8"},{"number":626,"title":"626","url":"https://vip.opstream15.com/20221024/27571_a0d352a2/index.m3u8"},{"number":627,"title":"627","url":"https://vip.opstream16.com/20221107/25517_beb7e722/index.m3u8"},{"number":628,"title":"628","url":"https://vip.opstream15.com/20221114/28197_e26b8ea3/index.m3u8"},{"number":629,"title":"629","url":"https://vip.opstream16.com/20221121/26306_a8599abf/index.m3u8"},{"number":630,"title":"630","url":"https://vip.opstream15.com/20221128/28683_41ef4fe3/index.m3u8"},{"number":631,"title":"631","url":"https://vip.opstream16.com/20221205/26949_6e5b93cf/index.m3u8"},{"number":632,"title":"632","url":"https://vip.opstream16.com/20221213/27036_0cb731bf/index.m3u8"},{"number":633,"title":"633","url":"https://vip.opstream16.com/20221219/27560_fb6d1e5e/index.m3u8"},{"number":634,"title":"634","url":"https://vip.opstream16.com/20221226/28219_f3cb435b/index.m3u8"},{"number":635,"title":"635","url":"https://vip.opstream16.com/20230102/28493_dd81aecb/index.m3u8"},{"number":636,"title":"636","url":"https://vip.opstream16.com/20230109/28879_f396b2e9/index.m3u8"},{"number":637,"title":"637","url":"https://vip.opstream15.com/20230501/38511_fe32dfbf/index.m3u8"},{"number":638,"title":"638","url":"https://vip.opstream15.com/20230501/38512_41440218/index.m3u8"},{"number":639,"title":"639","url":"https://vip.opstream15.com/20230501/38513_44b1bbe5/index.m3u8"},{"number":640,"title":"640","url":"https://vip.opstream15.com/20230501/38514_c4d20fd2/index.m3u8"},{"number":641,"title":"641","url":"https://vip.opstream15.com/20230501/38515_890c5d55/index.m3u8"},{"number":642,"title":"642","url":"https://vip.opstream15.com/20230501/38516_6caccf0f/index.m3u8"},{"number":643,"title":"643","url":"https://vip.opstream15.com/20230501/38517_aed8f5ab/index.m3u8"},{"number":644,"title":"644","url":"https://vip.opstream15.com/20230501/38518_597c8a4f/index.m3u8"},{"number":645,"title":"645","url":"https://vip.opstream16.com/20230501/34517_13a2d953/index.m3u8"},{"number":646,"title":"646","url":"https://vip.opstream15.com/20230501/38519_11f12ae5/index.m3u8"},{"number":647,"title":"647","url":"https://vip.opstream15.com/20230501/38520_52c09ca7/index.m3u8"},{"number":648,"title":"648","url":"https://vip.opstream15.com/20230501/38521_9ee560b6/index.m3u8"},{"number":649,"title":"649","url":"https://vip.opstream15.com/20230501/38522_63eb26c9/index.m3u8"},{"number":650,"title":"650","url":"https://vip.opstream15.com/20230501/38523_aa1752d1/index.m3u8"},{"number":651,"title":"651","url":"https://vip.opstream15.com/20230501/38524_242df420/index.m3u8"},{"number":652,"title":"652","url":"https://vip.opstream15.com/20230501/38525_3db262a2/index.m3u8"},{"number":653,"title":"653","url":"https://vip.opstream15.com/20230509/38938_aacb9b20/index.m3u8"},{"number":654,"title":"654","url":"https://vip.opstream15.com/20230516/39306_9f6ac7a7/index.m3u8"},{"number":655,"title":"655","url":"https://vip.opstream15.com/20230522/39460_94dfbe55/index.m3u8"},{"number":656,"title":"656","url":"https://vip.opstream11.com/20230613/44696_a268befc/index.m3u8"},{"number":657,"title":"657","url":"https://vip.opstream11.com/20230605/44306_a768d956/index.m3u8"},{"number":658,"title":"658","url":"https://vip.opstream11.com/20230613/44697_0a957e0b/index.m3u8"},{"number":659,"title":"659","url":"https://vip.opstream11.com/20230619/44929_91eb8541/index.m3u8"},{"number":660,"title":"660","url":"https://vip.opstream15.com/20230703/40101_7ac784d6/index.m3u8"},{"number":661,"title":"661","url":"https://vip.opstream15.com/20230703/40102_52f6e71c/index.m3u8"},{"number":662,"title":"662","url":"https://vip.opstream11.com/20230718/45886_5d685c5e/index.m3u8"},{"number":663,"title":"663","url":"https://vip.opstream11.com/20230718/45885_f8dbb93c/index.m3u8"},{"number":664,"title":"664","url":"https://vip.opstream15.com/20230725/40796_d8120d85/index.m3u8"},{"number":665,"title":"665","url":"https://vip.opstream15.com/20230901/42005_4d23451d/index.m3u8"},{"number":666,"title":"666","url":"https://vip.opstream15.com/20230901/42006_69a17af9/index.m3u8"},{"number":667,"title":"667","url":"https://vip.opstream15.com/20230901/42007_7b6b9154/index.m3u8"},{"number":668,"title":"668","url":"https://vip.opstream15.com/20230901/42008_aea3d802/index.m3u8"},{"number":669,"title":"669","url":"https://vip.opstream15.com/20230901/42009_1ec2d0c0/index.m3u8"},{"number":670,"title":"670","url":"https://vip.opstream90.com/20250626/7832_ed0e6f99/index.m3u8"},{"number":671,"title":"671","url":"https://vip.opstream90.com/20250626/7833_4747f5ca/index.m3u8"},{"number":672,"title":"672","url":"https://vip.opstream90.com/20250626/7834_c44bebb9/index.m3u8"},{"number":673,"title":"673","url":"https://vip.opstream90.com/20250626/7835_62ce4772/index.m3u8"},{"number":674,"title":"674","url":"https://vip.opstream90.com/20250626/7836_6354461b/index.m3u8"},{"number":675,"title":"675","url":"https://vip.opstream90.com/20250626/7837_16841159/index.m3u8"},{"number":676,"title":"676","url":"https://vip.opstream90.com/20250626/7838_79121bb9/index.m3u8"},{"number":677,"title":"677","url":"https://vip.opstream90.com/20250626/7839_ca91c546/index.m3u8"},{"number":678,"title":"678","url":"https://vip.opstream90.com/20250626/7840_dc9fa5f2/index.m3u8"},{"number":679,"title":"679","url":"https://vip.opstream90.com/20250626/7841_2a2107d1/index.m3u8"},{"number":680,"title":"680","url":"https://vip.opstream90.com/20250626/7842_881cb553/index.m3u8"},{"number":681,"title":"681","url":"https://vip.opstream90.com/20250626/7843_8d917ee2/index.m3u8"},{"number":682,"title":"682","url":"https://vip.opstream90.com/20250626/7844_b356e7ae/index.m3u8"},{"number":683,"title":"683","url":"https://vip.opstream90.com/20250626/7845_0118a063/index.m3u8"},{"number":684,"title":"684","url":"https://vip.opstream90.com/20250626/7846_808e22af/index.m3u8"},{"number":685,"title":"685","url":"https://vip.opstream90.com/20250626/7872_b543376b/index.m3u8"},{"number":686,"title":"686","url":"https://vip.opstream90.com/20250626/7873_44e215cf/index.m3u8"},{"number":687,"title":"687","url":"https://vip.opstream90.com/20250626/7874_305ddad0/index.m3u8"},{"number":688,"title":"688","url":"https://vip.opstream90.com/20250626/7875_eba237ec/index.m3u8"},{"number":689,"title":"689","url":"https://vip.opstream90.com/20250626/7876_42dab568/index.m3u8"},{"number":690,"title":"690","url":"https://vip.opstream90.com/20250626/7877_d19a006f/index.m3u8"},{"number":691,"title":"691","url":"https://vip.opstream90.com/20250626/7878_21c3134e/index.m3u8"},{"number":692,"title":"692","url":"https://vip.opstream90.com/20250626/7879_f0282b5f/index.m3u8"},{"number":693,"title":"693","url":"https://vip.opstream90.com/20250626/7880_46ba59a6/index.m3u8"},{"number":694,"title":"694","url":"https://vip.opstream90.com/20250626/7881_7c9e9afa/index.m3u8"},{"number":695,"title":"695","url":"https://vip.opstream90.com/20250626/7882_3dde11a7/index.m3u8"},{"number":696,"title":"696","url":"https://vip.opstream90.com/20250626/7883_3f8e8bb5/index.m3u8"},{"number":697,"title":"697","url":"https://vip.opstream90.com/20250626/7884_46dce5f2/index.m3u8"},{"number":698,"title":"698","url":"https://vip.opstream90.com/20250626/7886_8b78af9b/index.m3u8"},{"number":699,"title":"699","url":"https://vip.opstream90.com/20250626/7885_5c225901/index.m3u8"},{"number":700,"title":"700","url":"https://vip.opstream90.com/20250626/7887_11f38f8e/index.m3u8"},{"number":701,"title":"701","url":"https://vip.opstream90.com/20250721/9180_398410ec/index.m3u8"},{"number":702,"title":"702","url":"https://vip.opstream90.com/20250721/9178_ebc03fa6/index.m3u8"},{"number":703,"title":"703","url":"https://vip.opstream90.com/20250721/9179_f4666b1c/index.m3u8"},{"number":704,"title":"704","url":"https://vip.opstream90.com/20250721/9181_4d215ab7/index.m3u8"},{"number":705,"title":"705","url":"https://vip.opstream90.com/20250721/9182_3d57fe6d/index.m3u8"},{"number":706,"title":"706","url":"https://vip.opstream90.com/20250723/9252_613690cd/index.m3u8"},{"number":707,"title":"707","url":"https://vip.opstream90.com/20250721/9183_6f0ca672/index.m3u8"},{"number":708,"title":"708","url":"https://vip.opstream90.com/20250721/9184_642eaa34/index.m3u8"},{"number":709,"title":"709","url":"https://vip.opstream90.com/20250723/9253_4e8eaf89/index.m3u8"},{"number":710,"title":"710","url":"https://vip.opstream90.com/20250723/9254_09d90af0/index.m3u8"},{"number":711,"title":"711","url":"https://vip.opstream90.com/20250723/9255_0eac690d/index.m3u8"},{"number":712,"title":"712","url":"https://vip.opstream90.com/20250723/9256_e82a0d32/index.m3u8"},{"number":713,"title":"713","url":"https://vip.opstream90.com/20250723/9257_c8461bf1/index.m3u8"},{"number":714,"title":"714","url":"https://vip.opstream90.com/20250723/9258_1868f17c/index.m3u8"},{"number":715,"title":"715","url":"https://vip.opstream90.com/20250723/9259_eb6dc8ab/index.m3u8"},{"number":716,"title":"716","url":"https://vip.opstream90.com/20250723/9260_f3a4ff48/index.m3u8"},{"number":717,"title":"717","url":"https://vip.opstream90.com/20250723/9261_43f8e83d/index.m3u8"},{"number":718,"title":"718","url":"https://vip.opstream90.com/20250723/9262_0801b20e/index.m3u8"},{"number":719,"title":"719","url":"https://vip.opstream90.com/20250723/9263_40826945/index.m3u8"},{"number":720,"title":"720","url":"https://vip.opstream90.com/20250723/9266_adbe673f/index.m3u8"},{"number":721,"title":"721","url":"https://vip.opstream90.com/20250727/9651_c2138774/index.m3u8"},{"number":722,"title":"722","url":"https://vip.opstream90.com/20250727/9652_33d3b157/index.m3u8"},{"number":723,"title":"723","url":"https://vip.opstream90.com/20250727/9653_b7de9319/index.m3u8"},{"number":724,"title":"724","url":"https://vip.opstream90.com/20250727/9654_51beafc3/index.m3u8"},{"number":725,"title":"725","url":"https://vip.opstream90.com/20250727/9655_fcdbc4f5/index.m3u8"},{"number":726,"title":"726","url":"https://vip.opstream90.com/20250727/9656_ab49b208/index.m3u8"},{"number":727,"title":"727","url":"https://vip.opstream90.com/20250727/9657_c4bf1e24/index.m3u8"},{"number":728,"title":"728","url":"https://vip.opstream90.com/20250727/9658_9547ad6b/index.m3u8"},{"number":729,"title":"729","url":"https://vip.opstream90.com/20250727/9659_969ebecd/index.m3u8"},{"number":730,"title":"730","url":"https://vip.opstream90.com/20250727/9660_24aa17e7/index.m3u8"},{"number":731,"title":"731","url":"https://vip.opstream90.com/20250727/9661_953ecc4b/index.m3u8"},{"number":732,"title":"732","url":"https://vip.opstream90.com/20250727/9662_99f16d38/index.m3u8"},{"number":733,"title":"733","url":"https://vip.opstream90.com/20250727/9663_f03704cb/index.m3u8"},{"number":734,"title":"734","url":"https://vip.opstream90.com/20250727/9664_b7b70189/index.m3u8"},{"number":735,"title":"735","url":"https://vip.opstream90.com/20250727/9665_500ee910/index.m3u8"},{"number":736,"title":"736","url":"https://vip.opstream90.com/20250727/9666_acfe22ee/index.m3u8"},{"number":737,"title":"737","url":"https://vip.opstream90.com/20250728/9672_66705064/index.m3u8"},{"number":738,"title":"738","url":"https://vip.opstream90.com/20250728/9673_41f6e8b5/index.m3u8"},{"number":739,"title":"739","url":"https://vip.opstream90.com/20250728/9674_f8ff8b20/index.m3u8"},{"number":740,"title":"740","url":"https://vip.opstream90.com/20250728/9675_e97a4f04/index.m3u8"},{"number":741,"title":"741","url":"https://vip.opstream90.com/20250728/9676_b8b2926b/index.m3u8"},{"number":742,"title":"742","url":"https://vip.opstream90.com/20250728/9677_8e5d5b79/index.m3u8"},{"number":743,"title":"743","url":"https://vip.opstream90.com/20250728/9678_8f2f470b/index.m3u8"},{"number":744,"title":"744","url":"https://vip.opstream90.com/20250728/9679_6f221fcb/index.m3u8"},{"number":745,"title":"745","url":"https://vip.opstream90.com/20250728/9680_f62f37c4/index.m3u8"},{"number":746,"title":"746","url":"https://vip.opstream90.com/20250728/9682_38d67c3e/index.m3u8"},{"number":747,"title":"747","url":"https://vip.opstream90.com/20250728/9683_eddc3427/index.m3u8"},{"number":748,"title":"748","url":"https://vip.opstream90.com/20250728/9685_274e6fcf/index.m3u8"},{"number":749,"title":"749","url":"https://vip.opstream90.com/20250728/9686_f16ba6f0/index.m3u8"},{"number":750,"title":"750","url":"https://vip.opstream90.com/20250728/9687_219ece62/index.m3u8"},{"number":751,"title":"751","url":"https://vip.opstream90.com/20251005/14145_43f8e3f1/index.m3u8"},{"number":752,"title":"752","url":"https://vip.opstream90.com/20251005/14146_d1efdc26/index.m3u8"},{"number":753,"title":"753","url":"https://vip.opstream90.com/20251005/14147_e5770a47/index.m3u8"},{"number":754,"title":"754","url":"https://vip.opstream90.com/20251006/14148_3d2c3ec9/index.m3u8"},{"number":755,"title":"755","url":"https://vip.opstream90.com/20251006/14149_150353f8/index.m3u8"},{"number":756,"title":"756","url":"https://vip.opstream90.com/20251006/14150_6e52547a/index.m3u8"},{"number":757,"title":"757","url":"https://vip.opstream90.com/20251006/14151_ee2689a4/index.m3u8"},{"number":758,"title":"758","url":"https://vip.opstream90.com/20251006/14152_7ec9ec49/index.m3u8"},{"number":759,"title":"759","url":"https://vip.opstream90.com/20251006/14153_7515989d/index.m3u8"},{"number":760,"title":"760","url":"https://vip.opstream90.com/20251006/14154_c4d2b569/index.m3u8"},{"number":353,"title":"353","url":"https://vip.opstream90.com/20251014/14988_6b339b7d/index.m3u8"},{"number":762,"title":"762","url":"https://vip.opstream90.com/20251014/14989_0b49b88c/index.m3u8"},{"number":763,"title":"763","url":"https://vip.opstream90.com/20251014/14990_1b591643/index.m3u8"},{"number":764,"title":"764","url":"https://vip.opstream90.com/20251014/14991_ddd6bac0/index.m3u8"},{"number":765,"title":"765","url":"https://vip.opstream90.com/20251014/14992_88f64d69/index.m3u8"},{"number":766,"title":"766","url":"https://vip.opstream90.com/20251014/14993_9d83ecaa/index.m3u8"},{"number":767,"title":"767","url":"https://vip.opstream90.com/20251014/14994_11734c64/index.m3u8"},{"number":768,"title":"768","url":"https://vip.opstream90.com/20251014/14995_c8a504a9/index.m3u8"},{"number":769,"title":"769","url":"https://vip.opstream90.com/20251014/14996_cc535d66/index.m3u8"},{"number":770,"title":"770","url":"https://vip.opstream90.com/20251014/14997_ebd74b9b/index.m3u8"},{"number":771,"title":"771","url":"https://vip.opstream90.com/20251014/14998_21c1da87/index.m3u8"},{"number":772,"title":"772","url":"https://vip.opstream90.com/20251014/14999_3493d96f/index.m3u8"},{"number":773,"title":"773","url":"https://vip.opstream90.com/20251014/15000_3f74a886/index.m3u8"},{"number":774,"title":"774","url":"https://vip.opstream90.com/20251021/15707_f0b57183/index.m3u8"},{"number":775,"title":"775","url":"https://vip.opstream90.com/20251029/16377_6e6cd987/index.m3u8"},{"number":776,"title":"776","url":"https://vip.opstream90.com/20251103/16765_c61f0917/index.m3u8"},{"number":777,"title":"777","url":"https://vip.opstream90.com/20251110/17350_8f62fe2a/index.m3u8"},{"number":778,"title":"778","url":"https://vip.opstream90.com/20251125/18427_66980d8d/index.m3u8"},{"number":779,"title":"779","url":"https://vip.opstream90.com/20251201/18959_5d7ee540/index.m3u8"},{"number":780,"title":"780","url":"https://vip.opstream90.com/20251208/19604_cde52d3f/index.m3u8"},{"number":781,"title":"781","url":"https://vip.opstream90.com/20251215/20224_32824c14/index.m3u8"},{"number":782,"title":"782","url":"https://vip.opstream90.com/20251223/20792_0366cd91/index.m3u8"},{"number":783,"title":"783","url":"https://vip.opstream90.com/20251230/21247_df6a6e36/index.m3u8"},{"number":784,"title":"784","url":"https://vip.opstream90.com/20260106/21784_c39fff1a/index.m3u8"},{"number":785,"title":"785","url":"https://vip.opstream90.com/20260113/22273_8b604179/index.m3u8"},{"number":786,"title":"786","url":"https://vip.opstream90.com/20260119/22778_12fb22dc/index.m3u8"},{"number":787,"title":"787","url":"https://vip.opstream90.com/20260126/23166_018e3f08/index.m3u8"},{"number":788,"title":"788","url":"https://vip.opstream90.com/20260203/23738_11bd0ff5/index.m3u8"},{"number":789,"title":"789","url":"https://vip.opstream90.com/20260209/24655_514f065d/index.m3u8"}]} diff --git a/android-tv/settings.gradle.kts b/android-tv/settings.gradle.kts new file mode 100644 index 0000000..1332a8e --- /dev/null +++ b/android-tv/settings.gradle.kts @@ -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") diff --git a/backend/go.mod b/backend/go.mod index 5d7b7cb..6adcb8b 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -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 ) diff --git a/backend/go.sum b/backend/go.sum index e548add..036dc54 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -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= diff --git a/backend/internal/database/database.go b/backend/internal/database/database.go index 4b24555..e89e13c 100644 --- a/backend/internal/database/database.go +++ b/backend/internal/database/database.go @@ -5,7 +5,7 @@ import ( "streamflow-backend/internal/models" - "gorm.io/driver/sqlite" + "github.com/glebarez/sqlite" "gorm.io/gorm" "gorm.io/gorm/logger" ) diff --git a/backend/yt-dlp.exe b/backend/yt-dlp.exe new file mode 100644 index 0000000..8788f61 Binary files /dev/null and b/backend/yt-dlp.exe differ diff --git a/docker-compose.yml b/docker-compose.yml index ed6f499..a5391e2 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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: diff --git a/frontend-react/src/components/Navbar.tsx b/frontend-react/src/components/Navbar.tsx index 15a99ab..fc43504 100644 --- a/frontend-react/src/components/Navbar.tsx +++ b/frontend-react/src/components/Navbar.tsx @@ -54,26 +54,49 @@ const Navbar = () => { -
-
- 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" - /> - - -
+
+
+
+ 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" + /> + + +
-
- + + + + + Install App + + +
+ +
@@ -81,6 +104,28 @@ const Navbar = () => { {isMenuOpen && (
+ {/* Mobile Install App Button */} + setIsMenuOpen(false)} + > + + + + + Download Android TV App + +
{
+ {/* Install App Button (PC/Tablet) */} + + + + + + TV APP + +
{ Home - {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 }) => { ); })} + {/* APK Download in Mobile Nav */} + +
+ + + + +
+ TV APP +
diff --git a/frontend-react/src/themes/netflix/Layout.tsx b/frontend-react/src/themes/netflix/Layout.tsx index 72cb0c5..895d9a2 100644 --- a/frontend-react/src/themes/netflix/Layout.tsx +++ b/frontend-react/src/themes/netflix/Layout.tsx @@ -70,21 +70,64 @@ export const Layout = ({ children }: { children: ReactNode }) => { ))} -
-
+
+ {/* PC/Tablet Sidebar Install Link */} + + + + + + TV APP + + +
© 2026 StreamFlow
{/* Mobile Bottom Nav (Visible only on small screens) */} -
- {NAV_ITEMS.slice(0, 5).map((item) => ( +
+ {NAV_ITEMS.slice(0, 4).map((item) => ( {item.name} ))} + {/* APK Download in Mobile Nav */} + +
+ + + + +
+ TV App +
{/* Main Content Area */}