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

View file

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

View file

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

View file

@ -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.
*/

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.UserConfiguration
/**
* A combination of the app-specific preferences and server-side user configuration
*/
data class UserPreferences(
val appPreferences: AppPreferences,
val userConfig: UserConfiguration,

View file

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

View file

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

View file

@ -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 <T : Collection<*>, 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,

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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.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<ItemSortBy>,

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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<T : DolphinModel>(
val api: ApiClient,
) : ViewModel() {
@ -57,6 +60,9 @@ abstract class ItemViewModel<T : DolphinModel>(
): 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>(
api: ApiClient,
) : ItemViewModel<T>(api) {

View file

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

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.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,
)
},

View file

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

View file

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

View file

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

View file

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

View file

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

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

View file

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

View file

@ -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<NavKey> = 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)
}
}

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

View file

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

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.type
// This file is various functions for testing KeyEvent types
fun isDirectionalDpad(event: KeyEvent): Boolean =
event.key == Key.DirectionUp ||
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.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",

View file

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

View file

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

View file

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

View file

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

View file

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

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) =
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,

View file

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

View file

@ -165,7 +165,7 @@ class SwitchUserViewModel
}
quickConnectJob =
viewModelScope.launch(ExceptionHandler()) {
viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) {
while (!state.authenticated) {
delay(5_000L)
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.DialogPopup
/**
* Display a list of users plus option to add a new one or switch servers
*/
@Composable
fun UserList(
users: List<JellyfinUser>,

View file

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

View file

@ -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<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> {
/**
* 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<T> {
enableTotalRecordCount: Boolean,
): T
/**
* Execute the given request
*/
suspend fun execute(
api: ApiClient,
request: T,
@ -136,121 +271,3 @@ val GetSuggestionsRequestHandler =
request: GetSuggestionsRequest,
): 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
/**
* 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
*/

View file

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

View file

@ -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<LoadingState>,
private val errorMessage: String?,

View file

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

View file

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