From 763589fa0d141c4127f494a6dd43b5d3f93b74ab Mon Sep 17 00:00:00 2001 From: Damontecres Date: Sun, 12 Oct 2025 21:11:43 -0400 Subject: [PATCH 01/22] Improvement to playback activity tracking --- .../dolphin/ui/playback/PlaybackOverlay.kt | 6 ++- .../dolphin/ui/playback/PlaybackViewModel.kt | 42 +++++++++------ .../util/TrackActivityPlaybackListener.kt | 53 +++++++++++++------ 3 files changed, 68 insertions(+), 33 deletions(-) diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/playback/PlaybackOverlay.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/playback/PlaybackOverlay.kt index 5fc2f3c6..db8b9965 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/ui/playback/PlaybackOverlay.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/playback/PlaybackOverlay.kt @@ -12,7 +12,9 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.itemsIndexed @@ -375,8 +377,10 @@ fun Controller( Text( text = "Chapters", style = MaterialTheme.typography.titleLarge, - modifier = Modifier.padding(start = 16.dp), + modifier = Modifier.padding(start = 16.dp, top = 16.dp), ) + } else { + Spacer(Modifier.height(32.dp)) } } } diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/playback/PlaybackViewModel.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/playback/PlaybackViewModel.kt index df3ac97e..8df90178 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/ui/playback/PlaybackViewModel.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/playback/PlaybackViewModel.kt @@ -53,6 +53,7 @@ import org.jellyfin.sdk.model.api.MediaSegmentDto import org.jellyfin.sdk.model.api.MediaSegmentType import org.jellyfin.sdk.model.api.MediaSourceInfo import org.jellyfin.sdk.model.api.MediaStreamType +import org.jellyfin.sdk.model.api.PlayMethod import org.jellyfin.sdk.model.api.PlaybackInfoDto import org.jellyfin.sdk.model.api.SubtitlePlaybackMode import org.jellyfin.sdk.model.api.TrickplayInfo @@ -74,7 +75,7 @@ enum class TranscodeType { data class StreamDecision( val itemId: UUID, - val type: TranscodeType, + val type: PlayMethod, val url: String, ) @@ -188,7 +189,7 @@ class PlaybackViewModel }?.sortedWith(compareBy { it.language }.thenByDescending { it.channels }) .orEmpty() - // TODO audio selection based on channel layout, etc + // TODO audio selection based on channel layout or preferences or default val audioLanguage = preferences.userConfig.audioLanguagePreference val audioIndex = if (audioLanguage != null) { @@ -236,22 +237,14 @@ class PlaybackViewModel SubtitlePlaybackMode.NONE -> null } - Timber.v("base.mediaStreams=${base.mediaStreams}") - Timber.v("subtitleTracks=$subtitleStreams") - Timber.v("audioStreams=$audioStreams") +// Timber.v("base.mediaStreams=${base.mediaStreams}") +// Timber.v("subtitleTracks=$subtitleStreams") +// Timber.v("audioStreams=$audioStreams") Timber.d("Selected audioIndex=$audioIndex, subtitleIndex=$subtitleIndex") withContext(Dispatchers.Main) { this@PlaybackViewModel.audioStreams.value = audioStreams this@PlaybackViewModel.subtitleStreams.value = subtitleStreams - this@PlaybackViewModel.activityListener?.let { - it.release() - player.removeListener(it) - } - val activityListener = - TrackActivityPlaybackListener(api, itemId, player) - player.addListener(activityListener) - this@PlaybackViewModel.activityListener = activityListener changeStreams( itemId, @@ -328,9 +321,9 @@ class PlaybackViewModel } val transcodeType = when { - source.supportsDirectPlay -> TranscodeType.DIRECT_PLAY - source.supportsDirectStream -> TranscodeType.DIRECT_STREAM - source.supportsTranscoding -> TranscodeType.TRANSCODE + source.supportsDirectPlay -> PlayMethod.DIRECT_PLAY + source.supportsDirectStream -> PlayMethod.DIRECT_STREAM + source.supportsTranscoding -> PlayMethod.TRANSCODE else -> throw Exception("No supported playback method") } val decision = StreamDecision(itemId, transcodeType, mediaUrl) @@ -370,9 +363,24 @@ class PlaybackViewModel subtitleIndex, source.id?.toUUIDOrNull(), listOf(), + playMethod = transcodeType, + playSessionId = response.playSessionId, ) withContext(Dispatchers.Main) { + this@PlaybackViewModel.activityListener?.let { + it.release() + player.removeListener(it) + } + val activityListener = + TrackActivityPlaybackListener( + api = api, + player = player, + playback = playback, + ) + player.addListener(activityListener) + this@PlaybackViewModel.activityListener = activityListener + duration.value = source.runTimeTicks?.ticks stream.value = decision currentPlayback.value = playback @@ -581,6 +589,8 @@ data class CurrentPlayback( val subtitleIndex: Int?, val mediaSourceId: UUID?, val tracks: List, + val playMethod: PlayMethod, + val playSessionId: String?, ) private val Format.idAsInt: Int? diff --git a/app/src/main/java/com/github/damontecres/dolphin/util/TrackActivityPlaybackListener.kt b/app/src/main/java/com/github/damontecres/dolphin/util/TrackActivityPlaybackListener.kt index fe192222..736d9890 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/util/TrackActivityPlaybackListener.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/util/TrackActivityPlaybackListener.kt @@ -3,21 +3,22 @@ package com.github.damontecres.dolphin.util import androidx.annotation.OptIn import androidx.media3.common.Player import androidx.media3.common.util.UnstableApi +import com.github.damontecres.dolphin.ui.playback.CurrentPlayback import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.extensions.playStateApi -import org.jellyfin.sdk.model.api.PlayMethod import org.jellyfin.sdk.model.api.PlaybackOrder import org.jellyfin.sdk.model.api.PlaybackProgressInfo +import org.jellyfin.sdk.model.api.PlaybackStartInfo import org.jellyfin.sdk.model.api.PlaybackStopInfo import org.jellyfin.sdk.model.api.RepeatMode import org.jellyfin.sdk.model.extensions.inWholeTicks import timber.log.Timber import java.util.Timer import java.util.TimerTask -import java.util.UUID import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicLong import kotlin.time.Duration.Companion.milliseconds @@ -29,8 +30,8 @@ import kotlin.time.Duration.Companion.seconds @OptIn(UnstableApi::class) class TrackActivityPlaybackListener( private val api: ApiClient, - private val itemId: UUID, private val player: Player, + var playback: CurrentPlayback, ) : Player.Listener { private val coroutineScope = CoroutineScope(Dispatchers.Main) private val task: TimerTask @@ -41,6 +42,21 @@ class TrackActivityPlaybackListener( private var incrementedPlayCount = AtomicBoolean(false) init { + coroutineScope.launch(Dispatchers.IO + ExceptionHandler()) { + api.playStateApi.reportPlaybackStart( + PlaybackStartInfo( + canSeek = true, + itemId = playback.itemId, + isPaused = withContext(Dispatchers.Main) { !player.isPlaying }, + playMethod = playback.playMethod, + repeatMode = RepeatMode.REPEAT_NONE, + playbackOrder = PlaybackOrder.DEFAULT, + isMuted = false, + audioStreamIndex = playback.audioIndex, + subtitleStreamIndex = playback.subtitleIndex, + ), + ) + } val saveActivityInterval = 10.seconds.inWholeMilliseconds val delay = 1.seconds.inWholeMilliseconds // Every x seconds, check if the video is playing @@ -74,12 +90,13 @@ class TrackActivityPlaybackListener( fun release() { task.cancel() TIMER.purge() - coroutineScope.launch(ExceptionHandler()) { + coroutineScope.launch(Dispatchers.IO + ExceptionHandler()) { api.playStateApi.reportPlaybackStopped( PlaybackStopInfo( - itemId = itemId, - positionTicks = player.currentPosition.milliseconds.inWholeTicks, + itemId = playback.itemId, + positionTicks = withContext(Dispatchers.Main) { player.currentPosition.milliseconds.inWholeTicks }, failed = false, + playSessionId = playback.playSessionId, ), ) } @@ -98,28 +115,32 @@ class TrackActivityPlaybackListener( override fun onPlaybackStateChanged(playbackState: Int) { if (playbackState == Player.STATE_ENDED) { - Timber.Forest.v("onPlaybackStateChanged STATE_ENDED") - // TODO mark as watched + Timber.v("onPlaybackStateChanged STATE_ENDED") + saveActivity(player.duration) } } private fun saveActivity(position: Long) { - coroutineScope.launch(ExceptionHandler()) { - val totalDuration = totalPlayDurationMilliseconds.get() + coroutineScope.launch(Dispatchers.IO + ExceptionHandler()) { +// val totalDuration = totalPlayDurationMilliseconds.get() val calcPosition = - (if (position >= 0) position else player.currentPosition).milliseconds - Timber.Forest.v("saveActivity: itemId=$itemId, pos=$calcPosition") - // TODO parameters + withContext(Dispatchers.Main) { + (if (position >= 0) position else player.currentPosition).milliseconds + } +// Timber.v("saveActivity: itemId=$itemId, pos=$calcPosition") api.playStateApi.reportPlaybackProgress( PlaybackProgressInfo( - itemId = itemId, + itemId = playback.itemId, positionTicks = calcPosition.inWholeTicks, canSeek = true, - isPaused = !player.isPlaying, + isPaused = withContext(Dispatchers.Main) { !player.isPlaying }, isMuted = false, - playMethod = PlayMethod.DIRECT_PLAY, + playMethod = playback.playMethod, repeatMode = RepeatMode.REPEAT_NONE, playbackOrder = PlaybackOrder.DEFAULT, + playSessionId = playback.playSessionId, + audioStreamIndex = playback.audioIndex, + subtitleStreamIndex = playback.subtitleIndex, ), ) } From 207f8656ddfaf6267269919fa77798869680971d Mon Sep 17 00:00:00 2001 From: Damontecres Date: Sun, 12 Oct 2025 21:12:18 -0400 Subject: [PATCH 02/22] Updater fixes --- app/src/main/AndroidManifest.xml | 10 ++++++ .../damontecres/dolphin/util/UpdateChecker.kt | 36 +++++++++++++------ 2 files changed, 36 insertions(+), 10 deletions(-) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 741efb5b..20ae77cf 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/util/UpdateChecker.kt b/app/src/main/java/com/github/damontecres/dolphin/util/UpdateChecker.kt index 7fc35045..339d3b2a 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/util/UpdateChecker.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/util/UpdateChecker.kt @@ -31,6 +31,7 @@ import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive import okhttp3.OkHttpClient import okhttp3.Request +import okhttp3.Response import timber.log.Timber import java.io.File import java.util.Date @@ -212,8 +213,18 @@ class UpdateChecker intent.data = uri context.startActivity(intent) } else { - Timber.e("Resolver URI is null") - // TODO show error Toast + Timber.e("Resolver URI is null, trying fallback") +// showToast(context, "Unable to download the apk") + val targetFile = fallbackDownload(it) + val intent = Intent(Intent.ACTION_INSTALL_PACKAGE) + intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + intent.data = + FileProvider.getUriForFile( + activity, + activity.packageName + ".provider", + targetFile, + ) + activity.startActivity(intent) } } else { if (ContextCompat.checkSelfPermission( @@ -234,13 +245,7 @@ class UpdateChecker PERMISSION_REQUEST_CODE, ) } else { - val downloadDir = - Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS) - downloadDir.mkdirs() - val targetFile = File(downloadDir, ASSET_NAME) - targetFile.outputStream().use { output -> - it.body!!.byteStream().copyTo(output) - } + val targetFile = fallbackDownload(it) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { val intent = Intent(Intent.ACTION_INSTALL_PACKAGE) intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) @@ -261,12 +266,23 @@ class UpdateChecker } } else { Timber.v("Request failed for ${release.downloadUrl}: ${it.code}") - // TODO show error toast + showToast(context, "Error downloading the apk: ${it.code}") } } } } + private fun fallbackDownload(response: Response): File { + val downloadDir = + Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS) + downloadDir.mkdirs() + val targetFile = File(downloadDir, ASSET_NAME) + targetFile.outputStream().use { output -> + response.body!!.byteStream().copyTo(output) + } + return targetFile + } + /** * Delete previously downloaded APKs */ From 5fc3718e127963a4049c22b9f10c60b5936362de Mon Sep 17 00:00:00 2001 From: Damontecres Date: Sun, 12 Oct 2025 21:12:35 -0400 Subject: [PATCH 03/22] Home page updates & UI tweaks --- .../damontecres/dolphin/DolphinApplication.kt | 20 ++++++++++++- .../ui/components/CollectionFolderGrid.kt | 12 +++++--- .../dolphin/ui/detail/movie/MovieDetails.kt | 29 +++++++++---------- .../ui/detail/movie/MovieDetailsHeader.kt | 6 ++-- .../ui/detail/series/SeriesOverview.kt | 29 +++++++++---------- .../damontecres/dolphin/ui/main/HomePage.kt | 2 +- .../damontecres/dolphin/ui/nav/NavDrawer.kt | 1 - 7 files changed, 59 insertions(+), 40 deletions(-) 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/ui/components/CollectionFolderGrid.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/components/CollectionFolderGrid.kt index 56318a95..d27556d8 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 @@ -102,18 +102,21 @@ class CollectionFolderViewModel val request = GetItemsRequest( parentId = item.id, - isSeries = true, - mediaTypes = null, -// recursive = true, enableImageTypes = listOf(ImageType.PRIMARY, ImageType.THUMB), includeItemTypes = includeItemTypes, + recursive = true, 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 = @@ -145,6 +148,7 @@ class CollectionFolderViewModel nameLessThan = letter.toString(), limit = 0, enableTotalRecordCount = true, + recursive = true, ) val result by GetItemsRequestHandler.execute(api, request) result.totalRecordCount diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/detail/movie/MovieDetails.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/detail/movie/MovieDetails.kt index 44ad8f07..2a103269 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/ui/detail/movie/MovieDetails.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/detail/movie/MovieDetails.kt @@ -13,7 +13,6 @@ import androidx.compose.foundation.relocation.BringIntoViewRequester import androidx.compose.foundation.relocation.bringIntoViewRequester import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.PlayArrow -import androidx.compose.material.icons.filled.Settings import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -177,20 +176,20 @@ fun MovieDetails( Destination.Playback(movie), ) }, - DialogItem( - "Playback Settings", - Icons.Default.Settings, -// iconColor = Color.Green.copy(alpha = .8f), - ) { - // TODO choose audio or subtitle tracks? - }, - DialogItem( - "Play Version", - Icons.Default.PlayArrow, - iconColor = Color.Green.copy(alpha = .8f), - ) { - // TODO only show for multiple files - }, +// DialogItem( +// "Playback Settings", +// Icons.Default.Settings, +// // iconColor = Color.Green.copy(alpha = .8f), +// ) { +// // TODO choose audio or subtitle tracks? +// }, +// DialogItem( +// "Play Version", +// Icons.Default.PlayArrow, +// iconColor = Color.Green.copy(alpha = .8f), +// ) { +// // TODO only show for multiple files +// }, ), ) }, diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/detail/movie/MovieDetailsHeader.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/detail/movie/MovieDetailsHeader.kt index 53945031..7f636fd9 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/ui/detail/movie/MovieDetailsHeader.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/detail/movie/MovieDetailsHeader.kt @@ -150,8 +150,8 @@ fun MovieDetailsHeader( overflow = TextOverflow.Ellipsis, modifier = Modifier - .padding(8.dp) - .height(60.dp), + .padding(8.dp), +// .height(60.dp), ) } } @@ -161,6 +161,8 @@ fun MovieDetailsHeader( ?.let { Text( text = "Directed by $it", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, ) } // Key-Values diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/detail/series/SeriesOverview.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/detail/series/SeriesOverview.kt index 4710e7fc..0b57e8ce 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/ui/detail/series/SeriesOverview.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/detail/series/SeriesOverview.kt @@ -3,7 +3,6 @@ package com.github.damontecres.dolphin.ui.detail.series import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowForward import androidx.compose.material.icons.filled.PlayArrow -import androidx.compose.material.icons.filled.Settings import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -188,7 +187,6 @@ fun SeriesOverview( Icons.Default.PlayArrow, iconColor = Color.Green.copy(alpha = .8f), ) { - viewModel.release() viewModel.navigateTo( Destination.Playback( ep.id, @@ -202,7 +200,6 @@ fun SeriesOverview( Icons.AutoMirrored.Filled.ArrowForward, // iconColor = Color.Green.copy(alpha = .8f), ) { - viewModel.release() viewModel.navigateTo( Destination.MediaItem( series.id, @@ -211,20 +208,20 @@ fun SeriesOverview( ), ) }, - DialogItem( - "Playback Settings", - Icons.Default.Settings, +// DialogItem( +// "Playback Settings", +// Icons.Default.Settings, +// // iconColor = Color.Green.copy(alpha = .8f), +// ) { +// // TODO choose audio or subtitle tracks? +// }, +// DialogItem( +// "Play Version", +// Icons.Default.PlayArrow, // iconColor = Color.Green.copy(alpha = .8f), - ) { - // TODO choose audio or subtitle tracks? - }, - DialogItem( - "Play Version", - Icons.Default.PlayArrow, - iconColor = Color.Green.copy(alpha = .8f), - ) { - // TODO only show for multiple files - }, +// ) { +// // TODO only show for multiple files +// }, ), ) } diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/main/HomePage.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/main/HomePage.kt index 010bd969..9b7608f6 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/ui/main/HomePage.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/main/HomePage.kt @@ -121,7 +121,7 @@ fun HomePageContent( Modifier .fillMaxHeight(.7f) .fillMaxWidth(.7f) - .alpha(.4f) + .alpha(.75f) .align(Alignment.TopEnd) .drawWithContent { drawContent() diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/nav/NavDrawer.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/nav/NavDrawer.kt index e98b72bd..ac53ff41 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/ui/nav/NavDrawer.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/nav/NavDrawer.kt @@ -311,7 +311,6 @@ fun NavigationDrawerScope.LibraryNavItem( modifier: Modifier = Modifier, interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, ) { - // TODO val icon = when (library.data.collectionType) { CollectionType.MOVIES -> R.string.fa_film From 370cd2c1464d10c8e22fe8835817f4f3567efcd4 Mon Sep 17 00:00:00 2001 From: Damontecres Date: Sun, 12 Oct 2025 21:13:11 -0400 Subject: [PATCH 04/22] File for 207f865 --- app/src/main/res/xml/provider_paths.xml | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 app/src/main/res/xml/provider_paths.xml diff --git a/app/src/main/res/xml/provider_paths.xml b/app/src/main/res/xml/provider_paths.xml new file mode 100644 index 00000000..01f924c1 --- /dev/null +++ b/app/src/main/res/xml/provider_paths.xml @@ -0,0 +1,4 @@ + + + + From 4113f990b20d915441fbc5a7f728eb057fc71b90 Mon Sep 17 00:00:00 2001 From: Damontecres Date: Sun, 12 Oct 2025 22:31:09 -0400 Subject: [PATCH 05/22] Fix focus issues & add clear image cache option --- .../dolphin/preferences/AppPreference.kt | 8 ++++++++ .../dolphin/ui/components/PlayButtons.kt | 18 ++++++------------ .../ui/detail/series/FocusedEpisodeHeader.kt | 2 ++ .../ui/detail/series/SeriesOverviewContent.kt | 6 +++--- .../damontecres/dolphin/ui/main/HomePage.kt | 4 ++++ .../dolphin/ui/playback/PlaybackViewModel.kt | 16 +++++++++------- .../ui/preferences/PreferencesContent.kt | 17 +++++++++++++++++ app/src/main/res/values/strings.xml | 1 + 8 files changed, 50 insertions(+), 22 deletions(-) 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..7ac87673 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 @@ -439,6 +439,13 @@ sealed interface AppPreference { indexToValue = { SkipSegmentBehavior.forNumber(it) }, valueToIndex = { it.number }, ) + + val ClearImageCache = + AppClickablePreference( + title = R.string.clear_image_cache, + getter = { }, + setter = { prefs, _ -> prefs }, + ) } } @@ -513,6 +520,7 @@ val advancedPreferences = title = R.string.more, preferences = listOf( + AppPreference.ClearImageCache, AppPreference.OssLicenseInfo, ), ), 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/series/FocusedEpisodeHeader.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/detail/series/FocusedEpisodeHeader.kt index 1e6de9a1..be1212ab 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/ui/detail/series/FocusedEpisodeHeader.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/detail/series/FocusedEpisodeHeader.kt @@ -50,6 +50,8 @@ fun FocusedEpisodeHeader( Text( text = dto.episodeTitle ?: dto.name ?: "", style = MaterialTheme.typography.headlineSmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis, modifier = Modifier, ) Row( diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/detail/series/SeriesOverviewContent.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/detail/series/SeriesOverviewContent.kt index 6da98e3c..3a48d6fd 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/ui/detail/series/SeriesOverviewContent.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/detail/series/SeriesOverviewContent.kt @@ -16,7 +16,6 @@ import androidx.compose.foundation.lazy.rememberLazyListState 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.key import androidx.compose.runtime.mutableIntStateOf @@ -34,6 +33,7 @@ import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Tab @@ -77,8 +77,6 @@ fun SeriesOverviewContent( val tabRowFocusRequester = remember { FocusRequester() } val focusedEpisode = episodes.getOrNull(position.episodeRowIndex) - LaunchedEffect(position) { - } Box( modifier = @@ -184,6 +182,8 @@ fun SeriesOverviewContent( Text( text = it, style = MaterialTheme.typography.headlineMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, modifier = Modifier, ) } diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/main/HomePage.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/main/HomePage.kt index 9b7608f6..a0310d9d 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/ui/main/HomePage.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/main/HomePage.kt @@ -249,12 +249,16 @@ fun MainPageHeader( Text( text = title, style = MaterialTheme.typography.headlineMedium.copy(fontWeight = FontWeight.SemiBold), + maxLines = 1, + overflow = TextOverflow.Ellipsis, ) } subtitle?.let { Text( text = subtitle, style = MaterialTheme.typography.headlineSmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis, ) } if (details.isNotEmpty()) { diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/playback/PlaybackViewModel.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/playback/PlaybackViewModel.kt index 8df90178..763b4a28 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/ui/playback/PlaybackViewModel.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/playback/PlaybackViewModel.kt @@ -280,10 +280,11 @@ class PlaybackViewModel Timber.i("No change in playback for changeStreams") return } + // TODO if the new audio or subtitle index is already in the streams (eg direct play), should toggle in the player instead val maxBitrate = preferences.appPreferences.playbackPreferences.maxBitrate .takeIf { it > 0 } ?: AppPreference.DEFAULT_BITRATE - val response = + val response by api.mediaInfoApi .getPostedPlaybackInfo( itemId, @@ -302,7 +303,7 @@ class PlaybackViewModel alwaysBurnInSubtitleWhenTranscoding = null, maxStreamingBitrate = maxBitrate.toInt(), ), - ).content + ) val source = response.mediaSources.firstOrNull() source?.let { source -> val mediaUrl = @@ -335,8 +336,8 @@ class PlaybackViewModel ?.let { it.deliveryUrl?.let { deliveryUrl -> var flags = 0 - if (it.isForced) flags = flags.and(C.SELECTION_FLAG_FORCED) - if (it.isDefault) flags = flags.and(C.SELECTION_FLAG_DEFAULT) + if (it.isForced) flags = flags.or(C.SELECTION_FLAG_FORCED) + if (it.isDefault) flags = flags.or(C.SELECTION_FLAG_DEFAULT) MediaItem.SubtitleConfiguration .Builder( api.createUrl(deliveryUrl).toUri(), @@ -368,6 +369,7 @@ class PlaybackViewModel ) withContext(Dispatchers.Main) { + // TODO, don't need to release & recreate when switching streams this@PlaybackViewModel.activityListener?.let { it.release() player.removeListener(it) @@ -389,7 +391,7 @@ class PlaybackViewModel positionMs, ) if (audioIndex != null || subtitleIndex != null) { - val trackActivationListener = + val onTracksChangedListener = object : Player.Listener { override fun onTracksChanged(tracks: Tracks) { Timber.v("onTracksChanged: $tracks") @@ -408,7 +410,7 @@ class PlaybackViewModel } } } - player.addListener(trackActivationListener) + player.addListener(onTracksChangedListener) } } val trickPlayInfo = @@ -416,7 +418,7 @@ class PlaybackViewModel ?.get(source.id) ?.values ?.firstOrNull() - Timber.v("Trickplay info: $trickPlayInfo") +// Timber.v("Trickplay info: $trickPlayInfo") withContext(Dispatchers.Main) { trickplay.value = trickPlayInfo } diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/preferences/PreferencesContent.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/preferences/PreferencesContent.kt index f7fa5bc7..013b5a85 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/ui/preferences/PreferencesContent.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/preferences/PreferencesContent.kt @@ -39,6 +39,7 @@ import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text import androidx.tv.material3.surfaceColorAtElevation +import coil3.SingletonImageLoader import com.github.damontecres.dolphin.R import com.github.damontecres.dolphin.preferences.AppPreference import com.github.damontecres.dolphin.preferences.AppPreferences @@ -245,6 +246,22 @@ fun PreferencesContent( ) } + AppPreference.ClearImageCache -> { + ClickPreference( + title = stringResource(pref.title), + onClick = { + SingletonImageLoader.get(context).let { + it.memoryCache?.clear() + it.diskCache?.clear() + } + }, + modifier = Modifier, + summary = null, + onLongClick = {}, + interactionSource = interactionSource, + ) + } + else -> { val value = pref.getter.invoke(preferences) ComposablePreference( diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index adfa8142..afad58ef 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -74,5 +74,6 @@ URL used to check for app updates Updates No update available + Clear image cache From 61a69f206b4388c7771a0a06115a98ab7c81d8ef Mon Sep 17 00:00:00 2001 From: Damontecres Date: Mon, 13 Oct 2025 10:38:56 -0400 Subject: [PATCH 06/22] Long click seasons & show season overlay --- .../dolphin/ui/cards/SeasonCard.kt | 3 +- .../dolphin/ui/detail/SeriesDetails.kt | 64 ++++++++++++++++++- .../dolphin/ui/detail/SeriesViewModel.kt | 6 +- 3 files changed, 69 insertions(+), 4 deletions(-) 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..8a06a4c9 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, diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/detail/SeriesDetails.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/detail/SeriesDetails.kt index fe7e7fd2..8f492df1 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/ui/detail/SeriesDetails.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/detail/SeriesDetails.kt @@ -54,6 +54,9 @@ import com.github.damontecres.dolphin.ui.cards.ItemRow import com.github.damontecres.dolphin.ui.cards.PersonRow import com.github.damontecres.dolphin.ui.cards.SeasonCard import com.github.damontecres.dolphin.ui.components.ConfirmDialog +import com.github.damontecres.dolphin.ui.components.DialogItem +import com.github.damontecres.dolphin.ui.components.DialogParams +import com.github.damontecres.dolphin.ui.components.DialogPopup import com.github.damontecres.dolphin.ui.components.DotSeparatedRow import com.github.damontecres.dolphin.ui.components.ErrorMessage import com.github.damontecres.dolphin.ui.components.ExpandableFaButton @@ -95,6 +98,7 @@ fun SeriesDetails( var overviewDialog by remember { mutableStateOf(null) } var showWatchConfirmation by remember { mutableStateOf(false) } + var seasonDialog by remember { mutableStateOf(null) } when (val state = loading) { is LoadingState.Error -> ErrorMessage(state) @@ -112,6 +116,20 @@ fun SeriesDetails( played = played, modifier = modifier, onClickItem = { viewModel.navigateTo(it.destination()) }, + onLongClickItem = { season -> + seasonDialog = + buildDialogForSeason( + season, + onClickItem = { viewModel.navigateTo(it.destination()) }, + markPlayed = { played -> + viewModel.setWatched( + season.id, + played, + null, + ) + }, + ) + }, overviewOnClick = { overviewDialog = ItemDetailsDialogInfo( @@ -145,6 +163,15 @@ fun SeriesDetails( onDismissRequest = { overviewDialog = null }, ) } + seasonDialog?.let { params -> + DialogPopup( + showDialog = true, + title = params.title, + dialogItems = params.items, + waitToLoad = params.fromLongClick, + onDismissRequest = { seasonDialog = null }, + ) + } } @Composable @@ -155,6 +182,7 @@ fun SeriesDetailsContent( people: List, played: Boolean, onClickItem: (BaseItem) -> Unit, + onLongClickItem: (BaseItem) -> Unit, overviewOnClick: () -> Unit, playOnClick: () -> Unit, watchOnClick: () -> Unit, @@ -230,7 +258,7 @@ fun SeriesDetailsContent( title = "Seasons", items = seasons.items, onClickItem = onClickItem, - onLongClickItem = { }, + onLongClickItem = onLongClickItem, cardOnFocus = { isFocused, index -> // if (isFocused) { // scope.launch(ExceptionHandler()) { @@ -251,6 +279,7 @@ fun SeriesDetailsContent( onLongClick = onLongClick, imageHeight = Cards.height2x3, imageWidth = Dp.Unspecified, + showImageOverlay = true, modifier = mod .ifElse( @@ -388,3 +417,36 @@ fun SeriesDetailsHeader( } } } + +fun buildDialogForSeason( + s: BaseItem, + onClickItem: (BaseItem) -> Unit, + markPlayed: (Boolean) -> Unit, +): DialogParams { + val items = + buildList { + add( + DialogItem("Go to", Icons.Default.PlayArrow) { + onClickItem.invoke(s) + }, + ) + if (s.data.userData?.played == true) { + add( + DialogItem("Mark as unplayed", R.string.fa_eye) { + markPlayed.invoke(false) + }, + ) + } else { + add( + DialogItem("Mark as played", R.string.fa_eye_slash) { + markPlayed.invoke(true) + }, + ) + } + } + return DialogParams( + title = s.name ?: "Season", + fromLongClick = true, + items = items, + ) +} diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/detail/SeriesViewModel.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/detail/SeriesViewModel.kt index 57da663a..ec632794 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/ui/detail/SeriesViewModel.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/detail/SeriesViewModel.kt @@ -244,14 +244,16 @@ class SeriesViewModel fun setWatched( itemId: UUID, played: Boolean, - listIndex: Int, + listIndex: Int?, ) = viewModelScope.launch(ExceptionHandler()) { if (played) { api.playStateApi.markPlayedItem(itemId) } else { api.playStateApi.markUnplayedItem(itemId) } - refreshEpisode(itemId, listIndex) + listIndex?.let { + refreshEpisode(itemId, listIndex) + } } fun setWatchedSeries(played: Boolean) = From 3906eb196a8ddd92e5845a29b8bb93159890fffa Mon Sep 17 00:00:00 2001 From: Damontecres Date: Mon, 13 Oct 2025 11:07:16 -0400 Subject: [PATCH 07/22] Fix next up episode back handling --- .../damontecres/dolphin/ui/playback/PlaybackPage.kt | 13 ++++++------- .../dolphin/ui/playback/PlaybackViewModel.kt | 2 ++ 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/playback/PlaybackPage.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/playback/PlaybackPage.kt index 7f154064..4bf0f763 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/ui/playback/PlaybackPage.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/playback/PlaybackPage.kt @@ -183,17 +183,17 @@ fun PlaybackPage( Box( modifier - .background(Color.Black) - .onKeyEvent(keyHandler::onKeyEvent) - .focusRequester(focusRequester) - .focusable(), + .background(Color.Black), ) { val playerSize by animateFloatAsState(if (nextUp == null) 1f else .66f) Box( modifier = Modifier .fillMaxSize(playerSize) - .align(Alignment.TopCenter), + .align(Alignment.TopCenter) + .onKeyEvent(keyHandler::onKeyEvent) + .focusRequester(focusRequester) + .focusable(), ) { PlayerSurface( player = player, @@ -354,7 +354,7 @@ fun PlaybackPage( // Next up episode BackHandler(nextUp != null) { - viewModel.cancelUpNextEpisode() + viewModel.navigationManager.goBack() } AnimatedVisibility( nextUp != null, @@ -374,7 +374,6 @@ fun PlaybackPage( preferences.appPreferences.playbackPreferences.autoPlayNextDelaySeconds, ) } - // TODO need extra back press for some reason BackHandler(timeLeft > 0 && autoPlayEnabled) { timeLeft = -1 autoPlayEnabled = false diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/playback/PlaybackViewModel.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/playback/PlaybackViewModel.kt index 763b4a28..157ce0b1 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/ui/playback/PlaybackViewModel.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/playback/PlaybackViewModel.kt @@ -22,6 +22,7 @@ import com.github.damontecres.dolphin.preferences.UserPreferences import com.github.damontecres.dolphin.ui.DefaultItemFields import com.github.damontecres.dolphin.ui.indexOfFirstOrNull import com.github.damontecres.dolphin.ui.nav.Destination +import com.github.damontecres.dolphin.ui.nav.NavigationManager import com.github.damontecres.dolphin.util.ApiRequestPager import com.github.damontecres.dolphin.util.EqualityMutableLiveData import com.github.damontecres.dolphin.util.ExceptionHandler @@ -85,6 +86,7 @@ class PlaybackViewModel constructor( @ApplicationContext context: Context, val api: ApiClient, + val navigationManager: NavigationManager, ) : ViewModel() { val player: ExoPlayer = ExoPlayer From f7378188730d5135691d0d1eba108c23eed1a525 Mon Sep 17 00:00:00 2001 From: Damontecres Date: Mon, 13 Oct 2025 13:38:49 -0400 Subject: [PATCH 08/22] Refactor PlaybackViewModel to handle generic playlists --- .../dolphin/data/model/Playlist.kt | 74 ++++ .../dolphin/ui/playback/PlaybackControls.kt | 10 +- .../dolphin/ui/playback/PlaybackPage.kt | 32 +- .../dolphin/ui/playback/PlaybackViewModel.kt | 362 +++++++++--------- .../dolphin/util/ApiRequestPager.kt | 21 + 5 files changed, 314 insertions(+), 185 deletions(-) create mode 100644 app/src/main/java/com/github/damontecres/dolphin/data/model/Playlist.kt 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..32406465 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/dolphin/data/model/Playlist.kt @@ -0,0 +1,74 @@ +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) + + 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): Playlist { + val request = + GetPlaylistItemsRequest( + playlistId = playlistId, + fields = DefaultItemFields, + limit = Playlist.MAX_SIZE, + ) + val items = GetPlaylistItemsRequestHandler.execute(api, request).content.items + return Playlist(items.map { BaseItem.from(it, api) }, 0) + } + } diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/playback/PlaybackControls.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/playback/PlaybackControls.kt index 886308ee..212ce009 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/ui/playback/PlaybackControls.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/playback/PlaybackControls.kt @@ -91,6 +91,10 @@ sealed interface PlaybackAction { data class Scale( val scale: ContentScale, ) : PlaybackAction + + data object Previous : PlaybackAction + + data object Next : PlaybackAction } @OptIn(UnstableApi::class) @@ -174,6 +178,7 @@ fun PlaybackControls( player = playerControls, initialFocusRequester = initialFocusRequester, onControllerInteraction = onControllerInteraction, + onPlaybackActionClick = onPlaybackActionClick, showPlay = showPlay, previousEnabled = previousEnabled, nextEnabled = nextEnabled, @@ -443,6 +448,7 @@ fun PlaybackButtons( player: Player, initialFocusRequester: FocusRequester, onControllerInteraction: () -> Unit, + onPlaybackActionClick: (PlaybackAction) -> Unit, showPlay: Boolean, previousEnabled: Boolean, nextEnabled: Boolean, @@ -459,7 +465,7 @@ fun PlaybackButtons( iconRes = R.drawable.baseline_skip_previous_24, onClick = { onControllerInteraction.invoke() - player.seekToPrevious() + onPlaybackActionClick.invoke(PlaybackAction.Previous) }, enabled = previousEnabled, onControllerInteraction = onControllerInteraction, @@ -500,7 +506,7 @@ fun PlaybackButtons( iconRes = R.drawable.baseline_skip_next_24, onClick = { onControllerInteraction.invoke() - player.seekToNext() + onPlaybackActionClick.invoke(PlaybackAction.Next) }, enabled = nextEnabled, onControllerInteraction = onControllerInteraction, diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/playback/PlaybackPage.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/playback/PlaybackPage.kt index 4bf0f763..db20299b 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/ui/playback/PlaybackPage.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/playback/PlaybackPage.kt @@ -50,14 +50,13 @@ import androidx.media3.ui.SubtitleView import androidx.media3.ui.compose.PlayerSurface import androidx.media3.ui.compose.SURFACE_TYPE_SURFACE_VIEW import androidx.media3.ui.compose.modifiers.resizeWithContentScale -import androidx.media3.ui.compose.state.rememberNextButtonState import androidx.media3.ui.compose.state.rememberPlayPauseButtonState import androidx.media3.ui.compose.state.rememberPresentationState -import androidx.media3.ui.compose.state.rememberPreviousButtonState import androidx.tv.material3.Button import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text import androidx.tv.material3.surfaceColorAtElevation +import com.github.damontecres.dolphin.data.model.Playlist import com.github.damontecres.dolphin.preferences.UserPreferences import com.github.damontecres.dolphin.preferences.skipBackOnResume import com.github.damontecres.dolphin.ui.OneTimeLaunchedEffect @@ -105,7 +104,8 @@ fun PlaybackPage( var cues by remember { mutableStateOf>(listOf()) } var showDebugInfo by remember { mutableStateOf(prefs.showDebugInfo) } - val nextUp by viewModel.nextUpEpisode.observeAsState(null) + val nextUp by viewModel.nextUp.observeAsState(null) + val playlist by viewModel.playlist.observeAsState(Playlist(listOf())) // TODO move to viewmodel? val cueListener = @@ -138,8 +138,6 @@ fun PlaybackPage( Modifier.resizeWithContentScale(contentScale, presentationState.videoSizeDp) val focusRequester = remember { FocusRequester() } val playPauseState = rememberPlayPauseButtonState(player) - val previousState = rememberPreviousButtonState(player) - val nextState = rememberNextButtonState(player) val seekBarState = rememberSeekBarState(player, scope) LaunchedEffect(Unit) { @@ -261,8 +259,8 @@ fun PlaybackPage( playerControls = player, controllerViewState = controllerViewState, showPlay = playPauseState.showPlay, - previousEnabled = previousState.isEnabled, - nextEnabled = nextState.isEnabled, + previousEnabled = true, + nextEnabled = playlist.hasNext(), seekEnabled = true, seekForward = preferences.appPreferences.playbackPreferences.skipForwardMs.milliseconds, seekBack = preferences.appPreferences.playbackPreferences.skipBackMs.milliseconds, @@ -290,6 +288,20 @@ fun PlaybackPage( is PlaybackAction.ToggleCaptions -> { viewModel.changeSubtitleStream(it.index) } + + PlaybackAction.Next -> { + // TODO focus is lost + viewModel.playUpNextUp() + } + + PlaybackAction.Previous -> { + val pos = player.currentPosition + if (pos < player.maxSeekToPreviousPosition && playlist.hasPrevious()) { + viewModel.playPrevious() + } else { + player.seekToPrevious() + } + } } }, onSeekBarChange = seekBarState::onValueChange, @@ -381,14 +393,14 @@ fun PlaybackPage( if (autoPlayEnabled) { LaunchedEffect(Unit) { if (timeLeft == 0L) { - viewModel.playUpNextEpisode() + viewModel.playUpNextUp() } else { while (timeLeft > 0) { delay(1.seconds) timeLeft-- } if (timeLeft == 0L && autoPlayEnabled) { - viewModel.playUpNextEpisode() + viewModel.playUpNextUp() } } } @@ -402,7 +414,7 @@ fun PlaybackPage( description = it.data.overview, imageUrl = it.imageUrl, aspectRatio = it.data.primaryImageAspectRatio?.toFloat() ?: (16f / 9), - onClick = { viewModel.playUpNextEpisode() }, + onClick = { viewModel.playUpNextUp() }, timeLeft = if (autoPlayEnabled) timeLeft.seconds else null, modifier = Modifier diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/playback/PlaybackViewModel.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/playback/PlaybackViewModel.kt index 157ce0b1..b333911c 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/ui/playback/PlaybackViewModel.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/playback/PlaybackViewModel.kt @@ -16,17 +16,15 @@ import androidx.media3.common.util.UnstableApi import androidx.media3.exoplayer.ExoPlayer import com.github.damontecres.dolphin.data.model.BaseItem import com.github.damontecres.dolphin.data.model.Chapter +import com.github.damontecres.dolphin.data.model.Playlist +import com.github.damontecres.dolphin.data.model.PlaylistCreator import com.github.damontecres.dolphin.preferences.AppPreference import com.github.damontecres.dolphin.preferences.SkipSegmentBehavior import com.github.damontecres.dolphin.preferences.UserPreferences -import com.github.damontecres.dolphin.ui.DefaultItemFields -import com.github.damontecres.dolphin.ui.indexOfFirstOrNull import com.github.damontecres.dolphin.ui.nav.Destination import com.github.damontecres.dolphin.ui.nav.NavigationManager -import com.github.damontecres.dolphin.util.ApiRequestPager import com.github.damontecres.dolphin.util.EqualityMutableLiveData import com.github.damontecres.dolphin.util.ExceptionHandler -import com.github.damontecres.dolphin.util.GetEpisodesRequestHandler import com.github.damontecres.dolphin.util.TrackActivityPlaybackListener import com.github.damontecres.dolphin.util.TrackSupport import com.github.damontecres.dolphin.util.checkForSupport @@ -58,7 +56,6 @@ import org.jellyfin.sdk.model.api.PlayMethod import org.jellyfin.sdk.model.api.PlaybackInfoDto import org.jellyfin.sdk.model.api.SubtitlePlaybackMode import org.jellyfin.sdk.model.api.TrickplayInfo -import org.jellyfin.sdk.model.api.request.GetEpisodesRequest import org.jellyfin.sdk.model.extensions.inWholeTicks import org.jellyfin.sdk.model.extensions.ticks import org.jellyfin.sdk.model.serializer.toUUIDOrNull @@ -86,6 +83,7 @@ class PlaybackViewModel constructor( @ApplicationContext context: Context, val api: ApiClient, + val playlistCreator: PlaylistCreator, val navigationManager: NavigationManager, ) : ViewModel() { val player: ExoPlayer = @@ -114,9 +112,10 @@ class PlaybackViewModel private lateinit var dto: BaseItemDto private var activityListener: TrackActivityPlaybackListener? = null - private val episodes = MutableLiveData>() - private var currentEpisodeIndex = Int.MAX_VALUE - val nextUpEpisode = MutableLiveData() + val nextUp = MutableLiveData() + private var isPlaylist = false + + val playlist = MutableLiveData(Playlist(listOf())) init { addCloseable { this@PlaybackViewModel.activityListener?.release() } @@ -128,151 +127,179 @@ class PlaybackViewModel deviceProfile: DeviceProfile, preferences: UserPreferences, ) { - nextUpEpisode.value = null + nextUp.value = null this.preferences = preferences this.deviceProfile = deviceProfile val itemId = destination.itemId this.itemId = itemId val item = destination.item viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) { - val base = item?.data ?: api.userLibraryApi.getItem(itemId).content - dto = base - val title = - if (base.type == BaseItemKind.EPISODE) { - base.seriesName + val queriedItem = item?.data ?: api.userLibraryApi.getItem(itemId).content + val base = + if (queriedItem.type == BaseItemKind.PLAYLIST) { + isPlaylist = true + val playlist = playlistCreator.createFromPlaylistId(queriedItem.id) + withContext(Dispatchers.Main) { + this@PlaybackViewModel.playlist.value = playlist + } + // TODO start index + playlist.items.first().data } else { - base.name + queriedItem } - val subtitle = - if (base.type == BaseItemKind.EPISODE) { - buildList { - add(base.seasonEpisodePadded) - add(base.name) - add(base.premiereDate?.let { formatDateTime(it) }) - }.filterNotNull().joinToString(" - ") - } else { - base.productionYear?.toString() + + play(base, destination.positionMs) + + if (!isPlaylist && queriedItem.type == BaseItemKind.EPISODE) { + val playlist = + playlistCreator.createFromEpisode(queriedItem.seriesId!!, queriedItem.id) + withContext(Dispatchers.Main) { + this@PlaybackViewModel.playlist.value = playlist } - withContext(Dispatchers.Main) { - this@PlaybackViewModel.title.value = title - this@PlaybackViewModel.subtitle.value = subtitle } + maybeSetupPlaylistListener() + } + } + + private suspend fun play( + base: BaseItemDto, + positionMs: Long, + ) = withContext(Dispatchers.IO) { + Timber.i("Playing ${base.id}") + dto = base + val title = + if (base.type == BaseItemKind.EPISODE) { + base.seriesName + } else { + base.name + } + val subtitle = + if (base.type == BaseItemKind.EPISODE) { + buildList { + add(base.seasonEpisodePadded) + add(base.name) + add(base.premiereDate?.let { formatDateTime(it) }) + }.filterNotNull().joinToString(" - ") + } else { + base.productionYear?.toString() + } + withContext(Dispatchers.Main) { + this@PlaybackViewModel.title.value = title + this@PlaybackViewModel.subtitle.value = subtitle + } + base.mediaStreams + ?.filter { it.type == MediaStreamType.VIDEO } + ?.forEach { Timber.v("${it.videoRangeType}, ${it.videoRange}") } + val subtitleStreams = base.mediaStreams - ?.filter { it.type == MediaStreamType.VIDEO } - ?.forEach { Timber.v("${it.videoRangeType}, ${it.videoRange}") } - val subtitleStreams = - base.mediaStreams - ?.filter { it.type == MediaStreamType.SUBTITLE } - ?.map { - SubtitleStream( - it.index, - it.language, - it.title, - it.codec, - it.codecTag, - it.isExternal, - it.isForced, - it.isDefault, - ) - }.orEmpty() - val audioStreams = - base.mediaStreams - ?.filter { it.type == MediaStreamType.AUDIO } - ?.map { - AudioStream( - it.index, - it.language, - it.title, - it.codec, - it.codecTag, - it.channels, - it.channelLayout, - ) - }?.sortedWith(compareBy { it.language }.thenByDescending { it.channels }) - .orEmpty() + ?.filter { it.type == MediaStreamType.SUBTITLE } + ?.map { + SubtitleStream( + it.index, + it.language, + it.title, + it.codec, + it.codecTag, + it.isExternal, + it.isForced, + it.isDefault, + ) + }.orEmpty() + val audioStreams = + base.mediaStreams + ?.filter { it.type == MediaStreamType.AUDIO } + ?.map { + AudioStream( + it.index, + it.language, + it.title, + it.codec, + it.codecTag, + it.channels, + it.channelLayout, + ) + }?.sortedWith(compareBy { it.language }.thenByDescending { it.channels }) + .orEmpty() - // TODO audio selection based on channel layout or preferences or default - val audioLanguage = preferences.userConfig.audioLanguagePreference - val audioIndex = - if (audioLanguage != null) { - audioStreams.firstOrNull { it.language == audioLanguage }?.index - ?: audioStreams.firstOrNull()?.index - } else { - audioStreams.firstOrNull()?.index + // TODO audio selection based on channel layout or preferences or default + val audioLanguage = preferences.userConfig.audioLanguagePreference + val audioIndex = + if (audioLanguage != null) { + audioStreams.firstOrNull { it.language == audioLanguage }?.index + ?: audioStreams.firstOrNull()?.index + } else { + audioStreams.firstOrNull()?.index + } + val subtitleMode = preferences.userConfig.subtitleMode + val subtitleLanguage = preferences.userConfig.subtitleLanguagePreference + val subtitleIndex = + when (subtitleMode) { + SubtitlePlaybackMode.ALWAYS -> { + if (subtitleLanguage != null) { + subtitleStreams.firstOrNull { it.language == subtitleLanguage }?.index + } else { + subtitleStreams.firstOrNull()?.index + } } - val subtitleMode = preferences.userConfig.subtitleMode - val subtitleLanguage = preferences.userConfig.subtitleLanguagePreference - val subtitleIndex = - when (subtitleMode) { - SubtitlePlaybackMode.ALWAYS -> { - if (subtitleLanguage != null) { - subtitleStreams.firstOrNull { it.language == subtitleLanguage }?.index - } else { - subtitleStreams.firstOrNull()?.index - } + + SubtitlePlaybackMode.ONLY_FORCED -> + if (subtitleLanguage != null) { + subtitleStreams.firstOrNull { it.language == subtitleLanguage && it.forced }?.index + } else { + subtitleStreams.firstOrNull { it.forced }?.index } - SubtitlePlaybackMode.ONLY_FORCED -> - if (subtitleLanguage != null) { - subtitleStreams.firstOrNull { it.language == subtitleLanguage && it.forced }?.index - } else { - subtitleStreams.firstOrNull { it.forced }?.index - } - - SubtitlePlaybackMode.SMART -> { - if (audioLanguage != null && subtitleLanguage != null && audioLanguage != subtitleLanguage) { - subtitleStreams.firstOrNull { it.language == subtitleLanguage }?.index - } else { - null - } + SubtitlePlaybackMode.SMART -> { + if (audioLanguage != null && subtitleLanguage != null && audioLanguage != subtitleLanguage) { + subtitleStreams.firstOrNull { it.language == subtitleLanguage }?.index + } else { + null } - - SubtitlePlaybackMode.DEFAULT -> { - // TODO check for language? - ( - subtitleStreams.firstOrNull { it.default && it.forced } - ?: subtitleStreams.firstOrNull { it.default } - ?: subtitleStreams.firstOrNull { it.forced } - )?.index - } - - SubtitlePlaybackMode.NONE -> null } + SubtitlePlaybackMode.DEFAULT -> { + // TODO check for language? + ( + subtitleStreams.firstOrNull { it.default && it.forced } + ?: subtitleStreams.firstOrNull { it.default } + ?: subtitleStreams.firstOrNull { it.forced } + )?.index + } + + SubtitlePlaybackMode.NONE -> null + } + // Timber.v("base.mediaStreams=${base.mediaStreams}") // Timber.v("subtitleTracks=$subtitleStreams") // Timber.v("audioStreams=$audioStreams") - Timber.d("Selected audioIndex=$audioIndex, subtitleIndex=$subtitleIndex") + Timber.d("Selected audioIndex=$audioIndex, subtitleIndex=$subtitleIndex") - withContext(Dispatchers.Main) { - this@PlaybackViewModel.audioStreams.value = audioStreams - this@PlaybackViewModel.subtitleStreams.value = subtitleStreams + withContext(Dispatchers.Main) { + this@PlaybackViewModel.audioStreams.value = audioStreams + this@PlaybackViewModel.subtitleStreams.value = subtitleStreams - changeStreams( - itemId, - audioIndex, - subtitleIndex, - if (destination.positionMs > 0) destination.positionMs else C.TIME_UNSET, - ) - player.prepare() + changeStreams( + base, + audioIndex, + subtitleIndex, + if (positionMs > 0) positionMs else C.TIME_UNSET, + ) + player.prepare() - this@PlaybackViewModel.chapters.value = Chapter.fromDto(dto, api) - Timber.v("chapters=${this@PlaybackViewModel.chapters.value}") - } - if (base.type == BaseItemKind.EPISODE) { - base.seriesId?.let(::getEpisodes) - } - listenForSegments() + this@PlaybackViewModel.chapters.value = Chapter.fromDto(base, api) + Timber.v("chapters=${this@PlaybackViewModel.chapters.value?.size}") } + listenForSegments() } @OptIn(UnstableApi::class) private suspend fun changeStreams( - itemId: UUID, + item: BaseItemDto, audioIndex: Int?, subtitleIndex: Int?, positionMs: Long = C.TIME_UNSET, ) { + val itemId = item.id if (currentPlayback.value?.let { it.itemId == itemId && it.audioIndex == audioIndex && @@ -416,7 +443,7 @@ class PlaybackViewModel } } val trickPlayInfo = - dto.trickplay + item.trickplay ?.get(source.id) ?.values ?.firstOrNull() @@ -431,7 +458,7 @@ class PlaybackViewModel val itemId = currentPlayback.value?.itemId ?: return viewModelScope.launch(ExceptionHandler()) { changeStreams( - itemId, + dto, index, currentPlayback.value?.subtitleIndex, player.currentPosition, @@ -443,7 +470,7 @@ class PlaybackViewModel val itemId = currentPlayback.value?.itemId ?: return viewModelScope.launch(ExceptionHandler()) { changeStreams( - itemId, + dto, currentPlayback.value?.audioIndex, index, player.currentPosition, @@ -463,56 +490,26 @@ class PlaybackViewModel ) } - fun getEpisodes(seriesId: UUID) { - viewModelScope.launch(Dispatchers.IO) { - val request = - GetEpisodesRequest( - seriesId = seriesId, - fields = DefaultItemFields, - startItemId = itemId, - limit = 2, - ) - val episodes = GetEpisodesRequestHandler.execute(api, request).content.items - val currentEpisodeIndex = episodes.indexOfFirstOrNull { it.id == itemId } - Timber.v("Current episode is $currentEpisodeIndex of ${episodes.size}") - if (currentEpisodeIndex != null) { - val nextIndex = currentEpisodeIndex + 1 - if (nextIndex < episodes.size) { - val listener = - object : Player.Listener { - override fun onPlaybackStateChanged(playbackState: Int) { - if (playbackState == Player.STATE_ENDED) { - viewModelScope.launch(Dispatchers.IO) { - val nextItem = BaseItem.from(episodes[nextIndex], api) - Timber.v("Setting next up episode to ${nextItem.id}") - withContext(Dispatchers.Main) { - nextUpEpisode.value = nextItem - } + private fun maybeSetupPlaylistListener() { + playlist.value?.let { playlist -> + if (playlist.hasNext()) { + Timber.v("Adding lister for playlist with ${playlist.items.size} items") + val listener = + object : Player.Listener { + override fun onPlaybackStateChanged(playbackState: Int) { + if (playbackState == Player.STATE_ENDED) { + viewModelScope.launch(Dispatchers.IO + ExceptionHandler()) { + val nextItem = playlist.peek() + Timber.v("Setting next up to ${nextItem?.id}") + withContext(Dispatchers.Main) { + nextUp.value = nextItem } - player.removeListener(this) } } } - player.addListener(listener) - -// viewModelScope.launch(Dispatchers.IO) { -// while (this.isActive) { -// delay(5.seconds) -// val remaining = -// withContext(Dispatchers.Main) { -// (player.duration - player.currentPosition).milliseconds -// } -// if (remaining < 2.minutes) { // TODO time & preference -// val nextItem = episodes.getBlocking(nextIndex) -// Timber.v("Setting next up episode to ${nextItem?.id}") -// withContext(Dispatchers.Main) { -// nextUpEpisode.value = nextItem -// } -// break -// } -// } -// } - } + } + player.addListener(listener) + addCloseable { player.removeListener(listener) } } } } @@ -576,14 +573,30 @@ class PlaybackViewModel } } - fun playUpNextEpisode() { - nextUpEpisode.value?.let { - init(Destination.Playback(it.id, 0, it), deviceProfile, preferences) + fun playUpNextUp() { + playlist.value?.let { + if (it.hasNext()) { + viewModelScope.launch(ExceptionHandler()) { + cancelUpNextEpisode() + play(it.getAndAdvance().data, 0) + } + } + } + } + + fun playPrevious() { + playlist.value?.let { + if (it.hasPrevious()) { + viewModelScope.launch(ExceptionHandler()) { + cancelUpNextEpisode() + play(it.getPreviousAndReverse().data, 0) + } + } } } fun cancelUpNextEpisode() { - nextUpEpisode.value = null + nextUp.value = null } } @@ -670,3 +683,6 @@ private fun applyTrackSelections( } } } + +private val singlePlays = + setOf(BaseItemKind.EPISODE, BaseItemKind.MOVIE, BaseItemKind.VIDEO, BaseItemKind.MUSIC_VIDEO) diff --git a/app/src/main/java/com/github/damontecres/dolphin/util/ApiRequestPager.kt b/app/src/main/java/com/github/damontecres/dolphin/util/ApiRequestPager.kt index 2648db01..88c2c037 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/util/ApiRequestPager.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/util/ApiRequestPager.kt @@ -16,12 +16,14 @@ import kotlinx.coroutines.sync.withLock import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.Response import org.jellyfin.sdk.api.client.extensions.itemsApi +import org.jellyfin.sdk.api.client.extensions.playlistsApi import org.jellyfin.sdk.api.client.extensions.suggestionsApi import org.jellyfin.sdk.api.client.extensions.tvShowsApi import org.jellyfin.sdk.model.api.BaseItemDtoQueryResult import org.jellyfin.sdk.model.api.request.GetEpisodesRequest import org.jellyfin.sdk.model.api.request.GetItemsRequest import org.jellyfin.sdk.model.api.request.GetNextUpRequest +import org.jellyfin.sdk.model.api.request.GetPlaylistItemsRequest import org.jellyfin.sdk.model.api.request.GetResumeItemsRequest import org.jellyfin.sdk.model.api.request.GetSuggestionsRequest import timber.log.Timber @@ -271,3 +273,22 @@ val GetSuggestionsRequestHandler = request: GetSuggestionsRequest, ): Response = api.suggestionsApi.getSuggestions(request) } + +val GetPlaylistItemsRequestHandler = + object : RequestHandler { + override fun prepare( + request: GetPlaylistItemsRequest, + startIndex: Int, + limit: Int, + enableTotalRecordCount: Boolean, + ): GetPlaylistItemsRequest = + request.copy( + startIndex = startIndex, + limit = limit, + ) + + override suspend fun execute( + api: ApiClient, + request: GetPlaylistItemsRequest, + ): Response = api.playlistsApi.getPlaylistItems(request) + } From 07dabee8b9edb265f648e51ff04809c6dbcedb3e Mon Sep 17 00:00:00 2001 From: Damontecres Date: Mon, 13 Oct 2025 14:33:12 -0400 Subject: [PATCH 09/22] Reuse overview text composable --- .../dolphin/ui/components/OverviewText.kt | 69 +++++++++++++++++++ .../dolphin/ui/detail/SeriesDetails.kt | 50 ++------------ .../ui/detail/movie/MovieDetailsHeader.kt | 52 +++----------- .../ui/detail/series/FocusedEpisodeHeader.kt | 55 ++------------- 4 files changed, 91 insertions(+), 135 deletions(-) create mode 100644 app/src/main/java/com/github/damontecres/dolphin/ui/components/OverviewText.kt 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..c14e916e --- /dev/null +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/components/OverviewText.kt @@ -0,0 +1,69 @@ +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, +) { + 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 = true, + 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/detail/SeriesDetails.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/detail/SeriesDetails.kt index 8f492df1..1aa5c8bb 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/ui/detail/SeriesDetails.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/detail/SeriesDetails.kt @@ -1,10 +1,5 @@ package com.github.damontecres.dolphin.ui.detail -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.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -18,7 +13,6 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.relocation.BringIntoViewRequester import androidx.compose.foundation.relocation.bringIntoViewRequester -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.runtime.Composable @@ -38,7 +32,6 @@ 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.text.style.TextOverflow import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel @@ -62,6 +55,7 @@ import com.github.damontecres.dolphin.ui.components.ErrorMessage import com.github.damontecres.dolphin.ui.components.ExpandableFaButton import com.github.damontecres.dolphin.ui.components.ExpandablePlayButton import com.github.damontecres.dolphin.ui.components.LoadingPage +import com.github.damontecres.dolphin.ui.components.OverviewText import com.github.damontecres.dolphin.ui.components.StarRating import com.github.damontecres.dolphin.ui.components.StarRatingPrecision import com.github.damontecres.dolphin.ui.data.ItemDetailsDialog @@ -71,8 +65,6 @@ import com.github.damontecres.dolphin.ui.ifElse import com.github.damontecres.dolphin.ui.isNotNullOrBlank import com.github.damontecres.dolphin.ui.letNotEmpty import com.github.damontecres.dolphin.ui.nav.Destination -import com.github.damontecres.dolphin.ui.playOnClickSound -import com.github.damontecres.dolphin.ui.playSoundOnFocus import com.github.damontecres.dolphin.ui.rememberPosition import com.github.damontecres.dolphin.ui.roundMinutes import com.github.damontecres.dolphin.ui.tryRequestFocus @@ -362,40 +354,12 @@ fun SeriesDetailsHeader( } dto.overview?.let { overview -> - val interactionSource = remember { MutableInteractionSource() } - 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 = true, - interactionSource = interactionSource, - indication = LocalIndication.current, - ) { - playOnClickSound(context) - overviewOnClick.invoke() - }, - ) { - Text( - text = overview, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurface, - maxLines = 3, - overflow = TextOverflow.Ellipsis, - modifier = - Modifier - .padding(8.dp) - .height(60.dp), - ) - } + OverviewText( + overview = overview, + maxLines = 3, + onClick = overviewOnClick, + textBoxHeight = Dp.Unspecified, + ) } Row( horizontalArrangement = Arrangement.spacedBy(16.dp), diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/detail/movie/MovieDetailsHeader.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/detail/movie/MovieDetailsHeader.kt index 7f636fd9..7d133b7c 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/ui/detail/movie/MovieDetailsHeader.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/detail/movie/MovieDetailsHeader.kt @@ -1,12 +1,6 @@ package com.github.damontecres.dolphin.ui.detail.movie -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.Arrangement -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -15,9 +9,7 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.relocation.BringIntoViewRequester -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha @@ -27,18 +19,18 @@ import androidx.compose.ui.graphics.Shadow import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text import com.github.damontecres.dolphin.R import com.github.damontecres.dolphin.data.model.BaseItem import com.github.damontecres.dolphin.ui.components.DotSeparatedRow +import com.github.damontecres.dolphin.ui.components.OverviewText import com.github.damontecres.dolphin.ui.components.StarRating import com.github.damontecres.dolphin.ui.components.StarRatingPrecision import com.github.damontecres.dolphin.ui.components.TitleValueText import com.github.damontecres.dolphin.ui.isNotNullOrBlank -import com.github.damontecres.dolphin.ui.playOnClickSound -import com.github.damontecres.dolphin.ui.playSoundOnFocus import com.github.damontecres.dolphin.ui.roundMinutes import com.github.damontecres.dolphin.ui.timeRemaining import com.github.damontecres.dolphin.util.formatSubtitleLang @@ -120,40 +112,12 @@ fun MovieDetailsHeader( // Description dto.overview?.let { overview -> - val interactionSource = remember { MutableInteractionSource() } - 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 = true, - interactionSource = interactionSource, - indication = LocalIndication.current, - ) { - playOnClickSound(context) - overviewOnClick.invoke() - }, - ) { - Text( - text = overview, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurface, - maxLines = 3, - overflow = TextOverflow.Ellipsis, - modifier = - Modifier - .padding(8.dp), -// .height(60.dp), - ) - } + OverviewText( + overview = overview, + maxLines = 3, + onClick = overviewOnClick, + textBoxHeight = Dp.Unspecified, + ) } movie.data.people ?.filter { it.type == PersonKind.DIRECTOR && it.name.isNotNullOrBlank() } diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/detail/series/FocusedEpisodeHeader.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/detail/series/FocusedEpisodeHeader.kt index be1212ab..59722721 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/ui/detail/series/FocusedEpisodeHeader.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/detail/series/FocusedEpisodeHeader.kt @@ -1,23 +1,13 @@ package com.github.damontecres.dolphin.ui.detail.series -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.Arrangement -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer 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.Alignment 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 @@ -25,10 +15,9 @@ import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text import com.github.damontecres.dolphin.data.model.BaseItem import com.github.damontecres.dolphin.ui.components.DotSeparatedRow +import com.github.damontecres.dolphin.ui.components.OverviewText import com.github.damontecres.dolphin.ui.components.StarRating import com.github.damontecres.dolphin.ui.components.StarRatingPrecision -import com.github.damontecres.dolphin.ui.playOnClickSound -import com.github.damontecres.dolphin.ui.playSoundOnFocus import com.github.damontecres.dolphin.ui.roundMinutes import com.github.damontecres.dolphin.ui.timeRemaining import com.github.damontecres.dolphin.util.formatDateTime @@ -93,42 +82,12 @@ fun FocusedEpisodeHeader( } else { Spacer(Modifier.height(24.dp)) } - } - } - - val interactionSource = remember { MutableInteractionSource() } - 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 = true, - interactionSource = interactionSource, - indication = LocalIndication.current, - ) { - playOnClickSound(context) - overviewOnClick.invoke() - }, - ) { - Text( - text = dto.overview ?: "", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurface, - maxLines = 3, - overflow = TextOverflow.Ellipsis, - modifier = - Modifier - .padding(8.dp) - .height(60.dp), - ) + } ?: Spacer(Modifier.height(24.dp)) } + OverviewText( + overview = dto.overview ?: "", + maxLines = 3, + onClick = overviewOnClick, + ) } } From aab7e2eb17f4155fd0531f129cbfb50bbdbeafff Mon Sep 17 00:00:00 2001 From: Damontecres Date: Mon, 13 Oct 2025 15:18:27 -0400 Subject: [PATCH 10/22] Support for playlists --- .../dolphin/data/model/BaseItem.kt | 8 + .../dolphin/data/model/DolphinModel.kt | 1 + .../dolphin/data/model/Playlist.kt | 6 +- .../ui/components/CollectionFolderGrid.kt | 5 +- .../dolphin/ui/components/OverviewText.kt | 3 +- .../dolphin/ui/detail/ItemViewModel.kt | 15 +- .../dolphin/ui/detail/PlaylistDetails.kt | 314 ++++++++++++++++++ .../damontecres/dolphin/ui/nav/Destination.kt | 1 + .../dolphin/ui/nav/DestinationContent.kt | 14 + .../dolphin/ui/playback/PlaybackViewModel.kt | 6 +- .../damontecres/dolphin/util/Constants.kt | 1 + 11 files changed, 356 insertions(+), 18 deletions(-) create mode 100644 app/src/main/java/com/github/damontecres/dolphin/ui/detail/PlaylistDetails.kt 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..4c4b3fd6 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 @@ -23,6 +24,13 @@ data class BaseItem( @Transient val name = data.name + @Transient + 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 indexNumber = data.indexNumber 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 index 9632e318..c26598de 100644 --- 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 @@ -18,6 +18,7 @@ fun convertModel( ): DolphinModel = when (dto.type) { BaseItemKind.COLLECTION_FOLDER -> Library.fromDto(dto, api) + BaseItemKind.USER_VIEW -> Library.fromDto(dto, api) // TODO BaseItemKind.VIDEO -> Video.fromDto(dto, api) 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 index 32406465..7735e326 100644 --- 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 @@ -61,11 +61,15 @@ class PlaylistCreator return Playlist(episodes.map { BaseItem.from(it, api) }, startIndex) } - suspend fun createFromPlaylistId(playlistId: UUID): Playlist { + suspend fun createFromPlaylistId( + playlistId: UUID, + startIndex: Int?, + ): Playlist { val request = GetPlaylistItemsRequest( playlistId = playlistId, fields = DefaultItemFields, + startIndex = startIndex, limit = Playlist.MAX_SIZE, ) val items = GetPlaylistItemsRequestHandler.execute(api, request).content.items 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 d27556d8..cd81afe8 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 @@ -181,7 +181,6 @@ fun CollectionFolderGrid( 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) { @@ -193,7 +192,6 @@ fun CollectionFolderGrid( pager?.let { pager -> CollectionFolderGridContent( preferences, - library!!, item!!, pager, sortAndDirection = sortAndDirection, @@ -214,7 +212,6 @@ fun CollectionFolderGrid( @Composable fun CollectionFolderGridContent( preferences: UserPreferences, - library: Library, item: BaseItem, pager: List, sortAndDirection: SortAndDirection, @@ -225,7 +222,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 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 index c14e916e..07807567 100644 --- 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 @@ -31,6 +31,7 @@ fun OverviewText( modifier: Modifier = Modifier, interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, textBoxHeight: Dp = maxLines * 20.dp, + enabled: Boolean = true, ) { val context = LocalContext.current val isFocused = interactionSource.collectIsFocusedAsState().value @@ -46,7 +47,7 @@ fun OverviewText( .background(bgColor, shape = RoundedCornerShape(8.dp)) .playSoundOnFocus(true) .clickable( - enabled = true, + enabled = enabled, interactionSource = interactionSource, indication = LocalIndication.current, ) { diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/detail/ItemViewModel.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/detail/ItemViewModel.kt index 590085bb..49123c44 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/ui/detail/ItemViewModel.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/detail/ItemViewModel.kt @@ -4,8 +4,6 @@ import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.github.damontecres.dolphin.data.model.BaseItem -import com.github.damontecres.dolphin.data.model.DolphinModel -import com.github.damontecres.dolphin.data.model.convertModel import com.github.damontecres.dolphin.util.LoadingExceptionHandler import com.github.damontecres.dolphin.util.LoadingState import kotlinx.coroutines.Dispatchers @@ -22,16 +20,15 @@ import java.util.UUID /** * Basic [ViewModel] for a single fetchable item from the API */ -abstract class ItemViewModel( +abstract class ItemViewModel( val api: ApiClient, ) : ViewModel() { val item = MutableLiveData(null) - val model = MutableLiveData(null) suspend fun fetchItem( itemId: UUID, potential: BaseItem?, - ): BaseItem? = + ): BaseItem = withContext(Dispatchers.IO) { // val fetchedItem = // when { @@ -44,11 +41,9 @@ abstract class ItemViewModel( // } val it = api.userLibraryApi.getItem(itemId).content val fetchedItem = BaseItem.from(it, api) - return@withContext fetchedItem?.let { - val modelInstance = convertModel(fetchedItem.data, api) + return@withContext fetchedItem.let { withContext(Dispatchers.Main) { item.value = fetchedItem - model.value = modelInstance as T } fetchedItem } @@ -63,7 +58,7 @@ abstract class ItemViewModel( /** * Extends [ItemViewModel] to include a loading state tracking when the item has been fetched or if an error occurred */ -abstract class LoadingItemViewModel( +abstract class LoadingItemViewModel( api: ApiClient, ) : ItemViewModel(api) { val loading = MutableLiveData(LoadingState.Loading) @@ -80,10 +75,8 @@ abstract class LoadingItemViewModel( ) { try { val fetchedItem = api.userLibraryApi.getItem(itemId).content - val modelInstance = convertModel(fetchedItem, api) withContext(Dispatchers.Main) { item.value = BaseItem.from(fetchedItem, api) - model.value = modelInstance as T loading.value = LoadingState.Success } } catch (e: Exception) { diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/detail/PlaylistDetails.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/detail/PlaylistDetails.kt new file mode 100644 index 00000000..f9807ae9 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/detail/PlaylistDetails.kt @@ -0,0 +1,314 @@ +package com.github.damontecres.dolphin.ui.detail + +import androidx.compose.foundation.background +import androidx.compose.foundation.focusGroup +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight +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.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.shape.RoundedCornerShape +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.mutableIntStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.focusRestorer +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.unit.dp +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.viewModelScope +import androidx.tv.material3.ListItem +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Text +import androidx.tv.material3.surfaceColorAtElevation +import coil3.compose.AsyncImage +import com.github.damontecres.dolphin.data.model.BaseItem +import com.github.damontecres.dolphin.data.model.Library +import com.github.damontecres.dolphin.ui.DefaultItemFields +import com.github.damontecres.dolphin.ui.cards.ItemCardImage +import com.github.damontecres.dolphin.ui.components.ErrorMessage +import com.github.damontecres.dolphin.ui.components.LoadingPage +import com.github.damontecres.dolphin.ui.components.OverviewText +import com.github.damontecres.dolphin.ui.ifElse +import com.github.damontecres.dolphin.ui.isNotNullOrBlank +import com.github.damontecres.dolphin.ui.nav.Destination +import com.github.damontecres.dolphin.ui.nav.NavigationManager +import com.github.damontecres.dolphin.ui.roundMinutes +import com.github.damontecres.dolphin.ui.tryRequestFocus +import com.github.damontecres.dolphin.util.ApiRequestPager +import com.github.damontecres.dolphin.util.GetPlaylistItemsRequestHandler +import com.github.damontecres.dolphin.util.LoadingExceptionHandler +import com.github.damontecres.dolphin.util.LoadingState +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.jellyfin.sdk.api.client.ApiClient +import org.jellyfin.sdk.model.api.request.GetPlaylistItemsRequest +import org.jellyfin.sdk.model.extensions.ticks +import java.util.UUID +import javax.inject.Inject + +@HiltViewModel +class PlaylistViewModel + @Inject + constructor( + api: ApiClient, + val navigationManager: NavigationManager, + ) : ItemViewModel(api) { + val loading = MutableLiveData(LoadingState.Pending) + val items = MutableLiveData>(listOf()) + + fun init(playlistId: UUID) { + loading.value = LoadingState.Loading + viewModelScope.launch( + Dispatchers.IO + + LoadingExceptionHandler(loading, "Failed to fetch playlist $playlistId"), + ) { + val playlist = fetchItem(playlistId, null) + val request = + GetPlaylistItemsRequest( + playlistId = playlist.id, + fields = DefaultItemFields, + ) + val pager = ApiRequestPager(api, request, GetPlaylistItemsRequestHandler, viewModelScope).init() + withContext(Dispatchers.Main) { + items.value = pager + loading.value = LoadingState.Success + } + } + } + } + +@Composable +fun PlaylistDetails( + destination: Destination.MediaItem, + modifier: Modifier = Modifier, + viewModel: PlaylistViewModel = hiltViewModel(), +) { + LaunchedEffect(Unit) { + viewModel.init(destination.itemId) + } + val loading by viewModel.loading.observeAsState(LoadingState.Pending) + val playlist by viewModel.item.observeAsState(null) + val items by viewModel.items.observeAsState(listOf()) + + when (val st = loading) { + is LoadingState.Error -> ErrorMessage(st, modifier) + LoadingState.Pending, LoadingState.Loading -> LoadingPage(modifier) + LoadingState.Success -> + playlist?.let { + val focusRequester = remember { FocusRequester() } + LaunchedEffect(Unit) { focusRequester.tryRequestFocus() } + PlaylistDetailsContent( + playlist = it, + items = items, + onClickIndex = { index -> + viewModel.navigationManager.navigateTo( + Destination.Playback( + itemId = it.id, + positionMs = 0L, + startIndex = index, + ), + ) + }, + modifier = modifier.focusRequester(focusRequester), + ) + } + } +} + +@Composable +fun PlaylistDetailsContent( + playlist: BaseItem, + items: List, + onClickIndex: (Int) -> Unit, + modifier: Modifier = Modifier, +) { + var savedIndex by rememberSaveable { mutableIntStateOf(0) } + var focusedIndex by remember { mutableIntStateOf(savedIndex) } + val focusRequester = remember { FocusRequester() } + + val focusedItem = items.getOrNull(focusedIndex) + + Box( + modifier = modifier, + ) { + if (focusedItem?.backdropImageUrl.isNotNullOrBlank()) { + val gradientColor = MaterialTheme.colorScheme.background + AsyncImage( + model = focusedItem.backdropImageUrl, + contentDescription = null, + contentScale = ContentScale.Crop, + alignment = Alignment.TopEnd, + modifier = + Modifier + .fillMaxHeight(.85f) + .alpha(.4f) + .drawWithContent { + drawContent() + drawRect( + Brush.verticalGradient( + colors = listOf(Color.Transparent, gradientColor), + startY = 500f, + ), + ) + drawRect( + Brush.horizontalGradient( + colors = listOf(gradientColor, Color.Transparent), + endX = 400f, + startX = 100f, + ), + ) + }, + ) + } + Column( + verticalArrangement = Arrangement.spacedBy(16.dp), + modifier = + Modifier + .padding(start = 16.dp, top = 16.dp) + .fillMaxSize(), + ) { + Text( + text = playlist.name ?: "Playlist", + color = MaterialTheme.colorScheme.onSurface, + style = MaterialTheme.typography.displayMedium, + ) + PlaylistDetailsHeader( + focusedItem = focusedItem, + modifier = + Modifier + .padding(start = 16.dp) + .fillMaxWidth(.66f), + ) + LazyColumn( + contentPadding = PaddingValues(8.dp), + modifier = + Modifier + .fillMaxWidth(.8f) + .align(Alignment.CenterHorizontally) + .background( + MaterialTheme.colorScheme.surfaceColorAtElevation(1.dp), + shape = RoundedCornerShape(16.dp), + ).focusGroup() + .focusRestorer(focusRequester), + ) { + itemsIndexed(items) { index, item -> + val interactionSource = remember { MutableInteractionSource() } + ListItem( + selected = false, + onClick = { + savedIndex = index + onClickIndex.invoke(index) + }, + interactionSource = interactionSource, + headlineContent = { + Text( + text = item?.title ?: "", + style = MaterialTheme.typography.titleLarge, + ) + }, + supportingContent = { + Text( + text = item?.subtitle ?: "", + style = MaterialTheme.typography.titleSmall, + ) + }, + trailingContent = { + item?.data?.runTimeTicks?.ticks?.roundMinutes?.let { + Text( + text = it.toString(), + ) + } + }, + leadingContent = { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(16.dp), + ) { + Text( + text = "${index + 1}.", + style = MaterialTheme.typography.labelLarge, + ) + ItemCardImage( + imageUrl = item?.imageUrl, + name = item?.name, + showOverlay = true, + favorite = item?.data?.userData?.isFavorite ?: false, + watched = item?.data?.userData?.played ?: false, + unwatchedCount = item?.data?.userData?.unplayedItemCount ?: -1, + watchedPercent = 0.0, + modifier = Modifier.width(160.dp), + useFallbackText = false, + ) + } + }, + modifier = + Modifier + .height(80.dp) + .ifElse( + index == savedIndex, + Modifier.focusRequester(focusRequester), + ).onFocusChanged { + if (it.isFocused) { + focusedIndex = index + } + }, + ) + } + } + } + } +} + +@Composable +fun PlaylistDetailsHeader( + focusedItem: BaseItem?, + modifier: Modifier = Modifier, +) { + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = modifier, + ) { + Text( + text = focusedItem?.title ?: "", + color = MaterialTheme.colorScheme.onSurface, + style = MaterialTheme.typography.headlineLarge, + ) + Text( + text = focusedItem?.subtitle ?: "", + color = MaterialTheme.colorScheme.onSurface, + style = MaterialTheme.typography.headlineSmall, + ) + OverviewText( + overview = focusedItem?.data?.overview ?: "", + maxLines = 2, + onClick = {}, + enabled = false, + ) + } +} diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/nav/Destination.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/nav/Destination.kt index e384f8bf..4c13c09b 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/ui/nav/Destination.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/nav/Destination.kt @@ -67,6 +67,7 @@ sealed class Destination( val itemId: UUID, val positionMs: Long, @Transient val item: BaseItem? = null, + val startIndex: Int? = null, ) : Destination(true) { override fun toString(): String = "Playback(itemId=$itemId, positionMs=$positionMs)" diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/nav/DestinationContent.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/nav/DestinationContent.kt index 9eaa1c36..05914f03 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/ui/nav/DestinationContent.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/nav/DestinationContent.kt @@ -9,6 +9,7 @@ import com.github.damontecres.dolphin.ui.detail.CollectionFolderGeneric import com.github.damontecres.dolphin.ui.detail.CollectionFolderMovie import com.github.damontecres.dolphin.ui.detail.CollectionFolderTv import com.github.damontecres.dolphin.ui.detail.EpisodeDetails +import com.github.damontecres.dolphin.ui.detail.PlaylistDetails import com.github.damontecres.dolphin.ui.detail.SeasonDetails import com.github.damontecres.dolphin.ui.detail.SeriesDetails import com.github.damontecres.dolphin.ui.detail.movie.MovieDetails @@ -130,6 +131,19 @@ fun DestinationContent( } } + BaseItemKind.PLAYLIST -> + PlaylistDetails( + destination = destination, + modifier = modifier, + ) + + BaseItemKind.USER_VIEW -> + CollectionFolderGeneric( + preferences, + destination, + modifier, + ) + else -> { Text("Unsupported item type: ${destination.type}") } diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/playback/PlaybackViewModel.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/playback/PlaybackViewModel.kt index b333911c..8ce8e7ea 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/ui/playback/PlaybackViewModel.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/playback/PlaybackViewModel.kt @@ -138,7 +138,11 @@ class PlaybackViewModel val base = if (queriedItem.type == BaseItemKind.PLAYLIST) { isPlaylist = true - val playlist = playlistCreator.createFromPlaylistId(queriedItem.id) + val playlist = + playlistCreator.createFromPlaylistId( + queriedItem.id, + destination.startIndex, + ) withContext(Dispatchers.Main) { this@PlaybackViewModel.playlist.value = playlist } diff --git a/app/src/main/java/com/github/damontecres/dolphin/util/Constants.kt b/app/src/main/java/com/github/damontecres/dolphin/util/Constants.kt index 243b6bac..e65b2c7a 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/util/Constants.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/util/Constants.kt @@ -27,4 +27,5 @@ val supportedCollectionTypes = CollectionType.MOVIES, CollectionType.TVSHOWS, CollectionType.HOMEVIDEOS, + CollectionType.PLAYLISTS, ) From 9148ca07190c71221d6c288b4ff037319b8dc3f6 Mon Sep 17 00:00:00 2001 From: Damontecres Date: Mon, 13 Oct 2025 16:23:50 -0400 Subject: [PATCH 11/22] Playlist UI updates & more handling of unsupported playback --- .../dolphin/ui/components/Dialogs.kt | 18 ++ .../dolphin/ui/detail/PlaylistDetails.kt | 274 ++++++++++++------ .../dolphin/ui/playback/PlaybackViewModel.kt | 246 ++++++++-------- 3 files changed, 337 insertions(+), 201 deletions(-) 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/detail/PlaylistDetails.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/detail/PlaylistDetails.kt index f9807ae9..e04ecfe6 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/ui/detail/PlaylistDetails.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/detail/PlaylistDetails.kt @@ -3,6 +3,7 @@ package com.github.damontecres.dolphin.ui.detail import androidx.compose.foundation.background import androidx.compose.foundation.focusGroup 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 @@ -17,11 +18,15 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowForward +import androidx.compose.material.icons.filled.PlayArrow 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.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue @@ -36,6 +41,7 @@ import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.MutableLiveData @@ -49,9 +55,13 @@ import com.github.damontecres.dolphin.data.model.BaseItem import com.github.damontecres.dolphin.data.model.Library import com.github.damontecres.dolphin.ui.DefaultItemFields import com.github.damontecres.dolphin.ui.cards.ItemCardImage +import com.github.damontecres.dolphin.ui.components.DialogItem +import com.github.damontecres.dolphin.ui.components.DialogParams +import com.github.damontecres.dolphin.ui.components.DialogPopup import com.github.damontecres.dolphin.ui.components.ErrorMessage import com.github.damontecres.dolphin.ui.components.LoadingPage import com.github.damontecres.dolphin.ui.components.OverviewText +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.nav.Destination @@ -116,6 +126,8 @@ fun PlaylistDetails( val playlist by viewModel.item.observeAsState(null) val items by viewModel.items.observeAsState(listOf()) + var longClickDialog by remember { mutableStateOf(null) } + when (val st = loading) { is LoadingState.Error -> ErrorMessage(st, modifier) LoadingState.Pending, LoadingState.Loading -> LoadingPage(modifier) @@ -126,7 +138,8 @@ fun PlaylistDetails( PlaylistDetailsContent( playlist = it, items = items, - onClickIndex = { index -> + focusRequester = focusRequester, + onClickIndex = { index, _ -> viewModel.navigationManager.navigateTo( Destination.Playback( itemId = it.id, @@ -135,23 +148,64 @@ fun PlaylistDetails( ), ) }, - modifier = modifier.focusRequester(focusRequester), + onLongClickIndex = { index, item -> + longClickDialog = + DialogParams( + fromLongClick = true, + title = item.name ?: "", + items = + listOf( + DialogItem( + "Go to", + Icons.Default.ArrowForward, + ) { + viewModel.navigationManager.navigateTo( + Destination.MediaItem( + itemId = item.id, + type = item.type, + item = item, + ), + ) + }, + DialogItem( + "Play from here", + Icons.Default.PlayArrow, + ) { + viewModel.navigationManager.navigateTo( + Destination.Playback( + itemId = it.id, + positionMs = 0L, + startIndex = index, + ), + ) + }, + ), + ) + }, + modifier = modifier, ) } } + longClickDialog?.let { params -> + DialogPopup( + params = params, + onDismissRequest = { longClickDialog = null }, + ) + } } @Composable fun PlaylistDetailsContent( playlist: BaseItem, items: List, - onClickIndex: (Int) -> Unit, + onClickIndex: (Int, BaseItem) -> Unit, + onLongClickIndex: (Int, BaseItem) -> Unit, modifier: Modifier = Modifier, + focusRequester: FocusRequester = remember { FocusRequester() }, ) { var savedIndex by rememberSaveable { mutableIntStateOf(0) } var focusedIndex by remember { mutableIntStateOf(savedIndex) } - val focusRequester = remember { FocusRequester() } - + val focus = remember { FocusRequester() } val focusedItem = items.getOrNull(focusedIndex) Box( @@ -190,95 +244,81 @@ fun PlaylistDetailsContent( verticalArrangement = Arrangement.spacedBy(16.dp), modifier = Modifier - .padding(start = 16.dp, top = 16.dp) + .padding(top = 16.dp) .fillMaxSize(), ) { - Text( - text = playlist.name ?: "Playlist", - color = MaterialTheme.colorScheme.onSurface, - style = MaterialTheme.typography.displayMedium, - ) - PlaylistDetailsHeader( - focusedItem = focusedItem, + Row( + horizontalArrangement = Arrangement.spacedBy(32.dp), modifier = Modifier - .padding(start = 16.dp) - .fillMaxWidth(.66f), - ) - LazyColumn( - contentPadding = PaddingValues(8.dp), - modifier = - Modifier - .fillMaxWidth(.8f) - .align(Alignment.CenterHorizontally) - .background( - MaterialTheme.colorScheme.surfaceColorAtElevation(1.dp), - shape = RoundedCornerShape(16.dp), - ).focusGroup() - .focusRestorer(focusRequester), + .padding(horizontal = 16.dp) + .fillMaxWidth(), ) { - itemsIndexed(items) { index, item -> - val interactionSource = remember { MutableInteractionSource() } - ListItem( - selected = false, - onClick = { - savedIndex = index - onClickIndex.invoke(index) - }, - interactionSource = interactionSource, - headlineContent = { - Text( - text = item?.title ?: "", - style = MaterialTheme.typography.titleLarge, - ) - }, - supportingContent = { - Text( - text = item?.subtitle ?: "", - style = MaterialTheme.typography.titleSmall, - ) - }, - trailingContent = { - item?.data?.runTimeTicks?.ticks?.roundMinutes?.let { - Text( - text = it.toString(), - ) - } - }, - leadingContent = { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(16.dp), - ) { - Text( - text = "${index + 1}.", - style = MaterialTheme.typography.labelLarge, - ) - ItemCardImage( - imageUrl = item?.imageUrl, - name = item?.name, - showOverlay = true, - favorite = item?.data?.userData?.isFavorite ?: false, - watched = item?.data?.userData?.played ?: false, - unwatchedCount = item?.data?.userData?.unplayedItemCount ?: -1, - watchedPercent = 0.0, - modifier = Modifier.width(160.dp), - useFallbackText = false, - ) - } - }, + PlaylistDetailsHeader( + focusedItem = focusedItem, + modifier = + Modifier + .padding(start = 16.dp) + .fillMaxWidth(.25f), + ) + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) { + Text( + text = playlist.name ?: "Playlist", + color = MaterialTheme.colorScheme.onSurface, + style = MaterialTheme.typography.displayMedium, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth(), + ) + LazyColumn( + contentPadding = PaddingValues(8.dp), modifier = Modifier - .height(80.dp) - .ifElse( - index == savedIndex, - Modifier.focusRequester(focusRequester), - ).onFocusChanged { - if (it.isFocused) { - focusedIndex = index + .padding(bottom = 32.dp) + .fillMaxHeight() +// .fillMaxWidth(.8f) + .weight(1f) + .background( + MaterialTheme.colorScheme.surfaceColorAtElevation(1.dp), + shape = RoundedCornerShape(16.dp), + ).focusRequester(focusRequester) + .focusGroup() + .focusRestorer(focus), + ) { + itemsIndexed(items) { index, item -> + PlaylistItem( + item = item, + index = index, + onClick = { + savedIndex = index + item?.let { + onClickIndex.invoke(index, item) } }, - ) + onLongClick = { + savedIndex = index + item?.let { + onLongClickIndex.invoke(index, item) + } + }, + modifier = + Modifier + .height(80.dp) + .ifElse( + index == savedIndex, + Modifier.focusRequester(focus), + ).onFocusChanged { + if (it.isFocused) { + focusedIndex = index + } + }, + ) + } + } } } } @@ -306,9 +346,71 @@ fun PlaylistDetailsHeader( ) OverviewText( overview = focusedItem?.data?.overview ?: "", - maxLines = 2, + maxLines = 10, onClick = {}, enabled = false, ) } } + +@Composable +fun PlaylistItem( + item: BaseItem?, + index: Int, + onClick: () -> Unit, + onLongClick: () -> Unit, + modifier: Modifier = Modifier, + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, +) { + val focused by interactionSource.collectIsFocusedAsState() + ListItem( + selected = false, + onClick = onClick, + onLongClick = onLongClick, + interactionSource = interactionSource, + headlineContent = { + Text( + text = item?.title ?: "", + style = MaterialTheme.typography.titleLarge, + modifier = Modifier.enableMarquee(focused), + ) + }, + supportingContent = { + Text( + text = item?.subtitle ?: "", + style = MaterialTheme.typography.titleSmall, + modifier = Modifier.enableMarquee(focused), + ) + }, + trailingContent = { + item?.data?.runTimeTicks?.ticks?.roundMinutes?.let { + Text( + text = it.toString(), + ) + } + }, + leadingContent = { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(16.dp), + ) { + Text( + text = "${index + 1}.", + style = MaterialTheme.typography.labelLarge, + ) + ItemCardImage( + imageUrl = item?.imageUrl, + name = item?.name, + showOverlay = true, + favorite = item?.data?.userData?.isFavorite ?: false, + watched = item?.data?.userData?.played ?: false, + unwatchedCount = item?.data?.userData?.unplayedItemCount ?: -1, + watchedPercent = 0.0, + modifier = Modifier.width(160.dp), + useFallbackText = false, + ) + } + }, + modifier = modifier, + ) +} diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/playback/PlaybackViewModel.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/playback/PlaybackViewModel.kt index 8ce8e7ea..198c10a1 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/ui/playback/PlaybackViewModel.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/playback/PlaybackViewModel.kt @@ -23,6 +23,7 @@ import com.github.damontecres.dolphin.preferences.SkipSegmentBehavior import com.github.damontecres.dolphin.preferences.UserPreferences import com.github.damontecres.dolphin.ui.nav.Destination import com.github.damontecres.dolphin.ui.nav.NavigationManager +import com.github.damontecres.dolphin.ui.showToast import com.github.damontecres.dolphin.util.EqualityMutableLiveData import com.github.damontecres.dolphin.util.ExceptionHandler import com.github.damontecres.dolphin.util.TrackActivityPlaybackListener @@ -31,6 +32,7 @@ import com.github.damontecres.dolphin.util.checkForSupport import com.github.damontecres.dolphin.util.formatDateTime import com.github.damontecres.dolphin.util.seasonEpisodePadded import com.github.damontecres.dolphin.util.subtitleMimeTypes +import com.github.damontecres.dolphin.util.supportItemKinds import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.Dispatchers @@ -81,7 +83,7 @@ data class StreamDecision( class PlaybackViewModel @Inject constructor( - @ApplicationContext context: Context, + @param:ApplicationContext val context: Context, val api: ApiClient, val playlistCreator: PlaylistCreator, val navigationManager: NavigationManager, @@ -168,133 +170,139 @@ class PlaybackViewModel private suspend fun play( base: BaseItemDto, positionMs: Long, - ) = withContext(Dispatchers.IO) { - Timber.i("Playing ${base.id}") - dto = base - val title = - if (base.type == BaseItemKind.EPISODE) { - base.seriesName - } else { - base.name + ): Boolean = + withContext(Dispatchers.IO) { + Timber.i("Playing ${base.id}") + if (base.type !in supportItemKinds) { + showToast(context, "Unsupported type '${base.type}', skipping...") + return@withContext false } - val subtitle = - if (base.type == BaseItemKind.EPISODE) { - buildList { - add(base.seasonEpisodePadded) - add(base.name) - add(base.premiereDate?.let { formatDateTime(it) }) - }.filterNotNull().joinToString(" - ") - } else { - base.productionYear?.toString() + dto = base + val title = + if (base.type == BaseItemKind.EPISODE) { + base.seriesName + } else { + base.name + } + val subtitle = + if (base.type == BaseItemKind.EPISODE) { + buildList { + add(base.seasonEpisodePadded) + add(base.name) + add(base.premiereDate?.let { formatDateTime(it) }) + }.filterNotNull().joinToString(" - ") + } else { + base.productionYear?.toString() + } + withContext(Dispatchers.Main) { + this@PlaybackViewModel.title.value = title + this@PlaybackViewModel.subtitle.value = subtitle } - withContext(Dispatchers.Main) { - this@PlaybackViewModel.title.value = title - this@PlaybackViewModel.subtitle.value = subtitle - } - base.mediaStreams - ?.filter { it.type == MediaStreamType.VIDEO } - ?.forEach { Timber.v("${it.videoRangeType}, ${it.videoRange}") } - val subtitleStreams = base.mediaStreams - ?.filter { it.type == MediaStreamType.SUBTITLE } - ?.map { - SubtitleStream( - it.index, - it.language, - it.title, - it.codec, - it.codecTag, - it.isExternal, - it.isForced, - it.isDefault, - ) - }.orEmpty() - val audioStreams = - base.mediaStreams - ?.filter { it.type == MediaStreamType.AUDIO } - ?.map { - AudioStream( - it.index, - it.language, - it.title, - it.codec, - it.codecTag, - it.channels, - it.channelLayout, - ) - }?.sortedWith(compareBy { it.language }.thenByDescending { it.channels }) - .orEmpty() + ?.filter { it.type == MediaStreamType.VIDEO } + ?.forEach { Timber.v("${it.videoRangeType}, ${it.videoRange}") } + val subtitleStreams = + base.mediaStreams + ?.filter { it.type == MediaStreamType.SUBTITLE } + ?.map { + SubtitleStream( + it.index, + it.language, + it.title, + it.codec, + it.codecTag, + it.isExternal, + it.isForced, + it.isDefault, + ) + }.orEmpty() + val audioStreams = + base.mediaStreams + ?.filter { it.type == MediaStreamType.AUDIO } + ?.map { + AudioStream( + it.index, + it.language, + it.title, + it.codec, + it.codecTag, + it.channels, + it.channelLayout, + ) + }?.sortedWith(compareBy { it.language }.thenByDescending { it.channels }) + .orEmpty() - // TODO audio selection based on channel layout or preferences or default - val audioLanguage = preferences.userConfig.audioLanguagePreference - val audioIndex = - if (audioLanguage != null) { - audioStreams.firstOrNull { it.language == audioLanguage }?.index - ?: audioStreams.firstOrNull()?.index - } else { - audioStreams.firstOrNull()?.index - } - val subtitleMode = preferences.userConfig.subtitleMode - val subtitleLanguage = preferences.userConfig.subtitleLanguagePreference - val subtitleIndex = - when (subtitleMode) { - SubtitlePlaybackMode.ALWAYS -> { - if (subtitleLanguage != null) { - subtitleStreams.firstOrNull { it.language == subtitleLanguage }?.index - } else { - subtitleStreams.firstOrNull()?.index - } + // TODO audio selection based on channel layout or preferences or default + val audioLanguage = preferences.userConfig.audioLanguagePreference + val audioIndex = + if (audioLanguage != null) { + audioStreams.firstOrNull { it.language == audioLanguage }?.index + ?: audioStreams.firstOrNull()?.index + } else { + audioStreams.firstOrNull()?.index } - - SubtitlePlaybackMode.ONLY_FORCED -> - if (subtitleLanguage != null) { - subtitleStreams.firstOrNull { it.language == subtitleLanguage && it.forced }?.index - } else { - subtitleStreams.firstOrNull { it.forced }?.index + val subtitleMode = preferences.userConfig.subtitleMode + val subtitleLanguage = preferences.userConfig.subtitleLanguagePreference + val subtitleIndex = + when (subtitleMode) { + SubtitlePlaybackMode.ALWAYS -> { + if (subtitleLanguage != null) { + subtitleStreams.firstOrNull { it.language == subtitleLanguage }?.index + } else { + subtitleStreams.firstOrNull()?.index + } } - SubtitlePlaybackMode.SMART -> { - if (audioLanguage != null && subtitleLanguage != null && audioLanguage != subtitleLanguage) { - subtitleStreams.firstOrNull { it.language == subtitleLanguage }?.index - } else { - null + SubtitlePlaybackMode.ONLY_FORCED -> + if (subtitleLanguage != null) { + subtitleStreams.firstOrNull { it.language == subtitleLanguage && it.forced }?.index + } else { + subtitleStreams.firstOrNull { it.forced }?.index + } + + SubtitlePlaybackMode.SMART -> { + if (audioLanguage != null && subtitleLanguage != null && audioLanguage != subtitleLanguage) { + subtitleStreams.firstOrNull { it.language == subtitleLanguage }?.index + } else { + null + } } - } - SubtitlePlaybackMode.DEFAULT -> { - // TODO check for language? - ( - subtitleStreams.firstOrNull { it.default && it.forced } - ?: subtitleStreams.firstOrNull { it.default } - ?: subtitleStreams.firstOrNull { it.forced } - )?.index - } + SubtitlePlaybackMode.DEFAULT -> { + // TODO check for language? + ( + subtitleStreams.firstOrNull { it.default && it.forced } + ?: subtitleStreams.firstOrNull { it.default } + ?: subtitleStreams.firstOrNull { it.forced } + )?.index + } - SubtitlePlaybackMode.NONE -> null - } + SubtitlePlaybackMode.NONE -> null + } // Timber.v("base.mediaStreams=${base.mediaStreams}") // Timber.v("subtitleTracks=$subtitleStreams") // Timber.v("audioStreams=$audioStreams") - Timber.d("Selected audioIndex=$audioIndex, subtitleIndex=$subtitleIndex") + Timber.d("Selected audioIndex=$audioIndex, subtitleIndex=$subtitleIndex") - withContext(Dispatchers.Main) { - this@PlaybackViewModel.audioStreams.value = audioStreams - this@PlaybackViewModel.subtitleStreams.value = subtitleStreams + withContext(Dispatchers.Main) { + this@PlaybackViewModel.audioStreams.value = audioStreams + this@PlaybackViewModel.subtitleStreams.value = subtitleStreams - changeStreams( - base, - audioIndex, - subtitleIndex, - if (positionMs > 0) positionMs else C.TIME_UNSET, - ) - player.prepare() + changeStreams( + base, + audioIndex, + subtitleIndex, + if (positionMs > 0) positionMs else C.TIME_UNSET, + ) + player.prepare() - this@PlaybackViewModel.chapters.value = Chapter.fromDto(base, api) - Timber.v("chapters=${this@PlaybackViewModel.chapters.value?.size}") + this@PlaybackViewModel.chapters.value = Chapter.fromDto(base, api) + Timber.v("chapters=${this@PlaybackViewModel.chapters.value?.size}") + } + listenForSegments() + return@withContext true } - listenForSegments() - } @OptIn(UnstableApi::class) private suspend fun changeStreams( @@ -582,7 +590,11 @@ class PlaybackViewModel if (it.hasNext()) { viewModelScope.launch(ExceptionHandler()) { cancelUpNextEpisode() - play(it.getAndAdvance().data, 0) + val item = it.getAndAdvance() + val played = play(item.data, 0) + if (!played) { + playUpNextUp() + } } } } @@ -593,7 +605,11 @@ class PlaybackViewModel if (it.hasPrevious()) { viewModelScope.launch(ExceptionHandler()) { cancelUpNextEpisode() - play(it.getPreviousAndReverse().data, 0) + val item = it.getPreviousAndReverse() + val played = play(item.data, 0) + if (!played) { + playPrevious() + } } } } @@ -602,6 +618,9 @@ class PlaybackViewModel fun cancelUpNextEpisode() { nextUp.value = null } + + private fun toastUnsupported(item: BaseItemDto) { + } } data class CurrentPlayback( @@ -687,6 +706,3 @@ private fun applyTrackSelections( } } } - -private val singlePlays = - setOf(BaseItemKind.EPISODE, BaseItemKind.MOVIE, BaseItemKind.VIDEO, BaseItemKind.MUSIC_VIDEO) From a93f183f5f88d61a4cece2d3e019aec376aca883 Mon Sep 17 00:00:00 2001 From: Damontecres Date: Mon, 13 Oct 2025 16:41:12 -0400 Subject: [PATCH 12/22] Handle shuffling playlists --- .../dolphin/data/model/Playlist.kt | 7 ++- .../dolphin/ui/detail/PlaylistDetails.kt | 46 ++++++++++++++++++- .../damontecres/dolphin/ui/nav/Destination.kt | 1 + .../dolphin/ui/playback/PlaybackViewModel.kt | 13 +++++- app/src/main/res/values/fa_strings.xml | 1 + app/src/main/res/values/strings.xml | 1 + 6 files changed, 65 insertions(+), 4 deletions(-) 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 index 7735e326..e97a7b88 100644 --- 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 @@ -64,6 +64,7 @@ class PlaylistCreator suspend fun createFromPlaylistId( playlistId: UUID, startIndex: Int?, + shuffled: Boolean, ): Playlist { val request = GetPlaylistItemsRequest( @@ -73,6 +74,10 @@ class PlaylistCreator limit = Playlist.MAX_SIZE, ) val items = GetPlaylistItemsRequestHandler.execute(api, request).content.items - return Playlist(items.map { BaseItem.from(it, api) }, 0) + 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/ui/detail/PlaylistDetails.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/detail/PlaylistDetails.kt index e04ecfe6..0072248f 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/ui/detail/PlaylistDetails.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/detail/PlaylistDetails.kt @@ -35,6 +35,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.drawWithContent import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusProperties import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.focusRestorer import androidx.compose.ui.focus.onFocusChanged @@ -51,6 +52,7 @@ import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text import androidx.tv.material3.surfaceColorAtElevation import coil3.compose.AsyncImage +import com.github.damontecres.dolphin.R import com.github.damontecres.dolphin.data.model.BaseItem import com.github.damontecres.dolphin.data.model.Library import com.github.damontecres.dolphin.ui.DefaultItemFields @@ -59,6 +61,8 @@ import com.github.damontecres.dolphin.ui.components.DialogItem import com.github.damontecres.dolphin.ui.components.DialogParams import com.github.damontecres.dolphin.ui.components.DialogPopup import com.github.damontecres.dolphin.ui.components.ErrorMessage +import com.github.damontecres.dolphin.ui.components.ExpandableFaButton +import com.github.damontecres.dolphin.ui.components.ExpandablePlayButton import com.github.damontecres.dolphin.ui.components.LoadingPage import com.github.damontecres.dolphin.ui.components.OverviewText import com.github.damontecres.dolphin.ui.enableMarquee @@ -81,6 +85,7 @@ import org.jellyfin.sdk.model.api.request.GetPlaylistItemsRequest import org.jellyfin.sdk.model.extensions.ticks import java.util.UUID import javax.inject.Inject +import kotlin.time.Duration @HiltViewModel class PlaylistViewModel @@ -148,6 +153,16 @@ fun PlaylistDetails( ), ) }, + onClickPlay = { shuffle -> + viewModel.navigationManager.navigateTo( + Destination.Playback( + itemId = it.id, + positionMs = 0L, + startIndex = 0, + shuffle = shuffle, + ), + ) + }, onLongClickIndex = { index, item -> longClickDialog = DialogParams( @@ -200,6 +215,7 @@ fun PlaylistDetailsContent( items: List, onClickIndex: (Int, BaseItem) -> Unit, onLongClickIndex: (Int, BaseItem) -> Unit, + onClickPlay: (shuffle: Boolean) -> Unit, modifier: Modifier = Modifier, focusRequester: FocusRequester = remember { FocusRequester() }, ) { @@ -208,6 +224,8 @@ fun PlaylistDetailsContent( val focus = remember { FocusRequester() } val focusedItem = items.getOrNull(focusedIndex) + val playButtonFocusRequester = remember { FocusRequester() } + Box( modifier = modifier, ) { @@ -248,6 +266,7 @@ fun PlaylistDetailsContent( .fillMaxSize(), ) { Row( + verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(32.dp), modifier = Modifier @@ -256,6 +275,8 @@ fun PlaylistDetailsContent( ) { PlaylistDetailsHeader( focusedItem = focusedItem, + onClickPlay = onClickPlay, + playButtonFocusRequester = playButtonFocusRequester, modifier = Modifier .padding(start = 16.dp) @@ -283,7 +304,9 @@ fun PlaylistDetailsContent( // .fillMaxWidth(.8f) .weight(1f) .background( - MaterialTheme.colorScheme.surfaceColorAtElevation(1.dp), + MaterialTheme.colorScheme + .surfaceColorAtElevation(1.dp) + .copy(alpha = .75f), shape = RoundedCornerShape(16.dp), ).focusRequester(focusRequester) .focusGroup() @@ -315,6 +338,9 @@ fun PlaylistDetailsContent( if (it.isFocused) { focusedIndex = index } + }.focusProperties { + left = playButtonFocusRequester + previous = playButtonFocusRequester }, ) } @@ -328,12 +354,30 @@ fun PlaylistDetailsContent( @Composable fun PlaylistDetailsHeader( focusedItem: BaseItem?, + onClickPlay: (shuffle: Boolean) -> Unit, + playButtonFocusRequester: FocusRequester, modifier: Modifier = Modifier, ) { Column( verticalArrangement = Arrangement.spacedBy(8.dp), modifier = modifier, ) { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.focusRequester(playButtonFocusRequester), + ) { + ExpandablePlayButton( + title = R.string.play, + resume = Duration.ZERO, + icon = Icons.Default.PlayArrow, + onClick = { onClickPlay.invoke(false) }, + ) + ExpandableFaButton( + title = R.string.shuffle, + iconStringRes = R.string.fa_shuffle, + onClick = { onClickPlay.invoke(true) }, + ) + } Text( text = focusedItem?.title ?: "", color = MaterialTheme.colorScheme.onSurface, diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/nav/Destination.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/nav/Destination.kt index 4c13c09b..71b4e263 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/ui/nav/Destination.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/nav/Destination.kt @@ -68,6 +68,7 @@ sealed class Destination( val positionMs: Long, @Transient val item: BaseItem? = null, val startIndex: Int? = null, + val shuffle: Boolean = false, ) : Destination(true) { override fun toString(): String = "Playback(itemId=$itemId, positionMs=$positionMs)" diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/playback/PlaybackViewModel.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/playback/PlaybackViewModel.kt index 198c10a1..7c2bf923 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/ui/playback/PlaybackViewModel.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/playback/PlaybackViewModel.kt @@ -1,6 +1,7 @@ package com.github.damontecres.dolphin.ui.playback import android.content.Context +import android.widget.Toast import androidx.annotation.OptIn import androidx.core.net.toUri import androidx.lifecycle.MutableLiveData @@ -144,6 +145,7 @@ class PlaybackViewModel playlistCreator.createFromPlaylistId( queriedItem.id, destination.startIndex, + destination.shuffle, ) withContext(Dispatchers.Main) { this@PlaybackViewModel.playlist.value = playlist @@ -154,7 +156,10 @@ class PlaybackViewModel queriedItem } - play(base, destination.positionMs) + val played = play(base, destination.positionMs) + if (!played) { + playUpNextUp() + } if (!isPlaylist && queriedItem.type == BaseItemKind.EPISODE) { val playlist = @@ -174,7 +179,11 @@ class PlaybackViewModel withContext(Dispatchers.IO) { Timber.i("Playing ${base.id}") if (base.type !in supportItemKinds) { - showToast(context, "Unsupported type '${base.type}', skipping...") + showToast( + context, + "Unsupported type '${base.type}', skipping...", + Toast.LENGTH_SHORT, + ) return@withContext false } dto = base diff --git a/app/src/main/res/values/fa_strings.xml b/app/src/main/res/values/fa_strings.xml index 161d8ff6..8b6e56df 100644 --- a/app/src/main/res/values/fa_strings.xml +++ b/app/src/main/res/values/fa_strings.xml @@ -31,4 +31,5 @@ + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index afad58ef..c7b96a3c 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -75,5 +75,6 @@ Updates No update available Clear image cache + Shuffle From b40db0074f214e7725a693919ff183cc03a1986f Mon Sep 17 00:00:00 2001 From: Damontecres Date: Mon, 13 Oct 2025 21:58:55 -0400 Subject: [PATCH 13/22] Support collections --- .../damontecres/dolphin/ui/cards/GridCard.kt | 4 +-- .../ui/components/CollectionFolderGrid.kt | 35 +++++++++++++++---- .../damontecres/dolphin/ui/detail/CardGrid.kt | 4 +-- .../ui/detail/CollectionFolderGeneric.kt | 2 ++ .../ui/detail/CollectionFolderMovie.kt | 1 + .../dolphin/ui/detail/CollectionFolderTv.kt | 1 + .../dolphin/ui/detail/ItemViewModel.kt | 8 +++-- .../dolphin/ui/nav/DestinationContent.kt | 18 ++++++++++ .../damontecres/dolphin/ui/nav/NavDrawer.kt | 2 ++ .../damontecres/dolphin/util/Constants.kt | 1 + app/src/main/res/values/fa_strings.xml | 2 ++ 11 files changed, 64 insertions(+), 14 deletions(-) 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/components/CollectionFolderGrid.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/components/CollectionFolderGrid.kt index cd81afe8..a5c90ff6 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 @@ -72,6 +72,7 @@ class CollectionFolderViewModel itemId: UUID, potential: BaseItem?, sortAndDirection: SortAndDirection, + recursive: Boolean, ): Job = viewModelScope.launch( LoadingExceptionHandler( @@ -80,10 +81,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,6 +100,15 @@ 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() } @@ -104,7 +117,8 @@ class CollectionFolderViewModel parentId = item.id, enableImageTypes = listOf(ImageType.PRIMARY, ImageType.THUMB), includeItemTypes = includeItemTypes, - recursive = true, + recursive = recursive, + excludeItemIds = listOf(item.id), sortBy = listOf( sortAndDirection.sort, @@ -120,7 +134,13 @@ class CollectionFolderViewModel 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) { @@ -164,6 +184,7 @@ class CollectionFolderViewModel fun CollectionFolderGrid( preferences: UserPreferences, destination: Destination.MediaItem, + recursive: Boolean, onClickItem: (BaseItem) -> Unit, modifier: Modifier = Modifier, viewModel: CollectionFolderViewModel = hiltViewModel(), @@ -176,7 +197,7 @@ 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) @@ -198,7 +219,7 @@ fun CollectionFolderGrid( modifier = modifier, onClickItem = onClickItem, onSortChange = { - viewModel.loadResults(it) + viewModel.loadResults(it, recursive) }, showTitle = showTitle, positionCallback = positionCallback, @@ -263,7 +284,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/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/ItemViewModel.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/detail/ItemViewModel.kt index 49123c44..5b7001b6 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/ui/detail/ItemViewModel.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/detail/ItemViewModel.kt @@ -61,13 +61,14 @@ abstract class ItemViewModel( abstract class LoadingItemViewModel( api: ApiClient, ) : ItemViewModel(api) { - val loading = MutableLiveData(LoadingState.Loading) + val loading = MutableLiveData(LoadingState.Pending) open fun init( itemId: UUID, potential: BaseItem?, - ): Job? = - viewModelScope.launch( + ): Job? { + loading.value = LoadingState.Loading + return viewModelScope.launch( LoadingExceptionHandler( loading, "Error loading item $itemId", @@ -85,4 +86,5 @@ abstract class LoadingItemViewModel( loading.value = LoadingState.Error("Error loading item $itemId", e) } } + } } diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/nav/DestinationContent.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/nav/DestinationContent.kt index 05914f03..2f971c17 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/ui/nav/DestinationContent.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/nav/DestinationContent.kt @@ -106,6 +106,14 @@ fun DestinationContent( modifier, ) + BaseItemKind.BOX_SET -> + CollectionFolderGeneric( + preferences, + destination, + false, + modifier, + ) + BaseItemKind.COLLECTION_FOLDER -> { when (destination.item?.data?.collectionType) { CollectionType.TVSHOWS -> @@ -122,10 +130,19 @@ fun DestinationContent( modifier, ) + CollectionType.BOXSETS -> + CollectionFolderGeneric( + preferences, + destination, + true, + modifier, + ) + else -> CollectionFolderGeneric( preferences, destination, + false, modifier, ) } @@ -141,6 +158,7 @@ fun DestinationContent( CollectionFolderGeneric( preferences, destination, + true, modifier, ) diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/nav/NavDrawer.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/nav/NavDrawer.kt index ac53ff41..40a6a6a0 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/ui/nav/NavDrawer.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/nav/NavDrawer.kt @@ -318,6 +318,8 @@ fun NavigationDrawerScope.LibraryNavItem( CollectionType.HOMEVIDEOS -> R.string.fa_video CollectionType.LIVETV -> R.string.fa_tv CollectionType.MUSIC -> R.string.fa_music + CollectionType.BOXSETS -> R.string.fa_open_folder + CollectionType.PLAYLISTS -> R.string.fa_list_ul else -> R.string.fa_film } val isFocused = interactionSource.collectIsFocusedAsState().value diff --git a/app/src/main/java/com/github/damontecres/dolphin/util/Constants.kt b/app/src/main/java/com/github/damontecres/dolphin/util/Constants.kt index e65b2c7a..aa8a2ab9 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/util/Constants.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/util/Constants.kt @@ -28,4 +28,5 @@ val supportedCollectionTypes = CollectionType.TVSHOWS, CollectionType.HOMEVIDEOS, CollectionType.PLAYLISTS, + CollectionType.BOXSETS, ) diff --git a/app/src/main/res/values/fa_strings.xml b/app/src/main/res/values/fa_strings.xml index 8b6e56df..68a0990e 100644 --- a/app/src/main/res/values/fa_strings.xml +++ b/app/src/main/res/values/fa_strings.xml @@ -32,4 +32,6 @@ + + From fc56a2c63142fe99283fe3810bc57b7bdd2cb349 Mon Sep 17 00:00:00 2001 From: Damontecres Date: Mon, 13 Oct 2025 22:13:21 -0400 Subject: [PATCH 14/22] Simple combine resume & next up --- .../dolphin/preferences/AppPreference.kt | 13 ++++ .../preferences/AppPreferencesSerializer.kt | 1 + .../dolphin/ui/main/HomeViewModel.kt | 74 +++++++------------ app/src/main/proto/DolphinDataStore.proto | 1 + app/src/main/res/values/strings.xml | 1 + 5 files changed, 43 insertions(+), 47 deletions(-) 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 7ac87673..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, @@ -457,6 +469,7 @@ val basicPreferences = listOf( AppPreference.HomePageItems, AppPreference.RewatchNextUp, + AppPreference.CombineContinueNext, AppPreference.PlayThemeMusic, AppPreference.RememberSelectedTab, AppPreference.ThemeColors, 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/main/HomeViewModel.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/main/HomeViewModel.kt index 552ff0e6..21ac3325 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/ui/main/HomeViewModel.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/main/HomeViewModel.kt @@ -40,7 +40,8 @@ class HomeViewModel val homeRows = MutableLiveData>() fun init(preferences: UserPreferences) { - val limit = preferences.appPreferences.homePagePreferences.maxItemsPerRow + val prefs = preferences.appPreferences.homePagePreferences + val limit = prefs.maxItemsPerRow viewModelScope.launch( Dispatchers.IO + LoadingExceptionHandler( @@ -78,53 +79,32 @@ class HomeViewModel // } // TODO data is fetched all together which may be slow for large servers + val resume = getResume(user.id, limit) + val nextUp = getNextUp(user.id, limit, prefs.enableRewatchingNextUp) + val latest = getLatest(user, limit) + val homeRows = - homeSections - .mapNotNull { section -> - Timber.Forest.v("Loading section: %s", section.name) - when (section) { - HomeSection.LATEST_MEDIA -> { - getLatest(user, limit) - } - - HomeSection.RESUME -> { - val items = getResume(user.id, limit) - listOf( - HomeRow( - section = section, - items = items, - ), - ) - } - - HomeSection.NEXT_UP -> { - val nextUp = - getNextUp( - user.id, - limit, - preferences.appPreferences.homePagePreferences.enableRewatchingNextUp, - ) - listOf( - HomeRow( - section = section, - items = nextUp, - ), - ) - } - - // TODO - HomeSection.LIVE_TV -> null - HomeSection.ACTIVE_RECORDINGS -> null - - // TODO Not supported? - HomeSection.LIBRARY_TILES_SMALL -> null - HomeSection.LIBRARY_BUTTONS -> null - HomeSection.RESUME_AUDIO -> null - HomeSection.RESUME_BOOK -> null - HomeSection.NONE -> null - } - }.flatten() - .filter { it.items.isNotEmpty() } + if (prefs.combineContinueNext) { + listOf( + HomeRow( + section = HomeSection.NEXT_UP, + items = resume + nextUp, + ), + *latest.toTypedArray(), + ) + } else { + listOf( + HomeRow( + section = HomeSection.RESUME, + items = resume, + ), + HomeRow( + section = HomeSection.NEXT_UP, + items = nextUp, + ), + *latest.toTypedArray(), + ) + } withContext(Dispatchers.Main) { this@HomeViewModel.homeRows.value = homeRows loadingState.value = LoadingState.Success diff --git a/app/src/main/proto/DolphinDataStore.proto b/app/src/main/proto/DolphinDataStore.proto index 3b22208f..51f1a465 100644 --- a/app/src/main/proto/DolphinDataStore.proto +++ b/app/src/main/proto/DolphinDataStore.proto @@ -30,6 +30,7 @@ message PlaybackPreferences { message HomePagePreferences{ int32 max_items_per_row = 1; bool enable_rewatching_next_up = 2; + bool combine_continue_next = 3; } enum ThemeSongVolume { diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index c7b96a3c..1d88c220 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -76,5 +76,6 @@ No update available Clear image cache Shuffle + From 8dba5613766a6edfcbf325e1e2f98f71bacc589f Mon Sep 17 00:00:00 2001 From: Damontecres Date: Tue, 14 Oct 2025 14:15:07 -0400 Subject: [PATCH 15/22] Show queue during playback --- .../dolphin/data/model/Playlist.kt | 12 ++ .../damontecres/dolphin/ui/UiConstants.kt | 1 + .../dolphin/ui/cards/SeasonCard.kt | 4 +- .../dolphin/ui/playback/PlaybackOverlay.kt | 156 +++++++++++++++--- .../dolphin/ui/playback/PlaybackPage.kt | 4 + .../dolphin/ui/playback/PlaybackViewModel.kt | 15 +- 6 files changed, 167 insertions(+), 25 deletions(-) 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 index e97a7b88..d6f677a9 100644 --- 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 @@ -32,6 +32,18 @@ class Playlist( 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 } 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/SeasonCard.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/cards/SeasonCard.kt index 8a06a4c9..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 @@ -110,7 +110,7 @@ fun SeasonCard( .fillMaxWidth(), ) { Text( - text = dto?.name ?: "", + text = item?.title ?: "", maxLines = 1, textAlign = TextAlign.Center, modifier = @@ -120,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/playback/PlaybackOverlay.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/playback/PlaybackOverlay.kt index db8b9965..e689bd72 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/ui/playback/PlaybackOverlay.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/playback/PlaybackOverlay.kt @@ -6,6 +6,7 @@ import androidx.compose.animation.fadeOut import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideOutVertically import androidx.compose.foundation.background +import androidx.compose.foundation.focusable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.collectIsFocusedAsState import androidx.compose.foundation.layout.Arrangement @@ -32,7 +33,6 @@ import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.focusRestorer import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.input.key.KeyEventType -import androidx.compose.ui.input.key.onKeyEvent import androidx.compose.ui.input.key.onPreviewKeyEvent import androidx.compose.ui.input.key.type import androidx.compose.ui.layout.ContentScale @@ -43,9 +43,12 @@ import androidx.compose.ui.unit.sp import androidx.media3.common.Player 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.Chapter +import com.github.damontecres.dolphin.data.model.Playlist import com.github.damontecres.dolphin.ui.AppColors import com.github.damontecres.dolphin.ui.cards.ChapterCard +import com.github.damontecres.dolphin.ui.cards.SeasonCard import com.github.damontecres.dolphin.ui.ifElse import com.github.damontecres.dolphin.ui.isNotNullOrBlank import com.github.damontecres.dolphin.ui.letNotEmpty @@ -85,6 +88,8 @@ fun PlaybackOverlay( subtitle: String? = null, trickplayInfo: TrickplayInfo? = null, trickplayUrlFor: (Int) -> String? = { null }, + playlist: Playlist = Playlist(listOf(), 0), + onClickPlaylist: (BaseItem) -> Unit = {}, seekBarInteractionSource: MutableInteractionSource = remember { MutableInteractionSource() }, ) { val seekBarFocused by seekBarInteractionSource.collectIsFocusedAsState() @@ -119,10 +124,17 @@ fun PlaybackOverlay( enter = slideInVertically() + fadeIn(), exit = slideOutVertically() + fadeOut(), ) { + val nextState = + if (chapters.isNotEmpty()) { + OverlayViewState.CHAPTERS + } else if (playlist.hasNext()) { + OverlayViewState.QUEUE + } else { + null + } Controller( title = title, subtitleStreams = subtitleStreams, - chapters = chapters, playerControls = playerControls, controllerViewState = controllerViewState, showPlay = showPlay, @@ -145,18 +157,14 @@ fun PlaybackOverlay( audioStreams = audioStreams, subtitle = subtitle, seekBarInteractionSource = seekBarInteractionSource, + nextState = nextState, + onNextStateFocus = { + nextState?.let { state = it } + }, modifier = Modifier - .onKeyEvent { e -> - if (chapters.isNotEmpty() && - e.type == KeyEventType.KeyDown && isDown(e) && - !seekBarFocused - ) { - state = OverlayViewState.CHAPTERS - true - } - false - }.onGloballyPositioned { + // Don't use key events because this control has vertical items so up/down is tough to manage + .onGloballyPositioned { controllerHeight = with(density) { it.size.height.toDp() } }, ) @@ -179,8 +187,9 @@ fun PlaybackOverlay( if (e.type == KeyEventType.KeyUp && isUp(e)) { state = OverlayViewState.CONTROLLER true + } else { + false } - false }, ) { Text( @@ -223,6 +232,89 @@ fun PlaybackOverlay( ) } } + if (playlist.hasNext()) { + Text( + text = "Queue", + style = MaterialTheme.typography.titleLarge, + modifier = + Modifier + .padding(start = 16.dp, top = 16.dp) + .onFocusChanged { + if (it.isFocused) state = OverlayViewState.QUEUE + }.focusable(), + ) + } + } + } + } + AnimatedVisibility( + state == OverlayViewState.QUEUE, + enter = slideInVertically { it / 2 } + fadeIn(), + exit = slideOutVertically { it / 2 } + fadeOut(), + ) { + if (playlist.hasNext()) { + val items = remember { playlist.upcomingItems() } + val focusRequester = remember { FocusRequester() } + LaunchedEffect(Unit) { focusRequester.tryRequestFocus() } + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = + Modifier + .fillMaxWidth() + .padding(8.dp) + .onPreviewKeyEvent { e -> + if (e.type == KeyEventType.KeyUp && isUp(e)) { + if (chapters.isNotEmpty()) { + state = OverlayViewState.CHAPTERS + } else { + state = OverlayViewState.CONTROLLER + } + true + } else { + false + } + }, + ) { + Text( + text = "Queue", + style = MaterialTheme.typography.titleLarge, + ) + LazyRow( + contentPadding = PaddingValues(16.dp), + horizontalArrangement = Arrangement.spacedBy(16.dp), + modifier = + Modifier + .fillMaxWidth() + .focusRestorer(focusRequester) + .onFocusChanged { + if (it.hasFocus) { + controllerViewState.pulseControls() + } + }, + ) { + itemsIndexed(items) { index, item -> + val interactionSource = remember { MutableInteractionSource() } + val isFocused = interactionSource.collectIsFocusedAsState().value + LaunchedEffect(isFocused) { + if (isFocused) controllerViewState.pulseControls() + } + SeasonCard( + item = item, + onClick = { + onClickPlaylist.invoke(item) + controllerViewState.hideControls() + }, + onLongClick = {}, + imageHeight = 140.dp, + interactionSource = interactionSource, + modifier = + Modifier.ifElse( + index == 0, + Modifier.focusRequester(focusRequester), + ), + ) + } + } } } } @@ -293,6 +385,7 @@ fun PlaybackOverlay( enum class OverlayViewState { CONTROLLER, CHAPTERS, + QUEUE, } /** @@ -302,7 +395,6 @@ enum class OverlayViewState { fun Controller( title: String?, subtitleStreams: List, - chapters: List, playerControls: Player, controllerViewState: ControllerViewState, showPlay: Boolean, @@ -320,9 +412,11 @@ fun Controller( moreButtonOptions: MoreButtonOptions, currentPlayback: CurrentPlayback?, audioStreams: List, + nextState: OverlayViewState?, modifier: Modifier = Modifier, subtitle: String? = null, seekBarInteractionSource: MutableInteractionSource = remember { MutableInteractionSource() }, + onNextStateFocus: () -> Unit = {}, ) { Column( verticalArrangement = Arrangement.spacedBy(8.dp), @@ -373,14 +467,32 @@ fun Controller( seekForward = seekForward, skipBackOnResume = skipBackOnResume, ) - if (chapters.isNotEmpty()) { - Text( - text = "Chapters", - style = MaterialTheme.typography.titleLarge, - modifier = Modifier.padding(start = 16.dp, top = 16.dp), - ) - } else { - Spacer(Modifier.height(32.dp)) + when (nextState) { + OverlayViewState.CHAPTERS -> + Text( + text = "Chapters", + style = MaterialTheme.typography.titleLarge, + modifier = + Modifier + .padding(start = 16.dp, top = 16.dp) + .onFocusChanged { + if (it.isFocused) onNextStateFocus.invoke() + }.focusable(), + ) + + OverlayViewState.QUEUE -> + Text( + text = "Queue", + style = MaterialTheme.typography.titleLarge, + modifier = + Modifier + .padding(start = 16.dp, top = 16.dp) + .onFocusChanged { + if (it.isFocused) onNextStateFocus.invoke() + }.focusable(), + ) + + else -> Spacer(Modifier.height(32.dp)) } } } diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/playback/PlaybackPage.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/playback/PlaybackPage.kt index db20299b..3487d96a 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/ui/playback/PlaybackPage.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/playback/PlaybackPage.kt @@ -314,6 +314,10 @@ fun PlaybackPage( trickplayInfo = trickplay, trickplayUrlFor = viewModel::getTrickplayUrl, chapters = chapters, + playlist = playlist, + onClickPlaylist = { + viewModel.playItemInPlaylist(it) + }, ) } diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/playback/PlaybackViewModel.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/playback/PlaybackViewModel.kt index 7c2bf923..438060c4 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/ui/playback/PlaybackViewModel.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/playback/PlaybackViewModel.kt @@ -628,7 +628,20 @@ class PlaybackViewModel nextUp.value = null } - private fun toastUnsupported(item: BaseItemDto) { + fun playItemInPlaylist(item: BaseItem) { + playlist.value?.let { playlist -> + viewModelScope.launch(ExceptionHandler()) { + val toPlay = playlist.advanceTo(item.id) + if (toPlay != null) { + val played = play(toPlay.data, 0) + if (!played) { + playUpNextUp() + } + } else { + // TODO + } + } + } } } From d1e9561eaeb6ed2a4cb0121c8b581b25dd235413 Mon Sep 17 00:00:00 2001 From: Damontecres Date: Tue, 14 Oct 2025 14:19:59 -0400 Subject: [PATCH 16/22] Seek bar uses same skip intervals --- .../dolphin/ui/playback/PlaybackControls.kt | 6 ++++++ .../github/damontecres/dolphin/ui/playback/SeekBar.kt | 11 ++++++----- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/playback/PlaybackControls.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/playback/PlaybackControls.kt index 212ce009..ae4d2693 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/ui/playback/PlaybackControls.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/playback/PlaybackControls.kt @@ -156,6 +156,8 @@ fun PlaybackControls( interactionSource = seekBarInteractionSource, isEnabled = seekEnabled, intervals = seekBarIntervals, + seekBack = seekBack, + seekForward = seekForward, modifier = Modifier .padding(vertical = 0.dp) @@ -211,6 +213,8 @@ fun SeekBar( intervals: Int, controllerViewState: ControllerViewState, onSeekProgress: (Long) -> Unit, + seekBack: Duration, + seekForward: Duration, modifier: Modifier = Modifier, interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, ) { @@ -241,6 +245,8 @@ fun SeekBar( interactionSource = interactionSource, enabled = isEnabled, durationMs = player.contentDuration, + seekBack = seekBack, + seekForward = seekForward, ) Row( modifier = Modifier.fillMaxWidth(), diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/playback/SeekBar.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/playback/SeekBar.kt index 50f65b36..b0b83181 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/ui/playback/SeekBar.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/playback/SeekBar.kt @@ -48,7 +48,7 @@ import androidx.compose.ui.unit.dp import androidx.tv.material3.MaterialTheme import com.github.damontecres.dolphin.ui.handleDPadKeyEvents import kotlinx.coroutines.FlowPreview -import kotlin.time.Duration.Companion.seconds +import kotlin.time.Duration @Composable fun SteppedSeekBarImpl( @@ -105,6 +105,8 @@ fun IntervalSeekBarImpl( bufferedProgress: Float, onSeek: (Long) -> Unit, controllerViewState: ControllerViewState, + seekBack: Duration, + seekForward: Duration, modifier: Modifier = Modifier, interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, enabled: Boolean = true, @@ -120,21 +122,20 @@ fun IntervalSeekBarImpl( if (!isFocused) hasSeeked = false } - val offset = 30.seconds.inWholeMilliseconds - SeekBarDisplay( enabled = enabled, progress = (progressToUse.toDouble() / durationMs).toFloat(), bufferedProgress = bufferedProgress, onLeft = { controllerViewState.pulseControls() - seekPositionMs = (progressToUse - offset).coerceAtLeast(0L) + seekPositionMs = (progressToUse - seekBack.inWholeMilliseconds).coerceAtLeast(0L) hasSeeked = true onSeek(seekPositionMs) }, onRight = { controllerViewState.pulseControls() - seekPositionMs = (progressToUse + offset).coerceAtMost(durationMs) + seekPositionMs = + (progressToUse + seekForward.inWholeMilliseconds).coerceAtMost(durationMs) hasSeeked = true onSeek(seekPositionMs) }, From c6b7e0f4de09efc4a229e45460e4c5d4aa22b1af Mon Sep 17 00:00:00 2001 From: Damontecres Date: Tue, 14 Oct 2025 16:25:09 -0400 Subject: [PATCH 17/22] Remove skip button with back or timer & show it in controls --- .../dolphin/ui/playback/PlaybackControls.kt | 44 ++++++++++++++----- .../dolphin/ui/playback/PlaybackOverlay.kt | 5 +++ .../dolphin/ui/playback/PlaybackPage.kt | 17 ++++++- 3 files changed, 53 insertions(+), 13 deletions(-) diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/playback/PlaybackControls.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/playback/PlaybackControls.kt index ae4d2693..e7360ec2 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/ui/playback/PlaybackControls.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/playback/PlaybackControls.kt @@ -65,6 +65,8 @@ import com.github.damontecres.dolphin.util.ExceptionHandler import kotlinx.coroutines.delay import kotlinx.coroutines.isActive import kotlinx.coroutines.launch +import org.jellyfin.sdk.model.api.MediaSegmentDto +import org.jellyfin.sdk.model.extensions.ticks import timber.log.Timber import kotlin.time.Duration import kotlin.time.Duration.Companion.seconds @@ -120,6 +122,7 @@ fun PlaybackControls( seekBack: Duration, skipBackOnResume: Duration?, seekForward: Duration, + currentSegment: MediaSegmentDto?, modifier: Modifier = Modifier, initialFocusRequester: FocusRequester = remember { FocusRequester() }, seekBarInteractionSource: MutableInteractionSource = remember { MutableInteractionSource() }, @@ -189,18 +192,37 @@ fun PlaybackControls( skipBackOnResume = skipBackOnResume, modifier = Modifier.align(Alignment.Center), ) - RightPlaybackButtons( - subtitleStreams = subtitleStreams, - onControllerInteraction = onControllerInteraction, - onControllerInteractionForDialog = onControllerInteractionForDialog, - onPlaybackActionClick = onPlaybackActionClick, - subtitleIndex = subtitleIndex, - audioStreams = audioStreams, - audioIndex = audioIndex, - playbackSpeed = playbackSpeed, - scale = scale, + Row( modifier = Modifier.align(Alignment.CenterEnd), - ) + ) { + currentSegment?.let { segment -> + Button( + onClick = { + playerControls.seekTo(segment.endTicks.ticks.inWholeMilliseconds) + }, + modifier = + Modifier + .align(Alignment.CenterVertically) + .padding(end = 32.dp), + ) { + Text( + text = "Skip ${segment.type.serialName}", + ) + } + } + RightPlaybackButtons( + subtitleStreams = subtitleStreams, + onControllerInteraction = onControllerInteraction, + onControllerInteractionForDialog = onControllerInteractionForDialog, + onPlaybackActionClick = onPlaybackActionClick, + subtitleIndex = subtitleIndex, + audioStreams = audioStreams, + audioIndex = audioIndex, + playbackSpeed = playbackSpeed, + scale = scale, + modifier = Modifier, + ) + } } } } diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/playback/PlaybackOverlay.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/playback/PlaybackOverlay.kt index e689bd72..97715dfc 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/ui/playback/PlaybackOverlay.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/playback/PlaybackOverlay.kt @@ -53,6 +53,7 @@ import com.github.damontecres.dolphin.ui.ifElse import com.github.damontecres.dolphin.ui.isNotNullOrBlank import com.github.damontecres.dolphin.ui.letNotEmpty import com.github.damontecres.dolphin.ui.tryRequestFocus +import org.jellyfin.sdk.model.api.MediaSegmentDto import org.jellyfin.sdk.model.api.TrickplayInfo import kotlin.time.Duration @@ -84,6 +85,7 @@ fun PlaybackOverlay( moreButtonOptions: MoreButtonOptions, currentPlayback: CurrentPlayback?, audioStreams: List, + currentSegment: MediaSegmentDto?, modifier: Modifier = Modifier, subtitle: String? = null, trickplayInfo: TrickplayInfo? = null, @@ -161,6 +163,7 @@ fun PlaybackOverlay( onNextStateFocus = { nextState?.let { state = it } }, + currentSegment = currentSegment, modifier = Modifier // Don't use key events because this control has vertical items so up/down is tough to manage @@ -413,6 +416,7 @@ fun Controller( currentPlayback: CurrentPlayback?, audioStreams: List, nextState: OverlayViewState?, + currentSegment: MediaSegmentDto?, modifier: Modifier = Modifier, subtitle: String? = null, seekBarInteractionSource: MutableInteractionSource = remember { MutableInteractionSource() }, @@ -466,6 +470,7 @@ fun Controller( seekBack = seekBack, seekForward = seekForward, skipBackOnResume = skipBackOnResume, + currentSegment = currentSegment, ) when (nextState) { OverlayViewState.CHAPTERS -> diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/playback/PlaybackPage.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/playback/PlaybackPage.kt index 3487d96a..3cc45df2 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/ui/playback/PlaybackPage.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/playback/PlaybackPage.kt @@ -100,6 +100,7 @@ fun PlaybackPage( val chapters by viewModel.chapters.observeAsState(listOf()) val currentPlayback by viewModel.currentPlayback.observeAsState(null) val currentSegment by viewModel.currentSegment.observeAsState(null) + var segmentCancelled by remember(currentSegment?.id) { mutableStateOf(false) } var cues by remember { mutableStateOf>(listOf()) } var showDebugInfo by remember { mutableStateOf(prefs.showDebugInfo) } @@ -179,6 +180,13 @@ fun PlaybackPage( skipBackOnResume = preferences.appPreferences.playbackPreferences.skipBackOnResume, ) + val showSegment = + !segmentCancelled && currentSegment != null && + !controllerViewState.controlsVisible && skipIndicatorDuration == 0L + BackHandler(showSegment) { + segmentCancelled = true + } + Box( modifier .background(Color.Black), @@ -318,6 +326,7 @@ fun PlaybackPage( onClickPlaylist = { viewModel.playItemInPlaylist(it) }, + currentSegment = currentSegment, ) } @@ -345,7 +354,7 @@ fun PlaybackPage( // Ask to skip intros, etc button AnimatedVisibility( - currentSegment != null && !controllerViewState.controlsVisible && skipIndicatorDuration == 0L, + showSegment, modifier = Modifier .padding(40.dp) @@ -353,7 +362,11 @@ fun PlaybackPage( ) { currentSegment?.let { segment -> val focusRequester = remember { FocusRequester() } - LaunchedEffect(Unit) { focusRequester.tryRequestFocus() } + LaunchedEffect(Unit) { + focusRequester.tryRequestFocus() + delay(10.seconds) + segmentCancelled = false + } Button( onClick = { player.seekTo(segment.endTicks.ticks.inWholeMilliseconds) From 9d67b9a3ca50e974ed2d54ab9689a4b6430f7475 Mon Sep 17 00:00:00 2001 From: Damontecres Date: Tue, 14 Oct 2025 19:34:45 -0400 Subject: [PATCH 18/22] Remove unused code --- .../dolphin/data/model/DolphinModel.kt | 30 ---- .../damontecres/dolphin/data/model/Library.kt | 31 ---- .../damontecres/dolphin/data/model/Video.kt | 28 --- .../cards/{ItemCard.kt => ItemCardImage.kt} | 117 ------------ .../damontecres/dolphin/ui/cards/ItemRow.kt | 11 +- .../damontecres/dolphin/ui/cards/NullCard.kt | 39 ---- .../ui/components/CollectionFolderGrid.kt | 3 +- .../dolphin/ui/detail/EpisodeDetails.kt | 166 ------------------ .../dolphin/ui/detail/ItemViewModel.kt | 6 +- .../dolphin/ui/detail/PlaylistDetails.kt | 13 +- .../dolphin/ui/detail/SeasonDetails.kt | 113 ------------ .../dolphin/ui/detail/SeriesViewModel.kt | 3 +- .../dolphin/ui/detail/VideoDetails.kt | 3 +- .../dolphin/ui/detail/movie/MovieDetails.kt | 3 +- .../dolphin/ui/nav/DestinationContent.kt | 16 -- 15 files changed, 12 insertions(+), 570 deletions(-) delete mode 100644 app/src/main/java/com/github/damontecres/dolphin/data/model/DolphinModel.kt delete mode 100644 app/src/main/java/com/github/damontecres/dolphin/data/model/Library.kt delete mode 100644 app/src/main/java/com/github/damontecres/dolphin/data/model/Video.kt rename app/src/main/java/com/github/damontecres/dolphin/ui/cards/{ItemCard.kt => ItemCardImage.kt} (60%) delete mode 100644 app/src/main/java/com/github/damontecres/dolphin/ui/cards/NullCard.kt delete mode 100644 app/src/main/java/com/github/damontecres/dolphin/ui/detail/EpisodeDetails.kt delete mode 100644 app/src/main/java/com/github/damontecres/dolphin/ui/detail/SeasonDetails.kt 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 c26598de..00000000 --- a/app/src/main/java/com/github/damontecres/dolphin/data/model/DolphinModel.kt +++ /dev/null @@ -1,30 +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) - BaseItemKind.USER_VIEW -> 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/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/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/components/CollectionFolderGrid.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/components/CollectionFolderGrid.kt index a5c90ff6..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() 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