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