mirror of
https://github.com/JustinZeus/Wholphin.git
synced 2026-07-08 23:51:21 +02:00
Merge branch 'main' into fea/libass-android-support
This commit is contained in:
commit
08ecb5ad3a
115 changed files with 996 additions and 331 deletions
|
|
@ -14,6 +14,7 @@ The app uses:
|
||||||
* [Room](https://developer.android.com/training/data-storage/room) & [DataStore](https://developer.android.com/topic/libraries/architecture/datastore) for local data storage
|
* [Room](https://developer.android.com/training/data-storage/room) & [DataStore](https://developer.android.com/topic/libraries/architecture/datastore) for local data storage
|
||||||
* [Hilt](https://developer.android.com/training/dependency-injection/hilt-android) for dependency injection
|
* [Hilt](https://developer.android.com/training/dependency-injection/hilt-android) for dependency injection
|
||||||
* [Media3/ExoPlayer](https://developer.android.com/media/media3/exoplayer) for media playback
|
* [Media3/ExoPlayer](https://developer.android.com/media/media3/exoplayer) for media playback
|
||||||
|
* [MPV/libmpv](https://github.com/mpv-player/mpv) for media playback
|
||||||
* [Coil](https://coil-kt.github.io/coil/) for image loading
|
* [Coil](https://coil-kt.github.io/coil/) for image loading
|
||||||
* [OkHttp](https://square.github.io/okhttp/) for HTTP requests
|
* [OkHttp](https://square.github.io/okhttp/) for HTTP requests
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,12 +8,18 @@ import com.github.damontecres.wholphin.ui.nav.Destination
|
||||||
import org.jellyfin.sdk.model.UUID
|
import org.jellyfin.sdk.model.UUID
|
||||||
import org.jellyfin.sdk.model.api.ExtraType
|
import org.jellyfin.sdk.model.api.ExtraType
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents "extras" for media such as behind-the-scenes or deleted scenes
|
||||||
|
*/
|
||||||
sealed interface ExtrasItem {
|
sealed interface ExtrasItem {
|
||||||
val parentId: UUID
|
val parentId: UUID
|
||||||
val type: ExtraType
|
val type: ExtraType
|
||||||
val destination: Destination
|
val destination: Destination
|
||||||
val title: String?
|
val title: String?
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents multiple extras of the same type
|
||||||
|
*/
|
||||||
data class Group(
|
data class Group(
|
||||||
override val parentId: UUID,
|
override val parentId: UUID,
|
||||||
override val type: ExtraType,
|
override val type: ExtraType,
|
||||||
|
|
@ -25,6 +31,9 @@ sealed interface ExtrasItem {
|
||||||
override val title: String? = null
|
override val title: String? = null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents a single extra
|
||||||
|
*/
|
||||||
data class Single(
|
data class Single(
|
||||||
override val parentId: UUID,
|
override val parentId: UUID,
|
||||||
override val type: ExtraType,
|
override val type: ExtraType,
|
||||||
|
|
@ -38,6 +47,9 @@ sealed interface ExtrasItem {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts [ExtraType] to the string resource ID
|
||||||
|
*/
|
||||||
@get:StringRes
|
@get:StringRes
|
||||||
val ExtraType.stringRes: Int
|
val ExtraType.stringRes: Int
|
||||||
get() =
|
get() =
|
||||||
|
|
@ -56,6 +68,9 @@ val ExtraType.stringRes: Int
|
||||||
ExtraType.SHORT -> R.string.shorts
|
ExtraType.SHORT -> R.string.shorts
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts [ExtraType] to the plural resource ID
|
||||||
|
*/
|
||||||
@get:PluralsRes
|
@get:PluralsRes
|
||||||
val ExtraType.pluralRes: Int
|
val ExtraType.pluralRes: Int
|
||||||
get() =
|
get() =
|
||||||
|
|
|
||||||
|
|
@ -50,6 +50,11 @@ val DefaultPlaylistItemsOptions =
|
||||||
DecadeFilter,
|
DecadeFilter,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A way to filter libraries
|
||||||
|
*
|
||||||
|
* Gets and sets values within a [GetItemsFilter]
|
||||||
|
*/
|
||||||
sealed interface ItemFilterBy<T> {
|
sealed interface ItemFilterBy<T> {
|
||||||
@get:StringRes
|
@get:StringRes
|
||||||
val stringRes: Int
|
val stringRes: Int
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,14 @@ import org.jellyfin.sdk.model.extensions.ticks
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
import kotlin.time.Duration
|
import kotlin.time.Duration
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents audio or a song as a stripped down [BaseItem] since there may be a lot of these created.
|
||||||
|
*
|
||||||
|
* Typically added to a MediaItem as the tag for reference later
|
||||||
|
*
|
||||||
|
* The "key" can be used by a Compose LazyList key function as it will uniquely identify this particular
|
||||||
|
* audio even if the same song is added to the queue multiple times
|
||||||
|
*/
|
||||||
@Stable
|
@Stable
|
||||||
data class AudioItem(
|
data class AudioItem(
|
||||||
val key: Long = keyTracker++,
|
val key: Long = keyTracker++,
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,9 @@ import java.util.Locale
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
import kotlin.time.Duration
|
import kotlin.time.Duration
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wrapper for [BaseItemDto] with shortcuts for various UI elements
|
||||||
|
*/
|
||||||
@Serializable
|
@Serializable
|
||||||
@Stable
|
@Stable
|
||||||
data class BaseItem(
|
data class BaseItem(
|
||||||
|
|
@ -92,6 +95,9 @@ data class BaseItem(
|
||||||
@Transient
|
@Transient
|
||||||
val timeRemainingOrRuntime: Duration? = data.timeRemaining ?: data.runTimeTicks?.ticks
|
val timeRemainingOrRuntime: Duration? = data.timeRemaining ?: data.runTimeTicks?.ticks
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Contains pre computed UI elements that would be expensive to create on the main thread
|
||||||
|
*/
|
||||||
@Transient
|
@Transient
|
||||||
val ui =
|
val ui =
|
||||||
BaseItemUi(
|
BaseItemUi(
|
||||||
|
|
@ -178,6 +184,9 @@ data class BaseItem(
|
||||||
it.dayOfMonth.toString().padStart(2, '0')
|
it.dayOfMonth.toString().padStart(2, '0')
|
||||||
}?.toIntOrNull()
|
}?.toIntOrNull()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert this [BaseItem] into a [Destination] to navigate to its page in the app
|
||||||
|
*/
|
||||||
fun destination(index: Int? = null): Destination {
|
fun destination(index: Int? = null): Destination {
|
||||||
if (destinationOverride != null) return destinationOverride
|
if (destinationOverride != null) return destinationOverride
|
||||||
val result =
|
val result =
|
||||||
|
|
@ -228,6 +237,7 @@ data class BaseItem(
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
@Deprecated("Use regular constructor instead")
|
||||||
fun from(
|
fun from(
|
||||||
dto: BaseItemDto,
|
dto: BaseItemDto,
|
||||||
api: ApiClient,
|
api: ApiClient,
|
||||||
|
|
@ -249,6 +259,9 @@ data class BaseItemUi(
|
||||||
val quickDetails: AnnotatedString,
|
val quickDetails: AnnotatedString,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create the special [Destination.FilteredCollection] for the given genre information
|
||||||
|
*/
|
||||||
fun createGenreDestination(
|
fun createGenreDestination(
|
||||||
genreId: UUID,
|
genreId: UUID,
|
||||||
genreName: String,
|
genreName: String,
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,9 @@ import org.jellyfin.sdk.model.api.ImageType
|
||||||
import org.jellyfin.sdk.model.extensions.ticks
|
import org.jellyfin.sdk.model.extensions.ticks
|
||||||
import kotlin.time.Duration
|
import kotlin.time.Duration
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents a chapter within a video
|
||||||
|
*/
|
||||||
data class Chapter(
|
data class Chapter(
|
||||||
val name: String?,
|
val name: String?,
|
||||||
val position: Duration,
|
val position: Duration,
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,9 @@ import org.jellyfin.sdk.model.serializer.UUIDSerializer
|
||||||
import java.time.LocalDate
|
import java.time.LocalDate
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The type of a Seerr/Discover object with mapping to the Jellyfin [BaseItemKind]
|
||||||
|
*/
|
||||||
@Serializable
|
@Serializable
|
||||||
enum class SeerrItemType(
|
enum class SeerrItemType(
|
||||||
val baseItemKind: BaseItemKind?,
|
val baseItemKind: BaseItemKind?,
|
||||||
|
|
@ -46,6 +49,9 @@ enum class SeerrItemType(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How available is a particular discovered item within the Jellyfin server
|
||||||
|
*/
|
||||||
@Serializable
|
@Serializable
|
||||||
enum class SeerrAvailability(
|
enum class SeerrAvailability(
|
||||||
val status: Int,
|
val status: Int,
|
||||||
|
|
@ -64,7 +70,7 @@ enum class SeerrAvailability(
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* An item provided by a discovery service (ie Seerr). It may exist on the JF server as well.
|
* An item provided by a discovery service (ie Seerr). It may exist on the JF server as well, see [availability].
|
||||||
*/
|
*/
|
||||||
@Stable
|
@Stable
|
||||||
@Serializable
|
@Serializable
|
||||||
|
|
@ -104,6 +110,9 @@ data class DiscoverItem(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A rating for a discovered item which is usually fetched separately from the item
|
||||||
|
*/
|
||||||
data class DiscoverRating(
|
data class DiscoverRating(
|
||||||
val criticRating: Int?,
|
val criticRating: Int?,
|
||||||
val audienceRating: Float?,
|
val audienceRating: Float?,
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,9 @@ import org.jellyfin.sdk.model.api.request.GetPersonsRequest
|
||||||
import org.jellyfin.sdk.model.serializer.UUIDSerializer
|
import org.jellyfin.sdk.model.serializer.UUIDSerializer
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filter for a collection folder
|
||||||
|
*/
|
||||||
@Serializable
|
@Serializable
|
||||||
data class CollectionFolderFilter(
|
data class CollectionFolderFilter(
|
||||||
val nameOverride: String? = null,
|
val nameOverride: String? = null,
|
||||||
|
|
@ -24,6 +27,9 @@ data class CollectionFolderFilter(
|
||||||
val useSavedLibraryDisplayInfo: Boolean = true,
|
val useSavedLibraryDisplayInfo: Boolean = true,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A sort of simplified filter which can be [applyTo] a [GetItemsRequest] or [GetPersonsRequest] to add or remove filters
|
||||||
|
*/
|
||||||
@Serializable
|
@Serializable
|
||||||
data class GetItemsFilter(
|
data class GetItemsFilter(
|
||||||
val favorite: Boolean? = null,
|
val favorite: Boolean? = null,
|
||||||
|
|
@ -52,7 +58,7 @@ data class GetItemsFilter(
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Clear all of the values for the given filters
|
* Clear all the values for the given filters
|
||||||
*/
|
*/
|
||||||
fun delete(filterOptions: List<ItemFilterBy<*>>): GetItemsFilter {
|
fun delete(filterOptions: List<ItemFilterBy<*>>): GetItemsFilter {
|
||||||
var newFilter = this
|
var newFilter = this
|
||||||
|
|
@ -128,6 +134,9 @@ data class GetItemsFilter(
|
||||||
isFavorite = favorite,
|
isFavorite = favorite,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Merge another [GetItemsFilter] onto this one, replacing only unset values
|
||||||
|
*/
|
||||||
fun merge(filter: GetItemsFilter): GetItemsFilter =
|
fun merge(filter: GetItemsFilter): GetItemsFilter =
|
||||||
this.copy(
|
this.copy(
|
||||||
favorite = favorite ?: filter.favorite,
|
favorite = favorite ?: filter.favorite,
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,10 @@ import kotlinx.serialization.UseSerializers
|
||||||
import org.jellyfin.sdk.model.serializer.UUIDSerializer
|
import org.jellyfin.sdk.model.serializer.UUIDSerializer
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Store the media source and audio/subtitle tracks chosen for a specific media item
|
||||||
|
*
|
||||||
|
*/
|
||||||
@Entity(
|
@Entity(
|
||||||
foreignKeys = [
|
foreignKeys = [
|
||||||
ForeignKey(
|
ForeignKey(
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,11 @@ import kotlinx.serialization.UseSerializers
|
||||||
import org.jellyfin.sdk.model.serializer.UUIDSerializer
|
import org.jellyfin.sdk.model.serializer.UUIDSerializer
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Store modifications to an audio/subtitle track in a media item
|
||||||
|
*
|
||||||
|
* For example, the subtitle delay
|
||||||
|
*/
|
||||||
@Entity(
|
@Entity(
|
||||||
foreignKeys = [
|
foreignKeys = [
|
||||||
ForeignKey(
|
ForeignKey(
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,9 @@ import org.jellyfin.sdk.model.ServerVersion
|
||||||
import org.jellyfin.sdk.model.serializer.UUIDSerializer
|
import org.jellyfin.sdk.model.serializer.UUIDSerializer
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents a Jellyfin server
|
||||||
|
*/
|
||||||
@Entity(tableName = "servers")
|
@Entity(tableName = "servers")
|
||||||
@Serializable
|
@Serializable
|
||||||
data class JellyfinServer(
|
data class JellyfinServer(
|
||||||
|
|
@ -29,6 +32,9 @@ data class JellyfinServer(
|
||||||
val serverVersion: ServerVersion? by lazy { version?.let(ServerVersion::fromString) }
|
val serverVersion: ServerVersion? by lazy { version?.let(ServerVersion::fromString) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents a Jellyfin user for a particular server
|
||||||
|
*/
|
||||||
@Entity(
|
@Entity(
|
||||||
tableName = "users",
|
tableName = "users",
|
||||||
foreignKeys = [
|
foreignKeys = [
|
||||||
|
|
@ -59,6 +65,9 @@ data class JellyfinUser(
|
||||||
"JellyfinUser(rowId=$rowId, id=$id, name=$name, serverId=$serverId, accessToken?=${accessToken.isNotNullOrBlank()}, pin?=${pin.isNotNullOrBlank()})"
|
"JellyfinUser(rowId=$rowId, id=$id, name=$name, serverId=$serverId, accessToken?=${accessToken.isNotNullOrBlank()}, pin?=${pin.isNotNullOrBlank()})"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents the relationship between [JellyfinServer] and its [JellyfinUser]
|
||||||
|
*/
|
||||||
data class JellyfinServerUsers(
|
data class JellyfinServerUsers(
|
||||||
@Embedded val server: JellyfinServer,
|
@Embedded val server: JellyfinServer,
|
||||||
@Relation(
|
@Relation(
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,11 @@ import org.jellyfin.sdk.model.api.SortOrder
|
||||||
import org.jellyfin.sdk.model.serializer.UUIDSerializer
|
import org.jellyfin.sdk.model.serializer.UUIDSerializer
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stores the filter, sort, and view options a user changes for a library
|
||||||
|
*
|
||||||
|
* This allows for restoring these settings whenever the user navigates to the library
|
||||||
|
*/
|
||||||
@Entity(
|
@Entity(
|
||||||
foreignKeys = [
|
foreignKeys = [
|
||||||
ForeignKey(
|
ForeignKey(
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,9 @@ enum class NavPinType {
|
||||||
UNPINNED,
|
UNPINNED,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stores preference information about nav drawer items such as its order and whether to show or put in More
|
||||||
|
*/
|
||||||
@Entity(
|
@Entity(
|
||||||
foreignKeys = [
|
foreignKeys = [
|
||||||
ForeignKey(
|
ForeignKey(
|
||||||
|
|
@ -12,6 +12,9 @@ import org.jellyfin.sdk.model.api.BaseItemPerson
|
||||||
import org.jellyfin.sdk.model.api.ImageType
|
import org.jellyfin.sdk.model.api.ImageType
|
||||||
import org.jellyfin.sdk.model.api.PersonKind
|
import org.jellyfin.sdk.model.api.PersonKind
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents a person in some media such as an actor or director
|
||||||
|
*/
|
||||||
@Stable
|
@Stable
|
||||||
data class Person(
|
data class Person(
|
||||||
val id: UUID,
|
val id: UUID,
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,9 @@ import androidx.room.Entity
|
||||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Store effects applied to some media such as color or hue adjustments
|
||||||
|
*/
|
||||||
@Entity(tableName = "playback_effects", primaryKeys = ["jellyfinUserRowId", "itemId", "type"])
|
@Entity(tableName = "playback_effects", primaryKeys = ["jellyfinUserRowId", "itemId", "type"])
|
||||||
data class PlaybackEffect(
|
data class PlaybackEffect(
|
||||||
val jellyfinUserRowId: Int,
|
val jellyfinUserRowId: Int,
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,10 @@ import kotlinx.serialization.UseSerializers
|
||||||
import org.jellyfin.sdk.model.serializer.UUIDSerializer
|
import org.jellyfin.sdk.model.serializer.UUIDSerializer
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stores the language choices for a series so they can be applied automatically to other episodes
|
||||||
|
* without the user needing to explicitly choose the tracks
|
||||||
|
*/
|
||||||
@Entity(
|
@Entity(
|
||||||
foreignKeys = [
|
foreignKeys = [
|
||||||
ForeignKey(
|
ForeignKey(
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,11 @@ import androidx.compose.runtime.setValue
|
||||||
import org.jellyfin.sdk.model.api.MediaType
|
import org.jellyfin.sdk.model.api.MediaType
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tracks playback of multiple items. Points to the current media with function to advance or go to previous ones.
|
||||||
|
*
|
||||||
|
* This is not the same thing as a Jellyfin server playlist
|
||||||
|
*/
|
||||||
class Playlist(
|
class Playlist(
|
||||||
items: List<BaseItem>,
|
items: List<BaseItem>,
|
||||||
startIndex: Int = 0,
|
startIndex: Int = 0,
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,9 @@ package com.github.damontecres.wholphin.data.model
|
||||||
|
|
||||||
import com.github.damontecres.wholphin.services.SeerrUserConfig
|
import com.github.damontecres.wholphin.services.SeerrUserConfig
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Permission levels for a user on a Seerr server
|
||||||
|
*/
|
||||||
enum class SeerrPermission(
|
enum class SeerrPermission(
|
||||||
private val flag: Int,
|
private val flag: Int,
|
||||||
) {
|
) {
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,9 @@ import kotlinx.serialization.Serializable
|
||||||
import kotlinx.serialization.UseSerializers
|
import kotlinx.serialization.UseSerializers
|
||||||
import org.jellyfin.sdk.model.serializer.UUIDSerializer
|
import org.jellyfin.sdk.model.serializer.UUIDSerializer
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents a Seerr server instance
|
||||||
|
*/
|
||||||
@Entity(
|
@Entity(
|
||||||
tableName = "seerr_servers",
|
tableName = "seerr_servers",
|
||||||
indices = [Index("url", unique = true)],
|
indices = [Index("url", unique = true)],
|
||||||
|
|
@ -26,6 +29,9 @@ data class SeerrServer(
|
||||||
val version: String? = null,
|
val version: String? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents a user on a [SeerrServer]
|
||||||
|
*/
|
||||||
@Entity(
|
@Entity(
|
||||||
tableName = "seerr_users",
|
tableName = "seerr_users",
|
||||||
foreignKeys = [
|
foreignKeys = [
|
||||||
|
|
@ -56,12 +62,18 @@ data class SeerrUser(
|
||||||
"SeerrUser(jellyfinUserRowId=$jellyfinUserRowId, serverId=$serverId, authMethod=$authMethod, username=$username, password?=${password.isNotNullOrBlank()}, credential?=${credential.isNotNullOrBlank()})"
|
"SeerrUser(jellyfinUserRowId=$jellyfinUserRowId, serverId=$serverId, authMethod=$authMethod, username=$username, password?=${password.isNotNullOrBlank()}, credential?=${credential.isNotNullOrBlank()})"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The method used to authenticate a user to the server
|
||||||
|
*/
|
||||||
enum class SeerrAuthMethod {
|
enum class SeerrAuthMethod {
|
||||||
LOCAL,
|
LOCAL,
|
||||||
JELLYFIN,
|
JELLYFIN,
|
||||||
API_KEY,
|
API_KEY,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents the relationship between a [SeerrServer] and its [SeerrUser]s
|
||||||
|
*/
|
||||||
data class SeerrServerUsers(
|
data class SeerrServerUsers(
|
||||||
@Embedded val server: SeerrServer,
|
@Embedded val server: SeerrServer,
|
||||||
@Relation(
|
@Relation(
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,15 @@
|
||||||
package com.github.damontecres.wholphin.data.model
|
package com.github.damontecres.wholphin.data.model
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents a trailer for media
|
||||||
|
*/
|
||||||
sealed interface Trailer {
|
sealed interface Trailer {
|
||||||
val name: String
|
val name: String
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A [Trailer] stored on the Jellyfin server
|
||||||
|
*/
|
||||||
data class LocalTrailer(
|
data class LocalTrailer(
|
||||||
val baseItem: BaseItem,
|
val baseItem: BaseItem,
|
||||||
) : Trailer {
|
) : Trailer {
|
||||||
|
|
@ -11,6 +17,9 @@ data class LocalTrailer(
|
||||||
get() = baseItem.name ?: ""
|
get() = baseItem.name ?: ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A [Trailer] available via a remote URL, such as YouTube
|
||||||
|
*/
|
||||||
data class RemoteTrailer(
|
data class RemoteTrailer(
|
||||||
override val name: String,
|
override val name: String,
|
||||||
val url: String,
|
val url: String,
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ import androidx.media3.effect.ScaleAndRotateTransformation
|
||||||
import androidx.room.Ignore
|
import androidx.room.Ignore
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Modifications to a video playback
|
* Modifications to an image or video playback
|
||||||
*/
|
*/
|
||||||
data class VideoFilter(
|
data class VideoFilter(
|
||||||
val rotation: Int = 0,
|
val rotation: Int = 0,
|
||||||
|
|
|
||||||
|
|
@ -501,6 +501,18 @@ sealed interface AppPreference<Pref, T> {
|
||||||
valueToIndex = { if (it != AppThemeColors.UNRECOGNIZED) it.number else 0 },
|
valueToIndex = { if (it != AppThemeColors.UNRECOGNIZED) it.number else 0 },
|
||||||
)
|
)
|
||||||
|
|
||||||
|
val ShowLogos =
|
||||||
|
AppSwitchPreference<AppPreferences>(
|
||||||
|
title = R.string.prefer_logos,
|
||||||
|
defaultValue = true,
|
||||||
|
getter = { it.interfacePreferences.showLogos },
|
||||||
|
setter = { prefs, value ->
|
||||||
|
prefs.updateInterfacePreferences { showLogos = value }
|
||||||
|
},
|
||||||
|
summaryOn = R.string.enabled,
|
||||||
|
summaryOff = R.string.disabled,
|
||||||
|
)
|
||||||
|
|
||||||
val InstalledVersion =
|
val InstalledVersion =
|
||||||
AppClickablePreference<AppPreferences>(
|
AppClickablePreference<AppPreferences>(
|
||||||
title = R.string.installed_version,
|
title = R.string.installed_version,
|
||||||
|
|
@ -1122,6 +1134,7 @@ val advancedPreferences =
|
||||||
preferences =
|
preferences =
|
||||||
listOf(
|
listOf(
|
||||||
AppPreference.ShowClock,
|
AppPreference.ShowClock,
|
||||||
|
AppPreference.ShowLogos,
|
||||||
AppPreference.ManageMedia,
|
AppPreference.ManageMedia,
|
||||||
AppPreference.CombineContinueNext,
|
AppPreference.CombineContinueNext,
|
||||||
// Temporarily disabled, see https://github.com/damontecres/Wholphin/pull/127#issuecomment-3478058418
|
// Temporarily disabled, see https://github.com/damontecres/Wholphin/pull/127#issuecomment-3478058418
|
||||||
|
|
|
||||||
|
|
@ -95,6 +95,7 @@ class AppPreferencesSerializer
|
||||||
AppPreference.NavDrawerSwitchOnFocus.defaultValue
|
AppPreference.NavDrawerSwitchOnFocus.defaultValue
|
||||||
showClock = AppPreference.ShowClock.defaultValue
|
showClock = AppPreference.ShowClock.defaultValue
|
||||||
backdropStyle = AppPreference.BackdropStylePref.defaultValue
|
backdropStyle = AppPreference.BackdropStylePref.defaultValue
|
||||||
|
showLogos = AppPreference.ShowLogos.defaultValue
|
||||||
|
|
||||||
subtitlesPreferences =
|
subtitlesPreferences =
|
||||||
SubtitlePreferences
|
SubtitlePreferences
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,9 @@ import java.io.File
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles any changes needed when the app in upgraded on the device such as setting new preferences
|
||||||
|
*/
|
||||||
@Singleton
|
@Singleton
|
||||||
class AppUpgradeHandler
|
class AppUpgradeHandler
|
||||||
@Inject
|
@Inject
|
||||||
|
|
@ -61,6 +64,7 @@ class AppUpgradeHandler
|
||||||
Timber.i(
|
Timber.i(
|
||||||
"App updated: $previousVersion=>$newVersion, $previousVersionCode=>$newVersionCode",
|
"App updated: $previousVersion=>$newVersion, $previousVersionCode=>$newVersionCode",
|
||||||
)
|
)
|
||||||
|
// Store the previous and new version info
|
||||||
prefs.edit(true) {
|
prefs.edit(true) {
|
||||||
putString(VERSION_NAME_PREVIOUS_KEY, previousVersion)
|
putString(VERSION_NAME_PREVIOUS_KEY, previousVersion)
|
||||||
putLong(VERSION_CODE_PREVIOUS_KEY, previousVersionCode)
|
putLong(VERSION_CODE_PREVIOUS_KEY, previousVersionCode)
|
||||||
|
|
@ -83,6 +87,9 @@ class AppUpgradeHandler
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Copies the font file used by MPV subtitles to the app's files directory
|
||||||
|
*/
|
||||||
fun copySubfont(overwrite: Boolean) {
|
fun copySubfont(overwrite: Boolean) {
|
||||||
try {
|
try {
|
||||||
val fontFileName = "subfont.ttf"
|
val fontFileName = "subfont.ttf"
|
||||||
|
|
@ -111,6 +118,9 @@ class AppUpgradeHandler
|
||||||
const val VERSION_CODE_CURRENT_KEY = "version.current.code"
|
const val VERSION_CODE_CURRENT_KEY = "version.current.code"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Perform any needed upgrades
|
||||||
|
*/
|
||||||
suspend fun upgradeApp(
|
suspend fun upgradeApp(
|
||||||
previous: Version,
|
previous: Version,
|
||||||
current: Version,
|
current: Version,
|
||||||
|
|
@ -282,7 +292,7 @@ class AppUpgradeHandler
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (previous.isEqualOrBefore(Version.fromString("0.6.0-0-g0"))) {
|
if (previous.isEqualOrBefore(Version.fromString("0.5.4-6-g0"))) {
|
||||||
appPreferences.updateData {
|
appPreferences.updateData {
|
||||||
it.updateMusicPreferences {
|
it.updateMusicPreferences {
|
||||||
showBackdrop = true
|
showBackdrop = true
|
||||||
|
|
@ -291,5 +301,13 @@ class AppUpgradeHandler
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (previous.isEqualOrBefore(Version.fromString("0.5.4-15-g0"))) {
|
||||||
|
appPreferences.updateData {
|
||||||
|
it.updateInterfacePreferences {
|
||||||
|
showLogos = AppPreference.ShowLogos.defaultValue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,11 @@ import timber.log.Timber
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stores state for the backdrop of the app shown on non-full screen pages
|
||||||
|
*
|
||||||
|
* This is usually the backdrop of the currently focused media
|
||||||
|
*/
|
||||||
@Singleton
|
@Singleton
|
||||||
@OptIn(FlowPreview::class)
|
@OptIn(FlowPreview::class)
|
||||||
class BackdropService
|
class BackdropService
|
||||||
|
|
@ -46,6 +51,9 @@ class BackdropService
|
||||||
private val _backdropFlow = MutableStateFlow<BackdropResult>(BackdropResult.NONE)
|
private val _backdropFlow = MutableStateFlow<BackdropResult>(BackdropResult.NONE)
|
||||||
val backdropFlow = _backdropFlow
|
val backdropFlow = _backdropFlow
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update the backdrop to use the specified item
|
||||||
|
*/
|
||||||
suspend fun submit(item: BaseItem) =
|
suspend fun submit(item: BaseItem) =
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
val imageUrl =
|
val imageUrl =
|
||||||
|
|
@ -57,8 +65,14 @@ class BackdropService
|
||||||
submit(item.id.toString(), imageUrl)
|
submit(item.id.toString(), imageUrl)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update the backdrop to use the specified discovered item
|
||||||
|
*/
|
||||||
suspend fun submit(item: DiscoverItem) = submit("discover_${item.id}", item.backDropUrl)
|
suspend fun submit(item: DiscoverItem) = submit("discover_${item.id}", item.backDropUrl)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update the backdrop to use the specified URL
|
||||||
|
*/
|
||||||
suspend fun submit(
|
suspend fun submit(
|
||||||
itemId: String,
|
itemId: String,
|
||||||
imageUrl: String?,
|
imageUrl: String?,
|
||||||
|
|
@ -74,6 +88,9 @@ class BackdropService
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove the backdrop, such as when switching pages
|
||||||
|
*/
|
||||||
suspend fun clearBackdrop() {
|
suspend fun clearBackdrop() {
|
||||||
_backdropFlow.update {
|
_backdropFlow.update {
|
||||||
BackdropResult.NONE
|
BackdropResult.NONE
|
||||||
|
|
@ -224,6 +241,9 @@ class BackdropService
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The result from determining the backdrop URL and extracted colors for the dynamic backdrop
|
||||||
|
*/
|
||||||
data class BackdropResult(
|
data class BackdropResult(
|
||||||
val itemId: String?,
|
val itemId: String?,
|
||||||
val imageUrl: String?,
|
val imageUrl: String?,
|
||||||
|
|
@ -245,6 +265,9 @@ data class BackdropResult(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The colors extracted from an image
|
||||||
|
*/
|
||||||
data class ExtractedColors(
|
data class ExtractedColors(
|
||||||
val primary: Color,
|
val primary: Color,
|
||||||
val secondary: Color,
|
val secondary: Color,
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,9 @@ import java.util.concurrent.TimeUnit
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Caches when media was lasted played. This is mostly used by the combined Continue Watching & Next Up home row.
|
||||||
|
*/
|
||||||
@Singleton
|
@Singleton
|
||||||
class DatePlayedService
|
class DatePlayedService
|
||||||
@Inject
|
@Inject
|
||||||
|
|
@ -80,6 +83,12 @@ class DatePlayedService
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the logical timestamp when the item was last played.
|
||||||
|
*
|
||||||
|
* This is calculated using lastest of the actual last played timestamp of the item,
|
||||||
|
* the previous episode's last played timestamp, and the episode's premiere date
|
||||||
|
*/
|
||||||
suspend fun getLastPlayed(item: BaseItem): LocalDateTime? =
|
suspend fun getLastPlayed(item: BaseItem): LocalDateTime? =
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
val seriesId = item.data.seriesId
|
val seriesId = item.data.seriesId
|
||||||
|
|
@ -93,6 +102,11 @@ class DatePlayedService
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove the cached last date played for the given item's series
|
||||||
|
*
|
||||||
|
* Used for when a user plays this item
|
||||||
|
*/
|
||||||
fun invalidate(item: BaseItem) {
|
fun invalidate(item: BaseItem) {
|
||||||
item.data.seriesId?.let { seriesId ->
|
item.data.seriesId?.let { seriesId ->
|
||||||
Timber.d("Invalidating %s", seriesId)
|
Timber.d("Invalidating %s", seriesId)
|
||||||
|
|
@ -100,6 +114,9 @@ class DatePlayedService
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove the cached last data played for the given item or its series
|
||||||
|
*/
|
||||||
suspend fun invalidate(itemId: UUID) {
|
suspend fun invalidate(itemId: UUID) {
|
||||||
val seriesId =
|
val seriesId =
|
||||||
api.userLibraryApi.getItem(itemId = itemId).content.let {
|
api.userLibraryApi.getItem(itemId = itemId).content.let {
|
||||||
|
|
@ -121,6 +138,9 @@ class DatePlayedService
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Invalidates the date played cached when the current user changes
|
||||||
|
*/
|
||||||
@ActivityScoped
|
@ActivityScoped
|
||||||
class DatePlayedInvalidationService
|
class DatePlayedInvalidationService
|
||||||
@Inject
|
@Inject
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,9 @@ import org.jellyfin.sdk.model.api.DeviceProfile
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates and caches the device direct play/transcoding profile sent to the server for ExoPlayer
|
||||||
|
*/
|
||||||
@Singleton
|
@Singleton
|
||||||
class DeviceProfileService
|
class DeviceProfileService
|
||||||
@Inject
|
@Inject
|
||||||
|
|
@ -21,7 +24,7 @@ class DeviceProfileService
|
||||||
@param:ApplicationContext private val context: Context,
|
@param:ApplicationContext private val context: Context,
|
||||||
) {
|
) {
|
||||||
val mediaCodecCapabilitiesTest by lazy {
|
val mediaCodecCapabilitiesTest by lazy {
|
||||||
// Created lazily below on the IO thread since it cn take time
|
// Created lazily below on another thread since it cn take time
|
||||||
MediaCodecCapabilitiesTest(context)
|
MediaCodecCapabilitiesTest(context)
|
||||||
}
|
}
|
||||||
private val mutex = Mutex()
|
private val mutex = Mutex()
|
||||||
|
|
@ -33,7 +36,7 @@ class DeviceProfileService
|
||||||
prefs: PlaybackPreferences,
|
prefs: PlaybackPreferences,
|
||||||
serverVersion: ServerVersion?,
|
serverVersion: ServerVersion?,
|
||||||
): DeviceProfile =
|
): DeviceProfile =
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.Default) {
|
||||||
mutex.withLock {
|
mutex.withLock {
|
||||||
val newConfig =
|
val newConfig =
|
||||||
DeviceProfileConfiguration(
|
DeviceProfileConfiguration(
|
||||||
|
|
|
||||||
|
|
@ -9,12 +9,20 @@ import java.util.UUID
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get extras for media
|
||||||
|
*
|
||||||
|
* @see [ExtrasItem]
|
||||||
|
*/
|
||||||
@Singleton
|
@Singleton
|
||||||
class ExtrasService
|
class ExtrasService
|
||||||
@Inject
|
@Inject
|
||||||
constructor(
|
constructor(
|
||||||
private val api: ApiClient,
|
private val api: ApiClient,
|
||||||
) {
|
) {
|
||||||
|
/**
|
||||||
|
* Get the [ExtrasItem]s for the given item
|
||||||
|
*/
|
||||||
suspend fun getExtras(itemId: UUID): List<ExtrasItem> {
|
suspend fun getExtras(itemId: UUID): List<ExtrasItem> {
|
||||||
val extrasMap =
|
val extrasMap =
|
||||||
api.userLibraryApi
|
api.userLibraryApi
|
||||||
|
|
@ -42,6 +50,9 @@ class ExtrasService
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The order which extras should be shown
|
||||||
|
*/
|
||||||
private val ExtraType.sortOrder: Int
|
private val ExtraType.sortOrder: Int
|
||||||
get() =
|
get() =
|
||||||
when (this) {
|
when (this) {
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,9 @@ import java.util.UUID
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles toggling media as favorited or watched
|
||||||
|
*/
|
||||||
@Singleton
|
@Singleton
|
||||||
class FavoriteWatchManager
|
class FavoriteWatchManager
|
||||||
@Inject
|
@Inject
|
||||||
|
|
|
||||||
|
|
@ -63,6 +63,9 @@ import java.io.File
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles getting home page settings and data
|
||||||
|
*/
|
||||||
@Singleton
|
@Singleton
|
||||||
class HomeSettingsService
|
class HomeSettingsService
|
||||||
@Inject
|
@Inject
|
||||||
|
|
@ -84,6 +87,9 @@ class HomeSettingsService
|
||||||
allowTrailingComma = true
|
allowTrailingComma = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The current home page settings
|
||||||
|
*/
|
||||||
val currentSettings = MutableStateFlow(HomePageResolvedSettings.EMPTY)
|
val currentSettings = MutableStateFlow(HomePageResolvedSettings.EMPTY)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -245,6 +251,9 @@ class HomeSettingsService
|
||||||
currentSettings.update { resolvedSettings }
|
currentSettings.update { resolvedSettings }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the settings and set them to be the current settings
|
||||||
|
*/
|
||||||
suspend fun updateCurrent(settings: HomePageSettings) {
|
suspend fun updateCurrent(settings: HomePageSettings) {
|
||||||
val resolvedRows =
|
val resolvedRows =
|
||||||
settings.rows.mapIndexed { index, config ->
|
settings.rows.mapIndexed { index, config ->
|
||||||
|
|
@ -317,6 +326,9 @@ class HomeSettingsService
|
||||||
return HomePageResolvedSettings(rowConfig)
|
return HomePageResolvedSettings(rowConfig)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create home page settings from the user's web UI home page settings
|
||||||
|
*/
|
||||||
suspend fun parseFromWebConfig(userId: UUID): HomePageResolvedSettings? {
|
suspend fun parseFromWebConfig(userId: UUID): HomePageResolvedSettings? {
|
||||||
val customPrefs =
|
val customPrefs =
|
||||||
api.displayPreferencesApi
|
api.displayPreferencesApi
|
||||||
|
|
@ -886,7 +898,7 @@ class HomeSettingsService
|
||||||
limit = limit,
|
limit = limit,
|
||||||
enableUserData = true,
|
enableUserData = true,
|
||||||
enableImages = true,
|
enableImages = true,
|
||||||
enableImageTypes = listOf(ImageType.PRIMARY),
|
enableImageTypes = listOf(ImageType.PRIMARY, ImageType.LOGO),
|
||||||
imageTypeLimit = 1,
|
imageTypeLimit = 1,
|
||||||
)
|
)
|
||||||
api.liveTvApi
|
api.liveTvApi
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,8 @@
|
||||||
package com.github.damontecres.wholphin.services
|
package com.github.damontecres.wholphin.services
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import com.github.damontecres.wholphin.R
|
|
||||||
import com.github.damontecres.wholphin.data.model.BaseItem
|
import com.github.damontecres.wholphin.data.model.BaseItem
|
||||||
import com.github.damontecres.wholphin.ui.SlimItemFields
|
import com.github.damontecres.wholphin.ui.SlimItemFields
|
||||||
import com.github.damontecres.wholphin.util.HomeRowLoadingState
|
|
||||||
import com.github.damontecres.wholphin.util.supportItemKinds
|
import com.github.damontecres.wholphin.util.supportItemKinds
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
|
@ -16,13 +14,7 @@ import kotlinx.coroutines.withContext
|
||||||
import org.jellyfin.sdk.api.client.ApiClient
|
import org.jellyfin.sdk.api.client.ApiClient
|
||||||
import org.jellyfin.sdk.api.client.extensions.itemsApi
|
import org.jellyfin.sdk.api.client.extensions.itemsApi
|
||||||
import org.jellyfin.sdk.api.client.extensions.tvShowsApi
|
import org.jellyfin.sdk.api.client.extensions.tvShowsApi
|
||||||
import org.jellyfin.sdk.api.client.extensions.userLibraryApi
|
|
||||||
import org.jellyfin.sdk.api.client.extensions.userViewsApi
|
|
||||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||||
import org.jellyfin.sdk.model.api.CollectionType
|
|
||||||
import org.jellyfin.sdk.model.api.ImageType
|
|
||||||
import org.jellyfin.sdk.model.api.UserDto
|
|
||||||
import org.jellyfin.sdk.model.api.request.GetLatestMediaRequest
|
|
||||||
import org.jellyfin.sdk.model.api.request.GetNextUpRequest
|
import org.jellyfin.sdk.model.api.request.GetNextUpRequest
|
||||||
import org.jellyfin.sdk.model.api.request.GetResumeItemsRequest
|
import org.jellyfin.sdk.model.api.request.GetResumeItemsRequest
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
|
|
@ -32,6 +24,9 @@ import javax.inject.Inject
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
import kotlin.time.Duration.Companion.milliseconds
|
import kotlin.time.Duration.Companion.milliseconds
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get continue watching and next up items for users
|
||||||
|
*/
|
||||||
@Singleton
|
@Singleton
|
||||||
class LatestNextUpService
|
class LatestNextUpService
|
||||||
@Inject
|
@Inject
|
||||||
|
|
@ -40,6 +35,9 @@ class LatestNextUpService
|
||||||
private val api: ApiClient,
|
private val api: ApiClient,
|
||||||
private val datePlayedService: DatePlayedService,
|
private val datePlayedService: DatePlayedService,
|
||||||
) {
|
) {
|
||||||
|
/**
|
||||||
|
* Get resume (continue watching) items for a user
|
||||||
|
*/
|
||||||
suspend fun getResume(
|
suspend fun getResume(
|
||||||
userId: UUID,
|
userId: UUID,
|
||||||
limit: Int,
|
limit: Int,
|
||||||
|
|
@ -61,12 +59,6 @@ class LatestNextUpService
|
||||||
remove(BaseItemKind.EPISODE)
|
remove(BaseItemKind.EPISODE)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
enableImageTypes =
|
|
||||||
listOf(
|
|
||||||
ImageType.PRIMARY,
|
|
||||||
ImageType.THUMB,
|
|
||||||
ImageType.BACKDROP,
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
val items =
|
val items =
|
||||||
api.itemsApi
|
api.itemsApi
|
||||||
|
|
@ -77,6 +69,9 @@ class LatestNextUpService
|
||||||
return items
|
return items
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get next up items for a user
|
||||||
|
*/
|
||||||
suspend fun getNextUp(
|
suspend fun getNextUp(
|
||||||
userId: UUID,
|
userId: UUID,
|
||||||
limit: Int,
|
limit: Int,
|
||||||
|
|
@ -108,65 +103,11 @@ class LatestNextUpService
|
||||||
return nextUp
|
return nextUp
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun getLatest(
|
/**
|
||||||
user: UserDto,
|
* Create the combined Continue Watching & Next Up items
|
||||||
limit: Int,
|
*
|
||||||
includedIds: List<UUID>,
|
* @see [DatePlayedService]
|
||||||
): List<LatestData> {
|
*/
|
||||||
val excluded = user.configuration?.latestItemsExcludes.orEmpty()
|
|
||||||
val views by api.userViewsApi.getUserViews()
|
|
||||||
val latestData =
|
|
||||||
views.items
|
|
||||||
.filter {
|
|
||||||
it.id in includedIds && it.id !in excluded &&
|
|
||||||
it.collectionType in supportedLatestCollectionTypes
|
|
||||||
}.map { view ->
|
|
||||||
val title =
|
|
||||||
view.name?.let { context.getString(R.string.recently_added_in, it) }
|
|
||||||
?: context.getString(R.string.recently_added)
|
|
||||||
val request =
|
|
||||||
GetLatestMediaRequest(
|
|
||||||
fields = SlimItemFields,
|
|
||||||
imageTypeLimit = 1,
|
|
||||||
parentId = view.id,
|
|
||||||
groupItems = true,
|
|
||||||
limit = limit,
|
|
||||||
isPlayed = null, // Server will handle user's preference
|
|
||||||
)
|
|
||||||
LatestData(title, request)
|
|
||||||
}
|
|
||||||
|
|
||||||
return latestData
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun loadLatest(latestData: List<LatestData>): List<HomeRowLoadingState> {
|
|
||||||
val rows =
|
|
||||||
latestData.mapNotNull { (title, request) ->
|
|
||||||
try {
|
|
||||||
val latest =
|
|
||||||
api.userLibraryApi
|
|
||||||
.getLatestMedia(request)
|
|
||||||
.content
|
|
||||||
.map { BaseItem.from(it, api, true) }
|
|
||||||
if (latest.isNotEmpty()) {
|
|
||||||
HomeRowLoadingState.Success(
|
|
||||||
title = title,
|
|
||||||
items = latest,
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
null
|
|
||||||
}
|
|
||||||
} catch (ex: Exception) {
|
|
||||||
Timber.e(ex, "Exception fetching %s", title)
|
|
||||||
HomeRowLoadingState.Error(
|
|
||||||
title = title,
|
|
||||||
exception = ex,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return rows
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun buildCombined(
|
suspend fun buildCombined(
|
||||||
resume: List<BaseItem>,
|
resume: List<BaseItem>,
|
||||||
nextUp: List<BaseItem>,
|
nextUp: List<BaseItem>,
|
||||||
|
|
@ -200,17 +141,3 @@ class LatestNextUpService
|
||||||
return@withContext result
|
return@withContext result
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val supportedLatestCollectionTypes =
|
|
||||||
setOf(
|
|
||||||
CollectionType.MOVIES,
|
|
||||||
CollectionType.TVSHOWS,
|
|
||||||
CollectionType.HOMEVIDEOS,
|
|
||||||
// Exclude Live TV because a recording folder view will be used instead
|
|
||||||
null, // Recordings & mixed collection types
|
|
||||||
)
|
|
||||||
|
|
||||||
data class LatestData(
|
|
||||||
val title: String,
|
|
||||||
val request: GetLatestMediaRequest,
|
|
||||||
)
|
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,9 @@ import timber.log.Timber
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Service to manage media such as deletions
|
||||||
|
*/
|
||||||
@Singleton
|
@Singleton
|
||||||
class MediaManagementService
|
class MediaManagementService
|
||||||
@Inject
|
@Inject
|
||||||
|
|
@ -45,6 +48,9 @@ class MediaManagementService
|
||||||
return canDelete(item, appPreferences)
|
return canDelete(item, appPreferences)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the item can be deleted. This means the app setting is enabled and the user has permission.
|
||||||
|
*/
|
||||||
fun canDelete(
|
fun canDelete(
|
||||||
item: BaseItem,
|
item: BaseItem,
|
||||||
appPreferences: AppPreferences,
|
appPreferences: AppPreferences,
|
||||||
|
|
@ -62,10 +68,14 @@ class MediaManagementService
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete the item.
|
||||||
|
*
|
||||||
|
* This item will be sent through [deletedItemFlow] for other services or view models to react.
|
||||||
|
*/
|
||||||
suspend fun deleteItem(item: BaseItem): DeleteResult {
|
suspend fun deleteItem(item: BaseItem): DeleteResult {
|
||||||
try {
|
try {
|
||||||
Timber.i("Deleting %s", item.id)
|
Timber.i("Deleting %s", item.id)
|
||||||
// TODO enable
|
|
||||||
api.libraryApi.deleteItem(item.id)
|
api.libraryApi.deleteItem(item.id)
|
||||||
_deletedItemFlow.emit(DeletedItem(item))
|
_deletedItemFlow.emit(DeletedItem(item))
|
||||||
return DeleteResult.Success
|
return DeleteResult.Success
|
||||||
|
|
@ -88,6 +98,9 @@ sealed interface DeleteResult {
|
||||||
) : DeleteResult
|
) : DeleteResult
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convenience function to delete an item and show a Toast based on success or error
|
||||||
|
*/
|
||||||
fun ViewModel.deleteItem(
|
fun ViewModel.deleteItem(
|
||||||
context: Context,
|
context: Context,
|
||||||
mediaManagementService: MediaManagementService,
|
mediaManagementService: MediaManagementService,
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,9 @@ import timber.log.Timber
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send media info to the server
|
||||||
|
*/
|
||||||
@Singleton
|
@Singleton
|
||||||
class MediaReportService
|
class MediaReportService
|
||||||
@Inject
|
@Inject
|
||||||
|
|
@ -39,6 +42,9 @@ class MediaReportService
|
||||||
encodeDefaults = false
|
encodeDefaults = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch the media info and send it to the server
|
||||||
|
*/
|
||||||
fun sendReportFor(itemId: UUID) {
|
fun sendReportFor(itemId: UUID) {
|
||||||
ioScope.launchIO(ExceptionHandler(autoToast = true)) {
|
ioScope.launchIO(ExceptionHandler(autoToast = true)) {
|
||||||
val item = api.userLibraryApi.getItem(itemId = itemId).content
|
val item = api.userLibraryApi.getItem(itemId = itemId).content
|
||||||
|
|
@ -46,6 +52,9 @@ class MediaReportService
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send the media report for the given item
|
||||||
|
*/
|
||||||
suspend fun sendReportFor(item: BaseItemDto) {
|
suspend fun sendReportFor(item: BaseItemDto) {
|
||||||
val sources =
|
val sources =
|
||||||
item.mediaSources ?: api.userLibraryApi
|
item.mediaSources ?: api.userLibraryApi
|
||||||
|
|
|
||||||
|
|
@ -63,6 +63,11 @@ import javax.inject.Inject
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
import kotlin.time.Duration.Companion.seconds
|
import kotlin.time.Duration.Companion.seconds
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manage the global state for playing music
|
||||||
|
*
|
||||||
|
* Has functions for modifying the queue
|
||||||
|
*/
|
||||||
@OptIn(UnstableApi::class)
|
@OptIn(UnstableApi::class)
|
||||||
@Singleton
|
@Singleton
|
||||||
class MusicService
|
class MusicService
|
||||||
|
|
@ -104,6 +109,11 @@ class MusicService
|
||||||
private var activityTracker: TrackActivityPlaybackListener? = null
|
private var activityTracker: TrackActivityPlaybackListener? = null
|
||||||
private var websocketJob: Job? = null
|
private var websocketJob: Job? = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start music playback
|
||||||
|
*
|
||||||
|
* Sets up the media session, activity tracking, and actual playback
|
||||||
|
*/
|
||||||
suspend fun start() {
|
suspend fun start() {
|
||||||
if (mediaSession == null) {
|
if (mediaSession == null) {
|
||||||
mutex.withLock {
|
mutex.withLock {
|
||||||
|
|
@ -130,6 +140,9 @@ class MusicService
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop music playback
|
||||||
|
*/
|
||||||
suspend fun stop() {
|
suspend fun stop() {
|
||||||
mutex.withLock {
|
mutex.withLock {
|
||||||
Timber.i("Stopping music")
|
Timber.i("Stopping music")
|
||||||
|
|
@ -191,6 +204,9 @@ class MusicService
|
||||||
addAllToQueue(items, startIndex)
|
addAllToQueue(items, startIndex)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replace the queue with the given items
|
||||||
|
*/
|
||||||
suspend fun setQueue(
|
suspend fun setQueue(
|
||||||
items: List<BaseItem>,
|
items: List<BaseItem>,
|
||||||
shuffled: Boolean,
|
shuffled: Boolean,
|
||||||
|
|
@ -208,6 +224,9 @@ class MusicService
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add an item to the specified index of the queue. If no index specified, it will be added to the end.
|
||||||
|
*/
|
||||||
suspend fun addToQueue(
|
suspend fun addToQueue(
|
||||||
item: BaseItem,
|
item: BaseItem,
|
||||||
index: Int? = null,
|
index: Int? = null,
|
||||||
|
|
@ -229,6 +248,11 @@ class MusicService
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add all the items in teh list to end of the queue
|
||||||
|
*
|
||||||
|
* @param startIndex The index to start from within the source list
|
||||||
|
*/
|
||||||
suspend fun addAllToQueue(
|
suspend fun addAllToQueue(
|
||||||
list: BlockingList<BaseItem?>,
|
list: BlockingList<BaseItem?>,
|
||||||
startIndex: Int,
|
startIndex: Int,
|
||||||
|
|
@ -255,6 +279,9 @@ class MusicService
|
||||||
updateQueueSize()
|
updateQueueSize()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts a [BaseItem] into a [MediaItem] setting an [AudioItem] as its tag
|
||||||
|
*/
|
||||||
private fun convert(audio: BaseItem): MediaItem {
|
private fun convert(audio: BaseItem): MediaItem {
|
||||||
val url =
|
val url =
|
||||||
api.universalAudioApi.getUniversalAudioStreamUrl(
|
api.universalAudioApi.getUniversalAudioStreamUrl(
|
||||||
|
|
@ -282,6 +309,9 @@ class MusicService
|
||||||
.build()
|
.build()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates the state for when the queue changes
|
||||||
|
*/
|
||||||
private suspend fun updateQueueSize() {
|
private suspend fun updateQueueSize() {
|
||||||
// val ids =
|
// val ids =
|
||||||
// withContext(Dispatchers.Default) {
|
// withContext(Dispatchers.Default) {
|
||||||
|
|
@ -306,6 +336,9 @@ class MusicService
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Move an item within the queue
|
||||||
|
*/
|
||||||
suspend fun moveQueue(
|
suspend fun moveQueue(
|
||||||
index: Int,
|
index: Int,
|
||||||
direction: MoveDirection,
|
direction: MoveDirection,
|
||||||
|
|
@ -314,6 +347,9 @@ class MusicService
|
||||||
updateQueueSize()
|
updateQueueSize()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Move an item within the queue
|
||||||
|
*/
|
||||||
suspend fun moveQueue(
|
suspend fun moveQueue(
|
||||||
index: Int,
|
index: Int,
|
||||||
newIndex: Int,
|
newIndex: Int,
|
||||||
|
|
@ -322,6 +358,9 @@ class MusicService
|
||||||
updateQueueSize()
|
updateQueueSize()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start playback at the given index of the queue
|
||||||
|
*/
|
||||||
suspend fun playIndex(index: Int) {
|
suspend fun playIndex(index: Int) {
|
||||||
onMain {
|
onMain {
|
||||||
player.seekTo(index, 0L)
|
player.seekTo(index, 0L)
|
||||||
|
|
@ -330,6 +369,9 @@ class MusicService
|
||||||
// MusicPlayerListener will update state
|
// MusicPlayerListener will update state
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Play this item next after the current, ie add the item as the next index in the queue
|
||||||
|
*/
|
||||||
suspend fun playNext(song: BaseItem) {
|
suspend fun playNext(song: BaseItem) {
|
||||||
val mediaItem = convert(song)
|
val mediaItem = convert(song)
|
||||||
onMain {
|
onMain {
|
||||||
|
|
@ -341,6 +383,9 @@ class MusicService
|
||||||
updateQueueSize()
|
updateQueueSize()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* From the item at the given index from the queue
|
||||||
|
*/
|
||||||
suspend fun removeFromQueue(index: Int) {
|
suspend fun removeFromQueue(index: Int) {
|
||||||
onMain { player.removeMediaItem(index) }
|
onMain { player.removeMediaItem(index) }
|
||||||
updateQueueSize()
|
updateQueueSize()
|
||||||
|
|
@ -353,7 +398,10 @@ class MusicService
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
fun subscribe(): Job =
|
/**
|
||||||
|
* Subscribes to the server websocket to receive playback commands
|
||||||
|
*/
|
||||||
|
private fun subscribe(): Job =
|
||||||
api.webSocket
|
api.webSocket
|
||||||
.subscribe<PlaystateMessage>()
|
.subscribe<PlaystateMessage>()
|
||||||
.onEach { message ->
|
.onEach { message ->
|
||||||
|
|
@ -503,6 +551,11 @@ private class MusicPlayerListener(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remember the queue currently playing
|
||||||
|
*
|
||||||
|
* @see MusicServiceState
|
||||||
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun rememberQueue(
|
fun rememberQueue(
|
||||||
player: Player,
|
player: Player,
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,9 @@ import javax.inject.Inject
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
import kotlin.time.Duration.Companion.hours
|
import kotlin.time.Duration.Companion.hours
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the items to show in the nav drawer
|
||||||
|
*/
|
||||||
@Singleton
|
@Singleton
|
||||||
class NavDrawerService
|
class NavDrawerService
|
||||||
@Inject
|
@Inject
|
||||||
|
|
@ -54,6 +57,7 @@ class NavDrawerService
|
||||||
val state: StateFlow<NavDrawerItemState> = _state
|
val state: StateFlow<NavDrawerItemState> = _state
|
||||||
|
|
||||||
init {
|
init {
|
||||||
|
// Handle updating the nav drawer when the user changes
|
||||||
serverRepository.currentUser
|
serverRepository.currentUser
|
||||||
.asFlow()
|
.asFlow()
|
||||||
.combine(serverRepository.currentUserDto.asFlow()) { user, userDto ->
|
.combine(serverRepository.currentUserDto.asFlow()) { user, userDto ->
|
||||||
|
|
@ -74,10 +78,13 @@ class NavDrawerService
|
||||||
showToast(context, "Error fetching user's views")
|
showToast(context, "Error fetching user's views")
|
||||||
}.launchIn(coroutineScope)
|
}.launchIn(coroutineScope)
|
||||||
|
|
||||||
|
// Handle when the user has logged into a Seerr server
|
||||||
seerrServerRepository.active
|
seerrServerRepository.active
|
||||||
.onEach { discoverActive ->
|
.onEach { discoverActive ->
|
||||||
_state.update { it.copy(discoverEnabled = discoverActive) }
|
_state.update { it.copy(discoverEnabled = discoverActive) }
|
||||||
}.launchIn(coroutineScope)
|
}.launchIn(coroutineScope)
|
||||||
|
|
||||||
|
// Handle when music is actively playing or not
|
||||||
coroutineScope.launchDefault {
|
coroutineScope.launchDefault {
|
||||||
musicService.state.collectLatest { music ->
|
musicService.state.collectLatest { music ->
|
||||||
Timber.v("MusicService updated")
|
Timber.v("MusicService updated")
|
||||||
|
|
@ -114,6 +121,9 @@ class NavDrawerService
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all the libraries the user has access to
|
||||||
|
*/
|
||||||
suspend fun getAllUserLibraries(
|
suspend fun getAllUserLibraries(
|
||||||
userId: UUID,
|
userId: UUID,
|
||||||
tvAccess: Boolean,
|
tvAccess: Boolean,
|
||||||
|
|
@ -147,6 +157,9 @@ class NavDrawerService
|
||||||
return libraries
|
return libraries
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the libraries that the user has not "pinned". These will show in the More section.
|
||||||
|
*/
|
||||||
suspend fun getFilteredUserLibraries(
|
suspend fun getFilteredUserLibraries(
|
||||||
user: JellyfinUser,
|
user: JellyfinUser,
|
||||||
tvAccess: Boolean,
|
tvAccess: Boolean,
|
||||||
|
|
@ -161,6 +174,9 @@ class NavDrawerService
|
||||||
return libraries
|
return libraries
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update the current state of the nav drawer items
|
||||||
|
*/
|
||||||
suspend fun updateNavDrawer(
|
suspend fun updateNavDrawer(
|
||||||
user: JellyfinUser,
|
user: JellyfinUser,
|
||||||
userDto: UserDto,
|
userDto: UserDto,
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,9 @@ import org.jellyfin.sdk.api.client.extensions.itemsApi
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets people in media, specifically to check if they are favorited or not
|
||||||
|
*/
|
||||||
@Singleton
|
@Singleton
|
||||||
class PeopleFavorites
|
class PeopleFavorites
|
||||||
@Inject
|
@Inject
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,11 @@ import java.util.UUID
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create [Playlist]s (not Jellyfin server playlist) for playback
|
||||||
|
*
|
||||||
|
* Used to create a queue of episodes or from Play All button
|
||||||
|
*/
|
||||||
@Singleton
|
@Singleton
|
||||||
class PlaylistCreator
|
class PlaylistCreator
|
||||||
@Inject
|
@Inject
|
||||||
|
|
@ -74,6 +79,9 @@ class PlaylistCreator
|
||||||
return Playlist(episodes, startIndex)
|
return Playlist(episodes, startIndex)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create from a server playlist ID
|
||||||
|
*/
|
||||||
suspend fun createFromPlaylistId(
|
suspend fun createFromPlaylistId(
|
||||||
playlistId: UUID,
|
playlistId: UUID,
|
||||||
startIndex: Int?,
|
startIndex: Int?,
|
||||||
|
|
@ -114,7 +122,12 @@ class PlaylistCreator
|
||||||
req =
|
req =
|
||||||
GetItemsRequest(
|
GetItemsRequest(
|
||||||
parentId = item.id,
|
parentId = item.id,
|
||||||
enableImageTypes = listOf(ImageType.PRIMARY, ImageType.THUMB),
|
enableImageTypes =
|
||||||
|
listOf(
|
||||||
|
ImageType.PRIMARY,
|
||||||
|
ImageType.THUMB,
|
||||||
|
ImageType.LOGO,
|
||||||
|
),
|
||||||
includeItemTypes = includeItemTypes,
|
includeItemTypes = includeItemTypes,
|
||||||
recursive = true,
|
recursive = true,
|
||||||
excludeItemIds = listOf(item.id),
|
excludeItemIds = listOf(item.id),
|
||||||
|
|
@ -133,6 +146,11 @@ class PlaylistCreator
|
||||||
return Playlist(items, 0)
|
return Playlist(items, 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a [Playlist] contextually based on the given item.
|
||||||
|
*
|
||||||
|
* For example, an episode creates a queue of next up episodes in the series, or a movie creates one for its parts if needed
|
||||||
|
*/
|
||||||
suspend fun createFrom(
|
suspend fun createFrom(
|
||||||
item: BaseItemDto,
|
item: BaseItemDto,
|
||||||
startIndex: Int = 0,
|
startIndex: Int = 0,
|
||||||
|
|
@ -270,6 +288,9 @@ class PlaylistCreator
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the playlists on the server for a given media type
|
||||||
|
*/
|
||||||
suspend fun getServerPlaylists(
|
suspend fun getServerPlaylists(
|
||||||
mediaType: MediaType?,
|
mediaType: MediaType?,
|
||||||
scope: CoroutineScope,
|
scope: CoroutineScope,
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,9 @@ import javax.inject.Singleton
|
||||||
import kotlin.math.roundToInt
|
import kotlin.math.roundToInt
|
||||||
import kotlin.time.Duration.Companion.seconds
|
import kotlin.time.Duration.Companion.seconds
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles determining whether the refresh rate and/or resolution of the display need to changed when playing media
|
||||||
|
*/
|
||||||
@Singleton
|
@Singleton
|
||||||
class RefreshRateService
|
class RefreshRateService
|
||||||
@Inject
|
@Inject
|
||||||
|
|
@ -119,6 +122,9 @@ class RefreshRateService
|
||||||
MainActivity.instance.changeDisplayMode(0)
|
MainActivity.instance.changeDisplayMode(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Listens for the display to change so we known the refresh rate or resolution is updated
|
||||||
|
*/
|
||||||
private class Listener(
|
private class Listener(
|
||||||
val displayId: Int,
|
val displayId: Int,
|
||||||
) : DisplayManager.DisplayListener {
|
) : DisplayManager.DisplayListener {
|
||||||
|
|
@ -213,6 +219,9 @@ class RefreshRateService
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wrapper for a [Display.Mode]
|
||||||
|
*/
|
||||||
data class DisplayMode(
|
data class DisplayMode(
|
||||||
val modeId: Int,
|
val modeId: Int,
|
||||||
val physicalWidth: Int,
|
val physicalWidth: Int,
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,9 @@ import javax.inject.Inject
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
import kotlin.time.Duration.Companion.milliseconds
|
import kotlin.time.Duration.Companion.milliseconds
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles the queue of items to show on the screensaver, both in-app or OS
|
||||||
|
*/
|
||||||
@Singleton
|
@Singleton
|
||||||
class ScreensaverService
|
class ScreensaverService
|
||||||
@Inject
|
@Inject
|
||||||
|
|
@ -67,6 +70,9 @@ class ScreensaverService
|
||||||
}.launchIn(scope)
|
}.launchIn(scope)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reset the timer before showing the in-app screensaver
|
||||||
|
*/
|
||||||
fun pulse() {
|
fun pulse() {
|
||||||
waitJob?.cancel()
|
waitJob?.cancel()
|
||||||
if (_state.value.enabled) {
|
if (_state.value.enabled) {
|
||||||
|
|
@ -95,6 +101,9 @@ class ScreensaverService
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Immediately start the in-app screensaver
|
||||||
|
*/
|
||||||
fun start() {
|
fun start() {
|
||||||
_state.update {
|
_state.update {
|
||||||
it.copy(
|
it.copy(
|
||||||
|
|
@ -104,6 +113,9 @@ class ScreensaverService
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Immediately stop the in-app screensaver
|
||||||
|
*/
|
||||||
fun stop(cancelJob: Boolean) {
|
fun stop(cancelJob: Boolean) {
|
||||||
_state.update {
|
_state.update {
|
||||||
it.copy(
|
it.copy(
|
||||||
|
|
@ -114,6 +126,9 @@ class ScreensaverService
|
||||||
if (cancelJob) waitJob?.cancel()
|
if (cancelJob) waitJob?.cancel()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Signal to the OS for keeping the screen on such as during playback or when the in-app screensaver is active
|
||||||
|
*/
|
||||||
fun keepScreenOn(keep: Boolean) {
|
fun keepScreenOn(keep: Boolean) {
|
||||||
scope.launchDefault {
|
scope.launchDefault {
|
||||||
val screensaverEnabled = _state.value.enabled
|
val screensaverEnabled = _state.value.enabled
|
||||||
|
|
@ -136,6 +151,9 @@ class ScreensaverService
|
||||||
keepScreenOn.update { keep }
|
keepScreenOn.update { keep }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a flow of items to show on the screensaver
|
||||||
|
*/
|
||||||
fun createItemFlow(scope: CoroutineScope): Flow<CurrentItem?> =
|
fun createItemFlow(scope: CoroutineScope): Flow<CurrentItem?> =
|
||||||
flow {
|
flow {
|
||||||
val prefs =
|
val prefs =
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,9 @@ import org.jellyfin.sdk.model.api.MediaType
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Listens for basic messages from the server such as messages
|
||||||
|
*/
|
||||||
@ActivityScoped
|
@ActivityScoped
|
||||||
class ServerEventListener
|
class ServerEventListener
|
||||||
@Inject
|
@Inject
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,9 @@ import java.util.UUID
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manage the track choices for media
|
||||||
|
*/
|
||||||
@Singleton
|
@Singleton
|
||||||
class StreamChoiceService
|
class StreamChoiceService
|
||||||
@Inject
|
@Inject
|
||||||
|
|
@ -94,6 +97,9 @@ class StreamChoiceService
|
||||||
result
|
result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the audio stream that should play
|
||||||
|
*/
|
||||||
suspend fun chooseAudioStream(
|
suspend fun chooseAudioStream(
|
||||||
source: MediaSourceInfo,
|
source: MediaSourceInfo,
|
||||||
seriesId: UUID?,
|
seriesId: UUID?,
|
||||||
|
|
@ -108,6 +114,9 @@ class StreamChoiceService
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the audio stream that should play
|
||||||
|
*/
|
||||||
fun chooseAudioStream(
|
fun chooseAudioStream(
|
||||||
candidates: List<MediaStream>,
|
candidates: List<MediaStream>,
|
||||||
itemPlayback: ItemPlayback?,
|
itemPlayback: ItemPlayback?,
|
||||||
|
|
@ -135,6 +144,9 @@ class StreamChoiceService
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the subtitle stream that should play
|
||||||
|
*/
|
||||||
suspend fun chooseSubtitleStream(
|
suspend fun chooseSubtitleStream(
|
||||||
source: MediaSourceInfo,
|
source: MediaSourceInfo,
|
||||||
audioStream: MediaStream?,
|
audioStream: MediaStream?,
|
||||||
|
|
@ -196,6 +208,9 @@ class StreamChoiceService
|
||||||
)?.index
|
)?.index
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the subtitle stream that should play
|
||||||
|
*/
|
||||||
fun chooseSubtitleStream(
|
fun chooseSubtitleStream(
|
||||||
audioStreamLang: String?,
|
audioStreamLang: String?,
|
||||||
candidates: List<MediaStream>,
|
candidates: List<MediaStream>,
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,9 @@ import org.jellyfin.sdk.api.client.extensions.userLibraryApi
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets trailers for media
|
||||||
|
*/
|
||||||
@Singleton
|
@Singleton
|
||||||
class TrailerService
|
class TrailerService
|
||||||
@Inject
|
@Inject
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,9 @@ import javax.inject.Singleton
|
||||||
import kotlin.time.Duration.Companion.hours
|
import kotlin.time.Duration.Companion.hours
|
||||||
import kotlin.time.Duration.Companion.milliseconds
|
import kotlin.time.Duration.Companion.milliseconds
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if an app update is available
|
||||||
|
*/
|
||||||
@Singleton
|
@Singleton
|
||||||
class UpdateChecker
|
class UpdateChecker
|
||||||
@Inject
|
@Inject
|
||||||
|
|
@ -65,6 +68,11 @@ class UpdateChecker
|
||||||
val ACTIVE = true
|
val ACTIVE = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* If the app hasn't recently checked, check for any updates and if there is one, show a toast message
|
||||||
|
*
|
||||||
|
* This is safe to call many times because it will only show the toast at most once every 12 hours
|
||||||
|
*/
|
||||||
suspend fun maybeShowUpdateToast(
|
suspend fun maybeShowUpdateToast(
|
||||||
updateUrl: String,
|
updateUrl: String,
|
||||||
showNegativeToast: Boolean = false,
|
showNegativeToast: Boolean = false,
|
||||||
|
|
@ -112,11 +120,17 @@ class UpdateChecker
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the currently installed version
|
||||||
|
*/
|
||||||
fun getInstalledVersion(): Version {
|
fun getInstalledVersion(): Version {
|
||||||
val pkgInfo = context.packageManager.getPackageInfo(context.packageName, 0)
|
val pkgInfo = context.packageManager.getPackageInfo(context.packageName, 0)
|
||||||
return Version.Companion.fromString(pkgInfo.versionName!!)
|
return Version.fromString(pkgInfo.versionName!!)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the latest released version
|
||||||
|
*/
|
||||||
suspend fun getLatestRelease(updateUrl: String): Release? {
|
suspend fun getLatestRelease(updateUrl: String): Release? {
|
||||||
return withContext(Dispatchers.IO) {
|
return withContext(Dispatchers.IO) {
|
||||||
val request =
|
val request =
|
||||||
|
|
@ -161,6 +175,9 @@ class UpdateChecker
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Download and install an update
|
||||||
|
*/
|
||||||
suspend fun installRelease(
|
suspend fun installRelease(
|
||||||
release: Release,
|
release: Release,
|
||||||
callback: DownloadCallback,
|
callback: DownloadCallback,
|
||||||
|
|
@ -263,6 +280,9 @@ class UpdateChecker
|
||||||
return targetFile
|
return targetFile
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the app has permission to write the download
|
||||||
|
*/
|
||||||
fun hasPermissions(): Boolean =
|
fun hasPermissions(): Boolean =
|
||||||
Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q ||
|
Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q ||
|
||||||
(
|
(
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,9 @@ import kotlinx.coroutines.flow.map
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the current user's [UserPreferences]
|
||||||
|
*/
|
||||||
@Singleton
|
@Singleton
|
||||||
class UserPreferencesService
|
class UserPreferencesService
|
||||||
@Inject
|
@Inject
|
||||||
|
|
|
||||||
|
|
@ -34,26 +34,48 @@ import org.jellyfin.sdk.model.DeviceInfo
|
||||||
import javax.inject.Qualifier
|
import javax.inject.Qualifier
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An [OkHttpClient] that includes the user's access token when making requests
|
||||||
|
*/
|
||||||
@Qualifier
|
@Qualifier
|
||||||
@Retention(AnnotationRetention.BINARY)
|
@Retention(AnnotationRetention.BINARY)
|
||||||
annotation class AuthOkHttpClient
|
annotation class AuthOkHttpClient
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A basic [OkHttpClient] that does not include auth
|
||||||
|
*/
|
||||||
@Qualifier
|
@Qualifier
|
||||||
@Retention(AnnotationRetention.BINARY)
|
@Retention(AnnotationRetention.BINARY)
|
||||||
annotation class StandardOkHttpClient
|
annotation class StandardOkHttpClient
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A [CoroutineScope] with [Dispatchers.IO]
|
||||||
|
*/
|
||||||
@Qualifier
|
@Qualifier
|
||||||
@Retention(AnnotationRetention.BINARY)
|
@Retention(AnnotationRetention.BINARY)
|
||||||
annotation class IoCoroutineScope
|
annotation class IoCoroutineScope
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A [CoroutineScope] with [Dispatchers.Default]
|
||||||
|
*/
|
||||||
@Qualifier
|
@Qualifier
|
||||||
@Retention(AnnotationRetention.BINARY)
|
@Retention(AnnotationRetention.BINARY)
|
||||||
annotation class DefaultCoroutineScope
|
annotation class DefaultCoroutineScope
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [Dispatchers.IO]
|
||||||
|
*
|
||||||
|
* @see IoCoroutineScope
|
||||||
|
*/
|
||||||
@Qualifier
|
@Qualifier
|
||||||
@Retention(AnnotationRetention.BINARY)
|
@Retention(AnnotationRetention.BINARY)
|
||||||
annotation class IoDispatcher
|
annotation class IoDispatcher
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [Dispatchers.Default]
|
||||||
|
*
|
||||||
|
* @see DefaultCoroutineScope
|
||||||
|
*/
|
||||||
@Qualifier
|
@Qualifier
|
||||||
@Retention(AnnotationRetention.BINARY)
|
@Retention(AnnotationRetention.BINARY)
|
||||||
annotation class DefaultDispatcher
|
annotation class DefaultDispatcher
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,9 @@ import kotlin.time.Duration.Companion.minutes
|
||||||
import kotlin.time.Duration.Companion.seconds
|
import kotlin.time.Duration.Companion.seconds
|
||||||
import kotlin.time.toJavaDuration
|
import kotlin.time.toJavaDuration
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schedules the [TvProviderWorker] to update the OS resume watching row
|
||||||
|
*/
|
||||||
@ActivityScoped
|
@ActivityScoped
|
||||||
class TvProviderSchedulerService
|
class TvProviderSchedulerService
|
||||||
@Inject
|
@Inject
|
||||||
|
|
@ -44,6 +47,7 @@ class TvProviderSchedulerService
|
||||||
workManager.cancelUniqueWork(TvProviderWorker.WORK_NAME)
|
workManager.cancelUniqueWork(TvProviderWorker.WORK_NAME)
|
||||||
if (supportsTvProvider) {
|
if (supportsTvProvider) {
|
||||||
if (user != null) {
|
if (user != null) {
|
||||||
|
// Schedule a new worker whenever the user changes
|
||||||
activity.lifecycleScope.launchIO(ExceptionHandler()) {
|
activity.lifecycleScope.launchIO(ExceptionHandler()) {
|
||||||
Timber.i("Scheduling TvProviderWorker for ${user.user}")
|
Timber.i("Scheduling TvProviderWorker for ${user.user}")
|
||||||
workManager
|
workManager
|
||||||
|
|
@ -70,6 +74,9 @@ class TvProviderSchedulerService
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run the [TvProviderWorker] as a one-off instead of scheduled
|
||||||
|
*/
|
||||||
fun launchOneTimeRefresh() {
|
fun launchOneTimeRefresh() {
|
||||||
if (supportsTvProvider) {
|
if (supportsTvProvider) {
|
||||||
activity.lifecycleScope.launchIO(ExceptionHandler()) {
|
activity.lifecycleScope.launchIO(ExceptionHandler()) {
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,11 @@ import java.util.Date
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
import kotlin.time.Duration.Companion.minutes
|
import kotlin.time.Duration.Companion.minutes
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates the Android OS resume watching row for a given Jellyfin user
|
||||||
|
*
|
||||||
|
* @see TvProviderSchedulerService
|
||||||
|
*/
|
||||||
@HiltWorker
|
@HiltWorker
|
||||||
class TvProviderWorker
|
class TvProviderWorker
|
||||||
@AssistedInject
|
@AssistedInject
|
||||||
|
|
|
||||||
|
|
@ -214,7 +214,7 @@ fun BannerCardWithTitle(
|
||||||
) {
|
) {
|
||||||
val focused by interactionSource.collectIsFocusedAsState()
|
val focused by interactionSource.collectIsFocusedAsState()
|
||||||
val spaceBetween by animateDpAsState(if (focused) 12.dp else 4.dp)
|
val spaceBetween by animateDpAsState(if (focused) 12.dp else 4.dp)
|
||||||
val spaceBelow by animateDpAsState(if (focused) 4.dp else 12.dp)
|
val spaceBelow by animateDpAsState(if (focused) 0.dp else 8.dp)
|
||||||
val focusedAfterDelay by rememberFocusedAfterDelay(interactionSource)
|
val focusedAfterDelay by rememberFocusedAfterDelay(interactionSource)
|
||||||
val aspectRationToUse = aspectRatio.coerceAtLeast(AspectRatios.MIN)
|
val aspectRationToUse = aspectRatio.coerceAtLeast(AspectRatios.MIN)
|
||||||
val width = cardHeight * aspectRationToUse
|
val width = cardHeight * aspectRationToUse
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,11 @@ import androidx.tv.material3.ProvideTextStyle
|
||||||
import androidx.tv.material3.Surface
|
import androidx.tv.material3.Surface
|
||||||
import androidx.tv.material3.Text
|
import androidx.tv.material3.Text
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This is a re-implementation of [androidx.tv.material3.Button] with altered sizing, padding, colors, etc
|
||||||
|
*
|
||||||
|
* This allows for creating smaller and fully circular Buttons.
|
||||||
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun Button(
|
fun Button(
|
||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
|
|
|
||||||
|
|
@ -398,6 +398,7 @@ class CollectionFolderViewModel
|
||||||
ImageType.PRIMARY,
|
ImageType.PRIMARY,
|
||||||
ImageType.THUMB,
|
ImageType.THUMB,
|
||||||
ImageType.BACKDROP,
|
ImageType.BACKDROP,
|
||||||
|
ImageType.LOGO,
|
||||||
),
|
),
|
||||||
includeItemTypes = includeItemTypes,
|
includeItemTypes = includeItemTypes,
|
||||||
recursive = recursive,
|
recursive = recursive,
|
||||||
|
|
@ -960,11 +961,12 @@ fun CollectionFolderGridContent(
|
||||||
AnimatedVisibility(viewOptions.showDetails) {
|
AnimatedVisibility(viewOptions.showDetails) {
|
||||||
HomePageHeader(
|
HomePageHeader(
|
||||||
item = focusedItem,
|
item = focusedItem,
|
||||||
|
showLogo = preferences.appPreferences.interfacePreferences.showLogos,
|
||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.height(200.dp)
|
.height(200.dp)
|
||||||
.padding(top = 48.dp, bottom = 32.dp, start = 8.dp),
|
.padding(HeaderUtils.padding),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
when (val state = loadingState) {
|
when (val state = loadingState) {
|
||||||
|
|
|
||||||
|
|
@ -90,6 +90,9 @@ sealed interface DialogItemEntry
|
||||||
|
|
||||||
data object DialogItemDivider : DialogItemEntry
|
data object DialogItemDivider : DialogItemEntry
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An item within a [DialogPopup]
|
||||||
|
*/
|
||||||
data class DialogItem(
|
data class DialogItem(
|
||||||
val headlineContent: @Composable () -> Unit,
|
val headlineContent: @Composable () -> Unit,
|
||||||
val onClick: () -> Unit,
|
val onClick: () -> Unit,
|
||||||
|
|
|
||||||
|
|
@ -34,7 +34,6 @@ import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import androidx.compose.ui.graphics.SolidColor
|
import androidx.compose.ui.graphics.SolidColor
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.input.ImeAction
|
import androidx.compose.ui.text.input.ImeAction
|
||||||
|
|
@ -49,8 +48,10 @@ import androidx.tv.material3.MaterialTheme
|
||||||
import com.github.damontecres.wholphin.R
|
import com.github.damontecres.wholphin.R
|
||||||
import com.github.damontecres.wholphin.ui.PreviewTvSpec
|
import com.github.damontecres.wholphin.ui.PreviewTvSpec
|
||||||
import com.github.damontecres.wholphin.ui.theme.WholphinTheme
|
import com.github.damontecres.wholphin.ui.theme.WholphinTheme
|
||||||
import com.google.protobuf.value
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An input field for text customized for TV entry
|
||||||
|
*/
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun EditTextBox(
|
fun EditTextBox(
|
||||||
|
|
|
||||||
|
|
@ -55,6 +55,14 @@ import com.github.damontecres.wholphin.ui.theme.WholphinTheme
|
||||||
import com.github.damontecres.wholphin.ui.tryRequestFocus
|
import com.github.damontecres.wholphin.ui.tryRequestFocus
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Button for filtering data.
|
||||||
|
*
|
||||||
|
* Clicking on it will show a drop-down menu of filterable options. Many of these show a second drop-down menu when clicked.
|
||||||
|
*
|
||||||
|
* @see GetItemsFilter
|
||||||
|
* @see ItemFilterBy
|
||||||
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun FilterByButton(
|
fun FilterByButton(
|
||||||
filterOptions: List<ItemFilterBy<*>>,
|
filterOptions: List<ItemFilterBy<*>>,
|
||||||
|
|
|
||||||
|
|
@ -156,6 +156,9 @@ private val genreCache by lazy {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a mapping from genre IDs to image URLs using random items within each genre
|
||||||
|
*/
|
||||||
suspend fun getGenreImageMap(
|
suspend fun getGenreImageMap(
|
||||||
api: ApiClient,
|
api: ApiClient,
|
||||||
userId: UUID?,
|
userId: UUID?,
|
||||||
|
|
@ -230,6 +233,9 @@ data class Genre(
|
||||||
override val sortName: String get() = name
|
override val sortName: String get() = name
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show an optimized grid of genres for a library
|
||||||
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun GenreCardGrid(
|
fun GenreCardGrid(
|
||||||
itemId: UUID,
|
itemId: UUID,
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,11 @@ import androidx.tv.material3.Text
|
||||||
|
|
||||||
private const val MAX_TO_SHOW = 4
|
private const val MAX_TO_SHOW = 4
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Display a comma separated list of genres
|
||||||
|
*
|
||||||
|
* Only the first few will be shown
|
||||||
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun GenreText(
|
fun GenreText(
|
||||||
genres: List<String>,
|
genres: List<String>,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,24 @@
|
||||||
|
package com.github.damontecres.wholphin.ui.components
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
|
object HeaderUtils {
|
||||||
|
val topPadding = 48.dp
|
||||||
|
val bottomPadding = 32.dp
|
||||||
|
val startPadding = 8.dp
|
||||||
|
|
||||||
|
val padding = PaddingValues(top = topPadding, bottom = bottomPadding, start = startPadding)
|
||||||
|
|
||||||
|
val height = 180.dp
|
||||||
|
|
||||||
|
val logoHeight = 60.dp
|
||||||
|
|
||||||
|
val modifier =
|
||||||
|
Modifier
|
||||||
|
.padding(padding)
|
||||||
|
.height(height)
|
||||||
|
}
|
||||||
|
|
@ -82,6 +82,9 @@ class ItemGridViewModel
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Display a grid of a list of arbitrary item IDs such as for [com.github.damontecres.wholphin.data.ExtrasItem]
|
||||||
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun ItemGrid(
|
fun ItemGrid(
|
||||||
destination: Destination.ItemGrid,
|
destination: Destination.ItemGrid,
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,9 @@ import androidx.compose.ui.Modifier
|
||||||
import com.mikepenz.aboutlibraries.ui.compose.android.produceLibraries
|
import com.mikepenz.aboutlibraries.ui.compose.android.produceLibraries
|
||||||
import com.mikepenz.aboutlibraries.ui.compose.m3.LibrariesContainer
|
import com.mikepenz.aboutlibraries.ui.compose.m3.LibrariesContainer
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Displays dependencies' license information to comply with attribution
|
||||||
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun LicenseInfo(modifier: Modifier = Modifier) {
|
fun LicenseInfo(modifier: Modifier = Modifier) {
|
||||||
val libraries by produceLibraries()
|
val libraries by produceLibraries()
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,9 @@ import androidx.tv.material3.Text
|
||||||
import com.github.damontecres.wholphin.ui.playOnClickSound
|
import com.github.damontecres.wholphin.ui.playOnClickSound
|
||||||
import com.github.damontecres.wholphin.ui.playSoundOnFocus
|
import com.github.damontecres.wholphin.ui.playSoundOnFocus
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show the overview text for an item. Uses a fixed size and allows for clicking.
|
||||||
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun OverviewText(
|
fun OverviewText(
|
||||||
overview: String,
|
overview: String,
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,7 @@ import com.github.damontecres.wholphin.ui.rememberPosition
|
||||||
import com.github.damontecres.wholphin.util.ApiRequestPager
|
import com.github.damontecres.wholphin.util.ApiRequestPager
|
||||||
import com.github.damontecres.wholphin.util.HomeRowLoadingState
|
import com.github.damontecres.wholphin.util.HomeRowLoadingState
|
||||||
import com.github.damontecres.wholphin.util.LoadingState
|
import com.github.damontecres.wholphin.util.LoadingState
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import kotlinx.coroutines.Deferred
|
import kotlinx.coroutines.Deferred
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.async
|
import kotlinx.coroutines.async
|
||||||
|
|
@ -50,8 +51,11 @@ import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import org.jellyfin.sdk.model.api.MediaType
|
import org.jellyfin.sdk.model.api.MediaType
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Abstract [ViewModel] for the "Recommended" tab for a library
|
||||||
|
*/
|
||||||
abstract class RecommendedViewModel(
|
abstract class RecommendedViewModel(
|
||||||
val context: Context,
|
@param:ApplicationContext val context: Context,
|
||||||
val navigationManager: NavigationManager,
|
val navigationManager: NavigationManager,
|
||||||
val favoriteWatchManager: FavoriteWatchManager,
|
val favoriteWatchManager: FavoriteWatchManager,
|
||||||
val mediaReportService: MediaReportService,
|
val mediaReportService: MediaReportService,
|
||||||
|
|
@ -207,6 +211,7 @@ fun RecommendedContent(
|
||||||
},
|
},
|
||||||
showClock = preferences.appPreferences.interfacePreferences.showClock,
|
showClock = preferences.appPreferences.interfacePreferences.showClock,
|
||||||
onUpdateBackdrop = viewModel::updateBackdrop,
|
onUpdateBackdrop = viewModel::updateBackdrop,
|
||||||
|
showLogo = preferences.appPreferences.interfacePreferences.showLogos,
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,11 @@ import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.tv.material3.LocalContentColor
|
import androidx.tv.material3.LocalContentColor
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shows a dot indicator
|
||||||
|
*
|
||||||
|
* Intended for using within the `leadingContent` of a [androidx.tv.material3.ListItem]
|
||||||
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun BoxScope.SelectedLeadingContent(
|
fun BoxScope.SelectedLeadingContent(
|
||||||
selected: Boolean,
|
selected: Boolean,
|
||||||
|
|
|
||||||
|
|
@ -126,16 +126,19 @@ data class SliderColors(
|
||||||
@Composable
|
@Composable
|
||||||
fun default() =
|
fun default() =
|
||||||
SliderColors(
|
SliderColors(
|
||||||
activeFocused = SliderActiveColor(true),
|
activeFocused = sliderActiveColor(true),
|
||||||
activeUnfocused = SliderActiveColor(false),
|
activeUnfocused = sliderActiveColor(false),
|
||||||
inactiveFocused = SliderInactiveColor(true),
|
inactiveFocused = sliderInactiveColor(true),
|
||||||
inactiveUnfocused = SliderInactiveColor(false),
|
inactiveUnfocused = sliderInactiveColor(false),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determines the active color for the slider. This is the "filled" left side of the slider
|
||||||
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun SliderActiveColor(focused: Boolean): Color {
|
fun sliderActiveColor(focused: Boolean): Color {
|
||||||
val theme = LocalTheme.current
|
val theme = LocalTheme.current
|
||||||
return when (theme) {
|
return when (theme) {
|
||||||
AppThemeColors.UNRECOGNIZED,
|
AppThemeColors.UNRECOGNIZED,
|
||||||
|
|
@ -165,8 +168,11 @@ fun SliderActiveColor(focused: Boolean): Color {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determines the inactive color for the slider. This is background of "unfilled" right side of the slider.
|
||||||
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun SliderInactiveColor(focused: Boolean): Color {
|
fun sliderInactiveColor(focused: Boolean): Color {
|
||||||
val theme = LocalTheme.current
|
val theme = LocalTheme.current
|
||||||
return when (theme) {
|
return when (theme) {
|
||||||
AppThemeColors.UNRECOGNIZED,
|
AppThemeColors.UNRECOGNIZED,
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,9 @@ import androidx.tv.material3.MaterialTheme
|
||||||
import androidx.tv.material3.Text
|
import androidx.tv.material3.Text
|
||||||
import com.github.damontecres.wholphin.ui.util.LocalClock
|
import com.github.damontecres.wholphin.ui.util.LocalClock
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Displays the [LocalClock] in the upper right corner of the parent [androidx.compose.foundation.layout.Box]
|
||||||
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun BoxScope.TimeDisplay(modifier: Modifier = Modifier) {
|
fun BoxScope.TimeDisplay(modifier: Modifier = Modifier) {
|
||||||
val timeString by LocalClock.current.timeString
|
val timeString by LocalClock.current.timeString
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,95 @@
|
||||||
|
package com.github.damontecres.wholphin.ui.components
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.heightIn
|
||||||
|
import androidx.compose.foundation.layout.widthIn
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.tv.material3.MaterialTheme
|
||||||
|
import androidx.tv.material3.Text
|
||||||
|
import coil3.compose.AsyncImage
|
||||||
|
import com.github.damontecres.wholphin.data.model.BaseItem
|
||||||
|
import com.github.damontecres.wholphin.ui.LocalImageUrlService
|
||||||
|
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||||
|
import org.jellyfin.sdk.model.api.ImageType
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun TitleOrLogo(
|
||||||
|
title: String?,
|
||||||
|
logoImageUrl: String?,
|
||||||
|
showLogo: Boolean,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
var imageError by remember { mutableStateOf(false) }
|
||||||
|
Box(
|
||||||
|
modifier = modifier.heightIn(max = HeaderUtils.logoHeight),
|
||||||
|
) {
|
||||||
|
if (showLogo && logoImageUrl != null && !imageError) {
|
||||||
|
AsyncImage(
|
||||||
|
model = logoImageUrl,
|
||||||
|
contentDescription = title,
|
||||||
|
contentScale = ContentScale.Fit,
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.height(HeaderUtils.logoHeight)
|
||||||
|
.widthIn(max = 320.dp),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Title(title, Modifier)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun Title(
|
||||||
|
title: String?,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = title ?: "",
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
style = MaterialTheme.typography.headlineMedium,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
maxLines = 2,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
modifier = modifier,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun TitleOrLogo(
|
||||||
|
item: BaseItem?,
|
||||||
|
showLogo: Boolean,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val logoImageUrl = rememberLogoUrl(item)
|
||||||
|
TitleOrLogo(
|
||||||
|
title = item?.title,
|
||||||
|
logoImageUrl = logoImageUrl,
|
||||||
|
showLogo = showLogo,
|
||||||
|
modifier = modifier,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun rememberLogoUrl(item: BaseItem?): String? {
|
||||||
|
val imageUrlService = LocalImageUrlService.current
|
||||||
|
return remember(item?.id) {
|
||||||
|
if (item?.type == BaseItemKind.EPISODE && item.data.seriesId != null && item.data.parentLogoImageTag != null) {
|
||||||
|
imageUrlService.getItemImageUrl(item.data.seriesId!!, ImageType.LOGO)
|
||||||
|
} else if (ImageType.LOGO in item?.data?.imageTags.orEmpty()) {
|
||||||
|
imageUrlService.getItemImageUrl(item, ImageType.LOGO)
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -39,6 +39,11 @@ import com.github.damontecres.wholphin.ui.tryRequestFocus
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
import org.jellyfin.sdk.model.api.ImageType
|
import org.jellyfin.sdk.model.api.ImageType
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A dialog that shows the controls for making changes to a [ViewOptions] object. The caller must manage the state of the [ViewOptions].
|
||||||
|
*
|
||||||
|
* It displays the [AppPreference] objects from [ViewOptions.OPTIONS]
|
||||||
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun ViewOptionsDialog(
|
fun ViewOptionsDialog(
|
||||||
viewOptions: ViewOptions,
|
viewOptions: ViewOptions,
|
||||||
|
|
@ -105,6 +110,11 @@ fun ViewOptionsDialog(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stores the customizable view changes from the user
|
||||||
|
*
|
||||||
|
* This is used to determine how the UI looks such as card size and shape
|
||||||
|
*/
|
||||||
@Serializable
|
@Serializable
|
||||||
data class ViewOptions(
|
data class ViewOptions(
|
||||||
val columns: Int = 6,
|
val columns: Int = 6,
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,10 @@ import timber.log.Timber
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A supplementary [ViewModel] for adding items to a server playlist
|
||||||
|
* @see com.github.damontecres.wholphin.ui.detail.PlaylistDialog
|
||||||
|
*/
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class AddPlaylistViewModel
|
class AddPlaylistViewModel
|
||||||
@Inject
|
@Inject
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,9 @@ data class ItemDetailsDialogInfo(
|
||||||
val files: List<MediaSourceInfo>,
|
val files: List<MediaSourceInfo>,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dialog showing metadata about an item
|
||||||
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun ItemDetailsDialog(
|
fun ItemDetailsDialog(
|
||||||
info: ItemDetailsDialogInfo,
|
info: ItemDetailsDialogInfo,
|
||||||
|
|
|
||||||
|
|
@ -85,6 +85,9 @@ interface CardGridItem {
|
||||||
val sortName: String
|
val sortName: String
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shows a vertical grid of [CardGridItem]s
|
||||||
|
*/
|
||||||
@OptIn(ExperimentalFoundationApi::class)
|
@OptIn(ExperimentalFoundationApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun <T : CardGridItem> CardGrid(
|
fun <T : CardGridItem> CardGrid(
|
||||||
|
|
|
||||||
|
|
@ -49,9 +49,8 @@ enum class ClearChosenStreams {
|
||||||
* If there are any (ie one or more) subtitle tracks, adds an option to disable or pick one
|
* If there are any (ie one or more) subtitle tracks, adds an option to disable or pick one
|
||||||
*
|
*
|
||||||
* @param item the media item to build for, typically an Episode or Movie
|
* @param item the media item to build for, typically an Episode or Movie
|
||||||
* @param series the item's series or null if not a TV episode; a non-null value will include a "Go to Series" option
|
* @param seriesId the item's series or null if not a TV episode; a non-null value will include a "Go to Series" option
|
||||||
* @param sourceId the item's media source UUID
|
* @param sourceId the item's media source UUID
|
||||||
* @param navigateTo a function to trigger a navigation
|
|
||||||
* @param onChooseVersion callback to pick a version of the item
|
* @param onChooseVersion callback to pick a version of the item
|
||||||
* @param onChooseTracks callback to pick a track for the given type of the item
|
* @param onChooseTracks callback to pick a track for the given type of the item
|
||||||
* @param onShowOverview callback to show overview dialog with media information
|
* @param onShowOverview callback to show overview dialog with media information
|
||||||
|
|
|
||||||
|
|
@ -256,7 +256,7 @@ class PlaylistViewModel
|
||||||
/**
|
/**
|
||||||
* This method tries to determine the [MediaType] of a playlist
|
* This method tries to determine the [MediaType] of a playlist
|
||||||
*
|
*
|
||||||
* In theory, the server will set the type, but sometime it doesn't
|
* In theory, the server will set the type, but sometimes it doesn't
|
||||||
*/
|
*/
|
||||||
private suspend fun determineMediaType() {
|
private suspend fun determineMediaType() {
|
||||||
// Use the type the server says
|
// Use the type the server says
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,6 @@ import androidx.compose.foundation.focusable
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.fillMaxHeight
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
|
|
@ -50,6 +49,7 @@ import com.github.damontecres.wholphin.ui.components.DialogItem
|
||||||
import com.github.damontecres.wholphin.ui.components.DialogParams
|
import com.github.damontecres.wholphin.ui.components.DialogParams
|
||||||
import com.github.damontecres.wholphin.ui.components.DialogPopup
|
import com.github.damontecres.wholphin.ui.components.DialogPopup
|
||||||
import com.github.damontecres.wholphin.ui.components.ErrorMessage
|
import com.github.damontecres.wholphin.ui.components.ErrorMessage
|
||||||
|
import com.github.damontecres.wholphin.ui.components.HeaderUtils
|
||||||
import com.github.damontecres.wholphin.ui.components.LoadingPage
|
import com.github.damontecres.wholphin.ui.components.LoadingPage
|
||||||
import com.github.damontecres.wholphin.ui.components.Optional
|
import com.github.damontecres.wholphin.ui.components.Optional
|
||||||
import com.github.damontecres.wholphin.ui.data.AddPlaylistViewModel
|
import com.github.damontecres.wholphin.ui.data.AddPlaylistViewModel
|
||||||
|
|
@ -379,11 +379,13 @@ fun CollectionDetailsContent(
|
||||||
if (state.viewOptions.cardViewOptions.showDetails) {
|
if (state.viewOptions.cardViewOptions.showDetails) {
|
||||||
HomePageHeader(
|
HomePageHeader(
|
||||||
item = focusedItem,
|
item = focusedItem,
|
||||||
|
showLogo = preferences.appPreferences.interfacePreferences.showLogos,
|
||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
.padding(top = 48.dp, start = 8.dp)
|
.padding(
|
||||||
.fillMaxHeight(.33f)
|
top = HeaderUtils.topPadding,
|
||||||
.fillMaxWidth(),
|
bottom = 8.dp,
|
||||||
|
).height(HeaderUtils.height),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -412,15 +414,16 @@ fun CollectionDetailsContent(
|
||||||
) {
|
) {
|
||||||
CollectionDetailsHeader(
|
CollectionDetailsHeader(
|
||||||
collection = state.collection!!,
|
collection = state.collection!!,
|
||||||
|
showLogo = preferences.appPreferences.interfacePreferences.showLogos,
|
||||||
logoImageUrl = state.logoImageUrl,
|
logoImageUrl = state.logoImageUrl,
|
||||||
overviewOnClick = overviewOnClick,
|
overviewOnClick = overviewOnClick,
|
||||||
bringIntoViewRequester = bringIntoViewRequester,
|
bringIntoViewRequester = bringIntoViewRequester,
|
||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
.padding(top = 48.dp, start = 8.dp)
|
.padding(
|
||||||
// TODO
|
top = HeaderUtils.topPadding,
|
||||||
.fillMaxHeight(.36f)
|
bottom = HeaderUtils.bottomPadding,
|
||||||
.fillMaxWidth(),
|
).height(HeaderUtils.height),
|
||||||
)
|
)
|
||||||
CollectionButtons(
|
CollectionButtons(
|
||||||
state = state,
|
state = state,
|
||||||
|
|
|
||||||
|
|
@ -3,28 +3,24 @@ package com.github.damontecres.wholphin.ui.detail.collection
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.relocation.BringIntoViewRequester
|
import androidx.compose.foundation.relocation.BringIntoViewRequester
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.focus.onFocusChanged
|
import androidx.compose.ui.focus.onFocusChanged
|
||||||
import androidx.compose.ui.layout.ContentScale
|
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.text.font.FontStyle
|
import androidx.compose.ui.text.font.FontStyle
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
|
||||||
import androidx.compose.ui.unit.Dp
|
import androidx.compose.ui.unit.Dp
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.tv.material3.MaterialTheme
|
import androidx.tv.material3.MaterialTheme
|
||||||
import androidx.tv.material3.Text
|
import androidx.tv.material3.Text
|
||||||
import coil3.compose.AsyncImage
|
|
||||||
import com.github.damontecres.wholphin.data.model.BaseItem
|
import com.github.damontecres.wholphin.data.model.BaseItem
|
||||||
import com.github.damontecres.wholphin.ui.components.GenreText
|
import com.github.damontecres.wholphin.ui.components.GenreText
|
||||||
|
import com.github.damontecres.wholphin.ui.components.HeaderUtils
|
||||||
import com.github.damontecres.wholphin.ui.components.OverviewText
|
import com.github.damontecres.wholphin.ui.components.OverviewText
|
||||||
import com.github.damontecres.wholphin.ui.components.QuickDetails
|
import com.github.damontecres.wholphin.ui.components.QuickDetails
|
||||||
|
import com.github.damontecres.wholphin.ui.components.TitleOrLogo
|
||||||
import com.github.damontecres.wholphin.ui.letNotEmpty
|
import com.github.damontecres.wholphin.ui.letNotEmpty
|
||||||
import com.github.damontecres.wholphin.util.ExceptionHandler
|
import com.github.damontecres.wholphin.util.ExceptionHandler
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
@ -32,6 +28,7 @@ import kotlinx.coroutines.launch
|
||||||
@Composable
|
@Composable
|
||||||
fun CollectionDetailsHeader(
|
fun CollectionDetailsHeader(
|
||||||
collection: BaseItem,
|
collection: BaseItem,
|
||||||
|
showLogo: Boolean,
|
||||||
logoImageUrl: String?,
|
logoImageUrl: String?,
|
||||||
overviewOnClick: () -> Unit,
|
overviewOnClick: () -> Unit,
|
||||||
bringIntoViewRequester: BringIntoViewRequester,
|
bringIntoViewRequester: BringIntoViewRequester,
|
||||||
|
|
@ -44,28 +41,15 @@ fun CollectionDetailsHeader(
|
||||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
) {
|
) {
|
||||||
if (logoImageUrl != null) {
|
TitleOrLogo(
|
||||||
AsyncImage(
|
title = collection.name,
|
||||||
model = logoImageUrl,
|
showLogo = showLogo,
|
||||||
contentDescription = collection.name,
|
logoImageUrl = logoImageUrl,
|
||||||
modifier = Modifier.height(80.dp),
|
modifier =
|
||||||
alignment = Alignment.TopStart,
|
Modifier
|
||||||
contentScale = ContentScale.Fit,
|
.fillMaxWidth(.75f)
|
||||||
)
|
.padding(start = HeaderUtils.startPadding),
|
||||||
} else {
|
)
|
||||||
// Title
|
|
||||||
Text(
|
|
||||||
text = collection.name ?: "",
|
|
||||||
color = MaterialTheme.colorScheme.onBackground,
|
|
||||||
style = MaterialTheme.typography.headlineMedium.copy(fontWeight = FontWeight.SemiBold),
|
|
||||||
maxLines = 1,
|
|
||||||
overflow = TextOverflow.Ellipsis,
|
|
||||||
modifier =
|
|
||||||
Modifier
|
|
||||||
.fillMaxWidth(.75f)
|
|
||||||
.padding(start = 8.dp),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
|
|
@ -74,18 +58,18 @@ fun CollectionDetailsHeader(
|
||||||
QuickDetails(
|
QuickDetails(
|
||||||
collection.ui.quickDetails,
|
collection.ui.quickDetails,
|
||||||
collection.timeRemainingOrRuntime,
|
collection.timeRemainingOrRuntime,
|
||||||
Modifier.padding(start = 8.dp),
|
Modifier.padding(start = HeaderUtils.startPadding),
|
||||||
)
|
)
|
||||||
|
|
||||||
dto.genres?.letNotEmpty {
|
dto.genres?.letNotEmpty {
|
||||||
GenreText(it, Modifier.padding(start = 8.dp))
|
GenreText(it, Modifier.padding(start = HeaderUtils.startPadding))
|
||||||
}
|
}
|
||||||
dto.taglines?.firstOrNull()?.let { tagline ->
|
dto.taglines?.firstOrNull()?.let { tagline ->
|
||||||
Text(
|
Text(
|
||||||
text = tagline,
|
text = tagline,
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
fontStyle = FontStyle.Italic,
|
fontStyle = FontStyle.Italic,
|
||||||
modifier = Modifier.padding(start = 8.dp),
|
modifier = Modifier.padding(start = HeaderUtils.startPadding),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -83,6 +83,7 @@ fun CollectionRows(
|
||||||
onUpdateBackdrop = {},
|
onUpdateBackdrop = {},
|
||||||
headerComposable = {},
|
headerComposable = {},
|
||||||
takeFocus = false,
|
takeFocus = false,
|
||||||
|
showLogo = preferences.appPreferences.interfacePreferences.showLogos,
|
||||||
modifier = Modifier,
|
modifier = Modifier,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -62,6 +62,7 @@ import kotlinx.coroutines.withContext
|
||||||
import org.jellyfin.sdk.api.client.ApiClient
|
import org.jellyfin.sdk.api.client.ApiClient
|
||||||
import org.jellyfin.sdk.api.client.extensions.userLibraryApi
|
import org.jellyfin.sdk.api.client.extensions.userLibraryApi
|
||||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||||
|
import org.jellyfin.sdk.model.api.ImageType
|
||||||
import org.jellyfin.sdk.model.api.ItemSortBy
|
import org.jellyfin.sdk.model.api.ItemSortBy
|
||||||
import org.jellyfin.sdk.model.api.SortOrder
|
import org.jellyfin.sdk.model.api.SortOrder
|
||||||
import org.jellyfin.sdk.model.api.request.GetItemsRequest
|
import org.jellyfin.sdk.model.api.request.GetItemsRequest
|
||||||
|
|
@ -139,13 +140,12 @@ class CollectionViewModel
|
||||||
.content
|
.content
|
||||||
.let { BaseItem(it, false) }
|
.let { BaseItem(it, false) }
|
||||||
backdropService.submit(collection)
|
backdropService.submit(collection)
|
||||||
val logoImageUrl = null
|
val logoImageUrl =
|
||||||
// TODO add logo back
|
if (ImageType.LOGO in collection.data.imageTags.orEmpty()) {
|
||||||
// if (ImageType.LOGO in collection.data.imageTags.orEmpty()) {
|
imageUrlService.getItemImageUrl(collection, ImageType.LOGO)
|
||||||
// imageUrlService.getItemImageUrl(collection, ImageType.LOGO)
|
} else {
|
||||||
// } else {
|
null
|
||||||
// null
|
}
|
||||||
// }
|
|
||||||
_state.update {
|
_state.update {
|
||||||
it.copy(
|
it.copy(
|
||||||
collection = collection,
|
collection = collection,
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,7 @@ import com.github.damontecres.wholphin.ui.components.DialogParams
|
||||||
import com.github.damontecres.wholphin.ui.components.DialogPopup
|
import com.github.damontecres.wholphin.ui.components.DialogPopup
|
||||||
import com.github.damontecres.wholphin.ui.components.ErrorMessage
|
import com.github.damontecres.wholphin.ui.components.ErrorMessage
|
||||||
import com.github.damontecres.wholphin.ui.components.ExpandablePlayButtons
|
import com.github.damontecres.wholphin.ui.components.ExpandablePlayButtons
|
||||||
|
import com.github.damontecres.wholphin.ui.components.HeaderUtils
|
||||||
import com.github.damontecres.wholphin.ui.components.LoadingPage
|
import com.github.damontecres.wholphin.ui.components.LoadingPage
|
||||||
import com.github.damontecres.wholphin.ui.components.Optional
|
import com.github.damontecres.wholphin.ui.components.Optional
|
||||||
import com.github.damontecres.wholphin.ui.components.chooseStream
|
import com.github.damontecres.wholphin.ui.components.chooseStream
|
||||||
|
|
@ -332,7 +333,7 @@ fun EpisodeDetailsContent(
|
||||||
Box(modifier = modifier) {
|
Box(modifier = modifier) {
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
contentPadding = PaddingValues(vertical = 8.dp),
|
contentPadding = PaddingValues(bottom = 8.dp),
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
) {
|
) {
|
||||||
item {
|
item {
|
||||||
|
|
@ -352,7 +353,7 @@ fun EpisodeDetailsContent(
|
||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(top = 32.dp, bottom = 16.dp),
|
.padding(top = HeaderUtils.topPadding, bottom = 16.dp),
|
||||||
)
|
)
|
||||||
ExpandablePlayButtons(
|
ExpandablePlayButtons(
|
||||||
resumePosition = resumePosition,
|
resumePosition = resumePosition,
|
||||||
|
|
|
||||||
|
|
@ -172,6 +172,9 @@ class LiveTvViewModel
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a list of [LocalDateTime] for each hour from now
|
||||||
|
*/
|
||||||
private fun buildGuideTimes() =
|
private fun buildGuideTimes() =
|
||||||
buildList {
|
buildList {
|
||||||
val start = LocalDateTime.now().roundDownToHalfHour()
|
val start = LocalDateTime.now().roundDownToHalfHour()
|
||||||
|
|
@ -194,15 +197,22 @@ class LiveTvViewModel
|
||||||
loading.setValueOnMain(LoadingState.Success)
|
loading.setValueOnMain(LoadingState.Success)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get live TV programs for a subset of channels
|
||||||
|
*
|
||||||
|
* @param guideStart The start timestamp to fetch from
|
||||||
|
* @param channels The full list of channels
|
||||||
|
* @param channelIndices The indices of which channels to fetch programs for
|
||||||
|
*/
|
||||||
private suspend fun fetchPrograms(
|
private suspend fun fetchPrograms(
|
||||||
guideStart: LocalDateTime,
|
guideStart: LocalDateTime,
|
||||||
channels: List<TvChannel>,
|
channels: List<TvChannel>,
|
||||||
range: IntRange,
|
channelIndices: IntRange,
|
||||||
) = mutex.withLock {
|
) = mutex.withLock {
|
||||||
val maxStartDate = guideStart.plusHours(MAX_HOURS).minusMinutes(1)
|
val maxStartDate = guideStart.plusHours(MAX_HOURS).minusMinutes(1)
|
||||||
val minEndDate = guideStart.plusMinutes(1L)
|
val minEndDate = guideStart.plusMinutes(1L)
|
||||||
val channelsToFetch = channels.subList(range.first, range.last + 1)
|
val channelsToFetch = channels.subList(channelIndices.first, channelIndices.last + 1)
|
||||||
Timber.v("Fetching programs for $range channels ${channelsToFetch.size}")
|
Timber.v("Fetching programs for $channelIndices channels ${channelsToFetch.size}")
|
||||||
val request =
|
val request =
|
||||||
GetProgramsDto(
|
GetProgramsDto(
|
||||||
maxStartDate = maxStartDate,
|
maxStartDate = maxStartDate,
|
||||||
|
|
@ -390,7 +400,7 @@ class LiveTvViewModel
|
||||||
Timber.d("Got ${fetchedPrograms.size} programs & ${finalProgramList.size} total programs")
|
Timber.d("Got ${fetchedPrograms.size} programs & ${finalProgramList.size} total programs")
|
||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Main) {
|
||||||
this@LiveTvViewModel.programs.value =
|
this@LiveTvViewModel.programs.value =
|
||||||
FetchedPrograms(range, finalProgramList, programsByChannel)
|
FetchedPrograms(channelIndices, finalProgramList, programsByChannel)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -472,6 +482,11 @@ class LiveTvViewModel
|
||||||
|
|
||||||
private var focusLoadingJob: Job? = null
|
private var focusLoadingJob: Job? = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Callback when focusing on the EPG grid
|
||||||
|
*
|
||||||
|
* This determines if more programs/channels should be fetched based on the current position
|
||||||
|
*/
|
||||||
fun onFocusChannel(position: RowColumn) {
|
fun onFocusChannel(position: RowColumn) {
|
||||||
channels.value?.let { channels ->
|
channels.value?.let { channels ->
|
||||||
val fetchedRange = programs.value!!.range
|
val fetchedRange = programs.value!!.range
|
||||||
|
|
|
||||||
|
|
@ -51,6 +51,7 @@ import com.github.damontecres.wholphin.ui.components.DialogParams
|
||||||
import com.github.damontecres.wholphin.ui.components.DialogPopup
|
import com.github.damontecres.wholphin.ui.components.DialogPopup
|
||||||
import com.github.damontecres.wholphin.ui.components.ErrorMessage
|
import com.github.damontecres.wholphin.ui.components.ErrorMessage
|
||||||
import com.github.damontecres.wholphin.ui.components.ExpandablePlayButtons
|
import com.github.damontecres.wholphin.ui.components.ExpandablePlayButtons
|
||||||
|
import com.github.damontecres.wholphin.ui.components.HeaderUtils
|
||||||
import com.github.damontecres.wholphin.ui.components.LoadingPage
|
import com.github.damontecres.wholphin.ui.components.LoadingPage
|
||||||
import com.github.damontecres.wholphin.ui.components.Optional
|
import com.github.damontecres.wholphin.ui.components.Optional
|
||||||
import com.github.damontecres.wholphin.ui.components.chooseStream
|
import com.github.damontecres.wholphin.ui.components.chooseStream
|
||||||
|
|
@ -439,7 +440,7 @@ fun MovieDetailsContent(
|
||||||
Box(modifier = modifier) {
|
Box(modifier = modifier) {
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
contentPadding = PaddingValues(vertical = 8.dp),
|
contentPadding = PaddingValues(bottom = 8.dp),
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
) {
|
) {
|
||||||
item {
|
item {
|
||||||
|
|
@ -459,7 +460,7 @@ fun MovieDetailsContent(
|
||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(top = 40.dp, bottom = 16.dp),
|
.padding(top = HeaderUtils.topPadding, bottom = 16.dp),
|
||||||
)
|
)
|
||||||
ExpandablePlayButtons(
|
ExpandablePlayButtons(
|
||||||
resumePosition = resumePosition,
|
resumePosition = resumePosition,
|
||||||
|
|
|
||||||
|
|
@ -13,8 +13,6 @@ import androidx.compose.ui.focus.onFocusChanged
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.font.FontStyle
|
import androidx.compose.ui.text.font.FontStyle
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
|
||||||
import androidx.compose.ui.unit.Dp
|
import androidx.compose.ui.unit.Dp
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.tv.material3.MaterialTheme
|
import androidx.tv.material3.MaterialTheme
|
||||||
|
|
@ -24,8 +22,10 @@ import com.github.damontecres.wholphin.data.ChosenStreams
|
||||||
import com.github.damontecres.wholphin.data.model.BaseItem
|
import com.github.damontecres.wholphin.data.model.BaseItem
|
||||||
import com.github.damontecres.wholphin.preferences.UserPreferences
|
import com.github.damontecres.wholphin.preferences.UserPreferences
|
||||||
import com.github.damontecres.wholphin.ui.components.GenreText
|
import com.github.damontecres.wholphin.ui.components.GenreText
|
||||||
|
import com.github.damontecres.wholphin.ui.components.HeaderUtils
|
||||||
import com.github.damontecres.wholphin.ui.components.OverviewText
|
import com.github.damontecres.wholphin.ui.components.OverviewText
|
||||||
import com.github.damontecres.wholphin.ui.components.QuickDetails
|
import com.github.damontecres.wholphin.ui.components.QuickDetails
|
||||||
|
import com.github.damontecres.wholphin.ui.components.TitleOrLogo
|
||||||
import com.github.damontecres.wholphin.ui.components.VideoStreamDetails
|
import com.github.damontecres.wholphin.ui.components.VideoStreamDetails
|
||||||
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
|
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
|
||||||
import com.github.damontecres.wholphin.ui.letNotEmpty
|
import com.github.damontecres.wholphin.ui.letNotEmpty
|
||||||
|
|
@ -50,16 +50,13 @@ fun MovieDetailsHeader(
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
) {
|
) {
|
||||||
// Title
|
// Title
|
||||||
Text(
|
TitleOrLogo(
|
||||||
text = movie.name ?: "",
|
item = movie,
|
||||||
color = MaterialTheme.colorScheme.onBackground,
|
showLogo = preferences.appPreferences.interfacePreferences.showLogos,
|
||||||
style = MaterialTheme.typography.headlineMedium.copy(fontWeight = FontWeight.SemiBold),
|
|
||||||
maxLines = 1,
|
|
||||||
overflow = TextOverflow.Ellipsis,
|
|
||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxWidth(.75f)
|
.fillMaxWidth(.75f)
|
||||||
.padding(start = 8.dp),
|
.padding(start = HeaderUtils.startPadding),
|
||||||
)
|
)
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
|
|
@ -69,24 +66,29 @@ fun MovieDetailsHeader(
|
||||||
QuickDetails(
|
QuickDetails(
|
||||||
movie.ui.quickDetails,
|
movie.ui.quickDetails,
|
||||||
movie.timeRemainingOrRuntime,
|
movie.timeRemainingOrRuntime,
|
||||||
Modifier.padding(start = 8.dp),
|
Modifier.padding(start = HeaderUtils.startPadding),
|
||||||
)
|
)
|
||||||
|
|
||||||
dto.genres?.letNotEmpty {
|
dto.genres?.letNotEmpty {
|
||||||
GenreText(it, Modifier.padding(start = 8.dp))
|
GenreText(it, Modifier.padding(start = HeaderUtils.startPadding))
|
||||||
}
|
}
|
||||||
|
|
||||||
VideoStreamDetails(
|
VideoStreamDetails(
|
||||||
chosenStreams = chosenStreams,
|
chosenStreams = chosenStreams,
|
||||||
numberOfVersions = movie.data.mediaSourceCount ?: 0,
|
numberOfVersions = movie.data.mediaSourceCount ?: 0,
|
||||||
modifier = Modifier.padding(start = 8.dp, top = 4.dp, bottom = 16.dp),
|
modifier =
|
||||||
|
Modifier.padding(
|
||||||
|
start = HeaderUtils.startPadding,
|
||||||
|
top = 4.dp,
|
||||||
|
bottom = 16.dp,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
dto.taglines?.firstOrNull()?.let { tagline ->
|
dto.taglines?.firstOrNull()?.let { tagline ->
|
||||||
Text(
|
Text(
|
||||||
text = tagline,
|
text = tagline,
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
fontStyle = FontStyle.Italic,
|
fontStyle = FontStyle.Italic,
|
||||||
modifier = Modifier.padding(start = 8.dp),
|
modifier = Modifier.padding(start = HeaderUtils.startPadding),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -122,7 +124,7 @@ fun MovieDetailsHeader(
|
||||||
text = stringResource(R.string.directed_by, it),
|
text = stringResource(R.string.directed_by, it),
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
modifier = Modifier.padding(start = 8.dp),
|
modifier = Modifier.padding(start = HeaderUtils.startPadding),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ import com.github.damontecres.wholphin.data.ChosenStreams
|
||||||
import com.github.damontecres.wholphin.data.model.BaseItem
|
import com.github.damontecres.wholphin.data.model.BaseItem
|
||||||
import com.github.damontecres.wholphin.preferences.UserPreferences
|
import com.github.damontecres.wholphin.preferences.UserPreferences
|
||||||
import com.github.damontecres.wholphin.ui.components.EpisodeName
|
import com.github.damontecres.wholphin.ui.components.EpisodeName
|
||||||
|
import com.github.damontecres.wholphin.ui.components.HeaderUtils
|
||||||
import com.github.damontecres.wholphin.ui.components.OverviewText
|
import com.github.damontecres.wholphin.ui.components.OverviewText
|
||||||
import com.github.damontecres.wholphin.ui.components.QuickDetails
|
import com.github.damontecres.wholphin.ui.components.QuickDetails
|
||||||
import com.github.damontecres.wholphin.ui.components.VideoStreamDetails
|
import com.github.damontecres.wholphin.ui.components.VideoStreamDetails
|
||||||
|
|
@ -32,17 +33,21 @@ fun FocusedEpisodeHeader(
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
) {
|
) {
|
||||||
EpisodeName(dto, modifier = Modifier.padding(start = 8.dp))
|
EpisodeName(dto, modifier = Modifier.padding(start = HeaderUtils.startPadding))
|
||||||
|
|
||||||
ep?.ui?.quickDetails?.let {
|
ep?.ui?.quickDetails?.let {
|
||||||
QuickDetails(it, ep.timeRemainingOrRuntime, Modifier.padding(start = 8.dp))
|
QuickDetails(
|
||||||
|
it,
|
||||||
|
ep.timeRemainingOrRuntime,
|
||||||
|
Modifier.padding(start = HeaderUtils.startPadding),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (dto != null) {
|
if (dto != null) {
|
||||||
VideoStreamDetails(
|
VideoStreamDetails(
|
||||||
chosenStreams = chosenStreams,
|
chosenStreams = chosenStreams,
|
||||||
numberOfVersions = dto.mediaSourceCount ?: 0,
|
numberOfVersions = dto.mediaSourceCount ?: 0,
|
||||||
modifier = Modifier.padding(start = 8.dp),
|
modifier = Modifier.padding(start = HeaderUtils.startPadding),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
OverviewText(
|
OverviewText(
|
||||||
|
|
|
||||||
|
|
@ -37,14 +37,10 @@ import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.platform.LocalFocusManager
|
import androidx.compose.ui.platform.LocalFocusManager
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
|
||||||
import androidx.compose.ui.unit.Dp
|
import androidx.compose.ui.unit.Dp
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||||
import androidx.lifecycle.compose.LifecycleResumeEffect
|
import androidx.lifecycle.compose.LifecycleResumeEffect
|
||||||
import androidx.tv.material3.MaterialTheme
|
|
||||||
import androidx.tv.material3.Text
|
|
||||||
import com.github.damontecres.wholphin.R
|
import com.github.damontecres.wholphin.R
|
||||||
import com.github.damontecres.wholphin.data.ExtrasItem
|
import com.github.damontecres.wholphin.data.ExtrasItem
|
||||||
import com.github.damontecres.wholphin.data.model.BaseItem
|
import com.github.damontecres.wholphin.data.model.BaseItem
|
||||||
|
|
@ -69,10 +65,12 @@ import com.github.damontecres.wholphin.ui.components.ErrorMessage
|
||||||
import com.github.damontecres.wholphin.ui.components.ExpandableFaButton
|
import com.github.damontecres.wholphin.ui.components.ExpandableFaButton
|
||||||
import com.github.damontecres.wholphin.ui.components.ExpandablePlayButton
|
import com.github.damontecres.wholphin.ui.components.ExpandablePlayButton
|
||||||
import com.github.damontecres.wholphin.ui.components.GenreText
|
import com.github.damontecres.wholphin.ui.components.GenreText
|
||||||
|
import com.github.damontecres.wholphin.ui.components.HeaderUtils
|
||||||
import com.github.damontecres.wholphin.ui.components.LoadingPage
|
import com.github.damontecres.wholphin.ui.components.LoadingPage
|
||||||
import com.github.damontecres.wholphin.ui.components.Optional
|
import com.github.damontecres.wholphin.ui.components.Optional
|
||||||
import com.github.damontecres.wholphin.ui.components.OverviewText
|
import com.github.damontecres.wholphin.ui.components.OverviewText
|
||||||
import com.github.damontecres.wholphin.ui.components.QuickDetails
|
import com.github.damontecres.wholphin.ui.components.QuickDetails
|
||||||
|
import com.github.damontecres.wholphin.ui.components.TitleOrLogo
|
||||||
import com.github.damontecres.wholphin.ui.components.TrailerButton
|
import com.github.damontecres.wholphin.ui.components.TrailerButton
|
||||||
import com.github.damontecres.wholphin.ui.data.AddPlaylistViewModel
|
import com.github.damontecres.wholphin.ui.data.AddPlaylistViewModel
|
||||||
import com.github.damontecres.wholphin.ui.data.ItemDetailsDialog
|
import com.github.damontecres.wholphin.ui.data.ItemDetailsDialog
|
||||||
|
|
@ -386,7 +384,6 @@ fun SeriesDetailsContent(
|
||||||
Column(
|
Column(
|
||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
.padding(vertical = 16.dp)
|
|
||||||
.fillMaxSize(),
|
.fillMaxSize(),
|
||||||
) {
|
) {
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
|
|
@ -397,18 +394,19 @@ fun SeriesDetailsContent(
|
||||||
item {
|
item {
|
||||||
SeriesDetailsHeader(
|
SeriesDetailsHeader(
|
||||||
series = series,
|
series = series,
|
||||||
|
showLogo = preferences.appPreferences.interfacePreferences.showLogos,
|
||||||
overviewOnClick = overviewOnClick,
|
overviewOnClick = overviewOnClick,
|
||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.bringIntoViewRequester(bringIntoViewRequester)
|
.bringIntoViewRequester(bringIntoViewRequester)
|
||||||
.padding(top = 32.dp, bottom = 16.dp),
|
.padding(top = HeaderUtils.topPadding, bottom = 16.dp),
|
||||||
)
|
)
|
||||||
Row(
|
Row(
|
||||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
.padding(start = 8.dp)
|
.padding(start = HeaderUtils.startPadding)
|
||||||
.focusRequester(focusRequesters[HEADER_ROW])
|
.focusRequester(focusRequesters[HEADER_ROW])
|
||||||
.focusRestorer(playFocusRequester)
|
.focusRestorer(playFocusRequester)
|
||||||
.focusGroup()
|
.focusGroup()
|
||||||
|
|
@ -684,6 +682,7 @@ fun SeriesDetailsContent(
|
||||||
@Composable
|
@Composable
|
||||||
fun SeriesDetailsHeader(
|
fun SeriesDetailsHeader(
|
||||||
series: BaseItem,
|
series: BaseItem,
|
||||||
|
showLogo: Boolean,
|
||||||
overviewOnClick: () -> Unit,
|
overviewOnClick: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
|
|
@ -693,24 +692,25 @@ fun SeriesDetailsHeader(
|
||||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
) {
|
) {
|
||||||
Text(
|
TitleOrLogo(
|
||||||
text = series.name ?: stringResource(R.string.unknown),
|
item = series,
|
||||||
color = MaterialTheme.colorScheme.onBackground,
|
showLogo = showLogo,
|
||||||
style = MaterialTheme.typography.headlineMedium.copy(fontWeight = FontWeight.SemiBold),
|
|
||||||
maxLines = 1,
|
|
||||||
overflow = TextOverflow.Ellipsis,
|
|
||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxWidth(.75f)
|
.fillMaxWidth(.75f)
|
||||||
.padding(start = 8.dp),
|
.padding(start = HeaderUtils.startPadding),
|
||||||
)
|
)
|
||||||
Column(
|
Column(
|
||||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
modifier = Modifier.fillMaxWidth(.60f),
|
modifier = Modifier.fillMaxWidth(.60f),
|
||||||
) {
|
) {
|
||||||
QuickDetails(series.ui.quickDetails, null, Modifier.padding(start = 8.dp))
|
QuickDetails(
|
||||||
|
series.ui.quickDetails,
|
||||||
|
null,
|
||||||
|
Modifier.padding(start = HeaderUtils.startPadding),
|
||||||
|
)
|
||||||
dto.genres?.letNotEmpty {
|
dto.genres?.letNotEmpty {
|
||||||
GenreText(it, Modifier.padding(start = 8.dp, bottom = 8.dp))
|
GenreText(it, Modifier.padding(start = HeaderUtils.startPadding, bottom = 8.dp))
|
||||||
}
|
}
|
||||||
dto.overview?.let { overview ->
|
dto.overview?.let { overview ->
|
||||||
OverviewText(
|
OverviewText(
|
||||||
|
|
|
||||||
|
|
@ -52,9 +52,10 @@ import com.github.damontecres.wholphin.ui.AspectRatios
|
||||||
import com.github.damontecres.wholphin.ui.cards.BannerCard
|
import com.github.damontecres.wholphin.ui.cards.BannerCard
|
||||||
import com.github.damontecres.wholphin.ui.cards.PersonRow
|
import com.github.damontecres.wholphin.ui.cards.PersonRow
|
||||||
import com.github.damontecres.wholphin.ui.components.ErrorMessage
|
import com.github.damontecres.wholphin.ui.components.ErrorMessage
|
||||||
|
import com.github.damontecres.wholphin.ui.components.HeaderUtils
|
||||||
import com.github.damontecres.wholphin.ui.components.LoadingPage
|
import com.github.damontecres.wholphin.ui.components.LoadingPage
|
||||||
import com.github.damontecres.wholphin.ui.components.SeriesName
|
|
||||||
import com.github.damontecres.wholphin.ui.components.TabRow
|
import com.github.damontecres.wholphin.ui.components.TabRow
|
||||||
|
import com.github.damontecres.wholphin.ui.components.TitleOrLogo
|
||||||
import com.github.damontecres.wholphin.ui.ifElse
|
import com.github.damontecres.wholphin.ui.ifElse
|
||||||
import com.github.damontecres.wholphin.ui.logTab
|
import com.github.damontecres.wholphin.ui.logTab
|
||||||
import com.github.damontecres.wholphin.ui.playback.isPlayKeyUp
|
import com.github.damontecres.wholphin.ui.playback.isPlayKeyUp
|
||||||
|
|
@ -143,10 +144,12 @@ fun SeriesOverviewContent(
|
||||||
.bringIntoViewRequester(bringIntoViewRequester),
|
.bringIntoViewRequester(bringIntoViewRequester),
|
||||||
) {
|
) {
|
||||||
val paddingValues =
|
val paddingValues =
|
||||||
if (preferences.appPreferences.interfacePreferences.showClock) {
|
remember(preferences.appPreferences.interfacePreferences.showClock) {
|
||||||
PaddingValues(start = 0.dp, end = 184.dp)
|
if (preferences.appPreferences.interfacePreferences.showClock) {
|
||||||
} else {
|
PaddingValues(start = 0.dp, end = 184.dp)
|
||||||
PaddingValues(start = 0.dp, end = 16.dp)
|
} else {
|
||||||
|
PaddingValues(start = 0.dp, end = 16.dp)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
TabRow(
|
TabRow(
|
||||||
selectedTabIndex = selectedTabIndex,
|
selectedTabIndex = selectedTabIndex,
|
||||||
|
|
@ -164,7 +167,11 @@ fun SeriesOverviewContent(
|
||||||
.padding(bottom = 4.dp)
|
.padding(bottom = 4.dp)
|
||||||
.fillMaxWidth(),
|
.fillMaxWidth(),
|
||||||
)
|
)
|
||||||
SeriesName(series.name, Modifier.padding(start = 8.dp))
|
TitleOrLogo(
|
||||||
|
item = series,
|
||||||
|
showLogo = preferences.appPreferences.interfacePreferences.showLogos,
|
||||||
|
modifier = Modifier.padding(start = HeaderUtils.startPadding),
|
||||||
|
)
|
||||||
FocusedEpisodeHeader(
|
FocusedEpisodeHeader(
|
||||||
preferences = preferences,
|
preferences = preferences,
|
||||||
ep = focusedEpisode,
|
ep = focusedEpisode,
|
||||||
|
|
|
||||||
|
|
@ -250,6 +250,8 @@ fun SeerrDiscoverPage(
|
||||||
overviewTwoLines = true,
|
overviewTwoLines = true,
|
||||||
quickDetails = details,
|
quickDetails = details,
|
||||||
timeRemaining = null,
|
timeRemaining = null,
|
||||||
|
showLogo = preferences.appPreferences.interfacePreferences.showLogos,
|
||||||
|
logoImageUrl = null, // TODO
|
||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
.padding(top = 24.dp, bottom = 16.dp, start = 32.dp)
|
.padding(top = 24.dp, bottom = 16.dp, start = 32.dp)
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,6 @@ import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxHeight
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
|
|
@ -39,7 +38,6 @@ import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.AnnotatedString
|
import androidx.compose.ui.text.AnnotatedString
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||||
|
|
@ -61,9 +59,12 @@ import com.github.damontecres.wholphin.ui.components.DialogPopup
|
||||||
import com.github.damontecres.wholphin.ui.components.EpisodeName
|
import com.github.damontecres.wholphin.ui.components.EpisodeName
|
||||||
import com.github.damontecres.wholphin.ui.components.ErrorMessage
|
import com.github.damontecres.wholphin.ui.components.ErrorMessage
|
||||||
import com.github.damontecres.wholphin.ui.components.FocusableItemRow
|
import com.github.damontecres.wholphin.ui.components.FocusableItemRow
|
||||||
|
import com.github.damontecres.wholphin.ui.components.HeaderUtils
|
||||||
import com.github.damontecres.wholphin.ui.components.LoadingPage
|
import com.github.damontecres.wholphin.ui.components.LoadingPage
|
||||||
import com.github.damontecres.wholphin.ui.components.QuickDetails
|
import com.github.damontecres.wholphin.ui.components.QuickDetails
|
||||||
import com.github.damontecres.wholphin.ui.components.RowColumnItem
|
import com.github.damontecres.wholphin.ui.components.RowColumnItem
|
||||||
|
import com.github.damontecres.wholphin.ui.components.TitleOrLogo
|
||||||
|
import com.github.damontecres.wholphin.ui.components.rememberLogoUrl
|
||||||
import com.github.damontecres.wholphin.ui.data.AddPlaylistViewModel
|
import com.github.damontecres.wholphin.ui.data.AddPlaylistViewModel
|
||||||
import com.github.damontecres.wholphin.ui.data.RowColumn
|
import com.github.damontecres.wholphin.ui.data.RowColumn
|
||||||
import com.github.damontecres.wholphin.ui.detail.MoreDialogActions
|
import com.github.damontecres.wholphin.ui.detail.MoreDialogActions
|
||||||
|
|
@ -172,6 +173,7 @@ fun HomePage(
|
||||||
loadingState = refreshing,
|
loadingState = refreshing,
|
||||||
showClock = preferences.appPreferences.interfacePreferences.showClock,
|
showClock = preferences.appPreferences.interfacePreferences.showClock,
|
||||||
onUpdateBackdrop = viewModel::updateBackdrop,
|
onUpdateBackdrop = viewModel::updateBackdrop,
|
||||||
|
showLogo = preferences.appPreferences.interfacePreferences.showLogos,
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
)
|
)
|
||||||
dialog?.let { params ->
|
dialog?.let { params ->
|
||||||
|
|
@ -222,6 +224,7 @@ fun HomePageContent(
|
||||||
onClickPlay: (RowColumn, BaseItem) -> Unit,
|
onClickPlay: (RowColumn, BaseItem) -> Unit,
|
||||||
showClock: Boolean,
|
showClock: Boolean,
|
||||||
onUpdateBackdrop: (BaseItem) -> Unit,
|
onUpdateBackdrop: (BaseItem) -> Unit,
|
||||||
|
showLogo: Boolean,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
loadingState: LoadingState? = null,
|
loadingState: LoadingState? = null,
|
||||||
listState: LazyListState = rememberLazyListState(),
|
listState: LazyListState = rememberLazyListState(),
|
||||||
|
|
@ -230,10 +233,8 @@ fun HomePageContent(
|
||||||
headerComposable: @Composable (focusedItem: BaseItem?) -> Unit = { focusedItem ->
|
headerComposable: @Composable (focusedItem: BaseItem?) -> Unit = { focusedItem ->
|
||||||
HomePageHeader(
|
HomePageHeader(
|
||||||
item = focusedItem,
|
item = focusedItem,
|
||||||
modifier =
|
showLogo = showLogo,
|
||||||
Modifier
|
modifier = HeaderUtils.modifier,
|
||||||
.padding(top = 48.dp, bottom = 32.dp, start = 8.dp)
|
|
||||||
.fillMaxHeight(.33f),
|
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
|
|
@ -410,6 +411,7 @@ fun HomePageContent(
|
||||||
@Composable
|
@Composable
|
||||||
fun HomePageHeader(
|
fun HomePageHeader(
|
||||||
item: BaseItem?,
|
item: BaseItem?,
|
||||||
|
showLogo: Boolean,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val isEpisode = item?.type == BaseItemKind.EPISODE
|
val isEpisode = item?.type == BaseItemKind.EPISODE
|
||||||
|
|
@ -421,6 +423,8 @@ fun HomePageHeader(
|
||||||
overviewTwoLines = isEpisode,
|
overviewTwoLines = isEpisode,
|
||||||
quickDetails = item?.ui?.quickDetails ?: AnnotatedString(""),
|
quickDetails = item?.ui?.quickDetails ?: AnnotatedString(""),
|
||||||
timeRemaining = item?.timeRemainingOrRuntime,
|
timeRemaining = item?.timeRemainingOrRuntime,
|
||||||
|
showLogo = showLogo,
|
||||||
|
logoImageUrl = rememberLogoUrl(item),
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -433,31 +437,28 @@ fun HomePageHeader(
|
||||||
overviewTwoLines: Boolean,
|
overviewTwoLines: Boolean,
|
||||||
quickDetails: AnnotatedString?,
|
quickDetails: AnnotatedString?,
|
||||||
timeRemaining: Duration?,
|
timeRemaining: Duration?,
|
||||||
|
showLogo: Boolean,
|
||||||
|
logoImageUrl: String?,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
) {
|
) {
|
||||||
title?.let {
|
TitleOrLogo(
|
||||||
Text(
|
title = title,
|
||||||
text = it,
|
logoImageUrl = logoImageUrl,
|
||||||
style = MaterialTheme.typography.headlineMedium.copy(fontWeight = FontWeight.SemiBold),
|
showLogo = showLogo,
|
||||||
color = MaterialTheme.colorScheme.onBackground,
|
modifier = Modifier.fillMaxWidth(.75f),
|
||||||
maxLines = 1,
|
)
|
||||||
overflow = TextOverflow.Ellipsis,
|
|
||||||
modifier = Modifier.fillMaxWidth(.75f),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Column(
|
Column(
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxWidth(.6f)
|
.fillMaxWidth(.6f),
|
||||||
.fillMaxHeight(),
|
|
||||||
) {
|
) {
|
||||||
subtitle?.let {
|
if (subtitle != null) {
|
||||||
EpisodeName(it)
|
EpisodeName(subtitle)
|
||||||
}
|
}
|
||||||
QuickDetails(quickDetails ?: AnnotatedString(""), timeRemaining)
|
QuickDetails(quickDetails ?: AnnotatedString(""), timeRemaining)
|
||||||
val overviewModifier =
|
val overviewModifier =
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,7 @@ import com.github.damontecres.wholphin.R
|
||||||
import com.github.damontecres.wholphin.data.model.HomeRowConfig
|
import com.github.damontecres.wholphin.data.model.HomeRowConfig
|
||||||
import com.github.damontecres.wholphin.data.model.HomeRowViewOptions
|
import com.github.damontecres.wholphin.data.model.HomeRowViewOptions
|
||||||
import com.github.damontecres.wholphin.preferences.AppPreferences
|
import com.github.damontecres.wholphin.preferences.AppPreferences
|
||||||
|
import com.github.damontecres.wholphin.preferences.UserPreferences
|
||||||
import com.github.damontecres.wholphin.ui.components.ConfirmDialog
|
import com.github.damontecres.wholphin.ui.components.ConfirmDialog
|
||||||
import com.github.damontecres.wholphin.ui.data.RowColumn
|
import com.github.damontecres.wholphin.ui.data.RowColumn
|
||||||
import com.github.damontecres.wholphin.ui.detail.search.SearchForDialog
|
import com.github.damontecres.wholphin.ui.detail.search.SearchForDialog
|
||||||
|
|
@ -51,6 +52,7 @@ val settingsWidth = 360.dp
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun HomeSettingsPage(
|
fun HomeSettingsPage(
|
||||||
|
preferences: UserPreferences,
|
||||||
modifier: Modifier,
|
modifier: Modifier,
|
||||||
viewModel: HomeSettingsViewModel = hiltViewModel(),
|
viewModel: HomeSettingsViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
|
|
@ -310,6 +312,7 @@ fun HomeSettingsPage(
|
||||||
listState = listState,
|
listState = listState,
|
||||||
takeFocus = false,
|
takeFocus = false,
|
||||||
showEmptyRows = true,
|
showEmptyRows = true,
|
||||||
|
showLogo = preferences.appPreferences.interfacePreferences.showLogos,
|
||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxHeight()
|
.fillMaxHeight()
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,9 @@ import com.github.damontecres.wholphin.ui.CrossFadeFactory
|
||||||
import kotlin.time.Duration
|
import kotlin.time.Duration
|
||||||
import kotlin.time.Duration.Companion.milliseconds
|
import kotlin.time.Duration.Companion.milliseconds
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shows the current backdrop images provided by [com.github.damontecres.wholphin.services.BackdropService]
|
||||||
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun Backdrop(
|
fun Backdrop(
|
||||||
drawerIsOpen: Boolean,
|
drawerIsOpen: Boolean,
|
||||||
|
|
@ -59,6 +62,9 @@ fun Backdrop(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shows the current backdrop images provided by the [BackdropResult]
|
||||||
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun Backdrop(
|
fun Backdrop(
|
||||||
backdrop: BackdropResult,
|
backdrop: BackdropResult,
|
||||||
|
|
|
||||||
|
|
@ -69,7 +69,7 @@ fun DestinationContent(
|
||||||
}
|
}
|
||||||
|
|
||||||
is Destination.HomeSettings -> {
|
is Destination.HomeSettings -> {
|
||||||
HomeSettingsPage(modifier)
|
HomeSettingsPage(preferences, modifier)
|
||||||
}
|
}
|
||||||
|
|
||||||
is Destination.PlaybackList,
|
is Destination.PlaybackList,
|
||||||
|
|
|
||||||
|
|
@ -157,6 +157,9 @@ class NavDrawerViewModel
|
||||||
|
|
||||||
fun getUserImage(user: JellyfinUser): String = api.imageApi.getUserImageUrl(user.id)
|
fun getUserImage(user: JellyfinUser): String = api.imageApi.getUserImageUrl(user.id)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine which nav drawer item should be highlighted as currently selected
|
||||||
|
*/
|
||||||
fun updateSelectedIndex() {
|
fun updateSelectedIndex() {
|
||||||
viewModelScope.launchDefault {
|
viewModelScope.launchDefault {
|
||||||
val asDestinations =
|
val asDestinations =
|
||||||
|
|
@ -215,6 +218,11 @@ class NavDrawerViewModel
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An item that can be shown in the nav drawer
|
||||||
|
*
|
||||||
|
* Some are built-in such as Favorites. Others are created dynamically for libraries.
|
||||||
|
*/
|
||||||
sealed interface NavDrawerItem {
|
sealed interface NavDrawerItem {
|
||||||
val id: String
|
val id: String
|
||||||
|
|
||||||
|
|
@ -242,6 +250,9 @@ sealed interface NavDrawerItem {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A server provided nav drawer item, typically a library
|
||||||
|
*/
|
||||||
data class ServerNavDrawerItem(
|
data class ServerNavDrawerItem(
|
||||||
val itemId: UUID,
|
val itemId: UUID,
|
||||||
val name: String,
|
val name: String,
|
||||||
|
|
|
||||||
|
|
@ -57,6 +57,11 @@ import androidx.tv.material3.NavigationDrawerItemDefaults
|
||||||
import androidx.tv.material3.NavigationDrawerScope
|
import androidx.tv.material3.NavigationDrawerScope
|
||||||
import androidx.tv.material3.rememberDrawerState
|
import androidx.tv.material3.rememberDrawerState
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This is a re-implementation of [androidx.tv.material3.ModalNavigationDrawer].
|
||||||
|
*
|
||||||
|
* It changes padding, sizing, and animations that couldn't be changed in the Androidx implementation.
|
||||||
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun ModalNavigationDrawer(
|
fun ModalNavigationDrawer(
|
||||||
drawerContent: @Composable NavigationDrawerScope.(DrawerValue) -> Unit,
|
drawerContent: @Composable NavigationDrawerScope.(DrawerValue) -> Unit,
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,11 @@ import org.jellyfin.sdk.model.api.BaseItemKind
|
||||||
import org.jellyfin.sdk.model.api.TrickplayInfo
|
import org.jellyfin.sdk.model.api.TrickplayInfo
|
||||||
import org.jellyfin.sdk.model.extensions.ticks
|
import org.jellyfin.sdk.model.extensions.ticks
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Metadata about the currently playing media
|
||||||
|
*
|
||||||
|
* @see CurrentPlayback
|
||||||
|
*/
|
||||||
data class CurrentMediaInfo(
|
data class CurrentMediaInfo(
|
||||||
val sourceId: String?,
|
val sourceId: String?,
|
||||||
val videoStream: SimpleVideoStream?,
|
val videoStream: SimpleVideoStream?,
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,11 @@ import org.jellyfin.sdk.model.api.PlayMethod
|
||||||
import org.jellyfin.sdk.model.api.TranscodingInfo
|
import org.jellyfin.sdk.model.api.TranscodingInfo
|
||||||
import kotlin.time.Duration
|
import kotlin.time.Duration
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Information about how the current media is being played such transcoding and decoder info
|
||||||
|
*
|
||||||
|
* @see CurrentMediaInfo
|
||||||
|
*/
|
||||||
data class CurrentPlayback(
|
data class CurrentPlayback(
|
||||||
val item: BaseItem,
|
val item: BaseItem,
|
||||||
val tracks: List<TrackSupport>,
|
val tracks: List<TrackSupport>,
|
||||||
|
|
|
||||||
|
|
@ -51,7 +51,7 @@ import org.jellyfin.sdk.model.api.RemoteSubtitleInfo
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun DownloadSubtitlesContent(
|
fun DownloadSubtitlesContent(
|
||||||
state: SubtitleSearch,
|
state: SubtitleSearchStatus,
|
||||||
language: String,
|
language: String,
|
||||||
onSearch: (String) -> Unit,
|
onSearch: (String) -> Unit,
|
||||||
onClickDownload: (RemoteSubtitleInfo) -> Unit,
|
onClickDownload: (RemoteSubtitleInfo) -> Unit,
|
||||||
|
|
@ -59,7 +59,7 @@ fun DownloadSubtitlesContent(
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
when (val s = state) {
|
when (val s = state) {
|
||||||
SubtitleSearch.Searching -> {
|
SubtitleSearchStatus.Searching -> {
|
||||||
Wrapper {
|
Wrapper {
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(R.string.searching),
|
text = stringResource(R.string.searching),
|
||||||
|
|
@ -69,7 +69,7 @@ fun DownloadSubtitlesContent(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
SubtitleSearch.Downloading -> {
|
SubtitleSearchStatus.Downloading -> {
|
||||||
Wrapper {
|
Wrapper {
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(R.string.downloading),
|
text = stringResource(R.string.downloading),
|
||||||
|
|
@ -79,11 +79,11 @@ fun DownloadSubtitlesContent(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
is SubtitleSearch.Error -> {
|
is SubtitleSearchStatus.Error -> {
|
||||||
Wrapper { ErrorMessage(null, s.ex, modifier) }
|
Wrapper { ErrorMessage(null, s.ex, modifier) }
|
||||||
}
|
}
|
||||||
|
|
||||||
is SubtitleSearch.Success -> {
|
is SubtitleSearchStatus.Success -> {
|
||||||
val dialogItems = convertRemoteSubtitles(s.options, onClickDownload)
|
val dialogItems = convertRemoteSubtitles(s.options, onClickDownload)
|
||||||
val focusRequester = remember { FocusRequester() }
|
val focusRequester = remember { FocusRequester() }
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
|
|
|
||||||
|
|
@ -84,6 +84,9 @@ import org.jellyfin.sdk.model.extensions.ticks
|
||||||
import kotlin.time.Duration
|
import kotlin.time.Duration
|
||||||
import kotlin.time.Duration.Companion.seconds
|
import kotlin.time.Duration.Companion.seconds
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Possible actions the user can take during playback
|
||||||
|
*/
|
||||||
sealed interface PlaybackAction {
|
sealed interface PlaybackAction {
|
||||||
data object ShowDebug : PlaybackAction
|
data object ShowDebug : PlaybackAction
|
||||||
|
|
||||||
|
|
@ -114,6 +117,9 @@ sealed interface PlaybackAction {
|
||||||
data object Next : PlaybackAction
|
data object Next : PlaybackAction
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* UI for actual playback controls such as seek bar, play/pause and seek buttons
|
||||||
|
*/
|
||||||
@OptIn(UnstableApi::class)
|
@OptIn(UnstableApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun PlaybackControls(
|
fun PlaybackControls(
|
||||||
|
|
@ -515,6 +521,11 @@ fun PlaybackFaButton(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show a dialog aligned to the bottom of the screen instead of centered.
|
||||||
|
*
|
||||||
|
* @param gravity The [Gravity] to align the dialog to left or right
|
||||||
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun <T> BottomDialog(
|
fun <T> BottomDialog(
|
||||||
choices: List<BottomDialogItem<T>>,
|
choices: List<BottomDialogItem<T>>,
|
||||||
|
|
@ -594,10 +605,6 @@ fun <T> BottomDialog(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
data class MoreButtonOptions(
|
|
||||||
val options: Map<String, PlaybackAction>,
|
|
||||||
)
|
|
||||||
|
|
||||||
data class BottomDialogItem<T>(
|
data class BottomDialogItem<T>(
|
||||||
val data: T,
|
val data: T,
|
||||||
val headline: String,
|
val headline: String,
|
||||||
|
|
|
||||||
|
|
@ -62,6 +62,14 @@ data class PlaybackSettings(
|
||||||
val playbackSpeedEnabled: Boolean,
|
val playbackSpeedEnabled: Boolean,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Centralized UI component for displaying dialogs during playback
|
||||||
|
*
|
||||||
|
* Typically, the user will click something generating a [PlaybackAction] which translates into the
|
||||||
|
* [PlaybackDialogType] determining which dialog is shown by this component.
|
||||||
|
*
|
||||||
|
* @see PlaybackAction
|
||||||
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun PlaybackDialog(
|
fun PlaybackDialog(
|
||||||
enableSubtitleDelay: Boolean,
|
enableSubtitleDelay: Boolean,
|
||||||
|
|
|
||||||
|
|
@ -575,6 +575,8 @@ enum class OverlayViewState {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A wrapper for the playback controls to show title and other information, plus the actual controls
|
* A wrapper for the playback controls to show title and other information, plus the actual controls
|
||||||
|
*
|
||||||
|
* @see PlaybackControls
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun Controller(
|
fun Controller(
|
||||||
|
|
|
||||||
|
|
@ -175,7 +175,7 @@ fun PlaybackPageContent(
|
||||||
val nextUp by viewModel.nextUp.observeAsState(null)
|
val nextUp by viewModel.nextUp.observeAsState(null)
|
||||||
val playlist by viewModel.playlist.observeAsState(Playlist(listOf()))
|
val playlist by viewModel.playlist.observeAsState(Playlist(listOf()))
|
||||||
|
|
||||||
val subtitleSearch by viewModel.subtitleSearch.observeAsState(null)
|
val subtitleSearch by viewModel.subtitleSearchStatus.observeAsState(null)
|
||||||
val subtitleSearchLanguage by viewModel.subtitleSearchLanguage.observeAsState(Locale.current.language)
|
val subtitleSearchLanguage by viewModel.subtitleSearchLanguage.observeAsState(Locale.current.language)
|
||||||
|
|
||||||
var playbackDialog by remember { mutableStateOf<PlaybackDialogType?>(null) }
|
var playbackDialog by remember { mutableStateOf<PlaybackDialogType?>(null) }
|
||||||
|
|
|
||||||
|
|
@ -188,7 +188,7 @@ class PlaybackViewModel
|
||||||
private var isPlaylist = false
|
private var isPlaylist = false
|
||||||
|
|
||||||
val playlist = MutableLiveData<Playlist>(Playlist(listOf()))
|
val playlist = MutableLiveData<Playlist>(Playlist(listOf()))
|
||||||
val subtitleSearch = MutableLiveData<SubtitleSearch?>(null)
|
val subtitleSearchStatus = MutableLiveData<SubtitleSearchStatus?>(null)
|
||||||
val subtitleSearchLanguage = MutableLiveData<String>(Locale.current.language)
|
val subtitleSearchLanguage = MutableLiveData<String>(Locale.current.language)
|
||||||
|
|
||||||
val currentUserDto = serverRepository.currentUserDto
|
val currentUserDto = serverRepository.currentUserDto
|
||||||
|
|
|
||||||
|
|
@ -1,39 +0,0 @@
|
||||||
package com.github.damontecres.wholphin.ui.playback
|
|
||||||
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
|
||||||
import androidx.compose.runtime.State
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.media3.common.Player
|
|
||||||
import androidx.media3.common.listen
|
|
||||||
import androidx.media3.common.util.UnstableApi
|
|
||||||
|
|
||||||
@UnstableApi
|
|
||||||
@Composable
|
|
||||||
fun rememberPlayerLoadingState(player: Player): PlayerLoadingState {
|
|
||||||
val state = remember(player) { PlayerLoadingState(player) }
|
|
||||||
LaunchedEffect(player) {
|
|
||||||
state.observe()
|
|
||||||
}
|
|
||||||
return state
|
|
||||||
}
|
|
||||||
|
|
||||||
@UnstableApi
|
|
||||||
class PlayerLoadingState(
|
|
||||||
private val player: Player,
|
|
||||||
) : State<Boolean> {
|
|
||||||
override var value by mutableStateOf(player.isLoading)
|
|
||||||
private set
|
|
||||||
|
|
||||||
suspend fun observe() {
|
|
||||||
value = player.isLoading
|
|
||||||
player.listen {
|
|
||||||
if (it.contains(Player.EVENT_IS_LOADING_CHANGED)) {
|
|
||||||
value = player.isLoading
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -49,6 +49,11 @@ import androidx.tv.material3.MaterialTheme
|
||||||
import kotlinx.coroutines.FlowPreview
|
import kotlinx.coroutines.FlowPreview
|
||||||
import kotlin.time.Duration
|
import kotlin.time.Duration
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This is a seek bar which seeks by a percentage of the duration instead of a fixed amount of time
|
||||||
|
*
|
||||||
|
* For example, if [intervals] is 10, then each seek will be 10% of total media duration
|
||||||
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun SteppedSeekBarImpl(
|
fun SteppedSeekBarImpl(
|
||||||
progress: Float,
|
progress: Float,
|
||||||
|
|
@ -97,6 +102,9 @@ fun SteppedSeekBarImpl(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A seek (or scrubber) bar which seeks forward or back a fixed amount of time per move
|
||||||
|
*/
|
||||||
@OptIn(FlowPreview::class)
|
@OptIn(FlowPreview::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun IntervalSeekBarImpl(
|
fun IntervalSeekBarImpl(
|
||||||
|
|
@ -147,8 +155,14 @@ fun IntervalSeekBarImpl(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Actually renders the seek bar. It has callbacks for when the user moves to the left or right
|
||||||
|
*
|
||||||
|
* @see IntervalSeekBarImpl
|
||||||
|
* @see SteppedSeekBarImpl
|
||||||
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun SeekBarDisplay(
|
private fun SeekBarDisplay(
|
||||||
progress: Float,
|
progress: Float,
|
||||||
bufferedProgress: Float,
|
bufferedProgress: Float,
|
||||||
durationMs: Long,
|
durationMs: Long,
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
package com.github.damontecres.wholphin.ui.playback
|
package com.github.damontecres.wholphin.ui.playback
|
||||||
|
|
||||||
|
import androidx.annotation.FloatRange
|
||||||
import androidx.compose.foundation.Canvas
|
import androidx.compose.foundation.Canvas
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.border
|
import androidx.compose.foundation.border
|
||||||
|
|
@ -52,20 +53,21 @@ fun Modifier.offsetByPercent(
|
||||||
*
|
*
|
||||||
* @param xPercentage percent offset between 0 inclusive and 1 inclusive
|
* @param xPercentage percent offset between 0 inclusive and 1 inclusive
|
||||||
*/
|
*/
|
||||||
fun Modifier.offsetByPercent(xPercentage: Float) =
|
fun Modifier.offsetByPercent(
|
||||||
this.then(
|
@FloatRange(0.0, 1.0) xPercentage: Float,
|
||||||
Modifier.layout { measurable, constraints ->
|
) = this.then(
|
||||||
val placeable = measurable.measure(constraints)
|
Modifier.layout { measurable, constraints ->
|
||||||
layout(placeable.width, placeable.height) {
|
val placeable = measurable.measure(constraints)
|
||||||
placeable.placeRelative(
|
layout(placeable.width, placeable.height) {
|
||||||
x =
|
placeable.placeRelative(
|
||||||
((constraints.maxWidth * xPercentage).toInt() - placeable.width / 2)
|
x =
|
||||||
.coerceIn(0, constraints.maxWidth - placeable.width),
|
((constraints.maxWidth * xPercentage).toInt() - placeable.width / 2)
|
||||||
y = 0,
|
.coerceIn(0, constraints.maxWidth - placeable.width),
|
||||||
)
|
y = 0,
|
||||||
}
|
)
|
||||||
},
|
}
|
||||||
)
|
},
|
||||||
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Show trickplay preview image. This composable assumes the provided URL is for the correct index.
|
* Show trickplay preview image. This composable assumes the provided URL is for the correct index.
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,11 @@ import com.github.damontecres.wholphin.ui.isNotNullOrBlank
|
||||||
import com.github.damontecres.wholphin.ui.util.StreamFormatting.mediaStreamDisplayTitle
|
import com.github.damontecres.wholphin.ui.util.StreamFormatting.mediaStreamDisplayTitle
|
||||||
import org.jellyfin.sdk.model.api.MediaStream
|
import org.jellyfin.sdk.model.api.MediaStream
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A slimmer [MediaStream] with minimal data for UI purposes
|
||||||
|
*
|
||||||
|
* @see SimpleVideoStream
|
||||||
|
*/
|
||||||
data class SimpleMediaStream(
|
data class SimpleMediaStream(
|
||||||
val index: Int,
|
val index: Int,
|
||||||
val streamTitle: String?,
|
val streamTitle: String?,
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue