Add some javadocs and comments

This commit is contained in:
Damontecres 2025-10-10 14:49:13 -04:00
parent 405faa388e
commit 59b3ef2644
No known key found for this signature in database
65 changed files with 539 additions and 202 deletions

View file

@ -1,12 +1,12 @@
# Dolphin - an OSS Android TV client for Jellyfin # 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. 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 ## 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. 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 - 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 - Show Movie/TV Show titles when browsing libraries
- Play TV Show theme music, if available - 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 - Using D-Pad left/right for seeking during playback
- Subtly show playback position while seeking w/ D-Pad
- Quickly access video chapters during playback - 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 ## Installation
Downloader Code: `Dolphin CODE`
1. Enable side-loading "unknown" apps 1. Enable side-loading "unknown" apps
- https://androidtvnews.com/unknown-sources-chromecast-google-tv/ - https://androidtvnews.com/unknown-sources-chromecast-google-tv/
- https://www.xda-developers.com/how-to-sideload-apps-android-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 ### 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. 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 ## 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! 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! 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 ## Additional screenshots
TODO TODO

View file

@ -100,7 +100,7 @@ class MainActivity : AppCompatActivity() {
remember(appPreferences) { remember(appPreferences) {
createDeviceProfile(this@MainActivity, preferences, false) createDeviceProfile(this@MainActivity, preferences, false)
} }
val backStack = rememberNavBackStack(Destination.Main()) val backStack = rememberNavBackStack(Destination.Home())
navigationManager.backStack = backStack navigationManager.backStack = backStack
if (server != null && user != null) { if (server != null && user != null) {

View file

@ -17,6 +17,9 @@ import timber.log.Timber
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
/**
* Handles managing the current server & user as well as adding & removing new ones
*/
@Singleton @Singleton
class ServerRepository class ServerRepository
@Inject @Inject
@ -33,6 +36,11 @@ class ServerRepository
private var _currentUserDto by mutableStateOf<UserDto?>(null) private var _currentUserDto by mutableStateOf<UserDto?>(null)
val currentUserDto get() = _currentUserDto 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) { suspend fun addAndChangeServer(server: JellyfinServer) {
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
serverDao.addServer(server) serverDao.addServer(server)
@ -40,11 +48,20 @@ class ServerRepository
changeServer(server) changeServer(server)
} }
/**
* Updates the [ApiClient] to the server's URL
*
* The current user is removed
*/
fun changeServer(server: JellyfinServer) { fun changeServer(server: JellyfinServer) {
apiClient.update(baseUrl = server.url, accessToken = null) apiClient.update(baseUrl = server.url, accessToken = null)
_currentServer = server _currentServer = server
_currentUser = null
} }
/**
* Saves the server & User to the app database and updates the [ApiClient] to use this server & user
*/
suspend fun changeUser( suspend fun changeUser(
server: JellyfinServer, server: JellyfinServer,
user: JellyfinUser, 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( suspend fun restoreSession(
serverId: String, serverId: String,
userId: String, userId: String,
@ -114,6 +134,9 @@ class ServerRepository
return false return false
} }
/**
* Given a successful [AuthenticationResult], switch to the user that just authenticated
*/
suspend fun changeUser( suspend fun changeUser(
serverUrl: String, serverUrl: String,
authenticationResult: AuthenticationResult, authenticationResult: AuthenticationResult,

View file

@ -13,7 +13,7 @@ import kotlin.time.Duration.Companion.minutes
import kotlin.time.Duration.Companion.seconds 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. * @param T The type of the preference value.
*/ */

View file

@ -3,6 +3,9 @@ package com.github.damontecres.dolphin.preferences
import org.jellyfin.sdk.model.api.SubtitlePlaybackMode import org.jellyfin.sdk.model.api.SubtitlePlaybackMode
import org.jellyfin.sdk.model.api.UserConfiguration import org.jellyfin.sdk.model.api.UserConfiguration
/**
* A combination of the app-specific preferences and server-side user configuration
*/
data class UserPreferences( data class UserPreferences(
val appPreferences: AppPreferences, val appPreferences: AppPreferences,
val userConfig: UserConfiguration, val userConfig: UserConfiguration,

View file

@ -14,6 +14,9 @@ import coil3.util.DebugLogger
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import kotlin.time.ExperimentalTime import kotlin.time.ExperimentalTime
/**
* Configure Coil image loading
*/
@OptIn(ExperimentalTime::class, ExperimentalCoilApi::class) @OptIn(ExperimentalTime::class, ExperimentalCoilApi::class)
@Composable @Composable
fun CoilConfig( fun CoilConfig(

View file

@ -6,6 +6,9 @@ import coil3.size.Size
import coil3.size.pxOrElse import coil3.size.pxOrElse
import coil3.transform.Transformation import coil3.transform.Transformation
/**
* A Coil [Transformation] that extracts a subimage from a trickplay image
*/
class CoilTrickplayTransformation( class CoilTrickplayTransformation(
val targetWidth: Int, val targetWidth: Int,
val targetHeight: Int, val targetHeight: Int,

View file

@ -39,6 +39,8 @@ import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.minutes import kotlin.time.Duration.Companion.minutes
import kotlin.time.Duration.Companion.seconds import kotlin.time.Duration.Companion.seconds
// This file is a dumping ground mostly for extensions
@OptIn(ExperimentalContracts::class) @OptIn(ExperimentalContracts::class)
fun CharSequence?.isNotNullOrBlank(): Boolean { fun CharSequence?.isNotNullOrBlank(): Boolean {
contract { contract {
@ -180,12 +182,21 @@ fun playOnClickSound(
audioManager.playSoundEffect(effectType) audioManager.playSoundEffect(effectType)
} }
/**
* Rounds a [Duration] to nearest whole minute
*/
val Duration.roundMinutes: Duration val Duration.roundMinutes: Duration
get() = (this + 30.seconds).inWholeMinutes.minutes get() = (this + 30.seconds).inWholeMinutes.minutes
/**
* Rounds a [Duration] to nearest whole second
*/
val Duration.roundSeconds: Duration val Duration.roundSeconds: Duration
get() = (this + 30.milliseconds).inWholeSeconds.seconds get() = (this + 30.milliseconds).inWholeSeconds.seconds
/**
* Gets the user's playback position as a [Duration]
*/
val BaseItemDto.timeRemaining: Duration? val BaseItemDto.timeRemaining: Duration?
get() = get() =
userData?.playbackPositionTicks?.let { 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)) 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)) 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) @OptIn(ExperimentalContracts::class)
inline fun <T : Collection<*>, R> T.letNotEmpty(block: (T) -> R): R? { inline fun <T : Collection<*>, R> T.letNotEmpty(block: (T) -> R): R? {
contract { 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? { fun Context.findActivity(): Activity? {
if (this is Activity) { if (this is Activity) {
return this return this
@ -246,6 +269,9 @@ fun Context.findActivity(): Activity? {
return null return null
} }
/**
* Keep the screen on for an [Activity]. Often used with [findActivity].
*/
fun Activity.keepScreenOn(keep: Boolean) { fun Activity.keepScreenOn(keep: Boolean) {
Timber.v("Keep screen on: $keep") Timber.v("Keep screen on: $keep")
if (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( fun logCoilError(
url: String?, url: String?,
errorResult: ErrorResult, errorResult: ErrorResult,

View file

@ -10,8 +10,13 @@ import androidx.compose.ui.unit.dp
import com.github.damontecres.dolphin.R import com.github.damontecres.dolphin.R
import org.jellyfin.sdk.model.api.ItemFields 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)) val FontAwesome = FontFamily(Font(resId = R.font.fa_solid_900))
/**
* Colors not associated with the theme
*/
sealed class AppColors private constructor() { sealed class AppColors private constructor() {
companion object { companion object {
val TransparentBlack25 = Color(0x40000000) val TransparentBlack25 = Color(0x40000000)
@ -22,6 +27,9 @@ sealed class AppColors private constructor() {
const val DEFAULT_PAGE_SIZE = 100 const val DEFAULT_PAGE_SIZE = 100
/**
* The default [ItemFields] to fetch for most queries
*/
val DefaultItemFields = val DefaultItemFields =
listOf( listOf(
ItemFields.PRIMARY_IMAGE_ASPECT_RATIO, ItemFields.PRIMARY_IMAGE_ASPECT_RATIO,
@ -40,8 +48,8 @@ val DefaultButtonPadding =
bottom = 10.dp / 2, bottom = 10.dp / 2,
) )
object Cards { object CardDefaults {
val defaultHeight2x3 = 180.dp val height2x3 = 180.dp
val playedPercentHeight = 6.dp val playedPercentHeight = 6.dp
} }

View file

@ -32,9 +32,12 @@ import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text import androidx.tv.material3.Text
import coil3.compose.AsyncImage import coil3.compose.AsyncImage
import com.github.damontecres.dolphin.ui.AppColors 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 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 @Composable
fun BannerCard( fun BannerCard(
name: String?, name: String?,
@ -128,7 +131,7 @@ fun BannerCard(
.background( .background(
MaterialTheme.colorScheme.tertiary, MaterialTheme.colorScheme.tertiary,
).clip(RectangleShape) ).clip(RectangleShape)
.height(Cards.playedPercentHeight) .height(CardDefaults.playedPercentHeight)
.fillMaxWidth((playPercent / 100).toFloat()), .fillMaxWidth((playPercent / 100).toFloat()),
) )
} }

View file

@ -21,6 +21,9 @@ import com.github.damontecres.dolphin.ui.AppColors
import com.github.damontecres.dolphin.ui.roundSeconds import com.github.damontecres.dolphin.ui.roundSeconds
import kotlin.time.Duration import kotlin.time.Duration
/**
* Card for a [com.github.damontecres.dolphin.data.model.Chapter]
*/
@Composable @Composable
fun ChapterCard( fun ChapterCard(
name: String?, name: String?,

View file

@ -28,6 +28,9 @@ import com.github.damontecres.dolphin.data.model.BaseItem
import com.github.damontecres.dolphin.ui.enableMarquee import com.github.damontecres.dolphin.ui.enableMarquee
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
/**
* Card for use in [com.github.damontecres.dolphin.ui.detail.CardGrid]
*/
@Composable @Composable
fun GridCard( fun GridCard(
item: BaseItem?, item: BaseItem?,

View file

@ -48,7 +48,7 @@ import androidx.tv.material3.Text
import coil3.compose.AsyncImage import coil3.compose.AsyncImage
import com.github.damontecres.dolphin.R import com.github.damontecres.dolphin.R
import com.github.damontecres.dolphin.data.model.BaseItem 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.FontAwesome
import com.github.damontecres.dolphin.ui.enableMarquee import com.github.damontecres.dolphin.ui.enableMarquee
import com.github.damontecres.dolphin.ui.ifElse import com.github.damontecres.dolphin.ui.ifElse
@ -59,6 +59,7 @@ import kotlinx.coroutines.delay
import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.BaseItemKind
@Composable @Composable
@Deprecated("Old style, prefer SeasonCard or other Card")
fun ItemCard( fun ItemCard(
item: BaseItem?, item: BaseItem?,
onClick: () -> Unit, onClick: () -> Unit,
@ -281,7 +282,7 @@ fun ItemCardImage(
.background( .background(
MaterialTheme.colorScheme.tertiary, MaterialTheme.colorScheme.tertiary,
).clip(RectangleShape) ).clip(RectangleShape)
.height(Cards.playedPercentHeight) .height(CardDefaults.playedPercentHeight)
.fillMaxWidth((percent / 100.0).toFloat()), .fillMaxWidth((percent / 100.0).toFloat()),
) )
} }

View file

@ -13,6 +13,7 @@ import androidx.tv.material3.Text
import com.github.damontecres.dolphin.ui.ifElse import com.github.damontecres.dolphin.ui.ifElse
@Composable @Composable
@Deprecated("Cards should handle nulls natively")
fun NullCard( fun NullCard(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
cardWidth: Dp? = null, cardWidth: Dp? = null,

View file

@ -25,6 +25,9 @@ import com.github.damontecres.dolphin.data.model.Person
import com.github.damontecres.dolphin.ui.enableMarquee import com.github.damontecres.dolphin.ui.enableMarquee
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
/**
* A Card for a [Person] such as an actor or director
*/
@Composable @Composable
fun PersonCard( fun PersonCard(
item: Person, item: Person,

View file

@ -29,6 +29,9 @@ import com.github.damontecres.dolphin.data.model.BaseItem
import com.github.damontecres.dolphin.ui.enableMarquee import com.github.damontecres.dolphin.ui.enableMarquee
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
/**
* A Card for a TV Show Season, but can generically show most items
*/
@Composable @Composable
fun SeasonCard( fun SeasonCard(
item: BaseItem?, item: BaseItem?,
@ -100,7 +103,10 @@ fun SeasonCard(
} }
Column( Column(
verticalArrangement = Arrangement.spacedBy(0.dp), verticalArrangement = Arrangement.spacedBy(0.dp),
modifier = Modifier.padding(bottom = spaceBelow).fillMaxWidth(), modifier =
Modifier
.padding(bottom = spaceBelow)
.fillMaxWidth(),
) { ) {
Text( Text(
text = dto?.name ?: "", text = dto?.name ?: "",

View file

@ -24,11 +24,14 @@ fun CircularProgress(
} }
} }
/**
* Fill the space with a loading indicator
*/
@Composable @Composable
fun LoadingPage() { fun LoadingPage(modifier: Modifier = Modifier) {
Box( Box(
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
modifier = Modifier.fillMaxSize(), modifier = modifier.fillMaxSize(),
) { ) {
CircularProgressIndicator( CircularProgressIndicator(
color = MaterialTheme.colorScheme.border, color = MaterialTheme.colorScheme.border,

View file

@ -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.AnimatedVisibility
import androidx.compose.animation.fadeIn 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.preferences.UserPreferences
import com.github.damontecres.dolphin.ui.DefaultItemFields import com.github.damontecres.dolphin.ui.DefaultItemFields
import com.github.damontecres.dolphin.ui.OneTimeLaunchedEffect 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.MovieSortOptions
import com.github.damontecres.dolphin.ui.data.SeriesSortOptions import com.github.damontecres.dolphin.ui.data.SeriesSortOptions
import com.github.damontecres.dolphin.ui.data.SortAndDirection import com.github.damontecres.dolphin.ui.data.SortAndDirection
import com.github.damontecres.dolphin.ui.data.VideoSortOptions 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.nav.Destination
import com.github.damontecres.dolphin.ui.tryRequestFocus import com.github.damontecres.dolphin.ui.tryRequestFocus
import com.github.damontecres.dolphin.util.ApiRequestPager 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 @Composable
fun CollectionFolderDetails( fun CollectionFolderGrid(
preferences: UserPreferences, preferences: UserPreferences,
destination: Destination.MediaItem, destination: Destination.MediaItem,
onClickItem: (BaseItem) -> Unit, onClickItem: (BaseItem) -> Unit,
@ -181,7 +185,7 @@ fun CollectionFolderDetails(
LoadingState.Loading -> LoadingPage() LoadingState.Loading -> LoadingPage()
LoadingState.Success -> { LoadingState.Success -> {
pager?.let { pager -> pager?.let { pager ->
CollectionDetails( CollectionFolderGridContent(
preferences, preferences,
library!!, library!!,
item!!, item!!,
@ -202,7 +206,7 @@ fun CollectionFolderDetails(
} }
@Composable @Composable
fun CollectionDetails( fun CollectionFolderGridContent(
preferences: UserPreferences, preferences: UserPreferences,
library: Library, library: Library,
item: BaseItem, item: BaseItem,

View file

@ -2,7 +2,6 @@ package com.github.damontecres.dolphin.ui.components
import android.view.KeyEvent import android.view.KeyEvent
import androidx.annotation.StringRes import androidx.annotation.StringRes
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.focusable import androidx.compose.foundation.focusable
import androidx.compose.foundation.gestures.scrollBy import androidx.compose.foundation.gestures.scrollBy
@ -58,6 +57,9 @@ import com.github.damontecres.dolphin.util.ExceptionHandler
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
/**
* Parameters for rendering a [DialogPopup]
*/
data class DialogParams( data class DialogParams(
val fromLongClick: Boolean, val fromLongClick: Boolean,
val title: String, 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 @Composable
fun DialogPopup( fun DialogPopup(
showDialog: Boolean, showDialog: Boolean,
@ -230,6 +236,9 @@ fun DialogPopup(
} }
} }
/**
* A dialog that can be scrolled, typically for longer text content
*/
@Composable @Composable
fun ScrollableDialog( fun ScrollableDialog(
onDismissRequest: () -> Unit, onDismissRequest: () -> Unit,
@ -282,6 +291,9 @@ fun ScrollableDialog(
} }
} }
/**
* Shows a basic dialog
*/
@Composable @Composable
fun BasicDialog( fun BasicDialog(
onDismissRequest: () -> Unit, onDismissRequest: () -> Unit,
@ -306,6 +318,9 @@ fun BasicDialog(
} }
} }
/**
* Shows a confirmation dialog
*/
@Composable @Composable
fun ConfirmDialog( fun ConfirmDialog(
title: String, title: String,
@ -323,6 +338,9 @@ fun ConfirmDialog(
}, },
) )
/**
* Content for a confirmation dialog
*/
@Composable @Composable
fun ConfirmDialogContent( fun ConfirmDialogContent(
title: String, title: String,

View file

@ -1,6 +1,7 @@
package com.github.damontecres.dolphin.ui.components package com.github.damontecres.dolphin.ui.components
import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.defaultMinSize import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.height 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.Icon
import androidx.tv.material3.MaterialTheme import androidx.tv.material3.MaterialTheme
import com.github.damontecres.dolphin.R 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) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun EditTextBox( fun EditTextBox(
@ -138,6 +145,9 @@ fun EditTextBox(
} }
} }
/**
* And [EditTextBox] styles for searches
*/
@Composable @Composable
fun SearchEditTextBox( fun SearchEditTextBox(
value: String, value: String,
@ -176,3 +186,21 @@ fun SearchEditTextBox(
height, height,
) )
} }
@PreviewTvSpec
@Composable
private fun EditTextBoxPreview() {
DolphinTheme {
Column {
EditTextBox(
value = "string",
onValueChange = {},
)
SearchEditTextBox(
value = "search query",
onValueChange = {},
onSearchClick = { },
)
}
}
}

View file

@ -10,6 +10,9 @@ import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text import androidx.tv.material3.Text
import com.github.damontecres.dolphin.util.LoadingState import com.github.damontecres.dolphin.util.LoadingState
/**
* Displays an error message and/or exception
*/
@Composable @Composable
fun ErrorMessage( fun ErrorMessage(
message: String?, message: String?,

View file

@ -26,6 +26,9 @@ import com.github.damontecres.dolphin.ui.FontAwesome
import com.github.damontecres.dolphin.ui.ifElse import com.github.damontecres.dolphin.ui.ifElse
import kotlin.time.Duration import kotlin.time.Duration
/**
* An icon button typically used in a row for playing media
*/
@Composable @Composable
fun PlayButton( fun PlayButton(
@StringRes title: Int, @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 @Composable
fun ExpandablePlayButton( fun ExpandablePlayButton(
@StringRes title: Int, @StringRes title: Int,
@ -85,6 +93,9 @@ fun ExpandablePlayButton(
} }
} }
/**
* Similar to [ExpandablePlayButton], but uses a [FontAwesome] string instead of an Icon
*/
@Composable @Composable
fun ExpandableFaButton( fun ExpandableFaButton(
@StringRes title: Int, @StringRes title: Int,

View file

@ -33,6 +33,9 @@ import com.github.damontecres.dolphin.ui.tryRequestFocus
import kotlin.time.Duration import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds import kotlin.time.Duration.Companion.seconds
/**
* Standard row of [PlayButton] including Play (or Resume & Restart) & More
*/
@Composable @Composable
fun PlayButtons( fun PlayButtons(
resumePosition: Duration, resumePosition: Duration,
@ -116,6 +119,9 @@ fun PlayButtons(
} }
} }
/**
* Standard row of [ExpandablePlayButton] including Play (or Resume & Restart), Mark played, & More
*/
@Composable @Composable
fun ExpandablePlayButtons( fun ExpandablePlayButtons(
resumePosition: Duration, resumePosition: Duration,

View file

@ -63,6 +63,9 @@ val EmptyStarColor = Color(0x2AFFC700)
val ratingBarHeight: Dp = 32.dp val ratingBarHeight: Dp = 32.dp
/**
* Shows a rating out of 5 stars
*/
@Composable @Composable
fun StarRating( fun StarRating(
rating100: Int, rating100: Int,

View file

@ -123,6 +123,9 @@ class RecommendedMovieViewModel
} }
} }
/**
* The "recommended" tab of a movie library
*/
@Composable @Composable
fun RecommendedMovie( fun RecommendedMovie(
preferences: UserPreferences, preferences: UserPreferences,

View file

@ -135,6 +135,9 @@ class RecommendedTvShowViewModel
} }
} }
/**
* The "recommended" tab of a TV show library
*/
@Composable @Composable
fun RecommendedTvShow( fun RecommendedTvShow(
preferences: UserPreferences, preferences: UserPreferences,

View file

@ -24,6 +24,9 @@ import androidx.compose.ui.unit.dp
import androidx.tv.material3.MaterialTheme import androidx.tv.material3.MaterialTheme
import com.github.damontecres.dolphin.ui.handleDPadKeyEvents import com.github.damontecres.dolphin.ui.handleDPadKeyEvents
/**
* A TV capable control for choosing a value
*/
@Composable @Composable
fun SliderBar( fun SliderBar(
value: Long, value: Long,

View file

@ -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.ItemSortBy
import org.jellyfin.sdk.model.api.SortOrder 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 @Composable
fun SortByButton( fun SortByButton(
sortOptions: List<ItemSortBy>, sortOptions: List<ItemSortBy>,

View file

@ -22,6 +22,9 @@ import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Switch import androidx.tv.material3.Switch
import androidx.tv.material3.Text import androidx.tv.material3.Text
/**
* A labeled [Switch], but the entire composable is focusable & clickable
*/
@Composable @Composable
fun SwitchWithLabel( fun SwitchWithLabel(
label: String, label: String,

View file

@ -41,6 +41,9 @@ import androidx.tv.material3.Text
import com.github.damontecres.dolphin.ui.ifElse import com.github.damontecres.dolphin.ui.ifElse
import com.github.damontecres.dolphin.ui.playOnClickSound import com.github.damontecres.dolphin.ui.playOnClickSound
/**
* An optionally clickable composable that displays a Key-Value pair vertically
*/
@Composable @Composable
fun TitleValueText( fun TitleValueText(
title: String, title: String,

View file

@ -10,6 +10,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import com.github.damontecres.dolphin.preferences.UserPreferences 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.nav.Destination
import com.github.damontecres.dolphin.ui.preferences.PreferencesViewModel import com.github.damontecres.dolphin.ui.preferences.PreferencesViewModel
@ -21,7 +22,7 @@ fun CollectionFolderGeneric(
preferencesViewModel: PreferencesViewModel = hiltViewModel(), preferencesViewModel: PreferencesViewModel = hiltViewModel(),
) { ) {
var showHeader by remember { mutableStateOf(true) } var showHeader by remember { mutableStateOf(true) }
CollectionFolderDetails( CollectionFolderGrid(
preferences = preferences, preferences = preferences,
onClickItem = { preferencesViewModel.navigationManager.navigateTo(it.destination()) }, onClickItem = { preferencesViewModel.navigationManager.navigateTo(it.destination()) },
destination = destination, destination = destination,

View file

@ -32,6 +32,7 @@ import androidx.tv.material3.Text
import com.github.damontecres.dolphin.data.model.BaseItem import com.github.damontecres.dolphin.data.model.BaseItem
import com.github.damontecres.dolphin.preferences.UserPreferences import com.github.damontecres.dolphin.preferences.UserPreferences
import com.github.damontecres.dolphin.preferences.rememberTab 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.ErrorMessage
import com.github.damontecres.dolphin.ui.components.RecommendedMovie import com.github.damontecres.dolphin.ui.components.RecommendedMovie
import com.github.damontecres.dolphin.ui.ifElse import com.github.damontecres.dolphin.ui.ifElse
@ -150,7 +151,7 @@ fun CollectionFolderMovie(
) )
} }
1 -> { 1 -> {
CollectionFolderDetails( CollectionFolderGrid(
preferences = preferences, preferences = preferences,
onClickItem = onClickItem, onClickItem = onClickItem,
destination = destination, destination = destination,

View file

@ -32,6 +32,7 @@ import androidx.tv.material3.Text
import com.github.damontecres.dolphin.data.model.BaseItem import com.github.damontecres.dolphin.data.model.BaseItem
import com.github.damontecres.dolphin.preferences.UserPreferences import com.github.damontecres.dolphin.preferences.UserPreferences
import com.github.damontecres.dolphin.preferences.rememberTab 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.ErrorMessage
import com.github.damontecres.dolphin.ui.components.RecommendedTvShow import com.github.damontecres.dolphin.ui.components.RecommendedTvShow
import com.github.damontecres.dolphin.ui.ifElse import com.github.damontecres.dolphin.ui.ifElse
@ -150,7 +151,7 @@ fun CollectionFolderTv(
) )
} }
1 -> { 1 -> {
CollectionFolderDetails( CollectionFolderGrid(
preferences = preferences, preferences = preferences,
destination = destination, destination = destination,
showTitle = false, showTitle = false,

View file

@ -19,6 +19,9 @@ import org.jellyfin.sdk.model.api.ImageType
import timber.log.Timber import timber.log.Timber
import java.util.UUID import java.util.UUID
/**
* Basic [ViewModel] for a single fetchable item from the API
*/
abstract class ItemViewModel<T : DolphinModel>( abstract class ItemViewModel<T : DolphinModel>(
val api: ApiClient, val api: ApiClient,
) : ViewModel() { ) : ViewModel() {
@ -57,6 +60,9 @@ abstract class ItemViewModel<T : DolphinModel>(
): String? = api.imageApi.getItemImageUrl(itemId, type) ): 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<T : DolphinModel>( abstract class LoadingItemViewModel<T : DolphinModel>(
api: ApiClient, api: ApiClient,
) : ItemViewModel<T>(api) { ) : ItemViewModel<T>(api) {

View file

@ -1,6 +1,5 @@
package com.github.damontecres.dolphin.ui.detail package com.github.damontecres.dolphin.ui.detail
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@ -112,12 +111,3 @@ fun SeasonDetails(
} }
} }
} }
@Composable
fun EpisodeHeader(
item: BaseItem,
modifier: Modifier = Modifier,
) {
Column(modifier = modifier) {
}
}

View file

@ -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.BaseItem
import com.github.damontecres.dolphin.data.model.Person import com.github.damontecres.dolphin.data.model.Person
import com.github.damontecres.dolphin.preferences.UserPreferences 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.ItemRow
import com.github.damontecres.dolphin.ui.cards.PersonRow import com.github.damontecres.dolphin.ui.cards.PersonRow
import com.github.damontecres.dolphin.ui.cards.SeasonCard import com.github.damontecres.dolphin.ui.cards.SeasonCard
@ -241,7 +241,7 @@ fun SeriesDetailsContent(
onClick = onClick, onClick = onClick,
onLongClick = onLongClick, onLongClick = onLongClick,
modifier = mod, modifier = mod,
imageHeight = Cards.defaultHeight2x3, imageHeight = CardDefaults.height2x3,
imageWidth = Dp.Unspecified, imageWidth = Dp.Unspecified,
) )
}, },

View file

@ -84,6 +84,8 @@ class SeriesViewModel
val item = fetchItem(seriesId, potential) val item = fetchItem(seriesId, potential)
if (item != null) { if (item != null) {
val seasonsInfo = getSeasons(item) val seasonsInfo = getSeasons(item)
// If a particular season was requested, fetch those episodes, otherwise get the first season
val episodeInfo = val episodeInfo =
(season ?: seasonsInfo.items.firstOrNull()?.indexNumber) (season ?: seasonsInfo.items.firstOrNull()?.indexNumber)
?.let { seasonNum -> ?.let { seasonNum ->
@ -110,6 +112,9 @@ class SeriesViewModel
} }
} }
/**
* If the series has a theme song & app settings allow, play it
*/
@OptIn(UnstableApi::class) @OptIn(UnstableApi::class)
private fun maybePlayThemeSong(playThemeSongs: ThemeSongVolume) { private fun maybePlayThemeSong(playThemeSongs: ThemeSongVolume) {
val volume = val volume =
@ -276,6 +281,9 @@ class SeriesViewModel
} }
} }
/**
* Play whichever episode is next up for series or else the first episode
*/
fun playNextUp() { fun playNextUp() {
viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) { viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) {
val result by api.tvShowsApi.getNextUp(seriesId = seriesId) 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 { private suspend fun convertPager(pager: ApiRequestPager<*>): ItemListAndMapping {
val pairs = val pairs =
pager.mapIndexed { index, _ -> pager.mapIndexed { index, _ ->

View file

@ -39,7 +39,7 @@ import androidx.tv.material3.Text
import coil3.compose.AsyncImage import coil3.compose.AsyncImage
import com.github.damontecres.dolphin.data.model.BaseItem import com.github.damontecres.dolphin.data.model.BaseItem
import com.github.damontecres.dolphin.preferences.UserPreferences 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.BannerCard
import com.github.damontecres.dolphin.ui.cards.ItemRow import com.github.damontecres.dolphin.ui.cards.ItemRow
import com.github.damontecres.dolphin.ui.components.DotSeparatedRow import com.github.damontecres.dolphin.ui.components.DotSeparatedRow
@ -195,7 +195,7 @@ fun HomePageContent(
Modifier.focusRequester(positionFocusRequester), Modifier.focusRequester(positionFocusRequester),
), ),
interactionSource = null, interactionSource = null,
cardHeight = Cards.defaultHeight2x3, cardHeight = CardDefaults.height2x3,
) )
}, },
) )

View file

@ -77,6 +77,7 @@ class HomeViewModel
// ) // )
// } // }
// TODO data is fetched all together which may be slow for large servers
val homeRows = val homeRows =
homeSections homeSections
.mapNotNull { section -> .mapNotNull { section ->

View file

@ -6,7 +6,6 @@ import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@ -26,12 +25,12 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.github.damontecres.dolphin.data.model.BaseItem import com.github.damontecres.dolphin.data.model.BaseItem
import com.github.damontecres.dolphin.preferences.UserPreferences 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.DefaultItemFields
import com.github.damontecres.dolphin.ui.cards.EpisodeCard import com.github.damontecres.dolphin.ui.cards.EpisodeCard
import com.github.damontecres.dolphin.ui.cards.ItemRow import com.github.damontecres.dolphin.ui.cards.ItemRow
import com.github.damontecres.dolphin.ui.cards.SeasonCard 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.isNotNullOrBlank
import com.github.damontecres.dolphin.ui.nav.NavigationManager import com.github.damontecres.dolphin.ui.nav.NavigationManager
import com.github.damontecres.dolphin.ui.tryRequestFocus import com.github.damontecres.dolphin.ui.tryRequestFocus
@ -151,17 +150,14 @@ fun SearchPage(
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) { ) {
EditTextBox( SearchEditTextBox(
value = query, value = query,
onValueChange = { onValueChange = {
query = it query = it
}, },
keyboardActions = onSearchClick = {
KeyboardActions( viewModel.search(query)
onSearch = { },
viewModel.search(query)
},
),
modifier = Modifier.focusRequester(searchFocusRequester), modifier = Modifier.focusRequester(searchFocusRequester),
) )
} }
@ -181,7 +177,7 @@ fun SearchPage(
item = item, item = item,
onClick = onClick, onClick = onClick,
onLongClick = onLongClick, onLongClick = onLongClick,
imageHeight = Cards.defaultHeight2x3, imageHeight = CardDefaults.height2x3,
modifier = mod, modifier = mod,
) )
}, },
@ -203,7 +199,7 @@ fun SearchPage(
item = item, item = item,
onClick = onClick, onClick = onClick,
onLongClick = onLongClick, onLongClick = onLongClick,
imageHeight = Cards.defaultHeight2x3, imageHeight = CardDefaults.height2x3,
modifier = mod, modifier = mod,
) )
}, },

View file

@ -14,6 +14,11 @@ import com.github.damontecres.dolphin.preferences.UserPreferences
import org.jellyfin.sdk.model.api.DeviceProfile import org.jellyfin.sdk.model.api.DeviceProfile
import timber.log.Timber 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 @Composable
fun ApplicationContent( fun ApplicationContent(
server: JellyfinServer, server: JellyfinServer,

View file

@ -13,6 +13,11 @@ import kotlinx.serialization.UseSerializers
import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.BaseItemKind
import java.util.UUID 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 @Serializable
sealed class Destination( sealed class Destination(
val fullScreen: Boolean = false, val fullScreen: Boolean = false,
@ -24,7 +29,7 @@ sealed class Destination(
data object UserList : Destination(true) data object UserList : Destination(true)
@Serializable @Serializable
data class Main( data class Home(
val id: Long = 0L, val id: Long = 0L,
) : Destination() ) : Destination()

View file

@ -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.detail.series.SeriesOverview
import com.github.damontecres.dolphin.ui.main.HomePage import com.github.damontecres.dolphin.ui.main.HomePage
import com.github.damontecres.dolphin.ui.main.SearchPage 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.preferences.PreferencesPage
import com.github.damontecres.dolphin.ui.setup.SwitchServerContent import com.github.damontecres.dolphin.ui.setup.SwitchServerContent
import com.github.damontecres.dolphin.ui.setup.SwitchUserContent 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.CollectionType
import org.jellyfin.sdk.model.api.DeviceProfile import org.jellyfin.sdk.model.api.DeviceProfile
/**
* Chose the page for the [Destination]
*/
@Composable @Composable
fun DestinationContent( fun DestinationContent(
destination: Destination, destination: Destination,
@ -31,14 +34,14 @@ fun DestinationContent(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
when (destination) { when (destination) {
is Destination.Main -> is Destination.Home ->
HomePage( HomePage(
preferences = preferences, preferences = preferences,
modifier = modifier, modifier = modifier,
) )
is Destination.Playback -> is Destination.Playback ->
PlaybackContent( PlaybackPage(
preferences = preferences, preferences = preferences,
deviceProfile = deviceProfile, deviceProfile = deviceProfile,
destination = destination, destination = destination,

View file

@ -102,6 +102,9 @@ class NavDrawerViewModel
} }
} }
/**
* Display the left side navigation drawer with [DestinationContent] on the right
*/
@Composable @Composable
fun NavDrawer( fun NavDrawer(
destination: Destination, destination: Destination,
@ -113,7 +116,7 @@ fun NavDrawer(
viewModel: NavDrawerViewModel = viewModel: NavDrawerViewModel =
hiltViewModel( hiltViewModel(
LocalView.current.findViewTreeViewModelStoreOwner()!!, 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) val drawerState = rememberDrawerState(DrawerValue.Closed)
@ -121,11 +124,15 @@ fun NavDrawer(
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val context = LocalContext.current val context = LocalContext.current
val drawerFocusRequester = remember { FocusRequester() } 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) drawerState.setValue(DrawerValue.Open)
drawerFocusRequester.requestFocus() drawerFocusRequester.requestFocus()
} }
val libraries by viewModel.libraries.observeAsState(listOf()) 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 selectedIndex by viewModel.selectedIndex.observeAsState(-1)
val focusRequester = remember { FocusRequester() } val focusRequester = remember { FocusRequester() }
@ -185,7 +192,7 @@ fun NavDrawer(
selected = selectedIndex == -1, selected = selectedIndex == -1,
onClick = { onClick = {
viewModel.setIndex(-1) viewModel.setIndex(-1)
if (destination is Destination.Main) { if (destination is Destination.Home) {
viewModel.navigationManager.reloadHome() viewModel.navigationManager.reloadHome()
} else { } else {
viewModel.navigationManager.goToHome() viewModel.navigationManager.goToHome()

View file

@ -4,34 +4,52 @@ import androidx.navigation3.runtime.NavKey
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
/**
* Manages navigating between pages and manages the app's back stack
*/
@Singleton @Singleton
class NavigationManager class NavigationManager
@Inject @Inject
constructor() { constructor() {
var backStack: MutableList<NavKey> = mutableListOf() var backStack: MutableList<NavKey> = mutableListOf()
/**
* Go to the specified [Destination]
*/
fun navigateTo(destination: Destination) { fun navigateTo(destination: Destination) {
backStack.add(destination) backStack.add(destination)
} }
/**
* Go to the specified [Destination], but reset the back stack to Home first
*/
fun navigateToFromDrawer(destination: Destination) { fun navigateToFromDrawer(destination: Destination) {
goToHome() goToHome()
backStack.add(destination) backStack.add(destination)
} }
/**
* Go to the previous page
*/
fun goBack() { fun goBack() {
backStack.removeLastOrNull() backStack.removeLastOrNull()
} }
/**
* Go all the way back to the home page
*/
fun goToHome() { fun goToHome() {
while (backStack.size > 1) { while (backStack.size > 1) {
backStack.removeLastOrNull() backStack.removeLastOrNull()
} }
} }
/**
* Go all the way back to the home page, and reload it from scratch
*/
fun reloadHome() { fun reloadHome() {
goToHome() goToHome()
val id = (backStack[0] as Destination.Main).id + 1 val id = (backStack[0] as Destination.Home).id + 1
backStack[0] = Destination.Main(id) backStack[0] = Destination.Home(id)
} }
} }

View file

@ -8,6 +8,11 @@ import androidx.media3.common.Player.Listener
import com.github.damontecres.dolphin.ui.findActivity import com.github.damontecres.dolphin.ui.findActivity
import com.github.damontecres.dolphin.ui.keepScreenOn 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 @Composable
fun AmbientPlayerListener(player: Player) { fun AmbientPlayerListener(player: Player) {
val context = LocalContext.current val context = LocalContext.current

View file

@ -10,6 +10,11 @@ import kotlinx.coroutines.channels.Channel.Factory.CONFLATED
import kotlinx.coroutines.flow.consumeAsFlow import kotlinx.coroutines.flow.consumeAsFlow
import kotlinx.coroutines.flow.debounce 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( class ControllerViewState internal constructor(
@param:IntRange(from = 0) @param:IntRange(from = 0)
private val hideMilliseconds: Long, private val hideMilliseconds: Long,

View file

@ -6,6 +6,8 @@ import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.key import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.type import androidx.compose.ui.input.key.type
// This file is various functions for testing KeyEvent types
fun isDirectionalDpad(event: KeyEvent): Boolean = fun isDirectionalDpad(event: KeyEvent): Boolean =
event.key == Key.DirectionUp || event.key == Key.DirectionUp ||
event.key == Key.DirectionDown || event.key == Key.DirectionDown ||

View file

@ -41,6 +41,9 @@ import com.github.damontecres.dolphin.ui.tryRequestFocus
import kotlin.time.Duration import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds import kotlin.time.Duration.Companion.seconds
/**
* Layout for showing the next up episode during playback
*/
@Composable @Composable
fun NextUpEpisode( fun NextUpEpisode(
title: String?, title: String?,
@ -169,7 +172,7 @@ fun NextUpCard(
@PreviewTvSpec @PreviewTvSpec
@Composable @Composable
fun NextUpEpisodePreview() { private fun NextUpEpisodePreview() {
DolphinTheme(true) { DolphinTheme(true) {
NextUpEpisode( NextUpEpisode(
title = "Episode Title", title = "Episode Title",

View file

@ -11,6 +11,9 @@ import com.github.damontecres.dolphin.ui.seekBack
import com.github.damontecres.dolphin.ui.seekForward import com.github.damontecres.dolphin.ui.seekForward
import kotlin.time.Duration import kotlin.time.Duration
/**
* Handles [KeyEvent]s during playback on [PlaybackPage]
*/
class PlaybackKeyHandler( class PlaybackKeyHandler(
private val player: Player, private val player: Player,
private val controlsEnabled: Boolean, private val controlsEnabled: Boolean,

View file

@ -54,6 +54,9 @@ import kotlin.time.Duration
private val titleTextSize = 28.sp private val titleTextSize = 28.sp
private val subtitleTextSize = 18.sp private val subtitleTextSize = 18.sp
/**
* The overlay during playback showing controls, seek preview image, debug info, etc
*/
@Composable @Composable
fun PlaybackOverlay( fun PlaybackOverlay(
title: String?, title: String?,
@ -103,14 +106,14 @@ fun PlaybackOverlay(
// This will be calculated after composition // This will be calculated after composition
var controllerHeight by remember { mutableStateOf(0.dp) } var controllerHeight by remember { mutableStateOf(0.dp) }
var state by remember { mutableStateOf(ViewState.CONTROLLER) } var state by remember { mutableStateOf(OverlayViewState.CONTROLLER) }
Box( Box(
modifier = modifier, modifier = modifier,
contentAlignment = Alignment.BottomCenter, contentAlignment = Alignment.BottomCenter,
) { ) {
AnimatedVisibility( AnimatedVisibility(
state == ViewState.CONTROLLER, state == OverlayViewState.CONTROLLER,
enter = slideInVertically() + fadeIn(), enter = slideInVertically() + fadeIn(),
exit = slideOutVertically() + fadeOut(), exit = slideOutVertically() + fadeOut(),
) { ) {
@ -147,7 +150,7 @@ fun PlaybackOverlay(
e.type == KeyEventType.KeyDown && isDown(e) && e.type == KeyEventType.KeyDown && isDown(e) &&
!seekBarFocused !seekBarFocused
) { ) {
state = ViewState.CHAPTERS state = OverlayViewState.CHAPTERS
true true
} }
false false
@ -157,7 +160,7 @@ fun PlaybackOverlay(
) )
} }
AnimatedVisibility( AnimatedVisibility(
state == ViewState.CHAPTERS, state == OverlayViewState.CHAPTERS,
enter = slideInVertically { it / 2 } + fadeIn(), enter = slideInVertically { it / 2 } + fadeIn(),
exit = slideOutVertically { it / 2 } + fadeOut(), exit = slideOutVertically { it / 2 } + fadeOut(),
) { ) {
@ -172,7 +175,7 @@ fun PlaybackOverlay(
.padding(8.dp) .padding(8.dp)
.onPreviewKeyEvent { e -> .onPreviewKeyEvent { e ->
if (e.type == KeyEventType.KeyUp && isUp(e)) { if (e.type == KeyEventType.KeyUp && isUp(e)) {
state = ViewState.CONTROLLER state = OverlayViewState.CONTROLLER
true true
} }
false false
@ -221,11 +224,6 @@ fun PlaybackOverlay(
} }
} }
} }
when (state) {
ViewState.CONTROLLER -> {}
ViewState.CHAPTERS -> {}
}
if (seekBarInteractionSource.collectIsFocusedAsState().value) { if (seekBarInteractionSource.collectIsFocusedAsState().value) {
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
@ -287,11 +285,17 @@ fun PlaybackOverlay(
} }
} }
enum class ViewState { /**
* The view state of the overlay
*/
enum class OverlayViewState {
CONTROLLER, CONTROLLER,
CHAPTERS, CHAPTERS,
} }
/**
* A wrapper for the playback controls to show title and other information, plus the actual controls
*/
@Composable @Composable
fun Controller( fun Controller(
title: String?, title: String?,

View file

@ -71,9 +71,12 @@ import org.jellyfin.sdk.model.extensions.ticks
import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds import kotlin.time.Duration.Companion.seconds
/**
* The actual playback page which shows media & playback controls
*/
@OptIn(UnstableApi::class) @OptIn(UnstableApi::class)
@Composable @Composable
fun PlaybackContent( fun PlaybackPage(
preferences: UserPreferences, preferences: UserPreferences,
deviceProfile: DeviceProfile, deviceProfile: DeviceProfile,
destination: Destination.Playback, 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) { if (!controllerViewState.controlsVisible && skipIndicatorDuration != 0L) {
SkipIndicator( SkipIndicator(
durationMs = skipIndicatorDuration, durationMs = skipIndicatorDuration,
@ -218,6 +222,7 @@ fun PlaybackContent(
.align(Alignment.BottomCenter) .align(Alignment.BottomCenter)
.padding(bottom = 70.dp), .padding(bottom = 70.dp),
) )
// Show a small progress bar along the bottom of the screen
val showSkipProgress = true // TODO get from preferences val showSkipProgress = true // TODO get from preferences
if (showSkipProgress) { if (showSkipProgress) {
duration?.let { duration?.let {
@ -237,6 +242,7 @@ fun PlaybackContent(
} }
} }
// The playback controls
AnimatedVisibility( AnimatedVisibility(
controllerViewState.controlsVisible, controllerViewState.controlsVisible,
Modifier, Modifier,
@ -299,6 +305,7 @@ fun PlaybackContent(
) )
} }
// Subtitles
if (!controllerViewState.controlsVisible && skipIndicatorDuration == 0L && currentPlayback?.subtitleIndex != null) { if (!controllerViewState.controlsVisible && skipIndicatorDuration == 0L && currentPlayback?.subtitleIndex != null) {
AndroidView( AndroidView(
factory = { context -> factory = { context ->
@ -320,6 +327,7 @@ fun PlaybackContent(
) )
} }
// Ask to skip intros, etc button
AnimatedVisibility( AnimatedVisibility(
currentSegment != null && !controllerViewState.controlsVisible && skipIndicatorDuration == 0L, currentSegment != null && !controllerViewState.controlsVisible && skipIndicatorDuration == 0L,
modifier = modifier =
@ -344,6 +352,7 @@ fun PlaybackContent(
} }
} }
// Next up episode
BackHandler(nextUp != null) { BackHandler(nextUp != null) {
viewModel.cancelUpNextEpisode() viewModel.cancelUpNextEpisode()
} }

View file

@ -17,6 +17,9 @@ import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text import androidx.tv.material3.Text
import com.github.damontecres.dolphin.util.TrackSupport import com.github.damontecres.dolphin.util.TrackSupport
/**
* Debug info about the current playback tracks
*/
@Composable @Composable
fun PlaybackTrackInfo( fun PlaybackTrackInfo(
trackSupport: List<TrackSupport>, trackSupport: List<TrackSupport>,

View file

@ -319,6 +319,7 @@ class PlaybackViewModel
mediaSourceId = source.id, mediaSourceId = source.id,
static = true, static = true,
tag = source.eTag, tag = source.eTag,
playSessionId = response.playSessionId,
) )
} else if (source.supportsDirectStream) { } else if (source.supportsDirectStream) {
api.createUrl(source.transcodingUrl!!) api.createUrl(source.transcodingUrl!!)
@ -522,6 +523,12 @@ class PlaybackViewModel
it.type != MediaSegmentType.UNKNOWN && currentTicks >= it.startTicks && currentTicks < it.endTicks it.type != MediaSegmentType.UNKNOWN && currentTicks >= it.startTicks && currentTicks < it.endTicks
} }
if (currentSegment != null) { if (currentSegment != null) {
Timber.d(
"Found media segment for %s: %s, %s",
currentSegment.itemId,
currentSegment.id,
currentSegment.type,
)
val behavior = val behavior =
when (currentSegment.type) { when (currentSegment.type) {
MediaSegmentType.COMMERCIAL -> prefs.skipCommercials MediaSegmentType.COMMERCIAL -> prefs.skipCommercials

View file

@ -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) = fun Modifier.offsetByPercent(xPercentage: Float) =
this.then( this.then(
Modifier.layout { measurable, constraints -> 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 @Composable
fun SeekPreviewImage( fun SeekPreviewImage(
previewImageUrl: String, previewImageUrl: String,

View file

@ -37,6 +37,9 @@ sealed interface ServerConnectionStatus {
) : ServerConnectionStatus ) : ServerConnectionStatus
} }
/**
* Display a list of servers plus option to add a new one
*/
@Composable @Composable
fun ServerList( fun ServerList(
servers: List<JellyfinServer>, servers: List<JellyfinServer>,

View file

@ -165,7 +165,7 @@ class SwitchUserViewModel
} }
quickConnectJob = quickConnectJob =
viewModelScope.launch(ExceptionHandler()) { viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) {
while (!state.authenticated) { while (!state.authenticated) {
delay(5_000L) delay(5_000L)
state = state =

View file

@ -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.DialogItem
import com.github.damontecres.dolphin.ui.components.DialogPopup import com.github.damontecres.dolphin.ui.components.DialogPopup
/**
* Display a list of users plus option to add a new one or switch servers
*/
@Composable @Composable
fun UserList( fun UserList(
users: List<JellyfinUser>, users: List<JellyfinUser>,

View file

@ -1,6 +1,5 @@
package com.github.damontecres.dolphin.ui.theme package com.github.damontecres.dolphin.ui.theme
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable import androidx.compose.runtime.Immutable
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
@ -29,7 +28,7 @@ val unspecified_scheme =
@Composable @Composable
fun DolphinTheme( fun DolphinTheme(
darkTheme: Boolean = isSystemInDarkTheme(), darkTheme: Boolean = true,
appThemeColors: AppThemeColors = AppThemeColors.PURPLE, appThemeColors: AppThemeColors = AppThemeColors.PURPLE,
content: @Composable () -> Unit, content: @Composable () -> Unit,
) { ) {

View file

@ -27,7 +27,139 @@ import org.jellyfin.sdk.model.api.request.GetSuggestionsRequest
import timber.log.Timber import timber.log.Timber
import java.util.function.Predicate 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<T>(
val api: ApiClient,
val request: T,
val requestHandler: RequestHandler<T>,
private val scope: CoroutineScope,
private val pageSize: Int = DEFAULT_PAGE_SIZE,
cacheSize: Long = 8,
private val useSeriesForPrimary: Boolean = false,
) : AbstractList<BaseItem?>(),
BlockingList<BaseItem?> {
private var items by mutableStateOf(ItemList<BaseItem>(0, pageSize, mapOf()))
private var totalCount by mutableIntStateOf(-1)
private val mutex = Mutex()
private val cachedPages =
CacheBuilder
.newBuilder()
.maximumSize(cacheSize)
.build<Int, List<BaseItem>>()
suspend fun init(): ApiRequestPager<T> {
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..<totalCount) {
val item = items[index]
if (item == null) {
fetchPage(index)
}
return item
} else {
throw IndexOutOfBoundsException("$index of $totalCount")
}
}
override suspend fun getBlocking(index: Int): BaseItem? {
if (index in 0..<totalCount) {
val item = items[index]
if (item == null) {
fetchPage(index).join()
return items[index]
}
return item
} else {
throw IndexOutOfBoundsException("$index of $totalCount")
}
}
override suspend fun indexOfBlocking(predicate: Predicate<BaseItem?>): 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<T>(
val size: Int,
val pageSize: Int,
val pages: Map<Int, List<T>>,
) {
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<T> { interface RequestHandler<T> {
/**
* Prepare the given request with the specified parameters (eg which page to fetch)
*/
fun prepare( fun prepare(
request: T, request: T,
startIndex: Int, startIndex: Int,
@ -35,6 +167,9 @@ interface RequestHandler<T> {
enableTotalRecordCount: Boolean, enableTotalRecordCount: Boolean,
): T ): T
/**
* Execute the given request
*/
suspend fun execute( suspend fun execute(
api: ApiClient, api: ApiClient,
request: T, request: T,
@ -136,121 +271,3 @@ val GetSuggestionsRequestHandler =
request: GetSuggestionsRequest, request: GetSuggestionsRequest,
): Response<BaseItemDtoQueryResult> = api.suggestionsApi.getSuggestions(request) ): Response<BaseItemDtoQueryResult> = api.suggestionsApi.getSuggestions(request)
} }
class ApiRequestPager<T>(
val api: ApiClient,
val request: T,
val requestHandler: RequestHandler<T>,
private val scope: CoroutineScope,
private val pageSize: Int = DEFAULT_PAGE_SIZE,
cacheSize: Long = 8,
private val useSeriesForPrimary: Boolean = false,
) : AbstractList<BaseItem?>(),
BlockingList<BaseItem?> {
private var items by mutableStateOf(ItemList<BaseItem>(0, pageSize, mapOf()))
private var totalCount by mutableIntStateOf(-1)
private val mutex = Mutex()
private val cachedPages =
CacheBuilder
.newBuilder()
.maximumSize(cacheSize)
.build<Int, List<BaseItem>>()
suspend fun init(): ApiRequestPager<T> {
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..<totalCount) {
val item = items[index]
if (item == null) {
fetchPage(index)
}
return item
} else {
throw IndexOutOfBoundsException("$index of $totalCount")
}
}
override suspend fun getBlocking(position: Int): BaseItem? {
if (position in 0..<totalCount) {
val item = items[position]
if (item == null) {
fetchPage(position).join()
return items[position]
}
return item
} else {
throw IndexOutOfBoundsException("$position of $totalCount")
}
}
override suspend fun indexOfBlocking(predicate: Predicate<BaseItem?>): 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<T>(
val size: Int,
val pageSize: Int,
val pages: Map<Int, List<T>>,
) {
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
}
}
}
}

View file

@ -8,9 +8,7 @@ import timber.log.Timber
import kotlin.coroutines.CoroutineContext import kotlin.coroutines.CoroutineContext
/** /**
* A general [CoroutineExceptionHandler] which can optionally show [Toast]s when an exception is thrown * A general [CoroutineExceptionHandler] which can optionally show a [Toast] when an exception is thrown
*
* Note: a toast will be shown for each parameter given, up to three!
* *
* @param autoToast automatically show a toast with the exception's message * @param autoToast automatically show a toast with the exception's message
*/ */

View file

@ -5,6 +5,9 @@ import org.jellyfin.sdk.model.api.BaseItemDto
import java.time.LocalDateTime import java.time.LocalDateTime
import java.time.format.DateTimeFormatter import java.time.format.DateTimeFormatter
/**
* Format a [LocalDateTime] as `Aug 24, 2000`
*/
fun formatDateTime(dateTime: LocalDateTime): String = fun formatDateTime(dateTime: LocalDateTime): String =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val formatter = DateTimeFormatter.ofPattern("MMM d, yyyy") val formatter = DateTimeFormatter.ofPattern("MMM d, yyyy")
@ -15,6 +18,9 @@ fun formatDateTime(dateTime: LocalDateTime): String =
dateTime.toString() dateTime.toString()
} }
/**
* If the item has season & episode info, format as `S# E#`
*/
val BaseItemDto.seasonEpisode: String? val BaseItemDto.seasonEpisode: String?
get() = get() =
if (parentIndexNumber != null && indexNumber != null && indexNumberEnd != null) { if (parentIndexNumber != null && indexNumber != null && indexNumberEnd != null) {
@ -25,6 +31,9 @@ val BaseItemDto.seasonEpisode: String?
null null
} }
/**
* If the item has season & episode info, format padded as `S## E##`
*/
val BaseItemDto.seasonEpisodePadded: String? val BaseItemDto.seasonEpisodePadded: String?
get() = get() =
if (parentIndexNumber != null && indexNumber != null) { if (parentIndexNumber != null && indexNumber != null) {

View file

@ -11,6 +11,9 @@ import kotlinx.coroutines.withContext
import timber.log.Timber import timber.log.Timber
import kotlin.coroutines.CoroutineContext 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( class LoadingExceptionHandler(
private val loadingState: MutableLiveData<LoadingState>, private val loadingState: MutableLiveData<LoadingState>,
private val errorMessage: String?, private val errorMessage: String?,

View file

@ -1,5 +1,8 @@
package com.github.damontecres.dolphin.util package com.github.damontecres.dolphin.util
/**
* Generic state for loading something from the API
*/
sealed interface LoadingState { sealed interface LoadingState {
object Loading : LoadingState object Loading : LoadingState

View file

@ -10,6 +10,9 @@ import androidx.media3.common.util.UnstableApi
import timber.log.Timber import timber.log.Timber
import java.util.Locale 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( data class TrackSupport(
val id: String?, val id: String?,
val type: TrackType, val type: TrackType,