Merge branch 'main' into fea/jellyseerr

This commit is contained in:
Damontecres 2025-12-23 13:11:24 -05:00
commit a2884a7225
No known key found for this signature in database
37 changed files with 698 additions and 370 deletions

View file

@ -2,6 +2,7 @@ package com.github.damontecres.wholphin
import android.os.Bundle import android.os.Bundle
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
@ -12,18 +13,19 @@ import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.datastore.core.DataStore import androidx.datastore.core.DataStore
import androidx.lifecycle.ViewModel
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.navigation3.runtime.rememberNavBackStack import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator
import androidx.navigation3.runtime.NavEntry
import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator
import androidx.navigation3.ui.NavDisplay
import androidx.tv.material3.ExperimentalTvMaterial3Api import androidx.tv.material3.ExperimentalTvMaterial3Api
import androidx.tv.material3.MaterialTheme import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Surface import androidx.tv.material3.Surface
@ -33,24 +35,30 @@ import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.preferences.DefaultUserConfiguration import com.github.damontecres.wholphin.preferences.DefaultUserConfiguration
import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.services.AppUpgradeHandler import com.github.damontecres.wholphin.services.AppUpgradeHandler
import com.github.damontecres.wholphin.services.BackdropService
import com.github.damontecres.wholphin.services.DeviceProfileService import com.github.damontecres.wholphin.services.DeviceProfileService
import com.github.damontecres.wholphin.services.ImageUrlService import com.github.damontecres.wholphin.services.ImageUrlService
import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.services.PlaybackLifecycleObserver import com.github.damontecres.wholphin.services.PlaybackLifecycleObserver
import com.github.damontecres.wholphin.services.RefreshRateService import com.github.damontecres.wholphin.services.RefreshRateService
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.UpdateChecker import com.github.damontecres.wholphin.services.UpdateChecker
import com.github.damontecres.wholphin.services.hilt.AuthOkHttpClient import com.github.damontecres.wholphin.services.hilt.AuthOkHttpClient
import com.github.damontecres.wholphin.ui.CoilConfig import com.github.damontecres.wholphin.ui.CoilConfig
import com.github.damontecres.wholphin.ui.LocalImageUrlService import com.github.damontecres.wholphin.ui.LocalImageUrlService
import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.nav.ApplicationContent import com.github.damontecres.wholphin.ui.nav.ApplicationContent
import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.setup.SwitchServerContent
import com.github.damontecres.wholphin.ui.setup.SwitchUserContent
import com.github.damontecres.wholphin.ui.theme.WholphinTheme import com.github.damontecres.wholphin.ui.theme.WholphinTheme
import com.github.damontecres.wholphin.ui.util.ProvideLocalClock import com.github.damontecres.wholphin.ui.util.ProvideLocalClock
import com.github.damontecres.wholphin.util.DebugLogTree import com.github.damontecres.wholphin.util.DebugLogTree
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import org.jellyfin.sdk.model.serializer.toUUIDOrNull import org.jellyfin.sdk.model.serializer.toUUIDOrNull
@ -59,8 +67,7 @@ import javax.inject.Inject
@AndroidEntryPoint @AndroidEntryPoint
class MainActivity : AppCompatActivity() { class MainActivity : AppCompatActivity() {
@Inject private val viewModel: MainActivityViewModel by viewModels()
lateinit var serverRepository: ServerRepository
@Inject @Inject
lateinit var userPreferencesDataStore: DataStore<AppPreferences> lateinit var userPreferencesDataStore: DataStore<AppPreferences>
@ -72,27 +79,26 @@ class MainActivity : AppCompatActivity() {
@Inject @Inject
lateinit var navigationManager: NavigationManager lateinit var navigationManager: NavigationManager
@Inject
lateinit var setupNavigationManager: SetupNavigationManager
@Inject @Inject
lateinit var updateChecker: UpdateChecker lateinit var updateChecker: UpdateChecker
@Inject @Inject
lateinit var appUpgradeHandler: AppUpgradeHandler lateinit var appUpgradeHandler: AppUpgradeHandler
@Inject
lateinit var serverEventListener: ServerEventListener
@Inject @Inject
lateinit var playbackLifecycleObserver: PlaybackLifecycleObserver lateinit var playbackLifecycleObserver: PlaybackLifecycleObserver
@Inject
lateinit var deviceProfileService: DeviceProfileService
@Inject @Inject
lateinit var imageUrlService: ImageUrlService lateinit var imageUrlService: ImageUrlService
@Inject @Inject
lateinit var refreshRateService: RefreshRateService lateinit var refreshRateService: RefreshRateService
private var signInAuto = true
@OptIn(ExperimentalTvMaterial3Api::class) @OptIn(ExperimentalTvMaterial3Api::class)
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
@ -109,9 +115,13 @@ class MainActivity : AppCompatActivity() {
window.attributes = attrs.apply { preferredRefreshRate = mode.refreshRate } window.attributes = attrs.apply { preferredRefreshRate = mode.refreshRate }
} }
} }
viewModel.appStart()
setContent { setContent {
val appPreferences by userPreferencesDataStore.data.collectAsState(null) val appPreferences by userPreferencesDataStore.data.collectAsState(null)
appPreferences?.let { appPreferences -> appPreferences?.let { appPreferences ->
LaunchedEffect(appPreferences.signInAutomatically) {
signInAuto = appPreferences.signInAutomatically
}
CoilConfig( CoilConfig(
diskCacheSizeBytes = diskCacheSizeBytes =
appPreferences.advancedPreferences.imageDiskCacheSizeBytes.let { appPreferences.advancedPreferences.imageDiskCacheSizeBytes.let {
@ -140,100 +150,85 @@ class MainActivity : AppCompatActivity() {
.background(MaterialTheme.colorScheme.background), .background(MaterialTheme.colorScheme.background),
shape = RectangleShape, shape = RectangleShape,
) { ) {
var isRestoringSession by remember { mutableStateOf(true) } // val backStack = rememberNavBackStack(SetupDestination.Loading)
LaunchedEffect(Unit) { // setupNavigationManager.backStack = backStack
// TODO PIN-related val backStack = setupNavigationManager.backStack
// if (appPreferences.signInAutomatically) { NavDisplay(
try { backStack = backStack,
serverRepository.restoreSession( onBack = { backStack.removeLastOrNull() },
appPreferences.currentServerId?.toUUIDOrNull(), entryDecorators =
appPreferences.currentUserId?.toUUIDOrNull(), listOf(
) rememberSaveableStateHolderNavEntryDecorator(),
} catch (ex: Exception) { rememberViewModelStoreNavEntryDecorator(),
Timber.e(ex, "Exception restoring session") ),
} entryProvider = { key ->
// } key as SetupDestination
isRestoringSession = false NavEntry(key) {
} when (key) {
val current by serverRepository.current.observeAsState() SetupDestination.Loading -> {
Box(
modifier = Modifier.size(200.dp),
contentAlignment = Alignment.Center,
) {
CircularProgressIndicator(
color = MaterialTheme.colorScheme.border,
modifier = Modifier.align(Alignment.Center),
)
}
}
val preferences = SetupDestination.ServerList -> {
UserPreferences( SwitchServerContent(Modifier.fillMaxSize())
appPreferences, }
current?.userDto?.configuration ?: DefaultUserConfiguration,
)
if (isRestoringSession) { is SetupDestination.UserList -> {
Box( SwitchUserContent(
modifier = Modifier.size(200.dp), currentServer = key.server,
contentAlignment = Alignment.Center, Modifier.fillMaxSize(),
) { )
CircularProgressIndicator( }
color = MaterialTheme.colorScheme.border,
modifier = Modifier.align(Alignment.Center),
)
}
} else {
// TODO PIN-related
// DisposableEffect(Unit) {
// onDispose {
// if (!appPreferences.signInAutomatically || current?.user?.hasPin == true) {
// serverRepository.closeSession()
// }
// }
// }
key(current?.server?.id, current?.user?.id) {
// TODO PIN-related
// LaunchedEffect(current?.user?.pin) {
// if (current?.user?.pin?.isNotNullOrBlank() == true) {
// // If user has a pin, then obscure the window in previews
// window?.setFlags(
// WindowManager.LayoutParams.FLAG_SECURE,
// WindowManager.LayoutParams.FLAG_SECURE,
// )
// } else {
// window?.clearFlags(WindowManager.LayoutParams.FLAG_SECURE)
// }
// }
val initialDestination =
when {
current != null -> Destination.Home()
// TODO PIN-related is SetupDestination.AppContent -> {
// !appPreferences.signInAutomatically -> Destination.ServerList val current = key.current
ProvideLocalClock {
else -> Destination.ServerList if (UpdateChecker.ACTIVE && appPreferences.autoCheckForUpdates) {
} LaunchedEffect(Unit) {
val backStack = rememberNavBackStack(initialDestination) try {
navigationManager.backStack = backStack updateChecker.maybeShowUpdateToast(
if (UpdateChecker.ACTIVE && appPreferences.autoCheckForUpdates) { appPreferences.updateUrl,
LaunchedEffect(Unit) { )
try { } catch (ex: Exception) {
updateChecker.maybeShowUpdateToast(appPreferences.updateUrl) Timber.w(
} catch (ex: Exception) { ex,
Timber.w(ex, "Failed to check for update") "Exception during update check",
)
}
}
}
val appPreferences by userPreferencesDataStore.data.collectAsState(
appPreferences,
)
val preferences =
remember(appPreferences, current) {
UserPreferences(
appPreferences,
current.userDto.configuration
?: DefaultUserConfiguration,
)
}
ApplicationContent(
user = current.user,
server = current.server,
navigationManager = navigationManager,
preferences = preferences,
modifier = Modifier.fillMaxSize(),
)
}
} }
} }
} }
LaunchedEffect(current, preferences) { },
withContext(Dispatchers.IO) { )
deviceProfileService.getOrCreateDeviceProfile(
preferences.appPreferences.playbackPreferences,
current?.server?.serverVersion,
)
}
}
ProvideLocalClock {
ApplicationContent(
user = current?.user,
server = current?.server,
navigationManager = navigationManager,
preferences = preferences,
modifier = Modifier.fillMaxSize(),
)
}
}
}
} }
} }
} }
@ -250,12 +245,69 @@ class MainActivity : AppCompatActivity() {
override fun onRestart() { override fun onRestart() {
super.onRestart() super.onRestart()
// TODO PIN-related Timber.i("onRestart")
viewModel.appStart()
// val signInAutomatically = // val signInAutomatically =
// runBlocking { userPreferencesDataStore.data.firstOrNull()?.signInAutomatically } ?: true // runBlocking { userPreferencesDataStore.data.firstOrNull()?.signInAutomatically } ?: true
// Timber.i("onRestart: signInAutomatically=$signInAutomatically")
// if (!signInAutomatically || serverRepository.currentUser.value?.hasPin == true) { // // TODO PIN-related
// // if (!signInAutomatically || serverRepository.currentUser.value?.hasPin == true) {
// if (!signInAutomatically) {
// serverRepository.closeSession() // serverRepository.closeSession()
// } // }
} }
} }
@HiltViewModel
class MainActivityViewModel
@Inject
constructor(
private val preferences: DataStore<AppPreferences>,
private val serverRepository: ServerRepository,
private val navigationManager: SetupNavigationManager,
private val deviceProfileService: DeviceProfileService,
private val backdropService: BackdropService,
) : ViewModel() {
fun appStart() {
viewModelScope.launch {
val prefs = preferences.data.firstOrNull() ?: AppPreferences.getDefaultInstance()
if (prefs.signInAutomatically) {
val current =
withContext(Dispatchers.IO) {
serverRepository.restoreSession(
prefs.currentServerId?.toUUIDOrNull(),
prefs.currentUserId?.toUUIDOrNull(),
)
}
if (current != null) {
// 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 =
withContext(Dispatchers.IO) {
serverRepository.serverDao.getServer(currentServerId)?.server
}
if (currentServer != null) {
navigationManager.navigateTo(SetupDestination.UserList(currentServer))
} else {
navigationManager.navigateTo(SetupDestination.ServerList)
}
} else {
navigationManager.navigateTo(SetupDestination.ServerList)
}
}
}
viewModelScope.launchIO {
// Create the mediaCodecCapabilitiesTest if needed
deviceProfileService.mediaCodecCapabilitiesTest.supportsAVC()
}
}
}

View file

@ -15,6 +15,7 @@ import com.github.damontecres.wholphin.util.EqualityMutableLiveData
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.serialization.Serializable
import org.jellyfin.sdk.Jellyfin import org.jellyfin.sdk.Jellyfin
import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.systemApi import org.jellyfin.sdk.api.client.extensions.systemApi
@ -68,44 +69,46 @@ class ServerRepository
suspend fun changeUser( suspend fun changeUser(
server: JellyfinServer, server: JellyfinServer,
user: JellyfinUser, user: JellyfinUser,
) = withContext(Dispatchers.IO) { ): CurrentUser? =
if (server.id != user.serverId) { withContext(Dispatchers.IO) {
throw IllegalStateException("User is not part of the server") if (server.id != user.serverId) {
} throw IllegalStateException("User is not part of the server")
Timber.v("Changing user to ${user.name} on ${server.url}")
apiClient.update(baseUrl = server.url, accessToken = user.accessToken)
val userDto by apiClient.userApi.getCurrentUser()
val updatedServer =
try {
val sysInfo by apiClient.systemApi.getPublicSystemInfo()
server.copy(name = sysInfo.serverName, version = sysInfo.version)
} catch (ex: Exception) {
Timber.w(ex, "Exception fetching public system info")
server
} }
var updatedUser = Timber.v("Changing user to ${user.name} on ${server.url}")
user.copy( apiClient.update(baseUrl = server.url, accessToken = user.accessToken)
id = userDto.id, val userDto by apiClient.userApi.getCurrentUser()
name = userDto.name, val updatedServer =
) try {
serverDao.addOrUpdateServer(updatedServer) val sysInfo by apiClient.systemApi.getPublicSystemInfo()
updatedUser = serverDao.addOrUpdateUser(updatedUser) server.copy(name = sysInfo.serverName, version = sysInfo.version)
userPreferencesDataStore.updateData { } catch (ex: Exception) {
it Timber.w(ex, "Exception fetching public system info")
.toBuilder() server
.apply { }
currentServerId = updatedServer.id.toServerString() var updatedUser =
currentUserId = updatedUser.id.toServerString() user.copy(
}.build() id = userDto.id,
name = userDto.name,
)
serverDao.addOrUpdateServer(updatedServer)
updatedUser = serverDao.addOrUpdateUser(updatedUser)
userPreferencesDataStore.updateData {
it
.toBuilder()
.apply {
currentServerId = updatedServer.id.toServerString()
currentUserId = updatedUser.id.toServerString()
}.build()
}
withContext(Dispatchers.Main) {
_current.value = CurrentUser(updatedServer, updatedUser, userDto)
}
sharedPreferences.edit(true) {
putString(SERVER_URL_KEY, updatedServer.url)
putString(ACCESS_TOKEN_KEY, updatedUser.accessToken)
}
return@withContext _current.value
} }
withContext(Dispatchers.Main) {
_current.value = CurrentUser(updatedServer, updatedUser, userDto)
}
sharedPreferences.edit(true) {
putString(SERVER_URL_KEY, updatedServer.url)
putString(ACCESS_TOKEN_KEY, updatedUser.accessToken)
}
}
/** /**
* Restores a session for the given server & user such as when the app reopens * Restores a session for the given server & user such as when the app reopens
@ -115,10 +118,10 @@ class ServerRepository
suspend fun restoreSession( suspend fun restoreSession(
serverId: UUID?, serverId: UUID?,
userId: UUID?, userId: UUID?,
): Boolean { ): CurrentUser? {
if (serverId == null || userId == null) { if (serverId == null || userId == null) {
_current.setValueOnMain(null) _current.setValueOnMain(null)
return false return null
} }
val serverAndUsers = val serverAndUsers =
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
@ -129,13 +132,17 @@ class ServerRepository
if (user != null) { if (user != null) {
// TODO pin-related // TODO pin-related
// if (user != null && !user.hasPin) { // if (user != null && !user.hasPin) {
changeUser(serverAndUsers.server, user) return changeUser(serverAndUsers.server, user)
return true
} }
} }
return false return null
} }
suspend fun fetchLastUsedServer(serverId: UUID?): JellyfinServer? =
withContext(Dispatchers.IO) {
serverId?.let { serverDao.getServer(serverId)?.server }
}
fun closeSession() { fun closeSession() {
_current.value = null _current.value = null
} }
@ -170,7 +177,7 @@ class ServerRepository
) )
} }
if (user != null) { if (user != null) {
changeUser(server, user) return@withContext changeUser(server, user)
} else { } else {
throw IllegalArgumentException("Authentication result's user was null") throw IllegalArgumentException("Authentication result's user was null")
} }
@ -260,6 +267,7 @@ class ServerRepository
} }
} }
@Serializable
data class CurrentUser( data class CurrentUser(
val server: JellyfinServer, val server: JellyfinServer,
val user: JellyfinUser, val user: JellyfinUser,

View file

@ -14,6 +14,16 @@ import org.jellyfin.sdk.model.api.request.GetPersonsRequest
import org.jellyfin.sdk.model.serializer.UUIDSerializer import org.jellyfin.sdk.model.serializer.UUIDSerializer
import java.util.UUID import java.util.UUID
@Serializable
data class CollectionFolderFilter(
val nameOverride: String? = null,
val filter: GetItemsFilter = GetItemsFilter(),
/**
* Whether to use the libray's saved sort & filter
*/
val useSavedLibraryDisplayInfo: Boolean = true,
)
@Serializable @Serializable
data class GetItemsFilter( data class GetItemsFilter(
val favorite: Boolean? = null, val favorite: Boolean? = null,

View file

@ -41,6 +41,7 @@ data class JellyfinServer(
], ],
indices = [Index("id", "serverId", unique = true)], indices = [Index("id", "serverId", unique = true)],
) )
@Serializable
data class JellyfinUser( data class JellyfinUser(
@PrimaryKey(autoGenerate = true) @PrimaryKey(autoGenerate = true)
val rowId: Int = 0, val rowId: Int = 0,

View file

@ -851,8 +851,7 @@ val basicPreferences =
title = R.string.ui_interface, title = R.string.ui_interface,
preferences = preferences =
listOf( listOf(
// TODO PIN-related AppPreference.SignInAuto,
// AppPreference.SignInAuto,
AppPreference.HomePageItems, AppPreference.HomePageItems,
AppPreference.CombineContinueNext, AppPreference.CombineContinueNext,
AppPreference.RewatchNextUp, AppPreference.RewatchNextUp,

View file

@ -20,7 +20,7 @@ class DeviceProfileService
constructor( constructor(
@param:ApplicationContext private val context: Context, @param:ApplicationContext private val context: Context,
) { ) {
private val mediaCodecCapabilitiesTest by lazy { val mediaCodecCapabilitiesTest by lazy {
// Created lazily below on the IO thread since it cn take time // Created lazily below on the IO thread since it cn take time
MediaCodecCapabilitiesTest(context) MediaCodecCapabilitiesTest(context)
} }

View file

@ -29,12 +29,7 @@ class NavigationManager
*/ */
fun navigateToFromDrawer(destination: Destination) { fun navigateToFromDrawer(destination: Destination) {
goToHome() goToHome()
if (destination == Destination.ServerList || destination is Destination.UserList) { backStack.add(destination)
backStack.add(destination)
(0..<backStack.size - 1).forEach { _ -> backStack.removeAt(0) }
} else {
backStack.add(destination)
}
log() log()
} }

View file

@ -10,6 +10,7 @@ import com.github.damontecres.wholphin.data.model.PlaylistInfo
import com.github.damontecres.wholphin.ui.DefaultItemFields import com.github.damontecres.wholphin.ui.DefaultItemFields
import com.github.damontecres.wholphin.ui.components.baseItemKinds import com.github.damontecres.wholphin.ui.components.baseItemKinds
import com.github.damontecres.wholphin.ui.data.SortAndDirection import com.github.damontecres.wholphin.ui.data.SortAndDirection
import com.github.damontecres.wholphin.ui.gt
import com.github.damontecres.wholphin.ui.indexOfFirstOrNull import com.github.damontecres.wholphin.ui.indexOfFirstOrNull
import com.github.damontecres.wholphin.ui.playback.playable import com.github.damontecres.wholphin.ui.playback.playable
import com.github.damontecres.wholphin.ui.toServerString import com.github.damontecres.wholphin.ui.toServerString
@ -22,6 +23,7 @@ import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.playlistsApi import org.jellyfin.sdk.api.client.extensions.playlistsApi
import org.jellyfin.sdk.api.client.extensions.videosApi
import org.jellyfin.sdk.model.api.BaseItemDto import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.CreatePlaylistDto import org.jellyfin.sdk.model.api.CreatePlaylistDto
@ -64,10 +66,14 @@ class PlaylistCreator
sortBy = if (shuffled) ItemSortBy.RANDOM else null, sortBy = if (shuffled) ItemSortBy.RANDOM else null,
limit = Playlist.MAX_SIZE, limit = Playlist.MAX_SIZE,
) )
val episodes = GetEpisodesRequestHandler.execute(api, request).content.items val episodes =
GetEpisodesRequestHandler
.execute(api, request)
.content.items
.convertAndAddParts(false)
val startIndex = val startIndex =
episodeId?.let { episodes.indexOfFirstOrNull { it.id == episodeId } } ?: 0 episodeId?.let { episodes.indexOfFirstOrNull { it.id == episodeId } } ?: 0
return Playlist(episodes.map { BaseItem.from(it, api) }, startIndex) return Playlist(episodes, startIndex)
} }
suspend fun createFromPlaylistId( suspend fun createFromPlaylistId(
@ -82,12 +88,11 @@ class PlaylistCreator
startIndex = startIndex, startIndex = startIndex,
limit = Playlist.MAX_SIZE, limit = Playlist.MAX_SIZE,
) )
val items = GetPlaylistItemsRequestHandler.execute(api, request).content.items var items = GetPlaylistItemsRequestHandler.execute(api, request).content.items
var baseItems = items.map { BaseItem.from(it, api) }
if (shuffled) { if (shuffled) {
baseItems = baseItems.shuffled() items = items.shuffled()
} }
return Playlist(baseItems, 0) return Playlist(items.convertAndAddParts(), 0)
} }
private suspend fun createFromCollection( private suspend fun createFromCollection(
@ -120,19 +125,20 @@ class PlaylistCreator
), ),
) )
val items = val items =
GetItemsRequestHandler.execute(api, request).content.items.map { GetItemsRequestHandler
BaseItem.from(it, api) .execute(api, request)
} .content.items
.convertAndAddParts()
return Playlist(items, 0) return Playlist(items, 0)
} }
suspend fun createFrom( suspend fun createFrom(
item: BaseItemDto, item: BaseItemDto,
startIndex: Int = 0, startIndex: Int = 0,
sortAndDirection: SortAndDirection?, sortAndDirection: SortAndDirection? = SortAndDirection.DEFAULT,
shuffled: Boolean, shuffled: Boolean = false,
recursive: Boolean, recursive: Boolean = false,
filter: GetItemsFilter, filter: GetItemsFilter = GetItemsFilter(),
): PlaylistCreationResult = ): PlaylistCreationResult =
when (item.type) { when (item.type) {
BaseItemKind.BOX_SET, BaseItemKind.BOX_SET,
@ -166,7 +172,7 @@ class PlaylistCreator
), ),
) )
} else { } else {
PlaylistCreationResult.Error(null, "Episode has not seriesId") PlaylistCreationResult.Error(null, "Episode has no seriesId")
} }
} }
@ -182,7 +188,7 @@ class PlaylistCreator
), ),
) )
} else { } else {
PlaylistCreationResult.Error(null, "Episode has not seriesId") PlaylistCreationResult.Error(null, "Episode has no seriesId")
} }
} }
@ -207,6 +213,26 @@ class PlaylistCreator
) )
} }
BaseItemKind.MOVIE,
BaseItemKind.VIDEO,
BaseItemKind.MUSIC_VIDEO,
-> {
val list =
buildList {
add(BaseItem(item, false))
if (item.partCount.gt(1)) {
api.videosApi
.getAdditionalPart(item.id)
.content.items
.map {
BaseItem(it, false)
}.let(::addAll)
}
}
PlaylistCreationResult.Success(Playlist(list, 0))
}
// Not support yet // Not support yet
// BaseItemKind.AGGREGATE_FOLDER -> TODO() // BaseItemKind.AGGREGATE_FOLDER -> TODO()
// BaseItemKind.FOLDER -> TODO() // BaseItemKind.FOLDER -> TODO()
@ -220,6 +246,20 @@ class PlaylistCreator
} }
} }
private suspend fun List<BaseItemDto>.convertAndAddParts(useSeriesForPrimary: Boolean = false): List<BaseItem> =
buildList {
this@convertAndAddParts.forEach { ep ->
add(BaseItem(ep, useSeriesForPrimary))
if (ep.partCount.gt(1)) {
val parts =
api.videosApi.getAdditionalPart(ep.id).content.items.map { part ->
BaseItem(part, useSeriesForPrimary)
}
addAll(parts)
}
}
}
suspend fun getServerPlaylists( suspend fun getServerPlaylists(
mediaType: MediaType?, mediaType: MediaType?,
scope: CoroutineScope, scope: CoroutineScope,

View file

@ -0,0 +1,54 @@
package com.github.damontecres.wholphin.services
import androidx.compose.runtime.mutableStateListOf
import androidx.navigation3.runtime.NavKey
import com.github.damontecres.wholphin.data.CurrentUser
import com.github.damontecres.wholphin.data.model.JellyfinServer
import kotlinx.serialization.Serializable
import org.acra.ACRA
import timber.log.Timber
import javax.inject.Inject
import javax.inject.Singleton
/**
* Manages navigating for setup
*/
@Singleton
class SetupNavigationManager
@Inject
constructor() {
var backStack: MutableList<NavKey> = mutableStateListOf(SetupDestination.Loading)
/**
* Go to the specified [SetupDestination]
*/
fun navigateTo(destination: SetupDestination) {
backStack[0] = destination
log()
}
private fun log() {
val dest = backStack.lastOrNull().toString()
Timber.i("Current setup destination: %s", dest)
ACRA.errorReporter.putCustomData("setupDestination", dest)
}
}
@Serializable
sealed interface SetupDestination : NavKey {
@Serializable
data object Loading : SetupDestination
@Serializable
data object ServerList : SetupDestination
@Serializable
data class UserList(
val server: JellyfinServer,
) : SetupDestination
@Serializable
data class AppContent(
val current: CurrentUser,
) : SetupDestination
}

View file

@ -418,3 +418,10 @@ fun rememberBackDropImage(item: BaseItem): String? {
val imageUrlService = LocalImageUrlService.current val imageUrlService = LocalImageUrlService.current
return remember(item) { imageUrlService.getItemImageUrl(item, ImageType.BACKDROP) } return remember(item) { imageUrlService.getItemImageUrl(item, ImageType.BACKDROP) }
} }
/**
* Check if this, coalescing nulls to zero, is greater than that
*/
fun Int?.gt(that: Int) = (this ?: 0) > that
fun Int?.lt(that: Int) = (this ?: 0) < that

View file

@ -65,7 +65,6 @@ fun GenreCard(
.fillMaxSize() .fillMaxSize()
.clip(RoundedCornerShape(8.dp)), .clip(RoundedCornerShape(8.dp)),
) { ) {
Timber.v("genre image=${genre?.imageUrl}")
if (genre?.imageUrl.isNotNullOrBlank()) { if (genre?.imageUrl.isNotNullOrBlank()) {
AsyncImage( AsyncImage(
model = model =

View file

@ -58,6 +58,7 @@ import com.github.damontecres.wholphin.data.filter.PlayedFilter
import com.github.damontecres.wholphin.data.filter.VideoTypeFilter import com.github.damontecres.wholphin.data.filter.VideoTypeFilter
import com.github.damontecres.wholphin.data.filter.YearFilter import com.github.damontecres.wholphin.data.filter.YearFilter
import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.data.model.CollectionFolderFilter
import com.github.damontecres.wholphin.data.model.GetItemsFilter import com.github.damontecres.wholphin.data.model.GetItemsFilter
import com.github.damontecres.wholphin.data.model.GetItemsFilterOverride import com.github.damontecres.wholphin.data.model.GetItemsFilterOverride
import com.github.damontecres.wholphin.data.model.LibraryDisplayInfo import com.github.damontecres.wholphin.data.model.LibraryDisplayInfo
@ -136,12 +137,13 @@ class CollectionFolderViewModel
val viewOptions = MutableLiveData<ViewOptions>() val viewOptions = MutableLiveData<ViewOptions>()
private var useSeriesForPrimary: Boolean = true private var useSeriesForPrimary: Boolean = true
private lateinit var collectionFilter: CollectionFolderFilter
fun init( fun init(
itemId: String, itemId: String,
initialSortAndDirection: SortAndDirection?, initialSortAndDirection: SortAndDirection?,
recursive: Boolean, recursive: Boolean,
filter: GetItemsFilter, collectionFilter: CollectionFolderFilter,
useSeriesForPrimary: Boolean, useSeriesForPrimary: Boolean,
defaultViewOptions: ViewOptions, defaultViewOptions: ViewOptions,
): Job = ): Job =
@ -151,6 +153,7 @@ class CollectionFolderViewModel
context.getString(R.string.error_loading_collection, itemId), context.getString(R.string.error_loading_collection, itemId),
) + Dispatchers.IO, ) + Dispatchers.IO,
) { ) {
this@CollectionFolderViewModel.collectionFilter = collectionFilter
this@CollectionFolderViewModel.useSeriesForPrimary = useSeriesForPrimary this@CollectionFolderViewModel.useSeriesForPrimary = useSeriesForPrimary
this@CollectionFolderViewModel.itemId = itemId this@CollectionFolderViewModel.itemId = itemId
itemId.toUUIDOrNull()?.let { itemId.toUUIDOrNull()?.let {
@ -166,15 +169,17 @@ class CollectionFolderViewModel
) )
val sortAndDirection = val sortAndDirection =
libraryDisplayInfo?.sortAndDirection if (collectionFilter.useSavedLibraryDisplayInfo) {
?: initialSortAndDirection libraryDisplayInfo?.sortAndDirection
?: SortAndDirection.DEFAULT } else {
null
} ?: initialSortAndDirection ?: SortAndDirection.DEFAULT
val filterToUse = val filterToUse =
if (libraryDisplayInfo?.filter != null) { if (collectionFilter.useSavedLibraryDisplayInfo && libraryDisplayInfo?.filter != null) {
filter.merge(libraryDisplayInfo.filter) collectionFilter.filter.merge(libraryDisplayInfo.filter)
} else { } else {
filter collectionFilter.filter
} }
loadResults(true, sortAndDirection, recursive, filterToUse, useSeriesForPrimary) loadResults(true, sortAndDirection, recursive, filterToUse, useSeriesForPrimary)
@ -185,18 +190,20 @@ class CollectionFolderViewModel
newSort: SortAndDirection = this.sortAndDirection.value!!, newSort: SortAndDirection = this.sortAndDirection.value!!,
viewOptions: ViewOptions? = this.viewOptions.value, viewOptions: ViewOptions? = this.viewOptions.value,
) { ) {
serverRepository.currentUser.value?.let { user -> if (collectionFilter.useSavedLibraryDisplayInfo) {
viewModelScope.launch(Dispatchers.IO) { serverRepository.currentUser.value?.let { user ->
val libraryDisplayInfo = viewModelScope.launch(Dispatchers.IO) {
LibraryDisplayInfo( val libraryDisplayInfo =
userId = user.rowId, LibraryDisplayInfo(
itemId = itemId, userId = user.rowId,
sort = newSort.sort, itemId = itemId,
direction = newSort.direction, sort = newSort.sort,
filter = newFilter, direction = newSort.direction,
viewOptions = viewOptions, filter = newFilter,
) viewOptions = viewOptions,
libraryDisplayInfoDao.saveItem(libraryDisplayInfo) )
libraryDisplayInfoDao.saveItem(libraryDisplayInfo)
}
} }
} }
} }
@ -490,7 +497,7 @@ class CollectionFolderViewModel
fun CollectionFolderGrid( fun CollectionFolderGrid(
preferences: UserPreferences, preferences: UserPreferences,
itemId: UUID, itemId: UUID,
initialFilter: GetItemsFilter, initialFilter: CollectionFolderFilter,
recursive: Boolean, recursive: Boolean,
onClickItem: (Int, BaseItem) -> Unit, onClickItem: (Int, BaseItem) -> Unit,
sortOptions: List<ItemSortBy>, sortOptions: List<ItemSortBy>,
@ -523,7 +530,7 @@ fun CollectionFolderGrid(
fun CollectionFolderGrid( fun CollectionFolderGrid(
preferences: UserPreferences, preferences: UserPreferences,
itemId: String, itemId: String,
initialFilter: GetItemsFilter, initialFilter: CollectionFolderFilter,
recursive: Boolean, recursive: Boolean,
onClickItem: (Int, BaseItem) -> Unit, onClickItem: (Int, BaseItem) -> Unit,
sortOptions: List<ItemSortBy>, sortOptions: List<ItemSortBy>,
@ -550,7 +557,7 @@ fun CollectionFolderGrid(
) )
} }
val sortAndDirection by viewModel.sortAndDirection.observeAsState(SortAndDirection.DEFAULT) val sortAndDirection by viewModel.sortAndDirection.observeAsState(SortAndDirection.DEFAULT)
val filter by viewModel.filter.observeAsState(initialFilter) val filter by viewModel.filter.observeAsState(initialFilter.filter)
val loading by viewModel.loading.observeAsState(LoadingState.Loading) val loading by viewModel.loading.observeAsState(LoadingState.Loading)
val backgroundLoading by viewModel.backgroundLoading.observeAsState(LoadingState.Loading) val backgroundLoading by viewModel.backgroundLoading.observeAsState(LoadingState.Loading)
val item by viewModel.item.observeAsState() val item by viewModel.item.observeAsState()
@ -574,11 +581,17 @@ fun CollectionFolderGrid(
LoadingState.Success -> { LoadingState.Success -> {
pager?.let { pager -> pager?.let { pager ->
val title =
initialFilter.nameOverride
?: item?.name
?: item?.data?.collectionType?.name
?: stringResource(R.string.collection)
Box(modifier = modifier) { Box(modifier = modifier) {
CollectionFolderGridContent( CollectionFolderGridContent(
preferences, preferences = preferences,
item, item = item,
pager, title = title,
pager = pager,
sortAndDirection = sortAndDirection!!, sortAndDirection = sortAndDirection!!,
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
onClickItem = onClickItem, onClickItem = onClickItem,
@ -699,6 +712,7 @@ fun CollectionFolderGrid(
fun CollectionFolderGridContent( fun CollectionFolderGridContent(
preferences: UserPreferences, preferences: UserPreferences,
item: BaseItem?, item: BaseItem?,
title: String,
pager: List<BaseItem?>, pager: List<BaseItem?>,
sortAndDirection: SortAndDirection, sortAndDirection: SortAndDirection,
onClickItem: (Int, BaseItem) -> Unit, onClickItem: (Int, BaseItem) -> Unit,
@ -722,7 +736,6 @@ fun CollectionFolderGridContent(
onFilterChange: (GetItemsFilter) -> Unit = {}, onFilterChange: (GetItemsFilter) -> Unit = {},
) { ) {
val context = LocalContext.current val context = LocalContext.current
val title = item?.name ?: item?.data?.collectionType?.name ?: stringResource(R.string.collection)
var showHeader by rememberSaveable { mutableStateOf(true) } var showHeader by rememberSaveable { mutableStateOf(true) }
var showViewOptions by rememberSaveable { mutableStateOf(false) } var showViewOptions by rememberSaveable { mutableStateOf(false) }
@ -833,8 +846,7 @@ fun CollectionFolderGridContent(
item = focusedItem, item = focusedItem,
modifier = modifier =
Modifier Modifier
.fillMaxWidth(.6f) .fillMaxWidth()
// .fillMaxHeight(.25f)
.height(140.dp) .height(140.dp)
.padding(16.dp), .padding(16.dp),
) )

View file

@ -20,14 +20,17 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.tv.material3.LocalTextStyle import androidx.tv.material3.LocalTextStyle
@ -54,22 +57,24 @@ fun DotSeparatedRow(
text = text, text = text,
style = textStyle, style = textStyle,
color = MaterialTheme.colorScheme.onSurface, color = MaterialTheme.colorScheme.onSurface,
maxLines = 1,
) )
if (communityRating != null || criticRating != null || index != texts.lastIndex) { if (communityRating != null || criticRating != null || index != texts.lastIndex) {
Dot() Dot()
} }
} }
val height = with(LocalDensity.current) { textStyle.fontSize.toDp() }
communityRating?.let { communityRating?.let {
SimpleStarRating( SimpleStarRating(
communityRating = it, communityRating = it,
modifier = Modifier, modifier = Modifier.height(height),
) )
if (criticRating != null) { if (criticRating != null) {
Dot() Dot()
} }
} }
criticRating?.let { criticRating?.let {
TomatoRating(it) TomatoRating(it, Modifier.height(height))
} }
} }
} }

View file

@ -15,6 +15,9 @@ import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.MutableLiveData import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.data.model.CollectionFolderFilter
import com.github.damontecres.wholphin.data.model.GetItemsFilter import com.github.damontecres.wholphin.data.model.GetItemsFilter
import com.github.damontecres.wholphin.services.ImageUrlService import com.github.damontecres.wholphin.services.ImageUrlService
import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.services.NavigationManager
@ -39,6 +42,7 @@ import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit import kotlinx.coroutines.sync.withPermit
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.userLibraryApi
import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.ImageType import org.jellyfin.sdk.model.api.ImageType
import org.jellyfin.sdk.model.api.ItemFields import org.jellyfin.sdk.model.api.ItemFields
@ -55,9 +59,12 @@ class GenreViewModel
constructor( constructor(
private val api: ApiClient, private val api: ApiClient,
private val imageUrlService: ImageUrlService, private val imageUrlService: ImageUrlService,
private val serverRepository: ServerRepository,
val navigationManager: NavigationManager, val navigationManager: NavigationManager,
) : ViewModel() { ) : ViewModel() {
private lateinit var itemId: UUID private lateinit var itemId: UUID
val item = MutableLiveData<BaseItem?>(null)
val loading = MutableLiveData<LoadingState>(LoadingState.Pending) val loading = MutableLiveData<LoadingState>(LoadingState.Pending)
val genres = MutableLiveData<List<Genre>>(listOf()) val genres = MutableLiveData<List<Genre>>(listOf())
@ -65,8 +72,14 @@ class GenreViewModel
loading.value = LoadingState.Loading loading.value = LoadingState.Loading
this.itemId = itemId this.itemId = itemId
viewModelScope.launch(Dispatchers.IO + LoadingExceptionHandler(loading, "Failed to fetch genres")) { viewModelScope.launch(Dispatchers.IO + LoadingExceptionHandler(loading, "Failed to fetch genres")) {
val item =
api.userLibraryApi.getItem(itemId = itemId).content.let {
BaseItem(it, false)
}
this@GenreViewModel.item.setValueOnMain(item)
val request = val request =
GetGenresRequest( GetGenresRequest(
userId = serverRepository.currentUser.value?.id,
parentId = itemId, parentId = itemId,
fields = SlimItemFields, fields = SlimItemFields,
) )
@ -187,13 +200,23 @@ fun GenreCardGrid(
LoadingState.Success -> { LoadingState.Success -> {
Box(modifier = modifier) { Box(modifier = modifier) {
LaunchedEffect(Unit) { gridFocusRequester.tryRequestFocus() } LaunchedEffect(Unit) { gridFocusRequester.tryRequestFocus() }
val item by viewModel.item.observeAsState(null)
CardGrid( CardGrid(
pager = genres, pager = genres,
onClickItem = { _, genre -> onClickItem = { _, genre ->
viewModel.navigationManager.navigateTo( viewModel.navigationManager.navigateTo(
Destination.FilteredCollection( Destination.FilteredCollection(
itemId = itemId, itemId = itemId,
filter = GetItemsFilter(genres = listOf(genre.id)), filter =
CollectionFolderFilter(
nameOverride =
listOfNotNull(
genre.name,
item?.title,
).joinToString(" "),
filter = GetItemsFilter(genres = listOf(genre.id)),
useSavedLibraryDisplayInfo = false,
),
recursive = true, recursive = true,
), ),
) )

View file

@ -102,6 +102,7 @@ fun SimpleStarRating(
if (text.isNotNullOrBlank()) { if (text.isNotNullOrBlank()) {
Text( Text(
text = text, text = text,
maxLines = 1,
modifier = Modifier, modifier = Modifier,
) )
val height = with(LocalDensity.current) { LocalTextStyle.current.fontSize.toDp() } val height = with(LocalDensity.current) { LocalTextStyle.current.fontSize.toDp() }
@ -130,6 +131,7 @@ fun TomatoRating(
CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.onSurface) { CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.onSurface) {
Text( Text(
text = rating.toInt().toString() + "%", text = rating.toInt().toString() + "%",
maxLines = 1,
modifier = Modifier, modifier = Modifier,
) )
} }

View file

@ -9,14 +9,15 @@ import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.ServerRepository 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.AppPreferences
import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.services.BackdropService import com.github.damontecres.wholphin.services.BackdropService
import com.github.damontecres.wholphin.services.DatePlayedService
import com.github.damontecres.wholphin.services.FavoriteWatchManager import com.github.damontecres.wholphin.services.FavoriteWatchManager
import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.ui.SlimItemFields import com.github.damontecres.wholphin.ui.SlimItemFields
import com.github.damontecres.wholphin.ui.data.RowColumn import com.github.damontecres.wholphin.ui.data.RowColumn
import com.github.damontecres.wholphin.ui.main.buildCombinedNextUp
import com.github.damontecres.wholphin.ui.setValueOnMain import com.github.damontecres.wholphin.ui.setValueOnMain
import com.github.damontecres.wholphin.ui.toBaseItems import com.github.damontecres.wholphin.ui.toBaseItems
import com.github.damontecres.wholphin.util.ExceptionHandler import com.github.damontecres.wholphin.util.ExceptionHandler
@ -57,6 +58,7 @@ class RecommendedTvShowViewModel
private val api: ApiClient, private val api: ApiClient,
private val serverRepository: ServerRepository, private val serverRepository: ServerRepository,
private val preferencesDataStore: DataStore<AppPreferences>, private val preferencesDataStore: DataStore<AppPreferences>,
private val datePlayedService: DatePlayedService,
@Assisted val parentId: UUID, @Assisted val parentId: UUID,
navigationManager: NavigationManager, navigationManager: NavigationManager,
favoriteWatchManager: FavoriteWatchManager, favoriteWatchManager: FavoriteWatchManager,
@ -78,17 +80,17 @@ class RecommendedTvShowViewModel
override fun init() { override fun init() {
viewModelScope.launch(Dispatchers.IO + ExceptionHandler()) { viewModelScope.launch(Dispatchers.IO + ExceptionHandler()) {
val itemsPerRow = val preferences =
preferencesDataStore.data preferencesDataStore.data.firstOrNull() ?: AppPreferences.getDefaultInstance()
.firstOrNull() val combineNextUp = preferences.homePagePreferences.combineContinueNext
?.homePagePreferences val itemsPerRow = preferences.homePagePreferences.maxItemsPerRow
?.maxItemsPerRow val userId = serverRepository.currentUser.value?.id
?: AppPreference.HomePageItems.defaultValue.toInt()
try { try {
val resumeItemsDeferred = val resumeItemsDeferred =
viewModelScope.async(Dispatchers.IO) { viewModelScope.async(Dispatchers.IO) {
val resumeItemsRequest = val resumeItemsRequest =
GetResumeItemsRequest( GetResumeItemsRequest(
userId = userId,
parentId = parentId, parentId = parentId,
fields = SlimItemFields, fields = SlimItemFields,
includeItemTypes = listOf(BaseItemKind.EPISODE), includeItemTypes = listOf(BaseItemKind.EPISODE),
@ -106,12 +108,14 @@ class RecommendedTvShowViewModel
viewModelScope.async(Dispatchers.IO) { viewModelScope.async(Dispatchers.IO) {
val nextUpRequest = val nextUpRequest =
GetNextUpRequest( GetNextUpRequest(
parentId = parentId, userId = userId,
fields = SlimItemFields, fields = SlimItemFields,
enableUserData = true, imageTypeLimit = 1,
startIndex = 0, parentId = parentId,
limit = itemsPerRow, limit = itemsPerRow,
enableTotalRecordCount = false, enableResumable = false,
enableUserData = true,
enableRewatching = preferences.homePagePreferences.enableRewatchingNextUp,
) )
GetNextUpRequestHandler GetNextUpRequestHandler
@ -119,21 +123,45 @@ class RecommendedTvShowViewModel
.toBaseItems(api, true) .toBaseItems(api, true)
} }
val resumeItems = resumeItemsDeferred.await() val resumeItems = resumeItemsDeferred.await()
update(
R.string.continue_watching,
HomeRowLoadingState.Success(
context.getString(R.string.continue_watching),
resumeItems,
),
)
val nextUpItems = nextUpItemsDeferred.await() val nextUpItems = nextUpItemsDeferred.await()
update( if (combineNextUp) {
R.string.next_up, val combined =
HomeRowLoadingState.Success( buildCombinedNextUp(
context.getString(R.string.next_up), viewModelScope,
nextUpItems, datePlayedService,
), resumeItems,
) nextUpItems,
)
update(
R.string.continue_watching,
HomeRowLoadingState.Success(
context.getString(R.string.continue_watching),
combined,
),
)
update(
R.string.next_up,
HomeRowLoadingState.Success(
context.getString(R.string.next_up),
listOf(),
),
)
} else {
update(
R.string.continue_watching,
HomeRowLoadingState.Success(
context.getString(R.string.continue_watching),
resumeItems,
),
)
update(
R.string.next_up,
HomeRowLoadingState.Success(
context.getString(R.string.next_up),
nextUpItems,
),
)
}
if (resumeItems.isNotEmpty() || nextUpItems.isNotEmpty()) { if (resumeItems.isNotEmpty() || nextUpItems.isNotEmpty()) {
loading.setValueOnMain(LoadingState.Success) loading.setValueOnMain(LoadingState.Success)

View file

@ -10,7 +10,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.data.model.GetItemsFilter import com.github.damontecres.wholphin.data.model.CollectionFolderFilter
import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.ui.components.CollectionFolderGrid import com.github.damontecres.wholphin.ui.components.CollectionFolderGrid
import com.github.damontecres.wholphin.ui.components.ViewOptionsPoster import com.github.damontecres.wholphin.ui.components.ViewOptionsPoster
@ -28,7 +28,7 @@ fun CollectionFolderBoxSet(
item: BaseItem?, item: BaseItem?,
recursive: Boolean, recursive: Boolean,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
filter: GetItemsFilter = GetItemsFilter(), filter: CollectionFolderFilter = CollectionFolderFilter(),
preferencesViewModel: PreferencesViewModel = hiltViewModel(), preferencesViewModel: PreferencesViewModel = hiltViewModel(),
playEnabled: Boolean = false, playEnabled: Boolean = false,
) { ) {

View file

@ -11,7 +11,7 @@ import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import com.github.damontecres.wholphin.data.filter.DefaultFilterOptions import com.github.damontecres.wholphin.data.filter.DefaultFilterOptions
import com.github.damontecres.wholphin.data.filter.ItemFilterBy import com.github.damontecres.wholphin.data.filter.ItemFilterBy
import com.github.damontecres.wholphin.data.model.GetItemsFilter import com.github.damontecres.wholphin.data.model.CollectionFolderFilter
import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.ui.components.CollectionFolderGrid import com.github.damontecres.wholphin.ui.components.CollectionFolderGrid
import com.github.damontecres.wholphin.ui.components.ViewOptionsPoster import com.github.damontecres.wholphin.ui.components.ViewOptionsPoster
@ -29,7 +29,7 @@ fun CollectionFolderGeneric(
recursive: Boolean, recursive: Boolean,
playEnabled: Boolean, playEnabled: Boolean,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
filter: GetItemsFilter = GetItemsFilter(), filter: CollectionFolderFilter = CollectionFolderFilter(),
filterOptions: List<ItemFilterBy<*>> = DefaultFilterOptions, filterOptions: List<ItemFilterBy<*>> = DefaultFilterOptions,
sortOptions: List<ItemSortBy> = VideoSortOptions, sortOptions: List<ItemSortBy> = VideoSortOptions,
preferencesViewModel: PreferencesViewModel = hiltViewModel(), preferencesViewModel: PreferencesViewModel = hiltViewModel(),

View file

@ -29,7 +29,7 @@ import androidx.lifecycle.viewModelScope
import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.data.model.GetItemsFilter import com.github.damontecres.wholphin.data.model.CollectionFolderFilter
import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.services.BackdropService import com.github.damontecres.wholphin.services.BackdropService
import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.services.NavigationManager
@ -164,7 +164,7 @@ fun CollectionFolderLiveTv(
preferences = preferences, preferences = preferences,
onClickItem = onClickItem, onClickItem = onClickItem,
itemId = folders[folderIndex].id, itemId = folders[folderIndex].id,
initialFilter = GetItemsFilter(), initialFilter = CollectionFolderFilter(),
showTitle = false, showTitle = false,
recursive = false, recursive = false,
sortOptions = VideoSortOptions, sortOptions = VideoSortOptions,

View file

@ -23,6 +23,7 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.model.CollectionFolderFilter
import com.github.damontecres.wholphin.data.model.GetItemsFilter import com.github.damontecres.wholphin.data.model.GetItemsFilter
import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.ui.components.CollectionFolderGrid import com.github.damontecres.wholphin.ui.components.CollectionFolderGrid
@ -115,8 +116,11 @@ fun CollectionFolderMovie(
}, },
itemId = destination.itemId, itemId = destination.itemId,
initialFilter = initialFilter =
GetItemsFilter( CollectionFolderFilter(
includeItemTypes = listOf(BaseItemKind.MOVIE), filter =
GetItemsFilter(
includeItemTypes = listOf(BaseItemKind.MOVIE),
),
), ),
showTitle = false, showTitle = false,
recursive = true, recursive = true,
@ -143,8 +147,11 @@ fun CollectionFolderMovie(
}, },
itemId = destination.itemId, itemId = destination.itemId,
initialFilter = initialFilter =
GetItemsFilter( CollectionFolderFilter(
includeItemTypes = listOf(BaseItemKind.BOX_SET), filter =
GetItemsFilter(
includeItemTypes = listOf(BaseItemKind.BOX_SET),
),
), ),
showTitle = false, showTitle = false,
recursive = true, recursive = true,

View file

@ -10,7 +10,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.data.model.GetItemsFilter import com.github.damontecres.wholphin.data.model.CollectionFolderFilter
import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.ui.components.CollectionFolderGrid import com.github.damontecres.wholphin.ui.components.CollectionFolderGrid
import com.github.damontecres.wholphin.ui.components.ViewOptionsSquare import com.github.damontecres.wholphin.ui.components.ViewOptionsSquare
@ -25,7 +25,7 @@ fun CollectionFolderPlaylist(
item: BaseItem?, item: BaseItem?,
recursive: Boolean, recursive: Boolean,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
filter: GetItemsFilter = GetItemsFilter(), filter: CollectionFolderFilter = CollectionFolderFilter(),
preferencesViewModel: PreferencesViewModel = hiltViewModel(), preferencesViewModel: PreferencesViewModel = hiltViewModel(),
) { ) {
var showHeader by remember { mutableStateOf(true) } var showHeader by remember { mutableStateOf(true) }

View file

@ -9,7 +9,7 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import com.github.damontecres.wholphin.data.model.GetItemsFilter import com.github.damontecres.wholphin.data.model.CollectionFolderFilter
import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.ui.components.CollectionFolderGrid import com.github.damontecres.wholphin.ui.components.CollectionFolderGrid
import com.github.damontecres.wholphin.ui.components.ViewOptionsPoster import com.github.damontecres.wholphin.ui.components.ViewOptionsPoster
@ -23,7 +23,7 @@ fun CollectionFolderRecordings(
itemId: UUID, itemId: UUID,
recursive: Boolean, recursive: Boolean,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
filter: GetItemsFilter = GetItemsFilter(), filter: CollectionFolderFilter = CollectionFolderFilter(),
preferencesViewModel: PreferencesViewModel = hiltViewModel(), preferencesViewModel: PreferencesViewModel = hiltViewModel(),
) { ) {
var showHeader by remember { mutableStateOf(true) } var showHeader by remember { mutableStateOf(true) }

View file

@ -24,6 +24,7 @@ import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.data.model.CollectionFolderFilter
import com.github.damontecres.wholphin.data.model.GetItemsFilter import com.github.damontecres.wholphin.data.model.GetItemsFilter
import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.ui.components.CollectionFolderGrid import com.github.damontecres.wholphin.ui.components.CollectionFolderGrid
@ -115,8 +116,11 @@ fun CollectionFolderTv(
preferences = preferences, preferences = preferences,
itemId = destination.itemId, itemId = destination.itemId,
initialFilter = initialFilter =
GetItemsFilter( CollectionFolderFilter(
includeItemTypes = listOf(BaseItemKind.SERIES), filter =
GetItemsFilter(
includeItemTypes = listOf(BaseItemKind.SERIES),
),
), ),
showTitle = false, showTitle = false,
recursive = true, recursive = true,

View file

@ -25,6 +25,7 @@ import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.filter.DefaultForFavoritesFilterOptions import com.github.damontecres.wholphin.data.filter.DefaultForFavoritesFilterOptions
import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.data.model.CollectionFolderFilter
import com.github.damontecres.wholphin.data.model.GetItemsFilter import com.github.damontecres.wholphin.data.model.GetItemsFilter
import com.github.damontecres.wholphin.data.model.GetItemsFilterOverride import com.github.damontecres.wholphin.data.model.GetItemsFilterOverride
import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.preferences.UserPreferences
@ -117,9 +118,12 @@ fun FavoritesPage(
onClickItem = { _, item -> onClickItem.invoke(item) }, onClickItem = { _, item -> onClickItem.invoke(item) },
itemId = "${NavDrawerItem.Favorites.id}_movies", itemId = "${NavDrawerItem.Favorites.id}_movies",
initialFilter = initialFilter =
GetItemsFilter( CollectionFolderFilter(
favorite = true, filter =
includeItemTypes = listOf(BaseItemKind.MOVIE), GetItemsFilter(
favorite = true,
includeItemTypes = listOf(BaseItemKind.MOVIE),
),
), ),
showTitle = false, showTitle = false,
recursive = true, recursive = true,
@ -145,9 +149,12 @@ fun FavoritesPage(
onClickItem = { _, item -> onClickItem.invoke(item) }, onClickItem = { _, item -> onClickItem.invoke(item) },
itemId = "${NavDrawerItem.Favorites.id}_series", itemId = "${NavDrawerItem.Favorites.id}_series",
initialFilter = initialFilter =
GetItemsFilter( CollectionFolderFilter(
favorite = true, filter =
includeItemTypes = listOf(BaseItemKind.SERIES), GetItemsFilter(
favorite = true,
includeItemTypes = listOf(BaseItemKind.SERIES),
),
), ),
showTitle = false, showTitle = false,
recursive = true, recursive = true,
@ -173,9 +180,12 @@ fun FavoritesPage(
onClickItem = { _, item -> onClickItem.invoke(item) }, onClickItem = { _, item -> onClickItem.invoke(item) },
itemId = "${NavDrawerItem.Favorites.id}_episodes", itemId = "${NavDrawerItem.Favorites.id}_episodes",
initialFilter = initialFilter =
GetItemsFilter( CollectionFolderFilter(
favorite = true, filter =
includeItemTypes = listOf(BaseItemKind.EPISODE), GetItemsFilter(
favorite = true,
includeItemTypes = listOf(BaseItemKind.EPISODE),
),
), ),
showTitle = false, showTitle = false,
recursive = true, recursive = true,
@ -202,9 +212,12 @@ fun FavoritesPage(
onClickItem = { _, item -> onClickItem.invoke(item) }, onClickItem = { _, item -> onClickItem.invoke(item) },
itemId = "${NavDrawerItem.Favorites.id}_videos", itemId = "${NavDrawerItem.Favorites.id}_videos",
initialFilter = initialFilter =
GetItemsFilter( CollectionFolderFilter(
favorite = true, filter =
includeItemTypes = listOf(BaseItemKind.VIDEO), GetItemsFilter(
favorite = true,
includeItemTypes = listOf(BaseItemKind.VIDEO),
),
), ),
showTitle = false, showTitle = false,
recursive = true, recursive = true,
@ -230,9 +243,12 @@ fun FavoritesPage(
onClickItem = { _, item -> onClickItem.invoke(item) }, onClickItem = { _, item -> onClickItem.invoke(item) },
itemId = "${NavDrawerItem.Favorites.id}_playlists", itemId = "${NavDrawerItem.Favorites.id}_playlists",
initialFilter = initialFilter =
GetItemsFilter( CollectionFolderFilter(
favorite = true, filter =
includeItemTypes = listOf(BaseItemKind.PLAYLIST), GetItemsFilter(
favorite = true,
includeItemTypes = listOf(BaseItemKind.PLAYLIST),
),
), ),
showTitle = false, showTitle = false,
recursive = true, recursive = true,
@ -258,9 +274,12 @@ fun FavoritesPage(
onClickItem = { _, item -> onClickItem.invoke(item) }, onClickItem = { _, item -> onClickItem.invoke(item) },
itemId = "${NavDrawerItem.Favorites.id}_people", itemId = "${NavDrawerItem.Favorites.id}_people",
initialFilter = initialFilter =
GetItemsFilter( CollectionFolderFilter(
favorite = true, filter =
override = GetItemsFilterOverride.PERSON, GetItemsFilter(
favorite = true,
override = GetItemsFilterOverride.PERSON,
),
), ),
initialSortAndDirection = initialSortAndDirection =
SortAndDirection( SortAndDirection(

View file

@ -40,6 +40,7 @@ import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.focusRestorer import androidx.compose.ui.focus.focusRestorer
import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.key.onKeyEvent
import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@ -59,6 +60,7 @@ import com.github.damontecres.wholphin.ui.components.TabRow
import com.github.damontecres.wholphin.ui.formatDateTime import com.github.damontecres.wholphin.ui.formatDateTime
import com.github.damontecres.wholphin.ui.ifElse import com.github.damontecres.wholphin.ui.ifElse
import com.github.damontecres.wholphin.ui.logTab import com.github.damontecres.wholphin.ui.logTab
import com.github.damontecres.wholphin.ui.playback.isPlayKeyUp
import com.github.damontecres.wholphin.ui.tryRequestFocus import com.github.damontecres.wholphin.ui.tryRequestFocus
import com.github.damontecres.wholphin.ui.util.rememberDelayedNestedScroll import com.github.damontecres.wholphin.ui.util.rememberDelayedNestedScroll
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -249,6 +251,12 @@ fun SeriesOverviewContent(
bringIntoViewRequester.bringIntoView() bringIntoViewRequester.bringIntoView()
} }
} }
}.onKeyEvent {
if (episode != null && isPlayKeyUp(it)) {
onClick.invoke(episode)
return@onKeyEvent true
}
return@onKeyEvent false
}, },
interactionSource = interactionSource, interactionSource = interactionSource,
cardHeight = 120.dp, cardHeight = 120.dp,

View file

@ -24,6 +24,7 @@ import com.github.damontecres.wholphin.util.LoadingState
import com.github.damontecres.wholphin.util.supportItemKinds import com.github.damontecres.wholphin.util.supportItemKinds
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.async import kotlinx.coroutines.async
@ -90,6 +91,7 @@ class HomeViewModel
), ),
) { ) {
Timber.d("init HomeViewModel") Timber.d("init HomeViewModel")
backdropService.clearBackdrop()
serverRepository.currentUserDto.value?.let { userDto -> serverRepository.currentUserDto.value?.let { userDto ->
val includedIds = val includedIds =
@ -109,7 +111,13 @@ class HomeViewModel
val watching = val watching =
buildList { buildList {
if (prefs.combineContinueNext) { if (prefs.combineContinueNext) {
val items = buildCombined(resume, nextUp) val items =
buildCombinedNextUp(
viewModelScope,
datePlayedService,
resume,
nextUp,
)
add( add(
HomeRowLoadingState.Success( HomeRowLoadingState.Success(
title = context.getString(R.string.continue_watching), title = context.getString(R.string.continue_watching),
@ -267,39 +275,6 @@ class HomeViewModel
latestRows.setValueOnMain(rows) latestRows.setValueOnMain(rows)
} }
private suspend fun buildCombined(
resume: List<BaseItem>,
nextUp: List<BaseItem>,
): List<BaseItem> =
withContext(Dispatchers.IO) {
val start = System.currentTimeMillis()
val semaphore = Semaphore(3)
val deferred =
nextUp
.filter { it.data.seriesId != null }
.map { item ->
viewModelScope.async(Dispatchers.IO) {
try {
semaphore.withPermit {
datePlayedService.getLastPlayed(item)
}
} catch (ex: Exception) {
Timber.e(ex, "Error fetching %s", item.id)
null
}
}
}
val nextUpLastPlayed = deferred.awaitAll()
val timestamps = mutableMapOf<UUID, LocalDateTime?>()
nextUp.map { it.id }.zip(nextUpLastPlayed).toMap(timestamps)
resume.forEach { timestamps[it.id] = it.data.userData?.lastPlayedDate }
val result = (resume + nextUp).sortedByDescending { timestamps[it.id] }
val duration = (System.currentTimeMillis() - start).milliseconds
Timber.v("buildCombined took %s", duration)
return@withContext result
}
fun setWatched( fun setWatched(
itemId: UUID, itemId: UUID,
played: Boolean, played: Boolean,
@ -340,3 +315,38 @@ data class LatestData(
val title: String, val title: String,
val request: GetLatestMediaRequest, val request: GetLatestMediaRequest,
) )
suspend fun buildCombinedNextUp(
scope: CoroutineScope,
datePlayedService: DatePlayedService,
resume: List<BaseItem>,
nextUp: List<BaseItem>,
): List<BaseItem> =
withContext(Dispatchers.IO) {
val start = System.currentTimeMillis()
val semaphore = Semaphore(3)
val deferred =
nextUp
.filter { it.data.seriesId != null }
.map { item ->
scope.async(Dispatchers.IO) {
try {
semaphore.withPermit {
datePlayedService.getLastPlayed(item)
}
} catch (ex: Exception) {
Timber.e(ex, "Error fetching %s", item.id)
null
}
}
}
val nextUpLastPlayed = deferred.awaitAll()
val timestamps = mutableMapOf<UUID, LocalDateTime?>()
nextUp.map { it.id }.zip(nextUpLastPlayed).toMap(timestamps)
resume.forEach { timestamps[it.id] = it.data.userData?.lastPlayedDate }
val result = (resume + nextUp).sortedByDescending { timestamps[it.id] }
val duration = (System.currentTimeMillis() - start).milliseconds
Timber.v("buildCombined took %s", duration)
return@withContext result
}

View file

@ -8,6 +8,7 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.saveable.rememberSerializable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.alpha
@ -24,8 +25,12 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator
import androidx.navigation3.runtime.NavBackStack
import androidx.navigation3.runtime.NavEntry import androidx.navigation3.runtime.NavEntry
import androidx.navigation3.runtime.NavKey
import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator
import androidx.navigation3.runtime.serialization.NavBackStackSerializer
import androidx.navigation3.runtime.serialization.NavKeySerializer
import androidx.navigation3.ui.NavDisplay import androidx.navigation3.ui.NavDisplay
import androidx.tv.material3.MaterialTheme import androidx.tv.material3.MaterialTheme
import coil3.compose.AsyncImage import coil3.compose.AsyncImage
@ -62,13 +67,22 @@ class ApplicationContentViewModel
*/ */
@Composable @Composable
fun ApplicationContent( fun ApplicationContent(
server: JellyfinServer?, server: JellyfinServer,
user: JellyfinUser?, user: JellyfinUser,
navigationManager: NavigationManager, navigationManager: NavigationManager,
preferences: UserPreferences, preferences: UserPreferences,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
viewModel: ApplicationContentViewModel = hiltViewModel(), viewModel: ApplicationContentViewModel = hiltViewModel(),
) { ) {
val backStack: MutableList<NavKey> =
rememberSerializable(
server,
user,
serializer = NavBackStackSerializer(elementSerializer = NavKeySerializer()),
) {
NavBackStack(Destination.Home())
}
navigationManager.backStack = backStack
val backdrop by viewModel.backdropService.backdropFlow.collectAsStateWithLifecycle() val backdrop by viewModel.backdropService.backdropFlow.collectAsStateWithLifecycle()
val backdropStyle = preferences.appPreferences.interfacePreferences.backdropStyle val backdropStyle = preferences.appPreferences.interfacePreferences.backdropStyle
Box( Box(

View file

@ -5,10 +5,10 @@ package com.github.damontecres.wholphin.ui.nav
import androidx.annotation.StringRes import androidx.annotation.StringRes
import androidx.navigation3.runtime.NavKey import androidx.navigation3.runtime.NavKey
import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.data.model.CollectionFolderFilter
import com.github.damontecres.wholphin.data.model.DiscoverItem import com.github.damontecres.wholphin.data.model.DiscoverItem
import com.github.damontecres.wholphin.data.model.GetItemsFilter import com.github.damontecres.wholphin.data.model.GetItemsFilter
import com.github.damontecres.wholphin.data.model.ItemPlayback import com.github.damontecres.wholphin.data.model.ItemPlayback
import com.github.damontecres.wholphin.data.model.JellyfinServer
import com.github.damontecres.wholphin.ui.data.SortAndDirection import com.github.damontecres.wholphin.ui.data.SortAndDirection
import com.github.damontecres.wholphin.ui.detail.series.SeasonEpisode import com.github.damontecres.wholphin.ui.detail.series.SeasonEpisode
import com.github.damontecres.wholphin.ui.detail.series.SeasonEpisodeIds import com.github.damontecres.wholphin.ui.detail.series.SeasonEpisodeIds
@ -29,14 +29,6 @@ import java.util.UUID
sealed class Destination( sealed class Destination(
val fullScreen: Boolean = false, val fullScreen: Boolean = false,
) : NavKey { ) : NavKey {
@Serializable
data object ServerList : Destination(true)
@Serializable
data class UserList(
val server: JellyfinServer,
) : Destination(true)
@Serializable @Serializable
data class Home( data class Home(
val id: Long = 0L, val id: Long = 0L,
@ -104,7 +96,7 @@ sealed class Destination(
@Serializable @Serializable
data class FilteredCollection( data class FilteredCollection(
val itemId: UUID, val itemId: UUID,
val filter: GetItemsFilter, val filter: CollectionFolderFilter,
val recursive: Boolean, val recursive: Boolean,
) : Destination(false) ) : Destination(false)

View file

@ -32,8 +32,6 @@ import com.github.damontecres.wholphin.ui.playback.PlaybackPage
import com.github.damontecres.wholphin.ui.preferences.PreferencesPage import com.github.damontecres.wholphin.ui.preferences.PreferencesPage
import com.github.damontecres.wholphin.ui.seerr.SeerrDiscoverPage import com.github.damontecres.wholphin.ui.seerr.SeerrDiscoverPage
import com.github.damontecres.wholphin.ui.setup.InstallUpdatePage import com.github.damontecres.wholphin.ui.setup.InstallUpdatePage
import com.github.damontecres.wholphin.ui.setup.SwitchServerContent
import com.github.damontecres.wholphin.ui.setup.SwitchUserContent
import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.CollectionType import org.jellyfin.sdk.model.api.CollectionType
import timber.log.Timber import timber.log.Timber
@ -69,14 +67,6 @@ fun DestinationContent(
) )
} }
Destination.ServerList -> {
SwitchServerContent(modifier)
}
is Destination.UserList -> {
SwitchUserContent(destination.server, modifier)
}
is Destination.Settings -> { is Destination.Settings -> {
PreferencesPage( PreferencesPage(
preferences.appPreferences, preferences.appPreferences,

View file

@ -79,6 +79,8 @@ import com.github.damontecres.wholphin.preferences.AppThemeColors
import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.services.BackdropService import com.github.damontecres.wholphin.services.BackdropService
import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.services.SetupDestination
import com.github.damontecres.wholphin.services.SetupNavigationManager
import com.github.damontecres.wholphin.ui.FontAwesome import com.github.damontecres.wholphin.ui.FontAwesome
import com.github.damontecres.wholphin.ui.components.TimeDisplay import com.github.damontecres.wholphin.ui.components.TimeDisplay
import com.github.damontecres.wholphin.ui.ifElse import com.github.damontecres.wholphin.ui.ifElse
@ -106,6 +108,7 @@ class NavDrawerViewModel
constructor( constructor(
private val navDrawerItemRepository: NavDrawerItemRepository, private val navDrawerItemRepository: NavDrawerItemRepository,
val navigationManager: NavigationManager, val navigationManager: NavigationManager,
val setupNavigationManager: SetupNavigationManager,
val backdropService: BackdropService, val backdropService: BackdropService,
) : ViewModel() { ) : ViewModel() {
private var all: List<NavDrawerItem>? = null private var all: List<NavDrawerItem>? = null
@ -375,10 +378,8 @@ fun NavDrawer(
drawerOpen = drawerState.isOpen, drawerOpen = drawerState.isOpen,
interactionSource = interactionSource, interactionSource = interactionSource,
onClick = { onClick = {
viewModel.navigationManager.navigateToFromDrawer( viewModel.setupNavigationManager.navigateTo(
Destination.UserList( SetupDestination.UserList(server),
server,
),
) )
}, },
modifier = Modifier.padding(start = drawerPadding), modifier = Modifier.padding(start = drawerPadding),

View file

@ -107,7 +107,7 @@ fun DownloadSubtitlesContent(
color = MaterialTheme.colorScheme.onSurface, color = MaterialTheme.colorScheme.onSurface,
) )
// val lang = rememberTextFieldState(language) // val lang = rememberTextFieldState(language)
var lang by rememberSaveable { mutableStateOf("") } var lang by rememberSaveable { mutableStateOf(language) }
Row( Row(
horizontalArrangement = Arrangement.spacedBy(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,

View file

@ -286,11 +286,12 @@ class PlaybackViewModel
playNextUp() playNextUp()
} }
if (!isPlaylist && queriedItem.type == BaseItemKind.EPISODE) { if (!isPlaylist) {
val playlist = val result = playlistCreator.createFrom(queriedItem)
playlistCreator.createFromEpisode(queriedItem.seriesId!!, queriedItem.id) if (result is PlaylistCreationResult.Success && result.playlist.items.isNotEmpty()) {
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
this@PlaybackViewModel.playlist.value = playlist this@PlaybackViewModel.playlist.value = result.playlist
}
} }
} }
} }

View file

@ -9,9 +9,9 @@ import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.JellyfinServerDao import com.github.damontecres.wholphin.data.JellyfinServerDao
import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.data.model.JellyfinServer import com.github.damontecres.wholphin.data.model.JellyfinServer
import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.services.SetupDestination
import com.github.damontecres.wholphin.services.SetupNavigationManager
import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.setValueOnMain import com.github.damontecres.wholphin.ui.setValueOnMain
import com.github.damontecres.wholphin.ui.showToast import com.github.damontecres.wholphin.ui.showToast
import com.github.damontecres.wholphin.util.LoadingState import com.github.damontecres.wholphin.util.LoadingState
@ -41,7 +41,7 @@ class SwitchServerViewModel
val jellyfin: Jellyfin, val jellyfin: Jellyfin,
val serverRepository: ServerRepository, val serverRepository: ServerRepository,
val serverDao: JellyfinServerDao, val serverDao: JellyfinServerDao,
val navigationManager: NavigationManager, val navigationManager: SetupNavigationManager,
) : ViewModel() { ) : ViewModel() {
val servers = MutableLiveData<List<JellyfinServer>>(listOf()) val servers = MutableLiveData<List<JellyfinServer>>(listOf())
val serverStatus = MutableLiveData<Map<UUID, ServerConnectionStatus>>(mapOf()) val serverStatus = MutableLiveData<Map<UUID, ServerConnectionStatus>>(mapOf())
@ -156,7 +156,7 @@ class SwitchServerViewModel
) )
serverRepository.addAndChangeServer(updatedServer) serverRepository.addAndChangeServer(updatedServer)
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
navigationManager.navigateTo(Destination.UserList(server)) navigationManager.navigateTo(SetupDestination.UserList(updatedServer))
} }
} else if (result is ServerConnectionStatus.Error) { } else if (result is ServerConnectionStatus.Error) {
showToast(context, "Error connecting: $${result.message}") showToast(context, "Error connecting: $${result.message}")
@ -201,7 +201,7 @@ class SwitchServerViewModel
} }
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
addServerState.value = LoadingState.Success addServerState.value = LoadingState.Success
navigationManager.navigateTo(Destination.UserList(server)) navigationManager.navigateTo(SetupDestination.UserList(server))
} }
} else { } else {
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {

View file

@ -37,6 +37,7 @@ import androidx.tv.material3.Text
import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.model.JellyfinServer import com.github.damontecres.wholphin.data.model.JellyfinServer
import com.github.damontecres.wholphin.data.model.JellyfinUser import com.github.damontecres.wholphin.data.model.JellyfinUser
import com.github.damontecres.wholphin.services.SetupDestination
import com.github.damontecres.wholphin.ui.components.BasicDialog import com.github.damontecres.wholphin.ui.components.BasicDialog
import com.github.damontecres.wholphin.ui.components.CircularProgress import com.github.damontecres.wholphin.ui.components.CircularProgress
import com.github.damontecres.wholphin.ui.components.EditTextBox import com.github.damontecres.wholphin.ui.components.EditTextBox
@ -130,7 +131,9 @@ fun SwitchUserContent(
viewModel.removeUser(user) viewModel.removeUser(user)
}, },
onSwitchServer = { onSwitchServer = {
viewModel.navigationManager.navigateTo(Destination.ServerList) viewModel.setupNavigationManager.navigateTo(
SetupDestination.ServerList,
)
}, },
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) )

View file

@ -9,6 +9,8 @@ import com.github.damontecres.wholphin.data.model.JellyfinServer
import com.github.damontecres.wholphin.data.model.JellyfinUser import com.github.damontecres.wholphin.data.model.JellyfinUser
import com.github.damontecres.wholphin.services.ImageUrlService import com.github.damontecres.wholphin.services.ImageUrlService
import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.services.SetupDestination
import com.github.damontecres.wholphin.services.SetupNavigationManager
import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.setValueOnMain import com.github.damontecres.wholphin.ui.setValueOnMain
import com.github.damontecres.wholphin.util.ExceptionHandler import com.github.damontecres.wholphin.util.ExceptionHandler
@ -43,6 +45,7 @@ class SwitchUserViewModel
val serverRepository: ServerRepository, val serverRepository: ServerRepository,
val serverDao: JellyfinServerDao, val serverDao: JellyfinServerDao,
val navigationManager: NavigationManager, val navigationManager: NavigationManager,
val setupNavigationManager: SetupNavigationManager,
val imageUrlService: ImageUrlService, val imageUrlService: ImageUrlService,
@Assisted val server: JellyfinServer, @Assisted val server: JellyfinServer,
) : ViewModel() { ) : ViewModel() {
@ -120,9 +123,11 @@ class SwitchUserViewModel
fun switchUser(user: JellyfinUser) { fun switchUser(user: JellyfinUser) {
viewModelScope.launchIO { viewModelScope.launchIO {
try { try {
serverRepository.changeUser(server, user) val current = serverRepository.changeUser(server, user)
withContext(Dispatchers.Main) { if (current != null) {
navigationManager.goToHome() withContext(Dispatchers.Main) {
setupNavigationManager.navigateTo(SetupDestination.AppContent(current))
}
} }
} catch (ex: Exception) { } catch (ex: Exception) {
Timber.e(ex, "Error switching user") Timber.e(ex, "Error switching user")
@ -144,9 +149,11 @@ class SwitchUserViewModel
username = username, username = username,
password = password, password = password,
) )
serverRepository.changeUser(server.url, authenticationResult) val current = serverRepository.changeUser(server.url, authenticationResult)
withContext(Dispatchers.Main) { if (current != null) {
navigationManager.goToHome() withContext(Dispatchers.Main) {
setupNavigationManager.navigateTo(SetupDestination.AppContent(current))
}
} }
} catch (ex: Exception) { } catch (ex: Exception) {
Timber.e(ex, "Error logging in user") Timber.e(ex, "Error logging in user")
@ -192,9 +199,13 @@ class SwitchUserViewModel
val authenticationResult by api.userApi.authenticateWithQuickConnect( val authenticationResult by api.userApi.authenticateWithQuickConnect(
QuickConnectDto(secret = state.secret), QuickConnectDto(secret = state.secret),
) )
serverRepository.changeUser(server.url, authenticationResult) val current = serverRepository.changeUser(server.url, authenticationResult)
withContext(Dispatchers.Main) { if (current != null) {
navigationManager.goToHome() withContext(Dispatchers.Main) {
setupNavigationManager.navigateTo(
SetupDestination.AppContent(current),
)
}
} }
} catch (ex: Exception) { } catch (ex: Exception) {
Timber.e(ex, "Error during quick connect") Timber.e(ex, "Error during quick connect")

View file

@ -38,9 +38,17 @@ object MPVLib {
external fun create(appctx: Context) external fun create(appctx: Context)
external fun init() fun initialize() {
synchronized(this) { init() }
}
external fun destroy() private external fun init()
fun tearDown() {
synchronized(this) { destroy() }
}
private external fun destroy()
external fun attachSurface(surface: Surface) external fun attachSurface(surface: Surface)

View file

@ -76,6 +76,7 @@ class MpvPlayer(
private var surface: Surface? = null private var surface: Surface? = null
private val playbackState = AtomicReference<PlaybackState>(PlaybackState.EMPTY) private val playbackState = AtomicReference<PlaybackState>(PlaybackState.EMPTY)
private val mpvLogger = MpvLogger()
// This looper will sent events to the main thread // This looper will sent events to the main thread
private val looper = Util.getCurrentOrMainLooper() private val looper = Util.getCurrentOrMainLooper()
@ -87,7 +88,7 @@ class MpvPlayer(
) { listener, eventFlags -> ) { listener, eventFlags ->
listener.onEvents(this@MpvPlayer, Player.Events(eventFlags)) listener.onEvents(this@MpvPlayer, Player.Events(eventFlags))
} }
private val availableCommands: Player.Commands private var availableCommands: Player.Commands = Player.Commands.Builder().build()
private val trackSelector = DefaultTrackSelector(context) private val trackSelector = DefaultTrackSelector(context)
// This thread/looper will receive commands from the main thread to execute // This thread/looper will receive commands from the main thread to execute
@ -101,7 +102,13 @@ class MpvPlayer(
private set private set
init { init {
sendCommand(MpvCommand.INITIALIZE, null)
}
private fun init() {
Timber.v("config-dir=${context.filesDir.path}") Timber.v("config-dir=${context.filesDir.path}")
MPVLib.addLogObserver(mpvLogger)
MPVLib.create(context) MPVLib.create(context)
MPVLib.setOptionString("config", "yes") MPVLib.setOptionString("config", "yes")
MPVLib.setOptionString("config-dir", context.filesDir.path) MPVLib.setOptionString("config-dir", context.filesDir.path)
@ -120,17 +127,14 @@ class MpvPlayer(
MPVLib.setOptionString("demuxer-max-bytes", "${cacheMegs * 1024 * 1024}") MPVLib.setOptionString("demuxer-max-bytes", "${cacheMegs * 1024 * 1024}")
MPVLib.setOptionString("demuxer-max-back-bytes", "${cacheMegs * 1024 * 1024}") MPVLib.setOptionString("demuxer-max-back-bytes", "${cacheMegs * 1024 * 1024}")
MPVLib.init() MPVLib.initialize()
MPVLib.setOptionString("force-window", "no") MPVLib.setOptionString("force-window", "no")
MPVLib.setOptionString("idle", "yes") MPVLib.setOptionString("idle", "yes")
// MPVLib.setOptionString("sub-fonts-dir", File(context.filesDir, "fonts").absolutePath) // MPVLib.setOptionString("sub-fonts-dir", File(context.filesDir, "fonts").absolutePath)
internalHandler.post { MPVLib.addObserver(this@MpvPlayer)
MPVLib.addObserver(this@MpvPlayer) MPVProperty.observedProperties.forEach(MPVLib::observeProperty)
MPVProperty.observedProperties.forEach(MPVLib::observeProperty)
MPVLib.addLogObserver(MpvLogger())
}
availableCommands = availableCommands =
Player.Commands Player.Commands
@ -298,15 +302,14 @@ class MpvPlayer(
override fun release() { override fun release() {
Timber.i("release") Timber.i("release")
internalHandler.removeCallbacks(updatePlaybackState)
playbackState.update { playbackState.update {
PlaybackState.EMPTY PlaybackState.EMPTY
} }
if (!isReleased) { if (!isReleased) {
thread.quit() internalHandler.removeCallbacks(updatePlaybackState)
MPVLib.removeObserver(this) MPVLib.removeObserver(this@MpvPlayer)
clearVideoSurfaceView(null) sendCommand(MpvCommand.DESTROY, null)
MPVLib.destroy() thread.quitSafely()
} }
isReleased = true isReleased = true
} }
@ -466,15 +469,9 @@ class MpvPlayer(
throwIfReleased() throwIfReleased()
if (DEBUG) Timber.v("setVideoSurfaceView") if (DEBUG) Timber.v("setVideoSurfaceView")
val surface = surfaceView?.holder?.surface val surface = surfaceView?.holder?.surface
if (surface != null) { if (surface != null && surface.isValid) {
this.surface = surface
Timber.v("Queued attach") Timber.v("Queued attach")
MPVLib.attachSurface(surface) sendCommand(MpvCommand.ATTACH_SURFACE, surface)
MPVLib.setOptionString("force-window", "yes")
Timber.d("Attached surface")
playbackState.load().media?.let {
sendCommand(MpvCommand.LOAD_FILE, it)
}
} else { } else {
clearVideoSurfaceView(null) clearVideoSurfaceView(null)
} }
@ -483,9 +480,7 @@ class MpvPlayer(
override fun clearVideoSurfaceView(surfaceView: SurfaceView?) { override fun clearVideoSurfaceView(surfaceView: SurfaceView?) {
if (surface == surfaceView?.holder?.surface) { if (surface == surfaceView?.holder?.surface) {
Timber.d("clearVideoSurfaceView") Timber.d("clearVideoSurfaceView")
MPVLib.detachSurface() sendCommand(MpvCommand.ATTACH_SURFACE, null)
MPVLib.setPropertyString("vo", "null")
MPVLib.setPropertyString("force-window", "no")
} else { } else {
Timber.w("clearVideoSurfaceView called with different surface") Timber.w("clearVideoSurfaceView called with different surface")
} }
@ -621,7 +616,7 @@ class MpvPlayer(
} }
override fun event(eventId: Int) { override fun event(eventId: Int) {
Timber.v("event: thread=${Thread.currentThread().name}, eventId=$eventId") Timber.v("event: eventId=%s", eventId)
when (eventId) { when (eventId) {
MPV_EVENT_START_FILE -> { MPV_EVENT_START_FILE -> {
internalHandler.post(updatePlaybackState) internalHandler.post(updatePlaybackState)
@ -891,6 +886,33 @@ class MpvPlayer(
MpvCommand.LOAD_FILE -> { MpvCommand.LOAD_FILE -> {
loadFile(msg.obj as MediaAndPosition) loadFile(msg.obj as MediaAndPosition)
} }
MpvCommand.ATTACH_SURFACE -> {
val surface = msg.obj as Surface?
if (surface == null || (this.surface != null && this.surface != surface)) {
// If clearing or changing the surface
MPVLib.detachSurface()
MPVLib.setPropertyString("vo", "null")
MPVLib.setPropertyString("force-window", "no")
}
if (surface != null) {
MPVLib.attachSurface(surface)
this.surface = surface
MPVLib.setOptionString("force-window", "yes")
Timber.d("Attached surface")
}
}
MpvCommand.INITIALIZE -> {
init()
}
MpvCommand.DESTROY -> {
clearVideoSurfaceView(null)
MPVLib.removeLogObserver(mpvLogger)
MPVLib.tearDown()
Timber.d("MPVLib destroyed")
}
} }
return true return true
} }
@ -1020,4 +1042,7 @@ enum class MpvCommand {
SET_SPEED, SET_SPEED,
SET_SUBTITLE_DELAY, SET_SUBTITLE_DELAY,
LOAD_FILE, LOAD_FILE,
ATTACH_SURFACE,
INITIALIZE,
DESTROY,
} }