diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index ebc2d062..e97ca2d9 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -1,6 +1,9 @@ + + + @@ -52,6 +55,7 @@ + + + + + + + "IDLE" @@ -185,6 +192,14 @@ class MainActivity : ComponentActivity() { var showSplash by remember { mutableStateOf(true) } val context = LocalContext.current + val configuration = LocalConfiguration.current + val uiPreferences = remember { AppUiModePreferences(applicationContext) } + val appUiMode by uiPreferences.mode.collectAsState(initial = AppUiMode.AUTOMATIC) + val deviceFormFactor = remember(configuration.uiMode, context) { + DeviceFormFactorDetector.detect(context) + } + val appUiRoot = appUiMode.resolve(deviceFormFactor) + SideEffect { cachedAppUiRoot = appUiRoot } // Check for a crash that happened last session. // If found, show the CrashReporterScreen instead of the normal UI. @@ -331,56 +346,64 @@ class MainActivity : ComponentActivity() { val isDeeplinkShort by this@MainActivity.isDeeplinkShort val openMusicPlayerRequest by this@MainActivity.openMusicPlayerRequest - FlowApp( - currentTheme = themeMode, - themeVariant = themeVariant, - customThemePalettes = customThemePalettes, - systemLightThemeMode = systemLightThemeMode, - systemDarkThemeMode = systemDarkThemeMode, - systemDarkThemeVariant = systemDarkThemeVariant, - onThemeChange = { newTheme -> - themeMode = newTheme - scope.launch { - dataManager.setThemeMode(newTheme) - } - }, - onThemeVariantChange = { variant -> - themeVariant = variant - scope.launch { - dataManager.setThemeVariant(variant) - } - }, - onCustomThemePalettesChange = { palettes -> - customThemePalettes = palettes - scope.launch { - dataManager.setCustomThemePalettes(palettes) - } - }, - onSystemLightThemeChange = { newTheme -> - systemLightThemeMode = newTheme - scope.launch { - dataManager.setSystemLightThemeMode(newTheme) - } - }, - onSystemDarkThemeChange = { newTheme -> - systemDarkThemeMode = newTheme - scope.launch { - dataManager.setSystemDarkThemeMode(newTheme) - } - }, - onSystemDarkThemeVariantChange = { variant -> - systemDarkThemeVariant = variant - scope.launch { - dataManager.setSystemDarkThemeVariant(variant) + if (appUiRoot == AppUiRoot.TV) { + FlowTvApp( + deeplinkVideoId = deeplinkVideoId, + isShort = isDeeplinkShort, + onDeeplinkConsumed = { consumeDeeplink() }, + ) + } else { + FlowApp( + currentTheme = themeMode, + themeVariant = themeVariant, + customThemePalettes = customThemePalettes, + systemLightThemeMode = systemLightThemeMode, + systemDarkThemeMode = systemDarkThemeMode, + systemDarkThemeVariant = systemDarkThemeVariant, + onThemeChange = { newTheme -> + themeMode = newTheme + scope.launch { + dataManager.setThemeMode(newTheme) + } + }, + onThemeVariantChange = { variant -> + themeVariant = variant + scope.launch { + dataManager.setThemeVariant(variant) + } + }, + onCustomThemePalettesChange = { palettes -> + customThemePalettes = palettes + scope.launch { + dataManager.setCustomThemePalettes(palettes) + } + }, + onSystemLightThemeChange = { newTheme -> + systemLightThemeMode = newTheme + scope.launch { + dataManager.setSystemLightThemeMode(newTheme) + } + }, + onSystemDarkThemeChange = { newTheme -> + systemDarkThemeMode = newTheme + scope.launch { + dataManager.setSystemDarkThemeMode(newTheme) + } + }, + onSystemDarkThemeVariantChange = { variant -> + systemDarkThemeVariant = variant + scope.launch { + dataManager.setSystemDarkThemeVariant(variant) + } + }, + deeplinkVideoId = deeplinkVideoId, + isShort = isDeeplinkShort, + openMusicPlayerRequest = openMusicPlayerRequest, + onDeeplinkConsumed = { + consumeDeeplink() } - }, - deeplinkVideoId = deeplinkVideoId, - isShort = isDeeplinkShort, - openMusicPlayerRequest = openMusicPlayerRequest, - onDeeplinkConsumed = { - consumeDeeplink() - } - ) + ) + } // 2. THE SPLASH SCREEN (Z-Index Top) if (showSplash) { @@ -590,7 +613,9 @@ class MainActivity : ComponentActivity() { ) videoLifecycleLog("onStop") if (!isInPictureInPictureMode && !PictureInPictureHelper.isPopupActive) { - requestedOrientation = android.content.pm.ActivityInfo.SCREEN_ORIENTATION_PORTRAIT + if (cachedAppUiRoot == AppUiRoot.MOBILE) { + requestedOrientation = android.content.pm.ActivityInfo.SCREEN_ORIENTATION_PORTRAIT + } if (!cachedShortsBackgroundPlay) { io.github.aedev.flow.player.shorts.ShortsPlayerPool.getInstance().pauseAll() } @@ -615,6 +640,7 @@ class MainActivity : ComponentActivity() { override fun onUserLeaveHint() { super.onUserLeaveHint() + if (cachedAppUiRoot == AppUiRoot.TV) return FlowCrashHandler.recordPhase("activity", "onUserLeaveHint autoPip=$cachedAutoPipEnabled") videoLifecycleLog("onUserLeaveHint") // Only enter PiP mode if video is playing and has progressed @@ -653,6 +679,7 @@ class MainActivity : ComponentActivity() { isPlaying: Boolean = true, openSettingsOnDenied: Boolean = false ): Boolean { + if (cachedAppUiRoot == AppUiRoot.TV) return false if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return false if (!PictureInPictureHelper.isPipAllowed(this)) { if (openSettingsOnDenied) { 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/screens/settings/SettingsScreen.kt b/app/src/main/java/io/github/aedev/flow/ui/screens/settings/SettingsScreen.kt index 8b9e7eb9..c2fa9d84 100644 --- a/app/src/main/java/io/github/aedev/flow/ui/screens/settings/SettingsScreen.kt +++ b/app/src/main/java/io/github/aedev/flow/ui/screens/settings/SettingsScreen.kt @@ -44,6 +44,8 @@ import io.github.aedev.flow.player.DeepFlowManager import io.github.aedev.flow.ui.theme.ThemeMode import io.github.aedev.flow.ui.theme.extendedColors import io.github.aedev.flow.data.local.PlayerPreferences +import io.github.aedev.flow.data.local.AppUiModePreferences +import io.github.aedev.flow.platform.AppUiMode import io.github.aedev.flow.utils.AppLanguageManager import com.google.gson.JsonParser import kotlinx.coroutines.Dispatchers @@ -98,6 +100,9 @@ fun SettingsScreen( val context = LocalContext.current val coroutineScope = rememberCoroutineScope() val playerPreferences = remember { PlayerPreferences(context) } + val appUiModePreferences = remember { AppUiModePreferences(context) } + val appUiMode by appUiModePreferences.mode.collectAsStateWithLifecycle(initialValue = AppUiMode.AUTOMATIC) + var showInterfaceModeDialog by remember { mutableStateOf(false) } val backupRepo = remember { io.github.aedev.flow.data.local.BackupRepository(context) } // Brain State @@ -122,6 +127,16 @@ fun SettingsScreen( val discordSettingsState by DiscordPresenceRuntime.settingsState.collectAsStateWithLifecycle() val discordSettingsSummary = discordSettingsSummaryText(discordSettingsState) + if (showInterfaceModeDialog) { + InterfaceModeDialog( + selected = appUiMode, + onSelected = { mode -> + coroutineScope.launch { appUiModePreferences.setMode(mode) } + }, + onDismiss = { showInterfaceModeDialog = false }, + ) + } + // Deep Flow state val deepFlowActive by playerPreferences.deepFlowActive.collectAsState(initial = false) val deepFlowActivatedAt by playerPreferences.deepFlowActivatedAt.collectAsState(initial = 0L) @@ -233,6 +248,7 @@ fun SettingsScreen( val allSettingsEntries = listOf( SettingSearchEntry(Icons.Outlined.Psychology, androidx.compose.ui.res.stringResource(io.github.aedev.flow.R.string.flow_control_center), androidx.compose.ui.res.stringResource(io.github.aedev.flow.R.string.neural_interest_map_subtitle), secFlowEngine, onNavigateToPersonality), SettingSearchEntry(Icons.Outlined.Palette, androidx.compose.ui.res.stringResource(io.github.aedev.flow.R.string.settings_item_theme), "", secAppearance, onNavigateToAppearance), + SettingSearchEntry(Icons.Outlined.Tv, androidx.compose.ui.res.stringResource(io.github.aedev.flow.R.string.settings_item_interface_mode), androidx.compose.ui.res.stringResource(io.github.aedev.flow.R.string.settings_item_interface_mode_subtitle), secAppearance) { showInterfaceModeDialog = true }, SettingSearchEntry(Icons.Outlined.Language, androidx.compose.ui.res.stringResource(io.github.aedev.flow.R.string.settings_item_app_language), currentAppLanguageLabel, secAppearance) { showAppLanguageDialog = true }, SettingSearchEntry(Icons.Outlined.AppShortcut, androidx.compose.ui.res.stringResource(io.github.aedev.flow.R.string.settings_item_app_icon), androidx.compose.ui.res.stringResource(io.github.aedev.flow.R.string.settings_item_app_icon_subtitle), secAppearance, onNavigateToAppIconPicker), SettingSearchEntry(Icons.Outlined.Tune, androidx.compose.ui.res.stringResource(io.github.aedev.flow.R.string.settings_item_player_appearance), androidx.compose.ui.res.stringResource(io.github.aedev.flow.R.string.settings_item_player_appearance_subtitle), secAppearance, onNavigateToPlayerAppearance), @@ -718,6 +734,13 @@ item { onClick = onNavigateToAppearance ) HorizontalDivider(Modifier.padding(start = 56.dp), color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)) + SettingsItem( + icon = Icons.Outlined.Tv, + title = androidx.compose.ui.res.stringResource(io.github.aedev.flow.R.string.settings_item_interface_mode), + subtitle = androidx.compose.ui.res.stringResource(io.github.aedev.flow.R.string.settings_item_interface_mode_subtitle), + onClick = { showInterfaceModeDialog = true } + ) + HorizontalDivider(Modifier.padding(start = 56.dp), color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)) SettingsItem( icon = Icons.Outlined.Language, title = androidx.compose.ui.res.stringResource(io.github.aedev.flow.R.string.settings_item_app_language), 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..18708bbe --- /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.focus.onFocusChanged +import androidx.compose.ui.graphics.graphicsLayer +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..ad7d9f5e --- /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.inverseOnSurface, + 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