Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">

<uses-feature android:name="android.software.leanback" android:required="false" />
<uses-feature android:name="android.hardware.touchscreen" android:required="false" />

<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
Expand Down Expand Up @@ -52,6 +55,7 @@
<application
android:name=".FlowApplication"
android:allowBackup="true"
android:banner="@drawable/tv_banner"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
Expand Down Expand Up @@ -84,6 +88,19 @@
android:name="com.google.android.gms.car.application"
android:resource="@xml/automotive_app_desc" />

<activity
android:name=".TvActivity"
android:banner="@drawable/tv_banner"
android:exported="true"
android:launchMode="singleTop"
android:screenOrientation="landscape"
android:theme="@style/Theme.Flow.Starting">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
</intent-filter>
</activity>

<activity
android:name=".MainActivity"
android:exported="true"
Expand Down
127 changes: 77 additions & 50 deletions app/src/main/java/io/github/aedev/flow/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,10 @@ import androidx.compose.runtime.*
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
import io.github.aedev.flow.data.local.LocalDataManager
import io.github.aedev.flow.data.local.AppUiModePreferences
import io.github.aedev.flow.player.GlobalPlayerState
import io.github.aedev.flow.ui.FlowApp
import io.github.aedev.flow.ui.theme.FlowTheme
Expand All @@ -44,7 +46,11 @@ import io.github.aedev.flow.utils.UpdateManager
import io.github.aedev.flow.utils.UpdateInfo
import io.github.aedev.flow.network.AppProxyManager
import io.github.aedev.flow.player.PictureInPictureHelper
import io.github.aedev.flow.platform.AppUiMode
import io.github.aedev.flow.platform.AppUiRoot
import io.github.aedev.flow.platform.DeviceFormFactorDetector
import io.github.aedev.flow.ui.components.UpdateDialog
import io.github.aedev.flow.ui.tv.FlowTvApp
import io.github.aedev.flow.BuildConfig
import androidx.activity.SystemBarStyle
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
Expand Down Expand Up @@ -78,6 +84,7 @@ class MainActivity : ComponentActivity() {

private var pipDismissCheckJob: Job? = null
private var pendingAutoPip = false
private var cachedAppUiRoot = AppUiRoot.MOBILE

private fun videoPlaybackStateName(state: Int?): String = when (state) {
androidx.media3.common.Player.STATE_IDLE -> "IDLE"
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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()
}
Expand All @@ -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
Expand Down Expand Up @@ -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) {
Expand Down
18 changes: 18 additions & 0 deletions app/src/main/java/io/github/aedev/flow/TvActivity.kt
Original file line number Diff line number Diff line change
@@ -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()
}
}
Original file line number Diff line number Diff line change
@@ -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<Preferences> 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<AppUiMode> = 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")
}
}
25 changes: 25 additions & 0 deletions app/src/main/java/io/github/aedev/flow/platform/AppUiMode.kt
Original file line number Diff line number Diff line change
@@ -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,
}
Original file line number Diff line number Diff line change
@@ -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
}
}
Original file line number Diff line number Diff line change
@@ -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))
}
},
)
}
Loading
Loading