mirror of
https://github.com/JustinZeus/Wholphin.git
synced 2026-07-08 15:50:16 +02:00
Add some javadocs and comments
This commit is contained in:
parent
405faa388e
commit
59b3ef2644
65 changed files with 539 additions and 202 deletions
25
README.md
25
README.md
|
|
@ -1,12 +1,12 @@
|
|||
# Dolphin - an OSS Android TV client for Jellyfin
|
||||
|
||||
This is an Android TV client for [Jellyfin](https://jellyfin.org/). It aims to provide a more Plex inspired UI/UX experience for users migrating from Plex to Jellyfin.
|
||||
This is an Android TV client for [Jellyfin](https://jellyfin.org/). It aims to provide a Plex inspired UI experience for users migrating from Plex to Jellyfin.
|
||||
|
||||
This is not a fork of the [official client](https://github.com/jellyfin/jellyfin-androidtv). The user interface and controls have been written completely from scratch.
|
||||
|
||||
## Motivation
|
||||
|
||||
After using Plex and its Android TV app for 10+ years, I found the official Jellyfin client UI/UX to be a barrier for me using Jellyfin more, so if you wish the interface and playback controls were more like Plex's Android TV app, then Dolphin might work for you!
|
||||
After using Plex and its Android TV app for years, I found the official Jellyfin Android TV client UI/UX to be a barrier to using Jellyfin more, so if you wish the interface and playback controls were a bit more like Plex's Android TV app, then Dolphin might work for you!
|
||||
|
||||
That said, Dolphin does not yet implement every feature in Jellyfin. It is a work in progress that will continue to improve over time.
|
||||
|
||||
|
|
@ -15,14 +15,18 @@ That said, Dolphin does not yet implement every feature in Jellyfin. It is a wor
|
|||
- A navigation drawer for quick access to libraries, search, and settings from almost anywhere in the app
|
||||
- Show Movie/TV Show titles when browsing libraries
|
||||
- Play TV Show theme music, if available
|
||||
- Plex inspired playback controls, plus other enhancements, such as:
|
||||
- Plex inspired playback controls, such as:
|
||||
- Using D-Pad left/right for seeking during playback
|
||||
- Subtly show playback position while seeking w/ D-Pad
|
||||
- Quickly access video chapters during playback
|
||||
- Setting to optionally skip back when resuming playback
|
||||
- Optionally skip back a few seconds when resuming playback
|
||||
- Other (subjective) enhancements:
|
||||
- Subtly show playback position along the bottom of the screen while seeking w/ D-Pad
|
||||
- Force Continue Watching & Next Up TV episodes to use their Series posters
|
||||
|
||||
## Installation
|
||||
|
||||
Downloader Code: `Dolphin CODE`
|
||||
|
||||
1. Enable side-loading "unknown" apps
|
||||
- https://androidtvnews.com/unknown-sources-chromecast-google-tv/
|
||||
- https://www.xda-developers.com/how-to-sideload-apps-android-tv/
|
||||
|
|
@ -37,13 +41,13 @@ That said, Dolphin does not yet implement every feature in Jellyfin. It is a wor
|
|||
|
||||
### Upgrading the app
|
||||
|
||||
After the initial install above, the app will automatically check for updates which can then be installed in settings.
|
||||
After the initial install above, the app will automatically check for updates. The updates can be installed in settings.
|
||||
|
||||
The first time you attempt an update, the OS should guide you through enabling the required additional permissions for the app to install updates.
|
||||
|
||||
## Compatibility
|
||||
|
||||
Dolphin requires Android 7.1+ (or Fire TV OS 6+) and Jellyfin server `10.10.x` (tested on primarily `10.10.7`).
|
||||
Requires Android 7.1+ (or Fire TV OS 6+) and Jellyfin server `10.10.x` (tested on primarily `10.10.7`).
|
||||
|
||||
The app is tested on a variety of Android TV/Fire TV OS devices, but if you encounter issues, please file an issue!
|
||||
|
||||
|
|
@ -55,6 +59,13 @@ Please check before submitting that your issue or pull request is not a duplicat
|
|||
|
||||
If you plan to submit a pull request, please read the [contributing guide](CONTRIBUTING.md) before submitting!
|
||||
|
||||
## Acknowledgements
|
||||
|
||||
- Thanks to the Jellyfin team for creating and maintaining such a great open-source media server
|
||||
- Thanks to the official Jellyfin Android TV client developers, some code for creating the device direct play profile is adapted from there
|
||||
- Thanks to the Jellyfin Kotlin SDK developers for making it easier to interact with the Jellyfin server API
|
||||
- Thanks to numerous other libraries that make app development even possible
|
||||
|
||||
## Additional screenshots
|
||||
|
||||
TODO
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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()),
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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?,
|
||||
|
|
|
|||
|
|
@ -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?,
|
||||
|
|
|
|||
|
|
@ -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()),
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 ?: "",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 = { },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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?,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -123,6 +123,9 @@ class RecommendedMovieViewModel
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The "recommended" tab of a movie library
|
||||
*/
|
||||
@Composable
|
||||
fun RecommendedMovie(
|
||||
preferences: UserPreferences,
|
||||
|
|
|
|||
|
|
@ -135,6 +135,9 @@ class RecommendedTvShowViewModel
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The "recommended" tab of a TV show library
|
||||
*/
|
||||
@Composable
|
||||
fun RecommendedTvShow(
|
||||
preferences: UserPreferences,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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, _ ->
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
},
|
||||
)
|
||||
|
|
|
|||
|
|
@ -77,6 +77,7 @@ class HomeViewModel
|
|||
// )
|
||||
// }
|
||||
|
||||
// TODO data is fetched all together which may be slow for large servers
|
||||
val homeRows =
|
||||
homeSections
|
||||
.mapNotNull { section ->
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 ||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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?,
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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>,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>,
|
||||
|
|
|
|||
|
|
@ -165,7 +165,7 @@ class SwitchUserViewModel
|
|||
}
|
||||
|
||||
quickConnectJob =
|
||||
viewModelScope.launch(ExceptionHandler()) {
|
||||
viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) {
|
||||
while (!state.authenticated) {
|
||||
delay(5_000L)
|
||||
state =
|
||||
|
|
|
|||
|
|
@ -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>,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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?,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,8 @@
|
|||
package com.github.damontecres.dolphin.util
|
||||
|
||||
/**
|
||||
* Generic state for loading something from the API
|
||||
*/
|
||||
sealed interface LoadingState {
|
||||
object Loading : LoadingState
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue