update tv ux, fix configuration-cache issues

This commit is contained in:
2026-07-21 14:38:02 +03:00
parent a264db284d
commit a062427d0d
11 changed files with 50 additions and 79 deletions
+2 -2
View File
@@ -12,8 +12,8 @@ android {
applicationId = namespace applicationId = namespace
minSdk = libs.versions.proj.min.get().toInt() minSdk = libs.versions.proj.min.get().toInt()
targetSdk = libs.versions.proj.target.get().toInt() targetSdk = libs.versions.proj.target.get().toInt()
versionCode = 39 versionCode = 40
versionName = "1.21" versionName = "1.22"
} }
} }
@@ -44,7 +44,7 @@ class SyncClientServerRecentlyWatchedRepository(
private fun createSync(): RemoteSourceSynchronizer<RecentlyWatchedInfo, String> { private fun createSync(): RemoteSourceSynchronizer<RecentlyWatchedInfo, String> {
return RemoteSourceSynchronizer( return RemoteSourceSynchronizer(
isSync = { syncRepository.isSynchronized(AggregateType.RECENTLY_WATCHED) }, isSync = { syncRepository.isSynchronized(AggregateType.RECENTLY_WATCHED) },
setSync = { syncRepository.set(AggregateType.RECENTLY_WATCHED, true) }, setSync = { syncRepository.set(AggregateType.RECENTLY_WATCHED, it) },
authTokenCache = authTokenCache, authTokenCache = authTokenCache,
queryRemote = { remote.getAllRecentlyWatched() }, queryRemote = { remote.getAllRecentlyWatched() },
queryLocal = { local.getAllRecentlyWatched() }, queryLocal = { local.getAllRecentlyWatched() },
@@ -8,7 +8,6 @@ import kotlinx.serialization.json.encodeToJsonElement
import kotlinx.serialization.json.put import kotlinx.serialization.json.put
import org.koin.core.annotation.Factory import org.koin.core.annotation.Factory
import org.koin.core.annotation.Named import org.koin.core.annotation.Named
import ru.shadowsparky.domain.DispatcherProvider
import ru.shadowsparky.http.domain.TokenStorage import ru.shadowsparky.http.domain.TokenStorage
import ru.shadowsparky.http.domain.hasAccount import ru.shadowsparky.http.domain.hasAccount
import ru.shadowsparky.vbox.shared.data.RemoteSourceSynchronizer import ru.shadowsparky.vbox.shared.data.RemoteSourceSynchronizer
@@ -30,8 +29,7 @@ class SyncClientSavedMovieRepository(
private val syncRepository: SyncRepository, private val syncRepository: SyncRepository,
private val authTokenCache: TokenStorage, private val authTokenCache: TokenStorage,
private val json: Json, private val json: Json,
private val pendingOperationSynchronizer: PendingOperationSynchronizer, private val pendingOperationSynchronizer: PendingOperationSynchronizer
private val dispatcherProvider: DispatcherProvider
) : SavedMovieRepository { ) : SavedMovieRepository {
override suspend fun save(details: VideoDetails) { override suspend fun save(details: VideoDetails) {
@@ -81,7 +79,7 @@ class SyncClientSavedMovieRepository(
private fun createSync(): RemoteSourceSynchronizer<VideoDetails, Long> { private fun createSync(): RemoteSourceSynchronizer<VideoDetails, Long> {
return RemoteSourceSynchronizer( return RemoteSourceSynchronizer(
isSync = { syncRepository.isSynchronized(AggregateType.SAVED_MOVIE) }, isSync = { syncRepository.isSynchronized(AggregateType.SAVED_MOVIE) },
setSync = { syncRepository.set(AggregateType.SAVED_MOVIE, true) }, setSync = { syncRepository.set(AggregateType.SAVED_MOVIE, it) },
authTokenCache = authTokenCache, authTokenCache = authTokenCache,
queryRemote = { remote.getAll().first() }, queryRemote = { remote.getAll().first() },
queryLocal = { local.getAll().first() }, queryLocal = { local.getAll().first() },
@@ -42,7 +42,7 @@ class SyncClientServerSearchRepository(
private fun createSync(): RemoteSourceSynchronizer<String, String> { private fun createSync(): RemoteSourceSynchronizer<String, String> {
return RemoteSourceSynchronizer( return RemoteSourceSynchronizer(
isSync = { syncRepository.isSynchronized(AggregateType.SEARCH) }, isSync = { syncRepository.isSynchronized(AggregateType.SEARCH) },
setSync = { syncRepository.set(AggregateType.SEARCH, true) }, setSync = { syncRepository.set(AggregateType.SEARCH, it) },
authTokenCache = authTokenCache, authTokenCache = authTokenCache,
queryRemote = { remote.search("").first() }, queryRemote = { remote.search("").first() },
queryLocal = { local.search("").first() }, queryLocal = { local.search("").first() },
@@ -43,7 +43,7 @@ class SyncMovieTagRepository(
private fun createSync(): RemoteSourceSynchronizer<MovieTagLink, String> { private fun createSync(): RemoteSourceSynchronizer<MovieTagLink, String> {
return RemoteSourceSynchronizer( return RemoteSourceSynchronizer(
isSync = { syncRepository.isSynchronized(AggregateType.MOVIE_TAG) }, isSync = { syncRepository.isSynchronized(AggregateType.MOVIE_TAG) },
setSync = { syncRepository.set(AggregateType.MOVIE_TAG, true) }, setSync = { syncRepository.set(AggregateType.MOVIE_TAG, it) },
authTokenCache = tokenStorage, authTokenCache = tokenStorage,
queryRemote = { queryRemote = {
val allTags = userTagRemote.userTags.first() val allTags = userTagRemote.userTags.first()
@@ -40,7 +40,7 @@ class SyncUserTagRepository(
private fun createSync(): RemoteSourceSynchronizer<TagInfo, String> { private fun createSync(): RemoteSourceSynchronizer<TagInfo, String> {
return RemoteSourceSynchronizer( return RemoteSourceSynchronizer(
isSync = { syncRepository.isSynchronized(AggregateType.USER_TAG) }, isSync = { syncRepository.isSynchronized(AggregateType.USER_TAG) },
setSync = { syncRepository.set(AggregateType.USER_TAG, true) }, setSync = { syncRepository.set(AggregateType.USER_TAG, it) },
authTokenCache = tokenStorage, authTokenCache = tokenStorage,
queryRemote = { remote.userTags.first() }, queryRemote = { remote.userTags.first() },
queryLocal = { local.userTags.first() }, queryLocal = { local.userTags.first() },
@@ -10,17 +10,6 @@ import ru.shadowsparky.http.domain.TokenStorage
import ru.shadowsparky.http.domain.hasAccount import ru.shadowsparky.http.domain.hasAccount
import ru.shadowsparky.koin import ru.shadowsparky.koin
suspend fun TokenStorage.use(
active: suspend () -> Unit,
fallback: suspend () -> Unit
) {
if (hasAccount()) {
runCatching { active() }.onFailure { fallback() }
} else {
fallback()
}
}
fun <T> TokenStorage.queryFlow( fun <T> TokenStorage.queryFlow(
active: Flow<T>, active: Flow<T>,
fallback: Flow<T>, fallback: Flow<T>,
@@ -7,26 +7,32 @@ import org.gradle.kotlin.dsl.configure
import ru.shadowsparky.applyCoroutines import ru.shadowsparky.applyCoroutines
import ru.shadowsparky.findLibs import ru.shadowsparky.findLibs
import ru.shadowsparky.setupAndroid import ru.shadowsparky.setupAndroid
import java.io.File
import java.io.FileInputStream
import java.util.Properties import java.util.Properties
class AndroidApplication : Plugin<Project> { class AndroidApplication : Plugin<Project> {
override fun apply(target: Project) = with(target) { override fun apply(target: Project) = with(target) {
val libs = findLibs() val libs = findLibs()
pluginManager.apply(libs.findPlugin("android.application").get().get().pluginId) pluginManager.apply(libs.findPlugin("android.application").get().get().pluginId)
val keystoreFileProvider = rootProject.layout.projectDirectory.file("keystore.properties")
val keystorePropertiesProvider =
providers.fileContents(keystoreFileProvider).asText.map { content ->
Properties().apply { content.reader().use { load(it) } }
}
extensions.configure<ApplicationExtension> { extensions.configure<ApplicationExtension> {
val keystorePropertiesFile = File(rootDir, "keystore.properties")
signingConfigs { signingConfigs {
if (keystorePropertiesFile.exists()) { register("release") {
val keystoreProperties = Properties() val propertiesFile = keystoreFileProvider.asFile
keystoreProperties.load(FileInputStream(keystorePropertiesFile))
create("release") { if (propertiesFile.exists()) {
storeFile = File(rootDir, "release.keystore") val properties = keystorePropertiesProvider.get()
check(storeFile?.exists() == true) { "Unable to load keystore file!" }
storePassword = keystoreProperties["RELEASE_STORE_PASSWORD"].toString() val keystoreFile = rootProject.layout.projectDirectory.file("release.keystore").asFile
keyAlias = keystoreProperties["RELEASE_KEY_ALIAS"].toString() check(keystoreFile.exists()) { "Unable to load keystore file!" }
keyPassword = keystoreProperties["RELEASE_KEY_PASSWORD"].toString()
storeFile = keystoreFile
storePassword = properties.getProperty("RELEASE_STORE_PASSWORD")
keyAlias = properties.getProperty("RELEASE_KEY_ALIAS")
keyPassword = properties.getProperty("RELEASE_KEY_PASSWORD")
} }
} }
} }
@@ -38,15 +44,15 @@ class AndroidApplication : Plugin<Project> {
getByName("release") { getByName("release") {
isMinifyEnabled = true isMinifyEnabled = true
isShrinkResources = true isShrinkResources = true
if (keystorePropertiesFile.exists()) { if (signingConfigs.findByName("release")?.storeFile?.exists() == true) {
signingConfig = signingConfigs.getByName("release") signingConfig = signingConfigs.getByName("release")
} }
proguardFiles(file("proguard-rules.pro")) proguardFiles(layout.projectDirectory.file("proguard-rules.pro"))
} }
getByName("debug") { getByName("debug") {
isMinifyEnabled = false isMinifyEnabled = false
applicationIdSuffix = ".debug" applicationIdSuffix = ".debug"
if (keystorePropertiesFile.exists()) { if (signingConfigs.findByName("release")?.storeFile?.exists() == true) {
signingConfig = signingConfigs.getByName("release") signingConfig = signingConfigs.getByName("release")
} }
} }
+1 -1
View File
@@ -2,12 +2,12 @@ kotlin.code.style=official
android.useAndroidX=true android.useAndroidX=true
org.gradle.jvmargs=-Xms1g -Xmx3584m -XX:+UseParallelGC -Dfile.encoding=UTF-8 org.gradle.jvmargs=-Xms1g -Xmx3584m -XX:+UseParallelGC -Dfile.encoding=UTF-8
org.gradle.daemon.idletimeout=60000
org.jetbrains.compose.experimental.jscanvas.enabled=true org.jetbrains.compose.experimental.jscanvas.enabled=true
org.jetbrains.compose.experimental.uikit.enabled=true org.jetbrains.compose.experimental.uikit.enabled=true
android.nonTransitiveRClass=false android.nonTransitiveRClass=false
android.r8.strictFullModeForKeepRules=true android.r8.strictFullModeForKeepRules=true
android.injected.enableStableIds=true
org.gradle.caching=true org.gradle.caching=true
org.gradle.parallel=true org.gradle.parallel=true
@@ -30,6 +30,7 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.jetbrains.compose.resources.getString import org.jetbrains.compose.resources.getString
import org.koin.core.annotation.Factory import org.koin.core.annotation.Factory
import kotlin.time.Duration.Companion.milliseconds
@Factory @Factory
class AndroidPlayerController(context: Context) : PlayerController { class AndroidPlayerController(context: Context) : PlayerController {
@@ -114,9 +115,7 @@ class AndroidPlayerController(context: Context) : PlayerController {
) )
} }
}.flatten() }.flatten()
audioTracks.value = list audioTracks.value = list
if (!isTrackInitialized && list.isNotEmpty()) { if (!isTrackInitialized && list.isNotEmpty()) {
isTrackInitialized = true isTrackInitialized = true
list.firstOrNull()?.let { selectAudioTrack(it.id) } list.firstOrNull()?.let { selectAudioTrack(it.id) }
@@ -151,6 +150,7 @@ class AndroidPlayerController(context: Context) : PlayerController {
override suspend fun load(url: String, startPositionMs: Long): Unit = override suspend fun load(url: String, startPositionMs: Long): Unit =
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
isTrackInitialized = false
bindListenerIfNeeded(true) bindListenerIfNeeded(true)
val mediaItem = MediaItem.fromUri(url) val mediaItem = MediaItem.fromUri(url)
exoPlayer.setMediaItem(mediaItem) exoPlayer.setMediaItem(mediaItem)
@@ -199,7 +199,7 @@ class AndroidPlayerController(context: Context) : PlayerController {
progressJob = scope.launch { progressJob = scope.launch {
while (isActive) { while (isActive) {
updateState() updateState()
delay(200) delay(200.milliseconds)
} }
} }
} }
@@ -59,6 +59,7 @@ import androidx.compose.ui.unit.dp
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.painterResource import org.jetbrains.compose.resources.painterResource
import ru.shadowsparky.ui.components.isTv
import ru.shadowsparky.videoplayer.AudioTrackInfo import ru.shadowsparky.videoplayer.AudioTrackInfo
import ru.shadowsparky.videoplayer.ControlsState import ru.shadowsparky.videoplayer.ControlsState
import ru.shadowsparky.videoplayer.PlayerController import ru.shadowsparky.videoplayer.PlayerController
@@ -74,6 +75,7 @@ fun DefaultControls(
controller: PlayerController, controller: PlayerController,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
val isTv = isTv()
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val audioTracksState by controller.audioTracks.collectAsState() val audioTracksState by controller.audioTracks.collectAsState()
@@ -102,8 +104,8 @@ fun DefaultControls(
} }
) )
} }
LockUiButton(focus, isUiLocked.value, audioTracksState.isNotEmpty()) { if (!isTv) {
isUiLocked.value = it LockUiButton(focus, isUiLocked.value) { isUiLocked.value = it }
} }
} }
if (!isUiLocked.value) { if (!isUiLocked.value) {
@@ -154,8 +156,8 @@ fun DefaultControls(
Key.DirectionRight -> { Key.DirectionRight -> {
scope.launch { controller.seek(false) } scope.launch { controller.seek(false) }
} }
Key.DirectionUp -> focus.next.requestFocus() Key.DirectionUp -> focus.play.requestFocus()
Key.DirectionDown -> focus.lock.requestFocus() Key.DirectionDown -> focus.audioTrack.requestFocus()
else -> return@onPreviewKeyEvent false else -> return@onPreviewKeyEvent false
} }
} }
@@ -171,7 +173,6 @@ fun DefaultControls(
private fun LockUiButton( private fun LockUiButton(
focusContainer: FocusContainer, focusContainer: FocusContainer,
state: Boolean, state: Boolean,
hasAudioTrack: Boolean,
onChanged: (Boolean) -> Unit onChanged: (Boolean) -> Unit
) { ) {
IconButton( IconButton(
@@ -215,7 +216,7 @@ private fun AudioTracksButton(
onDismissRequest = { expanded = false }, onDismissRequest = { expanded = false },
modifier = Modifier.focusable() modifier = Modifier.focusable()
) { ) {
for (i in 0 until tracks.size) { for (i in tracks.indices) {
val (track, requester) = tracks[i] val (track, requester) = tracks[i]
DropdownMenuItem( DropdownMenuItem(
text = { text = {
@@ -280,6 +281,7 @@ private fun BoxScope.CenterControls(
controller: PlayerController controller: PlayerController
) { ) {
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
var canRequestFocus by remember { mutableStateOf(false) }
Box( Box(
modifier = Modifier.align(Alignment.Center) modifier = Modifier.align(Alignment.Center)
.focusGroup() .focusGroup()
@@ -287,9 +289,7 @@ private fun BoxScope.CenterControls(
.padding(horizontal = 16.dp, vertical = 8.dp) .padding(horizontal = 16.dp, vertical = 8.dp)
) { ) {
when { when {
state.isBuffering -> { state.isBuffering -> CircularWavyProgressIndicator()
CircularWavyProgressIndicator()
}
state.isEnded -> { state.isEnded -> {
IconButton( IconButton(
onClick = { onClick = {
@@ -297,7 +297,7 @@ private fun BoxScope.CenterControls(
controller.seekTo(0) controller.seekTo(0)
controller.play() controller.play()
} }
}, modifier = Modifier.focusableWithBorder(focusContainer.next) }
) { ) {
Icon( Icon(
painter = painterResource(Res.drawable.replay_24px), painter = painterResource(Res.drawable.replay_24px),
@@ -312,18 +312,6 @@ private fun BoxScope.CenterControls(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(16.dp) horizontalArrangement = Arrangement.spacedBy(16.dp)
) { ) {
IconButton(
onClick = {
scope.launch { controller.seek(true) }
}, modifier = Modifier.focusableWithBorder(focusContainer.prev)
) {
Text(
text = "-$DEF_SEEK_TIME",
color = Color.White,
style = MaterialTheme.typography.bodyMedium
)
}
IconButton( IconButton(
onClick = { onClick = {
scope.launch { scope.launch {
@@ -343,26 +331,18 @@ private fun BoxScope.CenterControls(
tint = Color.White tint = Color.White
) )
} }
}
IconButton( canRequestFocus = true
onClick = {
scope.launch { controller.seek(false) }
}, modifier = Modifier.focusableWithBorder(focusContainer.next)
) {
Text(
text = "+$DEF_SEEK_TIME",
color = Color.White,
style = MaterialTheme.typography.bodyMedium
)
} }
} }
} }
} LaunchedEffect(state.videoUrl) { canRequestFocus = false }
} LaunchedEffect(canRequestFocus) {
LaunchedEffect(null) { if (canRequestFocus) {
withFrameNanos {} withFrameNanos {}
focusContainer.play.requestFocus() focusContainer.play.requestFocus()
} }
}
} }
private suspend fun PlayerController.seek(minus: Boolean) { private suspend fun PlayerController.seek(minus: Boolean) {
@@ -399,9 +379,7 @@ private fun formatDuration(millis: Long): String {
private class FocusContainer( private class FocusContainer(
val audioTrack: FocusRequester = FocusRequester(), val audioTrack: FocusRequester = FocusRequester(),
val lock: FocusRequester = FocusRequester(), val lock: FocusRequester = FocusRequester(),
val prev: FocusRequester = FocusRequester(),
val play: FocusRequester = FocusRequester(), val play: FocusRequester = FocusRequester(),
val next: FocusRequester = FocusRequester(),
val slider: FocusRequester = FocusRequester() val slider: FocusRequester = FocusRequester()
) )