## Description Adds a new playback "backend" to play media in another app such as VLC. By default, Wholphin will use the system default app. Typically if you haven't chosen one, a dialog will show to pick which app. You can also set a specific external app to use in Wholphin independent of the system default. Currently, this only support single item playback, ie playing next up episodes is not supported yet. Also since there is no standardized way to send resume position, external subtitle info, etc, Wholphin makes a best effort. VLC, mpv-android, & MX-Player are specifically tested/supported. ### Related issues Closes #85 ### Testing Emulator & nvidia shield w/ VLC, mpv-android, & MX-player ## Screenshots <img width="543" height="407" alt="image" src="https://github.com/user-attachments/assets/ab37d41e-2909-40ed-b537-191ebb54d979" /> ## AI or LLM usage None
481 lines
19 KiB
Kotlin
481 lines
19 KiB
Kotlin
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.AppPreference
|
|
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.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()
|
|
|
|
@Inject
|
|
lateinit var userPreferencesDataStore: DataStore<AppPreferences>
|
|
|
|
@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<List<Destination>>(backStackStr)
|
|
if (!savedInstanceState.getBoolean(KEY_EXTERNAL_PLAYER)) {
|
|
val lastDest = backStack.lastOrNull()
|
|
if (lastDest is Destination.Playback ||
|
|
lastDest is Destination.PlaybackList ||
|
|
lastDest is Destination.Slideshow
|
|
) {
|
|
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(
|
|
diskCacheSizeBytes =
|
|
appPreferences.advancedPreferences.imageDiskCacheSizeBytes.let {
|
|
if (it < AppPreference.ImageDiskCacheSize.min * AppPreference.MEGA_BIT) {
|
|
AppPreference.ImageDiskCacheSize.defaultValue * AppPreference.MEGA_BIT
|
|
} else {
|
|
it
|
|
}
|
|
},
|
|
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()
|
|
}
|
|
|
|
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<AppPreferences>,
|
|
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()
|
|
}
|
|
}
|
|
}
|