Add a lot of code documentation (#1145)

## Description
A brain dump documenting many classes and functions throughout the app.
Definitely does not document everything, but covers most of the major
components. This should be useful for new contributors.

There is a small amount of code clean up too.

There are no user facing changes.

### Related issues
N/A

### Testing
N/A

## Screenshots
N/A

## AI or LLM usage
None
This commit is contained in:
Ray 2026-03-29 10:52:25 -04:00 committed by GitHub
parent 2485d2c644
commit 66f060dccb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
92 changed files with 719 additions and 222 deletions

View file

@ -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

View file

@ -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() =

View file

@ -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

View file

@ -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++,

View file

@ -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,

View file

@ -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,

View file

@ -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?,

View file

@ -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,

View file

@ -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(

View file

@ -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(

View file

@ -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(

View file

@ -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(

View file

@ -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(

View file

@ -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,

View file

@ -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,

View file

@ -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(

View file

@ -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,

View file

@ -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,
) {

View file

@ -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(

View file

@ -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,

View file

@ -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,

View file

@ -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,

View file

@ -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,

View file

@ -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

View file

@ -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(

View file

@ -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) {

View file

@ -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

View file

@ -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

View file

@ -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,12 +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.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
@ -31,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
@ -39,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,
@ -60,13 +59,6 @@ class LatestNextUpService
remove(BaseItemKind.EPISODE)
}
},
// enableImageTypes =
// listOf(
// ImageType.PRIMARY,
// ImageType.THUMB,
// ImageType.BACKDROP,
// ImageType.LOGO,
// ),
)
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,
@ -98,13 +93,6 @@ class LatestNextUpService
enableUserData = true,
enableRewatching = enableRewatching,
nextUpDateCutoff = nextUpDateCutoff,
// enableImageTypes =
// listOf(
// ImageType.PRIMARY,
// ImageType.THUMB,
// ImageType.BACKDROP,
// ImageType.LOGO,
// ),
)
val nextUp =
api.tvShowsApi
@ -115,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>,
@ -207,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,
)

View file

@ -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,

View file

@ -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

View file

@ -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,

View file

@ -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,

View file

@ -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

View file

@ -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?,
@ -138,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,
@ -275,6 +288,9 @@ class PlaylistCreator
}
}
/**
* Get the playlists on the server for a given media type
*/
suspend fun getServerPlaylists(
mediaType: MediaType?,
scope: CoroutineScope,

View file

@ -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,

View file

@ -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 =

View file

@ -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

View file

@ -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>,

View file

@ -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

View file

@ -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 ||
(

View file

@ -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

View file

@ -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

View file

@ -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()) {

View file

@ -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

View file

@ -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,

View file

@ -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,

View file

@ -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(

View file

@ -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<*>>,

View file

@ -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,

View file

@ -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>,

View file

@ -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,

View file

@ -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()

View file

@ -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,

View file

@ -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,

View file

@ -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,

View file

@ -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,

View file

@ -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

View file

@ -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,

View file

@ -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

View file

@ -42,6 +42,9 @@ data class ItemDetailsDialogInfo(
val files: List<MediaSourceInfo>,
)
/**
* Dialog showing metadata about an item
*/
@Composable
fun ItemDetailsDialog(
info: ItemDetailsDialogInfo,

View file

@ -85,6 +85,9 @@ interface CardGridItem {
val sortName: String
}
/**
* Shows a vertical grid of [CardGridItem]s
*/
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun <T : CardGridItem> CardGrid(

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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,

View file

@ -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,

View file

@ -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,

View file

@ -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?,

View file

@ -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>,

View file

@ -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) {

View file

@ -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,

View file

@ -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,

View file

@ -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(

View file

@ -168,7 +168,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) }

View file

@ -187,7 +187,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

View file

@ -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
}
}
}
}

View file

@ -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,

View file

@ -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) {
@ -65,7 +67,7 @@ fun Modifier.offsetByPercent(xPercentage: Float) =
)
}
},
)
)
/**
* Show trickplay preview image. This composable assumes the provided URL is for the correct index.

View file

@ -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?,

View file

@ -25,6 +25,9 @@ import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.ui.ifElse
import kotlin.math.abs
/**
* Shows a rotating indicator when seeking. Displays the amount of seconds in the [durationMs].
*/
@Composable
fun SkipIndicator(
durationMs: Long,

View file

@ -30,6 +30,9 @@ import kotlin.time.Duration.Companion.seconds
private val delayIncrements = listOf(50.milliseconds, 250.milliseconds, 1.seconds)
/**
* Shows buttons for adding to or subtracting from the given [Duration], ie subtitle delay
*/
@Composable
fun SubtitleDelay(
delay: Duration,

View file

@ -20,23 +20,26 @@ import org.jellyfin.sdk.model.api.MediaStreamType
import org.jellyfin.sdk.model.api.RemoteSubtitleInfo
import timber.log.Timber
sealed interface SubtitleSearch {
data object Searching : SubtitleSearch
sealed interface SubtitleSearchStatus {
data object Searching : SubtitleSearchStatus
data object Downloading : SubtitleSearch
data object Downloading : SubtitleSearchStatus
data class Success(
val options: List<RemoteSubtitleInfo>,
) : SubtitleSearch
) : SubtitleSearchStatus
data class Error(
val message: String?,
val ex: Exception?,
) : SubtitleSearch
) : SubtitleSearchStatus
}
/**
* Trigger a search for subtitles in the given language for the currently playing media
*/
fun PlaybackViewModel.searchForSubtitles(language: String = Locale.current.language) {
subtitleSearch.value = SubtitleSearch.Searching
subtitleSearchStatus.value = SubtitleSearchStatus.Searching
subtitleSearchLanguage.value = language
viewModelScope.launchIO {
try {
@ -52,23 +55,26 @@ fun PlaybackViewModel.searchForSubtitles(language: String = Locale.current.langu
compareByDescending<RemoteSubtitleInfo> { it.communityRating }
.thenByDescending { it.downloadCount },
)
subtitleSearch.setValueOnMain(SubtitleSearch.Success(results))
subtitleSearchStatus.setValueOnMain(SubtitleSearchStatus.Success(results))
}
} catch (ex: Exception) {
Timber.e(ex, "Exception while searching for subtitles")
subtitleSearch.setValueOnMain(SubtitleSearch.Error(null, ex))
subtitleSearchStatus.setValueOnMain(SubtitleSearchStatus.Error(null, ex))
}
}
}
/**
* Download the remote subtitles and attempt to activate them once complete
*/
fun PlaybackViewModel.downloadAndSwitchSubtitles(
subtitleId: String?,
wasPlaying: Boolean,
) {
if (subtitleId == null) {
subtitleSearch.value = SubtitleSearch.Error("Subtitle has no ID", null)
subtitleSearchStatus.value = SubtitleSearchStatus.Error("Subtitle has no ID", null)
} else {
subtitleSearch.value = SubtitleSearch.Downloading
subtitleSearchStatus.value = SubtitleSearchStatus.Downloading
viewModelScope.launchIO {
try {
currentItemPlayback.value?.let {
@ -161,7 +167,7 @@ fun PlaybackViewModel.downloadAndSwitchSubtitles(
)
}
}
subtitleSearch.setValueOnMain(null)
subtitleSearchStatus.setValueOnMain(null)
withContext(Dispatchers.Main) {
if (wasPlaying) {
player.play()
@ -170,12 +176,12 @@ fun PlaybackViewModel.downloadAndSwitchSubtitles(
}
} catch (ex: Exception) {
Timber.e(ex, "Exception while downloading subtitles: $subtitleId")
subtitleSearch.setValueOnMain(SubtitleSearch.Error(null, ex))
subtitleSearchStatus.setValueOnMain(SubtitleSearchStatus.Error(null, ex))
}
}
}
}
fun PlaybackViewModel.cancelSubtitleSearch() {
subtitleSearch.value = null
subtitleSearchStatus.value = null
}

View file

@ -15,6 +15,9 @@ import org.jellyfin.sdk.model.api.SubtitleDeliveryMethod
import timber.log.Timber
import kotlin.math.max
/**
* Functions for selecting which audio & subtitle tracks to activate in the [androidx.media3.common.Player]
*/
object TrackSelectionUtils {
@OptIn(UnstableApi::class)
fun createTrackSelections(
@ -258,6 +261,9 @@ fun List<MediaStream>.findExternalSubtitle(subtitleIndex: Int?): MediaStream? =
}
}
/**
* The result of [TrackSelectionUtils.createTrackSelections]
*/
data class TrackSelectionResult(
val trackSelectionParameters: TrackSelectionParameters,
val audioSelected: Boolean,

View file

@ -3,12 +3,24 @@ package com.github.damontecres.wholphin.util
import java.util.function.IntFunction
import java.util.function.Predicate
/**
* A [List] which has function that will wait for a result
*/
interface BlockingList<T> : List<T> {
/**
* Get the specified index, possibly blocking until it is available
*/
suspend fun getBlocking(index: Int): T
/**
* Get first index that matches the given predicate, possibly blocking while searching
*/
suspend fun indexOfBlocking(predicate: Predicate<T>): Int
companion object {
/**
* Create a [BlockingList] over a regular [List]
*/
fun <T> of(list: List<T>): BlockingList<T> = BlockingListWrapper(list)
}
}

View file

@ -20,6 +20,9 @@ import org.json.JSONObject
import timber.log.Timber
import java.util.Date
/**
* Sends a crash report to the current server
*/
@AutoService(ReportSenderFactory::class)
class CrashReportSenderFactory : ReportSenderFactory {
override fun create(

View file

@ -4,6 +4,9 @@ import android.util.Log
import com.github.damontecres.wholphin.BuildConfig
import timber.log.Timber
/**
* Enable debug logging via [Timber] if enabled in the app settings
*/
class DebugLogTree private constructor() : Timber.Tree() {
// Only add logging for below INFO, production logger in WholphinApplication logs >=INFO
override fun isLoggable(

View file

@ -1,9 +0,0 @@
package com.github.damontecres.wholphin.util
import androidx.compose.ui.focus.FocusRequester
data class FocusPair(
val row: Int,
val column: Int,
val focusRequester: FocusRequester,
)

View file

@ -3,6 +3,9 @@ package com.github.damontecres.wholphin.util
import com.github.damontecres.wholphin.preferences.UserPreferences
import java.util.UUID
/**
* Functions to remember tabs choices for a library
*/
interface RememberTabManager {
/**
* If enabled, get the remembered tab index for the given item

View file

@ -1,5 +1,8 @@
package com.github.damontecres.wholphin.util
/**
* A utility class to lazily transforms items from the source list
*/
class TransformList<S, T>(
private val source: List<S>,
private val transform: (S) -> T,

View file

@ -2,6 +2,9 @@ package com.github.damontecres.wholphin.util
import kotlinx.serialization.Serializable
/**
* Represents a version in the format of `<major>.<minor>.<patch>-<numCommits>-g<gitSha>` as output by `git describe`
*/
@Serializable
data class Version(
val major: Int,

View file

@ -1,14 +1,10 @@
commit f82fb1a2d8ff9917a7bdb0bc7101a0474359ccdc
Author: Damontecres <damontecres@gmail.com>
Date: Sat Nov 22 13:00:55 2025 -0500
# This patches Wholphin for releasing on app stores where self updating is not permitted
Setup for play store
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 6d84299..12576af 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -4,7 +4,6 @@
diff --git i/app/src/main/AndroidManifest.xml w/app/src/main/AndroidManifest.xml
index 4479ae86..80596698 100644
--- i/app/src/main/AndroidManifest.xml
+++ w/app/src/main/AndroidManifest.xml
@@ -6,7 +6,6 @@
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
@ -16,7 +12,7 @@ index 6d84299..12576af 100644
<uses-permission
android:name="android.permission.WRITE_EXTERNAL_STORAGE"
android:maxSdkVersion="28" />
@@ -17,7 +16,7 @@
@@ -20,7 +19,7 @@
android:required="false" />
<uses-feature
android:name="android.software.leanback"
@ -25,11 +21,11 @@ index 6d84299..12576af 100644
<uses-feature
android:name="android.hardware.microphone"
android:required="false" />
diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/UpdateChecker.kt b/app/src/main/java/com/github/damontecres/wholphin/services/UpdateChecker.kt
index c7ac435..fa42fe1 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/services/UpdateChecker.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/services/UpdateChecker.kt
@@ -62,7 +62,7 @@ class UpdateChecker
diff --git i/app/src/main/java/com/github/damontecres/wholphin/services/UpdateChecker.kt w/app/src/main/java/com/github/damontecres/wholphin/services/UpdateChecker.kt
index 9e0665dd..bc1e1c16 100644
--- i/app/src/main/java/com/github/damontecres/wholphin/services/UpdateChecker.kt
+++ w/app/src/main/java/com/github/damontecres/wholphin/services/UpdateChecker.kt
@@ -65,7 +65,7 @@ class UpdateChecker
private val NOTE_REGEX = Regex("<!-- app-note:(.+) -->")
@ -37,4 +33,4 @@ index c7ac435..fa42fe1 100644
+ val ACTIVE = false
}
suspend fun maybeShowUpdateToast(
/**