V3.1 Release: Optimized Android TV App (Parallel Loading, D-pad Controls, Image Caching)
This commit is contained in:
parent
05b320e823
commit
59290ca7f6
5 changed files with 133 additions and 44 deletions
|
|
@ -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).
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue