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

@ -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 = {
viewModel.search(query)
},
),
onSearchClick = {
viewModel.search(query)
},
modifier = Modifier.focusRequester(searchFocusRequester),
)
}
@ -181,7 +177,7 @@ fun SearchPage(
item = item,
onClick = onClick,
onLongClick = onLongClick,
imageHeight = Cards.defaultHeight2x3,
imageHeight = CardDefaults.height2x3,
modifier = mod,
)
},
@ -203,7 +199,7 @@ fun SearchPage(
item = item,
onClick = onClick,
onLongClick = onLongClick,
imageHeight = Cards.defaultHeight2x3,
imageHeight = CardDefaults.height2x3,
modifier = mod,
)
},

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,