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