mirror of
https://github.com/JustinZeus/Wholphin.git
synced 2026-07-08 15:50:16 +02:00
Add a lot of code documentation (#1145)
## Description A brain dump documenting many classes and functions throughout the app. Definitely does not document everything, but covers most of the major components. This should be useful for new contributors. There is a small amount of code clean up too. There are no user facing changes. ### Related issues N/A ### Testing N/A ## Screenshots N/A ## AI or LLM usage None
This commit is contained in:
parent
2485d2c644
commit
66f060dccb
92 changed files with 719 additions and 222 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,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,12 +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.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
|
||||||
|
|
@ -31,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
|
||||||
|
|
@ -39,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,
|
||||||
|
|
@ -60,13 +59,6 @@ class LatestNextUpService
|
||||||
remove(BaseItemKind.EPISODE)
|
remove(BaseItemKind.EPISODE)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
// enableImageTypes =
|
|
||||||
// listOf(
|
|
||||||
// ImageType.PRIMARY,
|
|
||||||
// ImageType.THUMB,
|
|
||||||
// ImageType.BACKDROP,
|
|
||||||
// ImageType.LOGO,
|
|
||||||
// ),
|
|
||||||
)
|
)
|
||||||
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,
|
||||||
|
|
@ -98,13 +93,6 @@ class LatestNextUpService
|
||||||
enableUserData = true,
|
enableUserData = true,
|
||||||
enableRewatching = enableRewatching,
|
enableRewatching = enableRewatching,
|
||||||
nextUpDateCutoff = nextUpDateCutoff,
|
nextUpDateCutoff = nextUpDateCutoff,
|
||||||
// enableImageTypes =
|
|
||||||
// listOf(
|
|
||||||
// ImageType.PRIMARY,
|
|
||||||
// ImageType.THUMB,
|
|
||||||
// ImageType.BACKDROP,
|
|
||||||
// ImageType.LOGO,
|
|
||||||
// ),
|
|
||||||
)
|
)
|
||||||
val nextUp =
|
val nextUp =
|
||||||
api.tvShowsApi
|
api.tvShowsApi
|
||||||
|
|
@ -115,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>,
|
||||||
|
|
@ -207,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?,
|
||||||
|
|
@ -138,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,
|
||||||
|
|
@ -275,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
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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>,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -168,7 +168,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) }
|
||||||
|
|
|
||||||
|
|
@ -187,7 +187,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?,
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,9 @@ import com.github.damontecres.wholphin.R
|
||||||
import com.github.damontecres.wholphin.ui.ifElse
|
import com.github.damontecres.wholphin.ui.ifElse
|
||||||
import kotlin.math.abs
|
import kotlin.math.abs
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shows a rotating indicator when seeking. Displays the amount of seconds in the [durationMs].
|
||||||
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun SkipIndicator(
|
fun SkipIndicator(
|
||||||
durationMs: Long,
|
durationMs: Long,
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,9 @@ import kotlin.time.Duration.Companion.seconds
|
||||||
|
|
||||||
private val delayIncrements = listOf(50.milliseconds, 250.milliseconds, 1.seconds)
|
private val delayIncrements = listOf(50.milliseconds, 250.milliseconds, 1.seconds)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shows buttons for adding to or subtracting from the given [Duration], ie subtitle delay
|
||||||
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun SubtitleDelay(
|
fun SubtitleDelay(
|
||||||
delay: Duration,
|
delay: Duration,
|
||||||
|
|
|
||||||
|
|
@ -20,23 +20,26 @@ import org.jellyfin.sdk.model.api.MediaStreamType
|
||||||
import org.jellyfin.sdk.model.api.RemoteSubtitleInfo
|
import org.jellyfin.sdk.model.api.RemoteSubtitleInfo
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
|
|
||||||
sealed interface SubtitleSearch {
|
sealed interface SubtitleSearchStatus {
|
||||||
data object Searching : SubtitleSearch
|
data object Searching : SubtitleSearchStatus
|
||||||
|
|
||||||
data object Downloading : SubtitleSearch
|
data object Downloading : SubtitleSearchStatus
|
||||||
|
|
||||||
data class Success(
|
data class Success(
|
||||||
val options: List<RemoteSubtitleInfo>,
|
val options: List<RemoteSubtitleInfo>,
|
||||||
) : SubtitleSearch
|
) : SubtitleSearchStatus
|
||||||
|
|
||||||
data class Error(
|
data class Error(
|
||||||
val message: String?,
|
val message: String?,
|
||||||
val ex: Exception?,
|
val ex: Exception?,
|
||||||
) : SubtitleSearch
|
) : SubtitleSearchStatus
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trigger a search for subtitles in the given language for the currently playing media
|
||||||
|
*/
|
||||||
fun PlaybackViewModel.searchForSubtitles(language: String = Locale.current.language) {
|
fun PlaybackViewModel.searchForSubtitles(language: String = Locale.current.language) {
|
||||||
subtitleSearch.value = SubtitleSearch.Searching
|
subtitleSearchStatus.value = SubtitleSearchStatus.Searching
|
||||||
subtitleSearchLanguage.value = language
|
subtitleSearchLanguage.value = language
|
||||||
viewModelScope.launchIO {
|
viewModelScope.launchIO {
|
||||||
try {
|
try {
|
||||||
|
|
@ -52,23 +55,26 @@ fun PlaybackViewModel.searchForSubtitles(language: String = Locale.current.langu
|
||||||
compareByDescending<RemoteSubtitleInfo> { it.communityRating }
|
compareByDescending<RemoteSubtitleInfo> { it.communityRating }
|
||||||
.thenByDescending { it.downloadCount },
|
.thenByDescending { it.downloadCount },
|
||||||
)
|
)
|
||||||
subtitleSearch.setValueOnMain(SubtitleSearch.Success(results))
|
subtitleSearchStatus.setValueOnMain(SubtitleSearchStatus.Success(results))
|
||||||
}
|
}
|
||||||
} catch (ex: Exception) {
|
} catch (ex: Exception) {
|
||||||
Timber.e(ex, "Exception while searching for subtitles")
|
Timber.e(ex, "Exception while searching for subtitles")
|
||||||
subtitleSearch.setValueOnMain(SubtitleSearch.Error(null, ex))
|
subtitleSearchStatus.setValueOnMain(SubtitleSearchStatus.Error(null, ex))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Download the remote subtitles and attempt to activate them once complete
|
||||||
|
*/
|
||||||
fun PlaybackViewModel.downloadAndSwitchSubtitles(
|
fun PlaybackViewModel.downloadAndSwitchSubtitles(
|
||||||
subtitleId: String?,
|
subtitleId: String?,
|
||||||
wasPlaying: Boolean,
|
wasPlaying: Boolean,
|
||||||
) {
|
) {
|
||||||
if (subtitleId == null) {
|
if (subtitleId == null) {
|
||||||
subtitleSearch.value = SubtitleSearch.Error("Subtitle has no ID", null)
|
subtitleSearchStatus.value = SubtitleSearchStatus.Error("Subtitle has no ID", null)
|
||||||
} else {
|
} else {
|
||||||
subtitleSearch.value = SubtitleSearch.Downloading
|
subtitleSearchStatus.value = SubtitleSearchStatus.Downloading
|
||||||
viewModelScope.launchIO {
|
viewModelScope.launchIO {
|
||||||
try {
|
try {
|
||||||
currentItemPlayback.value?.let {
|
currentItemPlayback.value?.let {
|
||||||
|
|
@ -161,7 +167,7 @@ fun PlaybackViewModel.downloadAndSwitchSubtitles(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
subtitleSearch.setValueOnMain(null)
|
subtitleSearchStatus.setValueOnMain(null)
|
||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Main) {
|
||||||
if (wasPlaying) {
|
if (wasPlaying) {
|
||||||
player.play()
|
player.play()
|
||||||
|
|
@ -170,12 +176,12 @@ fun PlaybackViewModel.downloadAndSwitchSubtitles(
|
||||||
}
|
}
|
||||||
} catch (ex: Exception) {
|
} catch (ex: Exception) {
|
||||||
Timber.e(ex, "Exception while downloading subtitles: $subtitleId")
|
Timber.e(ex, "Exception while downloading subtitles: $subtitleId")
|
||||||
subtitleSearch.setValueOnMain(SubtitleSearch.Error(null, ex))
|
subtitleSearchStatus.setValueOnMain(SubtitleSearchStatus.Error(null, ex))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun PlaybackViewModel.cancelSubtitleSearch() {
|
fun PlaybackViewModel.cancelSubtitleSearch() {
|
||||||
subtitleSearch.value = null
|
subtitleSearchStatus.value = null
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,9 @@ import org.jellyfin.sdk.model.api.SubtitleDeliveryMethod
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
import kotlin.math.max
|
import kotlin.math.max
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Functions for selecting which audio & subtitle tracks to activate in the [androidx.media3.common.Player]
|
||||||
|
*/
|
||||||
object TrackSelectionUtils {
|
object TrackSelectionUtils {
|
||||||
@OptIn(UnstableApi::class)
|
@OptIn(UnstableApi::class)
|
||||||
fun createTrackSelections(
|
fun createTrackSelections(
|
||||||
|
|
@ -258,6 +261,9 @@ fun List<MediaStream>.findExternalSubtitle(subtitleIndex: Int?): MediaStream? =
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The result of [TrackSelectionUtils.createTrackSelections]
|
||||||
|
*/
|
||||||
data class TrackSelectionResult(
|
data class TrackSelectionResult(
|
||||||
val trackSelectionParameters: TrackSelectionParameters,
|
val trackSelectionParameters: TrackSelectionParameters,
|
||||||
val audioSelected: Boolean,
|
val audioSelected: Boolean,
|
||||||
|
|
|
||||||
|
|
@ -3,12 +3,24 @@ package com.github.damontecres.wholphin.util
|
||||||
import java.util.function.IntFunction
|
import java.util.function.IntFunction
|
||||||
import java.util.function.Predicate
|
import java.util.function.Predicate
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A [List] which has function that will wait for a result
|
||||||
|
*/
|
||||||
interface BlockingList<T> : List<T> {
|
interface BlockingList<T> : List<T> {
|
||||||
|
/**
|
||||||
|
* Get the specified index, possibly blocking until it is available
|
||||||
|
*/
|
||||||
suspend fun getBlocking(index: Int): T
|
suspend fun getBlocking(index: Int): T
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get first index that matches the given predicate, possibly blocking while searching
|
||||||
|
*/
|
||||||
suspend fun indexOfBlocking(predicate: Predicate<T>): Int
|
suspend fun indexOfBlocking(predicate: Predicate<T>): Int
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
/**
|
||||||
|
* Create a [BlockingList] over a regular [List]
|
||||||
|
*/
|
||||||
fun <T> of(list: List<T>): BlockingList<T> = BlockingListWrapper(list)
|
fun <T> of(list: List<T>): BlockingList<T> = BlockingListWrapper(list)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,9 @@ import org.json.JSONObject
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
import java.util.Date
|
import java.util.Date
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sends a crash report to the current server
|
||||||
|
*/
|
||||||
@AutoService(ReportSenderFactory::class)
|
@AutoService(ReportSenderFactory::class)
|
||||||
class CrashReportSenderFactory : ReportSenderFactory {
|
class CrashReportSenderFactory : ReportSenderFactory {
|
||||||
override fun create(
|
override fun create(
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,9 @@ import android.util.Log
|
||||||
import com.github.damontecres.wholphin.BuildConfig
|
import com.github.damontecres.wholphin.BuildConfig
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enable debug logging via [Timber] if enabled in the app settings
|
||||||
|
*/
|
||||||
class DebugLogTree private constructor() : Timber.Tree() {
|
class DebugLogTree private constructor() : Timber.Tree() {
|
||||||
// Only add logging for below INFO, production logger in WholphinApplication logs >=INFO
|
// Only add logging for below INFO, production logger in WholphinApplication logs >=INFO
|
||||||
override fun isLoggable(
|
override fun isLoggable(
|
||||||
|
|
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
package com.github.damontecres.wholphin.util
|
|
||||||
|
|
||||||
import androidx.compose.ui.focus.FocusRequester
|
|
||||||
|
|
||||||
data class FocusPair(
|
|
||||||
val row: Int,
|
|
||||||
val column: Int,
|
|
||||||
val focusRequester: FocusRequester,
|
|
||||||
)
|
|
||||||
|
|
@ -3,6 +3,9 @@ package com.github.damontecres.wholphin.util
|
||||||
import com.github.damontecres.wholphin.preferences.UserPreferences
|
import com.github.damontecres.wholphin.preferences.UserPreferences
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Functions to remember tabs choices for a library
|
||||||
|
*/
|
||||||
interface RememberTabManager {
|
interface RememberTabManager {
|
||||||
/**
|
/**
|
||||||
* If enabled, get the remembered tab index for the given item
|
* If enabled, get the remembered tab index for the given item
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,8 @@
|
||||||
package com.github.damontecres.wholphin.util
|
package com.github.damontecres.wholphin.util
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A utility class to lazily transforms items from the source list
|
||||||
|
*/
|
||||||
class TransformList<S, T>(
|
class TransformList<S, T>(
|
||||||
private val source: List<S>,
|
private val source: List<S>,
|
||||||
private val transform: (S) -> T,
|
private val transform: (S) -> T,
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,9 @@ package com.github.damontecres.wholphin.util
|
||||||
|
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents a version in the format of `<major>.<minor>.<patch>-<numCommits>-g<gitSha>` as output by `git describe`
|
||||||
|
*/
|
||||||
@Serializable
|
@Serializable
|
||||||
data class Version(
|
data class Version(
|
||||||
val major: Int,
|
val major: Int,
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,10 @@
|
||||||
commit f82fb1a2d8ff9917a7bdb0bc7101a0474359ccdc
|
# This patches Wholphin for releasing on app stores where self updating is not permitted
|
||||||
Author: Damontecres <damontecres@gmail.com>
|
|
||||||
Date: Sat Nov 22 13:00:55 2025 -0500
|
|
||||||
|
|
||||||
Setup for play store
|
diff --git i/app/src/main/AndroidManifest.xml w/app/src/main/AndroidManifest.xml
|
||||||
|
index 4479ae86..80596698 100644
|
||||||
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
|
--- i/app/src/main/AndroidManifest.xml
|
||||||
index 6d84299..12576af 100644
|
+++ w/app/src/main/AndroidManifest.xml
|
||||||
--- a/app/src/main/AndroidManifest.xml
|
@@ -6,7 +6,6 @@
|
||||||
+++ b/app/src/main/AndroidManifest.xml
|
|
||||||
@@ -4,7 +4,6 @@
|
|
||||||
<uses-permission android:name="android.permission.INTERNET" />
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
|
|
@ -16,7 +12,7 @@ index 6d84299..12576af 100644
|
||||||
<uses-permission
|
<uses-permission
|
||||||
android:name="android.permission.WRITE_EXTERNAL_STORAGE"
|
android:name="android.permission.WRITE_EXTERNAL_STORAGE"
|
||||||
android:maxSdkVersion="28" />
|
android:maxSdkVersion="28" />
|
||||||
@@ -17,7 +16,7 @@
|
@@ -20,7 +19,7 @@
|
||||||
android:required="false" />
|
android:required="false" />
|
||||||
<uses-feature
|
<uses-feature
|
||||||
android:name="android.software.leanback"
|
android:name="android.software.leanback"
|
||||||
|
|
@ -25,11 +21,11 @@ index 6d84299..12576af 100644
|
||||||
<uses-feature
|
<uses-feature
|
||||||
android:name="android.hardware.microphone"
|
android:name="android.hardware.microphone"
|
||||||
android:required="false" />
|
android:required="false" />
|
||||||
diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/UpdateChecker.kt b/app/src/main/java/com/github/damontecres/wholphin/services/UpdateChecker.kt
|
diff --git i/app/src/main/java/com/github/damontecres/wholphin/services/UpdateChecker.kt w/app/src/main/java/com/github/damontecres/wholphin/services/UpdateChecker.kt
|
||||||
index c7ac435..fa42fe1 100644
|
index 9e0665dd..bc1e1c16 100644
|
||||||
--- a/app/src/main/java/com/github/damontecres/wholphin/services/UpdateChecker.kt
|
--- i/app/src/main/java/com/github/damontecres/wholphin/services/UpdateChecker.kt
|
||||||
+++ b/app/src/main/java/com/github/damontecres/wholphin/services/UpdateChecker.kt
|
+++ w/app/src/main/java/com/github/damontecres/wholphin/services/UpdateChecker.kt
|
||||||
@@ -62,7 +62,7 @@ class UpdateChecker
|
@@ -65,7 +65,7 @@ class UpdateChecker
|
||||||
|
|
||||||
private val NOTE_REGEX = Regex("<!-- app-note:(.+) -->")
|
private val NOTE_REGEX = Regex("<!-- app-note:(.+) -->")
|
||||||
|
|
||||||
|
|
@ -37,4 +33,4 @@ index c7ac435..fa42fe1 100644
|
||||||
+ val ACTIVE = false
|
+ val ACTIVE = false
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun maybeShowUpdateToast(
|
/**
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue