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