From 59290ca7f6619eb59edef94c6fd59b8d82b36b6d Mon Sep 17 00:00:00 2001 From: vndangkhoa Date: Sun, 15 Feb 2026 18:37:12 +0700 Subject: [PATCH] V3.1 Release: Optimized Android TV App (Parallel Loading, D-pad Controls, Image Caching) --- README.md | 4 +- .../java/com/streamflow/tv/MainActivity.kt | 21 ++++ .../streamflow/tv/ui/screens/PlayerScreen.kt | 51 ++++++++++ .../streamflow/tv/viewmodel/HomeViewModel.kt | 97 +++++++++++-------- docker-compose.yml | 4 +- 5 files changed, 133 insertions(+), 44 deletions(-) diff --git a/README.md b/README.md index 6878996..f0fdaf3 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# StreamFlow V3 +# StreamFlow V3.1 StreamFlow is a high-performance video streaming web application featuring a pure Go backend and a modern React + Tailwind frontend. @@ -8,6 +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. +- **Android TV Support (New)**: Optimized TV client with D-pad controls and 10s skip. +- **Performance Optimized**: Parallel API fetching and global image caching for instant loading. - **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). diff --git a/android-tv/app/src/main/java/com/streamflow/tv/MainActivity.kt b/android-tv/app/src/main/java/com/streamflow/tv/MainActivity.kt index 300f640..cc90c9d 100644 --- a/android-tv/app/src/main/java/com/streamflow/tv/MainActivity.kt +++ b/android-tv/app/src/main/java/com/streamflow/tv/MainActivity.kt @@ -22,10 +22,31 @@ import com.streamflow.tv.ui.theme.StreamFlowTheme import com.streamflow.tv.ui.theme.StreamFlowTvTheme import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch +import coil.Coil +import coil.ImageLoader +import coil.disk.DiskCache +import coil.memory.MemoryCache class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + + // Setup Coil with caching + val imageLoader = ImageLoader.Builder(this) + .memoryCache { + MemoryCache.Builder(this) + .maxSizePercent(0.25) + .build() + } + .diskCache { + DiskCache.Builder() + .directory(this.cacheDir.resolve("image_cache")) + .maxSizePercent(0.02) + .build() + } + .build() + Coil.setImageLoader(imageLoader) + setContent { StreamFlowTvApp() } diff --git a/android-tv/app/src/main/java/com/streamflow/tv/ui/screens/PlayerScreen.kt b/android-tv/app/src/main/java/com/streamflow/tv/ui/screens/PlayerScreen.kt index fecc120..e7b6cab 100644 --- a/android-tv/app/src/main/java/com/streamflow/tv/ui/screens/PlayerScreen.kt +++ b/android-tv/app/src/main/java/com/streamflow/tv/ui/screens/PlayerScreen.kt @@ -9,9 +9,13 @@ import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.key.* import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp import androidx.compose.ui.viewinterop.AndroidView +import androidx.compose.foundation.focusable +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester import androidx.lifecycle.viewmodel.compose.viewModel import androidx.media3.common.MediaItem import androidx.media3.common.util.UnstableApi @@ -36,6 +40,7 @@ fun PlayerScreen( val uiState by viewModel.uiState.collectAsState() val context = LocalContext.current val colors = StreamFlowTheme.colors + var playerView by remember { mutableStateOf(null) } LaunchedEffect(slug, episode) { viewModel.loadPlayer(slug, episode) @@ -89,11 +94,53 @@ fun PlayerScreen( } } + val focusRequester = remember { FocusRequester() } + Box( modifier = Modifier .fillMaxSize() .background(Color.Black) + .focusRequester(focusRequester) + .focusable() + .onPreviewKeyEvent { keyEvent -> + if (keyEvent.type == KeyEventType.KeyDown) { + when (keyEvent.nativeKeyEvent.keyCode) { + android.view.KeyEvent.KEYCODE_DPAD_CENTER, + android.view.KeyEvent.KEYCODE_ENTER -> { + // Toggle controls visibility + if (playerView?.isControllerFullyVisible == true) { + playerView?.hideController() + } else { + playerView?.showController() + } + true + } + android.view.KeyEvent.KEYCODE_DPAD_LEFT -> { + // Seek backward 10s + playerView?.showController() + exoPlayer.seekTo(maxOf(0, exoPlayer.currentPosition - 10000)) + true + } + android.view.KeyEvent.KEYCODE_DPAD_RIGHT -> { + // Seek forward 10s + playerView?.showController() + exoPlayer.seekTo(minOf(exoPlayer.duration, exoPlayer.currentPosition + 10000)) + true + } + android.view.KeyEvent.KEYCODE_DPAD_UP, + android.view.KeyEvent.KEYCODE_DPAD_DOWN -> { + playerView?.showController() + true + } + else -> false + } + } else false + } ) { + LaunchedEffect(Unit) { + focusRequester.requestFocus() + } + if (uiState.isLoading || uiState.source == null) { Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { Column(horizontalAlignment = Alignment.CenterHorizontally) { @@ -117,10 +164,14 @@ fun PlayerScreen( PlayerView(ctx).apply { player = exoPlayer useController = true + setShowNextButton(false) + setShowPreviousButton(false) + controllerAutoShow = true layoutParams = FrameLayout.LayoutParams( ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT ) + playerView = this } }, modifier = Modifier.fillMaxSize() diff --git a/android-tv/app/src/main/java/com/streamflow/tv/viewmodel/HomeViewModel.kt b/android-tv/app/src/main/java/com/streamflow/tv/viewmodel/HomeViewModel.kt index d30e80e..46e36cf 100644 --- a/android-tv/app/src/main/java/com/streamflow/tv/viewmodel/HomeViewModel.kt +++ b/android-tv/app/src/main/java/com/streamflow/tv/viewmodel/HomeViewModel.kt @@ -7,6 +7,8 @@ import com.streamflow.tv.data.repository.MovieRepository import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.first +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll import kotlinx.coroutines.launch data class HomeUiState( @@ -66,55 +68,68 @@ class HomeViewModel : ViewModel() { ) } else { // Load all categories for home - val allMovies = mutableMapOf>() - var heroItems = listOf() - val allFlattened = mutableListOf() + val allMovies = java.util.Collections.synchronizedMap(mutableMapOf>()) + val allFlattened = java.util.Collections.synchronizedList(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) + kotlinx.coroutines.coroutineScope { + // 1. Initial categories + val categoryTasks = categories.map { (slug, name) -> + async { + try { + val response = repository.getHomeVideos(slug) + allMovies[name] = response.items + allFlattened.addAll(response.items) + response.items + } catch (_: Exception) { emptyList() } } - } catch (_: Exception) { } + } + + // 2. Fetch Genres & Countries metadata in parallel + val genresDeferred = async { try { repository.getGenres().take(8) } catch (_: Exception) { emptyList() } } + val countriesDeferred = async { try { repository.getCountries().take(5) } catch (_: Exception) { emptyList() } } + + val genres = genresDeferred.await() + val countries = countriesDeferred.await() + + // 3. Fetch Genre and Country content in parallel + val genreTasks = genres.map { genre -> + async { + try { + val response = repository.getHomeVideos(genre.slug) + if (response.items.isNotEmpty()) { + allMovies["Genre: ${genre.name}"] = response.items + allFlattened.addAll(response.items) + } + } catch (_: Exception) { } + } + } + + val countryTasks = countries.map { country -> + async { + try { + val response = repository.getHomeVideos(country.slug) + if (response.items.isNotEmpty()) { + allMovies["Country: ${country.name}"] = response.items + allFlattened.addAll(response.items) + } + } catch (_: Exception) { } + } + } + + // Wait for everything + categoryTasks.awaitAll() + genreTasks.awaitAll() + countryTasks.awaitAll() } - // 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) { } + val heroItems = allMovies[categories.first().second]?.take(5) ?: emptyList() _uiState.value = _uiState.value.copy( heroMovies = heroItems, watchedMovies = history, - recommendedMovies = allFlattened.filter { m -> history.none { it.slug == m.slug } }.distinctBy { it.slug }.shuffled().take(15), - categoryMovies = allMovies, + recommendedMovies = allFlattened.filter { m -> history.none { it.slug == m.slug } } + .distinctBy { it.slug }.shuffled().take(15), + categoryMovies = allMovies.toMap(), isLoading = false ) } diff --git a/docker-compose.yml b/docker-compose.yml index a5391e2..ba59892 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -3,14 +3,14 @@ version: '3.8' services: streamflow: # build: . - image: git.khoavo.myds.me/vndangkhoa/kv-streamflow:v3 + image: git.khoavo.myds.me/vndangkhoa/kv-streamflow:v3.1 container_name: streamflow platform: linux/amd64 ports: - "3478:8000" environment: - DATABASE_URL=/app/data/streamflow.db - - TMDB_API_KEY=${TMDB_API_KEY} + # - TMDB_API_KEY=${TMDB_API_KEY} volumes: - ./data:/app/data restart: always