diff --git a/app/src/main/kotlin/com/arflix/tv/data/telegram/TelegramRepository.kt b/app/src/main/kotlin/com/arflix/tv/data/telegram/TelegramRepository.kt index 0ab15435..c7b12314 100644 --- a/app/src/main/kotlin/com/arflix/tv/data/telegram/TelegramRepository.kt +++ b/app/src/main/kotlin/com/arflix/tv/data/telegram/TelegramRepository.kt @@ -81,6 +81,15 @@ class TelegramRepository @Inject constructor( wipeTdlibFiles(context) } + fun getCacheSize(): Long { + val dir = File(context.filesDir, "tdlib_files") + return if (dir.exists()) dir.walkBottomUp().filter { it.isFile }.sumOf { it.length() } else 0L + } + + fun clearCache() { + File(context.filesDir, "tdlib_files").listFiles()?.forEach { it.deleteRecursively() } + } + suspend fun getChats(limit: Int = 200): List { val result = client.sendRequest(TdApi.GetChats().also { it.limit = limit }) val chats = (result as? TdApi.Chats) ?: return emptyList() diff --git a/app/src/main/kotlin/com/arflix/tv/data/telegram/TelegramStreamingProxy.kt b/app/src/main/kotlin/com/arflix/tv/data/telegram/TelegramStreamingProxy.kt index 9aa7d69f..e2f4af1a 100644 --- a/app/src/main/kotlin/com/arflix/tv/data/telegram/TelegramStreamingProxy.kt +++ b/app/src/main/kotlin/com/arflix/tv/data/telegram/TelegramStreamingProxy.kt @@ -49,6 +49,7 @@ class TelegramStreamingProxy @Inject constructor( private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) private var port: Int = 0 private var server: io.ktor.server.engine.ApplicationEngine? = null + @Volatile private var lastStreamedFileId: Int? = null fun start() { if (server != null) return @@ -63,6 +64,14 @@ class TelegramStreamingProxy @Inject constructor( return@get } + // When a new file starts streaming, clean up the previous one so + // TDLib's downloaded chunks don't accumulate in tdlib_files/ indefinitely. + val prev = lastStreamedFileId + if (prev != null && prev != fileId) { + scope.launch { deleteFile(prev) } + } + lastStreamedFileId = fileId + val rangeHeader = call.request.headers[HttpHeaders.Range] val (rangeStart, rangeEnd) = parseRange(rangeHeader) @@ -111,11 +120,26 @@ class TelegramStreamingProxy @Inject constructor( } fun stop() { + lastStreamedFileId?.let { scope.launch { deleteFile(it) } } + lastStreamedFileId = null server?.stop(0, 0) server = null Log.d(TAG, "Streaming proxy stopped") } + private suspend fun deleteFile(fileId: Int) { + runCatching { + client.sendRequest(TdApi.CancelDownloadFile().also { req -> + req.fileId = fileId + req.onlyIfPending = false + }) + } + runCatching { + client.sendRequest(TdApi.DeleteFile().also { it.fileId = fileId }) + Log.d(TAG, "Deleted cached file $fileId") + } + } + fun getUrl(fileId: Int): String { val url = "http://localhost:$port/file/$fileId" Log.d(TAG, "Generated stream URL: $url") diff --git a/app/src/main/kotlin/com/arflix/tv/ui/components/PersonModal.kt b/app/src/main/kotlin/com/arflix/tv/ui/components/PersonModal.kt index 46c09855..185df2db 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/components/PersonModal.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/components/PersonModal.kt @@ -53,6 +53,8 @@ import androidx.compose.ui.input.key.key import androidx.compose.ui.input.key.onKeyEvent import androidx.compose.ui.input.key.type import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow @@ -107,6 +109,7 @@ fun PersonModal( var focusedKnownForIndex by remember { mutableIntStateOf(0) } val scrollState = rememberScrollState() val focusRequester = remember { FocusRequester() } + val isRtl = LocalLayoutDirection.current == LayoutDirection.Rtl // Request focus when modal becomes visible androidx.compose.runtime.LaunchedEffect(isVisible) { @@ -148,12 +151,21 @@ fun PersonModal( true } Key.DirectionLeft -> { - if (focusedKnownForIndex > 0) focusedKnownForIndex-- + if (isRtl) { + val max = (person?.knownFor?.size ?: 1) - 1 + if (focusedKnownForIndex < max) focusedKnownForIndex++ + } else { + if (focusedKnownForIndex > 0) focusedKnownForIndex-- + } true } Key.DirectionRight -> { - val max = (person?.knownFor?.size ?: 1) - 1 - if (focusedKnownForIndex < max) focusedKnownForIndex++ + if (isRtl) { + if (focusedKnownForIndex > 0) focusedKnownForIndex-- + } else { + val max = (person?.knownFor?.size ?: 1) - 1 + if (focusedKnownForIndex < max) focusedKnownForIndex++ + } true } Key.Enter, Key.DirectionCenter -> { diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/search/SearchScreen.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/search/SearchScreen.kt index bce011c2..5cb4dafd 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/search/SearchScreen.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/search/SearchScreen.kt @@ -63,9 +63,11 @@ import androidx.compose.ui.input.key.KeyEventType import androidx.compose.ui.input.key.key import androidx.compose.ui.input.key.onPreviewKeyEvent import androidx.compose.ui.input.key.type +import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.unit.Dp @@ -119,6 +121,7 @@ fun SearchScreen( val configuration = LocalConfiguration.current val isCompactHeight = configuration.screenHeightDp <= 780 val isTouchDevice = LocalDeviceType.current.isTouchDevice() + val isRtl = LocalLayoutDirection.current == LayoutDirection.Rtl val searchBarWidth = if (isTouchDevice) configuration.screenWidthDp.dp - 24.dp else (configuration.screenWidthDp.dp * 0.48f).coerceIn(460.dp, 680.dp) @@ -304,7 +307,12 @@ fun SearchScreen( runCatching { searchFocusRequester.requestFocus() } return@onPreviewKeyEvent true } - when (event.key) { + val effectiveKey = when (event.key) { + Key.DirectionLeft -> if (isRtl) Key.DirectionRight else Key.DirectionLeft + Key.DirectionRight -> if (isRtl) Key.DirectionLeft else Key.DirectionRight + else -> event.key + } + when (effectiveKey) { Key.Back, Key.Escape -> when (focusZone) { FocusZone.RESULTS -> { if (showFilters && quickFilters.isNotEmpty()) { @@ -536,6 +544,7 @@ fun SearchScreen( focusedFilterIndex = focusedFilterIndex, filtersFocusRequester = filtersFocusRequester, isTouchDevice = isTouchDevice, + isRtl = isRtl, onFocused = { index -> focusZone = FocusZone.FILTERS focusedFilterIndex = index @@ -733,6 +742,7 @@ private fun DiscoverFilterStrip( focusedFilterIndex: Int, filtersFocusRequester: FocusRequester, isTouchDevice: Boolean, + isRtl: Boolean = false, onFocused: (Int) -> Unit, onMoveUp: () -> Unit, onMoveDown: () -> Unit, @@ -761,8 +771,8 @@ private fun DiscoverFilterStrip( when (event.key) { Key.DirectionUp -> { onMoveUp(); true } Key.DirectionDown -> { onMoveDown(); true } - Key.DirectionLeft -> { onMoveLeft(); true } - Key.DirectionRight -> { onMoveRight(); true } + Key.DirectionLeft -> { if (isRtl) onMoveRight() else onMoveLeft(); true } + Key.DirectionRight -> { if (isRtl) onMoveLeft() else onMoveRight(); true } Key.Enter, Key.DirectionCenter -> false else -> false } diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/telegram/TelegramSettingsScreen.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/telegram/TelegramSettingsScreen.kt index dc6dc7d9..4c676170 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/telegram/TelegramSettingsScreen.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/telegram/TelegramSettingsScreen.kt @@ -5,6 +5,7 @@ import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable +import androidx.compose.foundation.focusable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -13,6 +14,7 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width @@ -29,12 +31,22 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.KeyEventType +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.onKeyEvent +import androidx.compose.ui.input.key.type import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.platform.LocalContext @@ -67,13 +79,15 @@ fun TelegramSettingsScreen( viewModel: TelegramSettingsViewModel = hiltViewModel() ) { val authState by viewModel.authState.collectAsState() + val cacheSizeBytes by viewModel.cacheSizeBytes.collectAsState() + var showDisconnectConfirm by remember { mutableStateOf(false) } Box( modifier = Modifier .fillMaxSize() .background(appBackgroundDark()) - .padding(horizontal = 32.dp, vertical = 24.dp) ) { + Box(modifier = Modifier.fillMaxSize().padding(horizontal = 32.dp, vertical = 24.dp)) { Column(modifier = Modifier.fillMaxSize()) { Row( verticalAlignment = Alignment.CenterVertically, @@ -124,7 +138,9 @@ fun TelegramSettingsScreen( ) is TelegramAuthState.Ready -> ConnectedContent( firstName = state.firstName, - onDisconnect = { viewModel.disconnect() } + cacheSizeBytes = cacheSizeBytes, + onDisconnect = { showDisconnectConfirm = true }, + onClearCache = { viewModel.clearCache() } ) is TelegramAuthState.Error -> ErrorContent( message = state.message, @@ -132,6 +148,17 @@ fun TelegramSettingsScreen( ) } } + } // inner padded Box + + if (showDisconnectConfirm) { + DisconnectConfirmDialog( + onConfirm = { + showDisconnectConfirm = false + viewModel.disconnect() + }, + onDismiss = { showDisconnectConfirm = false } + ) + } } } @@ -428,7 +455,9 @@ private fun PasswordContent(onSubmit: (String) -> Unit) { @Composable private fun ConnectedContent( firstName: String, - onDisconnect: () -> Unit + cacheSizeBytes: Long, + onDisconnect: () -> Unit, + onClearCache: () -> Unit ) { Column( modifier = Modifier.fillMaxWidth(), @@ -469,6 +498,48 @@ private fun ConnectedContent( ) } } + + Spacer(modifier = Modifier.height(12.dp)) + + var cacheFocused by remember { mutableStateOf(false) } + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { if (cacheSizeBytes > 0L) onClearCache() } + .onFocusChanged { cacheFocused = it.isFocused } + .background( + if (cacheFocused) Pink.copy(alpha = 0.18f) else Color.White.copy(alpha = 0.04f), + RoundedCornerShape(12.dp) + ) + .border( + width = if (cacheFocused) 2.dp else 1.dp, + color = if (cacheFocused) Pink else Color.White.copy(alpha = 0.08f), + shape = RoundedCornerShape(12.dp) + ) + .padding(horizontal = 16.dp, vertical = 14.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Column { + Text( + text = "Video Cache", + style = ArflixTypography.cardTitle.copy(fontSize = 14.sp), + color = if (cacheFocused) Pink else TextPrimary + ) + Text( + text = formatCacheSize(cacheSizeBytes), + style = ArflixTypography.caption.copy(fontSize = 13.sp), + color = TextSecondary + ) + } + if (cacheSizeBytes > 0L) { + Text( + text = "CLEAR", + style = ArflixTypography.label.copy(fontSize = 11.sp), + color = Pink + ) + } + } } } @@ -509,6 +580,116 @@ private fun ActionButton(label: String, onClick: () -> Unit) { } } +@OptIn(ExperimentalTvMaterial3Api::class) +@Composable +private fun DisconnectConfirmDialog(onConfirm: () -> Unit, onDismiss: () -> Unit) { + val focusRequester = remember { FocusRequester() } + var focusedButton by remember { mutableIntStateOf(0) } // 0 = cancel, 1 = disconnect + val isRtl = LocalLayoutDirection.current == LayoutDirection.Rtl + + LaunchedEffect(Unit) { runCatching { focusRequester.requestFocus() } } + + Box( + modifier = Modifier + .fillMaxSize() + .background(Color.Black.copy(alpha = 0.65f)) + .clickable(onClick = onDismiss), + contentAlignment = Alignment.Center + ) { + Column( + modifier = Modifier + .widthIn(max = 360.dp) + .background(BackgroundElevated, RoundedCornerShape(16.dp)) + .clickable(enabled = false) {} + .focusRequester(focusRequester) + .focusable() + .onKeyEvent { event -> + if (event.type != KeyEventType.KeyDown) return@onKeyEvent false + when (event.key) { + Key.Back, Key.Escape -> { onDismiss(); true } + Key.DirectionLeft -> { focusedButton = if (isRtl) 1 else 0; true } + Key.DirectionRight -> { focusedButton = if (isRtl) 0 else 1; true } + Key.Enter, Key.DirectionCenter -> { + if (focusedButton == 0) onDismiss() else onConfirm() + true + } + else -> false + } + } + .padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = "Disconnect Telegram?", + style = ArflixTypography.cardTitle.copy(fontSize = 18.sp), + color = TextPrimary + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = "You'll need to sign in again to use Telegram as a source.", + style = ArflixTypography.caption.copy(fontSize = 13.sp), + color = TextSecondary, + textAlign = TextAlign.Center + ) + Spacer(modifier = Modifier.height(24.dp)) + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + Box( + modifier = Modifier + .weight(1f) + .background( + if (focusedButton == 0) Color.White.copy(alpha = 0.15f) else Color.White.copy(alpha = 0.06f), + RoundedCornerShape(8.dp) + ) + .border( + width = if (focusedButton == 0) 2.dp else 0.dp, + color = if (focusedButton == 0) Color.White.copy(alpha = 0.5f) else Color.Transparent, + shape = RoundedCornerShape(8.dp) + ) + .clickable { onDismiss() } + .padding(vertical = 13.dp), + contentAlignment = Alignment.Center + ) { + Text( + text = "CANCEL", + style = ArflixTypography.label.copy(fontSize = 12.sp), + color = TextPrimary + ) + } + Box( + modifier = Modifier + .weight(1f) + .background( + if (focusedButton == 1) Pink.copy(alpha = 0.3f) else Pink.copy(alpha = 0.12f), + RoundedCornerShape(8.dp) + ) + .border( + width = if (focusedButton == 1) 2.dp else 1.dp, + color = if (focusedButton == 1) Pink else Pink.copy(alpha = 0.35f), + shape = RoundedCornerShape(8.dp) + ) + .clickable { onConfirm() } + .padding(vertical = 13.dp), + contentAlignment = Alignment.Center + ) { + Text( + text = "DISCONNECT", + style = ArflixTypography.label.copy(fontSize = 12.sp), + color = Pink + ) + } + } + } + } +} + +private fun formatCacheSize(bytes: Long): String = when { + bytes <= 0 -> "Empty" + bytes >= 1_000_000_000 -> "%.1f GB".format(bytes / 1_000_000_000.0) + bytes >= 1_000_000 -> "%.1f MB".format(bytes / 1_000_000.0) + bytes >= 1_000 -> "%.0f KB".format(bytes / 1_000.0) + else -> "$bytes B" +} + @Composable private fun inputColors() = TextFieldDefaults.colors( focusedContainerColor = BackgroundElevated, diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/telegram/TelegramSettingsViewModel.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/telegram/TelegramSettingsViewModel.kt index 1c4ac386..f18fecb5 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/telegram/TelegramSettingsViewModel.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/telegram/TelegramSettingsViewModel.kt @@ -1,10 +1,15 @@ package com.arflix.tv.ui.screens.settings.telegram import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope import com.arflix.tv.data.telegram.TelegramAuthState import com.arflix.tv.data.telegram.TelegramRepository import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch import javax.inject.Inject @HiltViewModel @@ -14,6 +19,13 @@ class TelegramSettingsViewModel @Inject constructor( val authState: StateFlow = repository.authState + private val _cacheSizeBytes = MutableStateFlow(0L) + val cacheSizeBytes: StateFlow = _cacheSizeBytes.asStateFlow() + + init { + refreshCacheSize() + } + fun startAuth() = repository.startAuth() fun startQrAuth() = repository.requestQrCode() fun submitPhone(phone: String) = repository.submitPhone(phone) @@ -22,5 +34,19 @@ class TelegramSettingsViewModel @Inject constructor( fun disconnect() { repository.disconnect() + _cacheSizeBytes.value = 0L + } + + fun clearCache() { + viewModelScope.launch(Dispatchers.IO) { + repository.clearCache() + _cacheSizeBytes.value = repository.getCacheSize() + } + } + + private fun refreshCacheSize() { + viewModelScope.launch(Dispatchers.IO) { + _cacheSizeBytes.value = repository.getCacheSize() + } } } diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/watchlist/WatchlistScreen.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/watchlist/WatchlistScreen.kt index a50c7b5a..312409a2 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/watchlist/WatchlistScreen.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/watchlist/WatchlistScreen.kt @@ -39,7 +39,9 @@ import androidx.compose.ui.input.key.KeyEventType import androidx.compose.ui.input.key.key import androidx.compose.ui.input.key.onKeyEvent import androidx.compose.ui.input.key.type +import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.platform.LocalLifecycleOwner +import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel @@ -96,6 +98,7 @@ fun WatchlistScreen( } else { if (isMobile) 200.dp else 230.dp } + val isRtl = LocalLayoutDirection.current == LayoutDirection.Rtl var isSidebarFocused by remember { mutableStateOf(false) } val hasProfile = currentProfile != null val maxSidebarIndex = topBarMaxIndex(hasProfile) @@ -167,7 +170,12 @@ fun WatchlistScreen( .focusable() .onKeyEvent { event -> if (event.type == KeyEventType.KeyDown) { - when (event.key) { + val effectiveKey = when (event.key) { + Key.DirectionLeft -> if (isRtl) Key.DirectionRight else Key.DirectionLeft + Key.DirectionRight -> if (isRtl) Key.DirectionLeft else Key.DirectionRight + else -> event.key + } + when (effectiveKey) { Key.Back, Key.Escape -> { if (isSidebarFocused) onBack() else isSidebarFocused = true true