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

@ -25,14 +25,17 @@ After forking and cloning your fork, you can import the project into Android Stu
You need a compatible Android Studio version for the configured AGP. This is generally `Narwhal 3 Feature Drop | 2025.1.3` or newer. See https://developer.android.com/build/releases/gradle-plugin and [`libs.versions.toml](./gradle/libs.versions.toml).
### Code organization
Code is split into several packages:
- `data` - app-specific data models and services
- `preferences` - Non-UI related code for user settings and preferences
- `services` - hilt injectable services often used by ViewModels for API calls
- `ui` - User interface code and ViewModels
- `util` - Utility classes and functions
### Native components
Wholphin includes some optional native components that are compiled outside of the normal Android gradle build process.
These components are not generally required to build or test the app during development.
If you want to build any of them locally, you must have the [Android NDK](https://developer.android.com/ndk) installed.
#### FFmpeg decoder module
Wholphin ships with [media3 ffmpeg decoder module](https://github.com/androidx/media/blob/release/libraries/decoder_ffmpeg/README.md).

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) {
initialSortAndDirection
?: (
serverRepository.currentUser.value?.let { user ->
libraryDisplayInfoDao.getItem(user, itemId)?.sortAndDirection
} ?: SortAndDirection.DEFAULT
} else {
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,6 +152,24 @@ class CollectionFolderViewModel
this@CollectionFolderViewModel.sortAndDirection.value = sortAndDirection
this@CollectionFolderViewModel.filter.value = filter
}
val newPager = createPager(sortAndDirection, recursive, filter)
newPager.init()
if (newPager.isNotEmpty()) newPager.getBlocking(0)
withContext(Dispatchers.Main) {
pager.value = newPager
loading.value = LoadingState.Success
}
}
}
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)
@ -207,11 +226,25 @@ class CollectionFolderViewModel
viewModelScope,
useSeriesForPrimary = true,
)
newPager.init()
if (newPager.isNotEmpty()) newPager.getBlocking(0)
withContext(Dispatchers.Main) {
pager.value = newPager
loading.value = LoadingState.Success
newPager
}
GetItemsFilterOverride.PERSON -> {
val request =
filter.applyTo(
GetPersonsRequest(
enableImageTypes = listOf(ImageType.PRIMARY, ImageType.THUMB),
),
)
val newPager =
ApiRequestPager(
api,
request,
GetPersonsHandler,
viewModelScope,
useSeriesForPrimary = true,
)
newPager
}
}
}
@ -437,6 +470,7 @@ fun CollectionFolderGridContent(
modifier = Modifier.fillMaxWidth(),
)
}
if (sortOptions.isNotEmpty()) {
SortByButton(
sortOptions = sortOptions,
current = sortAndDirection,
@ -444,6 +478,7 @@ fun CollectionFolderGridContent(
modifier = Modifier,
)
}
}
CardGrid(
pager = pager,
onClickItem = onClickItem,

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()
val buttonInteractionSource = remember { MutableInteractionSource() }
val focused =
interactionSource.collectIsFocusedAsState().value ||
buttonInteractionSource.collectIsFocusedAsState().value
LaunchedEffect(focused) {
if (focused) {
LaunchedEffect(Unit) {
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)
val trailers = trailerService.getTrailers(item)
withContext(Dispatchers.Main) {
this@MovieViewModel.trailers.value = trailers
}
}.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()
viewModelScope.launchIO {
val people = peopleFavorites.getPeopleFor(item)
this@MovieViewModel.people.setValueOnMain(people)
}
withContext(Dispatchers.Main) {
this@MovieViewModel.trailers.value = localTrailers + remoteTrailers
}
}
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>