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.
|
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.
|
- **High Performance**: Backend written in Go (Golang) for speed and concurrency.
|
||||||
- **Smart Scraping**: Integrated scraping engine (Rophim) with automated episode extraction.
|
- **Smart Scraping**: Integrated scraping engine (Rophim) with automated episode extraction.
|
||||||
- **HLS Streaming**: Native HLS playback support.
|
- **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.
|
- **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).
|
- **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 com.streamflow.tv.ui.theme.StreamFlowTvTheme
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import coil.Coil
|
||||||
|
import coil.ImageLoader
|
||||||
|
import coil.disk.DiskCache
|
||||||
|
import coil.memory.MemoryCache
|
||||||
|
|
||||||
class MainActivity : ComponentActivity() {
|
class MainActivity : ComponentActivity() {
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
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 {
|
setContent {
|
||||||
StreamFlowTvApp()
|
StreamFlowTvApp()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,9 +9,13 @@ import androidx.compose.runtime.*
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.input.key.*
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.viewinterop.AndroidView
|
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.lifecycle.viewmodel.compose.viewModel
|
||||||
import androidx.media3.common.MediaItem
|
import androidx.media3.common.MediaItem
|
||||||
import androidx.media3.common.util.UnstableApi
|
import androidx.media3.common.util.UnstableApi
|
||||||
|
|
@ -36,6 +40,7 @@ fun PlayerScreen(
|
||||||
val uiState by viewModel.uiState.collectAsState()
|
val uiState by viewModel.uiState.collectAsState()
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val colors = StreamFlowTheme.colors
|
val colors = StreamFlowTheme.colors
|
||||||
|
var playerView by remember { mutableStateOf<PlayerView?>(null) }
|
||||||
|
|
||||||
LaunchedEffect(slug, episode) {
|
LaunchedEffect(slug, episode) {
|
||||||
viewModel.loadPlayer(slug, episode)
|
viewModel.loadPlayer(slug, episode)
|
||||||
|
|
@ -89,11 +94,53 @@ fun PlayerScreen(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val focusRequester = remember { FocusRequester() }
|
||||||
|
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.background(Color.Black)
|
.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) {
|
if (uiState.isLoading || uiState.source == null) {
|
||||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||||
|
|
@ -117,10 +164,14 @@ fun PlayerScreen(
|
||||||
PlayerView(ctx).apply {
|
PlayerView(ctx).apply {
|
||||||
player = exoPlayer
|
player = exoPlayer
|
||||||
useController = true
|
useController = true
|
||||||
|
setShowNextButton(false)
|
||||||
|
setShowPreviousButton(false)
|
||||||
|
controllerAutoShow = true
|
||||||
layoutParams = FrameLayout.LayoutParams(
|
layoutParams = FrameLayout.LayoutParams(
|
||||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||||
ViewGroup.LayoutParams.MATCH_PARENT
|
ViewGroup.LayoutParams.MATCH_PARENT
|
||||||
)
|
)
|
||||||
|
playerView = this
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
modifier = Modifier.fillMaxSize()
|
modifier = Modifier.fillMaxSize()
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,8 @@ import com.streamflow.tv.data.repository.MovieRepository
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.async
|
||||||
|
import kotlinx.coroutines.awaitAll
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
data class HomeUiState(
|
data class HomeUiState(
|
||||||
|
|
@ -66,55 +68,68 @@ class HomeViewModel : ViewModel() {
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
// Load all categories for home
|
// Load all categories for home
|
||||||
val allMovies = mutableMapOf<String, List<Movie>>()
|
val allMovies = java.util.Collections.synchronizedMap(mutableMapOf<String, List<Movie>>())
|
||||||
var heroItems = listOf<Movie>()
|
val allFlattened = java.util.Collections.synchronizedList(mutableListOf<Movie>())
|
||||||
val allFlattened = mutableListOf<Movie>()
|
|
||||||
|
|
||||||
// 1. Initial categories
|
kotlinx.coroutines.coroutineScope {
|
||||||
categories.forEach { (slug, name) ->
|
// 1. Initial categories
|
||||||
try {
|
val categoryTasks = categories.map { (slug, name) ->
|
||||||
val response = repository.getHomeVideos(slug)
|
async {
|
||||||
allMovies[name] = response.items
|
try {
|
||||||
allFlattened.addAll(response.items)
|
val response = repository.getHomeVideos(slug)
|
||||||
if (heroItems.isEmpty()) {
|
allMovies[name] = response.items
|
||||||
heroItems = response.items.take(5)
|
allFlattened.addAll(response.items)
|
||||||
|
response.items
|
||||||
|
} catch (_: Exception) { emptyList<Movie>() }
|
||||||
}
|
}
|
||||||
} 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
|
val heroItems = allMovies[categories.first().second]?.take(5) ?: emptyList()
|
||||||
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(
|
_uiState.value = _uiState.value.copy(
|
||||||
heroMovies = heroItems,
|
heroMovies = heroItems,
|
||||||
watchedMovies = history,
|
watchedMovies = history,
|
||||||
recommendedMovies = allFlattened.filter { m -> history.none { it.slug == m.slug } }.distinctBy { it.slug }.shuffled().take(15),
|
recommendedMovies = allFlattened.filter { m -> history.none { it.slug == m.slug } }
|
||||||
categoryMovies = allMovies,
|
.distinctBy { it.slug }.shuffled().take(15),
|
||||||
|
categoryMovies = allMovies.toMap(),
|
||||||
isLoading = false
|
isLoading = false
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,14 +3,14 @@ version: '3.8'
|
||||||
services:
|
services:
|
||||||
streamflow:
|
streamflow:
|
||||||
# build: .
|
# build: .
|
||||||
image: git.khoavo.myds.me/vndangkhoa/kv-streamflow:v3
|
image: git.khoavo.myds.me/vndangkhoa/kv-streamflow:v3.1
|
||||||
container_name: streamflow
|
container_name: streamflow
|
||||||
platform: linux/amd64
|
platform: linux/amd64
|
||||||
ports:
|
ports:
|
||||||
- "3478:8000"
|
- "3478:8000"
|
||||||
environment:
|
environment:
|
||||||
- DATABASE_URL=/app/data/streamflow.db
|
- DATABASE_URL=/app/data/streamflow.db
|
||||||
- TMDB_API_KEY=${TMDB_API_KEY}
|
# - TMDB_API_KEY=${TMDB_API_KEY}
|
||||||
volumes:
|
volumes:
|
||||||
- ./data:/app/data
|
- ./data:/app/data
|
||||||
restart: always
|
restart: always
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue