package com.github.damontecres.wholphin import android.content.Context import android.content.Intent import android.content.res.Configuration import android.os.Bundle import android.view.KeyEvent import android.view.WindowManager import androidx.activity.compose.setContent import androidx.activity.viewModels import androidx.appcompat.app.AppCompatActivity import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState 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.Color import androidx.datastore.core.DataStore import androidx.lifecycle.ViewModel import androidx.lifecycle.lifecycleScope import androidx.lifecycle.viewModelScope import androidx.navigation3.runtime.NavBackStack import androidx.tv.material3.ExperimentalTvMaterial3Api import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.preferences.PlayerBackend import com.github.damontecres.wholphin.services.AppUpgradeHandler import com.github.damontecres.wholphin.services.BackdropService import com.github.damontecres.wholphin.services.DatePlayedInvalidationService import com.github.damontecres.wholphin.services.DeviceProfileService import com.github.damontecres.wholphin.services.ImageUrlService import com.github.damontecres.wholphin.services.LatestNextUpSchedulerService import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.services.PlaybackLifecycleObserver import com.github.damontecres.wholphin.services.RefreshRateService import com.github.damontecres.wholphin.services.ScreensaverService import com.github.damontecres.wholphin.services.ServerEventListener import com.github.damontecres.wholphin.services.SetupDestination import com.github.damontecres.wholphin.services.SetupNavigationManager import com.github.damontecres.wholphin.services.SuggestionsSchedulerService import com.github.damontecres.wholphin.services.UpdateChecker import com.github.damontecres.wholphin.services.UserSwitchListener import com.github.damontecres.wholphin.services.hilt.AuthOkHttpClient import com.github.damontecres.wholphin.services.tvprovider.TvProviderSchedulerService import com.github.damontecres.wholphin.ui.CoilConfig import com.github.damontecres.wholphin.ui.LocalImageUrlService import com.github.damontecres.wholphin.ui.components.LoadingPage import com.github.damontecres.wholphin.ui.detail.series.SeasonEpisodeIds import com.github.damontecres.wholphin.ui.launchDefault import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.playback.PlayExternalViewModel import com.github.damontecres.wholphin.ui.showToast import com.github.damontecres.wholphin.ui.theme.WholphinTheme import com.github.damontecres.wholphin.ui.util.ProvideLocalClock import com.github.damontecres.wholphin.util.DebugLogTree import com.github.damontecres.wholphin.util.ExceptionHandler import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withContext import kotlinx.serialization.json.Json import okhttp3.OkHttpClient import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.serializer.toUUIDOrNull import timber.log.Timber import javax.inject.Inject @AndroidEntryPoint class MainActivity : AppCompatActivity() { private val viewModel: MainActivityViewModel by viewModels() private val playExternalViewModel: PlayExternalViewModel by viewModels() @Inject lateinit var userPreferencesDataStore: DataStore @AuthOkHttpClient @Inject lateinit var okHttpClient: OkHttpClient @Inject lateinit var navigationManager: NavigationManager @Inject lateinit var setupNavigationManager: SetupNavigationManager @Inject lateinit var updateChecker: UpdateChecker @Inject lateinit var playbackLifecycleObserver: PlaybackLifecycleObserver @Inject lateinit var imageUrlService: ImageUrlService @Inject lateinit var refreshRateService: RefreshRateService @Inject lateinit var userSwitchListener: UserSwitchListener @Inject lateinit var tvProviderSchedulerService: TvProviderSchedulerService @Inject lateinit var suggestionsSchedulerService: SuggestionsSchedulerService @Inject lateinit var latestNextUpSchedulerService: LatestNextUpSchedulerService @Inject lateinit var backdropService: BackdropService // Note: unused but injected to ensure it is created @Inject lateinit var serverEventListener: ServerEventListener // Note: unused but injected to ensure it is created @Inject lateinit var datePlayedInvalidationService: DatePlayedInvalidationService @Inject lateinit var screensaverService: ScreensaverService private var signInAuto = true private val json = Json { classDiscriminator = "_type" } @OptIn(ExperimentalTvMaterial3Api::class) override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) instance = this Timber.i("MainActivity.onCreate: savedInstanceState is null=${savedInstanceState == null}") lifecycle.addObserver(playbackLifecycleObserver) val backStackStr = savedInstanceState?.getString(KEY_BACK_STACK) if (backStackStr != null) { Timber.d("Restoring back stack") var backStack = json.decodeFromString>(backStackStr) if (!playExternalViewModel.launched.value) { val lastDest = backStack.lastOrNull() if (lastDest.isPlayback) { Timber.v("Restoring back stack with playback") backStack = backStack.toMutableList().apply { removeAt(lastIndex) } } } navigationManager.backStack = NavBackStack(*backStack.toTypedArray()) } else { val startDestination = intent?.let(::extractDestination) ?: Destination.Home() navigationManager.backStack = NavBackStack(startDestination) } viewModel.serverRepository.currentUser.observe(this) { user -> if (user?.hasPin == true) { window?.setFlags( WindowManager.LayoutParams.FLAG_SECURE, WindowManager.LayoutParams.FLAG_SECURE, ) } else { window?.clearFlags(WindowManager.LayoutParams.FLAG_SECURE) } } screensaverService.keepScreenOn .onEach { keepScreenOn -> Timber.v("keepScreenOn: %s", keepScreenOn) withContext(Dispatchers.Main) { if (keepScreenOn) { window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) } else { window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) } } }.catch { ex -> Timber.e(ex, "Error with keepScreenOn") }.launchIn(lifecycleScope) viewModel.appStart() setContent { val appPreferences by userPreferencesDataStore.data.collectAsState(null) if (appPreferences == null) { // Show loading page if it is taking a while to get app preferences var showLoading by remember { mutableStateOf(false) } LaunchedEffect(Unit) { delay(500) Timber.i("Showing loading page") showLoading = true } if (showLoading) { Box( modifier = Modifier .fillMaxSize() .background(Color.Black), ) { LoadingPage() } } } appPreferences?.let { appPreferences -> LaunchedEffect(appPreferences.signInAutomatically) { signInAuto = appPreferences.signInAutomatically } CoilConfig( prefs = appPreferences, okHttpClient = okHttpClient, debugLogging = false, enableCache = true, ) LaunchedEffect(appPreferences.debugLogging) { DebugLogTree.INSTANCE.enabled = appPreferences.debugLogging } CompositionLocalProvider(LocalImageUrlService provides imageUrlService) { WholphinTheme( true, appThemeColors = appPreferences.interfacePreferences.appThemeColors, ) { ProvideLocalClock { MainContent( backStack = setupNavigationManager.backStack, navigationManager = navigationManager, appPreferences = appPreferences, backdropService = backdropService, screensaverService = screensaverService, modifier = Modifier.fillMaxSize(), ) } } } } } } override fun dispatchKeyEvent(event: KeyEvent): Boolean { if (screensaverService.state.value.show) { screensaverService.stop(false) screensaverService.pulse() return true } else { screensaverService.pulse() return super.dispatchKeyEvent(event) } } override fun onResume() { super.onResume() Timber.d("onResume") lifecycleScope.launchDefault { screensaverService.pulse() } } override fun onRestart() { super.onRestart() Timber.d("onRestart") viewModel.appStart() if (!playExternalViewModel.launched.value) { // If restarting during playback that is not external, go back a page val lastDest = navigationManager.backStack.lastOrNull() if (lastDest.isPlayback) { Timber.v("onRestart: go back from playback") navigationManager.goBack() } } } override fun onStop() { super.onStop() Timber.d("onStop") screensaverService.stop(true) tvProviderSchedulerService.launchOneTimeRefresh() } override fun onPause() { super.onPause() Timber.d("onPause") } override fun onStart() { super.onStart() Timber.d("onStart") lifecycleScope.launchDefault { val appPreferences = userPreferencesDataStore.data.first() if (UpdateChecker.ACTIVE && appPreferences.autoCheckForUpdates) { try { updateChecker.maybeShowUpdateToast( appPreferences.updateUrl, ) } catch (ex: Exception) { Timber.w( ex, "Exception during update check", ) } } } } override fun onSaveInstanceState(outState: Bundle) { super.onSaveInstanceState(outState) Timber.d("onSaveInstanceState") val str = json.encodeToString(navigationManager.backStack.toList()) outState.putString(KEY_BACK_STACK, str) val playerBackend = runBlocking { userPreferencesDataStore.data.firstOrNull() }?.playbackPreferences?.playerBackend outState.putBoolean(KEY_EXTERNAL_PLAYER, playerBackend == PlayerBackend.EXTERNAL_PLAYER) } override fun onRestoreInstanceState(savedInstanceState: Bundle) { super.onRestoreInstanceState(savedInstanceState) Timber.d("onRestoreInstanceState") } override fun onDestroy() { super.onDestroy() Timber.d("onDestroy") } override fun onConfigurationChanged(newConfig: Configuration) { super.onConfigurationChanged(newConfig) Timber.d("onConfigurationChanged") } override fun onNewIntent(intent: Intent) { super.onNewIntent(intent) Timber.v("onNewIntent") setIntent(intent) extractDestination(intent)?.let { navigationManager.replace(it) } } private fun extractDestination(intent: Intent): Destination? = intent.let { val itemId = it.getStringExtra(INTENT_ITEM_ID)?.toUUIDOrNull() val type = it.getStringExtra(INTENT_ITEM_TYPE)?.let(BaseItemKind::fromNameOrNull) if (itemId != null && type != null) { val seriesId = it.getStringExtra(INTENT_SERIES_ID)?.toUUIDOrNull() val seasonId = it.getStringExtra(INTENT_SEASON_ID)?.toUUIDOrNull() val episodeNumber = it.getIntExtra(INTENT_EPISODE_NUMBER, -1) val seasonNumber = it.getIntExtra(INTENT_SEASON_NUMBER, -1) if (seriesId != null && seasonId != null && episodeNumber >= 0 && seasonNumber >= 0) { Destination.SeriesOverview( itemId = seriesId, type = BaseItemKind.SERIES, seasonEpisode = SeasonEpisodeIds( seasonId = seasonId, seasonNumber = seasonNumber, episodeId = itemId, episodeNumber = episodeNumber, ), ) } else { Destination.MediaItem(itemId, type) } } else { null } } fun changeDisplayMode(modeId: Int) { lifecycleScope.launch(Dispatchers.Main + ExceptionHandler(autoToast = true)) { val attrs = window.attributes if (attrs.preferredDisplayModeId != modeId) { Timber.d("Switch preferredDisplayModeId to %s", modeId) window.attributes = attrs.apply { preferredDisplayModeId = modeId } } } } companion object { const val INTENT_ITEM_ID = "itemId" const val INTENT_ITEM_TYPE = "itemType" const val INTENT_SERIES_ID = "seriesId" const val INTENT_EPISODE_NUMBER = "epNum" const val INTENT_SEASON_NUMBER = "seaNum" const val INTENT_SEASON_ID = "seaId" private const val KEY_BACK_STACK = "backStack" private const val KEY_EXTERNAL_PLAYER = "extPlayer" lateinit var instance: MainActivity private set } } @HiltViewModel class MainActivityViewModel @Inject constructor( @param:ApplicationContext private val context: Context, private val preferences: DataStore, val serverRepository: ServerRepository, private val navigationManager: SetupNavigationManager, private val deviceProfileService: DeviceProfileService, private val backdropService: BackdropService, private val appUpgradeHandler: AppUpgradeHandler, ) : ViewModel() { fun appStart() { viewModelScope.launchDefault { try { val needUpgrade = appUpgradeHandler.needUpgrade() if (needUpgrade) { showToast( context, context.getString( R.string.updated_toast, appUpgradeHandler.currentVersion.toString(), ), ) appUpgradeHandler.run() } appUpgradeHandler.copySubfont(false) val prefs = preferences.data.firstOrNull() ?: AppPreferences.getDefaultInstance() val userHasPin = serverRepository.currentUser.value?.hasPin == true if (prefs.signInAutomatically && !userHasPin) { val current = serverRepository.restoreSession( prefs.currentServerId?.toUUIDOrNull(), prefs.currentUserId?.toUUIDOrNull(), ) if (current != null) { if (current.user.hasPin) { navigationManager.navigateTo(SetupDestination.UserList(current.server)) } else { // Restored navigationManager.navigateTo(SetupDestination.AppContent(current)) } } else { // Did not restore navigationManager.navigateTo(SetupDestination.ServerList) } } else { navigationManager.navigateTo(SetupDestination.Loading) backdropService.clearBackdrop() val currentServerId = prefs.currentServerId?.toUUIDOrNull() if (currentServerId != null) { val currentServer = serverRepository.serverDao.getServer(currentServerId)?.server if (currentServer != null) { navigationManager.navigateTo(SetupDestination.UserList(currentServer)) } else { navigationManager.navigateTo(SetupDestination.ServerList) } } else { navigationManager.navigateTo(SetupDestination.ServerList) } } } catch (ex: Exception) { Timber.e(ex, "Error during appStart") navigationManager.navigateTo(SetupDestination.ServerList) } } viewModelScope.launchDefault { // Create the mediaCodecCapabilitiesTest if needed deviceProfileService.mediaCodecCapabilitiesTest.supportsAVC() } } } private val Destination?.isPlayback: Boolean get() = this is Destination.Playback || this is Destination.PlaybackList || this is Destination.Slideshow