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