Merge branch 'main' into develop/mpv

This commit is contained in:
Damontecres 2025-11-13 20:03:45 -05:00
commit 91ad74615b
No known key found for this signature in database
64 changed files with 750 additions and 387 deletions

View file

@ -27,20 +27,20 @@ import androidx.tv.material3.ExperimentalTvMaterial3Api
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Surface
import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.hilt.AuthOkHttpClient
import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.preferences.DefaultUserConfiguration
import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.services.AppUpgradeHandler
import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.services.PlaybackLifecycleObserver
import com.github.damontecres.wholphin.services.ServerEventListener
import com.github.damontecres.wholphin.services.UpdateChecker
import com.github.damontecres.wholphin.services.hilt.AuthOkHttpClient
import com.github.damontecres.wholphin.ui.CoilConfig
import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.nav.ApplicationContent
import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.nav.NavigationManager
import com.github.damontecres.wholphin.ui.theme.WholphinTheme
import com.github.damontecres.wholphin.util.AppUpgradeHandler
import com.github.damontecres.wholphin.util.PlaybackLifecycleObserver
import com.github.damontecres.wholphin.util.ServerEventListener
import com.github.damontecres.wholphin.util.UpdateChecker
import com.github.damontecres.wholphin.util.profile.createDeviceProfile
import dagger.hilt.android.AndroidEntryPoint
import okhttp3.OkHttpClient

View file

@ -2,7 +2,7 @@ package com.github.damontecres.wholphin.data.model
import com.github.damontecres.wholphin.ui.detail.series.SeasonEpisodeIds
import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.util.seasonEpisode
import com.github.damontecres.wholphin.ui.seasonEpisode
import kotlinx.serialization.Serializable
import kotlinx.serialization.Transient
import org.jellyfin.sdk.api.client.ApiClient

View file

