diff --git a/README.md b/README.md index 4b4dbb55..9c549df4 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,12 @@ # Dolphin - an OSS Android TV client for Jellyfin -This is an Android TV client for [Jellyfin](https://jellyfin.org/). It aims to provide a more Plex inspired UI/UX experience for users migrating from Plex to Jellyfin. +This is an Android TV client for [Jellyfin](https://jellyfin.org/). It aims to provide a Plex inspired UI experience for users migrating from Plex to Jellyfin. This is not a fork of the [official client](https://github.com/jellyfin/jellyfin-androidtv). The user interface and controls have been written completely from scratch. ## Motivation -After using Plex and its Android TV app for 10+ years, I found the official Jellyfin client UI/UX to be a barrier for me using Jellyfin more, so if you wish the interface and playback controls were more like Plex's Android TV app, then Dolphin might work for you! +After using Plex and its Android TV app for years, I found the official Jellyfin Android TV client UI/UX to be a barrier to using Jellyfin more, so if you wish the interface and playback controls were a bit more like Plex's Android TV app, then Dolphin might work for you! That said, Dolphin does not yet implement every feature in Jellyfin. It is a work in progress that will continue to improve over time. @@ -15,14 +15,18 @@ That said, Dolphin does not yet implement every feature in Jellyfin. It is a wor - A navigation drawer for quick access to libraries, search, and settings from almost anywhere in the app - Show Movie/TV Show titles when browsing libraries - Play TV Show theme music, if available -- Plex inspired playback controls, plus other enhancements, such as: +- Plex inspired playback controls, such as: - Using D-Pad left/right for seeking during playback - - Subtly show playback position while seeking w/ D-Pad - Quickly access video chapters during playback - - Setting to optionally skip back when resuming playback + - Optionally skip back a few seconds when resuming playback +- Other (subjective) enhancements: + - Subtly show playback position along the bottom of the screen while seeking w/ D-Pad + - Force Continue Watching & Next Up TV episodes to use their Series posters ## Installation +Downloader Code: `Dolphin CODE` + 1. Enable side-loading "unknown" apps - https://androidtvnews.com/unknown-sources-chromecast-google-tv/ - https://www.xda-developers.com/how-to-sideload-apps-android-tv/ @@ -37,13 +41,13 @@ That said, Dolphin does not yet implement every feature in Jellyfin. It is a wor ### Upgrading the app -After the initial install above, the app will automatically check for updates which can then be installed in settings. +After the initial install above, the app will automatically check for updates. The updates can be installed in settings. The first time you attempt an update, the OS should guide you through enabling the required additional permissions for the app to install updates. ## Compatibility -Dolphin requires Android 7.1+ (or Fire TV OS 6+) and Jellyfin server `10.10.x` (tested on primarily `10.10.7`). +Requires Android 7.1+ (or Fire TV OS 6+) and Jellyfin server `10.10.x` (tested on primarily `10.10.7`). The app is tested on a variety of Android TV/Fire TV OS devices, but if you encounter issues, please file an issue! @@ -55,6 +59,13 @@ Please check before submitting that your issue or pull request is not a duplicat If you plan to submit a pull request, please read the [contributing guide](CONTRIBUTING.md) before submitting! +## Acknowledgements + +- Thanks to the Jellyfin team for creating and maintaining such a great open-source media server +- Thanks to the official Jellyfin Android TV client developers, some code for creating the device direct play profile is adapted from there +- Thanks to the Jellyfin Kotlin SDK developers for making it easier to interact with the Jellyfin server API +- Thanks to numerous other libraries that make app development even possible + ## Additional screenshots TODO diff --git a/app/src/main/java/com/github/damontecres/dolphin/MainActivity.kt b/app/src/main/java/com/github/damontecres/dolphin/MainActivity.kt index 7792a206..3121618a 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/MainActivity.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/MainActivity.kt @@ -100,7 +100,7 @@ class MainActivity : AppCompatActivity() { remember(appPreferences) { createDeviceProfile(this@MainActivity, preferences, false) } - val backStack = rememberNavBackStack(Destination.Main()) + val backStack = rememberNavBackStack(Destination.Home()) navigationManager.backStack = backStack if (server != null && user != null) { diff --git a/app/src/main/java/com/github/damontecres/dolphin/data/ServerRepository.kt b/app/src/main/java/com/github/damontecres/dolphin/data/ServerRepository.kt index efc765b6..ebafab9a 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/data/ServerRepository.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/data/ServerRepository.kt @@ -17,6 +17,9 @@ import timber.log.Timber import javax.inject.Inject import javax.inject.Singleton +/** + * Handles managing the current server & user as well as adding & removing new ones + */ @Singleton class ServerRepository @Inject @@ -33,6 +36,11 @@ class ServerRepository private var _currentUserDto by mutableStateOf(null) val currentUserDto get() = _currentUserDto + /** + * Adds a server to the app database and updated the [ApiClient] to the server's URL + * + * The current user is removed + */ suspend fun addAndChangeServer(server: JellyfinServer) { withContext(Dispatchers.IO) { serverDao.addServer(server) @@ -40,11 +48,20 @@ class ServerRepository changeServer(server) } + /** + * Updates the [ApiClient] to the server's URL + * + * The current user is removed + */ fun changeServer(server: JellyfinServer) { apiClient.update(baseUrl = server.url, accessToken = null) _currentServer = server + _currentUser = null } + /** + * Saves the server & User to the app database and updates the [ApiClient] to use this server & user + */ suspend fun changeUser( server: JellyfinServer, user: JellyfinUser, @@ -95,6 +112,9 @@ class ServerRepository } } + /** + * Restores a session for the given server & user such as when the app reopens + */ suspend fun restoreSession( serverId: String, userId: String, @@ -114,6 +134,9 @@ class ServerRepository return false } + /** + * Given a successful [AuthenticationResult], switch to the user that just authenticated + */ suspend fun changeUser( serverUrl: String, authenticationResult: AuthenticationResult, diff --git a/app/src/main/java/com/github/damontecres/dolphin/preferences/AppPreference.kt b/app/src/main/java/com/github/damontecres/dolphin/preferences/AppPreference.kt index 575bd939..50667494 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/preferences/AppPreference.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/preferences/AppPreference.kt @@ -13,7 +13,7 @@ import kotlin.time.Duration.Companion.minutes import kotlin.time.Duration.Companion.seconds /** - * A preference that can be stored in the shared preferences. + * A preference that can be stored in [AppPreferences]. * * @param T The type of the preference value. */ diff --git a/app/src/main/java/com/github/damontecres/dolphin/preferences/UserPreferences.kt b/app/src/main/java/com/github/damontecres/dolphin/preferences/UserPreferences.kt index 3a7e0499..c46b04e5 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/preferences/UserPreferences.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/preferences/UserPreferences.kt @@ -3,6 +3,9 @@ package com.github.damontecres.dolphin.preferences import org.jellyfin.sdk.model.api.SubtitlePlaybackMode import org.jellyfin.sdk.model.api.UserConfiguration +/** + * A combination of the app-specific preferences and server-side user configuration + */ data class UserPreferences( val appPreferences: AppPreferences, val userConfig: UserConfiguration, diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/CoilConfig.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/CoilConfig.kt index 3361d366..9971a693 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/ui/CoilConfig.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/CoilConfig.kt @@ -14,6 +14,9 @@ import coil3.util.DebugLogger import okhttp3.OkHttpClient import kotlin.time.ExperimentalTime +/** + * Configure Coil image loading + */ @OptIn(ExperimentalTime::class, ExperimentalCoilApi::class) @Composable fun CoilConfig( diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/CoilTrickplayTransformation.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/CoilTrickplayTransformation.kt index a5ffd9e9..b269d9d5 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/ui/CoilTrickplayTransformation.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/CoilTrickplayTransformation.kt @@ -6,6 +6,9 @@ import coil3.size.Size import coil3.size.pxOrElse import coil3.transform.Transformation +/** + * A Coil [Transformation] that extracts a subimage from a trickplay image + */ class CoilTrickplayTransformation( val targetWidth: Int, val targetHeight: Int, diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/Extensions.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/Extensions.kt index f4dcb046..86a16159 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/ui/Extensions.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/Extensions.kt @@ -39,6 +39,8 @@ import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.minutes import kotlin.time.Duration.Companion.seconds +// This file is a dumping ground mostly for extensions + @OptIn(ExperimentalContracts::class) fun CharSequence?.isNotNullOrBlank(): Boolean { contract { @@ -180,12 +182,21 @@ fun playOnClickSound( audioManager.playSoundEffect(effectType) } +/** + * Rounds a [Duration] to nearest whole minute + */ val Duration.roundMinutes: Duration get() = (this + 30.seconds).inWholeMinutes.minutes +/** + * Rounds a [Duration] to nearest whole second + */ val Duration.roundSeconds: Duration get() = (this + 30.milliseconds).inWholeSeconds.seconds +/** + * Gets the user's playback position as a [Duration] + */ val BaseItemDto.timeRemaining: Duration? get() = userData?.playbackPositionTicks?.let { @@ -196,10 +207,19 @@ val BaseItemDto.timeRemaining: Duration? } } +/** + * Seek back the current media item by a [Duration] + */ fun Player.seekBack(amount: Duration) = seekTo((currentPosition - amount.inWholeMilliseconds).coerceAtLeast(0L)) +/** + * Seek forward the current media item by a [Duration] + */ fun Player.seekForward(amount: Duration) = seekTo((currentPosition + amount.inWholeMilliseconds).coerceAtMost(duration)) +/** + * Like [let] but only if the collection is not empty, otherwise returns null + */ @OptIn(ExperimentalContracts::class) inline fun , R> T.letNotEmpty(block: (T) -> R): R? { contract { @@ -234,6 +254,9 @@ fun Arrangement.spacedByWithFooter(space: Dp) = } } +/** + * Tries to find the [Activity] for the given [Context]. Often used for [keepScreenOn]. + */ fun Context.findActivity(): Activity? { if (this is Activity) { return this @@ -246,6 +269,9 @@ fun Context.findActivity(): Activity? { return null } +/** + * Keep the screen on for an [Activity]. Often used with [findActivity]. + */ fun Activity.keepScreenOn(keep: Boolean) { Timber.v("Keep screen on: $keep") if (keep) { @@ -255,6 +281,11 @@ fun Activity.keepScreenOn(keep: Boolean) { } } +/** + * Selectively log errors from Coil image loading. + * + * If an HTTP error occurs, the entire stacktrace is not logged. + */ fun logCoilError( url: String?, errorResult: ErrorResult, diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/UiConstants.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/UiConstants.kt index 36cf187b..3539ea82 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/ui/UiConstants.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/UiConstants.kt @@ -10,8 +10,13 @@ import androidx.compose.ui.unit.dp import com.github.damontecres.dolphin.R import org.jellyfin.sdk.model.api.ItemFields +// This file is for constants used for the UI + val FontAwesome = FontFamily(Font(resId = R.font.fa_solid_900)) +/** + * Colors not associated with the theme + */ sealed class AppColors private constructor() { companion object { val TransparentBlack25 = Color(0x40000000) @@ -22,6 +27,9 @@ sealed class AppColors private constructor() { const val DEFAULT_PAGE_SIZE = 100 +/** + * The default [ItemFields] to fetch for most queries + */ val DefaultItemFields = listOf( ItemFields.PRIMARY_IMAGE_ASPECT_RATIO, @@ -40,8 +48,8 @@ val DefaultButtonPadding = bottom = 10.dp / 2, ) -object Cards { - val defaultHeight2x3 = 180.dp +object CardDefaults { + val height2x3 = 180.dp val playedPercentHeight = 6.dp } diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/cards/BannerCard.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/cards/BannerCard.kt index 9a6e4b9a..85ab0cc7 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/ui/cards/BannerCard.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/cards/BannerCard.kt @@ -32,9 +32,12 @@ import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text import coil3.compose.AsyncImage import com.github.damontecres.dolphin.ui.AppColors -import com.github.damontecres.dolphin.ui.Cards +import com.github.damontecres.dolphin.ui.CardDefaults import com.github.damontecres.dolphin.ui.isNotNullOrBlank +/** + * Displays an image as a card. If no image is available, the name will be shown instead + */ @Composable fun BannerCard( name: String?, @@ -128,7 +131,7 @@ fun BannerCard( .background( MaterialTheme.colorScheme.tertiary, ).clip(RectangleShape) - .height(Cards.playedPercentHeight) + .height(CardDefaults.playedPercentHeight) .fillMaxWidth((playPercent / 100).toFloat()), ) } diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/cards/ChapterCard.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/cards/ChapterCard.kt index ccc1742e..54c6efe4 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/ui/cards/ChapterCard.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/cards/ChapterCard.kt @@ -21,6 +21,9 @@ import com.github.damontecres.dolphin.ui.AppColors import com.github.damontecres.dolphin.ui.roundSeconds import kotlin.time.Duration +/** + * Card for a [com.github.damontecres.dolphin.data.model.Chapter] + */ @Composable fun ChapterCard( name: String?, diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/cards/GridCard.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/cards/GridCard.kt index 192a5d47..6137a859 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/ui/cards/GridCard.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/cards/GridCard.kt @@ -28,6 +28,9 @@ import com.github.damontecres.dolphin.data.model.BaseItem import com.github.damontecres.dolphin.ui.enableMarquee import kotlinx.coroutines.delay +/** + * Card for use in [com.github.damontecres.dolphin.ui.detail.CardGrid] + */ @Composable fun GridCard( item: BaseItem?, diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/cards/ItemCard.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/cards/ItemCard.kt index 02128966..51bbf671 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/ui/cards/ItemCard.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/cards/ItemCard.kt @@ -48,7 +48,7 @@ import androidx.tv.material3.Text import coil3.compose.AsyncImage import com.github.damontecres.dolphin.R import com.github.damontecres.dolphin.data.model.BaseItem -import com.github.damontecres.dolphin.ui.Cards +import com.github.damontecres.dolphin.ui.CardDefaults import com.github.damontecres.dolphin.ui.FontAwesome import com.github.damontecres.dolphin.ui.enableMarquee import com.github.damontecres.dolphin.ui.ifElse @@ -59,6 +59,7 @@ import kotlinx.coroutines.delay import org.jellyfin.sdk.model.api.BaseItemKind @Composable +@Deprecated("Old style, prefer SeasonCard or other Card") fun ItemCard( item: BaseItem?, onClick: () -> Unit, @@ -281,7 +282,7 @@ fun ItemCardImage( .background( MaterialTheme.colorScheme.tertiary, ).clip(RectangleShape) - .height(Cards.playedPercentHeight) + .height(CardDefaults.playedPercentHeight) .fillMaxWidth((percent / 100.0).toFloat()), ) } diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/cards/NullCard.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/cards/NullCard.kt index 3d6ea537..00f72096 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/ui/cards/NullCard.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/cards/NullCard.kt @@ -13,6 +13,7 @@ import androidx.tv.material3.Text import com.github.damontecres.dolphin.ui.ifElse @Composable +@Deprecated("Cards should handle nulls natively") fun NullCard( modifier: Modifier = Modifier, cardWidth: Dp? = null, diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/cards/PersonCard.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/cards/PersonCard.kt index 84d18bdc..4aba4d46 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/ui/cards/PersonCard.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/cards/PersonCard.kt @@ -25,6 +25,9 @@ import com.github.damontecres.dolphin.data.model.Person import com.github.damontecres.dolphin.ui.enableMarquee import kotlinx.coroutines.delay +/** + * A Card for a [Person] such as an actor or director + */ @Composable fun PersonCard( item: Person, diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/cards/SeasonCard.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/cards/SeasonCard.kt index 650646ba..ed2ed7fe 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/ui/cards/SeasonCard.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/cards/SeasonCard.kt @@ -29,6 +29,9 @@ import com.github.damontecres.dolphin.data.model.BaseItem import com.github.damontecres.dolphin.ui.enableMarquee import kotlinx.coroutines.delay +/** + * A Card for a TV Show Season, but can generically show most items + */ @Composable fun SeasonCard( item: BaseItem?, @@ -100,7 +103,10 @@ fun SeasonCard( } Column( verticalArrangement = Arrangement.spacedBy(0.dp), - modifier = Modifier.padding(bottom = spaceBelow).fillMaxWidth(), + modifier = + Modifier + .padding(bottom = spaceBelow) + .fillMaxWidth(), ) { Text( text = dto?.name ?: "", diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/components/CircularProgress.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/components/CircularProgress.kt index 243044eb..f854abe7 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/ui/components/CircularProgress.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/components/CircularProgress.kt @@ -24,11 +24,14 @@ fun CircularProgress( } } +/** + * Fill the space with a loading indicator + */ @Composable -fun LoadingPage() { +fun LoadingPage(modifier: Modifier = Modifier) { Box( contentAlignment = Alignment.Center, - modifier = Modifier.fillMaxSize(), + modifier = modifier.fillMaxSize(), ) { CircularProgressIndicator( color = MaterialTheme.colorScheme.border, diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/detail/CollectionFolderDetails.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/components/CollectionFolderGrid.kt similarity index 96% rename from app/src/main/java/com/github/damontecres/dolphin/ui/detail/CollectionFolderDetails.kt rename to app/src/main/java/com/github/damontecres/dolphin/ui/components/CollectionFolderGrid.kt index 95951748..7244a132 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/ui/detail/CollectionFolderDetails.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/components/CollectionFolderGrid.kt @@ -1,4 +1,4 @@ -package com.github.damontecres.dolphin.ui.detail +package com.github.damontecres.dolphin.ui.components import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.fadeIn @@ -31,13 +31,12 @@ import com.github.damontecres.dolphin.data.model.Library import com.github.damontecres.dolphin.preferences.UserPreferences import com.github.damontecres.dolphin.ui.DefaultItemFields import com.github.damontecres.dolphin.ui.OneTimeLaunchedEffect -import com.github.damontecres.dolphin.ui.components.ErrorMessage -import com.github.damontecres.dolphin.ui.components.LoadingPage -import com.github.damontecres.dolphin.ui.components.SortByButton import com.github.damontecres.dolphin.ui.data.MovieSortOptions import com.github.damontecres.dolphin.ui.data.SeriesSortOptions import com.github.damontecres.dolphin.ui.data.SortAndDirection import com.github.damontecres.dolphin.ui.data.VideoSortOptions +import com.github.damontecres.dolphin.ui.detail.CardGrid +import com.github.damontecres.dolphin.ui.detail.ItemViewModel import com.github.damontecres.dolphin.ui.nav.Destination import com.github.damontecres.dolphin.ui.tryRequestFocus import com.github.damontecres.dolphin.util.ApiRequestPager @@ -152,8 +151,13 @@ class CollectionFolderViewModel } } +/** + * Shows a collection folder as a grid + * + * This is the "Library" tab for Movies or TV shows + */ @Composable -fun CollectionFolderDetails( +fun CollectionFolderGrid( preferences: UserPreferences, destination: Destination.MediaItem, onClickItem: (BaseItem) -> Unit, @@ -181,7 +185,7 @@ fun CollectionFolderDetails( LoadingState.Loading -> LoadingPage() LoadingState.Success -> { pager?.let { pager -> - CollectionDetails( + CollectionFolderGridContent( preferences, library!!, item!!, @@ -202,7 +206,7 @@ fun CollectionFolderDetails( } @Composable -fun CollectionDetails( +fun CollectionFolderGridContent( preferences: UserPreferences, library: Library, item: BaseItem, diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/components/Dialogs.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/components/Dialogs.kt index 3ac10124..da309be2 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/ui/components/Dialogs.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/components/Dialogs.kt @@ -2,7 +2,6 @@ package com.github.damontecres.dolphin.ui.components import android.view.KeyEvent import androidx.annotation.StringRes -import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.focusable import androidx.compose.foundation.gestures.scrollBy @@ -58,6 +57,9 @@ import com.github.damontecres.dolphin.util.ExceptionHandler import kotlinx.coroutines.delay import kotlinx.coroutines.launch +/** + * Parameters for rendering a [DialogPopup] + */ data class DialogParams( val fromLongClick: Boolean, val title: String, @@ -138,7 +140,11 @@ data class DialogItem( } } -@OptIn(ExperimentalFoundationApi::class) +/** + * Show a dialog with a list of entries. + * + * @param waitToLoad items start as disabled for about ~1s, which is useful if the dialog spawned from a long press + */ @Composable fun DialogPopup( showDialog: Boolean, @@ -230,6 +236,9 @@ fun DialogPopup( } } +/** + * A dialog that can be scrolled, typically for longer text content + */ @Composable fun ScrollableDialog( onDismissRequest: () -> Unit, @@ -282,6 +291,9 @@ fun ScrollableDialog( } } +/** + * Shows a basic dialog + */ @Composable fun BasicDialog( onDismissRequest: () -> Unit, @@ -306,6 +318,9 @@ fun BasicDialog( } } +/** + * Shows a confirmation dialog + */ @Composable fun ConfirmDialog( title: String, @@ -323,6 +338,9 @@ fun ConfirmDialog( }, ) +/** + * Content for a confirmation dialog + */ @Composable fun ConfirmDialogContent( title: String, diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/components/EditTextBox.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/components/EditTextBox.kt index bcffa3cc..bddcbc2b 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/ui/components/EditTextBox.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/components/EditTextBox.kt @@ -1,6 +1,7 @@ package com.github.damontecres.dolphin.ui.components import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.defaultMinSize import androidx.compose.foundation.layout.height @@ -30,7 +31,13 @@ import androidx.compose.ui.unit.dp import androidx.tv.material3.Icon import androidx.tv.material3.MaterialTheme import com.github.damontecres.dolphin.R +import com.github.damontecres.dolphin.ui.PreviewTvSpec +import com.github.damontecres.dolphin.ui.theme.DolphinTheme +import com.google.protobuf.value +/** + * A modified [BasicTextField] that looks & fits better with TV material controls + */ @OptIn(ExperimentalMaterial3Api::class) @Composable fun EditTextBox( @@ -138,6 +145,9 @@ fun EditTextBox( } } +/** + * And [EditTextBox] styles for searches + */ @Composable fun SearchEditTextBox( value: String, @@ -176,3 +186,21 @@ fun SearchEditTextBox( height, ) } + +@PreviewTvSpec +@Composable +private fun EditTextBoxPreview() { + DolphinTheme { + Column { + EditTextBox( + value = "string", + onValueChange = {}, + ) + SearchEditTextBox( + value = "search query", + onValueChange = {}, + onSearchClick = { }, + ) + } + } +} diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/components/ErrorMessage.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/components/ErrorMessage.kt index ac8d7fc5..1e72dfb9 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/ui/components/ErrorMessage.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/components/ErrorMessage.kt @@ -10,6 +10,9 @@ import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text import com.github.damontecres.dolphin.util.LoadingState +/** + * Displays an error message and/or exception + */ @Composable fun ErrorMessage( message: String?, diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/components/PlayButton.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/components/PlayButton.kt index e9642387..307f1c2b 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/ui/components/PlayButton.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/components/PlayButton.kt @@ -26,6 +26,9 @@ import com.github.damontecres.dolphin.ui.FontAwesome import com.github.damontecres.dolphin.ui.ifElse import kotlin.time.Duration +/** + * An icon button typically used in a row for playing media + */ @Composable fun PlayButton( @StringRes title: Int, @@ -53,6 +56,11 @@ fun PlayButton( } } +/** + * An icon button typically used in a row for playing media + * + * Only shows the icon until focused when it expands to show the title + */ @Composable fun ExpandablePlayButton( @StringRes title: Int, @@ -85,6 +93,9 @@ fun ExpandablePlayButton( } } +/** + * Similar to [ExpandablePlayButton], but uses a [FontAwesome] string instead of an Icon + */ @Composable fun ExpandableFaButton( @StringRes title: Int, diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/components/PlayButtons.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/components/PlayButtons.kt index 10c46a2d..6d816c6d 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/ui/components/PlayButtons.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/components/PlayButtons.kt @@ -33,6 +33,9 @@ import com.github.damontecres.dolphin.ui.tryRequestFocus import kotlin.time.Duration import kotlin.time.Duration.Companion.seconds +/** + * Standard row of [PlayButton] including Play (or Resume & Restart) & More + */ @Composable fun PlayButtons( resumePosition: Duration, @@ -116,6 +119,9 @@ fun PlayButtons( } } +/** + * Standard row of [ExpandablePlayButton] including Play (or Resume & Restart), Mark played, & More + */ @Composable fun ExpandablePlayButtons( resumePosition: Duration, diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/components/Rating.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/components/Rating.kt index 6ce08fde..43a794a7 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/ui/components/Rating.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/components/Rating.kt @@ -63,6 +63,9 @@ val EmptyStarColor = Color(0x2AFFC700) val ratingBarHeight: Dp = 32.dp +/** + * Shows a rating out of 5 stars + */ @Composable fun StarRating( rating100: Int, diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/components/RecommendedMovie.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/components/RecommendedMovie.kt index 1862fcf2..c0f73274 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/ui/components/RecommendedMovie.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/components/RecommendedMovie.kt @@ -123,6 +123,9 @@ class RecommendedMovieViewModel } } +/** + * The "recommended" tab of a movie library + */ @Composable fun RecommendedMovie( preferences: UserPreferences, diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/components/RecommendedTvShow.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/components/RecommendedTvShow.kt index d7b625e0..80c1d6ba 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/ui/components/RecommendedTvShow.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/components/RecommendedTvShow.kt @@ -135,6 +135,9 @@ class RecommendedTvShowViewModel } } +/** + * The "recommended" tab of a TV show library + */ @Composable fun RecommendedTvShow( preferences: UserPreferences, diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/components/SliderBar.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/components/SliderBar.kt index b14a3ccb..601b7b5e 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/ui/components/SliderBar.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/components/SliderBar.kt @@ -24,6 +24,9 @@ import androidx.compose.ui.unit.dp import androidx.tv.material3.MaterialTheme import com.github.damontecres.dolphin.ui.handleDPadKeyEvents +/** + * A TV capable control for choosing a value + */ @Composable fun SliderBar( value: Long, diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/components/SortByButton.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/components/SortByButton.kt index 74c40c27..364c4ed6 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/ui/components/SortByButton.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/components/SortByButton.kt @@ -25,6 +25,11 @@ import com.github.damontecres.dolphin.ui.data.getStringRes import org.jellyfin.sdk.model.api.ItemSortBy import org.jellyfin.sdk.model.api.SortOrder +/** + * Button that displays current sort option and provides ability to choose another + * + * Long pressing will reverse the current sort as will selecting the current sort option from the list + */ @Composable fun SortByButton( sortOptions: List, diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/components/SwitchWithLabel.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/components/SwitchWithLabel.kt index 5b3a25a3..eff9ee99 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/ui/components/SwitchWithLabel.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/components/SwitchWithLabel.kt @@ -22,6 +22,9 @@ import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Switch import androidx.tv.material3.Text +/** + * A labeled [Switch], but the entire composable is focusable & clickable + */ @Composable fun SwitchWithLabel( label: String, diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/components/TitleValueText.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/components/TitleValueText.kt index cebf2aa7..371cc53a 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/ui/components/TitleValueText.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/components/TitleValueText.kt @@ -41,6 +41,9 @@ import androidx.tv.material3.Text import com.github.damontecres.dolphin.ui.ifElse import com.github.damontecres.dolphin.ui.playOnClickSound +/** + * An optionally clickable composable that displays a Key-Value pair vertically + */ @Composable fun TitleValueText( title: String, diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/detail/CollectionFolderGeneric.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/detail/CollectionFolderGeneric.kt index 4da1e054..69d356af 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/ui/detail/CollectionFolderGeneric.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/detail/CollectionFolderGeneric.kt @@ -10,6 +10,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import com.github.damontecres.dolphin.preferences.UserPreferences +import com.github.damontecres.dolphin.ui.components.CollectionFolderGrid import com.github.damontecres.dolphin.ui.nav.Destination import com.github.damontecres.dolphin.ui.preferences.PreferencesViewModel @@ -21,7 +22,7 @@ fun CollectionFolderGeneric( preferencesViewModel: PreferencesViewModel = hiltViewModel(), ) { var showHeader by remember { mutableStateOf(true) } - CollectionFolderDetails( + CollectionFolderGrid( preferences = preferences, onClickItem = { preferencesViewModel.navigationManager.navigateTo(it.destination()) }, destination = destination, diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/detail/CollectionFolderMovie.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/detail/CollectionFolderMovie.kt index 0042a1db..5be4e02f 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/ui/detail/CollectionFolderMovie.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/detail/CollectionFolderMovie.kt @@ -32,6 +32,7 @@ import androidx.tv.material3.Text import com.github.damontecres.dolphin.data.model.BaseItem import com.github.damontecres.dolphin.preferences.UserPreferences import com.github.damontecres.dolphin.preferences.rememberTab +import com.github.damontecres.dolphin.ui.components.CollectionFolderGrid import com.github.damontecres.dolphin.ui.components.ErrorMessage import com.github.damontecres.dolphin.ui.components.RecommendedMovie import com.github.damontecres.dolphin.ui.ifElse @@ -150,7 +151,7 @@ fun CollectionFolderMovie( ) } 1 -> { - CollectionFolderDetails( + CollectionFolderGrid( preferences = preferences, onClickItem = onClickItem, destination = destination, diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/detail/CollectionFolderTv.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/detail/CollectionFolderTv.kt index 62732935..5d25dee4 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/ui/detail/CollectionFolderTv.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/detail/CollectionFolderTv.kt @@ -32,6 +32,7 @@ import androidx.tv.material3.Text import com.github.damontecres.dolphin.data.model.BaseItem import com.github.damontecres.dolphin.preferences.UserPreferences import com.github.damontecres.dolphin.preferences.rememberTab +import com.github.damontecres.dolphin.ui.components.CollectionFolderGrid import com.github.damontecres.dolphin.ui.components.ErrorMessage import com.github.damontecres.dolphin.ui.components.RecommendedTvShow import com.github.damontecres.dolphin.ui.ifElse @@ -150,7 +151,7 @@ fun CollectionFolderTv( ) } 1 -> { - CollectionFolderDetails( + CollectionFolderGrid( preferences = preferences, destination = destination, showTitle = false, diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/detail/ItemViewModel.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/detail/ItemViewModel.kt index 94efdf41..590085bb 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/ui/detail/ItemViewModel.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/detail/ItemViewModel.kt @@ -19,6 +19,9 @@ import org.jellyfin.sdk.model.api.ImageType import timber.log.Timber import java.util.UUID +/** + * Basic [ViewModel] for a single fetchable item from the API + */ abstract class ItemViewModel( val api: ApiClient, ) : ViewModel() { @@ -57,6 +60,9 @@ abstract class ItemViewModel( ): String? = api.imageApi.getItemImageUrl(itemId, type) } +/** + * Extends [ItemViewModel] to include a loading state tracking when the item has been fetched or if an error occurred + */ abstract class LoadingItemViewModel( api: ApiClient, ) : ItemViewModel(api) { diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/detail/SeasonDetails.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/detail/SeasonDetails.kt index c8c4ee57..abd2ae81 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/ui/detail/SeasonDetails.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/detail/SeasonDetails.kt @@ -1,6 +1,5 @@ package com.github.damontecres.dolphin.ui.detail -import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.runtime.Composable @@ -112,12 +111,3 @@ fun SeasonDetails( } } } - -@Composable -fun EpisodeHeader( - item: BaseItem, - modifier: Modifier = Modifier, -) { - Column(modifier = modifier) { - } -} diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/detail/SeriesDetails.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/detail/SeriesDetails.kt index 38782c88..1e0ba7b5 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/ui/detail/SeriesDetails.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/detail/SeriesDetails.kt @@ -49,7 +49,7 @@ import com.github.damontecres.dolphin.R import com.github.damontecres.dolphin.data.model.BaseItem import com.github.damontecres.dolphin.data.model.Person import com.github.damontecres.dolphin.preferences.UserPreferences -import com.github.damontecres.dolphin.ui.Cards +import com.github.damontecres.dolphin.ui.CardDefaults import com.github.damontecres.dolphin.ui.cards.ItemRow import com.github.damontecres.dolphin.ui.cards.PersonRow import com.github.damontecres.dolphin.ui.cards.SeasonCard @@ -241,7 +241,7 @@ fun SeriesDetailsContent( onClick = onClick, onLongClick = onLongClick, modifier = mod, - imageHeight = Cards.defaultHeight2x3, + imageHeight = CardDefaults.height2x3, imageWidth = Dp.Unspecified, ) }, diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/detail/SeriesViewModel.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/detail/SeriesViewModel.kt index 227eaf77..fda14f00 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/ui/detail/SeriesViewModel.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/detail/SeriesViewModel.kt @@ -84,6 +84,8 @@ class SeriesViewModel val item = fetchItem(seriesId, potential) if (item != null) { val seasonsInfo = getSeasons(item) + + // If a particular season was requested, fetch those episodes, otherwise get the first season val episodeInfo = (season ?: seasonsInfo.items.firstOrNull()?.indexNumber) ?.let { seasonNum -> @@ -110,6 +112,9 @@ class SeriesViewModel } } + /** + * If the series has a theme song & app settings allow, play it + */ @OptIn(UnstableApi::class) private fun maybePlayThemeSong(playThemeSongs: ThemeSongVolume) { val volume = @@ -276,6 +281,9 @@ class SeriesViewModel } } + /** + * Play whichever episode is next up for series or else the first episode + */ fun playNextUp() { viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) { val result by api.tvShowsApi.getNextUp(seriesId = seriesId) @@ -312,6 +320,11 @@ data class ItemListAndMapping( } } +/** + * Calculate the index<->season number pairings + * + * This allows for handling of missing seasons + */ private suspend fun convertPager(pager: ApiRequestPager<*>): ItemListAndMapping { val pairs = pager.mapIndexed { index, _ -> diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/main/HomePage.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/main/HomePage.kt index 84456eeb..a7493af9 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/ui/main/HomePage.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/main/HomePage.kt @@ -39,7 +39,7 @@ import androidx.tv.material3.Text import coil3.compose.AsyncImage import com.github.damontecres.dolphin.data.model.BaseItem import com.github.damontecres.dolphin.preferences.UserPreferences -import com.github.damontecres.dolphin.ui.Cards +import com.github.damontecres.dolphin.ui.CardDefaults import com.github.damontecres.dolphin.ui.cards.BannerCard import com.github.damontecres.dolphin.ui.cards.ItemRow import com.github.damontecres.dolphin.ui.components.DotSeparatedRow @@ -195,7 +195,7 @@ fun HomePageContent( Modifier.focusRequester(positionFocusRequester), ), interactionSource = null, - cardHeight = Cards.defaultHeight2x3, + cardHeight = CardDefaults.height2x3, ) }, ) diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/main/HomeViewModel.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/main/HomeViewModel.kt index a0e48d3b..606dea38 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/ui/main/HomeViewModel.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/main/HomeViewModel.kt @@ -77,6 +77,7 @@ class HomeViewModel // ) // } + // TODO data is fetched all together which may be slow for large servers val homeRows = homeSections .mapNotNull { section -> diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/main/SearchPage.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/main/SearchPage.kt index 566eaab1..1b258aa2 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/ui/main/SearchPage.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/main/SearchPage.kt @@ -6,7 +6,6 @@ import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.text.KeyboardActions import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -26,12 +25,12 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.github.damontecres.dolphin.data.model.BaseItem import com.github.damontecres.dolphin.preferences.UserPreferences -import com.github.damontecres.dolphin.ui.Cards +import com.github.damontecres.dolphin.ui.CardDefaults import com.github.damontecres.dolphin.ui.DefaultItemFields import com.github.damontecres.dolphin.ui.cards.EpisodeCard import com.github.damontecres.dolphin.ui.cards.ItemRow import com.github.damontecres.dolphin.ui.cards.SeasonCard -import com.github.damontecres.dolphin.ui.components.EditTextBox +import com.github.damontecres.dolphin.ui.components.SearchEditTextBox import com.github.damontecres.dolphin.ui.isNotNullOrBlank import com.github.damontecres.dolphin.ui.nav.NavigationManager import com.github.damontecres.dolphin.ui.tryRequestFocus @@ -151,17 +150,14 @@ fun SearchPage( contentAlignment = Alignment.Center, modifier = Modifier.fillMaxWidth(), ) { - EditTextBox( + SearchEditTextBox( value = query, onValueChange = { query = it }, - keyboardActions = - KeyboardActions( - onSearch = { - viewModel.search(query) - }, - ), + onSearchClick = { + viewModel.search(query) + }, modifier = Modifier.focusRequester(searchFocusRequester), ) } @@ -181,7 +177,7 @@ fun SearchPage( item = item, onClick = onClick, onLongClick = onLongClick, - imageHeight = Cards.defaultHeight2x3, + imageHeight = CardDefaults.height2x3, modifier = mod, ) }, @@ -203,7 +199,7 @@ fun SearchPage( item = item, onClick = onClick, onLongClick = onLongClick, - imageHeight = Cards.defaultHeight2x3, + imageHeight = CardDefaults.height2x3, modifier = mod, ) }, diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/nav/ApplicationContent.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/nav/ApplicationContent.kt index ef58fc02..dcd484c6 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/ui/nav/ApplicationContent.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/nav/ApplicationContent.kt @@ -14,6 +14,11 @@ import com.github.damontecres.dolphin.preferences.UserPreferences import org.jellyfin.sdk.model.api.DeviceProfile import timber.log.Timber +/** + * This is generally the root composable of the of the app + * + * Here the navigation backstack is used and pages are rendered in the nav drawer or full screen + */ @Composable fun ApplicationContent( server: JellyfinServer, diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/nav/Destination.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/nav/Destination.kt index 4b1e50a4..6c1f2184 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/ui/nav/Destination.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/nav/Destination.kt @@ -13,6 +13,11 @@ import kotlinx.serialization.UseSerializers import org.jellyfin.sdk.model.api.BaseItemKind import java.util.UUID +/** + * Represents a page in the app + * + * @param fullScreen whether the page should be full page aka not include the nav drawer + */ @Serializable sealed class Destination( val fullScreen: Boolean = false, @@ -24,7 +29,7 @@ sealed class Destination( data object UserList : Destination(true) @Serializable - data class Main( + data class Home( val id: Long = 0L, ) : Destination() diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/nav/DestinationContent.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/nav/DestinationContent.kt index 43c998d2..063dff3d 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/ui/nav/DestinationContent.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/nav/DestinationContent.kt @@ -15,7 +15,7 @@ import com.github.damontecres.dolphin.ui.detail.movie.MovieDetails import com.github.damontecres.dolphin.ui.detail.series.SeriesOverview import com.github.damontecres.dolphin.ui.main.HomePage import com.github.damontecres.dolphin.ui.main.SearchPage -import com.github.damontecres.dolphin.ui.playback.PlaybackContent +import com.github.damontecres.dolphin.ui.playback.PlaybackPage import com.github.damontecres.dolphin.ui.preferences.PreferencesPage import com.github.damontecres.dolphin.ui.setup.SwitchServerContent import com.github.damontecres.dolphin.ui.setup.SwitchUserContent @@ -23,6 +23,9 @@ import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.CollectionType import org.jellyfin.sdk.model.api.DeviceProfile +/** + * Chose the page for the [Destination] + */ @Composable fun DestinationContent( destination: Destination, @@ -31,14 +34,14 @@ fun DestinationContent( modifier: Modifier = Modifier, ) { when (destination) { - is Destination.Main -> + is Destination.Home -> HomePage( preferences = preferences, modifier = modifier, ) is Destination.Playback -> - PlaybackContent( + PlaybackPage( preferences = preferences, deviceProfile = deviceProfile, destination = destination, diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/nav/NavDrawer.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/nav/NavDrawer.kt index 622772b6..ce5c98f1 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/ui/nav/NavDrawer.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/nav/NavDrawer.kt @@ -102,6 +102,9 @@ class NavDrawerViewModel } } +/** + * Display the left side navigation drawer with [DestinationContent] on the right + */ @Composable fun NavDrawer( destination: Destination, @@ -113,7 +116,7 @@ fun NavDrawer( viewModel: NavDrawerViewModel = hiltViewModel( LocalView.current.findViewTreeViewModelStoreOwner()!!, - key = "${server?.id}_${user?.id}", + key = "${server?.id}_${user?.id}", // Keyed to the server & user to ensure its reset when switching either ), ) { val drawerState = rememberDrawerState(DrawerValue.Closed) @@ -121,11 +124,15 @@ fun NavDrawer( val scope = rememberCoroutineScope() val context = LocalContext.current val drawerFocusRequester = remember { FocusRequester() } - BackHandler(enabled = (drawerState.currentValue == DrawerValue.Closed && destination is Destination.Main)) { + + // If the user presses back while on the home page, open the nav drawer, another back press will quit the app + BackHandler(enabled = (drawerState.currentValue == DrawerValue.Closed && destination is Destination.Home)) { drawerState.setValue(DrawerValue.Open) drawerFocusRequester.requestFocus() } val libraries by viewModel.libraries.observeAsState(listOf()) + + // A negative index is a built in page, >=0 is a library val selectedIndex by viewModel.selectedIndex.observeAsState(-1) val focusRequester = remember { FocusRequester() } @@ -185,7 +192,7 @@ fun NavDrawer( selected = selectedIndex == -1, onClick = { viewModel.setIndex(-1) - if (destination is Destination.Main) { + if (destination is Destination.Home) { viewModel.navigationManager.reloadHome() } else { viewModel.navigationManager.goToHome() diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/nav/NavigationManager.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/nav/NavigationManager.kt index b27e9ab2..87d22d5d 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/ui/nav/NavigationManager.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/nav/NavigationManager.kt @@ -4,34 +4,52 @@ import androidx.navigation3.runtime.NavKey import javax.inject.Inject import javax.inject.Singleton +/** + * Manages navigating between pages and manages the app's back stack + */ @Singleton class NavigationManager @Inject constructor() { var backStack: MutableList = mutableListOf() + /** + * Go to the specified [Destination] + */ fun navigateTo(destination: Destination) { backStack.add(destination) } + /** + * Go to the specified [Destination], but reset the back stack to Home first + */ fun navigateToFromDrawer(destination: Destination) { goToHome() backStack.add(destination) } + /** + * Go to the previous page + */ fun goBack() { backStack.removeLastOrNull() } + /** + * Go all the way back to the home page + */ fun goToHome() { while (backStack.size > 1) { backStack.removeLastOrNull() } } + /** + * Go all the way back to the home page, and reload it from scratch + */ fun reloadHome() { goToHome() - val id = (backStack[0] as Destination.Main).id + 1 - backStack[0] = Destination.Main(id) + val id = (backStack[0] as Destination.Home).id + 1 + backStack[0] = Destination.Home(id) } } diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/playback/AmbientPlayerListener.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/playback/AmbientPlayerListener.kt index 0021c23f..354b29f2 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/ui/playback/AmbientPlayerListener.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/playback/AmbientPlayerListener.kt @@ -8,6 +8,11 @@ import androidx.media3.common.Player.Listener import com.github.damontecres.dolphin.ui.findActivity import com.github.damontecres.dolphin.ui.keepScreenOn +/** + * Starts a [Player.Listener] that ensures the screen stays on without a screen saber during playback + * + * This will clean up the listener when disposed + */ @Composable fun AmbientPlayerListener(player: Player) { val context = LocalContext.current diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/playback/ControllerViewState.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/playback/ControllerViewState.kt index 56dfd962..c9926a45 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/ui/playback/ControllerViewState.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/playback/ControllerViewState.kt @@ -10,6 +10,11 @@ import kotlinx.coroutines.channels.Channel.Factory.CONFLATED import kotlinx.coroutines.flow.consumeAsFlow import kotlinx.coroutines.flow.debounce +/** + * The visibility state of the playback controls. Can [pulseControls] to show the controls for a specified time. + * + * A coroutine must call [observe] + */ class ControllerViewState internal constructor( @param:IntRange(from = 0) private val hideMilliseconds: Long, diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/playback/KeyIdentifier.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/playback/KeyIdentifier.kt index b2294684..5d4e05f1 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/ui/playback/KeyIdentifier.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/playback/KeyIdentifier.kt @@ -6,6 +6,8 @@ import androidx.compose.ui.input.key.KeyEventType import androidx.compose.ui.input.key.key import androidx.compose.ui.input.key.type +// This file is various functions for testing KeyEvent types + fun isDirectionalDpad(event: KeyEvent): Boolean = event.key == Key.DirectionUp || event.key == Key.DirectionDown || diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/playback/NextUpEpisode.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/playback/NextUpEpisode.kt index b6b69eec..a19c0256 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/ui/playback/NextUpEpisode.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/playback/NextUpEpisode.kt @@ -41,6 +41,9 @@ import com.github.damontecres.dolphin.ui.tryRequestFocus import kotlin.time.Duration import kotlin.time.Duration.Companion.seconds +/** + * Layout for showing the next up episode during playback + */ @Composable fun NextUpEpisode( title: String?, @@ -169,7 +172,7 @@ fun NextUpCard( @PreviewTvSpec @Composable -fun NextUpEpisodePreview() { +private fun NextUpEpisodePreview() { DolphinTheme(true) { NextUpEpisode( title = "Episode Title", diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/playback/PlaybackKeyHandler.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/playback/PlaybackKeyHandler.kt index ca697fa8..c5cf4874 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/ui/playback/PlaybackKeyHandler.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/playback/PlaybackKeyHandler.kt @@ -11,6 +11,9 @@ import com.github.damontecres.dolphin.ui.seekBack import com.github.damontecres.dolphin.ui.seekForward import kotlin.time.Duration +/** + * Handles [KeyEvent]s during playback on [PlaybackPage] + */ class PlaybackKeyHandler( private val player: Player, private val controlsEnabled: Boolean, diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/playback/PlaybackOverlay.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/playback/PlaybackOverlay.kt index 907c7d19..baef5461 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/ui/playback/PlaybackOverlay.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/playback/PlaybackOverlay.kt @@ -54,6 +54,9 @@ import kotlin.time.Duration private val titleTextSize = 28.sp private val subtitleTextSize = 18.sp +/** + * The overlay during playback showing controls, seek preview image, debug info, etc + */ @Composable fun PlaybackOverlay( title: String?, @@ -103,14 +106,14 @@ fun PlaybackOverlay( // This will be calculated after composition var controllerHeight by remember { mutableStateOf(0.dp) } - var state by remember { mutableStateOf(ViewState.CONTROLLER) } + var state by remember { mutableStateOf(OverlayViewState.CONTROLLER) } Box( modifier = modifier, contentAlignment = Alignment.BottomCenter, ) { AnimatedVisibility( - state == ViewState.CONTROLLER, + state == OverlayViewState.CONTROLLER, enter = slideInVertically() + fadeIn(), exit = slideOutVertically() + fadeOut(), ) { @@ -147,7 +150,7 @@ fun PlaybackOverlay( e.type == KeyEventType.KeyDown && isDown(e) && !seekBarFocused ) { - state = ViewState.CHAPTERS + state = OverlayViewState.CHAPTERS true } false @@ -157,7 +160,7 @@ fun PlaybackOverlay( ) } AnimatedVisibility( - state == ViewState.CHAPTERS, + state == OverlayViewState.CHAPTERS, enter = slideInVertically { it / 2 } + fadeIn(), exit = slideOutVertically { it / 2 } + fadeOut(), ) { @@ -172,7 +175,7 @@ fun PlaybackOverlay( .padding(8.dp) .onPreviewKeyEvent { e -> if (e.type == KeyEventType.KeyUp && isUp(e)) { - state = ViewState.CONTROLLER + state = OverlayViewState.CONTROLLER true } false @@ -221,11 +224,6 @@ fun PlaybackOverlay( } } } - when (state) { - ViewState.CONTROLLER -> {} - - ViewState.CHAPTERS -> {} - } if (seekBarInteractionSource.collectIsFocusedAsState().value) { LaunchedEffect(Unit) { @@ -287,11 +285,17 @@ fun PlaybackOverlay( } } -enum class ViewState { +/** + * The view state of the overlay + */ +enum class OverlayViewState { CONTROLLER, CHAPTERS, } +/** + * A wrapper for the playback controls to show title and other information, plus the actual controls + */ @Composable fun Controller( title: String?, diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/playback/PlaybackContent.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/playback/PlaybackPage.kt similarity index 97% rename from app/src/main/java/com/github/damontecres/dolphin/ui/playback/PlaybackContent.kt rename to app/src/main/java/com/github/damontecres/dolphin/ui/playback/PlaybackPage.kt index aca7ba0b..7f154064 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/ui/playback/PlaybackContent.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/playback/PlaybackPage.kt @@ -71,9 +71,12 @@ import org.jellyfin.sdk.model.extensions.ticks import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.seconds +/** + * The actual playback page which shows media & playback controls + */ @OptIn(UnstableApi::class) @Composable -fun PlaybackContent( +fun PlaybackPage( preferences: UserPreferences, deviceProfile: DeviceProfile, destination: Destination.Playback, @@ -207,6 +210,7 @@ fun PlaybackContent( } } + // If D-pad skipping, show the amount skipped in an animation if (!controllerViewState.controlsVisible && skipIndicatorDuration != 0L) { SkipIndicator( durationMs = skipIndicatorDuration, @@ -218,6 +222,7 @@ fun PlaybackContent( .align(Alignment.BottomCenter) .padding(bottom = 70.dp), ) + // Show a small progress bar along the bottom of the screen val showSkipProgress = true // TODO get from preferences if (showSkipProgress) { duration?.let { @@ -237,6 +242,7 @@ fun PlaybackContent( } } + // The playback controls AnimatedVisibility( controllerViewState.controlsVisible, Modifier, @@ -299,6 +305,7 @@ fun PlaybackContent( ) } + // Subtitles if (!controllerViewState.controlsVisible && skipIndicatorDuration == 0L && currentPlayback?.subtitleIndex != null) { AndroidView( factory = { context -> @@ -320,6 +327,7 @@ fun PlaybackContent( ) } + // Ask to skip intros, etc button AnimatedVisibility( currentSegment != null && !controllerViewState.controlsVisible && skipIndicatorDuration == 0L, modifier = @@ -344,6 +352,7 @@ fun PlaybackContent( } } + // Next up episode BackHandler(nextUp != null) { viewModel.cancelUpNextEpisode() } diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/playback/PlaybackTrackInfo.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/playback/PlaybackTrackInfo.kt index 1e60c681..fed4e5fa 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/ui/playback/PlaybackTrackInfo.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/playback/PlaybackTrackInfo.kt @@ -17,6 +17,9 @@ import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text import com.github.damontecres.dolphin.util.TrackSupport +/** + * Debug info about the current playback tracks + */ @Composable fun PlaybackTrackInfo( trackSupport: List, diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/playback/PlaybackViewModel.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/playback/PlaybackViewModel.kt index 6346814b..ad0c4dd3 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/ui/playback/PlaybackViewModel.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/playback/PlaybackViewModel.kt @@ -319,6 +319,7 @@ class PlaybackViewModel mediaSourceId = source.id, static = true, tag = source.eTag, + playSessionId = response.playSessionId, ) } else if (source.supportsDirectStream) { api.createUrl(source.transcodingUrl!!) @@ -522,6 +523,12 @@ class PlaybackViewModel it.type != MediaSegmentType.UNKNOWN && currentTicks >= it.startTicks && currentTicks < it.endTicks } if (currentSegment != null) { + Timber.d( + "Found media segment for %s: %s, %s", + currentSegment.itemId, + currentSegment.id, + currentSegment.type, + ) val behavior = when (currentSegment.type) { MediaSegmentType.COMMERCIAL -> prefs.skipCommercials diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/playback/SeekPreviewImage.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/playback/SeekPreviewImage.kt index a4354056..29d836c7 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/ui/playback/SeekPreviewImage.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/playback/SeekPreviewImage.kt @@ -44,6 +44,14 @@ fun Modifier.offsetByPercent( }, ) +/** + * Offset the composable by a percentage of the available x direction + * + * This will account for the composable actual width so it won't be pushed off screen. + * In other words, 0% means the left edge of the composable will be at the left end of the x-axis. + * + * @param xPercentage percent offset between 0 inclusive and 1 inclusive + */ fun Modifier.offsetByPercent(xPercentage: Float) = this.then( Modifier.layout { measurable, constraints -> @@ -59,6 +67,11 @@ fun Modifier.offsetByPercent(xPercentage: Float) = }, ) +/** + * Show trickplay preview image. This composable assumes the provided URL is for the correct index. + * + * If no trickplay image is available, just the timestamp will be shown. + */ @Composable fun SeekPreviewImage( previewImageUrl: String, diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/setup/ServerList.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/setup/ServerList.kt index bf559de3..51795849 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/ui/setup/ServerList.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/setup/ServerList.kt @@ -37,6 +37,9 @@ sealed interface ServerConnectionStatus { ) : ServerConnectionStatus } +/** + * Display a list of servers plus option to add a new one + */ @Composable fun ServerList( servers: List, diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/setup/SwitchUserViewModel.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/setup/SwitchUserViewModel.kt index 73740ff6..c0815838 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/ui/setup/SwitchUserViewModel.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/setup/SwitchUserViewModel.kt @@ -165,7 +165,7 @@ class SwitchUserViewModel } quickConnectJob = - viewModelScope.launch(ExceptionHandler()) { + viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) { while (!state.authenticated) { delay(5_000L) state = diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/setup/UserList.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/setup/UserList.kt index 87bf3822..8d9baa63 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/ui/setup/UserList.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/setup/UserList.kt @@ -26,6 +26,9 @@ import com.github.damontecres.dolphin.ui.FontAwesome import com.github.damontecres.dolphin.ui.components.DialogItem import com.github.damontecres.dolphin.ui.components.DialogPopup +/** + * Display a list of users plus option to add a new one or switch servers + */ @Composable fun UserList( users: List, diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/theme/Theme.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/theme/Theme.kt index 1f2659a7..e2811f81 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/ui/theme/Theme.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/theme/Theme.kt @@ -1,6 +1,5 @@ package com.github.damontecres.dolphin.ui.theme -import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.Immutable import androidx.compose.ui.graphics.Color @@ -29,7 +28,7 @@ val unspecified_scheme = @Composable fun DolphinTheme( - darkTheme: Boolean = isSystemInDarkTheme(), + darkTheme: Boolean = true, appThemeColors: AppThemeColors = AppThemeColors.PURPLE, content: @Composable () -> Unit, ) { diff --git a/app/src/main/java/com/github/damontecres/dolphin/util/ApiRequestPager.kt b/app/src/main/java/com/github/damontecres/dolphin/util/ApiRequestPager.kt index 788ecbef..2648db01 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/util/ApiRequestPager.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/util/ApiRequestPager.kt @@ -27,7 +27,139 @@ import org.jellyfin.sdk.model.api.request.GetSuggestionsRequest import timber.log.Timber import java.util.function.Predicate +/** + * Handles paging for an API request. You must call [init] prior to use. + * + * Initially, items returned will be null, but requesting the items triggers API calls in the given [CoroutineScope]. + * Since the items are stored in [androidx.compose.runtime.MutableState], it will automatically trigger recompositions when the items are ultimately fetched. + * + * Finally, items are cached allow for backward and forward scrolling. + */ +class ApiRequestPager( + val api: ApiClient, + val request: T, + val requestHandler: RequestHandler, + private val scope: CoroutineScope, + private val pageSize: Int = DEFAULT_PAGE_SIZE, + cacheSize: Long = 8, + private val useSeriesForPrimary: Boolean = false, +) : AbstractList(), + BlockingList { + private var items by mutableStateOf(ItemList(0, pageSize, mapOf())) + private var totalCount by mutableIntStateOf(-1) + private val mutex = Mutex() + private val cachedPages = + CacheBuilder + .newBuilder() + .maximumSize(cacheSize) + .build>() + + suspend fun init(): ApiRequestPager { + if (totalCount < 0) { + val newRequest = requestHandler.prepare(request, 0, 1, true) + val result = requestHandler.execute(api, newRequest).content + totalCount = result.totalRecordCount + } + return this + } + + override operator fun get(index: Int): BaseItem? { + if (index in 0..): Int { + init() + for (i in 0 until totalCount) { + val currentItem = getBlocking(i) + if (currentItem != null && predicate.test(currentItem)) { + return i + } + } + return -1 + } + + override val size: Int + get() = totalCount + + private fun fetchPage(position: Int): Job = + scope.launch(ExceptionHandler() + Dispatchers.IO) { + mutex.withLock { + val pageNumber = position / pageSize + if (cachedPages.getIfPresent(pageNumber) == null) { + if (DEBUG) Timber.v("fetchPage: $pageNumber") + val newRequest = + requestHandler.prepare( + request, + pageNumber * pageSize, + pageSize, + false, + ) + val result = requestHandler.execute(api, newRequest).content + val data = result.items.map { BaseItem.from(it, api, useSeriesForPrimary) } + cachedPages.put(pageNumber, data) + items = ItemList(totalCount, pageSize, cachedPages.asMap()) + } + } + } + + companion object { + private const val DEBUG = false + } + + class ItemList( + val size: Int, + val pageSize: Int, + val pages: Map>, + ) { + operator fun get(position: Int): T? { + val page = position / pageSize + val data = pages[page] + if (data != null) { + val index = position % pageSize + if (index in data.indices) { + return data[index] + } else { + // This can happen when items are removed while scrolling + Timber.w( + "Index $index not in data: position=$position, data.size=${data.size}", + ) + return null + } + } else { + return null + } + } + } +} + +/** + * Specifies how the [ApiRequestPager] should prepare and execute API calls + */ interface RequestHandler { + /** + * Prepare the given request with the specified parameters (eg which page to fetch) + */ fun prepare( request: T, startIndex: Int, @@ -35,6 +167,9 @@ interface RequestHandler { enableTotalRecordCount: Boolean, ): T + /** + * Execute the given request + */ suspend fun execute( api: ApiClient, request: T, @@ -136,121 +271,3 @@ val GetSuggestionsRequestHandler = request: GetSuggestionsRequest, ): Response = api.suggestionsApi.getSuggestions(request) } - -class ApiRequestPager( - val api: ApiClient, - val request: T, - val requestHandler: RequestHandler, - private val scope: CoroutineScope, - private val pageSize: Int = DEFAULT_PAGE_SIZE, - cacheSize: Long = 8, - private val useSeriesForPrimary: Boolean = false, -) : AbstractList(), - BlockingList { - private var items by mutableStateOf(ItemList(0, pageSize, mapOf())) - private var totalCount by mutableIntStateOf(-1) - private val mutex = Mutex() - private val cachedPages = - CacheBuilder - .newBuilder() - .maximumSize(cacheSize) - .build>() - - suspend fun init(): ApiRequestPager { - if (totalCount < 0) { - val newRequest = requestHandler.prepare(request, 0, 1, true) - val result = requestHandler.execute(api, newRequest).content - totalCount = result.totalRecordCount - } - return this - } - - override operator fun get(index: Int): BaseItem? { - if (index in 0..): Int { - init() - for (i in 0 until totalCount) { - val currentItem = getBlocking(i) - if (currentItem != null && predicate.test(currentItem)) { - return i - } - } - return -1 - } - - override val size: Int - get() = totalCount - - private fun fetchPage(position: Int): Job = - scope.launch(ExceptionHandler() + Dispatchers.IO) { - mutex.withLock { - val pageNumber = position / pageSize - if (cachedPages.getIfPresent(pageNumber) == null) { - if (DEBUG) Timber.v("fetchPage: $pageNumber") - val newRequest = - requestHandler.prepare( - request, - pageNumber * pageSize, - pageSize, - false, - ) - val result = requestHandler.execute(api, newRequest).content - val data = result.items.map { BaseItem.from(it, api, useSeriesForPrimary) } - cachedPages.put(pageNumber, data) - items = ItemList(totalCount, pageSize, cachedPages.asMap()) - } - } - } - - companion object { - private const val DEBUG = false - } - - class ItemList( - val size: Int, - val pageSize: Int, - val pages: Map>, - ) { - operator fun get(position: Int): T? { - val page = position / pageSize - val data = pages[page] - if (data != null) { - val index = position % pageSize - if (index in data.indices) { - return data[index] - } else { - // This can happen when items are removed while scrolling - Timber.w( - "Index $index not in data: position=$position, data.size=${data.size}", - ) - return null - } - } else { - return null - } - } - } -} diff --git a/app/src/main/java/com/github/damontecres/dolphin/util/ExceptionHandler.kt b/app/src/main/java/com/github/damontecres/dolphin/util/ExceptionHandler.kt index 6a9b4f50..980a6d14 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/util/ExceptionHandler.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/util/ExceptionHandler.kt @@ -8,9 +8,7 @@ import timber.log.Timber import kotlin.coroutines.CoroutineContext /** - * A general [CoroutineExceptionHandler] which can optionally show [Toast]s when an exception is thrown - * - * Note: a toast will be shown for each parameter given, up to three! + * A general [CoroutineExceptionHandler] which can optionally show a [Toast] when an exception is thrown * * @param autoToast automatically show a toast with the exception's message */ diff --git a/app/src/main/java/com/github/damontecres/dolphin/util/Formatting.kt b/app/src/main/java/com/github/damontecres/dolphin/util/Formatting.kt index c61d7fc1..0201d794 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/util/Formatting.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/util/Formatting.kt @@ -5,6 +5,9 @@ import org.jellyfin.sdk.model.api.BaseItemDto import java.time.LocalDateTime import java.time.format.DateTimeFormatter +/** + * Format a [LocalDateTime] as `Aug 24, 2000` + */ fun formatDateTime(dateTime: LocalDateTime): String = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { val formatter = DateTimeFormatter.ofPattern("MMM d, yyyy") @@ -15,6 +18,9 @@ fun formatDateTime(dateTime: LocalDateTime): String = dateTime.toString() } +/** + * If the item has season & episode info, format as `S# E#` + */ val BaseItemDto.seasonEpisode: String? get() = if (parentIndexNumber != null && indexNumber != null && indexNumberEnd != null) { @@ -25,6 +31,9 @@ val BaseItemDto.seasonEpisode: String? null } +/** + * If the item has season & episode info, format padded as `S## E##` + */ val BaseItemDto.seasonEpisodePadded: String? get() = if (parentIndexNumber != null && indexNumber != null) { diff --git a/app/src/main/java/com/github/damontecres/dolphin/util/LoadingExceptionHandler.kt b/app/src/main/java/com/github/damontecres/dolphin/util/LoadingExceptionHandler.kt index 9eb7bb87..d258b783 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/util/LoadingExceptionHandler.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/util/LoadingExceptionHandler.kt @@ -11,6 +11,9 @@ import kotlinx.coroutines.withContext import timber.log.Timber import kotlin.coroutines.CoroutineContext +/** + * A [CoroutineExceptionHandler] that is aware of a [LoadingState] and will set it to error if an exception occurs in the coroutine. + */ class LoadingExceptionHandler( private val loadingState: MutableLiveData, private val errorMessage: String?, diff --git a/app/src/main/java/com/github/damontecres/dolphin/util/LoadingState.kt b/app/src/main/java/com/github/damontecres/dolphin/util/LoadingState.kt index 21f35baa..3007ba6c 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/util/LoadingState.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/util/LoadingState.kt @@ -1,5 +1,8 @@ package com.github.damontecres.dolphin.util +/** + * Generic state for loading something from the API + */ sealed interface LoadingState { object Loading : LoadingState diff --git a/app/src/main/java/com/github/damontecres/dolphin/util/TrackSupport.kt b/app/src/main/java/com/github/damontecres/dolphin/util/TrackSupport.kt index cf9393b4..59fdcfc2 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/util/TrackSupport.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/util/TrackSupport.kt @@ -10,6 +10,9 @@ import androidx.media3.common.util.UnstableApi import timber.log.Timber import java.util.Locale +/** + * Represents a track in a media file along with information about whether the device can handle it natively or not + */ data class TrackSupport( val id: String?, val type: TrackType,