From db2ccd642ba64448d130a41789b94866556dfb23 Mon Sep 17 00:00:00 2001 From: Pasta Date: Thu, 16 Jul 2026 11:55:41 +0300 Subject: [PATCH 01/15] feat(tv): add native Android TV interface --- .github/workflows/apply-tv-integration.yml | 28 +++ .tv-bootstrap/integration.patch.gz.b64 | 1 + .../java/io/github/aedev/flow/TvActivity.kt | 18 ++ .../flow/data/local/AppUiModePreferences.kt | 31 +++ .../github/aedev/flow/platform/AppUiMode.kt | 25 +++ .../flow/platform/DeviceFormFactorDetector.kt | 22 ++ .../screens/settings/InterfaceModeDialog.kt | 71 +++++++ .../io/github/aedev/flow/ui/tv/FlowTvApp.kt | 116 +++++++++++ .../flow/ui/tv/components/TvFocusableCard.kt | 64 ++++++ .../flow/ui/tv/components/TvNavigationRail.kt | 78 +++++++ .../flow/ui/tv/components/TvScreenStates.kt | 51 +++++ .../flow/ui/tv/components/TvVideoCard.kt | 114 ++++++++++ .../flow/ui/tv/input/TvPlayerKeyMapper.kt | 26 +++ .../flow/ui/tv/navigation/TvDestination.kt | 31 +++ .../aedev/flow/ui/tv/screens/TvHomeScreen.kt | 141 +++++++++++++ .../flow/ui/tv/screens/TvLibraryScreen.kt | 140 +++++++++++++ .../flow/ui/tv/screens/TvPlayerScreen.kt | 195 ++++++++++++++++++ .../flow/ui/tv/screens/TvSearchScreen.kt | 121 +++++++++++ .../flow/ui/tv/screens/TvSettingsScreen.kt | 89 ++++++++ .../ui/tv/screens/TvSubscriptionsScreen.kt | 119 +++++++++++ app/src/main/res/drawable-xhdpi/tv_banner.png | Bin 0 -> 2430 bytes app/src/main/res/values/tv_strings.xml | 35 ++++ .../aedev/flow/platform/AppUiModeTest.kt | 28 +++ .../flow/ui/tv/input/TvPlayerKeyMapperTest.kt | 25 +++ .../ui/tv/navigation/TvDestinationTest.kt | 24 +++ 25 files changed, 1593 insertions(+) create mode 100644 .github/workflows/apply-tv-integration.yml create mode 100644 .tv-bootstrap/integration.patch.gz.b64 create mode 100644 app/src/main/java/io/github/aedev/flow/TvActivity.kt create mode 100644 app/src/main/java/io/github/aedev/flow/data/local/AppUiModePreferences.kt create mode 100644 app/src/main/java/io/github/aedev/flow/platform/AppUiMode.kt create mode 100644 app/src/main/java/io/github/aedev/flow/platform/DeviceFormFactorDetector.kt create mode 100644 app/src/main/java/io/github/aedev/flow/ui/screens/settings/InterfaceModeDialog.kt create mode 100644 app/src/main/java/io/github/aedev/flow/ui/tv/FlowTvApp.kt create mode 100644 app/src/main/java/io/github/aedev/flow/ui/tv/components/TvFocusableCard.kt create mode 100644 app/src/main/java/io/github/aedev/flow/ui/tv/components/TvNavigationRail.kt create mode 100644 app/src/main/java/io/github/aedev/flow/ui/tv/components/TvScreenStates.kt create mode 100644 app/src/main/java/io/github/aedev/flow/ui/tv/components/TvVideoCard.kt create mode 100644 app/src/main/java/io/github/aedev/flow/ui/tv/input/TvPlayerKeyMapper.kt create mode 100644 app/src/main/java/io/github/aedev/flow/ui/tv/navigation/TvDestination.kt create mode 100644 app/src/main/java/io/github/aedev/flow/ui/tv/screens/TvHomeScreen.kt create mode 100644 app/src/main/java/io/github/aedev/flow/ui/tv/screens/TvLibraryScreen.kt create mode 100644 app/src/main/java/io/github/aedev/flow/ui/tv/screens/TvPlayerScreen.kt create mode 100644 app/src/main/java/io/github/aedev/flow/ui/tv/screens/TvSearchScreen.kt create mode 100644 app/src/main/java/io/github/aedev/flow/ui/tv/screens/TvSettingsScreen.kt create mode 100644 app/src/main/java/io/github/aedev/flow/ui/tv/screens/TvSubscriptionsScreen.kt create mode 100644 app/src/main/res/drawable-xhdpi/tv_banner.png create mode 100644 app/src/main/res/values/tv_strings.xml create mode 100644 app/src/test/java/io/github/aedev/flow/platform/AppUiModeTest.kt create mode 100644 app/src/test/java/io/github/aedev/flow/ui/tv/input/TvPlayerKeyMapperTest.kt create mode 100644 app/src/test/java/io/github/aedev/flow/ui/tv/navigation/TvDestinationTest.kt diff --git a/.github/workflows/apply-tv-integration.yml b/.github/workflows/apply-tv-integration.yml new file mode 100644 index 00000000..d8573d8c --- /dev/null +++ b/.github/workflows/apply-tv-integration.yml @@ -0,0 +1,28 @@ +name: Apply TV integration patch + +on: + push: + branches: + - feat/android-tv-support + +permissions: + contents: write + +jobs: + apply: + if: ${{ github.actor != 'github-actions[bot]' }} + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Apply integration patch + run: | + base64 --decode .tv-bootstrap/integration.patch.gz.b64 | gzip --decompress > /tmp/tv-integration.patch + git apply --check /tmp/tv-integration.patch + git apply /tmp/tv-integration.patch + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add app/src/main/AndroidManifest.xml app/src/main/java/io/github/aedev/flow/MainActivity.kt app/src/main/java/io/github/aedev/flow/ui/screens/settings/SettingsScreen.kt + git commit -m "feat(tv): integrate TV runtime and settings" + git push diff --git a/.tv-bootstrap/integration.patch.gz.b64 b/.tv-bootstrap/integration.patch.gz.b64 new file mode 100644 index 00000000..5a2d3838 --- /dev/null +++ b/.tv-bootstrap/integration.patch.gz.b64 @@ -0,0 +1 @@ +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 \ No newline at end of file diff --git a/app/src/main/java/io/github/aedev/flow/TvActivity.kt b/app/src/main/java/io/github/aedev/flow/TvActivity.kt new file mode 100644 index 00000000..e2351b38 --- /dev/null +++ b/app/src/main/java/io/github/aedev/flow/TvActivity.kt @@ -0,0 +1,18 @@ +package io.github.aedev.flow + +import android.content.Intent +import android.os.Bundle +import androidx.activity.ComponentActivity + +/** Leanback launcher bridge that keeps [MainActivity] as Flow's single runtime host. */ +class TvActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + val forwardedIntent = Intent(intent).apply { + setClass(this@TvActivity, MainActivity::class.java) + addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP) + } + startActivity(forwardedIntent) + finish() + } +} diff --git a/app/src/main/java/io/github/aedev/flow/data/local/AppUiModePreferences.kt b/app/src/main/java/io/github/aedev/flow/data/local/AppUiModePreferences.kt new file mode 100644 index 00000000..eaa6500f --- /dev/null +++ b/app/src/main/java/io/github/aedev/flow/data/local/AppUiModePreferences.kt @@ -0,0 +1,31 @@ +package io.github.aedev.flow.data.local + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import io.github.aedev.flow.platform.AppUiMode +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +private val Context.appUiModeDataStore: DataStore by safePreferencesDataStore(name = "app_ui_mode") + +/** Stores the interface override independently from playback preferences. */ +class AppUiModePreferences(context: Context) { + private val appContext = context.applicationContext + + val mode: Flow = appContext.appUiModeDataStore.data.map { preferences -> + AppUiMode.fromStorage(preferences[MODE]) + } + + suspend fun setMode(mode: AppUiMode) { + appContext.appUiModeDataStore.edit { preferences -> + preferences[MODE] = mode.name + } + } + + private companion object { + val MODE = stringPreferencesKey("mode") + } +} diff --git a/app/src/main/java/io/github/aedev/flow/platform/AppUiMode.kt b/app/src/main/java/io/github/aedev/flow/platform/AppUiMode.kt new file mode 100644 index 00000000..0b0337d5 --- /dev/null +++ b/app/src/main/java/io/github/aedev/flow/platform/AppUiMode.kt @@ -0,0 +1,25 @@ +package io.github.aedev.flow.platform + +/** User-selected interface mode for the shared Flow APK. */ +enum class AppUiMode { + AUTOMATIC, + MOBILE, + TV; + + fun resolve(deviceFormFactor: DeviceFormFactor): AppUiRoot = when (this) { + AUTOMATIC -> if (deviceFormFactor == DeviceFormFactor.TV) AppUiRoot.TV else AppUiRoot.MOBILE + MOBILE -> AppUiRoot.MOBILE + TV -> AppUiRoot.TV + } + + companion object { + fun fromStorage(value: String?): AppUiMode = + entries.firstOrNull { it.name == value } ?: AUTOMATIC + } +} + +/** The UI root rendered by [io.github.aedev.flow.MainActivity]. */ +enum class AppUiRoot { + MOBILE, + TV, +} diff --git a/app/src/main/java/io/github/aedev/flow/platform/DeviceFormFactorDetector.kt b/app/src/main/java/io/github/aedev/flow/platform/DeviceFormFactorDetector.kt new file mode 100644 index 00000000..9532a3b2 --- /dev/null +++ b/app/src/main/java/io/github/aedev/flow/platform/DeviceFormFactorDetector.kt @@ -0,0 +1,22 @@ +package io.github.aedev.flow.platform + +import android.app.UiModeManager +import android.content.Context +import android.content.pm.PackageManager +import android.content.res.Configuration + +/** Runtime device categories relevant to Flow's interface selection. */ +enum class DeviceFormFactor { + MOBILE, + TV, +} + +/** Detects television devices without making touchscreen availability mandatory. */ +object DeviceFormFactorDetector { + fun detect(context: Context): DeviceFormFactor { + val uiModeManager = context.getSystemService(Context.UI_MODE_SERVICE) as? UiModeManager + val isTelevisionMode = uiModeManager?.currentModeType == Configuration.UI_MODE_TYPE_TELEVISION + val hasLeanback = context.packageManager.hasSystemFeature(PackageManager.FEATURE_LEANBACK) + return if (isTelevisionMode || hasLeanback) DeviceFormFactor.TV else DeviceFormFactor.MOBILE + } +} diff --git a/app/src/main/java/io/github/aedev/flow/ui/screens/settings/InterfaceModeDialog.kt b/app/src/main/java/io/github/aedev/flow/ui/screens/settings/InterfaceModeDialog.kt new file mode 100644 index 00000000..14cccc92 --- /dev/null +++ b/app/src/main/java/io/github/aedev/flow/ui/screens/settings/InterfaceModeDialog.kt @@ -0,0 +1,71 @@ +package io.github.aedev.flow.ui.screens.settings + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import io.github.aedev.flow.R +import io.github.aedev.flow.platform.AppUiMode + +@Composable +fun InterfaceModeDialog( + selected: AppUiMode, + onSelected: (AppUiMode) -> Unit, + onDismiss: () -> Unit, +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.interface_mode_title)) }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + AppUiMode.entries.forEach { mode -> + val title = when (mode) { + AppUiMode.AUTOMATIC -> stringResource(R.string.interface_mode_automatic) + AppUiMode.MOBILE -> stringResource(R.string.interface_mode_mobile) + AppUiMode.TV -> stringResource(R.string.interface_mode_tv) + } + val summary = when (mode) { + AppUiMode.AUTOMATIC -> stringResource(R.string.interface_mode_automatic_summary) + AppUiMode.MOBILE -> stringResource(R.string.interface_mode_mobile_summary) + AppUiMode.TV -> stringResource(R.string.interface_mode_tv_summary) + } + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { onSelected(mode) } + .padding(vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + RadioButton(selected = selected == mode, onClick = null) + Column { + Text(title, style = MaterialTheme.typography.titleMedium) + Text( + text = summary, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + } + }, + confirmButton = { + TextButton(onClick = onDismiss) { + Text(stringResource(R.string.close)) + } + }, + ) +} diff --git a/app/src/main/java/io/github/aedev/flow/ui/tv/FlowTvApp.kt b/app/src/main/java/io/github/aedev/flow/ui/tv/FlowTvApp.kt new file mode 100644 index 00000000..261dd718 --- /dev/null +++ b/app/src/main/java/io/github/aedev/flow/ui/tv/FlowTvApp.kt @@ -0,0 +1,116 @@ +package io.github.aedev.flow.ui.tv + +import androidx.activity.ComponentActivity +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.media3.common.util.UnstableApi +import io.github.aedev.flow.data.model.Video +import io.github.aedev.flow.player.GlobalPlayerState +import io.github.aedev.flow.ui.screens.home.HomeViewModel +import io.github.aedev.flow.ui.screens.player.VideoPlayerViewModel +import io.github.aedev.flow.ui.screens.search.SearchViewModel +import io.github.aedev.flow.ui.screens.subscriptions.SubscriptionsViewModel +import io.github.aedev.flow.ui.tv.components.TvNavigationRail +import io.github.aedev.flow.ui.tv.navigation.TvDestination +import io.github.aedev.flow.ui.tv.screens.TvHomeScreen +import io.github.aedev.flow.ui.tv.screens.TvLibraryScreen +import io.github.aedev.flow.ui.tv.screens.TvPlayerScreen +import io.github.aedev.flow.ui.tv.screens.TvSearchScreen +import io.github.aedev.flow.ui.tv.screens.TvSettingsScreen +import io.github.aedev.flow.ui.tv.screens.TvSubscriptionsScreen + +@androidx.annotation.OptIn(UnstableApi::class) +@Composable +fun FlowTvApp( + deeplinkVideoId: String? = null, + isShort: Boolean = false, + onDeeplinkConsumed: () -> Unit = {}, +) { + val context = LocalContext.current + val activity = context as ComponentActivity + val homeViewModel: HomeViewModel = hiltViewModel(activity) + val playerViewModel: VideoPlayerViewModel = hiltViewModel(activity) + val subscriptionsViewModel: SubscriptionsViewModel = viewModel(viewModelStoreOwner = activity) + val searchViewModel: SearchViewModel = viewModel(viewModelStoreOwner = activity) + var destination by remember { mutableStateOf(TvDestination.HOME) } + val activeVideo by GlobalPlayerState.currentVideo.collectAsStateWithLifecycle() + + fun play(video: Video) { + GlobalPlayerState.setCurrentVideo(video) + playerViewModel.playVideo(video) + } + + LaunchedEffect(deeplinkVideoId) { + val videoId = deeplinkVideoId ?: return@LaunchedEffect + play( + Video( + id = videoId, + title = "", + channelName = "", + channelId = "", + thumbnailUrl = "", + duration = 0, + viewCount = 0L, + uploadDate = "", + isShort = isShort, + ) + ) + onDeeplinkConsumed() + } + + Box(modifier = Modifier.fillMaxSize()) { + Row(modifier = Modifier.fillMaxSize()) { + TvNavigationRail( + selected = destination, + onSelected = { destination = it }, + ) + when (destination) { + TvDestination.HOME -> TvHomeScreen( + viewModel = homeViewModel, + onVideoClick = ::play, + modifier = Modifier.weight(1f), + ) + TvDestination.SUBSCRIPTIONS -> TvSubscriptionsScreen( + viewModel = subscriptionsViewModel, + onVideoClick = ::play, + modifier = Modifier.weight(1f), + ) + TvDestination.SEARCH -> TvSearchScreen( + viewModel = searchViewModel, + onVideoClick = ::play, + modifier = Modifier.weight(1f), + ) + TvDestination.LIBRARY -> TvLibraryScreen( + onVideoClick = ::play, + modifier = Modifier.weight(1f), + ) + TvDestination.SETTINGS -> TvSettingsScreen( + modifier = Modifier.weight(1f), + ) + } + } + + activeVideo?.let { video -> + TvPlayerScreen( + video = video, + viewModel = playerViewModel, + onClose = { + playerViewModel.clearVideo() + GlobalPlayerState.setCurrentVideo(null) + }, + ) + } + } +} diff --git a/app/src/main/java/io/github/aedev/flow/ui/tv/components/TvFocusableCard.kt b/app/src/main/java/io/github/aedev/flow/ui/tv/components/TvFocusableCard.kt new file mode 100644 index 00000000..dbe0e35d --- /dev/null +++ b/app/src/main/java/io/github/aedev/flow/ui/tv/components/TvFocusableCard.kt @@ -0,0 +1,64 @@ +package io.github.aedev.flow.ui.tv.components + +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.input.focus.onFocusChanged +import androidx.compose.ui.unit.dp + +/** Material 3 card with a visible ten-foot focus state. */ +@Composable +fun TvFocusableCard( + onClick: () -> Unit, + modifier: Modifier = Modifier, + content: @Composable BoxScope.() -> Unit, +) { + var focused by remember { mutableStateOf(false) } + val scale by animateFloatAsState( + targetValue = if (focused) 1.05f else 1f, + label = "tvCardScale", + ) + + Card( + onClick = onClick, + modifier = modifier + .graphicsLayer { + scaleX = scale + scaleY = scale + } + .onFocusChanged { focused = it.isFocused }, + colors = CardDefaults.cardColors( + containerColor = if (focused) { + MaterialTheme.colorScheme.secondaryContainer + } else { + MaterialTheme.colorScheme.surfaceContainer + }, + contentColor = if (focused) { + MaterialTheme.colorScheme.onSecondaryContainer + } else { + MaterialTheme.colorScheme.onSurface + }, + ), + border = if (focused) { + BorderStroke(2.dp, MaterialTheme.colorScheme.outline) + } else { + null + }, + elevation = CardDefaults.cardElevation( + defaultElevation = if (focused) 8.dp else 1.dp, + ), + ) { + Box(content = content) + } +} diff --git a/app/src/main/java/io/github/aedev/flow/ui/tv/components/TvNavigationRail.kt b/app/src/main/java/io/github/aedev/flow/ui/tv/components/TvNavigationRail.kt new file mode 100644 index 00000000..b84da6a3 --- /dev/null +++ b/app/src/main/java/io/github/aedev/flow/ui/tv/components/TvNavigationRail.kt @@ -0,0 +1,78 @@ +package io.github.aedev.flow.ui.tv.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import io.github.aedev.flow.ui.tv.navigation.TvDestination + +@Composable +fun TvNavigationRail( + selected: TvDestination, + onSelected: (TvDestination) -> Unit, + modifier: Modifier = Modifier, +) { + Surface( + modifier = modifier + .width(236.dp) + .fillMaxHeight(), + color = MaterialTheme.colorScheme.surfaceContainerLow, + ) { + Column( + modifier = Modifier.padding(horizontal = 20.dp, vertical = 32.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text( + text = stringResource(io.github.aedev.flow.R.string.app_name), + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp), + ) + Spacer(modifier = Modifier.size(8.dp)) + TvDestination.primary.forEach { destination -> + TvFocusableCard( + onClick = { onSelected(destination) }, + modifier = Modifier.fillMaxWidth(), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 14.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = destination.icon, + contentDescription = null, + tint = if (destination == selected) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + ) + Spacer(modifier = Modifier.width(14.dp)) + Text( + text = stringResource(destination.labelRes), + style = MaterialTheme.typography.titleMedium, + fontWeight = if (destination == selected) FontWeight.Bold else FontWeight.Medium, + ) + } + } + } + } + } +} diff --git a/app/src/main/java/io/github/aedev/flow/ui/tv/components/TvScreenStates.kt b/app/src/main/java/io/github/aedev/flow/ui/tv/components/TvScreenStates.kt new file mode 100644 index 00000000..869cafad --- /dev/null +++ b/app/src/main/java/io/github/aedev/flow/ui/tv/components/TvScreenStates.kt @@ -0,0 +1,51 @@ +package io.github.aedev.flow.ui.tv.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp + +@Composable +fun TvLoadingState(modifier: Modifier = Modifier) { + Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + CircularProgressIndicator() + } +} + +@Composable +fun TvMessageState( + title: String, + message: String? = null, + modifier: Modifier = Modifier, +) { + Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Column( + modifier = Modifier.padding(32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + text = title, + style = MaterialTheme.typography.headlineSmall, + textAlign = TextAlign.Center, + ) + if (!message.isNullOrBlank()) { + Text( + text = message, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + } + } + } +} diff --git a/app/src/main/java/io/github/aedev/flow/ui/tv/components/TvVideoCard.kt b/app/src/main/java/io/github/aedev/flow/ui/tv/components/TvVideoCard.kt new file mode 100644 index 00000000..27780641 --- /dev/null +++ b/app/src/main/java/io/github/aedev/flow/ui/tv/components/TvVideoCard.kt @@ -0,0 +1,114 @@ +package io.github.aedev.flow.ui.tv.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import coil.compose.AsyncImage +import io.github.aedev.flow.data.model.Video + +@Composable +fun TvVideoCard( + video: Video, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + TvFocusableCard( + onClick = onClick, + modifier = modifier.width(280.dp), + ) { + Column(modifier = Modifier.fillMaxWidth()) { + Box( + modifier = Modifier + .fillMaxWidth() + .aspectRatio(16f / 9f) + .background(MaterialTheme.colorScheme.surfaceVariant), + ) { + AsyncImage( + model = video.thumbnailUrl, + contentDescription = video.title, + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop, + ) + if (video.duration > 0) { + Text( + text = formatTvDuration(video.duration), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onInverseSurface, + modifier = Modifier + .align(Alignment.BottomEnd) + .padding(8.dp) + .background( + color = MaterialTheme.colorScheme.inverseSurface, + shape = MaterialTheme.shapes.extraSmall, + ) + .padding(horizontal = 6.dp, vertical = 2.dp), + ) + } + } + Column( + modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text( + text = video.title, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = video.channelName, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + } +} + +@Composable +fun TvVideoRow( + videos: List