@ -6,6 +6,7 @@ import kotlinx.serialization.Serializable
import kotlinx.serialization.UseSerializers
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.request.GetItemsRequest
import org.jellyfin.sdk.model.api.request.GetPersonsRequest
import org.jellyfin.sdk.model.serializer.UUIDSerializer
import java.util.UUID
@ -19,6 +20,7 @@ data class GetItemsFilter(
val studios: List<UUID>? = null,
val tags: List<String>? = null,
val includeItemTypes: List<BaseItemKind>? = null,
val override: GetItemsFilterOverride = GetItemsFilterOverride.NONE,
) {
fun applyTo(req: GetItemsRequest) =
req.copy(
@ -31,4 +33,14 @@ data class GetItemsFilter(
studioIds = studios,
tags = tags,
)
fun applyTo(req: GetPersonsRequest) =
req.copy(
isFavorite = favorite,
)
}
enum class GetItemsFilterOverride {
NONE,
PERSON,
}

View file

@ -13,6 +13,7 @@ data class Person(
val role: String?,
val type: PersonKind,
val imageUrl: String?,
val favorite: Boolean,
) {
companion object {
fun fromDto(
@ -25,6 +26,21 @@ data class Person(
role = dto.role,
type = dto.type,
imageUrl = api.imageApi.getItemImageUrl(dto.id, ImageType.PRIMARY),
favorite = false,
)
fun fromDto(
dto: BaseItemPerson,
favorite: Boolean,
api: ApiClient,
): Person =
Person(
id = dto.id,
name = dto.name,
role = dto.role,
type = dto.type,
imageUrl = api.imageApi.getItemImageUrl(dto.id, ImageType.PRIMARY),
favorite = favorite,
)
}
}

View file

@ -1,34 +1,10 @@
package com.github.damontecres.wholphin.data.model
import android.content.Context
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.setValue
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.ui.DefaultItemFields
import com.github.damontecres.wholphin.ui.indexOfFirstOrNull
import com.github.damontecres.wholphin.ui.toServerString
import com.github.damontecres.wholphin.util.ApiRequestPager
import com.github.damontecres.wholphin.util.GetEpisodesRequestHandler
import com.github.damontecres.wholphin.util.GetItemsRequestHandler
import com.github.damontecres.wholphin.util.GetPlaylistItemsRequestHandler
import com.github.damontecres.wholphin.util.TransformList
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineScope
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.playlistsApi
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.CreatePlaylistDto
import org.jellyfin.sdk.model.api.MediaType
import org.jellyfin.sdk.model.api.PlaylistUserPermissions
import org.jellyfin.sdk.model.api.request.GetEpisodesRequest
import org.jellyfin.sdk.model.api.request.GetItemsRequest
import org.jellyfin.sdk.model.api.request.GetPlaylistItemsRequest
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
import java.util.UUID
import javax.inject.Inject
import javax.inject.Singleton
class Playlist(
items: List<BaseItem>,
@ -71,107 +47,3 @@ data class PlaylistInfo(
val count: Int,
val mediaType: MediaType,
)
@Singleton
class PlaylistCreator
@Inject
constructor(
@param:ApplicationContext private val context: Context,
private val api: ApiClient,
private val serverRepository: ServerRepository,
) {
suspend fun createFromEpisode(
seriesId: UUID,
episodeId: UUID,
): Playlist {
val request =
GetEpisodesRequest(
seriesId = seriesId,
fields = DefaultItemFields,
startItemId = episodeId,
limit = Playlist.MAX_SIZE,
)
val episodes = GetEpisodesRequestHandler.execute(api, request).content.items
val startIndex =
episodes.indexOfFirstOrNull { it.id == episodeId }
?: throw IllegalStateException("Episode $episodeId was not returned")
return Playlist(episodes.map { BaseItem.from(it, api) }, startIndex)
}
suspend fun createFromPlaylistId(
playlistId: UUID,
startIndex: Int?,
shuffled: Boolean,
): Playlist {
val request =
GetPlaylistItemsRequest(
playlistId = playlistId,
fields = DefaultItemFields,
startIndex = startIndex,
limit = Playlist.MAX_SIZE,
)
val items = GetPlaylistItemsRequestHandler.execute(api, request).content.items
var baseItems = items.map { BaseItem.from(it, api) }
if (shuffled) {
baseItems = baseItems.shuffled()
}
return Playlist(baseItems, 0)
}
suspend fun getPlaylists(
mediaType: MediaType?,
scope: CoroutineScope,
): List<PlaylistInfo?> {
val request =
GetItemsRequest(
includeItemTypes = listOf(BaseItemKind.PLAYLIST),
mediaTypes = mediaType?.let { listOf(mediaType) },
recursive = true,
)
val pager = ApiRequestPager(api, request, GetItemsRequestHandler, scope).init()
return TransformList(pager) {
it?.let {
PlaylistInfo(
id = it.id,
name = it.name ?: context.getString(R.string.unknown),
count = it.data.childCount ?: 0,
mediaType = it.data.mediaType,
)
}
}
}
suspend fun createPlaylist(
name: String,
initialItems: List<UUID>,
): UUID? =
serverRepository.currentUser.value?.let { user ->
api.playlistsApi
.createPlaylist(
CreatePlaylistDto(
name = name,
ids = initialItems,
users = listOf(PlaylistUserPermissions(user.id, true)),
isPublic = false,
),
).content.id
.toUUIDOrNull()
}
suspend fun addToPlaylist(
playlistId: UUID,
itemId: UUID,
) {
api.playlistsApi.addItemToPlaylist(playlistId, listOf(itemId))
}
suspend fun removeFromPlaylist(
playlistId: UUID,
itemId: UUID,
) {
api.playlistsApi.removeItemFromPlaylist(
playlistId.toServerString(),
listOf(itemId.toServerString()),
)
}
}

View file

@ -1,4 +1,4 @@
package com.github.damontecres.wholphin.util
package com.github.damontecres.wholphin.services
import android.content.Context
import android.os.Build
@ -11,6 +11,7 @@ import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.preferences.updateInterfacePreferences
import com.github.damontecres.wholphin.preferences.updatePlaybackOverrides
import com.github.damontecres.wholphin.ui.preferences.PreferencesViewModel
import com.github.damontecres.wholphin.util.Version
import dagger.hilt.android.qualifiers.ApplicationContext
import timber.log.Timber
import java.io.File
@ -51,8 +52,8 @@ class AppUpgradeHandler
copySubfont(true)
upgradeApp(
context,
Version.fromString(previousVersion ?: "0.0.0"),
Version.fromString(newVersion),
Version.Companion.fromString(previousVersion ?: "0.0.0"),
Version.Companion.fromString(newVersion),
appPreferences,
)
} catch (ex: Exception) {
@ -96,7 +97,7 @@ suspend fun upgradeApp(
current: Version,
appPreferences: DataStore<AppPreferences>,
) {
if (previous.isEqualOrBefore(Version.fromString("0.1.0-2-g0"))) {
if (previous.isEqualOrBefore(Version.Companion.fromString("0.1.0-2-g0"))) {
appPreferences.updateData {
it.updatePlaybackOverrides {
ac3Supported = AppPreference.Ac3Supported.defaultValue
@ -106,21 +107,21 @@ suspend fun upgradeApp(
}
}
}
if (previous.isEqualOrBefore(Version.fromString("0.2.3-6-g0"))) {
if (previous.isEqualOrBefore(Version.Companion.fromString("0.2.3-6-g0"))) {
appPreferences.updateData {
it.updateInterfacePreferences {
navDrawerSwitchOnFocus = AppPreference.NavDrawerSwitchOnFocus.defaultValue
}
}
}
if (previous.isEqualOrBefore(Version.fromString("0.2.5-11-g0"))) {
if (previous.isEqualOrBefore(Version.Companion.fromString("0.2.5-11-g0"))) {
appPreferences.updateData {
it.updateInterfacePreferences {
showClock = AppPreference.ShowClock.defaultValue
}
}
}
if (previous.isEqualOrBefore(Version.fromString("0.2.7-1-g0"))) {
if (previous.isEqualOrBefore(Version.Companion.fromString("0.2.7-1-g0"))) {
PreferencesViewModel.resetSubtitleSettings(appPreferences)
}
}

View file

@ -1,4 +1,4 @@
package com.github.damontecres.wholphin.util
package com.github.damontecres.wholphin.services
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.playStateApi

View file

@ -1,6 +1,7 @@
package com.github.damontecres.wholphin.ui.nav
package com.github.damontecres.wholphin.services
import androidx.navigation3.runtime.NavKey
import com.github.damontecres.wholphin.ui.nav.Destination
import org.acra.ACRA
import timber.log.Timber
import javax.inject.Inject
@ -16,7 +17,7 @@ class NavigationManager
var backStack: MutableList<NavKey> = mutableListOf()
/**
* Go to the specified [Destination]
* Go to the specified [com.github.damontecres.wholphin.ui.nav.Destination]
*/
fun navigateTo(destination: Destination) {
backStack.add(destination)
@ -70,7 +71,7 @@ class NavigationManager
private fun log() {
val dest = backStack.lastOrNull().toString()
Timber.i("Current Destination: %s", dest)
Timber.Forest.i("Current Destination: %s", dest)
ACRA.errorReporter.putCustomData("destination", dest)
}
}

View file

@ -0,0 +1,34 @@
package com.github.damontecres.wholphin.services
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.data.model.Person
import com.github.damontecres.wholphin.ui.letNotEmpty
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.itemsApi
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class PeopleFavorites
@Inject
constructor(
private val api: ApiClient,
) {
suspend fun getPeopleFor(item: BaseItem): List<Person> =
item.data.people.orEmpty().map { it.id }.chunked(50).let { chunks ->
val favorites =
chunks
.map {
api.itemsApi
.getItems(ids = it)
.content.items
}.flatten()
.associateBy({ it.id }, { it.userData?.isFavorite ?: false })
val people =
item.data.people
?.letNotEmpty { people ->
people.map { Person.fromDto(it, favorites[it.id] ?: false, api) }
}.orEmpty()
people
}
}

View file

@ -1,9 +1,8 @@
package com.github.damontecres.wholphin.util
package com.github.damontecres.wholphin.services
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.nav.NavigationManager
import dagger.hilt.android.scopes.ActivityRetainedScoped
import javax.inject.Inject

View file

@ -1,6 +1,6 @@
@file:OptIn(markerClass = [UnstableApi::class])
package com.github.damontecres.wholphin.util
package com.github.damontecres.wholphin.services
import android.content.Context
import androidx.annotation.OptIn

View file

@ -0,0 +1,135 @@
package com.github.damontecres.wholphin.services
import android.content.Context
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.data.model.Playlist
import com.github.damontecres.wholphin.data.model.PlaylistInfo
import com.github.damontecres.wholphin.ui.DefaultItemFields
import com.github.damontecres.wholphin.ui.indexOfFirstOrNull
import com.github.damontecres.wholphin.ui.toServerString
import com.github.damontecres.wholphin.util.ApiRequestPager
import com.github.damontecres.wholphin.util.GetEpisodesRequestHandler
import com.github.damontecres.wholphin.util.GetItemsRequestHandler
import com.github.damontecres.wholphin.util.GetPlaylistItemsRequestHandler
import com.github.damontecres.wholphin.util.TransformList
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineScope
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.playlistsApi
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.CreatePlaylistDto
import org.jellyfin.sdk.model.api.MediaType
import org.jellyfin.sdk.model.api.PlaylistUserPermissions
import org.jellyfin.sdk.model.api.request.GetEpisodesRequest
import org.jellyfin.sdk.model.api.request.GetItemsRequest
import org.jellyfin.sdk.model.api.request.GetPlaylistItemsRequest
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
import java.util.UUID
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class PlaylistCreator
@Inject
constructor(
@param:ApplicationContext private val context: Context,
private val api: ApiClient,
private val serverRepository: ServerRepository,
) {
suspend fun createFromEpisode(
seriesId: UUID,
episodeId: UUID,
): Playlist {
val request =
GetEpisodesRequest(
seriesId = seriesId,
fields = DefaultItemFields,
startItemId = episodeId,
limit = Playlist.Companion.MAX_SIZE,
)
val episodes = GetEpisodesRequestHandler.execute(api, request).content.items
val startIndex =
episodes.indexOfFirstOrNull { it.id == episodeId }
?: throw IllegalStateException("Episode $episodeId was not returned")
return Playlist(episodes.map { BaseItem.Companion.from(it, api) }, startIndex)
}
suspend fun createFromPlaylistId(
playlistId: UUID,
startIndex: Int?,
shuffled: Boolean,
): Playlist {
val request =
GetPlaylistItemsRequest(
playlistId = playlistId,
fields = DefaultItemFields,
startIndex = startIndex,
limit = Playlist.Companion.MAX_SIZE,
)
val items = GetPlaylistItemsRequestHandler.execute(api, request).content.items
var baseItems = items.map { BaseItem.Companion.from(it, api) }
if (shuffled) {
baseItems = baseItems.shuffled()
}
return Playlist(baseItems, 0)
}
suspend fun getPlaylists(
mediaType: MediaType?,
scope: CoroutineScope,
): List<PlaylistInfo?> {
val request =
GetItemsRequest(
includeItemTypes = listOf(BaseItemKind.PLAYLIST),
mediaTypes = mediaType?.let { listOf(mediaType) },
recursive = true,
)
val pager = ApiRequestPager(api, request, GetItemsRequestHandler, scope).init()
return TransformList(pager) {
it?.let {
PlaylistInfo(
id = it.id,
name = it.name ?: context.getString(R.string.unknown),
count = it.data.childCount ?: 0,
mediaType = it.data.mediaType,
)
}
}
}
suspend fun createPlaylist(
name: String,
initialItems: List<UUID>,
): UUID? =
serverRepository.currentUser.value?.let { user ->
api.playlistsApi
.createPlaylist(
CreatePlaylistDto(
name = name,
ids = initialItems,
users = listOf(PlaylistUserPermissions(user.id, true)),
isPublic = false,
),
).content.id
.toUUIDOrNull()
}
suspend fun addToPlaylist(
playlistId: UUID,
itemId: UUID,
) {
api.playlistsApi.addItemToPlaylist(playlistId, listOf(itemId))
}
suspend fun removeFromPlaylist(
playlistId: UUID,
itemId: UUID,
) {
api.playlistsApi.removeItemFromPlaylist(
playlistId.toServerString(),
listOf(itemId.toServerString()),
)
}
}

View file

@ -1,4 +1,4 @@
package com.github.damontecres.wholphin.util
package com.github.damontecres.wholphin.services
import android.content.Context
import android.widget.Toast

View file

@ -1,4 +1,4 @@
package com.github.damontecres.wholphin.util
package com.github.damontecres.wholphin.services
import android.content.Context
import androidx.annotation.OptIn
@ -8,8 +8,8 @@ import androidx.media3.common.util.UnstableApi
import androidx.media3.datasource.okhttp.OkHttpDataSource
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
import com.github.damontecres.wholphin.hilt.AuthOkHttpClient
import com.github.damontecres.wholphin.preferences.ThemeSongVolume
import com.github.damontecres.wholphin.services.hilt.AuthOkHttpClient
import com.github.damontecres.wholphin.util.profile.Codec
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers

View file

@ -0,0 +1,81 @@
package com.github.damontecres.wholphin.services
import android.content.Context
import android.content.Intent
import androidx.core.net.toUri
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.data.model.LocalTrailer
import com.github.damontecres.wholphin.data.model.RemoteTrailer
import com.github.damontecres.wholphin.data.model.Trailer
import com.github.damontecres.wholphin.ui.nav.Destination
import dagger.hilt.android.qualifiers.ApplicationContext
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.userLibraryApi
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class TrailerService
@Inject
constructor(
@param:ApplicationContext private val context: Context,
private val api: ApiClient,
) {
suspend fun getTrailers(item: BaseItem): List<Trailer> {
val remoteTrailers =
item.data.remoteTrailers
?.mapNotNull { t ->
t.url?.let { url ->
val name =
t.name
// TODO would be nice to clean up the trailer name
// ?.replace(item.name ?: "", "")
// ?.removePrefix(" - ")
?: context.getString(R.string.trailer)
RemoteTrailer(name, url)
}
}.orEmpty()
.sortedBy { it.name }
val localTrailerCount = item.data.localTrailerCount ?: 0
val localTrailers =
if (localTrailerCount > 0) {
api.userLibraryApi.getLocalTrailers(item.id).content.map {
LocalTrailer(BaseItem.Companion.from(it, api))
}
} else {
listOf()
}
return localTrailers + remoteTrailers
}
companion object {
/**
* Note: This is explicitly <b>not</b> a member function because the injected Context is not an Activity.
* We want to start the intent without a new task which requires the Activity context
*
* This can be provided by LocalContext.current from Compose
*/
fun onClick(
context: Context,
trailer: Trailer,
navigateTo: (Destination) -> Unit,
) {
when (trailer) {
is LocalTrailer ->
navigateTo.invoke(
Destination.Playback(
itemId = trailer.baseItem.id,
item = trailer.baseItem,
positionMs = 0L,
),
)
is RemoteTrailer -> {
val intent = Intent(Intent.ACTION_VIEW, trailer.url.toUri())
context.startActivity(intent)
}
}
}
}
}

View file

@ -1,4 +1,4 @@
package com.github.damontecres.wholphin.util
package com.github.damontecres.wholphin.services
import android.Manifest
import android.content.ContentValues
@ -15,9 +15,10 @@ import androidx.core.content.FileProvider
import androidx.core.content.edit
import androidx.preference.PreferenceManager
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.hilt.StandardOkHttpClient
import com.github.damontecres.wholphin.services.hilt.StandardOkHttpClient
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
import com.github.damontecres.wholphin.ui.showToast
import com.github.damontecres.wholphin.util.Version
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
@ -110,7 +111,7 @@ class UpdateChecker
fun getInstalledVersion(): Version {
val pkgInfo = context.packageManager.getPackageInfo(context.packageName, 0)
return Version.fromString(pkgInfo.versionName!!)
return Version.Companion.fromString(pkgInfo.versionName!!)
}
suspend fun getLatestRelease(updateUrl: String): Release? {
@ -135,7 +136,7 @@ class UpdateChecker
if (it.isSuccessful && it.body != null) {
val result = Json.parseToJsonElement(it.body!!.string())
val name = result.jsonObject["name"]?.jsonPrimitive?.contentOrNull
val version = Version.tryFromString(name)
val version = Version.Companion.tryFromString(name)
val publishedAt =
result.jsonObject["published_at"]?.jsonPrimitive?.contentOrNull
val body = result.jsonObject["body"]?.jsonPrimitive?.contentOrNull
@ -236,7 +237,7 @@ class UpdateChecker
val targetFile = fallbackDownload(it, callback)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
val intent = Intent(Intent.ACTION_INSTALL_PACKAGE)
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK)
intent.data =
FileProvider.getUriForFile(
context,

View file

@ -1,4 +1,4 @@
package com.github.damontecres.wholphin.hilt
package com.github.damontecres.wholphin.services.hilt
import android.annotation.SuppressLint
import android.content.Context
@ -10,9 +10,9 @@ import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.preferences.updateInterfacePreferences
import com.github.damontecres.wholphin.services.FavoriteWatchManager
import com.github.damontecres.wholphin.services.FavoriteWatchManagerImpl
import com.github.damontecres.wholphin.util.ExceptionHandler
import com.github.damontecres.wholphin.util.FavoriteWatchManager
import com.github.damontecres.wholphin.util.FavoriteWatchManagerImpl
import com.github.damontecres.wholphin.util.RememberTabManager
import dagger.Module
import dagger.Provides

View file

@ -1,4 +1,4 @@
package com.github.damontecres.wholphin.hilt
package com.github.damontecres.wholphin.services.hilt
import android.content.Context
import androidx.datastore.core.DataStore

View file

@ -1,4 +1,4 @@
package com.github.damontecres.wholphin.util
package com.github.damontecres.wholphin.ui
import android.os.Build
import androidx.annotation.StringRes
@ -10,7 +10,7 @@ import com.github.damontecres.wholphin.data.model.ItemPlayback
import com.github.damontecres.wholphin.data.model.chooseSource
import com.github.damontecres.wholphin.data.model.chooseStream
import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
import com.github.damontecres.wholphin.util.languageName
import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.MediaSegmentType
import org.jellyfin.sdk.model.api.MediaStream

View file

@ -31,7 +31,7 @@ import androidx.tv.material3.Text
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.ui.AppColors
import com.github.damontecres.wholphin.ui.enableMarquee
import com.github.damontecres.wholphin.util.seasonEpisode
import com.github.damontecres.wholphin.ui.seasonEpisode
import kotlinx.coroutines.delay
@Composable
@ -105,7 +105,10 @@ fun EpisodeCard(
modifier =
Modifier
.align(Alignment.TopEnd)
.background(AppColors.TransparentBlack50, shape = RoundedCornerShape(8.dp)),
.background(
AppColors.TransparentBlack50,
shape = RoundedCornerShape(8.dp),
),
) {
Text(
text = dto?.seasonEpisode ?: "",
@ -116,7 +119,10 @@ fun EpisodeCard(
}
Column(
verticalArrangement = Arrangement.spacedBy(0.dp),
modifier = Modifier.padding(bottom = spaceBelow).fillMaxWidth(),
modifier =
Modifier
.padding(bottom = spaceBelow)
.fillMaxWidth(),
) {
Text(
text = dto?.seriesName ?: "",

View file

@ -72,8 +72,8 @@ fun PersonCard(
ItemCardImage(
imageUrl = item.imageUrl,
name = item.name,
showOverlay = false,
favorite = false,
showOverlay = true,
favorite = item.favorite,
watched = false,
unwatchedCount = -1,
watchedPercent = null,

View file

@ -34,8 +34,11 @@ import com.github.damontecres.wholphin.data.LibraryDisplayInfoDao
import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.data.model.GetItemsFilter
import com.github.damontecres.wholphin.data.model.GetItemsFilterOverride
import com.github.damontecres.wholphin.data.model.LibraryDisplayInfo
import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.services.FavoriteWatchManager
import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.ui.OneTimeLaunchedEffect
import com.github.damontecres.wholphin.ui.SlimItemFields
import com.github.damontecres.wholphin.ui.data.AddPlaylistViewModel
@ -46,13 +49,12 @@ import com.github.damontecres.wholphin.ui.detail.MoreDialogActions
import com.github.damontecres.wholphin.ui.detail.PlaylistDialog
import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState
import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItemsForHome
import com.github.damontecres.wholphin.ui.nav.NavigationManager
import com.github.damontecres.wholphin.ui.toServerString
import com.github.damontecres.wholphin.ui.tryRequestFocus
import com.github.damontecres.wholphin.util.ApiRequestPager
import com.github.damontecres.wholphin.util.ExceptionHandler
import com.github.damontecres.wholphin.util.FavoriteWatchManager
import com.github.damontecres.wholphin.util.GetItemsRequestHandler
import com.github.damontecres.wholphin.util.GetPersonsHandler
import com.github.damontecres.wholphin.util.LoadingExceptionHandler
import com.github.damontecres.wholphin.util.LoadingState
import dagger.hilt.android.lifecycle.HiltViewModel
@ -69,6 +71,7 @@ import org.jellyfin.sdk.model.api.ItemSortBy
import org.jellyfin.sdk.model.api.MediaType
import org.jellyfin.sdk.model.api.SortOrder
import org.jellyfin.sdk.model.api.request.GetItemsRequest
import org.jellyfin.sdk.model.api.request.GetPersonsRequest
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
import java.util.UUID
import javax.inject.Inject
@ -107,13 +110,12 @@ class CollectionFolderViewModel
}
val sortAndDirection =
if (initialSortAndDirection == null) {
serverRepository.currentUser.value?.let { user ->
libraryDisplayInfoDao.getItem(user, itemId)?.sortAndDirection
} ?: SortAndDirection.DEFAULT
} else {
SortAndDirection.DEFAULT
}
initialSortAndDirection
?: (
serverRepository.currentUser.value?.let { user ->
libraryDisplayInfoDao.getItem(user, itemId)?.sortAndDirection
} ?: SortAndDirection.DEFAULT
)
loadResults(sortAndDirection, recursive, filter)
}
@ -143,7 +145,6 @@ class CollectionFolderViewModel
recursive: Boolean,
filter: GetItemsFilter,
) {
val item = item.value
viewModelScope.launch(Dispatchers.IO) {
withContext(Dispatchers.Main) {
pager.value = listOf()
@ -151,62 +152,7 @@ class CollectionFolderViewModel
this@CollectionFolderViewModel.sortAndDirection.value = sortAndDirection
this@CollectionFolderViewModel.filter.value = filter
}
val includeItemTypes =
when (item?.data?.collectionType) {
CollectionType.MOVIES -> listOf(BaseItemKind.MOVIE)
CollectionType.TVSHOWS -> listOf(BaseItemKind.SERIES)
CollectionType.HOMEVIDEOS -> listOf(BaseItemKind.VIDEO)
CollectionType.MUSIC ->
listOf(
BaseItemKind.AUDIO,
BaseItemKind.MUSIC_ARTIST,
BaseItemKind.MUSIC_ALBUM,
)
CollectionType.BOXSETS -> listOf(BaseItemKind.BOX_SET)
CollectionType.PLAYLISTS -> listOf(BaseItemKind.PLAYLIST)
else -> listOf()
}
val request =
filter.applyTo(
GetItemsRequest(
parentId = item?.id,
enableImageTypes = listOf(ImageType.PRIMARY, ImageType.THUMB),
includeItemTypes = includeItemTypes,
recursive = recursive,
excludeItemIds = item?.let { listOf(item.id) },
sortBy =
buildList {
add(sortAndDirection.sort)
if (sortAndDirection.sort != ItemSortBy.SORT_NAME) {
add(ItemSortBy.SORT_NAME)
}
if (item?.data?.collectionType == CollectionType.MOVIES) {
add(ItemSortBy.PRODUCTION_YEAR)
}
},
sortOrder =
buildList {
add(sortAndDirection.direction)
if (sortAndDirection.sort != ItemSortBy.SORT_NAME) {
add(SortOrder.ASCENDING)
}
if (item?.data?.collectionType == CollectionType.MOVIES) {
add(SortOrder.ASCENDING)
}
},
fields = SlimItemFields,
),
)
val newPager =
ApiRequestPager(
api,
request,
GetItemsRequestHandler,
viewModelScope,
useSeriesForPrimary = true,
)
val newPager = createPager(sortAndDirection, recursive, filter)
newPager.init()
if (newPager.isNotEmpty()) newPager.getBlocking(0)
withContext(Dispatchers.Main) {
@ -216,6 +162,93 @@ class CollectionFolderViewModel
}
}
private fun createPager(
sortAndDirection: SortAndDirection,
recursive: Boolean,
filter: GetItemsFilter,
): ApiRequestPager<out Any> {
val item = item.value
return when (filter.override) {
GetItemsFilterOverride.NONE -> {
val includeItemTypes =
when (item?.data?.collectionType) {
CollectionType.MOVIES -> listOf(BaseItemKind.MOVIE)
CollectionType.TVSHOWS -> listOf(BaseItemKind.SERIES)
CollectionType.HOMEVIDEOS -> listOf(BaseItemKind.VIDEO)
CollectionType.MUSIC ->
listOf(
BaseItemKind.AUDIO,
BaseItemKind.MUSIC_ARTIST,
BaseItemKind.MUSIC_ALBUM,
)
CollectionType.BOXSETS -> listOf(BaseItemKind.BOX_SET)
CollectionType.PLAYLISTS -> listOf(BaseItemKind.PLAYLIST)
else -> listOf()
}
val request =
filter.applyTo(
GetItemsRequest(
parentId = item?.id,
enableImageTypes = listOf(ImageType.PRIMARY, ImageType.THUMB),
includeItemTypes = includeItemTypes,
recursive = recursive,
excludeItemIds = item?.let { listOf(item.id) },
sortBy =
buildList {
add(sortAndDirection.sort)
if (sortAndDirection.sort != ItemSortBy.SORT_NAME) {
add(ItemSortBy.SORT_NAME)
}
if (item?.data?.collectionType == CollectionType.MOVIES) {
add(ItemSortBy.PRODUCTION_YEAR)
}
},
sortOrder =
buildList {
add(sortAndDirection.direction)
if (sortAndDirection.sort != ItemSortBy.SORT_NAME) {
add(SortOrder.ASCENDING)
}
if (item?.data?.collectionType == CollectionType.MOVIES) {
add(SortOrder.ASCENDING)
}
},
fields = SlimItemFields,
),
)
val newPager =
ApiRequestPager(
api,
request,
GetItemsRequestHandler,
viewModelScope,
useSeriesForPrimary = true,
)
newPager
}
GetItemsFilterOverride.PERSON -> {
val request =
filter.applyTo(
GetPersonsRequest(
enableImageTypes = listOf(ImageType.PRIMARY, ImageType.THUMB),
),
)
val newPager =
ApiRequestPager(
api,
request,
GetPersonsHandler,
viewModelScope,
useSeriesForPrimary = true,
)
newPager
}
}
}
suspend fun positionOfLetter(letter: Char): Int? =
withContext(Dispatchers.IO) {
item.value?.let { item ->
@ -437,12 +470,14 @@ fun CollectionFolderGridContent(
modifier = Modifier.fillMaxWidth(),
)
}
SortByButton(
sortOptions = sortOptions,
current = sortAndDirection,
onSortChange = onSortChange,
modifier = Modifier,
)
if (sortOptions.isNotEmpty()) {
SortByButton(
sortOptions = sortOptions,
current = sortAndDirection,
onSortChange = onSortChange,
modifier = Modifier,
)
}
}
CardGrid(
pager = pager,

View file

@ -16,12 +16,12 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.data.model.GetItemsFilter
import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.ui.OneTimeLaunchedEffect
import com.github.damontecres.wholphin.ui.SlimItemFields
import com.github.damontecres.wholphin.ui.cards.GridCard
import com.github.damontecres.wholphin.ui.detail.CardGrid
import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.nav.NavigationManager
import com.github.damontecres.wholphin.ui.tryRequestFocus
import com.github.damontecres.wholphin.util.ApiRequestPager
import com.github.damontecres.wholphin.util.GetGenresRequestHandler

View file

@ -18,6 +18,8 @@ import androidx.lifecycle.viewModelScope
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.services.FavoriteWatchManager
import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.ui.OneTimeLaunchedEffect
import com.github.damontecres.wholphin.ui.data.AddPlaylistViewModel
import com.github.damontecres.wholphin.ui.data.RowColumn
@ -27,9 +29,7 @@ import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState
import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItemsForHome
import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.main.HomePageContent
import com.github.damontecres.wholphin.ui.nav.NavigationManager
import com.github.damontecres.wholphin.util.ApiRequestPager
import com.github.damontecres.wholphin.util.FavoriteWatchManager
import com.github.damontecres.wholphin.util.HomeRowLoadingState
import com.github.damontecres.wholphin.util.LoadingState
import kotlinx.coroutines.flow.MutableStateFlow
@ -42,7 +42,7 @@ abstract class RecommendedViewModel(
) : ViewModel() {
abstract fun init()
abstract val rows: MutableStateFlow<MutableList<HomeRowLoadingState>>
abstract val rows: MutableStateFlow<List<HomeRowLoadingState>>
val loading = MutableLiveData<LoadingState>(LoadingState.Loading)

View file

@ -8,13 +8,13 @@ import androidx.lifecycle.viewModelScope
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.services.FavoriteWatchManager
import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.ui.SlimItemFields
import com.github.damontecres.wholphin.ui.data.RowColumn
import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.nav.NavigationManager
import com.github.damontecres.wholphin.util.ApiRequestPager
import com.github.damontecres.wholphin.util.ExceptionHandler
import com.github.damontecres.wholphin.util.FavoriteWatchManager
import com.github.damontecres.wholphin.util.GetItemsRequestHandler
import com.github.damontecres.wholphin.util.GetResumeItemsRequestHandler
import com.github.damontecres.wholphin.util.GetSuggestionsRequestHandler
@ -57,13 +57,13 @@ class RecommendedMovieViewModel
}
override val rows =
MutableStateFlow<MutableList<HomeRowLoadingState>>(
MutableStateFlow<List<HomeRowLoadingState>>(
rowTitles
.map {
HomeRowLoadingState.Pending(
context.getString(it),
)
}.toMutableList(),
},
)
override fun init() {
@ -166,6 +166,9 @@ class RecommendedMovieViewModel
val result =
try {
pager.init()
if (pager.isNotEmpty()) {
pager.getBlocking(0)
}
HomeRowLoadingState.Success(title, pager)
} catch (ex: Exception) {
Timber.e(ex, "Error fetching %s", title)
@ -182,7 +185,7 @@ class RecommendedMovieViewModel
row: HomeRowLoadingState,
) {
rows.update { current ->
current.apply { set(position, row) }
current.toMutableList().apply { set(position, row) }
}
}

View file

@ -8,13 +8,13 @@ import androidx.lifecycle.viewModelScope
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.services.FavoriteWatchManager
import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.ui.SlimItemFields
import com.github.damontecres.wholphin.ui.data.RowColumn
import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.nav.NavigationManager
import com.github.damontecres.wholphin.util.ApiRequestPager
import com.github.damontecres.wholphin.util.ExceptionHandler
import com.github.damontecres.wholphin.util.FavoriteWatchManager
import com.github.damontecres.wholphin.util.GetItemsRequestHandler
import com.github.damontecres.wholphin.util.GetNextUpRequestHandler
import com.github.damontecres.wholphin.util.GetResumeItemsRequestHandler
@ -59,13 +59,13 @@ class RecommendedTvShowViewModel
}
override val rows =
MutableStateFlow<MutableList<HomeRowLoadingState>>(
MutableStateFlow<List<HomeRowLoadingState>>(
rowTitles
.map {
HomeRowLoadingState.Pending(
context.getString(it),
)
}.toMutableList(),
},
)
override fun init() {
@ -210,7 +210,7 @@ class RecommendedTvShowViewModel
row: HomeRowLoadingState,
) {
rows.update { current ->
current.apply { set(position, row) }
current.toMutableList().apply { set(position, row) }
}
}

View file

@ -5,7 +5,7 @@ import android.widget.Toast
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.github.damontecres.wholphin.data.model.PlaylistCreator
import com.github.damontecres.wholphin.services.PlaylistCreator
import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState
import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.setValueOnMain

View file

@ -14,11 +14,11 @@ import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.ProvideTextStyle
import androidx.tv.material3.Text
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.ui.byteRateSuffixes
import com.github.damontecres.wholphin.ui.components.ScrollableDialog
import com.github.damontecres.wholphin.ui.formatBytes
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
import com.github.damontecres.wholphin.ui.letNotEmpty
import com.github.damontecres.wholphin.util.byteRateSuffixes
import com.github.damontecres.wholphin.util.formatBytes
import com.github.damontecres.wholphin.util.languageName
import org.jellyfin.sdk.model.api.AudioSpatialFormat
import org.jellyfin.sdk.model.api.MediaSourceInfo

View file

@ -79,5 +79,6 @@ fun getStringRes(sort: ItemSortBy): Int =
ItemSortBy.CRITIC_RATING -> R.string.critic_rating
ItemSortBy.PLAY_COUNT -> R.string.play_count
ItemSortBy.AIRED_EPISODE_ORDER -> R.string.aired_episode_order
ItemSortBy.DEFAULT -> R.string.default_track
else -> throw IllegalArgumentException("Unsupported sort option: $sort")
}

View file

@ -31,6 +31,7 @@ import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.data.model.GetItemsFilter
import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.ui.components.CollectionFolderGrid
import com.github.damontecres.wholphin.ui.components.ErrorMessage
import com.github.damontecres.wholphin.ui.components.TabRow
@ -40,7 +41,6 @@ import com.github.damontecres.wholphin.ui.detail.livetv.TvGuideGrid
import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.logTab
import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.nav.NavigationManager
import com.github.damontecres.wholphin.ui.setValueOnMain
import com.github.damontecres.wholphin.ui.tryRequestFocus
import com.github.damontecres.wholphin.util.RememberTabManager

View file

@ -288,7 +288,6 @@ fun buildMoreDialogItemsForHome(
fun buildMoreDialogItemsForPerson(
context: Context,
person: Person,
// favorite: Boolean,
actions: MoreDialogActions,
): List<DialogItem> =
buildList {
@ -301,14 +300,13 @@ fun buildMoreDialogItemsForPerson(
actions.navigateTo(Destination.MediaItem(itemId, BaseItemKind.PERSON))
},
)
// TODO need way to get person's favorite status
// add(
// DialogItem(
// text = if (favorite) R.string.remove_favorite else R.string.add_favorite,
// iconStringRes = R.string.fa_heart,
// iconColor = if (favorite) Color.Red else Color.Unspecified,
// ) {
// actions.onClickFavorite.invoke(itemId, !favorite)
// },
// )
add(
DialogItem(
text = if (person.favorite) R.string.remove_favorite else R.string.add_favorite,
iconStringRes = R.string.fa_heart,
iconColor = if (person.favorite) Color.Red else Color.Unspecified,
) {
actions.onClickFavorite.invoke(itemId, !person.favorite)
},
)
}

View file

@ -25,6 +25,7 @@ import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.data.model.GetItemsFilter
import com.github.damontecres.wholphin.data.model.GetItemsFilterOverride
import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.ui.components.CollectionFolderGrid
import com.github.damontecres.wholphin.ui.components.ErrorMessage
@ -32,11 +33,15 @@ import com.github.damontecres.wholphin.ui.components.TabRow
import com.github.damontecres.wholphin.ui.data.EpisodeSortOptions
import com.github.damontecres.wholphin.ui.data.MovieSortOptions
import com.github.damontecres.wholphin.ui.data.SeriesSortOptions
import com.github.damontecres.wholphin.ui.data.SortAndDirection
import com.github.damontecres.wholphin.ui.data.VideoSortOptions
import com.github.damontecres.wholphin.ui.logTab
import com.github.damontecres.wholphin.ui.nav.NavDrawerItem
import com.github.damontecres.wholphin.ui.preferences.PreferencesViewModel
import com.github.damontecres.wholphin.ui.tryRequestFocus
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.ItemSortBy
import org.jellyfin.sdk.model.api.SortOrder
@Composable
fun FavoritesPage(
@ -59,6 +64,9 @@ fun FavoritesPage(
stringResource(R.string.movies),
stringResource(R.string.tv_shows),
stringResource(R.string.episodes),
stringResource(R.string.videos),
stringResource(R.string.playlists),
stringResource(R.string.people),
)
var selectedTabIndex by rememberSaveable { mutableIntStateOf(rememberedTabIndex) }
val focusRequester = remember { FocusRequester() }
@ -165,6 +173,81 @@ fun FavoritesPage(
},
)
}
3 -> {
CollectionFolderGrid(
preferences = preferences,
onClickItem = { _, item -> onClickItem.invoke(item) },
itemId = "${NavDrawerItem.Favorites.id}_videos",
initialFilter =
GetItemsFilter(
favorite = true,
includeItemTypes = listOf(BaseItemKind.VIDEO),
),
showTitle = false,
recursive = true,
sortOptions = VideoSortOptions,
modifier =
Modifier
.padding(start = 16.dp)
.fillMaxSize()
.focusRequester(focusRequester),
positionCallback = { columns, position ->
showHeader = position < columns
},
)
}
4 -> {
CollectionFolderGrid(
preferences = preferences,
onClickItem = { _, item -> onClickItem.invoke(item) },
itemId = "${NavDrawerItem.Favorites.id}_playlists",
initialFilter =
GetItemsFilter(
favorite = true,
includeItemTypes = listOf(BaseItemKind.PLAYLIST),
),
showTitle = false,
recursive = true,
sortOptions = VideoSortOptions,
modifier =
Modifier
.padding(start = 16.dp)
.fillMaxSize()
.focusRequester(focusRequester),
positionCallback = { columns, position ->
showHeader = position < columns
},
)
}
5 -> {
CollectionFolderGrid(
preferences = preferences,
onClickItem = { _, item -> onClickItem.invoke(item) },
itemId = "${NavDrawerItem.Favorites.id}_people",
initialFilter =
GetItemsFilter(
favorite = true,
override = GetItemsFilterOverride.PERSON,
),
initialSortAndDirection =
SortAndDirection(
ItemSortBy.DEFAULT,
SortOrder.ASCENDING,
),
showTitle = false,
recursive = true,
sortOptions = listOf(),
modifier =
Modifier
.padding(start = 16.dp)
.fillMaxSize()
.focusRequester(focusRequester),
positionCallback = { columns, position ->
showHeader = position < columns
},
)
}
else -> ErrorMessage("Invalid tab index $selectedTabIndex", null)
}
}

View file

@ -26,6 +26,8 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
@ -39,18 +41,22 @@ import coil3.compose.AsyncImage
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.services.FavoriteWatchManager
import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.ui.OneTimeLaunchedEffect
import com.github.damontecres.wholphin.ui.PreviewTvSpec
import com.github.damontecres.wholphin.ui.SlimItemFields
import com.github.damontecres.wholphin.ui.components.ErrorMessage
import com.github.damontecres.wholphin.ui.components.ExpandableFaButton
import com.github.damontecres.wholphin.ui.components.LoadingPage
import com.github.damontecres.wholphin.ui.components.LoadingRow
import com.github.damontecres.wholphin.ui.components.OverviewText
import com.github.damontecres.wholphin.ui.data.ItemDetailsDialog
import com.github.damontecres.wholphin.ui.data.ItemDetailsDialogInfo
import com.github.damontecres.wholphin.ui.formatDate
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.nav.NavigationManager
import com.github.damontecres.wholphin.ui.rememberPosition
import com.github.damontecres.wholphin.ui.setValueOnMain
import com.github.damontecres.wholphin.ui.theme.WholphinTheme
@ -60,7 +66,6 @@ import com.github.damontecres.wholphin.util.GetItemsRequestHandler
import com.github.damontecres.wholphin.util.LoadingExceptionHandler
import com.github.damontecres.wholphin.util.LoadingState
import com.github.damontecres.wholphin.util.RowLoadingState
import com.github.damontecres.wholphin.util.formatDate
import dagger.hilt.android.lifecycle.HiltViewModel
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.model.api.BaseItemKind
@ -78,6 +83,7 @@ class PersonViewModel
constructor(
api: ApiClient,
val navigationManager: NavigationManager,
private val favoriteWatchManager: FavoriteWatchManager,
) : LoadingItemViewModel(api) {
val movies = MutableLiveData<RowLoadingState>(RowLoadingState.Pending)
val series = MutableLiveData<RowLoadingState>(RowLoadingState.Pending)
@ -144,6 +150,13 @@ class PersonViewModel
}
}
}
fun setFavorite(favorite: Boolean) {
viewModelScope.launchIO {
favoriteWatchManager.setFavorite(itemUuid, favorite)
fetchAndSetItem(itemUuid)
}
}
}
@Composable
@ -180,6 +193,7 @@ fun PersonPage(
birthdate = person.data.premiereDate?.toLocalDate(),
deathdate = person.data.endDate?.toLocalDate(),
birthPlace = person.data.productionLocations?.firstOrNull(),
favorite = person.favorite,
movies = movies,
series = series,
episodes = episodes,
@ -187,6 +201,9 @@ fun PersonPage(
viewModel.navigationManager.navigateTo(item.destination())
},
overviewOnClick = { showOverviewDialog = true },
favoriteOnClick = {
viewModel.setFavorite(!person.favorite)
},
modifier = modifier,
)
AnimatedVisibility(showOverviewDialog) {
@ -220,11 +237,13 @@ fun PersonPageContent(
birthdate: LocalDate?,
deathdate: LocalDate?,
birthPlace: String?,
favorite: Boolean,
movies: RowLoadingState,
series: RowLoadingState,
episodes: RowLoadingState,
onClickItem: (Int, BaseItem) -> Unit,
overviewOnClick: () -> Unit,
favoriteOnClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val focusRequester = remember { FocusRequester() }
@ -237,8 +256,10 @@ fun PersonPageContent(
headerFocusRequester.tryRequestFocus()
}
}
var focusedOnHeader by remember { mutableStateOf(true) }
LazyColumn(
verticalArrangement = Arrangement.spacedBy(16.dp),
userScrollEnabled = !focusedOnHeader,
modifier =
modifier
.padding(start = 16.dp),
@ -251,13 +272,18 @@ fun PersonPageContent(
birthdate = birthdate,
birthPlace = birthPlace,
deathdate = deathdate,
favorite = favorite,
overviewOnClick = overviewOnClick,
favoriteOnClick = favoriteOnClick,
modifier =
Modifier
.fillMaxWidth()
.heightIn(max = 480.dp)
.padding(top = 16.dp, bottom = 40.dp)
.focusRequester(headerFocusRequester),
.focusRequester(headerFocusRequester)
.onFocusChanged {
focusedOnHeader = it.hasFocus
},
)
}
item {
@ -321,7 +347,9 @@ fun PersonHeader(
birthdate: LocalDate?,
deathdate: LocalDate?,
birthPlace: String?,
favorite: Boolean,
overviewOnClick: () -> Unit,
favoriteOnClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val bringIntoViewRequester = remember { BringIntoViewRequester() }
@ -380,9 +408,9 @@ fun PersonHeader(
overflow = TextOverflow.Ellipsis,
)
}
birthPlace?.let {
if (birthPlace.isNotNullOrBlank()) {
Text(
text = stringResource(R.string.birthplace) + ": $it",
text = stringResource(R.string.birthplace) + ": $birthPlace",
color = MaterialTheme.colorScheme.onSurface,
style = MaterialTheme.typography.titleSmall,
maxLines = 1,
@ -411,9 +439,12 @@ fun PersonHeader(
)
}
val interactionSource = remember { MutableInteractionSource() }
val focused by interactionSource.collectIsFocusedAsState()
if (focused) {
LaunchedEffect(Unit) {
val buttonInteractionSource = remember { MutableInteractionSource() }
val focused =
interactionSource.collectIsFocusedAsState().value ||
buttonInteractionSource.collectIsFocusedAsState().value
LaunchedEffect(focused) {
if (focused) {
bringIntoViewRequester.bringIntoView()
}
}
@ -424,6 +455,14 @@ fun PersonHeader(
interactionSource = interactionSource,
modifier = Modifier.padding(top = 8.dp),
)
ExpandableFaButton(
title = if (favorite) R.string.remove_favorite else R.string.add_favorite,
iconStringRes = R.string.fa_heart,
onClick = favoriteOnClick,
iconColor = if (favorite) Color.Red else Color.Unspecified,
interactionSource = buttonInteractionSource,
modifier = Modifier,
)
}
}
}
@ -440,6 +479,8 @@ private fun PersonPreview() {
birthPlace = "Phoenix, Arizona, USA",
deathdate = LocalDate.of(2025, 2, 1),
overviewOnClick = {},
favorite = true,
favoriteOnClick = {},
modifier = Modifier.fillMaxWidth(),
)
}

View file

@ -56,6 +56,7 @@ import androidx.tv.material3.surfaceColorAtElevation
import coil3.compose.AsyncImage
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.ui.DefaultItemFields
import com.github.damontecres.wholphin.ui.cards.ItemCardImage
import com.github.damontecres.wholphin.ui.components.DialogItem
@ -70,7 +71,6 @@ import com.github.damontecres.wholphin.ui.enableMarquee
import com.github.damontecres.wholphin.ui.ifElse
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.nav.NavigationManager
import com.github.damontecres.wholphin.ui.roundMinutes
import com.github.damontecres.wholphin.ui.tryRequestFocus
import com.github.damontecres.wholphin.util.ApiRequestPager

View file

@ -34,17 +34,17 @@ import androidx.tv.material3.Text
import androidx.tv.material3.surfaceColorAtElevation
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.ui.SlimItemFields
import com.github.damontecres.wholphin.ui.components.ErrorMessage
import com.github.damontecres.wholphin.ui.components.LoadingPage
import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.nav.NavigationManager
import com.github.damontecres.wholphin.ui.seasonEpisode
import com.github.damontecres.wholphin.ui.tryRequestFocus
import com.github.damontecres.wholphin.util.ExceptionHandler
import com.github.damontecres.wholphin.util.LoadingExceptionHandler
import com.github.damontecres.wholphin.util.LoadingState
import com.github.damontecres.wholphin.util.seasonEpisode
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext

View file

@ -8,12 +8,12 @@ import androidx.lifecycle.viewModelScope
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.WholphinApplication
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.ui.AppColors
import com.github.damontecres.wholphin.ui.data.RowColumn
import com.github.damontecres.wholphin.ui.detail.series.SeasonEpisode
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.nav.NavigationManager
import com.github.damontecres.wholphin.ui.setValueOnMain
import com.github.damontecres.wholphin.ui.toServerString
import com.github.damontecres.wholphin.util.ExceptionHandler

View file

@ -36,9 +36,9 @@ import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.ui.components.CircularProgress
import com.github.damontecres.wholphin.ui.components.ErrorMessage
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
import com.github.damontecres.wholphin.ui.seasonEpisode
import com.github.damontecres.wholphin.ui.tryRequestFocus
import com.github.damontecres.wholphin.util.LoadingState
import com.github.damontecres.wholphin.util.seasonEpisode
import java.time.LocalDateTime
import java.time.ZoneId

View file

@ -1,6 +1,5 @@
package com.github.damontecres.wholphin.ui.detail.movie
import android.content.Intent
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@ -54,6 +53,7 @@ import com.github.damontecres.wholphin.data.model.RemoteTrailer
import com.github.damontecres.wholphin.data.model.Trailer
import com.github.damontecres.wholphin.data.model.chooseSource
import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.services.TrailerService
import com.github.damontecres.wholphin.ui.Cards
import com.github.damontecres.wholphin.ui.cards.ChapterRow
import com.github.damontecres.wholphin.ui.cards.ItemRow
@ -277,22 +277,8 @@ fun MovieDetails(
items = items,
)
},
trailerOnClick = { trailer ->
when (trailer) {
is LocalTrailer ->
viewModel.navigateTo(
Destination.Playback(
itemId = trailer.baseItem.id,
item = trailer.baseItem,
positionMs = 0L,
),
)
is RemoteTrailer -> {
val intent = Intent(Intent.ACTION_VIEW, trailer.url.toUri())
context.startActivity(intent)
}
}
trailerOnClick = {
TrailerService.onClick(context, it, viewModel::navigateTo)
},
modifier = modifier,
)
@ -567,7 +553,7 @@ fun TrailerRow(
LazyRow(
state = state,
horizontalArrangement = Arrangement.spacedBy(16.dp),
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp),
contentPadding = PaddingValues(horizontal = 24.dp, vertical = 8.dp),
modifier =
Modifier
.fillMaxWidth()

View file

@ -32,11 +32,11 @@ import com.github.damontecres.wholphin.ui.components.DotSeparatedRow
import com.github.damontecres.wholphin.ui.components.OverviewText
import com.github.damontecres.wholphin.ui.components.SimpleStarRating
import com.github.damontecres.wholphin.ui.components.TitleValueText
import com.github.damontecres.wholphin.ui.getAudioDisplay
import com.github.damontecres.wholphin.ui.getSubtitleDisplay
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
import com.github.damontecres.wholphin.ui.roundMinutes
import com.github.damontecres.wholphin.ui.timeRemaining
import com.github.damontecres.wholphin.util.getAudioDisplay
import com.github.damontecres.wholphin.util.getSubtitleDisplay
import org.jellyfin.sdk.model.api.MediaStreamType
import org.jellyfin.sdk.model.api.PersonKind
import org.jellyfin.sdk.model.extensions.ticks

View file

@ -4,29 +4,27 @@ import android.content.Context
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.ChosenStreams
import com.github.damontecres.wholphin.data.ItemPlaybackRepository
import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.data.model.Chapter
import com.github.damontecres.wholphin.data.model.ItemPlayback
import com.github.damontecres.wholphin.data.model.LocalTrailer
import com.github.damontecres.wholphin.data.model.Person
import com.github.damontecres.wholphin.data.model.RemoteTrailer
import com.github.damontecres.wholphin.data.model.Trailer
import com.github.damontecres.wholphin.preferences.ThemeSongVolume
import com.github.damontecres.wholphin.services.FavoriteWatchManager
import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.services.PeopleFavorites
import com.github.damontecres.wholphin.services.ThemeSongPlayer
import com.github.damontecres.wholphin.services.TrailerService
import com.github.damontecres.wholphin.ui.SlimItemFields
import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.letNotEmpty
import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.nav.NavigationManager
import com.github.damontecres.wholphin.ui.setValueOnMain
import com.github.damontecres.wholphin.util.ExceptionHandler
import com.github.damontecres.wholphin.util.FavoriteWatchManager
import com.github.damontecres.wholphin.util.LoadingExceptionHandler
import com.github.damontecres.wholphin.util.LoadingState
import com.github.damontecres.wholphin.util.ThemeSongPlayer
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
@ -56,6 +54,8 @@ class MovieViewModel
val itemPlaybackRepository: ItemPlaybackRepository,
private val themeSongPlayer: ThemeSongPlayer,
private val favoriteWatchManager: FavoriteWatchManager,
private val peopleFavorites: PeopleFavorites,
private val trailerService: TrailerService,
@Assisted val itemId: UUID,
) : ViewModel() {
@AssistedFactory
@ -107,39 +107,16 @@ class MovieViewModel
loading.value = LoadingState.Success
}
viewModelScope.launchIO {
val remoteTrailers =
item.data.remoteTrailers
?.mapNotNull { t ->
t.url?.let { url ->
val name =
t.name
// TODO would be nice to clean up the trailer name
// ?.replace(item.name ?: "", "")
// ?.removePrefix(" - ")
?: context.getString(R.string.trailer)
RemoteTrailer(name, url)
}
}.orEmpty()
.sortedBy { it.name }
val localTrailerCount = item.data.localTrailerCount ?: 0
val localTrailers =
if (localTrailerCount > 0) {
api.userLibraryApi.getLocalTrailers(itemId).content.map {
LocalTrailer(BaseItem.Companion.from(it, api))
}
} else {
listOf()
}
val trailers = trailerService.getTrailers(item)
withContext(Dispatchers.Main) {
this@MovieViewModel.trailers.value = localTrailers + remoteTrailers
this@MovieViewModel.trailers.value = trailers
}
}
viewModelScope.launchIO {
val people = peopleFavorites.getPeopleFor(item)
this@MovieViewModel.people.setValueOnMain(people)
}
withContext(Dispatchers.Main) {
people.value =
item.data.people
?.letNotEmpty { people ->
people.map { Person.fromDto(it, api) }
}.orEmpty()
chapters.value = Chapter.fromDto(item.data, api)
}
if (!similar.isInitialized) {
@ -171,7 +148,14 @@ class MovieViewModel
favorite: Boolean,
) = viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) {
favoriteWatchManager.setFavorite(itemId, favorite)
val item = item.value
fetchAndSetItem()
if (item != null && itemId != item.id) {
viewModelScope.launchIO {
val people = peopleFavorites.getPeopleFor(item)
this@MovieViewModel.people.setValueOnMain(people)
}
}
}
fun savePlayVersion(

View file

@ -17,9 +17,9 @@ import com.github.damontecres.wholphin.data.model.chooseStream
import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.ui.components.ExpandablePlayButtons
import com.github.damontecres.wholphin.ui.components.TitleValueText
import com.github.damontecres.wholphin.ui.getAudioDisplay
import com.github.damontecres.wholphin.ui.getSubtitleDisplay
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
import com.github.damontecres.wholphin.util.getAudioDisplay
import com.github.damontecres.wholphin.util.getSubtitleDisplay
import org.jellyfin.sdk.model.api.MediaStreamType
import org.jellyfin.sdk.model.extensions.ticks
import kotlin.time.Duration

View file

@ -16,10 +16,10 @@ import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.ui.components.DotSeparatedRow
import com.github.damontecres.wholphin.ui.components.OverviewText
import com.github.damontecres.wholphin.ui.components.SimpleStarRating
import com.github.damontecres.wholphin.ui.formatDateTime
import com.github.damontecres.wholphin.ui.roundMinutes
import com.github.damontecres.wholphin.ui.seasonEpisode
import com.github.damontecres.wholphin.ui.timeRemaining
import com.github.damontecres.wholphin.util.formatDateTime
import com.github.damontecres.wholphin.util.seasonEpisode
import org.jellyfin.sdk.model.extensions.ticks
@Composable

View file

@ -46,7 +46,9 @@ import coil3.compose.AsyncImage
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.data.model.Person
import com.github.damontecres.wholphin.data.model.Trailer
import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.services.TrailerService
import com.github.damontecres.wholphin.ui.Cards
import com.github.damontecres.wholphin.ui.cards.ItemRow
import com.github.damontecres.wholphin.ui.cards.PersonRow
@ -71,6 +73,7 @@ import com.github.damontecres.wholphin.ui.detail.PlaylistDialog
import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState
import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItemsForHome
import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItemsForPerson
import com.github.damontecres.wholphin.ui.detail.movie.TrailerRow
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
import com.github.damontecres.wholphin.ui.letNotEmpty
import com.github.damontecres.wholphin.ui.nav.Destination
@ -102,6 +105,7 @@ fun SeriesDetails(
val item by viewModel.item.observeAsState()
val seasons by viewModel.seasons.observeAsState(listOf())
val trailers by viewModel.trailers.observeAsState(listOf())
val people by viewModel.people.observeAsState(listOf())
val similar by viewModel.similar.observeAsState(listOf())
@ -133,6 +137,7 @@ fun SeriesDetails(
preferences = preferences,
series = item,
seasons = seasons,
trailers = trailers,
people = people,
similar = similar,
played = played,
@ -174,6 +179,9 @@ fun SeriesDetails(
val favorite = item.data.userData?.isFavorite ?: false
viewModel.setFavorite(item.id, !favorite, null)
},
trailerOnClick = {
TrailerService.onClick(context, it, viewModel::navigateTo)
},
moreActions =
MoreDialogActions(
navigateTo = { viewModel.navigateTo(it) },
@ -244,7 +252,8 @@ fun SeriesDetails(
private const val HEADER_ROW = 0
private const val SEASONS_ROW = HEADER_ROW + 1
private const val PEOPLE_ROW = SEASONS_ROW + 1
private const val SIMILAR_ROW = PEOPLE_ROW + 1
private const val TRAILER_ROW = PEOPLE_ROW + 1
private const val SIMILAR_ROW = TRAILER_ROW + 1
@Composable
fun SeriesDetailsContent(
@ -252,6 +261,7 @@ fun SeriesDetailsContent(
series: BaseItem,
seasons: List<BaseItem>,
similar: List<BaseItem>,
trailers: List<Trailer>,
people: List<Person>,
played: Boolean,
favorite: Boolean,
@ -262,6 +272,7 @@ fun SeriesDetailsContent(
playOnClick: () -> Unit,
watchOnClick: () -> Unit,
favoriteOnClick: () -> Unit,
trailerOnClick: (Trailer) -> Unit,
moreActions: MoreDialogActions,
modifier: Modifier = Modifier,
) {
@ -452,6 +463,21 @@ fun SeriesDetailsContent(
)
}
}
if (trailers.isNotEmpty()) {
item {
TrailerRow(
trailers = trailers,
onClickTrailer = {
position = TRAILER_ROW
trailerOnClick.invoke(it)
},
modifier =
Modifier
.fillMaxWidth()
.focusRequester(focusRequesters[TRAILER_ROW]),
)
}
}
if (similar.isNotEmpty()) {
item {
ItemRow(

View file

@ -39,9 +39,9 @@ import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItems
import com.github.damontecres.wholphin.ui.equalsNotNull
import com.github.damontecres.wholphin.ui.indexOfFirstOrNull
import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.seasonEpisode
import com.github.damontecres.wholphin.ui.tryRequestFocus
import com.github.damontecres.wholphin.util.LoadingState
import com.github.damontecres.wholphin.util.seasonEpisode
import kotlinx.serialization.Serializable
import kotlinx.serialization.UseSerializers
import org.jellyfin.sdk.model.api.ImageType

View file

@ -49,11 +49,11 @@ import com.github.damontecres.wholphin.ui.cards.BannerCard
import com.github.damontecres.wholphin.ui.components.ErrorMessage
import com.github.damontecres.wholphin.ui.components.LoadingPage
import com.github.damontecres.wholphin.ui.components.TabRow
import com.github.damontecres.wholphin.ui.formatDateTime
import com.github.damontecres.wholphin.ui.ifElse
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
import com.github.damontecres.wholphin.ui.logTab
import com.github.damontecres.wholphin.ui.tryRequestFocus
import com.github.damontecres.wholphin.util.formatDateTime
import kotlin.time.Duration
@Composable

View file

@ -10,25 +10,27 @@ import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.data.model.ItemPlayback
import com.github.damontecres.wholphin.data.model.Person
import com.github.damontecres.wholphin.data.model.Trailer
import com.github.damontecres.wholphin.preferences.ThemeSongVolume
import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.services.FavoriteWatchManager
import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.services.PeopleFavorites
import com.github.damontecres.wholphin.services.ThemeSongPlayer
import com.github.damontecres.wholphin.services.TrailerService
import com.github.damontecres.wholphin.ui.SlimItemFields
import com.github.damontecres.wholphin.ui.detail.ItemViewModel
import com.github.damontecres.wholphin.ui.equalsNotNull
import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.letNotEmpty
import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.nav.NavigationManager
import com.github.damontecres.wholphin.ui.setValueOnMain
import com.github.damontecres.wholphin.ui.showToast
import com.github.damontecres.wholphin.util.ApiRequestPager
import com.github.damontecres.wholphin.util.ExceptionHandler
import com.github.damontecres.wholphin.util.FavoriteWatchManager
import com.github.damontecres.wholphin.util.GetEpisodesRequestHandler
import com.github.damontecres.wholphin.util.GetItemsRequestHandler
import com.github.damontecres.wholphin.util.LoadingExceptionHandler
import com.github.damontecres.wholphin.util.LoadingState
import com.github.damontecres.wholphin.util.ThemeSongPlayer
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers
@ -61,12 +63,15 @@ class SeriesViewModel
private val itemPlaybackRepository: ItemPlaybackRepository,
private val themeSongPlayer: ThemeSongPlayer,
private val favoriteWatchManager: FavoriteWatchManager,
private val peopleFavorites: PeopleFavorites,
private val trailerService: TrailerService,
) : ItemViewModel(api) {
private lateinit var seriesId: UUID
private lateinit var prefs: UserPreferences
val loading = MutableLiveData<LoadingState>(LoadingState.Loading)
val seasons = MutableLiveData<List<BaseItem>>(listOf())
val episodes = MutableLiveData<EpisodeList>(EpisodeList.Loading)
val trailers = MutableLiveData<List<Trailer>>(listOf())
val people = MutableLiveData<List<Person>>(listOf())
val similar = MutableLiveData<List<BaseItem>>()
@ -109,11 +114,16 @@ class SeriesViewModel
this@SeriesViewModel.seasons.value = seasons
episodes.value = episodeInfo
loading.value = LoadingState.Success
people.value =
item.data.people
?.letNotEmpty { people ->
people.map { Person.fromDto(it, api) }
}.orEmpty()
}
viewModelScope.launchIO {
val trailers = trailerService.getTrailers(item)
withContext(Dispatchers.Main) {
this@SeriesViewModel.trailers.value = trailers
}
}
viewModelScope.launchIO {
val people = peopleFavorites.getPeopleFor(item)
this@SeriesViewModel.people.setValueOnMain(people)
}
if (!similar.isInitialized) {
viewModelScope.launchIO {
@ -255,7 +265,11 @@ class SeriesViewModel
if (listIndex != null) {
refreshEpisode(itemId, listIndex)
} else {
fetchItem(seriesId)
val item = fetchItem(seriesId)
viewModelScope.launchIO {
val people = peopleFavorites.getPeopleFor(item)
this@SeriesViewModel.people.setValueOnMain(people)
}
}
}

View file

@ -62,15 +62,15 @@ import com.github.damontecres.wholphin.ui.detail.MoreDialogActions
import com.github.damontecres.wholphin.ui.detail.PlaylistDialog
import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState
import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItemsForHome
import com.github.damontecres.wholphin.ui.formatDateTime
import com.github.damontecres.wholphin.ui.ifElse
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
import com.github.damontecres.wholphin.ui.roundMinutes
import com.github.damontecres.wholphin.ui.seasonEpisode
import com.github.damontecres.wholphin.ui.timeRemaining
import com.github.damontecres.wholphin.ui.tryRequestFocus
import com.github.damontecres.wholphin.util.HomeRowLoadingState
import com.github.damontecres.wholphin.util.LoadingState
import com.github.damontecres.wholphin.util.formatDateTime
import com.github.damontecres.wholphin.util.seasonEpisode
import kotlinx.coroutines.delay
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.MediaType
@ -360,9 +360,15 @@ fun HomePageContent(
),
).onFocusChanged {
if (it.isFocused) {
val nonEmptyRowBefore =
homeRows
.subList(0, rowIndex)
.count {
it is HomeRowLoadingState.Success && it.items.isEmpty()
}
onFocusPosition?.invoke(
RowColumn(
rowIndex,
rowIndex - nonEmptyRowBefore,
index,
),
)

View file

@ -9,12 +9,12 @@ import com.github.damontecres.wholphin.data.NavDrawerItemRepository
import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.services.FavoriteWatchManager
import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.ui.SlimItemFields
import com.github.damontecres.wholphin.ui.nav.NavigationManager
import com.github.damontecres.wholphin.ui.nav.ServerNavDrawerItem
import com.github.damontecres.wholphin.ui.setValueOnMain
import com.github.damontecres.wholphin.util.ExceptionHandler
import com.github.damontecres.wholphin.util.FavoriteWatchManager
import com.github.damontecres.wholphin.util.HomeRowLoadingState
import com.github.damontecres.wholphin.util.LoadingExceptionHandler
import com.github.damontecres.wholphin.util.LoadingState

View file

@ -33,6 +33,7 @@ import androidx.tv.material3.Text
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.ui.Cards
import com.github.damontecres.wholphin.ui.SlimItemFields
import com.github.damontecres.wholphin.ui.cards.EpisodeCard
@ -42,7 +43,6 @@ import com.github.damontecres.wholphin.ui.components.SearchEditTextBox
import com.github.damontecres.wholphin.ui.data.RowColumn
import com.github.damontecres.wholphin.ui.ifElse
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
import com.github.damontecres.wholphin.ui.nav.NavigationManager
import com.github.damontecres.wholphin.ui.rememberPosition
import com.github.damontecres.wholphin.ui.tryRequestFocus
import com.github.damontecres.wholphin.util.ApiRequestPager

View file

@ -10,6 +10,7 @@ import androidx.navigation3.ui.NavDisplay
import com.github.damontecres.wholphin.data.model.JellyfinServer
import com.github.damontecres.wholphin.data.model.JellyfinUser
import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.ui.components.ErrorMessage
import org.jellyfin.sdk.model.api.DeviceProfile

View file

@ -68,7 +68,9 @@ import com.github.damontecres.wholphin.data.NavDrawerItemRepository
import com.github.damontecres.wholphin.data.model.JellyfinServer
import com.github.damontecres.wholphin.data.model.JellyfinUser
import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.ui.FontAwesome
import com.github.damontecres.wholphin.ui.TimeFormatter
import com.github.damontecres.wholphin.ui.ifElse
import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.preferences.PreferenceScreenOption
@ -76,7 +78,6 @@ import com.github.damontecres.wholphin.ui.setValueOnMain
import com.github.damontecres.wholphin.ui.spacedByWithFooter
import com.github.damontecres.wholphin.ui.toServerString
import com.github.damontecres.wholphin.ui.tryRequestFocus
import com.github.damontecres.wholphin.util.TimeFormatter
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay

View file

@ -39,13 +39,13 @@ import androidx.tv.material3.Text
import androidx.tv.material3.surfaceColorAtElevation
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.ui.AppColors
import com.github.damontecres.wholphin.ui.abbreviateNumber
import com.github.damontecres.wholphin.ui.components.DialogItem
import com.github.damontecres.wholphin.ui.components.DialogItemDivider
import com.github.damontecres.wholphin.ui.components.EditTextBox
import com.github.damontecres.wholphin.ui.components.ErrorMessage
import com.github.damontecres.wholphin.ui.ifElse
import com.github.damontecres.wholphin.ui.tryRequestFocus
import com.github.damontecres.wholphin.util.abbreviateNumber
import org.jellyfin.sdk.model.api.RemoteSubtitleInfo
@Composable

View file

@ -65,9 +65,9 @@ import com.github.damontecres.wholphin.ui.AppColors
import com.github.damontecres.wholphin.ui.indexOfFirstOrNull
import com.github.damontecres.wholphin.ui.seekBack
import com.github.damontecres.wholphin.ui.seekForward
import com.github.damontecres.wholphin.ui.stringRes
import com.github.damontecres.wholphin.ui.tryRequestFocus
import com.github.damontecres.wholphin.util.ExceptionHandler
import com.github.damontecres.wholphin.util.stringRes
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch

View file

@ -51,13 +51,13 @@ import com.github.damontecres.wholphin.data.model.Chapter
import com.github.damontecres.wholphin.data.model.ItemPlayback
import com.github.damontecres.wholphin.data.model.Playlist
import com.github.damontecres.wholphin.ui.AppColors
import com.github.damontecres.wholphin.ui.TimeFormatter
import com.github.damontecres.wholphin.ui.cards.ChapterCard
import com.github.damontecres.wholphin.ui.cards.SeasonCard
import com.github.damontecres.wholphin.ui.ifElse
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
import com.github.damontecres.wholphin.ui.letNotEmpty
import com.github.damontecres.wholphin.ui.tryRequestFocus
import com.github.damontecres.wholphin.util.TimeFormatter
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import org.jellyfin.sdk.model.api.MediaSegmentDto

View file

@ -75,11 +75,11 @@ import com.github.damontecres.wholphin.ui.components.LoadingPage
import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.preferences.subtitle.SubtitleSettings.applyToMpv
import com.github.damontecres.wholphin.ui.preferences.subtitle.SubtitleSettings.toSubtitleStyle
import com.github.damontecres.wholphin.ui.seasonEpisode
import com.github.damontecres.wholphin.ui.stringRes
import com.github.damontecres.wholphin.ui.tryRequestFocus
import com.github.damontecres.wholphin.util.ExceptionHandler
import com.github.damontecres.wholphin.util.LoadingState
import com.github.damontecres.wholphin.util.seasonEpisode
import com.github.damontecres.wholphin.util.stringRes
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch

View file

@ -25,7 +25,6 @@ import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.data.model.Chapter
import com.github.damontecres.wholphin.data.model.ItemPlayback
import com.github.damontecres.wholphin.data.model.Playlist
import com.github.damontecres.wholphin.data.model.PlaylistCreator
import com.github.damontecres.wholphin.data.model.TrackIndex
import com.github.damontecres.wholphin.data.model.chooseSource
import com.github.damontecres.wholphin.data.model.chooseStream
@ -35,9 +34,13 @@ import com.github.damontecres.wholphin.preferences.PlayerBackend
import com.github.damontecres.wholphin.preferences.ShowNextUpWhen
import com.github.damontecres.wholphin.preferences.SkipSegmentBehavior
import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.services.PlayerFactory
import com.github.damontecres.wholphin.services.PlaylistCreator
import com.github.damontecres.wholphin.ui.formatDateTime
import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.nav.NavigationManager
import com.github.damontecres.wholphin.ui.seasonEpisodePadded
import com.github.damontecres.wholphin.ui.seekBack
import com.github.damontecres.wholphin.ui.seekForward
import com.github.damontecres.wholphin.ui.setValueOnMain
@ -47,13 +50,10 @@ import com.github.damontecres.wholphin.util.EqualityMutableLiveData
import com.github.damontecres.wholphin.util.ExceptionHandler
import com.github.damontecres.wholphin.util.LoadingExceptionHandler
import com.github.damontecres.wholphin.util.LoadingState
import com.github.damontecres.wholphin.util.PlayerFactory
import com.github.damontecres.wholphin.util.TrackActivityPlaybackListener
import com.github.damontecres.wholphin.util.TrackSupport
import com.github.damontecres.wholphin.util.checkForSupport
import com.github.damontecres.wholphin.util.formatDateTime
import com.github.damontecres.wholphin.util.mpv.mpvDeviceProfile
import com.github.damontecres.wholphin.util.seasonEpisodePadded
import com.github.damontecres.wholphin.util.subtitleMimeTypes
import com.github.damontecres.wholphin.util.supportItemKinds
import dagger.hilt.android.lifecycle.HiltViewModel

View file

@ -14,10 +14,10 @@ import com.github.damontecres.wholphin.data.model.NavPinType
import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.preferences.resetSubtitles
import com.github.damontecres.wholphin.preferences.updateSubtitlePreferences
import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.ui.detail.DebugViewModel.Companion.sendAppLogs
import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.nav.NavDrawerItem
import com.github.damontecres.wholphin.ui.nav.NavigationManager
import com.github.damontecres.wholphin.ui.setValueOnMain
import com.github.damontecres.wholphin.util.ExceptionHandler
import com.github.damontecres.wholphin.util.RememberTabManager

View file

@ -50,22 +50,22 @@ import androidx.tv.material3.Text
import androidx.tv.material3.surfaceColorAtElevation
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.services.DownloadCallback
import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.services.Release
import com.github.damontecres.wholphin.services.UpdateChecker
import com.github.damontecres.wholphin.ui.PreviewTvSpec
import com.github.damontecres.wholphin.ui.components.BasicDialog
import com.github.damontecres.wholphin.ui.components.ErrorMessage
import com.github.damontecres.wholphin.ui.components.LoadingPage
import com.github.damontecres.wholphin.ui.formatBytes
import com.github.damontecres.wholphin.ui.ifElse
import com.github.damontecres.wholphin.ui.nav.NavigationManager
import com.github.damontecres.wholphin.ui.setValueOnMain
import com.github.damontecres.wholphin.ui.theme.WholphinTheme
import com.github.damontecres.wholphin.util.DownloadCallback
import com.github.damontecres.wholphin.util.ExceptionHandler
import com.github.damontecres.wholphin.util.LoadingExceptionHandler
import com.github.damontecres.wholphin.util.LoadingState
import com.github.damontecres.wholphin.util.Release
import com.github.damontecres.wholphin.util.UpdateChecker
import com.github.damontecres.wholphin.util.Version
import com.github.damontecres.wholphin.util.formatBytes
import com.mikepenz.markdown.m3.Markdown
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers

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.ServerRepository
import com.github.damontecres.wholphin.data.model.JellyfinServer
import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.nav.NavigationManager
import com.github.damontecres.wholphin.ui.showToast
import com.github.damontecres.wholphin.util.LoadingState
import dagger.hilt.android.lifecycle.HiltViewModel

View file

@ -7,8 +7,8 @@ import com.github.damontecres.wholphin.data.JellyfinServerDao
import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.data.model.JellyfinServer
import com.github.damontecres.wholphin.data.model.JellyfinUser
import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.nav.NavigationManager
import com.github.damontecres.wholphin.ui.setValueOnMain
import com.github.damontecres.wholphin.util.ExceptionHandler
import com.github.damontecres.wholphin.util.LoadingState

View file

@ -18,6 +18,7 @@ import org.jellyfin.sdk.api.client.Response
import org.jellyfin.sdk.api.client.extensions.genresApi
import org.jellyfin.sdk.api.client.extensions.itemsApi
import org.jellyfin.sdk.api.client.extensions.liveTvApi
import org.jellyfin.sdk.api.client.extensions.personsApi
import org.jellyfin.sdk.api.client.extensions.playlistsApi
import org.jellyfin.sdk.api.client.extensions.suggestionsApi
import org.jellyfin.sdk.api.client.extensions.tvShowsApi
@ -28,6 +29,7 @@ import org.jellyfin.sdk.model.api.request.GetEpisodesRequest
import org.jellyfin.sdk.model.api.request.GetGenresRequest
import org.jellyfin.sdk.model.api.request.GetItemsRequest
import org.jellyfin.sdk.model.api.request.GetNextUpRequest
import org.jellyfin.sdk.model.api.request.GetPersonsRequest
import org.jellyfin.sdk.model.api.request.GetPlaylistItemsRequest
import org.jellyfin.sdk.model.api.request.GetResumeItemsRequest
import org.jellyfin.sdk.model.api.request.GetSuggestionsRequest
@ -365,3 +367,22 @@ val GetProgramsDtoHandler =
request: GetProgramsDto,
): Response<BaseItemDtoQueryResult> = api.liveTvApi.getPrograms(request)
}
val GetPersonsHandler =
object : RequestHandler<GetPersonsRequest> {
override fun prepare(
request: GetPersonsRequest,
startIndex: Int,
limit: Int,
enableTotalRecordCount: Boolean,
): GetPersonsRequest =
request.copy(
// startIndex = startIndex,
limit = limit,
)
override suspend fun execute(
api: ApiClient,
request: GetPersonsRequest,
): Response<BaseItemDtoQueryResult> = api.personsApi.getPersons((request))
}

View file

@ -2,7 +2,7 @@ package com.github.damontecres.wholphin.util
import android.content.Context
import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.hilt.AppModule
import com.github.damontecres.wholphin.services.hilt.AppModule
import com.google.auto.service.AutoService
import kotlinx.coroutines.runBlocking
import okhttp3.OkHttpClient

View file

@ -82,6 +82,7 @@
<string name="playback_speed">Playback Speed</string>
<string name="playback">Playback</string>
<string name="playlist">Playlist</string>
<string name="playlists">Playlists</string>
<string name="preview">Preview</string>
<string name="profile_specific_settings">User Profile Settings</string>
<string name="queue">Queue</string>
@ -135,6 +136,7 @@
<string name="version">Version</string>
<string name="video_scale">Video Scale</string>
<string name="video">Video</string>
<string name="videos">Videos</string>
<string name="watch_live">Watch live</string>
<string name="years_old">%1$d years old</string>
<string name="play_with_transcoding">Play with transcoding</string>