diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
new file mode 100644
index 00000000..7dd39496
--- /dev/null
+++ b/CONTRIBUTING.md
@@ -0,0 +1,24 @@
+# Contributing to Dolphin
+
+We appreciate your interest in contributing to Dolphin!
+
+## Code of Conduct
+
+We follow Jellyfin's example by adhering to their [Code of Conduct](https://jellyfin.org/docs/general/community-standards/#code-of-conduct).
+
+## How to contribute
+
+You can contribute in several ways:
+* File bug reports
+* Request new features
+* Propose code changes via pull request
+
+In all cases, be sure to fully explain the issue.
+
+### Code change proposals
+
+You acknowledge that, if applicable, submitting and subsequent acceptance of any pull request you, the code contributor of the pull request, agree and acknowledge that the code will be licensed under [GPLv2](LICENSE).
+
+Please be thoughtful when contributing code and consider the long term maintenance/support aspect for any new features.
+
+See the [developer's guide](./DEVELOPMENT.md) for more information.
diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md
new file mode 100644
index 00000000..55e82bbc
--- /dev/null
+++ b/DEVELOPMENT.md
@@ -0,0 +1,26 @@
+# Dolphin developer's guide
+
+See also the [Contributing](CONTRIBUTING.md) guide for general information on contributing to the project.
+
+## Overview
+
+This project is an Android TV client for Jellyfin. It is written in Kotlin and uses the official [Jellyfin Kotlin SDK](https://github.com/jellyfin/jellyfin-sdk-kotlin) to interact with the server.
+
+The app is a single Activity (`MainActivity`) with MVVM architecture.
+
+The app uses:
+* [Compose](https://developer.android.com/jetpack/compose) for the UI
+* [Navigation 3](https://developer.android.com/guide/navigation/navigation-3) for navigating app screen
+* [Room](https://developer.android.com/training/data-storage/room) & [DataStore](https://developer.android.com/topic/libraries/architecture/datastore) for local data storage
+* [Hilt](https://developer.android.com/training/dependency-injection/hilt-android) for dependency injection
+* [Media3/ExoPlayer](https://developer.android.com/media/media3/exoplayer) for media playback
+* [Coil](https://coil-kt.github.io/coil/) for image loading
+* [OkHttp](https://square.github.io/okhttp/) for HTTP requests
+
+## Getting started
+
+We follow GitHub's fork & pull request model for contributions.
+
+After forking and cloning your fork, you can import the project into Android Studio.
+
+You need a compatible Android Studio version for the configured AGP. This is generally `Narwhal 3 Feature Drop | 2025.1.3` or newer. See https://developer.android.com/build/releases/gradle-plugin and [`libs.versions.toml](./gradle/libs.versions.toml).
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 26ebd428..1956a7d1 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -72,6 +72,7 @@ android {
compileOptions {
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
+ isCoreLibraryDesugaringEnabled = true
}
kotlinOptions {
jvmTarget = "11"
@@ -227,4 +228,5 @@ dependencies {
androidTestImplementation(libs.androidx.compose.ui.test.junit4)
debugImplementation(libs.androidx.compose.ui.tooling)
debugImplementation(libs.androidx.compose.ui.test.manifest)
+ coreLibraryDesugaring(libs.desugar.jdk.libs)
}
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index c3dc2998..4ad03ab1 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -41,6 +41,16 @@
+
+
+
+
diff --git a/app/src/main/java/com/github/damontecres/dolphin/DolphinApplication.kt b/app/src/main/java/com/github/damontecres/dolphin/DolphinApplication.kt
index c15fcb6b..cc1640b4 100644
--- a/app/src/main/java/com/github/damontecres/dolphin/DolphinApplication.kt
+++ b/app/src/main/java/com/github/damontecres/dolphin/DolphinApplication.kt
@@ -1,6 +1,7 @@
package com.github.damontecres.dolphin
import android.app.Application
+import android.util.Log
import dagger.hilt.android.HiltAndroidApp
import timber.log.Timber
@@ -10,8 +11,25 @@ class DolphinApplication : Application() {
instance = this
if (BuildConfig.DEBUG) {
- // TODO minimal logging for release builds?
Timber.plant(Timber.DebugTree())
+ } else {
+ Timber.plant(
+ object : Timber.Tree() {
+ override fun isLoggable(
+ tag: String?,
+ priority: Int,
+ ): Boolean = priority >= Log.INFO
+
+ override fun log(
+ priority: Int,
+ tag: String?,
+ message: String,
+ t: Throwable?,
+ ) {
+ Log.println(priority, tag ?: "Dolphin", message)
+ }
+ },
+ )
}
}
diff --git a/app/src/main/java/com/github/damontecres/dolphin/data/JellyfinServer.kt b/app/src/main/java/com/github/damontecres/dolphin/data/JellyfinServer.kt
index e5359357..b33e6769 100644
--- a/app/src/main/java/com/github/damontecres/dolphin/data/JellyfinServer.kt
+++ b/app/src/main/java/com/github/damontecres/dolphin/data/JellyfinServer.kt
@@ -55,9 +55,17 @@ interface JellyfinServerDao {
@Insert(onConflict = OnConflictStrategy.IGNORE)
fun addServer(server: JellyfinServer): Long
- @Update(onConflict = OnConflictStrategy.ABORT)
+ @Update
fun updateServer(server: JellyfinServer): Int
+ @Transaction
+ fun addOrUpdateServer(server: JellyfinServer) {
+ val result = addServer(server)
+ if (result == -1L) {
+ updateServer(server)
+ }
+ }
+
@Insert(onConflict = OnConflictStrategy.REPLACE)
fun addUser(user: JellyfinUser)
diff --git a/app/src/main/java/com/github/damontecres/dolphin/data/ServerRepository.kt b/app/src/main/java/com/github/damontecres/dolphin/data/ServerRepository.kt
index fd24aab9..ab685642 100644
--- a/app/src/main/java/com/github/damontecres/dolphin/data/ServerRepository.kt
+++ b/app/src/main/java/com/github/damontecres/dolphin/data/ServerRepository.kt
@@ -9,6 +9,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.jellyfin.sdk.Jellyfin
import org.jellyfin.sdk.api.client.ApiClient
+import org.jellyfin.sdk.api.client.extensions.systemApi
import org.jellyfin.sdk.api.client.extensions.userApi
import org.jellyfin.sdk.model.api.AuthenticationResult
import org.jellyfin.sdk.model.api.UserDto
@@ -42,7 +43,7 @@ class ServerRepository
*/
suspend fun addAndChangeServer(server: JellyfinServer) {
withContext(Dispatchers.IO) {
- serverDao.addServer(server)
+ serverDao.addOrUpdateServer(server)
}
changeServer(server)
}
@@ -74,14 +75,15 @@ class ServerRepository
apiClient.userApi
.getCurrentUser()
.content
+ val sysInfo by apiClient.systemApi.getSystemInfo()
- val updatedServer = server.copy(name = userDto.serverName)
+ val updatedServer = server.copy(name = sysInfo.serverName)
val updatedUser =
user.copy(
id = userDto.id.toString(),
name = userDto.name,
)
- serverDao.addServer(updatedServer)
+ serverDao.addOrUpdateServer(updatedServer)
serverDao.addUser(updatedUser)
userPreferencesDataStore.updateData {
it
diff --git a/app/src/main/java/com/github/damontecres/dolphin/data/model/BaseItem.kt b/app/src/main/java/com/github/damontecres/dolphin/data/model/BaseItem.kt
index 7a0334a5..25408458 100644
--- a/app/src/main/java/com/github/damontecres/dolphin/data/model/BaseItem.kt
+++ b/app/src/main/java/com/github/damontecres/dolphin/data/model/BaseItem.kt
@@ -2,6 +2,7 @@ package com.github.damontecres.dolphin.data.model
import com.github.damontecres.dolphin.ui.detail.series.SeasonEpisode
import com.github.damontecres.dolphin.ui.nav.Destination
+import com.github.damontecres.dolphin.util.seasonEpisode
import kotlinx.serialization.Serializable
import kotlinx.serialization.Transient
import org.jellyfin.sdk.api.client.ApiClient
@@ -24,7 +25,11 @@ data class BaseItem(
@Transient val name = data.name
@Transient
- val indexNumber = data.indexNumber
+ val title = if (type == BaseItemKind.EPISODE) data.seriesName else name
+
+ @Transient
+ val subtitle =
+ if (type == BaseItemKind.EPISODE) data.seasonEpisode + " - " + name else data.productionYear?.toString()
@Transient
val resumeMs =
@@ -33,12 +38,23 @@ data class BaseItem(
?.ticks
?.inWholeMilliseconds
+ @Transient
+ val indexNumber = data.indexNumber ?: dateAsIndex()
+
+ private fun dateAsIndex(): Int? =
+ data.premiereDate
+ ?.let {
+ it.year.toString() +
+ it.monthValue.toString().padStart(2, '0') +
+ it.dayOfMonth.toString().padStart(2, '0')
+ }?.toIntOrNull()
+
fun destination(): Destination {
val result =
// Redirect episodes & seasons to their series if possible
when (type) {
BaseItemKind.EPISODE -> {
- data.indexNumber?.let { episode ->
+ indexNumber?.let { episode ->
data.parentIndexNumber?.let { season ->
Destination.SeriesOverview(
data.seriesId!!,
diff --git a/app/src/main/java/com/github/damontecres/dolphin/data/model/DolphinModel.kt b/app/src/main/java/com/github/damontecres/dolphin/data/model/DolphinModel.kt
deleted file mode 100644
index 9632e318..00000000
--- a/app/src/main/java/com/github/damontecres/dolphin/data/model/DolphinModel.kt
+++ /dev/null
@@ -1,29 +0,0 @@
-package com.github.damontecres.dolphin.data.model
-
-import org.jellyfin.sdk.api.client.ApiClient
-import org.jellyfin.sdk.model.api.BaseItemDto
-import org.jellyfin.sdk.model.api.BaseItemKind
-import java.util.UUID
-
-sealed interface DolphinModel {
- val id: UUID
- val name: String?
- val type: BaseItemKind
- val imageUrl: String?
-}
-
-fun convertModel(
- dto: BaseItemDto,
- api: ApiClient,
-): DolphinModel =
- when (dto.type) {
- BaseItemKind.COLLECTION_FOLDER -> Library.fromDto(dto, api)
-
- // TODO
- BaseItemKind.VIDEO -> Video.fromDto(dto, api)
- BaseItemKind.SERIES -> Video.fromDto(dto, api)
- BaseItemKind.MOVIE -> Video.fromDto(dto, api)
- BaseItemKind.SEASON -> Video.fromDto(dto, api)
- BaseItemKind.EPISODE -> Video.fromDto(dto, api)
- else -> throw IllegalArgumentException("Unsupported item type: ${dto.type}")
- }
diff --git a/app/src/main/java/com/github/damontecres/dolphin/data/model/Library.kt b/app/src/main/java/com/github/damontecres/dolphin/data/model/Library.kt
deleted file mode 100644
index 06d59d39..00000000
--- a/app/src/main/java/com/github/damontecres/dolphin/data/model/Library.kt
+++ /dev/null
@@ -1,31 +0,0 @@
-package com.github.damontecres.dolphin.data.model
-
-import org.jellyfin.sdk.api.client.ApiClient
-import org.jellyfin.sdk.api.client.extensions.imageApi
-import org.jellyfin.sdk.model.api.BaseItemDto
-import org.jellyfin.sdk.model.api.BaseItemKind
-import org.jellyfin.sdk.model.api.CollectionType
-import org.jellyfin.sdk.model.api.ImageType
-import java.util.UUID
-
-data class Library(
- override val id: UUID,
- override val name: String?,
- override val type: BaseItemKind,
- override val imageUrl: String?,
- val collectionType: CollectionType,
-) : DolphinModel {
- companion object {
- fun fromDto(
- dto: BaseItemDto,
- api: ApiClient,
- ): Library =
- Library(
- id = dto.id,
- name = dto.name,
- type = dto.type,
- imageUrl = api.imageApi.getItemImageUrl(dto.id, ImageType.PRIMARY),
- collectionType = dto.collectionType ?: CollectionType.UNKNOWN,
- )
- }
-}
diff --git a/app/src/main/java/com/github/damontecres/dolphin/data/model/Playlist.kt b/app/src/main/java/com/github/damontecres/dolphin/data/model/Playlist.kt
new file mode 100644
index 00000000..d6f677a9
--- /dev/null
+++ b/app/src/main/java/com/github/damontecres/dolphin/data/model/Playlist.kt
@@ -0,0 +1,95 @@
+package com.github.damontecres.dolphin.data.model
+
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableIntStateOf
+import androidx.compose.runtime.setValue
+import com.github.damontecres.dolphin.ui.DefaultItemFields
+import com.github.damontecres.dolphin.ui.indexOfFirstOrNull
+import com.github.damontecres.dolphin.util.GetEpisodesRequestHandler
+import com.github.damontecres.dolphin.util.GetPlaylistItemsRequestHandler
+import org.jellyfin.sdk.api.client.ApiClient
+import org.jellyfin.sdk.model.api.request.GetEpisodesRequest
+import org.jellyfin.sdk.model.api.request.GetPlaylistItemsRequest
+import java.util.UUID
+import javax.inject.Inject
+import javax.inject.Singleton
+
+class Playlist(
+ items: List,
+ startIndex: Int = 0,
+) {
+ val items = items.subList(startIndex, items.size)
+
+ var index by mutableIntStateOf(0)
+
+ fun hasPrevious(): Boolean = index > 0
+
+ fun hasNext(): Boolean = index < items.size
+
+ fun getPreviousAndReverse(): BaseItem = items[--index]
+
+ fun getAndAdvance(): BaseItem = items[++index]
+
+ fun peek(): BaseItem? = items.getOrNull(index + 1)
+
+ fun upcomingItems(): List = items.subList(index + 1, items.size)
+
+ fun advanceTo(id: UUID): BaseItem? {
+ while (hasNext()) {
+ val potential = getAndAdvance()
+ if (potential.id == id) {
+ return potential
+ }
+ }
+ return null
+ }
+
+ companion object {
+ const val MAX_SIZE = 100
+ }
+}
+
+@Singleton
+class PlaylistCreator
+ @Inject
+ constructor(
+ private val api: ApiClient,
+ ) {
+ suspend fun createFromEpisode(
+ seriesId: UUID,
+ episodeId: UUID,
+ ): Playlist {
+ val request =
+ GetEpisodesRequest(
+ seriesId = seriesId,
+ fields = DefaultItemFields,
+ startItemId = episodeId,
+ limit = Playlist.MAX_SIZE,
+ )
+ val episodes = GetEpisodesRequestHandler.execute(api, request).content.items
+ val startIndex =
+ episodes.indexOfFirstOrNull { it.id == episodeId }
+ ?: throw IllegalStateException("Episode $episodeId was not returned")
+ return Playlist(episodes.map { BaseItem.from(it, api) }, startIndex)
+ }
+
+ suspend fun createFromPlaylistId(
+ playlistId: UUID,
+ startIndex: Int?,
+ shuffled: Boolean,
+ ): Playlist {
+ val request =
+ GetPlaylistItemsRequest(
+ playlistId = playlistId,
+ fields = DefaultItemFields,
+ startIndex = startIndex,
+ limit = Playlist.MAX_SIZE,
+ )
+ val items = GetPlaylistItemsRequestHandler.execute(api, request).content.items
+ var baseItems = items.map { BaseItem.from(it, api) }
+ if (shuffled) {
+ baseItems = baseItems.shuffled()
+ }
+ return Playlist(baseItems, 0)
+ }
+ }
diff --git a/app/src/main/java/com/github/damontecres/dolphin/data/model/Video.kt b/app/src/main/java/com/github/damontecres/dolphin/data/model/Video.kt
deleted file mode 100644
index 99ba6d43..00000000
--- a/app/src/main/java/com/github/damontecres/dolphin/data/model/Video.kt
+++ /dev/null
@@ -1,28 +0,0 @@
-package com.github.damontecres.dolphin.data.model
-
-import org.jellyfin.sdk.api.client.ApiClient
-import org.jellyfin.sdk.api.client.extensions.imageApi
-import org.jellyfin.sdk.model.api.BaseItemDto
-import org.jellyfin.sdk.model.api.BaseItemKind
-import org.jellyfin.sdk.model.api.ImageType
-import java.util.UUID
-
-data class Video(
- override val id: UUID,
- override val name: String?,
- override val type: BaseItemKind,
- override val imageUrl: String?,
-) : DolphinModel {
- companion object {
- fun fromDto(
- dto: BaseItemDto,
- api: ApiClient,
- ): Video =
- Video(
- id = dto.id,
- name = dto.name,
- type = dto.type,
- imageUrl = api.imageApi.getItemImageUrl(dto.id, ImageType.PRIMARY),
- )
- }
-}
diff --git a/app/src/main/java/com/github/damontecres/dolphin/preferences/AppPreference.kt b/app/src/main/java/com/github/damontecres/dolphin/preferences/AppPreference.kt
index 912e31d9..ffc14ce2 100644
--- a/app/src/main/java/com/github/damontecres/dolphin/preferences/AppPreference.kt
+++ b/app/src/main/java/com/github/damontecres/dolphin/preferences/AppPreference.kt
@@ -164,6 +164,18 @@ sealed interface AppPreference {
summarizer = { value -> value?.toString() },
)
+ val CombineContinueNext =
+ AppSwitchPreference(
+ title = R.string.combine_continue_next,
+ defaultValue = false,
+ getter = { it.homePagePreferences.combineContinueNext },
+ setter = { prefs, value ->
+ prefs.updateHomePagePreferences { combineContinueNext = value }
+ },
+ summaryOn = R.string.enabled,
+ summaryOff = R.string.disabled,
+ )
+
val RewatchNextUp =
AppSwitchPreference(
title = R.string.rewatch_next_up,
@@ -439,6 +451,13 @@ sealed interface AppPreference {
indexToValue = { SkipSegmentBehavior.forNumber(it) },
valueToIndex = { it.number },
)
+
+ val ClearImageCache =
+ AppClickablePreference(
+ title = R.string.clear_image_cache,
+ getter = { },
+ setter = { prefs, _ -> prefs },
+ )
}
}
@@ -450,6 +469,7 @@ val basicPreferences =
listOf(
AppPreference.HomePageItems,
AppPreference.RewatchNextUp,
+ AppPreference.CombineContinueNext,
AppPreference.PlayThemeMusic,
AppPreference.RememberSelectedTab,
AppPreference.ThemeColors,
@@ -513,6 +533,7 @@ val advancedPreferences =
title = R.string.more,
preferences =
listOf(
+ AppPreference.ClearImageCache,
AppPreference.OssLicenseInfo,
),
),
diff --git a/app/src/main/java/com/github/damontecres/dolphin/preferences/AppPreferencesSerializer.kt b/app/src/main/java/com/github/damontecres/dolphin/preferences/AppPreferencesSerializer.kt
index f48df5ac..9043e288 100644
--- a/app/src/main/java/com/github/damontecres/dolphin/preferences/AppPreferencesSerializer.kt
+++ b/app/src/main/java/com/github/damontecres/dolphin/preferences/AppPreferencesSerializer.kt
@@ -49,6 +49,7 @@ class AppPreferencesSerializer
.apply {
maxItemsPerRow = AppPreference.HomePageItems.defaultValue.toInt()
enableRewatchingNextUp = AppPreference.RewatchNextUp.defaultValue
+ combineContinueNext = AppPreference.CombineContinueNext.defaultValue
}.build()
interfacePreferences =
InterfacePreferences
diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/UiConstants.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/UiConstants.kt
index c75cfeef..a05b00f0 100644
--- a/app/src/main/java/com/github/damontecres/dolphin/ui/UiConstants.kt
+++ b/app/src/main/java/com/github/damontecres/dolphin/ui/UiConstants.kt
@@ -38,6 +38,7 @@ val DefaultItemFields =
ItemFields.OVERVIEW,
ItemFields.TRICKPLAY,
ItemFields.SORT_NAME,
+ ItemFields.CHAPTERS,
)
val DefaultButtonPadding =
diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/cards/GridCard.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/cards/GridCard.kt
index 6137a859..9480531e 100644
--- a/app/src/main/java/com/github/damontecres/dolphin/ui/cards/GridCard.kt
+++ b/app/src/main/java/com/github/damontecres/dolphin/ui/cards/GridCard.kt
@@ -98,7 +98,7 @@ fun GridCard(
.fillMaxWidth(),
) {
Text(
- text = item?.name ?: "",
+ text = item?.title ?: "",
maxLines = 1,
textAlign = TextAlign.Center,
overflow = TextOverflow.Ellipsis,
@@ -109,7 +109,7 @@ fun GridCard(
.enableMarquee(focusedAfterDelay),
)
Text(
- text = item?.data?.productionYear?.toString() ?: "",
+ text = item?.subtitle ?: "",
maxLines = 1,
textAlign = TextAlign.Center,
modifier =
diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/cards/ItemCard.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/cards/ItemCardImage.kt
similarity index 60%
rename from app/src/main/java/com/github/damontecres/dolphin/ui/cards/ItemCard.kt
rename to app/src/main/java/com/github/damontecres/dolphin/ui/cards/ItemCardImage.kt
index a7cbe5ce..6b68004c 100644
--- a/app/src/main/java/com/github/damontecres/dolphin/ui/cards/ItemCard.kt
+++ b/app/src/main/java/com/github/damontecres/dolphin/ui/cards/ItemCardImage.kt
@@ -5,22 +5,16 @@ import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
-import androidx.compose.foundation.interaction.MutableInteractionSource
-import androidx.compose.foundation.interaction.collectIsFocusedAsState
-import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
-import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
-import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.runtime.Composable
-import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@@ -29,134 +23,23 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.BlendMode
-import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.colorResource
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
-import androidx.compose.ui.text.style.TextAlign
-import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
-import androidx.tv.material3.Card
-import androidx.tv.material3.CardDefaults
import androidx.tv.material3.Icon
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
import coil3.compose.AsyncImage
import com.github.damontecres.dolphin.R
-import com.github.damontecres.dolphin.data.model.BaseItem
import com.github.damontecres.dolphin.ui.Cards
import com.github.damontecres.dolphin.ui.FontAwesome
-import com.github.damontecres.dolphin.ui.enableMarquee
-import com.github.damontecres.dolphin.ui.ifElse
import com.github.damontecres.dolphin.ui.isNotNullOrBlank
import com.github.damontecres.dolphin.ui.logCoilError
-import com.github.damontecres.dolphin.util.seasonEpisode
-import kotlinx.coroutines.delay
-import org.jellyfin.sdk.model.api.BaseItemKind
-
-@Composable
-@Deprecated("Old style, prefer SeasonCard or other Card")
-fun ItemCard(
- item: BaseItem?,
- onClick: () -> Unit,
- onLongClick: () -> Unit,
- modifier: Modifier = Modifier,
- cardWidth: Dp? = null,
- cardHeight: Dp? = 200.dp * .85f,
- interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
-) {
- val hideOverlayDelay = 750L
-
- val focused = interactionSource.collectIsFocusedAsState().value
- var focusedAfterDelay by remember { mutableStateOf(false) }
-
- if (focused) {
- LaunchedEffect(Unit) {
- delay(hideOverlayDelay)
- if (focused) {
- focusedAfterDelay = true
- } else {
- focusedAfterDelay = false
- }
- }
- } else {
- focusedAfterDelay = false
- }
-
- if (item == null) {
- NullCard(modifier, cardWidth, cardHeight, interactionSource)
- } else {
- val dto = item.data
- // TODO better aspect ratio handling
-// val height =
-// if (dto.primaryImageAspectRatio != null && dto.primaryImageAspectRatio!! > 1) cardWidth else cardHeight
- Card(
- modifier = modifier,
- onClick = onClick,
- onLongClick = onLongClick,
- interactionSource = interactionSource,
- colors =
- CardDefaults.colors(
- containerColor = Color.Transparent,
- ),
- ) {
- Column(
- verticalArrangement = Arrangement.spacedBy(4.dp),
- modifier = Modifier.ifElse(cardWidth != null, { Modifier.width(cardWidth!!) }),
- ) {
- ItemCardImage(
- imageUrl = item.imageUrl,
- name = item.name,
- showOverlay = !focusedAfterDelay,
- favorite = dto.userData?.isFavorite ?: false,
- watched = dto.userData?.played ?: false,
- unwatchedCount = dto.userData?.unplayedItemCount ?: -1,
- watchedPercent = dto.userData?.playedPercentage,
- modifier =
- Modifier
- .fillMaxWidth()
- .ifElse(cardHeight != null, { Modifier.height(cardHeight!!) }),
- )
- Column(
- verticalArrangement = Arrangement.spacedBy(0.dp),
- modifier = Modifier.padding(bottom = 4.dp),
- ) {
- Text(
- text = item.name ?: "",
- maxLines = 1,
- textAlign = TextAlign.Center,
- modifier =
- Modifier
- .fillMaxWidth()
- .padding(horizontal = 4.dp)
- .enableMarquee(focusedAfterDelay),
- )
- Text(
- text = item.data.productionYear?.toString() ?: "",
- maxLines = 1,
- textAlign = TextAlign.Center,
- modifier =
- Modifier
- .fillMaxWidth()
- .padding(horizontal = 4.dp)
- .enableMarquee(focusedAfterDelay),
- )
- }
- if (dto.type == BaseItemKind.EPISODE) {
- dto.seasonEpisode?.let {
- Text(
- text = it,
- )
- }
- }
- }
- }
- }
-}
@Composable
fun ItemCardImage(
diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/cards/ItemRow.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/cards/ItemRow.kt
index 92a67704..2eb1fd58 100644
--- a/app/src/main/java/com/github/damontecres/dolphin/ui/cards/ItemRow.kt
+++ b/app/src/main/java/com/github/damontecres/dolphin/ui/cards/ItemRow.kt
@@ -29,21 +29,14 @@ fun ItemRow(
items: List,
onClickItem: (BaseItem) -> Unit,
onLongClickItem: (BaseItem) -> Unit,
- modifier: Modifier = Modifier,
cardContent: @Composable (
index: Int,
item: BaseItem?,
modifier: Modifier,
onClick: () -> Unit,
onLongClick: () -> Unit,
- ) -> Unit = { index, item, mod, onClick, onLongClick ->
- ItemCard(
- item = item,
- onClick = onClick,
- onLongClick = onLongClick,
- modifier = mod,
- )
- },
+ ) -> Unit,
+ modifier: Modifier = Modifier,
focusPair: FocusPair? = null,
cardOnFocus: ((isFocused: Boolean, index: Int) -> Unit)? = null,
) {
diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/cards/NullCard.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/cards/NullCard.kt
deleted file mode 100644
index 00f72096..00000000
--- a/app/src/main/java/com/github/damontecres/dolphin/ui/cards/NullCard.kt
+++ /dev/null
@@ -1,39 +0,0 @@
-package com.github.damontecres.dolphin.ui.cards
-
-import androidx.compose.foundation.interaction.MutableInteractionSource
-import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.height
-import androidx.compose.foundation.layout.width
-import androidx.compose.runtime.Composable
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.unit.Dp
-import androidx.compose.ui.unit.dp
-import androidx.tv.material3.Card
-import androidx.tv.material3.Text
-import com.github.damontecres.dolphin.ui.ifElse
-
-@Composable
-@Deprecated("Cards should handle nulls natively")
-fun NullCard(
- modifier: Modifier = Modifier,
- cardWidth: Dp? = null,
- cardHeight: Dp? = 200.dp * .75f,
- interactionSource: MutableInteractionSource? = null,
-) {
- Card(
- modifier = modifier,
- onClick = {},
- interactionSource = interactionSource,
- ) {
- Column(
- modifier =
- Modifier
- .ifElse(cardHeight != null, { Modifier.height(cardHeight!!) })
- .ifElse(cardWidth != null, { Modifier.width(cardWidth!!) }),
- ) {
- Text(
- text = "Loading...",
- )
- }
- }
-}
diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/cards/SeasonCard.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/cards/SeasonCard.kt
index ed2ed7fe..2def9731 100644
--- a/app/src/main/java/com/github/damontecres/dolphin/ui/cards/SeasonCard.kt
+++ b/app/src/main/java/com/github/damontecres/dolphin/ui/cards/SeasonCard.kt
@@ -41,6 +41,7 @@ fun SeasonCard(
imageHeight: Dp = Dp.Unspecified,
imageWidth: Dp = Dp.Unspecified,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
+ showImageOverlay: Boolean = false,
) {
val dto = item?.data
val focused by interactionSource.collectIsFocusedAsState()
@@ -89,7 +90,7 @@ fun SeasonCard(
ItemCardImage(
imageUrl = item?.imageUrl,
name = item?.name,
- showOverlay = false,
+ showOverlay = showImageOverlay,
favorite = dto?.userData?.isFavorite ?: false,
watched = dto?.userData?.played ?: false,
unwatchedCount = dto?.userData?.unplayedItemCount ?: -1,
@@ -109,7 +110,7 @@ fun SeasonCard(
.fillMaxWidth(),
) {
Text(
- text = dto?.name ?: "",
+ text = item?.title ?: "",
maxLines = 1,
textAlign = TextAlign.Center,
modifier =
@@ -119,7 +120,7 @@ fun SeasonCard(
.enableMarquee(focusedAfterDelay),
)
Text(
- text = item?.data?.productionYear?.toString() ?: "",
+ text = item?.subtitle ?: "",
maxLines = 1,
textAlign = TextAlign.Center,
modifier =
diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/components/CollectionFolderGrid.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/components/CollectionFolderGrid.kt
index 56318a95..8b571a95 100644
--- a/app/src/main/java/com/github/damontecres/dolphin/ui/components/CollectionFolderGrid.kt
+++ b/app/src/main/java/com/github/damontecres/dolphin/ui/components/CollectionFolderGrid.kt
@@ -27,7 +27,6 @@ import androidx.lifecycle.viewModelScope
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
import com.github.damontecres.dolphin.data.model.BaseItem
-import com.github.damontecres.dolphin.data.model.Library
import com.github.damontecres.dolphin.preferences.UserPreferences
import com.github.damontecres.dolphin.ui.DefaultItemFields
import com.github.damontecres.dolphin.ui.OneTimeLaunchedEffect
@@ -63,7 +62,7 @@ class CollectionFolderViewModel
@Inject
constructor(
api: ApiClient,
- ) : ItemViewModel(api) {
+ ) : ItemViewModel(api) {
val loading = MutableLiveData(LoadingState.Loading)
val pager = MutableLiveData>(listOf())
val sortAndDirection = MutableLiveData()
@@ -72,6 +71,7 @@ class CollectionFolderViewModel
itemId: UUID,
potential: BaseItem?,
sortAndDirection: SortAndDirection,
+ recursive: Boolean,
): Job =
viewModelScope.launch(
LoadingExceptionHandler(
@@ -80,10 +80,13 @@ class CollectionFolderViewModel
) + Dispatchers.IO,
) {
fetchItem(itemId, potential)
- loadResults(sortAndDirection)
+ loadResults(sortAndDirection, recursive)
}
- fun loadResults(sortAndDirection: SortAndDirection) {
+ fun loadResults(
+ sortAndDirection: SortAndDirection,
+ recursive: Boolean,
+ ) {
item.value?.let { item ->
viewModelScope.launch(Dispatchers.IO) {
withContext(Dispatchers.Main) {
@@ -96,28 +99,47 @@ class CollectionFolderViewModel
CollectionType.MOVIES -> listOf(BaseItemKind.MOVIE)
CollectionType.TVSHOWS -> listOf(BaseItemKind.SERIES)
CollectionType.HOMEVIDEOS -> listOf(BaseItemKind.VIDEO)
+ CollectionType.MUSIC ->
+ listOf(
+ BaseItemKind.AUDIO,
+ BaseItemKind.MUSIC_ARTIST,
+ BaseItemKind.MUSIC_ALBUM,
+ )
+
+ CollectionType.BOXSETS -> listOf(BaseItemKind.BOX_SET)
+ CollectionType.PLAYLISTS -> listOf(BaseItemKind.PLAYLIST)
else -> listOf()
}
val request =
GetItemsRequest(
parentId = item.id,
- isSeries = true,
- mediaTypes = null,
-// recursive = true,
enableImageTypes = listOf(ImageType.PRIMARY, ImageType.THUMB),
includeItemTypes = includeItemTypes,
+ recursive = recursive,
+ excludeItemIds = listOf(item.id),
sortBy =
listOf(
sortAndDirection.sort,
ItemSortBy.SORT_NAME,
ItemSortBy.PRODUCTION_YEAR,
),
- sortOrder = listOf(sortAndDirection.direction),
+ sortOrder =
+ listOf(
+ sortAndDirection.direction,
+ SortOrder.ASCENDING,
+ SortOrder.ASCENDING,
+ ),
fields = DefaultItemFields,
)
val newPager =
- ApiRequestPager(api, request, GetItemsRequestHandler, viewModelScope)
+ ApiRequestPager(
+ api,
+ request,
+ GetItemsRequestHandler,
+ viewModelScope,
+ useSeriesForPrimary = true,
+ )
newPager.init()
if (newPager.isNotEmpty()) newPager.getBlocking(0)
withContext(Dispatchers.Main) {
@@ -145,6 +167,7 @@ class CollectionFolderViewModel
nameLessThan = letter.toString(),
limit = 0,
enableTotalRecordCount = true,
+ recursive = true,
)
val result by GetItemsRequestHandler.execute(api, request)
result.totalRecordCount
@@ -160,6 +183,7 @@ class CollectionFolderViewModel
fun CollectionFolderGrid(
preferences: UserPreferences,
destination: Destination.MediaItem,
+ recursive: Boolean,
onClickItem: (BaseItem) -> Unit,
modifier: Modifier = Modifier,
viewModel: CollectionFolderViewModel = hiltViewModel(),
@@ -172,12 +196,11 @@ fun CollectionFolderGrid(
positionCallback: ((columns: Int, position: Int) -> Unit)? = null,
) {
OneTimeLaunchedEffect {
- viewModel.init(destination.itemId, destination.item, initialSortAndDirection)
+ viewModel.init(destination.itemId, destination.item, initialSortAndDirection, recursive)
}
val sortAndDirection by viewModel.sortAndDirection.observeAsState(initialSortAndDirection)
val loading by viewModel.loading.observeAsState(LoadingState.Loading)
val item by viewModel.item.observeAsState()
- val library by viewModel.model.observeAsState()
val pager by viewModel.pager.observeAsState()
when (val state = loading) {
@@ -189,14 +212,13 @@ fun CollectionFolderGrid(
pager?.let { pager ->
CollectionFolderGridContent(
preferences,
- library!!,
item!!,
pager,
sortAndDirection = sortAndDirection,
modifier = modifier,
onClickItem = onClickItem,
onSortChange = {
- viewModel.loadResults(it)
+ viewModel.loadResults(it, recursive)
},
showTitle = showTitle,
positionCallback = positionCallback,
@@ -210,7 +232,6 @@ fun CollectionFolderGrid(
@Composable
fun CollectionFolderGridContent(
preferences: UserPreferences,
- library: Library,
item: BaseItem,
pager: List,
sortAndDirection: SortAndDirection,
@@ -221,7 +242,7 @@ fun CollectionFolderGridContent(
showTitle: Boolean = true,
positionCallback: ((columns: Int, position: Int) -> Unit)? = null,
) {
- val title = library.name ?: item.data.name ?: item.data.collectionType?.name ?: "Collection"
+ val title = item.name ?: item.data.collectionType?.name ?: "Collection"
val sortOptions =
when (item.data.collectionType) {
CollectionType.MOVIES -> MovieSortOptions
@@ -262,7 +283,7 @@ fun CollectionFolderGridContent(
CardGrid(
pager = pager,
onClickItem = onClickItem,
- longClicker = {},
+ onLongClickItem = {},
letterPosition = letterPosition,
gridFocusRequester = gridFocusRequester,
showJumpButtons = false, // TODO add preference
diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/components/Dialogs.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/components/Dialogs.kt
index da309be2..ca2531c1 100644
--- a/app/src/main/java/com/github/damontecres/dolphin/ui/components/Dialogs.kt
+++ b/app/src/main/java/com/github/damontecres/dolphin/ui/components/Dialogs.kt
@@ -236,6 +236,24 @@ fun DialogPopup(
}
}
+@Composable
+fun DialogPopup(
+ params: DialogParams,
+ onDismissRequest: () -> Unit,
+ dismissOnClick: Boolean = true,
+ properties: DialogProperties = DialogProperties(),
+ elevation: Dp = 3.dp,
+) = DialogPopup(
+ showDialog = true,
+ waitToLoad = params.fromLongClick,
+ title = params.title,
+ dialogItems = params.items,
+ onDismissRequest = onDismissRequest,
+ dismissOnClick = dismissOnClick,
+ properties = properties,
+ elevation = elevation,
+)
+
/**
* A dialog that can be scrolled, typically for longer text content
*/
diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/components/OverviewText.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/components/OverviewText.kt
new file mode 100644
index 00000000..07807567
--- /dev/null
+++ b/app/src/main/java/com/github/damontecres/dolphin/ui/components/OverviewText.kt
@@ -0,0 +1,70 @@
+package com.github.damontecres.dolphin.ui.components
+
+import androidx.compose.foundation.LocalIndication
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.interaction.MutableInteractionSource
+import androidx.compose.foundation.interaction.collectIsFocusedAsState
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.times
+import androidx.tv.material3.MaterialTheme
+import androidx.tv.material3.Text
+import com.github.damontecres.dolphin.ui.playOnClickSound
+import com.github.damontecres.dolphin.ui.playSoundOnFocus
+
+@Composable
+fun OverviewText(
+ overview: String,
+ maxLines: Int,
+ onClick: () -> Unit,
+ modifier: Modifier = Modifier,
+ interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
+ textBoxHeight: Dp = maxLines * 20.dp,
+ enabled: Boolean = true,
+) {
+ val context = LocalContext.current
+ val isFocused = interactionSource.collectIsFocusedAsState().value
+ val bgColor =
+ if (isFocused) {
+ MaterialTheme.colorScheme.onPrimary.copy(alpha = .4f)
+ } else {
+ Color.Unspecified
+ }
+ Box(
+ modifier =
+ modifier
+ .background(bgColor, shape = RoundedCornerShape(8.dp))
+ .playSoundOnFocus(true)
+ .clickable(
+ enabled = enabled,
+ interactionSource = interactionSource,
+ indication = LocalIndication.current,
+ ) {
+ playOnClickSound(context)
+ onClick.invoke()
+ },
+ ) {
+ Text(
+ text = overview,
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurface,
+ maxLines = maxLines,
+ overflow = TextOverflow.Ellipsis,
+ modifier =
+ Modifier
+ .padding(8.dp)
+ .height(textBoxHeight),
+ )
+ }
+}
diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/components/PlayButtons.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/components/PlayButtons.kt
index 6d816c6d..7b5e0b4d 100644
--- a/app/src/main/java/com/github/damontecres/dolphin/ui/components/PlayButtons.kt
+++ b/app/src/main/java/com/github/damontecres/dolphin/ui/components/PlayButtons.kt
@@ -15,7 +15,6 @@ import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.FocusState
-import androidx.compose.ui.focus.focusProperties
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.focusRestorer
import androidx.compose.ui.focus.onFocusChanged
@@ -29,7 +28,6 @@ import androidx.tv.material3.Text
import com.github.damontecres.dolphin.R
import com.github.damontecres.dolphin.ui.PreviewTvSpec
import com.github.damontecres.dolphin.ui.theme.DolphinTheme
-import com.github.damontecres.dolphin.ui.tryRequestFocus
import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds
@@ -139,14 +137,10 @@ fun ExpandablePlayButtons(
modifier =
modifier
.focusGroup()
- .focusProperties {
- onEnter = {
- firstFocus.tryRequestFocus()
- }
- },
+ .focusRestorer(firstFocus),
) {
if (resumePosition > Duration.ZERO) {
- item {
+ item("play") {
ExpandablePlayButton(
R.string.resume,
resumePosition,
@@ -157,7 +151,7 @@ fun ExpandablePlayButtons(
.focusRequester(firstFocus),
)
}
- item {
+ item("restart") {
ExpandablePlayButton(
R.string.restart,
Duration.ZERO,
@@ -168,7 +162,7 @@ fun ExpandablePlayButtons(
)
}
} else {
- item {
+ item("play") {
ExpandablePlayButton(
R.string.play,
Duration.ZERO,
@@ -182,7 +176,7 @@ fun ExpandablePlayButtons(
}
// Watched button
- item {
+ item("watched") {
ExpandableFaButton(
title = if (watched) R.string.mark_unwatched else R.string.mark_watched,
iconStringRes = if (watched) R.string.fa_eye else R.string.fa_eye_slash,
@@ -192,7 +186,7 @@ fun ExpandablePlayButtons(
}
// More button
- item {
+ item("more") {
ExpandablePlayButton(
R.string.more,
Duration.ZERO,
diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/detail/CardGrid.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/detail/CardGrid.kt
index 17cb2607..da5e80a8 100644
--- a/app/src/main/java/com/github/damontecres/dolphin/ui/detail/CardGrid.kt
+++ b/app/src/main/java/com/github/damontecres/dolphin/ui/detail/CardGrid.kt
@@ -69,7 +69,7 @@ private const val DEBUG = false
fun CardGrid(
pager: List,
onClickItem: (BaseItem) -> Unit,
- longClicker: (BaseItem) -> Unit,
+ onLongClickItem: (BaseItem) -> Unit,
letterPosition: suspend (Char) -> Int,
gridFocusRequester: FocusRequester,
showJumpButtons: Boolean,
@@ -257,7 +257,7 @@ fun CardGrid(
onClickItem.invoke(item)
}
},
- onLongClick = { if (item != null) longClicker.invoke(item) },
+ onLongClick = { if (item != null) onLongClickItem.invoke(item) },
modifier =
mod
.ifElse(index == 0, Modifier.focusRequester(zeroFocus))
diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/detail/CollectionFolderGeneric.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/detail/CollectionFolderGeneric.kt
index 69d356af..01d5aa19 100644
--- a/app/src/main/java/com/github/damontecres/dolphin/ui/detail/CollectionFolderGeneric.kt
+++ b/app/src/main/java/com/github/damontecres/dolphin/ui/detail/CollectionFolderGeneric.kt
@@ -18,6 +18,7 @@ import com.github.damontecres.dolphin.ui.preferences.PreferencesViewModel
fun CollectionFolderGeneric(
preferences: UserPreferences,
destination: Destination.MediaItem,
+ recursive: Boolean,
modifier: Modifier = Modifier,
preferencesViewModel: PreferencesViewModel = hiltViewModel(),
) {
@@ -27,6 +28,7 @@ fun CollectionFolderGeneric(
onClickItem = { preferencesViewModel.navigationManager.navigateTo(it.destination()) },
destination = destination,
showTitle = showHeader,
+ recursive = recursive,
modifier =
modifier
.padding(start = 16.dp),
diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/detail/CollectionFolderMovie.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/detail/CollectionFolderMovie.kt
index 5be4e02f..8aea2b1b 100644
--- a/app/src/main/java/com/github/damontecres/dolphin/ui/detail/CollectionFolderMovie.kt
+++ b/app/src/main/java/com/github/damontecres/dolphin/ui/detail/CollectionFolderMovie.kt
@@ -156,6 +156,7 @@ fun CollectionFolderMovie(
onClickItem = onClickItem,
destination = destination,
showTitle = false,
+ recursive = true,
modifier =
Modifier
.padding(start = 16.dp)
diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/detail/CollectionFolderTv.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/detail/CollectionFolderTv.kt
index 5d25dee4..16702d2f 100644
--- a/app/src/main/java/com/github/damontecres/dolphin/ui/detail/CollectionFolderTv.kt
+++ b/app/src/main/java/com/github/damontecres/dolphin/ui/detail/CollectionFolderTv.kt
@@ -155,6 +155,7 @@ fun CollectionFolderTv(
preferences = preferences,
destination = destination,
showTitle = false,
+ recursive = true,
modifier =
Modifier
.padding(start = 16.dp)
diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/detail/EpisodeDetails.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/detail/EpisodeDetails.kt
deleted file mode 100644
index d0d7d8cb..00000000
--- a/app/src/main/java/com/github/damontecres/dolphin/ui/detail/EpisodeDetails.kt
+++ /dev/null
@@ -1,166 +0,0 @@
-package com.github.damontecres.dolphin.ui.detail
-
-import androidx.compose.foundation.layout.Box
-import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.Spacer
-import androidx.compose.foundation.layout.fillMaxSize
-import androidx.compose.foundation.layout.fillMaxWidth
-import androidx.compose.foundation.layout.height
-import androidx.compose.foundation.relocation.BringIntoViewRequester
-import androidx.compose.foundation.relocation.bringIntoViewRequester
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.LaunchedEffect
-import androidx.compose.runtime.getValue
-import androidx.compose.runtime.livedata.observeAsState
-import androidx.compose.runtime.remember
-import androidx.compose.runtime.rememberCoroutineScope
-import androidx.compose.ui.Alignment
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.draw.drawWithContent
-import androidx.compose.ui.graphics.Brush
-import androidx.compose.ui.graphics.Color
-import androidx.compose.ui.layout.ContentScale
-import androidx.compose.ui.platform.LocalContext
-import androidx.compose.ui.unit.dp
-import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
-import androidx.tv.material3.MaterialTheme
-import coil3.compose.AsyncImage
-import com.github.damontecres.dolphin.data.model.BaseItem
-import com.github.damontecres.dolphin.data.model.Video
-import com.github.damontecres.dolphin.preferences.UserPreferences
-import com.github.damontecres.dolphin.ui.components.ErrorMessage
-import com.github.damontecres.dolphin.ui.components.LoadingPage
-import com.github.damontecres.dolphin.ui.components.details.VideoDetailsHeader
-import com.github.damontecres.dolphin.ui.isNotNullOrBlank
-import com.github.damontecres.dolphin.ui.nav.Destination
-import com.github.damontecres.dolphin.util.LoadingState
-import com.github.damontecres.dolphin.util.seasonEpisode
-import dagger.hilt.android.lifecycle.HiltViewModel
-import org.jellyfin.sdk.api.client.ApiClient
-import org.jellyfin.sdk.model.api.ImageType
-import org.jellyfin.sdk.model.extensions.ticks
-import timber.log.Timber
-import javax.inject.Inject
-import kotlin.time.Duration.Companion.seconds
-
-@HiltViewModel
-class EpisodeViewModel
- @Inject
- constructor(
- api: ApiClient,
- ) : LoadingItemViewModel