V3.1 Release: Optimized Android TV App (Parallel Loading, D-pad Controls, Image Caching)

This commit is contained in:
vndangkhoa 2026-02-15 18:37:12 +07:00
parent 05b320e823
commit 59290ca7f6
5 changed files with 133 additions and 44 deletions

View file

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

View file

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

View file

@ -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<PlayerView?>(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()

View file

@ -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,26 +68,32 @@ class HomeViewModel : ViewModel() {
)
} else {
// Load all categories for home
val allMovies = mutableMapOf<String, List<Movie>>()
var heroItems = listOf<Movie>()
val allFlattened = mutableListOf<Movie>()
val allMovies = java.util.Collections.synchronizedMap(mutableMapOf<String, List<Movie>>())
val allFlattened = java.util.Collections.synchronizedList(mutableListOf<Movie>())
kotlinx.coroutines.coroutineScope {
// 1. Initial categories
categories.forEach { (slug, name) ->
val categoryTasks = categories.map { (slug, name) ->
async {
try {
val response = repository.getHomeVideos(slug)
allMovies[name] = response.items
allFlattened.addAll(response.items)
if (heroItems.isEmpty()) {
heroItems = response.items.take(5)
response.items
} catch (_: Exception) { emptyList<Movie>() }
}
} catch (_: Exception) { }
}
// 2. Fetch Genres
try {
val genres = repository.getGenres()
genres.take(8).forEach { genre ->
// 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()) {
@ -94,12 +102,10 @@ class HomeViewModel : ViewModel() {
}
} catch (_: Exception) { }
}
} catch (_: Exception) { }
}
// 3. Fetch Countries
try {
val countries = repository.getCountries()
countries.take(5).forEach { country ->
val countryTasks = countries.map { country ->
async {
try {
val response = repository.getHomeVideos(country.slug)
if (response.items.isNotEmpty()) {
@ -108,13 +114,22 @@ class HomeViewModel : ViewModel() {
}
} catch (_: Exception) { }
}
} catch (_: Exception) { }
}
// Wait for everything
categoryTasks.awaitAll()
genreTasks.awaitAll()
countryTasks.awaitAll()
}
val heroItems = allMovies[categories.first().second]?.take(5) ?: emptyList()
_uiState.value = _uiState.value.copy(
heroMovies = heroItems,
watchedMovies = history,
recommendedMovies = allFlattened.filter { m -> history.none { it.slug == m.slug } }.distinctBy { it.slug }.shuffled().take(15),
categoryMovies = allMovies,
recommendedMovies = allFlattened.filter { m -> history.none { it.slug == m.slug } }
.distinctBy { it.slug }.shuffled().take(15),
categoryMovies = allMovies.toMap(),
isLoading = false
)
}

View file

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